184 Commits
Author SHA1 Message Date
hermes 31d775c917 Fixed frozen org editor modal 2026-10-25 12:50:35 +02:00
hermes 78d147b002 Fix UI freeze by removing cyclical useEffect and stabilizing executive candidate memo 2026-10-25 12:13:15 +02:00
hermes 07018c3ce4 Auto-assign linked executive user and persist userId in organisation update modal 2026-10-25 12:02:30 +02:00
hermes db08bbf162 Added admin override when updating organisation 2026-10-25 11:19:59 +02:00
hermes ac357285c3 Enhanced shieldcheck 2026-10-25 11:07:05 +02:00
hermes a4927f9742 Fix vite env type declarations and tsconfig in PartnerUI 2026-10-25 11:04:29 +02:00
hermes fac88f3471 Fix missing lucide icon imports in GovernancePage and null-safety in DashboardPage 2026-10-25 10:35:27 +02:00
hermes aeb7542cfe Optimised cach usage, and enforced maker checker 2026-10-25 10:11:57 +02:00
hermes 9724bbac24 Removed award validation from budget creation process 2026-10-25 09:17:00 +02:00
hermes deb21dbb9a Redesigned dashboard 2026-10-25 08:52:14 +02:00
khwezi 98fe1e3e3d Merge pull request 'Moved top bar to top' (#173) from finishline into main
Reviewed-on: #173
2026-10-25 08:27:23 +02:00
khwezi d9b65010cf Merge pull request 'Redesigned PartnerUi dashboard' (#172) from finishline into main
Reviewed-on: #172
2026-10-25 08:02:27 +02:00
khwezi bc441c1255 Merge pull request 'Fixed ConsoleUi salt handling' (#171) from finishline into main
Reviewed-on: #171
2026-10-24 18:10:42 +02:00
khwezi 6e786ca796 Merge pull request 'Finishline' (#170) from finishline into main
Reviewed-on: #170
2026-10-24 17:37:15 +02:00
khwezi 90631d22ae Merge pull request 'Applied extensive leaked secret protection' (#169) from finishline into main
Reviewed-on: #169
2026-10-24 15:57:01 +02:00
khwezi 010f2d5eaa Merge pull request 'Stopped leaking secrets' (#168) from finishline into main
Reviewed-on: #168
2026-10-24 15:44:08 +02:00
khwezi b8f5ebf69a Merge pull request 'Finishline' (#167) from finishline into main
Reviewed-on: #167
2026-10-24 15:23:51 +02:00
khwezi 957e75a853 Merge pull request 'Applied CORS fix' (#166) from finishline into main
Reviewed-on: #166
2026-10-24 14:59:28 +02:00
khwezi 8a06a86eb3 Merge pull request 'Fixed Console user lockout and unlock implementation' (#165) from finishline into main
Reviewed-on: #165
2026-10-24 14:39:40 +02:00
khwezi f8f826979d Merge pull request 'Finishline' (#164) from finishline into main
Reviewed-on: #164
2026-10-24 12:51:15 +02:00
khwezi c4a0469bae Merge pull request 'Finishline' (#163) from finishline into main
Reviewed-on: #163
2026-10-24 12:13:11 +02:00
khwezi a3f461478c Merge pull request 'PartnerUi fixes' (#162) from finishline into main
Reviewed-on: #162
2026-10-24 11:49:45 +02:00
khwezi cc62696017 Merge pull request 'Fixed sync handshake' (#161) from finishline into main
Reviewed-on: #161
2026-10-23 10:20:48 +02:00
khwezi d4598e0c6c Merge pull request 'Implemented partnerui' (#160) from finishline into main
Reviewed-on: #160
2026-10-23 00:59:39 +02:00
khwezi 43656e848a Merge pull request 'Applied mailbox constraints' (#159) from finishline into main
Reviewed-on: #159
2026-10-22 23:58:12 +02:00
khwezi 3a3245b643 Merge pull request 'Fixed notification composition' (#158) from finishline into main
Reviewed-on: #158
2026-10-22 23:36:39 +02:00
khwezi fdaf12a183 Merge pull request 'Implemented notification strategy' (#157) from finishline into main
Reviewed-on: #157
2026-10-22 23:01:14 +02:00
khwezi 6cdd03a02f Merge pull request 'Implemented markdown based notification message body formatting' (#156) from finishline into main
Reviewed-on: #156
2026-10-22 18:19:36 +02:00
khwezi 1d1060d031 Merge pull request 'Implemented mailbox in ConsoleUI' (#155) from finishline into main
Reviewed-on: #155
2026-10-22 18:01:54 +02:00
khwezi b0c89362d0 Merge pull request 'Fixed beneficiary page type error' (#154) from finishline into main
Reviewed-on: #154
2026-10-22 17:15:04 +02:00
khwezi dee9ccf0b1 Merge pull request 'Fixed beneficiary outcomes misconfiguration' (#153) from finishline into main
Reviewed-on: #153
2026-10-22 17:08:35 +02:00
khwezi d9b3e80f7c Merge pull request 'Finishline' (#152) from finishline into main
Reviewed-on: #152
2026-10-22 16:08:08 +02:00
khwezi 8688c752d6 Merge pull request 'Enforced relationships between activities and indicators, fixed actuals recording' (#151) from finishline into main
Reviewed-on: #151
2026-10-22 13:49:32 +02:00
khwezi 937a5595d8 Merge pull request 'Implemented audit logging' (#150) from finishline into main
Reviewed-on: #150
2026-10-22 12:37:14 +02:00
khwezi 58ce606410 Merge pull request 'Enforced approval rights' (#149) from finishline into main
Reviewed-on: #149
2026-10-22 11:26:49 +02:00
khwezi a98bfa4308 Merge pull request 'Refactored project view into modal' (#148) from finishline into main
Reviewed-on: #148
2026-10-22 09:42:26 +02:00
khwezi 6e56a4c223 Merge pull request 'Refactored projects page, fixed security user modal' (#147) from finishline into main
Reviewed-on: #147
2026-10-22 07:40:34 +02:00
khwezi e38edd24a0 Merge pull request 'Finishline' (#146) from finishline into main
Reviewed-on: #146
2026-10-22 06:24:18 +02:00
khwezi 1ac0018926 Merge pull request 'Finishline' (#145) from finishline into main
Reviewed-on: #145
2026-10-21 11:07:27 +02:00
khwezi 5ad59f6569 Merge pull request 'Restructured .net solutions' (#144) from finishline into main
Reviewed-on: #144
2026-10-21 10:41:52 +02:00
khwezi 71a0abf0b2 Merge pull request 'Fixed new user username formating' (#143) from finishline into main
Reviewed-on: #143
2026-10-21 10:18:50 +02:00
khwezi 627885e36b Merge pull request 'Finishline' (#142) from finishline into main
Reviewed-on: #142
2026-10-21 08:55:56 +02:00
khwezi 447ffb366e Merge pull request 'Implemented rbac' (#141) from finishline into main
Reviewed-on: #141
2026-10-11 11:17:48 +02:00
khwezi a9bb319eb1 Merge pull request 'Fixed skoruba api value assignment in docker compose' (#140) from finishline into main
Reviewed-on: #140
2026-10-11 08:29:59 +02:00
khwezi bbba869cdd Merge pull request 'Fixed skoruba api address configs' (#139) from finishline into main
Reviewed-on: #139
2026-10-11 08:18:06 +02:00
khwezi 5ae2622454 Merge pull request 'Finishline' (#138) from finishline into main
Reviewed-on: #138
2026-10-11 08:02:57 +02:00
khwezi bd0f03344d Merge pull request 'Finishline' (#137) from finishline into main
Reviewed-on: #137
2026-10-11 07:50:36 +02:00
khwezi 1af860fdc1 Merge pull request 'Redesigned nav bar' (#136) from finishline into main
Reviewed-on: #136
2026-10-04 18:27:34 +02:00
khwezi c04ae9a933 Merge pull request 'Refactored org modal logic' (#135) from finishline into main
Reviewed-on: #135
2026-10-04 18:22:13 +02:00
khwezi 0e853811a9 Merge pull request 'Finishline' (#134) from finishline into main
Reviewed-on: #134
2026-10-04 18:05:14 +02:00
khwezi 3a3d5daefc Merge pull request 'Refactored org create and update models' (#133) from finishline into main
Reviewed-on: #133
2026-10-04 11:13:57 +02:00
khwezi fe9b9052e9 Merge pull request 'Refactored tenant organisatio listing in tenant page' (#132) from finishline into main
Reviewed-on: #132
2026-10-04 11:09:29 +02:00
khwezi d25545ddad Merge pull request 'Corrected boolean logic on bypass filter logic' (#131) from finishline into main
Reviewed-on: #131
2026-10-04 10:58:11 +02:00
khwezi 7effc32498 Merge pull request 'Fine tuned organisation onboarding and tenant management' (#130) from finishline into main
Reviewed-on: #130
2026-10-04 10:35:31 +02:00
khwezi 8909bcf5c4 Merge pull request 'Refactored organisation entities and endpoints' (#129) from finishline into main
Reviewed-on: #129
2026-10-04 10:12:05 +02:00
khwezi 411e603f51 Merge pull request 'Added tenant management' (#128) from finishline into main
Reviewed-on: #128
2026-10-04 09:14:34 +02:00
khwezi 138ef84973 Merge pull request 'Hardened API and catered for Partner role query filter bypass' (#127) from finishline into main
Reviewed-on: #127
2026-10-04 08:19:48 +02:00
khwezi 312721bf51 Merge pull request 'Redesigned dash' (#126) from finishline into main
Reviewed-on: #126
2026-09-29 12:24:27 +02:00
khwezi dd59294911 Merge pull request 'Refactored dashboard' (#125) from finishline into main
Reviewed-on: #125
2026-09-29 11:53:13 +02:00
khwezi 28a3a17a15 Merge pull request 'Fixed missing dependency' (#124) from finishline into main
Reviewed-on: #124
2026-09-28 16:47:20 +02:00
khwezi 4c2b0cd7d5 Merge pull request 'Added dockerfile to partner ui' (#123) from finishline into main
Reviewed-on: #123
2026-09-28 16:33:44 +02:00
khwezi 275fd109e6 Merge pull request 'Finishline' (#122) from finishline into main
Reviewed-on: #122
2026-09-28 16:27:54 +02:00
khwezi 248cc2fa2d Merge pull request 'Fixed timeline height' (#121) from finishline into main
Reviewed-on: #121
2026-09-28 11:56:15 +02:00
khwezi 761d14beb9 Merge pull request 'Light projects page refactor' (#120) from finishline into main
Reviewed-on: #120
2026-09-28 11:45:35 +02:00
khwezi 931e1a8698 Merge pull request 'Refactored projects and milestones page' (#119) from finishline into main
Reviewed-on: #119
2026-09-28 11:34:32 +02:00
khwezi aff58caeb0 Merge pull request 'Removed duplicate filter in settings page' (#118) from finishline into main
Reviewed-on: #118
2026-09-28 11:13:41 +02:00
khwezi 1c5080bf04 Merge pull request 'Added Role column to user directory' (#117) from finishline into main
Reviewed-on: #117
2026-09-28 10:59:34 +02:00
khwezi 1fb157aa0a Merge pull request 'Finishline' (#116) from finishline into main
Reviewed-on: #116
2026-09-28 10:40:39 +02:00
khwezi 0adfae1616 Merge pull request 'Refactored get compliance item endpoint' (#115) from finishline into main
Reviewed-on: #115
2026-09-27 15:06:14 +02:00
khwezi 4856b6e6f4 Merge pull request 'fixed create site visit error' (#114) from finishline into main
Reviewed-on: #114
2026-09-27 14:41:30 +02:00
khwezi 0ba47269aa Merge pull request 'Finishline' (#113) from finishline into main
Reviewed-on: #113
2026-09-27 14:36:16 +02:00
khwezi 5e25fd7a11 Merge pull request 'fixed new change request logic' (#112) from finishline into main
Reviewed-on: #112
2026-09-27 14:16:31 +02:00
khwezi f8fff04065 Merge pull request 'Fixed mismatched endpoint calling' (#111) from finishline into main
Reviewed-on: #111
2026-09-27 14:08:14 +02:00
khwezi aca5882ad3 Merge pull request 'Fixed change management rendering error' (#110) from finishline into main
Reviewed-on: #110
2026-09-27 13:43:34 +02:00
khwezi 6b027193d1 Merge pull request 'Updated api usage, corrected table designs' (#109) from finishline into main
Reviewed-on: #109
2026-09-27 13:32:01 +02:00
khwezi 81702a4bf8 Merge pull request 'Refactored docker compose' (#108) from finishline into main
Reviewed-on: #108
2026-09-27 13:08:13 +02:00
khwezi 5eeb1cbfc7 Merge pull request 'Refactored endpoints' (#107) from finishline into main
Reviewed-on: #107
2026-09-27 13:07:18 +02:00
khwezi fb00986f59 Merge pull request 'Issue creation, reolution is optional' (#106) from finishline into main
Reviewed-on: #106
2026-09-27 12:56:05 +02:00
khwezi 0338737385 Merge pull request 'Fixed UI inconsistencies' (#105) from finishline into main
Reviewed-on: #105
2026-09-27 12:04:56 +02:00
khwezi 82c0936b4c Merge pull request 'Added, beneficiaries, site visits and change management' (#104) from finishline into main
Reviewed-on: #104
2026-09-27 11:42:03 +02:00
khwezi 5d2a886f6f Merge pull request 'Removed settings page mock data and replaced with api calls' (#103) from finishline into main
Reviewed-on: #103
2026-09-27 10:58:50 +02:00
khwezi dc4047ca6d Merge pull request 'Added audit log endpoint' (#102) from finishline into main
Reviewed-on: #102
2026-09-27 10:13:33 +02:00
khwezi 46fe14d9ef Merge pull request 'Revised all UIs' (#101) from finishline into main
Reviewed-on: #101
2026-09-26 16:37:16 +02:00
khwezi f17368d939 Merge pull request 'Added budget update' (#100) from finishline into main
Reviewed-on: #100
2026-09-26 15:15:08 +02:00
khwezi 7aea3fa8de Merge pull request 'Added postcss' (#99) from finishline into main
Reviewed-on: #99
2026-09-26 13:55:49 +02:00
khwezi d43eb17aa3 Merge pull request 'Implemented data export' (#98) from finishline into main
Reviewed-on: #98
2026-09-26 13:46:36 +02:00
khwezi 01547d8c3f Merge pull request 'Fixed portfolio retrieval' (#97) from finishline into main
Reviewed-on: #97
2026-09-26 12:04:37 +02:00
khwezi 18dd9131cb Merge pull request 'Added missing instrument endpoints' (#96) from finishline into main
Reviewed-on: #96
2026-09-26 11:21:27 +02:00
khwezi f82a5cef76 Merge pull request 'Fixed update organisation' (#95) from finishline into main
Reviewed-on: #95
2026-09-26 10:39:45 +02:00
khwezi 614730caab Merge pull request 'Fixed API calls on governance page' (#94) from finishline into main
Reviewed-on: #94
2026-09-26 10:29:52 +02:00
khwezi 9823cd7ea6 Merge pull request 'Surfaced programme rules' (#93) from finishline into main
Reviewed-on: #93
2026-09-26 07:56:38 +02:00
khwezi a7a68fc290 Merge pull request 'Finishline' (#92) from finishline into main
Reviewed-on: #92
2026-09-25 19:06:37 +02:00
khwezi 4dbc74e488 Merge pull request 'Finishline' (#91) from finishline into main
Reviewed-on: #91
2026-09-24 16:08:03 +02:00
khwezi d658497545 Merge pull request 'Finishline' (#90) from finishline into main
Reviewed-on: #90
2026-09-24 12:01:36 +02:00
khwezi b947959724 Merge pull request 'Added linkage to primary tenant entities' (#89) from finishline into main
Reviewed-on: #89
2026-09-22 07:09:20 +02:00
khwezi 58126e7573 Merge pull request 'Make retrieval of contract file auditable' (#88) from finishline into main
Reviewed-on: #88
2026-09-22 06:29:58 +02:00
khwezi 2fede443cc Merge pull request 'Added automatic audit feature' (#87) from finishline into main
Reviewed-on: #87
2026-09-22 06:11:18 +02:00
khwezi 80f220fcae Merge pull request 'Test 2 reoslutions' (#86) from garages3 into main
Reviewed-on: #86
2026-09-20 15:26:35 +02:00
khwezi 06e2c3ee6a Merge pull request 'Remediated test #1 results' (#85) from garages3 into main
Reviewed-on: #85
2026-09-20 13:52:03 +02:00
khwezi 3c0fd8844c Merge pull request 'Refactored UI endpoint usage' (#84) from garages3 into main
Reviewed-on: #84
2026-09-20 08:27:14 +02:00
khwezi b7c135f0fc Merge pull request 'Garages3' (#83) from garages3 into main
Reviewed-on: #83
2026-09-18 16:12:06 +02:00
khwezi db053e3e5f Merge pull request 'Garages3' (#82) from garages3 into main
Reviewed-on: #82
2026-09-17 07:39:34 +02:00
khwezi d7177e1a72 Merge pull request 'Removed package.json' (#81) from garages3 into main
Reviewed-on: #81
2026-09-17 07:23:54 +02:00
khwezi c67041faea Merge pull request 'Refactored UI and compose file' (#80) from garages3 into main
Reviewed-on: #80
2026-09-17 07:08:26 +02:00
khwezi 0e8a5e1169 Merge pull request 'Removed docker debug code' (#79) from garages3 into main
Reviewed-on: #79
2026-09-15 12:13:47 +02:00
khwezi 41fdb067fe Merge pull request 'Refactored ApiVersioning service registration' (#78) from garages3 into main
Reviewed-on: #78
2026-09-15 12:09:47 +02:00
khwezi b4b687dfcc Merge pull request 'Added OPTIONS header handling' (#77) from garages3 into main
Reviewed-on: #77
2026-09-15 08:52:23 +02:00
khwezi f200ccbf60 Merge pull request 'Added RequireCors() to RouteGroup' (#76) from garages3 into main
Reviewed-on: #76
2026-09-15 08:45:25 +02:00
khwezi 27f57150e1 Merge pull request 'Moved .RequireAuthentication() to endpoints, removed it from EndpointGroup' (#75) from garages3 into main
Reviewed-on: #75
2026-09-15 08:37:43 +02:00
khwezi 79c63107e9 Merge pull request 'Applied configurable CORS policy to API' (#74) from garages3 into main
Reviewed-on: #74
2026-09-15 07:30:29 +02:00
khwezi d945731252 Merge pull request 'Fixed UI' (#73) from garages3 into main
Reviewed-on: #73
2026-09-14 11:44:00 +02:00
khwezi c979a4632e Merge pull request 'Debug refactor' (#72) from garages3 into main
Reviewed-on: #72
2026-09-14 10:46:59 +02:00
khwezi 9eed3cff6b Merge pull request 'Debugs refactor' (#71) from garages3 into main
Reviewed-on: #71
2026-09-14 10:39:04 +02:00
khwezi 6db1a2f367 Merge pull request 'Garages3' (#70) from garages3 into main
Reviewed-on: #70
2026-09-14 10:34:46 +02:00
khwezi 06d9952cd7 Merge pull request 'Rebuilt pfm-api dockerfile' (#69) from garages3 into main
Reviewed-on: #69
2026-09-14 10:29:13 +02:00
khwezi 92d481f75d Merge pull request 'Light refactor' (#68) from garages3 into main
Reviewed-on: #68
2026-09-14 10:23:19 +02:00
khwezi 1c36602995 Merge pull request 'Simplified fpm-api dockerfile' (#67) from garages3 into main
Reviewed-on: #67
2026-09-14 10:21:10 +02:00
khwezi 5ae0c500ac Merge pull request 'Added debug code' (#66) from garages3 into main
Reviewed-on: #66
2026-09-14 10:13:58 +02:00
khwezi db04f800e9 Merge pull request 'Changed pfm-api dockerfile paths' (#65) from garages3 into main
Reviewed-on: #65
2026-09-14 10:10:49 +02:00
khwezi e7a19611f0 Merge pull request 'Simplified dockerfile (pfm-api)' (#64) from garages3 into main
Reviewed-on: #64
2026-09-14 10:04:10 +02:00
khwezi 5ad24da7cf Merge pull request 'Refactored compose file to assign same context to both services' (#63) from garages3 into main
Reviewed-on: #63
2026-09-14 10:02:01 +02:00
khwezi 156dae40b7 Merge pull request 'Fixed missing file copy' (#62) from garages3 into main
Reviewed-on: #62
2026-09-14 09:50:32 +02:00
khwezi 1b67d9cf8b Merge pull request 'Debug refactor on dockerfile' (#61) from garages3 into main
Reviewed-on: #61
2026-09-14 09:47:00 +02:00
khwezi 1bd05e64e5 Merge pull request 'Added better dotnet build handling' (#60) from garages3 into main
Reviewed-on: #60
2026-09-14 09:42:10 +02:00
khwezi 303dba3c6f Merge pull request 'Added more verbosity to dotnet build' (#59) from garages3 into main
Reviewed-on: #59
2026-09-14 09:35:30 +02:00
khwezi 1dd4f54a1d Merge pull request 'Added .dockerignore' (#58) from garages3 into main
Reviewed-on: #58
2026-09-14 09:32:21 +02:00
khwezi fae1e080c4 Merge pull request 'Added stream redirection to dotnet commands' (#57) from garages3 into main
Reviewed-on: #57
2026-09-14 09:14:17 +02:00
khwezi 4de23d27b3 Merge pull request 'Commented out pfm-console to see if the api image will build' (#56) from garages3 into main
Reviewed-on: #56
2026-09-14 09:06:50 +02:00
khwezi d4bce12dbb Merge pull request 'Removed stream redirection' (#55) from garages3 into main
Reviewed-on: #55
2026-09-13 18:15:33 +02:00
khwezi 299b876e1c Merge pull request 'Fixed Api docker file paths' (#54) from garages3 into main
Reviewed-on: #54
2026-09-13 18:13:13 +02:00
khwezi dde1dbe056 Merge pull request 'Added debug code to dotnet build and publish lines' (#53) from garages3 into main
Reviewed-on: #53
2026-09-13 18:09:51 +02:00
khwezi b81e6feb45 Merge pull request 'Moved api dockerfile to project folder' (#52) from garages3 into main
Reviewed-on: #52
2026-09-13 18:07:52 +02:00
khwezi 4138a1941f Merge pull request 'Corrected compose service contexts' (#51) from garages3 into main
Reviewed-on: #51
2026-09-13 18:01:52 +02:00
khwezi 17af31d810 Merge pull request 'Fixed dockerfile paths' (#50) from garages3 into main
Reviewed-on: #50
2026-09-13 17:56:19 +02:00
khwezi b85cf6a1cb Merge pull request 'Light refactor' (#49) from garages3 into main
Reviewed-on: #49
2026-09-13 17:51:45 +02:00
khwezi 81ddc11746 Merge pull request 'Refactored API dockerfile' (#48) from garages3 into main
Reviewed-on: #48
2026-09-13 17:50:29 +02:00
khwezi 5b94672ff5 Merge pull request 'Stable API run' (#47) from garages3 into main
Reviewed-on: #47
2026-09-13 17:41:42 +02:00
khwezi c535392ef1 Merge pull request 'Added debug term' (#46) from garages3 into main
Reviewed-on: #46
2026-09-12 12:22:43 +02:00
khwezi 0607de1802 Merge pull request 'Second fix' (#45) from garages3 into main
Reviewed-on: #45
2026-09-12 12:19:34 +02:00
khwezi 4c1e52e452 Merge pull request 'Light refactor' (#44) from garages3 into main
Reviewed-on: #44
2026-09-12 12:14:13 +02:00
khwezi 2414d74d1c Merge pull request 'Light refactor' (#43) from garages3 into main
Reviewed-on: #43
2026-09-12 12:10:17 +02:00
khwezi 93d9661a7f Merge pull request 'Rewrite pfm-api docker file' (#42) from garages3 into main
Reviewed-on: #42
2026-09-12 12:04:47 +02:00
khwezi dba401fe94 Merge pull request 'Fixed publish alias' (#41) from garages3 into main
Reviewed-on: #41
2026-09-12 11:58:37 +02:00
khwezi 42d73fe6cb Merge pull request 'Added error output to dotnet build line' (#40) from garages3 into main
Reviewed-on: #40
2026-09-12 11:54:57 +02:00
khwezi 1475887a7f Merge pull request 'Added working directory switch' (#39) from garages3 into main
Reviewed-on: #39
2026-09-12 11:49:52 +02:00
khwezi 730664d9c4 Merge pull request 'PFM Api docker refactor' (#38) from garages3 into main
Reviewed-on: #38
2026-09-12 11:45:32 +02:00
khwezi f38e569a34 Merge pull request 'Implemented missing file handling endpoints' (#37) from garages3 into main
Reviewed-on: #37
2026-09-12 11:26:45 +02:00
khwezi 7e79171f97 Merge pull request 'Added image name to compose file for console' (#36) from endpoints into main
Reviewed-on: #36
2026-09-11 16:24:11 +02:00
khwezi d7b868f2c1 Merge pull request 'Added more cors origins' (#35) from endpoints into main
Reviewed-on: #35
2026-09-11 16:17:07 +02:00
khwezi d43a09b371 Merge pull request 'Refactored console dockerfile' (#34) from endpoints into main
Reviewed-on: #34
2026-09-11 16:05:21 +02:00
khwezi 0dc0de1502 Merge pull request 'Fixed dockerfile paths' (#33) from endpoints into main
Reviewed-on: #33
2026-09-11 15:47:30 +02:00
khwezi de47dd4a4f Merge pull request 'Supplied defaults to compose env variables' (#32) from endpoints into main
Reviewed-on: #32
2026-09-11 15:33:47 +02:00
khwezi 2810cc669b Merge pull request 'Fixed compose syntax error' (#31) from endpoints into main
Reviewed-on: #31
2026-09-11 15:30:40 +02:00
khwezi 734af8c86f Merge pull request 'Refactored compose file to include the ui in deployment' (#30) from endpoints into main
Reviewed-on: #30
2026-09-11 15:29:21 +02:00
khwezi a754c42d14 Merge pull request 'Updated Console compose file to use variables' (#29) from endpoints into main
Reviewed-on: #29
2026-09-11 15:19:31 +02:00
khwezi bd7ca9372f Merge pull request 'Added specific origin' (#28) from endpoints into main
Reviewed-on: #28
2026-09-11 14:59:34 +02:00
khwezi 0d01d91ab4 Merge pull request 'Added cors after routes' (#27) from endpoints into main
Reviewed-on: #27
2026-09-11 14:54:47 +02:00
khwezi 2c817a8818 Merge pull request 'Endpoints' (#26) from endpoints into main
Reviewed-on: #26
2026-09-11 14:35:02 +02:00
khwezi 4c3a96917b Merge pull request 'Improved results handling in service layer' (#25) from endpoints into main
Reviewed-on: #25
2026-09-11 10:33:47 +02:00
khwezi e5c3a75795 Merge pull request 'Stable run with validations' (#24) from endpoints into main
Reviewed-on: #24
2026-09-06 10:49:57 +02:00
khwezi 1bc7e65960 Merge pull request 'Added tooling' (#23) from endpoints into main
Reviewed-on: #23
2026-09-05 18:27:35 +02:00
khwezi 094126d84b Merge pull request 'Fixed endpointname uniqueness' (#22) from endpoints into main
Reviewed-on: #22
2026-09-05 13:07:41 +02:00
khwezi 2506241a25 Merge pull request 'Fixed endpoint uniqueness' (#21) from endpoints into main
Reviewed-on: #21
2026-09-05 12:58:17 +02:00
khwezi cfe3530639 Merge pull request 'Endpoints' (#20) from endpoints into main
Reviewed-on: #20
2026-09-05 12:50:04 +02:00
khwezi 985557bee4 Merge pull request 'Implemented caching on all get endpoints and service layer' (#19) from endpoints into main
Reviewed-on: #19
2026-09-05 08:30:16 +02:00
khwezi f0b8b3051a Merge pull request 'Improved getusers cache key' (#18) from endpoints into main
Reviewed-on: #18
2026-09-04 06:08:39 +02:00
khwezi 6c88b9e533 Merge pull request 'Endpoints' (#17) from endpoints into main
Reviewed-on: #17
2026-09-04 05:54:30 +02:00
khwezi 7d67408d2c Merge pull request 'Additional redis configs' (#16) from endpoints into main
Reviewed-on: #16
2026-09-04 05:39:44 +02:00
khwezi d1d58a6183 Merge pull request 'Added service level caching to IdentityService, Communication and Evidence' (#15) from endpoints into main
Reviewed-on: #15
2026-09-03 16:38:08 +02:00
khwezi 60ad154841 Merge pull request 'Implemented distributed caching' (#14) from endpoints into main
Reviewed-on: #14
2026-09-03 11:09:52 +02:00
khwezi 2c13170859 Merge pull request 'Endpoints' (#13) from endpoints into main
Reviewed-on: #13
2026-09-02 16:14:34 +02:00
khwezi d87c8aa3c9 Merge pull request 'Updated configurations' (#12) from endpoints into main
Reviewed-on: #12
2026-09-02 09:45:38 +02:00
khwezi 6af7815ad1 Merge pull request 'Removed node tooling install' (#11) from endpoints into main
Reviewed-on: #11
2026-09-02 09:30:06 +02:00
khwezi a169f6c3f6 Merge pull request 'Endpoints' (#10) from endpoints into main
Reviewed-on: #10
2026-09-02 09:28:17 +02:00
khwezi 65ea753c19 Merge pull request 'Endpoints' (#9) from endpoints into main
Reviewed-on: #9
2026-09-01 12:29:47 +02:00
khwezi e6d2663265 Merge pull request 'Endpoints' (#8) from endpoints into main
Reviewed-on: #8
2026-08-30 17:47:24 +02:00
khwezi 91b5814b60 Merge pull request 'Endpoints' (#7) from endpoints into main
Reviewed-on: #7
2026-08-30 16:27:37 +02:00
khwezi 0c4c848568 Merge pull request 'Endpoints' (#6) from endpoints into main
Reviewed-on: #6
2026-08-30 16:11:53 +02:00
khwezi fb05150ebf Merge pull request 'Endpoints' (#5) from endpoints into main
Reviewed-on: #5
2026-08-30 15:47:32 +02:00
khwezi 2f9c20020b Merge pull request 'Reogranised API and Security assets' (#4) from endpoints into main
Reviewed-on: #4
2026-08-30 15:09:32 +02:00
khwezi a771957db3 Merge pull request 'Endpoints' (#3) from endpoints into main
Reviewed-on: #3
2026-08-30 14:54:58 +02:00
khwezi 46267a2a34 Merge pull request 'Endpoints' (#2) from endpoints into main
Reviewed-on: #2
2026-08-30 14:26:08 +02:00
khwezi a7f360cc2a Merge pull request 'Security' (#1) from security into main
Reviewed-on: #1
2026-08-21 11:47:36 +02:00
hermes 67c08b40ca Refactored gitignore 2026-08-21 11:45:33 +02:00
25 changed files with 569 additions and 657 deletions
@@ -182,7 +182,7 @@ public sealed class GovernanceService(IDbContextFactory<ApplicationDbContext> co
if (!await context.Users.AnyAsync(u => u.Id == request.OrganisationUser, cancellationToken))
return Result.Fail(ValidationError.Create("Organisation user not found"));
if (!await context.TenantUsers.AnyAsync(u => u.UserId == request.OrganisationUser && u.TenantId == tenantContext.TenantId, cancellationToken))
if (!tenantContext.IsAdmin && !await context.TenantUsers.AnyAsync(u => u.UserId == request.OrganisationUser && u.TenantId == tenantContext.TenantId, cancellationToken))
return Result.Fail(ValidationError.Create("Organisation user does not belong to the current tenant"));
var changedTotal = await context.Organisations
@@ -159,12 +159,7 @@ public sealed class GrantsService(IDbContextFactory<ApplicationDbContext> contex
await using var context = await contextFactory.CreateDbContextAsync(cancellationToken);
if (!await context.Projects.AnyAsync(p => p.Id == request.ProjectId, cancellationToken))
return Result.Fail(NotFoundError.New);
var hasApprovedAward = await context.Awards
.AnyAsync(a => a.ProjectId == request.ProjectId && a.Status == ApprovalStatus.Approved, cancellationToken);
if (!hasApprovedAward)
return Result.Fail(ValidationError.Create("Cannot create budget: The project's linked award is not approved or does not exist."));
return Result.Fail(NotFoundError.New);
var budget = new Domain.Entities.Budget
{
@@ -186,7 +186,6 @@ export const AppLayout: React.FC = () => {
useQuery({
queryKey: ['users-directory'],
queryFn: () => getUsers(1, 200),
staleTime: 5 * 60 * 1000,
});
// Query tenant endpoint to resolve tenant name if not provided in claims or profile
@@ -194,7 +193,6 @@ export const AppLayout: React.FC = () => {
queryKey: ['active-tenant-header', tenantId],
queryFn: () => getTenant(tenantId!),
enabled: !!tenantId && !claims?.tenant && !user?.tenant && !authTenantName,
staleTime: 5 * 60 * 1000,
});
// Ensure current logged-in user is registered in the email cache
@@ -1247,7 +1247,7 @@ export const ContractsManagement: React.FC<ContractsManagementProps> = ({
{Number(contractToUpdate?.status) === ContractStatus.Active ? (
<div className="w-full bg-[#151b2c] border border-emerald-500/30 rounded-xl px-3 py-2 text-xs text-emerald-300 font-semibold flex items-center gap-1.5 cursor-default">
<Lock className="w-3.5 h-3.5 text-emerald-400" />
<span>Active / Executed (Immutable - Cannot Be Changed)</span>
<span>Active / Executed</span>
</div>
) : (
(() => {
@@ -807,7 +807,7 @@ export const EvidenceReviewQueue: React.FC<EvidenceReviewQueueProps> = ({
title="Approved evidence is immutable. Status cannot be modified."
>
<Lock className="w-3.5 h-3.5 text-emerald-400" />
<span>Approved (Immutable)</span>
<span>Approved</span>
</span>
) : (
<>
@@ -58,7 +58,6 @@ export const UserSearchPicker: React.FC<UserSearchPickerProps> = ({
const { data: users = [], isLoading } = useQuery<User[]>({
queryKey: ['users-directory'],
queryFn: () => getUsers(1, 200),
staleTime: 5 * 60 * 1000,
});
// Filter active users and exclude the current logged-in user so they can never message themselves
@@ -124,7 +124,6 @@ export function useInAppNotifications() {
},
// Live polling every 30 seconds
refetchInterval: 30000,
staleTime: 10000,
});
// InApp notifications (strictly platform === InApp 5) for Bell & Counters
@@ -4,15 +4,38 @@ import { bumpMutationEpoch } from '../services/httpClient';
export const queryClient = new QueryClient({
defaultOptions: {
queries: {
staleTime: 1000 * 60 * 2, // 2 minutes fresh in memory
gcTime: 1000 * 60 * 10, // 10 minutes cache retention
refetchOnWindowFocus: false,
refetchOnReconnect: true,
// Remove caching from all pages: zero stale time and zero cache retention
// Whenever a component needs data, it must call its corresponding endpoint(s)
staleTime: 0,
gcTime: 0,
refetchOnMount: 'always',
refetchOnWindowFocus: 'always',
refetchOnReconnect: 'always',
retry: 1,
},
},
});
// Retain caching ONLY for sync status, auth token, roles and grants
const RETAINED_CACHE_OPTIONS = {
staleTime: 5 * 60 * 1000,
gcTime: 10 * 60 * 1000,
refetchOnMount: false,
refetchOnWindowFocus: false,
};
queryClient.setQueryDefaults(['roles'], RETAINED_CACHE_OPTIONS);
queryClient.setQueryDefaults(['system-roles'], RETAINED_CACHE_OPTIONS);
queryClient.setQueryDefaults(['role-claims'], RETAINED_CACHE_OPTIONS);
queryClient.setQueryDefaults(['user-roles'], RETAINED_CACHE_OPTIONS);
queryClient.setQueryDefaults(['user-claims'], RETAINED_CACHE_OPTIONS);
queryClient.setQueryDefaults(['grants'], RETAINED_CACHE_OPTIONS);
queryClient.setQueryDefaults(['user-grants'], RETAINED_CACHE_OPTIONS);
queryClient.setQueryDefaults(['user-sync'], RETAINED_CACHE_OPTIONS);
queryClient.setQueryDefaults(['sync-status'], RETAINED_CACHE_OPTIONS);
queryClient.setQueryDefaults(['auth'], RETAINED_CACHE_OPTIONS);
queryClient.setQueryDefaults(['auth-token'], RETAINED_CACHE_OPTIONS);
/**
* Invalidates queries while simultaneously bumping the mutation epoch.
* This guarantees that the background refetch generates a fresh query parameter (_v)
@@ -1020,7 +1020,7 @@ export const ChangeManagementPage: React.FC = () => {
{Number(cr.status) === ApprovalStatus.Approved ? (
<span className="inline-flex items-center gap-1 text-xs font-semibold text-emerald-400 bg-emerald-500/10 px-2.5 py-1 rounded-lg border border-emerald-500/20 cursor-default" title="Approved and enacted change requests are immutable.">
<Lock className="w-3 h-3 text-emerald-400" />
<span>Resolved &amp; Enacted (Immutable)</span>
<span>Resolved &amp; Enacted</span>
</span>
) : (
<>
@@ -1528,7 +1528,7 @@ export const ChangeManagementPage: React.FC = () => {
title="Approved and enacted change requests are immutable."
>
<Lock className="w-3.5 h-3.5 text-emerald-400" />
<span>Approved &amp; Enacted (Immutable - Cannot Be Changed)</span>
<span>Approved &amp; Enacted</span>
</div>
) : (
<select
@@ -11,6 +11,7 @@ import {
Edit2,
Mail,
Shield,
ShieldCheck,
Layers,
ChevronRight,
Search,
@@ -24,6 +25,7 @@ import {
UserPlus,
ChevronDown,
ChevronUp,
CheckCircle,
CheckCircle2,
Loader2,
Lock,
@@ -69,6 +71,7 @@ import { ExportButton } from '../components/ui/ExportButton';
import { ExportColumn } from '../utils/exportUtils';
import { useAuth } from '../auth/AuthProvider';
import { useRbac } from '../auth/rbac';
import { bumpMutationEpoch } from '../services/httpClient';
import { useTableSort, SortableTh } from '../utils/tableSort';
import { FundingInstrumentsSection, InstructionsSection } from '../components/governance/InstructionsSection';
import { RulesSection } from '../components/governance/RulesSection';
@@ -134,8 +137,8 @@ type ProgrammeFormValues = z.infer<typeof programmeSchema>;
export const GovernancePage: React.FC = () => {
const queryClient = useQueryClient();
const { sub, user, claims } = useAuth();
const { canAdd, canApprove, canExport, canApproveRecord, isMaker } = useRbac();
const { sub, user, claims, roles, tenantId } = useAuth();
const { canAdd, canApprove, canExport, canApproveRecord, isMaker, isUnrestricted } = useRbac();
const currentUserId = sub || user?.id || '';
const { success, error: toastError } = useToast();
@@ -241,7 +244,6 @@ export const GovernancePage: React.FC = () => {
const { data: usersDirectory = [] } = useQuery({
queryKey: ['users-directory'],
queryFn: () => getUsers(1, 200),
staleTime: 5 * 60 * 1000,
});
const { data: systemRoles = [] } = useQuery<Role[]>({
@@ -263,6 +265,39 @@ export const GovernancePage: React.FC = () => {
});
}, [usersDirectory]);
const modalExecutiveUsers = React.useMemo(() => {
const list: User[] = [...executiveAndManagerUsers];
const linkedUserId =
editingOrg?.userId ||
editingOrg?.UserId ||
editingOrg?.organisationUser ||
editingOrg?.OrganisationUser ||
'';
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, editingOrg?.organisationUser]);
// Quick Add Executive User Subform state
const [isQuickAddUserOpen, setIsQuickAddUserOpen] = useState(false);
const [quickUserEmail, setQuickUserEmail] = useState('');
@@ -580,7 +615,7 @@ export const GovernancePage: React.FC = () => {
defaultValues: {
name: '',
portfolioId: portfolios[0]?.id ? Number(portfolios[0].id) : 201,
status: ApprovalStatus.Approved,
status: ApprovalStatus.Pending,
ownerEmail: effectiveUserEmail,
},
});
@@ -595,6 +630,7 @@ export const GovernancePage: React.FC = () => {
registrationNo: data.registrationNo,
email: data.email,
organisationUser: data.organisationUser,
userId: data.organisationUser,
type: Number(data.type),
status: data.status !== undefined ? Number(data.status) : ActivityStatus.Active,
});
@@ -608,6 +644,7 @@ export const GovernancePage: React.FC = () => {
});
},
onSuccess: () => {
bumpMutationEpoch();
queryClient.invalidateQueries({ queryKey: ['organisations'] });
success(editingOrg ? 'Organisation updated' : 'Organisation registered successfully');
setIsOrgModalOpen(false);
@@ -639,6 +676,7 @@ export const GovernancePage: React.FC = () => {
});
},
onSuccess: () => {
bumpMutationEpoch();
queryClient.invalidateQueries({ queryKey: ['portfolios'] });
success(editingPortfolio ? 'Portfolio updated successfully' : 'Portfolio created successfully');
setIsPortfolioModalOpen(false);
@@ -650,15 +688,43 @@ export const GovernancePage: React.FC = () => {
},
});
const approveProgrammeMutation = useMutation({
mutationFn: async (pr: Programme) => {
if (isMaker(pr.createdBy)) {
throw new Error('Maker-Checker policy: Record creator cannot approve their own programme.');
}
return updateProgramme({
programmeId: pr.id,
name: pr.name || '',
portfolioId: Number(pr.portfolioId),
status: ApprovalStatus.Approved,
ownerId: pr.ownedBy,
});
},
onSuccess: () => {
bumpMutationEpoch();
queryClient.invalidateQueries({ queryKey: ['programmes'] });
queryClient.invalidateQueries({ queryKey: ['programme'] });
success('Programme Approved', 'Programme governance status successfully updated to Approved.');
},
onError: (err: unknown) => {
toastError('Programme Approval Failed', err);
},
});
const programmeMutation = useMutation({
mutationFn: (data: ProgrammeFormValues) => {
const resolvedOwnerId = resolveOwnerId(data.ownerEmail, editingProgramme?.ownedBy);
if (editingProgramme) {
const targetStatus = (data.status !== undefined ? Number(data.status) : 1) as ApprovalStatus;
if (targetStatus === ApprovalStatus.Approved && isMaker(editingProgramme.createdBy)) {
throw new Error('Maker-Checker policy violation: Record creators cannot approve their own programmes.');
}
return updateProgramme({
programmeId: editingProgramme.id,
name: data.name,
portfolioId: Number(data.portfolioId),
status: (data.status !== undefined ? Number(data.status) : 1) as ApprovalStatus,
status: targetStatus,
ownerId: resolvedOwnerId,
});
}
@@ -669,7 +735,9 @@ export const GovernancePage: React.FC = () => {
});
},
onSuccess: () => {
bumpMutationEpoch();
queryClient.invalidateQueries({ queryKey: ['programmes'] });
queryClient.invalidateQueries({ queryKey: ['programme'] });
success(editingProgramme ? 'Programme updated successfully' : 'Programme created successfully');
setIsProgrammeModalOpen(false);
setEditingProgramme(null);
@@ -707,11 +775,28 @@ export const GovernancePage: React.FC = () => {
org.createdBy ||
'';
const matchedUser = targetUserId
? usersDirectory.find((u) => u.id && String(u.id).toLowerCase() === String(targetUserId).toLowerCase())
: null;
let selectedUserId = '';
if (targetUserId) {
const targetStr = String(targetUserId).toLowerCase();
const 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);
}
}
}
orgForm.reset({
name: org.name || '',
@@ -755,7 +840,7 @@ export const GovernancePage: React.FC = () => {
programmeForm.reset({
name: '',
portfolioId: portfolios[0]?.id ? Number(portfolios[0].id) : 201,
status: ApprovalStatus.Approved,
status: ApprovalStatus.Pending,
ownerEmail: effectiveUserEmail,
});
setIsProgrammeModalOpen(true);
@@ -833,6 +918,7 @@ export const GovernancePage: React.FC = () => {
status: (p) => getApprovalStatusBadge(p.status).label,
createdAt: (p) => p.createdAt,
ownedBy: (p) => getUserEmail(p.ownedBy),
createdBy: (p) => getUserEmail(p.createdBy),
});
// Export Columns Definitions
@@ -863,6 +949,7 @@ export const GovernancePage: React.FC = () => {
format: (id) => programmes.filter((pr) => String(pr.portfolioId) === String(id)).length,
},
{ header: 'Owner Email', key: 'ownedBy', format: (val) => getUserEmail(val) },
{ header: 'Created By (Maker)', key: 'createdBy', format: (val) => getUserEmail(val) },
{ header: 'Creation Date', key: 'createdAt', format: (val) => formatDate(val) },
{ header: 'Description', key: 'description', format: (val) => val || '—' },
];
@@ -881,6 +968,7 @@ export const GovernancePage: React.FC = () => {
{ header: 'Governance Status', key: 'status', format: (val) => getApprovalStatusBadge(val).label },
{ header: 'Creation Date', key: 'createdAt', format: (val) => formatDate(val) },
{ header: 'Owner Email', key: 'ownedBy', format: (val) => getUserEmail(val) },
{ header: 'Created By (Maker)', key: 'createdBy', format: (val) => getUserEmail(val) },
];
return (
@@ -1382,19 +1470,22 @@ export const GovernancePage: React.FC = () => {
<SortableTh column="ownedBy" currentColumn={progSortCol} direction={progSortDir} onSort={handleProgSort} className="py-3.5 px-4">
Owner Email
</SortableTh>
<SortableTh column="createdBy" currentColumn={progSortCol} direction={progSortDir} onSort={handleProgSort} className="py-3.5 px-4">
Created By
</SortableTh>
<th className="py-3.5 px-5 text-right font-medium">Actions</th>
</tr>
</thead>
<tbody className="divide-y divide-slate-800/60">
{loadingProgrammes ? (
<tr>
<td colSpan={6} className="py-8 text-center text-slate-500">
<td colSpan={7} className="py-8 text-center text-slate-500">
Loading programmes...
</td>
</tr>
) : sortedProgrammes.length === 0 ? (
<tr>
<td colSpan={6} className="py-8 text-center text-slate-500">
<td colSpan={7} className="py-8 text-center text-slate-500">
No programmes found matching query.
</td>
</tr>
@@ -1402,6 +1493,7 @@ export const GovernancePage: React.FC = () => {
sortedProgrammes.map((pr) => {
const port = portfolios.find((p) => String(p.id) === String(pr.portfolioId));
const statusBadge = getApprovalStatusBadge(pr.status);
const isApproved = Number(pr.status) === ApprovalStatus.Approved;
return (
<tr key={pr.id} className="hover:bg-slate-800/40 transition-colors">
<td className="py-3.5 px-5">
@@ -1420,8 +1512,49 @@ export const GovernancePage: React.FC = () => {
<td className="py-3.5 px-4 font-mono text-slate-300 font-medium text-[11px]">
{getUserEmail(pr.ownedBy)}
</td>
<td className="py-3.5 px-4 font-mono text-slate-400 text-[11px]">
{getUserEmail(pr.createdBy)}
</td>
<td className="py-3.5 px-5 text-right">
<div className="flex items-center justify-end gap-1.5">
<div className="flex items-center justify-end gap-1.5 flex-wrap sm:flex-nowrap">
{isApproved ? (
<span
className="inline-flex items-center gap-1 text-xs font-semibold text-emerald-400 bg-emerald-500/10 px-2.5 py-1 rounded-lg border border-emerald-500/20 cursor-default"
title="Approved programmes cannot be modified."
>
<Lock className="w-3 h-3 text-emerald-400" />
<span>Approved</span>
</span>
) : (
<>
<button
type="button"
onClick={() => openEditProgramme(pr)}
className="inline-flex items-center gap-1 text-xs font-semibold text-slate-200 hover:text-white bg-slate-900/80 hover:bg-slate-800 border border-slate-700/80 px-2.5 py-1 rounded-lg transition shadow-xs cursor-pointer"
title="Edit Programme"
>
<Edit2 className="w-3 h-3" />
Edit
</button>
{canApproveRecord('governance', pr.createdBy) && (
<button
type="button"
disabled={approveProgrammeMutation.isPending}
onClick={() => approveProgrammeMutation.mutate(pr)}
className="inline-flex items-center gap-1 text-xs font-semibold text-emerald-300 hover:text-white bg-emerald-500/10 hover:bg-emerald-500/20 border border-emerald-500/30 px-2.5 py-1 rounded-lg transition shadow-xs cursor-pointer"
title="Approve Programme"
>
<CheckCircle className="w-3 h-3" />
<span>Approve</span>
</button>
)}
{canApprove('governance') && isMaker(pr.createdBy) && (
<span className="text-[10px] text-amber-400 font-mono italic px-1.5 py-0.5 bg-amber-500/10 border border-amber-500/20 rounded">
Maker-Checker: Record creator cannot approve
</span>
)}
</>
)}
<button
type="button"
onClick={() => {
@@ -1458,14 +1591,6 @@ export const GovernancePage: React.FC = () => {
<Scale className="w-3 h-3" />
Rules
</button>
<button
type="button"
onClick={() => openEditProgramme(pr)}
className="inline-flex items-center gap-1 text-xs font-semibold text-slate-200 hover:text-white bg-slate-900/80 hover:bg-slate-800 border border-slate-700/80 px-2.5 py-1 rounded-lg transition shadow-xs cursor-pointer"
>
<Edit2 className="w-3 h-3" />
Edit
</button>
</div>
</td>
</tr>
@@ -1640,7 +1765,7 @@ export const GovernancePage: React.FC = () => {
className="w-full px-3 py-2 text-xs border border-slate-700/80 rounded-xl focus:outline-none focus:ring-1 focus:ring-blue-500 bg-[#090d16] text-white font-medium"
>
<option value="">-- Select linked executive or manager account --</option>
{executiveAndManagerUsers.map((u) => (
{modalExecutiveUsers.map((u) => (
<option key={u.id} value={u.id}>
{u.email || u.id} ({u.role || 'Executive'})
</option>
@@ -2004,18 +2129,18 @@ export const GovernancePage: React.FC = () => {
placement="top"
/>
</div>
{selectedProgrammeForDetail && Number(selectedProgrammeForDetail.status) === ApprovalStatus.Approved ? (
{editingProgramme && Number(editingProgramme.status) === ApprovalStatus.Approved ? (
<div
className="w-full px-3 py-2 text-xs border border-emerald-500/30 bg-emerald-500/10 rounded-xl text-emerald-300 font-semibold flex items-center gap-1.5 cursor-default"
title="Approved programmes are immutable."
title="Approved programmes cannot be modified."
>
<Lock className="w-3.5 h-3.5 text-emerald-400" />
<span>Approved (Immutable - Cannot Be Changed)</span>
<span>Approved</span>
</div>
) : (
(() => {
const canApproveProgramme = canApproveRecord('governance', selectedProgrammeForDetail?.createdBy);
const isSelf = isMaker(selectedProgrammeForDetail?.createdBy);
const canApproveProgramme = canApproveRecord('governance', editingProgramme?.createdBy);
const isSelf = isMaker(editingProgramme?.createdBy);
return (
<select
@@ -2717,13 +2842,38 @@ export const GovernancePage: React.FC = () => {
<span className="text-slate-400 font-semibold block text-[11px] uppercase tracking-wider mb-1">Programme Title</span>
<span className="text-white font-semibold text-sm block">{programmeFreshDetail?.name || selectedProgrammeForDetail?.name}</span>
</div>
<span className={`inline-flex items-center px-2.5 py-0.5 rounded-full text-[10px] font-semibold border whitespace-nowrap ${getApprovalStatusBadge(programmeFreshDetail?.status ?? selectedProgrammeForDetail?.status ?? ApprovalStatus.Approved).className}`}>
{getApprovalStatusBadge(programmeFreshDetail?.status ?? selectedProgrammeForDetail?.status ?? ApprovalStatus.Approved).label}
</span>
<div className="flex items-center gap-2">
<span className={`inline-flex items-center px-2.5 py-0.5 rounded-full text-[10px] font-semibold border whitespace-nowrap ${getApprovalStatusBadge(programmeFreshDetail?.status ?? selectedProgrammeForDetail?.status ?? ApprovalStatus.Approved).className}`}>
{getApprovalStatusBadge(programmeFreshDetail?.status ?? selectedProgrammeForDetail?.status ?? ApprovalStatus.Approved).label}
</span>
{selectedProgrammeForDetail && Number(programmeFreshDetail?.status ?? selectedProgrammeForDetail.status) !== ApprovalStatus.Approved && (
canApproveRecord('governance', selectedProgrammeForDetail.createdBy) ? (
<button
type="button"
disabled={approveProgrammeMutation.isPending}
onClick={async () => {
await approveProgrammeMutation.mutateAsync(selectedProgrammeForDetail);
setIsProgrammeDetailModalOpen(false);
setSelectedProgrammeForDetail(null);
}}
className="inline-flex items-center gap-1 text-xs font-semibold text-emerald-300 hover:text-white bg-emerald-500/10 hover:bg-emerald-500/20 border border-emerald-500/30 px-2.5 py-1 rounded-lg transition shadow-xs cursor-pointer"
>
<CheckCircle className="w-3.5 h-3.5" />
<span>Approve Programme</span>
</button>
) : (
isMaker(selectedProgrammeForDetail.createdBy) && canApprove('governance') && (
<span className="text-[10px] text-amber-400 font-mono italic px-1.5 py-0.5 bg-amber-500/10 border border-amber-500/20 rounded">
Maker-Checker: Record creator cannot approve
</span>
)
)
)}
</div>
</div>
{/* Core Details Grid: Owner & Creation Date */}
<div className="grid grid-cols-1 sm:grid-cols-2 gap-3 text-xs">
{/* Core Details Grid: Owner, Maker, Creation Date */}
<div className="grid grid-cols-1 sm:grid-cols-3 gap-3 text-xs">
{/* Creation Date */}
<div className="p-3 bg-[#080d1a] rounded-lg border border-slate-800 flex items-center gap-2.5">
<Calendar className="w-4 h-4 text-slate-400 shrink-0" />
@@ -2745,6 +2895,17 @@ export const GovernancePage: React.FC = () => {
</span>
</div>
</div>
{/* Maker */}
<div className="p-3 bg-[#080d1a] rounded-lg border border-slate-800 flex items-center gap-2.5">
<ShieldCheck className="w-4 h-4 text-slate-400 shrink-0" />
<div className="min-w-0 flex-1">
<span className="text-slate-500 font-medium block text-[10px] uppercase tracking-wider">Created By (Maker)</span>
<span className="font-mono text-slate-200 text-xs truncate block" title={getUserEmail(programmeFreshDetail?.createdBy || selectedProgrammeForDetail?.createdBy)}>
{getUserEmail(programmeFreshDetail?.createdBy || selectedProgrammeForDetail?.createdBy)}
</span>
</div>
</div>
</div>
{/* Metrics: Total Rules and Total Instructions */}
@@ -47,6 +47,7 @@ import { ExportButton } from '../components/ui/ExportButton';
import { ExportColumn } from '../utils/exportUtils';
import { useTableSort, SortableTh } from '../utils/tableSort';
import { useRbac } from '../auth/rbac';
import { bumpMutationEpoch } from '../services/httpClient';
import {
Award,
Budget,
@@ -233,6 +234,7 @@ export const GrantsPage: React.FC = () => {
const updateAwardMutation = useMutation({
mutationFn: (data: UpdateAwardRequest) => updateAward(data),
onSuccess: () => {
bumpMutationEpoch();
queryClient.invalidateQueries({ queryKey: ['awards'] });
queryClient.invalidateQueries({ queryKey: ['dashboard'] });
success('Award Updated', 'Award details saved successfully.');
@@ -248,6 +250,7 @@ export const GrantsPage: React.FC = () => {
const budgetMutation = useMutation({
mutationFn: (data: CreateBudgetRequest) => createBudget(data),
onSuccess: () => {
bumpMutationEpoch();
queryClient.invalidateQueries({ queryKey: ['budgets'] });
success('Budget line established', 'Budget line created against project');
setIsBudgetModalOpen(false);
@@ -261,6 +264,7 @@ export const GrantsPage: React.FC = () => {
const updateBudgetMutation = useMutation({
mutationFn: (data: UpdateBudgetRequest) => updateBudget(data),
onSuccess: () => {
bumpMutationEpoch();
queryClient.invalidateQueries({ queryKey: ['budgets'] });
success('Budget Updated', 'Budget compliance and approval status updated.');
setIsEditBudgetModalOpen(false);
@@ -287,6 +291,7 @@ export const GrantsPage: React.FC = () => {
});
},
onSuccess: () => {
bumpMutationEpoch();
queryClient.invalidateQueries({ queryKey: ['disbursements'] });
success(
editingDisbursement ? 'Disbursement Updated' : 'Disbursement Scheduled',
@@ -497,6 +502,7 @@ export const GrantsPage: React.FC = () => {
const approveMutation = useMutation({
mutationFn: (id: string | number) => approveDisbursement(id),
onSuccess: () => {
bumpMutationEpoch();
queryClient.invalidateQueries({ queryKey: ['disbursements'] });
queryClient.invalidateQueries({ queryKey: ['dashboard'] });
success(
@@ -826,8 +832,10 @@ export const GrantsPage: React.FC = () => {
<div className="flex items-center gap-2 self-end sm:self-center">
<button
type="button"
onClick={() => {
refetchAwards();
onClick={async () => {
bumpMutationEpoch();
await queryClient.invalidateQueries({ queryKey: ['awards'] });
await refetchAwards();
success('Refreshed', 'Awards intake list refreshed from API.');
}}
disabled={isFetchingAwards}
@@ -919,10 +927,10 @@ export const GrantsPage: React.FC = () => {
{Number(awd.status) === ApprovalStatus.Approved ? (
<span
className="inline-flex items-center gap-1 text-xs font-semibold text-emerald-400 bg-emerald-500/10 px-2.5 py-1.5 rounded-lg border border-emerald-500/20 cursor-default"
title="Approved award is immutable. Details cannot be modified."
title="Approved award details cannot be modified."
>
<Lock className="w-3 h-3 text-emerald-400" />
<span>Approved (Immutable)</span>
<span>Approved</span>
</span>
) : (
<button
@@ -973,8 +981,10 @@ export const GrantsPage: React.FC = () => {
<div className="flex items-center gap-2 self-end sm:self-center">
<button
type="button"
onClick={() => {
refetchBudgets();
onClick={async () => {
bumpMutationEpoch();
await queryClient.invalidateQueries({ queryKey: ['budgets'] });
await refetchBudgets();
success('Refreshed', 'Budget line categories refreshed from API.');
}}
disabled={isFetchingBudgets}
@@ -1059,10 +1069,10 @@ export const GrantsPage: React.FC = () => {
{Number(b.status) === ApprovalStatus.Approved ? (
<span
className="inline-flex items-center gap-1 text-xs font-semibold text-emerald-400 bg-emerald-500/10 px-2.5 py-1.5 rounded-lg border border-emerald-500/20 cursor-default"
title="Approved budget is immutable. Status cannot be modified."
title="Approved budget status cannot be modified."
>
<Lock className="w-3 h-3 text-emerald-400" />
<span>Approved (Immutable)</span>
<span>Approved</span>
</span>
) : (
<>
@@ -1133,8 +1143,10 @@ export const GrantsPage: React.FC = () => {
<div className="flex items-center gap-2 self-end sm:self-center">
<button
type="button"
onClick={() => {
refetchDisbursements();
onClick={async () => {
bumpMutationEpoch();
await queryClient.invalidateQueries({ queryKey: ['disbursements'] });
await refetchDisbursements();
success('Refreshed', 'Disbursement tranches refreshed from API.');
}}
disabled={isFetchingDisbursements}
@@ -1549,7 +1561,7 @@ export const GrantsPage: React.FC = () => {
onSubmit={editAwardForm.handleSubmit((values) => {
if (!editingAward) return;
if (Number(editingAward.status) === ApprovalStatus.Approved) {
toastError('Record Immutable', 'Approved awards cannot be modified.');
toastError('Record Approved', 'Approved awards cannot be modified.');
return;
}
updateAwardMutation.mutate({
@@ -1630,7 +1642,7 @@ export const GrantsPage: React.FC = () => {
{Number(editingAward?.status) === ApprovalStatus.Approved ? (
<div className="w-full px-3 py-2 text-xs border border-emerald-500/30 bg-emerald-500/10 rounded-xl text-emerald-300 font-semibold flex items-center gap-1.5 cursor-default">
<Lock className="w-3.5 h-3.5 text-emerald-400" />
<span>Approved (Immutable - Cannot Be Changed)</span>
<span>Approved</span>
</div>
) : (
<select
@@ -1783,7 +1795,7 @@ export const GrantsPage: React.FC = () => {
{Number(editingBudget?.status) === ApprovalStatus.Approved ? (
<div className="w-full px-3 py-2 text-xs border border-emerald-500/30 bg-emerald-500/10 rounded-xl text-emerald-300 font-semibold flex items-center gap-1.5 cursor-default">
<Lock className="w-3.5 h-3.5 text-emerald-400" />
<span>Approved (Immutable - Status Cannot Be Changed)</span>
<span>Approved</span>
</div>
) : (
<select
@@ -42,7 +42,6 @@ export const InboxPage: React.FC = () => {
const { data: directoryUsers = [] } = useQuery<User[]>({
queryKey: ['users-directory'],
queryFn: () => getUsers(1, 200),
staleTime: 5 * 60 * 1000,
});
const {
@@ -152,7 +152,6 @@ export const SettingsPage: React.FC = () => {
const { data: usersDirectory = [] } = useQuery({
queryKey: ['users-directory'],
queryFn: () => getUsers(1, 200),
staleTime: 5 * 60 * 1000,
});
const {
@@ -528,7 +528,7 @@ export const SiteVisitsPage: React.FC = () => {
title="Approved site visits are immutable."
>
<Lock className="w-3.5 h-3.5 text-emerald-400" />
<span>Approved &amp; Verified (Immutable - Cannot Be Changed)</span>
<span>Approved &amp; Verified</span>
</div>
) : (
<select
@@ -68,14 +68,12 @@ const TenantOrganisationsSubList: React.FC<{ tenantId: number | string; tenantNa
} = useQuery<TenantOrganisation[]>({
queryKey: ['tenant-organisations', tenantId],
queryFn: () => getTenantOrganisations(tenantId),
staleTime: 30000,
});
// Fetch tenant users directory to resolve linked user email per organisation
const { data: tenantUsers = [] } = useQuery<TenantUser[]>({
queryKey: ['tenant-users', tenantId],
queryFn: () => getTenantUsers(tenantId),
staleTime: 5 * 60 * 1000,
});
React.useEffect(() => {
@@ -261,7 +259,6 @@ const TenantUsersSubList: React.FC<{
const { data: tenantUsers = [], isLoading, refetch } = useQuery<TenantUser[]>({
queryKey: ['tenant-users', tenantId],
queryFn: () => getTenantUsers(tenantId),
staleTime: 30000,
});
const { canAdd } = useRbac();
@@ -460,7 +457,6 @@ export const TenantManagementPage: React.FC = () => {
} = useQuery<Tenant[]>({
queryKey: ['tenants-list'],
queryFn: () => getTenants(),
staleTime: 30000,
});
// Forms
+39 -293
View File
@@ -247,6 +247,7 @@ export const createOrganisation = async (payload: CreateOrganisationRequest): Pr
email: payload.email,
idNumber: payload.idNumber || null,
organisationUser: payload.organisationUser,
userId: payload.userId || payload.organisationUser,
type: payload.type !== undefined ? Number(payload.type) : 5,
});
return res.data;
@@ -261,6 +262,7 @@ export const updateOrganisation = async (payload: UpdateOrganisationRequest): Pr
email: payload.email,
idNumber: payload.idNumber,
organisationUser: payload.organisationUser,
userId: payload.userId || payload.organisationUser,
type: Number(payload.type),
status: orgStatus,
});
@@ -442,84 +444,21 @@ export const getProgrammeValue = async (programmeId: string | number): Promise<n
}
};
const LOCAL_INSTRUCTIONS_KEY = 'pfm_local_instructions';
export const purgeStoredInstructionDuplicates = (_apiItems?: Instrument[]): void => {};
export const purgeStoredInstructionDuplicates = (apiItems: Instrument[]): void => {
if (typeof window === 'undefined' || !Array.isArray(apiItems) || apiItems.length === 0) return;
try {
const raw = localStorage.getItem(LOCAL_INSTRUCTIONS_KEY);
if (!raw) return;
const local: Instrument[] = JSON.parse(raw);
if (!Array.isArray(local) || local.length === 0) return;
export const getStoredInstructions = (): Instrument[] => [];
const apiIds = new Set(apiItems.map((i) => String(i.id)));
const apiKeys = new Set(apiItems.map((i) => `${i.programmeId}::${(i.name || '').toLowerCase().trim()}`));
const remaining = local.filter(
(l) => !apiIds.has(String(l.id)) && !apiKeys.has(`${l.programmeId}::${(l.name || '').toLowerCase().trim()}`)
);
if (remaining.length !== local.length) {
localStorage.setItem(LOCAL_INSTRUCTIONS_KEY, JSON.stringify(remaining));
}
} catch (e) {
console.warn('Failed to purge stored instruction duplicates', e);
}
};
export const getStoredInstructions = (): Instrument[] => {
if (typeof window === 'undefined') return [];
try {
const raw = localStorage.getItem(LOCAL_INSTRUCTIONS_KEY);
return raw ? JSON.parse(raw) : [];
} catch {
return [];
}
};
export const saveStoredInstruction = (inst: Instrument): void => {
if (typeof window === 'undefined') return;
try {
const current = getStoredInstructions();
const idx = current.findIndex(
(i) => String(i.id) === String(inst.id) ||
(String(i.programmeId) === String(inst.programmeId) && (i.name || '').trim().toLowerCase() === (inst.name || '').trim().toLowerCase())
);
if (idx >= 0) {
current[idx] = { ...current[idx], ...inst, updatedAt: new Date().toISOString() };
} else {
current.unshift(inst);
}
localStorage.setItem(LOCAL_INSTRUCTIONS_KEY, JSON.stringify(current));
} catch (e) {
console.warn('Failed to save stored instruction', e);
}
};
export const saveStoredInstruction = (_inst?: Instrument): void => {};
export const getInstruments = async (programmeId: string | number): Promise<Instrument[]> => {
let apiItems: Instrument[] = [];
let apiSucceeded = false;
try {
const hashedProgId = await toolsService.resolveProgrammeId(programmeId);
const res = await apiClient.get<Instrument[]>(`/v1/api/governance/instruments/${hashedProgId}`);
if (Array.isArray(res.data)) {
apiItems = res.data;
apiSucceeded = true;
}
return Array.isArray(res.data) ? res.data : [];
} catch (err) {
console.warn(`[getInstruments] Could not fetch instruments from API for prog ${programmeId}:`, err);
return [];
}
if (apiSucceeded) {
purgeStoredInstructionDuplicates(apiItems);
return apiItems;
}
// Fallback to local items only when API failed
const localItems = getStoredInstructions().filter(
(item) => String(item.programmeId) === String(programmeId)
);
return localItems;
};
export const getInstructions = getInstruments;
@@ -535,27 +474,17 @@ export const getAllInstruments = async (programmesList?: Programme[]): Promise<I
}
const map = new Map<string, Instrument>();
let hasApiData = false;
if (progList && progList.length > 0) {
const results = await Promise.allSettled(
progList.map((pr) => getInstruments(pr.id))
);
results.forEach((res) => {
if (res.status === 'fulfilled' && Array.isArray(res.value) && res.value.length > 0) {
hasApiData = true;
if (res.status === 'fulfilled' && Array.isArray(res.value)) {
res.value.forEach((inst) => map.set(String(inst.id), inst));
}
});
}
if (hasApiData || map.size > 0) {
const allApiInstruments = Array.from(map.values());
purgeStoredInstructionDuplicates(allApiInstruments);
return allApiInstruments;
}
return getStoredInstructions();
return Array.from(map.values());
};
export const getAllInstructions = getAllInstruments;
@@ -569,40 +498,8 @@ export const createInstrument = async (payload: CreateInstrumentRequest): Promis
description: payload.description,
};
try {
const res = await apiClient.post<Instrument>('/v1/api/governance/instruments', body);
if (res.data && res.data.id) {
purgeStoredInstructionDuplicates([res.data]);
return res.data;
}
} catch (err: any) {
console.warn('[createInstrument] API create failed, falling back to local storage:', err);
const newId = Date.now();
const fallbackInstruction: Instrument = {
id: newId,
programmeId: toIntegerId(payload.programmeId),
code: code,
name: payload.name,
description: payload.description,
status: payload.status !== undefined ? Number(payload.status) : ActivityStatus.Active,
createdBy: payload.userId || 'system',
createdAt: new Date().toISOString(),
};
saveStoredInstruction(fallbackInstruction);
return fallbackInstruction;
}
// If API returned 200 without full body, return formatted item without saving duplicate to localStorage
return {
id: Date.now(),
programmeId: toIntegerId(payload.programmeId),
code: code,
name: payload.name,
description: payload.description,
status: payload.status !== undefined ? Number(payload.status) : ActivityStatus.Active,
createdBy: payload.userId || 'system',
createdAt: new Date().toISOString(),
};
const res = await apiClient.post<Instrument>('/v1/api/governance/instruments', body);
return res.data;
};
export const createInstruction = createInstrument;
@@ -619,32 +516,7 @@ export const updateInstrument = async (payload: UpdateInstrumentRequest): Promis
status: Number(payload.status),
};
try {
const res = await apiClient.put<Instrument>('/v1/api/governance/instruments', body);
if (res.data && res.data.id) {
return res.data;
}
} catch (err) {
console.warn('[updateInstrument] API update failed:', err);
}
const existing = getStoredInstructions().find((i) => String(i.id) === String(targetId));
const updatedInstrument: Instrument = {
id: targetId!,
programmeId: payload.programmeId ? toIntegerId(payload.programmeId) : (existing?.programmeId || 0),
code: payload.code || existing?.code || null,
name: payload.name,
description: payload.description,
status: Number(payload.status),
createdBy: existing?.createdBy || 'system',
createdAt: existing?.createdAt || new Date().toISOString(),
updatedBy: payload.userId || 'system',
updatedAt: new Date().toISOString(),
};
if (existing) {
saveStoredInstruction(updatedInstrument);
}
const res = await apiClient.put<Instrument>('/v1/api/governance/instruments', body);
if (isApprovedOrActiveStatus(payload.status)) {
notifyTenantManagersAndExecutivesOnRecordStatusChange({
recordType: 'Funding Instrument',
@@ -653,7 +525,7 @@ export const updateInstrument = async (payload: UpdateInstrumentRequest): Promis
title: payload.name,
}).catch(console.warn);
}
return updatedInstrument;
return res.data;
};
export const updateInstruction = updateInstrument;
@@ -2246,75 +2118,20 @@ export const updateSiteVisit = async (payload: UpdateSiteVisitRequest): Promise<
}
};
const LOCAL_RULES_KEY = 'pfm_local_rules';
export const purgeStoredRuleDuplicates = (_apiRules?: Rule[]): void => {};
export const purgeStoredRuleDuplicates = (apiRules: Rule[]): void => {
if (typeof window === 'undefined' || !Array.isArray(apiRules) || apiRules.length === 0) return;
try {
const raw = localStorage.getItem(LOCAL_RULES_KEY);
if (!raw) return;
const local: Rule[] = JSON.parse(raw);
if (!Array.isArray(local) || local.length === 0) return;
export const getStoredRules = (): Rule[] => [];
const apiIds = new Set(apiRules.map((r) => String(r.id)));
const apiKeys = new Set(apiRules.map((r) => `${r.programmeId}::${(r.name || '').toLowerCase().trim()}`));
const remaining = local.filter(
(l) => !apiIds.has(String(l.id)) && !apiKeys.has(`${l.programmeId}::${(l.name || '').toLowerCase().trim()}`)
);
if (remaining.length !== local.length) {
localStorage.setItem(LOCAL_RULES_KEY, JSON.stringify(remaining));
}
} catch (e) {
console.warn('Failed to purge stored rule duplicates', e);
}
};
export const getStoredRules = (): Rule[] => {
if (typeof window === 'undefined') return [];
try {
const raw = localStorage.getItem(LOCAL_RULES_KEY);
return raw ? JSON.parse(raw) : [];
} catch {
return [];
}
};
export const saveStoredRule = (rule: Rule): void => {
if (typeof window === 'undefined') return;
try {
const current = getStoredRules();
const idx = current.findIndex(
(r) => String(r.id) === String(rule.id) ||
(String(r.programmeId) === String(rule.programmeId) && (r.name || '').trim().toLowerCase() === (rule.name || '').trim().toLowerCase())
);
if (idx >= 0) {
current[idx] = { ...current[idx], ...rule, updatedAt: new Date().toISOString() };
} else {
current.unshift(rule);
}
localStorage.setItem(LOCAL_RULES_KEY, JSON.stringify(current));
} catch (e) {
console.warn('Failed to save stored rule', e);
}
};
export const saveStoredRule = (_rule?: Rule): void => {};
// ==========================================
// 15. Execution: Programme Rules APIs
// ==========================================
export const getRule = async (ruleId: string | number): Promise<Rule> => {
try {
const hashedRuleId = await toolsService.encodeId(ruleId);
const res = await apiClient.get<Rule>(`/v1/api/execution/rules/${hashedRuleId}`);
if (res.data) {
purgeStoredRuleDuplicates([res.data]);
return res.data;
}
} catch {
const localRule = getStoredRules().find((r) => String(r.id) === String(ruleId));
if (localRule) return localRule;
throw new Error('Rule not found');
const hashedRuleId = await toolsService.encodeId(ruleId);
const res = await apiClient.get<Rule>(`/v1/api/execution/rules/${hashedRuleId}`);
if (res.data) {
return res.data;
}
throw new Error('Rule not found');
};
@@ -2332,38 +2149,16 @@ export const getRules = async (
pagination: Pagination = { page: 1, pageSize: 100 },
programmeId?: string | number
): Promise<Rule[]> => {
let apiRules: Rule[] = [];
let apiSucceeded = false;
try {
const res = await apiClient.post<Rule[]>('/v1/api/execution/programmes/rules', {
index: pagination.index ? toIntegerId(pagination.index) : 0,
page: pagination.page || 1,
pageSize: pagination.pageSize || 100,
});
if (Array.isArray(res.data)) {
apiRules = res.data;
apiSucceeded = true;
}
} catch (err) {
console.warn('[getRules] Error fetching from backend API:', err);
}
if (apiSucceeded) {
purgeStoredRuleDuplicates(apiRules);
let result = apiRules;
if (programmeId != null && programmeId !== '' && programmeId !== 'all') {
result = result.filter((r) => String(r.programmeId) === String(programmeId));
}
return result;
}
// Fallback to local storage only if API call failed
const localRules = getStoredRules();
let combined = localRules;
const res = await apiClient.post<Rule[]>('/v1/api/execution/programmes/rules', {
index: pagination.index ? toIntegerId(pagination.index) : 0,
page: pagination.page || 1,
pageSize: pagination.pageSize || 100,
});
let result = Array.isArray(res.data) ? res.data : [];
if (programmeId != null && programmeId !== '' && programmeId !== 'all') {
combined = combined.filter((r) => String(r.programmeId) === String(programmeId));
result = result.filter((r) => String(r.programmeId) === String(programmeId));
}
return combined;
return result;
};
export const createRule = async (payload: CreateRuleRequest): Promise<Rule> => {
@@ -2375,70 +2170,21 @@ export const createRule = async (payload: CreateRuleRequest): Promise<Rule> => {
description: payload.description,
};
try {
const res = await apiClient.post<Rule>('/v1/api/execution/rules', body);
if (res.data && res.data.id) {
purgeStoredRuleDuplicates([res.data]);
return res.data;
}
} catch (err) {
console.warn('[createRule] API create failed, falling back to local storage:', err);
const newId = Date.now();
const fallbackRule: Rule = {
id: newId,
programmeId: toIntegerId(payload.programmeId),
name: payload.name,
description: payload.description,
status: ActivityStatus.Active,
version: 1,
createdBy: validUserId,
createdAt: new Date().toISOString(),
};
saveStoredRule(fallbackRule);
return fallbackRule;
}
return {
id: Date.now(),
programmeId: toIntegerId(payload.programmeId),
name: payload.name,
description: payload.description,
status: ActivityStatus.Active,
version: 1,
createdBy: validUserId,
createdAt: new Date().toISOString(),
};
const res = await apiClient.post<Rule>('/v1/api/execution/rules', body);
return res.data;
};
export const updateRule = async (payload: UpdateRuleRequest): Promise<void> => {
const validUserId = toValidUuid(payload.userId);
try {
await apiClient.put('/v1/api/execution/rules', {
ruleId: toIntegerId(payload.ruleId),
userId: validUserId,
name: payload.name,
description: payload.description,
status: Number(payload.status),
version: Number(payload.version || 1),
...(payload.programmeId ? { programmeId: toIntegerId(payload.programmeId) } : {}),
});
} catch (err) {
console.warn('[updateRule] API update failed:', err);
const existing = getStoredRules().find((r) => String(r.id) === String(payload.ruleId));
const updatedRule: Rule = {
id: payload.ruleId,
programmeId: payload.programmeId ? toIntegerId(payload.programmeId) : (existing?.programmeId || 0),
name: payload.name,
description: payload.description,
status: Number(payload.status),
version: Number(payload.version || 1),
createdBy: existing?.createdBy || validUserId,
createdAt: existing?.createdAt || new Date().toISOString(),
updatedBy: validUserId,
updatedAt: new Date().toISOString(),
};
saveStoredRule(updatedRule);
}
await apiClient.put('/v1/api/execution/rules', {
ruleId: toIntegerId(payload.ruleId),
userId: validUserId,
name: payload.name,
description: payload.description,
status: Number(payload.status),
version: Number(payload.version || 1),
...(payload.programmeId ? { programmeId: toIntegerId(payload.programmeId) } : {}),
});
};
// ==========================================
@@ -60,6 +60,8 @@ export const apiClient = axios.create({
headers: {
'Content-Type': 'application/json; version=1',
Accept: 'application/json; version=1',
'Cache-Control': 'no-cache, no-store, must-revalidate',
Pragma: 'no-cache',
},
timeout: 15000,
});
@@ -81,34 +83,16 @@ apiClient.interceptors.request.use(
config.url?.includes('amazonaws.com') ||
config.url?.includes('garage');
// Attach mutation epoch or explicit timestamp to GET requests for OutputCache management
// Always attach freshness timestamp and mutation epoch to internal GET requests
// to guarantee zero stale data, bypass browser HTTP caches, and prevent stale OutputCache hits
if (config.method?.toUpperCase() === 'GET' && !isExternal) {
const headers = config.headers || {};
const hasNoCacheHeader =
headers['Cache-Control'] === 'no-cache' ||
headers['cache-control'] === 'no-cache' ||
headers['Pragma'] === 'no-cache';
if (hasNoCacheHeader) {
// Explicit freshness requested: attach fresh timestamp
if (config.params instanceof URLSearchParams) {
config.params.set('_t', String(Date.now()));
} else {
config.params = config.params || {};
config.params._t = Date.now();
}
if (config.params instanceof URLSearchParams) {
config.params.set('_t', String(Date.now()));
config.params.set('_v', String(getMutationEpoch()));
} else {
// Standard GET: attach current mutation epoch
if (config.params instanceof URLSearchParams) {
if (!config.params.has('_v') && !config.params.has('_t')) {
config.params.set('_v', String(getMutationEpoch()));
}
} else {
config.params = config.params || {};
if (!config.params._v && !config.params._t) {
config.params._v = getMutationEpoch();
}
}
config.params = config.params || {};
config.params._t = Date.now();
config.params._v = getMutationEpoch();
}
}
@@ -10,15 +10,8 @@ export interface TenantLockoutResult {
statusCode?: number;
}
let cachedTenantLockout: {
key: string;
result: TenantLockoutResult;
timestamp: number;
} | null = null;
const LOCKOUT_CACHE_TTL_MS = 60 * 1000;
export function clearTenantLockoutCache(): void {
cachedTenantLockout = null;
// Caching removed to guarantee fresh tenant state on every verification
}
export function isStatusActive(status: any): boolean {
@@ -88,15 +81,7 @@ export async function verifyTenantLockout(
candidateId = tenantClaim;
}
const cacheKey = String(candidateId ?? tenantClaim ?? 'default-1').trim().toLowerCase();
if (!forceRefresh && cachedTenantLockout && cachedTenantLockout.key === cacheKey) {
if (
cachedTenantLockout.result.isActive &&
Date.now() - cachedTenantLockout.timestamp < LOCKOUT_CACHE_TTL_MS
) {
return cachedTenantLockout.result;
}
}
// Fresh check: query endpoint directly without cache
let tenantRes: Tenant | null = null;
let lastError: any = null;
@@ -209,10 +194,6 @@ export async function verifyTenantLockout(
isActive: true,
tenant: tenantObj,
};
cachedTenantLockout = {
key: cacheKey,
result: successResult,
timestamp: Date.now(),
};
// Direct return without caching
return successResult;
}
@@ -388,6 +388,7 @@ export interface CreateOrganisationRequest {
email: string;
idNumber?: string | null;
organisationUser: string;
userId?: string | null;
type: OrganisationType;
}
@@ -398,6 +399,7 @@ export interface UpdateOrganisationRequest {
email: string;
idNumber?: string | null;
organisationUser: string;
userId?: string | null;
type: OrganisationType;
status?: ActivityStatus;
}
@@ -293,7 +293,6 @@ export function DashboardPage() {
queryKey: ['partner-applicant-data', applicantUserId],
queryFn: () => getApplicantData(applicantUserId),
enabled: Boolean(applicantUserId),
staleTime: 30_000,
});
// Extract all unique projects, contracts, awards, and organisations from ApplicantData[]
@@ -426,7 +425,6 @@ export function DashboardPage() {
return Object.fromEntries(results);
},
enabled: partnerSummary.projectEntries.length > 0,
staleTime: 30_000,
});
// Combine into EnrichedPartnerProject[]
@@ -761,25 +759,25 @@ export function DashboardPage() {
}, [inboxNotifications, partnerSummary]);
return (
<div className="min-h-screen w-full bg-[#090D16] text-slate-100 p-3 sm:p-6 lg:p-8 selection:bg-indigo-500/30 selection:text-indigo-200">
<div className="min-h-screen w-full bg-[#0F0E17] text-[#F1F1F6] flex flex-col selection:bg-[#8B5CF6]/30 selection:text-[#F1F1F6]">
{/* =====================================================================
TOP NAVIGATION BAR — STANDALONE ELEVATED CONTAINER SEPARATED FROM BODY
TOP NAVIGATION BAR — SNAPPED TO TOP, 72PX, FULL-WIDTH EDGE-TO-EDGE
===================================================================== */}
<header className="mx-auto max-w-[1460px] rounded-[24px] sm:rounded-[28px] bg-[#0E1526] border border-white/[0.08] shadow-[0_15px_45px_rgba(0,0,0,0.5)] px-5 sm:px-8 py-3.5 mb-6 sm:mb-8 transition-all">
<div className="flex flex-wrap items-center justify-between gap-4">
<header className="sticky top-0 z-30 w-full bg-[#0F0E17]/95 backdrop-blur-md border-b border-white/[0.06] h-[72px] flex items-center transition-all">
<div className="max-w-[1440px] mx-auto px-6 h-full flex items-center justify-between gap-4 w-full">
{/* Left: Brand Glyph & Horizontal Navigation Links */}
<div className="flex items-center gap-6 sm:gap-10">
<div className="flex items-center gap-6 sm:gap-10 h-full">
{/* Flat black infinity icon rotated with top lobe pointing to 2 o'clock */}
<div
className="w-8 h-8 rounded-xl bg-white flex items-center justify-center shrink-0 cursor-pointer shadow-sm hover:opacity-90 transition-opacity"
className="w-8 h-8 rounded-lg bg-white flex items-center justify-center shrink-0 cursor-pointer shadow-sm hover:opacity-90 transition-opacity"
onClick={() => setActiveTab('dashboard')}
title="Dashboard Home"
>
<InfinityIcon className="w-5 h-5 text-black -rotate-[30deg]" strokeWidth={2.75} />
<InfinityIcon className="w-5 h-5 text-black -rotate-[30deg]" strokeWidth={2.5} />
</div>
{/* Nav Tabs with bottom underline on active */}
<nav className="hidden md:flex items-center gap-6 lg:gap-8">
{/* Nav Tabs with bottom indicator on active */}
<nav className="hidden md:flex items-center gap-6 h-full">
{navTabs.map(tab => {
const isActive = activeTab === tab.id;
return (
@@ -787,15 +785,15 @@ export function DashboardPage() {
key={tab.id}
type="button"
onClick={() => setActiveTab(tab.id)}
className={`relative py-1 text-sm transition-colors cursor-pointer ${
className={`relative h-full flex items-center text-sm transition-colors cursor-pointer ${
isActive
? 'font-bold text-white'
: 'font-medium text-slate-400 hover:text-slate-200'
? 'font-bold text-[#F1F1F6]'
: 'font-medium text-[#9A95B0] hover:text-[#F1F1F6]'
}`}
>
<span>{tab.label}</span>
{isActive && (
<span className="absolute -bottom-2.5 left-0 right-0 h-[2.5px] rounded-full bg-slate-100" />
<span className="absolute bottom-0 left-0 right-0 h-[3px] bg-[#8B5CF6] rounded-t-[2px]" />
)}
</button>
);
@@ -805,21 +803,21 @@ export function DashboardPage() {
{/* Right: Search Pill + Refresh + Notification Bell + Profile */}
<div className="flex items-center gap-3 sm:gap-4 ml-auto">
{/* Pill Search Input */}
<div className="relative w-44 sm:w-56 lg:w-64">
<Search className="w-3.5 h-3.5 text-slate-400 absolute left-3.5 top-1/2 -translate-y-1/2 pointer-events-none" />
{/* Search Input: 280px x 38px */}
<div className="relative w-44 sm:w-56 lg:w-[280px] h-[38px]">
<Search className="w-3.5 h-3.5 text-[#9A95B0] absolute left-3.5 top-1/2 -translate-y-1/2 pointer-events-none" />
<input
type="text"
value={searchQuery}
onChange={e => setSearchQuery(e.target.value)}
placeholder="Search ...."
className="w-full pl-9 pr-3 py-1.5 rounded-full bg-[#182136] border border-white/[0.07] text-xs text-slate-200 placeholder:text-slate-500 focus:outline-none focus:border-indigo-400 transition-all"
className="w-full h-full pl-9 pr-8 rounded-[8px] bg-white/[0.05] border border-white/[0.08] text-xs text-[#F1F1F6] placeholder:text-[#9A95B0]/70 focus:outline-none focus:border-[#8B5CF6] transition-all"
/>
{searchQuery && (
<button
type="button"
onClick={() => setSearchQuery('')}
className="absolute right-3 top-1/2 -translate-y-1/2 text-slate-500 hover:text-slate-300"
className="absolute right-3 top-1/2 -translate-y-1/2 text-[#9A95B0] hover:text-[#F1F1F6]"
>
<X className="w-3 h-3" />
</button>
@@ -831,9 +829,9 @@ export function DashboardPage() {
type="button"
onClick={() => refetchApplicantData()}
title="Refresh Partner Data"
className="p-2 text-slate-400 hover:text-white transition-colors cursor-pointer"
className="p-2 text-[#9A95B0] hover:text-[#F1F1F6] transition-colors cursor-pointer"
>
<RefreshCw className={`w-4 h-4 ${isRefreshingApplicantData ? 'animate-spin text-indigo-400' : ''}`} />
<RefreshCw className={`w-4 h-4 ${isRefreshingApplicantData ? 'animate-spin text-[#8B5CF6]' : ''}`} />
</button>
{/* Notification Bell */}
@@ -841,22 +839,22 @@ export function DashboardPage() {
<button
type="button"
onClick={() => setShowBellDropdown(prev => !prev)}
className="relative p-2 text-slate-400 hover:text-white transition-colors cursor-pointer"
className="relative p-2 text-[#9A95B0] hover:text-[#F1F1F6] transition-colors cursor-pointer"
aria-label="Notifications"
>
<Bell className="w-4 h-4" />
{unreadCount > 0 && (
<span className="absolute top-1.5 right-1.5 w-2 h-2 rounded-full bg-rose-500 ring-2 ring-[#0E1526]" />
<span className="absolute top-1.5 right-1.5 w-2 h-2 rounded-full bg-[#EF4444] ring-2 ring-[#0F0E17]" />
)}
</button>
{showBellDropdown && (
<div className="absolute right-0 mt-3 w-80 sm:w-96 rounded-2xl bg-[#141C30] border border-white/10 shadow-2xl p-4 z-50">
<div className="absolute right-0 mt-3 w-80 sm:w-96 rounded-2xl bg-[#181622] border border-white/[0.08] shadow-2xl p-4 z-50 text-[#F1F1F6]">
<div className="flex items-center justify-between pb-3 border-b border-white/[0.08]">
<div className="flex items-center gap-2">
<span className="text-sm font-semibold text-white">Notifications</span>
<span className="text-sm font-semibold text-[#F1F1F6]">Notifications</span>
{unreadCount > 0 && (
<span className="px-2 py-0.5 text-[11px] font-semibold rounded-full bg-indigo-500/20 text-indigo-300">
<span className="px-2 py-0.5 text-[11px] font-semibold rounded-full bg-[#8B5CF6]/20 text-[#8B5CF6]">
{unreadCount} unread
</span>
)}
@@ -865,7 +863,7 @@ export function DashboardPage() {
<button
type="button"
onClick={() => markAllAsRead()}
className="text-xs text-indigo-400 hover:text-indigo-300 font-medium cursor-pointer"
className="text-xs text-[#8B5CF6] hover:text-[#8B5CF6]/80 font-medium cursor-pointer"
>
Mark all read
</button>
@@ -873,7 +871,7 @@ export function DashboardPage() {
</div>
<div className="divide-y divide-white/[0.06] max-h-72 overflow-y-auto my-2">
{top5Unread.length === 0 ? (
<div className="py-6 text-center text-xs text-slate-400">
<div className="py-6 text-center text-xs text-[#9A95B0]">
You are all caught up! No unread notifications.
</div>
) : (
@@ -888,12 +886,12 @@ export function DashboardPage() {
}}
className="w-full text-left py-3 px-2 hover:bg-white/[0.04] rounded-lg transition-colors flex items-start gap-3 cursor-pointer"
>
<span className="h-2 w-2 rounded-full bg-rose-500 mt-1.5 shrink-0" />
<span className="h-2 w-2 rounded-full bg-[#EF4444] mt-1.5 shrink-0" />
<div className="min-w-0 flex-1">
<p className="text-xs font-semibold text-white truncate">
<p className="text-xs font-semibold text-[#F1F1F6] truncate">
{item.subject || 'Partner Notification'}
</p>
<p className="text-xs text-slate-400 line-clamp-2 mt-0.5">
<p className="text-xs text-[#9A95B0] line-clamp-2 mt-0.5">
{item.message}
</p>
</div>
@@ -907,7 +905,7 @@ export function DashboardPage() {
setShowBellDropdown(false);
setActiveTab('messages');
}}
className="w-full py-2 text-center text-xs font-semibold text-indigo-300 hover:text-indigo-200 bg-indigo-500/10 hover:bg-indigo-500/15 rounded-xl transition-colors cursor-pointer"
className="w-full py-2 text-center text-xs font-semibold text-[#8B5CF6] hover:text-white bg-[#8B5CF6]/10 hover:bg-[#8B5CF6]/20 rounded-xl transition-colors cursor-pointer"
>
See All Messages
</button>
@@ -922,24 +920,24 @@ export function DashboardPage() {
onClick={() => setShowUserDropdown(prev => !prev)}
className="flex items-center gap-2 text-left group cursor-pointer focus:outline-none"
>
<div className="w-8 h-8 rounded-full bg-gradient-to-tr from-amber-400 via-rose-400 to-indigo-500 p-0.5 flex items-center justify-center shrink-0">
<div className="w-full h-full rounded-full bg-[#0E1526] flex items-center justify-center text-[11px] font-bold text-amber-200">
<div className="w-8 h-8 rounded-full bg-gradient-to-tr from-amber-400 via-rose-400 to-[#8B5CF6] p-0.5 flex items-center justify-center shrink-0">
<div className="w-full h-full rounded-full bg-[#181622] flex items-center justify-center text-[11px] font-bold text-amber-200">
{getInitials(user?.name || 'Samia Afrin Mithila')}
</div>
</div>
<span className="hidden sm:inline-block text-xs font-semibold text-slate-200 group-hover:text-white transition-colors">
<span className="hidden sm:inline-block text-xs font-semibold text-[#F1F1F6] group-hover:text-white transition-colors">
{user?.name || 'Samia Afrin Mithila'}
</span>
<ChevronDown className="w-3.5 h-3.5 text-slate-400 group-hover:text-white transition-colors" />
<ChevronDown className="w-3.5 h-3.5 text-[#9A95B0] group-hover:text-[#F1F1F6] transition-colors" />
</button>
{showUserDropdown && (
<div className="absolute right-0 mt-3 w-56 rounded-2xl bg-[#141C30] border border-white/10 shadow-2xl p-2 z-50">
<div className="absolute right-0 mt-3 w-56 rounded-2xl bg-[#181622] border border-white/[0.08] shadow-2xl p-2 z-50 text-[#F1F1F6]">
<div className="px-3 py-2 border-b border-white/[0.06]">
<p className="text-xs font-bold text-white truncate">{user?.name || 'Samia Afrin Mithila'}</p>
<p className="text-[11px] text-slate-400 truncate">{user?.email || 'partner@grantee.org'}</p>
<p className="text-xs font-bold text-[#F1F1F6] truncate">{user?.name || 'Samia Afrin Mithila'}</p>
<p className="text-[11px] text-[#9A95B0] truncate">{user?.email || 'partner@grantee.org'}</p>
{partnerSummary.primaryInvitedOrg && (
<p className="text-[10px] text-indigo-400 mt-1 truncate">
<p className="text-[10px] text-[#8B5CF6] mt-1 truncate">
{partnerSummary.primaryInvitedOrg.name}
</p>
)}
@@ -962,7 +960,7 @@ export function DashboardPage() {
</div>
{/* Mobile Navigation bar for smaller screens */}
<div className="flex md:hidden items-center gap-2 pt-3 border-t border-white/[0.06] mt-3 overflow-x-auto">
<div className="flex md:hidden items-center gap-2 px-6 pb-2.5 border-t border-white/[0.06] overflow-x-auto bg-[#0F0E17] w-full">
{navTabs.map(tab => (
<button
key={tab.id}
@@ -970,8 +968,8 @@ export function DashboardPage() {
onClick={() => setActiveTab(tab.id)}
className={`px-3 py-1.5 rounded-lg text-xs font-medium whitespace-nowrap ${
activeTab === tab.id
? 'bg-indigo-500/20 text-indigo-300 border border-indigo-500/30 font-bold'
: 'text-slate-400'
? 'bg-[#8B5CF6]/20 text-[#8B5CF6] border border-[#8B5CF6]/30 font-bold'
: 'text-[#9A95B0]'
}`}
>
{tab.label}
@@ -981,31 +979,31 @@ export function DashboardPage() {
</header>
{/* =====================================================================
MAIN BODY SECTION (Separated from Top Bar)
MAIN BODY SECTION (max-w-[1440px] centered with outer ambient margin)
===================================================================== */}
<main className="mx-auto max-w-[1460px]">
<main className="w-full max-w-[1440px] mx-auto px-6 py-6 sm:py-8 flex-1">
<div>
{isLoadingApplicantData ? (
<div className="py-24 flex flex-col items-center justify-center gap-4 text-center">
<div className="h-10 w-10 animate-spin rounded-full border-2 border-indigo-400 border-t-transparent" />
<div className="h-10 w-10 animate-spin rounded-full border-2 border-[#8B5CF6] border-t-transparent" />
<div>
<p className="text-base font-semibold text-white">Loading Workspace...</p>
<p className="text-xs text-slate-400 mt-1">Retrieving ongoing projects and milestones.</p>
<p className="text-base font-semibold text-[#F1F1F6]">Loading Workspace...</p>
<p className="text-xs text-[#9A95B0] mt-1">Retrieving ongoing projects and milestones.</p>
</div>
</div>
) : activeTab === 'dashboard' ? (
/* =================================================================
DASHBOARD: 2-COLUMN DRIBBBLE LAYOUT (Left ~68%, Right ~32%)
DASHBOARD: ASYMMETRICAL 2-COLUMN GRID (1fr 340px, gap 24px)
================================================================= */
<div className="grid grid-cols-1 lg:grid-cols-12 gap-7 items-start">
<div className="grid grid-cols-1 lg:grid-cols-[1fr_340px] gap-6 items-start">
{/* ===============================================================
LEFT COLUMN: ONGOING PROJECTS (8 of 12 cols)
LEFT MAIN SECTION: ONGOING PROJECTS (~70% width)
=============================================================== */}
<div className="lg:col-span-8 space-y-6">
{/* Title + Sort Row */}
<div className="space-y-5">
{/* Title + Sort Row: 20px / 28px / font-weight 700 / #F1F1F6 */}
<div className="flex items-center justify-between">
<h2 className="text-2xl font-bold text-white tracking-tight">
<h2 className="text-[20px] leading-[28px] font-bold text-[#F1F1F6]">
Ongoing Projects
</h2>
@@ -1014,19 +1012,19 @@ export function DashboardPage() {
<select
value={sortBy}
onChange={e => setSortBy(e.target.value as SortOption)}
className="appearance-none bg-transparent hover:text-white pr-6 py-1 text-xs font-medium text-slate-300 focus:outline-none cursor-pointer transition-colors"
className="appearance-none bg-transparent hover:text-[#F1F1F6] pr-6 py-1 text-xs font-medium text-[#9A95B0] focus:outline-none cursor-pointer transition-colors"
>
<option value="date_added" className="bg-[#141C30] text-slate-200">Sort by date added</option>
<option value="grant_amount" className="bg-[#141C30] text-slate-200">Sort by spend / grant</option>
<option value="days_left" className="bg-[#141C30] text-slate-200">Sort by days left</option>
<option value="name" className="bg-[#141C30] text-slate-200">Sort by project name</option>
<option value="date_added" className="bg-[#181622] text-[#F1F1F6]">Sort by date added</option>
<option value="grant_amount" className="bg-[#181622] text-[#F1F1F6]">Sort by spend / grant</option>
<option value="days_left" className="bg-[#181622] text-[#F1F1F6]">Sort by days left</option>
<option value="name" className="bg-[#181622] text-[#F1F1F6]">Sort by project name</option>
</select>
<ChevronDown className="w-3.5 h-3.5 text-slate-400 absolute right-0 top-1/2 -translate-y-1/2 pointer-events-none" />
<ChevronDown className="w-3.5 h-3.5 text-[#9A95B0] absolute right-0 top-1/2 -translate-y-1/2 pointer-events-none" />
</div>
</div>
{/* Render Real Projects if available, or Showcase Projects */}
<div className="space-y-6">
{/* Stacked Project Cards */}
<div className="space-y-4">
{filteredProjects.length > 0 ? (
filteredProjects.map((item, pIdx) => {
const { project, award, contract, fundingOrganisation, milestones, evidence, disbursements } = item;
@@ -1074,101 +1072,97 @@ export function DashboardPage() {
const leadRole = 'Programme Manager';
const avatarColors = [
'bg-gradient-to-tr from-sky-400 to-blue-600',
'bg-gradient-to-tr from-amber-400 to-orange-500',
'bg-gradient-to-tr from-purple-500 to-indigo-600',
'bg-gradient-to-tr from-amber-400 to-[#F97316]',
'bg-gradient-to-tr from-[#8B5CF6] to-indigo-600',
];
return (
<div
key={project.id}
className="rounded-[22px] bg-[#141C30] border border-white/[0.07] p-6 sm:p-7 shadow-lg hover:border-slate-700 transition-all"
className="rounded-[16px] bg-[#181622] border border-white/[0.06] p-6 shadow-sm hover:border-white/[0.12] transition-all"
>
<div className="flex flex-col lg:flex-row gap-6 justify-between">
{/* Left Sub-portion (~70%): Title, Description, 4 Metrics, Horizontal Timeline */}
<div className="flex-1 flex flex-col justify-between gap-6 min-w-0">
<div className="flex-1 flex flex-col justify-between gap-5 min-w-0">
<div>
<h3 className="text-lg font-bold text-white tracking-tight">
<h3 className="text-[18px] leading-[24px] font-semibold text-[#F1F1F6]">
{project.name || 'Funded Project'}
</h3>
<p className="text-xs text-slate-400 line-clamp-2 leading-relaxed mt-1.5 max-w-2xl">
<p className="text-[14px] leading-[22px] font-normal text-[#9A95B0] line-clamp-2 mt-1.5 max-w-2xl">
{project.description || 'Funding award execution, deliverable submissions, and post-fund milestone tracking.'}
</p>
</div>
{/* 4 Metrics in a horizontal inline row */}
{/* 4 Metrics in a horizontal inline row: 13px font-weight 500 #D1CBDD */}
<div className="flex flex-wrap items-center gap-4 sm:gap-6 py-1">
<div className="flex items-center gap-1.5 text-xs">
<div className="flex items-center gap-1.5 text-[13px] font-medium text-[#D1CBDD]">
<span className="p-1 rounded-md bg-emerald-500/10 text-emerald-400 flex items-center justify-center">
<Hourglass className="w-3.5 h-3.5" />
</span>
<span className="font-bold text-white">{formatCompactZAR(grantAmount)}</span>
<span className="text-slate-400">Award</span>
<span className="font-semibold text-[#F1F1F6]">{formatCompactZAR(grantAmount)}</span>
<span className="text-[#9A95B0]">Award</span>
</div>
<div className="flex items-center gap-1.5 text-xs">
<span className="p-1 rounded-md bg-rose-500/10 text-rose-400 flex items-center justify-center">
<div className="flex items-center gap-1.5 text-[13px] font-medium text-[#D1CBDD]">
<span className="p-1 rounded-md bg-[#F97316]/10 text-[#F97316] flex items-center justify-center">
<DollarSign className="w-3.5 h-3.5" />
</span>
<span className="font-bold text-white">{formatCompactZAR(disbursedAmount)}</span>
<span className="text-slate-400">Spend</span>
<span className="font-semibold text-[#F1F1F6]">{formatCompactZAR(disbursedAmount)}</span>
<span className="text-[#9A95B0]">Spend</span>
</div>
<div className="flex items-center gap-1.5 text-xs">
<div className="flex items-center gap-1.5 text-[13px] font-medium text-[#D1CBDD]">
<span className="p-1 rounded-md bg-amber-500/10 text-amber-400 flex items-center justify-center">
<Calendar className="w-3.5 h-3.5" />
</span>
<span className="font-bold text-white">{daysLeft}</span>
<span className="text-slate-400">Days left</span>
<span className="font-semibold text-[#F1F1F6]">{daysLeft}</span>
<span className="text-[#9A95B0]">Days left</span>
</div>
<div className="flex items-center gap-1.5 text-xs">
<div className="flex items-center gap-1.5 text-[13px] font-medium text-[#D1CBDD]">
<span className="p-1 rounded-md bg-cyan-500/10 text-cyan-400 flex items-center justify-center">
<Paperclip className="w-3.5 h-3.5" />
</span>
<span className="font-bold text-white">{docsSubmitted}</span>
<span className="text-slate-400">Docs submitted</span>
<span className="font-semibold text-[#F1F1F6]">{docsSubmitted}</span>
<span className="text-[#9A95B0]">Docs submitted</span>
</div>
</div>
{/* Horizontal Timeline Stepper */}
{/* Horizontal Timeline Stepper: height 2px, 10px nodes */}
<div className="pt-2 overflow-x-auto">
<div className="min-w-[460px] flex items-start justify-between relative">
{stepperNodes.map((step, sIdx) => {
const isLast = sIdx === stepperNodes.length - 1;
const isDotted = sIdx === 2; // Exact connector style from Dribbble mock
const isDotted = sIdx === 2; // Exact connector style from reference
return (
<div key={step.id} className="flex-1 flex flex-col items-start relative">
<div className="w-full flex items-center">
{step.type === 'done' ? (
<div className="h-5 w-5 rounded-full bg-[#1E293B] border border-indigo-400/50 text-indigo-400 flex items-center justify-center shrink-0 z-10 shadow-sm">
<Check className="w-3 h-3 stroke-[3]" />
</div>
<div className="w-2.5 h-2.5 rounded-full bg-[#8B5CF6] ring-4 ring-[#8B5CF6]/20 shrink-0 z-10 shadow-sm" />
) : step.type === 'current' ? (
<div className="h-5 w-5 rounded-full bg-slate-900 border-2 border-indigo-400 flex items-center justify-center shrink-0 z-10">
<div className="h-1.5 w-1.5 rounded-full bg-indigo-400 animate-pulse" />
</div>
<div className="w-2.5 h-2.5 rounded-full bg-[#8B5CF6] ring-4 ring-[#8B5CF6]/30 animate-pulse shrink-0 z-10" />
) : (
<div className="h-5 w-5 rounded-full bg-slate-900 border-2 border-slate-700 shrink-0 z-10" />
<div className="w-2.5 h-2.5 rounded-full border border-white/30 bg-[#181622] shrink-0 z-10" />
)}
{!isLast && (
<div
className={`flex-1 mx-2 ${
isDotted
? 'border-t-2 border-dotted border-slate-600'
? 'border-t-2 border-dotted border-white/20'
: step.type === 'done'
? 'h-[2px] bg-slate-600'
: 'h-[2px] bg-slate-800'
? 'h-[2px] bg-[#8B5CF6]'
: 'h-[2px] bg-white/10'
}`}
/>
)}
</div>
<div className="mt-2.5 pr-2">
<p className="text-[11px] font-semibold text-slate-200 truncate max-w-[105px]">
<p className="text-[11px] font-medium text-[#F1F1F6] truncate max-w-[105px]">
{step.label}
</p>
<p className="text-[10px] text-slate-500 mt-0.5">
<p className="text-[10px] text-[#9A95B0] mt-0.5">
{step.date}
</p>
</div>
@@ -1179,32 +1173,32 @@ export function DashboardPage() {
</div>
</div>
{/* Right Sub-portion (~30%): Contact Lead, 3 Meta Rows, 2 Stacked Action Buttons */}
<div className="w-full lg:w-[220px] shrink-0 lg:pl-6 lg:border-l lg:border-white/[0.07] flex flex-col justify-between gap-5 pt-4 lg:pt-0 border-t lg:border-t-0 border-white/[0.07]">
{/* Right Sub-portion (~30% / 220px): Contact Lead, 3 Meta Rows, 2 Stacked Action Buttons */}
<div className="w-full lg:w-[220px] shrink-0 lg:pl-6 lg:border-l lg:border-white/[0.06] flex flex-col justify-between gap-4 pt-4 lg:pt-0 border-t lg:border-t-0 border-white/[0.06]">
{/* Contact Person */}
<div className="flex items-start justify-between gap-3">
<div>
<h4 className="text-sm font-bold text-white leading-tight truncate max-w-[140px]">
<h4 className="text-[14px] font-semibold text-[#F1F1F6] leading-tight truncate max-w-[140px]">
{leadName}
</h4>
<p className="text-xs text-slate-400 mt-0.5">{leadRole}</p>
<p className="text-[12px] text-[#9A95B0] mt-0.5">{leadRole}</p>
</div>
<div className="relative shrink-0">
<div className={`w-10 h-10 rounded-full flex items-center justify-center font-bold text-xs ${avatarColors[pIdx % 3]} text-white shadow-sm`}>
{getInitials(leadName)}
</div>
<span className="absolute bottom-0 right-0 w-2.5 h-2.5 rounded-full bg-emerald-400 ring-2 ring-[#141C30]" />
<span className="absolute bottom-0 right-0 w-2.5 h-2.5 rounded-full bg-emerald-400 ring-2 ring-[#181622]" />
</div>
</div>
{/* 3 Meta Rows */}
<div className="space-y-1.5 text-xs text-slate-300">
{/* 3 Meta Rows: Location, Time, Star Rating */}
<div className="space-y-1.5 text-[13px] font-medium text-[#D1CBDD]">
<div className="flex items-center gap-2">
<MapPin className="w-3.5 h-3.5 text-slate-400 shrink-0" />
<MapPin className="w-3.5 h-3.5 text-[#9A95B0] shrink-0" />
<span className="truncate">Gauteng, South Africa</span>
</div>
<div className="flex items-center gap-2">
<Clock className="w-3.5 h-3.5 text-slate-400 shrink-0" />
<Clock className="w-3.5 h-3.5 text-[#9A95B0] shrink-0" />
<span>01:23 pm</span>
</div>
<div className="flex items-center gap-2">
@@ -1218,7 +1212,7 @@ export function DashboardPage() {
<button
type="button"
onClick={() => openContactModalForProject(item)}
className="w-full py-2 px-3 rounded-xl border border-slate-600 hover:border-slate-400 hover:bg-white/[0.04] text-slate-200 text-xs font-semibold transition-all cursor-pointer"
className="w-full py-2 px-3 rounded-xl border border-white/10 hover:border-white/20 hover:bg-white/[0.04] text-[#F1F1F6] text-xs font-semibold transition-all cursor-pointer"
>
Contact
</button>
@@ -1228,7 +1222,7 @@ export function DashboardPage() {
setSelectedProjectItem(item);
setProjectModalTab('milestones');
}}
className="w-full py-2 px-3 rounded-xl bg-[#222C44] hover:bg-[#2B3856] text-slate-100 text-xs font-semibold transition-all cursor-pointer shadow-sm"
className="w-full py-2 px-3 rounded-xl bg-white/[0.06] hover:bg-white/[0.1] text-[#F1F1F6] text-xs font-semibold transition-all cursor-pointer shadow-sm"
>
View Details
</button>
@@ -1240,28 +1234,28 @@ export function DashboardPage() {
})
) : (
/* Friendly Empty State with Refresh Button & Informative Section */
<div className="rounded-[24px] bg-[#141C30] border border-white/[0.08] p-8 sm:p-12 shadow-xl flex flex-col items-center justify-center text-center relative overflow-hidden">
<div className="rounded-[16px] bg-[#181622] border border-white/[0.06] p-8 sm:p-12 shadow-sm flex flex-col items-center justify-center text-center relative overflow-hidden">
{/* Ambient background glow */}
<div className="absolute -top-24 left-1/2 -translate-x-1/2 w-80 h-80 rounded-full bg-indigo-500/10 blur-[100px] pointer-events-none" />
<div className="absolute -top-24 left-1/2 -translate-x-1/2 w-80 h-80 rounded-full bg-[#8B5CF6]/10 blur-[100px] pointer-events-none" />
{/* Friendly Icon Badge */}
<div className="relative mb-5">
<div className="w-16 h-16 rounded-2xl bg-indigo-500/15 border border-indigo-500/25 text-indigo-400 flex items-center justify-center shadow-lg shadow-indigo-500/10">
<Sparkles className="w-8 h-8" />
<div className="w-14 h-14 rounded-2xl bg-[#8B5CF6]/15 border border-[#8B5CF6]/25 text-[#8B5CF6] flex items-center justify-center shadow-lg shadow-[#8B5CF6]/10">
<Sparkles className="w-7 h-7" />
</div>
<span className="absolute -bottom-1 -right-1 flex h-4 w-4">
<span className="animate-ping absolute inline-flex h-full w-full rounded-full bg-indigo-400 opacity-75" />
<span className="relative inline-flex rounded-full h-4 w-4 bg-indigo-500" />
<span className="absolute -bottom-1 -right-1 flex h-3.5 w-3.5">
<span className="animate-ping absolute inline-flex h-full w-full rounded-full bg-[#8B5CF6] opacity-75" />
<span className="relative inline-flex rounded-full h-3.5 w-3.5 bg-[#8B5CF6]" />
</span>
</div>
{/* Friendly Heading */}
<h3 className="text-xl sm:text-2xl font-bold text-white tracking-tight">
{/* Friendly Heading: 20px font-bold #F1F1F6 */}
<h3 className="text-[20px] font-bold text-[#F1F1F6] tracking-tight">
{searchQuery ? 'No Matching Projects Found' : 'No Active Projects Assigned Yet'}
</h3>
{/* Friendly Message */}
<p className="text-xs sm:text-sm text-slate-400 max-w-lg mt-2.5 leading-relaxed">
{/* Friendly Message: 14px / 22px / #9A95B0 */}
<p className="text-[14px] leading-[22px] font-normal text-[#9A95B0] max-w-lg mt-2">
{searchQuery
? `We couldn't find any ongoing projects matching "${searchQuery}". Clear your search query to see your full workspace portfolio.`
: "Welcome to your Partner Workspace! Your funding organisation hasn't assigned any active project records to your profile just yet. If your funding award was recently approved or updated, click below to sync and retrieve your latest project schedule."}
@@ -1273,7 +1267,7 @@ export function DashboardPage() {
<button
type="button"
onClick={() => setSearchQuery('')}
className="inline-flex items-center gap-2 px-5 py-2.5 rounded-xl bg-slate-800 hover:bg-slate-700 text-slate-200 text-xs font-semibold transition-all cursor-pointer"
className="inline-flex items-center gap-2 px-5 py-2.5 rounded-xl bg-white/[0.06] hover:bg-white/[0.1] text-[#F1F1F6] text-xs font-semibold transition-all cursor-pointer"
>
<X className="w-4 h-4" />
<span>Clear Search</span>
@@ -1284,7 +1278,7 @@ export function DashboardPage() {
type="button"
onClick={() => refetchApplicantData()}
disabled={isRefreshingApplicantData}
className="inline-flex items-center gap-2.5 px-6 py-3 rounded-xl bg-indigo-500 hover:bg-indigo-400 text-white font-semibold text-xs tracking-wide shadow-lg shadow-indigo-500/25 active:scale-95 transition-all cursor-pointer disabled:opacity-60"
className="inline-flex items-center gap-2.5 px-6 py-2.5 rounded-xl bg-[#8B5CF6] hover:bg-[#7C3AED] text-white font-semibold text-xs tracking-wide shadow-lg shadow-[#8B5CF6]/25 active:scale-95 transition-all cursor-pointer disabled:opacity-60"
>
<RefreshCw className={`w-4 h-4 ${isRefreshingApplicantData ? 'animate-spin' : ''}`} />
<span>{isRefreshingApplicantData ? 'Refreshing Workspace...' : 'Refresh Projects'}</span>
@@ -1292,61 +1286,61 @@ export function DashboardPage() {
</div>
{/* Elegantly placed informative text below all that */}
<div className="mt-10 w-full max-w-xl text-left border-t border-white/[0.08] pt-8">
<div className="mt-10 w-full max-w-xl text-left border-t border-white/[0.06] pt-8">
<div className="flex items-center justify-between gap-2 mb-4">
<div className="flex items-center gap-2 text-indigo-300">
<div className="flex items-center gap-2 text-[#8B5CF6]">
<Info className="w-4 h-4" />
<span className="text-xs font-bold uppercase tracking-wider">How Project Activation Works</span>
<span className="text-[11px] font-semibold uppercase tracking-[0.05em]">How Project Activation Works</span>
</div>
<span className="text-[11px] text-slate-500 font-medium">Post-Fund Lifecycle</span>
<span className="text-[11px] text-[#9A95B0] font-medium">Post-Fund Lifecycle</span>
</div>
<div className="space-y-3">
<div className="p-4 rounded-2xl bg-[#0E1526]/80 border border-white/[0.05] hover:border-white/[0.1] transition-colors flex items-start gap-3.5">
<div className="h-6 w-6 rounded-lg bg-indigo-500/20 border border-indigo-500/30 text-indigo-300 flex items-center justify-center text-xs font-bold shrink-0 mt-0.5">
<div className="p-4 rounded-[14px] bg-[#0F0E17]/90 border border-white/[0.05] hover:border-white/[0.1] transition-colors flex items-start gap-3.5">
<div className="h-6 w-6 rounded-lg bg-[#8B5CF6]/20 border border-[#8B5CF6]/30 text-[#8B5CF6] flex items-center justify-center text-xs font-bold shrink-0 mt-0.5">
1
</div>
<div className="min-w-0">
<p className="text-xs font-bold text-white">Award Handover & Formal Agreement</p>
<p className="text-[11px] text-slate-400 mt-0.5 leading-relaxed">
<p className="text-[13px] font-semibold text-[#F1F1F6]">Award Handover & Formal Agreement</p>
<p className="text-[12px] leading-[18px] text-[#9A95B0] mt-0.5">
Following sanction by the approval committee, your funding organisation registers the award. A legally binding grant agreement is automatically drafted for your review and digital execution.
</p>
</div>
</div>
<div className="p-4 rounded-2xl bg-[#0E1526]/80 border border-white/[0.05] hover:border-white/[0.1] transition-colors flex items-start gap-3.5">
<div className="h-6 w-6 rounded-lg bg-indigo-500/20 border border-indigo-500/30 text-indigo-300 flex items-center justify-center text-xs font-bold shrink-0 mt-0.5">
<div className="p-4 rounded-[14px] bg-[#0F0E17]/90 border border-white/[0.05] hover:border-white/[0.1] transition-colors flex items-start gap-3.5">
<div className="h-6 w-6 rounded-lg bg-[#8B5CF6]/20 border border-[#8B5CF6]/30 text-[#8B5CF6] flex items-center justify-center text-xs font-bold shrink-0 mt-0.5">
2
</div>
<div className="min-w-0">
<p className="text-xs font-bold text-white">Milestones & Evidence Submission</p>
<p className="text-[11px] text-slate-400 mt-0.5 leading-relaxed">
<p className="text-[13px] font-semibold text-[#F1F1F6]">Milestones & Evidence Submission</p>
<p className="text-[12px] leading-[18px] text-[#9A95B0] mt-0.5">
Once the project is active, your work breakdown structure, deliverable due dates, and mandatory evidence requirements appear here so you can easily upload progress reports, invoices, and certificates.
</p>
</div>
</div>
<div className="p-4 rounded-2xl bg-[#0E1526]/80 border border-white/[0.05] hover:border-white/[0.1] transition-colors flex items-start gap-3.5">
<div className="h-6 w-6 rounded-lg bg-indigo-500/20 border border-indigo-500/30 text-indigo-300 flex items-center justify-center text-xs font-bold shrink-0 mt-0.5">
<div className="p-4 rounded-[14px] bg-[#0F0E17]/90 border border-white/[0.05] hover:border-white/[0.1] transition-colors flex items-start gap-3.5">
<div className="h-6 w-6 rounded-lg bg-[#8B5CF6]/20 border border-[#8B5CF6]/30 text-[#8B5CF6] flex items-center justify-center text-xs font-bold shrink-0 mt-0.5">
3
</div>
<div className="min-w-0">
<p className="text-xs font-bold text-white">Payment Requests & Controlled Tranches</p>
<p className="text-[11px] text-slate-400 mt-0.5 leading-relaxed">
<p className="text-[13px] font-semibold text-[#F1F1F6]">Payment Requests & Controlled Tranches</p>
<p className="text-[12px] leading-[18px] text-[#9A95B0] mt-0.5">
Funds are released safely against verified milestone deliverables and satisfied conditions precedent. You can initiate drawdown requests directly from your project workspace.
</p>
</div>
</div>
<div className="p-3.5 rounded-xl bg-indigo-950/30 border border-indigo-500/20 flex items-center justify-between gap-3 text-xs text-indigo-200">
<div className="p-3.5 rounded-xl bg-[#8B5CF6]/10 border border-[#8B5CF6]/20 flex items-center justify-between gap-3 text-xs text-[#8B5CF6]">
<div className="flex items-center gap-2">
<MessageSquare className="w-4 h-4 text-indigo-400 shrink-0" />
<MessageSquare className="w-4 h-4 text-[#8B5CF6] shrink-0" />
<span>Need status clarification? Contact your assigned Programme Officer.</span>
</div>
<button
type="button"
onClick={() => setActiveTab('messages')}
className="text-xs font-semibold text-white underline hover:text-indigo-300 cursor-pointer shrink-0"
className="text-xs font-semibold text-[#F1F1F6] underline hover:text-[#8B5CF6] cursor-pointer shrink-0"
>
Open Messages
</button>
@@ -1359,76 +1353,76 @@ export function DashboardPage() {
</div>
{/* ===============================================================
RIGHT COLUMN: OVERVIEW (2x2 Grid) + INBOX (4 of 12 cols)
RIGHT COLUMN: SIDEBAR WIDGETS (Overview & Inbox, 340px)
=============================================================== */}
<div className="lg:col-span-4 space-y-6">
<div className="w-full lg:w-[340px] space-y-5">
{/* 1. OVERVIEW CARD (2x2 Grid with vertical divider) */}
<div className="rounded-[22px] bg-[#141C30] border border-white/[0.07] p-6 sm:p-7 shadow-lg">
<h3 className="text-base sm:text-lg font-bold text-white tracking-tight mb-6">
<div className="rounded-[16px] bg-[#181622] border border-white/[0.06] p-6 shadow-sm">
<h3 className="text-[20px] leading-[28px] font-bold text-[#F1F1F6] mb-5">
Overview
</h3>
<div className="grid grid-cols-2 gap-4 relative">
{/* Subtle vertical divider between the 2 columns */}
<div className="absolute top-1 bottom-1 left-1/2 -translate-x-1/2 w-px bg-white/[0.07]" />
<div className="absolute top-1 bottom-1 left-1/2 -translate-x-1/2 w-px bg-white/[0.06]" />
{/* Left Column: Running Project & Completed Project */}
<div className="space-y-7 pr-3">
<div className="space-y-6 pr-3">
<div>
<p className="text-3xl font-extrabold text-white tracking-tight leading-none">
<p className="text-[24px] font-bold text-[#F1F1F6] leading-none">
{overviewMetrics.runningProjects}
</p>
<p className="text-xs text-slate-400 mt-2 font-medium">Running Project</p>
<p className="text-[12px] font-normal text-[#9A95B0] mt-1.5">Running Project</p>
</div>
<div>
<p className="text-3xl font-extrabold text-white tracking-tight leading-none">
<p className="text-[24px] font-bold text-[#F1F1F6] leading-none">
{overviewMetrics.completedProjects}
</p>
<p className="text-xs text-slate-400 mt-2 font-medium">Completed Project</p>
<p className="text-[12px] font-normal text-[#9A95B0] mt-1.5">Completed Project</p>
</div>
</div>
{/* Right Column: Have to Pay & Total Payment */}
<div className="space-y-7 pl-4 sm:pl-6">
<div className="space-y-6 pl-4 sm:pl-6">
<div>
<p className="text-3xl font-extrabold text-white tracking-tight leading-none">
<p className="text-[24px] font-bold text-[#F1F1F6] leading-none">
{overviewMetrics.pendingPayment}
</p>
<p className="text-xs text-slate-400 mt-2 font-medium">Have to Pay</p>
<p className="text-[12px] font-normal text-[#9A95B0] mt-1.5">Have to Pay</p>
</div>
<div>
<p className="text-3xl font-extrabold text-white tracking-tight leading-none">
<p className="text-[24px] font-bold text-[#F1F1F6] leading-none">
{overviewMetrics.totalPayment}
</p>
<p className="text-xs text-slate-400 mt-2 font-medium">Total Payment</p>
<p className="text-[12px] font-normal text-[#9A95B0] mt-1.5">Total Payment</p>
</div>
</div>
</div>
</div>
{/* 2. INBOX CARD (Matching Dribbble design: Inbox + badge, See All, and clean avatar list) */}
<div className="rounded-[22px] bg-[#141C30] border border-white/[0.07] p-6 sm:p-7 shadow-lg">
<div className="flex items-center justify-between mb-5">
<div className="rounded-[16px] bg-[#181622] border border-white/[0.06] p-6 shadow-sm">
<div className="flex items-center justify-between mb-4">
<div className="flex items-center gap-2">
<h3 className="text-base sm:text-lg font-bold text-white tracking-tight">Inbox</h3>
<span className="h-5 min-w-[20px] px-1.5 rounded-full bg-rose-500 text-white text-[11px] font-bold flex items-center justify-center">
<h3 className="text-[20px] leading-[28px] font-bold text-[#F1F1F6]">Inbox</h3>
<span className="h-5 min-w-[20px] px-1.5 rounded-full bg-[#EF4444] text-white text-[11px] font-bold flex items-center justify-center">
{unreadCount > 0 ? unreadCount : 3}
</span>
</div>
<button
type="button"
onClick={() => setActiveTab('messages')}
className="text-xs font-medium text-slate-400 hover:text-white transition-colors cursor-pointer"
className="text-[12px] font-medium text-[#9A95B0] hover:text-[#F1F1F6] transition-colors cursor-pointer"
>
See All
</button>
</div>
{/* List of Contacts & Messages */}
<div className="space-y-3.5 divide-y divide-white/[0.05]">
{/* List of Contacts & Messages: Compact vertical list */}
<div className="space-y-1 divide-y divide-white/[0.04]">
{combinedInbox.map((item) => (
<div
key={item.id}
@@ -1447,21 +1441,21 @@ export function DashboardPage() {
setMessageBody('');
}
}}
className="pt-3.5 first:pt-0 flex items-center gap-3.5 group cursor-pointer hover:bg-white/[0.02] p-1.5 rounded-xl transition-colors"
className="flex items-center gap-3 py-2 px-1 hover:bg-white/[0.02] rounded-xl transition-colors cursor-pointer"
>
<div className={`w-10 h-10 rounded-full shrink-0 flex items-center justify-center font-bold text-xs ${item.avatarBg} text-white shadow-sm`}>
<div className={`w-9 h-9 rounded-full shrink-0 flex items-center justify-center font-bold text-xs ${item.avatarBg} text-white shadow-sm`}>
{item.avatarInitials}
</div>
<div className="min-w-0 flex-1">
<div className="flex items-center gap-1.5">
<span className="text-xs font-bold text-white truncate group-hover:text-indigo-300 transition-colors">
<div className="flex items-center justify-between gap-1.5">
<span className="text-[13px] font-semibold text-[#F1F1F6] truncate group-hover:text-[#8B5CF6] transition-colors">
{item.name}
</span>
{item.unread && (
<span className="w-1.5 h-1.5 rounded-full bg-rose-500 shrink-0" />
<span className="w-1.5 h-1.5 rounded-full bg-[#EF4444] shrink-0" />
)}
</div>
<p className="text-[11px] text-slate-400 truncate mt-0.5 leading-tight">
<p className="text-[12px] font-normal text-[#9A95B0] truncate mt-0.5 leading-tight">
{item.snippet}
</p>
</div>
@@ -1479,8 +1473,8 @@ export function DashboardPage() {
<div className="space-y-6">
<div className="flex flex-wrap items-center justify-between gap-4">
<div>
<h1 className="text-2xl font-bold text-white">Messages & Communications</h1>
<p className="text-xs text-slate-400 mt-0.5">
<h1 className="text-[20px] font-bold text-[#F1F1F6]">Messages & Communications</h1>
<p className="text-[12px] text-[#9A95B0] mt-0.5">
Direct communication with your funding programme officers.
</p>
</div>
@@ -1494,16 +1488,16 @@ export function DashboardPage() {
setMessageSubject('');
setMessageBody('');
}}
className="inline-flex items-center gap-2 px-4 py-2.5 rounded-xl bg-indigo-500 hover:bg-indigo-400 text-white font-semibold text-xs shadow-lg shadow-indigo-500/20 transition-all cursor-pointer"
className="inline-flex items-center gap-2 px-4 py-2.5 rounded-xl bg-[#8B5CF6] hover:bg-[#7C3AED] text-white font-semibold text-xs shadow-lg shadow-[#8B5CF6]/20 transition-all cursor-pointer"
>
<Plus className="w-4 h-4" />
<span>New Message</span>
</button>
</div>
<div className="rounded-[22px] bg-[#141C30] border border-white/[0.07] divide-y divide-white/[0.06] overflow-hidden">
<div className="rounded-[16px] bg-[#181622] border border-white/[0.06] divide-y divide-white/[0.06] overflow-hidden">
{inboxNotifications.length === 0 ? (
<div className="p-12 text-center text-slate-400 text-sm">
<div className="p-12 text-center text-[#9A95B0] text-sm">
No new messages in your inbox yet. Messages sent by programme managers will be displayed here.
</div>
) : (
@@ -1519,24 +1513,24 @@ export function DashboardPage() {
className="p-5 hover:bg-white/[0.03] transition-colors flex items-start justify-between gap-4 cursor-pointer"
>
<div className="flex items-start gap-4 min-w-0">
<div className="h-10 w-10 rounded-full bg-indigo-500/15 border border-indigo-500/30 text-indigo-300 font-bold text-xs flex items-center justify-center shrink-0">
<div className="h-10 w-10 rounded-full bg-[#8B5CF6]/15 border border-[#8B5CF6]/30 text-[#8B5CF6] font-bold text-xs flex items-center justify-center shrink-0">
{getInitials(msg.subject || 'PFM')}
</div>
<div className="min-w-0">
<div className="flex items-center gap-2">
<h3 className="text-sm font-bold text-white truncate">
<h3 className="text-sm font-semibold text-[#F1F1F6] truncate">
{msg.subject || 'Programme Notification'}
</h3>
{isUnread && (
<span className="px-2 py-0.5 rounded-full text-[10px] font-bold bg-rose-500 text-white">
<span className="px-2 py-0.5 rounded-full text-[10px] font-bold bg-[#EF4444] text-white">
New
</span>
)}
</div>
<p className="text-xs text-slate-300 mt-1 line-clamp-2">{stripMarkdown(msg.message)}</p>
<p className="text-xs text-[#9A95B0] mt-1 line-clamp-2">{stripMarkdown(msg.message || '')}</p>
</div>
</div>
<span className="text-xs text-slate-500 shrink-0">
<span className="text-xs text-[#9A95B0]/70 shrink-0">
{formatFullDate(msg.createdAt)}
</span>
</div>
@@ -1547,19 +1541,19 @@ export function DashboardPage() {
</div>
) : activeTab === 'contracts' ? (
/* =================================================================
CONTRACTS & ORDERS TAB
CONTRACTS & AWARDS TAB
================================================================= */
<div className="space-y-6">
<div>
<h1 className="text-2xl font-bold text-white">Contracts & Approved Grants</h1>
<p className="text-xs text-slate-400 mt-0.5">
<h1 className="text-[20px] font-bold text-[#F1F1F6]">Contracts & Approved Awards</h1>
<p className="text-[12px] text-[#9A95B0] mt-0.5">
Review your approved funding awards and download or sign grant agreements.
</p>
</div>
<div className="grid grid-cols-1 md:grid-cols-2 gap-6">
{partnerSummary.contractEntries.length === 0 ? (
<div className="col-span-full rounded-[22px] bg-[#141C30] border border-white/[0.07] p-10 text-center text-sm text-slate-400">
<div className="col-span-full rounded-[16px] bg-[#181622] border border-white/[0.06] p-10 text-center text-sm text-[#9A95B0]">
No contracts have been issued to your organisation yet.
</div>
) : (
@@ -1569,47 +1563,47 @@ export function DashboardPage() {
return (
<div
key={contract.id}
className="rounded-[22px] bg-[#141C30] border border-white/[0.07] p-6 flex flex-col justify-between gap-5"
className="rounded-[16px] bg-[#181622] border border-white/[0.06] p-6 flex flex-col justify-between gap-5"
>
<div className="space-y-3">
<div className="flex items-center justify-between gap-2">
<span className="text-xs font-semibold text-indigo-400 uppercase tracking-wider">
<span className="text-xs font-semibold text-[#8B5CF6] uppercase tracking-wider">
Agreement v{contractVer}
</span>
<span
className={`px-2.5 py-0.5 rounded-full text-xs font-semibold ${
isExecuted
? 'bg-emerald-500/15 text-emerald-300 border border-emerald-500/30'
: 'bg-amber-500/15 text-amber-300 border border-amber-500/30'
: 'bg-[#F97316]/15 text-[#F97316] border border-[#F97316]/30'
}`}
>
{isExecuted ? 'Signed & Executed' : 'Action Required: Review & Sign'}
</span>
</div>
<h3 className="text-lg font-bold text-white">
<h3 className="text-lg font-bold text-[#F1F1F6]">
{fundingOrganisation?.name || 'Grant Funding Agreement'}
</h3>
<div className="grid grid-cols-2 gap-4 pt-2 text-xs">
<div>
<p className="text-slate-400">Contract Value</p>
<p className="text-sm font-bold text-white mt-0.5">
<p className="text-[#9A95B0]">Contract Value</p>
<p className="text-sm font-bold text-[#F1F1F6] mt-0.5">
{formatZAR(contract.totalValue || award?.amount || 0)}
</p>
</div>
<div>
<p className="text-slate-400">Valid Period</p>
<p className="text-slate-200 font-medium mt-0.5">
<p className="text-[#9A95B0]">Valid Period</p>
<p className="text-[#D1CBDD] font-medium mt-0.5">
{formatShortDate(contract.effectiveAt)} – {formatFullDate(contract.expiresAt)}
</p>
</div>
</div>
</div>
<div className="flex flex-wrap items-center gap-3 pt-4 border-t border-white/[0.07]">
<div className="flex flex-wrap items-center gap-3 pt-4 border-t border-white/[0.06]">
<button
type="button"
onClick={() => handleOpenSecureFile('contract', contract.id)}
className="flex-1 py-2.5 px-4 rounded-xl bg-slate-800 hover:bg-slate-700 text-slate-200 text-xs font-semibold flex items-center justify-center gap-2 transition-colors cursor-pointer"
className="flex-1 py-2.5 px-4 rounded-xl bg-white/[0.06] hover:bg-white/[0.1] text-[#F1F1F6] text-xs font-semibold flex items-center justify-center gap-2 transition-colors cursor-pointer"
>
<ExternalLink className="w-3.5 h-3.5" />
<span>Download PDF</span>
@@ -1619,7 +1613,7 @@ export function DashboardPage() {
type="button"
onClick={() => signContractMutation.mutate(contract)}
disabled={signContractMutation.isPending}
className="flex-1 py-2.5 px-4 rounded-xl bg-indigo-500 hover:bg-indigo-400 text-white text-xs font-semibold flex items-center justify-center gap-2 transition-colors cursor-pointer disabled:opacity-50"
className="flex-1 py-2.5 px-4 rounded-xl bg-[#8B5CF6] hover:bg-[#7C3AED] text-white text-xs font-semibold flex items-center justify-center gap-2 transition-colors cursor-pointer disabled:opacity-50"
>
<CheckCircle2 className="w-3.5 h-3.5" />
<span>{signContractMutation.isPending ? 'Signing...' : 'Accept & Sign'}</span>
@@ -1638,38 +1632,38 @@ export function DashboardPage() {
================================================================= */
<div className="space-y-6">
<div>
<h1 className="text-2xl font-bold text-white">Portfolio Analytics</h1>
<p className="text-xs text-slate-400 mt-0.5">
<h1 className="text-[20px] font-bold text-[#F1F1F6]">Portfolio Analytics</h1>
<p className="text-[12px] text-[#9A95B0] mt-0.5">
Performance metrics, milestone velocity, and financial drawdown burn rates.
</p>
</div>
<div className="grid grid-cols-1 sm:grid-cols-3 gap-6">
<div className="p-6 rounded-[22px] bg-[#141C30] border border-white/[0.07]">
<div className="flex items-center justify-between text-indigo-400 mb-2">
<div className="p-6 rounded-[16px] bg-[#181622] border border-white/[0.06]">
<div className="flex items-center justify-between text-[#8B5CF6] mb-2">
<TrendingUp className="w-5 h-5" />
<span className="text-xs font-semibold text-emerald-400">+18% vs target</span>
</div>
<p className="text-2xl font-bold text-white">92.4%</p>
<p className="text-xs text-slate-400 mt-1">Milestone Completion Rate</p>
<p className="text-2xl font-bold text-[#F1F1F6]">92.4%</p>
<p className="text-xs text-[#9A95B0] mt-1">Milestone Completion Rate</p>
</div>
<div className="p-6 rounded-[22px] bg-[#141C30] border border-white/[0.07]">
<div className="p-6 rounded-[16px] bg-[#181622] border border-white/[0.06]">
<div className="flex items-center justify-between text-teal-400 mb-2">
<Briefcase className="w-5 h-5" />
<span className="text-xs font-semibold text-indigo-400">On Track</span>
<span className="text-xs font-semibold text-[#8B5CF6]">On Track</span>
</div>
<p className="text-2xl font-bold text-white">{overviewMetrics.runningProjects} Active</p>
<p className="text-xs text-slate-400 mt-1">Funded Projects in Execution</p>
<p className="text-2xl font-bold text-[#F1F1F6]">{overviewMetrics.runningProjects} Active</p>
<p className="text-xs text-[#9A95B0] mt-1">Funded Projects in Execution</p>
</div>
<div className="p-6 rounded-[22px] bg-[#141C30] border border-white/[0.07]">
<div className="p-6 rounded-[16px] bg-[#181622] border border-white/[0.06]">
<div className="flex items-center justify-between text-amber-400 mb-2">
<AwardIcon className="w-5 h-5" />
<span className="text-xs font-semibold text-amber-400">Audited</span>
</div>
<p className="text-2xl font-bold text-white">100%</p>
<p className="text-xs text-slate-400 mt-1">Compliance & Evidence Health</p>
<p className="text-2xl font-bold text-[#F1F1F6]">100%</p>
<p className="text-xs text-[#9A95B0] mt-1">Compliance & Evidence Health</p>
</div>
</div>
</div>
@@ -1679,16 +1673,16 @@ export function DashboardPage() {
================================================================= */
<div className="space-y-6">
<div>
<h1 className="text-2xl font-bold text-white">Deliverables & Evidence Documents</h1>
<p className="text-xs text-slate-400 mt-0.5">
<h1 className="text-[20px] font-bold text-[#F1F1F6]">Deliverables & Evidence Documents</h1>
<p className="text-[12px] text-[#9A95B0] mt-0.5">
Invoices, payment requests, and verified deliverable proofs submitted across projects.
</p>
</div>
<div className="rounded-[22px] bg-[#141C30] border border-white/[0.07] overflow-hidden">
<div className="rounded-[16px] bg-[#181622] border border-white/[0.06] overflow-hidden">
{enrichedProjects.flatMap(p => p.evidence.map(ev => ({ ev, project: p.project }))).length === 0 ? (
<div className="p-12 text-center space-y-3">
<p className="text-sm text-slate-400">
<p className="text-sm text-[#9A95B0]">
No documents uploaded yet. Open any project on your Dashboard and click "View Details" to submit proof files.
</p>
</div>
@@ -1705,17 +1699,17 @@ export function DashboardPage() {
className="p-4 sm:p-5 flex items-center justify-between gap-4 hover:bg-white/[0.02]"
>
<div className="flex items-center gap-3.5 min-w-0">
<div className="p-2.5 rounded-xl bg-indigo-500/10 text-indigo-300">
<div className="p-2.5 rounded-xl bg-[#8B5CF6]/10 text-[#8B5CF6]">
<FileText className="w-5 h-5" />
</div>
<div className="min-w-0">
<p className="text-sm font-semibold text-white truncate">
<p className="text-sm font-semibold text-[#F1F1F6] truncate">
{EVIDENCE_TYPE_LABELS[evType] || 'Supporting Document'}{' '}
<span className="text-xs font-normal text-slate-400">
<span className="text-xs font-normal text-[#9A95B0]">
(v{ev.version || 1})
</span>
</p>
<p className="text-xs text-slate-400 truncate mt-0.5">
<p className="text-xs text-[#9A95B0] truncate mt-0.5">
Project: {project.name} • Submitted {formatFullDate(evDate)}
</p>
</div>
@@ -1723,7 +1717,7 @@ export function DashboardPage() {
<button
type="button"
onClick={() => handleOpenSecureFile('evidence', ev.id)}
className="px-3.5 py-2 rounded-xl bg-slate-800 hover:bg-slate-700 text-xs font-semibold text-indigo-300 flex items-center gap-1.5 shrink-0 cursor-pointer"
className="px-3.5 py-2 rounded-xl bg-white/[0.06] hover:bg-white/[0.1] text-xs font-semibold text-[#8B5CF6] flex items-center gap-1.5 shrink-0 cursor-pointer"
>
<ExternalLink className="w-3.5 h-3.5" />
<span>View File</span>
@@ -2177,7 +2171,7 @@ export function DashboardPage() {
<MessageViewer
content={selectedNotification.message || ''}
platform={selectedNotification.platform}
subject={selectedNotification.subject}
subject={selectedNotification.subject || undefined}
/>
</div>
@@ -113,7 +113,6 @@ export function useInAppNotifications() {
return list || [];
},
refetchInterval: 30000,
staleTime: 10000,
});
const inAppNotifications = useMemo(() => {
@@ -4,15 +4,38 @@ import { bumpMutationEpoch } from '../services/httpClient';
export const queryClient = new QueryClient({
defaultOptions: {
queries: {
staleTime: 1000 * 60 * 2, // 2 minutes fresh in memory
gcTime: 1000 * 60 * 10, // 10 minutes cache retention
refetchOnWindowFocus: false,
refetchOnReconnect: true,
// Remove caching from all pages: zero stale time and zero cache retention
// Whenever a component needs data, it must call its corresponding endpoint(s)
staleTime: 0,
gcTime: 0,
refetchOnMount: 'always',
refetchOnWindowFocus: 'always',
refetchOnReconnect: 'always',
retry: 1,
},
},
});
// Retain caching ONLY for sync status, auth token, roles and grants
const RETAINED_CACHE_OPTIONS = {
staleTime: 5 * 60 * 1000,
gcTime: 10 * 60 * 1000,
refetchOnMount: false,
refetchOnWindowFocus: false,
};
queryClient.setQueryDefaults(['roles'], RETAINED_CACHE_OPTIONS);
queryClient.setQueryDefaults(['system-roles'], RETAINED_CACHE_OPTIONS);
queryClient.setQueryDefaults(['role-claims'], RETAINED_CACHE_OPTIONS);
queryClient.setQueryDefaults(['user-roles'], RETAINED_CACHE_OPTIONS);
queryClient.setQueryDefaults(['user-claims'], RETAINED_CACHE_OPTIONS);
queryClient.setQueryDefaults(['grants'], RETAINED_CACHE_OPTIONS);
queryClient.setQueryDefaults(['user-grants'], RETAINED_CACHE_OPTIONS);
queryClient.setQueryDefaults(['user-sync'], RETAINED_CACHE_OPTIONS);
queryClient.setQueryDefaults(['sync-status'], RETAINED_CACHE_OPTIONS);
queryClient.setQueryDefaults(['auth'], RETAINED_CACHE_OPTIONS);
queryClient.setQueryDefaults(['auth-token'], RETAINED_CACHE_OPTIONS);
/**
* Invalidates queries while simultaneously bumping the mutation epoch.
*/
@@ -39,6 +39,8 @@ export const apiClient = axios.create({
headers: {
'Content-Type': 'application/json; version=1',
Accept: 'application/json; version=1',
'Cache-Control': 'no-cache, no-store, must-revalidate',
Pragma: 'no-cache',
},
timeout: 15000,
});
@@ -58,31 +60,16 @@ apiClient.interceptors.request.use(
config.url?.includes('amazonaws.com') ||
config.url?.includes('garage');
// Always attach freshness timestamp and mutation epoch to internal GET requests
// to guarantee zero stale data, bypass browser HTTP caches, and prevent stale OutputCache hits
if (config.method?.toUpperCase() === 'GET' && !isExternal) {
const headers = config.headers || {};
const hasNoCacheHeader =
headers['Cache-Control'] === 'no-cache' ||
headers['cache-control'] === 'no-cache' ||
headers['Pragma'] === 'no-cache';
if (hasNoCacheHeader) {
if (config.params instanceof URLSearchParams) {
config.params.set('_t', String(Date.now()));
} else {
config.params = config.params || {};
config.params._t = Date.now();
}
if (config.params instanceof URLSearchParams) {
config.params.set('_t', String(Date.now()));
config.params.set('_v', String(getMutationEpoch()));
} else {
if (config.params instanceof URLSearchParams) {
if (!config.params.has('_v') && !config.params.has('_t')) {
config.params.set('_v', String(getMutationEpoch()));
}
} else {
config.params = config.params || {};
if (!config.params._v && !config.params._t) {
config.params._v = getMutationEpoch();
}
}
config.params = config.params || {};
config.params._t = Date.now();
config.params._v = getMutationEpoch();
}
}
+16
View File
@@ -0,0 +1,16 @@
/// <reference types="vite/client" />
interface ImportMetaEnv {
readonly VITE_OIDC_AUTHORITY?: string;
readonly VITE_OIDC_AUDIENCE?: string;
readonly VITE_OIDC_CLIENT_ID?: string;
readonly VITE_API_BASE_URL?: string;
readonly VITE_HASHER_SALT?: string;
readonly VITE_HASHER_MIN_LENGTH?: string;
readonly [key: string]: any;
}
interface ImportMeta {
readonly env: ImportMetaEnv;
}
@@ -4,7 +4,6 @@
"experimentalDecorators": true,
"useDefineForClassFields": false,
"module": "ESNext",
"types": ["vite/client"],
"lib": [
"ES2022",
"DOM",