Compare commits
141
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
a9bb319eb1 | ||
|
|
bbba869cdd | ||
|
|
5ae2622454 | ||
|
|
bd0f03344d | ||
|
|
1af860fdc1 | ||
|
|
c04ae9a933 | ||
|
|
0e853811a9 | ||
|
|
3a3d5daefc | ||
|
|
fe9b9052e9 | ||
|
|
d25545ddad | ||
|
|
7effc32498 | ||
|
|
8909bcf5c4 | ||
|
|
411e603f51 | ||
|
|
138ef84973 | ||
|
|
312721bf51 | ||
|
|
dd59294911 | ||
|
|
28a3a17a15 | ||
|
|
4c2b0cd7d5 | ||
|
|
275fd109e6 | ||
|
|
248cc2fa2d | ||
|
|
761d14beb9 | ||
|
|
931e1a8698 | ||
|
|
aff58caeb0 | ||
|
|
1c5080bf04 | ||
|
|
1fb157aa0a | ||
|
|
0adfae1616 | ||
|
|
4856b6e6f4 | ||
|
|
0ba47269aa | ||
|
|
5e25fd7a11 | ||
|
|
f8fff04065 | ||
|
|
aca5882ad3 | ||
|
|
6b027193d1 | ||
|
|
81702a4bf8 | ||
|
|
5eeb1cbfc7 | ||
|
|
fb00986f59 | ||
|
|
0338737385 | ||
|
|
82c0936b4c | ||
|
|
5d2a886f6f | ||
|
|
dc4047ca6d | ||
|
|
46fe14d9ef | ||
|
|
f17368d939 | ||
|
|
7aea3fa8de | ||
|
|
d43eb17aa3 | ||
|
|
01547d8c3f | ||
|
|
18dd9131cb | ||
|
|
f82a5cef76 | ||
|
|
614730caab | ||
|
|
9823cd7ea6 | ||
|
|
a7a68fc290 | ||
|
|
4dbc74e488 | ||
|
|
d658497545 | ||
|
|
b947959724 | ||
|
|
58126e7573 | ||
|
|
2fede443cc | ||
|
|
80f220fcae | ||
|
|
06e2c3ee6a | ||
|
|
3c0fd8844c | ||
|
|
b7c135f0fc | ||
|
|
db053e3e5f | ||
|
|
d7177e1a72 | ||
|
|
c67041faea | ||
|
|
0e8a5e1169 | ||
|
|
41fdb067fe | ||
|
|
b4b687dfcc | ||
|
|
f200ccbf60 | ||
|
|
27f57150e1 | ||
|
|
79c63107e9 | ||
|
|
d945731252 | ||
|
|
c979a4632e | ||
|
|
9eed3cff6b | ||
|
|
6db1a2f367 | ||
|
|
06d9952cd7 | ||
|
|
92d481f75d | ||
|
|
1c36602995 | ||
|
|
5ae0c500ac | ||
|
|
db04f800e9 | ||
|
|
e7a19611f0 | ||
|
|
5ad24da7cf | ||
|
|
156dae40b7 | ||
|
|
1b67d9cf8b | ||
|
|
1bd05e64e5 | ||
|
|
303dba3c6f | ||
|
|
1dd4f54a1d | ||
|
|
fae1e080c4 | ||
|
|
4de23d27b3 | ||
|
|
d4bce12dbb | ||
|
|
299b876e1c | ||
|
|
dde1dbe056 | ||
|
|
b81e6feb45 | ||
|
|
4138a1941f | ||
|
|
17af31d810 | ||
|
|
b85cf6a1cb | ||
|
|
81ddc11746 | ||
|
|
5b94672ff5 | ||
|
|
c535392ef1 | ||
|
|
0607de1802 | ||
|
|
4c1e52e452 | ||
|
|
2414d74d1c | ||
|
|
93d9661a7f | ||
|
|
dba401fe94 | ||
|
|
42d73fe6cb | ||
|
|
1475887a7f | ||
|
|
730664d9c4 | ||
|
|
f38e569a34 | ||
|
|
7e79171f97 | ||
|
|
d7b868f2c1 | ||
|
|
d43a09b371 | ||
|
|
0dc0de1502 | ||
|
|
de47dd4a4f | ||
|
|
2810cc669b | ||
|
|
734af8c86f | ||
|
|
a754c42d14 | ||
|
|
bd7ca9372f | ||
|
|
0d01d91ab4 | ||
|
|
2c817a8818 | ||
|
|
4c3a96917b | ||
|
|
e5c3a75795 | ||
|
|
1bc7e65960 | ||
|
|
094126d84b | ||
|
|
2506241a25 | ||
|
|
cfe3530639 | ||
|
|
985557bee4 | ||
|
|
f0b8b3051a | ||
|
|
6c88b9e533 | ||
|
|
7d67408d2c | ||
|
|
d1d58a6183 | ||
|
|
60ad154841 | ||
|
|
2c13170859 | ||
|
|
d87c8aa3c9 | ||
|
|
6af7815ad1 | ||
|
|
a169f6c3f6 | ||
|
|
65ea753c19 | ||
|
|
e6d2663265 | ||
|
|
91b5814b60 | ||
|
|
0c4c848568 | ||
|
|
fb05150ebf | ||
|
|
2f9c20020b | ||
|
|
a771957db3 | ||
|
|
46267a2a34 | ||
|
|
a7f360cc2a | ||
|
|
67c08b40ca |
-192
@@ -1,192 +0,0 @@
|
||||
# PostFundManagement (PFM) Platform — Architectural Specification
|
||||
|
||||
> **Document Version**: 2.0
|
||||
> **Target Runtimes**: .NET 10.0 (C# 13) | React 19 / TypeScript 5.8 / Vite
|
||||
> **Scope**: System Architecture, Security Topology, RBAC Enforcement, and Maker-Checker Pattern
|
||||
|
||||
---
|
||||
|
||||
## 1. System Topology & Architectural Layers
|
||||
|
||||
The PFM platform is constructed using **Clean Architecture** and **Domain-Driven Design (DDD)** principles to guarantee strict decoupling of business rules, persistence concerns, and user interfaces.
|
||||
|
||||
```
|
||||
┌────────────────────────────────────────────────────────────────────────┐
|
||||
│ PRESENTATION TIER │
|
||||
│ PostFundManagement.ConsoleUI │ PostFundManagement.PartnerUI │
|
||||
│ (Internal Staff & Governance) │ (External Recipient Portal) │
|
||||
│ React 19 + TypeScript + Vite │ React 19 + WebGL (OGL) + Vite │
|
||||
└───────────────────────────────────┬────────────────────────────────────┘
|
||||
│ HTTPS / REST (v1/api/*) + OIDC PKCE
|
||||
┌───────────────────────────────────▼────────────────────────────────────┐
|
||||
│ API GATEWAY │
|
||||
│ PostFundManagement.Api (ASP.NET Core Minimal APIs / Carter) │
|
||||
│ Authentication: JWT Bearer + OpenTelemetry Observability │
|
||||
└───────────────────────────────────┬────────────────────────────────────┘
|
||||
│
|
||||
┌───────────────────────────────────▼────────────────────────────────────┐
|
||||
│ APPLICATION LAYER │
|
||||
│ PostFundManagement.Application │
|
||||
│ CQRS (Mediator), Business Validators, Domain Services, Quartz Jobs │
|
||||
└──────────────────┬─────────────────────────────────┬───────────────────┘
|
||||
│ │
|
||||
┌──────────────────▼──────────────────┐ ┌──────────▼───────────────────┐
|
||||
│ DOMAIN LAYER │ │ INFRASTRUCTURE LAYER │
|
||||
│ PostFundManagement.Domain │ │ PostFundManagement.Infrastr.│
|
||||
│ Entities, Value Objects, Enums, │ │ PostgreSQL (EF Core 10), │
|
||||
│ Domain Invariants, Audit Models │ │ Skoruba STS, S3 Garage, │
|
||||
└─────────────────────────────────────┘ │ Redis HybridCache │
|
||||
└──────────────────────────────┘
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 2. Security & Identity Architecture
|
||||
|
||||
Authentication and authorization are separated cleanly across system boundaries:
|
||||
|
||||
* **Security Token Service (STS)**: Duende IdentityServer / Skoruba Administration (`https://sts.security.khongisa.co.za`) issues cryptographically signed JWT access tokens using OpenID Connect (OIDC) with Proof Key for Code Exchange (PKCE).
|
||||
* **Audience & Scopes**: Web clients request standard OIDC scopes (`openid`, `profile`, `roles`, `email`, `address`) along with the target API resource scope (`pfm-api-dev`).
|
||||
* **Tenant Isolation**: Every authenticated principal carries a `tenant_id` claim. Multitenant query filters in EF Core enforce cross-tenant partition boundaries at the data access layer.
|
||||
* **Partner Isolation**: Grantees and external recipients possess the `partner` role. The `ConsoleUI` enforces immediate lockout for `partner` roles, restricting external users exclusively to `PartnerUI`.
|
||||
|
||||
---
|
||||
|
||||
## 3. Role-Based Access Control (RBAC) Architecture
|
||||
|
||||
PFM implements a fine-grained, claim-based RBAC model where system permissions are decomposed into **15 operational domains** and **5 progressive action capabilities**.
|
||||
|
||||
### 3.1 Role Claims Inventory
|
||||
|
||||
| Claim Key | Operational Domain | Functional Scope |
|
||||
| :--- | :--- | :--- |
|
||||
| `dashboard` | Executive Dashboard | Macro portfolio KPIs, disbursed capital, and ISO 31000 risk heatmap |
|
||||
| `governance` | Governance & Structure | Holding entities, funder portfolios, programmes, rules, and instruments |
|
||||
| `projects_milestones` | Projects & Milestones | Work breakdown structure (WBS), contractual milestones, and activities |
|
||||
| `grant_financials` | Grants & Financials | Statutory funding awards, budget lines, contracts, and disbursement tranches |
|
||||
| `evidence_risks` | M&E, Evidence & Risks | Deliverable evidence verification queue, indicators, and risk registers |
|
||||
| `beneficiaries` | Beneficiaries & Impact | Demographic targets, POPIA compliance items, and outcome deliverables |
|
||||
| `change_management` | Change Management | Project issues, contractual change requests, and budget variations |
|
||||
| `site_visits` | Site Visits | Physical field inspections, checklist observations, and inspector findings |
|
||||
| `settings_invites` | Settings Invites | Onboarding invitations and candidate notifications dispatch |
|
||||
| `settings_users` | Settings Users | User provisioning, role assignments, and account lockouts |
|
||||
| `settings_auditlog` | Settings Audit Log | Immutable audit ledger and system telemetry records |
|
||||
| `settings_notifications`| Settings Notifications | SMTP engines, notification channels, and dispatch telemetry |
|
||||
| `tenant_self_service` | Tenant Self-Service | Tenant profile configuration and delegated sub-user management |
|
||||
| `tenant_management` | Tenant Management | Multi-tenant directory, partition allocation, and solution ownership |
|
||||
| `unrestricted` | System Administration | Root administrative bypass across all system partitions |
|
||||
|
||||
### 3.2 Claim Actions & Capability Hierarchy
|
||||
|
||||
Action capabilities within a claim domain follow a strict subset hierarchy:
|
||||
|
||||
$$\text{view} \subset \text{add} \subset \text{update} \quad \text{and} \quad \text{view} \subset \text{export}$$
|
||||
|
||||
* **`view`**: Read-only access to entity lists, detailed records, metrics, and progress scores.
|
||||
* **`add`**: Can create new records, edit draft records, submit milestone evidence, and change workflow statuses *other than approval* (includes `view`).
|
||||
* **`update`**: Authoritative governance capability. Can execute **approval** status transitions (`Approved`, `Active` contracts) on records (includes `view` and `add`).
|
||||
* **`export`**: Can generate and download structured spreadsheets (`.xlsx`) and formal PDF reports (includes `view`).
|
||||
* **`all`**: Comprehensive authority (`view`, `add`, `update`, `export`) over the claim domain.
|
||||
|
||||
### 3.3 Authoritative Role-to-Claims Matrix
|
||||
|
||||
| Role | Assigned Claim Domains | Standard Capabilities | Record Approval Authority (`update`) |
|
||||
| :--- | :--- | :--- | :---: |
|
||||
| **`Admin`** | `unrestricted` | `all` | **Yes (System-wide)** |
|
||||
| **`Executive`** | `dashboard`, `governance`, `projects_milestones`, `grant_financials`, `evidence_risks`, `tenant_self_service` | `all` | **Yes** |
|
||||
| **`Manager`** | `dashboard`, `projects_milestones`, `grant_financials`, `evidence_risks`, `beneficiaries`, `change_management`, `site_visits`, `settings_invites`, `settings_users`, `settings_auditlog`, `settings_notifications`, `tenant_self_service` | `all` | **Yes** |
|
||||
| **`Supervisor`** | `dashboard`, `projects_milestones`, `grant_financials`, `evidence_risks`, `beneficiaries`, `change_management`, `site_visits`, `settings_invites`, `settings_users` | `view, add, update, export`<br>*(Dashboard: `view, export`)* | **Yes** |
|
||||
| **`Contributor`** | `projects_milestones`, `grant_financials`, `evidence_risks`, `beneficiaries`, `change_management`, `site_visits`, `settings_invites` | **`view, add`** | ❌ **Strictly Prohibited** |
|
||||
|
||||
---
|
||||
|
||||
## 4. Maker-Checker Pattern Architecture
|
||||
|
||||
To eliminate fraud risk, self-dealing, and conflicts of interest, PFM enforces the **Maker-Checker Pattern** (Segregation of Duties / Dual Authorization).
|
||||
|
||||
### 4.1 Core Principle
|
||||
> **The user who creates or drafts a record (the "Maker") is strictly prohibited from verifying or approving that record (the "Checker"), regardless of their administrative role or RBAC claim privileges.**
|
||||
|
||||
Even if an `Executive`, `Manager`, or `Supervisor` possesses the `update` claim action on a domain, they cannot approve a record if they are identified as the record's creator.
|
||||
|
||||
```mermaid
|
||||
flowchart LR
|
||||
A["Maker (User A)<br/>Creates Record"] --> B["Record Persisted<br/>with createdBy = User A"]
|
||||
B --> C{"Checker Evaluation<br/>(Approver Login)"}
|
||||
C -->|Checker == User A| D["Approval BLOCKED<br/>Maker-Checker Violation"]
|
||||
C -->|Checker == User B<br/>with 'update' claim| E["Approval GRANTED<br/>Status -> Approved"]
|
||||
```
|
||||
|
||||
### 4.2 Record Attribution Model
|
||||
Every operational domain entity immutably captures the creator identity:
|
||||
* `createdBy`: Cryptographic user identifier (Guid / Subject ID) populated from the verified token claim `context.GetUserId()` upon creation.
|
||||
* `approvedBy`: User identifier of the independent verifier populated upon successful transition to `Approved`.
|
||||
|
||||
### 4.3 Client-Side Enforcement Hooks & Helpers
|
||||
In `PostFundManagement.ConsoleUI`, the Maker-Checker rule is evaluated dynamically via [`useRbac()`](file:///c:/Users/Admin/source/repos/PostFundManagement/PostFundManagement.ConsoleUI/src/auth/rbac.ts):
|
||||
|
||||
* **`isMaker(createdBy)`**: Compares `createdBy` against current user `user.id`, `sub`, and `user.email`.
|
||||
* **`canApproveRecord(claim, createdBy)`**: Evaluates:
|
||||
$$\text{canApproveRecord} = \text{canApprove}(\text{claim}) \land \neg\text{isMaker}(\text{createdBy})$$
|
||||
* **`getApprovalRestrictionReason(claim, createdBy)`**: Surfaces explanatory feedback:
|
||||
- `"Requires update/approval permission"` if the claim lacks `update`.
|
||||
- `"Maker-Checker policy: Record creators cannot approve their own records"` if `isMaker` is true.
|
||||
|
||||
### 4.4 UI Guard Implementations
|
||||
1. **Status Dropdowns**: The `Approved` option in status `<select>` controls is disabled with contextual labels:
|
||||
```tsx
|
||||
<option value={ApprovalStatus.Approved} disabled={!canApproveRecord('projects_milestones', record.createdBy)}>
|
||||
Approved {isMaker(record.createdBy) ? '(Maker-Checker: Cannot approve own record)' : ''}
|
||||
</option>
|
||||
```
|
||||
2. **Action Buttons**: Fast-track "Approve" buttons (e.g. Budget Approval, Disbursement Authorisation, Evidence Review) are hidden or accompanied by a Maker-Checker indicator badge when viewed by the creator.
|
||||
3. **Export Controls**: `<ExportButton>` enforces domain claim checks explicitly (`claim="grant_financials"`), rejecting unauthenticated exports.
|
||||
|
||||
---
|
||||
|
||||
## 5. Caching Strategy, Mutation Versioning & Performance Optimization
|
||||
|
||||
To achieve sub-50ms page transitions while guaranteeing data freshness across mutations in a distributed architecture, PFM employs a multi-tiered caching topology.
|
||||
|
||||
```
|
||||
┌────────────────────────────────────────────────────────────────────────┐
|
||||
│ REACT QUERY CACHE (In-Memory Browser Tier) │
|
||||
│ staleTime: 2m (dynamic) | 5m (taxonomy) | gcTime: 10m │
|
||||
└───────────────────────────────────┬────────────────────────────────────┘
|
||||
│ Cache Miss / Background Refetch
|
||||
┌───────────────────────────────────▼────────────────────────────────────┐
|
||||
│ AXIOS CLIENT INTERCEPTOR & MUTATION VERSIONING (_v) │
|
||||
│ Reads/Browsing: attaches active `_v` epoch token │
|
||||
│ Mutations (POST/PUT/PATCH/DELETE): auto-bumps epoch -> new `_v` token │
|
||||
└───────────────────────────────────┬────────────────────────────────────┘
|
||||
│ HTTP GET /v1/api/*?_v=<epoch>
|
||||
┌───────────────────────────────────▼────────────────────────────────────┐
|
||||
│ ASP.NET CORE OUTPUTCACHE (Redis / Memory Tier) │
|
||||
│ Base Policy: 2-minute expiration partitioned by URI + QueryString │
|
||||
│ Hits same `_v` -> 2ms response | New `_v` -> DB query & cache refresh │
|
||||
└────────────────────────────────────────────────────────────────────────┘
|
||||
```
|
||||
|
||||
### 5.1 The OutputCache Stale Data Challenge
|
||||
ASP.NET Core Minimal APIs in `PostFundManagement.Api` attach `.CacheOutput()` to GET endpoints, using a 2-minute base sliding expiration backed by Redis / Memory cache.
|
||||
|
||||
Because OutputCache keys are generated from the HTTP Method, Host, Path, and **Query String**, a naive client refetch after a mutation (e.g. `queryClient.invalidateQueries({ queryKey: ['projects'] })`) calls `GET /v1/api/projects`. OutputCache intercepts this identical URI, returning the pre-mutation response cached earlier. The updated project status or approved milestone does not reflect in the UI until the 2-minute cache expires.
|
||||
|
||||
### 5.2 Client-Side Mutation Epoch Versioning
|
||||
PFM solves this without modifying backend endpoints via **Client-Side Mutation Versioning**:
|
||||
1. **Epoch Tracking (`activeMutationEpoch`)**: `httpClient.ts` tracks an active mutation timestamp initialized from `sessionStorage` or `Date.now()`.
|
||||
2. **Automatic Mutation Interceptor**: Every successful mutating HTTP response (`POST`, `PUT`, `PATCH`, `DELETE` with HTTP 2xx) triggers `bumpMutationEpoch()`.
|
||||
3. **Query Parameter Injection**: Outgoing `GET` requests to internal APIs automatically append `_v: getMutationEpoch()`.
|
||||
4. **Instant Invalidation**: When a mutation succeeds, the epoch is bumped. Subsequent React Query refetches send the new `_v`, producing an immediate **cache miss** in ASP.NET Core OutputCache. The backend queries PostgreSQL, returns fresh data, and caches the new state under the new `_v` key.
|
||||
5. **Cache Hit Snappiness**: Navigations between pages while no mutations have occurred reuse the stable `_v`, yielding sub-5ms responses from Redis/Memory OutputCache and 0ms from React Query in-memory cache.
|
||||
6. **Explicit Freshness (`_t`)**: When `Cache-Control: no-cache` is specified or during explicit refresh operations, a unique timestamp `_t` is attached to force a guaranteed fresh fetch.
|
||||
|
||||
### 5.3 RBAC Startup Pipeline Acceleration
|
||||
Following the introduction of fine-grained RBAC and Maker-Checker checks, startup performance was streamlined to eliminate sequential network waterfalls:
|
||||
* **Cache-First Role Claims**: `fetchAndCacheSystemRoles` and `fetchAndCacheRoleClaims` check in-memory and `localStorage` caches *before* dispatching network calls.
|
||||
* **Parallel User Resolution**: `getUserRolesFromApi` and `getUserClaimsFromApi` are resolved concurrently using `Promise.allSettled`.
|
||||
* **Parallel Claim Resolution**: Claims across all assigned roles are retrieved concurrently rather than in serial loops.
|
||||
* **Immediate Non-Blocking Session Render**: If valid session tokens and user profiles exist in `localStorage`, `AuthProvider` immediately unblocks UI rendering (`isLoading: false`) and runs tenant lockout checks, account synchronization, and claim revalidation asynchronously in the background.
|
||||
* **In-Memory User Sync & Lockout Caching**: `isUserSyncedInBrowser` and `verifyTenantLockout` maintain in-memory references to eliminate synchronous `JSON.parse` operations from the request pipeline.
|
||||
|
||||
|
||||
@@ -1,101 +0,0 @@
|
||||
<Project>
|
||||
<PropertyGroup>
|
||||
<TargetFramework>net10.0</TargetFramework>
|
||||
<ImplicitUsings>enable</ImplicitUsings>
|
||||
<Nullable>enable</Nullable>
|
||||
<LangVersion>latest</LangVersion>
|
||||
<NoWarn>$(NoWarn);1701;1702;MA0048;MA0051;AV0029;AV0030</NoWarn>
|
||||
</PropertyGroup>
|
||||
|
||||
<!-- Assembly signing and licensing for non-test projects -->
|
||||
<PropertyGroup Condition="!$(MSBuildProjectName.EndsWith('Tests'))">
|
||||
<SignAssembly>true</SignAssembly>
|
||||
<AssemblyOriginatorKeyFile>$(MSBuildThisFileDirectory)PostFundManagement.snk</AssemblyOriginatorKeyFile>
|
||||
<PackageLicenseFile>$(MSBuildThisFileDirectory)LICENSE</PackageLicenseFile>
|
||||
</PropertyGroup>
|
||||
|
||||
<!-- Centralized Common Usings across solution -->
|
||||
<ItemGroup>
|
||||
<!-- System & BCL -->
|
||||
<Using Include="System.Collections.ObjectModel" />
|
||||
<Using Include="System.ComponentModel.DataAnnotations" />
|
||||
<Using Include="System.Globalization" />
|
||||
<Using Include="System.Net" />
|
||||
<Using Include="System.Reflection" />
|
||||
<Using Include="System.Security.Cryptography" />
|
||||
<Using Include="System.Security.Cryptography.X509Certificates" />
|
||||
<Using Include="System.Text" />
|
||||
<Using Include="System.Text.Json" />
|
||||
<Using Include="System.Text.Json.Serialization" />
|
||||
<Using Include="System.Text.RegularExpressions" />
|
||||
<Using Include="System.Threading.Channels" />
|
||||
<Using Include="System.Web" />
|
||||
|
||||
<!-- Microsoft Extensions & Web Core -->
|
||||
<Using Include="Microsoft.AspNetCore.Builder" />
|
||||
<Using Include="Microsoft.AspNetCore.Components.Server.ProtectedBrowserStorage" />
|
||||
<Using Include="Microsoft.AspNetCore.DataProtection" />
|
||||
<Using Include="Microsoft.AspNetCore.Http" />
|
||||
<Using Include="Microsoft.AspNetCore.Routing" />
|
||||
<Using Include="Microsoft.Extensions.Configuration" />
|
||||
<Using Include="Microsoft.Extensions.DependencyInjection" />
|
||||
<Using Include="Microsoft.Extensions.DependencyInjection.Extensions" />
|
||||
<Using Include="Microsoft.Extensions.Hosting" />
|
||||
<Using Include="Microsoft.Extensions.Logging" />
|
||||
<Using Include="Microsoft.Extensions.Options" />
|
||||
<Using Include="Microsoft.IdentityModel.Tokens" />
|
||||
|
||||
<!-- Domain Constants -->
|
||||
<Using Static="true" Include="PostFundManagement.Domain.Extensions.Constants" />
|
||||
|
||||
<!-- CQRS, Utilities & Persistence -->
|
||||
<Using Include="FluentResults" />
|
||||
<Using Include="HashidsNet" />
|
||||
<Using Include="Humanizer" />
|
||||
<Using Include="Mediator" />
|
||||
<Using Include="Microsoft.EntityFrameworkCore" />
|
||||
<Using Include="Microsoft.EntityFrameworkCore.Design" />
|
||||
<Using Include="Microsoft.EntityFrameworkCore.Metadata.Builders" />
|
||||
<Using Include="Quartz" />
|
||||
<Using Include="Refit" />
|
||||
|
||||
<!-- API & Documentation -->
|
||||
<Using Include="Asp.Versioning" />
|
||||
<Using Include="Microsoft.AspNetCore.OpenApi" />
|
||||
<Using Include="Microsoft.OpenApi" />
|
||||
<Using Include="Scalar.AspNetCore" />
|
||||
|
||||
<!-- Authentication -->
|
||||
<Using Include="IdentityModel.AspNetCore.OAuth2Introspection" />
|
||||
<Using Include="Microsoft.AspNetCore.Authentication" />
|
||||
<Using Include="Microsoft.AspNetCore.Authentication.Cookies" />
|
||||
<Using Include="Microsoft.AspNetCore.Authentication.JwtBearer" />
|
||||
<Using Include="Microsoft.AspNetCore.Authentication.OpenIdConnect" />
|
||||
|
||||
<!-- Diagnostics & Health -->
|
||||
<Using Include="Microsoft.AspNetCore.Diagnostics.HealthChecks" />
|
||||
<Using Include="Microsoft.Extensions.Diagnostics.HealthChecks" />
|
||||
|
||||
<!-- Messaging & Cloud Storage -->
|
||||
<Using Include="Amazon.Runtime" />
|
||||
<Using Include="Amazon.S3" />
|
||||
<Using Include="Amazon.S3.Model" />
|
||||
<Using Include="MailKit.Net.Smtp" />
|
||||
<Using Include="MimeKit" />
|
||||
</ItemGroup>
|
||||
|
||||
<!-- Domain-specific Usings (Domain and downstream projects excluding Infrastructure where Claim/Activity/TokenRequest collide) -->
|
||||
<ItemGroup Condition="'$(MSBuildProjectName)' == 'PostFundManagement.Domain'">
|
||||
<Using Include="System.Security.Claims" />
|
||||
<Using Include="AccessTokenClient" />
|
||||
</ItemGroup>
|
||||
|
||||
<!-- Non-Domain / Non-Infrastructure Usings (Application, Api, Tests) -->
|
||||
<ItemGroup Condition="'$(MSBuildProjectName)' != 'PostFundManagement.Domain' and '$(MSBuildProjectName)' != 'PostFundManagement.Infrastructure'">
|
||||
<Using Include="System.Diagnostics" />
|
||||
<Using Include="System.Security.Claims" />
|
||||
<Using Include="AccessTokenClient" />
|
||||
<Using Static="true" Include="PostFundManagement.Application.Extensions.General" />
|
||||
</ItemGroup>
|
||||
</Project>
|
||||
|
||||
@@ -1,117 +0,0 @@
|
||||
<Project>
|
||||
<PropertyGroup>
|
||||
<ManagePackageVersionsCentrally>true</ManagePackageVersionsCentrally>
|
||||
<CentralPackageTransitivePinningEnabled>true</CentralPackageTransitivePinningEnabled>
|
||||
</PropertyGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<!-- Core & Utilities -->
|
||||
<PackageVersion Include="Castle.Core.AsyncInterceptor" Version="2.1.0" />
|
||||
<PackageVersion Include="Hashids.net" Version="1.7.0" />
|
||||
<PackageVersion Include="Humanizer" Version="3.0.10" />
|
||||
<PackageVersion Include="Scrutor" Version="7.0.0" />
|
||||
|
||||
<!-- Microsoft Extensions & Configuration -->
|
||||
<PackageVersion Include="Microsoft.Extensions.Caching.Hybrid" Version="10.10.0" />
|
||||
<PackageVersion Include="Microsoft.Extensions.Caching.StackExchangeRedis" Version="10.0.12" />
|
||||
<PackageVersion Include="Microsoft.Extensions.Configuration" Version="10.0.12" />
|
||||
<PackageVersion Include="Microsoft.Extensions.Configuration.Abstractions" Version="10.0.12" />
|
||||
<PackageVersion Include="Microsoft.Extensions.Configuration.EnvironmentVariables" Version="10.0.12" />
|
||||
<PackageVersion Include="Microsoft.Extensions.Configuration.Json" Version="10.0.12" />
|
||||
<PackageVersion Include="Microsoft.Extensions.Configuration.UserSecrets" Version="10.0.12" />
|
||||
<PackageVersion Include="Microsoft.Extensions.DependencyInjection" Version="10.0.12" />
|
||||
<PackageVersion Include="Microsoft.Extensions.Diagnostics.HealthChecks" Version="10.0.12" />
|
||||
<PackageVersion Include="Microsoft.Extensions.Http.Polly" Version="10.0.12" />
|
||||
<PackageVersion Include="Microsoft.Extensions.Http.Resilience" Version="10.10.0" />
|
||||
<PackageVersion Include="Microsoft.Extensions.Validation" Version="10.0.12" />
|
||||
<PackageVersion Include="Microsoft.FeatureManagement.AspNetCore" Version="4.7.0" />
|
||||
|
||||
<!-- Security, Authentication & Identity -->
|
||||
<PackageVersion Include="AccessTokenClient.Extensions" Version="5.1.0" />
|
||||
<PackageVersion Include="Duende.AccessTokenManagement" Version="4.2.0" />
|
||||
<PackageVersion Include="Duende.AccessTokenManagement.OpenIdConnect" Version="4.2.0" />
|
||||
<PackageVersion Include="IdentityModel" Version="6.2.0" />
|
||||
<PackageVersion Include="IdentityModel.AspNetCore" Version="4.3.0" />
|
||||
<PackageVersion Include="IdentityModel.AspNetCore.OAuth2introspection" Version="6.2.0" />
|
||||
<PackageVersion Include="IdentityServer4.AccessTokenValidation" Version="3.0.1" />
|
||||
<PackageVersion Include="KubernetesClient" Version="19.0.2" />
|
||||
<PackageVersion Include="Microsoft.AspNetCore.Authentication.Certificate" Version="10.0.12" />
|
||||
<PackageVersion Include="Microsoft.AspNetCore.Authentication.JwtBearer" Version="10.0.12" />
|
||||
<PackageVersion Include="Microsoft.AspNetCore.DataProtection.EntityFrameworkCore" Version="10.0.12" />
|
||||
|
||||
<!-- Upgraded to latest non-vulnerable version -->
|
||||
<PackageVersion Include="System.Security.Cryptography.Pkcs" Version="10.0.13" />
|
||||
<PackageVersion Include="System.Security.Cryptography.Xml" Version="10.0.13" />
|
||||
|
||||
<!-- Resilience -->
|
||||
<PackageVersion Include="Polly" Version="8.8.0" />
|
||||
<PackageVersion Include="Polly.Extensions" Version="8.8.0" />
|
||||
|
||||
<!-- API, Web & Documentation -->
|
||||
<PackageVersion Include="Asp.Versioning.Abstractions" Version="10.2.1" />
|
||||
<PackageVersion Include="Asp.Versioning.Http" Version="10.2.3" />
|
||||
<PackageVersion Include="Asp.Versioning.Mvc.ApiExplorer" Version="10.2.1" />
|
||||
<PackageVersion Include="Microsoft.AspNetCore.OpenApi" Version="10.0.12" />
|
||||
<PackageVersion Include="Microsoft.AspNetCore.OutputCaching.StackExchangeRedis" Version="10.0.12" />
|
||||
<PackageVersion Include="Refit.HttpClientFactory" Version="15.2.0" />
|
||||
<PackageVersion Include="Scalar.AspNetCore" Version="2.17.4" />
|
||||
|
||||
<!-- Database & Entity Framework Core -->
|
||||
<PackageVersion Include="Microsoft.EntityFrameworkCore" Version="10.0.12" />
|
||||
<PackageVersion Include="Microsoft.EntityFrameworkCore.Abstractions" Version="10.0.12" />
|
||||
<PackageVersion Include="Microsoft.EntityFrameworkCore.Analyzers" Version="10.0.12" />
|
||||
<PackageVersion Include="Microsoft.EntityFrameworkCore.Design" Version="10.0.12" />
|
||||
<PackageVersion Include="Microsoft.EntityFrameworkCore.InMemory" Version="10.0.12" />
|
||||
<PackageVersion Include="Microsoft.EntityFrameworkCore.Relational" Version="10.0.12" />
|
||||
<PackageVersion Include="Microsoft.EntityFrameworkCore.Tools" Version="10.0.12" />
|
||||
<PackageVersion Include="Npgsql.EntityFrameworkCore.PostgreSQL" Version="10.0.3" />
|
||||
|
||||
<!-- Quartz Scheduler -->
|
||||
<PackageVersion Include="Quartz" Version="4.1.0" />
|
||||
<PackageVersion Include="Quartz.AspNetCore" Version="4.1.0" />
|
||||
<PackageVersion Include="Quartz.Extensions.DependencyInjection" Version="4.1.0" />
|
||||
<PackageVersion Include="Quartz.Plugins" Version="4.1.0" />
|
||||
<PackageVersion Include="Quartz.Plugins.TimeZoneConverter" Version="4.1.0" />
|
||||
<PackageVersion Include="Quartz.Serialization.SystemTextJson" Version="4.1.0" />
|
||||
|
||||
<!-- Health Checks -->
|
||||
<PackageVersion Include="AspNetCore.HealthChecks.NpgSql" Version="9.0.0" />
|
||||
<PackageVersion Include="AspNetCore.HealthChecks.UI" Version="9.0.0" />
|
||||
<PackageVersion Include="AspNetCore.HealthChecks.UI.Client" Version="9.0.0" />
|
||||
<PackageVersion Include="AspNetCore.HealthChecks.UI.Core" Version="9.0.0" />
|
||||
<PackageVersion Include="AspNetCore.HealthChecks.UI.Data" Version="9.0.0" />
|
||||
<PackageVersion Include="AspNetCore.HealthChecks.UI.InMemory.Storage" Version="9.0.0" />
|
||||
|
||||
<!-- Observability & OpenTelemetry -->
|
||||
<PackageVersion Include="OpenTelemetry" Version="1.18.0" />
|
||||
<PackageVersion Include="OpenTelemetry.Exporter.Console" Version="1.18.0" />
|
||||
<PackageVersion Include="OpenTelemetry.Exporter.OpenTelemetryProtocol" Version="1.18.0" />
|
||||
<PackageVersion Include="OpenTelemetry.Extensions.Hosting" Version="1.18.0" />
|
||||
<PackageVersion Include="OpenTelemetry.Instrumentation.AspNetCore" Version="1.18.0" />
|
||||
<PackageVersion Include="OpenTelemetry.Instrumentation.Http" Version="1.18.0" />
|
||||
<PackageVersion Include="OpenTelemetry.Instrumentation.Runtime" Version="1.18.0" />
|
||||
|
||||
<!-- Email & Messaging -->
|
||||
<PackageVersion Include="MailKit" Version="4.18.0" />
|
||||
<PackageVersion Include="MimeKit" Version="4.18.0" />
|
||||
|
||||
<!-- CQRS & Mediator -->
|
||||
<PackageVersion Include="FluentResults" Version="4.0.0" />
|
||||
<PackageVersion Include="Mediator.Abstractions" Version="3.0.2" />
|
||||
<PackageVersion Include="Mediator.SourceGenerator" Version="3.0.2" />
|
||||
|
||||
<!-- Cloud Storage -->
|
||||
<PackageVersion Include="AWSSDK.Extensions.NetCore.Setup" Version="4.0.101.4" />
|
||||
<PackageVersion Include="AWSSDK.S3" Version="4.0.103.3" />
|
||||
|
||||
<!-- Analyzers -->
|
||||
<PackageVersion Include="Meziantou.Analyzer" Version="3.0.259" />
|
||||
|
||||
<!-- Testing -->
|
||||
<PackageVersion Include="coverlet.collector" Version="6.0.4" />
|
||||
<PackageVersion Include="Microsoft.NET.Test.Sdk" Version="17.14.1" />
|
||||
<PackageVersion Include="xunit" Version="2.9.3" />
|
||||
<PackageVersion Include="xunit.runner.visualstudio" Version="3.1.4" />
|
||||
</ItemGroup>
|
||||
</Project>
|
||||
|
||||
@@ -8,11 +8,7 @@ EXPOSE 8080
|
||||
FROM mcr.microsoft.com/dotnet/sdk:10.0 AS build
|
||||
WORKDIR /src
|
||||
|
||||
# Copy props and csproj files for layer caching
|
||||
COPY ["Directory.Build.props", "./"]
|
||||
COPY ["Directory.Packages.props", "./"]
|
||||
COPY ["PostFundManagement.snk", "./"]
|
||||
COPY ["LICENSE", "./"]
|
||||
# Copy csproj files for layer caching
|
||||
COPY ["PostFundManagement.Api/PostFundManagement.Api.csproj", "PostFundManagement.Api/"]
|
||||
COPY ["PostFundManagement.Application/PostFundManagement.Application.csproj", "PostFundManagement.Application/"]
|
||||
COPY ["PostFundManagement.Domain/PostFundManagement.Domain.csproj", "PostFundManagement.Domain/"]
|
||||
|
||||
@@ -1,36 +0,0 @@
|
||||
using PostFundManagement.Application.Abstractions.Services;
|
||||
using PostFundManagement.Application.Extensions;
|
||||
using PostFundManagement.Application.Services.Communications;
|
||||
using PostFundManagement.Domain.Abstractions;
|
||||
using PostFundManagement.Domain.Errors;
|
||||
using PostFundManagement.Domain.Extensions;
|
||||
using PostFundManagement.Domain.Models;
|
||||
|
||||
namespace PostFundManagement.Api.Endpoints.Communications;
|
||||
|
||||
[ApiVersionTarget(1)]
|
||||
public sealed class CreateNotificationEndpoint : IEndpoint
|
||||
{
|
||||
public void Map(IEndpointRouteBuilder builder)
|
||||
{
|
||||
builder.MapPost("api/communications/notifications", async (CreateNotificationRequest request,
|
||||
HttpContext context, ICommunicationsService service, CancellationToken cancellationToken = default) =>
|
||||
await service.CreateNotificationAsync(request, context.GetUserId(), cancellationToken) switch
|
||||
{
|
||||
{ IsSuccess: true } => Results.Created(),
|
||||
var r when r.HasError<ValidationError>() => Results.BadRequest(r.Errors.ToProblems()),
|
||||
var r when r.HasError<CreateError>() => Results.BadRequest(r.Errors.ToProblems()),
|
||||
var r => Results.InternalServerError(r.Errors.ToProblems())
|
||||
})
|
||||
.WithDescription("Creates a new in-app or outbound notification record")
|
||||
.RequireAuthorization(Constants.MainScope)
|
||||
.WithName(typeof(CreateNotificationEndpoint).ToEndpointName())
|
||||
.MapToApiVersion(new ApiVersion(1))
|
||||
.Produces(StatusCodes.Status201Created)
|
||||
.Produces<Problems>(StatusCodes.Status400BadRequest)
|
||||
.Produces<Problems>(StatusCodes.Status500InternalServerError)
|
||||
.Produces(StatusCodes.Status401Unauthorized)
|
||||
.WithTags(EndpointTags.Communications);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,49 +0,0 @@
|
||||
using PostFundManagement.Application.Abstractions.Services;
|
||||
using PostFundManagement.Application.Extensions;
|
||||
using PostFundManagement.Domain.Abstractions;
|
||||
using PostFundManagement.Domain.Extensions;
|
||||
using PostFundManagement.Domain.Models;
|
||||
|
||||
namespace PostFundManagement.Api.Endpoints.Communications;
|
||||
|
||||
[ApiVersionTarget(1)]
|
||||
public sealed class GetUserNotificationsEndpoint : IEndpoint
|
||||
{
|
||||
public void Map(IEndpointRouteBuilder builder)
|
||||
{
|
||||
builder.MapGet("api/communications/notifications/user/{userId:guid}", async (Guid userId,
|
||||
ICommunicationsService service, CancellationToken cancellationToken = default) =>
|
||||
await service.GetUserNotificationsAsync(userId, cancellationToken) switch
|
||||
{
|
||||
{ IsSuccess: true, Value: var notifications } => Results.Ok(notifications),
|
||||
var r => Results.InternalServerError(r.Errors.ToProblems()),
|
||||
})
|
||||
.WithDescription("Retrieves all notifications targeted at a specific user")
|
||||
.RequireAuthorization(Constants.MainScope)
|
||||
.WithName(typeof(GetUserNotificationsEndpoint).ToEndpointName())
|
||||
.MapToApiVersion(new ApiVersion(1))
|
||||
.Produces<Notification[]>(StatusCodes.Status200OK)
|
||||
.Produces(StatusCodes.Status401Unauthorized)
|
||||
.Produces<Problems>(StatusCodes.Status500InternalServerError)
|
||||
.WithTags(EndpointTags.Communications)
|
||||
.CacheOutput();
|
||||
|
||||
builder.MapGet("api/communications/notifications/inbox", async (HttpContext context,
|
||||
ICommunicationsService service, CancellationToken cancellationToken = default) =>
|
||||
await service.GetUserNotificationsAsync(context.GetUserId(), cancellationToken) switch
|
||||
{
|
||||
{ IsSuccess: true, Value: var notifications } => Results.Ok(notifications),
|
||||
var r => Results.InternalServerError(r.Errors.ToProblems()),
|
||||
})
|
||||
.WithDescription("Retrieves all in-app and system notifications targeted at the authenticated caller")
|
||||
.RequireAuthorization(Constants.MainScope)
|
||||
.WithName($"{typeof(GetUserNotificationsEndpoint).ToEndpointName()}Inbox")
|
||||
.MapToApiVersion(new ApiVersion(1))
|
||||
.Produces<Notification[]>(StatusCodes.Status200OK)
|
||||
.Produces(StatusCodes.Status401Unauthorized)
|
||||
.Produces<Problems>(StatusCodes.Status500InternalServerError)
|
||||
.WithTags(EndpointTags.Communications)
|
||||
.CacheOutput();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,37 +0,0 @@
|
||||
using PostFundManagement.Application.Abstractions.Services;
|
||||
using PostFundManagement.Application.Extensions;
|
||||
using PostFundManagement.Application.Services.Communications;
|
||||
using PostFundManagement.Domain.Abstractions;
|
||||
using PostFundManagement.Domain.Errors;
|
||||
using PostFundManagement.Domain.Extensions;
|
||||
using PostFundManagement.Domain.Models;
|
||||
|
||||
namespace PostFundManagement.Api.Endpoints.Communications;
|
||||
|
||||
[ApiVersionTarget(1)]
|
||||
public sealed class UpdateNotificationEndpoint : IEndpoint
|
||||
{
|
||||
public void Map(IEndpointRouteBuilder builder)
|
||||
{
|
||||
builder.MapPut("api/communications/notifications", async (UpdateNotificationRequest request,
|
||||
ICommunicationsService service, CancellationToken cancellationToken = default) =>
|
||||
await service.UpdateNotificationAsync(request, cancellationToken) switch
|
||||
{
|
||||
{ IsSuccess: true } => Results.NoContent(),
|
||||
var r when r.HasError<NotFoundError>() => Results.NotFound(r.Errors.ToProblems()),
|
||||
var r when r.HasError<UpdateError>() => Results.BadRequest(r.Errors.ToProblems()),
|
||||
var r => Results.InternalServerError(r.Errors.ToProblems())
|
||||
})
|
||||
.WithDescription("Updates a notification status, delivery timestamp, or error state")
|
||||
.RequireAuthorization(Constants.MainScope)
|
||||
.WithName(typeof(UpdateNotificationEndpoint).ToEndpointName())
|
||||
.MapToApiVersion(new ApiVersion(1))
|
||||
.Produces(StatusCodes.Status204NoContent)
|
||||
.Produces<Problems>(StatusCodes.Status404NotFound)
|
||||
.Produces<Problems>(StatusCodes.Status400BadRequest)
|
||||
.Produces<Problems>(StatusCodes.Status500InternalServerError)
|
||||
.Produces(StatusCodes.Status401Unauthorized)
|
||||
.WithTags(EndpointTags.Communications);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,34 +0,0 @@
|
||||
using PostFundManagement.Application.Abstractions.Services;
|
||||
using PostFundManagement.Application.Extensions;
|
||||
using PostFundManagement.Domain.Abstractions;
|
||||
using PostFundManagement.Domain.Errors;
|
||||
using PostFundManagement.Domain.Extensions;
|
||||
using PostFundManagement.Domain.Models;
|
||||
|
||||
namespace PostFundManagement.Api.Endpoints.Governance;
|
||||
|
||||
[ApiVersionTarget(1)]
|
||||
public sealed class GetTenantUsersEndpoint : IEndpoint
|
||||
{
|
||||
public void Map(IEndpointRouteBuilder builder)
|
||||
{
|
||||
builder.MapGet("api/governance/tenants/{tenantIdHash}/users", async (IGovernanceService service, string tenantIdHash,
|
||||
CancellationToken cancellationToken = default) =>
|
||||
await service.GetTenantUsersAsync(tenantIdHash, cancellationToken) switch
|
||||
{
|
||||
{ IsSuccess: true, Value: var tenantUsers } => Results.Ok(tenantUsers),
|
||||
var r when r.HasError<IdDecodeError>() => Results.Forbid(),
|
||||
var r => Results.InternalServerError(r.Errors.ToProblems())
|
||||
})
|
||||
.WithDescription("Gets users created and managed by one tenant")
|
||||
.RequireAuthorization(Constants.MainScope)
|
||||
.WithName(typeof(GetTenantUsersEndpoint).ToEndpointName("Retrieval"))
|
||||
.MapToApiVersion(new ApiVersion(1))
|
||||
.Produces<TenantUser[]>(StatusCodes.Status200OK)
|
||||
.Produces(StatusCodes.Status403Forbidden)
|
||||
.Produces(StatusCodes.Status401Unauthorized)
|
||||
.Produces<Problems>(StatusCodes.Status500InternalServerError)
|
||||
.WithTags(EndpointTags.Governance);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,36 +0,0 @@
|
||||
using PostFundManagement.Application.Abstractions.Services;
|
||||
using PostFundManagement.Application.Extensions;
|
||||
using PostFundManagement.Application.Services.Grants;
|
||||
using PostFundManagement.Domain.Abstractions;
|
||||
using PostFundManagement.Domain.Errors;
|
||||
using PostFundManagement.Domain.Extensions;
|
||||
|
||||
namespace PostFundManagement.Api.Endpoints.Grants;
|
||||
|
||||
[ApiVersionTarget(1)]
|
||||
public sealed class UpdateAwardEndpoint : IEndpoint
|
||||
{
|
||||
public void Map(IEndpointRouteBuilder builder)
|
||||
{
|
||||
builder.MapPut("api/grants/awards", async (UpdateAwardRequest request, HttpContext context,
|
||||
IGrantsService service, CancellationToken cancellationToken = default) =>
|
||||
await service.UpdateAwardAsync(request, context.GetUserId(), cancellationToken) switch
|
||||
{
|
||||
{ IsSuccess: true } => Results.Ok(),
|
||||
var r when r.HasError<NotFoundError>() => Results.NotFound(r.Errors.ToProblems()),
|
||||
var r when r.HasError<ValidationError>() => Results.BadRequest(r.Errors.ToProblems()),
|
||||
var r when r.HasError<UpdateError>() => Results.BadRequest(r.Errors.ToProblems()),
|
||||
var r => Results.InternalServerError(r.Errors.ToProblems())
|
||||
})
|
||||
.WithDescription("Updates a registered funding award")
|
||||
.RequireAuthorization(Constants.MainScope)
|
||||
.WithName(typeof(UpdateAwardEndpoint).ToEndpointName())
|
||||
.MapToApiVersion(new ApiVersion(1))
|
||||
.Produces(StatusCodes.Status200OK)
|
||||
.Produces(StatusCodes.Status401Unauthorized)
|
||||
.Produces<Problems>(StatusCodes.Status404NotFound)
|
||||
.Produces<Problems>(StatusCodes.Status400BadRequest)
|
||||
.Produces<Problems>(StatusCodes.Status500InternalServerError)
|
||||
.WithTags(EndpointTags.Grants);
|
||||
}
|
||||
}
|
||||
@@ -1,4 +1,4 @@
|
||||
using PostFundManagement.Application.Abstractions.Services;
|
||||
using PostFundManagement.Application.Abstractions.Services;
|
||||
using PostFundManagement.Application.Extensions;
|
||||
using PostFundManagement.Application.Services.Projects;
|
||||
using PostFundManagement.Domain.Abstractions;
|
||||
@@ -12,7 +12,14 @@ public sealed class CreateOutcomeEndpoint : IEndpoint
|
||||
{
|
||||
public void Map(IEndpointRouteBuilder builder)
|
||||
{
|
||||
builder.MapPost("api/projects/beneficiaries/outcomes", HandleCreateAsync)
|
||||
builder.MapPost("api/projects/beneficiaris/outcomes", async (CreateOutcomeRequest request, HttpContext context,
|
||||
IProjectService service, CancellationToken cancellationToken = default) =>
|
||||
await service.CreateOutcomeAsync(request, context.GetUserId(), cancellationToken) switch
|
||||
{
|
||||
{ IsSuccess: true } => Results.Ok(),
|
||||
var r when r.HasError<CreateError>() => Results.BadRequest(r.Errors.ToProblems()),
|
||||
var r => Results.InternalServerError(r.Errors.ToProblems())
|
||||
})
|
||||
.WithDescription("Add a new beneficiary outcome")
|
||||
.RequireAuthorization(Constants.MainScope)
|
||||
.WithName(typeof(CreateOutcomeEndpoint).ToEndpointName())
|
||||
@@ -22,25 +29,6 @@ public sealed class CreateOutcomeEndpoint : IEndpoint
|
||||
.Produces<Problems>(StatusCodes.Status500InternalServerError)
|
||||
.Produces(StatusCodes.Status401Unauthorized)
|
||||
.WithTags(EndpointTags.Projects);
|
||||
|
||||
builder.MapPost("api/projects/beneficiaris/outcomes", HandleCreateAsync)
|
||||
.WithDescription("Add a new beneficiary outcome (legacy route)")
|
||||
.RequireAuthorization(Constants.MainScope)
|
||||
.WithName(typeof(CreateOutcomeEndpoint).ToEndpointName("TypoAlias"))
|
||||
.MapToApiVersion(new ApiVersion(1))
|
||||
.Produces(StatusCodes.Status200OK)
|
||||
.Produces<Problems>(StatusCodes.Status400BadRequest)
|
||||
.Produces<Problems>(StatusCodes.Status500InternalServerError)
|
||||
.Produces(StatusCodes.Status401Unauthorized)
|
||||
.WithTags(EndpointTags.Projects);
|
||||
}
|
||||
|
||||
private static async Task<IResult> HandleCreateAsync(CreateOutcomeRequest request, HttpContext context,
|
||||
IProjectService service, CancellationToken cancellationToken = default) =>
|
||||
await service.CreateOutcomeAsync(request, context.GetUserId(), cancellationToken) switch
|
||||
{
|
||||
{ IsSuccess: true } => Results.Ok(),
|
||||
var r when r.HasError<CreateError>() => Results.BadRequest(r.Errors.ToProblems()),
|
||||
var r => Results.InternalServerError(r.Errors.ToProblems())
|
||||
};
|
||||
}
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
using PostFundManagement.Application.Abstractions.Services;
|
||||
using PostFundManagement.Application.Abstractions.Services;
|
||||
using PostFundManagement.Application.Extensions;
|
||||
using PostFundManagement.Domain.Abstractions;
|
||||
using PostFundManagement.Domain.Errors;
|
||||
@@ -12,7 +12,15 @@ public sealed class GetOutcomeEndpoint : IEndpoint
|
||||
{
|
||||
public void Map(IEndpointRouteBuilder builder)
|
||||
{
|
||||
builder.MapGet("api/projects/outcome/{outcomeId}", HandleGetAsync)
|
||||
builder.MapGet("api/projects/outcomes/{outcomeId}", async (string outcomeId, IProjectService service,
|
||||
CancellationToken cancellationToken = default) =>
|
||||
await service.GetOutcomeAsync(outcomeId, cancellationToken) switch
|
||||
{
|
||||
{ IsSuccess: true, Value: var outcome } => Results.Ok(outcome),
|
||||
var r when r.HasError<IdDecodeError>() => Results.Forbid(),
|
||||
var r when r.HasError<NotFoundError>() => Results.NotFound(r.Errors.ToProblems()),
|
||||
var r => Results.InternalServerError(r.Errors.ToProblems())
|
||||
})
|
||||
.WithDescription("Gets a specific project outcome")
|
||||
.RequireAuthorization(Constants.MainScope)
|
||||
.WithName(typeof(GetOutcomeEndpoint).ToEndpointName())
|
||||
@@ -24,28 +32,5 @@ public sealed class GetOutcomeEndpoint : IEndpoint
|
||||
.Produces<Problems>(StatusCodes.Status500InternalServerError)
|
||||
.WithTags(EndpointTags.Projects)
|
||||
.CacheOutput();
|
||||
|
||||
builder.MapGet("api/projects/outcomes/single/{outcomeId}", HandleGetAsync)
|
||||
.WithDescription("Gets a specific project outcome")
|
||||
.RequireAuthorization(Constants.MainScope)
|
||||
.WithName(typeof(GetOutcomeEndpoint).ToEndpointName("Single"))
|
||||
.MapToApiVersion(new ApiVersion(1))
|
||||
.Produces<Outcome>(StatusCodes.Status200OK)
|
||||
.Produces(StatusCodes.Status403Forbidden)
|
||||
.Produces(StatusCodes.Status401Unauthorized)
|
||||
.Produces<Problems>(StatusCodes.Status404NotFound)
|
||||
.Produces<Problems>(StatusCodes.Status500InternalServerError)
|
||||
.WithTags(EndpointTags.Projects)
|
||||
.CacheOutput();
|
||||
}
|
||||
|
||||
private static async Task<IResult> HandleGetAsync(string outcomeId, IProjectService service,
|
||||
CancellationToken cancellationToken = default) =>
|
||||
await service.GetOutcomeAsync(outcomeId, cancellationToken) switch
|
||||
{
|
||||
{ IsSuccess: true, Value: var outcome } => Results.Ok(outcome),
|
||||
var r when r.HasError<IdDecodeError>() => Results.Forbid(),
|
||||
var r when r.HasError<NotFoundError>() => Results.NotFound(r.Errors.ToProblems()),
|
||||
var r => Results.InternalServerError(r.Errors.ToProblems())
|
||||
};
|
||||
}
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
using PostFundManagement.Application.Abstractions.Services;
|
||||
using PostFundManagement.Application.Abstractions.Services;
|
||||
using PostFundManagement.Application.Extensions;
|
||||
using PostFundManagement.Domain.Abstractions;
|
||||
using PostFundManagement.Domain.Errors;
|
||||
@@ -12,7 +12,15 @@ public sealed class GetOutcomesEndpoint : IEndpoint
|
||||
{
|
||||
public void Map(IEndpointRouteBuilder builder)
|
||||
{
|
||||
builder.MapGet("api/projects/beneficiaries/{beneficiaryId}/outcomes", HandleGetAsync)
|
||||
builder.MapGet("api/projects/outcomes/{beneficiatyId}", async (string beneficiatyId, IProjectService service,
|
||||
CancellationToken cancellationToken = default) =>
|
||||
await service.GetOutcomesAsync(beneficiatyId, cancellationToken) switch
|
||||
{
|
||||
{ IsSuccess: true, Value: var outcomes } => Results.Ok(outcomes),
|
||||
var r when r.HasError<IdDecodeError>() => Results.Forbid(),
|
||||
var r when r.HasError<NotFoundError>() => Results.NotFound(r.Errors.ToProblems()),
|
||||
var r => Results.InternalServerError(r.Errors.ToProblems())
|
||||
})
|
||||
.WithDescription("Gets project beneficiary outcomes")
|
||||
.RequireAuthorization(Constants.MainScope)
|
||||
.WithName(typeof(GetOutcomesEndpoint).ToEndpointName())
|
||||
@@ -24,41 +32,5 @@ public sealed class GetOutcomesEndpoint : IEndpoint
|
||||
.Produces<Problems>(StatusCodes.Status500InternalServerError)
|
||||
.WithTags(EndpointTags.Projects)
|
||||
.CacheOutput();
|
||||
|
||||
builder.MapGet("api/projects/beneficiaris/{beneficiaryId}/outcomes", HandleGetAsync)
|
||||
.WithDescription("Gets project beneficiary outcomes")
|
||||
.RequireAuthorization(Constants.MainScope)
|
||||
.WithName(typeof(GetOutcomesEndpoint).ToEndpointName("TypoAlias"))
|
||||
.MapToApiVersion(new ApiVersion(1))
|
||||
.Produces<Outcome[]>(StatusCodes.Status200OK)
|
||||
.Produces(StatusCodes.Status403Forbidden)
|
||||
.Produces(StatusCodes.Status401Unauthorized)
|
||||
.Produces<Problems>(StatusCodes.Status404NotFound)
|
||||
.Produces<Problems>(StatusCodes.Status500InternalServerError)
|
||||
.WithTags(EndpointTags.Projects)
|
||||
.CacheOutput();
|
||||
|
||||
builder.MapGet("api/projects/outcomes/{beneficiaryId}", HandleGetAsync)
|
||||
.WithDescription("Gets project beneficiary outcomes")
|
||||
.RequireAuthorization(Constants.MainScope)
|
||||
.WithName(typeof(GetOutcomesEndpoint).ToEndpointName("Legacy"))
|
||||
.MapToApiVersion(new ApiVersion(1))
|
||||
.Produces<Outcome[]>(StatusCodes.Status200OK)
|
||||
.Produces(StatusCodes.Status403Forbidden)
|
||||
.Produces(StatusCodes.Status401Unauthorized)
|
||||
.Produces<Problems>(StatusCodes.Status404NotFound)
|
||||
.Produces<Problems>(StatusCodes.Status500InternalServerError)
|
||||
.WithTags(EndpointTags.Projects)
|
||||
.CacheOutput();
|
||||
}
|
||||
|
||||
private static async Task<IResult> HandleGetAsync(string beneficiaryId, IProjectService service,
|
||||
CancellationToken cancellationToken = default) =>
|
||||
await service.GetOutcomesAsync(beneficiaryId, cancellationToken) switch
|
||||
{
|
||||
{ IsSuccess: true, Value: var outcomes } => Results.Ok(outcomes),
|
||||
var r when r.HasError<IdDecodeError>() => Results.Forbid(),
|
||||
var r when r.HasError<NotFoundError>() => Results.NotFound(r.Errors.ToProblems()),
|
||||
var r => Results.InternalServerError(r.Errors.ToProblems())
|
||||
};
|
||||
}
|
||||
|
||||
@@ -1,37 +0,0 @@
|
||||
using PostFundManagement.Application.Abstractions.Services;
|
||||
using PostFundManagement.Application.Extensions;
|
||||
using PostFundManagement.Domain.Abstractions;
|
||||
using PostFundManagement.Domain.Errors;
|
||||
using PostFundManagement.Domain.Extensions;
|
||||
using PostFundManagement.Domain.Models;
|
||||
|
||||
namespace PostFundManagement.Api.Endpoints.Projects;
|
||||
|
||||
[ApiVersionTarget(1)]
|
||||
public sealed class GetProjectOutcomesEndpoint : IEndpoint
|
||||
{
|
||||
public void Map(IEndpointRouteBuilder builder)
|
||||
{
|
||||
builder.MapGet("api/projects/{projectId}/outcomes", async (string projectId, IProjectService service,
|
||||
CancellationToken cancellationToken = default) =>
|
||||
await service.GetProjectOutcomesAsync(projectId, cancellationToken) switch
|
||||
{
|
||||
{ IsSuccess: true, Value: var outcomes } => Results.Ok(outcomes),
|
||||
var r when r.HasError<IdDecodeError>() => Results.Forbid(),
|
||||
var r when r.HasError<NotFoundError>() => Results.NotFound(r.Errors.ToProblems()),
|
||||
var r => Results.InternalServerError(r.Errors.ToProblems())
|
||||
})
|
||||
.WithDescription("Gets project outcomes for all beneficiaries in a project")
|
||||
.RequireAuthorization(Constants.MainScope)
|
||||
.WithName(typeof(GetProjectOutcomesEndpoint).ToEndpointName())
|
||||
.MapToApiVersion(new ApiVersion(1))
|
||||
.Produces<Outcome[]>(StatusCodes.Status200OK)
|
||||
.Produces(StatusCodes.Status403Forbidden)
|
||||
.Produces(StatusCodes.Status401Unauthorized)
|
||||
.Produces<Problems>(StatusCodes.Status404NotFound)
|
||||
.Produces<Problems>(StatusCodes.Status500InternalServerError)
|
||||
.WithTags(EndpointTags.Projects)
|
||||
.CacheOutput();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,36 +0,0 @@
|
||||
using PostFundManagement.Application.Abstractions.Services;
|
||||
using PostFundManagement.Application.Extensions;
|
||||
using PostFundManagement.Application.Services.Projects;
|
||||
using PostFundManagement.Domain.Abstractions;
|
||||
using PostFundManagement.Domain.Errors;
|
||||
using PostFundManagement.Domain.Extensions;
|
||||
|
||||
namespace PostFundManagement.Api.Endpoints.Projects;
|
||||
|
||||
[ApiVersionTarget(1)]
|
||||
public class UpdateActivityEndpoint : IEndpoint
|
||||
{
|
||||
public void Map(IEndpointRouteBuilder builder)
|
||||
{
|
||||
builder.MapPut("api/projects/activities", async (UpdateActivityRequest request,
|
||||
HttpContext context, IProjectService service, CancellationToken cancellationToken = default) =>
|
||||
await service.UpdateActivityAsync(request, context.GetUserId(), cancellationToken) switch
|
||||
{
|
||||
{ IsSuccess: true } => Results.Ok(),
|
||||
var r when r.HasError<NotFoundError>() => Results.NotFound(r.Errors.ToProblems()),
|
||||
var r when r.HasError<UpdateError>() => Results.BadRequest(r.Errors.ToProblems()),
|
||||
var r => Results.InternalServerError(r.Errors.ToProblems())
|
||||
})
|
||||
.RequireAuthorization(Constants.MainScope)
|
||||
.WithDescription("Update an existing activity under project (M04)")
|
||||
.WithName(typeof(UpdateActivityEndpoint).ToEndpointName())
|
||||
.MapToApiVersion(new ApiVersion(1))
|
||||
.Produces(StatusCodes.Status200OK)
|
||||
.Produces<Problems>(StatusCodes.Status404NotFound)
|
||||
.Produces<Problems>(StatusCodes.Status400BadRequest)
|
||||
.Produces<Problems>(StatusCodes.Status500InternalServerError)
|
||||
.Produces(StatusCodes.Status401Unauthorized)
|
||||
.WithTags(EndpointTags.Projects);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -28,6 +28,7 @@ public class GetUsersEndpoint : IEndpoint
|
||||
.Produces(StatusCodes.Status404NotFound)
|
||||
.Produces(StatusCodes.Status401Unauthorized)
|
||||
.Produces<Problems>(StatusCodes.Status500InternalServerError)
|
||||
.WithTags(EndpointTags.Users);
|
||||
.WithTags(EndpointTags.Users)
|
||||
.CacheOutput();
|
||||
}
|
||||
}
|
||||
@@ -1,4 +1,4 @@
|
||||
using PostFundManagement.Application.Abstractions.Services;
|
||||
using PostFundManagement.Application.Abstractions.Services;
|
||||
using PostFundManagement.Application.Extensions;
|
||||
using PostFundManagement.Domain.Abstractions;
|
||||
using PostFundManagement.Domain.Errors;
|
||||
@@ -13,18 +13,12 @@ public sealed class SetUserLockoutEndpoint : IEndpoint
|
||||
{
|
||||
builder.MapPut("api/users/{userId:Guid}/session/lockout/{disable:bool}", async (Guid userId, bool disable, HttpContext context,
|
||||
IUserService service, CancellationToken cancellationToken = default) =>
|
||||
{
|
||||
var (_, role) = context.User.GetUserRole();
|
||||
if (role != null && role.Equals("Partner", StringComparison.OrdinalIgnoreCase))
|
||||
return Results.Forbid();
|
||||
|
||||
return await service.SetUserLockoutAsync(userId, context.GetUserId(), disable, cancellationToken) switch
|
||||
await service.SetUserLockoutAsync(userId, context.GetUserId(), disable, cancellationToken) switch
|
||||
{
|
||||
{ IsSuccess: true } => Results.Ok(),
|
||||
var r when r.HasError<NotFoundError>() => Results.NotFound(r.Errors.ToProblems()),
|
||||
var r when r.HasError<UpdateError>() => Results.BadRequest(r.Errors.ToProblems()),
|
||||
var r => Results.InternalServerError(r.Errors.ToProblems())
|
||||
};
|
||||
})
|
||||
.WithDescription("Lock user session")
|
||||
.RequireAuthorization(Constants.MainScope)
|
||||
@@ -32,7 +26,6 @@ public sealed class SetUserLockoutEndpoint : IEndpoint
|
||||
.MapToApiVersion(new ApiVersion(1))
|
||||
.Produces(StatusCodes.Status200OK)
|
||||
.Produces(StatusCodes.Status401Unauthorized)
|
||||
.Produces(StatusCodes.Status403Forbidden)
|
||||
.Produces<Problems>(StatusCodes.Status404NotFound)
|
||||
.Produces<Problems>(StatusCodes.Status400BadRequest)
|
||||
.Produces<Problems>(StatusCodes.Status500InternalServerError)
|
||||
|
||||
@@ -11,8 +11,8 @@ public class SyncUserEndpoint : IEndpoint
|
||||
{
|
||||
public void Map(IEndpointRouteBuilder builder)
|
||||
{
|
||||
builder.MapPost("api/integration/sync", async (HttpContext context, IUserService service,
|
||||
CancellationToken cancellationToken = default) =>
|
||||
builder.MapPost("api/integration/sync", async (IJobOrchestrator jobOrchestrator,
|
||||
HttpContext context, IUserService service, CancellationToken cancellationToken = default) =>
|
||||
await service.SyncUserAsync(context.GetUserId(), context.GetUserEmail(), context.GetUserRole(), cancellationToken) switch
|
||||
{
|
||||
{ IsSuccess: true } => Results.Ok(),
|
||||
|
||||
@@ -1,63 +1,111 @@
|
||||
<Project Sdk="Microsoft.NET.Sdk.Web">
|
||||
<Project Sdk="Microsoft.NET.Sdk.Web">
|
||||
|
||||
<PropertyGroup>
|
||||
<TargetFramework>net10.0</TargetFramework>
|
||||
<Nullable>enable</Nullable>
|
||||
<ImplicitUsings>enable</ImplicitUsings>
|
||||
<UserSecretsId>59a05094-4ac2-472d-af04-3407e909432d</UserSecretsId>
|
||||
<AssemblyOriginatorKeyFile>..\PostFundManagement.snk</AssemblyOriginatorKeyFile>
|
||||
<SignAssembly>True</SignAssembly>
|
||||
<PackageLicenseFile>..\LICENSE</PackageLicenseFile>
|
||||
</PropertyGroup>
|
||||
|
||||
<!-- Security (OIDC) -->
|
||||
<PropertyGroup Condition="'$(Configuration)|$(Platform)'=='Debug|AnyCPU'">
|
||||
<NoWarn>1701;1702;AV0029;AV0030;</NoWarn>
|
||||
</PropertyGroup>
|
||||
|
||||
<PropertyGroup Condition="'$(Configuration)|$(Platform)'=='Release|AnyCPU'">
|
||||
<NoWarn>1701;1702;AV0029;AV0030;</NoWarn>
|
||||
</PropertyGroup>
|
||||
|
||||
<!-- Security (IODC)-->
|
||||
<ItemGroup>
|
||||
<PackageReference Include="IdentityModel.AspNetCore" />
|
||||
<PackageReference Include="IdentityModel.AspNetCore.OAuth2introspection" />
|
||||
<PackageReference Include="IdentityServer4.AccessTokenValidation" />
|
||||
<PackageReference Include="IdentityModel" />
|
||||
<PackageReference Include="KubernetesClient" />
|
||||
<PackageReference Include="Microsoft.AspNetCore.Authentication.Certificate" />
|
||||
<PackageReference Include="Microsoft.AspNetCore.Authentication.JwtBearer" />
|
||||
<PackageReference Include="IdentityModel.AspNetCore" Version="4.3.0" />
|
||||
<PackageReference Include="IdentityModel.AspNetCore.OAuth2introspection" Version="6.2.0" />
|
||||
<PackageReference Include="IdentityServer4.AccessTokenValidation" Version="3.0.1" />
|
||||
<PackageReference Include="IdentityModel" Version="6.2.0" />
|
||||
<PackageReference Include="KubernetesClient" Version="19.0.2" />
|
||||
<PackageReference Include="Microsoft.AspNetCore.Authentication.Certificate" Version="10.0.12" />
|
||||
<PackageReference Include="Microsoft.AspNetCore.Authentication.JwtBearer" Version="10.0.12" />
|
||||
|
||||
<Using Include="Microsoft.FeatureManagement" />
|
||||
<Using Include="Microsoft.AspNetCore.Authentication" />
|
||||
<Using Include="Microsoft.AspNetCore.Authentication.Cookies" />
|
||||
<Using Include="Microsoft.AspNetCore.Authentication.OpenIdConnect" />
|
||||
<Using Include="Microsoft.AspNetCore.Authentication.JwtBearer" />
|
||||
<Using Include="Microsoft.IdentityModel.Tokens" />
|
||||
<Using Include="System.Security.Claims" />
|
||||
</ItemGroup>
|
||||
|
||||
<!-- Health Checks -->
|
||||
<ItemGroup>
|
||||
<PackageReference Include="AspNetCore.HealthChecks.UI" />
|
||||
<PackageReference Include="AspNetCore.HealthChecks.UI.Client" />
|
||||
<PackageReference Include="AspNetCore.HealthChecks.UI.InMemory.Storage" />
|
||||
<PackageReference Include="AspNetCore.HealthChecks.NpgSql" />
|
||||
<PackageReference Include="AspNetCore.HealthChecks.UI" Version="9.0.0" />
|
||||
<PackageReference Include="AspNetCore.HealthChecks.UI.Client" Version="9.0.0" />
|
||||
<PackageReference Include="AspNetCore.HealthChecks.UI.InMemory.Storage" Version="9.0.0" />
|
||||
<PackageReference Include="AspNetCore.HealthChecks.NpgSql" Version="9.0.0" />
|
||||
|
||||
<Using Include="Microsoft.Extensions.Diagnostics.HealthChecks" />
|
||||
<Using Include="Microsoft.AspNetCore.Diagnostics.HealthChecks" />
|
||||
</ItemGroup>
|
||||
|
||||
<!-- API Versioning -->
|
||||
<ItemGroup>
|
||||
<PackageReference Include="AccessTokenClient.Extensions" />
|
||||
<PackageReference Include="Asp.Versioning.Abstractions" />
|
||||
<PackageReference Include="Asp.Versioning.Http" />
|
||||
<PackageReference Include="Asp.Versioning.Mvc.ApiExplorer" />
|
||||
<PackageReference Include="AccessTokenClient.Extensions" Version="5.1.0" />
|
||||
<PackageReference Include="Asp.Versioning.Abstractions" Version="10.2.1" />
|
||||
<PackageReference Include="Asp.Versioning.Http" Version="10.2.3" />
|
||||
<PackageReference Include="Asp.Versioning.Mvc.ApiExplorer" Version="10.2.1" />
|
||||
|
||||
<Using Include="Asp.Versioning" />
|
||||
<Using Include="Asp.Versioning.Builder" />
|
||||
</ItemGroup>
|
||||
|
||||
<!-- API Documentation -->
|
||||
<ItemGroup>
|
||||
<PackageReference Include="Microsoft.AspNetCore.OpenApi" />
|
||||
<PackageReference Include="Scalar.AspNetCore" />
|
||||
<PackageReference Include="Microsoft.AspNetCore.OpenApi" Version="10.0.12" />
|
||||
<PackageReference Include="Scalar.AspNetCore" Version="2.17.4" />
|
||||
|
||||
<Using Include="Scalar.AspNetCore" />
|
||||
<Using Include="Microsoft.OpenApi" />
|
||||
<Using Include="Microsoft.AspNetCore.OpenApi" />
|
||||
</ItemGroup>
|
||||
|
||||
<!-- File Nesting -->
|
||||
<!-- file nesting -->
|
||||
<ItemGroup>
|
||||
<ProjectCapability Include="ConfigurableFileNesting" />
|
||||
<ProjectCapability Include="ConfigurableFileNestingFeatureEnabled" />
|
||||
</ItemGroup>
|
||||
|
||||
<!-- CQRS Source Generator -->
|
||||
<!-- CQRS -->
|
||||
<ItemGroup>
|
||||
<PackageReference Include="Mediator.SourceGenerator">
|
||||
<PackageReference Include="Mediator.SourceGenerator" Version="3.0.2">
|
||||
<PrivateAssets>all</PrivateAssets>
|
||||
<IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>
|
||||
</PackageReference>
|
||||
|
||||
<Using Include="FluentResults" />
|
||||
<Using Include="Mediator" />
|
||||
<Using Include="Quartz" />
|
||||
</ItemGroup>
|
||||
|
||||
<!-- Project-specific Usings -->
|
||||
<!-- Shared Static Usings -->
|
||||
<ItemGroup>
|
||||
<Using Static="true" Include="PostFundManagement.Application.Extensions.General" />
|
||||
<Using Alias="Problems" Include="Microsoft.AspNetCore.Mvc.ProblemDetails" />
|
||||
<Using Include="Asp.Versioning.Builder" />
|
||||
<Using Include="Microsoft.AspNetCore.Mvc" />
|
||||
<Using Include="Microsoft.FeatureManagement" />
|
||||
</ItemGroup>
|
||||
|
||||
<!-- Shared Global Usings -->
|
||||
<ItemGroup>
|
||||
<Using Include="System.Web" />
|
||||
<Using Include="PostFundManagement.Domain.Attributes" />
|
||||
<Using Include="System.Diagnostics" />
|
||||
<Using Include="System.Reflection" />
|
||||
<Using Include="Microsoft.AspNetCore.Mvc" />
|
||||
<Using Include="Microsoft.EntityFrameworkCore" />
|
||||
<Using Include="System.ComponentModel.DataAnnotations" />
|
||||
<Using Include="Microsoft.Extensions.DependencyInjection.Extensions" />
|
||||
<Using Include="System.Security.Cryptography.X509Certificates" />
|
||||
<Using Include="Microsoft.AspNetCore.DataProtection" />
|
||||
<Using Include="Refit" />
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup>
|
||||
|
||||
@@ -116,30 +116,7 @@ builder.Services.AddCors(options =>
|
||||
{
|
||||
options.AddPolicy(CorsPolicy, policy =>
|
||||
{
|
||||
policy.SetIsOriginAllowed(origin =>
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(origin)) return false;
|
||||
var clean = origin.TrimEnd('/');
|
||||
if (allowedOrigins.Any(a => string.Equals(a.TrimEnd('/'), clean, StringComparison.OrdinalIgnoreCase)))
|
||||
return true;
|
||||
|
||||
try
|
||||
{
|
||||
var host = new Uri(origin).Host;
|
||||
if (host.EndsWith("khongisa.co.za", StringComparison.OrdinalIgnoreCase) ||
|
||||
host.Equals("localhost", StringComparison.OrdinalIgnoreCase) ||
|
||||
host.Equals("127.0.0.1", StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
return true;
|
||||
}
|
||||
}
|
||||
catch
|
||||
{
|
||||
// ignore parse failure
|
||||
}
|
||||
|
||||
return false;
|
||||
})
|
||||
policy.WithOrigins(allowedOrigins)
|
||||
.AllowAnyHeader()
|
||||
.AllowAnyMethod()
|
||||
.AllowCredentials();
|
||||
@@ -154,7 +131,6 @@ var scheduler = await schedulerFactory.GetScheduler();
|
||||
if (scheduler!.Status != SchedulerStatus.Running)
|
||||
await scheduler.Start();
|
||||
|
||||
app.UseCors(CorsPolicy);
|
||||
app.UseHsts();
|
||||
app.UseHttpsRedirection();
|
||||
app.UseRouting();
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
{
|
||||
"EnableServiceCaching": true,
|
||||
"CorsOrigins": [
|
||||
"https://partner.ifri.khongisa.co.za",
|
||||
"https://console.ifri.khongisa.co.za",
|
||||
"https://localhost:7208"
|
||||
],
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
using PostFundManagement.Application.Services.Communications;
|
||||
using PostFundManagement.Application.Services.Communications;
|
||||
using PostFundManagement.Domain;
|
||||
using PostFundManagement.Domain.Attributes;
|
||||
using PostFundManagement.Domain.Models;
|
||||
@@ -31,9 +31,6 @@ public interface ICommunicationsService
|
||||
[Log]
|
||||
Task<Result<Notification[]>> GetNotificationsAsync(string organisationIdHash, CancellationToken cancellationToken = default);
|
||||
|
||||
[Log]
|
||||
Task<Result<Notification[]>> GetUserNotificationsAsync(Guid userId, CancellationToken cancellationToken = default);
|
||||
|
||||
[Log]
|
||||
Task<Result<Notification>> GetNotificationAsync(string notificationIdHash, CancellationToken cancellationToken = default);
|
||||
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
using PostFundManagement.Application.Services.Evidence;
|
||||
using PostFundManagement.Application.Services.Evidence;
|
||||
using PostFundManagement.Domain;
|
||||
using PostFundManagement.Domain.Attributes;
|
||||
using PostFundManagement.Domain.Models;
|
||||
@@ -15,10 +15,6 @@ public interface IEvidenceService
|
||||
[Audit]
|
||||
Task<Result> RecordIndicatorActualAmountAsync(string indicatorIdHash, decimal amount, Guid userId, CancellationToken cancellationToken = default);
|
||||
|
||||
[Log]
|
||||
[Audit]
|
||||
Task<Result> RecordIndicatorActualAmountAsync(string indicatorIdHash, decimal amount, decimal? actualCost, Guid userId, CancellationToken cancellationToken = default);
|
||||
|
||||
[Log]
|
||||
Task<Result<Indicator[]>> GetIndicatorsAsync(Pagination pagination, CancellationToken cancellationToken = default);
|
||||
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
using PostFundManagement.Application.Services.Governance;
|
||||
using PostFundManagement.Application.Services.Governance;
|
||||
using PostFundManagement.Domain;
|
||||
using PostFundManagement.Domain.Attributes;
|
||||
using PostFundManagement.Domain.Models;
|
||||
@@ -95,7 +95,4 @@ public interface IGovernanceService
|
||||
|
||||
[Log]
|
||||
Task<Result<TenantOrganisation>> GetTenantOrganisationAsync(string tenantOrganisationIdHash, CancellationToken cancellationToken = default);
|
||||
|
||||
[Log]
|
||||
Task<Result<TenantUser[]>> GetTenantUsersAsync(string tenantIdHash, CancellationToken cancellationToken = default);
|
||||
}
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
using PostFundManagement.Application.Services.Grants;
|
||||
using PostFundManagement.Application.Services.Grants;
|
||||
using PostFundManagement.Domain;
|
||||
using PostFundManagement.Domain.Attributes;
|
||||
using PostFundManagement.Domain.Models;
|
||||
@@ -45,10 +45,6 @@ public interface IGrantsService
|
||||
[Audit]
|
||||
Task<Result> CreateAwardAsync(CreateAwardRequest request, Guid userId, CancellationToken cancellationToken = default);
|
||||
|
||||
[Log]
|
||||
[Audit]
|
||||
Task<Result> UpdateAwardAsync(UpdateAwardRequest request, Guid userId, CancellationToken cancellationToken = default);
|
||||
|
||||
[Log]
|
||||
[Audit]
|
||||
Task<Result<string>> GetContractUrlAsync(string contractIdHash, CancellationToken cancellationToken = default);
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
using PostFundManagement.Application.Services.Projects;
|
||||
using PostFundManagement.Application.Services.Projects;
|
||||
using PostFundManagement.Domain;
|
||||
using PostFundManagement.Domain.Attributes;
|
||||
using PostFundManagement.Domain.Models;
|
||||
@@ -28,10 +28,6 @@ public interface IProjectService
|
||||
[Audit]
|
||||
Task<Result> CreateActivityAsync(CreateActivityRequest request, Guid userId, CancellationToken cancellationToken = default);
|
||||
|
||||
[Log]
|
||||
[Audit]
|
||||
Task<Result> UpdateActivityAsync(UpdateActivityRequest request, Guid userId, CancellationToken cancellationToken = default);
|
||||
|
||||
[Log]
|
||||
Task<Result<Milestone[]>> GetProjectMilestonesAsync(string projectIdHash, CancellationToken cancellationToken = default);
|
||||
|
||||
@@ -142,9 +138,6 @@ public interface IProjectService
|
||||
[Log]
|
||||
Task<Result<Outcome[]>> GetOutcomesAsync(string beneficiaryIdHash, CancellationToken cancellationToken = default);
|
||||
|
||||
[Log]
|
||||
Task<Result<Outcome[]>> GetProjectOutcomesAsync(string projectIdHash, CancellationToken cancellationToken = default);
|
||||
|
||||
[Log]
|
||||
Task<Result<Outcome>> GetOutcomeAsync(string outcomeIdHash, CancellationToken cancellationToken = default);
|
||||
|
||||
|
||||
@@ -40,10 +40,8 @@ public class ClientCredentialsTokenHandler(ILogger<ClientCredentialsTokenHandler
|
||||
|
||||
if (!response.IsSuccessStatusCode)
|
||||
{
|
||||
var content = await response.Content.ReadAsStringAsync(cancellationToken);
|
||||
logger.LogWarning("HTTP request failed with status {StatusCode} for {Method} {RequestUri}. Details: {Details}",
|
||||
response.StatusCode, request.Method, request.RequestUri, content);
|
||||
|
||||
logger.LogWarning("HTTP request failed with status {StatusCode} for {Method} {RequestUri}",
|
||||
response.StatusCode, request.Method, request.RequestUri);
|
||||
}
|
||||
|
||||
return response;
|
||||
|
||||
@@ -1,39 +1,11 @@
|
||||
using System.Reflection;
|
||||
using System.Security.Claims;
|
||||
using System.Text.Json;
|
||||
using Castle.DynamicProxy;
|
||||
using FluentResults;
|
||||
using Microsoft.AspNetCore.Http;
|
||||
using PostFundManagement.Application.Services.Shared;
|
||||
using PostFundManagement.Application.Services.Shared;
|
||||
using PostFundManagement.Domain.Attributes;
|
||||
|
||||
namespace PostFundManagement.Application.Interceptors;
|
||||
|
||||
public sealed class AuditInterceptor(AuditService auditService, IHttpContextAccessor httpContextAccessor) : IAsyncInterceptor
|
||||
{
|
||||
public void InterceptSynchronous(IInvocation invocation)
|
||||
{
|
||||
var method = invocation.MethodInvocationTarget ?? invocation.Method;
|
||||
var auditAttr = method.GetCustomAttribute<AuditAttribute>()
|
||||
?? invocation.Method.GetCustomAttribute<AuditAttribute>();
|
||||
|
||||
if (auditAttr == null)
|
||||
{
|
||||
invocation.Proceed();
|
||||
return;
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
invocation.Proceed();
|
||||
AuditAction(invocation, invocation.ReturnValue, null);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
AuditAction(invocation, null, ex);
|
||||
throw;
|
||||
}
|
||||
}
|
||||
public void InterceptSynchronous(IInvocation invocation) => invocation.Proceed();
|
||||
|
||||
public void InterceptAsynchronous(IInvocation invocation) =>
|
||||
invocation.ReturnValue = InternalInterceptAsynchronous(invocation);
|
||||
@@ -41,177 +13,49 @@ public sealed class AuditInterceptor(AuditService auditService, IHttpContextAcce
|
||||
public void InterceptAsynchronous<TResult>(IInvocation invocation) =>
|
||||
invocation.ReturnValue = InternalInterceptAsynchronous<TResult>(invocation);
|
||||
|
||||
private async Task InternalInterceptAsynchronous(IInvocation invocation)
|
||||
{
|
||||
var method = invocation.MethodInvocationTarget ?? invocation.Method;
|
||||
var auditAttr = method.GetCustomAttribute<AuditAttribute>()
|
||||
?? invocation.Method.GetCustomAttribute<AuditAttribute>();
|
||||
|
||||
if (auditAttr == null)
|
||||
private static async Task InternalInterceptAsynchronous(IInvocation invocation)
|
||||
{
|
||||
invocation.Proceed();
|
||||
var t = (Task)invocation.ReturnValue;
|
||||
await t.ConfigureAwait(false);
|
||||
return;
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
invocation.Proceed();
|
||||
var task = (Task)invocation.ReturnValue;
|
||||
|
||||
await task.ConfigureAwait(false);
|
||||
await AuditActionAsync(invocation, null, null).ConfigureAwait(false);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
await AuditActionAsync(invocation, null, ex).ConfigureAwait(false);
|
||||
throw;
|
||||
}
|
||||
}
|
||||
|
||||
private async Task<TResult> InternalInterceptAsynchronous<TResult>(IInvocation invocation)
|
||||
{
|
||||
invocation.Proceed();
|
||||
|
||||
var task = (Task<TResult>)invocation.ReturnValue;
|
||||
var resultObj = await task.ConfigureAwait(false);
|
||||
|
||||
var method = invocation.MethodInvocationTarget ?? invocation.Method;
|
||||
var auditAttr = method.GetCustomAttribute<AuditAttribute>()
|
||||
?? invocation.Method.GetCustomAttribute<AuditAttribute>();
|
||||
|
||||
if (auditAttr == null)
|
||||
{
|
||||
invocation.Proceed();
|
||||
var t = (Task<TResult>)invocation.ReturnValue;
|
||||
return await t.ConfigureAwait(false);
|
||||
}
|
||||
if (auditAttr == null) return resultObj;
|
||||
|
||||
TResult resultObj;
|
||||
try
|
||||
{
|
||||
invocation.Proceed();
|
||||
var task = (Task<TResult>)invocation.ReturnValue;
|
||||
resultObj = await task.ConfigureAwait(false);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
await AuditActionAsync(invocation, null, ex).ConfigureAwait(false);
|
||||
throw;
|
||||
}
|
||||
|
||||
await AuditActionAsync(invocation, resultObj, null).ConfigureAwait(false);
|
||||
return resultObj;
|
||||
}
|
||||
|
||||
private async Task AuditActionAsync(IInvocation invocation, object? resultObj, Exception? exception)
|
||||
if (resultObj is IResultBase result && result.IsSuccess)
|
||||
{
|
||||
try
|
||||
{
|
||||
var method = invocation.MethodInvocationTarget ?? invocation.Method;
|
||||
var action = ResolveActionName(method.Name);
|
||||
var entityName = ResolveEntityName(method, resultObj, invocation.Arguments);
|
||||
var entityId = ResolveEntityId(resultObj, invocation.Arguments);
|
||||
var changes = BuildPayloadSummary(method.GetParameters(), invocation.Arguments);
|
||||
var changes = BuildPayloadSummary(invocation.Method.GetParameters(), invocation.Arguments);
|
||||
var performedBy = ResolveUserId();
|
||||
var tenantId = ResolveTenantId(invocation.Arguments);
|
||||
|
||||
if (exception != null)
|
||||
{
|
||||
var errorDetails = $"[Exception] {exception.GetType().Name}: {exception.Message}\n{exception.StackTrace}";
|
||||
await auditService.LogActionAsync(
|
||||
entityId: entityId,
|
||||
entityName: entityName,
|
||||
action: action,
|
||||
changes: changes,
|
||||
performedBy: performedBy,
|
||||
error: errorDetails,
|
||||
tenantId: tenantId).ConfigureAwait(false);
|
||||
return;
|
||||
}
|
||||
|
||||
if (resultObj is IResultBase result)
|
||||
{
|
||||
if (result.IsSuccess)
|
||||
{
|
||||
await auditService.LogActionAsync(
|
||||
entityId: entityId,
|
||||
entityName: entityName,
|
||||
action: action,
|
||||
changes: changes,
|
||||
performedBy: performedBy,
|
||||
error: null,
|
||||
tenantId: tenantId).ConfigureAwait(false);
|
||||
}
|
||||
else if (result.IsFailed)
|
||||
{
|
||||
var errorMessages = new List<string>();
|
||||
if (result.Reasons != null)
|
||||
{
|
||||
foreach (var reason in result.Reasons)
|
||||
{
|
||||
if (reason is IExceptionalError exceptionalError)
|
||||
errorMessages.Add($"{exceptionalError.Message} ({exceptionalError.Exception.GetType().Name}: {exceptionalError.Exception.Message})\n{exceptionalError.Exception.StackTrace}");
|
||||
else if (reason is IError error)
|
||||
errorMessages.Add(error.Message);
|
||||
}
|
||||
}
|
||||
|
||||
var rawErrorType = result.Reasons?.FirstOrDefault()?.GetType().Name.Replace("Error", string.Empty);
|
||||
var errorType = string.IsNullOrWhiteSpace(rawErrorType) || rawErrorType == "Reason" ? "Error" : rawErrorType;
|
||||
var errorsString = string.Join("; ", errorMessages);
|
||||
var errorDetails = $"[{errorType}] {errorsString}";
|
||||
|
||||
await auditService.LogActionAsync(
|
||||
entityId: entityId,
|
||||
entityName: entityName,
|
||||
action: action,
|
||||
changes: changes,
|
||||
performedBy: performedBy,
|
||||
error: errorDetails,
|
||||
tenantId: tenantId).ConfigureAwait(false);
|
||||
}
|
||||
}
|
||||
else
|
||||
{
|
||||
await auditService.LogActionAsync(
|
||||
entityId: entityId,
|
||||
entityName: entityName,
|
||||
action: action,
|
||||
changes: changes,
|
||||
performedBy: performedBy,
|
||||
error: null,
|
||||
tenantId: tenantId).ConfigureAwait(false);
|
||||
}
|
||||
}
|
||||
catch
|
||||
{
|
||||
/* eat all exceptions, don't interrupt program flow */
|
||||
performedBy: performedBy).ConfigureAwait(false);
|
||||
}
|
||||
catch { /* eat all exceptions, don't interrupt program flow */ }
|
||||
}
|
||||
|
||||
private void AuditAction(IInvocation invocation, object? resultObj, Exception? exception)
|
||||
{
|
||||
try
|
||||
{
|
||||
AuditActionAsync(invocation, resultObj, exception).GetAwaiter().GetResult();
|
||||
}
|
||||
catch
|
||||
{
|
||||
/* eat all exceptions, don't interrupt program flow */
|
||||
}
|
||||
}
|
||||
|
||||
private static long? ResolveTenantId(object[] args)
|
||||
{
|
||||
foreach (var arg in args)
|
||||
{
|
||||
if (arg == null || arg is CancellationToken or IFormFile) continue;
|
||||
var prop = arg.GetType().GetProperty("TenantId");
|
||||
if (prop != null)
|
||||
{
|
||||
var val = prop.GetValue(arg);
|
||||
if (val is long l && l > 0) return l;
|
||||
if (val is int i && i > 0) return i;
|
||||
if (val is string s && long.TryParse(s, out var parsed) && parsed > 0) return parsed;
|
||||
}
|
||||
}
|
||||
return null;
|
||||
return resultObj;
|
||||
}
|
||||
|
||||
private static string ResolveActionName(string methodName) =>
|
||||
@@ -229,7 +73,7 @@ public sealed class AuditInterceptor(AuditService auditService, IHttpContextAcce
|
||||
{
|
||||
var genericDef = returnType.GetGenericTypeDefinition();
|
||||
|
||||
if (genericDef == typeof(Task<>))
|
||||
if (genericDef == typeof(Task<>) || genericDef == typeof(Task<>))
|
||||
innerType = returnType.GetGenericArguments()[0];
|
||||
}
|
||||
|
||||
|
||||
@@ -1,29 +1,105 @@
|
||||
<Project Sdk="Microsoft.NET.Sdk">
|
||||
<Project Sdk="Microsoft.NET.Sdk">
|
||||
|
||||
<PropertyGroup>
|
||||
<TargetFramework>net10.0</TargetFramework>
|
||||
<ImplicitUsings>enable</ImplicitUsings>
|
||||
<Nullable>enable</Nullable>
|
||||
<AssemblyOriginatorKeyFile>..\PostFundManagement.snk</AssemblyOriginatorKeyFile>
|
||||
<SignAssembly>True</SignAssembly>
|
||||
<PackageLicenseFile>..\LICENSE</PackageLicenseFile>
|
||||
</PropertyGroup>
|
||||
|
||||
<PropertyGroup Condition="'$(Configuration)|$(Platform)'=='Debug|AnyCPU'">
|
||||
<NoWarn>1701;1702;AV0029;AV0030;</NoWarn>
|
||||
</PropertyGroup>
|
||||
|
||||
<PropertyGroup Condition="'$(Configuration)|$(Platform)'=='Release|AnyCPU'">
|
||||
<NoWarn>1701;1702;AV0029;AV0030;</NoWarn>
|
||||
</PropertyGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<ProjectReference Include="..\PostFundManagement.Domain\PostFundManagement.Domain.csproj" />
|
||||
<ProjectReference Include="..\PostFundManagement.Infrastructure\PostFundManagement.Infrastructure.csproj" />
|
||||
</ItemGroup>
|
||||
|
||||
<!-- Dependencies -->
|
||||
<!-- API Documentation -->
|
||||
<ItemGroup>
|
||||
<PackageReference Include="Castle.Core.AsyncInterceptor" />
|
||||
<PackageReference Include="Duende.AccessTokenManagement" />
|
||||
<PackageReference Include="Duende.AccessTokenManagement.OpenIdConnect" />
|
||||
<PackageReference Include="Microsoft.AspNetCore.OpenApi" />
|
||||
<PackageReference Include="Microsoft.Extensions.Validation" />
|
||||
<PackageReference Include="Scalar.AspNetCore" />
|
||||
<PackageReference Include="Castle.Core.AsyncInterceptor" Version="2.1.0" />
|
||||
<PackageReference Include="Duende.AccessTokenManagement" Version="4.2.0" />
|
||||
<PackageReference Include="Duende.AccessTokenManagement.OpenIdConnect" Version="4.2.0" />
|
||||
<PackageReference Include="Microsoft.AspNetCore.OpenApi" Version="10.0.12" />
|
||||
<PackageReference Include="Microsoft.Extensions.Validation" Version="10.0.12" />
|
||||
<PackageReference Include="Scalar.AspNetCore" Version="2.17.4" />
|
||||
|
||||
<Using Include="Scalar.AspNetCore" />
|
||||
<Using Include="Microsoft.OpenApi" />
|
||||
<Using Include="Microsoft.AspNetCore.OpenApi" />
|
||||
</ItemGroup>
|
||||
|
||||
<!-- Project-specific Usings -->
|
||||
<!-- Shared Static Usings -->
|
||||
<ItemGroup>
|
||||
<Using Include="Castle.DynamicProxy" />
|
||||
<Using Include="Duende.AccessTokenManagement" />
|
||||
<Using Static="true" Include="PostFundManagement.Application.Extensions.General" />
|
||||
<Using Static="true" Include="PostFundManagement.Domain.Extensions.Constants" />
|
||||
</ItemGroup>
|
||||
|
||||
<!-- Shared Usings -->
|
||||
<ItemGroup>
|
||||
<Using Include="Humanizer" />
|
||||
<Using Include="Duende.AccessTokenManagement.OpenIdConnect" />
|
||||
<Using Include="Duende.AccessTokenManagement" />
|
||||
<Using Include="Castle.DynamicProxy" />
|
||||
<Using Include="Microsoft.FeatureManagement" />
|
||||
<Using Include="System.ComponentModel.DataAnnotations" />
|
||||
<Using Include="Microsoft.Extensions.Caching.Distributed" />
|
||||
<Using Include="Microsoft.Extensions.Caching.Hybrid" />
|
||||
<Using Include="Microsoft.FeatureManagement" />
|
||||
<Using Include="System.IdentityModel.Tokens.Jwt" />
|
||||
<Using Include="Asp.Versioning" />
|
||||
<Using Include="System.Security.Claims" />
|
||||
<Using Include="System.Diagnostics" />
|
||||
<Using Include="MimeKit" />
|
||||
<Using Include="MailKit.Net.Smtp" />
|
||||
<Using Include="Quartz" />
|
||||
<Using Include="Refit" />
|
||||
<Using Include="AccessTokenClient" />
|
||||
<Using Include="Microsoft.AspNetCore.Authentication" />
|
||||
<Using Include="Microsoft.AspNetCore.Authentication.OpenIdConnect" />
|
||||
<Using Include="Microsoft.AspNetCore.Authentication.Cookies" />
|
||||
<Using Include="IdentityModel.AspNetCore.OAuth2Introspection" />
|
||||
<Using Include="Microsoft.AspNetCore.Authentication.JwtBearer" />
|
||||
<Using Include="Microsoft.Extensions.Configuration" />
|
||||
<Using Include="Amazon.S3" />
|
||||
<Using Include="Amazon.S3.Model" />
|
||||
<Using Include="Amazon.Runtime" />
|
||||
<Using Include="Microsoft.AspNetCore.DataProtection.EntityFrameworkCore" />
|
||||
<Using Include="Microsoft.EntityFrameworkCore" />
|
||||
<Using Include="Microsoft.EntityFrameworkCore.Design" />
|
||||
<Using Include="Microsoft.EntityFrameworkCore.Metadata.Builders" />
|
||||
<Using Include="Mediator" />
|
||||
<Using Include="FluentResults" />
|
||||
<Using Include="Microsoft.AspNetCore.DataProtection" />
|
||||
<Using Include="System.Security.Cryptography.X509Certificates" />
|
||||
<Using Include="Microsoft.AspNetCore.Components.Server.ProtectedBrowserStorage" />
|
||||
<Using Include="System.Text.Json.Serialization" />
|
||||
<Using Include="System.Reflection" />
|
||||
<Using Include="Microsoft.Extensions.DependencyInjection.Extensions" />
|
||||
<Using Include="Microsoft.AspNetCore.Routing" />
|
||||
<Using Include="System.Web" />
|
||||
<Using Include="Microsoft.IdentityModel.Tokens" />
|
||||
<Using Include="Microsoft.AspNetCore.Http" />
|
||||
<Using Include="HashidsNet" />
|
||||
<Using Include="System.Net" />
|
||||
<Using Include="System.Text.RegularExpressions" />
|
||||
<Using Include="System.Globalization" />
|
||||
<Using Include="Microsoft.AspNetCore.Builder" />
|
||||
<Using Include="Microsoft.Extensions.Hosting" />
|
||||
<Using Include="System.Text" />
|
||||
<Using Include="System.Text.Json" />
|
||||
<Using Include="System.Threading.Channels" />
|
||||
<Using Include="System.Collections.ObjectModel" />
|
||||
<Using Include="Microsoft.Extensions.DependencyInjection" />
|
||||
<Using Include="System.Security.Cryptography" />
|
||||
<Using Include="Microsoft.Extensions.Options" />
|
||||
<Using Include="Microsoft.Extensions.Logging" />
|
||||
</ItemGroup>
|
||||
|
||||
</Project>
|
||||
|
||||
+1
-4
@@ -1,4 +1,4 @@
|
||||
using PostFundManagement.Application.Abstractions.Services;
|
||||
using PostFundManagement.Application.Abstractions.Services;
|
||||
using PostFundManagement.Application.Extensions;
|
||||
using PostFundManagement.Application.Services.Shared;
|
||||
using PostFundManagement.Domain;
|
||||
@@ -152,9 +152,6 @@ public sealed class CommunicationsCacheService(CommunicationsService service, ID
|
||||
return Result.Ok(cached.FromJson<Notification[]>());
|
||||
}));
|
||||
|
||||
public async Task<Result<Notification[]>> GetUserNotificationsAsync(Guid userId, CancellationToken cancellationToken = default) =>
|
||||
await service.GetUserNotificationsAsync(userId, cancellationToken);
|
||||
|
||||
public async Task<Result<Invite>> SendInviteAsync(InviteRequest request, Guid userId, CancellationToken cancellationToken = default) =>
|
||||
await service.SendInviteAsync(request, userId, cancellationToken);
|
||||
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
using PostFundManagement.Application.Abstractions.Services;
|
||||
using PostFundManagement.Application.Abstractions.Services;
|
||||
using PostFundManagement.Application.Services.Shared;
|
||||
using PostFundManagement.Domain;
|
||||
using PostFundManagement.Domain.Abstractions;
|
||||
@@ -94,8 +94,6 @@ public sealed class CommunicationsService(IDbContextFactory<ApplicationDbContext
|
||||
{
|
||||
await using var context = await contextFactory.CreateDbContextAsync(cancellationToken);
|
||||
|
||||
var initialStatus = request.Status ?? (request.Platform == NotificationPlatform.InApp ? NotificationStatus.Delivered : NotificationStatus.Queued);
|
||||
|
||||
context.Notifications.Add(new Domain.Entities.Notification
|
||||
{
|
||||
CreatedAt = DateTime.UtcNow,
|
||||
@@ -105,7 +103,7 @@ public sealed class CommunicationsService(IDbContextFactory<ApplicationDbContext
|
||||
Subject = request.Subject,
|
||||
Message = request.Message,
|
||||
Platform = request.Platform,
|
||||
Status = initialStatus,
|
||||
Status = NotificationStatus.Queued,
|
||||
TenantId = tenantContext.TenantId
|
||||
});
|
||||
|
||||
@@ -118,17 +116,16 @@ public sealed class CommunicationsService(IDbContextFactory<ApplicationDbContext
|
||||
{
|
||||
await using var context = await contextFactory.CreateDbContextAsync(cancellationToken);
|
||||
|
||||
var entity = await context.Notifications.FirstOrDefaultAsync(n => n.Id == request.NotificationId, cancellationToken);
|
||||
if (entity is null)
|
||||
return Result.Fail(NotFoundError.Create($"Notification #{request.NotificationId} not found"));
|
||||
var changedTotal = await context.Notifications
|
||||
.Where(n => n.Id == request.NotificationId)
|
||||
.ExecuteUpdateAsync(f => f
|
||||
.SetProperty(u => u.Destination, request.Destination)
|
||||
.SetProperty(u => u.SentAt, request.SentAt)
|
||||
.SetProperty(u => u.Error, request.Error)
|
||||
.SetProperty(u => u.UpdatedAt, DateTime.UtcNow)
|
||||
.SetProperty(u => u.Status, request.Status), cancellationToken);
|
||||
|
||||
entity.Status = request.Status;
|
||||
entity.UpdatedAt = DateTime.UtcNow;
|
||||
if (request.SentAt.HasValue) entity.SentAt = request.SentAt.Value;
|
||||
if (!string.IsNullOrWhiteSpace(request.Destination)) entity.Destination = request.Destination;
|
||||
if (!string.IsNullOrWhiteSpace(request.Error)) entity.Error = request.Error;
|
||||
|
||||
return Result.OkIf(await context.SaveChangesAsync(cancellationToken) > 0, UpdateError.Create($"Failed to update notification: {request.NotificationId}, contact support"));
|
||||
return Result.OkIf(changedTotal > 0, UpdateError.Create($"Failed to update notification: {request.NotificationId}, contact support"));
|
||||
}));
|
||||
|
||||
public async Task<Result<Notification[]>> GetNotificationsAsync(string organisationIdHash, CancellationToken cancellationToken = default) =>
|
||||
@@ -150,19 +147,6 @@ public sealed class CommunicationsService(IDbContextFactory<ApplicationDbContext
|
||||
return Result.Ok(notifications?.Select(i => i.Map()).ToArray() ?? []);
|
||||
}));
|
||||
|
||||
public async Task<Result<Notification[]>> GetUserNotificationsAsync(Guid userId, CancellationToken cancellationToken = default) =>
|
||||
await Result.Try((Func<Task<Result<Notification[]>>>)(async () =>
|
||||
{
|
||||
await using var context = await contextFactory.CreateDbContextAsync(cancellationToken);
|
||||
|
||||
var notifications = await context.Notifications.AsNoTracking()
|
||||
.Where(n => n.Recipient == userId)
|
||||
.OrderByDescending(o => o.CreatedAt)
|
||||
.ToListAsync(cancellationToken);
|
||||
|
||||
return Result.Ok(notifications?.Select(i => i.Map()).ToArray() ?? []);
|
||||
}));
|
||||
|
||||
public async Task<Result<Notification>> GetNotificationAsync(string notificationIdHash, CancellationToken cancellationToken = default) =>
|
||||
await Result.Try((Func<Task<Result<Notification>>>)(async () =>
|
||||
{
|
||||
|
||||
@@ -1,20 +1,18 @@
|
||||
using PostFundManagement.Domain;
|
||||
using PostFundManagement.Domain;
|
||||
|
||||
namespace PostFundManagement.Application.Services.Communications;
|
||||
|
||||
public sealed record UpdateNotificationRequest([Required] long NotificationId,
|
||||
string? Destination = null,
|
||||
[Required] NotificationStatus Status = NotificationStatus.Read,
|
||||
DateTime? SentAt = null,
|
||||
[MaxLength(TextLength)] string? Error = null);
|
||||
public sealed record UpdateNotificationRequest([Required] long NotificationId, string? Destination,
|
||||
[Required] NotificationStatus Status,
|
||||
[Required] DateTime SentAt,
|
||||
[MaxLength(TextLength)] string? Error);
|
||||
|
||||
public sealed record CreateNotificationRequest([Required] long OrganisationId,
|
||||
[Required] Guid Recipient,
|
||||
[Required] NotificationPlatform Platform,
|
||||
[Required][MaxLength(LabelLength)] string? Subject,
|
||||
[Required][MaxLength(LabelLength)] string? Destination,
|
||||
[Required][MaxLength(LargeTextLength)] string Message,
|
||||
NotificationStatus? Status = null);
|
||||
[Required][MaxLength(TextLength)] string Message);
|
||||
|
||||
public sealed record InviteRequest([Required] long OrganisationId,
|
||||
[Required] long InvitedOrganisationId,
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
using PostFundManagement.Application.Abstractions.Services;
|
||||
using PostFundManagement.Application.Abstractions.Services;
|
||||
using PostFundManagement.Application.Extensions;
|
||||
using PostFundManagement.Application.Services.Shared;
|
||||
using PostFundManagement.Domain;
|
||||
@@ -189,9 +189,6 @@ public sealed class EvidenceCacheService(EvidenceService service, IDistributedCa
|
||||
public async Task<Result> RecordIndicatorActualAmountAsync(string indicatorIdHash, decimal amount, Guid userId, CancellationToken cancellationToken = default) =>
|
||||
await service.RecordIndicatorActualAmountAsync(indicatorIdHash, amount, userId, cancellationToken);
|
||||
|
||||
public async Task<Result> RecordIndicatorActualAmountAsync(string indicatorIdHash, decimal amount, decimal? actualCost, Guid userId, CancellationToken cancellationToken = default) =>
|
||||
await service.RecordIndicatorActualAmountAsync(indicatorIdHash, amount, actualCost, userId, cancellationToken);
|
||||
|
||||
public async Task<Result> UpdateEvidenceAsync(UpdateEvidenceRequest request, Guid userId, CancellationToken cancellationToken = default) =>
|
||||
await service.UpdateEvidenceAsync(request, userId, cancellationToken);
|
||||
}
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
using PostFundManagement.Application.Abstractions.Services;
|
||||
using PostFundManagement.Application.Abstractions.Services;
|
||||
using PostFundManagement.Application.Services.Shared;
|
||||
using PostFundManagement.Domain;
|
||||
using PostFundManagement.Domain.Abstractions;
|
||||
@@ -50,9 +50,6 @@ public sealed class EvidenceService(IDbContextFactory<ApplicationDbContext> cont
|
||||
}));
|
||||
|
||||
public async Task<Result> RecordIndicatorActualAmountAsync(string indicatorIdHash, decimal amount, Guid userId, CancellationToken cancellationToken = default) =>
|
||||
await RecordIndicatorActualAmountAsync(indicatorIdHash, amount, null, userId, cancellationToken);
|
||||
|
||||
public async Task<Result> RecordIndicatorActualAmountAsync(string indicatorIdHash, decimal amount, decimal? actualCost, Guid userId, CancellationToken cancellationToken = default) =>
|
||||
await Result.Try((Func<Task<Result>>)(async () =>
|
||||
{
|
||||
var decodeResult = hashService.DecodeLongIdHash(indicatorIdHash);
|
||||
@@ -66,7 +63,6 @@ public sealed class EvidenceService(IDbContextFactory<ApplicationDbContext> cont
|
||||
var changedTotal = await context.Indicators.Where(i => i.Id == decodeResult.Value)
|
||||
.ExecuteUpdateAsync(f => f
|
||||
.SetProperty(f => f.ActualAmount, amount)
|
||||
.SetProperty(f => f.ActualCost, f => actualCost.HasValue ? actualCost.Value : f.ActualCost)
|
||||
.SetProperty(f => f.CreatedBy, userId), cancellationToken);
|
||||
|
||||
return Result.OkIf(changedTotal > 0, UpdateError.Create("Failed to update indicator."));
|
||||
@@ -134,21 +130,14 @@ public sealed class EvidenceService(IDbContextFactory<ApplicationDbContext> cont
|
||||
if (!await context.Projects.AnyAsync(p => p.Id == request.ProjectId, cancellationToken))
|
||||
return Result.Fail(NotFoundError.New);
|
||||
|
||||
if (request.ActivityId.HasValue && !await context.Activities.AnyAsync(a => a.Id == request.ActivityId.Value && a.ProjectId == request.ProjectId, cancellationToken))
|
||||
return Result.Fail(NotFoundError.Create("Activity not found"));
|
||||
|
||||
context.Indicators.Add(new Domain.Entities.Indicator
|
||||
{
|
||||
ProjectId = request.ProjectId,
|
||||
ActivityId = request.ActivityId,
|
||||
|
||||
Name = request.Name,
|
||||
UnitOfMeasure = request.UnitOfMeasure,
|
||||
BaselineAmount = request.BaselineAmount,
|
||||
TargetAmount = request.TargetAmount,
|
||||
ActualAmount = request.BaselineAmount,
|
||||
TargetCost = request.TargetCost,
|
||||
ActualCost = 0,
|
||||
CreatedBy = userId,
|
||||
CreatedAt = DateTime.UtcNow
|
||||
});
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
using PostFundManagement.Domain;
|
||||
using PostFundManagement.Domain;
|
||||
|
||||
namespace PostFundManagement.Application.Services.Evidence;
|
||||
|
||||
@@ -7,7 +7,7 @@ public sealed record UpdateEvidenceRequest([Required] long EvidenceId,
|
||||
[Required] EvidenceType EvidenceType,
|
||||
[Required] ApprovalStatus Status);
|
||||
|
||||
public sealed record RecordActualRequest([Required] decimal ActualAmount, decimal? ActualCost = null);
|
||||
public sealed record RecordActualRequest([Required] decimal ActualAmount);
|
||||
|
||||
public sealed record CreateMilestoneRequest([Required] long ProjectId,
|
||||
[Required] DateTime DueAt,
|
||||
@@ -18,6 +18,4 @@ public sealed record CreateIndicatorRequest([Required] long ProjectId,
|
||||
[Required][MaxLength(LabelLength)] string Name,
|
||||
[Required] UnitOfMeasure UnitOfMeasure,
|
||||
[Required] decimal BaselineAmount,
|
||||
[Required] decimal TargetAmount,
|
||||
long? ActivityId = null,
|
||||
decimal TargetCost = 0);
|
||||
[Required] decimal TargetAmount);
|
||||
|
||||
@@ -94,16 +94,6 @@ public sealed class ExecutionService(IDbContextFactory<ApplicationDbContext> con
|
||||
|
||||
await using var context = await contextFactory.CreateDbContextAsync(cancellationToken);
|
||||
|
||||
var disbursement = await context.Disbursements.AsNoTracking()
|
||||
.FirstOrDefaultAsync(d => d.Id == decodeResult.Value, cancellationToken);
|
||||
if (disbursement is null)
|
||||
return Result.Fail(NotFoundError.Create("Disbursement not found."));
|
||||
|
||||
var isAwardApproved = await context.Awards
|
||||
.AnyAsync(a => a.ProjectId == disbursement.ProjectId && a.Status == ApprovalStatus.Approved, cancellationToken);
|
||||
if (!isAwardApproved)
|
||||
return Result.Fail(ValidationError.Create("Cannot approve disbursement: The project's linked award is not approved or does not exist."));
|
||||
|
||||
var changedTotal = await context.Disbursements.Where(d => d.Id == decodeResult.Value)
|
||||
.ExecuteUpdateAsync(f => f
|
||||
.SetProperty(f => f.Status, ApprovalStatus.Approved)
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
using PostFundManagement.Application.Abstractions.Services;
|
||||
using PostFundManagement.Application.Abstractions.Services;
|
||||
using PostFundManagement.Application.Extensions;
|
||||
using PostFundManagement.Application.Services.Shared;
|
||||
using PostFundManagement.Domain;
|
||||
@@ -288,9 +288,6 @@ public sealed class GovernanceCacheService(GovernanceService service, IDistribut
|
||||
return Result.Ok(cached.FromJson<TenantOrganisation[]>());
|
||||
}));
|
||||
|
||||
public async Task<Result<TenantUser[]>> GetTenantUsersAsync(string tenantIdHash, CancellationToken cancellationToken = default) =>
|
||||
await service.GetTenantUsersAsync(tenantIdHash, cancellationToken);
|
||||
|
||||
public async Task<Result<Tenant[]>> GetTenantsAsync(CancellationToken cancellationToken = default) =>
|
||||
await Result.Try((Func<Task<Result<Tenant[]>>>)(async () =>
|
||||
{
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
using PostFundManagement.Application.Abstractions.Services;
|
||||
using PostFundManagement.Application.Abstractions.Services;
|
||||
using PostFundManagement.Application.Extensions;
|
||||
using PostFundManagement.Application.Services.Shared;
|
||||
using PostFundManagement.Domain;
|
||||
@@ -208,9 +208,9 @@ public sealed class GovernanceService(IDbContextFactory<ApplicationDbContext> co
|
||||
linkedUser!.IdNumber = request.IdNumber.Clean();
|
||||
}
|
||||
|
||||
await context.SaveChangesAsync(cancellationToken);
|
||||
var userUpdated = await context.SaveChangesAsync(cancellationToken) > 0;
|
||||
|
||||
return Result.OkIf(changedTotal > 0 && updateResult.IsSuccess, UpdateError.Create("Failed to update organisation"));
|
||||
return Result.OkIf(changedTotal > 0 && updateResult.IsSuccess && userUpdated, UpdateError.Create("Failed to update organisation"));
|
||||
}));
|
||||
|
||||
public async Task<Result> CreatePortfolioAsync(CreatePortfolioRequest request, Guid userId, CancellationToken cancellationToken = default) =>
|
||||
@@ -452,26 +452,6 @@ public sealed class GovernanceService(IDbContextFactory<ApplicationDbContext> co
|
||||
return Result.Ok(tenantOrganisations?.Select(i => i.Map()).ToArray() ?? []);
|
||||
}));
|
||||
|
||||
public async Task<Result<TenantUser[]>> GetTenantUsersAsync(string tenantIdHash, CancellationToken cancellationToken = default) =>
|
||||
await Result.Try((Func<Task<Result<TenantUser[]>>>)(async () =>
|
||||
{
|
||||
var decodeResult = hashService.DecodeLongIdHash(tenantIdHash);
|
||||
|
||||
if (decodeResult.IsFailed)
|
||||
return Result.Fail(IdDecodeError.Create()
|
||||
.CausedBy(decodeResult.Errors));
|
||||
|
||||
await using var context = await contextFactory.CreateDbContextAsync(cancellationToken);
|
||||
|
||||
var tenantUsers = await context.TenantUsers.AsNoTracking()
|
||||
.Include(i => i.User)
|
||||
.Include(i => i.Tenant)
|
||||
.Where(f => f.TenantId == decodeResult.Value)
|
||||
.ToListAsync(cancellationToken);
|
||||
|
||||
return Result.Ok(tenantUsers?.Select(i => i.Map()).ToArray() ?? []);
|
||||
}));
|
||||
|
||||
public async Task<Result<TenantOrganisation>> GetTenantOrganisationAsync(string tenantOrganisationIdHash, CancellationToken cancellationToken = default) =>
|
||||
await Result.Try((Func<Task<Result<TenantOrganisation>>>)(async () =>
|
||||
{
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
using PostFundManagement.Domain;
|
||||
using PostFundManagement.Domain;
|
||||
|
||||
namespace PostFundManagement.Application.Services.Governance;
|
||||
|
||||
@@ -37,7 +37,7 @@ public sealed record UpdateOrganisationRequest([Required] long OrganisationId,
|
||||
[Required][MaxLength(LabelLength)] string Name,
|
||||
[Required][MaxLength(LabelLength)] string RegistrationNo,
|
||||
[Required][EmailAddress][MaxLength(LabelLength)] string Email,
|
||||
[MaxLength(LabelLength)] string? IdNumber,
|
||||
[Required][MaxLength(LabelLength)] string IdNumber,
|
||||
[Required] Guid OrganisationUser,
|
||||
[Required] OrganisationType Type,
|
||||
[Required] ActivityStatus Status);
|
||||
@@ -45,7 +45,7 @@ public sealed record UpdateOrganisationRequest([Required] long OrganisationId,
|
||||
public sealed record CreateOrganisationRequest([Required][MaxLength(LabelLength)] string Name,
|
||||
[Required][MaxLength(LabelLength)] string RegistrationNo,
|
||||
[Required][EmailAddress][MaxLength(LabelLength)] string Email,
|
||||
[MaxLength(LabelLength)] string? IdNumber,
|
||||
[Required][MaxLength(LabelLength)] string IdNumber,
|
||||
[Required] Guid OrganisationUser,
|
||||
[Required] OrganisationType Type = OrganisationType.PrivateLimitedCompany);
|
||||
|
||||
|
||||
@@ -12,9 +12,6 @@ public sealed class GrantsCacheService(GrantsService service, IDistributedCache
|
||||
public async Task<Result> CreateAwardAsync(CreateAwardRequest request, Guid userId, CancellationToken cancellationToken = default) =>
|
||||
await service.CreateAwardAsync(request, userId, cancellationToken);
|
||||
|
||||
public async Task<Result> UpdateAwardAsync(UpdateAwardRequest request, Guid userId, CancellationToken cancellationToken = default) =>
|
||||
await service.UpdateAwardAsync(request, userId, cancellationToken);
|
||||
|
||||
public async Task<Result> CreateBudgetAsync(CreateBudgetRequest request, Guid userId, CancellationToken cancellationToken = default) =>
|
||||
await service.CreateBudgetAsync(request, userId, cancellationToken);
|
||||
|
||||
|
||||
@@ -161,11 +161,6 @@ public sealed class GrantsService(IDbContextFactory<ApplicationDbContext> contex
|
||||
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."));
|
||||
|
||||
var budget = new Domain.Entities.Budget
|
||||
{
|
||||
ProjectId = request.ProjectId,
|
||||
@@ -211,44 +206,6 @@ public sealed class GrantsService(IDbContextFactory<ApplicationDbContext> contex
|
||||
return Result.OkIf(await context.SaveChangesAsync(cancellationToken) > 0, CreateError.Create("Failed to create award"));
|
||||
}));
|
||||
|
||||
public async Task<Result> UpdateAwardAsync(UpdateAwardRequest request, Guid userId, CancellationToken cancellationToken = default) =>
|
||||
await Result.Try((Func<Task<Result>>)(async () =>
|
||||
{
|
||||
if (request.Amount <= 0)
|
||||
return Result.Fail(ValidationError.Create("Approved amount must be greater than zero."));
|
||||
|
||||
await using var context = await contextFactory.CreateDbContextAsync(cancellationToken);
|
||||
|
||||
var existingAward = await context.Awards.FirstOrDefaultAsync(a => a.Id == request.AwardId, cancellationToken);
|
||||
if (existingAward is null)
|
||||
return Result.Fail(NotFoundError.Create("Award not found"));
|
||||
|
||||
if (existingAward.Status == ApprovalStatus.Approved)
|
||||
return Result.Fail(ValidationError.Create("Approved awards are immutable and cannot be updated."));
|
||||
|
||||
if (!await context.Organisations.AnyAsync(o => o.Id == request.OrganisationId, cancellationToken))
|
||||
return Result.Fail(NotFoundError.Create("Organisation not found"));
|
||||
|
||||
if (!await context.Programmes.AnyAsync(p => p.Id == request.ProgrammeId, cancellationToken))
|
||||
return Result.Fail(NotFoundError.Create("Programme not found"));
|
||||
|
||||
existingAward.OrganisationId = request.OrganisationId;
|
||||
existingAward.ProgrammeId = request.ProgrammeId;
|
||||
existingAward.Amount = request.Amount;
|
||||
if (request.Status.HasValue)
|
||||
{
|
||||
existingAward.Status = request.Status.Value;
|
||||
if (request.Status.Value == ApprovalStatus.Approved)
|
||||
{
|
||||
existingAward.ApprovedBy = userId;
|
||||
}
|
||||
}
|
||||
existingAward.UpdatedBy = userId;
|
||||
existingAward.UpdatedAt = DateTime.UtcNow;
|
||||
|
||||
return Result.OkIf(await context.SaveChangesAsync(cancellationToken) > 0, UpdateError.Create("Failed to update award"));
|
||||
}));
|
||||
|
||||
public async Task<Result<string>> GetContractUrlAsync(string contractIdHash, CancellationToken cancellationToken = default) =>
|
||||
await Result.Try((Func<Task<Result<string>>>)(async () =>
|
||||
{
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
using PostFundManagement.Domain;
|
||||
using PostFundManagement.Domain;
|
||||
|
||||
namespace PostFundManagement.Application.Services.Grants;
|
||||
|
||||
@@ -8,8 +8,6 @@ public sealed record UpdateContractRequest([Required] long ContractId, [Required
|
||||
|
||||
public sealed record CreateAwardRequest([Required] long OrganisationId, [Required] long ProgrammeId, [Required] decimal Amount);
|
||||
|
||||
public sealed record UpdateAwardRequest([Required] long AwardId, [Required] long OrganisationId, [Required] long ProgrammeId, [Required] decimal Amount, ApprovalStatus? Status);
|
||||
|
||||
public sealed record CreateBudgetRequest([Required] long ProjectId,
|
||||
[Required][MaxLength(LabelLength)] string Name,
|
||||
[Required] decimal Amount);
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
using PostFundManagement.Application.Abstractions.Services;
|
||||
using PostFundManagement.Application.Abstractions.Services;
|
||||
using PostFundManagement.Application.Extensions;
|
||||
using PostFundManagement.Application.Services.Shared;
|
||||
using PostFundManagement.Domain;
|
||||
@@ -12,9 +12,6 @@ public sealed class ProjectCacheService(ProjectService service, IDistributedCach
|
||||
public async Task<Result> CreateActivityAsync(CreateActivityRequest request, Guid userId, CancellationToken cancellationToken = default) =>
|
||||
await service.CreateActivityAsync(request, userId, cancellationToken);
|
||||
|
||||
public async Task<Result> UpdateActivityAsync(UpdateActivityRequest request, Guid userId, CancellationToken cancellationToken = default) =>
|
||||
await service.UpdateActivityAsync(request, userId, cancellationToken);
|
||||
|
||||
public async Task<Result> CreateBeneficiaryAsync(CreateBeneficiaryRequest request, Guid userId, CancellationToken cancellationToken = default) =>
|
||||
await service.CreateBeneficiaryAsync(request, userId, cancellationToken);
|
||||
|
||||
@@ -375,30 +372,6 @@ public sealed class ProjectCacheService(ProjectService service, IDistributedCach
|
||||
return Result.Ok(cached.FromJson<Outcome[]>());
|
||||
}));
|
||||
|
||||
public async Task<Result<Outcome[]>> GetProjectOutcomesAsync(string projectIdHash, CancellationToken cancellationToken = default) =>
|
||||
await Result.Try((Func<Task<Result<Outcome[]>>>)(async () =>
|
||||
{
|
||||
if (!await featureManager.IsEnabledAsync(ServiceCachingFeature))
|
||||
return await service.GetProjectOutcomesAsync(projectIdHash, cancellationToken);
|
||||
|
||||
var cacheKey = HashService.ToMd5Hash($"getprojectoutcomes-{projectIdHash}").Value;
|
||||
|
||||
var cached = await cache.GetStringAsync(cacheKey, cancellationToken);
|
||||
|
||||
if (string.IsNullOrWhiteSpace(cached))
|
||||
{
|
||||
var result = await service.GetProjectOutcomesAsync(projectIdHash, cancellationToken);
|
||||
|
||||
if (result.IsFailed) return result;
|
||||
|
||||
await cache.SetStringAsync(cacheKey, result.Value.ToJson(), cacheOptions, cancellationToken);
|
||||
|
||||
return result.Value;
|
||||
}
|
||||
|
||||
return Result.Ok(cached.FromJson<Outcome[]>());
|
||||
}));
|
||||
|
||||
public async Task<Result<Programme>> GetProgrammeAsync(string programmeIdHash, CancellationToken cancellationToken = default) =>
|
||||
await Result.Try((Func<Task<Result<Programme>>>)(async () =>
|
||||
{
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
using PostFundManagement.Application.Abstractions.Services;
|
||||
using PostFundManagement.Application.Abstractions.Services;
|
||||
using PostFundManagement.Application.Services.Shared;
|
||||
using PostFundManagement.Domain;
|
||||
using PostFundManagement.Domain.Abstractions;
|
||||
@@ -20,27 +20,6 @@ public sealed class ProjectService(IDbContextFactory<ApplicationDbContext> conte
|
||||
if (!await context.Users.AnyAsync(u => u.Id == userId, cancellationToken))
|
||||
return Result.Fail(NotFoundError.New);
|
||||
|
||||
if (request.Status == ApprovalStatus.Approved)
|
||||
{
|
||||
var hasUnmetCompliance = await context.ComplianceItems
|
||||
.AnyAsync(c => c.ProjectId == request.ProjectId && c.Status == ComplianceStatus.NotMet, cancellationToken);
|
||||
if (hasUnmetCompliance)
|
||||
return Result.Fail(ValidationError.Create("Project cannot be approved because some compliance items are not met."));
|
||||
|
||||
var projectBeneficiaryIds = await context.Beneficiaries
|
||||
.Where(b => b.ProjectId == request.ProjectId)
|
||||
.Select(b => b.Id)
|
||||
.ToListAsync(cancellationToken);
|
||||
|
||||
if (projectBeneficiaryIds.Count > 0)
|
||||
{
|
||||
var hasUnmetOutcomes = await context.Outcomes
|
||||
.AnyAsync(o => projectBeneficiaryIds.Contains(o.BeneficiaryId) && o.Status == ComplianceStatus.NotMet, cancellationToken);
|
||||
if (hasUnmetOutcomes)
|
||||
return Result.Fail(ValidationError.Create("Project cannot be approved because some beneficiary outcomes are not met."));
|
||||
}
|
||||
}
|
||||
|
||||
var changedTotal = await context.Projects
|
||||
.Where(f => f.Id == request.ProjectId)
|
||||
.ExecuteUpdateAsync(f => f
|
||||
@@ -144,29 +123,6 @@ public sealed class ProjectService(IDbContextFactory<ApplicationDbContext> conte
|
||||
if (!await context.Milestones.AnyAsync(m => m.Id == request.MilestoneId.Value, cancellationToken))
|
||||
return Result.Fail(NotFoundError.New);
|
||||
|
||||
var approvedBudget = await context.Budgets
|
||||
.Where(b => b.ProjectId == request.ProjectId && (b.Status == ApprovalStatus.Approved || b.Status == ApprovalStatus.Pending))
|
||||
.SumAsync(b => b.Amount, cancellationToken);
|
||||
|
||||
if (approvedBudget == 0)
|
||||
{
|
||||
approvedBudget = await context.Awards
|
||||
.Where(a => a.ProjectId == request.ProjectId)
|
||||
.SumAsync(a => a.Amount, cancellationToken);
|
||||
}
|
||||
|
||||
if (approvedBudget > 0)
|
||||
{
|
||||
var existingTotal = await context.Activities
|
||||
.Where(a => a.ProjectId == request.ProjectId)
|
||||
.SumAsync(a => a.Amount, cancellationToken);
|
||||
|
||||
if (existingTotal + request.Amount > approvedBudget)
|
||||
{
|
||||
return Result.Fail(CreateError.Create($"Activity budget R {request.Amount:N2} exceeds the available project budget. Approved budget: R {approvedBudget:N2}, already allocated: R {existingTotal:N2}, remaining: R {(approvedBudget - existingTotal):N2}."));
|
||||
}
|
||||
}
|
||||
|
||||
context.Activities.Add(new Domain.Entities.Activity
|
||||
{
|
||||
ProjectId = request.ProjectId,
|
||||
@@ -185,54 +141,6 @@ public sealed class ProjectService(IDbContextFactory<ApplicationDbContext> conte
|
||||
CreateError.Create("No record was created."));
|
||||
}));
|
||||
|
||||
public async Task<Result> UpdateActivityAsync(UpdateActivityRequest request, Guid userId, CancellationToken cancellationToken = default) =>
|
||||
await Result.Try((Func<Task<Result>>)(async () =>
|
||||
{
|
||||
await using var context = await contextFactory.CreateDbContextAsync(cancellationToken);
|
||||
|
||||
var activity = await context.Activities.FirstOrDefaultAsync(a => a.Id == request.ActivityId, cancellationToken);
|
||||
if (activity is null)
|
||||
return Result.Fail(NotFoundError.Create("Activity not found"));
|
||||
|
||||
if (request.MilestoneId.HasValue && !await context.Milestones.AnyAsync(m => m.Id == request.MilestoneId.Value, cancellationToken))
|
||||
return Result.Fail(NotFoundError.Create("Milestone not found"));
|
||||
|
||||
var approvedBudget = await context.Budgets
|
||||
.Where(b => b.ProjectId == request.ProjectId && (b.Status == ApprovalStatus.Approved || b.Status == ApprovalStatus.Pending))
|
||||
.SumAsync(b => b.Amount, cancellationToken);
|
||||
|
||||
if (approvedBudget == 0)
|
||||
{
|
||||
approvedBudget = await context.Awards
|
||||
.Where(a => a.ProjectId == request.ProjectId)
|
||||
.SumAsync(a => a.Amount, cancellationToken);
|
||||
}
|
||||
|
||||
if (approvedBudget > 0)
|
||||
{
|
||||
var otherTotal = await context.Activities
|
||||
.Where(a => a.ProjectId == request.ProjectId && a.Id != request.ActivityId)
|
||||
.SumAsync(a => a.Amount, cancellationToken);
|
||||
|
||||
if (otherTotal + request.Amount > approvedBudget)
|
||||
{
|
||||
return Result.Fail(UpdateError.Create($"Updated activity budget R {request.Amount:N2} exceeds the available project budget. Approved budget: R {approvedBudget:N2}, other allocated: R {otherTotal:N2}, remaining: R {(approvedBudget - otherTotal):N2}."));
|
||||
}
|
||||
}
|
||||
|
||||
activity.Title = request.Title.Humanize(LetterCasing.Title).Trim();
|
||||
activity.Description = request.Description;
|
||||
activity.Category = request.Category.Humanize(LetterCasing.Title).Trim();
|
||||
activity.Amount = request.Amount;
|
||||
activity.MilestoneId = request.MilestoneId;
|
||||
activity.PerformedAt = request.PerformedAt;
|
||||
activity.UpdatedBy = userId;
|
||||
activity.UpdatedAt = DateTime.UtcNow;
|
||||
|
||||
return Result.OkIf(await context.SaveChangesAsync(cancellationToken) > 0,
|
||||
UpdateError.Create("No changes detected, update cancelled."));
|
||||
}));
|
||||
|
||||
public async Task<Result<Milestone[]>> GetProjectMilestonesAsync(string projectIdHash, CancellationToken cancellationToken = default) =>
|
||||
await Result.Try((Func<Task<Result<Milestone[]>>>)(async () =>
|
||||
{
|
||||
@@ -565,9 +473,8 @@ public sealed class ProjectService(IDbContextFactory<ApplicationDbContext> conte
|
||||
CreatedAt = DateTime.UtcNow,
|
||||
CreatedBy = userId,
|
||||
ProjectId = request.ProjectId,
|
||||
BeneficiaryId = request.BeneficiaryId,
|
||||
Requirements = request.Requirements,
|
||||
Status = request.Status,
|
||||
Status = ApprovalStatus.Pending,
|
||||
TenantId = tenantContext.TenantId,
|
||||
Title = request.Title.Humanize(LetterCasing.Title).Trim()
|
||||
});
|
||||
@@ -585,10 +492,7 @@ public sealed class ProjectService(IDbContextFactory<ApplicationDbContext> conte
|
||||
.ExecuteUpdateAsync(f => f
|
||||
.SetProperty(u => u.Title, request.Title.Humanize(LetterCasing.Title).Trim())
|
||||
.SetProperty(u => u.Requirements, request.Requirements)
|
||||
.SetProperty(u => u.BeneficiaryId, request.BeneficiaryId)
|
||||
.SetProperty(u => u.Status, request.Status)
|
||||
.SetProperty(u => u.UpdatedAt, DateTime.UtcNow)
|
||||
.SetProperty(u => u.VerifiedBy, userId), cancellationToken);
|
||||
.SetProperty(u => u.Status, request.Status), cancellationToken);
|
||||
|
||||
return Result.OkIf(changedTotal > 0, UpdateError.Create("No records changed"));
|
||||
}));
|
||||
@@ -793,7 +697,6 @@ public sealed class ProjectService(IDbContextFactory<ApplicationDbContext> conte
|
||||
CreatedBy = userId,
|
||||
Description = request.Description,
|
||||
Name = request.Name.Humanize(LetterCasing.Title).Trim(),
|
||||
Status = request.Status,
|
||||
TenantId = tenantContext.TenantId
|
||||
});
|
||||
|
||||
@@ -809,8 +712,7 @@ public sealed class ProjectService(IDbContextFactory<ApplicationDbContext> conte
|
||||
.Where(o => o.Id == request.OutcomeId)
|
||||
.ExecuteUpdateAsync(f => f
|
||||
.SetProperty(u => u.Description, request.Description)
|
||||
.SetProperty(u => u.Name, request.Name.Humanize(LetterCasing.Title).Trim())
|
||||
.SetProperty(u => u.Status, request.Status), cancellationToken);
|
||||
.SetProperty(u => u.Name, request.Name.Humanize(LetterCasing.Title).Trim()), cancellationToken);
|
||||
|
||||
return Result.OkIf(changedTotal > 0, UpdateError.Create("No records changed"));
|
||||
}));
|
||||
@@ -834,25 +736,6 @@ public sealed class ProjectService(IDbContextFactory<ApplicationDbContext> conte
|
||||
return Result.Ok(outcomes?.Select(o => o.Map()).ToArray() ?? []);
|
||||
}));
|
||||
|
||||
public async Task<Result<Outcome[]>> GetProjectOutcomesAsync(string projectIdHash, CancellationToken cancellationToken = default) =>
|
||||
await Result.Try((Func<Task<Result<Outcome[]>>>)(async () =>
|
||||
{
|
||||
var decodeResult = hashService.DecodeLongIdHash(projectIdHash);
|
||||
|
||||
if (decodeResult.IsFailed)
|
||||
return Result.Fail<Outcome[]>(IdDecodeError.Create()
|
||||
.CausedBy(decodeResult.Errors));
|
||||
|
||||
await using var context = await contextFactory.CreateDbContextAsync(cancellationToken);
|
||||
|
||||
var outcomes = await context.Outcomes.AsNoTracking()
|
||||
.Where(o => o.Beneficiary!.ProjectId == decodeResult.Value)
|
||||
.OrderByDescending(o => o.Id)
|
||||
.ToListAsync(cancellationToken);
|
||||
|
||||
return Result.Ok(outcomes?.Select(o => o.Map()).ToArray() ?? []);
|
||||
}));
|
||||
|
||||
public async Task<Result<Outcome>> GetOutcomeAsync(string outcomeIdHash, CancellationToken cancellationToken = default) =>
|
||||
await Result.Try((Func<Task<Result<Outcome>>>)(async () =>
|
||||
{
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
using PostFundManagement.Domain;
|
||||
using PostFundManagement.Domain;
|
||||
|
||||
namespace PostFundManagement.Application.Services.Projects;
|
||||
|
||||
@@ -15,13 +15,11 @@ public sealed record CreateSiteVisitRequest([Required] long ProjectId,
|
||||
|
||||
public sealed record UpdateOutcomeRequest([Required] long OutcomeId,
|
||||
[Required][MaxLength(LabelLength)] string Name,
|
||||
[Required][MaxLength(TextLength)] string Description,
|
||||
[Required] ComplianceStatus Status = ComplianceStatus.NotMet);
|
||||
[Required][MaxLength(TextLength)] string Description);
|
||||
|
||||
public sealed record CreateOutcomeRequest([Required] long BeneficiaryId,
|
||||
[Required][MaxLength(LabelLength)] string Name,
|
||||
[Required][MaxLength(TextLength)] string Description,
|
||||
ComplianceStatus Status = ComplianceStatus.NotMet);
|
||||
[Required][MaxLength(TextLength)] string Description);
|
||||
|
||||
public sealed record UpdateIssueRequest([Required] long IssueId,
|
||||
[Required][MaxLength(LabelLength)] string Title,
|
||||
@@ -55,14 +53,11 @@ public sealed record CreateBeneficiaryRequest([Required] long ProjectId,
|
||||
public sealed record UpdateComplianceItemRequest([Required] long ComplianceItemId,
|
||||
[Required][MaxLength(LabelLength)] string Title,
|
||||
[Required][MaxLength(TextLength)] string Requirements,
|
||||
[Required] ComplianceStatus Status,
|
||||
long? BeneficiaryId = null);
|
||||
[Required] ApprovalStatus Status);
|
||||
|
||||
public sealed record CreateComplianceItemRequest([Required] long ProjectId,
|
||||
[Required][MaxLength(LabelLength)] string Title,
|
||||
[Required][MaxLength(TextLength)] string Requirements,
|
||||
ComplianceStatus Status = ComplianceStatus.NotMet,
|
||||
long? BeneficiaryId = null);
|
||||
[Required][MaxLength(TextLength)] string Requirements);
|
||||
|
||||
public sealed record UpdateChangeRequest([Required] long ChangeRequestId,
|
||||
[Required][MaxLength(LabelLength)] string Title,
|
||||
@@ -111,12 +106,3 @@ public sealed record CreateActivityRequest([Required] long ProjectId,
|
||||
[Required][MaxLength(LabelLength)] string Title,
|
||||
[Required][MaxLength(TextLength)] string Description,
|
||||
DateTime? PerformedAt);
|
||||
|
||||
public sealed record UpdateActivityRequest([Required] long ActivityId,
|
||||
[Required] long ProjectId,
|
||||
long? MilestoneId,
|
||||
[Required][MaxLength(LabelLength)] string Category,
|
||||
[Required] decimal Amount,
|
||||
[Required][MaxLength(LabelLength)] string Title,
|
||||
[Required][MaxLength(TextLength)] string Description,
|
||||
DateTime? PerformedAt);
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
using PostFundManagement.Domain;
|
||||
using PostFundManagement.Domain;
|
||||
using PostFundManagement.Domain.Abstractions;
|
||||
using PostFundManagement.Domain.Errors;
|
||||
using PostFundManagement.Domain.Extensions;
|
||||
@@ -10,22 +10,13 @@ namespace PostFundManagement.Application.Services.Shared;
|
||||
public sealed class AuditService(IDbContextFactory<ApplicationDbContext> contextFactory, ITenantContextService tenantContextService) : IService
|
||||
{
|
||||
public async Task<Result<AuditLog[]>> GetAuditLogsAsync(CancellationToken cancellationToken = default) =>
|
||||
await GetAuditLogsAsync(new Pagination { Page = 1, PageSize = 500, Index = 1 }, cancellationToken);
|
||||
|
||||
public async Task<Result<AuditLog[]>> GetAuditLogsAsync(Pagination pagination, CancellationToken cancellationToken = default) =>
|
||||
await Result.Try((Func<Task<Result<AuditLog[]>>>)(async () =>
|
||||
{
|
||||
await using var context = await contextFactory.CreateDbContextAsync(cancellationToken);
|
||||
|
||||
var query = context.AuditLogs.AsQueryable();
|
||||
var pagination = new Pagination { Page = 1, PageSize = 500, Index = 1 };
|
||||
|
||||
if (!tenantContextService.IsAdmin)
|
||||
{
|
||||
var currentTenantId = tenantContextService.TenantId > 0 ? tenantContextService.TenantId : 1L;
|
||||
query = query.Where(o => o.TenantId == currentTenantId);
|
||||
}
|
||||
|
||||
var logs = await query
|
||||
var logs = await context.AuditLogs
|
||||
.OrderByDescending(o => o.Id)
|
||||
.Skip((pagination.Page - 1) * pagination.PageSize)
|
||||
.Take(pagination.PageSize)
|
||||
@@ -34,22 +25,24 @@ public sealed class AuditService(IDbContextFactory<ApplicationDbContext> context
|
||||
return Result.Ok(logs.Select(p => p.Map()).ToArray());
|
||||
}));
|
||||
|
||||
public async Task<Result> LogActionAsync(
|
||||
string entityId,
|
||||
string entityName,
|
||||
string action,
|
||||
string[] changes,
|
||||
Guid performedBy,
|
||||
string? error = null,
|
||||
long? tenantId = null,
|
||||
CancellationToken cancellationToken = default) =>
|
||||
await Result.Try((Func<Task<Result>>)(async () =>
|
||||
public async Task<Result<AuditLog[]>> GetAuditLogsAsync(Pagination pagination, CancellationToken cancellationToken = default) =>
|
||||
await Result.Try((Func<Task<Result<AuditLog[]>>>)(async () =>
|
||||
{
|
||||
await using var context = await contextFactory.CreateDbContextAsync(cancellationToken);
|
||||
|
||||
var resolvedTenantId = (tenantId.HasValue && tenantId.Value > 0)
|
||||
? tenantId.Value
|
||||
: (tenantContextService.TenantId > 0 ? tenantContextService.TenantId : 1L);
|
||||
var logs = await context.AuditLogs
|
||||
.OrderByDescending(o => o.Id)
|
||||
.Skip((pagination.Page - 1) * pagination.PageSize)
|
||||
.Take(pagination.PageSize)
|
||||
.ToListAsync(cancellationToken);
|
||||
|
||||
return Result.Ok(logs.Select(p => p.Map()).ToArray());
|
||||
}));
|
||||
|
||||
public async Task<Result> LogActionAsync(string entityId, string entityName, string action, string[] changes, Guid performedBy, CancellationToken cancellationToken = default) =>
|
||||
await Result.Try((Func<Task<Result>>)(async () =>
|
||||
{
|
||||
await using var context = await contextFactory.CreateDbContextAsync(cancellationToken);
|
||||
|
||||
context.Add(new Domain.Entities.AuditLog
|
||||
{
|
||||
@@ -59,8 +52,7 @@ public sealed class AuditService(IDbContextFactory<ApplicationDbContext> context
|
||||
EntityName = entityName,
|
||||
PerformedBy = performedBy,
|
||||
Timestamp = DateTime.UtcNow,
|
||||
TenantId = resolvedTenantId,
|
||||
Error = error,
|
||||
TenantId = tenantContextService.TenantId > 0 ? tenantContextService.TenantId : null,
|
||||
});
|
||||
|
||||
return Result.FailIf(await context.SaveChangesAsync(cancellationToken) <= 0, CreateError.New);
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
using PostFundManagement.Application.Abstractions.Services;
|
||||
using PostFundManagement.Application.Abstractions.Services;
|
||||
using PostFundManagement.Application.Extensions;
|
||||
using PostFundManagement.Application.Services.Shared;
|
||||
using PostFundManagement.Domain;
|
||||
@@ -151,7 +151,30 @@ public sealed class UserCacheService(UserService service, IDistributedCache cach
|
||||
}));
|
||||
|
||||
public async Task<Result<User[]>> GetUsersAsync(Pagination pagination, UserFilterRequest request, CancellationToken cancellationToken = default) =>
|
||||
await service.GetUsersAsync(pagination, request, cancellationToken);
|
||||
await Result.Try((Func<Task<Result<User[]>>>)(async () =>
|
||||
{
|
||||
if (!await featureManager.IsEnabledAsync(ServiceCachingFeature))
|
||||
return await service.GetUsersAsync(pagination, request, cancellationToken);
|
||||
|
||||
var cacheKey = HashService.ToMd5Hash($"getusers-{pagination.Page}-{pagination.PageSize}-" +
|
||||
$"{request.Email}-{request.Status}-{request.SortBy}-{request.SortDescending}").Value;
|
||||
|
||||
var cached = await cache.GetStringAsync(cacheKey, cancellationToken);
|
||||
|
||||
if (string.IsNullOrWhiteSpace(cached))
|
||||
{
|
||||
var usersResult = await service.GetUsersAsync(pagination, request, cancellationToken);
|
||||
|
||||
if (usersResult.IsFailed) return usersResult;
|
||||
|
||||
await cache.SetStringAsync(cacheKey, usersResult.Value.ToJson(),
|
||||
cacheOptions, cancellationToken);
|
||||
|
||||
return usersResult;
|
||||
}
|
||||
|
||||
return Result.Ok(cached.FromJson<User[]>());
|
||||
}));
|
||||
|
||||
public async Task<Result> LogoutUserAsync(Guid targetUserId, Guid userId, CancellationToken cancellationToken = default) =>
|
||||
await service.LogoutUserAsync(targetUserId, userId, cancellationToken);
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
using PostFundManagement.Application.Abstractions.Services;
|
||||
using PostFundManagement.Application.Abstractions.Services;
|
||||
using PostFundManagement.Application.Services.Shared;
|
||||
using PostFundManagement.Domain;
|
||||
using PostFundManagement.Domain.Abstractions;
|
||||
@@ -147,30 +147,10 @@ public sealed class UserService(IDbContextFactory<ApplicationDbContext> contextF
|
||||
if (await context.Users.AnyAsync(u => u.Email == email, cancellationToken))
|
||||
return Result.Fail(ValidationError.Create("User with this email already exists."));
|
||||
|
||||
// Normalize and sanitize request.UserName for ASP.NET Core Identity / Skoruba
|
||||
// IdentityServer rejects user names containing whitespace or disallowed characters
|
||||
var userName = request.UserName?.Trim();
|
||||
if (string.IsNullOrWhiteSpace(userName))
|
||||
{
|
||||
userName = email;
|
||||
}
|
||||
else
|
||||
{
|
||||
// Replace whitespace with dots and filter to allowed Identity characters
|
||||
userName = System.Text.RegularExpressions.Regex.Replace(userName, @"\s+", ".").ToLowerInvariant();
|
||||
userName = System.Text.RegularExpressions.Regex.Replace(userName, @"[^a-z0-9_.\-@+]", "");
|
||||
if (string.IsNullOrWhiteSpace(userName))
|
||||
{
|
||||
userName = email;
|
||||
}
|
||||
}
|
||||
request.UserName = userName;
|
||||
request.Email = email;
|
||||
|
||||
var createUserResult = await skorubaService.CreateUserAsync(request, cancellationToken);
|
||||
|
||||
if (createUserResult.IsFailed)
|
||||
return Result.Fail(CreateError.Create(createUserResult.Errors.FirstOrDefault()?.Message ?? "Failed to create user."));
|
||||
return Result.Fail(CreateError.Create("Failed to create user."));
|
||||
|
||||
var newUserId = Guid.Parse(createUserResult.Value.Trim());
|
||||
|
||||
|
||||
@@ -12,6 +12,7 @@ APP_URL="MY_APP_URL"
|
||||
VITE_OIDC_AUTHORITY="https://sts.security.khongisa.co.za"
|
||||
VITE_OIDC_AUDIENCE="pfm-api-dev"
|
||||
VITE_OIDC_CLIENT_ID="pfm-scalar-dev"
|
||||
VITE_OIDC_CLIENT_SECRET="secret_71f88570a46eb22eb5270b47f69e3332c84d8db85d008f0276cb7431c8872d27"
|
||||
|
||||
# PFM Backend API Base URL
|
||||
VITE_API_BASE_URL="https://api.ifri.khongisa.co.za/"
|
||||
|
||||
@@ -26,12 +26,15 @@ OIDC_AUDIENCE="${VITE_OIDC_AUDIENCE:-${OIDC_AUDIENCE:-${SecuritySettings__Audien
|
||||
# 4. OIDC Client ID
|
||||
OIDC_CLIENT_ID="${VITE_OIDC_CLIENT_ID:-${OIDC_CLIENT_ID:-${SecuritySettings__ClientId:-${SecuritySettings_ClientId:-${CLIENT_ID:-}}}}}"
|
||||
|
||||
# 5. OIDC Scope
|
||||
# 5. OIDC Client Secret
|
||||
OIDC_CLIENT_SECRET="${VITE_OIDC_CLIENT_SECRET:-${OIDC_CLIENT_SECRET:-${SecuritySettings__ClientSecret:-${SecuritySettings_ClientSecret:-${CLIENT_SECRET:-}}}}}"
|
||||
|
||||
# 6. OIDC Scope
|
||||
OIDC_SCOPE="${VITE_OIDC_SCOPE:-${OIDC_SCOPE:-${SecuritySettings__Scope:-${SecuritySettings_Scope:-${SCOPE:-}}}}}"
|
||||
|
||||
# 6. Hasher Settings
|
||||
HASHER_MIN_LENGTH="${VITE_HASHER_MIN_LENGTH:-${HASHER_MIN_LENGTH:-${HasherSettings__MinLength:-${HasherSettings_MinLength:-11}}}}}"
|
||||
HASHER_SALT="${VITE_HASHER_SALT:-${HASHER_SALT:-${HasherSettings__Salt:-${HasherSettings_Salt:-TGlnaHQgQ2hhcm1zLCBNaWRyYW5kQm9va3MgbWFrZSBsb3RzIG9mIG1vbmV5IGFuZCBhcmUgYSBzdWNjZXNzZnVsIHZpcmFsIHN0b3J5IGluIFNvdXRoIEFmcmljYQ==}}}}"
|
||||
# 7. Hasher Settings
|
||||
HASHER_SALT="${VITE_HASHER_SALT:-${HASHER_SALT:-${HasherSettings__Salt:-${HasherSettings_Salt:-}}}}}"
|
||||
HASHER_MIN_LENGTH="${VITE_HASHER_MIN_LENGTH:-${HASHER_MIN_LENGTH:-${HasherSettings__MinLength:-${HasherSettings_MinLength:-}}}}}"
|
||||
|
||||
if [ -d "$TARGET_DIR" ]; then
|
||||
echo "[docker-entrypoint] Generating runtime configuration files in $TARGET_DIR..."
|
||||
@@ -48,12 +51,14 @@ window.__RUNTIME_CONFIG__ = {
|
||||
VITE_OIDC_AUDIENCE: "${OIDC_AUDIENCE}",
|
||||
OIDC_CLIENT_ID: "${OIDC_CLIENT_ID}",
|
||||
VITE_OIDC_CLIENT_ID: "${OIDC_CLIENT_ID}",
|
||||
OIDC_CLIENT_SECRET: "${OIDC_CLIENT_SECRET}",
|
||||
VITE_OIDC_CLIENT_SECRET: "${OIDC_CLIENT_SECRET}",
|
||||
OIDC_SCOPE: "${OIDC_SCOPE}",
|
||||
VITE_OIDC_SCOPE: "${OIDC_SCOPE}",
|
||||
HASHER_MIN_LENGTH: "${HASHER_MIN_LENGTH}",
|
||||
VITE_HASHER_MIN_LENGTH: "${HASHER_MIN_LENGTH}",
|
||||
HASHER_SALT: "${HASHER_SALT}",
|
||||
VITE_HASHER_SALT: "${HASHER_SALT}",
|
||||
HASHER_MIN_LENGTH: "${HASHER_MIN_LENGTH}",
|
||||
VITE_HASHER_MIN_LENGTH: "${HASHER_MIN_LENGTH}",
|
||||
ApiSettings: {
|
||||
BaseUrl: "${API_BASE_URL}"
|
||||
},
|
||||
@@ -61,6 +66,7 @@ window.__RUNTIME_CONFIG__ = {
|
||||
Authority: "${OIDC_AUTHORITY}",
|
||||
Audience: "${OIDC_AUDIENCE}",
|
||||
ClientId: "${OIDC_CLIENT_ID}",
|
||||
ClientSecret: "${OIDC_CLIENT_SECRET}",
|
||||
Scope: "${OIDC_SCOPE}"
|
||||
},
|
||||
HasherSettings: {
|
||||
@@ -80,6 +86,7 @@ EOF
|
||||
"Authority": "${OIDC_AUTHORITY}",
|
||||
"Audience": "${OIDC_AUDIENCE}",
|
||||
"ClientId": "${OIDC_CLIENT_ID}",
|
||||
"ClientSecret": "${OIDC_CLIENT_SECRET}",
|
||||
"Scope": "${OIDC_SCOPE}"
|
||||
},
|
||||
"HasherSettings": {
|
||||
|
||||
@@ -40,8 +40,6 @@
|
||||
"devDependencies": {
|
||||
"@types/express": "^4.17.21",
|
||||
"@types/node": "^22.14.0",
|
||||
"@types/react": "^19.3.0",
|
||||
"@types/react-dom": "^19.3.0",
|
||||
"autoprefixer": "^10.4.21",
|
||||
"esbuild": "^0.25.0",
|
||||
"postcss": "^8.5.28",
|
||||
|
||||
@@ -6,10 +6,11 @@
|
||||
"Authority": "",
|
||||
"Audience": "",
|
||||
"ClientId": "",
|
||||
"ClientSecret": "",
|
||||
"Scope": ""
|
||||
},
|
||||
"HasherSettings": {
|
||||
"Salt": "TGlnaHQgQ2hhcm1zLCBNaWRyYW5kQm9va3MgbWFrZSBsb3RzIG9mIG1vbmV5IGFuZCBhcmUgYSBzdWNjZXNzZnVsIHZpcmFsIHN0b3J5IGluIFNvdXRoIEFmcmljYQ==",
|
||||
"MinHashLength": "11"
|
||||
"Salt": "",
|
||||
"MinHashLength": ""
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,13 +1,4 @@
|
||||
// Development and fallback runtime configuration.
|
||||
// In Docker / Kubernetes / Cloud Run deployments, this file is dynamically
|
||||
// populated at container startup by /docker-entrypoint.sh with deployment environment variables.
|
||||
window.__RUNTIME_CONFIG__ = window.__RUNTIME_CONFIG__ || {
|
||||
HASHER_SALT: "TGlnaHQgQ2hhcm1zLCBNaWRyYW5kQm9va3MgbWFrZSBsb3RzIG9mIG1vbmV5IGFuZCBhcmUgYSBzdWNjZXNzZnVsIHZpcmFsIHN0b3J5IGluIFNvdXRoIEFmcmljYQ==",
|
||||
VITE_HASHER_SALT: "TGlnaHQgQ2hhcm1zLCBNaWRyYW5kQm9va3MgbWFrZSBsb3RzIG9mIG1vbmV5IGFuZCBhcmUgYSBzdWNjZXNzZnVsIHZpcmFsIHN0b3J5IGluIFNvdXRoIEFmcmljYQ==",
|
||||
HASHER_MIN_LENGTH: "11",
|
||||
VITE_HASHER_MIN_LENGTH: "11",
|
||||
HasherSettings: {
|
||||
Salt: "TGlnaHQgQ2hhcm1zLCBNaWRyYW5kQm9va3MgbWFrZSBsb3RzIG9mIG1vbmV5IGFuZCBhcmUgYSBzdWNjZXNzZnVsIHZpcmFsIHN0b3J5IGluIFNvdXRoIEFmcmljYQ==",
|
||||
MinHashLength: "11"
|
||||
}
|
||||
};
|
||||
window.__RUNTIME_CONFIG__ = window.__RUNTIME_CONFIG__ || {};
|
||||
|
||||
@@ -3,7 +3,6 @@ import { BrowserRouter, Routes, Route, Navigate } from 'react-router-dom';
|
||||
import { QueryClientProvider } from '@tanstack/react-query';
|
||||
import { queryClient } from './lib/queryClient';
|
||||
import { AuthProvider, ProtectedRoute, TenantAdminProtectedRoute } from './auth/AuthProvider';
|
||||
import { ClaimProtectedRoute, TenantManagementProtectedRoute, useRbac } from './auth/rbac';
|
||||
import { ToastProvider } from './components/ui/Toast';
|
||||
import { AppLayout } from './components/layout/AppLayout';
|
||||
|
||||
@@ -14,12 +13,10 @@ const ProjectsPage = lazy(() => import('./pages/ProjectsPage').then(m => ({ defa
|
||||
const BeneficiariesPage = lazy(() => import('./pages/BeneficiariesPage').then(m => ({ default: m.BeneficiariesPage })));
|
||||
const ChangeManagementPage = lazy(() => import('./pages/ChangeManagementPage').then(m => ({ default: m.ChangeManagementPage })));
|
||||
const SiteVisitsPage = lazy(() => import('./pages/SiteVisitsPage').then(m => ({ default: m.SiteVisitsPage })));
|
||||
const CompliancePage = lazy(() => import('./pages/CompliancePage').then(m => ({ default: m.CompliancePage })));
|
||||
const GrantsPage = lazy(() => import('./pages/GrantsPage').then(m => ({ default: m.GrantsPage })));
|
||||
const MonitoringPage = lazy(() => import('./pages/MonitoringPage').then(m => ({ default: m.MonitoringPage })));
|
||||
const TenantManagementPage = lazy(() => import('./pages/TenantManagementPage').then(m => ({ default: m.TenantManagementPage })));
|
||||
const SettingsPage = lazy(() => import('./pages/SettingsPage').then(m => ({ default: m.SettingsPage })));
|
||||
const InboxPage = lazy(() => import('./pages/InboxPage').then(m => ({ default: m.InboxPage })));
|
||||
|
||||
const RouteLoading = () => (
|
||||
<div className="flex items-center justify-center min-h-[400px] w-full text-slate-400">
|
||||
@@ -30,18 +27,6 @@ const RouteLoading = () => (
|
||||
</div>
|
||||
);
|
||||
|
||||
const DefaultRouteRedirect = () => {
|
||||
const { canView } = useRbac();
|
||||
if (canView('dashboard')) return <Navigate to="/dashboard" replace />;
|
||||
if (canView('projects_milestones')) return <Navigate to="/projects" replace />;
|
||||
if (canView('grant_financials')) return <Navigate to="/grants" replace />;
|
||||
if (canView('evidence_risks')) return <Navigate to="/monitoring" replace />;
|
||||
if (canView('governance')) return <Navigate to="/governance" replace />;
|
||||
if (canView('beneficiaries')) return <Navigate to="/projects/beneficiaries" replace />;
|
||||
if (canView('settings_users') || canView('settings_invites')) return <Navigate to="/settings" replace />;
|
||||
return <Navigate to="/projects" replace />;
|
||||
};
|
||||
|
||||
export default function App() {
|
||||
return (
|
||||
<QueryClientProvider client={queryClient}>
|
||||
@@ -62,133 +47,38 @@ export default function App() {
|
||||
</ProtectedRoute>
|
||||
}
|
||||
>
|
||||
<Route
|
||||
path="/dashboard"
|
||||
element={
|
||||
<ClaimProtectedRoute claim="dashboard">
|
||||
<ExecutiveDashboard />
|
||||
</ClaimProtectedRoute>
|
||||
}
|
||||
/>
|
||||
<Route
|
||||
path="/governance"
|
||||
element={
|
||||
<ClaimProtectedRoute claim="governance">
|
||||
<GovernancePage />
|
||||
</ClaimProtectedRoute>
|
||||
}
|
||||
/>
|
||||
<Route
|
||||
path="/projects"
|
||||
element={
|
||||
<ClaimProtectedRoute claim="projects_milestones">
|
||||
<ProjectsPage />
|
||||
</ClaimProtectedRoute>
|
||||
}
|
||||
/>
|
||||
<Route
|
||||
path="/projects/beneficiaries"
|
||||
element={
|
||||
<ClaimProtectedRoute claim="beneficiaries">
|
||||
<BeneficiariesPage />
|
||||
</ClaimProtectedRoute>
|
||||
}
|
||||
/>
|
||||
<Route
|
||||
path="/beneficiaries"
|
||||
element={
|
||||
<ClaimProtectedRoute claim="beneficiaries">
|
||||
<BeneficiariesPage />
|
||||
</ClaimProtectedRoute>
|
||||
}
|
||||
/>
|
||||
<Route
|
||||
path="/projects/change-management"
|
||||
element={
|
||||
<ClaimProtectedRoute claim="change_management">
|
||||
<ChangeManagementPage />
|
||||
</ClaimProtectedRoute>
|
||||
}
|
||||
/>
|
||||
<Route
|
||||
path="/change-management"
|
||||
element={
|
||||
<ClaimProtectedRoute claim="change_management">
|
||||
<ChangeManagementPage />
|
||||
</ClaimProtectedRoute>
|
||||
}
|
||||
/>
|
||||
<Route
|
||||
path="/projects/site-visits"
|
||||
element={
|
||||
<ClaimProtectedRoute claim="site_visits">
|
||||
<SiteVisitsPage />
|
||||
</ClaimProtectedRoute>
|
||||
}
|
||||
/>
|
||||
<Route
|
||||
path="/site-visits"
|
||||
element={
|
||||
<ClaimProtectedRoute claim="site_visits">
|
||||
<SiteVisitsPage />
|
||||
</ClaimProtectedRoute>
|
||||
}
|
||||
/>
|
||||
<Route
|
||||
path="/projects/compliance"
|
||||
element={
|
||||
<ClaimProtectedRoute claim="compliance">
|
||||
<CompliancePage />
|
||||
</ClaimProtectedRoute>
|
||||
}
|
||||
/>
|
||||
<Route
|
||||
path="/compliance"
|
||||
element={
|
||||
<ClaimProtectedRoute claim="compliance">
|
||||
<CompliancePage />
|
||||
</ClaimProtectedRoute>
|
||||
}
|
||||
/>
|
||||
<Route
|
||||
path="/grants"
|
||||
element={
|
||||
<ClaimProtectedRoute claim="grant_financials">
|
||||
<GrantsPage />
|
||||
</ClaimProtectedRoute>
|
||||
}
|
||||
/>
|
||||
<Route
|
||||
path="/monitoring"
|
||||
element={
|
||||
<ClaimProtectedRoute claim="evidence_risks">
|
||||
<MonitoringPage />
|
||||
</ClaimProtectedRoute>
|
||||
}
|
||||
/>
|
||||
<Route path="/dashboard" element={<ExecutiveDashboard />} />
|
||||
<Route path="/governance" element={<GovernancePage />} />
|
||||
<Route path="/projects" element={<ProjectsPage />} />
|
||||
<Route path="/projects/beneficiaries" element={<BeneficiariesPage />} />
|
||||
<Route path="/beneficiaries" element={<BeneficiariesPage />} />
|
||||
<Route path="/projects/change-management" element={<ChangeManagementPage />} />
|
||||
<Route path="/change-management" element={<ChangeManagementPage />} />
|
||||
<Route path="/projects/site-visits" element={<SiteVisitsPage />} />
|
||||
<Route path="/site-visits" element={<SiteVisitsPage />} />
|
||||
<Route path="/grants" element={<GrantsPage />} />
|
||||
<Route path="/monitoring" element={<MonitoringPage />} />
|
||||
<Route
|
||||
path="/tenants"
|
||||
element={
|
||||
<TenantManagementProtectedRoute>
|
||||
<TenantAdminProtectedRoute>
|
||||
<TenantManagementPage />
|
||||
</TenantManagementProtectedRoute>
|
||||
</TenantAdminProtectedRoute>
|
||||
}
|
||||
/>
|
||||
<Route
|
||||
path="/governance/tenants"
|
||||
element={
|
||||
<TenantManagementProtectedRoute>
|
||||
<TenantAdminProtectedRoute>
|
||||
<TenantManagementPage />
|
||||
</TenantManagementProtectedRoute>
|
||||
</TenantAdminProtectedRoute>
|
||||
}
|
||||
/>
|
||||
<Route path="/inbox" element={<InboxPage />} />
|
||||
<Route path="/mailbox" element={<InboxPage />} />
|
||||
<Route path="/settings" element={<SettingsPage />} />
|
||||
</Route>
|
||||
|
||||
{/* Fallback Redirect */}
|
||||
<Route path="*" element={<DefaultRouteRedirect />} />
|
||||
<Route path="*" element={<Navigate to="/dashboard" replace />} />
|
||||
</Routes>
|
||||
</Suspense>
|
||||
</ToastProvider>
|
||||
@@ -197,4 +87,3 @@ export default function App() {
|
||||
</QueryClientProvider>
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -10,14 +10,13 @@ import {
|
||||
isActivationPending,
|
||||
getActivationPendingReason,
|
||||
clearUserSyncInBrowser,
|
||||
setActivationPending,
|
||||
UserSyncResult,
|
||||
} from '../services/userSyncService';
|
||||
import { AccountActivationModal } from '../components/auth/AccountActivationModal';
|
||||
import { PartnerAccessDeniedModal } from '../components/auth/PartnerAccessDeniedModal';
|
||||
import { SessionExpiredModal } from '../components/auth/SessionExpiredModal';
|
||||
import { encodeHashIdSync } from '../utils/hashids';
|
||||
import { verifyTenantLockout, clearTenantLockoutCache } from '../services/tenantLockoutService';
|
||||
import { encodeHashIdSync } from '../services/toolsService';
|
||||
import { verifyTenantLockout } from '../services/tenantLockoutService';
|
||||
import {
|
||||
onSessionExpired,
|
||||
triggerSessionExpired,
|
||||
@@ -25,7 +24,6 @@ import {
|
||||
cancelSessionExpiration,
|
||||
dismissSubordinateModals,
|
||||
} from '../services/sessionExpiration';
|
||||
import { resolveUserRolesAndClaims } from '../services/roleClaimsCache';
|
||||
|
||||
export interface AuthUserProfile {
|
||||
id: string;
|
||||
@@ -65,7 +63,7 @@ interface AuthContextType {
|
||||
authError: string | null;
|
||||
}
|
||||
|
||||
export const AuthContext = createContext<AuthContextType | undefined>(undefined);
|
||||
const AuthContext = createContext<AuthContextType | undefined>(undefined);
|
||||
|
||||
export const LOCAL_STORAGE_TOKEN_KEY = 'pfm_access_token';
|
||||
export const LOCAL_STORAGE_USER_KEY = 'pfm_auth_user';
|
||||
@@ -308,6 +306,11 @@ export function resetUserManager(): void {
|
||||
userManagerInstance = null;
|
||||
}
|
||||
|
||||
// Automatically reset UserManager when runtime configuration is updated
|
||||
onConfigChanged(() => {
|
||||
resetUserManager();
|
||||
});
|
||||
|
||||
export function getUserManager(): UserManager {
|
||||
if (!userManagerInstance && typeof window !== 'undefined') {
|
||||
const sec = getSecuritySettings();
|
||||
@@ -316,7 +319,7 @@ export function getUserManager(): UserManager {
|
||||
userManagerInstance = new UserManager({
|
||||
authority: sec.Authority,
|
||||
client_id: sec.ClientId,
|
||||
...(sec.ClientSecret ? { client_secret: sec.ClientSecret } : {}),
|
||||
client_secret: sec.ClientSecret,
|
||||
redirect_uri: `${origin}/dashboard`,
|
||||
post_logout_redirect_uri: `${origin}/`,
|
||||
response_type: 'code',
|
||||
@@ -411,7 +414,7 @@ export const AuthProvider: React.FC<{ children: ReactNode }> = ({ children }) =>
|
||||
const urlParams = new URLSearchParams(window.location.search);
|
||||
if (urlParams.has('code') && urlParams.has('state')) {
|
||||
const mgr = getUserManager();
|
||||
const oidcUser: OidcUser | undefined = await mgr.signinCallback();
|
||||
const oidcUser: OidcUser = await mgr.signinCallback();
|
||||
|
||||
if (oidcUser && oidcUser.access_token) {
|
||||
const token = oidcUser.access_token;
|
||||
@@ -433,8 +436,8 @@ export const AuthProvider: React.FC<{ children: ReactNode }> = ({ children }) =>
|
||||
localStorage.setItem(LOCAL_STORAGE_CLAIMS_KEY, JSON.stringify(mergedClaims));
|
||||
|
||||
const tenantClaimValues = extractTenantClaimValues(mergedClaims, token);
|
||||
let tenantClaim = (mergedClaims.tenant as string) || (oidcUser.profile?.tenant as string) || tenantClaimValues.tenant || undefined;
|
||||
let parsedTenantId = extractTenantId(mergedClaims, token);
|
||||
const tenantClaim = (mergedClaims.tenant as string) || (oidcUser.profile?.tenant as string) || tenantClaimValues.tenant || undefined;
|
||||
const parsedTenantId = extractTenantId(mergedClaims, token);
|
||||
setTenantId(parsedTenantId);
|
||||
|
||||
const profile: AuthUserProfile = {
|
||||
@@ -454,38 +457,7 @@ export const AuthProvider: React.FC<{ children: ReactNode }> = ({ children }) =>
|
||||
// Clean up code and state from URL
|
||||
window.history.replaceState({}, document.title, window.location.pathname);
|
||||
|
||||
// Step 1: Synchronize authenticated user profile with backend directory FIRST (/v1/api/integration/sync)
|
||||
try {
|
||||
const syncResult = await syncUserAccount(userSub, token, profile, true);
|
||||
if (syncResult.isUnauthorized) {
|
||||
await logout();
|
||||
window.location.replace('/');
|
||||
return;
|
||||
}
|
||||
|
||||
// Step 2: Retrieve user roles, role claims, and user claims (Requirement 2 & 3 & 7)
|
||||
try {
|
||||
const resolved = await resolveUserRolesAndClaims(userSub, mergedClaims, profile);
|
||||
if (resolved.primaryRole) {
|
||||
profile.role = resolved.primaryRole;
|
||||
}
|
||||
if (resolved.tenant) {
|
||||
profile.tenant = resolved.tenant;
|
||||
tenantClaim = resolved.tenant;
|
||||
setTenantName(resolved.tenant);
|
||||
}
|
||||
if (resolved.tenant_id !== undefined) {
|
||||
profile.tenant_id = resolved.tenant_id;
|
||||
parsedTenantId = resolved.tenant_id;
|
||||
setTenantId(resolved.tenant_id);
|
||||
}
|
||||
setUser({ ...profile });
|
||||
setClaims({ ...resolved.claims });
|
||||
localStorage.setItem(LOCAL_STORAGE_USER_KEY, JSON.stringify(profile));
|
||||
localStorage.setItem(LOCAL_STORAGE_CLAIMS_KEY, JSON.stringify(resolved.claims));
|
||||
} catch {}
|
||||
|
||||
// Step 3: Verify tenant lockout now that user is synced and authenticated
|
||||
// Verify tenant lockout at the instance tenant and tenant_id claims are retrieved
|
||||
const tenantLockoutResult = await verifyTenantLockout(
|
||||
parsedTenantId ?? tenantClaimValues.tenantId,
|
||||
tenantClaim
|
||||
@@ -507,6 +479,14 @@ export const AuthProvider: React.FC<{ children: ReactNode }> = ({ children }) =>
|
||||
setTenantName(tenantClaim);
|
||||
}
|
||||
|
||||
// Synchronize authenticated user profile with backend directory
|
||||
try {
|
||||
const syncResult = await syncUserAccount(userSub, token, profile, true);
|
||||
if (syncResult.isUnauthorized) {
|
||||
await logout();
|
||||
window.location.replace('/');
|
||||
return;
|
||||
}
|
||||
if (syncResult.isActivationPending) {
|
||||
setIsPendingActivation(true);
|
||||
setIsSynced(false);
|
||||
@@ -524,9 +504,7 @@ export const AuthProvider: React.FC<{ children: ReactNode }> = ({ children }) =>
|
||||
}
|
||||
setIsPendingActivation(true);
|
||||
setIsSynced(false);
|
||||
const rawMsg = syncErr?.message || 'Account activation pending';
|
||||
const cleanMsg = /network\s*error/i.test(rawMsg) ? rawMsg.replace(/network\s*error/gi, 'Permission Check') : rawMsg;
|
||||
setActivationError(cleanMsg);
|
||||
setActivationError(syncErr?.message || 'Account activation pending');
|
||||
}
|
||||
|
||||
setIsLoading(false);
|
||||
@@ -562,10 +540,10 @@ export const AuthProvider: React.FC<{ children: ReactNode }> = ({ children }) =>
|
||||
setSub(storedSub);
|
||||
}
|
||||
|
||||
let storedTenantId = extractTenantId(parsedClaims, storedToken);
|
||||
const storedTenantId = extractTenantId(parsedClaims, storedToken);
|
||||
setTenantId(storedTenantId);
|
||||
const tenantClaimValues = extractTenantClaimValues(parsedClaims, storedToken);
|
||||
let storedTenantClaim = (parsedClaims.tenant as string) || tenantClaimValues.tenant || undefined;
|
||||
const storedTenantClaim = (parsedClaims.tenant as string) || tenantClaimValues.tenant || undefined;
|
||||
|
||||
let resolvedUser: AuthUserProfile | null = null;
|
||||
if (storedUser) {
|
||||
@@ -582,60 +560,7 @@ export const AuthProvider: React.FC<{ children: ReactNode }> = ({ children }) =>
|
||||
} catch {}
|
||||
}
|
||||
|
||||
if (storedTenantClaim) {
|
||||
setTenantName(storedTenantClaim);
|
||||
}
|
||||
|
||||
// Initial fast unblock: valid persisted session found, unblock UI immediately
|
||||
setIsLoading(false);
|
||||
|
||||
// Asynchronous background revalidation (User Sync, Role Claims, Tenant Lockout)
|
||||
(async () => {
|
||||
try {
|
||||
const wasPending = isActivationPending();
|
||||
setIsPendingActivation(wasPending);
|
||||
setActivationError(getActivationPendingReason());
|
||||
|
||||
// Step 1: Sync user account FIRST
|
||||
const syncResult = await syncUserAccount(
|
||||
storedSub || '',
|
||||
storedToken,
|
||||
resolvedUser || undefined,
|
||||
false
|
||||
);
|
||||
|
||||
if (syncResult.isUnauthorized) {
|
||||
await logout();
|
||||
return;
|
||||
}
|
||||
|
||||
// Step 2: Retrieve user roles, role claims, and user claims
|
||||
try {
|
||||
const resolved = await resolveUserRolesAndClaims(
|
||||
storedSub || '',
|
||||
parsedClaims,
|
||||
resolvedUser || {}
|
||||
);
|
||||
if (resolvedUser) {
|
||||
if (resolved.primaryRole) resolvedUser.role = resolved.primaryRole;
|
||||
if (resolved.tenant) {
|
||||
resolvedUser.tenant = resolved.tenant;
|
||||
storedTenantClaim = resolved.tenant;
|
||||
setTenantName(resolved.tenant);
|
||||
}
|
||||
if (resolved.tenant_id !== undefined) {
|
||||
resolvedUser.tenant_id = resolved.tenant_id;
|
||||
storedTenantId = resolved.tenant_id;
|
||||
setTenantId(resolved.tenant_id);
|
||||
}
|
||||
setUser({ ...resolvedUser });
|
||||
localStorage.setItem(LOCAL_STORAGE_USER_KEY, JSON.stringify(resolvedUser));
|
||||
}
|
||||
setClaims({ ...resolved.claims });
|
||||
localStorage.setItem(LOCAL_STORAGE_CLAIMS_KEY, JSON.stringify(resolved.claims));
|
||||
} catch {}
|
||||
|
||||
// Step 3: Verify tenant lockout now that user is verified active
|
||||
// Verify tenant lockout at the instance tenant and tenant_id claims are retrieved
|
||||
const tenantLockoutResult = await verifyTenantLockout(
|
||||
storedTenantId ?? tenantClaimValues.tenantId,
|
||||
storedTenantClaim
|
||||
@@ -645,6 +570,7 @@ export const AuthProvider: React.FC<{ children: ReactNode }> = ({ children }) =>
|
||||
setIsPendingActivation(true);
|
||||
setIsSynced(false);
|
||||
setActivationError(tenantLockoutResult.errorMessage || 'Tenant is inactive or not found');
|
||||
setIsLoading(false);
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -655,16 +581,35 @@ export const AuthProvider: React.FC<{ children: ReactNode }> = ({ children }) =>
|
||||
localStorage.setItem(LOCAL_STORAGE_USER_KEY, JSON.stringify(resolvedUser));
|
||||
}
|
||||
setTenantName(tenantLockoutResult.tenant.name);
|
||||
} else if (storedTenantClaim) {
|
||||
setTenantName(storedTenantClaim);
|
||||
}
|
||||
|
||||
// Check if session is already marked pending activation or needs sync check
|
||||
const wasPending = isActivationPending();
|
||||
setIsPendingActivation(wasPending);
|
||||
setActivationError(getActivationPendingReason());
|
||||
|
||||
try {
|
||||
// Verify sync with browser cache priority (hotspot prevention)
|
||||
const syncResult = await syncUserAccount(
|
||||
storedSub || '',
|
||||
storedToken,
|
||||
resolvedUser || undefined,
|
||||
false
|
||||
);
|
||||
|
||||
if (syncResult.isUnauthorized) {
|
||||
// HTTP 401: Normal process of routing user to landing page
|
||||
await logout();
|
||||
return;
|
||||
}
|
||||
|
||||
if (syncResult.isActivationPending) {
|
||||
setIsPendingActivation(true);
|
||||
setIsSynced(false);
|
||||
setActivationError(syncResult.errorMessage || null);
|
||||
return;
|
||||
}
|
||||
|
||||
if (syncResult.success) {
|
||||
} else if (syncResult.success) {
|
||||
setIsPendingActivation(false);
|
||||
setIsSynced(true);
|
||||
setActivationError(null);
|
||||
@@ -676,13 +621,8 @@ export const AuthProvider: React.FC<{ children: ReactNode }> = ({ children }) =>
|
||||
}
|
||||
setIsPendingActivation(true);
|
||||
setIsSynced(false);
|
||||
const rawMsg = syncErr?.message || 'Account activation pending';
|
||||
const cleanMsg = /network\s*error/i.test(rawMsg) ? rawMsg.replace(/network\s*error/gi, 'Permission Check') : rawMsg;
|
||||
setActivationError(cleanMsg);
|
||||
setActivationError(syncErr?.message || 'Account activation pending');
|
||||
}
|
||||
})();
|
||||
|
||||
return;
|
||||
}
|
||||
} catch (err: unknown) {
|
||||
console.warn('OIDC initialization notice:', err);
|
||||
@@ -697,65 +637,12 @@ export const AuthProvider: React.FC<{ children: ReactNode }> = ({ children }) =>
|
||||
|
||||
const syncUserSession = async (force = true): Promise<UserSyncResult> => {
|
||||
try {
|
||||
await loadRuntimeConfig();
|
||||
clearTenantLockoutCache();
|
||||
|
||||
// Step 1: Proceed with user sync FIRST (/v1/api/integration/sync)
|
||||
const result = await syncUserAccount(sub, accessToken, user || undefined, force);
|
||||
if (result.isUnauthorized) {
|
||||
await logout();
|
||||
return result;
|
||||
}
|
||||
if (result.isActivationPending) {
|
||||
setIsPendingActivation(true);
|
||||
setIsSynced(false);
|
||||
setActivationError(result.errorMessage || null);
|
||||
return result;
|
||||
}
|
||||
|
||||
// Step 2: Refresh user roles and claims from backend
|
||||
let currentTenantId: number | string | null = tenantId;
|
||||
let currentTenantClaim: string | undefined =
|
||||
(claims.tenant as string) || user?.tenant || undefined;
|
||||
|
||||
try {
|
||||
const resolved = await resolveUserRolesAndClaims(sub, claims, user || {});
|
||||
const updatedProfile = user ? { ...user } : null;
|
||||
if (updatedProfile && resolved.primaryRole) {
|
||||
updatedProfile.role = resolved.primaryRole;
|
||||
}
|
||||
if (resolved.tenant) {
|
||||
currentTenantClaim = resolved.tenant;
|
||||
setTenantName(resolved.tenant);
|
||||
if (updatedProfile) updatedProfile.tenant = resolved.tenant;
|
||||
}
|
||||
if (resolved.tenant_id !== undefined) {
|
||||
currentTenantId = resolved.tenant_id;
|
||||
setTenantId(resolved.tenant_id);
|
||||
if (updatedProfile) updatedProfile.tenant_id = resolved.tenant_id;
|
||||
}
|
||||
if (updatedProfile) {
|
||||
setUser(updatedProfile);
|
||||
localStorage.setItem(LOCAL_STORAGE_USER_KEY, JSON.stringify(updatedProfile));
|
||||
}
|
||||
setClaims({ ...resolved.claims });
|
||||
localStorage.setItem(LOCAL_STORAGE_CLAIMS_KEY, JSON.stringify(resolved.claims));
|
||||
} catch {}
|
||||
|
||||
// Step 3: Re-verify tenant status via GetTenant now that user is synced
|
||||
// Re-verify tenant status via GetTenant as part of the lockout resolution
|
||||
const tenantClaimValues = extractTenantClaimValues(claims, accessToken);
|
||||
const effectiveTenantId =
|
||||
currentTenantId ??
|
||||
result.data?.tenantId ??
|
||||
result.data?.organisationId ??
|
||||
tenantClaimValues.tenantId;
|
||||
const effectiveTenantClaim = currentTenantClaim || tenantClaimValues.tenant || undefined;
|
||||
const currentTenantId = tenantId ?? tenantClaimValues.tenantId;
|
||||
const currentTenantClaim = (claims.tenant as string) || user?.tenant || tenantClaimValues.tenant || undefined;
|
||||
|
||||
const tenantLockoutResult = await verifyTenantLockout(
|
||||
effectiveTenantId,
|
||||
effectiveTenantClaim,
|
||||
true
|
||||
);
|
||||
const tenantLockoutResult = await verifyTenantLockout(currentTenantId, currentTenantClaim);
|
||||
if (!tenantLockoutResult.isActive) {
|
||||
const msg = tenantLockoutResult.errorMessage || 'Tenant is not Active. Lockout active.';
|
||||
setIsPendingActivation(true);
|
||||
@@ -768,22 +655,28 @@ export const AuthProvider: React.FC<{ children: ReactNode }> = ({ children }) =>
|
||||
};
|
||||
}
|
||||
|
||||
if (tenantLockoutResult.tenant?.name) {
|
||||
setTenantName(tenantLockoutResult.tenant.name);
|
||||
// Tenant is verified Active: proceed with user sync
|
||||
const result = await syncUserAccount(sub, accessToken, user || undefined, force);
|
||||
if (result.isUnauthorized) {
|
||||
await logout();
|
||||
return result;
|
||||
}
|
||||
|
||||
setActivationPending(false);
|
||||
if (result.success) {
|
||||
setIsPendingActivation(false);
|
||||
setIsSynced(true);
|
||||
setActivationError(null);
|
||||
} else if (result.isActivationPending) {
|
||||
setIsPendingActivation(true);
|
||||
setIsSynced(false);
|
||||
setActivationError(result.errorMessage || null);
|
||||
}
|
||||
return result;
|
||||
} catch (err: any) {
|
||||
if (err?.response?.status === 401) {
|
||||
await logout();
|
||||
return { success: false, isUnauthorized: true };
|
||||
}
|
||||
const rawMsg = err?.message || 'Sync failed';
|
||||
const msg = /network\s*error/i.test(rawMsg) ? rawMsg.replace(/network\s*error/gi, 'Permission Check') : rawMsg;
|
||||
const msg = err?.message || 'Sync failed';
|
||||
setIsPendingActivation(true);
|
||||
setIsSynced(false);
|
||||
setActivationError(msg);
|
||||
|
||||
@@ -1,437 +0,0 @@
|
||||
import React, { useMemo } from 'react';
|
||||
import { Navigate } from 'react-router-dom';
|
||||
import { useAuth } from './AuthProvider';
|
||||
|
||||
export type RoleClaim =
|
||||
| 'dashboard'
|
||||
| 'governance'
|
||||
| 'projects_milestones'
|
||||
| 'grant_financials'
|
||||
| 'evidence_risks'
|
||||
| 'beneficiaries'
|
||||
| 'change_management'
|
||||
| 'site_visits'
|
||||
| 'compliance'
|
||||
| 'settings_invites'
|
||||
| 'settings_users'
|
||||
| 'settings_auditlog'
|
||||
| 'settings_notifications'
|
||||
| 'tenant_self_service'
|
||||
| 'tenant_management'
|
||||
| 'unrestricted';
|
||||
|
||||
export type ClaimAction = 'view' | 'add' | 'update' | 'export' | 'all';
|
||||
|
||||
export interface RoleClaimDefinition {
|
||||
claim: RoleClaim;
|
||||
label: string;
|
||||
description: string;
|
||||
}
|
||||
|
||||
export const ROLE_CLAIM_DEFINITIONS: RoleClaimDefinition[] = [
|
||||
{ claim: 'dashboard', label: 'Executive Dashboard', description: 'Strategic macro metrics and ISO 31000 portfolio health' },
|
||||
{ claim: 'governance', label: 'Governance & Structure', description: 'Holding entities, investment portfolios, and statutory programmes' },
|
||||
{ claim: 'projects_milestones', label: 'Projects & Milestones', description: 'Work breakdown structure, scheduled milestones, and progress' },
|
||||
{ claim: 'grant_financials', label: 'Grant & Financials', description: 'Funding awards, budget line allocations, and disbursements' },
|
||||
{ claim: 'evidence_risks', label: 'M&E, Evidence & Risks', description: 'Evidence verification queue and ISO 31000 risk registers' },
|
||||
{ claim: 'beneficiaries', label: 'Beneficiaries', description: 'Target demographics, compliance tracking, and outcomes' },
|
||||
{ claim: 'change_management', label: 'Change Management', description: 'Contractual change requests, issues, and enactments' },
|
||||
{ claim: 'site_visits', label: 'Site Visits', description: 'Physical field inspection reports and inspector findings' },
|
||||
{ claim: 'compliance', label: 'Compliance', description: 'Statutory compliance dashboard, outcome fulfillment, and risk monitoring' },
|
||||
{ claim: 'settings_invites', label: 'Settings Invites', description: 'Statutory onboarding invitations and applicant notifications' },
|
||||
{ claim: 'settings_users', label: 'Settings Users', description: 'Direct user account administration and role memberships' },
|
||||
{ claim: 'settings_auditlog', label: 'Settings Audit Log', description: 'System-wide activity ledger and telemetry records' },
|
||||
{ claim: 'settings_notifications', label: 'Settings Notifications', description: 'SMTP engines, notification dispatch, and gateways' },
|
||||
{ claim: 'tenant_self_service', label: 'Tenant Self-Service', description: 'Tenant configuration parameters and self-managed sub-users' },
|
||||
{ claim: 'tenant_management', label: 'Tenant Management', description: 'Multi-tenant partition directory and cross-tenant isolation' },
|
||||
{ claim: 'unrestricted', label: 'Unrestricted Access', description: 'Full system-wide administrative control' },
|
||||
];
|
||||
|
||||
export const CLAIM_ACTION_OPTIONS: { action: ClaimAction; label: string; description: string }[] = [
|
||||
{ action: 'view', label: 'view', description: 'Can view records based on role' },
|
||||
{ action: 'add', label: 'add', description: 'Can create records or give feedback; change non-approve statuses' },
|
||||
{ action: 'update', label: 'update', description: 'Can use approve status change in dropdowns' },
|
||||
{ action: 'export', label: 'export', description: 'Can click export buttons (Spreadsheet/PDF)' },
|
||||
{ action: 'all', label: 'all', description: 'Can do everything' },
|
||||
];
|
||||
|
||||
/**
|
||||
* Base Role to Claims mapping according to PFM RBAC Specification.
|
||||
*/
|
||||
export const ROLE_CLAIM_MAP: Record<string, RoleClaim[]> = {
|
||||
admin: ['unrestricted'],
|
||||
executive: [
|
||||
'dashboard',
|
||||
'compliance',
|
||||
'governance',
|
||||
'projects_milestones',
|
||||
'grant_financials',
|
||||
'evidence_risks',
|
||||
'tenant_self_service',
|
||||
],
|
||||
supervisor: [
|
||||
'dashboard',
|
||||
'compliance',
|
||||
'projects_milestones',
|
||||
'grant_financials',
|
||||
'evidence_risks',
|
||||
'beneficiaries',
|
||||
'change_management',
|
||||
'site_visits',
|
||||
'settings_invites',
|
||||
'settings_users',
|
||||
],
|
||||
manager: [
|
||||
'dashboard',
|
||||
'compliance',
|
||||
'projects_milestones',
|
||||
'grant_financials',
|
||||
'evidence_risks',
|
||||
'beneficiaries',
|
||||
'change_management',
|
||||
'site_visits',
|
||||
'settings_invites',
|
||||
'settings_users',
|
||||
'settings_auditlog',
|
||||
'settings_notifications',
|
||||
'tenant_self_service',
|
||||
],
|
||||
contributor: [
|
||||
'projects_milestones',
|
||||
'grant_financials',
|
||||
'evidence_risks',
|
||||
'beneficiaries',
|
||||
'change_management',
|
||||
'site_visits',
|
||||
'settings_invites',
|
||||
],
|
||||
};
|
||||
|
||||
/**
|
||||
* Resolves action capability set given a claim value string.
|
||||
* - view: can view records based on role
|
||||
* - add: can add/create records or give feedback, change statuses other than approve (+ view)
|
||||
* - update: can use approve status change in drop downs (+ view, + add)
|
||||
* - export: can click export buttons (+ view)
|
||||
* - all: can do everything
|
||||
*/
|
||||
export function resolveClaimActionSet(
|
||||
claimValue: string | null | undefined,
|
||||
roleName?: string
|
||||
): Set<ClaimAction> {
|
||||
const actions = new Set<ClaimAction>();
|
||||
if (!claimValue && !roleName) return actions;
|
||||
|
||||
const rawVal = (claimValue || '').toLowerCase().trim();
|
||||
const role = (roleName || '').toLowerCase().trim();
|
||||
|
||||
// Explicit claim assignment values
|
||||
if (rawVal) {
|
||||
if (rawVal === 'all' || rawVal === 'unrestricted' || rawVal === 'true' || rawVal === '1') {
|
||||
actions.add('all');
|
||||
actions.add('view');
|
||||
actions.add('add');
|
||||
actions.add('update');
|
||||
actions.add('export');
|
||||
return actions;
|
||||
}
|
||||
if (rawVal === 'none' || rawVal === 'restricted' || rawVal === 'false' || rawVal === '0') {
|
||||
return actions;
|
||||
}
|
||||
|
||||
const tokens = rawVal.split(',').map((t) => t.trim());
|
||||
for (const val of tokens) {
|
||||
if (val === 'all') {
|
||||
actions.add('all');
|
||||
actions.add('view');
|
||||
actions.add('add');
|
||||
actions.add('update');
|
||||
actions.add('export');
|
||||
} else if (val === 'view' || val === 'read') {
|
||||
actions.add('view');
|
||||
} else if (val === 'add' || val === 'edit' || val === 'write' || val === 'create') {
|
||||
// 'add' encompasses create and non-approval edits
|
||||
actions.add('view');
|
||||
actions.add('add');
|
||||
} else if (val === 'update' || val === 'approve' || val === 'verify') {
|
||||
// 'update' strictly grants approval capability (+ view and add)
|
||||
actions.add('view');
|
||||
actions.add('add');
|
||||
actions.add('update');
|
||||
} else if (val === 'export') {
|
||||
actions.add('view');
|
||||
actions.add('export');
|
||||
}
|
||||
}
|
||||
return actions;
|
||||
}
|
||||
|
||||
// Default fallback by role if no overriding claim value specified
|
||||
if (role === 'admin') {
|
||||
actions.add('all');
|
||||
actions.add('view');
|
||||
actions.add('add');
|
||||
actions.add('update');
|
||||
actions.add('export');
|
||||
} else if (role === 'executive' || role === 'manager') {
|
||||
actions.add('all');
|
||||
actions.add('view');
|
||||
actions.add('add');
|
||||
actions.add('update');
|
||||
actions.add('export');
|
||||
} else if (role === 'supervisor') {
|
||||
// Supervisor performs operational review and verification/approval of milestones & evidence
|
||||
actions.add('view');
|
||||
actions.add('add');
|
||||
actions.add('update');
|
||||
actions.add('export');
|
||||
} else if (role === 'contributor') {
|
||||
// Contributor is strictly working-level execution: creating activities, submitting evidence, etc. (NO approve/update)
|
||||
actions.add('view');
|
||||
actions.add('add');
|
||||
} else {
|
||||
actions.add('view');
|
||||
}
|
||||
|
||||
return actions;
|
||||
}
|
||||
|
||||
/**
|
||||
* Helper to extract explicit claim value from varied dictionary structures
|
||||
*/
|
||||
export function extractExplicitClaimValue(
|
||||
claims: Record<string, any> = {},
|
||||
claim: RoleClaim
|
||||
): string | null {
|
||||
if (!claims || typeof claims !== 'object') return null;
|
||||
|
||||
if (claims[claim] !== undefined && claims[claim] !== null) {
|
||||
return String(claims[claim]).trim();
|
||||
}
|
||||
if (claims[`role_claim:${claim}`] !== undefined) {
|
||||
return String(claims[`role_claim:${claim}`]).trim();
|
||||
}
|
||||
if (claims[`claim:${claim}`] !== undefined) {
|
||||
return String(claims[`claim:${claim}`]).trim();
|
||||
}
|
||||
|
||||
const noUnderscore = claim.replace(/_/g, '');
|
||||
if (claims[noUnderscore] !== undefined) {
|
||||
return String(claims[noUnderscore]).trim();
|
||||
}
|
||||
|
||||
const arrayCandidates = [
|
||||
claims.claims,
|
||||
claims.UserClaims,
|
||||
claims.user_claims,
|
||||
claims.role_claims,
|
||||
];
|
||||
for (const arr of arrayCandidates) {
|
||||
if (Array.isArray(arr)) {
|
||||
for (const item of arr) {
|
||||
if (typeof item === 'object' && item !== null) {
|
||||
const type = (item.type || item.claimType || item.name || '').toLowerCase().trim();
|
||||
if (type === claim || type === noUnderscore) {
|
||||
return String(item.value || item.claimValue || 'all').trim();
|
||||
}
|
||||
} else if (typeof item === 'string') {
|
||||
const [type, val] = item.split(':').map((s) => s.trim().toLowerCase());
|
||||
if (type === claim || type === noUnderscore) {
|
||||
return val || 'all';
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Evaluates whether a user with given roles and token claims has permission for a specific claim and action.
|
||||
* If a role claim is not present, the user is fully restricted to performing actions associated with claim in role.
|
||||
*/
|
||||
export function evaluateUserPermission(
|
||||
roles: string[] = [],
|
||||
claims: Record<string, any> = {},
|
||||
claim: RoleClaim,
|
||||
action: ClaimAction = 'view'
|
||||
): boolean {
|
||||
// Security lockout: Partner role is completely locked out of this console
|
||||
const isPartner = roles.some((r) => r.toLowerCase().trim() === 'partner');
|
||||
if (isPartner) return false;
|
||||
|
||||
// Unrestricted Admin or unrestricted claim -> full access
|
||||
const hasUnrestrictedRole = roles.some((r) => r.toLowerCase().trim() === 'admin');
|
||||
if (hasUnrestrictedRole) return true;
|
||||
|
||||
const rawUnrestricted = extractExplicitClaimValue(claims, 'unrestricted');
|
||||
if (
|
||||
rawUnrestricted === 'all' ||
|
||||
rawUnrestricted === 'true' ||
|
||||
rawUnrestricted === '1' ||
|
||||
claims.unrestricted === true
|
||||
) {
|
||||
return true;
|
||||
}
|
||||
|
||||
if (claim === 'unrestricted') {
|
||||
return hasUnrestrictedRole;
|
||||
}
|
||||
|
||||
// 1. Check if user has explicit claim in claims dictionary
|
||||
const rawClaimValue = extractExplicitClaimValue(claims, claim);
|
||||
if (rawClaimValue !== null && rawClaimValue !== '') {
|
||||
const actionSet = resolveClaimActionSet(rawClaimValue);
|
||||
return actionSet.has('all') || actionSet.has(action);
|
||||
}
|
||||
|
||||
// 2. Check base role mappings
|
||||
let claimFoundInRole = false;
|
||||
for (const r of roles) {
|
||||
const normalizedRole = r.toLowerCase().trim();
|
||||
const allowedClaims = ROLE_CLAIM_MAP[normalizedRole];
|
||||
if (allowedClaims && allowedClaims.includes(claim)) {
|
||||
claimFoundInRole = true;
|
||||
const actionSet = resolveClaimActionSet(undefined, normalizedRole);
|
||||
if (actionSet.has('all') || actionSet.has(action)) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// If a role claim is not present, the user is fully restricted
|
||||
return false;
|
||||
}
|
||||
|
||||
/**
|
||||
* Evaluates Maker-Checker separation of duties constraint.
|
||||
* In PFM governance, the user who created (or initiated) a record ("Maker")
|
||||
* is strictly barred from approving ("Checking") their own record.
|
||||
*
|
||||
* @param createdBy The user identifier of the record creator (Guid, sub, email)
|
||||
* @param currentUserId Current logged-in user ID (user.id or sub)
|
||||
* @param currentUserSub Current logged-in user subject claim
|
||||
* @param currentUserEmail Current logged-in user email
|
||||
* @returns boolean true if user is the creator (Maker), false otherwise
|
||||
*/
|
||||
export function isRecordMaker(
|
||||
createdBy?: string | null,
|
||||
currentUserId?: string | null,
|
||||
currentUserSub?: string | null,
|
||||
currentUserEmail?: string | null
|
||||
): boolean {
|
||||
if (!createdBy) return false;
|
||||
const creator = createdBy.trim().toLowerCase();
|
||||
if (currentUserId && creator === currentUserId.trim().toLowerCase()) return true;
|
||||
if (currentUserSub && creator === currentUserSub.trim().toLowerCase()) return true;
|
||||
if (currentUserEmail && creator === currentUserEmail.trim().toLowerCase()) return true;
|
||||
return false;
|
||||
}
|
||||
|
||||
/**
|
||||
* React hook exposing RBAC evaluation helpers for components.
|
||||
*/
|
||||
export function useRbac() {
|
||||
const { roles, claims, user, sub } = useAuth();
|
||||
|
||||
const permissions = useMemo(() => {
|
||||
const isUnrestricted =
|
||||
roles.some((r) => r.toLowerCase().trim() === 'admin') ||
|
||||
claims?.unrestricted === true ||
|
||||
claims?.unrestricted === 'all';
|
||||
|
||||
const canAccess = (claim: RoleClaim, action: ClaimAction = 'view'): boolean => {
|
||||
return evaluateUserPermission(roles, claims, claim, action);
|
||||
};
|
||||
|
||||
const canView = (claim: RoleClaim): boolean => canAccess(claim, 'view');
|
||||
const canAdd = (claim: RoleClaim): boolean => canAccess(claim, 'add');
|
||||
const canApprove = (claim: RoleClaim): boolean => canAccess(claim, 'update');
|
||||
const canExport = (claim: RoleClaim): boolean => canAccess(claim, 'export');
|
||||
|
||||
const isMaker = (createdBy?: string | null): boolean => {
|
||||
return isRecordMaker(createdBy, user?.id, sub, user?.email);
|
||||
};
|
||||
|
||||
const canApproveRecord = (claim: RoleClaim, createdBy?: string | null): boolean => {
|
||||
if (!canApprove(claim)) return false;
|
||||
// Maker-Checker policy: record creator cannot approve their own record
|
||||
return !isMaker(createdBy);
|
||||
};
|
||||
|
||||
const getApprovalRestrictionReason = (claim: RoleClaim, createdBy?: string | null): string | null => {
|
||||
if (!canApprove(claim)) return 'Requires update/approval permission';
|
||||
if (isMaker(createdBy)) return 'Maker-Checker policy: Record creators cannot approve their own records';
|
||||
return null;
|
||||
};
|
||||
|
||||
return {
|
||||
isUnrestricted,
|
||||
canAccess,
|
||||
canView,
|
||||
canAdd,
|
||||
canApprove,
|
||||
canExport,
|
||||
isMaker,
|
||||
canApproveRecord,
|
||||
getApprovalRestrictionReason,
|
||||
};
|
||||
}, [roles, claims, user, sub]);
|
||||
|
||||
return permissions;
|
||||
}
|
||||
|
||||
/**
|
||||
* Route protection component for claim-restricted screens.
|
||||
*/
|
||||
export const ClaimProtectedRoute: React.FC<{
|
||||
claim: RoleClaim;
|
||||
action?: ClaimAction;
|
||||
children: React.ReactNode;
|
||||
}> = ({ claim, action = 'view', children }) => {
|
||||
const { canAccess, canView } = useRbac();
|
||||
const allowed = canAccess(claim, action);
|
||||
|
||||
if (!allowed) {
|
||||
let fallbackPath = '/dashboard';
|
||||
if (canView('dashboard')) fallbackPath = '/dashboard';
|
||||
else if (canView('compliance')) fallbackPath = '/compliance';
|
||||
else if (canView('projects_milestones')) fallbackPath = '/projects';
|
||||
else if (canView('grant_financials')) fallbackPath = '/grants';
|
||||
else if (canView('evidence_risks')) fallbackPath = '/monitoring';
|
||||
else if (canView('governance')) fallbackPath = '/governance';
|
||||
else if (canView('beneficiaries')) fallbackPath = '/projects/beneficiaries';
|
||||
else if (canView('settings_users') || canView('settings_invites')) fallbackPath = '/settings';
|
||||
|
||||
return React.createElement(Navigate, { to: fallbackPath, replace: true });
|
||||
}
|
||||
|
||||
return React.createElement(React.Fragment, null, children);
|
||||
};
|
||||
|
||||
/**
|
||||
* Route protection for Tenant Management (Solution Owners [Tenant 1 + Admin] OR Tenant Self-Service users).
|
||||
*/
|
||||
export const TenantManagementProtectedRoute: React.FC<{
|
||||
children: React.ReactNode;
|
||||
}> = ({ children }) => {
|
||||
const { canManageTenants, isLoading } = useAuth();
|
||||
const { canView, canAccess } = useRbac();
|
||||
|
||||
if (isLoading) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const hasAccess =
|
||||
canManageTenants ||
|
||||
canView('tenant_management') ||
|
||||
canView('tenant_self_service') ||
|
||||
canAccess('tenant_self_service');
|
||||
|
||||
if (!hasAccess) {
|
||||
return React.createElement(Navigate, { to: '/dashboard', replace: true });
|
||||
}
|
||||
|
||||
return React.createElement(React.Fragment, null, children);
|
||||
};
|
||||
@@ -1,5 +1,4 @@
|
||||
import React, { useState, useEffect } from 'react';
|
||||
import { createPortal } from 'react-dom';
|
||||
import { useNavigate } from 'react-router-dom';
|
||||
import {
|
||||
ShieldAlert,
|
||||
@@ -90,15 +89,14 @@ export const AccountActivationModal: React.FC<AccountActivationModalProps> = ({
|
||||
);
|
||||
}
|
||||
// If result.success is true, AuthProvider updates state, and this modal unmounts automatically!
|
||||
} catch (err: unknown) {
|
||||
const errObj = err as { response?: { status?: number }; message?: string };
|
||||
if (errObj?.response?.status === 401) {
|
||||
} catch (err: any) {
|
||||
if (err?.response?.status === 401) {
|
||||
await onLogout();
|
||||
navigate('/', { replace: true });
|
||||
return;
|
||||
}
|
||||
setErrorMessage(
|
||||
errObj?.message ||
|
||||
err?.message ||
|
||||
'Account activation check encountered an error. Please confirm with your administrator and try again.'
|
||||
);
|
||||
} finally {
|
||||
@@ -115,94 +113,141 @@ export const AccountActivationModal: React.FC<AccountActivationModalProps> = ({
|
||||
navigate('/');
|
||||
};
|
||||
|
||||
const isTenantIssue = !!errorMessage?.toLowerCase().includes('tenant');
|
||||
|
||||
const modalContent = (
|
||||
return (
|
||||
<div
|
||||
role="dialog"
|
||||
aria-modal="true"
|
||||
aria-labelledby="activation-modal-title"
|
||||
className="fixed inset-0 z-[9999] flex items-center justify-center p-4 bg-[#060a12]/85 backdrop-blur-md animate-in fade-in duration-200 select-none overflow-y-auto"
|
||||
className="fixed inset-0 z-50 flex items-center justify-center p-4 sm:p-6 bg-[#060a12]/90 backdrop-blur-md animate-in fade-in duration-200 select-none"
|
||||
>
|
||||
{/* Non-dismissable Card Container: No X button, no escape dismiss, no backdrop click */}
|
||||
<div
|
||||
className="relative w-full max-w-lg bg-[#0d1322] border border-slate-800 rounded-2xl shadow-2xl shadow-black/80 overflow-hidden flex flex-col text-slate-100 my-auto"
|
||||
className="relative w-full max-w-xl bg-[#0d1322] border border-amber-500/30 rounded-2xl shadow-2xl shadow-black/80 overflow-hidden flex flex-col text-slate-100"
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
>
|
||||
{/* Subtle accent top border */}
|
||||
<div className={`h-1 w-full ${isTenantIssue ? 'bg-amber-500' : 'bg-blue-600'}`} />
|
||||
{/* Amber accent top bar */}
|
||||
<div className="h-1.5 w-full bg-gradient-to-r from-amber-500 via-amber-400 to-yellow-500" />
|
||||
|
||||
{/* Header */}
|
||||
<div className="p-5 pb-4 border-b border-slate-800/80 bg-[#0f172a]/60">
|
||||
{/* Modal Header */}
|
||||
<div className="p-6 pb-4 border-b border-slate-800/80 bg-[#0f172a]/60">
|
||||
<div className="flex items-start justify-between gap-4">
|
||||
<div className="flex items-center gap-3">
|
||||
<div className={`p-2.5 rounded-xl border ${isTenantIssue ? 'bg-amber-500/10 border-amber-500/20 text-amber-400' : 'bg-blue-500/10 border-blue-500/20 text-blue-400'}`}>
|
||||
<ShieldAlert className="w-5 h-5" />
|
||||
<div className="flex items-center gap-3.5">
|
||||
<div className="relative p-2.5 rounded-xl bg-amber-500/10 border border-amber-500/20 text-amber-400">
|
||||
<ShieldAlert className="w-6 h-6" />
|
||||
<span className="absolute -top-1 -right-1 flex h-2.5 w-2.5">
|
||||
<span className="animate-ping absolute inline-flex h-full w-full rounded-full bg-amber-400 opacity-75"></span>
|
||||
<span className="relative inline-flex rounded-full h-2.5 w-2.5 bg-amber-500"></span>
|
||||
</span>
|
||||
</div>
|
||||
<div>
|
||||
<h2 id="activation-modal-title" className="text-base font-bold text-white tracking-tight">
|
||||
{isTenantIssue ? 'Tenant Access Restricted' : 'Account Pending Activation'}
|
||||
<span className="inline-flex items-center gap-1.5 text-[11px] font-semibold tracking-wider uppercase text-amber-400 bg-amber-500/10 border border-amber-500/20 px-2 py-0.5 rounded-full mb-1">
|
||||
Access Restricted • Lockout Active
|
||||
</span>
|
||||
<h2 id="activation-modal-title" className="text-lg sm:text-xl font-bold text-white tracking-tight">
|
||||
{errorMessage?.toLowerCase().includes('tenant')
|
||||
? 'Tenant Lockout • Account Inactive'
|
||||
: 'Account Pending Administrator Activation'}
|
||||
</h2>
|
||||
<p className="text-xs text-slate-400 mt-0.5">
|
||||
{isTenantIssue
|
||||
? 'The assigned tenant partition is currently inactive or restricted.'
|
||||
: 'Your account is authenticated and awaiting administrator enablement.'}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<BrandLogo size={36} showGlow={false} animateOnHover={false} />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Modal Body */}
|
||||
<div className="p-6 space-y-5 overflow-y-auto max-h-[70vh]">
|
||||
{/* Status notification block */}
|
||||
<div className="bg-slate-900/80 border border-slate-800 rounded-xl p-4 space-y-2.5">
|
||||
<div className="flex items-center gap-2 text-emerald-400 text-xs font-semibold">
|
||||
<CheckCircle2 className="w-4 h-4 shrink-0" />
|
||||
<span>Identity Verified & Signed In Successfully</span>
|
||||
</div>
|
||||
<p className="text-xs text-slate-300 leading-relaxed">
|
||||
You are a registered user and have successfully authenticated via Enterprise Single Sign-On.
|
||||
However, your account has not yet been enabled in the system directory by a system administrator.
|
||||
</p>
|
||||
<p className="text-xs text-slate-400 leading-relaxed border-t border-slate-800/80 pt-2 font-medium">
|
||||
If your administrator informs you that your account has been activated, please click the{' '}
|
||||
<strong className="text-white">Sync Account</strong> button below to refresh your status and unlock the system.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* User Profile Card */}
|
||||
<div className="rounded-xl border border-slate-800 bg-[#090e1a]/60 p-4 space-y-3">
|
||||
<div className="text-[11px] font-semibold text-slate-400 uppercase tracking-wider flex items-center justify-between">
|
||||
<span>Authenticated User Profile</span>
|
||||
<span className="text-amber-400/90 text-[10px] font-mono bg-amber-500/10 px-2 py-0.5 rounded border border-amber-500/20">
|
||||
Lockout Active
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<BrandLogo size={32} showGlow={false} animateOnHover={false} />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Body */}
|
||||
<div className="p-5 space-y-4">
|
||||
{/* User Profile Summary */}
|
||||
<div className="rounded-xl border border-slate-800/80 bg-slate-900/60 p-3.5 space-y-2 text-xs">
|
||||
<div className="grid grid-cols-2 gap-2.5">
|
||||
<div>
|
||||
<span className="text-[10px] uppercase font-semibold text-slate-500 block">User</span>
|
||||
<div className="grid grid-cols-1 sm:grid-cols-2 gap-3 text-xs">
|
||||
<div className="flex items-center gap-2.5 bg-slate-900/60 p-2.5 rounded-lg border border-slate-800/60">
|
||||
<UserCheck className="w-4 h-4 text-blue-400 shrink-0" />
|
||||
<div className="min-w-0">
|
||||
<span className="text-[10px] text-slate-500 block">Name</span>
|
||||
<span className="font-semibold text-white truncate block">{displayName}</span>
|
||||
</div>
|
||||
<div>
|
||||
<span className="text-[10px] uppercase font-semibold text-slate-500 block">Email</span>
|
||||
<span className="text-slate-300 truncate block">{displayEmail || '—'}</span>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-2.5 bg-slate-900/60 p-2.5 rounded-lg border border-slate-800/60">
|
||||
<Mail className="w-4 h-4 text-blue-400 shrink-0" />
|
||||
<div className="min-w-0">
|
||||
<span className="text-[10px] text-slate-500 block">Email</span>
|
||||
<span className="font-semibold text-slate-200 truncate block">{displayEmail || 'Not specified'}</span>
|
||||
</div>
|
||||
</div>
|
||||
<div className="grid grid-cols-2 gap-2.5 border-t border-slate-800/60 pt-2">
|
||||
<div>
|
||||
<span className="text-[10px] uppercase font-semibold text-slate-500 block">Tenant</span>
|
||||
<span className="text-slate-300 truncate block">{tenant}</span>
|
||||
|
||||
<div className="flex items-center gap-2.5 bg-slate-900/60 p-2.5 rounded-lg border border-slate-800/60">
|
||||
<Building2 className="w-4 h-4 text-indigo-400 shrink-0" />
|
||||
<div className="min-w-0">
|
||||
<span className="text-[10px] text-slate-500 block">Tenant / Role</span>
|
||||
<span className="font-semibold text-slate-200 truncate block">
|
||||
{tenant} ({role})
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-2.5 bg-slate-900/60 p-2.5 rounded-lg border border-slate-800/60">
|
||||
<Fingerprint className="w-4 h-4 text-slate-400 shrink-0" />
|
||||
<div className="min-w-0">
|
||||
<span className="text-[10px] text-slate-500 block">Subject ID</span>
|
||||
<span className="font-mono text-[11px] text-slate-400 truncate block">{sub || 'SSO-Principal'}</span>
|
||||
</div>
|
||||
<div>
|
||||
<span className="text-[10px] uppercase font-semibold text-slate-500 block">Role</span>
|
||||
<span className="text-blue-400 font-medium truncate block">{role}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Feedback notice if sync failed */}
|
||||
{/* Sync Attempt Feedback / Error Notice */}
|
||||
{errorMessage && (
|
||||
<div className="rounded-xl border border-amber-500/25 bg-amber-500/10 p-3 flex items-start gap-2.5 text-xs text-amber-200">
|
||||
<AlertTriangle className="w-4 h-4 text-amber-400 shrink-0 mt-0.5" />
|
||||
<div className="min-w-0">
|
||||
<span className="font-semibold text-amber-300 block">Notice:</span>
|
||||
<p className="text-[11px] text-amber-200/90 leading-relaxed mt-0.5">{errorMessage}</p>
|
||||
<div className="rounded-xl border border-rose-500/30 bg-rose-500/10 p-3.5 flex items-start gap-3 animate-in fade-in duration-150">
|
||||
<AlertTriangle className="w-4 h-4 text-rose-400 shrink-0 mt-0.5" />
|
||||
<div className="space-y-1">
|
||||
<p className="text-xs font-semibold text-rose-300">
|
||||
{errorMessage?.toLowerCase().includes('tenant')
|
||||
? 'Tenant Lockout In Effect'
|
||||
: 'Account Not Activated Yet'}
|
||||
</p>
|
||||
<p className="text-xs text-rose-200/90 leading-relaxed">{errorMessage}</p>
|
||||
{syncAttemptCount > 0 && (
|
||||
<p className="text-[11px] text-rose-400/80 pt-1">
|
||||
Attempt #{syncAttemptCount} checked at {new Date().toLocaleTimeString()}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<p className="text-[11px] text-slate-400 text-center">
|
||||
Once your administrator enables your account, click <strong className="text-white">Sync Account</strong> to enter the console.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* Footer Actions */}
|
||||
<div className="p-4 border-t border-slate-800 bg-[#0a0f1d] flex flex-col sm:flex-row items-center justify-between gap-2.5">
|
||||
{/* Modal Actions */}
|
||||
<div className="p-5 border-t border-slate-800/80 bg-[#0a0f1d] flex flex-col sm:flex-row items-center justify-between gap-3">
|
||||
<div className="flex items-center gap-2 w-full sm:w-auto">
|
||||
<button
|
||||
onClick={handleNavigateLanding}
|
||||
type="button"
|
||||
className="flex-1 sm:flex-none inline-flex items-center justify-center gap-1.5 px-3 py-2 text-xs font-medium text-slate-400 hover:text-white bg-slate-900 hover:bg-slate-800 border border-slate-800 rounded-xl transition cursor-pointer"
|
||||
className="flex-1 sm:flex-none inline-flex items-center justify-center gap-1.5 px-3.5 py-2 text-xs font-medium text-slate-400 hover:text-slate-200 bg-slate-900 hover:bg-slate-800 border border-slate-800 rounded-xl transition cursor-pointer"
|
||||
title="View public landing page. Entering the console again will display this modal until enabled."
|
||||
>
|
||||
<ArrowLeft className="w-3.5 h-3.5" />
|
||||
<span>Landing Page</span>
|
||||
@@ -211,7 +256,8 @@ export const AccountActivationModal: React.FC<AccountActivationModalProps> = ({
|
||||
<button
|
||||
onClick={handleSignOut}
|
||||
type="button"
|
||||
className="flex-1 sm:flex-none inline-flex items-center justify-center gap-1.5 px-3 py-2 text-xs font-medium text-slate-400 hover:text-rose-400 bg-slate-900 hover:bg-rose-500/10 border border-slate-800 hover:border-rose-500/20 rounded-xl transition cursor-pointer"
|
||||
className="flex-1 sm:flex-none inline-flex items-center justify-center gap-1.5 px-3.5 py-2 text-xs font-medium text-slate-400 hover:text-rose-400 bg-slate-900 hover:bg-rose-500/10 border border-slate-800 hover:border-rose-500/30 rounded-xl transition cursor-pointer"
|
||||
title="Sign out of current SSO session"
|
||||
>
|
||||
<LogOut className="w-3.5 h-3.5" />
|
||||
<span>Sign Out</span>
|
||||
@@ -223,17 +269,13 @@ export const AccountActivationModal: React.FC<AccountActivationModalProps> = ({
|
||||
disabled={isSyncing}
|
||||
type="button"
|
||||
id="activation-sync-btn"
|
||||
className="w-full sm:w-auto inline-flex items-center justify-center gap-2 px-4 py-2 text-xs font-semibold text-white bg-blue-600 hover:bg-blue-500 active:bg-blue-700 disabled:opacity-50 rounded-xl shadow-md transition cursor-pointer"
|
||||
className="w-full sm:w-auto inline-flex items-center justify-center gap-2 px-5 py-2.5 text-xs font-semibold text-white bg-blue-600 hover:bg-blue-500 active:bg-blue-700 disabled:opacity-50 disabled:pointer-events-none rounded-xl shadow-lg shadow-blue-600/30 transition cursor-pointer"
|
||||
>
|
||||
<RefreshCw className={`w-3.5 h-3.5 ${isSyncing ? 'animate-spin' : ''}`} />
|
||||
<span>{isSyncing ? 'Checking...' : 'Sync Account'}</span>
|
||||
<RefreshCw className={`w-4 h-4 ${isSyncing ? 'animate-spin' : ''}`} />
|
||||
<span>{isSyncing ? 'Verifying Activation...' : 'Sync Account'}</span>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
||||
return typeof document !== 'undefined'
|
||||
? createPortal(modalContent, document.body)
|
||||
: modalContent;
|
||||
};
|
||||
|
||||
@@ -1,5 +1,4 @@
|
||||
import React, { useEffect } from 'react';
|
||||
import { createPortal } from 'react-dom';
|
||||
import { useNavigate } from 'react-router-dom';
|
||||
import {
|
||||
ShieldAlert,
|
||||
@@ -55,68 +54,121 @@ export const PartnerAccessDeniedModal: React.FC<PartnerAccessDeniedModalProps> =
|
||||
navigate('/', { replace: true });
|
||||
};
|
||||
|
||||
const modalContent = (
|
||||
return (
|
||||
<div
|
||||
role="alertdialog"
|
||||
aria-modal="true"
|
||||
aria-labelledby="partner-denied-title"
|
||||
aria-describedby="partner-denied-description"
|
||||
className="fixed inset-0 z-[9999] flex items-center justify-center p-4 bg-black/80 backdrop-blur-md animate-in fade-in duration-150 overflow-y-auto"
|
||||
className="fixed inset-0 z-50 flex items-center justify-center p-4 bg-black/85 backdrop-blur-md animate-in fade-in duration-200"
|
||||
>
|
||||
<div className="relative w-full max-w-md overflow-hidden rounded-2xl bg-[#0d1322] border border-slate-800 shadow-2xl my-auto text-slate-100">
|
||||
<div className="h-1 w-full bg-rose-600" />
|
||||
<div className="relative w-full max-w-lg overflow-hidden rounded-2xl bg-[#0e1424] border border-rose-900/60 shadow-2xl shadow-rose-950/50">
|
||||
{/* Glow Header Accent */}
|
||||
<div className="h-2 w-full bg-linear-to-r from-rose-600 via-red-500 to-amber-500" />
|
||||
|
||||
<div className="p-5 space-y-4">
|
||||
<div className="flex items-start gap-3">
|
||||
<div className="flex h-10 w-10 shrink-0 items-center justify-center rounded-xl bg-rose-500/10 border border-rose-500/20 text-rose-400">
|
||||
<Ban className="h-5 w-5" />
|
||||
<div className="p-6 sm:p-8 space-y-6">
|
||||
{/* Header */}
|
||||
<div className="flex items-start gap-4">
|
||||
<div className="flex h-12 w-12 shrink-0 items-center justify-center rounded-2xl bg-rose-500/15 border border-rose-500/30 text-rose-400">
|
||||
<Ban className="h-6 w-6 text-rose-400" />
|
||||
</div>
|
||||
<div>
|
||||
<h2 id="partner-denied-title" className="text-base font-bold text-white tracking-tight">
|
||||
Partner Portal Required
|
||||
<div className="space-y-1">
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="inline-flex items-center gap-1 text-[11px] font-semibold text-rose-400 bg-rose-950/60 px-2 py-0.5 rounded-md border border-rose-800/60">
|
||||
<Lock className="w-3 h-3" />
|
||||
Restricted Role
|
||||
</span>
|
||||
<span className="text-xs text-slate-500">Security Policy</span>
|
||||
</div>
|
||||
<h2 id="partner-denied-title" className="text-lg font-bold text-white tracking-tight">
|
||||
Access Denied: Partner Role
|
||||
</h2>
|
||||
<p id="partner-denied-description" className="text-xs text-slate-400 mt-0.5">
|
||||
Accounts with the Partner role must access the Partner Portal.
|
||||
<p id="partner-denied-description" className="text-xs text-slate-400 leading-relaxed">
|
||||
Users assigned the <strong className="text-rose-300">Partner</strong> role by security are not permitted to access the PFM Backoffice Console.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="rounded-xl bg-slate-900/60 border border-slate-800 p-3.5 space-y-2 text-xs">
|
||||
<div className="flex items-center justify-between">
|
||||
<span className="text-slate-500 text-[11px] uppercase font-semibold">User</span>
|
||||
<span className="text-white font-medium">{displayName}</span>
|
||||
{/* User & Identity Summary Card */}
|
||||
<div className="rounded-xl bg-slate-900/80 border border-slate-800 p-4 space-y-3 text-xs">
|
||||
<div className="flex items-center justify-between pb-2 border-b border-slate-800/80">
|
||||
<span className="text-slate-400 font-medium">Account Name</span>
|
||||
<span className="text-white font-semibold">{displayName}</span>
|
||||
</div>
|
||||
|
||||
{displayEmail && (
|
||||
<div className="flex items-center justify-between border-t border-slate-800/60 pt-2">
|
||||
<span className="text-slate-500 text-[11px] uppercase font-semibold">Email</span>
|
||||
<span className="text-slate-300 font-mono text-[11px]">{displayEmail}</span>
|
||||
<div className="flex items-center justify-between pb-2 border-b border-slate-800/80">
|
||||
<span className="text-slate-400 font-medium flex items-center gap-1.5">
|
||||
<Mail className="w-3.5 h-3.5 text-slate-500" />
|
||||
Email
|
||||
</span>
|
||||
<span className="text-slate-200 font-mono">{displayEmail}</span>
|
||||
</div>
|
||||
)}
|
||||
<div className="flex items-center justify-between border-t border-slate-800/60 pt-2">
|
||||
<span className="text-slate-500 text-[11px] uppercase font-semibold">Role</span>
|
||||
<span className="text-rose-400 font-medium">{role}</span>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center justify-between pb-2 border-b border-slate-800/80">
|
||||
<span className="text-slate-400 font-medium flex items-center gap-1.5">
|
||||
<Building2 className="w-3.5 h-3.5 text-slate-500" />
|
||||
Assigned Role
|
||||
</span>
|
||||
<span className="text-rose-400 font-semibold bg-rose-950/60 px-2 py-0.5 rounded border border-rose-800/50">
|
||||
{role}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<p className="text-[11px] text-slate-400 text-center leading-relaxed">
|
||||
The Console is reserved for statutory administrators and programme managers.
|
||||
{tenant && (
|
||||
<div className="flex items-center justify-between pb-2 border-b border-slate-800/80">
|
||||
<span className="text-slate-400 font-medium flex items-center gap-1.5">
|
||||
<Building2 className="w-3.5 h-3.5 text-slate-500" />
|
||||
Tenant Context
|
||||
</span>
|
||||
<span className="text-slate-200 font-medium truncate max-w-[220px]">{tenant}</span>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{sub && (
|
||||
<div className="flex items-center justify-between text-[11px] text-slate-500">
|
||||
<span className="flex items-center gap-1">
|
||||
<Fingerprint className="w-3 h-3" />
|
||||
Subject Key
|
||||
</span>
|
||||
<span className="font-mono text-slate-400 truncate max-w-[200px]">{sub}</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Security Policy Information */}
|
||||
<div className="p-4 rounded-xl bg-rose-950/20 border border-rose-900/40 text-xs text-rose-200 leading-relaxed space-y-1.5">
|
||||
<p className="font-semibold text-rose-300 flex items-center gap-1.5">
|
||||
<ShieldAlert className="w-4 h-4 text-rose-400" />
|
||||
Administrative Boundary Notice
|
||||
</p>
|
||||
<p className="text-[11px] text-rose-300/90">
|
||||
The PFM Console is reserved for statutory administrators, sponsors, programme managers, and governance officers. Partner credentials cannot access this environment. If you require backoffice administrative access, please request an updated role assignment from your system administrator.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="pt-1">
|
||||
{/* Actions */}
|
||||
<div className="pt-2">
|
||||
<button
|
||||
onClick={handleLogoutClick}
|
||||
className="w-full flex items-center justify-center gap-2 py-2.5 px-4 rounded-xl bg-blue-600 hover:bg-blue-500 text-white text-xs font-semibold shadow-md transition cursor-pointer"
|
||||
className="w-full flex items-center justify-center gap-2 py-2.5 px-4 rounded-xl bg-slate-800 hover:bg-slate-700 text-slate-200 hover:text-white text-xs font-semibold border border-slate-700 transition cursor-pointer shadow-sm"
|
||||
>
|
||||
<LogOut className="w-4 h-4" />
|
||||
<span>Sign Out & Return Home</span>
|
||||
<LogOut className="w-4 h-4 text-rose-400" />
|
||||
<span>Sign Out & Return to Home</span>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Footer Brand watermark */}
|
||||
<div className="px-6 py-3 bg-[#0a0f1d] border-t border-slate-800/80 flex items-center justify-between text-[11px] text-slate-500">
|
||||
<div className="flex items-center gap-2">
|
||||
<BrandLogo size={16} showGlow={false} animateOnHover={false} />
|
||||
<span>Post Fund Management (PFM)</span>
|
||||
</div>
|
||||
<span>National Security Gateway</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
||||
return typeof document !== 'undefined'
|
||||
? createPortal(modalContent, document.body)
|
||||
: modalContent;
|
||||
};
|
||||
|
||||
@@ -1,5 +1,4 @@
|
||||
import React, { useEffect, useState, useRef } from 'react';
|
||||
import { createPortal } from 'react-dom';
|
||||
import { ShieldAlert, Clock, ArrowRight, X, AlertTriangle, LogOut } from 'lucide-react';
|
||||
import { clearSessionAndRedirectToLanding, dismissSubordinateModals } from '../../services/sessionExpiration';
|
||||
|
||||
@@ -84,10 +83,10 @@ export const SessionExpiredModal: React.FC<SessionExpiredModalProps> = ({
|
||||
const secondsRemaining = Math.max(0, Math.ceil(timeLeftMs / 1000));
|
||||
const progressPercent = Math.max(0, Math.min(100, (timeLeftMs / totalDurationMs) * 100));
|
||||
|
||||
const modalContent = (
|
||||
return (
|
||||
<div
|
||||
id="session-expired-modal-backdrop"
|
||||
className="fixed inset-0 z-[9999] flex items-center justify-center bg-black/80 backdrop-blur-md p-4 animate-in fade-in duration-150 overflow-y-auto"
|
||||
className="fixed inset-0 z-[9999] flex items-center justify-center bg-black/85 backdrop-blur-md p-4 animate-in fade-in duration-200"
|
||||
onMouseEnter={() => setIsPaused(true)}
|
||||
onMouseLeave={() => setIsPaused(false)}
|
||||
role="dialog"
|
||||
@@ -96,20 +95,27 @@ export const SessionExpiredModal: React.FC<SessionExpiredModalProps> = ({
|
||||
>
|
||||
<div
|
||||
id="session-expired-modal-content"
|
||||
className="relative w-full max-w-md bg-[#0d1322] border border-slate-800 rounded-2xl shadow-2xl p-5 overflow-hidden text-slate-200 space-y-4 my-auto"
|
||||
className="relative w-full max-w-md bg-[#0c121e] border border-amber-500/40 rounded-2xl shadow-2xl shadow-black/90 p-6 overflow-hidden text-slate-200 space-y-5"
|
||||
>
|
||||
<div className="absolute top-0 left-0 right-0 h-1 bg-amber-500" />
|
||||
{/* Subtle decorative top accent bar */}
|
||||
<div className="absolute top-0 left-0 right-0 h-1 bg-gradient-to-r from-amber-500 via-orange-500 to-rose-500" />
|
||||
|
||||
{/* Top bar with close button */}
|
||||
<div className="flex items-start justify-between pt-1">
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="w-10 h-10 rounded-xl bg-amber-500/10 border border-amber-500/20 flex items-center justify-center text-amber-400 shrink-0">
|
||||
<ShieldAlert className="w-5 h-5" />
|
||||
<div className="w-12 h-12 rounded-2xl bg-amber-500/10 border border-amber-500/30 flex items-center justify-center text-amber-400 shrink-0 shadow-lg shadow-amber-500/10">
|
||||
<ShieldAlert className="w-6 h-6" />
|
||||
</div>
|
||||
<div>
|
||||
<div className="flex items-center gap-2">
|
||||
<h3 id="session-expired-title" className="text-base font-bold text-white tracking-tight">
|
||||
Session Expired
|
||||
</h3>
|
||||
<p className="text-xs text-slate-400">Authorization credentials expired</p>
|
||||
<span className="text-[10px] font-mono font-semibold px-2 py-0.5 rounded bg-rose-950/60 text-rose-300 border border-rose-800/60">
|
||||
HTTP 401
|
||||
</span>
|
||||
</div>
|
||||
<p className="text-xs text-slate-400 mt-0.5">Authorization token invalidated</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -118,53 +124,63 @@ export const SessionExpiredModal: React.FC<SessionExpiredModalProps> = ({
|
||||
type="button"
|
||||
onClick={handleAction}
|
||||
aria-label="Dismiss and return to landing page"
|
||||
className="p-1.5 text-slate-400 hover:text-white rounded-lg hover:bg-slate-800 transition-colors cursor-pointer"
|
||||
className="p-1.5 text-slate-400 hover:text-white rounded-lg hover:bg-slate-800/80 transition-colors"
|
||||
>
|
||||
<X className="w-4 h-4" />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div className="text-xs text-slate-300 leading-relaxed bg-slate-900/60 p-3.5 rounded-xl border border-slate-800/80 space-y-1.5">
|
||||
{/* Informative Explanation (non-technical and clear) */}
|
||||
<div className="space-y-2.5 text-xs text-slate-300 leading-relaxed bg-slate-900/60 p-4 rounded-xl border border-slate-800/80">
|
||||
<p>
|
||||
Your active session has ended to protect account security. Please sign in again to continue working.
|
||||
Your active working session has ended or your security credentials have expired. To protect your data and prevent unauthorized changes, your session has been closed.
|
||||
</p>
|
||||
{reason && (
|
||||
<div className="flex items-start gap-1.5 pt-1 text-[11px] text-amber-300/90 font-mono">
|
||||
<div className="flex items-start gap-2 pt-1 text-[11px] text-amber-300/90 font-mono">
|
||||
<AlertTriangle className="w-3.5 h-3.5 shrink-0 mt-0.5 text-amber-400" />
|
||||
<span className="break-words">{reason}</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="flex items-center justify-between text-xs text-slate-400">
|
||||
<span className="flex items-center gap-1.5">
|
||||
{/* Progress Bar Countdown */}
|
||||
<div className="space-y-2">
|
||||
<div className="flex items-center justify-between text-xs">
|
||||
<span className="text-slate-400 flex items-center gap-1.5">
|
||||
<Clock className="w-3.5 h-3.5 text-amber-400" />
|
||||
Redirecting in <strong className="text-white font-mono">{secondsRemaining}s</strong>
|
||||
Returning to landing page in{' '}
|
||||
<strong className="text-white font-mono">{secondsRemaining}s</strong>
|
||||
</span>
|
||||
{isPaused && (
|
||||
<span className="text-[10px] text-amber-400/80 px-1.5 py-0.5 rounded bg-amber-500/10 border border-amber-500/20">
|
||||
Paused
|
||||
<span className="text-[10px] text-amber-400/80 font-medium px-1.5 py-0.2 rounded bg-amber-950/40 border border-amber-900/60">
|
||||
Paused on hover
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="pt-1">
|
||||
{/* Progress bar line */}
|
||||
<div className="w-full h-2 bg-slate-800/90 rounded-full overflow-hidden border border-slate-700/60 p-0.5">
|
||||
<div
|
||||
className="h-full rounded-full bg-gradient-to-r from-amber-500 via-orange-500 to-rose-500 transition-all duration-75 ease-linear"
|
||||
style={{ width: `${progressPercent}%` }}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Actions */}
|
||||
<div className="pt-2 flex flex-col sm:flex-row items-center gap-2.5">
|
||||
<button
|
||||
id="session-expired-return-btn"
|
||||
type="button"
|
||||
onClick={handleAction}
|
||||
className="w-full py-2.5 px-4 text-xs font-semibold text-white bg-blue-600 hover:bg-blue-500 rounded-xl shadow-md transition-all flex items-center justify-center gap-2 cursor-pointer"
|
||||
className="w-full py-2.5 px-4 text-xs font-semibold text-white bg-gradient-to-r from-blue-600 to-indigo-600 hover:from-blue-500 hover:to-indigo-500 rounded-xl shadow-lg shadow-blue-500/20 transition-all flex items-center justify-center gap-2"
|
||||
>
|
||||
<LogOut className="w-4 h-4" />
|
||||
<span>Return to Landing Page</span>
|
||||
<ArrowRight className="w-3.5 h-3.5" />
|
||||
<span>Return to Landing Page Now</span>
|
||||
<ArrowRight className="w-3.5 h-3.5 ml-0.5" />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
||||
return typeof document !== 'undefined'
|
||||
? createPortal(modalContent, document.body)
|
||||
: modalContent;
|
||||
};
|
||||
|
||||
@@ -48,7 +48,7 @@ const instrumentSchema = z.object({
|
||||
name: z.string().min(3, 'Funding instrument title must be at least 3 characters'),
|
||||
code: z.string().optional(),
|
||||
description: z.string().min(5, 'Description is required (minimum 5 characters)'),
|
||||
status: z.coerce.number(),
|
||||
status: z.coerce.number().default(ActivityStatus.Active),
|
||||
});
|
||||
|
||||
type InstrumentFormValues = z.infer<typeof instrumentSchema>;
|
||||
@@ -169,8 +169,8 @@ export const FundingInstrumentsSection: React.FC<FundingInstrumentsSectionProps>
|
||||
setEditingInstrument(null);
|
||||
if (onCloseCreateModalExternal) onCloseCreateModalExternal();
|
||||
},
|
||||
onError: (err: unknown) => {
|
||||
toastError('Save Failed', err);
|
||||
onError: (err: any) => {
|
||||
toastError('Save Failed', err?.message || 'Failed to save funding instrument');
|
||||
},
|
||||
});
|
||||
|
||||
@@ -351,7 +351,6 @@ export const FundingInstrumentsSection: React.FC<FundingInstrumentsSectionProps>
|
||||
</button>
|
||||
|
||||
<ExportButton
|
||||
claim="governance"
|
||||
title="Funding Instruments Registry"
|
||||
subtitle="Authoritative funding instruments and financing directives attached to strategic programmes"
|
||||
filename={`pfm-funding-instruments-${new Date().toISOString().split('T')[0]}`}
|
||||
|
||||
@@ -46,8 +46,8 @@ const ruleSchema = z.object({
|
||||
programmeId: z.coerce.number().min(1, 'Please select an associated programme from the dropdown'),
|
||||
name: z.string().min(3, 'Rule name must be at least 3 characters'),
|
||||
description: z.string().min(5, 'Description is required (minimum 5 characters)'),
|
||||
version: z.coerce.number().min(1, 'Version must be at least 1'),
|
||||
status: z.coerce.number(),
|
||||
version: z.coerce.number().min(1, 'Version must be at least 1').default(1),
|
||||
status: z.coerce.number().default(ActivityStatus.Active),
|
||||
});
|
||||
|
||||
type RuleFormValues = z.infer<typeof ruleSchema>;
|
||||
@@ -166,8 +166,8 @@ export const RulesSection: React.FC<RulesSectionProps> = ({
|
||||
setEditingRule(null);
|
||||
if (onCloseCreateModalExternal) onCloseCreateModalExternal();
|
||||
},
|
||||
onError: (err: unknown) => {
|
||||
toastError('Save Failed', err);
|
||||
onError: (err: any) => {
|
||||
toastError('Save Failed', err?.message || 'Failed to save programme rule');
|
||||
},
|
||||
});
|
||||
|
||||
@@ -347,7 +347,6 @@ export const RulesSection: React.FC<RulesSectionProps> = ({
|
||||
</button>
|
||||
|
||||
<ExportButton
|
||||
claim="governance"
|
||||
title="Programme Rules Registry"
|
||||
subtitle="Statutory, regulatory, and eligibility rules governing strategic funding programmes"
|
||||
filename={`pfm-programme-rules-${new Date().toISOString().split('T')[0]}`}
|
||||
|
||||
@@ -1,8 +1,6 @@
|
||||
import React, { useState } from 'react';
|
||||
import { NavLink, Outlet, useNavigate, Link } from 'react-router-dom';
|
||||
import { NotificationBell } from './NotificationBell';
|
||||
import {
|
||||
Inbox,
|
||||
LayoutDashboard,
|
||||
Building2,
|
||||
FolderKanban,
|
||||
@@ -31,14 +29,12 @@ import { ErrorBoundary } from '../ui/ErrorBoundary';
|
||||
import { useQuery } from '@tanstack/react-query';
|
||||
import { getUsers, getTenant } from '../../services/api';
|
||||
import { getUserEmail, registerUserEmail } from '../../lib/formatters';
|
||||
import { useRbac, RoleClaim } from '../../auth/rbac';
|
||||
|
||||
interface SubNavItem {
|
||||
name: string;
|
||||
path: string;
|
||||
icon: React.ElementType;
|
||||
help?: string;
|
||||
claim?: RoleClaim;
|
||||
}
|
||||
|
||||
interface NavItem {
|
||||
@@ -46,7 +42,6 @@ interface NavItem {
|
||||
path: string;
|
||||
icon: React.ElementType;
|
||||
help: string;
|
||||
claim?: RoleClaim;
|
||||
subItems?: SubNavItem[];
|
||||
}
|
||||
|
||||
@@ -56,49 +51,36 @@ const navItems: NavItem[] = [
|
||||
path: '/dashboard',
|
||||
icon: LayoutDashboard,
|
||||
help: 'Strategic macro metrics, portfolio value, disbursed funding, ISO 31000 risk heatmap, and projects requiring immediate intervention.',
|
||||
claim: 'dashboard',
|
||||
},
|
||||
{
|
||||
name: 'Governance & Structure',
|
||||
path: '/governance',
|
||||
icon: Building2,
|
||||
help: 'Master records for legal entities, holding structures, investment portfolios, and thematic programmes funded under statutory mandates.',
|
||||
claim: 'governance',
|
||||
},
|
||||
{
|
||||
name: 'Projects & Milestones',
|
||||
path: '/projects',
|
||||
icon: FolderKanban,
|
||||
help: 'Project Work Breakdown Structure (WBS), contractual deliverables, scheduled milestones, evidence verification, and execution tracking.',
|
||||
claim: 'projects_milestones',
|
||||
subItems: [
|
||||
{
|
||||
name: 'Beneficiaries',
|
||||
path: '/projects/beneficiaries',
|
||||
icon: Users,
|
||||
help: 'Manage target beneficiaries, demographics, and statutory compliance items satisfying beneficiaries.',
|
||||
claim: 'beneficiaries',
|
||||
},
|
||||
{
|
||||
name: 'Change Management',
|
||||
path: '/projects/change-management',
|
||||
icon: GitPullRequest,
|
||||
help: 'Track project issues, contractual change requests, and resolve changes in-place.',
|
||||
claim: 'change_management',
|
||||
},
|
||||
{
|
||||
name: 'Site Visits',
|
||||
path: '/projects/site-visits',
|
||||
icon: MapPin,
|
||||
help: 'Log physical site visits, field observations, inspector findings, and project feedback.',
|
||||
claim: 'site_visits',
|
||||
},
|
||||
{
|
||||
name: 'Compliance',
|
||||
path: '/compliance',
|
||||
icon: ShieldCheck,
|
||||
help: 'Portfolio & programme compliance dashboard, statutory requirements, beneficiary outcome fulfillment, and risk monitoring.',
|
||||
claim: 'compliance',
|
||||
},
|
||||
],
|
||||
},
|
||||
@@ -107,27 +89,18 @@ const navItems: NavItem[] = [
|
||||
path: '/grants',
|
||||
icon: Coins,
|
||||
help: 'Financial management including statutory awards, multi-year budget line allocations, tranches, and scheduled payment disbursements.',
|
||||
claim: 'grant_financials',
|
||||
},
|
||||
{
|
||||
name: 'M&E, Evidence & Risks',
|
||||
path: '/monitoring',
|
||||
icon: ShieldAlert,
|
||||
help: 'Monitoring & Evaluation (M&E) framework, indicator tracking, ISO 31000 risk registers, mitigation plans, and compliance audits.',
|
||||
claim: 'evidence_risks',
|
||||
},
|
||||
{
|
||||
name: 'Tenant Management',
|
||||
path: '/tenants',
|
||||
icon: Database,
|
||||
help: 'Multi-tenant partition directory, organizational onboarding, status lifecycle controls, and system-wide isolation boundaries.',
|
||||
claim: 'tenant_management',
|
||||
},
|
||||
{
|
||||
name: 'Inbox',
|
||||
path: '/inbox',
|
||||
icon: Inbox,
|
||||
help: 'In-app notifications, communications, deliverable messages, drafts, and replies across active tenant users.',
|
||||
},
|
||||
{
|
||||
name: 'Settings & Communications',
|
||||
@@ -138,9 +111,7 @@ const navItems: NavItem[] = [
|
||||
];
|
||||
|
||||
export const AppLayout: React.FC = () => {
|
||||
const { user, claims, sub, tenantId, hashedTenantId, tenantName: authTenantName, canManageTenants, logout, roles } = useAuth();
|
||||
const userRole = user?.role || (Array.isArray(roles) && roles.length > 0 ? roles[0] : (claims?.role as string) || 'User');
|
||||
const { canView } = useRbac();
|
||||
const { user, claims, sub, tenantId, hashedTenantId, tenantName: authTenantName, canManageTenants, logout } = useAuth();
|
||||
const [sidebarOpen, setSidebarOpen] = useState(true);
|
||||
const [userMenuOpen, setUserMenuOpen] = useState(false);
|
||||
const navigate = useNavigate();
|
||||
@@ -149,37 +120,12 @@ export const AppLayout: React.FC = () => {
|
||||
await logout();
|
||||
};
|
||||
|
||||
// Filter navigation shelf strictly according to RBAC Role Claims
|
||||
const visibleNavItems = navItems
|
||||
.filter((item) => {
|
||||
// Only show Tenant Management if user's tenant_id == 1 and their role list has Admin in it
|
||||
const visibleNavItems = navItems.filter((item) => {
|
||||
if (item.path === '/tenants') {
|
||||
return canManageTenants || canView('tenant_management') || canView('tenant_self_service');
|
||||
}
|
||||
if (item.path === '/settings') {
|
||||
return (
|
||||
canView('settings_users') ||
|
||||
canView('settings_invites') ||
|
||||
canView('settings_notifications') ||
|
||||
canView('settings_auditlog') ||
|
||||
canView('tenant_self_service')
|
||||
);
|
||||
}
|
||||
if (item.claim) {
|
||||
return canView(item.claim);
|
||||
return canManageTenants;
|
||||
}
|
||||
return true;
|
||||
})
|
||||
.map((item) => {
|
||||
if (!item.subItems) return item;
|
||||
return {
|
||||
...item,
|
||||
subItems: item.subItems.filter((subItem) => {
|
||||
if (subItem.claim) {
|
||||
return canView(subItem.claim);
|
||||
}
|
||||
return true;
|
||||
}),
|
||||
};
|
||||
});
|
||||
|
||||
// Pre-populate user directory cache so all components and modals resolve user emails instantly
|
||||
@@ -409,11 +355,6 @@ export const AppLayout: React.FC = () => {
|
||||
<p className="text-[11px] text-slate-400 truncate font-mono" title={tokenUserEmail}>
|
||||
{tokenUserEmail || 'Authenticated User'}
|
||||
</p>
|
||||
<div className="mt-1 flex items-center gap-1.5">
|
||||
<span className="text-[10px] font-semibold text-blue-400 bg-blue-500/10 border border-blue-500/25 px-1.5 py-0.5 rounded truncate" title={`Assigned Role: ${userRole}`}>
|
||||
Role: {userRole}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
<button
|
||||
onClick={handleLogout}
|
||||
@@ -428,14 +369,13 @@ export const AppLayout: React.FC = () => {
|
||||
<div className="flex flex-col items-center gap-2">
|
||||
<div
|
||||
className="h-10 w-10 shrink-0 rounded-xl bg-blue-600/20 border border-blue-500/40 flex items-center justify-center font-bold text-xs text-blue-400 relative group cursor-pointer shadow-xs"
|
||||
title={`${tokenUserName} (${tokenUserEmail}) • Role: ${userRole}`}
|
||||
title={`${tokenUserName} (${tokenUserEmail})`}
|
||||
>
|
||||
{tokenUserName ? tokenUserName.slice(0, 2).toUpperCase() : 'AU'}
|
||||
<span className="absolute -bottom-0.5 -right-0.5 w-3 h-3 rounded-full bg-emerald-500 border-2 border-[#0d121f]" />
|
||||
<div className="absolute left-full ml-3 px-3 py-2 bg-[#0d1322] text-white text-xs font-medium rounded-xl shadow-2xl border border-slate-700 whitespace-nowrap z-50 pointer-events-none opacity-0 group-hover:opacity-100 transition-all duration-150">
|
||||
<div className="font-semibold">{tokenUserName}</div>
|
||||
<div className="text-[10px] text-slate-400 font-mono mt-0.5">{tokenUserEmail}</div>
|
||||
<div className="text-[10px] text-blue-400 font-semibold mt-1">Role: {userRole}</div>
|
||||
</div>
|
||||
</div>
|
||||
<button
|
||||
@@ -471,7 +411,7 @@ export const AppLayout: React.FC = () => {
|
||||
|
||||
<div className="hidden sm:flex items-center gap-2 text-xs font-medium text-slate-300">
|
||||
<span className="text-slate-400">Tenant:</span>
|
||||
{canManageTenants || canView('tenant_management') || canView('tenant_self_service') ? (
|
||||
{canManageTenants ? (
|
||||
<Link
|
||||
to="/tenants"
|
||||
className="bg-slate-900/80 hover:bg-slate-800/90 text-slate-200 hover:text-white px-2.5 py-1 rounded-lg border border-slate-800 hover:border-slate-700 flex items-center gap-1.5 font-medium shadow-xs max-w-[320px] truncate transition group cursor-pointer"
|
||||
@@ -533,17 +473,13 @@ export const AppLayout: React.FC = () => {
|
||||
|
||||
{/* User Profile and Controls */}
|
||||
<div className="relative flex items-center gap-3">
|
||||
{/* Notification Bell in close proximity to username */}
|
||||
<NotificationBell />
|
||||
<div className="text-right hidden sm:block">
|
||||
<div className="flex items-center justify-end gap-1.5">
|
||||
<p className="text-xs font-semibold text-white truncate max-w-[150px]" title={tokenUserName}>
|
||||
<p className="text-xs font-semibold text-white truncate max-w-[200px]" title={tokenUserName}>
|
||||
{tokenUserName}
|
||||
</p>
|
||||
<span className="text-[10px] font-semibold uppercase tracking-wider text-blue-400 bg-blue-500/10 border border-blue-500/25 px-1.5 py-0.5 rounded truncate" title={`Role: ${userRole}`}>
|
||||
{userRole}
|
||||
</span>
|
||||
</div>
|
||||
<p className="text-[11px] text-slate-400 font-mono truncate max-w-[200px]" title={tokenUserEmail}>
|
||||
{tokenUserEmail}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<button
|
||||
@@ -562,24 +498,10 @@ export const AppLayout: React.FC = () => {
|
||||
onMouseLeave={() => setUserMenuOpen(false)}
|
||||
>
|
||||
<div className="p-2.5 border-b border-slate-800">
|
||||
<div className="flex items-center justify-between gap-1.5">
|
||||
<p className="text-xs font-semibold text-white truncate">{tokenUserName}</p>
|
||||
<span className="text-[10px] font-semibold uppercase text-blue-400 bg-blue-500/10 border border-blue-500/25 px-1.5 py-0.5 rounded shrink-0">
|
||||
{userRole}
|
||||
</span>
|
||||
</div>
|
||||
<p className="text-[11px] text-slate-300 font-medium truncate mt-0.5">{tokenUserEmail || (sub ? getUserEmail(sub) : '')}</p>
|
||||
<p className="text-[10px] text-slate-400 mt-1">Role: <strong className="text-blue-300">{userRole}</strong></p>
|
||||
<p className="text-xs font-semibold text-white">{tokenUserName}</p>
|
||||
<p className="text-[11px] text-slate-300 font-medium truncate">{tokenUserEmail || (sub ? getUserEmail(sub) : '')}</p>
|
||||
</div>
|
||||
|
||||
<Link
|
||||
to="/inbox"
|
||||
onClick={() => setUserMenuOpen(false)}
|
||||
className="flex items-center gap-2 px-3 py-2 text-xs text-slate-300 hover:text-white hover:bg-slate-800/60 rounded-xl transition"
|
||||
>
|
||||
<Inbox className="w-3.5 h-3.5 text-blue-400" />
|
||||
<span>In-App Mailbox</span>
|
||||
</Link>
|
||||
<Link
|
||||
to="/"
|
||||
onClick={() => setUserMenuOpen(false)}
|
||||
@@ -603,21 +525,18 @@ export const AppLayout: React.FC = () => {
|
||||
</header>
|
||||
|
||||
{/* Main Content Area */}
|
||||
<main className="flex-1 overflow-y-auto min-w-0 p-4 sm:p-6 md:p-8 bg-[#090d16] relative text-slate-100">
|
||||
{/* Ambient Glows & Dot Grid - Isolated inside overflow-hidden to prevent phantom page scrollbars */}
|
||||
<div className="absolute inset-0 overflow-hidden pointer-events-none">
|
||||
<main className="flex-1 overflow-y-auto p-6 md:p-8 bg-[#090d16] relative text-slate-100">
|
||||
<div
|
||||
className="absolute inset-0 opacity-15"
|
||||
className="absolute inset-0 pointer-events-none opacity-15"
|
||||
style={{
|
||||
backgroundImage: 'radial-gradient(circle at 1px 1px, rgba(255,255,255,0.15) 1px, transparent 0)',
|
||||
backgroundSize: '32px 32px',
|
||||
}}
|
||||
/>
|
||||
<div className="absolute -top-24 -left-24 w-80 h-80 bg-blue-600/10 rounded-full blur-3xl" />
|
||||
<div className="absolute top-1/2 -right-24 w-80 h-80 bg-indigo-600/10 rounded-full blur-3xl" />
|
||||
</div>
|
||||
<div className="absolute -top-24 -left-24 w-80 h-80 bg-blue-600/10 rounded-full blur-3xl pointer-events-none" />
|
||||
<div className="absolute top-1/2 -right-24 w-80 h-80 bg-indigo-600/10 rounded-full blur-3xl pointer-events-none" />
|
||||
|
||||
<div className="relative z-10 min-w-0 w-full">
|
||||
<div className="relative z-10">
|
||||
<ErrorBoundary>
|
||||
<Outlet />
|
||||
</ErrorBoundary>
|
||||
|
||||
@@ -1,216 +0,0 @@
|
||||
import React, { useState, useRef, useEffect } from 'react';
|
||||
import { useNavigate } from 'react-router-dom';
|
||||
import {
|
||||
Bell,
|
||||
Mail,
|
||||
CheckCheck,
|
||||
ChevronRight,
|
||||
Inbox,
|
||||
Clock,
|
||||
Sparkles,
|
||||
} from 'lucide-react';
|
||||
import { useInAppNotifications } from '../../hooks/useInAppNotifications';
|
||||
import { formatDate } from '../../lib/formatters';
|
||||
import { stripMarkdown } from '../../lib/markdownUtils';
|
||||
|
||||
export const NotificationBell: React.FC = () => {
|
||||
const navigate = useNavigate();
|
||||
const { top5Unread, unreadCount, markAsRead, markAllAsRead } = useInAppNotifications();
|
||||
|
||||
const [isOpen, setIsOpen] = useState(false);
|
||||
const leaveTimerRef = useRef<number | null>(null);
|
||||
const containerRef = useRef<HTMLDivElement>(null);
|
||||
|
||||
// Clear timer on unmount
|
||||
useEffect(() => {
|
||||
return () => {
|
||||
if (leaveTimerRef.current) {
|
||||
window.clearTimeout(leaveTimerRef.current);
|
||||
}
|
||||
};
|
||||
}, []);
|
||||
|
||||
const handleMouseEnter = () => {
|
||||
if (leaveTimerRef.current) {
|
||||
window.clearTimeout(leaveTimerRef.current);
|
||||
leaveTimerRef.current = null;
|
||||
}
|
||||
setIsOpen(true);
|
||||
};
|
||||
|
||||
const handleMouseLeave = () => {
|
||||
leaveTimerRef.current = window.setTimeout(() => {
|
||||
setIsOpen(false);
|
||||
}, 250);
|
||||
};
|
||||
|
||||
const handleNotificationClick = async (id: number | string) => {
|
||||
await markAsRead(id);
|
||||
setIsOpen(false);
|
||||
navigate(`/inbox?id=${id}`);
|
||||
};
|
||||
|
||||
const handleViewAll = () => {
|
||||
setIsOpen(false);
|
||||
navigate('/inbox');
|
||||
};
|
||||
|
||||
// Format relative timestamp
|
||||
const getRelativeTime = (dateStr?: string) => {
|
||||
if (!dateStr) return '';
|
||||
try {
|
||||
const now = new Date();
|
||||
const date = new Date(dateStr);
|
||||
const diffMs = now.getTime() - date.getTime();
|
||||
const diffMins = Math.floor(diffMs / (1000 * 60));
|
||||
const diffHours = Math.floor(diffMs / (1000 * 60 * 60));
|
||||
const diffDays = Math.floor(diffMs / (1000 * 60 * 60 * 24));
|
||||
|
||||
if (diffMins < 1) return 'Just now';
|
||||
if (diffMins < 60) return `${diffMins}m ago`;
|
||||
if (diffHours < 24) return `${diffHours}h ago`;
|
||||
if (diffDays < 7) return `${diffDays}d ago`;
|
||||
return formatDate(dateStr);
|
||||
} catch {
|
||||
return '';
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div
|
||||
ref={containerRef}
|
||||
className="relative"
|
||||
onMouseEnter={handleMouseEnter}
|
||||
onMouseLeave={handleMouseLeave}
|
||||
>
|
||||
{/* Bell Button */}
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setIsOpen(prev => !prev)}
|
||||
id="header-notification-bell-btn"
|
||||
className={`relative h-9 w-9 rounded-full flex items-center justify-center transition cursor-pointer ${
|
||||
isOpen
|
||||
? 'bg-blue-600/30 text-white border border-blue-400/50 shadow-md shadow-blue-500/20'
|
||||
: 'bg-slate-900/80 hover:bg-slate-800 text-slate-300 hover:text-white border border-slate-700/80 hover:border-slate-600 shadow-xs'
|
||||
}`}
|
||||
title={`Notifications • ${unreadCount} unread`}
|
||||
aria-label="In-App Notifications"
|
||||
>
|
||||
<Bell className="w-4 h-4" />
|
||||
|
||||
{/* Unread Count Badge */}
|
||||
{unreadCount > 0 && (
|
||||
<span
|
||||
id="notification-unread-count-badge"
|
||||
className="absolute -top-1 -right-1 flex items-center justify-center min-w-[18px] h-[18px] px-1 rounded-full bg-rose-600 text-[10px] font-extrabold text-white shadow-lg ring-2 ring-[#0d121f] animate-pulse"
|
||||
>
|
||||
{unreadCount > 99 ? '99+' : unreadCount}
|
||||
</span>
|
||||
)}
|
||||
</button>
|
||||
|
||||
{/* Floating Hover Popover */}
|
||||
{isOpen && (
|
||||
<div
|
||||
id="notification-bell-dropdown"
|
||||
className="absolute right-0 top-full mt-2 w-80 sm:w-96 bg-[#0d1322] border border-slate-700/80 rounded-2xl shadow-2xl p-0 z-50 backdrop-blur-xl animate-in fade-in slide-in-from-top-2 overflow-hidden"
|
||||
>
|
||||
{/* Popover Header */}
|
||||
<div className="px-4 py-3 border-b border-slate-800 flex items-center justify-between bg-gradient-to-r from-blue-950/40 via-slate-900/40 to-slate-900/30">
|
||||
<div className="flex items-center gap-2">
|
||||
<Mail className="w-4 h-4 text-blue-400" />
|
||||
<h4 className="text-xs font-bold text-white tracking-tight">In-App Notifications</h4>
|
||||
{unreadCount > 0 && (
|
||||
<span className="text-[10px] font-semibold text-rose-300 bg-rose-500/20 border border-rose-500/30 px-1.5 py-0.2 rounded-full">
|
||||
{unreadCount} unread
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{unreadCount > 0 && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={markAllAsRead}
|
||||
className="flex items-center gap-1 text-[11px] text-blue-400 hover:text-blue-300 transition cursor-pointer"
|
||||
title="Mark all notifications as read"
|
||||
>
|
||||
<CheckCheck className="w-3.5 h-3.5" />
|
||||
<span>Mark read</span>
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Top 5 Notifications List */}
|
||||
<div className="max-h-80 overflow-y-auto divide-y divide-slate-800/60">
|
||||
{top5Unread.length === 0 ? (
|
||||
<div className="py-8 px-4 text-center">
|
||||
<div className="w-10 h-10 mx-auto rounded-full bg-emerald-500/10 border border-emerald-500/20 flex items-center justify-center text-emerald-400 mb-2">
|
||||
<Sparkles className="w-5 h-5" />
|
||||
</div>
|
||||
<p className="text-xs font-semibold text-slate-200">All caught up!</p>
|
||||
<p className="text-[11px] text-slate-400 mt-0.5">
|
||||
No unread in-app notifications at this time.
|
||||
</p>
|
||||
</div>
|
||||
) : (
|
||||
top5Unread.map(item => {
|
||||
const relativeTime = getRelativeTime(item.createdAt);
|
||||
return (
|
||||
<div
|
||||
key={item.id}
|
||||
onClick={() => handleNotificationClick(item.id)}
|
||||
className="p-3.5 hover:bg-slate-800/60 transition cursor-pointer group flex items-start gap-3 relative"
|
||||
>
|
||||
{/* Unread Accent Indicator */}
|
||||
<div className="w-2 h-2 rounded-full bg-blue-500 mt-1.5 shrink-0 shadow-sm shadow-blue-500" />
|
||||
|
||||
<div className="flex-1 min-w-0">
|
||||
<div className="flex items-center justify-between gap-1 mb-0.5">
|
||||
<p className="text-xs font-semibold text-white group-hover:text-blue-400 transition truncate">
|
||||
{item.subject || 'In-App Notification'}
|
||||
</p>
|
||||
{relativeTime && (
|
||||
<span className="text-[10px] text-slate-400 shrink-0 font-mono flex items-center gap-1">
|
||||
<Clock className="w-2.5 h-2.5" />
|
||||
{relativeTime}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<p className="text-[11px] text-slate-300 line-clamp-2 leading-relaxed">
|
||||
{stripMarkdown(item.message || 'No additional message content.')}
|
||||
</p>
|
||||
|
||||
{item.destination && (
|
||||
<p className="text-[10px] text-slate-400 font-mono mt-1 truncate">
|
||||
{item.destination}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<ChevronRight className="w-4 h-4 text-slate-400 group-hover:text-white group-hover:translate-x-0.5 transition shrink-0 mt-1" />
|
||||
</div>
|
||||
);
|
||||
})
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Popover Footer: Open Inbox */}
|
||||
<div className="p-2.5 border-t border-slate-800 bg-[#090d16]/90 flex items-center justify-between">
|
||||
<button
|
||||
type="button"
|
||||
onClick={handleViewAll}
|
||||
id="view-mailbox-btn"
|
||||
className="w-full flex items-center justify-center gap-1.5 py-1.5 px-3 rounded-xl bg-blue-600/20 hover:bg-blue-600/30 text-blue-300 hover:text-white border border-blue-500/30 text-xs font-semibold transition cursor-pointer shadow-xs"
|
||||
>
|
||||
<Inbox className="w-3.5 h-3.5" />
|
||||
<span>Go to Mailbox ({unreadCount} unread)</span>
|
||||
<ChevronRight className="w-3 h-3 ml-0.5" />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -36,7 +36,7 @@ import {
|
||||
getAllProgrammes,
|
||||
getProjects,
|
||||
} from '../../services/api';
|
||||
import { toolsService } from '../../utils/hashids';
|
||||
import { toolsService } from '../../services/toolsService';
|
||||
import { Modal } from '../ui/Modal';
|
||||
import { useToast } from '../ui/Toast';
|
||||
import { ProblemDetailsAlert } from '../ui/ProblemDetailsAlert';
|
||||
@@ -44,7 +44,6 @@ import { HelpTooltip } from '../ui/HelpTooltip';
|
||||
import { ExportButton } from '../ui/ExportButton';
|
||||
import { ExportColumn } from '../../utils/exportUtils';
|
||||
import { useTableSort, SortableTh } from '../../utils/tableSort';
|
||||
import { useRbac } from '../../auth/rbac';
|
||||
import {
|
||||
Contract,
|
||||
Award,
|
||||
@@ -74,11 +73,10 @@ export const ContractsManagement: React.FC<ContractsManagementProps> = ({
|
||||
}) => {
|
||||
const queryClient = useQueryClient();
|
||||
const { success, error: toastError, info } = useToast();
|
||||
const { canAdd, canApprove, canApproveRecord, isMaker } = useRbac();
|
||||
|
||||
// Component state
|
||||
const [internalCreateModalOpen, setInternalCreateModalOpen] = useState(false);
|
||||
const isCreateModalOpen = Boolean(isCreateModalOpenExternal) || internalCreateModalOpen;
|
||||
const isCreateModalOpen = isCreateModalOpenExternal ?? internalCreateModalOpen;
|
||||
const setCreateModalOpen = (open: boolean) => {
|
||||
if (onCloseCreateModalExternal && !open) {
|
||||
onCloseCreateModalExternal();
|
||||
@@ -247,7 +245,7 @@ export const ContractsManagement: React.FC<ContractsManagementProps> = ({
|
||||
|
||||
// Target contractId for upload
|
||||
const contractIdToUploadAgainst =
|
||||
createdContract?.id ?? selectedAwardId;
|
||||
createdContract?.id ?? (createdContract as any)?.contractId ?? selectedAwardId;
|
||||
|
||||
// Step 2: Upload PDF document to POST /v1/api/grants/contracts/{contractId}
|
||||
if (contractPdfFile && contractIdToUploadAgainst) {
|
||||
@@ -277,9 +275,6 @@ export const ContractsManagement: React.FC<ContractsManagementProps> = ({
|
||||
mutationFn: async () => {
|
||||
setUploadApiError(null);
|
||||
if (!contractForFileUpload) throw new Error('No contract selected for upload.');
|
||||
if (Number(contractForFileUpload.status) === ContractStatus.Active) {
|
||||
throw new Error('Active contracts are legally executed and immutable. Documents cannot be modified.');
|
||||
}
|
||||
if (!selectedUploadFile) throw new Error('Please select a PDF document to upload.');
|
||||
|
||||
await uploadContractFile(contractForFileUpload.id, selectedUploadFile);
|
||||
@@ -420,21 +415,21 @@ export const ContractsManagement: React.FC<ContractsManagementProps> = ({
|
||||
sortDirection: contractSortDir,
|
||||
handleSort: handleContractSort,
|
||||
} = useTableSort<Contract>(filteredContracts, null, null, {
|
||||
id: (c: Contract) => c.id,
|
||||
awardId: (c: Contract) => c.totalValue || c.awardId,
|
||||
programmeId: (c: Contract) => {
|
||||
id: (c: any) => c.id,
|
||||
awardId: (c: any) => c.totalValue || c.awardId,
|
||||
programmeId: (c: any) => {
|
||||
const a = getAwardForContract(c);
|
||||
const pr = getProgrammeForAward(a);
|
||||
return pr?.name || '';
|
||||
},
|
||||
organisationId: (c: Contract) => {
|
||||
organisationId: (c: any) => {
|
||||
const a = getAwardForContract(c);
|
||||
const o = getOrganisationForAward(a);
|
||||
return o?.name || '';
|
||||
},
|
||||
signedDate: (c: Contract) => c.effectiveAt || c.createdAt,
|
||||
status: (c: Contract) => getContractStatusBadge(c.status).label,
|
||||
fileUri: (c: Contract) => c.signedDocumentUrl || '',
|
||||
signedDate: (c: any) => c.signedDate,
|
||||
status: (c: any) => getContractStatusBadge(c.status).label,
|
||||
fileUri: (c: any) => c.fileUri || '',
|
||||
});
|
||||
|
||||
return (
|
||||
@@ -607,7 +602,6 @@ export const ContractsManagement: React.FC<ContractsManagementProps> = ({
|
||||
</button>
|
||||
|
||||
<ExportButton
|
||||
claim="grant_financials"
|
||||
title="Contract Registry & Agreements"
|
||||
subtitle="Authoritative repository of bilateral legal agreements and contract files"
|
||||
filename={`pfm-contracts-${new Date().toISOString().split('T')[0]}`}
|
||||
@@ -615,7 +609,6 @@ export const ContractsManagement: React.FC<ContractsManagementProps> = ({
|
||||
data={filteredContracts}
|
||||
/>
|
||||
|
||||
{canAdd('grant_financials') && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => {
|
||||
@@ -627,7 +620,6 @@ export const ContractsManagement: React.FC<ContractsManagementProps> = ({
|
||||
<Plus className="w-4 h-4" />
|
||||
Create Contract
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -655,7 +647,7 @@ export const ContractsManagement: React.FC<ContractsManagementProps> = ({
|
||||
<p className="text-slate-500 max-w-sm">
|
||||
{searchTerm || statusFilter !== 'all'
|
||||
? 'No contracts match the current search filters. Try resetting the filters.'
|
||||
: 'No contracts have been created yet. Click "Create Contract" to link an approved award.'}
|
||||
: 'No contracts have been created yet. Click "Create Contract & Upload PDF" to link an approved award.'}
|
||||
</p>
|
||||
{!(searchTerm || statusFilter !== 'all') && (
|
||||
<button
|
||||
@@ -826,7 +818,6 @@ export const ContractsManagement: React.FC<ContractsManagementProps> = ({
|
||||
<span>Secure PDF</span>
|
||||
</button>
|
||||
|
||||
{Number(contract.status) !== ContractStatus.Active && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => {
|
||||
@@ -840,7 +831,6 @@ export const ContractsManagement: React.FC<ContractsManagementProps> = ({
|
||||
<Upload className="w-3 h-3" />
|
||||
<span>Replace</span>
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
<div className="text-[9px] text-slate-500 mt-1 flex items-center gap-1">
|
||||
<Lock className="w-2.5 h-2.5 text-slate-500" />
|
||||
@@ -850,15 +840,6 @@ export const ContractsManagement: React.FC<ContractsManagementProps> = ({
|
||||
|
||||
{/* Actions */}
|
||||
<td className="py-3.5 px-4 text-right">
|
||||
{Number(contract.status) === ContractStatus.Active ? (
|
||||
<span
|
||||
className="inline-flex items-center gap-1 text-[11px] font-semibold text-emerald-400 bg-emerald-500/10 px-2.5 py-1 rounded-lg border border-emerald-500/20 cursor-default"
|
||||
title="Active contracts are executed and immutable."
|
||||
>
|
||||
<Lock className="w-3 h-3 text-emerald-400" />
|
||||
<span>Active (Locked)</span>
|
||||
</span>
|
||||
) : (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => {
|
||||
@@ -871,7 +852,6 @@ export const ContractsManagement: React.FC<ContractsManagementProps> = ({
|
||||
<Edit2 className="w-3 h-3" />
|
||||
<span>Status</span>
|
||||
</button>
|
||||
)}
|
||||
</td>
|
||||
</tr>
|
||||
);
|
||||
@@ -898,7 +878,7 @@ export const ContractsManagement: React.FC<ContractsManagementProps> = ({
|
||||
className="space-y-4"
|
||||
>
|
||||
{/* ProblemDetails Error Alert */}
|
||||
{Boolean(createApiError) && (
|
||||
{createApiError && (
|
||||
<ProblemDetailsAlert
|
||||
error={createApiError}
|
||||
onDismiss={() => setCreateApiError(null)}
|
||||
@@ -1126,7 +1106,7 @@ export const ContractsManagement: React.FC<ContractsManagementProps> = ({
|
||||
}}
|
||||
className="space-y-4"
|
||||
>
|
||||
{Boolean(uploadApiError) && (
|
||||
{uploadApiError && (
|
||||
<ProblemDetailsAlert
|
||||
error={uploadApiError}
|
||||
onDismiss={() => setUploadApiError(null)}
|
||||
@@ -1228,7 +1208,7 @@ export const ContractsManagement: React.FC<ContractsManagementProps> = ({
|
||||
}}
|
||||
className="space-y-4"
|
||||
>
|
||||
{Boolean(updateApiError) && (
|
||||
{updateApiError && (
|
||||
<ProblemDetailsAlert
|
||||
error={updateApiError}
|
||||
onDismiss={() => setUpdateApiError(null)}
|
||||
@@ -1244,41 +1224,17 @@ export const ContractsManagement: React.FC<ContractsManagementProps> = ({
|
||||
<label className="block text-xs font-medium text-slate-200 mb-1">
|
||||
Contract Lifecycle Status <span className="text-rose-400">*</span>
|
||||
</label>
|
||||
{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>
|
||||
</div>
|
||||
) : (
|
||||
(() => {
|
||||
const canApproveContract = canApproveRecord('grant_financials', contractToUpdate?.createdBy);
|
||||
const isSelf = isMaker(contractToUpdate?.createdBy);
|
||||
|
||||
return (
|
||||
<select
|
||||
value={newStatus}
|
||||
onChange={(e) => setNewStatus(Number(e.target.value))}
|
||||
className="w-full bg-[#151b2c] border border-slate-700 rounded-xl px-3 py-2 text-xs text-white focus:outline-none focus:border-blue-500 font-medium"
|
||||
className="w-full bg-[#151b2c] border border-slate-700 rounded-xl px-3 py-2 text-xs text-white focus:outline-none focus:border-blue-500"
|
||||
>
|
||||
{CONTRACT_STATUS_OPTIONS.map((opt) => {
|
||||
const isActiveOpt = opt.value === ContractStatus.Active;
|
||||
const isOptDisabled = isActiveOpt && !canApproveContract;
|
||||
let reasonText = '';
|
||||
if (isActiveOpt && !canApprove('grant_financials')) {
|
||||
reasonText = ' (Requires Update Permission)';
|
||||
} else if (isActiveOpt && isSelf) {
|
||||
reasonText = ' (Maker-Checker: Cannot approve own record)';
|
||||
}
|
||||
return (
|
||||
<option key={opt.value} value={opt.value} disabled={isOptDisabled}>
|
||||
{opt.label}{reasonText}
|
||||
{CONTRACT_STATUS_OPTIONS.map((opt) => (
|
||||
<option key={opt.value} value={opt.value}>
|
||||
{opt.label}
|
||||
</option>
|
||||
);
|
||||
})}
|
||||
))}
|
||||
</select>
|
||||
);
|
||||
})()
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="flex items-center justify-end gap-2 pt-3 border-t border-slate-800">
|
||||
@@ -1291,7 +1247,7 @@ export const ContractsManagement: React.FC<ContractsManagementProps> = ({
|
||||
</button>
|
||||
<button
|
||||
type="submit"
|
||||
disabled={updateStatusMutation.isPending || Number(contractToUpdate?.status) === ContractStatus.Active}
|
||||
disabled={updateStatusMutation.isPending}
|
||||
className="px-4 py-2 text-xs font-semibold text-white bg-blue-600 hover:bg-blue-500 disabled:opacity-50 rounded-xl shadow-lg shadow-blue-600/30 transition flex items-center gap-1.5 cursor-pointer"
|
||||
>
|
||||
{updateStatusMutation.isPending ? (
|
||||
|
||||
@@ -24,7 +24,6 @@ import {
|
||||
FileSpreadsheet,
|
||||
FileCheck,
|
||||
RefreshCw,
|
||||
Lock,
|
||||
} from 'lucide-react';
|
||||
import {
|
||||
getAllEvidence,
|
||||
@@ -58,7 +57,6 @@ import { useToast } from '../ui/Toast';
|
||||
import { HelpTooltip } from '../ui/HelpTooltip';
|
||||
import { ExportButton } from '../ui/ExportButton';
|
||||
import { ExportColumn } from '../../utils/exportUtils';
|
||||
import { useRbac } from '../../auth/rbac';
|
||||
|
||||
export interface EvidenceReviewQueueProps {
|
||||
initialProjectId?: string | number | 'all';
|
||||
@@ -88,7 +86,6 @@ export const EvidenceReviewQueue: React.FC<EvidenceReviewQueueProps> = ({
|
||||
}) => {
|
||||
const queryClient = useQueryClient();
|
||||
const { success, error: toastError } = useToast();
|
||||
const { canAdd, canApprove, canExport, canApproveRecord, isMaker } = useRbac();
|
||||
|
||||
// Filter States: default to initialProjectId if given, otherwise when projects load default to first project
|
||||
const [selectedProjectId, setSelectedProjectId] = useState<string | number | 'all'>(initialProjectId);
|
||||
@@ -363,8 +360,8 @@ export const EvidenceReviewQueue: React.FC<EvidenceReviewQueueProps> = ({
|
||||
}
|
||||
|
||||
success('Download Initiated', `Opening official file: ${ev.fileName || `evidence_${ev.id}`}`);
|
||||
} catch (err: unknown) {
|
||||
toastError('Download Failed', err);
|
||||
} catch (err: any) {
|
||||
toastError('Download Failed', err?.message || 'Unable to retrieve file link');
|
||||
} finally {
|
||||
setIsDownloading(null);
|
||||
}
|
||||
@@ -457,7 +454,6 @@ export const EvidenceReviewQueue: React.FC<EvidenceReviewQueueProps> = ({
|
||||
</button>
|
||||
|
||||
<ExportButton
|
||||
claim="evidence_risks"
|
||||
title="Deliverable Evidence & Verification Ledger"
|
||||
subtitle="Audited records of submitted milestone deliverables and verification decisions"
|
||||
filename={`pfm-evidence-review-${new Date().toISOString().split('T')[0]}`}
|
||||
@@ -465,7 +461,6 @@ export const EvidenceReviewQueue: React.FC<EvidenceReviewQueueProps> = ({
|
||||
data={filteredEvidence}
|
||||
/>
|
||||
|
||||
{canAdd('evidence_risks') && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setIsManualIngestModalOpen(true)}
|
||||
@@ -475,7 +470,6 @@ export const EvidenceReviewQueue: React.FC<EvidenceReviewQueueProps> = ({
|
||||
<Upload className="w-3.5 h-3.5" />
|
||||
<span>Ingest Evidence</span>
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -801,17 +795,6 @@ export const EvidenceReviewQueue: React.FC<EvidenceReviewQueueProps> = ({
|
||||
<span>Inspect</span>
|
||||
</button>
|
||||
|
||||
{ev.status === ApprovalStatus.Approved ? (
|
||||
<span
|
||||
className="inline-flex items-center gap-1.5 text-xs font-semibold px-3 py-1.5 rounded-xl border bg-emerald-500/15 text-emerald-300 border-emerald-500/30 cursor-default"
|
||||
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>
|
||||
) : (
|
||||
<>
|
||||
{canAdd('evidence_risks') && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() =>
|
||||
@@ -833,9 +816,7 @@ export const EvidenceReviewQueue: React.FC<EvidenceReviewQueueProps> = ({
|
||||
<XCircle className="w-3.5 h-3.5" />
|
||||
<span>{ev.status === ApprovalStatus.Rejected ? 'Rejected' : 'Reject'}</span>
|
||||
</button>
|
||||
)}
|
||||
|
||||
{canApproveRecord('evidence_risks', ev.createdBy) && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() =>
|
||||
@@ -847,20 +828,16 @@ export const EvidenceReviewQueue: React.FC<EvidenceReviewQueueProps> = ({
|
||||
})
|
||||
}
|
||||
disabled={reviewMutation.isPending && String(reviewMutation.variables?.id) === String(ev.id)}
|
||||
className="inline-flex items-center gap-1 text-xs font-semibold px-2.5 py-1.5 rounded-xl border transition cursor-pointer text-emerald-400 hover:text-white bg-emerald-500/10 hover:bg-emerald-600 border border-emerald-500/25"
|
||||
className={`inline-flex items-center gap-1 text-xs font-semibold px-2.5 py-1.5 rounded-xl border transition cursor-pointer ${
|
||||
ev.status === ApprovalStatus.Approved
|
||||
? 'bg-emerald-500/20 text-emerald-300 border-emerald-500/30 opacity-75'
|
||||
: 'text-emerald-400 hover:text-white bg-emerald-500/10 hover:bg-emerald-600 border border-emerald-500/25'
|
||||
}`}
|
||||
title="Approve evidence submission"
|
||||
>
|
||||
<CheckCircle2 className="w-3.5 h-3.5" />
|
||||
<span>Approve</span>
|
||||
<span>{ev.status === ApprovalStatus.Approved ? 'Approved' : 'Approve'}</span>
|
||||
</button>
|
||||
)}
|
||||
{canApprove('evidence_risks') && isMaker(ev.createdBy) && (
|
||||
<span className="text-[10px] text-amber-400/90 italic font-mono px-1.5 py-0.5 bg-amber-500/10 border border-amber-500/20 rounded-md">
|
||||
Maker-Checker (Creator)
|
||||
</span>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
|
||||
<button
|
||||
type="button"
|
||||
@@ -1012,7 +989,6 @@ export const EvidenceReviewQueue: React.FC<EvidenceReviewQueueProps> = ({
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-2.5 shrink-0">
|
||||
{canAdd('evidence_risks') && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() =>
|
||||
@@ -1034,9 +1010,7 @@ export const EvidenceReviewQueue: React.FC<EvidenceReviewQueueProps> = ({
|
||||
<XCircle className="w-3.5 h-3.5" />
|
||||
<span>{reviewMutation.isPending && reviewMutation.variables?.status === ApprovalStatus.Rejected ? 'Rejecting...' : 'Reject Evidence'}</span>
|
||||
</button>
|
||||
)}
|
||||
|
||||
{canApproveRecord('evidence_risks', inspectEvidence.createdBy) && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() =>
|
||||
@@ -1058,13 +1032,6 @@ export const EvidenceReviewQueue: React.FC<EvidenceReviewQueueProps> = ({
|
||||
<CheckCircle2 className="w-3.5 h-3.5" />
|
||||
<span>{reviewMutation.isPending && reviewMutation.variables?.status === ApprovalStatus.Approved ? 'Approving...' : 'Approve Evidence'}</span>
|
||||
</button>
|
||||
)}
|
||||
{canApprove('evidence_risks') && isMaker(inspectEvidence.createdBy) && (
|
||||
<span className="text-xs text-amber-400 font-medium px-3 py-1.5 bg-amber-500/10 border border-amber-500/20 rounded-xl flex items-center gap-1.5">
|
||||
<AlertTriangle className="w-3.5 h-3.5 text-amber-400 shrink-0" />
|
||||
<span>Maker-Checker Policy: As the creator of this submission, you cannot approve it.</span>
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -1,711 +0,0 @@
|
||||
import React, { useState, useEffect, useRef } from 'react';
|
||||
import {
|
||||
Send,
|
||||
Save,
|
||||
Trash2,
|
||||
X,
|
||||
Mail,
|
||||
AlertCircle,
|
||||
CheckCircle2,
|
||||
Bold,
|
||||
Italic,
|
||||
Underline,
|
||||
Heading2,
|
||||
List,
|
||||
ListOrdered,
|
||||
Quote,
|
||||
Table,
|
||||
Minus,
|
||||
Sparkles,
|
||||
Eye,
|
||||
Edit3,
|
||||
Smartphone,
|
||||
ShieldCheck,
|
||||
} from 'lucide-react';
|
||||
import { UserSearchPicker, SelectedUser } from './UserSearchPicker';
|
||||
import { useInAppNotifications, DraftMessage } from '../../hooks/useInAppNotifications';
|
||||
import { NotificationPlatform } from '../../types';
|
||||
import {
|
||||
renderMarkdownToHtml,
|
||||
parseMarkdownToHtml,
|
||||
isHtmlContent,
|
||||
stripMarkdown,
|
||||
PFM_LOGO_DATA_URI,
|
||||
} from '../../lib/markdownUtils';
|
||||
import { BrandLogo } from '../ui/BrandLogo';
|
||||
import { useAuth } from '../../auth/AuthProvider';
|
||||
|
||||
interface ComposeMessageModalProps {
|
||||
isOpen: boolean;
|
||||
onClose: () => void;
|
||||
initialRecipient?: SelectedUser;
|
||||
initialSubject?: string;
|
||||
initialMessage?: string;
|
||||
initialPlatform?: NotificationPlatform;
|
||||
draftToEdit?: DraftMessage | null;
|
||||
}
|
||||
|
||||
export const ComposeMessageModal: React.FC<ComposeMessageModalProps> = ({
|
||||
isOpen,
|
||||
onClose,
|
||||
initialRecipient,
|
||||
initialSubject = '',
|
||||
initialMessage = '',
|
||||
initialPlatform = NotificationPlatform.InApp,
|
||||
draftToEdit,
|
||||
}) => {
|
||||
const { user, claims } = useAuth();
|
||||
const currentUserId = String(user?.id || claims?.sub || '').trim().toLowerCase();
|
||||
const currentUserEmail = String(user?.email || claims?.email || '').trim().toLowerCase();
|
||||
const tenantName =
|
||||
(claims?.tenant as string) ||
|
||||
user?.tenant ||
|
||||
'Department of Science, Innovation & Technology';
|
||||
const { sendMessage, isSending, saveDraft, deleteDraft } = useInAppNotifications();
|
||||
|
||||
const [selectedUsers, setSelectedUsers] = useState<SelectedUser[]>([]);
|
||||
const [subject, setSubject] = useState('');
|
||||
const [message, setMessage] = useState('');
|
||||
const [platform, setPlatform] = useState<NotificationPlatform>(initialPlatform);
|
||||
const [activeTab, setActiveTab] = useState<'write' | 'preview'>('write');
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [successNotice, setSuccessNotice] = useState<string | null>(null);
|
||||
|
||||
const editorRef = useRef<HTMLDivElement>(null);
|
||||
|
||||
const isCurrentUser = (candidate?: { id?: string; email?: string }) => {
|
||||
if (!candidate) return false;
|
||||
const cid = String(candidate.id || '').trim().toLowerCase();
|
||||
const cemail = String(candidate.email || '').trim().toLowerCase();
|
||||
if (currentUserId && cid === currentUserId) return true;
|
||||
if (currentUserEmail && cemail === currentUserEmail) return true;
|
||||
return false;
|
||||
};
|
||||
|
||||
const toInitialRichHtml = (raw: string): string => {
|
||||
if (!raw || !raw.trim()) return '';
|
||||
if (isHtmlContent(raw)) return raw;
|
||||
return parseMarkdownToHtml(raw);
|
||||
};
|
||||
|
||||
// Initialize or populate from draft or props
|
||||
useEffect(() => {
|
||||
if (!isOpen) return;
|
||||
|
||||
let initialHtml = '';
|
||||
if (draftToEdit) {
|
||||
const candidate = draftToEdit.recipientId
|
||||
? {
|
||||
id: draftToEdit.recipientId,
|
||||
email: draftToEdit.recipientEmail,
|
||||
name: draftToEdit.recipientName,
|
||||
}
|
||||
: undefined;
|
||||
setSelectedUsers(candidate && !isCurrentUser(candidate) ? [candidate] : []);
|
||||
setSubject(draftToEdit.subject || '');
|
||||
initialHtml = toInitialRichHtml(draftToEdit.message || '');
|
||||
setMessage(initialHtml);
|
||||
setPlatform(draftToEdit.platform || NotificationPlatform.InApp);
|
||||
} else {
|
||||
setSelectedUsers(
|
||||
initialRecipient && !isCurrentUser(initialRecipient) ? [initialRecipient] : []
|
||||
);
|
||||
setSubject(initialSubject || '');
|
||||
initialHtml = toInitialRichHtml(initialMessage || '');
|
||||
setMessage(initialHtml);
|
||||
setPlatform(initialPlatform);
|
||||
}
|
||||
setActiveTab('write');
|
||||
setError(null);
|
||||
setSuccessNotice(null);
|
||||
|
||||
setTimeout(() => {
|
||||
if (editorRef.current) {
|
||||
editorRef.current.innerHTML = initialHtml;
|
||||
}
|
||||
}, 0);
|
||||
}, [isOpen, initialRecipient, initialSubject, initialMessage, initialPlatform, draftToEdit]);
|
||||
|
||||
// Restore editor innerHTML when switching back from preview tab
|
||||
useEffect(() => {
|
||||
if (activeTab === 'write' && editorRef.current) {
|
||||
if (editorRef.current.innerHTML !== message) {
|
||||
editorRef.current.innerHTML = message;
|
||||
}
|
||||
}
|
||||
}, [activeTab]);
|
||||
|
||||
if (!isOpen) return null;
|
||||
|
||||
const handleSelectUser = (selected: SelectedUser) => {
|
||||
if (isCurrentUser(selected)) {
|
||||
setError('You cannot send a message to yourself.');
|
||||
return;
|
||||
}
|
||||
setSelectedUsers([selected]);
|
||||
setError(null);
|
||||
};
|
||||
|
||||
const handleRemoveUser = () => {
|
||||
setSelectedUsers([]);
|
||||
};
|
||||
|
||||
const syncEditorContent = () => {
|
||||
if (editorRef.current) {
|
||||
setMessage(editorRef.current.innerHTML);
|
||||
}
|
||||
};
|
||||
|
||||
const applyFormat = (command: string, value?: string) => {
|
||||
const editor = editorRef.current;
|
||||
if (!editor) return;
|
||||
editor.focus();
|
||||
document.execCommand(command, false, value);
|
||||
syncEditorContent();
|
||||
};
|
||||
|
||||
const insertHtmlBlock = (htmlSnippet: string) => {
|
||||
const editor = editorRef.current;
|
||||
if (!editor) return;
|
||||
editor.focus();
|
||||
const inserted = document.execCommand('insertHTML', false, htmlSnippet);
|
||||
if (!inserted) {
|
||||
editor.innerHTML += htmlSnippet;
|
||||
}
|
||||
syncEditorContent();
|
||||
};
|
||||
|
||||
// Quick insertions for PFM branding and templates
|
||||
const insertBrandedCallout = () => {
|
||||
insertHtmlBlock(
|
||||
`<div style="background:rgba(12,74,110,0.25);border-left:4px solid #38bdf8;padding:10px 14px;border-radius:8px;margin:10px 0;color:#bae6fd;font-size:12px;"><strong>Post-Fund Management Official Notice</strong><br/>Please verify all supporting evidence documentation before tranche disbursement sign-off.</div><p><br/></p>`
|
||||
);
|
||||
};
|
||||
|
||||
const insertMilestoneTable = () => {
|
||||
insertHtmlBlock(
|
||||
`<div style="overflow-x:auto;margin:12px 0;"><table style="width:100%;border-collapse:collapse;border:1px solid #334155;font-size:12px;text-align:left;"><thead><tr style="background:#1e293b;color:#f8fafc;"><th style="padding:8px 12px;border:1px solid #334155;">Deliverable Code</th><th style="padding:8px 12px;border:1px solid #334155;">Milestone Name</th><th style="padding:8px 12px;border:1px solid #334155;">Status</th><th style="padding:8px 12px;border:1px solid #334155;">Due Date</th></tr></thead><tbody><tr style="background:#0f172a;color:#cbd5e1;"><td style="padding:8px 12px;border:1px solid #334155;">DEL-01</td><td style="padding:8px 12px;border:1px solid #334155;">Technical Architecture Review</td><td style="padding:8px 12px;border:1px solid #334155;">Verified</td><td style="padding:8px 12px;border:1px solid #334155;">2026-11-15</td></tr><tr style="background:#0f172a;color:#cbd5e1;"><td style="padding:8px 12px;border:1px solid #334155;">DEL-02</td><td style="padding:8px 12px;border:1px solid #334155;">Financial Expenditure Proof</td><td style="padding:8px 12px;border:1px solid #334155;">Under Review</td><td style="padding:8px 12px;border:1px solid #334155;">2026-12-01</td></tr></tbody></table></div><p><br/></p>`
|
||||
);
|
||||
};
|
||||
|
||||
const insertPlatformLogo = () => {
|
||||
insertHtmlBlock(
|
||||
`<p><img src="${PFM_LOGO_DATA_URI}" alt="PFM Platform Logo" width="48" height="48" style="display:inline-block;border-radius:10px;margin:6px 0;" /></p><p><br/></p>`
|
||||
);
|
||||
};
|
||||
|
||||
const hasMeaningfulContent = (html: string) => {
|
||||
if (!html) return false;
|
||||
if (stripMarkdown(html).trim().length > 0) return true;
|
||||
return /<(img|table|hr)\b/i.test(html);
|
||||
};
|
||||
|
||||
const handleSaveDraft = () => {
|
||||
const currentHtml = editorRef.current ? editorRef.current.innerHTML : message;
|
||||
if (!subject.trim() && !hasMeaningfulContent(currentHtml)) {
|
||||
setError('Please add a subject or message to save as draft');
|
||||
return;
|
||||
}
|
||||
|
||||
const firstUser = selectedUsers[0];
|
||||
saveDraft({
|
||||
id: draftToEdit?.id,
|
||||
recipientId: firstUser?.id || '',
|
||||
recipientEmail: firstUser?.email || '',
|
||||
recipientName: firstUser?.name || '',
|
||||
subject: subject.trim() || 'Untitled Draft',
|
||||
message: currentHtml.trim(),
|
||||
platform,
|
||||
});
|
||||
|
||||
setSuccessNotice('Draft saved successfully in Drafts folder');
|
||||
setTimeout(() => {
|
||||
onClose();
|
||||
}, 600);
|
||||
};
|
||||
|
||||
const handleSend = async (e: React.FormEvent) => {
|
||||
e.preventDefault();
|
||||
|
||||
const currentHtml = editorRef.current ? editorRef.current.innerHTML : message;
|
||||
|
||||
if (selectedUsers.length === 0) {
|
||||
setError('Please select a recipient from the tenant directory');
|
||||
return;
|
||||
}
|
||||
|
||||
if (selectedUsers.length > 1) {
|
||||
setError('Only one recipient is allowed per message');
|
||||
return;
|
||||
}
|
||||
|
||||
if (isCurrentUser(selectedUsers[0])) {
|
||||
setError('You cannot send a message to yourself');
|
||||
return;
|
||||
}
|
||||
|
||||
if (!subject.trim()) {
|
||||
setError('Please enter a subject');
|
||||
return;
|
||||
}
|
||||
|
||||
if (!hasMeaningfulContent(currentHtml)) {
|
||||
setError('Please enter a message body');
|
||||
return;
|
||||
}
|
||||
|
||||
setError(null);
|
||||
try {
|
||||
const recipient = selectedUsers[0];
|
||||
|
||||
const finalMessage =
|
||||
platform === NotificationPlatform.Email
|
||||
? renderMarkdownToHtml(currentHtml.trim(), {
|
||||
applyBranding: true,
|
||||
emailFormat: true,
|
||||
subject: subject.trim(),
|
||||
tenantName,
|
||||
})
|
||||
: currentHtml.trim();
|
||||
|
||||
await sendMessage({
|
||||
recipientId: recipient.id,
|
||||
recipientEmail: recipient.email,
|
||||
recipientName: recipient.name,
|
||||
subject: subject.trim(),
|
||||
message: finalMessage,
|
||||
platform,
|
||||
draftId: draftToEdit?.id,
|
||||
});
|
||||
|
||||
setSuccessNotice(
|
||||
platform === NotificationPlatform.Email
|
||||
? 'Email notification dispatched!'
|
||||
: 'In-App message sent successfully!'
|
||||
);
|
||||
setTimeout(() => {
|
||||
onClose();
|
||||
}, 700);
|
||||
} catch (err: any) {
|
||||
setError(err?.message || 'Failed to dispatch notification');
|
||||
}
|
||||
};
|
||||
|
||||
const handleDiscard = () => {
|
||||
if (draftToEdit?.id) {
|
||||
deleteDraft(draftToEdit.id);
|
||||
}
|
||||
onClose();
|
||||
};
|
||||
|
||||
// Preview content based on chosen platform
|
||||
const livePreviewHtml =
|
||||
platform === NotificationPlatform.Email
|
||||
? renderMarkdownToHtml(message, {
|
||||
applyBranding: true,
|
||||
emailFormat: true,
|
||||
subject: subject || 'Notice Preview',
|
||||
tenantName,
|
||||
})
|
||||
: message;
|
||||
|
||||
const plainCharCount = stripMarkdown(message).length;
|
||||
|
||||
return (
|
||||
<div className="fixed inset-0 z-50 flex items-center justify-center p-4 bg-slate-950/85 backdrop-blur-md animate-in fade-in duration-150">
|
||||
<div className="w-full max-w-3xl bg-[#0d1322] border border-slate-700/80 rounded-3xl shadow-2xl overflow-hidden flex flex-col max-h-[92vh]">
|
||||
{/* Modal Header */}
|
||||
<div className="px-6 py-4 border-b border-slate-800 flex items-center justify-between bg-gradient-to-r from-blue-950/50 via-slate-900/60 to-slate-900/40">
|
||||
<div className="flex items-center gap-3">
|
||||
<BrandLogo size={28} showGlow animateOnHover={false} />
|
||||
<div>
|
||||
<div className="flex items-center gap-2">
|
||||
<h3 className="text-sm font-bold text-white tracking-tight">
|
||||
{draftToEdit
|
||||
? 'Edit Saved Draft'
|
||||
: initialSubject.startsWith('Re:')
|
||||
? 'Reply to Message'
|
||||
: 'Compose New Communication'}
|
||||
</h3>
|
||||
<span className="text-[10px] font-semibold text-blue-300 bg-blue-500/15 border border-blue-500/30 px-2 py-0.5 rounded-full">
|
||||
Rich Text Composer
|
||||
</span>
|
||||
</div>
|
||||
<p className="text-[11px] text-slate-400">
|
||||
Type and format your message using the toolbar for In-App or branded Email delivery
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
<button
|
||||
onClick={onClose}
|
||||
className="p-1.5 rounded-lg text-slate-400 hover:text-white hover:bg-slate-800 transition cursor-pointer"
|
||||
>
|
||||
<X className="w-4 h-4" />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Form Body */}
|
||||
<form onSubmit={handleSend} className="p-6 space-y-4 overflow-y-auto flex-1">
|
||||
{error && (
|
||||
<div className="p-3 rounded-xl bg-rose-500/10 border border-rose-500/25 text-rose-300 text-xs flex items-center gap-2">
|
||||
<AlertCircle className="w-4 h-4 shrink-0 text-rose-400" />
|
||||
<span>{error}</span>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{successNotice && (
|
||||
<div className="p-3 rounded-xl bg-emerald-500/10 border border-emerald-500/25 text-emerald-300 text-xs flex items-center gap-2">
|
||||
<CheckCircle2 className="w-4 h-4 shrink-0 text-emerald-400" />
|
||||
<span>{successNotice}</span>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Platform Selector (InApp vs Email) */}
|
||||
<div className="p-3 rounded-2xl bg-slate-900/60 border border-slate-800 flex flex-col sm:flex-row sm:items-center justify-between gap-3">
|
||||
<div>
|
||||
<label className="text-xs font-semibold text-slate-200 block">
|
||||
Dispatch Platform Mode
|
||||
</label>
|
||||
<p className="text-[11px] text-slate-400">
|
||||
Select target transmission protocol for this notification
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center rounded-xl bg-slate-950 p-1 border border-slate-700/80">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setPlatform(NotificationPlatform.InApp)}
|
||||
className={`flex items-center gap-2 px-3 py-1.5 rounded-lg text-xs font-semibold transition cursor-pointer ${
|
||||
platform === NotificationPlatform.InApp
|
||||
? 'bg-blue-600 text-white shadow-md shadow-blue-600/30'
|
||||
: 'text-slate-400 hover:text-slate-200'
|
||||
}`}
|
||||
>
|
||||
<Smartphone className="w-3.5 h-3.5" />
|
||||
<span>In-App</span>
|
||||
</button>
|
||||
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setPlatform(NotificationPlatform.Email)}
|
||||
className={`flex items-center gap-2 px-3 py-1.5 rounded-lg text-xs font-semibold transition cursor-pointer ${
|
||||
platform === NotificationPlatform.Email
|
||||
? 'bg-emerald-600 text-white shadow-md shadow-emerald-600/30'
|
||||
: 'text-slate-400 hover:text-slate-200'
|
||||
}`}
|
||||
>
|
||||
<Mail className="w-3.5 h-3.5" />
|
||||
<span>Email</span>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Platform Description Indicator */}
|
||||
<div className="px-1 text-[11px]">
|
||||
{platform === NotificationPlatform.InApp ? (
|
||||
<span className="text-blue-300 flex items-center gap-1.5">
|
||||
<span className="w-2 h-2 rounded-full bg-blue-400 animate-pulse" />
|
||||
Delivered instantly to the recipient's <strong>In-App</strong> Mailbox & Notification Bell.
|
||||
</span>
|
||||
) : (
|
||||
<span className="text-emerald-300 flex items-center gap-1.5">
|
||||
<span className="w-2 h-2 rounded-full bg-emerald-400 animate-pulse" />
|
||||
Formatted with <strong>PFM Executive Branding</strong> and embedded logo before dispatching via email.
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Recipient Picker */}
|
||||
<div>
|
||||
<label className="block text-xs font-semibold text-slate-300 mb-1.5">
|
||||
To (Recipient) <span className="text-rose-400">*</span>
|
||||
</label>
|
||||
<UserSearchPicker
|
||||
selectedUsers={selectedUsers}
|
||||
onSelectUser={handleSelectUser}
|
||||
onRemoveUser={handleRemoveUser}
|
||||
multiple={false}
|
||||
placeholder="Search tenant users by name, email, or functional role..."
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Subject Field */}
|
||||
<div>
|
||||
<label className="block text-xs font-semibold text-slate-300 mb-1.5">
|
||||
Subject Line <span className="text-rose-400">*</span>
|
||||
</label>
|
||||
<input
|
||||
type="text"
|
||||
value={subject}
|
||||
onChange={e => setSubject(e.target.value)}
|
||||
placeholder="e.g. Funding Award Contract Review & Milestone Verification Notice"
|
||||
className="w-full px-3.5 py-2.5 rounded-xl bg-slate-900/90 border border-slate-700/80 hover:border-slate-600 focus:border-blue-500 text-slate-100 placeholder-slate-400 text-xs focus:outline-none transition shadow-inner font-medium"
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Rich Text Editor / Branded Preview Tabs */}
|
||||
<div className="space-y-2">
|
||||
<div className="flex items-center justify-between">
|
||||
<label className="text-xs font-semibold text-slate-300">
|
||||
Message Content <span className="text-rose-400">*</span>
|
||||
</label>
|
||||
|
||||
{/* Compose vs Email Preview Tabs */}
|
||||
<div className="flex items-center rounded-lg bg-slate-900 p-0.5 border border-slate-700/80">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setActiveTab('write')}
|
||||
className={`flex items-center gap-1.5 px-3 py-1 rounded-md text-xs font-medium transition cursor-pointer ${
|
||||
activeTab === 'write'
|
||||
? 'bg-blue-600 text-white font-semibold shadow-xs'
|
||||
: 'text-slate-400 hover:text-white'
|
||||
}`}
|
||||
>
|
||||
<Edit3 className="w-3 h-3" />
|
||||
<span>Compose</span>
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => {
|
||||
syncEditorContent();
|
||||
setActiveTab('preview');
|
||||
}}
|
||||
className={`flex items-center gap-1.5 px-3 py-1 rounded-md text-xs font-medium transition cursor-pointer ${
|
||||
activeTab === 'preview'
|
||||
? 'bg-blue-600 text-white font-semibold shadow-xs'
|
||||
: 'text-slate-400 hover:text-white'
|
||||
}`}
|
||||
>
|
||||
<Eye className="w-3 h-3" />
|
||||
<span>Preview Delivery</span>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{activeTab === 'write' ? (
|
||||
<div className="rounded-2xl border border-slate-700/80 bg-slate-900/90 overflow-hidden shadow-inner focus-within:border-blue-500 transition">
|
||||
{/* WYSIWYG Formatting Toolbar */}
|
||||
<div className="px-3 py-2 border-b border-slate-800 bg-slate-950/70 flex flex-wrap items-center gap-1">
|
||||
<button
|
||||
type="button"
|
||||
onMouseDown={e => e.preventDefault()}
|
||||
onClick={() => applyFormat('bold')}
|
||||
className="p-1.5 rounded-lg text-slate-300 hover:text-white hover:bg-slate-800 transition cursor-pointer"
|
||||
title="Bold"
|
||||
>
|
||||
<Bold className="w-3.5 h-3.5" />
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onMouseDown={e => e.preventDefault()}
|
||||
onClick={() => applyFormat('italic')}
|
||||
className="p-1.5 rounded-lg text-slate-300 hover:text-white hover:bg-slate-800 transition cursor-pointer"
|
||||
title="Italic"
|
||||
>
|
||||
<Italic className="w-3.5 h-3.5" />
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onMouseDown={e => e.preventDefault()}
|
||||
onClick={() => applyFormat('underline')}
|
||||
className="p-1.5 rounded-lg text-slate-300 hover:text-white hover:bg-slate-800 transition cursor-pointer"
|
||||
title="Underline"
|
||||
>
|
||||
<Underline className="w-3.5 h-3.5" />
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onMouseDown={e => e.preventDefault()}
|
||||
onClick={() => applyFormat('formatBlock', 'H3')}
|
||||
className="p-1.5 rounded-lg text-slate-300 hover:text-white hover:bg-slate-800 transition cursor-pointer"
|
||||
title="Heading"
|
||||
>
|
||||
<Heading2 className="w-3.5 h-3.5" />
|
||||
</button>
|
||||
|
||||
<span className="w-px h-4 bg-slate-800 mx-1" />
|
||||
|
||||
<button
|
||||
type="button"
|
||||
onMouseDown={e => e.preventDefault()}
|
||||
onClick={() => applyFormat('insertUnorderedList')}
|
||||
className="p-1.5 rounded-lg text-slate-300 hover:text-white hover:bg-slate-800 transition cursor-pointer"
|
||||
title="Bullet List"
|
||||
>
|
||||
<List className="w-3.5 h-3.5" />
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onMouseDown={e => e.preventDefault()}
|
||||
onClick={() => applyFormat('insertOrderedList')}
|
||||
className="p-1.5 rounded-lg text-slate-300 hover:text-white hover:bg-slate-800 transition cursor-pointer"
|
||||
title="Numbered List"
|
||||
>
|
||||
<ListOrdered className="w-3.5 h-3.5" />
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onMouseDown={e => e.preventDefault()}
|
||||
onClick={() => applyFormat('formatBlock', 'BLOCKQUOTE')}
|
||||
className="p-1.5 rounded-lg text-slate-300 hover:text-white hover:bg-slate-800 transition cursor-pointer"
|
||||
title="Quote Block"
|
||||
>
|
||||
<Quote className="w-3.5 h-3.5" />
|
||||
</button>
|
||||
|
||||
<span className="w-px h-4 bg-slate-800 mx-1" />
|
||||
|
||||
<button
|
||||
type="button"
|
||||
onMouseDown={e => e.preventDefault()}
|
||||
onClick={insertMilestoneTable}
|
||||
className="p-1.5 rounded-lg text-slate-300 hover:text-white hover:bg-slate-800 transition cursor-pointer"
|
||||
title="Insert Deliverable Table"
|
||||
>
|
||||
<Table className="w-3.5 h-3.5" />
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onMouseDown={e => e.preventDefault()}
|
||||
onClick={() => applyFormat('insertHorizontalRule')}
|
||||
className="p-1.5 rounded-lg text-slate-300 hover:text-white hover:bg-slate-800 transition cursor-pointer"
|
||||
title="Insert Divider Line"
|
||||
>
|
||||
<Minus className="w-3.5 h-3.5" />
|
||||
</button>
|
||||
|
||||
<span className="w-px h-4 bg-slate-800 mx-1" />
|
||||
|
||||
{/* Platform Brand Insertions */}
|
||||
<button
|
||||
type="button"
|
||||
onMouseDown={e => e.preventDefault()}
|
||||
onClick={insertBrandedCallout}
|
||||
className="flex items-center gap-1 px-2 py-1 rounded-lg bg-blue-500/10 hover:bg-blue-500/20 text-blue-300 text-[11px] font-medium border border-blue-500/25 transition cursor-pointer"
|
||||
title="Insert official PFM callout block"
|
||||
>
|
||||
<Sparkles className="w-3 h-3 text-blue-400" />
|
||||
<span>PFM Notice</span>
|
||||
</button>
|
||||
|
||||
<button
|
||||
type="button"
|
||||
onMouseDown={e => e.preventDefault()}
|
||||
onClick={insertPlatformLogo}
|
||||
className="flex items-center gap-1 px-2 py-1 rounded-lg bg-emerald-500/10 hover:bg-emerald-500/20 text-emerald-300 text-[11px] font-medium border border-emerald-500/25 transition cursor-pointer"
|
||||
title="Embed official PFM logo"
|
||||
>
|
||||
<BrandLogo size={14} showGlow={false} animateOnHover={false} />
|
||||
<span>Embed Logo</span>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Rich Text WYSIWYG Surface */}
|
||||
<div className="relative">
|
||||
{!hasMeaningfulContent(message) && (
|
||||
<div className="pointer-events-none absolute top-4 left-4 right-4 text-slate-500 text-xs select-none">
|
||||
Type your message here and highlight text or use the toolbar above to format headings, bullet points, tables, and official notices...
|
||||
</div>
|
||||
)}
|
||||
<div
|
||||
ref={editorRef}
|
||||
contentEditable
|
||||
suppressContentEditableWarning
|
||||
onInput={syncEditorContent}
|
||||
onBlur={syncEditorContent}
|
||||
className="w-full min-h-[200px] max-h-[320px] overflow-y-auto p-4 bg-transparent text-slate-100 text-xs focus:outline-none leading-relaxed font-sans [&_h1]:text-lg [&_h1]:font-bold [&_h1]:text-white [&_h1]:my-2 [&_h2]:text-base [&_h2]:font-bold [&_h2]:text-white [&_h2]:my-2 [&_h3]:text-sm [&_h3]:font-bold [&_h3]:text-white [&_h3]:my-1.5 [&_ul]:list-disc [&_ul]:pl-5 [&_ul]:my-1.5 [&_ol]:list-decimal [&_ol]:pl-5 [&_ol]:my-1.5 [&_blockquote]:border-l-4 [&_blockquote]:border-blue-500 [&_blockquote]:pl-3 [&_blockquote]:italic [&_blockquote]:text-slate-300 [&_blockquote]:my-2 [&_hr]:border-slate-700 [&_hr]:my-3"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
/* Live Fully-Rendered Delivery Preview */
|
||||
<div className="rounded-2xl border border-slate-700/80 bg-slate-950/80 p-5 overflow-y-auto max-h-72 min-h-56">
|
||||
<div className="mb-3 pb-2 border-b border-slate-800 flex items-center justify-between text-[11px] text-slate-400">
|
||||
<span className="flex items-center gap-1.5 text-blue-300 font-semibold">
|
||||
<ShieldCheck className="w-3.5 h-3.5 text-blue-400" />
|
||||
Delivery Preview ({platform === NotificationPlatform.Email ? 'Email' : 'In-App'})
|
||||
</span>
|
||||
<span className="text-[10px] text-slate-400 font-mono">
|
||||
Subject: {subject || 'Untitled'}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
{hasMeaningfulContent(message) ? (
|
||||
<div
|
||||
className="prose prose-invert max-w-none text-xs leading-relaxed [&_h1]:text-white [&_h2]:text-white [&_h3]:text-white [&_ul]:list-disc [&_ul]:pl-5 [&_ol]:list-decimal [&_ol]:pl-5 [&_blockquote]:border-l-4 [&_blockquote]:border-blue-500 [&_blockquote]:pl-3 [&_blockquote]:italic"
|
||||
dangerouslySetInnerHTML={{ __html: livePreviewHtml }}
|
||||
/>
|
||||
) : (
|
||||
<div className="py-12 text-center text-slate-400 text-xs italic">
|
||||
Type your message in the Compose tab to preview how it will appear upon delivery.
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Footer Metadata */}
|
||||
<div className="flex items-center justify-between text-[11px] text-slate-400 px-1 pt-1">
|
||||
<span className="flex items-center gap-1">
|
||||
<span>Tenant Partition:</span>
|
||||
<strong className="text-slate-300">{tenantName}</strong>
|
||||
</span>
|
||||
<span className="font-mono">{plainCharCount} characters</span>
|
||||
</div>
|
||||
|
||||
{/* Modal Footer Action Buttons */}
|
||||
<div className="pt-4 border-t border-slate-800 flex items-center justify-between">
|
||||
<div className="flex items-center gap-2">
|
||||
<button
|
||||
type="button"
|
||||
onClick={handleDiscard}
|
||||
className="flex items-center gap-1.5 px-3 py-2 rounded-xl text-slate-400 hover:text-rose-400 hover:bg-rose-950/20 text-xs font-medium transition cursor-pointer"
|
||||
title="Discard draft"
|
||||
>
|
||||
<Trash2 className="w-3.5 h-3.5" />
|
||||
<span>Discard</span>
|
||||
</button>
|
||||
|
||||
<button
|
||||
type="button"
|
||||
onClick={handleSaveDraft}
|
||||
className="flex items-center gap-1.5 px-3.5 py-2 rounded-xl bg-slate-800 hover:bg-slate-700 text-slate-200 text-xs font-medium border border-slate-700 transition cursor-pointer"
|
||||
title="Save as Draft"
|
||||
>
|
||||
<Save className="w-3.5 h-3.5 text-blue-400" />
|
||||
<span>Save Draft</span>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-2">
|
||||
<button
|
||||
type="button"
|
||||
onClick={onClose}
|
||||
className="px-4 py-2 rounded-xl bg-slate-900 hover:bg-slate-800 text-slate-300 text-xs font-medium border border-slate-800 transition cursor-pointer"
|
||||
>
|
||||
Cancel
|
||||
</button>
|
||||
|
||||
<button
|
||||
type="submit"
|
||||
disabled={isSending}
|
||||
className="flex items-center gap-2 px-5 py-2 rounded-xl bg-blue-600 hover:bg-blue-500 text-white text-xs font-semibold shadow-lg shadow-blue-600/30 transition disabled:opacity-50 cursor-pointer"
|
||||
>
|
||||
{isSending ? (
|
||||
<div className="w-3.5 h-3.5 border-2 border-white border-t-transparent rounded-full animate-spin" />
|
||||
) : (
|
||||
<Send className="w-3.5 h-3.5" />
|
||||
)}
|
||||
<span>
|
||||
{platform === NotificationPlatform.Email
|
||||
? 'Dispatch Email'
|
||||
: 'Send In-App Message'}
|
||||
</span>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
@@ -1,100 +0,0 @@
|
||||
import React, { useState, useMemo } from 'react';
|
||||
import {
|
||||
Copy,
|
||||
Check,
|
||||
ShieldCheck,
|
||||
} from 'lucide-react';
|
||||
import { BrandLogo } from '../ui/BrandLogo';
|
||||
import { parseMarkdownToHtml, isHtmlContent, stripMarkdown } from '../../lib/markdownUtils';
|
||||
|
||||
interface MarkdownViewerProps {
|
||||
content: string;
|
||||
subject?: string;
|
||||
showBrandingHeader?: boolean;
|
||||
className?: string;
|
||||
}
|
||||
|
||||
export const MarkdownViewer: React.FC<MarkdownViewerProps> = ({
|
||||
content,
|
||||
showBrandingHeader = true,
|
||||
className = '',
|
||||
}) => {
|
||||
const [copied, setCopied] = useState(false);
|
||||
|
||||
const isHtml = useMemo(() => isHtmlContent(content), [content]);
|
||||
|
||||
const renderedHtml = useMemo(() => {
|
||||
if (!content) return '<p class="text-slate-400 italic">No content</p>';
|
||||
if (isHtml) return content;
|
||||
return parseMarkdownToHtml(content);
|
||||
}, [content, isHtml]);
|
||||
|
||||
const handleCopy = () => {
|
||||
navigator.clipboard.writeText(stripMarkdown(content) || content);
|
||||
setCopied(true);
|
||||
setTimeout(() => setCopied(false), 2000);
|
||||
};
|
||||
|
||||
return (
|
||||
<div className={`flex flex-col rounded-2xl bg-slate-900/40 border border-slate-800/80 overflow-hidden ${className}`}>
|
||||
{/* Top Controls Bar */}
|
||||
<div className="px-4 py-2 border-b border-slate-800 bg-[#0d1322]/80 flex items-center justify-between text-xs text-slate-400">
|
||||
<div className="flex items-center gap-2">
|
||||
{showBrandingHeader && (
|
||||
<div className="flex items-center gap-1.5 text-slate-300 font-semibold pr-2 border-r border-slate-700/60">
|
||||
<BrandLogo size={18} showGlow={false} animateOnHover={false} />
|
||||
<span className="text-[11px] tracking-tight">PFM Communications</span>
|
||||
</div>
|
||||
)}
|
||||
<span className="text-[10px] text-blue-400 font-mono">
|
||||
In-App Message
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-2">
|
||||
{/* Copy Button */}
|
||||
<button
|
||||
type="button"
|
||||
onClick={handleCopy}
|
||||
className="flex items-center gap-1 px-2 py-1 rounded-lg bg-slate-800 hover:bg-slate-700 text-slate-300 hover:text-white text-[11px] font-medium transition cursor-pointer border border-slate-700/60"
|
||||
title="Copy message text to clipboard"
|
||||
>
|
||||
{copied ? (
|
||||
<>
|
||||
<Check className="w-3 h-3 text-emerald-400" />
|
||||
<span className="text-emerald-300">Copied</span>
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<Copy className="w-3 h-3" />
|
||||
<span>Copy</span>
|
||||
</>
|
||||
)}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Content Rendering */}
|
||||
<div className="p-6 overflow-x-auto text-sm leading-relaxed text-slate-200 font-sans">
|
||||
<div
|
||||
className="prose prose-invert max-w-none [&_h1]:text-white [&_h1]:text-xl [&_h1]:font-bold [&_h1]:mb-3 [&_h2]:text-white [&_h2]:text-lg [&_h2]:font-bold [&_h2]:mb-2 [&_h3]:text-white [&_h3]:text-base [&_h3]:font-bold [&_h3]:mb-2 [&_h4]:text-white [&_h4]:font-semibold [&_a]:text-blue-400 [&_a:hover]:underline [&_ul]:list-disc [&_ul]:pl-5 [&_ul]:my-2 [&_ol]:list-decimal [&_ol]:pl-5 [&_ol]:my-2 [&_table]:w-full [&_table]:border-collapse [&_th]:border [&_th]:border-slate-700 [&_th]:bg-slate-800/80 [&_th]:p-2 [&_td]:border [&_td]:border-slate-800 [&_td]:p-2 [&_blockquote]:border-l-4 [&_blockquote]:border-blue-500 [&_blockquote]:pl-4 [&_blockquote]:italic [&_blockquote]:my-2 [&_code]:bg-slate-800 [&_code]:px-1.5 [&_code]:py-0.5 [&_code]:rounded [&_code]:text-blue-300"
|
||||
dangerouslySetInnerHTML={{ __html: renderedHtml }}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Branded Footer Stamp */}
|
||||
{showBrandingHeader && (
|
||||
<div className="px-5 py-2.5 border-t border-slate-800/80 bg-[#0a0f1b] flex items-center justify-between text-[11px] text-slate-400">
|
||||
<div className="flex items-center gap-1.5">
|
||||
<ShieldCheck className="w-3.5 h-3.5 text-blue-400" />
|
||||
<span>Verified PFM Secure Platform Formatting</span>
|
||||
</div>
|
||||
<span className="font-mono text-[10px] text-slate-400">
|
||||
{stripMarkdown(content).length} characters
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -1,380 +0,0 @@
|
||||
import React, { useState, useMemo } from 'react';
|
||||
import { useQuery } from '@tanstack/react-query';
|
||||
import {
|
||||
Search,
|
||||
User as UserIcon,
|
||||
Check,
|
||||
X,
|
||||
Shield,
|
||||
Briefcase,
|
||||
Coins,
|
||||
ShieldAlert,
|
||||
Users,
|
||||
ChevronDown,
|
||||
ChevronUp,
|
||||
} from 'lucide-react';
|
||||
import { getUsers } from '../../services/api';
|
||||
import { User, ActivityStatus } from '../../types';
|
||||
import { useAuth } from '../../auth/AuthProvider';
|
||||
|
||||
export interface SelectedUser {
|
||||
id: string;
|
||||
email: string;
|
||||
name?: string;
|
||||
role?: string;
|
||||
}
|
||||
|
||||
interface UserSearchPickerProps {
|
||||
selectedUsers: SelectedUser[];
|
||||
onSelectUser: (user: SelectedUser) => void;
|
||||
onRemoveUser: (userId: string) => void;
|
||||
multiple?: boolean;
|
||||
placeholder?: string;
|
||||
}
|
||||
|
||||
interface UserGroup {
|
||||
id: string;
|
||||
label: string;
|
||||
icon: React.ElementType;
|
||||
color: string;
|
||||
users: User[];
|
||||
}
|
||||
|
||||
export const UserSearchPicker: React.FC<UserSearchPickerProps> = ({
|
||||
selectedUsers,
|
||||
onSelectUser,
|
||||
onRemoveUser,
|
||||
placeholder = 'Search by name, email, or role...',
|
||||
}) => {
|
||||
const { user, claims } = useAuth();
|
||||
const currentUserId = String(user?.id || claims?.sub || '').trim().toLowerCase();
|
||||
const currentUserEmail = String(user?.email || claims?.email || '').trim().toLowerCase();
|
||||
|
||||
const [searchTerm, setSearchTerm] = useState('');
|
||||
const [isDropdownOpen, setIsDropdownOpen] = useState(false);
|
||||
const [collapsedGroups, setCollapsedGroups] = useState<Record<string, boolean>>({});
|
||||
|
||||
// Query tenant users
|
||||
const { data: users = [], isLoading } = useQuery<User[]>({
|
||||
queryKey: ['users-directory'],
|
||||
queryFn: () => getUsers(1, 200),
|
||||
staleTime: 5 * 60 * 1000,
|
||||
});
|
||||
|
||||
// Filter active users and exclude the current logged-in user so they can never message themselves
|
||||
const activeUsers = useMemo(() => {
|
||||
return users.filter(u => {
|
||||
if (u.status !== ActivityStatus.Active || !u.email) return false;
|
||||
const uid = String(u.id || '').trim().toLowerCase();
|
||||
const uemail = u.email.trim().toLowerCase();
|
||||
if (currentUserId && uid === currentUserId) return false;
|
||||
if (currentUserEmail && uemail === currentUserEmail) return false;
|
||||
return true;
|
||||
});
|
||||
}, [users, currentUserId, currentUserEmail]);
|
||||
|
||||
// Organize users into intuitive functional groups
|
||||
const groupedUsers = useMemo(() => {
|
||||
const term = searchTerm.toLowerCase().trim();
|
||||
|
||||
const filtered = activeUsers.filter(u => {
|
||||
if (!term) return true;
|
||||
const email = (u.email || '').toLowerCase();
|
||||
const role = String(u.role ?? '').toLowerCase();
|
||||
return email.includes(term) || role.includes(term);
|
||||
});
|
||||
|
||||
const adminUsers: User[] = [];
|
||||
const pmUsers: User[] = [];
|
||||
const financeUsers: User[] = [];
|
||||
const meUsers: User[] = [];
|
||||
const otherUsers: User[] = [];
|
||||
|
||||
filtered.forEach(u => {
|
||||
const r = String(u.role ?? '').toLowerCase();
|
||||
if (r.includes('admin') || r.includes('executive') || r.includes('director')) {
|
||||
adminUsers.push(u);
|
||||
} else if (r.includes('programme') || r.includes('project') || r.includes('manager')) {
|
||||
pmUsers.push(u);
|
||||
} else if (r.includes('finance') || r.includes('disbursement') || r.includes('accountant') || r.includes('budget')) {
|
||||
financeUsers.push(u);
|
||||
} else if (r.includes('m&e') || r.includes('monitoring') || r.includes('eval') || r.includes('risk') || r.includes('compliance')) {
|
||||
meUsers.push(u);
|
||||
} else {
|
||||
otherUsers.push(u);
|
||||
}
|
||||
});
|
||||
|
||||
const groups: UserGroup[] = [];
|
||||
|
||||
if (adminUsers.length > 0) {
|
||||
groups.push({
|
||||
id: 'admin',
|
||||
label: 'Leadership & Administration',
|
||||
icon: Shield,
|
||||
color: 'text-amber-400 bg-amber-500/10 border-amber-500/20',
|
||||
users: adminUsers,
|
||||
});
|
||||
}
|
||||
|
||||
if (pmUsers.length > 0) {
|
||||
groups.push({
|
||||
id: 'pm',
|
||||
label: 'Programme & Project Management',
|
||||
icon: Briefcase,
|
||||
color: 'text-blue-400 bg-blue-500/10 border-blue-500/20',
|
||||
users: pmUsers,
|
||||
});
|
||||
}
|
||||
|
||||
if (financeUsers.length > 0) {
|
||||
groups.push({
|
||||
id: 'finance',
|
||||
label: 'Finance & Disbursements',
|
||||
icon: Coins,
|
||||
color: 'text-emerald-400 bg-emerald-500/10 border-emerald-500/20',
|
||||
users: financeUsers,
|
||||
});
|
||||
}
|
||||
|
||||
if (meUsers.length > 0) {
|
||||
groups.push({
|
||||
id: 'me',
|
||||
label: 'M&E, Compliance & Risks',
|
||||
icon: ShieldAlert,
|
||||
color: 'text-purple-400 bg-purple-500/10 border-purple-500/20',
|
||||
users: meUsers,
|
||||
});
|
||||
}
|
||||
|
||||
if (otherUsers.length > 0) {
|
||||
groups.push({
|
||||
id: 'team',
|
||||
label: 'Tenant Staff & Team Members',
|
||||
icon: Users,
|
||||
color: 'text-slate-400 bg-slate-500/10 border-slate-500/20',
|
||||
users: otherUsers,
|
||||
});
|
||||
}
|
||||
|
||||
return groups;
|
||||
}, [activeUsers, searchTerm]);
|
||||
|
||||
const toggleGroupCollapse = (groupId: string) => {
|
||||
setCollapsedGroups(prev => ({
|
||||
...prev,
|
||||
[groupId]: !prev[groupId],
|
||||
}));
|
||||
};
|
||||
|
||||
const isUserSelected = (userId: string) => {
|
||||
return selectedUsers.some(u => u.id === userId);
|
||||
};
|
||||
|
||||
const handleUserClick = (u: User) => {
|
||||
if (!u.email) return;
|
||||
|
||||
if (isUserSelected(u.id)) {
|
||||
onRemoveUser(u.id);
|
||||
} else {
|
||||
const displayName = u.email.split('@')[0].replace(/[._-]/g, ' ');
|
||||
const formattedName = displayName
|
||||
.split(' ')
|
||||
.map(w => w.charAt(0).toUpperCase() + w.slice(1))
|
||||
.join(' ');
|
||||
|
||||
// Strictly single-recipient selection
|
||||
onSelectUser({
|
||||
id: u.id,
|
||||
email: u.email,
|
||||
name: formattedName,
|
||||
role: String(u.role ?? 'User'),
|
||||
});
|
||||
setSearchTerm('');
|
||||
setIsDropdownOpen(false);
|
||||
}
|
||||
};
|
||||
|
||||
const singleSelectedUser = selectedUsers[0];
|
||||
|
||||
return (
|
||||
<div className="relative w-full">
|
||||
{/* Single Selected Recipient or Search Input Box */}
|
||||
{singleSelectedUser ? (
|
||||
<div className="flex items-center justify-between px-3 py-2 rounded-xl bg-blue-500/10 border border-blue-500/30 text-blue-100 text-xs shadow-xs">
|
||||
<div className="flex items-center gap-2.5 min-w-0">
|
||||
<div className="w-6 h-6 rounded-full bg-blue-600/40 text-[10px] font-bold flex items-center justify-center text-blue-200 shrink-0 border border-blue-400/30">
|
||||
{(singleSelectedUser.name || singleSelectedUser.email).slice(0, 2).toUpperCase()}
|
||||
</div>
|
||||
<div className="flex items-center gap-2 min-w-0">
|
||||
<span className="font-semibold text-white truncate">
|
||||
{singleSelectedUser.name || singleSelectedUser.email}
|
||||
</span>
|
||||
<span className="text-[11px] text-slate-400 font-mono truncate">
|
||||
<{singleSelectedUser.email}>
|
||||
</span>
|
||||
{singleSelectedUser.role && (
|
||||
<span className="text-[10px] text-blue-300 bg-blue-900/40 px-1.5 py-0.5 rounded border border-blue-800/40 shrink-0">
|
||||
{singleSelectedUser.role}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => onRemoveUser(singleSelectedUser.id)}
|
||||
className="flex items-center gap-1 text-[11px] text-blue-300 hover:text-white hover:bg-blue-600/30 px-2 py-1 rounded-lg transition cursor-pointer shrink-0"
|
||||
title="Remove recipient to select another user"
|
||||
>
|
||||
<X className="w-3.5 h-3.5" />
|
||||
<span>Change</span>
|
||||
</button>
|
||||
</div>
|
||||
) : (
|
||||
<div className="relative">
|
||||
<div className="absolute inset-y-0 left-0 pl-3 flex items-center pointer-events-none text-slate-400">
|
||||
<Search className="w-4 h-4" />
|
||||
</div>
|
||||
<input
|
||||
type="text"
|
||||
value={searchTerm}
|
||||
onChange={e => {
|
||||
setSearchTerm(e.target.value);
|
||||
if (!isDropdownOpen) setIsDropdownOpen(true);
|
||||
}}
|
||||
onFocus={() => setIsDropdownOpen(true)}
|
||||
placeholder={placeholder}
|
||||
className="w-full pl-9 pr-8 py-2 rounded-xl bg-slate-900/90 border border-slate-700/80 hover:border-slate-600 focus:border-blue-500 text-slate-100 placeholder-slate-400 text-xs focus:outline-none transition shadow-inner"
|
||||
/>
|
||||
{searchTerm && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setSearchTerm('')}
|
||||
className="absolute inset-y-0 right-0 pr-3 flex items-center text-slate-400 hover:text-white"
|
||||
>
|
||||
<X className="w-3.5 h-3.5" />
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Organized User Directory Dropdown (only when no recipient is locked in) */}
|
||||
{isDropdownOpen && !singleSelectedUser && (
|
||||
<div className="absolute left-0 right-0 top-full mt-1.5 max-h-72 overflow-y-auto bg-[#0d1322] border border-slate-700/80 rounded-2xl shadow-2xl z-50 p-2 space-y-3 backdrop-blur-md animate-in fade-in slide-in-from-top-1">
|
||||
{isLoading ? (
|
||||
<div className="py-6 text-center text-xs text-slate-400 flex items-center justify-center gap-2">
|
||||
<div className="w-4 h-4 border-2 border-blue-500 border-t-transparent rounded-full animate-spin" />
|
||||
<span>Loading tenant users directory...</span>
|
||||
</div>
|
||||
) : groupedUsers.length === 0 ? (
|
||||
<div className="py-6 text-center text-xs text-slate-400">
|
||||
No matching users found for "{searchTerm}".
|
||||
</div>
|
||||
) : (
|
||||
groupedUsers.map(group => {
|
||||
const GroupIcon = group.icon;
|
||||
const isCollapsed = collapsedGroups[group.id];
|
||||
|
||||
return (
|
||||
<div key={group.id} className="space-y-1">
|
||||
{/* Category Header */}
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => toggleGroupCollapse(group.id)}
|
||||
className="w-full flex items-center justify-between px-2.5 py-1.5 rounded-lg bg-slate-900/60 hover:bg-slate-800/60 text-slate-300 text-[11px] font-semibold transition cursor-pointer border border-slate-800/60"
|
||||
>
|
||||
<div className="flex items-center gap-2">
|
||||
<span className={`p-1 rounded-md border ${group.color}`}>
|
||||
<GroupIcon className="w-3 h-3" />
|
||||
</span>
|
||||
<span>{group.label}</span>
|
||||
<span className="text-[10px] text-slate-400 bg-slate-800 px-1.5 py-0.2 rounded-full border border-slate-700 font-mono">
|
||||
{group.users.length}
|
||||
</span>
|
||||
</div>
|
||||
{isCollapsed ? (
|
||||
<ChevronDown className="w-3.5 h-3.5 text-slate-400" />
|
||||
) : (
|
||||
<ChevronUp className="w-3.5 h-3.5 text-slate-400" />
|
||||
)}
|
||||
</button>
|
||||
|
||||
{/* Users inside group */}
|
||||
{!isCollapsed && (
|
||||
<div className="grid grid-cols-1 gap-1 pl-1 pr-1 pt-0.5">
|
||||
{group.users.map(u => {
|
||||
const selected = isUserSelected(u.id);
|
||||
const displayName = (u.email || '').split('@')[0].replace(/[._-]/g, ' ');
|
||||
const formattedName = displayName
|
||||
.split(' ')
|
||||
.map(w => w.charAt(0).toUpperCase() + w.slice(1))
|
||||
.join(' ');
|
||||
|
||||
return (
|
||||
<div
|
||||
key={u.id}
|
||||
onClick={() => handleUserClick(u)}
|
||||
className={`flex items-center justify-between px-3 py-2 rounded-xl text-xs transition cursor-pointer border ${
|
||||
selected
|
||||
? 'bg-blue-600/20 border-blue-500/40 text-white'
|
||||
: 'bg-slate-900/40 hover:bg-slate-800/70 border-slate-800/40 text-slate-200'
|
||||
}`}
|
||||
>
|
||||
<div className="flex items-center gap-2.5 min-w-0">
|
||||
<div
|
||||
className={`w-7 h-7 shrink-0 rounded-full flex items-center justify-center font-bold text-[10px] border ${
|
||||
selected
|
||||
? 'bg-blue-600 border-blue-400 text-white'
|
||||
: 'bg-slate-800 border-slate-700 text-slate-300'
|
||||
}`}
|
||||
>
|
||||
{formattedName.slice(0, 2).toUpperCase()}
|
||||
</div>
|
||||
<div className="min-w-0">
|
||||
<p className="font-medium text-xs truncate text-white">{formattedName}</p>
|
||||
<p className="text-[10px] text-slate-400 font-mono truncate">{u.email}</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-2 shrink-0">
|
||||
{u.role && (
|
||||
<span className="text-[10px] font-semibold text-blue-400 bg-blue-500/10 border border-blue-500/20 px-1.5 py-0.5 rounded truncate max-w-[120px]">
|
||||
{u.role}
|
||||
</span>
|
||||
)}
|
||||
<div
|
||||
className={`w-4 h-4 rounded-md border flex items-center justify-center ${
|
||||
selected
|
||||
? 'bg-blue-600 border-blue-500 text-white'
|
||||
: 'border-slate-700 bg-slate-950/60'
|
||||
}`}
|
||||
>
|
||||
{selected && <Check className="w-3 h-3 stroke-[3]" />}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
})
|
||||
)}
|
||||
|
||||
{/* Close button at bottom */}
|
||||
<div className="pt-2 border-t border-slate-800 flex justify-end">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setIsDropdownOpen(false)}
|
||||
className="text-[11px] text-slate-400 hover:text-white px-3 py-1 rounded-lg hover:bg-slate-800 transition cursor-pointer"
|
||||
>
|
||||
Done Selecting
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import React, { useState, useMemo } from 'react';
|
||||
import React, { useState } from 'react';
|
||||
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
|
||||
import { useForm } from 'react-hook-form';
|
||||
import { zodResolver } from '@hookform/resolvers/zod';
|
||||
@@ -14,10 +14,6 @@ import {
|
||||
Flag,
|
||||
X,
|
||||
RotateCcw,
|
||||
Target,
|
||||
AlertTriangle,
|
||||
ChevronRight,
|
||||
ShieldAlert,
|
||||
} from 'lucide-react';
|
||||
import { Modal } from '../ui/Modal';
|
||||
import { HelpTooltip } from '../ui/HelpTooltip';
|
||||
@@ -27,10 +23,8 @@ import {
|
||||
createActivity,
|
||||
updateActivity,
|
||||
getMilestones,
|
||||
getProjectIndicators,
|
||||
getBudgets,
|
||||
} from '../../services/api';
|
||||
import { Project, Activity, Milestone, Indicator, Budget } from '../../types';
|
||||
import { Project, Activity, Milestone } from '../../types';
|
||||
import { formatDate, formatZAR } from '../../lib/formatters';
|
||||
|
||||
const activitySchema = z.object({
|
||||
@@ -48,14 +42,12 @@ interface ProjectActivitiesModalProps {
|
||||
isOpen: boolean;
|
||||
onClose: () => void;
|
||||
project: Project | null;
|
||||
onOpenIndicatorsForActivity?: (activityId: string | number) => void;
|
||||
}
|
||||
|
||||
export const ProjectActivitiesModal: React.FC<ProjectActivitiesModalProps> = ({
|
||||
isOpen,
|
||||
onClose,
|
||||
project,
|
||||
onOpenIndicatorsForActivity,
|
||||
}) => {
|
||||
const queryClient = useQueryClient();
|
||||
const { success, error: toastError } = useToast();
|
||||
@@ -81,47 +73,8 @@ export const ProjectActivitiesModal: React.FC<ProjectActivitiesModalProps> = ({
|
||||
enabled: isOpen && !!projectId,
|
||||
});
|
||||
|
||||
const { data: indicators = [] } = useQuery({
|
||||
queryKey: ['indicators', projectId],
|
||||
queryFn: () => (projectId ? getProjectIndicators(projectId) : Promise.resolve([])),
|
||||
enabled: isOpen && !!projectId,
|
||||
});
|
||||
|
||||
const { data: allBudgets = [] } = useQuery({
|
||||
queryKey: ['budgets'],
|
||||
queryFn: () => getBudgets(1, 100),
|
||||
enabled: isOpen,
|
||||
});
|
||||
|
||||
// Approved project budget calculations
|
||||
const approvedBudgetTotal = useMemo(() => {
|
||||
const projectBudgets = allBudgets.filter((b) => String(b.projectId) === String(projectId));
|
||||
return projectBudgets
|
||||
.filter((b) => b.status === undefined || b.status === null || Number(b.status) === 3)
|
||||
.reduce((sum, b) => sum + (Number(b.amount) || 0), 0);
|
||||
}, [allBudgets, projectId]);
|
||||
|
||||
const totalAllocated = useMemo(
|
||||
() => activities.reduce((acc, a) => acc + (Number(a.amount) || 0), 0),
|
||||
[activities]
|
||||
);
|
||||
|
||||
const maxAllowedForForm = useMemo(() => {
|
||||
if (approvedBudgetTotal <= 0) return Infinity;
|
||||
const baseRemaining = approvedBudgetTotal - totalAllocated;
|
||||
if (editingActivity) {
|
||||
return baseRemaining + (Number(editingActivity.amount) || 0);
|
||||
}
|
||||
return Math.max(0, baseRemaining);
|
||||
}, [approvedBudgetTotal, totalAllocated, editingActivity]);
|
||||
|
||||
const remainingBudget = useMemo(() => {
|
||||
if (approvedBudgetTotal <= 0) return 0;
|
||||
return Math.max(0, approvedBudgetTotal - totalAllocated);
|
||||
}, [approvedBudgetTotal, totalAllocated]);
|
||||
|
||||
const form = useForm<ActivityFormValues>({
|
||||
resolver: zodResolver(activitySchema),
|
||||
resolver: zodResolver(activitySchema) as any,
|
||||
defaultValues: {
|
||||
title: '',
|
||||
description: '',
|
||||
@@ -161,14 +114,6 @@ export const ProjectActivitiesModal: React.FC<ProjectActivitiesModalProps> = ({
|
||||
const mutation = useMutation({
|
||||
mutationFn: async (data: ActivityFormValues): Promise<void> => {
|
||||
if (!projectId) return;
|
||||
|
||||
const requestedAmount = Number(data.amount);
|
||||
if (approvedBudgetTotal > 0 && requestedAmount > maxAllowedForForm) {
|
||||
throw new Error(
|
||||
`Activity amount (${formatZAR(requestedAmount)}) exceeds the available remaining project budget (${formatZAR(maxAllowedForForm)}).`
|
||||
);
|
||||
}
|
||||
|
||||
const parsedMilestoneId =
|
||||
data.milestoneId && String(data.milestoneId).trim() !== ''
|
||||
? Number(data.milestoneId)
|
||||
@@ -182,7 +127,7 @@ export const ProjectActivitiesModal: React.FC<ProjectActivitiesModalProps> = ({
|
||||
title: data.title,
|
||||
description: data.description,
|
||||
category: data.category,
|
||||
amount: requestedAmount,
|
||||
amount: Number(data.amount),
|
||||
milestoneId: parsedMilestoneId,
|
||||
performedAt: performedAtIso,
|
||||
});
|
||||
@@ -192,7 +137,7 @@ export const ProjectActivitiesModal: React.FC<ProjectActivitiesModalProps> = ({
|
||||
title: data.title,
|
||||
description: data.description,
|
||||
category: data.category,
|
||||
amount: requestedAmount,
|
||||
amount: Number(data.amount),
|
||||
milestoneId: parsedMilestoneId,
|
||||
performedAt: performedAtIso,
|
||||
});
|
||||
@@ -200,7 +145,6 @@ export const ProjectActivitiesModal: React.FC<ProjectActivitiesModalProps> = ({
|
||||
},
|
||||
onSuccess: () => {
|
||||
queryClient.invalidateQueries({ queryKey: ['activities', projectId] });
|
||||
queryClient.invalidateQueries({ queryKey: ['milestones', projectId] });
|
||||
queryClient.invalidateQueries({ queryKey: ['projects'] });
|
||||
success(
|
||||
editingActivity ? 'Activity Updated' : 'Activity Created',
|
||||
@@ -219,6 +163,8 @@ export const ProjectActivitiesModal: React.FC<ProjectActivitiesModalProps> = ({
|
||||
|
||||
if (!project) return null;
|
||||
|
||||
const totalBudget = activities.reduce((acc, a) => acc + (Number(a.amount) || 0), 0);
|
||||
|
||||
return (
|
||||
<Modal
|
||||
isOpen={isOpen}
|
||||
@@ -229,52 +175,23 @@ export const ProjectActivitiesModal: React.FC<ProjectActivitiesModalProps> = ({
|
||||
}}
|
||||
title={`Work Breakdown Activities: ${project.name}`}
|
||||
subtitle={`Project #${project.id} • Operational Work Breakdown Structure (WBS)`}
|
||||
helpText="Under each milestone, operational activities are scheduled with budgeted amounts. The sum of all activity budgets across the project cannot exceed the approved project budget."
|
||||
helpText="Granular operational tasks executed under this project. Activities can optionally be linked to contractual milestones and are tracked with specific budget allocations."
|
||||
maxWidth="xl"
|
||||
>
|
||||
<div className="space-y-5">
|
||||
{/* KPI Summary Cards */}
|
||||
<div className="grid grid-cols-1 sm:grid-cols-3 gap-3">
|
||||
<div className="bg-[#0b101c] p-3.5 rounded-xl border border-slate-800">
|
||||
{/* KPI Summary Card */}
|
||||
<div className="bg-[#0b101c] p-4 rounded-xl border border-slate-800/90 flex flex-col sm:flex-row sm:items-center justify-between gap-4">
|
||||
<div className="space-y-1">
|
||||
<span className="text-[10px] uppercase font-bold text-slate-400 flex items-center gap-1.5">
|
||||
<Coins className="w-3.5 h-3.5 text-cyan-400" />
|
||||
Total Allocated Activities
|
||||
Total Allocated Work Budget
|
||||
</span>
|
||||
<div className="flex items-baseline gap-2 mt-1">
|
||||
<span className="text-xl font-bold font-mono text-cyan-400">{formatZAR(totalAllocated)}</span>
|
||||
<span className="text-[11px] text-slate-400">({activities.length} tasks)</span>
|
||||
<div className="flex items-baseline gap-2">
|
||||
<span className="text-2xl font-bold font-mono text-cyan-400">{formatZAR(totalBudget)}</span>
|
||||
<span className="text-xs text-slate-400">({activities.length} recorded tasks)</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="bg-[#0b101c] p-3.5 rounded-xl border border-slate-800">
|
||||
<span className="text-[10px] uppercase font-bold text-slate-400 flex items-center gap-1.5">
|
||||
<Coins className="w-3.5 h-3.5 text-emerald-400" />
|
||||
Approved Project Budget
|
||||
</span>
|
||||
<div className="flex items-baseline gap-2 mt-1">
|
||||
<span className="text-xl font-bold font-mono text-emerald-400">{formatZAR(approvedBudgetTotal)}</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="bg-[#0b101c] p-3.5 rounded-xl border border-slate-800">
|
||||
<span className="text-[10px] uppercase font-bold text-slate-400 flex items-center gap-1.5">
|
||||
<Coins className="w-3.5 h-3.5 text-blue-400" />
|
||||
Remaining Budget Ceiling
|
||||
</span>
|
||||
<div className="flex items-baseline gap-2 mt-1">
|
||||
<span className={`text-xl font-bold font-mono ${remainingBudget <= 0 && approvedBudgetTotal > 0 ? 'text-rose-400' : 'text-blue-400'}`}>
|
||||
{formatZAR(remainingBudget)}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Action Header */}
|
||||
<div className="flex items-center justify-between bg-[#0d121f] p-3 rounded-xl border border-slate-800">
|
||||
<span className="text-xs text-slate-400">
|
||||
Scheduled Tasks: <strong className="text-white">{activities.length}</strong>
|
||||
</span>
|
||||
|
||||
<div className="flex items-center gap-2">
|
||||
{!isFormOpen && (
|
||||
<button
|
||||
@@ -309,14 +226,7 @@ export const ProjectActivitiesModal: React.FC<ProjectActivitiesModalProps> = ({
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{approvedBudgetTotal > 0 && (
|
||||
<div className="p-2.5 rounded-lg bg-blue-500/10 border border-blue-500/25 text-xs flex items-center justify-between text-blue-300">
|
||||
<span>Maximum activity budget allowance:</span>
|
||||
<strong className="font-mono">{formatZAR(maxAllowedForForm)}</strong>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<form onSubmit={form.handleSubmit((data) => mutation.mutate(data))} className="space-y-3">
|
||||
<form onSubmit={form.handleSubmit((data) => mutation.mutate(data as any))} className="space-y-3">
|
||||
<div>
|
||||
<label className="block text-xs font-semibold text-slate-300 mb-1">Activity Title *</label>
|
||||
<input
|
||||
@@ -352,7 +262,6 @@ export const ProjectActivitiesModal: React.FC<ProjectActivitiesModalProps> = ({
|
||||
<label className="block text-xs font-semibold text-slate-300 mb-1">Budget Amount (ZAR) *</label>
|
||||
<input
|
||||
type="number"
|
||||
step="0.01"
|
||||
{...form.register('amount')}
|
||||
className="w-full px-3 py-2 text-xs border border-slate-700/80 rounded-xl focus:outline-none focus:ring-1 focus:ring-cyan-500 text-white font-mono bg-[#090d16]"
|
||||
/>
|
||||
@@ -441,9 +350,7 @@ export const ProjectActivitiesModal: React.FC<ProjectActivitiesModalProps> = ({
|
||||
<div className="p-8 text-center bg-[#0b101c] rounded-xl border border-dashed border-slate-800 text-slate-400 space-y-2">
|
||||
<Briefcase className="w-8 h-8 text-slate-600 mx-auto" />
|
||||
<p className="text-xs font-semibold text-slate-300">No work activities recorded yet</p>
|
||||
<p className="text-[11px] text-slate-500">
|
||||
Deconstruct the project milestones into operational tasks with allocated budgets.
|
||||
</p>
|
||||
<p className="text-[11px] text-slate-500">Deconstruct the project into operational tasks with allocated budgets.</p>
|
||||
<button
|
||||
type="button"
|
||||
onClick={openCreateForm}
|
||||
@@ -453,24 +360,17 @@ export const ProjectActivitiesModal: React.FC<ProjectActivitiesModalProps> = ({
|
||||
</button>
|
||||
</div>
|
||||
) : (
|
||||
<div className="space-y-3 max-h-[420px] overflow-y-auto pr-1">
|
||||
<div className="space-y-2 max-h-[380px] overflow-y-auto pr-1">
|
||||
{activities.map((a) => {
|
||||
const linkedMilestone = a.milestoneId
|
||||
? milestones.find((m) => String(m.id) === String(a.milestoneId))
|
||||
: null;
|
||||
|
||||
const actIndicators = indicators.filter((i) => String(i.activityId) === String(a.id));
|
||||
const actActualCost = actIndicators.reduce((sum, i) => sum + (Number(i.actualCost) || 0), 0);
|
||||
const isOverrun = actActualCost > Number(a.amount) && a.amount > 0;
|
||||
|
||||
return (
|
||||
<div
|
||||
key={a.id}
|
||||
className={`p-3.5 rounded-xl border bg-[#0d121f] transition space-y-2.5 ${
|
||||
isOverrun ? 'border-amber-500/40 bg-amber-950/10' : 'border-slate-800 hover:border-slate-700'
|
||||
}`}
|
||||
className="p-3.5 rounded-xl border border-slate-800 bg-[#0d121f] hover:border-slate-700 transition flex flex-col sm:flex-row sm:items-start justify-between gap-3"
|
||||
>
|
||||
<div className="flex flex-col sm:flex-row sm:items-start justify-between gap-3">
|
||||
<div className="space-y-1">
|
||||
<div className="flex items-center gap-2 flex-wrap">
|
||||
<span className="text-xs font-bold text-white">{a.title}</span>
|
||||
@@ -494,16 +394,7 @@ export const ProjectActivitiesModal: React.FC<ProjectActivitiesModalProps> = ({
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-2 shrink-0 self-end sm:self-center">
|
||||
<div className="text-right mr-1">
|
||||
<span className="block font-mono text-xs font-bold text-cyan-400">
|
||||
{formatZAR(a.amount)}
|
||||
</span>
|
||||
{actActualCost > 0 && (
|
||||
<span className={`block font-mono text-[10px] ${isOverrun ? 'text-rose-400 font-bold' : 'text-slate-400'}`}>
|
||||
Actual: {formatZAR(actActualCost)}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
<span className="font-mono text-xs font-bold text-cyan-400 mr-2">{formatZAR(a.amount)}</span>
|
||||
|
||||
<Link
|
||||
to={`/monitoring?tab=evidence&projectId=${project.id}&activityId=${a.id}`}
|
||||
@@ -524,50 +415,6 @@ export const ProjectActivitiesModal: React.FC<ProjectActivitiesModalProps> = ({
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Linked Delivery Indicators Section */}
|
||||
<div className="pt-2 border-t border-slate-800/80 flex flex-col sm:flex-row sm:items-center justify-between gap-2 text-xs">
|
||||
<div className="flex items-center gap-2 flex-wrap">
|
||||
<span className="text-[11px] text-slate-400 flex items-center gap-1">
|
||||
<Target className="w-3 h-3 text-indigo-400" />
|
||||
Delivery Specifications:
|
||||
</span>
|
||||
{actIndicators.length === 0 ? (
|
||||
<span className="text-[11px] text-slate-500 italic">No indicators defined for this activity yet.</span>
|
||||
) : (
|
||||
<div className="flex items-center gap-1.5 flex-wrap">
|
||||
{actIndicators.map((ind) => (
|
||||
<span
|
||||
key={ind.id}
|
||||
className="px-2 py-0.5 rounded bg-slate-900 border border-slate-700/70 text-[10px] text-slate-300 font-mono"
|
||||
>
|
||||
{ind.name}: {ind.actualAmount}/{ind.targetAmount}
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-2 shrink-0">
|
||||
{isOverrun && (
|
||||
<span className="text-[10px] font-bold text-rose-400 bg-rose-500/10 px-2 py-0.5 rounded border border-rose-500/25">
|
||||
⚠️ Overrun: +{formatZAR(actActualCost - Number(a.amount))}
|
||||
</span>
|
||||
)}
|
||||
|
||||
{onOpenIndicatorsForActivity && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => onOpenIndicatorsForActivity(a.id)}
|
||||
className="inline-flex items-center gap-1 text-[11px] font-semibold text-indigo-400 hover:text-indigo-300 hover:underline cursor-pointer"
|
||||
>
|
||||
<span>Manage Indicators ({actIndicators.length})</span>
|
||||
<ChevronRight className="w-3 h-3" />
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
|
||||
@@ -1,496 +0,0 @@
|
||||
import React from 'react';
|
||||
import { useQuery } from '@tanstack/react-query';
|
||||
import {
|
||||
ShieldCheck,
|
||||
ShieldAlert,
|
||||
AlertTriangle,
|
||||
Users,
|
||||
Target,
|
||||
Coins,
|
||||
CheckCircle2,
|
||||
} from 'lucide-react';
|
||||
import { Modal } from '../ui/Modal';
|
||||
import { ComplianceStatusBadge } from '../ui/StatusBadge';
|
||||
import {
|
||||
Project,
|
||||
Programme,
|
||||
Outcome,
|
||||
ComplianceStatus,
|
||||
ApprovalStatus,
|
||||
} from '../../types';
|
||||
import {
|
||||
getProjectComplianceItems,
|
||||
getProjectBeneficiaries,
|
||||
getBeneficiaryOutcomes,
|
||||
getProjectRisks,
|
||||
getMilestones,
|
||||
getActivities,
|
||||
getBudgets,
|
||||
} from '../../services/api';
|
||||
import {
|
||||
formatDate,
|
||||
formatZAR,
|
||||
getRiskScoreColor,
|
||||
} from '../../lib/formatters';
|
||||
|
||||
interface ProjectComplianceModalProps {
|
||||
isOpen: boolean;
|
||||
onClose: () => void;
|
||||
project: Project | null;
|
||||
programme: Programme | null;
|
||||
}
|
||||
|
||||
export const ProjectComplianceModal: React.FC<ProjectComplianceModalProps> = ({
|
||||
isOpen,
|
||||
onClose,
|
||||
project,
|
||||
programme,
|
||||
}) => {
|
||||
const projectId = project?.id;
|
||||
|
||||
// Queries
|
||||
const { data: complianceItems = [], isLoading: loadingCompliance } = useQuery({
|
||||
queryKey: ['complianceItems', projectId],
|
||||
queryFn: () => (projectId ? getProjectComplianceItems(projectId) : Promise.resolve([])),
|
||||
enabled: isOpen && !!projectId,
|
||||
});
|
||||
|
||||
const { data: projectBeneficiaries = [], isLoading: loadingBeneficiaries } = useQuery({
|
||||
queryKey: ['projectBeneficiaries', projectId],
|
||||
queryFn: () => (projectId ? getProjectBeneficiaries(projectId) : Promise.resolve([])),
|
||||
enabled: isOpen && !!projectId,
|
||||
});
|
||||
|
||||
const { data: risks = [], isLoading: loadingRisks } = useQuery({
|
||||
queryKey: ['risks', projectId],
|
||||
queryFn: () => (projectId ? getProjectRisks(projectId) : Promise.resolve([])),
|
||||
enabled: isOpen && !!projectId,
|
||||
});
|
||||
|
||||
const { data: milestones = [] } = useQuery({
|
||||
queryKey: ['milestones', projectId],
|
||||
queryFn: () => (projectId ? getMilestones(projectId) : Promise.resolve([])),
|
||||
enabled: isOpen && !!projectId,
|
||||
});
|
||||
|
||||
const { data: activities = [] } = useQuery({
|
||||
queryKey: ['activities', projectId],
|
||||
queryFn: () => (projectId ? getActivities(projectId) : Promise.resolve([])),
|
||||
enabled: isOpen && !!projectId,
|
||||
});
|
||||
|
||||
const { data: allBudgets = [] } = useQuery({
|
||||
queryKey: ['budgets'],
|
||||
queryFn: () => getBudgets(1, 100),
|
||||
enabled: isOpen && !!projectId,
|
||||
});
|
||||
|
||||
// Beneficiary outcomes query
|
||||
const { data: outcomes = [], isLoading: loadingOutcomes } = useQuery({
|
||||
queryKey: ['beneficiaryOutcomes', projectBeneficiaries.map((b) => b.id)],
|
||||
queryFn: async (): Promise<Outcome[]> => {
|
||||
if (projectBeneficiaries.length === 0) return [];
|
||||
const results = await Promise.all(
|
||||
projectBeneficiaries.map(async (b) => {
|
||||
try {
|
||||
return await getBeneficiaryOutcomes(b.id);
|
||||
} catch {
|
||||
return [];
|
||||
}
|
||||
})
|
||||
);
|
||||
return results.flat();
|
||||
},
|
||||
enabled: isOpen && projectBeneficiaries.length > 0,
|
||||
});
|
||||
|
||||
if (!project) return null;
|
||||
|
||||
// Compliance calculations
|
||||
const totalCompCount = complianceItems.length;
|
||||
const metCompCount = complianceItems.filter(
|
||||
(c) => Number(c.status) === ComplianceStatus.Met || Number(c.status) === ComplianceStatus.Exceeded
|
||||
).length;
|
||||
const unmetCompCount = totalCompCount - metCompCount;
|
||||
|
||||
// Outcomes calculations
|
||||
const totalOutcomesCount = outcomes.length;
|
||||
const metOutcomesCount = outcomes.filter(
|
||||
(o) => Number(o.status) === ComplianceStatus.Met || Number(o.status) === ComplianceStatus.Exceeded
|
||||
).length;
|
||||
const unmetOutcomesCount = totalOutcomesCount - metOutcomesCount;
|
||||
|
||||
// Overall compliance verdict (Invariant: 100% met required for approval)
|
||||
const is100PercentCompliant =
|
||||
unmetCompCount === 0 && unmetOutcomesCount === 0;
|
||||
|
||||
// Financial calculations
|
||||
const projectBudgets = allBudgets.filter((b) => String(b.projectId) === String(projectId));
|
||||
const approvedBudgetTotal = projectBudgets
|
||||
.filter((b) => Number(b.status) === ApprovalStatus.Approved || b.status === undefined)
|
||||
.reduce((sum, b) => sum + (Number(b.amount) || 0), 0);
|
||||
const totalActivityBudget = activities.reduce((sum, a) => sum + (Number(a.amount) || 0), 0);
|
||||
const financialVariance = approvedBudgetTotal - totalActivityBudget;
|
||||
const isWithinBudget = financialVariance >= 0;
|
||||
|
||||
// Milestone Progress
|
||||
const verifiedMilestonesCount = milestones.filter((m) => m.status === ApprovalStatus.Approved).length;
|
||||
const milestoneProgress = milestones.length > 0 ? Math.round((verifiedMilestonesCount / milestones.length) * 100) : 0;
|
||||
|
||||
return (
|
||||
<Modal
|
||||
isOpen={isOpen}
|
||||
onClose={onClose}
|
||||
title={`Risk & Compliance Status: ${project.name}`}
|
||||
subtitle={`Project #${project.id} • ${programme?.name || 'Programme'} • Statutory Governance Audit`}
|
||||
helpText="Comprehensive audit of project compliance items, beneficiary outcomes fulfillment, ISO 31000 risk register, and statutory approval eligibility."
|
||||
maxWidth="5xl"
|
||||
>
|
||||
<div className="space-y-6 text-slate-200">
|
||||
{/* Top Verdict Banner */}
|
||||
<div
|
||||
className={`p-4 sm:p-5 rounded-2xl border flex flex-col sm:flex-row sm:items-center justify-between gap-4 ${
|
||||
is100PercentCompliant
|
||||
? 'bg-emerald-950/30 border-emerald-500/40'
|
||||
: 'bg-rose-950/30 border-rose-500/40'
|
||||
}`}
|
||||
>
|
||||
<div className="flex items-start gap-3">
|
||||
<div
|
||||
className={`p-2.5 rounded-xl border mt-0.5 ${
|
||||
is100PercentCompliant
|
||||
? 'bg-emerald-500/20 text-emerald-400 border-emerald-500/30'
|
||||
: 'bg-rose-500/20 text-rose-400 border-rose-500/30'
|
||||
}`}
|
||||
>
|
||||
{is100PercentCompliant ? (
|
||||
<ShieldCheck className="w-6 h-6" />
|
||||
) : (
|
||||
<ShieldAlert className="w-6 h-6" />
|
||||
)}
|
||||
</div>
|
||||
<div>
|
||||
<div className="flex items-center gap-2">
|
||||
<h4 className="text-sm font-bold text-white">
|
||||
{is100PercentCompliant
|
||||
? '100% Fully Compliant — Approved for Project Governance'
|
||||
: 'Action Required: Project is Noncompliant'}
|
||||
</h4>
|
||||
<span
|
||||
className={`text-[10px] uppercase font-bold px-2 py-0.5 rounded-full border ${
|
||||
is100PercentCompliant
|
||||
? 'bg-emerald-500/20 text-emerald-300 border-emerald-500/40'
|
||||
: 'bg-rose-500/20 text-rose-300 border-rose-500/40 animate-pulse'
|
||||
}`}
|
||||
>
|
||||
{is100PercentCompliant ? 'Eligible for Approval' : 'Approvals Blocked'}
|
||||
</span>
|
||||
</div>
|
||||
<p className="text-xs text-slate-300 mt-1">
|
||||
{is100PercentCompliant
|
||||
? 'All statutory compliance requirements and beneficiary impact outcomes have met or exceeded threshold requirements.'
|
||||
: `This project has ${unmetCompCount} unmet compliance item(s) and ${unmetOutcomesCount} unmet beneficiary outcome(s). Per governance policy, projects cannot be approved until all compliance items and outcomes are 100% met.`}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="shrink-0 flex items-center gap-3">
|
||||
<div className="text-right">
|
||||
<span className="text-[10px] text-slate-400 block uppercase font-semibold">Budget At Risk</span>
|
||||
<span className={`text-base font-bold font-mono ${is100PercentCompliant ? 'text-emerald-400' : 'text-rose-400'}`}>
|
||||
{is100PercentCompliant ? 'R 0.00' : formatZAR(approvedBudgetTotal)}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 4 Summary Metric Cards */}
|
||||
<div className="grid grid-cols-2 lg:grid-cols-4 gap-3.5">
|
||||
<div className="bg-[#0b101c] p-3.5 rounded-xl border border-slate-800">
|
||||
<span className="text-[10px] uppercase font-bold text-slate-400 flex items-center gap-1.5">
|
||||
<ShieldCheck className="w-3.5 h-3.5 text-cyan-400" />
|
||||
Compliance Requirements
|
||||
</span>
|
||||
<div className="flex items-baseline gap-2 mt-1">
|
||||
<span className="text-xl font-bold font-mono text-cyan-400">
|
||||
{metCompCount}/{totalCompCount}
|
||||
</span>
|
||||
<span className="text-xs text-slate-400 font-semibold">
|
||||
({totalCompCount > 0 ? Math.round((metCompCount / totalCompCount) * 100) : 100}%)
|
||||
</span>
|
||||
</div>
|
||||
<span className="text-[10px] text-slate-500">
|
||||
{unmetCompCount === 0 ? 'All items satisfied' : `${unmetCompCount} pending fulfillment`}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div className="bg-[#0b101c] p-3.5 rounded-xl border border-slate-800">
|
||||
<span className="text-[10px] uppercase font-bold text-slate-400 flex items-center gap-1.5">
|
||||
<Target className="w-3.5 h-3.5 text-purple-400" />
|
||||
Beneficiary Outcomes
|
||||
</span>
|
||||
<div className="flex items-baseline gap-2 mt-1">
|
||||
<span className="text-xl font-bold font-mono text-purple-400">
|
||||
{metOutcomesCount}/{totalOutcomesCount}
|
||||
</span>
|
||||
<span className="text-xs text-slate-400 font-semibold">
|
||||
({totalOutcomesCount > 0 ? Math.round((metOutcomesCount / totalOutcomesCount) * 100) : 100}%)
|
||||
</span>
|
||||
</div>
|
||||
<span className="text-[10px] text-slate-500">
|
||||
Across {projectBeneficiaries.length} beneficiary group{projectBeneficiaries.length === 1 ? '' : 's'}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div className="bg-[#0b101c] p-3.5 rounded-xl border border-slate-800">
|
||||
<span className="text-[10px] uppercase font-bold text-slate-400 flex items-center gap-1.5">
|
||||
<Coins className="w-3.5 h-3.5 text-emerald-400" />
|
||||
Financial Variance
|
||||
</span>
|
||||
<div className="flex items-baseline gap-2 mt-1">
|
||||
<span
|
||||
className={`text-xl font-bold font-mono ${
|
||||
isWithinBudget ? 'text-emerald-400' : 'text-rose-400'
|
||||
}`}
|
||||
>
|
||||
{isWithinBudget ? `+${formatZAR(financialVariance)}` : `-${formatZAR(Math.abs(financialVariance))}`}
|
||||
</span>
|
||||
</div>
|
||||
<span className="text-[10px] text-slate-500">
|
||||
Budget: {formatZAR(approvedBudgetTotal)}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div className="bg-[#0b101c] p-3.5 rounded-xl border border-slate-800">
|
||||
<span className="text-[10px] uppercase font-bold text-slate-400 flex items-center gap-1.5">
|
||||
<AlertTriangle className="w-3.5 h-3.5 text-amber-400" />
|
||||
Active Project Risks
|
||||
</span>
|
||||
<div className="flex items-baseline gap-2 mt-1">
|
||||
<span className="text-xl font-bold font-mono text-amber-400">{risks.length}</span>
|
||||
<span className="text-xs text-slate-400">
|
||||
({risks.filter((r) => (Number(r.likelihood) || 1) * (Number(r.impact) || 1) >= 12).length} High)
|
||||
</span>
|
||||
</div>
|
||||
<span className="text-[10px] text-slate-500 flex items-center gap-1">
|
||||
<CheckCircle2 className="w-3 h-3 text-cyan-400 inline" /> Milestones: {milestoneProgress}%
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Section 1: Statutory Compliance Requirements */}
|
||||
<div className="bg-[#0b101c] rounded-2xl border border-slate-800 overflow-hidden shadow-sm">
|
||||
<div className="p-4 border-b border-slate-800 flex items-center justify-between bg-[#0d1322]">
|
||||
<div className="flex items-center gap-2">
|
||||
<ShieldCheck className="w-4 h-4 text-cyan-400" />
|
||||
<h4 className="text-xs font-bold uppercase tracking-wider text-white">
|
||||
Statutory Compliance Requirements ({complianceItems.length})
|
||||
</h4>
|
||||
</div>
|
||||
<span className="text-[11px] text-slate-400">
|
||||
{metCompCount} of {complianceItems.length} Met / Exceeded
|
||||
</span>
|
||||
</div>
|
||||
|
||||
{loadingCompliance ? (
|
||||
<div className="p-8 text-center text-xs text-slate-400">Loading compliance register...</div>
|
||||
) : complianceItems.length === 0 ? (
|
||||
<div className="p-6 text-center text-xs text-slate-500">
|
||||
No specific statutory compliance requirements captured for this project yet.
|
||||
</div>
|
||||
) : (
|
||||
<div className="overflow-x-auto">
|
||||
<table className="w-full text-left text-xs border-collapse">
|
||||
<thead>
|
||||
<tr className="bg-slate-900/60 border-b border-slate-800 text-slate-400 text-[11px] font-semibold">
|
||||
<th className="py-2.5 px-4">Title</th>
|
||||
<th className="py-2.5 px-4">Requirements Specification</th>
|
||||
<th className="py-2.5 px-4">Linked Beneficiary</th>
|
||||
<th className="py-2.5 px-4">Status</th>
|
||||
<th className="py-2.5 px-4 text-right">Created</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody className="divide-y divide-slate-800/60">
|
||||
{complianceItems.map((ci) => {
|
||||
const linkedBen = projectBeneficiaries.find(
|
||||
(b) => String(b.id) === String(ci.beneficiaryId)
|
||||
);
|
||||
return (
|
||||
<tr key={ci.id} className="hover:bg-slate-900/30 transition">
|
||||
<td className="py-2.5 px-4 font-semibold text-white">{ci.title || 'Untitled Item'}</td>
|
||||
<td className="py-2.5 px-4 text-slate-300 max-w-sm truncate" title={ci.requirements || ''}>
|
||||
{ci.requirements || 'N/A'}
|
||||
</td>
|
||||
<td className="py-2.5 px-4 text-slate-300">
|
||||
{linkedBen ? (
|
||||
<span className="px-2 py-0.5 rounded bg-blue-500/10 border border-blue-500/20 text-blue-300 text-[11px]">
|
||||
{linkedBen.name || 'Beneficiary'}
|
||||
</span>
|
||||
) : (
|
||||
<span className="text-slate-500 italic">Project-Wide</span>
|
||||
)}
|
||||
</td>
|
||||
<td className="py-2.5 px-4">
|
||||
<ComplianceStatusBadge status={ci.status} />
|
||||
</td>
|
||||
<td className="py-2.5 px-4 text-right text-slate-400 font-mono text-[11px]">
|
||||
{formatDate(ci.createdAt)}
|
||||
</td>
|
||||
</tr>
|
||||
);
|
||||
})}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Section 2: Affected Beneficiaries & Mapped Outcomes */}
|
||||
<div className="bg-[#0b101c] rounded-2xl border border-slate-800 overflow-hidden shadow-sm">
|
||||
<div className="p-4 border-b border-slate-800 flex items-center justify-between bg-[#0d1322]">
|
||||
<div className="flex items-center gap-2">
|
||||
<Users className="w-4 h-4 text-blue-400" />
|
||||
<h4 className="text-xs font-bold uppercase tracking-wider text-white">
|
||||
Affected Beneficiaries & Mapped Impact Outcomes ({projectBeneficiaries.length})
|
||||
</h4>
|
||||
</div>
|
||||
<span className="text-[11px] text-slate-400">
|
||||
Total Target: {projectBeneficiaries.reduce((sum, b) => sum + (Number(b.targetCount) || 0), 0)} Reached:{' '}
|
||||
{projectBeneficiaries.reduce((sum, b) => sum + (Number(b.reachedCount) || 0), 0)}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
{loadingBeneficiaries || loadingOutcomes ? (
|
||||
<div className="p-8 text-center text-xs text-slate-400">Loading beneficiaries & outcomes...</div>
|
||||
) : projectBeneficiaries.length === 0 ? (
|
||||
<div className="p-6 text-center text-xs text-slate-500">
|
||||
No target beneficiaries mapped to this project yet.
|
||||
</div>
|
||||
) : (
|
||||
<div className="p-4 space-y-4">
|
||||
{projectBeneficiaries.map((b) => {
|
||||
const benOutcomes = outcomes.filter((o) => String(o.beneficiaryId) === String(b.id));
|
||||
return (
|
||||
<div
|
||||
key={b.id}
|
||||
className="p-3.5 rounded-xl bg-slate-900/60 border border-slate-800 space-y-3"
|
||||
>
|
||||
<div className="flex items-center justify-between flex-wrap gap-2">
|
||||
<div>
|
||||
<span className="font-bold text-white text-xs mr-2">{b.name || 'Beneficiary Group'}</span>
|
||||
<span className="text-[11px] text-slate-400 bg-slate-800 px-2 py-0.5 rounded border border-slate-700/60">
|
||||
{b.category || 'General'}{b.location ? ` • ${b.location}` : ''}
|
||||
</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-3 text-xs font-mono">
|
||||
<span className="text-slate-400">
|
||||
Target: <strong className="text-white">{b.targetCount ?? 0}</strong>
|
||||
</span>
|
||||
<span className="text-slate-600">•</span>
|
||||
<span className="text-slate-400">
|
||||
Reached: <strong className="text-emerald-400">{b.reachedCount ?? 0}</strong>
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Mapped Outcomes */}
|
||||
<div className="pt-2 border-t border-slate-800/80">
|
||||
<span className="text-[10px] uppercase font-bold text-slate-400 block mb-1.5">
|
||||
Mapped Expected Outcomes ({benOutcomes.length})
|
||||
</span>
|
||||
{benOutcomes.length === 0 ? (
|
||||
<p className="text-[11px] text-slate-500 italic">No expected outcomes recorded for this group.</p>
|
||||
) : (
|
||||
<div className="grid grid-cols-1 sm:grid-cols-2 gap-2">
|
||||
{benOutcomes.map((o) => (
|
||||
<div
|
||||
key={o.id}
|
||||
className="p-2.5 rounded-lg bg-slate-950/60 border border-slate-800/80 flex items-start justify-between gap-2"
|
||||
>
|
||||
<div className="min-w-0">
|
||||
<span className="font-semibold text-slate-200 text-xs block truncate">{o.name || 'Outcome'}</span>
|
||||
<span className="text-[10px] text-slate-400 line-clamp-1">{o.description || 'No description'}</span>
|
||||
</div>
|
||||
<ComplianceStatusBadge status={o.status} />
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Section 3: Risk Profile (ISO 31000) */}
|
||||
<div className="bg-[#0b101c] rounded-2xl border border-slate-800 overflow-hidden shadow-sm">
|
||||
<div className="p-4 border-b border-slate-800 flex items-center justify-between bg-[#0d1322]">
|
||||
<div className="flex items-center gap-2">
|
||||
<AlertTriangle className="w-4 h-4 text-amber-400" />
|
||||
<h4 className="text-xs font-bold uppercase tracking-wider text-white">
|
||||
Active Project Risks Register ({risks.length})
|
||||
</h4>
|
||||
</div>
|
||||
<span className="text-[11px] text-slate-400">ISO 31000 Risk Management</span>
|
||||
</div>
|
||||
|
||||
{loadingRisks ? (
|
||||
<div className="p-8 text-center text-xs text-slate-400">Loading risk register...</div>
|
||||
) : risks.length === 0 ? (
|
||||
<div className="p-6 text-center text-xs text-slate-500">
|
||||
No active risks logged on this project register.
|
||||
</div>
|
||||
) : (
|
||||
<div className="overflow-x-auto">
|
||||
<table className="w-full text-left text-xs border-collapse">
|
||||
<thead>
|
||||
<tr className="bg-slate-900/60 border-b border-slate-800 text-slate-400 text-[11px] font-semibold">
|
||||
<th className="py-2.5 px-4">Risk Name</th>
|
||||
<th className="py-2.5 px-4">Category / Status</th>
|
||||
<th className="py-2.5 px-4 text-center">Score</th>
|
||||
<th className="py-2.5 px-4">Mitigation Strategy</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody className="divide-y divide-slate-800/60">
|
||||
{risks.map((r) => {
|
||||
const score = (Number(r.likelihood) || 1) * (Number(r.impact) || 1);
|
||||
const scoreColor = getRiskScoreColor(r.likelihood, r.impact);
|
||||
return (
|
||||
<tr key={r.id} className="hover:bg-slate-900/30 transition">
|
||||
<td className="py-2.5 px-4 font-semibold text-white">{r.name || r.title || 'Unnamed Risk'}</td>
|
||||
<td className="py-2.5 px-4 text-slate-300">{r.category || r.status || 'Operational'}</td>
|
||||
<td className="py-2.5 px-4 text-center">
|
||||
<span
|
||||
className={`px-2 py-0.5 rounded text-[10px] font-bold ${scoreColor.badge}`}
|
||||
>
|
||||
{scoreColor.level} ({score})
|
||||
</span>
|
||||
</td>
|
||||
<td className="py-2.5 px-4 text-slate-300 max-w-sm truncate" title={r.mitigationAction || r.mitigation || r.description || ''}>
|
||||
{r.mitigationAction || r.mitigation || r.description || 'No mitigation recorded'}
|
||||
</td>
|
||||
</tr>
|
||||
);
|
||||
})}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Footer Actions */}
|
||||
<div className="flex justify-end pt-3 border-t border-slate-800">
|
||||
<button
|
||||
type="button"
|
||||
onClick={onClose}
|
||||
className="px-4 py-2 rounded-xl text-xs font-semibold text-slate-300 hover:text-white bg-slate-800 hover:bg-slate-700 transition"
|
||||
>
|
||||
Close Audit Inspector
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</Modal>
|
||||
);
|
||||
};
|
||||
@@ -91,21 +91,21 @@ export const ProjectExpandedRow: React.FC<ProjectExpandedRowProps> = ({
|
||||
const totalActivityBudget = activities.reduce((sum, a) => sum + (Number(a.amount) || 0), 0);
|
||||
|
||||
return (
|
||||
<div className="p-4 sm:p-5 lg:p-6 bg-[#090d16]/95 border-t border-b border-blue-500/20 space-y-6 animate-in fade-in duration-200 w-full min-w-0">
|
||||
<div className="p-5 sm:p-6 bg-[#090d16]/95 border-t border-b border-blue-500/20 space-y-6 animate-in fade-in duration-200">
|
||||
{/* Project Meta Bar */}
|
||||
<div className="flex flex-col lg:flex-row lg:items-center justify-between gap-4 p-4 rounded-2xl bg-[#0d121f] border border-slate-800 min-w-0">
|
||||
<div className="space-y-1 min-w-0 flex-1">
|
||||
<div className="flex flex-col md:flex-row md:items-center justify-between gap-4 p-4 rounded-2xl bg-[#0d121f] border border-slate-800">
|
||||
<div className="space-y-1 max-w-3xl">
|
||||
<div className="flex items-center gap-2 flex-wrap">
|
||||
<span className="text-[10px] font-mono uppercase tracking-wider text-blue-400 bg-blue-500/10 px-2.5 py-0.5 rounded-md border border-blue-500/30 shrink-0">
|
||||
<span className="text-[10px] font-mono uppercase tracking-wider text-blue-400 bg-blue-500/10 px-2.5 py-0.5 rounded-md border border-blue-500/30">
|
||||
Project #{project.id} Summary
|
||||
</span>
|
||||
<span className="text-xs text-slate-400 truncate">
|
||||
<span className="text-xs text-slate-400">
|
||||
Owner / Created by: <strong className="text-slate-200">{getUserEmail(project.createdBy)}</strong>
|
||||
</span>
|
||||
{approvedBudgetTotal > 0 && (
|
||||
<>
|
||||
<span className="text-slate-600">•</span>
|
||||
<span className="text-xs text-emerald-400 font-mono font-semibold shrink-0">
|
||||
<span className="text-xs text-emerald-400 font-mono font-semibold">
|
||||
Approved Budget: {formatZAR(approvedBudgetTotal)}
|
||||
</span>
|
||||
</>
|
||||
@@ -114,10 +114,10 @@ export const ProjectExpandedRow: React.FC<ProjectExpandedRowProps> = ({
|
||||
<p className="text-xs text-slate-300 leading-relaxed">{project.description}</p>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-2 shrink-0 self-start lg:self-center flex-wrap">
|
||||
<div className="flex items-center gap-2 shrink-0 self-start md:self-center flex-wrap">
|
||||
<Link
|
||||
to={`/monitoring?tab=evidence&projectId=${project.id}`}
|
||||
className="inline-flex items-center gap-1.5 px-3 py-1.5 text-xs font-semibold text-blue-300 hover:text-white bg-blue-500/10 hover:bg-blue-500/20 border border-blue-500/30 rounded-xl transition cursor-pointer shrink-0"
|
||||
className="inline-flex items-center gap-1.5 px-3 py-1.5 text-xs font-semibold text-blue-300 hover:text-white bg-blue-500/10 hover:bg-blue-500/20 border border-blue-500/30 rounded-xl transition cursor-pointer"
|
||||
>
|
||||
<FileCheck className="w-3.5 h-3.5 text-blue-400" />
|
||||
Review Evidence Queue
|
||||
@@ -125,7 +125,7 @@ export const ProjectExpandedRow: React.FC<ProjectExpandedRowProps> = ({
|
||||
<button
|
||||
type="button"
|
||||
onClick={onOpenEditProject}
|
||||
className="inline-flex items-center gap-1.5 px-3 py-1.5 text-xs font-semibold text-slate-300 hover:text-white bg-slate-800 hover:bg-slate-700 border border-slate-700 rounded-xl transition cursor-pointer shrink-0"
|
||||
className="inline-flex items-center gap-1.5 px-3 py-1.5 text-xs font-semibold text-slate-300 hover:text-white bg-slate-800 hover:bg-slate-700 border border-slate-700 rounded-xl transition cursor-pointer"
|
||||
>
|
||||
<Edit2 className="w-3.5 h-3.5 text-slate-400" />
|
||||
Edit Project Scope
|
||||
@@ -145,9 +145,9 @@ export const ProjectExpandedRow: React.FC<ProjectExpandedRowProps> = ({
|
||||
/>
|
||||
|
||||
{/* 4 Vital Resource Summary Cards Grid */}
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 2xl:grid-cols-4 gap-4 min-w-0">
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-4 gap-4">
|
||||
{/* Card 1: Milestones */}
|
||||
<div className="bg-[#0d121f] rounded-2xl border border-slate-800 p-4 flex flex-col justify-between space-y-3 min-w-0">
|
||||
<div className="bg-[#0d121f] rounded-2xl border border-slate-800 p-4 flex flex-col justify-between space-y-3">
|
||||
<div className="space-y-2.5">
|
||||
<div className="flex items-center justify-between border-b border-slate-800/80 pb-2">
|
||||
<div className="flex items-center gap-2 text-xs font-bold text-white">
|
||||
@@ -173,14 +173,9 @@ export const ProjectExpandedRow: React.FC<ProjectExpandedRowProps> = ({
|
||||
{sBadge.label}
|
||||
</span>
|
||||
</div>
|
||||
<div className="flex items-center justify-between text-[10px] text-slate-400 font-mono">
|
||||
<span className="flex items-center gap-1">
|
||||
<div className="text-[10px] text-slate-400 flex items-center gap-1 font-mono">
|
||||
<Clock className="w-3 h-3 text-slate-500" />
|
||||
Due: {formatDate(m.dueAt)}
|
||||
</span>
|
||||
<span className="text-blue-400 font-bold">
|
||||
{formatZAR(activities.filter(a => String(a.milestoneId) === String(m.id)).reduce((s, a) => s + (Number(a.amount) || 0), 0))}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
@@ -203,7 +198,7 @@ export const ProjectExpandedRow: React.FC<ProjectExpandedRowProps> = ({
|
||||
</div>
|
||||
|
||||
{/* Card 2: Activities */}
|
||||
<div className="bg-[#0d121f] rounded-2xl border border-slate-800 p-4 flex flex-col justify-between space-y-3 min-w-0">
|
||||
<div className="bg-[#0d121f] rounded-2xl border border-slate-800 p-4 flex flex-col justify-between space-y-3">
|
||||
<div className="space-y-2.5">
|
||||
<div className="flex items-center justify-between border-b border-slate-800/80 pb-2">
|
||||
<div className="flex items-center gap-2 text-xs font-bold text-white">
|
||||
@@ -249,7 +244,7 @@ export const ProjectExpandedRow: React.FC<ProjectExpandedRowProps> = ({
|
||||
</div>
|
||||
|
||||
{/* Card 3: Indicators */}
|
||||
<div className="bg-[#0d121f] rounded-2xl border border-slate-800 p-4 flex flex-col justify-between space-y-3 min-w-0">
|
||||
<div className="bg-[#0d121f] rounded-2xl border border-slate-800 p-4 flex flex-col justify-between space-y-3">
|
||||
<div className="space-y-2.5">
|
||||
<div className="flex items-center justify-between border-b border-slate-800/80 pb-2">
|
||||
<div className="flex items-center gap-2 text-xs font-bold text-white">
|
||||
@@ -302,7 +297,7 @@ export const ProjectExpandedRow: React.FC<ProjectExpandedRowProps> = ({
|
||||
</div>
|
||||
|
||||
{/* Card 4: Risks */}
|
||||
<div className="bg-[#0d121f] rounded-2xl border border-slate-800 p-4 flex flex-col justify-between space-y-3 min-w-0">
|
||||
<div className="bg-[#0d121f] rounded-2xl border border-slate-800 p-4 flex flex-col justify-between space-y-3">
|
||||
<div className="space-y-2.5">
|
||||
<div className="flex items-center justify-between border-b border-slate-800/80 pb-2">
|
||||
<div className="flex items-center gap-2 text-xs font-bold text-white">
|
||||
|
||||
@@ -47,7 +47,7 @@ export const ProjectFormModal: React.FC<ProjectFormModalProps> = ({
|
||||
const { success, error: toastError } = useToast();
|
||||
|
||||
const form = useForm<ProjectFormValues>({
|
||||
resolver: zodResolver(projectSchema),
|
||||
resolver: zodResolver(projectSchema) as any,
|
||||
defaultValues: {
|
||||
name: '',
|
||||
description: '',
|
||||
@@ -125,7 +125,7 @@ export const ProjectFormModal: React.FC<ProjectFormModalProps> = ({
|
||||
helpText="Register or modify a funded project. Ensure the correct parent programme and industrial sector are assigned to preserve statutory reporting compliance."
|
||||
maxWidth="lg"
|
||||
>
|
||||
<form onSubmit={form.handleSubmit((data) => mutation.mutate(data))} className="space-y-4">
|
||||
<form onSubmit={form.handleSubmit((data) => mutation.mutate(data as any))} className="space-y-4">
|
||||
<div>
|
||||
<div className="flex items-center gap-1 mb-1">
|
||||
<label className="block text-xs font-semibold text-slate-300">Project Name *</label>
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import React, { useState, useMemo } from 'react';
|
||||
import React, { useState } from 'react';
|
||||
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
|
||||
import { useForm } from 'react-hook-form';
|
||||
import { zodResolver } from '@hookform/resolvers/zod';
|
||||
@@ -9,12 +9,8 @@ import {
|
||||
TrendingUp,
|
||||
X,
|
||||
RotateCcw,
|
||||
AlertTriangle,
|
||||
Briefcase,
|
||||
Filter,
|
||||
Info,
|
||||
ShieldCheck,
|
||||
Coins,
|
||||
Check,
|
||||
Edit3,
|
||||
} from 'lucide-react';
|
||||
import { Modal } from '../ui/Modal';
|
||||
import { HelpTooltip } from '../ui/HelpTooltip';
|
||||
@@ -22,18 +18,16 @@ import { useToast } from '../ui/Toast';
|
||||
import {
|
||||
getProjectIndicators,
|
||||
createIndicator,
|
||||
getActivities,
|
||||
recordIndicatorActual,
|
||||
} from '../../services/api';
|
||||
import { Project, Indicator, UnitOfMeasure, Activity } from '../../types';
|
||||
import { getUnitOfMeasureLabel, UNIT_OF_MEASURE_OPTIONS, formatZAR } from '../../lib/formatters';
|
||||
import { Project, Indicator, UnitOfMeasure } from '../../types';
|
||||
import { getUnitOfMeasureLabel, UNIT_OF_MEASURE_OPTIONS } from '../../lib/formatters';
|
||||
|
||||
const indicatorSchema = z.object({
|
||||
name: z.string().min(4, 'Indicator delivery specification required (min 4 chars)'),
|
||||
activityId: z.string().optional().nullable(),
|
||||
name: z.string().min(5, 'Indicator definition required (min 5 chars)'),
|
||||
unitOfMeasure: z.coerce.number().min(1),
|
||||
baselineAmount: z.coerce.number().min(0, 'Baseline must be >= 0'),
|
||||
targetAmount: z.coerce.number().min(1, 'Target must be > 0'),
|
||||
targetCost: z.coerce.number().min(0, 'Target cost must be >= 0').optional(),
|
||||
});
|
||||
|
||||
type IndicatorFormValues = z.infer<typeof indicatorSchema>;
|
||||
@@ -42,26 +36,25 @@ interface ProjectIndicatorsModalProps {
|
||||
isOpen: boolean;
|
||||
onClose: () => void;
|
||||
project: Project | null;
|
||||
initialActivityId?: string | number | null;
|
||||
}
|
||||
|
||||
export const ProjectIndicatorsModal: React.FC<ProjectIndicatorsModalProps> = ({
|
||||
isOpen,
|
||||
onClose,
|
||||
project,
|
||||
initialActivityId = null,
|
||||
}) => {
|
||||
const queryClient = useQueryClient();
|
||||
const { success, error: toastError } = useToast();
|
||||
|
||||
const [isFormOpen, setIsFormOpen] = useState(false);
|
||||
const [selectedActivityFilter, setSelectedActivityFilter] = useState<string>('all');
|
||||
const [recordingActualForId, setRecordingActualForId] = useState<string | number | null>(null);
|
||||
const [actualInput, setActualInput] = useState<number>(0);
|
||||
|
||||
const projectId = project?.id;
|
||||
|
||||
const {
|
||||
data: indicators = [],
|
||||
isLoading: isLoadingIndicators,
|
||||
isLoading,
|
||||
refetch,
|
||||
} = useQuery({
|
||||
queryKey: ['indicators', projectId],
|
||||
@@ -69,49 +62,31 @@ export const ProjectIndicatorsModal: React.FC<ProjectIndicatorsModalProps> = ({
|
||||
enabled: isOpen && !!projectId,
|
||||
});
|
||||
|
||||
const {
|
||||
data: activities = [],
|
||||
isLoading: isLoadingActivities,
|
||||
} = useQuery({
|
||||
queryKey: ['activities', projectId],
|
||||
queryFn: () => (projectId ? getActivities(projectId) : Promise.resolve([])),
|
||||
enabled: isOpen && !!projectId,
|
||||
});
|
||||
|
||||
const form = useForm<IndicatorFormValues>({
|
||||
resolver: zodResolver(indicatorSchema),
|
||||
resolver: zodResolver(indicatorSchema) as any,
|
||||
defaultValues: {
|
||||
name: '',
|
||||
activityId: initialActivityId ? String(initialActivityId) : '',
|
||||
unitOfMeasure: UnitOfMeasure.Count,
|
||||
baselineAmount: 0,
|
||||
targetAmount: 100,
|
||||
targetCost: 0,
|
||||
},
|
||||
});
|
||||
|
||||
const createMutation = useMutation({
|
||||
mutationFn: async (data: IndicatorFormValues) => {
|
||||
if (!projectId) return;
|
||||
const parsedActivityId = data.activityId && String(data.activityId).trim() !== ''
|
||||
? data.activityId
|
||||
: null;
|
||||
|
||||
return createIndicator({
|
||||
projectId: projectId,
|
||||
activityId: parsedActivityId,
|
||||
name: data.name,
|
||||
unitOfMeasure: Number(data.unitOfMeasure),
|
||||
baselineAmount: Number(data.baselineAmount),
|
||||
targetAmount: Number(data.targetAmount),
|
||||
targetCost: Number(data.targetCost) || 0,
|
||||
});
|
||||
},
|
||||
onSuccess: () => {
|
||||
queryClient.invalidateQueries({ queryKey: ['indicators', projectId] });
|
||||
queryClient.invalidateQueries({ queryKey: ['indicators'] });
|
||||
queryClient.invalidateQueries({ queryKey: ['activities', projectId] });
|
||||
success('Delivery Indicator Created', 'Indicator specification linked to work activity');
|
||||
success('Indicator Created', 'M&E Key Performance Indicator added to project');
|
||||
setIsFormOpen(false);
|
||||
form.reset();
|
||||
},
|
||||
@@ -120,22 +95,20 @@ export const ProjectIndicatorsModal: React.FC<ProjectIndicatorsModalProps> = ({
|
||||
},
|
||||
});
|
||||
|
||||
// Filter indicators by activity
|
||||
const filteredIndicators = useMemo(() => {
|
||||
if (selectedActivityFilter === 'all') return indicators;
|
||||
if (selectedActivityFilter === 'unlinked') return indicators.filter((i) => !i.activityId);
|
||||
return indicators.filter((i) => String(i.activityId) === String(selectedActivityFilter));
|
||||
}, [indicators, selectedActivityFilter]);
|
||||
|
||||
// Aggregate totals
|
||||
const totalTargetCost = useMemo(
|
||||
() => indicators.reduce((sum, i) => sum + (Number(i.targetCost) || 0), 0),
|
||||
[indicators]
|
||||
);
|
||||
const totalActualCost = useMemo(
|
||||
() => indicators.reduce((sum, i) => sum + (Number(i.actualCost) || 0), 0),
|
||||
[indicators]
|
||||
);
|
||||
const actualMutation = useMutation({
|
||||
mutationFn: async ({ indicatorId, actualAmount }: { indicatorId: string | number; actualAmount: number }) => {
|
||||
return recordIndicatorActual(indicatorId, actualAmount);
|
||||
},
|
||||
onSuccess: () => {
|
||||
queryClient.invalidateQueries({ queryKey: ['indicators', projectId] });
|
||||
queryClient.invalidateQueries({ queryKey: ['indicators'] });
|
||||
success('Actual Value Logged', 'M&E progress measurement updated successfully');
|
||||
setRecordingActualForId(null);
|
||||
},
|
||||
onError: (err: unknown) => {
|
||||
toastError('Failed to record actual value', err);
|
||||
},
|
||||
});
|
||||
|
||||
if (!project) return null;
|
||||
|
||||
@@ -144,86 +117,34 @@ export const ProjectIndicatorsModal: React.FC<ProjectIndicatorsModalProps> = ({
|
||||
isOpen={isOpen}
|
||||
onClose={() => {
|
||||
setIsFormOpen(false);
|
||||
setRecordingActualForId(null);
|
||||
onClose();
|
||||
}}
|
||||
title={`M&E Delivery Indicators: ${project.name}`}
|
||||
subtitle={`Project #${project.id} • Activity Delivery Specifications & Evidence Requirements`}
|
||||
helpText="Each indicator specifies an expected delivery output linked to an operational activity. Quantities and material costs are captured as actuals by the partner in the Partner Portal."
|
||||
title={`M&E Performance Indicators: ${project.name}`}
|
||||
subtitle={`Project #${project.id} • Results & Impact Framework`}
|
||||
helpText="Quantitative indicators monitor baseline vs. target deliverables (e.g. jobs created, patents filed, rural SMMEs trained, renewable MW generated)."
|
||||
maxWidth="xl"
|
||||
>
|
||||
<div className="space-y-5">
|
||||
{/* KPI Summary Cards */}
|
||||
<div className="grid grid-cols-1 sm:grid-cols-3 gap-3">
|
||||
<div className="bg-[#0b101c] p-3.5 rounded-xl border border-slate-800">
|
||||
{/* KPI Summary Card */}
|
||||
<div className="bg-[#0b101c] p-4 rounded-xl border border-slate-800/90 flex flex-col sm:flex-row sm:items-center justify-between gap-4">
|
||||
<div className="space-y-1">
|
||||
<span className="text-[10px] uppercase font-bold text-slate-400 flex items-center gap-1.5">
|
||||
<Target className="w-3.5 h-3.5 text-indigo-400" />
|
||||
Delivery Indicators
|
||||
Monitoring & Evaluation Targets
|
||||
</span>
|
||||
<div className="flex items-baseline gap-2 mt-1">
|
||||
<span className="text-xl font-bold text-white font-mono">{indicators.length}</span>
|
||||
<span className="text-[11px] text-slate-400">specifications</span>
|
||||
<div className="flex items-baseline gap-2">
|
||||
<span className="text-2xl font-bold text-white">{indicators.length}</span>
|
||||
<span className="text-xs text-slate-400">Tracked performance metrics</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="bg-[#0b101c] p-3.5 rounded-xl border border-slate-800">
|
||||
<span className="text-[10px] uppercase font-bold text-slate-400 flex items-center gap-1.5">
|
||||
<Coins className="w-3.5 h-3.5 text-cyan-400" />
|
||||
Target Delivery Cost
|
||||
</span>
|
||||
<div className="flex items-baseline gap-2 mt-1">
|
||||
<span className="text-xl font-bold font-mono text-cyan-400">{formatZAR(totalTargetCost)}</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="bg-[#0b101c] p-3.5 rounded-xl border border-slate-800">
|
||||
<span className="text-[10px] uppercase font-bold text-slate-400 flex items-center gap-1.5">
|
||||
<TrendingUp className="w-3.5 h-3.5 text-emerald-400" />
|
||||
Partner Actuals Incurred
|
||||
</span>
|
||||
<div className="flex items-baseline gap-2 mt-1">
|
||||
<span className={`text-xl font-bold font-mono ${totalActualCost > totalTargetCost && totalTargetCost > 0 ? 'text-amber-400' : 'text-emerald-400'}`}>
|
||||
{formatZAR(totalActualCost)}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Action Header & Activity Filter Bar */}
|
||||
<div className="flex flex-col sm:flex-row sm:items-center justify-between gap-3 bg-[#0d121f] p-3 rounded-xl border border-slate-800">
|
||||
<div className="flex items-center gap-2 flex-1">
|
||||
<Filter className="w-3.5 h-3.5 text-slate-400 shrink-0" />
|
||||
<span className="text-xs text-slate-400 shrink-0">Filter by Activity:</span>
|
||||
<select
|
||||
value={selectedActivityFilter}
|
||||
onChange={(e) => setSelectedActivityFilter(e.target.value)}
|
||||
className="px-2.5 py-1 text-xs border border-slate-700/80 rounded-lg focus:outline-none focus:ring-1 focus:ring-indigo-500 bg-[#090d16] text-white max-w-xs truncate"
|
||||
>
|
||||
<option value="all">All Activities ({indicators.length})</option>
|
||||
{activities.map((act) => {
|
||||
const count = indicators.filter((i) => String(i.activityId) === String(act.id)).length;
|
||||
return (
|
||||
<option key={act.id} value={String(act.id)}>
|
||||
{act.title} ({count} indicators)
|
||||
</option>
|
||||
);
|
||||
})}
|
||||
<option value="unlinked">Unlinked General Indicators</option>
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-2">
|
||||
{!isFormOpen && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => {
|
||||
form.reset({
|
||||
name: '',
|
||||
activityId: selectedActivityFilter !== 'all' && selectedActivityFilter !== 'unlinked' ? selectedActivityFilter : '',
|
||||
unitOfMeasure: UnitOfMeasure.Count,
|
||||
baselineAmount: 0,
|
||||
targetAmount: 100,
|
||||
targetCost: 0,
|
||||
});
|
||||
form.reset();
|
||||
setIsFormOpen(true);
|
||||
}}
|
||||
className="inline-flex items-center gap-1.5 px-3.5 py-2 text-xs font-semibold text-white bg-gradient-to-r from-indigo-600 to-blue-600 hover:from-indigo-500 hover:to-blue-500 rounded-xl shadow-md shadow-indigo-600/20 transition cursor-pointer"
|
||||
@@ -235,13 +156,13 @@ export const ProjectIndicatorsModal: React.FC<ProjectIndicatorsModalProps> = ({
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Indicator Create Form */}
|
||||
{/* Create Indicator Form */}
|
||||
{isFormOpen && (
|
||||
<div className="bg-[#0d121f] p-4 rounded-xl border border-indigo-500/30 space-y-4 animate-in fade-in">
|
||||
<div className="flex items-center justify-between border-b border-slate-800 pb-2.5">
|
||||
<span className="text-xs font-bold text-indigo-400 flex items-center gap-1.5">
|
||||
<Target className="w-3.5 h-3.5" />
|
||||
Define Activity Delivery Indicator & Evidence Expectation
|
||||
Define New Key Performance Indicator (KPI)
|
||||
</span>
|
||||
<button
|
||||
type="button"
|
||||
@@ -252,43 +173,21 @@ export const ProjectIndicatorsModal: React.FC<ProjectIndicatorsModalProps> = ({
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<form onSubmit={form.handleSubmit((data) => createMutation.mutate(data))} className="space-y-3">
|
||||
<form onSubmit={form.handleSubmit((data) => createMutation.mutate(data as any))} className="space-y-3">
|
||||
<div>
|
||||
<label className="block text-xs font-semibold text-slate-300 mb-1">
|
||||
Indicator Name / Delivery Output Specification *
|
||||
</label>
|
||||
<label className="block text-xs font-semibold text-slate-300 mb-1">Indicator Definition *</label>
|
||||
<input
|
||||
type="text"
|
||||
{...form.register('name')}
|
||||
className="w-full px-3 py-2 text-xs border border-slate-700/80 rounded-xl focus:outline-none focus:ring-1 focus:ring-indigo-500 text-white bg-[#090d16]"
|
||||
placeholder="e.g. Number of solar microgrids commissioned and tested"
|
||||
placeholder="e.g. Number of Smallholder Farmers Trained in Precision Irrigation"
|
||||
/>
|
||||
{form.formState.errors.name && (
|
||||
<p className="text-[10px] text-rose-500 mt-1">{form.formState.errors.name.message}</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="block text-xs font-semibold text-slate-300 mb-1">
|
||||
Parent Activity (Required Delivery Link) *
|
||||
</label>
|
||||
<select
|
||||
{...form.register('activityId')}
|
||||
className="w-full px-3 py-2 text-xs border border-slate-700/80 rounded-xl focus:outline-none focus:ring-1 focus:ring-indigo-500 bg-[#090d16] text-white font-medium"
|
||||
>
|
||||
<option value="">-- Select Parent Activity --</option>
|
||||
{activities.map((act) => (
|
||||
<option key={act.id} value={String(act.id)}>
|
||||
{act.title} (Budget: {formatZAR(act.amount)})
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
<p className="text-[10px] text-slate-500 mt-1">
|
||||
Linking to an activity establishes the causal delivery chain from planned task to substantiated output.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-1 sm:grid-cols-2 gap-3">
|
||||
<div className="grid grid-cols-1 sm:grid-cols-3 gap-3">
|
||||
<div>
|
||||
<label className="block text-xs font-semibold text-slate-300 mb-1">Unit of Measure *</label>
|
||||
<select
|
||||
@@ -304,47 +203,32 @@ export const ProjectIndicatorsModal: React.FC<ProjectIndicatorsModalProps> = ({
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="block text-xs font-semibold text-slate-300 mb-1">
|
||||
Expected Delivery Cost (Target Cost ZAR)
|
||||
</label>
|
||||
<label className="block text-xs font-semibold text-slate-300 mb-1">Baseline Amount *</label>
|
||||
<input
|
||||
type="number"
|
||||
step="0.01"
|
||||
{...form.register('targetCost')}
|
||||
className="w-full px-3 py-2 text-xs border border-slate-700/80 rounded-xl focus:outline-none focus:ring-1 focus:ring-indigo-500 text-white font-mono bg-[#090d16]"
|
||||
placeholder="0.00"
|
||||
/>
|
||||
<p className="text-[10px] text-slate-500 mt-1">Estimated monetary cost for delivering this indicator.</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-1 sm:grid-cols-2 gap-3">
|
||||
<div>
|
||||
<label className="block text-xs font-semibold text-slate-300 mb-1">Baseline Metric *</label>
|
||||
<input
|
||||
type="number"
|
||||
step="any"
|
||||
{...form.register('baselineAmount')}
|
||||
className="w-full px-3 py-2 text-xs border border-slate-700/80 rounded-xl focus:outline-none focus:ring-1 focus:ring-indigo-500 text-white font-mono bg-[#090d16]"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="block text-xs font-semibold text-slate-300 mb-1">Target Metric *</label>
|
||||
<label className="block text-xs font-semibold text-slate-300 mb-1">Target Amount *</label>
|
||||
<input
|
||||
type="number"
|
||||
step="any"
|
||||
{...form.register('targetAmount')}
|
||||
className="w-full px-3 py-2 text-xs border border-slate-700/80 rounded-xl focus:outline-none focus:ring-1 focus:ring-indigo-500 text-white font-mono bg-[#090d16]"
|
||||
/>
|
||||
{form.formState.errors.targetAmount && (
|
||||
<p className="text-[10px] text-rose-500 mt-1">{form.formState.errors.targetAmount.message}</p>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center justify-end gap-2 pt-2 border-t border-slate-800">
|
||||
<div className="pt-2 flex justify-end gap-2 border-t border-slate-800">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setIsFormOpen(false)}
|
||||
className="px-3 py-1.5 text-xs font-semibold text-slate-300 bg-slate-800 hover:bg-slate-700 rounded-xl cursor-pointer"
|
||||
className="px-3.5 py-1.5 text-xs font-semibold text-slate-300 bg-slate-800 hover:bg-slate-700 rounded-xl cursor-pointer"
|
||||
>
|
||||
Cancel
|
||||
</button>
|
||||
@@ -353,27 +237,17 @@ export const ProjectIndicatorsModal: React.FC<ProjectIndicatorsModalProps> = ({
|
||||
disabled={createMutation.isPending}
|
||||
className="px-4 py-1.5 text-xs font-semibold text-white bg-gradient-to-r from-indigo-600 to-blue-600 hover:from-indigo-500 hover:to-blue-500 rounded-xl shadow-md shadow-indigo-500/20 cursor-pointer"
|
||||
>
|
||||
{createMutation.isPending ? 'Saving...' : 'Define Indicator'}
|
||||
{createMutation.isPending ? 'Saving...' : 'Add Indicator'}
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Notice Card Explaining Partner Action */}
|
||||
<div className="bg-slate-900/60 p-3 rounded-xl border border-slate-800 flex items-start gap-2.5 text-slate-400 text-[11px] leading-relaxed">
|
||||
<Info className="w-4 h-4 text-cyan-400 shrink-0 mt-0.5" />
|
||||
<div>
|
||||
<strong className="text-slate-200">Maker-Checker Operational Notice:</strong> ConsoleUI enables Project Managers to establish output specifications and expected delivery costs. Actual item quantities, material expenditures, and verification evidence documents are recorded by the Grantee/Partner via the <strong className="text-cyan-400 font-mono">PartnerUI Portal</strong>.
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Indicators List */}
|
||||
<div className="space-y-2.5">
|
||||
<div className="flex items-center justify-between text-xs font-semibold text-slate-400 px-1">
|
||||
<span>
|
||||
Defined Delivery Specifications ({filteredIndicators.length} of {indicators.length})
|
||||
</span>
|
||||
<span>Performance Target Registry ({indicators.length})</span>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => refetch()}
|
||||
@@ -383,77 +257,49 @@ export const ProjectIndicatorsModal: React.FC<ProjectIndicatorsModalProps> = ({
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{isLoadingIndicators || isLoadingActivities ? (
|
||||
{isLoading ? (
|
||||
<div className="p-8 text-center text-xs text-slate-400 bg-[#0b101c] rounded-xl border border-slate-800">
|
||||
Loading indicators...
|
||||
</div>
|
||||
) : filteredIndicators.length === 0 ? (
|
||||
) : indicators.length === 0 ? (
|
||||
<div className="p-8 text-center bg-[#0b101c] rounded-xl border border-dashed border-slate-800 text-slate-400 space-y-2">
|
||||
<Target className="w-8 h-8 text-slate-600 mx-auto" />
|
||||
<p className="text-xs font-semibold text-slate-300">No indicators matching selection</p>
|
||||
<p className="text-[11px] text-slate-500">
|
||||
Define delivery output specifications and link them to operational activities.
|
||||
</p>
|
||||
<p className="text-xs font-semibold text-slate-300">No M&E indicators defined yet</p>
|
||||
<p className="text-[11px] text-slate-500">Track quantitative impact metrics against established baselines.</p>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setIsFormOpen(true)}
|
||||
className="mt-2 inline-flex items-center gap-1 text-xs font-semibold text-indigo-400 hover:underline"
|
||||
>
|
||||
<Plus className="w-3.5 h-3.5" /> Define first indicator
|
||||
<Plus className="w-3.5 h-3.5" /> Add first indicator
|
||||
</button>
|
||||
</div>
|
||||
) : (
|
||||
<div className="space-y-3 max-h-[420px] overflow-y-auto pr-1">
|
||||
{filteredIndicators.map((ind) => {
|
||||
<div className="space-y-2.5 max-h-[380px] overflow-y-auto pr-1">
|
||||
{indicators.map((ind) => {
|
||||
const target = Number(ind.targetAmount) || 1;
|
||||
const actual = Number(ind.actualAmount) || 0;
|
||||
const baseline = Number(ind.baselineAmount) || 0;
|
||||
const targetCost = Number(ind.targetCost) || 0;
|
||||
const actualCost = Number(ind.actualCost) || 0;
|
||||
const pct = Math.round((actual / target) * 100);
|
||||
|
||||
const parentActivity = activities.find((a) => String(a.id) === String(ind.activityId));
|
||||
const isOverrun = (actualCost > targetCost && targetCost > 0) || (parentActivity && actualCost > parentActivity.amount);
|
||||
const isRecordingThis = recordingActualForId === ind.id;
|
||||
|
||||
return (
|
||||
<div
|
||||
key={ind.id}
|
||||
className={`p-4 rounded-xl border bg-[#0d121f] transition space-y-3 ${
|
||||
isOverrun ? 'border-amber-500/40 bg-amber-950/10' : 'border-slate-800 hover:border-slate-700'
|
||||
}`}
|
||||
className="p-4 rounded-xl border border-slate-800 bg-[#0d121f] hover:border-slate-700 transition space-y-3"
|
||||
>
|
||||
<div className="flex flex-col sm:flex-row sm:items-start justify-between gap-2">
|
||||
<div className="space-y-1">
|
||||
<div className="flex flex-col sm:flex-row sm:items-center justify-between gap-2">
|
||||
<div>
|
||||
<div className="flex items-center gap-2 flex-wrap">
|
||||
<span className="text-xs font-bold text-white">{ind.name}</span>
|
||||
<span className="bg-slate-900 border border-slate-700 text-slate-300 px-2 py-0.5 rounded-md text-[10px]">
|
||||
{getUnitOfMeasureLabel(ind.unitOfMeasure)}
|
||||
</span>
|
||||
{parentActivity ? (
|
||||
<span className="bg-cyan-500/10 border border-cyan-500/30 text-cyan-300 px-2 py-0.5 rounded-md text-[10px] flex items-center gap-1">
|
||||
<Briefcase className="w-2.5 h-2.5" />
|
||||
{parentActivity.title}
|
||||
</span>
|
||||
) : (
|
||||
<span className="bg-slate-800 text-slate-400 px-2 py-0.5 rounded-md text-[10px]">
|
||||
General Project Indicator
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
<div className="text-[10px] text-slate-500 font-mono">
|
||||
Indicator ID #{ind.id}
|
||||
{ind.activityId && ` • Activity #${ind.activityId}`}
|
||||
</div>
|
||||
<span className="text-[10px] text-slate-500 font-mono">Indicator ID #{ind.id}</span>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-2 shrink-0">
|
||||
{isOverrun && (
|
||||
<span className="inline-flex items-center gap-1 px-2 py-0.5 rounded-md bg-rose-500/10 text-rose-400 border border-rose-500/30 text-[10px] font-bold">
|
||||
<AlertTriangle className="w-3 h-3 text-rose-400" />
|
||||
Cost Overrun
|
||||
</span>
|
||||
)}
|
||||
|
||||
<div className="flex items-center gap-2">
|
||||
<span
|
||||
className={`text-xs font-bold px-2 py-0.5 rounded-md border ${
|
||||
pct >= 100
|
||||
@@ -463,20 +309,34 @@ export const ProjectIndicatorsModal: React.FC<ProjectIndicatorsModalProps> = ({
|
||||
: 'bg-amber-500/10 text-amber-400 border-amber-500/30'
|
||||
}`}
|
||||
>
|
||||
{pct}% Delivered
|
||||
{pct}% Achieved
|
||||
</span>
|
||||
|
||||
<span
|
||||
className="inline-flex items-center gap-1 text-[10px] text-slate-400 bg-slate-900/90 border border-slate-700/80 px-2 py-0.5 rounded-md"
|
||||
title="Actuals are recorded by the partner in PartnerUI"
|
||||
{!isRecordingThis ? (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => {
|
||||
setRecordingActualForId(ind.id);
|
||||
setActualInput(actual);
|
||||
}}
|
||||
className="inline-flex items-center gap-1 text-[11px] font-semibold text-indigo-300 hover:text-white bg-indigo-500/10 hover:bg-indigo-500/20 border border-indigo-500/25 px-2.5 py-1 rounded-lg transition cursor-pointer"
|
||||
>
|
||||
<ShieldCheck className="w-3 h-3 text-cyan-400" />
|
||||
Partner Verified
|
||||
</span>
|
||||
<Edit3 className="w-3 h-3 text-indigo-400" />
|
||||
Record Actual
|
||||
</button>
|
||||
) : (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setRecordingActualForId(null)}
|
||||
className="text-slate-400 hover:text-slate-200 text-xs p-1"
|
||||
>
|
||||
<X className="w-4 h-4" />
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Deliverable Units Progress Bar */}
|
||||
{/* Progress Bar */}
|
||||
<div className="space-y-1">
|
||||
<div className="w-full h-2 bg-slate-900 rounded-full overflow-hidden border border-slate-800">
|
||||
<div
|
||||
@@ -491,45 +351,40 @@ export const ProjectIndicatorsModal: React.FC<ProjectIndicatorsModalProps> = ({
|
||||
/>
|
||||
</div>
|
||||
<div className="flex justify-between text-[11px] text-slate-400">
|
||||
<span>
|
||||
Baseline: <strong className="text-slate-300 font-mono">{baseline}</strong>
|
||||
</span>
|
||||
<span>
|
||||
Partner Actuals Reported:{' '}
|
||||
<strong className="text-white font-mono font-bold">{actual}</strong>
|
||||
</span>
|
||||
<span>
|
||||
Target: <strong className="text-indigo-400 font-mono">{target}</strong>
|
||||
</span>
|
||||
<span>Baseline: <strong className="text-slate-300 font-mono">{baseline}</strong></span>
|
||||
<span>Actual: <strong className="text-white font-mono font-bold">{actual}</strong></span>
|
||||
<span>Target: <strong className="text-indigo-400 font-mono">{target}</strong></span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Financial Cost Specification & Actuals Row */}
|
||||
<div className="pt-2 border-t border-slate-800/80 flex flex-col sm:flex-row sm:items-center justify-between gap-2 text-xs">
|
||||
<div className="flex items-center gap-4 text-slate-400 text-[11px]">
|
||||
<span>
|
||||
Target Cost: <strong className="text-cyan-400 font-mono">{formatZAR(targetCost)}</strong>
|
||||
</span>
|
||||
<span>
|
||||
Actual Material Cost:{' '}
|
||||
<strong className={`font-mono font-bold ${actualCost > targetCost && targetCost > 0 ? 'text-amber-400' : 'text-white'}`}>
|
||||
{formatZAR(actualCost)}
|
||||
</strong>
|
||||
</span>
|
||||
{targetCost > 0 && (
|
||||
<span className="text-slate-500">
|
||||
Variance: {formatZAR(targetCost - actualCost)}
|
||||
</span>
|
||||
)}
|
||||
{/* Inline Record Actual Form */}
|
||||
{isRecordingThis && (
|
||||
<div className="pt-2 border-t border-slate-800 flex items-center justify-between gap-3 animate-in fade-in">
|
||||
<span className="text-[11px] font-semibold text-slate-300">Update Actual Value:</span>
|
||||
<div className="flex items-center gap-2">
|
||||
<input
|
||||
type="number"
|
||||
value={actualInput}
|
||||
onChange={(e) => setActualInput(Number(e.target.value))}
|
||||
className="w-24 px-2 py-1 text-xs border border-slate-700 rounded-lg text-white font-mono bg-[#090d16] focus:outline-none focus:ring-1 focus:ring-indigo-500"
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
disabled={actualMutation.isPending}
|
||||
onClick={() =>
|
||||
actualMutation.mutate({
|
||||
indicatorId: ind.id,
|
||||
actualAmount: actualInput,
|
||||
})
|
||||
}
|
||||
className="px-3 py-1 text-xs font-semibold text-white bg-indigo-600 hover:bg-indigo-500 rounded-lg shadow-sm transition cursor-pointer"
|
||||
>
|
||||
{actualMutation.isPending ? 'Saving...' : 'Save'}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{isOverrun && (
|
||||
<div className="text-[10px] text-amber-300 bg-amber-500/10 px-2 py-0.5 rounded border border-amber-500/20">
|
||||
Cost exceeds delivery allocation; subject to project variation approval (M15).
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import React, { useState, useMemo } from 'react';
|
||||
import React, { useState } from 'react';
|
||||
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
|
||||
import { useForm } from 'react-hook-form';
|
||||
import { zodResolver } from '@hookform/resolvers/zod';
|
||||
@@ -15,11 +15,6 @@ import {
|
||||
TrendingUp,
|
||||
X,
|
||||
RotateCcw,
|
||||
Lock,
|
||||
Coins,
|
||||
Briefcase,
|
||||
Target,
|
||||
AlertTriangle,
|
||||
} from 'lucide-react';
|
||||
import { Modal } from '../ui/Modal';
|
||||
import { HelpTooltip } from '../ui/HelpTooltip';
|
||||
@@ -28,15 +23,10 @@ import {
|
||||
getMilestones,
|
||||
createMilestone,
|
||||
updateMilestone,
|
||||
getActivities,
|
||||
getProjectIndicators,
|
||||
getBudgets,
|
||||
} from '../../services/api';
|
||||
import { Project, Milestone, Priority, ApprovalStatus, Activity, Indicator } from '../../types';
|
||||
import { useRbac } from '../../auth/rbac';
|
||||
import { Project, Milestone, Priority, ApprovalStatus } from '../../types';
|
||||
import {
|
||||
formatDate,
|
||||
formatZAR,
|
||||
getApprovalStatusBadge,
|
||||
getPriorityBadge,
|
||||
PRIORITY_OPTIONS,
|
||||
@@ -65,7 +55,6 @@ export const ProjectMilestonesModal: React.FC<ProjectMilestonesModalProps> = ({
|
||||
}) => {
|
||||
const queryClient = useQueryClient();
|
||||
const { success, error: toastError } = useToast();
|
||||
const { canAdd, canApprove, canApproveRecord, isMaker } = useRbac();
|
||||
|
||||
const [isFormOpen, setIsFormOpen] = useState(false);
|
||||
const [editingMilestone, setEditingMilestone] = useState<Milestone | null>(null);
|
||||
@@ -74,7 +63,7 @@ export const ProjectMilestonesModal: React.FC<ProjectMilestonesModalProps> = ({
|
||||
|
||||
const {
|
||||
data: milestones = [],
|
||||
isLoading: isLoadingMilestones,
|
||||
isLoading,
|
||||
refetch,
|
||||
} = useQuery({
|
||||
queryKey: ['milestones', projectId],
|
||||
@@ -82,56 +71,8 @@ export const ProjectMilestonesModal: React.FC<ProjectMilestonesModalProps> = ({
|
||||
enabled: isOpen && !!projectId,
|
||||
});
|
||||
|
||||
const {
|
||||
data: activities = [],
|
||||
isLoading: isLoadingActivities,
|
||||
} = useQuery({
|
||||
queryKey: ['activities', projectId],
|
||||
queryFn: () => (projectId ? getActivities(projectId) : Promise.resolve([])),
|
||||
enabled: isOpen && !!projectId,
|
||||
});
|
||||
|
||||
const {
|
||||
data: indicators = [],
|
||||
isLoading: isLoadingIndicators,
|
||||
} = useQuery({
|
||||
queryKey: ['indicators', projectId],
|
||||
queryFn: () => (projectId ? getProjectIndicators(projectId) : Promise.resolve([])),
|
||||
enabled: isOpen && !!projectId,
|
||||
});
|
||||
|
||||
const { data: allBudgets = [] } = useQuery({
|
||||
queryKey: ['budgets'],
|
||||
queryFn: () => getBudgets(1, 100),
|
||||
enabled: isOpen,
|
||||
});
|
||||
|
||||
// Calculate project approved budget
|
||||
const approvedBudgetTotal = useMemo(() => {
|
||||
const projectBudgets = allBudgets.filter((b) => String(b.projectId) === String(projectId));
|
||||
return projectBudgets
|
||||
.filter((b) => b.status === undefined || b.status === null || Number(b.status) === 3)
|
||||
.reduce((sum, b) => sum + (Number(b.amount) || 0), 0);
|
||||
}, [allBudgets, projectId]);
|
||||
|
||||
// Total project value is the sum total from all milestones (sum of their activities)
|
||||
const totalProjectMilestonesValue = useMemo(() => {
|
||||
return milestones.reduce((sum, m) => {
|
||||
const mActs = activities.filter((a) => String(a.milestoneId) === String(m.id));
|
||||
return sum + mActs.reduce((s, a) => s + (Number(a.amount) || 0), 0);
|
||||
}, 0);
|
||||
}, [milestones, activities]);
|
||||
|
||||
// Total project actuals reported by partner from indicators
|
||||
const totalProjectActuals = useMemo(() => {
|
||||
return indicators.reduce((sum, i) => sum + (Number(i.actualCost) || 0), 0);
|
||||
}, [indicators]);
|
||||
|
||||
const verifiedCount = milestones.filter((m) => m.status === ApprovalStatus.Approved).length;
|
||||
const progressPct = milestones.length > 0 ? Math.round((verifiedCount / milestones.length) * 100) : 0;
|
||||
|
||||
const form = useForm<MilestoneFormValues>({
|
||||
resolver: zodResolver(milestoneSchema),
|
||||
resolver: zodResolver(milestoneSchema) as any,
|
||||
defaultValues: {
|
||||
name: '',
|
||||
dueAt: new Date(Date.now() + 60 * 24 * 60 * 60 * 1000).toISOString().slice(0, 10),
|
||||
@@ -203,6 +144,9 @@ export const ProjectMilestonesModal: React.FC<ProjectMilestonesModalProps> = ({
|
||||
|
||||
if (!project) return null;
|
||||
|
||||
const verifiedCount = milestones.filter((m) => m.status === ApprovalStatus.Approved).length;
|
||||
const progressPct = milestones.length > 0 ? Math.round((verifiedCount / milestones.length) * 100) : 0;
|
||||
|
||||
return (
|
||||
<Modal
|
||||
isOpen={isOpen}
|
||||
@@ -213,92 +157,25 @@ export const ProjectMilestonesModal: React.FC<ProjectMilestonesModalProps> = ({
|
||||
}}
|
||||
title={`Milestones & Deliverables: ${project.name}`}
|
||||
subtitle={`Project #${project.id} • Statutory Work Breakdown Schedule`}
|
||||
helpText="Contractual milestones represent formal deliverables. The monetary value of each milestone is dynamically aggregated from its scheduled activities vs indicator actuals. The project's total value is the sum of all milestones."
|
||||
maxWidth="4xl"
|
||||
helpText="Contractual milestones represent formal deliverables specified in the grant contract. Upon verification and approval, they increment the calculated project progress score and unblock disbursement releases."
|
||||
maxWidth="xl"
|
||||
>
|
||||
<div className="space-y-5">
|
||||
{/* KPI Cards: Project Value Roll-Up & Performance */}
|
||||
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-4 gap-3.5">
|
||||
<div className="bg-[#0b101c] p-4 rounded-xl border border-slate-800 hover:border-slate-700 transition flex flex-col justify-between min-w-0 shadow-sm">
|
||||
<div className="flex items-center justify-between gap-1 mb-1">
|
||||
<span className="text-[10px] uppercase font-bold text-slate-400 flex items-center gap-1.5 truncate">
|
||||
<Coins className="w-3.5 h-3.5 text-blue-400 shrink-0" />
|
||||
Total Project Value
|
||||
{/* Progress & Quick Stats Card */}
|
||||
<div className="bg-[#0b101c] p-4 rounded-xl border border-slate-800/90 flex flex-col sm:flex-row sm:items-center justify-between gap-4">
|
||||
<div className="space-y-1">
|
||||
<span className="text-[10px] uppercase font-bold text-slate-400 flex items-center gap-1.5">
|
||||
<TrendingUp className="w-3.5 h-3.5 text-blue-400" />
|
||||
Calculated Completion Score
|
||||
</span>
|
||||
</div>
|
||||
<div className="my-1.5">
|
||||
<div
|
||||
className="text-lg sm:text-xl font-bold font-mono text-blue-400 truncate tracking-tight"
|
||||
title={formatZAR(totalProjectMilestonesValue)}
|
||||
>
|
||||
{formatZAR(totalProjectMilestonesValue)}
|
||||
<div className="flex items-baseline gap-2">
|
||||
<span className="text-2xl font-bold text-white">{progressPct}%</span>
|
||||
<span className="text-xs text-slate-400">({verifiedCount} of {milestones.length} deliverables verified)</span>
|
||||
</div>
|
||||
</div>
|
||||
<span className="text-[10px] text-slate-500 truncate">Sum of all milestones</span>
|
||||
</div>
|
||||
|
||||
<div className="bg-[#0b101c] p-4 rounded-xl border border-slate-800 hover:border-slate-700 transition flex flex-col justify-between min-w-0 shadow-sm">
|
||||
<div className="flex items-center justify-between gap-1 mb-1">
|
||||
<span className="text-[10px] uppercase font-bold text-slate-400 flex items-center gap-1.5 truncate">
|
||||
<Coins className="w-3.5 h-3.5 text-emerald-400 shrink-0" />
|
||||
Approved Budget
|
||||
</span>
|
||||
</div>
|
||||
<div className="my-1.5">
|
||||
<div
|
||||
className="text-lg sm:text-xl font-bold font-mono text-emerald-400 truncate tracking-tight"
|
||||
title={formatZAR(approvedBudgetTotal)}
|
||||
>
|
||||
{formatZAR(approvedBudgetTotal)}
|
||||
</div>
|
||||
</div>
|
||||
<span className="text-[10px] text-slate-500 truncate">Allocated budget ceiling</span>
|
||||
</div>
|
||||
|
||||
<div className="bg-[#0b101c] p-4 rounded-xl border border-slate-800 hover:border-slate-700 transition flex flex-col justify-between min-w-0 shadow-sm">
|
||||
<div className="flex items-center justify-between gap-1 mb-1">
|
||||
<span className="text-[10px] uppercase font-bold text-slate-400 flex items-center gap-1.5 truncate">
|
||||
<TrendingUp className="w-3.5 h-3.5 text-cyan-400 shrink-0" />
|
||||
Partner Actuals
|
||||
</span>
|
||||
</div>
|
||||
<div className="my-1.5">
|
||||
<div
|
||||
className="text-lg sm:text-xl font-bold font-mono text-cyan-400 truncate tracking-tight"
|
||||
title={formatZAR(totalProjectActuals)}
|
||||
>
|
||||
{formatZAR(totalProjectActuals)}
|
||||
</div>
|
||||
</div>
|
||||
<span className="text-[10px] text-slate-500 truncate">Incurred material costs</span>
|
||||
</div>
|
||||
|
||||
<div className="bg-[#0b101c] p-4 rounded-xl border border-slate-800 hover:border-slate-700 transition flex flex-col justify-between min-w-0 shadow-sm">
|
||||
<div className="flex items-center justify-between gap-1 mb-1">
|
||||
<span className="text-[10px] uppercase font-bold text-slate-400 flex items-center gap-1.5 truncate">
|
||||
<CheckCircle2 className="w-3.5 h-3.5 text-indigo-400 shrink-0" />
|
||||
Milestone Progress
|
||||
</span>
|
||||
</div>
|
||||
<div className="my-1.5">
|
||||
<div className="text-lg sm:text-xl font-bold text-white font-mono truncate tracking-tight">
|
||||
{progressPct}%
|
||||
</div>
|
||||
</div>
|
||||
<span className="text-[10px] text-slate-500 truncate">
|
||||
{verifiedCount} of {milestones.length} verified
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Action Header */}
|
||||
<div className="flex items-center justify-between bg-[#0d121f] p-3 rounded-xl border border-slate-800">
|
||||
<span className="text-xs text-slate-400">
|
||||
Deliverable Milestones: <strong className="text-white">{milestones.length}</strong>
|
||||
</span>
|
||||
|
||||
<div className="flex items-center gap-2">
|
||||
{!isFormOpen && canAdd('projects_milestones') && (
|
||||
{!isFormOpen && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={openCreateForm}
|
||||
@@ -331,7 +208,7 @@ export const ProjectMilestonesModal: React.FC<ProjectMilestonesModalProps> = ({
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<form onSubmit={form.handleSubmit((data) => mutation.mutate(data))} className="space-y-3">
|
||||
<form onSubmit={form.handleSubmit((data) => mutation.mutate(data as any))} className="space-y-3">
|
||||
<div>
|
||||
<label className="block text-xs font-semibold text-slate-300 mb-1">
|
||||
Deliverable Name / Milestone Title *
|
||||
@@ -377,11 +254,10 @@ export const ProjectMilestonesModal: React.FC<ProjectMilestonesModalProps> = ({
|
||||
|
||||
{editingMilestone && (
|
||||
<div>
|
||||
<label className="block text-xs font-semibold text-slate-300 mb-1">Approval Status</label>
|
||||
<label className="block text-xs font-semibold text-slate-300 mb-1">Approval / Verification Status</label>
|
||||
<select
|
||||
{...form.register('status')}
|
||||
disabled={!canApprove('projects_milestones') || !canApproveRecord('projects_milestones', editingMilestone.createdBy)}
|
||||
className="w-full px-3 py-2 text-xs border border-slate-700/80 rounded-xl focus:outline-none focus:ring-1 focus:ring-blue-500 bg-[#090d16] text-white font-medium disabled:opacity-60 disabled:cursor-not-allowed"
|
||||
className="w-full px-3 py-2 text-xs border border-slate-700/80 rounded-xl focus:outline-none focus:ring-1 focus:ring-blue-500 bg-[#090d16] text-white font-medium"
|
||||
>
|
||||
{APPROVAL_STATUS_OPTIONS.map((opt) => (
|
||||
<option key={opt.value} value={opt.value}>
|
||||
@@ -389,12 +265,6 @@ export const ProjectMilestonesModal: React.FC<ProjectMilestonesModalProps> = ({
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
{editingMilestone && !canApproveRecord('projects_milestones', editingMilestone.createdBy) && (
|
||||
<p className="text-[10px] text-amber-400 mt-1 flex items-center gap-1">
|
||||
<Lock className="w-3 h-3" />
|
||||
Maker-Checker: Cannot verify or approve a milestone that you authored.
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
@@ -434,7 +304,7 @@ export const ProjectMilestonesModal: React.FC<ProjectMilestonesModalProps> = ({
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{isLoadingMilestones || isLoadingActivities || isLoadingIndicators ? (
|
||||
{isLoading ? (
|
||||
<div className="p-8 text-center text-xs text-slate-400 bg-[#0b101c] rounded-xl border border-slate-800">
|
||||
Loading milestones...
|
||||
</div>
|
||||
@@ -442,9 +312,7 @@ export const ProjectMilestonesModal: React.FC<ProjectMilestonesModalProps> = ({
|
||||
<div className="p-8 text-center bg-[#0b101c] rounded-xl border border-dashed border-slate-800 text-slate-400 space-y-2">
|
||||
<Flag className="w-8 h-8 text-slate-600 mx-auto" />
|
||||
<p className="text-xs font-semibold text-slate-300">No milestones registered yet</p>
|
||||
<p className="text-[11px] text-slate-500">
|
||||
Define contractual deliverables to monitor implementation progress.
|
||||
</p>
|
||||
<p className="text-[11px] text-slate-500">Define contractual deliverables to monitor implementation progress.</p>
|
||||
<button
|
||||
type="button"
|
||||
onClick={openCreateForm}
|
||||
@@ -454,38 +322,17 @@ export const ProjectMilestonesModal: React.FC<ProjectMilestonesModalProps> = ({
|
||||
</button>
|
||||
</div>
|
||||
) : (
|
||||
<div className="space-y-3 max-h-[420px] overflow-y-auto pr-1">
|
||||
<div className="space-y-2 max-h-[380px] overflow-y-auto pr-1">
|
||||
{milestones.map((m) => {
|
||||
const statusBadge = getApprovalStatusBadge(m.status);
|
||||
const priBadge = getPriorityBadge(m.priority);
|
||||
|
||||
// Milestone activities
|
||||
const mActivities = activities.filter((a) => String(a.milestoneId) === String(m.id));
|
||||
const plannedMilestoneValue = mActivities.reduce(
|
||||
(sum, a) => sum + (Number(a.amount) || 0),
|
||||
0
|
||||
);
|
||||
|
||||
// Milestone indicators (linked to activities under this milestone)
|
||||
const mActivityIds = new Set(mActivities.map((a) => String(a.id)));
|
||||
const mIndicators = indicators.filter(
|
||||
(i) => i.activityId && mActivityIds.has(String(i.activityId))
|
||||
);
|
||||
const actualMilestoneValue = mIndicators.reduce(
|
||||
(sum, i) => sum + (Number(i.actualCost) || 0),
|
||||
0
|
||||
);
|
||||
|
||||
const isOverrun = actualMilestoneValue > plannedMilestoneValue && plannedMilestoneValue > 0;
|
||||
const isApproved = m.status === ApprovalStatus.Approved;
|
||||
|
||||
return (
|
||||
<div
|
||||
key={m.id}
|
||||
className={`p-4 rounded-xl border bg-[#0d121f] transition space-y-3 ${
|
||||
isOverrun ? 'border-amber-500/40 bg-amber-950/10' : 'border-slate-800 hover:border-slate-700'
|
||||
}`}
|
||||
className="p-3.5 rounded-xl border border-slate-800 bg-[#0d121f] hover:border-slate-700 transition flex flex-col sm:flex-row sm:items-center justify-between gap-3"
|
||||
>
|
||||
<div className="flex flex-col sm:flex-row sm:items-start justify-between gap-3">
|
||||
<div className="space-y-1">
|
||||
<div className="flex items-center gap-2 flex-wrap">
|
||||
<span className="text-xs font-bold text-white">{m.name}</span>
|
||||
@@ -495,12 +342,6 @@ export const ProjectMilestonesModal: React.FC<ProjectMilestonesModalProps> = ({
|
||||
<span className={`inline-flex items-center px-2 py-0.5 rounded-full text-[10px] font-semibold border ${priBadge.className}`}>
|
||||
{priBadge.label}
|
||||
</span>
|
||||
{isOverrun && (
|
||||
<span className="inline-flex items-center gap-1 text-[10px] font-bold text-rose-400 bg-rose-500/10 px-2 py-0.5 rounded border border-rose-500/25">
|
||||
<AlertTriangle className="w-3 h-3 text-rose-400" />
|
||||
Budget Overrun
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
<div className="flex items-center gap-3 text-[11px] text-slate-400 font-mono">
|
||||
<span className="flex items-center gap-1">
|
||||
@@ -513,13 +354,6 @@ export const ProjectMilestonesModal: React.FC<ProjectMilestonesModalProps> = ({
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-2 shrink-0 self-end sm:self-center">
|
||||
<div className="text-right mr-2">
|
||||
<span className="text-[10px] text-slate-400 block font-sans">Milestone Value:</span>
|
||||
<span className="font-mono text-xs font-bold text-blue-400 block">
|
||||
{formatZAR(plannedMilestoneValue)}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<Link
|
||||
to={`/monitoring?tab=evidence&projectId=${project.id}&milestoneId=${m.id}`}
|
||||
className="inline-flex items-center gap-1 px-2.5 py-1 text-[11px] font-semibold text-blue-300 hover:text-white bg-blue-500/10 hover:bg-blue-500/20 border border-blue-500/25 rounded-lg transition"
|
||||
@@ -539,32 +373,6 @@ export const ProjectMilestonesModal: React.FC<ProjectMilestonesModalProps> = ({
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Breakdown of Activities & Indicators */}
|
||||
<div className="pt-2 border-t border-slate-800/80 flex flex-col sm:flex-row sm:items-center justify-between gap-2 text-xs">
|
||||
<div className="flex items-center gap-3 text-slate-400 text-[11px] flex-wrap">
|
||||
<span className="flex items-center gap-1">
|
||||
<Briefcase className="w-3 h-3 text-cyan-400" />
|
||||
Activities: <strong className="text-white font-mono">{mActivities.length}</strong>
|
||||
</span>
|
||||
<span>•</span>
|
||||
<span className="flex items-center gap-1">
|
||||
<Target className="w-3 h-3 text-indigo-400" />
|
||||
Indicators: <strong className="text-white font-mono">{mIndicators.length}</strong>
|
||||
</span>
|
||||
<span>•</span>
|
||||
<span>
|
||||
Actual Incurred: <strong className={`font-mono font-bold ${actualMilestoneValue > plannedMilestoneValue && plannedMilestoneValue > 0 ? 'text-amber-400' : 'text-slate-200'}`}>{formatZAR(actualMilestoneValue)}</strong>
|
||||
</span>
|
||||
</div>
|
||||
|
||||
{plannedMilestoneValue > 0 && (
|
||||
<div className="text-[10px] text-slate-500 font-mono">
|
||||
Variance: {formatZAR(plannedMilestoneValue - actualMilestoneValue)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
|
||||
@@ -67,7 +67,7 @@ export const ProjectRisksModal: React.FC<ProjectRisksModalProps> = ({
|
||||
});
|
||||
|
||||
const form = useForm<RiskFormValues>({
|
||||
resolver: zodResolver(riskSchema),
|
||||
resolver: zodResolver(riskSchema) as any,
|
||||
defaultValues: {
|
||||
name: '',
|
||||
description: '',
|
||||
@@ -202,7 +202,7 @@ export const ProjectRisksModal: React.FC<ProjectRisksModalProps> = ({
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<form onSubmit={form.handleSubmit((data) => mutation.mutate(data))} className="space-y-3">
|
||||
<form onSubmit={form.handleSubmit((data) => mutation.mutate(data as any))} className="space-y-3">
|
||||
<div>
|
||||
<label className="block text-xs font-semibold text-slate-300 mb-1">Risk Hazard Title *</label>
|
||||
<input
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import React, { useMemo, useRef, useState, useEffect } from 'react';
|
||||
import React, { useMemo } from 'react';
|
||||
import {
|
||||
Flag,
|
||||
Calendar,
|
||||
@@ -6,15 +6,9 @@ import {
|
||||
Briefcase,
|
||||
CheckCircle2,
|
||||
AlertCircle,
|
||||
TrendingUp,
|
||||
Plus,
|
||||
Coins,
|
||||
ChevronLeft,
|
||||
ChevronRight,
|
||||
ZoomIn,
|
||||
ZoomOut,
|
||||
Compass,
|
||||
ArrowRight,
|
||||
Sparkles,
|
||||
} from 'lucide-react';
|
||||
import { Project, Milestone, Activity, ApprovalStatus, Priority } from '../../types';
|
||||
import { formatDate, formatZAR, getApprovalStatusBadge, getPriorityBadge } from '../../lib/formatters';
|
||||
@@ -38,12 +32,7 @@ export const ProjectTimelineGantt: React.FC<ProjectTimelineGanttProps> = ({
|
||||
onManageMilestones,
|
||||
onManageActivities,
|
||||
}) => {
|
||||
const scrollContainerRef = useRef<HTMLDivElement>(null);
|
||||
const [zoomLevel, setZoomLevel] = useState<'compact' | 'standard' | 'expanded'>('standard');
|
||||
const [canScrollLeft, setCanScrollLeft] = useState(false);
|
||||
const [canScrollRight, setCanScrollRight] = useState(false);
|
||||
|
||||
// Compute timeline boundaries, first/last milestones, and ruler ticks
|
||||
// Compute timeline boundaries and items
|
||||
const timelineData = useMemo(() => {
|
||||
const dates: number[] = [];
|
||||
|
||||
@@ -67,14 +56,14 @@ export const ProjectTimelineGantt: React.FC<ProjectTimelineGanttProps> = ({
|
||||
if (dates.length > 0 && Math.min(...dates) !== Math.max(...dates)) {
|
||||
startMs = Math.min(...dates);
|
||||
endMs = Math.max(...dates);
|
||||
// Aesthetic buffer (6% on each side, minimum 10 days)
|
||||
const buffer = Math.max((endMs - startMs) * 0.06, 10 * 24 * 60 * 60 * 1000);
|
||||
// Add a slight buffer (5% on each side) for aesthetic padding
|
||||
const buffer = Math.max((endMs - startMs) * 0.05, 7 * 24 * 60 * 60 * 1000);
|
||||
startMs -= buffer;
|
||||
endMs += buffer;
|
||||
} else {
|
||||
// Default fallback
|
||||
startMs = now - 60 * 24 * 60 * 60 * 1000;
|
||||
endMs = now + 240 * 24 * 60 * 60 * 1000;
|
||||
// Default: current quarter +/- 3 months
|
||||
startMs = now - 90 * 24 * 60 * 60 * 1000;
|
||||
endMs = now + 180 * 24 * 60 * 60 * 1000;
|
||||
}
|
||||
|
||||
const totalDurationMs = endMs - startMs;
|
||||
@@ -85,36 +74,28 @@ export const ProjectTimelineGantt: React.FC<ProjectTimelineGanttProps> = ({
|
||||
return ((clamped - startMs) / totalDurationMs) * 100;
|
||||
};
|
||||
|
||||
// Monthly ruler ticks
|
||||
const ticks: { label: string; yearLabel?: string; percent: number; isStartOfYear: boolean; isQuarter: boolean }[] = [];
|
||||
// Generate monthly ticks for ruler
|
||||
const ticks: { label: string; percent: number; isStartOfYear: boolean }[] = [];
|
||||
const currDate = new Date(startMs);
|
||||
currDate.setDate(1);
|
||||
currDate.setHours(0, 0, 0, 0);
|
||||
|
||||
while (currDate.getTime() <= endMs) {
|
||||
if (currDate.getTime() >= startMs) {
|
||||
const monthNum = currDate.getMonth();
|
||||
ticks.push({
|
||||
label: currDate.toLocaleDateString('en-GB', { month: 'short' }),
|
||||
yearLabel: currDate.getFullYear().toString(),
|
||||
label: currDate.toLocaleDateString('en-GB', { month: 'short', year: '2-digit' }),
|
||||
percent: getPercent(currDate.getTime()),
|
||||
isStartOfYear: monthNum === 0,
|
||||
isQuarter: monthNum % 3 === 0,
|
||||
isStartOfYear: currDate.getMonth() === 0,
|
||||
});
|
||||
}
|
||||
currDate.setMonth(currDate.getMonth() + 1);
|
||||
}
|
||||
|
||||
// Sort milestones chronologically
|
||||
// Sort milestones & activities by date
|
||||
const sortedMilestones = [...milestones].sort(
|
||||
(a, b) => new Date(a.dueAt || 0).getTime() - new Date(b.dueAt || 0).getTime()
|
||||
);
|
||||
|
||||
// Identify first and last milestones
|
||||
const firstMilestone = sortedMilestones.length > 0 ? sortedMilestones[0] : null;
|
||||
const lastMilestone = sortedMilestones.length > 0 ? sortedMilestones[sortedMilestones.length - 1] : null;
|
||||
|
||||
// Sort activities chronologically
|
||||
const sortedActivities = [...activities].sort(
|
||||
(a, b) => new Date(a.performedAt || 0).getTime() - new Date(b.performedAt || 0).getTime()
|
||||
);
|
||||
@@ -122,9 +103,6 @@ export const ProjectTimelineGantt: React.FC<ProjectTimelineGanttProps> = ({
|
||||
const todayPercent = getPercent(now);
|
||||
const isTodayInRange = todayPercent >= 0 && todayPercent <= 100;
|
||||
|
||||
const projectStartPercent = project.startedAt ? getPercent(new Date(project.startedAt).getTime()) : null;
|
||||
const projectEndPercent = project.endedAt ? getPercent(new Date(project.endedAt).getTime()) : null;
|
||||
|
||||
return {
|
||||
startMs,
|
||||
endMs,
|
||||
@@ -132,420 +110,124 @@ export const ProjectTimelineGantt: React.FC<ProjectTimelineGanttProps> = ({
|
||||
getPercent,
|
||||
ticks,
|
||||
sortedMilestones,
|
||||
firstMilestone,
|
||||
lastMilestone,
|
||||
sortedActivities,
|
||||
todayPercent,
|
||||
isTodayInRange,
|
||||
projectStartPercent,
|
||||
projectEndPercent,
|
||||
};
|
||||
}, [project, milestones, activities]);
|
||||
|
||||
// Check scroll capability
|
||||
const updateScrollState = () => {
|
||||
const el = scrollContainerRef.current;
|
||||
if (!el) return;
|
||||
setCanScrollLeft(el.scrollLeft > 10);
|
||||
setCanScrollRight(el.scrollLeft < el.scrollWidth - el.clientWidth - 10);
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
updateScrollState();
|
||||
const el = scrollContainerRef.current;
|
||||
if (el) {
|
||||
el.addEventListener('scroll', updateScrollState);
|
||||
window.addEventListener('resize', updateScrollState);
|
||||
}
|
||||
return () => {
|
||||
if (el) el.removeEventListener('scroll', updateScrollState);
|
||||
window.removeEventListener('resize', updateScrollState);
|
||||
};
|
||||
}, [timelineData, zoomLevel]);
|
||||
|
||||
// Horizontal shift handlers (requirement 4)
|
||||
const handleScrollLeft = () => {
|
||||
if (!scrollContainerRef.current) return;
|
||||
scrollContainerRef.current.scrollBy({ left: -360, behavior: 'smooth' });
|
||||
};
|
||||
|
||||
const handleScrollRight = () => {
|
||||
if (!scrollContainerRef.current) return;
|
||||
scrollContainerRef.current.scrollBy({ left: 360, behavior: 'smooth' });
|
||||
};
|
||||
|
||||
// Quick jump handlers (requirement 5)
|
||||
const scrollToPercent = (percent: number) => {
|
||||
const el = scrollContainerRef.current;
|
||||
if (!el) return;
|
||||
const totalWidth = el.scrollWidth;
|
||||
const clientWidth = el.clientWidth;
|
||||
const targetScrollLeft = (percent / 100) * totalWidth - clientWidth / 2;
|
||||
el.scrollTo({ left: Math.max(0, targetScrollLeft), behavior: 'smooth' });
|
||||
};
|
||||
|
||||
const scrollToMilestone = (m: Milestone) => {
|
||||
if (!m.dueAt) return;
|
||||
const pct = timelineData.getPercent(new Date(m.dueAt).getTime());
|
||||
scrollToPercent(pct);
|
||||
};
|
||||
|
||||
const scrollToToday = () => {
|
||||
if (timelineData.isTodayInRange) {
|
||||
scrollToPercent(timelineData.todayPercent);
|
||||
}
|
||||
};
|
||||
|
||||
const scrollToProjectStart = () => {
|
||||
if (timelineData.projectStartPercent !== null) {
|
||||
scrollToPercent(timelineData.projectStartPercent);
|
||||
} else if (timelineData.firstMilestone) {
|
||||
scrollToMilestone(timelineData.firstMilestone);
|
||||
}
|
||||
};
|
||||
|
||||
const scrollToProjectEnd = () => {
|
||||
if (timelineData.projectEndPercent !== null) {
|
||||
scrollToPercent(timelineData.projectEndPercent);
|
||||
} else if (timelineData.lastMilestone) {
|
||||
scrollToMilestone(timelineData.lastMilestone);
|
||||
}
|
||||
};
|
||||
|
||||
// Zoom width classes
|
||||
const canvasWidthClass =
|
||||
zoomLevel === 'compact'
|
||||
? 'min-w-[1200px]'
|
||||
: zoomLevel === 'standard'
|
||||
? 'min-w-[1900px]'
|
||||
: 'min-w-[2800px]';
|
||||
|
||||
const verifiedMilestonesCount = milestones.filter((m) => m.status === ApprovalStatus.Approved).length;
|
||||
const milestoneProgress = milestones.length > 0 ? Math.round((verifiedMilestonesCount / milestones.length) * 100) : 0;
|
||||
const totalActivityBudget = activities.reduce((sum, a) => sum + (Number(a.amount) || 0), 0);
|
||||
|
||||
return (
|
||||
<div className="bg-[#0b101c] rounded-2xl border border-slate-800/80 p-4 sm:p-5 space-y-4 shadow-xl w-full min-w-0 relative">
|
||||
{/* Timeline Header, Key Stats & Enhanced Navigation Bar */}
|
||||
<div className="flex flex-col xl:flex-row xl:items-center justify-between gap-3.5 pb-4 border-b border-slate-800/80 min-w-0">
|
||||
{/* Title & Span Info */}
|
||||
<div className="flex items-center gap-3 min-w-0 flex-1">
|
||||
<div className="h-10 w-10 rounded-2xl bg-gradient-to-br from-blue-600/20 to-indigo-600/20 border border-blue-500/30 flex items-center justify-center text-blue-400 shadow-md shadow-blue-500/10 shrink-0">
|
||||
<Calendar className="w-5 h-5" />
|
||||
<div className="bg-[#0b101c] rounded-2xl border border-slate-800/80 p-5 space-y-5 overflow-hidden shadow-inner">
|
||||
{/* Timeline Header & Key Stats */}
|
||||
<div className="flex flex-wrap items-center justify-between gap-3 pb-4 border-b border-slate-800/80">
|
||||
<div className="flex items-center gap-2.5">
|
||||
<div className="h-8 w-8 rounded-xl bg-blue-500/10 border border-blue-500/30 flex items-center justify-center text-blue-400">
|
||||
<Calendar className="w-4 h-4" />
|
||||
</div>
|
||||
<div className="min-w-0">
|
||||
<div className="flex items-center gap-2 flex-wrap">
|
||||
<h4 className="text-sm font-bold text-white tracking-wide uppercase">
|
||||
Implementation Schedule & Timeline Gantt
|
||||
<div>
|
||||
<h4 className="text-xs font-bold text-white uppercase tracking-wider flex items-center gap-2">
|
||||
Implementation Schedule & Timeline
|
||||
<span className="text-[10px] normal-case font-normal text-slate-400">
|
||||
({timelineData.totalDurationDays} Days Span)
|
||||
</span>
|
||||
</h4>
|
||||
<span className="text-[11px] font-semibold text-blue-400 bg-blue-500/10 px-2 py-0.5 rounded-full border border-blue-500/30">
|
||||
{timelineData.totalDurationDays} Days Span
|
||||
</span>
|
||||
{project.startedAt && project.endedAt && (
|
||||
<span className="text-[11px] text-slate-400 font-mono">
|
||||
({formatDate(project.startedAt)} → {formatDate(project.endedAt)})
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
<p className="text-xs text-slate-400 mt-0.5 truncate">
|
||||
Chronological alignment of contractual deliverables, work breakdown activities, and target milestones.
|
||||
<p className="text-[11px] text-slate-400">
|
||||
Chronological alignment of contractual deliverables and operational activities.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Enhanced Navigation Toolbar (Quick Jump, Zoom, Scroll Controls) */}
|
||||
<div className="flex items-center gap-2 flex-wrap shrink-0">
|
||||
{/* Quick Jump Buttons (Requirement 5) */}
|
||||
<div className="flex items-center gap-1.5 p-1 bg-slate-900/90 rounded-xl border border-slate-800 text-xs flex-wrap">
|
||||
<span className="text-[10px] uppercase font-bold text-slate-500 px-1.5 hidden sm:inline">Jump:</span>
|
||||
|
||||
{/* Jump to First Milestone */}
|
||||
{timelineData.firstMilestone ? (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => scrollToMilestone(timelineData.firstMilestone!)}
|
||||
className="inline-flex items-center gap-1 px-2.5 py-1 rounded-lg text-[11px] font-semibold bg-emerald-500/15 hover:bg-emerald-500/25 text-emerald-300 border border-emerald-500/30 transition cursor-pointer shadow-xs"
|
||||
title={`Jump to First Milestone: "${timelineData.firstMilestone.name}" (${formatDate(timelineData.firstMilestone.dueAt)})`}
|
||||
>
|
||||
<Flag className="w-3 h-3 text-emerald-400" />
|
||||
<span className="max-w-[120px] truncate">
|
||||
Start: {timelineData.firstMilestone.name}
|
||||
</span>
|
||||
</button>
|
||||
) : project.startedAt ? (
|
||||
<button
|
||||
type="button"
|
||||
onClick={scrollToProjectStart}
|
||||
className="inline-flex items-center gap-1 px-2.5 py-1 rounded-lg text-[11px] font-semibold bg-slate-800 hover:bg-slate-700 text-slate-300 border border-slate-700 transition cursor-pointer"
|
||||
title={`Jump to Project Start (${formatDate(project.startedAt)})`}
|
||||
>
|
||||
<Calendar className="w-3 h-3 text-blue-400" />
|
||||
<span>Start Date</span>
|
||||
</button>
|
||||
) : null}
|
||||
|
||||
{/* Jump to Today */}
|
||||
{timelineData.isTodayInRange && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={scrollToToday}
|
||||
className="inline-flex items-center gap-1 px-2.5 py-1 rounded-lg text-[11px] font-semibold bg-amber-500/15 hover:bg-amber-500/25 text-amber-300 border border-amber-500/40 transition cursor-pointer shadow-xs"
|
||||
title="Jump directly to current date on timeline"
|
||||
>
|
||||
<Clock className="w-3 h-3 text-amber-400" />
|
||||
<span>Today</span>
|
||||
</button>
|
||||
)}
|
||||
|
||||
{/* Jump to Last Milestone */}
|
||||
{timelineData.lastMilestone ? (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => scrollToMilestone(timelineData.lastMilestone!)}
|
||||
className="inline-flex items-center gap-1 px-2.5 py-1 rounded-lg text-[11px] font-semibold bg-indigo-500/15 hover:bg-indigo-500/25 text-indigo-300 border border-indigo-500/30 transition cursor-pointer shadow-xs"
|
||||
title={`Jump to Final Milestone: "${timelineData.lastMilestone.name}" (${formatDate(timelineData.lastMilestone.dueAt)})`}
|
||||
>
|
||||
<CheckCircle2 className="w-3 h-3 text-indigo-400" />
|
||||
<span className="max-w-[120px] truncate">
|
||||
Final: {timelineData.lastMilestone.name}
|
||||
</span>
|
||||
</button>
|
||||
) : project.endedAt ? (
|
||||
<button
|
||||
type="button"
|
||||
onClick={scrollToProjectEnd}
|
||||
className="inline-flex items-center gap-1 px-2.5 py-1 rounded-lg text-[11px] font-semibold bg-slate-800 hover:bg-slate-700 text-slate-300 border border-slate-700 transition cursor-pointer"
|
||||
title={`Jump to Project Target End (${formatDate(project.endedAt)})`}
|
||||
>
|
||||
<Calendar className="w-3 h-3 text-indigo-400" />
|
||||
<span>Target End</span>
|
||||
</button>
|
||||
) : null}
|
||||
</div>
|
||||
|
||||
{/* Zoom Level Selector */}
|
||||
<div className="flex items-center p-0.5 bg-slate-900/90 rounded-xl border border-slate-800 text-[11px]">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setZoomLevel('compact')}
|
||||
className={`px-2 py-1 rounded-lg font-medium transition cursor-pointer ${
|
||||
zoomLevel === 'compact'
|
||||
? 'bg-blue-600 text-white shadow-xs'
|
||||
: 'text-slate-400 hover:text-white'
|
||||
}`}
|
||||
title="Compact View (Fits broader duration)"
|
||||
>
|
||||
Fit
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setZoomLevel('standard')}
|
||||
className={`px-2 py-1 rounded-lg font-medium transition cursor-pointer ${
|
||||
zoomLevel === 'standard'
|
||||
? 'bg-blue-600 text-white shadow-xs'
|
||||
: 'text-slate-400 hover:text-white'
|
||||
}`}
|
||||
title="Standard View (Balanced spacing)"
|
||||
>
|
||||
Standard
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setZoomLevel('expanded')}
|
||||
className={`px-2 py-1 rounded-lg font-medium transition cursor-pointer ${
|
||||
zoomLevel === 'expanded'
|
||||
? 'bg-blue-600 text-white shadow-xs'
|
||||
: 'text-slate-400 hover:text-white'
|
||||
}`}
|
||||
title="Expanded View (Deep detail, optimal for scrolling)"
|
||||
>
|
||||
Expanded
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Scroll Shift Buttons in Top Header */}
|
||||
<div className="flex items-center gap-1">
|
||||
<button
|
||||
type="button"
|
||||
onClick={handleScrollLeft}
|
||||
disabled={!canScrollLeft}
|
||||
className="p-1.5 rounded-xl bg-slate-900 hover:bg-slate-800 text-slate-300 hover:text-white border border-slate-700/80 transition cursor-pointer disabled:opacity-40 disabled:cursor-not-allowed"
|
||||
title="Shift timeline left (360px)"
|
||||
aria-label="Scroll left"
|
||||
>
|
||||
<ChevronLeft className="w-4 h-4" />
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={handleScrollRight}
|
||||
disabled={!canScrollRight}
|
||||
className="p-1.5 rounded-xl bg-slate-900 hover:bg-slate-800 text-slate-300 hover:text-white border border-slate-700/80 transition cursor-pointer disabled:opacity-40 disabled:cursor-not-allowed"
|
||||
title="Shift timeline right (360px)"
|
||||
aria-label="Scroll right"
|
||||
>
|
||||
<ChevronRight className="w-4 h-4" />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Milestone & Activity Legend & Milestone Span Pill */}
|
||||
<div className="flex flex-col sm:flex-row sm:items-center justify-between gap-2.5 text-xs text-slate-400 flex-wrap">
|
||||
<div className="flex items-center gap-3 flex-wrap">
|
||||
{/* Quick Legend & Actions */}
|
||||
<div className="flex items-center gap-3 text-[11px] flex-wrap">
|
||||
<div className="flex items-center gap-3 px-3 py-1 bg-slate-900/80 rounded-xl border border-slate-800 text-slate-400">
|
||||
<span className="flex items-center gap-1.5">
|
||||
<span className="w-2.5 h-2.5 rounded-full bg-emerald-500 shadow-sm shadow-emerald-500/50 inline-block" />
|
||||
<strong className="text-slate-300">Verified Milestone</strong>
|
||||
<span className="w-2.5 h-2.5 rounded-full bg-emerald-500 inline-block shadow-sm shadow-emerald-500/50" />
|
||||
Verified Deliverable
|
||||
</span>
|
||||
<span className="flex items-center gap-1.5">
|
||||
<span className="w-2.5 h-2.5 rounded-full bg-amber-500 shadow-sm shadow-amber-500/50 inline-block" />
|
||||
<strong className="text-slate-300">Pending Review</strong>
|
||||
<span className="w-2.5 h-2.5 rounded-full bg-amber-500 inline-block shadow-sm shadow-amber-500/50" />
|
||||
Pending / Review
|
||||
</span>
|
||||
<span className="flex items-center gap-1.5">
|
||||
<span className="w-2.5 h-2.5 rounded-full bg-blue-500 shadow-sm shadow-blue-500/50 inline-block" />
|
||||
<strong className="text-slate-300">Contractual Deliverable</strong>
|
||||
</span>
|
||||
<span className="flex items-center gap-1.5">
|
||||
<span className="w-2.5 h-2.5 rounded-sm bg-cyan-500 shadow-sm shadow-cyan-500/50 inline-block" />
|
||||
<strong className="text-slate-300">Work Activity</strong>
|
||||
<span className="w-2.5 h-2.5 rounded-sm bg-blue-500 inline-block shadow-sm shadow-blue-500/50" />
|
||||
Work Activity
|
||||
</span>
|
||||
</div>
|
||||
|
||||
{/* First & Last Milestone Span Pill (Requirement 5) */}
|
||||
{timelineData.firstMilestone && timelineData.lastMilestone && (
|
||||
<div className="flex items-center gap-1.5 text-[11px] font-mono bg-slate-900/90 px-3 py-1 rounded-full border border-slate-800 text-slate-300">
|
||||
<span className="text-emerald-400 font-semibold">1st: {formatDate(timelineData.firstMilestone.dueAt)}</span>
|
||||
<ArrowRight className="w-3 h-3 text-slate-500" />
|
||||
<span className="text-indigo-400 font-semibold">Final: {formatDate(timelineData.lastMilestone.dueAt)}</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Scrollable Timeline Visualization Container */}
|
||||
<div className="relative group/timeline rounded-2xl border border-slate-800/90 bg-[#070b13] overflow-hidden shadow-2xl">
|
||||
{/* Scrollable Viewport */}
|
||||
<div
|
||||
ref={scrollContainerRef}
|
||||
className="overflow-x-auto scrollbar-thin scrollbar-thumb-slate-700 scrollbar-track-slate-900 scroll-smooth w-full select-none"
|
||||
>
|
||||
{/* Inner Canvas with expansive width */}
|
||||
<div className={`relative pt-7 pb-14 px-6 min-h-[340px] ${canvasWidthClass}`}>
|
||||
{/* Background vertical month grid lines */}
|
||||
<div className="absolute inset-0 pointer-events-none">
|
||||
{timelineData.ticks.map((tick, idx) => (
|
||||
<div
|
||||
key={idx}
|
||||
className={`absolute top-0 bottom-0 w-px ${
|
||||
tick.isStartOfYear
|
||||
? 'bg-blue-500/30 border-dashed border-l border-blue-500/40'
|
||||
: tick.isQuarter
|
||||
? 'bg-slate-700/50'
|
||||
: 'bg-slate-800/40'
|
||||
}`}
|
||||
style={{ left: `${tick.percent}%` }}
|
||||
/>
|
||||
))}
|
||||
|
||||
{/* Project Start Line Marker */}
|
||||
{timelineData.projectStartPercent !== null && (
|
||||
<div
|
||||
className="absolute top-0 bottom-0 z-10 flex flex-col items-center pointer-events-none"
|
||||
style={{ left: `${timelineData.projectStartPercent}%` }}
|
||||
>
|
||||
<span className="px-2 py-0.5 text-[9px] font-bold text-blue-300 bg-blue-600/30 border border-blue-500/50 rounded-full whitespace-nowrap shadow-md">
|
||||
Start: {formatDate(project.startedAt)}
|
||||
</span>
|
||||
<div className="w-px h-full bg-blue-500/50 border-dashed border-l border-blue-400 mt-1" />
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Project Target End Line Marker */}
|
||||
{timelineData.projectEndPercent !== null && (
|
||||
<div
|
||||
className="absolute top-0 bottom-0 z-10 flex flex-col items-center pointer-events-none"
|
||||
style={{ left: `${timelineData.projectEndPercent}%` }}
|
||||
>
|
||||
<span className="px-2 py-0.5 text-[9px] font-bold text-indigo-300 bg-indigo-600/30 border border-indigo-500/50 rounded-full whitespace-nowrap shadow-md">
|
||||
End: {formatDate(project.endedAt)}
|
||||
</span>
|
||||
<div className="w-px h-full bg-indigo-500/50 border-dashed border-l border-indigo-400 mt-1" />
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Today line marker */}
|
||||
{timelineData.isTodayInRange && (
|
||||
<div
|
||||
className="absolute top-0 bottom-0 z-20 flex flex-col items-center pointer-events-none"
|
||||
style={{ left: `${timelineData.todayPercent}%` }}
|
||||
>
|
||||
<span className="px-2.5 py-0.5 text-[9px] font-bold text-amber-300 bg-amber-500/30 border border-amber-400/60 rounded-full whitespace-nowrap shadow-lg shadow-amber-500/25">
|
||||
Today
|
||||
</span>
|
||||
<div className="w-px h-full bg-amber-400 border-l border-amber-400 shadow-md shadow-amber-400/40 mt-1" />
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Main Gantt Timeline Track Container */}
|
||||
<div className="space-y-4 pt-1">
|
||||
{/* Month Scale / Ruler */}
|
||||
<div className="relative h-9 border-b border-slate-800 text-[11px] font-mono text-slate-400 mb-6">
|
||||
<div className="relative h-6 w-full border-b border-slate-800 text-[10px] font-mono text-slate-500 select-none">
|
||||
{timelineData.ticks.map((tick, idx) => (
|
||||
<div
|
||||
key={idx}
|
||||
className="absolute transform -translate-x-1/2 flex flex-col items-center"
|
||||
style={{ left: `${tick.percent}%` }}
|
||||
>
|
||||
<div className="flex items-center gap-1">
|
||||
<span
|
||||
className={
|
||||
tick.isStartOfYear
|
||||
? 'text-blue-400 font-bold text-xs'
|
||||
: tick.isQuarter
|
||||
? 'text-slate-200 font-semibold'
|
||||
: 'text-slate-400'
|
||||
}
|
||||
>
|
||||
<span className={tick.isStartOfYear ? 'text-blue-400 font-bold' : 'text-slate-400'}>
|
||||
{tick.label}
|
||||
</span>
|
||||
{tick.isStartOfYear && (
|
||||
<span className="text-[9px] text-blue-300 font-mono bg-blue-500/20 px-1 rounded">
|
||||
{tick.yearLabel}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
<span className="h-2 w-px bg-slate-700 mt-1" />
|
||||
<span className="h-1.5 w-px bg-slate-700 mt-0.5" />
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{/* Layer 1: Contractual Deliverables & Milestones Track */}
|
||||
<div className="relative z-10 mb-8 space-y-2.5">
|
||||
<div className="flex items-center justify-between text-xs font-bold text-slate-200 px-1 gap-2 flex-wrap">
|
||||
<span className="flex items-center gap-2 text-blue-400">
|
||||
<Flag className="w-4 h-4 text-blue-400" />
|
||||
{/* Timeline Visualization Surface */}
|
||||
<div className="relative min-h-[290px] bg-[#090d16] rounded-xl border border-slate-800/90 pt-8 pb-5 px-4 overflow-x-auto">
|
||||
{/* Background vertical grid lines */}
|
||||
<div className="absolute inset-0 pointer-events-none">
|
||||
{timelineData.ticks.map((tick, idx) => (
|
||||
<div
|
||||
key={idx}
|
||||
className="absolute top-0 bottom-0 w-px bg-slate-800/40"
|
||||
style={{ left: `${tick.percent}%` }}
|
||||
/>
|
||||
))}
|
||||
|
||||
{/* Today marker */}
|
||||
{timelineData.isTodayInRange && (
|
||||
<div
|
||||
className="absolute top-1 bottom-0 z-20 flex flex-col items-center pointer-events-none"
|
||||
style={{ left: `${timelineData.todayPercent}%` }}
|
||||
>
|
||||
<span className="px-2 py-0.5 text-[9px] font-bold text-amber-300 bg-amber-500/25 border border-amber-500/50 rounded-full whitespace-nowrap shadow-md shadow-amber-500/20">
|
||||
Today
|
||||
</span>
|
||||
<div className="w-px h-full bg-amber-400/80 border-dashed border-l border-amber-400 shadow-sm shadow-amber-400/30 mt-1" />
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Layer 1: Milestones Track */}
|
||||
<div className="relative z-10 mb-6 space-y-2">
|
||||
<div className="flex items-center justify-between text-[11px] font-semibold text-slate-300 px-1">
|
||||
<span className="flex items-center gap-1.5 text-blue-400">
|
||||
<Flag className="w-3.5 h-3.5" />
|
||||
Contractual Deliverables & Milestones ({milestones.length})
|
||||
</span>
|
||||
<button
|
||||
type="button"
|
||||
onClick={onManageMilestones}
|
||||
className="text-xs text-blue-400 hover:text-blue-300 hover:underline cursor-pointer flex items-center gap-1 font-semibold whitespace-nowrap"
|
||||
className="text-[10px] text-blue-400 hover:text-blue-300 hover:underline cursor-pointer flex items-center gap-1 font-medium"
|
||||
>
|
||||
<Plus className="w-3.5 h-3.5" /> Add / Manage Milestones
|
||||
<Plus className="w-3 h-3" /> Add / Manage Milestones
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{timelineData.sortedMilestones.length === 0 ? (
|
||||
<div className="p-6 bg-slate-900/40 rounded-xl border border-dashed border-slate-800 text-center text-xs text-slate-400">
|
||||
<div className="p-4 bg-slate-900/40 rounded-lg border border-dashed border-slate-800 text-center text-xs text-slate-500">
|
||||
No contractual milestones defined yet. Click "Add / Manage Milestones" to establish deliverables.
|
||||
</div>
|
||||
) : (
|
||||
<div className="relative h-28 bg-slate-900/60 rounded-2xl border border-slate-800/80 flex items-center px-4 shadow-inner">
|
||||
{/* Base connecting line */}
|
||||
<div className="absolute left-4 right-4 h-1.5 bg-slate-800 rounded-full overflow-hidden">
|
||||
{/* Visual progress overlay */}
|
||||
<div
|
||||
className="h-full bg-gradient-to-r from-emerald-500 to-blue-500 rounded-full transition-all duration-300"
|
||||
style={{ width: `${milestoneProgress}%` }}
|
||||
/>
|
||||
</div>
|
||||
<div className="relative h-24 bg-slate-900/50 rounded-xl border border-slate-800/80 flex items-center px-3">
|
||||
{/* Milestone connecting base line */}
|
||||
<div className="absolute left-3 right-3 h-1 bg-slate-800 rounded-full" />
|
||||
|
||||
{/* Milestone Pins */}
|
||||
{timelineData.sortedMilestones.map((m, idx) => {
|
||||
@@ -553,76 +235,54 @@ export const ProjectTimelineGantt: React.FC<ProjectTimelineGanttProps> = ({
|
||||
const pos = timelineData.getPercent(mTime);
|
||||
const isApproved = m.status === ApprovalStatus.Approved;
|
||||
const isPending = m.status === ApprovalStatus.Pending;
|
||||
const isFirst = timelineData.firstMilestone?.id === m.id;
|
||||
const isLast = timelineData.lastMilestone?.id === m.id && timelineData.sortedMilestones.length > 1;
|
||||
const priBadge = getPriorityBadge(m.priority);
|
||||
|
||||
return (
|
||||
<div
|
||||
key={m.id || idx}
|
||||
className="absolute group z-10 -translate-x-1/2 cursor-pointer transition-all duration-200"
|
||||
style={{ left: `${Math.min(97, Math.max(3, pos))}%` }}
|
||||
style={{ left: `${Math.min(96, Math.max(4, pos))}%` }}
|
||||
onClick={onManageMilestones}
|
||||
>
|
||||
{/* First / Last Milestone Distinctive Badge Indicator (Requirement 5) */}
|
||||
{isFirst && (
|
||||
<div className="absolute -top-7 left-1/2 -translate-x-1/2 whitespace-nowrap">
|
||||
<span className="px-2 py-0.5 rounded-full text-[9px] font-bold bg-emerald-500/25 border border-emerald-400 text-emerald-300 shadow-md flex items-center gap-1">
|
||||
<Sparkles className="w-2.5 h-2.5 text-emerald-400" />
|
||||
1st Milestone
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
{isLast && !isFirst && (
|
||||
<div className="absolute -top-7 left-1/2 -translate-x-1/2 whitespace-nowrap">
|
||||
<span className="px-2 py-0.5 rounded-full text-[9px] font-bold bg-indigo-500/25 border border-indigo-400 text-indigo-300 shadow-md flex items-center gap-1">
|
||||
<CheckCircle2 className="w-2.5 h-2.5 text-indigo-400" />
|
||||
Final Close-out
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Milestone Flag Icon Bubble */}
|
||||
{/* Flag Marker */}
|
||||
<div
|
||||
className={`h-9 w-9 rounded-2xl flex items-center justify-center shadow-lg transition-transform group-hover:scale-125 border ${
|
||||
className={`h-8 w-8 rounded-full flex items-center justify-center shadow-lg transition-transform group-hover:scale-125 border ${
|
||||
isApproved
|
||||
? 'bg-emerald-500/25 border-emerald-400 text-emerald-300 shadow-emerald-500/30'
|
||||
? 'bg-emerald-500/20 border-emerald-400 text-emerald-300 shadow-emerald-500/20'
|
||||
: isPending
|
||||
? 'bg-amber-500/25 border-amber-400 text-amber-300 shadow-amber-500/30'
|
||||
: 'bg-blue-500/25 border-blue-400 text-blue-300 shadow-blue-500/30'
|
||||
} ${isFirst ? 'ring-2 ring-emerald-400/50' : ''} ${isLast ? 'ring-2 ring-indigo-400/50' : ''}`}
|
||||
? 'bg-amber-500/20 border-amber-400 text-amber-300 shadow-amber-500/20'
|
||||
: 'bg-blue-500/20 border-blue-400 text-blue-300 shadow-blue-500/20'
|
||||
}`}
|
||||
>
|
||||
<Flag className="w-4 h-4 fill-current" />
|
||||
<Flag className="w-3.5 h-3.5 fill-current" />
|
||||
</div>
|
||||
|
||||
{/* Deliverable Label */}
|
||||
{/* Brief Pin Label */}
|
||||
<div className="mt-1 text-center whitespace-nowrap">
|
||||
<span className="text-[11px] font-bold text-slate-200 block max-w-[130px] truncate group-hover:text-white">
|
||||
<span className="text-[10px] font-bold text-slate-300 block max-w-[110px] truncate">
|
||||
{m.name}
|
||||
</span>
|
||||
<span className="text-[10px] text-slate-400 font-mono block">
|
||||
<span className="text-[9px] text-slate-500 font-mono">
|
||||
{formatDate(m.dueAt)}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
{/* Rich Hover Card */}
|
||||
<div className="absolute bottom-full left-1/2 -translate-x-1/2 mb-3 hidden group-hover:flex flex-col w-64 p-3 bg-slate-900 border border-slate-700/90 rounded-2xl shadow-2xl text-left z-30 pointer-events-none backdrop-blur-md">
|
||||
<div className="flex items-center justify-between gap-1 mb-1.5">
|
||||
<span className="text-[10px] font-mono text-blue-400 font-semibold">
|
||||
Milestone #{m.id}
|
||||
</span>
|
||||
<span className={`text-[9px] px-2 py-0.5 rounded border ${priBadge.className}`}>
|
||||
{/* Tooltip on Hover */}
|
||||
<div className="absolute bottom-full left-1/2 -translate-x-1/2 mb-2 hidden group-hover:flex flex-col w-56 p-2.5 bg-slate-900 border border-slate-700 rounded-xl shadow-2xl text-left z-30 pointer-events-none">
|
||||
<div className="flex items-center justify-between gap-1 mb-1">
|
||||
<span className="text-[10px] font-mono text-blue-400">Milestone #{m.id}</span>
|
||||
<span className={`text-[9px] px-1.5 py-0.2 rounded border ${priBadge.className}`}>
|
||||
{priBadge.label}
|
||||
</span>
|
||||
</div>
|
||||
<p className="text-xs font-bold text-white leading-tight mb-2">{m.name}</p>
|
||||
<div className="flex items-center justify-between text-[11px] text-slate-400 pt-2 border-t border-slate-800">
|
||||
<span className="flex items-center gap-1 font-mono">
|
||||
<Clock className="w-3.5 h-3.5 text-slate-500" />
|
||||
<p className="text-xs font-bold text-white leading-tight mb-1">{m.name}</p>
|
||||
<div className="flex items-center justify-between text-[10px] text-slate-400 pt-1 border-t border-slate-800">
|
||||
<span className="flex items-center gap-1">
|
||||
<Clock className="w-3 h-3 text-slate-500" />
|
||||
{formatDate(m.dueAt)}
|
||||
</span>
|
||||
<span className={isApproved ? 'text-emerald-400 font-bold' : 'text-amber-400 font-semibold'}>
|
||||
{isApproved ? '✓ Verified' : '● Pending Review'}
|
||||
<span className={isApproved ? 'text-emerald-400 font-semibold' : 'text-amber-400'}>
|
||||
{isApproved ? 'Verified' : 'Pending'}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
@@ -633,71 +293,69 @@ export const ProjectTimelineGantt: React.FC<ProjectTimelineGanttProps> = ({
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Layer 2: Work Breakdown Activities Track */}
|
||||
<div className="relative z-10 space-y-2.5">
|
||||
<div className="flex items-center justify-between text-xs font-bold text-slate-200 px-1 gap-2 flex-wrap">
|
||||
<span className="flex items-center gap-2 text-cyan-400">
|
||||
<Briefcase className="w-4 h-4 text-cyan-400" />
|
||||
{/* Layer 2: Activities Track */}
|
||||
<div className="relative z-10 space-y-2">
|
||||
<div className="flex items-center justify-between text-[11px] font-semibold text-slate-300 px-1">
|
||||
<span className="flex items-center gap-1.5 text-cyan-400">
|
||||
<Briefcase className="w-3.5 h-3.5" />
|
||||
Work Breakdown Activities ({activities.length})
|
||||
</span>
|
||||
<button
|
||||
type="button"
|
||||
onClick={onManageActivities}
|
||||
className="text-xs text-cyan-400 hover:text-cyan-300 hover:underline cursor-pointer flex items-center gap-1 font-semibold whitespace-nowrap"
|
||||
className="text-[10px] text-cyan-400 hover:text-cyan-300 hover:underline cursor-pointer flex items-center gap-1 font-medium"
|
||||
>
|
||||
<Plus className="w-3.5 h-3.5" /> Add / Manage Activities
|
||||
<Plus className="w-3 h-3" /> Add / Manage Activities
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{timelineData.sortedActivities.length === 0 ? (
|
||||
<div className="p-4 bg-slate-900/40 rounded-xl border border-dashed border-slate-800 text-center text-xs text-slate-400">
|
||||
<div className="p-3 bg-slate-900/40 rounded-lg border border-dashed border-slate-800 text-center text-xs text-slate-500">
|
||||
No activities recorded in work breakdown structure.
|
||||
</div>
|
||||
) : (
|
||||
<div className="space-y-2">
|
||||
{timelineData.sortedActivities.slice(0, 5).map((act, idx) => {
|
||||
<div className="space-y-1.5">
|
||||
{timelineData.sortedActivities.slice(0, 4).map((act, idx) => {
|
||||
const aTime = new Date(act.performedAt || project.startedAt || Date.now()).getTime();
|
||||
const pos = timelineData.getPercent(aTime);
|
||||
|
||||
return (
|
||||
<div
|
||||
key={act.id || idx}
|
||||
className="group relative flex items-center p-2.5 rounded-xl bg-slate-900/80 hover:bg-slate-800/90 border border-slate-800 hover:border-slate-700 transition cursor-pointer"
|
||||
className="group relative flex items-center p-2 rounded-xl bg-slate-900/80 hover:bg-slate-800/90 border border-slate-800 hover:border-slate-700 transition cursor-pointer"
|
||||
onClick={onManageActivities}
|
||||
>
|
||||
<div className="w-56 shrink-0 pr-3 border-r border-slate-800 truncate">
|
||||
<span className="text-xs font-semibold text-white block truncate group-hover:text-cyan-300 transition">
|
||||
{act.title}
|
||||
</span>
|
||||
<div className="w-48 shrink-0 pr-3 border-r border-slate-800 truncate">
|
||||
<span className="text-xs font-semibold text-white block truncate">{act.title}</span>
|
||||
<span className="text-[10px] text-slate-400">{act.category || 'Operations'}</span>
|
||||
</div>
|
||||
|
||||
<div className="flex-1 relative h-7 mx-4 flex items-center">
|
||||
<div className="flex-1 relative h-6 mx-3 flex items-center">
|
||||
<div className="w-full h-1 bg-slate-800 rounded-full" />
|
||||
<div
|
||||
className="absolute h-5 rounded-lg bg-cyan-500/20 border border-cyan-400/60 px-2.5 flex items-center text-[10px] font-bold text-cyan-300 shadow-md shadow-cyan-500/20 -translate-x-1/2 whitespace-nowrap group-hover:scale-105 transition"
|
||||
style={{ left: `${Math.min(95, Math.max(5, pos))}%` }}
|
||||
className="absolute h-4 rounded-md bg-cyan-500/20 border border-cyan-400/60 px-2 flex items-center text-[9px] font-bold text-cyan-300 shadow-sm shadow-cyan-500/20 -translate-x-1/2 whitespace-nowrap group-hover:scale-105 transition"
|
||||
style={{ left: `${Math.min(94, Math.max(6, pos))}%` }}
|
||||
>
|
||||
<Coins className="w-3 h-3 mr-1 text-cyan-400" />
|
||||
<Coins className="w-2.5 h-2.5 mr-1 text-cyan-400" />
|
||||
{formatZAR(act.amount)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="w-32 shrink-0 text-right font-mono text-xs text-slate-400">
|
||||
<div className="w-28 shrink-0 text-right font-mono text-[10px] text-slate-400">
|
||||
{formatDate(act.performedAt)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
|
||||
{activities.length > 5 && (
|
||||
{activities.length > 4 && (
|
||||
<div className="text-center pt-1">
|
||||
<button
|
||||
type="button"
|
||||
onClick={onManageActivities}
|
||||
className="text-xs text-cyan-400 hover:text-cyan-300 font-semibold hover:underline cursor-pointer"
|
||||
className="text-[11px] text-cyan-400 hover:text-cyan-300 font-semibold hover:underline cursor-pointer"
|
||||
>
|
||||
+ View all {activities.length} work breakdown tasks →
|
||||
+ View all {activities.length} work activities →
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
@@ -707,33 +365,66 @@ export const ProjectTimelineGantt: React.FC<ProjectTimelineGanttProps> = ({
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* BOTTOM NAVIGATION ARROWS (EXACTLY WHERE RED ARROWS WERE IN USER'S SCREENSHOT) */}
|
||||
{/* Bottom Left Navigation Arrow Button */}
|
||||
<div className="absolute bottom-3.5 left-4 z-20">
|
||||
<button
|
||||
type="button"
|
||||
onClick={handleScrollLeft}
|
||||
aria-label="Shift timeline left"
|
||||
title="Shift timeline left (360px)"
|
||||
className="flex items-center gap-1.5 px-3 py-1.5 rounded-xl bg-slate-900/95 hover:bg-blue-600 text-slate-200 hover:text-white border border-slate-700 hover:border-blue-500 shadow-2xl backdrop-blur-md transition-all cursor-pointer group active:scale-95"
|
||||
>
|
||||
<ChevronLeft className="w-4 h-4 text-blue-400 group-hover:text-white group-hover:-translate-x-0.5 transition-transform" />
|
||||
<span className="text-[11px] font-bold uppercase tracking-wider">Earlier</span>
|
||||
</button>
|
||||
{/* Bottom Summary KPI Bar */}
|
||||
<div className="grid grid-cols-2 sm:grid-cols-3 lg:grid-cols-5 gap-3 pt-2">
|
||||
<div className="p-3 bg-[#090d16] rounded-xl border border-slate-800">
|
||||
<span className="text-[10px] uppercase font-bold text-slate-500 block">Project Duration</span>
|
||||
<span className="text-sm font-bold text-white mt-0.5 block">
|
||||
{timelineData.totalDurationDays} Days
|
||||
</span>
|
||||
<span className="text-[10px] text-slate-400">
|
||||
{formatDate(project.startedAt)} → {formatDate(project.endedAt)}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
{/* Bottom Right Navigation Arrow Button */}
|
||||
<div className="absolute bottom-3.5 right-4 z-20">
|
||||
<button
|
||||
type="button"
|
||||
onClick={handleScrollRight}
|
||||
aria-label="Shift timeline right"
|
||||
title="Shift timeline right (360px)"
|
||||
className="flex items-center gap-1.5 px-3 py-1.5 rounded-xl bg-slate-900/95 hover:bg-blue-600 text-slate-200 hover:text-white border border-slate-700 hover:border-blue-500 shadow-2xl backdrop-blur-md transition-all cursor-pointer group active:scale-95"
|
||||
>
|
||||
<span className="text-[11px] font-bold uppercase tracking-wider">Later</span>
|
||||
<ChevronRight className="w-4 h-4 text-blue-400 group-hover:text-white group-hover:translate-x-0.5 transition-transform" />
|
||||
</button>
|
||||
<div className="p-3 bg-[#090d16] rounded-xl border border-slate-800">
|
||||
<span className="text-[10px] uppercase font-bold text-slate-500 block">Milestone Progress</span>
|
||||
<div className="flex items-baseline gap-1.5 mt-0.5">
|
||||
<span className="text-sm font-bold text-emerald-400">{milestoneProgress}%</span>
|
||||
<span className="text-[10px] text-slate-400">({verifiedMilestonesCount}/{milestones.length})</span>
|
||||
</div>
|
||||
<div className="w-full h-1.5 bg-slate-900 rounded-full mt-1.5 overflow-hidden">
|
||||
<div
|
||||
className="h-full bg-emerald-500 rounded-full transition-all"
|
||||
style={{ width: `${milestoneProgress}%` }}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="p-3 bg-[#090d16] rounded-xl border border-slate-800">
|
||||
<span className="text-[10px] uppercase font-bold text-slate-500 block">Work Breakdown Cost</span>
|
||||
<span className="text-sm font-bold text-cyan-400 mt-0.5 block font-mono">
|
||||
{formatZAR(totalActivityBudget)}
|
||||
</span>
|
||||
<span className="text-[10px] text-slate-400">{activities.length} total tasks</span>
|
||||
</div>
|
||||
|
||||
<div className="p-3 bg-[#090d16] rounded-xl border border-slate-800">
|
||||
<span className="text-[10px] uppercase font-bold text-slate-500 block">Governance State</span>
|
||||
<div className="mt-1">
|
||||
{(() => {
|
||||
const b = getApprovalStatusBadge(project.status);
|
||||
return (
|
||||
<span className={`inline-flex items-center px-2 py-0.5 rounded-full text-[10px] font-semibold border ${b.className}`}>
|
||||
{b.label}
|
||||
</span>
|
||||
);
|
||||
})()}
|
||||
</div>
|
||||
<span className="text-[10px] text-slate-500 mt-0.5 block">Statutory Clearance</span>
|
||||
</div>
|
||||
|
||||
{/* Total Value of Approved Project Budgets Widget */}
|
||||
<div className="p-3 bg-[#090d16] rounded-xl border border-slate-800">
|
||||
<span className="text-[10px] uppercase font-bold text-slate-500 block">Approved Budget Value</span>
|
||||
<span className="text-sm font-bold text-emerald-400 mt-0.5 block font-mono">
|
||||
{formatZAR(approvedBudgetTotal)}
|
||||
</span>
|
||||
<span className="text-[10px] text-slate-400">
|
||||
{approvedBudgetsCount > 0
|
||||
? `${approvedBudgetsCount} approved budget line${approvedBudgetsCount === 1 ? '' : 's'}`
|
||||
: 'Allocated Project Budget'}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -1,750 +0,0 @@
|
||||
import React, { useMemo } from 'react';
|
||||
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
|
||||
import { Link } from 'react-router-dom';
|
||||
import {
|
||||
FolderKanban,
|
||||
Flag,
|
||||
Briefcase,
|
||||
Target,
|
||||
ShieldAlert,
|
||||
FileCheck,
|
||||
Edit2,
|
||||
Calendar,
|
||||
Clock,
|
||||
Coins,
|
||||
ChevronRight,
|
||||
TrendingUp,
|
||||
AlertTriangle,
|
||||
AlertCircle,
|
||||
CheckCircle2,
|
||||
RotateCcw,
|
||||
Sparkles,
|
||||
ExternalLink,
|
||||
SlidersHorizontal,
|
||||
Lock,
|
||||
} from 'lucide-react';
|
||||
import { Modal } from '../ui/Modal';
|
||||
import { HelpTooltip } from '../ui/HelpTooltip';
|
||||
import { useToast } from '../ui/Toast';
|
||||
import { useRbac } from '../../auth/rbac';
|
||||
import {
|
||||
getActivities,
|
||||
getMilestones,
|
||||
getProjectIndicators,
|
||||
getProjectRisks,
|
||||
getBudgets,
|
||||
updateProjectStatus,
|
||||
} from '../../services/api';
|
||||
import { Project, ApprovalStatus, Programme } from '../../types';
|
||||
import {
|
||||
formatDate,
|
||||
formatZAR,
|
||||
getApprovalStatusBadge,
|
||||
getPriorityBadge,
|
||||
getRiskScoreColor,
|
||||
getUserEmail,
|
||||
getIndustrySectorLabel,
|
||||
getProjectTypeLabel,
|
||||
APPROVAL_STATUS_OPTIONS,
|
||||
} from '../../lib/formatters';
|
||||
import { ProjectTimelineGantt } from './ProjectTimelineGantt';
|
||||
import { evaluateProjectScheduleRisk } from '../../pages/ProjectsPage';
|
||||
|
||||
interface ProjectViewModalProps {
|
||||
isOpen: boolean;
|
||||
onClose: () => void;
|
||||
project: Project | null;
|
||||
programmeMap: Map<string | number, Programme>;
|
||||
onOpenMilestonesModal: () => void;
|
||||
onOpenActivitiesModal: () => void;
|
||||
onOpenIndicatorsModal: () => void;
|
||||
onOpenRisksModal: () => void;
|
||||
onOpenEditProject: () => void;
|
||||
}
|
||||
|
||||
export const ProjectViewModal: React.FC<ProjectViewModalProps> = ({
|
||||
isOpen,
|
||||
onClose,
|
||||
project,
|
||||
programmeMap,
|
||||
onOpenMilestonesModal,
|
||||
onOpenActivitiesModal,
|
||||
onOpenIndicatorsModal,
|
||||
onOpenRisksModal,
|
||||
onOpenEditProject,
|
||||
}) => {
|
||||
const queryClient = useQueryClient();
|
||||
const { success, error: toastError } = useToast();
|
||||
const { canAdd, canApprove, canApproveRecord, isMaker } = useRbac();
|
||||
|
||||
const projectId = project?.id;
|
||||
|
||||
// Queries for project resources
|
||||
const {
|
||||
data: activities = [],
|
||||
isLoading: loadingActivities,
|
||||
refetch: refetchActivities,
|
||||
} = useQuery({
|
||||
queryKey: ['activities', projectId],
|
||||
queryFn: () => (projectId ? getActivities(projectId) : Promise.resolve([])),
|
||||
enabled: isOpen && !!projectId,
|
||||
});
|
||||
|
||||
const {
|
||||
data: milestones = [],
|
||||
isLoading: loadingMilestones,
|
||||
refetch: refetchMilestones,
|
||||
} = useQuery({
|
||||
queryKey: ['milestones', projectId],
|
||||
queryFn: () => (projectId ? getMilestones(projectId) : Promise.resolve([])),
|
||||
enabled: isOpen && !!projectId,
|
||||
});
|
||||
|
||||
const {
|
||||
data: indicators = [],
|
||||
isLoading: loadingIndicators,
|
||||
refetch: refetchIndicators,
|
||||
} = useQuery({
|
||||
queryKey: ['indicators', projectId],
|
||||
queryFn: () => (projectId ? getProjectIndicators(projectId) : Promise.resolve([])),
|
||||
enabled: isOpen && !!projectId,
|
||||
});
|
||||
|
||||
const {
|
||||
data: risks = [],
|
||||
isLoading: loadingRisks,
|
||||
refetch: refetchRisks,
|
||||
} = useQuery({
|
||||
queryKey: ['risks', projectId],
|
||||
queryFn: () => (projectId ? getProjectRisks(projectId) : Promise.resolve([])),
|
||||
enabled: isOpen && !!projectId,
|
||||
});
|
||||
|
||||
const { data: allBudgets = [] } = useQuery({
|
||||
queryKey: ['budgets'],
|
||||
queryFn: () => getBudgets(1, 100),
|
||||
enabled: isOpen && !!projectId,
|
||||
});
|
||||
|
||||
// Governance Status Mutation
|
||||
const statusMutation = useMutation({
|
||||
mutationFn: ({ projectId, status }: { projectId: number | string; status: ApprovalStatus }) =>
|
||||
updateProjectStatus({ projectId, status }),
|
||||
onSuccess: () => {
|
||||
queryClient.invalidateQueries({ queryKey: ['projects'] });
|
||||
success('Status Updated', 'Project governance status updated successfully');
|
||||
},
|
||||
onError: (err: unknown) => {
|
||||
toastError('Failed to update status', err);
|
||||
},
|
||||
});
|
||||
|
||||
// Calculate budgets
|
||||
const projectBudgets = allBudgets.filter((b) => String(b.projectId) === String(projectId));
|
||||
const approvedBudgets = projectBudgets.filter(
|
||||
(b) =>
|
||||
b.status === undefined ||
|
||||
b.status === null ||
|
||||
Number(b.status) === ApprovalStatus.Approved ||
|
||||
Number(b.status) === 3
|
||||
);
|
||||
const approvedBudgetTotal = approvedBudgets.reduce((sum, b) => sum + (Number(b.amount) || 0), 0);
|
||||
|
||||
// Milestone Progress
|
||||
const verifiedMilestonesCount = milestones.filter((m) => m.status === ApprovalStatus.Approved).length;
|
||||
const milestoneProgress = milestones.length > 0 ? Math.round((verifiedMilestonesCount / milestones.length) * 100) : 0;
|
||||
const totalActivityBudget = activities.reduce((sum, a) => sum + (Number(a.amount) || 0), 0);
|
||||
|
||||
// Schedule Risk
|
||||
const scheduleRisk = useMemo(() => {
|
||||
if (!project) return null;
|
||||
return evaluateProjectScheduleRisk(project, { total: milestones.length, verified: verifiedMilestonesCount });
|
||||
}, [project, milestones.length, verifiedMilestonesCount]);
|
||||
|
||||
// Total Duration
|
||||
const durationDays = useMemo(() => {
|
||||
if (!project?.startedAt || !project?.endedAt) return 0;
|
||||
return Math.max(
|
||||
1,
|
||||
Math.round(
|
||||
(new Date(project.endedAt).getTime() - new Date(project.startedAt).getTime()) /
|
||||
(1000 * 60 * 60 * 24)
|
||||
)
|
||||
);
|
||||
}, [project?.startedAt, project?.endedAt]);
|
||||
|
||||
const prog = project ? programmeMap.get(project.programmeId) : null;
|
||||
|
||||
if (!project) return null;
|
||||
|
||||
const canApproveThis = canApproveRecord('projects_milestones', project.createdBy);
|
||||
const isSelf = isMaker(project.createdBy);
|
||||
const canModifyStatus = canAdd('projects_milestones') || canApprove('projects_milestones');
|
||||
|
||||
const handleRefreshAll = () => {
|
||||
refetchActivities();
|
||||
refetchMilestones();
|
||||
refetchIndicators();
|
||||
refetchRisks();
|
||||
};
|
||||
|
||||
return (
|
||||
<Modal
|
||||
isOpen={isOpen}
|
||||
onClose={onClose}
|
||||
title=""
|
||||
size="full-screen"
|
||||
zIndex="z-[9990]"
|
||||
>
|
||||
<div className="space-y-6 -mt-3 text-slate-200">
|
||||
{/* Top Header Banner: Project Title, ID, Sector, Status, and Primary Actions */}
|
||||
<div className="bg-gradient-to-r from-[#0d1322] via-[#0f172a] to-[#0d1322] p-5 sm:p-6 rounded-2xl border border-slate-800 shadow-xl relative overflow-hidden">
|
||||
<div className="absolute -right-8 -bottom-8 w-48 h-48 bg-blue-600/10 rounded-full blur-2xl pointer-events-none" />
|
||||
|
||||
<div className="flex flex-col lg:flex-row lg:items-center justify-between gap-4 relative z-10">
|
||||
{/* Project Meta Info */}
|
||||
<div className="space-y-2 min-w-0 flex-1">
|
||||
<div className="flex items-center gap-2.5 flex-wrap">
|
||||
<span className="text-xs font-mono font-bold text-blue-400 bg-blue-500/15 px-2.5 py-0.5 rounded-lg border border-blue-500/30">
|
||||
Project #{project.id}
|
||||
</span>
|
||||
<span className="text-xs font-semibold text-slate-300 bg-slate-900/90 px-2.5 py-0.5 rounded-lg border border-slate-700/80">
|
||||
{getIndustrySectorLabel(project.sector)}
|
||||
</span>
|
||||
<span className="text-xs text-slate-400">
|
||||
{getProjectTypeLabel(project.projectType)}
|
||||
</span>
|
||||
<span className="text-slate-500">•</span>
|
||||
<span className="text-xs text-slate-300">
|
||||
Programme:{' '}
|
||||
<strong className="text-blue-300">
|
||||
{prog?.name || `Prog #${project.programmeId}`}
|
||||
</strong>
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<h2 className="text-xl sm:text-2xl font-bold text-white tracking-tight leading-snug">
|
||||
{project.name || `Project #${project.id}`}
|
||||
</h2>
|
||||
|
||||
<div className="flex items-center gap-3 text-xs text-slate-400 flex-wrap pt-0.5">
|
||||
<span>
|
||||
Owner:{' '}
|
||||
<strong className="text-slate-200">{getUserEmail(project.createdBy)}</strong>
|
||||
</span>
|
||||
<span className="text-slate-600">•</span>
|
||||
<div className="flex items-center gap-1 font-mono text-slate-300">
|
||||
<Calendar className="w-3.5 h-3.5 text-blue-400 shrink-0" />
|
||||
<span>
|
||||
{formatDate(project.startedAt)} → {formatDate(project.endedAt)} ({durationDays} Days)
|
||||
</span>
|
||||
</div>
|
||||
<span className="text-slate-600">•</span>
|
||||
<span className="text-emerald-400 font-mono font-bold">
|
||||
Budget: {formatZAR(approvedBudgetTotal)}
|
||||
</span>
|
||||
<span className="text-slate-600">•</span>
|
||||
<span className="text-blue-400 font-mono font-bold">
|
||||
Project Value: {formatZAR(totalActivityBudget)}
|
||||
</span>
|
||||
<span className="text-slate-600">•</span>
|
||||
<span
|
||||
className={`font-mono font-bold px-2 py-0.5 rounded text-[11px] border ${
|
||||
approvedBudgetTotal - totalActivityBudget >= 0
|
||||
? 'text-emerald-400 bg-emerald-500/10 border-emerald-500/30'
|
||||
: 'text-rose-400 bg-rose-500/10 border-rose-500/30'
|
||||
}`}
|
||||
title="Approved Budget minus Total Project Value"
|
||||
>
|
||||
Variance: {approvedBudgetTotal - totalActivityBudget >= 0 ? '+' : ''}
|
||||
{formatZAR(approvedBudgetTotal - totalActivityBudget)}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Status & Action Controls */}
|
||||
<div className="flex flex-col sm:flex-row items-start sm:items-center gap-3 shrink-0">
|
||||
{/* Schedule Risk Pill */}
|
||||
{scheduleRisk && (
|
||||
<div className="flex items-center gap-1.5">
|
||||
<span
|
||||
className={`inline-flex items-center gap-1.5 px-3 py-1.5 rounded-xl text-xs font-bold border ${scheduleRisk.badgeClass}`}
|
||||
title={scheduleRisk.reason}
|
||||
>
|
||||
{scheduleRisk.isOverdue ? (
|
||||
<AlertTriangle className="w-4 h-4 text-rose-400" />
|
||||
) : scheduleRisk.isAtRisk ? (
|
||||
<AlertCircle className="w-4 h-4 text-amber-400" />
|
||||
) : (
|
||||
<CheckCircle2 className="w-4 h-4 text-emerald-400" />
|
||||
)}
|
||||
<span>
|
||||
{scheduleRisk.isOverdue
|
||||
? 'Overdue Schedule'
|
||||
: scheduleRisk.isAtRisk
|
||||
? `At Risk (${scheduleRisk.daysRemaining}d)`
|
||||
: 'On Track'}
|
||||
</span>
|
||||
</span>
|
||||
<HelpTooltip
|
||||
title="Schedule Risk Assessment"
|
||||
content={scheduleRisk.reason}
|
||||
size="sm"
|
||||
placement="bottom"
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Real-time Governance Status Dropdown / Immutable Locked State */}
|
||||
<div className="min-w-[140px]">
|
||||
{Number(project.status) === ApprovalStatus.Approved ? (
|
||||
<div
|
||||
className="inline-flex items-center gap-1.5 px-3 py-2 rounded-xl text-xs font-bold bg-emerald-500/15 border border-emerald-500/30 text-emerald-300 shadow-sm cursor-default"
|
||||
title="Approved records are immutable. Status cannot be modified."
|
||||
>
|
||||
<Lock className="w-3.5 h-3.5 text-emerald-400" />
|
||||
<span>Approved (Locked)</span>
|
||||
</div>
|
||||
) : (
|
||||
<select
|
||||
value={project.status}
|
||||
disabled={!canModifyStatus}
|
||||
onChange={(e) =>
|
||||
statusMutation.mutate({
|
||||
projectId: project.id,
|
||||
status: Number(e.target.value) as ApprovalStatus,
|
||||
})
|
||||
}
|
||||
className="w-full text-xs font-semibold bg-[#090d16] border border-slate-700/90 rounded-xl px-2.5 py-2 text-slate-200 focus:outline-none focus:ring-2 focus:ring-blue-500 cursor-pointer disabled:opacity-60 disabled:cursor-not-allowed truncate shadow-md"
|
||||
title="Project Governance Clearance Status"
|
||||
>
|
||||
{APPROVAL_STATUS_OPTIONS.map((opt) => {
|
||||
const isApprovedOpt = opt.value === ApprovalStatus.Approved;
|
||||
const isOptDisabled = isApprovedOpt && !canApproveThis;
|
||||
let reasonText = '';
|
||||
if (isApprovedOpt && !canApprove('projects_milestones')) {
|
||||
reasonText = ' (Requires Update)';
|
||||
} else if (isApprovedOpt && isSelf) {
|
||||
reasonText = ' (Maker-Checker)';
|
||||
}
|
||||
return (
|
||||
<option key={opt.value} value={opt.value} disabled={isOptDisabled}>
|
||||
{opt.label}{reasonText}
|
||||
</option>
|
||||
);
|
||||
})}
|
||||
</select>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Action Buttons */}
|
||||
<div className="flex items-center gap-2">
|
||||
<Link
|
||||
to={`/monitoring?tab=evidence&projectId=${project.id}`}
|
||||
className="inline-flex items-center gap-1.5 px-3 py-2 text-xs font-semibold text-blue-300 hover:text-white bg-blue-500/10 hover:bg-blue-500/20 border border-blue-500/30 rounded-xl transition cursor-pointer shadow-xs"
|
||||
title="Review Evidence Queue"
|
||||
>
|
||||
<FileCheck className="w-3.5 h-3.5 text-blue-400" />
|
||||
<span className="hidden sm:inline">Evidence Queue</span>
|
||||
</Link>
|
||||
|
||||
<button
|
||||
type="button"
|
||||
onClick={onOpenEditProject}
|
||||
className="inline-flex items-center gap-1.5 px-3 py-2 text-xs font-semibold text-slate-300 hover:text-white bg-slate-800 hover:bg-slate-700 border border-slate-700 rounded-xl transition cursor-pointer shadow-xs"
|
||||
title="Edit Project Scope & Schedule"
|
||||
>
|
||||
<Edit2 className="w-3.5 h-3.5 text-slate-400" />
|
||||
<span className="hidden sm:inline">Edit Scope</span>
|
||||
</button>
|
||||
|
||||
<button
|
||||
type="button"
|
||||
onClick={handleRefreshAll}
|
||||
className="p-2 rounded-xl text-blue-400 hover:text-blue-300 bg-slate-900/80 hover:bg-slate-800 border border-slate-700/80 transition cursor-pointer shadow-xs"
|
||||
title="Refresh project datasets"
|
||||
>
|
||||
<RotateCcw className="w-4 h-4" />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Project Narrative */}
|
||||
{project.description && (
|
||||
<div className="mt-3.5 pt-3 border-t border-slate-800/80 text-xs text-slate-300 leading-relaxed max-w-5xl">
|
||||
<span className="font-semibold text-slate-400 mr-1.5">Scope Narrative:</span>
|
||||
{project.description}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* 5 Vital Project KPI Metrics Bar */}
|
||||
<div className="grid grid-cols-2 sm:grid-cols-3 xl:grid-cols-5 gap-3.5">
|
||||
{/* Project Duration */}
|
||||
<div className="p-3.5 bg-[#0d121f] rounded-2xl border border-slate-800 shadow-md">
|
||||
<div className="flex items-center justify-between text-slate-400">
|
||||
<span className="text-[10px] uppercase font-bold tracking-wider">Project Duration</span>
|
||||
<Calendar className="w-3.5 h-3.5 text-blue-400" />
|
||||
</div>
|
||||
<span className="text-base font-bold text-white mt-1 block font-mono">
|
||||
{durationDays} Days
|
||||
</span>
|
||||
<span className="text-[10px] text-slate-400 block truncate mt-0.5">
|
||||
{formatDate(project.startedAt)} → {formatDate(project.endedAt)}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
{/* Milestone Progress */}
|
||||
<div className="p-3.5 bg-[#0d121f] rounded-2xl border border-slate-800 shadow-md">
|
||||
<div className="flex items-center justify-between text-slate-400">
|
||||
<span className="text-[10px] uppercase font-bold tracking-wider">Milestone Progress</span>
|
||||
<Flag className="w-3.5 h-3.5 text-emerald-400" />
|
||||
</div>
|
||||
<div className="flex items-baseline gap-2 mt-1">
|
||||
<span className="text-base font-bold text-emerald-400">{milestoneProgress}%</span>
|
||||
<span className="text-xs text-slate-400">
|
||||
({verifiedMilestonesCount}/{milestones.length} Verified)
|
||||
</span>
|
||||
</div>
|
||||
<div className="w-full h-1.5 bg-slate-900 rounded-full mt-2 overflow-hidden border border-slate-800">
|
||||
<div
|
||||
className="h-full bg-emerald-500 rounded-full transition-all duration-300"
|
||||
style={{ width: `${milestoneProgress}%` }}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Work Breakdown Cost */}
|
||||
<div className="p-3.5 bg-[#0d121f] rounded-2xl border border-slate-800 shadow-md">
|
||||
<div className="flex items-center justify-between text-slate-400">
|
||||
<span className="text-[10px] uppercase font-bold tracking-wider">Work Breakdown Cost</span>
|
||||
<Coins className="w-3.5 h-3.5 text-cyan-400" />
|
||||
</div>
|
||||
<span className="text-base font-bold text-cyan-400 mt-1 block font-mono">
|
||||
{formatZAR(totalActivityBudget)}
|
||||
</span>
|
||||
<span className="text-[10px] text-slate-400 block truncate mt-0.5">
|
||||
{activities.length} total recorded tasks
|
||||
</span>
|
||||
</div>
|
||||
|
||||
{/* Governance State */}
|
||||
<div className="p-3.5 bg-[#0d121f] rounded-2xl border border-slate-800 shadow-md">
|
||||
<div className="flex items-center justify-between text-slate-400">
|
||||
<span className="text-[10px] uppercase font-bold tracking-wider">Governance State</span>
|
||||
<CheckCircle2 className="w-3.5 h-3.5 text-indigo-400" />
|
||||
</div>
|
||||
<div className="mt-1">
|
||||
{(() => {
|
||||
const b = getApprovalStatusBadge(project.status);
|
||||
return (
|
||||
<span
|
||||
className={`inline-flex items-center px-2.5 py-0.5 rounded-full text-[11px] font-semibold border ${b.className}`}
|
||||
>
|
||||
{b.label}
|
||||
</span>
|
||||
);
|
||||
})()}
|
||||
</div>
|
||||
<span className="text-[10px] text-slate-500 mt-1 block">Statutory Clearance Gate</span>
|
||||
</div>
|
||||
|
||||
{/* Approved Budget Total */}
|
||||
<div className="p-3.5 bg-[#0d121f] rounded-2xl border border-slate-800 shadow-md">
|
||||
<div className="flex items-center justify-between text-slate-400">
|
||||
<span className="text-[10px] uppercase font-bold tracking-wider">Approved Budget Value</span>
|
||||
<TrendingUp className="w-3.5 h-3.5 text-emerald-400" />
|
||||
</div>
|
||||
<span className="text-base font-bold text-emerald-400 mt-1 block font-mono">
|
||||
{formatZAR(approvedBudgetTotal)}
|
||||
</span>
|
||||
<span className="text-[10px] text-slate-400 block truncate mt-0.5">
|
||||
{approvedBudgets.length > 0
|
||||
? `${approvedBudgets.length} approved budget line${approvedBudgets.length === 1 ? '' : 's'}`
|
||||
: 'Allocated Project Budget'}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Enhanced Navigable Timeline Gantt Chart Component (Requirements 4, 5, 6) */}
|
||||
<ProjectTimelineGantt
|
||||
project={project}
|
||||
milestones={milestones}
|
||||
activities={activities}
|
||||
approvedBudgetTotal={approvedBudgetTotal}
|
||||
approvedBudgetsCount={approvedBudgets.length}
|
||||
onManageMilestones={onOpenMilestonesModal}
|
||||
onManageActivities={onOpenActivitiesModal}
|
||||
/>
|
||||
|
||||
{/* 4 Vital Resource Summary Cards (Milestones, Activities, Indicators, Risks) */}
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 2xl:grid-cols-4 gap-4">
|
||||
{/* Card 1: Milestones */}
|
||||
<div className="bg-[#0d121f] rounded-2xl border border-slate-800 p-4 flex flex-col justify-between space-y-3 shadow-md">
|
||||
<div className="space-y-3">
|
||||
<div className="flex items-center justify-between border-b border-slate-800/80 pb-2.5">
|
||||
<div className="flex items-center gap-2 text-xs font-bold text-white">
|
||||
<Flag className="w-4 h-4 text-blue-400" />
|
||||
<span>Contractual Milestones ({milestones.length})</span>
|
||||
</div>
|
||||
<span className="text-[10px] font-mono text-emerald-400 font-bold bg-emerald-500/10 px-2 py-0.5 rounded-full border border-emerald-500/30">
|
||||
{verifiedMilestonesCount}/{milestones.length} Verified
|
||||
</span>
|
||||
</div>
|
||||
|
||||
{milestones.length === 0 ? (
|
||||
<p className="text-xs text-slate-500 py-4 text-center">No contractual milestones defined.</p>
|
||||
) : (
|
||||
<div className="space-y-2">
|
||||
{milestones.slice(0, 4).map((m) => {
|
||||
const sBadge = getApprovalStatusBadge(m.status);
|
||||
return (
|
||||
<div
|
||||
key={m.id}
|
||||
className="p-2.5 rounded-xl bg-slate-900/80 border border-slate-800 text-xs space-y-1 hover:border-slate-700 transition"
|
||||
>
|
||||
<div className="flex items-start justify-between gap-2">
|
||||
<span className="font-semibold text-slate-200 truncate block">{m.name}</span>
|
||||
<span className={`shrink-0 text-[9px] px-1.5 py-0.2 rounded border ${sBadge.className}`}>
|
||||
{sBadge.label}
|
||||
</span>
|
||||
</div>
|
||||
<div className="text-[10px] text-slate-400 flex items-center justify-between font-mono">
|
||||
<span className="flex items-center gap-1">
|
||||
<Clock className="w-3 h-3 text-slate-500" />
|
||||
Due: {formatDate(m.dueAt)}
|
||||
</span>
|
||||
<span className="text-slate-500">#{m.id}</span>
|
||||
</div>
|
||||
{(() => {
|
||||
const mActivities = activities.filter((a) => String(a.milestoneId) === String(m.id));
|
||||
const mValue = mActivities.reduce((sum, a) => sum + (Number(a.amount) || 0), 0);
|
||||
return (
|
||||
<div className="flex items-center justify-between text-[10px] font-mono pt-1 border-t border-slate-800/60">
|
||||
<span className="text-slate-400">Milestone Value:</span>
|
||||
<span className="font-bold text-blue-400">{formatZAR(mValue)}</span>
|
||||
</div>
|
||||
);
|
||||
})()}
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
{milestones.length > 4 && (
|
||||
<p className="text-[10px] text-slate-400 text-center font-medium">
|
||||
+{milestones.length - 4} more deliverables
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<button
|
||||
type="button"
|
||||
onClick={onOpenMilestonesModal}
|
||||
className="w-full py-2 px-3 text-xs font-semibold text-blue-400 bg-blue-500/10 hover:bg-blue-500/20 border border-blue-500/30 rounded-xl transition flex items-center justify-center gap-1.5 cursor-pointer shadow-xs"
|
||||
>
|
||||
<span>Manage Milestones & Deliverables</span>
|
||||
<ChevronRight className="w-3.5 h-3.5" />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Card 2: Activities */}
|
||||
<div className="bg-[#0d121f] rounded-2xl border border-slate-800 p-4 flex flex-col justify-between space-y-3 shadow-md">
|
||||
<div className="space-y-3">
|
||||
<div className="flex items-center justify-between border-b border-slate-800/80 pb-2.5">
|
||||
<div className="flex items-center gap-2 text-xs font-bold text-white">
|
||||
<Briefcase className="w-4 h-4 text-cyan-400" />
|
||||
<span>Work Activities ({activities.length})</span>
|
||||
</div>
|
||||
<span className="text-[10px] font-mono text-cyan-400 font-bold bg-cyan-500/10 px-2 py-0.5 rounded-full border border-cyan-500/30">
|
||||
{formatZAR(totalActivityBudget)}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
{activities.length === 0 ? (
|
||||
<p className="text-xs text-slate-500 py-4 text-center">No work activities recorded.</p>
|
||||
) : (
|
||||
<div className="space-y-2">
|
||||
{activities.slice(0, 4).map((act) => (
|
||||
<div
|
||||
key={act.id}
|
||||
className="p-2.5 rounded-xl bg-slate-900/80 border border-slate-800 text-xs space-y-1 hover:border-slate-700 transition"
|
||||
>
|
||||
{(() => {
|
||||
const actAmount = Number(act.amount) || 0;
|
||||
const actIndicators = indicators.filter((ind) => String(ind.activityId) === String(act.id));
|
||||
const actActuals = actIndicators.reduce((sum, ind) => sum + (Number(ind.actualCost) || 0), 0);
|
||||
const actVariance = actAmount - actActuals;
|
||||
const isPositive = actVariance >= 0;
|
||||
return (
|
||||
<>
|
||||
<div className="flex items-start justify-between gap-2">
|
||||
<span className="font-semibold text-slate-200 truncate block">{act.title}</span>
|
||||
<div className="flex items-center gap-1.5 shrink-0 font-mono text-xs">
|
||||
<span className="font-bold text-cyan-400">{formatZAR(actAmount)}</span>
|
||||
<span
|
||||
className={`text-[9px] font-bold px-1.5 py-0.2 rounded border ${
|
||||
isPositive
|
||||
? 'text-emerald-400 bg-emerald-500/10 border-emerald-500/30'
|
||||
: 'text-rose-400 bg-rose-500/10 border-rose-500/30'
|
||||
}`}
|
||||
title={`Budgeted: ${formatZAR(actAmount)} | Incurred Actuals: ${formatZAR(actActuals)} | Variance: ${isPositive ? '+' : ''}${formatZAR(actVariance)}`}
|
||||
>
|
||||
{isPositive ? `+${formatZAR(actVariance)}` : `-${formatZAR(Math.abs(actVariance))}`}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
})()}
|
||||
<div className="flex items-center justify-between text-[10px] text-slate-400">
|
||||
<span>{act.category || 'Operations'}</span>
|
||||
<span>{formatDate(act.performedAt)}</span>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
{activities.length > 4 && (
|
||||
<p className="text-[10px] text-slate-400 text-center font-medium">
|
||||
+{activities.length - 4} more tasks
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<button
|
||||
type="button"
|
||||
onClick={onOpenActivitiesModal}
|
||||
className="w-full py-2 px-3 text-xs font-semibold text-cyan-400 bg-cyan-500/10 hover:bg-cyan-500/20 border border-cyan-500/30 rounded-xl transition flex items-center justify-center gap-1.5 cursor-pointer shadow-xs"
|
||||
>
|
||||
<span>Manage Work Activities</span>
|
||||
<ChevronRight className="w-3.5 h-3.5" />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Card 3: Indicators */}
|
||||
<div className="bg-[#0d121f] rounded-2xl border border-slate-800 p-4 flex flex-col justify-between space-y-3 shadow-md">
|
||||
<div className="space-y-3">
|
||||
<div className="flex items-center justify-between border-b border-slate-800/80 pb-2.5">
|
||||
<div className="flex items-center gap-2 text-xs font-bold text-white">
|
||||
<Target className="w-4 h-4 text-indigo-400" />
|
||||
<span>M&E Indicators ({indicators.length})</span>
|
||||
</div>
|
||||
<span className="text-[10px] font-mono text-indigo-400 font-bold bg-indigo-500/10 px-2 py-0.5 rounded-full border border-indigo-500/30">
|
||||
Results Chain
|
||||
</span>
|
||||
</div>
|
||||
|
||||
{indicators.length === 0 ? (
|
||||
<p className="text-xs text-slate-500 py-4 text-center">No M&E indicators defined.</p>
|
||||
) : (
|
||||
<div className="space-y-2">
|
||||
{indicators.slice(0, 4).map((ind) => {
|
||||
const target = Number(ind.targetAmount) || 1;
|
||||
const actual = Number(ind.actualAmount) || 0;
|
||||
const pct = Math.round((actual / target) * 100);
|
||||
return (
|
||||
<div
|
||||
key={ind.id}
|
||||
className="p-2.5 rounded-xl bg-slate-900/80 border border-slate-800 text-xs space-y-1 hover:border-slate-700 transition"
|
||||
>
|
||||
<div className="flex items-start justify-between gap-2">
|
||||
<span className="font-semibold text-slate-200 truncate block">{ind.name}</span>
|
||||
<span
|
||||
className={
|
||||
pct >= 100 ? 'text-emerald-400 font-bold' : 'text-indigo-400 font-bold'
|
||||
}
|
||||
>
|
||||
{pct}%
|
||||
</span>
|
||||
</div>
|
||||
<div className="flex items-center justify-between text-[10px] text-slate-400">
|
||||
<span>
|
||||
Actual: <strong className="text-slate-200 font-mono">{actual}</strong>
|
||||
</span>
|
||||
<span>
|
||||
Target: <strong className="text-slate-200 font-mono">{target}</strong>
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
{indicators.length > 4 && (
|
||||
<p className="text-[10px] text-slate-400 text-center font-medium">
|
||||
+{indicators.length - 4} more indicators
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<button
|
||||
type="button"
|
||||
onClick={onOpenIndicatorsModal}
|
||||
className="w-full py-2 px-3 text-xs font-semibold text-indigo-400 bg-indigo-500/10 hover:bg-indigo-500/20 border border-indigo-500/30 rounded-xl transition flex items-center justify-center gap-1.5 cursor-pointer shadow-xs"
|
||||
>
|
||||
<span>Manage M&E Indicators</span>
|
||||
<ChevronRight className="w-3.5 h-3.5" />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Card 4: Risks */}
|
||||
<div className="bg-[#0d121f] rounded-2xl border border-slate-800 p-4 flex flex-col justify-between space-y-3 shadow-md">
|
||||
<div className="space-y-3">
|
||||
<div className="flex items-center justify-between border-b border-slate-800/80 pb-2.5">
|
||||
<div className="flex items-center gap-2 text-xs font-bold text-white">
|
||||
<ShieldAlert className="w-4 h-4 text-rose-400" />
|
||||
<span>Risk Register ({risks.length})</span>
|
||||
</div>
|
||||
<span className="text-[10px] font-mono text-rose-400 font-bold bg-rose-500/10 px-2 py-0.5 rounded-full border border-rose-500/30">
|
||||
ISO 31000
|
||||
</span>
|
||||
</div>
|
||||
|
||||
{risks.length === 0 ? (
|
||||
<p className="text-xs text-slate-500 py-4 text-center">No project hazards logged.</p>
|
||||
) : (
|
||||
<div className="space-y-2">
|
||||
{risks.slice(0, 4).map((rk) => {
|
||||
const score = (rk.likelihood || 1) * (rk.impact || 1);
|
||||
const riskInfo = getRiskScoreColor(rk.likelihood || 1, rk.impact || 1);
|
||||
return (
|
||||
<div
|
||||
key={rk.id}
|
||||
className="p-2.5 rounded-xl bg-slate-900/80 border border-slate-800 text-xs space-y-1 hover:border-slate-700 transition"
|
||||
>
|
||||
<div className="flex items-start justify-between gap-2">
|
||||
<span className="font-semibold text-slate-200 truncate block">{rk.name}</span>
|
||||
<span className={`text-[9px] px-1.5 py-0.2 rounded font-mono ${riskInfo.badge}`}>
|
||||
{score}/25 {riskInfo.level}
|
||||
</span>
|
||||
</div>
|
||||
<p className="text-[10px] text-slate-400 line-clamp-1">{rk.description}</p>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
{risks.length > 4 && (
|
||||
<p className="text-[10px] text-slate-400 text-center font-medium">
|
||||
+{risks.length - 4} more risks
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<button
|
||||
type="button"
|
||||
onClick={onOpenRisksModal}
|
||||
className="w-full py-2 px-3 text-xs font-semibold text-rose-400 bg-rose-500/10 hover:bg-rose-500/20 border border-rose-500/30 rounded-xl transition flex items-center justify-center gap-1.5 cursor-pointer shadow-xs"
|
||||
>
|
||||
<span>Manage Risk Register</span>
|
||||
<ChevronRight className="w-3.5 h-3.5" />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</Modal>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -1,8 +1,5 @@
|
||||
import React, { Component, ErrorInfo, ReactNode, useContext } from 'react';
|
||||
import { Link } from 'react-router-dom';
|
||||
import React, { Component, ErrorInfo, ReactNode } from 'react';
|
||||
import { AlertTriangle, RefreshCw, Home } from 'lucide-react';
|
||||
import { AuthContext } from '../../auth/AuthProvider';
|
||||
import { evaluateUserPermission } from '../../auth/rbac';
|
||||
|
||||
interface Props {
|
||||
children: ReactNode;
|
||||
@@ -14,70 +11,6 @@ interface State {
|
||||
error: Error | null;
|
||||
}
|
||||
|
||||
interface FallbackContentProps {
|
||||
fallbackTitle?: string;
|
||||
error: Error | null;
|
||||
onReset: () => void;
|
||||
}
|
||||
|
||||
const ErrorBoundaryFallbackContent: React.FC<FallbackContentProps> = ({
|
||||
fallbackTitle,
|
||||
error,
|
||||
onReset,
|
||||
}) => {
|
||||
const auth = useContext(AuthContext);
|
||||
const roles = auth?.roles || [];
|
||||
const claims = auth?.claims || {};
|
||||
const isUnrestricted =
|
||||
roles.some((r) => r.toLowerCase().trim() === 'admin') ||
|
||||
claims?.unrestricted === true ||
|
||||
claims?.unrestricted === 'all';
|
||||
|
||||
const canAccessDashboard = isUnrestricted || evaluateUserPermission(roles, claims, 'dashboard', 'view');
|
||||
|
||||
return (
|
||||
<div className="min-h-[400px] flex items-center justify-center p-6">
|
||||
<div className="max-w-md w-full bg-[#0f1523]/90 backdrop-blur-md rounded-2xl border border-rose-500/30 p-6 text-center shadow-2xl">
|
||||
<div className="w-12 h-12 rounded-2xl bg-rose-500/10 border border-rose-500/20 text-rose-400 flex items-center justify-center mx-auto mb-4">
|
||||
<AlertTriangle className="w-6 h-6" />
|
||||
</div>
|
||||
<h3 className="text-base font-bold text-white mb-2">
|
||||
{fallbackTitle || 'Display Module Encountered an Issue'}
|
||||
</h3>
|
||||
<p className="text-xs text-slate-400 mb-4 leading-relaxed">
|
||||
{canAccessDashboard
|
||||
? 'An unexpected display error occurred while rendering this view. You can reload the view or return to the executive dashboard.'
|
||||
: 'An unexpected display error occurred while rendering this view. Please try reloading the view.'}
|
||||
</p>
|
||||
{error?.message && (
|
||||
<div className="mb-4 p-2.5 bg-rose-950/40 rounded-xl border border-rose-800/40 text-left text-[11px] text-rose-300 font-mono break-all max-h-24 overflow-y-auto">
|
||||
{error.message}
|
||||
</div>
|
||||
)}
|
||||
<div className="flex items-center justify-center gap-3">
|
||||
<button
|
||||
type="button"
|
||||
onClick={onReset}
|
||||
className="inline-flex items-center gap-1.5 px-4 py-2 rounded-xl text-xs font-semibold bg-slate-800 hover:bg-slate-700 text-white border border-slate-700 transition cursor-pointer"
|
||||
>
|
||||
<RefreshCw className="w-3.5 h-3.5 text-blue-400" />
|
||||
<span>Reload View</span>
|
||||
</button>
|
||||
{canAccessDashboard && (
|
||||
<Link
|
||||
to="/dashboard"
|
||||
className="inline-flex items-center gap-1.5 px-4 py-2 rounded-xl text-xs font-semibold bg-blue-600 hover:bg-blue-500 text-white transition cursor-pointer shadow-md shadow-blue-500/20"
|
||||
>
|
||||
<Home className="w-3.5 h-3.5" />
|
||||
<span>Dashboard</span>
|
||||
</Link>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export class ErrorBoundary extends React.Component<Props, State> {
|
||||
declare props: Props;
|
||||
declare state: State;
|
||||
@@ -99,17 +32,42 @@ export class ErrorBoundary extends React.Component<Props, State> {
|
||||
}
|
||||
|
||||
public handleReset = () => {
|
||||
this.setState({ hasError: false, error: null });
|
||||
(this as any).setState({ hasError: false, error: null });
|
||||
};
|
||||
|
||||
public render() {
|
||||
if (this.state.hasError) {
|
||||
return (
|
||||
<ErrorBoundaryFallbackContent
|
||||
fallbackTitle={this.props.fallbackTitle}
|
||||
error={this.state.error}
|
||||
onReset={this.handleReset}
|
||||
/>
|
||||
<div className="min-h-[400px] flex items-center justify-center p-6">
|
||||
<div className="max-w-md w-full bg-[#0f1523]/90 backdrop-blur-md rounded-2xl border border-rose-500/30 p-6 text-center shadow-2xl">
|
||||
<div className="w-12 h-12 rounded-2xl bg-rose-500/10 border border-rose-500/20 text-rose-400 flex items-center justify-center mx-auto mb-4">
|
||||
<AlertTriangle className="w-6 h-6" />
|
||||
</div>
|
||||
<h3 className="text-base font-bold text-white mb-2">
|
||||
{this.props.fallbackTitle || 'Display Module Encountered an Issue'}
|
||||
</h3>
|
||||
<p className="text-xs text-slate-400 mb-6 leading-relaxed">
|
||||
An unexpected display error occurred while rendering this view. You can reload the view or return to the executive dashboard.
|
||||
</p>
|
||||
<div className="flex items-center justify-center gap-3">
|
||||
<button
|
||||
type="button"
|
||||
onClick={this.handleReset}
|
||||
className="inline-flex items-center gap-1.5 px-4 py-2 rounded-xl text-xs font-semibold bg-slate-800 hover:bg-slate-700 text-white border border-slate-700 transition cursor-pointer"
|
||||
>
|
||||
<RefreshCw className="w-3.5 h-3.5 text-blue-400" />
|
||||
<span>Reload View</span>
|
||||
</button>
|
||||
<a
|
||||
href="/dashboard"
|
||||
className="inline-flex items-center gap-1.5 px-4 py-2 rounded-xl text-xs font-semibold bg-blue-600 hover:bg-blue-500 text-white transition cursor-pointer shadow-md shadow-blue-500/20"
|
||||
>
|
||||
<Home className="w-3.5 h-3.5" />
|
||||
<span>Dashboard</span>
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -3,7 +3,6 @@ import { createPortal } from 'react-dom';
|
||||
import { Download, FileSpreadsheet, FileText, ChevronDown } from 'lucide-react';
|
||||
import { ExportColumn, exportToSpreadsheet, exportToPdf } from '../../utils/exportUtils';
|
||||
import { useAuth } from '../../auth/AuthProvider';
|
||||
import { useRbac, RoleClaim } from '../../auth/rbac';
|
||||
|
||||
export interface ExportButtonProps<T = any> {
|
||||
title: string;
|
||||
@@ -14,7 +13,6 @@ export interface ExportButtonProps<T = any> {
|
||||
label?: string;
|
||||
size?: 'sm' | 'md';
|
||||
className?: string;
|
||||
claim?: RoleClaim;
|
||||
}
|
||||
|
||||
export function ExportButton<T>({
|
||||
@@ -26,17 +24,14 @@ export function ExportButton<T>({
|
||||
label = 'Export',
|
||||
size = 'md',
|
||||
className = '',
|
||||
claim,
|
||||
}: ExportButtonProps<T>) {
|
||||
const [isOpen, setIsOpen] = useState(false);
|
||||
const [menuPosition, setMenuPosition] = useState<{ top: number; right: number } | null>(null);
|
||||
const dropdownRef = useRef<HTMLDivElement>(null);
|
||||
const menuRef = useRef<HTMLDivElement>(null);
|
||||
const { user, claims, tenantId } = useAuth();
|
||||
const { canExport, isUnrestricted } = useRbac();
|
||||
|
||||
const isClaimAllowed = claim ? canExport(claim) : isUnrestricted;
|
||||
const isDisabled = !data || data.length === 0 || !isClaimAllowed;
|
||||
const isDisabled = !data || data.length === 0;
|
||||
|
||||
const updatePosition = useCallback(() => {
|
||||
if (!dropdownRef.current) return;
|
||||
@@ -72,31 +67,31 @@ export function ExportButton<T>({
|
||||
};
|
||||
}, [isOpen, updatePosition]);
|
||||
|
||||
const handleExportSpreadsheet = async () => {
|
||||
const handleExportSpreadsheet = () => {
|
||||
if (isDisabled) return;
|
||||
setIsOpen(false);
|
||||
await exportToSpreadsheet({
|
||||
exportToSpreadsheet({
|
||||
title,
|
||||
subtitle,
|
||||
filename,
|
||||
columns,
|
||||
data,
|
||||
tenantName: (claims?.tenant_name as string) || (claims?.tenant as string) || `Tenant #${tenantId || 1}`,
|
||||
userEmail: user?.email || (claims?.email as string) || '',
|
||||
tenantName: (claims as any)?.tenant_name || (claims as any)?.tenant || `Tenant #${tenantId || 1}`,
|
||||
userEmail: user?.profile?.email || (claims as any)?.email || '',
|
||||
});
|
||||
};
|
||||
|
||||
const handleExportPdf = async () => {
|
||||
const handleExportPdf = () => {
|
||||
if (isDisabled) return;
|
||||
setIsOpen(false);
|
||||
await exportToPdf({
|
||||
exportToPdf({
|
||||
title,
|
||||
subtitle,
|
||||
filename,
|
||||
columns,
|
||||
data,
|
||||
tenantName: (claims?.tenant_name as string) || (claims?.tenant as string) || `Tenant #${tenantId || 1}`,
|
||||
userEmail: user?.email || (claims?.email as string) || '',
|
||||
tenantName: (claims as any)?.tenant_name || (claims as any)?.tenant || `Tenant #${tenantId || 1}`,
|
||||
userEmail: user?.profile?.email || (claims as any)?.email || '',
|
||||
});
|
||||
};
|
||||
|
||||
|
||||
@@ -1,18 +1,15 @@
|
||||
import React, { ReactNode, useEffect } from 'react';
|
||||
import { createPortal } from 'react-dom';
|
||||
import { X } from 'lucide-react';
|
||||
import { HelpTooltip } from './HelpTooltip';
|
||||
|
||||
export interface ModalProps {
|
||||
interface ModalProps {
|
||||
isOpen: boolean;
|
||||
onClose: () => void;
|
||||
title: string;
|
||||
subtitle?: string;
|
||||
helpText?: ReactNode;
|
||||
children: ReactNode;
|
||||
maxWidth?: 'sm' | 'md' | 'lg' | 'xl' | '2xl' | '3xl' | '4xl' | '5xl' | '6xl' | '7xl' | 'full-screen' | 'full';
|
||||
size?: 'sm' | 'md' | 'lg' | 'xl' | '2xl' | '3xl' | '4xl' | '5xl' | '6xl' | '7xl' | 'full-screen' | 'full' | string;
|
||||
zIndex?: string;
|
||||
maxWidth?: 'sm' | 'md' | 'lg' | 'xl' | '2xl';
|
||||
}
|
||||
|
||||
export const Modal: React.FC<ModalProps> = ({
|
||||
@@ -23,8 +20,6 @@ export const Modal: React.FC<ModalProps> = ({
|
||||
helpText,
|
||||
children,
|
||||
maxWidth = 'lg',
|
||||
size,
|
||||
zIndex = 'z-[9999]',
|
||||
}) => {
|
||||
useEffect(() => {
|
||||
const handleKeyDown = (e: KeyboardEvent) => {
|
||||
@@ -48,35 +43,21 @@ export const Modal: React.FC<ModalProps> = ({
|
||||
|
||||
if (!isOpen) return null;
|
||||
|
||||
const effectiveSize = (size || maxWidth) as string;
|
||||
const maxWidthClassMap: Record<string, string> = {
|
||||
const maxWidthClass = {
|
||||
sm: 'max-w-sm',
|
||||
md: 'max-w-md',
|
||||
lg: 'max-w-lg',
|
||||
xl: 'max-w-xl',
|
||||
'2xl': 'max-w-2xl',
|
||||
'3xl': 'max-w-3xl',
|
||||
'4xl': 'max-w-4xl',
|
||||
'5xl': 'max-w-5xl',
|
||||
'6xl': 'max-w-6xl',
|
||||
'7xl': 'max-w-7xl',
|
||||
'full-screen': 'max-w-[96vw]',
|
||||
full: 'max-w-full',
|
||||
};
|
||||
const maxWidthClass = maxWidthClassMap[effectiveSize] || 'max-w-lg';
|
||||
}[maxWidth];
|
||||
|
||||
const modalContent = (
|
||||
return (
|
||||
<div className="fixed inset-0 z-50 flex items-center justify-center p-4 bg-black/80 backdrop-blur-sm animate-in fade-in duration-150">
|
||||
<div
|
||||
className={`fixed inset-0 ${zIndex} flex items-center justify-center p-4 bg-black/80 backdrop-blur-md animate-in fade-in duration-150 overflow-y-auto`}
|
||||
onClick={onClose}
|
||||
role="dialog"
|
||||
aria-modal="true"
|
||||
>
|
||||
<div
|
||||
className={`relative w-full ${maxWidthClass} bg-[#0f1523] rounded-2xl shadow-2xl border border-slate-800/90 overflow-hidden flex flex-col max-h-[90vh] my-auto text-slate-200`}
|
||||
className={`relative w-full ${maxWidthClass} bg-[#0f1523] rounded-2xl shadow-2xl border border-slate-800/90 overflow-hidden flex flex-col max-h-[90vh] text-slate-200`}
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
>
|
||||
<div className="flex items-start justify-between p-5 border-b border-slate-800/80 bg-[#0d121f]/90 shrink-0">
|
||||
<div className="flex items-start justify-between p-5 border-b border-slate-800/80 bg-[#0d121f]/90">
|
||||
<div>
|
||||
<div className="flex items-center gap-2">
|
||||
<h3 className="text-lg font-semibold text-white tracking-tight">{title}</h3>
|
||||
@@ -92,20 +73,14 @@ export const Modal: React.FC<ModalProps> = ({
|
||||
{subtitle && <p className="text-xs text-slate-400 mt-0.5">{subtitle}</p>}
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
onClick={onClose}
|
||||
className="rounded-xl p-1.5 text-slate-400 hover:text-white hover:bg-slate-800/60 transition cursor-pointer"
|
||||
aria-label="Close dialog"
|
||||
>
|
||||
<X className="w-5 h-5" />
|
||||
</button>
|
||||
</div>
|
||||
<div className="p-6 overflow-y-auto flex-1 bg-[#0f1523] min-w-0">{children}</div>
|
||||
<div className="p-6 overflow-y-auto flex-1 bg-[#0f1523]">{children}</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
||||
return typeof document !== 'undefined'
|
||||
? createPortal(modalContent, document.body)
|
||||
: modalContent;
|
||||
};
|
||||
|
||||
@@ -1,82 +0,0 @@
|
||||
import React, { useState } from 'react';
|
||||
import { Eye, EyeOff, Copy, Check } from 'lucide-react';
|
||||
import { useToast } from './Toast';
|
||||
|
||||
export interface ObfuscatedIdNumberProps {
|
||||
idNumber?: string | null;
|
||||
className?: string;
|
||||
allowToggle?: boolean;
|
||||
}
|
||||
|
||||
export function maskIdNumber(id: string): string {
|
||||
const clean = id ? id.trim() : '';
|
||||
if (clean.length <= 4) {
|
||||
return '••••';
|
||||
}
|
||||
if (clean.length <= 8) {
|
||||
return `${clean.slice(0, 2)}••••${clean.slice(-2)}`;
|
||||
}
|
||||
// SA ID (13 digits) or standard national IDs: keep first 6 (DOB), mask middle 5, show last 2
|
||||
return `${clean.slice(0, 6)}•••••${clean.slice(-2)}`;
|
||||
}
|
||||
|
||||
export function ObfuscatedIdNumber({
|
||||
idNumber,
|
||||
className = '',
|
||||
allowToggle = true,
|
||||
}: ObfuscatedIdNumberProps): React.ReactElement {
|
||||
const [isRevealed, setIsRevealed] = useState<boolean>(false);
|
||||
const [copied, setCopied] = useState<boolean>(false);
|
||||
const { success } = useToast();
|
||||
|
||||
if (!idNumber) {
|
||||
return <span className="text-slate-600 font-mono text-xs">-</span>;
|
||||
}
|
||||
|
||||
const rawId: string = idNumber;
|
||||
|
||||
const handleCopy = async (e: React.MouseEvent<HTMLButtonElement>): Promise<void> => {
|
||||
e.stopPropagation();
|
||||
try {
|
||||
await navigator.clipboard.writeText(rawId);
|
||||
setCopied(true);
|
||||
success('Copied to Clipboard', 'ID Number copied.');
|
||||
setTimeout(() => setCopied(false), 2000);
|
||||
} catch {
|
||||
// Clipboard fallback
|
||||
}
|
||||
};
|
||||
|
||||
const handleToggle = (e: React.MouseEvent<HTMLButtonElement>): void => {
|
||||
e.stopPropagation();
|
||||
setIsRevealed((prev) => !prev);
|
||||
};
|
||||
|
||||
const displayedText = isRevealed ? rawId : maskIdNumber(rawId);
|
||||
|
||||
return (
|
||||
<div className={`inline-flex items-center gap-1.5 font-mono text-xs text-slate-300 ${className}`}>
|
||||
<span className="tracking-wider select-all">{displayedText}</span>
|
||||
|
||||
{allowToggle && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={handleToggle}
|
||||
title={isRevealed ? 'Hide ID Number' : 'Reveal ID Number'}
|
||||
className="p-1 rounded hover:bg-slate-800 text-slate-400 hover:text-slate-200 transition-colors cursor-pointer"
|
||||
>
|
||||
{isRevealed ? <EyeOff className="w-3.5 h-3.5 text-blue-400" /> : <Eye className="w-3.5 h-3.5" />}
|
||||
</button>
|
||||
)}
|
||||
|
||||
<button
|
||||
type="button"
|
||||
onClick={handleCopy}
|
||||
title="Copy ID Number"
|
||||
className="p-1 rounded hover:bg-slate-800 text-slate-400 hover:text-slate-200 transition-colors cursor-pointer"
|
||||
>
|
||||
{copied ? <Check className="w-3.5 h-3.5 text-emerald-400" /> : <Copy className="w-3.5 h-3.5" />}
|
||||
</button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -83,7 +83,7 @@ export const RiskHeatmapMatrix: React.FC<RiskHeatmapMatrixProps> = ({
|
||||
const projectMap = useMemo(() => {
|
||||
const map: Record<string, string> = {};
|
||||
projects.forEach((p) => {
|
||||
map[String(p.id)] = p.name || '';
|
||||
map[String(p.id)] = p.name;
|
||||
});
|
||||
return map;
|
||||
}, [projects]);
|
||||
|
||||
@@ -1,11 +1,10 @@
|
||||
import React from 'react';
|
||||
import { ApprovalStatus, Priority, ActivityStatus, UserRole, ComplianceStatus } from '../../types';
|
||||
import { ApprovalStatus, Priority, ActivityStatus, UserRole } from '../../types';
|
||||
import {
|
||||
getApprovalStatusBadge,
|
||||
getPriorityBadge,
|
||||
getActivityStatusBadge,
|
||||
getUserRoleBadge,
|
||||
getComplianceStatusBadge,
|
||||
} from '../../lib/formatters';
|
||||
|
||||
export const ApprovalStatusBadge: React.FC<{ status: ApprovalStatus | number | null | undefined }> = ({ status }) => {
|
||||
@@ -18,16 +17,6 @@ export const ApprovalStatusBadge: React.FC<{ status: ApprovalStatus | number | n
|
||||
);
|
||||
};
|
||||
|
||||
export const ComplianceStatusBadge: React.FC<{ status: ComplianceStatus | number | null | undefined }> = ({ status }) => {
|
||||
const badge = getComplianceStatusBadge(status ?? ComplianceStatus.NotMet);
|
||||
return (
|
||||
<span className={`inline-flex items-center gap-1.5 px-2.5 py-0.5 rounded-full text-[11px] font-semibold border whitespace-nowrap ${badge.className}`}>
|
||||
{badge.dot && <span className={`w-1.5 h-1.5 rounded-full ${badge.dot}`} />}
|
||||
<span>{badge.label}</span>
|
||||
</span>
|
||||
);
|
||||
};
|
||||
|
||||
export const PriorityBadge: React.FC<{ priority: Priority | number | null | undefined }> = ({ priority }) => {
|
||||
const badge = getPriorityBadge(priority ?? Priority.Medium);
|
||||
return (
|
||||
|
||||
@@ -1,5 +1,4 @@
|
||||
import React, { createContext, useContext, useState, useEffect, useRef, ReactNode } from 'react';
|
||||
import { createPortal } from 'react-dom';
|
||||
import {
|
||||
CheckCircle2,
|
||||
AlertCircle,
|
||||
@@ -112,12 +111,12 @@ const ToastCard: React.FC<{
|
||||
<div
|
||||
onMouseEnter={() => setIsPaused(true)}
|
||||
onMouseLeave={() => setIsPaused(false)}
|
||||
className={`pointer-events-auto relative flex flex-col rounded-2xl border shadow-2xl transition-all duration-200 overflow-hidden bg-[#0d1322] backdrop-blur-none ${
|
||||
className={`pointer-events-auto relative flex flex-col rounded-xl border shadow-2xl transition-all duration-200 overflow-hidden bg-[#111622] ${
|
||||
toast.type === 'success'
|
||||
? 'border-emerald-500/60 text-gray-100 shadow-emerald-950/40 ring-1 ring-emerald-500/20'
|
||||
? 'border-emerald-500/30 text-gray-200'
|
||||
: toast.type === 'error'
|
||||
? 'border-rose-500/70 text-gray-100 shadow-rose-950/60 ring-1 ring-rose-500/30'
|
||||
: 'border-blue-500/60 text-gray-100 shadow-blue-950/40 ring-1 ring-blue-500/20'
|
||||
? 'border-rose-500/40 text-gray-200 shadow-rose-950/20'
|
||||
: 'border-blue-500/30 text-gray-200'
|
||||
}`}
|
||||
>
|
||||
<div className="flex items-start gap-3 p-4">
|
||||
@@ -361,23 +360,18 @@ export const ToastProvider: React.FC<{ children: ReactNode }> = ({ children }) =
|
||||
});
|
||||
};
|
||||
|
||||
const toastRegion = (
|
||||
return (
|
||||
<ToastContext.Provider value={{ toast: addToast, success, error, info }}>
|
||||
{children}
|
||||
<div
|
||||
role="region"
|
||||
aria-label="System Notifications"
|
||||
className="fixed bottom-5 right-5 z-[999999] flex flex-col gap-3 max-w-md sm:max-w-lg w-full pointer-events-none p-2"
|
||||
style={{ zIndex: 999999 }}
|
||||
className="fixed bottom-4 right-4 z-50 flex flex-col gap-3 max-w-lg w-full pointer-events-none p-2"
|
||||
>
|
||||
{toasts.map((t) => (
|
||||
<ToastCard key={t.id} toast={t} onRemove={removeToast} />
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
|
||||
return (
|
||||
<ToastContext.Provider value={{ toast: addToast, success, error, info }}>
|
||||
{children}
|
||||
{typeof document !== 'undefined' ? createPortal(toastRegion, document.body) : null}
|
||||
</ToastContext.Provider>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -1,389 +0,0 @@
|
||||
import React from 'react';
|
||||
import { useForm } from 'react-hook-form';
|
||||
import { zodResolver } from '@hookform/resolvers/zod';
|
||||
import { z } from 'zod';
|
||||
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
|
||||
import { UserPlus, Shield, Hash, Mail, Phone, Lock, Building, CheckCircle2 } from 'lucide-react';
|
||||
import { Modal } from '../ui/Modal';
|
||||
import { useToast } from '../ui/Toast';
|
||||
import {
|
||||
createUser,
|
||||
getSystemRoles,
|
||||
assignUserRole,
|
||||
getTenants,
|
||||
getUsers,
|
||||
notifyNewUserCreated,
|
||||
} from '../../services/api';
|
||||
import { useAuth } from '../../auth/AuthProvider';
|
||||
import { useRbac } from '../../auth/rbac';
|
||||
import { CreateUserRequest, Role, Tenant, User } from '../../types';
|
||||
|
||||
const createUserSchema = z.object({
|
||||
tenantId: z.string().min(1, 'Tenant is required'),
|
||||
idNumber: z
|
||||
.string()
|
||||
.min(5, 'Identification Number must be at least 5 characters')
|
||||
.max(30, 'Identification Number is too long')
|
||||
.regex(/^[a-zA-Z0-9_-]+$/, 'ID Number must be alphanumeric (Hex-encodable)'),
|
||||
email: z.string().email('Please enter a valid email address'),
|
||||
userName: z.string().optional(),
|
||||
phoneNumber: z.string().optional(),
|
||||
initialRoleId: z.string().optional(),
|
||||
lockoutEnabled: z.boolean(),
|
||||
twoFactorEnabled: z.boolean(),
|
||||
});
|
||||
|
||||
type CreateUserFormValues = z.infer<typeof createUserSchema>;
|
||||
|
||||
interface CreateUserModalProps {
|
||||
isOpen: boolean;
|
||||
onClose: () => void;
|
||||
defaultTenantId?: number | string | null;
|
||||
}
|
||||
|
||||
export const CreateUserModal: React.FC<CreateUserModalProps> = ({
|
||||
isOpen,
|
||||
onClose,
|
||||
defaultTenantId,
|
||||
}) => {
|
||||
const { tenantId: userTenantId, canManageTenants } = useAuth();
|
||||
const { canAdd, canView, isUnrestricted } = useRbac();
|
||||
const canCreate = canAdd('settings_users') || canAdd('tenant_self_service') || canAdd('tenant_management') || isUnrestricted;
|
||||
const hasTenantSelfHelpAccess = canAdd('tenant_self_service') || canView('tenant_self_service');
|
||||
const queryClient = useQueryClient();
|
||||
const { success, error: toastError } = useToast();
|
||||
|
||||
const effectiveDefaultTenant = defaultTenantId
|
||||
? String(defaultTenantId)
|
||||
: userTenantId
|
||||
? String(userTenantId)
|
||||
: '1';
|
||||
|
||||
const {
|
||||
register,
|
||||
handleSubmit,
|
||||
reset,
|
||||
watch,
|
||||
setValue,
|
||||
formState: { errors },
|
||||
} = useForm<CreateUserFormValues>({
|
||||
resolver: zodResolver(createUserSchema),
|
||||
defaultValues: {
|
||||
tenantId: effectiveDefaultTenant,
|
||||
idNumber: '',
|
||||
email: '',
|
||||
userName: '',
|
||||
phoneNumber: '',
|
||||
initialRoleId: '',
|
||||
lockoutEnabled: false,
|
||||
twoFactorEnabled: false,
|
||||
},
|
||||
});
|
||||
|
||||
React.useEffect(() => {
|
||||
if (isOpen) {
|
||||
reset({
|
||||
tenantId: effectiveDefaultTenant,
|
||||
idNumber: '',
|
||||
email: '',
|
||||
userName: '',
|
||||
phoneNumber: '',
|
||||
initialRoleId: '',
|
||||
lockoutEnabled: false,
|
||||
twoFactorEnabled: false,
|
||||
});
|
||||
}
|
||||
}, [isOpen, effectiveDefaultTenant, reset]);
|
||||
|
||||
// System roles for assignment
|
||||
const { data: systemRoles = [], isLoading: loadingRoles } = useQuery<Role[]>({
|
||||
queryKey: ['system-roles'],
|
||||
queryFn: getSystemRoles,
|
||||
enabled: isOpen,
|
||||
});
|
||||
|
||||
// Filter out Admin role if user has access to tenant self-help (Requirement 1)
|
||||
const assignableRoles = React.useMemo(() => {
|
||||
return systemRoles.filter((r) => {
|
||||
const rName = r.name || r.Name || '';
|
||||
const rId = r.id || r.Id || '';
|
||||
const isRoleAdmin = rName.toLowerCase() === 'admin' || rId === '1';
|
||||
if (isRoleAdmin && hasTenantSelfHelpAccess) {
|
||||
return false;
|
||||
}
|
||||
return true;
|
||||
});
|
||||
}, [systemRoles, hasTenantSelfHelpAccess]);
|
||||
|
||||
// Tenants list for super-admins
|
||||
const { data: tenantsList = [] } = useQuery<Tenant[]>({
|
||||
queryKey: ['tenants'],
|
||||
queryFn: getTenants,
|
||||
enabled: isOpen && canManageTenants,
|
||||
});
|
||||
|
||||
const createUserMutation = useMutation({
|
||||
mutationFn: async (values: CreateUserFormValues) => {
|
||||
// Security enforcement: Users with tenant self-help access cannot create admin users
|
||||
if (values.initialRoleId && hasTenantSelfHelpAccess) {
|
||||
const selectedRole = systemRoles.find((r) => (r.id || r.Id) === values.initialRoleId);
|
||||
const selName = selectedRole?.name || selectedRole?.Name || '';
|
||||
const selId = selectedRole?.id || selectedRole?.Id || '';
|
||||
if (selectedRole && (selName.toLowerCase() === 'admin' || selId === '1')) {
|
||||
throw new Error('Users who have access to tenant self-help are restricted from creating Admin users.');
|
||||
}
|
||||
}
|
||||
|
||||
const rawEmail = values.email.trim().toLowerCase();
|
||||
const sanitizedUserName = values.userName?.trim()
|
||||
? values.userName.trim().toLowerCase().replace(/\s+/g, '.').replace(/[^a-z0-9_.\-@+]/g, '')
|
||||
: rawEmail.split('@')[0];
|
||||
|
||||
const payload: CreateUserRequest = {
|
||||
email: rawEmail,
|
||||
userName: sanitizedUserName || rawEmail,
|
||||
emailConfirmed: true,
|
||||
phoneNumber: values.phoneNumber?.trim() || null,
|
||||
phoneNumberConfirmed: false,
|
||||
LockoutEnabled: values.lockoutEnabled,
|
||||
twoFactorEnabled: values.twoFactorEnabled,
|
||||
accessFailedCount: 0,
|
||||
};
|
||||
|
||||
await createUser(values.tenantId, values.idNumber.trim(), payload);
|
||||
|
||||
let createdUser: User | undefined;
|
||||
try {
|
||||
await queryClient.invalidateQueries({ queryKey: ['users-directory'] });
|
||||
await queryClient.invalidateQueries({ queryKey: ['users'] });
|
||||
const freshUsers = await getUsers(1, 1000, { email: values.email.trim() });
|
||||
createdUser = freshUsers.find(
|
||||
(u) => u.email && u.email.toLowerCase() === values.email.trim().toLowerCase()
|
||||
);
|
||||
if (createdUser && values.initialRoleId) {
|
||||
await assignUserRole(createdUser.id, values.initialRoleId);
|
||||
}
|
||||
} catch {
|
||||
// Graceful fallback
|
||||
}
|
||||
|
||||
// RULE 3: Issue InApp and Email message directing new user to appropriate portal
|
||||
const selectedRole = systemRoles.find((r) => (r.id || r.Id) === values.initialRoleId);
|
||||
const roleName = selectedRole?.name || selectedRole?.Name || '';
|
||||
const isPartner =
|
||||
roleName.toLowerCase().trim() === 'partner' ||
|
||||
String(values.initialRoleId || '').toLowerCase().trim() === 'partner';
|
||||
|
||||
const targetUserId = createdUser?.id || values.idNumber.trim();
|
||||
await notifyNewUserCreated({
|
||||
userId: targetUserId,
|
||||
email: rawEmail,
|
||||
isPartner,
|
||||
roleName,
|
||||
tenantId: values.tenantId,
|
||||
}).catch(console.warn);
|
||||
|
||||
queryClient.invalidateQueries({ queryKey: ['inapp-notifications'] });
|
||||
queryClient.invalidateQueries({ queryKey: ['notifications'] });
|
||||
|
||||
return values;
|
||||
},
|
||||
onSuccess: (values) => {
|
||||
success('User Created', `User ${values.email} provisioned under Tenant #${values.tenantId}.`);
|
||||
queryClient.invalidateQueries({ queryKey: ['users'] });
|
||||
queryClient.invalidateQueries({ queryKey: ['users-directory'] });
|
||||
onClose();
|
||||
},
|
||||
onError: (err: unknown) => {
|
||||
toastError('User Creation Failed', err);
|
||||
},
|
||||
});
|
||||
|
||||
const onSubmit = (data: CreateUserFormValues) => {
|
||||
createUserMutation.mutate(data);
|
||||
};
|
||||
|
||||
return (
|
||||
<Modal
|
||||
isOpen={isOpen}
|
||||
onClose={onClose}
|
||||
title="Provision Tenant User"
|
||||
subtitle="Creates a new tenant user with Hashids Hex-encoded ID and RBAC capabilities without direct DB intervention."
|
||||
>
|
||||
<form onSubmit={handleSubmit(onSubmit)} className="space-y-4 text-xs">
|
||||
{/* Tenant Scope Selector */}
|
||||
<div>
|
||||
<label className="block text-slate-300 font-semibold mb-1 flex items-center gap-1.5">
|
||||
<Building className="w-3.5 h-3.5 text-blue-400" />
|
||||
<span>Tenant Partition Boundary</span>
|
||||
</label>
|
||||
{canManageTenants && tenantsList.length > 0 ? (
|
||||
<select
|
||||
{...register('tenantId')}
|
||||
className="w-full bg-slate-900 border border-slate-800 rounded-xl px-3 py-2 text-white focus:outline-none focus:border-blue-500"
|
||||
>
|
||||
{tenantsList.map((t) => (
|
||||
<option key={t.id} value={String(t.id)}>
|
||||
Tenant #{t.id} — {t.name || 'Enterprise Tenant'}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
) : (
|
||||
<input
|
||||
type="text"
|
||||
readOnly
|
||||
{...register('tenantId')}
|
||||
className="w-full bg-slate-900/60 border border-slate-800 rounded-xl px-3 py-2 text-slate-400 cursor-not-allowed font-mono"
|
||||
/>
|
||||
)}
|
||||
{errors.tenantId && <p className="text-rose-400 text-[11px] mt-1">{errors.tenantId.message}</p>}
|
||||
</div>
|
||||
|
||||
{/* ID Number with Hashids Hex guidance */}
|
||||
<div>
|
||||
<label className="block text-slate-300 font-semibold mb-1 flex items-center justify-between">
|
||||
<span className="flex items-center gap-1.5">
|
||||
<Hash className="w-3.5 h-3.5 text-blue-400" />
|
||||
<span>National ID / Identification Number</span>
|
||||
</span>
|
||||
<span className="text-[10px] text-blue-400 font-mono">Hashids Hex Encoded in API</span>
|
||||
</label>
|
||||
<input
|
||||
type="text"
|
||||
placeholder="e.g. 9501015800085 or ZA-982341"
|
||||
{...register('idNumber')}
|
||||
className="w-full bg-slate-900 border border-slate-800 rounded-xl px-3 py-2 text-white placeholder-slate-500 focus:outline-none focus:border-blue-500 font-mono"
|
||||
/>
|
||||
<p className="text-[11px] text-slate-400 mt-1">
|
||||
Standard South African National ID (13 digits) or alphanumeric identifier. The client automatically converts and hashes this via Hashids Hex.
|
||||
</p>
|
||||
{errors.idNumber && <p className="text-rose-400 text-[11px] mt-1">{errors.idNumber.message}</p>}
|
||||
</div>
|
||||
|
||||
{/* Email & Username */}
|
||||
<div className="grid grid-cols-1 sm:grid-cols-2 gap-3">
|
||||
<div>
|
||||
<label className="block text-slate-300 font-semibold mb-1 flex items-center gap-1.5">
|
||||
<Mail className="w-3.5 h-3.5 text-blue-400" />
|
||||
<span>Email Address</span>
|
||||
</label>
|
||||
<input
|
||||
type="email"
|
||||
placeholder="user@organisation.gov.za"
|
||||
{...register('email')}
|
||||
className="w-full bg-slate-900 border border-slate-800 rounded-xl px-3 py-2 text-white placeholder-slate-500 focus:outline-none focus:border-blue-500"
|
||||
/>
|
||||
{errors.email && <p className="text-rose-400 text-[11px] mt-1">{errors.email.message}</p>}
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="block text-slate-300 font-semibold mb-1">
|
||||
Username (Optional)
|
||||
</label>
|
||||
<input
|
||||
type="text"
|
||||
placeholder="e.g. jsmith"
|
||||
{...register('userName')}
|
||||
className="w-full bg-slate-900 border border-slate-800 rounded-xl px-3 py-2 text-white placeholder-slate-500 focus:outline-none focus:border-blue-500"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Phone & Initial Role */}
|
||||
<div className="grid grid-cols-1 sm:grid-cols-2 gap-3">
|
||||
<div>
|
||||
<label className="block text-slate-300 font-semibold mb-1 flex items-center gap-1.5">
|
||||
<Phone className="w-3.5 h-3.5 text-blue-400" />
|
||||
<span>Phone Number (Optional)</span>
|
||||
</label>
|
||||
<input
|
||||
type="tel"
|
||||
placeholder="+27 82 000 0000"
|
||||
{...register('phoneNumber')}
|
||||
className="w-full bg-slate-900 border border-slate-800 rounded-xl px-3 py-2 text-white placeholder-slate-500 focus:outline-none focus:border-blue-500"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="block text-slate-300 font-semibold mb-1 flex items-center gap-1.5">
|
||||
<Shield className="w-3.5 h-3.5 text-blue-400" />
|
||||
<span>Primary System Role</span>
|
||||
</label>
|
||||
<select
|
||||
{...register('initialRoleId')}
|
||||
disabled={loadingRoles}
|
||||
className="w-full bg-slate-900 border border-slate-800 rounded-xl px-3 py-2 text-white focus:outline-none focus:border-blue-500 disabled:opacity-50"
|
||||
>
|
||||
<option value="">Select Initial Role (Optional)</option>
|
||||
{assignableRoles.map((r) => {
|
||||
const roleId = r.id || r.Id || '';
|
||||
const roleLabel = r.name || r.Name || '';
|
||||
return (
|
||||
<option key={roleId} value={roleId}>
|
||||
{roleLabel}
|
||||
</option>
|
||||
);
|
||||
})}
|
||||
</select>
|
||||
{hasTenantSelfHelpAccess && (
|
||||
<p className="text-[10px] text-amber-400 mt-1">
|
||||
Tenant Self-Help Policy: Admin user creation is restricted.
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Security Flags */}
|
||||
<div className="p-3 bg-slate-900/60 rounded-xl border border-slate-800 space-y-2">
|
||||
<div className="text-[11px] font-semibold text-slate-400 uppercase tracking-wider">
|
||||
Security & Authentication Flags
|
||||
</div>
|
||||
<div className="flex items-center gap-6">
|
||||
<label className="flex items-center gap-2 cursor-pointer text-slate-300">
|
||||
<input
|
||||
type="checkbox"
|
||||
{...register('lockoutEnabled')}
|
||||
className="rounded border-slate-700 text-blue-600 focus:ring-blue-500 w-4 h-4"
|
||||
/>
|
||||
<span>Enable Account Lockout Protection</span>
|
||||
</label>
|
||||
|
||||
<label className="flex items-center gap-2 cursor-pointer text-slate-300">
|
||||
<input
|
||||
type="checkbox"
|
||||
{...register('twoFactorEnabled')}
|
||||
className="rounded border-slate-700 text-blue-600 focus:ring-blue-500 w-4 h-4"
|
||||
/>
|
||||
<span>Require Two-Factor Auth</span>
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{!canCreate && (
|
||||
<div className="p-3 rounded-xl bg-amber-500/10 border border-amber-500/20 text-amber-300 text-xs flex items-center gap-2">
|
||||
<span>User provisioning is restricted by your RBAC claim assignment.</span>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Form Actions */}
|
||||
<div className="pt-3 border-t border-slate-800 flex justify-end gap-2.5">
|
||||
<button
|
||||
type="button"
|
||||
onClick={onClose}
|
||||
className="px-4 py-2 text-xs font-semibold text-slate-400 hover:text-white bg-slate-800 hover:bg-slate-700 rounded-xl transition cursor-pointer"
|
||||
>
|
||||
Cancel
|
||||
</button>
|
||||
<button
|
||||
type="submit"
|
||||
disabled={!canCreate || createUserMutation.isPending}
|
||||
className="inline-flex items-center gap-1.5 px-4 py-2 text-xs font-semibold text-white bg-gradient-to-r from-blue-600 to-indigo-600 hover:from-blue-500 hover:to-indigo-500 rounded-xl shadow-md shadow-blue-600/30 transition cursor-pointer disabled:opacity-50"
|
||||
>
|
||||
<UserPlus className="w-3.5 h-3.5" />
|
||||
<span>{createUserMutation.isPending ? 'Provisioning...' : 'Provision User'}</span>
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
</Modal>
|
||||
);
|
||||
};
|
||||
@@ -1,685 +0,0 @@
|
||||
import React, { useState } from 'react';
|
||||
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
|
||||
import {
|
||||
Shield,
|
||||
Key,
|
||||
Lock,
|
||||
Unlock,
|
||||
LogOut,
|
||||
Plus,
|
||||
Trash2,
|
||||
RefreshCw,
|
||||
User as UserIcon,
|
||||
CheckCircle2,
|
||||
AlertTriangle,
|
||||
FileText,
|
||||
Hash,
|
||||
} from 'lucide-react';
|
||||
import { Modal } from '../ui/Modal';
|
||||
import { useToast } from '../ui/Toast';
|
||||
import {
|
||||
getUser,
|
||||
getUserRoles,
|
||||
getSystemRoles,
|
||||
assignUserRole,
|
||||
removeUserRole,
|
||||
getUserClaims,
|
||||
forceLogoutUser,
|
||||
setUserLockout,
|
||||
updateUserStatus,
|
||||
} from '../../services/api';
|
||||
import { clearCachedUserRbac, fetchAndCacheRoleClaims } from '../../services/roleClaimsCache';
|
||||
import { HelpTooltip } from '../ui/HelpTooltip';
|
||||
import { ObfuscatedIdNumber } from '../ui/ObfuscatedIdNumber';
|
||||
import { decodeIdNumberHex } from '../../utils/hashids';
|
||||
import { User, Role, Claim, ActivityStatus } from '../../types';
|
||||
import { formatDate, getActivityStatusBadge, getUserRoleBadge } from '../../lib/formatters';
|
||||
import { useRbac, ROLE_CLAIM_DEFINITIONS } from '../../auth/rbac';
|
||||
|
||||
interface UserSecurityModalProps {
|
||||
user: User | null;
|
||||
isOpen: boolean;
|
||||
onClose: () => void;
|
||||
}
|
||||
|
||||
export const UserSecurityModal: React.FC<UserSecurityModalProps> = ({
|
||||
user,
|
||||
isOpen,
|
||||
onClose,
|
||||
}) => {
|
||||
const queryClient = useQueryClient();
|
||||
const { success, error: toastError } = useToast();
|
||||
const { canApprove, canAdd, canView, canAccess, isUnrestricted } = useRbac();
|
||||
const hasTenantSelfHelpAccess = canAccess('tenant_self_service') || canView('tenant_self_service');
|
||||
|
||||
const canManageRolesAndClaims =
|
||||
canApprove('settings_users') ||
|
||||
canApprove('tenant_management') ||
|
||||
canApprove('tenant_self_service') ||
|
||||
isUnrestricted;
|
||||
const canManageSessions =
|
||||
canApprove('settings_users') ||
|
||||
canApprove('tenant_management') ||
|
||||
hasTenantSelfHelpAccess ||
|
||||
isUnrestricted;
|
||||
const canChangeStatus =
|
||||
canAdd('settings_users') ||
|
||||
canAdd('tenant_management') ||
|
||||
canAdd('tenant_self_service') ||
|
||||
canApprove('settings_users') ||
|
||||
isUnrestricted;
|
||||
|
||||
const [activeTab, setActiveTab] = useState<'overview' | 'roles' | 'claims' | 'sessions'>('overview');
|
||||
|
||||
const [selectedRoleToAdd, setSelectedRoleToAdd] = useState('');
|
||||
|
||||
const userId = user?.id || '';
|
||||
|
||||
// 1. Fetch fresh user details
|
||||
const { data: freshUser, isLoading: loadingUser, refetch: refetchUser } = useQuery<User | null>({
|
||||
queryKey: ['user-detail', userId],
|
||||
queryFn: () => (userId ? getUser(userId) : null),
|
||||
enabled: isOpen && !!userId,
|
||||
});
|
||||
|
||||
// 2. Fetch assigned user roles
|
||||
const { data: userRoles = [], isLoading: loadingUserRoles, refetch: refetchUserRoles } = useQuery<Role[]>({
|
||||
queryKey: ['user-roles', userId],
|
||||
queryFn: () => (userId ? getUserRoles(userId) : []),
|
||||
enabled: isOpen && !!userId,
|
||||
});
|
||||
|
||||
// 3. Fetch system roles directory
|
||||
const { data: systemRoles = [], isLoading: loadingSystemRoles } = useQuery<Role[]>({
|
||||
queryKey: ['system-roles'],
|
||||
queryFn: getSystemRoles,
|
||||
enabled: isOpen,
|
||||
});
|
||||
|
||||
// Effective role derivation for hooks (Single-role policy)
|
||||
const rawRoleValue = freshUser?.role || user?.role;
|
||||
const userRoleStr = rawRoleValue != null ? String(rawRoleValue).trim() : '';
|
||||
|
||||
const assignedRole = React.useMemo(() => {
|
||||
if (Array.isArray(userRoles) && userRoles.length > 0) {
|
||||
return userRoles[0];
|
||||
}
|
||||
if (userRoleStr && Array.isArray(systemRoles)) {
|
||||
const match = systemRoles.find(
|
||||
(r) =>
|
||||
r &&
|
||||
((r.name && r.name.toLowerCase() === userRoleStr.toLowerCase()) ||
|
||||
String(r.id) === userRoleStr)
|
||||
);
|
||||
if (match) return match;
|
||||
return { id: userRoleStr, name: userRoleStr } as Role;
|
||||
}
|
||||
return null;
|
||||
}, [userRoles, userRoleStr, systemRoles]);
|
||||
|
||||
const assignedRoleId = assignedRole?.id || assignedRole?.Id || '';
|
||||
const assignedRoleName = assignedRole?.name || assignedRole?.Name || '';
|
||||
|
||||
// 4. Fetch claims derived from the user's role by hitting the endpoint
|
||||
const {
|
||||
data: roleClaims = [],
|
||||
isLoading: loadingRoleClaims,
|
||||
refetch: refetchRoleClaims,
|
||||
} = useQuery<Claim[]>({
|
||||
queryKey: ['role-claims-detail', assignedRoleId, assignedRoleName],
|
||||
queryFn: async () => {
|
||||
if (!assignedRoleId && !assignedRoleName) return [];
|
||||
return await fetchAndCacheRoleClaims(String(assignedRoleId), assignedRoleName, true);
|
||||
},
|
||||
enabled: isOpen && (!!assignedRoleId || !!assignedRoleName),
|
||||
});
|
||||
|
||||
// 5. Fetch direct user claims if any
|
||||
const {
|
||||
data: directClaims = [],
|
||||
isLoading: loadingDirectClaims,
|
||||
refetch: refetchDirectClaims,
|
||||
} = useQuery<Claim[]>({
|
||||
queryKey: ['user-direct-claims', userId],
|
||||
queryFn: () => (userId ? getUserClaims(userId) : []),
|
||||
enabled: isOpen && !!userId,
|
||||
});
|
||||
|
||||
// Combined claims with metadata
|
||||
const userEffectiveClaims = React.useMemo(() => {
|
||||
const map = new Map<string, Claim & { description?: string; label?: string; source: string }>();
|
||||
|
||||
// 1. Role claims from endpoint
|
||||
if (Array.isArray(roleClaims)) {
|
||||
roleClaims.forEach((c) => {
|
||||
if (!c) return;
|
||||
const type = (c.claimType || c.type || '').trim();
|
||||
if (!type) return;
|
||||
const def = ROLE_CLAIM_DEFINITIONS.find((d) => d.claim.toLowerCase() === type.toLowerCase());
|
||||
map.set(type.toLowerCase(), {
|
||||
...c,
|
||||
claimType: type,
|
||||
claimValue: c.claimValue || c.value || 'all',
|
||||
label: def?.label || type,
|
||||
description: def?.description || 'Security claim granted via role membership',
|
||||
source: assignedRoleName ? `Role: ${assignedRoleName}` : 'Role Policy',
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
// 2. Direct user claims if any
|
||||
if (Array.isArray(directClaims)) {
|
||||
directClaims.forEach((c) => {
|
||||
if (!c) return;
|
||||
const type = (c.claimType || c.type || '').trim();
|
||||
if (!type) return;
|
||||
if (!map.has(type.toLowerCase())) {
|
||||
const def = ROLE_CLAIM_DEFINITIONS.find((d) => d.claim.toLowerCase() === type.toLowerCase());
|
||||
map.set(type.toLowerCase(), {
|
||||
...c,
|
||||
claimType: type,
|
||||
claimValue: c.claimValue || c.value || 'all',
|
||||
label: def?.label || type,
|
||||
description: def?.description || 'Direct user account claim',
|
||||
source: 'Direct User Claim',
|
||||
});
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
return Array.from(map.values());
|
||||
}, [roleClaims, directClaims, assignedRoleName]);
|
||||
|
||||
// Role Mutations (Strictly single-role per user)
|
||||
const assignRoleMutation = useMutation({
|
||||
mutationFn: (roleId: string) => {
|
||||
if ((Array.isArray(userRoles) ? userRoles : []).length >= 1) {
|
||||
throw new Error('Single-role policy: This user already has an assigned role. Please remove the existing role before assigning a new one.');
|
||||
}
|
||||
const selectedRole = (Array.isArray(systemRoles) ? systemRoles : []).find(
|
||||
(r) => r && (String(r.id) === String(roleId) || String(r.Id) === String(roleId))
|
||||
);
|
||||
const roleName = selectedRole?.name || selectedRole?.Name || '';
|
||||
if (selectedRole && (roleName.toLowerCase() === 'admin' || String(selectedRole.id) === '1') && hasTenantSelfHelpAccess) {
|
||||
throw new Error('Security policy: Users with tenant self-help access cannot assign Admin roles.');
|
||||
}
|
||||
return assignUserRole(userId, roleId);
|
||||
},
|
||||
onSuccess: () => {
|
||||
clearCachedUserRbac(userId);
|
||||
success('Role Assigned', 'Role assigned successfully.');
|
||||
refetchUserRoles();
|
||||
refetchUser();
|
||||
refetchRoleClaims();
|
||||
queryClient.invalidateQueries({ queryKey: ['users'] });
|
||||
setSelectedRoleToAdd('');
|
||||
},
|
||||
onError: (err: unknown) => toastError('Role Assignment Failed', err),
|
||||
});
|
||||
|
||||
const removeRoleMutation = useMutation({
|
||||
mutationFn: (roleId: string) => removeUserRole(userId, roleId),
|
||||
onSuccess: () => {
|
||||
clearCachedUserRbac(userId);
|
||||
success('Role Removed', 'Role revoked from user.');
|
||||
refetchUserRoles();
|
||||
refetchUser();
|
||||
refetchRoleClaims();
|
||||
queryClient.invalidateQueries({ queryKey: ['users'] });
|
||||
},
|
||||
onError: (err: unknown) => toastError('Role Removal Failed', err),
|
||||
});
|
||||
|
||||
// Session & Lockout Mutations
|
||||
const logoutMutation = useMutation({
|
||||
mutationFn: () => forceLogoutUser(userId),
|
||||
onSuccess: () => success('Session Terminated', 'Target user session revoked. Re-authentication required.'),
|
||||
onError: (err: unknown) => toastError('Logout Failed', err),
|
||||
});
|
||||
|
||||
const lockUserMutation = useMutation({
|
||||
mutationFn: () => setUserLockout(userId, true),
|
||||
onSuccess: () => {
|
||||
success('Account Locked', 'User account lockout enabled (disable = true).');
|
||||
refetchUser();
|
||||
queryClient.invalidateQueries({ queryKey: ['users'] });
|
||||
queryClient.invalidateQueries({ queryKey: ['user-detail', userId] });
|
||||
},
|
||||
onError: (err: unknown) => toastError('Lock User Failed', err),
|
||||
});
|
||||
|
||||
const unlockUserMutation = useMutation({
|
||||
mutationFn: () => setUserLockout(userId, false),
|
||||
onSuccess: () => {
|
||||
success('Account Unlocked', 'User account lockout disabled (disable = false).');
|
||||
refetchUser();
|
||||
queryClient.invalidateQueries({ queryKey: ['users'] });
|
||||
queryClient.invalidateQueries({ queryKey: ['user-detail', userId] });
|
||||
},
|
||||
onError: (err: unknown) => toastError('Unlock User Failed', err),
|
||||
});
|
||||
|
||||
const statusMutation = useMutation({
|
||||
mutationFn: (newStatus: ActivityStatus) => updateUserStatus(userId, newStatus),
|
||||
onSuccess: () => {
|
||||
success('Status Updated', 'User activity status changed.');
|
||||
refetchUser();
|
||||
queryClient.invalidateQueries({ queryKey: ['users'] });
|
||||
},
|
||||
onError: (err: unknown) => toastError('Status Update Failed', err),
|
||||
});
|
||||
|
||||
const availableRoles = React.useMemo(() => {
|
||||
return (Array.isArray(systemRoles) ? systemRoles : []).filter((r) => {
|
||||
if (!r) return false;
|
||||
const rId = r.id || r.Id;
|
||||
const rName = r.name || r.Name || '';
|
||||
if (hasTenantSelfHelpAccess && (rName.toLowerCase() === 'admin' || String(rId) === '1')) {
|
||||
return false;
|
||||
}
|
||||
return true;
|
||||
});
|
||||
}, [systemRoles, hasTenantSelfHelpAccess]);
|
||||
|
||||
if (!isOpen || !user) return null;
|
||||
|
||||
const effectiveUser: User = freshUser || user;
|
||||
const decodedId = effectiveUser.idNumber ? decodeIdNumberHex(effectiveUser.idNumber) : null;
|
||||
|
||||
return (
|
||||
<Modal
|
||||
isOpen={isOpen}
|
||||
onClose={onClose}
|
||||
title={`User Security: ${effectiveUser.email || effectiveUser.id}`}
|
||||
subtitle="Manage account status, role assignment, permissions, and active sessions."
|
||||
>
|
||||
<div className="space-y-4 text-xs">
|
||||
{/* Navigation Tabs */}
|
||||
<div className="flex gap-1.5 border-b border-slate-800 pb-2">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setActiveTab('overview')}
|
||||
className={`px-3 py-1.5 rounded-lg font-medium transition cursor-pointer ${
|
||||
activeTab === 'overview'
|
||||
? 'bg-blue-600 text-white font-semibold'
|
||||
: 'text-slate-400 hover:text-white hover:bg-slate-800'
|
||||
}`}
|
||||
>
|
||||
Profile & Status
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setActiveTab('roles')}
|
||||
className={`px-3 py-1.5 rounded-lg font-medium transition cursor-pointer flex items-center gap-1.5 ${
|
||||
activeTab === 'roles'
|
||||
? 'bg-blue-600 text-white font-semibold'
|
||||
: 'text-slate-400 hover:text-white hover:bg-slate-800'
|
||||
}`}
|
||||
>
|
||||
<Shield className="w-3.5 h-3.5" />
|
||||
<span>Role ({(Array.isArray(userRoles) ? userRoles : []).length})</span>
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setActiveTab('claims')}
|
||||
className={`px-3 py-1.5 rounded-lg font-medium transition cursor-pointer flex items-center gap-1.5 ${
|
||||
activeTab === 'claims'
|
||||
? 'bg-blue-600 text-white font-semibold'
|
||||
: 'text-slate-400 hover:text-white hover:bg-slate-800'
|
||||
}`}
|
||||
>
|
||||
<Key className="w-3.5 h-3.5" />
|
||||
<span>Claims ({userEffectiveClaims.length})</span>
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setActiveTab('sessions')}
|
||||
className={`px-3 py-1.5 rounded-lg font-medium transition cursor-pointer flex items-center gap-1.5 ${
|
||||
activeTab === 'sessions'
|
||||
? 'bg-blue-600 text-white font-semibold'
|
||||
: 'text-slate-400 hover:text-white hover:bg-slate-800'
|
||||
}`}
|
||||
>
|
||||
<Lock className="w-3.5 h-3.5" />
|
||||
<span>Security & Sessions</span>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* TAB 1: Overview & Profile */}
|
||||
{activeTab === 'overview' && (
|
||||
<div className="space-y-3">
|
||||
<div className="p-4 bg-slate-900/60 rounded-xl border border-slate-800 space-y-2.5">
|
||||
<div className="grid grid-cols-2 gap-3">
|
||||
<div>
|
||||
<span className="text-slate-400 font-semibold block text-[11px] uppercase">Email:</span>
|
||||
<span className="text-white font-medium">{effectiveUser.email || '—'}</span>
|
||||
</div>
|
||||
<div>
|
||||
<span className="text-slate-400 font-semibold block text-[11px] uppercase">User UUID:</span>
|
||||
<span className="font-mono text-slate-300 text-[11px] break-all">{effectiveUser.id}</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-2 gap-3 border-t border-slate-800/80 pt-2">
|
||||
<div>
|
||||
<span className="text-slate-400 font-semibold block text-[11px] uppercase">Id Number:</span>
|
||||
<div className="flex items-center gap-2 mt-0.5">
|
||||
<ObfuscatedIdNumber idNumber={effectiveUser.idNumber} />
|
||||
{decodedId && decodedId !== effectiveUser.idNumber && (
|
||||
<div className="flex items-center gap-1 text-[10px] text-blue-400 font-mono">
|
||||
<span>(Decoded:</span>
|
||||
<ObfuscatedIdNumber idNumber={decodedId} />
|
||||
<span>)</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
<span className="text-slate-400 font-semibold block text-[11px] uppercase">Last Login:</span>
|
||||
<span className="font-mono text-slate-300">
|
||||
{effectiveUser.lastLoginAt ? formatDate(effectiveUser.lastLoginAt) : 'Never'}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="border-t border-slate-800/80 pt-2 flex items-center justify-between">
|
||||
<div>
|
||||
<span className="text-slate-400 font-semibold block text-[11px] uppercase mb-0.5">
|
||||
Account Status:
|
||||
</span>
|
||||
<span
|
||||
className={`inline-flex items-center px-2 py-0.5 rounded text-[10px] font-semibold border ${
|
||||
(getActivityStatusBadge(effectiveUser?.status) || {}).className || 'bg-slate-800 text-slate-300'
|
||||
}`}
|
||||
>
|
||||
{(getActivityStatusBadge(effectiveUser?.status) || {}).label || 'Active'}
|
||||
</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
{effectiveUser?.status === ActivityStatus.Active ? (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => statusMutation.mutate(ActivityStatus.Inactive)}
|
||||
disabled={statusMutation.isPending || !canChangeStatus}
|
||||
className="px-2.5 py-1 text-xs font-semibold text-amber-400 bg-amber-500/10 hover:bg-amber-500/20 border border-amber-500/20 rounded-lg transition disabled:opacity-50 cursor-pointer"
|
||||
>
|
||||
Suspend User
|
||||
</button>
|
||||
) : (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => statusMutation.mutate(ActivityStatus.Active)}
|
||||
disabled={statusMutation.isPending || !canChangeStatus}
|
||||
className="px-2.5 py-1 text-xs font-semibold text-emerald-400 bg-emerald-500/10 hover:bg-emerald-500/20 border border-emerald-500/20 rounded-lg transition disabled:opacity-50 cursor-pointer"
|
||||
>
|
||||
Activate User
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* TAB 2: Roles (Single-Role Policy) */}
|
||||
{activeTab === 'roles' && (
|
||||
<div className="space-y-3">
|
||||
{/* If user already has a role, enforce single-role policy */}
|
||||
{(Array.isArray(userRoles) ? userRoles : []).length > 0 ? (
|
||||
<div className="p-3 bg-slate-900/80 rounded-xl border border-slate-800 flex items-center justify-between gap-3">
|
||||
<div className="flex items-center gap-2 min-w-0">
|
||||
<Shield className="w-4 h-4 text-blue-400 shrink-0" />
|
||||
<div className="min-w-0">
|
||||
<span className="font-semibold text-white block">Active Assigned Role (1 of 1 Max)</span>
|
||||
<p className="text-[11px] text-slate-400">
|
||||
The system strictly enforces one role per user. To assign a different role, remove the current role first.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
<span className="text-[10px] font-mono text-emerald-400 bg-emerald-500/10 border border-emerald-500/25 px-2 py-0.5 rounded-full shrink-0 font-semibold">
|
||||
Role Assigned
|
||||
</span>
|
||||
</div>
|
||||
) : (
|
||||
/* Add Role selector when user has no role */
|
||||
<div className="space-y-1.5">
|
||||
<label className="block text-[11px] font-semibold uppercase tracking-wider text-slate-400">
|
||||
Assign System Role (1 Role Max)
|
||||
</label>
|
||||
<div className="flex items-center gap-2">
|
||||
<select
|
||||
value={selectedRoleToAdd}
|
||||
onChange={(e) => setSelectedRoleToAdd(e.target.value)}
|
||||
disabled={loadingSystemRoles || availableRoles.length === 0 || !canManageRolesAndClaims || assignRoleMutation.isPending}
|
||||
className="flex-1 bg-slate-900 border border-slate-800 rounded-xl px-3 py-1.5 text-white focus:outline-none focus:border-blue-500 disabled:opacity-50 text-xs"
|
||||
>
|
||||
<option value="">
|
||||
{availableRoles.length === 0 ? 'No available roles' : 'Select role to assign...'}
|
||||
</option>
|
||||
{availableRoles.map((r) => {
|
||||
const roleOptId = r?.id || r?.Id || String(r);
|
||||
const roleOptName = r?.name || r?.Name || String(r);
|
||||
return (
|
||||
<option key={roleOptId} value={roleOptId}>
|
||||
{roleOptName}
|
||||
</option>
|
||||
);
|
||||
})}
|
||||
</select>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => selectedRoleToAdd && assignRoleMutation.mutate(selectedRoleToAdd)}
|
||||
disabled={!selectedRoleToAdd || assignRoleMutation.isPending || !canManageRolesAndClaims}
|
||||
className="inline-flex items-center gap-1 px-3 py-1.5 bg-blue-600 hover:bg-blue-500 text-white rounded-xl font-semibold transition disabled:opacity-50 cursor-pointer shrink-0 text-xs shadow-md shadow-blue-600/20"
|
||||
>
|
||||
<Plus className="w-3.5 h-3.5" />
|
||||
<span>{assignRoleMutation.isPending ? 'Assigning...' : 'Assign Role'}</span>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* List of currently assigned roles */}
|
||||
<div className="bg-slate-900/60 rounded-xl border border-slate-800 divide-y divide-slate-800/80">
|
||||
{loadingUserRoles ? (
|
||||
<div className="p-6 text-center text-slate-400 flex flex-col items-center gap-2">
|
||||
<RefreshCw className="w-5 h-5 animate-spin text-blue-400" />
|
||||
<span>Loading assigned role...</span>
|
||||
</div>
|
||||
) : (Array.isArray(userRoles) ? userRoles : []).length === 0 ? (
|
||||
<div className="p-6 text-center text-slate-500 flex flex-col items-center gap-2">
|
||||
<Shield className="w-7 h-7 text-slate-600 mb-0.5" />
|
||||
<p className="font-semibold text-slate-300">No role assigned to this user.</p>
|
||||
<p className="text-[11px] text-slate-500">Select a role above to assign it. Users must have exactly one role.</p>
|
||||
</div>
|
||||
) : (
|
||||
(Array.isArray(userRoles) ? userRoles : []).map((role) => {
|
||||
if (!role) return null;
|
||||
const roleId = role.id || role.Id || String(role);
|
||||
const roleName = role.name || role.Name || String(role);
|
||||
return (
|
||||
<div key={roleId} className="p-3.5 flex items-center justify-between gap-3">
|
||||
<div className="min-w-0">
|
||||
<div className="text-white font-semibold flex items-center gap-2">
|
||||
<Shield className="w-4 h-4 text-blue-400 shrink-0" />
|
||||
<span className="text-sm">{roleName}</span>
|
||||
<span className="text-[10px] text-blue-400 bg-blue-500/10 border border-blue-500/20 px-2 py-0.5 rounded font-mono font-medium">
|
||||
Active
|
||||
</span>
|
||||
</div>
|
||||
<div className="text-[10px] text-slate-400 font-mono mt-1">Role ID: {roleId}</div>
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => removeRoleMutation.mutate(roleId)}
|
||||
disabled={removeRoleMutation.isPending || !canManageRolesAndClaims}
|
||||
className="p-2 text-rose-400 hover:text-white hover:bg-rose-500/20 border border-transparent hover:border-rose-500/30 rounded-xl transition disabled:opacity-50 cursor-pointer shrink-0"
|
||||
title="Remove Role"
|
||||
>
|
||||
<Trash2 className="w-4 h-4" />
|
||||
</button>
|
||||
</div>
|
||||
);
|
||||
})
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* TAB 3: Claims (Read-Only Inherited from Role) */}
|
||||
{activeTab === 'claims' && (
|
||||
<div className="space-y-3">
|
||||
{/* Informational Policy Banner */}
|
||||
<div className="p-3 bg-blue-500/10 rounded-xl border border-blue-500/20 text-xs flex items-center justify-between gap-3">
|
||||
<div className="flex items-center gap-2.5 min-w-0">
|
||||
<CheckCircle2 className="w-4 h-4 text-blue-400 shrink-0" />
|
||||
<div className="min-w-0">
|
||||
<span className="font-semibold text-white block">Role-Derived Security Claims</span>
|
||||
<p className="text-[11px] text-slate-400">
|
||||
{assignedRoleName ? (
|
||||
<>Claims are inherited directly from the assigned role (<strong className="text-blue-300">{assignedRoleName}</strong>). They cannot be manually added or removed at the individual user level.</>
|
||||
) : (
|
||||
<>Assign a role on the Roles tab to grant security claims to this user.</>
|
||||
)}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
<span className="text-[10px] font-mono text-blue-400 bg-blue-500/20 px-2 py-0.5 rounded border border-blue-500/30 shrink-0 font-semibold">
|
||||
Read-Only
|
||||
</span>
|
||||
</div>
|
||||
|
||||
{/* Claims list */}
|
||||
<div className="bg-slate-900/60 rounded-xl border border-slate-800 divide-y divide-slate-800/80">
|
||||
{loadingRoleClaims || loadingDirectClaims ? (
|
||||
<div className="p-8 text-center text-slate-400 flex flex-col items-center gap-2">
|
||||
<RefreshCw className="w-6 h-6 animate-spin text-blue-400" />
|
||||
<span>Loading security claims from role endpoint...</span>
|
||||
</div>
|
||||
) : userEffectiveClaims.length === 0 ? (
|
||||
<div className="p-8 text-center text-slate-500 flex flex-col items-center gap-2">
|
||||
<Key className="w-8 h-8 text-slate-600 mb-1" />
|
||||
<p className="font-semibold text-slate-300">No active claims found for user.</p>
|
||||
<p className="text-[11px] text-slate-500 max-w-sm">
|
||||
{assignedRoleName
|
||||
? `The assigned role (${assignedRoleName}) does not contain any configured permission claims.`
|
||||
: 'Assign a role to this user on the Roles tab to establish functional permissions.'}
|
||||
</p>
|
||||
</div>
|
||||
) : (
|
||||
userEffectiveClaims.map((claim, idx) => {
|
||||
const claimType = claim.claimType || claim.type || 'Custom Claim';
|
||||
const claimVal = claim.claimValue ?? claim.value ?? 'all';
|
||||
return (
|
||||
<div key={idx} className="p-3.5 flex items-center justify-between gap-3">
|
||||
<div className="min-w-0 flex-1">
|
||||
<div className="text-white font-medium flex items-center gap-2 flex-wrap">
|
||||
<Key className="w-3.5 h-3.5 text-amber-400 shrink-0" />
|
||||
<span className="font-semibold text-xs text-white">{claim.label || claimType}</span>
|
||||
<span className="text-[10px] font-mono text-slate-400 bg-slate-800 px-1.5 py-0.2 rounded border border-slate-700/60">
|
||||
{claimType}
|
||||
</span>
|
||||
</div>
|
||||
{claim.description && (
|
||||
<p className="text-[11px] text-slate-400 mt-0.5 leading-relaxed">
|
||||
{claim.description}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
<div className="flex items-center gap-2 shrink-0">
|
||||
<span className="inline-flex items-center px-2 py-0.5 rounded text-[10px] font-semibold bg-emerald-500/10 text-emerald-400 border border-emerald-500/20 font-mono">
|
||||
Value: {claimVal}
|
||||
</span>
|
||||
<span className="text-[10px] text-slate-500 hidden sm:inline">
|
||||
{claim.source}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* TAB 4: Sessions & Security Interventions */}
|
||||
{activeTab === 'sessions' && (
|
||||
<div className="space-y-3">
|
||||
<div className="p-4 bg-slate-900/60 rounded-xl border border-slate-800 space-y-4">
|
||||
{/* Force Logout Action */}
|
||||
<div className="flex items-center justify-between border-b border-slate-800/80 pb-3 gap-3">
|
||||
<div>
|
||||
<h4 className="text-white font-semibold flex items-center gap-1.5">
|
||||
<LogOut className="w-4 h-4 text-amber-400" />
|
||||
<span>Revoke Active User Session</span>
|
||||
</h4>
|
||||
<p className="text-[11px] text-slate-400 mt-0.5">
|
||||
Immediately invalidates active JWT tokens and logs the user out.
|
||||
</p>
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => logoutMutation.mutate()}
|
||||
disabled={logoutMutation.isPending || !canManageSessions}
|
||||
className="px-3 py-1.5 bg-amber-500/10 hover:bg-amber-500/20 text-amber-400 border border-amber-500/20 rounded-xl font-semibold transition cursor-pointer disabled:opacity-50 shrink-0"
|
||||
>
|
||||
{logoutMutation.isPending ? 'Revoking...' : 'Force Logout'}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Lock / Unlock Account */}
|
||||
<div className="flex items-center justify-between gap-3">
|
||||
<div>
|
||||
<h4 className="text-white font-semibold flex items-center gap-1.5">
|
||||
<Lock className="w-4 h-4 text-rose-400" />
|
||||
<span>Account Lockout</span>
|
||||
</h4>
|
||||
<p className="text-[11px] text-slate-400 mt-0.5">
|
||||
Control tenant login authorization for this account.
|
||||
</p>
|
||||
</div>
|
||||
<div className="flex items-center gap-2 shrink-0">
|
||||
<button
|
||||
type="button"
|
||||
onClick={(e) => {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
lockUserMutation.mutate();
|
||||
}}
|
||||
disabled={lockUserMutation.isPending || unlockUserMutation.isPending || !canManageSessions}
|
||||
className="px-3 py-1.5 bg-rose-500/10 hover:bg-rose-500/20 text-rose-400 border border-rose-500/20 rounded-xl font-semibold transition cursor-pointer disabled:opacity-50"
|
||||
>
|
||||
{lockUserMutation.isPending ? 'Locking...' : 'Lock User'}
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={(e) => {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
unlockUserMutation.mutate();
|
||||
}}
|
||||
disabled={unlockUserMutation.isPending || lockUserMutation.isPending || !canManageSessions}
|
||||
className="px-3 py-1.5 bg-emerald-500/10 hover:bg-emerald-500/20 text-emerald-400 border border-emerald-500/20 rounded-xl font-semibold transition cursor-pointer disabled:opacity-50"
|
||||
>
|
||||
{unlockUserMutation.isPending ? 'Unlocking...' : 'Unlock User'}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Modal Footer */}
|
||||
<div className="pt-2 border-t border-slate-800 flex justify-end">
|
||||
<button
|
||||
type="button"
|
||||
onClick={onClose}
|
||||
className="px-4 py-2 text-xs font-semibold text-white bg-slate-800 hover:bg-slate-700 rounded-xl transition cursor-pointer"
|
||||
>
|
||||
Close
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</Modal>
|
||||
);
|
||||
};
|
||||
@@ -1,10 +1,10 @@
|
||||
import { updateHasherSettings } from './utils/hashids';
|
||||
import { updateHasherSettings } from './services/toolsService';
|
||||
|
||||
export interface SecuritySettings {
|
||||
Authority: string;
|
||||
Audience: string;
|
||||
ClientId: string;
|
||||
ClientSecret?: string;
|
||||
ClientSecret: string;
|
||||
Scope?: string;
|
||||
}
|
||||
|
||||
@@ -13,7 +13,7 @@ export interface ApiSettings {
|
||||
}
|
||||
|
||||
export interface HasherSettings {
|
||||
Salt?: string;
|
||||
Salt: string;
|
||||
MinHashLength: number;
|
||||
}
|
||||
|
||||
@@ -36,6 +36,7 @@ export const defaultSecuritySettings: SecuritySettings = {
|
||||
Authority: 'https://sts.security.khongisa.co.za',
|
||||
Audience: 'pfm-api-dev',
|
||||
ClientId: 'pfm-console-dev',
|
||||
ClientSecret: 'secret_dcaeaa28097d9ba5b06ec324d9eac9d9c81de96e0a203cf2c6154c6dd7c89e94',
|
||||
Scope: 'openid profile roles email address pfm-api-dev',
|
||||
};
|
||||
|
||||
@@ -48,10 +49,6 @@ export const defaultHasherSettings: HasherSettings = {
|
||||
MinHashLength: 11,
|
||||
};
|
||||
|
||||
function syncAllHasherSettings(salt: string | undefined, minHashLength: number): void {
|
||||
updateHasherSettings(salt, minHashLength);
|
||||
}
|
||||
|
||||
/**
|
||||
* Extracts first non-empty, trimmed string from candidate values.
|
||||
*/
|
||||
@@ -258,8 +255,9 @@ function resolveConfig(fetchedJson?: Record<string, any>): AppConfig {
|
||||
const clientSecret = pickFirstNonEmpty(
|
||||
windowConfig.clientSecret,
|
||||
fetchedConfig.clientSecret,
|
||||
buildEnvConfig.clientSecret
|
||||
);
|
||||
buildEnvConfig.clientSecret,
|
||||
defaultSecuritySettings.ClientSecret
|
||||
)!;
|
||||
|
||||
const scope = pickFirstNonEmpty(
|
||||
windowConfig.scope,
|
||||
@@ -280,7 +278,7 @@ function resolveConfig(fetchedJson?: Record<string, any>): AppConfig {
|
||||
fetchedConfig.hasherSalt,
|
||||
buildEnvConfig.hasherSalt,
|
||||
defaultHasherSettings.Salt
|
||||
);
|
||||
)!;
|
||||
|
||||
const minHashLength =
|
||||
windowConfig.hasherMinLength ||
|
||||
@@ -293,14 +291,14 @@ function resolveConfig(fetchedJson?: Record<string, any>): AppConfig {
|
||||
Authority: normalizeAuthorityUrl(authority),
|
||||
Audience: audience,
|
||||
ClientId: clientId,
|
||||
...(clientSecret ? { ClientSecret: clientSecret } : {}),
|
||||
ClientSecret: clientSecret,
|
||||
Scope: scope,
|
||||
},
|
||||
ApiSettings: {
|
||||
BaseUrl: normalizeBaseUrl(baseUrl),
|
||||
},
|
||||
HasherSettings: {
|
||||
Salt: salt || defaultHasherSettings.Salt,
|
||||
Salt: salt,
|
||||
MinHashLength: minHashLength,
|
||||
},
|
||||
ConfigSource: source,
|
||||
@@ -312,7 +310,7 @@ function resolveConfig(fetchedJson?: Record<string, any>): AppConfig {
|
||||
let runtimeConfig: AppConfig = resolveConfig();
|
||||
|
||||
// Immediately apply deterministic hasher settings
|
||||
syncAllHasherSettings(runtimeConfig.HasherSettings.Salt, runtimeConfig.HasherSettings.MinHashLength);
|
||||
updateHasherSettings(runtimeConfig.HasherSettings.Salt, runtimeConfig.HasherSettings.MinHashLength);
|
||||
|
||||
const configListeners: Array<(config: AppConfig) => void> = [];
|
||||
|
||||
@@ -362,7 +360,7 @@ export async function loadRuntimeConfig(): Promise<AppConfig> {
|
||||
runtimeConfig = resolveConfig(fetchedJson);
|
||||
|
||||
// Synchronize deterministic hasher settings
|
||||
syncAllHasherSettings(runtimeConfig.HasherSettings.Salt, runtimeConfig.HasherSettings.MinHashLength);
|
||||
updateHasherSettings(runtimeConfig.HasherSettings.Salt, runtimeConfig.HasherSettings.MinHashLength);
|
||||
|
||||
console.info(
|
||||
`[Config] Active runtime configuration: source=${runtimeConfig.ConfigSource}, API=${runtimeConfig.ApiSettings.BaseUrl}, STS=${runtimeConfig.SecuritySettings.Authority}`
|
||||
|
||||
@@ -1,382 +0,0 @@
|
||||
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
|
||||
import { useState, useEffect, useMemo, useCallback } from 'react';
|
||||
import { useAuth } from '../auth/AuthProvider';
|
||||
import {
|
||||
getUserNotifications,
|
||||
getNotifications,
|
||||
createNotification,
|
||||
markNotificationAsRead,
|
||||
updateNotification,
|
||||
getCurrentUserTenantId,
|
||||
} from '../services/api';
|
||||
import {
|
||||
Notification,
|
||||
NotificationPlatform,
|
||||
NotificationStatus,
|
||||
CreateNotificationRequest,
|
||||
} from '../types';
|
||||
|
||||
export interface DraftMessage {
|
||||
id: string;
|
||||
recipientId: string;
|
||||
recipientEmail: string;
|
||||
recipientName?: string;
|
||||
subject: string;
|
||||
message: string;
|
||||
platform?: NotificationPlatform;
|
||||
updatedAt: string;
|
||||
}
|
||||
|
||||
export function useInAppNotifications() {
|
||||
const { user, claims } = useAuth();
|
||||
const queryClient = useQueryClient();
|
||||
const userId = (user?.id || claims?.sub || '') as string;
|
||||
const userEmail = (user?.email || claims?.email || '') as string;
|
||||
|
||||
// Local storage keys for client-side starred and drafts
|
||||
const draftsStorageKey = `pfm_mailbox_drafts_${userId || 'default'}`;
|
||||
const starredStorageKey = `pfm_mailbox_starred_${userId || 'default'}`;
|
||||
const trashStorageKey = `pfm_mailbox_trash_${userId || 'default'}`;
|
||||
const sentStorageKey = `pfm_mailbox_sent_${userId || 'default'}`;
|
||||
|
||||
const readStorageList = useCallback(<T,>(key: string, fallbackKey?: string): T[] => {
|
||||
try {
|
||||
const stored = localStorage.getItem(key);
|
||||
const parsed: T[] = stored ? JSON.parse(stored) : [];
|
||||
if (fallbackKey && fallbackKey !== key) {
|
||||
const fallbackStored = localStorage.getItem(fallbackKey);
|
||||
const fallbackParsed: T[] = fallbackStored ? JSON.parse(fallbackStored) : [];
|
||||
if (fallbackParsed.length > 0) {
|
||||
const merged = [...parsed];
|
||||
for (const item of fallbackParsed) {
|
||||
const itemId = (item as any)?.id ?? item;
|
||||
if (!merged.some(m => ((m as any)?.id ?? m) === itemId)) {
|
||||
merged.push(item);
|
||||
}
|
||||
}
|
||||
localStorage.setItem(key, JSON.stringify(merged));
|
||||
localStorage.removeItem(fallbackKey);
|
||||
return merged;
|
||||
}
|
||||
}
|
||||
return parsed;
|
||||
} catch {
|
||||
return [];
|
||||
}
|
||||
}, []);
|
||||
|
||||
// Starred messages set
|
||||
const [starredIds, setStarredIds] = useState<string[]>(() =>
|
||||
readStorageList<string>(starredStorageKey, 'pfm_mailbox_starred_default')
|
||||
);
|
||||
|
||||
// Trashed messages set
|
||||
const [trashedIds, setTrashedIds] = useState<string[]>(() =>
|
||||
readStorageList<string>(trashStorageKey, 'pfm_mailbox_trash_default')
|
||||
);
|
||||
|
||||
// Local drafts list
|
||||
const [drafts, setDrafts] = useState<DraftMessage[]>(() =>
|
||||
readStorageList<DraftMessage>(draftsStorageKey, 'pfm_mailbox_drafts_default')
|
||||
);
|
||||
|
||||
// Local sent messages history
|
||||
const [localSent, setLocalSent] = useState<Notification[]>(() =>
|
||||
readStorageList<Notification>(sentStorageKey, 'pfm_mailbox_sent_default')
|
||||
);
|
||||
|
||||
// Synchronize across hook instances and userId changes
|
||||
const syncFromStorage = useCallback(() => {
|
||||
setStarredIds(readStorageList<string>(starredStorageKey, 'pfm_mailbox_starred_default'));
|
||||
setTrashedIds(readStorageList<string>(trashStorageKey, 'pfm_mailbox_trash_default'));
|
||||
setDrafts(readStorageList<DraftMessage>(draftsStorageKey, 'pfm_mailbox_drafts_default'));
|
||||
setLocalSent(readStorageList<Notification>(sentStorageKey, 'pfm_mailbox_sent_default'));
|
||||
}, [readStorageList, starredStorageKey, trashStorageKey, draftsStorageKey, sentStorageKey]);
|
||||
|
||||
useEffect(() => {
|
||||
syncFromStorage();
|
||||
}, [syncFromStorage]);
|
||||
|
||||
useEffect(() => {
|
||||
const handleSync = () => syncFromStorage();
|
||||
window.addEventListener('pfm-mailbox-sync', handleSync);
|
||||
window.addEventListener('storage', handleSync);
|
||||
return () => {
|
||||
window.removeEventListener('pfm-mailbox-sync', handleSync);
|
||||
window.removeEventListener('storage', handleSync);
|
||||
};
|
||||
}, [syncFromStorage]);
|
||||
|
||||
// Fetch notifications from backend
|
||||
const { data: rawNotifications = [], isLoading, refetch } = useQuery<Notification[]>({
|
||||
queryKey: ['inapp-notifications', userId],
|
||||
queryFn: async () => {
|
||||
// 1. Try user-specific notifications
|
||||
let list = await getUserNotifications(userId || undefined);
|
||||
// 2. If list is empty or fallback needed, also check organisation notifications
|
||||
if (!list || list.length === 0) {
|
||||
const orgList = await getNotifications();
|
||||
if (orgList && orgList.length > 0) {
|
||||
list = orgList;
|
||||
}
|
||||
}
|
||||
return list || [];
|
||||
},
|
||||
// Live polling every 30 seconds
|
||||
refetchInterval: 30000,
|
||||
staleTime: 10000,
|
||||
});
|
||||
|
||||
// InApp notifications (strictly platform === InApp 5) for Bell & Counters
|
||||
const inAppNotifications = useMemo(() => {
|
||||
return rawNotifications.filter(
|
||||
n => Number(n.platform) === NotificationPlatform.InApp || n.platform === (NotificationPlatform.InApp as any)
|
||||
);
|
||||
}, [rawNotifications]);
|
||||
|
||||
// Active in-app notifications in inbox (excluding trashed and drafts)
|
||||
const inboxNotifications = useMemo(() => {
|
||||
return inAppNotifications.filter(
|
||||
n => !trashedIds.includes(String(n.id)) && Number(n.status) !== NotificationStatus.Draft
|
||||
);
|
||||
}, [inAppNotifications, trashedIds]);
|
||||
|
||||
// Active mailbox notifications (always In-App notifications)
|
||||
const allMailboxNotifications = inboxNotifications;
|
||||
|
||||
// Unread InApp notifications: status !== Read && status !== Draft && status !== Cancelled && status !== Failed
|
||||
const unreadNotifications = useMemo(() => {
|
||||
return inboxNotifications.filter(
|
||||
n =>
|
||||
Number(n.status) !== NotificationStatus.Read &&
|
||||
Number(n.status) !== NotificationStatus.Cancelled &&
|
||||
Number(n.status) !== NotificationStatus.Failed
|
||||
);
|
||||
}, [inboxNotifications]);
|
||||
|
||||
const unreadCount = unreadNotifications.length;
|
||||
const top5Unread = useMemo(() => unreadNotifications.slice(0, 5), [unreadNotifications]);
|
||||
|
||||
// Starred notifications (In-App only)
|
||||
const starredNotifications = useMemo(() => {
|
||||
return inAppNotifications.filter(
|
||||
n =>
|
||||
starredIds.includes(String(n.id)) &&
|
||||
!trashedIds.includes(String(n.id))
|
||||
);
|
||||
}, [inAppNotifications, starredIds, trashedIds]);
|
||||
|
||||
// Trashed notifications (In-App only)
|
||||
const trashedNotifications = useMemo(() => {
|
||||
return inAppNotifications.filter(n => trashedIds.includes(String(n.id)));
|
||||
}, [inAppNotifications, trashedIds]);
|
||||
|
||||
// Mutation to mark notification as read
|
||||
const markAsReadMutation = useMutation({
|
||||
mutationFn: async (id: number | string) => {
|
||||
await markNotificationAsRead(id);
|
||||
},
|
||||
onMutate: async (id) => {
|
||||
await queryClient.cancelQueries({ queryKey: ['inapp-notifications', userId] });
|
||||
queryClient.setQueryData<Notification[]>(['inapp-notifications', userId], (old = []) =>
|
||||
old.map(n => (String(n.id) === String(id) ? { ...n, status: NotificationStatus.Read } : n))
|
||||
);
|
||||
},
|
||||
onSettled: () => {
|
||||
queryClient.invalidateQueries({ queryKey: ['inapp-notifications'] });
|
||||
},
|
||||
});
|
||||
|
||||
// Mark all unread as read
|
||||
const markAllAsRead = useCallback(async () => {
|
||||
const unread = unreadNotifications;
|
||||
for (const item of unread) {
|
||||
try {
|
||||
await markNotificationAsRead(item.id);
|
||||
} catch {}
|
||||
}
|
||||
queryClient.invalidateQueries({ queryKey: ['inapp-notifications'] });
|
||||
}, [unreadNotifications, queryClient]);
|
||||
|
||||
// Toggle star
|
||||
const toggleStar = useCallback((id: string | number) => {
|
||||
const strId = String(id);
|
||||
setStarredIds(prev => {
|
||||
const next = prev.includes(strId) ? prev.filter(x => x !== strId) : [...prev, strId];
|
||||
try {
|
||||
localStorage.setItem(starredStorageKey, JSON.stringify(next));
|
||||
window.dispatchEvent(new Event('pfm-mailbox-sync'));
|
||||
} catch {}
|
||||
return next;
|
||||
});
|
||||
}, [starredStorageKey]);
|
||||
|
||||
// Delete message (move to trash)
|
||||
const deleteMessage = useCallback((id: string | number) => {
|
||||
const strId = String(id);
|
||||
setTrashedIds(prev => {
|
||||
const next = prev.includes(strId) ? prev : [...prev, strId];
|
||||
try {
|
||||
localStorage.setItem(trashStorageKey, JSON.stringify(next));
|
||||
window.dispatchEvent(new Event('pfm-mailbox-sync'));
|
||||
} catch {}
|
||||
return next;
|
||||
});
|
||||
}, [trashStorageKey]);
|
||||
|
||||
// Restore from trash
|
||||
const restoreMessage = useCallback((id: string | number) => {
|
||||
const strId = String(id);
|
||||
setTrashedIds(prev => {
|
||||
const next = prev.filter(x => x !== strId);
|
||||
try {
|
||||
localStorage.setItem(trashStorageKey, JSON.stringify(next));
|
||||
window.dispatchEvent(new Event('pfm-mailbox-sync'));
|
||||
} catch {}
|
||||
return next;
|
||||
});
|
||||
}, [trashStorageKey]);
|
||||
|
||||
// Permanent delete
|
||||
const permanentDelete = useCallback((id: string | number) => {
|
||||
const strId = String(id);
|
||||
setTrashedIds(prev => {
|
||||
const next = prev.filter(x => x !== strId);
|
||||
try {
|
||||
localStorage.setItem(trashStorageKey, JSON.stringify(next));
|
||||
} catch {}
|
||||
return next;
|
||||
});
|
||||
setLocalSent(prev => {
|
||||
const next = prev.filter(n => String(n.id) !== strId);
|
||||
try {
|
||||
localStorage.setItem(sentStorageKey, JSON.stringify(next));
|
||||
window.dispatchEvent(new Event('pfm-mailbox-sync'));
|
||||
} catch {}
|
||||
return next;
|
||||
});
|
||||
}, [trashStorageKey, sentStorageKey]);
|
||||
|
||||
// Draft management
|
||||
const saveDraft = useCallback((draft: Omit<DraftMessage, 'id' | 'updatedAt'> & { id?: string }) => {
|
||||
const draftId = draft.id || `draft_${Date.now()}`;
|
||||
const newDraft: DraftMessage = {
|
||||
...draft,
|
||||
id: draftId,
|
||||
updatedAt: new Date().toISOString(),
|
||||
};
|
||||
setDrafts(prev => {
|
||||
const current = readStorageList<DraftMessage>(draftsStorageKey, 'pfm_mailbox_drafts_default');
|
||||
const base = current.length >= prev.length ? current : prev;
|
||||
const filtered = base.filter(d => d.id !== draftId);
|
||||
const next = [newDraft, ...filtered];
|
||||
try {
|
||||
localStorage.setItem(draftsStorageKey, JSON.stringify(next));
|
||||
window.dispatchEvent(new Event('pfm-mailbox-sync'));
|
||||
} catch {}
|
||||
return next;
|
||||
});
|
||||
return draftId;
|
||||
}, [draftsStorageKey, readStorageList]);
|
||||
|
||||
const deleteDraft = useCallback((id: string) => {
|
||||
setDrafts(prev => {
|
||||
const current = readStorageList<DraftMessage>(draftsStorageKey, 'pfm_mailbox_drafts_default');
|
||||
const base = current.length >= prev.length ? current : prev;
|
||||
const next = base.filter(d => d.id !== id);
|
||||
try {
|
||||
localStorage.setItem(draftsStorageKey, JSON.stringify(next));
|
||||
window.dispatchEvent(new Event('pfm-mailbox-sync'));
|
||||
} catch {}
|
||||
return next;
|
||||
});
|
||||
}, [draftsStorageKey, readStorageList]);
|
||||
|
||||
// Send new notification (InApp or Email)
|
||||
const sendMessageMutation = useMutation({
|
||||
mutationFn: async (payload: {
|
||||
recipientId: string;
|
||||
recipientEmail: string;
|
||||
recipientName?: string;
|
||||
subject: string;
|
||||
message: string;
|
||||
platform?: NotificationPlatform;
|
||||
draftId?: string;
|
||||
}) => {
|
||||
const orgId = getCurrentUserTenantId() || 1;
|
||||
const targetPlatform = payload.platform || NotificationPlatform.InApp;
|
||||
const formattedDestination = `To: ${payload.recipientEmail} | From: ${userEmail || 'System User'}`;
|
||||
|
||||
const req: CreateNotificationRequest = {
|
||||
organisationId: orgId,
|
||||
recipient: payload.recipientId,
|
||||
platform: targetPlatform,
|
||||
subject: payload.subject,
|
||||
destination: formattedDestination,
|
||||
message: payload.message,
|
||||
status: targetPlatform === NotificationPlatform.InApp ? NotificationStatus.Delivered : NotificationStatus.Queued,
|
||||
};
|
||||
|
||||
await createNotification(req);
|
||||
|
||||
// Record in local sent messages
|
||||
const sentItem: Notification = {
|
||||
id: `sent_${Date.now()}`,
|
||||
organisationId: orgId,
|
||||
recipient: payload.recipientId,
|
||||
platform: targetPlatform,
|
||||
status: NotificationStatus.Sent,
|
||||
subject: payload.subject,
|
||||
message: payload.message,
|
||||
destination: formattedDestination,
|
||||
createdAt: new Date().toISOString(),
|
||||
sentAt: new Date().toISOString(),
|
||||
};
|
||||
|
||||
setLocalSent(prev => {
|
||||
const next = [sentItem, ...prev];
|
||||
try {
|
||||
localStorage.setItem(sentStorageKey, JSON.stringify(next));
|
||||
window.dispatchEvent(new Event('pfm-mailbox-sync'));
|
||||
} catch {}
|
||||
return next;
|
||||
});
|
||||
|
||||
// If sending a draft, remove the draft
|
||||
if (payload.draftId) {
|
||||
deleteDraft(payload.draftId);
|
||||
}
|
||||
},
|
||||
onSuccess: () => {
|
||||
queryClient.invalidateQueries({ queryKey: ['inapp-notifications'] });
|
||||
},
|
||||
});
|
||||
|
||||
return {
|
||||
rawNotifications,
|
||||
inAppNotifications,
|
||||
allMailboxNotifications,
|
||||
inboxNotifications,
|
||||
unreadNotifications,
|
||||
unreadCount,
|
||||
top5Unread,
|
||||
starredNotifications,
|
||||
trashedNotifications,
|
||||
sentNotifications: localSent,
|
||||
drafts,
|
||||
starredIds,
|
||||
trashedIds,
|
||||
isLoading,
|
||||
refetch,
|
||||
markAsRead: (id: string | number) => markAsReadMutation.mutate(id),
|
||||
markAllAsRead,
|
||||
toggleStar,
|
||||
deleteMessage,
|
||||
restoreMessage,
|
||||
permanentDelete,
|
||||
saveDraft,
|
||||
deleteDraft,
|
||||
sendMessage: sendMessageMutation.mutateAsync,
|
||||
isSending: sendMessageMutation.isPending,
|
||||
};
|
||||
}
|
||||
@@ -33,18 +33,13 @@
|
||||
color-scheme: dark;
|
||||
}
|
||||
|
||||
/* Custom dark scrollbar styling - auto-appearing with transparent tracks */
|
||||
* {
|
||||
scrollbar-width: thin;
|
||||
scrollbar-color: #262626 transparent;
|
||||
}
|
||||
|
||||
/* Custom dark scrollbar styling */
|
||||
::-webkit-scrollbar {
|
||||
width: 6px;
|
||||
height: 6px;
|
||||
}
|
||||
::-webkit-scrollbar-track {
|
||||
background: transparent;
|
||||
background: #0a0a0a;
|
||||
}
|
||||
::-webkit-scrollbar-thumb {
|
||||
background: #262626;
|
||||
@@ -53,18 +48,6 @@
|
||||
::-webkit-scrollbar-thumb:hover {
|
||||
background: #3f3f46;
|
||||
}
|
||||
|
||||
/* Table responsive containment and smooth auto-scrolling */
|
||||
.overflow-x-auto {
|
||||
overflow-x: auto !important;
|
||||
max-width: 100%;
|
||||
-webkit-overflow-scrolling: touch;
|
||||
}
|
||||
|
||||
/* Ensure tables preserve minimum readable width without squashing action buttons */
|
||||
.overflow-x-auto > table {
|
||||
min-width: 780px;
|
||||
}
|
||||
}
|
||||
|
||||
/* High Contrast Dark Theme Adaptations for existing utility classes */
|
||||
|
||||
@@ -1,3 +1,5 @@
|
||||
import { jsPDF } from 'jspdf';
|
||||
import autoTable from 'jspdf-autotable';
|
||||
|
||||
export interface ExportColumn {
|
||||
header: string;
|
||||
@@ -51,7 +53,7 @@ export function exportToSpreadsheet({ filename, columns, data }: ExportDataOptio
|
||||
/**
|
||||
* Draws the high-visibility official PFM vector brand insignia onto the jsPDF document.
|
||||
*/
|
||||
function drawPfmLogo(doc: any, x: number, y: number, size: number): void {
|
||||
function drawPfmLogo(doc: jsPDF, x: number, y: number, size: number): void {
|
||||
const scale = size / 64;
|
||||
doc.saveGraphicsState();
|
||||
|
||||
@@ -181,14 +183,9 @@ function drawPfmLogo(doc: any, x: number, y: number, size: number): void {
|
||||
* Generates an executive-grade PDF document in LANDSCAPE mode with clear PFM logo visibility,
|
||||
* metadata header, and structured autoTable data.
|
||||
*/
|
||||
export async function exportToPdf({ title, subtitle, filename, columns, data }: ExportDataOptions): Promise<void> {
|
||||
export function exportToPdf({ title, subtitle, filename, columns, data }: ExportDataOptions): void {
|
||||
if (!data || data.length === 0) return;
|
||||
|
||||
const [{ jsPDF }, { default: autoTable }] = await Promise.all([
|
||||
import('jspdf'),
|
||||
import('jspdf-autotable'),
|
||||
]);
|
||||
|
||||
// Enforce Landscape orientation
|
||||
const doc = new jsPDF({
|
||||
orientation: 'landscape',
|
||||
|
||||
@@ -1,6 +1,5 @@
|
||||
import {
|
||||
ApprovalStatus,
|
||||
ComplianceStatus,
|
||||
ContractStatus,
|
||||
ActivityStatus,
|
||||
Priority,
|
||||
@@ -79,7 +78,6 @@ export const NOTIFICATION_STATUS_OPTIONS = [
|
||||
{ value: NotificationStatus.Failed, label: 'Failed' },
|
||||
{ value: NotificationStatus.Cancelled, label: 'Cancelled' },
|
||||
{ value: NotificationStatus.Bounced, label: 'Bounced' },
|
||||
{ value: NotificationStatus.Draft, label: 'Draft' },
|
||||
];
|
||||
|
||||
export const NOTIFICATION_PLATFORM_OPTIONS = [
|
||||
@@ -273,37 +271,6 @@ export const getApprovalStatusBadge = (status: ApprovalStatus | number) => {
|
||||
}
|
||||
};
|
||||
|
||||
export const getComplianceStatusBadge = (status: ComplianceStatus | number) => {
|
||||
const s = Number(status);
|
||||
switch (s) {
|
||||
case ComplianceStatus.Exceeded:
|
||||
return {
|
||||
label: 'Exceeded Expectation',
|
||||
className: 'bg-purple-500/15 text-purple-300 border-purple-500/30 font-semibold',
|
||||
dot: 'bg-purple-400',
|
||||
};
|
||||
case ComplianceStatus.Met:
|
||||
return {
|
||||
label: 'Met',
|
||||
className: 'bg-emerald-500/15 text-emerald-300 border-emerald-500/30 font-semibold',
|
||||
dot: 'bg-emerald-400',
|
||||
};
|
||||
case ComplianceStatus.NotMet:
|
||||
default:
|
||||
return {
|
||||
label: 'Not Met',
|
||||
className: 'bg-rose-500/15 text-rose-300 border-rose-500/30 font-semibold',
|
||||
dot: 'bg-rose-400',
|
||||
};
|
||||
}
|
||||
};
|
||||
|
||||
export const COMPLIANCE_STATUS_OPTIONS = [
|
||||
{ value: ComplianceStatus.NotMet, label: 'Not Met' },
|
||||
{ value: ComplianceStatus.Met, label: 'Met' },
|
||||
{ value: ComplianceStatus.Exceeded, label: 'Exceeded Expectation' },
|
||||
];
|
||||
|
||||
export const getContractStatusBadge = (status: ContractStatus | number) => {
|
||||
const s = Number(status);
|
||||
switch (s) {
|
||||
@@ -510,8 +477,6 @@ export const getEmailStatusBadge = (status?: EmailStatuses | number | null) => {
|
||||
export const getNotificationStatusBadge = (status?: NotificationStatus | number | null) => {
|
||||
const s = Number(status);
|
||||
switch (s) {
|
||||
case NotificationStatus.Draft:
|
||||
return { label: 'Draft', className: 'bg-purple-50 text-purple-800 border-purple-300 font-semibold' };
|
||||
case NotificationStatus.Delivered:
|
||||
case NotificationStatus.Read:
|
||||
return { label: getNotificationStatusLabel(s), className: 'bg-emerald-50 text-emerald-800 border-emerald-300 font-semibold' };
|
||||
|
||||
@@ -1,325 +0,0 @@
|
||||
/**
|
||||
* Markdown & Branded HTML Formatting Utilities for PostFundManagement (PFM)
|
||||
*/
|
||||
|
||||
export const PFM_BRAND_LOGO_SVG = `<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 64 64" width="40" height="40" fill="none">
|
||||
<defs>
|
||||
<radialGradient id="pfm-ambient" cx="50%" cy="50%" r="50%">
|
||||
<stop offset="0%" stop-color="#38bdf8" stop-opacity="0.4" />
|
||||
<stop offset="70%" stop-color="#2563eb" stop-opacity="0.1" />
|
||||
<stop offset="100%" stop-color="#1e1b4b" stop-opacity="0" />
|
||||
</radialGradient>
|
||||
<linearGradient id="pfm-top" x1="12" y1="8" x2="52" y2="26" gradientUnits="userSpaceOnUse">
|
||||
<stop offset="0%" stop-color="#a5b4fc" />
|
||||
<stop offset="35%" stop-color="#6366f1" />
|
||||
<stop offset="100%" stop-color="#4338ca" />
|
||||
</linearGradient>
|
||||
<linearGradient id="pfm-left" x1="10" y1="22" x2="30" y2="56" gradientUnits="userSpaceOnUse">
|
||||
<stop offset="0%" stop-color="#38bdf8" />
|
||||
<stop offset="45%" stop-color="#2563eb" />
|
||||
<stop offset="100%" stop-color="#1e40af" />
|
||||
</linearGradient>
|
||||
<linearGradient id="pfm-right" x1="34" y1="28" x2="54" y2="56" gradientUnits="userSpaceOnUse">
|
||||
<stop offset="0%" stop-color="#22d3ee" />
|
||||
<stop offset="50%" stop-color="#06b6d4" />
|
||||
<stop offset="100%" stop-color="#10b981" />
|
||||
</linearGradient>
|
||||
<linearGradient id="pfm-core" x1="27" y1="27" x2="37" y2="37" gradientUnits="userSpaceOnUse">
|
||||
<stop offset="0%" stop-color="#ffffff" />
|
||||
<stop offset="40%" stop-color="#e0f2fe" />
|
||||
<stop offset="100%" stop-color="#38bdf8" />
|
||||
</linearGradient>
|
||||
</defs>
|
||||
<circle cx="32" cy="32" r="31" fill="#090d16" />
|
||||
<circle cx="32" cy="32" r="30" stroke="#1e293b" stroke-width="1" />
|
||||
<circle cx="32" cy="32" r="28" fill="url(#pfm-ambient)" />
|
||||
<path d="M32 6.5 L53.5 19 C55 19.8 56 21.5 56 23.2 L56 40.8 C56 42.5 55 44.2 53.5 45 L32 57.5 C30.5 58.3 28.5 58.3 27 57.5 L5.5 45 C4 44.2 3 42.5 3 40.8 L3 23.2 C3 21.5 4 19.8 5.5 19 L27 6.5 C28.5 5.7 30.5 5.7 32 6.5 Z" stroke="#3b82f6" stroke-opacity="0.3" stroke-width="1.2" fill="none" />
|
||||
<path d="M32 9 L51.5 20.2 L43 25.2 L32 18.8 L21 25.2 L12.5 20.2 Z" fill="url(#pfm-top)" />
|
||||
<path d="M10 23 L19.2 28.4 L19.2 44.5 L30 50.8 L30 55.5 L10 43.8 Z" fill="url(#pfm-left)" />
|
||||
<path d="M54 23 L54 43.8 L34 55.5 L34 50.8 L44.8 44.5 L44.8 28.4 Z" fill="url(#pfm-right)" />
|
||||
<path d="M22 28.5 L32 22.8 L42 28.5 L42 35.5 L32 41.2 L22 35.5 Z" fill="#0b1120" stroke="#334155" stroke-width="1" />
|
||||
<path d="M25 31.5 L32 27.5 L39 31.5 L32 35.5 Z" fill="url(#pfm-top)" opacity="0.9" />
|
||||
<path d="M25 37 L32 41 L39 37 L32 33 L25 37 Z" fill="url(#pfm-right)" opacity="0.9" />
|
||||
<polygon points="32,27 36.5,32 32,37 27.5,32" fill="url(#pfm-core)" />
|
||||
<circle cx="32" cy="32" r="1.5" fill="#ffffff" />
|
||||
</svg>`;
|
||||
|
||||
export const PFM_LOGO_DATA_URI = `data:image/svg+xml;utf8,${encodeURIComponent(PFM_BRAND_LOGO_SVG)}`;
|
||||
|
||||
export interface MarkdownRenderOptions {
|
||||
applyBranding?: boolean;
|
||||
emailFormat?: boolean;
|
||||
subject?: string;
|
||||
tenantName?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Checks if the content string is formatted HTML or Markdown.
|
||||
*/
|
||||
export function isHtmlContent(content: string): boolean {
|
||||
if (!content) return false;
|
||||
const trimmed = content.trim();
|
||||
return (
|
||||
trimmed.startsWith('<!DOCTYPE') ||
|
||||
trimmed.startsWith('<html') ||
|
||||
trimmed.startsWith('<div') ||
|
||||
trimmed.startsWith('<table') ||
|
||||
trimmed.startsWith('<p') ||
|
||||
trimmed.startsWith('<h') ||
|
||||
trimmed.startsWith('<ul') ||
|
||||
trimmed.startsWith('<ol') ||
|
||||
trimmed.startsWith('<blockquote') ||
|
||||
(trimmed.includes('</') && trimmed.includes('>')) ||
|
||||
/<(strong|b|em|i|u|h[1-6]|ul|ol|li|table|blockquote|div|p|img|hr|br)\b/i.test(trimmed)
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Extracts raw plain text from markdown or HTML for concise notification snippets.
|
||||
*/
|
||||
export function stripMarkdown(text: string): string {
|
||||
if (!text) return '';
|
||||
return text
|
||||
.replace(/<[^>]*>/g, ' ') // Strip HTML tags
|
||||
.replace(/!\[.*?\]\(.*?\)/g, '') // Strip images
|
||||
.replace(/\[(.*?)\]\(.*?\)/g, '$1') // Extract link text
|
||||
.replace(/#{1,6}\s+/g, '') // Strip headers
|
||||
.replace(/(\*\*|__)(.*?)\1/g, '$2') // Strip bold
|
||||
.replace(/(\*|_)(.*?)\1/g, '$2') // Strip italic
|
||||
.replace(/~~(.*?)~~/g, '$1') // Strip strikethrough
|
||||
.replace(/`{1,3}.*?`{1,3}/gs, '') // Strip code blocks
|
||||
.replace(/^>\s+/gm, '') // Strip blockquotes
|
||||
.replace(/^[-*+]\s+/gm, '') // Strip list markers
|
||||
.replace(/^\d+\.\s+/gm, '') // Strip ordered list markers
|
||||
.replace(/\|/g, ' ') // Replace table pipes
|
||||
.replace(/\s+/g, ' ')
|
||||
.trim();
|
||||
}
|
||||
|
||||
/**
|
||||
* Parses markdown text into semantic HTML.
|
||||
*/
|
||||
export function parseMarkdownToHtml(markdown: string): string {
|
||||
if (!markdown) return '';
|
||||
|
||||
let html = markdown
|
||||
// Sanitize script tags
|
||||
.replace(/<script\b[^<]*(?:(?!<\/script>)<[^<]*)*<\/script>/gi, '')
|
||||
// Normalize line endings
|
||||
.replace(/\r\n/g, '\n');
|
||||
|
||||
// 1. Code blocks (```lang\ncode\n```)
|
||||
html = html.replace(/```([a-zA-Z0-9_-]*)\n([\s\S]*?)```/g, (_match, _lang, code) => {
|
||||
const escaped = code
|
||||
.replace(/&/g, '&')
|
||||
.replace(/</g, '<')
|
||||
.replace(/>/g, '>');
|
||||
return `<pre style="background:#0b1120;border:1px solid #1e293b;border-radius:12px;padding:14px;overflow-x:auto;font-family:ui-monospace,SFMono-Regular,Menlo,Monaco,Consolas,monospace;font-size:12px;color:#e2e8f0;margin:12px 0;"><code>${escaped}</code></pre>`;
|
||||
});
|
||||
|
||||
// 2. Inline code (`code`)
|
||||
html = html.replace(/`([^`]+)`/g, (_match, code) => {
|
||||
const escaped = code
|
||||
.replace(/&/g, '&')
|
||||
.replace(/</g, '<')
|
||||
.replace(/>/g, '>');
|
||||
return `<code style="background:#1e293b;color:#38bdf8;padding:2px 6px;border-radius:6px;font-family:monospace;font-size:12px;">${escaped}</code>`;
|
||||
});
|
||||
|
||||
// 3. Tables (| A | B |\n|---|---|\n| C | D |)
|
||||
html = html.replace(/((?:\|[^\n]+\|\n?)+)/g, (match) => {
|
||||
const rows = match.trim().split('\n');
|
||||
if (rows.length < 2) return match;
|
||||
|
||||
const hasSeparator = rows[1] && rows[1].includes('---');
|
||||
if (!hasSeparator) return match;
|
||||
|
||||
const parseCells = (row: string) =>
|
||||
row
|
||||
.split('|')
|
||||
.slice(1, -1)
|
||||
.map((c) => c.trim());
|
||||
|
||||
const headers = parseCells(rows[0]);
|
||||
const bodyRows = rows.slice(2);
|
||||
|
||||
let tableHtml = `<div style="overflow-x:auto;margin:14px 0;"><table style="width:100%;border-collapse:collapse;border:1px solid #334155;border-radius:8px;font-size:12px;text-align:left;">`;
|
||||
tableHtml += `<thead><tr style="background:#1e293b;color:#f8fafc;">`;
|
||||
headers.forEach((h) => {
|
||||
tableHtml += `<th style="padding:8px 12px;border:1px solid #334155;font-weight:600;">${h}</th>`;
|
||||
});
|
||||
tableHtml += `</tr></thead><tbody>`;
|
||||
|
||||
bodyRows.forEach((row, i) => {
|
||||
const cells = parseCells(row);
|
||||
const bg = i % 2 === 0 ? '#0f172a' : '#1e293b/40';
|
||||
tableHtml += `<tr style="background:${bg};color:#cbd5e1;">`;
|
||||
cells.forEach((c) => {
|
||||
tableHtml += `<td style="padding:8px 12px;border:1px solid #334155;">${c}</td>`;
|
||||
});
|
||||
tableHtml += `</tr>`;
|
||||
});
|
||||
|
||||
tableHtml += `</tbody></table></div>`;
|
||||
return tableHtml;
|
||||
});
|
||||
|
||||
// 4. Headers (# through ####)
|
||||
html = html.replace(/^#### (.*?)$/gm, '<h4 style="font-size:14px;font-weight:700;color:#f8fafc;margin:12px 0 6px 0;">$1</h4>');
|
||||
html = html.replace(/^### (.*?)$/gm, '<h3 style="font-size:16px;font-weight:700;color:#f8fafc;margin:14px 0 8px 0;">$1</h3>');
|
||||
html = html.replace(/^## (.*?)$/gm, '<h2 style="font-size:18px;font-weight:800;color:#ffffff;margin:16px 0 8px 0;border-bottom:1px solid #1e293b;padding-bottom:6px;">$1</h2>');
|
||||
html = html.replace(/^# (.*?)$/gm, '<h1 style="font-size:22px;font-weight:800;color:#ffffff;margin:18px 0 10px 0;border-bottom:1px solid #334155;padding-bottom:8px;">$1</h1>');
|
||||
|
||||
// 5. Blockquotes and GitHub alerts (> [!NOTE], > [!IMPORTANT], etc.)
|
||||
html = html.replace(/^>\s*\[!NOTE\]\s*(.*?)$/gim, '<div style="background:#0c4a6e22;border-left:4px solid #0284c7;padding:10px 14px;border-radius:8px;margin:10px 0;color:#bae6fd;font-size:12px;"><strong>NOTE:</strong> $1</div>');
|
||||
html = html.replace(/^>\s*\[!IMPORTANT\]\s*(.*?)$/gim, '<div style="background:#83184322;border-left:4px solid #db2777;padding:10px 14px;border-radius:8px;margin:10px 0;color:#fbcfe8;font-size:12px;"><strong>IMPORTANT:</strong> $1</div>');
|
||||
html = html.replace(/^>\s*\[!WARNING\]\s*(.*?)$/gim, '<div style="background:#713f1222;border-left:4px solid #d97706;padding:10px 14px;border-radius:8px;margin:10px 0;color:#fef08a;font-size:12px;"><strong>WARNING:</strong> $1</div>');
|
||||
html = html.replace(/^>\s*\[!TIP\]\s*(.*?)$/gim, '<div style="background:#064e3b22;border-left:4px solid #059669;padding:10px 14px;border-radius:8px;margin:10px 0;color:#a7f3d0;font-size:12px;"><strong>TIP:</strong> $1</div>');
|
||||
html = html.replace(/^>\s*(.*?)$/gm, '<blockquote style="border-left:3px solid #3b82f6;padding-left:12px;margin:8px 0;color:#94a3b8;font-style:italic;">$1</blockquote>');
|
||||
|
||||
// 6. Horizontal Rules (---)
|
||||
html = html.replace(/^(?:---|\*\*\*|___)\s*$/gm, '<hr style="border:none;border-top:1px solid #334155;margin:16px 0;" />');
|
||||
|
||||
// 7. Bold & Italic
|
||||
html = html.replace(/\*\*\*(.*?)\*\*\*/g, '<strong><em>$1</em></strong>');
|
||||
html = html.replace(/\*\*(.*?)\*\*/g, '<strong style="color:#ffffff;">$1</strong>');
|
||||
html = html.replace(/__(.*?)__/g, '<strong style="color:#ffffff;">$1</strong>');
|
||||
html = html.replace(/\*(.*?)\*/g, '<em style="color:#cbd5e1;">$1</em>');
|
||||
html = html.replace(/_(.*?)_/g, '<em style="color:#cbd5e1;">$1</em>');
|
||||
html = html.replace(/~~(.*?)~~/g, '<del style="color:#64748b;">$1</del>');
|
||||
|
||||
// 8. Images and Links
|
||||
html = html.replace(/!\[(.*?)\]\((.*?)\)/g, '<img src="$2" alt="$1" style="max-width:100%;border-radius:8px;margin:10px 0;box-shadow:0 4px 12px rgba(0,0,0,0.5);" />');
|
||||
html = html.replace(/\[(.*?)\]\((.*?)\)/g, '<a href="$2" target="_blank" rel="noopener noreferrer" style="color:#38bdf8;text-decoration:underline;">$1</a>');
|
||||
|
||||
// 9. Unordered Lists (- or *)
|
||||
html = html.replace(/^[-*+]\s+(.*?)$/gm, '<li style="margin-left:20px;list-style-type:disc;color:#cbd5e1;padding:2px 0;">$1</li>');
|
||||
|
||||
// 10. Ordered Lists (1. 2.)
|
||||
html = html.replace(/^\d+\.\s+(.*?)$/gm, '<li style="margin-left:20px;list-style-type:decimal;color:#cbd5e1;padding:2px 0;">$1</li>');
|
||||
|
||||
// 11. Paragraphs
|
||||
const paragraphs = html.split(/\n{2,}/);
|
||||
html = paragraphs
|
||||
.map((p) => {
|
||||
const trimmed = p.trim();
|
||||
if (!trimmed) return '';
|
||||
if (
|
||||
trimmed.startsWith('<h') ||
|
||||
trimmed.startsWith('<div') ||
|
||||
trimmed.startsWith('<table') ||
|
||||
trimmed.startsWith('<pre') ||
|
||||
trimmed.startsWith('<blockquote') ||
|
||||
trimmed.startsWith('<hr') ||
|
||||
trimmed.startsWith('<li')
|
||||
) {
|
||||
return trimmed;
|
||||
}
|
||||
return `<p style="margin:8px 0;line-height:1.6;color:#cbd5e1;">${trimmed.replace(/\n/g, '<br />')}</p>`;
|
||||
})
|
||||
.join('\n');
|
||||
|
||||
return html;
|
||||
}
|
||||
|
||||
/**
|
||||
* Transforms Markdown into full branded executive HTML for email notifications.
|
||||
*/
|
||||
export function renderMarkdownToHtml(
|
||||
markdown: string,
|
||||
options?: MarkdownRenderOptions
|
||||
): string {
|
||||
const trimmed = (markdown || '').trim();
|
||||
if (trimmed.startsWith('<!DOCTYPE') || trimmed.startsWith('<html')) {
|
||||
return trimmed;
|
||||
}
|
||||
|
||||
const contentHtml = isHtmlContent(trimmed)
|
||||
? trimmed.replace(/<script\b[^<]*(?:(?!<\/script>)<[^<]*)*<\/script>/gi, '')
|
||||
: parseMarkdownToHtml(trimmed);
|
||||
|
||||
if (!options?.applyBranding && !options?.emailFormat) {
|
||||
return contentHtml;
|
||||
}
|
||||
|
||||
const subject = options?.subject || 'Statutory Platform Notification';
|
||||
const tenantName = options?.tenantName || 'Post-Fund Management (PFM) Platform';
|
||||
const dateFormatted = new Date().toLocaleDateString('en-ZA', {
|
||||
day: 'numeric',
|
||||
month: 'short',
|
||||
year: 'numeric',
|
||||
});
|
||||
|
||||
return `<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="utf-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<title>${subject}</title>
|
||||
</head>
|
||||
<body style="margin:0;padding:24px 12px;background-color:#090d16;font-family:-apple-system,BlinkMacSystemFont,'Segoe UI',Roboto,Helvetica,Arial,sans-serif;color:#cbd5e1;">
|
||||
<table role="presentation" width="100%" cellpadding="0" cellspacing="0" style="max-width:640px;margin:0 auto;background-color:#0d1322;border:1px solid #1e293b;border-radius:16px;overflow:hidden;box-shadow:0 12px 36px rgba(0,0,0,0.6);">
|
||||
<!-- Executive Brand Header -->
|
||||
<tr>
|
||||
<td style="padding:20px 24px;background:linear-gradient(135deg, #0b1120 0%, #1e1b4b 50%, #0f172a 100%);border-bottom:1px solid #1e293b;">
|
||||
<table role="presentation" width="100%" cellpadding="0" cellspacing="0">
|
||||
<tr>
|
||||
<td width="48" style="vertical-align:middle;">
|
||||
<img src="${PFM_LOGO_DATA_URI}" alt="PFM Logo" width="40" height="40" style="display:block;border-radius:10px;" />
|
||||
</td>
|
||||
<td style="padding-left:14px;vertical-align:middle;">
|
||||
<div style="font-size:14px;font-weight:800;color:#ffffff;letter-spacing:-0.02em;text-transform:uppercase;">
|
||||
Post-Fund Management (PFM)
|
||||
</div>
|
||||
<div style="font-size:11px;color:#38bdf8;font-weight:600;margin-top:2px;">
|
||||
${tenantName}
|
||||
</div>
|
||||
</td>
|
||||
<td align="right" style="vertical-align:middle;">
|
||||
<span style="font-size:10px;font-weight:700;color:#38bdf8;background-color:rgba(56,189,248,0.12);border:1px solid rgba(56,189,248,0.25);padding:4px 8px;border-radius:20px;text-transform:uppercase;letter-spacing:0.04em;">
|
||||
Official Notice
|
||||
</span>
|
||||
</td>
|
||||
</tr>
|
||||
</table>
|
||||
</td>
|
||||
</tr>
|
||||
|
||||
<!-- Subject Banner -->
|
||||
<tr>
|
||||
<td style="padding:16px 24px;background-color:#0b101b;border-bottom:1px solid #1e293b;">
|
||||
<h2 style="margin:0;font-size:16px;font-weight:700;color:#ffffff;line-height:1.4;">
|
||||
${subject}
|
||||
</h2>
|
||||
<div style="font-size:11px;color:#64748b;margin-top:4px;">
|
||||
Dispatched on ${dateFormatted} • Electronic System Communication
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
|
||||
<!-- Message Content Body -->
|
||||
<tr>
|
||||
<td style="padding:24px;font-size:13px;line-height:1.65;color:#e2e8f0;">
|
||||
${contentHtml}
|
||||
</td>
|
||||
</tr>
|
||||
|
||||
<!-- Executive Footer -->
|
||||
<tr>
|
||||
<td style="padding:18px 24px;background-color:#080c14;border-top:1px solid #1e293b;font-size:11px;color:#64748b;text-align:center;">
|
||||
<div style="font-weight:600;color:#94a3b8;margin-bottom:4px;">
|
||||
Post-Fund Management, Performance & Impact Governance Platform
|
||||
</div>
|
||||
<div>
|
||||
This message was transmitted securely under active multi-tenant encryption. Please do not reply directly to automated dispatch notices.
|
||||
</div>
|
||||
<div style="margin-top:8px;font-size:10px;color:#475569;">
|
||||
© ${new Date().getFullYear()} PFM Governance Architecture. All rights reserved.
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
</table>
|
||||
</body>
|
||||
</html>`;
|
||||
}
|
||||
|
||||
@@ -1,27 +1,11 @@
|
||||
import { QueryClient, QueryKey, InvalidateQueryFilters } from '@tanstack/react-query';
|
||||
import { bumpMutationEpoch } from '../services/httpClient';
|
||||
import { QueryClient } from '@tanstack/react-query';
|
||||
|
||||
export const queryClient = new QueryClient({
|
||||
defaultOptions: {
|
||||
queries: {
|
||||
staleTime: 1000 * 60 * 2, // 2 minutes fresh in memory
|
||||
gcTime: 1000 * 60 * 10, // 10 minutes cache retention
|
||||
staleTime: 1000 * 60 * 3, // 3 minutes default cache
|
||||
refetchOnWindowFocus: false,
|
||||
refetchOnReconnect: true,
|
||||
retry: 1,
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
/**
|
||||
* Invalidates queries while simultaneously bumping the mutation epoch.
|
||||
* This guarantees that the background refetch generates a fresh query parameter (_v)
|
||||
* that bypasses backend ASP.NET Core OutputCaching.
|
||||
*/
|
||||
export async function invalidateAndBustCache(
|
||||
client: QueryClient,
|
||||
filters?: InvalidateQueryFilters
|
||||
): Promise<void> {
|
||||
bumpMutationEpoch();
|
||||
await client.invalidateQueries(filters);
|
||||
}
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -20,7 +20,6 @@ import {
|
||||
User,
|
||||
Clock,
|
||||
ArrowRight,
|
||||
Lock,
|
||||
} from 'lucide-react';
|
||||
import {
|
||||
getProjects,
|
||||
@@ -40,7 +39,6 @@ import { HelpTooltip } from '../components/ui/HelpTooltip';
|
||||
import { ExportButton } from '../components/ui/ExportButton';
|
||||
import { ExportColumn } from '../utils/exportUtils';
|
||||
import { useTableSort, SortableTh } from '../utils/tableSort';
|
||||
import { useRbac } from '../auth/rbac';
|
||||
import { ApprovalStatusBadge, PriorityBadge } from '../components/ui/StatusBadge';
|
||||
import { Issue, ChangeRequest, ApprovalStatus, ActivityStatus, Priority } from '../types';
|
||||
import {
|
||||
@@ -111,7 +109,6 @@ type ResolveChangeFormValues = z.infer<typeof resolveChangeSchema>;
|
||||
export const ChangeManagementPage: React.FC = () => {
|
||||
const queryClient = useQueryClient();
|
||||
const { success, error: toastError } = useToast();
|
||||
const { canAdd, canApprove, canExport, canApproveRecord, isMaker } = useRbac();
|
||||
|
||||
const [selectedProjectId, setSelectedProjectId] = useState<string>('all');
|
||||
const [activeTab, setActiveTab] = useState<'issues' | 'changes'>('issues');
|
||||
@@ -239,7 +236,7 @@ export const ChangeManagementPage: React.FC = () => {
|
||||
issueForm.reset();
|
||||
success('Issue Logged', 'New project issue created and assigned.');
|
||||
},
|
||||
onError: (err: unknown) => toastError('Creation Failed', err),
|
||||
onError: (err: any) => toastError('Creation Failed', err),
|
||||
});
|
||||
|
||||
const updateIssueMutation = useMutation({
|
||||
@@ -274,7 +271,7 @@ export const ChangeManagementPage: React.FC = () => {
|
||||
editIssueForm.reset();
|
||||
success('Issue Updated', 'Issue record and resolution status saved successfully.');
|
||||
},
|
||||
onError: (err: unknown) => toastError('Update Failed', err),
|
||||
onError: (err: any) => toastError('Update Failed', err),
|
||||
});
|
||||
|
||||
const resolveIssueMutation = useMutation({
|
||||
@@ -298,7 +295,7 @@ export const ChangeManagementPage: React.FC = () => {
|
||||
resolveIssueForm.reset();
|
||||
success('Issue Resolved', 'Resolution recorded and issue status updated.');
|
||||
},
|
||||
onError: (err: unknown) => toastError('Resolution Failed', err),
|
||||
onError: (err: any) => toastError('Resolution Failed', err),
|
||||
});
|
||||
|
||||
const createChangeMutation = useMutation({
|
||||
@@ -318,7 +315,7 @@ export const ChangeManagementPage: React.FC = () => {
|
||||
changeForm.reset();
|
||||
success('Change Request Submitted', 'Formal change request submitted for review.');
|
||||
},
|
||||
onError: (err: unknown) => toastError('Submission Failed', err),
|
||||
onError: (err: any) => toastError('Submission Failed', err),
|
||||
});
|
||||
|
||||
const resolveChangeMutation = useMutation({
|
||||
@@ -339,15 +336,12 @@ export const ChangeManagementPage: React.FC = () => {
|
||||
resolveChangeForm.reset();
|
||||
success('Change Decision Applied', 'Change request decision successfully resolved.');
|
||||
},
|
||||
onError: (err: unknown) => toastError('Decision Failed', err),
|
||||
onError: (err: any) => toastError('Decision Failed', err),
|
||||
});
|
||||
|
||||
const updateChangeMutation = useMutation({
|
||||
mutationFn: (vals: EditChangeFormValues) => {
|
||||
if (!editingChange) throw new Error('No change request selected');
|
||||
if (Number(editingChange.status) === ApprovalStatus.Approved) {
|
||||
throw new Error('Approved change requests are immutable and cannot be updated.');
|
||||
}
|
||||
return updateChangeRequest({
|
||||
changeRequestId: editingChange.id,
|
||||
title: vals.title,
|
||||
@@ -363,7 +357,7 @@ export const ChangeManagementPage: React.FC = () => {
|
||||
editChangeForm.reset();
|
||||
success('Change Request Updated', 'Change proposal and governance status saved.');
|
||||
},
|
||||
onError: (err: unknown) => toastError('Update Failed', err),
|
||||
onError: (err: any) => toastError('Update Failed', err),
|
||||
});
|
||||
|
||||
// Filtered & Sorted Issues
|
||||
@@ -485,10 +479,6 @@ export const ChangeManagementPage: React.FC = () => {
|
||||
};
|
||||
|
||||
const openEditChange = async (cr: ChangeRequest) => {
|
||||
if (Number(cr.status) === ApprovalStatus.Approved) {
|
||||
toastError('Record Immutable', 'Approved and enacted change requests are immutable.');
|
||||
return;
|
||||
}
|
||||
try {
|
||||
// 1. Uses GET method (NOT POST)
|
||||
// 2. Passes ChangeRequestId (NOT ProjectId)
|
||||
@@ -732,14 +722,12 @@ export const ChangeManagementPage: React.FC = () => {
|
||||
</button>
|
||||
|
||||
<ExportButton
|
||||
claim="change_management"
|
||||
data={sortedIssues}
|
||||
columns={issueExportCols}
|
||||
filename={`project_issues_${new Date().toISOString().split('T')[0]}`}
|
||||
title="Project Issues Register"
|
||||
/>
|
||||
|
||||
{canAdd('change_management') && (
|
||||
<button
|
||||
onClick={() => {
|
||||
setEditingIssue(null);
|
||||
@@ -751,7 +739,6 @@ export const ChangeManagementPage: React.FC = () => {
|
||||
<Plus className="w-3.5 h-3.5" />
|
||||
<span>Log Issue</span>
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -933,14 +920,12 @@ export const ChangeManagementPage: React.FC = () => {
|
||||
</button>
|
||||
|
||||
<ExportButton
|
||||
claim="change_management"
|
||||
data={sortedChanges}
|
||||
columns={changeExportCols}
|
||||
filename={`change_requests_${new Date().toISOString().split('T')[0]}`}
|
||||
title="Change Requests Register"
|
||||
/>
|
||||
|
||||
{canAdd('change_management') && (
|
||||
<button
|
||||
onClick={() => {
|
||||
setEditingChange(null);
|
||||
@@ -957,7 +942,6 @@ export const ChangeManagementPage: React.FC = () => {
|
||||
<GitPullRequest className="w-3.5 h-3.5 text-blue-400" />
|
||||
<span>Request Change</span>
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -1017,13 +1001,6 @@ export const ChangeManagementPage: React.FC = () => {
|
||||
</td>
|
||||
<td className="py-3.5 px-5 text-right whitespace-nowrap space-x-2">
|
||||
<div className="inline-flex items-center gap-1.5">
|
||||
{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 & Enacted (Immutable)</span>
|
||||
</span>
|
||||
) : (
|
||||
<>
|
||||
<button
|
||||
onClick={() => openEditChange(cr)}
|
||||
className="p-1 text-slate-400 hover:text-white hover:bg-slate-800 rounded-lg transition cursor-pointer"
|
||||
@@ -1031,19 +1008,24 @@ export const ChangeManagementPage: React.FC = () => {
|
||||
>
|
||||
<Edit2 className="w-3.5 h-3.5" />
|
||||
</button>
|
||||
{canApproveRecord('change_management', cr.createdBy) && (
|
||||
{cr.status === ApprovalStatus.Approved ? (
|
||||
<span className="text-xs font-semibold text-emerald-400 bg-emerald-500/10 px-2.5 py-1 rounded-lg border border-emerald-500/20">
|
||||
Resolved & Enacted
|
||||
</span>
|
||||
) : (
|
||||
<>
|
||||
<button
|
||||
onClick={() => openResolveChange(cr, ApprovalStatus.Approved)}
|
||||
className="px-2.5 py-1 text-xs font-semibold text-emerald-300 bg-emerald-950/50 hover:bg-emerald-900/60 border border-emerald-700/50 rounded-lg transition cursor-pointer flex items-center gap-1"
|
||||
>
|
||||
<Check className="w-3 h-3" /> Approve
|
||||
</button>
|
||||
)}
|
||||
{canApprove('change_management') && isMaker(cr.createdBy) && (
|
||||
<span className="text-[10px] text-amber-400 font-mono italic px-1.5 py-0.5 bg-amber-500/10 border border-amber-500/20 rounded">
|
||||
Maker-Checker
|
||||
</span>
|
||||
)}
|
||||
<button
|
||||
onClick={() => openResolveChange(cr, ApprovalStatus.Rejected)}
|
||||
className="px-2.5 py-1 text-xs font-semibold text-rose-300 bg-rose-950/50 hover:bg-rose-900/60 border border-rose-700/50 rounded-lg transition cursor-pointer flex items-center gap-1"
|
||||
>
|
||||
<XCircle className="w-3 h-3" /> Reject
|
||||
</button>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
@@ -1522,39 +1504,15 @@ export const ChangeManagementPage: React.FC = () => {
|
||||
|
||||
<div>
|
||||
<label className="block text-xs font-semibold text-slate-300 mb-1">Governance Status *</label>
|
||||
{editingChange && Number(editingChange.status) === ApprovalStatus.Approved ? (
|
||||
<div
|
||||
className="w-full bg-slate-900 border border-emerald-500/30 rounded-xl px-3 py-2 text-xs text-emerald-300 font-semibold flex items-center gap-1.5 cursor-default"
|
||||
title="Approved and enacted change requests are immutable."
|
||||
>
|
||||
<Lock className="w-3.5 h-3.5 text-emerald-400" />
|
||||
<span>Approved & Enacted (Immutable - Cannot Be Changed)</span>
|
||||
</div>
|
||||
) : (
|
||||
<select
|
||||
{...editChangeForm.register('status')}
|
||||
className="w-full bg-slate-900 border border-slate-800 rounded-xl px-3 py-2 text-xs text-white focus:outline-none focus:border-amber-500"
|
||||
>
|
||||
<option value={ApprovalStatus.Pending}>Pending Review</option>
|
||||
<option value={ApprovalStatus.Review}>Under Active Review</option>
|
||||
{(() => {
|
||||
const canApproveChange = canApproveRecord('change_management', editingChange?.createdBy);
|
||||
const isSelf = isMaker(editingChange?.createdBy);
|
||||
let reason = '';
|
||||
if (!canApprove('change_management')) {
|
||||
reason = ' (Requires Update Permission)';
|
||||
} else if (isSelf) {
|
||||
reason = ' (Maker-Checker: Cannot approve own record)';
|
||||
}
|
||||
return (
|
||||
<option value={ApprovalStatus.Approved} disabled={!canApproveChange}>
|
||||
Approved & Enacted {reason}
|
||||
</option>
|
||||
);
|
||||
})()}
|
||||
<option value={ApprovalStatus.Approved}>Approved & Enacted</option>
|
||||
<option value={ApprovalStatus.Rejected}>Rejected</option>
|
||||
</select>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
|
||||
@@ -1,529 +0,0 @@
|
||||
import React, { useState, useMemo } from 'react';
|
||||
import { useQuery } from '@tanstack/react-query';
|
||||
import {
|
||||
ShieldCheck,
|
||||
ShieldAlert,
|
||||
AlertTriangle,
|
||||
Users,
|
||||
Search,
|
||||
Filter,
|
||||
RefreshCw,
|
||||
Eye,
|
||||
CheckCircle2,
|
||||
Coins,
|
||||
Building2,
|
||||
FileSpreadsheet,
|
||||
Download,
|
||||
AlertCircle,
|
||||
} from 'lucide-react';
|
||||
import {
|
||||
getProjects,
|
||||
getAllProgrammes,
|
||||
getAllPortfolios,
|
||||
getBudgets,
|
||||
getProjectComplianceItems,
|
||||
getProjectBeneficiaries,
|
||||
getBeneficiaryOutcomes,
|
||||
getProjectRisks,
|
||||
} from '../services/api';
|
||||
import {
|
||||
Project,
|
||||
Programme,
|
||||
Portfolio,
|
||||
ComplianceItem,
|
||||
Beneficiary,
|
||||
Outcome,
|
||||
ComplianceStatus,
|
||||
ApprovalStatus,
|
||||
} from '../types';
|
||||
import {
|
||||
formatDate,
|
||||
formatZAR,
|
||||
formatCompactZAR,
|
||||
getIndustrySectorLabel,
|
||||
getProjectTypeLabel,
|
||||
getApprovalStatusBadge,
|
||||
} from '../lib/formatters';
|
||||
import { HelpTooltip } from '../components/ui/HelpTooltip';
|
||||
import { ComplianceStatusBadge } from '../components/ui/StatusBadge';
|
||||
import { ProjectComplianceModal } from '../components/projects/ProjectComplianceModal';
|
||||
import { ExportButton } from '../components/ui/ExportButton';
|
||||
import { ExportColumn } from '../utils/exportUtils';
|
||||
|
||||
export const CompliancePage: React.FC = () => {
|
||||
const [searchTerm, setSearchTerm] = useState('');
|
||||
const [selectedProgramme, setSelectedProgramme] = useState('All');
|
||||
const [selectedComplianceFilter, setSelectedComplianceFilter] = useState<'All' | 'Compliant' | 'Noncompliant'>('All');
|
||||
const [selectedProjectForAudit, setSelectedProjectForAudit] = useState<Project | null>(null);
|
||||
const [isAuditModalOpen, setIsAuditModalOpen] = useState(false);
|
||||
|
||||
// Queries
|
||||
const { data: projects = [], isLoading: loadingProjects, refetch: refetchProjects, isFetching } = useQuery({
|
||||
queryKey: ['projects'],
|
||||
queryFn: () => getProjects(),
|
||||
});
|
||||
|
||||
const { data: programmes = [] } = useQuery({
|
||||
queryKey: ['programmes'],
|
||||
queryFn: () => getAllProgrammes(),
|
||||
});
|
||||
|
||||
const { data: portfolios = [] } = useQuery({
|
||||
queryKey: ['portfolios'],
|
||||
queryFn: () => getAllPortfolios(),
|
||||
});
|
||||
|
||||
const { data: allBudgets = [] } = useQuery({
|
||||
queryKey: ['budgets'],
|
||||
queryFn: () => getBudgets(1, 200),
|
||||
});
|
||||
|
||||
const { data: beneficiariesMap = new Map<string | number, Beneficiary[]>() } = useQuery({
|
||||
queryKey: ['allProjectBeneficiaries', projects.map((p) => p.id)],
|
||||
queryFn: async () => {
|
||||
const map = new Map<string | number, Beneficiary[]>();
|
||||
await Promise.all(
|
||||
projects.map(async (p) => {
|
||||
try {
|
||||
const bens = await getProjectBeneficiaries(p.id);
|
||||
map.set(p.id, bens);
|
||||
} catch {
|
||||
map.set(p.id, []);
|
||||
}
|
||||
})
|
||||
);
|
||||
return map;
|
||||
},
|
||||
enabled: projects.length > 0,
|
||||
});
|
||||
|
||||
// Query compliance items for all projects
|
||||
const { data: complianceMap = new Map<string | number, ComplianceItem[]>(), isLoading: loadingComps } = useQuery({
|
||||
queryKey: ['allProjectCompliance', projects.map((p) => p.id)],
|
||||
queryFn: async () => {
|
||||
const map = new Map<string | number, ComplianceItem[]>();
|
||||
await Promise.all(
|
||||
projects.map(async (p) => {
|
||||
try {
|
||||
const items = await getProjectComplianceItems(p.id);
|
||||
map.set(p.id, items);
|
||||
} catch {
|
||||
map.set(p.id, []);
|
||||
}
|
||||
})
|
||||
);
|
||||
return map;
|
||||
},
|
||||
enabled: projects.length > 0,
|
||||
});
|
||||
|
||||
// Query beneficiary outcomes
|
||||
const allBensList = useMemo(() => {
|
||||
const list: Beneficiary[] = [];
|
||||
beneficiariesMap.forEach((bList) => list.push(...bList));
|
||||
return list;
|
||||
}, [beneficiariesMap]);
|
||||
|
||||
const { data: outcomesMap = new Map<string | number, Outcome[]>() } = useQuery({
|
||||
queryKey: ['allBeneficiaryOutcomes', allBensList.map((b) => b.id)],
|
||||
queryFn: async () => {
|
||||
const map = new Map<string | number, Outcome[]>();
|
||||
await Promise.all(
|
||||
allBensList.map(async (b) => {
|
||||
try {
|
||||
const outs = await getBeneficiaryOutcomes(b.id);
|
||||
map.set(b.id, outs);
|
||||
} catch {
|
||||
map.set(b.id, []);
|
||||
}
|
||||
})
|
||||
);
|
||||
return map;
|
||||
},
|
||||
enabled: allBensList.length > 0,
|
||||
});
|
||||
|
||||
// Lookups
|
||||
const programmeMap = useMemo(() => {
|
||||
const map = new Map<string | number, Programme>();
|
||||
programmes.forEach((pr) => map.set(pr.id, pr));
|
||||
return map;
|
||||
}, [programmes]);
|
||||
|
||||
// Aggregate project compliance records
|
||||
const projectComplianceStats = useMemo(() => {
|
||||
return projects.map((p) => {
|
||||
const prog = programmeMap.get(p.programmeId);
|
||||
const items = complianceMap.get(p.id) || [];
|
||||
const projectBens = beneficiariesMap.get(p.id) || [];
|
||||
|
||||
let totalOutcomes = 0;
|
||||
let metOutcomes = 0;
|
||||
projectBens.forEach((b) => {
|
||||
const bOutcomes = outcomesMap.get(b.id) || [];
|
||||
totalOutcomes += bOutcomes.length;
|
||||
metOutcomes += bOutcomes.filter(
|
||||
(o) => Number(o.status) === ComplianceStatus.Met || Number(o.status) === ComplianceStatus.Exceeded
|
||||
).length;
|
||||
});
|
||||
|
||||
const totalItems = items.length;
|
||||
const metItems = items.filter(
|
||||
(i) => Number(i.status) === ComplianceStatus.Met || Number(i.status) === ComplianceStatus.Exceeded
|
||||
).length;
|
||||
const unmetItems = totalItems - metItems;
|
||||
const unmetOutcomes = totalOutcomes - metOutcomes;
|
||||
|
||||
const isCompliant = unmetItems === 0 && unmetOutcomes === 0;
|
||||
|
||||
const projectBudgets = allBudgets.filter((b) => String(b.projectId) === String(p.id));
|
||||
const approvedBudget = projectBudgets
|
||||
.filter((b) => Number(b.status) === ApprovalStatus.Approved || b.status === undefined)
|
||||
.reduce((sum, b) => sum + (Number(b.amount) || 0), 0);
|
||||
|
||||
const budgetAtRisk = isCompliant ? 0 : approvedBudget;
|
||||
|
||||
return {
|
||||
project: p,
|
||||
programme: prog,
|
||||
totalItems,
|
||||
metItems,
|
||||
unmetItems,
|
||||
totalOutcomes,
|
||||
metOutcomes,
|
||||
unmetOutcomes,
|
||||
beneficiariesCount: projectBens.length,
|
||||
isCompliant,
|
||||
approvedBudget,
|
||||
budgetAtRisk,
|
||||
};
|
||||
});
|
||||
}, [projects, programmeMap, complianceMap, beneficiariesMap, outcomesMap, allBudgets]);
|
||||
|
||||
// Filtered rows
|
||||
const filteredRows = useMemo(() => {
|
||||
return projectComplianceStats.filter((row) => {
|
||||
if (selectedProgramme !== 'All' && String(row.project.programmeId) !== selectedProgramme) {
|
||||
return false;
|
||||
}
|
||||
if (selectedComplianceFilter === 'Compliant' && !row.isCompliant) {
|
||||
return false;
|
||||
}
|
||||
if (selectedComplianceFilter === 'Noncompliant' && row.isCompliant) {
|
||||
return false;
|
||||
}
|
||||
if (searchTerm.trim()) {
|
||||
const q = searchTerm.toLowerCase();
|
||||
const pName = (row.project.name || '').toLowerCase();
|
||||
const prName = (row.programme?.name || '').toLowerCase();
|
||||
const idStr = String(row.project.id);
|
||||
if (!pName.includes(q) && !prName.includes(q) && !idStr.includes(q)) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
return true;
|
||||
});
|
||||
}, [projectComplianceStats, selectedProgramme, selectedComplianceFilter, searchTerm]);
|
||||
|
||||
// Aggregate KPI figures
|
||||
const totalProjectsCount = projectComplianceStats.length;
|
||||
const compliantProjectsCount = projectComplianceStats.filter((r) => r.isCompliant).length;
|
||||
const noncompliantProjectsCount = totalProjectsCount - compliantProjectsCount;
|
||||
const overallComplianceRate = totalProjectsCount > 0 ? Math.round((compliantProjectsCount / totalProjectsCount) * 100) : 100;
|
||||
const totalBudgetAtRisk = projectComplianceStats.reduce((sum, r) => sum + r.budgetAtRisk, 0);
|
||||
const totalBeneficiaries = allBensList.length;
|
||||
|
||||
const complianceExportColumns: ExportColumn<any>[] = [
|
||||
{ header: 'Project ID', accessor: (r) => r.project.id },
|
||||
{ header: 'Project Name', accessor: (r) => r.project.name },
|
||||
{ header: 'Programme', accessor: (r) => r.programme?.name || `Prog #${r.project.programmeId}` },
|
||||
{ header: 'Sector', accessor: (r) => getIndustrySectorLabel(r.project.sector) },
|
||||
{ header: 'Compliance Status', accessor: (r) => (r.isCompliant ? '100% Compliant' : 'Noncompliant') },
|
||||
{ header: 'Requirements Met', accessor: (r) => `${r.metItems}/${r.totalItems}` },
|
||||
{ header: 'Outcomes Met', accessor: (r) => `${r.metOutcomes}/${r.totalOutcomes}` },
|
||||
{ header: 'Approved Budget', accessor: (r) => formatZAR(r.approvedBudget) },
|
||||
{ header: 'Budget At Risk', accessor: (r) => formatZAR(r.budgetAtRisk) },
|
||||
];
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
{/* Header Banner */}
|
||||
<div className="bg-gradient-to-r from-[#0d1322] via-[#0f172a] to-[#0d1322] p-6 rounded-3xl border border-slate-800 shadow-xl relative overflow-hidden flex flex-col md:flex-row md:items-center justify-between gap-4">
|
||||
<div className="space-y-1 relative z-10">
|
||||
<div className="flex items-center gap-2">
|
||||
<div className="p-2 bg-blue-500/10 border border-blue-500/20 rounded-xl text-blue-400">
|
||||
<ShieldCheck className="w-5 h-5" />
|
||||
</div>
|
||||
<span className="text-xs font-bold uppercase tracking-wider text-blue-400">
|
||||
Statutory Governance & Compliance
|
||||
</span>
|
||||
</div>
|
||||
<h1 className="text-2xl sm:text-3xl font-extrabold text-white tracking-tight">
|
||||
Portfolio Compliance Dashboard
|
||||
</h1>
|
||||
<p className="text-xs sm:text-sm text-slate-400 max-w-2xl">
|
||||
Enterprise-wide compliance audit across all funded projects, beneficiary impact outcomes, and capital at risk.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-2.5 relative z-10 self-start md:self-center">
|
||||
<ExportButton
|
||||
claim="compliance"
|
||||
data={filteredRows}
|
||||
columns={complianceExportColumns}
|
||||
filename={`portfolio_compliance_${new Date().toISOString().split('T')[0]}`}
|
||||
title="Portfolio Compliance Register"
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => refetchProjects()}
|
||||
disabled={isFetching}
|
||||
className="inline-flex items-center gap-1.5 px-3.5 py-2 text-xs font-semibold text-white bg-blue-600 hover:bg-blue-500 rounded-xl transition cursor-pointer shadow-md shadow-blue-600/30 disabled:opacity-50"
|
||||
>
|
||||
<RefreshCw className={`w-3.5 h-3.5 ${isFetching ? 'animate-spin' : ''}`} />
|
||||
<span>Refresh</span>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 5 Headline KPI Cards */}
|
||||
<div className="grid grid-cols-2 md:grid-cols-3 lg:grid-cols-5 gap-4">
|
||||
{/* Card 1: Overall Rate */}
|
||||
<div className="bg-[#0f1523]/80 p-5 rounded-2xl border border-slate-800/80 shadow-md backdrop-blur-sm flex flex-col justify-between">
|
||||
<div className="flex items-center justify-between text-slate-400">
|
||||
<span className="text-[11px] font-semibold uppercase tracking-wider text-slate-400">Compliance Rate</span>
|
||||
<div className="p-1.5 rounded-lg bg-blue-500/10 text-blue-400 border border-blue-500/20">
|
||||
<ShieldCheck className="w-4 h-4" />
|
||||
</div>
|
||||
</div>
|
||||
<div className="mt-3">
|
||||
<p className="text-2xl font-bold text-white tracking-tight">{overallComplianceRate}%</p>
|
||||
<p className="text-[10px] text-emerald-400 font-mono mt-1">100% threshold target</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Card 2: Compliant Projects */}
|
||||
<div className="bg-[#0f1523]/80 p-5 rounded-2xl border border-slate-800/80 shadow-md backdrop-blur-sm flex flex-col justify-between">
|
||||
<div className="flex items-center justify-between text-slate-400">
|
||||
<span className="text-[11px] font-semibold uppercase tracking-wider text-slate-400">Compliant</span>
|
||||
<div className="p-1.5 rounded-lg bg-emerald-500/10 text-emerald-400 border border-emerald-500/20">
|
||||
<CheckCircle2 className="w-4 h-4" />
|
||||
</div>
|
||||
</div>
|
||||
<div className="mt-3">
|
||||
<p className="text-2xl font-bold text-emerald-400 tracking-tight">{compliantProjectsCount}</p>
|
||||
<p className="text-[10px] text-slate-400 font-mono mt-1">Eligible for approval</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Card 3: Noncompliant Projects */}
|
||||
<div className="bg-[#0f1523]/80 p-5 rounded-2xl border border-slate-800/80 shadow-md backdrop-blur-sm flex flex-col justify-between">
|
||||
<div className="flex items-center justify-between text-slate-400">
|
||||
<span className="text-[11px] font-semibold uppercase tracking-wider text-slate-400">Noncompliant</span>
|
||||
<div className="p-1.5 rounded-lg bg-rose-500/10 text-rose-400 border border-rose-500/20">
|
||||
<ShieldAlert className="w-4 h-4" />
|
||||
</div>
|
||||
</div>
|
||||
<div className="mt-3">
|
||||
<p className="text-2xl font-bold text-rose-400 tracking-tight">{noncompliantProjectsCount}</p>
|
||||
<p className="text-[10px] text-rose-400 font-mono mt-1">Approvals blocked</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Card 4: Total Capital At Risk */}
|
||||
<div className="bg-[#0f1523]/80 p-5 rounded-2xl border border-slate-800/80 shadow-md backdrop-blur-sm flex flex-col justify-between">
|
||||
<div className="flex items-center justify-between text-slate-400">
|
||||
<span className="text-[11px] font-semibold uppercase tracking-wider text-slate-400">Capital At Risk</span>
|
||||
<div className="p-1.5 rounded-lg bg-amber-500/10 text-amber-400 border border-amber-500/20">
|
||||
<AlertTriangle className="w-4 h-4" />
|
||||
</div>
|
||||
</div>
|
||||
<div className="mt-3">
|
||||
<p className="text-2xl font-bold text-amber-400 tracking-tight">{formatCompactZAR(totalBudgetAtRisk)}</p>
|
||||
<p className="text-[10px] text-slate-400 font-mono mt-1">{formatZAR(totalBudgetAtRisk)}</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Card 5: Beneficiaries Monitored */}
|
||||
<div className="bg-[#0f1523]/80 p-5 rounded-2xl border border-slate-800/80 shadow-md backdrop-blur-sm flex flex-col justify-between">
|
||||
<div className="flex items-center justify-between text-slate-400">
|
||||
<span className="text-[11px] font-semibold uppercase tracking-wider text-slate-400">Beneficiaries</span>
|
||||
<div className="p-1.5 rounded-lg bg-purple-500/10 text-purple-400 border border-purple-500/20">
|
||||
<Users className="w-4 h-4" />
|
||||
</div>
|
||||
</div>
|
||||
<div className="mt-3">
|
||||
<p className="text-2xl font-bold text-white tracking-tight">{totalBeneficiaries}</p>
|
||||
<p className="text-[10px] text-slate-400 font-mono mt-1">Impact groups monitored</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Filter Toolbar */}
|
||||
<div className="bg-[#0f1523]/80 backdrop-blur-md p-4 rounded-2xl border border-slate-800/80 shadow-lg flex flex-wrap items-center justify-between gap-3">
|
||||
<div className="flex items-center gap-3 flex-wrap">
|
||||
<div className="relative">
|
||||
<Search className="w-3.5 h-3.5 absolute left-3 top-1/2 -translate-y-1/2 text-slate-500" />
|
||||
<input
|
||||
type="text"
|
||||
placeholder="Search project, programme, ID..."
|
||||
value={searchTerm}
|
||||
onChange={(e) => setSearchTerm(e.target.value)}
|
||||
className="pl-9 pr-3 py-1.5 bg-[#090d16] border border-slate-700/80 rounded-xl text-xs text-white placeholder-slate-500 focus:outline-none focus:border-blue-500 w-64 transition"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center bg-[#090d16] border border-slate-700/80 rounded-xl px-3 py-1.5 text-xs">
|
||||
<span className="text-slate-400 mr-2 text-[11px] font-semibold uppercase">Programme:</span>
|
||||
<select
|
||||
value={selectedProgramme}
|
||||
onChange={(e) => setSelectedProgramme(e.target.value)}
|
||||
className="bg-transparent text-white focus:outline-none cursor-pointer max-w-[180px] truncate"
|
||||
>
|
||||
<option value="All" className="bg-slate-900">All Programmes</option>
|
||||
{programmes.map((pr) => (
|
||||
<option key={pr.id} value={pr.id} className="bg-slate-900">
|
||||
{pr.name}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center bg-[#090d16] border border-slate-700/80 rounded-xl px-3 py-1.5 text-xs">
|
||||
<span className="text-slate-400 mr-2 text-[11px] font-semibold uppercase">Compliance:</span>
|
||||
<select
|
||||
value={selectedComplianceFilter}
|
||||
onChange={(e) => setSelectedComplianceFilter(e.target.value as any)}
|
||||
className="bg-transparent text-white focus:outline-none cursor-pointer"
|
||||
>
|
||||
<option value="All" className="bg-slate-900">All Statuses</option>
|
||||
<option value="Compliant" className="bg-slate-900">Compliant (100%)</option>
|
||||
<option value="Noncompliant" className="bg-slate-900">Noncompliant (Blocked)</option>
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<span className="text-xs text-slate-400 font-medium">
|
||||
Showing <strong className="text-white">{filteredRows.length}</strong> of {projectComplianceStats.length} projects
|
||||
</span>
|
||||
</div>
|
||||
|
||||
{/* Projects Compliance Register Table */}
|
||||
<div className="bg-[#0f1523]/80 rounded-2xl border border-slate-800/80 overflow-hidden shadow-xl backdrop-blur-sm">
|
||||
<div className="p-4 border-b border-slate-800/80 flex items-center justify-between bg-[#0d121f]/90">
|
||||
<div className="flex items-center gap-2">
|
||||
<ShieldCheck className="w-4 h-4 text-cyan-400" />
|
||||
<h3 className="text-xs font-bold uppercase tracking-wider text-white">
|
||||
Project Compliance Matrix & Audit Register
|
||||
</h3>
|
||||
</div>
|
||||
<span className="text-[11px] text-slate-400">
|
||||
Invariant: 100% compliance required for formal project approval
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div className="overflow-x-auto">
|
||||
<table className="w-full text-left text-xs border-collapse">
|
||||
<thead>
|
||||
<tr className="bg-[#090d16] border-b border-slate-800 text-slate-400 text-[11px] font-semibold select-none">
|
||||
<th className="py-3.5 px-5">Project Name & ID</th>
|
||||
<th className="py-3.5 px-4">Programme</th>
|
||||
<th className="py-3.5 px-4">Compliance Status</th>
|
||||
<th className="py-3.5 px-4 text-center">Requirements Met</th>
|
||||
<th className="py-3.5 px-4 text-center">Outcomes Met</th>
|
||||
<th className="py-3.5 px-4 text-right">Approved Budget</th>
|
||||
<th className="py-3.5 px-4 text-right">Capital At Risk</th>
|
||||
<th className="py-3.5 px-5 text-right font-medium">Actions</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody className="divide-y divide-slate-800/60 font-medium">
|
||||
{loadingProjects || loadingComps ? (
|
||||
<tr>
|
||||
<td colSpan={8} className="py-12 text-center text-slate-400 text-xs">
|
||||
<div className="flex flex-col items-center gap-2">
|
||||
<div className="w-6 h-6 border-2 border-blue-500 border-t-transparent rounded-full animate-spin" />
|
||||
<span>Loading compliance matrix...</span>
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
) : filteredRows.length === 0 ? (
|
||||
<tr>
|
||||
<td colSpan={8} className="py-10 text-center text-slate-500 text-xs">
|
||||
No projects matching active compliance filters.
|
||||
</td>
|
||||
</tr>
|
||||
) : (
|
||||
filteredRows.map((r) => (
|
||||
<tr key={r.project.id} className="hover:bg-slate-800/40 transition-colors">
|
||||
<td className="py-3.5 px-5">
|
||||
<span className="font-semibold text-white block">{r.project.name}</span>
|
||||
<span className="text-[10px] text-slate-500 font-mono">ID #{r.project.id} • {getIndustrySectorLabel(r.project.sector)}</span>
|
||||
</td>
|
||||
<td className="py-3.5 px-4 text-slate-300">
|
||||
{r.programme?.name || `Prog #${r.project.programmeId}`}
|
||||
</td>
|
||||
<td className="py-3.5 px-4">
|
||||
{r.isCompliant ? (
|
||||
<span className="inline-flex items-center gap-1.5 px-2.5 py-0.5 rounded-full text-[10px] font-semibold bg-emerald-500/10 text-emerald-400 border border-emerald-500/30">
|
||||
<CheckCircle2 className="w-3 h-3 text-emerald-400" />
|
||||
<span>100% Compliant</span>
|
||||
</span>
|
||||
) : (
|
||||
<span className="inline-flex items-center gap-1.5 px-2.5 py-0.5 rounded-full text-[10px] font-semibold bg-rose-500/10 text-rose-400 border border-rose-500/30">
|
||||
<AlertTriangle className="w-3 h-3 text-rose-400" />
|
||||
<span>Noncompliant ({r.unmetItems + r.unmetOutcomes} Unmet)</span>
|
||||
</span>
|
||||
)}
|
||||
</td>
|
||||
<td className="py-3.5 px-4 text-center font-mono font-semibold">
|
||||
<span className={r.unmetItems === 0 ? 'text-emerald-400' : 'text-rose-400'}>
|
||||
{r.metItems}/{r.totalItems}
|
||||
</span>
|
||||
</td>
|
||||
<td className="py-3.5 px-4 text-center font-mono font-semibold">
|
||||
<span className={r.unmetOutcomes === 0 ? 'text-emerald-400' : 'text-purple-400'}>
|
||||
{r.metOutcomes}/{r.totalOutcomes}
|
||||
</span>
|
||||
</td>
|
||||
<td className="py-3.5 px-4 text-right font-mono text-slate-300">
|
||||
{formatZAR(r.approvedBudget)}
|
||||
</td>
|
||||
<td className="py-3.5 px-4 text-right font-mono font-bold">
|
||||
<span className={r.budgetAtRisk > 0 ? 'text-rose-400' : 'text-emerald-400'}>
|
||||
{formatZAR(r.budgetAtRisk)}
|
||||
</span>
|
||||
</td>
|
||||
<td className="py-3.5 px-5 text-right whitespace-nowrap">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => {
|
||||
setSelectedProjectForAudit(r.project);
|
||||
setIsAuditModalOpen(true);
|
||||
}}
|
||||
className="inline-flex items-center gap-1 text-xs font-semibold text-emerald-300 hover:text-white bg-emerald-950/40 hover:bg-emerald-900/60 border border-emerald-500/30 px-3 py-1.5 rounded-xl transition cursor-pointer shadow-xs active:scale-95"
|
||||
>
|
||||
<ShieldCheck className="w-3.5 h-3.5 text-emerald-400" />
|
||||
<span>Inspect Compliance</span>
|
||||
</button>
|
||||
</td>
|
||||
</tr>
|
||||
))
|
||||
)}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Compliance Audit Modal */}
|
||||
<ProjectComplianceModal
|
||||
isOpen={isAuditModalOpen}
|
||||
onClose={() => {
|
||||
setIsAuditModalOpen(false);
|
||||
setSelectedProjectForAudit(null);
|
||||
}}
|
||||
project={selectedProjectForAudit}
|
||||
programme={selectedProjectForAudit ? programmeMap.get(selectedProjectForAudit.programmeId) || null : null}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
@@ -15,7 +15,6 @@ import {
|
||||
ShieldAlert,
|
||||
RotateCcw,
|
||||
LayoutDashboard,
|
||||
ShieldCheck,
|
||||
} from 'lucide-react';
|
||||
import {
|
||||
getDashboard,
|
||||
@@ -27,29 +26,13 @@ import {
|
||||
getDisbursements,
|
||||
getAllRisks,
|
||||
getAllIndicators,
|
||||
getProjectComplianceItems,
|
||||
} from '../services/api';
|
||||
import { formatCompactZAR, formatZAR, getIndustrySectorLabel } from '../lib/formatters';
|
||||
import { ApprovalStatus, ComplianceStatus } from '../types';
|
||||
import { ApprovalStatus } from '../types';
|
||||
import { RiskHeatmapMatrix } from '../components/ui/RiskHeatmapMatrix';
|
||||
import { HelpTooltip } from '../components/ui/HelpTooltip';
|
||||
import { useTableSort, SortableTh } from '../utils/tableSort';
|
||||
|
||||
interface AttentionProject {
|
||||
id: number | string;
|
||||
name: string;
|
||||
programme: string;
|
||||
status: string;
|
||||
risk: string;
|
||||
complianceStatus: string;
|
||||
isCompliant: boolean;
|
||||
unmetCount: number;
|
||||
budgetAtRisk: number;
|
||||
financialVariance: string;
|
||||
varianceType: string;
|
||||
maxScore: number;
|
||||
}
|
||||
|
||||
export const ExecutiveDashboard: React.FC = () => {
|
||||
const queryClient = useQueryClient();
|
||||
const [selectedProgramme, setSelectedProgramme] = useState('All');
|
||||
@@ -114,6 +97,11 @@ export const ExecutiveDashboard: React.FC = () => {
|
||||
queryFn: () => getAllIndicators(),
|
||||
});
|
||||
|
||||
const refetchAll = () => {
|
||||
refetchDashboard();
|
||||
queryClient.invalidateQueries();
|
||||
};
|
||||
|
||||
// Filtered projects according to active Programme and Sector dropdown selections
|
||||
const filteredProjects = useMemo(() => {
|
||||
return projects.filter((p) => {
|
||||
@@ -128,30 +116,6 @@ export const ExecutiveDashboard: React.FC = () => {
|
||||
});
|
||||
}, [projects, selectedProgramme, selectedSector]);
|
||||
|
||||
const { data: projectComplianceMap = new Map<string | number, any[]>() } = useQuery({
|
||||
queryKey: ['projectComplianceMap', filteredProjects.map((p) => p.id)],
|
||||
queryFn: async () => {
|
||||
const map = new Map<string | number, any[]>();
|
||||
await Promise.all(
|
||||
filteredProjects.map(async (p) => {
|
||||
try {
|
||||
const items = await getProjectComplianceItems(p.id);
|
||||
map.set(p.id, items);
|
||||
} catch {
|
||||
map.set(p.id, []);
|
||||
}
|
||||
})
|
||||
);
|
||||
return map;
|
||||
},
|
||||
enabled: filteredProjects.length > 0,
|
||||
});
|
||||
|
||||
const refetchAll = () => {
|
||||
refetchDashboard();
|
||||
queryClient.invalidateQueries();
|
||||
};
|
||||
|
||||
// Filtered risks associated with the filtered project set
|
||||
const filteredRisks = useMemo(() => {
|
||||
if (selectedProgramme === 'All' && selectedSector === 'All') {
|
||||
@@ -214,11 +178,6 @@ export const ExecutiveDashboard: React.FC = () => {
|
||||
return dashboard?.disbursed || 0;
|
||||
}, [dashboard, disbursements, filteredProjects, selectedProgramme, selectedSector]);
|
||||
|
||||
const disbursedPercentage = useMemo(() => {
|
||||
if (!portfolioValue || portfolioValue <= 0) return 0;
|
||||
return Math.min(100, Math.round((disbursed / portfolioValue) * 1000) / 10);
|
||||
}, [disbursed, portfolioValue]);
|
||||
|
||||
const onTrackPercentage = useMemo(() => {
|
||||
const targetProjects = filteredProjects.length > 0 ? filteredProjects : projects;
|
||||
if (targetProjects.length === 0) return 0;
|
||||
@@ -247,34 +206,6 @@ export const ExecutiveDashboard: React.FC = () => {
|
||||
return dashboard?.atRiskCount ?? 0;
|
||||
}, [dashboard, filteredRisks]);
|
||||
|
||||
// Compliance and Capital at Risk calculations
|
||||
const totalBudgetAtRisk = useMemo(() => {
|
||||
const targetProjects = filteredProjects.length > 0 ? filteredProjects : projects;
|
||||
return targetProjects.reduce((sum, p) => {
|
||||
const pComps = projectComplianceMap.get(p.id) || [];
|
||||
const hasUnmet = pComps.some((c) => Number(c.status) === ComplianceStatus.NotMet || Number(c.status) === 1);
|
||||
const isPendingOrRejected = p.status === ApprovalStatus.Rejected || p.status === ApprovalStatus.Pending;
|
||||
if (hasUnmet || isPendingOrRejected) {
|
||||
const pBudgets = budgets.filter((b) => String(b.projectId) === String(p.id));
|
||||
const pApproved = pBudgets
|
||||
.filter((b) => Number(b.status) === ApprovalStatus.Approved || b.status === undefined)
|
||||
.reduce((bSum, b) => bSum + (Number(b.amount) || 0), 0);
|
||||
return sum + pApproved;
|
||||
}
|
||||
return sum;
|
||||
}, 0);
|
||||
}, [filteredProjects, projects, projectComplianceMap, budgets]);
|
||||
|
||||
const complianceRate = useMemo(() => {
|
||||
const targetProjects = filteredProjects.length > 0 ? filteredProjects : projects;
|
||||
if (targetProjects.length === 0) return 100;
|
||||
const compliantCount = targetProjects.filter((p) => {
|
||||
const pComps = projectComplianceMap.get(p.id) || [];
|
||||
return !pComps.some((c) => Number(c.status) === ComplianceStatus.NotMet || Number(c.status) === 1);
|
||||
}).length;
|
||||
return Math.round((compliantCount / targetProjects.length) * 100);
|
||||
}, [filteredProjects, projects, projectComplianceMap]);
|
||||
|
||||
const beneficiariesReached = useMemo(() => {
|
||||
if (dashboard?.beneficiariesReached && dashboard.beneficiariesReached > 0) {
|
||||
return dashboard.beneficiariesReached;
|
||||
@@ -346,7 +277,7 @@ export const ExecutiveDashboard: React.FC = () => {
|
||||
}, [disbursements]);
|
||||
|
||||
// Projects requiring management attention based on risk profile
|
||||
const attentionProjects = useMemo<AttentionProject[]>(() => {
|
||||
const attentionProjects = useMemo(() => {
|
||||
const targetProjects = filteredProjects.length > 0 ? filteredProjects : projects;
|
||||
if (targetProjects.length === 0) return [];
|
||||
|
||||
@@ -361,7 +292,7 @@ export const ExecutiveDashboard: React.FC = () => {
|
||||
});
|
||||
|
||||
return targetProjects
|
||||
.map((p): AttentionProject => {
|
||||
.map((p) => {
|
||||
const maxScore = projectMaxRisk[String(p.id)] || 0;
|
||||
const isRejected = p.status === ApprovalStatus.Rejected;
|
||||
const isPending = p.status === ApprovalStatus.Pending || p.status === ApprovalStatus.Review;
|
||||
@@ -383,50 +314,33 @@ export const ExecutiveDashboard: React.FC = () => {
|
||||
varianceType = 'negative';
|
||||
}
|
||||
|
||||
const pComps = projectComplianceMap.get(p.id) || [];
|
||||
const unmetCount = pComps.filter(
|
||||
(c) => Number(c.status) === ComplianceStatus.NotMet || Number(c.status) === 1
|
||||
).length;
|
||||
const isCompliant = unmetCount === 0;
|
||||
|
||||
const pBudgets = budgets.filter((b) => String(b.projectId) === String(p.id));
|
||||
const pApprovedBudget = pBudgets
|
||||
.filter((b) => Number(b.status) === ApprovalStatus.Approved || b.status === undefined)
|
||||
.reduce((sum, b) => sum + (Number(b.amount) || 0), 0);
|
||||
|
||||
const projectBudgetAtRisk = !isCompliant || riskLevel === 'RED' || riskLevel === 'AMBER' ? pApprovedBudget : 0;
|
||||
|
||||
return {
|
||||
id: p.id,
|
||||
name: p.name || 'Unnamed Project',
|
||||
name: p.name,
|
||||
programme: p.programmeName || `Programme ${p.programmeId}`,
|
||||
status: statusLabel,
|
||||
risk: riskLevel,
|
||||
complianceStatus: isCompliant ? 'Compliant' : `Noncompliant (${unmetCount} Unmet)`,
|
||||
isCompliant,
|
||||
unmetCount,
|
||||
budgetAtRisk: projectBudgetAtRisk,
|
||||
financialVariance: variance,
|
||||
varianceType,
|
||||
maxScore,
|
||||
};
|
||||
})
|
||||
.filter((p) => p.risk === 'RED' || p.risk === 'AMBER' || !p.isCompliant)
|
||||
.sort((a, b) => b.budgetAtRisk - a.budgetAtRisk || b.maxScore - a.maxScore)
|
||||
.slice(0, 8);
|
||||
}, [projects, filteredProjects, risks, projectComplianceMap, budgets]);
|
||||
.filter((p) => p.risk === 'RED' || p.risk === 'AMBER')
|
||||
.sort((a, b) => b.maxScore - a.maxScore)
|
||||
.slice(0, 5);
|
||||
}, [projects, filteredProjects, risks]);
|
||||
|
||||
const {
|
||||
sortedData: sortedAttentionProjects,
|
||||
sortColumn: projectSortCol,
|
||||
sortDirection: projectSortDir,
|
||||
handleSort: handleProjectSort,
|
||||
} = useTableSort<AttentionProject>(attentionProjects, null, null, {
|
||||
name: (p) => p.name,
|
||||
programme: (p) => p.programme,
|
||||
status: (p) => p.status,
|
||||
risk: (p) => p.risk,
|
||||
financialVariance: (p) => p.financialVariance,
|
||||
} = useTableSort<any>(attentionProjects, null, null, {
|
||||
name: (p: any) => p.name,
|
||||
programme: (p: any) => p.programme,
|
||||
status: (p: any) => p.status,
|
||||
risk: (p: any) => p.risk,
|
||||
financialVariance: (p: any) => p.financialVariance,
|
||||
});
|
||||
|
||||
return (
|
||||
@@ -573,31 +487,13 @@ export const ExecutiveDashboard: React.FC = () => {
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Executive Portfolio KPIs: Structured 2-Tier Balanced Grid */}
|
||||
<div className="space-y-6">
|
||||
{/* Tier 1: Capital Commitments & Deployment Scale */}
|
||||
<div className="space-y-3">
|
||||
<div className="flex items-center justify-between px-1">
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="text-[11px] font-bold uppercase tracking-widest text-slate-400">
|
||||
Capital Deployment & Scale
|
||||
</span>
|
||||
<span className="h-1 w-1 rounded-full bg-slate-600" />
|
||||
<span className="text-xs text-slate-500 hidden sm:inline">Portfolio commitments, disbursements & capital exposure</span>
|
||||
</div>
|
||||
<span className="text-[10px] font-semibold text-blue-400/90 bg-blue-500/10 px-2 py-0.5 rounded-full border border-blue-500/20">
|
||||
Financial Governance
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-4 gap-4">
|
||||
{/* 6 Headline Portfolio KPI Cards */}
|
||||
<div className="grid grid-cols-2 md:grid-cols-3 lg:grid-cols-6 gap-4">
|
||||
{/* Card 1: Portfolio Value */}
|
||||
<div className="bg-[#0f1523]/80 p-5 rounded-2xl border border-slate-800/80 hover:border-blue-500/40 transition-all flex flex-col justify-between shadow-md group backdrop-blur-sm relative overflow-hidden">
|
||||
<div className="flex items-start justify-between gap-3">
|
||||
<div className="flex items-center gap-1.5 min-w-0 flex-1">
|
||||
<span className="text-xs font-semibold uppercase tracking-wider text-slate-400 truncate">
|
||||
Portfolio Value
|
||||
</span>
|
||||
<div className="bg-[#0f1523]/80 p-5 rounded-2xl border border-slate-800/80 hover:border-blue-500/30 transition-all flex flex-col justify-between shadow-md group backdrop-blur-sm">
|
||||
<div className="flex items-center justify-between text-slate-400">
|
||||
<div className="flex items-center gap-1.5">
|
||||
<span className="text-[11px] font-semibold uppercase tracking-wider text-slate-400">Portfolio Value</span>
|
||||
<HelpTooltip
|
||||
title="Total Portfolio Value"
|
||||
content="Aggregated statutory grant commitment amount approved across all active grant awards and contracts."
|
||||
@@ -605,72 +501,21 @@ export const ExecutiveDashboard: React.FC = () => {
|
||||
placement="top"
|
||||
/>
|
||||
</div>
|
||||
<div className="shrink-0 p-2 rounded-xl bg-blue-500/10 text-blue-400 border border-blue-500/20 group-hover:scale-105 transition-transform">
|
||||
<Coins className="w-4 h-4" />
|
||||
<div className="p-1.5 rounded-lg bg-blue-500/10 text-blue-400 border border-blue-500/20 group-hover:scale-110 transition">
|
||||
<Coins className="w-3.5 h-3.5" />
|
||||
</div>
|
||||
</div>
|
||||
<div className="mt-3">
|
||||
<p className="text-2xl sm:text-3xl font-bold text-white tracking-tight">
|
||||
{formatCompactZAR(portfolioValue)}
|
||||
</p>
|
||||
<div className="flex items-center justify-between mt-3 pt-2.5 border-t border-slate-800/70 text-xs">
|
||||
<span className="text-slate-400 font-mono text-[11px] truncate mr-2" title={formatZAR(portfolioValue)}>
|
||||
{formatZAR(portfolioValue)}
|
||||
</span>
|
||||
<span className="text-[10px] font-medium text-blue-400 bg-blue-500/10 px-1.5 py-0.5 rounded border border-blue-500/20 shrink-0">
|
||||
Statutory Total
|
||||
</span>
|
||||
</div>
|
||||
<p className="text-2xl font-bold text-white tracking-tight">{formatCompactZAR(portfolioValue)}</p>
|
||||
<p className="text-[10px] text-slate-400 font-mono mt-1">{formatZAR(portfolioValue)}</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Card 2: Disbursed */}
|
||||
<div className="bg-[#0f1523]/80 p-5 rounded-2xl border border-slate-800/80 hover:border-teal-500/40 transition-all flex flex-col justify-between shadow-md group backdrop-blur-sm relative overflow-hidden">
|
||||
<div className="flex items-start justify-between gap-3">
|
||||
<div className="flex items-center gap-1.5 min-w-0 flex-1">
|
||||
<span className="text-xs font-semibold uppercase tracking-wider text-slate-400 truncate">
|
||||
Disbursed
|
||||
</span>
|
||||
<HelpTooltip
|
||||
title="Disbursed Funds"
|
||||
content="Actual funds paid out to implementing grantees following verified milestone proof-of-performance."
|
||||
size="sm"
|
||||
placement="top"
|
||||
/>
|
||||
</div>
|
||||
<div className="shrink-0 p-2 rounded-xl bg-teal-500/10 text-teal-400 border border-teal-500/20 group-hover:scale-105 transition-transform">
|
||||
<TrendingUp className="w-4 h-4" />
|
||||
</div>
|
||||
</div>
|
||||
<div className="mt-3">
|
||||
<div className="flex items-baseline justify-between gap-2">
|
||||
<p className="text-2xl sm:text-3xl font-bold text-teal-400 tracking-tight">
|
||||
{formatCompactZAR(disbursed)}
|
||||
</p>
|
||||
<span className="text-xs font-semibold text-teal-400 bg-teal-500/10 px-2 py-0.5 rounded-full border border-teal-500/20 shrink-0 font-mono">
|
||||
{disbursedPercentage}%
|
||||
</span>
|
||||
</div>
|
||||
<div className="w-full h-1.5 bg-slate-800/90 rounded-full overflow-hidden mt-3">
|
||||
<div
|
||||
className="h-full bg-gradient-to-r from-teal-500 to-emerald-400 rounded-full transition-all duration-500"
|
||||
style={{ width: `${disbursedPercentage}%` }}
|
||||
/>
|
||||
</div>
|
||||
<div className="flex items-center justify-between mt-2.5 pt-2 border-t border-slate-800/70 text-xs text-slate-400">
|
||||
<span className="text-[11px]">Tranches released</span>
|
||||
<span className="font-mono text-slate-300 text-[11px]">{disbursedPercentage}% deployed</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Card 3: Active Awards */}
|
||||
<div className="bg-[#0f1523]/80 p-5 rounded-2xl border border-slate-800/80 hover:border-indigo-500/40 transition-all flex flex-col justify-between shadow-md group backdrop-blur-sm relative overflow-hidden">
|
||||
<div className="flex items-start justify-between gap-3">
|
||||
<div className="flex items-center gap-1.5 min-w-0 flex-1">
|
||||
<span className="text-xs font-semibold uppercase tracking-wider text-slate-400 truncate">
|
||||
Active Awards
|
||||
</span>
|
||||
{/* Card 2: Active Awards */}
|
||||
<div className="bg-[#0f1523]/80 p-5 rounded-2xl border border-slate-800/80 hover:border-blue-500/30 transition-all flex flex-col justify-between shadow-md group backdrop-blur-sm">
|
||||
<div className="flex items-center justify-between text-slate-400">
|
||||
<div className="flex items-center gap-1.5">
|
||||
<span className="text-[11px] font-semibold uppercase tracking-wider text-slate-400">Active Awards</span>
|
||||
<HelpTooltip
|
||||
title="Active Grant Awards"
|
||||
content="Total legally executed statutory agreements currently undergoing active disbursement and implementation."
|
||||
@@ -678,85 +523,47 @@ export const ExecutiveDashboard: React.FC = () => {
|
||||
placement="top"
|
||||
/>
|
||||
</div>
|
||||
<div className="shrink-0 p-2 rounded-xl bg-indigo-500/10 text-indigo-400 border border-indigo-500/20 group-hover:scale-105 transition-transform">
|
||||
<Layers className="w-4 h-4" />
|
||||
<div className="p-1.5 rounded-lg bg-indigo-500/10 text-indigo-400 border border-indigo-500/20 group-hover:scale-110 transition">
|
||||
<Layers className="w-3.5 h-3.5" />
|
||||
</div>
|
||||
</div>
|
||||
<div className="mt-3">
|
||||
<p className="text-2xl sm:text-3xl font-bold text-white tracking-tight">
|
||||
{activeAwards}
|
||||
<p className="text-2xl font-bold text-white tracking-tight">{activeAwards}</p>
|
||||
<p className="text-[10px] text-emerald-400 font-mono mt-1">
|
||||
Across {portfolios.length} portfolio{portfolios.length === 1 ? '' : 's'}
|
||||
</p>
|
||||
<div className="flex items-center justify-between mt-3 pt-2.5 border-t border-slate-800/70 text-xs">
|
||||
<span className="text-slate-400 text-[11px]">Under implementation</span>
|
||||
<span className="font-medium text-indigo-400 bg-indigo-500/10 px-1.5 py-0.5 rounded border border-indigo-500/20 shrink-0 text-[10px]">
|
||||
{portfolios.length} Portfolio{portfolios.length === 1 ? '' : 's'}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Card 4: Budget At Risk */}
|
||||
<div className="bg-[#0f1523]/80 p-5 rounded-2xl border border-slate-800/80 hover:border-rose-500/40 transition-all flex flex-col justify-between shadow-md group backdrop-blur-sm relative overflow-hidden">
|
||||
<div className="flex items-start justify-between gap-3">
|
||||
<div className="flex items-center gap-1.5 min-w-0 flex-1">
|
||||
<span className="text-xs font-semibold uppercase tracking-wider text-slate-400 truncate">
|
||||
Budget At Risk
|
||||
</span>
|
||||
{/* Card 3: Disbursed */}
|
||||
<div className="bg-[#0f1523]/80 p-5 rounded-2xl border border-slate-800/80 hover:border-teal-500/30 transition-all flex flex-col justify-between shadow-md group backdrop-blur-sm">
|
||||
<div className="flex items-center justify-between text-slate-400">
|
||||
<div className="flex items-center gap-1.5">
|
||||
<span className="text-[11px] font-semibold uppercase tracking-wider text-slate-400">Disbursed</span>
|
||||
<HelpTooltip
|
||||
title="Total Budgeted Capital At Risk"
|
||||
content="Aggregated approved budget across all projects currently flagged as noncompliant or carrying unmitigated high risks."
|
||||
title="Disbursed Funds"
|
||||
content="Actual funds paid out to implementing grantees following verified milestone proof-of-performance."
|
||||
size="sm"
|
||||
placement="top"
|
||||
/>
|
||||
</div>
|
||||
<div className="shrink-0 p-2 rounded-xl bg-rose-500/10 text-rose-400 border border-rose-500/20 group-hover:scale-105 transition-transform">
|
||||
<ShieldAlert className="w-4 h-4" />
|
||||
<div className="p-1.5 rounded-lg bg-teal-500/10 text-teal-400 border border-teal-500/20 group-hover:scale-110 transition">
|
||||
<TrendingUp className="w-3.5 h-3.5" />
|
||||
</div>
|
||||
</div>
|
||||
<div className="mt-3">
|
||||
<p className="text-2xl sm:text-3xl font-bold text-rose-400 tracking-tight">
|
||||
{formatCompactZAR(totalBudgetAtRisk)}
|
||||
<p className="text-2xl font-bold text-teal-400 tracking-tight">{formatCompactZAR(disbursed)}</p>
|
||||
<p className="text-[10px] text-slate-400 font-mono mt-1">
|
||||
{portfolioValue > 0 ? `${((disbursed / portfolioValue) * 100).toFixed(1)}% of commitments` : '0.0% of commitments'}
|
||||
</p>
|
||||
<div className="flex items-center justify-between mt-3 pt-2.5 border-t border-slate-800/70 text-xs">
|
||||
<span className="text-slate-400 font-mono text-[11px] truncate mr-2" title={formatZAR(totalBudgetAtRisk)}>
|
||||
{formatZAR(totalBudgetAtRisk)}
|
||||
</span>
|
||||
<span className={`text-[10px] font-medium px-1.5 py-0.5 rounded border shrink-0 ${
|
||||
totalBudgetAtRisk > 0
|
||||
? 'text-rose-400 bg-rose-500/10 border-rose-500/20'
|
||||
: 'text-emerald-400 bg-emerald-500/10 border-emerald-500/20'
|
||||
}`}>
|
||||
{totalBudgetAtRisk > 0 ? 'Exposed' : 'Clear'}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Tier 2: Operational Health, Risk & Beneficiary Impact */}
|
||||
<div className="space-y-3">
|
||||
<div className="flex items-center justify-between px-1">
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="text-[11px] font-bold uppercase tracking-widest text-slate-400">
|
||||
Operational Performance & Impact
|
||||
</span>
|
||||
<span className="h-1 w-1 rounded-full bg-slate-600" />
|
||||
<span className="text-xs text-slate-500 hidden sm:inline">Milestone pace, compliance gate health & direct beneficiaries</span>
|
||||
</div>
|
||||
<span className="text-[10px] font-semibold text-emerald-400/90 bg-emerald-500/10 px-2 py-0.5 rounded-full border border-emerald-500/20">
|
||||
Delivery Health
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-4 gap-4">
|
||||
{/* Card 5: On Track */}
|
||||
<div className="bg-[#0f1523]/80 p-5 rounded-2xl border border-slate-800/80 hover:border-emerald-500/40 transition-all flex flex-col justify-between shadow-md group backdrop-blur-sm relative overflow-hidden">
|
||||
<div className="flex items-start justify-between gap-3">
|
||||
<div className="flex items-center gap-1.5 min-w-0 flex-1">
|
||||
<span className="text-xs font-semibold uppercase tracking-wider text-slate-400 truncate">
|
||||
On Track
|
||||
</span>
|
||||
{/* Card 4: On Track */}
|
||||
<div className="bg-[#0f1523]/80 p-5 rounded-2xl border border-slate-800/80 hover:border-emerald-500/30 transition-all flex flex-col justify-between shadow-md group backdrop-blur-sm">
|
||||
<div className="flex items-center justify-between text-slate-400">
|
||||
<div className="flex items-center gap-1.5">
|
||||
<span className="text-[11px] font-semibold uppercase tracking-wider text-slate-400">On Track</span>
|
||||
<HelpTooltip
|
||||
title="On-Track Progress Ratio"
|
||||
content="Percentage of active projects meeting schedule deadlines and deliverable acceptance criteria without critical deviations."
|
||||
@@ -764,79 +571,21 @@ export const ExecutiveDashboard: React.FC = () => {
|
||||
placement="top"
|
||||
/>
|
||||
</div>
|
||||
<div className="shrink-0 p-2 rounded-xl bg-emerald-500/10 text-emerald-400 border border-emerald-500/20 group-hover:scale-105 transition-transform">
|
||||
<CheckCircle className="w-4 h-4" />
|
||||
<div className="p-1.5 rounded-lg bg-emerald-500/10 text-emerald-400 border border-emerald-500/20 group-hover:scale-110 transition">
|
||||
<CheckCircle className="w-3.5 h-3.5" />
|
||||
</div>
|
||||
</div>
|
||||
<div className="mt-3">
|
||||
<div className="flex items-baseline justify-between gap-2">
|
||||
<p className="text-2xl sm:text-3xl font-bold text-emerald-400 tracking-tight">
|
||||
{onTrackPercentage}%
|
||||
</p>
|
||||
<span className="text-xs font-semibold text-emerald-400 bg-emerald-500/10 px-2 py-0.5 rounded-full border border-emerald-500/20 shrink-0 font-mono">
|
||||
{onTrackPercentage >= 80 ? 'Optimal' : onTrackPercentage >= 60 ? 'Moderate' : 'Critical'}
|
||||
</span>
|
||||
</div>
|
||||
<div className="w-full h-1.5 bg-slate-800/90 rounded-full overflow-hidden mt-3">
|
||||
<div
|
||||
className="h-full bg-gradient-to-r from-emerald-500 to-teal-400 rounded-full transition-all duration-500"
|
||||
style={{ width: `${Math.min(100, onTrackPercentage)}%` }}
|
||||
/>
|
||||
</div>
|
||||
<div className="flex items-center justify-between mt-2.5 pt-2 border-t border-slate-800/70 text-xs text-slate-400">
|
||||
<span className="text-[11px]">Milestones verified</span>
|
||||
<span className="font-mono text-slate-300 text-[11px]">Pace index</span>
|
||||
</div>
|
||||
<p className="text-2xl font-bold text-emerald-400 tracking-tight">{onTrackPercentage}%</p>
|
||||
<p className="text-[10px] text-slate-400 font-mono mt-1">Milestones verified</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Card 6: Compliance Health */}
|
||||
<div className="bg-[#0f1523]/80 p-5 rounded-2xl border border-slate-800/80 hover:border-emerald-500/40 transition-all flex flex-col justify-between shadow-md group backdrop-blur-sm relative overflow-hidden">
|
||||
<div className="flex items-start justify-between gap-3">
|
||||
<div className="flex items-center gap-1.5 min-w-0 flex-1">
|
||||
<span className="text-xs font-semibold uppercase tracking-wider text-slate-400 truncate">
|
||||
Compliance
|
||||
</span>
|
||||
<HelpTooltip
|
||||
title="Portfolio Compliance Health"
|
||||
content="Percentage of active projects meeting 100% statutory compliance and beneficiary outcome criteria."
|
||||
size="sm"
|
||||
placement="top"
|
||||
/>
|
||||
</div>
|
||||
<div className="shrink-0 p-2 rounded-xl bg-emerald-500/10 text-emerald-400 border border-emerald-500/20 group-hover:scale-105 transition-transform">
|
||||
<ShieldCheck className="w-4 h-4" />
|
||||
</div>
|
||||
</div>
|
||||
<div className="mt-3">
|
||||
<div className="flex items-baseline justify-between gap-2">
|
||||
<p className="text-2xl sm:text-3xl font-bold text-emerald-400 tracking-tight">
|
||||
{complianceRate}%
|
||||
</p>
|
||||
<span className="text-xs font-semibold text-emerald-400 bg-emerald-500/10 px-2 py-0.5 rounded-full border border-emerald-500/20 shrink-0 font-mono">
|
||||
{complianceRate >= 90 ? 'Passed' : 'Gated'}
|
||||
</span>
|
||||
</div>
|
||||
<div className="w-full h-1.5 bg-slate-800/90 rounded-full overflow-hidden mt-3">
|
||||
<div
|
||||
className="h-full bg-gradient-to-r from-emerald-500 to-cyan-400 rounded-full transition-all duration-500"
|
||||
style={{ width: `${Math.min(100, complianceRate)}%` }}
|
||||
/>
|
||||
</div>
|
||||
<div className="flex items-center justify-between mt-2.5 pt-2 border-t border-slate-800/70 text-xs text-slate-400">
|
||||
<span className="text-[11px]">Statutory gate check</span>
|
||||
<span className="font-mono text-slate-300 text-[11px]">100% requirement</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Card 7: At Risk */}
|
||||
<div className="bg-[#0f1523]/80 p-5 rounded-2xl border border-slate-800/80 hover:border-amber-500/40 transition-all flex flex-col justify-between shadow-md group backdrop-blur-sm relative overflow-hidden">
|
||||
<div className="flex items-start justify-between gap-3">
|
||||
<div className="flex items-center gap-1.5 min-w-0 flex-1">
|
||||
<span className="text-xs font-semibold uppercase tracking-wider text-slate-400 truncate">
|
||||
At Risk
|
||||
</span>
|
||||
{/* Card 5: At Risk */}
|
||||
<div className="bg-[#0f1523]/80 p-5 rounded-2xl border border-slate-800/80 hover:border-amber-500/30 transition-all flex flex-col justify-between shadow-md group backdrop-blur-sm">
|
||||
<div className="flex items-center justify-between text-slate-400">
|
||||
<div className="flex items-center gap-1.5">
|
||||
<span className="text-[11px] font-semibold uppercase tracking-wider text-slate-400">At Risk</span>
|
||||
<HelpTooltip
|
||||
title="At-Risk Projects"
|
||||
content="Count of projects with high residual risk scores (≥12), schedule slippage >30 days, or adverse financial variance."
|
||||
@@ -844,34 +593,21 @@ export const ExecutiveDashboard: React.FC = () => {
|
||||
placement="top"
|
||||
/>
|
||||
</div>
|
||||
<div className="shrink-0 p-2 rounded-xl bg-amber-500/10 text-amber-400 border border-amber-500/20 group-hover:scale-105 transition-transform">
|
||||
<AlertTriangle className="w-4 h-4" />
|
||||
<div className="p-1.5 rounded-lg bg-amber-500/10 text-amber-400 border border-amber-500/20 group-hover:scale-110 transition">
|
||||
<AlertTriangle className="w-3.5 h-3.5" />
|
||||
</div>
|
||||
</div>
|
||||
<div className="mt-3">
|
||||
<p className="text-2xl sm:text-3xl font-bold text-amber-400 tracking-tight">
|
||||
{atRiskCount}
|
||||
</p>
|
||||
<div className="flex items-center justify-between mt-3 pt-2.5 border-t border-slate-800/70 text-xs">
|
||||
<span className="text-slate-400 text-[11px]">{atRiskCount === 1 ? 'Project flagged' : 'Projects flagged'}</span>
|
||||
<span className={`text-[10px] font-medium px-1.5 py-0.5 rounded border shrink-0 ${
|
||||
atRiskCount > 0
|
||||
? 'text-amber-400 bg-amber-500/10 border-amber-500/20'
|
||||
: 'text-emerald-400 bg-emerald-500/10 border-emerald-500/20'
|
||||
}`}>
|
||||
{atRiskCount > 0 ? 'Requires PM Action' : 'Nominal'}
|
||||
</span>
|
||||
</div>
|
||||
<p className="text-2xl font-bold text-amber-400 tracking-tight">{atRiskCount}</p>
|
||||
<p className="text-[10px] text-rose-400 font-mono mt-1">Requires PM action</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Card 8: Beneficiaries Reached */}
|
||||
<div className="bg-[#0f1523]/80 p-5 rounded-2xl border border-slate-800/80 hover:border-purple-500/40 transition-all flex flex-col justify-between shadow-md group backdrop-blur-sm relative overflow-hidden">
|
||||
<div className="flex items-start justify-between gap-3">
|
||||
<div className="flex items-center gap-1.5 min-w-0 flex-1">
|
||||
<span className="text-xs font-semibold uppercase tracking-wider text-slate-400 truncate">
|
||||
Beneficiaries
|
||||
</span>
|
||||
{/* Card 6: Beneficiaries Reached */}
|
||||
<div className="bg-[#0f1523]/80 p-5 rounded-2xl border border-slate-800/80 hover:border-indigo-500/30 transition-all flex flex-col justify-between shadow-md group backdrop-blur-sm">
|
||||
<div className="flex items-center justify-between text-slate-400">
|
||||
<div className="flex items-center gap-1.5">
|
||||
<span className="text-[11px] font-semibold uppercase tracking-wider text-slate-400">Beneficiaries</span>
|
||||
<HelpTooltip
|
||||
title="Beneficiaries Impacted"
|
||||
content="Cumulative count of direct beneficiaries reached through projects, verified against national M&E indicator targets."
|
||||
@@ -879,22 +615,15 @@ export const ExecutiveDashboard: React.FC = () => {
|
||||
placement="top"
|
||||
/>
|
||||
</div>
|
||||
<div className="shrink-0 p-2 rounded-xl bg-purple-500/10 text-purple-400 border border-purple-500/20 group-hover:scale-105 transition-transform">
|
||||
<Users className="w-4 h-4" />
|
||||
<div className="p-1.5 rounded-lg bg-indigo-500/10 text-indigo-400 border border-indigo-500/20 group-hover:scale-110 transition">
|
||||
<Users className="w-3.5 h-3.5" />
|
||||
</div>
|
||||
</div>
|
||||
<div className="mt-3">
|
||||
<p className="text-2xl sm:text-3xl font-bold text-white tracking-tight">
|
||||
{beneficiariesReached >= 1000 ? `${(beneficiariesReached / 1000).toFixed(1)}k` : beneficiariesReached}
|
||||
<p className="text-2xl font-bold text-white tracking-tight">
|
||||
{beneficiariesReached >= 1000 ? `${(beneficiariesReached / 1000).toFixed(0)}k` : beneficiariesReached}
|
||||
</p>
|
||||
<div className="flex items-center justify-between mt-3 pt-2.5 border-t border-slate-800/70 text-xs">
|
||||
<span className="text-slate-400 text-[11px]">Directly impacted</span>
|
||||
<span className="text-[10px] font-medium text-purple-400 bg-purple-500/10 px-1.5 py-0.5 rounded border border-purple-500/20 shrink-0">
|
||||
M&E Verified
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<p className="text-[10px] text-slate-400 font-mono mt-1">Directly impacted</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -1034,7 +763,7 @@ export const ExecutiveDashboard: React.FC = () => {
|
||||
<div className="p-5 border-b border-slate-800/80 flex flex-col sm:flex-row sm:items-center justify-between gap-2 bg-[#0d121f]/90">
|
||||
<div>
|
||||
<div className="flex items-center gap-2">
|
||||
<h3 className="text-base font-semibold text-white">Projects Flagged At Risk & Noncompliant</h3>
|
||||
<h3 className="text-base font-semibold text-white">Projects Requiring Attention</h3>
|
||||
<HelpTooltip
|
||||
title="Exception Escalations"
|
||||
content="Identifies projects breaching risk thresholds, schedule deadlines, or financial variances (>10%) requiring executive steering committee action."
|
||||
@@ -1067,14 +796,11 @@ export const ExecutiveDashboard: React.FC = () => {
|
||||
<SortableTh column="status" currentColumn={projectSortCol} direction={projectSortDir} onSort={handleProjectSort} className="py-3.5 px-4">
|
||||
Status
|
||||
</SortableTh>
|
||||
<SortableTh column="complianceStatus" currentColumn={projectSortCol} direction={projectSortDir} onSort={handleProjectSort} className="py-3.5 px-4">
|
||||
Compliance
|
||||
</SortableTh>
|
||||
<SortableTh column="risk" currentColumn={projectSortCol} direction={projectSortDir} onSort={handleProjectSort} align="center" className="py-3.5 px-4 text-center">
|
||||
Risk
|
||||
</SortableTh>
|
||||
<SortableTh column="budgetAtRisk" currentColumn={projectSortCol} direction={projectSortDir} onSort={handleProjectSort} align="right" className="py-3.5 px-4 text-right">
|
||||
Budget At Risk
|
||||
<SortableTh column="financialVariance" currentColumn={projectSortCol} direction={projectSortDir} onSort={handleProjectSort} align="right" className="py-3.5 px-4 text-right">
|
||||
Financial Variance
|
||||
</SortableTh>
|
||||
<th className="py-3.5 px-5 text-right font-medium">Action</th>
|
||||
</tr>
|
||||
@@ -1082,7 +808,7 @@ export const ExecutiveDashboard: React.FC = () => {
|
||||
<tbody className="divide-y divide-slate-800/60">
|
||||
{sortedAttentionProjects.length === 0 ? (
|
||||
<tr>
|
||||
<td colSpan={7} className="py-8 text-center text-xs text-slate-500">
|
||||
<td colSpan={6} className="py-8 text-center text-xs text-slate-500">
|
||||
No active projects currently flagged for executive attention. All projects are operating within target thresholds.
|
||||
</td>
|
||||
</tr>
|
||||
@@ -1107,19 +833,6 @@ export const ExecutiveDashboard: React.FC = () => {
|
||||
{p.status}
|
||||
</span>
|
||||
</td>
|
||||
<td className="py-3.5 px-4">
|
||||
{p.isCompliant ? (
|
||||
<span className="inline-flex items-center gap-1 px-2.5 py-0.5 rounded-full text-[10px] font-semibold bg-emerald-500/10 text-emerald-400 border border-emerald-500/30">
|
||||
<CheckCircle className="w-3 h-3 text-emerald-400" />
|
||||
<span>Compliant</span>
|
||||
</span>
|
||||
) : (
|
||||
<span className="inline-flex items-center gap-1 px-2.5 py-0.5 rounded-full text-[10px] font-semibold bg-rose-500/10 text-rose-400 border border-rose-500/30">
|
||||
<AlertTriangle className="w-3 h-3 text-rose-400" />
|
||||
<span>Noncompliant ({p.unmetCount} Unmet)</span>
|
||||
</span>
|
||||
)}
|
||||
</td>
|
||||
<td className="py-3.5 px-4 text-center">
|
||||
<span
|
||||
className={`inline-flex items-center justify-center font-bold px-2.5 py-0.5 rounded-md text-[10px] shadow-xs ${
|
||||
@@ -1133,9 +846,13 @@ export const ExecutiveDashboard: React.FC = () => {
|
||||
{p.risk}
|
||||
</span>
|
||||
</td>
|
||||
<td className="py-3.5 px-4 text-right font-mono font-bold text-xs">
|
||||
<span className={p.budgetAtRisk > 0 ? 'text-rose-400' : 'text-emerald-400'}>
|
||||
{formatZAR(p.budgetAtRisk)}
|
||||
<td className="py-3.5 px-4 text-right">
|
||||
<span
|
||||
className={`font-mono font-bold text-xs ${
|
||||
p.varianceType === 'negative' ? 'text-rose-400' : 'text-emerald-400'
|
||||
}`}
|
||||
>
|
||||
{p.financialVariance}
|
||||
</span>
|
||||
</td>
|
||||
<td className="py-3.5 px-5 text-right">
|
||||
|
||||
@@ -21,12 +21,6 @@ import {
|
||||
FileText,
|
||||
Scale,
|
||||
Calendar,
|
||||
UserPlus,
|
||||
ChevronDown,
|
||||
ChevronUp,
|
||||
CheckCircle2,
|
||||
Loader2,
|
||||
Lock,
|
||||
} from 'lucide-react';
|
||||
import {
|
||||
getOrganisations,
|
||||
@@ -56,11 +50,6 @@ import {
|
||||
getProgrammeValue,
|
||||
getUsers,
|
||||
getUser,
|
||||
createUser,
|
||||
assignUserRole,
|
||||
getSystemRoles,
|
||||
getCurrentUserTenantId,
|
||||
notifyNewUserCreated,
|
||||
} from '../services/api';
|
||||
import { Modal } from '../components/ui/Modal';
|
||||
import { HelpTooltip } from '../components/ui/HelpTooltip';
|
||||
@@ -68,7 +57,6 @@ import { useToast } from '../components/ui/Toast';
|
||||
import { ExportButton } from '../components/ui/ExportButton';
|
||||
import { ExportColumn } from '../utils/exportUtils';
|
||||
import { useAuth } from '../auth/AuthProvider';
|
||||
import { useRbac } from '../auth/rbac';
|
||||
import { useTableSort, SortableTh } from '../utils/tableSort';
|
||||
import { FundingInstrumentsSection, InstructionsSection } from '../components/governance/InstructionsSection';
|
||||
import { RulesSection } from '../components/governance/RulesSection';
|
||||
@@ -83,8 +71,6 @@ import {
|
||||
OrganisationType,
|
||||
ActivityStatus,
|
||||
ApprovalStatus,
|
||||
Role,
|
||||
CreateUserRequest,
|
||||
CreateOrganisationRequest,
|
||||
CreatePortfolioRequest,
|
||||
CreateProgrammeRequest,
|
||||
@@ -109,7 +95,8 @@ const organisationSchema = z.object({
|
||||
name: z.string().min(3, 'Name must be at least 3 characters'),
|
||||
registrationNo: z.string().min(4, 'Registration number is mandatory'),
|
||||
email: z.string().email('Enter a valid email address'),
|
||||
organisationUser: z.string().min(1, 'Please select a linked executive or manager user'),
|
||||
idNumber: z.string().min(4, 'Government issued ID number is required'),
|
||||
organisationUser: z.string().min(1, 'Please select a linked user'),
|
||||
type: z.coerce.number().min(1).max(27),
|
||||
status: z.coerce.number().optional(),
|
||||
});
|
||||
@@ -135,7 +122,6 @@ 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 currentUserId = sub || user?.id || '';
|
||||
const { success, error: toastError } = useToast();
|
||||
|
||||
@@ -244,126 +230,6 @@ export const GovernancePage: React.FC = () => {
|
||||
staleTime: 5 * 60 * 1000,
|
||||
});
|
||||
|
||||
const { data: systemRoles = [] } = useQuery<Role[]>({
|
||||
queryKey: ['system-roles'],
|
||||
queryFn: getSystemRoles,
|
||||
enabled: isOrgModalOpen,
|
||||
});
|
||||
|
||||
const assignableRolesForOrg = React.useMemo(() => {
|
||||
return systemRoles.filter(
|
||||
(r) => (r.name || r.Name || '').toLowerCase() !== 'admin' && (r.id || r.Id) !== '1'
|
||||
);
|
||||
}, [systemRoles]);
|
||||
|
||||
const executiveAndManagerUsers = React.useMemo(() => {
|
||||
return usersDirectory.filter((u) => {
|
||||
const role = String(u.role || '').toLowerCase();
|
||||
return role === 'executive' || role === 'manager';
|
||||
});
|
||||
}, [usersDirectory]);
|
||||
|
||||
// Quick Add Executive User Subform state
|
||||
const [isQuickAddUserOpen, setIsQuickAddUserOpen] = useState(false);
|
||||
const [quickUserEmail, setQuickUserEmail] = useState('');
|
||||
const [quickUserName, setQuickUserName] = useState('');
|
||||
const [quickUserPhone, setQuickUserPhone] = useState('');
|
||||
const [quickUserIdNumber, setQuickUserIdNumber] = useState('');
|
||||
const [quickUserRoleId, setQuickUserRoleId] = useState('');
|
||||
const [isSubmittingQuickUser, setIsSubmittingQuickUser] = useState(false);
|
||||
const [quickUserError, setQuickUserError] = useState<string | null>(null);
|
||||
|
||||
React.useEffect(() => {
|
||||
if (!quickUserRoleId && assignableRolesForOrg.length > 0) {
|
||||
const execRole = assignableRolesForOrg.find((r) => (r.name || r.Name || '').toLowerCase() === 'executive');
|
||||
if (execRole) setQuickUserRoleId(execRole.id || execRole.Id || '');
|
||||
else setQuickUserRoleId(assignableRolesForOrg[0].id || assignableRolesForOrg[0].Id || '');
|
||||
}
|
||||
}, [assignableRolesForOrg, quickUserRoleId]);
|
||||
|
||||
const handleQuickAddUser = async () => {
|
||||
setQuickUserError(null);
|
||||
if (!quickUserEmail || !quickUserEmail.includes('@')) {
|
||||
setQuickUserError('Please provide a valid email address.');
|
||||
return;
|
||||
}
|
||||
if (!quickUserIdNumber || quickUserIdNumber.trim().length < 4) {
|
||||
setQuickUserError('Government issued ID number is required (min 4 characters).');
|
||||
return;
|
||||
}
|
||||
|
||||
setIsSubmittingQuickUser(true);
|
||||
try {
|
||||
const tenantId = getCurrentUserTenantId() || 1;
|
||||
const rawEmail = quickUserEmail.trim().toLowerCase();
|
||||
const sanitizedUserName = quickUserName.trim()
|
||||
? quickUserName.trim().toLowerCase().replace(/\s+/g, '.').replace(/[^a-z0-9_.\-@+]/g, '')
|
||||
: rawEmail.split('@')[0];
|
||||
|
||||
const payload: CreateUserRequest = {
|
||||
email: rawEmail,
|
||||
userName: sanitizedUserName || rawEmail,
|
||||
emailConfirmed: true,
|
||||
phoneNumber: quickUserPhone.trim() || null,
|
||||
phoneNumberConfirmed: false,
|
||||
LockoutEnabled: false,
|
||||
twoFactorEnabled: false,
|
||||
accessFailedCount: 0,
|
||||
};
|
||||
|
||||
await createUser(tenantId, quickUserIdNumber.trim(), payload);
|
||||
|
||||
// Invalidate queries and fetch fresh user list immediately
|
||||
await queryClient.invalidateQueries({ queryKey: ['users-directory'] });
|
||||
await queryClient.invalidateQueries({ queryKey: ['users'] });
|
||||
const freshUsers = await getUsers(1, 200);
|
||||
|
||||
const createdUser = freshUsers.find(
|
||||
(u) => u.email && u.email.toLowerCase() === quickUserEmail.trim().toLowerCase()
|
||||
);
|
||||
|
||||
if (createdUser && quickUserRoleId) {
|
||||
try {
|
||||
await assignUserRole(createdUser.id, quickUserRoleId);
|
||||
await queryClient.invalidateQueries({ queryKey: ['users-directory'] });
|
||||
await queryClient.invalidateQueries({ queryKey: ['users'] });
|
||||
} catch {
|
||||
// Handled gracefully
|
||||
}
|
||||
orgForm.setValue('organisationUser', createdUser.id, { shouldValidate: true });
|
||||
} else if (createdUser) {
|
||||
orgForm.setValue('organisationUser', createdUser.id, { shouldValidate: true });
|
||||
}
|
||||
|
||||
// RULE 3: Notify created Executive/Manager user via InApp and Email (directs to Console)
|
||||
if (createdUser) {
|
||||
await notifyNewUserCreated({
|
||||
userId: createdUser.id,
|
||||
email: quickUserEmail,
|
||||
isPartner: false,
|
||||
roleName: 'Executive',
|
||||
tenantId,
|
||||
}).catch(console.warn);
|
||||
queryClient.invalidateQueries({ queryKey: ['inapp-notifications'] });
|
||||
queryClient.invalidateQueries({ queryKey: ['notifications'] });
|
||||
}
|
||||
|
||||
success('Executive User Created', `User ${quickUserEmail} successfully created and linked.`);
|
||||
setQuickUserEmail('');
|
||||
setQuickUserName('');
|
||||
setQuickUserPhone('');
|
||||
setQuickUserIdNumber('');
|
||||
setIsQuickAddUserOpen(false);
|
||||
} catch (err: unknown) {
|
||||
const errObj = err as { response?: { data?: { detail?: string } }; message?: string };
|
||||
const errorDetail = errObj?.response?.data?.detail || errObj?.message || 'Failed to create user.';
|
||||
setQuickUserError(errorDetail);
|
||||
toastError('Failed to create user', err);
|
||||
} finally {
|
||||
setIsSubmittingQuickUser(false);
|
||||
}
|
||||
};
|
||||
|
||||
const {
|
||||
data: organisations = [],
|
||||
isLoading: loadingOrgs,
|
||||
@@ -554,11 +420,12 @@ export const GovernancePage: React.FC = () => {
|
||||
|
||||
// Forms
|
||||
const orgForm = useForm<OrganisationFormValues>({
|
||||
resolver: zodResolver(organisationSchema),
|
||||
resolver: zodResolver(organisationSchema) as any,
|
||||
defaultValues: {
|
||||
name: '',
|
||||
registrationNo: '',
|
||||
email: '',
|
||||
idNumber: '',
|
||||
organisationUser: '',
|
||||
type: OrganisationType.Enterprise,
|
||||
status: ActivityStatus.Active,
|
||||
@@ -566,7 +433,7 @@ export const GovernancePage: React.FC = () => {
|
||||
});
|
||||
|
||||
const portfolioForm = useForm<PortfolioFormValues>({
|
||||
resolver: zodResolver(portfolioSchema),
|
||||
resolver: zodResolver(portfolioSchema) as any,
|
||||
defaultValues: {
|
||||
name: '',
|
||||
description: '',
|
||||
@@ -576,7 +443,7 @@ export const GovernancePage: React.FC = () => {
|
||||
});
|
||||
|
||||
const programmeForm = useForm<ProgrammeFormValues>({
|
||||
resolver: zodResolver(programmeSchema),
|
||||
resolver: zodResolver(programmeSchema) as any,
|
||||
defaultValues: {
|
||||
name: '',
|
||||
portfolioId: portfolios[0]?.id ? Number(portfolios[0].id) : 201,
|
||||
@@ -594,6 +461,7 @@ export const GovernancePage: React.FC = () => {
|
||||
name: data.name,
|
||||
registrationNo: data.registrationNo,
|
||||
email: data.email,
|
||||
idNumber: data.idNumber,
|
||||
organisationUser: data.organisationUser,
|
||||
type: Number(data.type),
|
||||
status: data.status !== undefined ? Number(data.status) : ActivityStatus.Active,
|
||||
@@ -603,6 +471,7 @@ export const GovernancePage: React.FC = () => {
|
||||
name: data.name,
|
||||
registrationNo: data.registrationNo,
|
||||
email: data.email,
|
||||
idNumber: data.idNumber,
|
||||
organisationUser: data.organisationUser,
|
||||
type: Number(data.type),
|
||||
});
|
||||
@@ -658,7 +527,7 @@ export const GovernancePage: React.FC = () => {
|
||||
programmeId: editingProgramme.id,
|
||||
name: data.name,
|
||||
portfolioId: Number(data.portfolioId),
|
||||
status: (data.status !== undefined ? Number(data.status) : 1) as ApprovalStatus,
|
||||
status: data.status !== undefined ? Number(data.status) : undefined,
|
||||
ownerId: resolvedOwnerId,
|
||||
});
|
||||
}
|
||||
@@ -682,49 +551,72 @@ export const GovernancePage: React.FC = () => {
|
||||
|
||||
const openCreateOrg = () => {
|
||||
setEditingOrg(null);
|
||||
const defaultUser = executiveAndManagerUsers[0];
|
||||
const defaultUserId = usersDirectory[0]?.id || sub || currentUserId || '';
|
||||
const defaultUser = usersDirectory.find((u) => u.id === defaultUserId);
|
||||
orgForm.reset({
|
||||
name: '',
|
||||
registrationNo: '',
|
||||
email: '',
|
||||
organisationUser: defaultUser?.id || '',
|
||||
idNumber: defaultUser?.idNumber || '',
|
||||
organisationUser: defaultUserId,
|
||||
type: OrganisationType.Enterprise,
|
||||
status: ActivityStatus.Active,
|
||||
});
|
||||
setIsQuickAddUserOpen(false);
|
||||
setQuickUserError(null);
|
||||
setIsOrgModalOpen(true);
|
||||
};
|
||||
|
||||
const openEditOrg = (org: Organisation) => {
|
||||
setEditingOrg(org);
|
||||
|
||||
// 1. Resolve target linked user ID synchronously from the organisation model
|
||||
const targetUserId =
|
||||
org.userId ||
|
||||
org.UserId ||
|
||||
org.organisationUser ||
|
||||
org.OrganisationUser ||
|
||||
(org as any).UserId ||
|
||||
(org as any).organisationUser ||
|
||||
(org as any).OrganisationUser ||
|
||||
org.createdBy ||
|
||||
'';
|
||||
|
||||
// 2. Fast O(1) in-memory match from cached users directory
|
||||
const matchedUser = targetUserId
|
||||
? usersDirectory.find((u) => u.id && String(u.id).toLowerCase() === String(targetUserId).toLowerCase())
|
||||
: null;
|
||||
|
||||
// 3. Resolve idNumber: prefer matchedUser's idNumber, or org's idNumber if any, otherwise blank ''
|
||||
const resolvedIdNumber = matchedUser?.idNumber || org.idNumber || (org as any).IdNumber || '';
|
||||
const selectedUserId = matchedUser?.id || targetUserId || '';
|
||||
|
||||
// 4. Perform assignment immediately to form state
|
||||
orgForm.reset({
|
||||
name: org.name || '',
|
||||
registrationNo: org.registrationNo || '',
|
||||
email: org.email || '',
|
||||
idNumber: resolvedIdNumber,
|
||||
organisationUser: selectedUserId,
|
||||
type: org.type || OrganisationType.Enterprise,
|
||||
status: org.status ?? ActivityStatus.Active,
|
||||
});
|
||||
|
||||
setIsQuickAddUserOpen(false);
|
||||
setQuickUserError(null);
|
||||
// 5. Instantly open modal with fully populated data (no lag, no progressive pop-in)
|
||||
setIsOrgModalOpen(true);
|
||||
|
||||
// 6. Non-blocking background sync if target user wasn't in cache or org has missing data
|
||||
if (targetUserId && !matchedUser) {
|
||||
getUser(targetUserId).then((fetchedUser) => {
|
||||
if (fetchedUser) {
|
||||
queryClient.setQueryData<User[]>(['users-directory'], (old = []) => {
|
||||
if (old.some((u) => u.id === fetchedUser.id)) return old;
|
||||
return [fetchedUser, ...old];
|
||||
});
|
||||
if (fetchedUser.idNumber && !orgForm.getValues('idNumber')) {
|
||||
orgForm.setValue('idNumber', fetchedUser.idNumber, { shouldValidate: true });
|
||||
}
|
||||
if (!orgForm.getValues('organisationUser')) {
|
||||
orgForm.setValue('organisationUser', fetchedUser.id, { shouldValidate: true });
|
||||
}
|
||||
}
|
||||
}).catch(() => {});
|
||||
}
|
||||
};
|
||||
|
||||
const openCreatePortfolio = () => {
|
||||
@@ -875,7 +767,7 @@ export const GovernancePage: React.FC = () => {
|
||||
key: 'portfolioId',
|
||||
format: (val) => {
|
||||
const port = portfolios.find((p) => String(p.id) === String(val));
|
||||
return port && port.name ? port.name : `Portfolio #${val}`;
|
||||
return port ? port.name : `Portfolio #${val}`;
|
||||
},
|
||||
},
|
||||
{ header: 'Governance Status', key: 'status', format: (val) => getApprovalStatusBadge(val).label },
|
||||
@@ -916,7 +808,7 @@ export const GovernancePage: React.FC = () => {
|
||||
|
||||
{/* Tab Actions */}
|
||||
<div className="flex items-center gap-2 relative z-10 self-start md:self-center flex-wrap">
|
||||
{activeTab === 'organisations' && canAdd('governance') && (
|
||||
{activeTab === 'organisations' && (
|
||||
<button
|
||||
onClick={openCreateOrg}
|
||||
className="inline-flex items-center gap-1.5 px-4 py-2.5 text-xs font-semibold text-white bg-gradient-to-r from-blue-600 to-indigo-600 hover:from-blue-500 hover:to-indigo-500 rounded-xl shadow-lg shadow-blue-600/25 transition cursor-pointer"
|
||||
@@ -925,7 +817,7 @@ export const GovernancePage: React.FC = () => {
|
||||
<span>Register Organisation</span>
|
||||
</button>
|
||||
)}
|
||||
{activeTab === 'portfolios' && canAdd('governance') && (
|
||||
{activeTab === 'portfolios' && (
|
||||
<button
|
||||
onClick={openCreatePortfolio}
|
||||
className="inline-flex items-center gap-1.5 px-4 py-2.5 text-xs font-semibold text-white bg-gradient-to-r from-blue-600 to-indigo-600 hover:from-blue-500 hover:to-indigo-500 rounded-xl shadow-lg shadow-blue-600/25 transition cursor-pointer"
|
||||
@@ -934,7 +826,7 @@ export const GovernancePage: React.FC = () => {
|
||||
<span>Create Portfolio</span>
|
||||
</button>
|
||||
)}
|
||||
{activeTab === 'programmes' && canAdd('governance') && (
|
||||
{activeTab === 'programmes' && (
|
||||
<button
|
||||
onClick={openCreateProgramme}
|
||||
className="inline-flex items-center gap-1.5 px-4 py-2.5 text-xs font-semibold text-white bg-gradient-to-r from-blue-600 to-indigo-600 hover:from-blue-500 hover:to-indigo-500 rounded-xl shadow-lg shadow-blue-600/25 transition cursor-pointer"
|
||||
@@ -943,7 +835,7 @@ export const GovernancePage: React.FC = () => {
|
||||
<span>Create Programme</span>
|
||||
</button>
|
||||
)}
|
||||
{(activeTab === 'instruments' || activeTab === 'instructions') && canAdd('governance') && (
|
||||
{(activeTab === 'instruments' || activeTab === 'instructions') && (
|
||||
<button
|
||||
onClick={() => setIsInstructionCreateTriggered(true)}
|
||||
className="inline-flex items-center gap-1.5 px-4 py-2.5 text-xs font-semibold text-white bg-gradient-to-r from-blue-600 to-indigo-600 hover:from-blue-500 hover:to-indigo-500 rounded-xl shadow-lg shadow-blue-600/25 transition cursor-pointer"
|
||||
@@ -952,7 +844,7 @@ export const GovernancePage: React.FC = () => {
|
||||
<span>Add Funding Instrument</span>
|
||||
</button>
|
||||
)}
|
||||
{activeTab === 'rules' && canAdd('governance') && (
|
||||
{activeTab === 'rules' && (
|
||||
<button
|
||||
onClick={() => setIsRuleCreateTriggered(true)}
|
||||
className="inline-flex items-center gap-1.5 px-4 py-2.5 text-xs font-semibold text-white bg-gradient-to-r from-blue-600 to-indigo-600 hover:from-blue-500 hover:to-indigo-500 rounded-xl shadow-lg shadow-blue-600/25 transition cursor-pointer"
|
||||
@@ -1081,7 +973,6 @@ export const GovernancePage: React.FC = () => {
|
||||
<span>Refresh Directory</span>
|
||||
</button>
|
||||
<ExportButton
|
||||
claim="governance"
|
||||
title="Organisation Master Registry"
|
||||
subtitle="Official directory of verified recipient and funder entities"
|
||||
filename={`pfm-organisations-${new Date().toISOString().split('T')[0]}`}
|
||||
@@ -1217,7 +1108,6 @@ export const GovernancePage: React.FC = () => {
|
||||
<span>Refresh Portfolios</span>
|
||||
</button>
|
||||
<ExportButton
|
||||
claim="governance"
|
||||
title="Funder Portfolios Registry"
|
||||
subtitle="High-level funding groupings and thematic capital allocations"
|
||||
filename={`pfm-portfolios-${new Date().toISOString().split('T')[0]}`}
|
||||
@@ -1267,8 +1157,8 @@ export const GovernancePage: React.FC = () => {
|
||||
|
||||
<div className="mt-4 pt-3 border-t border-slate-800/80 space-y-2.5">
|
||||
<div className="flex items-center justify-between text-xs text-slate-400">
|
||||
<span className="truncate max-w-[200px] font-medium text-slate-300" title={org && org.name ? org.name : `Org #${p.organisationId}`}>
|
||||
{org && org.name ? org.name : `Org #${p.organisationId}`}
|
||||
<span className="truncate max-w-[200px] font-medium text-slate-300" title={org ? org.name : `Org #${p.organisationId}`}>
|
||||
{org ? org.name : `Org #${p.organisationId}`}
|
||||
</span>
|
||||
<span className="font-mono text-[11px] text-slate-500 shrink-0">{formatDate(p.createdAt)}</span>
|
||||
</div>
|
||||
@@ -1349,7 +1239,6 @@ export const GovernancePage: React.FC = () => {
|
||||
<span>Refresh Programmes</span>
|
||||
</button>
|
||||
<ExportButton
|
||||
claim="governance"
|
||||
title="Strategic Programmes Hierarchy"
|
||||
subtitle="Operational funding vehicles linked to funder portfolios"
|
||||
filename={`pfm-programmes-${new Date().toISOString().split('T')[0]}`}
|
||||
@@ -1583,6 +1472,66 @@ export const GovernancePage: React.FC = () => {
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-1 sm:grid-cols-2 gap-3">
|
||||
<div>
|
||||
<div className="flex items-center gap-1 mb-1">
|
||||
<label className="block text-xs font-semibold text-slate-300">
|
||||
Linked User Account *
|
||||
</label>
|
||||
<HelpTooltip
|
||||
title="Designated Organisation User"
|
||||
content="Select the designated user account associated with this organisation profile."
|
||||
size="sm"
|
||||
placement="top"
|
||||
/>
|
||||
</div>
|
||||
<select
|
||||
{...orgForm.register('organisationUser', {
|
||||
onChange: (e) => {
|
||||
const selectedId = e.target.value;
|
||||
const selected = usersDirectory.find((u) => u.id === selectedId);
|
||||
// Populate selected user's ID number or blank '' if deselected or missing
|
||||
orgForm.setValue('idNumber', selected?.idNumber || '', { shouldValidate: true });
|
||||
},
|
||||
})}
|
||||
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 user account --</option>
|
||||
{usersDirectory.map((u) => (
|
||||
<option key={u.id} value={u.id}>
|
||||
{u.email || u.id} {u.role ? `(${u.role})` : ''} {u.idNumber ? `• ID: ${u.idNumber}` : ''}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
{orgForm.formState.errors.organisationUser && (
|
||||
<p className="text-[10px] text-rose-500 mt-1">{orgForm.formState.errors.organisationUser.message}</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<div className="flex items-center gap-1 mb-1">
|
||||
<label className="block text-xs font-semibold text-slate-300">
|
||||
Government Issued ID Number *
|
||||
</label>
|
||||
<HelpTooltip
|
||||
title="Government Issued ID"
|
||||
content="National identification document number or statutory passport ID of the linked user."
|
||||
size="sm"
|
||||
placement="top"
|
||||
/>
|
||||
</div>
|
||||
<input
|
||||
type="text"
|
||||
{...orgForm.register('idNumber')}
|
||||
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 font-mono text-white bg-[#090d16]"
|
||||
placeholder="e.g. 8401015800085 or Passport Number"
|
||||
/>
|
||||
{orgForm.formState.errors.idNumber && (
|
||||
<p className="text-[10px] text-rose-500 mt-1">{orgForm.formState.errors.idNumber.message}</p>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-1 sm:grid-cols-2 gap-3">
|
||||
<div>
|
||||
<div className="flex items-center gap-1 mb-1">
|
||||
@@ -1607,165 +1556,6 @@ export const GovernancePage: React.FC = () => {
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<div className="flex items-center justify-between mb-1">
|
||||
<div className="flex items-center gap-1">
|
||||
<label className="block text-xs font-semibold text-slate-300">
|
||||
Linked Executive / Manager User *
|
||||
</label>
|
||||
<HelpTooltip
|
||||
title="Designated Organisation User"
|
||||
content="Only users with an Executive or Manager role can be assigned to an organisation."
|
||||
size="sm"
|
||||
placement="top"
|
||||
/>
|
||||
</div>
|
||||
{!editingOrg && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => {
|
||||
setIsQuickAddUserOpen((prev) => !prev);
|
||||
setQuickUserError(null);
|
||||
}}
|
||||
className="text-[11px] font-semibold text-blue-400 hover:text-blue-300 inline-flex items-center gap-1 cursor-pointer transition-colors"
|
||||
>
|
||||
<UserPlus className="w-3.5 h-3.5" />
|
||||
{isQuickAddUserOpen ? 'Cancel' : '+ New Executive'}
|
||||
{isQuickAddUserOpen ? <ChevronUp className="w-3 h-3" /> : <ChevronDown className="w-3 h-3" />}
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
<select
|
||||
{...orgForm.register('organisationUser')}
|
||||
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) => (
|
||||
<option key={u.id} value={u.id}>
|
||||
{u.email || u.id} ({u.role || 'Executive'})
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
{orgForm.formState.errors.organisationUser && (
|
||||
<p className="text-[10px] text-rose-500 mt-1">{orgForm.formState.errors.organisationUser.message}</p>
|
||||
)}
|
||||
{executiveAndManagerUsers.length === 0 && !isQuickAddUserOpen && (
|
||||
<p className="text-[11px] text-amber-400 mt-1">
|
||||
No Executive or Manager user found. Create one below to continue.
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Inline Collapsible Subform to Create Executive User */}
|
||||
{isQuickAddUserOpen && !editingOrg && (
|
||||
<div className="p-3.5 rounded-xl border border-blue-500/30 bg-blue-950/20 space-y-3">
|
||||
<div className="flex items-center justify-between border-b border-blue-500/20 pb-2">
|
||||
<span className="text-xs font-semibold text-blue-300 flex items-center gap-1.5">
|
||||
<UserPlus className="w-4 h-4 text-blue-400" />
|
||||
Quick Create Executive / Manager User
|
||||
</span>
|
||||
<span className="text-[10px] text-slate-400">Mapped to tenant automatically</span>
|
||||
</div>
|
||||
|
||||
{quickUserError && (
|
||||
<div className="p-2 rounded bg-rose-500/10 border border-rose-500/20 text-rose-400 text-[11px]">
|
||||
{quickUserError}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="grid grid-cols-1 sm:grid-cols-2 gap-2.5">
|
||||
<div>
|
||||
<label className="block text-[11px] font-medium text-slate-300 mb-1">Email Address *</label>
|
||||
<input
|
||||
type="email"
|
||||
value={quickUserEmail}
|
||||
onChange={(e) => setQuickUserEmail(e.target.value)}
|
||||
placeholder="executive@organisation.org.za"
|
||||
className="w-full px-2.5 py-1.5 text-xs border border-slate-700/80 rounded-lg bg-[#090d16] text-white focus:outline-none focus:ring-1 focus:ring-blue-500"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="block text-[11px] font-medium text-slate-300 mb-1">Full Name / Username</label>
|
||||
<input
|
||||
type="text"
|
||||
value={quickUserName}
|
||||
onChange={(e) => setQuickUserName(e.target.value)}
|
||||
placeholder="e.g. Tony Ngcobo"
|
||||
className="w-full px-2.5 py-1.5 text-xs border border-slate-700/80 rounded-lg bg-[#090d16] text-white focus:outline-none focus:ring-1 focus:ring-blue-500"
|
||||
/>
|
||||
<p className="text-[10px] text-slate-500 mt-0.5">Will be normalized to valid login username (e.g. tony.ngcobo)</p>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="block text-[11px] font-medium text-slate-300 mb-1">Phone Number</label>
|
||||
<input
|
||||
type="text"
|
||||
value={quickUserPhone}
|
||||
onChange={(e) => setQuickUserPhone(e.target.value)}
|
||||
placeholder="+27 82 123 4567"
|
||||
className="w-full px-2.5 py-1.5 text-xs border border-slate-700/80 rounded-lg bg-[#090d16] text-white focus:outline-none focus:ring-1 focus:ring-blue-500"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="block text-[11px] font-medium text-slate-300 mb-1">Government Issued ID *</label>
|
||||
<input
|
||||
type="text"
|
||||
value={quickUserIdNumber}
|
||||
onChange={(e) => setQuickUserIdNumber(e.target.value)}
|
||||
placeholder="13-digit ID or Passport"
|
||||
className="w-full px-2.5 py-1.5 text-xs border border-slate-700/80 rounded-lg bg-[#090d16] text-white font-mono focus:outline-none focus:ring-1 focus:ring-blue-500"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="sm:col-span-2">
|
||||
<label className="block text-[11px] font-medium text-slate-300 mb-1">System Role *</label>
|
||||
<select
|
||||
value={quickUserRoleId}
|
||||
onChange={(e) => setQuickUserRoleId(e.target.value)}
|
||||
className="w-full px-2.5 py-1.5 text-xs border border-slate-700/80 rounded-lg bg-[#090d16] text-white focus:outline-none focus:ring-1 focus:ring-blue-500"
|
||||
>
|
||||
{assignableRolesForOrg.map((r) => (
|
||||
<option key={r.id} value={r.id}>
|
||||
{r.name}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex justify-end gap-2 pt-1">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setIsQuickAddUserOpen(false)}
|
||||
className="px-2.5 py-1 text-xs text-slate-400 hover:text-white rounded-lg transition-colors cursor-pointer"
|
||||
>
|
||||
Cancel
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={handleQuickAddUser}
|
||||
disabled={isSubmittingQuickUser}
|
||||
className="px-3 py-1.5 text-xs font-semibold text-white bg-blue-600 hover:bg-blue-500 rounded-lg transition-colors cursor-pointer flex items-center gap-1.5 disabled:opacity-50"
|
||||
>
|
||||
{isSubmittingQuickUser ? (
|
||||
<>
|
||||
<Loader2 className="w-3.5 h-3.5 animate-spin" />
|
||||
Creating...
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<CheckCircle2 className="w-3.5 h-3.5" />
|
||||
Create & Select User
|
||||
</>
|
||||
)}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{editingOrg && (
|
||||
<div>
|
||||
<div className="flex items-center gap-1 mb-1">
|
||||
@@ -1791,6 +1581,7 @@ export const GovernancePage: React.FC = () => {
|
||||
</select>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="pt-3 border-t border-slate-800/80 flex justify-end gap-2">
|
||||
<button
|
||||
@@ -2004,43 +1795,16 @@ export const GovernancePage: React.FC = () => {
|
||||
placement="top"
|
||||
/>
|
||||
</div>
|
||||
{selectedProgrammeForDetail && Number(selectedProgrammeForDetail.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."
|
||||
>
|
||||
<Lock className="w-3.5 h-3.5 text-emerald-400" />
|
||||
<span>Approved (Immutable - Cannot Be Changed)</span>
|
||||
</div>
|
||||
) : (
|
||||
(() => {
|
||||
const canApproveProgramme = canApproveRecord('governance', selectedProgrammeForDetail?.createdBy);
|
||||
const isSelf = isMaker(selectedProgrammeForDetail?.createdBy);
|
||||
|
||||
return (
|
||||
<select
|
||||
{...programmeForm.register('status')}
|
||||
className="w-full px-3 py-2 text-xs border border-slate-700/80 rounded-xl focus:outline-none focus:ring-1 focus:ring-blue-500 bg-[#090d16] text-white font-medium"
|
||||
>
|
||||
{APPROVAL_STATUS_OPTIONS.map((opt) => {
|
||||
const isApprovedOpt = opt.value === ApprovalStatus.Approved;
|
||||
const isOptDisabled = isApprovedOpt && !canApproveProgramme;
|
||||
let reason = '';
|
||||
if (isApprovedOpt && !canApprove('governance')) {
|
||||
reason = ' (Requires Update Permission)';
|
||||
} else if (isApprovedOpt && isSelf) {
|
||||
reason = ' (Maker-Checker: Cannot approve own record)';
|
||||
}
|
||||
return (
|
||||
<option key={opt.value} value={opt.value} disabled={isOptDisabled}>
|
||||
{opt.label}{reason}
|
||||
{APPROVAL_STATUS_OPTIONS.map((opt) => (
|
||||
<option key={opt.value} value={opt.value}>
|
||||
{opt.label}
|
||||
</option>
|
||||
);
|
||||
})}
|
||||
))}
|
||||
</select>
|
||||
);
|
||||
})()
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div>
|
||||
|
||||
@@ -25,7 +25,6 @@ import {
|
||||
getAwards,
|
||||
getAward,
|
||||
createAward,
|
||||
updateAward,
|
||||
getBudgets,
|
||||
getBudget,
|
||||
createBudget,
|
||||
@@ -46,14 +45,12 @@ import { useToast } from '../components/ui/Toast';
|
||||
import { ExportButton } from '../components/ui/ExportButton';
|
||||
import { ExportColumn } from '../utils/exportUtils';
|
||||
import { useTableSort, SortableTh } from '../utils/tableSort';
|
||||
import { useRbac } from '../auth/rbac';
|
||||
import {
|
||||
Award,
|
||||
Budget,
|
||||
Disbursement,
|
||||
ApprovalStatus,
|
||||
CreateAwardRequest,
|
||||
UpdateAwardRequest,
|
||||
CreateBudgetRequest,
|
||||
UpdateBudgetRequest,
|
||||
CreateDisbursementRequest,
|
||||
@@ -79,15 +76,6 @@ const awardSchema = z.object({
|
||||
programmeId: z.coerce.number().min(1, 'Select funding programme'),
|
||||
});
|
||||
|
||||
const editAwardSchema = z.object({
|
||||
organisationId: z.coerce.number().min(1, 'Recipient organisation is required'),
|
||||
programmeId: z.coerce.number().min(1, 'Funding programme is required'),
|
||||
amount: z
|
||||
.coerce
|
||||
.number()
|
||||
.positive('Approved funding amount must be strictly greater than zero (no negative or zero values)'),
|
||||
});
|
||||
|
||||
const budgetSchema = z.object({
|
||||
projectId: z.coerce.number().min(1, 'Select project'),
|
||||
name: z.string().min(3, 'Budget category name required'),
|
||||
@@ -104,22 +92,17 @@ const disbursementSchema = z.object({
|
||||
});
|
||||
|
||||
type AwardFormValues = z.infer<typeof awardSchema>;
|
||||
type EditAwardFormValues = z.infer<typeof editAwardSchema>;
|
||||
type BudgetFormValues = z.infer<typeof budgetSchema>;
|
||||
type DisbursementFormValues = z.infer<typeof disbursementSchema>;
|
||||
|
||||
export const GrantsPage: React.FC = () => {
|
||||
const queryClient = useQueryClient();
|
||||
const { success, error: toastError } = useToast();
|
||||
const { canAdd, canApprove, canExport, canApproveRecord, isMaker } = useRbac();
|
||||
|
||||
const [activeTab, setActiveTab] = useState<'awards' | 'budgets' | 'disbursements'>('awards');
|
||||
|
||||
// Modals
|
||||
const [isAwardModalOpen, setIsAwardModalOpen] = useState(false);
|
||||
const [editingAward, setEditingAward] = useState<Award | null>(null);
|
||||
const [isEditAwardModalOpen, setIsEditAwardModalOpen] = useState(false);
|
||||
const [selectedAwardStatus, setSelectedAwardStatus] = useState<ApprovalStatus>(ApprovalStatus.Pending);
|
||||
const [isBudgetModalOpen, setIsBudgetModalOpen] = useState(false);
|
||||
const [editingBudget, setEditingBudget] = useState<Budget | null>(null);
|
||||
const [isEditBudgetModalOpen, setIsEditBudgetModalOpen] = useState(false);
|
||||
@@ -180,7 +163,7 @@ export const GrantsPage: React.FC = () => {
|
||||
|
||||
// Forms
|
||||
const awardForm = useForm<AwardFormValues>({
|
||||
resolver: zodResolver(awardSchema),
|
||||
resolver: zodResolver(awardSchema) as any,
|
||||
defaultValues: {
|
||||
referenceNo: '',
|
||||
projectId: projects[0]?.id ? Number(projects[0].id) : 401,
|
||||
@@ -191,12 +174,8 @@ export const GrantsPage: React.FC = () => {
|
||||
},
|
||||
});
|
||||
|
||||
const editAwardForm = useForm<EditAwardFormValues>({
|
||||
resolver: zodResolver(editAwardSchema),
|
||||
});
|
||||
|
||||
const budgetForm = useForm<BudgetFormValues>({
|
||||
resolver: zodResolver(budgetSchema),
|
||||
resolver: zodResolver(budgetSchema) as any,
|
||||
defaultValues: {
|
||||
projectId: projects[0]?.id ? Number(projects[0].id) : 401,
|
||||
name: '',
|
||||
@@ -205,7 +184,7 @@ export const GrantsPage: React.FC = () => {
|
||||
});
|
||||
|
||||
const disbursementForm = useForm<DisbursementFormValues>({
|
||||
resolver: zodResolver(disbursementSchema),
|
||||
resolver: zodResolver(disbursementSchema) as any,
|
||||
defaultValues: {
|
||||
projectId: projects[0]?.id ? Number(projects[0].id) : 401,
|
||||
amount: 2500000,
|
||||
@@ -230,21 +209,6 @@ export const GrantsPage: React.FC = () => {
|
||||
},
|
||||
});
|
||||
|
||||
const updateAwardMutation = useMutation({
|
||||
mutationFn: (data: UpdateAwardRequest) => updateAward(data),
|
||||
onSuccess: () => {
|
||||
queryClient.invalidateQueries({ queryKey: ['awards'] });
|
||||
queryClient.invalidateQueries({ queryKey: ['dashboard'] });
|
||||
success('Award Updated', 'Award details saved successfully.');
|
||||
setIsEditAwardModalOpen(false);
|
||||
setEditingAward(null);
|
||||
editAwardForm.reset();
|
||||
},
|
||||
onError: (err: unknown) => {
|
||||
toastError('Award update failed', err);
|
||||
},
|
||||
});
|
||||
|
||||
const budgetMutation = useMutation({
|
||||
mutationFn: (data: CreateBudgetRequest) => createBudget(data),
|
||||
onSuccess: () => {
|
||||
@@ -702,7 +666,7 @@ export const GrantsPage: React.FC = () => {
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-2 relative z-10 self-start md:self-center">
|
||||
{activeTab === 'awards' && canAdd('grant_financials') && (
|
||||
{activeTab === 'awards' && (
|
||||
<button
|
||||
onClick={openCreateAward}
|
||||
className="inline-flex items-center gap-1.5 px-4 py-2.5 text-xs font-semibold text-white bg-gradient-to-r from-emerald-600 to-teal-600 hover:from-emerald-500 hover:to-teal-500 rounded-xl shadow-lg shadow-emerald-600/25 transition cursor-pointer"
|
||||
@@ -711,7 +675,7 @@ export const GrantsPage: React.FC = () => {
|
||||
<span>Register Approved Award</span>
|
||||
</button>
|
||||
)}
|
||||
{activeTab === 'budgets' && canAdd('grant_financials') && (
|
||||
{activeTab === 'budgets' && (
|
||||
<button
|
||||
onClick={() => setIsBudgetModalOpen(true)}
|
||||
className="inline-flex items-center gap-1.5 px-4 py-2.5 text-xs font-semibold text-white bg-gradient-to-r from-emerald-600 to-teal-600 hover:from-emerald-500 hover:to-teal-500 rounded-xl shadow-lg shadow-emerald-600/25 transition cursor-pointer"
|
||||
@@ -720,7 +684,7 @@ export const GrantsPage: React.FC = () => {
|
||||
<span>Add Budget Line</span>
|
||||
</button>
|
||||
)}
|
||||
{activeTab === 'disbursements' && canAdd('grant_financials') && (
|
||||
{activeTab === 'disbursements' && (
|
||||
<button
|
||||
onClick={openCreateDisbursement}
|
||||
className="inline-flex items-center gap-1.5 px-4 py-2.5 text-xs font-semibold text-white bg-gradient-to-r from-emerald-600 to-teal-600 hover:from-emerald-500 hover:to-teal-500 rounded-xl shadow-lg shadow-emerald-600/25 transition cursor-pointer"
|
||||
@@ -838,7 +802,6 @@ export const GrantsPage: React.FC = () => {
|
||||
<span>Refresh Awards</span>
|
||||
</button>
|
||||
<ExportButton
|
||||
claim="grant_financials"
|
||||
title="Grant Awards Registry"
|
||||
subtitle="Official directory of approved beneficiary funding allocations"
|
||||
filename={`pfm-awards-${new Date().toISOString().split('T')[0]}`}
|
||||
@@ -871,21 +834,18 @@ export const GrantsPage: React.FC = () => {
|
||||
<SortableTh column="createdAt" currentColumn={awardSortCol} direction={awardSortDir} onSort={handleAwardSort} align="right" className="py-3.5 px-5 text-right">
|
||||
Date Registered
|
||||
</SortableTh>
|
||||
<th className="py-3.5 px-5 text-right font-semibold text-xs text-slate-400 uppercase tracking-wider">
|
||||
Actions
|
||||
</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody className="divide-y divide-slate-800/50">
|
||||
{loadingAwards ? (
|
||||
<tr>
|
||||
<td colSpan={7} className="py-8 text-center text-slate-500">
|
||||
<td colSpan={6} className="py-8 text-center text-slate-500">
|
||||
Loading registered awards...
|
||||
</td>
|
||||
</tr>
|
||||
) : sortedAwards.length === 0 ? (
|
||||
<tr>
|
||||
<td colSpan={7} className="py-8 text-center text-slate-500">
|
||||
<td colSpan={6} className="py-8 text-center text-slate-500">
|
||||
{searchTerm ? 'No awards match your search query.' : 'No registered awards found.'}
|
||||
</td>
|
||||
</tr>
|
||||
@@ -915,36 +875,6 @@ export const GrantsPage: React.FC = () => {
|
||||
<td className="py-3.5 px-5 text-right font-mono text-slate-400">
|
||||
{formatDate(awd.createdAt)}
|
||||
</td>
|
||||
<td className="py-3.5 px-5 text-right">
|
||||
{Number(awd.status) === ApprovalStatus.Approved ? (
|
||||
<span
|
||||
className="inline-flex items-center gap-1 text-xs font-semibold text-emerald-400 bg-emerald-500/10 px-2.5 py-1.5 rounded-lg border border-emerald-500/20 cursor-default"
|
||||
title="Approved award is immutable. Details cannot be modified."
|
||||
>
|
||||
<Lock className="w-3 h-3 text-emerald-400" />
|
||||
<span>Approved (Immutable)</span>
|
||||
</span>
|
||||
) : (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => {
|
||||
setEditingAward(awd);
|
||||
setSelectedAwardStatus(Number(awd.status) as ApprovalStatus);
|
||||
editAwardForm.reset({
|
||||
organisationId: Number(awd.organisationId),
|
||||
programmeId: Number(awd.programmeId),
|
||||
amount: Number(awd.amount),
|
||||
});
|
||||
setIsEditAwardModalOpen(true);
|
||||
}}
|
||||
className="inline-flex items-center gap-1 text-xs font-semibold text-slate-200 hover:text-white bg-slate-900/80 hover:bg-slate-800 border border-slate-700/80 px-2.5 py-1.5 rounded-lg transition shadow-xs cursor-pointer"
|
||||
title="Edit Award"
|
||||
>
|
||||
<Edit2 className="w-3 h-3" />
|
||||
<span>Edit</span>
|
||||
</button>
|
||||
)}
|
||||
</td>
|
||||
</tr>
|
||||
);
|
||||
})
|
||||
@@ -985,7 +915,6 @@ export const GrantsPage: React.FC = () => {
|
||||
<span>Refresh Budgets</span>
|
||||
</button>
|
||||
<ExportButton
|
||||
claim="grant_financials"
|
||||
title="Project Budgets Registry"
|
||||
subtitle="Detailed statutory project budget allocations and category lines"
|
||||
filename={`pfm-budgets-${new Date().toISOString().split('T')[0]}`}
|
||||
@@ -1056,21 +985,11 @@ export const GrantsPage: React.FC = () => {
|
||||
<td className="py-3.5 px-4 font-mono text-slate-400">{getUserEmail(b.createdBy)}</td>
|
||||
<td className="py-3.5 px-5 text-right">
|
||||
<div className="flex items-center justify-end gap-1.5">
|
||||
{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."
|
||||
>
|
||||
<Lock className="w-3 h-3 text-emerald-400" />
|
||||
<span>Approved (Immutable)</span>
|
||||
</span>
|
||||
) : (
|
||||
<>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => {
|
||||
setEditingBudget(b);
|
||||
setSelectedBudgetStatus(b.status ?? ApprovalStatus.Pending);
|
||||
setSelectedBudgetStatus(b.status ?? ApprovalStatus.Approved);
|
||||
setIsEditBudgetModalOpen(true);
|
||||
}}
|
||||
className="inline-flex items-center gap-1 text-xs font-semibold text-slate-200 hover:text-white bg-slate-900/80 hover:bg-slate-800 border border-slate-700/80 px-2.5 py-1.5 rounded-lg transition shadow-xs cursor-pointer"
|
||||
@@ -1079,7 +998,7 @@ export const GrantsPage: React.FC = () => {
|
||||
<Edit2 className="w-3 h-3" />
|
||||
<span>Update</span>
|
||||
</button>
|
||||
{canApproveRecord('grant_financials', b.createdBy) && (
|
||||
{b.status !== ApprovalStatus.Approved && (
|
||||
<button
|
||||
type="button"
|
||||
disabled={updateBudgetMutation.isPending}
|
||||
@@ -1096,13 +1015,6 @@ export const GrantsPage: React.FC = () => {
|
||||
<span>Approve</span>
|
||||
</button>
|
||||
)}
|
||||
{canApprove('grant_financials') && isMaker(b.createdBy) && (
|
||||
<span className="text-[10px] text-amber-400 font-mono italic px-1.5 py-0.5 bg-amber-500/10 border border-amber-500/20 rounded">
|
||||
Maker-Checker
|
||||
</span>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
@@ -1145,7 +1057,6 @@ export const GrantsPage: React.FC = () => {
|
||||
<span>Refresh Disbursements</span>
|
||||
</button>
|
||||
<ExportButton
|
||||
claim="grant_financials"
|
||||
title="Disbursement Tranches Ledger"
|
||||
subtitle="Milestone-linked funding releases and payment authorizations"
|
||||
filename={`pfm-disbursements-${new Date().toISOString().split('T')[0]}`}
|
||||
@@ -1243,7 +1154,6 @@ export const GrantsPage: React.FC = () => {
|
||||
>
|
||||
<Edit2 className="w-3.5 h-3.5" />
|
||||
</button>
|
||||
{canApproveRecord('grant_financials', d.createdBy) && (
|
||||
<button
|
||||
onClick={() => approveMutation.mutate(d.id)}
|
||||
disabled={approveMutation.isPending}
|
||||
@@ -1252,12 +1162,6 @@ export const GrantsPage: React.FC = () => {
|
||||
<CheckCircle className="w-3.5 h-3.5" />
|
||||
Approve Payment
|
||||
</button>
|
||||
)}
|
||||
{canApprove('grant_financials') && isMaker(d.createdBy) && (
|
||||
<span className="text-[10px] text-amber-400 font-mono italic px-1.5 py-0.5 bg-amber-500/10 border border-amber-500/20 rounded">
|
||||
Maker-Checker (Creator)
|
||||
</span>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
@@ -1534,140 +1438,6 @@ export const GrantsPage: React.FC = () => {
|
||||
</form>
|
||||
</Modal>
|
||||
|
||||
{/* Modal: Edit Award */}
|
||||
<Modal
|
||||
isOpen={isEditAwardModalOpen}
|
||||
onClose={() => {
|
||||
setIsEditAwardModalOpen(false);
|
||||
setEditingAward(null);
|
||||
}}
|
||||
title={`Edit Award: ${editingAward?.referenceNo || `#${editingAward?.id}`}`}
|
||||
subtitle="Approved funding award record"
|
||||
helpText="Modify the funding programme, recipient organisation, and approved award allocation."
|
||||
>
|
||||
<form
|
||||
onSubmit={editAwardForm.handleSubmit((values) => {
|
||||
if (!editingAward) return;
|
||||
if (Number(editingAward.status) === ApprovalStatus.Approved) {
|
||||
toastError('Record Immutable', 'Approved awards cannot be modified.');
|
||||
return;
|
||||
}
|
||||
updateAwardMutation.mutate({
|
||||
awardId: editingAward.id,
|
||||
organisationId: values.organisationId,
|
||||
programmeId: values.programmeId,
|
||||
amount: values.amount,
|
||||
status: selectedAwardStatus,
|
||||
});
|
||||
})}
|
||||
className="space-y-4"
|
||||
>
|
||||
<div>
|
||||
<label className="block text-xs font-semibold text-slate-300 mb-1">
|
||||
Funding Programme <span className="text-rose-400">*</span>
|
||||
</label>
|
||||
<select
|
||||
{...editAwardForm.register('programmeId')}
|
||||
className="w-full px-3 py-2 text-xs bg-slate-900/80 border border-slate-700/80 rounded-xl text-white focus:outline-none focus:ring-2 focus:ring-blue-500/50 focus:border-blue-500 font-medium [&>option]:bg-slate-900 [&>option]:text-white"
|
||||
>
|
||||
<option value="">Select Programme</option>
|
||||
{programmes.map((p) => (
|
||||
<option key={p.id} value={p.id}>
|
||||
{p.name}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
{editAwardForm.formState.errors.programmeId && (
|
||||
<p className="text-[11px] text-rose-400 mt-1">
|
||||
{editAwardForm.formState.errors.programmeId.message}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="block text-xs font-semibold text-slate-300 mb-1">
|
||||
Recipient Organisation <span className="text-rose-400">*</span>
|
||||
</label>
|
||||
<select
|
||||
{...editAwardForm.register('organisationId')}
|
||||
className="w-full px-3 py-2 text-xs bg-slate-900/80 border border-slate-700/80 rounded-xl text-white focus:outline-none focus:ring-2 focus:ring-blue-500/50 focus:border-blue-500 font-medium [&>option]:bg-slate-900 [&>option]:text-white"
|
||||
>
|
||||
<option value="">Select Recipient Organisation</option>
|
||||
{organisations.map((o) => (
|
||||
<option key={o.id} value={o.id}>
|
||||
{o.name}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
{editAwardForm.formState.errors.organisationId && (
|
||||
<p className="text-[11px] text-rose-400 mt-1">
|
||||
{editAwardForm.formState.errors.organisationId.message}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="block text-xs font-semibold text-slate-300 mb-1">
|
||||
Approved Award Amount (ZAR) <span className="text-rose-400">*</span>
|
||||
</label>
|
||||
<input
|
||||
type="number"
|
||||
step="0.01"
|
||||
{...editAwardForm.register('amount')}
|
||||
className="w-full px-3 py-2 text-xs bg-slate-900/80 border border-slate-700/80 rounded-xl text-white focus:outline-none focus:ring-2 focus:ring-blue-500/50 focus:border-blue-500 font-mono"
|
||||
/>
|
||||
{editAwardForm.formState.errors.amount && (
|
||||
<p className="text-[11px] text-rose-400 mt-1">
|
||||
{editAwardForm.formState.errors.amount.message}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="block text-xs font-semibold text-slate-300 mb-1">
|
||||
Award Lifecycle Status <span className="text-rose-400">*</span>
|
||||
</label>
|
||||
{Number(editingAward?.status) === ApprovalStatus.Approved ? (
|
||||
<div className="w-full px-3 py-2 text-xs border border-emerald-500/30 bg-emerald-500/10 rounded-xl text-emerald-300 font-semibold flex items-center gap-1.5 cursor-default">
|
||||
<Lock className="w-3.5 h-3.5 text-emerald-400" />
|
||||
<span>Approved (Immutable - Cannot Be Changed)</span>
|
||||
</div>
|
||||
) : (
|
||||
<select
|
||||
value={selectedAwardStatus}
|
||||
onChange={(e) => setSelectedAwardStatus(Number(e.target.value) as ApprovalStatus)}
|
||||
className="w-full px-3 py-2 text-xs bg-slate-900/80 border border-slate-700/80 rounded-xl text-white focus:outline-none focus:ring-2 focus:ring-blue-500/50 focus:border-blue-500 font-medium [&>option]:bg-slate-900 [&>option]:text-white"
|
||||
>
|
||||
<option value={ApprovalStatus.Pending}>Pending Approval</option>
|
||||
<option value={ApprovalStatus.Review}>Under Review</option>
|
||||
<option value={ApprovalStatus.Approved}>Approved & Activated</option>
|
||||
<option value={ApprovalStatus.Rejected}>Rejected</option>
|
||||
</select>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="flex justify-end gap-2 pt-4 border-t border-slate-800">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => {
|
||||
setIsEditAwardModalOpen(false);
|
||||
setEditingAward(null);
|
||||
}}
|
||||
className="px-4 py-2 text-xs font-semibold text-slate-300 hover:text-white bg-slate-800 hover:bg-slate-700 rounded-xl transition cursor-pointer"
|
||||
>
|
||||
Cancel
|
||||
</button>
|
||||
<button
|
||||
type="submit"
|
||||
disabled={updateAwardMutation.isPending || Number(editingAward?.status) === ApprovalStatus.Approved}
|
||||
className="px-4 py-2 text-xs font-semibold text-white bg-blue-600 hover:bg-blue-500 rounded-xl transition shadow-md shadow-blue-500/20 disabled:opacity-50 cursor-pointer"
|
||||
>
|
||||
{updateAwardMutation.isPending ? 'Saving...' : 'Save Changes'}
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
</Modal>
|
||||
|
||||
{/* Modal: Add Budget */}
|
||||
<Modal
|
||||
isOpen={isBudgetModalOpen}
|
||||
@@ -1676,7 +1446,7 @@ export const GrantsPage: React.FC = () => {
|
||||
subtitle="Maintain approved expenditure breakdown and commitments"
|
||||
helpText="Establishes financial lines for project cost categories. Ensures all disbursements are mapped to audited budget ceilings."
|
||||
>
|
||||
<form onSubmit={budgetForm.handleSubmit((data) => budgetMutation.mutate(data as CreateBudgetRequest))} className="space-y-4">
|
||||
<form onSubmit={budgetForm.handleSubmit((data) => budgetMutation.mutate(data as any))} className="space-y-4">
|
||||
<div>
|
||||
<div className="flex items-center gap-1 mb-1.5">
|
||||
<label className="block text-xs font-semibold text-slate-300">Associated Project *</label>
|
||||
@@ -1780,36 +1550,15 @@ export const GrantsPage: React.FC = () => {
|
||||
<label className="block text-xs font-semibold text-slate-300 mb-1.5">
|
||||
Approval & Compliance Status *
|
||||
</label>
|
||||
{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>
|
||||
</div>
|
||||
) : (
|
||||
<select
|
||||
value={selectedBudgetStatus}
|
||||
onChange={(e) => setSelectedBudgetStatus(Number(e.target.value) as ApprovalStatus)}
|
||||
className="w-full px-3 py-2 text-xs bg-slate-900/80 border border-slate-700/80 rounded-xl text-white focus:outline-none focus:ring-2 focus:ring-blue-500/50 focus:border-blue-500 font-medium [&>option]:bg-slate-900 [&>option]:text-white"
|
||||
>
|
||||
{(() => {
|
||||
const canApproveBudget = canApproveRecord('grant_financials', editingBudget?.createdBy);
|
||||
const isSelf = isMaker(editingBudget?.createdBy);
|
||||
let reason = '';
|
||||
if (!canApprove('grant_financials')) {
|
||||
reason = ' (Requires Update Permission)';
|
||||
} else if (isSelf) {
|
||||
reason = ' (Maker-Checker: Cannot approve own record)';
|
||||
}
|
||||
return (
|
||||
<option value={ApprovalStatus.Approved} disabled={!canApproveBudget}>
|
||||
Approved {reason}
|
||||
</option>
|
||||
);
|
||||
})()}
|
||||
<option value={ApprovalStatus.Approved}>Approved</option>
|
||||
<option value={ApprovalStatus.Pending}>Pending Review</option>
|
||||
<option value={ApprovalStatus.Rejected}>Rejected / Blocked</option>
|
||||
</select>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="pt-3 border-t border-slate-800 flex justify-end gap-2">
|
||||
@@ -1825,7 +1574,7 @@ export const GrantsPage: React.FC = () => {
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
disabled={updateBudgetMutation.isPending || !editingBudget || Number(editingBudget.status) === ApprovalStatus.Approved}
|
||||
disabled={updateBudgetMutation.isPending || !editingBudget}
|
||||
onClick={() => {
|
||||
if (editingBudget) {
|
||||
updateBudgetMutation.mutate({
|
||||
|
||||
@@ -1,978 +0,0 @@
|
||||
import React, { useState, useEffect, useMemo } from 'react';
|
||||
import { useSearchParams } from 'react-router-dom';
|
||||
import { useQuery } from '@tanstack/react-query';
|
||||
import {
|
||||
Inbox,
|
||||
Send,
|
||||
FileText,
|
||||
Star,
|
||||
Trash2,
|
||||
Mail,
|
||||
MailOpen,
|
||||
Search,
|
||||
PenSquare,
|
||||
RefreshCw,
|
||||
Reply,
|
||||
CheckCircle2,
|
||||
Clock,
|
||||
RotateCcw,
|
||||
X,
|
||||
Smartphone,
|
||||
ShieldAlert,
|
||||
} from 'lucide-react';
|
||||
import { useInAppNotifications, DraftMessage } from '../hooks/useInAppNotifications';
|
||||
import { ComposeMessageModal } from '../components/notifications/ComposeMessageModal';
|
||||
import { MarkdownViewer } from '../components/notifications/MarkdownViewer';
|
||||
import { formatDate } from '../lib/formatters';
|
||||
import { Notification, NotificationPlatform, NotificationStatus, User } from '../types';
|
||||
import { useAuth } from '../auth/AuthProvider';
|
||||
import { stripMarkdown } from '../lib/markdownUtils';
|
||||
import { getUsers } from '../services/api';
|
||||
|
||||
type MailboxFolder = 'inbox' | 'starred' | 'sent' | 'drafts' | 'trash';
|
||||
|
||||
export const InboxPage: React.FC = () => {
|
||||
const [searchParams, setSearchParams] = useSearchParams();
|
||||
const targetIdParam = searchParams.get('id');
|
||||
|
||||
const { user, claims } = useAuth();
|
||||
const currentUserId = String(user?.id || claims?.sub || '').trim().toLowerCase();
|
||||
const currentUserEmail = (user?.email || claims?.email || '') as string;
|
||||
|
||||
const { data: directoryUsers = [] } = useQuery<User[]>({
|
||||
queryKey: ['users-directory'],
|
||||
queryFn: () => getUsers(1, 200),
|
||||
staleTime: 5 * 60 * 1000,
|
||||
});
|
||||
|
||||
const {
|
||||
allMailboxNotifications,
|
||||
inboxNotifications,
|
||||
unreadCount,
|
||||
starredNotifications,
|
||||
trashedNotifications,
|
||||
sentNotifications,
|
||||
drafts,
|
||||
starredIds,
|
||||
isLoading,
|
||||
refetch,
|
||||
markAsRead,
|
||||
toggleStar,
|
||||
deleteMessage,
|
||||
restoreMessage,
|
||||
permanentDelete,
|
||||
deleteDraft,
|
||||
sendMessage,
|
||||
isSending,
|
||||
} = useInAppNotifications();
|
||||
|
||||
// Active folder
|
||||
const [activeFolder, setActiveFolder] = useState<MailboxFolder>('inbox');
|
||||
// Search query
|
||||
const [searchTerm, setSearchTerm] = useState('');
|
||||
// Filter for unread only in Inbox
|
||||
const [showUnreadOnly, setShowUnreadOnly] = useState(false);
|
||||
// Selected message to view in detail
|
||||
const [selectedMessage, setSelectedMessage] = useState<Notification | null>(null);
|
||||
// Selected draft to edit/resume
|
||||
const [selectedDraft, setSelectedDraft] = useState<DraftMessage | null>(null);
|
||||
|
||||
// Compose modal state
|
||||
const [isComposeOpen, setIsComposeOpen] = useState(false);
|
||||
const [composeInitialRecipient, setComposeInitialRecipient] = useState<any>(undefined);
|
||||
const [composeInitialSubject, setComposeInitialSubject] = useState('');
|
||||
const [composeInitialMessage, setComposeInitialMessage] = useState('');
|
||||
const [composeInitialPlatform, setComposeInitialPlatform] = useState<NotificationPlatform>(NotificationPlatform.InApp);
|
||||
|
||||
// Inline quick reply state
|
||||
const [inlineReplyText, setInlineReplyText] = useState('');
|
||||
const [inlineReplySuccess, setInlineReplySuccess] = useState(false);
|
||||
// Self-reply prevention modal state
|
||||
const [isSelfReplyModalOpen, setIsSelfReplyModalOpen] = useState(false);
|
||||
|
||||
// Active folder message items (always In-App notifications)
|
||||
const folderMessages = useMemo(() => {
|
||||
switch (activeFolder) {
|
||||
case 'inbox':
|
||||
return inboxNotifications;
|
||||
case 'starred':
|
||||
return starredNotifications;
|
||||
case 'sent':
|
||||
return sentNotifications;
|
||||
case 'trash':
|
||||
return trashedNotifications;
|
||||
case 'drafts':
|
||||
return [];
|
||||
default:
|
||||
return inboxNotifications;
|
||||
}
|
||||
}, [activeFolder, inboxNotifications, starredNotifications, sentNotifications, trashedNotifications]);
|
||||
|
||||
// Filtered by search and unread toggle
|
||||
const filteredMessages = useMemo(() => {
|
||||
return folderMessages.filter(item => {
|
||||
// Unread only filter
|
||||
if (activeFolder === 'inbox' && showUnreadOnly) {
|
||||
if (Number(item.status) === NotificationStatus.Read) return false;
|
||||
}
|
||||
// Search term
|
||||
if (!searchTerm.trim()) return true;
|
||||
const term = searchTerm.toLowerCase();
|
||||
const subject = (item.subject || '').toLowerCase();
|
||||
const message = (item.message || '').toLowerCase();
|
||||
const dest = (item.destination || '').toLowerCase();
|
||||
return subject.includes(term) || message.includes(term) || dest.includes(term);
|
||||
});
|
||||
}, [folderMessages, activeFolder, showUnreadOnly, searchTerm]);
|
||||
|
||||
// Filtered drafts for Drafts folder
|
||||
const filteredDrafts = useMemo(() => {
|
||||
if (activeFolder !== 'drafts') return [];
|
||||
if (!searchTerm.trim()) return drafts;
|
||||
const term = searchTerm.toLowerCase();
|
||||
return drafts.filter(
|
||||
d =>
|
||||
d.subject.toLowerCase().includes(term) ||
|
||||
d.message.toLowerCase().includes(term) ||
|
||||
d.recipientEmail.toLowerCase().includes(term)
|
||||
);
|
||||
}, [drafts, activeFolder, searchTerm]);
|
||||
|
||||
// Automatically select message when URL param ?id=... is present
|
||||
useEffect(() => {
|
||||
if (targetIdParam) {
|
||||
const found = folderMessages.find(m => String(m.id) === targetIdParam);
|
||||
if (found) {
|
||||
setSelectedMessage(found);
|
||||
if (Number(found.status) !== NotificationStatus.Read) {
|
||||
markAsRead(found.id);
|
||||
}
|
||||
}
|
||||
} else if (!selectedMessage && folderMessages.length > 0 && activeFolder !== 'drafts') {
|
||||
setSelectedMessage(folderMessages[0]);
|
||||
}
|
||||
}, [targetIdParam, folderMessages, activeFolder]);
|
||||
|
||||
// Handle message selection
|
||||
const handleSelectMessage = (item: Notification) => {
|
||||
setSelectedMessage(item);
|
||||
setSearchParams({ id: String(item.id) });
|
||||
if (Number(item.status) !== NotificationStatus.Read) {
|
||||
markAsRead(item.id);
|
||||
}
|
||||
setInlineReplyText('');
|
||||
setInlineReplySuccess(false);
|
||||
};
|
||||
|
||||
// Open Compose Modal for a new message
|
||||
const handleOpenCompose = (defaultPlatform: NotificationPlatform = NotificationPlatform.InApp) => {
|
||||
setSelectedDraft(null);
|
||||
setComposeInitialRecipient(undefined);
|
||||
setComposeInitialSubject('');
|
||||
setComposeInitialMessage('');
|
||||
setComposeInitialPlatform(defaultPlatform);
|
||||
setIsComposeOpen(true);
|
||||
};
|
||||
|
||||
const extractSenderEmail = (msg: Notification): string => {
|
||||
const rawDest = msg.destination || '';
|
||||
if (rawDest.includes('From:')) {
|
||||
const fromPart = rawDest.split('From:')[1]?.split('|')[0]?.trim() || '';
|
||||
return fromPart.replace(/[<>]/g, '').trim();
|
||||
}
|
||||
if (!rawDest.includes('To:') && rawDest.includes('@')) {
|
||||
return rawDest.trim();
|
||||
}
|
||||
return '';
|
||||
};
|
||||
|
||||
const isNotificationFromSelf = (msg: Notification): boolean => {
|
||||
if (activeFolder === 'sent' || String(msg.id).startsWith('sent_')) {
|
||||
return true;
|
||||
}
|
||||
const senderEmail = extractSenderEmail(msg).toLowerCase();
|
||||
const myEmail = currentUserEmail.trim().toLowerCase();
|
||||
if (myEmail && senderEmail && senderEmail === myEmail) {
|
||||
return true;
|
||||
}
|
||||
if (!senderEmail && String(msg.recipient || '').trim().toLowerCase() === currentUserId) {
|
||||
return true;
|
||||
}
|
||||
return false;
|
||||
};
|
||||
|
||||
// Open Compose Modal for a Reply
|
||||
const handleReplyMessage = (msg: Notification) => {
|
||||
if (isNotificationFromSelf(msg)) {
|
||||
setIsSelfReplyModalOpen(true);
|
||||
return;
|
||||
}
|
||||
|
||||
const replyEmail = extractSenderEmail(msg);
|
||||
const matchedSender = replyEmail
|
||||
? directoryUsers.find(u => (u.email || '').trim().toLowerCase() === replyEmail.toLowerCase())
|
||||
: undefined;
|
||||
|
||||
if (
|
||||
(matchedSender && String(matchedSender.id).trim().toLowerCase() === currentUserId) ||
|
||||
(replyEmail && replyEmail.toLowerCase() === currentUserEmail.trim().toLowerCase())
|
||||
) {
|
||||
setIsSelfReplyModalOpen(true);
|
||||
return;
|
||||
}
|
||||
|
||||
const replySubject = msg.subject?.startsWith('Re:') ? msg.subject : `Re: ${msg.subject || 'Communication'}`;
|
||||
const quotedBody = `\n\n--- Original Message (${formatDate(msg.createdAt)}) ---\n> ${stripMarkdown(msg.message || '')}`;
|
||||
|
||||
setSelectedDraft(null);
|
||||
setComposeInitialRecipient(
|
||||
replyEmail && matchedSender
|
||||
? {
|
||||
id: String(matchedSender.id),
|
||||
email: replyEmail,
|
||||
name: replyEmail.split('@')[0],
|
||||
}
|
||||
: undefined
|
||||
);
|
||||
setComposeInitialSubject(replySubject);
|
||||
setComposeInitialMessage(quotedBody);
|
||||
setComposeInitialPlatform(msg.platform as NotificationPlatform || NotificationPlatform.InApp);
|
||||
setIsComposeOpen(true);
|
||||
};
|
||||
|
||||
// Resume editing a saved draft
|
||||
const handleEditDraft = (draft: DraftMessage) => {
|
||||
setSelectedDraft(draft);
|
||||
setComposeInitialRecipient(
|
||||
draft.recipientId
|
||||
? {
|
||||
id: draft.recipientId,
|
||||
email: draft.recipientEmail,
|
||||
name: draft.recipientName,
|
||||
}
|
||||
: undefined
|
||||
);
|
||||
setComposeInitialSubject(draft.subject);
|
||||
setComposeInitialMessage(draft.message);
|
||||
setComposeInitialPlatform(draft.platform || NotificationPlatform.InApp);
|
||||
setIsComposeOpen(true);
|
||||
};
|
||||
|
||||
// Send inline quick reply
|
||||
const handleSendInlineReply = async () => {
|
||||
if (!selectedMessage || !inlineReplyText.trim()) return;
|
||||
|
||||
if (isNotificationFromSelf(selectedMessage)) {
|
||||
setIsSelfReplyModalOpen(true);
|
||||
return;
|
||||
}
|
||||
|
||||
const replyEmail = extractSenderEmail(selectedMessage);
|
||||
const matchedSender = replyEmail
|
||||
? directoryUsers.find(u => (u.email || '').trim().toLowerCase() === replyEmail.toLowerCase())
|
||||
: undefined;
|
||||
|
||||
const targetRecipientId = matchedSender ? String(matchedSender.id) : String(selectedMessage.recipient);
|
||||
if (
|
||||
targetRecipientId.trim().toLowerCase() === currentUserId ||
|
||||
(replyEmail && replyEmail.toLowerCase() === currentUserEmail.trim().toLowerCase())
|
||||
) {
|
||||
setIsSelfReplyModalOpen(true);
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
await sendMessage({
|
||||
recipientId: targetRecipientId,
|
||||
recipientEmail: replyEmail || 'team@dsi.gov.za',
|
||||
subject: selectedMessage.subject?.startsWith('Re:')
|
||||
? selectedMessage.subject
|
||||
: `Re: ${selectedMessage.subject || 'Notification'}`,
|
||||
message: inlineReplyText.trim(),
|
||||
platform: selectedMessage.platform as NotificationPlatform || NotificationPlatform.InApp,
|
||||
});
|
||||
|
||||
setInlineReplySuccess(true);
|
||||
setInlineReplyText('');
|
||||
setTimeout(() => {
|
||||
setInlineReplySuccess(false);
|
||||
}, 3000);
|
||||
} catch (err) {
|
||||
console.error('Failed to send inline reply:', err);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="flex-1 flex flex-col h-full bg-[#090d16] text-slate-100 overflow-hidden font-sans">
|
||||
{/* Top Mailbox Breadcrumb & Action Banner */}
|
||||
<div className="h-14 px-6 border-b border-slate-800/80 bg-[#0d121f]/90 backdrop-blur-md flex items-center justify-between shrink-0">
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="w-8 h-8 rounded-xl bg-blue-600/20 border border-blue-500/30 flex items-center justify-center text-blue-400">
|
||||
<Mail className="w-4 h-4" />
|
||||
</div>
|
||||
<div>
|
||||
<div className="flex items-center gap-2">
|
||||
<h1 className="text-sm font-bold text-white tracking-tight">Mailbox & Communications</h1>
|
||||
<span className="text-[10px] font-semibold text-blue-300 bg-blue-500/10 border border-blue-500/30 px-2 py-0.5 rounded-full flex items-center gap-1">
|
||||
In-App Messaging
|
||||
</span>
|
||||
</div>
|
||||
<p className="text-[11px] text-slate-400">
|
||||
In-App notifications and internal platform communications
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-2">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => refetch()}
|
||||
className="flex items-center gap-1.5 px-3 py-1.5 rounded-xl bg-slate-900/80 hover:bg-slate-800 border border-slate-700/80 text-xs font-medium text-slate-300 hover:text-white transition cursor-pointer shadow-xs"
|
||||
title="Refresh mailbox messages"
|
||||
>
|
||||
<RefreshCw className={`w-3.5 h-3.5 ${isLoading ? 'animate-spin text-blue-400' : ''}`} />
|
||||
<span>Refresh</span>
|
||||
</button>
|
||||
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => handleOpenCompose()}
|
||||
id="compose-message-main-btn"
|
||||
className="flex items-center gap-2 px-4 py-1.5 rounded-xl bg-blue-600 hover:bg-blue-500 text-white text-xs font-semibold shadow-lg shadow-blue-600/30 transition cursor-pointer"
|
||||
>
|
||||
<PenSquare className="w-3.5 h-3.5" />
|
||||
<span>Compose</span>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Main Mailbox Three-Pane Layout */}
|
||||
<div className="flex-1 flex overflow-hidden">
|
||||
{/* ======================================================== */}
|
||||
{/* PANE 1: Mailbox Folders Sidebar */}
|
||||
{/* ======================================================== */}
|
||||
<aside className="w-60 shrink-0 border-r border-slate-800/80 bg-[#0b101b]/95 p-3 flex flex-col justify-between">
|
||||
<div className="space-y-4">
|
||||
{/* Primary Compose Action */}
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => handleOpenCompose()}
|
||||
className="w-full flex items-center justify-center gap-2 py-2.5 px-4 rounded-2xl bg-gradient-to-r from-blue-600 to-indigo-600 hover:from-blue-500 hover:to-indigo-500 text-white text-xs font-bold shadow-lg shadow-blue-600/25 transition cursor-pointer"
|
||||
>
|
||||
<PenSquare className="w-4 h-4" />
|
||||
<span>New Message</span>
|
||||
</button>
|
||||
|
||||
{/* Folder Navigation */}
|
||||
<nav className="space-y-1">
|
||||
{/* Inbox */}
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => {
|
||||
setActiveFolder('inbox');
|
||||
setSelectedDraft(null);
|
||||
}}
|
||||
className={`w-full flex items-center justify-between px-3 py-2 rounded-xl text-xs font-medium transition cursor-pointer ${
|
||||
activeFolder === 'inbox'
|
||||
? 'bg-blue-600/20 text-white border border-blue-500/30'
|
||||
: 'text-slate-300 hover:text-white hover:bg-slate-800/60'
|
||||
}`}
|
||||
>
|
||||
<div className="flex items-center gap-2.5">
|
||||
<Inbox className="w-4 h-4 text-blue-400" />
|
||||
<span>Inbox</span>
|
||||
</div>
|
||||
{unreadCount > 0 ? (
|
||||
<span className="text-[10px] font-bold text-white bg-blue-600 px-2 py-0.5 rounded-full shadow-xs">
|
||||
{unreadCount}
|
||||
</span>
|
||||
) : (
|
||||
<span className="text-[11px] text-slate-400 font-mono">
|
||||
{allMailboxNotifications.length}
|
||||
</span>
|
||||
)}
|
||||
</button>
|
||||
|
||||
{/* Starred */}
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => {
|
||||
setActiveFolder('starred');
|
||||
setSelectedDraft(null);
|
||||
}}
|
||||
className={`w-full flex items-center justify-between px-3 py-2 rounded-xl text-xs font-medium transition cursor-pointer ${
|
||||
activeFolder === 'starred'
|
||||
? 'bg-amber-500/20 text-white border border-amber-500/30'
|
||||
: 'text-slate-300 hover:text-white hover:bg-slate-800/60'
|
||||
}`}
|
||||
>
|
||||
<div className="flex items-center gap-2.5">
|
||||
<Star className="w-4 h-4 text-amber-400" />
|
||||
<span>Starred</span>
|
||||
</div>
|
||||
<span className="text-[11px] text-slate-400 font-mono">
|
||||
{starredNotifications.length}
|
||||
</span>
|
||||
</button>
|
||||
|
||||
{/* Sent */}
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => {
|
||||
setActiveFolder('sent');
|
||||
setSelectedDraft(null);
|
||||
}}
|
||||
className={`w-full flex items-center justify-between px-3 py-2 rounded-xl text-xs font-medium transition cursor-pointer ${
|
||||
activeFolder === 'sent'
|
||||
? 'bg-indigo-600/20 text-white border border-indigo-500/30'
|
||||
: 'text-slate-300 hover:text-white hover:bg-slate-800/60'
|
||||
}`}
|
||||
>
|
||||
<div className="flex items-center gap-2.5">
|
||||
<Send className="w-4 h-4 text-indigo-400" />
|
||||
<span>Sent</span>
|
||||
</div>
|
||||
<span className="text-[11px] text-slate-400 font-mono">
|
||||
{sentNotifications.length}
|
||||
</span>
|
||||
</button>
|
||||
|
||||
{/* Drafts */}
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => {
|
||||
setActiveFolder('drafts');
|
||||
setSelectedMessage(null);
|
||||
}}
|
||||
className={`w-full flex items-center justify-between px-3 py-2 rounded-xl text-xs font-medium transition cursor-pointer ${
|
||||
activeFolder === 'drafts'
|
||||
? 'bg-purple-600/20 text-white border border-purple-500/30'
|
||||
: 'text-slate-300 hover:text-white hover:bg-slate-800/60'
|
||||
}`}
|
||||
>
|
||||
<div className="flex items-center gap-2.5">
|
||||
<FileText className="w-4 h-4 text-purple-400" />
|
||||
<span>Drafts</span>
|
||||
</div>
|
||||
{drafts.length > 0 ? (
|
||||
<span className="text-[10px] font-bold text-purple-300 bg-purple-500/20 border border-purple-500/30 px-2 py-0.5 rounded-full">
|
||||
{drafts.length}
|
||||
</span>
|
||||
) : (
|
||||
<span className="text-[11px] text-slate-400 font-mono">
|
||||
0
|
||||
</span>
|
||||
)}
|
||||
</button>
|
||||
|
||||
{/* Trash */}
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => {
|
||||
setActiveFolder('trash');
|
||||
setSelectedDraft(null);
|
||||
}}
|
||||
className={`w-full flex items-center justify-between px-3 py-2 rounded-xl text-xs font-medium transition cursor-pointer ${
|
||||
activeFolder === 'trash'
|
||||
? 'bg-rose-600/20 text-white border border-rose-500/30'
|
||||
: 'text-slate-300 hover:text-white hover:bg-slate-800/60'
|
||||
}`}
|
||||
>
|
||||
<div className="flex items-center gap-2.5">
|
||||
<Trash2 className="w-4 h-4 text-rose-400" />
|
||||
<span>Trash</span>
|
||||
</div>
|
||||
<span className="text-[11px] text-slate-400 font-mono">
|
||||
{trashedNotifications.length}
|
||||
</span>
|
||||
</button>
|
||||
</nav>
|
||||
|
||||
{/* Quick Filter: Unread Toggle */}
|
||||
{activeFolder === 'inbox' && (
|
||||
<div className="pt-2 border-t border-slate-800/80">
|
||||
<label className="flex items-center gap-2 px-2.5 py-1.5 rounded-xl hover:bg-slate-800/50 cursor-pointer text-xs text-slate-300">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={showUnreadOnly}
|
||||
onChange={e => setShowUnreadOnly(e.target.checked)}
|
||||
className="rounded border-slate-700 bg-slate-900 text-blue-600 focus:ring-blue-500"
|
||||
/>
|
||||
<span>Unread only</span>
|
||||
</label>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Sidebar Footer Branding */}
|
||||
<div className="p-3 rounded-2xl bg-slate-900/40 border border-slate-800/60 space-y-1 text-[11px] text-slate-400">
|
||||
<div className="flex items-center gap-1.5 text-slate-300 font-semibold">
|
||||
<span className="w-2 h-2 rounded-full bg-emerald-400" />
|
||||
<span>In-App Notifications</span>
|
||||
</div>
|
||||
<p className="text-[10px] text-slate-400 leading-snug">
|
||||
Messages are delivered directly to your In-App Mailbox and Notification Bell.
|
||||
</p>
|
||||
</div>
|
||||
</aside>
|
||||
|
||||
{/* ======================================================== */}
|
||||
{/* PANE 2: Message List / Summary Cards */}
|
||||
{/* ======================================================== */}
|
||||
<section className="w-80 sm:w-96 shrink-0 border-r border-slate-800/80 bg-[#0d121f]/60 flex flex-col overflow-hidden">
|
||||
{/* Search Box Header */}
|
||||
<div className="p-3 border-b border-slate-800/80 bg-[#0d121f]/90 space-y-2">
|
||||
<div className="relative">
|
||||
<Search className="w-3.5 h-3.5 absolute left-3 top-1/2 -translate-y-1/2 text-slate-400" />
|
||||
<input
|
||||
type="text"
|
||||
value={searchTerm}
|
||||
onChange={e => setSearchTerm(e.target.value)}
|
||||
placeholder="Search messages by subject, sender, or text..."
|
||||
className="w-full pl-8 pr-7 py-1.5 rounded-xl bg-slate-900 border border-slate-700/80 text-xs text-slate-100 placeholder-slate-400 focus:border-blue-500 focus:outline-none transition shadow-inner"
|
||||
/>
|
||||
{searchTerm && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setSearchTerm('')}
|
||||
className="absolute right-2.5 top-1/2 -translate-y-1/2 text-slate-400 hover:text-white"
|
||||
>
|
||||
<X className="w-3.5 h-3.5" />
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="flex items-center justify-between text-[11px] text-slate-400 px-1">
|
||||
<span className="capitalize font-semibold text-slate-200">
|
||||
{activeFolder} ({activeFolder === 'drafts' ? filteredDrafts.length : filteredMessages.length})
|
||||
</span>
|
||||
{activeFolder === 'inbox' && unreadCount > 0 && (
|
||||
<span className="text-blue-400 font-medium">{unreadCount} unread</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* List of Messages or Drafts */}
|
||||
<div className="flex-1 overflow-y-auto divide-y divide-slate-800/60">
|
||||
{activeFolder === 'drafts' ? (
|
||||
filteredDrafts.length === 0 ? (
|
||||
<div className="py-16 px-6 text-center text-slate-400">
|
||||
<FileText className="w-8 h-8 mx-auto text-slate-600 mb-2" />
|
||||
<p className="text-xs font-semibold text-slate-300">No Drafts Saved</p>
|
||||
<p className="text-[11px] text-slate-400 mt-1">
|
||||
When you save unfinished messages, they will appear here.
|
||||
</p>
|
||||
</div>
|
||||
) : (
|
||||
filteredDrafts.map(d => (
|
||||
<div
|
||||
key={d.id}
|
||||
onClick={() => handleEditDraft(d)}
|
||||
className="p-3.5 hover:bg-slate-800/60 transition cursor-pointer group flex flex-col gap-1.5 relative"
|
||||
>
|
||||
<div className="flex items-center justify-between gap-1">
|
||||
<span className="text-[10px] font-bold text-purple-300 bg-purple-500/15 border border-purple-500/30 px-1.5 py-0.2 rounded">
|
||||
Draft
|
||||
</span>
|
||||
<span className="text-[10px] text-slate-400 font-mono">
|
||||
{formatDate(d.updatedAt)}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<h4 className="text-xs font-semibold text-white group-hover:text-purple-300 transition truncate">
|
||||
{d.subject || 'Untitled Draft'}
|
||||
</h4>
|
||||
|
||||
<p className="text-[11px] text-slate-300 line-clamp-2 leading-relaxed">
|
||||
{stripMarkdown(d.message || 'No content written yet.')}
|
||||
</p>
|
||||
|
||||
<div className="flex items-center justify-between pt-1">
|
||||
<span className="text-[10px] text-slate-400 truncate">
|
||||
To: {d.recipientEmail || 'No recipient selected'}
|
||||
</span>
|
||||
<button
|
||||
type="button"
|
||||
onClick={e => {
|
||||
e.stopPropagation();
|
||||
deleteDraft(d.id);
|
||||
}}
|
||||
className="text-slate-400 hover:text-rose-400 p-1 rounded transition"
|
||||
title="Delete draft"
|
||||
>
|
||||
<Trash2 className="w-3 h-3" />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
))
|
||||
)
|
||||
) : filteredMessages.length === 0 ? (
|
||||
<div className="py-16 px-6 text-center text-slate-400">
|
||||
<MailOpen className="w-8 h-8 mx-auto text-slate-600 mb-2" />
|
||||
<p className="text-xs font-semibold text-slate-300">No Messages Found</p>
|
||||
<p className="text-[11px] text-slate-400 mt-1">
|
||||
{searchTerm
|
||||
? `No messages matched "${searchTerm}"`
|
||||
: `No items in ${activeFolder}`}
|
||||
</p>
|
||||
</div>
|
||||
) : (
|
||||
filteredMessages.map(item => {
|
||||
const isSelected = selectedMessage?.id === item.id;
|
||||
const isUnread = Number(item.status) !== NotificationStatus.Read;
|
||||
const isStarred = starredIds.includes(String(item.id));
|
||||
|
||||
return (
|
||||
<div
|
||||
key={item.id}
|
||||
onClick={() => handleSelectMessage(item)}
|
||||
className={`p-3.5 transition cursor-pointer flex flex-col gap-1 relative ${
|
||||
isSelected
|
||||
? 'bg-blue-600/15 border-l-4 border-l-blue-500 text-white shadow-inner'
|
||||
: 'hover:bg-slate-800/50 border-l-4 border-l-transparent'
|
||||
}`}
|
||||
>
|
||||
{/* Top line: unread dot, platform badge, star, time */}
|
||||
<div className="flex items-center justify-between gap-1">
|
||||
<div className="flex items-center gap-1.5 min-w-0">
|
||||
{isUnread && (
|
||||
<span
|
||||
className="w-2 h-2 rounded-full bg-blue-500 shrink-0 shadow-sm shadow-blue-500 animate-pulse"
|
||||
title="Unread notification"
|
||||
/>
|
||||
)}
|
||||
<span className="text-[11px] font-semibold text-slate-300 truncate">
|
||||
{item.destination?.includes('From:')
|
||||
? item.destination.split('From:')[1]?.trim().replace(/[<>]/g, '')
|
||||
: item.destination || 'PFM System'}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-1.5 shrink-0">
|
||||
<span className="text-[9px] font-bold text-blue-300 bg-blue-500/15 border border-blue-500/30 px-1.5 py-0.2 rounded font-mono">
|
||||
In-App
|
||||
</span>
|
||||
|
||||
<button
|
||||
type="button"
|
||||
onClick={e => {
|
||||
e.stopPropagation();
|
||||
toggleStar(item.id);
|
||||
}}
|
||||
className={`p-0.5 rounded transition ${
|
||||
isStarred ? 'text-amber-400 hover:text-amber-300' : 'text-slate-400 hover:text-slate-300'
|
||||
}`}
|
||||
title={isStarred ? 'Unstar message' : 'Star message'}
|
||||
>
|
||||
<Star className={`w-3.5 h-3.5 ${isStarred ? 'fill-amber-400' : ''}`} />
|
||||
</button>
|
||||
<span className="text-[10px] text-slate-400 font-mono">
|
||||
{formatDate(item.createdAt)}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Subject */}
|
||||
<h4
|
||||
className={`text-xs truncate ${
|
||||
isUnread ? 'font-bold text-white' : 'font-medium text-slate-300'
|
||||
}`}
|
||||
>
|
||||
{item.subject || 'Statutory Platform Notice'}
|
||||
</h4>
|
||||
|
||||
{/* Snippet preview stripped of markdown syntax */}
|
||||
<p className="text-[11px] text-slate-400 line-clamp-2 leading-relaxed">
|
||||
{stripMarkdown(item.message || 'No additional details provided.')}
|
||||
</p>
|
||||
</div>
|
||||
);
|
||||
})
|
||||
)}
|
||||
</div>
|
||||
</section>
|
||||
|
||||
{/* ======================================================== */}
|
||||
{/* PANE 3: Full Message Detail & Interactive Actions */}
|
||||
{/* ======================================================== */}
|
||||
<main className="flex-1 bg-[#090d16] flex flex-col overflow-y-auto">
|
||||
{activeFolder === 'drafts' ? (
|
||||
<div className="flex-1 flex flex-col items-center justify-center p-8 text-center text-slate-400">
|
||||
<FileText className="w-12 h-12 text-slate-600 mb-3" />
|
||||
<h3 className="text-sm font-bold text-white">Drafts Folder</h3>
|
||||
<p className="text-xs text-slate-400 max-w-md mt-1 mb-4">
|
||||
Click any draft on the left pane to resume composing and dispatch your notification.
|
||||
</p>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => handleOpenCompose()}
|
||||
className="flex items-center gap-2 px-4 py-2 rounded-xl bg-blue-600 hover:bg-blue-500 text-white text-xs font-semibold shadow-lg shadow-blue-600/30 transition cursor-pointer"
|
||||
>
|
||||
<PenSquare className="w-4 h-4" />
|
||||
<span>Create New Message</span>
|
||||
</button>
|
||||
</div>
|
||||
) : !selectedMessage ? (
|
||||
<div className="flex-1 flex flex-col items-center justify-center p-8 text-center text-slate-400">
|
||||
<div className="w-16 h-16 rounded-3xl bg-slate-900 border border-slate-800 flex items-center justify-center text-slate-600 mb-4 shadow-xl">
|
||||
<Mail className="w-8 h-8" />
|
||||
</div>
|
||||
<h3 className="text-sm font-bold text-white">No Message Selected</h3>
|
||||
<p className="text-xs text-slate-400 max-w-sm mt-1 mb-4">
|
||||
Select an in-app conversation or email notice from the list to view its contents and attachments.
|
||||
</p>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => handleOpenCompose()}
|
||||
className="flex items-center gap-2 px-4 py-2 rounded-xl bg-slate-900 hover:bg-slate-800 text-slate-200 border border-slate-700 text-xs font-semibold transition cursor-pointer"
|
||||
>
|
||||
<PenSquare className="w-3.5 h-3.5 text-blue-400" />
|
||||
<span>Compose New Message</span>
|
||||
</button>
|
||||
</div>
|
||||
) : (
|
||||
<div className="flex-1 flex flex-col">
|
||||
{/* Message Header Toolbar */}
|
||||
<div className="p-5 border-b border-slate-800/80 bg-[#0d121f]/60 flex items-center justify-between shrink-0">
|
||||
<div className="flex items-center gap-3">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => handleReplyMessage(selectedMessage)}
|
||||
className="flex items-center gap-1.5 px-3 py-1.5 rounded-xl bg-blue-600 hover:bg-blue-500 text-white text-xs font-semibold shadow-md shadow-blue-600/20 transition cursor-pointer"
|
||||
title="Reply to message"
|
||||
>
|
||||
<Reply className="w-3.5 h-3.5" />
|
||||
<span>Reply</span>
|
||||
</button>
|
||||
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => toggleStar(selectedMessage.id)}
|
||||
className={`flex items-center gap-1.5 px-3 py-1.5 rounded-xl border text-xs font-medium transition cursor-pointer ${
|
||||
starredIds.includes(String(selectedMessage.id))
|
||||
? 'bg-amber-500/15 border-amber-500/30 text-amber-300'
|
||||
: 'bg-slate-900/80 border-slate-700/80 text-slate-300 hover:text-white'
|
||||
}`}
|
||||
>
|
||||
<Star
|
||||
className={`w-3.5 h-3.5 ${
|
||||
starredIds.includes(String(selectedMessage.id)) ? 'fill-amber-400' : ''
|
||||
}`}
|
||||
/>
|
||||
<span>{starredIds.includes(String(selectedMessage.id)) ? 'Starred' : 'Star'}</span>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-2">
|
||||
{activeFolder === 'trash' ? (
|
||||
<>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => restoreMessage(selectedMessage.id)}
|
||||
className="flex items-center gap-1.5 px-3 py-1.5 rounded-xl bg-slate-900/80 hover:bg-slate-800 border border-slate-700 text-xs text-slate-200 transition cursor-pointer"
|
||||
title="Restore message to Inbox"
|
||||
>
|
||||
<RotateCcw className="w-3.5 h-3.5 text-emerald-400" />
|
||||
<span>Restore</span>
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => permanentDelete(selectedMessage.id)}
|
||||
className="flex items-center gap-1.5 px-3 py-1.5 rounded-xl bg-rose-950/40 hover:bg-rose-900/40 border border-rose-800/60 text-xs text-rose-300 transition cursor-pointer"
|
||||
title="Permanently erase message"
|
||||
>
|
||||
<Trash2 className="w-3.5 h-3.5 text-rose-400" />
|
||||
<span>Delete Forever</span>
|
||||
</button>
|
||||
</>
|
||||
) : (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => {
|
||||
deleteMessage(selectedMessage.id);
|
||||
setSelectedMessage(null);
|
||||
}}
|
||||
className="p-2 rounded-xl text-slate-400 hover:text-rose-400 hover:bg-rose-950/20 border border-transparent hover:border-rose-900/40 transition cursor-pointer"
|
||||
title="Move message to Trash"
|
||||
>
|
||||
<Trash2 className="w-4 h-4" />
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Message Details & Body */}
|
||||
<div className="p-6 space-y-6 flex-1 overflow-y-auto max-w-4xl">
|
||||
{/* Subject Header */}
|
||||
<div>
|
||||
<h2 className="text-xl font-bold text-white tracking-tight leading-tight">
|
||||
{selectedMessage.subject || 'In-App Notification'}
|
||||
</h2>
|
||||
<div className="flex flex-wrap items-center gap-2 mt-2">
|
||||
<span className="text-[10px] font-semibold text-blue-300 bg-blue-500/15 border border-blue-500/30 px-2 py-0.5 rounded-md flex items-center gap-1">
|
||||
<Smartphone className="w-3 h-3 text-blue-400" />
|
||||
Channel: In-App
|
||||
</span>
|
||||
|
||||
<span className="text-[10px] font-mono text-slate-400 bg-slate-900 px-2 py-0.5 rounded-md border border-slate-800">
|
||||
ID: #{selectedMessage.id}
|
||||
</span>
|
||||
<span className="text-[10px] text-slate-400">
|
||||
Received: {formatDate(selectedMessage.createdAt)}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Sender & Recipient Card */}
|
||||
<div className="p-4 rounded-2xl bg-slate-900/60 border border-slate-800 flex items-start justify-between gap-4">
|
||||
<div className="flex items-start gap-3">
|
||||
<div className="w-10 h-10 rounded-2xl bg-gradient-to-br from-blue-600 to-indigo-600 flex items-center justify-center font-bold text-xs text-white shadow-md shadow-blue-500/20 shrink-0">
|
||||
{selectedMessage.destination?.includes('@')
|
||||
? selectedMessage.destination.split('@')[0].slice(0, 2).toUpperCase()
|
||||
: 'PF'}
|
||||
</div>
|
||||
<div>
|
||||
<div className="flex items-center gap-2">
|
||||
<p className="text-xs font-bold text-white">
|
||||
{selectedMessage.destination?.includes('From:')
|
||||
? selectedMessage.destination.split('From:')[1]?.trim().replace(/[<>]/g, '')
|
||||
: selectedMessage.destination || 'PFM Automated Dispatcher'}
|
||||
</p>
|
||||
<span className="text-[10px] text-slate-400 font-mono">
|
||||
(In-App Notification)
|
||||
</span>
|
||||
</div>
|
||||
<p className="text-[11px] text-slate-400 mt-0.5">
|
||||
To:{' '}
|
||||
<strong className="text-slate-300">
|
||||
{selectedMessage.destination?.includes('To:')
|
||||
? selectedMessage.destination.split('To:')[1]?.split('|')[0]?.trim()
|
||||
: currentUserEmail || 'You'}
|
||||
</strong>
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<span className="text-[10px] text-slate-400 font-mono shrink-0 flex items-center gap-1">
|
||||
<Clock className="w-3 h-3" />
|
||||
{formatDate(selectedMessage.createdAt)}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
{/* Fully-Rendered Markdown & HTML Message Body with Branding */}
|
||||
<MarkdownViewer
|
||||
content={selectedMessage.message || ''}
|
||||
subject={selectedMessage.subject || ''}
|
||||
showBrandingHeader={true}
|
||||
/>
|
||||
|
||||
{/* Inline Quick Reply Section */}
|
||||
<div className="pt-6 border-t border-slate-800 space-y-3">
|
||||
<div className="flex items-center justify-between">
|
||||
<h4 className="text-xs font-bold text-white flex items-center gap-1.5">
|
||||
<Reply className="w-3.5 h-3.5 text-blue-400" />
|
||||
<span>Quick Reply</span>
|
||||
</h4>
|
||||
<span className="text-[10px] text-slate-400">
|
||||
Send a quick response below or open the rich text composer
|
||||
</span>
|
||||
</div>
|
||||
|
||||
{inlineReplySuccess && (
|
||||
<div className="p-3 rounded-xl bg-emerald-500/10 border border-emerald-500/30 text-emerald-300 text-xs flex items-center gap-2">
|
||||
<CheckCircle2 className="w-4 h-4 text-emerald-400" />
|
||||
<span>Reply dispatched successfully!</span>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<textarea
|
||||
rows={4}
|
||||
value={inlineReplyText}
|
||||
onChange={e => setInlineReplyText(e.target.value)}
|
||||
placeholder="Type your reply..."
|
||||
className="w-full p-3.5 rounded-2xl bg-slate-900/90 border border-slate-700/80 hover:border-slate-600 focus:border-blue-500 text-slate-100 placeholder-slate-400 text-xs focus:outline-none transition shadow-inner resize-y leading-relaxed"
|
||||
/>
|
||||
|
||||
<div className="flex items-center justify-between">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => handleReplyMessage(selectedMessage)}
|
||||
className="text-xs text-blue-400 hover:text-blue-300 font-medium transition cursor-pointer"
|
||||
>
|
||||
Open rich compose editor with live preview
|
||||
</button>
|
||||
|
||||
<button
|
||||
type="button"
|
||||
disabled={isSending || !inlineReplyText.trim()}
|
||||
onClick={handleSendInlineReply}
|
||||
className="flex items-center gap-2 px-5 py-2 rounded-xl bg-blue-600 hover:bg-blue-500 text-white text-xs font-semibold shadow-md shadow-blue-600/30 transition disabled:opacity-40 cursor-pointer"
|
||||
>
|
||||
{isSending ? (
|
||||
<div className="w-3.5 h-3.5 border-2 border-white border-t-transparent rounded-full animate-spin" />
|
||||
) : (
|
||||
<Send className="w-3.5 h-3.5" />
|
||||
)}
|
||||
<span>Send Reply</span>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</main>
|
||||
</div>
|
||||
|
||||
{/* Compose Message Modal */}
|
||||
<ComposeMessageModal
|
||||
isOpen={isComposeOpen}
|
||||
onClose={() => setIsComposeOpen(false)}
|
||||
initialRecipient={composeInitialRecipient}
|
||||
initialSubject={composeInitialSubject}
|
||||
initialMessage={composeInitialMessage}
|
||||
initialPlatform={composeInitialPlatform}
|
||||
draftToEdit={selectedDraft}
|
||||
/>
|
||||
|
||||
{/* Self-Reply Prevention Modal */}
|
||||
{isSelfReplyModalOpen && (
|
||||
<div className="fixed inset-0 z-50 flex items-center justify-center p-4 bg-slate-950/80 backdrop-blur-sm animate-in fade-in duration-150">
|
||||
<div className="w-full max-w-md bg-[#0d1322] border border-rose-500/30 rounded-2xl shadow-2xl overflow-hidden p-6 space-y-4">
|
||||
<div className="flex items-start justify-between gap-3">
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="w-10 h-10 rounded-xl bg-rose-500/15 border border-rose-500/30 flex items-center justify-center text-rose-400 shrink-0">
|
||||
<ShieldAlert className="w-5 h-5" />
|
||||
</div>
|
||||
<div>
|
||||
<h3 className="text-sm font-bold text-white">Action Not Allowed</h3>
|
||||
<p className="text-[11px] text-rose-300 font-medium">Self-Reply Restricted</p>
|
||||
</div>
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setIsSelfReplyModalOpen(false)}
|
||||
className="p-1 rounded-lg text-slate-400 hover:text-white hover:bg-slate-800 transition cursor-pointer"
|
||||
>
|
||||
<X className="w-4 h-4" />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<p className="text-xs text-slate-300 leading-relaxed">
|
||||
You cannot reply to a notification that originated from your own account. Please compose a new message and select another recipient from the directory if you wish to follow up with a colleague.
|
||||
</p>
|
||||
|
||||
<div className="pt-2 border-t border-slate-800 flex justify-end gap-2">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setIsSelfReplyModalOpen(false)}
|
||||
className="px-4 py-2 rounded-xl bg-blue-600 hover:bg-blue-500 text-white text-xs font-semibold shadow-md shadow-blue-600/25 transition cursor-pointer"
|
||||
>
|
||||
Understood
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user