249 Commits
Author SHA1 Message Date
hermes 6114d8f457 Updated documentation 2026-10-28 09:28:06 +02:00
hermes b8c3201c58 Refactored quartz healthcheck on api 2026-10-27 23:59:58 +02:00
hermes a8daea830f Refactored quartz client registration 2026-10-27 23:48:36 +02:00
hermes 5be7c8c56a Removed defaults from feature flags 2026-10-27 23:17:18 +02:00
hermes d24b0dafb1 Added http accessor to scheduler 2026-10-27 23:04:50 +02:00
hermes cd47a2b93a Fixed scheduler and api configuration 2026-10-27 22:08:12 +02:00
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
hermes 48edfa1cbb Moved top bar to top 2026-10-25 08:26:37 +02:00
khwezi d9b65010cf Merge pull request 'Redesigned PartnerUi dashboard' (#172) from finishline into main
Reviewed-on: #172
2026-10-25 08:02:27 +02:00
hermes 3e1d8bc1cc Redesigned PartnerUi dashboard 2026-10-25 08:01:34 +02:00
khwezi bc441c1255 Merge pull request 'Fixed ConsoleUi salt handling' (#171) from finishline into main
Reviewed-on: #171
2026-10-24 18:10:42 +02:00
hermes 17a4149a73 Fixed ConsoleUi salt handling 2026-10-24 18:09:59 +02:00
khwezi 6e786ca796 Merge pull request 'Finishline' (#170) from finishline into main
Reviewed-on: #170
2026-10-24 17:37:15 +02:00
hermes 25e251aa79 Overhauled lockout process 2026-10-24 17:36:30 +02:00
hermes a731c979bb Overhauled lockout process 2026-10-24 17:29:48 +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
hermes 17a99ff084 Applied extensive leaked secret protection 2026-10-24 15:56:25 +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
hermes 29a11f0e0b Stopped leaking secrets 2026-10-24 15:43:10 +02:00
khwezi b8f5ebf69a Merge pull request 'Finishline' (#167) from finishline into main
Reviewed-on: #167
2026-10-24 15:23:51 +02:00
hermes 385c76510b Fixed partnerui message viewer 2026-10-24 15:23:12 +02:00
hermes 631a0b7a9a Fixed partner message reader 2026-10-24 15:18:34 +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
hermes 1233f15b91 Applied CORS fix 2026-10-24 14:58:55 +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
hermes c43e18f184 Fixed Console user lockout and unlock implementation 2026-10-24 14:38:55 +02:00
khwezi f8f826979d Merge pull request 'Finishline' (#164) from finishline into main
Reviewed-on: #164
2026-10-24 12:51:15 +02:00
hermes d912c5c6c3 Added partner to api cors list 2026-10-24 12:50:28 +02:00
hermes 20bdd8b209 Removed joborchestrator 2026-10-24 12:41:03 +02:00
khwezi c4a0469bae Merge pull request 'Finishline' (#163) from finishline into main
Reviewed-on: #163
2026-10-24 12:13:11 +02:00
hermes bf384d7e6a Reverted changes 2026-10-24 12:12:27 +02:00
hermes 957d2210a9 Refactored sync process 2026-10-24 12:11:48 +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
hermes 16fb6b9434 Simplified lockout modals 2026-10-24 11:48:19 +02:00
hermes 7220700bac Removed setUserLockout from PartnerUI 2026-10-24 11:39:47 +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
hermes 3316fc0275 Fixed sync handshake 2026-10-23 10:19:30 +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
hermes 8f58e15f0f Implemented partnerui 2026-10-23 00:58:47 +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
hermes 9ed210613e Applied mailbox constraints 2026-10-22 23:57:38 +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
hermes 5050c901bc Fixed notification composition 2026-10-22 23:36:03 +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
hermes 46c6ddcc80 Implemented notification strategy 2026-10-22 22:59:33 +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
hermes 7071c404f0 Implemented markdown based notification message body formatting 2026-10-22 18:19:08 +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
hermes cb89817d88 Implemented mailbox in ConsoleUI 2026-10-22 18:01:22 +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
hermes 6ff016e7ec Fixed beneficiary page type error 2026-10-22 17:14:35 +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
hermes d45a89ab67 Fixed beneficiary outcomes misconfiguration 2026-10-22 17:08:01 +02:00
khwezi d9b3e80f7c Merge pull request 'Finishline' (#152) from finishline into main
Reviewed-on: #152
2026-10-22 16:08:08 +02:00
hermes 3fda1ac5db Applied RBAC to compliance dashboard 2026-10-22 16:06:05 +02:00
hermes efc63ca398 Fixed error with compliance page 2026-10-22 15:58:22 +02:00
hermes 299d6ff4dc Improved compliance monitoring 2026-10-22 15:42:36 +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
hermes 9835121fd9 Enforced relationships between activities and indicators, fixed actuals recording 2026-10-22 13:47:54 +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
hermes 4dd0899209 Implemented audit logging 2026-10-22 12:31:37 +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
hermes 92891438f6 Enforced approval rights 2026-10-22 11:25:55 +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
hermes 0261a6093e Refactored project view into modal
Elevated toast message priority
2026-10-22 09:41:41 +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
hermes ccc49df720 Refactored projects page, fixed security user modal 2026-10-22 07:39:33 +02:00
khwezi e38edd24a0 Merge pull request 'Finishline' (#146) from finishline into main
Reviewed-on: #146
2026-10-22 06:24:18 +02:00
hermes c24e809b88 Applied UI rendering fixes 2026-10-22 06:23:22 +02:00
hermes 87f597c091 docs: formalize C# formatting, brace discipline, FluentResults vs ternary, grouping, and clean namespace rules in GEMINI.md 2026-10-22 05:01:10 +02:00
hermes 5a6c951641 docs: codify C# functional style, low branching, try-catch avoidance, and Clean/Vertical Slice/SOLID/DRY balance in GEMINI.md 2026-10-22 04:55:44 +02:00
khwezi 1ac0018926 Merge pull request 'Finishline' (#145) from finishline into main
Reviewed-on: #145
2026-10-21 11:07:27 +02:00
hermes acf8bc224b Fixed docker compose 2026-10-21 11:06:56 +02:00
hermes 558ca0dbb4 Resolved volnerability 2026-10-21 11:00:48 +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
hermes 270929948d Restructured .net solutions
Resolved volnerability
2026-10-21 10:41:23 +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
hermes 95a86e1a87 Fixed new user username formating 2026-10-21 10:16:54 +02:00
khwezi 627885e36b Merge pull request 'Finishline' (#142) from finishline into main
Reviewed-on: #142
2026-10-21 08:55:56 +02:00
hermes 5d7e2f74ce RBAC Improvements
Caching improvements
Maker-Checker Implementation
2026-10-21 07:27:47 +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
195 changed files with 42946 additions and 6030 deletions
+318
View File
@@ -0,0 +1,318 @@
# PostFundManagement (PFM) Platform — Architectural Specification
> **Document Version**: 2.0
> **Target Runtimes**: .NET 10.0 (C# 13) | React 19 / TypeScript 5.8 / Vite
> **Scope**: System Architecture, Security Topology, RBAC Enforcement, and Maker-Checker Pattern
---
## 1. System Topology & Architectural Layers
The PFM platform is constructed using **Clean Architecture** and **Domain-Driven Design (DDD)** principles to guarantee strict decoupling of business rules, persistence concerns, and user interfaces.
```
┌────────────────────────────────────────────────────────────────────────┐
│ PRESENTATION TIER │
│ PostFundManagement.ConsoleUI │ PostFundManagement.PartnerUI │
│ (Internal Staff & Governance) │ (External Recipient Portal) │
│ React 19 + TypeScript + Vite │ React 19 + WebGL (OGL) + Vite │
└───────────────────────────────────┬────────────────────────────────────┘
│ HTTPS / REST (v1/api/*) + OIDC PKCE
┌───────────────────────────────────▼────────────────────────────────────┐
│ API GATEWAY │
│ PostFundManagement.Api (ASP.NET Core Minimal APIs / Carter) │
│ Authentication: JWT Bearer + OpenTelemetry Observability │
│ Quartz Client: Scheduler-Only (ZeroSizeThreadPool / Enqueues Jobs) │
└───────────────────────────────────┬────────────────────────────────────┘
│
┌───────────────────────────────────▼────────────────────────────────────┐
│ APPLICATION LAYER │
│ PostFundManagement.Application │
│ CQRS (Mediator), Business Validators, Domain Services, Event Handlers │
└──────────────────┬─────────────────────────────────┬───────────────────┘
│ │
┌──────────────────▼──────────────────┐ ┌──────────▼───────────────────┐
│ DOMAIN LAYER │ │ INFRASTRUCTURE LAYER │
│ PostFundManagement.Domain │ │ PostFundManagement.Infrastr.│
│ Entities, Value Objects, Enums, │ │ PostgreSQL (EF Core 10), │
│ Domain Invariants, Audit Models │ │ Skoruba STS, S3 Garage, │
└─────────────────────────────────────┘ │ Redis, Quartz (pfm-scheduler)│
└──────────────┬───────────────┘
│
┌────────────────────────────────────────────────────────▼───────────────┐
│ BACKGROUND JOBS WORKER HOST │
│ PostFundManagement.Jobs (.NET 10 Background Host / Quartz Runner) │
│ Recurring Job Registration (JobsWorker) & Mediated Execution Engine │
│ Quartz Runner: Full Execution (DefaultThreadPool MaxConcurrency = 5) │
└────────────────────────────────────────────────────────────────────────┘
```
---
## 2. Security & Identity Architecture
Authentication and authorization are separated cleanly across system boundaries:
* **Security Token Service (STS)**: Duende IdentityServer / Skoruba Administration (`https://sts.security.khongisa.co.za`) issues cryptographically signed JWT access tokens using OpenID Connect (OIDC) with Proof Key for Code Exchange (PKCE).
* **Audience & Scopes**: Web clients request standard OIDC scopes (`openid`, `profile`, `roles`, `email`, `address`) along with the target API resource scope (`pfm-api-dev`).
* **Tenant Isolation**: Every authenticated principal carries a `tenant_id` claim. Multitenant query filters in EF Core enforce cross-tenant partition boundaries at the data access layer.
* **Partner Isolation**: Grantees and external recipients possess the `partner` role. The `ConsoleUI` enforces immediate lockout for `partner` roles, restricting external users exclusively to `PartnerUI`.
---
## 3. Role-Based Access Control (RBAC) Architecture
PFM implements a fine-grained, claim-based RBAC model where system permissions are decomposed into **15 operational domains** and **5 progressive action capabilities**.
### 3.1 Role Claims Inventory
| Claim Key | Operational Domain | Functional Scope |
| :--- | :--- | :--- |
| `dashboard` | Executive Dashboard | Macro portfolio KPIs, disbursed capital, and ISO 31000 risk heatmap |
| `governance` | Governance & Structure | Holding entities, funder portfolios, programmes, rules, and instruments |
| `projects_milestones` | Projects & Milestones | Work breakdown structure (WBS), contractual milestones, and activities |
| `grant_financials` | Grants & Financials | Statutory funding awards, budget lines, contracts, and disbursement tranches |
| `evidence_risks` | M&E, Evidence & Risks | Deliverable evidence verification queue, indicators, and risk registers |
| `beneficiaries` | Beneficiaries & Impact | Demographic targets, POPIA compliance items, and outcome deliverables |
| `change_management` | Change Management | Project issues, contractual change requests, and budget variations |
| `site_visits` | Site Visits | Physical field inspections, checklist observations, and inspector findings |
| `settings_invites` | Settings Invites | Onboarding invitations and candidate notifications dispatch |
| `settings_users` | Settings Users | User provisioning, role assignments, and account lockouts |
| `settings_auditlog` | Settings Audit Log | Immutable audit ledger and system telemetry records |
| `settings_notifications`| Settings Notifications | SMTP engines, notification channels, and dispatch telemetry |
| `tenant_self_service` | Tenant Self-Service | Tenant profile configuration and delegated sub-user management |
| `tenant_management` | Tenant Management | Multi-tenant directory, partition allocation, and solution ownership |
| `unrestricted` | System Administration | Root administrative bypass across all system partitions |
### 3.2 Claim Actions & Capability Hierarchy
Action capabilities within a claim domain follow a strict subset hierarchy:
$$\text{view} \subset \text{add} \subset \text{update} \quad \text{and} \quad \text{view} \subset \text{export}$$
* **`view`**: Read-only access to entity lists, detailed records, metrics, and progress scores.
* **`add`**: Can create new records, edit draft records, submit milestone evidence, and change workflow statuses *other than approval* (includes `view`).
* **`update`**: Authoritative governance capability. Can execute **approval** status transitions (`Approved`, `Active` contracts) on records (includes `view` and `add`).
* **`export`**: Can generate and download structured spreadsheets (`.xlsx`) and formal PDF reports (includes `view`).
* **`all`**: Comprehensive authority (`view`, `add`, `update`, `export`) over the claim domain.
### 3.3 Authoritative Role-to-Claims Matrix
| Role | Assigned Claim Domains | Standard Capabilities | Record Approval Authority (`update`) |
| :--- | :--- | :--- | :---: |
| **`Admin`** | `unrestricted` | `all` | **Yes (System-wide)** |
| **`Executive`** | `dashboard`, `governance`, `projects_milestones`, `grant_financials`, `evidence_risks`, `tenant_self_service` | `all` | **Yes** |
| **`Manager`** | `dashboard`, `projects_milestones`, `grant_financials`, `evidence_risks`, `beneficiaries`, `change_management`, `site_visits`, `settings_invites`, `settings_users`, `settings_auditlog`, `settings_notifications`, `tenant_self_service` | `all` | **Yes** |
| **`Supervisor`** | `dashboard`, `projects_milestones`, `grant_financials`, `evidence_risks`, `beneficiaries`, `change_management`, `site_visits`, `settings_invites`, `settings_users` | `view, add, update, export`<br>*(Dashboard: `view, export`)* | **Yes** |
| **`Contributor`** | `projects_milestones`, `grant_financials`, `evidence_risks`, `beneficiaries`, `change_management`, `site_visits`, `settings_invites` | **`view, add`** | ❌ **Strictly Prohibited** |
---
## 4. Maker-Checker Pattern Architecture
To eliminate fraud risk, self-dealing, and conflicts of interest, PFM enforces the **Maker-Checker Pattern** (Segregation of Duties / Dual Authorization).
### 4.1 Core Principle
> **The user who creates or drafts a record (the "Maker") is strictly prohibited from verifying or approving that record (the "Checker"), regardless of their administrative role or RBAC claim privileges.**
Even if an `Executive`, `Manager`, or `Supervisor` possesses the `update` claim action on a domain, they cannot approve a record if they are identified as the record's creator.
```mermaid
flowchart LR
A["Maker (User A)<br/>Creates Record"] --> B["Record Persisted<br/>with createdBy = User A"]
B --> C{"Checker Evaluation<br/>(Approver Login)"}
C -->|"Checker is User A"| D["Approval BLOCKED: Maker-Checker Violation"]
C -->|"Checker is User B with update claim"| E["Approval GRANTED: Status Approved"]
```
### 4.2 Record Attribution Model
Every operational domain entity immutably captures the creator identity:
* `createdBy`: Cryptographic user identifier (Guid / Subject ID) populated from the verified token claim `context.GetUserId()` upon creation.
* `approvedBy`: User identifier of the independent verifier populated upon successful transition to `Approved`.
### 4.3 Client-Side Enforcement Hooks & Helpers
In `PostFundManagement.ConsoleUI`, the Maker-Checker rule is evaluated dynamically via [`useRbac()`](file:///c:/Users/Admin/source/repos/PostFundManagement/PostFundManagement.ConsoleUI/src/auth/rbac.ts):
* **`isMaker(createdBy)`**: Compares `createdBy` against current user `user.id`, `sub`, and `user.email`.
* **`canApproveRecord(claim, createdBy)`**: Evaluates:
$$\text{canApproveRecord} = \text{canApprove}(\text{claim}) \land \neg\text{isMaker}(\text{createdBy})$$
* **`getApprovalRestrictionReason(claim, createdBy)`**: Surfaces explanatory feedback:
- `"Requires update/approval permission"` if the claim lacks `update`.
- `"Maker-Checker policy: Record creators cannot approve their own records"` if `isMaker` is true.
### 4.4 UI Guard Implementations
1. **Status Dropdowns**: The `Approved` option in status `<select>` controls is disabled with contextual labels:
```tsx
<option value={ApprovalStatus.Approved} disabled={!canApproveRecord('projects_milestones', record.createdBy)}>
Approved {isMaker(record.createdBy) ? '(Maker-Checker: Cannot approve own record)' : ''}
</option>
```
2. **Action Buttons**: Fast-track "Approve" buttons (e.g. Budget Approval, Disbursement Authorisation, Evidence Review) are hidden or accompanied by a Maker-Checker indicator badge when viewed by the creator.
3. **Export Controls**: `<ExportButton>` enforces domain claim checks explicitly (`claim="grant_financials"`), rejecting unauthenticated exports.
---
## 5. Caching Strategy, Mutation Versioning & Performance Optimization
To achieve sub-50ms page transitions while guaranteeing data freshness across mutations in a distributed architecture, PFM employs a multi-tiered caching topology.
```
┌────────────────────────────────────────────────────────────────────────┐
│ REACT QUERY CACHE (In-Memory Browser Tier) │
│ staleTime: 2m (dynamic) | 5m (taxonomy) | gcTime: 10m │
└───────────────────────────────────┬────────────────────────────────────┘
│ Cache Miss / Background Refetch
┌───────────────────────────────────▼────────────────────────────────────┐
│ AXIOS CLIENT INTERCEPTOR & MUTATION VERSIONING (_v) │
│ Reads/Browsing: attaches active `_v` epoch token │
│ Mutations (POST/PUT/PATCH/DELETE): auto-bumps epoch -> new `_v` token │
└───────────────────────────────────┬────────────────────────────────────┘
│ HTTP GET /v1/api/*?_v=<epoch>
┌───────────────────────────────────▼────────────────────────────────────┐
│ ASP.NET CORE OUTPUTCACHE (Redis / Memory Tier) │
│ Base Policy: 2-minute expiration partitioned by URI + QueryString │
│ Hits same `_v` -> 2ms response | New `_v` -> DB query & cache refresh │
└────────────────────────────────────────────────────────────────────────┘
```
### 5.1 The OutputCache Stale Data Challenge
ASP.NET Core Minimal APIs in `PostFundManagement.Api` attach `.CacheOutput()` to GET endpoints, using a 2-minute base sliding expiration backed by Redis / Memory cache.
Because OutputCache keys are generated from the HTTP Method, Host, Path, and **Query String**, a naive client refetch after a mutation (e.g. `queryClient.invalidateQueries({ queryKey: ['projects'] })`) calls `GET /v1/api/projects`. OutputCache intercepts this identical URI, returning the pre-mutation response cached earlier. The updated project status or approved milestone does not reflect in the UI until the 2-minute cache expires.
### 5.2 Client-Side Mutation Epoch Versioning
PFM solves this without modifying backend endpoints via **Client-Side Mutation Versioning**:
1. **Epoch Tracking (`activeMutationEpoch`)**: `httpClient.ts` tracks an active mutation timestamp initialized from `sessionStorage` or `Date.now()`.
2. **Automatic Mutation Interceptor**: Every successful mutating HTTP response (`POST`, `PUT`, `PATCH`, `DELETE` with HTTP 2xx) triggers `bumpMutationEpoch()`.
3. **Query Parameter Injection**: Outgoing `GET` requests to internal APIs automatically append `_v: getMutationEpoch()`.
4. **Instant Invalidation**: When a mutation succeeds, the epoch is bumped. Subsequent React Query refetches send the new `_v`, producing an immediate **cache miss** in ASP.NET Core OutputCache. The backend queries PostgreSQL, returns fresh data, and caches the new state under the new `_v` key.
5. **Cache Hit Snappiness**: Navigations between pages while no mutations have occurred reuse the stable `_v`, yielding sub-5ms responses from Redis/Memory OutputCache and 0ms from React Query in-memory cache.
6. **Explicit Freshness (`_t`)**: When `Cache-Control: no-cache` is specified or during explicit refresh operations, a unique timestamp `_t` is attached to force a guaranteed fresh fetch.
### 5.3 RBAC Startup Pipeline Acceleration
Following the introduction of fine-grained RBAC and Maker-Checker checks, startup performance was streamlined to eliminate sequential network waterfalls:
* **Cache-First Role Claims**: `fetchAndCacheSystemRoles` and `fetchAndCacheRoleClaims` check in-memory and `localStorage` caches *before* dispatching network calls.
* **Parallel User Resolution**: `getUserRolesFromApi` and `getUserClaimsFromApi` are resolved concurrently using `Promise.allSettled`.
* **Parallel Claim Resolution**: Claims across all assigned roles are retrieved concurrently rather than in serial loops.
* **Immediate Non-Blocking Session Render**: If valid session tokens and user profiles exist in `localStorage`, `AuthProvider` immediately unblocks UI rendering (`isLoading: false`) and runs tenant lockout checks, account synchronization, and claim revalidation asynchronously in the background.
* **In-Memory User Sync & Lockout Caching**: `isUserSyncedInBrowser` and `verifyTenantLockout` maintain in-memory references to eliminate synchronous `JSON.parse` operations from the request pipeline.
---
## 6. Distributed Background Job Processing Architecture (PostFundManagement.Jobs)
The PFM platform decouples synchronous API request handling from asynchronous, long-running, and scheduled workloads using a distributed **Quartz.NET (v4.6+)** clustering topology backed by PostgreSQL (`pfm-scheduler`).
### 6.1 Split-Node Topology: Scheduler Client vs. Execution Host
To maintain high API throughput, low memory footprint, and complete process isolation, PFM implements a split-node architecture between `PostFundManagement.Api` and `PostFundManagement.Jobs`:
```
┌──────────────────────────────────────────────┐
│ PostFundManagement.Api │
│ (API Gateway / Scheduler Client) │
│ │
│ * AddQuartzSchedulerClient() │
│ * ZeroSizeThreadPool (0 execution threads) │
│ * SchedulerStatus.Created (Standby mode) │
│ * Strictly schedules / enqueues events │
│ * Never acquires triggers or executes jobs │
└──────────────────────┬───────────────────────┘
│ Schedules IEvent jobs via IJobOrchestrator
▼
┌──────────────────────────────────────────────┐
│ PostgreSQL JobStore │
│ (pfm-scheduler) │
│ │
│ * Clustered tables (QRTZ_TRIGGERS, etc.) │
│ * System.Text.Json serialized IEvent payload│
│ * Distributed locking & check-in heartbeat │
└──────────────────────▲───────────────────────┘
│
│ Acquires triggers & executes (Polling loop)
┌──────────────────────┴───────────────────────┐
│ PostFundManagement.Jobs │
│ (Dedicated Worker Service) │
│ │
│ * AddQuartzScheduler() │
│ * DefaultThreadPool (MaxConcurrency = 5) │
│ * SchedulerStatus.Running (Started loop) │
│ * JobsWorker background service: │
│ - Registers recurring cron jobs on start │
│ - Enqueues one-time jobs when needed │
│ * Executes MediatorJob<TNotification> │
│ * Deserializes and publishes via IMediator │
└──────────────────────────────────────────────┘
```
| Characteristic | `PostFundManagement.Api` | `PostFundManagement.Jobs` |
| :--- | :--- | :--- |
| **Architectural Role** | API Gateway & Event Producer | Dedicated Execution Daemon & Consumer |
| **Registration Extension** | `AddQuartzSchedulerClient(...)` | `AddQuartzScheduler(...)` |
| **Thread Pool** | `ZeroSizeThreadPool` (0 threads) | `DefaultThreadPool` (`MaxConcurrency = 5`) |
| **Hosted Service** | None (`AddQuartzHostedService` omitted) | `AddQuartzHostedService(WaitForJobsToComplete = true)` |
| **Lifecycle State** | `SchedulerStatus.Created` (Standby) | `SchedulerStatus.Running` (Active execution loop) |
| **Readiness Check** | Verifies DB connectivity without requiring `Running` | Verifies active execution engine & DB connectivity |
| **Trigger Acquisition** | ❌ **Disabled** (Cannot acquire triggers or run jobs) | ✅ **Active** (Polls DB, acquires triggers, runs jobs) |
### 6.2 Event-Driven Mediated Orchestration (`IEvent` & `MediatorJob`)
Background tasks are modeled as strongly-typed domain events implementing `IEvent : INotification` (`PostFundManagement.Domain.Abstractions`).
```mermaid
sequenceDiagram
autonumber
participant API as PostFundManagement.Api
participant Orchestrator as IJobOrchestrator
participant DB as PostgreSQL pfm-scheduler
participant Host as PostFundManagement.Jobs
participant QuartzThread as Quartz Execution Engine
participant Mediator as IMediator MediatorJob
participant Handler as Domain Event Handlers
Note over API,Orchestrator: 1. Event Scheduling (API Node)
API->>Orchestrator: SendAsync(notification) / ScheduleAsync(notification, cron)
Orchestrator->>DB: Stores JobDetail and Trigger with serialized Payload JSON
Note over DB,Host: 2. Distributed Acquisition (Jobs Node)
QuartzThread->>DB: Polls and acquires due trigger with row-level locks
QuartzThread->>Host: Dispatches to local thread pool
Host->>Mediator: Instantiates MediatorJob[TNotification]
Mediator->>Mediator: Deserializes payload from MergedJobDataMap
Mediator->>Handler: Publish(notification, cancellationToken)
Handler-->>Host: Execution complete and updates business data in pfm DB
Host->>DB: Releases trigger and commits execution state
```
1. **Scheduling via `IJobOrchestrator`**:
- `SendAsync<TNotification>(notification)`: Schedules a durable, one-time execution in the `onetime-jobs` group with `StartNow()` and `DisallowConcurrentExecution()`.
- `ScheduleAsync<TNotification>(notification, cronExpression)`: Configures a durable, cron-driven recurring job in the `scheduled-jobs` group with automatic misfire handling (`CronTriggerMisfireInstruction.IgnoreMisfires`).
- `InterruptAsync(eventName, correlationId)`: Allows runtime cancellation of active job executions across the cluster.
2. **Transparent Deserialization & Dispatch (`MediatorJob<TNotification>`)**:
`MediatorJob<TNotification>` resolves `IMediator` from DI, deserializes the JSON payload, initiates OpenTelemetry distributed tracing (`MediatorTelemetry.Source`), and publishes the event to all registered in-process handlers.
### 6.3 Recurring vs. One-Time Job Patterns
* **Recurring Jobs**: Scheduled by `JobsWorker` (`BackgroundService` in `PostFundManagement.Jobs`) during application startup. These jobs govern periodic health sweeps, SLA tracking, notification batching, and risk evaluations on predictable cron intervals.
* **One-Time Jobs**: Primarily scheduled by `PostFundManagement.Api` as immediate or deferred side-effects of HTTP requests and lifecycle mutations (e.g. user invites, award registration triggers). Furthermore, a running recurring job in `PostFundManagement.Jobs` can dispatch a one-time job (`jobOrchestrator.SendAsync`) when handling fan-out tasks or invoking decoupled activities that cannot be triggered directly by the originating job.
### 6.4 Core Background Processing Capabilities
The background job runner powers four essential operational workloads:
1. **Automated Project Monitoring & Reminders**:
- Sweeps active projects, contractual deliverables, and milestone deadlines.
- Computes planned vs. actual progress variance and escalates overdue milestones (`FR-M04-017`, `FR-M07-009`).
- Dispatches automated reminders to Grantees/Partners prior to milestone due dates and submission cut-offs.
2. **Risk Monitoring & Automated Notifications**:
- Evaluates project risk registers, Key Risk Indicators (KRIs), and issue escalation thresholds (`FR-M09-001` - `011`).
- Detects risk score creep (Likelihood $\times$ Impact) and triggers automated alerts when risk tolerance thresholds are breached.
- Monitors overdue mitigation action plans and escalates unresolved high-severity risks to Programme Managers.
3. **Email Notification Processing**:
- Continuously consumes and batches pending notification records (`Platform.Email` / `NotificationPlatform.Email`).
- Dispatches transactional emails via MailKit / MimeKit SMTP with exponential backoff and retry policies.
- Updates delivery status (`Sent`, `Failed`, retry count) and logs dispatch telemetry for audit compliance (`FR-M25-001`).
4. **Platform Extensibility**:
- New background jobs and recurring routines can be introduced by creating an `IEvent` model in `PostFundManagement.Application` and scheduling it via `JobsWorker` or `IJobOrchestrator`, with zero impact on the API surface.
+102
View File
@@ -0,0 +1,102 @@
<Project>
<PropertyGroup>
<TargetFramework>net10.0</TargetFramework>
<ImplicitUsings>enable</ImplicitUsings>
<Nullable>enable</Nullable>
<LangVersion>latest</LangVersion>
<NoWarn>$(NoWarn);1701;1702;MA0048;MA0051;AV0029;AV0030</NoWarn>
</PropertyGroup>
<!-- Assembly signing and licensing for non-test projects -->
<PropertyGroup Condition="!$(MSBuildProjectName.EndsWith('Tests'))">
<SignAssembly>true</SignAssembly>
<AssemblyOriginatorKeyFile>$(MSBuildThisFileDirectory)PostFundManagement.snk</AssemblyOriginatorKeyFile>
<PackageLicenseFile>$(MSBuildThisFileDirectory)LICENSE</PackageLicenseFile>
</PropertyGroup>
<!-- Centralized Common Usings across solution -->
<ItemGroup>
<!-- System & BCL -->
<Using Include="System.Collections.ObjectModel" />
<Using Include="System.ComponentModel.DataAnnotations" />
<Using Include="System.Globalization" />
<Using Include="System.Net" />
<Using Include="System.Reflection" />
<Using Include="System.Security.Cryptography" />
<Using Include="System.Security.Cryptography.X509Certificates" />
<Using Include="System.Text" />
<Using Include="System.Text.Json" />
<Using Include="System.Text.Json.Serialization" />
<Using Include="System.Text.RegularExpressions" />
<Using Include="System.Threading.Channels" />
<Using Include="System.Web" />
<!-- Microsoft Extensions & Web Core -->
<Using Include="Microsoft.AspNetCore.Builder" />
<Using Include="Microsoft.AspNetCore.Components.Server.ProtectedBrowserStorage" />
<Using Include="Microsoft.AspNetCore.DataProtection" />
<Using Include="Microsoft.AspNetCore.Http" />
<Using Include="Microsoft.AspNetCore.Routing" />
<Using Include="Microsoft.Extensions.Configuration" />
<Using Include="Microsoft.Extensions.DependencyInjection" />
<Using Include="Microsoft.Extensions.DependencyInjection.Extensions" />
<Using Include="Microsoft.Extensions.Hosting" />
<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" />
<!-- CQRS, Utilities & Persistence -->
<Using Include="FluentResults" />
<Using Include="HashidsNet" />
<Using Include="Humanizer" />
<Using Include="Mediator" />
<Using Include="Microsoft.EntityFrameworkCore" />
<Using Include="Microsoft.EntityFrameworkCore.Design" />
<Using Include="Microsoft.EntityFrameworkCore.Metadata.Builders" />
<Using Include="Quartz" />
<Using Include="Refit" />
<!-- API & Documentation -->
<Using Include="Asp.Versioning" />
<Using Include="Microsoft.AspNetCore.OpenApi" />
<Using Include="Microsoft.OpenApi" />
<Using Include="Scalar.AspNetCore" />
<!-- Authentication -->
<Using Include="IdentityModel.AspNetCore.OAuth2Introspection" />
<Using Include="Microsoft.AspNetCore.Authentication" />
<Using Include="Microsoft.AspNetCore.Authentication.Cookies" />
<Using Include="Microsoft.AspNetCore.Authentication.JwtBearer" />
<Using Include="Microsoft.AspNetCore.Authentication.OpenIdConnect" />
<!-- Diagnostics & Health -->
<Using Include="Microsoft.AspNetCore.Diagnostics.HealthChecks" />
<Using Include="Microsoft.Extensions.Diagnostics.HealthChecks" />
<!-- Messaging & Cloud Storage -->
<Using Include="Amazon.Runtime" />
<Using Include="Amazon.S3" />
<Using Include="Amazon.S3.Model" />
<Using Include="MailKit.Net.Smtp" />
<Using Include="MimeKit" />
</ItemGroup>
<!-- Domain-specific Usings (Domain and downstream projects excluding Infrastructure where Claim/Activity/TokenRequest collide) -->
<ItemGroup Condition="'$(MSBuildProjectName)' == 'PostFundManagement.Domain'">
<Using Include="System.Security.Claims" />
<Using Include="AccessTokenClient" />
</ItemGroup>
<!-- Non-Domain / Non-Infrastructure Usings (Application, Api, Tests) -->
<ItemGroup Condition="'$(MSBuildProjectName)' != 'PostFundManagement.Domain' and '$(MSBuildProjectName)' != 'PostFundManagement.Infrastructure'">
<Using Include="System.Diagnostics" />
<Using Include="System.Security.Claims" />
<Using Include="AccessTokenClient" />
<Using Static="true" Include="PostFundManagement.Application.Extensions.General" />
</ItemGroup>
</Project>
+118
View File
@@ -0,0 +1,118 @@
<Project>
<PropertyGroup>
<ManagePackageVersionsCentrally>true</ManagePackageVersionsCentrally>
<CentralPackageTransitivePinningEnabled>true</CentralPackageTransitivePinningEnabled>
</PropertyGroup>
<ItemGroup>
<!-- Core & Utilities -->
<PackageVersion Include="Castle.Core.AsyncInterceptor" Version="2.1.0" />
<PackageVersion Include="Hashids.net" Version="1.7.0" />
<PackageVersion Include="Humanizer" Version="3.0.10" />
<PackageVersion Include="Scrutor" Version="7.0.0" />
<!-- Microsoft Extensions & Configuration -->
<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.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="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.14" />
<PackageVersion Include="System.Security.Cryptography.Xml" Version="10.0.14" />
<!-- Resilience -->
<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.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.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.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" />
<PackageVersion Include="AspNetCore.HealthChecks.UI" Version="9.0.0" />
<PackageVersion Include="AspNetCore.HealthChecks.UI.Client" Version="9.0.0" />
<PackageVersion Include="AspNetCore.HealthChecks.UI.Core" Version="9.0.0" />
<PackageVersion Include="AspNetCore.HealthChecks.UI.Data" Version="9.0.0" />
<PackageVersion Include="AspNetCore.HealthChecks.UI.InMemory.Storage" Version="9.0.0" />
<!-- Observability & OpenTelemetry -->
<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.2" />
<PackageVersion Include="MimeKit" Version="4.18.2" />
<!-- CQRS & Mediator -->
<PackageVersion Include="FluentResults" Version="4.0.0" />
<PackageVersion Include="Mediator.Abstractions" Version="3.0.2" />
<PackageVersion Include="Mediator.SourceGenerator" Version="3.0.2" />
<!-- Cloud Storage -->
<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.317" />
<!-- Testing -->
<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="4.0.1" />
</ItemGroup>
</Project>
+211 -58
View File
@@ -71,6 +71,7 @@ PostFundManagement/
├── PostFundManagement.Infrastructure/ # EF Core 10, PostgreSQL (Npgsql), Migrations, Data Protection ├── PostFundManagement.Infrastructure/ # EF Core 10, PostgreSQL (Npgsql), Migrations, Data Protection
├── PostFundManagement.Application/ # CQRS Handlers, Mediator, DTOs, Validations, S3, Email, Quartz ├── PostFundManagement.Application/ # CQRS Handlers, Mediator, DTOs, Validations, S3, Email, Quartz
├── PostFundManagement.Api/ # Minimal APIs / Carter endpoints, Auth, Swagger/Scalar, Versioning ├── PostFundManagement.Api/ # Minimal APIs / Carter endpoints, Auth, Swagger/Scalar, Versioning
├── PostFundManagement.Jobs/ # Dedicated Quartz.NET Background Worker Host (Recurring & One-Time Execution)
├── PostFundManagement.Tests/ # Unit & Integration test suite (xUnit, Coverlet) ├── PostFundManagement.Tests/ # Unit & Integration test suite (xUnit, Coverlet)
├── PostFundManagement.ConsoleUI/ # Internal Staff & Admin Web Portal (React 19 + Vite 6 + Tailwind 4) ├── PostFundManagement.ConsoleUI/ # Internal Staff & Admin Web Portal (React 19 + Vite 6 + Tailwind 4)
├── PostFundManagement.PartnerUI/ # External Grantee / Partner Portal (React 19 + Vite 8 + Tailwind 4) ├── PostFundManagement.PartnerUI/ # External Grantee / Partner Portal (React 19 + Vite 8 + Tailwind 4)
@@ -85,6 +86,7 @@ PostFundManagement/
| **`PostFundManagement.Infrastructure`** | `net10.0` | `Microsoft.EntityFrameworkCore` 10.0.12, `Npgsql.EntityFrameworkCore.PostgreSQL` 10.0.3, `Duende.AccessTokenManagement` 4.2.0, EF Core Tools & Design | Houses `PostFundManagementDbContext`, EF Core fluent configurations, entity-to-table mappings, PostgreSQL schema definitions, multitenancy filters, and migrations. | | **`PostFundManagement.Infrastructure`** | `net10.0` | `Microsoft.EntityFrameworkCore` 10.0.12, `Npgsql.EntityFrameworkCore.PostgreSQL` 10.0.3, `Duende.AccessTokenManagement` 4.2.0, EF Core Tools & Design | Houses `PostFundManagementDbContext`, EF Core fluent configurations, entity-to-table mappings, PostgreSQL schema definitions, multitenancy filters, and migrations. |
| **`PostFundManagement.Application`** | `net10.0` | `Mediator.SourceGenerator` / `Mediator.Abstractions` 3.0.2, `FluentResults` 4.0.0, `Microsoft.Extensions.Caching.Hybrid` 10.10.0, `Microsoft.Extensions.Caching.StackExchangeRedis` 10.0.12, `Refit` 15.2.0, `Duende.AccessTokenManagement.OpenIdConnect` 4.2.0, `Quartz` 4.1.0, `AWSSDK.S3` | Implements CQRS command/query handlers, domain validators, S3 object storage clients (Garage S3), background jobs (Quartz), notification dispatchers, and business logic. | | **`PostFundManagement.Application`** | `net10.0` | `Mediator.SourceGenerator` / `Mediator.Abstractions` 3.0.2, `FluentResults` 4.0.0, `Microsoft.Extensions.Caching.Hybrid` 10.10.0, `Microsoft.Extensions.Caching.StackExchangeRedis` 10.0.12, `Refit` 15.2.0, `Duende.AccessTokenManagement.OpenIdConnect` 4.2.0, `Quartz` 4.1.0, `AWSSDK.S3` | Implements CQRS command/query handlers, domain validators, S3 object storage clients (Garage S3), background jobs (Quartz), notification dispatchers, and business logic. |
| **`PostFundManagement.Api`** | `net10.0` | `Microsoft.AspNetCore.OpenApi` 10.0.12, `Scalar.AspNetCore` 2.17.4, `Asp.Versioning.Http` 10.2.3, `IdentityModel.AspNetCore` 4.3.0, `Microsoft.AspNetCore.Authentication.JwtBearer` 10.0.12, `AspNetCore.HealthChecks.NpgSql` 9.0.0, `AspNetCore.HealthChecks.UI` | Minimal API endpoint definitions divided by business slices (`Grants`, `Projects`, `Execution`, `Governance`, `Evidence`, `Communications`, `Users`). Handles routing, auth policies, error problem details, and OpenAPI metadata. | | **`PostFundManagement.Api`** | `net10.0` | `Microsoft.AspNetCore.OpenApi` 10.0.12, `Scalar.AspNetCore` 2.17.4, `Asp.Versioning.Http` 10.2.3, `IdentityModel.AspNetCore` 4.3.0, `Microsoft.AspNetCore.Authentication.JwtBearer` 10.0.12, `AspNetCore.HealthChecks.NpgSql` 9.0.0, `AspNetCore.HealthChecks.UI` | Minimal API endpoint definitions divided by business slices (`Grants`, `Projects`, `Execution`, `Governance`, `Evidence`, `Communications`, `Users`). Handles routing, auth policies, error problem details, and OpenAPI metadata. |
| **`PostFundManagement.Jobs`** | `net10.0` | `Quartz` 4.6.2, `Quartz.Extensions.DependencyInjection` 4.6.2, `Quartz.Serialization.SystemTextJson` 4.6.2, `Mediator.Abstractions` 3.0.2 | Dedicated background execution host. Executes recurring Quartz jobs (registered on startup by `JobsWorker`), processes asynchronous domain events (`IEvent`), and executes jobs concurrently (`MaxConcurrency = 5`). |
| **`PostFundManagement.Tests`** | `net10.0` | `xunit` 2.9.3, `xunit.runner.visualstudio` 3.1.4, `Microsoft.NET.Test.Sdk` 17.14.1, `coverlet.collector` 6.0.4 | Unit and integration test coverage for domain rules, CQRS handlers, financial calculation models, and gate condition validations. | | **`PostFundManagement.Tests`** | `net10.0` | `xunit` 2.9.3, `xunit.runner.visualstudio` 3.1.4, `Microsoft.NET.Test.Sdk` 17.14.1, `coverlet.collector` 6.0.4 | Unit and integration test coverage for domain rules, CQRS handlers, financial calculation models, and gate condition validations. |
### 2.2 Frontend Projects (React 19 / TypeScript) ### 2.2 Frontend Projects (React 19 / TypeScript)
@@ -116,10 +118,26 @@ PostFundManagement/
### 2.3 Infrastructure, Storage & Security Topology ### 2.3 Infrastructure, Storage & Security Topology
* **Database**: PostgreSQL 14+ (Separate connection strings for primary transactional store `pfm` and scheduler store `pfm-scheduler`). * **Database**: PostgreSQL 14+ (Separate connection strings for primary transactional store `pfm` and scheduler store `pfm-scheduler`).
* **Object / Evidence Store**: S3-compatible storage (e.g., Garage S3 / AWS S3) with pre-signed upload URLs and CDN acceleration. * **Object / Evidence Store**: S3-compatible storage (e.g., Garage S3 / AWS S3) with pre-signed upload URLs and CDN acceleration.
* **Cache**: Redis (`StackExchange.Redis` + ASP.NET HybridCache) for dashboard KPIs and taxonomy lookup caching. * **Cache**: Redis (`StackExchange.Redis` + ASP.NET Core `OutputCaching` 2-minute base policy on Minimal API GET endpoints + HybridCache). Paired with TanStack React Query in `ConsoleUI` and Axios mutation versioning (`_v` parameter) to guarantee instant reads and zero stale data after mutations.
* **Authentication / STS**: Duende IdentityServer / Skoruba Administration (`https://sts.security.khongisa.co.za`). * **Authentication / STS**: Duende IdentityServer / Skoruba Administration (`https://sts.security.khongisa.co.za`).
* **Observability**: OpenTelemetry exporters streaming traces, metrics, and logs to Aspire Dashboard / OTLP collectors. * **Observability**: OpenTelemetry exporters streaming traces, metrics, and logs to Aspire Dashboard / OTLP collectors.
### 2.4 Distributed Background Processing Architecture (Quartz.NET & PostFundManagement.Jobs)
* **Dedicated Execution Daemon**: `PostFundManagement.Jobs` is a standalone background host responsible for recurring job scheduling, lifecycle sweeps, and asynchronous event execution.
* **API Isolation (Scheduler Client)**: `PostFundManagement.Api` is strictly configured as a scheduler client via `AddQuartzSchedulerClient(...)` using `ZeroSizeThreadPool` (0 worker threads) and running in standby (`SchedulerStatus.Created`). The API enqueues jobs and triggers into the clustered PostgreSQL database (`pfm-scheduler`) but never acquires triggers or executes jobs locally.
* **Worker Execution Engine**: `PostFundManagement.Jobs` configures full Quartz execution via `AddQuartzScheduler(...)` using `DefaultThreadPool` (`MaxConcurrency = 5`) and `AddQuartzHostedService(options => options.WaitForJobsToComplete = true)`.
* **Event-Driven Orchestration (`IEvent` & `MediatorJob`)**:
- The API acts as the primary source of one-time events triggered by user actions and lifecycle transitions (implementing `IEvent : INotification`).
- Events are enqueued via `IJobOrchestrator.SendAsync` or `ScheduleAsync` and persisted in `pfm-scheduler` as `MediatorJob<TNotification>` with serialized JSON payloads.
- When acquired by `PostFundManagement.Jobs`, `MediatorJob` deserializes the event payload, propagates OpenTelemetry distributed traces, and publishes the event via `IMediator.Publish(...)`.
* **Startup Recurring Registration**: On daemon startup, `JobsWorker : BackgroundService` initializes and registers recurring cron jobs across tenant portfolios.
* **Cascading One-Time Execution**: If a recurring job identifies complex follow-up items that cannot be processed in the recurring execution window, it dispatches decoupled one-time jobs (`jobOrchestrator.SendAsync`) across worker threads.
* **Core Job Workloads**:
1. *Automated Project Monitoring & Reminders*: Sweeps milestones, checks contractual SLA deadlines, sets overdue flags (`FR-M07-009`), and sends reminder notices to Grantees/Partners.
2. *Risk Monitoring & Automated Notifications*: Evaluates risk score creep ($\text{Likelihood} \times \text{Impact}$), flags tolerance breaches, and sends escalation alerts for overdue mitigation actions.
3. *Email Notification Processing*: Batches and dispatches pending notification entries (`Platform.Email` / `NotificationPlatform.Email`) via MailKit/MimeKit SMTP and records delivery telemetry (`FR-M25-001`).
4. *Future Jobs*: Seamlessly supports future scheduled workloads (e.g. drawdown reconciliations, compliance expiries, portfolio aggregations) simply by adding `IEvent` handlers.
--- ---
## 3. Master Business Rules (BR-001 to BR-010) ## 3. Master Business Rules (BR-001 to BR-010)
@@ -554,6 +572,19 @@ The following baseline mappings define the claims assigned to system roles:
* Endpoints inspect JWT claims (`role_claim:<claim_name>` or array claims) to enforce authorization before executing command/query handlers. * Endpoints inspect JWT claims (`role_claim:<claim_name>` or array claims) to enforce authorization before executing command/query handlers.
* Violations immediately return `403 Forbidden` and log an audit security event (`NFR-004`, `AT-13`). * Violations immediately return `403 Forbidden` and log an audit security event (`NFR-004`, `AT-13`).
### 10.5 Maker-Checker Segregation of Duties Pattern
In addition to RBAC role and claim constraints, the platform enforces the **Maker-Checker Pattern** across all operational lifecycle modules:
* **Separation of Duty Rule**: **The user who creates or drafts a record (the "Maker") is strictly barred from verifying or approving that record (the "Checker"), regardless of their role or administrative privileges.**
* **Attribution**: Every domain entity immutably captures the creator's user identifier in `createdBy` (Guid/Subject ID resolved from `context.GetUserId()`).
* **Evaluation Formula**:
$$\text{Can Approve} = \text{Has 'update' claim permission} \land (\text{Authenticated User ID} \neq \text{Record createdBy})$$
* **Client UI Enforcement**:
* Components use `const { canApproveRecord, isMaker, getApprovalRestrictionReason } = useRbac()`.
* Status transition dropdowns disable the `Approved` / `Active` option when `!canApproveRecord(claim, record.createdBy)` with contextual indicator `(Maker-Checker: Cannot approve own record)`.
* Fast-track approval buttons (budget lines, disbursement tranches, evidence submissions, change requests) are hidden or marked with a Maker-Checker warning badge when inspected by the creator.
--- ---
## 11. Functional API Surface & Endpoint Catalog ## 11. Functional API Surface & Endpoint Catalog
@@ -835,7 +866,7 @@ The platform uses **Hashids** to obfuscate internal sequential database primary
* **Implementation**: Managed by `HashService` (`PostFundManagement.Application/Services/Shared/HashService.cs`) using the `Hashids.net` (v1.7.0) library. * **Implementation**: Managed by `HashService` (`PostFundManagement.Application/Services/Shared/HashService.cs`) using the `Hashids.net` (v1.7.0) library.
* **Shared Configuration (`HasherSettings`)**: * **Shared Configuration (`HasherSettings`)**:
* **Salt**: Sourced from configuration (`HasherSettings:Salt` or `HASHER_SALT`), defaulting to: * **Salt**: Sourced from configuration (`HasherSettings:Salt` or `HASHER_SALT`), defaulting to:
`"TGlnaHQgQ2hhcm1zLCBNaWRyYW5kQm9va3MgbWFrZSBsb3RzIG9mIG1vbmV5IGFuZCBhcmUgYSBzdWNjZXNzZnVsIHZpcmFsIHN0b3J5IGluIFNvdXRoIEFmcmljYQ=="` `"salt-random-string"`
* **Min Hash Length**: `HasherSettings:MinHashLength` or `HASHER_MIN_HASH_LENGTH` (default: `11`). * **Min Hash Length**: `HasherSettings:MinHashLength` or `HASHER_MIN_HASH_LENGTH` (default: `11`).
* **Core Service Methods**: * **Core Service Methods**:
* `HashEncodeLongId(long id)` / `HashEncodeIntId(int id)`: Encodes integers into obfuscated HashID strings (e.g. `1` $\to$ `kY5aB9m...`). * `HashEncodeLongId(long id)` / `HashEncodeIntId(int id)`: Encodes integers into obfuscated HashID strings (e.g. `1` $\to$ `kY5aB9m...`).
@@ -942,64 +973,186 @@ PostFundManagement.Infrastructure PostFundManagement.Domain
└── Migrations/ └── Abstractions/ # IEntity, IAggregateRoot, IService └── Migrations/ └── Abstractions/ # IEntity, IAggregateRoot, IService
``` ```
### 16.2 C# 13 & .NET 10 Coding Conventions ### 16.2 Architectural Balance: Clean Architecture, Vertical Slices, SOLID & DRY
1. **Primary Constructors & Modern Types**: The PFM backend strikes a deliberate, pragmatic balance between **Clean Architecture**, **Vertical Slice Architecture**, **SOLID principles**, and the **DRY (Don't Repeat Yourself)** principle:
* Services and endpoints use primary constructors:
```csharp 1. **Clean Architecture with Vertical Slices**:
public sealed class ProjectService( * **Domain Layer**: Completely isolated aggregate roots, entities, taxonomies (`Enums`), domain errors (`IError`), and abstractions. It has no dependencies on database technologies or presentation frameworks.
IDbContextFactory<ApplicationDbContext> contextFactory, * **Infrastructure Layer**: Encapsulates EF Core configurations, PostgreSQL mappings, and data protection.
HashService hashService, * **Application & Api Layers by Vertical Slice**: Features are organized cleanly into domain feature slices (`Projects`, `Grants`, `Execution`, `Governance`, `Evidence`, `Communications`, `Users`). Each slice groups its commands/queries, records (`Records.cs`), core service (`[Slice]Service`), hybrid cache decorator (`[Slice]CacheService`), and Minimal API endpoints (`[Action]Endpoint.cs`), preserving strong cohesion without code bloat.
ITenantContextService tenantContext) : IProjectService
``` 2. **Strict Adherence to SOLID Design Principles**:
* Extensive use of expression-bodied members (`=>`) for concise method definitions. * **Single Responsibility Principle (SRP)**:
* Nullable reference types (`<Nullable>enable</Nullable>`) strictly enforced. * `[Slice]Service`: Implements pure business logic, database transactions, and entity mapping.
2. **Functional Result Pattern (`FluentResults`)**: * `[Slice]CacheService`: Implements read caching and mutation eviction via `HybridCache`.
* Domain and Application services **never throw exceptions for predictable business validation failures**. * `LoggingInterceptor`: Exclusively manages structured logging upon failure.
* Methods return `Task<Result>` or `Task<Result<T>>`. * `AuditInterceptor`: Exclusively manages regulatory audit logging upon success (`BR-007`).
* Standard error objects in `PostFundManagement.Domain.Errors`: * `[Action]Endpoint`: Exclusively handles route binding, OpenAPI documentation, and HTTP mapping.
* `ValidationError`: Mandatory field missing, format invalid, or constraint violation. * **Open/Closed Principle (OCP)**: New cross-cutting behaviors (auditing, metrics, tracing) and endpoints are added via decorators, interceptors, and `IEndpoint` implementations without altering existing service logic.
* `NotFoundError`: Entity ID not found in database. * **Liskov Substitution Principle (LSP)**: `[Slice]CacheService` transparently decorates and substitutes `[Slice]Service` across all consumers via `I[Slice]Service`.
* `UpdateError`: Mutation failed or produced zero affected rows. * **Interface Segregation Principle (ISP)**: Lean, dedicated service interfaces per domain slice rather than bloated monolithic service contracts.
* `CreateError`: Entity creation failed during persistence. * **Dependency Inversion Principle (DIP)**: Endpoints and services depend exclusively on abstractions (`IDbContextFactory`, `ITenantContextService`, `HashService`, service interfaces) rather than concrete implementations.
* `IdDecodeError`: HashID token corrupted, invalid salt, or tampering detected.
3. **Unified Exception-to-Result Wrapping**: 3. **DRY Principle via AOP & Functional Utilities**:
* Service methods wrap their implementation inside `Result.Try`: * **Zero Cross-Cutting Boilerplate**: Developers never write repetitive logging or auditing code in service methods. Attributes `[Log]` and `[Audit]` handle these concerns declaratively via Castle DynamicProxy.
```csharp * **Unified Error & Problem Details**: Domain error records (`ValidationError`, `NotFoundError`, `UpdateError`, `CreateError`, `IdDecodeError`) and extension methods (`ToProblems()`, `ToEndpointName()`) prevent repeated error payload formatting.
public async Task<Result<Project>> GetProjectAsync(string projectIdHash, CancellationToken cancellationToken = default) => * **Shared HashID & Tenant Context**: Centralized `HashService` and `ITenantContextService` guarantee single-point security and tenant isolation enforcement.
await Result.Try((Func<Task<Result<Project>>>)(async () =>
{
// Execution logic...
}));
```
* Catches unhandled exceptions and converts them safely into `Result.Fail` without crashing the thread or leaking raw stack traces.
4. **Pattern-Matching Endpoint Handlers**:
* Minimal API endpoints map `Result<T>` directly to HTTP status codes via C# switch expressions:
```csharp
await service.UpdateProjectStatusAsync(...) switch
{
{ IsSuccess: true } => Results.Ok(),
var r when r.HasError<IdDecodeError>() => Results.Forbid(),
var r when r.HasError<NotFoundError>() => Results.NotFound(r.Errors.ToProblems()),
var r when r.HasError<ValidationError>() => Results.BadRequest(r.Errors.ToProblems()),
var r => Results.InternalServerError(r.Errors.ToProblems())
}
```
5. **High-Concurrency DbContext Management**:
* Services inject `IDbContextFactory<ApplicationDbContext>` rather than scoped `ApplicationDbContext`.
* Creates short-lived contexts using `await using var context = await contextFactory.CreateDbContextAsync(cancellationToken);`.
* Avoids thread concurrency issues in parallel mediator requests and background Quartz jobs.
6. **Query & Mutation Optimizations**:
* Queries use `.AsNoTracking()` to eliminate EF Core tracking overhead.
* Targeted updates leverage `.ExecuteUpdateAsync(...)` for in-database bulk updates without loading full entities into memory.
7. **Tiered Hybrid Caching**:
* Implements `Microsoft.Extensions.Caching.Hybrid` alongside StackExchange Redis.
* Local L1 memory cache: 30-minute expiration.
* Distributed L2 Redis cache: 2-minute expiration (`InstanceName = "PFM-API_"`).
--- ---
### 16.3 Logging Strategy Using `LoggingInterceptor` & `[Log]` ### 16.3 C# 13 & .NET 10 Coding Style: Functional Results, Formatting & Syntax Rules
> [!IMPORTANT]
> **Authoritative Coding Style Rules (Never Regress or Undo This Approach)**:
> Antigravity / Gemini must strictly adhere to the established C# functional idioms, layout, and syntax conventions across all projects. When authoring, refactoring, or extending backend code:
> - **DO NOT introduce imperative `try-catch` blocks** for control flow or routine domain logic.
> - **DO NOT introduce nested `if-else` return branches**.
> - **DO NOT use braces `{}` on single-line `if` statements** — braces are reserved exclusively for blocks with more than one line of code.
> - **Prefer FluentResults over ternary operators** (`Result.FailIf(...).ToResult(...)` over `condition ? Result.Fail(...) : Result.Ok(...)`).
> - **Prefer expression-bodied members (`=>`) and switch expressions** over statement bodies and traditional switch statements.
> - **Never split method parameters one-per-line** in method or constructor definitions.
> - **Group related variable declarations and function calls** without empty blank lines between them.
> - **Never leave unused `using` namespaces** in `.cs` files.
> - **Pragmatically avoid magic strings** using constants, enums, or domain errors without obsessive over-engineering.
#### 1. Extensive Avoidance of `try-catch` Blocks
* Imperative `try-catch` blocks are strictly avoided across all application services, endpoints, and domain logic.
* Methods wrap their entire execution functionally inside `Result.Try`:
```csharp
public async Task<Result<Project>> GetProjectAsync(string projectIdHash, CancellationToken cancellationToken = default) =>
await Result.Try((Func<Task<Result<Project>>>)(async () =>
{
// Execution logic without any try-catch...
}));
```
* Unexpected runtime exceptions are automatically caught by `Result.Try` and converted into a `Result.Fail` with exception metadata, allowing `LoggingInterceptor` to record errors without leaking raw exceptions or crashing threads.
#### 2. Minimal Branching, Guard Clauses & If-Statement Brace Discipline
* **No Braces for Single-Line `if`**: Braces `{}` are strictly omitted when the `if` body consists of a single statement/line:
```csharp
if (!await context.Users.AnyAsync(u => u.Id == userId, cancellationToken))
return Result.Fail(NotFoundError.New);
if (decodeResult.IsFailed)
return Result.Fail<Contract>(IdDecodeError.Create().CausedBy(decodeResult.Errors));
```
* **Braces Only for Multi-Line Blocks**: Braces `{}` are used **only** when the `if` block encompasses more than one line of code:
```csharp
if (string.IsNullOrWhiteSpace(cached))
{
var result = await service.GetActivitiesAsync(projectIdHash, cancellationToken);
if (result.IsFailed) return result;
await cache.SetStringAsync(cacheKey, result.Value.ToJson(), cacheOptions, cancellationToken);
return result.Value;
}
```
#### 3. FluentResults Monadic Chaining over Ternary Operators
* Whenever processing or transforming a response, **always choose FluentResults methods over ternary operators (`? :`)**:
```csharp
// Preferred (Fluent monadic style):
return Result.FailIf(project is null, NotFoundError.Create("Project not found"))
.ToResult(project?.Map()!);
return Result.OkIf(changedTotal > 0, UpdateError.Create("No changes detected, update cancelled."));
// Avoid (Ternary branching):
// return project is null ? Result.Fail<Project>(...) : Result.Ok(project.Map());
```
#### 4. Expression-Bodied Members & Switch Expressions
* Methods, mappers, getters, and endpoint registrations strongly prefer expression bodies (`=>`):
```csharp
public static Activity Map(this Entities.Activity entity) => new()
{
Id = entity.Id,
Title = entity.Title,
// ...
};
```
* Minimal API endpoints map `Result<T>` directly to HTTP status codes via C# pattern-matching switch expressions:
```csharp
await service.UpdateProjectStatusAsync(request, context.GetUserId(), cancellationToken) switch
{
{ IsSuccess: true } => Results.Ok(),
var r when r.HasError<IdDecodeError>() => Results.Forbid(),
var r when r.HasError<NotFoundError>() => Results.NotFound(r.Errors.ToProblems()),
var r when r.HasError<ValidationError>() => Results.BadRequest(r.Errors.ToProblems()),
var r when r.HasError<UpdateError>() => Results.BadRequest(r.Errors.ToProblems()),
var r => Results.InternalServerError(r.Errors.ToProblems())
}
```
#### 5. Compact Method & Constructor Definitions
* Method signatures are kept compact and readable on one line or cleanly wrapped; **parameters are never split into one line per variable**:
```csharp
// Preferred:
public async Task<Result> UpdateProjectStatusAsync(UpdateProjectStatusRequest request, Guid userId, CancellationToken cancellationToken = default) =>
public sealed class ProjectService(IDbContextFactory<ApplicationDbContext> contextFactory, HashService hashService,
ITenantContextService tenantContext) : IProjectService
// Avoid:
// public async Task<Result> UpdateProjectStatusAsync(
// UpdateProjectStatusRequest request,
// Guid userId,
// CancellationToken cancellationToken = default)
```
#### 6. Bracket Placement (Allman Style)
* Opening braces `{` are placed on a new line for type declarations, multi-line blocks, lambda bodies in `Result.Try`, and object initializers:
```csharp
public sealed class ProjectService(...) : IProjectService
{
public async Task<Result> MethodAsync(...) =>
await Result.Try((Func<Task<Result>>)(async () =>
{
// ...
}));
}
```
#### 7. Dense Grouping of Related Declarations & Calls
* Related variable declarations are grouped together without blank lines between them.
* Related sequential invocations are kept adjacent without extraneous empty lines:
```csharp
var name = request.Name.Humanize(LetterCasing.Title).Trim();
var description = request.Description.Humanize(LetterCasing.Sentence).Trim();
var cacheKey = HashService.ToMd5Hash($"project-{id}").Value;
var cached = await cache.GetStringAsync(cacheKey, cancellationToken);
```
#### 8. Clean Namespaces & Pragmatic String Handling
* **Zero Unused Namespaces**: Every `.cs` file maintains clean, minimal `using` directives without stale or unused imports. File-scoped namespaces (`namespace PostFundManagement...;`) are standard.
* **Pragmatic Magic String Avoidance**: Avoid magic strings for business-critical values (use constants in `Constants.cs`, `EndpointTags`, domain enums, or typed domain errors), but remain pragmatic (e.g. human-readable error messages and route patterns need not be over-abstracted into single-use constants).
#### 9. Functional Programming Orientation
* While C# is multi-paradigm, the codebase leans intentionally into functional programming concepts:
* Immutability via positional records (`public sealed record UpdateProjectStatusRequest(...)`).
* Pure extension mapping functions (`entity.Map()`).
* Declarative LINQ queries and pipeline data transformations.
* Monadic error handling (`Result<T>`, `Result.Try`, `CausedBy`).
#### 10. High-Concurrency DbContext Management
* Services inject `IDbContextFactory<ApplicationDbContext>` rather than scoped `ApplicationDbContext`.
* Creates short-lived contexts using `await using var context = await contextFactory.CreateDbContextAsync(cancellationToken);`.
* Avoids thread concurrency issues in parallel mediator requests and background Quartz jobs.
#### 11. Query & Mutation Optimizations
* Queries use `.AsNoTracking()` to eliminate EF Core tracking overhead.
* Targeted updates leverage `.ExecuteUpdateAsync(...)` for in-database bulk updates without loading full entities into memory.
#### 12. Tiered Hybrid Caching
* Implements `Microsoft.Extensions.Caching.Hybrid` alongside StackExchange Redis.
* Local L1 memory cache: 30-minute expiration.
* Distributed L2 Redis cache: 2-minute expiration (`InstanceName = "PFM-API_"`).
---
### 16.4 Logging Strategy Using `LoggingInterceptor` & `[Log]`
The application uses Aspect-Oriented Programming (AOP) via **Castle DynamicProxy** and `Castle.Core.AsyncInterceptor` to decouple logging from core domain logic. The application uses Aspect-Oriented Programming (AOP) via **Castle DynamicProxy** and `Castle.Core.AsyncInterceptor` to decouple logging from core domain logic.
@@ -1031,7 +1184,7 @@ The application uses Aspect-Oriented Programming (AOP) via **Castle DynamicProxy
--- ---
### 16.4 Audit Strategy Using `AuditInterceptor`, `[Audit]` & `AuditService` ### 16.5 Audit Strategy Using `AuditInterceptor`, `[Audit]` & `AuditService`
The audit trail is a strict regulatory requirement (`BR-007`, `NFR-008`, `AT-12`). Rather than writing manual audit code in every handler, the platform automates audit logging via `AuditInterceptor`. The audit trail is a strict regulatory requirement (`BR-007`, `NFR-008`, `AT-12`). Rather than writing manual audit code in every handler, the platform automates audit logging via `AuditInterceptor`.
@@ -1061,7 +1214,7 @@ The audit trail is a strict regulatory requirement (`BR-007`, `NFR-008`, `AT-12`
--- ---
### 16.5 AOP Service Registration Pipeline ### 16.6 AOP Service Registration Pipeline
All application services are registered through a unified decorator-and-proxy composition in [`PostFundManagement.Application/Extensions/ServiceExtensionTools.cs`](file:///c:/Users/Admin/source/repos/PostFundManagement/PostFundManagement.Application/Extensions/ServiceExtensionTools.cs) and [`Services.cs`](file:///c:/Users/Admin/source/repos/PostFundManagement/PostFundManagement.Application/Extensions/Services.cs): All application services are registered through a unified decorator-and-proxy composition in [`PostFundManagement.Application/Extensions/ServiceExtensionTools.cs`](file:///c:/Users/Admin/source/repos/PostFundManagement/PostFundManagement.Application/Extensions/ServiceExtensionTools.cs) and [`Services.cs`](file:///c:/Users/Admin/source/repos/PostFundManagement/PostFundManagement.Application/Extensions/Services.cs):
+5 -1
View File
@@ -8,7 +8,11 @@ EXPOSE 8080
FROM mcr.microsoft.com/dotnet/sdk:10.0 AS build FROM mcr.microsoft.com/dotnet/sdk:10.0 AS build
WORKDIR /src WORKDIR /src
# Copy csproj files for layer caching # Copy props and csproj files for layer caching
COPY ["Directory.Build.props", "./"]
COPY ["Directory.Packages.props", "./"]
COPY ["PostFundManagement.snk", "./"]
COPY ["LICENSE", "./"]
COPY ["PostFundManagement.Api/PostFundManagement.Api.csproj", "PostFundManagement.Api/"] COPY ["PostFundManagement.Api/PostFundManagement.Api.csproj", "PostFundManagement.Api/"]
COPY ["PostFundManagement.Application/PostFundManagement.Application.csproj", "PostFundManagement.Application/"] COPY ["PostFundManagement.Application/PostFundManagement.Application.csproj", "PostFundManagement.Application/"]
COPY ["PostFundManagement.Domain/PostFundManagement.Domain.csproj", "PostFundManagement.Domain/"] COPY ["PostFundManagement.Domain/PostFundManagement.Domain.csproj", "PostFundManagement.Domain/"]
@@ -0,0 +1,36 @@
using PostFundManagement.Application.Abstractions.Services;
using PostFundManagement.Application.Extensions;
using PostFundManagement.Application.Services.Communications;
using PostFundManagement.Domain.Abstractions;
using PostFundManagement.Domain.Errors;
using PostFundManagement.Domain.Extensions;
using PostFundManagement.Domain.Models;
namespace PostFundManagement.Api.Endpoints.Communications;
[ApiVersionTarget(1)]
public sealed class CreateNotificationEndpoint : IEndpoint
{
public void Map(IEndpointRouteBuilder builder)
{
builder.MapPost("api/communications/notifications", async (CreateNotificationRequest request,
HttpContext context, ICommunicationsService service, CancellationToken cancellationToken = default) =>
await service.CreateNotificationAsync(request, context.GetUserId(), cancellationToken) switch
{
{ IsSuccess: true } => Results.Created(),
var r when r.HasError<ValidationError>() => Results.BadRequest(r.Errors.ToProblems()),
var r when r.HasError<CreateError>() => Results.BadRequest(r.Errors.ToProblems()),
var r => Results.InternalServerError(r.Errors.ToProblems())
})
.WithDescription("Creates a new in-app or outbound notification record")
.RequireAuthorization(Constants.MainScope)
.WithName(typeof(CreateNotificationEndpoint).ToEndpointName())
.MapToApiVersion(new ApiVersion(1))
.Produces(StatusCodes.Status201Created)
.Produces<Problems>(StatusCodes.Status400BadRequest)
.Produces<Problems>(StatusCodes.Status500InternalServerError)
.Produces(StatusCodes.Status401Unauthorized)
.WithTags(EndpointTags.Communications);
}
}
@@ -0,0 +1,49 @@
using PostFundManagement.Application.Abstractions.Services;
using PostFundManagement.Application.Extensions;
using PostFundManagement.Domain.Abstractions;
using PostFundManagement.Domain.Extensions;
using PostFundManagement.Domain.Models;
namespace PostFundManagement.Api.Endpoints.Communications;
[ApiVersionTarget(1)]
public sealed class GetUserNotificationsEndpoint : IEndpoint
{
public void Map(IEndpointRouteBuilder builder)
{
builder.MapGet("api/communications/notifications/user/{userId:guid}", async (Guid userId,
ICommunicationsService service, CancellationToken cancellationToken = default) =>
await service.GetUserNotificationsAsync(userId, cancellationToken) switch
{
{ IsSuccess: true, Value: var notifications } => Results.Ok(notifications),
var r => Results.InternalServerError(r.Errors.ToProblems()),
})
.WithDescription("Retrieves all notifications targeted at a specific user")
.RequireAuthorization(Constants.MainScope)
.WithName(typeof(GetUserNotificationsEndpoint).ToEndpointName())
.MapToApiVersion(new ApiVersion(1))
.Produces<Notification[]>(StatusCodes.Status200OK)
.Produces(StatusCodes.Status401Unauthorized)
.Produces<Problems>(StatusCodes.Status500InternalServerError)
.WithTags(EndpointTags.Communications)
.CacheOutput();
builder.MapGet("api/communications/notifications/inbox", async (HttpContext context,
ICommunicationsService service, CancellationToken cancellationToken = default) =>
await service.GetUserNotificationsAsync(context.GetUserId(), cancellationToken) switch
{
{ IsSuccess: true, Value: var notifications } => Results.Ok(notifications),
var r => Results.InternalServerError(r.Errors.ToProblems()),
})
.WithDescription("Retrieves all in-app and system notifications targeted at the authenticated caller")
.RequireAuthorization(Constants.MainScope)
.WithName($"{typeof(GetUserNotificationsEndpoint).ToEndpointName()}Inbox")
.MapToApiVersion(new ApiVersion(1))
.Produces<Notification[]>(StatusCodes.Status200OK)
.Produces(StatusCodes.Status401Unauthorized)
.Produces<Problems>(StatusCodes.Status500InternalServerError)
.WithTags(EndpointTags.Communications)
.CacheOutput();
}
}
@@ -0,0 +1,37 @@
using PostFundManagement.Application.Abstractions.Services;
using PostFundManagement.Application.Extensions;
using PostFundManagement.Application.Services.Communications;
using PostFundManagement.Domain.Abstractions;
using PostFundManagement.Domain.Errors;
using PostFundManagement.Domain.Extensions;
using PostFundManagement.Domain.Models;
namespace PostFundManagement.Api.Endpoints.Communications;
[ApiVersionTarget(1)]
public sealed class UpdateNotificationEndpoint : IEndpoint
{
public void Map(IEndpointRouteBuilder builder)
{
builder.MapPut("api/communications/notifications", async (UpdateNotificationRequest request,
ICommunicationsService service, CancellationToken cancellationToken = default) =>
await service.UpdateNotificationAsync(request, cancellationToken) switch
{
{ IsSuccess: true } => Results.NoContent(),
var r when r.HasError<NotFoundError>() => Results.NotFound(r.Errors.ToProblems()),
var r when r.HasError<UpdateError>() => Results.BadRequest(r.Errors.ToProblems()),
var r => Results.InternalServerError(r.Errors.ToProblems())
})
.WithDescription("Updates a notification status, delivery timestamp, or error state")
.RequireAuthorization(Constants.MainScope)
.WithName(typeof(UpdateNotificationEndpoint).ToEndpointName())
.MapToApiVersion(new ApiVersion(1))
.Produces(StatusCodes.Status204NoContent)
.Produces<Problems>(StatusCodes.Status404NotFound)
.Produces<Problems>(StatusCodes.Status400BadRequest)
.Produces<Problems>(StatusCodes.Status500InternalServerError)
.Produces(StatusCodes.Status401Unauthorized)
.WithTags(EndpointTags.Communications);
}
}
@@ -11,7 +11,7 @@ public class GetProjectEvidenceEndpoint : IEndpoint
{ {
public void Map(IEndpointRouteBuilder builder) 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) => CancellationToken cancellationToken = default) =>
await service.GetProjectEvidencesAsync(projectId, cancellationToken) switch await service.GetProjectEvidencesAsync(projectId, cancellationToken) switch
{ {
@@ -0,0 +1,36 @@
using PostFundManagement.Application.Abstractions.Services;
using PostFundManagement.Application.Extensions;
using PostFundManagement.Application.Services.Grants;
using PostFundManagement.Domain.Abstractions;
using PostFundManagement.Domain.Errors;
using PostFundManagement.Domain.Extensions;
namespace PostFundManagement.Api.Endpoints.Grants;
[ApiVersionTarget(1)]
public sealed class UpdateAwardEndpoint : IEndpoint
{
public void Map(IEndpointRouteBuilder builder)
{
builder.MapPut("api/grants/awards", async (UpdateAwardRequest request, HttpContext context,
IGrantsService service, CancellationToken cancellationToken = default) =>
await service.UpdateAwardAsync(request, context.GetUserId(), cancellationToken) switch
{
{ IsSuccess: true } => Results.Ok(),
var r when r.HasError<NotFoundError>() => Results.NotFound(r.Errors.ToProblems()),
var r when r.HasError<ValidationError>() => Results.BadRequest(r.Errors.ToProblems()),
var r when r.HasError<UpdateError>() => Results.BadRequest(r.Errors.ToProblems()),
var r => Results.InternalServerError(r.Errors.ToProblems())
})
.WithDescription("Updates a registered funding award")
.RequireAuthorization(Constants.MainScope)
.WithName(typeof(UpdateAwardEndpoint).ToEndpointName())
.MapToApiVersion(new ApiVersion(1))
.Produces(StatusCodes.Status200OK)
.Produces(StatusCodes.Status401Unauthorized)
.Produces<Problems>(StatusCodes.Status404NotFound)
.Produces<Problems>(StatusCodes.Status400BadRequest)
.Produces<Problems>(StatusCodes.Status500InternalServerError)
.WithTags(EndpointTags.Grants);
}
}
@@ -1,4 +1,4 @@
using PostFundManagement.Application.Abstractions.Services; using PostFundManagement.Application.Abstractions.Services;
using PostFundManagement.Application.Extensions; using PostFundManagement.Application.Extensions;
using PostFundManagement.Application.Services.Projects; using PostFundManagement.Application.Services.Projects;
using PostFundManagement.Domain.Abstractions; using PostFundManagement.Domain.Abstractions;
@@ -12,14 +12,14 @@ public sealed class CreateOutcomeEndpoint : IEndpoint
{ {
public void Map(IEndpointRouteBuilder builder) public void Map(IEndpointRouteBuilder builder)
{ {
builder.MapPost("api/projects/beneficiaris/outcomes", async (CreateOutcomeRequest request, HttpContext context, builder.MapPost("api/projects/beneficiaries/outcomes", async (CreateOutcomeRequest request, HttpContext context,
IProjectService service, CancellationToken cancellationToken = default) => IProjectService service, CancellationToken cancellationToken = default) =>
await service.CreateOutcomeAsync(request, context.GetUserId(), cancellationToken) switch await service.CreateOutcomeAsync(request, context.GetUserId(), cancellationToken) switch
{ {
{ IsSuccess: true } => Results.Ok(), { IsSuccess: true } => Results.Ok(),
var r when r.HasError<CreateError>() => Results.BadRequest(r.Errors.ToProblems()), var r when r.HasError<CreateError>() => Results.BadRequest(r.Errors.ToProblems()),
var r => Results.InternalServerError(r.Errors.ToProblems()) var r => Results.InternalServerError(r.Errors.ToProblems())
}) })
.WithDescription("Add a new beneficiary outcome") .WithDescription("Add a new beneficiary outcome")
.RequireAuthorization(Constants.MainScope) .RequireAuthorization(Constants.MainScope)
.WithName(typeof(CreateOutcomeEndpoint).ToEndpointName()) .WithName(typeof(CreateOutcomeEndpoint).ToEndpointName())
@@ -31,4 +31,3 @@ public sealed class CreateOutcomeEndpoint : IEndpoint
.WithTags(EndpointTags.Projects); .WithTags(EndpointTags.Projects);
} }
} }
@@ -1,4 +1,4 @@
using PostFundManagement.Application.Abstractions.Services; using PostFundManagement.Application.Abstractions.Services;
using PostFundManagement.Application.Extensions; using PostFundManagement.Application.Extensions;
using PostFundManagement.Domain.Abstractions; using PostFundManagement.Domain.Abstractions;
using PostFundManagement.Domain.Errors; using PostFundManagement.Domain.Errors;
@@ -12,15 +12,7 @@ public sealed class GetOutcomeEndpoint : IEndpoint
{ {
public void Map(IEndpointRouteBuilder builder) public void Map(IEndpointRouteBuilder builder)
{ {
builder.MapGet("api/projects/outcomes/{outcomeId}", async (string outcomeId, IProjectService service, builder.MapGet("api/projects/outcome/{outcomeId}", HandleGetAsync)
CancellationToken cancellationToken = default) =>
await service.GetOutcomeAsync(outcomeId, cancellationToken) switch
{
{ IsSuccess: true, Value: var outcome } => Results.Ok(outcome),
var r when r.HasError<IdDecodeError>() => Results.Forbid(),
var r when r.HasError<NotFoundError>() => Results.NotFound(r.Errors.ToProblems()),
var r => Results.InternalServerError(r.Errors.ToProblems())
})
.WithDescription("Gets a specific project outcome") .WithDescription("Gets a specific project outcome")
.RequireAuthorization(Constants.MainScope) .RequireAuthorization(Constants.MainScope)
.WithName(typeof(GetOutcomeEndpoint).ToEndpointName()) .WithName(typeof(GetOutcomeEndpoint).ToEndpointName())
@@ -32,5 +24,28 @@ public sealed class GetOutcomeEndpoint : IEndpoint
.Produces<Problems>(StatusCodes.Status500InternalServerError) .Produces<Problems>(StatusCodes.Status500InternalServerError)
.WithTags(EndpointTags.Projects) .WithTags(EndpointTags.Projects)
.CacheOutput(); .CacheOutput();
builder.MapGet("api/projects/outcomes/single/{outcomeId}", HandleGetAsync)
.WithDescription("Gets a specific project outcome")
.RequireAuthorization(Constants.MainScope)
.WithName(typeof(GetOutcomeEndpoint).ToEndpointName("Single"))
.MapToApiVersion(new ApiVersion(1))
.Produces<Outcome>(StatusCodes.Status200OK)
.Produces(StatusCodes.Status403Forbidden)
.Produces(StatusCodes.Status401Unauthorized)
.Produces<Problems>(StatusCodes.Status404NotFound)
.Produces<Problems>(StatusCodes.Status500InternalServerError)
.WithTags(EndpointTags.Projects)
.CacheOutput();
} }
private static async Task<IResult> HandleGetAsync(string outcomeId, IProjectService service,
CancellationToken cancellationToken = default) =>
await service.GetOutcomeAsync(outcomeId, cancellationToken) switch
{
{ IsSuccess: true, Value: var outcome } => Results.Ok(outcome),
var r when r.HasError<IdDecodeError>() => Results.Forbid(),
var r when r.HasError<NotFoundError>() => Results.NotFound(r.Errors.ToProblems()),
var r => Results.InternalServerError(r.Errors.ToProblems())
};
} }
@@ -1,4 +1,4 @@
using PostFundManagement.Application.Abstractions.Services; using PostFundManagement.Application.Abstractions.Services;
using PostFundManagement.Application.Extensions; using PostFundManagement.Application.Extensions;
using PostFundManagement.Domain.Abstractions; using PostFundManagement.Domain.Abstractions;
using PostFundManagement.Domain.Errors; using PostFundManagement.Domain.Errors;
@@ -12,15 +12,7 @@ public sealed class GetOutcomesEndpoint : IEndpoint
{ {
public void Map(IEndpointRouteBuilder builder) public void Map(IEndpointRouteBuilder builder)
{ {
builder.MapGet("api/projects/outcomes/{beneficiatyId}", async (string beneficiatyId, IProjectService service, builder.MapGet("api/projects/beneficiaries/{beneficiaryId}/outcomes", HandleGetAsync)
CancellationToken cancellationToken = default) =>
await service.GetOutcomesAsync(beneficiatyId, cancellationToken) switch
{
{ IsSuccess: true, Value: var outcomes } => Results.Ok(outcomes),
var r when r.HasError<IdDecodeError>() => Results.Forbid(),
var r when r.HasError<NotFoundError>() => Results.NotFound(r.Errors.ToProblems()),
var r => Results.InternalServerError(r.Errors.ToProblems())
})
.WithDescription("Gets project beneficiary outcomes") .WithDescription("Gets project beneficiary outcomes")
.RequireAuthorization(Constants.MainScope) .RequireAuthorization(Constants.MainScope)
.WithName(typeof(GetOutcomesEndpoint).ToEndpointName()) .WithName(typeof(GetOutcomesEndpoint).ToEndpointName())
@@ -32,5 +24,41 @@ public sealed class GetOutcomesEndpoint : IEndpoint
.Produces<Problems>(StatusCodes.Status500InternalServerError) .Produces<Problems>(StatusCodes.Status500InternalServerError)
.WithTags(EndpointTags.Projects) .WithTags(EndpointTags.Projects)
.CacheOutput(); .CacheOutput();
builder.MapGet("api/projects/beneficiaris/{beneficiaryId}/outcomes", HandleGetAsync)
.WithDescription("Gets project beneficiary outcomes")
.RequireAuthorization(Constants.MainScope)
.WithName(typeof(GetOutcomesEndpoint).ToEndpointName("TypoAlias"))
.MapToApiVersion(new ApiVersion(1))
.Produces<Outcome[]>(StatusCodes.Status200OK)
.Produces(StatusCodes.Status403Forbidden)
.Produces(StatusCodes.Status401Unauthorized)
.Produces<Problems>(StatusCodes.Status404NotFound)
.Produces<Problems>(StatusCodes.Status500InternalServerError)
.WithTags(EndpointTags.Projects)
.CacheOutput();
builder.MapGet("api/projects/outcomes/{beneficiaryId}", HandleGetAsync)
.WithDescription("Gets project beneficiary outcomes")
.RequireAuthorization(Constants.MainScope)
.WithName(typeof(GetOutcomesEndpoint).ToEndpointName("Legacy"))
.MapToApiVersion(new ApiVersion(1))
.Produces<Outcome[]>(StatusCodes.Status200OK)
.Produces(StatusCodes.Status403Forbidden)
.Produces(StatusCodes.Status401Unauthorized)
.Produces<Problems>(StatusCodes.Status404NotFound)
.Produces<Problems>(StatusCodes.Status500InternalServerError)
.WithTags(EndpointTags.Projects)
.CacheOutput();
} }
private static async Task<IResult> HandleGetAsync(string beneficiaryId, IProjectService service,
CancellationToken cancellationToken = default) =>
await service.GetOutcomesAsync(beneficiaryId, cancellationToken) switch
{
{ IsSuccess: true, Value: var outcomes } => Results.Ok(outcomes),
var r when r.HasError<IdDecodeError>() => Results.Forbid(),
var r when r.HasError<NotFoundError>() => Results.NotFound(r.Errors.ToProblems()),
var r => Results.InternalServerError(r.Errors.ToProblems())
};
} }
@@ -0,0 +1,37 @@
using PostFundManagement.Application.Abstractions.Services;
using PostFundManagement.Application.Extensions;
using PostFundManagement.Domain.Abstractions;
using PostFundManagement.Domain.Errors;
using PostFundManagement.Domain.Extensions;
using PostFundManagement.Domain.Models;
namespace PostFundManagement.Api.Endpoints.Projects;
[ApiVersionTarget(1)]
public sealed class GetProjectOutcomesEndpoint : IEndpoint
{
public void Map(IEndpointRouteBuilder builder)
{
builder.MapGet("api/projects/{projectId}/outcomes", async (string projectId, IProjectService service,
CancellationToken cancellationToken = default) =>
await service.GetProjectOutcomesAsync(projectId, cancellationToken) switch
{
{ IsSuccess: true, Value: var outcomes } => Results.Ok(outcomes),
var r when r.HasError<IdDecodeError>() => Results.Forbid(),
var r when r.HasError<NotFoundError>() => Results.NotFound(r.Errors.ToProblems()),
var r => Results.InternalServerError(r.Errors.ToProblems())
})
.WithDescription("Gets project outcomes for all beneficiaries in a project")
.RequireAuthorization(Constants.MainScope)
.WithName(typeof(GetProjectOutcomesEndpoint).ToEndpointName())
.MapToApiVersion(new ApiVersion(1))
.Produces<Outcome[]>(StatusCodes.Status200OK)
.Produces(StatusCodes.Status403Forbidden)
.Produces(StatusCodes.Status401Unauthorized)
.Produces<Problems>(StatusCodes.Status404NotFound)
.Produces<Problems>(StatusCodes.Status500InternalServerError)
.WithTags(EndpointTags.Projects)
.CacheOutput();
}
}
@@ -0,0 +1,36 @@
using PostFundManagement.Application.Abstractions.Services;
using PostFundManagement.Application.Extensions;
using PostFundManagement.Application.Services.Projects;
using PostFundManagement.Domain.Abstractions;
using PostFundManagement.Domain.Errors;
using PostFundManagement.Domain.Extensions;
namespace PostFundManagement.Api.Endpoints.Projects;
[ApiVersionTarget(1)]
public class UpdateActivityEndpoint : IEndpoint
{
public void Map(IEndpointRouteBuilder builder)
{
builder.MapPut("api/projects/activities", async (UpdateActivityRequest request,
HttpContext context, IProjectService service, CancellationToken cancellationToken = default) =>
await service.UpdateActivityAsync(request, context.GetUserId(), cancellationToken) switch
{
{ IsSuccess: true } => Results.Ok(),
var r when r.HasError<NotFoundError>() => Results.NotFound(r.Errors.ToProblems()),
var r when r.HasError<UpdateError>() => Results.BadRequest(r.Errors.ToProblems()),
var r => Results.InternalServerError(r.Errors.ToProblems())
})
.RequireAuthorization(Constants.MainScope)
.WithDescription("Update an existing activity under project (M04)")
.WithName(typeof(UpdateActivityEndpoint).ToEndpointName())
.MapToApiVersion(new ApiVersion(1))
.Produces(StatusCodes.Status200OK)
.Produces<Problems>(StatusCodes.Status404NotFound)
.Produces<Problems>(StatusCodes.Status400BadRequest)
.Produces<Problems>(StatusCodes.Status500InternalServerError)
.Produces(StatusCodes.Status401Unauthorized)
.WithTags(EndpointTags.Projects);
}
}
@@ -1,4 +1,4 @@
using PostFundManagement.Application.Abstractions.Services; using PostFundManagement.Application.Abstractions.Services;
using PostFundManagement.Application.Extensions; using PostFundManagement.Application.Extensions;
using PostFundManagement.Domain.Abstractions; using PostFundManagement.Domain.Abstractions;
using PostFundManagement.Domain.Errors; using PostFundManagement.Domain.Errors;
@@ -13,22 +13,29 @@ public sealed class SetUserLockoutEndpoint : IEndpoint
{ {
builder.MapPut("api/users/{userId:Guid}/session/lockout/{disable:bool}", async (Guid userId, bool disable, HttpContext context, builder.MapPut("api/users/{userId:Guid}/session/lockout/{disable:bool}", async (Guid userId, bool disable, HttpContext context,
IUserService service, CancellationToken cancellationToken = default) => IUserService service, CancellationToken cancellationToken = default) =>
await service.SetUserLockoutAsync(userId, context.GetUserId(), disable, cancellationToken) switch {
{ var (_, role) = context.User.GetUserRole();
{ IsSuccess: true } => Results.Ok(), if (role != null && role.Equals("Partner", StringComparison.OrdinalIgnoreCase))
var r when r.HasError<NotFoundError>() => Results.NotFound(r.Errors.ToProblems()), return Results.Forbid();
var r when r.HasError<UpdateError>() => Results.BadRequest(r.Errors.ToProblems()),
var r => Results.InternalServerError(r.Errors.ToProblems()) return await service.SetUserLockoutAsync(userId, context.GetUserId(), disable, cancellationToken) switch
}) {
.WithDescription("Lock user session") { IsSuccess: true } => Results.Ok(),
.RequireAuthorization(Constants.MainScope) var r when r.HasError<NotFoundError>() => Results.NotFound(r.Errors.ToProblems()),
.WithName(typeof(SetUserLockoutEndpoint).ToEndpointName()) var r when r.HasError<UpdateError>() => Results.BadRequest(r.Errors.ToProblems()),
.MapToApiVersion(new ApiVersion(1)) var r => Results.InternalServerError(r.Errors.ToProblems())
.Produces(StatusCodes.Status200OK) };
.Produces(StatusCodes.Status401Unauthorized) })
.Produces<Problems>(StatusCodes.Status404NotFound) .WithDescription("Lock user session")
.Produces<Problems>(StatusCodes.Status400BadRequest) .RequireAuthorization(Constants.MainScope)
.Produces<Problems>(StatusCodes.Status500InternalServerError) .WithName(typeof(SetUserLockoutEndpoint).ToEndpointName())
.WithTags(EndpointTags.Users); .MapToApiVersion(new ApiVersion(1))
.Produces(StatusCodes.Status200OK)
.Produces(StatusCodes.Status401Unauthorized)
.Produces(StatusCodes.Status403Forbidden)
.Produces<Problems>(StatusCodes.Status404NotFound)
.Produces<Problems>(StatusCodes.Status400BadRequest)
.Produces<Problems>(StatusCodes.Status500InternalServerError)
.WithTags(EndpointTags.Users);
} }
} }
@@ -11,8 +11,8 @@ public class SyncUserEndpoint : IEndpoint
{ {
public void Map(IEndpointRouteBuilder builder) public void Map(IEndpointRouteBuilder builder)
{ {
builder.MapPost("api/integration/sync", async (IJobOrchestrator jobOrchestrator, builder.MapPost("api/integration/sync", async (HttpContext context, IUserService service,
HttpContext context, IUserService service, CancellationToken cancellationToken = default) => CancellationToken cancellationToken = default) =>
await service.SyncUserAsync(context.GetUserId(), context.GetUserEmail(), context.GetUserRole(), cancellationToken) switch await service.SyncUserAsync(context.GetUserId(), context.GetUserEmail(), context.GetUserRole(), cancellationToken) switch
{ {
{ IsSuccess: true } => Results.Ok(), { IsSuccess: true } => Results.Ok(),
@@ -1,111 +1,63 @@
<Project Sdk="Microsoft.NET.Sdk.Web"> <Project Sdk="Microsoft.NET.Sdk.Web">
<PropertyGroup> <PropertyGroup>
<TargetFramework>net10.0</TargetFramework>
<Nullable>enable</Nullable>
<ImplicitUsings>enable</ImplicitUsings>
<UserSecretsId>59a05094-4ac2-472d-af04-3407e909432d</UserSecretsId> <UserSecretsId>59a05094-4ac2-472d-af04-3407e909432d</UserSecretsId>
<AssemblyOriginatorKeyFile>..\PostFundManagement.snk</AssemblyOriginatorKeyFile>
<SignAssembly>True</SignAssembly>
<PackageLicenseFile>..\LICENSE</PackageLicenseFile>
</PropertyGroup> </PropertyGroup>
<PropertyGroup Condition="'$(Configuration)|$(Platform)'=='Debug|AnyCPU'"> <!-- Security (OIDC) -->
<NoWarn>1701;1702;AV0029;AV0030;</NoWarn>
</PropertyGroup>
<PropertyGroup Condition="'$(Configuration)|$(Platform)'=='Release|AnyCPU'">
<NoWarn>1701;1702;AV0029;AV0030;</NoWarn>
</PropertyGroup>
<!-- Security (IODC)-->
<ItemGroup> <ItemGroup>
<PackageReference Include="IdentityModel.AspNetCore" Version="4.3.0" /> <PackageReference Include="IdentityModel.AspNetCore" />
<PackageReference Include="IdentityModel.AspNetCore.OAuth2introspection" Version="6.2.0" /> <PackageReference Include="IdentityModel.AspNetCore.OAuth2introspection" />
<PackageReference Include="IdentityServer4.AccessTokenValidation" Version="3.0.1" /> <PackageReference Include="IdentityServer4.AccessTokenValidation" />
<PackageReference Include="IdentityModel" Version="6.2.0" /> <PackageReference Include="IdentityModel" />
<PackageReference Include="KubernetesClient" Version="19.0.2" /> <PackageReference Include="KubernetesClient" />
<PackageReference Include="Microsoft.AspNetCore.Authentication.Certificate" Version="10.0.12" /> <PackageReference Include="Microsoft.AspNetCore.Authentication.Certificate" />
<PackageReference Include="Microsoft.AspNetCore.Authentication.JwtBearer" Version="10.0.12" /> <PackageReference Include="Microsoft.AspNetCore.Authentication.JwtBearer" />
<Using Include="Microsoft.FeatureManagement" />
<Using Include="Microsoft.AspNetCore.Authentication" />
<Using Include="Microsoft.AspNetCore.Authentication.Cookies" />
<Using Include="Microsoft.AspNetCore.Authentication.OpenIdConnect" />
<Using Include="Microsoft.AspNetCore.Authentication.JwtBearer" />
<Using Include="Microsoft.IdentityModel.Tokens" />
<Using Include="System.Security.Claims" />
</ItemGroup> </ItemGroup>
<!-- Health Checks --> <!-- Health Checks -->
<ItemGroup> <ItemGroup>
<PackageReference Include="AspNetCore.HealthChecks.UI" Version="9.0.0" /> <PackageReference Include="AspNetCore.HealthChecks.UI" />
<PackageReference Include="AspNetCore.HealthChecks.UI.Client" Version="9.0.0" /> <PackageReference Include="AspNetCore.HealthChecks.UI.Client" />
<PackageReference Include="AspNetCore.HealthChecks.UI.InMemory.Storage" Version="9.0.0" /> <PackageReference Include="AspNetCore.HealthChecks.UI.InMemory.Storage" />
<PackageReference Include="AspNetCore.HealthChecks.NpgSql" Version="9.0.0" /> <PackageReference Include="AspNetCore.HealthChecks.NpgSql" />
<Using Include="Microsoft.Extensions.Diagnostics.HealthChecks" />
<Using Include="Microsoft.AspNetCore.Diagnostics.HealthChecks" />
</ItemGroup> </ItemGroup>
<!-- API Versioning --> <!-- API Versioning -->
<ItemGroup> <ItemGroup>
<PackageReference Include="AccessTokenClient.Extensions" Version="5.1.0" /> <PackageReference Include="AccessTokenClient.Extensions" />
<PackageReference Include="Asp.Versioning.Abstractions" Version="10.2.1" /> <PackageReference Include="Asp.Versioning.Abstractions" />
<PackageReference Include="Asp.Versioning.Http" Version="10.2.3" /> <PackageReference Include="Asp.Versioning.Http" />
<PackageReference Include="Asp.Versioning.Mvc.ApiExplorer" Version="10.2.1" /> <PackageReference Include="Asp.Versioning.Mvc.ApiExplorer" />
<Using Include="Asp.Versioning" />
<Using Include="Asp.Versioning.Builder" />
</ItemGroup> </ItemGroup>
<!-- API Documentation --> <!-- API Documentation -->
<ItemGroup> <ItemGroup>
<PackageReference Include="Microsoft.AspNetCore.OpenApi" Version="10.0.12" /> <PackageReference Include="Microsoft.AspNetCore.OpenApi" />
<PackageReference Include="Scalar.AspNetCore" Version="2.17.4" /> <PackageReference Include="Scalar.AspNetCore" />
<Using Include="Scalar.AspNetCore" />
<Using Include="Microsoft.OpenApi" />
<Using Include="Microsoft.AspNetCore.OpenApi" />
</ItemGroup> </ItemGroup>
<!-- file nesting --> <!-- File Nesting -->
<ItemGroup> <ItemGroup>
<ProjectCapability Include="ConfigurableFileNesting" /> <ProjectCapability Include="ConfigurableFileNesting" />
<ProjectCapability Include="ConfigurableFileNestingFeatureEnabled" /> <ProjectCapability Include="ConfigurableFileNestingFeatureEnabled" />
</ItemGroup> </ItemGroup>
<!-- CQRS --> <!-- CQRS Source Generator -->
<ItemGroup> <ItemGroup>
<PackageReference Include="Mediator.SourceGenerator" Version="3.0.2"> <PackageReference Include="Mediator.SourceGenerator">
<PrivateAssets>all</PrivateAssets> <PrivateAssets>all</PrivateAssets>
<IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets> <IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>
</PackageReference> </PackageReference>
<Using Include="FluentResults" />
<Using Include="Mediator" />
<Using Include="Quartz" />
</ItemGroup> </ItemGroup>
<!-- Shared Static Usings --> <!-- Project-specific Usings -->
<ItemGroup> <ItemGroup>
<Using Static="true" Include="PostFundManagement.Application.Extensions.General" />
<Using Alias="Problems" Include="Microsoft.AspNetCore.Mvc.ProblemDetails" /> <Using Alias="Problems" Include="Microsoft.AspNetCore.Mvc.ProblemDetails" />
</ItemGroup> <Using Include="Asp.Versioning.Builder" />
<!-- Shared Global Usings -->
<ItemGroup>
<Using Include="System.Web" />
<Using Include="PostFundManagement.Domain.Attributes" />
<Using Include="System.Diagnostics" />
<Using Include="System.Reflection" />
<Using Include="Microsoft.AspNetCore.Mvc" /> <Using Include="Microsoft.AspNetCore.Mvc" />
<Using Include="Microsoft.EntityFrameworkCore" /> <Using Include="Microsoft.FeatureManagement" />
<Using Include="System.ComponentModel.DataAnnotations" /> <Using Include="PostFundManagement.Domain.Attributes" />
<Using Include="Microsoft.Extensions.DependencyInjection.Extensions" />
<Using Include="System.Security.Cryptography.X509Certificates" />
<Using Include="Microsoft.AspNetCore.DataProtection" />
<Using Include="Refit" />
</ItemGroup> </ItemGroup>
<ItemGroup> <ItemGroup>
+25 -9
View File
@@ -4,7 +4,6 @@ using PostFundManagement.Domain.Abstractions;
using PostFundManagement.Domain.Health; using PostFundManagement.Domain.Health;
using PostFundManagement.Domain.Mediator; using PostFundManagement.Domain.Mediator;
using PostFundManagement.Infrastructure.Database.Services; using PostFundManagement.Infrastructure.Database.Services;
using static PostFundManagement.Domain.Extensions.Constants;
var builder = WebApplication.CreateBuilder(args); 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(TelemetryPipelineBehavior<,>));
builder.Services.AddScoped(typeof(IPipelineBehavior<,>), typeof(LoggingPipelineBehavior<,>)); 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.AddEmailServices(builder.Configuration);
builder.Services.AddHashServices(builder.Configuration); builder.Services.AddHashServices(builder.Configuration);
builder.Services.AddGarageS3(builder.Configuration); builder.Services.AddGarageS3(builder.Configuration);
@@ -116,7 +115,29 @@ builder.Services.AddCors(options =>
{ {
options.AddPolicy(CorsPolicy, policy => options.AddPolicy(CorsPolicy, policy =>
{ {
policy.WithOrigins(allowedOrigins) 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;
try
{
var host = new Uri(origin).Host;
if (host.EndsWith("khongisa.co.za", StringComparison.OrdinalIgnoreCase) ||
host.Equals("localhost", StringComparison.OrdinalIgnoreCase) ||
host.Equals("127.0.0.1", StringComparison.OrdinalIgnoreCase))
{
return true;
}
}
catch { /* eat all exceptions */ }
return false;
})
.AllowAnyHeader() .AllowAnyHeader()
.AllowAnyMethod() .AllowAnyMethod()
.AllowCredentials(); .AllowCredentials();
@@ -125,12 +146,7 @@ builder.Services.AddCors(options =>
var app = builder.Build(); var app = builder.Build();
var schedulerFactory = app.Services.GetRequiredService<ISchedulerFactory>(); app.UseCors(CorsPolicy);
var scheduler = await schedulerFactory.GetScheduler();
if (scheduler!.Status != SchedulerStatus.Running)
await scheduler.Start();
app.UseHsts(); app.UseHsts();
app.UseHttpsRedirection(); app.UseHttpsRedirection();
app.UseRouting(); app.UseRouting();
+1 -7
View File
@@ -1,6 +1,7 @@
{ {
"EnableServiceCaching": true, "EnableServiceCaching": true,
"CorsOrigins": [ "CorsOrigins": [
"https://partner.ifri.khongisa.co.za",
"https://console.ifri.khongisa.co.za", "https://console.ifri.khongisa.co.za",
"https://localhost:7208" "https://localhost:7208"
], ],
@@ -31,13 +32,6 @@
"Microsoft.EntityFrameworkCore.Update": "Error" "Microsoft.EntityFrameworkCore.Update": "Error"
} }
}, },
"EmailProcessorCron": "0 */5 * * * ?",
"Email": {
"SmtpServer": "smtp.example.com",
"SmtpPort": 587,
"SenderEmail": "noreply@example.com",
"SenderName": "Example"
},
"HasherSettings": { "HasherSettings": {
"MinHashLength": 11 "MinHashLength": 11
}, },
@@ -1,4 +1,4 @@
using PostFundManagement.Application.Services.Communications; using PostFundManagement.Application.Services.Communications;
using PostFundManagement.Domain; using PostFundManagement.Domain;
using PostFundManagement.Domain.Attributes; using PostFundManagement.Domain.Attributes;
using PostFundManagement.Domain.Models; using PostFundManagement.Domain.Models;
@@ -25,12 +25,14 @@ public interface ICommunicationsService
Task<Result> CreateNotificationAsync(CreateNotificationRequest request, Guid userId, CancellationToken cancellationToken = default); Task<Result> CreateNotificationAsync(CreateNotificationRequest request, Guid userId, CancellationToken cancellationToken = default);
[Log] [Log]
[Audit]
Task<Result> UpdateNotificationAsync(UpdateNotificationRequest request, CancellationToken cancellationToken = default); Task<Result> UpdateNotificationAsync(UpdateNotificationRequest request, CancellationToken cancellationToken = default);
[Log] [Log]
Task<Result<Notification[]>> GetNotificationsAsync(string organisationIdHash, CancellationToken cancellationToken = default); Task<Result<Notification[]>> GetNotificationsAsync(string organisationIdHash, CancellationToken cancellationToken = default);
[Log]
Task<Result<Notification[]>> GetUserNotificationsAsync(Guid userId, CancellationToken cancellationToken = default);
[Log] [Log]
Task<Result<Notification>> GetNotificationAsync(string notificationIdHash, CancellationToken cancellationToken = default); Task<Result<Notification>> GetNotificationAsync(string notificationIdHash, CancellationToken cancellationToken = default);
@@ -1,4 +1,4 @@
using PostFundManagement.Application.Services.Evidence; using PostFundManagement.Application.Services.Evidence;
using PostFundManagement.Domain; using PostFundManagement.Domain;
using PostFundManagement.Domain.Attributes; using PostFundManagement.Domain.Attributes;
using PostFundManagement.Domain.Models; using PostFundManagement.Domain.Models;
@@ -15,6 +15,10 @@ public interface IEvidenceService
[Audit] [Audit]
Task<Result> RecordIndicatorActualAmountAsync(string indicatorIdHash, decimal amount, Guid userId, CancellationToken cancellationToken = default); Task<Result> RecordIndicatorActualAmountAsync(string indicatorIdHash, decimal amount, Guid userId, CancellationToken cancellationToken = default);
[Log]
[Audit]
Task<Result> RecordIndicatorActualAmountAsync(string indicatorIdHash, decimal amount, decimal? actualCost, Guid userId, CancellationToken cancellationToken = default);
[Log] [Log]
Task<Result<Indicator[]>> GetIndicatorsAsync(Pagination pagination, CancellationToken cancellationToken = default); Task<Result<Indicator[]>> GetIndicatorsAsync(Pagination pagination, CancellationToken cancellationToken = default);
@@ -1,4 +1,4 @@
using PostFundManagement.Application.Services.Grants; using PostFundManagement.Application.Services.Grants;
using PostFundManagement.Domain; using PostFundManagement.Domain;
using PostFundManagement.Domain.Attributes; using PostFundManagement.Domain.Attributes;
using PostFundManagement.Domain.Models; using PostFundManagement.Domain.Models;
@@ -45,6 +45,10 @@ public interface IGrantsService
[Audit] [Audit]
Task<Result> CreateAwardAsync(CreateAwardRequest request, Guid userId, CancellationToken cancellationToken = default); Task<Result> CreateAwardAsync(CreateAwardRequest request, Guid userId, CancellationToken cancellationToken = default);
[Log]
[Audit]
Task<Result> UpdateAwardAsync(UpdateAwardRequest request, Guid userId, CancellationToken cancellationToken = default);
[Log] [Log]
[Audit] [Audit]
Task<Result<string>> GetContractUrlAsync(string contractIdHash, CancellationToken cancellationToken = default); Task<Result<string>> GetContractUrlAsync(string contractIdHash, CancellationToken cancellationToken = default);
@@ -1,4 +1,4 @@
using PostFundManagement.Application.Services.Projects; using PostFundManagement.Application.Services.Projects;
using PostFundManagement.Domain; using PostFundManagement.Domain;
using PostFundManagement.Domain.Attributes; using PostFundManagement.Domain.Attributes;
using PostFundManagement.Domain.Models; using PostFundManagement.Domain.Models;
@@ -28,6 +28,10 @@ public interface IProjectService
[Audit] [Audit]
Task<Result> CreateActivityAsync(CreateActivityRequest request, Guid userId, CancellationToken cancellationToken = default); Task<Result> CreateActivityAsync(CreateActivityRequest request, Guid userId, CancellationToken cancellationToken = default);
[Log]
[Audit]
Task<Result> UpdateActivityAsync(UpdateActivityRequest request, Guid userId, CancellationToken cancellationToken = default);
[Log] [Log]
Task<Result<Milestone[]>> GetProjectMilestonesAsync(string projectIdHash, CancellationToken cancellationToken = default); Task<Result<Milestone[]>> GetProjectMilestonesAsync(string projectIdHash, CancellationToken cancellationToken = default);
@@ -138,6 +142,9 @@ public interface IProjectService
[Log] [Log]
Task<Result<Outcome[]>> GetOutcomesAsync(string beneficiaryIdHash, CancellationToken cancellationToken = default); Task<Result<Outcome[]>> GetOutcomesAsync(string beneficiaryIdHash, CancellationToken cancellationToken = default);
[Log]
Task<Result<Outcome[]>> GetProjectOutcomesAsync(string projectIdHash, CancellationToken cancellationToken = default);
[Log] [Log]
Task<Result<Outcome>> GetOutcomeAsync(string outcomeIdHash, CancellationToken cancellationToken = default); Task<Result<Outcome>> GetOutcomeAsync(string outcomeIdHash, CancellationToken cancellationToken = default);
@@ -20,7 +20,7 @@ public static class Quartz
services.AddQuartz(config => services.AddQuartz(config =>
{ {
config.UseSimpleTypeLoader(); config.UseSimpleTypeLoader();
config.UseDefaultThreadPool(options => options.MaxConcurrency = 0); config.UseThreadPool<global::Quartz.Impl.ZeroSizeThreadPool>();
config.UseTimeZoneConverter(); config.UseTimeZoneConverter();
config.UsePersistentStore(storage => config.UsePersistentStore(storage =>
@@ -40,8 +40,10 @@ public class ClientCredentialsTokenHandler(ILogger<ClientCredentialsTokenHandler
if (!response.IsSuccessStatusCode) if (!response.IsSuccessStatusCode)
{ {
logger.LogWarning("HTTP request failed with status {StatusCode} for {Method} {RequestUri}", var content = await response.Content.ReadAsStringAsync(cancellationToken);
response.StatusCode, request.Method, request.RequestUri); logger.LogWarning("HTTP request failed with status {StatusCode} for {Method} {RequestUri}. Details: {Details}",
response.StatusCode, request.Method, request.RequestUri, content);
} }
return response; return response;
@@ -1,11 +1,33 @@
using PostFundManagement.Application.Services.Shared; using PostFundManagement.Application.Services.Shared;
using PostFundManagement.Domain.Attributes; using PostFundManagement.Domain.Attributes;
namespace PostFundManagement.Application.Interceptors; namespace PostFundManagement.Application.Interceptors;
public sealed class AuditInterceptor(AuditService auditService, IHttpContextAccessor httpContextAccessor) : IAsyncInterceptor public sealed class AuditInterceptor(AuditService auditService, IHttpContextAccessor httpContextAccessor) : IAsyncInterceptor
{ {
public void InterceptSynchronous(IInvocation invocation) => invocation.Proceed(); public void InterceptSynchronous(IInvocation invocation)
{
var method = invocation.MethodInvocationTarget ?? invocation.Method;
var auditAttr = method.GetCustomAttribute<AuditAttribute>()
?? invocation.Method.GetCustomAttribute<AuditAttribute>();
if (auditAttr == null)
{
invocation.Proceed();
return;
}
try
{
invocation.Proceed();
AuditAction(invocation, invocation.ReturnValue, null);
}
catch (Exception ex)
{
AuditAction(invocation, null, ex);
throw;
}
}
public void InterceptAsynchronous(IInvocation invocation) => public void InterceptAsynchronous(IInvocation invocation) =>
invocation.ReturnValue = InternalInterceptAsynchronous(invocation); invocation.ReturnValue = InternalInterceptAsynchronous(invocation);
@@ -13,49 +35,177 @@ public sealed class AuditInterceptor(AuditService auditService, IHttpContextAcce
public void InterceptAsynchronous<TResult>(IInvocation invocation) => public void InterceptAsynchronous<TResult>(IInvocation invocation) =>
invocation.ReturnValue = InternalInterceptAsynchronous<TResult>(invocation); invocation.ReturnValue = InternalInterceptAsynchronous<TResult>(invocation);
private static async Task InternalInterceptAsynchronous(IInvocation invocation) private async Task InternalInterceptAsynchronous(IInvocation invocation)
{ {
invocation.Proceed();
var task = (Task)invocation.ReturnValue;
await task.ConfigureAwait(false);
}
private async Task<TResult> InternalInterceptAsynchronous<TResult>(IInvocation invocation)
{
invocation.Proceed();
var task = (Task<TResult>)invocation.ReturnValue;
var resultObj = await task.ConfigureAwait(false);
var method = invocation.MethodInvocationTarget ?? invocation.Method; var method = invocation.MethodInvocationTarget ?? invocation.Method;
var auditAttr = method.GetCustomAttribute<AuditAttribute>() var auditAttr = method.GetCustomAttribute<AuditAttribute>()
?? invocation.Method.GetCustomAttribute<AuditAttribute>(); ?? invocation.Method.GetCustomAttribute<AuditAttribute>();
if (auditAttr == null) return resultObj; if (auditAttr == null)
if (resultObj is IResultBase result && result.IsSuccess)
{ {
try invocation.Proceed();
{ var t = (Task)invocation.ReturnValue;
var action = ResolveActionName(method.Name); await t.ConfigureAwait(false);
var entityName = ResolveEntityName(method, resultObj, invocation.Arguments); return;
var entityId = ResolveEntityId(resultObj, invocation.Arguments); }
var changes = BuildPayloadSummary(invocation.Method.GetParameters(), invocation.Arguments);
var performedBy = ResolveUserId();
try
{
invocation.Proceed();
var task = (Task)invocation.ReturnValue;
await task.ConfigureAwait(false);
await AuditActionAsync(invocation, null, null).ConfigureAwait(false);
}
catch (Exception ex)
{
await AuditActionAsync(invocation, null, ex).ConfigureAwait(false);
throw;
}
}
private async Task<TResult> InternalInterceptAsynchronous<TResult>(IInvocation invocation)
{
var method = invocation.MethodInvocationTarget ?? invocation.Method;
var auditAttr = method.GetCustomAttribute<AuditAttribute>()
?? invocation.Method.GetCustomAttribute<AuditAttribute>();
if (auditAttr == null)
{
invocation.Proceed();
var t = (Task<TResult>)invocation.ReturnValue;
return await t.ConfigureAwait(false);
}
TResult resultObj;
try
{
invocation.Proceed();
var task = (Task<TResult>)invocation.ReturnValue;
resultObj = await task.ConfigureAwait(false);
}
catch (Exception ex)
{
await AuditActionAsync(invocation, null, ex).ConfigureAwait(false);
throw;
}
await AuditActionAsync(invocation, resultObj, null).ConfigureAwait(false);
return resultObj;
}
private async Task AuditActionAsync(IInvocation invocation, object? resultObj, Exception? exception)
{
try
{
var method = invocation.MethodInvocationTarget ?? invocation.Method;
var action = ResolveActionName(method.Name);
var entityName = ResolveEntityName(method, resultObj, invocation.Arguments);
var entityId = ResolveEntityId(resultObj, invocation.Arguments);
var changes = BuildPayloadSummary(method.GetParameters(), invocation.Arguments);
var performedBy = ResolveUserId();
var tenantId = ResolveTenantId(invocation.Arguments);
if (exception != null)
{
var errorDetails = $"[Exception] {exception.GetType().Name}: {exception.Message}\n{exception.StackTrace}";
await auditService.LogActionAsync( await auditService.LogActionAsync(
entityId: entityId, entityId: entityId,
entityName: entityName, entityName: entityName,
action: action, action: action,
changes: changes, changes: changes,
performedBy: performedBy).ConfigureAwait(false); performedBy: performedBy,
error: errorDetails,
tenantId: tenantId).ConfigureAwait(false);
return;
} }
catch { /* eat all exceptions, don't interrupt program flow */ }
}
return resultObj; if (resultObj is IResultBase result)
{
if (result.IsSuccess)
{
await auditService.LogActionAsync(
entityId: entityId,
entityName: entityName,
action: action,
changes: changes,
performedBy: performedBy,
error: null,
tenantId: tenantId).ConfigureAwait(false);
}
else if (result.IsFailed)
{
var errorMessages = new List<string>();
if (result.Reasons != null)
{
foreach (var reason in result.Reasons)
{
if (reason is IExceptionalError exceptionalError)
errorMessages.Add($"{exceptionalError.Message} ({exceptionalError.Exception.GetType().Name}: {exceptionalError.Exception.Message})\n{exceptionalError.Exception.StackTrace}");
else if (reason is IError error)
errorMessages.Add(error.Message);
}
}
var rawErrorType = result.Reasons?.FirstOrDefault()?.GetType().Name.Replace("Error", string.Empty);
var errorType = string.IsNullOrWhiteSpace(rawErrorType) || rawErrorType == "Reason" ? "Error" : rawErrorType;
var errorsString = string.Join("; ", errorMessages);
var errorDetails = $"[{errorType}] {errorsString}";
await auditService.LogActionAsync(
entityId: entityId,
entityName: entityName,
action: action,
changes: changes,
performedBy: performedBy,
error: errorDetails,
tenantId: tenantId).ConfigureAwait(false);
}
}
else
{
await auditService.LogActionAsync(
entityId: entityId,
entityName: entityName,
action: action,
changes: changes,
performedBy: performedBy,
error: null,
tenantId: tenantId).ConfigureAwait(false);
}
}
catch
{
/* eat all exceptions, don't interrupt program flow */
}
}
private void AuditAction(IInvocation invocation, object? resultObj, Exception? exception)
{
try
{
AuditActionAsync(invocation, resultObj, exception).GetAwaiter().GetResult();
}
catch
{
/* eat all exceptions, don't interrupt program flow */
}
}
private static long? ResolveTenantId(object[] args)
{
foreach (var arg in args)
{
if (arg == null || arg is CancellationToken or IFormFile) continue;
var prop = arg.GetType().GetProperty("TenantId");
if (prop != null)
{
var val = prop.GetValue(arg);
if (val is long l && l > 0) return l;
if (val is int i && i > 0) return i;
if (val is string s && long.TryParse(s, out var parsed) && parsed > 0) return parsed;
}
}
return null;
} }
private static string ResolveActionName(string methodName) => private static string ResolveActionName(string methodName) =>
@@ -73,7 +223,7 @@ public sealed class AuditInterceptor(AuditService auditService, IHttpContextAcce
{ {
var genericDef = returnType.GetGenericTypeDefinition(); var genericDef = returnType.GetGenericTypeDefinition();
if (genericDef == typeof(Task<>) || genericDef == typeof(Task<>)) if (genericDef == typeof(Task<>))
innerType = returnType.GetGenericArguments()[0]; innerType = returnType.GetGenericArguments()[0];
} }
@@ -1,105 +1,29 @@
<Project Sdk="Microsoft.NET.Sdk"> <Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<TargetFramework>net10.0</TargetFramework>
<ImplicitUsings>enable</ImplicitUsings>
<Nullable>enable</Nullable>
<AssemblyOriginatorKeyFile>..\PostFundManagement.snk</AssemblyOriginatorKeyFile>
<SignAssembly>True</SignAssembly>
<PackageLicenseFile>..\LICENSE</PackageLicenseFile>
</PropertyGroup>
<PropertyGroup Condition="'$(Configuration)|$(Platform)'=='Debug|AnyCPU'">
<NoWarn>1701;1702;AV0029;AV0030;</NoWarn>
</PropertyGroup>
<PropertyGroup Condition="'$(Configuration)|$(Platform)'=='Release|AnyCPU'">
<NoWarn>1701;1702;AV0029;AV0030;</NoWarn>
</PropertyGroup>
<ItemGroup> <ItemGroup>
<ProjectReference Include="..\PostFundManagement.Domain\PostFundManagement.Domain.csproj" /> <ProjectReference Include="..\PostFundManagement.Domain\PostFundManagement.Domain.csproj" />
<ProjectReference Include="..\PostFundManagement.Infrastructure\PostFundManagement.Infrastructure.csproj" /> <ProjectReference Include="..\PostFundManagement.Infrastructure\PostFundManagement.Infrastructure.csproj" />
</ItemGroup> </ItemGroup>
<!-- API Documentation --> <!-- Dependencies -->
<ItemGroup> <ItemGroup>
<PackageReference Include="Castle.Core.AsyncInterceptor" Version="2.1.0" /> <PackageReference Include="Castle.Core.AsyncInterceptor" />
<PackageReference Include="Duende.AccessTokenManagement" Version="4.2.0" /> <PackageReference Include="Duende.AccessTokenManagement" />
<PackageReference Include="Duende.AccessTokenManagement.OpenIdConnect" Version="4.2.0" /> <PackageReference Include="Duende.AccessTokenManagement.OpenIdConnect" />
<PackageReference Include="Microsoft.AspNetCore.OpenApi" Version="10.0.12" /> <PackageReference Include="Microsoft.AspNetCore.OpenApi" />
<PackageReference Include="Microsoft.Extensions.Validation" Version="10.0.12" /> <PackageReference Include="Microsoft.Extensions.Validation" />
<PackageReference Include="Scalar.AspNetCore" Version="2.17.4" /> <PackageReference Include="Scalar.AspNetCore" />
<Using Include="Scalar.AspNetCore" />
<Using Include="Microsoft.OpenApi" />
<Using Include="Microsoft.AspNetCore.OpenApi" />
</ItemGroup> </ItemGroup>
<!-- Shared Static Usings --> <!-- Project-specific Usings -->
<ItemGroup> <ItemGroup>
<Using Static="true" Include="PostFundManagement.Application.Extensions.General" />
<Using Static="true" Include="PostFundManagement.Domain.Extensions.Constants" />
</ItemGroup>
<!-- Shared Usings -->
<ItemGroup>
<Using Include="Humanizer" />
<Using Include="Duende.AccessTokenManagement.OpenIdConnect" />
<Using Include="Duende.AccessTokenManagement" />
<Using Include="Castle.DynamicProxy" /> <Using Include="Castle.DynamicProxy" />
<Using Include="Microsoft.FeatureManagement" /> <Using Include="Duende.AccessTokenManagement" />
<Using Include="System.ComponentModel.DataAnnotations" /> <Using Include="Duende.AccessTokenManagement.OpenIdConnect" />
<Using Include="Microsoft.Extensions.Caching.Distributed" /> <Using Include="Microsoft.Extensions.Caching.Distributed" />
<Using Include="Microsoft.Extensions.Caching.Hybrid" /> <Using Include="Microsoft.Extensions.Caching.Hybrid" />
<Using Include="Microsoft.FeatureManagement" />
<Using Include="System.IdentityModel.Tokens.Jwt" /> <Using Include="System.IdentityModel.Tokens.Jwt" />
<Using Include="Asp.Versioning" />
<Using Include="System.Security.Claims" />
<Using Include="System.Diagnostics" />
<Using Include="MimeKit" />
<Using Include="MailKit.Net.Smtp" />
<Using Include="Quartz" />
<Using Include="Refit" />
<Using Include="AccessTokenClient" />
<Using Include="Microsoft.AspNetCore.Authentication" />
<Using Include="Microsoft.AspNetCore.Authentication.OpenIdConnect" />
<Using Include="Microsoft.AspNetCore.Authentication.Cookies" />
<Using Include="IdentityModel.AspNetCore.OAuth2Introspection" />
<Using Include="Microsoft.AspNetCore.Authentication.JwtBearer" />
<Using Include="Microsoft.Extensions.Configuration" />
<Using Include="Amazon.S3" />
<Using Include="Amazon.S3.Model" />
<Using Include="Amazon.Runtime" />
<Using Include="Microsoft.AspNetCore.DataProtection.EntityFrameworkCore" />
<Using Include="Microsoft.EntityFrameworkCore" />
<Using Include="Microsoft.EntityFrameworkCore.Design" />
<Using Include="Microsoft.EntityFrameworkCore.Metadata.Builders" />
<Using Include="Mediator" />
<Using Include="FluentResults" />
<Using Include="Microsoft.AspNetCore.DataProtection" />
<Using Include="System.Security.Cryptography.X509Certificates" />
<Using Include="Microsoft.AspNetCore.Components.Server.ProtectedBrowserStorage" />
<Using Include="System.Text.Json.Serialization" />
<Using Include="System.Reflection" />
<Using Include="Microsoft.Extensions.DependencyInjection.Extensions" />
<Using Include="Microsoft.AspNetCore.Routing" />
<Using Include="System.Web" />
<Using Include="Microsoft.IdentityModel.Tokens" />
<Using Include="Microsoft.AspNetCore.Http" />
<Using Include="HashidsNet" />
<Using Include="System.Net" />
<Using Include="System.Text.RegularExpressions" />
<Using Include="System.Globalization" />
<Using Include="Microsoft.AspNetCore.Builder" />
<Using Include="Microsoft.Extensions.Hosting" />
<Using Include="System.Text" />
<Using Include="System.Text.Json" />
<Using Include="System.Threading.Channels" />
<Using Include="System.Collections.ObjectModel" />
<Using Include="Microsoft.Extensions.DependencyInjection" />
<Using Include="System.Security.Cryptography" />
<Using Include="Microsoft.Extensions.Options" />
<Using Include="Microsoft.Extensions.Logging" />
</ItemGroup> </ItemGroup>
</Project> </Project>
@@ -1,4 +1,4 @@
using PostFundManagement.Application.Abstractions.Services; using PostFundManagement.Application.Abstractions.Services;
using PostFundManagement.Application.Extensions; using PostFundManagement.Application.Extensions;
using PostFundManagement.Application.Services.Shared; using PostFundManagement.Application.Services.Shared;
using PostFundManagement.Domain; using PostFundManagement.Domain;
@@ -152,6 +152,9 @@ public sealed class CommunicationsCacheService(CommunicationsService service, ID
return Result.Ok(cached.FromJson<Notification[]>()); return Result.Ok(cached.FromJson<Notification[]>());
})); }));
public async Task<Result<Notification[]>> GetUserNotificationsAsync(Guid userId, CancellationToken cancellationToken = default) =>
await service.GetUserNotificationsAsync(userId, cancellationToken);
public async Task<Result<Invite>> SendInviteAsync(InviteRequest request, Guid userId, CancellationToken cancellationToken = default) => public async Task<Result<Invite>> SendInviteAsync(InviteRequest request, Guid userId, CancellationToken cancellationToken = default) =>
await service.SendInviteAsync(request, userId, cancellationToken); await service.SendInviteAsync(request, userId, cancellationToken);
@@ -1,4 +1,4 @@
using PostFundManagement.Application.Abstractions.Services; using PostFundManagement.Application.Abstractions.Services;
using PostFundManagement.Application.Services.Shared; using PostFundManagement.Application.Services.Shared;
using PostFundManagement.Domain; using PostFundManagement.Domain;
using PostFundManagement.Domain.Abstractions; using PostFundManagement.Domain.Abstractions;
@@ -94,6 +94,8 @@ public sealed class CommunicationsService(IDbContextFactory<ApplicationDbContext
{ {
await using var context = await contextFactory.CreateDbContextAsync(cancellationToken); await using var context = await contextFactory.CreateDbContextAsync(cancellationToken);
var initialStatus = request.Status ?? (request.Platform == NotificationPlatform.InApp ? NotificationStatus.Delivered : NotificationStatus.Queued);
context.Notifications.Add(new Domain.Entities.Notification context.Notifications.Add(new Domain.Entities.Notification
{ {
CreatedAt = DateTime.UtcNow, CreatedAt = DateTime.UtcNow,
@@ -103,7 +105,7 @@ public sealed class CommunicationsService(IDbContextFactory<ApplicationDbContext
Subject = request.Subject, Subject = request.Subject,
Message = request.Message, Message = request.Message,
Platform = request.Platform, Platform = request.Platform,
Status = NotificationStatus.Queued, Status = initialStatus,
TenantId = tenantContext.TenantId TenantId = tenantContext.TenantId
}); });
@@ -116,16 +118,17 @@ public sealed class CommunicationsService(IDbContextFactory<ApplicationDbContext
{ {
await using var context = await contextFactory.CreateDbContextAsync(cancellationToken); await using var context = await contextFactory.CreateDbContextAsync(cancellationToken);
var changedTotal = await context.Notifications var entity = await context.Notifications.FirstOrDefaultAsync(n => n.Id == request.NotificationId, cancellationToken);
.Where(n => n.Id == request.NotificationId) if (entity is null)
.ExecuteUpdateAsync(f => f return Result.Fail(NotFoundError.Create($"Notification #{request.NotificationId} not found"));
.SetProperty(u => u.Destination, request.Destination)
.SetProperty(u => u.SentAt, request.SentAt)
.SetProperty(u => u.Error, request.Error)
.SetProperty(u => u.UpdatedAt, DateTime.UtcNow)
.SetProperty(u => u.Status, request.Status), cancellationToken);
return Result.OkIf(changedTotal > 0, UpdateError.Create($"Failed to update notification: {request.NotificationId}, contact support")); entity.Status = request.Status;
entity.UpdatedAt = DateTime.UtcNow;
if (request.SentAt.HasValue) entity.SentAt = request.SentAt.Value;
if (!string.IsNullOrWhiteSpace(request.Destination)) entity.Destination = request.Destination;
if (!string.IsNullOrWhiteSpace(request.Error)) entity.Error = request.Error;
return Result.OkIf(await context.SaveChangesAsync(cancellationToken) > 0, UpdateError.Create($"Failed to update notification: {request.NotificationId}, contact support"));
})); }));
public async Task<Result<Notification[]>> GetNotificationsAsync(string organisationIdHash, CancellationToken cancellationToken = default) => public async Task<Result<Notification[]>> GetNotificationsAsync(string organisationIdHash, CancellationToken cancellationToken = default) =>
@@ -147,6 +150,19 @@ public sealed class CommunicationsService(IDbContextFactory<ApplicationDbContext
return Result.Ok(notifications?.Select(i => i.Map()).ToArray() ?? []); return Result.Ok(notifications?.Select(i => i.Map()).ToArray() ?? []);
})); }));
public async Task<Result<Notification[]>> GetUserNotificationsAsync(Guid userId, CancellationToken cancellationToken = default) =>
await Result.Try((Func<Task<Result<Notification[]>>>)(async () =>
{
await using var context = await contextFactory.CreateDbContextAsync(cancellationToken);
var notifications = await context.Notifications.AsNoTracking()
.Where(n => n.Recipient == userId)
.OrderByDescending(o => o.CreatedAt)
.ToListAsync(cancellationToken);
return Result.Ok(notifications?.Select(i => i.Map()).ToArray() ?? []);
}));
public async Task<Result<Notification>> GetNotificationAsync(string notificationIdHash, CancellationToken cancellationToken = default) => public async Task<Result<Notification>> GetNotificationAsync(string notificationIdHash, CancellationToken cancellationToken = default) =>
await Result.Try((Func<Task<Result<Notification>>>)(async () => await Result.Try((Func<Task<Result<Notification>>>)(async () =>
{ {
@@ -1,23 +1,26 @@
using PostFundManagement.Domain; using PostFundManagement.Domain;
namespace PostFundManagement.Application.Services.Communications; namespace PostFundManagement.Application.Services.Communications;
public sealed record UpdateNotificationRequest([Required] long NotificationId, string? Destination, public sealed record UpdateNotificationRequest([Required] long NotificationId,
[Required] NotificationStatus Status, string? Destination = null,
[Required] DateTime SentAt, [Required] NotificationStatus Status = NotificationStatus.Read,
[MaxLength(TextLength)] string? Error); DateTime? SentAt = null,
[MaxLength(TextLength)] string? Error = null);
public sealed record CreateNotificationRequest([Required] long OrganisationId, public sealed record CreateNotificationRequest([Required] long OrganisationId,
[Required] Guid Recipient, [Required] Guid Recipient,
[Required] NotificationPlatform Platform, [Required] NotificationPlatform Platform,
[Required][MaxLength(LabelLength)] string? Subject, [Required][MaxLength(LabelLength)] string? Subject,
[Required][MaxLength(LabelLength)] string? Destination, [Required][MaxLength(LabelLength)] string? Destination,
[Required][MaxLength(TextLength)] string Message); [Required][MaxLength(LargeTextLength)] string Message,
NotificationStatus? Status = null);
public sealed record InviteRequest([Required] long OrganisationId, public sealed record InviteRequest([Required] long OrganisationId,
[Required] long InvitedOrganisationId, [Required] long InvitedOrganisationId,
[Required] long AwardId, [Required] long AwardId,
[Required] long ContractId, [Required] long ContractId,
[Required] long ProjectId,
[Required] Guid Recipient, [Required] Guid Recipient,
[Required][MaxLength(LabelLength)] string Message, [Required][MaxLength(LabelLength)] string Message,
[Required] NotificationPlatform Platform); [Required] NotificationPlatform Platform);
@@ -1,4 +1,4 @@
using PostFundManagement.Application.Abstractions.Services; using PostFundManagement.Application.Abstractions.Services;
using PostFundManagement.Application.Extensions; using PostFundManagement.Application.Extensions;
using PostFundManagement.Application.Services.Shared; using PostFundManagement.Application.Services.Shared;
using PostFundManagement.Domain; using PostFundManagement.Domain;
@@ -189,6 +189,9 @@ public sealed class EvidenceCacheService(EvidenceService service, IDistributedCa
public async Task<Result> RecordIndicatorActualAmountAsync(string indicatorIdHash, decimal amount, Guid userId, CancellationToken cancellationToken = default) => public async Task<Result> RecordIndicatorActualAmountAsync(string indicatorIdHash, decimal amount, Guid userId, CancellationToken cancellationToken = default) =>
await service.RecordIndicatorActualAmountAsync(indicatorIdHash, amount, userId, cancellationToken); await service.RecordIndicatorActualAmountAsync(indicatorIdHash, amount, userId, cancellationToken);
public async Task<Result> RecordIndicatorActualAmountAsync(string indicatorIdHash, decimal amount, decimal? actualCost, Guid userId, CancellationToken cancellationToken = default) =>
await service.RecordIndicatorActualAmountAsync(indicatorIdHash, amount, actualCost, userId, cancellationToken);
public async Task<Result> UpdateEvidenceAsync(UpdateEvidenceRequest request, Guid userId, CancellationToken cancellationToken = default) => public async Task<Result> UpdateEvidenceAsync(UpdateEvidenceRequest request, Guid userId, CancellationToken cancellationToken = default) =>
await service.UpdateEvidenceAsync(request, userId, cancellationToken); await service.UpdateEvidenceAsync(request, userId, cancellationToken);
} }
@@ -1,4 +1,4 @@
using PostFundManagement.Application.Abstractions.Services; using PostFundManagement.Application.Abstractions.Services;
using PostFundManagement.Application.Services.Shared; using PostFundManagement.Application.Services.Shared;
using PostFundManagement.Domain; using PostFundManagement.Domain;
using PostFundManagement.Domain.Abstractions; using PostFundManagement.Domain.Abstractions;
@@ -50,6 +50,9 @@ public sealed class EvidenceService(IDbContextFactory<ApplicationDbContext> cont
})); }));
public async Task<Result> RecordIndicatorActualAmountAsync(string indicatorIdHash, decimal amount, Guid userId, CancellationToken cancellationToken = default) => public async Task<Result> RecordIndicatorActualAmountAsync(string indicatorIdHash, decimal amount, Guid userId, CancellationToken cancellationToken = default) =>
await RecordIndicatorActualAmountAsync(indicatorIdHash, amount, null, userId, cancellationToken);
public async Task<Result> RecordIndicatorActualAmountAsync(string indicatorIdHash, decimal amount, decimal? actualCost, Guid userId, CancellationToken cancellationToken = default) =>
await Result.Try((Func<Task<Result>>)(async () => await Result.Try((Func<Task<Result>>)(async () =>
{ {
var decodeResult = hashService.DecodeLongIdHash(indicatorIdHash); var decodeResult = hashService.DecodeLongIdHash(indicatorIdHash);
@@ -63,6 +66,7 @@ public sealed class EvidenceService(IDbContextFactory<ApplicationDbContext> cont
var changedTotal = await context.Indicators.Where(i => i.Id == decodeResult.Value) var changedTotal = await context.Indicators.Where(i => i.Id == decodeResult.Value)
.ExecuteUpdateAsync(f => f .ExecuteUpdateAsync(f => f
.SetProperty(f => f.ActualAmount, amount) .SetProperty(f => f.ActualAmount, amount)
.SetProperty(f => f.ActualCost, f => actualCost.HasValue ? actualCost.Value : f.ActualCost)
.SetProperty(f => f.CreatedBy, userId), cancellationToken); .SetProperty(f => f.CreatedBy, userId), cancellationToken);
return Result.OkIf(changedTotal > 0, UpdateError.Create("Failed to update indicator.")); return Result.OkIf(changedTotal > 0, UpdateError.Create("Failed to update indicator."));
@@ -130,14 +134,21 @@ public sealed class EvidenceService(IDbContextFactory<ApplicationDbContext> cont
if (!await context.Projects.AnyAsync(p => p.Id == request.ProjectId, cancellationToken)) if (!await context.Projects.AnyAsync(p => p.Id == request.ProjectId, cancellationToken))
return Result.Fail(NotFoundError.New); return Result.Fail(NotFoundError.New);
if (request.ActivityId.HasValue && !await context.Activities.AnyAsync(a => a.Id == request.ActivityId.Value && a.ProjectId == request.ProjectId, cancellationToken))
return Result.Fail(NotFoundError.Create("Activity not found"));
context.Indicators.Add(new Domain.Entities.Indicator context.Indicators.Add(new Domain.Entities.Indicator
{ {
ProjectId = request.ProjectId, ProjectId = request.ProjectId,
ActivityId = request.ActivityId,
Name = request.Name, Name = request.Name,
UnitOfMeasure = request.UnitOfMeasure, UnitOfMeasure = request.UnitOfMeasure,
BaselineAmount = request.BaselineAmount, BaselineAmount = request.BaselineAmount,
TargetAmount = request.TargetAmount, TargetAmount = request.TargetAmount,
ActualAmount = request.BaselineAmount, ActualAmount = request.BaselineAmount,
TargetCost = request.TargetCost,
ActualCost = 0,
CreatedBy = userId, CreatedBy = userId,
CreatedAt = DateTime.UtcNow CreatedAt = DateTime.UtcNow
}); });
@@ -1,4 +1,4 @@
using PostFundManagement.Domain; using PostFundManagement.Domain;
namespace PostFundManagement.Application.Services.Evidence; namespace PostFundManagement.Application.Services.Evidence;
@@ -7,7 +7,7 @@ public sealed record UpdateEvidenceRequest([Required] long EvidenceId,
[Required] EvidenceType EvidenceType, [Required] EvidenceType EvidenceType,
[Required] ApprovalStatus Status); [Required] ApprovalStatus Status);
public sealed record RecordActualRequest([Required] decimal ActualAmount); public sealed record RecordActualRequest([Required] decimal ActualAmount, decimal? ActualCost = null);
public sealed record CreateMilestoneRequest([Required] long ProjectId, public sealed record CreateMilestoneRequest([Required] long ProjectId,
[Required] DateTime DueAt, [Required] DateTime DueAt,
@@ -18,4 +18,6 @@ public sealed record CreateIndicatorRequest([Required] long ProjectId,
[Required][MaxLength(LabelLength)] string Name, [Required][MaxLength(LabelLength)] string Name,
[Required] UnitOfMeasure UnitOfMeasure, [Required] UnitOfMeasure UnitOfMeasure,
[Required] decimal BaselineAmount, [Required] decimal BaselineAmount,
[Required] decimal TargetAmount); [Required] decimal TargetAmount,
long? ActivityId = null,
decimal TargetCost = 0);
@@ -94,6 +94,16 @@ public sealed class ExecutionService(IDbContextFactory<ApplicationDbContext> con
await using var context = await contextFactory.CreateDbContextAsync(cancellationToken); await using var context = await contextFactory.CreateDbContextAsync(cancellationToken);
var disbursement = await context.Disbursements.AsNoTracking()
.FirstOrDefaultAsync(d => d.Id == decodeResult.Value, cancellationToken);
if (disbursement is null)
return Result.Fail(NotFoundError.Create("Disbursement not found."));
var isAwardApproved = await context.Awards
.AnyAsync(a => a.ProjectId == disbursement.ProjectId && a.Status == ApprovalStatus.Approved, cancellationToken);
if (!isAwardApproved)
return Result.Fail(ValidationError.Create("Cannot approve disbursement: The project's linked award is not approved or does not exist."));
var changedTotal = await context.Disbursements.Where(d => d.Id == decodeResult.Value) var changedTotal = await context.Disbursements.Where(d => d.Id == decodeResult.Value)
.ExecuteUpdateAsync(f => f .ExecuteUpdateAsync(f => f
.SetProperty(f => f.Status, ApprovalStatus.Approved) .SetProperty(f => f.Status, ApprovalStatus.Approved)
@@ -182,7 +182,7 @@ public sealed class GovernanceService(IDbContextFactory<ApplicationDbContext> co
if (!await context.Users.AnyAsync(u => u.Id == request.OrganisationUser, cancellationToken)) if (!await context.Users.AnyAsync(u => u.Id == request.OrganisationUser, cancellationToken))
return Result.Fail(ValidationError.Create("Organisation user not found")); 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")); return Result.Fail(ValidationError.Create("Organisation user does not belong to the current tenant"));
var changedTotal = await context.Organisations 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.RegistrationNo, request.RegistrationNo.Clean())
.SetProperty(u => u.Email, request.Email.ToLower(CultureInfo.InvariantCulture).Clean()) .SetProperty(u => u.Email, request.Email.ToLower(CultureInfo.InvariantCulture).Clean())
.SetProperty(u => u.Type, request.Type) .SetProperty(u => u.Type, request.Type)
.SetProperty(u => u.UserId, request.OrganisationUser)
.SetProperty(u => u.Status, request.Status), cancellationToken); .SetProperty(u => u.Status, request.Status), cancellationToken);
var tenantOrganisation = await context.TenantOrganisations.AsNoTracking().FirstOrDefaultAsync(f => f.OrganisationId == request.OrganisationId, cancellationToken); var tenantOrganisation = await context.TenantOrganisations.AsNoTracking().FirstOrDefaultAsync(f => f.OrganisationId == request.OrganisationId, cancellationToken);
@@ -12,6 +12,9 @@ public sealed class GrantsCacheService(GrantsService service, IDistributedCache
public async Task<Result> CreateAwardAsync(CreateAwardRequest request, Guid userId, CancellationToken cancellationToken = default) => public async Task<Result> CreateAwardAsync(CreateAwardRequest request, Guid userId, CancellationToken cancellationToken = default) =>
await service.CreateAwardAsync(request, userId, cancellationToken); await service.CreateAwardAsync(request, userId, cancellationToken);
public async Task<Result> UpdateAwardAsync(UpdateAwardRequest request, Guid userId, CancellationToken cancellationToken = default) =>
await service.UpdateAwardAsync(request, userId, cancellationToken);
public async Task<Result> CreateBudgetAsync(CreateBudgetRequest request, Guid userId, CancellationToken cancellationToken = default) => public async Task<Result> CreateBudgetAsync(CreateBudgetRequest request, Guid userId, CancellationToken cancellationToken = default) =>
await service.CreateBudgetAsync(request, userId, cancellationToken); await service.CreateBudgetAsync(request, userId, cancellationToken);
@@ -206,6 +206,44 @@ public sealed class GrantsService(IDbContextFactory<ApplicationDbContext> contex
return Result.OkIf(await context.SaveChangesAsync(cancellationToken) > 0, CreateError.Create("Failed to create award")); return Result.OkIf(await context.SaveChangesAsync(cancellationToken) > 0, CreateError.Create("Failed to create award"));
})); }));
public async Task<Result> UpdateAwardAsync(UpdateAwardRequest request, Guid userId, CancellationToken cancellationToken = default) =>
await Result.Try((Func<Task<Result>>)(async () =>
{
if (request.Amount <= 0)
return Result.Fail(ValidationError.Create("Approved amount must be greater than zero."));
await using var context = await contextFactory.CreateDbContextAsync(cancellationToken);
var existingAward = await context.Awards.FirstOrDefaultAsync(a => a.Id == request.AwardId, cancellationToken);
if (existingAward is null)
return Result.Fail(NotFoundError.Create("Award not found"));
if (existingAward.Status == ApprovalStatus.Approved)
return Result.Fail(ValidationError.Create("Approved awards are immutable and cannot be updated."));
if (!await context.Organisations.AnyAsync(o => o.Id == request.OrganisationId, cancellationToken))
return Result.Fail(NotFoundError.Create("Organisation not found"));
if (!await context.Programmes.AnyAsync(p => p.Id == request.ProgrammeId, cancellationToken))
return Result.Fail(NotFoundError.Create("Programme not found"));
existingAward.OrganisationId = request.OrganisationId;
existingAward.ProgrammeId = request.ProgrammeId;
existingAward.Amount = request.Amount;
if (request.Status.HasValue)
{
existingAward.Status = request.Status.Value;
if (request.Status.Value == ApprovalStatus.Approved)
{
existingAward.ApprovedBy = userId;
}
}
existingAward.UpdatedBy = userId;
existingAward.UpdatedAt = DateTime.UtcNow;
return Result.OkIf(await context.SaveChangesAsync(cancellationToken) > 0, UpdateError.Create("Failed to update award"));
}));
public async Task<Result<string>> GetContractUrlAsync(string contractIdHash, CancellationToken cancellationToken = default) => public async Task<Result<string>> GetContractUrlAsync(string contractIdHash, CancellationToken cancellationToken = default) =>
await Result.Try((Func<Task<Result<string>>>)(async () => await Result.Try((Func<Task<Result<string>>>)(async () =>
{ {
@@ -1,4 +1,4 @@
using PostFundManagement.Domain; using PostFundManagement.Domain;
namespace PostFundManagement.Application.Services.Grants; namespace PostFundManagement.Application.Services.Grants;
@@ -8,6 +8,8 @@ public sealed record UpdateContractRequest([Required] long ContractId, [Required
public sealed record CreateAwardRequest([Required] long OrganisationId, [Required] long ProgrammeId, [Required] decimal Amount); public sealed record CreateAwardRequest([Required] long OrganisationId, [Required] long ProgrammeId, [Required] decimal Amount);
public sealed record UpdateAwardRequest([Required] long AwardId, [Required] long OrganisationId, [Required] long ProgrammeId, [Required] decimal Amount, ApprovalStatus? Status);
public sealed record CreateBudgetRequest([Required] long ProjectId, public sealed record CreateBudgetRequest([Required] long ProjectId,
[Required][MaxLength(LabelLength)] string Name, [Required][MaxLength(LabelLength)] string Name,
[Required] decimal Amount); [Required] decimal Amount);
@@ -1,4 +1,4 @@
using PostFundManagement.Application.Abstractions.Services; using PostFundManagement.Application.Abstractions.Services;
using PostFundManagement.Application.Extensions; using PostFundManagement.Application.Extensions;
using PostFundManagement.Application.Services.Shared; using PostFundManagement.Application.Services.Shared;
using PostFundManagement.Domain; using PostFundManagement.Domain;
@@ -12,6 +12,9 @@ public sealed class ProjectCacheService(ProjectService service, IDistributedCach
public async Task<Result> CreateActivityAsync(CreateActivityRequest request, Guid userId, CancellationToken cancellationToken = default) => public async Task<Result> CreateActivityAsync(CreateActivityRequest request, Guid userId, CancellationToken cancellationToken = default) =>
await service.CreateActivityAsync(request, userId, cancellationToken); await service.CreateActivityAsync(request, userId, cancellationToken);
public async Task<Result> UpdateActivityAsync(UpdateActivityRequest request, Guid userId, CancellationToken cancellationToken = default) =>
await service.UpdateActivityAsync(request, userId, cancellationToken);
public async Task<Result> CreateBeneficiaryAsync(CreateBeneficiaryRequest request, Guid userId, CancellationToken cancellationToken = default) => public async Task<Result> CreateBeneficiaryAsync(CreateBeneficiaryRequest request, Guid userId, CancellationToken cancellationToken = default) =>
await service.CreateBeneficiaryAsync(request, userId, cancellationToken); await service.CreateBeneficiaryAsync(request, userId, cancellationToken);
@@ -372,6 +375,30 @@ public sealed class ProjectCacheService(ProjectService service, IDistributedCach
return Result.Ok(cached.FromJson<Outcome[]>()); return Result.Ok(cached.FromJson<Outcome[]>());
})); }));
public async Task<Result<Outcome[]>> GetProjectOutcomesAsync(string projectIdHash, CancellationToken cancellationToken = default) =>
await Result.Try((Func<Task<Result<Outcome[]>>>)(async () =>
{
if (!await featureManager.IsEnabledAsync(ServiceCachingFeature))
return await service.GetProjectOutcomesAsync(projectIdHash, cancellationToken);
var cacheKey = HashService.ToMd5Hash($"getprojectoutcomes-{projectIdHash}").Value;
var cached = await cache.GetStringAsync(cacheKey, cancellationToken);
if (string.IsNullOrWhiteSpace(cached))
{
var result = await service.GetProjectOutcomesAsync(projectIdHash, cancellationToken);
if (result.IsFailed) return result;
await cache.SetStringAsync(cacheKey, result.Value.ToJson(), cacheOptions, cancellationToken);
return result.Value;
}
return Result.Ok(cached.FromJson<Outcome[]>());
}));
public async Task<Result<Programme>> GetProgrammeAsync(string programmeIdHash, CancellationToken cancellationToken = default) => public async Task<Result<Programme>> GetProgrammeAsync(string programmeIdHash, CancellationToken cancellationToken = default) =>
await Result.Try((Func<Task<Result<Programme>>>)(async () => await Result.Try((Func<Task<Result<Programme>>>)(async () =>
{ {
@@ -1,4 +1,4 @@
using PostFundManagement.Application.Abstractions.Services; using PostFundManagement.Application.Abstractions.Services;
using PostFundManagement.Application.Services.Shared; using PostFundManagement.Application.Services.Shared;
using PostFundManagement.Domain; using PostFundManagement.Domain;
using PostFundManagement.Domain.Abstractions; using PostFundManagement.Domain.Abstractions;
@@ -123,6 +123,29 @@ public sealed class ProjectService(IDbContextFactory<ApplicationDbContext> conte
if (!await context.Milestones.AnyAsync(m => m.Id == request.MilestoneId.Value, cancellationToken)) if (!await context.Milestones.AnyAsync(m => m.Id == request.MilestoneId.Value, cancellationToken))
return Result.Fail(NotFoundError.New); return Result.Fail(NotFoundError.New);
var approvedBudget = await context.Budgets
.Where(b => b.ProjectId == request.ProjectId && (b.Status == ApprovalStatus.Approved || b.Status == ApprovalStatus.Pending))
.SumAsync(b => b.Amount, cancellationToken);
if (approvedBudget == 0)
{
approvedBudget = await context.Awards
.Where(a => a.ProjectId == request.ProjectId)
.SumAsync(a => a.Amount, cancellationToken);
}
if (approvedBudget > 0)
{
var existingTotal = await context.Activities
.Where(a => a.ProjectId == request.ProjectId)
.SumAsync(a => a.Amount, cancellationToken);
if (existingTotal + request.Amount > approvedBudget)
{
return Result.Fail(CreateError.Create($"Activity budget R {request.Amount:N2} exceeds the available project budget. Approved budget: R {approvedBudget:N2}, already allocated: R {existingTotal:N2}, remaining: R {(approvedBudget - existingTotal):N2}."));
}
}
context.Activities.Add(new Domain.Entities.Activity context.Activities.Add(new Domain.Entities.Activity
{ {
ProjectId = request.ProjectId, ProjectId = request.ProjectId,
@@ -141,6 +164,54 @@ public sealed class ProjectService(IDbContextFactory<ApplicationDbContext> conte
CreateError.Create("No record was created.")); CreateError.Create("No record was created."));
})); }));
public async Task<Result> UpdateActivityAsync(UpdateActivityRequest request, Guid userId, CancellationToken cancellationToken = default) =>
await Result.Try((Func<Task<Result>>)(async () =>
{
await using var context = await contextFactory.CreateDbContextAsync(cancellationToken);
var activity = await context.Activities.FirstOrDefaultAsync(a => a.Id == request.ActivityId, cancellationToken);
if (activity is null)
return Result.Fail(NotFoundError.Create("Activity not found"));
if (request.MilestoneId.HasValue && !await context.Milestones.AnyAsync(m => m.Id == request.MilestoneId.Value, cancellationToken))
return Result.Fail(NotFoundError.Create("Milestone not found"));
var approvedBudget = await context.Budgets
.Where(b => b.ProjectId == request.ProjectId && (b.Status == ApprovalStatus.Approved || b.Status == ApprovalStatus.Pending))
.SumAsync(b => b.Amount, cancellationToken);
if (approvedBudget == 0)
{
approvedBudget = await context.Awards
.Where(a => a.ProjectId == request.ProjectId)
.SumAsync(a => a.Amount, cancellationToken);
}
if (approvedBudget > 0)
{
var otherTotal = await context.Activities
.Where(a => a.ProjectId == request.ProjectId && a.Id != request.ActivityId)
.SumAsync(a => a.Amount, cancellationToken);
if (otherTotal + request.Amount > approvedBudget)
{
return Result.Fail(UpdateError.Create($"Updated activity budget R {request.Amount:N2} exceeds the available project budget. Approved budget: R {approvedBudget:N2}, other allocated: R {otherTotal:N2}, remaining: R {(approvedBudget - otherTotal):N2}."));
}
}
activity.Title = request.Title.Humanize(LetterCasing.Title).Trim();
activity.Description = request.Description;
activity.Category = request.Category.Humanize(LetterCasing.Title).Trim();
activity.Amount = request.Amount;
activity.MilestoneId = request.MilestoneId;
activity.PerformedAt = request.PerformedAt;
activity.UpdatedBy = userId;
activity.UpdatedAt = DateTime.UtcNow;
return Result.OkIf(await context.SaveChangesAsync(cancellationToken) > 0,
UpdateError.Create("No changes detected, update cancelled."));
}));
public async Task<Result<Milestone[]>> GetProjectMilestonesAsync(string projectIdHash, CancellationToken cancellationToken = default) => public async Task<Result<Milestone[]>> GetProjectMilestonesAsync(string projectIdHash, CancellationToken cancellationToken = default) =>
await Result.Try((Func<Task<Result<Milestone[]>>>)(async () => await Result.Try((Func<Task<Result<Milestone[]>>>)(async () =>
{ {
@@ -473,8 +544,9 @@ public sealed class ProjectService(IDbContextFactory<ApplicationDbContext> conte
CreatedAt = DateTime.UtcNow, CreatedAt = DateTime.UtcNow,
CreatedBy = userId, CreatedBy = userId,
ProjectId = request.ProjectId, ProjectId = request.ProjectId,
BeneficiaryId = request.BeneficiaryId,
Requirements = request.Requirements, Requirements = request.Requirements,
Status = ApprovalStatus.Pending, Status = request.Status,
TenantId = tenantContext.TenantId, TenantId = tenantContext.TenantId,
Title = request.Title.Humanize(LetterCasing.Title).Trim() Title = request.Title.Humanize(LetterCasing.Title).Trim()
}); });
@@ -492,7 +564,10 @@ public sealed class ProjectService(IDbContextFactory<ApplicationDbContext> conte
.ExecuteUpdateAsync(f => f .ExecuteUpdateAsync(f => f
.SetProperty(u => u.Title, request.Title.Humanize(LetterCasing.Title).Trim()) .SetProperty(u => u.Title, request.Title.Humanize(LetterCasing.Title).Trim())
.SetProperty(u => u.Requirements, request.Requirements) .SetProperty(u => u.Requirements, request.Requirements)
.SetProperty(u => u.Status, request.Status), cancellationToken); .SetProperty(u => u.BeneficiaryId, request.BeneficiaryId)
.SetProperty(u => u.Status, request.Status)
.SetProperty(u => u.UpdatedAt, DateTime.UtcNow)
.SetProperty(u => u.VerifiedBy, userId), cancellationToken);
return Result.OkIf(changedTotal > 0, UpdateError.Create("No records changed")); return Result.OkIf(changedTotal > 0, UpdateError.Create("No records changed"));
})); }));
@@ -697,6 +772,7 @@ public sealed class ProjectService(IDbContextFactory<ApplicationDbContext> conte
CreatedBy = userId, CreatedBy = userId,
Description = request.Description, Description = request.Description,
Name = request.Name.Humanize(LetterCasing.Title).Trim(), Name = request.Name.Humanize(LetterCasing.Title).Trim(),
Status = request.Status,
TenantId = tenantContext.TenantId TenantId = tenantContext.TenantId
}); });
@@ -712,7 +788,8 @@ public sealed class ProjectService(IDbContextFactory<ApplicationDbContext> conte
.Where(o => o.Id == request.OutcomeId) .Where(o => o.Id == request.OutcomeId)
.ExecuteUpdateAsync(f => f .ExecuteUpdateAsync(f => f
.SetProperty(u => u.Description, request.Description) .SetProperty(u => u.Description, request.Description)
.SetProperty(u => u.Name, request.Name.Humanize(LetterCasing.Title).Trim()), cancellationToken); .SetProperty(u => u.Name, request.Name.Humanize(LetterCasing.Title).Trim())
.SetProperty(u => u.Status, request.Status), cancellationToken);
return Result.OkIf(changedTotal > 0, UpdateError.Create("No records changed")); return Result.OkIf(changedTotal > 0, UpdateError.Create("No records changed"));
})); }));
@@ -736,6 +813,25 @@ public sealed class ProjectService(IDbContextFactory<ApplicationDbContext> conte
return Result.Ok(outcomes?.Select(o => o.Map()).ToArray() ?? []); return Result.Ok(outcomes?.Select(o => o.Map()).ToArray() ?? []);
})); }));
public async Task<Result<Outcome[]>> GetProjectOutcomesAsync(string projectIdHash, CancellationToken cancellationToken = default) =>
await Result.Try((Func<Task<Result<Outcome[]>>>)(async () =>
{
var decodeResult = hashService.DecodeLongIdHash(projectIdHash);
if (decodeResult.IsFailed)
return Result.Fail<Outcome[]>(IdDecodeError.Create()
.CausedBy(decodeResult.Errors));
await using var context = await contextFactory.CreateDbContextAsync(cancellationToken);
var outcomes = await context.Outcomes.AsNoTracking()
.Where(o => o.Beneficiary!.ProjectId == decodeResult.Value)
.OrderByDescending(o => o.Id)
.ToListAsync(cancellationToken);
return Result.Ok(outcomes?.Select(o => o.Map()).ToArray() ?? []);
}));
public async Task<Result<Outcome>> GetOutcomeAsync(string outcomeIdHash, CancellationToken cancellationToken = default) => public async Task<Result<Outcome>> GetOutcomeAsync(string outcomeIdHash, CancellationToken cancellationToken = default) =>
await Result.Try((Func<Task<Result<Outcome>>>)(async () => await Result.Try((Func<Task<Result<Outcome>>>)(async () =>
{ {
@@ -1,4 +1,4 @@
using PostFundManagement.Domain; using PostFundManagement.Domain;
namespace PostFundManagement.Application.Services.Projects; namespace PostFundManagement.Application.Services.Projects;
@@ -15,11 +15,13 @@ public sealed record CreateSiteVisitRequest([Required] long ProjectId,
public sealed record UpdateOutcomeRequest([Required] long OutcomeId, public sealed record UpdateOutcomeRequest([Required] long OutcomeId,
[Required][MaxLength(LabelLength)] string Name, [Required][MaxLength(LabelLength)] string Name,
[Required][MaxLength(TextLength)] string Description); [Required][MaxLength(TextLength)] string Description,
[Required] ComplianceStatus Status = ComplianceStatus.NotMet);
public sealed record CreateOutcomeRequest([Required] long BeneficiaryId, public sealed record CreateOutcomeRequest([Required] long BeneficiaryId,
[Required][MaxLength(LabelLength)] string Name, [Required][MaxLength(LabelLength)] string Name,
[Required][MaxLength(TextLength)] string Description); [Required][MaxLength(TextLength)] string Description,
ComplianceStatus Status = ComplianceStatus.NotMet);
public sealed record UpdateIssueRequest([Required] long IssueId, public sealed record UpdateIssueRequest([Required] long IssueId,
[Required][MaxLength(LabelLength)] string Title, [Required][MaxLength(LabelLength)] string Title,
@@ -53,11 +55,14 @@ public sealed record CreateBeneficiaryRequest([Required] long ProjectId,
public sealed record UpdateComplianceItemRequest([Required] long ComplianceItemId, public sealed record UpdateComplianceItemRequest([Required] long ComplianceItemId,
[Required][MaxLength(LabelLength)] string Title, [Required][MaxLength(LabelLength)] string Title,
[Required][MaxLength(TextLength)] string Requirements, [Required][MaxLength(TextLength)] string Requirements,
[Required] ApprovalStatus Status); [Required] ComplianceStatus Status,
long? BeneficiaryId = null);
public sealed record CreateComplianceItemRequest([Required] long ProjectId, public sealed record CreateComplianceItemRequest([Required] long ProjectId,
[Required][MaxLength(LabelLength)] string Title, [Required][MaxLength(LabelLength)] string Title,
[Required][MaxLength(TextLength)] string Requirements); [Required][MaxLength(TextLength)] string Requirements,
ComplianceStatus Status = ComplianceStatus.NotMet,
long? BeneficiaryId = null);
public sealed record UpdateChangeRequest([Required] long ChangeRequestId, public sealed record UpdateChangeRequest([Required] long ChangeRequestId,
[Required][MaxLength(LabelLength)] string Title, [Required][MaxLength(LabelLength)] string Title,
@@ -106,3 +111,12 @@ public sealed record CreateActivityRequest([Required] long ProjectId,
[Required][MaxLength(LabelLength)] string Title, [Required][MaxLength(LabelLength)] string Title,
[Required][MaxLength(TextLength)] string Description, [Required][MaxLength(TextLength)] string Description,
DateTime? PerformedAt); DateTime? PerformedAt);
public sealed record UpdateActivityRequest([Required] long ActivityId,
[Required] long ProjectId,
long? MilestoneId,
[Required][MaxLength(LabelLength)] string Category,
[Required] decimal Amount,
[Required][MaxLength(LabelLength)] string Title,
[Required][MaxLength(TextLength)] string Description,
DateTime? PerformedAt);
@@ -1,4 +1,4 @@
using PostFundManagement.Domain; using PostFundManagement.Domain;
using PostFundManagement.Domain.Abstractions; using PostFundManagement.Domain.Abstractions;
using PostFundManagement.Domain.Errors; using PostFundManagement.Domain.Errors;
using PostFundManagement.Domain.Extensions; using PostFundManagement.Domain.Extensions;
@@ -10,27 +10,22 @@ namespace PostFundManagement.Application.Services.Shared;
public sealed class AuditService(IDbContextFactory<ApplicationDbContext> contextFactory, ITenantContextService tenantContextService) : IService public sealed class AuditService(IDbContextFactory<ApplicationDbContext> contextFactory, ITenantContextService tenantContextService) : IService
{ {
public async Task<Result<AuditLog[]>> GetAuditLogsAsync(CancellationToken cancellationToken = default) => public async Task<Result<AuditLog[]>> GetAuditLogsAsync(CancellationToken cancellationToken = default) =>
await Result.Try((Func<Task<Result<AuditLog[]>>>)(async () => await GetAuditLogsAsync(new Pagination { Page = 1, PageSize = 500, Index = 1 }, cancellationToken);
{
await using var context = await contextFactory.CreateDbContextAsync(cancellationToken);
var pagination = new Pagination { Page = 1, PageSize = 500, Index = 1 };
var logs = await context.AuditLogs
.OrderByDescending(o => o.Id)
.Skip((pagination.Page - 1) * pagination.PageSize)
.Take(pagination.PageSize)
.ToListAsync(cancellationToken);
return Result.Ok(logs.Select(p => p.Map()).ToArray());
}));
public async Task<Result<AuditLog[]>> GetAuditLogsAsync(Pagination pagination, CancellationToken cancellationToken = default) => public async Task<Result<AuditLog[]>> GetAuditLogsAsync(Pagination pagination, CancellationToken cancellationToken = default) =>
await Result.Try((Func<Task<Result<AuditLog[]>>>)(async () => await Result.Try((Func<Task<Result<AuditLog[]>>>)(async () =>
{ {
await using var context = await contextFactory.CreateDbContextAsync(cancellationToken); await using var context = await contextFactory.CreateDbContextAsync(cancellationToken);
var logs = await context.AuditLogs var query = context.AuditLogs.AsQueryable();
if (!tenantContextService.IsAdmin)
{
var currentTenantId = tenantContextService.TenantId > 0 ? tenantContextService.TenantId : 1L;
query = query.Where(o => o.TenantId == currentTenantId);
}
var logs = await query
.OrderByDescending(o => o.Id) .OrderByDescending(o => o.Id)
.Skip((pagination.Page - 1) * pagination.PageSize) .Skip((pagination.Page - 1) * pagination.PageSize)
.Take(pagination.PageSize) .Take(pagination.PageSize)
@@ -39,11 +34,23 @@ public sealed class AuditService(IDbContextFactory<ApplicationDbContext> context
return Result.Ok(logs.Select(p => p.Map()).ToArray()); return Result.Ok(logs.Select(p => p.Map()).ToArray());
})); }));
public async Task<Result> LogActionAsync(string entityId, string entityName, string action, string[] changes, Guid performedBy, CancellationToken cancellationToken = default) => public async Task<Result> LogActionAsync(
string entityId,
string entityName,
string action,
string[] changes,
Guid performedBy,
string? error = null,
long? tenantId = null,
CancellationToken cancellationToken = default) =>
await Result.Try((Func<Task<Result>>)(async () => await Result.Try((Func<Task<Result>>)(async () =>
{ {
await using var context = await contextFactory.CreateDbContextAsync(cancellationToken); await using var context = await contextFactory.CreateDbContextAsync(cancellationToken);
var resolvedTenantId = (tenantId.HasValue && tenantId.Value > 0)
? tenantId.Value
: (tenantContextService.TenantId > 0 ? tenantContextService.TenantId : 1L);
context.Add(new Domain.Entities.AuditLog context.Add(new Domain.Entities.AuditLog
{ {
EntityId = entityId, EntityId = entityId,
@@ -52,7 +59,8 @@ public sealed class AuditService(IDbContextFactory<ApplicationDbContext> context
EntityName = entityName, EntityName = entityName,
PerformedBy = performedBy, PerformedBy = performedBy,
Timestamp = DateTime.UtcNow, Timestamp = DateTime.UtcNow,
TenantId = tenantContextService.TenantId > 0 ? tenantContextService.TenantId : null, TenantId = resolvedTenantId,
Error = error,
}); });
return Result.FailIf(await context.SaveChangesAsync(cancellationToken) <= 0, CreateError.New); return Result.FailIf(await context.SaveChangesAsync(cancellationToken) <= 0, CreateError.New);
@@ -51,8 +51,10 @@ public sealed partial class HashService(IHashids hasher) : IService
int[] decoded = hasher.Decode(hash); int[] decoded = hasher.Decode(hash);
return Result.FailIf(decoded.Length <= 0, ValidationError.Create("Invalid or modified Int hash token.")) if (decoded.Length <= 0)
.ToResult(decoded[0]); return Result.Fail<int>(ValidationError.Create("Invalid or modified Int hash token."));
return Result.Ok(decoded[0]);
} }
public Result<long> DecodeLongIdHash(string hash) public Result<long> DecodeLongIdHash(string hash)
@@ -62,8 +64,10 @@ public sealed partial class HashService(IHashids hasher) : IService
long[] decoded = hasher.DecodeLong(hash); long[] decoded = hasher.DecodeLong(hash);
return Result.FailIf(decoded.Length <= 0, ValidationError.Create("Invalid or modified Long hash token.")) if (decoded.Length <= 0)
.ToResult(decoded[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) => public async Task<Result<string>> DecodeHexHash(string hex) =>
@@ -1,4 +1,4 @@
using PostFundManagement.Application.Abstractions.Services; using PostFundManagement.Application.Abstractions.Services;
using PostFundManagement.Application.Services.Shared; using PostFundManagement.Application.Services.Shared;
using PostFundManagement.Domain; using PostFundManagement.Domain;
using PostFundManagement.Domain.Abstractions; using PostFundManagement.Domain.Abstractions;
@@ -147,10 +147,30 @@ public sealed class UserService(IDbContextFactory<ApplicationDbContext> contextF
if (await context.Users.AnyAsync(u => u.Email == email, cancellationToken)) if (await context.Users.AnyAsync(u => u.Email == email, cancellationToken))
return Result.Fail(ValidationError.Create("User with this email already exists.")); return Result.Fail(ValidationError.Create("User with this email already exists."));
// Normalize and sanitize request.UserName for ASP.NET Core Identity / Skoruba
// IdentityServer rejects user names containing whitespace or disallowed characters
var userName = request.UserName?.Trim();
if (string.IsNullOrWhiteSpace(userName))
{
userName = email;
}
else
{
// Replace whitespace with dots and filter to allowed Identity characters
userName = System.Text.RegularExpressions.Regex.Replace(userName, @"\s+", ".").ToLowerInvariant();
userName = System.Text.RegularExpressions.Regex.Replace(userName, @"[^a-z0-9_.\-@+]", "");
if (string.IsNullOrWhiteSpace(userName))
{
userName = email;
}
}
request.UserName = userName;
request.Email = email;
var createUserResult = await skorubaService.CreateUserAsync(request, cancellationToken); var createUserResult = await skorubaService.CreateUserAsync(request, cancellationToken);
if (createUserResult.IsFailed) if (createUserResult.IsFailed)
return Result.Fail(CreateError.Create("Failed to create user.")); return Result.Fail(CreateError.Create(createUserResult.Errors.FirstOrDefault()?.Message ?? "Failed to create user."));
var newUserId = Guid.Parse(createUserResult.Value.Trim()); var newUserId = Guid.Parse(createUserResult.Value.Trim());
@@ -12,7 +12,6 @@ APP_URL="MY_APP_URL"
VITE_OIDC_AUTHORITY="https://sts.security.khongisa.co.za" VITE_OIDC_AUTHORITY="https://sts.security.khongisa.co.za"
VITE_OIDC_AUDIENCE="pfm-api-dev" VITE_OIDC_AUDIENCE="pfm-api-dev"
VITE_OIDC_CLIENT_ID="pfm-scalar-dev" VITE_OIDC_CLIENT_ID="pfm-scalar-dev"
VITE_OIDC_CLIENT_SECRET="secret_71f88570a46eb22eb5270b47f69e3332c84d8db85d008f0276cb7431c8872d27"
# PFM Backend API Base URL # PFM Backend API Base URL
VITE_API_BASE_URL="https://api.ifri.khongisa.co.za/" VITE_API_BASE_URL="https://api.ifri.khongisa.co.za/"
@@ -26,15 +26,12 @@ OIDC_AUDIENCE="${VITE_OIDC_AUDIENCE:-${OIDC_AUDIENCE:-${SecuritySettings__Audien
# 4. OIDC Client ID # 4. OIDC Client ID
OIDC_CLIENT_ID="${VITE_OIDC_CLIENT_ID:-${OIDC_CLIENT_ID:-${SecuritySettings__ClientId:-${SecuritySettings_ClientId:-${CLIENT_ID:-}}}}}" OIDC_CLIENT_ID="${VITE_OIDC_CLIENT_ID:-${OIDC_CLIENT_ID:-${SecuritySettings__ClientId:-${SecuritySettings_ClientId:-${CLIENT_ID:-}}}}}"
# 5. OIDC Client Secret # 5. OIDC Scope
OIDC_CLIENT_SECRET="${VITE_OIDC_CLIENT_SECRET:-${OIDC_CLIENT_SECRET:-${SecuritySettings__ClientSecret:-${SecuritySettings_ClientSecret:-${CLIENT_SECRET:-}}}}}"
# 6. OIDC Scope
OIDC_SCOPE="${VITE_OIDC_SCOPE:-${OIDC_SCOPE:-${SecuritySettings__Scope:-${SecuritySettings_Scope:-${SCOPE:-}}}}}" OIDC_SCOPE="${VITE_OIDC_SCOPE:-${OIDC_SCOPE:-${SecuritySettings__Scope:-${SecuritySettings_Scope:-${SCOPE:-}}}}}"
# 7. Hasher Settings # 6. Hasher Settings
HASHER_SALT="${VITE_HASHER_SALT:-${HASHER_SALT:-${HasherSettings__Salt:-${HasherSettings_Salt:-}}}}}" 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:-}}}}}" HASHER_SALT="${VITE_HASHER_SALT:-${HASHER_SALT:-${HasherSettings__Salt:-${HasherSettings_Salt:-TGlnaHQgQ2hhcm1zLCBNaWRyYW5kQm9va3MgbWFrZSBsb3RzIG9mIG1vbmV5IGFuZCBhcmUgYSBzdWNjZXNzZnVsIHZpcmFsIHN0b3J5IGluIFNvdXRoIEFmcmljYQ==}}}}"
if [ -d "$TARGET_DIR" ]; then if [ -d "$TARGET_DIR" ]; then
echo "[docker-entrypoint] Generating runtime configuration files in $TARGET_DIR..." echo "[docker-entrypoint] Generating runtime configuration files in $TARGET_DIR..."
@@ -51,14 +48,12 @@ window.__RUNTIME_CONFIG__ = {
VITE_OIDC_AUDIENCE: "${OIDC_AUDIENCE}", VITE_OIDC_AUDIENCE: "${OIDC_AUDIENCE}",
OIDC_CLIENT_ID: "${OIDC_CLIENT_ID}", OIDC_CLIENT_ID: "${OIDC_CLIENT_ID}",
VITE_OIDC_CLIENT_ID: "${OIDC_CLIENT_ID}", VITE_OIDC_CLIENT_ID: "${OIDC_CLIENT_ID}",
OIDC_CLIENT_SECRET: "${OIDC_CLIENT_SECRET}",
VITE_OIDC_CLIENT_SECRET: "${OIDC_CLIENT_SECRET}",
OIDC_SCOPE: "${OIDC_SCOPE}", OIDC_SCOPE: "${OIDC_SCOPE}",
VITE_OIDC_SCOPE: "${OIDC_SCOPE}", VITE_OIDC_SCOPE: "${OIDC_SCOPE}",
HASHER_SALT: "${HASHER_SALT}",
VITE_HASHER_SALT: "${HASHER_SALT}",
HASHER_MIN_LENGTH: "${HASHER_MIN_LENGTH}", HASHER_MIN_LENGTH: "${HASHER_MIN_LENGTH}",
VITE_HASHER_MIN_LENGTH: "${HASHER_MIN_LENGTH}", VITE_HASHER_MIN_LENGTH: "${HASHER_MIN_LENGTH}",
HASHER_SALT: "${HASHER_SALT}",
VITE_HASHER_SALT: "${HASHER_SALT}",
ApiSettings: { ApiSettings: {
BaseUrl: "${API_BASE_URL}" BaseUrl: "${API_BASE_URL}"
}, },
@@ -66,7 +61,6 @@ window.__RUNTIME_CONFIG__ = {
Authority: "${OIDC_AUTHORITY}", Authority: "${OIDC_AUTHORITY}",
Audience: "${OIDC_AUDIENCE}", Audience: "${OIDC_AUDIENCE}",
ClientId: "${OIDC_CLIENT_ID}", ClientId: "${OIDC_CLIENT_ID}",
ClientSecret: "${OIDC_CLIENT_SECRET}",
Scope: "${OIDC_SCOPE}" Scope: "${OIDC_SCOPE}"
}, },
HasherSettings: { HasherSettings: {
@@ -86,7 +80,6 @@ EOF
"Authority": "${OIDC_AUTHORITY}", "Authority": "${OIDC_AUTHORITY}",
"Audience": "${OIDC_AUDIENCE}", "Audience": "${OIDC_AUDIENCE}",
"ClientId": "${OIDC_CLIENT_ID}", "ClientId": "${OIDC_CLIENT_ID}",
"ClientSecret": "${OIDC_CLIENT_SECRET}",
"Scope": "${OIDC_SCOPE}" "Scope": "${OIDC_SCOPE}"
}, },
"HasherSettings": { "HasherSettings": {
@@ -6,11 +6,10 @@
"Authority": "", "Authority": "",
"Audience": "", "Audience": "",
"ClientId": "", "ClientId": "",
"ClientSecret": "",
"Scope": "" "Scope": ""
}, },
"HasherSettings": { "HasherSettings": {
"Salt": "", "Salt": "TGlnaHQgQ2hhcm1zLCBNaWRyYW5kQm9va3MgbWFrZSBsb3RzIG9mIG1vbmV5IGFuZCBhcmUgYSBzdWNjZXNzZnVsIHZpcmFsIHN0b3J5IGluIFNvdXRoIEFmcmljYQ==",
"MinHashLength": "" "MinHashLength": "11"
} }
} }
@@ -1,4 +1,13 @@
// Development and fallback runtime configuration. // Development and fallback runtime configuration.
// In Docker / Kubernetes / Cloud Run deployments, this file is dynamically // In Docker / Kubernetes / Cloud Run deployments, this file is dynamically
// populated at container startup by /docker-entrypoint.sh with deployment environment variables. // populated at container startup by /docker-entrypoint.sh with deployment environment variables.
window.__RUNTIME_CONFIG__ = window.__RUNTIME_CONFIG__ || {}; window.__RUNTIME_CONFIG__ = window.__RUNTIME_CONFIG__ || {
HASHER_SALT: "TGlnaHQgQ2hhcm1zLCBNaWRyYW5kQm9va3MgbWFrZSBsb3RzIG9mIG1vbmV5IGFuZCBhcmUgYSBzdWNjZXNzZnVsIHZpcmFsIHN0b3J5IGluIFNvdXRoIEFmcmljYQ==",
VITE_HASHER_SALT: "TGlnaHQgQ2hhcm1zLCBNaWRyYW5kQm9va3MgbWFrZSBsb3RzIG9mIG1vbmV5IGFuZCBhcmUgYSBzdWNjZXNzZnVsIHZpcmFsIHN0b3J5IGluIFNvdXRoIEFmcmljYQ==",
HASHER_MIN_LENGTH: "11",
VITE_HASHER_MIN_LENGTH: "11",
HasherSettings: {
Salt: "TGlnaHQgQ2hhcm1zLCBNaWRyYW5kQm9va3MgbWFrZSBsb3RzIG9mIG1vbmV5IGFuZCBhcmUgYSBzdWNjZXNzZnVsIHZpcmFsIHN0b3J5IGluIFNvdXRoIEFmcmljYQ==",
MinHashLength: "11"
}
};
+20
View File
@@ -14,10 +14,12 @@ const ProjectsPage = lazy(() => import('./pages/ProjectsPage').then(m => ({ defa
const BeneficiariesPage = lazy(() => import('./pages/BeneficiariesPage').then(m => ({ default: m.BeneficiariesPage }))); const BeneficiariesPage = lazy(() => import('./pages/BeneficiariesPage').then(m => ({ default: m.BeneficiariesPage })));
const ChangeManagementPage = lazy(() => import('./pages/ChangeManagementPage').then(m => ({ default: m.ChangeManagementPage }))); const ChangeManagementPage = lazy(() => import('./pages/ChangeManagementPage').then(m => ({ default: m.ChangeManagementPage })));
const SiteVisitsPage = lazy(() => import('./pages/SiteVisitsPage').then(m => ({ default: m.SiteVisitsPage }))); const SiteVisitsPage = lazy(() => import('./pages/SiteVisitsPage').then(m => ({ default: m.SiteVisitsPage })));
const CompliancePage = lazy(() => import('./pages/CompliancePage').then(m => ({ default: m.CompliancePage })));
const GrantsPage = lazy(() => import('./pages/GrantsPage').then(m => ({ default: m.GrantsPage }))); const GrantsPage = lazy(() => import('./pages/GrantsPage').then(m => ({ default: m.GrantsPage })));
const MonitoringPage = lazy(() => import('./pages/MonitoringPage').then(m => ({ default: m.MonitoringPage }))); const MonitoringPage = lazy(() => import('./pages/MonitoringPage').then(m => ({ default: m.MonitoringPage })));
const TenantManagementPage = lazy(() => import('./pages/TenantManagementPage').then(m => ({ default: m.TenantManagementPage }))); const TenantManagementPage = lazy(() => import('./pages/TenantManagementPage').then(m => ({ default: m.TenantManagementPage })));
const SettingsPage = lazy(() => import('./pages/SettingsPage').then(m => ({ default: m.SettingsPage }))); const SettingsPage = lazy(() => import('./pages/SettingsPage').then(m => ({ default: m.SettingsPage })));
const InboxPage = lazy(() => import('./pages/InboxPage').then(m => ({ default: m.InboxPage })));
const RouteLoading = () => ( const RouteLoading = () => (
<div className="flex items-center justify-center min-h-[400px] w-full text-slate-400"> <div className="flex items-center justify-center min-h-[400px] w-full text-slate-400">
@@ -132,6 +134,22 @@ export default function App() {
</ClaimProtectedRoute> </ClaimProtectedRoute>
} }
/> />
<Route
path="/projects/compliance"
element={
<ClaimProtectedRoute claim="compliance">
<CompliancePage />
</ClaimProtectedRoute>
}
/>
<Route
path="/compliance"
element={
<ClaimProtectedRoute claim="compliance">
<CompliancePage />
</ClaimProtectedRoute>
}
/>
<Route <Route
path="/grants" path="/grants"
element={ element={
@@ -164,6 +182,8 @@ export default function App() {
</TenantManagementProtectedRoute> </TenantManagementProtectedRoute>
} }
/> />
<Route path="/inbox" element={<InboxPage />} />
<Route path="/mailbox" element={<InboxPage />} />
<Route path="/settings" element={<SettingsPage />} /> <Route path="/settings" element={<SettingsPage />} />
</Route> </Route>
@@ -10,13 +10,14 @@ import {
isActivationPending, isActivationPending,
getActivationPendingReason, getActivationPendingReason,
clearUserSyncInBrowser, clearUserSyncInBrowser,
setActivationPending,
UserSyncResult, UserSyncResult,
} from '../services/userSyncService'; } from '../services/userSyncService';
import { AccountActivationModal } from '../components/auth/AccountActivationModal'; import { AccountActivationModal } from '../components/auth/AccountActivationModal';
import { PartnerAccessDeniedModal } from '../components/auth/PartnerAccessDeniedModal'; import { PartnerAccessDeniedModal } from '../components/auth/PartnerAccessDeniedModal';
import { SessionExpiredModal } from '../components/auth/SessionExpiredModal'; import { SessionExpiredModal } from '../components/auth/SessionExpiredModal';
import { encodeHashIdSync } from '../services/toolsService'; import { encodeHashIdSync } from '../utils/hashids';
import { verifyTenantLockout } from '../services/tenantLockoutService'; import { verifyTenantLockout, clearTenantLockoutCache } from '../services/tenantLockoutService';
import { import {
onSessionExpired, onSessionExpired,
triggerSessionExpired, triggerSessionExpired,
@@ -64,7 +65,7 @@ interface AuthContextType {
authError: string | null; authError: string | null;
} }
const AuthContext = createContext<AuthContextType | undefined>(undefined); export const AuthContext = createContext<AuthContextType | undefined>(undefined);
export const LOCAL_STORAGE_TOKEN_KEY = 'pfm_access_token'; export const LOCAL_STORAGE_TOKEN_KEY = 'pfm_access_token';
export const LOCAL_STORAGE_USER_KEY = 'pfm_auth_user'; export const LOCAL_STORAGE_USER_KEY = 'pfm_auth_user';
@@ -307,11 +308,6 @@ export function resetUserManager(): void {
userManagerInstance = null; userManagerInstance = null;
} }
// Automatically reset UserManager when runtime configuration is updated
onConfigChanged(() => {
resetUserManager();
});
export function getUserManager(): UserManager { export function getUserManager(): UserManager {
if (!userManagerInstance && typeof window !== 'undefined') { if (!userManagerInstance && typeof window !== 'undefined') {
const sec = getSecuritySettings(); const sec = getSecuritySettings();
@@ -320,7 +316,7 @@ export function getUserManager(): UserManager {
userManagerInstance = new UserManager({ userManagerInstance = new UserManager({
authority: sec.Authority, authority: sec.Authority,
client_id: sec.ClientId, client_id: sec.ClientId,
client_secret: sec.ClientSecret, ...(sec.ClientSecret ? { client_secret: sec.ClientSecret } : {}),
redirect_uri: `${origin}/dashboard`, redirect_uri: `${origin}/dashboard`,
post_logout_redirect_uri: `${origin}/`, post_logout_redirect_uri: `${origin}/`,
response_type: 'code', response_type: 'code',
@@ -415,7 +411,7 @@ export const AuthProvider: React.FC<{ children: ReactNode }> = ({ children }) =>
const urlParams = new URLSearchParams(window.location.search); const urlParams = new URLSearchParams(window.location.search);
if (urlParams.has('code') && urlParams.has('state')) { if (urlParams.has('code') && urlParams.has('state')) {
const mgr = getUserManager(); const mgr = getUserManager();
const oidcUser: OidcUser = await mgr.signinCallback(); const oidcUser: OidcUser | undefined = await mgr.signinCallback();
if (oidcUser && oidcUser.access_token) { if (oidcUser && oidcUser.access_token) {
const token = oidcUser.access_token; const token = oidcUser.access_token;
@@ -437,8 +433,8 @@ export const AuthProvider: React.FC<{ children: ReactNode }> = ({ children }) =>
localStorage.setItem(LOCAL_STORAGE_CLAIMS_KEY, JSON.stringify(mergedClaims)); localStorage.setItem(LOCAL_STORAGE_CLAIMS_KEY, JSON.stringify(mergedClaims));
const tenantClaimValues = extractTenantClaimValues(mergedClaims, token); const tenantClaimValues = extractTenantClaimValues(mergedClaims, token);
const tenantClaim = (mergedClaims.tenant as string) || (oidcUser.profile?.tenant as string) || tenantClaimValues.tenant || undefined; let tenantClaim = (mergedClaims.tenant as string) || (oidcUser.profile?.tenant as string) || tenantClaimValues.tenant || undefined;
const parsedTenantId = extractTenantId(mergedClaims, token); let parsedTenantId = extractTenantId(mergedClaims, token);
setTenantId(parsedTenantId); setTenantId(parsedTenantId);
const profile: AuthUserProfile = { const profile: AuthUserProfile = {
@@ -458,29 +454,7 @@ export const AuthProvider: React.FC<{ children: ReactNode }> = ({ children }) =>
// Clean up code and state from URL // Clean up code and state from URL
window.history.replaceState({}, document.title, window.location.pathname); window.history.replaceState({}, document.title, window.location.pathname);
// Verify tenant lockout at the instance tenant and tenant_id claims are retrieved // Step 1: Synchronize authenticated user profile with backend directory FIRST (/v1/api/integration/sync)
const tenantLockoutResult = await verifyTenantLockout(
parsedTenantId ?? tenantClaimValues.tenantId,
tenantClaim
);
if (!tenantLockoutResult.isActive) {
setIsPendingActivation(true);
setIsSynced(false);
setActivationError(tenantLockoutResult.errorMessage || 'Tenant is inactive or not found');
setIsLoading(false);
return;
}
if (tenantLockoutResult.tenant?.name) {
profile.tenant = tenantLockoutResult.tenant.name;
setTenantName(tenantLockoutResult.tenant.name);
localStorage.setItem(LOCAL_STORAGE_USER_KEY, JSON.stringify(profile));
} else if (tenantClaim) {
setTenantName(tenantClaim);
}
// Synchronize authenticated user profile with backend directory
try { try {
const syncResult = await syncUserAccount(userSub, token, profile, true); const syncResult = await syncUserAccount(userSub, token, profile, true);
if (syncResult.isUnauthorized) { if (syncResult.isUnauthorized) {
@@ -488,6 +462,51 @@ export const AuthProvider: React.FC<{ children: ReactNode }> = ({ children }) =>
window.location.replace('/'); window.location.replace('/');
return; return;
} }
// Step 2: Retrieve user roles, role claims, and user claims (Requirement 2 & 3 & 7)
try {
const resolved = await resolveUserRolesAndClaims(userSub, mergedClaims, profile);
if (resolved.primaryRole) {
profile.role = resolved.primaryRole;
}
if (resolved.tenant) {
profile.tenant = resolved.tenant;
tenantClaim = resolved.tenant;
setTenantName(resolved.tenant);
}
if (resolved.tenant_id !== undefined) {
profile.tenant_id = resolved.tenant_id;
parsedTenantId = resolved.tenant_id;
setTenantId(resolved.tenant_id);
}
setUser({ ...profile });
setClaims({ ...resolved.claims });
localStorage.setItem(LOCAL_STORAGE_USER_KEY, JSON.stringify(profile));
localStorage.setItem(LOCAL_STORAGE_CLAIMS_KEY, JSON.stringify(resolved.claims));
} catch {}
// Step 3: Verify tenant lockout now that user is synced and authenticated
const tenantLockoutResult = await verifyTenantLockout(
parsedTenantId ?? tenantClaimValues.tenantId,
tenantClaim
);
if (!tenantLockoutResult.isActive) {
setIsPendingActivation(true);
setIsSynced(false);
setActivationError(tenantLockoutResult.errorMessage || 'Tenant is inactive or not found');
setIsLoading(false);
return;
}
if (tenantLockoutResult.tenant?.name) {
profile.tenant = tenantLockoutResult.tenant.name;
setTenantName(tenantLockoutResult.tenant.name);
localStorage.setItem(LOCAL_STORAGE_USER_KEY, JSON.stringify(profile));
} else if (tenantClaim) {
setTenantName(tenantClaim);
}
if (syncResult.isActivationPending) { if (syncResult.isActivationPending) {
setIsPendingActivation(true); setIsPendingActivation(true);
setIsSynced(false); setIsSynced(false);
@@ -496,26 +515,6 @@ export const AuthProvider: React.FC<{ children: ReactNode }> = ({ children }) =>
setIsPendingActivation(false); setIsPendingActivation(false);
setIsSynced(true); setIsSynced(true);
setActivationError(null); setActivationError(null);
// Retrieve user roles, role claims, and user claims (Requirement 2 & 3 & 7)
try {
const resolved = await resolveUserRolesAndClaims(userSub, mergedClaims, profile);
if (resolved.primaryRole) {
profile.role = resolved.primaryRole;
}
if (resolved.tenant) {
profile.tenant = resolved.tenant;
setTenantName(resolved.tenant);
}
if (resolved.tenant_id !== undefined) {
profile.tenant_id = resolved.tenant_id;
setTenantId(resolved.tenant_id);
}
setUser({ ...profile });
setClaims({ ...resolved.claims });
localStorage.setItem(LOCAL_STORAGE_USER_KEY, JSON.stringify(profile));
localStorage.setItem(LOCAL_STORAGE_CLAIMS_KEY, JSON.stringify(resolved.claims));
} catch {}
} }
} catch (syncErr: any) { } catch (syncErr: any) {
if (syncErr?.response?.status === 401) { if (syncErr?.response?.status === 401) {
@@ -525,7 +524,9 @@ export const AuthProvider: React.FC<{ children: ReactNode }> = ({ children }) =>
} }
setIsPendingActivation(true); setIsPendingActivation(true);
setIsSynced(false); setIsSynced(false);
setActivationError(syncErr?.message || 'Account activation pending'); const rawMsg = syncErr?.message || 'Account activation pending';
const cleanMsg = /network\s*error/i.test(rawMsg) ? rawMsg.replace(/network\s*error/gi, 'Permission Check') : rawMsg;
setActivationError(cleanMsg);
} }
setIsLoading(false); setIsLoading(false);
@@ -561,10 +562,10 @@ export const AuthProvider: React.FC<{ children: ReactNode }> = ({ children }) =>
setSub(storedSub); setSub(storedSub);
} }
const storedTenantId = extractTenantId(parsedClaims, storedToken); let storedTenantId = extractTenantId(parsedClaims, storedToken);
setTenantId(storedTenantId); setTenantId(storedTenantId);
const tenantClaimValues = extractTenantClaimValues(parsedClaims, storedToken); const tenantClaimValues = extractTenantClaimValues(parsedClaims, storedToken);
const storedTenantClaim = (parsedClaims.tenant as string) || tenantClaimValues.tenant || undefined; let storedTenantClaim = (parsedClaims.tenant as string) || tenantClaimValues.tenant || undefined;
let resolvedUser: AuthUserProfile | null = null; let resolvedUser: AuthUserProfile | null = null;
if (storedUser) { if (storedUser) {
@@ -581,61 +582,34 @@ export const AuthProvider: React.FC<{ children: ReactNode }> = ({ children }) =>
} catch {} } catch {}
} }
// Verify tenant lockout at the instance tenant and tenant_id claims are retrieved if (storedTenantClaim) {
const tenantLockoutResult = await verifyTenantLockout(
storedTenantId ?? tenantClaimValues.tenantId,
storedTenantClaim
);
if (!tenantLockoutResult.isActive) {
setIsPendingActivation(true);
setIsSynced(false);
setActivationError(tenantLockoutResult.errorMessage || 'Tenant is inactive or not found');
setIsLoading(false);
return;
}
if (tenantLockoutResult.tenant?.name) {
if (resolvedUser) {
resolvedUser.tenant = tenantLockoutResult.tenant.name;
setUser({ ...resolvedUser });
localStorage.setItem(LOCAL_STORAGE_USER_KEY, JSON.stringify(resolvedUser));
}
setTenantName(tenantLockoutResult.tenant.name);
} else if (storedTenantClaim) {
setTenantName(storedTenantClaim); setTenantName(storedTenantClaim);
} }
// Check if session is already marked pending activation or needs sync check // Initial fast unblock: valid persisted session found, unblock UI immediately
const wasPending = isActivationPending(); setIsLoading(false);
setIsPendingActivation(wasPending);
setActivationError(getActivationPendingReason());
try { // Asynchronous background revalidation (User Sync, Role Claims, Tenant Lockout)
// Verify sync with browser cache priority (hotspot prevention) (async () => {
const syncResult = await syncUserAccount( try {
storedSub || '', const wasPending = isActivationPending();
storedToken, setIsPendingActivation(wasPending);
resolvedUser || undefined, setActivationError(getActivationPendingReason());
false
);
if (syncResult.isUnauthorized) { // Step 1: Sync user account FIRST
// HTTP 401: Normal process of routing user to landing page const syncResult = await syncUserAccount(
await logout(); storedSub || '',
return; storedToken,
} resolvedUser || undefined,
false
);
if (syncResult.isActivationPending) { if (syncResult.isUnauthorized) {
setIsPendingActivation(true); await logout();
setIsSynced(false); return;
setActivationError(syncResult.errorMessage || null); }
} else if (syncResult.success) {
setIsPendingActivation(false);
setIsSynced(true);
setActivationError(null);
// Retrieve user roles, role claims, and user claims (Requirement 2 & 3 & 7) // Step 2: Retrieve user roles, role claims, and user claims
try { try {
const resolved = await resolveUserRolesAndClaims( const resolved = await resolveUserRolesAndClaims(
storedSub || '', storedSub || '',
@@ -646,10 +620,12 @@ export const AuthProvider: React.FC<{ children: ReactNode }> = ({ children }) =>
if (resolved.primaryRole) resolvedUser.role = resolved.primaryRole; if (resolved.primaryRole) resolvedUser.role = resolved.primaryRole;
if (resolved.tenant) { if (resolved.tenant) {
resolvedUser.tenant = resolved.tenant; resolvedUser.tenant = resolved.tenant;
storedTenantClaim = resolved.tenant;
setTenantName(resolved.tenant); setTenantName(resolved.tenant);
} }
if (resolved.tenant_id !== undefined) { if (resolved.tenant_id !== undefined) {
resolvedUser.tenant_id = resolved.tenant_id; resolvedUser.tenant_id = resolved.tenant_id;
storedTenantId = resolved.tenant_id;
setTenantId(resolved.tenant_id); setTenantId(resolved.tenant_id);
} }
setUser({ ...resolvedUser }); setUser({ ...resolvedUser });
@@ -658,16 +634,55 @@ export const AuthProvider: React.FC<{ children: ReactNode }> = ({ children }) =>
setClaims({ ...resolved.claims }); setClaims({ ...resolved.claims });
localStorage.setItem(LOCAL_STORAGE_CLAIMS_KEY, JSON.stringify(resolved.claims)); localStorage.setItem(LOCAL_STORAGE_CLAIMS_KEY, JSON.stringify(resolved.claims));
} catch {} } catch {}
// Step 3: Verify tenant lockout now that user is verified active
const tenantLockoutResult = await verifyTenantLockout(
storedTenantId ?? tenantClaimValues.tenantId,
storedTenantClaim
);
if (!tenantLockoutResult.isActive) {
setIsPendingActivation(true);
setIsSynced(false);
setActivationError(tenantLockoutResult.errorMessage || 'Tenant is inactive or not found');
return;
}
if (tenantLockoutResult.tenant?.name) {
if (resolvedUser) {
resolvedUser.tenant = tenantLockoutResult.tenant.name;
setUser({ ...resolvedUser });
localStorage.setItem(LOCAL_STORAGE_USER_KEY, JSON.stringify(resolvedUser));
}
setTenantName(tenantLockoutResult.tenant.name);
}
if (syncResult.isActivationPending) {
setIsPendingActivation(true);
setIsSynced(false);
setActivationError(syncResult.errorMessage || null);
return;
}
if (syncResult.success) {
setIsPendingActivation(false);
setIsSynced(true);
setActivationError(null);
}
} catch (syncErr: any) {
if (syncErr?.response?.status === 401) {
await logout();
return;
}
setIsPendingActivation(true);
setIsSynced(false);
const rawMsg = syncErr?.message || 'Account activation pending';
const cleanMsg = /network\s*error/i.test(rawMsg) ? rawMsg.replace(/network\s*error/gi, 'Permission Check') : rawMsg;
setActivationError(cleanMsg);
} }
} catch (syncErr: any) { })();
if (syncErr?.response?.status === 401) {
await logout(); return;
return;
}
setIsPendingActivation(true);
setIsSynced(false);
setActivationError(syncErr?.message || 'Account activation pending');
}
} }
} catch (err: unknown) { } catch (err: unknown) {
console.warn('OIDC initialization notice:', err); console.warn('OIDC initialization notice:', err);
@@ -682,12 +697,65 @@ export const AuthProvider: React.FC<{ children: ReactNode }> = ({ children }) =>
const syncUserSession = async (force = true): Promise<UserSyncResult> => { const syncUserSession = async (force = true): Promise<UserSyncResult> => {
try { try {
// Re-verify tenant status via GetTenant as part of the lockout resolution await loadRuntimeConfig();
const tenantClaimValues = extractTenantClaimValues(claims, accessToken); clearTenantLockoutCache();
const currentTenantId = tenantId ?? tenantClaimValues.tenantId;
const currentTenantClaim = (claims.tenant as string) || user?.tenant || tenantClaimValues.tenant || undefined;
const tenantLockoutResult = await verifyTenantLockout(currentTenantId, currentTenantClaim); // Step 1: Proceed with user sync FIRST (/v1/api/integration/sync)
const result = await syncUserAccount(sub, accessToken, user || undefined, force);
if (result.isUnauthorized) {
await logout();
return result;
}
if (result.isActivationPending) {
setIsPendingActivation(true);
setIsSynced(false);
setActivationError(result.errorMessage || null);
return result;
}
// Step 2: Refresh user roles and claims from backend
let currentTenantId: number | string | null = tenantId;
let currentTenantClaim: string | undefined =
(claims.tenant as string) || user?.tenant || undefined;
try {
const resolved = await resolveUserRolesAndClaims(sub, claims, user || {});
const updatedProfile = user ? { ...user } : null;
if (updatedProfile && resolved.primaryRole) {
updatedProfile.role = resolved.primaryRole;
}
if (resolved.tenant) {
currentTenantClaim = resolved.tenant;
setTenantName(resolved.tenant);
if (updatedProfile) updatedProfile.tenant = resolved.tenant;
}
if (resolved.tenant_id !== undefined) {
currentTenantId = resolved.tenant_id;
setTenantId(resolved.tenant_id);
if (updatedProfile) updatedProfile.tenant_id = resolved.tenant_id;
}
if (updatedProfile) {
setUser(updatedProfile);
localStorage.setItem(LOCAL_STORAGE_USER_KEY, JSON.stringify(updatedProfile));
}
setClaims({ ...resolved.claims });
localStorage.setItem(LOCAL_STORAGE_CLAIMS_KEY, JSON.stringify(resolved.claims));
} catch {}
// Step 3: Re-verify tenant status via GetTenant now that user is synced
const tenantClaimValues = extractTenantClaimValues(claims, accessToken);
const effectiveTenantId =
currentTenantId ??
result.data?.tenantId ??
result.data?.organisationId ??
tenantClaimValues.tenantId;
const effectiveTenantClaim = currentTenantClaim || tenantClaimValues.tenant || undefined;
const tenantLockoutResult = await verifyTenantLockout(
effectiveTenantId,
effectiveTenantClaim,
true
);
if (!tenantLockoutResult.isActive) { if (!tenantLockoutResult.isActive) {
const msg = tenantLockoutResult.errorMessage || 'Tenant is not Active. Lockout active.'; const msg = tenantLockoutResult.errorMessage || 'Tenant is not Active. Lockout active.';
setIsPendingActivation(true); setIsPendingActivation(true);
@@ -700,28 +768,22 @@ export const AuthProvider: React.FC<{ children: ReactNode }> = ({ children }) =>
}; };
} }
// Tenant is verified Active: proceed with user sync if (tenantLockoutResult.tenant?.name) {
const result = await syncUserAccount(sub, accessToken, user || undefined, force); setTenantName(tenantLockoutResult.tenant.name);
if (result.isUnauthorized) {
await logout();
return result;
}
if (result.success) {
setIsPendingActivation(false);
setIsSynced(true);
setActivationError(null);
} else if (result.isActivationPending) {
setIsPendingActivation(true);
setIsSynced(false);
setActivationError(result.errorMessage || null);
} }
setActivationPending(false);
setIsPendingActivation(false);
setIsSynced(true);
setActivationError(null);
return result; return result;
} catch (err: any) { } catch (err: any) {
if (err?.response?.status === 401) { if (err?.response?.status === 401) {
await logout(); await logout();
return { success: false, isUnauthorized: true }; return { success: false, isUnauthorized: true };
} }
const msg = err?.message || 'Sync failed'; const rawMsg = err?.message || 'Sync failed';
const msg = /network\s*error/i.test(rawMsg) ? rawMsg.replace(/network\s*error/gi, 'Permission Check') : rawMsg;
setIsPendingActivation(true); setIsPendingActivation(true);
setIsSynced(false); setIsSynced(false);
setActivationError(msg); setActivationError(msg);
+60 -5
View File
@@ -11,6 +11,7 @@ export type RoleClaim =
| 'beneficiaries' | 'beneficiaries'
| 'change_management' | 'change_management'
| 'site_visits' | 'site_visits'
| 'compliance'
| 'settings_invites' | 'settings_invites'
| 'settings_users' | 'settings_users'
| 'settings_auditlog' | 'settings_auditlog'
@@ -36,6 +37,7 @@ export const ROLE_CLAIM_DEFINITIONS: RoleClaimDefinition[] = [
{ claim: 'beneficiaries', label: 'Beneficiaries', description: 'Target demographics, compliance tracking, and outcomes' }, { claim: 'beneficiaries', label: 'Beneficiaries', description: 'Target demographics, compliance tracking, and outcomes' },
{ claim: 'change_management', label: 'Change Management', description: 'Contractual change requests, issues, and enactments' }, { claim: 'change_management', label: 'Change Management', description: 'Contractual change requests, issues, and enactments' },
{ claim: 'site_visits', label: 'Site Visits', description: 'Physical field inspection reports and inspector findings' }, { claim: 'site_visits', label: 'Site Visits', description: 'Physical field inspection reports and inspector findings' },
{ claim: 'compliance', label: 'Compliance', description: 'Statutory compliance dashboard, outcome fulfillment, and risk monitoring' },
{ claim: 'settings_invites', label: 'Settings Invites', description: 'Statutory onboarding invitations and applicant notifications' }, { claim: 'settings_invites', label: 'Settings Invites', description: 'Statutory onboarding invitations and applicant notifications' },
{ claim: 'settings_users', label: 'Settings Users', description: 'Direct user account administration and role memberships' }, { claim: 'settings_users', label: 'Settings Users', description: 'Direct user account administration and role memberships' },
{ claim: 'settings_auditlog', label: 'Settings Audit Log', description: 'System-wide activity ledger and telemetry records' }, { claim: 'settings_auditlog', label: 'Settings Audit Log', description: 'System-wide activity ledger and telemetry records' },
@@ -60,6 +62,7 @@ export const ROLE_CLAIM_MAP: Record<string, RoleClaim[]> = {
admin: ['unrestricted'], admin: ['unrestricted'],
executive: [ executive: [
'dashboard', 'dashboard',
'compliance',
'governance', 'governance',
'projects_milestones', 'projects_milestones',
'grant_financials', 'grant_financials',
@@ -68,6 +71,7 @@ export const ROLE_CLAIM_MAP: Record<string, RoleClaim[]> = {
], ],
supervisor: [ supervisor: [
'dashboard', 'dashboard',
'compliance',
'projects_milestones', 'projects_milestones',
'grant_financials', 'grant_financials',
'evidence_risks', 'evidence_risks',
@@ -79,6 +83,7 @@ export const ROLE_CLAIM_MAP: Record<string, RoleClaim[]> = {
], ],
manager: [ manager: [
'dashboard', 'dashboard',
'compliance',
'projects_milestones', 'projects_milestones',
'grant_financials', 'grant_financials',
'evidence_risks', 'evidence_risks',
@@ -142,12 +147,14 @@ export function resolveClaimActionSet(
actions.add('add'); actions.add('add');
actions.add('update'); actions.add('update');
actions.add('export'); actions.add('export');
} else if (val === 'view') { } else if (val === 'view' || val === 'read') {
actions.add('view'); actions.add('view');
} else if (val === 'add') { } else if (val === 'add' || val === 'edit' || val === 'write' || val === 'create') {
// 'add' encompasses create and non-approval edits
actions.add('view'); actions.add('view');
actions.add('add'); actions.add('add');
} else if (val === 'update') { } else if (val === 'update' || val === 'approve' || val === 'verify') {
// 'update' strictly grants approval capability (+ view and add)
actions.add('view'); actions.add('view');
actions.add('add'); actions.add('add');
actions.add('update'); actions.add('update');
@@ -173,10 +180,13 @@ export function resolveClaimActionSet(
actions.add('update'); actions.add('update');
actions.add('export'); actions.add('export');
} else if (role === 'supervisor') { } else if (role === 'supervisor') {
// Supervisor performs operational review and verification/approval of milestones & evidence
actions.add('view'); actions.add('view');
actions.add('add'); actions.add('add');
actions.add('update');
actions.add('export'); actions.add('export');
} else if (role === 'contributor') { } else if (role === 'contributor') {
// Contributor is strictly working-level execution: creating activities, submitting evidence, etc. (NO approve/update)
actions.add('view'); actions.add('view');
actions.add('add'); actions.add('add');
} else { } else {
@@ -294,11 +304,36 @@ export function evaluateUserPermission(
return false; return false;
} }
/**
* Evaluates Maker-Checker separation of duties constraint.
* In PFM governance, the user who created (or initiated) a record ("Maker")
* is strictly barred from approving ("Checking") their own record.
*
* @param createdBy The user identifier of the record creator (Guid, sub, email)
* @param currentUserId Current logged-in user ID (user.id or sub)
* @param currentUserSub Current logged-in user subject claim
* @param currentUserEmail Current logged-in user email
* @returns boolean true if user is the creator (Maker), false otherwise
*/
export function isRecordMaker(
createdBy?: string | null,
currentUserId?: string | null,
currentUserSub?: string | null,
currentUserEmail?: string | null
): boolean {
if (!createdBy) return false;
const creator = createdBy.trim().toLowerCase();
if (currentUserId && creator === currentUserId.trim().toLowerCase()) return true;
if (currentUserSub && creator === currentUserSub.trim().toLowerCase()) return true;
if (currentUserEmail && creator === currentUserEmail.trim().toLowerCase()) return true;
return false;
}
/** /**
* React hook exposing RBAC evaluation helpers for components. * React hook exposing RBAC evaluation helpers for components.
*/ */
export function useRbac() { export function useRbac() {
const { roles, claims } = useAuth(); const { roles, claims, user, sub } = useAuth();
const permissions = useMemo(() => { const permissions = useMemo(() => {
const isUnrestricted = const isUnrestricted =
@@ -315,6 +350,22 @@ export function useRbac() {
const canApprove = (claim: RoleClaim): boolean => canAccess(claim, 'update'); const canApprove = (claim: RoleClaim): boolean => canAccess(claim, 'update');
const canExport = (claim: RoleClaim): boolean => canAccess(claim, 'export'); const canExport = (claim: RoleClaim): boolean => canAccess(claim, 'export');
const isMaker = (createdBy?: string | null): boolean => {
return isRecordMaker(createdBy, user?.id, sub, user?.email);
};
const canApproveRecord = (claim: RoleClaim, createdBy?: string | null): boolean => {
if (!canApprove(claim)) return false;
// Maker-Checker policy: record creator cannot approve their own record
return !isMaker(createdBy);
};
const getApprovalRestrictionReason = (claim: RoleClaim, createdBy?: string | null): string | null => {
if (!canApprove(claim)) return 'Requires update/approval permission';
if (isMaker(createdBy)) return 'Maker-Checker policy: Record creators cannot approve their own records';
return null;
};
return { return {
isUnrestricted, isUnrestricted,
canAccess, canAccess,
@@ -322,8 +373,11 @@ export function useRbac() {
canAdd, canAdd,
canApprove, canApprove,
canExport, canExport,
isMaker,
canApproveRecord,
getApprovalRestrictionReason,
}; };
}, [roles, claims]); }, [roles, claims, user, sub]);
return permissions; return permissions;
} }
@@ -342,6 +396,7 @@ export const ClaimProtectedRoute: React.FC<{
if (!allowed) { if (!allowed) {
let fallbackPath = '/dashboard'; let fallbackPath = '/dashboard';
if (canView('dashboard')) fallbackPath = '/dashboard'; if (canView('dashboard')) fallbackPath = '/dashboard';
else if (canView('compliance')) fallbackPath = '/compliance';
else if (canView('projects_milestones')) fallbackPath = '/projects'; else if (canView('projects_milestones')) fallbackPath = '/projects';
else if (canView('grant_financials')) fallbackPath = '/grants'; else if (canView('grant_financials')) fallbackPath = '/grants';
else if (canView('evidence_risks')) fallbackPath = '/monitoring'; else if (canView('evidence_risks')) fallbackPath = '/monitoring';
@@ -1,4 +1,5 @@
import React, { useState, useEffect } from 'react'; import React, { useState, useEffect } from 'react';
import { createPortal } from 'react-dom';
import { useNavigate } from 'react-router-dom'; import { useNavigate } from 'react-router-dom';
import { import {
ShieldAlert, ShieldAlert,
@@ -89,14 +90,15 @@ export const AccountActivationModal: React.FC<AccountActivationModalProps> = ({
); );
} }
// If result.success is true, AuthProvider updates state, and this modal unmounts automatically! // If result.success is true, AuthProvider updates state, and this modal unmounts automatically!
} catch (err: any) { } catch (err: unknown) {
if (err?.response?.status === 401) { const errObj = err as { response?: { status?: number }; message?: string };
if (errObj?.response?.status === 401) {
await onLogout(); await onLogout();
navigate('/', { replace: true }); navigate('/', { replace: true });
return; return;
} }
setErrorMessage( setErrorMessage(
err?.message || errObj?.message ||
'Account activation check encountered an error. Please confirm with your administrator and try again.' 'Account activation check encountered an error. Please confirm with your administrator and try again.'
); );
} finally { } finally {
@@ -113,141 +115,94 @@ export const AccountActivationModal: React.FC<AccountActivationModalProps> = ({
navigate('/'); navigate('/');
}; };
return ( const isTenantIssue = !!errorMessage?.toLowerCase().includes('tenant');
const modalContent = (
<div <div
role="dialog" role="dialog"
aria-modal="true" aria-modal="true"
aria-labelledby="activation-modal-title" aria-labelledby="activation-modal-title"
className="fixed inset-0 z-50 flex items-center justify-center p-4 sm:p-6 bg-[#060a12]/90 backdrop-blur-md animate-in fade-in duration-200 select-none" className="fixed inset-0 z-[9999] flex items-center justify-center p-4 bg-[#060a12]/85 backdrop-blur-md animate-in fade-in duration-200 select-none overflow-y-auto"
> >
{/* Non-dismissable Card Container: No X button, no escape dismiss, no backdrop click */}
<div <div
className="relative w-full max-w-xl bg-[#0d1322] border border-amber-500/30 rounded-2xl shadow-2xl shadow-black/80 overflow-hidden flex flex-col text-slate-100" className="relative w-full max-w-lg bg-[#0d1322] border border-slate-800 rounded-2xl shadow-2xl shadow-black/80 overflow-hidden flex flex-col text-slate-100 my-auto"
onClick={(e) => e.stopPropagation()} onClick={(e) => e.stopPropagation()}
> >
{/* Amber accent top bar */} {/* Subtle accent top border */}
<div className="h-1.5 w-full bg-gradient-to-r from-amber-500 via-amber-400 to-yellow-500" /> <div className={`h-1 w-full ${isTenantIssue ? 'bg-amber-500' : 'bg-blue-600'}`} />
{/* Modal Header */} {/* Header */}
<div className="p-6 pb-4 border-b border-slate-800/80 bg-[#0f172a]/60"> <div className="p-5 pb-4 border-b border-slate-800/80 bg-[#0f172a]/60">
<div className="flex items-start justify-between gap-4"> <div className="flex items-start justify-between gap-4">
<div className="flex items-center gap-3.5"> <div className="flex items-center gap-3">
<div className="relative p-2.5 rounded-xl bg-amber-500/10 border border-amber-500/20 text-amber-400"> <div className={`p-2.5 rounded-xl border ${isTenantIssue ? 'bg-amber-500/10 border-amber-500/20 text-amber-400' : 'bg-blue-500/10 border-blue-500/20 text-blue-400'}`}>
<ShieldAlert className="w-6 h-6" /> <ShieldAlert className="w-5 h-5" />
<span className="absolute -top-1 -right-1 flex h-2.5 w-2.5">
<span className="animate-ping absolute inline-flex h-full w-full rounded-full bg-amber-400 opacity-75"></span>
<span className="relative inline-flex rounded-full h-2.5 w-2.5 bg-amber-500"></span>
</span>
</div> </div>
<div> <div>
<span className="inline-flex items-center gap-1.5 text-[11px] font-semibold tracking-wider uppercase text-amber-400 bg-amber-500/10 border border-amber-500/20 px-2 py-0.5 rounded-full mb-1"> <h2 id="activation-modal-title" className="text-base font-bold text-white tracking-tight">
Access Restricted • Lockout Active {isTenantIssue ? 'Tenant Access Restricted' : 'Account Pending Activation'}
</span>
<h2 id="activation-modal-title" className="text-lg sm:text-xl font-bold text-white tracking-tight">
{errorMessage?.toLowerCase().includes('tenant')
? 'Tenant Lockout • Account Inactive'
: 'Account Pending Administrator Activation'}
</h2> </h2>
<p className="text-xs text-slate-400 mt-0.5">
{isTenantIssue
? 'The assigned tenant partition is currently inactive or restricted.'
: 'Your account is authenticated and awaiting administrator enablement.'}
</p>
</div> </div>
</div> </div>
<BrandLogo size={36} showGlow={false} animateOnHover={false} /> <BrandLogo size={32} showGlow={false} animateOnHover={false} />
</div> </div>
</div> </div>
{/* Modal Body */} {/* Body */}
<div className="p-6 space-y-5 overflow-y-auto max-h-[70vh]"> <div className="p-5 space-y-4">
{/* Status notification block */} {/* User Profile Summary */}
<div className="bg-slate-900/80 border border-slate-800 rounded-xl p-4 space-y-2.5"> <div className="rounded-xl border border-slate-800/80 bg-slate-900/60 p-3.5 space-y-2 text-xs">
<div className="flex items-center gap-2 text-emerald-400 text-xs font-semibold"> <div className="grid grid-cols-2 gap-2.5">
<CheckCircle2 className="w-4 h-4 shrink-0" /> <div>
<span>Identity Verified & Signed In Successfully</span> <span className="text-[10px] uppercase font-semibold text-slate-500 block">User</span>
<span className="font-semibold text-white truncate block">{displayName}</span>
</div>
<div>
<span className="text-[10px] uppercase font-semibold text-slate-500 block">Email</span>
<span className="text-slate-300 truncate block">{displayEmail || '—'}</span>
</div>
</div> </div>
<p className="text-xs text-slate-300 leading-relaxed"> <div className="grid grid-cols-2 gap-2.5 border-t border-slate-800/60 pt-2">
You are a registered user and have successfully authenticated via Enterprise Single Sign-On. <div>
However, your account has not yet been enabled in the system directory by a system administrator. <span className="text-[10px] uppercase font-semibold text-slate-500 block">Tenant</span>
</p> <span className="text-slate-300 truncate block">{tenant}</span>
<p className="text-xs text-slate-400 leading-relaxed border-t border-slate-800/80 pt-2 font-medium">
If your administrator informs you that your account has been activated, please click the{' '}
<strong className="text-white">Sync Account</strong> button below to refresh your status and unlock the system.
</p>
</div>
{/* User Profile Card */}
<div className="rounded-xl border border-slate-800 bg-[#090e1a]/60 p-4 space-y-3">
<div className="text-[11px] font-semibold text-slate-400 uppercase tracking-wider flex items-center justify-between">
<span>Authenticated User Profile</span>
<span className="text-amber-400/90 text-[10px] font-mono bg-amber-500/10 px-2 py-0.5 rounded border border-amber-500/20">
Lockout Active
</span>
</div>
<div className="grid grid-cols-1 sm:grid-cols-2 gap-3 text-xs">
<div className="flex items-center gap-2.5 bg-slate-900/60 p-2.5 rounded-lg border border-slate-800/60">
<UserCheck className="w-4 h-4 text-blue-400 shrink-0" />
<div className="min-w-0">
<span className="text-[10px] text-slate-500 block">Name</span>
<span className="font-semibold text-white truncate block">{displayName}</span>
</div>
</div> </div>
<div>
<div className="flex items-center gap-2.5 bg-slate-900/60 p-2.5 rounded-lg border border-slate-800/60"> <span className="text-[10px] uppercase font-semibold text-slate-500 block">Role</span>
<Mail className="w-4 h-4 text-blue-400 shrink-0" /> <span className="text-blue-400 font-medium truncate block">{role}</span>
<div className="min-w-0">
<span className="text-[10px] text-slate-500 block">Email</span>
<span className="font-semibold text-slate-200 truncate block">{displayEmail || 'Not specified'}</span>
</div>
</div>
<div className="flex items-center gap-2.5 bg-slate-900/60 p-2.5 rounded-lg border border-slate-800/60">
<Building2 className="w-4 h-4 text-indigo-400 shrink-0" />
<div className="min-w-0">
<span className="text-[10px] text-slate-500 block">Tenant / Role</span>
<span className="font-semibold text-slate-200 truncate block">
{tenant} ({role})
</span>
</div>
</div>
<div className="flex items-center gap-2.5 bg-slate-900/60 p-2.5 rounded-lg border border-slate-800/60">
<Fingerprint className="w-4 h-4 text-slate-400 shrink-0" />
<div className="min-w-0">
<span className="text-[10px] text-slate-500 block">Subject ID</span>
<span className="font-mono text-[11px] text-slate-400 truncate block">{sub || 'SSO-Principal'}</span>
</div>
</div> </div>
</div> </div>
</div> </div>
{/* Sync Attempt Feedback / Error Notice */} {/* Feedback notice if sync failed */}
{errorMessage && ( {errorMessage && (
<div className="rounded-xl border border-rose-500/30 bg-rose-500/10 p-3.5 flex items-start gap-3 animate-in fade-in duration-150"> <div className="rounded-xl border border-amber-500/25 bg-amber-500/10 p-3 flex items-start gap-2.5 text-xs text-amber-200">
<AlertTriangle className="w-4 h-4 text-rose-400 shrink-0 mt-0.5" /> <AlertTriangle className="w-4 h-4 text-amber-400 shrink-0 mt-0.5" />
<div className="space-y-1"> <div className="min-w-0">
<p className="text-xs font-semibold text-rose-300"> <span className="font-semibold text-amber-300 block">Notice:</span>
{errorMessage?.toLowerCase().includes('tenant') <p className="text-[11px] text-amber-200/90 leading-relaxed mt-0.5">{errorMessage}</p>
? 'Tenant Lockout In Effect'
: 'Account Not Activated Yet'}
</p>
<p className="text-xs text-rose-200/90 leading-relaxed">{errorMessage}</p>
{syncAttemptCount > 0 && (
<p className="text-[11px] text-rose-400/80 pt-1">
Attempt #{syncAttemptCount} checked at {new Date().toLocaleTimeString()}
</p>
)}
</div> </div>
</div> </div>
)} )}
<p className="text-[11px] text-slate-400 text-center">
Once your administrator enables your account, click <strong className="text-white">Sync Account</strong> to enter the console.
</p>
</div> </div>
{/* Modal Actions */} {/* Footer Actions */}
<div className="p-5 border-t border-slate-800/80 bg-[#0a0f1d] flex flex-col sm:flex-row items-center justify-between gap-3"> <div className="p-4 border-t border-slate-800 bg-[#0a0f1d] flex flex-col sm:flex-row items-center justify-between gap-2.5">
<div className="flex items-center gap-2 w-full sm:w-auto"> <div className="flex items-center gap-2 w-full sm:w-auto">
<button <button
onClick={handleNavigateLanding} onClick={handleNavigateLanding}
type="button" type="button"
className="flex-1 sm:flex-none inline-flex items-center justify-center gap-1.5 px-3.5 py-2 text-xs font-medium text-slate-400 hover:text-slate-200 bg-slate-900 hover:bg-slate-800 border border-slate-800 rounded-xl transition cursor-pointer" className="flex-1 sm:flex-none inline-flex items-center justify-center gap-1.5 px-3 py-2 text-xs font-medium text-slate-400 hover:text-white bg-slate-900 hover:bg-slate-800 border border-slate-800 rounded-xl transition cursor-pointer"
title="View public landing page. Entering the console again will display this modal until enabled."
> >
<ArrowLeft className="w-3.5 h-3.5" /> <ArrowLeft className="w-3.5 h-3.5" />
<span>Landing Page</span> <span>Landing Page</span>
@@ -256,8 +211,7 @@ export const AccountActivationModal: React.FC<AccountActivationModalProps> = ({
<button <button
onClick={handleSignOut} onClick={handleSignOut}
type="button" type="button"
className="flex-1 sm:flex-none inline-flex items-center justify-center gap-1.5 px-3.5 py-2 text-xs font-medium text-slate-400 hover:text-rose-400 bg-slate-900 hover:bg-rose-500/10 border border-slate-800 hover:border-rose-500/30 rounded-xl transition cursor-pointer" className="flex-1 sm:flex-none inline-flex items-center justify-center gap-1.5 px-3 py-2 text-xs font-medium text-slate-400 hover:text-rose-400 bg-slate-900 hover:bg-rose-500/10 border border-slate-800 hover:border-rose-500/20 rounded-xl transition cursor-pointer"
title="Sign out of current SSO session"
> >
<LogOut className="w-3.5 h-3.5" /> <LogOut className="w-3.5 h-3.5" />
<span>Sign Out</span> <span>Sign Out</span>
@@ -269,13 +223,17 @@ export const AccountActivationModal: React.FC<AccountActivationModalProps> = ({
disabled={isSyncing} disabled={isSyncing}
type="button" type="button"
id="activation-sync-btn" id="activation-sync-btn"
className="w-full sm:w-auto inline-flex items-center justify-center gap-2 px-5 py-2.5 text-xs font-semibold text-white bg-blue-600 hover:bg-blue-500 active:bg-blue-700 disabled:opacity-50 disabled:pointer-events-none rounded-xl shadow-lg shadow-blue-600/30 transition cursor-pointer" className="w-full sm:w-auto inline-flex items-center justify-center gap-2 px-4 py-2 text-xs font-semibold text-white bg-blue-600 hover:bg-blue-500 active:bg-blue-700 disabled:opacity-50 rounded-xl shadow-md transition cursor-pointer"
> >
<RefreshCw className={`w-4 h-4 ${isSyncing ? 'animate-spin' : ''}`} /> <RefreshCw className={`w-3.5 h-3.5 ${isSyncing ? 'animate-spin' : ''}`} />
<span>{isSyncing ? 'Verifying Activation...' : 'Sync Account'}</span> <span>{isSyncing ? 'Checking...' : 'Sync Account'}</span>
</button> </button>
</div> </div>
</div> </div>
</div> </div>
); );
return typeof document !== 'undefined'
? createPortal(modalContent, document.body)
: modalContent;
}; };
@@ -1,4 +1,5 @@
import React, { useEffect } from 'react'; import React, { useEffect } from 'react';
import { createPortal } from 'react-dom';
import { useNavigate } from 'react-router-dom'; import { useNavigate } from 'react-router-dom';
import { import {
ShieldAlert, ShieldAlert,
@@ -54,121 +55,68 @@ export const PartnerAccessDeniedModal: React.FC<PartnerAccessDeniedModalProps> =
navigate('/', { replace: true }); navigate('/', { replace: true });
}; };
return ( const modalContent = (
<div <div
role="alertdialog" role="alertdialog"
aria-modal="true" aria-modal="true"
aria-labelledby="partner-denied-title" aria-labelledby="partner-denied-title"
aria-describedby="partner-denied-description" aria-describedby="partner-denied-description"
className="fixed inset-0 z-50 flex items-center justify-center p-4 bg-black/85 backdrop-blur-md animate-in fade-in duration-200" className="fixed inset-0 z-[9999] flex items-center justify-center p-4 bg-black/80 backdrop-blur-md animate-in fade-in duration-150 overflow-y-auto"
> >
<div className="relative w-full max-w-lg overflow-hidden rounded-2xl bg-[#0e1424] border border-rose-900/60 shadow-2xl shadow-rose-950/50"> <div className="relative w-full max-w-md overflow-hidden rounded-2xl bg-[#0d1322] border border-slate-800 shadow-2xl my-auto text-slate-100">
{/* Glow Header Accent */} <div className="h-1 w-full bg-rose-600" />
<div className="h-2 w-full bg-linear-to-r from-rose-600 via-red-500 to-amber-500" />
<div className="p-6 sm:p-8 space-y-6"> <div className="p-5 space-y-4">
{/* Header */} <div className="flex items-start gap-3">
<div className="flex items-start gap-4"> <div className="flex h-10 w-10 shrink-0 items-center justify-center rounded-xl bg-rose-500/10 border border-rose-500/20 text-rose-400">
<div className="flex h-12 w-12 shrink-0 items-center justify-center rounded-2xl bg-rose-500/15 border border-rose-500/30 text-rose-400"> <Ban className="h-5 w-5" />
<Ban className="h-6 w-6 text-rose-400" />
</div> </div>
<div className="space-y-1"> <div>
<div className="flex items-center gap-2"> <h2 id="partner-denied-title" className="text-base font-bold text-white tracking-tight">
<span className="inline-flex items-center gap-1 text-[11px] font-semibold text-rose-400 bg-rose-950/60 px-2 py-0.5 rounded-md border border-rose-800/60"> Partner Portal Required
<Lock className="w-3 h-3" />
Restricted Role
</span>
<span className="text-xs text-slate-500">Security Policy</span>
</div>
<h2 id="partner-denied-title" className="text-lg font-bold text-white tracking-tight">
Access Denied: Partner Role
</h2> </h2>
<p id="partner-denied-description" className="text-xs text-slate-400 leading-relaxed"> <p id="partner-denied-description" className="text-xs text-slate-400 mt-0.5">
Users assigned the <strong className="text-rose-300">Partner</strong> role by security are not permitted to access the PFM Backoffice Console. Accounts with the Partner role must access the Partner Portal.
</p> </p>
</div> </div>
</div> </div>
{/* User & Identity Summary Card */} <div className="rounded-xl bg-slate-900/60 border border-slate-800 p-3.5 space-y-2 text-xs">
<div className="rounded-xl bg-slate-900/80 border border-slate-800 p-4 space-y-3 text-xs"> <div className="flex items-center justify-between">
<div className="flex items-center justify-between pb-2 border-b border-slate-800/80"> <span className="text-slate-500 text-[11px] uppercase font-semibold">User</span>
<span className="text-slate-400 font-medium">Account Name</span> <span className="text-white font-medium">{displayName}</span>
<span className="text-white font-semibold">{displayName}</span>
</div> </div>
{displayEmail && ( {displayEmail && (
<div className="flex items-center justify-between pb-2 border-b border-slate-800/80"> <div className="flex items-center justify-between border-t border-slate-800/60 pt-2">
<span className="text-slate-400 font-medium flex items-center gap-1.5"> <span className="text-slate-500 text-[11px] uppercase font-semibold">Email</span>
<Mail className="w-3.5 h-3.5 text-slate-500" /> <span className="text-slate-300 font-mono text-[11px]">{displayEmail}</span>
Email
</span>
<span className="text-slate-200 font-mono">{displayEmail}</span>
</div> </div>
)} )}
<div className="flex items-center justify-between border-t border-slate-800/60 pt-2">
<div className="flex items-center justify-between pb-2 border-b border-slate-800/80"> <span className="text-slate-500 text-[11px] uppercase font-semibold">Role</span>
<span className="text-slate-400 font-medium flex items-center gap-1.5"> <span className="text-rose-400 font-medium">{role}</span>
<Building2 className="w-3.5 h-3.5 text-slate-500" />
Assigned Role
</span>
<span className="text-rose-400 font-semibold bg-rose-950/60 px-2 py-0.5 rounded border border-rose-800/50">
{role}
</span>
</div> </div>
{tenant && (
<div className="flex items-center justify-between pb-2 border-b border-slate-800/80">
<span className="text-slate-400 font-medium flex items-center gap-1.5">
<Building2 className="w-3.5 h-3.5 text-slate-500" />
Tenant Context
</span>
<span className="text-slate-200 font-medium truncate max-w-[220px]">{tenant}</span>
</div>
)}
{sub && (
<div className="flex items-center justify-between text-[11px] text-slate-500">
<span className="flex items-center gap-1">
<Fingerprint className="w-3 h-3" />
Subject Key
</span>
<span className="font-mono text-slate-400 truncate max-w-[200px]">{sub}</span>
</div>
)}
</div> </div>
{/* Security Policy Information */} <p className="text-[11px] text-slate-400 text-center leading-relaxed">
<div className="p-4 rounded-xl bg-rose-950/20 border border-rose-900/40 text-xs text-rose-200 leading-relaxed space-y-1.5"> The Console is reserved for statutory administrators and programme managers.
<p className="font-semibold text-rose-300 flex items-center gap-1.5"> </p>
<ShieldAlert className="w-4 h-4 text-rose-400" />
Administrative Boundary Notice
</p>
<p className="text-[11px] text-rose-300/90">
The PFM Console is reserved for statutory administrators, sponsors, programme managers, and governance officers. Partner credentials cannot access this environment. If you require backoffice administrative access, please request an updated role assignment from your system administrator.
</p>
</div>
{/* Actions */} <div className="pt-1">
<div className="pt-2">
<button <button
onClick={handleLogoutClick} onClick={handleLogoutClick}
className="w-full flex items-center justify-center gap-2 py-2.5 px-4 rounded-xl bg-slate-800 hover:bg-slate-700 text-slate-200 hover:text-white text-xs font-semibold border border-slate-700 transition cursor-pointer shadow-sm" className="w-full flex items-center justify-center gap-2 py-2.5 px-4 rounded-xl bg-blue-600 hover:bg-blue-500 text-white text-xs font-semibold shadow-md transition cursor-pointer"
> >
<LogOut className="w-4 h-4 text-rose-400" /> <LogOut className="w-4 h-4" />
<span>Sign Out & Return to Home</span> <span>Sign Out & Return Home</span>
</button> </button>
</div> </div>
</div> </div>
{/* Footer Brand watermark */}
<div className="px-6 py-3 bg-[#0a0f1d] border-t border-slate-800/80 flex items-center justify-between text-[11px] text-slate-500">
<div className="flex items-center gap-2">
<BrandLogo size={16} showGlow={false} animateOnHover={false} />
<span>Post Fund Management (PFM)</span>
</div>
<span>National Security Gateway</span>
</div>
</div> </div>
</div> </div>
); );
return typeof document !== 'undefined'
? createPortal(modalContent, document.body)
: modalContent;
}; };
@@ -1,4 +1,5 @@
import React, { useEffect, useState, useRef } from 'react'; import React, { useEffect, useState, useRef } from 'react';
import { createPortal } from 'react-dom';
import { ShieldAlert, Clock, ArrowRight, X, AlertTriangle, LogOut } from 'lucide-react'; import { ShieldAlert, Clock, ArrowRight, X, AlertTriangle, LogOut } from 'lucide-react';
import { clearSessionAndRedirectToLanding, dismissSubordinateModals } from '../../services/sessionExpiration'; import { clearSessionAndRedirectToLanding, dismissSubordinateModals } from '../../services/sessionExpiration';
@@ -83,10 +84,10 @@ export const SessionExpiredModal: React.FC<SessionExpiredModalProps> = ({
const secondsRemaining = Math.max(0, Math.ceil(timeLeftMs / 1000)); const secondsRemaining = Math.max(0, Math.ceil(timeLeftMs / 1000));
const progressPercent = Math.max(0, Math.min(100, (timeLeftMs / totalDurationMs) * 100)); const progressPercent = Math.max(0, Math.min(100, (timeLeftMs / totalDurationMs) * 100));
return ( const modalContent = (
<div <div
id="session-expired-modal-backdrop" id="session-expired-modal-backdrop"
className="fixed inset-0 z-[9999] flex items-center justify-center bg-black/85 backdrop-blur-md p-4 animate-in fade-in duration-200" className="fixed inset-0 z-[9999] flex items-center justify-center bg-black/80 backdrop-blur-md p-4 animate-in fade-in duration-150 overflow-y-auto"
onMouseEnter={() => setIsPaused(true)} onMouseEnter={() => setIsPaused(true)}
onMouseLeave={() => setIsPaused(false)} onMouseLeave={() => setIsPaused(false)}
role="dialog" role="dialog"
@@ -95,27 +96,20 @@ export const SessionExpiredModal: React.FC<SessionExpiredModalProps> = ({
> >
<div <div
id="session-expired-modal-content" id="session-expired-modal-content"
className="relative w-full max-w-md bg-[#0c121e] border border-amber-500/40 rounded-2xl shadow-2xl shadow-black/90 p-6 overflow-hidden text-slate-200 space-y-5" className="relative w-full max-w-md bg-[#0d1322] border border-slate-800 rounded-2xl shadow-2xl p-5 overflow-hidden text-slate-200 space-y-4 my-auto"
> >
{/* Subtle decorative top accent bar */} <div className="absolute top-0 left-0 right-0 h-1 bg-amber-500" />
<div className="absolute top-0 left-0 right-0 h-1 bg-gradient-to-r from-amber-500 via-orange-500 to-rose-500" />
{/* Top bar with close button */}
<div className="flex items-start justify-between pt-1"> <div className="flex items-start justify-between pt-1">
<div className="flex items-center gap-3"> <div className="flex items-center gap-3">
<div className="w-12 h-12 rounded-2xl bg-amber-500/10 border border-amber-500/30 flex items-center justify-center text-amber-400 shrink-0 shadow-lg shadow-amber-500/10"> <div className="w-10 h-10 rounded-xl bg-amber-500/10 border border-amber-500/20 flex items-center justify-center text-amber-400 shrink-0">
<ShieldAlert className="w-6 h-6" /> <ShieldAlert className="w-5 h-5" />
</div> </div>
<div> <div>
<div className="flex items-center gap-2"> <h3 id="session-expired-title" className="text-base font-bold text-white tracking-tight">
<h3 id="session-expired-title" className="text-base font-bold text-white tracking-tight"> Session Expired
Session Expired </h3>
</h3> <p className="text-xs text-slate-400">Authorization credentials expired</p>
<span className="text-[10px] font-mono font-semibold px-2 py-0.5 rounded bg-rose-950/60 text-rose-300 border border-rose-800/60">
HTTP 401
</span>
</div>
<p className="text-xs text-slate-400 mt-0.5">Authorization token invalidated</p>
</div> </div>
</div> </div>
@@ -124,63 +118,53 @@ export const SessionExpiredModal: React.FC<SessionExpiredModalProps> = ({
type="button" type="button"
onClick={handleAction} onClick={handleAction}
aria-label="Dismiss and return to landing page" aria-label="Dismiss and return to landing page"
className="p-1.5 text-slate-400 hover:text-white rounded-lg hover:bg-slate-800/80 transition-colors" className="p-1.5 text-slate-400 hover:text-white rounded-lg hover:bg-slate-800 transition-colors cursor-pointer"
> >
<X className="w-4 h-4" /> <X className="w-4 h-4" />
</button> </button>
</div> </div>
{/* Informative Explanation (non-technical and clear) */} <div className="text-xs text-slate-300 leading-relaxed bg-slate-900/60 p-3.5 rounded-xl border border-slate-800/80 space-y-1.5">
<div className="space-y-2.5 text-xs text-slate-300 leading-relaxed bg-slate-900/60 p-4 rounded-xl border border-slate-800/80">
<p> <p>
Your active working session has ended or your security credentials have expired. To protect your data and prevent unauthorized changes, your session has been closed. Your active session has ended to protect account security. Please sign in again to continue working.
</p> </p>
{reason && ( {reason && (
<div className="flex items-start gap-2 pt-1 text-[11px] text-amber-300/90 font-mono"> <div className="flex items-start gap-1.5 pt-1 text-[11px] text-amber-300/90 font-mono">
<AlertTriangle className="w-3.5 h-3.5 shrink-0 mt-0.5 text-amber-400" /> <AlertTriangle className="w-3.5 h-3.5 shrink-0 mt-0.5 text-amber-400" />
<span className="break-words">{reason}</span> <span className="break-words">{reason}</span>
</div> </div>
)} )}
</div> </div>
{/* Progress Bar Countdown */} <div className="flex items-center justify-between text-xs text-slate-400">
<div className="space-y-2"> <span className="flex items-center gap-1.5">
<div className="flex items-center justify-between text-xs"> <Clock className="w-3.5 h-3.5 text-amber-400" />
<span className="text-slate-400 flex items-center gap-1.5"> Redirecting in <strong className="text-white font-mono">{secondsRemaining}s</strong>
<Clock className="w-3.5 h-3.5 text-amber-400" /> </span>
Returning to landing page in{' '} {isPaused && (
<strong className="text-white font-mono">{secondsRemaining}s</strong> <span className="text-[10px] text-amber-400/80 px-1.5 py-0.5 rounded bg-amber-500/10 border border-amber-500/20">
Paused
</span> </span>
{isPaused && ( )}
<span className="text-[10px] text-amber-400/80 font-medium px-1.5 py-0.2 rounded bg-amber-950/40 border border-amber-900/60">
Paused on hover
</span>
)}
</div>
{/* Progress bar line */}
<div className="w-full h-2 bg-slate-800/90 rounded-full overflow-hidden border border-slate-700/60 p-0.5">
<div
className="h-full rounded-full bg-gradient-to-r from-amber-500 via-orange-500 to-rose-500 transition-all duration-75 ease-linear"
style={{ width: `${progressPercent}%` }}
/>
</div>
</div> </div>
{/* Actions */} <div className="pt-1">
<div className="pt-2 flex flex-col sm:flex-row items-center gap-2.5">
<button <button
id="session-expired-return-btn" id="session-expired-return-btn"
type="button" type="button"
onClick={handleAction} onClick={handleAction}
className="w-full py-2.5 px-4 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-all flex items-center justify-center gap-2" className="w-full py-2.5 px-4 text-xs font-semibold text-white bg-blue-600 hover:bg-blue-500 rounded-xl shadow-md transition-all flex items-center justify-center gap-2 cursor-pointer"
> >
<LogOut className="w-4 h-4" /> <LogOut className="w-4 h-4" />
<span>Return to Landing Page Now</span> <span>Return to Landing Page</span>
<ArrowRight className="w-3.5 h-3.5 ml-0.5" /> <ArrowRight className="w-3.5 h-3.5" />
</button> </button>
</div> </div>
</div> </div>
</div> </div>
); );
return typeof document !== 'undefined'
? createPortal(modalContent, document.body)
: modalContent;
}; };
@@ -48,7 +48,7 @@ const instrumentSchema = z.object({
name: z.string().min(3, 'Funding instrument title must be at least 3 characters'), name: z.string().min(3, 'Funding instrument title must be at least 3 characters'),
code: z.string().optional(), code: z.string().optional(),
description: z.string().min(5, 'Description is required (minimum 5 characters)'), description: z.string().min(5, 'Description is required (minimum 5 characters)'),
status: z.coerce.number().default(ActivityStatus.Active), status: z.coerce.number(),
}); });
type InstrumentFormValues = z.infer<typeof instrumentSchema>; type InstrumentFormValues = z.infer<typeof instrumentSchema>;
@@ -169,8 +169,8 @@ export const FundingInstrumentsSection: React.FC<FundingInstrumentsSectionProps>
setEditingInstrument(null); setEditingInstrument(null);
if (onCloseCreateModalExternal) onCloseCreateModalExternal(); if (onCloseCreateModalExternal) onCloseCreateModalExternal();
}, },
onError: (err: any) => { onError: (err: unknown) => {
toastError('Save Failed', err?.message || 'Failed to save funding instrument'); toastError('Save Failed', err);
}, },
}); });
@@ -351,6 +351,7 @@ export const FundingInstrumentsSection: React.FC<FundingInstrumentsSectionProps>
</button> </button>
<ExportButton <ExportButton
claim="governance"
title="Funding Instruments Registry" title="Funding Instruments Registry"
subtitle="Authoritative funding instruments and financing directives attached to strategic programmes" subtitle="Authoritative funding instruments and financing directives attached to strategic programmes"
filename={`pfm-funding-instruments-${new Date().toISOString().split('T')[0]}`} filename={`pfm-funding-instruments-${new Date().toISOString().split('T')[0]}`}
@@ -46,8 +46,8 @@ const ruleSchema = z.object({
programmeId: z.coerce.number().min(1, 'Please select an associated programme from the dropdown'), programmeId: z.coerce.number().min(1, 'Please select an associated programme from the dropdown'),
name: z.string().min(3, 'Rule name must be at least 3 characters'), name: z.string().min(3, 'Rule name must be at least 3 characters'),
description: z.string().min(5, 'Description is required (minimum 5 characters)'), description: z.string().min(5, 'Description is required (minimum 5 characters)'),
version: z.coerce.number().min(1, 'Version must be at least 1').default(1), version: z.coerce.number().min(1, 'Version must be at least 1'),
status: z.coerce.number().default(ActivityStatus.Active), status: z.coerce.number(),
}); });
type RuleFormValues = z.infer<typeof ruleSchema>; type RuleFormValues = z.infer<typeof ruleSchema>;
@@ -166,8 +166,8 @@ export const RulesSection: React.FC<RulesSectionProps> = ({
setEditingRule(null); setEditingRule(null);
if (onCloseCreateModalExternal) onCloseCreateModalExternal(); if (onCloseCreateModalExternal) onCloseCreateModalExternal();
}, },
onError: (err: any) => { onError: (err: unknown) => {
toastError('Save Failed', err?.message || 'Failed to save programme rule'); toastError('Save Failed', err);
}, },
}); });
@@ -347,6 +347,7 @@ export const RulesSection: React.FC<RulesSectionProps> = ({
</button> </button>
<ExportButton <ExportButton
claim="governance"
title="Programme Rules Registry" title="Programme Rules Registry"
subtitle="Statutory, regulatory, and eligibility rules governing strategic funding programmes" subtitle="Statutory, regulatory, and eligibility rules governing strategic funding programmes"
filename={`pfm-programme-rules-${new Date().toISOString().split('T')[0]}`} filename={`pfm-programme-rules-${new Date().toISOString().split('T')[0]}`}
@@ -1,6 +1,8 @@
import React, { useState } from 'react'; 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 { import {
Inbox,
LayoutDashboard, LayoutDashboard,
Building2, Building2,
FolderKanban, FolderKanban,
@@ -91,6 +93,13 @@ const navItems: NavItem[] = [
help: 'Log physical site visits, field observations, inspector findings, and project feedback.', help: 'Log physical site visits, field observations, inspector findings, and project feedback.',
claim: 'site_visits', claim: 'site_visits',
}, },
{
name: 'Compliance',
path: '/compliance',
icon: ShieldCheck,
help: 'Portfolio & programme compliance dashboard, statutory requirements, beneficiary outcome fulfillment, and risk monitoring.',
claim: 'compliance',
},
], ],
}, },
{ {
@@ -114,6 +123,12 @@ const navItems: NavItem[] = [
help: 'Multi-tenant partition directory, organizational onboarding, status lifecycle controls, and system-wide isolation boundaries.', help: 'Multi-tenant partition directory, organizational onboarding, status lifecycle controls, and system-wide isolation boundaries.',
claim: 'tenant_management', claim: 'tenant_management',
}, },
{
name: 'Inbox',
path: '/inbox',
icon: Inbox,
help: 'In-app notifications, communications, deliverable messages, drafts, and replies across active tenant users.',
},
{ {
name: 'Settings & Communications', name: 'Settings & Communications',
path: '/settings', path: '/settings',
@@ -129,6 +144,27 @@ export const AppLayout: React.FC = () => {
const [sidebarOpen, setSidebarOpen] = useState(true); const [sidebarOpen, setSidebarOpen] = useState(true);
const [userMenuOpen, setUserMenuOpen] = useState(false); const [userMenuOpen, setUserMenuOpen] = useState(false);
const navigate = useNavigate(); 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 () => { const handleLogout = async () => {
await logout(); await logout();
@@ -171,7 +207,6 @@ export const AppLayout: React.FC = () => {
useQuery({ useQuery({
queryKey: ['users-directory'], queryKey: ['users-directory'],
queryFn: () => getUsers(1, 200), queryFn: () => getUsers(1, 200),
staleTime: 5 * 60 * 1000,
}); });
// Query tenant endpoint to resolve tenant name if not provided in claims or profile // Query tenant endpoint to resolve tenant name if not provided in claims or profile
@@ -179,7 +214,6 @@ export const AppLayout: React.FC = () => {
queryKey: ['active-tenant-header', tenantId], queryKey: ['active-tenant-header', tenantId],
queryFn: () => getTenant(tenantId!), queryFn: () => getTenant(tenantId!),
enabled: !!tenantId && !claims?.tenant && !user?.tenant && !authTenantName, enabled: !!tenantId && !claims?.tenant && !user?.tenant && !authTenantName,
staleTime: 5 * 60 * 1000,
}); });
// Ensure current logged-in user is registered in the email cache // Ensure current logged-in user is registered in the email cache
@@ -331,26 +365,45 @@ export const AppLayout: React.FC = () => {
); );
} }
const isCollapsed = isSectionCollapsed(item.path, item.subItems);
return ( return (
<div key={item.path} className="space-y-1"> <div key={item.path} className="space-y-1">
<NavLink <div className="flex items-center gap-1">
to={item.path} <NavLink
end={hasSub} to={item.path}
className={({ isActive }) => end={hasSub}
`flex items-center gap-3 px-3.5 py-2.5 rounded-xl text-xs font-medium transition-all min-w-0 ${ className={({ isActive }) =>
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 ${
? 'bg-gradient-to-r from-blue-600 to-indigo-600 text-white shadow-md shadow-blue-600/25 font-semibold' isActive
: 'text-slate-400 hover:text-slate-100 hover:bg-slate-800/60' ? '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} }
> title={item.name}
<Icon className="w-4.5 h-4.5 shrink-0" /> >
<span className="truncate flex-1">{item.name}</span> <Icon className="w-4.5 h-4.5 shrink-0" />
</NavLink> <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 */} {/* 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"> <div className="pl-6 pr-1 py-1 space-y-1 border-l border-slate-800/80 ml-5">
{item.subItems?.map((subItem) => { {item.subItems?.map((subItem) => {
const SubIcon = subItem.icon; const SubIcon = subItem.icon;
@@ -518,6 +571,8 @@ export const AppLayout: React.FC = () => {
{/* User Profile and Controls */} {/* User Profile and Controls */}
<div className="relative flex items-center gap-3"> <div className="relative flex items-center gap-3">
{/* Notification Bell in close proximity to username */}
<NotificationBell />
<div className="text-right hidden sm:block"> <div className="text-right hidden sm:block">
<div className="flex items-center justify-end gap-1.5"> <div className="flex items-center justify-end gap-1.5">
<p className="text-xs font-semibold text-white truncate max-w-[150px]" title={tokenUserName}> <p className="text-xs font-semibold text-white truncate max-w-[150px]" title={tokenUserName}>
@@ -527,9 +582,6 @@ export const AppLayout: React.FC = () => {
{userRole} {userRole}
</span> </span>
</div> </div>
<p className="text-[11px] text-slate-400 font-mono truncate max-w-[200px]" title={tokenUserEmail}>
{tokenUserEmail}
</p>
</div> </div>
<button <button
@@ -558,6 +610,14 @@ export const AppLayout: React.FC = () => {
<p className="text-[10px] text-slate-400 mt-1">Role: <strong className="text-blue-300">{userRole}</strong></p> <p className="text-[10px] text-slate-400 mt-1">Role: <strong className="text-blue-300">{userRole}</strong></p>
</div> </div>
<Link
to="/inbox"
onClick={() => setUserMenuOpen(false)}
className="flex items-center gap-2 px-3 py-2 text-xs text-slate-300 hover:text-white hover:bg-slate-800/60 rounded-xl transition"
>
<Inbox className="w-3.5 h-3.5 text-blue-400" />
<span>In-App Mailbox</span>
</Link>
<Link <Link
to="/" to="/"
onClick={() => setUserMenuOpen(false)} onClick={() => setUserMenuOpen(false)}
@@ -581,18 +641,21 @@ export const AppLayout: React.FC = () => {
</header> </header>
{/* Main Content Area */} {/* Main Content Area */}
<main className="flex-1 overflow-y-auto p-6 md:p-8 bg-[#090d16] relative text-slate-100"> <main className="flex-1 overflow-y-auto min-w-0 p-4 sm:p-6 md:p-8 bg-[#090d16] relative text-slate-100">
<div {/* Ambient Glows & Dot Grid - Isolated inside overflow-hidden to prevent phantom page scrollbars */}
className="absolute inset-0 pointer-events-none opacity-15" <div className="absolute inset-0 overflow-hidden pointer-events-none">
style={{ <div
backgroundImage: 'radial-gradient(circle at 1px 1px, rgba(255,255,255,0.15) 1px, transparent 0)', className="absolute inset-0 opacity-15"
backgroundSize: '32px 32px', style={{
}} backgroundImage: 'radial-gradient(circle at 1px 1px, rgba(255,255,255,0.15) 1px, transparent 0)',
/> backgroundSize: '32px 32px',
<div className="absolute -top-24 -left-24 w-80 h-80 bg-blue-600/10 rounded-full blur-3xl pointer-events-none" /> }}
<div className="absolute top-1/2 -right-24 w-80 h-80 bg-indigo-600/10 rounded-full blur-3xl pointer-events-none" /> />
<div className="absolute -top-24 -left-24 w-80 h-80 bg-blue-600/10 rounded-full blur-3xl" />
<div className="absolute top-1/2 -right-24 w-80 h-80 bg-indigo-600/10 rounded-full blur-3xl" />
</div>
<div className="relative z-10"> <div className="relative z-10 min-w-0 w-full">
<ErrorBoundary> <ErrorBoundary>
<Outlet /> <Outlet />
</ErrorBoundary> </ErrorBoundary>
@@ -0,0 +1,216 @@
import React, { useState, useRef, useEffect } from 'react';
import { useNavigate } from 'react-router-dom';
import {
Bell,
Mail,
CheckCheck,
ChevronRight,
Inbox,
Clock,
Sparkles,
} from 'lucide-react';
import { useInAppNotifications } from '../../hooks/useInAppNotifications';
import { formatDate } from '../../lib/formatters';
import { stripMarkdown } from '../../lib/markdownUtils';
export const NotificationBell: React.FC = () => {
const navigate = useNavigate();
const { top5Unread, unreadCount, markAsRead, markAllAsRead } = useInAppNotifications();
const [isOpen, setIsOpen] = useState(false);
const leaveTimerRef = useRef<number | null>(null);
const containerRef = useRef<HTMLDivElement>(null);
// Clear timer on unmount
useEffect(() => {
return () => {
if (leaveTimerRef.current) {
window.clearTimeout(leaveTimerRef.current);
}
};
}, []);
const handleMouseEnter = () => {
if (leaveTimerRef.current) {
window.clearTimeout(leaveTimerRef.current);
leaveTimerRef.current = null;
}
setIsOpen(true);
};
const handleMouseLeave = () => {
leaveTimerRef.current = window.setTimeout(() => {
setIsOpen(false);
}, 250);
};
const handleNotificationClick = async (id: number | string) => {
await markAsRead(id);
setIsOpen(false);
navigate(`/inbox?id=${id}`);
};
const handleViewAll = () => {
setIsOpen(false);
navigate('/inbox');
};
// Format relative timestamp
const getRelativeTime = (dateStr?: string) => {
if (!dateStr) return '';
try {
const now = new Date();
const date = new Date(dateStr);
const diffMs = now.getTime() - date.getTime();
const diffMins = Math.floor(diffMs / (1000 * 60));
const diffHours = Math.floor(diffMs / (1000 * 60 * 60));
const diffDays = Math.floor(diffMs / (1000 * 60 * 60 * 24));
if (diffMins < 1) return 'Just now';
if (diffMins < 60) return `${diffMins}m ago`;
if (diffHours < 24) return `${diffHours}h ago`;
if (diffDays < 7) return `${diffDays}d ago`;
return formatDate(dateStr);
} catch {
return '';
}
};
return (
<div
ref={containerRef}
className="relative"
onMouseEnter={handleMouseEnter}
onMouseLeave={handleMouseLeave}
>
{/* Bell Button */}
<button
type="button"
onClick={() => setIsOpen(prev => !prev)}
id="header-notification-bell-btn"
className={`relative h-9 w-9 rounded-full flex items-center justify-center transition cursor-pointer ${
isOpen
? 'bg-blue-600/30 text-white border border-blue-400/50 shadow-md shadow-blue-500/20'
: 'bg-slate-900/80 hover:bg-slate-800 text-slate-300 hover:text-white border border-slate-700/80 hover:border-slate-600 shadow-xs'
}`}
title={`Notifications • ${unreadCount} unread`}
aria-label="In-App Notifications"
>
<Bell className="w-4 h-4" />
{/* Unread Count Badge */}
{unreadCount > 0 && (
<span
id="notification-unread-count-badge"
className="absolute -top-1 -right-1 flex items-center justify-center min-w-[18px] h-[18px] px-1 rounded-full bg-rose-600 text-[10px] font-extrabold text-white shadow-lg ring-2 ring-[#0d121f] animate-pulse"
>
{unreadCount > 99 ? '99+' : unreadCount}
</span>
)}
</button>
{/* Floating Hover Popover */}
{isOpen && (
<div
id="notification-bell-dropdown"
className="absolute right-0 top-full mt-2 w-80 sm:w-96 bg-[#0d1322] border border-slate-700/80 rounded-2xl shadow-2xl p-0 z-50 backdrop-blur-xl animate-in fade-in slide-in-from-top-2 overflow-hidden"
>
{/* Popover Header */}
<div className="px-4 py-3 border-b border-slate-800 flex items-center justify-between bg-gradient-to-r from-blue-950/40 via-slate-900/40 to-slate-900/30">
<div className="flex items-center gap-2">
<Mail className="w-4 h-4 text-blue-400" />
<h4 className="text-xs font-bold text-white tracking-tight">In-App Notifications</h4>
{unreadCount > 0 && (
<span className="text-[10px] font-semibold text-rose-300 bg-rose-500/20 border border-rose-500/30 px-1.5 py-0.2 rounded-full">
{unreadCount} unread
</span>
)}
</div>
{unreadCount > 0 && (
<button
type="button"
onClick={markAllAsRead}
className="flex items-center gap-1 text-[11px] text-blue-400 hover:text-blue-300 transition cursor-pointer"
title="Mark all notifications as read"
>
<CheckCheck className="w-3.5 h-3.5" />
<span>Mark read</span>
</button>
)}
</div>
{/* Top 5 Notifications List */}
<div className="max-h-80 overflow-y-auto divide-y divide-slate-800/60">
{top5Unread.length === 0 ? (
<div className="py-8 px-4 text-center">
<div className="w-10 h-10 mx-auto rounded-full bg-emerald-500/10 border border-emerald-500/20 flex items-center justify-center text-emerald-400 mb-2">
<Sparkles className="w-5 h-5" />
</div>
<p className="text-xs font-semibold text-slate-200">All caught up!</p>
<p className="text-[11px] text-slate-400 mt-0.5">
No unread in-app notifications at this time.
</p>
</div>
) : (
top5Unread.map(item => {
const relativeTime = getRelativeTime(item.createdAt);
return (
<div
key={item.id}
onClick={() => handleNotificationClick(item.id)}
className="p-3.5 hover:bg-slate-800/60 transition cursor-pointer group flex items-start gap-3 relative"
>
{/* Unread Accent Indicator */}
<div className="w-2 h-2 rounded-full bg-blue-500 mt-1.5 shrink-0 shadow-sm shadow-blue-500" />
<div className="flex-1 min-w-0">
<div className="flex items-center justify-between gap-1 mb-0.5">
<p className="text-xs font-semibold text-white group-hover:text-blue-400 transition truncate">
{item.subject || 'In-App Notification'}
</p>
{relativeTime && (
<span className="text-[10px] text-slate-400 shrink-0 font-mono flex items-center gap-1">
<Clock className="w-2.5 h-2.5" />
{relativeTime}
</span>
)}
</div>
<p className="text-[11px] text-slate-300 line-clamp-2 leading-relaxed">
{stripMarkdown(item.message || 'No additional message content.')}
</p>
{item.destination && (
<p className="text-[10px] text-slate-400 font-mono mt-1 truncate">
{item.destination}
</p>
)}
</div>
<ChevronRight className="w-4 h-4 text-slate-400 group-hover:text-white group-hover:translate-x-0.5 transition shrink-0 mt-1" />
</div>
);
})
)}
</div>
{/* Popover Footer: Open Inbox */}
<div className="p-2.5 border-t border-slate-800 bg-[#090d16]/90 flex items-center justify-between">
<button
type="button"
onClick={handleViewAll}
id="view-mailbox-btn"
className="w-full flex items-center justify-center gap-1.5 py-1.5 px-3 rounded-xl bg-blue-600/20 hover:bg-blue-600/30 text-blue-300 hover:text-white border border-blue-500/30 text-xs font-semibold transition cursor-pointer shadow-xs"
>
<Inbox className="w-3.5 h-3.5" />
<span>Go to Mailbox ({unreadCount} unread)</span>
<ChevronRight className="w-3 h-3 ml-0.5" />
</button>
</div>
</div>
)}
</div>
);
};
@@ -36,7 +36,7 @@ import {
getAllProgrammes, getAllProgrammes,
getProjects, getProjects,
} from '../../services/api'; } from '../../services/api';
import { toolsService } from '../../services/toolsService'; import { toolsService } from '../../utils/hashids';
import { Modal } from '../ui/Modal'; import { Modal } from '../ui/Modal';
import { useToast } from '../ui/Toast'; import { useToast } from '../ui/Toast';
import { ProblemDetailsAlert } from '../ui/ProblemDetailsAlert'; import { ProblemDetailsAlert } from '../ui/ProblemDetailsAlert';
@@ -44,6 +44,7 @@ import { HelpTooltip } from '../ui/HelpTooltip';
import { ExportButton } from '../ui/ExportButton'; import { ExportButton } from '../ui/ExportButton';
import { ExportColumn } from '../../utils/exportUtils'; import { ExportColumn } from '../../utils/exportUtils';
import { useTableSort, SortableTh } from '../../utils/tableSort'; import { useTableSort, SortableTh } from '../../utils/tableSort';
import { useRbac } from '../../auth/rbac';
import { import {
Contract, Contract,
Award, Award,
@@ -73,10 +74,11 @@ export const ContractsManagement: React.FC<ContractsManagementProps> = ({
}) => { }) => {
const queryClient = useQueryClient(); const queryClient = useQueryClient();
const { success, error: toastError, info } = useToast(); const { success, error: toastError, info } = useToast();
const { canAdd, canApprove, canApproveRecord, isMaker } = useRbac();
// Component state // Component state
const [internalCreateModalOpen, setInternalCreateModalOpen] = useState(false); const [internalCreateModalOpen, setInternalCreateModalOpen] = useState(false);
const isCreateModalOpen = isCreateModalOpenExternal ?? internalCreateModalOpen; const isCreateModalOpen = Boolean(isCreateModalOpenExternal) || internalCreateModalOpen;
const setCreateModalOpen = (open: boolean) => { const setCreateModalOpen = (open: boolean) => {
if (onCloseCreateModalExternal && !open) { if (onCloseCreateModalExternal && !open) {
onCloseCreateModalExternal(); onCloseCreateModalExternal();
@@ -245,7 +247,7 @@ export const ContractsManagement: React.FC<ContractsManagementProps> = ({
// Target contractId for upload // Target contractId for upload
const contractIdToUploadAgainst = const contractIdToUploadAgainst =
createdContract?.id ?? (createdContract as any)?.contractId ?? selectedAwardId; createdContract?.id ?? selectedAwardId;
// Step 2: Upload PDF document to POST /v1/api/grants/contracts/{contractId} // Step 2: Upload PDF document to POST /v1/api/grants/contracts/{contractId}
if (contractPdfFile && contractIdToUploadAgainst) { if (contractPdfFile && contractIdToUploadAgainst) {
@@ -275,6 +277,9 @@ export const ContractsManagement: React.FC<ContractsManagementProps> = ({
mutationFn: async () => { mutationFn: async () => {
setUploadApiError(null); setUploadApiError(null);
if (!contractForFileUpload) throw new Error('No contract selected for upload.'); if (!contractForFileUpload) throw new Error('No contract selected for upload.');
if (Number(contractForFileUpload.status) === ContractStatus.Active) {
throw new Error('Active contracts are legally executed and immutable. Documents cannot be modified.');
}
if (!selectedUploadFile) throw new Error('Please select a PDF document to upload.'); if (!selectedUploadFile) throw new Error('Please select a PDF document to upload.');
await uploadContractFile(contractForFileUpload.id, selectedUploadFile); await uploadContractFile(contractForFileUpload.id, selectedUploadFile);
@@ -415,21 +420,21 @@ export const ContractsManagement: React.FC<ContractsManagementProps> = ({
sortDirection: contractSortDir, sortDirection: contractSortDir,
handleSort: handleContractSort, handleSort: handleContractSort,
} = useTableSort<Contract>(filteredContracts, null, null, { } = useTableSort<Contract>(filteredContracts, null, null, {
id: (c: any) => c.id, id: (c: Contract) => c.id,
awardId: (c: any) => c.totalValue || c.awardId, awardId: (c: Contract) => c.totalValue || c.awardId,
programmeId: (c: any) => { programmeId: (c: Contract) => {
const a = getAwardForContract(c); const a = getAwardForContract(c);
const pr = getProgrammeForAward(a); const pr = getProgrammeForAward(a);
return pr?.name || ''; return pr?.name || '';
}, },
organisationId: (c: any) => { organisationId: (c: Contract) => {
const a = getAwardForContract(c); const a = getAwardForContract(c);
const o = getOrganisationForAward(a); const o = getOrganisationForAward(a);
return o?.name || ''; return o?.name || '';
}, },
signedDate: (c: any) => c.signedDate, signedDate: (c: Contract) => c.effectiveAt || c.createdAt,
status: (c: any) => getContractStatusBadge(c.status).label, status: (c: Contract) => getContractStatusBadge(c.status).label,
fileUri: (c: any) => c.fileUri || '', fileUri: (c: Contract) => c.signedDocumentUrl || '',
}); });
return ( return (
@@ -602,6 +607,7 @@ export const ContractsManagement: React.FC<ContractsManagementProps> = ({
</button> </button>
<ExportButton <ExportButton
claim="grant_financials"
title="Contract Registry & Agreements" title="Contract Registry & Agreements"
subtitle="Authoritative repository of bilateral legal agreements and contract files" subtitle="Authoritative repository of bilateral legal agreements and contract files"
filename={`pfm-contracts-${new Date().toISOString().split('T')[0]}`} filename={`pfm-contracts-${new Date().toISOString().split('T')[0]}`}
@@ -609,17 +615,19 @@ export const ContractsManagement: React.FC<ContractsManagementProps> = ({
data={filteredContracts} data={filteredContracts}
/> />
<button {canAdd('grant_financials') && (
type="button" <button
onClick={() => { type="button"
resetCreateForm(); onClick={() => {
setCreateModalOpen(true); resetCreateForm();
}} setCreateModalOpen(true);
className="inline-flex items-center justify-center gap-1.5 px-4 py-2 text-xs font-semibold text-white bg-gradient-to-r from-blue-600 to-indigo-600 hover:from-blue-500 hover:to-indigo-500 rounded-xl shadow-md shadow-blue-500/20 transition cursor-pointer" }}
> className="inline-flex items-center justify-center gap-1.5 px-4 py-2 text-xs font-semibold text-white bg-gradient-to-r from-blue-600 to-indigo-600 hover:from-blue-500 hover:to-indigo-500 rounded-xl shadow-md shadow-blue-500/20 transition cursor-pointer"
<Plus className="w-4 h-4" /> >
Create Contract <Plus className="w-4 h-4" />
</button> Create Contract
</button>
)}
</div> </div>
</div> </div>
@@ -647,7 +655,7 @@ export const ContractsManagement: React.FC<ContractsManagementProps> = ({
<p className="text-slate-500 max-w-sm"> <p className="text-slate-500 max-w-sm">
{searchTerm || statusFilter !== 'all' {searchTerm || statusFilter !== 'all'
? 'No contracts match the current search filters. Try resetting the filters.' ? 'No contracts match the current search filters. Try resetting the filters.'
: 'No contracts have been created yet. Click "Create Contract & Upload PDF" to link an approved award.'} : 'No contracts have been created yet. Click "Create Contract" to link an approved award.'}
</p> </p>
{!(searchTerm || statusFilter !== 'all') && ( {!(searchTerm || statusFilter !== 'all') && (
<button <button
@@ -818,19 +826,21 @@ export const ContractsManagement: React.FC<ContractsManagementProps> = ({
<span>Secure PDF</span> <span>Secure PDF</span>
</button> </button>
<button {Number(contract.status) !== ContractStatus.Active && (
type="button" <button
onClick={() => { type="button"
setContractForFileUpload(contract); onClick={() => {
setSelectedUploadFile(null); setContractForFileUpload(contract);
setUploadApiError(null); setSelectedUploadFile(null);
}} setUploadApiError(null);
className="inline-flex items-center gap-1 px-2 py-1 text-[11px] text-slate-400 hover:text-slate-200 bg-slate-800 hover:bg-slate-700 rounded-lg border border-slate-700 transition cursor-pointer" }}
title="Upload fresh PDF or replace existing contract document" className="inline-flex items-center gap-1 px-2 py-1 text-[11px] text-slate-400 hover:text-slate-200 bg-slate-800 hover:bg-slate-700 rounded-lg border border-slate-700 transition cursor-pointer"
> title="Upload fresh PDF or replace existing contract document"
<Upload className="w-3 h-3" /> >
<span>Replace</span> <Upload className="w-3 h-3" />
</button> <span>Replace</span>
</button>
)}
</div> </div>
<div className="text-[9px] text-slate-500 mt-1 flex items-center gap-1"> <div className="text-[9px] text-slate-500 mt-1 flex items-center gap-1">
<Lock className="w-2.5 h-2.5 text-slate-500" /> <Lock className="w-2.5 h-2.5 text-slate-500" />
@@ -840,18 +850,28 @@ export const ContractsManagement: React.FC<ContractsManagementProps> = ({
{/* Actions */} {/* Actions */}
<td className="py-3.5 px-4 text-right"> <td className="py-3.5 px-4 text-right">
<button {Number(contract.status) === ContractStatus.Active ? (
type="button" <span
onClick={() => { className="inline-flex items-center gap-1 text-[11px] font-semibold text-emerald-400 bg-emerald-500/10 px-2.5 py-1 rounded-lg border border-emerald-500/20 cursor-default"
setContractToUpdate(contract); title="Active contracts are executed and immutable."
setNewStatus(Number(contract.status)); >
setUpdateApiError(null); <Lock className="w-3 h-3 text-emerald-400" />
}} <span>Active (Locked)</span>
className="inline-flex items-center gap-1 px-2.5 py-1 text-[11px] font-medium text-slate-300 hover:text-white bg-slate-800/80 hover:bg-slate-700 rounded-lg border border-slate-700 transition cursor-pointer" </span>
> ) : (
<Edit2 className="w-3 h-3" /> <button
<span>Status</span> type="button"
</button> onClick={() => {
setContractToUpdate(contract);
setNewStatus(Number(contract.status));
setUpdateApiError(null);
}}
className="inline-flex items-center gap-1 px-2.5 py-1 text-[11px] font-medium text-slate-300 hover:text-white bg-slate-800/80 hover:bg-slate-700 rounded-lg border border-slate-700 transition cursor-pointer"
>
<Edit2 className="w-3 h-3" />
<span>Status</span>
</button>
)}
</td> </td>
</tr> </tr>
); );
@@ -878,7 +898,7 @@ export const ContractsManagement: React.FC<ContractsManagementProps> = ({
className="space-y-4" className="space-y-4"
> >
{/* ProblemDetails Error Alert */} {/* ProblemDetails Error Alert */}
{createApiError && ( {Boolean(createApiError) && (
<ProblemDetailsAlert <ProblemDetailsAlert
error={createApiError} error={createApiError}
onDismiss={() => setCreateApiError(null)} onDismiss={() => setCreateApiError(null)}
@@ -1106,7 +1126,7 @@ export const ContractsManagement: React.FC<ContractsManagementProps> = ({
}} }}
className="space-y-4" className="space-y-4"
> >
{uploadApiError && ( {Boolean(uploadApiError) && (
<ProblemDetailsAlert <ProblemDetailsAlert
error={uploadApiError} error={uploadApiError}
onDismiss={() => setUploadApiError(null)} onDismiss={() => setUploadApiError(null)}
@@ -1208,7 +1228,7 @@ export const ContractsManagement: React.FC<ContractsManagementProps> = ({
}} }}
className="space-y-4" className="space-y-4"
> >
{updateApiError && ( {Boolean(updateApiError) && (
<ProblemDetailsAlert <ProblemDetailsAlert
error={updateApiError} error={updateApiError}
onDismiss={() => setUpdateApiError(null)} onDismiss={() => setUpdateApiError(null)}
@@ -1224,17 +1244,41 @@ export const ContractsManagement: React.FC<ContractsManagementProps> = ({
<label className="block text-xs font-medium text-slate-200 mb-1"> <label className="block text-xs font-medium text-slate-200 mb-1">
Contract Lifecycle Status <span className="text-rose-400">*</span> Contract Lifecycle Status <span className="text-rose-400">*</span>
</label> </label>
<select {Number(contractToUpdate?.status) === ContractStatus.Active ? (
value={newStatus} <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">
onChange={(e) => setNewStatus(Number(e.target.value))} <Lock className="w-3.5 h-3.5 text-emerald-400" />
className="w-full bg-[#151b2c] border border-slate-700 rounded-xl px-3 py-2 text-xs text-white focus:outline-none focus:border-blue-500" <span>Active / Executed</span>
> </div>
{CONTRACT_STATUS_OPTIONS.map((opt) => ( ) : (
<option key={opt.value} value={opt.value}> (() => {
{opt.label} const canApproveContract = canApproveRecord('grant_financials', contractToUpdate?.createdBy);
</option> const isSelf = isMaker(contractToUpdate?.createdBy);
))}
</select> return (
<select
value={newStatus}
onChange={(e) => setNewStatus(Number(e.target.value))}
className="w-full bg-[#151b2c] border border-slate-700 rounded-xl px-3 py-2 text-xs text-white focus:outline-none focus:border-blue-500 font-medium"
>
{CONTRACT_STATUS_OPTIONS.map((opt) => {
const isActiveOpt = opt.value === ContractStatus.Active;
const isOptDisabled = isActiveOpt && !canApproveContract;
let reasonText = '';
if (isActiveOpt && !canApprove('grant_financials')) {
reasonText = ' (Requires Update Permission)';
} else if (isActiveOpt && isSelf) {
reasonText = ' (Maker-Checker: Cannot approve own record)';
}
return (
<option key={opt.value} value={opt.value} disabled={isOptDisabled}>
{opt.label}{reasonText}
</option>
);
})}
</select>
);
})()
)}
</div> </div>
<div className="flex items-center justify-end gap-2 pt-3 border-t border-slate-800"> <div className="flex items-center justify-end gap-2 pt-3 border-t border-slate-800">
@@ -1247,7 +1291,7 @@ export const ContractsManagement: React.FC<ContractsManagementProps> = ({
</button> </button>
<button <button
type="submit" type="submit"
disabled={updateStatusMutation.isPending} disabled={updateStatusMutation.isPending || Number(contractToUpdate?.status) === ContractStatus.Active}
className="px-4 py-2 text-xs font-semibold text-white bg-blue-600 hover:bg-blue-500 disabled:opacity-50 rounded-xl shadow-lg shadow-blue-600/30 transition flex items-center gap-1.5 cursor-pointer" className="px-4 py-2 text-xs font-semibold text-white bg-blue-600 hover:bg-blue-500 disabled:opacity-50 rounded-xl shadow-lg shadow-blue-600/30 transition flex items-center gap-1.5 cursor-pointer"
> >
{updateStatusMutation.isPending ? ( {updateStatusMutation.isPending ? (
@@ -24,6 +24,7 @@ import {
FileSpreadsheet, FileSpreadsheet,
FileCheck, FileCheck,
RefreshCw, RefreshCw,
Lock,
} from 'lucide-react'; } from 'lucide-react';
import { import {
getAllEvidence, getAllEvidence,
@@ -87,7 +88,7 @@ export const EvidenceReviewQueue: React.FC<EvidenceReviewQueueProps> = ({
}) => { }) => {
const queryClient = useQueryClient(); const queryClient = useQueryClient();
const { success, error: toastError } = useToast(); const { success, error: toastError } = useToast();
const { canAdd, canApprove, canExport } = useRbac(); const { canAdd, canApprove, canExport, canApproveRecord, isMaker } = useRbac();
// Filter States: default to initialProjectId if given, otherwise when projects load default to first project // Filter States: default to initialProjectId if given, otherwise when projects load default to first project
const [selectedProjectId, setSelectedProjectId] = useState<string | number | 'all'>(initialProjectId); const [selectedProjectId, setSelectedProjectId] = useState<string | number | 'all'>(initialProjectId);
@@ -362,8 +363,8 @@ export const EvidenceReviewQueue: React.FC<EvidenceReviewQueueProps> = ({
} }
success('Download Initiated', `Opening official file: ${ev.fileName || `evidence_${ev.id}`}`); success('Download Initiated', `Opening official file: ${ev.fileName || `evidence_${ev.id}`}`);
} catch (err: any) { } catch (err: unknown) {
toastError('Download Failed', err?.message || 'Unable to retrieve file link'); toastError('Download Failed', err);
} finally { } finally {
setIsDownloading(null); setIsDownloading(null);
} }
@@ -800,52 +801,65 @@ export const EvidenceReviewQueue: React.FC<EvidenceReviewQueueProps> = ({
<span>Inspect</span> <span>Inspect</span>
</button> </button>
{canAdd('evidence_risks') && ( {ev.status === ApprovalStatus.Approved ? (
<button <span
type="button" className="inline-flex items-center gap-1.5 text-xs font-semibold px-3 py-1.5 rounded-xl border bg-emerald-500/15 text-emerald-300 border-emerald-500/30 cursor-default"
onClick={() => title="Approved evidence is immutable. Status cannot be modified."
reviewMutation.mutate({
id: ev.id,
status: ApprovalStatus.Rejected,
version: ev.version,
evidenceType: ev.type,
})
}
disabled={reviewMutation.isPending && String(reviewMutation.variables?.id) === String(ev.id)}
className={`inline-flex items-center gap-1 text-xs font-semibold px-2.5 py-1.5 rounded-xl border transition cursor-pointer ${
ev.status === ApprovalStatus.Rejected
? 'bg-rose-500/20 text-rose-300 border-rose-500/30 opacity-75'
: 'text-rose-400 hover:text-white bg-rose-500/10 hover:bg-rose-600 border border-rose-500/25'
}`}
title="Reject evidence submission"
> >
<XCircle className="w-3.5 h-3.5" /> <Lock className="w-3.5 h-3.5 text-emerald-400" />
<span>{ev.status === ApprovalStatus.Rejected ? 'Rejected' : 'Reject'}</span> <span>Approved</span>
</button> </span>
)} ) : (
<>
{canAdd('evidence_risks') && (
<button
type="button"
onClick={() =>
reviewMutation.mutate({
id: ev.id,
status: ApprovalStatus.Rejected,
version: ev.version,
evidenceType: ev.type,
})
}
disabled={reviewMutation.isPending && String(reviewMutation.variables?.id) === String(ev.id)}
className={`inline-flex items-center gap-1 text-xs font-semibold px-2.5 py-1.5 rounded-xl border transition cursor-pointer ${
ev.status === ApprovalStatus.Rejected
? 'bg-rose-500/20 text-rose-300 border-rose-500/30 opacity-75'
: 'text-rose-400 hover:text-white bg-rose-500/10 hover:bg-rose-600 border border-rose-500/25'
}`}
title="Reject evidence submission"
>
<XCircle className="w-3.5 h-3.5" />
<span>{ev.status === ApprovalStatus.Rejected ? 'Rejected' : 'Reject'}</span>
</button>
)}
{canApprove('evidence_risks') && ( {canApproveRecord('evidence_risks', ev.createdBy) && (
<button <button
type="button" type="button"
onClick={() => onClick={() =>
reviewMutation.mutate({ reviewMutation.mutate({
id: ev.id, id: ev.id,
status: ApprovalStatus.Approved, status: ApprovalStatus.Approved,
version: ev.version, version: ev.version,
evidenceType: ev.type, evidenceType: ev.type,
}) })
} }
disabled={reviewMutation.isPending && String(reviewMutation.variables?.id) === String(ev.id)} disabled={reviewMutation.isPending && String(reviewMutation.variables?.id) === String(ev.id)}
className={`inline-flex items-center gap-1 text-xs font-semibold px-2.5 py-1.5 rounded-xl border transition cursor-pointer ${ className="inline-flex items-center gap-1 text-xs font-semibold px-2.5 py-1.5 rounded-xl border transition cursor-pointer text-emerald-400 hover:text-white bg-emerald-500/10 hover:bg-emerald-600 border border-emerald-500/25"
ev.status === ApprovalStatus.Approved title="Approve evidence submission"
? 'bg-emerald-500/20 text-emerald-300 border-emerald-500/30 opacity-75' >
: 'text-emerald-400 hover:text-white bg-emerald-500/10 hover:bg-emerald-600 border border-emerald-500/25' <CheckCircle2 className="w-3.5 h-3.5" />
}`} <span>Approve</span>
title="Approve evidence submission" </button>
> )}
<CheckCircle2 className="w-3.5 h-3.5" /> {canApprove('evidence_risks') && isMaker(ev.createdBy) && (
<span>{ev.status === ApprovalStatus.Approved ? 'Approved' : 'Approve'}</span> <span className="text-[10px] text-amber-400/90 italic font-mono px-1.5 py-0.5 bg-amber-500/10 border border-amber-500/20 rounded-md">
</button> Maker-Checker (Creator)
</span>
)}
</>
)} )}
<button <button
@@ -1022,7 +1036,7 @@ export const EvidenceReviewQueue: React.FC<EvidenceReviewQueueProps> = ({
</button> </button>
)} )}
{canApprove('evidence_risks') && ( {canApproveRecord('evidence_risks', inspectEvidence.createdBy) && (
<button <button
type="button" type="button"
onClick={() => onClick={() =>
@@ -1045,6 +1059,12 @@ export const EvidenceReviewQueue: React.FC<EvidenceReviewQueueProps> = ({
<span>{reviewMutation.isPending && reviewMutation.variables?.status === ApprovalStatus.Approved ? 'Approving...' : 'Approve Evidence'}</span> <span>{reviewMutation.isPending && reviewMutation.variables?.status === ApprovalStatus.Approved ? 'Approving...' : 'Approve Evidence'}</span>
</button> </button>
)} )}
{canApprove('evidence_risks') && isMaker(inspectEvidence.createdBy) && (
<span className="text-xs text-amber-400 font-medium px-3 py-1.5 bg-amber-500/10 border border-amber-500/20 rounded-xl flex items-center gap-1.5">
<AlertTriangle className="w-3.5 h-3.5 text-amber-400 shrink-0" />
<span>Maker-Checker Policy: As the creator of this submission, you cannot approve it.</span>
</span>
)}
</div> </div>
</div> </div>
</div> </div>
@@ -0,0 +1,711 @@
import React, { useState, useEffect, useRef } from 'react';
import {
Send,
Save,
Trash2,
X,
Mail,
AlertCircle,
CheckCircle2,
Bold,
Italic,
Underline,
Heading2,
List,
ListOrdered,
Quote,
Table,
Minus,
Sparkles,
Eye,
Edit3,
Smartphone,
ShieldCheck,
} from 'lucide-react';
import { UserSearchPicker, SelectedUser } from './UserSearchPicker';
import { useInAppNotifications, DraftMessage } from '../../hooks/useInAppNotifications';
import { NotificationPlatform } from '../../types';
import {
renderMarkdownToHtml,
parseMarkdownToHtml,
isHtmlContent,
stripMarkdown,
PFM_LOGO_DATA_URI,
} from '../../lib/markdownUtils';
import { BrandLogo } from '../ui/BrandLogo';
import { useAuth } from '../../auth/AuthProvider';
interface ComposeMessageModalProps {
isOpen: boolean;
onClose: () => void;
initialRecipient?: SelectedUser;
initialSubject?: string;
initialMessage?: string;
initialPlatform?: NotificationPlatform;
draftToEdit?: DraftMessage | null;
}
export const ComposeMessageModal: React.FC<ComposeMessageModalProps> = ({
isOpen,
onClose,
initialRecipient,
initialSubject = '',
initialMessage = '',
initialPlatform = NotificationPlatform.InApp,
draftToEdit,
}) => {
const { user, claims } = useAuth();
const currentUserId = String(user?.id || claims?.sub || '').trim().toLowerCase();
const currentUserEmail = String(user?.email || claims?.email || '').trim().toLowerCase();
const tenantName =
(claims?.tenant as string) ||
user?.tenant ||
'Department of Science, Innovation & Technology';
const { sendMessage, isSending, saveDraft, deleteDraft } = useInAppNotifications();
const [selectedUsers, setSelectedUsers] = useState<SelectedUser[]>([]);
const [subject, setSubject] = useState('');
const [message, setMessage] = useState('');
const [platform, setPlatform] = useState<NotificationPlatform>(initialPlatform);
const [activeTab, setActiveTab] = useState<'write' | 'preview'>('write');
const [error, setError] = useState<string | null>(null);
const [successNotice, setSuccessNotice] = useState<string | null>(null);
const editorRef = useRef<HTMLDivElement>(null);
const isCurrentUser = (candidate?: { id?: string; email?: string }) => {
if (!candidate) return false;
const cid = String(candidate.id || '').trim().toLowerCase();
const cemail = String(candidate.email || '').trim().toLowerCase();
if (currentUserId && cid === currentUserId) return true;
if (currentUserEmail && cemail === currentUserEmail) return true;
return false;
};
const toInitialRichHtml = (raw: string): string => {
if (!raw || !raw.trim()) return '';
if (isHtmlContent(raw)) return raw;
return parseMarkdownToHtml(raw);
};
// Initialize or populate from draft or props
useEffect(() => {
if (!isOpen) return;
let initialHtml = '';
if (draftToEdit) {
const candidate = draftToEdit.recipientId
? {
id: draftToEdit.recipientId,
email: draftToEdit.recipientEmail,
name: draftToEdit.recipientName,
}
: undefined;
setSelectedUsers(candidate && !isCurrentUser(candidate) ? [candidate] : []);
setSubject(draftToEdit.subject || '');
initialHtml = toInitialRichHtml(draftToEdit.message || '');
setMessage(initialHtml);
setPlatform(draftToEdit.platform || NotificationPlatform.InApp);
} else {
setSelectedUsers(
initialRecipient && !isCurrentUser(initialRecipient) ? [initialRecipient] : []
);
setSubject(initialSubject || '');
initialHtml = toInitialRichHtml(initialMessage || '');
setMessage(initialHtml);
setPlatform(initialPlatform);
}
setActiveTab('write');
setError(null);
setSuccessNotice(null);
setTimeout(() => {
if (editorRef.current) {
editorRef.current.innerHTML = initialHtml;
}
}, 0);
}, [isOpen, initialRecipient, initialSubject, initialMessage, initialPlatform, draftToEdit]);
// Restore editor innerHTML when switching back from preview tab
useEffect(() => {
if (activeTab === 'write' && editorRef.current) {
if (editorRef.current.innerHTML !== message) {
editorRef.current.innerHTML = message;
}
}
}, [activeTab]);
if (!isOpen) return null;
const handleSelectUser = (selected: SelectedUser) => {
if (isCurrentUser(selected)) {
setError('You cannot send a message to yourself.');
return;
}
setSelectedUsers([selected]);
setError(null);
};
const handleRemoveUser = () => {
setSelectedUsers([]);
};
const syncEditorContent = () => {
if (editorRef.current) {
setMessage(editorRef.current.innerHTML);
}
};
const applyFormat = (command: string, value?: string) => {
const editor = editorRef.current;
if (!editor) return;
editor.focus();
document.execCommand(command, false, value);
syncEditorContent();
};
const insertHtmlBlock = (htmlSnippet: string) => {
const editor = editorRef.current;
if (!editor) return;
editor.focus();
const inserted = document.execCommand('insertHTML', false, htmlSnippet);
if (!inserted) {
editor.innerHTML += htmlSnippet;
}
syncEditorContent();
};
// Quick insertions for PFM branding and templates
const insertBrandedCallout = () => {
insertHtmlBlock(
`<div style="background:rgba(12,74,110,0.25);border-left:4px solid #38bdf8;padding:10px 14px;border-radius:8px;margin:10px 0;color:#bae6fd;font-size:12px;"><strong>Post-Fund Management Official Notice</strong><br/>Please verify all supporting evidence documentation before tranche disbursement sign-off.</div><p><br/></p>`
);
};
const insertMilestoneTable = () => {
insertHtmlBlock(
`<div style="overflow-x:auto;margin:12px 0;"><table style="width:100%;border-collapse:collapse;border:1px solid #334155;font-size:12px;text-align:left;"><thead><tr style="background:#1e293b;color:#f8fafc;"><th style="padding:8px 12px;border:1px solid #334155;">Deliverable Code</th><th style="padding:8px 12px;border:1px solid #334155;">Milestone Name</th><th style="padding:8px 12px;border:1px solid #334155;">Status</th><th style="padding:8px 12px;border:1px solid #334155;">Due Date</th></tr></thead><tbody><tr style="background:#0f172a;color:#cbd5e1;"><td style="padding:8px 12px;border:1px solid #334155;">DEL-01</td><td style="padding:8px 12px;border:1px solid #334155;">Technical Architecture Review</td><td style="padding:8px 12px;border:1px solid #334155;">Verified</td><td style="padding:8px 12px;border:1px solid #334155;">2026-11-15</td></tr><tr style="background:#0f172a;color:#cbd5e1;"><td style="padding:8px 12px;border:1px solid #334155;">DEL-02</td><td style="padding:8px 12px;border:1px solid #334155;">Financial Expenditure Proof</td><td style="padding:8px 12px;border:1px solid #334155;">Under Review</td><td style="padding:8px 12px;border:1px solid #334155;">2026-12-01</td></tr></tbody></table></div><p><br/></p>`
);
};
const insertPlatformLogo = () => {
insertHtmlBlock(
`<p><img src="${PFM_LOGO_DATA_URI}" alt="PFM Platform Logo" width="48" height="48" style="display:inline-block;border-radius:10px;margin:6px 0;" /></p><p><br/></p>`
);
};
const hasMeaningfulContent = (html: string) => {
if (!html) return false;
if (stripMarkdown(html).trim().length > 0) return true;
return /<(img|table|hr)\b/i.test(html);
};
const handleSaveDraft = () => {
const currentHtml = editorRef.current ? editorRef.current.innerHTML : message;
if (!subject.trim() && !hasMeaningfulContent(currentHtml)) {
setError('Please add a subject or message to save as draft');
return;
}
const firstUser = selectedUsers[0];
saveDraft({
id: draftToEdit?.id,
recipientId: firstUser?.id || '',
recipientEmail: firstUser?.email || '',
recipientName: firstUser?.name || '',
subject: subject.trim() || 'Untitled Draft',
message: currentHtml.trim(),
platform,
});
setSuccessNotice('Draft saved successfully in Drafts folder');
setTimeout(() => {
onClose();
}, 600);
};
const handleSend = async (e: React.FormEvent) => {
e.preventDefault();
const currentHtml = editorRef.current ? editorRef.current.innerHTML : message;
if (selectedUsers.length === 0) {
setError('Please select a recipient from the tenant directory');
return;
}
if (selectedUsers.length > 1) {
setError('Only one recipient is allowed per message');
return;
}
if (isCurrentUser(selectedUsers[0])) {
setError('You cannot send a message to yourself');
return;
}
if (!subject.trim()) {
setError('Please enter a subject');
return;
}
if (!hasMeaningfulContent(currentHtml)) {
setError('Please enter a message body');
return;
}
setError(null);
try {
const recipient = selectedUsers[0];
const finalMessage =
platform === NotificationPlatform.Email
? renderMarkdownToHtml(currentHtml.trim(), {
applyBranding: true,
emailFormat: true,
subject: subject.trim(),
tenantName,
})
: currentHtml.trim();
await sendMessage({
recipientId: recipient.id,
recipientEmail: recipient.email,
recipientName: recipient.name,
subject: subject.trim(),
message: finalMessage,
platform,
draftId: draftToEdit?.id,
});
setSuccessNotice(
platform === NotificationPlatform.Email
? 'Email notification dispatched!'
: 'In-App message sent successfully!'
);
setTimeout(() => {
onClose();
}, 700);
} catch (err: any) {
setError(err?.message || 'Failed to dispatch notification');
}
};
const handleDiscard = () => {
if (draftToEdit?.id) {
deleteDraft(draftToEdit.id);
}
onClose();
};
// Preview content based on chosen platform
const livePreviewHtml =
platform === NotificationPlatform.Email
? renderMarkdownToHtml(message, {
applyBranding: true,
emailFormat: true,
subject: subject || 'Notice Preview',
tenantName,
})
: message;
const plainCharCount = stripMarkdown(message).length;
return (
<div className="fixed inset-0 z-50 flex items-center justify-center p-4 bg-slate-950/85 backdrop-blur-md animate-in fade-in duration-150">
<div className="w-full max-w-3xl bg-[#0d1322] border border-slate-700/80 rounded-3xl shadow-2xl overflow-hidden flex flex-col max-h-[92vh]">
{/* Modal Header */}
<div className="px-6 py-4 border-b border-slate-800 flex items-center justify-between bg-gradient-to-r from-blue-950/50 via-slate-900/60 to-slate-900/40">
<div className="flex items-center gap-3">
<BrandLogo size={28} showGlow animateOnHover={false} />
<div>
<div className="flex items-center gap-2">
<h3 className="text-sm font-bold text-white tracking-tight">
{draftToEdit
? 'Edit Saved Draft'
: initialSubject.startsWith('Re:')
? 'Reply to Message'
: 'Compose New Communication'}
</h3>
<span className="text-[10px] font-semibold text-blue-300 bg-blue-500/15 border border-blue-500/30 px-2 py-0.5 rounded-full">
Rich Text Composer
</span>
</div>
<p className="text-[11px] text-slate-400">
Type and format your message using the toolbar for In-App or branded Email delivery
</p>
</div>
</div>
<button
onClick={onClose}
className="p-1.5 rounded-lg text-slate-400 hover:text-white hover:bg-slate-800 transition cursor-pointer"
>
<X className="w-4 h-4" />
</button>
</div>
{/* Form Body */}
<form onSubmit={handleSend} className="p-6 space-y-4 overflow-y-auto flex-1">
{error && (
<div className="p-3 rounded-xl bg-rose-500/10 border border-rose-500/25 text-rose-300 text-xs flex items-center gap-2">
<AlertCircle className="w-4 h-4 shrink-0 text-rose-400" />
<span>{error}</span>
</div>
)}
{successNotice && (
<div className="p-3 rounded-xl bg-emerald-500/10 border border-emerald-500/25 text-emerald-300 text-xs flex items-center gap-2">
<CheckCircle2 className="w-4 h-4 shrink-0 text-emerald-400" />
<span>{successNotice}</span>
</div>
)}
{/* Platform Selector (InApp vs Email) */}
<div className="p-3 rounded-2xl bg-slate-900/60 border border-slate-800 flex flex-col sm:flex-row sm:items-center justify-between gap-3">
<div>
<label className="text-xs font-semibold text-slate-200 block">
Dispatch Platform Mode
</label>
<p className="text-[11px] text-slate-400">
Select target transmission protocol for this notification
</p>
</div>
<div className="flex items-center rounded-xl bg-slate-950 p-1 border border-slate-700/80">
<button
type="button"
onClick={() => setPlatform(NotificationPlatform.InApp)}
className={`flex items-center gap-2 px-3 py-1.5 rounded-lg text-xs font-semibold transition cursor-pointer ${
platform === NotificationPlatform.InApp
? 'bg-blue-600 text-white shadow-md shadow-blue-600/30'
: 'text-slate-400 hover:text-slate-200'
}`}
>
<Smartphone className="w-3.5 h-3.5" />
<span>In-App</span>
</button>
<button
type="button"
onClick={() => setPlatform(NotificationPlatform.Email)}
className={`flex items-center gap-2 px-3 py-1.5 rounded-lg text-xs font-semibold transition cursor-pointer ${
platform === NotificationPlatform.Email
? 'bg-emerald-600 text-white shadow-md shadow-emerald-600/30'
: 'text-slate-400 hover:text-slate-200'
}`}
>
<Mail className="w-3.5 h-3.5" />
<span>Email</span>
</button>
</div>
</div>
{/* Platform Description Indicator */}
<div className="px-1 text-[11px]">
{platform === NotificationPlatform.InApp ? (
<span className="text-blue-300 flex items-center gap-1.5">
<span className="w-2 h-2 rounded-full bg-blue-400 animate-pulse" />
Delivered instantly to the recipient's <strong>In-App</strong> Mailbox & Notification Bell.
</span>
) : (
<span className="text-emerald-300 flex items-center gap-1.5">
<span className="w-2 h-2 rounded-full bg-emerald-400 animate-pulse" />
Formatted with <strong>PFM Executive Branding</strong> and embedded logo before dispatching via email.
</span>
)}
</div>
{/* Recipient Picker */}
<div>
<label className="block text-xs font-semibold text-slate-300 mb-1.5">
To (Recipient) <span className="text-rose-400">*</span>
</label>
<UserSearchPicker
selectedUsers={selectedUsers}
onSelectUser={handleSelectUser}
onRemoveUser={handleRemoveUser}
multiple={false}
placeholder="Search tenant users by name, email, or functional role..."
/>
</div>
{/* Subject Field */}
<div>
<label className="block text-xs font-semibold text-slate-300 mb-1.5">
Subject Line <span className="text-rose-400">*</span>
</label>
<input
type="text"
value={subject}
onChange={e => setSubject(e.target.value)}
placeholder="e.g. Funding Award Contract Review & Milestone Verification Notice"
className="w-full px-3.5 py-2.5 rounded-xl bg-slate-900/90 border border-slate-700/80 hover:border-slate-600 focus:border-blue-500 text-slate-100 placeholder-slate-400 text-xs focus:outline-none transition shadow-inner font-medium"
/>
</div>
{/* Rich Text Editor / Branded Preview Tabs */}
<div className="space-y-2">
<div className="flex items-center justify-between">
<label className="text-xs font-semibold text-slate-300">
Message Content <span className="text-rose-400">*</span>
</label>
{/* Compose vs Email Preview Tabs */}
<div className="flex items-center rounded-lg bg-slate-900 p-0.5 border border-slate-700/80">
<button
type="button"
onClick={() => setActiveTab('write')}
className={`flex items-center gap-1.5 px-3 py-1 rounded-md text-xs font-medium transition cursor-pointer ${
activeTab === 'write'
? 'bg-blue-600 text-white font-semibold shadow-xs'
: 'text-slate-400 hover:text-white'
}`}
>
<Edit3 className="w-3 h-3" />
<span>Compose</span>
</button>
<button
type="button"
onClick={() => {
syncEditorContent();
setActiveTab('preview');
}}
className={`flex items-center gap-1.5 px-3 py-1 rounded-md text-xs font-medium transition cursor-pointer ${
activeTab === 'preview'
? 'bg-blue-600 text-white font-semibold shadow-xs'
: 'text-slate-400 hover:text-white'
}`}
>
<Eye className="w-3 h-3" />
<span>Preview Delivery</span>
</button>
</div>
</div>
{activeTab === 'write' ? (
<div className="rounded-2xl border border-slate-700/80 bg-slate-900/90 overflow-hidden shadow-inner focus-within:border-blue-500 transition">
{/* WYSIWYG Formatting Toolbar */}
<div className="px-3 py-2 border-b border-slate-800 bg-slate-950/70 flex flex-wrap items-center gap-1">
<button
type="button"
onMouseDown={e => e.preventDefault()}
onClick={() => applyFormat('bold')}
className="p-1.5 rounded-lg text-slate-300 hover:text-white hover:bg-slate-800 transition cursor-pointer"
title="Bold"
>
<Bold className="w-3.5 h-3.5" />
</button>
<button
type="button"
onMouseDown={e => e.preventDefault()}
onClick={() => applyFormat('italic')}
className="p-1.5 rounded-lg text-slate-300 hover:text-white hover:bg-slate-800 transition cursor-pointer"
title="Italic"
>
<Italic className="w-3.5 h-3.5" />
</button>
<button
type="button"
onMouseDown={e => e.preventDefault()}
onClick={() => applyFormat('underline')}
className="p-1.5 rounded-lg text-slate-300 hover:text-white hover:bg-slate-800 transition cursor-pointer"
title="Underline"
>
<Underline className="w-3.5 h-3.5" />
</button>
<button
type="button"
onMouseDown={e => e.preventDefault()}
onClick={() => applyFormat('formatBlock', 'H3')}
className="p-1.5 rounded-lg text-slate-300 hover:text-white hover:bg-slate-800 transition cursor-pointer"
title="Heading"
>
<Heading2 className="w-3.5 h-3.5" />
</button>
<span className="w-px h-4 bg-slate-800 mx-1" />
<button
type="button"
onMouseDown={e => e.preventDefault()}
onClick={() => applyFormat('insertUnorderedList')}
className="p-1.5 rounded-lg text-slate-300 hover:text-white hover:bg-slate-800 transition cursor-pointer"
title="Bullet List"
>
<List className="w-3.5 h-3.5" />
</button>
<button
type="button"
onMouseDown={e => e.preventDefault()}
onClick={() => applyFormat('insertOrderedList')}
className="p-1.5 rounded-lg text-slate-300 hover:text-white hover:bg-slate-800 transition cursor-pointer"
title="Numbered List"
>
<ListOrdered className="w-3.5 h-3.5" />
</button>
<button
type="button"
onMouseDown={e => e.preventDefault()}
onClick={() => applyFormat('formatBlock', 'BLOCKQUOTE')}
className="p-1.5 rounded-lg text-slate-300 hover:text-white hover:bg-slate-800 transition cursor-pointer"
title="Quote Block"
>
<Quote className="w-3.5 h-3.5" />
</button>
<span className="w-px h-4 bg-slate-800 mx-1" />
<button
type="button"
onMouseDown={e => e.preventDefault()}
onClick={insertMilestoneTable}
className="p-1.5 rounded-lg text-slate-300 hover:text-white hover:bg-slate-800 transition cursor-pointer"
title="Insert Deliverable Table"
>
<Table className="w-3.5 h-3.5" />
</button>
<button
type="button"
onMouseDown={e => e.preventDefault()}
onClick={() => applyFormat('insertHorizontalRule')}
className="p-1.5 rounded-lg text-slate-300 hover:text-white hover:bg-slate-800 transition cursor-pointer"
title="Insert Divider Line"
>
<Minus className="w-3.5 h-3.5" />
</button>
<span className="w-px h-4 bg-slate-800 mx-1" />
{/* Platform Brand Insertions */}
<button
type="button"
onMouseDown={e => e.preventDefault()}
onClick={insertBrandedCallout}
className="flex items-center gap-1 px-2 py-1 rounded-lg bg-blue-500/10 hover:bg-blue-500/20 text-blue-300 text-[11px] font-medium border border-blue-500/25 transition cursor-pointer"
title="Insert official PFM callout block"
>
<Sparkles className="w-3 h-3 text-blue-400" />
<span>PFM Notice</span>
</button>
<button
type="button"
onMouseDown={e => e.preventDefault()}
onClick={insertPlatformLogo}
className="flex items-center gap-1 px-2 py-1 rounded-lg bg-emerald-500/10 hover:bg-emerald-500/20 text-emerald-300 text-[11px] font-medium border border-emerald-500/25 transition cursor-pointer"
title="Embed official PFM logo"
>
<BrandLogo size={14} showGlow={false} animateOnHover={false} />
<span>Embed Logo</span>
</button>
</div>
{/* Rich Text WYSIWYG Surface */}
<div className="relative">
{!hasMeaningfulContent(message) && (
<div className="pointer-events-none absolute top-4 left-4 right-4 text-slate-500 text-xs select-none">
Type your message here and highlight text or use the toolbar above to format headings, bullet points, tables, and official notices...
</div>
)}
<div
ref={editorRef}
contentEditable
suppressContentEditableWarning
onInput={syncEditorContent}
onBlur={syncEditorContent}
className="w-full min-h-[200px] max-h-[320px] overflow-y-auto p-4 bg-transparent text-slate-100 text-xs focus:outline-none leading-relaxed font-sans [&_h1]:text-lg [&_h1]:font-bold [&_h1]:text-white [&_h1]:my-2 [&_h2]:text-base [&_h2]:font-bold [&_h2]:text-white [&_h2]:my-2 [&_h3]:text-sm [&_h3]:font-bold [&_h3]:text-white [&_h3]:my-1.5 [&_ul]:list-disc [&_ul]:pl-5 [&_ul]:my-1.5 [&_ol]:list-decimal [&_ol]:pl-5 [&_ol]:my-1.5 [&_blockquote]:border-l-4 [&_blockquote]:border-blue-500 [&_blockquote]:pl-3 [&_blockquote]:italic [&_blockquote]:text-slate-300 [&_blockquote]:my-2 [&_hr]:border-slate-700 [&_hr]:my-3"
/>
</div>
</div>
) : (
/* Live Fully-Rendered Delivery Preview */
<div className="rounded-2xl border border-slate-700/80 bg-slate-950/80 p-5 overflow-y-auto max-h-72 min-h-56">
<div className="mb-3 pb-2 border-b border-slate-800 flex items-center justify-between text-[11px] text-slate-400">
<span className="flex items-center gap-1.5 text-blue-300 font-semibold">
<ShieldCheck className="w-3.5 h-3.5 text-blue-400" />
Delivery Preview ({platform === NotificationPlatform.Email ? 'Email' : 'In-App'})
</span>
<span className="text-[10px] text-slate-400 font-mono">
Subject: {subject || 'Untitled'}
</span>
</div>
{hasMeaningfulContent(message) ? (
<div
className="prose prose-invert max-w-none text-xs leading-relaxed [&_h1]:text-white [&_h2]:text-white [&_h3]:text-white [&_ul]:list-disc [&_ul]:pl-5 [&_ol]:list-decimal [&_ol]:pl-5 [&_blockquote]:border-l-4 [&_blockquote]:border-blue-500 [&_blockquote]:pl-3 [&_blockquote]:italic"
dangerouslySetInnerHTML={{ __html: livePreviewHtml }}
/>
) : (
<div className="py-12 text-center text-slate-400 text-xs italic">
Type your message in the Compose tab to preview how it will appear upon delivery.
</div>
)}
</div>
)}
</div>
{/* Footer Metadata */}
<div className="flex items-center justify-between text-[11px] text-slate-400 px-1 pt-1">
<span className="flex items-center gap-1">
<span>Tenant Partition:</span>
<strong className="text-slate-300">{tenantName}</strong>
</span>
<span className="font-mono">{plainCharCount} characters</span>
</div>
{/* Modal Footer Action Buttons */}
<div className="pt-4 border-t border-slate-800 flex items-center justify-between">
<div className="flex items-center gap-2">
<button
type="button"
onClick={handleDiscard}
className="flex items-center gap-1.5 px-3 py-2 rounded-xl text-slate-400 hover:text-rose-400 hover:bg-rose-950/20 text-xs font-medium transition cursor-pointer"
title="Discard draft"
>
<Trash2 className="w-3.5 h-3.5" />
<span>Discard</span>
</button>
<button
type="button"
onClick={handleSaveDraft}
className="flex items-center gap-1.5 px-3.5 py-2 rounded-xl bg-slate-800 hover:bg-slate-700 text-slate-200 text-xs font-medium border border-slate-700 transition cursor-pointer"
title="Save as Draft"
>
<Save className="w-3.5 h-3.5 text-blue-400" />
<span>Save Draft</span>
</button>
</div>
<div className="flex items-center gap-2">
<button
type="button"
onClick={onClose}
className="px-4 py-2 rounded-xl bg-slate-900 hover:bg-slate-800 text-slate-300 text-xs font-medium border border-slate-800 transition cursor-pointer"
>
Cancel
</button>
<button
type="submit"
disabled={isSending}
className="flex items-center gap-2 px-5 py-2 rounded-xl bg-blue-600 hover:bg-blue-500 text-white text-xs font-semibold shadow-lg shadow-blue-600/30 transition disabled:opacity-50 cursor-pointer"
>
{isSending ? (
<div className="w-3.5 h-3.5 border-2 border-white border-t-transparent rounded-full animate-spin" />
) : (
<Send className="w-3.5 h-3.5" />
)}
<span>
{platform === NotificationPlatform.Email
? 'Dispatch Email'
: 'Send In-App Message'}
</span>
</button>
</div>
</div>
</form>
</div>
</div>
);
};
@@ -0,0 +1,100 @@
import React, { useState, useMemo } from 'react';
import {
Copy,
Check,
ShieldCheck,
} from 'lucide-react';
import { BrandLogo } from '../ui/BrandLogo';
import { parseMarkdownToHtml, isHtmlContent, stripMarkdown } from '../../lib/markdownUtils';
interface MarkdownViewerProps {
content: string;
subject?: string;
showBrandingHeader?: boolean;
className?: string;
}
export const MarkdownViewer: React.FC<MarkdownViewerProps> = ({
content,
showBrandingHeader = true,
className = '',
}) => {
const [copied, setCopied] = useState(false);
const isHtml = useMemo(() => isHtmlContent(content), [content]);
const renderedHtml = useMemo(() => {
if (!content) return '<p class="text-slate-400 italic">No content</p>';
if (isHtml) return content;
return parseMarkdownToHtml(content);
}, [content, isHtml]);
const handleCopy = () => {
navigator.clipboard.writeText(stripMarkdown(content) || content);
setCopied(true);
setTimeout(() => setCopied(false), 2000);
};
return (
<div className={`flex flex-col rounded-2xl bg-slate-900/40 border border-slate-800/80 overflow-hidden ${className}`}>
{/* Top Controls Bar */}
<div className="px-4 py-2 border-b border-slate-800 bg-[#0d1322]/80 flex items-center justify-between text-xs text-slate-400">
<div className="flex items-center gap-2">
{showBrandingHeader && (
<div className="flex items-center gap-1.5 text-slate-300 font-semibold pr-2 border-r border-slate-700/60">
<BrandLogo size={18} showGlow={false} animateOnHover={false} />
<span className="text-[11px] tracking-tight">PFM Communications</span>
</div>
)}
<span className="text-[10px] text-blue-400 font-mono">
In-App Message
</span>
</div>
<div className="flex items-center gap-2">
{/* Copy Button */}
<button
type="button"
onClick={handleCopy}
className="flex items-center gap-1 px-2 py-1 rounded-lg bg-slate-800 hover:bg-slate-700 text-slate-300 hover:text-white text-[11px] font-medium transition cursor-pointer border border-slate-700/60"
title="Copy message text to clipboard"
>
{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>
{/* Content Rendering */}
<div className="p-6 overflow-x-auto text-sm leading-relaxed text-slate-200 font-sans">
<div
className="prose prose-invert max-w-none [&_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-blue-400 [&_a:hover]:underline [&_ul]:list-disc [&_ul]:pl-5 [&_ul]:my-2 [&_ol]:list-decimal [&_ol]:pl-5 [&_ol]:my-2 [&_table]:w-full [&_table]:border-collapse [&_th]:border [&_th]:border-slate-700 [&_th]:bg-slate-800/80 [&_th]:p-2 [&_td]:border [&_td]:border-slate-800 [&_td]:p-2 [&_blockquote]:border-l-4 [&_blockquote]:border-blue-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-blue-300"
dangerouslySetInnerHTML={{ __html: renderedHtml }}
/>
</div>
{/* Branded Footer Stamp */}
{showBrandingHeader && (
<div className="px-5 py-2.5 border-t border-slate-800/80 bg-[#0a0f1b] flex items-center justify-between text-[11px] text-slate-400">
<div className="flex items-center gap-1.5">
<ShieldCheck className="w-3.5 h-3.5 text-blue-400" />
<span>Verified PFM Secure Platform Formatting</span>
</div>
<span className="font-mono text-[10px] text-slate-400">
{stripMarkdown(content).length} characters
</span>
</div>
)}
</div>
);
};
@@ -0,0 +1,379 @@
import React, { useState, useMemo } from 'react';
import { useQuery } from '@tanstack/react-query';
import {
Search,
User as UserIcon,
Check,
X,
Shield,
Briefcase,
Coins,
ShieldAlert,
Users,
ChevronDown,
ChevronUp,
} from 'lucide-react';
import { getUsers } from '../../services/api';
import { User, ActivityStatus } from '../../types';
import { useAuth } from '../../auth/AuthProvider';
export interface SelectedUser {
id: string;
email: string;
name?: string;
role?: string;
}
interface UserSearchPickerProps {
selectedUsers: SelectedUser[];
onSelectUser: (user: SelectedUser) => void;
onRemoveUser: (userId: string) => void;
multiple?: boolean;
placeholder?: string;
}
interface UserGroup {
id: string;
label: string;
icon: React.ElementType;
color: string;
users: User[];
}
export const UserSearchPicker: React.FC<UserSearchPickerProps> = ({
selectedUsers,
onSelectUser,
onRemoveUser,
placeholder = 'Search by name, email, or role...',
}) => {
const { user, claims } = useAuth();
const currentUserId = String(user?.id || claims?.sub || '').trim().toLowerCase();
const currentUserEmail = String(user?.email || claims?.email || '').trim().toLowerCase();
const [searchTerm, setSearchTerm] = useState('');
const [isDropdownOpen, setIsDropdownOpen] = useState(false);
const [collapsedGroups, setCollapsedGroups] = useState<Record<string, boolean>>({});
// Query tenant users
const { data: users = [], isLoading } = useQuery<User[]>({
queryKey: ['users-directory'],
queryFn: () => getUsers(1, 200),
});
// Filter active users and exclude the current logged-in user so they can never message themselves
const activeUsers = useMemo(() => {
return users.filter(u => {
if (u.status !== ActivityStatus.Active || !u.email) return false;
const uid = String(u.id || '').trim().toLowerCase();
const uemail = u.email.trim().toLowerCase();
if (currentUserId && uid === currentUserId) return false;
if (currentUserEmail && uemail === currentUserEmail) return false;
return true;
});
}, [users, currentUserId, currentUserEmail]);
// Organize users into intuitive functional groups
const groupedUsers = useMemo(() => {
const term = searchTerm.toLowerCase().trim();
const filtered = activeUsers.filter(u => {
if (!term) return true;
const email = (u.email || '').toLowerCase();
const role = String(u.role ?? '').toLowerCase();
return email.includes(term) || role.includes(term);
});
const adminUsers: User[] = [];
const pmUsers: User[] = [];
const financeUsers: User[] = [];
const meUsers: User[] = [];
const otherUsers: User[] = [];
filtered.forEach(u => {
const r = String(u.role ?? '').toLowerCase();
if (r.includes('admin') || r.includes('executive') || r.includes('director')) {
adminUsers.push(u);
} else if (r.includes('programme') || r.includes('project') || r.includes('manager')) {
pmUsers.push(u);
} else if (r.includes('finance') || r.includes('disbursement') || r.includes('accountant') || r.includes('budget')) {
financeUsers.push(u);
} else if (r.includes('m&e') || r.includes('monitoring') || r.includes('eval') || r.includes('risk') || r.includes('compliance')) {
meUsers.push(u);
} else {
otherUsers.push(u);
}
});
const groups: UserGroup[] = [];
if (adminUsers.length > 0) {
groups.push({
id: 'admin',
label: 'Leadership & Administration',
icon: Shield,
color: 'text-amber-400 bg-amber-500/10 border-amber-500/20',
users: adminUsers,
});
}
if (pmUsers.length > 0) {
groups.push({
id: 'pm',
label: 'Programme & Project Management',
icon: Briefcase,
color: 'text-blue-400 bg-blue-500/10 border-blue-500/20',
users: pmUsers,
});
}
if (financeUsers.length > 0) {
groups.push({
id: 'finance',
label: 'Finance & Disbursements',
icon: Coins,
color: 'text-emerald-400 bg-emerald-500/10 border-emerald-500/20',
users: financeUsers,
});
}
if (meUsers.length > 0) {
groups.push({
id: 'me',
label: 'M&E, Compliance & Risks',
icon: ShieldAlert,
color: 'text-purple-400 bg-purple-500/10 border-purple-500/20',
users: meUsers,
});
}
if (otherUsers.length > 0) {
groups.push({
id: 'team',
label: 'Tenant Staff & Team Members',
icon: Users,
color: 'text-slate-400 bg-slate-500/10 border-slate-500/20',
users: otherUsers,
});
}
return groups;
}, [activeUsers, searchTerm]);
const toggleGroupCollapse = (groupId: string) => {
setCollapsedGroups(prev => ({
...prev,
[groupId]: !prev[groupId],
}));
};
const isUserSelected = (userId: string) => {
return selectedUsers.some(u => u.id === userId);
};
const handleUserClick = (u: User) => {
if (!u.email) return;
if (isUserSelected(u.id)) {
onRemoveUser(u.id);
} else {
const displayName = u.email.split('@')[0].replace(/[._-]/g, ' ');
const formattedName = displayName
.split(' ')
.map(w => w.charAt(0).toUpperCase() + w.slice(1))
.join(' ');
// Strictly single-recipient selection
onSelectUser({
id: u.id,
email: u.email,
name: formattedName,
role: String(u.role ?? 'User'),
});
setSearchTerm('');
setIsDropdownOpen(false);
}
};
const singleSelectedUser = selectedUsers[0];
return (
<div className="relative w-full">
{/* Single Selected Recipient or Search Input Box */}
{singleSelectedUser ? (
<div className="flex items-center justify-between px-3 py-2 rounded-xl bg-blue-500/10 border border-blue-500/30 text-blue-100 text-xs shadow-xs">
<div className="flex items-center gap-2.5 min-w-0">
<div className="w-6 h-6 rounded-full bg-blue-600/40 text-[10px] font-bold flex items-center justify-center text-blue-200 shrink-0 border border-blue-400/30">
{(singleSelectedUser.name || singleSelectedUser.email).slice(0, 2).toUpperCase()}
</div>
<div className="flex items-center gap-2 min-w-0">
<span className="font-semibold text-white truncate">
{singleSelectedUser.name || singleSelectedUser.email}
</span>
<span className="text-[11px] text-slate-400 font-mono truncate">
&lt;{singleSelectedUser.email}&gt;
</span>
{singleSelectedUser.role && (
<span className="text-[10px] text-blue-300 bg-blue-900/40 px-1.5 py-0.5 rounded border border-blue-800/40 shrink-0">
{singleSelectedUser.role}
</span>
)}
</div>
</div>
<button
type="button"
onClick={() => onRemoveUser(singleSelectedUser.id)}
className="flex items-center gap-1 text-[11px] text-blue-300 hover:text-white hover:bg-blue-600/30 px-2 py-1 rounded-lg transition cursor-pointer shrink-0"
title="Remove recipient to select another user"
>
<X className="w-3.5 h-3.5" />
<span>Change</span>
</button>
</div>
) : (
<div className="relative">
<div className="absolute inset-y-0 left-0 pl-3 flex items-center pointer-events-none text-slate-400">
<Search className="w-4 h-4" />
</div>
<input
type="text"
value={searchTerm}
onChange={e => {
setSearchTerm(e.target.value);
if (!isDropdownOpen) setIsDropdownOpen(true);
}}
onFocus={() => setIsDropdownOpen(true)}
placeholder={placeholder}
className="w-full pl-9 pr-8 py-2 rounded-xl bg-slate-900/90 border border-slate-700/80 hover:border-slate-600 focus:border-blue-500 text-slate-100 placeholder-slate-400 text-xs focus:outline-none transition shadow-inner"
/>
{searchTerm && (
<button
type="button"
onClick={() => setSearchTerm('')}
className="absolute inset-y-0 right-0 pr-3 flex items-center text-slate-400 hover:text-white"
>
<X className="w-3.5 h-3.5" />
</button>
)}
</div>
)}
{/* Organized User Directory Dropdown (only when no recipient is locked in) */}
{isDropdownOpen && !singleSelectedUser && (
<div className="absolute left-0 right-0 top-full mt-1.5 max-h-72 overflow-y-auto bg-[#0d1322] border border-slate-700/80 rounded-2xl shadow-2xl z-50 p-2 space-y-3 backdrop-blur-md animate-in fade-in slide-in-from-top-1">
{isLoading ? (
<div className="py-6 text-center text-xs text-slate-400 flex items-center justify-center gap-2">
<div className="w-4 h-4 border-2 border-blue-500 border-t-transparent rounded-full animate-spin" />
<span>Loading tenant users directory...</span>
</div>
) : groupedUsers.length === 0 ? (
<div className="py-6 text-center text-xs text-slate-400">
No matching users found for "{searchTerm}".
</div>
) : (
groupedUsers.map(group => {
const GroupIcon = group.icon;
const isCollapsed = collapsedGroups[group.id];
return (
<div key={group.id} className="space-y-1">
{/* Category Header */}
<button
type="button"
onClick={() => toggleGroupCollapse(group.id)}
className="w-full flex items-center justify-between px-2.5 py-1.5 rounded-lg bg-slate-900/60 hover:bg-slate-800/60 text-slate-300 text-[11px] font-semibold transition cursor-pointer border border-slate-800/60"
>
<div className="flex items-center gap-2">
<span className={`p-1 rounded-md border ${group.color}`}>
<GroupIcon className="w-3 h-3" />
</span>
<span>{group.label}</span>
<span className="text-[10px] text-slate-400 bg-slate-800 px-1.5 py-0.2 rounded-full border border-slate-700 font-mono">
{group.users.length}
</span>
</div>
{isCollapsed ? (
<ChevronDown className="w-3.5 h-3.5 text-slate-400" />
) : (
<ChevronUp className="w-3.5 h-3.5 text-slate-400" />
)}
</button>
{/* Users inside group */}
{!isCollapsed && (
<div className="grid grid-cols-1 gap-1 pl-1 pr-1 pt-0.5">
{group.users.map(u => {
const selected = isUserSelected(u.id);
const displayName = (u.email || '').split('@')[0].replace(/[._-]/g, ' ');
const formattedName = displayName
.split(' ')
.map(w => w.charAt(0).toUpperCase() + w.slice(1))
.join(' ');
return (
<div
key={u.id}
onClick={() => handleUserClick(u)}
className={`flex items-center justify-between px-3 py-2 rounded-xl text-xs transition cursor-pointer border ${
selected
? 'bg-blue-600/20 border-blue-500/40 text-white'
: 'bg-slate-900/40 hover:bg-slate-800/70 border-slate-800/40 text-slate-200'
}`}
>
<div className="flex items-center gap-2.5 min-w-0">
<div
className={`w-7 h-7 shrink-0 rounded-full flex items-center justify-center font-bold text-[10px] border ${
selected
? 'bg-blue-600 border-blue-400 text-white'
: 'bg-slate-800 border-slate-700 text-slate-300'
}`}
>
{formattedName.slice(0, 2).toUpperCase()}
</div>
<div className="min-w-0">
<p className="font-medium text-xs truncate text-white">{formattedName}</p>
<p className="text-[10px] text-slate-400 font-mono truncate">{u.email}</p>
</div>
</div>
<div className="flex items-center gap-2 shrink-0">
{u.role && (
<span className="text-[10px] font-semibold text-blue-400 bg-blue-500/10 border border-blue-500/20 px-1.5 py-0.5 rounded truncate max-w-[120px]">
{u.role}
</span>
)}
<div
className={`w-4 h-4 rounded-md border flex items-center justify-center ${
selected
? 'bg-blue-600 border-blue-500 text-white'
: 'border-slate-700 bg-slate-950/60'
}`}
>
{selected && <Check className="w-3 h-3 stroke-[3]" />}
</div>
</div>
</div>
);
})}
</div>
)}
</div>
);
})
)}
{/* Close button at bottom */}
<div className="pt-2 border-t border-slate-800 flex justify-end">
<button
type="button"
onClick={() => setIsDropdownOpen(false)}
className="text-[11px] text-slate-400 hover:text-white px-3 py-1 rounded-lg hover:bg-slate-800 transition cursor-pointer"
>
Done Selecting
</button>
</div>
</div>
)}
</div>
);
};
@@ -1,4 +1,4 @@
import React, { useState } from 'react'; import React, { useState, useMemo } from 'react';
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query'; import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
import { useForm } from 'react-hook-form'; import { useForm } from 'react-hook-form';
import { zodResolver } from '@hookform/resolvers/zod'; import { zodResolver } from '@hookform/resolvers/zod';
@@ -14,6 +14,10 @@ import {
Flag, Flag,
X, X,
RotateCcw, RotateCcw,
Target,
AlertTriangle,
ChevronRight,
ShieldAlert,
} from 'lucide-react'; } from 'lucide-react';
import { Modal } from '../ui/Modal'; import { Modal } from '../ui/Modal';
import { HelpTooltip } from '../ui/HelpTooltip'; import { HelpTooltip } from '../ui/HelpTooltip';
@@ -23,15 +27,17 @@ import {
createActivity, createActivity,
updateActivity, updateActivity,
getMilestones, getMilestones,
getProjectIndicators,
getBudgets,
} from '../../services/api'; } from '../../services/api';
import { Project, Activity, Milestone } from '../../types'; import { Project, Activity, Milestone, Indicator, Budget } from '../../types';
import { formatDate, formatZAR } from '../../lib/formatters'; import { formatDate, formatZAR } from '../../lib/formatters';
const activitySchema = z.object({ const activitySchema = z.object({
title: z.string().min(3, 'Activity title required'), title: z.string().min(3, 'Activity title required'),
description: z.string().min(5, 'Description required'), description: z.string().min(5, 'Description required'),
category: z.string().min(2, 'Category 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(), milestoneId: z.string().optional().nullable(),
performedAt: z.string().optional().nullable(), performedAt: z.string().optional().nullable(),
}); });
@@ -42,12 +48,14 @@ interface ProjectActivitiesModalProps {
isOpen: boolean; isOpen: boolean;
onClose: () => void; onClose: () => void;
project: Project | null; project: Project | null;
onOpenIndicatorsForActivity?: (activityId: string | number) => void;
} }
export const ProjectActivitiesModal: React.FC<ProjectActivitiesModalProps> = ({ export const ProjectActivitiesModal: React.FC<ProjectActivitiesModalProps> = ({
isOpen, isOpen,
onClose, onClose,
project, project,
onOpenIndicatorsForActivity,
}) => { }) => {
const queryClient = useQueryClient(); const queryClient = useQueryClient();
const { success, error: toastError } = useToast(); const { success, error: toastError } = useToast();
@@ -73,8 +81,47 @@ export const ProjectActivitiesModal: React.FC<ProjectActivitiesModalProps> = ({
enabled: isOpen && !!projectId, enabled: isOpen && !!projectId,
}); });
const { data: indicators = [] } = useQuery({
queryKey: ['indicators', projectId],
queryFn: () => (projectId ? getProjectIndicators(projectId) : Promise.resolve([])),
enabled: isOpen && !!projectId,
});
const { data: allBudgets = [] } = useQuery({
queryKey: ['budgets'],
queryFn: () => getBudgets(1, 100),
enabled: isOpen,
});
// Approved project budget calculations
const approvedBudgetTotal = useMemo(() => {
const projectBudgets = allBudgets.filter((b) => String(b.projectId) === String(projectId));
return projectBudgets
.filter((b) => b.status === undefined || b.status === null || Number(b.status) === 3)
.reduce((sum, b) => sum + (Number(b.amount) || 0), 0);
}, [allBudgets, projectId]);
const totalAllocated = useMemo(
() => activities.reduce((acc, a) => acc + (Number(a.amount) || 0), 0),
[activities]
);
const maxAllowedForForm = useMemo(() => {
if (approvedBudgetTotal <= 0) return Infinity;
const baseRemaining = approvedBudgetTotal - totalAllocated;
if (editingActivity) {
return baseRemaining + (Number(editingActivity.amount) || 0);
}
return Math.max(0, baseRemaining);
}, [approvedBudgetTotal, totalAllocated, editingActivity]);
const remainingBudget = useMemo(() => {
if (approvedBudgetTotal <= 0) return 0;
return Math.max(0, approvedBudgetTotal - totalAllocated);
}, [approvedBudgetTotal, totalAllocated]);
const form = useForm<ActivityFormValues>({ const form = useForm<ActivityFormValues>({
resolver: zodResolver(activitySchema) as any, resolver: zodResolver(activitySchema),
defaultValues: { defaultValues: {
title: '', title: '',
description: '', description: '',
@@ -114,6 +161,14 @@ export const ProjectActivitiesModal: React.FC<ProjectActivitiesModalProps> = ({
const mutation = useMutation({ const mutation = useMutation({
mutationFn: async (data: ActivityFormValues): Promise<void> => { mutationFn: async (data: ActivityFormValues): Promise<void> => {
if (!projectId) return; if (!projectId) return;
const requestedAmount = Number(data.amount);
if (approvedBudgetTotal > 0 && requestedAmount > maxAllowedForForm) {
throw new Error(
`Activity amount (${formatZAR(requestedAmount)}) exceeds the available remaining project budget (${formatZAR(maxAllowedForForm)}).`
);
}
const parsedMilestoneId = const parsedMilestoneId =
data.milestoneId && String(data.milestoneId).trim() !== '' data.milestoneId && String(data.milestoneId).trim() !== ''
? Number(data.milestoneId) ? Number(data.milestoneId)
@@ -127,7 +182,7 @@ export const ProjectActivitiesModal: React.FC<ProjectActivitiesModalProps> = ({
title: data.title, title: data.title,
description: data.description, description: data.description,
category: data.category, category: data.category,
amount: Number(data.amount), amount: requestedAmount,
milestoneId: parsedMilestoneId, milestoneId: parsedMilestoneId,
performedAt: performedAtIso, performedAt: performedAtIso,
}); });
@@ -137,7 +192,7 @@ export const ProjectActivitiesModal: React.FC<ProjectActivitiesModalProps> = ({
title: data.title, title: data.title,
description: data.description, description: data.description,
category: data.category, category: data.category,
amount: Number(data.amount), amount: requestedAmount,
milestoneId: parsedMilestoneId, milestoneId: parsedMilestoneId,
performedAt: performedAtIso, performedAt: performedAtIso,
}); });
@@ -145,6 +200,7 @@ export const ProjectActivitiesModal: React.FC<ProjectActivitiesModalProps> = ({
}, },
onSuccess: () => { onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['activities', projectId] }); queryClient.invalidateQueries({ queryKey: ['activities', projectId] });
queryClient.invalidateQueries({ queryKey: ['milestones', projectId] });
queryClient.invalidateQueries({ queryKey: ['projects'] }); queryClient.invalidateQueries({ queryKey: ['projects'] });
success( success(
editingActivity ? 'Activity Updated' : 'Activity Created', editingActivity ? 'Activity Updated' : 'Activity Created',
@@ -163,8 +219,6 @@ export const ProjectActivitiesModal: React.FC<ProjectActivitiesModalProps> = ({
if (!project) return null; if (!project) return null;
const totalBudget = activities.reduce((acc, a) => acc + (Number(a.amount) || 0), 0);
return ( return (
<Modal <Modal
isOpen={isOpen} isOpen={isOpen}
@@ -175,23 +229,52 @@ export const ProjectActivitiesModal: React.FC<ProjectActivitiesModalProps> = ({
}} }}
title={`Work Breakdown Activities: ${project.name}`} title={`Work Breakdown Activities: ${project.name}`}
subtitle={`Project #${project.id} • Operational Work Breakdown Structure (WBS)`} subtitle={`Project #${project.id} • Operational Work Breakdown Structure (WBS)`}
helpText="Granular operational tasks executed under this project. Activities can optionally be linked to contractual milestones and are tracked with specific budget allocations." helpText="Under each milestone, operational activities are scheduled with budgeted amounts. The sum of all activity budgets across the project cannot exceed the approved project budget."
maxWidth="xl" maxWidth="xl"
> >
<div className="space-y-5"> <div className="space-y-5">
{/* KPI Summary Card */} {/* KPI Summary Cards */}
<div className="bg-[#0b101c] p-4 rounded-xl border border-slate-800/90 flex flex-col sm:flex-row sm:items-center justify-between gap-4"> <div className="grid grid-cols-1 sm:grid-cols-3 gap-3">
<div className="space-y-1"> <div className="bg-[#0b101c] p-3.5 rounded-xl border border-slate-800">
<span className="text-[10px] uppercase font-bold text-slate-400 flex items-center gap-1.5"> <span className="text-[10px] uppercase font-bold text-slate-400 flex items-center gap-1.5">
<Coins className="w-3.5 h-3.5 text-cyan-400" /> <Coins className="w-3.5 h-3.5 text-cyan-400" />
Total Allocated Work Budget Total Allocated Activities
</span> </span>
<div className="flex items-baseline gap-2"> <div className="flex items-baseline gap-2 mt-1">
<span className="text-2xl font-bold font-mono text-cyan-400">{formatZAR(totalBudget)}</span> <span className="text-xl font-bold font-mono text-cyan-400">{formatZAR(totalAllocated)}</span>
<span className="text-xs text-slate-400">({activities.length} recorded tasks)</span> <span className="text-[11px] text-slate-400">({activities.length} tasks)</span>
</div> </div>
</div> </div>
<div className="bg-[#0b101c] p-3.5 rounded-xl border border-slate-800">
<span className="text-[10px] uppercase font-bold text-slate-400 flex items-center gap-1.5">
<Coins className="w-3.5 h-3.5 text-emerald-400" />
Approved Project Budget
</span>
<div className="flex items-baseline gap-2 mt-1">
<span className="text-xl font-bold font-mono text-emerald-400">{formatZAR(approvedBudgetTotal)}</span>
</div>
</div>
<div className="bg-[#0b101c] p-3.5 rounded-xl border border-slate-800">
<span className="text-[10px] uppercase font-bold text-slate-400 flex items-center gap-1.5">
<Coins className="w-3.5 h-3.5 text-blue-400" />
Remaining Budget Ceiling
</span>
<div className="flex items-baseline gap-2 mt-1">
<span className={`text-xl font-bold font-mono ${remainingBudget <= 0 && approvedBudgetTotal > 0 ? 'text-rose-400' : 'text-blue-400'}`}>
{formatZAR(remainingBudget)}
</span>
</div>
</div>
</div>
{/* Action Header */}
<div className="flex items-center justify-between bg-[#0d121f] p-3 rounded-xl border border-slate-800">
<span className="text-xs text-slate-400">
Scheduled Tasks: <strong className="text-white">{activities.length}</strong>
</span>
<div className="flex items-center gap-2"> <div className="flex items-center gap-2">
{!isFormOpen && ( {!isFormOpen && (
<button <button
@@ -226,7 +309,14 @@ export const ProjectActivitiesModal: React.FC<ProjectActivitiesModalProps> = ({
</button> </button>
</div> </div>
<form onSubmit={form.handleSubmit((data) => mutation.mutate(data as any))} className="space-y-3"> {approvedBudgetTotal > 0 && (
<div className="p-2.5 rounded-lg bg-blue-500/10 border border-blue-500/25 text-xs flex items-center justify-between text-blue-300">
<span>Maximum activity budget allowance:</span>
<strong className="font-mono">{formatZAR(maxAllowedForForm)}</strong>
</div>
)}
<form onSubmit={form.handleSubmit((data) => mutation.mutate(data))} className="space-y-3">
<div> <div>
<label className="block text-xs font-semibold text-slate-300 mb-1">Activity Title *</label> <label className="block text-xs font-semibold text-slate-300 mb-1">Activity Title *</label>
<input <input
@@ -262,6 +352,7 @@ export const ProjectActivitiesModal: React.FC<ProjectActivitiesModalProps> = ({
<label className="block text-xs font-semibold text-slate-300 mb-1">Budget Amount (ZAR) *</label> <label className="block text-xs font-semibold text-slate-300 mb-1">Budget Amount (ZAR) *</label>
<input <input
type="number" type="number"
step="0.01"
{...form.register('amount')} {...form.register('amount')}
className="w-full px-3 py-2 text-xs border border-slate-700/80 rounded-xl focus:outline-none focus:ring-1 focus:ring-cyan-500 text-white font-mono bg-[#090d16]" className="w-full px-3 py-2 text-xs border border-slate-700/80 rounded-xl focus:outline-none focus:ring-1 focus:ring-cyan-500 text-white font-mono bg-[#090d16]"
/> />
@@ -350,7 +441,9 @@ export const ProjectActivitiesModal: React.FC<ProjectActivitiesModalProps> = ({
<div className="p-8 text-center bg-[#0b101c] rounded-xl border border-dashed border-slate-800 text-slate-400 space-y-2"> <div className="p-8 text-center bg-[#0b101c] rounded-xl border border-dashed border-slate-800 text-slate-400 space-y-2">
<Briefcase className="w-8 h-8 text-slate-600 mx-auto" /> <Briefcase className="w-8 h-8 text-slate-600 mx-auto" />
<p className="text-xs font-semibold text-slate-300">No work activities recorded yet</p> <p className="text-xs font-semibold text-slate-300">No work activities recorded yet</p>
<p className="text-[11px] text-slate-500">Deconstruct the project into operational tasks with allocated budgets.</p> <p className="text-[11px] text-slate-500">
Deconstruct the project milestones into operational tasks with allocated budgets.
</p>
<button <button
type="button" type="button"
onClick={openCreateForm} onClick={openCreateForm}
@@ -360,59 +453,119 @@ export const ProjectActivitiesModal: React.FC<ProjectActivitiesModalProps> = ({
</button> </button>
</div> </div>
) : ( ) : (
<div className="space-y-2 max-h-[380px] overflow-y-auto pr-1"> <div className="space-y-3 max-h-[420px] overflow-y-auto pr-1">
{activities.map((a) => { {activities.map((a) => {
const linkedMilestone = a.milestoneId const linkedMilestone = a.milestoneId
? milestones.find((m) => String(m.id) === String(a.milestoneId)) ? milestones.find((m) => String(m.id) === String(a.milestoneId))
: null; : null;
const actIndicators = indicators.filter((i) => String(i.activityId) === String(a.id));
const actActualCost = actIndicators.reduce((sum, i) => sum + (Number(i.actualCost) || 0), 0);
const isOverrun = actActualCost > Number(a.amount) && a.amount > 0;
return ( return (
<div <div
key={a.id} key={a.id}
className="p-3.5 rounded-xl border border-slate-800 bg-[#0d121f] hover:border-slate-700 transition flex flex-col sm:flex-row sm:items-start justify-between gap-3" className={`p-3.5 rounded-xl border bg-[#0d121f] transition space-y-2.5 ${
isOverrun ? 'border-amber-500/40 bg-amber-950/10' : 'border-slate-800 hover:border-slate-700'
}`}
> >
<div className="space-y-1"> <div className="flex flex-col sm:flex-row sm:items-start justify-between gap-3">
<div className="flex items-center gap-2 flex-wrap"> <div className="space-y-1">
<span className="text-xs font-bold text-white">{a.title}</span> <div className="flex items-center gap-2 flex-wrap">
<span className="bg-slate-900 border border-slate-700 text-slate-300 px-2 py-0.5 rounded-md text-[10px] font-medium"> <span className="text-xs font-bold text-white">{a.title}</span>
{a.category} <span className="bg-slate-900 border border-slate-700 text-slate-300 px-2 py-0.5 rounded-md text-[10px] font-medium">
</span> {a.category}
{a.milestoneId && (
<span className="inline-flex items-center gap-1 text-[10px] font-mono text-blue-400 bg-blue-500/10 px-2 py-0.5 rounded-md border border-blue-500/25">
<Flag className="w-2.5 h-2.5" />
Milestone #{a.milestoneId}
{linkedMilestone ? ` (${linkedMilestone.name})` : ''}
</span> </span>
)} {a.milestoneId && (
<span className="inline-flex items-center gap-1 text-[10px] font-mono text-blue-400 bg-blue-500/10 px-2 py-0.5 rounded-md border border-blue-500/25">
<Flag className="w-2.5 h-2.5" />
Milestone #{a.milestoneId}
{linkedMilestone ? ` (${linkedMilestone.name})` : ''}
</span>
)}
</div>
<p className="text-xs text-slate-400 leading-relaxed">{a.description}</p>
<div className="flex items-center gap-3 text-[11px] text-slate-500 font-mono pt-1">
<span>Date: {formatDate(a.performedAt)}</span>
<span>&bull;</span>
<span>ID #{a.id}</span>
</div>
</div> </div>
<p className="text-xs text-slate-400 leading-relaxed">{a.description}</p>
<div className="flex items-center gap-3 text-[11px] text-slate-500 font-mono pt-1"> <div className="flex items-center gap-2 shrink-0 self-end sm:self-center">
<span>Date: {formatDate(a.performedAt)}</span> <div className="text-right mr-1">
<span>&bull;</span> <span className="block font-mono text-xs font-bold text-cyan-400">
<span>ID #{a.id}</span> {formatZAR(a.amount)}
</span>
{actActualCost > 0 && (
<span className={`block font-mono text-[10px] ${isOverrun ? 'text-rose-400 font-bold' : 'text-slate-400'}`}>
Actual: {formatZAR(actActualCost)}
</span>
)}
</div>
<Link
to={`/monitoring?tab=evidence&projectId=${project.id}&activityId=${a.id}`}
className="inline-flex items-center gap-1 px-2.5 py-1 text-[11px] font-semibold text-cyan-300 hover:text-white bg-cyan-500/10 hover:bg-cyan-500/20 border border-cyan-500/25 rounded-lg transition"
title="Inspect activity evidence"
>
<FileCheck className="w-3.5 h-3.5 text-cyan-400" />
Evidence
</Link>
<button
type="button"
onClick={() => openEditForm(a)}
className="p-1.5 rounded-lg text-slate-400 hover:text-cyan-400 hover:bg-slate-800 transition cursor-pointer"
title="Edit Activity"
>
<Edit2 className="w-3.5 h-3.5" />
</button>
</div> </div>
</div> </div>
<div className="flex items-center gap-2 shrink-0 self-end sm:self-center"> {/* Linked Delivery Indicators Section */}
<span className="font-mono text-xs font-bold text-cyan-400 mr-2">{formatZAR(a.amount)}</span> <div className="pt-2 border-t border-slate-800/80 flex flex-col sm:flex-row sm:items-center justify-between gap-2 text-xs">
<div className="flex items-center gap-2 flex-wrap">
<span className="text-[11px] text-slate-400 flex items-center gap-1">
<Target className="w-3 h-3 text-indigo-400" />
Delivery Specifications:
</span>
{actIndicators.length === 0 ? (
<span className="text-[11px] text-slate-500 italic">No indicators defined for this activity yet.</span>
) : (
<div className="flex items-center gap-1.5 flex-wrap">
{actIndicators.map((ind) => (
<span
key={ind.id}
className="px-2 py-0.5 rounded bg-slate-900 border border-slate-700/70 text-[10px] text-slate-300 font-mono"
>
{ind.name}: {ind.actualAmount}/{ind.targetAmount}
</span>
))}
</div>
)}
</div>
<Link <div className="flex items-center gap-2 shrink-0">
to={`/monitoring?tab=evidence&projectId=${project.id}&activityId=${a.id}`} {isOverrun && (
className="inline-flex items-center gap-1 px-2.5 py-1 text-[11px] font-semibold text-cyan-300 hover:text-white bg-cyan-500/10 hover:bg-cyan-500/20 border border-cyan-500/25 rounded-lg transition" <span className="text-[10px] font-bold text-rose-400 bg-rose-500/10 px-2 py-0.5 rounded border border-rose-500/25">
title="Inspect activity evidence" ⚠️ Overrun: +{formatZAR(actActualCost - Number(a.amount))}
> </span>
<FileCheck className="w-3.5 h-3.5 text-cyan-400" /> )}
Evidence
</Link>
<button {onOpenIndicatorsForActivity && (
type="button" <button
onClick={() => openEditForm(a)} type="button"
className="p-1.5 rounded-lg text-slate-400 hover:text-cyan-400 hover:bg-slate-800 transition cursor-pointer" onClick={() => onOpenIndicatorsForActivity(a.id)}
title="Edit Activity" className="inline-flex items-center gap-1 text-[11px] font-semibold text-indigo-400 hover:text-indigo-300 hover:underline cursor-pointer"
> >
<Edit2 className="w-3.5 h-3.5" /> <span>Manage Indicators ({actIndicators.length})</span>
</button> <ChevronRight className="w-3 h-3" />
</button>
)}
</div>
</div> </div>
</div> </div>
); );
@@ -0,0 +1,496 @@
import React from 'react';
import { useQuery } from '@tanstack/react-query';
import {
ShieldCheck,
ShieldAlert,
AlertTriangle,
Users,
Target,
Coins,
CheckCircle2,
} from 'lucide-react';
import { Modal } from '../ui/Modal';
import { ComplianceStatusBadge } from '../ui/StatusBadge';
import {
Project,
Programme,
Outcome,
ComplianceStatus,
ApprovalStatus,
} from '../../types';
import {
getProjectComplianceItems,
getProjectBeneficiaries,
getBeneficiaryOutcomes,
getProjectRisks,
getMilestones,
getActivities,
getBudgets,
} from '../../services/api';
import {
formatDate,
formatZAR,
getRiskScoreColor,
} from '../../lib/formatters';
interface ProjectComplianceModalProps {
isOpen: boolean;
onClose: () => void;
project: Project | null;
programme: Programme | null;
}
export const ProjectComplianceModal: React.FC<ProjectComplianceModalProps> = ({
isOpen,
onClose,
project,
programme,
}) => {
const projectId = project?.id;
// Queries
const { data: complianceItems = [], isLoading: loadingCompliance } = useQuery({
queryKey: ['complianceItems', projectId],
queryFn: () => (projectId ? getProjectComplianceItems(projectId) : Promise.resolve([])),
enabled: isOpen && !!projectId,
});
const { data: projectBeneficiaries = [], isLoading: loadingBeneficiaries } = useQuery({
queryKey: ['projectBeneficiaries', projectId],
queryFn: () => (projectId ? getProjectBeneficiaries(projectId) : Promise.resolve([])),
enabled: isOpen && !!projectId,
});
const { data: risks = [], isLoading: loadingRisks } = useQuery({
queryKey: ['risks', projectId],
queryFn: () => (projectId ? getProjectRisks(projectId) : Promise.resolve([])),
enabled: isOpen && !!projectId,
});
const { data: milestones = [] } = useQuery({
queryKey: ['milestones', projectId],
queryFn: () => (projectId ? getMilestones(projectId) : Promise.resolve([])),
enabled: isOpen && !!projectId,
});
const { data: activities = [] } = useQuery({
queryKey: ['activities', projectId],
queryFn: () => (projectId ? getActivities(projectId) : Promise.resolve([])),
enabled: isOpen && !!projectId,
});
const { data: allBudgets = [] } = useQuery({
queryKey: ['budgets'],
queryFn: () => getBudgets(1, 100),
enabled: isOpen && !!projectId,
});
// Beneficiary outcomes query
const { data: outcomes = [], isLoading: loadingOutcomes } = useQuery({
queryKey: ['beneficiaryOutcomes', projectBeneficiaries.map((b) => b.id)],
queryFn: async (): Promise<Outcome[]> => {
if (projectBeneficiaries.length === 0) return [];
const results = await Promise.all(
projectBeneficiaries.map(async (b) => {
try {
return await getBeneficiaryOutcomes(b.id);
} catch {
return [];
}
})
);
return results.flat();
},
enabled: isOpen && projectBeneficiaries.length > 0,
});
if (!project) return null;
// Compliance calculations
const totalCompCount = complianceItems.length;
const metCompCount = complianceItems.filter(
(c) => Number(c.status) === ComplianceStatus.Met || Number(c.status) === ComplianceStatus.Exceeded
).length;
const unmetCompCount = totalCompCount - metCompCount;
// Outcomes calculations
const totalOutcomesCount = outcomes.length;
const metOutcomesCount = outcomes.filter(
(o) => Number(o.status) === ComplianceStatus.Met || Number(o.status) === ComplianceStatus.Exceeded
).length;
const unmetOutcomesCount = totalOutcomesCount - metOutcomesCount;
// Overall compliance verdict (Invariant: 100% met required for approval)
const is100PercentCompliant =
unmetCompCount === 0 && unmetOutcomesCount === 0;
// Financial calculations
const projectBudgets = allBudgets.filter((b) => String(b.projectId) === String(projectId));
const approvedBudgetTotal = projectBudgets
.filter((b) => Number(b.status) === ApprovalStatus.Approved || b.status === undefined)
.reduce((sum, b) => sum + (Number(b.amount) || 0), 0);
const totalActivityBudget = activities.reduce((sum, a) => sum + (Number(a.amount) || 0), 0);
const financialVariance = approvedBudgetTotal - totalActivityBudget;
const isWithinBudget = financialVariance >= 0;
// Milestone Progress
const verifiedMilestonesCount = milestones.filter((m) => m.status === ApprovalStatus.Approved).length;
const milestoneProgress = milestones.length > 0 ? Math.round((verifiedMilestonesCount / milestones.length) * 100) : 0;
return (
<Modal
isOpen={isOpen}
onClose={onClose}
title={`Risk & Compliance Status: ${project.name}`}
subtitle={`Project #${project.id} • ${programme?.name || 'Programme'} • Statutory Governance Audit`}
helpText="Comprehensive audit of project compliance items, beneficiary outcomes fulfillment, ISO 31000 risk register, and statutory approval eligibility."
maxWidth="5xl"
>
<div className="space-y-6 text-slate-200">
{/* Top Verdict Banner */}
<div
className={`p-4 sm:p-5 rounded-2xl border flex flex-col sm:flex-row sm:items-center justify-between gap-4 ${
is100PercentCompliant
? 'bg-emerald-950/30 border-emerald-500/40'
: 'bg-rose-950/30 border-rose-500/40'
}`}
>
<div className="flex items-start gap-3">
<div
className={`p-2.5 rounded-xl border mt-0.5 ${
is100PercentCompliant
? 'bg-emerald-500/20 text-emerald-400 border-emerald-500/30'
: 'bg-rose-500/20 text-rose-400 border-rose-500/30'
}`}
>
{is100PercentCompliant ? (
<ShieldCheck className="w-6 h-6" />
) : (
<ShieldAlert className="w-6 h-6" />
)}
</div>
<div>
<div className="flex items-center gap-2">
<h4 className="text-sm font-bold text-white">
{is100PercentCompliant
? '100% Fully Compliant — Approved for Project Governance'
: 'Action Required: Project is Noncompliant'}
</h4>
<span
className={`text-[10px] uppercase font-bold px-2 py-0.5 rounded-full border ${
is100PercentCompliant
? 'bg-emerald-500/20 text-emerald-300 border-emerald-500/40'
: 'bg-rose-500/20 text-rose-300 border-rose-500/40 animate-pulse'
}`}
>
{is100PercentCompliant ? 'Eligible for Approval' : 'Approvals Blocked'}
</span>
</div>
<p className="text-xs text-slate-300 mt-1">
{is100PercentCompliant
? 'All statutory compliance requirements and beneficiary impact outcomes have met or exceeded threshold requirements.'
: `This project has ${unmetCompCount} unmet compliance item(s) and ${unmetOutcomesCount} unmet beneficiary outcome(s). Per governance policy, projects cannot be approved until all compliance items and outcomes are 100% met.`}
</p>
</div>
</div>
<div className="shrink-0 flex items-center gap-3">
<div className="text-right">
<span className="text-[10px] text-slate-400 block uppercase font-semibold">Budget At Risk</span>
<span className={`text-base font-bold font-mono ${is100PercentCompliant ? 'text-emerald-400' : 'text-rose-400'}`}>
{is100PercentCompliant ? 'R 0.00' : formatZAR(approvedBudgetTotal)}
</span>
</div>
</div>
</div>
{/* 4 Summary Metric Cards */}
<div className="grid grid-cols-2 lg:grid-cols-4 gap-3.5">
<div className="bg-[#0b101c] p-3.5 rounded-xl border border-slate-800">
<span className="text-[10px] uppercase font-bold text-slate-400 flex items-center gap-1.5">
<ShieldCheck className="w-3.5 h-3.5 text-cyan-400" />
Compliance Requirements
</span>
<div className="flex items-baseline gap-2 mt-1">
<span className="text-xl font-bold font-mono text-cyan-400">
{metCompCount}/{totalCompCount}
</span>
<span className="text-xs text-slate-400 font-semibold">
({totalCompCount > 0 ? Math.round((metCompCount / totalCompCount) * 100) : 100}%)
</span>
</div>
<span className="text-[10px] text-slate-500">
{unmetCompCount === 0 ? 'All items satisfied' : `${unmetCompCount} pending fulfillment`}
</span>
</div>
<div className="bg-[#0b101c] p-3.5 rounded-xl border border-slate-800">
<span className="text-[10px] uppercase font-bold text-slate-400 flex items-center gap-1.5">
<Target className="w-3.5 h-3.5 text-purple-400" />
Beneficiary Outcomes
</span>
<div className="flex items-baseline gap-2 mt-1">
<span className="text-xl font-bold font-mono text-purple-400">
{metOutcomesCount}/{totalOutcomesCount}
</span>
<span className="text-xs text-slate-400 font-semibold">
({totalOutcomesCount > 0 ? Math.round((metOutcomesCount / totalOutcomesCount) * 100) : 100}%)
</span>
</div>
<span className="text-[10px] text-slate-500">
Across {projectBeneficiaries.length} beneficiary group{projectBeneficiaries.length === 1 ? '' : 's'}
</span>
</div>
<div className="bg-[#0b101c] p-3.5 rounded-xl border border-slate-800">
<span className="text-[10px] uppercase font-bold text-slate-400 flex items-center gap-1.5">
<Coins className="w-3.5 h-3.5 text-emerald-400" />
Financial Variance
</span>
<div className="flex items-baseline gap-2 mt-1">
<span
className={`text-xl font-bold font-mono ${
isWithinBudget ? 'text-emerald-400' : 'text-rose-400'
}`}
>
{isWithinBudget ? `+${formatZAR(financialVariance)}` : `-${formatZAR(Math.abs(financialVariance))}`}
</span>
</div>
<span className="text-[10px] text-slate-500">
Budget: {formatZAR(approvedBudgetTotal)}
</span>
</div>
<div className="bg-[#0b101c] p-3.5 rounded-xl border border-slate-800">
<span className="text-[10px] uppercase font-bold text-slate-400 flex items-center gap-1.5">
<AlertTriangle className="w-3.5 h-3.5 text-amber-400" />
Active Project Risks
</span>
<div className="flex items-baseline gap-2 mt-1">
<span className="text-xl font-bold font-mono text-amber-400">{risks.length}</span>
<span className="text-xs text-slate-400">
({risks.filter((r) => (Number(r.likelihood) || 1) * (Number(r.impact) || 1) >= 12).length} High)
</span>
</div>
<span className="text-[10px] text-slate-500 flex items-center gap-1">
<CheckCircle2 className="w-3 h-3 text-cyan-400 inline" /> Milestones: {milestoneProgress}%
</span>
</div>
</div>
{/* Section 1: Statutory Compliance Requirements */}
<div className="bg-[#0b101c] rounded-2xl border border-slate-800 overflow-hidden shadow-sm">
<div className="p-4 border-b border-slate-800 flex items-center justify-between bg-[#0d1322]">
<div className="flex items-center gap-2">
<ShieldCheck className="w-4 h-4 text-cyan-400" />
<h4 className="text-xs font-bold uppercase tracking-wider text-white">
Statutory Compliance Requirements ({complianceItems.length})
</h4>
</div>
<span className="text-[11px] text-slate-400">
{metCompCount} of {complianceItems.length} Met / Exceeded
</span>
</div>
{loadingCompliance ? (
<div className="p-8 text-center text-xs text-slate-400">Loading compliance register...</div>
) : complianceItems.length === 0 ? (
<div className="p-6 text-center text-xs text-slate-500">
No specific statutory compliance requirements captured for this project yet.
</div>
) : (
<div className="overflow-x-auto">
<table className="w-full text-left text-xs border-collapse">
<thead>
<tr className="bg-slate-900/60 border-b border-slate-800 text-slate-400 text-[11px] font-semibold">
<th className="py-2.5 px-4">Title</th>
<th className="py-2.5 px-4">Requirements Specification</th>
<th className="py-2.5 px-4">Linked Beneficiary</th>
<th className="py-2.5 px-4">Status</th>
<th className="py-2.5 px-4 text-right">Created</th>
</tr>
</thead>
<tbody className="divide-y divide-slate-800/60">
{complianceItems.map((ci) => {
const linkedBen = projectBeneficiaries.find(
(b) => String(b.id) === String(ci.beneficiaryId)
);
return (
<tr key={ci.id} className="hover:bg-slate-900/30 transition">
<td className="py-2.5 px-4 font-semibold text-white">{ci.title || 'Untitled Item'}</td>
<td className="py-2.5 px-4 text-slate-300 max-w-sm truncate" title={ci.requirements || ''}>
{ci.requirements || 'N/A'}
</td>
<td className="py-2.5 px-4 text-slate-300">
{linkedBen ? (
<span className="px-2 py-0.5 rounded bg-blue-500/10 border border-blue-500/20 text-blue-300 text-[11px]">
{linkedBen.name || 'Beneficiary'}
</span>
) : (
<span className="text-slate-500 italic">Project-Wide</span>
)}
</td>
<td className="py-2.5 px-4">
<ComplianceStatusBadge status={ci.status} />
</td>
<td className="py-2.5 px-4 text-right text-slate-400 font-mono text-[11px]">
{formatDate(ci.createdAt)}
</td>
</tr>
);
})}
</tbody>
</table>
</div>
)}
</div>
{/* Section 2: Affected Beneficiaries & Mapped Outcomes */}
<div className="bg-[#0b101c] rounded-2xl border border-slate-800 overflow-hidden shadow-sm">
<div className="p-4 border-b border-slate-800 flex items-center justify-between bg-[#0d1322]">
<div className="flex items-center gap-2">
<Users className="w-4 h-4 text-blue-400" />
<h4 className="text-xs font-bold uppercase tracking-wider text-white">
Affected Beneficiaries & Mapped Impact Outcomes ({projectBeneficiaries.length})
</h4>
</div>
<span className="text-[11px] text-slate-400">
Total Target: {projectBeneficiaries.reduce((sum, b) => sum + (Number(b.targetCount) || 0), 0)} Reached:{' '}
{projectBeneficiaries.reduce((sum, b) => sum + (Number(b.reachedCount) || 0), 0)}
</span>
</div>
{loadingBeneficiaries || loadingOutcomes ? (
<div className="p-8 text-center text-xs text-slate-400">Loading beneficiaries & outcomes...</div>
) : projectBeneficiaries.length === 0 ? (
<div className="p-6 text-center text-xs text-slate-500">
No target beneficiaries mapped to this project yet.
</div>
) : (
<div className="p-4 space-y-4">
{projectBeneficiaries.map((b) => {
const benOutcomes = outcomes.filter((o) => String(o.beneficiaryId) === String(b.id));
return (
<div
key={b.id}
className="p-3.5 rounded-xl bg-slate-900/60 border border-slate-800 space-y-3"
>
<div className="flex items-center justify-between flex-wrap gap-2">
<div>
<span className="font-bold text-white text-xs mr-2">{b.name || 'Beneficiary Group'}</span>
<span className="text-[11px] text-slate-400 bg-slate-800 px-2 py-0.5 rounded border border-slate-700/60">
{b.category || 'General'}{b.location ? ` • ${b.location}` : ''}
</span>
</div>
<div className="flex items-center gap-3 text-xs font-mono">
<span className="text-slate-400">
Target: <strong className="text-white">{b.targetCount ?? 0}</strong>
</span>
<span className="text-slate-600">&bull;</span>
<span className="text-slate-400">
Reached: <strong className="text-emerald-400">{b.reachedCount ?? 0}</strong>
</span>
</div>
</div>
{/* Mapped Outcomes */}
<div className="pt-2 border-t border-slate-800/80">
<span className="text-[10px] uppercase font-bold text-slate-400 block mb-1.5">
Mapped Expected Outcomes ({benOutcomes.length})
</span>
{benOutcomes.length === 0 ? (
<p className="text-[11px] text-slate-500 italic">No expected outcomes recorded for this group.</p>
) : (
<div className="grid grid-cols-1 sm:grid-cols-2 gap-2">
{benOutcomes.map((o) => (
<div
key={o.id}
className="p-2.5 rounded-lg bg-slate-950/60 border border-slate-800/80 flex items-start justify-between gap-2"
>
<div className="min-w-0">
<span className="font-semibold text-slate-200 text-xs block truncate">{o.name || 'Outcome'}</span>
<span className="text-[10px] text-slate-400 line-clamp-1">{o.description || 'No description'}</span>
</div>
<ComplianceStatusBadge status={o.status} />
</div>
))}
</div>
)}
</div>
</div>
);
})}
</div>
)}
</div>
{/* Section 3: Risk Profile (ISO 31000) */}
<div className="bg-[#0b101c] rounded-2xl border border-slate-800 overflow-hidden shadow-sm">
<div className="p-4 border-b border-slate-800 flex items-center justify-between bg-[#0d1322]">
<div className="flex items-center gap-2">
<AlertTriangle className="w-4 h-4 text-amber-400" />
<h4 className="text-xs font-bold uppercase tracking-wider text-white">
Active Project Risks Register ({risks.length})
</h4>
</div>
<span className="text-[11px] text-slate-400">ISO 31000 Risk Management</span>
</div>
{loadingRisks ? (
<div className="p-8 text-center text-xs text-slate-400">Loading risk register...</div>
) : risks.length === 0 ? (
<div className="p-6 text-center text-xs text-slate-500">
No active risks logged on this project register.
</div>
) : (
<div className="overflow-x-auto">
<table className="w-full text-left text-xs border-collapse">
<thead>
<tr className="bg-slate-900/60 border-b border-slate-800 text-slate-400 text-[11px] font-semibold">
<th className="py-2.5 px-4">Risk Name</th>
<th className="py-2.5 px-4">Category / Status</th>
<th className="py-2.5 px-4 text-center">Score</th>
<th className="py-2.5 px-4">Mitigation Strategy</th>
</tr>
</thead>
<tbody className="divide-y divide-slate-800/60">
{risks.map((r) => {
const score = (Number(r.likelihood) || 1) * (Number(r.impact) || 1);
const scoreColor = getRiskScoreColor(r.likelihood, r.impact);
return (
<tr key={r.id} className="hover:bg-slate-900/30 transition">
<td className="py-2.5 px-4 font-semibold text-white">{r.name || r.title || 'Unnamed Risk'}</td>
<td className="py-2.5 px-4 text-slate-300">{r.category || r.status || 'Operational'}</td>
<td className="py-2.5 px-4 text-center">
<span
className={`px-2 py-0.5 rounded text-[10px] font-bold ${scoreColor.badge}`}
>
{scoreColor.level} ({score})
</span>
</td>
<td className="py-2.5 px-4 text-slate-300 max-w-sm truncate" title={r.mitigationAction || r.mitigation || r.description || ''}>
{r.mitigationAction || r.mitigation || r.description || 'No mitigation recorded'}
</td>
</tr>
);
})}
</tbody>
</table>
</div>
)}
</div>
{/* Footer Actions */}
<div className="flex justify-end pt-3 border-t border-slate-800">
<button
type="button"
onClick={onClose}
className="px-4 py-2 rounded-xl text-xs font-semibold text-slate-300 hover:text-white bg-slate-800 hover:bg-slate-700 transition"
>
Close Audit Inspector
</button>
</div>
</div>
</Modal>
);
};
@@ -20,6 +20,7 @@ import {
getProjectIndicators, getProjectIndicators,
getProjectRisks, getProjectRisks,
getBudgets, getBudgets,
getDisbursements,
} from '../../services/api'; } from '../../services/api';
import { Project, ApprovalStatus, Budget } from '../../types'; import { Project, ApprovalStatus, Budget } from '../../types';
import { import {
@@ -86,38 +87,63 @@ export const ProjectExpandedRow: React.FC<ProjectExpandedRowProps> = ({
); );
const approvedBudgetTotal = approvedBudgets.reduce((sum, b) => sum + (Number(b.amount) || 0), 0); 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 verifiedMilestonesCount = milestones.filter((m) => m.status === ApprovalStatus.Approved).length;
const milestoneProgress = milestones.length > 0 ? Math.round((verifiedMilestonesCount / milestones.length) * 100) : 0; 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 totalActivityBudget = activities.reduce((sum, a) => sum + (Number(a.amount) || 0), 0);
const projectValue = totalActivityBudget - totalDisbursed;
const projectVariance = approvedBudgetTotal - projectValue;
return ( return (
<div className="p-5 sm:p-6 bg-[#090d16]/95 border-t border-b border-blue-500/20 space-y-6 animate-in fade-in duration-200"> <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">
{/* Project Meta Bar */} {/* Project Meta Bar */}
<div className="flex flex-col md:flex-row md:items-center justify-between gap-4 p-4 rounded-2xl bg-[#0d121f] border border-slate-800"> <div className="flex flex-col lg:flex-row lg:items-center justify-between gap-4 p-4 rounded-2xl bg-[#0d121f] border border-slate-800 min-w-0">
<div className="space-y-1 max-w-3xl"> <div className="space-y-1 min-w-0 flex-1">
<div className="flex items-center gap-2 flex-wrap"> <div className="flex items-center gap-2 flex-wrap">
<span className="text-[10px] font-mono uppercase tracking-wider text-blue-400 bg-blue-500/10 px-2.5 py-0.5 rounded-md border border-blue-500/30"> <span className="text-[10px] font-mono uppercase tracking-wider text-blue-400 bg-blue-500/10 px-2.5 py-0.5 rounded-md border border-blue-500/30 shrink-0">
Project #{project.id} Summary Project #{project.id} Summary
</span> </span>
<span className="text-xs text-slate-400"> <span className="text-xs text-slate-400 truncate">
Owner / Created by: <strong className="text-slate-200">{getUserEmail(project.createdBy)}</strong> Owner / Created by: <strong className="text-slate-200">{getUserEmail(project.createdBy)}</strong>
</span> </span>
{approvedBudgetTotal > 0 && ( {approvedBudgetTotal > 0 && (
<> <>
<span className="text-slate-600">&bull;</span> <span className="text-slate-600">&bull;</span>
<span className="text-xs text-emerald-400 font-mono font-semibold"> <span className="text-xs text-emerald-400 font-mono font-semibold shrink-0">
Approved Budget: {formatZAR(approvedBudgetTotal)} Approved Budget: {formatZAR(approvedBudgetTotal)}
</span> </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> </div>
<p className="text-xs text-slate-300 leading-relaxed">{project.description}</p> <p className="text-xs text-slate-300 leading-relaxed">{project.description}</p>
</div> </div>
<div className="flex items-center gap-2 shrink-0 self-start md:self-center flex-wrap"> <div className="flex items-center gap-2 shrink-0 self-start lg:self-center flex-wrap">
<Link <Link
to={`/monitoring?tab=evidence&projectId=${project.id}`} to={`/monitoring?tab=evidence&projectId=${project.id}`}
className="inline-flex items-center gap-1.5 px-3 py-1.5 text-xs font-semibold text-blue-300 hover:text-white bg-blue-500/10 hover:bg-blue-500/20 border border-blue-500/30 rounded-xl transition cursor-pointer" className="inline-flex items-center gap-1.5 px-3 py-1.5 text-xs font-semibold text-blue-300 hover:text-white bg-blue-500/10 hover:bg-blue-500/20 border border-blue-500/30 rounded-xl transition cursor-pointer shrink-0"
> >
<FileCheck className="w-3.5 h-3.5 text-blue-400" /> <FileCheck className="w-3.5 h-3.5 text-blue-400" />
Review Evidence Queue Review Evidence Queue
@@ -125,7 +151,7 @@ export const ProjectExpandedRow: React.FC<ProjectExpandedRowProps> = ({
<button <button
type="button" type="button"
onClick={onOpenEditProject} onClick={onOpenEditProject}
className="inline-flex items-center gap-1.5 px-3 py-1.5 text-xs font-semibold text-slate-300 hover:text-white bg-slate-800 hover:bg-slate-700 border border-slate-700 rounded-xl transition cursor-pointer" className="inline-flex items-center gap-1.5 px-3 py-1.5 text-xs font-semibold text-slate-300 hover:text-white bg-slate-800 hover:bg-slate-700 border border-slate-700 rounded-xl transition cursor-pointer shrink-0"
> >
<Edit2 className="w-3.5 h-3.5 text-slate-400" /> <Edit2 className="w-3.5 h-3.5 text-slate-400" />
Edit Project Scope Edit Project Scope
@@ -145,9 +171,9 @@ export const ProjectExpandedRow: React.FC<ProjectExpandedRowProps> = ({
/> />
{/* 4 Vital Resource Summary Cards Grid */} {/* 4 Vital Resource Summary Cards Grid */}
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-4 gap-4"> <div className="grid grid-cols-1 md:grid-cols-2 2xl:grid-cols-4 gap-4 min-w-0">
{/* Card 1: Milestones */} {/* Card 1: Milestones */}
<div className="bg-[#0d121f] rounded-2xl border border-slate-800 p-4 flex flex-col justify-between space-y-3"> <div className="bg-[#0d121f] rounded-2xl border border-slate-800 p-4 flex flex-col justify-between space-y-3 min-w-0">
<div className="space-y-2.5"> <div className="space-y-2.5">
<div className="flex items-center justify-between border-b border-slate-800/80 pb-2"> <div className="flex items-center justify-between border-b border-slate-800/80 pb-2">
<div className="flex items-center gap-2 text-xs font-bold text-white"> <div className="flex items-center gap-2 text-xs font-bold text-white">
@@ -173,9 +199,14 @@ export const ProjectExpandedRow: React.FC<ProjectExpandedRowProps> = ({
{sBadge.label} {sBadge.label}
</span> </span>
</div> </div>
<div className="text-[10px] text-slate-400 flex items-center gap-1 font-mono"> <div className="flex items-center justify-between text-[10px] text-slate-400 font-mono">
<Clock className="w-3 h-3 text-slate-500" /> <span className="flex items-center gap-1">
Due: {formatDate(m.dueAt)} <Clock className="w-3 h-3 text-slate-500" />
Due: {formatDate(m.dueAt)}
</span>
<span className="text-blue-400 font-bold">
{formatZAR(activities.filter(a => String(a.milestoneId) === String(m.id)).reduce((s, a) => s + (Number(a.amount) || 0), 0))}
</span>
</div> </div>
</div> </div>
); );
@@ -198,7 +229,7 @@ export const ProjectExpandedRow: React.FC<ProjectExpandedRowProps> = ({
</div> </div>
{/* Card 2: Activities */} {/* Card 2: Activities */}
<div className="bg-[#0d121f] rounded-2xl border border-slate-800 p-4 flex flex-col justify-between space-y-3"> <div className="bg-[#0d121f] rounded-2xl border border-slate-800 p-4 flex flex-col justify-between space-y-3 min-w-0">
<div className="space-y-2.5"> <div className="space-y-2.5">
<div className="flex items-center justify-between border-b border-slate-800/80 pb-2"> <div className="flex items-center justify-between border-b border-slate-800/80 pb-2">
<div className="flex items-center gap-2 text-xs font-bold text-white"> <div className="flex items-center gap-2 text-xs font-bold text-white">
@@ -244,7 +275,7 @@ export const ProjectExpandedRow: React.FC<ProjectExpandedRowProps> = ({
</div> </div>
{/* Card 3: Indicators */} {/* Card 3: Indicators */}
<div className="bg-[#0d121f] rounded-2xl border border-slate-800 p-4 flex flex-col justify-between space-y-3"> <div className="bg-[#0d121f] rounded-2xl border border-slate-800 p-4 flex flex-col justify-between space-y-3 min-w-0">
<div className="space-y-2.5"> <div className="space-y-2.5">
<div className="flex items-center justify-between border-b border-slate-800/80 pb-2"> <div className="flex items-center justify-between border-b border-slate-800/80 pb-2">
<div className="flex items-center gap-2 text-xs font-bold text-white"> <div className="flex items-center gap-2 text-xs font-bold text-white">
@@ -297,7 +328,7 @@ export const ProjectExpandedRow: React.FC<ProjectExpandedRowProps> = ({
</div> </div>
{/* Card 4: Risks */} {/* Card 4: Risks */}
<div className="bg-[#0d121f] rounded-2xl border border-slate-800 p-4 flex flex-col justify-between space-y-3"> <div className="bg-[#0d121f] rounded-2xl border border-slate-800 p-4 flex flex-col justify-between space-y-3 min-w-0">
<div className="space-y-2.5"> <div className="space-y-2.5">
<div className="flex items-center justify-between border-b border-slate-800/80 pb-2"> <div className="flex items-center justify-between border-b border-slate-800/80 pb-2">
<div className="flex items-center gap-2 text-xs font-bold text-white"> <div className="flex items-center gap-2 text-xs font-bold text-white">
@@ -47,7 +47,7 @@ export const ProjectFormModal: React.FC<ProjectFormModalProps> = ({
const { success, error: toastError } = useToast(); const { success, error: toastError } = useToast();
const form = useForm<ProjectFormValues>({ const form = useForm<ProjectFormValues>({
resolver: zodResolver(projectSchema) as any, resolver: zodResolver(projectSchema),
defaultValues: { defaultValues: {
name: '', name: '',
description: '', description: '',
@@ -125,7 +125,7 @@ export const ProjectFormModal: React.FC<ProjectFormModalProps> = ({
helpText="Register or modify a funded project. Ensure the correct parent programme and industrial sector are assigned to preserve statutory reporting compliance." helpText="Register or modify a funded project. Ensure the correct parent programme and industrial sector are assigned to preserve statutory reporting compliance."
maxWidth="lg" maxWidth="lg"
> >
<form onSubmit={form.handleSubmit((data) => mutation.mutate(data as any))} className="space-y-4"> <form onSubmit={form.handleSubmit((data) => mutation.mutate(data))} className="space-y-4">
<div> <div>
<div className="flex items-center gap-1 mb-1"> <div className="flex items-center gap-1 mb-1">
<label className="block text-xs font-semibold text-slate-300">Project Name *</label> <label className="block text-xs font-semibold text-slate-300">Project Name *</label>
@@ -1,4 +1,4 @@
import React, { useState } from 'react'; import React, { useState, useMemo } from 'react';
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query'; import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
import { useForm } from 'react-hook-form'; import { useForm } from 'react-hook-form';
import { zodResolver } from '@hookform/resolvers/zod'; import { zodResolver } from '@hookform/resolvers/zod';
@@ -9,8 +9,12 @@ import {
TrendingUp, TrendingUp,
X, X,
RotateCcw, RotateCcw,
Check, AlertTriangle,
Edit3, Briefcase,
Filter,
Info,
ShieldCheck,
Coins,
} from 'lucide-react'; } from 'lucide-react';
import { Modal } from '../ui/Modal'; import { Modal } from '../ui/Modal';
import { HelpTooltip } from '../ui/HelpTooltip'; import { HelpTooltip } from '../ui/HelpTooltip';
@@ -18,16 +22,18 @@ import { useToast } from '../ui/Toast';
import { import {
getProjectIndicators, getProjectIndicators,
createIndicator, createIndicator,
recordIndicatorActual, getActivities,
} from '../../services/api'; } from '../../services/api';
import { Project, Indicator, UnitOfMeasure } from '../../types'; import { Project, Indicator, UnitOfMeasure, Activity } from '../../types';
import { getUnitOfMeasureLabel, UNIT_OF_MEASURE_OPTIONS } from '../../lib/formatters'; import { getUnitOfMeasureLabel, UNIT_OF_MEASURE_OPTIONS, formatZAR } from '../../lib/formatters';
const indicatorSchema = z.object({ const indicatorSchema = z.object({
name: z.string().min(5, 'Indicator definition required (min 5 chars)'), name: z.string().min(4, 'Indicator delivery specification required (min 4 chars)'),
activityId: z.string().optional().nullable(),
unitOfMeasure: z.coerce.number().min(1), unitOfMeasure: z.coerce.number().min(1),
baselineAmount: z.coerce.number().min(0, 'Baseline must be >= 0'), baselineAmount: z.coerce.number().min(0, 'Baseline must be >= 0'),
targetAmount: z.coerce.number().min(1, 'Target must be > 0'), targetAmount: z.coerce.number().min(1, 'Target must be > 0'),
targetCost: z.coerce.number().min(0, 'Target cost must be >= 0').optional(),
}); });
type IndicatorFormValues = z.infer<typeof indicatorSchema>; type IndicatorFormValues = z.infer<typeof indicatorSchema>;
@@ -36,25 +42,26 @@ interface ProjectIndicatorsModalProps {
isOpen: boolean; isOpen: boolean;
onClose: () => void; onClose: () => void;
project: Project | null; project: Project | null;
initialActivityId?: string | number | null;
} }
export const ProjectIndicatorsModal: React.FC<ProjectIndicatorsModalProps> = ({ export const ProjectIndicatorsModal: React.FC<ProjectIndicatorsModalProps> = ({
isOpen, isOpen,
onClose, onClose,
project, project,
initialActivityId = null,
}) => { }) => {
const queryClient = useQueryClient(); const queryClient = useQueryClient();
const { success, error: toastError } = useToast(); const { success, error: toastError } = useToast();
const [isFormOpen, setIsFormOpen] = useState(false); const [isFormOpen, setIsFormOpen] = useState(false);
const [recordingActualForId, setRecordingActualForId] = useState<string | number | null>(null); const [selectedActivityFilter, setSelectedActivityFilter] = useState<string>('all');
const [actualInput, setActualInput] = useState<number>(0);
const projectId = project?.id; const projectId = project?.id;
const { const {
data: indicators = [], data: indicators = [],
isLoading, isLoading: isLoadingIndicators,
refetch, refetch,
} = useQuery({ } = useQuery({
queryKey: ['indicators', projectId], queryKey: ['indicators', projectId],
@@ -62,31 +69,49 @@ export const ProjectIndicatorsModal: React.FC<ProjectIndicatorsModalProps> = ({
enabled: isOpen && !!projectId, enabled: isOpen && !!projectId,
}); });
const {
data: activities = [],
isLoading: isLoadingActivities,
} = useQuery({
queryKey: ['activities', projectId],
queryFn: () => (projectId ? getActivities(projectId) : Promise.resolve([])),
enabled: isOpen && !!projectId,
});
const form = useForm<IndicatorFormValues>({ const form = useForm<IndicatorFormValues>({
resolver: zodResolver(indicatorSchema) as any, resolver: zodResolver(indicatorSchema),
defaultValues: { defaultValues: {
name: '', name: '',
activityId: initialActivityId ? String(initialActivityId) : '',
unitOfMeasure: UnitOfMeasure.Count, unitOfMeasure: UnitOfMeasure.Count,
baselineAmount: 0, baselineAmount: 0,
targetAmount: 100, targetAmount: 100,
targetCost: 0,
}, },
}); });
const createMutation = useMutation({ const createMutation = useMutation({
mutationFn: async (data: IndicatorFormValues) => { mutationFn: async (data: IndicatorFormValues) => {
if (!projectId) return; if (!projectId) return;
const parsedActivityId = data.activityId && String(data.activityId).trim() !== ''
? data.activityId
: null;
return createIndicator({ return createIndicator({
projectId: projectId, projectId: projectId,
activityId: parsedActivityId,
name: data.name, name: data.name,
unitOfMeasure: Number(data.unitOfMeasure), unitOfMeasure: Number(data.unitOfMeasure),
baselineAmount: Number(data.baselineAmount), baselineAmount: Number(data.baselineAmount),
targetAmount: Number(data.targetAmount), targetAmount: Number(data.targetAmount),
targetCost: Number(data.targetCost) || 0,
}); });
}, },
onSuccess: () => { onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['indicators', projectId] }); queryClient.invalidateQueries({ queryKey: ['indicators', projectId] });
queryClient.invalidateQueries({ queryKey: ['indicators'] }); queryClient.invalidateQueries({ queryKey: ['indicators'] });
success('Indicator Created', 'M&E Key Performance Indicator added to project'); queryClient.invalidateQueries({ queryKey: ['activities', projectId] });
success('Delivery Indicator Created', 'Indicator specification linked to work activity');
setIsFormOpen(false); setIsFormOpen(false);
form.reset(); form.reset();
}, },
@@ -95,20 +120,22 @@ export const ProjectIndicatorsModal: React.FC<ProjectIndicatorsModalProps> = ({
}, },
}); });
const actualMutation = useMutation({ // Filter indicators by activity
mutationFn: async ({ indicatorId, actualAmount }: { indicatorId: string | number; actualAmount: number }) => { const filteredIndicators = useMemo(() => {
return recordIndicatorActual(indicatorId, actualAmount); if (selectedActivityFilter === 'all') return indicators;
}, if (selectedActivityFilter === 'unlinked') return indicators.filter((i) => !i.activityId);
onSuccess: () => { return indicators.filter((i) => String(i.activityId) === String(selectedActivityFilter));
queryClient.invalidateQueries({ queryKey: ['indicators', projectId] }); }, [indicators, selectedActivityFilter]);
queryClient.invalidateQueries({ queryKey: ['indicators'] });
success('Actual Value Logged', 'M&E progress measurement updated successfully'); // Aggregate totals
setRecordingActualForId(null); const totalTargetCost = useMemo(
}, () => indicators.reduce((sum, i) => sum + (Number(i.targetCost) || 0), 0),
onError: (err: unknown) => { [indicators]
toastError('Failed to record actual value', err); );
}, const totalActualCost = useMemo(
}); () => indicators.reduce((sum, i) => sum + (Number(i.actualCost) || 0), 0),
[indicators]
);
if (!project) return null; if (!project) return null;
@@ -117,34 +144,86 @@ export const ProjectIndicatorsModal: React.FC<ProjectIndicatorsModalProps> = ({
isOpen={isOpen} isOpen={isOpen}
onClose={() => { onClose={() => {
setIsFormOpen(false); setIsFormOpen(false);
setRecordingActualForId(null);
onClose(); onClose();
}} }}
title={`M&E Performance Indicators: ${project.name}`} title={`M&E Delivery Indicators: ${project.name}`}
subtitle={`Project #${project.id} • Results & Impact Framework`} subtitle={`Project #${project.id} • Activity Delivery Specifications & Evidence Requirements`}
helpText="Quantitative indicators monitor baseline vs. target deliverables (e.g. jobs created, patents filed, rural SMMEs trained, renewable MW generated)." helpText="Each indicator specifies an expected delivery output linked to an operational activity. Quantities and material costs are captured as actuals by the partner in the Partner Portal."
maxWidth="xl" maxWidth="xl"
> >
<div className="space-y-5"> <div className="space-y-5">
{/* KPI Summary Card */} {/* KPI Summary Cards */}
<div className="bg-[#0b101c] p-4 rounded-xl border border-slate-800/90 flex flex-col sm:flex-row sm:items-center justify-between gap-4"> <div className="grid grid-cols-1 sm:grid-cols-3 gap-3">
<div className="space-y-1"> <div className="bg-[#0b101c] p-3.5 rounded-xl border border-slate-800">
<span className="text-[10px] uppercase font-bold text-slate-400 flex items-center gap-1.5"> <span className="text-[10px] uppercase font-bold text-slate-400 flex items-center gap-1.5">
<Target className="w-3.5 h-3.5 text-indigo-400" /> <Target className="w-3.5 h-3.5 text-indigo-400" />
Monitoring & Evaluation Targets Delivery Indicators
</span> </span>
<div className="flex items-baseline gap-2"> <div className="flex items-baseline gap-2 mt-1">
<span className="text-2xl font-bold text-white">{indicators.length}</span> <span className="text-xl font-bold text-white font-mono">{indicators.length}</span>
<span className="text-xs text-slate-400">Tracked performance metrics</span> <span className="text-[11px] text-slate-400">specifications</span>
</div> </div>
</div> </div>
<div className="bg-[#0b101c] p-3.5 rounded-xl border border-slate-800">
<span className="text-[10px] uppercase font-bold text-slate-400 flex items-center gap-1.5">
<Coins className="w-3.5 h-3.5 text-cyan-400" />
Target Delivery Cost
</span>
<div className="flex items-baseline gap-2 mt-1">
<span className="text-xl font-bold font-mono text-cyan-400">{formatZAR(totalTargetCost)}</span>
</div>
</div>
<div className="bg-[#0b101c] p-3.5 rounded-xl border border-slate-800">
<span className="text-[10px] uppercase font-bold text-slate-400 flex items-center gap-1.5">
<TrendingUp className="w-3.5 h-3.5 text-emerald-400" />
Partner Actuals Incurred
</span>
<div className="flex items-baseline gap-2 mt-1">
<span className={`text-xl font-bold font-mono ${totalActualCost > totalTargetCost && totalTargetCost > 0 ? 'text-amber-400' : 'text-emerald-400'}`}>
{formatZAR(totalActualCost)}
</span>
</div>
</div>
</div>
{/* Action Header & Activity Filter Bar */}
<div className="flex flex-col sm:flex-row sm:items-center justify-between gap-3 bg-[#0d121f] p-3 rounded-xl border border-slate-800">
<div className="flex items-center gap-2 flex-1">
<Filter className="w-3.5 h-3.5 text-slate-400 shrink-0" />
<span className="text-xs text-slate-400 shrink-0">Filter by Activity:</span>
<select
value={selectedActivityFilter}
onChange={(e) => setSelectedActivityFilter(e.target.value)}
className="px-2.5 py-1 text-xs border border-slate-700/80 rounded-lg focus:outline-none focus:ring-1 focus:ring-indigo-500 bg-[#090d16] text-white max-w-xs truncate"
>
<option value="all">All Activities ({indicators.length})</option>
{activities.map((act) => {
const count = indicators.filter((i) => String(i.activityId) === String(act.id)).length;
return (
<option key={act.id} value={String(act.id)}>
{act.title} ({count} indicators)
</option>
);
})}
<option value="unlinked">Unlinked General Indicators</option>
</select>
</div>
<div className="flex items-center gap-2"> <div className="flex items-center gap-2">
{!isFormOpen && ( {!isFormOpen && (
<button <button
type="button" type="button"
onClick={() => { onClick={() => {
form.reset(); form.reset({
name: '',
activityId: selectedActivityFilter !== 'all' && selectedActivityFilter !== 'unlinked' ? selectedActivityFilter : '',
unitOfMeasure: UnitOfMeasure.Count,
baselineAmount: 0,
targetAmount: 100,
targetCost: 0,
});
setIsFormOpen(true); setIsFormOpen(true);
}} }}
className="inline-flex items-center gap-1.5 px-3.5 py-2 text-xs font-semibold text-white bg-gradient-to-r from-indigo-600 to-blue-600 hover:from-indigo-500 hover:to-blue-500 rounded-xl shadow-md shadow-indigo-600/20 transition cursor-pointer" className="inline-flex items-center gap-1.5 px-3.5 py-2 text-xs font-semibold text-white bg-gradient-to-r from-indigo-600 to-blue-600 hover:from-indigo-500 hover:to-blue-500 rounded-xl shadow-md shadow-indigo-600/20 transition cursor-pointer"
@@ -156,13 +235,13 @@ export const ProjectIndicatorsModal: React.FC<ProjectIndicatorsModalProps> = ({
</div> </div>
</div> </div>
{/* Create Indicator Form */} {/* Indicator Create Form */}
{isFormOpen && ( {isFormOpen && (
<div className="bg-[#0d121f] p-4 rounded-xl border border-indigo-500/30 space-y-4 animate-in fade-in"> <div className="bg-[#0d121f] p-4 rounded-xl border border-indigo-500/30 space-y-4 animate-in fade-in">
<div className="flex items-center justify-between border-b border-slate-800 pb-2.5"> <div className="flex items-center justify-between border-b border-slate-800 pb-2.5">
<span className="text-xs font-bold text-indigo-400 flex items-center gap-1.5"> <span className="text-xs font-bold text-indigo-400 flex items-center gap-1.5">
<Target className="w-3.5 h-3.5" /> <Target className="w-3.5 h-3.5" />
Define New Key Performance Indicator (KPI) Define Activity Delivery Indicator & Evidence Expectation
</span> </span>
<button <button
type="button" type="button"
@@ -173,21 +252,43 @@ export const ProjectIndicatorsModal: React.FC<ProjectIndicatorsModalProps> = ({
</button> </button>
</div> </div>
<form onSubmit={form.handleSubmit((data) => createMutation.mutate(data as any))} className="space-y-3"> <form onSubmit={form.handleSubmit((data) => createMutation.mutate(data))} className="space-y-3">
<div> <div>
<label className="block text-xs font-semibold text-slate-300 mb-1">Indicator Definition *</label> <label className="block text-xs font-semibold text-slate-300 mb-1">
Indicator Name / Delivery Output Specification *
</label>
<input <input
type="text" type="text"
{...form.register('name')} {...form.register('name')}
className="w-full px-3 py-2 text-xs border border-slate-700/80 rounded-xl focus:outline-none focus:ring-1 focus:ring-indigo-500 text-white bg-[#090d16]" className="w-full px-3 py-2 text-xs border border-slate-700/80 rounded-xl focus:outline-none focus:ring-1 focus:ring-indigo-500 text-white bg-[#090d16]"
placeholder="e.g. Number of Smallholder Farmers Trained in Precision Irrigation" placeholder="e.g. Number of solar microgrids commissioned and tested"
/> />
{form.formState.errors.name && ( {form.formState.errors.name && (
<p className="text-[10px] text-rose-500 mt-1">{form.formState.errors.name.message}</p> <p className="text-[10px] text-rose-500 mt-1">{form.formState.errors.name.message}</p>
)} )}
</div> </div>
<div className="grid grid-cols-1 sm:grid-cols-3 gap-3"> <div>
<label className="block text-xs font-semibold text-slate-300 mb-1">
Parent Activity (Required Delivery Link) *
</label>
<select
{...form.register('activityId')}
className="w-full px-3 py-2 text-xs border border-slate-700/80 rounded-xl focus:outline-none focus:ring-1 focus:ring-indigo-500 bg-[#090d16] text-white font-medium"
>
<option value="">-- Select Parent Activity --</option>
{activities.map((act) => (
<option key={act.id} value={String(act.id)}>
{act.title} (Budget: {formatZAR(act.amount)})
</option>
))}
</select>
<p className="text-[10px] text-slate-500 mt-1">
Linking to an activity establishes the causal delivery chain from planned task to substantiated output.
</p>
</div>
<div className="grid grid-cols-1 sm:grid-cols-2 gap-3">
<div> <div>
<label className="block text-xs font-semibold text-slate-300 mb-1">Unit of Measure *</label> <label className="block text-xs font-semibold text-slate-300 mb-1">Unit of Measure *</label>
<select <select
@@ -203,32 +304,47 @@ export const ProjectIndicatorsModal: React.FC<ProjectIndicatorsModalProps> = ({
</div> </div>
<div> <div>
<label className="block text-xs font-semibold text-slate-300 mb-1">Baseline Amount *</label> <label className="block text-xs font-semibold text-slate-300 mb-1">
Expected Delivery Cost (Target Cost ZAR)
</label>
<input <input
type="number" type="number"
step="0.01"
{...form.register('targetCost')}
className="w-full px-3 py-2 text-xs border border-slate-700/80 rounded-xl focus:outline-none focus:ring-1 focus:ring-indigo-500 text-white font-mono bg-[#090d16]"
placeholder="0.00"
/>
<p className="text-[10px] text-slate-500 mt-1">Estimated monetary cost for delivering this indicator.</p>
</div>
</div>
<div className="grid grid-cols-1 sm:grid-cols-2 gap-3">
<div>
<label className="block text-xs font-semibold text-slate-300 mb-1">Baseline Metric *</label>
<input
type="number"
step="any"
{...form.register('baselineAmount')} {...form.register('baselineAmount')}
className="w-full px-3 py-2 text-xs border border-slate-700/80 rounded-xl focus:outline-none focus:ring-1 focus:ring-indigo-500 text-white font-mono bg-[#090d16]" className="w-full px-3 py-2 text-xs border border-slate-700/80 rounded-xl focus:outline-none focus:ring-1 focus:ring-indigo-500 text-white font-mono bg-[#090d16]"
/> />
</div> </div>
<div> <div>
<label className="block text-xs font-semibold text-slate-300 mb-1">Target Amount *</label> <label className="block text-xs font-semibold text-slate-300 mb-1">Target Metric *</label>
<input <input
type="number" type="number"
step="any"
{...form.register('targetAmount')} {...form.register('targetAmount')}
className="w-full px-3 py-2 text-xs border border-slate-700/80 rounded-xl focus:outline-none focus:ring-1 focus:ring-indigo-500 text-white font-mono bg-[#090d16]" className="w-full px-3 py-2 text-xs border border-slate-700/80 rounded-xl focus:outline-none focus:ring-1 focus:ring-indigo-500 text-white font-mono bg-[#090d16]"
/> />
{form.formState.errors.targetAmount && (
<p className="text-[10px] text-rose-500 mt-1">{form.formState.errors.targetAmount.message}</p>
)}
</div> </div>
</div> </div>
<div className="pt-2 flex justify-end gap-2 border-t border-slate-800"> <div className="flex items-center justify-end gap-2 pt-2 border-t border-slate-800">
<button <button
type="button" type="button"
onClick={() => setIsFormOpen(false)} onClick={() => setIsFormOpen(false)}
className="px-3.5 py-1.5 text-xs font-semibold text-slate-300 bg-slate-800 hover:bg-slate-700 rounded-xl cursor-pointer" className="px-3 py-1.5 text-xs font-semibold text-slate-300 bg-slate-800 hover:bg-slate-700 rounded-xl cursor-pointer"
> >
Cancel Cancel
</button> </button>
@@ -237,17 +353,27 @@ export const ProjectIndicatorsModal: React.FC<ProjectIndicatorsModalProps> = ({
disabled={createMutation.isPending} disabled={createMutation.isPending}
className="px-4 py-1.5 text-xs font-semibold text-white bg-gradient-to-r from-indigo-600 to-blue-600 hover:from-indigo-500 hover:to-blue-500 rounded-xl shadow-md shadow-indigo-500/20 cursor-pointer" className="px-4 py-1.5 text-xs font-semibold text-white bg-gradient-to-r from-indigo-600 to-blue-600 hover:from-indigo-500 hover:to-blue-500 rounded-xl shadow-md shadow-indigo-500/20 cursor-pointer"
> >
{createMutation.isPending ? 'Saving...' : 'Add Indicator'} {createMutation.isPending ? 'Saving...' : 'Define Indicator'}
</button> </button>
</div> </div>
</form> </form>
</div> </div>
)} )}
{/* Notice Card Explaining Partner Action */}
<div className="bg-slate-900/60 p-3 rounded-xl border border-slate-800 flex items-start gap-2.5 text-slate-400 text-[11px] leading-relaxed">
<Info className="w-4 h-4 text-cyan-400 shrink-0 mt-0.5" />
<div>
<strong className="text-slate-200">Maker-Checker Operational Notice:</strong> ConsoleUI enables Project Managers to establish output specifications and expected delivery costs. Actual item quantities, material expenditures, and verification evidence documents are recorded by the Grantee/Partner via the <strong className="text-cyan-400 font-mono">PartnerUI Portal</strong>.
</div>
</div>
{/* Indicators List */} {/* Indicators List */}
<div className="space-y-2.5"> <div className="space-y-2.5">
<div className="flex items-center justify-between text-xs font-semibold text-slate-400 px-1"> <div className="flex items-center justify-between text-xs font-semibold text-slate-400 px-1">
<span>Performance Target Registry ({indicators.length})</span> <span>
Defined Delivery Specifications ({filteredIndicators.length} of {indicators.length})
</span>
<button <button
type="button" type="button"
onClick={() => refetch()} onClick={() => refetch()}
@@ -257,49 +383,77 @@ export const ProjectIndicatorsModal: React.FC<ProjectIndicatorsModalProps> = ({
</button> </button>
</div> </div>
{isLoading ? ( {isLoadingIndicators || isLoadingActivities ? (
<div className="p-8 text-center text-xs text-slate-400 bg-[#0b101c] rounded-xl border border-slate-800"> <div className="p-8 text-center text-xs text-slate-400 bg-[#0b101c] rounded-xl border border-slate-800">
Loading indicators... Loading indicators...
</div> </div>
) : indicators.length === 0 ? ( ) : filteredIndicators.length === 0 ? (
<div className="p-8 text-center bg-[#0b101c] rounded-xl border border-dashed border-slate-800 text-slate-400 space-y-2"> <div className="p-8 text-center bg-[#0b101c] rounded-xl border border-dashed border-slate-800 text-slate-400 space-y-2">
<Target className="w-8 h-8 text-slate-600 mx-auto" /> <Target className="w-8 h-8 text-slate-600 mx-auto" />
<p className="text-xs font-semibold text-slate-300">No M&E indicators defined yet</p> <p className="text-xs font-semibold text-slate-300">No indicators matching selection</p>
<p className="text-[11px] text-slate-500">Track quantitative impact metrics against established baselines.</p> <p className="text-[11px] text-slate-500">
Define delivery output specifications and link them to operational activities.
</p>
<button <button
type="button" type="button"
onClick={() => setIsFormOpen(true)} onClick={() => setIsFormOpen(true)}
className="mt-2 inline-flex items-center gap-1 text-xs font-semibold text-indigo-400 hover:underline" className="mt-2 inline-flex items-center gap-1 text-xs font-semibold text-indigo-400 hover:underline"
> >
<Plus className="w-3.5 h-3.5" /> Add first indicator <Plus className="w-3.5 h-3.5" /> Define first indicator
</button> </button>
</div> </div>
) : ( ) : (
<div className="space-y-2.5 max-h-[380px] overflow-y-auto pr-1"> <div className="space-y-3 max-h-[420px] overflow-y-auto pr-1">
{indicators.map((ind) => { {filteredIndicators.map((ind) => {
const target = Number(ind.targetAmount) || 1; const target = Number(ind.targetAmount) || 1;
const actual = Number(ind.actualAmount) || 0; const actual = Number(ind.actualAmount) || 0;
const baseline = Number(ind.baselineAmount) || 0; const baseline = Number(ind.baselineAmount) || 0;
const targetCost = Number(ind.targetCost) || 0;
const actualCost = Number(ind.actualCost) || 0;
const pct = Math.round((actual / target) * 100); const pct = Math.round((actual / target) * 100);
const isRecordingThis = recordingActualForId === ind.id;
const parentActivity = activities.find((a) => String(a.id) === String(ind.activityId));
const isOverrun = (actualCost > targetCost && targetCost > 0) || (parentActivity && actualCost > parentActivity.amount);
return ( return (
<div <div
key={ind.id} key={ind.id}
className="p-4 rounded-xl border border-slate-800 bg-[#0d121f] hover:border-slate-700 transition space-y-3" className={`p-4 rounded-xl border bg-[#0d121f] transition space-y-3 ${
isOverrun ? 'border-amber-500/40 bg-amber-950/10' : 'border-slate-800 hover:border-slate-700'
}`}
> >
<div className="flex flex-col sm:flex-row sm:items-center justify-between gap-2"> <div className="flex flex-col sm:flex-row sm:items-start justify-between gap-2">
<div> <div className="space-y-1">
<div className="flex items-center gap-2 flex-wrap"> <div className="flex items-center gap-2 flex-wrap">
<span className="text-xs font-bold text-white">{ind.name}</span> <span className="text-xs font-bold text-white">{ind.name}</span>
<span className="bg-slate-900 border border-slate-700 text-slate-300 px-2 py-0.5 rounded-md text-[10px]"> <span className="bg-slate-900 border border-slate-700 text-slate-300 px-2 py-0.5 rounded-md text-[10px]">
{getUnitOfMeasureLabel(ind.unitOfMeasure)} {getUnitOfMeasureLabel(ind.unitOfMeasure)}
</span> </span>
{parentActivity ? (
<span className="bg-cyan-500/10 border border-cyan-500/30 text-cyan-300 px-2 py-0.5 rounded-md text-[10px] flex items-center gap-1">
<Briefcase className="w-2.5 h-2.5" />
{parentActivity.title}
</span>
) : (
<span className="bg-slate-800 text-slate-400 px-2 py-0.5 rounded-md text-[10px]">
General Project Indicator
</span>
)}
</div>
<div className="text-[10px] text-slate-500 font-mono">
Indicator ID #{ind.id}
{ind.activityId && ` • Activity #${ind.activityId}`}
</div> </div>
<span className="text-[10px] text-slate-500 font-mono">Indicator ID #{ind.id}</span>
</div> </div>
<div className="flex items-center gap-2"> <div className="flex items-center gap-2 shrink-0">
{isOverrun && (
<span className="inline-flex items-center gap-1 px-2 py-0.5 rounded-md bg-rose-500/10 text-rose-400 border border-rose-500/30 text-[10px] font-bold">
<AlertTriangle className="w-3 h-3 text-rose-400" />
Cost Overrun
</span>
)}
<span <span
className={`text-xs font-bold px-2 py-0.5 rounded-md border ${ className={`text-xs font-bold px-2 py-0.5 rounded-md border ${
pct >= 100 pct >= 100
@@ -309,34 +463,20 @@ export const ProjectIndicatorsModal: React.FC<ProjectIndicatorsModalProps> = ({
: 'bg-amber-500/10 text-amber-400 border-amber-500/30' : 'bg-amber-500/10 text-amber-400 border-amber-500/30'
}`} }`}
> >
{pct}% Achieved {pct}% Delivered
</span> </span>
{!isRecordingThis ? ( <span
<button className="inline-flex items-center gap-1 text-[10px] text-slate-400 bg-slate-900/90 border border-slate-700/80 px-2 py-0.5 rounded-md"
type="button" title="Actuals are recorded by the partner in PartnerUI"
onClick={() => { >
setRecordingActualForId(ind.id); <ShieldCheck className="w-3 h-3 text-cyan-400" />
setActualInput(actual); Partner Verified
}} </span>
className="inline-flex items-center gap-1 text-[11px] font-semibold text-indigo-300 hover:text-white bg-indigo-500/10 hover:bg-indigo-500/20 border border-indigo-500/25 px-2.5 py-1 rounded-lg transition cursor-pointer"
>
<Edit3 className="w-3 h-3 text-indigo-400" />
Record Actual
</button>
) : (
<button
type="button"
onClick={() => setRecordingActualForId(null)}
className="text-slate-400 hover:text-slate-200 text-xs p-1"
>
<X className="w-4 h-4" />
</button>
)}
</div> </div>
</div> </div>
{/* Progress Bar */} {/* Deliverable Units Progress Bar */}
<div className="space-y-1"> <div className="space-y-1">
<div className="w-full h-2 bg-slate-900 rounded-full overflow-hidden border border-slate-800"> <div className="w-full h-2 bg-slate-900 rounded-full overflow-hidden border border-slate-800">
<div <div
@@ -351,39 +491,44 @@ export const ProjectIndicatorsModal: React.FC<ProjectIndicatorsModalProps> = ({
/> />
</div> </div>
<div className="flex justify-between text-[11px] text-slate-400"> <div className="flex justify-between text-[11px] text-slate-400">
<span>Baseline: <strong className="text-slate-300 font-mono">{baseline}</strong></span> <span>
<span>Actual: <strong className="text-white font-mono font-bold">{actual}</strong></span> Baseline: <strong className="text-slate-300 font-mono">{baseline}</strong>
<span>Target: <strong className="text-indigo-400 font-mono">{target}</strong></span> </span>
<span>
Partner Actuals Reported:{' '}
<strong className="text-white font-mono font-bold">{actual}</strong>
</span>
<span>
Target: <strong className="text-indigo-400 font-mono">{target}</strong>
</span>
</div> </div>
</div> </div>
{/* Inline Record Actual Form */} {/* Financial Cost Specification & Actuals Row */}
{isRecordingThis && ( <div className="pt-2 border-t border-slate-800/80 flex flex-col sm:flex-row sm:items-center justify-between gap-2 text-xs">
<div className="pt-2 border-t border-slate-800 flex items-center justify-between gap-3 animate-in fade-in"> <div className="flex items-center gap-4 text-slate-400 text-[11px]">
<span className="text-[11px] font-semibold text-slate-300">Update Actual Value:</span> <span>
<div className="flex items-center gap-2"> Target Cost: <strong className="text-cyan-400 font-mono">{formatZAR(targetCost)}</strong>
<input </span>
type="number" <span>
value={actualInput} Actual Material Cost:{' '}
onChange={(e) => setActualInput(Number(e.target.value))} <strong className={`font-mono font-bold ${actualCost > targetCost && targetCost > 0 ? 'text-amber-400' : 'text-white'}`}>
className="w-24 px-2 py-1 text-xs border border-slate-700 rounded-lg text-white font-mono bg-[#090d16] focus:outline-none focus:ring-1 focus:ring-indigo-500" {formatZAR(actualCost)}
/> </strong>
<button </span>
type="button" {targetCost > 0 && (
disabled={actualMutation.isPending} <span className="text-slate-500">
onClick={() => Variance: {formatZAR(targetCost - actualCost)}
actualMutation.mutate({ </span>
indicatorId: ind.id, )}
actualAmount: actualInput,
})
}
className="px-3 py-1 text-xs font-semibold text-white bg-indigo-600 hover:bg-indigo-500 rounded-lg shadow-sm transition cursor-pointer"
>
{actualMutation.isPending ? 'Saving...' : 'Save'}
</button>
</div>
</div> </div>
)}
{isOverrun && (
<div className="text-[10px] text-amber-300 bg-amber-500/10 px-2 py-0.5 rounded border border-amber-500/20">
Cost exceeds delivery allocation; subject to project variation approval (M15).
</div>
)}
</div>
</div> </div>
); );
})} })}
@@ -1,4 +1,4 @@
import React, { useState } from 'react'; import React, { useState, useMemo } from 'react';
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query'; import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
import { useForm } from 'react-hook-form'; import { useForm } from 'react-hook-form';
import { zodResolver } from '@hookform/resolvers/zod'; import { zodResolver } from '@hookform/resolvers/zod';
@@ -15,6 +15,11 @@ import {
TrendingUp, TrendingUp,
X, X,
RotateCcw, RotateCcw,
Lock,
Coins,
Briefcase,
Target,
AlertTriangle,
} from 'lucide-react'; } from 'lucide-react';
import { Modal } from '../ui/Modal'; import { Modal } from '../ui/Modal';
import { HelpTooltip } from '../ui/HelpTooltip'; import { HelpTooltip } from '../ui/HelpTooltip';
@@ -23,10 +28,15 @@ import {
getMilestones, getMilestones,
createMilestone, createMilestone,
updateMilestone, updateMilestone,
getActivities,
getProjectIndicators,
getBudgets,
} from '../../services/api'; } from '../../services/api';
import { Project, Milestone, Priority, ApprovalStatus } from '../../types'; import { Project, Milestone, Priority, ApprovalStatus, Activity, Indicator } from '../../types';
import { useRbac } from '../../auth/rbac';
import { import {
formatDate, formatDate,
formatZAR,
getApprovalStatusBadge, getApprovalStatusBadge,
getPriorityBadge, getPriorityBadge,
PRIORITY_OPTIONS, PRIORITY_OPTIONS,
@@ -55,6 +65,7 @@ export const ProjectMilestonesModal: React.FC<ProjectMilestonesModalProps> = ({
}) => { }) => {
const queryClient = useQueryClient(); const queryClient = useQueryClient();
const { success, error: toastError } = useToast(); const { success, error: toastError } = useToast();
const { canAdd, canApprove, canApproveRecord, isMaker } = useRbac();
const [isFormOpen, setIsFormOpen] = useState(false); const [isFormOpen, setIsFormOpen] = useState(false);
const [editingMilestone, setEditingMilestone] = useState<Milestone | null>(null); const [editingMilestone, setEditingMilestone] = useState<Milestone | null>(null);
@@ -63,7 +74,7 @@ export const ProjectMilestonesModal: React.FC<ProjectMilestonesModalProps> = ({
const { const {
data: milestones = [], data: milestones = [],
isLoading, isLoading: isLoadingMilestones,
refetch, refetch,
} = useQuery({ } = useQuery({
queryKey: ['milestones', projectId], queryKey: ['milestones', projectId],
@@ -71,8 +82,56 @@ export const ProjectMilestonesModal: React.FC<ProjectMilestonesModalProps> = ({
enabled: isOpen && !!projectId, enabled: isOpen && !!projectId,
}); });
const {
data: activities = [],
isLoading: isLoadingActivities,
} = useQuery({
queryKey: ['activities', projectId],
queryFn: () => (projectId ? getActivities(projectId) : Promise.resolve([])),
enabled: isOpen && !!projectId,
});
const {
data: indicators = [],
isLoading: isLoadingIndicators,
} = useQuery({
queryKey: ['indicators', projectId],
queryFn: () => (projectId ? getProjectIndicators(projectId) : Promise.resolve([])),
enabled: isOpen && !!projectId,
});
const { data: allBudgets = [] } = useQuery({
queryKey: ['budgets'],
queryFn: () => getBudgets(1, 100),
enabled: isOpen,
});
// Calculate project approved budget
const approvedBudgetTotal = useMemo(() => {
const projectBudgets = allBudgets.filter((b) => String(b.projectId) === String(projectId));
return projectBudgets
.filter((b) => b.status === undefined || b.status === null || Number(b.status) === 3)
.reduce((sum, b) => sum + (Number(b.amount) || 0), 0);
}, [allBudgets, projectId]);
// Total project value is the sum total from all milestones (sum of their activities)
const totalProjectMilestonesValue = useMemo(() => {
return milestones.reduce((sum, m) => {
const mActs = activities.filter((a) => String(a.milestoneId) === String(m.id));
return sum + mActs.reduce((s, a) => s + (Number(a.amount) || 0), 0);
}, 0);
}, [milestones, activities]);
// Total project actuals reported by partner from indicators
const totalProjectActuals = useMemo(() => {
return indicators.reduce((sum, i) => sum + (Number(i.actualCost) || 0), 0);
}, [indicators]);
const verifiedCount = milestones.filter((m) => m.status === ApprovalStatus.Approved).length;
const progressPct = milestones.length > 0 ? Math.round((verifiedCount / milestones.length) * 100) : 0;
const form = useForm<MilestoneFormValues>({ const form = useForm<MilestoneFormValues>({
resolver: zodResolver(milestoneSchema) as any, resolver: zodResolver(milestoneSchema),
defaultValues: { defaultValues: {
name: '', name: '',
dueAt: new Date(Date.now() + 60 * 24 * 60 * 60 * 1000).toISOString().slice(0, 10), dueAt: new Date(Date.now() + 60 * 24 * 60 * 60 * 1000).toISOString().slice(0, 10),
@@ -144,9 +203,6 @@ export const ProjectMilestonesModal: React.FC<ProjectMilestonesModalProps> = ({
if (!project) return null; if (!project) return null;
const verifiedCount = milestones.filter((m) => m.status === ApprovalStatus.Approved).length;
const progressPct = milestones.length > 0 ? Math.round((verifiedCount / milestones.length) * 100) : 0;
return ( return (
<Modal <Modal
isOpen={isOpen} isOpen={isOpen}
@@ -157,25 +213,92 @@ export const ProjectMilestonesModal: React.FC<ProjectMilestonesModalProps> = ({
}} }}
title={`Milestones & Deliverables: ${project.name}`} title={`Milestones & Deliverables: ${project.name}`}
subtitle={`Project #${project.id} • Statutory Work Breakdown Schedule`} subtitle={`Project #${project.id} • Statutory Work Breakdown Schedule`}
helpText="Contractual milestones represent formal deliverables specified in the grant contract. Upon verification and approval, they increment the calculated project progress score and unblock disbursement releases." helpText="Contractual milestones represent formal deliverables. The monetary value of each milestone is dynamically aggregated from its scheduled activities vs indicator actuals. The project's total value is the sum of all milestones."
maxWidth="xl" maxWidth="4xl"
> >
<div className="space-y-5"> <div className="space-y-5">
{/* Progress & Quick Stats Card */} {/* KPI Cards: Project Value Roll-Up & Performance */}
<div className="bg-[#0b101c] p-4 rounded-xl border border-slate-800/90 flex flex-col sm:flex-row sm:items-center justify-between gap-4"> <div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-4 gap-3.5">
<div className="space-y-1"> <div className="bg-[#0b101c] p-4 rounded-xl border border-slate-800 hover:border-slate-700 transition flex flex-col justify-between min-w-0 shadow-sm">
<span className="text-[10px] uppercase font-bold text-slate-400 flex items-center gap-1.5"> <div className="flex items-center justify-between gap-1 mb-1">
<TrendingUp className="w-3.5 h-3.5 text-blue-400" /> <span className="text-[10px] uppercase font-bold text-slate-400 flex items-center gap-1.5 truncate">
Calculated Completion Score <Coins className="w-3.5 h-3.5 text-blue-400 shrink-0" />
</span> Total Project Value
<div className="flex items-baseline gap-2"> </span>
<span className="text-2xl font-bold text-white">{progressPct}%</span>
<span className="text-xs text-slate-400">({verifiedCount} of {milestones.length} deliverables verified)</span>
</div> </div>
<div className="my-1.5">
<div
className="text-lg sm:text-xl font-bold font-mono text-blue-400 truncate tracking-tight"
title={formatZAR(totalProjectMilestonesValue)}
>
{formatZAR(totalProjectMilestonesValue)}
</div>
</div>
<span className="text-[10px] text-slate-500 truncate">Sum of all milestones</span>
</div> </div>
<div className="bg-[#0b101c] p-4 rounded-xl border border-slate-800 hover:border-slate-700 transition flex flex-col justify-between min-w-0 shadow-sm">
<div className="flex items-center justify-between gap-1 mb-1">
<span className="text-[10px] uppercase font-bold text-slate-400 flex items-center gap-1.5 truncate">
<Coins className="w-3.5 h-3.5 text-emerald-400 shrink-0" />
Approved Budget
</span>
</div>
<div className="my-1.5">
<div
className="text-lg sm:text-xl font-bold font-mono text-emerald-400 truncate tracking-tight"
title={formatZAR(approvedBudgetTotal)}
>
{formatZAR(approvedBudgetTotal)}
</div>
</div>
<span className="text-[10px] text-slate-500 truncate">Allocated budget ceiling</span>
</div>
<div className="bg-[#0b101c] p-4 rounded-xl border border-slate-800 hover:border-slate-700 transition flex flex-col justify-between min-w-0 shadow-sm">
<div className="flex items-center justify-between gap-1 mb-1">
<span className="text-[10px] uppercase font-bold text-slate-400 flex items-center gap-1.5 truncate">
<TrendingUp className="w-3.5 h-3.5 text-cyan-400 shrink-0" />
Partner Actuals
</span>
</div>
<div className="my-1.5">
<div
className="text-lg sm:text-xl font-bold font-mono text-cyan-400 truncate tracking-tight"
title={formatZAR(totalProjectActuals)}
>
{formatZAR(totalProjectActuals)}
</div>
</div>
<span className="text-[10px] text-slate-500 truncate">Incurred material costs</span>
</div>
<div className="bg-[#0b101c] p-4 rounded-xl border border-slate-800 hover:border-slate-700 transition flex flex-col justify-between min-w-0 shadow-sm">
<div className="flex items-center justify-between gap-1 mb-1">
<span className="text-[10px] uppercase font-bold text-slate-400 flex items-center gap-1.5 truncate">
<CheckCircle2 className="w-3.5 h-3.5 text-indigo-400 shrink-0" />
Milestone Progress
</span>
</div>
<div className="my-1.5">
<div className="text-lg sm:text-xl font-bold text-white font-mono truncate tracking-tight">
{progressPct}%
</div>
</div>
<span className="text-[10px] text-slate-500 truncate">
{verifiedCount} of {milestones.length} verified
</span>
</div>
</div>
{/* Action Header */}
<div className="flex items-center justify-between bg-[#0d121f] p-3 rounded-xl border border-slate-800">
<span className="text-xs text-slate-400">
Deliverable Milestones: <strong className="text-white">{milestones.length}</strong>
</span>
<div className="flex items-center gap-2"> <div className="flex items-center gap-2">
{!isFormOpen && ( {!isFormOpen && canAdd('projects_milestones') && (
<button <button
type="button" type="button"
onClick={openCreateForm} onClick={openCreateForm}
@@ -208,7 +331,7 @@ export const ProjectMilestonesModal: React.FC<ProjectMilestonesModalProps> = ({
</button> </button>
</div> </div>
<form onSubmit={form.handleSubmit((data) => mutation.mutate(data as any))} className="space-y-3"> <form onSubmit={form.handleSubmit((data) => mutation.mutate(data))} className="space-y-3">
<div> <div>
<label className="block text-xs font-semibold text-slate-300 mb-1"> <label className="block text-xs font-semibold text-slate-300 mb-1">
Deliverable Name / Milestone Title * Deliverable Name / Milestone Title *
@@ -254,10 +377,11 @@ export const ProjectMilestonesModal: React.FC<ProjectMilestonesModalProps> = ({
{editingMilestone && ( {editingMilestone && (
<div> <div>
<label className="block text-xs font-semibold text-slate-300 mb-1">Approval / Verification Status</label> <label className="block text-xs font-semibold text-slate-300 mb-1">Approval Status</label>
<select <select
{...form.register('status')} {...form.register('status')}
className="w-full px-3 py-2 text-xs border border-slate-700/80 rounded-xl focus:outline-none focus:ring-1 focus:ring-blue-500 bg-[#090d16] text-white font-medium" disabled={!canApprove('projects_milestones') || !canApproveRecord('projects_milestones', editingMilestone.createdBy)}
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 disabled:opacity-60 disabled:cursor-not-allowed"
> >
{APPROVAL_STATUS_OPTIONS.map((opt) => ( {APPROVAL_STATUS_OPTIONS.map((opt) => (
<option key={opt.value} value={opt.value}> <option key={opt.value} value={opt.value}>
@@ -265,6 +389,12 @@ export const ProjectMilestonesModal: React.FC<ProjectMilestonesModalProps> = ({
</option> </option>
))} ))}
</select> </select>
{editingMilestone && !canApproveRecord('projects_milestones', editingMilestone.createdBy) && (
<p className="text-[10px] text-amber-400 mt-1 flex items-center gap-1">
<Lock className="w-3 h-3" />
Maker-Checker: Cannot verify or approve a milestone that you authored.
</p>
)}
</div> </div>
)} )}
@@ -304,7 +434,7 @@ export const ProjectMilestonesModal: React.FC<ProjectMilestonesModalProps> = ({
</button> </button>
</div> </div>
{isLoading ? ( {isLoadingMilestones || isLoadingActivities || isLoadingIndicators ? (
<div className="p-8 text-center text-xs text-slate-400 bg-[#0b101c] rounded-xl border border-slate-800"> <div className="p-8 text-center text-xs text-slate-400 bg-[#0b101c] rounded-xl border border-slate-800">
Loading milestones... Loading milestones...
</div> </div>
@@ -312,7 +442,9 @@ export const ProjectMilestonesModal: React.FC<ProjectMilestonesModalProps> = ({
<div className="p-8 text-center bg-[#0b101c] rounded-xl border border-dashed border-slate-800 text-slate-400 space-y-2"> <div className="p-8 text-center bg-[#0b101c] rounded-xl border border-dashed border-slate-800 text-slate-400 space-y-2">
<Flag className="w-8 h-8 text-slate-600 mx-auto" /> <Flag className="w-8 h-8 text-slate-600 mx-auto" />
<p className="text-xs font-semibold text-slate-300">No milestones registered yet</p> <p className="text-xs font-semibold text-slate-300">No milestones registered yet</p>
<p className="text-[11px] text-slate-500">Define contractual deliverables to monitor implementation progress.</p> <p className="text-[11px] text-slate-500">
Define contractual deliverables to monitor implementation progress.
</p>
<button <button
type="button" type="button"
onClick={openCreateForm} onClick={openCreateForm}
@@ -322,55 +454,115 @@ export const ProjectMilestonesModal: React.FC<ProjectMilestonesModalProps> = ({
</button> </button>
</div> </div>
) : ( ) : (
<div className="space-y-2 max-h-[380px] overflow-y-auto pr-1"> <div className="space-y-3 max-h-[420px] overflow-y-auto pr-1">
{milestones.map((m) => { {milestones.map((m) => {
const statusBadge = getApprovalStatusBadge(m.status); const statusBadge = getApprovalStatusBadge(m.status);
const priBadge = getPriorityBadge(m.priority); const priBadge = getPriorityBadge(m.priority);
const isApproved = m.status === ApprovalStatus.Approved;
// Milestone activities
const mActivities = activities.filter((a) => String(a.milestoneId) === String(m.id));
const plannedMilestoneValue = mActivities.reduce(
(sum, a) => sum + (Number(a.amount) || 0),
0
);
// Milestone indicators (linked to activities under this milestone)
const mActivityIds = new Set(mActivities.map((a) => String(a.id)));
const mIndicators = indicators.filter(
(i) => i.activityId && mActivityIds.has(String(i.activityId))
);
const actualMilestoneValue = mIndicators.reduce(
(sum, i) => sum + (Number(i.actualCost) || 0),
0
);
const isOverrun = actualMilestoneValue > plannedMilestoneValue && plannedMilestoneValue > 0;
return ( return (
<div <div
key={m.id} key={m.id}
className="p-3.5 rounded-xl border border-slate-800 bg-[#0d121f] hover:border-slate-700 transition flex flex-col sm:flex-row sm:items-center justify-between gap-3" className={`p-4 rounded-xl border bg-[#0d121f] transition space-y-3 ${
isOverrun ? 'border-amber-500/40 bg-amber-950/10' : 'border-slate-800 hover:border-slate-700'
}`}
> >
<div className="space-y-1"> <div className="flex flex-col sm:flex-row sm:items-start justify-between gap-3">
<div className="flex items-center gap-2 flex-wrap"> <div className="space-y-1">
<span className="text-xs font-bold text-white">{m.name}</span> <div className="flex items-center gap-2 flex-wrap">
<span className={`inline-flex items-center px-2 py-0.5 rounded-full text-[10px] font-semibold border ${statusBadge.className}`}> <span className="text-xs font-bold text-white">{m.name}</span>
{statusBadge.label} <span className={`inline-flex items-center px-2 py-0.5 rounded-full text-[10px] font-semibold border ${statusBadge.className}`}>
</span> {statusBadge.label}
<span className={`inline-flex items-center px-2 py-0.5 rounded-full text-[10px] font-semibold border ${priBadge.className}`}> </span>
{priBadge.label} <span className={`inline-flex items-center px-2 py-0.5 rounded-full text-[10px] font-semibold border ${priBadge.className}`}>
</span> {priBadge.label}
</span>
{isOverrun && (
<span className="inline-flex items-center gap-1 text-[10px] font-bold text-rose-400 bg-rose-500/10 px-2 py-0.5 rounded border border-rose-500/25">
<AlertTriangle className="w-3 h-3 text-rose-400" />
Budget Overrun
</span>
)}
</div>
<div className="flex items-center gap-3 text-[11px] text-slate-400 font-mono">
<span className="flex items-center gap-1">
<Clock className="w-3 h-3 text-slate-500" />
Due: {formatDate(m.dueAt)}
</span>
<span>&bull;</span>
<span>ID #{m.id}</span>
</div>
</div> </div>
<div className="flex items-center gap-3 text-[11px] text-slate-400 font-mono">
<span className="flex items-center gap-1"> <div className="flex items-center gap-2 shrink-0 self-end sm:self-center">
<Clock className="w-3 h-3 text-slate-500" /> <div className="text-right mr-2">
Due: {formatDate(m.dueAt)} <span className="text-[10px] text-slate-400 block font-sans">Milestone Value:</span>
</span> <span className="font-mono text-xs font-bold text-blue-400 block">
<span>&bull;</span> {formatZAR(plannedMilestoneValue)}
<span>ID #{m.id}</span> </span>
</div>
<Link
to={`/monitoring?tab=evidence&projectId=${project.id}&milestoneId=${m.id}`}
className="inline-flex items-center gap-1 px-2.5 py-1 text-[11px] font-semibold text-blue-300 hover:text-white bg-blue-500/10 hover:bg-blue-500/20 border border-blue-500/25 rounded-lg transition"
title="Inspect verification evidence"
>
<FileCheck className="w-3.5 h-3.5 text-blue-400" />
Evidence
</Link>
<button
type="button"
onClick={() => openEditForm(m)}
className="p-1.5 rounded-lg text-slate-400 hover:text-blue-400 hover:bg-slate-800 transition cursor-pointer"
title="Edit Milestone"
>
<Edit2 className="w-3.5 h-3.5" />
</button>
</div> </div>
</div> </div>
<div className="flex items-center gap-2 shrink-0 self-end sm:self-center"> {/* Breakdown of Activities & Indicators */}
<Link <div className="pt-2 border-t border-slate-800/80 flex flex-col sm:flex-row sm:items-center justify-between gap-2 text-xs">
to={`/monitoring?tab=evidence&projectId=${project.id}&milestoneId=${m.id}`} <div className="flex items-center gap-3 text-slate-400 text-[11px] flex-wrap">
className="inline-flex items-center gap-1 px-2.5 py-1 text-[11px] font-semibold text-blue-300 hover:text-white bg-blue-500/10 hover:bg-blue-500/20 border border-blue-500/25 rounded-lg transition" <span className="flex items-center gap-1">
title="Inspect verification evidence" <Briefcase className="w-3 h-3 text-cyan-400" />
> Activities: <strong className="text-white font-mono">{mActivities.length}</strong>
<FileCheck className="w-3.5 h-3.5 text-blue-400" /> </span>
Evidence <span>&bull;</span>
</Link> <span className="flex items-center gap-1">
<Target className="w-3 h-3 text-indigo-400" />
Indicators: <strong className="text-white font-mono">{mIndicators.length}</strong>
</span>
<span>&bull;</span>
<span>
Actual Incurred: <strong className={`font-mono font-bold ${actualMilestoneValue > plannedMilestoneValue && plannedMilestoneValue > 0 ? 'text-amber-400' : 'text-slate-200'}`}>{formatZAR(actualMilestoneValue)}</strong>
</span>
</div>
<button {plannedMilestoneValue > 0 && (
type="button" <div className="text-[10px] text-slate-500 font-mono">
onClick={() => openEditForm(m)} Variance: {formatZAR(plannedMilestoneValue - actualMilestoneValue)}
className="p-1.5 rounded-lg text-slate-400 hover:text-blue-400 hover:bg-slate-800 transition cursor-pointer" </div>
title="Edit Milestone" )}
>
<Edit2 className="w-3.5 h-3.5" />
</button>
</div> </div>
</div> </div>
); );
@@ -67,7 +67,7 @@ export const ProjectRisksModal: React.FC<ProjectRisksModalProps> = ({
}); });
const form = useForm<RiskFormValues>({ const form = useForm<RiskFormValues>({
resolver: zodResolver(riskSchema) as any, resolver: zodResolver(riskSchema),
defaultValues: { defaultValues: {
name: '', name: '',
description: '', description: '',
@@ -202,7 +202,7 @@ export const ProjectRisksModal: React.FC<ProjectRisksModalProps> = ({
</button> </button>
</div> </div>
<form onSubmit={form.handleSubmit((data) => mutation.mutate(data as any))} className="space-y-3"> <form onSubmit={form.handleSubmit((data) => mutation.mutate(data))} className="space-y-3">
<div> <div>
<label className="block text-xs font-semibold text-slate-300 mb-1">Risk Hazard Title *</label> <label className="block text-xs font-semibold text-slate-300 mb-1">Risk Hazard Title *</label>
<input <input
@@ -1,4 +1,4 @@
import React, { useMemo } from 'react'; import React, { useMemo, useRef, useState, useEffect } from 'react';
import { import {
Flag, Flag,
Calendar, Calendar,
@@ -6,9 +6,15 @@ import {
Briefcase, Briefcase,
CheckCircle2, CheckCircle2,
AlertCircle, AlertCircle,
TrendingUp,
Plus, Plus,
Coins, Coins,
ChevronLeft,
ChevronRight,
ZoomIn,
ZoomOut,
Compass,
ArrowRight,
Sparkles,
} from 'lucide-react'; } from 'lucide-react';
import { Project, Milestone, Activity, ApprovalStatus, Priority } from '../../types'; import { Project, Milestone, Activity, ApprovalStatus, Priority } from '../../types';
import { formatDate, formatZAR, getApprovalStatusBadge, getPriorityBadge } from '../../lib/formatters'; import { formatDate, formatZAR, getApprovalStatusBadge, getPriorityBadge } from '../../lib/formatters';
@@ -32,7 +38,12 @@ export const ProjectTimelineGantt: React.FC<ProjectTimelineGanttProps> = ({
onManageMilestones, onManageMilestones,
onManageActivities, onManageActivities,
}) => { }) => {
// Compute timeline boundaries and items const scrollContainerRef = useRef<HTMLDivElement>(null);
const [zoomLevel, setZoomLevel] = useState<'compact' | 'standard' | 'expanded'>('standard');
const [canScrollLeft, setCanScrollLeft] = useState(false);
const [canScrollRight, setCanScrollRight] = useState(false);
// Compute timeline boundaries, first/last milestones, and ruler ticks
const timelineData = useMemo(() => { const timelineData = useMemo(() => {
const dates: number[] = []; const dates: number[] = [];
@@ -56,14 +67,14 @@ export const ProjectTimelineGantt: React.FC<ProjectTimelineGanttProps> = ({
if (dates.length > 0 && Math.min(...dates) !== Math.max(...dates)) { if (dates.length > 0 && Math.min(...dates) !== Math.max(...dates)) {
startMs = Math.min(...dates); startMs = Math.min(...dates);
endMs = Math.max(...dates); endMs = Math.max(...dates);
// Add a slight buffer (5% on each side) for aesthetic padding // Aesthetic buffer (6% on each side, minimum 10 days)
const buffer = Math.max((endMs - startMs) * 0.05, 7 * 24 * 60 * 60 * 1000); const buffer = Math.max((endMs - startMs) * 0.06, 10 * 24 * 60 * 60 * 1000);
startMs -= buffer; startMs -= buffer;
endMs += buffer; endMs += buffer;
} else { } else {
// Default: current quarter +/- 3 months // Default fallback
startMs = now - 90 * 24 * 60 * 60 * 1000; startMs = now - 60 * 24 * 60 * 60 * 1000;
endMs = now + 180 * 24 * 60 * 60 * 1000; endMs = now + 240 * 24 * 60 * 60 * 1000;
} }
const totalDurationMs = endMs - startMs; const totalDurationMs = endMs - startMs;
@@ -74,28 +85,36 @@ export const ProjectTimelineGantt: React.FC<ProjectTimelineGanttProps> = ({
return ((clamped - startMs) / totalDurationMs) * 100; return ((clamped - startMs) / totalDurationMs) * 100;
}; };
// Generate monthly ticks for ruler // Monthly ruler ticks
const ticks: { label: string; percent: number; isStartOfYear: boolean }[] = []; const ticks: { label: string; yearLabel?: string; percent: number; isStartOfYear: boolean; isQuarter: boolean }[] = [];
const currDate = new Date(startMs); const currDate = new Date(startMs);
currDate.setDate(1); currDate.setDate(1);
currDate.setHours(0, 0, 0, 0); currDate.setHours(0, 0, 0, 0);
while (currDate.getTime() <= endMs) { while (currDate.getTime() <= endMs) {
if (currDate.getTime() >= startMs) { if (currDate.getTime() >= startMs) {
const monthNum = currDate.getMonth();
ticks.push({ ticks.push({
label: currDate.toLocaleDateString('en-GB', { month: 'short', year: '2-digit' }), label: currDate.toLocaleDateString('en-GB', { month: 'short' }),
yearLabel: currDate.getFullYear().toString(),
percent: getPercent(currDate.getTime()), percent: getPercent(currDate.getTime()),
isStartOfYear: currDate.getMonth() === 0, isStartOfYear: monthNum === 0,
isQuarter: monthNum % 3 === 0,
}); });
} }
currDate.setMonth(currDate.getMonth() + 1); currDate.setMonth(currDate.getMonth() + 1);
} }
// Sort milestones & activities by date // Sort milestones chronologically
const sortedMilestones = [...milestones].sort( const sortedMilestones = [...milestones].sort(
(a, b) => new Date(a.dueAt || 0).getTime() - new Date(b.dueAt || 0).getTime() (a, b) => new Date(a.dueAt || 0).getTime() - new Date(b.dueAt || 0).getTime()
); );
// Identify first and last milestones
const firstMilestone = sortedMilestones.length > 0 ? sortedMilestones[0] : null;
const lastMilestone = sortedMilestones.length > 0 ? sortedMilestones[sortedMilestones.length - 1] : null;
// Sort activities chronologically
const sortedActivities = [...activities].sort( const sortedActivities = [...activities].sort(
(a, b) => new Date(a.performedAt || 0).getTime() - new Date(b.performedAt || 0).getTime() (a, b) => new Date(a.performedAt || 0).getTime() - new Date(b.performedAt || 0).getTime()
); );
@@ -103,6 +122,9 @@ export const ProjectTimelineGantt: React.FC<ProjectTimelineGanttProps> = ({
const todayPercent = getPercent(now); const todayPercent = getPercent(now);
const isTodayInRange = todayPercent >= 0 && todayPercent <= 100; const isTodayInRange = todayPercent >= 0 && todayPercent <= 100;
const projectStartPercent = project.startedAt ? getPercent(new Date(project.startedAt).getTime()) : null;
const projectEndPercent = project.endedAt ? getPercent(new Date(project.endedAt).getTime()) : null;
return { return {
startMs, startMs,
endMs, endMs,
@@ -110,321 +132,608 @@ export const ProjectTimelineGantt: React.FC<ProjectTimelineGanttProps> = ({
getPercent, getPercent,
ticks, ticks,
sortedMilestones, sortedMilestones,
firstMilestone,
lastMilestone,
sortedActivities, sortedActivities,
todayPercent, todayPercent,
isTodayInRange, isTodayInRange,
projectStartPercent,
projectEndPercent,
}; };
}, [project, milestones, activities]); }, [project, milestones, activities]);
// Check scroll capability
const updateScrollState = () => {
const el = scrollContainerRef.current;
if (!el) return;
setCanScrollLeft(el.scrollLeft > 10);
setCanScrollRight(el.scrollLeft < el.scrollWidth - el.clientWidth - 10);
};
useEffect(() => {
updateScrollState();
const el = scrollContainerRef.current;
if (el) {
el.addEventListener('scroll', updateScrollState);
window.addEventListener('resize', updateScrollState);
}
return () => {
if (el) el.removeEventListener('scroll', updateScrollState);
window.removeEventListener('resize', updateScrollState);
};
}, [timelineData, zoomLevel]);
// Horizontal shift handlers (requirement 4)
const handleScrollLeft = () => {
if (!scrollContainerRef.current) return;
scrollContainerRef.current.scrollBy({ left: -360, behavior: 'smooth' });
};
const handleScrollRight = () => {
if (!scrollContainerRef.current) return;
scrollContainerRef.current.scrollBy({ left: 360, behavior: 'smooth' });
};
// Quick jump handlers (requirement 5)
const scrollToPercent = (percent: number) => {
const el = scrollContainerRef.current;
if (!el) return;
const totalWidth = el.scrollWidth;
const clientWidth = el.clientWidth;
const targetScrollLeft = (percent / 100) * totalWidth - clientWidth / 2;
el.scrollTo({ left: Math.max(0, targetScrollLeft), behavior: 'smooth' });
};
const scrollToMilestone = (m: Milestone) => {
if (!m.dueAt) return;
const pct = timelineData.getPercent(new Date(m.dueAt).getTime());
scrollToPercent(pct);
};
const scrollToToday = () => {
if (timelineData.isTodayInRange) {
scrollToPercent(timelineData.todayPercent);
}
};
const scrollToProjectStart = () => {
if (timelineData.projectStartPercent !== null) {
scrollToPercent(timelineData.projectStartPercent);
} else if (timelineData.firstMilestone) {
scrollToMilestone(timelineData.firstMilestone);
}
};
const scrollToProjectEnd = () => {
if (timelineData.projectEndPercent !== null) {
scrollToPercent(timelineData.projectEndPercent);
} else if (timelineData.lastMilestone) {
scrollToMilestone(timelineData.lastMilestone);
}
};
// Zoom width classes
const canvasWidthClass =
zoomLevel === 'compact'
? 'min-w-[1200px]'
: zoomLevel === 'standard'
? 'min-w-[1900px]'
: 'min-w-[2800px]';
const verifiedMilestonesCount = milestones.filter((m) => m.status === ApprovalStatus.Approved).length; const verifiedMilestonesCount = milestones.filter((m) => m.status === ApprovalStatus.Approved).length;
const milestoneProgress = milestones.length > 0 ? Math.round((verifiedMilestonesCount / milestones.length) * 100) : 0; 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 totalActivityBudget = activities.reduce((sum, a) => sum + (Number(a.amount) || 0), 0);
return ( return (
<div className="bg-[#0b101c] rounded-2xl border border-slate-800/80 p-5 space-y-5 overflow-hidden shadow-inner"> <div className="bg-[#0b101c] rounded-2xl border border-slate-800/80 p-4 sm:p-5 space-y-4 shadow-xl w-full min-w-0 relative">
{/* Timeline Header & Key Stats */} {/* Timeline Header, Key Stats & Enhanced Navigation Bar */}
<div className="flex flex-wrap items-center justify-between gap-3 pb-4 border-b border-slate-800/80"> <div className="flex flex-col xl:flex-row xl:items-center justify-between gap-3.5 pb-4 border-b border-slate-800/80 min-w-0">
<div className="flex items-center gap-2.5"> {/* Title & Span Info */}
<div className="h-8 w-8 rounded-xl bg-blue-500/10 border border-blue-500/30 flex items-center justify-center text-blue-400"> <div className="flex items-center gap-3 min-w-0 flex-1">
<Calendar className="w-4 h-4" /> <div className="h-10 w-10 rounded-2xl bg-gradient-to-br from-blue-600/20 to-indigo-600/20 border border-blue-500/30 flex items-center justify-center text-blue-400 shadow-md shadow-blue-500/10 shrink-0">
<Calendar className="w-5 h-5" />
</div> </div>
<div> <div className="min-w-0">
<h4 className="text-xs font-bold text-white uppercase tracking-wider flex items-center gap-2"> <div className="flex items-center gap-2 flex-wrap">
Implementation Schedule & Timeline <h4 className="text-sm font-bold text-white tracking-wide uppercase">
<span className="text-[10px] normal-case font-normal text-slate-400"> Implementation Schedule & Timeline Gantt
({timelineData.totalDurationDays} Days Span) </h4>
<span className="text-[11px] font-semibold text-blue-400 bg-blue-500/10 px-2 py-0.5 rounded-full border border-blue-500/30">
{timelineData.totalDurationDays} Days Span
</span> </span>
</h4> {project.startedAt && project.endedAt && (
<p className="text-[11px] text-slate-400"> <span className="text-[11px] text-slate-400 font-mono">
Chronological alignment of contractual deliverables and operational activities. ({formatDate(project.startedAt)} &rarr; {formatDate(project.endedAt)})
</span>
)}
</div>
<p className="text-xs text-slate-400 mt-0.5 truncate">
Chronological alignment of contractual deliverables, work breakdown activities, and target milestones.
</p> </p>
</div> </div>
</div> </div>
{/* Quick Legend & Actions */} {/* Enhanced Navigation Toolbar (Quick Jump, Zoom, Scroll Controls) */}
<div className="flex items-center gap-3 text-[11px] flex-wrap"> <div className="flex items-center gap-2 flex-wrap shrink-0">
<div className="flex items-center gap-3 px-3 py-1 bg-slate-900/80 rounded-xl border border-slate-800 text-slate-400"> {/* Quick Jump Buttons (Requirement 5) */}
<span className="flex items-center gap-1.5"> <div className="flex items-center gap-1.5 p-1 bg-slate-900/90 rounded-xl border border-slate-800 text-xs flex-wrap">
<span className="w-2.5 h-2.5 rounded-full bg-emerald-500 inline-block shadow-sm shadow-emerald-500/50" /> <span className="text-[10px] uppercase font-bold text-slate-500 px-1.5 hidden sm:inline">Jump:</span>
Verified Deliverable
</span> {/* Jump to First Milestone */}
<span className="flex items-center gap-1.5"> {timelineData.firstMilestone ? (
<span className="w-2.5 h-2.5 rounded-full bg-amber-500 inline-block shadow-sm shadow-amber-500/50" /> <button
Pending / Review type="button"
</span> onClick={() => scrollToMilestone(timelineData.firstMilestone!)}
<span className="flex items-center gap-1.5"> className="inline-flex items-center gap-1 px-2.5 py-1 rounded-lg text-[11px] font-semibold bg-emerald-500/15 hover:bg-emerald-500/25 text-emerald-300 border border-emerald-500/30 transition cursor-pointer shadow-xs"
<span className="w-2.5 h-2.5 rounded-sm bg-blue-500 inline-block shadow-sm shadow-blue-500/50" /> title={`Jump to First Milestone: "${timelineData.firstMilestone.name}" (${formatDate(timelineData.firstMilestone.dueAt)})`}
Work Activity >
</span> <Flag className="w-3 h-3 text-emerald-400" />
<span className="max-w-[120px] truncate">
Start: {timelineData.firstMilestone.name}
</span>
</button>
) : project.startedAt ? (
<button
type="button"
onClick={scrollToProjectStart}
className="inline-flex items-center gap-1 px-2.5 py-1 rounded-lg text-[11px] font-semibold bg-slate-800 hover:bg-slate-700 text-slate-300 border border-slate-700 transition cursor-pointer"
title={`Jump to Project Start (${formatDate(project.startedAt)})`}
>
<Calendar className="w-3 h-3 text-blue-400" />
<span>Start Date</span>
</button>
) : null}
{/* Jump to Today */}
{timelineData.isTodayInRange && (
<button
type="button"
onClick={scrollToToday}
className="inline-flex items-center gap-1 px-2.5 py-1 rounded-lg text-[11px] font-semibold bg-amber-500/15 hover:bg-amber-500/25 text-amber-300 border border-amber-500/40 transition cursor-pointer shadow-xs"
title="Jump directly to current date on timeline"
>
<Clock className="w-3 h-3 text-amber-400" />
<span>Today</span>
</button>
)}
{/* Jump to Last Milestone */}
{timelineData.lastMilestone ? (
<button
type="button"
onClick={() => scrollToMilestone(timelineData.lastMilestone!)}
className="inline-flex items-center gap-1 px-2.5 py-1 rounded-lg text-[11px] font-semibold bg-indigo-500/15 hover:bg-indigo-500/25 text-indigo-300 border border-indigo-500/30 transition cursor-pointer shadow-xs"
title={`Jump to Final Milestone: "${timelineData.lastMilestone.name}" (${formatDate(timelineData.lastMilestone.dueAt)})`}
>
<CheckCircle2 className="w-3 h-3 text-indigo-400" />
<span className="max-w-[120px] truncate">
Final: {timelineData.lastMilestone.name}
</span>
</button>
) : project.endedAt ? (
<button
type="button"
onClick={scrollToProjectEnd}
className="inline-flex items-center gap-1 px-2.5 py-1 rounded-lg text-[11px] font-semibold bg-slate-800 hover:bg-slate-700 text-slate-300 border border-slate-700 transition cursor-pointer"
title={`Jump to Project Target End (${formatDate(project.endedAt)})`}
>
<Calendar className="w-3 h-3 text-indigo-400" />
<span>Target End</span>
</button>
) : null}
</div>
{/* Zoom Level Selector */}
<div className="flex items-center p-0.5 bg-slate-900/90 rounded-xl border border-slate-800 text-[11px]">
<button
type="button"
onClick={() => setZoomLevel('compact')}
className={`px-2 py-1 rounded-lg font-medium transition cursor-pointer ${
zoomLevel === 'compact'
? 'bg-blue-600 text-white shadow-xs'
: 'text-slate-400 hover:text-white'
}`}
title="Compact View (Fits broader duration)"
>
Fit
</button>
<button
type="button"
onClick={() => setZoomLevel('standard')}
className={`px-2 py-1 rounded-lg font-medium transition cursor-pointer ${
zoomLevel === 'standard'
? 'bg-blue-600 text-white shadow-xs'
: 'text-slate-400 hover:text-white'
}`}
title="Standard View (Balanced spacing)"
>
Standard
</button>
<button
type="button"
onClick={() => setZoomLevel('expanded')}
className={`px-2 py-1 rounded-lg font-medium transition cursor-pointer ${
zoomLevel === 'expanded'
? 'bg-blue-600 text-white shadow-xs'
: 'text-slate-400 hover:text-white'
}`}
title="Expanded View (Deep detail, optimal for scrolling)"
>
Expanded
</button>
</div>
{/* Scroll Shift Buttons in Top Header */}
<div className="flex items-center gap-1">
<button
type="button"
onClick={handleScrollLeft}
disabled={!canScrollLeft}
className="p-1.5 rounded-xl bg-slate-900 hover:bg-slate-800 text-slate-300 hover:text-white border border-slate-700/80 transition cursor-pointer disabled:opacity-40 disabled:cursor-not-allowed"
title="Shift timeline left (360px)"
aria-label="Scroll left"
>
<ChevronLeft className="w-4 h-4" />
</button>
<button
type="button"
onClick={handleScrollRight}
disabled={!canScrollRight}
className="p-1.5 rounded-xl bg-slate-900 hover:bg-slate-800 text-slate-300 hover:text-white border border-slate-700/80 transition cursor-pointer disabled:opacity-40 disabled:cursor-not-allowed"
title="Shift timeline right (360px)"
aria-label="Scroll right"
>
<ChevronRight className="w-4 h-4" />
</button>
</div> </div>
</div> </div>
</div> </div>
{/* Main Gantt Timeline Track Container */} {/* Milestone & Activity Legend & Milestone Span Pill */}
<div className="space-y-4 pt-1"> <div className="flex flex-col sm:flex-row sm:items-center justify-between gap-2.5 text-xs text-slate-400 flex-wrap">
{/* Month Scale / Ruler */} <div className="flex items-center gap-3 flex-wrap">
<div className="relative h-6 w-full border-b border-slate-800 text-[10px] font-mono text-slate-500 select-none"> <span className="flex items-center gap-1.5">
{timelineData.ticks.map((tick, idx) => ( <span className="w-2.5 h-2.5 rounded-full bg-emerald-500 shadow-sm shadow-emerald-500/50 inline-block" />
<div <strong className="text-slate-300">Verified Milestone</strong>
key={idx} </span>
className="absolute transform -translate-x-1/2 flex flex-col items-center" <span className="flex items-center gap-1.5">
style={{ left: `${tick.percent}%` }} <span className="w-2.5 h-2.5 rounded-full bg-amber-500 shadow-sm shadow-amber-500/50 inline-block" />
> <strong className="text-slate-300">Pending Review</strong>
<span className={tick.isStartOfYear ? 'text-blue-400 font-bold' : 'text-slate-400'}> </span>
{tick.label} <span className="flex items-center gap-1.5">
</span> <span className="w-2.5 h-2.5 rounded-full bg-blue-500 shadow-sm shadow-blue-500/50 inline-block" />
<span className="h-1.5 w-px bg-slate-700 mt-0.5" /> <strong className="text-slate-300">Contractual Deliverable</strong>
</div> </span>
))} <span className="flex items-center gap-1.5">
<span className="w-2.5 h-2.5 rounded-sm bg-cyan-500 shadow-sm shadow-cyan-500/50 inline-block" />
<strong className="text-slate-300">Work Activity</strong>
</span>
</div> </div>
{/* Timeline Visualization Surface */} {/* First & Last Milestone Span Pill (Requirement 5) */}
<div className="relative min-h-[290px] bg-[#090d16] rounded-xl border border-slate-800/90 pt-8 pb-5 px-4 overflow-x-auto"> {timelineData.firstMilestone && timelineData.lastMilestone && (
{/* Background vertical grid lines */} <div className="flex items-center gap-1.5 text-[11px] font-mono bg-slate-900/90 px-3 py-1 rounded-full border border-slate-800 text-slate-300">
<div className="absolute inset-0 pointer-events-none"> <span className="text-emerald-400 font-semibold">1st: {formatDate(timelineData.firstMilestone.dueAt)}</span>
{timelineData.ticks.map((tick, idx) => ( <ArrowRight className="w-3 h-3 text-slate-500" />
<div <span className="text-indigo-400 font-semibold">Final: {formatDate(timelineData.lastMilestone.dueAt)}</span>
key={idx}
className="absolute top-0 bottom-0 w-px bg-slate-800/40"
style={{ left: `${tick.percent}%` }}
/>
))}
{/* Today marker */}
{timelineData.isTodayInRange && (
<div
className="absolute top-1 bottom-0 z-20 flex flex-col items-center pointer-events-none"
style={{ left: `${timelineData.todayPercent}%` }}
>
<span className="px-2 py-0.5 text-[9px] font-bold text-amber-300 bg-amber-500/25 border border-amber-500/50 rounded-full whitespace-nowrap shadow-md shadow-amber-500/20">
Today
</span>
<div className="w-px h-full bg-amber-400/80 border-dashed border-l border-amber-400 shadow-sm shadow-amber-400/30 mt-1" />
</div>
)}
</div> </div>
)}
</div>
{/* Layer 1: Milestones Track */} {/* Scrollable Timeline Visualization Container */}
<div className="relative z-10 mb-6 space-y-2"> <div className="relative group/timeline rounded-2xl border border-slate-800/90 bg-[#070b13] overflow-hidden shadow-2xl">
<div className="flex items-center justify-between text-[11px] font-semibold text-slate-300 px-1"> {/* Scrollable Viewport */}
<span className="flex items-center gap-1.5 text-blue-400"> <div
<Flag className="w-3.5 h-3.5" /> ref={scrollContainerRef}
Contractual Deliverables & Milestones ({milestones.length}) className="overflow-x-auto scrollbar-thin scrollbar-thumb-slate-700 scrollbar-track-slate-900 scroll-smooth w-full select-none"
</span> >
<button {/* Inner Canvas with expansive width */}
type="button" <div className={`relative pt-7 pb-14 px-6 min-h-[340px] ${canvasWidthClass}`}>
onClick={onManageMilestones} {/* Background vertical month grid lines */}
className="text-[10px] text-blue-400 hover:text-blue-300 hover:underline cursor-pointer flex items-center gap-1 font-medium" <div className="absolute inset-0 pointer-events-none">
> {timelineData.ticks.map((tick, idx) => (
<Plus className="w-3 h-3" /> Add / Manage Milestones <div
</button> key={idx}
className={`absolute top-0 bottom-0 w-px ${
tick.isStartOfYear
? 'bg-blue-500/30 border-dashed border-l border-blue-500/40'
: tick.isQuarter
? 'bg-slate-700/50'
: 'bg-slate-800/40'
}`}
style={{ left: `${tick.percent}%` }}
/>
))}
{/* Project Start Line Marker */}
{timelineData.projectStartPercent !== null && (
<div
className="absolute top-0 bottom-0 z-10 flex flex-col items-center pointer-events-none"
style={{ left: `${timelineData.projectStartPercent}%` }}
>
<span className="px-2 py-0.5 text-[9px] font-bold text-blue-300 bg-blue-600/30 border border-blue-500/50 rounded-full whitespace-nowrap shadow-md">
Start: {formatDate(project.startedAt)}
</span>
<div className="w-px h-full bg-blue-500/50 border-dashed border-l border-blue-400 mt-1" />
</div>
)}
{/* Project Target End Line Marker */}
{timelineData.projectEndPercent !== null && (
<div
className="absolute top-0 bottom-0 z-10 flex flex-col items-center pointer-events-none"
style={{ left: `${timelineData.projectEndPercent}%` }}
>
<span className="px-2 py-0.5 text-[9px] font-bold text-indigo-300 bg-indigo-600/30 border border-indigo-500/50 rounded-full whitespace-nowrap shadow-md">
End: {formatDate(project.endedAt)}
</span>
<div className="w-px h-full bg-indigo-500/50 border-dashed border-l border-indigo-400 mt-1" />
</div>
)}
{/* Today line marker */}
{timelineData.isTodayInRange && (
<div
className="absolute top-0 bottom-0 z-20 flex flex-col items-center pointer-events-none"
style={{ left: `${timelineData.todayPercent}%` }}
>
<span className="px-2.5 py-0.5 text-[9px] font-bold text-amber-300 bg-amber-500/30 border border-amber-400/60 rounded-full whitespace-nowrap shadow-lg shadow-amber-500/25">
Today
</span>
<div className="w-px h-full bg-amber-400 border-l border-amber-400 shadow-md shadow-amber-400/40 mt-1" />
</div>
)}
</div> </div>
{timelineData.sortedMilestones.length === 0 ? ( {/* Month Scale / Ruler */}
<div className="p-4 bg-slate-900/40 rounded-lg border border-dashed border-slate-800 text-center text-xs text-slate-500"> <div className="relative h-9 border-b border-slate-800 text-[11px] font-mono text-slate-400 mb-6">
No contractual milestones defined yet. Click &quot;Add / Manage Milestones&quot; to establish deliverables. {timelineData.ticks.map((tick, idx) => (
</div> <div
) : ( key={idx}
<div className="relative h-24 bg-slate-900/50 rounded-xl border border-slate-800/80 flex items-center px-3"> className="absolute transform -translate-x-1/2 flex flex-col items-center"
{/* Milestone connecting base line */} style={{ left: `${tick.percent}%` }}
<div className="absolute left-3 right-3 h-1 bg-slate-800 rounded-full" /> >
<div className="flex items-center gap-1">
{/* Milestone Pins */} <span
{timelineData.sortedMilestones.map((m, idx) => { className={
const mTime = new Date(m.dueAt || 0).getTime(); tick.isStartOfYear
const pos = timelineData.getPercent(mTime); ? 'text-blue-400 font-bold text-xs'
const isApproved = m.status === ApprovalStatus.Approved; : tick.isQuarter
const isPending = m.status === ApprovalStatus.Pending; ? 'text-slate-200 font-semibold'
const priBadge = getPriorityBadge(m.priority); : 'text-slate-400'
}
return (
<div
key={m.id || idx}
className="absolute group z-10 -translate-x-1/2 cursor-pointer transition-all duration-200"
style={{ left: `${Math.min(96, Math.max(4, pos))}%` }}
onClick={onManageMilestones}
> >
{/* Flag Marker */} {tick.label}
</span>
{tick.isStartOfYear && (
<span className="text-[9px] text-blue-300 font-mono bg-blue-500/20 px-1 rounded">
{tick.yearLabel}
</span>
)}
</div>
<span className="h-2 w-px bg-slate-700 mt-1" />
</div>
))}
</div>
{/* Layer 1: Contractual Deliverables & Milestones Track */}
<div className="relative z-10 mb-8 space-y-2.5">
<div className="flex items-center justify-between text-xs font-bold text-slate-200 px-1 gap-2 flex-wrap">
<span className="flex items-center gap-2 text-blue-400">
<Flag className="w-4 h-4 text-blue-400" />
Contractual Deliverables & Milestones ({milestones.length})
</span>
<button
type="button"
onClick={onManageMilestones}
className="text-xs text-blue-400 hover:text-blue-300 hover:underline cursor-pointer flex items-center gap-1 font-semibold whitespace-nowrap"
>
<Plus className="w-3.5 h-3.5" /> Add / Manage Milestones
</button>
</div>
{timelineData.sortedMilestones.length === 0 ? (
<div className="p-6 bg-slate-900/40 rounded-xl border border-dashed border-slate-800 text-center text-xs text-slate-400">
No contractual milestones defined yet. Click &quot;Add / Manage Milestones&quot; to establish deliverables.
</div>
) : (
<div className="relative h-28 bg-slate-900/60 rounded-2xl border border-slate-800/80 flex items-center px-4 shadow-inner">
{/* Base connecting line */}
<div className="absolute left-4 right-4 h-1.5 bg-slate-800 rounded-full overflow-hidden">
{/* Visual progress overlay */}
<div
className="h-full bg-gradient-to-r from-emerald-500 to-blue-500 rounded-full transition-all duration-300"
style={{ width: `${milestoneProgress}%` }}
/>
</div>
{/* Milestone Pins */}
{timelineData.sortedMilestones.map((m, idx) => {
const mTime = new Date(m.dueAt || 0).getTime();
const pos = timelineData.getPercent(mTime);
const isApproved = m.status === ApprovalStatus.Approved;
const isPending = m.status === ApprovalStatus.Pending;
const isFirst = timelineData.firstMilestone?.id === m.id;
const isLast = timelineData.lastMilestone?.id === m.id && timelineData.sortedMilestones.length > 1;
const priBadge = getPriorityBadge(m.priority);
return (
<div <div
className={`h-8 w-8 rounded-full flex items-center justify-center shadow-lg transition-transform group-hover:scale-125 border ${ key={m.id || idx}
isApproved className="absolute group z-10 -translate-x-1/2 cursor-pointer transition-all duration-200"
? 'bg-emerald-500/20 border-emerald-400 text-emerald-300 shadow-emerald-500/20' style={{ left: `${Math.min(97, Math.max(3, pos))}%` }}
: isPending onClick={onManageMilestones}
? 'bg-amber-500/20 border-amber-400 text-amber-300 shadow-amber-500/20'
: 'bg-blue-500/20 border-blue-400 text-blue-300 shadow-blue-500/20'
}`}
> >
<Flag className="w-3.5 h-3.5 fill-current" /> {/* First / Last Milestone Distinctive Badge Indicator (Requirement 5) */}
</div> {isFirst && (
<div className="absolute -top-7 left-1/2 -translate-x-1/2 whitespace-nowrap">
<span className="px-2 py-0.5 rounded-full text-[9px] font-bold bg-emerald-500/25 border border-emerald-400 text-emerald-300 shadow-md flex items-center gap-1">
<Sparkles className="w-2.5 h-2.5 text-emerald-400" />
1st Milestone
</span>
</div>
)}
{isLast && !isFirst && (
<div className="absolute -top-7 left-1/2 -translate-x-1/2 whitespace-nowrap">
<span className="px-2 py-0.5 rounded-full text-[9px] font-bold bg-indigo-500/25 border border-indigo-400 text-indigo-300 shadow-md flex items-center gap-1">
<CheckCircle2 className="w-2.5 h-2.5 text-indigo-400" />
Final Close-out
</span>
</div>
)}
{/* Brief Pin Label */} {/* Milestone Flag Icon Bubble */}
<div className="mt-1 text-center whitespace-nowrap"> <div
<span className="text-[10px] font-bold text-slate-300 block max-w-[110px] truncate"> className={`h-9 w-9 rounded-2xl flex items-center justify-center shadow-lg transition-transform group-hover:scale-125 border ${
{m.name} isApproved
</span> ? 'bg-emerald-500/25 border-emerald-400 text-emerald-300 shadow-emerald-500/30'
<span className="text-[9px] text-slate-500 font-mono"> : isPending
{formatDate(m.dueAt)} ? 'bg-amber-500/25 border-amber-400 text-amber-300 shadow-amber-500/30'
</span> : 'bg-blue-500/25 border-blue-400 text-blue-300 shadow-blue-500/30'
</div> } ${isFirst ? 'ring-2 ring-emerald-400/50' : ''} ${isLast ? 'ring-2 ring-indigo-400/50' : ''}`}
>
{/* Tooltip on Hover */} <Flag className="w-4 h-4 fill-current" />
<div className="absolute bottom-full left-1/2 -translate-x-1/2 mb-2 hidden group-hover:flex flex-col w-56 p-2.5 bg-slate-900 border border-slate-700 rounded-xl shadow-2xl text-left z-30 pointer-events-none">
<div className="flex items-center justify-between gap-1 mb-1">
<span className="text-[10px] font-mono text-blue-400">Milestone #{m.id}</span>
<span className={`text-[9px] px-1.5 py-0.2 rounded border ${priBadge.className}`}>
{priBadge.label}
</span>
</div> </div>
<p className="text-xs font-bold text-white leading-tight mb-1">{m.name}</p>
<div className="flex items-center justify-between text-[10px] text-slate-400 pt-1 border-t border-slate-800"> {/* Deliverable Label */}
<span className="flex items-center gap-1"> <div className="mt-1 text-center whitespace-nowrap">
<Clock className="w-3 h-3 text-slate-500" /> <span className="text-[11px] font-bold text-slate-200 block max-w-[130px] truncate group-hover:text-white">
{m.name}
</span>
<span className="text-[10px] text-slate-400 font-mono block">
{formatDate(m.dueAt)} {formatDate(m.dueAt)}
</span> </span>
<span className={isApproved ? 'text-emerald-400 font-semibold' : 'text-amber-400'}> </div>
{isApproved ? 'Verified' : 'Pending'}
</span> {/* Rich Hover Card */}
<div className="absolute bottom-full left-1/2 -translate-x-1/2 mb-3 hidden group-hover:flex flex-col w-64 p-3 bg-slate-900 border border-slate-700/90 rounded-2xl shadow-2xl text-left z-30 pointer-events-none backdrop-blur-md">
<div className="flex items-center justify-between gap-1 mb-1.5">
<span className="text-[10px] font-mono text-blue-400 font-semibold">
Milestone #{m.id}
</span>
<span className={`text-[9px] px-2 py-0.5 rounded border ${priBadge.className}`}>
{priBadge.label}
</span>
</div>
<p className="text-xs font-bold text-white leading-tight mb-2">{m.name}</p>
<div className="flex items-center justify-between text-[11px] text-slate-400 pt-2 border-t border-slate-800">
<span className="flex items-center gap-1 font-mono">
<Clock className="w-3.5 h-3.5 text-slate-500" />
{formatDate(m.dueAt)}
</span>
<span className={isApproved ? 'text-emerald-400 font-bold' : 'text-amber-400 font-semibold'}>
{isApproved ? '✓ Verified' : '● Pending Review'}
</span>
</div>
</div> </div>
</div> </div>
</div> );
); })}
})} </div>
</div> )}
)}
</div>
{/* Layer 2: Activities Track */}
<div className="relative z-10 space-y-2">
<div className="flex items-center justify-between text-[11px] font-semibold text-slate-300 px-1">
<span className="flex items-center gap-1.5 text-cyan-400">
<Briefcase className="w-3.5 h-3.5" />
Work Breakdown Activities ({activities.length})
</span>
<button
type="button"
onClick={onManageActivities}
className="text-[10px] text-cyan-400 hover:text-cyan-300 hover:underline cursor-pointer flex items-center gap-1 font-medium"
>
<Plus className="w-3 h-3" /> Add / Manage Activities
</button>
</div> </div>
{timelineData.sortedActivities.length === 0 ? ( {/* Layer 2: Work Breakdown Activities Track */}
<div className="p-3 bg-slate-900/40 rounded-lg border border-dashed border-slate-800 text-center text-xs text-slate-500"> <div className="relative z-10 space-y-2.5">
No activities recorded in work breakdown structure. <div className="flex items-center justify-between text-xs font-bold text-slate-200 px-1 gap-2 flex-wrap">
<span className="flex items-center gap-2 text-cyan-400">
<Briefcase className="w-4 h-4 text-cyan-400" />
Work Breakdown Activities ({activities.length})
</span>
<button
type="button"
onClick={onManageActivities}
className="text-xs text-cyan-400 hover:text-cyan-300 hover:underline cursor-pointer flex items-center gap-1 font-semibold whitespace-nowrap"
>
<Plus className="w-3.5 h-3.5" /> Add / Manage Activities
</button>
</div> </div>
) : (
<div className="space-y-1.5">
{timelineData.sortedActivities.slice(0, 4).map((act, idx) => {
const aTime = new Date(act.performedAt || project.startedAt || Date.now()).getTime();
const pos = timelineData.getPercent(aTime);
return ( {timelineData.sortedActivities.length === 0 ? (
<div <div className="p-4 bg-slate-900/40 rounded-xl border border-dashed border-slate-800 text-center text-xs text-slate-400">
key={act.id || idx} No activities recorded in work breakdown structure.
className="group relative flex items-center p-2 rounded-xl bg-slate-900/80 hover:bg-slate-800/90 border border-slate-800 hover:border-slate-700 transition cursor-pointer" </div>
onClick={onManageActivities} ) : (
> <div className="space-y-2">
<div className="w-48 shrink-0 pr-3 border-r border-slate-800 truncate"> {timelineData.sortedActivities.slice(0, 5).map((act, idx) => {
<span className="text-xs font-semibold text-white block truncate">{act.title}</span> const aTime = new Date(act.performedAt || project.startedAt || Date.now()).getTime();
<span className="text-[10px] text-slate-400">{act.category || 'Operations'}</span> const pos = timelineData.getPercent(aTime);
</div>
<div className="flex-1 relative h-6 mx-3 flex items-center"> return (
<div className="w-full h-1 bg-slate-800 rounded-full" /> <div
<div key={act.id || idx}
className="absolute h-4 rounded-md bg-cyan-500/20 border border-cyan-400/60 px-2 flex items-center text-[9px] font-bold text-cyan-300 shadow-sm shadow-cyan-500/20 -translate-x-1/2 whitespace-nowrap group-hover:scale-105 transition" className="group relative flex items-center p-2.5 rounded-xl bg-slate-900/80 hover:bg-slate-800/90 border border-slate-800 hover:border-slate-700 transition cursor-pointer"
style={{ left: `${Math.min(94, Math.max(6, pos))}%` }} onClick={onManageActivities}
> >
<Coins className="w-2.5 h-2.5 mr-1 text-cyan-400" /> <div className="w-56 shrink-0 pr-3 border-r border-slate-800 truncate">
{formatZAR(act.amount)} <span className="text-xs font-semibold text-white block truncate group-hover:text-cyan-300 transition">
{act.title}
</span>
<span className="text-[10px] text-slate-400">{act.category || 'Operations'}</span>
</div>
<div className="flex-1 relative h-7 mx-4 flex items-center">
<div className="w-full h-1 bg-slate-800 rounded-full" />
<div
className="absolute h-5 rounded-lg bg-cyan-500/20 border border-cyan-400/60 px-2.5 flex items-center text-[10px] font-bold text-cyan-300 shadow-md shadow-cyan-500/20 -translate-x-1/2 whitespace-nowrap group-hover:scale-105 transition"
style={{ left: `${Math.min(95, Math.max(5, pos))}%` }}
>
<Coins className="w-3 h-3 mr-1 text-cyan-400" />
{formatZAR(act.amount)}
</div>
</div>
<div className="w-32 shrink-0 text-right font-mono text-xs text-slate-400">
{formatDate(act.performedAt)}
</div> </div>
</div> </div>
);
})}
<div className="w-28 shrink-0 text-right font-mono text-[10px] text-slate-400"> {activities.length > 5 && (
{formatDate(act.performedAt)} <div className="text-center pt-1">
</div> <button
type="button"
onClick={onManageActivities}
className="text-xs text-cyan-400 hover:text-cyan-300 font-semibold hover:underline cursor-pointer"
>
+ View all {activities.length} work breakdown tasks &rarr;
</button>
</div> </div>
); )}
})} </div>
)}
{activities.length > 4 && ( </div>
<div className="text-center pt-1">
<button
type="button"
onClick={onManageActivities}
className="text-[11px] text-cyan-400 hover:text-cyan-300 font-semibold hover:underline cursor-pointer"
>
+ View all {activities.length} work activities &rarr;
</button>
</div>
)}
</div>
)}
</div>
</div>
</div>
{/* Bottom Summary KPI Bar */}
<div className="grid grid-cols-2 sm:grid-cols-3 lg:grid-cols-5 gap-3 pt-2">
<div className="p-3 bg-[#090d16] rounded-xl border border-slate-800">
<span className="text-[10px] uppercase font-bold text-slate-500 block">Project Duration</span>
<span className="text-sm font-bold text-white mt-0.5 block">
{timelineData.totalDurationDays} Days
</span>
<span className="text-[10px] text-slate-400">
{formatDate(project.startedAt)} &rarr; {formatDate(project.endedAt)}
</span>
</div>
<div className="p-3 bg-[#090d16] rounded-xl border border-slate-800">
<span className="text-[10px] uppercase font-bold text-slate-500 block">Milestone Progress</span>
<div className="flex items-baseline gap-1.5 mt-0.5">
<span className="text-sm font-bold text-emerald-400">{milestoneProgress}%</span>
<span className="text-[10px] text-slate-400">({verifiedMilestonesCount}/{milestones.length})</span>
</div>
<div className="w-full h-1.5 bg-slate-900 rounded-full mt-1.5 overflow-hidden">
<div
className="h-full bg-emerald-500 rounded-full transition-all"
style={{ width: `${milestoneProgress}%` }}
/>
</div> </div>
</div> </div>
<div className="p-3 bg-[#090d16] rounded-xl border border-slate-800"> {/* BOTTOM NAVIGATION ARROWS (EXACTLY WHERE RED ARROWS WERE IN USER'S SCREENSHOT) */}
<span className="text-[10px] uppercase font-bold text-slate-500 block">Work Breakdown Cost</span> {/* Bottom Left Navigation Arrow Button */}
<span className="text-sm font-bold text-cyan-400 mt-0.5 block font-mono"> <div className="absolute bottom-3.5 left-4 z-20">
{formatZAR(totalActivityBudget)} <button
</span> type="button"
<span className="text-[10px] text-slate-400">{activities.length} total tasks</span> onClick={handleScrollLeft}
aria-label="Shift timeline left"
title="Shift timeline left (360px)"
className="flex items-center gap-1.5 px-3 py-1.5 rounded-xl bg-slate-900/95 hover:bg-blue-600 text-slate-200 hover:text-white border border-slate-700 hover:border-blue-500 shadow-2xl backdrop-blur-md transition-all cursor-pointer group active:scale-95"
>
<ChevronLeft className="w-4 h-4 text-blue-400 group-hover:text-white group-hover:-translate-x-0.5 transition-transform" />
<span className="text-[11px] font-bold uppercase tracking-wider">Earlier</span>
</button>
</div> </div>
<div className="p-3 bg-[#090d16] rounded-xl border border-slate-800"> {/* Bottom Right Navigation Arrow Button */}
<span className="text-[10px] uppercase font-bold text-slate-500 block">Governance State</span> <div className="absolute bottom-3.5 right-4 z-20">
<div className="mt-1"> <button
{(() => { type="button"
const b = getApprovalStatusBadge(project.status); onClick={handleScrollRight}
return ( aria-label="Shift timeline right"
<span className={`inline-flex items-center px-2 py-0.5 rounded-full text-[10px] font-semibold border ${b.className}`}> title="Shift timeline right (360px)"
{b.label} className="flex items-center gap-1.5 px-3 py-1.5 rounded-xl bg-slate-900/95 hover:bg-blue-600 text-slate-200 hover:text-white border border-slate-700 hover:border-blue-500 shadow-2xl backdrop-blur-md transition-all cursor-pointer group active:scale-95"
</span> >
); <span className="text-[11px] font-bold uppercase tracking-wider">Later</span>
})()} <ChevronRight className="w-4 h-4 text-blue-400 group-hover:text-white group-hover:translate-x-0.5 transition-transform" />
</div> </button>
<span className="text-[10px] text-slate-500 mt-0.5 block">Statutory Clearance</span>
</div>
{/* Total Value of Approved Project Budgets Widget */}
<div className="p-3 bg-[#090d16] rounded-xl border border-slate-800">
<span className="text-[10px] uppercase font-bold text-slate-500 block">Approved Budget Value</span>
<span className="text-sm font-bold text-emerald-400 mt-0.5 block font-mono">
{formatZAR(approvedBudgetTotal)}
</span>
<span className="text-[10px] text-slate-400">
{approvedBudgetsCount > 0
? `${approvedBudgetsCount} approved budget line${approvedBudgetsCount === 1 ? '' : 's'}`
: 'Allocated Project Budget'}
</span>
</div> </div>
</div> </div>
</div> </div>
@@ -0,0 +1,763 @@
import React, { useMemo } from 'react';
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
import { Link } from 'react-router-dom';
import {
FolderKanban,
Flag,
Briefcase,
Target,
ShieldAlert,
FileCheck,
Edit2,
Calendar,
Clock,
Coins,
ChevronRight,
TrendingUp,
AlertTriangle,
AlertCircle,
CheckCircle2,
RotateCcw,
Sparkles,
ExternalLink,
SlidersHorizontal,
Lock,
} from 'lucide-react';
import { Modal } from '../ui/Modal';
import { HelpTooltip } from '../ui/HelpTooltip';
import { useToast } from '../ui/Toast';
import { useRbac } from '../../auth/rbac';
import {
getActivities,
getMilestones,
getProjectIndicators,
getProjectRisks,
getBudgets,
getDisbursements,
updateProjectStatus,
} from '../../services/api';
import { Project, ApprovalStatus, Programme } from '../../types';
import {
formatDate,
formatZAR,
getApprovalStatusBadge,
getPriorityBadge,
getRiskScoreColor,
getUserEmail,
getIndustrySectorLabel,
getProjectTypeLabel,
APPROVAL_STATUS_OPTIONS,
} from '../../lib/formatters';
import { ProjectTimelineGantt } from './ProjectTimelineGantt';
import { evaluateProjectScheduleRisk } from '../../pages/ProjectsPage';
interface ProjectViewModalProps {
isOpen: boolean;
onClose: () => void;
project: Project | null;
programmeMap: Map<string | number, Programme>;
onOpenMilestonesModal: () => void;
onOpenActivitiesModal: () => void;
onOpenIndicatorsModal: () => void;
onOpenRisksModal: () => void;
onOpenEditProject: () => void;
}
export const ProjectViewModal: React.FC<ProjectViewModalProps> = ({
isOpen,
onClose,
project,
programmeMap,
onOpenMilestonesModal,
onOpenActivitiesModal,
onOpenIndicatorsModal,
onOpenRisksModal,
onOpenEditProject,
}) => {
const queryClient = useQueryClient();
const { success, error: toastError } = useToast();
const { canAdd, canApprove, canApproveRecord, isMaker } = useRbac();
const projectId = project?.id;
// Queries for project resources
const {
data: activities = [],
isLoading: loadingActivities,
refetch: refetchActivities,
} = useQuery({
queryKey: ['activities', projectId],
queryFn: () => (projectId ? getActivities(projectId) : Promise.resolve([])),
enabled: isOpen && !!projectId,
});
const {
data: milestones = [],
isLoading: loadingMilestones,
refetch: refetchMilestones,
} = useQuery({
queryKey: ['milestones', projectId],
queryFn: () => (projectId ? getMilestones(projectId) : Promise.resolve([])),
enabled: isOpen && !!projectId,
});
const {
data: indicators = [],
isLoading: loadingIndicators,
refetch: refetchIndicators,
} = useQuery({
queryKey: ['indicators', projectId],
queryFn: () => (projectId ? getProjectIndicators(projectId) : Promise.resolve([])),
enabled: isOpen && !!projectId,
});
const {
data: risks = [],
isLoading: loadingRisks,
refetch: refetchRisks,
} = useQuery({
queryKey: ['risks', projectId],
queryFn: () => (projectId ? getProjectRisks(projectId) : Promise.resolve([])),
enabled: isOpen && !!projectId,
});
const { data: allBudgets = [] } = useQuery({
queryKey: ['budgets'],
queryFn: () => getBudgets(1, 100),
enabled: isOpen && !!projectId,
});
// Governance Status Mutation
const statusMutation = useMutation({
mutationFn: ({ projectId, status }: { projectId: number | string; status: ApprovalStatus }) =>
updateProjectStatus({ projectId, status }),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['projects'] });
success('Status Updated', 'Project governance status updated successfully');
},
onError: (err: unknown) => {
toastError('Failed to update status', err);
},
});
// Calculate budgets
const projectBudgets = allBudgets.filter((b) => String(b.projectId) === String(projectId));
const approvedBudgets = projectBudgets.filter(
(b) =>
b.status === undefined ||
b.status === null ||
Number(b.status) === ApprovalStatus.Approved ||
Number(b.status) === 3
);
const approvedBudgetTotal = approvedBudgets.reduce((sum, b) => sum + (Number(b.amount) || 0), 0);
// 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;
return evaluateProjectScheduleRisk(project, { total: milestones.length, verified: verifiedMilestonesCount });
}, [project, milestones.length, verifiedMilestonesCount]);
// Total Duration
const durationDays = useMemo(() => {
if (!project?.startedAt || !project?.endedAt) return 0;
return Math.max(
1,
Math.round(
(new Date(project.endedAt).getTime() - new Date(project.startedAt).getTime()) /
(1000 * 60 * 60 * 24)
)
);
}, [project?.startedAt, project?.endedAt]);
const prog = project ? programmeMap.get(project.programmeId) : null;
if (!project) return null;
const canApproveThis = canApproveRecord('projects_milestones', project.createdBy);
const isSelf = isMaker(project.createdBy);
const canModifyStatus = canAdd('projects_milestones') || canApprove('projects_milestones');
const handleRefreshAll = () => {
refetchActivities();
refetchMilestones();
refetchIndicators();
refetchRisks();
};
return (
<Modal
isOpen={isOpen}
onClose={onClose}
title=""
size="full-screen"
zIndex="z-[9990]"
>
<div className="space-y-6 -mt-3 text-slate-200">
{/* Top Header Banner: Project Title, ID, Sector, Status, and Primary Actions */}
<div className="bg-gradient-to-r from-[#0d1322] via-[#0f172a] to-[#0d1322] p-5 sm:p-6 rounded-2xl border border-slate-800 shadow-xl relative overflow-hidden">
<div className="absolute -right-8 -bottom-8 w-48 h-48 bg-blue-600/10 rounded-full blur-2xl pointer-events-none" />
<div className="flex flex-col lg:flex-row lg:items-center justify-between gap-4 relative z-10">
{/* Project Meta Info */}
<div className="space-y-2 min-w-0 flex-1">
<div className="flex items-center gap-2.5 flex-wrap">
<span className="text-xs font-mono font-bold text-blue-400 bg-blue-500/15 px-2.5 py-0.5 rounded-lg border border-blue-500/30">
Project #{project.id}
</span>
<span className="text-xs font-semibold text-slate-300 bg-slate-900/90 px-2.5 py-0.5 rounded-lg border border-slate-700/80">
{getIndustrySectorLabel(project.sector)}
</span>
<span className="text-xs text-slate-400">
{getProjectTypeLabel(project.projectType)}
</span>
<span className="text-slate-500">&bull;</span>
<span className="text-xs text-slate-300">
Programme:{' '}
<strong className="text-blue-300">
{prog?.name || `Prog #${project.programmeId}`}
</strong>
</span>
</div>
<h2 className="text-xl sm:text-2xl font-bold text-white tracking-tight leading-snug">
{project.name || `Project #${project.id}`}
</h2>
<div className="flex items-center gap-3 text-xs text-slate-400 flex-wrap pt-0.5">
<span>
Owner:{' '}
<strong className="text-slate-200">{getUserEmail(project.createdBy)}</strong>
</span>
<span className="text-slate-600">&bull;</span>
<div className="flex items-center gap-1 font-mono text-slate-300">
<Calendar className="w-3.5 h-3.5 text-blue-400 shrink-0" />
<span>
{formatDate(project.startedAt)} &rarr; {formatDate(project.endedAt)} ({durationDays} Days)
</span>
</div>
<span className="text-slate-600">&bull;</span>
<span className="text-emerald-400 font-mono font-bold">
Budget: {formatZAR(approvedBudgetTotal)}
</span>
<span className="text-slate-600">&bull;</span>
<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 ${
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: ${formatZAR(approvedBudgetTotal)} | Net Project Value: ${formatZAR(projectValue)} (Activities: ${formatZAR(totalActivityBudget)} - Disbursements: ${formatZAR(totalDisbursed)}) | Variance: ${projectVariance >= 0 ? '+' : ''}${formatZAR(projectVariance)}`}
>
Variance: {projectVariance >= 0 ? '+' : ''}
{formatZAR(projectVariance)}
</span>
</div>
</div>
{/* Status & Action Controls */}
<div className="flex flex-col sm:flex-row items-start sm:items-center gap-3 shrink-0">
{/* Schedule Risk Pill */}
{scheduleRisk && (
<div className="flex items-center gap-1.5">
<span
className={`inline-flex items-center gap-1.5 px-3 py-1.5 rounded-xl text-xs font-bold border ${scheduleRisk.badgeClass}`}
title={scheduleRisk.reason}
>
{scheduleRisk.isOverdue ? (
<AlertTriangle className="w-4 h-4 text-rose-400" />
) : scheduleRisk.isAtRisk ? (
<AlertCircle className="w-4 h-4 text-amber-400" />
) : (
<CheckCircle2 className="w-4 h-4 text-emerald-400" />
)}
<span>
{scheduleRisk.isOverdue
? 'Overdue Schedule'
: scheduleRisk.isAtRisk
? `At Risk (${scheduleRisk.daysRemaining}d)`
: 'On Track'}
</span>
</span>
<HelpTooltip
title="Schedule Risk Assessment"
content={scheduleRisk.reason}
size="sm"
placement="bottom"
/>
</div>
)}
{/* Real-time Governance Status Dropdown / Immutable Locked State */}
<div className="min-w-[140px]">
{Number(project.status) === ApprovalStatus.Approved ? (
<div
className="inline-flex items-center gap-1.5 px-3 py-2 rounded-xl text-xs font-bold bg-emerald-500/15 border border-emerald-500/30 text-emerald-300 shadow-sm cursor-default"
title="Approved records are immutable. Status cannot be modified."
>
<Lock className="w-3.5 h-3.5 text-emerald-400" />
<span>Approved (Locked)</span>
</div>
) : (
<select
value={project.status}
disabled={!canModifyStatus}
onChange={(e) =>
statusMutation.mutate({
projectId: project.id,
status: Number(e.target.value) as ApprovalStatus,
})
}
className="w-full text-xs font-semibold bg-[#090d16] border border-slate-700/90 rounded-xl px-2.5 py-2 text-slate-200 focus:outline-none focus:ring-2 focus:ring-blue-500 cursor-pointer disabled:opacity-60 disabled:cursor-not-allowed truncate shadow-md"
title="Project Governance Clearance Status"
>
{APPROVAL_STATUS_OPTIONS.map((opt) => {
const isApprovedOpt = opt.value === ApprovalStatus.Approved;
const isOptDisabled = isApprovedOpt && !canApproveThis;
let reasonText = '';
if (isApprovedOpt && !canApprove('projects_milestones')) {
reasonText = ' (Requires Update)';
} else if (isApprovedOpt && isSelf) {
reasonText = ' (Maker-Checker)';
}
return (
<option key={opt.value} value={opt.value} disabled={isOptDisabled}>
{opt.label}{reasonText}
</option>
);
})}
</select>
)}
</div>
{/* Action Buttons */}
<div className="flex items-center gap-2">
<Link
to={`/monitoring?tab=evidence&projectId=${project.id}`}
className="inline-flex items-center gap-1.5 px-3 py-2 text-xs font-semibold text-blue-300 hover:text-white bg-blue-500/10 hover:bg-blue-500/20 border border-blue-500/30 rounded-xl transition cursor-pointer shadow-xs"
title="Review Evidence Queue"
>
<FileCheck className="w-3.5 h-3.5 text-blue-400" />
<span className="hidden sm:inline">Evidence Queue</span>
</Link>
<button
type="button"
onClick={onOpenEditProject}
className="inline-flex items-center gap-1.5 px-3 py-2 text-xs font-semibold text-slate-300 hover:text-white bg-slate-800 hover:bg-slate-700 border border-slate-700 rounded-xl transition cursor-pointer shadow-xs"
title="Edit Project Scope & Schedule"
>
<Edit2 className="w-3.5 h-3.5 text-slate-400" />
<span className="hidden sm:inline">Edit Scope</span>
</button>
<button
type="button"
onClick={handleRefreshAll}
className="p-2 rounded-xl text-blue-400 hover:text-blue-300 bg-slate-900/80 hover:bg-slate-800 border border-slate-700/80 transition cursor-pointer shadow-xs"
title="Refresh project datasets"
>
<RotateCcw className="w-4 h-4" />
</button>
</div>
</div>
</div>
{/* Project Narrative */}
{project.description && (
<div className="mt-3.5 pt-3 border-t border-slate-800/80 text-xs text-slate-300 leading-relaxed max-w-5xl">
<span className="font-semibold text-slate-400 mr-1.5">Scope Narrative:</span>
{project.description}
</div>
)}
</div>
{/* 5 Vital Project KPI Metrics Bar */}
<div className="grid grid-cols-2 sm:grid-cols-3 xl:grid-cols-5 gap-3.5">
{/* Project Duration */}
<div className="p-3.5 bg-[#0d121f] rounded-2xl border border-slate-800 shadow-md">
<div className="flex items-center justify-between text-slate-400">
<span className="text-[10px] uppercase font-bold tracking-wider">Project Duration</span>
<Calendar className="w-3.5 h-3.5 text-blue-400" />
</div>
<span className="text-base font-bold text-white mt-1 block font-mono">
{durationDays} Days
</span>
<span className="text-[10px] text-slate-400 block truncate mt-0.5">
{formatDate(project.startedAt)} &rarr; {formatDate(project.endedAt)}
</span>
</div>
{/* Milestone Progress */}
<div className="p-3.5 bg-[#0d121f] rounded-2xl border border-slate-800 shadow-md">
<div className="flex items-center justify-between text-slate-400">
<span className="text-[10px] uppercase font-bold tracking-wider">Milestone Progress</span>
<Flag className="w-3.5 h-3.5 text-emerald-400" />
</div>
<div className="flex items-baseline gap-2 mt-1">
<span className="text-base font-bold text-emerald-400">{milestoneProgress}%</span>
<span className="text-xs text-slate-400">
({verifiedMilestonesCount}/{milestones.length} Verified)
</span>
</div>
<div className="w-full h-1.5 bg-slate-900 rounded-full mt-2 overflow-hidden border border-slate-800">
<div
className="h-full bg-emerald-500 rounded-full transition-all duration-300"
style={{ width: `${milestoneProgress}%` }}
/>
</div>
</div>
{/* Work Breakdown Cost */}
<div className="p-3.5 bg-[#0d121f] rounded-2xl border border-slate-800 shadow-md">
<div className="flex items-center justify-between text-slate-400">
<span className="text-[10px] uppercase font-bold tracking-wider">Work Breakdown Cost</span>
<Coins className="w-3.5 h-3.5 text-cyan-400" />
</div>
<span className="text-base font-bold text-cyan-400 mt-1 block font-mono">
{formatZAR(totalActivityBudget)}
</span>
<span className="text-[10px] text-slate-400 block truncate mt-0.5">
{activities.length} total recorded tasks
</span>
</div>
{/* Governance State */}
<div className="p-3.5 bg-[#0d121f] rounded-2xl border border-slate-800 shadow-md">
<div className="flex items-center justify-between text-slate-400">
<span className="text-[10px] uppercase font-bold tracking-wider">Governance State</span>
<CheckCircle2 className="w-3.5 h-3.5 text-indigo-400" />
</div>
<div className="mt-1">
{(() => {
const b = getApprovalStatusBadge(project.status);
return (
<span
className={`inline-flex items-center px-2.5 py-0.5 rounded-full text-[11px] font-semibold border ${b.className}`}
>
{b.label}
</span>
);
})()}
</div>
<span className="text-[10px] text-slate-500 mt-1 block">Statutory Clearance Gate</span>
</div>
{/* Approved Budget Total */}
<div className="p-3.5 bg-[#0d121f] rounded-2xl border border-slate-800 shadow-md">
<div className="flex items-center justify-between text-slate-400">
<span className="text-[10px] uppercase font-bold tracking-wider">Approved Budget Value</span>
<TrendingUp className="w-3.5 h-3.5 text-emerald-400" />
</div>
<span className="text-base font-bold text-emerald-400 mt-1 block font-mono">
{formatZAR(approvedBudgetTotal)}
</span>
<span className="text-[10px] text-slate-400 block truncate mt-0.5">
{approvedBudgets.length > 0
? `${approvedBudgets.length} approved budget line${approvedBudgets.length === 1 ? '' : 's'}`
: 'Allocated Project Budget'}
</span>
</div>
</div>
{/* Enhanced Navigable Timeline Gantt Chart Component (Requirements 4, 5, 6) */}
<ProjectTimelineGantt
project={project}
milestones={milestones}
activities={activities}
approvedBudgetTotal={approvedBudgetTotal}
approvedBudgetsCount={approvedBudgets.length}
onManageMilestones={onOpenMilestonesModal}
onManageActivities={onOpenActivitiesModal}
/>
{/* 4 Vital Resource Summary Cards (Milestones, Activities, Indicators, Risks) */}
<div className="grid grid-cols-1 md:grid-cols-2 2xl:grid-cols-4 gap-4">
{/* Card 1: Milestones */}
<div className="bg-[#0d121f] rounded-2xl border border-slate-800 p-4 flex flex-col justify-between space-y-3 shadow-md">
<div className="space-y-3">
<div className="flex items-center justify-between border-b border-slate-800/80 pb-2.5">
<div className="flex items-center gap-2 text-xs font-bold text-white">
<Flag className="w-4 h-4 text-blue-400" />
<span>Contractual Milestones ({milestones.length})</span>
</div>
<span className="text-[10px] font-mono text-emerald-400 font-bold bg-emerald-500/10 px-2 py-0.5 rounded-full border border-emerald-500/30">
{verifiedMilestonesCount}/{milestones.length} Verified
</span>
</div>
{milestones.length === 0 ? (
<p className="text-xs text-slate-500 py-4 text-center">No contractual milestones defined.</p>
) : (
<div className="space-y-2">
{milestones.slice(0, 4).map((m) => {
const sBadge = getApprovalStatusBadge(m.status);
return (
<div
key={m.id}
className="p-2.5 rounded-xl bg-slate-900/80 border border-slate-800 text-xs space-y-1 hover:border-slate-700 transition"
>
<div className="flex items-start justify-between gap-2">
<span className="font-semibold text-slate-200 truncate block">{m.name}</span>
<span className={`shrink-0 text-[9px] px-1.5 py-0.2 rounded border ${sBadge.className}`}>
{sBadge.label}
</span>
</div>
<div className="text-[10px] text-slate-400 flex items-center justify-between font-mono">
<span className="flex items-center gap-1">
<Clock className="w-3 h-3 text-slate-500" />
Due: {formatDate(m.dueAt)}
</span>
<span className="text-slate-500">#{m.id}</span>
</div>
{(() => {
const mActivities = activities.filter((a) => String(a.milestoneId) === String(m.id));
const mValue = mActivities.reduce((sum, a) => sum + (Number(a.amount) || 0), 0);
return (
<div className="flex items-center justify-between text-[10px] font-mono pt-1 border-t border-slate-800/60">
<span className="text-slate-400">Milestone Value:</span>
<span className="font-bold text-blue-400">{formatZAR(mValue)}</span>
</div>
);
})()}
</div>
);
})}
{milestones.length > 4 && (
<p className="text-[10px] text-slate-400 text-center font-medium">
+{milestones.length - 4} more deliverables
</p>
)}
</div>
)}
</div>
<button
type="button"
onClick={onOpenMilestonesModal}
className="w-full py-2 px-3 text-xs font-semibold text-blue-400 bg-blue-500/10 hover:bg-blue-500/20 border border-blue-500/30 rounded-xl transition flex items-center justify-center gap-1.5 cursor-pointer shadow-xs"
>
<span>Manage Milestones & Deliverables</span>
<ChevronRight className="w-3.5 h-3.5" />
</button>
</div>
{/* Card 2: Activities */}
<div className="bg-[#0d121f] rounded-2xl border border-slate-800 p-4 flex flex-col justify-between space-y-3 shadow-md">
<div className="space-y-3">
<div className="flex items-center justify-between border-b border-slate-800/80 pb-2.5">
<div className="flex items-center gap-2 text-xs font-bold text-white">
<Briefcase className="w-4 h-4 text-cyan-400" />
<span>Work Activities ({activities.length})</span>
</div>
<span className="text-[10px] font-mono text-cyan-400 font-bold bg-cyan-500/10 px-2 py-0.5 rounded-full border border-cyan-500/30">
{formatZAR(totalActivityBudget)}
</span>
</div>
{activities.length === 0 ? (
<p className="text-xs text-slate-500 py-4 text-center">No work activities recorded.</p>
) : (
<div className="space-y-2">
{activities.slice(0, 4).map((act) => (
<div
key={act.id}
className="p-2.5 rounded-xl bg-slate-900/80 border border-slate-800 text-xs space-y-1 hover:border-slate-700 transition"
>
{(() => {
const actAmount = Number(act.amount) || 0;
const actIndicators = indicators.filter((ind) => String(ind.activityId) === String(act.id));
const actActuals = actIndicators.reduce((sum, ind) => sum + (Number(ind.actualCost) || 0), 0);
const actVariance = actAmount - actActuals;
const isPositive = actVariance >= 0;
return (
<>
<div className="flex items-start justify-between gap-2">
<span className="font-semibold text-slate-200 truncate block">{act.title}</span>
<div className="flex items-center gap-1.5 shrink-0 font-mono text-xs">
<span className="font-bold text-cyan-400">{formatZAR(actAmount)}</span>
<span
className={`text-[9px] font-bold px-1.5 py-0.2 rounded border ${
isPositive
? 'text-emerald-400 bg-emerald-500/10 border-emerald-500/30'
: 'text-rose-400 bg-rose-500/10 border-rose-500/30'
}`}
title={`Budgeted: ${formatZAR(actAmount)} | Incurred Actuals: ${formatZAR(actActuals)} | Variance: ${isPositive ? '+' : ''}${formatZAR(actVariance)}`}
>
{isPositive ? `+${formatZAR(actVariance)}` : `-${formatZAR(Math.abs(actVariance))}`}
</span>
</div>
</div>
</>
);
})()}
<div className="flex items-center justify-between text-[10px] text-slate-400">
<span>{act.category || 'Operations'}</span>
<span>{formatDate(act.performedAt)}</span>
</div>
</div>
))}
{activities.length > 4 && (
<p className="text-[10px] text-slate-400 text-center font-medium">
+{activities.length - 4} more tasks
</p>
)}
</div>
)}
</div>
<button
type="button"
onClick={onOpenActivitiesModal}
className="w-full py-2 px-3 text-xs font-semibold text-cyan-400 bg-cyan-500/10 hover:bg-cyan-500/20 border border-cyan-500/30 rounded-xl transition flex items-center justify-center gap-1.5 cursor-pointer shadow-xs"
>
<span>Manage Work Activities</span>
<ChevronRight className="w-3.5 h-3.5" />
</button>
</div>
{/* Card 3: Indicators */}
<div className="bg-[#0d121f] rounded-2xl border border-slate-800 p-4 flex flex-col justify-between space-y-3 shadow-md">
<div className="space-y-3">
<div className="flex items-center justify-between border-b border-slate-800/80 pb-2.5">
<div className="flex items-center gap-2 text-xs font-bold text-white">
<Target className="w-4 h-4 text-indigo-400" />
<span>M&E Indicators ({indicators.length})</span>
</div>
<span className="text-[10px] font-mono text-indigo-400 font-bold bg-indigo-500/10 px-2 py-0.5 rounded-full border border-indigo-500/30">
Results Chain
</span>
</div>
{indicators.length === 0 ? (
<p className="text-xs text-slate-500 py-4 text-center">No M&E indicators defined.</p>
) : (
<div className="space-y-2">
{indicators.slice(0, 4).map((ind) => {
const target = Number(ind.targetAmount) || 1;
const actual = Number(ind.actualAmount) || 0;
const pct = Math.round((actual / target) * 100);
return (
<div
key={ind.id}
className="p-2.5 rounded-xl bg-slate-900/80 border border-slate-800 text-xs space-y-1 hover:border-slate-700 transition"
>
<div className="flex items-start justify-between gap-2">
<span className="font-semibold text-slate-200 truncate block">{ind.name}</span>
<span
className={
pct >= 100 ? 'text-emerald-400 font-bold' : 'text-indigo-400 font-bold'
}
>
{pct}%
</span>
</div>
<div className="flex items-center justify-between text-[10px] text-slate-400">
<span>
Actual: <strong className="text-slate-200 font-mono">{actual}</strong>
</span>
<span>
Target: <strong className="text-slate-200 font-mono">{target}</strong>
</span>
</div>
</div>
);
})}
{indicators.length > 4 && (
<p className="text-[10px] text-slate-400 text-center font-medium">
+{indicators.length - 4} more indicators
</p>
)}
</div>
)}
</div>
<button
type="button"
onClick={onOpenIndicatorsModal}
className="w-full py-2 px-3 text-xs font-semibold text-indigo-400 bg-indigo-500/10 hover:bg-indigo-500/20 border border-indigo-500/30 rounded-xl transition flex items-center justify-center gap-1.5 cursor-pointer shadow-xs"
>
<span>Manage M&E Indicators</span>
<ChevronRight className="w-3.5 h-3.5" />
</button>
</div>
{/* Card 4: Risks */}
<div className="bg-[#0d121f] rounded-2xl border border-slate-800 p-4 flex flex-col justify-between space-y-3 shadow-md">
<div className="space-y-3">
<div className="flex items-center justify-between border-b border-slate-800/80 pb-2.5">
<div className="flex items-center gap-2 text-xs font-bold text-white">
<ShieldAlert className="w-4 h-4 text-rose-400" />
<span>Risk Register ({risks.length})</span>
</div>
<span className="text-[10px] font-mono text-rose-400 font-bold bg-rose-500/10 px-2 py-0.5 rounded-full border border-rose-500/30">
ISO 31000
</span>
</div>
{risks.length === 0 ? (
<p className="text-xs text-slate-500 py-4 text-center">No project hazards logged.</p>
) : (
<div className="space-y-2">
{risks.slice(0, 4).map((rk) => {
const score = (rk.likelihood || 1) * (rk.impact || 1);
const riskInfo = getRiskScoreColor(rk.likelihood || 1, rk.impact || 1);
return (
<div
key={rk.id}
className="p-2.5 rounded-xl bg-slate-900/80 border border-slate-800 text-xs space-y-1 hover:border-slate-700 transition"
>
<div className="flex items-start justify-between gap-2">
<span className="font-semibold text-slate-200 truncate block">{rk.name}</span>
<span className={`text-[9px] px-1.5 py-0.2 rounded font-mono ${riskInfo.badge}`}>
{score}/25 {riskInfo.level}
</span>
</div>
<p className="text-[10px] text-slate-400 line-clamp-1">{rk.description}</p>
</div>
);
})}
{risks.length > 4 && (
<p className="text-[10px] text-slate-400 text-center font-medium">
+{risks.length - 4} more risks
</p>
)}
</div>
)}
</div>
<button
type="button"
onClick={onOpenRisksModal}
className="w-full py-2 px-3 text-xs font-semibold text-rose-400 bg-rose-500/10 hover:bg-rose-500/20 border border-rose-500/30 rounded-xl transition flex items-center justify-center gap-1.5 cursor-pointer shadow-xs"
>
<span>Manage Risk Register</span>
<ChevronRight className="w-3.5 h-3.5" />
</button>
</div>
</div>
</div>
</Modal>
);
};
@@ -1,5 +1,8 @@
import React, { Component, ErrorInfo, ReactNode } from 'react'; import React, { Component, ErrorInfo, ReactNode, useContext } from 'react';
import { Link } from 'react-router-dom';
import { AlertTriangle, RefreshCw, Home } from 'lucide-react'; import { AlertTriangle, RefreshCw, Home } from 'lucide-react';
import { AuthContext } from '../../auth/AuthProvider';
import { evaluateUserPermission } from '../../auth/rbac';
interface Props { interface Props {
children: ReactNode; children: ReactNode;
@@ -11,6 +14,70 @@ interface State {
error: Error | null; error: Error | null;
} }
interface FallbackContentProps {
fallbackTitle?: string;
error: Error | null;
onReset: () => void;
}
const ErrorBoundaryFallbackContent: React.FC<FallbackContentProps> = ({
fallbackTitle,
error,
onReset,
}) => {
const auth = useContext(AuthContext);
const roles = auth?.roles || [];
const claims = auth?.claims || {};
const isUnrestricted =
roles.some((r) => r.toLowerCase().trim() === 'admin') ||
claims?.unrestricted === true ||
claims?.unrestricted === 'all';
const canAccessDashboard = isUnrestricted || evaluateUserPermission(roles, claims, 'dashboard', 'view');
return (
<div className="min-h-[400px] flex items-center justify-center p-6">
<div className="max-w-md w-full bg-[#0f1523]/90 backdrop-blur-md rounded-2xl border border-rose-500/30 p-6 text-center shadow-2xl">
<div className="w-12 h-12 rounded-2xl bg-rose-500/10 border border-rose-500/20 text-rose-400 flex items-center justify-center mx-auto mb-4">
<AlertTriangle className="w-6 h-6" />
</div>
<h3 className="text-base font-bold text-white mb-2">
{fallbackTitle || 'Display Module Encountered an Issue'}
</h3>
<p className="text-xs text-slate-400 mb-4 leading-relaxed">
{canAccessDashboard
? 'An unexpected display error occurred while rendering this view. You can reload the view or return to the executive dashboard.'
: 'An unexpected display error occurred while rendering this view. Please try reloading the view.'}
</p>
{error?.message && (
<div className="mb-4 p-2.5 bg-rose-950/40 rounded-xl border border-rose-800/40 text-left text-[11px] text-rose-300 font-mono break-all max-h-24 overflow-y-auto">
{error.message}
</div>
)}
<div className="flex items-center justify-center gap-3">
<button
type="button"
onClick={onReset}
className="inline-flex items-center gap-1.5 px-4 py-2 rounded-xl text-xs font-semibold bg-slate-800 hover:bg-slate-700 text-white border border-slate-700 transition cursor-pointer"
>
<RefreshCw className="w-3.5 h-3.5 text-blue-400" />
<span>Reload View</span>
</button>
{canAccessDashboard && (
<Link
to="/dashboard"
className="inline-flex items-center gap-1.5 px-4 py-2 rounded-xl text-xs font-semibold bg-blue-600 hover:bg-blue-500 text-white transition cursor-pointer shadow-md shadow-blue-500/20"
>
<Home className="w-3.5 h-3.5" />
<span>Dashboard</span>
</Link>
)}
</div>
</div>
</div>
);
};
export class ErrorBoundary extends React.Component<Props, State> { export class ErrorBoundary extends React.Component<Props, State> {
declare props: Props; declare props: Props;
declare state: State; declare state: State;
@@ -32,42 +99,17 @@ export class ErrorBoundary extends React.Component<Props, State> {
} }
public handleReset = () => { public handleReset = () => {
(this as any).setState({ hasError: false, error: null }); this.setState({ hasError: false, error: null });
}; };
public render() { public render() {
if (this.state.hasError) { if (this.state.hasError) {
return ( return (
<div className="min-h-[400px] flex items-center justify-center p-6"> <ErrorBoundaryFallbackContent
<div className="max-w-md w-full bg-[#0f1523]/90 backdrop-blur-md rounded-2xl border border-rose-500/30 p-6 text-center shadow-2xl"> fallbackTitle={this.props.fallbackTitle}
<div className="w-12 h-12 rounded-2xl bg-rose-500/10 border border-rose-500/20 text-rose-400 flex items-center justify-center mx-auto mb-4"> error={this.state.error}
<AlertTriangle className="w-6 h-6" /> onReset={this.handleReset}
</div> />
<h3 className="text-base font-bold text-white mb-2">
{this.props.fallbackTitle || 'Display Module Encountered an Issue'}
</h3>
<p className="text-xs text-slate-400 mb-6 leading-relaxed">
An unexpected display error occurred while rendering this view. You can reload the view or return to the executive dashboard.
</p>
<div className="flex items-center justify-center gap-3">
<button
type="button"
onClick={this.handleReset}
className="inline-flex items-center gap-1.5 px-4 py-2 rounded-xl text-xs font-semibold bg-slate-800 hover:bg-slate-700 text-white border border-slate-700 transition cursor-pointer"
>
<RefreshCw className="w-3.5 h-3.5 text-blue-400" />
<span>Reload View</span>
</button>
<a
href="/dashboard"
className="inline-flex items-center gap-1.5 px-4 py-2 rounded-xl text-xs font-semibold bg-blue-600 hover:bg-blue-500 text-white transition cursor-pointer shadow-md shadow-blue-500/20"
>
<Home className="w-3.5 h-3.5" />
<span>Dashboard</span>
</a>
</div>
</div>
</div>
); );
} }
@@ -33,9 +33,9 @@ export function ExportButton<T>({
const dropdownRef = useRef<HTMLDivElement>(null); const dropdownRef = useRef<HTMLDivElement>(null);
const menuRef = useRef<HTMLDivElement>(null); const menuRef = useRef<HTMLDivElement>(null);
const { user, claims, tenantId } = useAuth(); const { user, claims, tenantId } = useAuth();
const { canExport } = useRbac(); const { canExport, isUnrestricted } = useRbac();
const isClaimAllowed = claim ? canExport(claim) : true; const isClaimAllowed = claim ? canExport(claim) : isUnrestricted;
const isDisabled = !data || data.length === 0 || !isClaimAllowed; const isDisabled = !data || data.length === 0 || !isClaimAllowed;
const updatePosition = useCallback(() => { const updatePosition = useCallback(() => {
@@ -81,8 +81,8 @@ export function ExportButton<T>({
filename, filename,
columns, columns,
data, data,
tenantName: (claims as any)?.tenant_name || (claims as any)?.tenant || `Tenant #${tenantId || 1}`, tenantName: (claims?.tenant_name as string) || (claims?.tenant as string) || `Tenant #${tenantId || 1}`,
userEmail: (user as any)?.profile?.email || user?.email || (claims as any)?.email || '', userEmail: user?.email || (claims?.email as string) || '',
}); });
}; };
@@ -95,8 +95,8 @@ export function ExportButton<T>({
filename, filename,
columns, columns,
data, data,
tenantName: (claims as any)?.tenant_name || (claims as any)?.tenant || `Tenant #${tenantId || 1}`, tenantName: (claims?.tenant_name as string) || (claims?.tenant as string) || `Tenant #${tenantId || 1}`,
userEmail: (user as any)?.profile?.email || user?.email || (claims as any)?.email || '', userEmail: user?.email || (claims?.email as string) || '',
}); });
}; };
@@ -1,4 +1,5 @@
import React, { ReactNode, useEffect } from 'react'; import React, { ReactNode, useEffect } from 'react';
import { createPortal } from 'react-dom';
import { X } from 'lucide-react'; import { X } from 'lucide-react';
import { HelpTooltip } from './HelpTooltip'; import { HelpTooltip } from './HelpTooltip';
@@ -9,8 +10,9 @@ export interface ModalProps {
subtitle?: string; subtitle?: string;
helpText?: ReactNode; helpText?: ReactNode;
children: ReactNode; children: ReactNode;
maxWidth?: 'sm' | 'md' | 'lg' | 'xl' | '2xl' | '3xl' | '4xl' | '5xl' | '6xl' | 'full'; maxWidth?: 'sm' | 'md' | 'lg' | 'xl' | '2xl' | '3xl' | '4xl' | '5xl' | '6xl' | '7xl' | 'full-screen' | 'full';
size?: 'sm' | 'md' | 'lg' | 'xl' | '2xl' | '3xl' | '4xl' | '5xl' | '6xl' | 'full' | string; size?: 'sm' | 'md' | 'lg' | 'xl' | '2xl' | '3xl' | '4xl' | '5xl' | '6xl' | '7xl' | 'full-screen' | 'full' | string;
zIndex?: string;
} }
export const Modal: React.FC<ModalProps> = ({ export const Modal: React.FC<ModalProps> = ({
@@ -22,6 +24,7 @@ export const Modal: React.FC<ModalProps> = ({
children, children,
maxWidth = 'lg', maxWidth = 'lg',
size, size,
zIndex = 'z-[9999]',
}) => { }) => {
useEffect(() => { useEffect(() => {
const handleKeyDown = (e: KeyboardEvent) => { const handleKeyDown = (e: KeyboardEvent) => {
@@ -56,17 +59,24 @@ export const Modal: React.FC<ModalProps> = ({
'4xl': 'max-w-4xl', '4xl': 'max-w-4xl',
'5xl': 'max-w-5xl', '5xl': 'max-w-5xl',
'6xl': 'max-w-6xl', '6xl': 'max-w-6xl',
'7xl': 'max-w-7xl',
'full-screen': 'max-w-[96vw]',
full: 'max-w-full', full: 'max-w-full',
}; };
const maxWidthClass = maxWidthClassMap[effectiveSize] || 'max-w-lg'; const maxWidthClass = maxWidthClassMap[effectiveSize] || 'max-w-lg';
return ( const modalContent = (
<div className="fixed inset-0 z-50 flex items-center justify-center p-4 bg-black/80 backdrop-blur-sm animate-in fade-in duration-150"> <div
className={`fixed inset-0 ${zIndex} flex items-center justify-center p-4 bg-black/80 backdrop-blur-md animate-in fade-in duration-150 overflow-y-auto`}
onClick={onClose}
role="dialog"
aria-modal="true"
>
<div <div
className={`relative w-full ${maxWidthClass} bg-[#0f1523] rounded-2xl shadow-2xl border border-slate-800/90 overflow-hidden flex flex-col max-h-[90vh] text-slate-200`} className={`relative w-full ${maxWidthClass} bg-[#0f1523] rounded-2xl shadow-2xl border border-slate-800/90 overflow-hidden flex flex-col max-h-[90vh] my-auto text-slate-200`}
onClick={(e) => e.stopPropagation()} onClick={(e) => e.stopPropagation()}
> >
<div className="flex items-start justify-between p-5 border-b border-slate-800/80 bg-[#0d121f]/90"> <div className="flex items-start justify-between p-5 border-b border-slate-800/80 bg-[#0d121f]/90 shrink-0">
<div> <div>
<div className="flex items-center gap-2"> <div className="flex items-center gap-2">
<h3 className="text-lg font-semibold text-white tracking-tight">{title}</h3> <h3 className="text-lg font-semibold text-white tracking-tight">{title}</h3>
@@ -82,14 +92,20 @@ export const Modal: React.FC<ModalProps> = ({
{subtitle && <p className="text-xs text-slate-400 mt-0.5">{subtitle}</p>} {subtitle && <p className="text-xs text-slate-400 mt-0.5">{subtitle}</p>}
</div> </div>
<button <button
type="button"
onClick={onClose} onClick={onClose}
className="rounded-xl p-1.5 text-slate-400 hover:text-white hover:bg-slate-800/60 transition cursor-pointer" className="rounded-xl p-1.5 text-slate-400 hover:text-white hover:bg-slate-800/60 transition cursor-pointer"
aria-label="Close dialog"
> >
<X className="w-5 h-5" /> <X className="w-5 h-5" />
</button> </button>
</div> </div>
<div className="p-6 overflow-y-auto flex-1 bg-[#0f1523]">{children}</div> <div className="p-6 overflow-y-auto flex-1 bg-[#0f1523] min-w-0">{children}</div>
</div> </div>
</div> </div>
); );
return typeof document !== 'undefined'
? createPortal(modalContent, document.body)
: modalContent;
}; };
@@ -83,7 +83,7 @@ export const RiskHeatmapMatrix: React.FC<RiskHeatmapMatrixProps> = ({
const projectMap = useMemo(() => { const projectMap = useMemo(() => {
const map: Record<string, string> = {}; const map: Record<string, string> = {};
projects.forEach((p) => { projects.forEach((p) => {
map[String(p.id)] = p.name; map[String(p.id)] = p.name || '';
}); });
return map; return map;
}, [projects]); }, [projects]);
@@ -1,10 +1,11 @@
import React from 'react'; import React from 'react';
import { ApprovalStatus, Priority, ActivityStatus, UserRole } from '../../types'; import { ApprovalStatus, Priority, ActivityStatus, UserRole, ComplianceStatus } from '../../types';
import { import {
getApprovalStatusBadge, getApprovalStatusBadge,
getPriorityBadge, getPriorityBadge,
getActivityStatusBadge, getActivityStatusBadge,
getUserRoleBadge, getUserRoleBadge,
getComplianceStatusBadge,
} from '../../lib/formatters'; } from '../../lib/formatters';
export const ApprovalStatusBadge: React.FC<{ status: ApprovalStatus | number | null | undefined }> = ({ status }) => { export const ApprovalStatusBadge: React.FC<{ status: ApprovalStatus | number | null | undefined }> = ({ status }) => {
@@ -17,6 +18,16 @@ export const ApprovalStatusBadge: React.FC<{ status: ApprovalStatus | number | n
); );
}; };
export const ComplianceStatusBadge: React.FC<{ status: ComplianceStatus | number | null | undefined }> = ({ status }) => {
const badge = getComplianceStatusBadge(status ?? ComplianceStatus.NotMet);
return (
<span className={`inline-flex items-center gap-1.5 px-2.5 py-0.5 rounded-full text-[11px] font-semibold border whitespace-nowrap ${badge.className}`}>
{badge.dot && <span className={`w-1.5 h-1.5 rounded-full ${badge.dot}`} />}
<span>{badge.label}</span>
</span>
);
};
export const PriorityBadge: React.FC<{ priority: Priority | number | null | undefined }> = ({ priority }) => { export const PriorityBadge: React.FC<{ priority: Priority | number | null | undefined }> = ({ priority }) => {
const badge = getPriorityBadge(priority ?? Priority.Medium); const badge = getPriorityBadge(priority ?? Priority.Medium);
return ( return (
@@ -1,4 +1,5 @@
import React, { createContext, useContext, useState, useEffect, useRef, ReactNode } from 'react'; import React, { createContext, useContext, useState, useEffect, useRef, ReactNode } from 'react';
import { createPortal } from 'react-dom';
import { import {
CheckCircle2, CheckCircle2,
AlertCircle, AlertCircle,
@@ -111,12 +112,12 @@ const ToastCard: React.FC<{
<div <div
onMouseEnter={() => setIsPaused(true)} onMouseEnter={() => setIsPaused(true)}
onMouseLeave={() => setIsPaused(false)} onMouseLeave={() => setIsPaused(false)}
className={`pointer-events-auto relative flex flex-col rounded-xl border shadow-2xl transition-all duration-200 overflow-hidden bg-[#111622] ${ className={`pointer-events-auto relative flex flex-col rounded-2xl border shadow-2xl transition-all duration-200 overflow-hidden bg-[#0d1322] backdrop-blur-none ${
toast.type === 'success' toast.type === 'success'
? 'border-emerald-500/30 text-gray-200' ? 'border-emerald-500/60 text-gray-100 shadow-emerald-950/40 ring-1 ring-emerald-500/20'
: toast.type === 'error' : toast.type === 'error'
? 'border-rose-500/40 text-gray-200 shadow-rose-950/20' ? 'border-rose-500/70 text-gray-100 shadow-rose-950/60 ring-1 ring-rose-500/30'
: 'border-blue-500/30 text-gray-200' : 'border-blue-500/60 text-gray-100 shadow-blue-950/40 ring-1 ring-blue-500/20'
}`} }`}
> >
<div className="flex items-start gap-3 p-4"> <div className="flex items-start gap-3 p-4">
@@ -360,18 +361,23 @@ export const ToastProvider: React.FC<{ children: ReactNode }> = ({ children }) =
}); });
}; };
const toastRegion = (
<div
role="region"
aria-label="System Notifications"
className="fixed bottom-5 right-5 z-[999999] flex flex-col gap-3 max-w-md sm:max-w-lg w-full pointer-events-none p-2"
style={{ zIndex: 999999 }}
>
{toasts.map((t) => (
<ToastCard key={t.id} toast={t} onRemove={removeToast} />
))}
</div>
);
return ( return (
<ToastContext.Provider value={{ toast: addToast, success, error, info }}> <ToastContext.Provider value={{ toast: addToast, success, error, info }}>
{children} {children}
<div {typeof document !== 'undefined' ? createPortal(toastRegion, document.body) : null}
role="region"
aria-label="System Notifications"
className="fixed bottom-4 right-4 z-50 flex flex-col gap-3 max-w-lg w-full pointer-events-none p-2"
>
{toasts.map((t) => (
<ToastCard key={t.id} toast={t} onRemove={removeToast} />
))}
</div>
</ToastContext.Provider> </ToastContext.Provider>
); );
}; };
@@ -6,10 +6,17 @@ import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
import { UserPlus, Shield, Hash, Mail, Phone, Lock, Building, CheckCircle2 } from 'lucide-react'; import { UserPlus, Shield, Hash, Mail, Phone, Lock, Building, CheckCircle2 } from 'lucide-react';
import { Modal } from '../ui/Modal'; import { Modal } from '../ui/Modal';
import { useToast } from '../ui/Toast'; import { useToast } from '../ui/Toast';
import { createUser, getSystemRoles, assignUserRole, getTenants, getUsers } from '../../services/api'; import {
createUser,
getSystemRoles,
assignUserRole,
getTenants,
getUsers,
notifyNewUserCreated,
} from '../../services/api';
import { useAuth } from '../../auth/AuthProvider'; import { useAuth } from '../../auth/AuthProvider';
import { useRbac } from '../../auth/rbac'; import { useRbac } from '../../auth/rbac';
import { CreateUserRequest, Role, Tenant } from '../../types'; import { CreateUserRequest, Role, Tenant, User } from '../../types';
const createUserSchema = z.object({ const createUserSchema = z.object({
tenantId: z.string().min(1, 'Tenant is required'), tenantId: z.string().min(1, 'Tenant is required'),
@@ -22,8 +29,8 @@ const createUserSchema = z.object({
userName: z.string().optional(), userName: z.string().optional(),
phoneNumber: z.string().optional(), phoneNumber: z.string().optional(),
initialRoleId: z.string().optional(), initialRoleId: z.string().optional(),
lockoutEnabled: z.boolean().default(false), lockoutEnabled: z.boolean(),
twoFactorEnabled: z.boolean().default(false), twoFactorEnabled: z.boolean(),
}); });
type CreateUserFormValues = z.infer<typeof createUserSchema>; type CreateUserFormValues = z.infer<typeof createUserSchema>;
@@ -98,7 +105,9 @@ export const CreateUserModal: React.FC<CreateUserModalProps> = ({
// Filter out Admin role if user has access to tenant self-help (Requirement 1) // Filter out Admin role if user has access to tenant self-help (Requirement 1)
const assignableRoles = React.useMemo(() => { const assignableRoles = React.useMemo(() => {
return systemRoles.filter((r) => { return systemRoles.filter((r) => {
const isRoleAdmin = r.name.toLowerCase() === 'admin' || r.id === '1'; const rName = r.name || r.Name || '';
const rId = r.id || r.Id || '';
const isRoleAdmin = rName.toLowerCase() === 'admin' || rId === '1';
if (isRoleAdmin && hasTenantSelfHelpAccess) { if (isRoleAdmin && hasTenantSelfHelpAccess) {
return false; return false;
} }
@@ -117,15 +126,22 @@ export const CreateUserModal: React.FC<CreateUserModalProps> = ({
mutationFn: async (values: CreateUserFormValues) => { mutationFn: async (values: CreateUserFormValues) => {
// Security enforcement: Users with tenant self-help access cannot create admin users // Security enforcement: Users with tenant self-help access cannot create admin users
if (values.initialRoleId && hasTenantSelfHelpAccess) { if (values.initialRoleId && hasTenantSelfHelpAccess) {
const selectedRole = systemRoles.find((r) => r.id === values.initialRoleId); const selectedRole = systemRoles.find((r) => (r.id || r.Id) === values.initialRoleId);
if (selectedRole && (selectedRole.name.toLowerCase() === 'admin' || selectedRole.id === '1')) { const selName = selectedRole?.name || selectedRole?.Name || '';
const selId = selectedRole?.id || selectedRole?.Id || '';
if (selectedRole && (selName.toLowerCase() === 'admin' || selId === '1')) {
throw new Error('Users who have access to tenant self-help are restricted from creating Admin users.'); throw new Error('Users who have access to tenant self-help are restricted from creating Admin users.');
} }
} }
const rawEmail = values.email.trim().toLowerCase();
const sanitizedUserName = values.userName?.trim()
? values.userName.trim().toLowerCase().replace(/\s+/g, '.').replace(/[^a-z0-9_.\-@+]/g, '')
: rawEmail.split('@')[0];
const payload: CreateUserRequest = { const payload: CreateUserRequest = {
email: values.email.trim(), email: rawEmail,
userName: values.userName?.trim() || values.email.split('@')[0], userName: sanitizedUserName || rawEmail,
emailConfirmed: true, emailConfirmed: true,
phoneNumber: values.phoneNumber?.trim() || null, phoneNumber: values.phoneNumber?.trim() || null,
phoneNumberConfirmed: false, phoneNumberConfirmed: false,
@@ -136,22 +152,40 @@ export const CreateUserModal: React.FC<CreateUserModalProps> = ({
await createUser(values.tenantId, values.idNumber.trim(), payload); await createUser(values.tenantId, values.idNumber.trim(), payload);
if (values.initialRoleId) { let createdUser: User | undefined;
try { try {
await queryClient.invalidateQueries({ queryKey: ['users-directory'] }); await queryClient.invalidateQueries({ queryKey: ['users-directory'] });
await queryClient.invalidateQueries({ queryKey: ['users'] }); await queryClient.invalidateQueries({ queryKey: ['users'] });
const freshUsers = await getUsers(1, 1000, { email: values.email.trim() }); const freshUsers = await getUsers(1, 1000, { email: values.email.trim() });
const createdUser = freshUsers.find( createdUser = freshUsers.find(
(u) => u.email && u.email.toLowerCase() === values.email.trim().toLowerCase() (u) => u.email && u.email.toLowerCase() === values.email.trim().toLowerCase()
); );
if (createdUser) { if (createdUser && values.initialRoleId) {
await assignUserRole(createdUser.id, values.initialRoleId); await assignUserRole(createdUser.id, values.initialRoleId);
}
} catch {
// Graceful fallback
} }
} catch {
// Graceful fallback
} }
// RULE 3: Issue InApp and Email message directing new user to appropriate portal
const selectedRole = systemRoles.find((r) => (r.id || r.Id) === values.initialRoleId);
const roleName = selectedRole?.name || selectedRole?.Name || '';
const isPartner =
roleName.toLowerCase().trim() === 'partner' ||
String(values.initialRoleId || '').toLowerCase().trim() === 'partner';
const targetUserId = createdUser?.id || values.idNumber.trim();
await notifyNewUserCreated({
userId: targetUserId,
email: rawEmail,
isPartner,
roleName,
tenantId: values.tenantId,
}).catch(console.warn);
queryClient.invalidateQueries({ queryKey: ['inapp-notifications'] });
queryClient.invalidateQueries({ queryKey: ['notifications'] });
return values; return values;
}, },
onSuccess: (values) => { onSuccess: (values) => {
@@ -160,7 +194,7 @@ export const CreateUserModal: React.FC<CreateUserModalProps> = ({
queryClient.invalidateQueries({ queryKey: ['users-directory'] }); queryClient.invalidateQueries({ queryKey: ['users-directory'] });
onClose(); onClose();
}, },
onError: (err: any) => { onError: (err: unknown) => {
toastError('User Creation Failed', err); toastError('User Creation Failed', err);
}, },
}); });
@@ -281,11 +315,15 @@ export const CreateUserModal: React.FC<CreateUserModalProps> = ({
className="w-full bg-slate-900 border border-slate-800 rounded-xl px-3 py-2 text-white focus:outline-none focus:border-blue-500 disabled:opacity-50" className="w-full bg-slate-900 border border-slate-800 rounded-xl px-3 py-2 text-white focus:outline-none focus:border-blue-500 disabled:opacity-50"
> >
<option value="">Select Initial Role (Optional)</option> <option value="">Select Initial Role (Optional)</option>
{assignableRoles.map((r) => ( {assignableRoles.map((r) => {
<option key={r.id} value={r.id}> const roleId = r.id || r.Id || '';
{r.name} const roleLabel = r.name || r.Name || '';
</option> return (
))} <option key={roleId} value={roleId}>
{roleLabel}
</option>
);
})}
</select> </select>
{hasTenantSelfHelpAccess && ( {hasTenantSelfHelpAccess && (
<p className="text-[10px] text-amber-400 mt-1"> <p className="text-[10px] text-amber-400 mt-1">
@@ -24,19 +24,18 @@ import {
assignUserRole, assignUserRole,
removeUserRole, removeUserRole,
getUserClaims, getUserClaims,
addUserClaim,
deleteUserClaim,
forceLogoutUser, forceLogoutUser,
setUserLockout, setUserLockout,
updateUserStatus, updateUserStatus,
getSystemClaims,
} from '../../services/api'; } from '../../services/api';
import { clearCachedUserRbac, fetchAndCacheRoleClaims } from '../../services/roleClaimsCache';
import { HelpTooltip } from '../ui/HelpTooltip'; import { HelpTooltip } from '../ui/HelpTooltip';
import { ObfuscatedIdNumber } from '../ui/ObfuscatedIdNumber'; import { ObfuscatedIdNumber } from '../ui/ObfuscatedIdNumber';
import { decodeIdNumberHex } from '../../utils/hashids'; import { decodeIdNumberHex } from '../../utils/hashids';
import { useAuth } from '../../auth/AuthProvider';
import { User, Role, Claim, ActivityStatus } from '../../types'; import { User, Role, Claim, ActivityStatus } from '../../types';
import { formatDate, getActivityStatusBadge, getUserRoleBadge } from '../../lib/formatters'; import { formatDate, getActivityStatusBadge, getUserRoleBadge } from '../../lib/formatters';
import { useRbac, ROLE_CLAIM_DEFINITIONS, CLAIM_ACTION_OPTIONS } from '../../auth/rbac'; import { useRbac, ROLE_CLAIM_DEFINITIONS } from '../../auth/rbac';
interface UserSecurityModalProps { interface UserSecurityModalProps {
user: User | null; user: User | null;
@@ -51,14 +50,19 @@ export const UserSecurityModal: React.FC<UserSecurityModalProps> = ({
}) => { }) => {
const queryClient = useQueryClient(); const queryClient = useQueryClient();
const { success, error: toastError } = useToast(); const { success, error: toastError } = useToast();
const { user: currentAuthUser } = useAuth();
const { canApprove, canAdd, canView, canAccess, isUnrestricted } = useRbac(); const { canApprove, canAdd, canView, canAccess, isUnrestricted } = useRbac();
const hasTenantSelfHelpAccess = canAccess('tenant_self_service') || canView('tenant_self_service'); 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 = const canManageRolesAndClaims =
canApprove('settings_users') || canApprove('settings_users') ||
canApprove('tenant_management') || canApprove('tenant_management') ||
canApprove('tenant_self_service') || canApprove('tenant_self_service') ||
canAdd('tenant_self_service') ||
isUnrestricted; isUnrestricted;
const canManageSessions = const canManageSessions =
canApprove('settings_users') || canApprove('settings_users') ||
@@ -66,21 +70,17 @@ export const UserSecurityModal: React.FC<UserSecurityModalProps> = ({
hasTenantSelfHelpAccess || hasTenantSelfHelpAccess ||
isUnrestricted; isUnrestricted;
const canChangeStatus = const canChangeStatus =
canAdd('settings_users') || !isSelf &&
canAdd('tenant_management') || (canAdd('settings_users') ||
canAdd('tenant_self_service') || canAdd('tenant_management') ||
canApprove('settings_users') || canAdd('tenant_self_service') ||
isUnrestricted; canApprove('settings_users') ||
isUnrestricted);
const [activeTab, setActiveTab] = useState<'overview' | 'roles' | 'claims' | 'sessions'>('overview'); const [activeTab, setActiveTab] = useState<'overview' | 'roles' | 'claims' | 'sessions'>('overview');
// Claim creation form state
const [newClaimType, setNewClaimType] = useState('');
const [newClaimValue, setNewClaimValue] = useState('');
const [selectedRoleToAdd, setSelectedRoleToAdd] = useState(''); const [selectedRoleToAdd, setSelectedRoleToAdd] = useState('');
const userId = user?.id || '';
// 1. Fetch fresh user details // 1. Fetch fresh user details
const { data: freshUser, isLoading: loadingUser, refetch: refetchUser } = useQuery<User | null>({ const { data: freshUser, isLoading: loadingUser, refetch: refetchUser } = useQuery<User | null>({
queryKey: ['user-detail', userId], queryKey: ['user-detail', userId],
@@ -102,171 +102,217 @@ export const UserSecurityModal: React.FC<UserSecurityModalProps> = ({
enabled: isOpen, enabled: isOpen,
}); });
// 4. Fetch user claims // Effective role derivation for hooks (Single-role policy)
const { data: userClaims = [], isLoading: loadingUserClaims, refetch: refetchUserClaims } = useQuery<Claim[]>({ const rawRoleValue = freshUser?.role || user?.role;
queryKey: ['user-claims', userId], const userRoleStr = rawRoleValue != null ? String(rawRoleValue).trim() : '';
const assignedRole = React.useMemo(() => {
if (Array.isArray(userRoles) && userRoles.length > 0) {
return userRoles[0];
}
if (userRoleStr && Array.isArray(systemRoles)) {
const match = systemRoles.find(
(r) =>
r &&
((r.name && r.name.toLowerCase() === userRoleStr.toLowerCase()) ||
String(r.id) === userRoleStr)
);
if (match) return match;
return { id: userRoleStr, name: userRoleStr } as Role;
}
return null;
}, [userRoles, userRoleStr, systemRoles]);
const assignedRoleId = assignedRole?.id || assignedRole?.Id || '';
const assignedRoleName = assignedRole?.name || assignedRole?.Name || '';
// 4. Fetch claims derived from the user's role by hitting the endpoint
const {
data: roleClaims = [],
isLoading: loadingRoleClaims,
refetch: refetchRoleClaims,
} = useQuery<Claim[]>({
queryKey: ['role-claims-detail', assignedRoleId, assignedRoleName],
queryFn: async () => {
if (!assignedRoleId && !assignedRoleName) return [];
return await fetchAndCacheRoleClaims(String(assignedRoleId), assignedRoleName, true);
},
enabled: isOpen && (!!assignedRoleId || !!assignedRoleName),
});
// 5. Fetch direct user claims if any
const {
data: directClaims = [],
isLoading: loadingDirectClaims,
refetch: refetchDirectClaims,
} = useQuery<Claim[]>({
queryKey: ['user-direct-claims', userId],
queryFn: () => (userId ? getUserClaims(userId) : []), queryFn: () => (userId ? getUserClaims(userId) : []),
enabled: isOpen && !!userId, enabled: isOpen && !!userId,
}); });
// 5. Fetch all system claims across the identity management system (Requirement 4 & 7) // Combined claims with metadata
const { data: systemClaims = [], isLoading: loadingSystemClaims } = useQuery<Claim[]>({ const userEffectiveClaims = React.useMemo(() => {
queryKey: ['system-claims'], const map = new Map<string, Claim & { description?: string; label?: string; source: string }>();
queryFn: getSystemClaims,
enabled: isOpen,
});
// Role Mutations // 1. Role claims from endpoint
if (Array.isArray(roleClaims)) {
roleClaims.forEach((c) => {
if (!c) return;
const type = (c.claimType || c.type || '').trim();
if (!type) return;
const def = ROLE_CLAIM_DEFINITIONS.find((d) => d.claim.toLowerCase() === type.toLowerCase());
map.set(type.toLowerCase(), {
...c,
claimType: type,
claimValue: c.claimValue || c.value || 'all',
label: def?.label || type,
description: def?.description || 'Security claim granted via role membership',
source: assignedRoleName ? `Role: ${assignedRoleName}` : 'Role Policy',
});
});
}
// 2. Direct user claims if any
if (Array.isArray(directClaims)) {
directClaims.forEach((c) => {
if (!c) return;
const type = (c.claimType || c.type || '').trim();
if (!type) return;
if (!map.has(type.toLowerCase())) {
const def = ROLE_CLAIM_DEFINITIONS.find((d) => d.claim.toLowerCase() === type.toLowerCase());
map.set(type.toLowerCase(), {
...c,
claimType: type,
claimValue: c.claimValue || c.value || 'all',
label: def?.label || type,
description: def?.description || 'Direct user account claim',
source: 'Direct User Claim',
});
}
});
}
return Array.from(map.values());
}, [roleClaims, directClaims, assignedRoleName]);
// Role Mutations (Strictly single-role per user)
const assignRoleMutation = useMutation({ const assignRoleMutation = useMutation({
mutationFn: (roleId: string) => { mutationFn: (roleId: string) => {
if ((Array.isArray(userRoles) ? userRoles : []).length >= 1) {
throw new Error('Single-role policy: This user already has an assigned role. Please remove the existing role before assigning a new one.');
}
const selectedRole = (Array.isArray(systemRoles) ? systemRoles : []).find( const selectedRole = (Array.isArray(systemRoles) ? systemRoles : []).find(
(r) => r && (String(r.id) === String(roleId) || String((r as any).Id) === String(roleId)) (r) => r && (String(r.id) === String(roleId) || String(r.Id) === String(roleId))
); );
const roleName = selectedRole?.name || (selectedRole as any)?.Name || ''; const roleName = selectedRole?.name || selectedRole?.Name || '';
if (selectedRole && (roleName.toLowerCase() === 'admin' || String(selectedRole.id) === '1') && hasTenantSelfHelpAccess) { if (selectedRole && (roleName.toLowerCase() === 'admin' || String(selectedRole.id) === '1') && hasTenantSelfHelpAccess) {
throw new Error('Security policy: Users with tenant self-help access cannot assign Admin roles.'); throw new Error('Security policy: Users with tenant self-help access cannot assign Admin roles.');
} }
return assignUserRole(userId, roleId); return assignUserRole(userId, roleId);
}, },
onSuccess: () => { onSuccess: () => {
success('Role Assigned', 'Role membership updated successfully.'); clearCachedUserRbac(userId);
success('Role Assigned', 'Role assigned successfully.');
refetchUserRoles(); refetchUserRoles();
refetchUser();
refetchRoleClaims();
queryClient.invalidateQueries({ queryKey: ['users'] });
setSelectedRoleToAdd(''); setSelectedRoleToAdd('');
}, },
onError: (err: any) => toastError('Role Assignment Failed', err), onError: (err: unknown) => toastError('Role Assignment Failed', err),
}); });
const removeRoleMutation = useMutation({ const removeRoleMutation = useMutation({
mutationFn: (roleId: string) => removeUserRole(userId, roleId), mutationFn: (roleId: string) => removeUserRole(userId, roleId),
onSuccess: () => { onSuccess: () => {
clearCachedUserRbac(userId);
success('Role Removed', 'Role revoked from user.'); success('Role Removed', 'Role revoked from user.');
refetchUserRoles(); refetchUserRoles();
refetchUser();
refetchRoleClaims();
queryClient.invalidateQueries({ queryKey: ['users'] });
}, },
onError: (err: any) => toastError('Role Removal Failed', err), onError: (err: unknown) => toastError('Role Removal Failed', err),
});
// Claim Mutations
const addClaimMutation = useMutation({
mutationFn: () => {
const normalizedType = newClaimType.toLowerCase().trim();
if ((normalizedType === 'unrestricted' || normalizedType === 'admin') && hasTenantSelfHelpAccess) {
throw new Error('Security policy: Users with tenant self-help access cannot assign Admin or Unrestricted claims.');
}
return addUserClaim({
userId,
claimId: Math.floor(Math.random() * 10000) + 1,
claimType: newClaimType.trim(),
claimValue: newClaimValue.trim(),
});
},
onSuccess: () => {
success('Claim Granted', 'User claim added successfully.');
refetchUserClaims();
setNewClaimType('');
setNewClaimValue('');
},
onError: (err: any) => toastError('Claim Creation Failed', err),
});
const deleteClaimMutation = useMutation({
mutationFn: (claimId: string | number) => deleteUserClaim(userId, claimId),
onSuccess: () => {
success('Claim Revoked', 'Claim removed from user.');
refetchUserClaims();
},
onError: (err: any) => toastError('Claim Revocation Failed', err),
}); });
// Session & Lockout Mutations // Session & Lockout Mutations
const logoutMutation = useMutation({ const logoutMutation = useMutation({
mutationFn: () => forceLogoutUser(userId), mutationFn: () => forceLogoutUser(userId),
onSuccess: () => success('Session Terminated', 'Target user session revoked. Re-authentication required.'), onSuccess: () => success('Session Terminated', 'Target user session revoked. Re-authentication required.'),
onError: (err: any) => toastError('Logout Failed', err), onError: (err: unknown) => toastError('Logout Failed', err),
}); });
const lockoutMutation = useMutation({ const lockUserMutation = useMutation({
mutationFn: (disableLock: boolean) => setUserLockout(userId, disableLock), mutationFn: () => setUserLockout(userId, true),
onSuccess: (_, disableLock) => { onSuccess: () => {
success( success('Account Locked', 'User account lockout enabled (disable = true).');
disableLock ? 'Account Unlocked' : 'Account Locked',
`User account lockout status successfully modified.`
);
refetchUser(); refetchUser();
queryClient.invalidateQueries({ queryKey: ['users'] });
queryClient.invalidateQueries({ queryKey: ['user-detail', userId] });
}, },
onError: (err: any) => toastError('Lockout Update Failed', err), onError: (err: unknown) => toastError('Lock User Failed', err),
});
const unlockUserMutation = useMutation({
mutationFn: () => setUserLockout(userId, false),
onSuccess: () => {
success('Account Unlocked', 'User account lockout disabled (disable = false).');
refetchUser();
queryClient.invalidateQueries({ queryKey: ['users'] });
queryClient.invalidateQueries({ queryKey: ['user-detail', userId] });
},
onError: (err: unknown) => toastError('Unlock User Failed', err),
}); });
const statusMutation = useMutation({ 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: () => { onSuccess: () => {
success('Status Updated', 'User activity status changed.'); success('Status Updated', 'User activity status changed.');
refetchUser(); refetchUser();
queryClient.invalidateQueries({ queryKey: ['users'] }); queryClient.invalidateQueries({ queryKey: ['users'] });
}, },
onError: (err: any) => toastError('Status Update Failed', err), onError: (err: unknown) => toastError('Status Update Failed', err),
}); });
if (!user) return null; const availableRoles = React.useMemo(() => {
return (Array.isArray(systemRoles) ? systemRoles : []).filter((r) => {
if (!r) return false;
const rId = r.id || r.Id;
const rName = r.name || r.Name || '';
if (hasTenantSelfHelpAccess && (rName.toLowerCase() === 'admin' || String(rId) === '1')) {
return false;
}
return true;
});
}, [systemRoles, hasTenantSelfHelpAccess]);
const effectiveUser = freshUser || user; if (!isOpen || !user) return null;
const decodedId = effectiveUser?.idNumber ? decodeIdNumberHex(effectiveUser.idNumber) : null;
const assignedRoleIds = new Set(
(Array.isArray(userRoles) ? userRoles : []).map((r) => r?.id || (r as any)?.Id).filter(Boolean)
);
const availableRoles = (Array.isArray(systemRoles) ? systemRoles : []).filter((r) => {
if (!r) return false;
const rId = r.id || (r as any).Id;
const rName = r.name || (r as any).Name || '';
if (rId && assignedRoleIds.has(rId)) return false;
if (hasTenantSelfHelpAccess && (rName.toLowerCase() === 'admin' || String(rId) === '1')) {
return false;
}
return true;
});
const assignableClaims = React.useMemo(() => { const effectiveUser: User = freshUser || user;
const claimMap = new Map<string, { claim: string; label: string }>(); const decodedId = effectiveUser.idNumber ? decodeIdNumberHex(effectiveUser.idNumber) : null;
if (Array.isArray(systemClaims)) {
systemClaims.forEach((c) => {
if (!c) return;
const type = c.claimType || c.type;
if (type && typeof type === 'string') {
if (hasTenantSelfHelpAccess && (type.toLowerCase() === 'unrestricted' || type.toLowerCase() === 'admin')) {
return;
}
claimMap.set(type, { claim: type, label: type });
}
});
}
if (Array.isArray(ROLE_CLAIM_DEFINITIONS)) {
ROLE_CLAIM_DEFINITIONS.forEach((def) => {
if (!def || !def.claim) return;
const claimStr = String(def.claim);
if (hasTenantSelfHelpAccess && (claimStr.toLowerCase() === 'unrestricted' || claimStr.toLowerCase() === 'admin')) {
return;
}
claimMap.set(claimStr, { claim: claimStr, label: `${claimStr} (${def.label || claimStr})` });
});
}
return Array.from(claimMap.values());
}, [systemClaims, hasTenantSelfHelpAccess]);
return ( return (
<Modal <Modal
isOpen={isOpen} isOpen={isOpen}
onClose={onClose} onClose={onClose}
title={`User Administration: ${effectiveUser.email || effectiveUser.id}`} title={`User Security: ${effectiveUser.email || effectiveUser.id}`}
subtitle="Direct tenant-level user, role, claims, and session administration without database intervention." subtitle="Manage account status, role assignment, permissions, and active sessions."
> >
<div className="space-y-4 text-xs"> <div className="space-y-4 text-xs">
{/* Navigation Tabs */} {/* Navigation Tabs */}
<div className="flex gap-2 border-b border-slate-800 pb-2"> <div className="flex gap-1.5 border-b border-slate-800 pb-2">
<button <button
type="button" type="button"
onClick={() => setActiveTab('overview')} onClick={() => setActiveTab('overview')}
className={`px-3 py-1.5 rounded-lg font-semibold transition cursor-pointer ${ className={`px-3 py-1.5 rounded-lg font-medium transition cursor-pointer ${
activeTab === 'overview' activeTab === 'overview'
? 'bg-blue-600 text-white' ? 'bg-blue-600 text-white font-semibold'
: 'text-slate-400 hover:text-white hover:bg-slate-800' : 'text-slate-400 hover:text-white hover:bg-slate-800'
}`} }`}
> >
@@ -275,33 +321,33 @@ export const UserSecurityModal: React.FC<UserSecurityModalProps> = ({
<button <button
type="button" type="button"
onClick={() => setActiveTab('roles')} onClick={() => setActiveTab('roles')}
className={`px-3 py-1.5 rounded-lg font-semibold transition cursor-pointer flex items-center gap-1.5 ${ className={`px-3 py-1.5 rounded-lg font-medium transition cursor-pointer flex items-center gap-1.5 ${
activeTab === 'roles' activeTab === 'roles'
? 'bg-blue-600 text-white' ? 'bg-blue-600 text-white font-semibold'
: 'text-slate-400 hover:text-white hover:bg-slate-800' : 'text-slate-400 hover:text-white hover:bg-slate-800'
}`} }`}
> >
<Shield className="w-3.5 h-3.5" /> <Shield className="w-3.5 h-3.5" />
<span>Roles ({userRoles.length})</span> <span>Role ({(Array.isArray(userRoles) ? userRoles : []).length})</span>
</button> </button>
<button <button
type="button" type="button"
onClick={() => setActiveTab('claims')} onClick={() => setActiveTab('claims')}
className={`px-3 py-1.5 rounded-lg font-semibold transition cursor-pointer flex items-center gap-1.5 ${ className={`px-3 py-1.5 rounded-lg font-medium transition cursor-pointer flex items-center gap-1.5 ${
activeTab === 'claims' activeTab === 'claims'
? 'bg-blue-600 text-white' ? 'bg-blue-600 text-white font-semibold'
: 'text-slate-400 hover:text-white hover:bg-slate-800' : 'text-slate-400 hover:text-white hover:bg-slate-800'
}`} }`}
> >
<Key className="w-3.5 h-3.5" /> <Key className="w-3.5 h-3.5" />
<span>Claims ({userClaims.length})</span> <span>Claims ({userEffectiveClaims.length})</span>
</button> </button>
<button <button
type="button" type="button"
onClick={() => setActiveTab('sessions')} onClick={() => setActiveTab('sessions')}
className={`px-3 py-1.5 rounded-lg font-semibold transition cursor-pointer flex items-center gap-1.5 ${ className={`px-3 py-1.5 rounded-lg font-medium transition cursor-pointer flex items-center gap-1.5 ${
activeTab === 'sessions' activeTab === 'sessions'
? 'bg-blue-600 text-white' ? 'bg-blue-600 text-white font-semibold'
: 'text-slate-400 hover:text-white hover:bg-slate-800' : 'text-slate-400 hover:text-white hover:bg-slate-800'
}`} }`}
> >
@@ -354,14 +400,21 @@ export const UserSecurityModal: React.FC<UserSecurityModalProps> = ({
</span> </span>
<span <span
className={`inline-flex items-center px-2 py-0.5 rounded text-[10px] font-semibold border ${ className={`inline-flex items-center px-2 py-0.5 rounded text-[10px] font-semibold border ${
(getActivityStatusBadge(effectiveUser.status) || {}).className || 'bg-slate-800 text-slate-300' (getActivityStatusBadge(effectiveUser?.status) || {}).className || 'bg-slate-800 text-slate-300'
}`} }`}
> >
{(getActivityStatusBadge(effectiveUser.status) || {}).label || 'Active'} {(getActivityStatusBadge(effectiveUser?.status) || {}).label || 'Active'}
</span> </span>
</div> </div>
<div className="flex items-center gap-2"> <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 <button
type="button" type="button"
onClick={() => statusMutation.mutate(ActivityStatus.Inactive)} onClick={() => statusMutation.mutate(ActivityStatus.Inactive)}
@@ -386,65 +439,102 @@ export const UserSecurityModal: React.FC<UserSecurityModalProps> = ({
</div> </div>
)} )}
{/* TAB 2: Roles */} {/* TAB 2: Roles (Single-Role Policy) */}
{activeTab === 'roles' && ( {activeTab === 'roles' && (
<div className="space-y-3"> <div className="space-y-3">
{/* Add Role selector */} {/* If user already has a role, enforce single-role policy */}
<div className="flex items-center gap-2"> {(Array.isArray(userRoles) ? userRoles : []).length > 0 ? (
<select <div className="p-3 bg-slate-900/80 rounded-xl border border-slate-800 flex items-center justify-between gap-3">
value={selectedRoleToAdd} <div className="flex items-center gap-2 min-w-0">
onChange={(e) => setSelectedRoleToAdd(e.target.value)} <Shield className="w-4 h-4 text-blue-400 shrink-0" />
disabled={loadingSystemRoles || availableRoles.length === 0 || !canManageRolesAndClaims} <div className="min-w-0">
className="flex-1 bg-slate-900 border border-slate-800 rounded-xl px-3 py-1.5 text-white focus:outline-none focus:border-blue-500 disabled:opacity-50" <span className="font-semibold text-white block">Active Assigned Role (1 of 1 Max)</span>
> <p className="text-[11px] text-slate-400">
<option value=""> The system strictly enforces one role per user. To assign a different role, remove the current role first.
{availableRoles.length === 0 ? 'All system roles assigned' : 'Select role to assign...'} </p>
</option> </div>
{availableRoles.map((r) => ( </div>
<option key={r.id} value={r.id}> <span className="text-[10px] font-mono text-emerald-400 bg-emerald-500/10 border border-emerald-500/25 px-2 py-0.5 rounded-full shrink-0 font-semibold">
{r.name} Role Assigned
</option> </span>
))} </div>
</select> ) : (
<button /* Add Role selector when user has no role */
type="button" <div className="space-y-1.5">
onClick={() => selectedRoleToAdd && assignRoleMutation.mutate(selectedRoleToAdd)} <label className="block text-[11px] font-semibold uppercase tracking-wider text-slate-400">
disabled={!selectedRoleToAdd || assignRoleMutation.isPending || !canManageRolesAndClaims} Assign System Role (1 Role Max)
className="inline-flex items-center gap-1 px-3 py-1.5 bg-blue-600 hover:bg-blue-500 text-white rounded-xl font-semibold transition disabled:opacity-50 cursor-pointer" </label>
> <div className="flex items-center gap-2">
<Plus className="w-3.5 h-3.5" /> <select
<span>Assign Role</span> value={selectedRoleToAdd}
</button> onChange={(e) => setSelectedRoleToAdd(e.target.value)}
</div> disabled={loadingSystemRoles || availableRoles.length === 0 || !canManageRolesAndClaims || assignRoleMutation.isPending}
className="flex-1 bg-slate-900 border border-slate-800 rounded-xl px-3 py-1.5 text-white focus:outline-none focus:border-blue-500 disabled:opacity-50 text-xs"
>
<option value="">
{availableRoles.length === 0 ? 'No available roles' : 'Select role to assign...'}
</option>
{availableRoles.map((r) => {
const roleOptId = r?.id || r?.Id || String(r);
const roleOptName = r?.name || r?.Name || String(r);
return (
<option key={roleOptId} value={roleOptId}>
{roleOptName}
</option>
);
})}
</select>
<button
type="button"
onClick={() => selectedRoleToAdd && assignRoleMutation.mutate(selectedRoleToAdd)}
disabled={!selectedRoleToAdd || assignRoleMutation.isPending || !canManageRolesAndClaims}
className="inline-flex items-center gap-1 px-3 py-1.5 bg-blue-600 hover:bg-blue-500 text-white rounded-xl font-semibold transition disabled:opacity-50 cursor-pointer shrink-0 text-xs shadow-md shadow-blue-600/20"
>
<Plus className="w-3.5 h-3.5" />
<span>{assignRoleMutation.isPending ? 'Assigning...' : 'Assign Role'}</span>
</button>
</div>
</div>
)}
{/* List of currently assigned roles */} {/* List of currently assigned roles */}
<div className="bg-slate-900/60 rounded-xl border border-slate-800 divide-y divide-slate-800/80"> <div className="bg-slate-900/60 rounded-xl border border-slate-800 divide-y divide-slate-800/80">
{loadingUserRoles ? ( {loadingUserRoles ? (
<div className="p-4 text-center text-slate-500">Loading roles...</div> <div className="p-6 text-center text-slate-400 flex flex-col items-center gap-2">
<RefreshCw className="w-5 h-5 animate-spin text-blue-400" />
<span>Loading assigned role...</span>
</div>
) : (Array.isArray(userRoles) ? userRoles : []).length === 0 ? ( ) : (Array.isArray(userRoles) ? userRoles : []).length === 0 ? (
<div className="p-4 text-center text-slate-500">No roles assigned to this user.</div> <div className="p-6 text-center text-slate-500 flex flex-col items-center gap-2">
<Shield className="w-7 h-7 text-slate-600 mb-0.5" />
<p className="font-semibold text-slate-300">No role assigned to this user.</p>
<p className="text-[11px] text-slate-500">Select a role above to assign it. Users must have exactly one role.</p>
</div>
) : ( ) : (
(Array.isArray(userRoles) ? userRoles : []).map((role) => { (Array.isArray(userRoles) ? userRoles : []).map((role) => {
if (!role) return null; if (!role) return null;
const roleId = role.id || (role as any).Id || String(role); const roleId = role.id || role.Id || String(role);
const roleName = role.name || (role as any).Name || String(role); const roleName = role.name || role.Name || String(role);
return ( return (
<div key={roleId} className="p-3 flex items-center justify-between"> <div key={roleId} className="p-3.5 flex items-center justify-between gap-3">
<div> <div className="min-w-0">
<div className="text-white font-semibold flex items-center gap-1.5"> <div className="text-white font-semibold flex items-center gap-2">
<Shield className="w-3.5 h-3.5 text-blue-400" /> <Shield className="w-4 h-4 text-blue-400 shrink-0" />
<span>{roleName}</span> <span className="text-sm">{roleName}</span>
<span className="text-[10px] text-blue-400 bg-blue-500/10 border border-blue-500/20 px-2 py-0.5 rounded font-mono font-medium">
Active
</span>
</div> </div>
<div className="text-[10px] text-slate-500 font-mono">Role ID: {roleId}</div> <div className="text-[10px] text-slate-400 font-mono mt-1">Role ID: {roleId}</div>
</div> </div>
<button <button
type="button" type="button"
onClick={() => removeRoleMutation.mutate(roleId)} onClick={() => removeRoleMutation.mutate(roleId)}
disabled={removeRoleMutation.isPending || !canManageRolesAndClaims} disabled={removeRoleMutation.isPending || !canManageRolesAndClaims}
className="p-1.5 text-rose-400 hover:text-rose-300 hover:bg-rose-500/10 rounded-lg transition disabled:opacity-50 cursor-pointer" className="p-2 text-rose-400 hover:text-white hover:bg-rose-500/20 border border-transparent hover:border-rose-500/30 rounded-xl transition disabled:opacity-50 cursor-pointer shrink-0"
title="Revoke Role" title="Remove Role"
> >
<Trash2 className="w-3.5 h-3.5" /> <Trash2 className="w-4 h-4" />
</button> </button>
</div> </div>
); );
@@ -454,127 +544,74 @@ export const UserSecurityModal: React.FC<UserSecurityModalProps> = ({
</div> </div>
)} )}
{/* TAB 3: Claims */} {/* TAB 3: Claims (Read-Only Inherited from Role) */}
{activeTab === 'claims' && ( {activeTab === 'claims' && (
<div className="space-y-3"> <div className="space-y-3">
{/* Add claim inline form */} {/* Informational Policy Banner */}
<div className="p-3 bg-slate-900/60 rounded-xl border border-slate-800 space-y-2.5"> <div className="p-3 bg-blue-500/10 rounded-xl border border-blue-500/20 text-xs flex items-center justify-between gap-3">
<div className="flex items-center justify-between"> <div className="flex items-center gap-2.5 min-w-0">
<span className="text-[11px] font-semibold text-slate-400 uppercase tracking-wider">Grant Role Claim / Custom Claim</span> <CheckCircle2 className="w-4 h-4 text-blue-400 shrink-0" />
<span className="text-[10px] text-slate-500 font-mono">RBAC Standard Policy</span> <div className="min-w-0">
</div> <span className="font-semibold text-white block">Role-Derived Security Claims</span>
<div className="grid grid-cols-1 sm:grid-cols-2 gap-2"> <p className="text-[11px] text-slate-400">
<div> {assignedRoleName ? (
<label className="block text-[10px] text-slate-400 mb-1 font-medium">Claim Domain</label> <>Claims are inherited directly from the assigned role (<strong className="text-blue-300">{assignedRoleName}</strong>). They cannot be manually added or removed at the individual user level.</>
<select ) : (
value={assignableClaims.some((d) => d.claim === newClaimType) ? newClaimType : (newClaimType ? 'custom' : '')} <>Assign a role on the Roles tab to grant security claims to this user.</>
onChange={(e) => { )}
if (e.target.value === 'custom') { </p>
setNewClaimType('');
} else {
setNewClaimType(e.target.value);
}
}}
className="w-full bg-slate-900 border border-slate-800 rounded-lg px-2.5 py-1.5 text-white text-xs focus:outline-none focus:border-blue-500"
>
<option value="">-- Select System Claim --</option>
{assignableClaims.map((def) => (
<option key={def.claim} value={def.claim}>
{def.label}
</option>
))}
<option value="custom">-- Custom Claim Type --</option>
</select>
{(!assignableClaims.some((d) => d.claim === newClaimType) || newClaimType === '') && (
<input
type="text"
placeholder="Custom Claim Type..."
value={newClaimType}
onChange={(e) => setNewClaimType(e.target.value)}
className="mt-1.5 w-full bg-slate-900 border border-slate-800 rounded-lg px-2.5 py-1.5 text-white text-xs placeholder-slate-500 focus:outline-none focus:border-blue-500"
/>
)}
</div>
<div>
<label className="block text-[10px] text-slate-400 mb-1 font-medium">Claim Value / Action Assignment</label>
<select
value={CLAIM_ACTION_OPTIONS.some((a) => a.action === newClaimValue) ? newClaimValue : (newClaimValue ? 'custom' : '')}
onChange={(e) => {
if (e.target.value === 'custom') {
setNewClaimValue('');
} else {
setNewClaimValue(e.target.value);
}
}}
className="w-full bg-slate-900 border border-slate-800 rounded-lg px-2.5 py-1.5 text-white text-xs focus:outline-none focus:border-blue-500"
>
<option value="">-- Select Action Value --</option>
{CLAIM_ACTION_OPTIONS.map((opt) => (
<option key={opt.action} value={opt.action}>
{opt.action}: {opt.description}
</option>
))}
<option value="custom">-- Custom Value --</option>
</select>
{(!CLAIM_ACTION_OPTIONS.some((a) => a.action === newClaimValue) || newClaimValue === '') && (
<input
type="text"
placeholder="Custom Value (e.g. true)..."
value={newClaimValue}
onChange={(e) => setNewClaimValue(e.target.value)}
className="mt-1.5 w-full bg-slate-900 border border-slate-800 rounded-lg px-2.5 py-1.5 text-white text-xs placeholder-slate-500 focus:outline-none focus:border-blue-500"
/>
)}
</div> </div>
</div> </div>
<div className="flex justify-end pt-1"> <span className="text-[10px] font-mono text-blue-400 bg-blue-500/20 px-2 py-0.5 rounded border border-blue-500/30 shrink-0 font-semibold">
<button Read-Only
type="button" </span>
onClick={() => addClaimMutation.mutate()}
disabled={!newClaimType.trim() || !newClaimValue.trim() || addClaimMutation.isPending || !canManageRolesAndClaims}
className="inline-flex items-center gap-1 px-3 py-1 bg-blue-600 hover:bg-blue-500 text-white rounded-lg font-semibold transition disabled:opacity-50 cursor-pointer text-xs"
>
<Plus className="w-3.5 h-3.5" />
<span>Grant Claim</span>
</button>
</div>
</div> </div>
{/* Existing claims list */} {/* Claims list */}
<div className="bg-slate-900/60 rounded-xl border border-slate-800 divide-y divide-slate-800/80"> <div className="bg-slate-900/60 rounded-xl border border-slate-800 divide-y divide-slate-800/80">
{loadingUserClaims ? ( {loadingRoleClaims || loadingDirectClaims ? (
<div className="p-4 text-center text-slate-500">Loading user claims...</div> <div className="p-8 text-center text-slate-400 flex flex-col items-center gap-2">
) : (Array.isArray(userClaims) ? userClaims : []).length === 0 ? ( <RefreshCw className="w-6 h-6 animate-spin text-blue-400" />
<div className="p-4 text-center text-slate-500">No active claims found for user.</div> <span>Loading security claims from role endpoint...</span>
</div>
) : userEffectiveClaims.length === 0 ? (
<div className="p-8 text-center text-slate-500 flex flex-col items-center gap-2">
<Key className="w-8 h-8 text-slate-600 mb-1" />
<p className="font-semibold text-slate-300">No active claims found for user.</p>
<p className="text-[11px] text-slate-500 max-w-sm">
{assignedRoleName
? `The assigned role (${assignedRoleName}) does not contain any configured permission claims.`
: 'Assign a role to this user on the Roles tab to establish functional permissions.'}
</p>
</div>
) : ( ) : (
(Array.isArray(userClaims) ? userClaims : []).map((claim, idx) => { userEffectiveClaims.map((claim, idx) => {
if (!claim) return null;
const claimType = claim.claimType || claim.type || 'Custom Claim'; const claimType = claim.claimType || claim.type || 'Custom Claim';
const claimVal = claim.claimValue ?? claim.value ?? ''; const claimVal = claim.claimValue ?? claim.value ?? 'all';
const claimId = claim.claimId || (claim as any).id || claimType || String(idx);
return ( return (
<div key={idx} className="p-3 flex items-center justify-between"> <div key={idx} className="p-3.5 flex items-center justify-between gap-3">
<div> <div className="min-w-0 flex-1">
<div className="text-white font-medium flex items-center gap-1.5"> <div className="text-white font-medium flex items-center gap-2 flex-wrap">
<Key className="w-3.5 h-3.5 text-amber-400" /> <Key className="w-3.5 h-3.5 text-amber-400 shrink-0" />
<span>{claimType}</span> <span className="font-semibold text-xs text-white">{claim.label || claimType}</span>
</div> <span className="text-[10px] font-mono text-slate-400 bg-slate-800 px-1.5 py-0.2 rounded border border-slate-700/60">
<div className="text-[11px] text-slate-300 font-mono mt-0.5"> {claimType}
Value: <span className="text-emerald-400">{claimVal}</span> </span>
{claim.issuer && <span className="text-slate-500 ml-2">({claim.issuer})</span>}
</div> </div>
{claim.description && (
<p className="text-[11px] text-slate-400 mt-0.5 leading-relaxed">
{claim.description}
</p>
)}
</div>
<div className="flex items-center gap-2 shrink-0">
<span className="inline-flex items-center px-2 py-0.5 rounded text-[10px] font-semibold bg-emerald-500/10 text-emerald-400 border border-emerald-500/20 font-mono">
Value: {claimVal}
</span>
<span className="text-[10px] text-slate-500 hidden sm:inline">
{claim.source}
</span>
</div> </div>
{claimType && (
<button
type="button"
onClick={() => deleteClaimMutation.mutate(claimId)}
disabled={deleteClaimMutation.isPending || !canManageRolesAndClaims}
className="p-1.5 text-rose-400 hover:text-rose-300 hover:bg-rose-500/10 rounded-lg transition disabled:opacity-50 cursor-pointer"
title="Delete Claim"
>
<Trash2 className="w-3.5 h-3.5" />
</button>
)}
</div> </div>
); );
}) })
@@ -583,70 +620,66 @@ export const UserSecurityModal: React.FC<UserSecurityModalProps> = ({
</div> </div>
)} )}
{/* TAB 4: Sessions & Direct Account Interventions */} {/* TAB 4: Sessions & Security Interventions */}
{activeTab === 'sessions' && ( {activeTab === 'sessions' && (
<div className="space-y-3"> <div className="space-y-3">
<div className="p-4 bg-slate-900/60 rounded-xl border border-slate-800 space-y-4"> <div className="p-4 bg-slate-900/60 rounded-xl border border-slate-800 space-y-4">
{/* Force Logout Action */} {/* Force Logout Action */}
<div className="flex items-center justify-between border-b border-slate-800/80 pb-3"> <div className="flex items-center justify-between border-b border-slate-800/80 pb-3 gap-3">
<div> <div>
<h4 className="text-white font-semibold flex items-center gap-1.5"> <h4 className="text-white font-semibold flex items-center gap-1.5">
<LogOut className="w-4 h-4 text-amber-400" /> <LogOut className="w-4 h-4 text-amber-400" />
<span>Revoke Active User Session</span> <span>Revoke Active User Session</span>
<HelpTooltip
title="User Session Invalidation"
content="Enables a tenant self-service user to immediately logout a user if they are currently logged in, terminating their active token and session."
size="sm"
placement="top"
/>
</h4> </h4>
<p className="text-[11px] text-slate-400 mt-0.5"> <p className="text-[11px] text-slate-400 mt-0.5">
Terminates all active JWT tokens and sessions immediately without direct database intervention. Immediately invalidates active JWT tokens and logs the user out.
</p> </p>
</div> </div>
<button <button
type="button" type="button"
onClick={() => logoutMutation.mutate()} onClick={() => logoutMutation.mutate()}
disabled={logoutMutation.isPending || !canManageSessions} disabled={logoutMutation.isPending || !canManageSessions}
className="px-3 py-1.5 bg-amber-500/10 hover:bg-amber-500/20 text-amber-400 border border-amber-500/20 rounded-xl font-semibold transition cursor-pointer disabled:opacity-50" className="px-3 py-1.5 bg-amber-500/10 hover:bg-amber-500/20 text-amber-400 border border-amber-500/20 rounded-xl font-semibold transition cursor-pointer disabled:opacity-50 shrink-0"
> >
{logoutMutation.isPending ? 'Revoking...' : 'Force Logout'} {logoutMutation.isPending ? 'Revoking...' : 'Force Logout'}
</button> </button>
</div> </div>
{/* Lock / Unlock Account */} {/* Lock / Unlock Account */}
<div className="flex items-center justify-between"> <div className="flex items-center justify-between gap-3">
<div> <div>
<h4 className="text-white font-semibold flex items-center gap-1.5"> <h4 className="text-white font-semibold flex items-center gap-1.5">
<Lock className="w-4 h-4 text-rose-400" /> <Lock className="w-4 h-4 text-rose-400" />
<span>Account Lockout Control</span> <span>Account Lockout</span>
<HelpTooltip
title="Account Lockout Control"
content="Enables a tenant self-service user to change a user's lockout status. The system defaults to a lockout period of 100 years when locking a user account."
size="sm"
placement="top"
/>
</h4> </h4>
<p className="text-[11px] text-slate-400 mt-0.5"> <p className="text-[11px] text-slate-400 mt-0.5">
Temporarily lock out or re-enable the user from logging into this tenant. The system defaults to a lockout period of 100 years. Control tenant login authorization for this account.
</p> </p>
</div> </div>
<div className="flex items-center gap-2"> <div className="flex items-center gap-2 shrink-0">
<button <button
type="button" type="button"
onClick={() => lockoutMutation.mutate(false)} onClick={(e) => {
disabled={lockoutMutation.isPending || !canManageSessions} e.preventDefault();
e.stopPropagation();
lockUserMutation.mutate();
}}
disabled={lockUserMutation.isPending || unlockUserMutation.isPending || !canManageSessions}
className="px-3 py-1.5 bg-rose-500/10 hover:bg-rose-500/20 text-rose-400 border border-rose-500/20 rounded-xl font-semibold transition cursor-pointer disabled:opacity-50" className="px-3 py-1.5 bg-rose-500/10 hover:bg-rose-500/20 text-rose-400 border border-rose-500/20 rounded-xl font-semibold transition cursor-pointer disabled:opacity-50"
> >
Lock User {lockUserMutation.isPending ? 'Locking...' : 'Lock User'}
</button> </button>
<button <button
type="button" type="button"
onClick={() => lockoutMutation.mutate(true)} onClick={(e) => {
disabled={lockoutMutation.isPending || !canManageSessions} e.preventDefault();
e.stopPropagation();
unlockUserMutation.mutate();
}}
disabled={unlockUserMutation.isPending || lockUserMutation.isPending || !canManageSessions}
className="px-3 py-1.5 bg-emerald-500/10 hover:bg-emerald-500/20 text-emerald-400 border border-emerald-500/20 rounded-xl font-semibold transition cursor-pointer disabled:opacity-50" className="px-3 py-1.5 bg-emerald-500/10 hover:bg-emerald-500/20 text-emerald-400 border border-emerald-500/20 rounded-xl font-semibold transition cursor-pointer disabled:opacity-50"
> >
Unlock User {unlockUserMutation.isPending ? 'Unlocking...' : 'Unlock User'}
</button> </button>
</div> </div>
</div> </div>
+14 -12
View File
@@ -1,10 +1,10 @@
import { updateHasherSettings } from './services/toolsService'; import { updateHasherSettings } from './utils/hashids';
export interface SecuritySettings { export interface SecuritySettings {
Authority: string; Authority: string;
Audience: string; Audience: string;
ClientId: string; ClientId: string;
ClientSecret: string; ClientSecret?: string;
Scope?: string; Scope?: string;
} }
@@ -13,7 +13,7 @@ export interface ApiSettings {
} }
export interface HasherSettings { export interface HasherSettings {
Salt: string; Salt?: string;
MinHashLength: number; MinHashLength: number;
} }
@@ -36,7 +36,6 @@ export const defaultSecuritySettings: SecuritySettings = {
Authority: 'https://sts.security.khongisa.co.za', Authority: 'https://sts.security.khongisa.co.za',
Audience: 'pfm-api-dev', Audience: 'pfm-api-dev',
ClientId: 'pfm-console-dev', ClientId: 'pfm-console-dev',
ClientSecret: 'secret_dcaeaa28097d9ba5b06ec324d9eac9d9c81de96e0a203cf2c6154c6dd7c89e94',
Scope: 'openid profile roles email address pfm-api-dev', Scope: 'openid profile roles email address pfm-api-dev',
}; };
@@ -49,6 +48,10 @@ export const defaultHasherSettings: HasherSettings = {
MinHashLength: 11, MinHashLength: 11,
}; };
function syncAllHasherSettings(salt: string | undefined, minHashLength: number): void {
updateHasherSettings(salt, minHashLength);
}
/** /**
* Extracts first non-empty, trimmed string from candidate values. * Extracts first non-empty, trimmed string from candidate values.
*/ */
@@ -255,9 +258,8 @@ function resolveConfig(fetchedJson?: Record<string, any>): AppConfig {
const clientSecret = pickFirstNonEmpty( const clientSecret = pickFirstNonEmpty(
windowConfig.clientSecret, windowConfig.clientSecret,
fetchedConfig.clientSecret, fetchedConfig.clientSecret,
buildEnvConfig.clientSecret, buildEnvConfig.clientSecret
defaultSecuritySettings.ClientSecret );
)!;
const scope = pickFirstNonEmpty( const scope = pickFirstNonEmpty(
windowConfig.scope, windowConfig.scope,
@@ -278,7 +280,7 @@ function resolveConfig(fetchedJson?: Record<string, any>): AppConfig {
fetchedConfig.hasherSalt, fetchedConfig.hasherSalt,
buildEnvConfig.hasherSalt, buildEnvConfig.hasherSalt,
defaultHasherSettings.Salt defaultHasherSettings.Salt
)!; );
const minHashLength = const minHashLength =
windowConfig.hasherMinLength || windowConfig.hasherMinLength ||
@@ -291,14 +293,14 @@ function resolveConfig(fetchedJson?: Record<string, any>): AppConfig {
Authority: normalizeAuthorityUrl(authority), Authority: normalizeAuthorityUrl(authority),
Audience: audience, Audience: audience,
ClientId: clientId, ClientId: clientId,
ClientSecret: clientSecret, ...(clientSecret ? { ClientSecret: clientSecret } : {}),
Scope: scope, Scope: scope,
}, },
ApiSettings: { ApiSettings: {
BaseUrl: normalizeBaseUrl(baseUrl), BaseUrl: normalizeBaseUrl(baseUrl),
}, },
HasherSettings: { HasherSettings: {
Salt: salt, Salt: salt || defaultHasherSettings.Salt,
MinHashLength: minHashLength, MinHashLength: minHashLength,
}, },
ConfigSource: source, ConfigSource: source,
@@ -310,7 +312,7 @@ function resolveConfig(fetchedJson?: Record<string, any>): AppConfig {
let runtimeConfig: AppConfig = resolveConfig(); let runtimeConfig: AppConfig = resolveConfig();
// Immediately apply deterministic hasher settings // Immediately apply deterministic hasher settings
updateHasherSettings(runtimeConfig.HasherSettings.Salt, runtimeConfig.HasherSettings.MinHashLength); syncAllHasherSettings(runtimeConfig.HasherSettings.Salt, runtimeConfig.HasherSettings.MinHashLength);
const configListeners: Array<(config: AppConfig) => void> = []; const configListeners: Array<(config: AppConfig) => void> = [];
@@ -360,7 +362,7 @@ export async function loadRuntimeConfig(): Promise<AppConfig> {
runtimeConfig = resolveConfig(fetchedJson); runtimeConfig = resolveConfig(fetchedJson);
// Synchronize deterministic hasher settings // Synchronize deterministic hasher settings
updateHasherSettings(runtimeConfig.HasherSettings.Salt, runtimeConfig.HasherSettings.MinHashLength); syncAllHasherSettings(runtimeConfig.HasherSettings.Salt, runtimeConfig.HasherSettings.MinHashLength);
console.info( console.info(
`[Config] Active runtime configuration: source=${runtimeConfig.ConfigSource}, API=${runtimeConfig.ApiSettings.BaseUrl}, STS=${runtimeConfig.SecuritySettings.Authority}` `[Config] Active runtime configuration: source=${runtimeConfig.ConfigSource}, API=${runtimeConfig.ApiSettings.BaseUrl}, STS=${runtimeConfig.SecuritySettings.Authority}`
@@ -0,0 +1,411 @@
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
import { useState, useEffect, useMemo, useCallback } from 'react';
import { useAuth } from '../auth/AuthProvider';
import {
getUserNotifications,
getNotifications,
createNotification,
markNotificationAsRead,
archiveNotification,
updateNotification,
getCurrentUserTenantId,
} from '../services/api';
import {
Notification,
NotificationPlatform,
NotificationStatus,
CreateNotificationRequest,
} from '../types';
export interface DraftMessage {
id: string;
recipientId: string;
recipientEmail: string;
recipientName?: string;
subject: string;
message: string;
platform?: NotificationPlatform;
updatedAt: string;
}
export function useInAppNotifications() {
const { user, claims } = useAuth();
const queryClient = useQueryClient();
const userId = (user?.id || claims?.sub || '') as string;
const userEmail = (user?.email || claims?.email || '') as string;
// Local storage keys for client-side starred and drafts
const draftsStorageKey = `pfm_mailbox_drafts_${userId || 'default'}`;
const starredStorageKey = `pfm_mailbox_starred_${userId || 'default'}`;
const trashStorageKey = `pfm_mailbox_trash_${userId || 'default'}`;
const sentStorageKey = `pfm_mailbox_sent_${userId || 'default'}`;
const readStorageList = useCallback(<T,>(key: string, fallbackKey?: string): T[] => {
try {
const stored = localStorage.getItem(key);
const parsed: T[] = stored ? JSON.parse(stored) : [];
if (fallbackKey && fallbackKey !== key) {
const fallbackStored = localStorage.getItem(fallbackKey);
const fallbackParsed: T[] = fallbackStored ? JSON.parse(fallbackStored) : [];
if (fallbackParsed.length > 0) {
const merged = [...parsed];
for (const item of fallbackParsed) {
const itemId = (item as any)?.id ?? item;
if (!merged.some(m => ((m as any)?.id ?? m) === itemId)) {
merged.push(item);
}
}
localStorage.setItem(key, JSON.stringify(merged));
localStorage.removeItem(fallbackKey);
return merged;
}
}
return parsed;
} catch {
return [];
}
}, []);
// Starred messages set
const [starredIds, setStarredIds] = useState<string[]>(() =>
readStorageList<string>(starredStorageKey, 'pfm_mailbox_starred_default')
);
// Trashed messages set
const [trashedIds, setTrashedIds] = useState<string[]>(() =>
readStorageList<string>(trashStorageKey, 'pfm_mailbox_trash_default')
);
// Local drafts list
const [drafts, setDrafts] = useState<DraftMessage[]>(() =>
readStorageList<DraftMessage>(draftsStorageKey, 'pfm_mailbox_drafts_default')
);
// Local sent messages history
const [localSent, setLocalSent] = useState<Notification[]>(() =>
readStorageList<Notification>(sentStorageKey, 'pfm_mailbox_sent_default')
);
// Synchronize across hook instances and userId changes
const syncFromStorage = useCallback(() => {
setStarredIds(readStorageList<string>(starredStorageKey, 'pfm_mailbox_starred_default'));
setTrashedIds(readStorageList<string>(trashStorageKey, 'pfm_mailbox_trash_default'));
setDrafts(readStorageList<DraftMessage>(draftsStorageKey, 'pfm_mailbox_drafts_default'));
setLocalSent(readStorageList<Notification>(sentStorageKey, 'pfm_mailbox_sent_default'));
}, [readStorageList, starredStorageKey, trashStorageKey, draftsStorageKey, sentStorageKey]);
useEffect(() => {
syncFromStorage();
}, [syncFromStorage]);
useEffect(() => {
const handleSync = () => syncFromStorage();
window.addEventListener('pfm-mailbox-sync', handleSync);
window.addEventListener('storage', handleSync);
return () => {
window.removeEventListener('pfm-mailbox-sync', handleSync);
window.removeEventListener('storage', handleSync);
};
}, [syncFromStorage]);
// Fetch notifications from backend
const { data: rawNotifications = [], isLoading, refetch } = useQuery<Notification[]>({
queryKey: ['inapp-notifications', userId],
queryFn: async () => {
// 1. Try user-specific notifications
let list = await getUserNotifications(userId || undefined);
// 2. If list is empty or fallback needed, also check organisation notifications
if (!list || list.length === 0) {
const orgList = await getNotifications();
if (orgList && orgList.length > 0) {
list = orgList;
}
}
return list || [];
},
// Live polling every 30 seconds
refetchInterval: 30000,
});
// InApp notifications (strictly platform === InApp 5) for Bell & Counters
const inAppNotifications = useMemo(() => {
return rawNotifications.filter(
n => Number(n.platform) === NotificationPlatform.InApp || n.platform === (NotificationPlatform.InApp as any)
);
}, [rawNotifications]);
// 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.Archived &&
Number(n.status) !== NotificationStatus.Draft
);
}, [inAppNotifications, trashedIds]);
// Active mailbox notifications (always In-App notifications)
const allMailboxNotifications = inboxNotifications;
// Unread InApp notifications: status !== Read && status !== Draft && status !== Cancelled && status !== Failed
const unreadNotifications = useMemo(() => {
return inboxNotifications.filter(
n =>
Number(n.status) !== NotificationStatus.Read &&
Number(n.status) !== NotificationStatus.Cancelled &&
Number(n.status) !== NotificationStatus.Failed
);
}, [inboxNotifications]);
const unreadCount = unreadNotifications.length;
const top5Unread = useMemo(() => unreadNotifications.slice(0, 5), [unreadNotifications]);
// Starred notifications (In-App only)
const starredNotifications = useMemo(() => {
return inAppNotifications.filter(
n =>
starredIds.includes(String(n.id)) &&
!trashedIds.includes(String(n.id))
);
}, [inAppNotifications, starredIds, trashedIds]);
// Trashed notifications (In-App only)
const trashedNotifications = useMemo(() => {
return inAppNotifications.filter(
n => trashedIds.includes(String(n.id)) || Number(n.status) === NotificationStatus.Archived
);
}, [inAppNotifications, trashedIds]);
// Mutation to mark notification as read
const markAsReadMutation = useMutation({
mutationFn: async (id: number | string) => {
await markNotificationAsRead(id);
},
onMutate: async (id) => {
await queryClient.cancelQueries({ queryKey: ['inapp-notifications', userId] });
queryClient.setQueryData<Notification[]>(['inapp-notifications', userId], (old = []) =>
old.map(n => (String(n.id) === String(id) ? { ...n, status: NotificationStatus.Read } : n))
);
},
onSettled: () => {
queryClient.invalidateQueries({ queryKey: ['inapp-notifications'] });
},
});
// Mark all unread as read
const markAllAsRead = useCallback(async () => {
const unread = unreadNotifications;
for (const item of unread) {
try {
await markNotificationAsRead(item.id);
} catch {}
}
queryClient.invalidateQueries({ queryKey: ['inapp-notifications'] });
}, [unreadNotifications, queryClient]);
// Toggle star
const toggleStar = useCallback((id: string | number) => {
const strId = String(id);
setStarredIds(prev => {
const next = prev.includes(strId) ? prev.filter(x => x !== strId) : [...prev, strId];
try {
localStorage.setItem(starredStorageKey, JSON.stringify(next));
window.dispatchEvent(new Event('pfm-mailbox-sync'));
} catch {}
return next;
});
}, [starredStorageKey]);
// 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];
try {
localStorage.setItem(trashStorageKey, JSON.stringify(next));
window.dispatchEvent(new Event('pfm-mailbox-sync'));
} catch {}
return next;
});
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);
try {
localStorage.setItem(trashStorageKey, JSON.stringify(next));
window.dispatchEvent(new Event('pfm-mailbox-sync'));
} catch {}
return next;
});
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) => {
const strId = String(id);
setTrashedIds(prev => {
const next = prev.filter(x => x !== strId);
try {
localStorage.setItem(trashStorageKey, JSON.stringify(next));
} catch {}
return next;
});
setLocalSent(prev => {
const next = prev.filter(n => String(n.id) !== strId);
try {
localStorage.setItem(sentStorageKey, JSON.stringify(next));
window.dispatchEvent(new Event('pfm-mailbox-sync'));
} catch {}
return next;
});
}, [trashStorageKey, sentStorageKey]);
// Draft management
const saveDraft = useCallback((draft: Omit<DraftMessage, 'id' | 'updatedAt'> & { id?: string }) => {
const draftId = draft.id || `draft_${Date.now()}`;
const newDraft: DraftMessage = {
...draft,
id: draftId,
updatedAt: new Date().toISOString(),
};
setDrafts(prev => {
const current = readStorageList<DraftMessage>(draftsStorageKey, 'pfm_mailbox_drafts_default');
const base = current.length >= prev.length ? current : prev;
const filtered = base.filter(d => d.id !== draftId);
const next = [newDraft, ...filtered];
try {
localStorage.setItem(draftsStorageKey, JSON.stringify(next));
window.dispatchEvent(new Event('pfm-mailbox-sync'));
} catch {}
return next;
});
return draftId;
}, [draftsStorageKey, readStorageList]);
const deleteDraft = useCallback((id: string) => {
setDrafts(prev => {
const current = readStorageList<DraftMessage>(draftsStorageKey, 'pfm_mailbox_drafts_default');
const base = current.length >= prev.length ? current : prev;
const next = base.filter(d => d.id !== id);
try {
localStorage.setItem(draftsStorageKey, JSON.stringify(next));
window.dispatchEvent(new Event('pfm-mailbox-sync'));
} catch {}
return next;
});
}, [draftsStorageKey, readStorageList]);
// Send new notification (InApp or Email)
const sendMessageMutation = useMutation({
mutationFn: async (payload: {
recipientId: string;
recipientEmail: string;
recipientName?: string;
subject: string;
message: string;
platform?: NotificationPlatform;
draftId?: string;
}) => {
const orgId = getCurrentUserTenantId() || 1;
const targetPlatform = payload.platform || NotificationPlatform.InApp;
const formattedDestination = `To: ${payload.recipientEmail} | From: ${userEmail || 'System User'}`;
const req: CreateNotificationRequest = {
organisationId: orgId,
recipient: payload.recipientId,
platform: targetPlatform,
subject: payload.subject,
destination: formattedDestination,
message: payload.message,
status: targetPlatform === NotificationPlatform.InApp ? NotificationStatus.Delivered : NotificationStatus.Queued,
};
await createNotification(req);
// Record in local sent messages
const sentItem: Notification = {
id: `sent_${Date.now()}`,
organisationId: orgId,
recipient: payload.recipientId,
platform: targetPlatform,
status: NotificationStatus.Sent,
subject: payload.subject,
message: payload.message,
destination: formattedDestination,
createdAt: new Date().toISOString(),
sentAt: new Date().toISOString(),
};
setLocalSent(prev => {
const next = [sentItem, ...prev];
try {
localStorage.setItem(sentStorageKey, JSON.stringify(next));
window.dispatchEvent(new Event('pfm-mailbox-sync'));
} catch {}
return next;
});
// If sending a draft, remove the draft
if (payload.draftId) {
deleteDraft(payload.draftId);
}
},
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['inapp-notifications'] });
},
});
return {
rawNotifications,
inAppNotifications,
allMailboxNotifications,
inboxNotifications,
unreadNotifications,
unreadCount,
top5Unread,
starredNotifications,
trashedNotifications,
sentNotifications: localSent,
drafts,
starredIds,
trashedIds,
isLoading,
refetch,
markAsRead: (id: string | number) => markAsReadMutation.mutate(id),
markAllAsRead,
toggleStar,
deleteMessage,
restoreMessage,
permanentDelete,
saveDraft,
deleteDraft,
sendMessage: sendMessageMutation.mutateAsync,
isSending: sendMessageMutation.isPending,
};
}
+19 -2
View File
@@ -33,13 +33,18 @@
color-scheme: dark; color-scheme: dark;
} }
/* Custom dark scrollbar styling */ /* Custom dark scrollbar styling - auto-appearing with transparent tracks */
* {
scrollbar-width: thin;
scrollbar-color: #262626 transparent;
}
::-webkit-scrollbar { ::-webkit-scrollbar {
width: 6px; width: 6px;
height: 6px; height: 6px;
} }
::-webkit-scrollbar-track { ::-webkit-scrollbar-track {
background: #0a0a0a; background: transparent;
} }
::-webkit-scrollbar-thumb { ::-webkit-scrollbar-thumb {
background: #262626; background: #262626;
@@ -48,6 +53,18 @@
::-webkit-scrollbar-thumb:hover { ::-webkit-scrollbar-thumb:hover {
background: #3f3f46; background: #3f3f46;
} }
/* Table responsive containment and smooth auto-scrolling */
.overflow-x-auto {
overflow-x: auto !important;
max-width: 100%;
-webkit-overflow-scrolling: touch;
}
/* Ensure tables preserve minimum readable width without squashing action buttons */
.overflow-x-auto > table {
min-width: 780px;
}
} }
/* High Contrast Dark Theme Adaptations for existing utility classes */ /* High Contrast Dark Theme Adaptations for existing utility classes */
@@ -1,5 +1,6 @@
import { import {
ApprovalStatus, ApprovalStatus,
ComplianceStatus,
ContractStatus, ContractStatus,
ActivityStatus, ActivityStatus,
Priority, Priority,
@@ -78,6 +79,8 @@ export const NOTIFICATION_STATUS_OPTIONS = [
{ value: NotificationStatus.Failed, label: 'Failed' }, { value: NotificationStatus.Failed, label: 'Failed' },
{ value: NotificationStatus.Cancelled, label: 'Cancelled' }, { value: NotificationStatus.Cancelled, label: 'Cancelled' },
{ value: NotificationStatus.Bounced, label: 'Bounced' }, { value: NotificationStatus.Bounced, label: 'Bounced' },
{ value: NotificationStatus.Draft, label: 'Draft' },
{ value: NotificationStatus.Archived, label: 'Archived' },
]; ];
export const NOTIFICATION_PLATFORM_OPTIONS = [ export const NOTIFICATION_PLATFORM_OPTIONS = [
@@ -271,6 +274,37 @@ export const getApprovalStatusBadge = (status: ApprovalStatus | number) => {
} }
}; };
export const getComplianceStatusBadge = (status: ComplianceStatus | number) => {
const s = Number(status);
switch (s) {
case ComplianceStatus.Exceeded:
return {
label: 'Exceeded Expectation',
className: 'bg-purple-500/15 text-purple-300 border-purple-500/30 font-semibold',
dot: 'bg-purple-400',
};
case ComplianceStatus.Met:
return {
label: 'Met',
className: 'bg-emerald-500/15 text-emerald-300 border-emerald-500/30 font-semibold',
dot: 'bg-emerald-400',
};
case ComplianceStatus.NotMet:
default:
return {
label: 'Not Met',
className: 'bg-rose-500/15 text-rose-300 border-rose-500/30 font-semibold',
dot: 'bg-rose-400',
};
}
};
export const COMPLIANCE_STATUS_OPTIONS = [
{ value: ComplianceStatus.NotMet, label: 'Not Met' },
{ value: ComplianceStatus.Met, label: 'Met' },
{ value: ComplianceStatus.Exceeded, label: 'Exceeded Expectation' },
];
export const getContractStatusBadge = (status: ContractStatus | number) => { export const getContractStatusBadge = (status: ContractStatus | number) => {
const s = Number(status); const s = Number(status);
switch (s) { switch (s) {
@@ -477,6 +511,10 @@ export const getEmailStatusBadge = (status?: EmailStatuses | number | null) => {
export const getNotificationStatusBadge = (status?: NotificationStatus | number | null) => { export const getNotificationStatusBadge = (status?: NotificationStatus | number | null) => {
const s = Number(status); const s = Number(status);
switch (s) { 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: case NotificationStatus.Delivered:
case NotificationStatus.Read: case NotificationStatus.Read:
return { label: getNotificationStatusLabel(s), className: 'bg-emerald-50 text-emerald-800 border-emerald-300 font-semibold' }; return { label: getNotificationStatusLabel(s), className: 'bg-emerald-50 text-emerald-800 border-emerald-300 font-semibold' };
@@ -0,0 +1,325 @@
/**
* Markdown & Branded HTML Formatting Utilities for PostFundManagement (PFM)
*/
export const PFM_BRAND_LOGO_SVG = `<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 64 64" width="40" height="40" fill="none">
<defs>
<radialGradient id="pfm-ambient" cx="50%" cy="50%" r="50%">
<stop offset="0%" stop-color="#38bdf8" stop-opacity="0.4" />
<stop offset="70%" stop-color="#2563eb" stop-opacity="0.1" />
<stop offset="100%" stop-color="#1e1b4b" stop-opacity="0" />
</radialGradient>
<linearGradient id="pfm-top" x1="12" y1="8" x2="52" y2="26" gradientUnits="userSpaceOnUse">
<stop offset="0%" stop-color="#a5b4fc" />
<stop offset="35%" stop-color="#6366f1" />
<stop offset="100%" stop-color="#4338ca" />
</linearGradient>
<linearGradient id="pfm-left" x1="10" y1="22" x2="30" y2="56" gradientUnits="userSpaceOnUse">
<stop offset="0%" stop-color="#38bdf8" />
<stop offset="45%" stop-color="#2563eb" />
<stop offset="100%" stop-color="#1e40af" />
</linearGradient>
<linearGradient id="pfm-right" x1="34" y1="28" x2="54" y2="56" gradientUnits="userSpaceOnUse">
<stop offset="0%" stop-color="#22d3ee" />
<stop offset="50%" stop-color="#06b6d4" />
<stop offset="100%" stop-color="#10b981" />
</linearGradient>
<linearGradient id="pfm-core" x1="27" y1="27" x2="37" y2="37" gradientUnits="userSpaceOnUse">
<stop offset="0%" stop-color="#ffffff" />
<stop offset="40%" stop-color="#e0f2fe" />
<stop offset="100%" stop-color="#38bdf8" />
</linearGradient>
</defs>
<circle cx="32" cy="32" r="31" fill="#090d16" />
<circle cx="32" cy="32" r="30" stroke="#1e293b" stroke-width="1" />
<circle cx="32" cy="32" r="28" fill="url(#pfm-ambient)" />
<path d="M32 6.5 L53.5 19 C55 19.8 56 21.5 56 23.2 L56 40.8 C56 42.5 55 44.2 53.5 45 L32 57.5 C30.5 58.3 28.5 58.3 27 57.5 L5.5 45 C4 44.2 3 42.5 3 40.8 L3 23.2 C3 21.5 4 19.8 5.5 19 L27 6.5 C28.5 5.7 30.5 5.7 32 6.5 Z" stroke="#3b82f6" stroke-opacity="0.3" stroke-width="1.2" fill="none" />
<path d="M32 9 L51.5 20.2 L43 25.2 L32 18.8 L21 25.2 L12.5 20.2 Z" fill="url(#pfm-top)" />
<path d="M10 23 L19.2 28.4 L19.2 44.5 L30 50.8 L30 55.5 L10 43.8 Z" fill="url(#pfm-left)" />
<path d="M54 23 L54 43.8 L34 55.5 L34 50.8 L44.8 44.5 L44.8 28.4 Z" fill="url(#pfm-right)" />
<path d="M22 28.5 L32 22.8 L42 28.5 L42 35.5 L32 41.2 L22 35.5 Z" fill="#0b1120" stroke="#334155" stroke-width="1" />
<path d="M25 31.5 L32 27.5 L39 31.5 L32 35.5 Z" fill="url(#pfm-top)" opacity="0.9" />
<path d="M25 37 L32 41 L39 37 L32 33 L25 37 Z" fill="url(#pfm-right)" opacity="0.9" />
<polygon points="32,27 36.5,32 32,37 27.5,32" fill="url(#pfm-core)" />
<circle cx="32" cy="32" r="1.5" fill="#ffffff" />
</svg>`;
export const PFM_LOGO_DATA_URI = `data:image/svg+xml;utf8,${encodeURIComponent(PFM_BRAND_LOGO_SVG)}`;
export interface MarkdownRenderOptions {
applyBranding?: boolean;
emailFormat?: boolean;
subject?: string;
tenantName?: string;
}
/**
* Checks if the content string is formatted HTML or Markdown.
*/
export function isHtmlContent(content: string): boolean {
if (!content) return false;
const trimmed = content.trim();
return (
trimmed.startsWith('<!DOCTYPE') ||
trimmed.startsWith('<html') ||
trimmed.startsWith('<div') ||
trimmed.startsWith('<table') ||
trimmed.startsWith('<p') ||
trimmed.startsWith('<h') ||
trimmed.startsWith('<ul') ||
trimmed.startsWith('<ol') ||
trimmed.startsWith('<blockquote') ||
(trimmed.includes('</') && trimmed.includes('>')) ||
/<(strong|b|em|i|u|h[1-6]|ul|ol|li|table|blockquote|div|p|img|hr|br)\b/i.test(trimmed)
);
}
/**
* Extracts raw plain text from markdown or HTML for concise notification snippets.
*/
export function stripMarkdown(text: string): string {
if (!text) return '';
return text
.replace(/<[^>]*>/g, ' ') // Strip HTML tags
.replace(/!\[.*?\]\(.*?\)/g, '') // Strip images
.replace(/\[(.*?)\]\(.*?\)/g, '$1') // Extract link text
.replace(/#{1,6}\s+/g, '') // Strip headers
.replace(/(\*\*|__)(.*?)\1/g, '$2') // Strip bold
.replace(/(\*|_)(.*?)\1/g, '$2') // Strip italic
.replace(/~~(.*?)~~/g, '$1') // Strip strikethrough
.replace(/`{1,3}.*?`{1,3}/gs, '') // Strip code blocks
.replace(/^>\s+/gm, '') // Strip blockquotes
.replace(/^[-*+]\s+/gm, '') // Strip list markers
.replace(/^\d+\.\s+/gm, '') // Strip ordered list markers
.replace(/\|/g, ' ') // Replace table pipes
.replace(/\s+/g, ' ')
.trim();
}
/**
* Parses markdown text into semantic HTML.
*/
export function parseMarkdownToHtml(markdown: string): string {
if (!markdown) return '';
let html = markdown
// Sanitize script tags
.replace(/<script\b[^<]*(?:(?!<\/script>)<[^<]*)*<\/script>/gi, '')
// Normalize line endings
.replace(/\r\n/g, '\n');
// 1. Code blocks (```lang\ncode\n```)
html = html.replace(/```([a-zA-Z0-9_-]*)\n([\s\S]*?)```/g, (_match, _lang, code) => {
const escaped = code
.replace(/&/g, '&amp;')
.replace(/</g, '&lt;')
.replace(/>/g, '&gt;');
return `<pre style="background:#0b1120;border:1px solid #1e293b;border-radius:12px;padding:14px;overflow-x:auto;font-family:ui-monospace,SFMono-Regular,Menlo,Monaco,Consolas,monospace;font-size:12px;color:#e2e8f0;margin:12px 0;"><code>${escaped}</code></pre>`;
});
// 2. Inline code (`code`)
html = html.replace(/`([^`]+)`/g, (_match, code) => {
const escaped = code
.replace(/&/g, '&amp;')
.replace(/</g, '&lt;')
.replace(/>/g, '&gt;');
return `<code style="background:#1e293b;color:#38bdf8;padding:2px 6px;border-radius:6px;font-family:monospace;font-size:12px;">${escaped}</code>`;
});
// 3. Tables (| A | B |\n|---|---|\n| C | D |)
html = html.replace(/((?:\|[^\n]+\|\n?)+)/g, (match) => {
const rows = match.trim().split('\n');
if (rows.length < 2) return match;
const hasSeparator = rows[1] && rows[1].includes('---');
if (!hasSeparator) return match;
const parseCells = (row: string) =>
row
.split('|')
.slice(1, -1)
.map((c) => c.trim());
const headers = parseCells(rows[0]);
const bodyRows = rows.slice(2);
let tableHtml = `<div style="overflow-x:auto;margin:14px 0;"><table style="width:100%;border-collapse:collapse;border:1px solid #334155;border-radius:8px;font-size:12px;text-align:left;">`;
tableHtml += `<thead><tr style="background:#1e293b;color:#f8fafc;">`;
headers.forEach((h) => {
tableHtml += `<th style="padding:8px 12px;border:1px solid #334155;font-weight:600;">${h}</th>`;
});
tableHtml += `</tr></thead><tbody>`;
bodyRows.forEach((row, i) => {
const cells = parseCells(row);
const bg = i % 2 === 0 ? '#0f172a' : '#1e293b/40';
tableHtml += `<tr style="background:${bg};color:#cbd5e1;">`;
cells.forEach((c) => {
tableHtml += `<td style="padding:8px 12px;border:1px solid #334155;">${c}</td>`;
});
tableHtml += `</tr>`;
});
tableHtml += `</tbody></table></div>`;
return tableHtml;
});
// 4. Headers (# through ####)
html = html.replace(/^#### (.*?)$/gm, '<h4 style="font-size:14px;font-weight:700;color:#f8fafc;margin:12px 0 6px 0;">$1</h4>');
html = html.replace(/^### (.*?)$/gm, '<h3 style="font-size:16px;font-weight:700;color:#f8fafc;margin:14px 0 8px 0;">$1</h3>');
html = html.replace(/^## (.*?)$/gm, '<h2 style="font-size:18px;font-weight:800;color:#ffffff;margin:16px 0 8px 0;border-bottom:1px solid #1e293b;padding-bottom:6px;">$1</h2>');
html = html.replace(/^# (.*?)$/gm, '<h1 style="font-size:22px;font-weight:800;color:#ffffff;margin:18px 0 10px 0;border-bottom:1px solid #334155;padding-bottom:8px;">$1</h1>');
// 5. Blockquotes and GitHub alerts (> [!NOTE], > [!IMPORTANT], etc.)
html = html.replace(/^>\s*\[!NOTE\]\s*(.*?)$/gim, '<div style="background:#0c4a6e22;border-left:4px solid #0284c7;padding:10px 14px;border-radius:8px;margin:10px 0;color:#bae6fd;font-size:12px;"><strong>NOTE:</strong> $1</div>');
html = html.replace(/^>\s*\[!IMPORTANT\]\s*(.*?)$/gim, '<div style="background:#83184322;border-left:4px solid #db2777;padding:10px 14px;border-radius:8px;margin:10px 0;color:#fbcfe8;font-size:12px;"><strong>IMPORTANT:</strong> $1</div>');
html = html.replace(/^>\s*\[!WARNING\]\s*(.*?)$/gim, '<div style="background:#713f1222;border-left:4px solid #d97706;padding:10px 14px;border-radius:8px;margin:10px 0;color:#fef08a;font-size:12px;"><strong>WARNING:</strong> $1</div>');
html = html.replace(/^>\s*\[!TIP\]\s*(.*?)$/gim, '<div style="background:#064e3b22;border-left:4px solid #059669;padding:10px 14px;border-radius:8px;margin:10px 0;color:#a7f3d0;font-size:12px;"><strong>TIP:</strong> $1</div>');
html = html.replace(/^>\s*(.*?)$/gm, '<blockquote style="border-left:3px solid #3b82f6;padding-left:12px;margin:8px 0;color:#94a3b8;font-style:italic;">$1</blockquote>');
// 6. Horizontal Rules (---)
html = html.replace(/^(?:---|\*\*\*|___)\s*$/gm, '<hr style="border:none;border-top:1px solid #334155;margin:16px 0;" />');
// 7. Bold & Italic
html = html.replace(/\*\*\*(.*?)\*\*\*/g, '<strong><em>$1</em></strong>');
html = html.replace(/\*\*(.*?)\*\*/g, '<strong style="color:#ffffff;">$1</strong>');
html = html.replace(/__(.*?)__/g, '<strong style="color:#ffffff;">$1</strong>');
html = html.replace(/\*(.*?)\*/g, '<em style="color:#cbd5e1;">$1</em>');
html = html.replace(/_(.*?)_/g, '<em style="color:#cbd5e1;">$1</em>');
html = html.replace(/~~(.*?)~~/g, '<del style="color:#64748b;">$1</del>');
// 8. Images and Links
html = html.replace(/!\[(.*?)\]\((.*?)\)/g, '<img src="$2" alt="$1" style="max-width:100%;border-radius:8px;margin:10px 0;box-shadow:0 4px 12px rgba(0,0,0,0.5);" />');
html = html.replace(/\[(.*?)\]\((.*?)\)/g, '<a href="$2" target="_blank" rel="noopener noreferrer" style="color:#38bdf8;text-decoration:underline;">$1</a>');
// 9. Unordered Lists (- or *)
html = html.replace(/^[-*+]\s+(.*?)$/gm, '<li style="margin-left:20px;list-style-type:disc;color:#cbd5e1;padding:2px 0;">$1</li>');
// 10. Ordered Lists (1. 2.)
html = html.replace(/^\d+\.\s+(.*?)$/gm, '<li style="margin-left:20px;list-style-type:decimal;color:#cbd5e1;padding:2px 0;">$1</li>');
// 11. Paragraphs
const paragraphs = html.split(/\n{2,}/);
html = paragraphs
.map((p) => {
const trimmed = p.trim();
if (!trimmed) return '';
if (
trimmed.startsWith('<h') ||
trimmed.startsWith('<div') ||
trimmed.startsWith('<table') ||
trimmed.startsWith('<pre') ||
trimmed.startsWith('<blockquote') ||
trimmed.startsWith('<hr') ||
trimmed.startsWith('<li')
) {
return trimmed;
}
return `<p style="margin:8px 0;line-height:1.6;color:#cbd5e1;">${trimmed.replace(/\n/g, '<br />')}</p>`;
})
.join('\n');
return html;
}
/**
* Transforms Markdown into full branded executive HTML for email notifications.
*/
export function renderMarkdownToHtml(
markdown: string,
options?: MarkdownRenderOptions
): string {
const trimmed = (markdown || '').trim();
if (trimmed.startsWith('<!DOCTYPE') || trimmed.startsWith('<html')) {
return trimmed;
}
const contentHtml = isHtmlContent(trimmed)
? trimmed.replace(/<script\b[^<]*(?:(?!<\/script>)<[^<]*)*<\/script>/gi, '')
: parseMarkdownToHtml(trimmed);
if (!options?.applyBranding && !options?.emailFormat) {
return contentHtml;
}
const subject = options?.subject || 'Statutory Platform Notification';
const tenantName = options?.tenantName || 'Post-Fund Management (PFM) Platform';
const dateFormatted = new Date().toLocaleDateString('en-ZA', {
day: 'numeric',
month: 'short',
year: 'numeric',
});
return `<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>${subject}</title>
</head>
<body style="margin:0;padding:24px 12px;background-color:#090d16;font-family:-apple-system,BlinkMacSystemFont,'Segoe UI',Roboto,Helvetica,Arial,sans-serif;color:#cbd5e1;">
<table role="presentation" width="100%" cellpadding="0" cellspacing="0" style="max-width:640px;margin:0 auto;background-color:#0d1322;border:1px solid #1e293b;border-radius:16px;overflow:hidden;box-shadow:0 12px 36px rgba(0,0,0,0.6);">
<!-- Executive Brand Header -->
<tr>
<td style="padding:20px 24px;background:linear-gradient(135deg, #0b1120 0%, #1e1b4b 50%, #0f172a 100%);border-bottom:1px solid #1e293b;">
<table role="presentation" width="100%" cellpadding="0" cellspacing="0">
<tr>
<td width="48" style="vertical-align:middle;">
<img src="${PFM_LOGO_DATA_URI}" alt="PFM Logo" width="40" height="40" style="display:block;border-radius:10px;" />
</td>
<td style="padding-left:14px;vertical-align:middle;">
<div style="font-size:14px;font-weight:800;color:#ffffff;letter-spacing:-0.02em;text-transform:uppercase;">
Post-Fund Management (PFM)
</div>
<div style="font-size:11px;color:#38bdf8;font-weight:600;margin-top:2px;">
${tenantName}
</div>
</td>
<td align="right" style="vertical-align:middle;">
<span style="font-size:10px;font-weight:700;color:#38bdf8;background-color:rgba(56,189,248,0.12);border:1px solid rgba(56,189,248,0.25);padding:4px 8px;border-radius:20px;text-transform:uppercase;letter-spacing:0.04em;">
Official Notice
</span>
</td>
</tr>
</table>
</td>
</tr>
<!-- Subject Banner -->
<tr>
<td style="padding:16px 24px;background-color:#0b101b;border-bottom:1px solid #1e293b;">
<h2 style="margin:0;font-size:16px;font-weight:700;color:#ffffff;line-height:1.4;">
${subject}
</h2>
<div style="font-size:11px;color:#64748b;margin-top:4px;">
Dispatched on ${dateFormatted} • Electronic System Communication
</div>
</td>
</tr>
<!-- Message Content Body -->
<tr>
<td style="padding:24px;font-size:13px;line-height:1.65;color:#e2e8f0;">
${contentHtml}
</td>
</tr>
<!-- Executive Footer -->
<tr>
<td style="padding:18px 24px;background-color:#080c14;border-top:1px solid #1e293b;font-size:11px;color:#64748b;text-align:center;">
<div style="font-weight:600;color:#94a3b8;margin-bottom:4px;">
Post-Fund Management, Performance & Impact Governance Platform
</div>
<div>
This message was transmitted securely under active multi-tenant encryption. Please do not reply directly to automated dispatch notices.
</div>
<div style="margin-top:8px;font-size:10px;color:#475569;">
&copy; ${new Date().getFullYear()} PFM Governance Architecture. All rights reserved.
</div>
</td>
</tr>
</table>
</body>
</html>`;
}
@@ -1,11 +1,50 @@
import { QueryClient } from '@tanstack/react-query'; import { QueryClient, QueryKey, InvalidateQueryFilters } from '@tanstack/react-query';
import { bumpMutationEpoch } from '../services/httpClient';
export const queryClient = new QueryClient({ export const queryClient = new QueryClient({
defaultOptions: { defaultOptions: {
queries: { queries: {
staleTime: 1000 * 60 * 3, // 3 minutes default cache // Remove caching from all pages: zero stale time and zero cache retention
refetchOnWindowFocus: false, // Whenever a component needs data, it must call its corresponding endpoint(s)
staleTime: 0,
gcTime: 0,
refetchOnMount: 'always',
refetchOnWindowFocus: 'always',
refetchOnReconnect: 'always',
retry: 1, 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)
* that bypasses backend ASP.NET Core OutputCaching.
*/
export async function invalidateAndBustCache(
client: QueryClient,
filters?: InvalidateQueryFilters
): Promise<void> {
bumpMutationEpoch();
await client.invalidateQueries(filters);
}
File diff suppressed because it is too large Load Diff
@@ -20,6 +20,7 @@ import {
User, User,
Clock, Clock,
ArrowRight, ArrowRight,
Lock,
} from 'lucide-react'; } from 'lucide-react';
import { import {
getProjects, getProjects,
@@ -110,7 +111,7 @@ type ResolveChangeFormValues = z.infer<typeof resolveChangeSchema>;
export const ChangeManagementPage: React.FC = () => { export const ChangeManagementPage: React.FC = () => {
const queryClient = useQueryClient(); const queryClient = useQueryClient();
const { success, error: toastError } = useToast(); const { success, error: toastError } = useToast();
const { canAdd, canApprove, canExport } = useRbac(); const { canAdd, canApprove, canExport, canApproveRecord, isMaker } = useRbac();
const [selectedProjectId, setSelectedProjectId] = useState<string>('all'); const [selectedProjectId, setSelectedProjectId] = useState<string>('all');
const [activeTab, setActiveTab] = useState<'issues' | 'changes'>('issues'); const [activeTab, setActiveTab] = useState<'issues' | 'changes'>('issues');
@@ -238,7 +239,7 @@ export const ChangeManagementPage: React.FC = () => {
issueForm.reset(); issueForm.reset();
success('Issue Logged', 'New project issue created and assigned.'); success('Issue Logged', 'New project issue created and assigned.');
}, },
onError: (err: any) => toastError('Creation Failed', err), onError: (err: unknown) => toastError('Creation Failed', err),
}); });
const updateIssueMutation = useMutation({ const updateIssueMutation = useMutation({
@@ -273,7 +274,7 @@ export const ChangeManagementPage: React.FC = () => {
editIssueForm.reset(); editIssueForm.reset();
success('Issue Updated', 'Issue record and resolution status saved successfully.'); success('Issue Updated', 'Issue record and resolution status saved successfully.');
}, },
onError: (err: any) => toastError('Update Failed', err), onError: (err: unknown) => toastError('Update Failed', err),
}); });
const resolveIssueMutation = useMutation({ const resolveIssueMutation = useMutation({
@@ -297,7 +298,7 @@ export const ChangeManagementPage: React.FC = () => {
resolveIssueForm.reset(); resolveIssueForm.reset();
success('Issue Resolved', 'Resolution recorded and issue status updated.'); success('Issue Resolved', 'Resolution recorded and issue status updated.');
}, },
onError: (err: any) => toastError('Resolution Failed', err), onError: (err: unknown) => toastError('Resolution Failed', err),
}); });
const createChangeMutation = useMutation({ const createChangeMutation = useMutation({
@@ -317,7 +318,7 @@ export const ChangeManagementPage: React.FC = () => {
changeForm.reset(); changeForm.reset();
success('Change Request Submitted', 'Formal change request submitted for review.'); success('Change Request Submitted', 'Formal change request submitted for review.');
}, },
onError: (err: any) => toastError('Submission Failed', err), onError: (err: unknown) => toastError('Submission Failed', err),
}); });
const resolveChangeMutation = useMutation({ const resolveChangeMutation = useMutation({
@@ -338,12 +339,15 @@ export const ChangeManagementPage: React.FC = () => {
resolveChangeForm.reset(); resolveChangeForm.reset();
success('Change Decision Applied', 'Change request decision successfully resolved.'); success('Change Decision Applied', 'Change request decision successfully resolved.');
}, },
onError: (err: any) => toastError('Decision Failed', err), onError: (err: unknown) => toastError('Decision Failed', err),
}); });
const updateChangeMutation = useMutation({ const updateChangeMutation = useMutation({
mutationFn: (vals: EditChangeFormValues) => { mutationFn: (vals: EditChangeFormValues) => {
if (!editingChange) throw new Error('No change request selected'); if (!editingChange) throw new Error('No change request selected');
if (Number(editingChange.status) === ApprovalStatus.Approved) {
throw new Error('Approved change requests are immutable and cannot be updated.');
}
return updateChangeRequest({ return updateChangeRequest({
changeRequestId: editingChange.id, changeRequestId: editingChange.id,
title: vals.title, title: vals.title,
@@ -359,7 +363,7 @@ export const ChangeManagementPage: React.FC = () => {
editChangeForm.reset(); editChangeForm.reset();
success('Change Request Updated', 'Change proposal and governance status saved.'); success('Change Request Updated', 'Change proposal and governance status saved.');
}, },
onError: (err: any) => toastError('Update Failed', err), onError: (err: unknown) => toastError('Update Failed', err),
}); });
// Filtered & Sorted Issues // Filtered & Sorted Issues
@@ -481,6 +485,10 @@ export const ChangeManagementPage: React.FC = () => {
}; };
const openEditChange = async (cr: ChangeRequest) => { const openEditChange = async (cr: ChangeRequest) => {
if (Number(cr.status) === ApprovalStatus.Approved) {
toastError('Record Immutable', 'Approved and enacted change requests are immutable.');
return;
}
try { try {
// 1. Uses GET method (NOT POST) // 1. Uses GET method (NOT POST)
// 2. Passes ChangeRequestId (NOT ProjectId) // 2. Passes ChangeRequestId (NOT ProjectId)
@@ -1009,20 +1017,21 @@ export const ChangeManagementPage: React.FC = () => {
</td> </td>
<td className="py-3.5 px-5 text-right whitespace-nowrap space-x-2"> <td className="py-3.5 px-5 text-right whitespace-nowrap space-x-2">
<div className="inline-flex items-center gap-1.5"> <div className="inline-flex items-center gap-1.5">
<button {Number(cr.status) === ApprovalStatus.Approved ? (
onClick={() => openEditChange(cr)} <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.">
className="p-1 text-slate-400 hover:text-white hover:bg-slate-800 rounded-lg transition cursor-pointer" <Lock className="w-3 h-3 text-emerald-400" />
title="Edit Change Request" <span>Resolved &amp; Enacted</span>
>
<Edit2 className="w-3.5 h-3.5" />
</button>
{cr.status === ApprovalStatus.Approved ? (
<span className="text-xs font-semibold text-emerald-400 bg-emerald-500/10 px-2.5 py-1 rounded-lg border border-emerald-500/20">
Resolved & Enacted
</span> </span>
) : ( ) : (
<> <>
{canApprove('change_management') && ( <button
onClick={() => openEditChange(cr)}
className="p-1 text-slate-400 hover:text-white hover:bg-slate-800 rounded-lg transition cursor-pointer"
title="Edit Change Request"
>
<Edit2 className="w-3.5 h-3.5" />
</button>
{canApproveRecord('change_management', cr.createdBy) && (
<button <button
onClick={() => openResolveChange(cr, ApprovalStatus.Approved)} onClick={() => openResolveChange(cr, ApprovalStatus.Approved)}
className="px-2.5 py-1 text-xs font-semibold text-emerald-300 bg-emerald-950/50 hover:bg-emerald-900/60 border border-emerald-700/50 rounded-lg transition cursor-pointer flex items-center gap-1" className="px-2.5 py-1 text-xs font-semibold text-emerald-300 bg-emerald-950/50 hover:bg-emerald-900/60 border border-emerald-700/50 rounded-lg transition cursor-pointer flex items-center gap-1"
@@ -1030,13 +1039,10 @@ export const ChangeManagementPage: React.FC = () => {
<Check className="w-3 h-3" /> Approve <Check className="w-3 h-3" /> Approve
</button> </button>
)} )}
{canAdd('change_management') && ( {canApprove('change_management') && isMaker(cr.createdBy) && (
<button <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">
onClick={() => openResolveChange(cr, ApprovalStatus.Rejected)} Maker-Checker
className="px-2.5 py-1 text-xs font-semibold text-rose-300 bg-rose-950/50 hover:bg-rose-900/60 border border-rose-700/50 rounded-lg transition cursor-pointer flex items-center gap-1" </span>
>
<XCircle className="w-3 h-3" /> Reject
</button>
)} )}
</> </>
)} )}
@@ -1516,17 +1522,39 @@ export const ChangeManagementPage: React.FC = () => {
<div> <div>
<label className="block text-xs font-semibold text-slate-300 mb-1">Governance Status *</label> <label className="block text-xs font-semibold text-slate-300 mb-1">Governance Status *</label>
<select {editingChange && Number(editingChange.status) === ApprovalStatus.Approved ? (
{...editChangeForm.register('status')} <div
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-amber-500" className="w-full bg-slate-900 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"
> title="Approved and enacted change requests are immutable."
<option value={ApprovalStatus.Pending}>Pending Review</option> >
<option value={ApprovalStatus.Review}>Under Active Review</option> <Lock className="w-3.5 h-3.5 text-emerald-400" />
<option value={ApprovalStatus.Approved} disabled={!canApprove('change_management')}> <span>Approved &amp; Enacted</span>
Approved &amp; Enacted {!canApprove('change_management') ? '(Requires Update Permission)' : ''} </div>
</option> ) : (
<option value={ApprovalStatus.Rejected}>Rejected</option> <select
</select> {...editChangeForm.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-amber-500"
>
<option value={ApprovalStatus.Pending}>Pending Review</option>
<option value={ApprovalStatus.Review}>Under Active Review</option>
{(() => {
const canApproveChange = canApproveRecord('change_management', editingChange?.createdBy);
const isSelf = isMaker(editingChange?.createdBy);
let reason = '';
if (!canApprove('change_management')) {
reason = ' (Requires Update Permission)';
} else if (isSelf) {
reason = ' (Maker-Checker: Cannot approve own record)';
}
return (
<option value={ApprovalStatus.Approved} disabled={!canApproveChange}>
Approved &amp; Enacted {reason}
</option>
);
})()}
<option value={ApprovalStatus.Rejected}>Rejected</option>
</select>
)}
</div> </div>
</div> </div>
@@ -0,0 +1,529 @@
import React, { useState, useMemo } from 'react';
import { useQuery } from '@tanstack/react-query';
import {
ShieldCheck,
ShieldAlert,
AlertTriangle,
Users,
Search,
Filter,
RefreshCw,
Eye,
CheckCircle2,
Coins,
Building2,
FileSpreadsheet,
Download,
AlertCircle,
} from 'lucide-react';
import {
getProjects,
getAllProgrammes,
getAllPortfolios,
getBudgets,
getProjectComplianceItems,
getProjectBeneficiaries,
getBeneficiaryOutcomes,
getProjectRisks,
} from '../services/api';
import {
Project,
Programme,
Portfolio,
ComplianceItem,
Beneficiary,
Outcome,
ComplianceStatus,
ApprovalStatus,
} from '../types';
import {
formatDate,
formatZAR,
formatCompactZAR,
getIndustrySectorLabel,
getProjectTypeLabel,
getApprovalStatusBadge,
} from '../lib/formatters';
import { HelpTooltip } from '../components/ui/HelpTooltip';
import { ComplianceStatusBadge } from '../components/ui/StatusBadge';
import { ProjectComplianceModal } from '../components/projects/ProjectComplianceModal';
import { ExportButton } from '../components/ui/ExportButton';
import { ExportColumn } from '../utils/exportUtils';
export const CompliancePage: React.FC = () => {
const [searchTerm, setSearchTerm] = useState('');
const [selectedProgramme, setSelectedProgramme] = useState('All');
const [selectedComplianceFilter, setSelectedComplianceFilter] = useState<'All' | 'Compliant' | 'Noncompliant'>('All');
const [selectedProjectForAudit, setSelectedProjectForAudit] = useState<Project | null>(null);
const [isAuditModalOpen, setIsAuditModalOpen] = useState(false);
// Queries
const { data: projects = [], isLoading: loadingProjects, refetch: refetchProjects, isFetching } = useQuery({
queryKey: ['projects'],
queryFn: () => getProjects(),
});
const { data: programmes = [] } = useQuery({
queryKey: ['programmes'],
queryFn: () => getAllProgrammes(),
});
const { data: portfolios = [] } = useQuery({
queryKey: ['portfolios'],
queryFn: () => getAllPortfolios(),
});
const { data: allBudgets = [] } = useQuery({
queryKey: ['budgets'],
queryFn: () => getBudgets(1, 200),
});
const { data: beneficiariesMap = new Map<string | number, Beneficiary[]>() } = useQuery({
queryKey: ['allProjectBeneficiaries', projects.map((p) => p.id)],
queryFn: async () => {
const map = new Map<string | number, Beneficiary[]>();
await Promise.all(
projects.map(async (p) => {
try {
const bens = await getProjectBeneficiaries(p.id);
map.set(p.id, bens);
} catch {
map.set(p.id, []);
}
})
);
return map;
},
enabled: projects.length > 0,
});
// Query compliance items for all projects
const { data: complianceMap = new Map<string | number, ComplianceItem[]>(), isLoading: loadingComps } = useQuery({
queryKey: ['allProjectCompliance', projects.map((p) => p.id)],
queryFn: async () => {
const map = new Map<string | number, ComplianceItem[]>();
await Promise.all(
projects.map(async (p) => {
try {
const items = await getProjectComplianceItems(p.id);
map.set(p.id, items);
} catch {
map.set(p.id, []);
}
})
);
return map;
},
enabled: projects.length > 0,
});
// Query beneficiary outcomes
const allBensList = useMemo(() => {
const list: Beneficiary[] = [];
beneficiariesMap.forEach((bList) => list.push(...bList));
return list;
}, [beneficiariesMap]);
const { data: outcomesMap = new Map<string | number, Outcome[]>() } = useQuery({
queryKey: ['allBeneficiaryOutcomes', allBensList.map((b) => b.id)],
queryFn: async () => {
const map = new Map<string | number, Outcome[]>();
await Promise.all(
allBensList.map(async (b) => {
try {
const outs = await getBeneficiaryOutcomes(b.id);
map.set(b.id, outs);
} catch {
map.set(b.id, []);
}
})
);
return map;
},
enabled: allBensList.length > 0,
});
// Lookups
const programmeMap = useMemo(() => {
const map = new Map<string | number, Programme>();
programmes.forEach((pr) => map.set(pr.id, pr));
return map;
}, [programmes]);
// Aggregate project compliance records
const projectComplianceStats = useMemo(() => {
return projects.map((p) => {
const prog = programmeMap.get(p.programmeId);
const items = complianceMap.get(p.id) || [];
const projectBens = beneficiariesMap.get(p.id) || [];
let totalOutcomes = 0;
let metOutcomes = 0;
projectBens.forEach((b) => {
const bOutcomes = outcomesMap.get(b.id) || [];
totalOutcomes += bOutcomes.length;
metOutcomes += bOutcomes.filter(
(o) => Number(o.status) === ComplianceStatus.Met || Number(o.status) === ComplianceStatus.Exceeded
).length;
});
const totalItems = items.length;
const metItems = items.filter(
(i) => Number(i.status) === ComplianceStatus.Met || Number(i.status) === ComplianceStatus.Exceeded
).length;
const unmetItems = totalItems - metItems;
const unmetOutcomes = totalOutcomes - metOutcomes;
const isCompliant = unmetItems === 0 && unmetOutcomes === 0;
const projectBudgets = allBudgets.filter((b) => String(b.projectId) === String(p.id));
const approvedBudget = projectBudgets
.filter((b) => Number(b.status) === ApprovalStatus.Approved || b.status === undefined)
.reduce((sum, b) => sum + (Number(b.amount) || 0), 0);
const budgetAtRisk = isCompliant ? 0 : approvedBudget;
return {
project: p,
programme: prog,
totalItems,
metItems,
unmetItems,
totalOutcomes,
metOutcomes,
unmetOutcomes,
beneficiariesCount: projectBens.length,
isCompliant,
approvedBudget,
budgetAtRisk,
};
});
}, [projects, programmeMap, complianceMap, beneficiariesMap, outcomesMap, allBudgets]);
// Filtered rows
const filteredRows = useMemo(() => {
return projectComplianceStats.filter((row) => {
if (selectedProgramme !== 'All' && String(row.project.programmeId) !== selectedProgramme) {
return false;
}
if (selectedComplianceFilter === 'Compliant' && !row.isCompliant) {
return false;
}
if (selectedComplianceFilter === 'Noncompliant' && row.isCompliant) {
return false;
}
if (searchTerm.trim()) {
const q = searchTerm.toLowerCase();
const pName = (row.project.name || '').toLowerCase();
const prName = (row.programme?.name || '').toLowerCase();
const idStr = String(row.project.id);
if (!pName.includes(q) && !prName.includes(q) && !idStr.includes(q)) {
return false;
}
}
return true;
});
}, [projectComplianceStats, selectedProgramme, selectedComplianceFilter, searchTerm]);
// Aggregate KPI figures
const totalProjectsCount = projectComplianceStats.length;
const compliantProjectsCount = projectComplianceStats.filter((r) => r.isCompliant).length;
const noncompliantProjectsCount = totalProjectsCount - compliantProjectsCount;
const overallComplianceRate = totalProjectsCount > 0 ? Math.round((compliantProjectsCount / totalProjectsCount) * 100) : 100;
const totalBudgetAtRisk = projectComplianceStats.reduce((sum, r) => sum + r.budgetAtRisk, 0);
const totalBeneficiaries = allBensList.length;
const complianceExportColumns: ExportColumn<any>[] = [
{ header: 'Project ID', accessor: (r) => r.project.id },
{ header: 'Project Name', accessor: (r) => r.project.name },
{ header: 'Programme', accessor: (r) => r.programme?.name || `Prog #${r.project.programmeId}` },
{ header: 'Sector', accessor: (r) => getIndustrySectorLabel(r.project.sector) },
{ header: 'Compliance Status', accessor: (r) => (r.isCompliant ? '100% Compliant' : 'Noncompliant') },
{ header: 'Requirements Met', accessor: (r) => `${r.metItems}/${r.totalItems}` },
{ header: 'Outcomes Met', accessor: (r) => `${r.metOutcomes}/${r.totalOutcomes}` },
{ header: 'Approved Budget', accessor: (r) => formatZAR(r.approvedBudget) },
{ header: 'Budget At Risk', accessor: (r) => formatZAR(r.budgetAtRisk) },
];
return (
<div className="space-y-6">
{/* Header Banner */}
<div className="bg-gradient-to-r from-[#0d1322] via-[#0f172a] to-[#0d1322] p-6 rounded-3xl border border-slate-800 shadow-xl relative overflow-hidden flex flex-col md:flex-row md:items-center justify-between gap-4">
<div className="space-y-1 relative z-10">
<div className="flex items-center gap-2">
<div className="p-2 bg-blue-500/10 border border-blue-500/20 rounded-xl text-blue-400">
<ShieldCheck className="w-5 h-5" />
</div>
<span className="text-xs font-bold uppercase tracking-wider text-blue-400">
Statutory Governance & Compliance
</span>
</div>
<h1 className="text-2xl sm:text-3xl font-extrabold text-white tracking-tight">
Portfolio Compliance Dashboard
</h1>
<p className="text-xs sm:text-sm text-slate-400 max-w-2xl">
Enterprise-wide compliance audit across all funded projects, beneficiary impact outcomes, and capital at risk.
</p>
</div>
<div className="flex items-center gap-2.5 relative z-10 self-start md:self-center">
<ExportButton
claim="compliance"
data={filteredRows}
columns={complianceExportColumns}
filename={`portfolio_compliance_${new Date().toISOString().split('T')[0]}`}
title="Portfolio Compliance Register"
/>
<button
type="button"
onClick={() => refetchProjects()}
disabled={isFetching}
className="inline-flex items-center gap-1.5 px-3.5 py-2 text-xs font-semibold text-white bg-blue-600 hover:bg-blue-500 rounded-xl transition cursor-pointer shadow-md shadow-blue-600/30 disabled:opacity-50"
>
<RefreshCw className={`w-3.5 h-3.5 ${isFetching ? 'animate-spin' : ''}`} />
<span>Refresh</span>
</button>
</div>
</div>
{/* 5 Headline KPI Cards */}
<div className="grid grid-cols-2 md:grid-cols-3 lg:grid-cols-5 gap-4">
{/* Card 1: Overall Rate */}
<div className="bg-[#0f1523]/80 p-5 rounded-2xl border border-slate-800/80 shadow-md backdrop-blur-sm flex flex-col justify-between">
<div className="flex items-center justify-between text-slate-400">
<span className="text-[11px] font-semibold uppercase tracking-wider text-slate-400">Compliance Rate</span>
<div className="p-1.5 rounded-lg bg-blue-500/10 text-blue-400 border border-blue-500/20">
<ShieldCheck className="w-4 h-4" />
</div>
</div>
<div className="mt-3">
<p className="text-2xl font-bold text-white tracking-tight">{overallComplianceRate}%</p>
<p className="text-[10px] text-emerald-400 font-mono mt-1">100% threshold target</p>
</div>
</div>
{/* Card 2: Compliant Projects */}
<div className="bg-[#0f1523]/80 p-5 rounded-2xl border border-slate-800/80 shadow-md backdrop-blur-sm flex flex-col justify-between">
<div className="flex items-center justify-between text-slate-400">
<span className="text-[11px] font-semibold uppercase tracking-wider text-slate-400">Compliant</span>
<div className="p-1.5 rounded-lg bg-emerald-500/10 text-emerald-400 border border-emerald-500/20">
<CheckCircle2 className="w-4 h-4" />
</div>
</div>
<div className="mt-3">
<p className="text-2xl font-bold text-emerald-400 tracking-tight">{compliantProjectsCount}</p>
<p className="text-[10px] text-slate-400 font-mono mt-1">Eligible for approval</p>
</div>
</div>
{/* Card 3: Noncompliant Projects */}
<div className="bg-[#0f1523]/80 p-5 rounded-2xl border border-slate-800/80 shadow-md backdrop-blur-sm flex flex-col justify-between">
<div className="flex items-center justify-between text-slate-400">
<span className="text-[11px] font-semibold uppercase tracking-wider text-slate-400">Noncompliant</span>
<div className="p-1.5 rounded-lg bg-rose-500/10 text-rose-400 border border-rose-500/20">
<ShieldAlert className="w-4 h-4" />
</div>
</div>
<div className="mt-3">
<p className="text-2xl font-bold text-rose-400 tracking-tight">{noncompliantProjectsCount}</p>
<p className="text-[10px] text-rose-400 font-mono mt-1">Approvals blocked</p>
</div>
</div>
{/* Card 4: Total Capital At Risk */}
<div className="bg-[#0f1523]/80 p-5 rounded-2xl border border-slate-800/80 shadow-md backdrop-blur-sm flex flex-col justify-between">
<div className="flex items-center justify-between text-slate-400">
<span className="text-[11px] font-semibold uppercase tracking-wider text-slate-400">Capital At Risk</span>
<div className="p-1.5 rounded-lg bg-amber-500/10 text-amber-400 border border-amber-500/20">
<AlertTriangle className="w-4 h-4" />
</div>
</div>
<div className="mt-3">
<p className="text-2xl font-bold text-amber-400 tracking-tight">{formatCompactZAR(totalBudgetAtRisk)}</p>
<p className="text-[10px] text-slate-400 font-mono mt-1">{formatZAR(totalBudgetAtRisk)}</p>
</div>
</div>
{/* Card 5: Beneficiaries Monitored */}
<div className="bg-[#0f1523]/80 p-5 rounded-2xl border border-slate-800/80 shadow-md backdrop-blur-sm flex flex-col justify-between">
<div className="flex items-center justify-between text-slate-400">
<span className="text-[11px] font-semibold uppercase tracking-wider text-slate-400">Beneficiaries</span>
<div className="p-1.5 rounded-lg bg-purple-500/10 text-purple-400 border border-purple-500/20">
<Users className="w-4 h-4" />
</div>
</div>
<div className="mt-3">
<p className="text-2xl font-bold text-white tracking-tight">{totalBeneficiaries}</p>
<p className="text-[10px] text-slate-400 font-mono mt-1">Impact groups monitored</p>
</div>
</div>
</div>
{/* Filter Toolbar */}
<div className="bg-[#0f1523]/80 backdrop-blur-md p-4 rounded-2xl border border-slate-800/80 shadow-lg flex flex-wrap items-center justify-between gap-3">
<div className="flex items-center gap-3 flex-wrap">
<div className="relative">
<Search className="w-3.5 h-3.5 absolute left-3 top-1/2 -translate-y-1/2 text-slate-500" />
<input
type="text"
placeholder="Search project, programme, ID..."
value={searchTerm}
onChange={(e) => setSearchTerm(e.target.value)}
className="pl-9 pr-3 py-1.5 bg-[#090d16] border border-slate-700/80 rounded-xl text-xs text-white placeholder-slate-500 focus:outline-none focus:border-blue-500 w-64 transition"
/>
</div>
<div className="flex items-center bg-[#090d16] border border-slate-700/80 rounded-xl px-3 py-1.5 text-xs">
<span className="text-slate-400 mr-2 text-[11px] font-semibold uppercase">Programme:</span>
<select
value={selectedProgramme}
onChange={(e) => setSelectedProgramme(e.target.value)}
className="bg-transparent text-white focus:outline-none cursor-pointer max-w-[180px] truncate"
>
<option value="All" className="bg-slate-900">All Programmes</option>
{programmes.map((pr) => (
<option key={pr.id} value={pr.id} className="bg-slate-900">
{pr.name}
</option>
))}
</select>
</div>
<div className="flex items-center bg-[#090d16] border border-slate-700/80 rounded-xl px-3 py-1.5 text-xs">
<span className="text-slate-400 mr-2 text-[11px] font-semibold uppercase">Compliance:</span>
<select
value={selectedComplianceFilter}
onChange={(e) => setSelectedComplianceFilter(e.target.value as any)}
className="bg-transparent text-white focus:outline-none cursor-pointer"
>
<option value="All" className="bg-slate-900">All Statuses</option>
<option value="Compliant" className="bg-slate-900">Compliant (100%)</option>
<option value="Noncompliant" className="bg-slate-900">Noncompliant (Blocked)</option>
</select>
</div>
</div>
<span className="text-xs text-slate-400 font-medium">
Showing <strong className="text-white">{filteredRows.length}</strong> of {projectComplianceStats.length} projects
</span>
</div>
{/* Projects Compliance Register Table */}
<div className="bg-[#0f1523]/80 rounded-2xl border border-slate-800/80 overflow-hidden shadow-xl backdrop-blur-sm">
<div className="p-4 border-b border-slate-800/80 flex items-center justify-between bg-[#0d121f]/90">
<div className="flex items-center gap-2">
<ShieldCheck className="w-4 h-4 text-cyan-400" />
<h3 className="text-xs font-bold uppercase tracking-wider text-white">
Project Compliance Matrix & Audit Register
</h3>
</div>
<span className="text-[11px] text-slate-400">
Invariant: 100% compliance required for formal project approval
</span>
</div>
<div className="overflow-x-auto">
<table className="w-full text-left text-xs border-collapse">
<thead>
<tr className="bg-[#090d16] border-b border-slate-800 text-slate-400 text-[11px] font-semibold select-none">
<th className="py-3.5 px-5">Project Name & ID</th>
<th className="py-3.5 px-4">Programme</th>
<th className="py-3.5 px-4">Compliance Status</th>
<th className="py-3.5 px-4 text-center">Requirements Met</th>
<th className="py-3.5 px-4 text-center">Outcomes Met</th>
<th className="py-3.5 px-4 text-right">Approved Budget</th>
<th className="py-3.5 px-4 text-right">Capital At Risk</th>
<th className="py-3.5 px-5 text-right font-medium">Actions</th>
</tr>
</thead>
<tbody className="divide-y divide-slate-800/60 font-medium">
{loadingProjects || loadingComps ? (
<tr>
<td colSpan={8} className="py-12 text-center text-slate-400 text-xs">
<div className="flex flex-col items-center gap-2">
<div className="w-6 h-6 border-2 border-blue-500 border-t-transparent rounded-full animate-spin" />
<span>Loading compliance matrix...</span>
</div>
</td>
</tr>
) : filteredRows.length === 0 ? (
<tr>
<td colSpan={8} className="py-10 text-center text-slate-500 text-xs">
No projects matching active compliance filters.
</td>
</tr>
) : (
filteredRows.map((r) => (
<tr key={r.project.id} className="hover:bg-slate-800/40 transition-colors">
<td className="py-3.5 px-5">
<span className="font-semibold text-white block">{r.project.name}</span>
<span className="text-[10px] text-slate-500 font-mono">ID #{r.project.id} &bull; {getIndustrySectorLabel(r.project.sector)}</span>
</td>
<td className="py-3.5 px-4 text-slate-300">
{r.programme?.name || `Prog #${r.project.programmeId}`}
</td>
<td className="py-3.5 px-4">
{r.isCompliant ? (
<span className="inline-flex items-center gap-1.5 px-2.5 py-0.5 rounded-full text-[10px] font-semibold bg-emerald-500/10 text-emerald-400 border border-emerald-500/30">
<CheckCircle2 className="w-3 h-3 text-emerald-400" />
<span>100% Compliant</span>
</span>
) : (
<span className="inline-flex items-center gap-1.5 px-2.5 py-0.5 rounded-full text-[10px] font-semibold bg-rose-500/10 text-rose-400 border border-rose-500/30">
<AlertTriangle className="w-3 h-3 text-rose-400" />
<span>Noncompliant ({r.unmetItems + r.unmetOutcomes} Unmet)</span>
</span>
)}
</td>
<td className="py-3.5 px-4 text-center font-mono font-semibold">
<span className={r.unmetItems === 0 ? 'text-emerald-400' : 'text-rose-400'}>
{r.metItems}/{r.totalItems}
</span>
</td>
<td className="py-3.5 px-4 text-center font-mono font-semibold">
<span className={r.unmetOutcomes === 0 ? 'text-emerald-400' : 'text-purple-400'}>
{r.metOutcomes}/{r.totalOutcomes}
</span>
</td>
<td className="py-3.5 px-4 text-right font-mono text-slate-300">
{formatZAR(r.approvedBudget)}
</td>
<td className="py-3.5 px-4 text-right font-mono font-bold">
<span className={r.budgetAtRisk > 0 ? 'text-rose-400' : 'text-emerald-400'}>
{formatZAR(r.budgetAtRisk)}
</span>
</td>
<td className="py-3.5 px-5 text-right whitespace-nowrap">
<button
type="button"
onClick={() => {
setSelectedProjectForAudit(r.project);
setIsAuditModalOpen(true);
}}
className="inline-flex items-center gap-1 text-xs font-semibold text-emerald-300 hover:text-white bg-emerald-950/40 hover:bg-emerald-900/60 border border-emerald-500/30 px-3 py-1.5 rounded-xl transition cursor-pointer shadow-xs active:scale-95"
>
<ShieldCheck className="w-3.5 h-3.5 text-emerald-400" />
<span>Inspect Compliance</span>
</button>
</td>
</tr>
))
)}
</tbody>
</table>
</div>
</div>
{/* Compliance Audit Modal */}
<ProjectComplianceModal
isOpen={isAuditModalOpen}
onClose={() => {
setIsAuditModalOpen(false);
setSelectedProjectForAudit(null);
}}
project={selectedProjectForAudit}
programme={selectedProjectForAudit ? programmeMap.get(selectedProjectForAudit.programmeId) || null : null}
/>
</div>
);
};
@@ -15,6 +15,7 @@ import {
ShieldAlert, ShieldAlert,
RotateCcw, RotateCcw,
LayoutDashboard, LayoutDashboard,
ShieldCheck,
} from 'lucide-react'; } from 'lucide-react';
import { import {
getDashboard, getDashboard,
@@ -26,13 +27,29 @@ import {
getDisbursements, getDisbursements,
getAllRisks, getAllRisks,
getAllIndicators, getAllIndicators,
getProjectComplianceItems,
} from '../services/api'; } from '../services/api';
import { formatCompactZAR, formatZAR, getIndustrySectorLabel } from '../lib/formatters'; import { formatCompactZAR, formatZAR, getIndustrySectorLabel } from '../lib/formatters';
import { ApprovalStatus } from '../types'; import { ApprovalStatus, ComplianceStatus } from '../types';
import { RiskHeatmapMatrix } from '../components/ui/RiskHeatmapMatrix'; import { RiskHeatmapMatrix } from '../components/ui/RiskHeatmapMatrix';
import { HelpTooltip } from '../components/ui/HelpTooltip'; import { HelpTooltip } from '../components/ui/HelpTooltip';
import { useTableSort, SortableTh } from '../utils/tableSort'; import { useTableSort, SortableTh } from '../utils/tableSort';
interface AttentionProject {
id: number | string;
name: string;
programme: string;
status: string;
risk: string;
complianceStatus: string;
isCompliant: boolean;
unmetCount: number;
budgetAtRisk: number;
financialVariance: string;
varianceType: string;
maxScore: number;
}
export const ExecutiveDashboard: React.FC = () => { export const ExecutiveDashboard: React.FC = () => {
const queryClient = useQueryClient(); const queryClient = useQueryClient();
const [selectedProgramme, setSelectedProgramme] = useState('All'); const [selectedProgramme, setSelectedProgramme] = useState('All');
@@ -97,11 +114,6 @@ export const ExecutiveDashboard: React.FC = () => {
queryFn: () => getAllIndicators(), queryFn: () => getAllIndicators(),
}); });
const refetchAll = () => {
refetchDashboard();
queryClient.invalidateQueries();
};
// Filtered projects according to active Programme and Sector dropdown selections // Filtered projects according to active Programme and Sector dropdown selections
const filteredProjects = useMemo(() => { const filteredProjects = useMemo(() => {
return projects.filter((p) => { return projects.filter((p) => {
@@ -116,6 +128,30 @@ export const ExecutiveDashboard: React.FC = () => {
}); });
}, [projects, selectedProgramme, selectedSector]); }, [projects, selectedProgramme, selectedSector]);
const { data: projectComplianceMap = new Map<string | number, any[]>() } = useQuery({
queryKey: ['projectComplianceMap', filteredProjects.map((p) => p.id)],
queryFn: async () => {
const map = new Map<string | number, any[]>();
await Promise.all(
filteredProjects.map(async (p) => {
try {
const items = await getProjectComplianceItems(p.id);
map.set(p.id, items);
} catch {
map.set(p.id, []);
}
})
);
return map;
},
enabled: filteredProjects.length > 0,
});
const refetchAll = () => {
refetchDashboard();
queryClient.invalidateQueries();
};
// Filtered risks associated with the filtered project set // Filtered risks associated with the filtered project set
const filteredRisks = useMemo(() => { const filteredRisks = useMemo(() => {
if (selectedProgramme === 'All' && selectedSector === 'All') { if (selectedProgramme === 'All' && selectedSector === 'All') {
@@ -178,6 +214,11 @@ export const ExecutiveDashboard: React.FC = () => {
return dashboard?.disbursed || 0; return dashboard?.disbursed || 0;
}, [dashboard, disbursements, filteredProjects, selectedProgramme, selectedSector]); }, [dashboard, disbursements, filteredProjects, selectedProgramme, selectedSector]);
const disbursedPercentage = useMemo(() => {
if (!portfolioValue || portfolioValue <= 0) return 0;
return Math.min(100, Math.round((disbursed / portfolioValue) * 1000) / 10);
}, [disbursed, portfolioValue]);
const onTrackPercentage = useMemo(() => { const onTrackPercentage = useMemo(() => {
const targetProjects = filteredProjects.length > 0 ? filteredProjects : projects; const targetProjects = filteredProjects.length > 0 ? filteredProjects : projects;
if (targetProjects.length === 0) return 0; if (targetProjects.length === 0) return 0;
@@ -206,6 +247,34 @@ export const ExecutiveDashboard: React.FC = () => {
return dashboard?.atRiskCount ?? 0; return dashboard?.atRiskCount ?? 0;
}, [dashboard, filteredRisks]); }, [dashboard, filteredRisks]);
// Compliance and Capital at Risk calculations
const totalBudgetAtRisk = useMemo(() => {
const targetProjects = filteredProjects.length > 0 ? filteredProjects : projects;
return targetProjects.reduce((sum, p) => {
const pComps = projectComplianceMap.get(p.id) || [];
const hasUnmet = pComps.some((c) => Number(c.status) === ComplianceStatus.NotMet || Number(c.status) === 1);
const isPendingOrRejected = p.status === ApprovalStatus.Rejected || p.status === ApprovalStatus.Pending;
if (hasUnmet || isPendingOrRejected) {
const pBudgets = budgets.filter((b) => String(b.projectId) === String(p.id));
const pApproved = pBudgets
.filter((b) => Number(b.status) === ApprovalStatus.Approved || b.status === undefined)
.reduce((bSum, b) => bSum + (Number(b.amount) || 0), 0);
return sum + pApproved;
}
return sum;
}, 0);
}, [filteredProjects, projects, projectComplianceMap, budgets]);
const complianceRate = useMemo(() => {
const targetProjects = filteredProjects.length > 0 ? filteredProjects : projects;
if (targetProjects.length === 0) return 100;
const compliantCount = targetProjects.filter((p) => {
const pComps = projectComplianceMap.get(p.id) || [];
return !pComps.some((c) => Number(c.status) === ComplianceStatus.NotMet || Number(c.status) === 1);
}).length;
return Math.round((compliantCount / targetProjects.length) * 100);
}, [filteredProjects, projects, projectComplianceMap]);
const beneficiariesReached = useMemo(() => { const beneficiariesReached = useMemo(() => {
if (dashboard?.beneficiariesReached && dashboard.beneficiariesReached > 0) { if (dashboard?.beneficiariesReached && dashboard.beneficiariesReached > 0) {
return dashboard.beneficiariesReached; return dashboard.beneficiariesReached;
@@ -277,7 +346,7 @@ export const ExecutiveDashboard: React.FC = () => {
}, [disbursements]); }, [disbursements]);
// Projects requiring management attention based on risk profile // Projects requiring management attention based on risk profile
const attentionProjects = useMemo(() => { const attentionProjects = useMemo<AttentionProject[]>(() => {
const targetProjects = filteredProjects.length > 0 ? filteredProjects : projects; const targetProjects = filteredProjects.length > 0 ? filteredProjects : projects;
if (targetProjects.length === 0) return []; if (targetProjects.length === 0) return [];
@@ -292,7 +361,7 @@ export const ExecutiveDashboard: React.FC = () => {
}); });
return targetProjects return targetProjects
.map((p) => { .map((p): AttentionProject => {
const maxScore = projectMaxRisk[String(p.id)] || 0; const maxScore = projectMaxRisk[String(p.id)] || 0;
const isRejected = p.status === ApprovalStatus.Rejected; const isRejected = p.status === ApprovalStatus.Rejected;
const isPending = p.status === ApprovalStatus.Pending || p.status === ApprovalStatus.Review; const isPending = p.status === ApprovalStatus.Pending || p.status === ApprovalStatus.Review;
@@ -314,33 +383,50 @@ export const ExecutiveDashboard: React.FC = () => {
varianceType = 'negative'; varianceType = 'negative';
} }
const pComps = projectComplianceMap.get(p.id) || [];
const unmetCount = pComps.filter(
(c) => Number(c.status) === ComplianceStatus.NotMet || Number(c.status) === 1
).length;
const isCompliant = unmetCount === 0;
const pBudgets = budgets.filter((b) => String(b.projectId) === String(p.id));
const pApprovedBudget = pBudgets
.filter((b) => Number(b.status) === ApprovalStatus.Approved || b.status === undefined)
.reduce((sum, b) => sum + (Number(b.amount) || 0), 0);
const projectBudgetAtRisk = !isCompliant || riskLevel === 'RED' || riskLevel === 'AMBER' ? pApprovedBudget : 0;
return { return {
id: p.id, id: p.id,
name: p.name, name: p.name || 'Unnamed Project',
programme: p.programmeName || `Programme ${p.programmeId}`, programme: p.programmeName || `Programme ${p.programmeId}`,
status: statusLabel, status: statusLabel,
risk: riskLevel, risk: riskLevel,
complianceStatus: isCompliant ? 'Compliant' : `Noncompliant (${unmetCount} Unmet)`,
isCompliant,
unmetCount,
budgetAtRisk: projectBudgetAtRisk,
financialVariance: variance, financialVariance: variance,
varianceType, varianceType,
maxScore, maxScore,
}; };
}) })
.filter((p) => p.risk === 'RED' || p.risk === 'AMBER') .filter((p) => p.risk === 'RED' || p.risk === 'AMBER' || !p.isCompliant)
.sort((a, b) => b.maxScore - a.maxScore) .sort((a, b) => b.budgetAtRisk - a.budgetAtRisk || b.maxScore - a.maxScore)
.slice(0, 5); .slice(0, 8);
}, [projects, filteredProjects, risks]); }, [projects, filteredProjects, risks, projectComplianceMap, budgets]);
const { const {
sortedData: sortedAttentionProjects, sortedData: sortedAttentionProjects,
sortColumn: projectSortCol, sortColumn: projectSortCol,
sortDirection: projectSortDir, sortDirection: projectSortDir,
handleSort: handleProjectSort, handleSort: handleProjectSort,
} = useTableSort<any>(attentionProjects, null, null, { } = useTableSort<AttentionProject>(attentionProjects, null, null, {
name: (p: any) => p.name, name: (p) => p.name,
programme: (p: any) => p.programme, programme: (p) => p.programme,
status: (p: any) => p.status, status: (p) => p.status,
risk: (p: any) => p.risk, risk: (p) => p.risk,
financialVariance: (p: any) => p.financialVariance, financialVariance: (p) => p.financialVariance,
}); });
return ( return (
@@ -487,143 +573,328 @@ export const ExecutiveDashboard: React.FC = () => {
</div> </div>
</div> </div>
{/* 6 Headline Portfolio KPI Cards */} {/* Executive Portfolio KPIs: Structured 2-Tier Balanced Grid */}
<div className="grid grid-cols-2 md:grid-cols-3 lg:grid-cols-6 gap-4"> <div className="space-y-6">
{/* Card 1: Portfolio Value */} {/* Tier 1: Capital Commitments & Deployment Scale */}
<div className="bg-[#0f1523]/80 p-5 rounded-2xl border border-slate-800/80 hover:border-blue-500/30 transition-all flex flex-col justify-between shadow-md group backdrop-blur-sm"> <div className="space-y-3">
<div className="flex items-center justify-between text-slate-400"> <div className="flex items-center justify-between px-1">
<div className="flex items-center gap-1.5"> <div className="flex items-center gap-2">
<span className="text-[11px] font-semibold uppercase tracking-wider text-slate-400">Portfolio Value</span> <span className="text-[11px] font-bold uppercase tracking-widest text-slate-400">
<HelpTooltip Capital Deployment & Scale
title="Total Portfolio Value" </span>
content="Aggregated statutory grant commitment amount approved across all active grant awards and contracts." <span className="h-1 w-1 rounded-full bg-slate-600" />
size="sm" <span className="text-xs text-slate-500 hidden sm:inline">Portfolio commitments, disbursements & capital exposure</span>
placement="top"
/>
</div>
<div className="p-1.5 rounded-lg bg-blue-500/10 text-blue-400 border border-blue-500/20 group-hover:scale-110 transition">
<Coins className="w-3.5 h-3.5" />
</div> </div>
<span className="text-[10px] font-semibold text-blue-400/90 bg-blue-500/10 px-2 py-0.5 rounded-full border border-blue-500/20">
Financial Governance
</span>
</div> </div>
<div className="mt-3">
<p className="text-2xl font-bold text-white tracking-tight">{formatCompactZAR(portfolioValue)}</p> <div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-4 gap-4">
<p className="text-[10px] text-slate-400 font-mono mt-1">{formatZAR(portfolioValue)}</p> {/* Card 1: Portfolio Value */}
<div className="bg-[#0f1523]/80 p-5 rounded-2xl border border-slate-800/80 hover:border-blue-500/40 transition-all flex flex-col justify-between shadow-md group backdrop-blur-sm relative overflow-hidden">
<div className="flex items-start justify-between gap-3">
<div className="flex items-center gap-1.5 min-w-0 flex-1">
<span className="text-xs font-semibold uppercase tracking-wider text-slate-400 truncate">
Portfolio Value
</span>
<HelpTooltip
title="Total Portfolio Value"
content="Aggregated statutory grant commitment amount approved across all active grant awards and contracts."
size="sm"
placement="top"
/>
</div>
<div className="shrink-0 p-2 rounded-xl bg-blue-500/10 text-blue-400 border border-blue-500/20 group-hover:scale-105 transition-transform">
<Coins className="w-4 h-4" />
</div>
</div>
<div className="mt-3">
<p className="text-2xl sm:text-3xl font-bold text-white tracking-tight">
{formatCompactZAR(portfolioValue)}
</p>
<div className="flex items-center justify-between mt-3 pt-2.5 border-t border-slate-800/70 text-xs">
<span className="text-slate-400 font-mono text-[11px] truncate mr-2" title={formatZAR(portfolioValue)}>
{formatZAR(portfolioValue)}
</span>
<span className="text-[10px] font-medium text-blue-400 bg-blue-500/10 px-1.5 py-0.5 rounded border border-blue-500/20 shrink-0">
Statutory Total
</span>
</div>
</div>
</div>
{/* Card 2: Disbursed */}
<div className="bg-[#0f1523]/80 p-5 rounded-2xl border border-slate-800/80 hover:border-teal-500/40 transition-all flex flex-col justify-between shadow-md group backdrop-blur-sm relative overflow-hidden">
<div className="flex items-start justify-between gap-3">
<div className="flex items-center gap-1.5 min-w-0 flex-1">
<span className="text-xs font-semibold uppercase tracking-wider text-slate-400 truncate">
Disbursed
</span>
<HelpTooltip
title="Disbursed Funds"
content="Actual funds paid out to implementing grantees following verified milestone proof-of-performance."
size="sm"
placement="top"
/>
</div>
<div className="shrink-0 p-2 rounded-xl bg-teal-500/10 text-teal-400 border border-teal-500/20 group-hover:scale-105 transition-transform">
<TrendingUp className="w-4 h-4" />
</div>
</div>
<div className="mt-3">
<div className="flex items-baseline justify-between gap-2">
<p className="text-2xl sm:text-3xl font-bold text-teal-400 tracking-tight">
{formatCompactZAR(disbursed)}
</p>
<span className="text-xs font-semibold text-teal-400 bg-teal-500/10 px-2 py-0.5 rounded-full border border-teal-500/20 shrink-0 font-mono">
{disbursedPercentage}%
</span>
</div>
<div className="w-full h-1.5 bg-slate-800/90 rounded-full overflow-hidden mt-3">
<div
className="h-full bg-gradient-to-r from-teal-500 to-emerald-400 rounded-full transition-all duration-500"
style={{ width: `${disbursedPercentage}%` }}
/>
</div>
<div className="flex items-center justify-between mt-2.5 pt-2 border-t border-slate-800/70 text-xs text-slate-400">
<span className="text-[11px]">Tranches released</span>
<span className="font-mono text-slate-300 text-[11px]">{disbursedPercentage}% deployed</span>
</div>
</div>
</div>
{/* Card 3: Active Awards */}
<div className="bg-[#0f1523]/80 p-5 rounded-2xl border border-slate-800/80 hover:border-indigo-500/40 transition-all flex flex-col justify-between shadow-md group backdrop-blur-sm relative overflow-hidden">
<div className="flex items-start justify-between gap-3">
<div className="flex items-center gap-1.5 min-w-0 flex-1">
<span className="text-xs font-semibold uppercase tracking-wider text-slate-400 truncate">
Active Awards
</span>
<HelpTooltip
title="Active Grant Awards"
content="Total legally executed statutory agreements currently undergoing active disbursement and implementation."
size="sm"
placement="top"
/>
</div>
<div className="shrink-0 p-2 rounded-xl bg-indigo-500/10 text-indigo-400 border border-indigo-500/20 group-hover:scale-105 transition-transform">
<Layers className="w-4 h-4" />
</div>
</div>
<div className="mt-3">
<p className="text-2xl sm:text-3xl font-bold text-white tracking-tight">
{activeAwards}
</p>
<div className="flex items-center justify-between mt-3 pt-2.5 border-t border-slate-800/70 text-xs">
<span className="text-slate-400 text-[11px]">Under implementation</span>
<span className="font-medium text-indigo-400 bg-indigo-500/10 px-1.5 py-0.5 rounded border border-indigo-500/20 shrink-0 text-[10px]">
{portfolios.length} Portfolio{portfolios.length === 1 ? '' : 's'}
</span>
</div>
</div>
</div>
{/* Card 4: Budget At Risk */}
<div className="bg-[#0f1523]/80 p-5 rounded-2xl border border-slate-800/80 hover:border-rose-500/40 transition-all flex flex-col justify-between shadow-md group backdrop-blur-sm relative overflow-hidden">
<div className="flex items-start justify-between gap-3">
<div className="flex items-center gap-1.5 min-w-0 flex-1">
<span className="text-xs font-semibold uppercase tracking-wider text-slate-400 truncate">
Budget At Risk
</span>
<HelpTooltip
title="Total Budgeted Capital At Risk"
content="Aggregated approved budget across all projects currently flagged as noncompliant or carrying unmitigated high risks."
size="sm"
placement="top"
/>
</div>
<div className="shrink-0 p-2 rounded-xl bg-rose-500/10 text-rose-400 border border-rose-500/20 group-hover:scale-105 transition-transform">
<ShieldAlert className="w-4 h-4" />
</div>
</div>
<div className="mt-3">
<p className="text-2xl sm:text-3xl font-bold text-rose-400 tracking-tight">
{formatCompactZAR(totalBudgetAtRisk)}
</p>
<div className="flex items-center justify-between mt-3 pt-2.5 border-t border-slate-800/70 text-xs">
<span className="text-slate-400 font-mono text-[11px] truncate mr-2" title={formatZAR(totalBudgetAtRisk)}>
{formatZAR(totalBudgetAtRisk)}
</span>
<span className={`text-[10px] font-medium px-1.5 py-0.5 rounded border shrink-0 ${
totalBudgetAtRisk > 0
? 'text-rose-400 bg-rose-500/10 border-rose-500/20'
: 'text-emerald-400 bg-emerald-500/10 border-emerald-500/20'
}`}>
{totalBudgetAtRisk > 0 ? 'Exposed' : 'Clear'}
</span>
</div>
</div>
</div>
</div> </div>
</div> </div>
{/* Card 2: Active Awards */} {/* Tier 2: Operational Health, Risk & Beneficiary Impact */}
<div className="bg-[#0f1523]/80 p-5 rounded-2xl border border-slate-800/80 hover:border-blue-500/30 transition-all flex flex-col justify-between shadow-md group backdrop-blur-sm"> <div className="space-y-3">
<div className="flex items-center justify-between text-slate-400"> <div className="flex items-center justify-between px-1">
<div className="flex items-center gap-1.5"> <div className="flex items-center gap-2">
<span className="text-[11px] font-semibold uppercase tracking-wider text-slate-400">Active Awards</span> <span className="text-[11px] font-bold uppercase tracking-widest text-slate-400">
<HelpTooltip Operational Performance & Impact
title="Active Grant Awards" </span>
content="Total legally executed statutory agreements currently undergoing active disbursement and implementation." <span className="h-1 w-1 rounded-full bg-slate-600" />
size="sm" <span className="text-xs text-slate-500 hidden sm:inline">Milestone pace, compliance gate health & direct beneficiaries</span>
placement="top"
/>
</div>
<div className="p-1.5 rounded-lg bg-indigo-500/10 text-indigo-400 border border-indigo-500/20 group-hover:scale-110 transition">
<Layers className="w-3.5 h-3.5" />
</div> </div>
<span className="text-[10px] font-semibold text-emerald-400/90 bg-emerald-500/10 px-2 py-0.5 rounded-full border border-emerald-500/20">
Delivery Health
</span>
</div> </div>
<div className="mt-3">
<p className="text-2xl font-bold text-white tracking-tight">{activeAwards}</p>
<p className="text-[10px] text-emerald-400 font-mono mt-1">
Across {portfolios.length} portfolio{portfolios.length === 1 ? '' : 's'}
</p>
</div>
</div>
{/* Card 3: Disbursed */} <div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-4 gap-4">
<div className="bg-[#0f1523]/80 p-5 rounded-2xl border border-slate-800/80 hover:border-teal-500/30 transition-all flex flex-col justify-between shadow-md group backdrop-blur-sm"> {/* Card 5: On Track */}
<div className="flex items-center justify-between text-slate-400"> <div className="bg-[#0f1523]/80 p-5 rounded-2xl border border-slate-800/80 hover:border-emerald-500/40 transition-all flex flex-col justify-between shadow-md group backdrop-blur-sm relative overflow-hidden">
<div className="flex items-center gap-1.5"> <div className="flex items-start justify-between gap-3">
<span className="text-[11px] font-semibold uppercase tracking-wider text-slate-400">Disbursed</span> <div className="flex items-center gap-1.5 min-w-0 flex-1">
<HelpTooltip <span className="text-xs font-semibold uppercase tracking-wider text-slate-400 truncate">
title="Disbursed Funds" On Track
content="Actual funds paid out to implementing grantees following verified milestone proof-of-performance." </span>
size="sm" <HelpTooltip
placement="top" title="On-Track Progress Ratio"
/> content="Percentage of active projects meeting schedule deadlines and deliverable acceptance criteria without critical deviations."
size="sm"
placement="top"
/>
</div>
<div className="shrink-0 p-2 rounded-xl bg-emerald-500/10 text-emerald-400 border border-emerald-500/20 group-hover:scale-105 transition-transform">
<CheckCircle className="w-4 h-4" />
</div>
</div>
<div className="mt-3">
<div className="flex items-baseline justify-between gap-2">
<p className="text-2xl sm:text-3xl font-bold text-emerald-400 tracking-tight">
{onTrackPercentage}%
</p>
<span className="text-xs font-semibold text-emerald-400 bg-emerald-500/10 px-2 py-0.5 rounded-full border border-emerald-500/20 shrink-0 font-mono">
{onTrackPercentage >= 80 ? 'Optimal' : onTrackPercentage >= 60 ? 'Moderate' : 'Critical'}
</span>
</div>
<div className="w-full h-1.5 bg-slate-800/90 rounded-full overflow-hidden mt-3">
<div
className="h-full bg-gradient-to-r from-emerald-500 to-teal-400 rounded-full transition-all duration-500"
style={{ width: `${Math.min(100, onTrackPercentage)}%` }}
/>
</div>
<div className="flex items-center justify-between mt-2.5 pt-2 border-t border-slate-800/70 text-xs text-slate-400">
<span className="text-[11px]">Milestones verified</span>
<span className="font-mono text-slate-300 text-[11px]">Pace index</span>
</div>
</div>
</div> </div>
<div className="p-1.5 rounded-lg bg-teal-500/10 text-teal-400 border border-teal-500/20 group-hover:scale-110 transition">
<TrendingUp className="w-3.5 h-3.5" />
</div>
</div>
<div className="mt-3">
<p className="text-2xl font-bold text-teal-400 tracking-tight">{formatCompactZAR(disbursed)}</p>
<p className="text-[10px] text-slate-400 font-mono mt-1">
{portfolioValue > 0 ? `${((disbursed / portfolioValue) * 100).toFixed(1)}% of commitments` : '0.0% of commitments'}
</p>
</div>
</div>
{/* Card 4: On Track */} {/* Card 6: Compliance Health */}
<div className="bg-[#0f1523]/80 p-5 rounded-2xl border border-slate-800/80 hover:border-emerald-500/30 transition-all flex flex-col justify-between shadow-md group backdrop-blur-sm"> <div className="bg-[#0f1523]/80 p-5 rounded-2xl border border-slate-800/80 hover:border-emerald-500/40 transition-all flex flex-col justify-between shadow-md group backdrop-blur-sm relative overflow-hidden">
<div className="flex items-center justify-between text-slate-400"> <div className="flex items-start justify-between gap-3">
<div className="flex items-center gap-1.5"> <div className="flex items-center gap-1.5 min-w-0 flex-1">
<span className="text-[11px] font-semibold uppercase tracking-wider text-slate-400">On Track</span> <span className="text-xs font-semibold uppercase tracking-wider text-slate-400 truncate">
<HelpTooltip Compliance
title="On-Track Progress Ratio" </span>
content="Percentage of active projects meeting schedule deadlines and deliverable acceptance criteria without critical deviations." <HelpTooltip
size="sm" title="Portfolio Compliance Health"
placement="top" content="Percentage of active projects meeting 100% statutory compliance and beneficiary outcome criteria."
/> size="sm"
placement="top"
/>
</div>
<div className="shrink-0 p-2 rounded-xl bg-emerald-500/10 text-emerald-400 border border-emerald-500/20 group-hover:scale-105 transition-transform">
<ShieldCheck className="w-4 h-4" />
</div>
</div>
<div className="mt-3">
<div className="flex items-baseline justify-between gap-2">
<p className="text-2xl sm:text-3xl font-bold text-emerald-400 tracking-tight">
{complianceRate}%
</p>
<span className="text-xs font-semibold text-emerald-400 bg-emerald-500/10 px-2 py-0.5 rounded-full border border-emerald-500/20 shrink-0 font-mono">
{complianceRate >= 90 ? 'Passed' : 'Gated'}
</span>
</div>
<div className="w-full h-1.5 bg-slate-800/90 rounded-full overflow-hidden mt-3">
<div
className="h-full bg-gradient-to-r from-emerald-500 to-cyan-400 rounded-full transition-all duration-500"
style={{ width: `${Math.min(100, complianceRate)}%` }}
/>
</div>
<div className="flex items-center justify-between mt-2.5 pt-2 border-t border-slate-800/70 text-xs text-slate-400">
<span className="text-[11px]">Statutory gate check</span>
<span className="font-mono text-slate-300 text-[11px]">100% requirement</span>
</div>
</div>
</div> </div>
<div className="p-1.5 rounded-lg bg-emerald-500/10 text-emerald-400 border border-emerald-500/20 group-hover:scale-110 transition">
<CheckCircle className="w-3.5 h-3.5" />
</div>
</div>
<div className="mt-3">
<p className="text-2xl font-bold text-emerald-400 tracking-tight">{onTrackPercentage}%</p>
<p className="text-[10px] text-slate-400 font-mono mt-1">Milestones verified</p>
</div>
</div>
{/* Card 5: At Risk */} {/* Card 7: At Risk */}
<div className="bg-[#0f1523]/80 p-5 rounded-2xl border border-slate-800/80 hover:border-amber-500/30 transition-all flex flex-col justify-between shadow-md group backdrop-blur-sm"> <div className="bg-[#0f1523]/80 p-5 rounded-2xl border border-slate-800/80 hover:border-amber-500/40 transition-all flex flex-col justify-between shadow-md group backdrop-blur-sm relative overflow-hidden">
<div className="flex items-center justify-between text-slate-400"> <div className="flex items-start justify-between gap-3">
<div className="flex items-center gap-1.5"> <div className="flex items-center gap-1.5 min-w-0 flex-1">
<span className="text-[11px] font-semibold uppercase tracking-wider text-slate-400">At Risk</span> <span className="text-xs font-semibold uppercase tracking-wider text-slate-400 truncate">
<HelpTooltip At Risk
title="At-Risk Projects" </span>
content="Count of projects with high residual risk scores (≥12), schedule slippage >30 days, or adverse financial variance." <HelpTooltip
size="sm" title="At-Risk Projects"
placement="top" content="Count of projects with high residual risk scores (≥12), schedule slippage >30 days, or adverse financial variance."
/> size="sm"
placement="top"
/>
</div>
<div className="shrink-0 p-2 rounded-xl bg-amber-500/10 text-amber-400 border border-amber-500/20 group-hover:scale-105 transition-transform">
<AlertTriangle className="w-4 h-4" />
</div>
</div>
<div className="mt-3">
<p className="text-2xl sm:text-3xl font-bold text-amber-400 tracking-tight">
{atRiskCount}
</p>
<div className="flex items-center justify-between mt-3 pt-2.5 border-t border-slate-800/70 text-xs">
<span className="text-slate-400 text-[11px]">{atRiskCount === 1 ? 'Project flagged' : 'Projects flagged'}</span>
<span className={`text-[10px] font-medium px-1.5 py-0.5 rounded border shrink-0 ${
atRiskCount > 0
? 'text-amber-400 bg-amber-500/10 border-amber-500/20'
: 'text-emerald-400 bg-emerald-500/10 border-emerald-500/20'
}`}>
{atRiskCount > 0 ? 'Requires PM Action' : 'Nominal'}
</span>
</div>
</div>
</div> </div>
<div className="p-1.5 rounded-lg bg-amber-500/10 text-amber-400 border border-amber-500/20 group-hover:scale-110 transition">
<AlertTriangle className="w-3.5 h-3.5" />
</div>
</div>
<div className="mt-3">
<p className="text-2xl font-bold text-amber-400 tracking-tight">{atRiskCount}</p>
<p className="text-[10px] text-rose-400 font-mono mt-1">Requires PM action</p>
</div>
</div>
{/* Card 6: Beneficiaries Reached */} {/* Card 8: Beneficiaries Reached */}
<div className="bg-[#0f1523]/80 p-5 rounded-2xl border border-slate-800/80 hover:border-indigo-500/30 transition-all flex flex-col justify-between shadow-md group backdrop-blur-sm"> <div className="bg-[#0f1523]/80 p-5 rounded-2xl border border-slate-800/80 hover:border-purple-500/40 transition-all flex flex-col justify-between shadow-md group backdrop-blur-sm relative overflow-hidden">
<div className="flex items-center justify-between text-slate-400"> <div className="flex items-start justify-between gap-3">
<div className="flex items-center gap-1.5"> <div className="flex items-center gap-1.5 min-w-0 flex-1">
<span className="text-[11px] font-semibold uppercase tracking-wider text-slate-400">Beneficiaries</span> <span className="text-xs font-semibold uppercase tracking-wider text-slate-400 truncate">
<HelpTooltip Beneficiaries
title="Beneficiaries Impacted" </span>
content="Cumulative count of direct beneficiaries reached through projects, verified against national M&E indicator targets." <HelpTooltip
size="sm" title="Beneficiaries Impacted"
placement="top" content="Cumulative count of direct beneficiaries reached through projects, verified against national M&E indicator targets."
/> size="sm"
placement="top"
/>
</div>
<div className="shrink-0 p-2 rounded-xl bg-purple-500/10 text-purple-400 border border-purple-500/20 group-hover:scale-105 transition-transform">
<Users className="w-4 h-4" />
</div>
</div>
<div className="mt-3">
<p className="text-2xl sm:text-3xl font-bold text-white tracking-tight">
{beneficiariesReached >= 1000 ? `${(beneficiariesReached / 1000).toFixed(1)}k` : beneficiariesReached}
</p>
<div className="flex items-center justify-between mt-3 pt-2.5 border-t border-slate-800/70 text-xs">
<span className="text-slate-400 text-[11px]">Directly impacted</span>
<span className="text-[10px] font-medium text-purple-400 bg-purple-500/10 px-1.5 py-0.5 rounded border border-purple-500/20 shrink-0">
M&E Verified
</span>
</div>
</div>
</div> </div>
<div className="p-1.5 rounded-lg bg-indigo-500/10 text-indigo-400 border border-indigo-500/20 group-hover:scale-110 transition">
<Users className="w-3.5 h-3.5" />
</div>
</div>
<div className="mt-3">
<p className="text-2xl font-bold text-white tracking-tight">
{beneficiariesReached >= 1000 ? `${(beneficiariesReached / 1000).toFixed(0)}k` : beneficiariesReached}
</p>
<p className="text-[10px] text-slate-400 font-mono mt-1">Directly impacted</p>
</div> </div>
</div> </div>
</div> </div>
@@ -763,7 +1034,7 @@ export const ExecutiveDashboard: React.FC = () => {
<div className="p-5 border-b border-slate-800/80 flex flex-col sm:flex-row sm:items-center justify-between gap-2 bg-[#0d121f]/90"> <div className="p-5 border-b border-slate-800/80 flex flex-col sm:flex-row sm:items-center justify-between gap-2 bg-[#0d121f]/90">
<div> <div>
<div className="flex items-center gap-2"> <div className="flex items-center gap-2">
<h3 className="text-base font-semibold text-white">Projects Requiring Attention</h3> <h3 className="text-base font-semibold text-white">Projects Flagged At Risk & Noncompliant</h3>
<HelpTooltip <HelpTooltip
title="Exception Escalations" title="Exception Escalations"
content="Identifies projects breaching risk thresholds, schedule deadlines, or financial variances (>10%) requiring executive steering committee action." content="Identifies projects breaching risk thresholds, schedule deadlines, or financial variances (>10%) requiring executive steering committee action."
@@ -796,11 +1067,14 @@ export const ExecutiveDashboard: React.FC = () => {
<SortableTh column="status" currentColumn={projectSortCol} direction={projectSortDir} onSort={handleProjectSort} className="py-3.5 px-4"> <SortableTh column="status" currentColumn={projectSortCol} direction={projectSortDir} onSort={handleProjectSort} className="py-3.5 px-4">
Status Status
</SortableTh> </SortableTh>
<SortableTh column="complianceStatus" currentColumn={projectSortCol} direction={projectSortDir} onSort={handleProjectSort} className="py-3.5 px-4">
Compliance
</SortableTh>
<SortableTh column="risk" currentColumn={projectSortCol} direction={projectSortDir} onSort={handleProjectSort} align="center" className="py-3.5 px-4 text-center"> <SortableTh column="risk" currentColumn={projectSortCol} direction={projectSortDir} onSort={handleProjectSort} align="center" className="py-3.5 px-4 text-center">
Risk Risk
</SortableTh> </SortableTh>
<SortableTh column="financialVariance" currentColumn={projectSortCol} direction={projectSortDir} onSort={handleProjectSort} align="right" className="py-3.5 px-4 text-right"> <SortableTh column="budgetAtRisk" currentColumn={projectSortCol} direction={projectSortDir} onSort={handleProjectSort} align="right" className="py-3.5 px-4 text-right">
Financial Variance Budget At Risk
</SortableTh> </SortableTh>
<th className="py-3.5 px-5 text-right font-medium">Action</th> <th className="py-3.5 px-5 text-right font-medium">Action</th>
</tr> </tr>
@@ -808,7 +1082,7 @@ export const ExecutiveDashboard: React.FC = () => {
<tbody className="divide-y divide-slate-800/60"> <tbody className="divide-y divide-slate-800/60">
{sortedAttentionProjects.length === 0 ? ( {sortedAttentionProjects.length === 0 ? (
<tr> <tr>
<td colSpan={6} className="py-8 text-center text-xs text-slate-500"> <td colSpan={7} className="py-8 text-center text-xs text-slate-500">
No active projects currently flagged for executive attention. All projects are operating within target thresholds. No active projects currently flagged for executive attention. All projects are operating within target thresholds.
</td> </td>
</tr> </tr>
@@ -833,6 +1107,19 @@ export const ExecutiveDashboard: React.FC = () => {
{p.status} {p.status}
</span> </span>
</td> </td>
<td className="py-3.5 px-4">
{p.isCompliant ? (
<span className="inline-flex items-center gap-1 px-2.5 py-0.5 rounded-full text-[10px] font-semibold bg-emerald-500/10 text-emerald-400 border border-emerald-500/30">
<CheckCircle className="w-3 h-3 text-emerald-400" />
<span>Compliant</span>
</span>
) : (
<span className="inline-flex items-center gap-1 px-2.5 py-0.5 rounded-full text-[10px] font-semibold bg-rose-500/10 text-rose-400 border border-rose-500/30">
<AlertTriangle className="w-3 h-3 text-rose-400" />
<span>Noncompliant ({p.unmetCount} Unmet)</span>
</span>
)}
</td>
<td className="py-3.5 px-4 text-center"> <td className="py-3.5 px-4 text-center">
<span <span
className={`inline-flex items-center justify-center font-bold px-2.5 py-0.5 rounded-md text-[10px] shadow-xs ${ className={`inline-flex items-center justify-center font-bold px-2.5 py-0.5 rounded-md text-[10px] shadow-xs ${
@@ -846,13 +1133,9 @@ export const ExecutiveDashboard: React.FC = () => {
{p.risk} {p.risk}
</span> </span>
</td> </td>
<td className="py-3.5 px-4 text-right"> <td className="py-3.5 px-4 text-right font-mono font-bold text-xs">
<span <span className={p.budgetAtRisk > 0 ? 'text-rose-400' : 'text-emerald-400'}>
className={`font-mono font-bold text-xs ${ {formatZAR(p.budgetAtRisk)}
p.varianceType === 'negative' ? 'text-rose-400' : 'text-emerald-400'
}`}
>
{p.financialVariance}
</span> </span>
</td> </td>
<td className="py-3.5 px-5 text-right"> <td className="py-3.5 px-5 text-right">
@@ -11,6 +11,7 @@ import {
Edit2, Edit2,
Mail, Mail,
Shield, Shield,
ShieldCheck,
Layers, Layers,
ChevronRight, ChevronRight,
Search, Search,
@@ -24,8 +25,11 @@ import {
UserPlus, UserPlus,
ChevronDown, ChevronDown,
ChevronUp, ChevronUp,
CheckCircle,
CheckCircle2, CheckCircle2,
Loader2, Loader2,
Lock,
AlertTriangle,
} from 'lucide-react'; } from 'lucide-react';
import { import {
getOrganisations, getOrganisations,
@@ -59,6 +63,7 @@ import {
assignUserRole, assignUserRole,
getSystemRoles, getSystemRoles,
getCurrentUserTenantId, getCurrentUserTenantId,
notifyNewUserCreated,
} from '../services/api'; } from '../services/api';
import { Modal } from '../components/ui/Modal'; import { Modal } from '../components/ui/Modal';
import { HelpTooltip } from '../components/ui/HelpTooltip'; import { HelpTooltip } from '../components/ui/HelpTooltip';
@@ -67,6 +72,7 @@ import { ExportButton } from '../components/ui/ExportButton';
import { ExportColumn } from '../utils/exportUtils'; import { ExportColumn } from '../utils/exportUtils';
import { useAuth } from '../auth/AuthProvider'; import { useAuth } from '../auth/AuthProvider';
import { useRbac } from '../auth/rbac'; import { useRbac } from '../auth/rbac';
import { bumpMutationEpoch } from '../services/httpClient';
import { useTableSort, SortableTh } from '../utils/tableSort'; import { useTableSort, SortableTh } from '../utils/tableSort';
import { FundingInstrumentsSection, InstructionsSection } from '../components/governance/InstructionsSection'; import { FundingInstrumentsSection, InstructionsSection } from '../components/governance/InstructionsSection';
import { RulesSection } from '../components/governance/RulesSection'; import { RulesSection } from '../components/governance/RulesSection';
@@ -108,6 +114,7 @@ const organisationSchema = z.object({
registrationNo: z.string().min(4, 'Registration number is mandatory'), registrationNo: z.string().min(4, 'Registration number is mandatory'),
email: z.string().email('Enter a valid email address'), email: z.string().email('Enter a valid email address'),
organisationUser: z.string().min(1, 'Please select a linked executive or manager user'), 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), type: z.coerce.number().min(1).max(27),
status: z.coerce.number().optional(), status: z.coerce.number().optional(),
}); });
@@ -132,8 +139,8 @@ type ProgrammeFormValues = z.infer<typeof programmeSchema>;
export const GovernancePage: React.FC = () => { export const GovernancePage: React.FC = () => {
const queryClient = useQueryClient(); const queryClient = useQueryClient();
const { sub, user, claims } = useAuth(); const { sub, user, claims, roles, tenantId } = useAuth();
const { canAdd, canApprove, canExport } = useRbac(); const { canAdd, canApprove, canExport, canApproveRecord, isMaker, isUnrestricted } = useRbac();
const currentUserId = sub || user?.id || ''; const currentUserId = sub || user?.id || '';
const { success, error: toastError } = useToast(); const { success, error: toastError } = useToast();
@@ -239,7 +246,6 @@ export const GovernancePage: React.FC = () => {
const { data: usersDirectory = [] } = useQuery({ const { data: usersDirectory = [] } = useQuery({
queryKey: ['users-directory'], queryKey: ['users-directory'],
queryFn: () => getUsers(1, 200), queryFn: () => getUsers(1, 200),
staleTime: 5 * 60 * 1000,
}); });
const { data: systemRoles = [] } = useQuery<Role[]>({ const { data: systemRoles = [] } = useQuery<Role[]>({
@@ -250,7 +256,7 @@ export const GovernancePage: React.FC = () => {
const assignableRolesForOrg = React.useMemo(() => { const assignableRolesForOrg = React.useMemo(() => {
return systemRoles.filter( return systemRoles.filter(
(r) => r.name.toLowerCase() !== 'admin' && r.id !== '1' (r) => (r.name || r.Name || '').toLowerCase() !== 'admin' && (r.id || r.Id) !== '1'
); );
}, [systemRoles]); }, [systemRoles]);
@@ -261,6 +267,37 @@ export const GovernancePage: React.FC = () => {
}); });
}, [usersDirectory]); }, [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 // Quick Add Executive User Subform state
const [isQuickAddUserOpen, setIsQuickAddUserOpen] = useState(false); const [isQuickAddUserOpen, setIsQuickAddUserOpen] = useState(false);
const [quickUserEmail, setQuickUserEmail] = useState(''); const [quickUserEmail, setQuickUserEmail] = useState('');
@@ -273,9 +310,9 @@ export const GovernancePage: React.FC = () => {
React.useEffect(() => { React.useEffect(() => {
if (!quickUserRoleId && assignableRolesForOrg.length > 0) { if (!quickUserRoleId && assignableRolesForOrg.length > 0) {
const execRole = assignableRolesForOrg.find((r) => r.name.toLowerCase() === 'executive'); const execRole = assignableRolesForOrg.find((r) => (r.name || r.Name || '').toLowerCase() === 'executive');
if (execRole) setQuickUserRoleId(execRole.id); if (execRole) setQuickUserRoleId(execRole.id || execRole.Id || '');
else setQuickUserRoleId(assignableRolesForOrg[0].id); else setQuickUserRoleId(assignableRolesForOrg[0].id || assignableRolesForOrg[0].Id || '');
} }
}, [assignableRolesForOrg, quickUserRoleId]); }, [assignableRolesForOrg, quickUserRoleId]);
@@ -293,9 +330,14 @@ export const GovernancePage: React.FC = () => {
setIsSubmittingQuickUser(true); setIsSubmittingQuickUser(true);
try { try {
const tenantId = getCurrentUserTenantId() || 1; const tenantId = getCurrentUserTenantId() || 1;
const rawEmail = quickUserEmail.trim().toLowerCase();
const sanitizedUserName = quickUserName.trim()
? quickUserName.trim().toLowerCase().replace(/\s+/g, '.').replace(/[^a-z0-9_.\-@+]/g, '')
: rawEmail.split('@')[0];
const payload: CreateUserRequest = { const payload: CreateUserRequest = {
email: quickUserEmail.trim().toLowerCase(), email: rawEmail,
userName: quickUserName.trim() || quickUserEmail.split('@')[0], userName: sanitizedUserName || rawEmail,
emailConfirmed: true, emailConfirmed: true,
phoneNumber: quickUserPhone.trim() || null, phoneNumber: quickUserPhone.trim() || null,
phoneNumberConfirmed: false, phoneNumberConfirmed: false,
@@ -328,14 +370,28 @@ export const GovernancePage: React.FC = () => {
orgForm.setValue('organisationUser', createdUser.id, { shouldValidate: true }); orgForm.setValue('organisationUser', createdUser.id, { shouldValidate: true });
} }
// RULE 3: Notify created Executive/Manager user via InApp and Email (directs to Console)
if (createdUser) {
await notifyNewUserCreated({
userId: createdUser.id,
email: quickUserEmail,
isPartner: false,
roleName: 'Executive',
tenantId,
}).catch(console.warn);
queryClient.invalidateQueries({ queryKey: ['inapp-notifications'] });
queryClient.invalidateQueries({ queryKey: ['notifications'] });
}
success('Executive User Created', `User ${quickUserEmail} successfully created and linked.`); success('Executive User Created', `User ${quickUserEmail} successfully created and linked.`);
setQuickUserEmail(''); setQuickUserEmail('');
setQuickUserName(''); setQuickUserName('');
setQuickUserPhone(''); setQuickUserPhone('');
setQuickUserIdNumber(''); setQuickUserIdNumber('');
setIsQuickAddUserOpen(false); setIsQuickAddUserOpen(false);
} catch (err: any) { } catch (err: unknown) {
const errorDetail = err?.response?.data?.detail || err?.message || 'Failed to create user.'; const errObj = err as { response?: { data?: { detail?: string } }; message?: string };
const errorDetail = errObj?.response?.data?.detail || errObj?.message || 'Failed to create user.';
setQuickUserError(errorDetail); setQuickUserError(errorDetail);
toastError('Failed to create user', err); toastError('Failed to create user', err);
} finally { } finally {
@@ -533,19 +589,31 @@ export const GovernancePage: React.FC = () => {
// Forms // Forms
const orgForm = useForm<OrganisationFormValues>({ const orgForm = useForm<OrganisationFormValues>({
resolver: zodResolver(organisationSchema) as any, resolver: zodResolver(organisationSchema),
defaultValues: { defaultValues: {
name: '', name: '',
registrationNo: '', registrationNo: '',
email: '', email: '',
organisationUser: '', organisationUser: '',
idNumber: '',
type: OrganisationType.Enterprise, 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>({ const portfolioForm = useForm<PortfolioFormValues>({
resolver: zodResolver(portfolioSchema) as any, resolver: zodResolver(portfolioSchema),
defaultValues: { defaultValues: {
name: '', name: '',
description: '', description: '',
@@ -555,11 +623,11 @@ export const GovernancePage: React.FC = () => {
}); });
const programmeForm = useForm<ProgrammeFormValues>({ const programmeForm = useForm<ProgrammeFormValues>({
resolver: zodResolver(programmeSchema) as any, resolver: zodResolver(programmeSchema),
defaultValues: { defaultValues: {
name: '', name: '',
portfolioId: portfolios[0]?.id ? Number(portfolios[0].id) : 201, portfolioId: portfolios[0]?.id ? Number(portfolios[0].id) : 201,
status: ApprovalStatus.Approved, status: ApprovalStatus.Pending,
ownerEmail: effectiveUserEmail, ownerEmail: effectiveUserEmail,
}, },
}); });
@@ -568,11 +636,20 @@ export const GovernancePage: React.FC = () => {
const orgMutation = useMutation({ const orgMutation = useMutation({
mutationFn: (data: OrganisationFormValues) => { mutationFn: (data: OrganisationFormValues) => {
if (editingOrg) { 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({ return updateOrganisation({
organisationId: editingOrg.id, organisationId: editingOrg.id,
name: data.name, name: data.name,
registrationNo: data.registrationNo, registrationNo: data.registrationNo,
email: data.email, email: data.email,
idNumber: data.idNumber.trim(),
organisationUser: data.organisationUser, organisationUser: data.organisationUser,
type: Number(data.type), type: Number(data.type),
status: data.status !== undefined ? Number(data.status) : ActivityStatus.Active, status: data.status !== undefined ? Number(data.status) : ActivityStatus.Active,
@@ -582,11 +659,13 @@ export const GovernancePage: React.FC = () => {
name: data.name, name: data.name,
registrationNo: data.registrationNo, registrationNo: data.registrationNo,
email: data.email, email: data.email,
idNumber: data.idNumber?.trim() || null,
organisationUser: data.organisationUser, organisationUser: data.organisationUser,
type: Number(data.type), type: Number(data.type),
}); });
}, },
onSuccess: () => { onSuccess: () => {
bumpMutationEpoch();
queryClient.invalidateQueries({ queryKey: ['organisations'] }); queryClient.invalidateQueries({ queryKey: ['organisations'] });
success(editingOrg ? 'Organisation updated' : 'Organisation registered successfully'); success(editingOrg ? 'Organisation updated' : 'Organisation registered successfully');
setIsOrgModalOpen(false); setIsOrgModalOpen(false);
@@ -618,6 +697,7 @@ export const GovernancePage: React.FC = () => {
}); });
}, },
onSuccess: () => { onSuccess: () => {
bumpMutationEpoch();
queryClient.invalidateQueries({ queryKey: ['portfolios'] }); queryClient.invalidateQueries({ queryKey: ['portfolios'] });
success(editingPortfolio ? 'Portfolio updated successfully' : 'Portfolio created successfully'); success(editingPortfolio ? 'Portfolio updated successfully' : 'Portfolio created successfully');
setIsPortfolioModalOpen(false); setIsPortfolioModalOpen(false);
@@ -629,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({ const programmeMutation = useMutation({
mutationFn: (data: ProgrammeFormValues) => { mutationFn: (data: ProgrammeFormValues) => {
const resolvedOwnerId = resolveOwnerId(data.ownerEmail, editingProgramme?.ownedBy); const resolvedOwnerId = resolveOwnerId(data.ownerEmail, editingProgramme?.ownedBy);
if (editingProgramme) { 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({ return updateProgramme({
programmeId: editingProgramme.id, programmeId: editingProgramme.id,
name: data.name, name: data.name,
portfolioId: Number(data.portfolioId), portfolioId: Number(data.portfolioId),
status: data.status !== undefined ? Number(data.status) : undefined, status: targetStatus,
ownerId: resolvedOwnerId, ownerId: resolvedOwnerId,
}); });
} }
@@ -648,7 +756,9 @@ export const GovernancePage: React.FC = () => {
}); });
}, },
onSuccess: () => { onSuccess: () => {
bumpMutationEpoch();
queryClient.invalidateQueries({ queryKey: ['programmes'] }); queryClient.invalidateQueries({ queryKey: ['programmes'] });
queryClient.invalidateQueries({ queryKey: ['programme'] });
success(editingProgramme ? 'Programme updated successfully' : 'Programme created successfully'); success(editingProgramme ? 'Programme updated successfully' : 'Programme created successfully');
setIsProgrammeModalOpen(false); setIsProgrammeModalOpen(false);
setEditingProgramme(null); setEditingProgramme(null);
@@ -667,8 +777,9 @@ export const GovernancePage: React.FC = () => {
registrationNo: '', registrationNo: '',
email: '', email: '',
organisationUser: defaultUser?.id || '', organisationUser: defaultUser?.id || '',
idNumber: defaultUser?.idNumber || '',
type: OrganisationType.Enterprise, type: OrganisationType.Enterprise,
status: ActivityStatus.Active, status: ActivityStatus.Inactive,
}); });
setIsQuickAddUserOpen(false); setIsQuickAddUserOpen(false);
setQuickUserError(null); setQuickUserError(null);
@@ -680,27 +791,52 @@ export const GovernancePage: React.FC = () => {
const targetUserId = const targetUserId =
org.userId || org.userId ||
(org as any).UserId || org.UserId ||
(org as any).organisationUser ||
(org as any).OrganisationUser ||
org.createdBy ||
''; '';
const matchedUser = targetUserId let selectedUserId = '';
? usersDirectory.find((u) => u.id && String(u.id).toLowerCase() === String(targetUserId).toLowerCase()) let matchedUser: User | undefined;
: null; 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({ orgForm.reset({
name: org.name || '', name: org.name || '',
registrationNo: org.registrationNo || '', registrationNo: org.registrationNo || '',
email: org.email || '', email: org.email || '',
organisationUser: selectedUserId, organisationUser: selectedUserId,
idNumber: resolvedIdNumber,
type: org.type || OrganisationType.Enterprise, type: org.type || OrganisationType.Enterprise,
status: org.status ?? ActivityStatus.Active, 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); setIsQuickAddUserOpen(false);
setQuickUserError(null); setQuickUserError(null);
setIsOrgModalOpen(true); setIsOrgModalOpen(true);
@@ -734,7 +870,7 @@ export const GovernancePage: React.FC = () => {
programmeForm.reset({ programmeForm.reset({
name: '', name: '',
portfolioId: portfolios[0]?.id ? Number(portfolios[0].id) : 201, portfolioId: portfolios[0]?.id ? Number(portfolios[0].id) : 201,
status: ApprovalStatus.Approved, status: ApprovalStatus.Pending,
ownerEmail: effectiveUserEmail, ownerEmail: effectiveUserEmail,
}); });
setIsProgrammeModalOpen(true); setIsProgrammeModalOpen(true);
@@ -812,6 +948,7 @@ export const GovernancePage: React.FC = () => {
status: (p) => getApprovalStatusBadge(p.status).label, status: (p) => getApprovalStatusBadge(p.status).label,
createdAt: (p) => p.createdAt, createdAt: (p) => p.createdAt,
ownedBy: (p) => getUserEmail(p.ownedBy), ownedBy: (p) => getUserEmail(p.ownedBy),
createdBy: (p) => getUserEmail(p.createdBy),
}); });
// Export Columns Definitions // Export Columns Definitions
@@ -842,6 +979,7 @@ export const GovernancePage: React.FC = () => {
format: (id) => programmes.filter((pr) => String(pr.portfolioId) === String(id)).length, format: (id) => programmes.filter((pr) => String(pr.portfolioId) === String(id)).length,
}, },
{ header: 'Owner Email', key: 'ownedBy', format: (val) => getUserEmail(val) }, { 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: 'Creation Date', key: 'createdAt', format: (val) => formatDate(val) },
{ header: 'Description', key: 'description', format: (val) => val || '—' }, { header: 'Description', key: 'description', format: (val) => val || '—' },
]; ];
@@ -854,12 +992,13 @@ export const GovernancePage: React.FC = () => {
key: 'portfolioId', key: 'portfolioId',
format: (val) => { format: (val) => {
const port = portfolios.find((p) => String(p.id) === String(val)); const port = portfolios.find((p) => String(p.id) === String(val));
return port ? port.name : `Portfolio #${val}`; return port && port.name ? port.name : `Portfolio #${val}`;
}, },
}, },
{ header: 'Governance Status', key: 'status', format: (val) => getApprovalStatusBadge(val).label }, { header: 'Governance Status', key: 'status', format: (val) => getApprovalStatusBadge(val).label },
{ header: 'Creation Date', key: 'createdAt', format: (val) => formatDate(val) }, { header: 'Creation Date', key: 'createdAt', format: (val) => formatDate(val) },
{ header: 'Owner Email', key: 'ownedBy', format: (val) => getUserEmail(val) }, { header: 'Owner Email', key: 'ownedBy', format: (val) => getUserEmail(val) },
{ header: 'Created By (Maker)', key: 'createdBy', format: (val) => getUserEmail(val) },
]; ];
return ( return (
@@ -1129,6 +1268,11 @@ export const GovernancePage: React.FC = () => {
<Mail className="w-3.5 h-3.5 text-slate-500" /> <Mail className="w-3.5 h-3.5 text-slate-500" />
{org.email} {org.email}
</span> </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>
<td className="py-3.5 px-4"> <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}`}> <span className={`inline-flex items-center px-2.5 py-0.5 rounded-full text-[10px] font-semibold border ${statusBadge.className}`}>
@@ -1246,8 +1390,8 @@ export const GovernancePage: React.FC = () => {
<div className="mt-4 pt-3 border-t border-slate-800/80 space-y-2.5"> <div className="mt-4 pt-3 border-t border-slate-800/80 space-y-2.5">
<div className="flex items-center justify-between text-xs text-slate-400"> <div className="flex items-center justify-between text-xs text-slate-400">
<span className="truncate max-w-[200px] font-medium text-slate-300" title={org ? org.name : `Org #${p.organisationId}`}> <span className="truncate max-w-[200px] font-medium text-slate-300" title={org && org.name ? org.name : `Org #${p.organisationId}`}>
{org ? org.name : `Org #${p.organisationId}`} {org && org.name ? org.name : `Org #${p.organisationId}`}
</span> </span>
<span className="font-mono text-[11px] text-slate-500 shrink-0">{formatDate(p.createdAt)}</span> <span className="font-mono text-[11px] text-slate-500 shrink-0">{formatDate(p.createdAt)}</span>
</div> </div>
@@ -1361,19 +1505,22 @@ export const GovernancePage: React.FC = () => {
<SortableTh column="ownedBy" currentColumn={progSortCol} direction={progSortDir} onSort={handleProgSort} className="py-3.5 px-4"> <SortableTh column="ownedBy" currentColumn={progSortCol} direction={progSortDir} onSort={handleProgSort} className="py-3.5 px-4">
Owner Email Owner Email
</SortableTh> </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> <th className="py-3.5 px-5 text-right font-medium">Actions</th>
</tr> </tr>
</thead> </thead>
<tbody className="divide-y divide-slate-800/60"> <tbody className="divide-y divide-slate-800/60">
{loadingProgrammes ? ( {loadingProgrammes ? (
<tr> <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... Loading programmes...
</td> </td>
</tr> </tr>
) : sortedProgrammes.length === 0 ? ( ) : sortedProgrammes.length === 0 ? (
<tr> <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. No programmes found matching query.
</td> </td>
</tr> </tr>
@@ -1381,6 +1528,7 @@ export const GovernancePage: React.FC = () => {
sortedProgrammes.map((pr) => { sortedProgrammes.map((pr) => {
const port = portfolios.find((p) => String(p.id) === String(pr.portfolioId)); const port = portfolios.find((p) => String(p.id) === String(pr.portfolioId));
const statusBadge = getApprovalStatusBadge(pr.status); const statusBadge = getApprovalStatusBadge(pr.status);
const isApproved = Number(pr.status) === ApprovalStatus.Approved;
return ( return (
<tr key={pr.id} className="hover:bg-slate-800/40 transition-colors"> <tr key={pr.id} className="hover:bg-slate-800/40 transition-colors">
<td className="py-3.5 px-5"> <td className="py-3.5 px-5">
@@ -1399,8 +1547,49 @@ export const GovernancePage: React.FC = () => {
<td className="py-3.5 px-4 font-mono text-slate-300 font-medium text-[11px]"> <td className="py-3.5 px-4 font-mono text-slate-300 font-medium text-[11px]">
{getUserEmail(pr.ownedBy)} {getUserEmail(pr.ownedBy)}
</td> </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"> <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 <button
type="button" type="button"
onClick={() => { onClick={() => {
@@ -1437,14 +1626,6 @@ export const GovernancePage: React.FC = () => {
<Scale className="w-3 h-3" /> <Scale className="w-3 h-3" />
Rules Rules
</button> </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> </div>
</td> </td>
</tr> </tr>
@@ -1590,11 +1771,11 @@ export const GovernancePage: React.FC = () => {
<div className="flex items-center justify-between mb-1"> <div className="flex items-center justify-between mb-1">
<div className="flex items-center gap-1"> <div className="flex items-center gap-1">
<label className="block text-xs font-semibold text-slate-300"> <label className="block text-xs font-semibold text-slate-300">
Linked Executive / Manager User * Responsible Executive User *
</label> </label>
<HelpTooltip <HelpTooltip
title="Designated Organisation User" title="Executive / Organisation User"
content="Only users with an Executive or Manager role can be assigned to an organisation." content="The Executive user responsible for this organisation. Stored as userId in the organisation record and submitted as organisationUser to the API."
size="sm" size="sm"
placement="top" placement="top"
/> />
@@ -1615,11 +1796,36 @@ export const GovernancePage: React.FC = () => {
)} )}
</div> </div>
<select <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" 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> <option value="">-- Select linked executive or manager account --</option>
{executiveAndManagerUsers.map((u) => ( {modalExecutiveUsers.map((u) => (
<option key={u.id} value={u.id}> <option key={u.id} value={u.id}>
{u.email || u.id} ({u.role || 'Executive'}) {u.email || u.id} ({u.role || 'Executive'})
</option> </option>
@@ -1633,9 +1839,61 @@ export const GovernancePage: React.FC = () => {
No Executive or Manager user found. Create one below to continue. No Executive or Manager user found. Create one below to continue.
</p> </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>
</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 */} {/* Inline Collapsible Subform to Create Executive User */}
{isQuickAddUserOpen && !editingOrg && ( {isQuickAddUserOpen && !editingOrg && (
<div className="p-3.5 rounded-xl border border-blue-500/30 bg-blue-950/20 space-y-3"> <div className="p-3.5 rounded-xl border border-blue-500/30 bg-blue-950/20 space-y-3">
@@ -1666,14 +1924,15 @@ export const GovernancePage: React.FC = () => {
</div> </div>
<div> <div>
<label className="block text-[11px] font-medium text-slate-300 mb-1">Full / User Name</label> <label className="block text-[11px] font-medium text-slate-300 mb-1">Full Name / Username</label>
<input <input
type="text" type="text"
value={quickUserName} value={quickUserName}
onChange={(e) => setQuickUserName(e.target.value)} onChange={(e) => setQuickUserName(e.target.value)}
placeholder="e.g. Sipho Ndlovu" placeholder="e.g. Tony Ngcobo"
className="w-full px-2.5 py-1.5 text-xs border border-slate-700/80 rounded-lg bg-[#090d16] text-white focus:outline-none focus:ring-1 focus:ring-blue-500" className="w-full px-2.5 py-1.5 text-xs border border-slate-700/80 rounded-lg bg-[#090d16] text-white focus:outline-none focus:ring-1 focus:ring-blue-500"
/> />
<p className="text-[10px] text-slate-500 mt-0.5">Will be normalized to valid login username (e.g. tony.ngcobo)</p>
</div> </div>
<div> <div>
@@ -1982,16 +2241,43 @@ export const GovernancePage: React.FC = () => {
placement="top" placement="top"
/> />
</div> </div>
<select {editingProgramme && Number(editingProgramme.status) === ApprovalStatus.Approved ? (
{...programmeForm.register('status')} <div
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" 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 cannot be modified."
{APPROVAL_STATUS_OPTIONS.map((opt) => ( >
<option key={opt.value} value={opt.value}> <Lock className="w-3.5 h-3.5 text-emerald-400" />
{opt.label} <span>Approved</span>
</option> </div>
))} ) : (
</select> (() => {
const canApproveProgramme = canApproveRecord('governance', editingProgramme?.createdBy);
const isSelf = isMaker(editingProgramme?.createdBy);
return (
<select
{...programmeForm.register('status')}
className="w-full px-3 py-2 text-xs border border-slate-700/80 rounded-xl focus:outline-none focus:ring-1 focus:ring-blue-500 bg-[#090d16] text-white font-medium"
>
{APPROVAL_STATUS_OPTIONS.map((opt) => {
const isApprovedOpt = opt.value === ApprovalStatus.Approved;
const isOptDisabled = isApprovedOpt && !canApproveProgramme;
let reason = '';
if (isApprovedOpt && !canApprove('governance')) {
reason = ' (Requires Update Permission)';
} else if (isApprovedOpt && isSelf) {
reason = ' (Maker-Checker: Cannot approve own record)';
}
return (
<option key={opt.value} value={opt.value} disabled={isOptDisabled}>
{opt.label}{reason}
</option>
);
})}
</select>
);
})()
)}
</div> </div>
<div> <div>
@@ -2411,11 +2697,17 @@ export const GovernancePage: React.FC = () => {
<span className="text-slate-300">{getOrganisationTypeLabel(orgFreshDetail?.type ?? selectedOrgForDetail?.type ?? OrganisationType.Enterprise)}</span> <span className="text-slate-300">{getOrganisationTypeLabel(orgFreshDetail?.type ?? selectedOrgForDetail?.type ?? OrganisationType.Enterprise)}</span>
</div> </div>
<div className="flex items-center gap-2 min-w-0"> <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"> <span className="text-blue-400 font-mono text-[11px] truncate">
{getUserEmail(orgFreshDetail?.userId || selectedOrgForDetail?.userId) || orgFreshDetail?.userId || selectedOrgForDetail?.userId || '—'} {getUserEmail(orgFreshDetail?.userId || selectedOrgForDetail?.userId) || orgFreshDetail?.userId || selectedOrgForDetail?.userId || '—'}
</span> </span>
</div> </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>
</div> </div>
@@ -2668,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-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> <span className="text-white font-semibold text-sm block">{programmeFreshDetail?.name || selectedProgrammeForDetail?.name}</span>
</div> </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}`}> <div className="flex items-center gap-2">
{getApprovalStatusBadge(programmeFreshDetail?.status ?? selectedProgrammeForDetail?.status ?? ApprovalStatus.Approved).label} <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}`}>
</span> {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> </div>
{/* Core Details Grid: Owner & Creation Date */} {/* Core Details Grid: Owner, Maker, Creation Date */}
<div className="grid grid-cols-1 sm:grid-cols-2 gap-3 text-xs"> <div className="grid grid-cols-1 sm:grid-cols-3 gap-3 text-xs">
{/* Creation Date */} {/* Creation Date */}
<div className="p-3 bg-[#080d1a] rounded-lg border border-slate-800 flex items-center gap-2.5"> <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" /> <Calendar className="w-4 h-4 text-slate-400 shrink-0" />
@@ -2696,6 +3013,17 @@ export const GovernancePage: React.FC = () => {
</span> </span>
</div> </div>
</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> </div>
{/* Metrics: Total Rules and Total Instructions */} {/* Metrics: Total Rules and Total Instructions */}
@@ -25,6 +25,7 @@ import {
getAwards, getAwards,
getAward, getAward,
createAward, createAward,
updateAward,
getBudgets, getBudgets,
getBudget, getBudget,
createBudget, createBudget,
@@ -46,12 +47,14 @@ import { ExportButton } from '../components/ui/ExportButton';
import { ExportColumn } from '../utils/exportUtils'; import { ExportColumn } from '../utils/exportUtils';
import { useTableSort, SortableTh } from '../utils/tableSort'; import { useTableSort, SortableTh } from '../utils/tableSort';
import { useRbac } from '../auth/rbac'; import { useRbac } from '../auth/rbac';
import { bumpMutationEpoch } from '../services/httpClient';
import { import {
Award, Award,
Budget, Budget,
Disbursement, Disbursement,
ApprovalStatus, ApprovalStatus,
CreateAwardRequest, CreateAwardRequest,
UpdateAwardRequest,
CreateBudgetRequest, CreateBudgetRequest,
UpdateBudgetRequest, UpdateBudgetRequest,
CreateDisbursementRequest, CreateDisbursementRequest,
@@ -77,6 +80,15 @@ const awardSchema = z.object({
programmeId: z.coerce.number().min(1, 'Select funding programme'), programmeId: z.coerce.number().min(1, 'Select funding programme'),
}); });
const editAwardSchema = z.object({
organisationId: z.coerce.number().min(1, 'Recipient organisation is required'),
programmeId: z.coerce.number().min(1, 'Funding programme is required'),
amount: z
.coerce
.number()
.positive('Approved funding amount must be strictly greater than zero (no negative or zero values)'),
});
const budgetSchema = z.object({ const budgetSchema = z.object({
projectId: z.coerce.number().min(1, 'Select project'), projectId: z.coerce.number().min(1, 'Select project'),
name: z.string().min(3, 'Budget category name required'), name: z.string().min(3, 'Budget category name required'),
@@ -93,22 +105,26 @@ const disbursementSchema = z.object({
}); });
type AwardFormValues = z.infer<typeof awardSchema>; type AwardFormValues = z.infer<typeof awardSchema>;
type EditAwardFormValues = z.infer<typeof editAwardSchema>;
type BudgetFormValues = z.infer<typeof budgetSchema>; type BudgetFormValues = z.infer<typeof budgetSchema>;
type DisbursementFormValues = z.infer<typeof disbursementSchema>; type DisbursementFormValues = z.infer<typeof disbursementSchema>;
export const GrantsPage: React.FC = () => { export const GrantsPage: React.FC = () => {
const queryClient = useQueryClient(); const queryClient = useQueryClient();
const { success, error: toastError } = useToast(); const { success, error: toastError } = useToast();
const { canAdd, canApprove, canExport } = useRbac(); const { canAdd, canApprove, canExport, canApproveRecord, isMaker } = useRbac();
const [activeTab, setActiveTab] = useState<'awards' | 'budgets' | 'disbursements'>('awards'); const [activeTab, setActiveTab] = useState<'awards' | 'budgets' | 'disbursements'>('awards');
// Modals // Modals
const [isAwardModalOpen, setIsAwardModalOpen] = useState(false); const [isAwardModalOpen, setIsAwardModalOpen] = useState(false);
const [editingAward, setEditingAward] = useState<Award | null>(null);
const [isEditAwardModalOpen, setIsEditAwardModalOpen] = useState(false);
const [selectedAwardStatus, setSelectedAwardStatus] = useState<ApprovalStatus>(ApprovalStatus.Pending);
const [isBudgetModalOpen, setIsBudgetModalOpen] = useState(false); const [isBudgetModalOpen, setIsBudgetModalOpen] = useState(false);
const [editingBudget, setEditingBudget] = useState<Budget | null>(null); const [editingBudget, setEditingBudget] = useState<Budget | null>(null);
const [isEditBudgetModalOpen, setIsEditBudgetModalOpen] = useState(false); 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 [isDisbursementModalOpen, setIsDisbursementModalOpen] = useState(false);
const [editingDisbursement, setEditingDisbursement] = useState<Disbursement | null>(null); const [editingDisbursement, setEditingDisbursement] = useState<Disbursement | null>(null);
@@ -165,7 +181,7 @@ export const GrantsPage: React.FC = () => {
// Forms // Forms
const awardForm = useForm<AwardFormValues>({ const awardForm = useForm<AwardFormValues>({
resolver: zodResolver(awardSchema) as any, resolver: zodResolver(awardSchema),
defaultValues: { defaultValues: {
referenceNo: '', referenceNo: '',
projectId: projects[0]?.id ? Number(projects[0].id) : 401, projectId: projects[0]?.id ? Number(projects[0].id) : 401,
@@ -176,8 +192,12 @@ export const GrantsPage: React.FC = () => {
}, },
}); });
const editAwardForm = useForm<EditAwardFormValues>({
resolver: zodResolver(editAwardSchema),
});
const budgetForm = useForm<BudgetFormValues>({ const budgetForm = useForm<BudgetFormValues>({
resolver: zodResolver(budgetSchema) as any, resolver: zodResolver(budgetSchema),
defaultValues: { defaultValues: {
projectId: projects[0]?.id ? Number(projects[0].id) : 401, projectId: projects[0]?.id ? Number(projects[0].id) : 401,
name: '', name: '',
@@ -186,7 +206,7 @@ export const GrantsPage: React.FC = () => {
}); });
const disbursementForm = useForm<DisbursementFormValues>({ const disbursementForm = useForm<DisbursementFormValues>({
resolver: zodResolver(disbursementSchema) as any, resolver: zodResolver(disbursementSchema),
defaultValues: { defaultValues: {
projectId: projects[0]?.id ? Number(projects[0].id) : 401, projectId: projects[0]?.id ? Number(projects[0].id) : 401,
amount: 2500000, amount: 2500000,
@@ -211,9 +231,26 @@ 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.');
setIsEditAwardModalOpen(false);
setEditingAward(null);
editAwardForm.reset();
},
onError: (err: unknown) => {
toastError('Award update failed', err);
},
});
const budgetMutation = useMutation({ const budgetMutation = useMutation({
mutationFn: (data: CreateBudgetRequest) => createBudget(data), mutationFn: (data: CreateBudgetRequest) => createBudget(data),
onSuccess: () => { onSuccess: () => {
bumpMutationEpoch();
queryClient.invalidateQueries({ queryKey: ['budgets'] }); queryClient.invalidateQueries({ queryKey: ['budgets'] });
success('Budget line established', 'Budget line created against project'); success('Budget line established', 'Budget line created against project');
setIsBudgetModalOpen(false); setIsBudgetModalOpen(false);
@@ -227,6 +264,7 @@ export const GrantsPage: React.FC = () => {
const updateBudgetMutation = useMutation({ const updateBudgetMutation = useMutation({
mutationFn: (data: UpdateBudgetRequest) => updateBudget(data), mutationFn: (data: UpdateBudgetRequest) => updateBudget(data),
onSuccess: () => { onSuccess: () => {
bumpMutationEpoch();
queryClient.invalidateQueries({ queryKey: ['budgets'] }); queryClient.invalidateQueries({ queryKey: ['budgets'] });
success('Budget Updated', 'Budget compliance and approval status updated.'); success('Budget Updated', 'Budget compliance and approval status updated.');
setIsEditBudgetModalOpen(false); setIsEditBudgetModalOpen(false);
@@ -253,6 +291,7 @@ export const GrantsPage: React.FC = () => {
}); });
}, },
onSuccess: () => { onSuccess: () => {
bumpMutationEpoch();
queryClient.invalidateQueries({ queryKey: ['disbursements'] }); queryClient.invalidateQueries({ queryKey: ['disbursements'] });
success( success(
editingDisbursement ? 'Disbursement Updated' : 'Disbursement Scheduled', editingDisbursement ? 'Disbursement Updated' : 'Disbursement Scheduled',
@@ -463,6 +502,7 @@ export const GrantsPage: React.FC = () => {
const approveMutation = useMutation({ const approveMutation = useMutation({
mutationFn: (id: string | number) => approveDisbursement(id), mutationFn: (id: string | number) => approveDisbursement(id),
onSuccess: () => { onSuccess: () => {
bumpMutationEpoch();
queryClient.invalidateQueries({ queryKey: ['disbursements'] }); queryClient.invalidateQueries({ queryKey: ['disbursements'] });
queryClient.invalidateQueries({ queryKey: ['dashboard'] }); queryClient.invalidateQueries({ queryKey: ['dashboard'] });
success( success(
@@ -603,8 +643,14 @@ export const GrantsPage: React.FC = () => {
handleSort: handleAwardSort, handleSort: handleAwardSort,
} = useTableSort(filteredAwards, null, null, { } = useTableSort(filteredAwards, null, null, {
referenceNo: (a) => a.referenceNo, referenceNo: (a) => a.referenceNo,
organisationId: (a) => a.organisationName || `Org #${a.organisationId}`, organisationId: (a) => {
programmeId: (a) => a.programmeName || `Programme #${a.programmeId}`, 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, amount: (a) => a.amount,
status: (a) => getApprovalStatusBadge(a.status).label, status: (a) => getApprovalStatusBadge(a.status).label,
createdAt: (a) => a.createdAt, createdAt: (a) => a.createdAt,
@@ -617,7 +663,10 @@ export const GrantsPage: React.FC = () => {
handleSort: handleBudgetSort, handleSort: handleBudgetSort,
} = useTableSort(filteredBudgets, null, null, { } = useTableSort(filteredBudgets, null, null, {
name: (b) => b.name, 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, amount: (b) => b.amount,
createdAt: (b) => b.createdAt, createdAt: (b) => b.createdAt,
status: (b) => getApprovalStatusBadge(b.status ?? ApprovalStatus.Approved).label, status: (b) => getApprovalStatusBadge(b.status ?? ApprovalStatus.Approved).label,
@@ -631,8 +680,15 @@ export const GrantsPage: React.FC = () => {
handleSort: handleDisbSort, handleSort: handleDisbSort,
} = useTableSort(filteredDisbursements, null, null, { } = useTableSort(filteredDisbursements, null, null, {
id: (d) => d.id, id: (d) => d.id,
projectId: (d) => d.projectId, projectId: (d) => {
milestoneId: (d) => d.milestoneId || 0, 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, amount: (d) => d.amount,
paymentReference: (d) => d.paymentReference || '', paymentReference: (d) => d.paymentReference || '',
status: (d) => getApprovalStatusBadge(d.status).label, status: (d) => getApprovalStatusBadge(d.status).label,
@@ -792,8 +848,10 @@ export const GrantsPage: React.FC = () => {
<div className="flex items-center gap-2 self-end sm:self-center"> <div className="flex items-center gap-2 self-end sm:self-center">
<button <button
type="button" type="button"
onClick={() => { onClick={async () => {
refetchAwards(); bumpMutationEpoch();
await queryClient.invalidateQueries({ queryKey: ['awards'] });
await refetchAwards();
success('Refreshed', 'Awards intake list refreshed from API.'); success('Refreshed', 'Awards intake list refreshed from API.');
}} }}
disabled={isFetchingAwards} disabled={isFetchingAwards}
@@ -837,18 +895,21 @@ export const GrantsPage: React.FC = () => {
<SortableTh column="createdAt" currentColumn={awardSortCol} direction={awardSortDir} onSort={handleAwardSort} align="right" className="py-3.5 px-5 text-right"> <SortableTh column="createdAt" currentColumn={awardSortCol} direction={awardSortDir} onSort={handleAwardSort} align="right" className="py-3.5 px-5 text-right">
Date Registered Date Registered
</SortableTh> </SortableTh>
<th className="py-3.5 px-5 text-right font-semibold text-xs text-slate-400 uppercase tracking-wider">
Actions
</th>
</tr> </tr>
</thead> </thead>
<tbody className="divide-y divide-slate-800/50"> <tbody className="divide-y divide-slate-800/50">
{loadingAwards ? ( {loadingAwards ? (
<tr> <tr>
<td colSpan={6} className="py-8 text-center text-slate-500"> <td colSpan={7} className="py-8 text-center text-slate-500">
Loading registered awards... Loading registered awards...
</td> </td>
</tr> </tr>
) : sortedAwards.length === 0 ? ( ) : sortedAwards.length === 0 ? (
<tr> <tr>
<td colSpan={6} className="py-8 text-center text-slate-500"> <td colSpan={7} className="py-8 text-center text-slate-500">
{searchTerm ? 'No awards match your search query.' : 'No registered awards found.'} {searchTerm ? 'No awards match your search query.' : 'No registered awards found.'}
</td> </td>
</tr> </tr>
@@ -862,10 +923,28 @@ export const GrantsPage: React.FC = () => {
<span className="text-[10px] text-slate-500 font-mono">System ID: #{awd.id}</span> <span className="text-[10px] text-slate-500 font-mono">System ID: #{awd.id}</span>
</td> </td>
<td className="py-3.5 px-4 text-slate-200 font-medium"> <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>
<td className="py-3.5 px-4 text-slate-400"> <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>
<td className="py-3.5 px-4 text-right font-mono font-bold text-white text-xs"> <td className="py-3.5 px-4 text-right font-mono font-bold text-white text-xs">
{formatZAR(awd.amount)} {formatZAR(awd.amount)}
@@ -878,6 +957,36 @@ export const GrantsPage: React.FC = () => {
<td className="py-3.5 px-5 text-right font-mono text-slate-400"> <td className="py-3.5 px-5 text-right font-mono text-slate-400">
{formatDate(awd.createdAt)} {formatDate(awd.createdAt)}
</td> </td>
<td className="py-3.5 px-5 text-right">
{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 details cannot be modified."
>
<Lock className="w-3 h-3 text-emerald-400" />
<span>Approved</span>
</span>
) : (
<button
type="button"
onClick={() => {
setEditingAward(awd);
setSelectedAwardStatus(Number(awd.status) as ApprovalStatus);
editAwardForm.reset({
organisationId: Number(awd.organisationId),
programmeId: Number(awd.programmeId),
amount: Number(awd.amount),
});
setIsEditAwardModalOpen(true);
}}
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.5 rounded-lg transition shadow-xs cursor-pointer"
title="Edit Award"
>
<Edit2 className="w-3 h-3" />
<span>Edit</span>
</button>
)}
</td>
</tr> </tr>
); );
}) })
@@ -906,8 +1015,10 @@ export const GrantsPage: React.FC = () => {
<div className="flex items-center gap-2 self-end sm:self-center"> <div className="flex items-center gap-2 self-end sm:self-center">
<button <button
type="button" type="button"
onClick={() => { onClick={async () => {
refetchBudgets(); bumpMutationEpoch();
await queryClient.invalidateQueries({ queryKey: ['budgets'] });
await refetchBudgets();
success('Refreshed', 'Budget line categories refreshed from API.'); success('Refreshed', 'Budget line categories refreshed from API.');
}} }}
disabled={isFetchingBudgets} disabled={isFetchingBudgets}
@@ -937,7 +1048,7 @@ export const GrantsPage: React.FC = () => {
Budget Line Category Budget Line Category
</SortableTh> </SortableTh>
<SortableTh column="projectId" currentColumn={budgetSortCol} direction={budgetSortDir} onSort={handleBudgetSort} className="py-3.5 px-4"> <SortableTh column="projectId" currentColumn={budgetSortCol} direction={budgetSortDir} onSort={handleBudgetSort} className="py-3.5 px-4">
Project ID Project
</SortableTh> </SortableTh>
<SortableTh column="amount" currentColumn={budgetSortCol} direction={budgetSortDir} onSort={handleBudgetSort} align="right" className="py-3.5 px-4 text-right"> <SortableTh column="amount" currentColumn={budgetSortCol} direction={budgetSortDir} onSort={handleBudgetSort} align="right" className="py-3.5 px-4 text-right">
Approved Amount (ZAR) Approved Amount (ZAR)
@@ -976,7 +1087,18 @@ export const GrantsPage: React.FC = () => {
<span className="font-semibold text-white block">{b.name}</span> <span className="font-semibold text-white block">{b.name}</span>
<span className="text-[10px] text-slate-500 font-mono">ID: #{b.id}</span> <span className="text-[10px] text-slate-500 font-mono">ID: #{b.id}</span>
</td> </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"> <td className="py-3.5 px-4 text-right font-mono font-bold text-white">
{formatZAR(b.amount)} {formatZAR(b.amount)}
</td> </td>
@@ -989,35 +1111,52 @@ export const GrantsPage: React.FC = () => {
<td className="py-3.5 px-4 font-mono text-slate-400">{getUserEmail(b.createdBy)}</td> <td className="py-3.5 px-4 font-mono text-slate-400">{getUserEmail(b.createdBy)}</td>
<td className="py-3.5 px-5 text-right"> <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">
<button {Number(b.status) === ApprovalStatus.Approved ? (
type="button" <span
onClick={() => { 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"
setEditingBudget(b); title="Approved budget status cannot be modified."
setSelectedBudgetStatus(b.status ?? ApprovalStatus.Approved);
setIsEditBudgetModalOpen(true);
}}
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.5 rounded-lg transition shadow-xs cursor-pointer"
title="Update Budget Status"
>
<Edit2 className="w-3 h-3" />
<span>Update</span>
</button>
{b.status !== ApprovalStatus.Approved && canApprove('grant_financials') && (
<button
type="button"
disabled={updateBudgetMutation.isPending}
onClick={() => {
updateBudgetMutation.mutate({
budgetId: b.id,
status: ApprovalStatus.Approved,
});
}}
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.5 rounded-lg transition shadow-xs cursor-pointer"
title="Approve Budget Line"
> >
<CheckCircle className="w-3 h-3" /> <Lock className="w-3 h-3 text-emerald-400" />
<span>Approve</span> <span>Approved</span>
</button> </span>
) : (
<>
<button
type="button"
onClick={() => {
setEditingBudget(b);
setSelectedBudgetStatus(b.status ?? ApprovalStatus.Pending);
setIsEditBudgetModalOpen(true);
}}
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.5 rounded-lg transition shadow-xs cursor-pointer"
title="Update Budget Status"
>
<Edit2 className="w-3 h-3" />
<span>Update</span>
</button>
{canApproveRecord('grant_financials', b.createdBy) && (
<button
type="button"
disabled={updateBudgetMutation.isPending}
onClick={() => {
updateBudgetMutation.mutate({
budgetId: b.id,
status: ApprovalStatus.Approved,
});
}}
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.5 rounded-lg transition shadow-xs cursor-pointer"
title="Approve Budget Line"
>
<CheckCircle className="w-3 h-3" />
<span>Approve</span>
</button>
)}
{canApprove('grant_financials') && isMaker(b.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
</span>
)}
</>
)} )}
</div> </div>
</td> </td>
@@ -1049,8 +1188,10 @@ export const GrantsPage: React.FC = () => {
<div className="flex items-center gap-2 self-end sm:self-center"> <div className="flex items-center gap-2 self-end sm:self-center">
<button <button
type="button" type="button"
onClick={() => { onClick={async () => {
refetchDisbursements(); bumpMutationEpoch();
await queryClient.invalidateQueries({ queryKey: ['disbursements'] });
await refetchDisbursements();
success('Refreshed', 'Disbursement tranches refreshed from API.'); success('Refreshed', 'Disbursement tranches refreshed from API.');
}} }}
disabled={isFetchingDisbursements} disabled={isFetchingDisbursements}
@@ -1088,7 +1229,7 @@ export const GrantsPage: React.FC = () => {
Disbursement ID Disbursement ID
</SortableTh> </SortableTh>
<SortableTh column="projectId" currentColumn={disbSortCol} direction={disbSortDir} onSort={handleDisbSort} className="py-3.5 px-4"> <SortableTh column="projectId" currentColumn={disbSortCol} direction={disbSortDir} onSort={handleDisbSort} className="py-3.5 px-4">
Project ID Project
</SortableTh> </SortableTh>
<SortableTh column="milestoneId" currentColumn={disbSortCol} direction={disbSortDir} onSort={handleDisbSort} className="py-3.5 px-4"> <SortableTh column="milestoneId" currentColumn={disbSortCol} direction={disbSortDir} onSort={handleDisbSort} className="py-3.5 px-4">
Milestone Association Milestone Association
@@ -1127,9 +1268,32 @@ export const GrantsPage: React.FC = () => {
<td className="py-3.5 px-5 font-mono font-bold text-white"> <td className="py-3.5 px-5 font-mono font-bold text-white">
DSB-{d.id} DSB-{d.id}
</td> </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"> <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>
<td className="py-3.5 px-4 text-right font-mono font-bold text-white text-xs"> <td className="py-3.5 px-4 text-right font-mono font-bold text-white text-xs">
{formatZAR(d.amount)} {formatZAR(d.amount)}
@@ -1159,7 +1323,7 @@ export const GrantsPage: React.FC = () => {
> >
<Edit2 className="w-3.5 h-3.5" /> <Edit2 className="w-3.5 h-3.5" />
</button> </button>
{canApprove('grant_financials') && ( {canApproveRecord('grant_financials', d.createdBy) && (
<button <button
onClick={() => approveMutation.mutate(d.id)} onClick={() => approveMutation.mutate(d.id)}
disabled={approveMutation.isPending} disabled={approveMutation.isPending}
@@ -1169,6 +1333,11 @@ export const GrantsPage: React.FC = () => {
Approve Payment Approve Payment
</button> </button>
)} )}
{canApprove('grant_financials') && isMaker(d.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 (Creator)
</span>
)}
</> </>
)} )}
</div> </div>
@@ -1445,6 +1614,161 @@ export const GrantsPage: React.FC = () => {
</form> </form>
</Modal> </Modal>
{/* Modal: Edit Award */}
<Modal
isOpen={isEditAwardModalOpen}
onClose={() => {
setIsEditAwardModalOpen(false);
setEditingAward(null);
}}
title={`Edit Award: ${editingAward?.referenceNo || `#${editingAward?.id}`}`}
subtitle="Approved funding award record"
helpText="Modify the funding programme, recipient organisation, and approved award allocation."
>
<form
onSubmit={editAwardForm.handleSubmit((values) => {
if (!editingAward) return;
if (Number(editingAward.status) === ApprovalStatus.Approved) {
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({
awardId: editingAward.id,
organisationId: values.organisationId,
programmeId: values.programmeId,
amount: values.amount,
status: selectedAwardStatus,
});
})}
className="space-y-4"
>
<div>
<label className="block text-xs font-semibold text-slate-300 mb-1">
Funding Programme <span className="text-rose-400">*</span>
</label>
<select
{...editAwardForm.register('programmeId')}
className="w-full px-3 py-2 text-xs bg-slate-900/80 border border-slate-700/80 rounded-xl text-white focus:outline-none focus:ring-2 focus:ring-blue-500/50 focus:border-blue-500 font-medium [&>option]:bg-slate-900 [&>option]:text-white"
>
<option value="">Select Programme</option>
{programmes.map((p) => (
<option key={p.id} value={p.id}>
{p.name}
</option>
))}
</select>
{editAwardForm.formState.errors.programmeId && (
<p className="text-[11px] text-rose-400 mt-1">
{editAwardForm.formState.errors.programmeId.message}
</p>
)}
</div>
<div>
<label className="block text-xs font-semibold text-slate-300 mb-1">
Recipient Organisation <span className="text-rose-400">*</span>
</label>
<select
{...editAwardForm.register('organisationId')}
className="w-full px-3 py-2 text-xs bg-slate-900/80 border border-slate-700/80 rounded-xl text-white focus:outline-none focus:ring-2 focus:ring-blue-500/50 focus:border-blue-500 font-medium [&>option]:bg-slate-900 [&>option]:text-white"
>
<option value="">Select Recipient Organisation</option>
{organisations.map((o) => (
<option key={o.id} value={o.id}>
{o.name}
</option>
))}
</select>
{editAwardForm.formState.errors.organisationId && (
<p className="text-[11px] text-rose-400 mt-1">
{editAwardForm.formState.errors.organisationId.message}
</p>
)}
</div>
<div>
<label className="block text-xs font-semibold text-slate-300 mb-1">
Approved Award Amount (ZAR) <span className="text-rose-400">*</span>
</label>
<input
type="number"
step="0.01"
{...editAwardForm.register('amount')}
className="w-full px-3 py-2 text-xs bg-slate-900/80 border border-slate-700/80 rounded-xl text-white focus:outline-none focus:ring-2 focus:ring-blue-500/50 focus:border-blue-500 font-mono"
/>
{editAwardForm.formState.errors.amount && (
<p className="text-[11px] text-rose-400 mt-1">
{editAwardForm.formState.errors.amount.message}
</p>
)}
</div>
<div>
<label className="block text-xs font-semibold text-slate-300 mb-1">
Award Lifecycle Status <span className="text-rose-400">*</span>
</label>
{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</span>
</div>
) : (
<select
value={selectedAwardStatus}
onChange={(e) => setSelectedAwardStatus(Number(e.target.value) as ApprovalStatus)}
className="w-full px-3 py-2 text-xs bg-slate-900/80 border border-slate-700/80 rounded-xl text-white focus:outline-none focus:ring-2 focus:ring-blue-500/50 focus:border-blue-500 font-medium [&>option]:bg-slate-900 [&>option]:text-white"
>
<option value={ApprovalStatus.Pending}>Pending Approval</option>
<option value={ApprovalStatus.Review}>Under Review</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>
)}
</div>
<div className="flex justify-end gap-2 pt-4 border-t border-slate-800">
<button
type="button"
onClick={() => {
setIsEditAwardModalOpen(false);
setEditingAward(null);
}}
className="px-4 py-2 text-xs font-semibold text-slate-300 hover:text-white bg-slate-800 hover:bg-slate-700 rounded-xl transition cursor-pointer"
>
Cancel
</button>
<button
type="submit"
disabled={updateAwardMutation.isPending || Number(editingAward?.status) === ApprovalStatus.Approved}
className="px-4 py-2 text-xs font-semibold text-white bg-blue-600 hover:bg-blue-500 rounded-xl transition shadow-md shadow-blue-500/20 disabled:opacity-50 cursor-pointer"
>
{updateAwardMutation.isPending ? 'Saving...' : 'Save Changes'}
</button>
</div>
</form>
</Modal>
{/* Modal: Add Budget */} {/* Modal: Add Budget */}
<Modal <Modal
isOpen={isBudgetModalOpen} isOpen={isBudgetModalOpen}
@@ -1453,7 +1777,7 @@ export const GrantsPage: React.FC = () => {
subtitle="Maintain approved expenditure breakdown and commitments" subtitle="Maintain approved expenditure breakdown and commitments"
helpText="Establishes financial lines for project cost categories. Ensures all disbursements are mapped to audited budget ceilings." helpText="Establishes financial lines for project cost categories. Ensures all disbursements are mapped to audited budget ceilings."
> >
<form onSubmit={budgetForm.handleSubmit((data) => budgetMutation.mutate(data as any))} className="space-y-4"> <form onSubmit={budgetForm.handleSubmit((data) => budgetMutation.mutate(data as CreateBudgetRequest))} className="space-y-4">
<div> <div>
<div className="flex items-center gap-1 mb-1.5"> <div className="flex items-center gap-1 mb-1.5">
<label className="block text-xs font-semibold text-slate-300">Associated Project *</label> <label className="block text-xs font-semibold text-slate-300">Associated Project *</label>
@@ -1557,17 +1881,36 @@ export const GrantsPage: React.FC = () => {
<label className="block text-xs font-semibold text-slate-300 mb-1.5"> <label className="block text-xs font-semibold text-slate-300 mb-1.5">
Approval &amp; Compliance Status * Approval &amp; Compliance Status *
</label> </label>
<select {Number(editingBudget?.status) === ApprovalStatus.Approved ? (
value={selectedBudgetStatus} <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">
onChange={(e) => setSelectedBudgetStatus(Number(e.target.value) as ApprovalStatus)} <Lock className="w-3.5 h-3.5 text-emerald-400" />
className="w-full px-3 py-2 text-xs bg-slate-900/80 border border-slate-700/80 rounded-xl text-white focus:outline-none focus:ring-2 focus:ring-blue-500/50 focus:border-blue-500 font-medium [&>option]:bg-slate-900 [&>option]:text-white" <span>Approved</span>
> </div>
<option value={ApprovalStatus.Approved} disabled={!canApprove('grant_financials')}> ) : (
Approved {!canApprove('grant_financials') ? '(Requires Update Permission)' : ''} <select
</option> value={selectedBudgetStatus}
<option value={ApprovalStatus.Pending}>Pending Review</option> onChange={(e) => setSelectedBudgetStatus(Number(e.target.value) as ApprovalStatus)}
<option value={ApprovalStatus.Rejected}>Rejected / Blocked</option> className="w-full px-3 py-2 text-xs bg-slate-900/80 border border-slate-700/80 rounded-xl text-white focus:outline-none focus:ring-2 focus:ring-blue-500/50 focus:border-blue-500 font-medium [&>option]:bg-slate-900 [&>option]:text-white"
</select> >
{(() => {
const canApproveBudget = canApproveRecord('grant_financials', editingBudget?.createdBy);
const isSelf = isMaker(editingBudget?.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={!canApproveBudget}>
Approved {reason}
</option>
);
})()}
<option value={ApprovalStatus.Pending}>Pending Review</option>
<option value={ApprovalStatus.Rejected}>Rejected / Blocked</option>
</select>
)}
</div> </div>
<div className="pt-3 border-t border-slate-800 flex justify-end gap-2"> <div className="pt-3 border-t border-slate-800 flex justify-end gap-2">
@@ -1583,7 +1926,7 @@ export const GrantsPage: React.FC = () => {
</button> </button>
<button <button
type="button" type="button"
disabled={updateBudgetMutation.isPending || !editingBudget} disabled={updateBudgetMutation.isPending || !editingBudget || Number(editingBudget.status) === ApprovalStatus.Approved}
onClick={() => { onClick={() => {
if (editingBudget) { if (editingBudget) {
updateBudgetMutation.mutate({ updateBudgetMutation.mutate({
@@ -0,0 +1,997 @@
import React, { useState, useEffect, useMemo } from 'react';
import { useSearchParams } from 'react-router-dom';
import { useQuery } from '@tanstack/react-query';
import {
Inbox,
Send,
FileText,
Star,
Trash2,
Mail,
MailOpen,
Search,
PenSquare,
RefreshCw,
Reply,
CheckCircle2,
Clock,
RotateCcw,
X,
Smartphone,
ShieldAlert,
} from 'lucide-react';
import { useInAppNotifications, DraftMessage } from '../hooks/useInAppNotifications';
import { ComposeMessageModal } from '../components/notifications/ComposeMessageModal';
import { MarkdownViewer } from '../components/notifications/MarkdownViewer';
import { formatDate } from '../lib/formatters';
import { Notification, NotificationPlatform, NotificationStatus, User } from '../types';
import { useAuth } from '../auth/AuthProvider';
import { stripMarkdown } from '../lib/markdownUtils';
import { getUsers } from '../services/api';
type MailboxFolder = 'inbox' | 'starred' | 'sent' | 'drafts' | 'trash';
export const InboxPage: React.FC = () => {
const [searchParams, setSearchParams] = useSearchParams();
const targetIdParam = searchParams.get('id');
const { user, claims } = useAuth();
const currentUserId = String(user?.id || claims?.sub || '').trim().toLowerCase();
const currentUserEmail = (user?.email || claims?.email || '') as string;
const { data: directoryUsers = [] } = useQuery<User[]>({
queryKey: ['users-directory'],
queryFn: () => getUsers(1, 200),
});
const {
allMailboxNotifications,
inboxNotifications,
unreadCount,
starredNotifications,
trashedNotifications,
sentNotifications,
drafts,
starredIds,
isLoading,
refetch,
markAsRead,
toggleStar,
deleteMessage,
restoreMessage,
permanentDelete,
deleteDraft,
sendMessage,
isSending,
} = useInAppNotifications();
// Active folder
const [activeFolder, setActiveFolder] = useState<MailboxFolder>('inbox');
// Search query
const [searchTerm, setSearchTerm] = useState('');
// Filter for unread only in Inbox
const [showUnreadOnly, setShowUnreadOnly] = useState(false);
// Selected message to view in detail
const [selectedMessage, setSelectedMessage] = useState<Notification | null>(null);
// Selected draft to edit/resume
const [selectedDraft, setSelectedDraft] = useState<DraftMessage | null>(null);
// Compose modal state
const [isComposeOpen, setIsComposeOpen] = useState(false);
const [composeInitialRecipient, setComposeInitialRecipient] = useState<any>(undefined);
const [composeInitialSubject, setComposeInitialSubject] = useState('');
const [composeInitialMessage, setComposeInitialMessage] = useState('');
const [composeInitialPlatform, setComposeInitialPlatform] = useState<NotificationPlatform>(NotificationPlatform.InApp);
// Inline quick reply state
const [inlineReplyText, setInlineReplyText] = useState('');
const [inlineReplySuccess, setInlineReplySuccess] = useState(false);
// Self-reply prevention modal state
const [isSelfReplyModalOpen, setIsSelfReplyModalOpen] = useState(false);
// Active folder message items (always In-App notifications)
const folderMessages = useMemo(() => {
switch (activeFolder) {
case 'inbox':
return inboxNotifications;
case 'starred':
return starredNotifications;
case 'sent':
return sentNotifications;
case 'trash':
return trashedNotifications;
case 'drafts':
return [];
default:
return inboxNotifications;
}
}, [activeFolder, inboxNotifications, starredNotifications, sentNotifications, trashedNotifications]);
// Filtered by search and unread toggle
const filteredMessages = useMemo(() => {
return folderMessages.filter(item => {
// Unread only filter
if (activeFolder === 'inbox' && showUnreadOnly) {
if (Number(item.status) === NotificationStatus.Read) return false;
}
// Search term
if (!searchTerm.trim()) return true;
const term = searchTerm.toLowerCase();
const subject = (item.subject || '').toLowerCase();
const message = (item.message || '').toLowerCase();
const dest = (item.destination || '').toLowerCase();
return subject.includes(term) || message.includes(term) || dest.includes(term);
});
}, [folderMessages, activeFolder, showUnreadOnly, searchTerm]);
// Filtered drafts for Drafts folder
const filteredDrafts = useMemo(() => {
if (activeFolder !== 'drafts') return [];
if (!searchTerm.trim()) return drafts;
const term = searchTerm.toLowerCase();
return drafts.filter(
d =>
d.subject.toLowerCase().includes(term) ||
d.message.toLowerCase().includes(term) ||
d.recipientEmail.toLowerCase().includes(term)
);
}, [drafts, activeFolder, searchTerm]);
// Automatically select message when URL param ?id=... is present
useEffect(() => {
if (targetIdParam) {
const found = folderMessages.find(m => String(m.id) === targetIdParam);
if (found) {
setSelectedMessage(found);
if (Number(found.status) !== NotificationStatus.Read) {
markAsRead(found.id);
}
}
} else if (!selectedMessage && folderMessages.length > 0 && activeFolder !== 'drafts') {
const first = folderMessages[0];
setSelectedMessage(first);
if (activeFolder === 'inbox' && Number(first.status) !== NotificationStatus.Read) {
markAsRead(first.id);
}
}
}, [targetIdParam, folderMessages, activeFolder]);
// Handle message selection
const handleSelectMessage = (item: Notification) => {
setSelectedMessage(item);
setSearchParams({ id: String(item.id) });
if (Number(item.status) !== NotificationStatus.Read) {
markAsRead(item.id);
}
setInlineReplyText('');
setInlineReplySuccess(false);
};
// Open Compose Modal for a new message
const handleOpenCompose = (defaultPlatform: NotificationPlatform = NotificationPlatform.InApp) => {
setSelectedDraft(null);
setComposeInitialRecipient(undefined);
setComposeInitialSubject('');
setComposeInitialMessage('');
setComposeInitialPlatform(defaultPlatform);
setIsComposeOpen(true);
};
const extractSenderEmail = (msg: Notification): string => {
const rawDest = msg.destination || '';
if (rawDest.includes('From:')) {
const fromPart = rawDest.split('From:')[1]?.split('|')[0]?.trim() || '';
return fromPart.replace(/[<>]/g, '').trim();
}
if (!rawDest.includes('To:') && rawDest.includes('@')) {
return rawDest.trim();
}
return '';
};
const isNotificationFromSelf = (msg: Notification): boolean => {
if (activeFolder === 'sent' || String(msg.id).startsWith('sent_')) {
return true;
}
const senderEmail = extractSenderEmail(msg).toLowerCase();
const myEmail = currentUserEmail.trim().toLowerCase();
if (myEmail && senderEmail && senderEmail === myEmail) {
return true;
}
if (!senderEmail && String(msg.recipient || '').trim().toLowerCase() === currentUserId) {
return true;
}
return false;
};
// Open Compose Modal for a Reply
const handleReplyMessage = (msg: Notification) => {
if (isNotificationFromSelf(msg)) {
setIsSelfReplyModalOpen(true);
return;
}
const replyEmail = extractSenderEmail(msg);
const matchedSender = replyEmail
? directoryUsers.find(u => (u.email || '').trim().toLowerCase() === replyEmail.toLowerCase())
: undefined;
if (
(matchedSender && String(matchedSender.id).trim().toLowerCase() === currentUserId) ||
(replyEmail && replyEmail.toLowerCase() === currentUserEmail.trim().toLowerCase())
) {
setIsSelfReplyModalOpen(true);
return;
}
const replySubject = msg.subject?.startsWith('Re:') ? msg.subject : `Re: ${msg.subject || 'Communication'}`;
const quotedBody = `\n\n--- Original Message (${formatDate(msg.createdAt)}) ---\n> ${stripMarkdown(msg.message || '')}`;
setSelectedDraft(null);
setComposeInitialRecipient(
replyEmail && matchedSender
? {
id: String(matchedSender.id),
email: replyEmail,
name: replyEmail.split('@')[0],
}
: undefined
);
setComposeInitialSubject(replySubject);
setComposeInitialMessage(quotedBody);
setComposeInitialPlatform(msg.platform as NotificationPlatform || NotificationPlatform.InApp);
setIsComposeOpen(true);
};
// Resume editing a saved draft
const handleEditDraft = (draft: DraftMessage) => {
setSelectedDraft(draft);
setComposeInitialRecipient(
draft.recipientId
? {
id: draft.recipientId,
email: draft.recipientEmail,
name: draft.recipientName,
}
: undefined
);
setComposeInitialSubject(draft.subject);
setComposeInitialMessage(draft.message);
setComposeInitialPlatform(draft.platform || NotificationPlatform.InApp);
setIsComposeOpen(true);
};
// Send inline quick reply
const handleSendInlineReply = async () => {
if (!selectedMessage || !inlineReplyText.trim()) return;
if (isNotificationFromSelf(selectedMessage)) {
setIsSelfReplyModalOpen(true);
return;
}
const replyEmail = extractSenderEmail(selectedMessage);
const matchedSender = replyEmail
? directoryUsers.find(u => (u.email || '').trim().toLowerCase() === replyEmail.toLowerCase())
: undefined;
const targetRecipientId = matchedSender ? String(matchedSender.id) : String(selectedMessage.recipient);
if (
targetRecipientId.trim().toLowerCase() === currentUserId ||
(replyEmail && replyEmail.toLowerCase() === currentUserEmail.trim().toLowerCase())
) {
setIsSelfReplyModalOpen(true);
return;
}
try {
await sendMessage({
recipientId: targetRecipientId,
recipientEmail: replyEmail || 'team@dsi.gov.za',
subject: selectedMessage.subject?.startsWith('Re:')
? selectedMessage.subject
: `Re: ${selectedMessage.subject || 'Notification'}`,
message: inlineReplyText.trim(),
platform: selectedMessage.platform as NotificationPlatform || NotificationPlatform.InApp,
});
setInlineReplySuccess(true);
setInlineReplyText('');
setTimeout(() => {
setInlineReplySuccess(false);
}, 3000);
} catch (err) {
console.error('Failed to send inline reply:', err);
}
};
return (
<div className="flex-1 flex flex-col h-full bg-[#090d16] text-slate-100 overflow-hidden font-sans">
{/* Top Mailbox Breadcrumb & Action Banner */}
<div className="h-14 px-6 border-b border-slate-800/80 bg-[#0d121f]/90 backdrop-blur-md flex items-center justify-between shrink-0">
<div className="flex items-center gap-3">
<div className="w-8 h-8 rounded-xl bg-blue-600/20 border border-blue-500/30 flex items-center justify-center text-blue-400">
<Mail className="w-4 h-4" />
</div>
<div>
<div className="flex items-center gap-2">
<h1 className="text-sm font-bold text-white tracking-tight">Mailbox & Communications</h1>
<span className="text-[10px] font-semibold text-blue-300 bg-blue-500/10 border border-blue-500/30 px-2 py-0.5 rounded-full flex items-center gap-1">
In-App Messaging
</span>
</div>
<p className="text-[11px] text-slate-400">
In-App notifications and internal platform communications
</p>
</div>
</div>
<div className="flex items-center gap-2">
<button
type="button"
onClick={() => refetch()}
className="flex items-center gap-1.5 px-3 py-1.5 rounded-xl bg-slate-900/80 hover:bg-slate-800 border border-slate-700/80 text-xs font-medium text-slate-300 hover:text-white transition cursor-pointer shadow-xs"
title="Refresh mailbox messages"
>
<RefreshCw className={`w-3.5 h-3.5 ${isLoading ? 'animate-spin text-blue-400' : ''}`} />
<span>Refresh</span>
</button>
<button
type="button"
onClick={() => handleOpenCompose()}
id="compose-message-main-btn"
className="flex items-center gap-2 px-4 py-1.5 rounded-xl bg-blue-600 hover:bg-blue-500 text-white text-xs font-semibold shadow-lg shadow-blue-600/30 transition cursor-pointer"
>
<PenSquare className="w-3.5 h-3.5" />
<span>Compose</span>
</button>
</div>
</div>
{/* Main Mailbox Three-Pane Layout */}
<div className="flex-1 flex overflow-hidden">
{/* ======================================================== */}
{/* PANE 1: Mailbox Folders Sidebar */}
{/* ======================================================== */}
<aside className="w-60 shrink-0 border-r border-slate-800/80 bg-[#0b101b]/95 p-3 flex flex-col justify-between">
<div className="space-y-4">
{/* Primary Compose Action */}
<button
type="button"
onClick={() => handleOpenCompose()}
className="w-full flex items-center justify-center gap-2 py-2.5 px-4 rounded-2xl bg-gradient-to-r from-blue-600 to-indigo-600 hover:from-blue-500 hover:to-indigo-500 text-white text-xs font-bold shadow-lg shadow-blue-600/25 transition cursor-pointer"
>
<PenSquare className="w-4 h-4" />
<span>New Message</span>
</button>
{/* Folder Navigation */}
<nav className="space-y-1">
{/* Inbox */}
<button
type="button"
onClick={() => {
setActiveFolder('inbox');
setSelectedDraft(null);
}}
className={`w-full flex items-center justify-between px-3 py-2 rounded-xl text-xs font-medium transition cursor-pointer ${
activeFolder === 'inbox'
? 'bg-blue-600/20 text-white border border-blue-500/30'
: 'text-slate-300 hover:text-white hover:bg-slate-800/60'
}`}
>
<div className="flex items-center gap-2.5">
<Inbox className="w-4 h-4 text-blue-400" />
<span>Inbox</span>
</div>
{unreadCount > 0 ? (
<span className="text-[10px] font-bold text-white bg-blue-600 px-2 py-0.5 rounded-full shadow-xs">
{unreadCount}
</span>
) : (
<span className="text-[11px] text-slate-400 font-mono">
{allMailboxNotifications.length}
</span>
)}
</button>
{/* Starred */}
<button
type="button"
onClick={() => {
setActiveFolder('starred');
setSelectedDraft(null);
}}
className={`w-full flex items-center justify-between px-3 py-2 rounded-xl text-xs font-medium transition cursor-pointer ${
activeFolder === 'starred'
? 'bg-amber-500/20 text-white border border-amber-500/30'
: 'text-slate-300 hover:text-white hover:bg-slate-800/60'
}`}
>
<div className="flex items-center gap-2.5">
<Star className="w-4 h-4 text-amber-400" />
<span>Starred</span>
</div>
<span className="text-[11px] text-slate-400 font-mono">
{starredNotifications.length}
</span>
</button>
{/* Sent */}
<button
type="button"
onClick={() => {
setActiveFolder('sent');
setSelectedDraft(null);
}}
className={`w-full flex items-center justify-between px-3 py-2 rounded-xl text-xs font-medium transition cursor-pointer ${
activeFolder === 'sent'
? 'bg-indigo-600/20 text-white border border-indigo-500/30'
: 'text-slate-300 hover:text-white hover:bg-slate-800/60'
}`}
>
<div className="flex items-center gap-2.5">
<Send className="w-4 h-4 text-indigo-400" />
<span>Sent</span>
</div>
<span className="text-[11px] text-slate-400 font-mono">
{sentNotifications.length}
</span>
</button>
{/* Drafts */}
<button
type="button"
onClick={() => {
setActiveFolder('drafts');
setSelectedMessage(null);
}}
className={`w-full flex items-center justify-between px-3 py-2 rounded-xl text-xs font-medium transition cursor-pointer ${
activeFolder === 'drafts'
? 'bg-purple-600/20 text-white border border-purple-500/30'
: 'text-slate-300 hover:text-white hover:bg-slate-800/60'
}`}
>
<div className="flex items-center gap-2.5">
<FileText className="w-4 h-4 text-purple-400" />
<span>Drafts</span>
</div>
{drafts.length > 0 ? (
<span className="text-[10px] font-bold text-purple-300 bg-purple-500/20 border border-purple-500/30 px-2 py-0.5 rounded-full">
{drafts.length}
</span>
) : (
<span className="text-[11px] text-slate-400 font-mono">
0
</span>
)}
</button>
{/* Trash */}
<button
type="button"
onClick={() => {
setActiveFolder('trash');
setSelectedDraft(null);
}}
className={`w-full flex items-center justify-between px-3 py-2 rounded-xl text-xs font-medium transition cursor-pointer ${
activeFolder === 'trash'
? 'bg-rose-600/20 text-white border border-rose-500/30'
: 'text-slate-300 hover:text-white hover:bg-slate-800/60'
}`}
>
<div className="flex items-center gap-2.5">
<Trash2 className="w-4 h-4 text-rose-400" />
<span>Trash</span>
</div>
<span className="text-[11px] text-slate-400 font-mono">
{trashedNotifications.length}
</span>
</button>
</nav>
{/* Quick Filter: Unread Toggle */}
{activeFolder === 'inbox' && (
<div className="pt-2 border-t border-slate-800/80">
<label className="flex items-center gap-2 px-2.5 py-1.5 rounded-xl hover:bg-slate-800/50 cursor-pointer text-xs text-slate-300">
<input
type="checkbox"
checked={showUnreadOnly}
onChange={e => setShowUnreadOnly(e.target.checked)}
className="rounded border-slate-700 bg-slate-900 text-blue-600 focus:ring-blue-500"
/>
<span>Unread only</span>
</label>
</div>
)}
</div>
{/* Sidebar Footer Branding */}
<div className="p-3 rounded-2xl bg-slate-900/40 border border-slate-800/60 space-y-1 text-[11px] text-slate-400">
<div className="flex items-center gap-1.5 text-slate-300 font-semibold">
<span className="w-2 h-2 rounded-full bg-emerald-400" />
<span>In-App Notifications</span>
</div>
<p className="text-[10px] text-slate-400 leading-snug">
Messages are delivered directly to your In-App Mailbox and Notification Bell.
</p>
</div>
</aside>
{/* ======================================================== */}
{/* PANE 2: Message List / Summary Cards */}
{/* ======================================================== */}
<section className="w-80 sm:w-96 shrink-0 border-r border-slate-800/80 bg-[#0d121f]/60 flex flex-col overflow-hidden">
{/* Search Box Header */}
<div className="p-3 border-b border-slate-800/80 bg-[#0d121f]/90 space-y-2">
<div className="relative">
<Search className="w-3.5 h-3.5 absolute left-3 top-1/2 -translate-y-1/2 text-slate-400" />
<input
type="text"
value={searchTerm}
onChange={e => setSearchTerm(e.target.value)}
placeholder="Search messages by subject, sender, or text..."
className="w-full pl-8 pr-7 py-1.5 rounded-xl bg-slate-900 border border-slate-700/80 text-xs text-slate-100 placeholder-slate-400 focus:border-blue-500 focus:outline-none transition shadow-inner"
/>
{searchTerm && (
<button
type="button"
onClick={() => setSearchTerm('')}
className="absolute right-2.5 top-1/2 -translate-y-1/2 text-slate-400 hover:text-white"
>
<X className="w-3.5 h-3.5" />
</button>
)}
</div>
<div className="flex items-center justify-between text-[11px] text-slate-400 px-1">
<span className="capitalize font-semibold text-slate-200">
{activeFolder} ({activeFolder === 'drafts' ? filteredDrafts.length : filteredMessages.length})
</span>
{activeFolder === 'inbox' && unreadCount > 0 && (
<span className="text-blue-400 font-medium">{unreadCount} unread</span>
)}
</div>
</div>
{/* List of Messages or Drafts */}
<div className="flex-1 overflow-y-auto divide-y divide-slate-800/60">
{activeFolder === 'drafts' ? (
filteredDrafts.length === 0 ? (
<div className="py-16 px-6 text-center text-slate-400">
<FileText className="w-8 h-8 mx-auto text-slate-600 mb-2" />
<p className="text-xs font-semibold text-slate-300">No Drafts Saved</p>
<p className="text-[11px] text-slate-400 mt-1">
When you save unfinished messages, they will appear here.
</p>
</div>
) : (
filteredDrafts.map(d => (
<div
key={d.id}
onClick={() => handleEditDraft(d)}
className="p-3.5 hover:bg-slate-800/60 transition cursor-pointer group flex flex-col gap-1.5 relative"
>
<div className="flex items-center justify-between gap-1">
<span className="text-[10px] font-bold text-purple-300 bg-purple-500/15 border border-purple-500/30 px-1.5 py-0.2 rounded">
Draft
</span>
<span className="text-[10px] text-slate-400 font-mono">
{formatDate(d.updatedAt)}
</span>
</div>
<h4 className="text-xs font-semibold text-white group-hover:text-purple-300 transition truncate">
{d.subject || 'Untitled Draft'}
</h4>
<p className="text-[11px] text-slate-300 line-clamp-2 leading-relaxed">
{stripMarkdown(d.message || 'No content written yet.')}
</p>
<div className="flex items-center justify-between pt-1">
<span className="text-[10px] text-slate-400 truncate">
To: {d.recipientEmail || 'No recipient selected'}
</span>
<button
type="button"
onClick={e => {
e.stopPropagation();
deleteDraft(d.id);
}}
className="text-slate-400 hover:text-rose-400 p-1 rounded transition"
title="Delete draft"
>
<Trash2 className="w-3 h-3" />
</button>
</div>
</div>
))
)
) : filteredMessages.length === 0 ? (
<div className="py-16 px-6 text-center text-slate-400">
<MailOpen className="w-8 h-8 mx-auto text-slate-600 mb-2" />
<p className="text-xs font-semibold text-slate-300">No Messages Found</p>
<p className="text-[11px] text-slate-400 mt-1">
{searchTerm
? `No messages matched "${searchTerm}"`
: `No items in ${activeFolder}`}
</p>
</div>
) : (
filteredMessages.map(item => {
const isSelected = selectedMessage?.id === item.id;
const isUnread = Number(item.status) !== NotificationStatus.Read;
const isStarred = starredIds.includes(String(item.id));
return (
<div
key={item.id}
onClick={() => handleSelectMessage(item)}
className={`p-3.5 transition cursor-pointer flex flex-col gap-1 relative ${
isSelected
? 'bg-blue-600/15 border-l-4 border-l-blue-500 text-white shadow-inner'
: 'hover:bg-slate-800/50 border-l-4 border-l-transparent'
}`}
>
{/* Top line: unread dot, platform badge, star, time */}
<div className="flex items-center justify-between gap-1">
<div className="flex items-center gap-1.5 min-w-0">
{isUnread && (
<span
className="w-2 h-2 rounded-full bg-blue-500 shrink-0 shadow-sm shadow-blue-500 animate-pulse"
title="Unread notification"
/>
)}
<span className="text-[11px] font-semibold text-slate-300 truncate">
{item.destination?.includes('From:')
? item.destination.split('From:')[1]?.trim().replace(/[<>]/g, '')
: item.destination || 'PFM System'}
</span>
</div>
<div className="flex items-center gap-1.5 shrink-0">
<span className="text-[9px] font-bold text-blue-300 bg-blue-500/15 border border-blue-500/30 px-1.5 py-0.2 rounded font-mono">
In-App
</span>
<button
type="button"
onClick={e => {
e.stopPropagation();
toggleStar(item.id);
}}
className={`p-0.5 rounded transition ${
isStarred ? 'text-amber-400 hover:text-amber-300' : 'text-slate-400 hover:text-slate-300'
}`}
title={isStarred ? 'Unstar message' : 'Star message'}
>
<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>
</div>
</div>
{/* Subject */}
<h4
className={`text-xs truncate ${
isUnread ? 'font-bold text-white' : 'font-medium text-slate-300'
}`}
>
{item.subject || 'Statutory Platform Notice'}
</h4>
{/* Snippet preview stripped of markdown syntax */}
<p className="text-[11px] text-slate-400 line-clamp-2 leading-relaxed">
{stripMarkdown(item.message || 'No additional details provided.')}
</p>
</div>
);
})
)}
</div>
</section>
{/* ======================================================== */}
{/* PANE 3: Full Message Detail & Interactive Actions */}
{/* ======================================================== */}
<main className="flex-1 bg-[#090d16] flex flex-col overflow-y-auto">
{activeFolder === 'drafts' ? (
<div className="flex-1 flex flex-col items-center justify-center p-8 text-center text-slate-400">
<FileText className="w-12 h-12 text-slate-600 mb-3" />
<h3 className="text-sm font-bold text-white">Drafts Folder</h3>
<p className="text-xs text-slate-400 max-w-md mt-1 mb-4">
Click any draft on the left pane to resume composing and dispatch your notification.
</p>
<button
type="button"
onClick={() => handleOpenCompose()}
className="flex items-center gap-2 px-4 py-2 rounded-xl bg-blue-600 hover:bg-blue-500 text-white text-xs font-semibold shadow-lg shadow-blue-600/30 transition cursor-pointer"
>
<PenSquare className="w-4 h-4" />
<span>Create New Message</span>
</button>
</div>
) : !selectedMessage ? (
<div className="flex-1 flex flex-col items-center justify-center p-8 text-center text-slate-400">
<div className="w-16 h-16 rounded-3xl bg-slate-900 border border-slate-800 flex items-center justify-center text-slate-600 mb-4 shadow-xl">
<Mail className="w-8 h-8" />
</div>
<h3 className="text-sm font-bold text-white">No Message Selected</h3>
<p className="text-xs text-slate-400 max-w-sm mt-1 mb-4">
Select an in-app conversation or email notice from the list to view its contents and attachments.
</p>
<button
type="button"
onClick={() => handleOpenCompose()}
className="flex items-center gap-2 px-4 py-2 rounded-xl bg-slate-900 hover:bg-slate-800 text-slate-200 border border-slate-700 text-xs font-semibold transition cursor-pointer"
>
<PenSquare className="w-3.5 h-3.5 text-blue-400" />
<span>Compose New Message</span>
</button>
</div>
) : (
<div className="flex-1 flex flex-col">
{/* Message Header Toolbar */}
<div className="p-5 border-b border-slate-800/80 bg-[#0d121f]/60 flex items-center justify-between shrink-0">
<div className="flex items-center gap-3">
<button
type="button"
onClick={() => handleReplyMessage(selectedMessage)}
className="flex items-center gap-1.5 px-3 py-1.5 rounded-xl bg-blue-600 hover:bg-blue-500 text-white text-xs font-semibold shadow-md shadow-blue-600/20 transition cursor-pointer"
title="Reply to message"
>
<Reply className="w-3.5 h-3.5" />
<span>Reply</span>
</button>
<button
type="button"
onClick={() => toggleStar(selectedMessage.id)}
className={`flex items-center gap-1.5 px-3 py-1.5 rounded-xl border text-xs font-medium transition cursor-pointer ${
starredIds.includes(String(selectedMessage.id))
? 'bg-amber-500/15 border-amber-500/30 text-amber-300'
: 'bg-slate-900/80 border-slate-700/80 text-slate-300 hover:text-white'
}`}
>
<Star
className={`w-3.5 h-3.5 ${
starredIds.includes(String(selectedMessage.id)) ? 'fill-amber-400' : ''
}`}
/>
<span>{starredIds.includes(String(selectedMessage.id)) ? 'Starred' : 'Star'}</span>
</button>
</div>
<div className="flex items-center gap-2">
{activeFolder === 'trash' ? (
<>
<button
type="button"
onClick={() => restoreMessage(selectedMessage.id)}
className="flex items-center gap-1.5 px-3 py-1.5 rounded-xl bg-slate-900/80 hover:bg-slate-800 border border-slate-700 text-xs text-slate-200 transition cursor-pointer"
title="Restore message to Inbox"
>
<RotateCcw className="w-3.5 h-3.5 text-emerald-400" />
<span>Restore</span>
</button>
<button
type="button"
onClick={() => permanentDelete(selectedMessage.id)}
className="flex items-center gap-1.5 px-3 py-1.5 rounded-xl bg-rose-950/40 hover:bg-rose-900/40 border border-rose-800/60 text-xs text-rose-300 transition cursor-pointer"
title="Permanently erase message"
>
<Trash2 className="w-3.5 h-3.5 text-rose-400" />
<span>Delete Forever</span>
</button>
</>
) : (
<button
type="button"
onClick={() => {
deleteMessage(selectedMessage.id);
setSelectedMessage(null);
}}
className="p-2 rounded-xl text-slate-400 hover:text-rose-400 hover:bg-rose-950/20 border border-transparent hover:border-rose-900/40 transition cursor-pointer"
title="Move message to Trash"
>
<Trash2 className="w-4 h-4" />
</button>
)}
</div>
</div>
{/* Message Details & Body */}
<div className="p-6 space-y-6 flex-1 overflow-y-auto max-w-4xl">
{/* Subject Header */}
<div>
<h2 className="text-xl font-bold text-white tracking-tight leading-tight">
{selectedMessage.subject || 'In-App Notification'}
</h2>
<div className="flex flex-wrap items-center gap-2 mt-2">
<span className="text-[10px] font-semibold text-blue-300 bg-blue-500/15 border border-blue-500/30 px-2 py-0.5 rounded-md flex items-center gap-1">
<Smartphone className="w-3 h-3 text-blue-400" />
Channel: In-App
</span>
<span className="text-[10px] font-mono text-slate-400 bg-slate-900 px-2 py-0.5 rounded-md border border-slate-800">
ID: #{selectedMessage.id}
</span>
<span className="text-[10px] text-slate-400">
Received: {formatDate(selectedMessage.createdAt)}
</span>
</div>
</div>
{/* Sender & Recipient Card */}
<div className="p-4 rounded-2xl bg-slate-900/60 border border-slate-800 flex items-start justify-between gap-4">
<div className="flex items-start gap-3">
<div className="w-10 h-10 rounded-2xl bg-gradient-to-br from-blue-600 to-indigo-600 flex items-center justify-center font-bold text-xs text-white shadow-md shadow-blue-500/20 shrink-0">
{selectedMessage.destination?.includes('@')
? selectedMessage.destination.split('@')[0].slice(0, 2).toUpperCase()
: 'PF'}
</div>
<div>
<div className="flex items-center gap-2">
<p className="text-xs font-bold text-white">
{selectedMessage.destination?.includes('From:')
? selectedMessage.destination.split('From:')[1]?.trim().replace(/[<>]/g, '')
: selectedMessage.destination || 'PFM Automated Dispatcher'}
</p>
<span className="text-[10px] text-slate-400 font-mono">
(In-App Notification)
</span>
</div>
<p className="text-[11px] text-slate-400 mt-0.5">
To:{' '}
<strong className="text-slate-300">
{selectedMessage.destination?.includes('To:')
? selectedMessage.destination.split('To:')[1]?.split('|')[0]?.trim()
: currentUserEmail || 'You'}
</strong>
</p>
</div>
</div>
<span className="text-[10px] text-slate-400 font-mono shrink-0 flex items-center gap-1">
<Clock className="w-3 h-3" />
{formatDate(selectedMessage.createdAt)}
</span>
</div>
{/* Fully-Rendered Markdown & HTML Message Body with Branding */}
<MarkdownViewer
content={selectedMessage.message || ''}
subject={selectedMessage.subject || ''}
showBrandingHeader={true}
/>
{/* Inline Quick Reply Section */}
<div className="pt-6 border-t border-slate-800 space-y-3">
<div className="flex items-center justify-between">
<h4 className="text-xs font-bold text-white flex items-center gap-1.5">
<Reply className="w-3.5 h-3.5 text-blue-400" />
<span>Quick Reply</span>
</h4>
<span className="text-[10px] text-slate-400">
Send a quick response below or open the rich text composer
</span>
</div>
{inlineReplySuccess && (
<div className="p-3 rounded-xl bg-emerald-500/10 border border-emerald-500/30 text-emerald-300 text-xs flex items-center gap-2">
<CheckCircle2 className="w-4 h-4 text-emerald-400" />
<span>Reply dispatched successfully!</span>
</div>
)}
<textarea
rows={4}
value={inlineReplyText}
onChange={e => setInlineReplyText(e.target.value)}
placeholder="Type your reply..."
className="w-full p-3.5 rounded-2xl bg-slate-900/90 border border-slate-700/80 hover:border-slate-600 focus:border-blue-500 text-slate-100 placeholder-slate-400 text-xs focus:outline-none transition shadow-inner resize-y leading-relaxed"
/>
<div className="flex items-center justify-between">
<button
type="button"
onClick={() => handleReplyMessage(selectedMessage)}
className="text-xs text-blue-400 hover:text-blue-300 font-medium transition cursor-pointer"
>
Open rich compose editor with live preview
</button>
<button
type="button"
disabled={isSending || !inlineReplyText.trim()}
onClick={handleSendInlineReply}
className="flex items-center gap-2 px-5 py-2 rounded-xl bg-blue-600 hover:bg-blue-500 text-white text-xs font-semibold shadow-md shadow-blue-600/30 transition disabled:opacity-40 cursor-pointer"
>
{isSending ? (
<div className="w-3.5 h-3.5 border-2 border-white border-t-transparent rounded-full animate-spin" />
) : (
<Send className="w-3.5 h-3.5" />
)}
<span>Send Reply</span>
</button>
</div>
</div>
</div>
</div>
)}
</main>
</div>
{/* Compose Message Modal */}
<ComposeMessageModal
isOpen={isComposeOpen}
onClose={() => setIsComposeOpen(false)}
initialRecipient={composeInitialRecipient}
initialSubject={composeInitialSubject}
initialMessage={composeInitialMessage}
initialPlatform={composeInitialPlatform}
draftToEdit={selectedDraft}
/>
{/* Self-Reply Prevention Modal */}
{isSelfReplyModalOpen && (
<div className="fixed inset-0 z-50 flex items-center justify-center p-4 bg-slate-950/80 backdrop-blur-sm animate-in fade-in duration-150">
<div className="w-full max-w-md bg-[#0d1322] border border-rose-500/30 rounded-2xl shadow-2xl overflow-hidden p-6 space-y-4">
<div className="flex items-start justify-between gap-3">
<div className="flex items-center gap-3">
<div className="w-10 h-10 rounded-xl bg-rose-500/15 border border-rose-500/30 flex items-center justify-center text-rose-400 shrink-0">
<ShieldAlert className="w-5 h-5" />
</div>
<div>
<h3 className="text-sm font-bold text-white">Action Not Allowed</h3>
<p className="text-[11px] text-rose-300 font-medium">Self-Reply Restricted</p>
</div>
</div>
<button
type="button"
onClick={() => setIsSelfReplyModalOpen(false)}
className="p-1 rounded-lg text-slate-400 hover:text-white hover:bg-slate-800 transition cursor-pointer"
>
<X className="w-4 h-4" />
</button>
</div>
<p className="text-xs text-slate-300 leading-relaxed">
You cannot reply to a notification that originated from your own account. Please compose a new message and select another recipient from the directory if you wish to follow up with a colleague.
</p>
<div className="pt-2 border-t border-slate-800 flex justify-end gap-2">
<button
type="button"
onClick={() => setIsSelfReplyModalOpen(false)}
className="px-4 py-2 rounded-xl bg-blue-600 hover:bg-blue-500 text-white text-xs font-semibold shadow-md shadow-blue-600/25 transition cursor-pointer"
>
Understood
</button>
</div>
</div>
</div>
)}
</div>
);
};
@@ -43,6 +43,7 @@ import { useToast } from '../components/ui/Toast';
import { ExportButton } from '../components/ui/ExportButton'; import { ExportButton } from '../components/ui/ExportButton';
import { ExportColumn } from '../utils/exportUtils'; import { ExportColumn } from '../utils/exportUtils';
import { useTableSort, SortableTh } from '../utils/tableSort'; import { useTableSort, SortableTh } from '../utils/tableSort';
import { useRbac } from '../auth/rbac';
import { ContractsManagement } from '../components/monitoring/ContractsManagement'; import { ContractsManagement } from '../components/monitoring/ContractsManagement';
import { EvidenceReviewQueue } from '../components/monitoring/EvidenceReviewQueue'; import { EvidenceReviewQueue } from '../components/monitoring/EvidenceReviewQueue';
import { RiskHeatmapMatrix } from '../components/ui/RiskHeatmapMatrix'; import { RiskHeatmapMatrix } from '../components/ui/RiskHeatmapMatrix';
@@ -100,22 +101,22 @@ export const WORKFLOW_STAGES = [
export const MonitoringPage: React.FC = () => { export const MonitoringPage: React.FC = () => {
const queryClient = useQueryClient(); const queryClient = useQueryClient();
const { success, error: toastError } = useToast(); const { success, error: toastError } = useToast();
const { canAdd, canExport } = useRbac();
const [searchParams, setSearchParams] = useSearchParams(); const [searchParams, setSearchParams] = useSearchParams();
const tabParam = searchParams.get('tab'); const tabParam = searchParams.get('tab');
const [activeTab, setActiveTab] = useState<'contracts' | 'evidence' | 'indicators' | 'risks'>( const [activeTab, setActiveTab] = useState<'contracts' | 'evidence' | 'risks'>(
tabParam === 'evidence' || tabParam === 'indicators' || tabParam === 'risks' ? tabParam : 'contracts' tabParam === 'evidence' || tabParam === 'risks' ? tabParam : 'contracts'
); );
const [isCreateContractModalOpen, setIsCreateContractModalOpen] = useState(false);
useEffect(() => { useEffect(() => {
const t = searchParams.get('tab'); const t = searchParams.get('tab');
if (t === 'evidence' || t === 'indicators' || t === 'risks' || t === 'contracts') { if (t === 'evidence' || t === 'risks' || t === 'contracts') {
setActiveTab(t); setActiveTab(t);
} }
}, [searchParams]); }, [searchParams]);
const handleTabChange = (tab: 'contracts' | 'evidence' | 'indicators' | 'risks') => { const handleTabChange = (tab: 'contracts' | 'evidence' | 'risks') => {
setActiveTab(tab); setActiveTab(tab);
setSearchParams((prev) => { setSearchParams((prev) => {
const next = new URLSearchParams(prev); const next = new URLSearchParams(prev);
@@ -175,7 +176,7 @@ export const MonitoringPage: React.FC = () => {
// Forms // Forms
const indicatorForm = useForm<IndicatorFormValues>({ const indicatorForm = useForm<IndicatorFormValues>({
resolver: zodResolver(indicatorSchema) as any, resolver: zodResolver(indicatorSchema),
defaultValues: { defaultValues: {
projectId: projects[0]?.id ? Number(projects[0].id) : 401, projectId: projects[0]?.id ? Number(projects[0].id) : 401,
name: '', name: '',
@@ -186,7 +187,7 @@ export const MonitoringPage: React.FC = () => {
}); });
const riskForm = useForm<RiskFormValues>({ const riskForm = useForm<RiskFormValues>({
resolver: zodResolver(riskSchema) as any, resolver: zodResolver(riskSchema),
defaultValues: { defaultValues: {
projectId: projects[0]?.id ? Number(projects[0].id) : 401, projectId: projects[0]?.id ? Number(projects[0].id) : 401,
name: '', name: '',
@@ -435,25 +436,8 @@ export const MonitoringPage: React.FC = () => {
</div> </div>
<div className="flex items-center gap-2 relative z-10 self-start md:self-center flex-wrap"> <div className="flex items-center gap-2 relative z-10 self-start md:self-center flex-wrap">
{activeTab === 'contracts' && (
<button {activeTab === 'risks' && canAdd('evidence_risks') && (
onClick={() => setIsCreateContractModalOpen(true)}
className="inline-flex items-center gap-1.5 px-4 py-2.5 text-xs font-semibold text-white bg-gradient-to-r from-indigo-600 to-purple-600 hover:from-indigo-500 hover:to-purple-500 rounded-xl shadow-lg shadow-indigo-600/25 transition cursor-pointer"
>
<Plus className="w-4 h-4" />
<span>Create Contract &amp; Upload PDF</span>
</button>
)}
{activeTab === 'indicators' && (
<button
onClick={() => setIsIndicatorModalOpen(true)}
className="inline-flex items-center gap-1.5 px-4 py-2.5 text-xs font-semibold text-white bg-gradient-to-r from-indigo-600 to-purple-600 hover:from-indigo-500 hover:to-purple-500 rounded-xl shadow-lg shadow-indigo-600/25 transition cursor-pointer"
>
<Plus className="w-4 h-4" />
<span>Define Indicator</span>
</button>
)}
{activeTab === 'risks' && (
<button <button
onClick={openCreateRisk} onClick={openCreateRisk}
className="inline-flex items-center gap-1.5 px-4 py-2.5 text-xs font-semibold text-white bg-gradient-to-r from-indigo-600 to-purple-600 hover:from-indigo-500 hover:to-purple-500 rounded-xl shadow-lg shadow-indigo-600/25 transition cursor-pointer" className="inline-flex items-center gap-1.5 px-4 py-2.5 text-xs font-semibold text-white bg-gradient-to-r from-indigo-600 to-purple-600 hover:from-indigo-500 hover:to-purple-500 rounded-xl shadow-lg shadow-indigo-600/25 transition cursor-pointer"
@@ -501,23 +485,7 @@ export const MonitoringPage: React.FC = () => {
placement="top" placement="top"
/> />
</button> </button>
<button
onClick={() => handleTabChange('indicators')}
className={`px-4 py-2 text-xs font-medium rounded-xl transition flex items-center gap-2 cursor-pointer ${
activeTab === 'indicators'
? 'bg-blue-600 text-white shadow-lg shadow-blue-600/30'
: 'text-slate-400 hover:text-slate-200 hover:bg-slate-800/50'
}`}
>
<TrendingUp className="w-4 h-4" />
<span>Indicators & Actuals ({indicators.length})</span>
<HelpTooltip
title="Quantitative Indicators"
content="Baseline vs. target metric progression with automated green/amber/red performance thresholds."
size="sm"
placement="top"
/>
</button>
<button <button
onClick={() => handleTabChange('risks')} onClick={() => handleTabChange('risks')}
className={`px-4 py-2 text-xs font-medium rounded-xl transition flex items-center gap-2 cursor-pointer ${ className={`px-4 py-2 text-xs font-medium rounded-xl transition flex items-center gap-2 cursor-pointer ${
@@ -539,10 +507,7 @@ export const MonitoringPage: React.FC = () => {
{/* Tab 0: Contracts & Legal Documents with PDF Upload */} {/* Tab 0: Contracts & Legal Documents with PDF Upload */}
{activeTab === 'contracts' && ( {activeTab === 'contracts' && (
<ContractsManagement <ContractsManagement />
isCreateModalOpenExternal={isCreateContractModalOpen}
onCloseCreateModalExternal={() => setIsCreateContractModalOpen(false)}
/>
)} )}
{/* Tab 1: Project & Milestone Evidence Review Hub */} {/* Tab 1: Project & Milestone Evidence Review Hub */}
@@ -555,216 +520,6 @@ export const MonitoringPage: React.FC = () => {
/> />
)} )}
{/* Tab 2: Indicators Table (M08) */}
{activeTab === 'indicators' && (
<div className="space-y-4">
{/* Indicators Search and Filter Toolbar */}
<div className="flex flex-col lg:flex-row lg:items-center justify-between gap-3 bg-[#0d121f]/90 p-4 rounded-2xl border border-slate-800/80 backdrop-blur-sm">
<div className="flex flex-col sm:flex-row items-stretch sm:items-center gap-3 flex-1 flex-wrap">
{/* Search */}
<div className="relative flex-1 min-w-[200px] max-w-md">
<Search className="w-4 h-4 text-slate-500 absolute left-3 top-3" />
<input
type="text"
placeholder="Search indicators, statement, project ID..."
value={indicatorSearchTerm}
onChange={(e) => setIndicatorSearchTerm(e.target.value)}
className="w-full pl-9 pr-4 py-2.5 text-xs bg-[#090d16] border border-slate-700/80 rounded-xl focus:outline-none focus:ring-1 focus:ring-blue-500 text-slate-200 placeholder:text-slate-500 transition shadow-xs"
/>
</div>
{/* Project Filter */}
<div className="flex items-center gap-2">
<label className="text-xs text-slate-400 whitespace-nowrap font-medium">Project:</label>
<select
value={indicatorProjectFilter}
onChange={(e) => setIndicatorProjectFilter(e.target.value === 'all' ? 'all' : Number(e.target.value))}
className="px-3 py-2 text-xs bg-[#090d16] border border-slate-700/80 rounded-xl text-slate-200 focus:outline-none focus:ring-1 focus:ring-blue-500 [&>option]:bg-slate-900"
>
<option value="all">All Projects ({projects.length})</option>
{projects.map((p) => (
<option key={p.id} value={p.id}>
{p.name} (#{p.id})
</option>
))}
</select>
</div>
{/* Status Filter */}
<div className="flex items-center gap-2">
<label className="text-xs text-slate-400 whitespace-nowrap flex items-center gap-1 font-medium">
<Filter className="w-3.5 h-3.5 text-slate-400" />
Status:
</label>
<select
value={indicatorStatusFilter}
onChange={(e) => setIndicatorStatusFilter(e.target.value)}
className="px-3 py-2 text-xs bg-[#090d16] border border-slate-700/80 rounded-xl text-slate-200 focus:outline-none focus:ring-1 focus:ring-blue-500 [&>option]:bg-slate-900"
>
<option value="all">All Statuses</option>
<option value="green">≥90% Target Achieved</option>
<option value="amber">70–89% On Track</option>
<option value="red">&lt;70% Underperforming</option>
<option value="pending">Pending Actuals</option>
</select>
</div>
{/* Unit Filter */}
<div className="flex items-center gap-2">
<label className="text-xs text-slate-400 whitespace-nowrap font-medium">Unit:</label>
<select
value={indicatorUnitFilter}
onChange={(e) => setIndicatorUnitFilter(e.target.value === 'all' ? 'all' : Number(e.target.value))}
className="px-3 py-2 text-xs bg-[#090d16] border border-slate-700/80 rounded-xl text-slate-200 focus:outline-none focus:ring-1 focus:ring-blue-500 [&>option]:bg-slate-900"
>
<option value="all">All Units</option>
{UNIT_OF_MEASURE_OPTIONS.map((opt) => (
<option key={opt.value} value={opt.value}>
{opt.label}
</option>
))}
</select>
</div>
{(indicatorSearchTerm || indicatorProjectFilter !== 'all' || indicatorStatusFilter !== 'all' || indicatorUnitFilter !== 'all') && (
<button
type="button"
onClick={() => {
setIndicatorSearchTerm('');
setIndicatorProjectFilter('all');
setIndicatorStatusFilter('all');
setIndicatorUnitFilter('all');
}}
className="inline-flex items-center gap-1 text-[11px] font-semibold text-slate-400 hover:text-white px-2.5 py-2 bg-slate-800 hover:bg-slate-700 rounded-xl transition cursor-pointer"
>
<RotateCcw className="w-3 h-3" />
<span>Reset</span>
</button>
)}
</div>
{/* Action Buttons: Refresh & Export */}
<div className="flex items-center gap-2 self-end lg:self-center">
<button
type="button"
onClick={() => {
refetchIndicators();
success('Refreshed', 'Latest performance metrics loaded from API.');
}}
disabled={isFetchingIndicators}
className="inline-flex items-center justify-center gap-1.5 px-3.5 py-2 text-xs font-semibold text-slate-300 bg-[#0d121f] hover:bg-slate-800 border border-slate-700/80 rounded-xl transition cursor-pointer shadow-xs disabled:opacity-50"
title="Refresh indicators from API"
>
<RefreshCw className={`w-3.5 h-3.5 text-blue-400 ${isFetchingIndicators ? 'animate-spin' : ''}`} />
<span>Refresh</span>
</button>
<ExportButton
title="M&E Performance Indicators"
subtitle="Baseline and target metrics tracking for monitoring and evaluation"
filename={`pfm-indicators-${new Date().toISOString().split('T')[0]}`}
columns={indicatorExportColumns}
data={filteredIndicators}
/>
</div>
</div>
{/* Indicators Table Card */}
<div className="bg-[#0f1523]/80 backdrop-blur-md rounded-2xl border border-slate-800/80 shadow-xl overflow-hidden">
<div className="p-4 border-b border-slate-800/80 flex items-center justify-between">
<h3 className="text-sm font-semibold text-white flex items-center gap-2">
<TrendingUp className="w-4 h-4 text-blue-400" />
Performance Indicators &amp; Actuals Ledger
</h3>
<span className="text-xs text-slate-400 font-mono">
Showing {filteredIndicators.length} of {indicators.length} indicators
</span>
</div>
<div className="overflow-x-auto">
<table className="w-full text-left text-xs border-collapse">
<thead>
<tr className="bg-slate-900/60 border-b border-slate-800/80 text-slate-400 uppercase tracking-wider font-semibold">
<SortableTh column="name" currentColumn={indSortCol} direction={indSortDir} onSort={handleIndSort} className="py-3.5 px-5">
Indicator Name
</SortableTh>
<SortableTh column="projectId" currentColumn={indSortCol} direction={indSortDir} onSort={handleIndSort} className="py-3.5 px-4">
Project ID
</SortableTh>
<SortableTh column="unitOfMeasure" currentColumn={indSortCol} direction={indSortDir} onSort={handleIndSort} className="py-3.5 px-4">
Unit of Measure
</SortableTh>
<SortableTh column="baselineAmount" currentColumn={indSortCol} direction={indSortDir} onSort={handleIndSort} align="right" className="py-3.5 px-4 text-right">
Baseline
</SortableTh>
<SortableTh column="targetAmount" currentColumn={indSortCol} direction={indSortDir} onSort={handleIndSort} align="right" className="py-3.5 px-4 text-right">
Target
</SortableTh>
<SortableTh column="actualAmount" currentColumn={indSortCol} direction={indSortDir} onSort={handleIndSort} align="right" className="py-3.5 px-4 text-right">
Actual Recorded
</SortableTh>
<SortableTh column="status" currentColumn={indSortCol} direction={indSortDir} onSort={handleIndSort} className="py-3.5 px-4">
Status
</SortableTh>
<th className="py-3.5 px-5 text-right font-medium">Action</th>
</tr>
</thead>
<tbody className="divide-y divide-slate-800/50">
{loadingIndicators ? (
<tr>
<td colSpan={8} className="py-12 text-center text-slate-500">
<RefreshCw className="w-6 h-6 animate-spin mx-auto mb-2 text-blue-400" />
Loading project indicators from API...
</td>
</tr>
) : sortedIndicators.length === 0 ? (
<tr>
<td colSpan={8} className="py-12 text-center text-slate-500">
<TrendingUp className="w-8 h-8 text-slate-600 mx-auto mb-2" />
{indicatorSearchTerm || indicatorProjectFilter !== 'all' || indicatorStatusFilter !== 'all' || indicatorUnitFilter !== 'all'
? 'No indicators match your filter criteria.'
: 'No performance indicators defined yet. Click "Define Indicator" to begin.'}
</td>
</tr>
) : (
sortedIndicators.map((ind) => {
const statusObj = getIndicatorStatus(ind.actualAmount, ind.targetAmount);
return (
<tr key={ind.id} className="hover:bg-slate-800/40 transition-colors">
<td className="py-3.5 px-5 font-semibold text-white">{ind.name}</td>
<td className="py-3.5 px-4 text-slate-400 font-mono">Project #{ind.projectId}</td>
<td className="py-3.5 px-4 text-slate-300">{getUnitOfMeasureLabel(ind.unitOfMeasure)}</td>
<td className="py-3.5 px-4 text-right font-mono text-slate-400">{ind.baselineAmount}</td>
<td className="py-3.5 px-4 text-right font-mono font-bold text-white">{ind.targetAmount}</td>
<td className="py-3.5 px-4 text-right font-mono font-bold text-emerald-400 text-xs">
{ind.actualAmount}
</td>
<td className="py-3.5 px-4">
<span className={`inline-flex items-center px-2 py-0.5 rounded-full text-[10px] font-semibold border ${statusObj.badge}`}>
{statusObj.label}
</span>
</td>
<td className="py-3.5 px-5 text-right">
<button
onClick={() => {
setSelectedIndicatorForActual(ind);
setActualInput(ind.actualAmount);
}}
className="inline-flex items-center gap-1 text-xs font-semibold text-slate-300 hover:text-white bg-slate-800/80 hover:bg-slate-700 px-3 py-1.5 rounded-lg border border-slate-700/60 transition cursor-pointer"
>
Record Actual
</button>
</td>
</tr>
);
})
)}
</tbody>
</table>
</div>
</div>
</div>
)}
{/* Tab 3: Risk Register & Heatmap (M09) */} {/* Tab 3: Risk Register & Heatmap (M09) */}
{activeTab === 'risks' && ( {activeTab === 'risks' && (
<div className="space-y-6"> <div className="space-y-6">
@@ -861,6 +616,7 @@ export const MonitoringPage: React.FC = () => {
</button> </button>
<ExportButton <ExportButton
claim="evidence_risks"
title="Project Risk Register & Heatmap" title="Project Risk Register & Heatmap"
subtitle="Itemized audit log of identified risks and mitigation controls" subtitle="Itemized audit log of identified risks and mitigation controls"
filename={`pfm-risks-${new Date().toISOString().split('T')[0]}`} filename={`pfm-risks-${new Date().toISOString().split('T')[0]}`}
@@ -979,7 +735,7 @@ export const MonitoringPage: React.FC = () => {
subtitle="Logical Framework & Theory of Change metric" subtitle="Logical Framework & Theory of Change metric"
helpText="Quantitative or qualitative KPI tracked across reporting periods against target thresholds to assess project efficacy." helpText="Quantitative or qualitative KPI tracked across reporting periods against target thresholds to assess project efficacy."
> >
<form onSubmit={indicatorForm.handleSubmit((data) => indicatorMutation.mutate(data as any))} className="space-y-4"> <form onSubmit={indicatorForm.handleSubmit((data) => indicatorMutation.mutate(data as CreateIndicatorRequest))} className="space-y-4">
<div> <div>
<div className="flex items-center gap-1 mb-1.5"> <div className="flex items-center gap-1 mb-1.5">
<label className="block text-xs font-semibold text-slate-300">Project *</label> <label className="block text-xs font-semibold text-slate-300">Project *</label>
@@ -1166,7 +922,7 @@ export const MonitoringPage: React.FC = () => {
subtitle={editingRisk ? `Updating risk item #${editingRisk.id}` : 'Identify and score project delivery risks'} subtitle={editingRisk ? `Updating risk item #${editingRisk.id}` : 'Identify and score project delivery risks'}
helpText="Risks are scored by Likelihood (1-5) x Impact (1-5) to plot on the 5x5 Heatmap. High severity risks flag alerts for portfolio governance." helpText="Risks are scored by Likelihood (1-5) x Impact (1-5) to plot on the 5x5 Heatmap. High severity risks flag alerts for portfolio governance."
> >
<form onSubmit={riskForm.handleSubmit((data) => riskMutation.mutate(data as any))} className="space-y-4"> <form onSubmit={riskForm.handleSubmit((data) => riskMutation.mutate(data))} className="space-y-4">
{!editingRisk && ( {!editingRisk && (
<div> <div>
<div className="flex items-center gap-1 mb-1.5"> <div className="flex items-center gap-1 mb-1.5">
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
@@ -19,6 +19,7 @@ import {
ShieldCheck, ShieldCheck,
Building, Building,
Send, Send,
Lock,
} from 'lucide-react'; } from 'lucide-react';
import { import {
getProjects, getProjects,
@@ -54,7 +55,7 @@ type SiteVisitFormValues = z.infer<typeof siteVisitSchema>;
export const SiteVisitsPage: React.FC = () => { export const SiteVisitsPage: React.FC = () => {
const queryClient = useQueryClient(); const queryClient = useQueryClient();
const { success, error: toastError } = useToast(); const { success, error: toastError } = useToast();
const { canAdd, canApprove, canExport } = useRbac(); const { canAdd, canApprove, canExport, canApproveRecord, isMaker } = useRbac();
const [selectedProjectId, setSelectedProjectId] = useState<string>('all'); const [selectedProjectId, setSelectedProjectId] = useState<string>('all');
const [searchTerm, setSearchTerm] = useState(''); const [searchTerm, setSearchTerm] = useState('');
@@ -114,12 +115,15 @@ export const SiteVisitsPage: React.FC = () => {
visitForm.reset(); visitForm.reset();
success('Site Visit Logged', 'Field inspection recorded with inspector findings.'); success('Site Visit Logged', 'Field inspection recorded with inspector findings.');
}, },
onError: (err: any) => toastError('Logging Failed', err), onError: (err: unknown) => toastError('Logging Failed', err),
}); });
const updateVisitMutation = useMutation({ const updateVisitMutation = useMutation({
mutationFn: (vals: SiteVisitFormValues) => { mutationFn: (vals: SiteVisitFormValues) => {
if (!editingVisit) throw new Error('No visit selected'); if (!editingVisit) throw new Error('No visit selected');
if (Number(editingVisit.status) === ApprovalStatus.Approved) {
throw new Error('Approved site visits are verified and immutable. No modifications are permitted.');
}
return updateSiteVisit({ return updateSiteVisit({
siteVisitId: editingVisit.id, siteVisitId: editingVisit.id,
inspectorName: vals.inspectorName, inspectorName: vals.inspectorName,
@@ -135,7 +139,7 @@ export const SiteVisitsPage: React.FC = () => {
visitForm.reset(); visitForm.reset();
success('Site Visit Updated', 'Inspection report and verification status updated.'); success('Site Visit Updated', 'Inspection report and verification status updated.');
}, },
onError: (err: any) => toastError('Update Failed', err), onError: (err: unknown) => toastError('Update Failed', err),
}); });
// Filtered & Sorted Visits // Filtered & Sorted Visits
@@ -165,6 +169,10 @@ export const SiteVisitsPage: React.FC = () => {
]; ];
const handleOpenEdit = async (v: SiteVisit) => { const handleOpenEdit = async (v: SiteVisit) => {
if (Number(v.status) === ApprovalStatus.Approved) {
toastError('Record Immutable', 'Approved site visits are verified and cannot be edited.');
return;
}
try { try {
const fresh = await getSiteVisit(v.id); const fresh = await getSiteVisit(v.id);
setEditingVisit(fresh); setEditingVisit(fresh);
@@ -446,13 +454,22 @@ export const SiteVisitsPage: React.FC = () => {
> >
View & Feedback View & Feedback
</button> </button>
<button {Number(v.status) === ApprovalStatus.Approved ? (
onClick={() => handleOpenEdit(v)} <span
className="p-1 text-slate-400 hover:text-white hover:bg-slate-800 rounded-lg transition cursor-pointer" className="p-1 text-emerald-400 cursor-default"
title="Edit site visit" title="Approved site visits are verified and immutable."
> >
<Edit2 className="w-3.5 h-3.5" /> <Lock className="w-3.5 h-3.5" />
</button> </span>
) : (
<button
onClick={() => handleOpenEdit(v)}
className="p-1 text-slate-400 hover:text-white hover:bg-slate-800 rounded-lg transition cursor-pointer"
title="Edit site visit"
>
<Edit2 className="w-3.5 h-3.5" />
</button>
)}
</td> </td>
</tr> </tr>
)) ))
@@ -505,21 +522,40 @@ export const SiteVisitsPage: React.FC = () => {
{editingVisit && ( {editingVisit && (
<div> <div>
<label className="block text-xs font-semibold text-slate-300 mb-1">Verification Status</label> <label className="block text-xs font-semibold text-slate-300 mb-1">Verification Status</label>
<select {Number(editingVisit.status) === ApprovalStatus.Approved ? (
{...visitForm.register('status')} <div
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-emerald-500" className="w-full bg-slate-900 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"
> title="Approved site visits are immutable."
{APPROVAL_STATUS_OPTIONS.map((opt) => ( >
<option <Lock className="w-3.5 h-3.5 text-emerald-400" />
key={opt.value} <span>Approved &amp; Verified</span>
value={opt.value} </div>
disabled={opt.value === ApprovalStatus.Approved && !canApprove('site_visits')} ) : (
> <select
{opt.label} {...visitForm.register('status')}
{opt.value === ApprovalStatus.Approved && !canApprove('site_visits') ? ' (Requires Update Permission)' : ''} 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-emerald-500"
</option> >
))} {(() => {
</select> const canApproveVisit = canApproveRecord('site_visits', editingVisit?.createdBy);
const isSelf = isMaker(editingVisit?.createdBy);
return APPROVAL_STATUS_OPTIONS.map((opt) => {
const isApprovedOpt = opt.value === ApprovalStatus.Approved;
const isOptDisabled = isApprovedOpt && !canApproveVisit;
let reason = '';
if (isApprovedOpt && !canApprove('site_visits')) {
reason = ' (Requires Update Permission)';
} else if (isApprovedOpt && isSelf) {
reason = ' (Maker-Checker: Cannot approve own record)';
}
return (
<option key={opt.value} value={opt.value} disabled={isOptDisabled}>
{opt.label}{reason}
</option>
);
});
})()}
</select>
)}
</div> </div>
)} )}
@@ -68,14 +68,12 @@ const TenantOrganisationsSubList: React.FC<{ tenantId: number | string; tenantNa
} = useQuery<TenantOrganisation[]>({ } = useQuery<TenantOrganisation[]>({
queryKey: ['tenant-organisations', tenantId], queryKey: ['tenant-organisations', tenantId],
queryFn: () => getTenantOrganisations(tenantId), queryFn: () => getTenantOrganisations(tenantId),
staleTime: 30000,
}); });
// Fetch tenant users directory to resolve linked user email per organisation // Fetch tenant users directory to resolve linked user email per organisation
const { data: tenantUsers = [] } = useQuery<TenantUser[]>({ const { data: tenantUsers = [] } = useQuery<TenantUser[]>({
queryKey: ['tenant-users', tenantId], queryKey: ['tenant-users', tenantId],
queryFn: () => getTenantUsers(tenantId), queryFn: () => getTenantUsers(tenantId),
staleTime: 5 * 60 * 1000,
}); });
React.useEffect(() => { React.useEffect(() => {
@@ -261,7 +259,6 @@ const TenantUsersSubList: React.FC<{
const { data: tenantUsers = [], isLoading, refetch } = useQuery<TenantUser[]>({ const { data: tenantUsers = [], isLoading, refetch } = useQuery<TenantUser[]>({
queryKey: ['tenant-users', tenantId], queryKey: ['tenant-users', tenantId],
queryFn: () => getTenantUsers(tenantId), queryFn: () => getTenantUsers(tenantId),
staleTime: 30000,
}); });
const { canAdd } = useRbac(); const { canAdd } = useRbac();
@@ -309,8 +306,8 @@ const TenantUsersSubList: React.FC<{
</div> </div>
</div> </div>
<div className="overflow-x-auto rounded-lg border border-slate-800/80 bg-slate-900/40"> <div className="overflow-x-auto rounded-lg border border-slate-800/80 bg-slate-900/40 min-w-0 w-full">
<table className="w-full text-left text-xs border-collapse"> <table className="w-full text-left text-xs border-collapse min-w-[700px]">
<thead> <thead>
<tr className="bg-slate-900/80 border-b border-slate-800/80 text-slate-400 text-[11px] uppercase tracking-wider font-semibold"> <tr className="bg-slate-900/80 border-b border-slate-800/80 text-slate-400 text-[11px] uppercase tracking-wider font-semibold">
<th className="py-2 px-3">Email Address</th> <th className="py-2 px-3">Email Address</th>
@@ -351,13 +348,14 @@ const TenantUsersSubList: React.FC<{
<td className="py-2 px-3 text-right"> <td className="py-2 px-3 text-right">
<button <button
onClick={() => { onClick={() => {
const userToManage: User = u || { const userToManage: User = {
id: tu.userId, ...(u || {}),
email: null, id: u?.id || tu.userId,
role: null, status: u?.status ?? tu.status,
idNumber: null, email: u?.email || null,
status: tu.status, role: u?.role || null,
lastLoginAt: null, idNumber: u?.idNumber || null,
lastLoginAt: u?.lastLoginAt || null,
}; };
onManageUser(userToManage); onManageUser(userToManage);
}} }}
@@ -459,7 +457,6 @@ export const TenantManagementPage: React.FC = () => {
} = useQuery<Tenant[]>({ } = useQuery<Tenant[]>({
queryKey: ['tenants-list'], queryKey: ['tenants-list'],
queryFn: () => getTenants(), queryFn: () => getTenants(),
staleTime: 30000,
}); });
// Forms // Forms
@@ -782,9 +779,9 @@ export const TenantManagementPage: React.FC = () => {
</div> </div>
{/* Tenants Table */} {/* Tenants Table */}
<div className="bg-[#0f1523]/80 rounded-2xl border border-slate-800/80 overflow-hidden shadow-xl backdrop-blur-md"> <div className="bg-[#0f1523]/80 rounded-2xl border border-slate-800/80 overflow-hidden shadow-xl backdrop-blur-md min-w-0 w-full">
<div className="overflow-x-auto"> <div className="overflow-x-auto min-w-0 w-full">
<table className="w-full text-left text-xs border-collapse"> <table className="w-full text-left text-xs border-collapse min-w-[800px]">
<thead> <thead>
<tr className="bg-slate-900/60 border-b border-slate-800/80 text-slate-400 font-semibold uppercase tracking-wider"> <tr className="bg-slate-900/60 border-b border-slate-800/80 text-slate-400 font-semibold uppercase tracking-wider">
<th className="py-3.5 px-3 w-10 text-center"> <th className="py-3.5 px-3 w-10 text-center">
@@ -961,6 +958,7 @@ export const TenantManagementPage: React.FC = () => {
isOpen={isCreateModalOpen} isOpen={isCreateModalOpen}
onClose={() => setIsCreateModalOpen(false)} onClose={() => setIsCreateModalOpen(false)}
title="Provision New Tenant Partition" title="Provision New Tenant Partition"
subtitle="Define an isolated multi-tenant partition for grants, portfolios, and users."
size="md" size="md"
> >
<form <form
@@ -969,17 +967,6 @@ export const TenantManagementPage: React.FC = () => {
)} )}
className="space-y-4" className="space-y-4"
> >
<div className="p-3.5 rounded-xl bg-blue-950/20 border border-blue-800/30 text-xs text-blue-200 flex items-start gap-2.5">
<Info className="w-4 h-4 text-blue-400 shrink-0 mt-0.5" />
<div className="space-y-1 leading-relaxed">
<p className="font-semibold text-white">Multi-Tenant Partition Boundary</p>
<p>
Creating a new tenant defines an isolated partition boundary for grants, portfolios, projects, and users.
New tenants are automatically initialized in the Active state.
</p>
</div>
</div>
<div> <div>
<label className="block text-xs font-semibold text-slate-300 mb-1.5"> <label className="block text-xs font-semibold text-slate-300 mb-1.5">
Tenant Organization Name <span className="text-rose-400">*</span> Tenant Organization Name <span className="text-rose-400">*</span>
@@ -1006,7 +993,7 @@ export const TenantManagementPage: React.FC = () => {
<button <button
type="submit" type="submit"
disabled={createMutation.isPending} disabled={createMutation.isPending}
className="flex items-center gap-1.5 px-4 py-2 bg-gradient-to-r from-blue-600 to-indigo-600 hover:from-blue-500 hover:to-indigo-500 text-white text-xs font-semibold rounded-xl shadow-md transition disabled:opacity-50 cursor-pointer" className="flex items-center gap-1.5 px-4 py-2 bg-blue-600 hover:bg-blue-500 text-white text-xs font-semibold rounded-xl shadow-md transition disabled:opacity-50 cursor-pointer"
> >
{createMutation.isPending && <RefreshCw className="w-3.5 h-3.5 animate-spin" />} {createMutation.isPending && <RefreshCw className="w-3.5 h-3.5 animate-spin" />}
<span>{createMutation.isPending ? 'Provisioning...' : 'Create Tenant'}</span> <span>{createMutation.isPending ? 'Provisioning...' : 'Create Tenant'}</span>
@@ -1020,6 +1007,7 @@ export const TenantManagementPage: React.FC = () => {
isOpen={isUpdateModalOpen} isOpen={isUpdateModalOpen}
onClose={() => setIsUpdateModalOpen(false)} onClose={() => setIsUpdateModalOpen(false)}
title={`Update Tenant: ${selectedTenant?.name || ''}`} title={`Update Tenant: ${selectedTenant?.name || ''}`}
subtitle="Modify tenant partition name and operational access status."
size="md" size="md"
> >
{selectedTenant && ( {selectedTenant && (
@@ -1063,13 +1051,12 @@ export const TenantManagementPage: React.FC = () => {
{/* Warning when selecting Inactive or Disabled */} {/* Warning when selecting Inactive or Disabled */}
{Number(updateForm.watch('status')) !== ActivityStatus.Active && ( {Number(updateForm.watch('status')) !== ActivityStatus.Active && (
<div className="p-3 rounded-xl bg-amber-950/30 border border-amber-800/40 text-xs text-amber-200 flex items-start gap-2.5"> <div className="p-3 rounded-xl bg-amber-500/10 border border-amber-500/20 text-xs text-amber-200 flex items-start gap-2.5">
<AlertTriangle className="w-4 h-4 text-amber-400 shrink-0 mt-0.5" /> <AlertTriangle className="w-4 h-4 text-amber-400 shrink-0 mt-0.5" />
<div className="space-y-1"> <div className="space-y-0.5">
<p className="font-semibold text-white">Multitenancy Lockout Warning</p> <p className="font-semibold text-amber-300">Lockout Notice</p>
<p className="text-[11px] leading-relaxed text-amber-300/90"> <p className="text-[11px] text-amber-200/90 leading-relaxed">
Changing this tenant's status to Inactive or Disabled immediately locks out all users assigned to this partition. Setting this tenant to Inactive or Disabled immediately locks out assigned users from accessing the API.
The backend API will deny requests until the tenant is restored to Active status.
</p> </p>
</div> </div>
</div> </div>
@@ -1086,7 +1073,7 @@ export const TenantManagementPage: React.FC = () => {
<button <button
type="submit" type="submit"
disabled={updateMutation.isPending} disabled={updateMutation.isPending}
className="flex items-center gap-1.5 px-4 py-2 bg-gradient-to-r from-blue-600 to-indigo-600 hover:from-blue-500 hover:to-indigo-500 text-white text-xs font-semibold rounded-xl shadow-md transition disabled:opacity-50 cursor-pointer" className="flex items-center gap-1.5 px-4 py-2 bg-blue-600 hover:bg-blue-500 text-white text-xs font-semibold rounded-xl shadow-md transition disabled:opacity-50 cursor-pointer"
> >
{updateMutation.isPending && <RefreshCw className="w-3.5 h-3.5 animate-spin" />} {updateMutation.isPending && <RefreshCw className="w-3.5 h-3.5 animate-spin" />}
<span>{updateMutation.isPending ? 'Saving...' : 'Update Tenant'}</span> <span>{updateMutation.isPending ? 'Saving...' : 'Update Tenant'}</span>
@@ -1101,66 +1088,47 @@ export const TenantManagementPage: React.FC = () => {
isOpen={isDetailModalOpen} isOpen={isDetailModalOpen}
onClose={() => setIsDetailModalOpen(false)} onClose={() => setIsDetailModalOpen(false)}
title={`Tenant Details: ${selectedTenant?.name || ''}`} title={`Tenant Details: ${selectedTenant?.name || ''}`}
subtitle="Partition boundary information, creation audit, and operational status."
size="lg" size="lg"
> >
{selectedTenant && ( {selectedTenant && (
<div className="space-y-5"> <div className="space-y-4">
<div className="grid grid-cols-1 sm:grid-cols-2 gap-4 bg-slate-950/60 p-4 rounded-xl border border-slate-800"> <div className="grid grid-cols-2 sm:grid-cols-3 gap-3 bg-slate-950/60 p-3.5 rounded-xl border border-slate-800 text-xs">
<div> <div>
<p className="text-[11px] text-slate-500 uppercase tracking-wider">Tenant Numeric ID</p> <p className="text-[10px] text-slate-500 uppercase tracking-wider font-semibold">Tenant ID</p>
<p className="text-sm font-bold text-white mt-0.5">#{selectedTenant.id}</p> <p className="text-sm font-bold text-white mt-0.5">#{selectedTenant.id}</p>
</div> </div>
<div> <div>
<p className="text-[11px] text-slate-500 uppercase tracking-wider">Tenant Name</p> <p className="text-[10px] text-slate-500 uppercase tracking-wider font-semibold">Tenant Name</p>
<p className="text-sm font-semibold text-slate-200 mt-0.5">{selectedTenant.name || '—'}</p> <p className="text-xs font-semibold text-slate-200 mt-0.5 truncate">{selectedTenant.name || '—'}</p>
</div> </div>
<div> <div>
<p className="text-[11px] text-slate-500 uppercase tracking-wider">Operational Status</p> <p className="text-[10px] text-slate-500 uppercase tracking-wider font-semibold">Operational Status</p>
<div className="mt-1"> <div className="mt-1">
<ActivityStatusBadge status={selectedTenant.status} /> <ActivityStatusBadge status={selectedTenant.status} />
</div> </div>
</div> </div>
<div> <div>
<p className="text-[11px] text-slate-500 uppercase tracking-wider">Created Date</p> <p className="text-[10px] text-slate-500 uppercase tracking-wider font-semibold">Created Date</p>
<p className="text-xs text-slate-300 mt-0.5">{formatDate(selectedTenant.createdAt)}</p> <p className="text-xs text-slate-300 mt-0.5">{formatDate(selectedTenant.createdAt)}</p>
</div> </div>
<div> <div>
<p className="text-[11px] text-slate-500 uppercase tracking-wider">Created By</p> <p className="text-[10px] text-slate-500 uppercase tracking-wider font-semibold">Created By</p>
<p className="text-xs font-mono text-slate-300 mt-0.5"> <p className="text-xs font-mono text-slate-300 mt-0.5 truncate">
{getUserEmail(selectedTenant.createdBy) || selectedTenant.createdBy || 'System Admin'} {getUserEmail(selectedTenant.createdBy) || selectedTenant.createdBy || 'System Admin'}
</p> </p>
</div> </div>
{selectedTenant.updatedAt && ( {selectedTenant.updatedAt && (
<div> <div>
<p className="text-[11px] text-slate-500 uppercase tracking-wider">Last Updated</p> <p className="text-[10px] text-slate-500 uppercase tracking-wider font-semibold">Last Updated</p>
<p className="text-xs text-slate-300 mt-0.5">{formatDate(selectedTenant.updatedAt)}</p> <p className="text-xs text-slate-300 mt-0.5">{formatDate(selectedTenant.updatedAt)}</p>
</div> </div>
)} )}
{selectedTenant.updatedBy && (
<div>
<p className="text-[11px] text-slate-500 uppercase tracking-wider">Updated By</p>
<p className="text-xs font-mono text-slate-300 mt-0.5">
{getUserEmail(selectedTenant.updatedBy) || selectedTenant.updatedBy}
</p>
</div>
)}
</div>
<div className="p-3.5 rounded-xl bg-slate-900/80 border border-slate-800 text-xs text-slate-300 space-y-2">
<p className="font-semibold text-white flex items-center gap-1.5">
<ShieldCheck className="w-4 h-4 text-blue-400" />
Partition Scope &amp; Authentication
</p>
<p className="text-[11px] text-slate-400 leading-relaxed">
When users authenticate, their security token contains claims identifying their tenant partition.
The system automatically scopes data queries to this tenant and enforces operational lockout if the status is not Active.
</p>
</div> </div>
<div className="flex items-center justify-end gap-2.5 pt-3 border-t border-slate-800"> <div className="flex items-center justify-end gap-2.5 pt-3 border-t border-slate-800">
@@ -1170,7 +1138,7 @@ export const TenantManagementPage: React.FC = () => {
setIsDetailModalOpen(false); setIsDetailModalOpen(false);
handleOpenUpdate(selectedTenant); handleOpenUpdate(selectedTenant);
}} }}
className="flex items-center gap-1.5 px-4 py-2 bg-gradient-to-r from-blue-600 to-indigo-600 hover:from-blue-500 hover:to-indigo-500 text-white text-xs font-semibold rounded-xl transition cursor-pointer" className="flex items-center gap-1.5 px-4 py-2 bg-blue-600 hover:bg-blue-500 text-white text-xs font-semibold rounded-xl transition cursor-pointer"
> >
<Edit2 className="w-3.5 h-3.5" /> <Edit2 className="w-3.5 h-3.5" />
<span>Edit Tenant Status</span> <span>Edit Tenant Status</span>
@@ -1198,14 +1166,16 @@ export const TenantManagementPage: React.FC = () => {
/> />
{/* User Security & Administration Modal */} {/* User Security & Administration Modal */}
<UserSecurityModal {isSecurityModalOpen && securityUser && (
isOpen={isSecurityModalOpen} <UserSecurityModal
onClose={() => { isOpen={isSecurityModalOpen}
setIsSecurityModalOpen(false); onClose={() => {
setSecurityUser(null); setIsSecurityModalOpen(false);
}} setSecurityUser(null);
user={securityUser} }}
/> user={securityUser}
/>
)}
</div> </div>
); );
}; };
+308 -306
View File
@@ -1,6 +1,19 @@
import axios, { AxiosError } from 'axios'; import axios, { AxiosError } from 'axios';
import { apiClient } from './httpClient'; import { apiClient } from './httpClient';
import { toolsService } from './toolsService'; import { toolsService } from '../utils/hashids';
import {
notifyTenantManagersAndExecutivesOnRecordStatusChange,
notifyInviteApproved,
notifyNewUserCreated,
isApprovedOrActiveStatus,
} from './notificationRulesService';
export {
notifyTenantManagersAndExecutivesOnRecordStatusChange,
notifyInviteApproved,
notifyNewUserCreated,
isApprovedOrActiveStatus,
};
import { import {
getCurrentUserSub, getCurrentUserSub,
getCurrentUserTenantId, getCurrentUserTenantId,
@@ -37,6 +50,7 @@ import {
UpdateMilestoneRequest, UpdateMilestoneRequest,
Award, Award,
CreateAwardRequest, CreateAwardRequest,
UpdateAwardRequest,
Contract, Contract,
ContractStatus, ContractStatus,
CreateContractRequest, CreateContractRequest,
@@ -63,6 +77,7 @@ import {
AuditLog, AuditLog,
ProblemDetails, ProblemDetails,
ActivityStatus, ActivityStatus,
ApprovalStatus,
Tenant, Tenant,
TenantOrganisation, TenantOrganisation,
TenantUser, TenantUser,
@@ -97,6 +112,8 @@ import {
Claim, Claim,
CreateUserRequest, CreateUserRequest,
AddUserClaimRequest, AddUserClaimRequest,
CreateNotificationRequest,
UpdateNotificationRequest,
} from '../types'; } from '../types';
import { import {
@@ -236,16 +253,25 @@ export const createOrganisation = async (payload: CreateOrganisationRequest): Pr
}; };
export const updateOrganisation = async (payload: UpdateOrganisationRequest): Promise<Organisation> => { export const updateOrganisation = async (payload: UpdateOrganisationRequest): Promise<Organisation> => {
const orgStatus = payload.status !== undefined ? Number(payload.status) : ActivityStatus.Active;
const res = await apiClient.put('/v1/api/governance/organisations', { const res = await apiClient.put('/v1/api/governance/organisations', {
organisationId: toIntegerId(payload.organisationId), organisationId: toIntegerId(payload.organisationId),
name: payload.name, name: payload.name,
registrationNo: payload.registrationNo, registrationNo: payload.registrationNo,
email: payload.email, email: payload.email,
idNumber: payload.idNumber, idNumber: payload.idNumber || null,
organisationUser: payload.organisationUser, organisationUser: payload.organisationUser,
type: Number(payload.type), type: Number(payload.type),
status: payload.status !== undefined ? Number(payload.status) : ActivityStatus.Active, status: orgStatus,
}); });
if (isApprovedOrActiveStatus(orgStatus)) {
notifyTenantManagersAndExecutivesOnRecordStatusChange({
recordType: 'Organisation',
recordId: payload.organisationId,
newStatus: orgStatus,
title: payload.name,
}).catch(console.warn);
}
return res.data; return res.data;
}; };
@@ -416,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 => { export const getStoredInstructions = (): Instrument[] => [];
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;
const apiIds = new Set(apiItems.map((i) => String(i.id))); export const saveStoredInstruction = (_inst?: Instrument): void => {};
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 getInstruments = async (programmeId: string | number): Promise<Instrument[]> => { export const getInstruments = async (programmeId: string | number): Promise<Instrument[]> => {
let apiItems: Instrument[] = [];
let apiSucceeded = false;
try { try {
const hashedProgId = await toolsService.resolveProgrammeId(programmeId); const hashedProgId = await toolsService.resolveProgrammeId(programmeId);
const res = await apiClient.get<Instrument[]>(`/v1/api/governance/instruments/${hashedProgId}`); const res = await apiClient.get<Instrument[]>(`/v1/api/governance/instruments/${hashedProgId}`);
if (Array.isArray(res.data)) { return Array.isArray(res.data) ? res.data : [];
apiItems = res.data;
apiSucceeded = true;
}
} catch (err) { } catch (err) {
console.warn(`[getInstruments] Could not fetch instruments from API for prog ${programmeId}:`, 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; export const getInstructions = getInstruments;
@@ -509,27 +472,17 @@ export const getAllInstruments = async (programmesList?: Programme[]): Promise<I
} }
const map = new Map<string, Instrument>(); const map = new Map<string, Instrument>();
let hasApiData = false;
if (progList && progList.length > 0) { if (progList && progList.length > 0) {
const results = await Promise.allSettled( const results = await Promise.allSettled(
progList.map((pr) => getInstruments(pr.id)) progList.map((pr) => getInstruments(pr.id))
); );
results.forEach((res) => { results.forEach((res) => {
if (res.status === 'fulfilled' && Array.isArray(res.value) && res.value.length > 0) { if (res.status === 'fulfilled' && Array.isArray(res.value)) {
hasApiData = true;
res.value.forEach((inst) => map.set(String(inst.id), inst)); res.value.forEach((inst) => map.set(String(inst.id), inst));
} }
}); });
} }
return Array.from(map.values());
if (hasApiData || map.size > 0) {
const allApiInstruments = Array.from(map.values());
purgeStoredInstructionDuplicates(allApiInstruments);
return allApiInstruments;
}
return getStoredInstructions();
}; };
export const getAllInstructions = getAllInstruments; export const getAllInstructions = getAllInstruments;
@@ -543,40 +496,8 @@ export const createInstrument = async (payload: CreateInstrumentRequest): Promis
description: payload.description, description: payload.description,
}; };
try { const res = await apiClient.post<Instrument>('/v1/api/governance/instruments', body);
const res = await apiClient.post<Instrument>('/v1/api/governance/instruments', body); return res.data;
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(),
};
}; };
export const createInstruction = createInstrument; export const createInstruction = createInstrument;
@@ -593,33 +514,16 @@ export const updateInstrument = async (payload: UpdateInstrumentRequest): Promis
status: Number(payload.status), status: Number(payload.status),
}; };
try { const res = await apiClient.put<Instrument>('/v1/api/governance/instruments', body);
const res = await apiClient.put<Instrument>('/v1/api/governance/instruments', body); if (isApprovedOrActiveStatus(payload.status)) {
if (res.data && res.data.id) { notifyTenantManagersAndExecutivesOnRecordStatusChange({
return res.data; recordType: 'Funding Instrument',
} recordId: targetId!,
} catch (err) { newStatus: payload.status,
console.warn('[updateInstrument] API update failed:', err); title: payload.name,
}).catch(console.warn);
} }
return res.data;
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);
}
return updatedInstrument;
}; };
export const updateInstruction = updateInstrument; export const updateInstruction = updateInstrument;
@@ -664,12 +568,21 @@ export const updateProgramme = async (payload: UpdateProgrammeRequest): Promise<
if (ownerId && typeof ownerId === 'string' && ownerId.includes('@')) { if (ownerId && typeof ownerId === 'string' && ownerId.includes('@')) {
ownerId = getUserIdByEmail(ownerId) || userSub || ownerId; ownerId = getUserIdByEmail(ownerId) || userSub || ownerId;
} }
const progStatus = Number(payload.status) || 1;
const res = await apiClient.put('/v1/api/projects/programme', { const res = await apiClient.put('/v1/api/projects/programme', {
programmeId: toIntegerId(payload.programmeId), programmeId: toIntegerId(payload.programmeId),
name: payload.name, name: payload.name,
ownerId: ownerId || userSub || '00000000-0000-0000-0000-000000000000', ownerId: ownerId || userSub || '00000000-0000-0000-0000-000000000000',
status: Number(payload.status) || 1, status: progStatus,
}); });
if (isApprovedOrActiveStatus(progStatus)) {
notifyTenantManagersAndExecutivesOnRecordStatusChange({
recordType: 'Programme',
recordId: payload.programmeId,
newStatus: progStatus,
title: payload.name,
}).catch(console.warn);
}
return res.data; return res.data;
}; };
@@ -722,6 +635,13 @@ export const updateProjectStatus = async (payload: UpdateProjectStatusRequest):
projectId: toIntegerId(payload.projectId), projectId: toIntegerId(payload.projectId),
status: Number(payload.status), status: Number(payload.status),
}); });
if (isApprovedOrActiveStatus(payload.status)) {
notifyTenantManagersAndExecutivesOnRecordStatusChange({
recordType: 'Project',
recordId: payload.projectId,
newStatus: payload.status,
}).catch(console.warn);
}
}; };
export const getActivities = async (projectId: string | number): Promise<Activity[]> => { export const getActivities = async (projectId: string | number): Promise<Activity[]> => {
@@ -823,6 +743,14 @@ export const updateMilestone = async (payload: UpdateMilestoneRequest): Promise<
priority: Number(payload.priority) || 1, priority: Number(payload.priority) || 1,
status: Number(payload.status) || 1, status: Number(payload.status) || 1,
}); });
if (isApprovedOrActiveStatus(payload.status)) {
notifyTenantManagersAndExecutivesOnRecordStatusChange({
recordType: 'Milestone',
recordId: payload.milestoneId,
newStatus: payload.status,
title: payload.name,
}).catch(console.warn);
}
}; };
export const getProjectIndicators = async (projectId: string | number): Promise<Indicator[]> => { export const getProjectIndicators = async (projectId: string | number): Promise<Indicator[]> => {
@@ -869,6 +797,27 @@ export const createAward = async (payload: CreateAwardRequest): Promise<Award> =
return res.data; return res.data;
}; };
export const updateAward = async (payload: UpdateAwardRequest): Promise<void> => {
const numericAwardId = toIntegerId(payload.awardId);
const numericOrgId = toIntegerId(payload.organisationId);
const numericProgId = toIntegerId(payload.programmeId);
await apiClient.put('/v1/api/grants/awards', {
awardId: numericAwardId,
organisationId: numericOrgId,
programmeId: numericProgId,
amount: Number(payload.amount),
...(payload.status !== undefined ? { status: Number(payload.status) } : {}),
});
if (payload.status === undefined || isApprovedOrActiveStatus(payload.status)) {
notifyTenantManagersAndExecutivesOnRecordStatusChange({
recordType: 'Award',
recordId: payload.awardId,
newStatus: payload.status !== undefined ? payload.status : ApprovalStatus.Approved,
}).catch(console.warn);
}
};
export const fetchHashId = async (id64: number | string): Promise<string> => { export const fetchHashId = async (id64: number | string): Promise<string> => {
try { try {
const res = await apiClient.get<string>(`/v1/api/tools/hashid/${id64}`); const res = await apiClient.get<string>(`/v1/api/tools/hashid/${id64}`);
@@ -1001,6 +950,14 @@ export const updateContract = async (payload: UpdateContractRequest): Promise<vo
contractId: toIntegerId(payload.contractId), contractId: toIntegerId(payload.contractId),
status: Number(payload.status), status: Number(payload.status),
}); });
if (isApprovedOrActiveStatus(payload.status)) {
notifyTenantManagersAndExecutivesOnRecordStatusChange({
recordType: 'Contract',
recordId: payload.contractId,
newStatus: payload.status,
}).catch(console.warn);
}
}; };
export const uploadContractFile = async ( export const uploadContractFile = async (
@@ -1061,6 +1018,13 @@ export const updateBudget = async (payload: UpdateBudgetRequest): Promise<void>
budgetId: toIntegerId(payload.budgetId), budgetId: toIntegerId(payload.budgetId),
status: Number(payload.status), status: Number(payload.status),
}); });
if (isApprovedOrActiveStatus(payload.status)) {
notifyTenantManagersAndExecutivesOnRecordStatusChange({
recordType: 'Budget',
recordId: payload.budgetId,
newStatus: payload.status,
}).catch(console.warn);
}
}; };
// ========================================== // ==========================================
@@ -1099,6 +1063,11 @@ export const updateDisbursement = async (payload: UpdateDisbursementRequest): Pr
export const approveDisbursement = async (disbursementId: string | number): Promise<void> => { export const approveDisbursement = async (disbursementId: string | number): Promise<void> => {
const hashedDisbId = await toolsService.encodeId(disbursementId); const hashedDisbId = await toolsService.encodeId(disbursementId);
await apiClient.post(`/v1/api/execution/disbursements/${hashedDisbId}/approve`, {}); await apiClient.post(`/v1/api/execution/disbursements/${hashedDisbId}/approve`, {});
notifyTenantManagersAndExecutivesOnRecordStatusChange({
recordType: 'Disbursement',
recordId: disbursementId,
newStatus: ApprovalStatus.Approved,
}).catch(console.warn);
}; };
/** /**
@@ -1201,10 +1170,12 @@ export const getIndicator = async (indicatorId: string | number): Promise<Indica
export const createIndicator = async (payload: CreateIndicatorRequest): Promise<Indicator> => { export const createIndicator = async (payload: CreateIndicatorRequest): Promise<Indicator> => {
const res = await apiClient.post('/v1/api/evidence/indicators', { const res = await apiClient.post('/v1/api/evidence/indicators', {
projectId: toIntegerId(payload.projectId), projectId: toIntegerId(payload.projectId),
activityId: toNullableIntegerId(payload.activityId),
name: payload.name, name: payload.name,
unitOfMeasure: Number(payload.unitOfMeasure) || 1, unitOfMeasure: Number(payload.unitOfMeasure) || 1,
baselineAmount: Number(payload.baselineAmount) || 0, baselineAmount: Number(payload.baselineAmount) || 0,
targetAmount: Number(payload.targetAmount) || 0, targetAmount: Number(payload.targetAmount) || 0,
targetCost: Number(payload.targetCost) || 0,
}); });
return res.data; return res.data;
}; };
@@ -1258,13 +1229,13 @@ export const getMilestoneEvidence = async (milestoneId: string | number): Promis
export const getProjectEvidence = async (projectId: string | number): Promise<Evidence[]> => { export const getProjectEvidence = async (projectId: string | number): Promise<Evidence[]> => {
try { try {
const hashedProjectId = await toolsService.resolveProjectId(projectId); 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 || []; return res.data || [];
} catch (err: any) { } catch (err: any) {
// If hashed path parameter returns 404 or 400 and projectId differs, attempt with raw ID // 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) !== '') { if ((err?.response?.status === 404 || err?.response?.status === 400) && String(projectId) !== '') {
try { 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 || []; return rawRes.data || [];
} catch (fallbackErr: any) { } catch (fallbackErr: any) {
if (fallbackErr?.response?.status === 404) { if (fallbackErr?.response?.status === 404) {
@@ -1297,6 +1268,13 @@ export const updateEvidence = async (payload: UpdateEvidenceRequest): Promise<vo
evidenceType: Number(payload.evidenceType), evidenceType: Number(payload.evidenceType),
status: Number(payload.status), status: Number(payload.status),
}); });
if (isApprovedOrActiveStatus(payload.status)) {
notifyTenantManagersAndExecutivesOnRecordStatusChange({
recordType: 'Evidence',
recordId: payload.evidenceId,
newStatus: payload.status,
}).catch(console.warn);
}
}; };
// ========================================== // ==========================================
@@ -1382,10 +1360,22 @@ export const createUser = async (
): Promise<void> => { ): Promise<void> => {
const encodedTenant = encodeEntityId(tenantId) || String(tenantId); const encodedTenant = encodeEntityId(tenantId) || String(tenantId);
const encodedIdNumber = encodeIdNumberHex(idNumber); const encodedIdNumber = encodeIdNumberHex(idNumber);
// Normalize and sanitize userName: Duende / Skoruba Identity Server rejects usernames with spaces
const rawEmail = payload.email?.trim().toLowerCase() || '';
const rawUserName = payload.userName?.trim();
let sanitizedUserName = rawUserName
? rawUserName.toLowerCase().replace(/\s+/g, '.').replace(/[^a-z0-9_.\-@+]/g, '')
: rawEmail.split('@')[0];
if (!sanitizedUserName) {
sanitizedUserName = rawEmail;
}
await apiClient.post(`/v1/api/users/${encodeURIComponent(encodedTenant)}/${encodeURIComponent(encodedIdNumber)}`, { await apiClient.post(`/v1/api/users/${encodeURIComponent(encodedTenant)}/${encodeURIComponent(encodedIdNumber)}`, {
id: payload.id || null, id: payload.id || null,
userName: payload.userName || null, userName: sanitizedUserName,
email: payload.email || null, email: rawEmail || null,
emailConfirmed: payload.emailConfirmed ?? true, emailConfirmed: payload.emailConfirmed ?? true,
phoneNumber: payload.phoneNumber || null, phoneNumber: payload.phoneNumber || null,
phoneNumberConfirmed: payload.phoneNumberConfirmed ?? false, phoneNumberConfirmed: payload.phoneNumberConfirmed ?? false,
@@ -1524,6 +1514,7 @@ export const sendInvite = async (payload: InviteRequest): Promise<Invite> => {
invitedOrganisationId: toSafeInt64String(payload.invitedOrganisationId), invitedOrganisationId: toSafeInt64String(payload.invitedOrganisationId),
awardId: toSafeInt64String(payload.awardId), awardId: toSafeInt64String(payload.awardId),
contractId: toSafeInt64String(payload.contractId), contractId: toSafeInt64String(payload.contractId),
projectId: toSafeInt64String(payload.projectId || 0),
recipient: payload.recipient, recipient: payload.recipient,
message: payload.message, message: payload.message,
platform: payload.platform !== undefined ? Number(payload.platform) : 1, platform: payload.platform !== undefined ? Number(payload.platform) : 1,
@@ -1585,7 +1576,63 @@ export const getNotifications = async (organisationId?: string | number): Promis
return []; return [];
}; };
export const getUserNotifications = async (userId?: string): Promise<Notification[]> => {
try {
const url = userId ? `/v1/api/communications/notifications/user/${encodeURIComponent(userId)}` : `/v1/api/communications/notifications/inbox`;
const res = await apiClient.get<Notification[]>(url);
if (res.data && Array.isArray(res.data)) {
return res.data;
}
} catch (err: any) {
console.warn('[getUserNotifications] API error:', err);
}
return [];
};
export const createNotification = async (payload: CreateNotificationRequest): Promise<void> => {
const tenantId = getCurrentUserTenantId();
const orgId = payload.organisationId !== undefined && payload.organisationId !== '' && payload.organisationId !== null
? toIntegerId(payload.organisationId)
: (tenantId !== null ? tenantId : 1);
await apiClient.post('/v1/api/communications/notifications', {
organisationId: orgId,
recipient: payload.recipient,
platform: payload.platform,
subject: payload.subject || null,
destination: payload.destination || 'In-App User Inbox',
message: payload.message,
status: payload.status !== undefined
? payload.status
: (Number(payload.platform) === NotificationPlatform.InApp ? NotificationStatus.Delivered : undefined),
});
};
export const updateNotification = async (payload: UpdateNotificationRequest): Promise<void> => {
await apiClient.put('/v1/api/communications/notifications', {
notificationId: toIntegerId(payload.notificationId),
destination: payload.destination || undefined,
status: payload.status,
sentAt: payload.sentAt || new Date().toISOString(),
error: payload.error || null,
});
};
export const markNotificationAsRead = async (notificationId: number | string): Promise<void> => {
await updateNotification({
notificationId: toIntegerId(notificationId),
status: NotificationStatus.Read,
});
};
// ========================================== // ==========================================
export const archiveNotification = async (notificationId: number | string): Promise<void> => {
await updateNotification({
notificationId: toIntegerId(notificationId),
status: NotificationStatus.Archived,
});
};
// 9. Governance: Tenants APIs // 9. Governance: Tenants APIs
// ========================================== // ==========================================
export const getTenants = async (): Promise<Tenant[]> => { export const getTenants = async (): Promise<Tenant[]> => {
@@ -1611,6 +1658,14 @@ export const updateTenant = async (payload: UpdateTenantRequest): Promise<void>
name: payload.name, name: payload.name,
status: Number(payload.status), status: Number(payload.status),
}); });
if (isApprovedOrActiveStatus(payload.status)) {
notifyTenantManagersAndExecutivesOnRecordStatusChange({
recordType: 'Tenant',
recordId: payload.tenantId,
newStatus: payload.status,
title: payload.name,
}).catch(console.warn);
}
}; };
export const getTenantOrganisations = async (tenantId: string | number): Promise<TenantOrganisation[]> => { export const getTenantOrganisations = async (tenantId: string | number): Promise<TenantOrganisation[]> => {
@@ -1714,14 +1769,41 @@ export const updateBeneficiary = async (payload: UpdateBeneficiaryRequest): Prom
export const getOutcome = async (outcomeId: string | number): Promise<Outcome> => { export const getOutcome = async (outcomeId: string | number): Promise<Outcome> => {
const hashedOutcomeId = await toolsService.encodeId(outcomeId); const hashedOutcomeId = await toolsService.encodeId(outcomeId);
const res = await apiClient.get<Outcome>(`/v1/api/projects/outcomes/${hashedOutcomeId}`); try {
return res.data; const res = await apiClient.get<Outcome>(`/v1/api/projects/outcome/${hashedOutcomeId}`);
return res.data;
} catch {
const res = await apiClient.get<Outcome>(`/v1/api/projects/outcomes/single/${hashedOutcomeId}`);
return res.data;
}
}; };
export const getBeneficiaryOutcomes = async (beneficiaryId: string | number): Promise<Outcome[]> => { export const getBeneficiaryOutcomes = async (beneficiaryId: string | number): Promise<Outcome[]> => {
const hashedBeneficiaryId = await toolsService.encodeId(beneficiaryId); const hashedBeneficiaryId = await toolsService.encodeId(beneficiaryId);
const res = await apiClient.get<Outcome[]>(`/v1/api/projects/outcomes/${hashedBeneficiaryId}`); try {
return res.data || []; const res = await apiClient.get<Outcome[]>(`/v1/api/projects/beneficiaries/${hashedBeneficiaryId}/outcomes`);
return Array.isArray(res.data) ? res.data : (res.data ? [res.data] : []);
} catch (err: any) {
if (err?.response?.status === 404) {
try {
const res = await apiClient.get<Outcome[]>(`/v1/api/projects/outcomes/${hashedBeneficiaryId}`);
return Array.isArray(res.data) ? res.data : (res.data ? [res.data] : []);
} catch {
return [];
}
}
return [];
}
};
export const getProjectOutcomes = async (projectId: string | number): Promise<Outcome[]> => {
const hashedProjectId = await toolsService.resolveProjectId(projectId);
try {
const res = await apiClient.get<Outcome[]>(`/v1/api/projects/${hashedProjectId}/outcomes`);
return Array.isArray(res.data) ? res.data : [];
} catch {
return [];
}
}; };
export const createOutcome = async (payload: CreateOutcomeRequest): Promise<void> => { export const createOutcome = async (payload: CreateOutcomeRequest): Promise<void> => {
@@ -1729,12 +1811,13 @@ export const createOutcome = async (payload: CreateOutcomeRequest): Promise<void
beneficiaryId: toIntegerId(payload.beneficiaryId), beneficiaryId: toIntegerId(payload.beneficiaryId),
name: payload.name, name: payload.name,
description: payload.description, description: payload.description,
status: payload.status !== undefined ? Number(payload.status) : 1,
}; };
try { try {
await apiClient.post('/v1/api/projects/beneficiaris/outcomes', body); await apiClient.post('/v1/api/projects/beneficiaries/outcomes', body);
} catch (err: any) { } catch (err: any) {
if (err?.response?.status === 404) { if (err?.response?.status === 404) {
await apiClient.post('/v1/api/projects/beneficiaries/outcomes', body); await apiClient.post('/v1/api/projects/beneficiaris/outcomes', body);
} else { } else {
throw err; throw err;
} }
@@ -1746,7 +1829,16 @@ export const updateOutcome = async (payload: UpdateOutcomeRequest): Promise<void
outcomeId: toIntegerId(payload.outcomeId), outcomeId: toIntegerId(payload.outcomeId),
name: payload.name, name: payload.name,
description: payload.description, description: payload.description,
status: Number(payload.status),
}); });
if (isApprovedOrActiveStatus(payload.status)) {
notifyTenantManagersAndExecutivesOnRecordStatusChange({
recordType: 'Outcome',
recordId: payload.outcomeId,
newStatus: payload.status,
title: payload.name,
}).catch(console.warn);
}
}; };
// ========================================== // ==========================================
@@ -1808,6 +1900,14 @@ export const updateChangeRequest = async (payload: UpdateChangeRequest): Promise
revisedBudget: Number(payload.revisedBudget), revisedBudget: Number(payload.revisedBudget),
status: Number(payload.status), status: Number(payload.status),
}); });
if (isApprovedOrActiveStatus(payload.status)) {
notifyTenantManagersAndExecutivesOnRecordStatusChange({
recordType: 'Change Request',
recordId: payload.changeRequestId,
newStatus: payload.status,
title: payload.title,
}).catch(console.warn);
}
}; };
// ========================================== // ==========================================
@@ -1855,6 +1955,8 @@ export const createComplianceItem = async (payload: CreateComplianceItemRequest)
projectId: toIntegerId(payload.projectId), projectId: toIntegerId(payload.projectId),
title: payload.title, title: payload.title,
requirements: payload.requirements, requirements: payload.requirements,
status: payload.status !== undefined ? Number(payload.status) : 1,
beneficiaryId: payload.beneficiaryId ? toIntegerId(payload.beneficiaryId) : null,
}); });
}; };
@@ -1864,7 +1966,16 @@ export const updateComplianceItem = async (payload: UpdateComplianceItemRequest)
title: payload.title, title: payload.title,
requirements: payload.requirements, requirements: payload.requirements,
status: Number(payload.status), status: Number(payload.status),
beneficiaryId: payload.beneficiaryId ? toIntegerId(payload.beneficiaryId) : null,
}); });
if (isApprovedOrActiveStatus(payload.status)) {
notifyTenantManagersAndExecutivesOnRecordStatusChange({
recordType: 'Compliance Item',
recordId: payload.complianceItemId,
newStatus: payload.status,
title: payload.title,
}).catch(console.warn);
}
}; };
// ========================================== // ==========================================
@@ -1932,6 +2043,15 @@ export const updateIssue = async (payload: UpdateIssueRequest): Promise<void> =>
status: payload.status !== undefined ? Number(payload.status) : ActivityStatus.Active, status: payload.status !== undefined ? Number(payload.status) : ActivityStatus.Active,
assignedTo: payload.assignedTo, assignedTo: payload.assignedTo,
}); });
const issueStatus = payload.status !== undefined ? payload.status : ActivityStatus.Active;
if (isApprovedOrActiveStatus(issueStatus)) {
notifyTenantManagersAndExecutivesOnRecordStatusChange({
recordType: 'Issue',
recordId: payload.issueId,
newStatus: issueStatus,
title: payload.title,
}).catch(console.warn);
}
}; };
// ========================================== // ==========================================
@@ -1996,77 +2116,30 @@ export const updateSiteVisit = async (payload: UpdateSiteVisitRequest): Promise<
status: Number(payload.status), status: Number(payload.status),
visitedAt: visitedAtIso, visitedAt: visitedAtIso,
}); });
}; if (isApprovedOrActiveStatus(payload.status)) {
notifyTenantManagersAndExecutivesOnRecordStatusChange({
const LOCAL_RULES_KEY = 'pfm_local_rules'; recordType: 'Site Visit',
recordId: payload.siteVisitId,
export const purgeStoredRuleDuplicates = (apiRules: Rule[]): void => { newStatus: payload.status,
if (typeof window === 'undefined' || !Array.isArray(apiRules) || apiRules.length === 0) return; title: payload.inspectorName,
try { }).catch(console.warn);
const raw = localStorage.getItem(LOCAL_RULES_KEY);
if (!raw) return;
const local: Rule[] = JSON.parse(raw);
if (!Array.isArray(local) || local.length === 0) return;
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[] => { export const purgeStoredRuleDuplicates = (_apiRules?: Rule[]): void => {};
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 => { export const getStoredRules = (): Rule[] => [];
if (typeof window === 'undefined') return;
try { export const saveStoredRule = (_rule?: Rule): void => {};
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);
}
};
// ========================================== // ==========================================
// 15. Execution: Programme Rules APIs // 15. Execution: Programme Rules APIs
// ========================================== // ==========================================
export const getRule = async (ruleId: string | number): Promise<Rule> => { export const getRule = async (ruleId: string | number): Promise<Rule> => {
try { const hashedRuleId = await toolsService.encodeId(ruleId);
const hashedRuleId = await toolsService.encodeId(ruleId); const res = await apiClient.get<Rule>(`/v1/api/execution/rules/${hashedRuleId}`);
const res = await apiClient.get<Rule>(`/v1/api/execution/rules/${hashedRuleId}`); if (res.data) {
if (res.data) { return 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');
} }
throw new Error('Rule not found'); throw new Error('Rule not found');
}; };
@@ -2084,38 +2157,16 @@ export const getRules = async (
pagination: Pagination = { page: 1, pageSize: 100 }, pagination: Pagination = { page: 1, pageSize: 100 },
programmeId?: string | number programmeId?: string | number
): Promise<Rule[]> => { ): Promise<Rule[]> => {
let apiRules: Rule[] = []; const res = await apiClient.post<Rule[]>('/v1/api/execution/programmes/rules', {
let apiSucceeded = false; index: pagination.index ? toIntegerId(pagination.index) : 0,
try { page: pagination.page || 1,
const res = await apiClient.post<Rule[]>('/v1/api/execution/programmes/rules', { pageSize: pagination.pageSize || 100,
index: pagination.index ? toIntegerId(pagination.index) : 0, });
page: pagination.page || 1, let result = Array.isArray(res.data) ? res.data : [];
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;
if (programmeId != null && programmeId !== '' && programmeId !== 'all') { 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> => { export const createRule = async (payload: CreateRuleRequest): Promise<Rule> => {
@@ -2127,70 +2178,21 @@ export const createRule = async (payload: CreateRuleRequest): Promise<Rule> => {
description: payload.description, description: payload.description,
}; };
try { const res = await apiClient.post<Rule>('/v1/api/execution/rules', body);
const res = await apiClient.post<Rule>('/v1/api/execution/rules', body); return res.data;
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(),
};
}; };
export const updateRule = async (payload: UpdateRuleRequest): Promise<void> => { export const updateRule = async (payload: UpdateRuleRequest): Promise<void> => {
const validUserId = toValidUuid(payload.userId); const validUserId = toValidUuid(payload.userId);
try { await apiClient.put('/v1/api/execution/rules', {
await apiClient.put('/v1/api/execution/rules', { ruleId: toIntegerId(payload.ruleId),
ruleId: toIntegerId(payload.ruleId), userId: validUserId,
userId: validUserId, name: payload.name,
name: payload.name, description: payload.description,
description: payload.description, status: Number(payload.status),
status: Number(payload.status), version: Number(payload.version || 1),
version: Number(payload.version || 1), ...(payload.programmeId ? { programmeId: toIntegerId(payload.programmeId) } : {}),
...(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);
}
}; };
// ========================================== // ==========================================

Some files were not shown because too many files have changed in this diff Show More