Compare commits
76
Commits
1d1060d031
..
main
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
04b5656fd1 | ||
|
|
7f686fa8c6 | ||
|
|
6114d8f457 | ||
|
|
b8c3201c58 | ||
|
|
a8daea830f | ||
|
|
5be7c8c56a | ||
|
|
d24b0dafb1 | ||
|
|
cd47a2b93a | ||
|
|
c11535ebfc | ||
|
|
129d10c7d1 | ||
|
|
9f132eeb29 | ||
|
|
80c798e5e6 | ||
|
|
2690b3230e | ||
|
|
155a379ab4 | ||
|
|
f56cb70b55 | ||
|
|
bb77a4dc9a | ||
|
|
5ac317cbe3 | ||
|
|
0bcc3484dc | ||
|
|
fddf400ddd | ||
|
|
a1b8410bc2 | ||
|
|
cb0da32ce4 | ||
|
|
70e286670e | ||
|
|
c6075a5c39 | ||
|
|
4e1211a1e1 | ||
|
|
c8e54815ef | ||
|
|
31d775c917 | ||
|
|
78d147b002 | ||
|
|
07018c3ce4 | ||
|
|
db08bbf162 | ||
|
|
ac357285c3 | ||
|
|
a4927f9742 | ||
|
|
fac88f3471 | ||
|
|
aeb7542cfe | ||
|
|
9724bbac24 | ||
|
|
deb21dbb9a | ||
|
|
98fe1e3e3d | ||
|
|
48edfa1cbb | ||
|
|
d9b65010cf | ||
|
|
3e1d8bc1cc | ||
|
|
bc441c1255 | ||
|
|
17a4149a73 | ||
|
|
6e786ca796 | ||
|
|
25e251aa79 | ||
|
|
a731c979bb | ||
|
|
90631d22ae | ||
|
|
17a99ff084 | ||
|
|
010f2d5eaa | ||
|
|
29a11f0e0b | ||
|
|
b8f5ebf69a | ||
|
|
385c76510b | ||
|
|
631a0b7a9a | ||
|
|
957e75a853 | ||
|
|
1233f15b91 | ||
|
|
8a06a86eb3 | ||
|
|
c43e18f184 | ||
|
|
f8f826979d | ||
|
|
d912c5c6c3 | ||
|
|
20bdd8b209 | ||
|
|
c4a0469bae | ||
|
|
bf384d7e6a | ||
|
|
957d2210a9 | ||
|
|
a3f461478c | ||
|
|
16fb6b9434 | ||
|
|
7220700bac | ||
|
|
cc62696017 | ||
|
|
3316fc0275 | ||
|
|
d4598e0c6c | ||
|
|
8f58e15f0f | ||
|
|
43656e848a | ||
|
|
9ed210613e | ||
|
|
3a3245b643 | ||
|
|
5050c901bc | ||
|
|
fdaf12a183 | ||
|
|
46c6ddcc80 | ||
|
|
6cdd03a02f | ||
|
|
7071c404f0 |
+131
-5
@@ -22,12 +22,13 @@ The PFM platform is constructed using **Clean Architecture** and **Domain-Driven
|
|||||||
│ API GATEWAY │
|
│ API GATEWAY │
|
||||||
│ PostFundManagement.Api (ASP.NET Core Minimal APIs / Carter) │
|
│ PostFundManagement.Api (ASP.NET Core Minimal APIs / Carter) │
|
||||||
│ Authentication: JWT Bearer + OpenTelemetry Observability │
|
│ Authentication: JWT Bearer + OpenTelemetry Observability │
|
||||||
|
│ Quartz Client: Scheduler-Only (ZeroSizeThreadPool / Enqueues Jobs) │
|
||||||
└───────────────────────────────────┬────────────────────────────────────┘
|
└───────────────────────────────────┬────────────────────────────────────┘
|
||||||
│
|
│
|
||||||
┌───────────────────────────────────▼────────────────────────────────────┐
|
┌───────────────────────────────────▼────────────────────────────────────┐
|
||||||
│ APPLICATION LAYER │
|
│ APPLICATION LAYER │
|
||||||
│ PostFundManagement.Application │
|
│ PostFundManagement.Application │
|
||||||
│ CQRS (Mediator), Business Validators, Domain Services, Quartz Jobs │
|
│ CQRS (Mediator), Business Validators, Domain Services, Event Handlers │
|
||||||
└──────────────────┬─────────────────────────────────┬───────────────────┘
|
└──────────────────┬─────────────────────────────────┬───────────────────┘
|
||||||
│ │
|
│ │
|
||||||
┌──────────────────▼──────────────────┐ ┌──────────▼───────────────────┐
|
┌──────────────────▼──────────────────┐ ┌──────────▼───────────────────┐
|
||||||
@@ -35,8 +36,15 @@ The PFM platform is constructed using **Clean Architecture** and **Domain-Driven
|
|||||||
│ PostFundManagement.Domain │ │ PostFundManagement.Infrastr.│
|
│ PostFundManagement.Domain │ │ PostFundManagement.Infrastr.│
|
||||||
│ Entities, Value Objects, Enums, │ │ PostgreSQL (EF Core 10), │
|
│ Entities, Value Objects, Enums, │ │ PostgreSQL (EF Core 10), │
|
||||||
│ Domain Invariants, Audit Models │ │ Skoruba STS, S3 Garage, │
|
│ Domain Invariants, Audit Models │ │ Skoruba STS, S3 Garage, │
|
||||||
└─────────────────────────────────────┘ │ Redis HybridCache │
|
└─────────────────────────────────────┘ │ Redis, Quartz (pfm-scheduler)│
|
||||||
└──────────────────────────────┘
|
└──────────────┬───────────────┘
|
||||||
|
│
|
||||||
|
┌────────────────────────────────────────────────────────▼───────────────┐
|
||||||
|
│ BACKGROUND JOBS WORKER HOST │
|
||||||
|
│ PostFundManagement.Jobs (.NET 10 Background Host / Quartz Runner) │
|
||||||
|
│ Recurring Job Registration (JobsWorker) & Mediated Execution Engine │
|
||||||
|
│ Quartz Runner: Full Execution (DefaultThreadPool MaxConcurrency = 5) │
|
||||||
|
└────────────────────────────────────────────────────────────────────────┘
|
||||||
```
|
```
|
||||||
|
|
||||||
---
|
---
|
||||||
@@ -113,8 +121,8 @@ Even if an `Executive`, `Manager`, or `Supervisor` possesses the `update` claim
|
|||||||
flowchart LR
|
flowchart LR
|
||||||
A["Maker (User A)<br/>Creates Record"] --> B["Record Persisted<br/>with createdBy = User A"]
|
A["Maker (User A)<br/>Creates Record"] --> B["Record Persisted<br/>with createdBy = User A"]
|
||||||
B --> C{"Checker Evaluation<br/>(Approver Login)"}
|
B --> C{"Checker Evaluation<br/>(Approver Login)"}
|
||||||
C -->|Checker == User A| D["Approval BLOCKED<br/>Maker-Checker Violation"]
|
C -->|"Checker is User A"| D["Approval BLOCKED: Maker-Checker Violation"]
|
||||||
C -->|Checker == User B<br/>with 'update' claim| E["Approval GRANTED<br/>Status -> Approved"]
|
C -->|"Checker is User B with update claim"| E["Approval GRANTED: Status Approved"]
|
||||||
```
|
```
|
||||||
|
|
||||||
### 4.2 Record Attribution Model
|
### 4.2 Record Attribution Model
|
||||||
@@ -189,4 +197,122 @@ Following the introduction of fine-grained RBAC and Maker-Checker checks, startu
|
|||||||
* **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.
|
* **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.
|
* **In-Memory User Sync & Lockout Caching**: `isUserSyncedInBrowser` and `verifyTenantLockout` maintain in-memory references to eliminate synchronous `JSON.parse` operations from the request pipeline.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 6. Distributed Background Job Processing Architecture (PostFundManagement.Jobs)
|
||||||
|
|
||||||
|
The PFM platform decouples synchronous API request handling from asynchronous, long-running, and scheduled workloads using a distributed **Quartz.NET (v4.6+)** clustering topology backed by PostgreSQL (`pfm-scheduler`).
|
||||||
|
|
||||||
|
### 6.1 Split-Node Topology: Scheduler Client vs. Execution Host
|
||||||
|
|
||||||
|
To maintain high API throughput, low memory footprint, and complete process isolation, PFM implements a split-node architecture between `PostFundManagement.Api` and `PostFundManagement.Jobs`:
|
||||||
|
|
||||||
|
```
|
||||||
|
┌──────────────────────────────────────────────┐
|
||||||
|
│ PostFundManagement.Api │
|
||||||
|
│ (API Gateway / Scheduler Client) │
|
||||||
|
│ │
|
||||||
|
│ * AddQuartzSchedulerClient() │
|
||||||
|
│ * ZeroSizeThreadPool (0 execution threads) │
|
||||||
|
│ * SchedulerStatus.Created (Standby mode) │
|
||||||
|
│ * Strictly schedules / enqueues events │
|
||||||
|
│ * Never acquires triggers or executes jobs │
|
||||||
|
└──────────────────────┬───────────────────────┘
|
||||||
|
│ Schedules IEvent jobs via IJobOrchestrator
|
||||||
|
▼
|
||||||
|
┌──────────────────────────────────────────────┐
|
||||||
|
│ PostgreSQL JobStore │
|
||||||
|
│ (pfm-scheduler) │
|
||||||
|
│ │
|
||||||
|
│ * Clustered tables (QRTZ_TRIGGERS, etc.) │
|
||||||
|
│ * System.Text.Json serialized IEvent payload│
|
||||||
|
│ * Distributed locking & check-in heartbeat │
|
||||||
|
└──────────────────────▲───────────────────────┘
|
||||||
|
│
|
||||||
|
│ Acquires triggers & executes (Polling loop)
|
||||||
|
┌──────────────────────┴───────────────────────┐
|
||||||
|
│ PostFundManagement.Jobs │
|
||||||
|
│ (Dedicated Worker Service) │
|
||||||
|
│ │
|
||||||
|
│ * AddQuartzScheduler() │
|
||||||
|
│ * DefaultThreadPool (MaxConcurrency = 5) │
|
||||||
|
│ * SchedulerStatus.Running (Started loop) │
|
||||||
|
│ * JobsWorker background service: │
|
||||||
|
│ - Registers recurring cron jobs on start │
|
||||||
|
│ - Enqueues one-time jobs when needed │
|
||||||
|
│ * Executes MediatorJob<TNotification> │
|
||||||
|
│ * Deserializes and publishes via IMediator │
|
||||||
|
└──────────────────────────────────────────────┘
|
||||||
|
```
|
||||||
|
|
||||||
|
| Characteristic | `PostFundManagement.Api` | `PostFundManagement.Jobs` |
|
||||||
|
| :--- | :--- | :--- |
|
||||||
|
| **Architectural Role** | API Gateway & Event Producer | Dedicated Execution Daemon & Consumer |
|
||||||
|
| **Registration Extension** | `AddQuartzSchedulerClient(...)` | `AddQuartzScheduler(...)` |
|
||||||
|
| **Thread Pool** | `ZeroSizeThreadPool` (0 threads) | `DefaultThreadPool` (`MaxConcurrency = 5`) |
|
||||||
|
| **Hosted Service** | None (`AddQuartzHostedService` omitted) | `AddQuartzHostedService(WaitForJobsToComplete = true)` |
|
||||||
|
| **Lifecycle State** | `SchedulerStatus.Created` (Standby) | `SchedulerStatus.Running` (Active execution loop) |
|
||||||
|
| **Readiness Check** | Verifies DB connectivity without requiring `Running` | Verifies active execution engine & DB connectivity |
|
||||||
|
| **Trigger Acquisition** | ❌ **Disabled** (Cannot acquire triggers or run jobs) | ✅ **Active** (Polls DB, acquires triggers, runs jobs) |
|
||||||
|
|
||||||
|
### 6.2 Event-Driven Mediated Orchestration (`IEvent` & `MediatorJob`)
|
||||||
|
|
||||||
|
Background tasks are modeled as strongly-typed domain events implementing `IEvent : INotification` (`PostFundManagement.Domain.Abstractions`).
|
||||||
|
|
||||||
|
```mermaid
|
||||||
|
sequenceDiagram
|
||||||
|
autonumber
|
||||||
|
participant API as PostFundManagement.Api
|
||||||
|
participant Orchestrator as IJobOrchestrator
|
||||||
|
participant DB as PostgreSQL pfm-scheduler
|
||||||
|
participant Host as PostFundManagement.Jobs
|
||||||
|
participant QuartzThread as Quartz Execution Engine
|
||||||
|
participant Mediator as IMediator MediatorJob
|
||||||
|
participant Handler as Domain Event Handlers
|
||||||
|
|
||||||
|
Note over API,Orchestrator: 1. Event Scheduling (API Node)
|
||||||
|
API->>Orchestrator: SendAsync(notification) / ScheduleAsync(notification, cron)
|
||||||
|
Orchestrator->>DB: Stores JobDetail and Trigger with serialized Payload JSON
|
||||||
|
Note over DB,Host: 2. Distributed Acquisition (Jobs Node)
|
||||||
|
QuartzThread->>DB: Polls and acquires due trigger with row-level locks
|
||||||
|
QuartzThread->>Host: Dispatches to local thread pool
|
||||||
|
Host->>Mediator: Instantiates MediatorJob[TNotification]
|
||||||
|
Mediator->>Mediator: Deserializes payload from MergedJobDataMap
|
||||||
|
Mediator->>Handler: Publish(notification, cancellationToken)
|
||||||
|
Handler-->>Host: Execution complete and updates business data in pfm DB
|
||||||
|
Host->>DB: Releases trigger and commits execution state
|
||||||
|
```
|
||||||
|
|
||||||
|
1. **Scheduling via `IJobOrchestrator`**:
|
||||||
|
- `SendAsync<TNotification>(notification)`: Schedules a durable, one-time execution in the `onetime-jobs` group with `StartNow()` and `DisallowConcurrentExecution()`.
|
||||||
|
- `ScheduleAsync<TNotification>(notification, cronExpression)`: Configures a durable, cron-driven recurring job in the `scheduled-jobs` group with automatic misfire handling (`CronTriggerMisfireInstruction.IgnoreMisfires`).
|
||||||
|
- `InterruptAsync(eventName, correlationId)`: Allows runtime cancellation of active job executions across the cluster.
|
||||||
|
2. **Transparent Deserialization & Dispatch (`MediatorJob<TNotification>`)**:
|
||||||
|
`MediatorJob<TNotification>` resolves `IMediator` from DI, deserializes the JSON payload, initiates OpenTelemetry distributed tracing (`MediatorTelemetry.Source`), and publishes the event to all registered in-process handlers.
|
||||||
|
|
||||||
|
### 6.3 Recurring vs. One-Time Job Patterns
|
||||||
|
|
||||||
|
* **Recurring Jobs**: Scheduled by `JobsWorker` (`BackgroundService` in `PostFundManagement.Jobs`) during application startup. These jobs govern periodic health sweeps, SLA tracking, notification batching, and risk evaluations on predictable cron intervals.
|
||||||
|
* **One-Time Jobs**: Primarily scheduled by `PostFundManagement.Api` as immediate or deferred side-effects of HTTP requests and lifecycle mutations (e.g. user invites, award registration triggers). Furthermore, a running recurring job in `PostFundManagement.Jobs` can dispatch a one-time job (`jobOrchestrator.SendAsync`) when handling fan-out tasks or invoking decoupled activities that cannot be triggered directly by the originating job.
|
||||||
|
|
||||||
|
### 6.4 Core Background Processing Capabilities
|
||||||
|
|
||||||
|
The background job runner powers four essential operational workloads:
|
||||||
|
|
||||||
|
1. **Automated Project Monitoring & Reminders**:
|
||||||
|
- Sweeps active projects, contractual deliverables, and milestone deadlines.
|
||||||
|
- Computes planned vs. actual progress variance and escalates overdue milestones (`FR-M04-017`, `FR-M07-009`).
|
||||||
|
- Dispatches automated reminders to Grantees/Partners prior to milestone due dates and submission cut-offs.
|
||||||
|
2. **Risk Monitoring & Automated Notifications**:
|
||||||
|
- Evaluates project risk registers, Key Risk Indicators (KRIs), and issue escalation thresholds (`FR-M09-001` - `011`).
|
||||||
|
- Detects risk score creep (Likelihood $\times$ Impact) and triggers automated alerts when risk tolerance thresholds are breached.
|
||||||
|
- Monitors overdue mitigation action plans and escalates unresolved high-severity risks to Programme Managers.
|
||||||
|
3. **Email Notification Processing**:
|
||||||
|
- Continuously consumes and batches pending notification records (`Platform.Email` / `NotificationPlatform.Email`).
|
||||||
|
- Dispatches transactional emails via MailKit / MimeKit SMTP with exponential backoff and retry policies.
|
||||||
|
- Updates delivery status (`Sent`, `Failed`, retry count) and logs dispatch telemetry for audit compliance (`FR-M25-001`).
|
||||||
|
4. **Platform Extensibility**:
|
||||||
|
- New background jobs and recurring routines can be introduced by creating an `IEvent` model in `PostFundManagement.Application` and scheduling it via `JobsWorker` or `IJobOrchestrator`, with zero impact on the API surface.
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -44,6 +44,7 @@
|
|||||||
<Using Include="Microsoft.Extensions.Logging" />
|
<Using Include="Microsoft.Extensions.Logging" />
|
||||||
<Using Include="Microsoft.Extensions.Options" />
|
<Using Include="Microsoft.Extensions.Options" />
|
||||||
<Using Include="Microsoft.IdentityModel.Tokens" />
|
<Using Include="Microsoft.IdentityModel.Tokens" />
|
||||||
|
<Using Include="Microsoft.FeatureManagement" />
|
||||||
|
|
||||||
<!-- Domain Constants -->
|
<!-- Domain Constants -->
|
||||||
<Using Static="true" Include="PostFundManagement.Domain.Extensions.Constants" />
|
<Using Static="true" Include="PostFundManagement.Domain.Extensions.Constants" />
|
||||||
|
|||||||
+56
-55
@@ -12,67 +12,68 @@
|
|||||||
<PackageVersion Include="Scrutor" Version="7.0.0" />
|
<PackageVersion Include="Scrutor" Version="7.0.0" />
|
||||||
|
|
||||||
<!-- Microsoft Extensions & Configuration -->
|
<!-- Microsoft Extensions & Configuration -->
|
||||||
<PackageVersion Include="Microsoft.Extensions.Caching.Hybrid" Version="10.10.0" />
|
<PackageVersion Include="Microsoft.Extensions.Caching.Hybrid" Version="10.11.0" />
|
||||||
<PackageVersion Include="Microsoft.Extensions.Caching.StackExchangeRedis" Version="10.0.12" />
|
<PackageVersion Include="Microsoft.Extensions.Caching.StackExchangeRedis" Version="10.0.14" />
|
||||||
<PackageVersion Include="Microsoft.Extensions.Configuration" Version="10.0.12" />
|
<PackageVersion Include="Microsoft.Extensions.Configuration" Version="10.0.14" />
|
||||||
<PackageVersion Include="Microsoft.Extensions.Configuration.Abstractions" Version="10.0.12" />
|
<PackageVersion Include="Microsoft.Extensions.Configuration.Abstractions" Version="10.0.14" />
|
||||||
<PackageVersion Include="Microsoft.Extensions.Configuration.EnvironmentVariables" Version="10.0.12" />
|
<PackageVersion Include="Microsoft.Extensions.Configuration.EnvironmentVariables" Version="10.0.14" />
|
||||||
<PackageVersion Include="Microsoft.Extensions.Configuration.Json" Version="10.0.12" />
|
<PackageVersion Include="Microsoft.Extensions.Configuration.Json" Version="10.0.14" />
|
||||||
<PackageVersion Include="Microsoft.Extensions.Configuration.UserSecrets" Version="10.0.12" />
|
<PackageVersion Include="Microsoft.Extensions.Configuration.UserSecrets" Version="10.0.14" />
|
||||||
<PackageVersion Include="Microsoft.Extensions.DependencyInjection" Version="10.0.12" />
|
<PackageVersion Include="Microsoft.Extensions.DependencyInjection" Version="10.0.14" />
|
||||||
<PackageVersion Include="Microsoft.Extensions.Diagnostics.HealthChecks" Version="10.0.12" />
|
<PackageVersion Include="Microsoft.Extensions.Diagnostics.HealthChecks" Version="10.0.14" />
|
||||||
<PackageVersion Include="Microsoft.Extensions.Http.Polly" Version="10.0.12" />
|
<PackageVersion Include="Microsoft.Extensions.Http.Polly" Version="10.0.14" />
|
||||||
<PackageVersion Include="Microsoft.Extensions.Http.Resilience" Version="10.10.0" />
|
<PackageVersion Include="Microsoft.Extensions.Http.Resilience" Version="10.11.0" />
|
||||||
<PackageVersion Include="Microsoft.Extensions.Validation" Version="10.0.12" />
|
<PackageVersion Include="Microsoft.Extensions.Validation" Version="10.0.14" />
|
||||||
<PackageVersion Include="Microsoft.FeatureManagement.AspNetCore" Version="4.7.0" />
|
<PackageVersion Include="Microsoft.FeatureManagement.AspNetCore" Version="4.8.0" />
|
||||||
|
<PackageVersion Include="Microsoft.Extensions.Hosting" Version="10.0.14" />
|
||||||
|
|
||||||
<!-- Security, Authentication & Identity -->
|
<!-- Security, Authentication & Identity -->
|
||||||
<PackageVersion Include="AccessTokenClient.Extensions" Version="5.1.0" />
|
<PackageVersion Include="AccessTokenClient.Extensions" Version="5.1.0" />
|
||||||
<PackageVersion Include="Duende.AccessTokenManagement" Version="4.2.0" />
|
<PackageVersion Include="Duende.AccessTokenManagement" Version="4.3.0" />
|
||||||
<PackageVersion Include="Duende.AccessTokenManagement.OpenIdConnect" Version="4.2.0" />
|
<PackageVersion Include="Duende.AccessTokenManagement.OpenIdConnect" Version="4.3.0" />
|
||||||
<PackageVersion Include="IdentityModel" Version="6.2.0" />
|
<PackageVersion Include="IdentityModel" Version="6.2.0" />
|
||||||
<PackageVersion Include="IdentityModel.AspNetCore" Version="4.3.0" />
|
<PackageVersion Include="IdentityModel.AspNetCore" Version="4.3.0" />
|
||||||
<PackageVersion Include="IdentityModel.AspNetCore.OAuth2introspection" Version="6.2.0" />
|
<PackageVersion Include="IdentityModel.AspNetCore.OAuth2introspection" Version="6.2.0" />
|
||||||
<PackageVersion Include="IdentityServer4.AccessTokenValidation" Version="3.0.1" />
|
<PackageVersion Include="IdentityServer4.AccessTokenValidation" Version="3.0.1" />
|
||||||
<PackageVersion Include="KubernetesClient" Version="19.0.2" />
|
<PackageVersion Include="KubernetesClient" Version="21.0.8" />
|
||||||
<PackageVersion Include="Microsoft.AspNetCore.Authentication.Certificate" Version="10.0.12" />
|
<PackageVersion Include="Microsoft.AspNetCore.Authentication.Certificate" Version="10.0.14" />
|
||||||
<PackageVersion Include="Microsoft.AspNetCore.Authentication.JwtBearer" Version="10.0.12" />
|
<PackageVersion Include="Microsoft.AspNetCore.Authentication.JwtBearer" Version="10.0.14" />
|
||||||
<PackageVersion Include="Microsoft.AspNetCore.DataProtection.EntityFrameworkCore" Version="10.0.12" />
|
<PackageVersion Include="Microsoft.AspNetCore.DataProtection.EntityFrameworkCore" Version="10.0.14" />
|
||||||
|
|
||||||
<!-- Upgraded to latest non-vulnerable version -->
|
<!-- Upgraded to latest non-vulnerable version -->
|
||||||
<PackageVersion Include="System.Security.Cryptography.Pkcs" Version="10.0.13" />
|
<PackageVersion Include="System.Security.Cryptography.Pkcs" Version="10.0.14" />
|
||||||
<PackageVersion Include="System.Security.Cryptography.Xml" Version="10.0.13" />
|
<PackageVersion Include="System.Security.Cryptography.Xml" Version="10.0.14" />
|
||||||
|
|
||||||
<!-- Resilience -->
|
<!-- Resilience -->
|
||||||
<PackageVersion Include="Polly" Version="8.8.0" />
|
<PackageVersion Include="Polly" Version="8.8.1" />
|
||||||
<PackageVersion Include="Polly.Extensions" Version="8.8.0" />
|
<PackageVersion Include="Polly.Extensions" Version="8.8.1" />
|
||||||
|
|
||||||
<!-- API, Web & Documentation -->
|
<!-- API, Web & Documentation -->
|
||||||
<PackageVersion Include="Asp.Versioning.Abstractions" Version="10.2.1" />
|
<PackageVersion Include="Asp.Versioning.Abstractions" Version="10.2.1" />
|
||||||
<PackageVersion Include="Asp.Versioning.Http" Version="10.2.3" />
|
<PackageVersion Include="Asp.Versioning.Http" Version="10.2.3" />
|
||||||
<PackageVersion Include="Asp.Versioning.Mvc.ApiExplorer" Version="10.2.1" />
|
<PackageVersion Include="Asp.Versioning.Mvc.ApiExplorer" Version="10.2.1" />
|
||||||
<PackageVersion Include="Microsoft.AspNetCore.OpenApi" Version="10.0.12" />
|
<PackageVersion Include="Microsoft.AspNetCore.OpenApi" Version="10.0.14" />
|
||||||
<PackageVersion Include="Microsoft.AspNetCore.OutputCaching.StackExchangeRedis" Version="10.0.12" />
|
<PackageVersion Include="Microsoft.AspNetCore.OutputCaching.StackExchangeRedis" Version="10.0.14" />
|
||||||
<PackageVersion Include="Refit.HttpClientFactory" Version="15.2.0" />
|
<PackageVersion Include="Refit.HttpClientFactory" Version="16.4.0" />
|
||||||
<PackageVersion Include="Scalar.AspNetCore" Version="2.17.4" />
|
<PackageVersion Include="Scalar.AspNetCore" Version="2.18.3" />
|
||||||
|
|
||||||
<!-- Database & Entity Framework Core -->
|
<!-- Database & Entity Framework Core -->
|
||||||
<PackageVersion Include="Microsoft.EntityFrameworkCore" Version="10.0.12" />
|
<PackageVersion Include="Microsoft.EntityFrameworkCore" Version="10.0.14" />
|
||||||
<PackageVersion Include="Microsoft.EntityFrameworkCore.Abstractions" Version="10.0.12" />
|
<PackageVersion Include="Microsoft.EntityFrameworkCore.Abstractions" Version="10.0.14" />
|
||||||
<PackageVersion Include="Microsoft.EntityFrameworkCore.Analyzers" Version="10.0.12" />
|
<PackageVersion Include="Microsoft.EntityFrameworkCore.Analyzers" Version="10.0.14" />
|
||||||
<PackageVersion Include="Microsoft.EntityFrameworkCore.Design" Version="10.0.12" />
|
<PackageVersion Include="Microsoft.EntityFrameworkCore.Design" Version="10.0.14" />
|
||||||
<PackageVersion Include="Microsoft.EntityFrameworkCore.InMemory" Version="10.0.12" />
|
<PackageVersion Include="Microsoft.EntityFrameworkCore.InMemory" Version="10.0.14" />
|
||||||
<PackageVersion Include="Microsoft.EntityFrameworkCore.Relational" Version="10.0.12" />
|
<PackageVersion Include="Microsoft.EntityFrameworkCore.Relational" Version="10.0.14" />
|
||||||
<PackageVersion Include="Microsoft.EntityFrameworkCore.Tools" Version="10.0.12" />
|
<PackageVersion Include="Microsoft.EntityFrameworkCore.Tools" Version="10.0.14" />
|
||||||
<PackageVersion Include="Npgsql.EntityFrameworkCore.PostgreSQL" Version="10.0.3" />
|
<PackageVersion Include="Npgsql.EntityFrameworkCore.PostgreSQL" Version="10.0.3" />
|
||||||
|
|
||||||
<!-- Quartz Scheduler -->
|
<!-- Quartz Scheduler -->
|
||||||
<PackageVersion Include="Quartz" Version="4.1.0" />
|
<PackageVersion Include="Quartz" Version="4.6.2" />
|
||||||
<PackageVersion Include="Quartz.AspNetCore" Version="4.1.0" />
|
<PackageVersion Include="Quartz.AspNetCore" Version="4.6.2" />
|
||||||
<PackageVersion Include="Quartz.Extensions.DependencyInjection" Version="4.1.0" />
|
<PackageVersion Include="Quartz.Extensions.DependencyInjection" Version="4.6.2" />
|
||||||
<PackageVersion Include="Quartz.Plugins" Version="4.1.0" />
|
<PackageVersion Include="Quartz.Plugins" Version="4.6.2" />
|
||||||
<PackageVersion Include="Quartz.Plugins.TimeZoneConverter" Version="4.1.0" />
|
<PackageVersion Include="Quartz.Plugins.TimeZoneConverter" Version="4.6.2" />
|
||||||
<PackageVersion Include="Quartz.Serialization.SystemTextJson" Version="4.1.0" />
|
<PackageVersion Include="Quartz.Serialization.SystemTextJson" Version="4.6.2" />
|
||||||
|
|
||||||
<!-- Health Checks -->
|
<!-- Health Checks -->
|
||||||
<PackageVersion Include="AspNetCore.HealthChecks.NpgSql" Version="9.0.0" />
|
<PackageVersion Include="AspNetCore.HealthChecks.NpgSql" Version="9.0.0" />
|
||||||
@@ -83,17 +84,17 @@
|
|||||||
<PackageVersion Include="AspNetCore.HealthChecks.UI.InMemory.Storage" Version="9.0.0" />
|
<PackageVersion Include="AspNetCore.HealthChecks.UI.InMemory.Storage" Version="9.0.0" />
|
||||||
|
|
||||||
<!-- Observability & OpenTelemetry -->
|
<!-- Observability & OpenTelemetry -->
|
||||||
<PackageVersion Include="OpenTelemetry" Version="1.18.0" />
|
<PackageVersion Include="OpenTelemetry" Version="1.20.1" />
|
||||||
<PackageVersion Include="OpenTelemetry.Exporter.Console" Version="1.18.0" />
|
<PackageVersion Include="OpenTelemetry.Exporter.Console" Version="1.20.1" />
|
||||||
<PackageVersion Include="OpenTelemetry.Exporter.OpenTelemetryProtocol" Version="1.18.0" />
|
<PackageVersion Include="OpenTelemetry.Exporter.OpenTelemetryProtocol" Version="1.20.1" />
|
||||||
<PackageVersion Include="OpenTelemetry.Extensions.Hosting" Version="1.18.0" />
|
<PackageVersion Include="OpenTelemetry.Extensions.Hosting" Version="1.20.1" />
|
||||||
<PackageVersion Include="OpenTelemetry.Instrumentation.AspNetCore" Version="1.18.0" />
|
<PackageVersion Include="OpenTelemetry.Instrumentation.AspNetCore" Version="1.20.1" />
|
||||||
<PackageVersion Include="OpenTelemetry.Instrumentation.Http" Version="1.18.0" />
|
<PackageVersion Include="OpenTelemetry.Instrumentation.Http" Version="1.20.1" />
|
||||||
<PackageVersion Include="OpenTelemetry.Instrumentation.Runtime" Version="1.18.0" />
|
<PackageVersion Include="OpenTelemetry.Instrumentation.Runtime" Version="1.20.1" />
|
||||||
|
|
||||||
<!-- Email & Messaging -->
|
<!-- Email & Messaging -->
|
||||||
<PackageVersion Include="MailKit" Version="4.18.0" />
|
<PackageVersion Include="MailKit" Version="4.18.2" />
|
||||||
<PackageVersion Include="MimeKit" Version="4.18.0" />
|
<PackageVersion Include="MimeKit" Version="4.18.2" />
|
||||||
|
|
||||||
<!-- CQRS & Mediator -->
|
<!-- CQRS & Mediator -->
|
||||||
<PackageVersion Include="FluentResults" Version="4.0.0" />
|
<PackageVersion Include="FluentResults" Version="4.0.0" />
|
||||||
@@ -101,17 +102,17 @@
|
|||||||
<PackageVersion Include="Mediator.SourceGenerator" Version="3.0.2" />
|
<PackageVersion Include="Mediator.SourceGenerator" Version="3.0.2" />
|
||||||
|
|
||||||
<!-- Cloud Storage -->
|
<!-- Cloud Storage -->
|
||||||
<PackageVersion Include="AWSSDK.Extensions.NetCore.Setup" Version="4.0.101.4" />
|
<PackageVersion Include="AWSSDK.Extensions.NetCore.Setup" Version="4.0.102.7" />
|
||||||
<PackageVersion Include="AWSSDK.S3" Version="4.0.103.3" />
|
<PackageVersion Include="AWSSDK.S3" Version="4.0.104.7" />
|
||||||
|
|
||||||
<!-- Analyzers -->
|
<!-- Analyzers -->
|
||||||
<PackageVersion Include="Meziantou.Analyzer" Version="3.0.259" />
|
<PackageVersion Include="Meziantou.Analyzer" Version="3.0.317" />
|
||||||
|
|
||||||
<!-- Testing -->
|
<!-- Testing -->
|
||||||
<PackageVersion Include="coverlet.collector" Version="6.0.4" />
|
<PackageVersion Include="coverlet.collector" Version="10.1.0" />
|
||||||
<PackageVersion Include="Microsoft.NET.Test.Sdk" Version="17.14.1" />
|
<PackageVersion Include="Microsoft.NET.Test.Sdk" Version="18.11.0" />
|
||||||
<PackageVersion Include="xunit" Version="2.9.3" />
|
<PackageVersion Include="xunit" Version="2.9.3" />
|
||||||
<PackageVersion Include="xunit.runner.visualstudio" Version="3.1.4" />
|
<PackageVersion Include="xunit.runner.visualstudio" Version="4.0.1" />
|
||||||
</ItemGroup>
|
</ItemGroup>
|
||||||
</Project>
|
</Project>
|
||||||
|
|
||||||
|
|||||||
@@ -71,6 +71,7 @@ PostFundManagement/
|
|||||||
├── PostFundManagement.Infrastructure/ # EF Core 10, PostgreSQL (Npgsql), Migrations, Data Protection
|
├── PostFundManagement.Infrastructure/ # EF Core 10, PostgreSQL (Npgsql), Migrations, Data Protection
|
||||||
├── PostFundManagement.Application/ # CQRS Handlers, Mediator, DTOs, Validations, S3, Email, Quartz
|
├── PostFundManagement.Application/ # CQRS Handlers, Mediator, DTOs, Validations, S3, Email, Quartz
|
||||||
├── PostFundManagement.Api/ # Minimal APIs / Carter endpoints, Auth, Swagger/Scalar, Versioning
|
├── PostFundManagement.Api/ # Minimal APIs / Carter endpoints, Auth, Swagger/Scalar, Versioning
|
||||||
|
├── PostFundManagement.Jobs/ # Dedicated Quartz.NET Background Worker Host (Recurring & One-Time Execution)
|
||||||
├── PostFundManagement.Tests/ # Unit & Integration test suite (xUnit, Coverlet)
|
├── PostFundManagement.Tests/ # Unit & Integration test suite (xUnit, Coverlet)
|
||||||
├── PostFundManagement.ConsoleUI/ # Internal Staff & Admin Web Portal (React 19 + Vite 6 + Tailwind 4)
|
├── PostFundManagement.ConsoleUI/ # Internal Staff & Admin Web Portal (React 19 + Vite 6 + Tailwind 4)
|
||||||
├── PostFundManagement.PartnerUI/ # External Grantee / Partner Portal (React 19 + Vite 8 + Tailwind 4)
|
├── PostFundManagement.PartnerUI/ # External Grantee / Partner Portal (React 19 + Vite 8 + Tailwind 4)
|
||||||
@@ -85,6 +86,7 @@ PostFundManagement/
|
|||||||
| **`PostFundManagement.Infrastructure`** | `net10.0` | `Microsoft.EntityFrameworkCore` 10.0.12, `Npgsql.EntityFrameworkCore.PostgreSQL` 10.0.3, `Duende.AccessTokenManagement` 4.2.0, EF Core Tools & Design | Houses `PostFundManagementDbContext`, EF Core fluent configurations, entity-to-table mappings, PostgreSQL schema definitions, multitenancy filters, and migrations. |
|
| **`PostFundManagement.Infrastructure`** | `net10.0` | `Microsoft.EntityFrameworkCore` 10.0.12, `Npgsql.EntityFrameworkCore.PostgreSQL` 10.0.3, `Duende.AccessTokenManagement` 4.2.0, EF Core Tools & Design | Houses `PostFundManagementDbContext`, EF Core fluent configurations, entity-to-table mappings, PostgreSQL schema definitions, multitenancy filters, and migrations. |
|
||||||
| **`PostFundManagement.Application`** | `net10.0` | `Mediator.SourceGenerator` / `Mediator.Abstractions` 3.0.2, `FluentResults` 4.0.0, `Microsoft.Extensions.Caching.Hybrid` 10.10.0, `Microsoft.Extensions.Caching.StackExchangeRedis` 10.0.12, `Refit` 15.2.0, `Duende.AccessTokenManagement.OpenIdConnect` 4.2.0, `Quartz` 4.1.0, `AWSSDK.S3` | Implements CQRS command/query handlers, domain validators, S3 object storage clients (Garage S3), background jobs (Quartz), notification dispatchers, and business logic. |
|
| **`PostFundManagement.Application`** | `net10.0` | `Mediator.SourceGenerator` / `Mediator.Abstractions` 3.0.2, `FluentResults` 4.0.0, `Microsoft.Extensions.Caching.Hybrid` 10.10.0, `Microsoft.Extensions.Caching.StackExchangeRedis` 10.0.12, `Refit` 15.2.0, `Duende.AccessTokenManagement.OpenIdConnect` 4.2.0, `Quartz` 4.1.0, `AWSSDK.S3` | Implements CQRS command/query handlers, domain validators, S3 object storage clients (Garage S3), background jobs (Quartz), notification dispatchers, and business logic. |
|
||||||
| **`PostFundManagement.Api`** | `net10.0` | `Microsoft.AspNetCore.OpenApi` 10.0.12, `Scalar.AspNetCore` 2.17.4, `Asp.Versioning.Http` 10.2.3, `IdentityModel.AspNetCore` 4.3.0, `Microsoft.AspNetCore.Authentication.JwtBearer` 10.0.12, `AspNetCore.HealthChecks.NpgSql` 9.0.0, `AspNetCore.HealthChecks.UI` | Minimal API endpoint definitions divided by business slices (`Grants`, `Projects`, `Execution`, `Governance`, `Evidence`, `Communications`, `Users`). Handles routing, auth policies, error problem details, and OpenAPI metadata. |
|
| **`PostFundManagement.Api`** | `net10.0` | `Microsoft.AspNetCore.OpenApi` 10.0.12, `Scalar.AspNetCore` 2.17.4, `Asp.Versioning.Http` 10.2.3, `IdentityModel.AspNetCore` 4.3.0, `Microsoft.AspNetCore.Authentication.JwtBearer` 10.0.12, `AspNetCore.HealthChecks.NpgSql` 9.0.0, `AspNetCore.HealthChecks.UI` | Minimal API endpoint definitions divided by business slices (`Grants`, `Projects`, `Execution`, `Governance`, `Evidence`, `Communications`, `Users`). Handles routing, auth policies, error problem details, and OpenAPI metadata. |
|
||||||
|
| **`PostFundManagement.Jobs`** | `net10.0` | `Quartz` 4.6.2, `Quartz.Extensions.DependencyInjection` 4.6.2, `Quartz.Serialization.SystemTextJson` 4.6.2, `Mediator.Abstractions` 3.0.2 | Dedicated background execution host. Executes recurring Quartz jobs (registered on startup by `JobsWorker`), processes asynchronous domain events (`IEvent`), and executes jobs concurrently (`MaxConcurrency = 5`). |
|
||||||
| **`PostFundManagement.Tests`** | `net10.0` | `xunit` 2.9.3, `xunit.runner.visualstudio` 3.1.4, `Microsoft.NET.Test.Sdk` 17.14.1, `coverlet.collector` 6.0.4 | Unit and integration test coverage for domain rules, CQRS handlers, financial calculation models, and gate condition validations. |
|
| **`PostFundManagement.Tests`** | `net10.0` | `xunit` 2.9.3, `xunit.runner.visualstudio` 3.1.4, `Microsoft.NET.Test.Sdk` 17.14.1, `coverlet.collector` 6.0.4 | Unit and integration test coverage for domain rules, CQRS handlers, financial calculation models, and gate condition validations. |
|
||||||
|
|
||||||
### 2.2 Frontend Projects (React 19 / TypeScript)
|
### 2.2 Frontend Projects (React 19 / TypeScript)
|
||||||
@@ -120,6 +122,22 @@ PostFundManagement/
|
|||||||
* **Authentication / STS**: Duende IdentityServer / Skoruba Administration (`https://sts.security.khongisa.co.za`).
|
* **Authentication / STS**: Duende IdentityServer / Skoruba Administration (`https://sts.security.khongisa.co.za`).
|
||||||
* **Observability**: OpenTelemetry exporters streaming traces, metrics, and logs to Aspire Dashboard / OTLP collectors.
|
* **Observability**: OpenTelemetry exporters streaming traces, metrics, and logs to Aspire Dashboard / OTLP collectors.
|
||||||
|
|
||||||
|
### 2.4 Distributed Background Processing Architecture (Quartz.NET & PostFundManagement.Jobs)
|
||||||
|
* **Dedicated Execution Daemon**: `PostFundManagement.Jobs` is a standalone background host responsible for recurring job scheduling, lifecycle sweeps, and asynchronous event execution.
|
||||||
|
* **API Isolation (Scheduler Client)**: `PostFundManagement.Api` is strictly configured as a scheduler client via `AddQuartzSchedulerClient(...)` using `ZeroSizeThreadPool` (0 worker threads) and running in standby (`SchedulerStatus.Created`). The API enqueues jobs and triggers into the clustered PostgreSQL database (`pfm-scheduler`) but never acquires triggers or executes jobs locally.
|
||||||
|
* **Worker Execution Engine**: `PostFundManagement.Jobs` configures full Quartz execution via `AddQuartzScheduler(...)` using `DefaultThreadPool` (`MaxConcurrency = 5`) and `AddQuartzHostedService(options => options.WaitForJobsToComplete = true)`.
|
||||||
|
* **Event-Driven Orchestration (`IEvent` & `MediatorJob`)**:
|
||||||
|
- The API acts as the primary source of one-time events triggered by user actions and lifecycle transitions (implementing `IEvent : INotification`).
|
||||||
|
- Events are enqueued via `IJobOrchestrator.SendAsync` or `ScheduleAsync` and persisted in `pfm-scheduler` as `MediatorJob<TNotification>` with serialized JSON payloads.
|
||||||
|
- When acquired by `PostFundManagement.Jobs`, `MediatorJob` deserializes the event payload, propagates OpenTelemetry distributed traces, and publishes the event via `IMediator.Publish(...)`.
|
||||||
|
* **Startup Recurring Registration**: On daemon startup, `JobsWorker : BackgroundService` initializes and registers recurring cron jobs across tenant portfolios.
|
||||||
|
* **Cascading One-Time Execution**: If a recurring job identifies complex follow-up items that cannot be processed in the recurring execution window, it dispatches decoupled one-time jobs (`jobOrchestrator.SendAsync`) across worker threads.
|
||||||
|
* **Core Job Workloads**:
|
||||||
|
1. *Automated Project Monitoring & Reminders*: Sweeps milestones, checks contractual SLA deadlines, sets overdue flags (`FR-M07-009`), and sends reminder notices to Grantees/Partners.
|
||||||
|
2. *Risk Monitoring & Automated Notifications*: Evaluates risk score creep ($\text{Likelihood} \times \text{Impact}$), flags tolerance breaches, and sends escalation alerts for overdue mitigation actions.
|
||||||
|
3. *Email Notification Processing*: Batches and dispatches pending notification entries (`Platform.Email` / `NotificationPlatform.Email`) via MailKit/MimeKit SMTP and records delivery telemetry (`FR-M25-001`).
|
||||||
|
4. *Future Jobs*: Seamlessly supports future scheduled workloads (e.g. drawdown reconciliations, compliance expiries, portfolio aggregations) simply by adding `IEvent` handlers.
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## 3. Master Business Rules (BR-001 to BR-010)
|
## 3. Master Business Rules (BR-001 to BR-010)
|
||||||
@@ -848,7 +866,7 @@ The platform uses **Hashids** to obfuscate internal sequential database primary
|
|||||||
* **Implementation**: Managed by `HashService` (`PostFundManagement.Application/Services/Shared/HashService.cs`) using the `Hashids.net` (v1.7.0) library.
|
* **Implementation**: Managed by `HashService` (`PostFundManagement.Application/Services/Shared/HashService.cs`) using the `Hashids.net` (v1.7.0) library.
|
||||||
* **Shared Configuration (`HasherSettings`)**:
|
* **Shared Configuration (`HasherSettings`)**:
|
||||||
* **Salt**: Sourced from configuration (`HasherSettings:Salt` or `HASHER_SALT`), defaulting to:
|
* **Salt**: Sourced from configuration (`HasherSettings:Salt` or `HASHER_SALT`), defaulting to:
|
||||||
`"TGlnaHQgQ2hhcm1zLCBNaWRyYW5kQm9va3MgbWFrZSBsb3RzIG9mIG1vbmV5IGFuZCBhcmUgYSBzdWNjZXNzZnVsIHZpcmFsIHN0b3J5IGluIFNvdXRoIEFmcmljYQ=="`
|
`"salt-random-string"`
|
||||||
* **Min Hash Length**: `HasherSettings:MinHashLength` or `HASHER_MIN_HASH_LENGTH` (default: `11`).
|
* **Min Hash Length**: `HasherSettings:MinHashLength` or `HASHER_MIN_HASH_LENGTH` (default: `11`).
|
||||||
* **Core Service Methods**:
|
* **Core Service Methods**:
|
||||||
* `HashEncodeLongId(long id)` / `HashEncodeIntId(int id)`: Encodes integers into obfuscated HashID strings (e.g. `1` $\to$ `kY5aB9m...`).
|
* `HashEncodeLongId(long id)` / `HashEncodeIntId(int id)`: Encodes integers into obfuscated HashID strings (e.g. `1` $\to$ `kY5aB9m...`).
|
||||||
|
|||||||
@@ -11,7 +11,7 @@ public class GetProjectEvidenceEndpoint : IEndpoint
|
|||||||
{
|
{
|
||||||
public void Map(IEndpointRouteBuilder builder)
|
public void Map(IEndpointRouteBuilder builder)
|
||||||
{
|
{
|
||||||
builder.MapGet("api/evidence/{projectId}", async (string projectId, IEvidenceService service,
|
builder.MapGet("api/evidence/projects/{projectId}", async (string projectId, IEvidenceService service,
|
||||||
CancellationToken cancellationToken = default) =>
|
CancellationToken cancellationToken = default) =>
|
||||||
await service.GetProjectEvidencesAsync(projectId, cancellationToken) switch
|
await service.GetProjectEvidencesAsync(projectId, cancellationToken) switch
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -12,7 +12,14 @@ public sealed class CreateOutcomeEndpoint : IEndpoint
|
|||||||
{
|
{
|
||||||
public void Map(IEndpointRouteBuilder builder)
|
public void Map(IEndpointRouteBuilder builder)
|
||||||
{
|
{
|
||||||
builder.MapPost("api/projects/beneficiaries/outcomes", HandleCreateAsync)
|
builder.MapPost("api/projects/beneficiaries/outcomes", async (CreateOutcomeRequest request, HttpContext context,
|
||||||
|
IProjectService service, CancellationToken cancellationToken = default) =>
|
||||||
|
await service.CreateOutcomeAsync(request, context.GetUserId(), cancellationToken) switch
|
||||||
|
{
|
||||||
|
{ IsSuccess: true } => Results.Ok(),
|
||||||
|
var r when r.HasError<CreateError>() => Results.BadRequest(r.Errors.ToProblems()),
|
||||||
|
var r => Results.InternalServerError(r.Errors.ToProblems())
|
||||||
|
})
|
||||||
.WithDescription("Add a new beneficiary outcome")
|
.WithDescription("Add a new beneficiary outcome")
|
||||||
.RequireAuthorization(Constants.MainScope)
|
.RequireAuthorization(Constants.MainScope)
|
||||||
.WithName(typeof(CreateOutcomeEndpoint).ToEndpointName())
|
.WithName(typeof(CreateOutcomeEndpoint).ToEndpointName())
|
||||||
@@ -22,25 +29,5 @@ public sealed class CreateOutcomeEndpoint : IEndpoint
|
|||||||
.Produces<Problems>(StatusCodes.Status500InternalServerError)
|
.Produces<Problems>(StatusCodes.Status500InternalServerError)
|
||||||
.Produces(StatusCodes.Status401Unauthorized)
|
.Produces(StatusCodes.Status401Unauthorized)
|
||||||
.WithTags(EndpointTags.Projects);
|
.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.Application.Extensions;
|
||||||
using PostFundManagement.Domain.Abstractions;
|
using PostFundManagement.Domain.Abstractions;
|
||||||
using PostFundManagement.Domain.Errors;
|
using PostFundManagement.Domain.Errors;
|
||||||
@@ -13,22 +13,29 @@ public sealed class SetUserLockoutEndpoint : IEndpoint
|
|||||||
{
|
{
|
||||||
builder.MapPut("api/users/{userId:Guid}/session/lockout/{disable:bool}", async (Guid userId, bool disable, HttpContext context,
|
builder.MapPut("api/users/{userId:Guid}/session/lockout/{disable:bool}", async (Guid userId, bool disable, HttpContext context,
|
||||||
IUserService service, CancellationToken cancellationToken = default) =>
|
IUserService service, CancellationToken cancellationToken = default) =>
|
||||||
await service.SetUserLockoutAsync(userId, context.GetUserId(), disable, cancellationToken) switch
|
{
|
||||||
{
|
var (_, role) = context.User.GetUserRole();
|
||||||
{ IsSuccess: true } => Results.Ok(),
|
if (role != null && role.Equals("Partner", StringComparison.OrdinalIgnoreCase))
|
||||||
var r when r.HasError<NotFoundError>() => Results.NotFound(r.Errors.ToProblems()),
|
return Results.Forbid();
|
||||||
var r when r.HasError<UpdateError>() => Results.BadRequest(r.Errors.ToProblems()),
|
|
||||||
var r => Results.InternalServerError(r.Errors.ToProblems())
|
return await service.SetUserLockoutAsync(userId, context.GetUserId(), disable, cancellationToken) switch
|
||||||
})
|
{
|
||||||
.WithDescription("Lock user session")
|
{ IsSuccess: true } => Results.Ok(),
|
||||||
.RequireAuthorization(Constants.MainScope)
|
var r when r.HasError<NotFoundError>() => Results.NotFound(r.Errors.ToProblems()),
|
||||||
.WithName(typeof(SetUserLockoutEndpoint).ToEndpointName())
|
var r when r.HasError<UpdateError>() => Results.BadRequest(r.Errors.ToProblems()),
|
||||||
.MapToApiVersion(new ApiVersion(1))
|
var r => Results.InternalServerError(r.Errors.ToProblems())
|
||||||
.Produces(StatusCodes.Status200OK)
|
};
|
||||||
.Produces(StatusCodes.Status401Unauthorized)
|
})
|
||||||
.Produces<Problems>(StatusCodes.Status404NotFound)
|
.WithDescription("Lock user session")
|
||||||
.Produces<Problems>(StatusCodes.Status400BadRequest)
|
.RequireAuthorization(Constants.MainScope)
|
||||||
.Produces<Problems>(StatusCodes.Status500InternalServerError)
|
.WithName(typeof(SetUserLockoutEndpoint).ToEndpointName())
|
||||||
.WithTags(EndpointTags.Users);
|
.MapToApiVersion(new ApiVersion(1))
|
||||||
|
.Produces(StatusCodes.Status200OK)
|
||||||
|
.Produces(StatusCodes.Status401Unauthorized)
|
||||||
|
.Produces(StatusCodes.Status403Forbidden)
|
||||||
|
.Produces<Problems>(StatusCodes.Status404NotFound)
|
||||||
|
.Produces<Problems>(StatusCodes.Status400BadRequest)
|
||||||
|
.Produces<Problems>(StatusCodes.Status500InternalServerError)
|
||||||
|
.WithTags(EndpointTags.Users);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -11,8 +11,8 @@ public class SyncUserEndpoint : IEndpoint
|
|||||||
{
|
{
|
||||||
public void Map(IEndpointRouteBuilder builder)
|
public void Map(IEndpointRouteBuilder builder)
|
||||||
{
|
{
|
||||||
builder.MapPost("api/integration/sync", async (IJobOrchestrator jobOrchestrator,
|
builder.MapPost("api/integration/sync", async (HttpContext context, IUserService service,
|
||||||
HttpContext context, IUserService service, CancellationToken cancellationToken = default) =>
|
CancellationToken cancellationToken = default) =>
|
||||||
await service.SyncUserAsync(context.GetUserId(), context.GetUserEmail(), context.GetUserRole(), cancellationToken) switch
|
await service.SyncUserAsync(context.GetUserId(), context.GetUserEmail(), context.GetUserRole(), cancellationToken) switch
|
||||||
{
|
{
|
||||||
{ IsSuccess: true } => Results.Ok(),
|
{ IsSuccess: true } => Results.Ok(),
|
||||||
|
|||||||
@@ -4,7 +4,6 @@ using PostFundManagement.Domain.Abstractions;
|
|||||||
using PostFundManagement.Domain.Health;
|
using PostFundManagement.Domain.Health;
|
||||||
using PostFundManagement.Domain.Mediator;
|
using PostFundManagement.Domain.Mediator;
|
||||||
using PostFundManagement.Infrastructure.Database.Services;
|
using PostFundManagement.Infrastructure.Database.Services;
|
||||||
using static PostFundManagement.Domain.Extensions.Constants;
|
|
||||||
|
|
||||||
var builder = WebApplication.CreateBuilder(args);
|
var builder = WebApplication.CreateBuilder(args);
|
||||||
|
|
||||||
@@ -45,7 +44,7 @@ builder.Services.AddSkorubaApiService(builder.Configuration);
|
|||||||
builder.Services.AddScoped(typeof(IPipelineBehavior<,>), typeof(TelemetryPipelineBehavior<,>));
|
builder.Services.AddScoped(typeof(IPipelineBehavior<,>), typeof(TelemetryPipelineBehavior<,>));
|
||||||
builder.Services.AddScoped(typeof(IPipelineBehavior<,>), typeof(LoggingPipelineBehavior<,>));
|
builder.Services.AddScoped(typeof(IPipelineBehavior<,>), typeof(LoggingPipelineBehavior<,>));
|
||||||
|
|
||||||
builder.Services.AddQuartzScheduler(DefaultSchedulerName, builder.Configuration);
|
builder.Services.AddQuartzSchedulerClient(DefaultSchedulerName, builder.Configuration);
|
||||||
builder.Services.AddEmailServices(builder.Configuration);
|
builder.Services.AddEmailServices(builder.Configuration);
|
||||||
builder.Services.AddHashServices(builder.Configuration);
|
builder.Services.AddHashServices(builder.Configuration);
|
||||||
builder.Services.AddGarageS3(builder.Configuration);
|
builder.Services.AddGarageS3(builder.Configuration);
|
||||||
@@ -116,7 +115,29 @@ builder.Services.AddCors(options =>
|
|||||||
{
|
{
|
||||||
options.AddPolicy(CorsPolicy, policy =>
|
options.AddPolicy(CorsPolicy, policy =>
|
||||||
{
|
{
|
||||||
policy.WithOrigins(allowedOrigins)
|
policy.SetIsOriginAllowed(origin =>
|
||||||
|
{
|
||||||
|
if (string.IsNullOrWhiteSpace(origin)) return false;
|
||||||
|
|
||||||
|
var clean = origin.TrimEnd('/');
|
||||||
|
|
||||||
|
if (allowedOrigins.Any(a => string.Equals(a.TrimEnd('/'), clean, StringComparison.OrdinalIgnoreCase)))
|
||||||
|
return true;
|
||||||
|
|
||||||
|
try
|
||||||
|
{
|
||||||
|
var host = new Uri(origin).Host;
|
||||||
|
if (host.EndsWith("khongisa.co.za", StringComparison.OrdinalIgnoreCase) ||
|
||||||
|
host.Equals("localhost", StringComparison.OrdinalIgnoreCase) ||
|
||||||
|
host.Equals("127.0.0.1", StringComparison.OrdinalIgnoreCase))
|
||||||
|
{
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
catch { /* eat all exceptions */ }
|
||||||
|
|
||||||
|
return false;
|
||||||
|
})
|
||||||
.AllowAnyHeader()
|
.AllowAnyHeader()
|
||||||
.AllowAnyMethod()
|
.AllowAnyMethod()
|
||||||
.AllowCredentials();
|
.AllowCredentials();
|
||||||
@@ -125,12 +146,7 @@ builder.Services.AddCors(options =>
|
|||||||
|
|
||||||
var app = builder.Build();
|
var app = builder.Build();
|
||||||
|
|
||||||
var schedulerFactory = app.Services.GetRequiredService<ISchedulerFactory>();
|
app.UseCors(CorsPolicy);
|
||||||
var scheduler = await schedulerFactory.GetScheduler();
|
|
||||||
|
|
||||||
if (scheduler!.Status != SchedulerStatus.Running)
|
|
||||||
await scheduler.Start();
|
|
||||||
|
|
||||||
app.UseHsts();
|
app.UseHsts();
|
||||||
app.UseHttpsRedirection();
|
app.UseHttpsRedirection();
|
||||||
app.UseRouting();
|
app.UseRouting();
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
{
|
{
|
||||||
"EnableServiceCaching": true,
|
"EnableServiceCaching": true,
|
||||||
"CorsOrigins": [
|
"CorsOrigins": [
|
||||||
|
"https://partner.ifri.khongisa.co.za",
|
||||||
"https://console.ifri.khongisa.co.za",
|
"https://console.ifri.khongisa.co.za",
|
||||||
"https://localhost:7208"
|
"https://localhost:7208"
|
||||||
],
|
],
|
||||||
@@ -31,13 +32,6 @@
|
|||||||
"Microsoft.EntityFrameworkCore.Update": "Error"
|
"Microsoft.EntityFrameworkCore.Update": "Error"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"EmailProcessorCron": "0 */5 * * * ?",
|
|
||||||
"Email": {
|
|
||||||
"SmtpServer": "smtp.example.com",
|
|
||||||
"SmtpPort": 587,
|
|
||||||
"SenderEmail": "noreply@example.com",
|
|
||||||
"SenderName": "Example"
|
|
||||||
},
|
|
||||||
"HasherSettings": {
|
"HasherSettings": {
|
||||||
"MinHashLength": 11
|
"MinHashLength": 11
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -25,7 +25,6 @@ public interface ICommunicationsService
|
|||||||
Task<Result> CreateNotificationAsync(CreateNotificationRequest request, Guid userId, CancellationToken cancellationToken = default);
|
Task<Result> CreateNotificationAsync(CreateNotificationRequest request, Guid userId, CancellationToken cancellationToken = default);
|
||||||
|
|
||||||
[Log]
|
[Log]
|
||||||
[Audit]
|
|
||||||
Task<Result> UpdateNotificationAsync(UpdateNotificationRequest request, CancellationToken cancellationToken = default);
|
Task<Result> UpdateNotificationAsync(UpdateNotificationRequest request, CancellationToken cancellationToken = default);
|
||||||
|
|
||||||
[Log]
|
[Log]
|
||||||
|
|||||||
@@ -0,0 +1,176 @@
|
|||||||
|
using PostFundManagement.Application.Abstractions.Services;
|
||||||
|
using PostFundManagement.Application.Services.Communications;
|
||||||
|
using PostFundManagement.Domain;
|
||||||
|
using PostFundManagement.Domain.Abstractions;
|
||||||
|
using PostFundManagement.Infrastructure.Database;
|
||||||
|
|
||||||
|
namespace PostFundManagement.Application.Events;
|
||||||
|
|
||||||
|
public sealed class LateProjectEvent : IEvent
|
||||||
|
{
|
||||||
|
public Guid Id { get; set; } = Guid.CreateVersion7();
|
||||||
|
|
||||||
|
public string Name { get; set; } = nameof(LateProjectEvent);
|
||||||
|
|
||||||
|
public DateTimeOffset EnqueueAt { get; set; } = DateTimeOffset.UtcNow;
|
||||||
|
|
||||||
|
public string CorrelationId { get; set; } = "lateproject-reminder";
|
||||||
|
|
||||||
|
public static LateProjectEvent Create() => new();
|
||||||
|
}
|
||||||
|
|
||||||
|
public sealed class LateProjectEventHandler(IDbContextFactory<ApplicationDbContext> contextFactory,
|
||||||
|
ICommunicationsService service) : INotificationHandler<LateProjectEvent>
|
||||||
|
{
|
||||||
|
readonly int lateDays = 3;
|
||||||
|
|
||||||
|
public async ValueTask Handle(LateProjectEvent notification, CancellationToken cancellationToken)
|
||||||
|
{
|
||||||
|
await using var context = await contextFactory.CreateDbContextAsync(cancellationToken);
|
||||||
|
|
||||||
|
var now = DateTime.UtcNow;
|
||||||
|
var overdueCutoff = lateDays > 0 ? now.AddDays(-lateDays) : now;
|
||||||
|
|
||||||
|
// Query overdue milestones that are not yet approved
|
||||||
|
// Execute ToListAsync before GroupBy so EF Core does not fail SQL translation
|
||||||
|
var overdueMilestones = await context.Milestones
|
||||||
|
.AsNoTracking()
|
||||||
|
.Include(i => i.Project)
|
||||||
|
.ThenInclude(p => p!.Programme)
|
||||||
|
.ThenInclude(pr => pr!.Owner)
|
||||||
|
.Include(i => i.Project)
|
||||||
|
.ThenInclude(p => p!.Programme)
|
||||||
|
.ThenInclude(pr => pr!.Portfolio)
|
||||||
|
.ThenInclude(po => po!.Organisation)
|
||||||
|
.ThenInclude(o => o!.User)
|
||||||
|
.Where(m => m.Status != ApprovalStatus.Approved && m.DueAt <= overdueCutoff)
|
||||||
|
.OrderBy(o => o.DueAt)
|
||||||
|
.ToListAsync(cancellationToken);
|
||||||
|
|
||||||
|
if (overdueMilestones.Count == 0) return;
|
||||||
|
|
||||||
|
var lateMilestonesByProject = overdueMilestones.GroupBy(g => g.ProjectId);
|
||||||
|
|
||||||
|
var partnerResourceStream = typeof(IDomain).Assembly
|
||||||
|
.GetManifestResourceStream("PostFundManagement.Domain.Templates.lateprojectsmilestones.html");
|
||||||
|
|
||||||
|
if (partnerResourceStream is null) return;
|
||||||
|
|
||||||
|
using var partnerHtmlTemplateReader = new StreamReader(partnerResourceStream);
|
||||||
|
var partnerTemplate = await partnerHtmlTemplateReader.ReadToEndAsync(cancellationToken);
|
||||||
|
|
||||||
|
var programmeResourceStream = typeof(IDomain).Assembly
|
||||||
|
.GetManifestResourceStream("PostFundManagement.Domain.Templates.noncompliantprojects-prog.html");
|
||||||
|
|
||||||
|
string? programmeTemplate = null;
|
||||||
|
if (programmeResourceStream is not null)
|
||||||
|
{
|
||||||
|
using var programmeHtmlTemplateReader = new StreamReader(programmeResourceStream);
|
||||||
|
programmeTemplate = await programmeHtmlTemplateReader.ReadToEndAsync(cancellationToken);
|
||||||
|
}
|
||||||
|
|
||||||
|
foreach (var group in lateMilestonesByProject)
|
||||||
|
{
|
||||||
|
var milestones = group.ToList();
|
||||||
|
|
||||||
|
if (milestones.Count == 0) continue;
|
||||||
|
|
||||||
|
var project = milestones[0].Project;
|
||||||
|
|
||||||
|
if (project is null) continue;
|
||||||
|
|
||||||
|
var mostLateMilestone = milestones.OrderBy(m => m.DueAt).FirstOrDefault();
|
||||||
|
|
||||||
|
var daysOverdue = mostLateMilestone != null
|
||||||
|
? (int)Math.Max(0, (now - mostLateMilestone.DueAt).TotalDays)
|
||||||
|
: 0;
|
||||||
|
|
||||||
|
var organisation = mostLateMilestone?.Project?.Programme?.Portfolio?.Organisation;
|
||||||
|
|
||||||
|
if (organisation is null) continue;
|
||||||
|
|
||||||
|
var recipientId = organisation.UserId ?? organisation.CreatedBy;
|
||||||
|
var recipientEmail = organisation.User?.Email ?? organisation.Email;
|
||||||
|
|
||||||
|
if (string.IsNullOrWhiteSpace(recipientEmail) || recipientId == Guid.Empty) continue;
|
||||||
|
|
||||||
|
var tableRowsBuilder = new StringBuilder();
|
||||||
|
foreach (var milestone in milestones.OrderBy(m => m.DueAt))
|
||||||
|
{
|
||||||
|
var delay = (int)Math.Max(0, (now - milestone.DueAt).TotalDays);
|
||||||
|
var statusText = milestone.Status.ToString();
|
||||||
|
var milestoneName = WebUtility.HtmlEncode(milestone.Name ?? "Milestone Deliverable");
|
||||||
|
|
||||||
|
tableRowsBuilder.AppendLine($@"
|
||||||
|
<tr style=""border-bottom: 1px solid #e2e8f0;"">
|
||||||
|
<td style=""padding: 10px 14px; font-weight: 500; color: #1e293b;"">{milestoneName}</td>
|
||||||
|
<td style=""padding: 10px 14px; color: #64748b; font-size: 13px;"">{milestone.DueAt:yyyy-MM-dd}</td>
|
||||||
|
<td style=""padding: 10px 14px; color: #dc2626; font-weight: 600; font-size: 13px;"">+{delay} d</td>
|
||||||
|
<td style=""padding: 10px 14px; font-size: 12px;""><span style=""background-color: #fee2e2; color: #991b1b; padding: 2px 8px; border-radius: 9999px; font-weight: 600;"">{statusText}</span></td>
|
||||||
|
</tr>");
|
||||||
|
}
|
||||||
|
|
||||||
|
// 1. Send notice to Partner / Recipient Organisation
|
||||||
|
var messageBody = partnerTemplate;
|
||||||
|
messageBody = messageBody.Replace("[OrganisationName]", WebUtility.HtmlEncode(organisation.Name ?? "Partner"));
|
||||||
|
messageBody = messageBody.Replace("[RecipientName]", WebUtility.HtmlEncode(organisation.Name ?? "Partner"));
|
||||||
|
messageBody = messageBody.Replace("[ProjectName]", WebUtility.HtmlEncode(project.Name ?? "Project"));
|
||||||
|
messageBody = messageBody.Replace("[ProjectStartDate]", project.StartedAt.HasValue ? $"{project.StartedAt:yyyy-MM-dd}" : "Not Specified");
|
||||||
|
messageBody = messageBody.Replace("[ProjectEndDate]", project.EndedAt.HasValue ? $"{project.EndedAt:yyyy-MM-dd}" : "Not Specified");
|
||||||
|
messageBody = messageBody.Replace("[TotalLateMilestones]", milestones.Count.ToString());
|
||||||
|
messageBody = messageBody.Replace("[TotalLateMiletones]", milestones.Count.ToString());
|
||||||
|
messageBody = messageBody.Replace("[MostLateMilestoneName]", WebUtility.HtmlEncode(mostLateMilestone?.Name ?? "N/A"));
|
||||||
|
messageBody = messageBody.Replace("[MostLateMilestoneDueDate]", mostLateMilestone != null ? $"{mostLateMilestone.DueAt:yyyy-MM-dd}" : "N/A");
|
||||||
|
messageBody = messageBody.Replace("[MostLateMilestoneDaysOverdue]", daysOverdue.ToString());
|
||||||
|
messageBody = messageBody.Replace("[LateMilestonesTable]", tableRowsBuilder.ToString());
|
||||||
|
messageBody = messageBody.Replace("[PortalUrl]", "https://partner.ifri.khongisa.co.za");
|
||||||
|
messageBody = messageBody.Replace("[Year]", now.Year.ToString());
|
||||||
|
|
||||||
|
var notificationRequest = new CreateNotificationRequest(organisation.Id, recipientId, NotificationPlatform.Email,
|
||||||
|
$"Overdue Milestone Alert: {project.Name}", recipientEmail, messageBody, NotificationStatus.Queued);
|
||||||
|
|
||||||
|
_ = await service.CreateNotificationAsync(notificationRequest, SystemUser, cancellationToken);
|
||||||
|
|
||||||
|
// 2. Construct and send escalation notification for the Programme Manager (FR-M07-009)
|
||||||
|
if (!string.IsNullOrWhiteSpace(programmeTemplate))
|
||||||
|
{
|
||||||
|
var programme = project.Programme;
|
||||||
|
var programmeManager = programme?.Owner;
|
||||||
|
var programmeManagerId = programme?.OwnedBy ?? programmeManager?.Id;
|
||||||
|
var programmeManagerEmail = programmeManager?.Email;
|
||||||
|
|
||||||
|
if (programmeManagerId.HasValue && programmeManagerId.Value != Guid.Empty && !string.IsNullOrWhiteSpace(programmeManagerEmail))
|
||||||
|
{
|
||||||
|
var progMessageBody = programmeTemplate;
|
||||||
|
progMessageBody = progMessageBody.Replace("[ProgrammeManagerName]", WebUtility.HtmlEncode(programmeManagerEmail ?? "Programme Manager"));
|
||||||
|
progMessageBody = progMessageBody.Replace("[ProgrammeName]", WebUtility.HtmlEncode(programme?.Name ?? "Programme"));
|
||||||
|
progMessageBody = progMessageBody.Replace("[PortfolioName]", WebUtility.HtmlEncode(programme?.Portfolio?.Name ?? "Portfolio"));
|
||||||
|
progMessageBody = progMessageBody.Replace("[OrganisationName]", WebUtility.HtmlEncode(organisation.Name ?? "Partner"));
|
||||||
|
progMessageBody = progMessageBody.Replace("[ProjectName]", WebUtility.HtmlEncode(project.Name ?? "Project"));
|
||||||
|
progMessageBody = progMessageBody.Replace("[ProjectStartDate]", project.StartedAt.HasValue ? $"{project.StartedAt:yyyy-MM-dd}" : "Not Specified");
|
||||||
|
progMessageBody = progMessageBody.Replace("[ProjectEndDate]", project.EndedAt.HasValue ? $"{project.EndedAt:yyyy-MM-dd}" : "Not Specified");
|
||||||
|
progMessageBody = progMessageBody.Replace("[TotalLateMilestones]", milestones.Count.ToString());
|
||||||
|
progMessageBody = progMessageBody.Replace("[TotalLateMiletones]", milestones.Count.ToString());
|
||||||
|
progMessageBody = progMessageBody.Replace("[MostLateMilestoneName]", WebUtility.HtmlEncode(mostLateMilestone?.Name ?? "N/A"));
|
||||||
|
progMessageBody = progMessageBody.Replace("[MostLateMilestoneDueDate]", mostLateMilestone != null ? $"{mostLateMilestone.DueAt:yyyy-MM-dd}" : "N/A");
|
||||||
|
progMessageBody = progMessageBody.Replace("[MostLateMilestoneDaysOverdue]", daysOverdue.ToString());
|
||||||
|
progMessageBody = progMessageBody.Replace("[LateMilestonesTable]", tableRowsBuilder.ToString());
|
||||||
|
progMessageBody = progMessageBody.Replace("[ConsoleUrl]", "https://console.ifri.khongisa.co.za");
|
||||||
|
progMessageBody = progMessageBody.Replace("[PortalUrl]", "https://partner.ifri.khongisa.co.za");
|
||||||
|
progMessageBody = progMessageBody.Replace("[Year]", now.Year.ToString());
|
||||||
|
|
||||||
|
var progNotificationRequest = new CreateNotificationRequest(
|
||||||
|
organisation.Id,
|
||||||
|
programmeManagerId.Value,
|
||||||
|
NotificationPlatform.Email,
|
||||||
|
$"[Escalation] Overdue Milestones: {project.Name} ({programme?.Name})",
|
||||||
|
programmeManagerEmail,
|
||||||
|
progMessageBody,
|
||||||
|
NotificationStatus.Queued);
|
||||||
|
|
||||||
|
_ = await service.CreateNotificationAsync(progNotificationRequest, SystemUser, cancellationToken);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,221 @@
|
|||||||
|
using System.Net;
|
||||||
|
using System.Text;
|
||||||
|
using PostFundManagement.Application.Abstractions.Services;
|
||||||
|
using PostFundManagement.Application.Services.Communications;
|
||||||
|
using PostFundManagement.Domain;
|
||||||
|
using PostFundManagement.Domain.Abstractions;
|
||||||
|
using PostFundManagement.Infrastructure.Database;
|
||||||
|
|
||||||
|
namespace PostFundManagement.Application.Events;
|
||||||
|
|
||||||
|
public sealed class NonCompliantProjectEvent : IEvent
|
||||||
|
{
|
||||||
|
public Guid Id { get; set; } = Guid.CreateVersion7();
|
||||||
|
|
||||||
|
public string Name { get; set; } = nameof(NonCompliantProjectEvent);
|
||||||
|
|
||||||
|
public DateTimeOffset EnqueueAt { get; set; } = DateTimeOffset.UtcNow;
|
||||||
|
|
||||||
|
public string CorrelationId { get; set; } = "noncompliant-project-check";
|
||||||
|
|
||||||
|
public static NonCompliantProjectEvent Create() => new();
|
||||||
|
}
|
||||||
|
|
||||||
|
public sealed class NonCompliantProjectEventHandler(IDbContextFactory<ApplicationDbContext> contextFactory,
|
||||||
|
ICommunicationsService service) : INotificationHandler<NonCompliantProjectEvent>
|
||||||
|
{
|
||||||
|
public async ValueTask Handle(NonCompliantProjectEvent notification, CancellationToken cancellationToken)
|
||||||
|
{
|
||||||
|
await using var context = await contextFactory.CreateDbContextAsync(cancellationToken);
|
||||||
|
|
||||||
|
var now = DateTime.UtcNow;
|
||||||
|
|
||||||
|
// Fetch active/monitored projects with programme, portfolio, and award organisations
|
||||||
|
var projects = await context.Projects
|
||||||
|
.AsNoTracking()
|
||||||
|
.Include(p => p.Programme)
|
||||||
|
.ThenInclude(pr => pr!.Owner)
|
||||||
|
.Include(p => p.Programme)
|
||||||
|
.ThenInclude(pr => pr!.Portfolio)
|
||||||
|
.ThenInclude(po => po!.Organisation)
|
||||||
|
.ThenInclude(o => o!.User)
|
||||||
|
.Include(p => p.Awards)
|
||||||
|
.ThenInclude(a => a.Organisation)
|
||||||
|
.ThenInclude(o => o!.User)
|
||||||
|
.ToListAsync(cancellationToken);
|
||||||
|
|
||||||
|
if (projects.Count == 0) return;
|
||||||
|
|
||||||
|
// Fetch compliance items and milestones to evaluate compliance according to ConsoleUI rules
|
||||||
|
var complianceItems = await context.ComplianceItems
|
||||||
|
.AsNoTracking()
|
||||||
|
.ToListAsync(cancellationToken);
|
||||||
|
|
||||||
|
var milestones = await context.Milestones
|
||||||
|
.AsNoTracking()
|
||||||
|
.ToListAsync(cancellationToken);
|
||||||
|
|
||||||
|
var complianceByProject = complianceItems.GroupBy(c => c.ProjectId)
|
||||||
|
.ToDictionary(g => g.Key, g => g.ToList());
|
||||||
|
|
||||||
|
var milestonesByProject = milestones.GroupBy(m => m.ProjectId)
|
||||||
|
.ToDictionary(g => g.Key, g => g.ToList());
|
||||||
|
|
||||||
|
// Load noncompliantprojects.html template for partner organisations
|
||||||
|
var partnerResourceStream = typeof(IDomain).Assembly
|
||||||
|
.GetManifestResourceStream("PostFundManagement.Domain.Templates.noncompliantprojects.html");
|
||||||
|
|
||||||
|
if (partnerResourceStream is null) return;
|
||||||
|
|
||||||
|
using var partnerHtmlTemplateReader = new StreamReader(partnerResourceStream);
|
||||||
|
var partnerTemplate = await partnerHtmlTemplateReader.ReadToEndAsync(cancellationToken);
|
||||||
|
|
||||||
|
// Load noncompliantprojects-prog.html template for programme managers
|
||||||
|
var progResourceStream = typeof(IDomain).Assembly
|
||||||
|
.GetManifestResourceStream("PostFundManagement.Domain.Templates.noncompliantprojects-prog.html");
|
||||||
|
|
||||||
|
string? programmeTemplate = null;
|
||||||
|
if (progResourceStream is not null)
|
||||||
|
{
|
||||||
|
using var progHtmlTemplateReader = new StreamReader(progResourceStream);
|
||||||
|
programmeTemplate = await progHtmlTemplateReader.ReadToEndAsync(cancellationToken);
|
||||||
|
}
|
||||||
|
|
||||||
|
foreach (var project in projects)
|
||||||
|
{
|
||||||
|
complianceByProject.TryGetValue(project.Id, out var pComplianceItems);
|
||||||
|
pComplianceItems ??= [];
|
||||||
|
|
||||||
|
milestonesByProject.TryGetValue(project.Id, out var pMilestones);
|
||||||
|
pMilestones ??= [];
|
||||||
|
|
||||||
|
// ConsoleUI Invariant: A project is 100% compliant if unmetCompCount == 0 && overdueMilestones.Count == 0
|
||||||
|
var unmetCompItems = pComplianceItems.Where(c => c.Status == ComplianceStatus.NotMet).ToList();
|
||||||
|
var overdueMilestones = pMilestones
|
||||||
|
.Where(m => m.Status != ApprovalStatus.Approved && m.DueAt < now)
|
||||||
|
.OrderBy(m => m.DueAt)
|
||||||
|
.ToList();
|
||||||
|
|
||||||
|
var isCompliant = unmetCompItems.Count == 0 && overdueMilestones.Count == 0;
|
||||||
|
if (isCompliant) continue;
|
||||||
|
|
||||||
|
// Compliance rate calculation
|
||||||
|
var totalEvaluated = pComplianceItems.Count + pMilestones.Count;
|
||||||
|
var totalMet = pComplianceItems.Count(c => c.Status == ComplianceStatus.Met || c.Status == ComplianceStatus.Exceeded)
|
||||||
|
+ pMilestones.Count(m => m.Status == ApprovalStatus.Approved);
|
||||||
|
var compliancePercentage = totalEvaluated > 0
|
||||||
|
? (int)Math.Round((double)totalMet / totalEvaluated * 100)
|
||||||
|
: 0;
|
||||||
|
|
||||||
|
// Resolve target partner organisation
|
||||||
|
var organisation = project.Awards.FirstOrDefault()?.Organisation
|
||||||
|
?? project.Programme?.Portfolio?.Organisation;
|
||||||
|
|
||||||
|
if (organisation is null) continue;
|
||||||
|
|
||||||
|
var recipientId = organisation.UserId ?? organisation.CreatedBy;
|
||||||
|
var recipientEmail = organisation.User?.Email ?? organisation.Email;
|
||||||
|
if (string.IsNullOrWhiteSpace(recipientEmail) || recipientId == Guid.Empty) continue;
|
||||||
|
|
||||||
|
// Construct non-compliance table rows
|
||||||
|
var tableRowsBuilder = new StringBuilder();
|
||||||
|
|
||||||
|
foreach (var item in unmetCompItems)
|
||||||
|
{
|
||||||
|
var title = WebUtility.HtmlEncode(item.Title ?? "Statutory Compliance Obligation");
|
||||||
|
var requirements = WebUtility.HtmlEncode(item.Requirements ?? "Verification evidence required");
|
||||||
|
|
||||||
|
tableRowsBuilder.AppendLine($@"
|
||||||
|
<tr style=""border-bottom: 1px solid #e2e8f0;"">
|
||||||
|
<td style=""padding: 10px 14px; font-weight: 600; color: #475569; font-size: 12px;"">Statutory / Legal</td>
|
||||||
|
<td style=""padding: 10px 14px; font-weight: 500; color: #1e293b;"">{title}</td>
|
||||||
|
<td style=""padding: 10px 14px; color: #64748b; font-size: 13px;"">{requirements}</td>
|
||||||
|
<td style=""padding: 10px 14px; font-size: 12px;""><span style=""background-color: #fee2e2; color: #991b1b; padding: 2px 8px; border-radius: 9999px; font-weight: 600;"">Not Met</span></td>
|
||||||
|
</tr>");
|
||||||
|
}
|
||||||
|
|
||||||
|
foreach (var m in overdueMilestones)
|
||||||
|
{
|
||||||
|
var delay = (int)Math.Max(0, (now - m.DueAt).TotalDays);
|
||||||
|
var mName = WebUtility.HtmlEncode(m.Name ?? "Milestone Deliverable");
|
||||||
|
|
||||||
|
tableRowsBuilder.AppendLine($@"
|
||||||
|
<tr style=""border-bottom: 1px solid #e2e8f0;"">
|
||||||
|
<td style=""padding: 10px 14px; font-weight: 600; color: #d97706; font-size: 12px;"">Milestone SLA</td>
|
||||||
|
<td style=""padding: 10px 14px; font-weight: 500; color: #1e293b;"">{mName}</td>
|
||||||
|
<td style=""padding: 10px 14px; color: #dc2626; font-size: 13px;"">Due: {m.DueAt:yyyy-MM-dd} (+{delay} d)</td>
|
||||||
|
<td style=""padding: 10px 14px; font-size: 12px;""><span style=""background-color: #fee2e2; color: #991b1b; padding: 2px 8px; border-radius: 9999px; font-weight: 600;"">Overdue</span></td>
|
||||||
|
</tr>");
|
||||||
|
}
|
||||||
|
|
||||||
|
// 1. Dispatch partner audit notice
|
||||||
|
var messageBody = partnerTemplate;
|
||||||
|
messageBody = messageBody.Replace("[OrganisationName]", WebUtility.HtmlEncode(organisation.Name ?? "Partner"));
|
||||||
|
messageBody = messageBody.Replace("[ProjectName]", WebUtility.HtmlEncode(project.Name ?? "Project"));
|
||||||
|
messageBody = messageBody.Replace("[ProgrammeName]", WebUtility.HtmlEncode(project.Programme?.Name ?? "Programme"));
|
||||||
|
messageBody = messageBody.Replace("[PortfolioName]", WebUtility.HtmlEncode(project.Programme?.Portfolio?.Name ?? "Portfolio"));
|
||||||
|
messageBody = messageBody.Replace("[ProjectStartDate]", project.StartedAt.HasValue ? $"{project.StartedAt:yyyy-MM-dd}" : "Not Specified");
|
||||||
|
messageBody = messageBody.Replace("[ProjectEndDate]", project.EndedAt.HasValue ? $"{project.EndedAt:yyyy-MM-dd}" : "Not Specified");
|
||||||
|
messageBody = messageBody.Replace("[ComplianceScore]", $"{compliancePercentage}%");
|
||||||
|
messageBody = messageBody.Replace("[UnmetComplianceCount]", unmetCompItems.Count.ToString());
|
||||||
|
messageBody = messageBody.Replace("[UnmetMilestonesCount]", overdueMilestones.Count.ToString());
|
||||||
|
messageBody = messageBody.Replace("[NonComplianceTable]", tableRowsBuilder.ToString());
|
||||||
|
messageBody = messageBody.Replace("[PortalUrl]", "https://partner.ifri.khongisa.co.za");
|
||||||
|
messageBody = messageBody.Replace("[ConsoleUrl]", "https://console.ifri.khongisa.co.za");
|
||||||
|
messageBody = messageBody.Replace("[Year]", now.Year.ToString());
|
||||||
|
|
||||||
|
var notificationRequest = new CreateNotificationRequest(
|
||||||
|
organisation.Id,
|
||||||
|
recipientId,
|
||||||
|
NotificationPlatform.Email,
|
||||||
|
$"[Audit Notice] Project Non-Compliance: {project.Name}",
|
||||||
|
recipientEmail,
|
||||||
|
messageBody,
|
||||||
|
NotificationStatus.Queued);
|
||||||
|
|
||||||
|
_ = await service.CreateNotificationAsync(notificationRequest, SystemUser, cancellationToken);
|
||||||
|
|
||||||
|
// 2. Dispatch internal escalation to Programme Manager (FR-M07-009 / M10)
|
||||||
|
if (!string.IsNullOrWhiteSpace(programmeTemplate))
|
||||||
|
{
|
||||||
|
var programme = project.Programme;
|
||||||
|
var programmeManager = programme?.Owner;
|
||||||
|
var programmeManagerId = programme?.OwnedBy ?? programmeManager?.Id;
|
||||||
|
var programmeManagerEmail = programmeManager?.Email;
|
||||||
|
|
||||||
|
if (programmeManagerId.HasValue && programmeManagerId.Value != Guid.Empty && !string.IsNullOrWhiteSpace(programmeManagerEmail))
|
||||||
|
{
|
||||||
|
var progMessageBody = programmeTemplate;
|
||||||
|
progMessageBody = progMessageBody.Replace("[ProgrammeManagerName]", WebUtility.HtmlEncode(programmeManagerEmail ?? "Programme Manager"));
|
||||||
|
progMessageBody = progMessageBody.Replace("[ProgrammeName]", WebUtility.HtmlEncode(programme?.Name ?? "Programme"));
|
||||||
|
progMessageBody = progMessageBody.Replace("[PortfolioName]", WebUtility.HtmlEncode(programme?.Portfolio?.Name ?? "Portfolio"));
|
||||||
|
progMessageBody = progMessageBody.Replace("[OrganisationName]", WebUtility.HtmlEncode(organisation.Name ?? "Partner"));
|
||||||
|
progMessageBody = progMessageBody.Replace("[ProjectName]", WebUtility.HtmlEncode(project.Name ?? "Project"));
|
||||||
|
progMessageBody = progMessageBody.Replace("[ProjectStartDate]", project.StartedAt.HasValue ? $"{project.StartedAt:yyyy-MM-dd}" : "Not Specified");
|
||||||
|
progMessageBody = progMessageBody.Replace("[ProjectEndDate]", project.EndedAt.HasValue ? $"{project.EndedAt:yyyy-MM-dd}" : "Not Specified");
|
||||||
|
progMessageBody = progMessageBody.Replace("[TotalLateMilestones]", overdueMilestones.Count.ToString());
|
||||||
|
progMessageBody = progMessageBody.Replace("[TotalLateMiletones]", overdueMilestones.Count.ToString());
|
||||||
|
progMessageBody = progMessageBody.Replace("[MostLateMilestoneName]", WebUtility.HtmlEncode(overdueMilestones.FirstOrDefault()?.Name ?? "N/A"));
|
||||||
|
progMessageBody = progMessageBody.Replace("[MostLateMilestoneDueDate]", overdueMilestones.FirstOrDefault() != null ? $"{overdueMilestones.First().DueAt:yyyy-MM-dd}" : "N/A");
|
||||||
|
progMessageBody = progMessageBody.Replace("[MostLateMilestoneDaysOverdue]", overdueMilestones.FirstOrDefault() != null ? $"{(int)Math.Max(0, (now - overdueMilestones.First().DueAt).TotalDays)}" : "0");
|
||||||
|
progMessageBody = progMessageBody.Replace("[LateMilestonesTable]", tableRowsBuilder.ToString());
|
||||||
|
progMessageBody = progMessageBody.Replace("[ConsoleUrl]", "https://console.ifri.khongisa.co.za");
|
||||||
|
progMessageBody = progMessageBody.Replace("[PortalUrl]", "https://partner.ifri.khongisa.co.za");
|
||||||
|
progMessageBody = progMessageBody.Replace("[Year]", now.Year.ToString());
|
||||||
|
|
||||||
|
var progNotificationRequest = new CreateNotificationRequest(
|
||||||
|
organisation.Id,
|
||||||
|
programmeManagerId.Value,
|
||||||
|
NotificationPlatform.Email,
|
||||||
|
$"[Weekly Audit Escalation] Non-Compliant Project: {project.Name} ({programme?.Name})",
|
||||||
|
programmeManagerEmail,
|
||||||
|
progMessageBody,
|
||||||
|
NotificationStatus.Queued);
|
||||||
|
|
||||||
|
_ = await service.CreateNotificationAsync(progNotificationRequest, SystemUser, cancellationToken);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
@@ -0,0 +1,122 @@
|
|||||||
|
using PostFundManagement.Application.Services.Shared;
|
||||||
|
using PostFundManagement.Domain.Abstractions;
|
||||||
|
using PostFundManagement.Infrastructure.Database;
|
||||||
|
|
||||||
|
namespace PostFundManagement.Application.Events;
|
||||||
|
|
||||||
|
public sealed class ProcessEmailEvent : IEvent
|
||||||
|
{
|
||||||
|
public Guid Id { get; set; } = Guid.CreateVersion7();
|
||||||
|
|
||||||
|
public string Name { get; set; } = nameof(ProcessEmailEvent);
|
||||||
|
|
||||||
|
public DateTimeOffset EnqueueAt { get; set; } = DateTimeOffset.UtcNow;
|
||||||
|
|
||||||
|
public string CorrelationId { get; set; } = "email-processor";
|
||||||
|
|
||||||
|
public static ProcessEmailEvent Create() => new();
|
||||||
|
}
|
||||||
|
|
||||||
|
public sealed class ProcessEmailEventHandler(IDbContextFactory<ApplicationDbContext> contextFactory,
|
||||||
|
IFeatureManager featureManager, EmailService emailService, ISkorubaUserService skorubaUserService) : INotificationHandler<ProcessEmailEvent>
|
||||||
|
{
|
||||||
|
const int maxBatch = 100;
|
||||||
|
|
||||||
|
public async ValueTask Handle(ProcessEmailEvent notification, CancellationToken cancellationToken)
|
||||||
|
{
|
||||||
|
if (!await featureManager.IsEnabledAsync(FeatureEmailProcessor)) return;
|
||||||
|
|
||||||
|
await using var context = await contextFactory.CreateDbContextAsync(cancellationToken);
|
||||||
|
|
||||||
|
var outgoing = await context.Notifications.AsNoTracking()
|
||||||
|
.Include(i => i.Organisation)
|
||||||
|
.Include(i => i.RecipientUser)
|
||||||
|
.Include(i => i.Tenant)
|
||||||
|
.Where(n => n.Status == Domain.NotificationStatus.Queued)
|
||||||
|
.OrderByDescending(o => o.CreatedAt)
|
||||||
|
.Take(maxBatch)
|
||||||
|
.ToListAsync(cancellationToken);
|
||||||
|
|
||||||
|
if (outgoing?.Count == 0) return;
|
||||||
|
|
||||||
|
if (emailService.Status is not (Domain.EmailStatuses.Connected or Domain.EmailStatuses.Success))
|
||||||
|
await emailService.ConnectAsync(cancellationToken);
|
||||||
|
|
||||||
|
var failedTransmissions = new List<(long notificationId, string? error)>();
|
||||||
|
|
||||||
|
foreach (var enqueuedMessage in outgoing!)
|
||||||
|
{
|
||||||
|
var userClaimResult = await skorubaUserService.GetUserClaimsAsync(enqueuedMessage.RecipientUser!.Id, cancellationToken: cancellationToken);
|
||||||
|
|
||||||
|
if (userClaimResult.IsFailed)
|
||||||
|
{
|
||||||
|
failedTransmissions.Add((enqueuedMessage.Id, "Failed to retrieve user claims"));
|
||||||
|
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
var name = userClaimResult.Value.FirstOrDefault(c => c.ClaimType == "given_name")?.ClaimValue ?? enqueuedMessage.Organisation!.Name;
|
||||||
|
var surname = userClaimResult.Value.FirstOrDefault(c => c.ClaimType == "family_name")?.ClaimValue ?? string.Empty;
|
||||||
|
var email = enqueuedMessage.RecipientUser?.Email ?? enqueuedMessage.Organisation?.Email ?? enqueuedMessage.Destination ?? string.Empty;
|
||||||
|
|
||||||
|
if (string.IsNullOrWhiteSpace(email))
|
||||||
|
{
|
||||||
|
failedTransmissions.Add((enqueuedMessage.Id, "Could not resolve destination email address"));
|
||||||
|
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
var message = new Domain.Models.Email.Message
|
||||||
|
{
|
||||||
|
Sender = new Domain.Models.Email.Party
|
||||||
|
{
|
||||||
|
Address = "noreply@ifritechnologies.co.za",
|
||||||
|
Name = "Post Fund Management"
|
||||||
|
},
|
||||||
|
Recipient = new Domain.Models.Email.Party
|
||||||
|
{
|
||||||
|
Address = email,
|
||||||
|
Name = string.IsNullOrWhiteSpace(surname)
|
||||||
|
? name : $"{name} {surname}"
|
||||||
|
},
|
||||||
|
Subject = enqueuedMessage.Subject,
|
||||||
|
Body = new Domain.Models.Email.Body
|
||||||
|
{
|
||||||
|
Properties = new Domain.Models.Email.BodyProperties
|
||||||
|
{
|
||||||
|
HasAttachments = false,
|
||||||
|
IsHtml = true
|
||||||
|
},
|
||||||
|
Message = enqueuedMessage.Message
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
var response = await emailService.SendEmailAsync(message, cancellationToken);
|
||||||
|
|
||||||
|
if (response.IsFailed || response.Value.Status is not (Domain.EmailStatuses.Connected or Domain.EmailStatuses.Success))
|
||||||
|
{
|
||||||
|
failedTransmissions.Add((enqueuedMessage.Id, $"Error: {response.Value.Error}"));
|
||||||
|
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
foreach (var (notificationId, error) in failedTransmissions)
|
||||||
|
await context.Notifications.Where(n => n.Id == notificationId)
|
||||||
|
.ExecuteUpdateAsync(p => p
|
||||||
|
.SetProperty(u => u.Status, Domain.NotificationStatus.Failed)
|
||||||
|
.SetProperty(u => u.Error, error), cancellationToken);
|
||||||
|
|
||||||
|
var failedIds = failedTransmissions.Select(f => f.notificationId).ToList();
|
||||||
|
|
||||||
|
foreach (var successMessage in outgoing)
|
||||||
|
{
|
||||||
|
if (failedIds.Contains(successMessage.Id)) continue;
|
||||||
|
|
||||||
|
await context.Notifications.Where(n => n.Id == successMessage.Id)
|
||||||
|
.ExecuteUpdateAsync(p => p
|
||||||
|
.SetProperty(u => u.Status, Domain.NotificationStatus.Sent)
|
||||||
|
.SetProperty(u => u.Error, string.Empty), cancellationToken);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -20,7 +20,7 @@ public static class Quartz
|
|||||||
services.AddQuartz(config =>
|
services.AddQuartz(config =>
|
||||||
{
|
{
|
||||||
config.UseSimpleTypeLoader();
|
config.UseSimpleTypeLoader();
|
||||||
config.UseDefaultThreadPool(options => options.MaxConcurrency = 0);
|
config.UseThreadPool<global::Quartz.Impl.ZeroSizeThreadPool>();
|
||||||
config.UseTimeZoneConverter();
|
config.UseTimeZoneConverter();
|
||||||
|
|
||||||
config.UsePersistentStore(storage =>
|
config.UsePersistentStore(storage =>
|
||||||
|
|||||||
@@ -1,9 +1,3 @@
|
|||||||
using System.Reflection;
|
|
||||||
using System.Security.Claims;
|
|
||||||
using System.Text.Json;
|
|
||||||
using Castle.DynamicProxy;
|
|
||||||
using FluentResults;
|
|
||||||
using Microsoft.AspNetCore.Http;
|
|
||||||
using PostFundManagement.Application.Services.Shared;
|
using PostFundManagement.Application.Services.Shared;
|
||||||
using PostFundManagement.Domain.Attributes;
|
using PostFundManagement.Domain.Attributes;
|
||||||
|
|
||||||
|
|||||||
+6
-6
@@ -15,7 +15,7 @@ public sealed class CommunicationsCacheService(CommunicationsService service, ID
|
|||||||
public async Task<Result<ApplicantData[]>> GetApplicantDataAsync(Guid applicantUserId, CancellationToken cancellationToken = default) =>
|
public async Task<Result<ApplicantData[]>> GetApplicantDataAsync(Guid applicantUserId, CancellationToken cancellationToken = default) =>
|
||||||
await Result.Try((Func<Task<Result<ApplicantData[]>>>)(async () =>
|
await Result.Try((Func<Task<Result<ApplicantData[]>>>)(async () =>
|
||||||
{
|
{
|
||||||
if (!await featureManager.IsEnabledAsync(ServiceCachingFeature))
|
if (!await featureManager.IsEnabledAsync(FeatureServiceCaching))
|
||||||
return await service.GetApplicantDataAsync(applicantUserId, cancellationToken);
|
return await service.GetApplicantDataAsync(applicantUserId, cancellationToken);
|
||||||
|
|
||||||
var cacheKey = HashService.ToMd5Hash($"getapplicantdata-{applicantUserId}").Value;
|
var cacheKey = HashService.ToMd5Hash($"getapplicantdata-{applicantUserId}").Value;
|
||||||
@@ -37,7 +37,7 @@ public sealed class CommunicationsCacheService(CommunicationsService service, ID
|
|||||||
public async Task<Result<Invite>> GetApplicantInviteAsync(Guid applicantUserId, CancellationToken cancellationToken = default) =>
|
public async Task<Result<Invite>> GetApplicantInviteAsync(Guid applicantUserId, CancellationToken cancellationToken = default) =>
|
||||||
await Result.Try((Func<Task<Result<Invite>>>)(async () =>
|
await Result.Try((Func<Task<Result<Invite>>>)(async () =>
|
||||||
{
|
{
|
||||||
if (!await featureManager.IsEnabledAsync(ServiceCachingFeature))
|
if (!await featureManager.IsEnabledAsync(FeatureServiceCaching))
|
||||||
return await service.GetApplicantInviteAsync(applicantUserId, cancellationToken);
|
return await service.GetApplicantInviteAsync(applicantUserId, cancellationToken);
|
||||||
|
|
||||||
var cacheKey = HashService.ToMd5Hash($"getapplicantinvite-{applicantUserId}").Value;
|
var cacheKey = HashService.ToMd5Hash($"getapplicantinvite-{applicantUserId}").Value;
|
||||||
@@ -59,7 +59,7 @@ public sealed class CommunicationsCacheService(CommunicationsService service, ID
|
|||||||
public async Task<Result<Invite[]>> GetApplicantInvitesAsync(Guid applicantUserId, CancellationToken cancellationToken = default) =>
|
public async Task<Result<Invite[]>> GetApplicantInvitesAsync(Guid applicantUserId, CancellationToken cancellationToken = default) =>
|
||||||
await Result.Try((Func<Task<Result<Invite[]>>>)(async () =>
|
await Result.Try((Func<Task<Result<Invite[]>>>)(async () =>
|
||||||
{
|
{
|
||||||
if (!await featureManager.IsEnabledAsync(ServiceCachingFeature))
|
if (!await featureManager.IsEnabledAsync(FeatureServiceCaching))
|
||||||
return await service.GetApplicantInvitesAsync(applicantUserId, cancellationToken);
|
return await service.GetApplicantInvitesAsync(applicantUserId, cancellationToken);
|
||||||
|
|
||||||
var cacheKey = HashService.ToMd5Hash($"getapplicantinvites-{applicantUserId}").Value;
|
var cacheKey = HashService.ToMd5Hash($"getapplicantinvites-{applicantUserId}").Value;
|
||||||
@@ -83,7 +83,7 @@ public sealed class CommunicationsCacheService(CommunicationsService service, ID
|
|||||||
public async Task<Result<Invite[]>> GetInvitesAsync(Pagination pagination, CancellationToken cancellationToken = default) =>
|
public async Task<Result<Invite[]>> GetInvitesAsync(Pagination pagination, CancellationToken cancellationToken = default) =>
|
||||||
await Result.Try((Func<Task<Result<Invite[]>>>)(async () =>
|
await Result.Try((Func<Task<Result<Invite[]>>>)(async () =>
|
||||||
{
|
{
|
||||||
if (!await featureManager.IsEnabledAsync(ServiceCachingFeature))
|
if (!await featureManager.IsEnabledAsync(FeatureServiceCaching))
|
||||||
return await service.GetInvitesAsync(pagination, cancellationToken);
|
return await service.GetInvitesAsync(pagination, cancellationToken);
|
||||||
|
|
||||||
var cacheKey = HashService.ToMd5Hash($"getinvites-{pagination.Page}-{pagination.PageSize}").Value;
|
var cacheKey = HashService.ToMd5Hash($"getinvites-{pagination.Page}-{pagination.PageSize}").Value;
|
||||||
@@ -107,7 +107,7 @@ public sealed class CommunicationsCacheService(CommunicationsService service, ID
|
|||||||
public async Task<Result<Notification>> GetNotificationAsync(string notificationIdHash, CancellationToken cancellationToken = default) =>
|
public async Task<Result<Notification>> GetNotificationAsync(string notificationIdHash, CancellationToken cancellationToken = default) =>
|
||||||
await Result.Try((Func<Task<Result<Notification>>>)(async () =>
|
await Result.Try((Func<Task<Result<Notification>>>)(async () =>
|
||||||
{
|
{
|
||||||
if (!await featureManager.IsEnabledAsync(ServiceCachingFeature))
|
if (!await featureManager.IsEnabledAsync(FeatureServiceCaching))
|
||||||
return await service.GetNotificationAsync(notificationIdHash, cancellationToken);
|
return await service.GetNotificationAsync(notificationIdHash, cancellationToken);
|
||||||
|
|
||||||
var cacheKey = HashService.ToMd5Hash($"getnotification-{notificationIdHash}").Value;
|
var cacheKey = HashService.ToMd5Hash($"getnotification-{notificationIdHash}").Value;
|
||||||
@@ -131,7 +131,7 @@ public sealed class CommunicationsCacheService(CommunicationsService service, ID
|
|||||||
public async Task<Result<Notification[]>> GetNotificationsAsync(string organisationIdHash, CancellationToken cancellationToken = default) =>
|
public async Task<Result<Notification[]>> GetNotificationsAsync(string organisationIdHash, CancellationToken cancellationToken = default) =>
|
||||||
await Result.Try((Func<Task<Result<Notification[]>>>)(async () =>
|
await Result.Try((Func<Task<Result<Notification[]>>>)(async () =>
|
||||||
{
|
{
|
||||||
if (!await featureManager.IsEnabledAsync(ServiceCachingFeature))
|
if (!await featureManager.IsEnabledAsync(FeatureServiceCaching))
|
||||||
return await service.GetNotificationsAsync(organisationIdHash, cancellationToken);
|
return await service.GetNotificationsAsync(organisationIdHash, cancellationToken);
|
||||||
|
|
||||||
var cacheKey = HashService.ToMd5Hash($"getnotifications-{organisationIdHash}").Value;
|
var cacheKey = HashService.ToMd5Hash($"getnotifications-{organisationIdHash}").Value;
|
||||||
|
|||||||
@@ -1,21 +0,0 @@
|
|||||||
using PostFundManagement.Application.Abstractions;
|
|
||||||
using PostFundManagement.Domain.Abstractions;
|
|
||||||
|
|
||||||
namespace PostFundManagement.Application.Services.Communications.Events;
|
|
||||||
|
|
||||||
public class SyncUserEvent : EventBase, IEvent
|
|
||||||
{
|
|
||||||
public string Name { get; set; } = nameof(SyncUserEvent);
|
|
||||||
|
|
||||||
public static SyncUserEvent Create() => new();
|
|
||||||
}
|
|
||||||
|
|
||||||
public class SyncUserEventHandler(ILogger<SyncUserEvent> logger) : INotificationHandler<SyncUserEvent>
|
|
||||||
{
|
|
||||||
public ValueTask Handle(SyncUserEvent notification, CancellationToken cancellationToken)
|
|
||||||
{
|
|
||||||
logger.LogInformation("Integration Sync executed. Correlation ID: {CorrelationId}", notification.CorrelationId);
|
|
||||||
|
|
||||||
return ValueTask.CompletedTask;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -20,6 +20,7 @@ public sealed record InviteRequest([Required] long OrganisationId,
|
|||||||
[Required] long InvitedOrganisationId,
|
[Required] long InvitedOrganisationId,
|
||||||
[Required] long AwardId,
|
[Required] long AwardId,
|
||||||
[Required] long ContractId,
|
[Required] long ContractId,
|
||||||
|
[Required] long ProjectId,
|
||||||
[Required] Guid Recipient,
|
[Required] Guid Recipient,
|
||||||
[Required][MaxLength(LabelLength)] string Message,
|
[Required][MaxLength(LabelLength)] string Message,
|
||||||
[Required] NotificationPlatform Platform);
|
[Required] NotificationPlatform Platform);
|
||||||
|
|||||||
@@ -21,7 +21,7 @@ public sealed class EvidenceCacheService(EvidenceService service, IDistributedCa
|
|||||||
public async Task<Result<Domain.Models.Evidence>> GetEvidenceAsync(string evidenceId, CancellationToken cancellationToken = default) =>
|
public async Task<Result<Domain.Models.Evidence>> GetEvidenceAsync(string evidenceId, CancellationToken cancellationToken = default) =>
|
||||||
await Result.Try((Func<Task<Result<Domain.Models.Evidence>>>)(async () =>
|
await Result.Try((Func<Task<Result<Domain.Models.Evidence>>>)(async () =>
|
||||||
{
|
{
|
||||||
if (!await featureManager.IsEnabledAsync(ServiceCachingFeature))
|
if (!await featureManager.IsEnabledAsync(FeatureServiceCaching))
|
||||||
return await service.GetEvidenceAsync(evidenceId, cancellationToken);
|
return await service.GetEvidenceAsync(evidenceId, cancellationToken);
|
||||||
|
|
||||||
var cacheKey = HashService.ToMd5Hash($"getevidence-{evidenceId}").Value;
|
var cacheKey = HashService.ToMd5Hash($"getevidence-{evidenceId}").Value;
|
||||||
@@ -45,7 +45,7 @@ public sealed class EvidenceCacheService(EvidenceService service, IDistributedCa
|
|||||||
public async Task<Result<Domain.Models.Evidence[]>> GetEvidenceFilesAsync(Pagination pagination, CancellationToken cancellationToken = default) =>
|
public async Task<Result<Domain.Models.Evidence[]>> GetEvidenceFilesAsync(Pagination pagination, CancellationToken cancellationToken = default) =>
|
||||||
await Result.Try((Func<Task<Result<Domain.Models.Evidence[]>>>)(async () =>
|
await Result.Try((Func<Task<Result<Domain.Models.Evidence[]>>>)(async () =>
|
||||||
{
|
{
|
||||||
if (!await featureManager.IsEnabledAsync(ServiceCachingFeature))
|
if (!await featureManager.IsEnabledAsync(FeatureServiceCaching))
|
||||||
return await service.GetEvidenceFilesAsync(pagination, cancellationToken);
|
return await service.GetEvidenceFilesAsync(pagination, cancellationToken);
|
||||||
|
|
||||||
var cacheKey = HashService.ToMd5Hash($"getevidencefiles-{pagination.Page}-{pagination.PageSize}").Value;
|
var cacheKey = HashService.ToMd5Hash($"getevidencefiles-{pagination.Page}-{pagination.PageSize}").Value;
|
||||||
@@ -69,7 +69,7 @@ public sealed class EvidenceCacheService(EvidenceService service, IDistributedCa
|
|||||||
public async Task<Result<string>> GetEvidenceUrlAsync(string evidenceIdHash, CancellationToken cancellationToken = default) =>
|
public async Task<Result<string>> GetEvidenceUrlAsync(string evidenceIdHash, CancellationToken cancellationToken = default) =>
|
||||||
await Result.Try((Func<Task<Result<string>>>)(async () =>
|
await Result.Try((Func<Task<Result<string>>>)(async () =>
|
||||||
{
|
{
|
||||||
if (!await featureManager.IsEnabledAsync(ServiceCachingFeature))
|
if (!await featureManager.IsEnabledAsync(FeatureServiceCaching))
|
||||||
return await service.GetEvidenceUrlAsync(evidenceIdHash, cancellationToken);
|
return await service.GetEvidenceUrlAsync(evidenceIdHash, cancellationToken);
|
||||||
|
|
||||||
var cacheKey = HashService.ToMd5Hash($"getevidenceurl-{evidenceIdHash}").Value;
|
var cacheKey = HashService.ToMd5Hash($"getevidenceurl-{evidenceIdHash}").Value;
|
||||||
@@ -93,7 +93,7 @@ public sealed class EvidenceCacheService(EvidenceService service, IDistributedCa
|
|||||||
public async Task<Result<Indicator>> GetIndicatorAsync(string indicatorId, CancellationToken cancellationToken = default) =>
|
public async Task<Result<Indicator>> GetIndicatorAsync(string indicatorId, CancellationToken cancellationToken = default) =>
|
||||||
await Result.Try((Func<Task<Result<Indicator>>>)(async () =>
|
await Result.Try((Func<Task<Result<Indicator>>>)(async () =>
|
||||||
{
|
{
|
||||||
if (!await featureManager.IsEnabledAsync(ServiceCachingFeature))
|
if (!await featureManager.IsEnabledAsync(FeatureServiceCaching))
|
||||||
return await service.GetIndicatorAsync(indicatorId, cancellationToken);
|
return await service.GetIndicatorAsync(indicatorId, cancellationToken);
|
||||||
|
|
||||||
var cacheKey = HashService.ToMd5Hash($"getindicator-{indicatorId}").Value;
|
var cacheKey = HashService.ToMd5Hash($"getindicator-{indicatorId}").Value;
|
||||||
@@ -117,7 +117,7 @@ public sealed class EvidenceCacheService(EvidenceService service, IDistributedCa
|
|||||||
public async Task<Result<Indicator[]>> GetIndicatorsAsync(Pagination pagination, CancellationToken cancellationToken = default) =>
|
public async Task<Result<Indicator[]>> GetIndicatorsAsync(Pagination pagination, CancellationToken cancellationToken = default) =>
|
||||||
await Result.Try((Func<Task<Result<Indicator[]>>>)(async () =>
|
await Result.Try((Func<Task<Result<Indicator[]>>>)(async () =>
|
||||||
{
|
{
|
||||||
if (!await featureManager.IsEnabledAsync(ServiceCachingFeature))
|
if (!await featureManager.IsEnabledAsync(FeatureServiceCaching))
|
||||||
return await service.GetIndicatorsAsync(pagination, cancellationToken);
|
return await service.GetIndicatorsAsync(pagination, cancellationToken);
|
||||||
|
|
||||||
var cacheKey = HashService.ToMd5Hash($"getindicators-{pagination.Page}-{pagination.PageSize}").Value;
|
var cacheKey = HashService.ToMd5Hash($"getindicators-{pagination.Page}-{pagination.PageSize}").Value;
|
||||||
@@ -141,7 +141,7 @@ public sealed class EvidenceCacheService(EvidenceService service, IDistributedCa
|
|||||||
public async Task<Result<Domain.Models.Evidence[]>> GetMilestoneEvidencesAsync(string milestoneIdHash, CancellationToken cancellationToken = default) =>
|
public async Task<Result<Domain.Models.Evidence[]>> GetMilestoneEvidencesAsync(string milestoneIdHash, CancellationToken cancellationToken = default) =>
|
||||||
await Result.Try((Func<Task<Result<Domain.Models.Evidence[]>>>)(async () =>
|
await Result.Try((Func<Task<Result<Domain.Models.Evidence[]>>>)(async () =>
|
||||||
{
|
{
|
||||||
if (!await featureManager.IsEnabledAsync(ServiceCachingFeature))
|
if (!await featureManager.IsEnabledAsync(FeatureServiceCaching))
|
||||||
return await service.GetMilestoneEvidencesAsync(milestoneIdHash, cancellationToken);
|
return await service.GetMilestoneEvidencesAsync(milestoneIdHash, cancellationToken);
|
||||||
|
|
||||||
var cacheKey = HashService.ToMd5Hash($"getmilestoneevidences-{milestoneIdHash}").Value;
|
var cacheKey = HashService.ToMd5Hash($"getmilestoneevidences-{milestoneIdHash}").Value;
|
||||||
@@ -165,7 +165,7 @@ public sealed class EvidenceCacheService(EvidenceService service, IDistributedCa
|
|||||||
public async Task<Result<Domain.Models.Evidence[]>> GetProjectEvidencesAsync(string projectIdHash, CancellationToken cancellationToken = default) =>
|
public async Task<Result<Domain.Models.Evidence[]>> GetProjectEvidencesAsync(string projectIdHash, CancellationToken cancellationToken = default) =>
|
||||||
await Result.Try((Func<Task<Result<Domain.Models.Evidence[]>>>)(async () =>
|
await Result.Try((Func<Task<Result<Domain.Models.Evidence[]>>>)(async () =>
|
||||||
{
|
{
|
||||||
if (!await featureManager.IsEnabledAsync(ServiceCachingFeature))
|
if (!await featureManager.IsEnabledAsync(FeatureServiceCaching))
|
||||||
return await service.GetProjectEvidencesAsync(projectIdHash, cancellationToken);
|
return await service.GetProjectEvidencesAsync(projectIdHash, cancellationToken);
|
||||||
|
|
||||||
var cacheKey = HashService.ToMd5Hash($"getprojectevidences-{projectIdHash}").Value;
|
var cacheKey = HashService.ToMd5Hash($"getprojectevidences-{projectIdHash}").Value;
|
||||||
|
|||||||
@@ -24,7 +24,7 @@ public sealed class ExecutionCacheService(ExecutionService service, IDistributed
|
|||||||
public async Task<Result<Disbursement>> GetDisbursementAsync(string disbursementIdHash, CancellationToken cancellationToken = default) =>
|
public async Task<Result<Disbursement>> GetDisbursementAsync(string disbursementIdHash, CancellationToken cancellationToken = default) =>
|
||||||
await Result.Try((Func<Task<Result<Disbursement>>>)(async () =>
|
await Result.Try((Func<Task<Result<Disbursement>>>)(async () =>
|
||||||
{
|
{
|
||||||
if (!await featureManager.IsEnabledAsync(ServiceCachingFeature))
|
if (!await featureManager.IsEnabledAsync(FeatureServiceCaching))
|
||||||
return await service.GetDisbursementAsync(disbursementIdHash, cancellationToken);
|
return await service.GetDisbursementAsync(disbursementIdHash, cancellationToken);
|
||||||
|
|
||||||
var cacheKey = HashService.ToMd5Hash($"getdisbursement-{disbursementIdHash}").Value;
|
var cacheKey = HashService.ToMd5Hash($"getdisbursement-{disbursementIdHash}").Value;
|
||||||
@@ -48,7 +48,7 @@ public sealed class ExecutionCacheService(ExecutionService service, IDistributed
|
|||||||
public async Task<Result<Disbursement[]>> GetDisbursementsAsync(Pagination pagination, CancellationToken cancellationToken = default) =>
|
public async Task<Result<Disbursement[]>> GetDisbursementsAsync(Pagination pagination, CancellationToken cancellationToken = default) =>
|
||||||
await Result.Try((Func<Task<Result<Disbursement[]>>>)(async () =>
|
await Result.Try((Func<Task<Result<Disbursement[]>>>)(async () =>
|
||||||
{
|
{
|
||||||
if (!await featureManager.IsEnabledAsync(ServiceCachingFeature))
|
if (!await featureManager.IsEnabledAsync(FeatureServiceCaching))
|
||||||
return await service.GetDisbursementsAsync(pagination, cancellationToken);
|
return await service.GetDisbursementsAsync(pagination, cancellationToken);
|
||||||
|
|
||||||
var cacheKey = HashService.ToMd5Hash($"getdisbursements-{pagination.Page}-{pagination.PageSize}-{pagination.Index}").Value;
|
var cacheKey = HashService.ToMd5Hash($"getdisbursements-{pagination.Page}-{pagination.PageSize}-{pagination.Index}").Value;
|
||||||
@@ -72,7 +72,7 @@ public sealed class ExecutionCacheService(ExecutionService service, IDistributed
|
|||||||
public async Task<Result<Risk>> GetRiskAsync(string riskIdHash, CancellationToken cancellationToken = default) =>
|
public async Task<Result<Risk>> GetRiskAsync(string riskIdHash, CancellationToken cancellationToken = default) =>
|
||||||
await Result.Try((Func<Task<Result<Risk>>>)(async () =>
|
await Result.Try((Func<Task<Result<Risk>>>)(async () =>
|
||||||
{
|
{
|
||||||
if (!await featureManager.IsEnabledAsync(ServiceCachingFeature))
|
if (!await featureManager.IsEnabledAsync(FeatureServiceCaching))
|
||||||
return await service.GetRiskAsync(riskIdHash, cancellationToken);
|
return await service.GetRiskAsync(riskIdHash, cancellationToken);
|
||||||
|
|
||||||
var cacheKey = HashService.ToMd5Hash($"getrisk-{riskIdHash}").Value;
|
var cacheKey = HashService.ToMd5Hash($"getrisk-{riskIdHash}").Value;
|
||||||
@@ -96,7 +96,7 @@ public sealed class ExecutionCacheService(ExecutionService service, IDistributed
|
|||||||
public async Task<Result<Risk[]>> GetRisksAsync(Pagination pagination, CancellationToken cancellationToken = default) =>
|
public async Task<Result<Risk[]>> GetRisksAsync(Pagination pagination, CancellationToken cancellationToken = default) =>
|
||||||
await Result.Try((Func<Task<Result<Risk[]>>>)(async () =>
|
await Result.Try((Func<Task<Result<Risk[]>>>)(async () =>
|
||||||
{
|
{
|
||||||
if (!await featureManager.IsEnabledAsync(ServiceCachingFeature))
|
if (!await featureManager.IsEnabledAsync(FeatureServiceCaching))
|
||||||
return await service.GetRisksAsync(pagination, cancellationToken);
|
return await service.GetRisksAsync(pagination, cancellationToken);
|
||||||
|
|
||||||
var cacheKey = HashService.ToMd5Hash($"getrisks-{pagination.Page}-{pagination.PageSize}-{pagination.Index}").Value;
|
var cacheKey = HashService.ToMd5Hash($"getrisks-{pagination.Page}-{pagination.PageSize}-{pagination.Index}").Value;
|
||||||
@@ -120,7 +120,7 @@ public sealed class ExecutionCacheService(ExecutionService service, IDistributed
|
|||||||
public async Task<Result<Rule>> GetRuleAsync(string ruleIdHash, CancellationToken cancellationToken = default) =>
|
public async Task<Result<Rule>> GetRuleAsync(string ruleIdHash, CancellationToken cancellationToken = default) =>
|
||||||
await Result.Try((Func<Task<Result<Rule>>>)(async () =>
|
await Result.Try((Func<Task<Result<Rule>>>)(async () =>
|
||||||
{
|
{
|
||||||
if (!await featureManager.IsEnabledAsync(ServiceCachingFeature))
|
if (!await featureManager.IsEnabledAsync(FeatureServiceCaching))
|
||||||
return await service.GetRuleAsync(ruleIdHash, cancellationToken);
|
return await service.GetRuleAsync(ruleIdHash, cancellationToken);
|
||||||
|
|
||||||
var cacheKey = HashService.ToMd5Hash($"getrisk-{ruleIdHash}").Value;
|
var cacheKey = HashService.ToMd5Hash($"getrisk-{ruleIdHash}").Value;
|
||||||
@@ -144,7 +144,7 @@ public sealed class ExecutionCacheService(ExecutionService service, IDistributed
|
|||||||
public async Task<Result<Rule[]>> GetRulesAsync(Pagination pagination, CancellationToken cancellationToken = default) =>
|
public async Task<Result<Rule[]>> GetRulesAsync(Pagination pagination, CancellationToken cancellationToken = default) =>
|
||||||
await Result.Try((Func<Task<Result<Rule[]>>>)(async () =>
|
await Result.Try((Func<Task<Result<Rule[]>>>)(async () =>
|
||||||
{
|
{
|
||||||
if (!await featureManager.IsEnabledAsync(ServiceCachingFeature))
|
if (!await featureManager.IsEnabledAsync(FeatureServiceCaching))
|
||||||
return await service.GetRulesAsync(pagination, cancellationToken);
|
return await service.GetRulesAsync(pagination, cancellationToken);
|
||||||
|
|
||||||
var cacheKey = HashService.ToMd5Hash($"getrules-{pagination.Page}-{pagination.PageSize}-{pagination.Index}").Value;
|
var cacheKey = HashService.ToMd5Hash($"getrules-{pagination.Page}-{pagination.PageSize}-{pagination.Index}").Value;
|
||||||
|
|||||||
@@ -27,7 +27,7 @@ public sealed class GovernanceCacheService(GovernanceService service, IDistribut
|
|||||||
public async Task<Result<Dashboard>> GetDashboardAsync(CancellationToken cancellationToken = default) =>
|
public async Task<Result<Dashboard>> GetDashboardAsync(CancellationToken cancellationToken = default) =>
|
||||||
await Result.Try((Func<Task<Result<Dashboard>>>)(async () =>
|
await Result.Try((Func<Task<Result<Dashboard>>>)(async () =>
|
||||||
{
|
{
|
||||||
if (!await featureManager.IsEnabledAsync(ServiceCachingFeature))
|
if (!await featureManager.IsEnabledAsync(FeatureServiceCaching))
|
||||||
return await service.GetDashboardAsync(cancellationToken);
|
return await service.GetDashboardAsync(cancellationToken);
|
||||||
|
|
||||||
var cacheKey = HashService.ToMd5Hash($"getdashboard").Value;
|
var cacheKey = HashService.ToMd5Hash($"getdashboard").Value;
|
||||||
@@ -51,7 +51,7 @@ public sealed class GovernanceCacheService(GovernanceService service, IDistribut
|
|||||||
public async Task<Result<Instrument[]>> GetInstrumentsAsync(string programmeIdHash, CancellationToken cancellationToken = default) =>
|
public async Task<Result<Instrument[]>> GetInstrumentsAsync(string programmeIdHash, CancellationToken cancellationToken = default) =>
|
||||||
await Result.Try((Func<Task<Result<Instrument[]>>>)(async () =>
|
await Result.Try((Func<Task<Result<Instrument[]>>>)(async () =>
|
||||||
{
|
{
|
||||||
if (!await featureManager.IsEnabledAsync(ServiceCachingFeature))
|
if (!await featureManager.IsEnabledAsync(FeatureServiceCaching))
|
||||||
return await service.GetInstrumentsAsync(programmeIdHash, cancellationToken);
|
return await service.GetInstrumentsAsync(programmeIdHash, cancellationToken);
|
||||||
|
|
||||||
var cacheKey = HashService.ToMd5Hash($"getinstruments").Value;
|
var cacheKey = HashService.ToMd5Hash($"getinstruments").Value;
|
||||||
@@ -75,7 +75,7 @@ public sealed class GovernanceCacheService(GovernanceService service, IDistribut
|
|||||||
public async Task<Result<Organisation>> GetOrganisationAsync(string organisationIdHash, CancellationToken cancellationToken = default) =>
|
public async Task<Result<Organisation>> GetOrganisationAsync(string organisationIdHash, CancellationToken cancellationToken = default) =>
|
||||||
await Result.Try((Func<Task<Result<Organisation>>>)(async () =>
|
await Result.Try((Func<Task<Result<Organisation>>>)(async () =>
|
||||||
{
|
{
|
||||||
if (!await featureManager.IsEnabledAsync(ServiceCachingFeature))
|
if (!await featureManager.IsEnabledAsync(FeatureServiceCaching))
|
||||||
return await service.GetOrganisationAsync(organisationIdHash, cancellationToken);
|
return await service.GetOrganisationAsync(organisationIdHash, cancellationToken);
|
||||||
|
|
||||||
var cacheKey = HashService.ToMd5Hash($"getorganisation-{organisationIdHash}").Value;
|
var cacheKey = HashService.ToMd5Hash($"getorganisation-{organisationIdHash}").Value;
|
||||||
@@ -99,7 +99,7 @@ public sealed class GovernanceCacheService(GovernanceService service, IDistribut
|
|||||||
public async Task<Result<Organisation[]>> GetOrganisationsAsync(Pagination pagination, CancellationToken cancellationToken = default) =>
|
public async Task<Result<Organisation[]>> GetOrganisationsAsync(Pagination pagination, CancellationToken cancellationToken = default) =>
|
||||||
await Result.Try((Func<Task<Result<Organisation[]>>>)(async () =>
|
await Result.Try((Func<Task<Result<Organisation[]>>>)(async () =>
|
||||||
{
|
{
|
||||||
if (!await featureManager.IsEnabledAsync(ServiceCachingFeature))
|
if (!await featureManager.IsEnabledAsync(FeatureServiceCaching))
|
||||||
return await service.GetOrganisationsAsync(pagination, cancellationToken);
|
return await service.GetOrganisationsAsync(pagination, cancellationToken);
|
||||||
|
|
||||||
var cacheKey = HashService.ToMd5Hash($"getorganisations-{pagination.Page}-{pagination.PageSize}").Value;
|
var cacheKey = HashService.ToMd5Hash($"getorganisations-{pagination.Page}-{pagination.PageSize}").Value;
|
||||||
@@ -123,7 +123,7 @@ public sealed class GovernanceCacheService(GovernanceService service, IDistribut
|
|||||||
public async Task<Result<Portfolio[]>> GetOrganisationPorfoliosAsync(string organisationIdHash, CancellationToken cancellationToken = default) =>
|
public async Task<Result<Portfolio[]>> GetOrganisationPorfoliosAsync(string organisationIdHash, CancellationToken cancellationToken = default) =>
|
||||||
await Result.Try((Func<Task<Result<Portfolio[]>>>)(async () =>
|
await Result.Try((Func<Task<Result<Portfolio[]>>>)(async () =>
|
||||||
{
|
{
|
||||||
if (!await featureManager.IsEnabledAsync(ServiceCachingFeature))
|
if (!await featureManager.IsEnabledAsync(FeatureServiceCaching))
|
||||||
return await service.GetOrganisationPorfoliosAsync(organisationIdHash, cancellationToken);
|
return await service.GetOrganisationPorfoliosAsync(organisationIdHash, cancellationToken);
|
||||||
|
|
||||||
var cacheKey = HashService.ToMd5Hash($"getorganisationportfolios-{organisationIdHash}").Value;
|
var cacheKey = HashService.ToMd5Hash($"getorganisationportfolios-{organisationIdHash}").Value;
|
||||||
@@ -147,7 +147,7 @@ public sealed class GovernanceCacheService(GovernanceService service, IDistribut
|
|||||||
public async Task<Result<Portfolio>> GetPortfolioAsync(string portfolioIdHash, CancellationToken cancellationToken = default) =>
|
public async Task<Result<Portfolio>> GetPortfolioAsync(string portfolioIdHash, CancellationToken cancellationToken = default) =>
|
||||||
await Result.Try((Func<Task<Result<Portfolio>>>)(async () =>
|
await Result.Try((Func<Task<Result<Portfolio>>>)(async () =>
|
||||||
{
|
{
|
||||||
if (!await featureManager.IsEnabledAsync(ServiceCachingFeature))
|
if (!await featureManager.IsEnabledAsync(FeatureServiceCaching))
|
||||||
return await service.GetPortfolioAsync(portfolioIdHash, cancellationToken);
|
return await service.GetPortfolioAsync(portfolioIdHash, cancellationToken);
|
||||||
|
|
||||||
var cacheKey = HashService.ToMd5Hash($"getportfolio-{portfolioIdHash}").Value;
|
var cacheKey = HashService.ToMd5Hash($"getportfolio-{portfolioIdHash}").Value;
|
||||||
@@ -171,7 +171,7 @@ public sealed class GovernanceCacheService(GovernanceService service, IDistribut
|
|||||||
public async Task<Result<Programme[]>> GetPortfolioProgrammesAsync(string portfolioIdHash, CancellationToken cancellationToken = default) =>
|
public async Task<Result<Programme[]>> GetPortfolioProgrammesAsync(string portfolioIdHash, CancellationToken cancellationToken = default) =>
|
||||||
await Result.Try((Func<Task<Result<Programme[]>>>)(async () =>
|
await Result.Try((Func<Task<Result<Programme[]>>>)(async () =>
|
||||||
{
|
{
|
||||||
if (!await featureManager.IsEnabledAsync(ServiceCachingFeature))
|
if (!await featureManager.IsEnabledAsync(FeatureServiceCaching))
|
||||||
return await service.GetPortfolioProgrammesAsync(portfolioIdHash, cancellationToken);
|
return await service.GetPortfolioProgrammesAsync(portfolioIdHash, cancellationToken);
|
||||||
|
|
||||||
var cacheKey = HashService.ToMd5Hash($"getportfolioprogrammes-{portfolioIdHash}").Value;
|
var cacheKey = HashService.ToMd5Hash($"getportfolioprogrammes-{portfolioIdHash}").Value;
|
||||||
@@ -195,7 +195,7 @@ public sealed class GovernanceCacheService(GovernanceService service, IDistribut
|
|||||||
public async Task<Result<Programme[]>> GetProgrammesAsync(string portfolioIdHash, CancellationToken cancellationToken = default) =>
|
public async Task<Result<Programme[]>> GetProgrammesAsync(string portfolioIdHash, CancellationToken cancellationToken = default) =>
|
||||||
await Result.Try((Func<Task<Result<Programme[]>>>)(async () =>
|
await Result.Try((Func<Task<Result<Programme[]>>>)(async () =>
|
||||||
{
|
{
|
||||||
if (!await featureManager.IsEnabledAsync(ServiceCachingFeature))
|
if (!await featureManager.IsEnabledAsync(FeatureServiceCaching))
|
||||||
return await service.GetProgrammesAsync(portfolioIdHash, cancellationToken);
|
return await service.GetProgrammesAsync(portfolioIdHash, cancellationToken);
|
||||||
|
|
||||||
var cacheKey = HashService.ToMd5Hash($"getprogrammes-{portfolioIdHash}").Value;
|
var cacheKey = HashService.ToMd5Hash($"getprogrammes-{portfolioIdHash}").Value;
|
||||||
@@ -219,7 +219,7 @@ public sealed class GovernanceCacheService(GovernanceService service, IDistribut
|
|||||||
public async Task<Result<Tenant>> GetTenantAsync(string tenantIdHash, CancellationToken cancellationToken = default) =>
|
public async Task<Result<Tenant>> GetTenantAsync(string tenantIdHash, CancellationToken cancellationToken = default) =>
|
||||||
await Result.Try((Func<Task<Result<Tenant>>>)(async () =>
|
await Result.Try((Func<Task<Result<Tenant>>>)(async () =>
|
||||||
{
|
{
|
||||||
if (!await featureManager.IsEnabledAsync(ServiceCachingFeature))
|
if (!await featureManager.IsEnabledAsync(FeatureServiceCaching))
|
||||||
return await service.GetTenantAsync(tenantIdHash, cancellationToken);
|
return await service.GetTenantAsync(tenantIdHash, cancellationToken);
|
||||||
|
|
||||||
var cacheKey = HashService.ToMd5Hash($"gettenant-{tenantIdHash}").Value;
|
var cacheKey = HashService.ToMd5Hash($"gettenant-{tenantIdHash}").Value;
|
||||||
@@ -243,7 +243,7 @@ public sealed class GovernanceCacheService(GovernanceService service, IDistribut
|
|||||||
public async Task<Result<TenantOrganisation>> GetTenantOrganisationAsync(string tenantOrganisationIdHash, CancellationToken cancellationToken = default) =>
|
public async Task<Result<TenantOrganisation>> GetTenantOrganisationAsync(string tenantOrganisationIdHash, CancellationToken cancellationToken = default) =>
|
||||||
await Result.Try((Func<Task<Result<TenantOrganisation>>>)(async () =>
|
await Result.Try((Func<Task<Result<TenantOrganisation>>>)(async () =>
|
||||||
{
|
{
|
||||||
if (!await featureManager.IsEnabledAsync(ServiceCachingFeature))
|
if (!await featureManager.IsEnabledAsync(FeatureServiceCaching))
|
||||||
return await service.GetTenantOrganisationAsync(tenantOrganisationIdHash, cancellationToken);
|
return await service.GetTenantOrganisationAsync(tenantOrganisationIdHash, cancellationToken);
|
||||||
|
|
||||||
var cacheKey = HashService.ToMd5Hash($"gettenantorganisation-{tenantOrganisationIdHash}").Value;
|
var cacheKey = HashService.ToMd5Hash($"gettenantorganisation-{tenantOrganisationIdHash}").Value;
|
||||||
@@ -267,7 +267,7 @@ public sealed class GovernanceCacheService(GovernanceService service, IDistribut
|
|||||||
public async Task<Result<TenantOrganisation[]>> GetTenantOrganisationsAsync(string tenantIdHash, CancellationToken cancellationToken = default) =>
|
public async Task<Result<TenantOrganisation[]>> GetTenantOrganisationsAsync(string tenantIdHash, CancellationToken cancellationToken = default) =>
|
||||||
await Result.Try((Func<Task<Result<TenantOrganisation[]>>>)(async () =>
|
await Result.Try((Func<Task<Result<TenantOrganisation[]>>>)(async () =>
|
||||||
{
|
{
|
||||||
if (!await featureManager.IsEnabledAsync(ServiceCachingFeature))
|
if (!await featureManager.IsEnabledAsync(FeatureServiceCaching))
|
||||||
return await service.GetTenantOrganisationsAsync(tenantIdHash, cancellationToken);
|
return await service.GetTenantOrganisationsAsync(tenantIdHash, cancellationToken);
|
||||||
|
|
||||||
var cacheKey = HashService.ToMd5Hash($"gettenantorganisations-{tenantIdHash}").Value;
|
var cacheKey = HashService.ToMd5Hash($"gettenantorganisations-{tenantIdHash}").Value;
|
||||||
@@ -294,7 +294,7 @@ public sealed class GovernanceCacheService(GovernanceService service, IDistribut
|
|||||||
public async Task<Result<Tenant[]>> GetTenantsAsync(CancellationToken cancellationToken = default) =>
|
public async Task<Result<Tenant[]>> GetTenantsAsync(CancellationToken cancellationToken = default) =>
|
||||||
await Result.Try((Func<Task<Result<Tenant[]>>>)(async () =>
|
await Result.Try((Func<Task<Result<Tenant[]>>>)(async () =>
|
||||||
{
|
{
|
||||||
if (!await featureManager.IsEnabledAsync(ServiceCachingFeature))
|
if (!await featureManager.IsEnabledAsync(FeatureServiceCaching))
|
||||||
return await service.GetTenantsAsync(cancellationToken);
|
return await service.GetTenantsAsync(cancellationToken);
|
||||||
|
|
||||||
var cacheKey = HashService.ToMd5Hash($"gettenants").Value;
|
var cacheKey = HashService.ToMd5Hash($"gettenants").Value;
|
||||||
@@ -336,7 +336,7 @@ public sealed class GovernanceCacheService(GovernanceService service, IDistribut
|
|||||||
public async Task<Result<Portfolio[]>> GetPorfoliosAsync(Pagination pagination, CancellationToken cancellationToken = default) =>
|
public async Task<Result<Portfolio[]>> GetPorfoliosAsync(Pagination pagination, CancellationToken cancellationToken = default) =>
|
||||||
await Result.Try((Func<Task<Result<Portfolio[]>>>)(async () =>
|
await Result.Try((Func<Task<Result<Portfolio[]>>>)(async () =>
|
||||||
{
|
{
|
||||||
if (!await featureManager.IsEnabledAsync(ServiceCachingFeature))
|
if (!await featureManager.IsEnabledAsync(FeatureServiceCaching))
|
||||||
return await service.GetPorfoliosAsync(pagination, cancellationToken);
|
return await service.GetPorfoliosAsync(pagination, cancellationToken);
|
||||||
|
|
||||||
var cacheKey = HashService.ToMd5Hash($"getportfolios-{pagination.Page}-{pagination.PageSize}").Value;
|
var cacheKey = HashService.ToMd5Hash($"getportfolios-{pagination.Page}-{pagination.PageSize}").Value;
|
||||||
@@ -360,7 +360,7 @@ public sealed class GovernanceCacheService(GovernanceService service, IDistribut
|
|||||||
public async Task<Result<decimal>> GetProgrammeValueAsync(string programmeIdHash, CancellationToken cancellationToken = default) =>
|
public async Task<Result<decimal>> GetProgrammeValueAsync(string programmeIdHash, CancellationToken cancellationToken = default) =>
|
||||||
await Result.Try((Func<Task<Result<decimal>>>)(async () =>
|
await Result.Try((Func<Task<Result<decimal>>>)(async () =>
|
||||||
{
|
{
|
||||||
if (!await featureManager.IsEnabledAsync(ServiceCachingFeature))
|
if (!await featureManager.IsEnabledAsync(FeatureServiceCaching))
|
||||||
return await service.GetProgrammeValueAsync(programmeIdHash, cancellationToken);
|
return await service.GetProgrammeValueAsync(programmeIdHash, cancellationToken);
|
||||||
|
|
||||||
var cacheKey = HashService.ToMd5Hash($"getprogrammevalue-{programmeIdHash}").Value;
|
var cacheKey = HashService.ToMd5Hash($"getprogrammevalue-{programmeIdHash}").Value;
|
||||||
@@ -386,7 +386,7 @@ public sealed class GovernanceCacheService(GovernanceService service, IDistribut
|
|||||||
public async Task<Result<decimal>> GetPorfolioValueAsync(string portfolioIdHash, CancellationToken cancellationToken = default) =>
|
public async Task<Result<decimal>> GetPorfolioValueAsync(string portfolioIdHash, CancellationToken cancellationToken = default) =>
|
||||||
await Result.Try((Func<Task<Result<decimal>>>)(async () =>
|
await Result.Try((Func<Task<Result<decimal>>>)(async () =>
|
||||||
{
|
{
|
||||||
if (!await featureManager.IsEnabledAsync(ServiceCachingFeature))
|
if (!await featureManager.IsEnabledAsync(FeatureServiceCaching))
|
||||||
return await service.GetPorfolioValueAsync(portfolioIdHash, cancellationToken);
|
return await service.GetPorfolioValueAsync(portfolioIdHash, cancellationToken);
|
||||||
|
|
||||||
var cacheKey = HashService.ToMd5Hash($"getportfoliovalue-{portfolioIdHash}").Value;
|
var cacheKey = HashService.ToMd5Hash($"getportfoliovalue-{portfolioIdHash}").Value;
|
||||||
|
|||||||
@@ -182,7 +182,7 @@ public sealed class GovernanceService(IDbContextFactory<ApplicationDbContext> co
|
|||||||
if (!await context.Users.AnyAsync(u => u.Id == request.OrganisationUser, cancellationToken))
|
if (!await context.Users.AnyAsync(u => u.Id == request.OrganisationUser, cancellationToken))
|
||||||
return Result.Fail(ValidationError.Create("Organisation user not found"));
|
return Result.Fail(ValidationError.Create("Organisation user not found"));
|
||||||
|
|
||||||
if (!await context.TenantUsers.AnyAsync(u => u.UserId == request.OrganisationUser && u.TenantId == tenantContext.TenantId, cancellationToken))
|
if (!tenantContext.IsAdmin && !await context.TenantUsers.AnyAsync(u => u.UserId == request.OrganisationUser && u.TenantId == tenantContext.TenantId, cancellationToken))
|
||||||
return Result.Fail(ValidationError.Create("Organisation user does not belong to the current tenant"));
|
return Result.Fail(ValidationError.Create("Organisation user does not belong to the current tenant"));
|
||||||
|
|
||||||
var changedTotal = await context.Organisations
|
var changedTotal = await context.Organisations
|
||||||
@@ -192,6 +192,7 @@ public sealed class GovernanceService(IDbContextFactory<ApplicationDbContext> co
|
|||||||
.SetProperty(u => u.RegistrationNo, request.RegistrationNo.Clean())
|
.SetProperty(u => u.RegistrationNo, request.RegistrationNo.Clean())
|
||||||
.SetProperty(u => u.Email, request.Email.ToLower(CultureInfo.InvariantCulture).Clean())
|
.SetProperty(u => u.Email, request.Email.ToLower(CultureInfo.InvariantCulture).Clean())
|
||||||
.SetProperty(u => u.Type, request.Type)
|
.SetProperty(u => u.Type, request.Type)
|
||||||
|
.SetProperty(u => u.UserId, request.OrganisationUser)
|
||||||
.SetProperty(u => u.Status, request.Status), cancellationToken);
|
.SetProperty(u => u.Status, request.Status), cancellationToken);
|
||||||
|
|
||||||
var tenantOrganisation = await context.TenantOrganisations.AsNoTracking().FirstOrDefaultAsync(f => f.OrganisationId == request.OrganisationId, cancellationToken);
|
var tenantOrganisation = await context.TenantOrganisations.AsNoTracking().FirstOrDefaultAsync(f => f.OrganisationId == request.OrganisationId, cancellationToken);
|
||||||
|
|||||||
@@ -27,7 +27,7 @@ public sealed class GrantsCacheService(GrantsService service, IDistributedCache
|
|||||||
public async Task<Result<Award>> GetAwardAsync(string awardIdHash, CancellationToken cancellationToken = default) =>
|
public async Task<Result<Award>> GetAwardAsync(string awardIdHash, CancellationToken cancellationToken = default) =>
|
||||||
await Result.Try((Func<Task<Result<Award>>>)(async () =>
|
await Result.Try((Func<Task<Result<Award>>>)(async () =>
|
||||||
{
|
{
|
||||||
if (!await featureManager.IsEnabledAsync(ServiceCachingFeature))
|
if (!await featureManager.IsEnabledAsync(FeatureServiceCaching))
|
||||||
return await service.GetAwardAsync(awardIdHash, cancellationToken);
|
return await service.GetAwardAsync(awardIdHash, cancellationToken);
|
||||||
|
|
||||||
var cacheKey = HashService.ToMd5Hash($"getaward-{awardIdHash}").Value;
|
var cacheKey = HashService.ToMd5Hash($"getaward-{awardIdHash}").Value;
|
||||||
@@ -51,7 +51,7 @@ public sealed class GrantsCacheService(GrantsService service, IDistributedCache
|
|||||||
public async Task<Result<Award[]>> GetAwardsAsync(Pagination pagination, CancellationToken cancellationToken = default) =>
|
public async Task<Result<Award[]>> GetAwardsAsync(Pagination pagination, CancellationToken cancellationToken = default) =>
|
||||||
await Result.Try((Func<Task<Result<Award[]>>>)(async () =>
|
await Result.Try((Func<Task<Result<Award[]>>>)(async () =>
|
||||||
{
|
{
|
||||||
if (!await featureManager.IsEnabledAsync(ServiceCachingFeature))
|
if (!await featureManager.IsEnabledAsync(FeatureServiceCaching))
|
||||||
return await service.GetAwardsAsync(pagination, cancellationToken);
|
return await service.GetAwardsAsync(pagination, cancellationToken);
|
||||||
|
|
||||||
var cacheKey = HashService.ToMd5Hash($"getawards-{pagination.Page}-{pagination.PageSize}-{pagination.Index}").Value;
|
var cacheKey = HashService.ToMd5Hash($"getawards-{pagination.Page}-{pagination.PageSize}-{pagination.Index}").Value;
|
||||||
@@ -75,7 +75,7 @@ public sealed class GrantsCacheService(GrantsService service, IDistributedCache
|
|||||||
public async Task<Result<Budget>> GetBudgetAsync(string budgetIdHash, CancellationToken cancellationToken = default) =>
|
public async Task<Result<Budget>> GetBudgetAsync(string budgetIdHash, CancellationToken cancellationToken = default) =>
|
||||||
await Result.Try((Func<Task<Result<Budget>>>)(async () =>
|
await Result.Try((Func<Task<Result<Budget>>>)(async () =>
|
||||||
{
|
{
|
||||||
if (!await featureManager.IsEnabledAsync(ServiceCachingFeature))
|
if (!await featureManager.IsEnabledAsync(FeatureServiceCaching))
|
||||||
return await service.GetBudgetAsync(budgetIdHash, cancellationToken);
|
return await service.GetBudgetAsync(budgetIdHash, cancellationToken);
|
||||||
|
|
||||||
var cacheKey = HashService.ToMd5Hash($"getbudget-{budgetIdHash}").Value;
|
var cacheKey = HashService.ToMd5Hash($"getbudget-{budgetIdHash}").Value;
|
||||||
@@ -99,7 +99,7 @@ public sealed class GrantsCacheService(GrantsService service, IDistributedCache
|
|||||||
public async Task<Result<Budget[]>> GetBudgetsAsync(Pagination pagination, CancellationToken cancellationToken = default) =>
|
public async Task<Result<Budget[]>> GetBudgetsAsync(Pagination pagination, CancellationToken cancellationToken = default) =>
|
||||||
await Result.Try((Func<Task<Result<Budget[]>>>)(async () =>
|
await Result.Try((Func<Task<Result<Budget[]>>>)(async () =>
|
||||||
{
|
{
|
||||||
if (!await featureManager.IsEnabledAsync(ServiceCachingFeature))
|
if (!await featureManager.IsEnabledAsync(FeatureServiceCaching))
|
||||||
return await service.GetBudgetsAsync(pagination, cancellationToken);
|
return await service.GetBudgetsAsync(pagination, cancellationToken);
|
||||||
|
|
||||||
var cacheKey = HashService.ToMd5Hash($"getbudgets-{pagination.Page}-{pagination.PageSize}-{pagination.Index}").Value;
|
var cacheKey = HashService.ToMd5Hash($"getbudgets-{pagination.Page}-{pagination.PageSize}-{pagination.Index}").Value;
|
||||||
@@ -123,7 +123,7 @@ public sealed class GrantsCacheService(GrantsService service, IDistributedCache
|
|||||||
public async Task<Result<Contract>> GetContractAsync(string contractIdHash, CancellationToken cancellationToken = default) =>
|
public async Task<Result<Contract>> GetContractAsync(string contractIdHash, CancellationToken cancellationToken = default) =>
|
||||||
await Result.Try((Func<Task<Result<Contract>>>)(async () =>
|
await Result.Try((Func<Task<Result<Contract>>>)(async () =>
|
||||||
{
|
{
|
||||||
if (!await featureManager.IsEnabledAsync(ServiceCachingFeature))
|
if (!await featureManager.IsEnabledAsync(FeatureServiceCaching))
|
||||||
return await service.GetContractAsync(contractIdHash, cancellationToken);
|
return await service.GetContractAsync(contractIdHash, cancellationToken);
|
||||||
|
|
||||||
var cacheKey = HashService.ToMd5Hash($"getctract-{contractIdHash}").Value;
|
var cacheKey = HashService.ToMd5Hash($"getctract-{contractIdHash}").Value;
|
||||||
@@ -147,7 +147,7 @@ public sealed class GrantsCacheService(GrantsService service, IDistributedCache
|
|||||||
public async Task<Result<Contract[]>> GetContractsAsync(Pagination pagination, CancellationToken cancellationToken = default) =>
|
public async Task<Result<Contract[]>> GetContractsAsync(Pagination pagination, CancellationToken cancellationToken = default) =>
|
||||||
await Result.Try((Func<Task<Result<Contract[]>>>)(async () =>
|
await Result.Try((Func<Task<Result<Contract[]>>>)(async () =>
|
||||||
{
|
{
|
||||||
if (!await featureManager.IsEnabledAsync(ServiceCachingFeature))
|
if (!await featureManager.IsEnabledAsync(FeatureServiceCaching))
|
||||||
return await service.GetContractsAsync(pagination, cancellationToken);
|
return await service.GetContractsAsync(pagination, cancellationToken);
|
||||||
|
|
||||||
var cacheKey = HashService.ToMd5Hash($"getcontracts-{pagination.Index}-{pagination.PageSize}-{pagination.Page}").Value;
|
var cacheKey = HashService.ToMd5Hash($"getcontracts-{pagination.Index}-{pagination.PageSize}-{pagination.Page}").Value;
|
||||||
@@ -171,7 +171,7 @@ public sealed class GrantsCacheService(GrantsService service, IDistributedCache
|
|||||||
public async Task<Result<string>> GetContractUrlAsync(string contractIdHash, CancellationToken cancellationToken = default) =>
|
public async Task<Result<string>> GetContractUrlAsync(string contractIdHash, CancellationToken cancellationToken = default) =>
|
||||||
await Result.Try((Func<Task<Result<string>>>)(async () =>
|
await Result.Try((Func<Task<Result<string>>>)(async () =>
|
||||||
{
|
{
|
||||||
if (!await featureManager.IsEnabledAsync(ServiceCachingFeature))
|
if (!await featureManager.IsEnabledAsync(FeatureServiceCaching))
|
||||||
return await service.GetContractUrlAsync(contractIdHash, cancellationToken);
|
return await service.GetContractUrlAsync(contractIdHash, cancellationToken);
|
||||||
|
|
||||||
var cacheKey = HashService.ToMd5Hash($"getcontracturl-{contractIdHash}").Value;
|
var cacheKey = HashService.ToMd5Hash($"getcontracturl-{contractIdHash}").Value;
|
||||||
|
|||||||
@@ -161,11 +161,6 @@ public sealed class GrantsService(IDbContextFactory<ApplicationDbContext> contex
|
|||||||
if (!await context.Projects.AnyAsync(p => p.Id == request.ProjectId, cancellationToken))
|
if (!await context.Projects.AnyAsync(p => p.Id == request.ProjectId, cancellationToken))
|
||||||
return Result.Fail(NotFoundError.New);
|
return Result.Fail(NotFoundError.New);
|
||||||
|
|
||||||
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
|
var budget = new Domain.Entities.Budget
|
||||||
{
|
{
|
||||||
ProjectId = request.ProjectId,
|
ProjectId = request.ProjectId,
|
||||||
|
|||||||
@@ -42,7 +42,7 @@ public sealed class ProjectCacheService(ProjectService service, IDistributedCach
|
|||||||
public async Task<Result<Domain.Models.Activity[]>> GetActivitiesAsync(string projectIdHash, CancellationToken cancellationToken = default) =>
|
public async Task<Result<Domain.Models.Activity[]>> GetActivitiesAsync(string projectIdHash, CancellationToken cancellationToken = default) =>
|
||||||
await Result.Try((Func<Task<Result<Domain.Models.Activity[]>>>)(async () =>
|
await Result.Try((Func<Task<Result<Domain.Models.Activity[]>>>)(async () =>
|
||||||
{
|
{
|
||||||
if (!await featureManager.IsEnabledAsync(ServiceCachingFeature))
|
if (!await featureManager.IsEnabledAsync(FeatureServiceCaching))
|
||||||
return await service.GetActivitiesAsync(projectIdHash, cancellationToken);
|
return await service.GetActivitiesAsync(projectIdHash, cancellationToken);
|
||||||
|
|
||||||
var cacheKey = HashService.ToMd5Hash($"getactivities-{projectIdHash}").Value;
|
var cacheKey = HashService.ToMd5Hash($"getactivities-{projectIdHash}").Value;
|
||||||
@@ -66,7 +66,7 @@ public sealed class ProjectCacheService(ProjectService service, IDistributedCach
|
|||||||
public async Task<Result<Domain.Models.Activity>> GetActivityAsync(string activityIdHash, CancellationToken cancellationToken = default) =>
|
public async Task<Result<Domain.Models.Activity>> GetActivityAsync(string activityIdHash, CancellationToken cancellationToken = default) =>
|
||||||
await Result.Try((Func<Task<Result<Domain.Models.Activity>>>)(async () =>
|
await Result.Try((Func<Task<Result<Domain.Models.Activity>>>)(async () =>
|
||||||
{
|
{
|
||||||
if (!await featureManager.IsEnabledAsync(ServiceCachingFeature))
|
if (!await featureManager.IsEnabledAsync(FeatureServiceCaching))
|
||||||
return await service.GetActivityAsync(activityIdHash, cancellationToken);
|
return await service.GetActivityAsync(activityIdHash, cancellationToken);
|
||||||
|
|
||||||
var cacheKey = HashService.ToMd5Hash($"getactivity-{activityIdHash}").Value;
|
var cacheKey = HashService.ToMd5Hash($"getactivity-{activityIdHash}").Value;
|
||||||
@@ -90,7 +90,7 @@ public sealed class ProjectCacheService(ProjectService service, IDistributedCach
|
|||||||
public async Task<Result<Beneficiary[]>> GetBeneficiariesAsync(string projectIdHash, CancellationToken cancellationToken = default) =>
|
public async Task<Result<Beneficiary[]>> GetBeneficiariesAsync(string projectIdHash, CancellationToken cancellationToken = default) =>
|
||||||
await Result.Try((Func<Task<Result<Beneficiary[]>>>)(async () =>
|
await Result.Try((Func<Task<Result<Beneficiary[]>>>)(async () =>
|
||||||
{
|
{
|
||||||
if (!await featureManager.IsEnabledAsync(ServiceCachingFeature))
|
if (!await featureManager.IsEnabledAsync(FeatureServiceCaching))
|
||||||
return await service.GetBeneficiariesAsync(projectIdHash, cancellationToken);
|
return await service.GetBeneficiariesAsync(projectIdHash, cancellationToken);
|
||||||
|
|
||||||
var cacheKey = HashService.ToMd5Hash($"getbeneficiaries-{projectIdHash}").Value;
|
var cacheKey = HashService.ToMd5Hash($"getbeneficiaries-{projectIdHash}").Value;
|
||||||
@@ -114,7 +114,7 @@ public sealed class ProjectCacheService(ProjectService service, IDistributedCach
|
|||||||
public async Task<Result<Beneficiary>> GetBeneficiaryAsync(string beneficiaryIdHash, CancellationToken cancellationToken = default) =>
|
public async Task<Result<Beneficiary>> GetBeneficiaryAsync(string beneficiaryIdHash, CancellationToken cancellationToken = default) =>
|
||||||
await Result.Try((Func<Task<Result<Beneficiary>>>)(async () =>
|
await Result.Try((Func<Task<Result<Beneficiary>>>)(async () =>
|
||||||
{
|
{
|
||||||
if (!await featureManager.IsEnabledAsync(ServiceCachingFeature))
|
if (!await featureManager.IsEnabledAsync(FeatureServiceCaching))
|
||||||
return await service.GetBeneficiaryAsync(beneficiaryIdHash, cancellationToken);
|
return await service.GetBeneficiaryAsync(beneficiaryIdHash, cancellationToken);
|
||||||
|
|
||||||
var cacheKey = HashService.ToMd5Hash($"getbeneficiary-{beneficiaryIdHash}").Value;
|
var cacheKey = HashService.ToMd5Hash($"getbeneficiary-{beneficiaryIdHash}").Value;
|
||||||
@@ -138,7 +138,7 @@ public sealed class ProjectCacheService(ProjectService service, IDistributedCach
|
|||||||
public async Task<Result<ChangeRequest>> GetChangeRequestAsync(string changeRequestIdHash, CancellationToken cancellationToken = default) =>
|
public async Task<Result<ChangeRequest>> GetChangeRequestAsync(string changeRequestIdHash, CancellationToken cancellationToken = default) =>
|
||||||
await Result.Try((Func<Task<Result<ChangeRequest>>>)(async () =>
|
await Result.Try((Func<Task<Result<ChangeRequest>>>)(async () =>
|
||||||
{
|
{
|
||||||
if (!await featureManager.IsEnabledAsync(ServiceCachingFeature))
|
if (!await featureManager.IsEnabledAsync(FeatureServiceCaching))
|
||||||
return await service.GetChangeRequestAsync(changeRequestIdHash, cancellationToken);
|
return await service.GetChangeRequestAsync(changeRequestIdHash, cancellationToken);
|
||||||
|
|
||||||
var cacheKey = HashService.ToMd5Hash($"getchangerequest-{changeRequestIdHash}").Value;
|
var cacheKey = HashService.ToMd5Hash($"getchangerequest-{changeRequestIdHash}").Value;
|
||||||
@@ -162,7 +162,7 @@ public sealed class ProjectCacheService(ProjectService service, IDistributedCach
|
|||||||
public async Task<Result<ChangeRequest[]>> GetChangeRequestsAsync(string projectIdHash, CancellationToken cancellationToken = default) =>
|
public async Task<Result<ChangeRequest[]>> GetChangeRequestsAsync(string projectIdHash, CancellationToken cancellationToken = default) =>
|
||||||
await Result.Try((Func<Task<Result<ChangeRequest[]>>>)(async () =>
|
await Result.Try((Func<Task<Result<ChangeRequest[]>>>)(async () =>
|
||||||
{
|
{
|
||||||
if (!await featureManager.IsEnabledAsync(ServiceCachingFeature))
|
if (!await featureManager.IsEnabledAsync(FeatureServiceCaching))
|
||||||
return await service.GetChangeRequestsAsync(projectIdHash, cancellationToken);
|
return await service.GetChangeRequestsAsync(projectIdHash, cancellationToken);
|
||||||
|
|
||||||
var cacheKey = HashService.ToMd5Hash($"getchangerequests-{projectIdHash}").Value;
|
var cacheKey = HashService.ToMd5Hash($"getchangerequests-{projectIdHash}").Value;
|
||||||
@@ -186,7 +186,7 @@ public sealed class ProjectCacheService(ProjectService service, IDistributedCach
|
|||||||
public async Task<Result<ComplianceItem>> GetComplianceItemAsync(string complianceItemIdHash, CancellationToken cancellationToken = default) =>
|
public async Task<Result<ComplianceItem>> GetComplianceItemAsync(string complianceItemIdHash, CancellationToken cancellationToken = default) =>
|
||||||
await Result.Try((Func<Task<Result<ComplianceItem>>>)(async () =>
|
await Result.Try((Func<Task<Result<ComplianceItem>>>)(async () =>
|
||||||
{
|
{
|
||||||
if (!await featureManager.IsEnabledAsync(ServiceCachingFeature))
|
if (!await featureManager.IsEnabledAsync(FeatureServiceCaching))
|
||||||
return await service.GetComplianceItemAsync(complianceItemIdHash, cancellationToken);
|
return await service.GetComplianceItemAsync(complianceItemIdHash, cancellationToken);
|
||||||
|
|
||||||
var cacheKey = HashService.ToMd5Hash($"getcomplianceitem-{complianceItemIdHash}").Value;
|
var cacheKey = HashService.ToMd5Hash($"getcomplianceitem-{complianceItemIdHash}").Value;
|
||||||
@@ -210,7 +210,7 @@ public sealed class ProjectCacheService(ProjectService service, IDistributedCach
|
|||||||
public async Task<Result<ComplianceItem[]>> GetComplianceItemsAsync(string projectIdHash, CancellationToken cancellationToken = default) =>
|
public async Task<Result<ComplianceItem[]>> GetComplianceItemsAsync(string projectIdHash, CancellationToken cancellationToken = default) =>
|
||||||
await Result.Try((Func<Task<Result<ComplianceItem[]>>>)(async () =>
|
await Result.Try((Func<Task<Result<ComplianceItem[]>>>)(async () =>
|
||||||
{
|
{
|
||||||
if (!await featureManager.IsEnabledAsync(ServiceCachingFeature))
|
if (!await featureManager.IsEnabledAsync(FeatureServiceCaching))
|
||||||
return await service.GetComplianceItemsAsync(projectIdHash, cancellationToken);
|
return await service.GetComplianceItemsAsync(projectIdHash, cancellationToken);
|
||||||
|
|
||||||
var cacheKey = HashService.ToMd5Hash($"getcomplianceitems-{projectIdHash}").Value;
|
var cacheKey = HashService.ToMd5Hash($"getcomplianceitems-{projectIdHash}").Value;
|
||||||
@@ -234,7 +234,7 @@ public sealed class ProjectCacheService(ProjectService service, IDistributedCach
|
|||||||
public async Task<Result<Issue>> GetIssueAsync(string issueIdHash, CancellationToken cancellationToken = default) =>
|
public async Task<Result<Issue>> GetIssueAsync(string issueIdHash, CancellationToken cancellationToken = default) =>
|
||||||
await Result.Try((Func<Task<Result<Issue>>>)(async () =>
|
await Result.Try((Func<Task<Result<Issue>>>)(async () =>
|
||||||
{
|
{
|
||||||
if (!await featureManager.IsEnabledAsync(ServiceCachingFeature))
|
if (!await featureManager.IsEnabledAsync(FeatureServiceCaching))
|
||||||
return await service.GetIssueAsync(issueIdHash, cancellationToken);
|
return await service.GetIssueAsync(issueIdHash, cancellationToken);
|
||||||
|
|
||||||
var cacheKey = HashService.ToMd5Hash($"getissue-{issueIdHash}").Value;
|
var cacheKey = HashService.ToMd5Hash($"getissue-{issueIdHash}").Value;
|
||||||
@@ -258,7 +258,7 @@ public sealed class ProjectCacheService(ProjectService service, IDistributedCach
|
|||||||
public async Task<Result<Issue[]>> GetIssuesAsync(string projectIdHash, CancellationToken cancellationToken = default) =>
|
public async Task<Result<Issue[]>> GetIssuesAsync(string projectIdHash, CancellationToken cancellationToken = default) =>
|
||||||
await Result.Try((Func<Task<Result<Issue[]>>>)(async () =>
|
await Result.Try((Func<Task<Result<Issue[]>>>)(async () =>
|
||||||
{
|
{
|
||||||
if (!await featureManager.IsEnabledAsync(ServiceCachingFeature))
|
if (!await featureManager.IsEnabledAsync(FeatureServiceCaching))
|
||||||
return await service.GetIssuesAsync(projectIdHash, cancellationToken);
|
return await service.GetIssuesAsync(projectIdHash, cancellationToken);
|
||||||
|
|
||||||
var cacheKey = HashService.ToMd5Hash($"getissues-{projectIdHash}").Value;
|
var cacheKey = HashService.ToMd5Hash($"getissues-{projectIdHash}").Value;
|
||||||
@@ -282,7 +282,7 @@ public sealed class ProjectCacheService(ProjectService service, IDistributedCach
|
|||||||
public async Task<Result<Milestone>> GetMilestoneAsync(string milestoneIdHash, CancellationToken cancellationToken = default) =>
|
public async Task<Result<Milestone>> GetMilestoneAsync(string milestoneIdHash, CancellationToken cancellationToken = default) =>
|
||||||
await Result.Try((Func<Task<Result<Milestone>>>)(async () =>
|
await Result.Try((Func<Task<Result<Milestone>>>)(async () =>
|
||||||
{
|
{
|
||||||
if (!await featureManager.IsEnabledAsync(ServiceCachingFeature))
|
if (!await featureManager.IsEnabledAsync(FeatureServiceCaching))
|
||||||
return await service.GetMilestoneAsync(milestoneIdHash, cancellationToken);
|
return await service.GetMilestoneAsync(milestoneIdHash, cancellationToken);
|
||||||
|
|
||||||
var cacheKey = HashService.ToMd5Hash($"getmilestone-{milestoneIdHash}").Value;
|
var cacheKey = HashService.ToMd5Hash($"getmilestone-{milestoneIdHash}").Value;
|
||||||
@@ -306,7 +306,7 @@ public sealed class ProjectCacheService(ProjectService service, IDistributedCach
|
|||||||
public async Task<Result<Milestone[]>> GetMilestonesAsync(Pagination pagination, CancellationToken cancellationToken = default) =>
|
public async Task<Result<Milestone[]>> GetMilestonesAsync(Pagination pagination, CancellationToken cancellationToken = default) =>
|
||||||
await Result.Try((Func<Task<Result<Milestone[]>>>)(async () =>
|
await Result.Try((Func<Task<Result<Milestone[]>>>)(async () =>
|
||||||
{
|
{
|
||||||
if (!await featureManager.IsEnabledAsync(ServiceCachingFeature))
|
if (!await featureManager.IsEnabledAsync(FeatureServiceCaching))
|
||||||
return await service.GetMilestonesAsync(pagination, cancellationToken);
|
return await service.GetMilestonesAsync(pagination, cancellationToken);
|
||||||
|
|
||||||
var cacheKey = HashService.ToMd5Hash($"getmilestones-{pagination.Index}-{pagination.PageSize}-{pagination.Page}").Value;
|
var cacheKey = HashService.ToMd5Hash($"getmilestones-{pagination.Index}-{pagination.PageSize}-{pagination.Page}").Value;
|
||||||
@@ -330,7 +330,7 @@ public sealed class ProjectCacheService(ProjectService service, IDistributedCach
|
|||||||
public async Task<Result<Outcome>> GetOutcomeAsync(string outcomeIdHash, CancellationToken cancellationToken = default) =>
|
public async Task<Result<Outcome>> GetOutcomeAsync(string outcomeIdHash, CancellationToken cancellationToken = default) =>
|
||||||
await Result.Try((Func<Task<Result<Outcome>>>)(async () =>
|
await Result.Try((Func<Task<Result<Outcome>>>)(async () =>
|
||||||
{
|
{
|
||||||
if (!await featureManager.IsEnabledAsync(ServiceCachingFeature))
|
if (!await featureManager.IsEnabledAsync(FeatureServiceCaching))
|
||||||
return await service.GetOutcomeAsync(outcomeIdHash, cancellationToken);
|
return await service.GetOutcomeAsync(outcomeIdHash, cancellationToken);
|
||||||
|
|
||||||
var cacheKey = HashService.ToMd5Hash($"getoutcome-{outcomeIdHash}").Value;
|
var cacheKey = HashService.ToMd5Hash($"getoutcome-{outcomeIdHash}").Value;
|
||||||
@@ -354,7 +354,7 @@ public sealed class ProjectCacheService(ProjectService service, IDistributedCach
|
|||||||
public async Task<Result<Outcome[]>> GetOutcomesAsync(string beneficiaryIdHash, CancellationToken cancellationToken = default) =>
|
public async Task<Result<Outcome[]>> GetOutcomesAsync(string beneficiaryIdHash, CancellationToken cancellationToken = default) =>
|
||||||
await Result.Try((Func<Task<Result<Outcome[]>>>)(async () =>
|
await Result.Try((Func<Task<Result<Outcome[]>>>)(async () =>
|
||||||
{
|
{
|
||||||
if (!await featureManager.IsEnabledAsync(ServiceCachingFeature))
|
if (!await featureManager.IsEnabledAsync(FeatureServiceCaching))
|
||||||
return await service.GetOutcomesAsync(beneficiaryIdHash, cancellationToken);
|
return await service.GetOutcomesAsync(beneficiaryIdHash, cancellationToken);
|
||||||
|
|
||||||
var cacheKey = HashService.ToMd5Hash($"getoutcomes-{beneficiaryIdHash}").Value;
|
var cacheKey = HashService.ToMd5Hash($"getoutcomes-{beneficiaryIdHash}").Value;
|
||||||
@@ -378,7 +378,7 @@ public sealed class ProjectCacheService(ProjectService service, IDistributedCach
|
|||||||
public async Task<Result<Outcome[]>> GetProjectOutcomesAsync(string projectIdHash, CancellationToken cancellationToken = default) =>
|
public async Task<Result<Outcome[]>> GetProjectOutcomesAsync(string projectIdHash, CancellationToken cancellationToken = default) =>
|
||||||
await Result.Try((Func<Task<Result<Outcome[]>>>)(async () =>
|
await Result.Try((Func<Task<Result<Outcome[]>>>)(async () =>
|
||||||
{
|
{
|
||||||
if (!await featureManager.IsEnabledAsync(ServiceCachingFeature))
|
if (!await featureManager.IsEnabledAsync(FeatureServiceCaching))
|
||||||
return await service.GetProjectOutcomesAsync(projectIdHash, cancellationToken);
|
return await service.GetProjectOutcomesAsync(projectIdHash, cancellationToken);
|
||||||
|
|
||||||
var cacheKey = HashService.ToMd5Hash($"getprojectoutcomes-{projectIdHash}").Value;
|
var cacheKey = HashService.ToMd5Hash($"getprojectoutcomes-{projectIdHash}").Value;
|
||||||
@@ -402,7 +402,7 @@ public sealed class ProjectCacheService(ProjectService service, IDistributedCach
|
|||||||
public async Task<Result<Programme>> GetProgrammeAsync(string programmeIdHash, CancellationToken cancellationToken = default) =>
|
public async Task<Result<Programme>> GetProgrammeAsync(string programmeIdHash, CancellationToken cancellationToken = default) =>
|
||||||
await Result.Try((Func<Task<Result<Programme>>>)(async () =>
|
await Result.Try((Func<Task<Result<Programme>>>)(async () =>
|
||||||
{
|
{
|
||||||
if (!await featureManager.IsEnabledAsync(ServiceCachingFeature))
|
if (!await featureManager.IsEnabledAsync(FeatureServiceCaching))
|
||||||
return await service.GetProgrammeAsync(programmeIdHash, cancellationToken);
|
return await service.GetProgrammeAsync(programmeIdHash, cancellationToken);
|
||||||
|
|
||||||
var cacheKey = HashService.ToMd5Hash($"getprogramme-{programmeIdHash}").Value;
|
var cacheKey = HashService.ToMd5Hash($"getprogramme-{programmeIdHash}").Value;
|
||||||
@@ -426,7 +426,7 @@ public sealed class ProjectCacheService(ProjectService service, IDistributedCach
|
|||||||
public async Task<Result<Project[]>> GetProgrammeProjectsAsync(string programmeIdHash, CancellationToken cancellationToken = default) =>
|
public async Task<Result<Project[]>> GetProgrammeProjectsAsync(string programmeIdHash, CancellationToken cancellationToken = default) =>
|
||||||
await Result.Try((Func<Task<Result<Project[]>>>)(async () =>
|
await Result.Try((Func<Task<Result<Project[]>>>)(async () =>
|
||||||
{
|
{
|
||||||
if (!await featureManager.IsEnabledAsync(ServiceCachingFeature))
|
if (!await featureManager.IsEnabledAsync(FeatureServiceCaching))
|
||||||
return await service.GetProgrammeProjectsAsync(programmeIdHash, cancellationToken);
|
return await service.GetProgrammeProjectsAsync(programmeIdHash, cancellationToken);
|
||||||
|
|
||||||
var cacheKey = HashService.ToMd5Hash($"getprogrammeprojects-{programmeIdHash}").Value;
|
var cacheKey = HashService.ToMd5Hash($"getprogrammeprojects-{programmeIdHash}").Value;
|
||||||
@@ -450,7 +450,7 @@ public sealed class ProjectCacheService(ProjectService service, IDistributedCach
|
|||||||
public async Task<Result<Programme[]>> GetProgrammesAsync(Pagination pagination, CancellationToken cancellationToken = default) =>
|
public async Task<Result<Programme[]>> GetProgrammesAsync(Pagination pagination, CancellationToken cancellationToken = default) =>
|
||||||
await Result.Try((Func<Task<Result<Programme[]>>>)(async () =>
|
await Result.Try((Func<Task<Result<Programme[]>>>)(async () =>
|
||||||
{
|
{
|
||||||
if (!await featureManager.IsEnabledAsync(ServiceCachingFeature))
|
if (!await featureManager.IsEnabledAsync(FeatureServiceCaching))
|
||||||
return await service.GetProgrammesAsync(pagination, cancellationToken);
|
return await service.GetProgrammesAsync(pagination, cancellationToken);
|
||||||
|
|
||||||
var cacheKey = HashService.ToMd5Hash($"getprogrammes-{pagination.Index}-{pagination.PageSize}-{pagination.Page}").Value;
|
var cacheKey = HashService.ToMd5Hash($"getprogrammes-{pagination.Index}-{pagination.PageSize}-{pagination.Page}").Value;
|
||||||
@@ -474,7 +474,7 @@ public sealed class ProjectCacheService(ProjectService service, IDistributedCach
|
|||||||
public async Task<Result<Project>> GetProjectAsync(string projectIdHash, CancellationToken cancellationToken = default) =>
|
public async Task<Result<Project>> GetProjectAsync(string projectIdHash, CancellationToken cancellationToken = default) =>
|
||||||
await Result.Try((Func<Task<Result<Project>>>)(async () =>
|
await Result.Try((Func<Task<Result<Project>>>)(async () =>
|
||||||
{
|
{
|
||||||
if (!await featureManager.IsEnabledAsync(ServiceCachingFeature))
|
if (!await featureManager.IsEnabledAsync(FeatureServiceCaching))
|
||||||
return await service.GetProjectAsync(projectIdHash, cancellationToken);
|
return await service.GetProjectAsync(projectIdHash, cancellationToken);
|
||||||
|
|
||||||
var cacheKey = HashService.ToMd5Hash($"getproject-{projectIdHash}").Value;
|
var cacheKey = HashService.ToMd5Hash($"getproject-{projectIdHash}").Value;
|
||||||
@@ -498,7 +498,7 @@ public sealed class ProjectCacheService(ProjectService service, IDistributedCach
|
|||||||
public async Task<Result<Indicator[]>> GetProjectIndicatorsAsync(string projectIdHash, CancellationToken cancellationToken = default) =>
|
public async Task<Result<Indicator[]>> GetProjectIndicatorsAsync(string projectIdHash, CancellationToken cancellationToken = default) =>
|
||||||
await Result.Try((Func<Task<Result<Indicator[]>>>)(async () =>
|
await Result.Try((Func<Task<Result<Indicator[]>>>)(async () =>
|
||||||
{
|
{
|
||||||
if (!await featureManager.IsEnabledAsync(ServiceCachingFeature))
|
if (!await featureManager.IsEnabledAsync(FeatureServiceCaching))
|
||||||
return await service.GetProjectIndicatorsAsync(projectIdHash, cancellationToken);
|
return await service.GetProjectIndicatorsAsync(projectIdHash, cancellationToken);
|
||||||
|
|
||||||
var cacheKey = HashService.ToMd5Hash($"getprojectindicators-{projectIdHash}").Value;
|
var cacheKey = HashService.ToMd5Hash($"getprojectindicators-{projectIdHash}").Value;
|
||||||
@@ -522,7 +522,7 @@ public sealed class ProjectCacheService(ProjectService service, IDistributedCach
|
|||||||
public async Task<Result<Milestone[]>> GetProjectMilestonesAsync(string projectIdHash, CancellationToken cancellationToken = default) =>
|
public async Task<Result<Milestone[]>> GetProjectMilestonesAsync(string projectIdHash, CancellationToken cancellationToken = default) =>
|
||||||
await Result.Try((Func<Task<Result<Milestone[]>>>)(async () =>
|
await Result.Try((Func<Task<Result<Milestone[]>>>)(async () =>
|
||||||
{
|
{
|
||||||
if (!await featureManager.IsEnabledAsync(ServiceCachingFeature))
|
if (!await featureManager.IsEnabledAsync(FeatureServiceCaching))
|
||||||
return await service.GetProjectMilestonesAsync(projectIdHash, cancellationToken);
|
return await service.GetProjectMilestonesAsync(projectIdHash, cancellationToken);
|
||||||
|
|
||||||
var cacheKey = HashService.ToMd5Hash($"getprojectmilestones-{projectIdHash}").Value;
|
var cacheKey = HashService.ToMd5Hash($"getprojectmilestones-{projectIdHash}").Value;
|
||||||
@@ -546,7 +546,7 @@ public sealed class ProjectCacheService(ProjectService service, IDistributedCach
|
|||||||
public async Task<Result<Risk[]>> GetProjectRisksAsync(string projectIdHash, CancellationToken cancellationToken = default) =>
|
public async Task<Result<Risk[]>> GetProjectRisksAsync(string projectIdHash, CancellationToken cancellationToken = default) =>
|
||||||
await Result.Try((Func<Task<Result<Risk[]>>>)(async () =>
|
await Result.Try((Func<Task<Result<Risk[]>>>)(async () =>
|
||||||
{
|
{
|
||||||
if (!await featureManager.IsEnabledAsync(ServiceCachingFeature))
|
if (!await featureManager.IsEnabledAsync(FeatureServiceCaching))
|
||||||
return await service.GetProjectRisksAsync(projectIdHash, cancellationToken);
|
return await service.GetProjectRisksAsync(projectIdHash, cancellationToken);
|
||||||
|
|
||||||
var cacheKey = HashService.ToMd5Hash($"getprojectrisks-{projectIdHash}").Value;
|
var cacheKey = HashService.ToMd5Hash($"getprojectrisks-{projectIdHash}").Value;
|
||||||
@@ -570,7 +570,7 @@ public sealed class ProjectCacheService(ProjectService service, IDistributedCach
|
|||||||
public async Task<Result<Project[]>> GetProjectsAsync(Pagination pagination, CancellationToken cancellationToken = default) =>
|
public async Task<Result<Project[]>> GetProjectsAsync(Pagination pagination, CancellationToken cancellationToken = default) =>
|
||||||
await Result.Try((Func<Task<Result<Project[]>>>)(async () =>
|
await Result.Try((Func<Task<Result<Project[]>>>)(async () =>
|
||||||
{
|
{
|
||||||
if (!await featureManager.IsEnabledAsync(ServiceCachingFeature))
|
if (!await featureManager.IsEnabledAsync(FeatureServiceCaching))
|
||||||
return await service.GetProjectsAsync(pagination, cancellationToken);
|
return await service.GetProjectsAsync(pagination, cancellationToken);
|
||||||
|
|
||||||
var cacheKey = HashService.ToMd5Hash($"getprojects-{pagination.Index}-{pagination.Page}-{pagination.PageSize}").Value;
|
var cacheKey = HashService.ToMd5Hash($"getprojects-{pagination.Index}-{pagination.Page}-{pagination.PageSize}").Value;
|
||||||
@@ -594,7 +594,7 @@ public sealed class ProjectCacheService(ProjectService service, IDistributedCach
|
|||||||
public async Task<Result<SiteVisit>> GetSiteVisitAsync(string siteVisitIdHash, CancellationToken cancellationToken = default) =>
|
public async Task<Result<SiteVisit>> GetSiteVisitAsync(string siteVisitIdHash, CancellationToken cancellationToken = default) =>
|
||||||
await Result.Try((Func<Task<Result<SiteVisit>>>)(async () =>
|
await Result.Try((Func<Task<Result<SiteVisit>>>)(async () =>
|
||||||
{
|
{
|
||||||
if (!await featureManager.IsEnabledAsync(ServiceCachingFeature))
|
if (!await featureManager.IsEnabledAsync(FeatureServiceCaching))
|
||||||
return await service.GetSiteVisitAsync(siteVisitIdHash, cancellationToken);
|
return await service.GetSiteVisitAsync(siteVisitIdHash, cancellationToken);
|
||||||
|
|
||||||
var cacheKey = HashService.ToMd5Hash($"getsitevisit-{siteVisitIdHash}").Value;
|
var cacheKey = HashService.ToMd5Hash($"getsitevisit-{siteVisitIdHash}").Value;
|
||||||
@@ -618,7 +618,7 @@ public sealed class ProjectCacheService(ProjectService service, IDistributedCach
|
|||||||
public async Task<Result<SiteVisit[]>> GetSiteVisitsAsync(string projectIdHash, CancellationToken cancellationToken = default) =>
|
public async Task<Result<SiteVisit[]>> GetSiteVisitsAsync(string projectIdHash, CancellationToken cancellationToken = default) =>
|
||||||
await Result.Try((Func<Task<Result<SiteVisit[]>>>)(async () =>
|
await Result.Try((Func<Task<Result<SiteVisit[]>>>)(async () =>
|
||||||
{
|
{
|
||||||
if (!await featureManager.IsEnabledAsync(ServiceCachingFeature))
|
if (!await featureManager.IsEnabledAsync(FeatureServiceCaching))
|
||||||
return await service.GetSiteVisitsAsync(projectIdHash, cancellationToken);
|
return await service.GetSiteVisitsAsync(projectIdHash, cancellationToken);
|
||||||
|
|
||||||
var cacheKey = HashService.ToMd5Hash($"getsitevisits-{projectIdHash}").Value;
|
var cacheKey = HashService.ToMd5Hash($"getsitevisits-{projectIdHash}").Value;
|
||||||
|
|||||||
@@ -20,27 +20,6 @@ public sealed class ProjectService(IDbContextFactory<ApplicationDbContext> conte
|
|||||||
if (!await context.Users.AnyAsync(u => u.Id == userId, cancellationToken))
|
if (!await context.Users.AnyAsync(u => u.Id == userId, cancellationToken))
|
||||||
return Result.Fail(NotFoundError.New);
|
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
|
var changedTotal = await context.Projects
|
||||||
.Where(f => f.Id == request.ProjectId)
|
.Where(f => f.Id == request.ProjectId)
|
||||||
.ExecuteUpdateAsync(f => f
|
.ExecuteUpdateAsync(f => f
|
||||||
|
|||||||
@@ -51,8 +51,10 @@ public sealed partial class HashService(IHashids hasher) : IService
|
|||||||
|
|
||||||
int[] decoded = hasher.Decode(hash);
|
int[] decoded = hasher.Decode(hash);
|
||||||
|
|
||||||
return Result.FailIf(decoded.Length <= 0, ValidationError.Create("Invalid or modified Int hash token."))
|
if (decoded.Length <= 0)
|
||||||
.ToResult(decoded[0]);
|
return Result.Fail<int>(ValidationError.Create("Invalid or modified Int hash token."));
|
||||||
|
|
||||||
|
return Result.Ok(decoded[0]);
|
||||||
}
|
}
|
||||||
|
|
||||||
public Result<long> DecodeLongIdHash(string hash)
|
public Result<long> DecodeLongIdHash(string hash)
|
||||||
@@ -62,8 +64,10 @@ public sealed partial class HashService(IHashids hasher) : IService
|
|||||||
|
|
||||||
long[] decoded = hasher.DecodeLong(hash);
|
long[] decoded = hasher.DecodeLong(hash);
|
||||||
|
|
||||||
return Result.FailIf(decoded.Length <= 0, ValidationError.Create("Invalid or modified Long hash token."))
|
if (decoded.Length <= 0)
|
||||||
.ToResult(decoded[0]);
|
return Result.Fail<long>(ValidationError.Create("Invalid or modified Long hash token."));
|
||||||
|
|
||||||
|
return Result.Ok(decoded[0]);
|
||||||
}
|
}
|
||||||
|
|
||||||
public async Task<Result<string>> DecodeHexHash(string hex) =>
|
public async Task<Result<string>> DecodeHexHash(string hex) =>
|
||||||
|
|||||||
@@ -29,7 +29,7 @@ public sealed class UserCacheService(UserService service, IDistributedCache cach
|
|||||||
public async Task<Result<Claim[]>> GetRoleClaimsAsync(Guid roleId, Guid userId, CancellationToken cancellationToken = default) =>
|
public async Task<Result<Claim[]>> GetRoleClaimsAsync(Guid roleId, Guid userId, CancellationToken cancellationToken = default) =>
|
||||||
await Result.Try((Func<Task<Result<Claim[]>>>)(async () =>
|
await Result.Try((Func<Task<Result<Claim[]>>>)(async () =>
|
||||||
{
|
{
|
||||||
if (!await featureManager.IsEnabledAsync(ServiceCachingFeature))
|
if (!await featureManager.IsEnabledAsync(FeatureServiceCaching))
|
||||||
return await service.GetRoleClaimsAsync(roleId, userId, cancellationToken);
|
return await service.GetRoleClaimsAsync(roleId, userId, cancellationToken);
|
||||||
|
|
||||||
var cacheKey = HashService.ToMd5Hash($"getsystemroles-{userId}").Value;
|
var cacheKey = HashService.ToMd5Hash($"getsystemroles-{userId}").Value;
|
||||||
@@ -54,7 +54,7 @@ public sealed class UserCacheService(UserService service, IDistributedCache cach
|
|||||||
public async Task<Result<Role[]>> GetSystemRolesAsync(Guid userId, CancellationToken cancellationToken = default) =>
|
public async Task<Result<Role[]>> GetSystemRolesAsync(Guid userId, CancellationToken cancellationToken = default) =>
|
||||||
await Result.Try((Func<Task<Result<Role[]>>>)(async () =>
|
await Result.Try((Func<Task<Result<Role[]>>>)(async () =>
|
||||||
{
|
{
|
||||||
if (!await featureManager.IsEnabledAsync(ServiceCachingFeature))
|
if (!await featureManager.IsEnabledAsync(FeatureServiceCaching))
|
||||||
return await service.GetSystemRolesAsync(userId, cancellationToken);
|
return await service.GetSystemRolesAsync(userId, cancellationToken);
|
||||||
|
|
||||||
var cacheKey = HashService.ToMd5Hash($"getsystemroles-{userId}").Value;
|
var cacheKey = HashService.ToMd5Hash($"getsystemroles-{userId}").Value;
|
||||||
@@ -79,7 +79,7 @@ public sealed class UserCacheService(UserService service, IDistributedCache cach
|
|||||||
public async Task<Result<User>> GetUserAsync(Guid userId, CancellationToken cancellationToken) =>
|
public async Task<Result<User>> GetUserAsync(Guid userId, CancellationToken cancellationToken) =>
|
||||||
await Result.Try((Func<Task<Result<User>>>)(async () =>
|
await Result.Try((Func<Task<Result<User>>>)(async () =>
|
||||||
{
|
{
|
||||||
if (!await featureManager.IsEnabledAsync(ServiceCachingFeature))
|
if (!await featureManager.IsEnabledAsync(FeatureServiceCaching))
|
||||||
return await service.GetUserAsync(userId, cancellationToken);
|
return await service.GetUserAsync(userId, cancellationToken);
|
||||||
|
|
||||||
var cacheKey = HashService.ToMd5Hash($"getuser-{userId}").Value;
|
var cacheKey = HashService.ToMd5Hash($"getuser-{userId}").Value;
|
||||||
@@ -103,7 +103,7 @@ public sealed class UserCacheService(UserService service, IDistributedCache cach
|
|||||||
public async Task<Result<Claim[]>> GetUserClaimsAsync(Guid targetUserId, Guid userId, CancellationToken cancellationToken = default) =>
|
public async Task<Result<Claim[]>> GetUserClaimsAsync(Guid targetUserId, Guid userId, CancellationToken cancellationToken = default) =>
|
||||||
await Result.Try((Func<Task<Result<Claim[]>>>)(async () =>
|
await Result.Try((Func<Task<Result<Claim[]>>>)(async () =>
|
||||||
{
|
{
|
||||||
if (!await featureManager.IsEnabledAsync(ServiceCachingFeature))
|
if (!await featureManager.IsEnabledAsync(FeatureServiceCaching))
|
||||||
return await service.GetUserClaimsAsync(targetUserId, userId, cancellationToken);
|
return await service.GetUserClaimsAsync(targetUserId, userId, cancellationToken);
|
||||||
|
|
||||||
var cacheKey = HashService.ToMd5Hash($"getuserclaims-{userId}").Value;
|
var cacheKey = HashService.ToMd5Hash($"getuserclaims-{userId}").Value;
|
||||||
@@ -128,7 +128,7 @@ public sealed class UserCacheService(UserService service, IDistributedCache cach
|
|||||||
public async Task<Result<Role[]>> GetUserRolesAsync(Guid targetUserId, Guid userId, CancellationToken cancellationToken = default) =>
|
public async Task<Result<Role[]>> GetUserRolesAsync(Guid targetUserId, Guid userId, CancellationToken cancellationToken = default) =>
|
||||||
await Result.Try((Func<Task<Result<Role[]>>>)(async () =>
|
await Result.Try((Func<Task<Result<Role[]>>>)(async () =>
|
||||||
{
|
{
|
||||||
if (!await featureManager.IsEnabledAsync(ServiceCachingFeature))
|
if (!await featureManager.IsEnabledAsync(FeatureServiceCaching))
|
||||||
return await service.GetUserRolesAsync(targetUserId, userId, cancellationToken);
|
return await service.GetUserRolesAsync(targetUserId, userId, cancellationToken);
|
||||||
|
|
||||||
var cacheKey = HashService.ToMd5Hash($"getuserroles-{userId}").Value;
|
var cacheKey = HashService.ToMd5Hash($"getuserroles-{userId}").Value;
|
||||||
|
|||||||
@@ -12,7 +12,6 @@ APP_URL="MY_APP_URL"
|
|||||||
VITE_OIDC_AUTHORITY="https://sts.security.khongisa.co.za"
|
VITE_OIDC_AUTHORITY="https://sts.security.khongisa.co.za"
|
||||||
VITE_OIDC_AUDIENCE="pfm-api-dev"
|
VITE_OIDC_AUDIENCE="pfm-api-dev"
|
||||||
VITE_OIDC_CLIENT_ID="pfm-scalar-dev"
|
VITE_OIDC_CLIENT_ID="pfm-scalar-dev"
|
||||||
VITE_OIDC_CLIENT_SECRET="secret_71f88570a46eb22eb5270b47f69e3332c84d8db85d008f0276cb7431c8872d27"
|
|
||||||
|
|
||||||
# PFM Backend API Base URL
|
# PFM Backend API Base URL
|
||||||
VITE_API_BASE_URL="https://api.ifri.khongisa.co.za/"
|
VITE_API_BASE_URL="https://api.ifri.khongisa.co.za/"
|
||||||
|
|||||||
@@ -26,15 +26,12 @@ OIDC_AUDIENCE="${VITE_OIDC_AUDIENCE:-${OIDC_AUDIENCE:-${SecuritySettings__Audien
|
|||||||
# 4. OIDC Client ID
|
# 4. OIDC Client ID
|
||||||
OIDC_CLIENT_ID="${VITE_OIDC_CLIENT_ID:-${OIDC_CLIENT_ID:-${SecuritySettings__ClientId:-${SecuritySettings_ClientId:-${CLIENT_ID:-}}}}}"
|
OIDC_CLIENT_ID="${VITE_OIDC_CLIENT_ID:-${OIDC_CLIENT_ID:-${SecuritySettings__ClientId:-${SecuritySettings_ClientId:-${CLIENT_ID:-}}}}}"
|
||||||
|
|
||||||
# 5. OIDC Client Secret
|
# 5. OIDC Scope
|
||||||
OIDC_CLIENT_SECRET="${VITE_OIDC_CLIENT_SECRET:-${OIDC_CLIENT_SECRET:-${SecuritySettings__ClientSecret:-${SecuritySettings_ClientSecret:-${CLIENT_SECRET:-}}}}}"
|
|
||||||
|
|
||||||
# 6. OIDC Scope
|
|
||||||
OIDC_SCOPE="${VITE_OIDC_SCOPE:-${OIDC_SCOPE:-${SecuritySettings__Scope:-${SecuritySettings_Scope:-${SCOPE:-}}}}}"
|
OIDC_SCOPE="${VITE_OIDC_SCOPE:-${OIDC_SCOPE:-${SecuritySettings__Scope:-${SecuritySettings_Scope:-${SCOPE:-}}}}}"
|
||||||
|
|
||||||
# 7. Hasher Settings
|
# 6. Hasher Settings
|
||||||
HASHER_SALT="${VITE_HASHER_SALT:-${HASHER_SALT:-${HasherSettings__Salt:-${HasherSettings_Salt:-}}}}}"
|
HASHER_MIN_LENGTH="${VITE_HASHER_MIN_LENGTH:-${HASHER_MIN_LENGTH:-${HasherSettings__MinLength:-${HasherSettings_MinLength:-11}}}}"
|
||||||
HASHER_MIN_LENGTH="${VITE_HASHER_MIN_LENGTH:-${HASHER_MIN_LENGTH:-${HasherSettings__MinLength:-${HasherSettings_MinLength:-}}}}}"
|
HASHER_SALT="${VITE_HASHER_SALT:-${HASHER_SALT:-${HasherSettings__Salt:-${HasherSettings_Salt:-TGlnaHQgQ2hhcm1zLCBNaWRyYW5kQm9va3MgbWFrZSBsb3RzIG9mIG1vbmV5IGFuZCBhcmUgYSBzdWNjZXNzZnVsIHZpcmFsIHN0b3J5IGluIFNvdXRoIEFmcmljYQ==}}}}"
|
||||||
|
|
||||||
if [ -d "$TARGET_DIR" ]; then
|
if [ -d "$TARGET_DIR" ]; then
|
||||||
echo "[docker-entrypoint] Generating runtime configuration files in $TARGET_DIR..."
|
echo "[docker-entrypoint] Generating runtime configuration files in $TARGET_DIR..."
|
||||||
@@ -51,14 +48,12 @@ window.__RUNTIME_CONFIG__ = {
|
|||||||
VITE_OIDC_AUDIENCE: "${OIDC_AUDIENCE}",
|
VITE_OIDC_AUDIENCE: "${OIDC_AUDIENCE}",
|
||||||
OIDC_CLIENT_ID: "${OIDC_CLIENT_ID}",
|
OIDC_CLIENT_ID: "${OIDC_CLIENT_ID}",
|
||||||
VITE_OIDC_CLIENT_ID: "${OIDC_CLIENT_ID}",
|
VITE_OIDC_CLIENT_ID: "${OIDC_CLIENT_ID}",
|
||||||
OIDC_CLIENT_SECRET: "${OIDC_CLIENT_SECRET}",
|
|
||||||
VITE_OIDC_CLIENT_SECRET: "${OIDC_CLIENT_SECRET}",
|
|
||||||
OIDC_SCOPE: "${OIDC_SCOPE}",
|
OIDC_SCOPE: "${OIDC_SCOPE}",
|
||||||
VITE_OIDC_SCOPE: "${OIDC_SCOPE}",
|
VITE_OIDC_SCOPE: "${OIDC_SCOPE}",
|
||||||
HASHER_SALT: "${HASHER_SALT}",
|
|
||||||
VITE_HASHER_SALT: "${HASHER_SALT}",
|
|
||||||
HASHER_MIN_LENGTH: "${HASHER_MIN_LENGTH}",
|
HASHER_MIN_LENGTH: "${HASHER_MIN_LENGTH}",
|
||||||
VITE_HASHER_MIN_LENGTH: "${HASHER_MIN_LENGTH}",
|
VITE_HASHER_MIN_LENGTH: "${HASHER_MIN_LENGTH}",
|
||||||
|
HASHER_SALT: "${HASHER_SALT}",
|
||||||
|
VITE_HASHER_SALT: "${HASHER_SALT}",
|
||||||
ApiSettings: {
|
ApiSettings: {
|
||||||
BaseUrl: "${API_BASE_URL}"
|
BaseUrl: "${API_BASE_URL}"
|
||||||
},
|
},
|
||||||
@@ -66,7 +61,6 @@ window.__RUNTIME_CONFIG__ = {
|
|||||||
Authority: "${OIDC_AUTHORITY}",
|
Authority: "${OIDC_AUTHORITY}",
|
||||||
Audience: "${OIDC_AUDIENCE}",
|
Audience: "${OIDC_AUDIENCE}",
|
||||||
ClientId: "${OIDC_CLIENT_ID}",
|
ClientId: "${OIDC_CLIENT_ID}",
|
||||||
ClientSecret: "${OIDC_CLIENT_SECRET}",
|
|
||||||
Scope: "${OIDC_SCOPE}"
|
Scope: "${OIDC_SCOPE}"
|
||||||
},
|
},
|
||||||
HasherSettings: {
|
HasherSettings: {
|
||||||
@@ -86,7 +80,6 @@ EOF
|
|||||||
"Authority": "${OIDC_AUTHORITY}",
|
"Authority": "${OIDC_AUTHORITY}",
|
||||||
"Audience": "${OIDC_AUDIENCE}",
|
"Audience": "${OIDC_AUDIENCE}",
|
||||||
"ClientId": "${OIDC_CLIENT_ID}",
|
"ClientId": "${OIDC_CLIENT_ID}",
|
||||||
"ClientSecret": "${OIDC_CLIENT_SECRET}",
|
|
||||||
"Scope": "${OIDC_SCOPE}"
|
"Scope": "${OIDC_SCOPE}"
|
||||||
},
|
},
|
||||||
"HasherSettings": {
|
"HasherSettings": {
|
||||||
|
|||||||
@@ -6,11 +6,10 @@
|
|||||||
"Authority": "",
|
"Authority": "",
|
||||||
"Audience": "",
|
"Audience": "",
|
||||||
"ClientId": "",
|
"ClientId": "",
|
||||||
"ClientSecret": "",
|
|
||||||
"Scope": ""
|
"Scope": ""
|
||||||
},
|
},
|
||||||
"HasherSettings": {
|
"HasherSettings": {
|
||||||
"Salt": "",
|
"Salt": "TGlnaHQgQ2hhcm1zLCBNaWRyYW5kQm9va3MgbWFrZSBsb3RzIG9mIG1vbmV5IGFuZCBhcmUgYSBzdWNjZXNzZnVsIHZpcmFsIHN0b3J5IGluIFNvdXRoIEFmcmljYQ==",
|
||||||
"MinHashLength": ""
|
"MinHashLength": "11"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,4 +1,13 @@
|
|||||||
// Development and fallback runtime configuration.
|
// Development and fallback runtime configuration.
|
||||||
// In Docker / Kubernetes / Cloud Run deployments, this file is dynamically
|
// In Docker / Kubernetes / Cloud Run deployments, this file is dynamically
|
||||||
// populated at container startup by /docker-entrypoint.sh with deployment environment variables.
|
// populated at container startup by /docker-entrypoint.sh with deployment environment variables.
|
||||||
window.__RUNTIME_CONFIG__ = window.__RUNTIME_CONFIG__ || {};
|
window.__RUNTIME_CONFIG__ = window.__RUNTIME_CONFIG__ || {
|
||||||
|
HASHER_SALT: "TGlnaHQgQ2hhcm1zLCBNaWRyYW5kQm9va3MgbWFrZSBsb3RzIG9mIG1vbmV5IGFuZCBhcmUgYSBzdWNjZXNzZnVsIHZpcmFsIHN0b3J5IGluIFNvdXRoIEFmcmljYQ==",
|
||||||
|
VITE_HASHER_SALT: "TGlnaHQgQ2hhcm1zLCBNaWRyYW5kQm9va3MgbWFrZSBsb3RzIG9mIG1vbmV5IGFuZCBhcmUgYSBzdWNjZXNzZnVsIHZpcmFsIHN0b3J5IGluIFNvdXRoIEFmcmljYQ==",
|
||||||
|
HASHER_MIN_LENGTH: "11",
|
||||||
|
VITE_HASHER_MIN_LENGTH: "11",
|
||||||
|
HasherSettings: {
|
||||||
|
Salt: "TGlnaHQgQ2hhcm1zLCBNaWRyYW5kQm9va3MgbWFrZSBsb3RzIG9mIG1vbmV5IGFuZCBhcmUgYSBzdWNjZXNzZnVsIHZpcmFsIHN0b3J5IGluIFNvdXRoIEFmcmljYQ==",
|
||||||
|
MinHashLength: "11"
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|||||||
@@ -10,13 +10,14 @@ import {
|
|||||||
isActivationPending,
|
isActivationPending,
|
||||||
getActivationPendingReason,
|
getActivationPendingReason,
|
||||||
clearUserSyncInBrowser,
|
clearUserSyncInBrowser,
|
||||||
|
setActivationPending,
|
||||||
UserSyncResult,
|
UserSyncResult,
|
||||||
} from '../services/userSyncService';
|
} from '../services/userSyncService';
|
||||||
import { AccountActivationModal } from '../components/auth/AccountActivationModal';
|
import { AccountActivationModal } from '../components/auth/AccountActivationModal';
|
||||||
import { PartnerAccessDeniedModal } from '../components/auth/PartnerAccessDeniedModal';
|
import { PartnerAccessDeniedModal } from '../components/auth/PartnerAccessDeniedModal';
|
||||||
import { SessionExpiredModal } from '../components/auth/SessionExpiredModal';
|
import { SessionExpiredModal } from '../components/auth/SessionExpiredModal';
|
||||||
import { encodeHashIdSync } from '../services/toolsService';
|
import { encodeHashIdSync } from '../utils/hashids';
|
||||||
import { verifyTenantLockout } from '../services/tenantLockoutService';
|
import { verifyTenantLockout, clearTenantLockoutCache } from '../services/tenantLockoutService';
|
||||||
import {
|
import {
|
||||||
onSessionExpired,
|
onSessionExpired,
|
||||||
triggerSessionExpired,
|
triggerSessionExpired,
|
||||||
@@ -307,11 +308,6 @@ export function resetUserManager(): void {
|
|||||||
userManagerInstance = null;
|
userManagerInstance = null;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Automatically reset UserManager when runtime configuration is updated
|
|
||||||
onConfigChanged(() => {
|
|
||||||
resetUserManager();
|
|
||||||
});
|
|
||||||
|
|
||||||
export function getUserManager(): UserManager {
|
export function getUserManager(): UserManager {
|
||||||
if (!userManagerInstance && typeof window !== 'undefined') {
|
if (!userManagerInstance && typeof window !== 'undefined') {
|
||||||
const sec = getSecuritySettings();
|
const sec = getSecuritySettings();
|
||||||
@@ -320,7 +316,7 @@ export function getUserManager(): UserManager {
|
|||||||
userManagerInstance = new UserManager({
|
userManagerInstance = new UserManager({
|
||||||
authority: sec.Authority,
|
authority: sec.Authority,
|
||||||
client_id: sec.ClientId,
|
client_id: sec.ClientId,
|
||||||
client_secret: sec.ClientSecret,
|
...(sec.ClientSecret ? { client_secret: sec.ClientSecret } : {}),
|
||||||
redirect_uri: `${origin}/dashboard`,
|
redirect_uri: `${origin}/dashboard`,
|
||||||
post_logout_redirect_uri: `${origin}/`,
|
post_logout_redirect_uri: `${origin}/`,
|
||||||
response_type: 'code',
|
response_type: 'code',
|
||||||
@@ -437,8 +433,8 @@ export const AuthProvider: React.FC<{ children: ReactNode }> = ({ children }) =>
|
|||||||
localStorage.setItem(LOCAL_STORAGE_CLAIMS_KEY, JSON.stringify(mergedClaims));
|
localStorage.setItem(LOCAL_STORAGE_CLAIMS_KEY, JSON.stringify(mergedClaims));
|
||||||
|
|
||||||
const tenantClaimValues = extractTenantClaimValues(mergedClaims, token);
|
const tenantClaimValues = extractTenantClaimValues(mergedClaims, token);
|
||||||
const tenantClaim = (mergedClaims.tenant as string) || (oidcUser.profile?.tenant as string) || tenantClaimValues.tenant || undefined;
|
let tenantClaim = (mergedClaims.tenant as string) || (oidcUser.profile?.tenant as string) || tenantClaimValues.tenant || undefined;
|
||||||
const parsedTenantId = extractTenantId(mergedClaims, token);
|
let parsedTenantId = extractTenantId(mergedClaims, token);
|
||||||
setTenantId(parsedTenantId);
|
setTenantId(parsedTenantId);
|
||||||
|
|
||||||
const profile: AuthUserProfile = {
|
const profile: AuthUserProfile = {
|
||||||
@@ -458,29 +454,7 @@ export const AuthProvider: React.FC<{ children: ReactNode }> = ({ children }) =>
|
|||||||
// Clean up code and state from URL
|
// Clean up code and state from URL
|
||||||
window.history.replaceState({}, document.title, window.location.pathname);
|
window.history.replaceState({}, document.title, window.location.pathname);
|
||||||
|
|
||||||
// Verify tenant lockout at the instance tenant and tenant_id claims are retrieved
|
// Step 1: Synchronize authenticated user profile with backend directory FIRST (/v1/api/integration/sync)
|
||||||
const tenantLockoutResult = await verifyTenantLockout(
|
|
||||||
parsedTenantId ?? tenantClaimValues.tenantId,
|
|
||||||
tenantClaim
|
|
||||||
);
|
|
||||||
|
|
||||||
if (!tenantLockoutResult.isActive) {
|
|
||||||
setIsPendingActivation(true);
|
|
||||||
setIsSynced(false);
|
|
||||||
setActivationError(tenantLockoutResult.errorMessage || 'Tenant is inactive or not found');
|
|
||||||
setIsLoading(false);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (tenantLockoutResult.tenant?.name) {
|
|
||||||
profile.tenant = tenantLockoutResult.tenant.name;
|
|
||||||
setTenantName(tenantLockoutResult.tenant.name);
|
|
||||||
localStorage.setItem(LOCAL_STORAGE_USER_KEY, JSON.stringify(profile));
|
|
||||||
} else if (tenantClaim) {
|
|
||||||
setTenantName(tenantClaim);
|
|
||||||
}
|
|
||||||
|
|
||||||
// Synchronize authenticated user profile with backend directory
|
|
||||||
try {
|
try {
|
||||||
const syncResult = await syncUserAccount(userSub, token, profile, true);
|
const syncResult = await syncUserAccount(userSub, token, profile, true);
|
||||||
if (syncResult.isUnauthorized) {
|
if (syncResult.isUnauthorized) {
|
||||||
@@ -488,6 +462,51 @@ export const AuthProvider: React.FC<{ children: ReactNode }> = ({ children }) =>
|
|||||||
window.location.replace('/');
|
window.location.replace('/');
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Step 2: Retrieve user roles, role claims, and user claims (Requirement 2 & 3 & 7)
|
||||||
|
try {
|
||||||
|
const resolved = await resolveUserRolesAndClaims(userSub, mergedClaims, profile);
|
||||||
|
if (resolved.primaryRole) {
|
||||||
|
profile.role = resolved.primaryRole;
|
||||||
|
}
|
||||||
|
if (resolved.tenant) {
|
||||||
|
profile.tenant = resolved.tenant;
|
||||||
|
tenantClaim = resolved.tenant;
|
||||||
|
setTenantName(resolved.tenant);
|
||||||
|
}
|
||||||
|
if (resolved.tenant_id !== undefined) {
|
||||||
|
profile.tenant_id = resolved.tenant_id;
|
||||||
|
parsedTenantId = resolved.tenant_id;
|
||||||
|
setTenantId(resolved.tenant_id);
|
||||||
|
}
|
||||||
|
setUser({ ...profile });
|
||||||
|
setClaims({ ...resolved.claims });
|
||||||
|
localStorage.setItem(LOCAL_STORAGE_USER_KEY, JSON.stringify(profile));
|
||||||
|
localStorage.setItem(LOCAL_STORAGE_CLAIMS_KEY, JSON.stringify(resolved.claims));
|
||||||
|
} catch {}
|
||||||
|
|
||||||
|
// Step 3: Verify tenant lockout now that user is synced and authenticated
|
||||||
|
const tenantLockoutResult = await verifyTenantLockout(
|
||||||
|
parsedTenantId ?? tenantClaimValues.tenantId,
|
||||||
|
tenantClaim
|
||||||
|
);
|
||||||
|
|
||||||
|
if (!tenantLockoutResult.isActive) {
|
||||||
|
setIsPendingActivation(true);
|
||||||
|
setIsSynced(false);
|
||||||
|
setActivationError(tenantLockoutResult.errorMessage || 'Tenant is inactive or not found');
|
||||||
|
setIsLoading(false);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (tenantLockoutResult.tenant?.name) {
|
||||||
|
profile.tenant = tenantLockoutResult.tenant.name;
|
||||||
|
setTenantName(tenantLockoutResult.tenant.name);
|
||||||
|
localStorage.setItem(LOCAL_STORAGE_USER_KEY, JSON.stringify(profile));
|
||||||
|
} else if (tenantClaim) {
|
||||||
|
setTenantName(tenantClaim);
|
||||||
|
}
|
||||||
|
|
||||||
if (syncResult.isActivationPending) {
|
if (syncResult.isActivationPending) {
|
||||||
setIsPendingActivation(true);
|
setIsPendingActivation(true);
|
||||||
setIsSynced(false);
|
setIsSynced(false);
|
||||||
@@ -496,26 +515,6 @@ export const AuthProvider: React.FC<{ children: ReactNode }> = ({ children }) =>
|
|||||||
setIsPendingActivation(false);
|
setIsPendingActivation(false);
|
||||||
setIsSynced(true);
|
setIsSynced(true);
|
||||||
setActivationError(null);
|
setActivationError(null);
|
||||||
|
|
||||||
// Retrieve user roles, role claims, and user claims (Requirement 2 & 3 & 7)
|
|
||||||
try {
|
|
||||||
const resolved = await resolveUserRolesAndClaims(userSub, mergedClaims, profile);
|
|
||||||
if (resolved.primaryRole) {
|
|
||||||
profile.role = resolved.primaryRole;
|
|
||||||
}
|
|
||||||
if (resolved.tenant) {
|
|
||||||
profile.tenant = resolved.tenant;
|
|
||||||
setTenantName(resolved.tenant);
|
|
||||||
}
|
|
||||||
if (resolved.tenant_id !== undefined) {
|
|
||||||
profile.tenant_id = resolved.tenant_id;
|
|
||||||
setTenantId(resolved.tenant_id);
|
|
||||||
}
|
|
||||||
setUser({ ...profile });
|
|
||||||
setClaims({ ...resolved.claims });
|
|
||||||
localStorage.setItem(LOCAL_STORAGE_USER_KEY, JSON.stringify(profile));
|
|
||||||
localStorage.setItem(LOCAL_STORAGE_CLAIMS_KEY, JSON.stringify(resolved.claims));
|
|
||||||
} catch {}
|
|
||||||
}
|
}
|
||||||
} catch (syncErr: any) {
|
} catch (syncErr: any) {
|
||||||
if (syncErr?.response?.status === 401) {
|
if (syncErr?.response?.status === 401) {
|
||||||
@@ -525,7 +524,9 @@ export const AuthProvider: React.FC<{ children: ReactNode }> = ({ children }) =>
|
|||||||
}
|
}
|
||||||
setIsPendingActivation(true);
|
setIsPendingActivation(true);
|
||||||
setIsSynced(false);
|
setIsSynced(false);
|
||||||
setActivationError(syncErr?.message || 'Account activation pending');
|
const rawMsg = syncErr?.message || 'Account activation pending';
|
||||||
|
const cleanMsg = /network\s*error/i.test(rawMsg) ? rawMsg.replace(/network\s*error/gi, 'Permission Check') : rawMsg;
|
||||||
|
setActivationError(cleanMsg);
|
||||||
}
|
}
|
||||||
|
|
||||||
setIsLoading(false);
|
setIsLoading(false);
|
||||||
@@ -561,10 +562,10 @@ export const AuthProvider: React.FC<{ children: ReactNode }> = ({ children }) =>
|
|||||||
setSub(storedSub);
|
setSub(storedSub);
|
||||||
}
|
}
|
||||||
|
|
||||||
const storedTenantId = extractTenantId(parsedClaims, storedToken);
|
let storedTenantId = extractTenantId(parsedClaims, storedToken);
|
||||||
setTenantId(storedTenantId);
|
setTenantId(storedTenantId);
|
||||||
const tenantClaimValues = extractTenantClaimValues(parsedClaims, storedToken);
|
const tenantClaimValues = extractTenantClaimValues(parsedClaims, storedToken);
|
||||||
const storedTenantClaim = (parsedClaims.tenant as string) || tenantClaimValues.tenant || undefined;
|
let storedTenantClaim = (parsedClaims.tenant as string) || tenantClaimValues.tenant || undefined;
|
||||||
|
|
||||||
let resolvedUser: AuthUserProfile | null = null;
|
let resolvedUser: AuthUserProfile | null = null;
|
||||||
if (storedUser) {
|
if (storedUser) {
|
||||||
@@ -588,9 +589,53 @@ export const AuthProvider: React.FC<{ children: ReactNode }> = ({ children }) =>
|
|||||||
// Initial fast unblock: valid persisted session found, unblock UI immediately
|
// Initial fast unblock: valid persisted session found, unblock UI immediately
|
||||||
setIsLoading(false);
|
setIsLoading(false);
|
||||||
|
|
||||||
// Asynchronous background revalidation (Tenant Lockout, User Sync, Role Claims)
|
// Asynchronous background revalidation (User Sync, Role Claims, Tenant Lockout)
|
||||||
(async () => {
|
(async () => {
|
||||||
try {
|
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
|
||||||
const tenantLockoutResult = await verifyTenantLockout(
|
const tenantLockoutResult = await verifyTenantLockout(
|
||||||
storedTenantId ?? tenantClaimValues.tenantId,
|
storedTenantId ?? tenantClaimValues.tenantId,
|
||||||
storedTenantClaim
|
storedTenantClaim
|
||||||
@@ -612,22 +657,6 @@ export const AuthProvider: React.FC<{ children: ReactNode }> = ({ children }) =>
|
|||||||
setTenantName(tenantLockoutResult.tenant.name);
|
setTenantName(tenantLockoutResult.tenant.name);
|
||||||
}
|
}
|
||||||
|
|
||||||
const wasPending = isActivationPending();
|
|
||||||
setIsPendingActivation(wasPending);
|
|
||||||
setActivationError(getActivationPendingReason());
|
|
||||||
|
|
||||||
const syncResult = await syncUserAccount(
|
|
||||||
storedSub || '',
|
|
||||||
storedToken,
|
|
||||||
resolvedUser || undefined,
|
|
||||||
false
|
|
||||||
);
|
|
||||||
|
|
||||||
if (syncResult.isUnauthorized) {
|
|
||||||
await logout();
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (syncResult.isActivationPending) {
|
if (syncResult.isActivationPending) {
|
||||||
setIsPendingActivation(true);
|
setIsPendingActivation(true);
|
||||||
setIsSynced(false);
|
setIsSynced(false);
|
||||||
@@ -639,27 +668,6 @@ export const AuthProvider: React.FC<{ children: ReactNode }> = ({ children }) =>
|
|||||||
setIsPendingActivation(false);
|
setIsPendingActivation(false);
|
||||||
setIsSynced(true);
|
setIsSynced(true);
|
||||||
setActivationError(null);
|
setActivationError(null);
|
||||||
|
|
||||||
const resolved = await resolveUserRolesAndClaims(
|
|
||||||
storedSub || '',
|
|
||||||
parsedClaims,
|
|
||||||
resolvedUser || {}
|
|
||||||
);
|
|
||||||
if (resolvedUser) {
|
|
||||||
if (resolved.primaryRole) resolvedUser.role = resolved.primaryRole;
|
|
||||||
if (resolved.tenant) {
|
|
||||||
resolvedUser.tenant = resolved.tenant;
|
|
||||||
setTenantName(resolved.tenant);
|
|
||||||
}
|
|
||||||
if (resolved.tenant_id !== undefined) {
|
|
||||||
resolvedUser.tenant_id = 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 (syncErr: any) {
|
} catch (syncErr: any) {
|
||||||
if (syncErr?.response?.status === 401) {
|
if (syncErr?.response?.status === 401) {
|
||||||
@@ -668,7 +676,9 @@ export const AuthProvider: React.FC<{ children: ReactNode }> = ({ children }) =>
|
|||||||
}
|
}
|
||||||
setIsPendingActivation(true);
|
setIsPendingActivation(true);
|
||||||
setIsSynced(false);
|
setIsSynced(false);
|
||||||
setActivationError(syncErr?.message || 'Account activation pending');
|
const rawMsg = syncErr?.message || 'Account activation pending';
|
||||||
|
const cleanMsg = /network\s*error/i.test(rawMsg) ? rawMsg.replace(/network\s*error/gi, 'Permission Check') : rawMsg;
|
||||||
|
setActivationError(cleanMsg);
|
||||||
}
|
}
|
||||||
})();
|
})();
|
||||||
|
|
||||||
@@ -687,12 +697,65 @@ export const AuthProvider: React.FC<{ children: ReactNode }> = ({ children }) =>
|
|||||||
|
|
||||||
const syncUserSession = async (force = true): Promise<UserSyncResult> => {
|
const syncUserSession = async (force = true): Promise<UserSyncResult> => {
|
||||||
try {
|
try {
|
||||||
// Re-verify tenant status via GetTenant as part of the lockout resolution
|
await loadRuntimeConfig();
|
||||||
const tenantClaimValues = extractTenantClaimValues(claims, accessToken);
|
clearTenantLockoutCache();
|
||||||
const currentTenantId = tenantId ?? tenantClaimValues.tenantId;
|
|
||||||
const currentTenantClaim = (claims.tenant as string) || user?.tenant || tenantClaimValues.tenant || undefined;
|
|
||||||
|
|
||||||
const tenantLockoutResult = await verifyTenantLockout(currentTenantId, currentTenantClaim);
|
// Step 1: Proceed with user sync FIRST (/v1/api/integration/sync)
|
||||||
|
const result = await syncUserAccount(sub, accessToken, user || undefined, force);
|
||||||
|
if (result.isUnauthorized) {
|
||||||
|
await logout();
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
if (result.isActivationPending) {
|
||||||
|
setIsPendingActivation(true);
|
||||||
|
setIsSynced(false);
|
||||||
|
setActivationError(result.errorMessage || null);
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Step 2: Refresh user roles and claims from backend
|
||||||
|
let currentTenantId: number | string | null = tenantId;
|
||||||
|
let currentTenantClaim: string | undefined =
|
||||||
|
(claims.tenant as string) || user?.tenant || undefined;
|
||||||
|
|
||||||
|
try {
|
||||||
|
const resolved = await resolveUserRolesAndClaims(sub, claims, user || {});
|
||||||
|
const updatedProfile = user ? { ...user } : null;
|
||||||
|
if (updatedProfile && resolved.primaryRole) {
|
||||||
|
updatedProfile.role = resolved.primaryRole;
|
||||||
|
}
|
||||||
|
if (resolved.tenant) {
|
||||||
|
currentTenantClaim = resolved.tenant;
|
||||||
|
setTenantName(resolved.tenant);
|
||||||
|
if (updatedProfile) updatedProfile.tenant = resolved.tenant;
|
||||||
|
}
|
||||||
|
if (resolved.tenant_id !== undefined) {
|
||||||
|
currentTenantId = resolved.tenant_id;
|
||||||
|
setTenantId(resolved.tenant_id);
|
||||||
|
if (updatedProfile) updatedProfile.tenant_id = resolved.tenant_id;
|
||||||
|
}
|
||||||
|
if (updatedProfile) {
|
||||||
|
setUser(updatedProfile);
|
||||||
|
localStorage.setItem(LOCAL_STORAGE_USER_KEY, JSON.stringify(updatedProfile));
|
||||||
|
}
|
||||||
|
setClaims({ ...resolved.claims });
|
||||||
|
localStorage.setItem(LOCAL_STORAGE_CLAIMS_KEY, JSON.stringify(resolved.claims));
|
||||||
|
} catch {}
|
||||||
|
|
||||||
|
// Step 3: Re-verify tenant status via GetTenant now that user is synced
|
||||||
|
const tenantClaimValues = extractTenantClaimValues(claims, accessToken);
|
||||||
|
const effectiveTenantId =
|
||||||
|
currentTenantId ??
|
||||||
|
result.data?.tenantId ??
|
||||||
|
result.data?.organisationId ??
|
||||||
|
tenantClaimValues.tenantId;
|
||||||
|
const effectiveTenantClaim = currentTenantClaim || tenantClaimValues.tenant || undefined;
|
||||||
|
|
||||||
|
const tenantLockoutResult = await verifyTenantLockout(
|
||||||
|
effectiveTenantId,
|
||||||
|
effectiveTenantClaim,
|
||||||
|
true
|
||||||
|
);
|
||||||
if (!tenantLockoutResult.isActive) {
|
if (!tenantLockoutResult.isActive) {
|
||||||
const msg = tenantLockoutResult.errorMessage || 'Tenant is not Active. Lockout active.';
|
const msg = tenantLockoutResult.errorMessage || 'Tenant is not Active. Lockout active.';
|
||||||
setIsPendingActivation(true);
|
setIsPendingActivation(true);
|
||||||
@@ -705,28 +768,22 @@ export const AuthProvider: React.FC<{ children: ReactNode }> = ({ children }) =>
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
// Tenant is verified Active: proceed with user sync
|
if (tenantLockoutResult.tenant?.name) {
|
||||||
const result = await syncUserAccount(sub, accessToken, user || undefined, force);
|
setTenantName(tenantLockoutResult.tenant.name);
|
||||||
if (result.isUnauthorized) {
|
|
||||||
await logout();
|
|
||||||
return result;
|
|
||||||
}
|
|
||||||
if (result.success) {
|
|
||||||
setIsPendingActivation(false);
|
|
||||||
setIsSynced(true);
|
|
||||||
setActivationError(null);
|
|
||||||
} else if (result.isActivationPending) {
|
|
||||||
setIsPendingActivation(true);
|
|
||||||
setIsSynced(false);
|
|
||||||
setActivationError(result.errorMessage || null);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
setActivationPending(false);
|
||||||
|
setIsPendingActivation(false);
|
||||||
|
setIsSynced(true);
|
||||||
|
setActivationError(null);
|
||||||
return result;
|
return result;
|
||||||
} catch (err: any) {
|
} catch (err: any) {
|
||||||
if (err?.response?.status === 401) {
|
if (err?.response?.status === 401) {
|
||||||
await logout();
|
await logout();
|
||||||
return { success: false, isUnauthorized: true };
|
return { success: false, isUnauthorized: true };
|
||||||
}
|
}
|
||||||
const msg = err?.message || 'Sync failed';
|
const rawMsg = err?.message || 'Sync failed';
|
||||||
|
const msg = /network\s*error/i.test(rawMsg) ? rawMsg.replace(/network\s*error/gi, 'Permission Check') : rawMsg;
|
||||||
setIsPendingActivation(true);
|
setIsPendingActivation(true);
|
||||||
setIsSynced(false);
|
setIsSynced(false);
|
||||||
setActivationError(msg);
|
setActivationError(msg);
|
||||||
|
|||||||
@@ -115,141 +115,94 @@ export const AccountActivationModal: React.FC<AccountActivationModalProps> = ({
|
|||||||
navigate('/');
|
navigate('/');
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const isTenantIssue = !!errorMessage?.toLowerCase().includes('tenant');
|
||||||
|
|
||||||
const modalContent = (
|
const modalContent = (
|
||||||
<div
|
<div
|
||||||
role="dialog"
|
role="dialog"
|
||||||
aria-modal="true"
|
aria-modal="true"
|
||||||
aria-labelledby="activation-modal-title"
|
aria-labelledby="activation-modal-title"
|
||||||
className="fixed inset-0 z-[9999] flex items-center justify-center p-4 sm:p-6 bg-[#060a12]/90 backdrop-blur-md animate-in fade-in duration-200 select-none overflow-y-auto"
|
className="fixed inset-0 z-[9999] flex items-center justify-center p-4 bg-[#060a12]/85 backdrop-blur-md animate-in fade-in duration-200 select-none overflow-y-auto"
|
||||||
>
|
>
|
||||||
{/* Non-dismissable Card Container: No X button, no escape dismiss, no backdrop click */}
|
|
||||||
<div
|
<div
|
||||||
className="relative w-full max-w-xl bg-[#0d1322] border border-amber-500/30 rounded-2xl shadow-2xl shadow-black/80 overflow-hidden flex flex-col text-slate-100 my-auto"
|
className="relative w-full max-w-lg bg-[#0d1322] border border-slate-800 rounded-2xl shadow-2xl shadow-black/80 overflow-hidden flex flex-col text-slate-100 my-auto"
|
||||||
onClick={(e) => e.stopPropagation()}
|
onClick={(e) => e.stopPropagation()}
|
||||||
>
|
>
|
||||||
{/* Amber accent top bar */}
|
{/* Subtle accent top border */}
|
||||||
<div className="h-1.5 w-full bg-gradient-to-r from-amber-500 via-amber-400 to-yellow-500" />
|
<div className={`h-1 w-full ${isTenantIssue ? 'bg-amber-500' : 'bg-blue-600'}`} />
|
||||||
|
|
||||||
{/* Modal Header */}
|
{/* Header */}
|
||||||
<div className="p-6 pb-4 border-b border-slate-800/80 bg-[#0f172a]/60">
|
<div className="p-5 pb-4 border-b border-slate-800/80 bg-[#0f172a]/60">
|
||||||
<div className="flex items-start justify-between gap-4">
|
<div className="flex items-start justify-between gap-4">
|
||||||
<div className="flex items-center gap-3.5">
|
<div className="flex items-center gap-3">
|
||||||
<div className="relative p-2.5 rounded-xl bg-amber-500/10 border border-amber-500/20 text-amber-400">
|
<div className={`p-2.5 rounded-xl border ${isTenantIssue ? 'bg-amber-500/10 border-amber-500/20 text-amber-400' : 'bg-blue-500/10 border-blue-500/20 text-blue-400'}`}>
|
||||||
<ShieldAlert className="w-6 h-6" />
|
<ShieldAlert className="w-5 h-5" />
|
||||||
<span className="absolute -top-1 -right-1 flex h-2.5 w-2.5">
|
|
||||||
<span className="animate-ping absolute inline-flex h-full w-full rounded-full bg-amber-400 opacity-75"></span>
|
|
||||||
<span className="relative inline-flex rounded-full h-2.5 w-2.5 bg-amber-500"></span>
|
|
||||||
</span>
|
|
||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
<span className="inline-flex items-center gap-1.5 text-[11px] font-semibold tracking-wider uppercase text-amber-400 bg-amber-500/10 border border-amber-500/20 px-2 py-0.5 rounded-full mb-1">
|
<h2 id="activation-modal-title" className="text-base font-bold text-white tracking-tight">
|
||||||
Access Restricted • Lockout Active
|
{isTenantIssue ? 'Tenant Access Restricted' : 'Account Pending Activation'}
|
||||||
</span>
|
|
||||||
<h2 id="activation-modal-title" className="text-lg sm:text-xl font-bold text-white tracking-tight">
|
|
||||||
{errorMessage?.toLowerCase().includes('tenant')
|
|
||||||
? 'Tenant Lockout • Account Inactive'
|
|
||||||
: 'Account Pending Administrator Activation'}
|
|
||||||
</h2>
|
</h2>
|
||||||
|
<p className="text-xs text-slate-400 mt-0.5">
|
||||||
|
{isTenantIssue
|
||||||
|
? 'The assigned tenant partition is currently inactive or restricted.'
|
||||||
|
: 'Your account is authenticated and awaiting administrator enablement.'}
|
||||||
|
</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<BrandLogo size={36} showGlow={false} animateOnHover={false} />
|
<BrandLogo size={32} showGlow={false} animateOnHover={false} />
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Modal Body */}
|
{/* Body */}
|
||||||
<div className="p-6 space-y-5 overflow-y-auto max-h-[70vh]">
|
<div className="p-5 space-y-4">
|
||||||
{/* Status notification block */}
|
{/* User Profile Summary */}
|
||||||
<div className="bg-slate-900/80 border border-slate-800 rounded-xl p-4 space-y-2.5">
|
<div className="rounded-xl border border-slate-800/80 bg-slate-900/60 p-3.5 space-y-2 text-xs">
|
||||||
<div className="flex items-center gap-2 text-emerald-400 text-xs font-semibold">
|
<div className="grid grid-cols-2 gap-2.5">
|
||||||
<CheckCircle2 className="w-4 h-4 shrink-0" />
|
<div>
|
||||||
<span>Identity Verified & Signed In Successfully</span>
|
<span className="text-[10px] uppercase font-semibold text-slate-500 block">User</span>
|
||||||
|
<span className="font-semibold text-white truncate block">{displayName}</span>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<span className="text-[10px] uppercase font-semibold text-slate-500 block">Email</span>
|
||||||
|
<span className="text-slate-300 truncate block">{displayEmail || '—'}</span>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<p className="text-xs text-slate-300 leading-relaxed">
|
<div className="grid grid-cols-2 gap-2.5 border-t border-slate-800/60 pt-2">
|
||||||
You are a registered user and have successfully authenticated via Enterprise Single Sign-On.
|
<div>
|
||||||
However, your account has not yet been enabled in the system directory by a system administrator.
|
<span className="text-[10px] uppercase font-semibold text-slate-500 block">Tenant</span>
|
||||||
</p>
|
<span className="text-slate-300 truncate block">{tenant}</span>
|
||||||
<p className="text-xs text-slate-400 leading-relaxed border-t border-slate-800/80 pt-2 font-medium">
|
|
||||||
If your administrator informs you that your account has been activated, please click the{' '}
|
|
||||||
<strong className="text-white">Sync Account</strong> button below to refresh your status and unlock the system.
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* User Profile Card */}
|
|
||||||
<div className="rounded-xl border border-slate-800 bg-[#090e1a]/60 p-4 space-y-3">
|
|
||||||
<div className="text-[11px] font-semibold text-slate-400 uppercase tracking-wider flex items-center justify-between">
|
|
||||||
<span>Authenticated User Profile</span>
|
|
||||||
<span className="text-amber-400/90 text-[10px] font-mono bg-amber-500/10 px-2 py-0.5 rounded border border-amber-500/20">
|
|
||||||
Lockout Active
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="grid grid-cols-1 sm:grid-cols-2 gap-3 text-xs">
|
|
||||||
<div className="flex items-center gap-2.5 bg-slate-900/60 p-2.5 rounded-lg border border-slate-800/60">
|
|
||||||
<UserCheck className="w-4 h-4 text-blue-400 shrink-0" />
|
|
||||||
<div className="min-w-0">
|
|
||||||
<span className="text-[10px] text-slate-500 block">Name</span>
|
|
||||||
<span className="font-semibold text-white truncate block">{displayName}</span>
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
|
<div>
|
||||||
<div className="flex items-center gap-2.5 bg-slate-900/60 p-2.5 rounded-lg border border-slate-800/60">
|
<span className="text-[10px] uppercase font-semibold text-slate-500 block">Role</span>
|
||||||
<Mail className="w-4 h-4 text-blue-400 shrink-0" />
|
<span className="text-blue-400 font-medium truncate block">{role}</span>
|
||||||
<div className="min-w-0">
|
|
||||||
<span className="text-[10px] text-slate-500 block">Email</span>
|
|
||||||
<span className="font-semibold text-slate-200 truncate block">{displayEmail || 'Not specified'}</span>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="flex items-center gap-2.5 bg-slate-900/60 p-2.5 rounded-lg border border-slate-800/60">
|
|
||||||
<Building2 className="w-4 h-4 text-indigo-400 shrink-0" />
|
|
||||||
<div className="min-w-0">
|
|
||||||
<span className="text-[10px] text-slate-500 block">Tenant / Role</span>
|
|
||||||
<span className="font-semibold text-slate-200 truncate block">
|
|
||||||
{tenant} ({role})
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="flex items-center gap-2.5 bg-slate-900/60 p-2.5 rounded-lg border border-slate-800/60">
|
|
||||||
<Fingerprint className="w-4 h-4 text-slate-400 shrink-0" />
|
|
||||||
<div className="min-w-0">
|
|
||||||
<span className="text-[10px] text-slate-500 block">Subject ID</span>
|
|
||||||
<span className="font-mono text-[11px] text-slate-400 truncate block">{sub || 'SSO-Principal'}</span>
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Sync Attempt Feedback / Error Notice */}
|
{/* Feedback notice if sync failed */}
|
||||||
{errorMessage && (
|
{errorMessage && (
|
||||||
<div className="rounded-xl border border-rose-500/30 bg-rose-500/10 p-3.5 flex items-start gap-3 animate-in fade-in duration-150">
|
<div className="rounded-xl border border-amber-500/25 bg-amber-500/10 p-3 flex items-start gap-2.5 text-xs text-amber-200">
|
||||||
<AlertTriangle className="w-4 h-4 text-rose-400 shrink-0 mt-0.5" />
|
<AlertTriangle className="w-4 h-4 text-amber-400 shrink-0 mt-0.5" />
|
||||||
<div className="space-y-1">
|
<div className="min-w-0">
|
||||||
<p className="text-xs font-semibold text-rose-300">
|
<span className="font-semibold text-amber-300 block">Notice:</span>
|
||||||
{errorMessage?.toLowerCase().includes('tenant')
|
<p className="text-[11px] text-amber-200/90 leading-relaxed mt-0.5">{errorMessage}</p>
|
||||||
? 'Tenant Lockout In Effect'
|
|
||||||
: 'Account Not Activated Yet'}
|
|
||||||
</p>
|
|
||||||
<p className="text-xs text-rose-200/90 leading-relaxed">{errorMessage}</p>
|
|
||||||
{syncAttemptCount > 0 && (
|
|
||||||
<p className="text-[11px] text-rose-400/80 pt-1">
|
|
||||||
Attempt #{syncAttemptCount} checked at {new Date().toLocaleTimeString()}
|
|
||||||
</p>
|
|
||||||
)}
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
<p className="text-[11px] text-slate-400 text-center">
|
||||||
|
Once your administrator enables your account, click <strong className="text-white">Sync Account</strong> to enter the console.
|
||||||
|
</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Modal Actions */}
|
{/* Footer Actions */}
|
||||||
<div className="p-5 border-t border-slate-800/80 bg-[#0a0f1d] flex flex-col sm:flex-row items-center justify-between gap-3">
|
<div className="p-4 border-t border-slate-800 bg-[#0a0f1d] flex flex-col sm:flex-row items-center justify-between gap-2.5">
|
||||||
<div className="flex items-center gap-2 w-full sm:w-auto">
|
<div className="flex items-center gap-2 w-full sm:w-auto">
|
||||||
<button
|
<button
|
||||||
onClick={handleNavigateLanding}
|
onClick={handleNavigateLanding}
|
||||||
type="button"
|
type="button"
|
||||||
className="flex-1 sm:flex-none inline-flex items-center justify-center gap-1.5 px-3.5 py-2 text-xs font-medium text-slate-400 hover:text-slate-200 bg-slate-900 hover:bg-slate-800 border border-slate-800 rounded-xl transition cursor-pointer"
|
className="flex-1 sm:flex-none inline-flex items-center justify-center gap-1.5 px-3 py-2 text-xs font-medium text-slate-400 hover:text-white bg-slate-900 hover:bg-slate-800 border border-slate-800 rounded-xl transition cursor-pointer"
|
||||||
title="View public landing page. Entering the console again will display this modal until enabled."
|
|
||||||
>
|
>
|
||||||
<ArrowLeft className="w-3.5 h-3.5" />
|
<ArrowLeft className="w-3.5 h-3.5" />
|
||||||
<span>Landing Page</span>
|
<span>Landing Page</span>
|
||||||
@@ -258,8 +211,7 @@ export const AccountActivationModal: React.FC<AccountActivationModalProps> = ({
|
|||||||
<button
|
<button
|
||||||
onClick={handleSignOut}
|
onClick={handleSignOut}
|
||||||
type="button"
|
type="button"
|
||||||
className="flex-1 sm:flex-none inline-flex items-center justify-center gap-1.5 px-3.5 py-2 text-xs font-medium text-slate-400 hover:text-rose-400 bg-slate-900 hover:bg-rose-500/10 border border-slate-800 hover:border-rose-500/30 rounded-xl transition cursor-pointer"
|
className="flex-1 sm:flex-none inline-flex items-center justify-center gap-1.5 px-3 py-2 text-xs font-medium text-slate-400 hover:text-rose-400 bg-slate-900 hover:bg-rose-500/10 border border-slate-800 hover:border-rose-500/20 rounded-xl transition cursor-pointer"
|
||||||
title="Sign out of current SSO session"
|
|
||||||
>
|
>
|
||||||
<LogOut className="w-3.5 h-3.5" />
|
<LogOut className="w-3.5 h-3.5" />
|
||||||
<span>Sign Out</span>
|
<span>Sign Out</span>
|
||||||
@@ -271,10 +223,10 @@ export const AccountActivationModal: React.FC<AccountActivationModalProps> = ({
|
|||||||
disabled={isSyncing}
|
disabled={isSyncing}
|
||||||
type="button"
|
type="button"
|
||||||
id="activation-sync-btn"
|
id="activation-sync-btn"
|
||||||
className="w-full sm:w-auto inline-flex items-center justify-center gap-2 px-5 py-2.5 text-xs font-semibold text-white bg-blue-600 hover:bg-blue-500 active:bg-blue-700 disabled:opacity-50 disabled:pointer-events-none rounded-xl shadow-lg shadow-blue-600/30 transition cursor-pointer"
|
className="w-full sm:w-auto inline-flex items-center justify-center gap-2 px-4 py-2 text-xs font-semibold text-white bg-blue-600 hover:bg-blue-500 active:bg-blue-700 disabled:opacity-50 rounded-xl shadow-md transition cursor-pointer"
|
||||||
>
|
>
|
||||||
<RefreshCw className={`w-4 h-4 ${isSyncing ? 'animate-spin' : ''}`} />
|
<RefreshCw className={`w-3.5 h-3.5 ${isSyncing ? 'animate-spin' : ''}`} />
|
||||||
<span>{isSyncing ? 'Verifying Activation...' : 'Sync Account'}</span>
|
<span>{isSyncing ? 'Checking...' : 'Sync Account'}</span>
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -61,114 +61,57 @@ export const PartnerAccessDeniedModal: React.FC<PartnerAccessDeniedModalProps> =
|
|||||||
aria-modal="true"
|
aria-modal="true"
|
||||||
aria-labelledby="partner-denied-title"
|
aria-labelledby="partner-denied-title"
|
||||||
aria-describedby="partner-denied-description"
|
aria-describedby="partner-denied-description"
|
||||||
className="fixed inset-0 z-[9999] flex items-center justify-center p-4 bg-black/85 backdrop-blur-md animate-in fade-in duration-200 overflow-y-auto"
|
className="fixed inset-0 z-[9999] flex items-center justify-center p-4 bg-black/80 backdrop-blur-md animate-in fade-in duration-150 overflow-y-auto"
|
||||||
>
|
>
|
||||||
<div className="relative w-full max-w-lg overflow-hidden rounded-2xl bg-[#0e1424] border border-rose-900/60 shadow-2xl shadow-rose-950/50 my-auto">
|
<div className="relative w-full max-w-md overflow-hidden rounded-2xl bg-[#0d1322] border border-slate-800 shadow-2xl my-auto text-slate-100">
|
||||||
{/* Glow Header Accent */}
|
<div className="h-1 w-full bg-rose-600" />
|
||||||
<div className="h-2 w-full bg-linear-to-r from-rose-600 via-red-500 to-amber-500" />
|
|
||||||
|
|
||||||
<div className="p-6 sm:p-8 space-y-6">
|
<div className="p-5 space-y-4">
|
||||||
{/* Header */}
|
<div className="flex items-start gap-3">
|
||||||
<div className="flex items-start gap-4">
|
<div className="flex h-10 w-10 shrink-0 items-center justify-center rounded-xl bg-rose-500/10 border border-rose-500/20 text-rose-400">
|
||||||
<div className="flex h-12 w-12 shrink-0 items-center justify-center rounded-2xl bg-rose-500/15 border border-rose-500/30 text-rose-400">
|
<Ban className="h-5 w-5" />
|
||||||
<Ban className="h-6 w-6 text-rose-400" />
|
|
||||||
</div>
|
</div>
|
||||||
<div className="space-y-1">
|
<div>
|
||||||
<div className="flex items-center gap-2">
|
<h2 id="partner-denied-title" className="text-base font-bold text-white tracking-tight">
|
||||||
<span className="inline-flex items-center gap-1 text-[11px] font-semibold text-rose-400 bg-rose-950/60 px-2 py-0.5 rounded-md border border-rose-800/60">
|
Partner Portal Required
|
||||||
<Lock className="w-3 h-3" />
|
|
||||||
Restricted Role
|
|
||||||
</span>
|
|
||||||
<span className="text-xs text-slate-500">Security Policy</span>
|
|
||||||
</div>
|
|
||||||
<h2 id="partner-denied-title" className="text-lg font-bold text-white tracking-tight">
|
|
||||||
Access Denied: Partner Role
|
|
||||||
</h2>
|
</h2>
|
||||||
<p id="partner-denied-description" className="text-xs text-slate-400 leading-relaxed">
|
<p id="partner-denied-description" className="text-xs text-slate-400 mt-0.5">
|
||||||
Users assigned the <strong className="text-rose-300">Partner</strong> role by security are not permitted to access the PFM Backoffice Console.
|
Accounts with the Partner role must access the Partner Portal.
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* User & Identity Summary Card */}
|
<div className="rounded-xl bg-slate-900/60 border border-slate-800 p-3.5 space-y-2 text-xs">
|
||||||
<div className="rounded-xl bg-slate-900/80 border border-slate-800 p-4 space-y-3 text-xs">
|
<div className="flex items-center justify-between">
|
||||||
<div className="flex items-center justify-between pb-2 border-b border-slate-800/80">
|
<span className="text-slate-500 text-[11px] uppercase font-semibold">User</span>
|
||||||
<span className="text-slate-400 font-medium">Account Name</span>
|
<span className="text-white font-medium">{displayName}</span>
|
||||||
<span className="text-white font-semibold">{displayName}</span>
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{displayEmail && (
|
{displayEmail && (
|
||||||
<div className="flex items-center justify-between pb-2 border-b border-slate-800/80">
|
<div className="flex items-center justify-between border-t border-slate-800/60 pt-2">
|
||||||
<span className="text-slate-400 font-medium flex items-center gap-1.5">
|
<span className="text-slate-500 text-[11px] uppercase font-semibold">Email</span>
|
||||||
<Mail className="w-3.5 h-3.5 text-slate-500" />
|
<span className="text-slate-300 font-mono text-[11px]">{displayEmail}</span>
|
||||||
Email
|
|
||||||
</span>
|
|
||||||
<span className="text-slate-200 font-mono">{displayEmail}</span>
|
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
<div className="flex items-center justify-between border-t border-slate-800/60 pt-2">
|
||||||
<div className="flex items-center justify-between pb-2 border-b border-slate-800/80">
|
<span className="text-slate-500 text-[11px] uppercase font-semibold">Role</span>
|
||||||
<span className="text-slate-400 font-medium flex items-center gap-1.5">
|
<span className="text-rose-400 font-medium">{role}</span>
|
||||||
<Building2 className="w-3.5 h-3.5 text-slate-500" />
|
|
||||||
Assigned Role
|
|
||||||
</span>
|
|
||||||
<span className="text-rose-400 font-semibold bg-rose-950/60 px-2 py-0.5 rounded border border-rose-800/50">
|
|
||||||
{role}
|
|
||||||
</span>
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{tenant && (
|
|
||||||
<div className="flex items-center justify-between pb-2 border-b border-slate-800/80">
|
|
||||||
<span className="text-slate-400 font-medium flex items-center gap-1.5">
|
|
||||||
<Building2 className="w-3.5 h-3.5 text-slate-500" />
|
|
||||||
Tenant Context
|
|
||||||
</span>
|
|
||||||
<span className="text-slate-200 font-medium truncate max-w-[220px]">{tenant}</span>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{sub && (
|
|
||||||
<div className="flex items-center justify-between text-[11px] text-slate-500">
|
|
||||||
<span className="flex items-center gap-1">
|
|
||||||
<Fingerprint className="w-3 h-3" />
|
|
||||||
Subject Key
|
|
||||||
</span>
|
|
||||||
<span className="font-mono text-slate-400 truncate max-w-[200px]">{sub}</span>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Security Policy Information */}
|
<p className="text-[11px] text-slate-400 text-center leading-relaxed">
|
||||||
<div className="p-4 rounded-xl bg-rose-950/20 border border-rose-900/40 text-xs text-rose-200 leading-relaxed space-y-1.5">
|
The Console is reserved for statutory administrators and programme managers.
|
||||||
<p className="font-semibold text-rose-300 flex items-center gap-1.5">
|
</p>
|
||||||
<ShieldAlert className="w-4 h-4 text-rose-400" />
|
|
||||||
Administrative Boundary Notice
|
|
||||||
</p>
|
|
||||||
<p className="text-[11px] text-rose-300/90">
|
|
||||||
The PFM Console is reserved for statutory administrators, sponsors, programme managers, and governance officers. Partner credentials cannot access this environment. If you require backoffice administrative access, please request an updated role assignment from your system administrator.
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Actions */}
|
<div className="pt-1">
|
||||||
<div className="pt-2">
|
|
||||||
<button
|
<button
|
||||||
onClick={handleLogoutClick}
|
onClick={handleLogoutClick}
|
||||||
className="w-full flex items-center justify-center gap-2 py-2.5 px-4 rounded-xl bg-slate-800 hover:bg-slate-700 text-slate-200 hover:text-white text-xs font-semibold border border-slate-700 transition cursor-pointer shadow-sm"
|
className="w-full flex items-center justify-center gap-2 py-2.5 px-4 rounded-xl bg-blue-600 hover:bg-blue-500 text-white text-xs font-semibold shadow-md transition cursor-pointer"
|
||||||
>
|
>
|
||||||
<LogOut className="w-4 h-4 text-rose-400" />
|
<LogOut className="w-4 h-4" />
|
||||||
<span>Sign Out & Return to Home</span>
|
<span>Sign Out & Return Home</span>
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Footer Brand watermark */}
|
|
||||||
<div className="px-6 py-3 bg-[#0a0f1d] border-t border-slate-800/80 flex items-center justify-between text-[11px] text-slate-500">
|
|
||||||
<div className="flex items-center gap-2">
|
|
||||||
<BrandLogo size={16} showGlow={false} animateOnHover={false} />
|
|
||||||
<span>Post Fund Management (PFM)</span>
|
|
||||||
</div>
|
|
||||||
<span>National Security Gateway</span>
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -87,7 +87,7 @@ export const SessionExpiredModal: React.FC<SessionExpiredModalProps> = ({
|
|||||||
const modalContent = (
|
const modalContent = (
|
||||||
<div
|
<div
|
||||||
id="session-expired-modal-backdrop"
|
id="session-expired-modal-backdrop"
|
||||||
className="fixed inset-0 z-[9999] flex items-center justify-center bg-black/85 backdrop-blur-md p-4 animate-in fade-in duration-200 overflow-y-auto"
|
className="fixed inset-0 z-[9999] flex items-center justify-center bg-black/80 backdrop-blur-md p-4 animate-in fade-in duration-150 overflow-y-auto"
|
||||||
onMouseEnter={() => setIsPaused(true)}
|
onMouseEnter={() => setIsPaused(true)}
|
||||||
onMouseLeave={() => setIsPaused(false)}
|
onMouseLeave={() => setIsPaused(false)}
|
||||||
role="dialog"
|
role="dialog"
|
||||||
@@ -96,27 +96,20 @@ export const SessionExpiredModal: React.FC<SessionExpiredModalProps> = ({
|
|||||||
>
|
>
|
||||||
<div
|
<div
|
||||||
id="session-expired-modal-content"
|
id="session-expired-modal-content"
|
||||||
className="relative w-full max-w-md bg-[#0c121e] border border-amber-500/40 rounded-2xl shadow-2xl shadow-black/90 p-6 overflow-hidden text-slate-200 space-y-5 my-auto"
|
className="relative w-full max-w-md bg-[#0d1322] border border-slate-800 rounded-2xl shadow-2xl p-5 overflow-hidden text-slate-200 space-y-4 my-auto"
|
||||||
>
|
>
|
||||||
{/* Subtle decorative top accent bar */}
|
<div className="absolute top-0 left-0 right-0 h-1 bg-amber-500" />
|
||||||
<div className="absolute top-0 left-0 right-0 h-1 bg-gradient-to-r from-amber-500 via-orange-500 to-rose-500" />
|
|
||||||
|
|
||||||
{/* Top bar with close button */}
|
|
||||||
<div className="flex items-start justify-between pt-1">
|
<div className="flex items-start justify-between pt-1">
|
||||||
<div className="flex items-center gap-3">
|
<div className="flex items-center gap-3">
|
||||||
<div className="w-12 h-12 rounded-2xl bg-amber-500/10 border border-amber-500/30 flex items-center justify-center text-amber-400 shrink-0 shadow-lg shadow-amber-500/10">
|
<div className="w-10 h-10 rounded-xl bg-amber-500/10 border border-amber-500/20 flex items-center justify-center text-amber-400 shrink-0">
|
||||||
<ShieldAlert className="w-6 h-6" />
|
<ShieldAlert className="w-5 h-5" />
|
||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
<div className="flex items-center gap-2">
|
<h3 id="session-expired-title" className="text-base font-bold text-white tracking-tight">
|
||||||
<h3 id="session-expired-title" className="text-base font-bold text-white tracking-tight">
|
Session Expired
|
||||||
Session Expired
|
</h3>
|
||||||
</h3>
|
<p className="text-xs text-slate-400">Authorization credentials expired</p>
|
||||||
<span className="text-[10px] font-mono font-semibold px-2 py-0.5 rounded bg-rose-950/60 text-rose-300 border border-rose-800/60">
|
|
||||||
HTTP 401
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
<p className="text-xs text-slate-400 mt-0.5">Authorization token invalidated</p>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -125,60 +118,46 @@ export const SessionExpiredModal: React.FC<SessionExpiredModalProps> = ({
|
|||||||
type="button"
|
type="button"
|
||||||
onClick={handleAction}
|
onClick={handleAction}
|
||||||
aria-label="Dismiss and return to landing page"
|
aria-label="Dismiss and return to landing page"
|
||||||
className="p-1.5 text-slate-400 hover:text-white rounded-lg hover:bg-slate-800/80 transition-colors"
|
className="p-1.5 text-slate-400 hover:text-white rounded-lg hover:bg-slate-800 transition-colors cursor-pointer"
|
||||||
>
|
>
|
||||||
<X className="w-4 h-4" />
|
<X className="w-4 h-4" />
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Informative Explanation (non-technical and clear) */}
|
<div className="text-xs text-slate-300 leading-relaxed bg-slate-900/60 p-3.5 rounded-xl border border-slate-800/80 space-y-1.5">
|
||||||
<div className="space-y-2.5 text-xs text-slate-300 leading-relaxed bg-slate-900/60 p-4 rounded-xl border border-slate-800/80">
|
|
||||||
<p>
|
<p>
|
||||||
Your active working session has ended or your security credentials have expired. To protect your data and prevent unauthorized changes, your session has been closed.
|
Your active session has ended to protect account security. Please sign in again to continue working.
|
||||||
</p>
|
</p>
|
||||||
{reason && (
|
{reason && (
|
||||||
<div className="flex items-start gap-2 pt-1 text-[11px] text-amber-300/90 font-mono">
|
<div className="flex items-start gap-1.5 pt-1 text-[11px] text-amber-300/90 font-mono">
|
||||||
<AlertTriangle className="w-3.5 h-3.5 shrink-0 mt-0.5 text-amber-400" />
|
<AlertTriangle className="w-3.5 h-3.5 shrink-0 mt-0.5 text-amber-400" />
|
||||||
<span className="break-words">{reason}</span>
|
<span className="break-words">{reason}</span>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Progress Bar Countdown */}
|
<div className="flex items-center justify-between text-xs text-slate-400">
|
||||||
<div className="space-y-2">
|
<span className="flex items-center gap-1.5">
|
||||||
<div className="flex items-center justify-between text-xs">
|
<Clock className="w-3.5 h-3.5 text-amber-400" />
|
||||||
<span className="text-slate-400 flex items-center gap-1.5">
|
Redirecting in <strong className="text-white font-mono">{secondsRemaining}s</strong>
|
||||||
<Clock className="w-3.5 h-3.5 text-amber-400" />
|
</span>
|
||||||
Returning to landing page in{' '}
|
{isPaused && (
|
||||||
<strong className="text-white font-mono">{secondsRemaining}s</strong>
|
<span className="text-[10px] text-amber-400/80 px-1.5 py-0.5 rounded bg-amber-500/10 border border-amber-500/20">
|
||||||
|
Paused
|
||||||
</span>
|
</span>
|
||||||
{isPaused && (
|
)}
|
||||||
<span className="text-[10px] text-amber-400/80 font-medium px-1.5 py-0.2 rounded bg-amber-950/40 border border-amber-900/60">
|
|
||||||
Paused on hover
|
|
||||||
</span>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Progress bar line */}
|
|
||||||
<div className="w-full h-2 bg-slate-800/90 rounded-full overflow-hidden border border-slate-700/60 p-0.5">
|
|
||||||
<div
|
|
||||||
className="h-full rounded-full bg-gradient-to-r from-amber-500 via-orange-500 to-rose-500 transition-all duration-75 ease-linear"
|
|
||||||
style={{ width: `${progressPercent}%` }}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Actions */}
|
<div className="pt-1">
|
||||||
<div className="pt-2 flex flex-col sm:flex-row items-center gap-2.5">
|
|
||||||
<button
|
<button
|
||||||
id="session-expired-return-btn"
|
id="session-expired-return-btn"
|
||||||
type="button"
|
type="button"
|
||||||
onClick={handleAction}
|
onClick={handleAction}
|
||||||
className="w-full py-2.5 px-4 text-xs font-semibold text-white bg-gradient-to-r from-blue-600 to-indigo-600 hover:from-blue-500 hover:to-indigo-500 rounded-xl shadow-lg shadow-blue-500/20 transition-all flex items-center justify-center gap-2"
|
className="w-full py-2.5 px-4 text-xs font-semibold text-white bg-blue-600 hover:bg-blue-500 rounded-xl shadow-md transition-all flex items-center justify-center gap-2 cursor-pointer"
|
||||||
>
|
>
|
||||||
<LogOut className="w-4 h-4" />
|
<LogOut className="w-4 h-4" />
|
||||||
<span>Return to Landing Page Now</span>
|
<span>Return to Landing Page</span>
|
||||||
<ArrowRight className="w-3.5 h-3.5 ml-0.5" />
|
<ArrowRight className="w-3.5 h-3.5" />
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -48,7 +48,7 @@ const instrumentSchema = z.object({
|
|||||||
name: z.string().min(3, 'Funding instrument title must be at least 3 characters'),
|
name: z.string().min(3, 'Funding instrument title must be at least 3 characters'),
|
||||||
code: z.string().optional(),
|
code: z.string().optional(),
|
||||||
description: z.string().min(5, 'Description is required (minimum 5 characters)'),
|
description: z.string().min(5, 'Description is required (minimum 5 characters)'),
|
||||||
status: z.coerce.number().default(ActivityStatus.Active),
|
status: z.coerce.number(),
|
||||||
});
|
});
|
||||||
|
|
||||||
type InstrumentFormValues = z.infer<typeof instrumentSchema>;
|
type InstrumentFormValues = z.infer<typeof instrumentSchema>;
|
||||||
|
|||||||
@@ -46,8 +46,8 @@ const ruleSchema = z.object({
|
|||||||
programmeId: z.coerce.number().min(1, 'Please select an associated programme from the dropdown'),
|
programmeId: z.coerce.number().min(1, 'Please select an associated programme from the dropdown'),
|
||||||
name: z.string().min(3, 'Rule name must be at least 3 characters'),
|
name: z.string().min(3, 'Rule name must be at least 3 characters'),
|
||||||
description: z.string().min(5, 'Description is required (minimum 5 characters)'),
|
description: z.string().min(5, 'Description is required (minimum 5 characters)'),
|
||||||
version: z.coerce.number().min(1, 'Version must be at least 1').default(1),
|
version: z.coerce.number().min(1, 'Version must be at least 1'),
|
||||||
status: z.coerce.number().default(ActivityStatus.Active),
|
status: z.coerce.number(),
|
||||||
});
|
});
|
||||||
|
|
||||||
type RuleFormValues = z.infer<typeof ruleSchema>;
|
type RuleFormValues = z.infer<typeof ruleSchema>;
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import React, { useState } from 'react';
|
import React, { useState } from 'react';
|
||||||
import { NavLink, Outlet, useNavigate, Link } from 'react-router-dom';
|
import { NavLink, Outlet, useNavigate, Link, useLocation } from 'react-router-dom';
|
||||||
import { NotificationBell } from './NotificationBell';
|
import { NotificationBell } from './NotificationBell';
|
||||||
import {
|
import {
|
||||||
Inbox,
|
Inbox,
|
||||||
@@ -124,7 +124,7 @@ const navItems: NavItem[] = [
|
|||||||
claim: 'tenant_management',
|
claim: 'tenant_management',
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
name: 'Mailbox & Inbox',
|
name: 'Inbox',
|
||||||
path: '/inbox',
|
path: '/inbox',
|
||||||
icon: Inbox,
|
icon: Inbox,
|
||||||
help: 'In-app notifications, communications, deliverable messages, drafts, and replies across active tenant users.',
|
help: 'In-app notifications, communications, deliverable messages, drafts, and replies across active tenant users.',
|
||||||
@@ -144,6 +144,27 @@ export const AppLayout: React.FC = () => {
|
|||||||
const [sidebarOpen, setSidebarOpen] = useState(true);
|
const [sidebarOpen, setSidebarOpen] = useState(true);
|
||||||
const [userMenuOpen, setUserMenuOpen] = useState(false);
|
const [userMenuOpen, setUserMenuOpen] = useState(false);
|
||||||
const navigate = useNavigate();
|
const navigate = useNavigate();
|
||||||
|
const location = useLocation();
|
||||||
|
const [collapsedSections, setCollapsedSections] = useState<Record<string, boolean>>({});
|
||||||
|
|
||||||
|
const isSectionCollapsed = (path: string, subItems?: SubNavItem[]): boolean => {
|
||||||
|
if (collapsedSections[path] !== undefined) {
|
||||||
|
return collapsedSections[path];
|
||||||
|
}
|
||||||
|
if (subItems?.some((s) => location.pathname.startsWith(s.path))) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
return false;
|
||||||
|
};
|
||||||
|
|
||||||
|
const toggleSection = (path: string, e: React.MouseEvent) => {
|
||||||
|
e.preventDefault();
|
||||||
|
e.stopPropagation();
|
||||||
|
setCollapsedSections((prev) => ({
|
||||||
|
...prev,
|
||||||
|
[path]: !isSectionCollapsed(path),
|
||||||
|
}));
|
||||||
|
};
|
||||||
|
|
||||||
const handleLogout = async () => {
|
const handleLogout = async () => {
|
||||||
await logout();
|
await logout();
|
||||||
@@ -186,7 +207,6 @@ export const AppLayout: React.FC = () => {
|
|||||||
useQuery({
|
useQuery({
|
||||||
queryKey: ['users-directory'],
|
queryKey: ['users-directory'],
|
||||||
queryFn: () => getUsers(1, 200),
|
queryFn: () => getUsers(1, 200),
|
||||||
staleTime: 5 * 60 * 1000,
|
|
||||||
});
|
});
|
||||||
|
|
||||||
// Query tenant endpoint to resolve tenant name if not provided in claims or profile
|
// Query tenant endpoint to resolve tenant name if not provided in claims or profile
|
||||||
@@ -194,7 +214,6 @@ export const AppLayout: React.FC = () => {
|
|||||||
queryKey: ['active-tenant-header', tenantId],
|
queryKey: ['active-tenant-header', tenantId],
|
||||||
queryFn: () => getTenant(tenantId!),
|
queryFn: () => getTenant(tenantId!),
|
||||||
enabled: !!tenantId && !claims?.tenant && !user?.tenant && !authTenantName,
|
enabled: !!tenantId && !claims?.tenant && !user?.tenant && !authTenantName,
|
||||||
staleTime: 5 * 60 * 1000,
|
|
||||||
});
|
});
|
||||||
|
|
||||||
// Ensure current logged-in user is registered in the email cache
|
// Ensure current logged-in user is registered in the email cache
|
||||||
@@ -346,26 +365,45 @@ export const AppLayout: React.FC = () => {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const isCollapsed = isSectionCollapsed(item.path, item.subItems);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div key={item.path} className="space-y-1">
|
<div key={item.path} className="space-y-1">
|
||||||
<NavLink
|
<div className="flex items-center gap-1">
|
||||||
to={item.path}
|
<NavLink
|
||||||
end={hasSub}
|
to={item.path}
|
||||||
className={({ isActive }) =>
|
end={hasSub}
|
||||||
`flex items-center gap-3 px-3.5 py-2.5 rounded-xl text-xs font-medium transition-all min-w-0 ${
|
className={({ isActive }) =>
|
||||||
isActive
|
`flex-1 flex items-center gap-3 px-3.5 py-2.5 rounded-xl text-xs font-medium transition-all min-w-0 ${
|
||||||
? 'bg-gradient-to-r from-blue-600 to-indigo-600 text-white shadow-md shadow-blue-600/25 font-semibold'
|
isActive
|
||||||
: 'text-slate-400 hover:text-slate-100 hover:bg-slate-800/60'
|
? 'bg-gradient-to-r from-blue-600 to-indigo-600 text-white shadow-md shadow-blue-600/25 font-semibold'
|
||||||
}`
|
: 'text-slate-400 hover:text-slate-100 hover:bg-slate-800/60'
|
||||||
}
|
}`
|
||||||
title={item.name}
|
}
|
||||||
>
|
title={item.name}
|
||||||
<Icon className="w-4.5 h-4.5 shrink-0" />
|
>
|
||||||
<span className="truncate flex-1">{item.name}</span>
|
<Icon className="w-4.5 h-4.5 shrink-0" />
|
||||||
</NavLink>
|
<span className="truncate flex-1">{item.name}</span>
|
||||||
|
</NavLink>
|
||||||
|
{hasSub && (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={(e) => toggleSection(item.path, e)}
|
||||||
|
className="p-2 text-slate-400 hover:text-white hover:bg-slate-800/80 rounded-lg transition-colors cursor-pointer"
|
||||||
|
title={isCollapsed ? `Expand ${item.name}` : `Collapse ${item.name}`}
|
||||||
|
aria-label={isCollapsed ? `Expand ${item.name}` : `Collapse ${item.name}`}
|
||||||
|
>
|
||||||
|
<ChevronDown
|
||||||
|
className={`w-3.5 h-3.5 transition-transform duration-200 ${
|
||||||
|
isCollapsed ? '-rotate-90 text-slate-500' : 'rotate-0 text-slate-300'
|
||||||
|
}`}
|
||||||
|
/>
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
{/* Sub-items under parent item */}
|
{/* Sub-items under parent item */}
|
||||||
{hasSub && (
|
{hasSub && !isCollapsed && (
|
||||||
<div className="pl-6 pr-1 py-1 space-y-1 border-l border-slate-800/80 ml-5">
|
<div className="pl-6 pr-1 py-1 space-y-1 border-l border-slate-800/80 ml-5">
|
||||||
{item.subItems?.map((subItem) => {
|
{item.subItems?.map((subItem) => {
|
||||||
const SubIcon = subItem.icon;
|
const SubIcon = subItem.icon;
|
||||||
|
|||||||
@@ -36,7 +36,7 @@ import {
|
|||||||
getAllProgrammes,
|
getAllProgrammes,
|
||||||
getProjects,
|
getProjects,
|
||||||
} from '../../services/api';
|
} from '../../services/api';
|
||||||
import { toolsService } from '../../services/toolsService';
|
import { toolsService } from '../../utils/hashids';
|
||||||
import { Modal } from '../ui/Modal';
|
import { Modal } from '../ui/Modal';
|
||||||
import { useToast } from '../ui/Toast';
|
import { useToast } from '../ui/Toast';
|
||||||
import { ProblemDetailsAlert } from '../ui/ProblemDetailsAlert';
|
import { ProblemDetailsAlert } from '../ui/ProblemDetailsAlert';
|
||||||
@@ -898,7 +898,7 @@ export const ContractsManagement: React.FC<ContractsManagementProps> = ({
|
|||||||
className="space-y-4"
|
className="space-y-4"
|
||||||
>
|
>
|
||||||
{/* ProblemDetails Error Alert */}
|
{/* ProblemDetails Error Alert */}
|
||||||
{createApiError && (
|
{Boolean(createApiError) && (
|
||||||
<ProblemDetailsAlert
|
<ProblemDetailsAlert
|
||||||
error={createApiError}
|
error={createApiError}
|
||||||
onDismiss={() => setCreateApiError(null)}
|
onDismiss={() => setCreateApiError(null)}
|
||||||
@@ -1126,7 +1126,7 @@ export const ContractsManagement: React.FC<ContractsManagementProps> = ({
|
|||||||
}}
|
}}
|
||||||
className="space-y-4"
|
className="space-y-4"
|
||||||
>
|
>
|
||||||
{uploadApiError && (
|
{Boolean(uploadApiError) && (
|
||||||
<ProblemDetailsAlert
|
<ProblemDetailsAlert
|
||||||
error={uploadApiError}
|
error={uploadApiError}
|
||||||
onDismiss={() => setUploadApiError(null)}
|
onDismiss={() => setUploadApiError(null)}
|
||||||
@@ -1228,7 +1228,7 @@ export const ContractsManagement: React.FC<ContractsManagementProps> = ({
|
|||||||
}}
|
}}
|
||||||
className="space-y-4"
|
className="space-y-4"
|
||||||
>
|
>
|
||||||
{updateApiError && (
|
{Boolean(updateApiError) && (
|
||||||
<ProblemDetailsAlert
|
<ProblemDetailsAlert
|
||||||
error={updateApiError}
|
error={updateApiError}
|
||||||
onDismiss={() => setUpdateApiError(null)}
|
onDismiss={() => setUpdateApiError(null)}
|
||||||
@@ -1247,7 +1247,7 @@ export const ContractsManagement: React.FC<ContractsManagementProps> = ({
|
|||||||
{Number(contractToUpdate?.status) === ContractStatus.Active ? (
|
{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">
|
<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" />
|
<Lock className="w-3.5 h-3.5 text-emerald-400" />
|
||||||
<span>Active / Executed (Immutable - Cannot Be Changed)</span>
|
<span>Active / Executed</span>
|
||||||
</div>
|
</div>
|
||||||
) : (
|
) : (
|
||||||
(() => {
|
(() => {
|
||||||
|
|||||||
@@ -807,7 +807,7 @@ export const EvidenceReviewQueue: React.FC<EvidenceReviewQueueProps> = ({
|
|||||||
title="Approved evidence is immutable. Status cannot be modified."
|
title="Approved evidence is immutable. Status cannot be modified."
|
||||||
>
|
>
|
||||||
<Lock className="w-3.5 h-3.5 text-emerald-400" />
|
<Lock className="w-3.5 h-3.5 text-emerald-400" />
|
||||||
<span>Approved (Immutable)</span>
|
<span>Approved</span>
|
||||||
</span>
|
</span>
|
||||||
) : (
|
) : (
|
||||||
<>
|
<>
|
||||||
|
|||||||
+209
-119
@@ -9,12 +9,12 @@ import {
|
|||||||
CheckCircle2,
|
CheckCircle2,
|
||||||
Bold,
|
Bold,
|
||||||
Italic,
|
Italic,
|
||||||
|
Underline,
|
||||||
Heading2,
|
Heading2,
|
||||||
List,
|
List,
|
||||||
ListOrdered,
|
ListOrdered,
|
||||||
Quote,
|
Quote,
|
||||||
Table,
|
Table,
|
||||||
Code,
|
|
||||||
Minus,
|
Minus,
|
||||||
Sparkles,
|
Sparkles,
|
||||||
Eye,
|
Eye,
|
||||||
@@ -25,7 +25,13 @@ import {
|
|||||||
import { UserSearchPicker, SelectedUser } from './UserSearchPicker';
|
import { UserSearchPicker, SelectedUser } from './UserSearchPicker';
|
||||||
import { useInAppNotifications, DraftMessage } from '../../hooks/useInAppNotifications';
|
import { useInAppNotifications, DraftMessage } from '../../hooks/useInAppNotifications';
|
||||||
import { NotificationPlatform } from '../../types';
|
import { NotificationPlatform } from '../../types';
|
||||||
import { renderMarkdownToHtml, parseMarkdownToHtml, PFM_LOGO_DATA_URI } from '../../lib/markdownUtils';
|
import {
|
||||||
|
renderMarkdownToHtml,
|
||||||
|
parseMarkdownToHtml,
|
||||||
|
isHtmlContent,
|
||||||
|
stripMarkdown,
|
||||||
|
PFM_LOGO_DATA_URI,
|
||||||
|
} from '../../lib/markdownUtils';
|
||||||
import { BrandLogo } from '../ui/BrandLogo';
|
import { BrandLogo } from '../ui/BrandLogo';
|
||||||
import { useAuth } from '../../auth/AuthProvider';
|
import { useAuth } from '../../auth/AuthProvider';
|
||||||
|
|
||||||
@@ -49,7 +55,12 @@ export const ComposeMessageModal: React.FC<ComposeMessageModalProps> = ({
|
|||||||
draftToEdit,
|
draftToEdit,
|
||||||
}) => {
|
}) => {
|
||||||
const { user, claims } = useAuth();
|
const { user, claims } = useAuth();
|
||||||
const tenantName = (claims?.tenant as string) || user?.tenant || 'Department of Science, Innovation & Technology';
|
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 { sendMessage, isSending, saveDraft, deleteDraft } = useInAppNotifications();
|
||||||
|
|
||||||
const [selectedUsers, setSelectedUsers] = useState<SelectedUser[]>([]);
|
const [selectedUsers, setSelectedUsers] = useState<SelectedUser[]>([]);
|
||||||
@@ -60,40 +71,78 @@ export const ComposeMessageModal: React.FC<ComposeMessageModalProps> = ({
|
|||||||
const [error, setError] = useState<string | null>(null);
|
const [error, setError] = useState<string | null>(null);
|
||||||
const [successNotice, setSuccessNotice] = useState<string | null>(null);
|
const [successNotice, setSuccessNotice] = useState<string | null>(null);
|
||||||
|
|
||||||
const textareaRef = useRef<HTMLTextAreaElement>(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
|
// Initialize or populate from draft or props
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
|
if (!isOpen) return;
|
||||||
|
|
||||||
|
let initialHtml = '';
|
||||||
if (draftToEdit) {
|
if (draftToEdit) {
|
||||||
setSelectedUsers(
|
const candidate = draftToEdit.recipientId
|
||||||
draftToEdit.recipientId
|
? {
|
||||||
? [
|
id: draftToEdit.recipientId,
|
||||||
{
|
email: draftToEdit.recipientEmail,
|
||||||
id: draftToEdit.recipientId,
|
name: draftToEdit.recipientName,
|
||||||
email: draftToEdit.recipientEmail,
|
}
|
||||||
name: draftToEdit.recipientName,
|
: undefined;
|
||||||
},
|
setSelectedUsers(candidate && !isCurrentUser(candidate) ? [candidate] : []);
|
||||||
]
|
|
||||||
: []
|
|
||||||
);
|
|
||||||
setSubject(draftToEdit.subject || '');
|
setSubject(draftToEdit.subject || '');
|
||||||
setMessage(draftToEdit.message || '');
|
initialHtml = toInitialRichHtml(draftToEdit.message || '');
|
||||||
setPlatform(NotificationPlatform.InApp);
|
setMessage(initialHtml);
|
||||||
|
setPlatform(draftToEdit.platform || NotificationPlatform.InApp);
|
||||||
} else {
|
} else {
|
||||||
setSelectedUsers(initialRecipient ? [initialRecipient] : []);
|
setSelectedUsers(
|
||||||
|
initialRecipient && !isCurrentUser(initialRecipient) ? [initialRecipient] : []
|
||||||
|
);
|
||||||
setSubject(initialSubject || '');
|
setSubject(initialSubject || '');
|
||||||
setMessage(initialMessage || '');
|
initialHtml = toInitialRichHtml(initialMessage || '');
|
||||||
|
setMessage(initialHtml);
|
||||||
setPlatform(initialPlatform);
|
setPlatform(initialPlatform);
|
||||||
}
|
}
|
||||||
setActiveTab('write');
|
setActiveTab('write');
|
||||||
setError(null);
|
setError(null);
|
||||||
setSuccessNotice(null);
|
setSuccessNotice(null);
|
||||||
|
|
||||||
|
setTimeout(() => {
|
||||||
|
if (editorRef.current) {
|
||||||
|
editorRef.current.innerHTML = initialHtml;
|
||||||
|
}
|
||||||
|
}, 0);
|
||||||
}, [isOpen, initialRecipient, initialSubject, initialMessage, initialPlatform, draftToEdit]);
|
}, [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;
|
if (!isOpen) return null;
|
||||||
|
|
||||||
const handleSelectUser = (user: SelectedUser) => {
|
const handleSelectUser = (selected: SelectedUser) => {
|
||||||
setSelectedUsers([user]);
|
if (isCurrentUser(selected)) {
|
||||||
|
setError('You cannot send a message to yourself.');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
setSelectedUsers([selected]);
|
||||||
setError(null);
|
setError(null);
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -101,51 +150,59 @@ export const ComposeMessageModal: React.FC<ComposeMessageModalProps> = ({
|
|||||||
setSelectedUsers([]);
|
setSelectedUsers([]);
|
||||||
};
|
};
|
||||||
|
|
||||||
// Helper to insert markdown text at cursor in textarea
|
const syncEditorContent = () => {
|
||||||
const insertMarkdown = (before: string, after: string = '', defaultText: string = '') => {
|
if (editorRef.current) {
|
||||||
const textarea = textareaRef.current;
|
setMessage(editorRef.current.innerHTML);
|
||||||
if (!textarea) return;
|
}
|
||||||
|
};
|
||||||
|
|
||||||
const start = textarea.selectionStart;
|
const applyFormat = (command: string, value?: string) => {
|
||||||
const end = textarea.selectionEnd;
|
const editor = editorRef.current;
|
||||||
const text = textarea.value;
|
if (!editor) return;
|
||||||
const selection = text.substring(start, end) || defaultText;
|
editor.focus();
|
||||||
|
document.execCommand(command, false, value);
|
||||||
|
syncEditorContent();
|
||||||
|
};
|
||||||
|
|
||||||
const replacement = `${before}${selection}${after}`;
|
const insertHtmlBlock = (htmlSnippet: string) => {
|
||||||
const newText = text.substring(0, start) + replacement + text.substring(end);
|
const editor = editorRef.current;
|
||||||
|
if (!editor) return;
|
||||||
setMessage(newText);
|
editor.focus();
|
||||||
|
const inserted = document.execCommand('insertHTML', false, htmlSnippet);
|
||||||
setTimeout(() => {
|
if (!inserted) {
|
||||||
textarea.focus();
|
editor.innerHTML += htmlSnippet;
|
||||||
textarea.setSelectionRange(
|
}
|
||||||
start + before.length,
|
syncEditorContent();
|
||||||
start + before.length + selection.length
|
|
||||||
);
|
|
||||||
}, 0);
|
|
||||||
};
|
};
|
||||||
|
|
||||||
// Quick insertions for PFM branding and templates
|
// Quick insertions for PFM branding and templates
|
||||||
const insertBrandedCallout = () => {
|
const insertBrandedCallout = () => {
|
||||||
insertMarkdown(
|
insertHtmlBlock(
|
||||||
`> [!NOTE]\n> **Post-Fund Management Official Notice**\n> Please verify all supporting evidence documentation before tranche disbursement sign-off.\n\n`
|
`<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 = () => {
|
const insertMilestoneTable = () => {
|
||||||
insertMarkdown(
|
insertHtmlBlock(
|
||||||
`| Deliverable Code | Milestone Name | Status | Due Date |\n| :--- | :--- | :--- | :--- |\n| DEL-01 | Technical Architecture Review | Verified | 2026-11-15 |\n| DEL-02 | Financial Expenditure Proof | Under Review | 2026-12-01 |\n\n`
|
`<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 = () => {
|
const insertPlatformLogo = () => {
|
||||||
insertMarkdown(
|
insertHtmlBlock(
|
||||||
`\n\n`
|
`<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 handleSaveDraft = () => {
|
||||||
if (!subject.trim() && !message.trim()) {
|
const currentHtml = editorRef.current ? editorRef.current.innerHTML : message;
|
||||||
|
if (!subject.trim() && !hasMeaningfulContent(currentHtml)) {
|
||||||
setError('Please add a subject or message to save as draft');
|
setError('Please add a subject or message to save as draft');
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
@@ -157,29 +214,42 @@ export const ComposeMessageModal: React.FC<ComposeMessageModalProps> = ({
|
|||||||
recipientEmail: firstUser?.email || '',
|
recipientEmail: firstUser?.email || '',
|
||||||
recipientName: firstUser?.name || '',
|
recipientName: firstUser?.name || '',
|
||||||
subject: subject.trim() || 'Untitled Draft',
|
subject: subject.trim() || 'Untitled Draft',
|
||||||
message: message.trim(),
|
message: currentHtml.trim(),
|
||||||
|
platform,
|
||||||
});
|
});
|
||||||
|
|
||||||
setSuccessNotice('Draft saved successfully in Drafts folder');
|
setSuccessNotice('Draft saved successfully in Drafts folder');
|
||||||
setTimeout(() => {
|
setTimeout(() => {
|
||||||
onClose();
|
onClose();
|
||||||
}, 800);
|
}, 600);
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleSend = async (e: React.FormEvent) => {
|
const handleSend = async (e: React.FormEvent) => {
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
|
|
||||||
|
const currentHtml = editorRef.current ? editorRef.current.innerHTML : message;
|
||||||
|
|
||||||
if (selectedUsers.length === 0) {
|
if (selectedUsers.length === 0) {
|
||||||
setError('Please select a recipient from the tenant directory');
|
setError('Please select a recipient from the tenant directory');
|
||||||
return;
|
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()) {
|
if (!subject.trim()) {
|
||||||
setError('Please enter a subject');
|
setError('Please enter a subject');
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!message.trim()) {
|
if (!hasMeaningfulContent(currentHtml)) {
|
||||||
setError('Please enter a message body');
|
setError('Please enter a message body');
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
@@ -188,18 +258,15 @@ export const ComposeMessageModal: React.FC<ComposeMessageModalProps> = ({
|
|||||||
try {
|
try {
|
||||||
const recipient = selectedUsers[0];
|
const recipient = selectedUsers[0];
|
||||||
|
|
||||||
// Format payload according to chosen platform:
|
|
||||||
// InApp messages are saved in markdown format.
|
|
||||||
// Email notifications are transformed from markdown to branded executive HTML before saving.
|
|
||||||
const finalMessage =
|
const finalMessage =
|
||||||
platform === NotificationPlatform.Email
|
platform === NotificationPlatform.Email
|
||||||
? renderMarkdownToHtml(message.trim(), {
|
? renderMarkdownToHtml(currentHtml.trim(), {
|
||||||
applyBranding: true,
|
applyBranding: true,
|
||||||
emailFormat: true,
|
emailFormat: true,
|
||||||
subject: subject.trim(),
|
subject: subject.trim(),
|
||||||
tenantName,
|
tenantName,
|
||||||
})
|
})
|
||||||
: message.trim();
|
: currentHtml.trim();
|
||||||
|
|
||||||
await sendMessage({
|
await sendMessage({
|
||||||
recipientId: recipient.id,
|
recipientId: recipient.id,
|
||||||
@@ -207,13 +274,14 @@ export const ComposeMessageModal: React.FC<ComposeMessageModalProps> = ({
|
|||||||
recipientName: recipient.name,
|
recipientName: recipient.name,
|
||||||
subject: subject.trim(),
|
subject: subject.trim(),
|
||||||
message: finalMessage,
|
message: finalMessage,
|
||||||
|
platform,
|
||||||
draftId: draftToEdit?.id,
|
draftId: draftToEdit?.id,
|
||||||
});
|
});
|
||||||
|
|
||||||
setSuccessNotice(
|
setSuccessNotice(
|
||||||
platform === NotificationPlatform.Email
|
platform === NotificationPlatform.Email
|
||||||
? 'Email notification rendered to HTML and dispatched!'
|
? 'Email notification dispatched!'
|
||||||
: 'In-App Markdown message sent successfully!'
|
: 'In-App message sent successfully!'
|
||||||
);
|
);
|
||||||
setTimeout(() => {
|
setTimeout(() => {
|
||||||
onClose();
|
onClose();
|
||||||
@@ -239,7 +307,9 @@ export const ComposeMessageModal: React.FC<ComposeMessageModalProps> = ({
|
|||||||
subject: subject || 'Notice Preview',
|
subject: subject || 'Notice Preview',
|
||||||
tenantName,
|
tenantName,
|
||||||
})
|
})
|
||||||
: parseMarkdownToHtml(message);
|
: message;
|
||||||
|
|
||||||
|
const plainCharCount = stripMarkdown(message).length;
|
||||||
|
|
||||||
return (
|
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="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">
|
||||||
@@ -258,11 +328,11 @@ export const ComposeMessageModal: React.FC<ComposeMessageModalProps> = ({
|
|||||||
: 'Compose New Communication'}
|
: 'Compose New Communication'}
|
||||||
</h3>
|
</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">
|
<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 Markdown Engine
|
Rich Text Composer
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
<p className="text-[11px] text-slate-400">
|
<p className="text-[11px] text-slate-400">
|
||||||
Author in Markdown with live preview, SVG branding, and dual InApp / Email delivery
|
Type and format your message using the toolbar for In-App or branded Email delivery
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -312,7 +382,7 @@ export const ComposeMessageModal: React.FC<ComposeMessageModalProps> = ({
|
|||||||
}`}
|
}`}
|
||||||
>
|
>
|
||||||
<Smartphone className="w-3.5 h-3.5" />
|
<Smartphone className="w-3.5 h-3.5" />
|
||||||
<span>In-App (Markdown)</span>
|
<span>In-App</span>
|
||||||
</button>
|
</button>
|
||||||
|
|
||||||
<button
|
<button
|
||||||
@@ -325,7 +395,7 @@ export const ComposeMessageModal: React.FC<ComposeMessageModalProps> = ({
|
|||||||
}`}
|
}`}
|
||||||
>
|
>
|
||||||
<Mail className="w-3.5 h-3.5" />
|
<Mail className="w-3.5 h-3.5" />
|
||||||
<span>Email (HTML + Brand)</span>
|
<span>Email</span>
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -335,12 +405,12 @@ export const ComposeMessageModal: React.FC<ComposeMessageModalProps> = ({
|
|||||||
{platform === NotificationPlatform.InApp ? (
|
{platform === NotificationPlatform.InApp ? (
|
||||||
<span className="text-blue-300 flex items-center gap-1.5">
|
<span className="text-blue-300 flex items-center gap-1.5">
|
||||||
<span className="w-2 h-2 rounded-full bg-blue-400 animate-pulse" />
|
<span className="w-2 h-2 rounded-full bg-blue-400 animate-pulse" />
|
||||||
Saved directly as <strong>Markdown</strong>. Delivered instantly to the recipient's InApp Mailbox & Notification Bell.
|
Delivered instantly to the recipient's <strong>In-App</strong> Mailbox & Notification Bell.
|
||||||
</span>
|
</span>
|
||||||
) : (
|
) : (
|
||||||
<span className="text-emerald-300 flex items-center gap-1.5">
|
<span className="text-emerald-300 flex items-center gap-1.5">
|
||||||
<span className="w-2 h-2 rounded-full bg-emerald-400 animate-pulse" />
|
<span className="w-2 h-2 rounded-full bg-emerald-400 animate-pulse" />
|
||||||
Composed in Markdown, transformed into <strong>Branded Executive HTML</strong> with embedded SVG logo before dispatching.
|
Formatted with <strong>PFM Executive Branding</strong> and embedded logo before dispatching via email.
|
||||||
</span>
|
</span>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
@@ -373,14 +443,14 @@ export const ComposeMessageModal: React.FC<ComposeMessageModalProps> = ({
|
|||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Markdown Editor / Preview Tabs */}
|
{/* Rich Text Editor / Branded Preview Tabs */}
|
||||||
<div className="space-y-2">
|
<div className="space-y-2">
|
||||||
<div className="flex items-center justify-between">
|
<div className="flex items-center justify-between">
|
||||||
<label className="text-xs font-semibold text-slate-300">
|
<label className="text-xs font-semibold text-slate-300">
|
||||||
Message Content <span className="text-rose-400">*</span>
|
Message Content <span className="text-rose-400">*</span>
|
||||||
</label>
|
</label>
|
||||||
|
|
||||||
{/* Write vs Preview Tabs */}
|
{/* Compose vs Email Preview Tabs */}
|
||||||
<div className="flex items-center rounded-lg bg-slate-900 p-0.5 border border-slate-700/80">
|
<div className="flex items-center rounded-lg bg-slate-900 p-0.5 border border-slate-700/80">
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
@@ -392,11 +462,14 @@ export const ComposeMessageModal: React.FC<ComposeMessageModalProps> = ({
|
|||||||
}`}
|
}`}
|
||||||
>
|
>
|
||||||
<Edit3 className="w-3 h-3" />
|
<Edit3 className="w-3 h-3" />
|
||||||
<span>Write Markdown</span>
|
<span>Compose</span>
|
||||||
</button>
|
</button>
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
onClick={() => setActiveTab('preview')}
|
onClick={() => {
|
||||||
|
syncEditorContent();
|
||||||
|
setActiveTab('preview');
|
||||||
|
}}
|
||||||
className={`flex items-center gap-1.5 px-3 py-1 rounded-md text-xs font-medium transition cursor-pointer ${
|
className={`flex items-center gap-1.5 px-3 py-1 rounded-md text-xs font-medium transition cursor-pointer ${
|
||||||
activeTab === 'preview'
|
activeTab === 'preview'
|
||||||
? 'bg-blue-600 text-white font-semibold shadow-xs'
|
? 'bg-blue-600 text-white font-semibold shadow-xs'
|
||||||
@@ -404,37 +477,48 @@ export const ComposeMessageModal: React.FC<ComposeMessageModalProps> = ({
|
|||||||
}`}
|
}`}
|
||||||
>
|
>
|
||||||
<Eye className="w-3 h-3" />
|
<Eye className="w-3 h-3" />
|
||||||
<span>Preview Rendered</span>
|
<span>Preview Delivery</span>
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Markdown Toolbar (Only when activeTab === 'write') */}
|
|
||||||
{activeTab === 'write' ? (
|
{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">
|
<div className="rounded-2xl border border-slate-700/80 bg-slate-900/90 overflow-hidden shadow-inner focus-within:border-blue-500 transition">
|
||||||
{/* Toolbar */}
|
{/* 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">
|
<div className="px-3 py-2 border-b border-slate-800 bg-slate-950/70 flex flex-wrap items-center gap-1">
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
onClick={() => insertMarkdown('**', '**', 'bold text')}
|
onMouseDown={e => e.preventDefault()}
|
||||||
className="p-1.5 rounded-lg text-slate-400 hover:text-white hover:bg-slate-800 transition cursor-pointer"
|
onClick={() => applyFormat('bold')}
|
||||||
title="Bold (**text**)"
|
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" />
|
<Bold className="w-3.5 h-3.5" />
|
||||||
</button>
|
</button>
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
onClick={() => insertMarkdown('*', '*', 'italic text')}
|
onMouseDown={e => e.preventDefault()}
|
||||||
className="p-1.5 rounded-lg text-slate-400 hover:text-white hover:bg-slate-800 transition cursor-pointer"
|
onClick={() => applyFormat('italic')}
|
||||||
title="Italic (*text*)"
|
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" />
|
<Italic className="w-3.5 h-3.5" />
|
||||||
</button>
|
</button>
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
onClick={() => insertMarkdown('### ', '', 'Heading Title')}
|
onMouseDown={e => e.preventDefault()}
|
||||||
className="p-1.5 rounded-lg text-slate-400 hover:text-white hover:bg-slate-800 transition cursor-pointer"
|
onClick={() => applyFormat('underline')}
|
||||||
title="Heading (### )"
|
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" />
|
<Heading2 className="w-3.5 h-3.5" />
|
||||||
</button>
|
</button>
|
||||||
@@ -443,25 +527,28 @@ export const ComposeMessageModal: React.FC<ComposeMessageModalProps> = ({
|
|||||||
|
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
onClick={() => insertMarkdown('- ', '', 'List item')}
|
onMouseDown={e => e.preventDefault()}
|
||||||
className="p-1.5 rounded-lg text-slate-400 hover:text-white hover:bg-slate-800 transition cursor-pointer"
|
onClick={() => applyFormat('insertUnorderedList')}
|
||||||
title="Bullet List (- )"
|
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" />
|
<List className="w-3.5 h-3.5" />
|
||||||
</button>
|
</button>
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
onClick={() => insertMarkdown('1. ', '', 'First item')}
|
onMouseDown={e => e.preventDefault()}
|
||||||
className="p-1.5 rounded-lg text-slate-400 hover:text-white hover:bg-slate-800 transition cursor-pointer"
|
onClick={() => applyFormat('insertOrderedList')}
|
||||||
title="Numbered List (1. )"
|
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" />
|
<ListOrdered className="w-3.5 h-3.5" />
|
||||||
</button>
|
</button>
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
onClick={() => insertMarkdown('> ', '', 'Quoted text')}
|
onMouseDown={e => e.preventDefault()}
|
||||||
className="p-1.5 rounded-lg text-slate-400 hover:text-white hover:bg-slate-800 transition cursor-pointer"
|
onClick={() => applyFormat('formatBlock', 'BLOCKQUOTE')}
|
||||||
title="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" />
|
<Quote className="w-3.5 h-3.5" />
|
||||||
</button>
|
</button>
|
||||||
@@ -470,25 +557,19 @@ export const ComposeMessageModal: React.FC<ComposeMessageModalProps> = ({
|
|||||||
|
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
|
onMouseDown={e => e.preventDefault()}
|
||||||
onClick={insertMilestoneTable}
|
onClick={insertMilestoneTable}
|
||||||
className="p-1.5 rounded-lg text-slate-400 hover:text-white hover:bg-slate-800 transition cursor-pointer"
|
className="p-1.5 rounded-lg text-slate-300 hover:text-white hover:bg-slate-800 transition cursor-pointer"
|
||||||
title="Insert Deliverable Table"
|
title="Insert Deliverable Table"
|
||||||
>
|
>
|
||||||
<Table className="w-3.5 h-3.5" />
|
<Table className="w-3.5 h-3.5" />
|
||||||
</button>
|
</button>
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
onClick={() => insertMarkdown('```\n', '\n```', 'code block')}
|
onMouseDown={e => e.preventDefault()}
|
||||||
className="p-1.5 rounded-lg text-slate-400 hover:text-white hover:bg-slate-800 transition cursor-pointer"
|
onClick={() => applyFormat('insertHorizontalRule')}
|
||||||
title="Code Block (```)"
|
className="p-1.5 rounded-lg text-slate-300 hover:text-white hover:bg-slate-800 transition cursor-pointer"
|
||||||
>
|
title="Insert Divider Line"
|
||||||
<Code className="w-3.5 h-3.5" />
|
|
||||||
</button>
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
onClick={() => insertMarkdown('\n---\n')}
|
|
||||||
className="p-1.5 rounded-lg text-slate-400 hover:text-white hover:bg-slate-800 transition cursor-pointer"
|
|
||||||
title="Horizontal Rule (---)"
|
|
||||||
>
|
>
|
||||||
<Minus className="w-3.5 h-3.5" />
|
<Minus className="w-3.5 h-3.5" />
|
||||||
</button>
|
</button>
|
||||||
@@ -498,6 +579,7 @@ export const ComposeMessageModal: React.FC<ComposeMessageModalProps> = ({
|
|||||||
{/* Platform Brand Insertions */}
|
{/* Platform Brand Insertions */}
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
|
onMouseDown={e => e.preventDefault()}
|
||||||
onClick={insertBrandedCallout}
|
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"
|
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"
|
title="Insert official PFM callout block"
|
||||||
@@ -508,46 +590,54 @@ export const ComposeMessageModal: React.FC<ComposeMessageModalProps> = ({
|
|||||||
|
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
|
onMouseDown={e => e.preventDefault()}
|
||||||
onClick={insertPlatformLogo}
|
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"
|
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 SVG PFM logo into markdown"
|
title="Embed official PFM logo"
|
||||||
>
|
>
|
||||||
<BrandLogo size={14} showGlow={false} animateOnHover={false} />
|
<BrandLogo size={14} showGlow={false} animateOnHover={false} />
|
||||||
<span>Embed Logo</span>
|
<span>Embed Logo</span>
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Textarea */}
|
{/* Rich Text WYSIWYG Surface */}
|
||||||
<textarea
|
<div className="relative">
|
||||||
ref={textareaRef}
|
{!hasMeaningfulContent(message) && (
|
||||||
rows={8}
|
<div className="pointer-events-none absolute top-4 left-4 right-4 text-slate-500 text-xs select-none">
|
||||||
value={message}
|
Type your message here and highlight text or use the toolbar above to format headings, bullet points, tables, and official notices...
|
||||||
onChange={e => setMessage(e.target.value)}
|
</div>
|
||||||
placeholder="Compose your message in Markdown... Use formatting buttons above for headings, bullet points, deliverable tables, and official PFM notices."
|
)}
|
||||||
className="w-full p-4 bg-transparent text-slate-100 placeholder-slate-400 text-xs focus:outline-none transition resize-y leading-relaxed font-mono"
|
<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>
|
</div>
|
||||||
) : (
|
) : (
|
||||||
/* Live Fully-Rendered Markdown / HTML Preview */
|
/* 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="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">
|
<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">
|
<span className="flex items-center gap-1.5 text-blue-300 font-semibold">
|
||||||
<ShieldCheck className="w-3.5 h-3.5 text-blue-400" />
|
<ShieldCheck className="w-3.5 h-3.5 text-blue-400" />
|
||||||
Live Rendered Preview ({platform === NotificationPlatform.Email ? 'HTML Email' : 'InApp Markdown'})
|
Delivery Preview ({platform === NotificationPlatform.Email ? 'Email' : 'In-App'})
|
||||||
</span>
|
</span>
|
||||||
<span className="text-[10px] text-slate-400 font-mono">
|
<span className="text-[10px] text-slate-400 font-mono">
|
||||||
Subject: {subject || 'Untitled'}
|
Subject: {subject || 'Untitled'}
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{message.trim() ? (
|
{hasMeaningfulContent(message) ? (
|
||||||
<div
|
<div
|
||||||
className="prose prose-invert max-w-none text-xs leading-relaxed"
|
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 }}
|
dangerouslySetInnerHTML={{ __html: livePreviewHtml }}
|
||||||
/>
|
/>
|
||||||
) : (
|
) : (
|
||||||
<div className="py-12 text-center text-slate-400 text-xs italic">
|
<div className="py-12 text-center text-slate-400 text-xs italic">
|
||||||
Type your markdown message in the Write tab to see it live-rendered here.
|
Type your message in the Compose tab to preview how it will appear upon delivery.
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
@@ -560,7 +650,7 @@ export const ComposeMessageModal: React.FC<ComposeMessageModalProps> = ({
|
|||||||
<span>Tenant Partition:</span>
|
<span>Tenant Partition:</span>
|
||||||
<strong className="text-slate-300">{tenantName}</strong>
|
<strong className="text-slate-300">{tenantName}</strong>
|
||||||
</span>
|
</span>
|
||||||
<span className="font-mono">{message.length} characters</span>
|
<span className="font-mono">{plainCharCount} characters</span>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Modal Footer Action Buttons */}
|
{/* Modal Footer Action Buttons */}
|
||||||
@@ -608,7 +698,7 @@ export const ComposeMessageModal: React.FC<ComposeMessageModalProps> = ({
|
|||||||
)}
|
)}
|
||||||
<span>
|
<span>
|
||||||
{platform === NotificationPlatform.Email
|
{platform === NotificationPlatform.Email
|
||||||
? 'Dispatch Email (HTML)'
|
? 'Dispatch Email'
|
||||||
: 'Send In-App Message'}
|
: 'Send In-App Message'}
|
||||||
</span>
|
</span>
|
||||||
</button>
|
</button>
|
||||||
|
|||||||
@@ -1,14 +1,11 @@
|
|||||||
import React, { useState, useMemo } from 'react';
|
import React, { useState, useMemo } from 'react';
|
||||||
import {
|
import {
|
||||||
Code,
|
|
||||||
Eye,
|
|
||||||
Copy,
|
Copy,
|
||||||
Check,
|
Check,
|
||||||
ShieldCheck,
|
ShieldCheck,
|
||||||
ExternalLink,
|
|
||||||
} from 'lucide-react';
|
} from 'lucide-react';
|
||||||
import { BrandLogo } from '../ui/BrandLogo';
|
import { BrandLogo } from '../ui/BrandLogo';
|
||||||
import { parseMarkdownToHtml, isHtmlContent } from '../../lib/markdownUtils';
|
import { parseMarkdownToHtml, isHtmlContent, stripMarkdown } from '../../lib/markdownUtils';
|
||||||
|
|
||||||
interface MarkdownViewerProps {
|
interface MarkdownViewerProps {
|
||||||
content: string;
|
content: string;
|
||||||
@@ -19,11 +16,9 @@ interface MarkdownViewerProps {
|
|||||||
|
|
||||||
export const MarkdownViewer: React.FC<MarkdownViewerProps> = ({
|
export const MarkdownViewer: React.FC<MarkdownViewerProps> = ({
|
||||||
content,
|
content,
|
||||||
subject,
|
|
||||||
showBrandingHeader = true,
|
showBrandingHeader = true,
|
||||||
className = '',
|
className = '',
|
||||||
}) => {
|
}) => {
|
||||||
const [viewMode, setViewMode] = useState<'rendered' | 'raw'>('rendered');
|
|
||||||
const [copied, setCopied] = useState(false);
|
const [copied, setCopied] = useState(false);
|
||||||
|
|
||||||
const isHtml = useMemo(() => isHtmlContent(content), [content]);
|
const isHtml = useMemo(() => isHtmlContent(content), [content]);
|
||||||
@@ -35,7 +30,7 @@ export const MarkdownViewer: React.FC<MarkdownViewerProps> = ({
|
|||||||
}, [content, isHtml]);
|
}, [content, isHtml]);
|
||||||
|
|
||||||
const handleCopy = () => {
|
const handleCopy = () => {
|
||||||
navigator.clipboard.writeText(content);
|
navigator.clipboard.writeText(stripMarkdown(content) || content);
|
||||||
setCopied(true);
|
setCopied(true);
|
||||||
setTimeout(() => setCopied(false), 2000);
|
setTimeout(() => setCopied(false), 2000);
|
||||||
};
|
};
|
||||||
@@ -48,49 +43,21 @@ export const MarkdownViewer: React.FC<MarkdownViewerProps> = ({
|
|||||||
{showBrandingHeader && (
|
{showBrandingHeader && (
|
||||||
<div className="flex items-center gap-1.5 text-slate-300 font-semibold pr-2 border-r border-slate-700/60">
|
<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} />
|
<BrandLogo size={18} showGlow={false} animateOnHover={false} />
|
||||||
<span className="text-[11px] tracking-tight">PFM Markdown Engine</span>
|
<span className="text-[11px] tracking-tight">PFM Communications</span>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
<span className="text-[10px] text-blue-400 font-mono">
|
<span className="text-[10px] text-blue-400 font-mono">
|
||||||
{isHtml ? 'Format: HTML Email' : 'Format: GitHub Flavored Markdown'}
|
In-App Message
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="flex items-center gap-2">
|
<div className="flex items-center gap-2">
|
||||||
{/* Toggle View Mode */}
|
|
||||||
<div className="flex items-center rounded-lg bg-slate-950/70 p-0.5 border border-slate-800">
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
onClick={() => setViewMode('rendered')}
|
|
||||||
className={`flex items-center gap-1 px-2 py-1 rounded-md text-[11px] font-medium transition cursor-pointer ${
|
|
||||||
viewMode === 'rendered'
|
|
||||||
? 'bg-blue-600/30 text-white font-semibold shadow-xs'
|
|
||||||
: 'text-slate-400 hover:text-white'
|
|
||||||
}`}
|
|
||||||
>
|
|
||||||
<Eye className="w-3 h-3" />
|
|
||||||
<span>Rendered</span>
|
|
||||||
</button>
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
onClick={() => setViewMode('raw')}
|
|
||||||
className={`flex items-center gap-1 px-2 py-1 rounded-md text-[11px] font-medium transition cursor-pointer ${
|
|
||||||
viewMode === 'raw'
|
|
||||||
? 'bg-blue-600/30 text-white font-semibold shadow-xs'
|
|
||||||
: 'text-slate-400 hover:text-white'
|
|
||||||
}`}
|
|
||||||
>
|
|
||||||
<Code className="w-3 h-3" />
|
|
||||||
<span>Raw Source</span>
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Copy Button */}
|
{/* Copy Button */}
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
onClick={handleCopy}
|
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"
|
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 content to clipboard"
|
title="Copy message text to clipboard"
|
||||||
>
|
>
|
||||||
{copied ? (
|
{copied ? (
|
||||||
<>
|
<>
|
||||||
@@ -108,25 +75,12 @@ export const MarkdownViewer: React.FC<MarkdownViewerProps> = ({
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Content Rendering */}
|
{/* Content Rendering */}
|
||||||
{viewMode === 'rendered' ? (
|
<div className="p-6 overflow-x-auto text-sm leading-relaxed text-slate-200 font-sans">
|
||||||
<div className="p-6 overflow-x-auto text-sm leading-relaxed text-slate-200 font-sans">
|
<div
|
||||||
{isHtml ? (
|
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"
|
||||||
<div
|
dangerouslySetInnerHTML={{ __html: renderedHtml }}
|
||||||
className="prose prose-invert max-w-none"
|
/>
|
||||||
dangerouslySetInnerHTML={{ __html: renderedHtml }}
|
</div>
|
||||||
/>
|
|
||||||
) : (
|
|
||||||
<div
|
|
||||||
className="prose prose-invert max-w-none [&_h1]:text-white [&_h2]:text-white [&_h3]:text-white [&_h4]:text-white [&_a]:text-blue-400 [&_a:hover]:underline [&_table]:border-collapse [&_th]:border [&_th]:border-slate-700 [&_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 [&_code]:bg-slate-800 [&_code]:px-1.5 [&_code]:py-0.5 [&_code]:rounded [&_code]:text-blue-300"
|
|
||||||
dangerouslySetInnerHTML={{ __html: renderedHtml }}
|
|
||||||
/>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
) : (
|
|
||||||
<pre className="p-5 overflow-x-auto font-mono text-xs text-slate-300 bg-[#070b13] select-all whitespace-pre-wrap leading-relaxed">
|
|
||||||
{content}
|
|
||||||
</pre>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{/* Branded Footer Stamp */}
|
{/* Branded Footer Stamp */}
|
||||||
{showBrandingHeader && (
|
{showBrandingHeader && (
|
||||||
@@ -136,7 +90,7 @@ export const MarkdownViewer: React.FC<MarkdownViewerProps> = ({
|
|||||||
<span>Verified PFM Secure Platform Formatting</span>
|
<span>Verified PFM Secure Platform Formatting</span>
|
||||||
</div>
|
</div>
|
||||||
<span className="font-mono text-[10px] text-slate-400">
|
<span className="font-mono text-[10px] text-slate-400">
|
||||||
{content.length} characters
|
{stripMarkdown(content).length} characters
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|||||||
@@ -15,6 +15,7 @@ import {
|
|||||||
} from 'lucide-react';
|
} from 'lucide-react';
|
||||||
import { getUsers } from '../../services/api';
|
import { getUsers } from '../../services/api';
|
||||||
import { User, ActivityStatus } from '../../types';
|
import { User, ActivityStatus } from '../../types';
|
||||||
|
import { useAuth } from '../../auth/AuthProvider';
|
||||||
|
|
||||||
export interface SelectedUser {
|
export interface SelectedUser {
|
||||||
id: string;
|
id: string;
|
||||||
@@ -43,9 +44,12 @@ export const UserSearchPicker: React.FC<UserSearchPickerProps> = ({
|
|||||||
selectedUsers,
|
selectedUsers,
|
||||||
onSelectUser,
|
onSelectUser,
|
||||||
onRemoveUser,
|
onRemoveUser,
|
||||||
multiple = false,
|
|
||||||
placeholder = 'Search by name, email, or role...',
|
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 [searchTerm, setSearchTerm] = useState('');
|
||||||
const [isDropdownOpen, setIsDropdownOpen] = useState(false);
|
const [isDropdownOpen, setIsDropdownOpen] = useState(false);
|
||||||
const [collapsedGroups, setCollapsedGroups] = useState<Record<string, boolean>>({});
|
const [collapsedGroups, setCollapsedGroups] = useState<Record<string, boolean>>({});
|
||||||
@@ -54,13 +58,19 @@ export const UserSearchPicker: React.FC<UserSearchPickerProps> = ({
|
|||||||
const { data: users = [], isLoading } = useQuery<User[]>({
|
const { data: users = [], isLoading } = useQuery<User[]>({
|
||||||
queryKey: ['users-directory'],
|
queryKey: ['users-directory'],
|
||||||
queryFn: () => getUsers(1, 200),
|
queryFn: () => getUsers(1, 200),
|
||||||
staleTime: 5 * 60 * 1000,
|
|
||||||
});
|
});
|
||||||
|
|
||||||
// Filter active users
|
// Filter active users and exclude the current logged-in user so they can never message themselves
|
||||||
const activeUsers = useMemo(() => {
|
const activeUsers = useMemo(() => {
|
||||||
return users.filter(u => u.status === ActivityStatus.Active && !!u.email);
|
return users.filter(u => {
|
||||||
}, [users]);
|
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
|
// Organize users into intuitive functional groups
|
||||||
const groupedUsers = useMemo(() => {
|
const groupedUsers = useMemo(() => {
|
||||||
@@ -69,7 +79,7 @@ export const UserSearchPicker: React.FC<UserSearchPickerProps> = ({
|
|||||||
const filtered = activeUsers.filter(u => {
|
const filtered = activeUsers.filter(u => {
|
||||||
if (!term) return true;
|
if (!term) return true;
|
||||||
const email = (u.email || '').toLowerCase();
|
const email = (u.email || '').toLowerCase();
|
||||||
const role = (u.role || '').toLowerCase();
|
const role = String(u.role ?? '').toLowerCase();
|
||||||
return email.includes(term) || role.includes(term);
|
return email.includes(term) || role.includes(term);
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -80,7 +90,7 @@ export const UserSearchPicker: React.FC<UserSearchPickerProps> = ({
|
|||||||
const otherUsers: User[] = [];
|
const otherUsers: User[] = [];
|
||||||
|
|
||||||
filtered.forEach(u => {
|
filtered.forEach(u => {
|
||||||
const r = (u.role || '').toLowerCase();
|
const r = String(u.role ?? '').toLowerCase();
|
||||||
if (r.includes('admin') || r.includes('executive') || r.includes('director')) {
|
if (r.includes('admin') || r.includes('executive') || r.includes('director')) {
|
||||||
adminUsers.push(u);
|
adminUsers.push(u);
|
||||||
} else if (r.includes('programme') || r.includes('project') || r.includes('manager')) {
|
} else if (r.includes('programme') || r.includes('project') || r.includes('manager')) {
|
||||||
@@ -172,87 +182,83 @@ export const UserSearchPicker: React.FC<UserSearchPickerProps> = ({
|
|||||||
.map(w => w.charAt(0).toUpperCase() + w.slice(1))
|
.map(w => w.charAt(0).toUpperCase() + w.slice(1))
|
||||||
.join(' ');
|
.join(' ');
|
||||||
|
|
||||||
if (!multiple) {
|
// Strictly single-recipient selection
|
||||||
// Single selection replaces
|
onSelectUser({
|
||||||
onSelectUser({
|
id: u.id,
|
||||||
id: u.id,
|
email: u.email,
|
||||||
email: u.email,
|
name: formattedName,
|
||||||
name: formattedName,
|
role: String(u.role ?? 'User'),
|
||||||
role: u.role || 'User',
|
});
|
||||||
});
|
setSearchTerm('');
|
||||||
setIsDropdownOpen(false);
|
setIsDropdownOpen(false);
|
||||||
} else {
|
|
||||||
onSelectUser({
|
|
||||||
id: u.id,
|
|
||||||
email: u.email,
|
|
||||||
name: formattedName,
|
|
||||||
role: u.role || 'User',
|
|
||||||
});
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const singleSelectedUser = selectedUsers[0];
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="relative w-full">
|
<div className="relative w-full">
|
||||||
{/* Selected Users Chips */}
|
{/* Single Selected Recipient or Search Input Box */}
|
||||||
{selectedUsers.length > 0 && (
|
{singleSelectedUser ? (
|
||||||
<div className="flex flex-wrap gap-1.5 mb-2">
|
<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">
|
||||||
{selectedUsers.map(su => (
|
<div className="flex items-center gap-2.5 min-w-0">
|
||||||
<span
|
<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">
|
||||||
key={su.id}
|
{(singleSelectedUser.name || singleSelectedUser.email).slice(0, 2).toUpperCase()}
|
||||||
className="inline-flex items-center gap-1.5 px-2.5 py-1 rounded-lg bg-blue-500/15 border border-blue-500/30 text-blue-200 text-xs font-medium shadow-xs"
|
</div>
|
||||||
>
|
<div className="flex items-center gap-2 min-w-0">
|
||||||
<div className="w-4 h-4 rounded-full bg-blue-600/40 text-[9px] font-bold flex items-center justify-center text-blue-300">
|
<span className="font-semibold text-white truncate">
|
||||||
{su.name ? su.name.slice(0, 2).toUpperCase() : su.email.slice(0, 2).toUpperCase()}
|
{singleSelectedUser.name || singleSelectedUser.email}
|
||||||
</div>
|
</span>
|
||||||
<span className="truncate max-w-[180px]">{su.name || su.email}</span>
|
<span className="text-[11px] text-slate-400 font-mono truncate">
|
||||||
{su.role && (
|
<{singleSelectedUser.email}>
|
||||||
<span className="text-[10px] text-blue-400 bg-blue-900/40 px-1 py-0.2 rounded border border-blue-800/40">
|
</span>
|
||||||
{su.role}
|
{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>
|
</span>
|
||||||
)}
|
)}
|
||||||
<button
|
</div>
|
||||||
type="button"
|
</div>
|
||||||
onClick={() => onRemoveUser(su.id)}
|
<button
|
||||||
className="text-blue-400 hover:text-white hover:bg-blue-600/30 p-0.5 rounded transition cursor-pointer"
|
type="button"
|
||||||
title="Remove recipient"
|
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"
|
||||||
<X className="w-3 h-3" />
|
title="Remove recipient to select another user"
|
||||||
</button>
|
>
|
||||||
</span>
|
<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>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{/* Search Input Box */}
|
{/* Organized User Directory Dropdown (only when no recipient is locked in) */}
|
||||||
<div className="relative">
|
{isDropdownOpen && !singleSelectedUser && (
|
||||||
<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 */}
|
|
||||||
{isDropdownOpen && (
|
|
||||||
<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">
|
<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 ? (
|
{isLoading ? (
|
||||||
<div className="py-6 text-center text-xs text-slate-400 flex items-center justify-center gap-2">
|
<div className="py-6 text-center text-xs text-slate-400 flex items-center justify-center gap-2">
|
||||||
|
|||||||
@@ -37,7 +37,7 @@ const activitySchema = z.object({
|
|||||||
title: z.string().min(3, 'Activity title required'),
|
title: z.string().min(3, 'Activity title required'),
|
||||||
description: z.string().min(5, 'Description required'),
|
description: z.string().min(5, 'Description required'),
|
||||||
category: z.string().min(2, 'Category required'),
|
category: z.string().min(2, 'Category required'),
|
||||||
amount: z.coerce.number().min(1, 'Amount must be positive'),
|
amount: z.coerce.number().min(0, 'Amount must be non-negative'),
|
||||||
milestoneId: z.string().optional().nullable(),
|
milestoneId: z.string().optional().nullable(),
|
||||||
performedAt: z.string().optional().nullable(),
|
performedAt: z.string().optional().nullable(),
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -20,6 +20,7 @@ import {
|
|||||||
getProjectIndicators,
|
getProjectIndicators,
|
||||||
getProjectRisks,
|
getProjectRisks,
|
||||||
getBudgets,
|
getBudgets,
|
||||||
|
getDisbursements,
|
||||||
} from '../../services/api';
|
} from '../../services/api';
|
||||||
import { Project, ApprovalStatus, Budget } from '../../types';
|
import { Project, ApprovalStatus, Budget } from '../../types';
|
||||||
import {
|
import {
|
||||||
@@ -86,9 +87,21 @@ export const ProjectExpandedRow: React.FC<ProjectExpandedRowProps> = ({
|
|||||||
);
|
);
|
||||||
const approvedBudgetTotal = approvedBudgets.reduce((sum, b) => sum + (Number(b.amount) || 0), 0);
|
const approvedBudgetTotal = approvedBudgets.reduce((sum, b) => sum + (Number(b.amount) || 0), 0);
|
||||||
|
|
||||||
|
const { data: disbursements = [] } = useQuery({
|
||||||
|
queryKey: ['disbursements', projectId],
|
||||||
|
queryFn: () => getDisbursements(1, 100),
|
||||||
|
});
|
||||||
|
|
||||||
|
const projectDisbursements = disbursements.filter(
|
||||||
|
(d) => String(d.projectId) === String(projectId) && (Number(d.status) === ApprovalStatus.Approved || Number(d.status) === 3)
|
||||||
|
);
|
||||||
|
const totalDisbursed = projectDisbursements.reduce((sum, d) => sum + (Number(d.amount) || 0), 0);
|
||||||
|
|
||||||
const verifiedMilestonesCount = milestones.filter((m) => m.status === ApprovalStatus.Approved).length;
|
const verifiedMilestonesCount = milestones.filter((m) => m.status === ApprovalStatus.Approved).length;
|
||||||
const milestoneProgress = milestones.length > 0 ? Math.round((verifiedMilestonesCount / milestones.length) * 100) : 0;
|
const milestoneProgress = milestones.length > 0 ? Math.round((verifiedMilestonesCount / milestones.length) * 100) : 0;
|
||||||
const totalActivityBudget = activities.reduce((sum, a) => sum + (Number(a.amount) || 0), 0);
|
const totalActivityBudget = activities.reduce((sum, a) => sum + (Number(a.amount) || 0), 0);
|
||||||
|
const projectValue = totalActivityBudget - totalDisbursed;
|
||||||
|
const projectVariance = approvedBudgetTotal - projectValue;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="p-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-4 sm:p-5 lg:p-6 bg-[#090d16]/95 border-t border-b border-blue-500/20 space-y-6 animate-in fade-in duration-200 w-full min-w-0">
|
||||||
@@ -108,6 +121,19 @@ export const ProjectExpandedRow: React.FC<ProjectExpandedRowProps> = ({
|
|||||||
<span className="text-xs text-emerald-400 font-mono font-semibold shrink-0">
|
<span className="text-xs text-emerald-400 font-mono font-semibold shrink-0">
|
||||||
Approved Budget: {formatZAR(approvedBudgetTotal)}
|
Approved Budget: {formatZAR(approvedBudgetTotal)}
|
||||||
</span>
|
</span>
|
||||||
|
<span className="text-slate-600">•</span>
|
||||||
|
<span className="text-xs text-blue-400 font-mono font-semibold shrink-0" title={`Activities: ${formatZAR(totalActivityBudget)} - Disbursements: ${formatZAR(totalDisbursed)}`}>
|
||||||
|
Project Value: {formatZAR(projectValue)}
|
||||||
|
</span>
|
||||||
|
<span className="text-slate-600">•</span>
|
||||||
|
<span
|
||||||
|
className={`text-xs font-mono font-semibold shrink-0 ${
|
||||||
|
projectVariance >= 0 ? 'text-emerald-400' : 'text-rose-400'
|
||||||
|
}`}
|
||||||
|
title="Approved Budget minus Net Project Value"
|
||||||
|
>
|
||||||
|
Variance: {projectVariance >= 0 ? '+' : ''}{formatZAR(projectVariance)}
|
||||||
|
</span>
|
||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -33,6 +33,7 @@ import {
|
|||||||
getProjectIndicators,
|
getProjectIndicators,
|
||||||
getProjectRisks,
|
getProjectRisks,
|
||||||
getBudgets,
|
getBudgets,
|
||||||
|
getDisbursements,
|
||||||
updateProjectStatus,
|
updateProjectStatus,
|
||||||
} from '../../services/api';
|
} from '../../services/api';
|
||||||
import { Project, ApprovalStatus, Programme } from '../../types';
|
import { Project, ApprovalStatus, Programme } from '../../types';
|
||||||
@@ -150,11 +151,23 @@ export const ProjectViewModal: React.FC<ProjectViewModalProps> = ({
|
|||||||
);
|
);
|
||||||
const approvedBudgetTotal = approvedBudgets.reduce((sum, b) => sum + (Number(b.amount) || 0), 0);
|
const approvedBudgetTotal = approvedBudgets.reduce((sum, b) => sum + (Number(b.amount) || 0), 0);
|
||||||
|
|
||||||
// Milestone Progress
|
// Milestone Progress & Disbursements
|
||||||
const verifiedMilestonesCount = milestones.filter((m) => m.status === ApprovalStatus.Approved).length;
|
const verifiedMilestonesCount = milestones.filter((m) => m.status === ApprovalStatus.Approved).length;
|
||||||
const milestoneProgress = milestones.length > 0 ? Math.round((verifiedMilestonesCount / milestones.length) * 100) : 0;
|
const milestoneProgress = milestones.length > 0 ? Math.round((verifiedMilestonesCount / milestones.length) * 100) : 0;
|
||||||
const totalActivityBudget = activities.reduce((sum, a) => sum + (Number(a.amount) || 0), 0);
|
const totalActivityBudget = activities.reduce((sum, a) => sum + (Number(a.amount) || 0), 0);
|
||||||
|
|
||||||
|
const { data: disbursements = [] } = useQuery({
|
||||||
|
queryKey: ['disbursements', project?.id],
|
||||||
|
queryFn: () => getDisbursements(1, 100),
|
||||||
|
enabled: !!project?.id,
|
||||||
|
});
|
||||||
|
const projectDisbursements = disbursements.filter(
|
||||||
|
(d) => String(d.projectId) === String(project?.id) && (Number(d.status) === ApprovalStatus.Approved || Number(d.status) === 3)
|
||||||
|
);
|
||||||
|
const totalDisbursed = projectDisbursements.reduce((sum, d) => sum + (Number(d.amount) || 0), 0);
|
||||||
|
const projectValue = totalActivityBudget - totalDisbursed;
|
||||||
|
const projectVariance = approvedBudgetTotal - projectValue;
|
||||||
|
|
||||||
// Schedule Risk
|
// Schedule Risk
|
||||||
const scheduleRisk = useMemo(() => {
|
const scheduleRisk = useMemo(() => {
|
||||||
if (!project) return null;
|
if (!project) return null;
|
||||||
@@ -244,20 +257,20 @@ export const ProjectViewModal: React.FC<ProjectViewModalProps> = ({
|
|||||||
Budget: {formatZAR(approvedBudgetTotal)}
|
Budget: {formatZAR(approvedBudgetTotal)}
|
||||||
</span>
|
</span>
|
||||||
<span className="text-slate-600">•</span>
|
<span className="text-slate-600">•</span>
|
||||||
<span className="text-blue-400 font-mono font-bold">
|
<span className="text-blue-400 font-mono font-bold" title={`Activities Total: ${formatZAR(totalActivityBudget)} minus Disbursements: ${formatZAR(totalDisbursed)}`}>
|
||||||
Project Value: {formatZAR(totalActivityBudget)}
|
Project Value: {formatZAR(projectValue)}
|
||||||
</span>
|
</span>
|
||||||
<span className="text-slate-600">•</span>
|
<span className="text-slate-600">•</span>
|
||||||
<span
|
<span
|
||||||
className={`font-mono font-bold px-2 py-0.5 rounded text-[11px] border ${
|
className={`font-mono font-bold px-2 py-0.5 rounded text-[11px] border ${
|
||||||
approvedBudgetTotal - totalActivityBudget >= 0
|
projectVariance >= 0
|
||||||
? 'text-emerald-400 bg-emerald-500/10 border-emerald-500/30'
|
? 'text-emerald-400 bg-emerald-500/10 border-emerald-500/30'
|
||||||
: 'text-rose-400 bg-rose-500/10 border-rose-500/30'
|
: 'text-rose-400 bg-rose-500/10 border-rose-500/30'
|
||||||
}`}
|
}`}
|
||||||
title="Approved Budget minus Total Project Value"
|
title={`Approved Budget: ${formatZAR(approvedBudgetTotal)} | Net Project Value: ${formatZAR(projectValue)} (Activities: ${formatZAR(totalActivityBudget)} - Disbursements: ${formatZAR(totalDisbursed)}) | Variance: ${projectVariance >= 0 ? '+' : ''}${formatZAR(projectVariance)}`}
|
||||||
>
|
>
|
||||||
Variance: {approvedBudgetTotal - totalActivityBudget >= 0 ? '+' : ''}
|
Variance: {projectVariance >= 0 ? '+' : ''}
|
||||||
{formatZAR(approvedBudgetTotal - totalActivityBudget)}
|
{formatZAR(projectVariance)}
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -83,7 +83,7 @@ export const RiskHeatmapMatrix: React.FC<RiskHeatmapMatrixProps> = ({
|
|||||||
const projectMap = useMemo(() => {
|
const projectMap = useMemo(() => {
|
||||||
const map: Record<string, string> = {};
|
const map: Record<string, string> = {};
|
||||||
projects.forEach((p) => {
|
projects.forEach((p) => {
|
||||||
map[String(p.id)] = p.name;
|
map[String(p.id)] = p.name || '';
|
||||||
});
|
});
|
||||||
return map;
|
return map;
|
||||||
}, [projects]);
|
}, [projects]);
|
||||||
|
|||||||
@@ -6,10 +6,17 @@ import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
|
|||||||
import { UserPlus, Shield, Hash, Mail, Phone, Lock, Building, CheckCircle2 } from 'lucide-react';
|
import { UserPlus, Shield, Hash, Mail, Phone, Lock, Building, CheckCircle2 } from 'lucide-react';
|
||||||
import { Modal } from '../ui/Modal';
|
import { Modal } from '../ui/Modal';
|
||||||
import { useToast } from '../ui/Toast';
|
import { useToast } from '../ui/Toast';
|
||||||
import { createUser, getSystemRoles, assignUserRole, getTenants, getUsers } from '../../services/api';
|
import {
|
||||||
|
createUser,
|
||||||
|
getSystemRoles,
|
||||||
|
assignUserRole,
|
||||||
|
getTenants,
|
||||||
|
getUsers,
|
||||||
|
notifyNewUserCreated,
|
||||||
|
} from '../../services/api';
|
||||||
import { useAuth } from '../../auth/AuthProvider';
|
import { useAuth } from '../../auth/AuthProvider';
|
||||||
import { useRbac } from '../../auth/rbac';
|
import { useRbac } from '../../auth/rbac';
|
||||||
import { CreateUserRequest, Role, Tenant } from '../../types';
|
import { CreateUserRequest, Role, Tenant, User } from '../../types';
|
||||||
|
|
||||||
const createUserSchema = z.object({
|
const createUserSchema = z.object({
|
||||||
tenantId: z.string().min(1, 'Tenant is required'),
|
tenantId: z.string().min(1, 'Tenant is required'),
|
||||||
@@ -22,8 +29,8 @@ const createUserSchema = z.object({
|
|||||||
userName: z.string().optional(),
|
userName: z.string().optional(),
|
||||||
phoneNumber: z.string().optional(),
|
phoneNumber: z.string().optional(),
|
||||||
initialRoleId: z.string().optional(),
|
initialRoleId: z.string().optional(),
|
||||||
lockoutEnabled: z.boolean().default(false),
|
lockoutEnabled: z.boolean(),
|
||||||
twoFactorEnabled: z.boolean().default(false),
|
twoFactorEnabled: z.boolean(),
|
||||||
});
|
});
|
||||||
|
|
||||||
type CreateUserFormValues = z.infer<typeof createUserSchema>;
|
type CreateUserFormValues = z.infer<typeof createUserSchema>;
|
||||||
@@ -98,7 +105,9 @@ export const CreateUserModal: React.FC<CreateUserModalProps> = ({
|
|||||||
// Filter out Admin role if user has access to tenant self-help (Requirement 1)
|
// Filter out Admin role if user has access to tenant self-help (Requirement 1)
|
||||||
const assignableRoles = React.useMemo(() => {
|
const assignableRoles = React.useMemo(() => {
|
||||||
return systemRoles.filter((r) => {
|
return systemRoles.filter((r) => {
|
||||||
const isRoleAdmin = r.name.toLowerCase() === 'admin' || r.id === '1';
|
const rName = r.name || r.Name || '';
|
||||||
|
const rId = r.id || r.Id || '';
|
||||||
|
const isRoleAdmin = rName.toLowerCase() === 'admin' || rId === '1';
|
||||||
if (isRoleAdmin && hasTenantSelfHelpAccess) {
|
if (isRoleAdmin && hasTenantSelfHelpAccess) {
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
@@ -117,8 +126,10 @@ export const CreateUserModal: React.FC<CreateUserModalProps> = ({
|
|||||||
mutationFn: async (values: CreateUserFormValues) => {
|
mutationFn: async (values: CreateUserFormValues) => {
|
||||||
// Security enforcement: Users with tenant self-help access cannot create admin users
|
// Security enforcement: Users with tenant self-help access cannot create admin users
|
||||||
if (values.initialRoleId && hasTenantSelfHelpAccess) {
|
if (values.initialRoleId && hasTenantSelfHelpAccess) {
|
||||||
const selectedRole = systemRoles.find((r) => r.id === values.initialRoleId);
|
const selectedRole = systemRoles.find((r) => (r.id || r.Id) === values.initialRoleId);
|
||||||
if (selectedRole && (selectedRole.name.toLowerCase() === 'admin' || selectedRole.id === '1')) {
|
const selName = selectedRole?.name || selectedRole?.Name || '';
|
||||||
|
const selId = selectedRole?.id || selectedRole?.Id || '';
|
||||||
|
if (selectedRole && (selName.toLowerCase() === 'admin' || selId === '1')) {
|
||||||
throw new Error('Users who have access to tenant self-help are restricted from creating Admin users.');
|
throw new Error('Users who have access to tenant self-help are restricted from creating Admin users.');
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -141,22 +152,40 @@ export const CreateUserModal: React.FC<CreateUserModalProps> = ({
|
|||||||
|
|
||||||
await createUser(values.tenantId, values.idNumber.trim(), payload);
|
await createUser(values.tenantId, values.idNumber.trim(), payload);
|
||||||
|
|
||||||
if (values.initialRoleId) {
|
let createdUser: User | undefined;
|
||||||
try {
|
try {
|
||||||
await queryClient.invalidateQueries({ queryKey: ['users-directory'] });
|
await queryClient.invalidateQueries({ queryKey: ['users-directory'] });
|
||||||
await queryClient.invalidateQueries({ queryKey: ['users'] });
|
await queryClient.invalidateQueries({ queryKey: ['users'] });
|
||||||
const freshUsers = await getUsers(1, 1000, { email: values.email.trim() });
|
const freshUsers = await getUsers(1, 1000, { email: values.email.trim() });
|
||||||
const createdUser = freshUsers.find(
|
createdUser = freshUsers.find(
|
||||||
(u) => u.email && u.email.toLowerCase() === values.email.trim().toLowerCase()
|
(u) => u.email && u.email.toLowerCase() === values.email.trim().toLowerCase()
|
||||||
);
|
);
|
||||||
if (createdUser) {
|
if (createdUser && values.initialRoleId) {
|
||||||
await assignUserRole(createdUser.id, values.initialRoleId);
|
await assignUserRole(createdUser.id, values.initialRoleId);
|
||||||
}
|
|
||||||
} catch {
|
|
||||||
// Graceful fallback
|
|
||||||
}
|
}
|
||||||
|
} catch {
|
||||||
|
// Graceful fallback
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// RULE 3: Issue InApp and Email message directing new user to appropriate portal
|
||||||
|
const selectedRole = systemRoles.find((r) => (r.id || r.Id) === values.initialRoleId);
|
||||||
|
const roleName = selectedRole?.name || selectedRole?.Name || '';
|
||||||
|
const isPartner =
|
||||||
|
roleName.toLowerCase().trim() === 'partner' ||
|
||||||
|
String(values.initialRoleId || '').toLowerCase().trim() === 'partner';
|
||||||
|
|
||||||
|
const targetUserId = createdUser?.id || values.idNumber.trim();
|
||||||
|
await notifyNewUserCreated({
|
||||||
|
userId: targetUserId,
|
||||||
|
email: rawEmail,
|
||||||
|
isPartner,
|
||||||
|
roleName,
|
||||||
|
tenantId: values.tenantId,
|
||||||
|
}).catch(console.warn);
|
||||||
|
|
||||||
|
queryClient.invalidateQueries({ queryKey: ['inapp-notifications'] });
|
||||||
|
queryClient.invalidateQueries({ queryKey: ['notifications'] });
|
||||||
|
|
||||||
return values;
|
return values;
|
||||||
},
|
},
|
||||||
onSuccess: (values) => {
|
onSuccess: (values) => {
|
||||||
@@ -286,11 +315,15 @@ export const CreateUserModal: React.FC<CreateUserModalProps> = ({
|
|||||||
className="w-full bg-slate-900 border border-slate-800 rounded-xl px-3 py-2 text-white focus:outline-none focus:border-blue-500 disabled:opacity-50"
|
className="w-full bg-slate-900 border border-slate-800 rounded-xl px-3 py-2 text-white focus:outline-none focus:border-blue-500 disabled:opacity-50"
|
||||||
>
|
>
|
||||||
<option value="">Select Initial Role (Optional)</option>
|
<option value="">Select Initial Role (Optional)</option>
|
||||||
{assignableRoles.map((r) => (
|
{assignableRoles.map((r) => {
|
||||||
<option key={r.id} value={r.id}>
|
const roleId = r.id || r.Id || '';
|
||||||
{r.name}
|
const roleLabel = r.name || r.Name || '';
|
||||||
</option>
|
return (
|
||||||
))}
|
<option key={roleId} value={roleId}>
|
||||||
|
{roleLabel}
|
||||||
|
</option>
|
||||||
|
);
|
||||||
|
})}
|
||||||
</select>
|
</select>
|
||||||
{hasTenantSelfHelpAccess && (
|
{hasTenantSelfHelpAccess && (
|
||||||
<p className="text-[10px] text-amber-400 mt-1">
|
<p className="text-[10px] text-amber-400 mt-1">
|
||||||
|
|||||||
@@ -32,6 +32,7 @@ import { clearCachedUserRbac, fetchAndCacheRoleClaims } from '../../services/rol
|
|||||||
import { HelpTooltip } from '../ui/HelpTooltip';
|
import { HelpTooltip } from '../ui/HelpTooltip';
|
||||||
import { ObfuscatedIdNumber } from '../ui/ObfuscatedIdNumber';
|
import { ObfuscatedIdNumber } from '../ui/ObfuscatedIdNumber';
|
||||||
import { decodeIdNumberHex } from '../../utils/hashids';
|
import { decodeIdNumberHex } from '../../utils/hashids';
|
||||||
|
import { useAuth } from '../../auth/AuthProvider';
|
||||||
import { User, Role, Claim, ActivityStatus } from '../../types';
|
import { User, Role, Claim, ActivityStatus } from '../../types';
|
||||||
import { formatDate, getActivityStatusBadge, getUserRoleBadge } from '../../lib/formatters';
|
import { formatDate, getActivityStatusBadge, getUserRoleBadge } from '../../lib/formatters';
|
||||||
import { useRbac, ROLE_CLAIM_DEFINITIONS } from '../../auth/rbac';
|
import { useRbac, ROLE_CLAIM_DEFINITIONS } from '../../auth/rbac';
|
||||||
@@ -49,9 +50,15 @@ export const UserSecurityModal: React.FC<UserSecurityModalProps> = ({
|
|||||||
}) => {
|
}) => {
|
||||||
const queryClient = useQueryClient();
|
const queryClient = useQueryClient();
|
||||||
const { success, error: toastError } = useToast();
|
const { success, error: toastError } = useToast();
|
||||||
|
const { user: currentAuthUser } = useAuth();
|
||||||
const { canApprove, canAdd, canView, canAccess, isUnrestricted } = useRbac();
|
const { canApprove, canAdd, canView, canAccess, isUnrestricted } = useRbac();
|
||||||
const hasTenantSelfHelpAccess = canAccess('tenant_self_service') || canView('tenant_self_service');
|
const hasTenantSelfHelpAccess = canAccess('tenant_self_service') || canView('tenant_self_service');
|
||||||
|
|
||||||
|
const userId = user?.id || '';
|
||||||
|
const isSelf =
|
||||||
|
Boolean(currentAuthUser?.id && userId && String(currentAuthUser.id).toLowerCase() === String(userId).toLowerCase()) ||
|
||||||
|
Boolean(currentAuthUser?.email && user?.email && currentAuthUser.email.toLowerCase() === user.email.toLowerCase());
|
||||||
|
|
||||||
const canManageRolesAndClaims =
|
const canManageRolesAndClaims =
|
||||||
canApprove('settings_users') ||
|
canApprove('settings_users') ||
|
||||||
canApprove('tenant_management') ||
|
canApprove('tenant_management') ||
|
||||||
@@ -63,18 +70,17 @@ export const UserSecurityModal: React.FC<UserSecurityModalProps> = ({
|
|||||||
hasTenantSelfHelpAccess ||
|
hasTenantSelfHelpAccess ||
|
||||||
isUnrestricted;
|
isUnrestricted;
|
||||||
const canChangeStatus =
|
const canChangeStatus =
|
||||||
canAdd('settings_users') ||
|
!isSelf &&
|
||||||
canAdd('tenant_management') ||
|
(canAdd('settings_users') ||
|
||||||
canAdd('tenant_self_service') ||
|
canAdd('tenant_management') ||
|
||||||
canApprove('settings_users') ||
|
canAdd('tenant_self_service') ||
|
||||||
isUnrestricted;
|
canApprove('settings_users') ||
|
||||||
|
isUnrestricted);
|
||||||
|
|
||||||
const [activeTab, setActiveTab] = useState<'overview' | 'roles' | 'claims' | 'sessions'>('overview');
|
const [activeTab, setActiveTab] = useState<'overview' | 'roles' | 'claims' | 'sessions'>('overview');
|
||||||
|
|
||||||
const [selectedRoleToAdd, setSelectedRoleToAdd] = useState('');
|
const [selectedRoleToAdd, setSelectedRoleToAdd] = useState('');
|
||||||
|
|
||||||
const userId = user?.id || '';
|
|
||||||
|
|
||||||
// 1. Fetch fresh user details
|
// 1. Fetch fresh user details
|
||||||
const { data: freshUser, isLoading: loadingUser, refetch: refetchUser } = useQuery<User | null>({
|
const { data: freshUser, isLoading: loadingUser, refetch: refetchUser } = useQuery<User | null>({
|
||||||
queryKey: ['user-detail', userId],
|
queryKey: ['user-detail', userId],
|
||||||
@@ -237,20 +243,35 @@ export const UserSecurityModal: React.FC<UserSecurityModalProps> = ({
|
|||||||
onError: (err: unknown) => toastError('Logout Failed', err),
|
onError: (err: unknown) => toastError('Logout Failed', err),
|
||||||
});
|
});
|
||||||
|
|
||||||
const lockoutMutation = useMutation({
|
const lockUserMutation = useMutation({
|
||||||
mutationFn: (disableLock: boolean) => setUserLockout(userId, disableLock),
|
mutationFn: () => setUserLockout(userId, true),
|
||||||
onSuccess: (_, disableLock) => {
|
onSuccess: () => {
|
||||||
success(
|
success('Account Locked', 'User account lockout enabled (disable = true).');
|
||||||
disableLock ? 'Account Unlocked' : 'Account Locked',
|
|
||||||
`User account lockout status successfully modified.`
|
|
||||||
);
|
|
||||||
refetchUser();
|
refetchUser();
|
||||||
|
queryClient.invalidateQueries({ queryKey: ['users'] });
|
||||||
|
queryClient.invalidateQueries({ queryKey: ['user-detail', userId] });
|
||||||
},
|
},
|
||||||
onError: (err: unknown) => toastError('Lockout Update Failed', err),
|
onError: (err: unknown) => toastError('Lock User Failed', err),
|
||||||
|
});
|
||||||
|
|
||||||
|
const unlockUserMutation = useMutation({
|
||||||
|
mutationFn: () => setUserLockout(userId, false),
|
||||||
|
onSuccess: () => {
|
||||||
|
success('Account Unlocked', 'User account lockout disabled (disable = false).');
|
||||||
|
refetchUser();
|
||||||
|
queryClient.invalidateQueries({ queryKey: ['users'] });
|
||||||
|
queryClient.invalidateQueries({ queryKey: ['user-detail', userId] });
|
||||||
|
},
|
||||||
|
onError: (err: unknown) => toastError('Unlock User Failed', err),
|
||||||
});
|
});
|
||||||
|
|
||||||
const statusMutation = useMutation({
|
const statusMutation = useMutation({
|
||||||
mutationFn: (newStatus: ActivityStatus) => updateUserStatus(userId, newStatus),
|
mutationFn: (newStatus: ActivityStatus) => {
|
||||||
|
if (isSelf) {
|
||||||
|
throw new Error('Maker-Checker Violation: You cannot alter your own account activity status.');
|
||||||
|
}
|
||||||
|
return updateUserStatus(userId, newStatus);
|
||||||
|
},
|
||||||
onSuccess: () => {
|
onSuccess: () => {
|
||||||
success('Status Updated', 'User activity status changed.');
|
success('Status Updated', 'User activity status changed.');
|
||||||
refetchUser();
|
refetchUser();
|
||||||
@@ -280,18 +301,18 @@ export const UserSecurityModal: React.FC<UserSecurityModalProps> = ({
|
|||||||
<Modal
|
<Modal
|
||||||
isOpen={isOpen}
|
isOpen={isOpen}
|
||||||
onClose={onClose}
|
onClose={onClose}
|
||||||
title={`User Administration: ${effectiveUser.email || effectiveUser.id}`}
|
title={`User Security: ${effectiveUser.email || effectiveUser.id}`}
|
||||||
subtitle="Direct tenant-level user, role, claims, and session administration without database intervention."
|
subtitle="Manage account status, role assignment, permissions, and active sessions."
|
||||||
>
|
>
|
||||||
<div className="space-y-4 text-xs">
|
<div className="space-y-4 text-xs">
|
||||||
{/* Navigation Tabs */}
|
{/* Navigation Tabs */}
|
||||||
<div className="flex gap-2 border-b border-slate-800 pb-2">
|
<div className="flex gap-1.5 border-b border-slate-800 pb-2">
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
onClick={() => setActiveTab('overview')}
|
onClick={() => setActiveTab('overview')}
|
||||||
className={`px-3 py-1.5 rounded-lg font-semibold transition cursor-pointer ${
|
className={`px-3 py-1.5 rounded-lg font-medium transition cursor-pointer ${
|
||||||
activeTab === 'overview'
|
activeTab === 'overview'
|
||||||
? 'bg-blue-600 text-white'
|
? 'bg-blue-600 text-white font-semibold'
|
||||||
: 'text-slate-400 hover:text-white hover:bg-slate-800'
|
: 'text-slate-400 hover:text-white hover:bg-slate-800'
|
||||||
}`}
|
}`}
|
||||||
>
|
>
|
||||||
@@ -300,21 +321,21 @@ export const UserSecurityModal: React.FC<UserSecurityModalProps> = ({
|
|||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
onClick={() => setActiveTab('roles')}
|
onClick={() => setActiveTab('roles')}
|
||||||
className={`px-3 py-1.5 rounded-lg font-semibold transition cursor-pointer flex items-center gap-1.5 ${
|
className={`px-3 py-1.5 rounded-lg font-medium transition cursor-pointer flex items-center gap-1.5 ${
|
||||||
activeTab === 'roles'
|
activeTab === 'roles'
|
||||||
? 'bg-blue-600 text-white'
|
? 'bg-blue-600 text-white font-semibold'
|
||||||
: 'text-slate-400 hover:text-white hover:bg-slate-800'
|
: 'text-slate-400 hover:text-white hover:bg-slate-800'
|
||||||
}`}
|
}`}
|
||||||
>
|
>
|
||||||
<Shield className="w-3.5 h-3.5" />
|
<Shield className="w-3.5 h-3.5" />
|
||||||
<span>Roles ({(Array.isArray(userRoles) ? userRoles : []).length})</span>
|
<span>Role ({(Array.isArray(userRoles) ? userRoles : []).length})</span>
|
||||||
</button>
|
</button>
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
onClick={() => setActiveTab('claims')}
|
onClick={() => setActiveTab('claims')}
|
||||||
className={`px-3 py-1.5 rounded-lg font-semibold transition cursor-pointer flex items-center gap-1.5 ${
|
className={`px-3 py-1.5 rounded-lg font-medium transition cursor-pointer flex items-center gap-1.5 ${
|
||||||
activeTab === 'claims'
|
activeTab === 'claims'
|
||||||
? 'bg-blue-600 text-white'
|
? 'bg-blue-600 text-white font-semibold'
|
||||||
: 'text-slate-400 hover:text-white hover:bg-slate-800'
|
: 'text-slate-400 hover:text-white hover:bg-slate-800'
|
||||||
}`}
|
}`}
|
||||||
>
|
>
|
||||||
@@ -324,9 +345,9 @@ export const UserSecurityModal: React.FC<UserSecurityModalProps> = ({
|
|||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
onClick={() => setActiveTab('sessions')}
|
onClick={() => setActiveTab('sessions')}
|
||||||
className={`px-3 py-1.5 rounded-lg font-semibold transition cursor-pointer flex items-center gap-1.5 ${
|
className={`px-3 py-1.5 rounded-lg font-medium transition cursor-pointer flex items-center gap-1.5 ${
|
||||||
activeTab === 'sessions'
|
activeTab === 'sessions'
|
||||||
? 'bg-blue-600 text-white'
|
? 'bg-blue-600 text-white font-semibold'
|
||||||
: 'text-slate-400 hover:text-white hover:bg-slate-800'
|
: 'text-slate-400 hover:text-white hover:bg-slate-800'
|
||||||
}`}
|
}`}
|
||||||
>
|
>
|
||||||
@@ -386,7 +407,14 @@ export const UserSecurityModal: React.FC<UserSecurityModalProps> = ({
|
|||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
<div className="flex items-center gap-2">
|
<div className="flex items-center gap-2">
|
||||||
{effectiveUser?.status === ActivityStatus.Active ? (
|
{isSelf ? (
|
||||||
|
<span
|
||||||
|
className="px-2.5 py-1 text-[11px] font-semibold text-slate-400 bg-slate-900 border border-slate-800 rounded-lg"
|
||||||
|
title="Maker-Checker policy: Administrators cannot change their own account activity status"
|
||||||
|
>
|
||||||
|
Self-Account (Protected)
|
||||||
|
</span>
|
||||||
|
) : effectiveUser?.status === ActivityStatus.Active ? (
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
onClick={() => statusMutation.mutate(ActivityStatus.Inactive)}
|
onClick={() => statusMutation.mutate(ActivityStatus.Inactive)}
|
||||||
@@ -592,70 +620,66 @@ export const UserSecurityModal: React.FC<UserSecurityModalProps> = ({
|
|||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{/* TAB 4: Sessions & Direct Account Interventions */}
|
{/* TAB 4: Sessions & Security Interventions */}
|
||||||
{activeTab === 'sessions' && (
|
{activeTab === 'sessions' && (
|
||||||
<div className="space-y-3">
|
<div className="space-y-3">
|
||||||
<div className="p-4 bg-slate-900/60 rounded-xl border border-slate-800 space-y-4">
|
<div className="p-4 bg-slate-900/60 rounded-xl border border-slate-800 space-y-4">
|
||||||
{/* Force Logout Action */}
|
{/* Force Logout Action */}
|
||||||
<div className="flex items-center justify-between border-b border-slate-800/80 pb-3">
|
<div className="flex items-center justify-between border-b border-slate-800/80 pb-3 gap-3">
|
||||||
<div>
|
<div>
|
||||||
<h4 className="text-white font-semibold flex items-center gap-1.5">
|
<h4 className="text-white font-semibold flex items-center gap-1.5">
|
||||||
<LogOut className="w-4 h-4 text-amber-400" />
|
<LogOut className="w-4 h-4 text-amber-400" />
|
||||||
<span>Revoke Active User Session</span>
|
<span>Revoke Active User Session</span>
|
||||||
<HelpTooltip
|
|
||||||
title="User Session Invalidation"
|
|
||||||
content="Enables a tenant self-service user to immediately logout a user if they are currently logged in, terminating their active token and session."
|
|
||||||
size="sm"
|
|
||||||
placement="top"
|
|
||||||
/>
|
|
||||||
</h4>
|
</h4>
|
||||||
<p className="text-[11px] text-slate-400 mt-0.5">
|
<p className="text-[11px] text-slate-400 mt-0.5">
|
||||||
Terminates all active JWT tokens and sessions immediately without direct database intervention.
|
Immediately invalidates active JWT tokens and logs the user out.
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
onClick={() => logoutMutation.mutate()}
|
onClick={() => logoutMutation.mutate()}
|
||||||
disabled={logoutMutation.isPending || !canManageSessions}
|
disabled={logoutMutation.isPending || !canManageSessions}
|
||||||
className="px-3 py-1.5 bg-amber-500/10 hover:bg-amber-500/20 text-amber-400 border border-amber-500/20 rounded-xl font-semibold transition cursor-pointer disabled:opacity-50"
|
className="px-3 py-1.5 bg-amber-500/10 hover:bg-amber-500/20 text-amber-400 border border-amber-500/20 rounded-xl font-semibold transition cursor-pointer disabled:opacity-50 shrink-0"
|
||||||
>
|
>
|
||||||
{logoutMutation.isPending ? 'Revoking...' : 'Force Logout'}
|
{logoutMutation.isPending ? 'Revoking...' : 'Force Logout'}
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Lock / Unlock Account */}
|
{/* Lock / Unlock Account */}
|
||||||
<div className="flex items-center justify-between">
|
<div className="flex items-center justify-between gap-3">
|
||||||
<div>
|
<div>
|
||||||
<h4 className="text-white font-semibold flex items-center gap-1.5">
|
<h4 className="text-white font-semibold flex items-center gap-1.5">
|
||||||
<Lock className="w-4 h-4 text-rose-400" />
|
<Lock className="w-4 h-4 text-rose-400" />
|
||||||
<span>Account Lockout Control</span>
|
<span>Account Lockout</span>
|
||||||
<HelpTooltip
|
|
||||||
title="Account Lockout Control"
|
|
||||||
content="Enables a tenant self-service user to change a user's lockout status. The system defaults to a lockout period of 100 years when locking a user account."
|
|
||||||
size="sm"
|
|
||||||
placement="top"
|
|
||||||
/>
|
|
||||||
</h4>
|
</h4>
|
||||||
<p className="text-[11px] text-slate-400 mt-0.5">
|
<p className="text-[11px] text-slate-400 mt-0.5">
|
||||||
Temporarily lock out or re-enable the user from logging into this tenant. The system defaults to a lockout period of 100 years.
|
Control tenant login authorization for this account.
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
<div className="flex items-center gap-2">
|
<div className="flex items-center gap-2 shrink-0">
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
onClick={() => lockoutMutation.mutate(false)}
|
onClick={(e) => {
|
||||||
disabled={lockoutMutation.isPending || !canManageSessions}
|
e.preventDefault();
|
||||||
|
e.stopPropagation();
|
||||||
|
lockUserMutation.mutate();
|
||||||
|
}}
|
||||||
|
disabled={lockUserMutation.isPending || unlockUserMutation.isPending || !canManageSessions}
|
||||||
className="px-3 py-1.5 bg-rose-500/10 hover:bg-rose-500/20 text-rose-400 border border-rose-500/20 rounded-xl font-semibold transition cursor-pointer disabled:opacity-50"
|
className="px-3 py-1.5 bg-rose-500/10 hover:bg-rose-500/20 text-rose-400 border border-rose-500/20 rounded-xl font-semibold transition cursor-pointer disabled:opacity-50"
|
||||||
>
|
>
|
||||||
Lock User
|
{lockUserMutation.isPending ? 'Locking...' : 'Lock User'}
|
||||||
</button>
|
</button>
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
onClick={() => lockoutMutation.mutate(true)}
|
onClick={(e) => {
|
||||||
disabled={lockoutMutation.isPending || !canManageSessions}
|
e.preventDefault();
|
||||||
|
e.stopPropagation();
|
||||||
|
unlockUserMutation.mutate();
|
||||||
|
}}
|
||||||
|
disabled={unlockUserMutation.isPending || lockUserMutation.isPending || !canManageSessions}
|
||||||
className="px-3 py-1.5 bg-emerald-500/10 hover:bg-emerald-500/20 text-emerald-400 border border-emerald-500/20 rounded-xl font-semibold transition cursor-pointer disabled:opacity-50"
|
className="px-3 py-1.5 bg-emerald-500/10 hover:bg-emerald-500/20 text-emerald-400 border border-emerald-500/20 rounded-xl font-semibold transition cursor-pointer disabled:opacity-50"
|
||||||
>
|
>
|
||||||
Unlock User
|
{unlockUserMutation.isPending ? 'Unlocking...' : 'Unlock User'}
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -1,10 +1,10 @@
|
|||||||
import { updateHasherSettings } from './services/toolsService';
|
import { updateHasherSettings } from './utils/hashids';
|
||||||
|
|
||||||
export interface SecuritySettings {
|
export interface SecuritySettings {
|
||||||
Authority: string;
|
Authority: string;
|
||||||
Audience: string;
|
Audience: string;
|
||||||
ClientId: string;
|
ClientId: string;
|
||||||
ClientSecret: string;
|
ClientSecret?: string;
|
||||||
Scope?: string;
|
Scope?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -13,7 +13,7 @@ export interface ApiSettings {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export interface HasherSettings {
|
export interface HasherSettings {
|
||||||
Salt: string;
|
Salt?: string;
|
||||||
MinHashLength: number;
|
MinHashLength: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -36,7 +36,6 @@ export const defaultSecuritySettings: SecuritySettings = {
|
|||||||
Authority: 'https://sts.security.khongisa.co.za',
|
Authority: 'https://sts.security.khongisa.co.za',
|
||||||
Audience: 'pfm-api-dev',
|
Audience: 'pfm-api-dev',
|
||||||
ClientId: 'pfm-console-dev',
|
ClientId: 'pfm-console-dev',
|
||||||
ClientSecret: 'secret_dcaeaa28097d9ba5b06ec324d9eac9d9c81de96e0a203cf2c6154c6dd7c89e94',
|
|
||||||
Scope: 'openid profile roles email address pfm-api-dev',
|
Scope: 'openid profile roles email address pfm-api-dev',
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -49,6 +48,10 @@ export const defaultHasherSettings: HasherSettings = {
|
|||||||
MinHashLength: 11,
|
MinHashLength: 11,
|
||||||
};
|
};
|
||||||
|
|
||||||
|
function syncAllHasherSettings(salt: string | undefined, minHashLength: number): void {
|
||||||
|
updateHasherSettings(salt, minHashLength);
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Extracts first non-empty, trimmed string from candidate values.
|
* Extracts first non-empty, trimmed string from candidate values.
|
||||||
*/
|
*/
|
||||||
@@ -255,9 +258,8 @@ function resolveConfig(fetchedJson?: Record<string, any>): AppConfig {
|
|||||||
const clientSecret = pickFirstNonEmpty(
|
const clientSecret = pickFirstNonEmpty(
|
||||||
windowConfig.clientSecret,
|
windowConfig.clientSecret,
|
||||||
fetchedConfig.clientSecret,
|
fetchedConfig.clientSecret,
|
||||||
buildEnvConfig.clientSecret,
|
buildEnvConfig.clientSecret
|
||||||
defaultSecuritySettings.ClientSecret
|
);
|
||||||
)!;
|
|
||||||
|
|
||||||
const scope = pickFirstNonEmpty(
|
const scope = pickFirstNonEmpty(
|
||||||
windowConfig.scope,
|
windowConfig.scope,
|
||||||
@@ -278,7 +280,7 @@ function resolveConfig(fetchedJson?: Record<string, any>): AppConfig {
|
|||||||
fetchedConfig.hasherSalt,
|
fetchedConfig.hasherSalt,
|
||||||
buildEnvConfig.hasherSalt,
|
buildEnvConfig.hasherSalt,
|
||||||
defaultHasherSettings.Salt
|
defaultHasherSettings.Salt
|
||||||
)!;
|
);
|
||||||
|
|
||||||
const minHashLength =
|
const minHashLength =
|
||||||
windowConfig.hasherMinLength ||
|
windowConfig.hasherMinLength ||
|
||||||
@@ -291,14 +293,14 @@ function resolveConfig(fetchedJson?: Record<string, any>): AppConfig {
|
|||||||
Authority: normalizeAuthorityUrl(authority),
|
Authority: normalizeAuthorityUrl(authority),
|
||||||
Audience: audience,
|
Audience: audience,
|
||||||
ClientId: clientId,
|
ClientId: clientId,
|
||||||
ClientSecret: clientSecret,
|
...(clientSecret ? { ClientSecret: clientSecret } : {}),
|
||||||
Scope: scope,
|
Scope: scope,
|
||||||
},
|
},
|
||||||
ApiSettings: {
|
ApiSettings: {
|
||||||
BaseUrl: normalizeBaseUrl(baseUrl),
|
BaseUrl: normalizeBaseUrl(baseUrl),
|
||||||
},
|
},
|
||||||
HasherSettings: {
|
HasherSettings: {
|
||||||
Salt: salt,
|
Salt: salt || defaultHasherSettings.Salt,
|
||||||
MinHashLength: minHashLength,
|
MinHashLength: minHashLength,
|
||||||
},
|
},
|
||||||
ConfigSource: source,
|
ConfigSource: source,
|
||||||
@@ -310,7 +312,7 @@ function resolveConfig(fetchedJson?: Record<string, any>): AppConfig {
|
|||||||
let runtimeConfig: AppConfig = resolveConfig();
|
let runtimeConfig: AppConfig = resolveConfig();
|
||||||
|
|
||||||
// Immediately apply deterministic hasher settings
|
// Immediately apply deterministic hasher settings
|
||||||
updateHasherSettings(runtimeConfig.HasherSettings.Salt, runtimeConfig.HasherSettings.MinHashLength);
|
syncAllHasherSettings(runtimeConfig.HasherSettings.Salt, runtimeConfig.HasherSettings.MinHashLength);
|
||||||
|
|
||||||
const configListeners: Array<(config: AppConfig) => void> = [];
|
const configListeners: Array<(config: AppConfig) => void> = [];
|
||||||
|
|
||||||
@@ -360,7 +362,7 @@ export async function loadRuntimeConfig(): Promise<AppConfig> {
|
|||||||
runtimeConfig = resolveConfig(fetchedJson);
|
runtimeConfig = resolveConfig(fetchedJson);
|
||||||
|
|
||||||
// Synchronize deterministic hasher settings
|
// Synchronize deterministic hasher settings
|
||||||
updateHasherSettings(runtimeConfig.HasherSettings.Salt, runtimeConfig.HasherSettings.MinHashLength);
|
syncAllHasherSettings(runtimeConfig.HasherSettings.Salt, runtimeConfig.HasherSettings.MinHashLength);
|
||||||
|
|
||||||
console.info(
|
console.info(
|
||||||
`[Config] Active runtime configuration: source=${runtimeConfig.ConfigSource}, API=${runtimeConfig.ApiSettings.BaseUrl}, STS=${runtimeConfig.SecuritySettings.Authority}`
|
`[Config] Active runtime configuration: source=${runtimeConfig.ConfigSource}, API=${runtimeConfig.ApiSettings.BaseUrl}, STS=${runtimeConfig.SecuritySettings.Authority}`
|
||||||
|
|||||||
@@ -6,6 +6,7 @@ import {
|
|||||||
getNotifications,
|
getNotifications,
|
||||||
createNotification,
|
createNotification,
|
||||||
markNotificationAsRead,
|
markNotificationAsRead,
|
||||||
|
archiveNotification,
|
||||||
updateNotification,
|
updateNotification,
|
||||||
getCurrentUserTenantId,
|
getCurrentUserTenantId,
|
||||||
} from '../services/api';
|
} from '../services/api';
|
||||||
@@ -39,70 +40,73 @@ export function useInAppNotifications() {
|
|||||||
const trashStorageKey = `pfm_mailbox_trash_${userId || 'default'}`;
|
const trashStorageKey = `pfm_mailbox_trash_${userId || 'default'}`;
|
||||||
const sentStorageKey = `pfm_mailbox_sent_${userId || 'default'}`;
|
const sentStorageKey = `pfm_mailbox_sent_${userId || 'default'}`;
|
||||||
|
|
||||||
// Starred messages set
|
const readStorageList = useCallback(<T,>(key: string, fallbackKey?: string): T[] => {
|
||||||
const [starredIds, setStarredIds] = useState<string[]>(() => {
|
|
||||||
try {
|
try {
|
||||||
const stored = localStorage.getItem(starredStorageKey);
|
const stored = localStorage.getItem(key);
|
||||||
return stored ? JSON.parse(stored) : [];
|
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 {
|
} catch {
|
||||||
return [];
|
return [];
|
||||||
}
|
}
|
||||||
});
|
}, []);
|
||||||
|
|
||||||
|
// Starred messages set
|
||||||
|
const [starredIds, setStarredIds] = useState<string[]>(() =>
|
||||||
|
readStorageList<string>(starredStorageKey, 'pfm_mailbox_starred_default')
|
||||||
|
);
|
||||||
|
|
||||||
// Trashed messages set
|
// Trashed messages set
|
||||||
const [trashedIds, setTrashedIds] = useState<string[]>(() => {
|
const [trashedIds, setTrashedIds] = useState<string[]>(() =>
|
||||||
try {
|
readStorageList<string>(trashStorageKey, 'pfm_mailbox_trash_default')
|
||||||
const stored = localStorage.getItem(trashStorageKey);
|
);
|
||||||
return stored ? JSON.parse(stored) : [];
|
|
||||||
} catch {
|
|
||||||
return [];
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
// Local drafts list
|
// Local drafts list
|
||||||
const [drafts, setDrafts] = useState<DraftMessage[]>(() => {
|
const [drafts, setDrafts] = useState<DraftMessage[]>(() =>
|
||||||
try {
|
readStorageList<DraftMessage>(draftsStorageKey, 'pfm_mailbox_drafts_default')
|
||||||
const stored = localStorage.getItem(draftsStorageKey);
|
);
|
||||||
return stored ? JSON.parse(stored) : [];
|
|
||||||
} catch {
|
|
||||||
return [];
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
// Local sent messages history
|
// Local sent messages history
|
||||||
const [localSent, setLocalSent] = useState<Notification[]>(() => {
|
const [localSent, setLocalSent] = useState<Notification[]>(() =>
|
||||||
try {
|
readStorageList<Notification>(sentStorageKey, 'pfm_mailbox_sent_default')
|
||||||
const stored = localStorage.getItem(sentStorageKey);
|
);
|
||||||
return stored ? JSON.parse(stored) : [];
|
|
||||||
} catch {
|
|
||||||
return [];
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
// Sync state to localStorage
|
// Synchronize across hook instances and userId changes
|
||||||
useEffect(() => {
|
const syncFromStorage = useCallback(() => {
|
||||||
try {
|
setStarredIds(readStorageList<string>(starredStorageKey, 'pfm_mailbox_starred_default'));
|
||||||
localStorage.setItem(starredStorageKey, JSON.stringify(starredIds));
|
setTrashedIds(readStorageList<string>(trashStorageKey, 'pfm_mailbox_trash_default'));
|
||||||
} catch {}
|
setDrafts(readStorageList<DraftMessage>(draftsStorageKey, 'pfm_mailbox_drafts_default'));
|
||||||
}, [starredIds, starredStorageKey]);
|
setLocalSent(readStorageList<Notification>(sentStorageKey, 'pfm_mailbox_sent_default'));
|
||||||
|
}, [readStorageList, starredStorageKey, trashStorageKey, draftsStorageKey, sentStorageKey]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
try {
|
syncFromStorage();
|
||||||
localStorage.setItem(trashStorageKey, JSON.stringify(trashedIds));
|
}, [syncFromStorage]);
|
||||||
} catch {}
|
|
||||||
}, [trashedIds, trashStorageKey]);
|
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
try {
|
const handleSync = () => syncFromStorage();
|
||||||
localStorage.setItem(draftsStorageKey, JSON.stringify(drafts));
|
window.addEventListener('pfm-mailbox-sync', handleSync);
|
||||||
} catch {}
|
window.addEventListener('storage', handleSync);
|
||||||
}, [drafts, draftsStorageKey]);
|
return () => {
|
||||||
|
window.removeEventListener('pfm-mailbox-sync', handleSync);
|
||||||
useEffect(() => {
|
window.removeEventListener('storage', handleSync);
|
||||||
try {
|
};
|
||||||
localStorage.setItem(sentStorageKey, JSON.stringify(localSent));
|
}, [syncFromStorage]);
|
||||||
} catch {}
|
|
||||||
}, [localSent, sentStorageKey]);
|
|
||||||
|
|
||||||
// Fetch notifications from backend
|
// Fetch notifications from backend
|
||||||
const { data: rawNotifications = [], isLoading, refetch } = useQuery<Notification[]>({
|
const { data: rawNotifications = [], isLoading, refetch } = useQuery<Notification[]>({
|
||||||
@@ -121,7 +125,6 @@ export function useInAppNotifications() {
|
|||||||
},
|
},
|
||||||
// Live polling every 30 seconds
|
// Live polling every 30 seconds
|
||||||
refetchInterval: 30000,
|
refetchInterval: 30000,
|
||||||
staleTime: 10000,
|
|
||||||
});
|
});
|
||||||
|
|
||||||
// InApp notifications (strictly platform === InApp 5) for Bell & Counters
|
// InApp notifications (strictly platform === InApp 5) for Bell & Counters
|
||||||
@@ -131,23 +134,19 @@ export function useInAppNotifications() {
|
|||||||
);
|
);
|
||||||
}, [rawNotifications]);
|
}, [rawNotifications]);
|
||||||
|
|
||||||
// Active inbox notifications (InApp + Email for mailbox, excluding trashed and drafts)
|
// Active in-app notifications in inbox (excluding trashed and drafts)
|
||||||
const allMailboxNotifications = useMemo(() => {
|
|
||||||
return rawNotifications.filter(
|
|
||||||
n =>
|
|
||||||
(Number(n.platform) === NotificationPlatform.InApp || Number(n.platform) === NotificationPlatform.Email) &&
|
|
||||||
!trashedIds.includes(String(n.id)) &&
|
|
||||||
Number(n.status) !== NotificationStatus.Draft
|
|
||||||
);
|
|
||||||
}, [rawNotifications, trashedIds]);
|
|
||||||
|
|
||||||
// Active in-app notifications in inbox
|
|
||||||
const inboxNotifications = useMemo(() => {
|
const inboxNotifications = useMemo(() => {
|
||||||
return inAppNotifications.filter(
|
return inAppNotifications.filter(
|
||||||
n => !trashedIds.includes(String(n.id)) && Number(n.status) !== NotificationStatus.Draft
|
n =>
|
||||||
|
!trashedIds.includes(String(n.id)) &&
|
||||||
|
Number(n.status) !== NotificationStatus.Archived &&
|
||||||
|
Number(n.status) !== NotificationStatus.Draft
|
||||||
);
|
);
|
||||||
}, [inAppNotifications, trashedIds]);
|
}, [inAppNotifications, trashedIds]);
|
||||||
|
|
||||||
|
// Active mailbox notifications (always In-App notifications)
|
||||||
|
const allMailboxNotifications = inboxNotifications;
|
||||||
|
|
||||||
// Unread InApp notifications: status !== Read && status !== Draft && status !== Cancelled && status !== Failed
|
// Unread InApp notifications: status !== Read && status !== Draft && status !== Cancelled && status !== Failed
|
||||||
const unreadNotifications = useMemo(() => {
|
const unreadNotifications = useMemo(() => {
|
||||||
return inboxNotifications.filter(
|
return inboxNotifications.filter(
|
||||||
@@ -161,20 +160,21 @@ export function useInAppNotifications() {
|
|||||||
const unreadCount = unreadNotifications.length;
|
const unreadCount = unreadNotifications.length;
|
||||||
const top5Unread = useMemo(() => unreadNotifications.slice(0, 5), [unreadNotifications]);
|
const top5Unread = useMemo(() => unreadNotifications.slice(0, 5), [unreadNotifications]);
|
||||||
|
|
||||||
// Starred notifications
|
// Starred notifications (In-App only)
|
||||||
const starredNotifications = useMemo(() => {
|
const starredNotifications = useMemo(() => {
|
||||||
return rawNotifications.filter(
|
return inAppNotifications.filter(
|
||||||
n =>
|
n =>
|
||||||
starredIds.includes(String(n.id)) &&
|
starredIds.includes(String(n.id)) &&
|
||||||
!trashedIds.includes(String(n.id)) &&
|
!trashedIds.includes(String(n.id))
|
||||||
(Number(n.platform) === NotificationPlatform.InApp || Number(n.platform) === NotificationPlatform.Email)
|
|
||||||
);
|
);
|
||||||
}, [rawNotifications, starredIds, trashedIds]);
|
}, [inAppNotifications, starredIds, trashedIds]);
|
||||||
|
|
||||||
// Trashed notifications
|
// Trashed notifications (In-App only)
|
||||||
const trashedNotifications = useMemo(() => {
|
const trashedNotifications = useMemo(() => {
|
||||||
return rawNotifications.filter(n => trashedIds.includes(String(n.id)));
|
return inAppNotifications.filter(
|
||||||
}, [rawNotifications, trashedIds]);
|
n => trashedIds.includes(String(n.id)) || Number(n.status) === NotificationStatus.Archived
|
||||||
|
);
|
||||||
|
}, [inAppNotifications, trashedIds]);
|
||||||
|
|
||||||
// Mutation to mark notification as read
|
// Mutation to mark notification as read
|
||||||
const markAsReadMutation = useMutation({
|
const markAsReadMutation = useMutation({
|
||||||
@@ -206,27 +206,85 @@ export function useInAppNotifications() {
|
|||||||
// Toggle star
|
// Toggle star
|
||||||
const toggleStar = useCallback((id: string | number) => {
|
const toggleStar = useCallback((id: string | number) => {
|
||||||
const strId = String(id);
|
const strId = String(id);
|
||||||
setStarredIds(prev => (prev.includes(strId) ? prev.filter(x => x !== strId) : [...prev, strId]));
|
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)
|
// Delete message (NotificationStatus == Archived & move to trash)
|
||||||
const deleteMessage = useCallback((id: string | number) => {
|
const deleteMessage = useCallback(async (id: string | number) => {
|
||||||
const strId = String(id);
|
const strId = String(id);
|
||||||
setTrashedIds(prev => (prev.includes(strId) ? prev : [...prev, strId]));
|
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;
|
||||||
|
});
|
||||||
|
|
||||||
// Restore from trash
|
queryClient.setQueryData<Notification[]>(['inapp-notifications', userId], (old = []) =>
|
||||||
const restoreMessage = useCallback((id: string | number) => {
|
old.map(n => (String(n.id) === strId ? { ...n, status: NotificationStatus.Archived } : n))
|
||||||
|
);
|
||||||
|
|
||||||
|
try {
|
||||||
|
await archiveNotification(id);
|
||||||
|
} catch (err) {
|
||||||
|
console.warn('[deleteMessage] Error archiving notification on server:', err);
|
||||||
|
} finally {
|
||||||
|
queryClient.invalidateQueries({ queryKey: ['inapp-notifications'] });
|
||||||
|
}
|
||||||
|
}, [trashStorageKey, userId, queryClient]);
|
||||||
|
|
||||||
|
// Restore from trash / unarchive
|
||||||
|
const restoreMessage = useCallback(async (id: string | number) => {
|
||||||
const strId = String(id);
|
const strId = String(id);
|
||||||
setTrashedIds(prev => prev.filter(x => x !== strId));
|
setTrashedIds(prev => {
|
||||||
}, []);
|
const next = prev.filter(x => x !== strId);
|
||||||
|
try {
|
||||||
|
localStorage.setItem(trashStorageKey, JSON.stringify(next));
|
||||||
|
window.dispatchEvent(new Event('pfm-mailbox-sync'));
|
||||||
|
} catch {}
|
||||||
|
return next;
|
||||||
|
});
|
||||||
|
|
||||||
|
queryClient.setQueryData<Notification[]>(['inapp-notifications', userId], (old = []) =>
|
||||||
|
old.map(n => (String(n.id) === strId ? { ...n, status: NotificationStatus.Read } : n))
|
||||||
|
);
|
||||||
|
|
||||||
|
try {
|
||||||
|
await markNotificationAsRead(id);
|
||||||
|
} catch (err) {
|
||||||
|
console.warn('[restoreMessage] Error restoring notification on server:', err);
|
||||||
|
} finally {
|
||||||
|
queryClient.invalidateQueries({ queryKey: ['inapp-notifications'] });
|
||||||
|
}
|
||||||
|
}, [trashStorageKey, userId, queryClient]);
|
||||||
|
|
||||||
// Permanent delete
|
// Permanent delete
|
||||||
const permanentDelete = useCallback((id: string | number) => {
|
const permanentDelete = useCallback((id: string | number) => {
|
||||||
const strId = String(id);
|
const strId = String(id);
|
||||||
setTrashedIds(prev => prev.filter(x => x !== strId));
|
setTrashedIds(prev => {
|
||||||
setLocalSent(prev => prev.filter(n => String(n.id) !== strId));
|
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
|
// Draft management
|
||||||
const saveDraft = useCallback((draft: Omit<DraftMessage, 'id' | 'updatedAt'> & { id?: string }) => {
|
const saveDraft = useCallback((draft: Omit<DraftMessage, 'id' | 'updatedAt'> & { id?: string }) => {
|
||||||
@@ -237,15 +295,31 @@ export function useInAppNotifications() {
|
|||||||
updatedAt: new Date().toISOString(),
|
updatedAt: new Date().toISOString(),
|
||||||
};
|
};
|
||||||
setDrafts(prev => {
|
setDrafts(prev => {
|
||||||
const filtered = prev.filter(d => d.id !== draftId);
|
const current = readStorageList<DraftMessage>(draftsStorageKey, 'pfm_mailbox_drafts_default');
|
||||||
return [newDraft, ...filtered];
|
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;
|
return draftId;
|
||||||
}, []);
|
}, [draftsStorageKey, readStorageList]);
|
||||||
|
|
||||||
const deleteDraft = useCallback((id: string) => {
|
const deleteDraft = useCallback((id: string) => {
|
||||||
setDrafts(prev => prev.filter(d => d.id !== id));
|
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)
|
// Send new notification (InApp or Email)
|
||||||
const sendMessageMutation = useMutation({
|
const sendMessageMutation = useMutation({
|
||||||
@@ -288,7 +362,14 @@ export function useInAppNotifications() {
|
|||||||
sentAt: new Date().toISOString(),
|
sentAt: new Date().toISOString(),
|
||||||
};
|
};
|
||||||
|
|
||||||
setLocalSent(prev => [sentItem, ...prev]);
|
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 sending a draft, remove the draft
|
||||||
if (payload.draftId) {
|
if (payload.draftId) {
|
||||||
|
|||||||
@@ -80,6 +80,7 @@ export const NOTIFICATION_STATUS_OPTIONS = [
|
|||||||
{ value: NotificationStatus.Cancelled, label: 'Cancelled' },
|
{ value: NotificationStatus.Cancelled, label: 'Cancelled' },
|
||||||
{ value: NotificationStatus.Bounced, label: 'Bounced' },
|
{ value: NotificationStatus.Bounced, label: 'Bounced' },
|
||||||
{ value: NotificationStatus.Draft, label: 'Draft' },
|
{ value: NotificationStatus.Draft, label: 'Draft' },
|
||||||
|
{ value: NotificationStatus.Archived, label: 'Archived' },
|
||||||
];
|
];
|
||||||
|
|
||||||
export const NOTIFICATION_PLATFORM_OPTIONS = [
|
export const NOTIFICATION_PLATFORM_OPTIONS = [
|
||||||
@@ -510,6 +511,8 @@ export const getEmailStatusBadge = (status?: EmailStatuses | number | null) => {
|
|||||||
export const getNotificationStatusBadge = (status?: NotificationStatus | number | null) => {
|
export const getNotificationStatusBadge = (status?: NotificationStatus | number | null) => {
|
||||||
const s = Number(status);
|
const s = Number(status);
|
||||||
switch (s) {
|
switch (s) {
|
||||||
|
case NotificationStatus.Archived:
|
||||||
|
return { label: 'Archived', className: 'bg-slate-800 text-slate-300 border-slate-700 font-semibold' };
|
||||||
case NotificationStatus.Draft:
|
case NotificationStatus.Draft:
|
||||||
return { label: 'Draft', className: 'bg-purple-50 text-purple-800 border-purple-300 font-semibold' };
|
return { label: 'Draft', className: 'bg-purple-50 text-purple-800 border-purple-300 font-semibold' };
|
||||||
case NotificationStatus.Delivered:
|
case NotificationStatus.Delivered:
|
||||||
|
|||||||
@@ -64,8 +64,13 @@ export function isHtmlContent(content: string): boolean {
|
|||||||
trimmed.startsWith('<html') ||
|
trimmed.startsWith('<html') ||
|
||||||
trimmed.startsWith('<div') ||
|
trimmed.startsWith('<div') ||
|
||||||
trimmed.startsWith('<table') ||
|
trimmed.startsWith('<table') ||
|
||||||
trimmed.startsWith('<p>') ||
|
trimmed.startsWith('<p') ||
|
||||||
(trimmed.includes('</') && trimmed.includes('>'))
|
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)
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -224,7 +229,14 @@ export function renderMarkdownToHtml(
|
|||||||
markdown: string,
|
markdown: string,
|
||||||
options?: MarkdownRenderOptions
|
options?: MarkdownRenderOptions
|
||||||
): string {
|
): string {
|
||||||
const contentHtml = parseMarkdownToHtml(markdown);
|
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) {
|
if (!options?.applyBranding && !options?.emailFormat) {
|
||||||
return contentHtml;
|
return contentHtml;
|
||||||
|
|||||||
@@ -4,15 +4,38 @@ import { bumpMutationEpoch } from '../services/httpClient';
|
|||||||
export const queryClient = new QueryClient({
|
export const queryClient = new QueryClient({
|
||||||
defaultOptions: {
|
defaultOptions: {
|
||||||
queries: {
|
queries: {
|
||||||
staleTime: 1000 * 60 * 2, // 2 minutes fresh in memory
|
// Remove caching from all pages: zero stale time and zero cache retention
|
||||||
gcTime: 1000 * 60 * 10, // 10 minutes cache retention
|
// Whenever a component needs data, it must call its corresponding endpoint(s)
|
||||||
refetchOnWindowFocus: false,
|
staleTime: 0,
|
||||||
refetchOnReconnect: true,
|
gcTime: 0,
|
||||||
|
refetchOnMount: 'always',
|
||||||
|
refetchOnWindowFocus: 'always',
|
||||||
|
refetchOnReconnect: 'always',
|
||||||
retry: 1,
|
retry: 1,
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// Retain caching ONLY for sync status, auth token, roles and grants
|
||||||
|
const RETAINED_CACHE_OPTIONS = {
|
||||||
|
staleTime: 5 * 60 * 1000,
|
||||||
|
gcTime: 10 * 60 * 1000,
|
||||||
|
refetchOnMount: false,
|
||||||
|
refetchOnWindowFocus: false,
|
||||||
|
};
|
||||||
|
|
||||||
|
queryClient.setQueryDefaults(['roles'], RETAINED_CACHE_OPTIONS);
|
||||||
|
queryClient.setQueryDefaults(['system-roles'], RETAINED_CACHE_OPTIONS);
|
||||||
|
queryClient.setQueryDefaults(['role-claims'], RETAINED_CACHE_OPTIONS);
|
||||||
|
queryClient.setQueryDefaults(['user-roles'], RETAINED_CACHE_OPTIONS);
|
||||||
|
queryClient.setQueryDefaults(['user-claims'], RETAINED_CACHE_OPTIONS);
|
||||||
|
queryClient.setQueryDefaults(['grants'], RETAINED_CACHE_OPTIONS);
|
||||||
|
queryClient.setQueryDefaults(['user-grants'], RETAINED_CACHE_OPTIONS);
|
||||||
|
queryClient.setQueryDefaults(['user-sync'], RETAINED_CACHE_OPTIONS);
|
||||||
|
queryClient.setQueryDefaults(['sync-status'], RETAINED_CACHE_OPTIONS);
|
||||||
|
queryClient.setQueryDefaults(['auth'], RETAINED_CACHE_OPTIONS);
|
||||||
|
queryClient.setQueryDefaults(['auth-token'], RETAINED_CACHE_OPTIONS);
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Invalidates queries while simultaneously bumping the mutation epoch.
|
* Invalidates queries while simultaneously bumping the mutation epoch.
|
||||||
* This guarantees that the background refetch generates a fresh query parameter (_v)
|
* This guarantees that the background refetch generates a fresh query parameter (_v)
|
||||||
|
|||||||
@@ -353,6 +353,12 @@ export const BeneficiariesPage: React.FC = () => {
|
|||||||
const updateCompMutation = useMutation({
|
const updateCompMutation = useMutation({
|
||||||
mutationFn: (vals: ComplianceFormValues) => {
|
mutationFn: (vals: ComplianceFormValues) => {
|
||||||
if (!editingComp) throw new Error('No item selected');
|
if (!editingComp) throw new Error('No item selected');
|
||||||
|
if (
|
||||||
|
(vals.status === ComplianceStatus.Met || vals.status === ComplianceStatus.Exceeded) &&
|
||||||
|
isMaker(editingComp.createdBy)
|
||||||
|
) {
|
||||||
|
throw new Error('Maker-Checker Violation: You created this compliance item and cannot approve it.');
|
||||||
|
}
|
||||||
return updateComplianceItem({
|
return updateComplianceItem({
|
||||||
complianceItemId: editingComp.id,
|
complianceItemId: editingComp.id,
|
||||||
title: vals.title,
|
title: vals.title,
|
||||||
@@ -399,6 +405,12 @@ export const BeneficiariesPage: React.FC = () => {
|
|||||||
const updateOutcomeMutation = useMutation({
|
const updateOutcomeMutation = useMutation({
|
||||||
mutationFn: (vals: OutcomeFormValues) => {
|
mutationFn: (vals: OutcomeFormValues) => {
|
||||||
if (!editingOutcome) throw new Error('No outcome selected for update');
|
if (!editingOutcome) throw new Error('No outcome selected for update');
|
||||||
|
if (
|
||||||
|
(vals.status === ComplianceStatus.Met || vals.status === ComplianceStatus.Exceeded) &&
|
||||||
|
isMaker(editingOutcome.createdBy)
|
||||||
|
) {
|
||||||
|
throw new Error('Maker-Checker Violation: You created this outcome record and cannot approve or certify fulfillment.');
|
||||||
|
}
|
||||||
return updateOutcome({
|
return updateOutcome({
|
||||||
outcomeId: editingOutcome.id,
|
outcomeId: editingOutcome.id,
|
||||||
name: vals.name,
|
name: vals.name,
|
||||||
@@ -1467,11 +1479,15 @@ export const BeneficiariesPage: React.FC = () => {
|
|||||||
{...compForm.register('status')}
|
{...compForm.register('status')}
|
||||||
className="w-full bg-slate-900 border border-slate-800 rounded-xl px-3 py-2 text-xs text-white focus:outline-none focus:border-blue-500 cursor-pointer"
|
className="w-full bg-slate-900 border border-slate-800 rounded-xl px-3 py-2 text-xs text-white focus:outline-none focus:border-blue-500 cursor-pointer"
|
||||||
>
|
>
|
||||||
{COMPLIANCE_STATUS_OPTIONS.map((opt) => (
|
{COMPLIANCE_STATUS_OPTIONS.map((opt) => {
|
||||||
<option key={opt.value} value={opt.value}>
|
const isApprovedOpt = opt.value === ComplianceStatus.Met || opt.value === ComplianceStatus.Exceeded;
|
||||||
{opt.label}
|
const makerRestricted = editingComp && isApprovedOpt && isMaker(editingComp.createdBy);
|
||||||
</option>
|
return (
|
||||||
))}
|
<option key={opt.value} value={opt.value} disabled={makerRestricted}>
|
||||||
|
{opt.label} {makerRestricted ? '(Maker-Checker: Restricted)' : ''}
|
||||||
|
</option>
|
||||||
|
);
|
||||||
|
})}
|
||||||
</select>
|
</select>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -1680,11 +1696,15 @@ export const BeneficiariesPage: React.FC = () => {
|
|||||||
{...outcomeForm.register('status')}
|
{...outcomeForm.register('status')}
|
||||||
className="w-full bg-slate-900 border border-slate-800 rounded-xl px-3 py-2 text-xs text-white focus:outline-none focus:border-blue-500 cursor-pointer"
|
className="w-full bg-slate-900 border border-slate-800 rounded-xl px-3 py-2 text-xs text-white focus:outline-none focus:border-blue-500 cursor-pointer"
|
||||||
>
|
>
|
||||||
{COMPLIANCE_STATUS_OPTIONS.map((opt) => (
|
{COMPLIANCE_STATUS_OPTIONS.map((opt) => {
|
||||||
<option key={opt.value} value={opt.value}>
|
const isMetOpt = opt.value === ComplianceStatus.Met || opt.value === ComplianceStatus.Exceeded;
|
||||||
{opt.label}
|
const makerRestricted = editingOutcome && isMetOpt && isMaker(editingOutcome.createdBy);
|
||||||
</option>
|
return (
|
||||||
))}
|
<option key={opt.value} value={opt.value} disabled={makerRestricted}>
|
||||||
|
{opt.label} {makerRestricted ? '(Maker-Checker: Restricted)' : ''}
|
||||||
|
</option>
|
||||||
|
);
|
||||||
|
})}
|
||||||
</select>
|
</select>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
|||||||
@@ -1020,7 +1020,7 @@ export const ChangeManagementPage: React.FC = () => {
|
|||||||
{Number(cr.status) === ApprovalStatus.Approved ? (
|
{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.">
|
<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" />
|
<Lock className="w-3 h-3 text-emerald-400" />
|
||||||
<span>Resolved & Enacted (Immutable)</span>
|
<span>Resolved & Enacted</span>
|
||||||
</span>
|
</span>
|
||||||
) : (
|
) : (
|
||||||
<>
|
<>
|
||||||
@@ -1528,7 +1528,7 @@ export const ChangeManagementPage: React.FC = () => {
|
|||||||
title="Approved and enacted change requests are immutable."
|
title="Approved and enacted change requests are immutable."
|
||||||
>
|
>
|
||||||
<Lock className="w-3.5 h-3.5 text-emerald-400" />
|
<Lock className="w-3.5 h-3.5 text-emerald-400" />
|
||||||
<span>Approved & Enacted (Immutable - Cannot Be Changed)</span>
|
<span>Approved & Enacted</span>
|
||||||
</div>
|
</div>
|
||||||
) : (
|
) : (
|
||||||
<select
|
<select
|
||||||
|
|||||||
@@ -214,6 +214,11 @@ export const ExecutiveDashboard: React.FC = () => {
|
|||||||
return dashboard?.disbursed || 0;
|
return dashboard?.disbursed || 0;
|
||||||
}, [dashboard, disbursements, filteredProjects, selectedProgramme, selectedSector]);
|
}, [dashboard, disbursements, filteredProjects, selectedProgramme, selectedSector]);
|
||||||
|
|
||||||
|
const disbursedPercentage = useMemo(() => {
|
||||||
|
if (!portfolioValue || portfolioValue <= 0) return 0;
|
||||||
|
return Math.min(100, Math.round((disbursed / portfolioValue) * 1000) / 10);
|
||||||
|
}, [disbursed, portfolioValue]);
|
||||||
|
|
||||||
const onTrackPercentage = useMemo(() => {
|
const onTrackPercentage = useMemo(() => {
|
||||||
const targetProjects = filteredProjects.length > 0 ? filteredProjects : projects;
|
const targetProjects = filteredProjects.length > 0 ? filteredProjects : projects;
|
||||||
if (targetProjects.length === 0) return 0;
|
if (targetProjects.length === 0) return 0;
|
||||||
@@ -341,7 +346,7 @@ export const ExecutiveDashboard: React.FC = () => {
|
|||||||
}, [disbursements]);
|
}, [disbursements]);
|
||||||
|
|
||||||
// Projects requiring management attention based on risk profile
|
// Projects requiring management attention based on risk profile
|
||||||
const attentionProjects = useMemo(() => {
|
const attentionProjects = useMemo<AttentionProject[]>(() => {
|
||||||
const targetProjects = filteredProjects.length > 0 ? filteredProjects : projects;
|
const targetProjects = filteredProjects.length > 0 ? filteredProjects : projects;
|
||||||
if (targetProjects.length === 0) return [];
|
if (targetProjects.length === 0) return [];
|
||||||
|
|
||||||
@@ -356,7 +361,7 @@ export const ExecutiveDashboard: React.FC = () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
return targetProjects
|
return targetProjects
|
||||||
.map((p) => {
|
.map((p): AttentionProject => {
|
||||||
const maxScore = projectMaxRisk[String(p.id)] || 0;
|
const maxScore = projectMaxRisk[String(p.id)] || 0;
|
||||||
const isRejected = p.status === ApprovalStatus.Rejected;
|
const isRejected = p.status === ApprovalStatus.Rejected;
|
||||||
const isPending = p.status === ApprovalStatus.Pending || p.status === ApprovalStatus.Review;
|
const isPending = p.status === ApprovalStatus.Pending || p.status === ApprovalStatus.Review;
|
||||||
@@ -393,7 +398,7 @@ export const ExecutiveDashboard: React.FC = () => {
|
|||||||
|
|
||||||
return {
|
return {
|
||||||
id: p.id,
|
id: p.id,
|
||||||
name: p.name,
|
name: p.name || 'Unnamed Project',
|
||||||
programme: p.programmeName || `Programme ${p.programmeId}`,
|
programme: p.programmeName || `Programme ${p.programmeId}`,
|
||||||
status: statusLabel,
|
status: statusLabel,
|
||||||
risk: riskLevel,
|
risk: riskLevel,
|
||||||
@@ -568,187 +573,328 @@ export const ExecutiveDashboard: React.FC = () => {
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* 8 Headline Portfolio KPI Cards with Compliance & Budget At Risk */}
|
{/* Executive Portfolio KPIs: Structured 2-Tier Balanced Grid */}
|
||||||
<div className="grid grid-cols-2 sm:grid-cols-4 xl:grid-cols-8 gap-3.5">
|
<div className="space-y-6">
|
||||||
{/* Card 1: Portfolio Value */}
|
{/* Tier 1: Capital Commitments & Deployment Scale */}
|
||||||
<div className="bg-[#0f1523]/80 p-5 rounded-2xl border border-slate-800/80 hover:border-blue-500/30 transition-all flex flex-col justify-between shadow-md group backdrop-blur-sm">
|
<div className="space-y-3">
|
||||||
<div className="flex items-center justify-between text-slate-400">
|
<div className="flex items-center justify-between px-1">
|
||||||
<div className="flex items-center gap-1.5">
|
<div className="flex items-center gap-2">
|
||||||
<span className="text-[11px] font-semibold uppercase tracking-wider text-slate-400">Portfolio Value</span>
|
<span className="text-[11px] font-bold uppercase tracking-widest text-slate-400">
|
||||||
<HelpTooltip
|
Capital Deployment & Scale
|
||||||
title="Total Portfolio Value"
|
</span>
|
||||||
content="Aggregated statutory grant commitment amount approved across all active grant awards and contracts."
|
<span className="h-1 w-1 rounded-full bg-slate-600" />
|
||||||
size="sm"
|
<span className="text-xs text-slate-500 hidden sm:inline">Portfolio commitments, disbursements & capital exposure</span>
|
||||||
placement="top"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
<div className="p-1.5 rounded-lg bg-blue-500/10 text-blue-400 border border-blue-500/20 group-hover:scale-110 transition">
|
|
||||||
<Coins className="w-3.5 h-3.5" />
|
|
||||||
</div>
|
</div>
|
||||||
|
<span className="text-[10px] font-semibold text-blue-400/90 bg-blue-500/10 px-2 py-0.5 rounded-full border border-blue-500/20">
|
||||||
|
Financial Governance
|
||||||
|
</span>
|
||||||
</div>
|
</div>
|
||||||
<div className="mt-3">
|
|
||||||
<p className="text-2xl font-bold text-white tracking-tight">{formatCompactZAR(portfolioValue)}</p>
|
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-4 gap-4">
|
||||||
<p className="text-[10px] text-slate-400 font-mono mt-1">{formatZAR(portfolioValue)}</p>
|
{/* Card 1: Portfolio Value */}
|
||||||
|
<div className="bg-[#0f1523]/80 p-5 rounded-2xl border border-slate-800/80 hover:border-blue-500/40 transition-all flex flex-col justify-between shadow-md group backdrop-blur-sm relative overflow-hidden">
|
||||||
|
<div className="flex items-start justify-between gap-3">
|
||||||
|
<div className="flex items-center gap-1.5 min-w-0 flex-1">
|
||||||
|
<span className="text-xs font-semibold uppercase tracking-wider text-slate-400 truncate">
|
||||||
|
Portfolio Value
|
||||||
|
</span>
|
||||||
|
<HelpTooltip
|
||||||
|
title="Total Portfolio Value"
|
||||||
|
content="Aggregated statutory grant commitment amount approved across all active grant awards and contracts."
|
||||||
|
size="sm"
|
||||||
|
placement="top"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className="shrink-0 p-2 rounded-xl bg-blue-500/10 text-blue-400 border border-blue-500/20 group-hover:scale-105 transition-transform">
|
||||||
|
<Coins className="w-4 h-4" />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="mt-3">
|
||||||
|
<p className="text-2xl sm:text-3xl font-bold text-white tracking-tight">
|
||||||
|
{formatCompactZAR(portfolioValue)}
|
||||||
|
</p>
|
||||||
|
<div className="flex items-center justify-between mt-3 pt-2.5 border-t border-slate-800/70 text-xs">
|
||||||
|
<span className="text-slate-400 font-mono text-[11px] truncate mr-2" title={formatZAR(portfolioValue)}>
|
||||||
|
{formatZAR(portfolioValue)}
|
||||||
|
</span>
|
||||||
|
<span className="text-[10px] font-medium text-blue-400 bg-blue-500/10 px-1.5 py-0.5 rounded border border-blue-500/20 shrink-0">
|
||||||
|
Statutory Total
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Card 2: Disbursed */}
|
||||||
|
<div className="bg-[#0f1523]/80 p-5 rounded-2xl border border-slate-800/80 hover:border-teal-500/40 transition-all flex flex-col justify-between shadow-md group backdrop-blur-sm relative overflow-hidden">
|
||||||
|
<div className="flex items-start justify-between gap-3">
|
||||||
|
<div className="flex items-center gap-1.5 min-w-0 flex-1">
|
||||||
|
<span className="text-xs font-semibold uppercase tracking-wider text-slate-400 truncate">
|
||||||
|
Disbursed
|
||||||
|
</span>
|
||||||
|
<HelpTooltip
|
||||||
|
title="Disbursed Funds"
|
||||||
|
content="Actual funds paid out to implementing grantees following verified milestone proof-of-performance."
|
||||||
|
size="sm"
|
||||||
|
placement="top"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className="shrink-0 p-2 rounded-xl bg-teal-500/10 text-teal-400 border border-teal-500/20 group-hover:scale-105 transition-transform">
|
||||||
|
<TrendingUp className="w-4 h-4" />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="mt-3">
|
||||||
|
<div className="flex items-baseline justify-between gap-2">
|
||||||
|
<p className="text-2xl sm:text-3xl font-bold text-teal-400 tracking-tight">
|
||||||
|
{formatCompactZAR(disbursed)}
|
||||||
|
</p>
|
||||||
|
<span className="text-xs font-semibold text-teal-400 bg-teal-500/10 px-2 py-0.5 rounded-full border border-teal-500/20 shrink-0 font-mono">
|
||||||
|
{disbursedPercentage}%
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<div className="w-full h-1.5 bg-slate-800/90 rounded-full overflow-hidden mt-3">
|
||||||
|
<div
|
||||||
|
className="h-full bg-gradient-to-r from-teal-500 to-emerald-400 rounded-full transition-all duration-500"
|
||||||
|
style={{ width: `${disbursedPercentage}%` }}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center justify-between mt-2.5 pt-2 border-t border-slate-800/70 text-xs text-slate-400">
|
||||||
|
<span className="text-[11px]">Tranches released</span>
|
||||||
|
<span className="font-mono text-slate-300 text-[11px]">{disbursedPercentage}% deployed</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Card 3: Active Awards */}
|
||||||
|
<div className="bg-[#0f1523]/80 p-5 rounded-2xl border border-slate-800/80 hover:border-indigo-500/40 transition-all flex flex-col justify-between shadow-md group backdrop-blur-sm relative overflow-hidden">
|
||||||
|
<div className="flex items-start justify-between gap-3">
|
||||||
|
<div className="flex items-center gap-1.5 min-w-0 flex-1">
|
||||||
|
<span className="text-xs font-semibold uppercase tracking-wider text-slate-400 truncate">
|
||||||
|
Active Awards
|
||||||
|
</span>
|
||||||
|
<HelpTooltip
|
||||||
|
title="Active Grant Awards"
|
||||||
|
content="Total legally executed statutory agreements currently undergoing active disbursement and implementation."
|
||||||
|
size="sm"
|
||||||
|
placement="top"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className="shrink-0 p-2 rounded-xl bg-indigo-500/10 text-indigo-400 border border-indigo-500/20 group-hover:scale-105 transition-transform">
|
||||||
|
<Layers className="w-4 h-4" />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="mt-3">
|
||||||
|
<p className="text-2xl sm:text-3xl font-bold text-white tracking-tight">
|
||||||
|
{activeAwards}
|
||||||
|
</p>
|
||||||
|
<div className="flex items-center justify-between mt-3 pt-2.5 border-t border-slate-800/70 text-xs">
|
||||||
|
<span className="text-slate-400 text-[11px]">Under implementation</span>
|
||||||
|
<span className="font-medium text-indigo-400 bg-indigo-500/10 px-1.5 py-0.5 rounded border border-indigo-500/20 shrink-0 text-[10px]">
|
||||||
|
{portfolios.length} Portfolio{portfolios.length === 1 ? '' : 's'}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Card 4: Budget At Risk */}
|
||||||
|
<div className="bg-[#0f1523]/80 p-5 rounded-2xl border border-slate-800/80 hover:border-rose-500/40 transition-all flex flex-col justify-between shadow-md group backdrop-blur-sm relative overflow-hidden">
|
||||||
|
<div className="flex items-start justify-between gap-3">
|
||||||
|
<div className="flex items-center gap-1.5 min-w-0 flex-1">
|
||||||
|
<span className="text-xs font-semibold uppercase tracking-wider text-slate-400 truncate">
|
||||||
|
Budget At Risk
|
||||||
|
</span>
|
||||||
|
<HelpTooltip
|
||||||
|
title="Total Budgeted Capital At Risk"
|
||||||
|
content="Aggregated approved budget across all projects currently flagged as noncompliant or carrying unmitigated high risks."
|
||||||
|
size="sm"
|
||||||
|
placement="top"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className="shrink-0 p-2 rounded-xl bg-rose-500/10 text-rose-400 border border-rose-500/20 group-hover:scale-105 transition-transform">
|
||||||
|
<ShieldAlert className="w-4 h-4" />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="mt-3">
|
||||||
|
<p className="text-2xl sm:text-3xl font-bold text-rose-400 tracking-tight">
|
||||||
|
{formatCompactZAR(totalBudgetAtRisk)}
|
||||||
|
</p>
|
||||||
|
<div className="flex items-center justify-between mt-3 pt-2.5 border-t border-slate-800/70 text-xs">
|
||||||
|
<span className="text-slate-400 font-mono text-[11px] truncate mr-2" title={formatZAR(totalBudgetAtRisk)}>
|
||||||
|
{formatZAR(totalBudgetAtRisk)}
|
||||||
|
</span>
|
||||||
|
<span className={`text-[10px] font-medium px-1.5 py-0.5 rounded border shrink-0 ${
|
||||||
|
totalBudgetAtRisk > 0
|
||||||
|
? 'text-rose-400 bg-rose-500/10 border-rose-500/20'
|
||||||
|
: 'text-emerald-400 bg-emerald-500/10 border-emerald-500/20'
|
||||||
|
}`}>
|
||||||
|
{totalBudgetAtRisk > 0 ? 'Exposed' : 'Clear'}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Card 2: Active Awards */}
|
{/* Tier 2: Operational Health, Risk & Beneficiary Impact */}
|
||||||
<div className="bg-[#0f1523]/80 p-5 rounded-2xl border border-slate-800/80 hover:border-blue-500/30 transition-all flex flex-col justify-between shadow-md group backdrop-blur-sm">
|
<div className="space-y-3">
|
||||||
<div className="flex items-center justify-between text-slate-400">
|
<div className="flex items-center justify-between px-1">
|
||||||
<div className="flex items-center gap-1.5">
|
<div className="flex items-center gap-2">
|
||||||
<span className="text-[11px] font-semibold uppercase tracking-wider text-slate-400">Active Awards</span>
|
<span className="text-[11px] font-bold uppercase tracking-widest text-slate-400">
|
||||||
<HelpTooltip
|
Operational Performance & Impact
|
||||||
title="Active Grant Awards"
|
</span>
|
||||||
content="Total legally executed statutory agreements currently undergoing active disbursement and implementation."
|
<span className="h-1 w-1 rounded-full bg-slate-600" />
|
||||||
size="sm"
|
<span className="text-xs text-slate-500 hidden sm:inline">Milestone pace, compliance gate health & direct beneficiaries</span>
|
||||||
placement="top"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
<div className="p-1.5 rounded-lg bg-indigo-500/10 text-indigo-400 border border-indigo-500/20 group-hover:scale-110 transition">
|
|
||||||
<Layers className="w-3.5 h-3.5" />
|
|
||||||
</div>
|
</div>
|
||||||
|
<span className="text-[10px] font-semibold text-emerald-400/90 bg-emerald-500/10 px-2 py-0.5 rounded-full border border-emerald-500/20">
|
||||||
|
Delivery Health
|
||||||
|
</span>
|
||||||
</div>
|
</div>
|
||||||
<div className="mt-3">
|
|
||||||
<p className="text-2xl font-bold text-white tracking-tight">{activeAwards}</p>
|
|
||||||
<p className="text-[10px] text-emerald-400 font-mono mt-1">
|
|
||||||
Across {portfolios.length} portfolio{portfolios.length === 1 ? '' : 's'}
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Card 3: Disbursed */}
|
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-4 gap-4">
|
||||||
<div className="bg-[#0f1523]/80 p-5 rounded-2xl border border-slate-800/80 hover:border-teal-500/30 transition-all flex flex-col justify-between shadow-md group backdrop-blur-sm">
|
{/* Card 5: On Track */}
|
||||||
<div className="flex items-center justify-between text-slate-400">
|
<div className="bg-[#0f1523]/80 p-5 rounded-2xl border border-slate-800/80 hover:border-emerald-500/40 transition-all flex flex-col justify-between shadow-md group backdrop-blur-sm relative overflow-hidden">
|
||||||
<div className="flex items-center gap-1.5">
|
<div className="flex items-start justify-between gap-3">
|
||||||
<span className="text-[11px] font-semibold uppercase tracking-wider text-slate-400">Disbursed</span>
|
<div className="flex items-center gap-1.5 min-w-0 flex-1">
|
||||||
<HelpTooltip
|
<span className="text-xs font-semibold uppercase tracking-wider text-slate-400 truncate">
|
||||||
title="Disbursed Funds"
|
On Track
|
||||||
content="Actual funds paid out to implementing grantees following verified milestone proof-of-performance."
|
</span>
|
||||||
size="sm"
|
<HelpTooltip
|
||||||
placement="top"
|
title="On-Track Progress Ratio"
|
||||||
/>
|
content="Percentage of active projects meeting schedule deadlines and deliverable acceptance criteria without critical deviations."
|
||||||
|
size="sm"
|
||||||
|
placement="top"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className="shrink-0 p-2 rounded-xl bg-emerald-500/10 text-emerald-400 border border-emerald-500/20 group-hover:scale-105 transition-transform">
|
||||||
|
<CheckCircle className="w-4 h-4" />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="mt-3">
|
||||||
|
<div className="flex items-baseline justify-between gap-2">
|
||||||
|
<p className="text-2xl sm:text-3xl font-bold text-emerald-400 tracking-tight">
|
||||||
|
{onTrackPercentage}%
|
||||||
|
</p>
|
||||||
|
<span className="text-xs font-semibold text-emerald-400 bg-emerald-500/10 px-2 py-0.5 rounded-full border border-emerald-500/20 shrink-0 font-mono">
|
||||||
|
{onTrackPercentage >= 80 ? 'Optimal' : onTrackPercentage >= 60 ? 'Moderate' : 'Critical'}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<div className="w-full h-1.5 bg-slate-800/90 rounded-full overflow-hidden mt-3">
|
||||||
|
<div
|
||||||
|
className="h-full bg-gradient-to-r from-emerald-500 to-teal-400 rounded-full transition-all duration-500"
|
||||||
|
style={{ width: `${Math.min(100, onTrackPercentage)}%` }}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center justify-between mt-2.5 pt-2 border-t border-slate-800/70 text-xs text-slate-400">
|
||||||
|
<span className="text-[11px]">Milestones verified</span>
|
||||||
|
<span className="font-mono text-slate-300 text-[11px]">Pace index</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div className="p-1.5 rounded-lg bg-teal-500/10 text-teal-400 border border-teal-500/20 group-hover:scale-110 transition">
|
|
||||||
<TrendingUp className="w-3.5 h-3.5" />
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div className="mt-3">
|
|
||||||
<p className="text-2xl font-bold text-teal-400 tracking-tight">{formatCompactZAR(disbursed)}</p>
|
|
||||||
<p className="text-[10px] text-slate-400 font-mono mt-1">
|
|
||||||
{portfolioValue > 0 ? `${((disbursed / portfolioValue) * 100).toFixed(1)}% of commitments` : '0.0% of commitments'}
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Card 4: On Track */}
|
{/* Card 6: Compliance Health */}
|
||||||
<div className="bg-[#0f1523]/80 p-5 rounded-2xl border border-slate-800/80 hover:border-emerald-500/30 transition-all flex flex-col justify-between shadow-md group backdrop-blur-sm">
|
<div className="bg-[#0f1523]/80 p-5 rounded-2xl border border-slate-800/80 hover:border-emerald-500/40 transition-all flex flex-col justify-between shadow-md group backdrop-blur-sm relative overflow-hidden">
|
||||||
<div className="flex items-center justify-between text-slate-400">
|
<div className="flex items-start justify-between gap-3">
|
||||||
<div className="flex items-center gap-1.5">
|
<div className="flex items-center gap-1.5 min-w-0 flex-1">
|
||||||
<span className="text-[11px] font-semibold uppercase tracking-wider text-slate-400">On Track</span>
|
<span className="text-xs font-semibold uppercase tracking-wider text-slate-400 truncate">
|
||||||
<HelpTooltip
|
Compliance
|
||||||
title="On-Track Progress Ratio"
|
</span>
|
||||||
content="Percentage of active projects meeting schedule deadlines and deliverable acceptance criteria without critical deviations."
|
<HelpTooltip
|
||||||
size="sm"
|
title="Portfolio Compliance Health"
|
||||||
placement="top"
|
content="Percentage of active projects meeting 100% statutory compliance and beneficiary outcome criteria."
|
||||||
/>
|
size="sm"
|
||||||
|
placement="top"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className="shrink-0 p-2 rounded-xl bg-emerald-500/10 text-emerald-400 border border-emerald-500/20 group-hover:scale-105 transition-transform">
|
||||||
|
<ShieldCheck className="w-4 h-4" />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="mt-3">
|
||||||
|
<div className="flex items-baseline justify-between gap-2">
|
||||||
|
<p className="text-2xl sm:text-3xl font-bold text-emerald-400 tracking-tight">
|
||||||
|
{complianceRate}%
|
||||||
|
</p>
|
||||||
|
<span className="text-xs font-semibold text-emerald-400 bg-emerald-500/10 px-2 py-0.5 rounded-full border border-emerald-500/20 shrink-0 font-mono">
|
||||||
|
{complianceRate >= 90 ? 'Passed' : 'Gated'}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<div className="w-full h-1.5 bg-slate-800/90 rounded-full overflow-hidden mt-3">
|
||||||
|
<div
|
||||||
|
className="h-full bg-gradient-to-r from-emerald-500 to-cyan-400 rounded-full transition-all duration-500"
|
||||||
|
style={{ width: `${Math.min(100, complianceRate)}%` }}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center justify-between mt-2.5 pt-2 border-t border-slate-800/70 text-xs text-slate-400">
|
||||||
|
<span className="text-[11px]">Statutory gate check</span>
|
||||||
|
<span className="font-mono text-slate-300 text-[11px]">100% requirement</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div className="p-1.5 rounded-lg bg-emerald-500/10 text-emerald-400 border border-emerald-500/20 group-hover:scale-110 transition">
|
|
||||||
<CheckCircle className="w-3.5 h-3.5" />
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div className="mt-3">
|
|
||||||
<p className="text-2xl font-bold text-emerald-400 tracking-tight">{onTrackPercentage}%</p>
|
|
||||||
<p className="text-[10px] text-slate-400 font-mono mt-1">Milestones verified</p>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Card 5: At Risk */}
|
{/* Card 7: At Risk */}
|
||||||
<div className="bg-[#0f1523]/80 p-5 rounded-2xl border border-slate-800/80 hover:border-amber-500/30 transition-all flex flex-col justify-between shadow-md group backdrop-blur-sm">
|
<div className="bg-[#0f1523]/80 p-5 rounded-2xl border border-slate-800/80 hover:border-amber-500/40 transition-all flex flex-col justify-between shadow-md group backdrop-blur-sm relative overflow-hidden">
|
||||||
<div className="flex items-center justify-between text-slate-400">
|
<div className="flex items-start justify-between gap-3">
|
||||||
<div className="flex items-center gap-1.5">
|
<div className="flex items-center gap-1.5 min-w-0 flex-1">
|
||||||
<span className="text-[11px] font-semibold uppercase tracking-wider text-slate-400">At Risk</span>
|
<span className="text-xs font-semibold uppercase tracking-wider text-slate-400 truncate">
|
||||||
<HelpTooltip
|
At Risk
|
||||||
title="At-Risk Projects"
|
</span>
|
||||||
content="Count of projects with high residual risk scores (≥12), schedule slippage >30 days, or adverse financial variance."
|
<HelpTooltip
|
||||||
size="sm"
|
title="At-Risk Projects"
|
||||||
placement="top"
|
content="Count of projects with high residual risk scores (≥12), schedule slippage >30 days, or adverse financial variance."
|
||||||
/>
|
size="sm"
|
||||||
|
placement="top"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className="shrink-0 p-2 rounded-xl bg-amber-500/10 text-amber-400 border border-amber-500/20 group-hover:scale-105 transition-transform">
|
||||||
|
<AlertTriangle className="w-4 h-4" />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="mt-3">
|
||||||
|
<p className="text-2xl sm:text-3xl font-bold text-amber-400 tracking-tight">
|
||||||
|
{atRiskCount}
|
||||||
|
</p>
|
||||||
|
<div className="flex items-center justify-between mt-3 pt-2.5 border-t border-slate-800/70 text-xs">
|
||||||
|
<span className="text-slate-400 text-[11px]">{atRiskCount === 1 ? 'Project flagged' : 'Projects flagged'}</span>
|
||||||
|
<span className={`text-[10px] font-medium px-1.5 py-0.5 rounded border shrink-0 ${
|
||||||
|
atRiskCount > 0
|
||||||
|
? 'text-amber-400 bg-amber-500/10 border-amber-500/20'
|
||||||
|
: 'text-emerald-400 bg-emerald-500/10 border-emerald-500/20'
|
||||||
|
}`}>
|
||||||
|
{atRiskCount > 0 ? 'Requires PM Action' : 'Nominal'}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div className="p-1.5 rounded-lg bg-amber-500/10 text-amber-400 border border-amber-500/20 group-hover:scale-110 transition">
|
|
||||||
<AlertTriangle className="w-3.5 h-3.5" />
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div className="mt-3">
|
|
||||||
<p className="text-2xl font-bold text-amber-400 tracking-tight">{atRiskCount}</p>
|
|
||||||
<p className="text-[10px] text-rose-400 font-mono mt-1">Requires PM action</p>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Card 6: Beneficiaries Reached */}
|
{/* Card 8: Beneficiaries Reached */}
|
||||||
<div className="bg-[#0f1523]/80 p-5 rounded-2xl border border-slate-800/80 hover:border-indigo-500/30 transition-all flex flex-col justify-between shadow-md group backdrop-blur-sm">
|
<div className="bg-[#0f1523]/80 p-5 rounded-2xl border border-slate-800/80 hover:border-purple-500/40 transition-all flex flex-col justify-between shadow-md group backdrop-blur-sm relative overflow-hidden">
|
||||||
<div className="flex items-center justify-between text-slate-400">
|
<div className="flex items-start justify-between gap-3">
|
||||||
<div className="flex items-center gap-1.5">
|
<div className="flex items-center gap-1.5 min-w-0 flex-1">
|
||||||
<span className="text-[11px] font-semibold uppercase tracking-wider text-slate-400">Beneficiaries</span>
|
<span className="text-xs font-semibold uppercase tracking-wider text-slate-400 truncate">
|
||||||
<HelpTooltip
|
Beneficiaries
|
||||||
title="Beneficiaries Impacted"
|
</span>
|
||||||
content="Cumulative count of direct beneficiaries reached through projects, verified against national M&E indicator targets."
|
<HelpTooltip
|
||||||
size="sm"
|
title="Beneficiaries Impacted"
|
||||||
placement="top"
|
content="Cumulative count of direct beneficiaries reached through projects, verified against national M&E indicator targets."
|
||||||
/>
|
size="sm"
|
||||||
|
placement="top"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className="shrink-0 p-2 rounded-xl bg-purple-500/10 text-purple-400 border border-purple-500/20 group-hover:scale-105 transition-transform">
|
||||||
|
<Users className="w-4 h-4" />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="mt-3">
|
||||||
|
<p className="text-2xl sm:text-3xl font-bold text-white tracking-tight">
|
||||||
|
{beneficiariesReached >= 1000 ? `${(beneficiariesReached / 1000).toFixed(1)}k` : beneficiariesReached}
|
||||||
|
</p>
|
||||||
|
<div className="flex items-center justify-between mt-3 pt-2.5 border-t border-slate-800/70 text-xs">
|
||||||
|
<span className="text-slate-400 text-[11px]">Directly impacted</span>
|
||||||
|
<span className="text-[10px] font-medium text-purple-400 bg-purple-500/10 px-1.5 py-0.5 rounded border border-purple-500/20 shrink-0">
|
||||||
|
M&E Verified
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div className="p-1.5 rounded-lg bg-indigo-500/10 text-indigo-400 border border-indigo-500/20 group-hover:scale-110 transition">
|
|
||||||
<Users className="w-3.5 h-3.5" />
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div className="mt-3">
|
|
||||||
<p className="text-2xl font-bold text-white tracking-tight">
|
|
||||||
{beneficiariesReached >= 1000 ? `${(beneficiariesReached / 1000).toFixed(0)}k` : beneficiariesReached}
|
|
||||||
</p>
|
|
||||||
<p className="text-[10px] text-slate-400 font-mono mt-1">Directly impacted</p>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Card 7: 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">
|
|
||||||
<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">Budget At Risk</span>
|
|
||||||
<HelpTooltip
|
|
||||||
title="Total Budgeted Capital At Risk"
|
|
||||||
content="Aggregated approved budget across all projects currently flagged as noncompliant or carrying unmitigated high risks."
|
|
||||||
size="sm"
|
|
||||||
placement="top"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
<div className="p-1.5 rounded-lg bg-rose-500/10 text-rose-400 border border-rose-500/20 group-hover:scale-110 transition">
|
|
||||||
<AlertTriangle className="w-3.5 h-3.5" />
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div className="mt-3">
|
|
||||||
<p className="text-2xl font-bold text-rose-400 tracking-tight">{formatCompactZAR(totalBudgetAtRisk)}</p>
|
|
||||||
<p className="text-[10px] text-slate-400 font-mono mt-1">{formatZAR(totalBudgetAtRisk)}</p>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Card 8: 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">
|
|
||||||
<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">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="p-1.5 rounded-lg bg-emerald-500/10 text-emerald-400 border border-emerald-500/20 group-hover:scale-110 transition">
|
|
||||||
<ShieldCheck className="w-3.5 h-3.5" />
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div className="mt-3">
|
|
||||||
<p className="text-2xl font-bold text-emerald-400 tracking-tight">{complianceRate}%</p>
|
|
||||||
<p className="text-[10px] text-slate-400 font-mono mt-1">100% statutory gate</p>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -11,6 +11,7 @@ import {
|
|||||||
Edit2,
|
Edit2,
|
||||||
Mail,
|
Mail,
|
||||||
Shield,
|
Shield,
|
||||||
|
ShieldCheck,
|
||||||
Layers,
|
Layers,
|
||||||
ChevronRight,
|
ChevronRight,
|
||||||
Search,
|
Search,
|
||||||
@@ -24,9 +25,11 @@ import {
|
|||||||
UserPlus,
|
UserPlus,
|
||||||
ChevronDown,
|
ChevronDown,
|
||||||
ChevronUp,
|
ChevronUp,
|
||||||
|
CheckCircle,
|
||||||
CheckCircle2,
|
CheckCircle2,
|
||||||
Loader2,
|
Loader2,
|
||||||
Lock,
|
Lock,
|
||||||
|
AlertTriangle,
|
||||||
} from 'lucide-react';
|
} from 'lucide-react';
|
||||||
import {
|
import {
|
||||||
getOrganisations,
|
getOrganisations,
|
||||||
@@ -60,6 +63,7 @@ import {
|
|||||||
assignUserRole,
|
assignUserRole,
|
||||||
getSystemRoles,
|
getSystemRoles,
|
||||||
getCurrentUserTenantId,
|
getCurrentUserTenantId,
|
||||||
|
notifyNewUserCreated,
|
||||||
} from '../services/api';
|
} from '../services/api';
|
||||||
import { Modal } from '../components/ui/Modal';
|
import { Modal } from '../components/ui/Modal';
|
||||||
import { HelpTooltip } from '../components/ui/HelpTooltip';
|
import { HelpTooltip } from '../components/ui/HelpTooltip';
|
||||||
@@ -68,6 +72,7 @@ import { ExportButton } from '../components/ui/ExportButton';
|
|||||||
import { ExportColumn } from '../utils/exportUtils';
|
import { ExportColumn } from '../utils/exportUtils';
|
||||||
import { useAuth } from '../auth/AuthProvider';
|
import { useAuth } from '../auth/AuthProvider';
|
||||||
import { useRbac } from '../auth/rbac';
|
import { useRbac } from '../auth/rbac';
|
||||||
|
import { bumpMutationEpoch } from '../services/httpClient';
|
||||||
import { useTableSort, SortableTh } from '../utils/tableSort';
|
import { useTableSort, SortableTh } from '../utils/tableSort';
|
||||||
import { FundingInstrumentsSection, InstructionsSection } from '../components/governance/InstructionsSection';
|
import { FundingInstrumentsSection, InstructionsSection } from '../components/governance/InstructionsSection';
|
||||||
import { RulesSection } from '../components/governance/RulesSection';
|
import { RulesSection } from '../components/governance/RulesSection';
|
||||||
@@ -109,6 +114,7 @@ const organisationSchema = z.object({
|
|||||||
registrationNo: z.string().min(4, 'Registration number is mandatory'),
|
registrationNo: z.string().min(4, 'Registration number is mandatory'),
|
||||||
email: z.string().email('Enter a valid email address'),
|
email: z.string().email('Enter a valid email address'),
|
||||||
organisationUser: z.string().min(1, 'Please select a linked executive or manager user'),
|
organisationUser: z.string().min(1, 'Please select a linked executive or manager user'),
|
||||||
|
idNumber: z.string().optional(),
|
||||||
type: z.coerce.number().min(1).max(27),
|
type: z.coerce.number().min(1).max(27),
|
||||||
status: z.coerce.number().optional(),
|
status: z.coerce.number().optional(),
|
||||||
});
|
});
|
||||||
@@ -133,8 +139,8 @@ type ProgrammeFormValues = z.infer<typeof programmeSchema>;
|
|||||||
|
|
||||||
export const GovernancePage: React.FC = () => {
|
export const GovernancePage: React.FC = () => {
|
||||||
const queryClient = useQueryClient();
|
const queryClient = useQueryClient();
|
||||||
const { sub, user, claims } = useAuth();
|
const { sub, user, claims, roles, tenantId } = useAuth();
|
||||||
const { canAdd, canApprove, canExport, canApproveRecord, isMaker } = useRbac();
|
const { canAdd, canApprove, canExport, canApproveRecord, isMaker, isUnrestricted } = useRbac();
|
||||||
const currentUserId = sub || user?.id || '';
|
const currentUserId = sub || user?.id || '';
|
||||||
const { success, error: toastError } = useToast();
|
const { success, error: toastError } = useToast();
|
||||||
|
|
||||||
@@ -240,7 +246,6 @@ export const GovernancePage: React.FC = () => {
|
|||||||
const { data: usersDirectory = [] } = useQuery({
|
const { data: usersDirectory = [] } = useQuery({
|
||||||
queryKey: ['users-directory'],
|
queryKey: ['users-directory'],
|
||||||
queryFn: () => getUsers(1, 200),
|
queryFn: () => getUsers(1, 200),
|
||||||
staleTime: 5 * 60 * 1000,
|
|
||||||
});
|
});
|
||||||
|
|
||||||
const { data: systemRoles = [] } = useQuery<Role[]>({
|
const { data: systemRoles = [] } = useQuery<Role[]>({
|
||||||
@@ -251,7 +256,7 @@ export const GovernancePage: React.FC = () => {
|
|||||||
|
|
||||||
const assignableRolesForOrg = React.useMemo(() => {
|
const assignableRolesForOrg = React.useMemo(() => {
|
||||||
return systemRoles.filter(
|
return systemRoles.filter(
|
||||||
(r) => r.name.toLowerCase() !== 'admin' && r.id !== '1'
|
(r) => (r.name || r.Name || '').toLowerCase() !== 'admin' && (r.id || r.Id) !== '1'
|
||||||
);
|
);
|
||||||
}, [systemRoles]);
|
}, [systemRoles]);
|
||||||
|
|
||||||
@@ -262,6 +267,37 @@ export const GovernancePage: React.FC = () => {
|
|||||||
});
|
});
|
||||||
}, [usersDirectory]);
|
}, [usersDirectory]);
|
||||||
|
|
||||||
|
const modalExecutiveUsers = React.useMemo(() => {
|
||||||
|
const list: User[] = [...executiveAndManagerUsers];
|
||||||
|
const linkedUserId =
|
||||||
|
editingOrg?.userId ||
|
||||||
|
editingOrg?.UserId ||
|
||||||
|
'';
|
||||||
|
|
||||||
|
if (linkedUserId) {
|
||||||
|
const linkedStr = String(linkedUserId).toLowerCase();
|
||||||
|
const alreadyIncluded = list.some((u) => u.id && String(u.id).toLowerCase() === linkedStr);
|
||||||
|
if (!alreadyIncluded) {
|
||||||
|
const inDirectory = usersDirectory.find((u) => u.id && String(u.id).toLowerCase() === linkedStr);
|
||||||
|
if (inDirectory) {
|
||||||
|
list.unshift(inDirectory);
|
||||||
|
} else {
|
||||||
|
const email = getUserEmail(linkedUserId);
|
||||||
|
const hasRealEmail = email && email !== '—' && email.includes('@') && email !== String(linkedUserId);
|
||||||
|
list.unshift({
|
||||||
|
id: String(linkedUserId),
|
||||||
|
email: hasRealEmail ? email : String(linkedUserId),
|
||||||
|
role: 'Executive',
|
||||||
|
status: ActivityStatus.Active,
|
||||||
|
lastLoginAt: null,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return list;
|
||||||
|
}, [executiveAndManagerUsers, usersDirectory, editingOrg?.id, editingOrg?.userId]);
|
||||||
|
|
||||||
// Quick Add Executive User Subform state
|
// Quick Add Executive User Subform state
|
||||||
const [isQuickAddUserOpen, setIsQuickAddUserOpen] = useState(false);
|
const [isQuickAddUserOpen, setIsQuickAddUserOpen] = useState(false);
|
||||||
const [quickUserEmail, setQuickUserEmail] = useState('');
|
const [quickUserEmail, setQuickUserEmail] = useState('');
|
||||||
@@ -274,9 +310,9 @@ export const GovernancePage: React.FC = () => {
|
|||||||
|
|
||||||
React.useEffect(() => {
|
React.useEffect(() => {
|
||||||
if (!quickUserRoleId && assignableRolesForOrg.length > 0) {
|
if (!quickUserRoleId && assignableRolesForOrg.length > 0) {
|
||||||
const execRole = assignableRolesForOrg.find((r) => r.name.toLowerCase() === 'executive');
|
const execRole = assignableRolesForOrg.find((r) => (r.name || r.Name || '').toLowerCase() === 'executive');
|
||||||
if (execRole) setQuickUserRoleId(execRole.id);
|
if (execRole) setQuickUserRoleId(execRole.id || execRole.Id || '');
|
||||||
else setQuickUserRoleId(assignableRolesForOrg[0].id);
|
else setQuickUserRoleId(assignableRolesForOrg[0].id || assignableRolesForOrg[0].Id || '');
|
||||||
}
|
}
|
||||||
}, [assignableRolesForOrg, quickUserRoleId]);
|
}, [assignableRolesForOrg, quickUserRoleId]);
|
||||||
|
|
||||||
@@ -334,6 +370,19 @@ export const GovernancePage: React.FC = () => {
|
|||||||
orgForm.setValue('organisationUser', createdUser.id, { shouldValidate: true });
|
orgForm.setValue('organisationUser', createdUser.id, { shouldValidate: true });
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// RULE 3: Notify created Executive/Manager user via InApp and Email (directs to Console)
|
||||||
|
if (createdUser) {
|
||||||
|
await notifyNewUserCreated({
|
||||||
|
userId: createdUser.id,
|
||||||
|
email: quickUserEmail,
|
||||||
|
isPartner: false,
|
||||||
|
roleName: 'Executive',
|
||||||
|
tenantId,
|
||||||
|
}).catch(console.warn);
|
||||||
|
queryClient.invalidateQueries({ queryKey: ['inapp-notifications'] });
|
||||||
|
queryClient.invalidateQueries({ queryKey: ['notifications'] });
|
||||||
|
}
|
||||||
|
|
||||||
success('Executive User Created', `User ${quickUserEmail} successfully created and linked.`);
|
success('Executive User Created', `User ${quickUserEmail} successfully created and linked.`);
|
||||||
setQuickUserEmail('');
|
setQuickUserEmail('');
|
||||||
setQuickUserName('');
|
setQuickUserName('');
|
||||||
@@ -546,11 +595,23 @@ export const GovernancePage: React.FC = () => {
|
|||||||
registrationNo: '',
|
registrationNo: '',
|
||||||
email: '',
|
email: '',
|
||||||
organisationUser: '',
|
organisationUser: '',
|
||||||
|
idNumber: '',
|
||||||
type: OrganisationType.Enterprise,
|
type: OrganisationType.Enterprise,
|
||||||
status: ActivityStatus.Active,
|
status: ActivityStatus.Inactive,
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
|
const watchedOrgUser = orgForm.watch('organisationUser');
|
||||||
|
const selectedExecutiveUser = React.useMemo(() => {
|
||||||
|
if (!watchedOrgUser) return null;
|
||||||
|
const str = String(watchedOrgUser).toLowerCase();
|
||||||
|
return (
|
||||||
|
usersDirectory.find((u) => u.id && String(u.id).toLowerCase() === str) ||
|
||||||
|
modalExecutiveUsers.find((u) => u.id && String(u.id).toLowerCase() === str) ||
|
||||||
|
null
|
||||||
|
);
|
||||||
|
}, [watchedOrgUser, usersDirectory, modalExecutiveUsers]);
|
||||||
|
|
||||||
const portfolioForm = useForm<PortfolioFormValues>({
|
const portfolioForm = useForm<PortfolioFormValues>({
|
||||||
resolver: zodResolver(portfolioSchema),
|
resolver: zodResolver(portfolioSchema),
|
||||||
defaultValues: {
|
defaultValues: {
|
||||||
@@ -566,7 +627,7 @@ export const GovernancePage: React.FC = () => {
|
|||||||
defaultValues: {
|
defaultValues: {
|
||||||
name: '',
|
name: '',
|
||||||
portfolioId: portfolios[0]?.id ? Number(portfolios[0].id) : 201,
|
portfolioId: portfolios[0]?.id ? Number(portfolios[0].id) : 201,
|
||||||
status: ApprovalStatus.Approved,
|
status: ApprovalStatus.Pending,
|
||||||
ownerEmail: effectiveUserEmail,
|
ownerEmail: effectiveUserEmail,
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
@@ -575,11 +636,20 @@ export const GovernancePage: React.FC = () => {
|
|||||||
const orgMutation = useMutation({
|
const orgMutation = useMutation({
|
||||||
mutationFn: (data: OrganisationFormValues) => {
|
mutationFn: (data: OrganisationFormValues) => {
|
||||||
if (editingOrg) {
|
if (editingOrg) {
|
||||||
|
if (!data.idNumber || !data.idNumber.trim()) {
|
||||||
|
orgForm.setError('idNumber', {
|
||||||
|
type: 'manual',
|
||||||
|
message: 'Government ID number is required to assign this executive user.',
|
||||||
|
});
|
||||||
|
throw new Error('Executive user ID number is required when updating an organisation.');
|
||||||
|
}
|
||||||
|
|
||||||
return updateOrganisation({
|
return updateOrganisation({
|
||||||
organisationId: editingOrg.id,
|
organisationId: editingOrg.id,
|
||||||
name: data.name,
|
name: data.name,
|
||||||
registrationNo: data.registrationNo,
|
registrationNo: data.registrationNo,
|
||||||
email: data.email,
|
email: data.email,
|
||||||
|
idNumber: data.idNumber.trim(),
|
||||||
organisationUser: data.organisationUser,
|
organisationUser: data.organisationUser,
|
||||||
type: Number(data.type),
|
type: Number(data.type),
|
||||||
status: data.status !== undefined ? Number(data.status) : ActivityStatus.Active,
|
status: data.status !== undefined ? Number(data.status) : ActivityStatus.Active,
|
||||||
@@ -589,11 +659,13 @@ export const GovernancePage: React.FC = () => {
|
|||||||
name: data.name,
|
name: data.name,
|
||||||
registrationNo: data.registrationNo,
|
registrationNo: data.registrationNo,
|
||||||
email: data.email,
|
email: data.email,
|
||||||
|
idNumber: data.idNumber?.trim() || null,
|
||||||
organisationUser: data.organisationUser,
|
organisationUser: data.organisationUser,
|
||||||
type: Number(data.type),
|
type: Number(data.type),
|
||||||
});
|
});
|
||||||
},
|
},
|
||||||
onSuccess: () => {
|
onSuccess: () => {
|
||||||
|
bumpMutationEpoch();
|
||||||
queryClient.invalidateQueries({ queryKey: ['organisations'] });
|
queryClient.invalidateQueries({ queryKey: ['organisations'] });
|
||||||
success(editingOrg ? 'Organisation updated' : 'Organisation registered successfully');
|
success(editingOrg ? 'Organisation updated' : 'Organisation registered successfully');
|
||||||
setIsOrgModalOpen(false);
|
setIsOrgModalOpen(false);
|
||||||
@@ -625,6 +697,7 @@ export const GovernancePage: React.FC = () => {
|
|||||||
});
|
});
|
||||||
},
|
},
|
||||||
onSuccess: () => {
|
onSuccess: () => {
|
||||||
|
bumpMutationEpoch();
|
||||||
queryClient.invalidateQueries({ queryKey: ['portfolios'] });
|
queryClient.invalidateQueries({ queryKey: ['portfolios'] });
|
||||||
success(editingPortfolio ? 'Portfolio updated successfully' : 'Portfolio created successfully');
|
success(editingPortfolio ? 'Portfolio updated successfully' : 'Portfolio created successfully');
|
||||||
setIsPortfolioModalOpen(false);
|
setIsPortfolioModalOpen(false);
|
||||||
@@ -636,15 +709,43 @@ export const GovernancePage: React.FC = () => {
|
|||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
|
const approveProgrammeMutation = useMutation({
|
||||||
|
mutationFn: async (pr: Programme) => {
|
||||||
|
if (isMaker(pr.createdBy)) {
|
||||||
|
throw new Error('Maker-Checker policy: Record creator cannot approve their own programme.');
|
||||||
|
}
|
||||||
|
return updateProgramme({
|
||||||
|
programmeId: pr.id,
|
||||||
|
name: pr.name || '',
|
||||||
|
portfolioId: Number(pr.portfolioId),
|
||||||
|
status: ApprovalStatus.Approved,
|
||||||
|
ownerId: pr.ownedBy,
|
||||||
|
});
|
||||||
|
},
|
||||||
|
onSuccess: () => {
|
||||||
|
bumpMutationEpoch();
|
||||||
|
queryClient.invalidateQueries({ queryKey: ['programmes'] });
|
||||||
|
queryClient.invalidateQueries({ queryKey: ['programme'] });
|
||||||
|
success('Programme Approved', 'Programme governance status successfully updated to Approved.');
|
||||||
|
},
|
||||||
|
onError: (err: unknown) => {
|
||||||
|
toastError('Programme Approval Failed', err);
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
const programmeMutation = useMutation({
|
const programmeMutation = useMutation({
|
||||||
mutationFn: (data: ProgrammeFormValues) => {
|
mutationFn: (data: ProgrammeFormValues) => {
|
||||||
const resolvedOwnerId = resolveOwnerId(data.ownerEmail, editingProgramme?.ownedBy);
|
const resolvedOwnerId = resolveOwnerId(data.ownerEmail, editingProgramme?.ownedBy);
|
||||||
if (editingProgramme) {
|
if (editingProgramme) {
|
||||||
|
const targetStatus = (data.status !== undefined ? Number(data.status) : 1) as ApprovalStatus;
|
||||||
|
if (targetStatus === ApprovalStatus.Approved && isMaker(editingProgramme.createdBy)) {
|
||||||
|
throw new Error('Maker-Checker policy violation: Record creators cannot approve their own programmes.');
|
||||||
|
}
|
||||||
return updateProgramme({
|
return updateProgramme({
|
||||||
programmeId: editingProgramme.id,
|
programmeId: editingProgramme.id,
|
||||||
name: data.name,
|
name: data.name,
|
||||||
portfolioId: Number(data.portfolioId),
|
portfolioId: Number(data.portfolioId),
|
||||||
status: data.status !== undefined ? Number(data.status) : undefined,
|
status: targetStatus,
|
||||||
ownerId: resolvedOwnerId,
|
ownerId: resolvedOwnerId,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
@@ -655,7 +756,9 @@ export const GovernancePage: React.FC = () => {
|
|||||||
});
|
});
|
||||||
},
|
},
|
||||||
onSuccess: () => {
|
onSuccess: () => {
|
||||||
|
bumpMutationEpoch();
|
||||||
queryClient.invalidateQueries({ queryKey: ['programmes'] });
|
queryClient.invalidateQueries({ queryKey: ['programmes'] });
|
||||||
|
queryClient.invalidateQueries({ queryKey: ['programme'] });
|
||||||
success(editingProgramme ? 'Programme updated successfully' : 'Programme created successfully');
|
success(editingProgramme ? 'Programme updated successfully' : 'Programme created successfully');
|
||||||
setIsProgrammeModalOpen(false);
|
setIsProgrammeModalOpen(false);
|
||||||
setEditingProgramme(null);
|
setEditingProgramme(null);
|
||||||
@@ -674,8 +777,9 @@ export const GovernancePage: React.FC = () => {
|
|||||||
registrationNo: '',
|
registrationNo: '',
|
||||||
email: '',
|
email: '',
|
||||||
organisationUser: defaultUser?.id || '',
|
organisationUser: defaultUser?.id || '',
|
||||||
|
idNumber: defaultUser?.idNumber || '',
|
||||||
type: OrganisationType.Enterprise,
|
type: OrganisationType.Enterprise,
|
||||||
status: ActivityStatus.Active,
|
status: ActivityStatus.Inactive,
|
||||||
});
|
});
|
||||||
setIsQuickAddUserOpen(false);
|
setIsQuickAddUserOpen(false);
|
||||||
setQuickUserError(null);
|
setQuickUserError(null);
|
||||||
@@ -688,26 +792,51 @@ export const GovernancePage: React.FC = () => {
|
|||||||
const targetUserId =
|
const targetUserId =
|
||||||
org.userId ||
|
org.userId ||
|
||||||
org.UserId ||
|
org.UserId ||
|
||||||
org.organisationUser ||
|
|
||||||
org.OrganisationUser ||
|
|
||||||
org.createdBy ||
|
|
||||||
'';
|
'';
|
||||||
|
|
||||||
const matchedUser = targetUserId
|
let selectedUserId = '';
|
||||||
? usersDirectory.find((u) => u.id && String(u.id).toLowerCase() === String(targetUserId).toLowerCase())
|
let matchedUser: User | undefined;
|
||||||
: null;
|
if (targetUserId) {
|
||||||
|
const targetStr = String(targetUserId).toLowerCase();
|
||||||
|
matchedUser = usersDirectory.find(
|
||||||
|
(u) =>
|
||||||
|
(u.id && String(u.id).toLowerCase() === targetStr) ||
|
||||||
|
(u.email && u.email.toLowerCase() === targetStr)
|
||||||
|
);
|
||||||
|
|
||||||
const selectedUserId = matchedUser?.id || targetUserId || '';
|
if (matchedUser) {
|
||||||
|
selectedUserId = matchedUser.id;
|
||||||
|
if (matchedUser.email) {
|
||||||
|
registerUserEmail(matchedUser.id, matchedUser.email);
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
selectedUserId = String(targetUserId);
|
||||||
|
const email = getUserEmail(targetUserId);
|
||||||
|
if (email && email !== '—' && email.includes('@')) {
|
||||||
|
registerUserEmail(selectedUserId, email);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const resolvedIdNumber = matchedUser?.idNumber || org.idNumber || '';
|
||||||
|
|
||||||
orgForm.reset({
|
orgForm.reset({
|
||||||
name: org.name || '',
|
name: org.name || '',
|
||||||
registrationNo: org.registrationNo || '',
|
registrationNo: org.registrationNo || '',
|
||||||
email: org.email || '',
|
email: org.email || '',
|
||||||
organisationUser: selectedUserId,
|
organisationUser: selectedUserId,
|
||||||
|
idNumber: resolvedIdNumber,
|
||||||
type: org.type || OrganisationType.Enterprise,
|
type: org.type || OrganisationType.Enterprise,
|
||||||
status: org.status ?? ActivityStatus.Active,
|
status: org.status ?? ActivityStatus.Active,
|
||||||
});
|
});
|
||||||
|
|
||||||
|
if (selectedUserId && !resolvedIdNumber) {
|
||||||
|
orgForm.setError('idNumber', {
|
||||||
|
type: 'manual',
|
||||||
|
message: 'This executive user has no ID number on file. An ID number is required to update the organisation.',
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
setIsQuickAddUserOpen(false);
|
setIsQuickAddUserOpen(false);
|
||||||
setQuickUserError(null);
|
setQuickUserError(null);
|
||||||
setIsOrgModalOpen(true);
|
setIsOrgModalOpen(true);
|
||||||
@@ -741,7 +870,7 @@ export const GovernancePage: React.FC = () => {
|
|||||||
programmeForm.reset({
|
programmeForm.reset({
|
||||||
name: '',
|
name: '',
|
||||||
portfolioId: portfolios[0]?.id ? Number(portfolios[0].id) : 201,
|
portfolioId: portfolios[0]?.id ? Number(portfolios[0].id) : 201,
|
||||||
status: ApprovalStatus.Approved,
|
status: ApprovalStatus.Pending,
|
||||||
ownerEmail: effectiveUserEmail,
|
ownerEmail: effectiveUserEmail,
|
||||||
});
|
});
|
||||||
setIsProgrammeModalOpen(true);
|
setIsProgrammeModalOpen(true);
|
||||||
@@ -819,6 +948,7 @@ export const GovernancePage: React.FC = () => {
|
|||||||
status: (p) => getApprovalStatusBadge(p.status).label,
|
status: (p) => getApprovalStatusBadge(p.status).label,
|
||||||
createdAt: (p) => p.createdAt,
|
createdAt: (p) => p.createdAt,
|
||||||
ownedBy: (p) => getUserEmail(p.ownedBy),
|
ownedBy: (p) => getUserEmail(p.ownedBy),
|
||||||
|
createdBy: (p) => getUserEmail(p.createdBy),
|
||||||
});
|
});
|
||||||
|
|
||||||
// Export Columns Definitions
|
// Export Columns Definitions
|
||||||
@@ -849,6 +979,7 @@ export const GovernancePage: React.FC = () => {
|
|||||||
format: (id) => programmes.filter((pr) => String(pr.portfolioId) === String(id)).length,
|
format: (id) => programmes.filter((pr) => String(pr.portfolioId) === String(id)).length,
|
||||||
},
|
},
|
||||||
{ header: 'Owner Email', key: 'ownedBy', format: (val) => getUserEmail(val) },
|
{ header: 'Owner Email', key: 'ownedBy', format: (val) => getUserEmail(val) },
|
||||||
|
{ header: 'Created By (Maker)', key: 'createdBy', format: (val) => getUserEmail(val) },
|
||||||
{ header: 'Creation Date', key: 'createdAt', format: (val) => formatDate(val) },
|
{ header: 'Creation Date', key: 'createdAt', format: (val) => formatDate(val) },
|
||||||
{ header: 'Description', key: 'description', format: (val) => val || '—' },
|
{ header: 'Description', key: 'description', format: (val) => val || '—' },
|
||||||
];
|
];
|
||||||
@@ -861,12 +992,13 @@ export const GovernancePage: React.FC = () => {
|
|||||||
key: 'portfolioId',
|
key: 'portfolioId',
|
||||||
format: (val) => {
|
format: (val) => {
|
||||||
const port = portfolios.find((p) => String(p.id) === String(val));
|
const port = portfolios.find((p) => String(p.id) === String(val));
|
||||||
return port ? port.name : `Portfolio #${val}`;
|
return port && port.name ? port.name : `Portfolio #${val}`;
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
{ header: 'Governance Status', key: 'status', format: (val) => getApprovalStatusBadge(val).label },
|
{ header: 'Governance Status', key: 'status', format: (val) => getApprovalStatusBadge(val).label },
|
||||||
{ header: 'Creation Date', key: 'createdAt', format: (val) => formatDate(val) },
|
{ header: 'Creation Date', key: 'createdAt', format: (val) => formatDate(val) },
|
||||||
{ header: 'Owner Email', key: 'ownedBy', format: (val) => getUserEmail(val) },
|
{ header: 'Owner Email', key: 'ownedBy', format: (val) => getUserEmail(val) },
|
||||||
|
{ header: 'Created By (Maker)', key: 'createdBy', format: (val) => getUserEmail(val) },
|
||||||
];
|
];
|
||||||
|
|
||||||
return (
|
return (
|
||||||
@@ -1136,6 +1268,11 @@ export const GovernancePage: React.FC = () => {
|
|||||||
<Mail className="w-3.5 h-3.5 text-slate-500" />
|
<Mail className="w-3.5 h-3.5 text-slate-500" />
|
||||||
{org.email}
|
{org.email}
|
||||||
</span>
|
</span>
|
||||||
|
{org.userId && (
|
||||||
|
<span className="text-[10px] text-blue-400/90 font-mono block mt-0.5" title={`Executive User: ${org.userId}`}>
|
||||||
|
Exec: {getUserEmail(org.userId)}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
</td>
|
</td>
|
||||||
<td className="py-3.5 px-4">
|
<td className="py-3.5 px-4">
|
||||||
<span className={`inline-flex items-center px-2.5 py-0.5 rounded-full text-[10px] font-semibold border ${statusBadge.className}`}>
|
<span className={`inline-flex items-center px-2.5 py-0.5 rounded-full text-[10px] font-semibold border ${statusBadge.className}`}>
|
||||||
@@ -1253,8 +1390,8 @@ export const GovernancePage: React.FC = () => {
|
|||||||
|
|
||||||
<div className="mt-4 pt-3 border-t border-slate-800/80 space-y-2.5">
|
<div className="mt-4 pt-3 border-t border-slate-800/80 space-y-2.5">
|
||||||
<div className="flex items-center justify-between text-xs text-slate-400">
|
<div className="flex items-center justify-between text-xs text-slate-400">
|
||||||
<span className="truncate max-w-[200px] font-medium text-slate-300" title={org ? org.name : `Org #${p.organisationId}`}>
|
<span className="truncate max-w-[200px] font-medium text-slate-300" title={org && org.name ? org.name : `Org #${p.organisationId}`}>
|
||||||
{org ? org.name : `Org #${p.organisationId}`}
|
{org && org.name ? org.name : `Org #${p.organisationId}`}
|
||||||
</span>
|
</span>
|
||||||
<span className="font-mono text-[11px] text-slate-500 shrink-0">{formatDate(p.createdAt)}</span>
|
<span className="font-mono text-[11px] text-slate-500 shrink-0">{formatDate(p.createdAt)}</span>
|
||||||
</div>
|
</div>
|
||||||
@@ -1368,19 +1505,22 @@ export const GovernancePage: React.FC = () => {
|
|||||||
<SortableTh column="ownedBy" currentColumn={progSortCol} direction={progSortDir} onSort={handleProgSort} className="py-3.5 px-4">
|
<SortableTh column="ownedBy" currentColumn={progSortCol} direction={progSortDir} onSort={handleProgSort} className="py-3.5 px-4">
|
||||||
Owner Email
|
Owner Email
|
||||||
</SortableTh>
|
</SortableTh>
|
||||||
|
<SortableTh column="createdBy" currentColumn={progSortCol} direction={progSortDir} onSort={handleProgSort} className="py-3.5 px-4">
|
||||||
|
Created By
|
||||||
|
</SortableTh>
|
||||||
<th className="py-3.5 px-5 text-right font-medium">Actions</th>
|
<th className="py-3.5 px-5 text-right font-medium">Actions</th>
|
||||||
</tr>
|
</tr>
|
||||||
</thead>
|
</thead>
|
||||||
<tbody className="divide-y divide-slate-800/60">
|
<tbody className="divide-y divide-slate-800/60">
|
||||||
{loadingProgrammes ? (
|
{loadingProgrammes ? (
|
||||||
<tr>
|
<tr>
|
||||||
<td colSpan={6} className="py-8 text-center text-slate-500">
|
<td colSpan={7} className="py-8 text-center text-slate-500">
|
||||||
Loading programmes...
|
Loading programmes...
|
||||||
</td>
|
</td>
|
||||||
</tr>
|
</tr>
|
||||||
) : sortedProgrammes.length === 0 ? (
|
) : sortedProgrammes.length === 0 ? (
|
||||||
<tr>
|
<tr>
|
||||||
<td colSpan={6} className="py-8 text-center text-slate-500">
|
<td colSpan={7} className="py-8 text-center text-slate-500">
|
||||||
No programmes found matching query.
|
No programmes found matching query.
|
||||||
</td>
|
</td>
|
||||||
</tr>
|
</tr>
|
||||||
@@ -1388,6 +1528,7 @@ export const GovernancePage: React.FC = () => {
|
|||||||
sortedProgrammes.map((pr) => {
|
sortedProgrammes.map((pr) => {
|
||||||
const port = portfolios.find((p) => String(p.id) === String(pr.portfolioId));
|
const port = portfolios.find((p) => String(p.id) === String(pr.portfolioId));
|
||||||
const statusBadge = getApprovalStatusBadge(pr.status);
|
const statusBadge = getApprovalStatusBadge(pr.status);
|
||||||
|
const isApproved = Number(pr.status) === ApprovalStatus.Approved;
|
||||||
return (
|
return (
|
||||||
<tr key={pr.id} className="hover:bg-slate-800/40 transition-colors">
|
<tr key={pr.id} className="hover:bg-slate-800/40 transition-colors">
|
||||||
<td className="py-3.5 px-5">
|
<td className="py-3.5 px-5">
|
||||||
@@ -1406,8 +1547,49 @@ export const GovernancePage: React.FC = () => {
|
|||||||
<td className="py-3.5 px-4 font-mono text-slate-300 font-medium text-[11px]">
|
<td className="py-3.5 px-4 font-mono text-slate-300 font-medium text-[11px]">
|
||||||
{getUserEmail(pr.ownedBy)}
|
{getUserEmail(pr.ownedBy)}
|
||||||
</td>
|
</td>
|
||||||
|
<td className="py-3.5 px-4 font-mono text-slate-400 text-[11px]">
|
||||||
|
{getUserEmail(pr.createdBy)}
|
||||||
|
</td>
|
||||||
<td className="py-3.5 px-5 text-right">
|
<td className="py-3.5 px-5 text-right">
|
||||||
<div className="flex items-center justify-end gap-1.5">
|
<div className="flex items-center justify-end gap-1.5 flex-wrap sm:flex-nowrap">
|
||||||
|
{isApproved ? (
|
||||||
|
<span
|
||||||
|
className="inline-flex items-center gap-1 text-xs font-semibold text-emerald-400 bg-emerald-500/10 px-2.5 py-1 rounded-lg border border-emerald-500/20 cursor-default"
|
||||||
|
title="Approved programmes cannot be modified."
|
||||||
|
>
|
||||||
|
<Lock className="w-3 h-3 text-emerald-400" />
|
||||||
|
<span>Approved</span>
|
||||||
|
</span>
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => openEditProgramme(pr)}
|
||||||
|
className="inline-flex items-center gap-1 text-xs font-semibold text-slate-200 hover:text-white bg-slate-900/80 hover:bg-slate-800 border border-slate-700/80 px-2.5 py-1 rounded-lg transition shadow-xs cursor-pointer"
|
||||||
|
title="Edit Programme"
|
||||||
|
>
|
||||||
|
<Edit2 className="w-3 h-3" />
|
||||||
|
Edit
|
||||||
|
</button>
|
||||||
|
{canApproveRecord('governance', pr.createdBy) && (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
disabled={approveProgrammeMutation.isPending}
|
||||||
|
onClick={() => approveProgrammeMutation.mutate(pr)}
|
||||||
|
className="inline-flex items-center gap-1 text-xs font-semibold text-emerald-300 hover:text-white bg-emerald-500/10 hover:bg-emerald-500/20 border border-emerald-500/30 px-2.5 py-1 rounded-lg transition shadow-xs cursor-pointer"
|
||||||
|
title="Approve Programme"
|
||||||
|
>
|
||||||
|
<CheckCircle className="w-3 h-3" />
|
||||||
|
<span>Approve</span>
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
{canApprove('governance') && isMaker(pr.createdBy) && (
|
||||||
|
<span className="text-[10px] text-amber-400 font-mono italic px-1.5 py-0.5 bg-amber-500/10 border border-amber-500/20 rounded">
|
||||||
|
Maker-Checker: Record creator cannot approve
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</>
|
||||||
|
)}
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
onClick={() => {
|
onClick={() => {
|
||||||
@@ -1444,14 +1626,6 @@ export const GovernancePage: React.FC = () => {
|
|||||||
<Scale className="w-3 h-3" />
|
<Scale className="w-3 h-3" />
|
||||||
Rules
|
Rules
|
||||||
</button>
|
</button>
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
onClick={() => openEditProgramme(pr)}
|
|
||||||
className="inline-flex items-center gap-1 text-xs font-semibold text-slate-200 hover:text-white bg-slate-900/80 hover:bg-slate-800 border border-slate-700/80 px-2.5 py-1 rounded-lg transition shadow-xs cursor-pointer"
|
|
||||||
>
|
|
||||||
<Edit2 className="w-3 h-3" />
|
|
||||||
Edit
|
|
||||||
</button>
|
|
||||||
</div>
|
</div>
|
||||||
</td>
|
</td>
|
||||||
</tr>
|
</tr>
|
||||||
@@ -1597,11 +1771,11 @@ export const GovernancePage: React.FC = () => {
|
|||||||
<div className="flex items-center justify-between mb-1">
|
<div className="flex items-center justify-between mb-1">
|
||||||
<div className="flex items-center gap-1">
|
<div className="flex items-center gap-1">
|
||||||
<label className="block text-xs font-semibold text-slate-300">
|
<label className="block text-xs font-semibold text-slate-300">
|
||||||
Linked Executive / Manager User *
|
Responsible Executive User *
|
||||||
</label>
|
</label>
|
||||||
<HelpTooltip
|
<HelpTooltip
|
||||||
title="Designated Organisation User"
|
title="Executive / Organisation User"
|
||||||
content="Only users with an Executive or Manager role can be assigned to an organisation."
|
content="The Executive user responsible for this organisation. Stored as userId in the organisation record and submitted as organisationUser to the API."
|
||||||
size="sm"
|
size="sm"
|
||||||
placement="top"
|
placement="top"
|
||||||
/>
|
/>
|
||||||
@@ -1622,11 +1796,36 @@ export const GovernancePage: React.FC = () => {
|
|||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
<select
|
<select
|
||||||
{...orgForm.register('organisationUser')}
|
{...orgForm.register('organisationUser', {
|
||||||
|
onChange: (e) => {
|
||||||
|
const selectedId = e.target.value;
|
||||||
|
if (!selectedId) {
|
||||||
|
orgForm.setValue('idNumber', '');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const str = String(selectedId).toLowerCase();
|
||||||
|
const matched =
|
||||||
|
usersDirectory.find((u) => u.id && String(u.id).toLowerCase() === str) ||
|
||||||
|
modalExecutiveUsers.find((u) => u.id && String(u.id).toLowerCase() === str);
|
||||||
|
|
||||||
|
if (matched?.idNumber && matched.idNumber.trim()) {
|
||||||
|
orgForm.setValue('idNumber', matched.idNumber.trim(), { shouldValidate: true });
|
||||||
|
orgForm.clearErrors('idNumber');
|
||||||
|
} else {
|
||||||
|
orgForm.setValue('idNumber', '', { shouldValidate: true });
|
||||||
|
if (editingOrg) {
|
||||||
|
orgForm.setError('idNumber', {
|
||||||
|
type: 'manual',
|
||||||
|
message: 'Selected user has no ID number on file. Please enter their ID number to enable executive assignment.',
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
})}
|
||||||
className="w-full px-3 py-2 text-xs border border-slate-700/80 rounded-xl focus:outline-none focus:ring-1 focus:ring-blue-500 bg-[#090d16] text-white font-medium"
|
className="w-full px-3 py-2 text-xs border border-slate-700/80 rounded-xl focus:outline-none focus:ring-1 focus:ring-blue-500 bg-[#090d16] text-white font-medium"
|
||||||
>
|
>
|
||||||
<option value="">-- Select linked executive or manager account --</option>
|
<option value="">-- Select linked executive or manager account --</option>
|
||||||
{executiveAndManagerUsers.map((u) => (
|
{modalExecutiveUsers.map((u) => (
|
||||||
<option key={u.id} value={u.id}>
|
<option key={u.id} value={u.id}>
|
||||||
{u.email || u.id} ({u.role || 'Executive'})
|
{u.email || u.id} ({u.role || 'Executive'})
|
||||||
</option>
|
</option>
|
||||||
@@ -1640,9 +1839,61 @@ export const GovernancePage: React.FC = () => {
|
|||||||
No Executive or Manager user found. Create one below to continue.
|
No Executive or Manager user found. Create one below to continue.
|
||||||
</p>
|
</p>
|
||||||
)}
|
)}
|
||||||
|
{watchedOrgUser && selectedExecutiveUser && selectedExecutiveUser.idNumber && selectedExecutiveUser.idNumber.trim() && (
|
||||||
|
<div className="flex items-center gap-1.5 text-[11px] text-emerald-400 mt-1">
|
||||||
|
<CheckCircle className="w-3.5 h-3.5 text-emerald-400 shrink-0" />
|
||||||
|
<span>ID on file: <strong className="font-mono text-emerald-300">{selectedExecutiveUser.idNumber}</strong></span>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{/* Missing ID Number Alert */}
|
||||||
|
{watchedOrgUser && selectedExecutiveUser && (!selectedExecutiveUser.idNumber || !selectedExecutiveUser.idNumber.trim()) && (
|
||||||
|
<div className="p-3 rounded-xl border border-amber-500/30 bg-amber-500/10 text-amber-300 text-xs flex items-start gap-2.5">
|
||||||
|
<AlertTriangle className="w-4 h-4 text-amber-400 shrink-0 mt-0.5" />
|
||||||
|
<div className="space-y-1">
|
||||||
|
<p className="font-semibold text-amber-200">
|
||||||
|
Missing Executive ID Number
|
||||||
|
</p>
|
||||||
|
<p className="text-[11px] text-amber-300/90 leading-relaxed">
|
||||||
|
The selected executive (<strong className="font-mono text-white">{selectedExecutiveUser.email || watchedOrgUser}</strong>) does not have a Government ID Number on file. An ID number is required to assign this executive user; otherwise the update will be ignored. Please enter their ID number below.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Executive User ID Number Input */}
|
||||||
|
<div>
|
||||||
|
<div className="flex items-center justify-between mb-1">
|
||||||
|
<div className="flex items-center gap-1">
|
||||||
|
<label className="block text-xs font-semibold text-slate-300">
|
||||||
|
Executive ID Number {editingOrg ? '*' : '(Optional)'}
|
||||||
|
</label>
|
||||||
|
<HelpTooltip
|
||||||
|
title="Executive ID Requirement"
|
||||||
|
content="Government Issued ID or Passport of the executive. Required when updating to persist the executive user link; otherwise the update will be ignored."
|
||||||
|
size="sm"
|
||||||
|
placement="top"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
{selectedExecutiveUser?.idNumber && (
|
||||||
|
<span className="text-[10px] text-emerald-400 font-mono">Sourced from profile</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
{...orgForm.register('idNumber')}
|
||||||
|
className={`w-full px-3 py-2 text-xs border rounded-xl focus:outline-none focus:ring-1 focus:ring-blue-500 text-white bg-[#090d16] font-mono ${
|
||||||
|
orgForm.formState.errors.idNumber ? 'border-rose-500 ring-1 ring-rose-500' : 'border-slate-700/80'
|
||||||
|
}`}
|
||||||
|
placeholder="Enter 13-digit ID or Passport Number"
|
||||||
|
/>
|
||||||
|
{orgForm.formState.errors.idNumber && (
|
||||||
|
<p className="text-[10px] text-rose-500 mt-1">{orgForm.formState.errors.idNumber.message}</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
{/* Inline Collapsible Subform to Create Executive User */}
|
{/* Inline Collapsible Subform to Create Executive User */}
|
||||||
{isQuickAddUserOpen && !editingOrg && (
|
{isQuickAddUserOpen && !editingOrg && (
|
||||||
<div className="p-3.5 rounded-xl border border-blue-500/30 bg-blue-950/20 space-y-3">
|
<div className="p-3.5 rounded-xl border border-blue-500/30 bg-blue-950/20 space-y-3">
|
||||||
@@ -1990,18 +2241,18 @@ export const GovernancePage: React.FC = () => {
|
|||||||
placement="top"
|
placement="top"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
{selectedProgrammeForDetail && Number(selectedProgrammeForDetail.status) === ApprovalStatus.Approved ? (
|
{editingProgramme && Number(editingProgramme.status) === ApprovalStatus.Approved ? (
|
||||||
<div
|
<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"
|
className="w-full px-3 py-2 text-xs border border-emerald-500/30 bg-emerald-500/10 rounded-xl text-emerald-300 font-semibold flex items-center gap-1.5 cursor-default"
|
||||||
title="Approved programmes are immutable."
|
title="Approved programmes cannot be modified."
|
||||||
>
|
>
|
||||||
<Lock className="w-3.5 h-3.5 text-emerald-400" />
|
<Lock className="w-3.5 h-3.5 text-emerald-400" />
|
||||||
<span>Approved (Immutable - Cannot Be Changed)</span>
|
<span>Approved</span>
|
||||||
</div>
|
</div>
|
||||||
) : (
|
) : (
|
||||||
(() => {
|
(() => {
|
||||||
const canApproveProgramme = canApproveRecord('governance', selectedProgrammeForDetail?.createdBy);
|
const canApproveProgramme = canApproveRecord('governance', editingProgramme?.createdBy);
|
||||||
const isSelf = isMaker(selectedProgrammeForDetail?.createdBy);
|
const isSelf = isMaker(editingProgramme?.createdBy);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<select
|
<select
|
||||||
@@ -2446,11 +2697,17 @@ export const GovernancePage: React.FC = () => {
|
|||||||
<span className="text-slate-300">{getOrganisationTypeLabel(orgFreshDetail?.type ?? selectedOrgForDetail?.type ?? OrganisationType.Enterprise)}</span>
|
<span className="text-slate-300">{getOrganisationTypeLabel(orgFreshDetail?.type ?? selectedOrgForDetail?.type ?? OrganisationType.Enterprise)}</span>
|
||||||
</div>
|
</div>
|
||||||
<div className="flex items-center gap-2 min-w-0">
|
<div className="flex items-center gap-2 min-w-0">
|
||||||
<span className="text-slate-400 font-semibold text-[11px] uppercase tracking-wider shrink-0">Linked User:</span>
|
<span className="text-slate-400 font-semibold text-[11px] uppercase tracking-wider shrink-0">Executive User:</span>
|
||||||
<span className="text-blue-400 font-mono text-[11px] truncate">
|
<span className="text-blue-400 font-mono text-[11px] truncate">
|
||||||
{getUserEmail(orgFreshDetail?.userId || selectedOrgForDetail?.userId) || orgFreshDetail?.userId || selectedOrgForDetail?.userId || '—'}
|
{getUserEmail(orgFreshDetail?.userId || selectedOrgForDetail?.userId) || orgFreshDetail?.userId || selectedOrgForDetail?.userId || '—'}
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
|
<div className="flex items-center gap-2 min-w-0">
|
||||||
|
<span className="text-slate-400 font-semibold text-[11px] uppercase tracking-wider shrink-0">Created By:</span>
|
||||||
|
<span className="text-slate-300 font-mono text-[11px] truncate">
|
||||||
|
{getUserEmail(orgFreshDetail?.createdBy || selectedOrgForDetail?.createdBy) || orgFreshDetail?.createdBy || selectedOrgForDetail?.createdBy || '—'}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -2703,13 +2960,38 @@ export const GovernancePage: React.FC = () => {
|
|||||||
<span className="text-slate-400 font-semibold block text-[11px] uppercase tracking-wider mb-1">Programme Title</span>
|
<span className="text-slate-400 font-semibold block text-[11px] uppercase tracking-wider mb-1">Programme Title</span>
|
||||||
<span className="text-white font-semibold text-sm block">{programmeFreshDetail?.name || selectedProgrammeForDetail?.name}</span>
|
<span className="text-white font-semibold text-sm block">{programmeFreshDetail?.name || selectedProgrammeForDetail?.name}</span>
|
||||||
</div>
|
</div>
|
||||||
<span className={`inline-flex items-center px-2.5 py-0.5 rounded-full text-[10px] font-semibold border whitespace-nowrap ${getApprovalStatusBadge(programmeFreshDetail?.status ?? selectedProgrammeForDetail?.status ?? ApprovalStatus.Approved).className}`}>
|
<div className="flex items-center gap-2">
|
||||||
{getApprovalStatusBadge(programmeFreshDetail?.status ?? selectedProgrammeForDetail?.status ?? ApprovalStatus.Approved).label}
|
<span className={`inline-flex items-center px-2.5 py-0.5 rounded-full text-[10px] font-semibold border whitespace-nowrap ${getApprovalStatusBadge(programmeFreshDetail?.status ?? selectedProgrammeForDetail?.status ?? ApprovalStatus.Approved).className}`}>
|
||||||
</span>
|
{getApprovalStatusBadge(programmeFreshDetail?.status ?? selectedProgrammeForDetail?.status ?? ApprovalStatus.Approved).label}
|
||||||
|
</span>
|
||||||
|
{selectedProgrammeForDetail && Number(programmeFreshDetail?.status ?? selectedProgrammeForDetail.status) !== ApprovalStatus.Approved && (
|
||||||
|
canApproveRecord('governance', selectedProgrammeForDetail.createdBy) ? (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
disabled={approveProgrammeMutation.isPending}
|
||||||
|
onClick={async () => {
|
||||||
|
await approveProgrammeMutation.mutateAsync(selectedProgrammeForDetail);
|
||||||
|
setIsProgrammeDetailModalOpen(false);
|
||||||
|
setSelectedProgrammeForDetail(null);
|
||||||
|
}}
|
||||||
|
className="inline-flex items-center gap-1 text-xs font-semibold text-emerald-300 hover:text-white bg-emerald-500/10 hover:bg-emerald-500/20 border border-emerald-500/30 px-2.5 py-1 rounded-lg transition shadow-xs cursor-pointer"
|
||||||
|
>
|
||||||
|
<CheckCircle className="w-3.5 h-3.5" />
|
||||||
|
<span>Approve Programme</span>
|
||||||
|
</button>
|
||||||
|
) : (
|
||||||
|
isMaker(selectedProgrammeForDetail.createdBy) && canApprove('governance') && (
|
||||||
|
<span className="text-[10px] text-amber-400 font-mono italic px-1.5 py-0.5 bg-amber-500/10 border border-amber-500/20 rounded">
|
||||||
|
Maker-Checker: Record creator cannot approve
|
||||||
|
</span>
|
||||||
|
)
|
||||||
|
)
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Core Details Grid: Owner & Creation Date */}
|
{/* Core Details Grid: Owner, Maker, Creation Date */}
|
||||||
<div className="grid grid-cols-1 sm:grid-cols-2 gap-3 text-xs">
|
<div className="grid grid-cols-1 sm:grid-cols-3 gap-3 text-xs">
|
||||||
{/* Creation Date */}
|
{/* Creation Date */}
|
||||||
<div className="p-3 bg-[#080d1a] rounded-lg border border-slate-800 flex items-center gap-2.5">
|
<div className="p-3 bg-[#080d1a] rounded-lg border border-slate-800 flex items-center gap-2.5">
|
||||||
<Calendar className="w-4 h-4 text-slate-400 shrink-0" />
|
<Calendar className="w-4 h-4 text-slate-400 shrink-0" />
|
||||||
@@ -2731,6 +3013,17 @@ export const GovernancePage: React.FC = () => {
|
|||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{/* Maker */}
|
||||||
|
<div className="p-3 bg-[#080d1a] rounded-lg border border-slate-800 flex items-center gap-2.5">
|
||||||
|
<ShieldCheck className="w-4 h-4 text-slate-400 shrink-0" />
|
||||||
|
<div className="min-w-0 flex-1">
|
||||||
|
<span className="text-slate-500 font-medium block text-[10px] uppercase tracking-wider">Created By (Maker)</span>
|
||||||
|
<span className="font-mono text-slate-200 text-xs truncate block" title={getUserEmail(programmeFreshDetail?.createdBy || selectedProgrammeForDetail?.createdBy)}>
|
||||||
|
{getUserEmail(programmeFreshDetail?.createdBy || selectedProgrammeForDetail?.createdBy)}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Metrics: Total Rules and Total Instructions */}
|
{/* Metrics: Total Rules and Total Instructions */}
|
||||||
|
|||||||
@@ -47,6 +47,7 @@ import { ExportButton } from '../components/ui/ExportButton';
|
|||||||
import { ExportColumn } from '../utils/exportUtils';
|
import { ExportColumn } from '../utils/exportUtils';
|
||||||
import { useTableSort, SortableTh } from '../utils/tableSort';
|
import { useTableSort, SortableTh } from '../utils/tableSort';
|
||||||
import { useRbac } from '../auth/rbac';
|
import { useRbac } from '../auth/rbac';
|
||||||
|
import { bumpMutationEpoch } from '../services/httpClient';
|
||||||
import {
|
import {
|
||||||
Award,
|
Award,
|
||||||
Budget,
|
Budget,
|
||||||
@@ -123,7 +124,7 @@ export const GrantsPage: React.FC = () => {
|
|||||||
const [isBudgetModalOpen, setIsBudgetModalOpen] = useState(false);
|
const [isBudgetModalOpen, setIsBudgetModalOpen] = useState(false);
|
||||||
const [editingBudget, setEditingBudget] = useState<Budget | null>(null);
|
const [editingBudget, setEditingBudget] = useState<Budget | null>(null);
|
||||||
const [isEditBudgetModalOpen, setIsEditBudgetModalOpen] = useState(false);
|
const [isEditBudgetModalOpen, setIsEditBudgetModalOpen] = useState(false);
|
||||||
const [selectedBudgetStatus, setSelectedBudgetStatus] = useState<ApprovalStatus>(ApprovalStatus.Approved);
|
const [selectedBudgetStatus, setSelectedBudgetStatus] = useState<ApprovalStatus>(ApprovalStatus.Pending);
|
||||||
const [isDisbursementModalOpen, setIsDisbursementModalOpen] = useState(false);
|
const [isDisbursementModalOpen, setIsDisbursementModalOpen] = useState(false);
|
||||||
const [editingDisbursement, setEditingDisbursement] = useState<Disbursement | null>(null);
|
const [editingDisbursement, setEditingDisbursement] = useState<Disbursement | null>(null);
|
||||||
|
|
||||||
@@ -233,6 +234,7 @@ export const GrantsPage: React.FC = () => {
|
|||||||
const updateAwardMutation = useMutation({
|
const updateAwardMutation = useMutation({
|
||||||
mutationFn: (data: UpdateAwardRequest) => updateAward(data),
|
mutationFn: (data: UpdateAwardRequest) => updateAward(data),
|
||||||
onSuccess: () => {
|
onSuccess: () => {
|
||||||
|
bumpMutationEpoch();
|
||||||
queryClient.invalidateQueries({ queryKey: ['awards'] });
|
queryClient.invalidateQueries({ queryKey: ['awards'] });
|
||||||
queryClient.invalidateQueries({ queryKey: ['dashboard'] });
|
queryClient.invalidateQueries({ queryKey: ['dashboard'] });
|
||||||
success('Award Updated', 'Award details saved successfully.');
|
success('Award Updated', 'Award details saved successfully.');
|
||||||
@@ -248,6 +250,7 @@ export const GrantsPage: React.FC = () => {
|
|||||||
const budgetMutation = useMutation({
|
const budgetMutation = useMutation({
|
||||||
mutationFn: (data: CreateBudgetRequest) => createBudget(data),
|
mutationFn: (data: CreateBudgetRequest) => createBudget(data),
|
||||||
onSuccess: () => {
|
onSuccess: () => {
|
||||||
|
bumpMutationEpoch();
|
||||||
queryClient.invalidateQueries({ queryKey: ['budgets'] });
|
queryClient.invalidateQueries({ queryKey: ['budgets'] });
|
||||||
success('Budget line established', 'Budget line created against project');
|
success('Budget line established', 'Budget line created against project');
|
||||||
setIsBudgetModalOpen(false);
|
setIsBudgetModalOpen(false);
|
||||||
@@ -261,6 +264,7 @@ export const GrantsPage: React.FC = () => {
|
|||||||
const updateBudgetMutation = useMutation({
|
const updateBudgetMutation = useMutation({
|
||||||
mutationFn: (data: UpdateBudgetRequest) => updateBudget(data),
|
mutationFn: (data: UpdateBudgetRequest) => updateBudget(data),
|
||||||
onSuccess: () => {
|
onSuccess: () => {
|
||||||
|
bumpMutationEpoch();
|
||||||
queryClient.invalidateQueries({ queryKey: ['budgets'] });
|
queryClient.invalidateQueries({ queryKey: ['budgets'] });
|
||||||
success('Budget Updated', 'Budget compliance and approval status updated.');
|
success('Budget Updated', 'Budget compliance and approval status updated.');
|
||||||
setIsEditBudgetModalOpen(false);
|
setIsEditBudgetModalOpen(false);
|
||||||
@@ -287,6 +291,7 @@ export const GrantsPage: React.FC = () => {
|
|||||||
});
|
});
|
||||||
},
|
},
|
||||||
onSuccess: () => {
|
onSuccess: () => {
|
||||||
|
bumpMutationEpoch();
|
||||||
queryClient.invalidateQueries({ queryKey: ['disbursements'] });
|
queryClient.invalidateQueries({ queryKey: ['disbursements'] });
|
||||||
success(
|
success(
|
||||||
editingDisbursement ? 'Disbursement Updated' : 'Disbursement Scheduled',
|
editingDisbursement ? 'Disbursement Updated' : 'Disbursement Scheduled',
|
||||||
@@ -497,6 +502,7 @@ export const GrantsPage: React.FC = () => {
|
|||||||
const approveMutation = useMutation({
|
const approveMutation = useMutation({
|
||||||
mutationFn: (id: string | number) => approveDisbursement(id),
|
mutationFn: (id: string | number) => approveDisbursement(id),
|
||||||
onSuccess: () => {
|
onSuccess: () => {
|
||||||
|
bumpMutationEpoch();
|
||||||
queryClient.invalidateQueries({ queryKey: ['disbursements'] });
|
queryClient.invalidateQueries({ queryKey: ['disbursements'] });
|
||||||
queryClient.invalidateQueries({ queryKey: ['dashboard'] });
|
queryClient.invalidateQueries({ queryKey: ['dashboard'] });
|
||||||
success(
|
success(
|
||||||
@@ -637,8 +643,14 @@ export const GrantsPage: React.FC = () => {
|
|||||||
handleSort: handleAwardSort,
|
handleSort: handleAwardSort,
|
||||||
} = useTableSort(filteredAwards, null, null, {
|
} = useTableSort(filteredAwards, null, null, {
|
||||||
referenceNo: (a) => a.referenceNo,
|
referenceNo: (a) => a.referenceNo,
|
||||||
organisationId: (a) => a.organisationName || `Org #${a.organisationId}`,
|
organisationId: (a) => {
|
||||||
programmeId: (a) => a.programmeName || `Programme #${a.programmeId}`,
|
const org = organisations.find((o) => String(o.id) === String(a.organisationId));
|
||||||
|
return (org?.name || a.organisationName || `Organisation #${a.organisationId}`).toLowerCase();
|
||||||
|
},
|
||||||
|
programmeId: (a) => {
|
||||||
|
const prog = programmes.find((p) => String(p.id) === String(a.programmeId));
|
||||||
|
return (prog?.name || a.programmeName || `Programme #${a.programmeId}`).toLowerCase();
|
||||||
|
},
|
||||||
amount: (a) => a.amount,
|
amount: (a) => a.amount,
|
||||||
status: (a) => getApprovalStatusBadge(a.status).label,
|
status: (a) => getApprovalStatusBadge(a.status).label,
|
||||||
createdAt: (a) => a.createdAt,
|
createdAt: (a) => a.createdAt,
|
||||||
@@ -651,7 +663,10 @@ export const GrantsPage: React.FC = () => {
|
|||||||
handleSort: handleBudgetSort,
|
handleSort: handleBudgetSort,
|
||||||
} = useTableSort(filteredBudgets, null, null, {
|
} = useTableSort(filteredBudgets, null, null, {
|
||||||
name: (b) => b.name,
|
name: (b) => b.name,
|
||||||
projectId: (b) => b.projectId,
|
projectId: (b) => {
|
||||||
|
const proj = projects.find((p) => String(p.id) === String(b.projectId));
|
||||||
|
return (proj?.name || `Project #${b.projectId}`).toLowerCase();
|
||||||
|
},
|
||||||
amount: (b) => b.amount,
|
amount: (b) => b.amount,
|
||||||
createdAt: (b) => b.createdAt,
|
createdAt: (b) => b.createdAt,
|
||||||
status: (b) => getApprovalStatusBadge(b.status ?? ApprovalStatus.Approved).label,
|
status: (b) => getApprovalStatusBadge(b.status ?? ApprovalStatus.Approved).label,
|
||||||
@@ -665,8 +680,15 @@ export const GrantsPage: React.FC = () => {
|
|||||||
handleSort: handleDisbSort,
|
handleSort: handleDisbSort,
|
||||||
} = useTableSort(filteredDisbursements, null, null, {
|
} = useTableSort(filteredDisbursements, null, null, {
|
||||||
id: (d) => d.id,
|
id: (d) => d.id,
|
||||||
projectId: (d) => d.projectId,
|
projectId: (d) => {
|
||||||
milestoneId: (d) => d.milestoneId || 0,
|
const proj = projects.find((p) => String(p.id) === String(d.projectId));
|
||||||
|
return (proj?.name || `Project #${d.projectId}`).toLowerCase();
|
||||||
|
},
|
||||||
|
milestoneId: (d) => {
|
||||||
|
if (!d.milestoneId) return 'Advance Tranche';
|
||||||
|
const ms = milestones.find((m) => String(m.id) === String(d.milestoneId));
|
||||||
|
return (ms?.name || `Milestone #${d.milestoneId}`).toLowerCase();
|
||||||
|
},
|
||||||
amount: (d) => d.amount,
|
amount: (d) => d.amount,
|
||||||
paymentReference: (d) => d.paymentReference || '',
|
paymentReference: (d) => d.paymentReference || '',
|
||||||
status: (d) => getApprovalStatusBadge(d.status).label,
|
status: (d) => getApprovalStatusBadge(d.status).label,
|
||||||
@@ -826,8 +848,10 @@ export const GrantsPage: React.FC = () => {
|
|||||||
<div className="flex items-center gap-2 self-end sm:self-center">
|
<div className="flex items-center gap-2 self-end sm:self-center">
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
onClick={() => {
|
onClick={async () => {
|
||||||
refetchAwards();
|
bumpMutationEpoch();
|
||||||
|
await queryClient.invalidateQueries({ queryKey: ['awards'] });
|
||||||
|
await refetchAwards();
|
||||||
success('Refreshed', 'Awards intake list refreshed from API.');
|
success('Refreshed', 'Awards intake list refreshed from API.');
|
||||||
}}
|
}}
|
||||||
disabled={isFetchingAwards}
|
disabled={isFetchingAwards}
|
||||||
@@ -899,10 +923,28 @@ export const GrantsPage: React.FC = () => {
|
|||||||
<span className="text-[10px] text-slate-500 font-mono">System ID: #{awd.id}</span>
|
<span className="text-[10px] text-slate-500 font-mono">System ID: #{awd.id}</span>
|
||||||
</td>
|
</td>
|
||||||
<td className="py-3.5 px-4 text-slate-200 font-medium">
|
<td className="py-3.5 px-4 text-slate-200 font-medium">
|
||||||
{awd.organisationName || `Org #${awd.organisationId}`}
|
{(() => {
|
||||||
|
const org = organisations.find((o) => String(o.id) === String(awd.organisationId));
|
||||||
|
const orgName = org?.name || awd.organisationName || (awd.organisationId ? `Organisation #${awd.organisationId}` : '—');
|
||||||
|
return (
|
||||||
|
<div>
|
||||||
|
<span className="text-white block font-medium">{orgName}</span>
|
||||||
|
{awd.organisationId && <span className="text-[10px] text-slate-500 font-mono">ID: #{awd.organisationId}</span>}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
})()}
|
||||||
</td>
|
</td>
|
||||||
<td className="py-3.5 px-4 text-slate-400">
|
<td className="py-3.5 px-4 text-slate-400">
|
||||||
{awd.programmeName || `Programme #${awd.programmeId}`}
|
{(() => {
|
||||||
|
const prog = programmes.find((p) => String(p.id) === String(awd.programmeId));
|
||||||
|
const progName = prog?.name || awd.programmeName || (awd.programmeId ? `Programme #${awd.programmeId}` : '—');
|
||||||
|
return (
|
||||||
|
<div>
|
||||||
|
<span className="text-slate-200 block">{progName}</span>
|
||||||
|
{awd.programmeId && <span className="text-[10px] text-slate-500 font-mono">Programme #{awd.programmeId}</span>}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
})()}
|
||||||
</td>
|
</td>
|
||||||
<td className="py-3.5 px-4 text-right font-mono font-bold text-white text-xs">
|
<td className="py-3.5 px-4 text-right font-mono font-bold text-white text-xs">
|
||||||
{formatZAR(awd.amount)}
|
{formatZAR(awd.amount)}
|
||||||
@@ -919,10 +961,10 @@ export const GrantsPage: React.FC = () => {
|
|||||||
{Number(awd.status) === ApprovalStatus.Approved ? (
|
{Number(awd.status) === ApprovalStatus.Approved ? (
|
||||||
<span
|
<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"
|
className="inline-flex items-center gap-1 text-xs font-semibold text-emerald-400 bg-emerald-500/10 px-2.5 py-1.5 rounded-lg border border-emerald-500/20 cursor-default"
|
||||||
title="Approved award is immutable. Details cannot be modified."
|
title="Approved award details cannot be modified."
|
||||||
>
|
>
|
||||||
<Lock className="w-3 h-3 text-emerald-400" />
|
<Lock className="w-3 h-3 text-emerald-400" />
|
||||||
<span>Approved (Immutable)</span>
|
<span>Approved</span>
|
||||||
</span>
|
</span>
|
||||||
) : (
|
) : (
|
||||||
<button
|
<button
|
||||||
@@ -973,8 +1015,10 @@ export const GrantsPage: React.FC = () => {
|
|||||||
<div className="flex items-center gap-2 self-end sm:self-center">
|
<div className="flex items-center gap-2 self-end sm:self-center">
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
onClick={() => {
|
onClick={async () => {
|
||||||
refetchBudgets();
|
bumpMutationEpoch();
|
||||||
|
await queryClient.invalidateQueries({ queryKey: ['budgets'] });
|
||||||
|
await refetchBudgets();
|
||||||
success('Refreshed', 'Budget line categories refreshed from API.');
|
success('Refreshed', 'Budget line categories refreshed from API.');
|
||||||
}}
|
}}
|
||||||
disabled={isFetchingBudgets}
|
disabled={isFetchingBudgets}
|
||||||
@@ -1004,7 +1048,7 @@ export const GrantsPage: React.FC = () => {
|
|||||||
Budget Line Category
|
Budget Line Category
|
||||||
</SortableTh>
|
</SortableTh>
|
||||||
<SortableTh column="projectId" currentColumn={budgetSortCol} direction={budgetSortDir} onSort={handleBudgetSort} className="py-3.5 px-4">
|
<SortableTh column="projectId" currentColumn={budgetSortCol} direction={budgetSortDir} onSort={handleBudgetSort} className="py-3.5 px-4">
|
||||||
Project ID
|
Project
|
||||||
</SortableTh>
|
</SortableTh>
|
||||||
<SortableTh column="amount" currentColumn={budgetSortCol} direction={budgetSortDir} onSort={handleBudgetSort} align="right" className="py-3.5 px-4 text-right">
|
<SortableTh column="amount" currentColumn={budgetSortCol} direction={budgetSortDir} onSort={handleBudgetSort} align="right" className="py-3.5 px-4 text-right">
|
||||||
Approved Amount (ZAR)
|
Approved Amount (ZAR)
|
||||||
@@ -1043,7 +1087,18 @@ export const GrantsPage: React.FC = () => {
|
|||||||
<span className="font-semibold text-white block">{b.name}</span>
|
<span className="font-semibold text-white block">{b.name}</span>
|
||||||
<span className="text-[10px] text-slate-500 font-mono">ID: #{b.id}</span>
|
<span className="text-[10px] text-slate-500 font-mono">ID: #{b.id}</span>
|
||||||
</td>
|
</td>
|
||||||
<td className="py-3.5 px-4 text-slate-400 font-mono">Project #{b.projectId}</td>
|
<td className="py-3.5 px-4 text-slate-200">
|
||||||
|
{(() => {
|
||||||
|
const proj = projects.find((p) => String(p.id) === String(b.projectId));
|
||||||
|
const projName = proj?.name || `Project #${b.projectId}`;
|
||||||
|
return (
|
||||||
|
<div>
|
||||||
|
<span className="font-medium text-white block">{projName}</span>
|
||||||
|
<span className="text-[10px] text-slate-500 font-mono">ID: #{b.projectId}</span>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
})()}
|
||||||
|
</td>
|
||||||
<td className="py-3.5 px-4 text-right font-mono font-bold text-white">
|
<td className="py-3.5 px-4 text-right font-mono font-bold text-white">
|
||||||
{formatZAR(b.amount)}
|
{formatZAR(b.amount)}
|
||||||
</td>
|
</td>
|
||||||
@@ -1059,10 +1114,10 @@ export const GrantsPage: React.FC = () => {
|
|||||||
{Number(b.status) === ApprovalStatus.Approved ? (
|
{Number(b.status) === ApprovalStatus.Approved ? (
|
||||||
<span
|
<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"
|
className="inline-flex items-center gap-1 text-xs font-semibold text-emerald-400 bg-emerald-500/10 px-2.5 py-1.5 rounded-lg border border-emerald-500/20 cursor-default"
|
||||||
title="Approved budget is immutable. Status cannot be modified."
|
title="Approved budget status cannot be modified."
|
||||||
>
|
>
|
||||||
<Lock className="w-3 h-3 text-emerald-400" />
|
<Lock className="w-3 h-3 text-emerald-400" />
|
||||||
<span>Approved (Immutable)</span>
|
<span>Approved</span>
|
||||||
</span>
|
</span>
|
||||||
) : (
|
) : (
|
||||||
<>
|
<>
|
||||||
@@ -1133,8 +1188,10 @@ export const GrantsPage: React.FC = () => {
|
|||||||
<div className="flex items-center gap-2 self-end sm:self-center">
|
<div className="flex items-center gap-2 self-end sm:self-center">
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
onClick={() => {
|
onClick={async () => {
|
||||||
refetchDisbursements();
|
bumpMutationEpoch();
|
||||||
|
await queryClient.invalidateQueries({ queryKey: ['disbursements'] });
|
||||||
|
await refetchDisbursements();
|
||||||
success('Refreshed', 'Disbursement tranches refreshed from API.');
|
success('Refreshed', 'Disbursement tranches refreshed from API.');
|
||||||
}}
|
}}
|
||||||
disabled={isFetchingDisbursements}
|
disabled={isFetchingDisbursements}
|
||||||
@@ -1172,7 +1229,7 @@ export const GrantsPage: React.FC = () => {
|
|||||||
Disbursement ID
|
Disbursement ID
|
||||||
</SortableTh>
|
</SortableTh>
|
||||||
<SortableTh column="projectId" currentColumn={disbSortCol} direction={disbSortDir} onSort={handleDisbSort} className="py-3.5 px-4">
|
<SortableTh column="projectId" currentColumn={disbSortCol} direction={disbSortDir} onSort={handleDisbSort} className="py-3.5 px-4">
|
||||||
Project ID
|
Project
|
||||||
</SortableTh>
|
</SortableTh>
|
||||||
<SortableTh column="milestoneId" currentColumn={disbSortCol} direction={disbSortDir} onSort={handleDisbSort} className="py-3.5 px-4">
|
<SortableTh column="milestoneId" currentColumn={disbSortCol} direction={disbSortDir} onSort={handleDisbSort} className="py-3.5 px-4">
|
||||||
Milestone Association
|
Milestone Association
|
||||||
@@ -1211,9 +1268,32 @@ export const GrantsPage: React.FC = () => {
|
|||||||
<td className="py-3.5 px-5 font-mono font-bold text-white">
|
<td className="py-3.5 px-5 font-mono font-bold text-white">
|
||||||
DSB-{d.id}
|
DSB-{d.id}
|
||||||
</td>
|
</td>
|
||||||
<td className="py-3.5 px-4 text-slate-300 font-mono">Project #{d.projectId}</td>
|
<td className="py-3.5 px-4 text-slate-200">
|
||||||
|
{(() => {
|
||||||
|
const proj = projects.find((p) => String(p.id) === String(d.projectId));
|
||||||
|
const projName = proj?.name || `Project #${d.projectId}`;
|
||||||
|
return (
|
||||||
|
<div>
|
||||||
|
<span className="font-medium text-white block">{projName}</span>
|
||||||
|
<span className="text-[10px] text-slate-500 font-mono">Project ID: #{d.projectId}</span>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
})()}
|
||||||
|
</td>
|
||||||
<td className="py-3.5 px-4 text-slate-400 font-medium">
|
<td className="py-3.5 px-4 text-slate-400 font-medium">
|
||||||
{d.milestoneId ? `Milestone #${d.milestoneId}` : 'Initial Advance Tranche'}
|
{(() => {
|
||||||
|
if (!d.milestoneId) {
|
||||||
|
return <span className="text-slate-400 italic">Initial Advance Tranche</span>;
|
||||||
|
}
|
||||||
|
const ms = milestones.find((m) => String(m.id) === String(d.milestoneId));
|
||||||
|
const msName = ms?.name || `Milestone #${d.milestoneId}`;
|
||||||
|
return (
|
||||||
|
<div>
|
||||||
|
<span className="font-medium block">{msName}</span>
|
||||||
|
<span className="text-[10px] text-slate-500 font-mono">Milestone ID: #{d.milestoneId}</span>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
})()}
|
||||||
</td>
|
</td>
|
||||||
<td className="py-3.5 px-4 text-right font-mono font-bold text-white text-xs">
|
<td className="py-3.5 px-4 text-right font-mono font-bold text-white text-xs">
|
||||||
{formatZAR(d.amount)}
|
{formatZAR(d.amount)}
|
||||||
@@ -1549,7 +1629,14 @@ export const GrantsPage: React.FC = () => {
|
|||||||
onSubmit={editAwardForm.handleSubmit((values) => {
|
onSubmit={editAwardForm.handleSubmit((values) => {
|
||||||
if (!editingAward) return;
|
if (!editingAward) return;
|
||||||
if (Number(editingAward.status) === ApprovalStatus.Approved) {
|
if (Number(editingAward.status) === ApprovalStatus.Approved) {
|
||||||
toastError('Record Immutable', 'Approved awards cannot be modified.');
|
toastError('Record Approved', 'Approved awards cannot be modified.');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (
|
||||||
|
selectedAwardStatus === ApprovalStatus.Approved &&
|
||||||
|
!canApproveRecord('grant_financials', editingAward.createdBy)
|
||||||
|
) {
|
||||||
|
toastError('Maker-Checker Violation', 'Record creators cannot approve their own award records.');
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
updateAwardMutation.mutate({
|
updateAwardMutation.mutate({
|
||||||
@@ -1630,7 +1717,7 @@ export const GrantsPage: React.FC = () => {
|
|||||||
{Number(editingAward?.status) === ApprovalStatus.Approved ? (
|
{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">
|
<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" />
|
<Lock className="w-3.5 h-3.5 text-emerald-400" />
|
||||||
<span>Approved (Immutable - Cannot Be Changed)</span>
|
<span>Approved</span>
|
||||||
</div>
|
</div>
|
||||||
) : (
|
) : (
|
||||||
<select
|
<select
|
||||||
@@ -1640,7 +1727,21 @@ export const GrantsPage: React.FC = () => {
|
|||||||
>
|
>
|
||||||
<option value={ApprovalStatus.Pending}>Pending Approval</option>
|
<option value={ApprovalStatus.Pending}>Pending Approval</option>
|
||||||
<option value={ApprovalStatus.Review}>Under Review</option>
|
<option value={ApprovalStatus.Review}>Under Review</option>
|
||||||
<option value={ApprovalStatus.Approved}>Approved & Activated</option>
|
{(() => {
|
||||||
|
const canApproveAwd = canApproveRecord('grant_financials', editingAward?.createdBy);
|
||||||
|
const isSelf = isMaker(editingAward?.createdBy);
|
||||||
|
let reason = '';
|
||||||
|
if (!canApprove('grant_financials')) {
|
||||||
|
reason = ' (Requires Update Permission)';
|
||||||
|
} else if (isSelf) {
|
||||||
|
reason = ' (Maker-Checker: Cannot approve own record)';
|
||||||
|
}
|
||||||
|
return (
|
||||||
|
<option value={ApprovalStatus.Approved} disabled={!canApproveAwd}>
|
||||||
|
Approved & Activated {reason}
|
||||||
|
</option>
|
||||||
|
);
|
||||||
|
})()}
|
||||||
<option value={ApprovalStatus.Rejected}>Rejected</option>
|
<option value={ApprovalStatus.Rejected}>Rejected</option>
|
||||||
</select>
|
</select>
|
||||||
)}
|
)}
|
||||||
@@ -1783,7 +1884,7 @@ export const GrantsPage: React.FC = () => {
|
|||||||
{Number(editingBudget?.status) === ApprovalStatus.Approved ? (
|
{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">
|
<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" />
|
<Lock className="w-3.5 h-3.5 text-emerald-400" />
|
||||||
<span>Approved (Immutable - Status Cannot Be Changed)</span>
|
<span>Approved</span>
|
||||||
</div>
|
</div>
|
||||||
) : (
|
) : (
|
||||||
<select
|
<select
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
import React, { useState, useEffect, useMemo } from 'react';
|
import React, { useState, useEffect, useMemo } from 'react';
|
||||||
import { useSearchParams } from 'react-router-dom';
|
import { useSearchParams } from 'react-router-dom';
|
||||||
|
import { useQuery } from '@tanstack/react-query';
|
||||||
import {
|
import {
|
||||||
Inbox,
|
Inbox,
|
||||||
Send,
|
Send,
|
||||||
@@ -17,26 +18,32 @@ import {
|
|||||||
RotateCcw,
|
RotateCcw,
|
||||||
X,
|
X,
|
||||||
Smartphone,
|
Smartphone,
|
||||||
Filter,
|
ShieldAlert,
|
||||||
} from 'lucide-react';
|
} from 'lucide-react';
|
||||||
import { useInAppNotifications, DraftMessage } from '../hooks/useInAppNotifications';
|
import { useInAppNotifications, DraftMessage } from '../hooks/useInAppNotifications';
|
||||||
import { ComposeMessageModal } from '../components/notifications/ComposeMessageModal';
|
import { ComposeMessageModal } from '../components/notifications/ComposeMessageModal';
|
||||||
import { MarkdownViewer } from '../components/notifications/MarkdownViewer';
|
import { MarkdownViewer } from '../components/notifications/MarkdownViewer';
|
||||||
import { formatDate } from '../lib/formatters';
|
import { formatDate } from '../lib/formatters';
|
||||||
import { Notification, NotificationPlatform, NotificationStatus } from '../types';
|
import { Notification, NotificationPlatform, NotificationStatus, User } from '../types';
|
||||||
import { useAuth } from '../auth/AuthProvider';
|
import { useAuth } from '../auth/AuthProvider';
|
||||||
import { stripMarkdown } from '../lib/markdownUtils';
|
import { stripMarkdown } from '../lib/markdownUtils';
|
||||||
|
import { getUsers } from '../services/api';
|
||||||
|
|
||||||
type MailboxFolder = 'inbox' | 'starred' | 'sent' | 'drafts' | 'trash';
|
type MailboxFolder = 'inbox' | 'starred' | 'sent' | 'drafts' | 'trash';
|
||||||
type PlatformFilter = 'all' | 'inapp' | 'email';
|
|
||||||
|
|
||||||
export const InboxPage: React.FC = () => {
|
export const InboxPage: React.FC = () => {
|
||||||
const [searchParams, setSearchParams] = useSearchParams();
|
const [searchParams, setSearchParams] = useSearchParams();
|
||||||
const targetIdParam = searchParams.get('id');
|
const targetIdParam = searchParams.get('id');
|
||||||
|
|
||||||
const { user, claims } = useAuth();
|
const { user, claims } = useAuth();
|
||||||
|
const currentUserId = String(user?.id || claims?.sub || '').trim().toLowerCase();
|
||||||
const currentUserEmail = (user?.email || claims?.email || '') as string;
|
const currentUserEmail = (user?.email || claims?.email || '') as string;
|
||||||
|
|
||||||
|
const { data: directoryUsers = [] } = useQuery<User[]>({
|
||||||
|
queryKey: ['users-directory'],
|
||||||
|
queryFn: () => getUsers(1, 200),
|
||||||
|
});
|
||||||
|
|
||||||
const {
|
const {
|
||||||
allMailboxNotifications,
|
allMailboxNotifications,
|
||||||
inboxNotifications,
|
inboxNotifications,
|
||||||
@@ -60,8 +67,6 @@ export const InboxPage: React.FC = () => {
|
|||||||
|
|
||||||
// Active folder
|
// Active folder
|
||||||
const [activeFolder, setActiveFolder] = useState<MailboxFolder>('inbox');
|
const [activeFolder, setActiveFolder] = useState<MailboxFolder>('inbox');
|
||||||
// Platform filter
|
|
||||||
const [platformFilter, setPlatformFilter] = useState<PlatformFilter>('all');
|
|
||||||
// Search query
|
// Search query
|
||||||
const [searchTerm, setSearchTerm] = useState('');
|
const [searchTerm, setSearchTerm] = useState('');
|
||||||
// Filter for unread only in Inbox
|
// Filter for unread only in Inbox
|
||||||
@@ -81,12 +86,14 @@ export const InboxPage: React.FC = () => {
|
|||||||
// Inline quick reply state
|
// Inline quick reply state
|
||||||
const [inlineReplyText, setInlineReplyText] = useState('');
|
const [inlineReplyText, setInlineReplyText] = useState('');
|
||||||
const [inlineReplySuccess, setInlineReplySuccess] = useState(false);
|
const [inlineReplySuccess, setInlineReplySuccess] = useState(false);
|
||||||
|
// Self-reply prevention modal state
|
||||||
|
const [isSelfReplyModalOpen, setIsSelfReplyModalOpen] = useState(false);
|
||||||
|
|
||||||
// Active folder message items
|
// Active folder message items (always In-App notifications)
|
||||||
const folderMessages = useMemo(() => {
|
const folderMessages = useMemo(() => {
|
||||||
switch (activeFolder) {
|
switch (activeFolder) {
|
||||||
case 'inbox':
|
case 'inbox':
|
||||||
return allMailboxNotifications;
|
return inboxNotifications;
|
||||||
case 'starred':
|
case 'starred':
|
||||||
return starredNotifications;
|
return starredNotifications;
|
||||||
case 'sent':
|
case 'sent':
|
||||||
@@ -96,20 +103,13 @@ export const InboxPage: React.FC = () => {
|
|||||||
case 'drafts':
|
case 'drafts':
|
||||||
return [];
|
return [];
|
||||||
default:
|
default:
|
||||||
return allMailboxNotifications;
|
return inboxNotifications;
|
||||||
}
|
}
|
||||||
}, [activeFolder, allMailboxNotifications, starredNotifications, sentNotifications, trashedNotifications]);
|
}, [activeFolder, inboxNotifications, starredNotifications, sentNotifications, trashedNotifications]);
|
||||||
|
|
||||||
// Filtered by search, platform, and unread toggle
|
// Filtered by search and unread toggle
|
||||||
const filteredMessages = useMemo(() => {
|
const filteredMessages = useMemo(() => {
|
||||||
return folderMessages.filter(item => {
|
return folderMessages.filter(item => {
|
||||||
// Platform filter
|
|
||||||
if (platformFilter === 'inapp' && Number(item.platform) !== NotificationPlatform.InApp) {
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
if (platformFilter === 'email' && Number(item.platform) !== NotificationPlatform.Email) {
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
// Unread only filter
|
// Unread only filter
|
||||||
if (activeFolder === 'inbox' && showUnreadOnly) {
|
if (activeFolder === 'inbox' && showUnreadOnly) {
|
||||||
if (Number(item.status) === NotificationStatus.Read) return false;
|
if (Number(item.status) === NotificationStatus.Read) return false;
|
||||||
@@ -122,7 +122,7 @@ export const InboxPage: React.FC = () => {
|
|||||||
const dest = (item.destination || '').toLowerCase();
|
const dest = (item.destination || '').toLowerCase();
|
||||||
return subject.includes(term) || message.includes(term) || dest.includes(term);
|
return subject.includes(term) || message.includes(term) || dest.includes(term);
|
||||||
});
|
});
|
||||||
}, [folderMessages, platformFilter, activeFolder, showUnreadOnly, searchTerm]);
|
}, [folderMessages, activeFolder, showUnreadOnly, searchTerm]);
|
||||||
|
|
||||||
// Filtered drafts for Drafts folder
|
// Filtered drafts for Drafts folder
|
||||||
const filteredDrafts = useMemo(() => {
|
const filteredDrafts = useMemo(() => {
|
||||||
@@ -148,7 +148,11 @@ export const InboxPage: React.FC = () => {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
} else if (!selectedMessage && folderMessages.length > 0 && activeFolder !== 'drafts') {
|
} else if (!selectedMessage && folderMessages.length > 0 && activeFolder !== 'drafts') {
|
||||||
setSelectedMessage(folderMessages[0]);
|
const first = folderMessages[0];
|
||||||
|
setSelectedMessage(first);
|
||||||
|
if (activeFolder === 'inbox' && Number(first.status) !== NotificationStatus.Read) {
|
||||||
|
markAsRead(first.id);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}, [targetIdParam, folderMessages, activeFolder]);
|
}, [targetIdParam, folderMessages, activeFolder]);
|
||||||
|
|
||||||
@@ -173,15 +177,51 @@ export const InboxPage: React.FC = () => {
|
|||||||
setIsComposeOpen(true);
|
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
|
// Open Compose Modal for a Reply
|
||||||
const handleReplyMessage = (msg: Notification) => {
|
const handleReplyMessage = (msg: Notification) => {
|
||||||
const rawDest = msg.destination || '';
|
if (isNotificationFromSelf(msg)) {
|
||||||
let replyEmail = '';
|
setIsSelfReplyModalOpen(true);
|
||||||
if (rawDest.includes('From:')) {
|
return;
|
||||||
const fromPart = rawDest.split('From:')[1]?.trim() || '';
|
}
|
||||||
replyEmail = fromPart.replace(/[<>]/g, '').trim();
|
|
||||||
} else if (rawDest.includes('@')) {
|
const replyEmail = extractSenderEmail(msg);
|
||||||
replyEmail = rawDest.trim();
|
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 replySubject = msg.subject?.startsWith('Re:') ? msg.subject : `Re: ${msg.subject || 'Communication'}`;
|
||||||
@@ -189,9 +229,9 @@ export const InboxPage: React.FC = () => {
|
|||||||
|
|
||||||
setSelectedDraft(null);
|
setSelectedDraft(null);
|
||||||
setComposeInitialRecipient(
|
setComposeInitialRecipient(
|
||||||
replyEmail
|
replyEmail && matchedSender
|
||||||
? {
|
? {
|
||||||
id: String(msg.recipient),
|
id: String(matchedSender.id),
|
||||||
email: replyEmail,
|
email: replyEmail,
|
||||||
name: replyEmail.split('@')[0],
|
name: replyEmail.split('@')[0],
|
||||||
}
|
}
|
||||||
@@ -225,18 +265,29 @@ export const InboxPage: React.FC = () => {
|
|||||||
const handleSendInlineReply = async () => {
|
const handleSendInlineReply = async () => {
|
||||||
if (!selectedMessage || !inlineReplyText.trim()) return;
|
if (!selectedMessage || !inlineReplyText.trim()) return;
|
||||||
|
|
||||||
const rawDest = selectedMessage.destination || '';
|
if (isNotificationFromSelf(selectedMessage)) {
|
||||||
let replyEmail = '';
|
setIsSelfReplyModalOpen(true);
|
||||||
if (rawDest.includes('From:')) {
|
return;
|
||||||
replyEmail = rawDest.split('From:')[1]?.trim().replace(/[<>]/g, '') || '';
|
}
|
||||||
} else if (rawDest.includes('@')) {
|
|
||||||
replyEmail = rawDest.trim();
|
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 {
|
try {
|
||||||
await sendMessage({
|
await sendMessage({
|
||||||
recipientId: String(selectedMessage.recipient),
|
recipientId: targetRecipientId,
|
||||||
recipientEmail: replyEmail || currentUserEmail || 'team@dsi.gov.za',
|
recipientEmail: replyEmail || 'team@dsi.gov.za',
|
||||||
subject: selectedMessage.subject?.startsWith('Re:')
|
subject: selectedMessage.subject?.startsWith('Re:')
|
||||||
? selectedMessage.subject
|
? selectedMessage.subject
|
||||||
: `Re: ${selectedMessage.subject || 'Notification'}`,
|
: `Re: ${selectedMessage.subject || 'Notification'}`,
|
||||||
@@ -266,11 +317,11 @@ export const InboxPage: React.FC = () => {
|
|||||||
<div className="flex items-center gap-2">
|
<div className="flex items-center gap-2">
|
||||||
<h1 className="text-sm font-bold text-white tracking-tight">Mailbox & Communications</h1>
|
<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">
|
<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">
|
||||||
Markdown & Branded HTML Engine
|
In-App Messaging
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
<p className="text-[11px] text-slate-400">
|
<p className="text-[11px] text-slate-400">
|
||||||
In-App markdown messaging and executive branded email notifications
|
In-App notifications and internal platform communications
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -406,10 +457,14 @@ export const InboxPage: React.FC = () => {
|
|||||||
<FileText className="w-4 h-4 text-purple-400" />
|
<FileText className="w-4 h-4 text-purple-400" />
|
||||||
<span>Drafts</span>
|
<span>Drafts</span>
|
||||||
</div>
|
</div>
|
||||||
{drafts.length > 0 && (
|
{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">
|
<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}
|
{drafts.length}
|
||||||
</span>
|
</span>
|
||||||
|
) : (
|
||||||
|
<span className="text-[11px] text-slate-400 font-mono">
|
||||||
|
0
|
||||||
|
</span>
|
||||||
)}
|
)}
|
||||||
</button>
|
</button>
|
||||||
|
|
||||||
@@ -436,57 +491,6 @@ export const InboxPage: React.FC = () => {
|
|||||||
</button>
|
</button>
|
||||||
</nav>
|
</nav>
|
||||||
|
|
||||||
{/* Platform Filter Buttons */}
|
|
||||||
<div className="pt-3 border-t border-slate-800/80 space-y-2">
|
|
||||||
<span className="text-[10px] font-semibold uppercase tracking-wider text-slate-400 px-2 block">
|
|
||||||
Channel Filter
|
|
||||||
</span>
|
|
||||||
<div className="flex flex-col gap-1">
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
onClick={() => setPlatformFilter('all')}
|
|
||||||
className={`w-full flex items-center justify-between px-2.5 py-1.5 rounded-lg text-xs font-medium transition cursor-pointer ${
|
|
||||||
platformFilter === 'all'
|
|
||||||
? 'bg-slate-800 text-white font-semibold'
|
|
||||||
: 'text-slate-400 hover:text-slate-200 hover:bg-slate-900'
|
|
||||||
}`}
|
|
||||||
>
|
|
||||||
<div className="flex items-center gap-2">
|
|
||||||
<Filter className="w-3.5 h-3.5 text-blue-400" />
|
|
||||||
<span>All Channels</span>
|
|
||||||
</div>
|
|
||||||
</button>
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
onClick={() => setPlatformFilter('inapp')}
|
|
||||||
className={`w-full flex items-center justify-between px-2.5 py-1.5 rounded-lg text-xs font-medium transition cursor-pointer ${
|
|
||||||
platformFilter === 'inapp'
|
|
||||||
? 'bg-blue-600/25 text-blue-200 font-semibold border border-blue-500/30'
|
|
||||||
: 'text-slate-400 hover:text-slate-200 hover:bg-slate-900'
|
|
||||||
}`}
|
|
||||||
>
|
|
||||||
<div className="flex items-center gap-2">
|
|
||||||
<Smartphone className="w-3.5 h-3.5 text-blue-400" />
|
|
||||||
<span>In-App Markdown</span>
|
|
||||||
</div>
|
|
||||||
</button>
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
onClick={() => setPlatformFilter('email')}
|
|
||||||
className={`w-full flex items-center justify-between px-2.5 py-1.5 rounded-lg text-xs font-medium transition cursor-pointer ${
|
|
||||||
platformFilter === 'email'
|
|
||||||
? 'bg-emerald-600/25 text-emerald-200 font-semibold border border-emerald-500/30'
|
|
||||||
: 'text-slate-400 hover:text-slate-200 hover:bg-slate-900'
|
|
||||||
}`}
|
|
||||||
>
|
|
||||||
<div className="flex items-center gap-2">
|
|
||||||
<Mail className="w-3.5 h-3.5 text-emerald-400" />
|
|
||||||
<span>Email HTML</span>
|
|
||||||
</div>
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Quick Filter: Unread Toggle */}
|
{/* Quick Filter: Unread Toggle */}
|
||||||
{activeFolder === 'inbox' && (
|
{activeFolder === 'inbox' && (
|
||||||
<div className="pt-2 border-t border-slate-800/80">
|
<div className="pt-2 border-t border-slate-800/80">
|
||||||
@@ -507,10 +511,10 @@ export const InboxPage: React.FC = () => {
|
|||||||
<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="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">
|
<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 className="w-2 h-2 rounded-full bg-emerald-400" />
|
||||||
<span>Format Standards</span>
|
<span>In-App Notifications</span>
|
||||||
</div>
|
</div>
|
||||||
<p className="text-[10px] text-slate-400 leading-snug">
|
<p className="text-[10px] text-slate-400 leading-snug">
|
||||||
InApp messages are stored as Markdown. Email notifications are transformed to branded HTML.
|
Messages are delivered directly to your In-App Mailbox and Notification Bell.
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
</aside>
|
</aside>
|
||||||
@@ -571,7 +575,7 @@ export const InboxPage: React.FC = () => {
|
|||||||
>
|
>
|
||||||
<div className="flex items-center justify-between gap-1">
|
<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">
|
<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 (Markdown)
|
Draft
|
||||||
</span>
|
</span>
|
||||||
<span className="text-[10px] text-slate-400 font-mono">
|
<span className="text-[10px] text-slate-400 font-mono">
|
||||||
{formatDate(d.updatedAt)}
|
{formatDate(d.updatedAt)}
|
||||||
@@ -620,7 +624,6 @@ export const InboxPage: React.FC = () => {
|
|||||||
const isSelected = selectedMessage?.id === item.id;
|
const isSelected = selectedMessage?.id === item.id;
|
||||||
const isUnread = Number(item.status) !== NotificationStatus.Read;
|
const isUnread = Number(item.status) !== NotificationStatus.Read;
|
||||||
const isStarred = starredIds.includes(String(item.id));
|
const isStarred = starredIds.includes(String(item.id));
|
||||||
const isEmail = Number(item.platform) === NotificationPlatform.Email;
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
@@ -649,15 +652,9 @@ export const InboxPage: React.FC = () => {
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="flex items-center gap-1.5 shrink-0">
|
<div className="flex items-center gap-1.5 shrink-0">
|
||||||
{isEmail ? (
|
<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">
|
||||||
<span className="text-[9px] font-bold text-emerald-300 bg-emerald-500/15 border border-emerald-500/30 px-1.5 py-0.2 rounded font-mono">
|
In-App
|
||||||
Email
|
</span>
|
||||||
</span>
|
|
||||||
) : (
|
|
||||||
<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">
|
|
||||||
InApp
|
|
||||||
</span>
|
|
||||||
)}
|
|
||||||
|
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
@@ -672,6 +669,22 @@ export const InboxPage: React.FC = () => {
|
|||||||
>
|
>
|
||||||
<Star className={`w-3.5 h-3.5 ${isStarred ? 'fill-amber-400' : ''}`} />
|
<Star className={`w-3.5 h-3.5 ${isStarred ? 'fill-amber-400' : ''}`} />
|
||||||
</button>
|
</button>
|
||||||
|
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={e => {
|
||||||
|
e.stopPropagation();
|
||||||
|
deleteMessage(item.id);
|
||||||
|
if (selectedMessage?.id === item.id) {
|
||||||
|
setSelectedMessage(null);
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
className="p-0.5 rounded text-slate-400 hover:text-rose-400 transition cursor-pointer"
|
||||||
|
title="Delete message (Archive)"
|
||||||
|
>
|
||||||
|
<Trash2 className="w-3.5 h-3.5" />
|
||||||
|
</button>
|
||||||
|
|
||||||
<span className="text-[10px] text-slate-400 font-mono">
|
<span className="text-[10px] text-slate-400 font-mono">
|
||||||
{formatDate(item.createdAt)}
|
{formatDate(item.createdAt)}
|
||||||
</span>
|
</span>
|
||||||
@@ -707,7 +720,7 @@ export const InboxPage: React.FC = () => {
|
|||||||
<FileText className="w-12 h-12 text-slate-600 mb-3" />
|
<FileText className="w-12 h-12 text-slate-600 mb-3" />
|
||||||
<h3 className="text-sm font-bold text-white">Drafts Folder</h3>
|
<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">
|
<p className="text-xs text-slate-400 max-w-md mt-1 mb-4">
|
||||||
Click any draft on the left pane to resume composing in Markdown and dispatch your notification.
|
Click any draft on the left pane to resume composing and dispatch your notification.
|
||||||
</p>
|
</p>
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
@@ -725,7 +738,7 @@ export const InboxPage: React.FC = () => {
|
|||||||
</div>
|
</div>
|
||||||
<h3 className="text-sm font-bold text-white">No Message Selected</h3>
|
<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">
|
<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 rendered markdown and attachments.
|
Select an in-app conversation or email notice from the list to view its contents and attachments.
|
||||||
</p>
|
</p>
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
@@ -815,17 +828,10 @@ export const InboxPage: React.FC = () => {
|
|||||||
{selectedMessage.subject || 'In-App Notification'}
|
{selectedMessage.subject || 'In-App Notification'}
|
||||||
</h2>
|
</h2>
|
||||||
<div className="flex flex-wrap items-center gap-2 mt-2">
|
<div className="flex flex-wrap items-center gap-2 mt-2">
|
||||||
{Number(selectedMessage.platform) === NotificationPlatform.Email ? (
|
<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">
|
||||||
<span className="text-[10px] font-semibold text-emerald-300 bg-emerald-500/15 border border-emerald-500/30 px-2 py-0.5 rounded-md flex items-center gap-1">
|
<Smartphone className="w-3 h-3 text-blue-400" />
|
||||||
<Mail className="w-3 h-3 text-emerald-400" />
|
Channel: In-App
|
||||||
Channel: Email (Branded HTML)
|
</span>
|
||||||
</span>
|
|
||||||
) : (
|
|
||||||
<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: InApp (Markdown)
|
|
||||||
</span>
|
|
||||||
)}
|
|
||||||
|
|
||||||
<span className="text-[10px] font-mono text-slate-400 bg-slate-900 px-2 py-0.5 rounded-md border border-slate-800">
|
<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}
|
ID: #{selectedMessage.id}
|
||||||
@@ -852,7 +858,7 @@ export const InboxPage: React.FC = () => {
|
|||||||
: selectedMessage.destination || 'PFM Automated Dispatcher'}
|
: selectedMessage.destination || 'PFM Automated Dispatcher'}
|
||||||
</p>
|
</p>
|
||||||
<span className="text-[10px] text-slate-400 font-mono">
|
<span className="text-[10px] text-slate-400 font-mono">
|
||||||
({Number(selectedMessage.platform) === NotificationPlatform.Email ? 'SMTP Protocol' : 'InApp Protocol'})
|
(In-App Notification)
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
<p className="text-[11px] text-slate-400 mt-0.5">
|
<p className="text-[11px] text-slate-400 mt-0.5">
|
||||||
@@ -884,10 +890,10 @@ export const InboxPage: React.FC = () => {
|
|||||||
<div className="flex items-center justify-between">
|
<div className="flex items-center justify-between">
|
||||||
<h4 className="text-xs font-bold text-white flex items-center gap-1.5">
|
<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" />
|
<Reply className="w-3.5 h-3.5 text-blue-400" />
|
||||||
<span>Quick Markdown Reply</span>
|
<span>Quick Reply</span>
|
||||||
</h4>
|
</h4>
|
||||||
<span className="text-[10px] text-slate-400">
|
<span className="text-[10px] text-slate-400">
|
||||||
Supports Markdown formatting (e.g. **bold**, - lists, `code`)
|
Send a quick response below or open the rich text composer
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -902,8 +908,8 @@ export const InboxPage: React.FC = () => {
|
|||||||
rows={4}
|
rows={4}
|
||||||
value={inlineReplyText}
|
value={inlineReplyText}
|
||||||
onChange={e => setInlineReplyText(e.target.value)}
|
onChange={e => setInlineReplyText(e.target.value)}
|
||||||
placeholder="Type your markdown reply... (e.g. **Approved** milestone deliverable proof)"
|
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 font-mono leading-relaxed"
|
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">
|
<div className="flex items-center justify-between">
|
||||||
@@ -946,6 +952,46 @@ export const InboxPage: React.FC = () => {
|
|||||||
initialPlatform={composeInitialPlatform}
|
initialPlatform={composeInitialPlatform}
|
||||||
draftToEdit={selectedDraft}
|
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>
|
</div>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -42,6 +42,7 @@ import {
|
|||||||
recordIndicatorActual,
|
recordIndicatorActual,
|
||||||
getBudgets,
|
getBudgets,
|
||||||
getActivities,
|
getActivities,
|
||||||
|
getDisbursements,
|
||||||
} from '../services/api';
|
} from '../services/api';
|
||||||
import { Modal } from '../components/ui/Modal';
|
import { Modal } from '../components/ui/Modal';
|
||||||
import { HelpTooltip } from '../components/ui/HelpTooltip';
|
import { HelpTooltip } from '../components/ui/HelpTooltip';
|
||||||
@@ -242,6 +243,11 @@ export const ProjectsPage: React.FC = () => {
|
|||||||
queryFn: () => getBudgets(1, 100),
|
queryFn: () => getBudgets(1, 100),
|
||||||
});
|
});
|
||||||
|
|
||||||
|
const { data: allDisbursements = [] } = useQuery({
|
||||||
|
queryKey: ['disbursements'],
|
||||||
|
queryFn: () => getDisbursements(1, 200),
|
||||||
|
});
|
||||||
|
|
||||||
const { data: allProjectActivities = new Map<string | number, any[]>() } = useQuery({
|
const { data: allProjectActivities = new Map<string | number, any[]>() } = useQuery({
|
||||||
queryKey: ['allProjectActivities', projects.map((p) => p.id)],
|
queryKey: ['allProjectActivities', projects.map((p) => p.id)],
|
||||||
queryFn: async () => {
|
queryFn: async () => {
|
||||||
@@ -311,6 +317,18 @@ export const ProjectsPage: React.FC = () => {
|
|||||||
return map;
|
return map;
|
||||||
}, [allBudgets]);
|
}, [allBudgets]);
|
||||||
|
|
||||||
|
// Disbursed capital per project
|
||||||
|
const disbursedByProject = useMemo(() => {
|
||||||
|
const map = new Map<string | number, number>();
|
||||||
|
allDisbursements.forEach((d) => {
|
||||||
|
if (Number(d.status) === ApprovalStatus.Approved || Number(d.status) === 3) {
|
||||||
|
const current = map.get(d.projectId) || 0;
|
||||||
|
map.set(d.projectId, current + (Number(d.amount) || 0));
|
||||||
|
}
|
||||||
|
});
|
||||||
|
return map;
|
||||||
|
}, [allDisbursements]);
|
||||||
|
|
||||||
// Filtered Projects
|
// Filtered Projects
|
||||||
const filteredProjects = useMemo(() => {
|
const filteredProjects = useMemo(() => {
|
||||||
return projects.filter((p) => {
|
return projects.filter((p) => {
|
||||||
@@ -380,9 +398,22 @@ export const ProjectsPage: React.FC = () => {
|
|||||||
return amt > 0 ? formatZAR(amt) : 'R 0';
|
return amt > 0 ? formatZAR(amt) : 'R 0';
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
header: 'Project Variance (ZAR)',
|
||||||
|
key: 'id',
|
||||||
|
format: (_, p) => {
|
||||||
|
const approvedBudget = approvedBudgetsByProject.get(p.id) || 0;
|
||||||
|
const pActs = allProjectActivities.get(p.id) || [];
|
||||||
|
const pActsTotal = pActs.reduce((sum, a) => sum + (Number(a.amount) || 0), 0);
|
||||||
|
const pDisbursedTotal = disbursedByProject.get(p.id) || 0;
|
||||||
|
const projectValue = pActsTotal - pDisbursedTotal;
|
||||||
|
const pVariance = approvedBudget - projectValue;
|
||||||
|
return `${pVariance >= 0 ? '+' : ''}${formatZAR(pVariance)}`;
|
||||||
|
},
|
||||||
|
},
|
||||||
{ header: 'Approval Status', key: 'status', format: (val) => getApprovalStatusBadge(val).label },
|
{ header: 'Approval Status', key: 'status', format: (val) => getApprovalStatusBadge(val).label },
|
||||||
{ header: 'Created By', key: 'createdBy', format: (val) => getUserEmail(val) },
|
{ header: 'Created By', key: 'createdBy', format: (val) => getUserEmail(val) },
|
||||||
], [programmeMap, milestonesByProject, approvedBudgetsByProject]);
|
], [programmeMap, milestonesByProject, approvedBudgetsByProject, allProjectActivities, disbursedByProject]);
|
||||||
|
|
||||||
// Table Sorting
|
// Table Sorting
|
||||||
const customExtractors = {
|
const customExtractors = {
|
||||||
@@ -404,6 +435,14 @@ export const ProjectsPage: React.FC = () => {
|
|||||||
if (risk.isAtRisk) return 2;
|
if (risk.isAtRisk) return 2;
|
||||||
return 3;
|
return 3;
|
||||||
},
|
},
|
||||||
|
variance: (p: Project) => {
|
||||||
|
const approvedBudget = approvedBudgetsByProject.get(p.id) || 0;
|
||||||
|
const pActs = allProjectActivities.get(p.id) || [];
|
||||||
|
const pActsTotal = pActs.reduce((sum, a) => sum + (Number(a.amount) || 0), 0);
|
||||||
|
const pDisbursedTotal = disbursedByProject.get(p.id) || 0;
|
||||||
|
const projectValue = pActsTotal - pDisbursedTotal;
|
||||||
|
return approvedBudget - projectValue;
|
||||||
|
},
|
||||||
};
|
};
|
||||||
|
|
||||||
const { sortedData: sortedProjects, sortColumn, sortDirection, handleSort } = useTableSort(
|
const { sortedData: sortedProjects, sortColumn, sortDirection, handleSort } = useTableSort(
|
||||||
@@ -1007,17 +1046,17 @@ export const ProjectsPage: React.FC = () => {
|
|||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
</SortableTh>
|
</SortableTh>
|
||||||
<th className="py-3.5 px-4 text-right font-semibold whitespace-nowrap w-36">
|
<SortableTh column="variance" currentColumn={sortColumn} direction={sortDirection} onSort={handleSort} align="right" className="py-3.5 px-4 text-right whitespace-nowrap w-36">
|
||||||
<div className="flex items-center justify-end gap-1">
|
<div className="flex items-center justify-end gap-1">
|
||||||
<span>Project Variance</span>
|
<span>Project Variance</span>
|
||||||
<HelpTooltip
|
<HelpTooltip
|
||||||
title="Project Variance"
|
title="Project Variance"
|
||||||
content="Approved allocated budget minus scheduled milestone activity values. Positive (green) indicates within budget constraints; negative (red) indicates cost overrun."
|
content="Approved allocated budget minus net project value (milestone activity commitments minus disbursements). Positive (green) indicates remaining budget headroom; negative (red) indicates cost overrun."
|
||||||
size="sm"
|
size="sm"
|
||||||
placement="bottom"
|
placement="bottom"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
</th>
|
</SortableTh>
|
||||||
<th className="py-3.5 px-4 text-right font-semibold whitespace-nowrap w-56">Actions</th>
|
<th className="py-3.5 px-4 text-right font-semibold whitespace-nowrap w-56">Actions</th>
|
||||||
</tr>
|
</tr>
|
||||||
</thead>
|
</thead>
|
||||||
@@ -1212,7 +1251,9 @@ export const ProjectsPage: React.FC = () => {
|
|||||||
const approvedBudget = approvedBudgetsByProject.get(p.id) || 0;
|
const approvedBudget = approvedBudgetsByProject.get(p.id) || 0;
|
||||||
const pActs = allProjectActivities.get(p.id) || [];
|
const pActs = allProjectActivities.get(p.id) || [];
|
||||||
const pActsTotal = pActs.reduce((sum, a) => sum + (Number(a.amount) || 0), 0);
|
const pActsTotal = pActs.reduce((sum, a) => sum + (Number(a.amount) || 0), 0);
|
||||||
const pVariance = approvedBudget - pActsTotal;
|
const pDisbursedTotal = disbursedByProject.get(p.id) || 0;
|
||||||
|
const projectValue = pActsTotal - pDisbursedTotal;
|
||||||
|
const pVariance = approvedBudget - projectValue;
|
||||||
const isPos = pVariance >= 0;
|
const isPos = pVariance >= 0;
|
||||||
return (
|
return (
|
||||||
<td className="py-3.5 px-4 text-right whitespace-nowrap w-36 font-mono font-bold" onClick={(e) => e.stopPropagation()}>
|
<td className="py-3.5 px-4 text-right whitespace-nowrap w-36 font-mono font-bold" onClick={(e) => e.stopPropagation()}>
|
||||||
@@ -1222,7 +1263,7 @@ export const ProjectsPage: React.FC = () => {
|
|||||||
? 'text-emerald-400 bg-emerald-500/10 border-emerald-500/30'
|
? 'text-emerald-400 bg-emerald-500/10 border-emerald-500/30'
|
||||||
: 'text-rose-400 bg-rose-500/10 border-rose-500/30'
|
: 'text-rose-400 bg-rose-500/10 border-rose-500/30'
|
||||||
}`}
|
}`}
|
||||||
title={`Approved Budget: ${formatZAR(approvedBudget)} | Milestone Activities: ${formatZAR(pActsTotal)} | Variance: ${isPos ? '+' : ''}${formatZAR(pVariance)}`}
|
title={`Approved Budget: ${formatZAR(approvedBudget)} | Milestone Activities: ${formatZAR(pActsTotal)} | Disbursements: ${formatZAR(pDisbursedTotal)} | Project Value: ${formatZAR(projectValue)} | Variance: ${isPos ? '+' : ''}${formatZAR(pVariance)}`}
|
||||||
>
|
>
|
||||||
{isPos ? `+${formatZAR(pVariance)}` : `-${formatZAR(Math.abs(pVariance))}`}
|
{isPos ? `+${formatZAR(pVariance)}` : `-${formatZAR(Math.abs(pVariance))}`}
|
||||||
</span>
|
</span>
|
||||||
|
|||||||
@@ -57,9 +57,16 @@ import {
|
|||||||
getOrganisations,
|
getOrganisations,
|
||||||
getAwards,
|
getAwards,
|
||||||
getContracts,
|
getContracts,
|
||||||
|
getProjects,
|
||||||
getAuditLogs,
|
getAuditLogs,
|
||||||
getNotifications,
|
getNotifications,
|
||||||
|
notifyInviteApproved,
|
||||||
|
getCurrentUserTenantId,
|
||||||
} from '../services/api';
|
} from '../services/api';
|
||||||
|
import {
|
||||||
|
getApprovedInviteIds,
|
||||||
|
markInviteAsApprovedInStorage,
|
||||||
|
} from '../services/notificationRulesService';
|
||||||
import { Modal } from '../components/ui/Modal';
|
import { Modal } from '../components/ui/Modal';
|
||||||
import { HelpTooltip } from '../components/ui/HelpTooltip';
|
import { HelpTooltip } from '../components/ui/HelpTooltip';
|
||||||
import { useToast } from '../components/ui/Toast';
|
import { useToast } from '../components/ui/Toast';
|
||||||
@@ -73,6 +80,8 @@ import {
|
|||||||
NotificationStatus,
|
NotificationStatus,
|
||||||
NotificationPlatform,
|
NotificationPlatform,
|
||||||
ActivityStatus,
|
ActivityStatus,
|
||||||
|
ApprovalStatus,
|
||||||
|
ContractStatus,
|
||||||
User,
|
User,
|
||||||
Invite,
|
Invite,
|
||||||
InviteRequest,
|
InviteRequest,
|
||||||
@@ -104,7 +113,7 @@ export const SettingsPage: React.FC = () => {
|
|||||||
|
|
||||||
const [activeTab, setActiveTab] = useState<'users' | 'invites' | 'notifications' | 'audit'>('users');
|
const [activeTab, setActiveTab] = useState<'users' | 'invites' | 'notifications' | 'audit'>('users');
|
||||||
|
|
||||||
const { canView, canAdd, canExport } = useRbac();
|
const { canView, canAdd, canExport, canApproveRecord, isMaker } = useRbac();
|
||||||
const canViewUsers = canView('settings_users') || canView('tenant_self_service');
|
const canViewUsers = canView('settings_users') || canView('tenant_self_service');
|
||||||
const canViewInvites = canView('settings_invites');
|
const canViewInvites = canView('settings_invites');
|
||||||
const canViewNotifications = canView('settings_notifications');
|
const canViewNotifications = canView('settings_notifications');
|
||||||
@@ -146,7 +155,6 @@ export const SettingsPage: React.FC = () => {
|
|||||||
const { data: usersDirectory = [] } = useQuery({
|
const { data: usersDirectory = [] } = useQuery({
|
||||||
queryKey: ['users-directory'],
|
queryKey: ['users-directory'],
|
||||||
queryFn: () => getUsers(1, 200),
|
queryFn: () => getUsers(1, 200),
|
||||||
staleTime: 5 * 60 * 1000,
|
|
||||||
});
|
});
|
||||||
|
|
||||||
const {
|
const {
|
||||||
@@ -235,35 +243,39 @@ export const SettingsPage: React.FC = () => {
|
|||||||
const [invitedOrgId, setInvitedOrgId] = useState<string | number>('');
|
const [invitedOrgId, setInvitedOrgId] = useState<string | number>('');
|
||||||
const [inviteAwardId, setInviteAwardId] = useState<string | number>('');
|
const [inviteAwardId, setInviteAwardId] = useState<string | number>('');
|
||||||
const [inviteContractId, setInviteContractId] = useState<string | number>('');
|
const [inviteContractId, setInviteContractId] = useState<string | number>('');
|
||||||
|
const [inviteProjectId, setInviteProjectId] = useState<string | number>('');
|
||||||
const [inviteRecipientUuid, setInviteRecipientUuid] = useState('');
|
const [inviteRecipientUuid, setInviteRecipientUuid] = useState('');
|
||||||
const [inviteMessage, setInviteMessage] = useState('');
|
const [inviteMessage, setInviteMessage] = useState('');
|
||||||
const [invitePlatform, setInvitePlatform] = useState<NotificationPlatform>(NotificationPlatform.Email);
|
const [invitePlatform, setInvitePlatform] = useState<NotificationPlatform>(NotificationPlatform.Email);
|
||||||
|
const [inviteAutoApprove, setInviteAutoApprove] = useState<boolean>(true);
|
||||||
|
|
||||||
// Invites filtering & search
|
// Approved invites tracking state
|
||||||
const [inviteSearchTerm, setInviteSearchTerm] = useState('');
|
const currentTenantId = getCurrentUserTenantId();
|
||||||
const [invitePlatformFilter, setInvitePlatformFilter] = useState<'all' | number>('all');
|
const [approvedInviteIds, setApprovedInviteIds] = useState<string[]>(() =>
|
||||||
|
getApprovedInviteIds(currentTenantId)
|
||||||
|
);
|
||||||
|
|
||||||
const {
|
const handleApproveInvite = async (inv: Invite, idx: number) => {
|
||||||
data: invitesList = [],
|
if (isMaker(inv.createdBy)) {
|
||||||
isLoading: loadingInvites,
|
toastError('Maker-Checker Violation', 'You cannot approve an invitation that you created.');
|
||||||
isFetching: isFetchingInvites,
|
return;
|
||||||
refetch: refetchInvites,
|
}
|
||||||
} = useQuery({
|
const inviteKey = inv.id ? String(inv.id) : `inv_${idx}`;
|
||||||
queryKey: ['invites'],
|
markInviteAsApprovedInStorage(inviteKey, currentTenantId);
|
||||||
queryFn: () => getInvites(1, 100),
|
setApprovedInviteIds((prev) => (prev.includes(inviteKey) ? prev : [...prev, inviteKey]));
|
||||||
});
|
|
||||||
|
|
||||||
const filteredInvites = useMemo(() => {
|
await notifyInviteApproved({
|
||||||
return invitesList.filter((inv) => {
|
invite: inv,
|
||||||
const matchSearch =
|
}).catch(console.warn);
|
||||||
!inviteSearchTerm.trim() ||
|
|
||||||
(inv.recipient && inv.recipient.toLowerCase().includes(inviteSearchTerm.toLowerCase())) ||
|
queryClient.invalidateQueries({ queryKey: ['invites'] });
|
||||||
(inv.message && inv.message.toLowerCase().includes(inviteSearchTerm.toLowerCase()));
|
queryClient.invalidateQueries({ queryKey: ['inapp-notifications'] });
|
||||||
const matchPlatform =
|
queryClient.invalidateQueries({ queryKey: ['notifications'] });
|
||||||
invitePlatformFilter === 'all' || Number(inv.platform) === Number(invitePlatformFilter);
|
success(
|
||||||
return matchSearch && matchPlatform;
|
'Invitation Approved',
|
||||||
});
|
'Partner user has been notified via In-App and Email messages with link to Partner Portal (https://partner.ifri.khongisa.co.za/).'
|
||||||
}, [invitesList, inviteSearchTerm, invitePlatformFilter]);
|
);
|
||||||
|
};
|
||||||
|
|
||||||
const { data: organisations = [] } = useQuery({
|
const { data: organisations = [] } = useQuery({
|
||||||
queryKey: ['organisations'],
|
queryKey: ['organisations'],
|
||||||
@@ -280,14 +292,136 @@ export const SettingsPage: React.FC = () => {
|
|||||||
queryFn: () => getContracts(1, 50),
|
queryFn: () => getContracts(1, 50),
|
||||||
});
|
});
|
||||||
|
|
||||||
|
const { data: projects = [] } = useQuery({
|
||||||
|
queryKey: ['projects'],
|
||||||
|
queryFn: () => getProjects(1, 100),
|
||||||
|
});
|
||||||
|
|
||||||
|
// Only awards that are Approved AND have an Approved/Active contract are eligible for invitations
|
||||||
|
const eligibleAwards = useMemo(() => {
|
||||||
|
return awards.filter((aw) => {
|
||||||
|
const isApprovedAward = Number(aw.status) === ApprovalStatus.Approved;
|
||||||
|
if (!isApprovedAward) return false;
|
||||||
|
const hasApprovedContract = contracts.some(
|
||||||
|
(c) =>
|
||||||
|
String(c.awardId) === String(aw.id) &&
|
||||||
|
(Number(c.status) === ContractStatus.Active || Number(c.status) === ContractStatus.Executed)
|
||||||
|
);
|
||||||
|
return hasApprovedContract;
|
||||||
|
});
|
||||||
|
}, [awards, contracts]);
|
||||||
|
|
||||||
|
const handleSelectAward = (awardId: string | number) => {
|
||||||
|
setInviteAwardId(awardId);
|
||||||
|
const selected = eligibleAwards.find((a) => String(a.id) === String(awardId));
|
||||||
|
if (selected) {
|
||||||
|
setInvitedOrgId(selected.organisationId);
|
||||||
|
const matchingContract = contracts.find(
|
||||||
|
(c) =>
|
||||||
|
String(c.awardId) === String(selected.id) &&
|
||||||
|
(Number(c.status) === ContractStatus.Active || Number(c.status) === ContractStatus.Executed)
|
||||||
|
);
|
||||||
|
if (matchingContract) {
|
||||||
|
setInviteContractId(matchingContract.id);
|
||||||
|
}
|
||||||
|
setInviteProjectId(selected.projectId || '');
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
React.useEffect(() => {
|
||||||
|
if (isSendInviteModalOpen) {
|
||||||
|
if (eligibleAwards.length > 0) {
|
||||||
|
const currentAward =
|
||||||
|
eligibleAwards.find((a) => String(a.id) === String(inviteAwardId)) || eligibleAwards[0];
|
||||||
|
setInviteAwardId(currentAward.id);
|
||||||
|
setInvitedOrgId(currentAward.organisationId);
|
||||||
|
const matchingContract = contracts.find(
|
||||||
|
(c) =>
|
||||||
|
String(c.awardId) === String(currentAward.id) &&
|
||||||
|
(Number(c.status) === ContractStatus.Active || Number(c.status) === ContractStatus.Executed)
|
||||||
|
);
|
||||||
|
if (matchingContract) {
|
||||||
|
setInviteContractId(matchingContract.id);
|
||||||
|
}
|
||||||
|
setInviteProjectId(currentAward.projectId || '');
|
||||||
|
} else {
|
||||||
|
setInviteAwardId('');
|
||||||
|
setInvitedOrgId('');
|
||||||
|
setInviteContractId('');
|
||||||
|
setInviteProjectId('');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}, [isSendInviteModalOpen, eligibleAwards, contracts]);
|
||||||
|
|
||||||
|
// Invites filtering & search
|
||||||
|
const [inviteSearchTerm, setInviteSearchTerm] = useState('');
|
||||||
|
const [invitePlatformFilter, setInvitePlatformFilter] = useState<'all' | number>('all');
|
||||||
|
|
||||||
|
const {
|
||||||
|
data: invitesList = [],
|
||||||
|
isLoading: loadingInvites,
|
||||||
|
isFetching: isFetchingInvites,
|
||||||
|
refetch: refetchInvites,
|
||||||
|
} = useQuery({
|
||||||
|
queryKey: ['invites'],
|
||||||
|
queryFn: () => getInvites(1, 100),
|
||||||
|
});
|
||||||
|
|
||||||
|
const getStoredInvitePlatform = (inviteId?: string | number): NotificationPlatform | undefined => {
|
||||||
|
if (!inviteId) return undefined;
|
||||||
|
try {
|
||||||
|
const raw = localStorage.getItem(`pfm_invite_platform_${inviteId}`);
|
||||||
|
return raw !== null ? (Number(raw) as NotificationPlatform) : undefined;
|
||||||
|
} catch {
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const storeInvitePlatform = (inviteId: string | number, platform: NotificationPlatform) => {
|
||||||
|
try {
|
||||||
|
localStorage.setItem(`pfm_invite_platform_${inviteId}`, String(platform));
|
||||||
|
} catch {}
|
||||||
|
};
|
||||||
|
|
||||||
|
const resolveUserEmail = (userIdOrUuid?: string | null): string => {
|
||||||
|
if (!userIdOrUuid) return '—';
|
||||||
|
const u = usersDirectory.find((usr) => String(usr.id).toLowerCase() === String(userIdOrUuid).toLowerCase());
|
||||||
|
const email = u?.email || getUserEmail(userIdOrUuid);
|
||||||
|
return email && email !== '—' ? email : String(userIdOrUuid);
|
||||||
|
};
|
||||||
|
|
||||||
const sendInviteMutation = useMutation({
|
const sendInviteMutation = useMutation({
|
||||||
mutationFn: (payload: InviteRequest) => sendInvite(payload),
|
mutationFn: (payload: InviteRequest) => sendInvite(payload),
|
||||||
onSuccess: () => {
|
onSuccess: (data, variables) => {
|
||||||
success('Invitation Dispatched', 'Statutory communications invitation sent successfully.');
|
success('Invitation Dispatched', 'Statutory communications invitation sent successfully.');
|
||||||
queryClient.invalidateQueries({ queryKey: ['invites'] });
|
queryClient.invalidateQueries({ queryKey: ['invites'] });
|
||||||
setIsSendInviteModalOpen(false);
|
setIsSendInviteModalOpen(false);
|
||||||
setInviteRecipientUuid('');
|
setInviteRecipientUuid('');
|
||||||
setInviteMessage('');
|
setInviteMessage('');
|
||||||
|
|
||||||
|
const invId = (data as any)?.id ? String((data as any).id) : `inv_${Date.now()}`;
|
||||||
|
storeInvitePlatform(invId, variables.platform);
|
||||||
|
|
||||||
|
if (inviteAutoApprove) {
|
||||||
|
markInviteAsApprovedInStorage(invId, currentTenantId);
|
||||||
|
setApprovedInviteIds((prev) => (prev.includes(invId) ? prev : [...prev, invId]));
|
||||||
|
|
||||||
|
notifyInviteApproved({
|
||||||
|
invite: {
|
||||||
|
id: invId,
|
||||||
|
recipient: variables.recipient,
|
||||||
|
invitedOrganisationId: variables.invitedOrganisationId,
|
||||||
|
organisationId: variables.organisationId,
|
||||||
|
awardId: variables.awardId,
|
||||||
|
contractId: variables.contractId,
|
||||||
|
projectId: variables.projectId,
|
||||||
|
message: variables.message,
|
||||||
|
},
|
||||||
|
}).then(() => {
|
||||||
|
queryClient.invalidateQueries({ queryKey: ['inapp-notifications'] });
|
||||||
|
queryClient.invalidateQueries({ queryKey: ['notifications'] });
|
||||||
|
}).catch(console.warn);
|
||||||
|
}
|
||||||
},
|
},
|
||||||
onError: (err: unknown) => {
|
onError: (err: unknown) => {
|
||||||
toastError('Dispatch Error', err);
|
toastError('Dispatch Error', err);
|
||||||
@@ -366,6 +500,38 @@ export const SettingsPage: React.FC = () => {
|
|||||||
handleSort: handleNotifSort,
|
handleSort: handleNotifSort,
|
||||||
} = useTableSort(filteredNotifications, 'createdAt', 'desc');
|
} = useTableSort(filteredNotifications, 'createdAt', 'desc');
|
||||||
|
|
||||||
|
const resolveInvitePlatform = (inv: Invite): NotificationPlatform => {
|
||||||
|
if (inv.platform !== undefined && inv.platform !== null) {
|
||||||
|
return Number(inv.platform) as NotificationPlatform;
|
||||||
|
}
|
||||||
|
const stored = getStoredInvitePlatform(inv.id);
|
||||||
|
if (stored !== undefined) return stored;
|
||||||
|
|
||||||
|
const linkedNotif = fetchedNotifications.find(
|
||||||
|
(n) =>
|
||||||
|
(inv.recipient && n.recipient?.toLowerCase() === inv.recipient?.toLowerCase()) ||
|
||||||
|
(inv.invitedOrganisationId && String(n.organisationId) === String(inv.invitedOrganisationId))
|
||||||
|
);
|
||||||
|
if (linkedNotif?.platform !== undefined && linkedNotif.platform !== null) {
|
||||||
|
return Number(linkedNotif.platform) as NotificationPlatform;
|
||||||
|
}
|
||||||
|
return NotificationPlatform.Email;
|
||||||
|
};
|
||||||
|
|
||||||
|
const filteredInvites = useMemo(() => {
|
||||||
|
return invitesList.filter((inv) => {
|
||||||
|
const recipientEmail = resolveUserEmail(inv.recipient).toLowerCase();
|
||||||
|
const matchSearch =
|
||||||
|
!inviteSearchTerm.trim() ||
|
||||||
|
(inv.recipient && inv.recipient.toLowerCase().includes(inviteSearchTerm.toLowerCase())) ||
|
||||||
|
recipientEmail.includes(inviteSearchTerm.toLowerCase());
|
||||||
|
const resolvedPlatform = resolveInvitePlatform(inv);
|
||||||
|
const matchPlatform =
|
||||||
|
invitePlatformFilter === 'all' || Number(resolvedPlatform) === Number(invitePlatformFilter);
|
||||||
|
return matchSearch && matchPlatform;
|
||||||
|
});
|
||||||
|
}, [invitesList, inviteSearchTerm, invitePlatformFilter, fetchedNotifications, usersDirectory]);
|
||||||
|
|
||||||
// ==========================================
|
// ==========================================
|
||||||
// 4. Audit Log Ledger State
|
// 4. Audit Log Ledger State
|
||||||
// ==========================================
|
// ==========================================
|
||||||
@@ -434,9 +600,22 @@ export const SettingsPage: React.FC = () => {
|
|||||||
{
|
{
|
||||||
header: 'Changes / Arguments',
|
header: 'Changes / Arguments',
|
||||||
key: 'changes',
|
key: 'changes',
|
||||||
format: (val) => (Array.isArray(val) ? val.join('; ') : val || '—'),
|
format: (val) => {
|
||||||
|
if (!val) return '—';
|
||||||
|
let str = Array.isArray(val) ? val.join('; ') : typeof val === 'object' ? JSON.stringify(val) : String(val);
|
||||||
|
str = str.replace(/\r\n|\r|\n/g, ' ').replace(/\s{2,}/g, ' ').trim();
|
||||||
|
return str.length > 250 ? `${str.slice(0, 247)}...` : str;
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
header: 'Error Details',
|
||||||
|
key: 'error',
|
||||||
|
format: (val) => {
|
||||||
|
if (!val) return '—';
|
||||||
|
let str = String(val).replace(/\r\n|\r|\n/g, ' ').replace(/\s{2,}/g, ' ').trim();
|
||||||
|
return str.length > 250 ? `${str.slice(0, 247)}...` : str;
|
||||||
|
},
|
||||||
},
|
},
|
||||||
{ header: 'Error Details', key: 'error', format: (val) => val || '—' },
|
|
||||||
];
|
];
|
||||||
|
|
||||||
const uniqueAuditEntities = Array.from(
|
const uniqueAuditEntities = Array.from(
|
||||||
@@ -506,13 +685,25 @@ export const SettingsPage: React.FC = () => {
|
|||||||
];
|
];
|
||||||
|
|
||||||
const inviteExportColumns: ExportColumn<Invite>[] = [
|
const inviteExportColumns: ExportColumn<Invite>[] = [
|
||||||
{ header: 'Recipient (UUID)', key: 'recipient' },
|
{
|
||||||
{ header: 'Channel / Platform', key: 'platform', format: (val) => getNotificationPlatformLabel(val) },
|
header: 'Recipient Email',
|
||||||
{ header: 'Invitation Message', key: 'message' },
|
key: 'recipient',
|
||||||
|
format: (val) => resolveUserEmail(val),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
header: 'Channel / Platform',
|
||||||
|
key: 'platform',
|
||||||
|
format: (_val, inv) => getNotificationPlatformLabel(resolveInvitePlatform(inv)),
|
||||||
|
},
|
||||||
{
|
{
|
||||||
header: 'Target Organisation / Grant',
|
header: 'Target Organisation / Grant',
|
||||||
key: 'invitedOrganisationId',
|
key: 'invitedOrganisationId',
|
||||||
format: (val, inv) => `Org #${val || inv.organisationId || '—'} (Award #${inv.awardId || '—'})`,
|
format: (val, inv) => {
|
||||||
|
const orgId = val || inv.organisationId;
|
||||||
|
const org = organisations.find((o) => String(o.id) === String(orgId));
|
||||||
|
const orgName = org?.name || `Organisation #${orgId || '—'}`;
|
||||||
|
return `${orgName} • Award #${inv.awardId || '—'}`;
|
||||||
|
},
|
||||||
},
|
},
|
||||||
{ header: 'Date Dispatched', key: 'createdAt', format: (val) => (val ? formatDate(val) : '—') },
|
{ header: 'Date Dispatched', key: 'createdAt', format: (val) => (val ? formatDate(val) : '—') },
|
||||||
];
|
];
|
||||||
@@ -538,7 +729,15 @@ export const SettingsPage: React.FC = () => {
|
|||||||
sortColumn: inviteSortCol,
|
sortColumn: inviteSortCol,
|
||||||
sortDirection: inviteSortDir,
|
sortDirection: inviteSortDir,
|
||||||
handleSort: handleInviteSort,
|
handleSort: handleInviteSort,
|
||||||
} = useTableSort(filteredInvites, 'createdAt', 'desc');
|
} = useTableSort(filteredInvites, 'createdAt', 'desc', {
|
||||||
|
recipient: (inv: Invite) => resolveUserEmail(inv.recipient),
|
||||||
|
platform: (inv: Invite) => getNotificationPlatformLabel(resolveInvitePlatform(inv)),
|
||||||
|
invitedOrganisationId: (inv: Invite) => {
|
||||||
|
const orgId = inv.invitedOrganisationId || inv.organisationId;
|
||||||
|
const org = organisations.find((o) => String(o.id) === String(orgId));
|
||||||
|
return org?.name || `Organisation #${orgId || ''}`;
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
const getActionBadge = (action?: string | null) => {
|
const getActionBadge = (action?: string | null) => {
|
||||||
const act = (action || 'GENERAL_EVENT').toUpperCase();
|
const act = (action || 'GENERAL_EVENT').toUpperCase();
|
||||||
@@ -1125,61 +1324,125 @@ export const SettingsPage: React.FC = () => {
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div className="overflow-x-auto min-w-0 w-full">
|
<div className="overflow-x-auto min-w-0 w-full">
|
||||||
<table className="w-full text-left text-xs border-collapse min-w-[850px]">
|
<table className="w-full text-left text-xs border-collapse min-w-[950px]">
|
||||||
<thead>
|
<thead>
|
||||||
<tr className="bg-slate-900/60 border-b border-slate-800/80 text-slate-400 uppercase tracking-wider font-semibold">
|
<tr className="bg-slate-900/60 border-b border-slate-800/80 text-slate-400 uppercase tracking-wider font-semibold">
|
||||||
<SortableTh column="recipient" currentColumn={inviteSortCol} direction={inviteSortDir} onSort={handleInviteSort} className="py-3.5 px-5">
|
<SortableTh column="recipient" currentColumn={inviteSortCol} direction={inviteSortDir} onSort={handleInviteSort} className="py-3.5 px-5">
|
||||||
Recipient (UUID)
|
Recipient Email
|
||||||
</SortableTh>
|
</SortableTh>
|
||||||
<SortableTh column="platform" currentColumn={inviteSortCol} direction={inviteSortDir} onSort={handleInviteSort} className="py-3.5 px-4">
|
<SortableTh column="platform" currentColumn={inviteSortCol} direction={inviteSortDir} onSort={handleInviteSort} className="py-3.5 px-4">
|
||||||
Channel
|
Channel
|
||||||
</SortableTh>
|
</SortableTh>
|
||||||
<SortableTh column="message" currentColumn={inviteSortCol} direction={inviteSortDir} onSort={handleInviteSort} className="py-3.5 px-4">
|
|
||||||
Message
|
|
||||||
</SortableTh>
|
|
||||||
<SortableTh column="invitedOrganisationId" currentColumn={inviteSortCol} direction={inviteSortDir} onSort={handleInviteSort} className="py-3.5 px-4">
|
<SortableTh column="invitedOrganisationId" currentColumn={inviteSortCol} direction={inviteSortDir} onSort={handleInviteSort} className="py-3.5 px-4">
|
||||||
Target Org / Grant
|
Target Org / Grant
|
||||||
</SortableTh>
|
</SortableTh>
|
||||||
|
<th className="py-3.5 px-4 font-semibold text-slate-400 uppercase tracking-wider">
|
||||||
|
Status
|
||||||
|
</th>
|
||||||
<SortableTh column="createdAt" currentColumn={inviteSortCol} direction={inviteSortDir} onSort={handleInviteSort} align="right" className="py-3.5 px-5 text-right">
|
<SortableTh column="createdAt" currentColumn={inviteSortCol} direction={inviteSortDir} onSort={handleInviteSort} align="right" className="py-3.5 px-5 text-right">
|
||||||
Created Date
|
Created Date
|
||||||
</SortableTh>
|
</SortableTh>
|
||||||
|
<th className="py-3.5 px-5 text-right font-semibold text-slate-400 uppercase tracking-wider">
|
||||||
|
Actions
|
||||||
|
</th>
|
||||||
</tr>
|
</tr>
|
||||||
</thead>
|
</thead>
|
||||||
<tbody className="divide-y divide-slate-800/50">
|
<tbody className="divide-y divide-slate-800/50">
|
||||||
{loadingInvites ? (
|
{loadingInvites ? (
|
||||||
<tr>
|
<tr>
|
||||||
<td colSpan={5} className="py-8 text-center text-slate-500">
|
<td colSpan={6} className="py-8 text-center text-slate-500">
|
||||||
Loading invitations ledger...
|
Loading invitations ledger...
|
||||||
</td>
|
</td>
|
||||||
</tr>
|
</tr>
|
||||||
) : sortedInvites.length === 0 ? (
|
) : sortedInvites.length === 0 ? (
|
||||||
<tr>
|
<tr>
|
||||||
<td colSpan={5} className="py-8 text-center text-slate-500">
|
<td colSpan={6} className="py-8 text-center text-slate-500">
|
||||||
No invitations recorded yet. Use “Dispatch Invitation” to send one.
|
No invitations recorded yet. Use “Dispatch Invitation” to send one.
|
||||||
</td>
|
</td>
|
||||||
</tr>
|
</tr>
|
||||||
) : (
|
) : (
|
||||||
sortedInvites.map((inv, idx) => (
|
sortedInvites.map((inv, idx) => {
|
||||||
<tr key={idx} className="hover:bg-slate-800/40 transition-colors">
|
const isApproved =
|
||||||
<td className="py-3.5 px-5 font-mono text-slate-300 text-[11px]">
|
approvedInviteIds.includes(String(inv.id)) ||
|
||||||
{inv.recipient}
|
approvedInviteIds.includes(`inv_${idx}`) ||
|
||||||
</td>
|
approvedInviteIds.includes(String(idx));
|
||||||
<td className="py-3.5 px-4">
|
const resolvedPlatform = resolveInvitePlatform(inv);
|
||||||
<span className="inline-flex items-center px-2 py-0.5 rounded text-[10px] font-semibold bg-blue-500/10 text-blue-400 border border-blue-500/20">
|
const userEmail = resolveUserEmail(inv.recipient);
|
||||||
{getNotificationPlatformLabel(inv.platform)}
|
const orgId = inv.invitedOrganisationId || inv.organisationId;
|
||||||
</span>
|
const org = organisations.find((o) => String(o.id) === String(orgId));
|
||||||
</td>
|
const orgName = org?.name || `Organisation #${orgId || '—'}`;
|
||||||
<td className="py-3.5 px-4 text-white font-medium max-w-xs truncate">
|
const isCreator = isMaker(inv.createdBy);
|
||||||
{inv.message}
|
|
||||||
</td>
|
return (
|
||||||
<td className="py-3.5 px-4 text-slate-400 text-[11px]">
|
<tr key={idx} className="hover:bg-slate-800/40 transition-colors">
|
||||||
Org #{inv.invitedOrganisationId || inv.organisationId} • Award #{inv.awardId}
|
<td className="py-3.5 px-5 text-slate-200 text-xs">
|
||||||
</td>
|
<div className="font-medium text-white flex items-center gap-1.5">
|
||||||
<td className="py-3.5 px-5 text-right font-mono text-slate-400 text-[11px]">
|
<Mail className="w-3.5 h-3.5 text-blue-400 shrink-0" />
|
||||||
{inv.createdAt ? formatDate(inv.createdAt) : 'Recently'}
|
<span className="truncate">{userEmail}</span>
|
||||||
</td>
|
</div>
|
||||||
</tr>
|
<div className="font-mono text-slate-500 text-[10px] truncate max-w-xs mt-0.5">
|
||||||
))
|
{inv.recipient}
|
||||||
|
</div>
|
||||||
|
</td>
|
||||||
|
<td className="py-3.5 px-4">
|
||||||
|
<span className="inline-flex items-center px-2 py-0.5 rounded text-[10px] font-semibold bg-blue-500/10 text-blue-400 border border-blue-500/20">
|
||||||
|
{getNotificationPlatformLabel(resolvedPlatform)}
|
||||||
|
</span>
|
||||||
|
</td>
|
||||||
|
<td className="py-3.5 px-4 text-slate-300 text-xs">
|
||||||
|
<div className="font-medium text-white">{orgName}</div>
|
||||||
|
<div className="text-slate-400 text-[11px] mt-0.5">
|
||||||
|
Award #{inv.awardId || '—'}
|
||||||
|
</div>
|
||||||
|
</td>
|
||||||
|
<td className="py-3.5 px-4">
|
||||||
|
{isApproved ? (
|
||||||
|
<span className="inline-flex items-center gap-1 px-2 py-0.5 rounded-full text-[10px] font-semibold bg-emerald-500/10 text-emerald-400 border border-emerald-500/20">
|
||||||
|
<CheckCircle className="w-3 h-3" />
|
||||||
|
Approved
|
||||||
|
</span>
|
||||||
|
) : (
|
||||||
|
<span className="inline-flex items-center gap-1 px-2 py-0.5 rounded-full text-[10px] font-semibold bg-amber-500/10 text-amber-400 border border-amber-500/20">
|
||||||
|
<RotateCcw className="w-3 h-3" />
|
||||||
|
Pending Approval
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</td>
|
||||||
|
<td className="py-3.5 px-5 text-right font-mono text-slate-400 text-[11px]">
|
||||||
|
{inv.createdAt ? formatDate(inv.createdAt) : 'Recently'}
|
||||||
|
</td>
|
||||||
|
<td className="py-3.5 px-5 text-right">
|
||||||
|
{isApproved ? (
|
||||||
|
<span className="inline-flex items-center gap-1 text-[11px] text-emerald-400/90 font-medium">
|
||||||
|
<Check className="w-3 h-3" />
|
||||||
|
Partner Notified
|
||||||
|
</span>
|
||||||
|
) : canApproveRecord('settings_invites', inv.createdBy) ? (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => handleApproveInvite(inv, idx)}
|
||||||
|
className="inline-flex items-center gap-1 px-2.5 py-1 rounded-lg text-xs font-semibold bg-emerald-600/20 text-emerald-400 hover:bg-emerald-600/30 border border-emerald-500/30 transition shadow-xs cursor-pointer"
|
||||||
|
title="Approve this invitation and issue InApp and Email notifications to the partner user"
|
||||||
|
>
|
||||||
|
<Check className="w-3 h-3" />
|
||||||
|
<span>Approve</span>
|
||||||
|
</button>
|
||||||
|
) : isCreator ? (
|
||||||
|
<span
|
||||||
|
className="inline-flex items-center gap-1 px-2 py-0.5 rounded text-[10px] font-semibold bg-amber-500/10 text-amber-400 border border-amber-500/20"
|
||||||
|
title="Maker-Checker: You created this invite and cannot approve it"
|
||||||
|
>
|
||||||
|
Maker (Creator)
|
||||||
|
</span>
|
||||||
|
) : (
|
||||||
|
<span className="text-[11px] text-slate-500">
|
||||||
|
Pending Review
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
);
|
||||||
|
})
|
||||||
)}
|
)}
|
||||||
</tbody>
|
</tbody>
|
||||||
</table>
|
</table>
|
||||||
@@ -2395,15 +2658,20 @@ export const SettingsPage: React.FC = () => {
|
|||||||
<form
|
<form
|
||||||
onSubmit={(e) => {
|
onSubmit={(e) => {
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
|
if (!inviteAwardId) {
|
||||||
|
toastError('Policy Violation', 'An approved award with an approved contract must be selected.');
|
||||||
|
return;
|
||||||
|
}
|
||||||
if (!inviteRecipientUuid || !inviteMessage) {
|
if (!inviteRecipientUuid || !inviteMessage) {
|
||||||
toastError('Validation Error', 'Recipient UUID and message content are mandatory.');
|
toastError('Validation Error', 'Recipient identifier and message content are mandatory.');
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
sendInviteMutation.mutate({
|
sendInviteMutation.mutate({
|
||||||
organisationId: inviteOrgId || (organisations[0]?.id ? Number(organisations[0].id) : 101),
|
organisationId: inviteOrgId || (organisations[0]?.id ? Number(organisations[0].id) : 101),
|
||||||
invitedOrganisationId: invitedOrgId || (organisations[1]?.id ? Number(organisations[1].id) : 102),
|
invitedOrganisationId: invitedOrgId || (organisations[1]?.id ? Number(organisations[1].id) : 102),
|
||||||
awardId: inviteAwardId || (awards[0]?.id ? Number(awards[0].id) : 301),
|
awardId: inviteAwardId,
|
||||||
contractId: inviteContractId || (contracts[0]?.id ? Number(contracts[0].id) : 401),
|
contractId: inviteContractId,
|
||||||
|
projectId: inviteProjectId || 0,
|
||||||
recipient: inviteRecipientUuid,
|
recipient: inviteRecipientUuid,
|
||||||
message: inviteMessage,
|
message: inviteMessage,
|
||||||
platform: invitePlatform,
|
platform: invitePlatform,
|
||||||
@@ -2411,18 +2679,51 @@ export const SettingsPage: React.FC = () => {
|
|||||||
}}
|
}}
|
||||||
className="space-y-4"
|
className="space-y-4"
|
||||||
>
|
>
|
||||||
<div>
|
{eligibleAwards.length === 0 ? (
|
||||||
<div className="flex items-center gap-1 mb-1.5">
|
<div className="p-3.5 rounded-xl bg-amber-500/10 border border-amber-500/30 text-amber-300 text-xs flex items-start gap-2.5">
|
||||||
<label className="block text-xs font-semibold text-slate-300">
|
<RotateCcw className="w-4 h-4 shrink-0 text-amber-400 mt-0.5" />
|
||||||
Recipient User UUID *
|
<div>
|
||||||
</label>
|
<span className="font-semibold block text-white">No Approved Awards Eligible for Invitation</span>
|
||||||
<HelpTooltip
|
<span className="text-[11px] text-amber-300/90 leading-relaxed block mt-0.5">
|
||||||
title="Recipient UUID"
|
System governance policy strictly requires that invitations may only be dispatched for awards that have been formally <strong>Approved</strong> and whose associated contract is <strong>Approved & Active</strong>.
|
||||||
content="Unique directory user identifier or email for targeted delivery."
|
</span>
|
||||||
size="sm"
|
</div>
|
||||||
placement="right"
|
|
||||||
/>
|
|
||||||
</div>
|
</div>
|
||||||
|
) : null}
|
||||||
|
|
||||||
|
<div className="space-y-1.5">
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<div className="flex items-center gap-1">
|
||||||
|
<label className="block text-xs font-semibold text-slate-300">
|
||||||
|
Recipient User *
|
||||||
|
</label>
|
||||||
|
<HelpTooltip
|
||||||
|
title="Recipient Directory Identifier"
|
||||||
|
content="Select a registered recipient user from directory or enter their unique user UUID."
|
||||||
|
size="sm"
|
||||||
|
placement="right"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
{usersDirectory.length > 0 && (
|
||||||
|
<span className="text-[10px] text-slate-400">Quick-select from registered users</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
{usersDirectory.length > 0 && (
|
||||||
|
<select
|
||||||
|
value={usersDirectory.some((u) => String(u.id).toLowerCase() === inviteRecipientUuid.toLowerCase()) ? inviteRecipientUuid : ''}
|
||||||
|
onChange={(e) => {
|
||||||
|
if (e.target.value) setInviteRecipientUuid(e.target.value);
|
||||||
|
}}
|
||||||
|
className="w-full px-3 py-2 text-xs bg-slate-900/80 border border-slate-700/80 rounded-xl text-white focus:outline-none focus:ring-2 focus:ring-blue-500/50 [&>option]:bg-slate-900 [&>option]:text-white mb-1"
|
||||||
|
>
|
||||||
|
<option value="">-- Choose from Registered Users --</option>
|
||||||
|
{usersDirectory.map((u) => (
|
||||||
|
<option key={u.id} value={u.id}>
|
||||||
|
{u.name ? `${u.name} — ` : ''}{u.email} ({u.id})
|
||||||
|
</option>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
|
)}
|
||||||
<input
|
<input
|
||||||
type="text"
|
type="text"
|
||||||
value={inviteRecipientUuid}
|
value={inviteRecipientUuid}
|
||||||
@@ -2433,6 +2734,98 @@ export const SettingsPage: React.FC = () => {
|
|||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<div className="grid grid-cols-2 gap-3">
|
||||||
|
<div>
|
||||||
|
<div className="flex items-center gap-1 mb-1.5">
|
||||||
|
<label className="block text-xs font-semibold text-slate-300">
|
||||||
|
Approved Award *
|
||||||
|
</label>
|
||||||
|
<HelpTooltip
|
||||||
|
title="Approved Award Required"
|
||||||
|
content="Only awards with status Approved and an Active/Approved contract are eligible for invitations."
|
||||||
|
size="sm"
|
||||||
|
placement="top"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<select
|
||||||
|
value={inviteAwardId}
|
||||||
|
onChange={(e) => handleSelectAward(e.target.value)}
|
||||||
|
disabled={eligibleAwards.length === 0}
|
||||||
|
className="w-full px-3 py-2 text-xs bg-slate-900/80 border border-slate-700/80 rounded-xl text-white focus:outline-none focus:ring-2 focus:ring-blue-500/50 [&>option]:bg-slate-900 [&>option]:text-white disabled:opacity-50"
|
||||||
|
required
|
||||||
|
>
|
||||||
|
{eligibleAwards.length === 0 ? (
|
||||||
|
<option value="">No approved awards available</option>
|
||||||
|
) : (
|
||||||
|
eligibleAwards.map((aw) => {
|
||||||
|
const recipientOrg = organisations.find((o) => String(o.id) === String(aw.organisationId));
|
||||||
|
return (
|
||||||
|
<option key={aw.id} value={aw.id}>
|
||||||
|
Award #{aw.id} — {recipientOrg?.name || `Org #${aw.organisationId}`}
|
||||||
|
</option>
|
||||||
|
);
|
||||||
|
})
|
||||||
|
)}
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<div className="flex items-center gap-1 mb-1.5">
|
||||||
|
<label className="block text-xs font-semibold text-slate-300">
|
||||||
|
Approved Contract *
|
||||||
|
</label>
|
||||||
|
<HelpTooltip
|
||||||
|
title="Associated Approved Contract"
|
||||||
|
content="The active/approved contract linked to the selected award."
|
||||||
|
size="sm"
|
||||||
|
placement="top"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
value={inviteContractId ? `Contract #${inviteContractId} (Approved & Active)` : 'No active contract'}
|
||||||
|
readOnly
|
||||||
|
className="w-full px-3 py-2 text-xs bg-slate-900/50 border border-slate-800 rounded-xl text-slate-300 font-mono focus:outline-none cursor-not-allowed"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="grid grid-cols-2 gap-3">
|
||||||
|
<div>
|
||||||
|
<div className="flex items-center gap-1 mb-1.5">
|
||||||
|
<label className="block text-xs font-semibold text-slate-300">
|
||||||
|
Target Organisation (Recipient Org)
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
value={(() => {
|
||||||
|
const o = organisations.find((org) => String(org.id) === String(invitedOrgId));
|
||||||
|
return o ? `${o.name} (#${o.id})` : invitedOrgId ? `Org #${invitedOrgId}` : '—';
|
||||||
|
})()}
|
||||||
|
readOnly
|
||||||
|
className="w-full px-3 py-2 text-xs bg-slate-900/50 border border-slate-800 rounded-xl text-slate-300 focus:outline-none cursor-not-allowed"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<div className="flex items-center gap-1 mb-1.5">
|
||||||
|
<label className="block text-xs font-semibold text-slate-300">
|
||||||
|
Associated Project
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
value={(() => {
|
||||||
|
const prj = projects.find((p) => String(p.id) === String(inviteProjectId));
|
||||||
|
return prj ? `${prj.name || 'Project'} (#${prj.id})` : inviteProjectId ? `Project #${inviteProjectId}` : 'Initial Award Project';
|
||||||
|
})()}
|
||||||
|
readOnly
|
||||||
|
className="w-full px-3 py-2 text-xs bg-slate-900/50 border border-slate-800 rounded-xl text-slate-300 focus:outline-none cursor-not-allowed"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
<div className="grid grid-cols-2 gap-3">
|
<div className="grid grid-cols-2 gap-3">
|
||||||
<div>
|
<div>
|
||||||
<div className="flex items-center gap-1 mb-1.5">
|
<div className="flex items-center gap-1 mb-1.5">
|
||||||
@@ -2462,7 +2855,7 @@ export const SettingsPage: React.FC = () => {
|
|||||||
<div>
|
<div>
|
||||||
<div className="flex items-center gap-1 mb-1.5">
|
<div className="flex items-center gap-1 mb-1.5">
|
||||||
<label className="block text-xs font-semibold text-slate-300">
|
<label className="block text-xs font-semibold text-slate-300">
|
||||||
Originating Organisation
|
Originating Authority
|
||||||
</label>
|
</label>
|
||||||
<HelpTooltip
|
<HelpTooltip
|
||||||
title="Inviting Authority"
|
title="Inviting Authority"
|
||||||
@@ -2485,58 +2878,6 @@ export const SettingsPage: React.FC = () => {
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="grid grid-cols-2 gap-3">
|
|
||||||
<div>
|
|
||||||
<div className="flex items-center gap-1 mb-1.5">
|
|
||||||
<label className="block text-xs font-semibold text-slate-300">
|
|
||||||
Associated Award
|
|
||||||
</label>
|
|
||||||
<HelpTooltip
|
|
||||||
title="Linked Grant Award"
|
|
||||||
content="Optional award attachment to link recipient permissions directly to grant administration."
|
|
||||||
size="sm"
|
|
||||||
placement="top"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
<select
|
|
||||||
value={inviteAwardId}
|
|
||||||
onChange={(e) => setInviteAwardId(e.target.value)}
|
|
||||||
className="w-full px-3 py-2 text-xs bg-slate-900/80 border border-slate-700/80 rounded-xl text-white focus:outline-none focus:ring-2 focus:ring-blue-500/50 [&>option]:bg-slate-900 [&>option]:text-white"
|
|
||||||
>
|
|
||||||
{awards.map((aw) => (
|
|
||||||
<option key={aw.id} value={aw.id}>
|
|
||||||
Award #{aw.id}
|
|
||||||
</option>
|
|
||||||
))}
|
|
||||||
</select>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div>
|
|
||||||
<div className="flex items-center gap-1 mb-1.5">
|
|
||||||
<label className="block text-xs font-semibold text-slate-300">
|
|
||||||
Associated Contract
|
|
||||||
</label>
|
|
||||||
<HelpTooltip
|
|
||||||
title="Linked Contract"
|
|
||||||
content="Optional bilateral agreement linking the recipient to contract execution."
|
|
||||||
size="sm"
|
|
||||||
placement="top"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
<select
|
|
||||||
value={inviteContractId}
|
|
||||||
onChange={(e) => setInviteContractId(e.target.value)}
|
|
||||||
className="w-full px-3 py-2 text-xs bg-slate-900/80 border border-slate-700/80 rounded-xl text-white focus:outline-none focus:ring-2 focus:ring-blue-500/50 [&>option]:bg-slate-900 [&>option]:text-white"
|
|
||||||
>
|
|
||||||
{contracts.map((ctr) => (
|
|
||||||
<option key={ctr.id} value={ctr.id}>
|
|
||||||
Contract #{ctr.id}
|
|
||||||
</option>
|
|
||||||
))}
|
|
||||||
</select>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div>
|
<div>
|
||||||
<div className="flex items-center gap-1 mb-1.5">
|
<div className="flex items-center gap-1 mb-1.5">
|
||||||
<label className="block text-xs font-semibold text-slate-300">
|
<label className="block text-xs font-semibold text-slate-300">
|
||||||
@@ -2559,6 +2900,22 @@ export const SettingsPage: React.FC = () => {
|
|||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<div className="flex items-start gap-2.5 p-3 rounded-xl bg-blue-950/20 border border-blue-900/40">
|
||||||
|
<input
|
||||||
|
type="checkbox"
|
||||||
|
id="inviteAutoApprove"
|
||||||
|
checked={inviteAutoApprove}
|
||||||
|
onChange={(e) => setInviteAutoApprove(e.target.checked)}
|
||||||
|
className="mt-0.5 w-4 h-4 rounded text-blue-600 bg-slate-900 border-slate-700 focus:ring-blue-500 cursor-pointer"
|
||||||
|
/>
|
||||||
|
<label htmlFor="inviteAutoApprove" className="text-xs text-slate-300 cursor-pointer select-none">
|
||||||
|
<span className="font-semibold text-white block">Formally Approve Invitation & Notify Partner</span>
|
||||||
|
<span className="text-[11px] text-slate-400 leading-relaxed block mt-0.5">
|
||||||
|
Automatically marks invite as Approved and issues In-App and Email notifications directing the partner user to the Partner Portal (<strong className="text-blue-400 font-mono">https://partner.ifri.khongisa.co.za/</strong>).
|
||||||
|
</span>
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
|
||||||
<div className="pt-3 border-t border-slate-800/80 flex justify-end gap-2">
|
<div className="pt-3 border-t border-slate-800/80 flex justify-end gap-2">
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
@@ -2569,7 +2926,7 @@ export const SettingsPage: React.FC = () => {
|
|||||||
</button>
|
</button>
|
||||||
<button
|
<button
|
||||||
type="submit"
|
type="submit"
|
||||||
disabled={sendInviteMutation.isPending}
|
disabled={sendInviteMutation.isPending || eligibleAwards.length === 0}
|
||||||
className="px-4 py-2 text-xs font-semibold text-white bg-gradient-to-r from-blue-600 to-indigo-600 hover:from-blue-500 hover:to-indigo-500 rounded-xl shadow-lg shadow-blue-500/20 transition disabled:opacity-50"
|
className="px-4 py-2 text-xs font-semibold text-white bg-gradient-to-r from-blue-600 to-indigo-600 hover:from-blue-500 hover:to-indigo-500 rounded-xl shadow-lg shadow-blue-500/20 transition disabled:opacity-50"
|
||||||
>
|
>
|
||||||
{sendInviteMutation.isPending ? 'Sending...' : 'Send Invitation'}
|
{sendInviteMutation.isPending ? 'Sending...' : 'Send Invitation'}
|
||||||
|
|||||||
@@ -528,7 +528,7 @@ export const SiteVisitsPage: React.FC = () => {
|
|||||||
title="Approved site visits are immutable."
|
title="Approved site visits are immutable."
|
||||||
>
|
>
|
||||||
<Lock className="w-3.5 h-3.5 text-emerald-400" />
|
<Lock className="w-3.5 h-3.5 text-emerald-400" />
|
||||||
<span>Approved & Verified (Immutable - Cannot Be Changed)</span>
|
<span>Approved & Verified</span>
|
||||||
</div>
|
</div>
|
||||||
) : (
|
) : (
|
||||||
<select
|
<select
|
||||||
|
|||||||
@@ -68,14 +68,12 @@ const TenantOrganisationsSubList: React.FC<{ tenantId: number | string; tenantNa
|
|||||||
} = useQuery<TenantOrganisation[]>({
|
} = useQuery<TenantOrganisation[]>({
|
||||||
queryKey: ['tenant-organisations', tenantId],
|
queryKey: ['tenant-organisations', tenantId],
|
||||||
queryFn: () => getTenantOrganisations(tenantId),
|
queryFn: () => getTenantOrganisations(tenantId),
|
||||||
staleTime: 30000,
|
|
||||||
});
|
});
|
||||||
|
|
||||||
// Fetch tenant users directory to resolve linked user email per organisation
|
// Fetch tenant users directory to resolve linked user email per organisation
|
||||||
const { data: tenantUsers = [] } = useQuery<TenantUser[]>({
|
const { data: tenantUsers = [] } = useQuery<TenantUser[]>({
|
||||||
queryKey: ['tenant-users', tenantId],
|
queryKey: ['tenant-users', tenantId],
|
||||||
queryFn: () => getTenantUsers(tenantId),
|
queryFn: () => getTenantUsers(tenantId),
|
||||||
staleTime: 5 * 60 * 1000,
|
|
||||||
});
|
});
|
||||||
|
|
||||||
React.useEffect(() => {
|
React.useEffect(() => {
|
||||||
@@ -261,7 +259,6 @@ const TenantUsersSubList: React.FC<{
|
|||||||
const { data: tenantUsers = [], isLoading, refetch } = useQuery<TenantUser[]>({
|
const { data: tenantUsers = [], isLoading, refetch } = useQuery<TenantUser[]>({
|
||||||
queryKey: ['tenant-users', tenantId],
|
queryKey: ['tenant-users', tenantId],
|
||||||
queryFn: () => getTenantUsers(tenantId),
|
queryFn: () => getTenantUsers(tenantId),
|
||||||
staleTime: 30000,
|
|
||||||
});
|
});
|
||||||
|
|
||||||
const { canAdd } = useRbac();
|
const { canAdd } = useRbac();
|
||||||
@@ -460,7 +457,6 @@ export const TenantManagementPage: React.FC = () => {
|
|||||||
} = useQuery<Tenant[]>({
|
} = useQuery<Tenant[]>({
|
||||||
queryKey: ['tenants-list'],
|
queryKey: ['tenants-list'],
|
||||||
queryFn: () => getTenants(),
|
queryFn: () => getTenants(),
|
||||||
staleTime: 30000,
|
|
||||||
});
|
});
|
||||||
|
|
||||||
// Forms
|
// Forms
|
||||||
@@ -962,6 +958,7 @@ export const TenantManagementPage: React.FC = () => {
|
|||||||
isOpen={isCreateModalOpen}
|
isOpen={isCreateModalOpen}
|
||||||
onClose={() => setIsCreateModalOpen(false)}
|
onClose={() => setIsCreateModalOpen(false)}
|
||||||
title="Provision New Tenant Partition"
|
title="Provision New Tenant Partition"
|
||||||
|
subtitle="Define an isolated multi-tenant partition for grants, portfolios, and users."
|
||||||
size="md"
|
size="md"
|
||||||
>
|
>
|
||||||
<form
|
<form
|
||||||
@@ -970,17 +967,6 @@ export const TenantManagementPage: React.FC = () => {
|
|||||||
)}
|
)}
|
||||||
className="space-y-4"
|
className="space-y-4"
|
||||||
>
|
>
|
||||||
<div className="p-3.5 rounded-xl bg-blue-950/20 border border-blue-800/30 text-xs text-blue-200 flex items-start gap-2.5">
|
|
||||||
<Info className="w-4 h-4 text-blue-400 shrink-0 mt-0.5" />
|
|
||||||
<div className="space-y-1 leading-relaxed">
|
|
||||||
<p className="font-semibold text-white">Multi-Tenant Partition Boundary</p>
|
|
||||||
<p>
|
|
||||||
Creating a new tenant defines an isolated partition boundary for grants, portfolios, projects, and users.
|
|
||||||
New tenants are automatically initialized in the Active state.
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div>
|
<div>
|
||||||
<label className="block text-xs font-semibold text-slate-300 mb-1.5">
|
<label className="block text-xs font-semibold text-slate-300 mb-1.5">
|
||||||
Tenant Organization Name <span className="text-rose-400">*</span>
|
Tenant Organization Name <span className="text-rose-400">*</span>
|
||||||
@@ -1007,7 +993,7 @@ export const TenantManagementPage: React.FC = () => {
|
|||||||
<button
|
<button
|
||||||
type="submit"
|
type="submit"
|
||||||
disabled={createMutation.isPending}
|
disabled={createMutation.isPending}
|
||||||
className="flex items-center gap-1.5 px-4 py-2 bg-gradient-to-r from-blue-600 to-indigo-600 hover:from-blue-500 hover:to-indigo-500 text-white text-xs font-semibold rounded-xl shadow-md transition disabled:opacity-50 cursor-pointer"
|
className="flex items-center gap-1.5 px-4 py-2 bg-blue-600 hover:bg-blue-500 text-white text-xs font-semibold rounded-xl shadow-md transition disabled:opacity-50 cursor-pointer"
|
||||||
>
|
>
|
||||||
{createMutation.isPending && <RefreshCw className="w-3.5 h-3.5 animate-spin" />}
|
{createMutation.isPending && <RefreshCw className="w-3.5 h-3.5 animate-spin" />}
|
||||||
<span>{createMutation.isPending ? 'Provisioning...' : 'Create Tenant'}</span>
|
<span>{createMutation.isPending ? 'Provisioning...' : 'Create Tenant'}</span>
|
||||||
@@ -1021,6 +1007,7 @@ export const TenantManagementPage: React.FC = () => {
|
|||||||
isOpen={isUpdateModalOpen}
|
isOpen={isUpdateModalOpen}
|
||||||
onClose={() => setIsUpdateModalOpen(false)}
|
onClose={() => setIsUpdateModalOpen(false)}
|
||||||
title={`Update Tenant: ${selectedTenant?.name || ''}`}
|
title={`Update Tenant: ${selectedTenant?.name || ''}`}
|
||||||
|
subtitle="Modify tenant partition name and operational access status."
|
||||||
size="md"
|
size="md"
|
||||||
>
|
>
|
||||||
{selectedTenant && (
|
{selectedTenant && (
|
||||||
@@ -1064,13 +1051,12 @@ export const TenantManagementPage: React.FC = () => {
|
|||||||
|
|
||||||
{/* Warning when selecting Inactive or Disabled */}
|
{/* Warning when selecting Inactive or Disabled */}
|
||||||
{Number(updateForm.watch('status')) !== ActivityStatus.Active && (
|
{Number(updateForm.watch('status')) !== ActivityStatus.Active && (
|
||||||
<div className="p-3 rounded-xl bg-amber-950/30 border border-amber-800/40 text-xs text-amber-200 flex items-start gap-2.5">
|
<div className="p-3 rounded-xl bg-amber-500/10 border border-amber-500/20 text-xs text-amber-200 flex items-start gap-2.5">
|
||||||
<AlertTriangle className="w-4 h-4 text-amber-400 shrink-0 mt-0.5" />
|
<AlertTriangle className="w-4 h-4 text-amber-400 shrink-0 mt-0.5" />
|
||||||
<div className="space-y-1">
|
<div className="space-y-0.5">
|
||||||
<p className="font-semibold text-white">Multitenancy Lockout Warning</p>
|
<p className="font-semibold text-amber-300">Lockout Notice</p>
|
||||||
<p className="text-[11px] leading-relaxed text-amber-300/90">
|
<p className="text-[11px] text-amber-200/90 leading-relaxed">
|
||||||
Changing this tenant's status to Inactive or Disabled immediately locks out all users assigned to this partition.
|
Setting this tenant to Inactive or Disabled immediately locks out assigned users from accessing the API.
|
||||||
The backend API will deny requests until the tenant is restored to Active status.
|
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -1087,7 +1073,7 @@ export const TenantManagementPage: React.FC = () => {
|
|||||||
<button
|
<button
|
||||||
type="submit"
|
type="submit"
|
||||||
disabled={updateMutation.isPending}
|
disabled={updateMutation.isPending}
|
||||||
className="flex items-center gap-1.5 px-4 py-2 bg-gradient-to-r from-blue-600 to-indigo-600 hover:from-blue-500 hover:to-indigo-500 text-white text-xs font-semibold rounded-xl shadow-md transition disabled:opacity-50 cursor-pointer"
|
className="flex items-center gap-1.5 px-4 py-2 bg-blue-600 hover:bg-blue-500 text-white text-xs font-semibold rounded-xl shadow-md transition disabled:opacity-50 cursor-pointer"
|
||||||
>
|
>
|
||||||
{updateMutation.isPending && <RefreshCw className="w-3.5 h-3.5 animate-spin" />}
|
{updateMutation.isPending && <RefreshCw className="w-3.5 h-3.5 animate-spin" />}
|
||||||
<span>{updateMutation.isPending ? 'Saving...' : 'Update Tenant'}</span>
|
<span>{updateMutation.isPending ? 'Saving...' : 'Update Tenant'}</span>
|
||||||
@@ -1102,66 +1088,47 @@ export const TenantManagementPage: React.FC = () => {
|
|||||||
isOpen={isDetailModalOpen}
|
isOpen={isDetailModalOpen}
|
||||||
onClose={() => setIsDetailModalOpen(false)}
|
onClose={() => setIsDetailModalOpen(false)}
|
||||||
title={`Tenant Details: ${selectedTenant?.name || ''}`}
|
title={`Tenant Details: ${selectedTenant?.name || ''}`}
|
||||||
|
subtitle="Partition boundary information, creation audit, and operational status."
|
||||||
size="lg"
|
size="lg"
|
||||||
>
|
>
|
||||||
{selectedTenant && (
|
{selectedTenant && (
|
||||||
<div className="space-y-5">
|
<div className="space-y-4">
|
||||||
<div className="grid grid-cols-1 sm:grid-cols-2 gap-4 bg-slate-950/60 p-4 rounded-xl border border-slate-800">
|
<div className="grid grid-cols-2 sm:grid-cols-3 gap-3 bg-slate-950/60 p-3.5 rounded-xl border border-slate-800 text-xs">
|
||||||
<div>
|
<div>
|
||||||
<p className="text-[11px] text-slate-500 uppercase tracking-wider">Tenant Numeric ID</p>
|
<p className="text-[10px] text-slate-500 uppercase tracking-wider font-semibold">Tenant ID</p>
|
||||||
<p className="text-sm font-bold text-white mt-0.5">#{selectedTenant.id}</p>
|
<p className="text-sm font-bold text-white mt-0.5">#{selectedTenant.id}</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div>
|
<div>
|
||||||
<p className="text-[11px] text-slate-500 uppercase tracking-wider">Tenant Name</p>
|
<p className="text-[10px] text-slate-500 uppercase tracking-wider font-semibold">Tenant Name</p>
|
||||||
<p className="text-sm font-semibold text-slate-200 mt-0.5">{selectedTenant.name || '—'}</p>
|
<p className="text-xs font-semibold text-slate-200 mt-0.5 truncate">{selectedTenant.name || '—'}</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div>
|
<div>
|
||||||
<p className="text-[11px] text-slate-500 uppercase tracking-wider">Operational Status</p>
|
<p className="text-[10px] text-slate-500 uppercase tracking-wider font-semibold">Operational Status</p>
|
||||||
<div className="mt-1">
|
<div className="mt-1">
|
||||||
<ActivityStatusBadge status={selectedTenant.status} />
|
<ActivityStatusBadge status={selectedTenant.status} />
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div>
|
<div>
|
||||||
<p className="text-[11px] text-slate-500 uppercase tracking-wider">Created Date</p>
|
<p className="text-[10px] text-slate-500 uppercase tracking-wider font-semibold">Created Date</p>
|
||||||
<p className="text-xs text-slate-300 mt-0.5">{formatDate(selectedTenant.createdAt)}</p>
|
<p className="text-xs text-slate-300 mt-0.5">{formatDate(selectedTenant.createdAt)}</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div>
|
<div>
|
||||||
<p className="text-[11px] text-slate-500 uppercase tracking-wider">Created By</p>
|
<p className="text-[10px] text-slate-500 uppercase tracking-wider font-semibold">Created By</p>
|
||||||
<p className="text-xs font-mono text-slate-300 mt-0.5">
|
<p className="text-xs font-mono text-slate-300 mt-0.5 truncate">
|
||||||
{getUserEmail(selectedTenant.createdBy) || selectedTenant.createdBy || 'System Admin'}
|
{getUserEmail(selectedTenant.createdBy) || selectedTenant.createdBy || 'System Admin'}
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{selectedTenant.updatedAt && (
|
{selectedTenant.updatedAt && (
|
||||||
<div>
|
<div>
|
||||||
<p className="text-[11px] text-slate-500 uppercase tracking-wider">Last Updated</p>
|
<p className="text-[10px] text-slate-500 uppercase tracking-wider font-semibold">Last Updated</p>
|
||||||
<p className="text-xs text-slate-300 mt-0.5">{formatDate(selectedTenant.updatedAt)}</p>
|
<p className="text-xs text-slate-300 mt-0.5">{formatDate(selectedTenant.updatedAt)}</p>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{selectedTenant.updatedBy && (
|
|
||||||
<div>
|
|
||||||
<p className="text-[11px] text-slate-500 uppercase tracking-wider">Updated By</p>
|
|
||||||
<p className="text-xs font-mono text-slate-300 mt-0.5">
|
|
||||||
{getUserEmail(selectedTenant.updatedBy) || selectedTenant.updatedBy}
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="p-3.5 rounded-xl bg-slate-900/80 border border-slate-800 text-xs text-slate-300 space-y-2">
|
|
||||||
<p className="font-semibold text-white flex items-center gap-1.5">
|
|
||||||
<ShieldCheck className="w-4 h-4 text-blue-400" />
|
|
||||||
Partition Scope & Authentication
|
|
||||||
</p>
|
|
||||||
<p className="text-[11px] text-slate-400 leading-relaxed">
|
|
||||||
When users authenticate, their security token contains claims identifying their tenant partition.
|
|
||||||
The system automatically scopes data queries to this tenant and enforces operational lockout if the status is not Active.
|
|
||||||
</p>
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="flex items-center justify-end gap-2.5 pt-3 border-t border-slate-800">
|
<div className="flex items-center justify-end gap-2.5 pt-3 border-t border-slate-800">
|
||||||
@@ -1171,7 +1138,7 @@ export const TenantManagementPage: React.FC = () => {
|
|||||||
setIsDetailModalOpen(false);
|
setIsDetailModalOpen(false);
|
||||||
handleOpenUpdate(selectedTenant);
|
handleOpenUpdate(selectedTenant);
|
||||||
}}
|
}}
|
||||||
className="flex items-center gap-1.5 px-4 py-2 bg-gradient-to-r from-blue-600 to-indigo-600 hover:from-blue-500 hover:to-indigo-500 text-white text-xs font-semibold rounded-xl transition cursor-pointer"
|
className="flex items-center gap-1.5 px-4 py-2 bg-blue-600 hover:bg-blue-500 text-white text-xs font-semibold rounded-xl transition cursor-pointer"
|
||||||
>
|
>
|
||||||
<Edit2 className="w-3.5 h-3.5" />
|
<Edit2 className="w-3.5 h-3.5" />
|
||||||
<span>Edit Tenant Status</span>
|
<span>Edit Tenant Status</span>
|
||||||
|
|||||||
@@ -1,6 +1,19 @@
|
|||||||
import axios, { AxiosError } from 'axios';
|
import axios, { AxiosError } from 'axios';
|
||||||
import { apiClient } from './httpClient';
|
import { apiClient } from './httpClient';
|
||||||
import { toolsService } from './toolsService';
|
import { toolsService } from '../utils/hashids';
|
||||||
|
import {
|
||||||
|
notifyTenantManagersAndExecutivesOnRecordStatusChange,
|
||||||
|
notifyInviteApproved,
|
||||||
|
notifyNewUserCreated,
|
||||||
|
isApprovedOrActiveStatus,
|
||||||
|
} from './notificationRulesService';
|
||||||
|
|
||||||
|
export {
|
||||||
|
notifyTenantManagersAndExecutivesOnRecordStatusChange,
|
||||||
|
notifyInviteApproved,
|
||||||
|
notifyNewUserCreated,
|
||||||
|
isApprovedOrActiveStatus,
|
||||||
|
};
|
||||||
import {
|
import {
|
||||||
getCurrentUserSub,
|
getCurrentUserSub,
|
||||||
getCurrentUserTenantId,
|
getCurrentUserTenantId,
|
||||||
@@ -64,6 +77,7 @@ import {
|
|||||||
AuditLog,
|
AuditLog,
|
||||||
ProblemDetails,
|
ProblemDetails,
|
||||||
ActivityStatus,
|
ActivityStatus,
|
||||||
|
ApprovalStatus,
|
||||||
Tenant,
|
Tenant,
|
||||||
TenantOrganisation,
|
TenantOrganisation,
|
||||||
TenantUser,
|
TenantUser,
|
||||||
@@ -239,16 +253,25 @@ export const createOrganisation = async (payload: CreateOrganisationRequest): Pr
|
|||||||
};
|
};
|
||||||
|
|
||||||
export const updateOrganisation = async (payload: UpdateOrganisationRequest): Promise<Organisation> => {
|
export const updateOrganisation = async (payload: UpdateOrganisationRequest): Promise<Organisation> => {
|
||||||
|
const orgStatus = payload.status !== undefined ? Number(payload.status) : ActivityStatus.Active;
|
||||||
const res = await apiClient.put('/v1/api/governance/organisations', {
|
const res = await apiClient.put('/v1/api/governance/organisations', {
|
||||||
organisationId: toIntegerId(payload.organisationId),
|
organisationId: toIntegerId(payload.organisationId),
|
||||||
name: payload.name,
|
name: payload.name,
|
||||||
registrationNo: payload.registrationNo,
|
registrationNo: payload.registrationNo,
|
||||||
email: payload.email,
|
email: payload.email,
|
||||||
idNumber: payload.idNumber,
|
idNumber: payload.idNumber || null,
|
||||||
organisationUser: payload.organisationUser,
|
organisationUser: payload.organisationUser,
|
||||||
type: Number(payload.type),
|
type: Number(payload.type),
|
||||||
status: payload.status !== undefined ? Number(payload.status) : ActivityStatus.Active,
|
status: orgStatus,
|
||||||
});
|
});
|
||||||
|
if (isApprovedOrActiveStatus(orgStatus)) {
|
||||||
|
notifyTenantManagersAndExecutivesOnRecordStatusChange({
|
||||||
|
recordType: 'Organisation',
|
||||||
|
recordId: payload.organisationId,
|
||||||
|
newStatus: orgStatus,
|
||||||
|
title: payload.name,
|
||||||
|
}).catch(console.warn);
|
||||||
|
}
|
||||||
return res.data;
|
return res.data;
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -419,84 +442,21 @@ export const getProgrammeValue = async (programmeId: string | number): Promise<n
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
const LOCAL_INSTRUCTIONS_KEY = 'pfm_local_instructions';
|
export const purgeStoredInstructionDuplicates = (_apiItems?: Instrument[]): void => {};
|
||||||
|
|
||||||
export const purgeStoredInstructionDuplicates = (apiItems: Instrument[]): void => {
|
export const getStoredInstructions = (): Instrument[] => [];
|
||||||
if (typeof window === 'undefined' || !Array.isArray(apiItems) || apiItems.length === 0) return;
|
|
||||||
try {
|
|
||||||
const raw = localStorage.getItem(LOCAL_INSTRUCTIONS_KEY);
|
|
||||||
if (!raw) return;
|
|
||||||
const local: Instrument[] = JSON.parse(raw);
|
|
||||||
if (!Array.isArray(local) || local.length === 0) return;
|
|
||||||
|
|
||||||
const apiIds = new Set(apiItems.map((i) => String(i.id)));
|
export const saveStoredInstruction = (_inst?: Instrument): void => {};
|
||||||
const apiKeys = new Set(apiItems.map((i) => `${i.programmeId}::${(i.name || '').toLowerCase().trim()}`));
|
|
||||||
|
|
||||||
const remaining = local.filter(
|
|
||||||
(l) => !apiIds.has(String(l.id)) && !apiKeys.has(`${l.programmeId}::${(l.name || '').toLowerCase().trim()}`)
|
|
||||||
);
|
|
||||||
|
|
||||||
if (remaining.length !== local.length) {
|
|
||||||
localStorage.setItem(LOCAL_INSTRUCTIONS_KEY, JSON.stringify(remaining));
|
|
||||||
}
|
|
||||||
} catch (e) {
|
|
||||||
console.warn('Failed to purge stored instruction duplicates', e);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
export const getStoredInstructions = (): Instrument[] => {
|
|
||||||
if (typeof window === 'undefined') return [];
|
|
||||||
try {
|
|
||||||
const raw = localStorage.getItem(LOCAL_INSTRUCTIONS_KEY);
|
|
||||||
return raw ? JSON.parse(raw) : [];
|
|
||||||
} catch {
|
|
||||||
return [];
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
export const saveStoredInstruction = (inst: Instrument): void => {
|
|
||||||
if (typeof window === 'undefined') return;
|
|
||||||
try {
|
|
||||||
const current = getStoredInstructions();
|
|
||||||
const idx = current.findIndex(
|
|
||||||
(i) => String(i.id) === String(inst.id) ||
|
|
||||||
(String(i.programmeId) === String(inst.programmeId) && (i.name || '').trim().toLowerCase() === (inst.name || '').trim().toLowerCase())
|
|
||||||
);
|
|
||||||
if (idx >= 0) {
|
|
||||||
current[idx] = { ...current[idx], ...inst, updatedAt: new Date().toISOString() };
|
|
||||||
} else {
|
|
||||||
current.unshift(inst);
|
|
||||||
}
|
|
||||||
localStorage.setItem(LOCAL_INSTRUCTIONS_KEY, JSON.stringify(current));
|
|
||||||
} catch (e) {
|
|
||||||
console.warn('Failed to save stored instruction', e);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
export const getInstruments = async (programmeId: string | number): Promise<Instrument[]> => {
|
export const getInstruments = async (programmeId: string | number): Promise<Instrument[]> => {
|
||||||
let apiItems: Instrument[] = [];
|
|
||||||
let apiSucceeded = false;
|
|
||||||
try {
|
try {
|
||||||
const hashedProgId = await toolsService.resolveProgrammeId(programmeId);
|
const hashedProgId = await toolsService.resolveProgrammeId(programmeId);
|
||||||
const res = await apiClient.get<Instrument[]>(`/v1/api/governance/instruments/${hashedProgId}`);
|
const res = await apiClient.get<Instrument[]>(`/v1/api/governance/instruments/${hashedProgId}`);
|
||||||
if (Array.isArray(res.data)) {
|
return Array.isArray(res.data) ? res.data : [];
|
||||||
apiItems = res.data;
|
|
||||||
apiSucceeded = true;
|
|
||||||
}
|
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
console.warn(`[getInstruments] Could not fetch instruments from API for prog ${programmeId}:`, err);
|
console.warn(`[getInstruments] Could not fetch instruments from API for prog ${programmeId}:`, err);
|
||||||
|
return [];
|
||||||
}
|
}
|
||||||
|
|
||||||
if (apiSucceeded) {
|
|
||||||
purgeStoredInstructionDuplicates(apiItems);
|
|
||||||
return apiItems;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Fallback to local items only when API failed
|
|
||||||
const localItems = getStoredInstructions().filter(
|
|
||||||
(item) => String(item.programmeId) === String(programmeId)
|
|
||||||
);
|
|
||||||
return localItems;
|
|
||||||
};
|
};
|
||||||
|
|
||||||
export const getInstructions = getInstruments;
|
export const getInstructions = getInstruments;
|
||||||
@@ -512,27 +472,17 @@ export const getAllInstruments = async (programmesList?: Programme[]): Promise<I
|
|||||||
}
|
}
|
||||||
|
|
||||||
const map = new Map<string, Instrument>();
|
const map = new Map<string, Instrument>();
|
||||||
let hasApiData = false;
|
|
||||||
|
|
||||||
if (progList && progList.length > 0) {
|
if (progList && progList.length > 0) {
|
||||||
const results = await Promise.allSettled(
|
const results = await Promise.allSettled(
|
||||||
progList.map((pr) => getInstruments(pr.id))
|
progList.map((pr) => getInstruments(pr.id))
|
||||||
);
|
);
|
||||||
results.forEach((res) => {
|
results.forEach((res) => {
|
||||||
if (res.status === 'fulfilled' && Array.isArray(res.value) && res.value.length > 0) {
|
if (res.status === 'fulfilled' && Array.isArray(res.value)) {
|
||||||
hasApiData = true;
|
|
||||||
res.value.forEach((inst) => map.set(String(inst.id), inst));
|
res.value.forEach((inst) => map.set(String(inst.id), inst));
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
return Array.from(map.values());
|
||||||
if (hasApiData || map.size > 0) {
|
|
||||||
const allApiInstruments = Array.from(map.values());
|
|
||||||
purgeStoredInstructionDuplicates(allApiInstruments);
|
|
||||||
return allApiInstruments;
|
|
||||||
}
|
|
||||||
|
|
||||||
return getStoredInstructions();
|
|
||||||
};
|
};
|
||||||
|
|
||||||
export const getAllInstructions = getAllInstruments;
|
export const getAllInstructions = getAllInstruments;
|
||||||
@@ -546,40 +496,8 @@ export const createInstrument = async (payload: CreateInstrumentRequest): Promis
|
|||||||
description: payload.description,
|
description: payload.description,
|
||||||
};
|
};
|
||||||
|
|
||||||
try {
|
const res = await apiClient.post<Instrument>('/v1/api/governance/instruments', body);
|
||||||
const res = await apiClient.post<Instrument>('/v1/api/governance/instruments', body);
|
return res.data;
|
||||||
if (res.data && res.data.id) {
|
|
||||||
purgeStoredInstructionDuplicates([res.data]);
|
|
||||||
return res.data;
|
|
||||||
}
|
|
||||||
} catch (err: any) {
|
|
||||||
console.warn('[createInstrument] API create failed, falling back to local storage:', err);
|
|
||||||
const newId = Date.now();
|
|
||||||
const fallbackInstruction: Instrument = {
|
|
||||||
id: newId,
|
|
||||||
programmeId: toIntegerId(payload.programmeId),
|
|
||||||
code: code,
|
|
||||||
name: payload.name,
|
|
||||||
description: payload.description,
|
|
||||||
status: payload.status !== undefined ? Number(payload.status) : ActivityStatus.Active,
|
|
||||||
createdBy: payload.userId || 'system',
|
|
||||||
createdAt: new Date().toISOString(),
|
|
||||||
};
|
|
||||||
saveStoredInstruction(fallbackInstruction);
|
|
||||||
return fallbackInstruction;
|
|
||||||
}
|
|
||||||
|
|
||||||
// If API returned 200 without full body, return formatted item without saving duplicate to localStorage
|
|
||||||
return {
|
|
||||||
id: Date.now(),
|
|
||||||
programmeId: toIntegerId(payload.programmeId),
|
|
||||||
code: code,
|
|
||||||
name: payload.name,
|
|
||||||
description: payload.description,
|
|
||||||
status: payload.status !== undefined ? Number(payload.status) : ActivityStatus.Active,
|
|
||||||
createdBy: payload.userId || 'system',
|
|
||||||
createdAt: new Date().toISOString(),
|
|
||||||
};
|
|
||||||
};
|
};
|
||||||
|
|
||||||
export const createInstruction = createInstrument;
|
export const createInstruction = createInstrument;
|
||||||
@@ -596,33 +514,16 @@ export const updateInstrument = async (payload: UpdateInstrumentRequest): Promis
|
|||||||
status: Number(payload.status),
|
status: Number(payload.status),
|
||||||
};
|
};
|
||||||
|
|
||||||
try {
|
const res = await apiClient.put<Instrument>('/v1/api/governance/instruments', body);
|
||||||
const res = await apiClient.put<Instrument>('/v1/api/governance/instruments', body);
|
if (isApprovedOrActiveStatus(payload.status)) {
|
||||||
if (res.data && res.data.id) {
|
notifyTenantManagersAndExecutivesOnRecordStatusChange({
|
||||||
return res.data;
|
recordType: 'Funding Instrument',
|
||||||
}
|
recordId: targetId!,
|
||||||
} catch (err) {
|
newStatus: payload.status,
|
||||||
console.warn('[updateInstrument] API update failed:', err);
|
title: payload.name,
|
||||||
|
}).catch(console.warn);
|
||||||
}
|
}
|
||||||
|
return res.data;
|
||||||
const existing = getStoredInstructions().find((i) => String(i.id) === String(targetId));
|
|
||||||
const updatedInstrument: Instrument = {
|
|
||||||
id: targetId!,
|
|
||||||
programmeId: payload.programmeId ? toIntegerId(payload.programmeId) : (existing?.programmeId || 0),
|
|
||||||
code: payload.code || existing?.code || null,
|
|
||||||
name: payload.name,
|
|
||||||
description: payload.description,
|
|
||||||
status: Number(payload.status),
|
|
||||||
createdBy: existing?.createdBy || 'system',
|
|
||||||
createdAt: existing?.createdAt || new Date().toISOString(),
|
|
||||||
updatedBy: payload.userId || 'system',
|
|
||||||
updatedAt: new Date().toISOString(),
|
|
||||||
};
|
|
||||||
|
|
||||||
if (existing) {
|
|
||||||
saveStoredInstruction(updatedInstrument);
|
|
||||||
}
|
|
||||||
return updatedInstrument;
|
|
||||||
};
|
};
|
||||||
|
|
||||||
export const updateInstruction = updateInstrument;
|
export const updateInstruction = updateInstrument;
|
||||||
@@ -667,12 +568,21 @@ export const updateProgramme = async (payload: UpdateProgrammeRequest): Promise<
|
|||||||
if (ownerId && typeof ownerId === 'string' && ownerId.includes('@')) {
|
if (ownerId && typeof ownerId === 'string' && ownerId.includes('@')) {
|
||||||
ownerId = getUserIdByEmail(ownerId) || userSub || ownerId;
|
ownerId = getUserIdByEmail(ownerId) || userSub || ownerId;
|
||||||
}
|
}
|
||||||
|
const progStatus = Number(payload.status) || 1;
|
||||||
const res = await apiClient.put('/v1/api/projects/programme', {
|
const res = await apiClient.put('/v1/api/projects/programme', {
|
||||||
programmeId: toIntegerId(payload.programmeId),
|
programmeId: toIntegerId(payload.programmeId),
|
||||||
name: payload.name,
|
name: payload.name,
|
||||||
ownerId: ownerId || userSub || '00000000-0000-0000-0000-000000000000',
|
ownerId: ownerId || userSub || '00000000-0000-0000-0000-000000000000',
|
||||||
status: Number(payload.status) || 1,
|
status: progStatus,
|
||||||
});
|
});
|
||||||
|
if (isApprovedOrActiveStatus(progStatus)) {
|
||||||
|
notifyTenantManagersAndExecutivesOnRecordStatusChange({
|
||||||
|
recordType: 'Programme',
|
||||||
|
recordId: payload.programmeId,
|
||||||
|
newStatus: progStatus,
|
||||||
|
title: payload.name,
|
||||||
|
}).catch(console.warn);
|
||||||
|
}
|
||||||
return res.data;
|
return res.data;
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -725,6 +635,13 @@ export const updateProjectStatus = async (payload: UpdateProjectStatusRequest):
|
|||||||
projectId: toIntegerId(payload.projectId),
|
projectId: toIntegerId(payload.projectId),
|
||||||
status: Number(payload.status),
|
status: Number(payload.status),
|
||||||
});
|
});
|
||||||
|
if (isApprovedOrActiveStatus(payload.status)) {
|
||||||
|
notifyTenantManagersAndExecutivesOnRecordStatusChange({
|
||||||
|
recordType: 'Project',
|
||||||
|
recordId: payload.projectId,
|
||||||
|
newStatus: payload.status,
|
||||||
|
}).catch(console.warn);
|
||||||
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
export const getActivities = async (projectId: string | number): Promise<Activity[]> => {
|
export const getActivities = async (projectId: string | number): Promise<Activity[]> => {
|
||||||
@@ -826,6 +743,14 @@ export const updateMilestone = async (payload: UpdateMilestoneRequest): Promise<
|
|||||||
priority: Number(payload.priority) || 1,
|
priority: Number(payload.priority) || 1,
|
||||||
status: Number(payload.status) || 1,
|
status: Number(payload.status) || 1,
|
||||||
});
|
});
|
||||||
|
if (isApprovedOrActiveStatus(payload.status)) {
|
||||||
|
notifyTenantManagersAndExecutivesOnRecordStatusChange({
|
||||||
|
recordType: 'Milestone',
|
||||||
|
recordId: payload.milestoneId,
|
||||||
|
newStatus: payload.status,
|
||||||
|
title: payload.name,
|
||||||
|
}).catch(console.warn);
|
||||||
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
export const getProjectIndicators = async (projectId: string | number): Promise<Indicator[]> => {
|
export const getProjectIndicators = async (projectId: string | number): Promise<Indicator[]> => {
|
||||||
@@ -884,6 +809,13 @@ export const updateAward = async (payload: UpdateAwardRequest): Promise<void> =>
|
|||||||
amount: Number(payload.amount),
|
amount: Number(payload.amount),
|
||||||
...(payload.status !== undefined ? { status: Number(payload.status) } : {}),
|
...(payload.status !== undefined ? { status: Number(payload.status) } : {}),
|
||||||
});
|
});
|
||||||
|
if (payload.status === undefined || isApprovedOrActiveStatus(payload.status)) {
|
||||||
|
notifyTenantManagersAndExecutivesOnRecordStatusChange({
|
||||||
|
recordType: 'Award',
|
||||||
|
recordId: payload.awardId,
|
||||||
|
newStatus: payload.status !== undefined ? payload.status : ApprovalStatus.Approved,
|
||||||
|
}).catch(console.warn);
|
||||||
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
export const fetchHashId = async (id64: number | string): Promise<string> => {
|
export const fetchHashId = async (id64: number | string): Promise<string> => {
|
||||||
@@ -1018,6 +950,14 @@ export const updateContract = async (payload: UpdateContractRequest): Promise<vo
|
|||||||
contractId: toIntegerId(payload.contractId),
|
contractId: toIntegerId(payload.contractId),
|
||||||
status: Number(payload.status),
|
status: Number(payload.status),
|
||||||
});
|
});
|
||||||
|
if (isApprovedOrActiveStatus(payload.status)) {
|
||||||
|
notifyTenantManagersAndExecutivesOnRecordStatusChange({
|
||||||
|
recordType: 'Contract',
|
||||||
|
recordId: payload.contractId,
|
||||||
|
newStatus: payload.status,
|
||||||
|
}).catch(console.warn);
|
||||||
|
}
|
||||||
|
|
||||||
};
|
};
|
||||||
|
|
||||||
export const uploadContractFile = async (
|
export const uploadContractFile = async (
|
||||||
@@ -1078,6 +1018,13 @@ export const updateBudget = async (payload: UpdateBudgetRequest): Promise<void>
|
|||||||
budgetId: toIntegerId(payload.budgetId),
|
budgetId: toIntegerId(payload.budgetId),
|
||||||
status: Number(payload.status),
|
status: Number(payload.status),
|
||||||
});
|
});
|
||||||
|
if (isApprovedOrActiveStatus(payload.status)) {
|
||||||
|
notifyTenantManagersAndExecutivesOnRecordStatusChange({
|
||||||
|
recordType: 'Budget',
|
||||||
|
recordId: payload.budgetId,
|
||||||
|
newStatus: payload.status,
|
||||||
|
}).catch(console.warn);
|
||||||
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
// ==========================================
|
// ==========================================
|
||||||
@@ -1116,6 +1063,11 @@ export const updateDisbursement = async (payload: UpdateDisbursementRequest): Pr
|
|||||||
export const approveDisbursement = async (disbursementId: string | number): Promise<void> => {
|
export const approveDisbursement = async (disbursementId: string | number): Promise<void> => {
|
||||||
const hashedDisbId = await toolsService.encodeId(disbursementId);
|
const hashedDisbId = await toolsService.encodeId(disbursementId);
|
||||||
await apiClient.post(`/v1/api/execution/disbursements/${hashedDisbId}/approve`, {});
|
await apiClient.post(`/v1/api/execution/disbursements/${hashedDisbId}/approve`, {});
|
||||||
|
notifyTenantManagersAndExecutivesOnRecordStatusChange({
|
||||||
|
recordType: 'Disbursement',
|
||||||
|
recordId: disbursementId,
|
||||||
|
newStatus: ApprovalStatus.Approved,
|
||||||
|
}).catch(console.warn);
|
||||||
};
|
};
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -1277,13 +1229,13 @@ export const getMilestoneEvidence = async (milestoneId: string | number): Promis
|
|||||||
export const getProjectEvidence = async (projectId: string | number): Promise<Evidence[]> => {
|
export const getProjectEvidence = async (projectId: string | number): Promise<Evidence[]> => {
|
||||||
try {
|
try {
|
||||||
const hashedProjectId = await toolsService.resolveProjectId(projectId);
|
const hashedProjectId = await toolsService.resolveProjectId(projectId);
|
||||||
const res = await apiClient.get<Evidence[]>(`/v1/api/evidence/${hashedProjectId}`);
|
const res = await apiClient.get<Evidence[]>(`/v1/api/evidence/projects/${hashedProjectId}`);
|
||||||
return res.data || [];
|
return res.data || [];
|
||||||
} catch (err: any) {
|
} catch (err: any) {
|
||||||
// If hashed path parameter returns 404 or 400 and projectId differs, attempt with raw ID
|
// If hashed path parameter returns 404 or 400 and projectId differs, attempt with raw ID
|
||||||
if ((err?.response?.status === 404 || err?.response?.status === 400) && String(projectId) !== '') {
|
if ((err?.response?.status === 404 || err?.response?.status === 400) && String(projectId) !== '') {
|
||||||
try {
|
try {
|
||||||
const rawRes = await apiClient.get<Evidence[]>(`/v1/api/evidence/${projectId}`);
|
const rawRes = await apiClient.get<Evidence[]>(`/v1/api/evidence/projects/${projectId}`);
|
||||||
return rawRes.data || [];
|
return rawRes.data || [];
|
||||||
} catch (fallbackErr: any) {
|
} catch (fallbackErr: any) {
|
||||||
if (fallbackErr?.response?.status === 404) {
|
if (fallbackErr?.response?.status === 404) {
|
||||||
@@ -1316,6 +1268,13 @@ export const updateEvidence = async (payload: UpdateEvidenceRequest): Promise<vo
|
|||||||
evidenceType: Number(payload.evidenceType),
|
evidenceType: Number(payload.evidenceType),
|
||||||
status: Number(payload.status),
|
status: Number(payload.status),
|
||||||
});
|
});
|
||||||
|
if (isApprovedOrActiveStatus(payload.status)) {
|
||||||
|
notifyTenantManagersAndExecutivesOnRecordStatusChange({
|
||||||
|
recordType: 'Evidence',
|
||||||
|
recordId: payload.evidenceId,
|
||||||
|
newStatus: payload.status,
|
||||||
|
}).catch(console.warn);
|
||||||
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
// ==========================================
|
// ==========================================
|
||||||
@@ -1555,6 +1514,7 @@ export const sendInvite = async (payload: InviteRequest): Promise<Invite> => {
|
|||||||
invitedOrganisationId: toSafeInt64String(payload.invitedOrganisationId),
|
invitedOrganisationId: toSafeInt64String(payload.invitedOrganisationId),
|
||||||
awardId: toSafeInt64String(payload.awardId),
|
awardId: toSafeInt64String(payload.awardId),
|
||||||
contractId: toSafeInt64String(payload.contractId),
|
contractId: toSafeInt64String(payload.contractId),
|
||||||
|
projectId: toSafeInt64String(payload.projectId || 0),
|
||||||
recipient: payload.recipient,
|
recipient: payload.recipient,
|
||||||
message: payload.message,
|
message: payload.message,
|
||||||
platform: payload.platform !== undefined ? Number(payload.platform) : 1,
|
platform: payload.platform !== undefined ? Number(payload.platform) : 1,
|
||||||
@@ -1642,7 +1602,9 @@ export const createNotification = async (payload: CreateNotificationRequest): Pr
|
|||||||
subject: payload.subject || null,
|
subject: payload.subject || null,
|
||||||
destination: payload.destination || 'In-App User Inbox',
|
destination: payload.destination || 'In-App User Inbox',
|
||||||
message: payload.message,
|
message: payload.message,
|
||||||
status: payload.status !== undefined ? payload.status : undefined,
|
status: payload.status !== undefined
|
||||||
|
? payload.status
|
||||||
|
: (Number(payload.platform) === NotificationPlatform.InApp ? NotificationStatus.Delivered : undefined),
|
||||||
});
|
});
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -1664,6 +1626,13 @@ export const markNotificationAsRead = async (notificationId: number | string): P
|
|||||||
};
|
};
|
||||||
|
|
||||||
// ==========================================
|
// ==========================================
|
||||||
|
export const archiveNotification = async (notificationId: number | string): Promise<void> => {
|
||||||
|
await updateNotification({
|
||||||
|
notificationId: toIntegerId(notificationId),
|
||||||
|
status: NotificationStatus.Archived,
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
// 9. Governance: Tenants APIs
|
// 9. Governance: Tenants APIs
|
||||||
// ==========================================
|
// ==========================================
|
||||||
export const getTenants = async (): Promise<Tenant[]> => {
|
export const getTenants = async (): Promise<Tenant[]> => {
|
||||||
@@ -1689,6 +1658,14 @@ export const updateTenant = async (payload: UpdateTenantRequest): Promise<void>
|
|||||||
name: payload.name,
|
name: payload.name,
|
||||||
status: Number(payload.status),
|
status: Number(payload.status),
|
||||||
});
|
});
|
||||||
|
if (isApprovedOrActiveStatus(payload.status)) {
|
||||||
|
notifyTenantManagersAndExecutivesOnRecordStatusChange({
|
||||||
|
recordType: 'Tenant',
|
||||||
|
recordId: payload.tenantId,
|
||||||
|
newStatus: payload.status,
|
||||||
|
title: payload.name,
|
||||||
|
}).catch(console.warn);
|
||||||
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
export const getTenantOrganisations = async (tenantId: string | number): Promise<TenantOrganisation[]> => {
|
export const getTenantOrganisations = async (tenantId: string | number): Promise<TenantOrganisation[]> => {
|
||||||
@@ -1854,6 +1831,14 @@ export const updateOutcome = async (payload: UpdateOutcomeRequest): Promise<void
|
|||||||
description: payload.description,
|
description: payload.description,
|
||||||
status: Number(payload.status),
|
status: Number(payload.status),
|
||||||
});
|
});
|
||||||
|
if (isApprovedOrActiveStatus(payload.status)) {
|
||||||
|
notifyTenantManagersAndExecutivesOnRecordStatusChange({
|
||||||
|
recordType: 'Outcome',
|
||||||
|
recordId: payload.outcomeId,
|
||||||
|
newStatus: payload.status,
|
||||||
|
title: payload.name,
|
||||||
|
}).catch(console.warn);
|
||||||
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
// ==========================================
|
// ==========================================
|
||||||
@@ -1915,6 +1900,14 @@ export const updateChangeRequest = async (payload: UpdateChangeRequest): Promise
|
|||||||
revisedBudget: Number(payload.revisedBudget),
|
revisedBudget: Number(payload.revisedBudget),
|
||||||
status: Number(payload.status),
|
status: Number(payload.status),
|
||||||
});
|
});
|
||||||
|
if (isApprovedOrActiveStatus(payload.status)) {
|
||||||
|
notifyTenantManagersAndExecutivesOnRecordStatusChange({
|
||||||
|
recordType: 'Change Request',
|
||||||
|
recordId: payload.changeRequestId,
|
||||||
|
newStatus: payload.status,
|
||||||
|
title: payload.title,
|
||||||
|
}).catch(console.warn);
|
||||||
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
// ==========================================
|
// ==========================================
|
||||||
@@ -1975,6 +1968,14 @@ export const updateComplianceItem = async (payload: UpdateComplianceItemRequest)
|
|||||||
status: Number(payload.status),
|
status: Number(payload.status),
|
||||||
beneficiaryId: payload.beneficiaryId ? toIntegerId(payload.beneficiaryId) : null,
|
beneficiaryId: payload.beneficiaryId ? toIntegerId(payload.beneficiaryId) : null,
|
||||||
});
|
});
|
||||||
|
if (isApprovedOrActiveStatus(payload.status)) {
|
||||||
|
notifyTenantManagersAndExecutivesOnRecordStatusChange({
|
||||||
|
recordType: 'Compliance Item',
|
||||||
|
recordId: payload.complianceItemId,
|
||||||
|
newStatus: payload.status,
|
||||||
|
title: payload.title,
|
||||||
|
}).catch(console.warn);
|
||||||
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
// ==========================================
|
// ==========================================
|
||||||
@@ -2042,6 +2043,15 @@ export const updateIssue = async (payload: UpdateIssueRequest): Promise<void> =>
|
|||||||
status: payload.status !== undefined ? Number(payload.status) : ActivityStatus.Active,
|
status: payload.status !== undefined ? Number(payload.status) : ActivityStatus.Active,
|
||||||
assignedTo: payload.assignedTo,
|
assignedTo: payload.assignedTo,
|
||||||
});
|
});
|
||||||
|
const issueStatus = payload.status !== undefined ? payload.status : ActivityStatus.Active;
|
||||||
|
if (isApprovedOrActiveStatus(issueStatus)) {
|
||||||
|
notifyTenantManagersAndExecutivesOnRecordStatusChange({
|
||||||
|
recordType: 'Issue',
|
||||||
|
recordId: payload.issueId,
|
||||||
|
newStatus: issueStatus,
|
||||||
|
title: payload.title,
|
||||||
|
}).catch(console.warn);
|
||||||
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
// ==========================================
|
// ==========================================
|
||||||
@@ -2106,77 +2116,30 @@ export const updateSiteVisit = async (payload: UpdateSiteVisitRequest): Promise<
|
|||||||
status: Number(payload.status),
|
status: Number(payload.status),
|
||||||
visitedAt: visitedAtIso,
|
visitedAt: visitedAtIso,
|
||||||
});
|
});
|
||||||
};
|
if (isApprovedOrActiveStatus(payload.status)) {
|
||||||
|
notifyTenantManagersAndExecutivesOnRecordStatusChange({
|
||||||
const LOCAL_RULES_KEY = 'pfm_local_rules';
|
recordType: 'Site Visit',
|
||||||
|
recordId: payload.siteVisitId,
|
||||||
export const purgeStoredRuleDuplicates = (apiRules: Rule[]): void => {
|
newStatus: payload.status,
|
||||||
if (typeof window === 'undefined' || !Array.isArray(apiRules) || apiRules.length === 0) return;
|
title: payload.inspectorName,
|
||||||
try {
|
}).catch(console.warn);
|
||||||
const raw = localStorage.getItem(LOCAL_RULES_KEY);
|
|
||||||
if (!raw) return;
|
|
||||||
const local: Rule[] = JSON.parse(raw);
|
|
||||||
if (!Array.isArray(local) || local.length === 0) return;
|
|
||||||
|
|
||||||
const apiIds = new Set(apiRules.map((r) => String(r.id)));
|
|
||||||
const apiKeys = new Set(apiRules.map((r) => `${r.programmeId}::${(r.name || '').toLowerCase().trim()}`));
|
|
||||||
|
|
||||||
const remaining = local.filter(
|
|
||||||
(l) => !apiIds.has(String(l.id)) && !apiKeys.has(`${l.programmeId}::${(l.name || '').toLowerCase().trim()}`)
|
|
||||||
);
|
|
||||||
|
|
||||||
if (remaining.length !== local.length) {
|
|
||||||
localStorage.setItem(LOCAL_RULES_KEY, JSON.stringify(remaining));
|
|
||||||
}
|
|
||||||
} catch (e) {
|
|
||||||
console.warn('Failed to purge stored rule duplicates', e);
|
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
export const getStoredRules = (): Rule[] => {
|
export const purgeStoredRuleDuplicates = (_apiRules?: Rule[]): void => {};
|
||||||
if (typeof window === 'undefined') return [];
|
|
||||||
try {
|
|
||||||
const raw = localStorage.getItem(LOCAL_RULES_KEY);
|
|
||||||
return raw ? JSON.parse(raw) : [];
|
|
||||||
} catch {
|
|
||||||
return [];
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
export const saveStoredRule = (rule: Rule): void => {
|
export const getStoredRules = (): Rule[] => [];
|
||||||
if (typeof window === 'undefined') return;
|
|
||||||
try {
|
export const saveStoredRule = (_rule?: Rule): void => {};
|
||||||
const current = getStoredRules();
|
|
||||||
const idx = current.findIndex(
|
|
||||||
(r) => String(r.id) === String(rule.id) ||
|
|
||||||
(String(r.programmeId) === String(rule.programmeId) && (r.name || '').trim().toLowerCase() === (rule.name || '').trim().toLowerCase())
|
|
||||||
);
|
|
||||||
if (idx >= 0) {
|
|
||||||
current[idx] = { ...current[idx], ...rule, updatedAt: new Date().toISOString() };
|
|
||||||
} else {
|
|
||||||
current.unshift(rule);
|
|
||||||
}
|
|
||||||
localStorage.setItem(LOCAL_RULES_KEY, JSON.stringify(current));
|
|
||||||
} catch (e) {
|
|
||||||
console.warn('Failed to save stored rule', e);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
// ==========================================
|
// ==========================================
|
||||||
// 15. Execution: Programme Rules APIs
|
// 15. Execution: Programme Rules APIs
|
||||||
// ==========================================
|
// ==========================================
|
||||||
export const getRule = async (ruleId: string | number): Promise<Rule> => {
|
export const getRule = async (ruleId: string | number): Promise<Rule> => {
|
||||||
try {
|
const hashedRuleId = await toolsService.encodeId(ruleId);
|
||||||
const hashedRuleId = await toolsService.encodeId(ruleId);
|
const res = await apiClient.get<Rule>(`/v1/api/execution/rules/${hashedRuleId}`);
|
||||||
const res = await apiClient.get<Rule>(`/v1/api/execution/rules/${hashedRuleId}`);
|
if (res.data) {
|
||||||
if (res.data) {
|
return res.data;
|
||||||
purgeStoredRuleDuplicates([res.data]);
|
|
||||||
return res.data;
|
|
||||||
}
|
|
||||||
} catch {
|
|
||||||
const localRule = getStoredRules().find((r) => String(r.id) === String(ruleId));
|
|
||||||
if (localRule) return localRule;
|
|
||||||
throw new Error('Rule not found');
|
|
||||||
}
|
}
|
||||||
throw new Error('Rule not found');
|
throw new Error('Rule not found');
|
||||||
};
|
};
|
||||||
@@ -2194,38 +2157,16 @@ export const getRules = async (
|
|||||||
pagination: Pagination = { page: 1, pageSize: 100 },
|
pagination: Pagination = { page: 1, pageSize: 100 },
|
||||||
programmeId?: string | number
|
programmeId?: string | number
|
||||||
): Promise<Rule[]> => {
|
): Promise<Rule[]> => {
|
||||||
let apiRules: Rule[] = [];
|
const res = await apiClient.post<Rule[]>('/v1/api/execution/programmes/rules', {
|
||||||
let apiSucceeded = false;
|
index: pagination.index ? toIntegerId(pagination.index) : 0,
|
||||||
try {
|
page: pagination.page || 1,
|
||||||
const res = await apiClient.post<Rule[]>('/v1/api/execution/programmes/rules', {
|
pageSize: pagination.pageSize || 100,
|
||||||
index: pagination.index ? toIntegerId(pagination.index) : 0,
|
});
|
||||||
page: pagination.page || 1,
|
let result = Array.isArray(res.data) ? res.data : [];
|
||||||
pageSize: pagination.pageSize || 100,
|
|
||||||
});
|
|
||||||
if (Array.isArray(res.data)) {
|
|
||||||
apiRules = res.data;
|
|
||||||
apiSucceeded = true;
|
|
||||||
}
|
|
||||||
} catch (err) {
|
|
||||||
console.warn('[getRules] Error fetching from backend API:', err);
|
|
||||||
}
|
|
||||||
|
|
||||||
if (apiSucceeded) {
|
|
||||||
purgeStoredRuleDuplicates(apiRules);
|
|
||||||
let result = apiRules;
|
|
||||||
if (programmeId != null && programmeId !== '' && programmeId !== 'all') {
|
|
||||||
result = result.filter((r) => String(r.programmeId) === String(programmeId));
|
|
||||||
}
|
|
||||||
return result;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Fallback to local storage only if API call failed
|
|
||||||
const localRules = getStoredRules();
|
|
||||||
let combined = localRules;
|
|
||||||
if (programmeId != null && programmeId !== '' && programmeId !== 'all') {
|
if (programmeId != null && programmeId !== '' && programmeId !== 'all') {
|
||||||
combined = combined.filter((r) => String(r.programmeId) === String(programmeId));
|
result = result.filter((r) => String(r.programmeId) === String(programmeId));
|
||||||
}
|
}
|
||||||
return combined;
|
return result;
|
||||||
};
|
};
|
||||||
|
|
||||||
export const createRule = async (payload: CreateRuleRequest): Promise<Rule> => {
|
export const createRule = async (payload: CreateRuleRequest): Promise<Rule> => {
|
||||||
@@ -2237,70 +2178,21 @@ export const createRule = async (payload: CreateRuleRequest): Promise<Rule> => {
|
|||||||
description: payload.description,
|
description: payload.description,
|
||||||
};
|
};
|
||||||
|
|
||||||
try {
|
const res = await apiClient.post<Rule>('/v1/api/execution/rules', body);
|
||||||
const res = await apiClient.post<Rule>('/v1/api/execution/rules', body);
|
return res.data;
|
||||||
if (res.data && res.data.id) {
|
|
||||||
purgeStoredRuleDuplicates([res.data]);
|
|
||||||
return res.data;
|
|
||||||
}
|
|
||||||
} catch (err) {
|
|
||||||
console.warn('[createRule] API create failed, falling back to local storage:', err);
|
|
||||||
const newId = Date.now();
|
|
||||||
const fallbackRule: Rule = {
|
|
||||||
id: newId,
|
|
||||||
programmeId: toIntegerId(payload.programmeId),
|
|
||||||
name: payload.name,
|
|
||||||
description: payload.description,
|
|
||||||
status: ActivityStatus.Active,
|
|
||||||
version: 1,
|
|
||||||
createdBy: validUserId,
|
|
||||||
createdAt: new Date().toISOString(),
|
|
||||||
};
|
|
||||||
saveStoredRule(fallbackRule);
|
|
||||||
return fallbackRule;
|
|
||||||
}
|
|
||||||
|
|
||||||
return {
|
|
||||||
id: Date.now(),
|
|
||||||
programmeId: toIntegerId(payload.programmeId),
|
|
||||||
name: payload.name,
|
|
||||||
description: payload.description,
|
|
||||||
status: ActivityStatus.Active,
|
|
||||||
version: 1,
|
|
||||||
createdBy: validUserId,
|
|
||||||
createdAt: new Date().toISOString(),
|
|
||||||
};
|
|
||||||
};
|
};
|
||||||
|
|
||||||
export const updateRule = async (payload: UpdateRuleRequest): Promise<void> => {
|
export const updateRule = async (payload: UpdateRuleRequest): Promise<void> => {
|
||||||
const validUserId = toValidUuid(payload.userId);
|
const validUserId = toValidUuid(payload.userId);
|
||||||
try {
|
await apiClient.put('/v1/api/execution/rules', {
|
||||||
await apiClient.put('/v1/api/execution/rules', {
|
ruleId: toIntegerId(payload.ruleId),
|
||||||
ruleId: toIntegerId(payload.ruleId),
|
userId: validUserId,
|
||||||
userId: validUserId,
|
name: payload.name,
|
||||||
name: payload.name,
|
description: payload.description,
|
||||||
description: payload.description,
|
status: Number(payload.status),
|
||||||
status: Number(payload.status),
|
version: Number(payload.version || 1),
|
||||||
version: Number(payload.version || 1),
|
...(payload.programmeId ? { programmeId: toIntegerId(payload.programmeId) } : {}),
|
||||||
...(payload.programmeId ? { programmeId: toIntegerId(payload.programmeId) } : {}),
|
});
|
||||||
});
|
|
||||||
} catch (err) {
|
|
||||||
console.warn('[updateRule] API update failed:', err);
|
|
||||||
const existing = getStoredRules().find((r) => String(r.id) === String(payload.ruleId));
|
|
||||||
const updatedRule: Rule = {
|
|
||||||
id: payload.ruleId,
|
|
||||||
programmeId: payload.programmeId ? toIntegerId(payload.programmeId) : (existing?.programmeId || 0),
|
|
||||||
name: payload.name,
|
|
||||||
description: payload.description,
|
|
||||||
status: Number(payload.status),
|
|
||||||
version: Number(payload.version || 1),
|
|
||||||
createdBy: existing?.createdBy || validUserId,
|
|
||||||
createdAt: existing?.createdAt || new Date().toISOString(),
|
|
||||||
updatedBy: validUserId,
|
|
||||||
updatedAt: new Date().toISOString(),
|
|
||||||
};
|
|
||||||
saveStoredRule(updatedRule);
|
|
||||||
}
|
|
||||||
};
|
};
|
||||||
|
|
||||||
// ==========================================
|
// ==========================================
|
||||||
|
|||||||
@@ -60,6 +60,8 @@ export const apiClient = axios.create({
|
|||||||
headers: {
|
headers: {
|
||||||
'Content-Type': 'application/json; version=1',
|
'Content-Type': 'application/json; version=1',
|
||||||
Accept: 'application/json; version=1',
|
Accept: 'application/json; version=1',
|
||||||
|
'Cache-Control': 'no-cache, no-store, must-revalidate',
|
||||||
|
Pragma: 'no-cache',
|
||||||
},
|
},
|
||||||
timeout: 15000,
|
timeout: 15000,
|
||||||
});
|
});
|
||||||
@@ -81,34 +83,16 @@ apiClient.interceptors.request.use(
|
|||||||
config.url?.includes('amazonaws.com') ||
|
config.url?.includes('amazonaws.com') ||
|
||||||
config.url?.includes('garage');
|
config.url?.includes('garage');
|
||||||
|
|
||||||
// Attach mutation epoch or explicit timestamp to GET requests for OutputCache management
|
// Always attach freshness timestamp and mutation epoch to internal GET requests
|
||||||
|
// to guarantee zero stale data, bypass browser HTTP caches, and prevent stale OutputCache hits
|
||||||
if (config.method?.toUpperCase() === 'GET' && !isExternal) {
|
if (config.method?.toUpperCase() === 'GET' && !isExternal) {
|
||||||
const headers = config.headers || {};
|
if (config.params instanceof URLSearchParams) {
|
||||||
const hasNoCacheHeader =
|
config.params.set('_t', String(Date.now()));
|
||||||
headers['Cache-Control'] === 'no-cache' ||
|
config.params.set('_v', String(getMutationEpoch()));
|
||||||
headers['cache-control'] === 'no-cache' ||
|
|
||||||
headers['Pragma'] === 'no-cache';
|
|
||||||
|
|
||||||
if (hasNoCacheHeader) {
|
|
||||||
// Explicit freshness requested: attach fresh timestamp
|
|
||||||
if (config.params instanceof URLSearchParams) {
|
|
||||||
config.params.set('_t', String(Date.now()));
|
|
||||||
} else {
|
|
||||||
config.params = config.params || {};
|
|
||||||
config.params._t = Date.now();
|
|
||||||
}
|
|
||||||
} else {
|
} else {
|
||||||
// Standard GET: attach current mutation epoch
|
config.params = config.params || {};
|
||||||
if (config.params instanceof URLSearchParams) {
|
config.params._t = Date.now();
|
||||||
if (!config.params.has('_v') && !config.params.has('_t')) {
|
config.params._v = getMutationEpoch();
|
||||||
config.params.set('_v', String(getMutationEpoch()));
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
config.params = config.params || {};
|
|
||||||
if (!config.params._v && !config.params._t) {
|
|
||||||
config.params._v = getMutationEpoch();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,416 @@
|
|||||||
|
import {
|
||||||
|
NotificationPlatform,
|
||||||
|
NotificationStatus,
|
||||||
|
ApprovalStatus,
|
||||||
|
ActivityStatus,
|
||||||
|
ContractStatus,
|
||||||
|
ComplianceStatus,
|
||||||
|
User,
|
||||||
|
Invite,
|
||||||
|
CreateNotificationRequest,
|
||||||
|
} from '../types';
|
||||||
|
import { apiClient } from './httpClient';
|
||||||
|
import {
|
||||||
|
getCurrentUserSub,
|
||||||
|
getCurrentUserClaims,
|
||||||
|
getCurrentUserTenantId,
|
||||||
|
} from '../auth/AuthProvider';
|
||||||
|
|
||||||
|
export const CONSOLE_PORTAL_URL = 'https://console.ifri.khongisa.co.za/';
|
||||||
|
export const PARTNER_PORTAL_URL = 'https://partner.ifri.khongisa.co.za/';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Dispatches a notification request directly via apiClient
|
||||||
|
*/
|
||||||
|
export async function dispatchNotification(payload: CreateNotificationRequest): Promise<void> {
|
||||||
|
const tenantId = getCurrentUserTenantId();
|
||||||
|
const orgId =
|
||||||
|
payload.organisationId !== undefined && payload.organisationId !== '' && payload.organisationId !== null
|
||||||
|
? Number(payload.organisationId)
|
||||||
|
: (tenantId !== null ? tenantId : 1);
|
||||||
|
|
||||||
|
await apiClient.post('/v1/api/communications/notifications', {
|
||||||
|
organisationId: orgId,
|
||||||
|
recipient: payload.recipient,
|
||||||
|
platform: payload.platform,
|
||||||
|
subject: payload.subject || null,
|
||||||
|
destination: payload.destination || 'In-App User Inbox',
|
||||||
|
message: payload.message,
|
||||||
|
status: payload.status !== undefined ? payload.status : undefined,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Retrieves users from directory directly via apiClient
|
||||||
|
*/
|
||||||
|
export async function fetchDirectoryUsers(page = 1, pageSize = 200): Promise<User[]> {
|
||||||
|
try {
|
||||||
|
const res = await apiClient.post<User[]>(`/v1/api/users/${page}/${pageSize}`, {});
|
||||||
|
return res.data || [];
|
||||||
|
} catch (err) {
|
||||||
|
console.warn('[NotificationRules] Failed to fetch users directory:', err);
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Validates whether a given status corresponds to "Approved" or "Active".
|
||||||
|
*/
|
||||||
|
export function isApprovedOrActiveStatus(status: number | string | undefined | null): boolean {
|
||||||
|
if (status === undefined || status === null) return false;
|
||||||
|
|
||||||
|
// Numeric enum matching
|
||||||
|
const numStatus = Number(status);
|
||||||
|
if (
|
||||||
|
numStatus === ApprovalStatus.Approved || // 3
|
||||||
|
numStatus === ActivityStatus.Active || // 1
|
||||||
|
numStatus === ContractStatus.Active || // 3
|
||||||
|
numStatus === ContractStatus.Executed || // 3
|
||||||
|
numStatus === ComplianceStatus.Met || // 2
|
||||||
|
numStatus === ComplianceStatus.Exceeded // 3
|
||||||
|
) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
// String matching
|
||||||
|
const strStatus = String(status).toLowerCase().trim();
|
||||||
|
return (
|
||||||
|
strStatus === 'approved' ||
|
||||||
|
strStatus === 'active' ||
|
||||||
|
strStatus === 'executed' ||
|
||||||
|
strStatus === 'met' ||
|
||||||
|
strStatus === 'exceeded' ||
|
||||||
|
strStatus === 'verified'
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Formats a friendly label for the approved or active status.
|
||||||
|
*/
|
||||||
|
export function getApprovedOrActiveStatusLabel(status: number | string | undefined | null): string {
|
||||||
|
if (status === undefined || status === null) return 'Active';
|
||||||
|
const numStatus = Number(status);
|
||||||
|
if (numStatus === ApprovalStatus.Approved) return 'Approved';
|
||||||
|
if (numStatus === ActivityStatus.Active) return 'Active';
|
||||||
|
if (numStatus === ContractStatus.Active) return 'Active';
|
||||||
|
if (numStatus === ComplianceStatus.Met) return 'Compliant (Met)';
|
||||||
|
|
||||||
|
const str = String(status);
|
||||||
|
return str.charAt(0).toUpperCase() + str.slice(1);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Retrieves Tenant Managers and Executives within the active tenant context.
|
||||||
|
* Falls back to the currently logged in user if directory lookup is restricted or empty.
|
||||||
|
*/
|
||||||
|
export async function getTenantManagersAndExecutives(): Promise<User[]> {
|
||||||
|
try {
|
||||||
|
const tenantId = getCurrentUserTenantId();
|
||||||
|
const users = await fetchDirectoryUsers(1, 200);
|
||||||
|
|
||||||
|
const managersAndExecutives = users.filter((u) => {
|
||||||
|
const roleStr = String(u.role || '').toLowerCase();
|
||||||
|
const isManagerOrExec =
|
||||||
|
roleStr.includes('manager') ||
|
||||||
|
roleStr.includes('executive') ||
|
||||||
|
roleStr.includes('director') ||
|
||||||
|
roleStr === '2' || // Role ID 2 = Executive
|
||||||
|
roleStr === '4'; // Role ID 4 = Manager
|
||||||
|
|
||||||
|
const matchesTenant =
|
||||||
|
!tenantId || !u.tenant_id || Number(u.tenant_id) === Number(tenantId);
|
||||||
|
|
||||||
|
return isManagerOrExec && matchesTenant;
|
||||||
|
});
|
||||||
|
|
||||||
|
if (managersAndExecutives.length > 0) {
|
||||||
|
return managersAndExecutives;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Secondary fallback: Any user with admin or supervisor if no direct manager/executive found
|
||||||
|
const elevatedUsers = users.filter((u) => {
|
||||||
|
const roleStr = String(u.role || '').toLowerCase();
|
||||||
|
return (
|
||||||
|
roleStr.includes('admin') ||
|
||||||
|
roleStr.includes('supervisor') ||
|
||||||
|
roleStr === '1' ||
|
||||||
|
roleStr === '3'
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
if (elevatedUsers.length > 0) {
|
||||||
|
return elevatedUsers;
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
console.warn('[NotificationRules] Directory lookup failed, using authenticated identity fallback:', err);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Graceful fallback to authenticated user identity so telemetry notification is recorded
|
||||||
|
const currentSub = getCurrentUserSub();
|
||||||
|
const currentClaims = getCurrentUserClaims();
|
||||||
|
if (currentSub) {
|
||||||
|
return [
|
||||||
|
{
|
||||||
|
id: currentSub,
|
||||||
|
email: (currentClaims.email as string) || (currentClaims.preferred_username as string) || 'manager@pfm.org.za',
|
||||||
|
status: ActivityStatus.Active,
|
||||||
|
lastLoginAt: null,
|
||||||
|
role: 'Manager',
|
||||||
|
name: (currentClaims.name as string) || 'Tenant Manager',
|
||||||
|
},
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface RecordStatusChangeNotificationParams {
|
||||||
|
recordType: string;
|
||||||
|
recordId: string | number;
|
||||||
|
newStatus: number | string;
|
||||||
|
title?: string;
|
||||||
|
previousStatus?: number | string;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* RULE 1:
|
||||||
|
* "Whenever a record change status to Approved, Active (for contract or similar records) exclusding users;
|
||||||
|
* the system must issue an InApp and Email message to Tenant Managers and Executives."
|
||||||
|
*/
|
||||||
|
export async function notifyTenantManagersAndExecutivesOnRecordStatusChange(
|
||||||
|
params: RecordStatusChangeNotificationParams
|
||||||
|
): Promise<void> {
|
||||||
|
const { recordType, recordId, newStatus, title } = params;
|
||||||
|
|
||||||
|
// Rule constraint: Strictly exclude users
|
||||||
|
if (recordType.toLowerCase() === 'user' || recordType.toLowerCase() === 'users') {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Only trigger when the target status is Approved or Active
|
||||||
|
if (!isApprovedOrActiveStatus(newStatus)) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const statusLabel = getApprovedOrActiveStatusLabel(newStatus);
|
||||||
|
const orgId = getCurrentUserTenantId() || 1;
|
||||||
|
const managersAndExecutives = await getTenantManagersAndExecutives();
|
||||||
|
|
||||||
|
if (managersAndExecutives.length === 0) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const recordDescriptor = title
|
||||||
|
? `${recordType} "${title}" (#${recordId})`
|
||||||
|
: `${recordType} #${recordId}`;
|
||||||
|
|
||||||
|
const inAppSubject = `[${statusLabel}] ${recordType} #${recordId} is now ${statusLabel}`;
|
||||||
|
const inAppMessage = `### Statutory Record Update\n\nThe record **${recordDescriptor}** has officially transitioned to **${statusLabel}** status.\n\n- **Record Category:** ${recordType}\n- **Identifier:** #${recordId}\n- **Updated Status:** ${statusLabel}\n- **Timestamp:** ${new Date().toLocaleString()}\n- **Portal Reference:** [Open PFM Console](${CONSOLE_PORTAL_URL})\n\nPlease review the updated portfolio commitments accordingly.`;
|
||||||
|
|
||||||
|
const emailSubject = `[PFM Governance Alert] ${recordType} #${recordId} Status Transition: ${statusLabel}`;
|
||||||
|
const emailMessage = `### Formal Statutory Notification\n\nThis is an automated notification to Tenant Managers and Executives regarding an approved record status update.\n\n**Record Details:**\n- **Category:** ${recordType}\n- **Reference Identifier:** #${recordId}\n${title ? `- **Title:** ${title}\n` : ''}- **New Lifecycle Status:** **${statusLabel}**\n- **Effective Timestamp:** ${new Date().toUTCString()}\n\nYou can inspect this record in the PFM Management Console: [Access Management Console](${CONSOLE_PORTAL_URL}).`;
|
||||||
|
|
||||||
|
// Dispatch both InApp and Email notifications to each Tenant Manager and Executive
|
||||||
|
for (const recipient of managersAndExecutives) {
|
||||||
|
if (!recipient.id) continue;
|
||||||
|
const recipientEmail = recipient.email || 'manager@pfm.org.za';
|
||||||
|
|
||||||
|
// 1. In-App Notification
|
||||||
|
try {
|
||||||
|
const inAppReq: CreateNotificationRequest = {
|
||||||
|
organisationId: orgId,
|
||||||
|
recipient: recipient.id,
|
||||||
|
platform: NotificationPlatform.InApp,
|
||||||
|
subject: inAppSubject,
|
||||||
|
destination: recipientEmail,
|
||||||
|
message: inAppMessage,
|
||||||
|
status: NotificationStatus.Delivered,
|
||||||
|
};
|
||||||
|
await dispatchNotification(inAppReq);
|
||||||
|
} catch (err) {
|
||||||
|
console.warn(`[NotificationRules] Failed to send InApp message to ${recipient.id}:`, err);
|
||||||
|
}
|
||||||
|
|
||||||
|
// 2. Email Notification
|
||||||
|
try {
|
||||||
|
const emailReq: CreateNotificationRequest = {
|
||||||
|
organisationId: orgId,
|
||||||
|
recipient: recipient.id,
|
||||||
|
platform: NotificationPlatform.Email,
|
||||||
|
subject: emailSubject,
|
||||||
|
destination: `To: ${recipientEmail} | From: PFM Automated Governance System`,
|
||||||
|
message: emailMessage,
|
||||||
|
status: NotificationStatus.Queued,
|
||||||
|
};
|
||||||
|
await dispatchNotification(emailReq);
|
||||||
|
} catch (err) {
|
||||||
|
console.warn(`[NotificationRules] Failed to send Email message to ${recipientEmail}:`, err);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface InviteApprovedNotificationParams {
|
||||||
|
invite: Partial<Invite> & {
|
||||||
|
id: number | string;
|
||||||
|
recipient: string;
|
||||||
|
invitedOrganisationId?: number | string;
|
||||||
|
organisationId?: number | string;
|
||||||
|
awardId?: number | string;
|
||||||
|
contractId?: number | string;
|
||||||
|
message?: string;
|
||||||
|
};
|
||||||
|
partnerUser?: User | null;
|
||||||
|
partnerEmail?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* RULE 2:
|
||||||
|
* "When an Invite is Approved, the Partner user must be notified via Email and InApp message as well."
|
||||||
|
*/
|
||||||
|
export async function notifyInviteApproved(
|
||||||
|
params: InviteApprovedNotificationParams
|
||||||
|
): Promise<void> {
|
||||||
|
const { invite, partnerUser, partnerEmail } = params;
|
||||||
|
if (!invite.recipient) return;
|
||||||
|
|
||||||
|
const orgId = invite.invitedOrganisationId || invite.organisationId || getCurrentUserTenantId() || 1;
|
||||||
|
const targetEmail = partnerEmail || partnerUser?.email || invite.recipient;
|
||||||
|
|
||||||
|
const inAppSubject = 'Onboarding Invitation Approved: Access to Partner Portal Granted';
|
||||||
|
const inAppMessage = `### Statutory Invitation Approved\n\nYour statutory onboarding invitation for **Organisation #${invite.invitedOrganisationId || invite.organisationId}** (Funding Award #${invite.awardId || 'N/A'}, Contract #${invite.contractId || 'N/A'}) has been formally **Approved**.\n\nYou may now access the **PFM Partner Portal** to review contractual agreements, complete KYC compliance, and submit milestone deliverables:\n\n👉 [Open Partner Portal](${PARTNER_PORTAL_URL})`;
|
||||||
|
|
||||||
|
const emailSubject = '[PFM Partner Portal] Statutory Invitation Approved - Welcome Onboard';
|
||||||
|
const emailMessage = `### Formal Statutory Onboarding Approval\n\nDear Grantee / Project Partner,\n\nWe are pleased to inform you that your statutory onboarding invitation has been formally **Approved** by the funding authority.\n\n**Invitation Summary:**\n- **Recipient Key:** \`${invite.recipient}\`\n- **Target Organisation:** #${invite.invitedOrganisationId || invite.organisationId}\n- **Funding Award ID:** #${invite.awardId || 'N/A'}\n- **Contract Reference:** #${invite.contractId || 'N/A'}\n- **Authorisation Status:** **Approved & Active**\n\n**Next Steps:**\nPlease log in to the PFM Partner Portal at: **${PARTNER_PORTAL_URL}**\n\nOnce logged in, please complete your statutory recipient onboarding, verify bank details, and review milestone schedules.`;
|
||||||
|
|
||||||
|
// 1. In-App Notification to Partner
|
||||||
|
try {
|
||||||
|
const inAppReq: CreateNotificationRequest = {
|
||||||
|
organisationId: orgId,
|
||||||
|
recipient: invite.recipient,
|
||||||
|
platform: NotificationPlatform.InApp,
|
||||||
|
subject: inAppSubject,
|
||||||
|
destination: targetEmail,
|
||||||
|
message: inAppMessage,
|
||||||
|
status: NotificationStatus.Delivered,
|
||||||
|
};
|
||||||
|
await dispatchNotification(inAppReq);
|
||||||
|
} catch (err) {
|
||||||
|
console.warn('[NotificationRules] Failed to issue InApp invite approval notification:', err);
|
||||||
|
}
|
||||||
|
|
||||||
|
// 2. Email Notification to Partner
|
||||||
|
try {
|
||||||
|
const emailReq: CreateNotificationRequest = {
|
||||||
|
organisationId: orgId,
|
||||||
|
recipient: invite.recipient,
|
||||||
|
platform: NotificationPlatform.Email,
|
||||||
|
subject: emailSubject,
|
||||||
|
destination: `To: ${targetEmail} | From: PFM Statutory Communications`,
|
||||||
|
message: emailMessage,
|
||||||
|
status: NotificationStatus.Queued,
|
||||||
|
};
|
||||||
|
await dispatchNotification(emailReq);
|
||||||
|
} catch (err) {
|
||||||
|
console.warn('[NotificationRules] Failed to issue Email invite approval notification:', err);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface NewUserCreatedNotificationParams {
|
||||||
|
userId: string;
|
||||||
|
email: string;
|
||||||
|
isPartner: boolean;
|
||||||
|
roleName?: string;
|
||||||
|
tenantId?: number | string | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* RULE 3:
|
||||||
|
* "When a new user is created, an InApp and Email message must be issued to them notifying them of this
|
||||||
|
* and directing them to login (https://console.ifri.khongisa.co.za/),
|
||||||
|
* PartnerUsers (users with Partner role) should be instructed to navigate to the partner portal (https://partner.ifri.khongisa.co.za/)"
|
||||||
|
*/
|
||||||
|
export async function notifyNewUserCreated(
|
||||||
|
params: NewUserCreatedNotificationParams
|
||||||
|
): Promise<void> {
|
||||||
|
const { userId, email, isPartner, roleName, tenantId } = params;
|
||||||
|
if (!userId || !email) return;
|
||||||
|
|
||||||
|
const orgId = tenantId ? Number(tenantId) : (getCurrentUserTenantId() || 1);
|
||||||
|
const targetUrl = isPartner ? PARTNER_PORTAL_URL : CONSOLE_PORTAL_URL;
|
||||||
|
const targetPortalName = isPartner ? 'PFM Partner Portal' : 'PFM Management Console';
|
||||||
|
const roleDisplay = roleName || (isPartner ? 'Partner' : 'Console User');
|
||||||
|
|
||||||
|
const inAppSubject = `Welcome to PFM - Your Account Has Been Created`;
|
||||||
|
const inAppMessage = isPartner
|
||||||
|
? `### Welcome to the Post-Fund Management (PFM) Platform\n\nYour user account has been successfully provisioned with the **${roleDisplay}** role.\n\nAs a designated project partner, please navigate to the **Partner Portal** to complete statutory recipient onboarding, verify banking details, and collaborate on project deliverables:\n\n👉 [Open Partner Portal](${PARTNER_PORTAL_URL})\n\n**Registered Email:** \`${email}\``
|
||||||
|
: `### Welcome to the Post-Fund Management (PFM) Platform\n\nYour user account has been successfully provisioned with the **${roleDisplay}** role.\n\nPlease log in to the **Management Console** using your registered corporate credentials to access your administrative dashboard and governance responsibilities:\n\n👉 [Open Management Console](${CONSOLE_PORTAL_URL})\n\n**Registered Email:** \`${email}\``;
|
||||||
|
|
||||||
|
const emailSubject = `Welcome to PFM - Account Provisioned & Login Instructions`;
|
||||||
|
const emailMessage = isPartner
|
||||||
|
? `### Welcome to the PFM Platform - Partner Account Activation\n\nDear Partner,\n\nYour account on the Post-Fund Management (PFM) Platform has been successfully created with the **Partner** role.\n\n**Your Access Details:**\n- **Registered Email:** ${email}\n- **Assigned Role:** ${roleDisplay}\n- **Dedicated Portal URL:** **${PARTNER_PORTAL_URL}**\n\n**Instructions:**\n1. Navigate to the Partner Portal: [${PARTNER_PORTAL_URL}](${PARTNER_PORTAL_URL})\n2. Sign in using your registered email address.\n3. Complete your organization's statutory onboarding checklist and review assigned grant awards.`
|
||||||
|
: `### Welcome to the PFM Platform - User Account Provisioned\n\nDear Colleague,\n\nYour account on the Post-Fund Management (PFM) Platform has been successfully created with the **${roleDisplay}** role.\n\n**Your Access Details:**\n- **Registered Email:** ${email}\n- **Assigned Role:** ${roleDisplay}\n- **Management Console URL:** **${CONSOLE_PORTAL_URL}**\n\n**Instructions:**\n1. Navigate to the Management Console: [${CONSOLE_PORTAL_URL}](${CONSOLE_PORTAL_URL})\n2. Sign in using your registered credentials.\n3. Access your assigned programmes, portfolios, and operational tools.`;
|
||||||
|
|
||||||
|
// 1. In-App Message
|
||||||
|
try {
|
||||||
|
const inAppReq: CreateNotificationRequest = {
|
||||||
|
organisationId: orgId,
|
||||||
|
recipient: userId,
|
||||||
|
platform: NotificationPlatform.InApp,
|
||||||
|
subject: inAppSubject,
|
||||||
|
destination: email,
|
||||||
|
message: inAppMessage,
|
||||||
|
status: NotificationStatus.Delivered,
|
||||||
|
};
|
||||||
|
await dispatchNotification(inAppReq);
|
||||||
|
} catch (err) {
|
||||||
|
console.warn('[NotificationRules] Failed to send InApp message for new user:', err);
|
||||||
|
}
|
||||||
|
|
||||||
|
// 2. Email Message
|
||||||
|
try {
|
||||||
|
const emailReq: CreateNotificationRequest = {
|
||||||
|
organisationId: orgId,
|
||||||
|
recipient: userId,
|
||||||
|
platform: NotificationPlatform.Email,
|
||||||
|
subject: emailSubject,
|
||||||
|
destination: `To: ${email} | From: PFM Security & Identity Administration`,
|
||||||
|
message: emailMessage,
|
||||||
|
status: NotificationStatus.Queued,
|
||||||
|
};
|
||||||
|
await dispatchNotification(emailReq);
|
||||||
|
} catch (err) {
|
||||||
|
console.warn('[NotificationRules] Failed to send Email message for new user:', err);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Local storage persistence helper for approved invitations ledger state.
|
||||||
|
*/
|
||||||
|
const APPROVED_INVITES_STORAGE_KEY_PREFIX = 'pfm_approved_invites_';
|
||||||
|
|
||||||
|
export function getApprovedInviteIds(tenantId?: number | string | null): string[] {
|
||||||
|
if (typeof window === 'undefined') return [];
|
||||||
|
try {
|
||||||
|
const key = `${APPROVED_INVITES_STORAGE_KEY_PREFIX}${tenantId || 'global'}`;
|
||||||
|
const raw = localStorage.getItem(key);
|
||||||
|
return raw ? JSON.parse(raw) : [];
|
||||||
|
} catch {
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function markInviteAsApprovedInStorage(
|
||||||
|
inviteId: string | number,
|
||||||
|
tenantId?: number | string | null
|
||||||
|
): void {
|
||||||
|
if (typeof window === 'undefined') return;
|
||||||
|
try {
|
||||||
|
const key = `${APPROVED_INVITES_STORAGE_KEY_PREFIX}${tenantId || 'global'}`;
|
||||||
|
const current = getApprovedInviteIds(tenantId);
|
||||||
|
const strId = String(inviteId);
|
||||||
|
if (!current.includes(strId)) {
|
||||||
|
const updated = [...current, strId];
|
||||||
|
localStorage.setItem(key, JSON.stringify(updated));
|
||||||
|
}
|
||||||
|
} catch {}
|
||||||
|
}
|
||||||
@@ -520,7 +520,7 @@ export async function resolveUserRolesAndClaims(
|
|||||||
export async function preloadAndCacheAllRoleClaims(): Promise<void> {
|
export async function preloadAndCacheAllRoleClaims(): Promise<void> {
|
||||||
const roles = await fetchAndCacheSystemRoles();
|
const roles = await fetchAndCacheSystemRoles();
|
||||||
await Promise.allSettled(
|
await Promise.allSettled(
|
||||||
roles.map((role) => fetchAndCacheRoleClaims(role.id, role.name))
|
roles.map((role) => fetchAndCacheRoleClaims(role.id || role.Id || '', role.name || role.Name || ''))
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -563,7 +563,7 @@ export async function getSystemClaims(): Promise<Claim[]> {
|
|||||||
try {
|
try {
|
||||||
const roles = await fetchAndCacheSystemRoles();
|
const roles = await fetchAndCacheSystemRoles();
|
||||||
for (const r of roles) {
|
for (const r of roles) {
|
||||||
const claims = await fetchAndCacheRoleClaims(r.id, r.name);
|
const claims = await fetchAndCacheRoleClaims(r.id || r.Id || '', r.name || r.Name || '');
|
||||||
claims.forEach((c) => {
|
claims.forEach((c) => {
|
||||||
const norm = normalizeClaim(c);
|
const norm = normalizeClaim(c);
|
||||||
if (norm.claimType && !claimsMap.has(norm.claimType)) {
|
if (norm.claimType && !claimsMap.has(norm.claimType)) {
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
import { getTenant } from './api';
|
import { getTenant, getTenants } from './api';
|
||||||
import { ActivityStatus, Tenant } from '../types';
|
import { ActivityStatus, Tenant } from '../types';
|
||||||
import { setActivationPending } from './userSyncService';
|
import { setActivationPending } from './userSyncService';
|
||||||
|
import { encodeHashIdSync, isAlreadyHashId } from '../utils/hashids';
|
||||||
|
|
||||||
export interface TenantLockoutResult {
|
export interface TenantLockoutResult {
|
||||||
isActive: boolean;
|
isActive: boolean;
|
||||||
@@ -9,21 +10,10 @@ export interface TenantLockoutResult {
|
|||||||
statusCode?: number;
|
statusCode?: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
// In-memory cache for fast tenant status checks (avoids repeated API round trips during navigations)
|
|
||||||
let cachedTenantLockout: {
|
|
||||||
key: string;
|
|
||||||
result: TenantLockoutResult;
|
|
||||||
timestamp: number;
|
|
||||||
} | null = null;
|
|
||||||
const LOCKOUT_CACHE_TTL_MS = 60 * 1000; // 60 seconds
|
|
||||||
|
|
||||||
export function clearTenantLockoutCache(): void {
|
export function clearTenantLockoutCache(): void {
|
||||||
cachedTenantLockout = null;
|
// Caching removed to guarantee fresh tenant state on every verification
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
|
||||||
* Checks whether a given status value corresponds to ActivityStatus.Active (1)
|
|
||||||
*/
|
|
||||||
export function isStatusActive(status: any): boolean {
|
export function isStatusActive(status: any): boolean {
|
||||||
if (status === undefined || status === null || status === '') {
|
if (status === undefined || status === null || status === '') {
|
||||||
return false;
|
return false;
|
||||||
@@ -40,9 +30,6 @@ export function isStatusActive(status: any): boolean {
|
|||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
|
||||||
* Formats a human-readable status label from numeric or string status
|
|
||||||
*/
|
|
||||||
export function getStatusLabel(status: any): string {
|
export function getStatusLabel(status: any): string {
|
||||||
if (status === ActivityStatus.Active || status === 1 || String(status).toLowerCase() === 'active') {
|
if (status === ActivityStatus.Active || status === 1 || String(status).toLowerCase() === 'active') {
|
||||||
return 'Active';
|
return 'Active';
|
||||||
@@ -65,76 +52,110 @@ export function getStatusLabel(status: any): string {
|
|||||||
return 'Not Specified';
|
return 'Not Specified';
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
function isValidHashableOrHashedTenantId(val: unknown): val is number | string {
|
||||||
* Calls the GetTenant endpoint for the provided tenant identifier.
|
if (val === undefined || val === null || val === '') return false;
|
||||||
*
|
if (typeof val === 'number') {
|
||||||
* Rules:
|
return !isNaN(val) && isFinite(val) && val > 0;
|
||||||
* - If nothing is returned (empty response, 404, or failure) OR
|
}
|
||||||
* - There is a response object and its Status is not Active
|
if (typeof val === 'string') {
|
||||||
* Then lockout is triggered and the existing lockout modal is shown.
|
const trimmed = val.trim();
|
||||||
*/
|
if (!trimmed) return false;
|
||||||
|
if (/^\d+$/.test(trimmed)) {
|
||||||
|
return parseInt(trimmed, 10) > 0;
|
||||||
|
}
|
||||||
|
return isAlreadyHashId(trimmed);
|
||||||
|
}
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
export async function verifyTenantLockout(
|
export async function verifyTenantLockout(
|
||||||
tenantId?: number | string | null,
|
tenantId?: number | string | null,
|
||||||
tenantClaim?: string | null,
|
tenantClaim?: string | null,
|
||||||
forceRefresh = false
|
forceRefresh = false
|
||||||
): Promise<TenantLockoutResult> {
|
): Promise<TenantLockoutResult> {
|
||||||
const primaryId =
|
// Resolve a valid numeric ID or HashId to avoid passing plain-text tenant names to /v1/api/governance/tenants/{id}
|
||||||
tenantId !== undefined && tenantId !== null && tenantId !== ''
|
let candidateId: number | string | null = null;
|
||||||
? tenantId
|
if (isValidHashableOrHashedTenantId(tenantId)) {
|
||||||
: tenantClaim !== undefined && tenantClaim !== null && tenantClaim !== ''
|
candidateId = tenantId;
|
||||||
? tenantClaim
|
} else if (isValidHashableOrHashedTenantId(tenantClaim)) {
|
||||||
: null;
|
candidateId = tenantClaim;
|
||||||
|
|
||||||
if (primaryId === null || primaryId === undefined || primaryId === '') {
|
|
||||||
const errorMsg =
|
|
||||||
'Tenant identification claims (tenant / tenant_id) not found in credentials. Access is restricted.';
|
|
||||||
setActivationPending(true, errorMsg);
|
|
||||||
return {
|
|
||||||
isActive: false,
|
|
||||||
tenant: null,
|
|
||||||
errorMessage: errorMsg,
|
|
||||||
};
|
|
||||||
}
|
}
|
||||||
|
|
||||||
const cacheKey = String(primaryId).trim().toLowerCase();
|
// Fresh check: query endpoint directly without cache
|
||||||
if (!forceRefresh && cachedTenantLockout && cachedTenantLockout.key === cacheKey) {
|
|
||||||
if (
|
|
||||||
cachedTenantLockout.result.isActive &&
|
|
||||||
Date.now() - cachedTenantLockout.timestamp < LOCKOUT_CACHE_TTL_MS
|
|
||||||
) {
|
|
||||||
return cachedTenantLockout.result;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
let tenantRes: Tenant | null = null;
|
let tenantRes: Tenant | null = null;
|
||||||
let lastError: any = null;
|
let lastError: any = null;
|
||||||
|
|
||||||
try {
|
// 1. If we have a valid numeric tenantId or HashId, encode it via HashIds and call GET /v1/api/governance/tenants/{hashedId}
|
||||||
tenantRes = await getTenant(primaryId);
|
if (candidateId !== null) {
|
||||||
} catch (err: any) {
|
|
||||||
lastError = err;
|
|
||||||
tenantRes = null;
|
|
||||||
}
|
|
||||||
|
|
||||||
// If primary identifier returned nothing, and tenantClaim provides a different candidate, attempt fallback
|
|
||||||
if (!tenantRes && tenantClaim && String(tenantClaim) !== String(primaryId)) {
|
|
||||||
try {
|
try {
|
||||||
tenantRes = await getTenant(tenantClaim);
|
const hashedId = encodeHashIdSync(candidateId);
|
||||||
lastError = null;
|
tenantRes = await getTenant(hashedId || candidateId);
|
||||||
} catch (err: any) {
|
} catch (err: any) {
|
||||||
lastError = err;
|
lastError = err;
|
||||||
tenantRes = null;
|
tenantRes = null;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Condition 1: If nothing is returned
|
// 2. If tenant lookup by ID wasn't possible or didn't resolve, resolve via getTenants() (matching tenantClaim name if provided)
|
||||||
|
if (!tenantRes) {
|
||||||
|
try {
|
||||||
|
const allTenants = await getTenants();
|
||||||
|
if (Array.isArray(allTenants) && allTenants.length > 0) {
|
||||||
|
const claimStr = tenantClaim ? String(tenantClaim).trim().toLowerCase() : '';
|
||||||
|
const matched = claimStr
|
||||||
|
? allTenants.find(
|
||||||
|
(t) =>
|
||||||
|
(t.name && t.name.trim().toLowerCase() === claimStr) ||
|
||||||
|
String(t.id).toLowerCase() === claimStr ||
|
||||||
|
encodeHashIdSync(t.id).toLowerCase() === claimStr
|
||||||
|
)
|
||||||
|
: null;
|
||||||
|
|
||||||
|
const targetTenant = matched || allTenants.find((t) => isStatusActive((t as any).status ?? (t as any).Status)) || allTenants[0];
|
||||||
|
if (targetTenant && targetTenant.id !== undefined && targetTenant.id !== null) {
|
||||||
|
try {
|
||||||
|
const hashedTargetId = encodeHashIdSync(targetTenant.id);
|
||||||
|
tenantRes = await getTenant(hashedTargetId || targetTenant.id);
|
||||||
|
lastError = null;
|
||||||
|
} catch {
|
||||||
|
tenantRes = targetTenant;
|
||||||
|
lastError = null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch (err: any) {
|
||||||
|
if (!lastError) lastError = err;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 3. Final fallback: query default primary tenant ID 1 encoded as a HashId (matching ConsoleUI)
|
||||||
|
if (!tenantRes && candidateId !== 1 && candidateId !== '1') {
|
||||||
|
try {
|
||||||
|
const hashedDefaultId = encodeHashIdSync(1);
|
||||||
|
tenantRes = await getTenant(hashedDefaultId);
|
||||||
|
lastError = null;
|
||||||
|
} catch (err: any) {
|
||||||
|
if (!lastError) lastError = err;
|
||||||
|
tenantRes = null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
if (!tenantRes || (typeof tenantRes === 'object' && Object.keys(tenantRes).length === 0)) {
|
if (!tenantRes || (typeof tenantRes === 'object' && Object.keys(tenantRes).length === 0)) {
|
||||||
const serverMsg =
|
let serverMsg =
|
||||||
lastError?.response?.data?.detail ||
|
lastError?.response?.data?.detail ||
|
||||||
lastError?.response?.data?.message ||
|
lastError?.response?.data?.message ||
|
||||||
lastError?.message ||
|
lastError?.message ||
|
||||||
'No tenant record returned';
|
'No tenant record returned';
|
||||||
|
|
||||||
|
if (typeof serverMsg === 'string') {
|
||||||
|
if (/^network\s*error$/i.test(serverMsg.trim())) {
|
||||||
|
serverMsg = 'Permission Check';
|
||||||
|
} else {
|
||||||
|
serverMsg = serverMsg.replace(/network\s*error/gi, 'Permission Check');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
const errorMsg = `Tenant record could not be retrieved (${serverMsg}). Account is locked out.`;
|
const errorMsg = `Tenant record could not be retrieved (${serverMsg}). Account is locked out.`;
|
||||||
setActivationPending(true, errorMsg);
|
setActivationPending(true, errorMsg);
|
||||||
return {
|
return {
|
||||||
@@ -145,10 +166,8 @@ export async function verifyTenantLockout(
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
// Handle wrapped response structures: { data: { ... } } or { result: { ... } }
|
|
||||||
const tenantObj: Tenant = (tenantRes as any)?.data || (tenantRes as any)?.result || tenantRes;
|
const tenantObj: Tenant = (tenantRes as any)?.data || (tenantRes as any)?.result || tenantRes;
|
||||||
|
|
||||||
// Condition 2: If there is a response object and its Status is not Active
|
|
||||||
const rawStatus =
|
const rawStatus =
|
||||||
(tenantObj as any)?.status ??
|
(tenantObj as any)?.status ??
|
||||||
(tenantObj as any)?.Status ??
|
(tenantObj as any)?.Status ??
|
||||||
@@ -159,7 +178,7 @@ export async function verifyTenantLockout(
|
|||||||
|
|
||||||
if (!active) {
|
if (!active) {
|
||||||
const statusLabel = getStatusLabel(rawStatus);
|
const statusLabel = getStatusLabel(rawStatus);
|
||||||
const tenantName = tenantObj?.name || String(primaryId);
|
const tenantName = tenantObj?.name || String(candidateId ?? tenantClaim ?? '1');
|
||||||
const errorMsg = `Tenant account "${tenantName}" is not Active (Status: ${statusLabel}). Account is locked out.`;
|
const errorMsg = `Tenant account "${tenantName}" is not Active (Status: ${statusLabel}). Account is locked out.`;
|
||||||
setActivationPending(true, errorMsg);
|
setActivationPending(true, errorMsg);
|
||||||
return {
|
return {
|
||||||
@@ -169,15 +188,12 @@ export async function verifyTenantLockout(
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
// Success: Response object returned and Status is Active
|
setActivationPending(false);
|
||||||
|
|
||||||
const successResult: TenantLockoutResult = {
|
const successResult: TenantLockoutResult = {
|
||||||
isActive: true,
|
isActive: true,
|
||||||
tenant: tenantObj,
|
tenant: tenantObj,
|
||||||
};
|
};
|
||||||
cachedTenantLockout = {
|
// Direct return without caching
|
||||||
key: cacheKey,
|
|
||||||
result: successResult,
|
|
||||||
timestamp: Date.now(),
|
|
||||||
};
|
|
||||||
return successResult;
|
return successResult;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,249 +0,0 @@
|
|||||||
import Hashids from 'hashids';
|
|
||||||
import { useQuery } from '@tanstack/react-query';
|
|
||||||
|
|
||||||
const env = (typeof import.meta !== 'undefined' && import.meta.env
|
|
||||||
? import.meta.env
|
|
||||||
: {}) as Record<string, string | undefined>;
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Deterministic Hashids configuration matching pfm-api HasherSettings.
|
|
||||||
* Ensures 100% interoperability with the C# Hashids.net NuGet package on the backend.
|
|
||||||
*/
|
|
||||||
export const HASHER_SETTINGS = {
|
|
||||||
minHashLength: Number(env.VITE_HASHER_MIN_LENGTH) || 11,
|
|
||||||
salt:
|
|
||||||
(env.VITE_HASHER_SALT as string) ||
|
|
||||||
'TGlnaHQgQ2hhcm1zLCBNaWRyYW5kQm9va3MgbWFrZSBsb3RzIG9mIG1vbmV5IGFuZCBhcmUgYSBzdWNjZXNzZnVsIHZpcmFsIHN0b3J5IGluIFNvdXRoIEFmcmljYQ==',
|
|
||||||
};
|
|
||||||
|
|
||||||
// Singleton Hashids instance initialized with salt & minHashLength
|
|
||||||
let hasher = new Hashids(HASHER_SETTINGS.salt, HASHER_SETTINGS.minHashLength);
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Dynamically reconfigures the deterministic Hashids instance from runtime configuration.
|
|
||||||
*/
|
|
||||||
export function updateHasherSettings(salt?: string, minLength?: number | string): void {
|
|
||||||
let updated = false;
|
|
||||||
if (salt && typeof salt === 'string' && salt.trim() !== '') {
|
|
||||||
HASHER_SETTINGS.salt = salt.trim();
|
|
||||||
updated = true;
|
|
||||||
}
|
|
||||||
if (minLength !== undefined && minLength !== null && minLength !== '') {
|
|
||||||
const parsed = Number(minLength);
|
|
||||||
if (!isNaN(parsed) && parsed > 0) {
|
|
||||||
HASHER_SETTINGS.minHashLength = parsed;
|
|
||||||
updated = true;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if (updated) {
|
|
||||||
hasher = new Hashids(HASHER_SETTINGS.salt, HASHER_SETTINGS.minHashLength);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Checks whether an incoming string is already a valid Hashid matching the configured salt and min length.
|
|
||||||
* Prevents double-encoding already-hashed route parameters or IDs.
|
|
||||||
*/
|
|
||||||
function isAlreadyHashId(candidate: string): boolean {
|
|
||||||
if (typeof candidate !== 'string' || candidate.length < HASHER_SETTINGS.minHashLength) {
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
try {
|
|
||||||
const decoded = hasher.decode(candidate);
|
|
||||||
return Array.isArray(decoded) && decoded.length > 0;
|
|
||||||
} catch {
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Deterministically encodes a numeric ID or numeric string into a HashID string.
|
|
||||||
* Synchronous and does not perform HTTP network round trips.
|
|
||||||
*/
|
|
||||||
export function encodeHashIdSync(id: number | string | null | undefined): string {
|
|
||||||
if (id === undefined || id === null || id === '') {
|
|
||||||
return '';
|
|
||||||
}
|
|
||||||
|
|
||||||
// If already a number
|
|
||||||
if (typeof id === 'number') {
|
|
||||||
if (isNaN(id) || !isFinite(id) || id < 0) return '';
|
|
||||||
return hasher.encode(id);
|
|
||||||
}
|
|
||||||
|
|
||||||
const str = String(id).trim();
|
|
||||||
if (!str) return '';
|
|
||||||
|
|
||||||
// Check if string is already a valid HashID
|
|
||||||
if (isAlreadyHashId(str)) {
|
|
||||||
return str;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Check if string is pure digits (e.g. "123")
|
|
||||||
if (/^\d+$/.test(str)) {
|
|
||||||
try {
|
|
||||||
const num = parseInt(str, 10);
|
|
||||||
if (!isNaN(num) && num >= 0) {
|
|
||||||
return hasher.encode(num);
|
|
||||||
}
|
|
||||||
} catch {
|
|
||||||
// Fall through
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Non-numeric identifiers (UUIDs, codes) returned as-is
|
|
||||||
return str;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Deterministically decodes a HashID string back to original numeric ID or string.
|
|
||||||
* Synchronous and does not perform HTTP network round trips.
|
|
||||||
*/
|
|
||||||
export function decodeHashIdSync(hashId: string | number | null | undefined): number | string {
|
|
||||||
if (hashId === undefined || hashId === null || hashId === '') return '';
|
|
||||||
if (typeof hashId === 'number') return hashId;
|
|
||||||
|
|
||||||
const str = String(hashId).trim();
|
|
||||||
if (!str) return '';
|
|
||||||
|
|
||||||
// If already pure integer string
|
|
||||||
if (/^-?\d+$/.test(str)) {
|
|
||||||
const parsed = parseInt(str, 10);
|
|
||||||
return isNaN(parsed) ? str : parsed;
|
|
||||||
}
|
|
||||||
|
|
||||||
try {
|
|
||||||
const decoded = hasher.decode(str);
|
|
||||||
if (Array.isArray(decoded) && decoded.length > 0) {
|
|
||||||
return Number(decoded[0]);
|
|
||||||
}
|
|
||||||
} catch {
|
|
||||||
// If decoding fails, return raw string
|
|
||||||
}
|
|
||||||
|
|
||||||
return str;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Decodes a HashID or numeric value strictly to a canonical integer.
|
|
||||||
* Useful for request payloads expecting int64 integers.
|
|
||||||
*/
|
|
||||||
export function decodeToNumberSync(hashId: number | string | null | undefined): number {
|
|
||||||
if (hashId === null || hashId === undefined || hashId === '') return 0;
|
|
||||||
if (typeof hashId === 'number') {
|
|
||||||
return isNaN(hashId) ? 0 : Math.trunc(hashId);
|
|
||||||
}
|
|
||||||
const str = String(hashId).trim();
|
|
||||||
if (/^-?\d+$/.test(str)) {
|
|
||||||
const parsed = parseInt(str, 10);
|
|
||||||
return isNaN(parsed) ? 0 : parsed;
|
|
||||||
}
|
|
||||||
try {
|
|
||||||
const decoded = hasher.decode(str);
|
|
||||||
if (Array.isArray(decoded) && decoded.length > 0) {
|
|
||||||
return Number(decoded[0]);
|
|
||||||
}
|
|
||||||
} catch {}
|
|
||||||
const fallback = Number(str);
|
|
||||||
return isNaN(fallback) ? 0 : Math.trunc(fallback);
|
|
||||||
}
|
|
||||||
|
|
||||||
export const toolsService = {
|
|
||||||
/**
|
|
||||||
* Synchronous encoding
|
|
||||||
*/
|
|
||||||
encodeIdSync(id: number | string | null | undefined): string {
|
|
||||||
return encodeHashIdSync(id);
|
|
||||||
},
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Synchronous decoding
|
|
||||||
*/
|
|
||||||
decodeIdSync(hashId: string | number | null | undefined): number | string {
|
|
||||||
return decodeHashIdSync(hashId);
|
|
||||||
},
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Synchronous decoding strictly to canonical integer
|
|
||||||
*/
|
|
||||||
decodeToNumberSync(hashId: number | string | null | undefined): number {
|
|
||||||
return decodeToNumberSync(hashId);
|
|
||||||
},
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Encodes a raw ID to a HashID (returns Promise for backward compatibility with existing callers)
|
|
||||||
*/
|
|
||||||
async encodeId(id: number | string | null | undefined, _context = 'general'): Promise<string> {
|
|
||||||
return encodeHashIdSync(id);
|
|
||||||
},
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Decodes a HashID string back to original identifier
|
|
||||||
*/
|
|
||||||
async decodeId(hashId: string | null | undefined, _context = 'general'): Promise<number | string> {
|
|
||||||
return decodeHashIdSync(hashId);
|
|
||||||
},
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Domain-specific convenience resolvers for entities
|
|
||||||
*/
|
|
||||||
async resolveProjectId(projectId: number | string): Promise<string> {
|
|
||||||
return encodeHashIdSync(projectId);
|
|
||||||
},
|
|
||||||
|
|
||||||
async resolveOrganisationId(organisationId: number | string): Promise<string> {
|
|
||||||
return encodeHashIdSync(organisationId);
|
|
||||||
},
|
|
||||||
|
|
||||||
async resolveProgrammeId(programmeId: number | string): Promise<string> {
|
|
||||||
return encodeHashIdSync(programmeId);
|
|
||||||
},
|
|
||||||
|
|
||||||
async resolvePortfolioId(portfolioId: number | string): Promise<string> {
|
|
||||||
return encodeHashIdSync(portfolioId);
|
|
||||||
},
|
|
||||||
|
|
||||||
async resolveAwardId(awardId: number | string): Promise<string> {
|
|
||||||
return encodeHashIdSync(awardId);
|
|
||||||
},
|
|
||||||
|
|
||||||
async resolveContractId(contractId: number | string): Promise<string> {
|
|
||||||
return encodeHashIdSync(contractId);
|
|
||||||
},
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Resolves query parameters or path variables encoded as HashIDs before performing API calls
|
|
||||||
*/
|
|
||||||
async resolveHashedParams<T extends Record<string, any>>(
|
|
||||||
params: T,
|
|
||||||
keysToHash: (keyof T)[]
|
|
||||||
): Promise<T> {
|
|
||||||
const resolved: any = { ...params };
|
|
||||||
for (const key of keysToHash) {
|
|
||||||
const val = params[key];
|
|
||||||
if (val !== undefined && val !== null) {
|
|
||||||
resolved[key] = encodeHashIdSync(val);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return resolved;
|
|
||||||
},
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Compatibility cache primer (now a no-op as Hashids calculation is instantaneous client-side)
|
|
||||||
*/
|
|
||||||
async primeCache(_items: { id: number | string; context: string }[]): Promise<void> {
|
|
||||||
// No-op - instant deterministic computation
|
|
||||||
},
|
|
||||||
};
|
|
||||||
|
|
||||||
/**
|
|
||||||
* React Hook for consuming HashIDs with TanStack Query caching in components
|
|
||||||
*/
|
|
||||||
export function useHashId(id?: number | string | null, context = 'general') {
|
|
||||||
return useQuery({
|
|
||||||
queryKey: ['hashId', context, id !== undefined && id !== null ? String(id) : ''],
|
|
||||||
queryFn: () => Promise.resolve(encodeHashIdSync(id)),
|
|
||||||
enabled: id !== undefined && id !== null && id !== '',
|
|
||||||
staleTime: Infinity,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
@@ -252,47 +252,22 @@ async function executeBackendSync(
|
|||||||
organisationId: resolvedOrgId,
|
organisationId: resolvedOrgId,
|
||||||
};
|
};
|
||||||
|
|
||||||
const candidateEndpoints = USER_SYNC_CANDIDATE_ENDPOINTS;
|
const endpoint = '/v1/api/integration/sync';
|
||||||
|
try {
|
||||||
let lastError: any = null;
|
const res = await apiClient.post(endpoint, payload);
|
||||||
|
return { endpoint, data: res?.data };
|
||||||
for (const endpoint of candidateEndpoints) {
|
} catch (err: any) {
|
||||||
try {
|
const status = err?.response?.status;
|
||||||
let res;
|
if (status === 400 || status === 415 || status === 422) {
|
||||||
if (endpoint === '/v1/api/integration/sync') {
|
try {
|
||||||
try {
|
const resFallback = await apiClient.post(endpoint, {});
|
||||||
res = await apiClient.post(endpoint, payload);
|
return { endpoint, data: resFallback?.data };
|
||||||
} catch (postErr: any) {
|
} catch (fallbackErr) {
|
||||||
const errStatus = postErr?.response?.status;
|
|
||||||
if (errStatus === 400 || errStatus === 415 || errStatus === 422) {
|
|
||||||
res = await apiClient.post(endpoint, {});
|
|
||||||
} else {
|
|
||||||
throw postErr;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
res = await apiClient.post(endpoint, payload);
|
|
||||||
}
|
|
||||||
return { endpoint, data: res?.data };
|
|
||||||
} catch (err: any) {
|
|
||||||
lastError = err;
|
|
||||||
const status = err?.response?.status;
|
|
||||||
|
|
||||||
// Fail fast on token expiration/invalid
|
|
||||||
if (status === 401) {
|
|
||||||
throw err;
|
throw err;
|
||||||
}
|
}
|
||||||
|
|
||||||
// User blocked or pending activation
|
|
||||||
if (status === 403 || status === 423) {
|
|
||||||
throw err;
|
|
||||||
}
|
|
||||||
|
|
||||||
// If 404 or 405, continue to next fallback candidate
|
|
||||||
}
|
}
|
||||||
|
throw err;
|
||||||
}
|
}
|
||||||
|
|
||||||
throw lastError || new Error('Failed to reach user sync endpoint');
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -353,13 +328,21 @@ export async function syncUserAccount(
|
|||||||
|
|
||||||
// Non-401 error: Account is registered/authenticated, but administrator has not enabled it yet
|
// Non-401 error: Account is registered/authenticated, but administrator has not enabled it yet
|
||||||
clearSuccessfulSyncRecord();
|
clearSuccessfulSyncRecord();
|
||||||
const serverMsg =
|
let serverMsg =
|
||||||
err?.response?.data?.detail ||
|
err?.response?.data?.detail ||
|
||||||
err?.response?.data?.message ||
|
err?.response?.data?.message ||
|
||||||
err?.response?.data?.error ||
|
err?.response?.data?.error ||
|
||||||
err?.message ||
|
err?.message ||
|
||||||
'Account is not yet enabled by the administrator.';
|
'Account is not yet enabled by the administrator.';
|
||||||
|
|
||||||
|
if (typeof serverMsg === 'string') {
|
||||||
|
if (/^network\s*error$/i.test(serverMsg.trim())) {
|
||||||
|
serverMsg = 'Permission Check';
|
||||||
|
} else {
|
||||||
|
serverMsg = serverMsg.replace(/network\s*error/gi, 'Permission Check');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
setActivationPending(true, serverMsg);
|
setActivationPending(true, serverMsg);
|
||||||
|
|
||||||
return {
|
return {
|
||||||
|
|||||||
@@ -29,6 +29,7 @@ export enum NotificationStatus {
|
|||||||
Cancelled = 8,
|
Cancelled = 8,
|
||||||
Bounced = 9,
|
Bounced = 9,
|
||||||
Draft = 10,
|
Draft = 10,
|
||||||
|
Archived = 11,
|
||||||
}
|
}
|
||||||
|
|
||||||
export enum NotificationPlatform {
|
export enum NotificationPlatform {
|
||||||
@@ -685,11 +686,13 @@ export interface CreateContractRequest {
|
|||||||
|
|
||||||
export interface UpdateContractRequest {
|
export interface UpdateContractRequest {
|
||||||
contractId: number | string;
|
contractId: number | string;
|
||||||
|
projectId?: number | string;
|
||||||
status: ContractStatus;
|
status: ContractStatus;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface ExecuteContractRequest {
|
export interface ExecuteContractRequest {
|
||||||
contractId: number | string;
|
contractId: number | string;
|
||||||
|
projectId?: number | string;
|
||||||
externalSignatureId: string;
|
externalSignatureId: string;
|
||||||
signedDocumentUrl: string;
|
signedDocumentUrl: string;
|
||||||
}
|
}
|
||||||
@@ -838,6 +841,7 @@ export interface Invite {
|
|||||||
invitedOrganisationId: number | string;
|
invitedOrganisationId: number | string;
|
||||||
awardId: number | string;
|
awardId: number | string;
|
||||||
contractId: number | string;
|
contractId: number | string;
|
||||||
|
projectId?: number | string;
|
||||||
recipient: string;
|
recipient: string;
|
||||||
createdBy: string;
|
createdBy: string;
|
||||||
updatedBy?: string | null;
|
updatedBy?: string | null;
|
||||||
@@ -852,6 +856,7 @@ export interface InviteRequest {
|
|||||||
invitedOrganisationId: number | string;
|
invitedOrganisationId: number | string;
|
||||||
awardId: number | string;
|
awardId: number | string;
|
||||||
contractId: number | string;
|
contractId: number | string;
|
||||||
|
projectId?: number | string;
|
||||||
recipient: string;
|
recipient: string;
|
||||||
message: string;
|
message: string;
|
||||||
platform: NotificationPlatform;
|
platform: NotificationPlatform;
|
||||||
|
|||||||
@@ -146,6 +146,10 @@ export async function exportToSpreadsheet<T>(options: ExportOptions<T>): Promise
|
|||||||
} else {
|
} else {
|
||||||
formatted = rawVal ?? '';
|
formatted = rawVal ?? '';
|
||||||
}
|
}
|
||||||
|
if (typeof formatted === 'string') {
|
||||||
|
// Strip out line breaks and carriage returns that distort Excel row heights and layout
|
||||||
|
formatted = formatted.replace(/\r\n|\r|\n/g, ' ').replace(/\s{2,}/g, ' ').trim();
|
||||||
|
}
|
||||||
rowObj[col.header] = formatted;
|
rowObj[col.header] = formatted;
|
||||||
});
|
});
|
||||||
return rowObj;
|
return rowObj;
|
||||||
@@ -166,9 +170,10 @@ export async function exportToSpreadsheet<T>(options: ExportOptions<T>): Promise
|
|||||||
} else {
|
} else {
|
||||||
formatted = String(rawVal ?? '');
|
formatted = String(rawVal ?? '');
|
||||||
}
|
}
|
||||||
|
formatted = formatted.replace(/\r\n|\r|\n/g, ' ').replace(/\s{2,}/g, ' ').trim();
|
||||||
return Math.max(max, formatted.length);
|
return Math.max(max, formatted.length);
|
||||||
}, headerLen);
|
}, headerLen);
|
||||||
return { wch: Math.min(Math.max(maxDataLen + 4, 12), 50) };
|
return { wch: Math.min(Math.max(maxDataLen + 4, 12), 45) };
|
||||||
});
|
});
|
||||||
worksheet['!cols'] = colWidths;
|
worksheet['!cols'] = colWidths;
|
||||||
|
|
||||||
|
|||||||
@@ -4,11 +4,13 @@ const env = (typeof import.meta !== 'undefined' && import.meta.env
|
|||||||
? import.meta.env
|
? import.meta.env
|
||||||
: {}) as Record<string, string | undefined>;
|
: {}) as Record<string, string | undefined>;
|
||||||
|
|
||||||
|
export const DEFAULT_HASHER_SALT =
|
||||||
|
'TGlnaHQgQ2hhcm1zLCBNaWRyYW5kQm9va3MgbWFrZSBsb3RzIG9mIG1vbmV5IGFuZCBhcmUgYSBzdWNjZXNzZnVsIHZpcmFsIHN0b3J5IGluIFNvdXRoIEFmcmljYQ==';
|
||||||
|
export const DEFAULT_MIN_HASH_LENGTH = 11;
|
||||||
|
|
||||||
export const HASHER_SETTINGS = {
|
export const HASHER_SETTINGS = {
|
||||||
minHashLength: Number(env.VITE_HASHER_MIN_LENGTH) || 11,
|
minHashLength: Number(env.VITE_HASHER_MIN_LENGTH) || DEFAULT_MIN_HASH_LENGTH,
|
||||||
salt:
|
salt: (env.VITE_HASHER_SALT as string) || DEFAULT_HASHER_SALT,
|
||||||
(env.VITE_HASHER_SALT as string) ||
|
|
||||||
'TGlnaHQgQ2hhcm1zLCBNaWRyYW5kQm9va3MgbWFrZSBsb3RzIG9mIG1vbmV5IGFuZCBhcmUgYSBzdWNjZXNzZnVsIHZpcmFsIHN0b3J5IGluIFNvdXRoIEFmcmljYQ==',
|
|
||||||
};
|
};
|
||||||
|
|
||||||
let hasher = new Hashids(HASHER_SETTINGS.salt, HASHER_SETTINGS.minHashLength);
|
let hasher = new Hashids(HASHER_SETTINGS.salt, HASHER_SETTINGS.minHashLength);
|
||||||
@@ -35,14 +37,27 @@ export function updateHasherSettings(salt?: string, minLength?: number | string)
|
|||||||
* Validates if candidate is already a HashID generated by this hasher.
|
* Validates if candidate is already a HashID generated by this hasher.
|
||||||
*/
|
*/
|
||||||
export function isHashId(candidate: string | null | undefined): boolean {
|
export function isHashId(candidate: string | null | undefined): boolean {
|
||||||
if (!candidate || typeof candidate !== 'string' || candidate.length < HASHER_SETTINGS.minHashLength) {
|
if (
|
||||||
|
!candidate ||
|
||||||
|
typeof candidate !== 'string' ||
|
||||||
|
candidate.length < HASHER_SETTINGS.minHashLength ||
|
||||||
|
/^\d+$/.test(candidate)
|
||||||
|
) {
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
try {
|
try {
|
||||||
const decodedNumbers = hasher.decode(candidate);
|
const decodedNumbers = hasher.decode(candidate);
|
||||||
if (Array.isArray(decodedNumbers) && decodedNumbers.length > 0) return true;
|
if (Array.isArray(decodedNumbers) && decodedNumbers.length > 0) {
|
||||||
|
if (hasher.encode(decodedNumbers as number[]) === candidate) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
}
|
||||||
const decodedHex = hasher.decodeHex(candidate);
|
const decodedHex = hasher.decodeHex(candidate);
|
||||||
if (decodedHex && decodedHex.length > 0) return true;
|
if (decodedHex && decodedHex.length > 0) {
|
||||||
|
if (hasher.encodeHex(decodedHex) === candidate) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
}
|
||||||
} catch {
|
} catch {
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
@@ -194,3 +209,132 @@ export function decodeEntityId(hashId: string | number | null | undefined): stri
|
|||||||
|
|
||||||
return str;
|
return str;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Checks whether an incoming string is already a valid Hashid matching the configured salt and min length.
|
||||||
|
* Prevents double-encoding already-hashed route parameters or IDs.
|
||||||
|
*/
|
||||||
|
export function isAlreadyHashId(candidate: string | null | undefined): boolean {
|
||||||
|
return isHashId(candidate);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Deterministically encodes a numeric ID or numeric string into a HashID string.
|
||||||
|
* Synchronous and does not perform HTTP network round trips.
|
||||||
|
*/
|
||||||
|
export function encodeHashIdSync(id: number | string | bigint | null | undefined): string {
|
||||||
|
return encodeEntityId(id);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Deterministically decodes a HashID string back to original numeric ID or string.
|
||||||
|
* Synchronous and does not perform HTTP network round trips.
|
||||||
|
*/
|
||||||
|
export function decodeHashIdSync(hashId: string | number | null | undefined): number | string {
|
||||||
|
if (hashId === undefined || hashId === null || hashId === '') return '';
|
||||||
|
if (typeof hashId === 'number') return hashId;
|
||||||
|
|
||||||
|
const str = String(hashId).trim();
|
||||||
|
if (!str) return '';
|
||||||
|
|
||||||
|
if (/^-?\d+$/.test(str)) {
|
||||||
|
const parsed = parseInt(str, 10);
|
||||||
|
return isNaN(parsed) ? str : parsed;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const decoded = hasher.decode(str);
|
||||||
|
if (Array.isArray(decoded) && decoded.length > 0) {
|
||||||
|
return Number(decoded[0]);
|
||||||
|
}
|
||||||
|
const decodedHex = hasher.decodeHex(str);
|
||||||
|
if (decodedHex) {
|
||||||
|
const num = Number(BigInt('0x' + decodedHex));
|
||||||
|
if (!isNaN(num)) return Math.trunc(num);
|
||||||
|
}
|
||||||
|
} catch {}
|
||||||
|
|
||||||
|
return str;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Decodes a HashID or numeric value strictly to a canonical integer.
|
||||||
|
* Useful for request payloads expecting int64 integers.
|
||||||
|
*/
|
||||||
|
export function decodeToNumberSync(hashId: number | string | null | undefined): number {
|
||||||
|
if (hashId === null || hashId === undefined || hashId === '') return 0;
|
||||||
|
if (typeof hashId === 'number') {
|
||||||
|
return isNaN(hashId) ? 0 : Math.trunc(hashId);
|
||||||
|
}
|
||||||
|
const str = String(hashId).trim();
|
||||||
|
if (/^-?\d+$/.test(str)) {
|
||||||
|
const parsed = parseInt(str, 10);
|
||||||
|
return isNaN(parsed) ? 0 : parsed;
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
const decoded = hasher.decode(str);
|
||||||
|
if (Array.isArray(decoded) && decoded.length > 0) {
|
||||||
|
return Number(decoded[0]);
|
||||||
|
}
|
||||||
|
const decodedHex = hasher.decodeHex(str);
|
||||||
|
if (decodedHex) {
|
||||||
|
const num = Number(BigInt('0x' + decodedHex));
|
||||||
|
if (!isNaN(num)) return Math.trunc(num);
|
||||||
|
}
|
||||||
|
} catch {}
|
||||||
|
const fallback = Number(str);
|
||||||
|
return isNaN(fallback) ? 0 : Math.trunc(fallback);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Legacy service resolver facade for compatibility with API endpoints and monitoring tables.
|
||||||
|
*/
|
||||||
|
export const toolsService = {
|
||||||
|
encodeIdSync(id: number | string | bigint | null | undefined): string {
|
||||||
|
return encodeHashIdSync(id);
|
||||||
|
},
|
||||||
|
decodeIdSync(hashId: string | number | null | undefined): number | string {
|
||||||
|
return decodeHashIdSync(hashId);
|
||||||
|
},
|
||||||
|
decodeToNumberSync(hashId: number | string | null | undefined): number {
|
||||||
|
return decodeToNumberSync(hashId);
|
||||||
|
},
|
||||||
|
async encodeId(id: number | string | bigint | null | undefined): Promise<string> {
|
||||||
|
return encodeHashIdSync(id);
|
||||||
|
},
|
||||||
|
async decodeId(hashId: string | number | null | undefined): Promise<number | string> {
|
||||||
|
return decodeHashIdSync(hashId);
|
||||||
|
},
|
||||||
|
async resolveProjectId(projectId: number | string): Promise<string> {
|
||||||
|
return encodeHashIdSync(projectId);
|
||||||
|
},
|
||||||
|
async resolveOrganisationId(organisationId: number | string): Promise<string> {
|
||||||
|
return encodeHashIdSync(organisationId);
|
||||||
|
},
|
||||||
|
async resolveProgrammeId(programmeId: number | string): Promise<string> {
|
||||||
|
return encodeHashIdSync(programmeId);
|
||||||
|
},
|
||||||
|
async resolvePortfolioId(portfolioId: number | string): Promise<string> {
|
||||||
|
return encodeHashIdSync(portfolioId);
|
||||||
|
},
|
||||||
|
async resolveAwardId(awardId: number | string): Promise<string> {
|
||||||
|
return encodeHashIdSync(awardId);
|
||||||
|
},
|
||||||
|
async resolveContractId(contractId: number | string): Promise<string> {
|
||||||
|
return encodeHashIdSync(contractId);
|
||||||
|
},
|
||||||
|
async resolveHashedParams<T extends Record<string, any>>(
|
||||||
|
params: T,
|
||||||
|
keysToHash: (keyof T)[]
|
||||||
|
): Promise<T> {
|
||||||
|
const resolved: any = { ...params };
|
||||||
|
for (const key of keysToHash) {
|
||||||
|
const val = params[key];
|
||||||
|
if (val !== undefined && val !== null) {
|
||||||
|
resolved[key] = encodeHashIdSync(val);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return resolved;
|
||||||
|
},
|
||||||
|
async primeCache(): Promise<void> {},
|
||||||
|
};
|
||||||
|
|||||||
+2
-1
@@ -4,8 +4,9 @@ interface ImportMetaEnv {
|
|||||||
readonly VITE_OIDC_AUTHORITY?: string;
|
readonly VITE_OIDC_AUTHORITY?: string;
|
||||||
readonly VITE_OIDC_AUDIENCE?: string;
|
readonly VITE_OIDC_AUDIENCE?: string;
|
||||||
readonly VITE_OIDC_CLIENT_ID?: string;
|
readonly VITE_OIDC_CLIENT_ID?: string;
|
||||||
readonly VITE_OIDC_CLIENT_SECRET?: string;
|
|
||||||
readonly VITE_API_BASE_URL?: string;
|
readonly VITE_API_BASE_URL?: string;
|
||||||
|
readonly VITE_HASHER_SALT?: string;
|
||||||
|
readonly VITE_HASHER_MIN_LENGTH?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
interface ImportMeta {
|
interface ImportMeta {
|
||||||
|
|||||||
@@ -50,7 +50,7 @@ export default defineConfig(async ({ command }) => {
|
|||||||
build: {
|
build: {
|
||||||
chunkSizeWarningLimit: 600,
|
chunkSizeWarningLimit: 600,
|
||||||
rollupOptions: {
|
rollupOptions: {
|
||||||
onwarn(warning, defaultHandler) {
|
onwarn(warning: any, defaultHandler: any) {
|
||||||
// Suppress benign Rollup warnings where Zod's internal explanatory doc comments mention `@__PURE__`
|
// Suppress benign Rollup warnings where Zod's internal explanatory doc comments mention `@__PURE__`
|
||||||
if (
|
if (
|
||||||
warning.code === 'INVALID_ANNOTATION' &&
|
warning.code === 'INVALID_ANNOTATION' &&
|
||||||
@@ -61,7 +61,7 @@ export default defineConfig(async ({ command }) => {
|
|||||||
defaultHandler(warning);
|
defaultHandler(warning);
|
||||||
},
|
},
|
||||||
output: {
|
output: {
|
||||||
manualChunks(id) {
|
manualChunks(id: string) {
|
||||||
if (id.includes('node_modules')) {
|
if (id.includes('node_modules')) {
|
||||||
// 1. Heavy export and PDF document generation
|
// 1. Heavy export and PDF document generation
|
||||||
if (id.includes('xlsx')) {
|
if (id.includes('xlsx')) {
|
||||||
|
|||||||
@@ -0,0 +1,3 @@
|
|||||||
|
namespace PostFundManagement.Domain.Abstractions;
|
||||||
|
|
||||||
|
public interface IDomain;
|
||||||
@@ -33,8 +33,8 @@ public sealed class EvidenceConfiguration : IEntityTypeConfiguration<Evidence>
|
|||||||
builder.Property(f => f.IndicatorId).IsRequired();
|
builder.Property(f => f.IndicatorId).IsRequired();
|
||||||
builder.Property(f => f.ActivityId).IsRequired();
|
builder.Property(f => f.ActivityId).IsRequired();
|
||||||
builder.Property(f => f.CreatedBy).IsRequired();
|
builder.Property(f => f.CreatedBy).IsRequired();
|
||||||
builder.Property(f => f.UpdatedBy).IsRequired();
|
|
||||||
builder.Property(f => f.CreatedAt).IsRequired().HasDefaultValueSql("now()");
|
builder.Property(f => f.CreatedAt).IsRequired().HasDefaultValueSql("now()");
|
||||||
|
builder.Property(f => f.UpdatedBy).IsRequired(false);
|
||||||
builder.Property(f => f.UpdatedAt).IsRequired(false);
|
builder.Property(f => f.UpdatedAt).IsRequired(false);
|
||||||
builder.Property(f => f.Version).IsRequired().HasDefaultValue(1);
|
builder.Property(f => f.Version).IsRequired().HasDefaultValue(1);
|
||||||
builder.Property(f => f.DocumentUrl).IsRequired();
|
builder.Property(f => f.DocumentUrl).IsRequired();
|
||||||
|
|||||||
@@ -24,6 +24,7 @@ public enum NotificationStatus : int
|
|||||||
Cancelled = 8,
|
Cancelled = 8,
|
||||||
Bounced = 9,
|
Bounced = 9,
|
||||||
Draft = 10,
|
Draft = 10,
|
||||||
|
Archived = 11,
|
||||||
}
|
}
|
||||||
|
|
||||||
public enum NotificationPlatform : int
|
public enum NotificationPlatform : int
|
||||||
|
|||||||
@@ -2,8 +2,6 @@ namespace PostFundManagement.Domain.Extensions;
|
|||||||
|
|
||||||
public static class Constants
|
public static class Constants
|
||||||
{
|
{
|
||||||
public const string ServiceCachingFeature = "EnableServiceCaching";
|
|
||||||
|
|
||||||
public const string SkorubaCredentialClient = nameof(SkorubaCredentialClient);
|
public const string SkorubaCredentialClient = nameof(SkorubaCredentialClient);
|
||||||
public const string SkorubaHttpClient = nameof(SkorubaHttpClient);
|
public const string SkorubaHttpClient = nameof(SkorubaHttpClient);
|
||||||
public const string MainScope = "ApiScope";
|
public const string MainScope = "ApiScope";
|
||||||
@@ -27,5 +25,12 @@ public static class Constants
|
|||||||
public const int ShortLabelLength = 100;
|
public const int ShortLabelLength = 100;
|
||||||
public const int TextLength = 1024;
|
public const int TextLength = 1024;
|
||||||
public const int MediumTextLength = 2048;
|
public const int MediumTextLength = 2048;
|
||||||
public const int LargeTextLength = 4096;
|
public const int LargeTextLength = 1_048_576; // 1 MB — accommodates branded HTML emails with embedded logo
|
||||||
|
|
||||||
|
public const string FeatureEmailProcessor = "EmailProcessorJobEnabled";
|
||||||
|
public const string FeatureLateProjectReminder = "LateProjectReminderEnabled";
|
||||||
|
public const string FeatureNonCompliantProjectCheck = "NonCompliantProjectCheckEnabled";
|
||||||
|
public const string FeatureServiceCaching = "ServiceCaching";
|
||||||
|
|
||||||
|
public static Guid SystemUser { get; } = new(0x6059f1e4, 0x6a41, 0x4447, 0xa5, 0x46, 0x86, 0xec, 0xe5, 0x10, 0x20, 0x4) /* 6059f1e4-6a41-4447-a546-86ece5102004 */;
|
||||||
}
|
}
|
||||||
@@ -11,8 +11,8 @@ public sealed class QuartzHealthCheck(ISchedulerFactory schedulerFactory) : IHea
|
|||||||
if (scheduler == null)
|
if (scheduler == null)
|
||||||
return HealthCheckResult.Unhealthy($"Scheduler with name '{DefaultSchedulerName}' not found.");
|
return HealthCheckResult.Unhealthy($"Scheduler with name '{DefaultSchedulerName}' not found.");
|
||||||
|
|
||||||
if (scheduler.Status != SchedulerStatus.Running)
|
if (scheduler.Status is not (SchedulerStatus.Running or SchedulerStatus.Standby or SchedulerStatus.Created))
|
||||||
return HealthCheckResult.Unhealthy($"{DefaultSchedulerName} Quartz scheduler is not running");
|
return HealthCheckResult.Unhealthy($"{DefaultSchedulerName} Quartz scheduler is not available (Status: {scheduler.Status})");
|
||||||
|
|
||||||
await scheduler.Exists(new JobKey(Guid.NewGuid().ToString()), cancellationToken);
|
await scheduler.Exists(new JobKey(Guid.NewGuid().ToString()), cancellationToken);
|
||||||
|
|
||||||
|
|||||||
@@ -1,5 +1,14 @@
|
|||||||
<Project Sdk="Microsoft.NET.Sdk">
|
<Project Sdk="Microsoft.NET.Sdk">
|
||||||
|
|
||||||
|
<ItemGroup>
|
||||||
|
<None Remove="Templates\lateprojectsmilestones.html" />
|
||||||
|
<None Remove="Templates\noncompliantprojects.html" />
|
||||||
|
<None Remove="Templates\noncompliantprojects-prog.html" />
|
||||||
|
<EmbeddedResource Include="Templates\lateprojectsmilestones.html" />
|
||||||
|
<EmbeddedResource Include="Templates\noncompliantprojects.html" />
|
||||||
|
<EmbeddedResource Include="Templates\noncompliantprojects-prog.html" />
|
||||||
|
</ItemGroup>
|
||||||
|
|
||||||
<!-- Transformation -->
|
<!-- Transformation -->
|
||||||
<ItemGroup>
|
<ItemGroup>
|
||||||
<PackageReference Include="Humanizer" />
|
<PackageReference Include="Humanizer" />
|
||||||
|
|||||||
@@ -0,0 +1,188 @@
|
|||||||
|
<!DOCTYPE html>
|
||||||
|
<html lang="en">
|
||||||
|
<head>
|
||||||
|
<meta charset="UTF-8">
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||||
|
<meta http-equiv="X-UA-Compatible" content="IE=edge">
|
||||||
|
<title>Project Milestone Overdue Notification</title>
|
||||||
|
<!--[if mso]>
|
||||||
|
<style type="text/css">
|
||||||
|
body, table, td { font-family: Arial, Helvetica, sans-serif !important; }
|
||||||
|
</style>
|
||||||
|
<![endif]-->
|
||||||
|
</head>
|
||||||
|
<body style="margin: 0; padding: 0; background-color: #f1f5f9; font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, Helvetica, Arial, sans-serif; -webkit-font-smoothing: antialiased; color: #1e293b; line-height: 1.6;">
|
||||||
|
|
||||||
|
<!-- Wrapper Table -->
|
||||||
|
<table role="presentation" border="0" cellpadding="0" cellspacing="0" width="100%" style="background-color: #f1f5f9; padding: 32px 16px;">
|
||||||
|
<tr>
|
||||||
|
<td align="center">
|
||||||
|
<!-- Main Container Card (600px) -->
|
||||||
|
<table role="presentation" border="0" cellpadding="0" cellspacing="0" width="100%" style="max-width: 600px; background-color: #ffffff; border-radius: 12px; overflow: hidden; box-shadow: 0 4px 6px -1px rgba(0, 0, 0, 0.05), 0 2px 4px -2px rgba(0, 0, 0, 0.05); border: 1px solid #e2e8f0;">
|
||||||
|
|
||||||
|
<!-- Header Bar -->
|
||||||
|
<tr>
|
||||||
|
<td style="background-color: #0f172a; padding: 24px 32px; border-bottom: 3px solid #e11d48;">
|
||||||
|
<table role="presentation" border="0" cellpadding="0" cellspacing="0" width="100%">
|
||||||
|
<tr>
|
||||||
|
<td>
|
||||||
|
<div style="font-size: 11px; font-weight: 700; color: #94a3b8; text-transform: uppercase; letter-spacing: 1.2px; margin-bottom: 4px;">
|
||||||
|
Post-Fund Management Platform (PFM)
|
||||||
|
</div>
|
||||||
|
<div style="font-size: 18px; font-weight: 700; color: #ffffff; letter-spacing: -0.3px;">
|
||||||
|
Portfolio Performance & Monitoring
|
||||||
|
</div>
|
||||||
|
</td>
|
||||||
|
<td align="right" style="vertical-align: middle;">
|
||||||
|
<span style="display: inline-block; background-color: rgba(225, 29, 72, 0.2); border: 1px solid rgba(225, 29, 72, 0.5); color: #fda4af; padding: 4px 10px; border-radius: 9999px; font-size: 11px; font-weight: 600; text-transform: uppercase; letter-spacing: 0.5px;">
|
||||||
|
Action Required
|
||||||
|
</span>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
</table>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
|
||||||
|
<!-- Alert Banner -->
|
||||||
|
<tr>
|
||||||
|
<td style="background-color: #fff1f2; border-bottom: 1px solid #ffe4e6; padding: 16px 32px;">
|
||||||
|
<table role="presentation" border="0" cellpadding="0" cellspacing="0" width="100%">
|
||||||
|
<tr>
|
||||||
|
<td style="width: 28px; vertical-align: top; padding-right: 12px;">
|
||||||
|
<div style="width: 24px; height: 24px; background-color: #e11d48; border-radius: 50%; color: #ffffff; text-align: center; line-height: 24px; font-size: 13px; font-weight: bold;">
|
||||||
|
!
|
||||||
|
</div>
|
||||||
|
</td>
|
||||||
|
<td>
|
||||||
|
<div style="font-size: 14px; font-weight: 700; color: #9f1239; margin-bottom: 2px;">
|
||||||
|
Overdue Milestone Notice: [TotalLateMilestones] Milestone(s) Exceeded Due Date
|
||||||
|
</div>
|
||||||
|
<div style="font-size: 12px; color: #be123c;">
|
||||||
|
Automated daily sweep flagged scheduled deliverables requiring evidence submission or status updates.
|
||||||
|
</div>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
</table>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
|
||||||
|
<!-- Content Body -->
|
||||||
|
<tr>
|
||||||
|
<td style="padding: 32px;">
|
||||||
|
|
||||||
|
<!-- Salutation -->
|
||||||
|
<p style="margin: 0 0 16px 0; font-size: 15px; color: #334155;">
|
||||||
|
Dear <strong>[OrganisationName]</strong> Team,
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<!-- Message -->
|
||||||
|
<p style="margin: 0 0 24px 0; font-size: 14px; color: #475569; line-height: 1.6;">
|
||||||
|
During our automated lifecycle monitoring sweep, our platform identified one or more contractual milestones for
|
||||||
|
<strong style="color: #0f172a;">[ProjectName]</strong> that have passed their agreed target completion deadlines without formal evidence approval.
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<!-- Project Overview Box -->
|
||||||
|
<table role="presentation" border="0" cellpadding="0" cellspacing="0" width="100%" style="background-color: #f8fafc; border-radius: 8px; border: 1px solid #e2e8f0; margin-bottom: 24px; overflow: hidden;">
|
||||||
|
<tr>
|
||||||
|
<td colspan="2" style="background-color: #f1f5f9; padding: 10px 16px; border-bottom: 1px solid #e2e8f0; font-size: 12px; font-weight: 700; color: #475569; text-transform: uppercase; letter-spacing: 0.5px;">
|
||||||
|
Project Summary
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
<tr>
|
||||||
|
<td style="padding: 12px 16px; font-size: 13px; color: #64748b; width: 40%; border-bottom: 1px solid #f1f5f9;">Project Title</td>
|
||||||
|
<td style="padding: 12px 16px; font-size: 13px; font-weight: 600; color: #0f172a; border-bottom: 1px solid #f1f5f9;">[ProjectName]</td>
|
||||||
|
</tr>
|
||||||
|
<tr>
|
||||||
|
<td style="padding: 12px 16px; font-size: 13px; color: #64748b; border-bottom: 1px solid #f1f5f9;">Implementation Window</td>
|
||||||
|
<td style="padding: 12px 16px; font-size: 13px; color: #334155; border-bottom: 1px solid #f1f5f9;">[ProjectStartDate] — [ProjectEndDate]</td>
|
||||||
|
</tr>
|
||||||
|
<tr>
|
||||||
|
<td style="padding: 12px 16px; font-size: 13px; color: #64748b; border-bottom: 1px solid #f1f5f9;">Overdue Deliverables</td>
|
||||||
|
<td style="padding: 12px 16px; font-size: 13px; font-weight: 700; color: #e11d48; border-bottom: 1px solid #f1f5f9;">
|
||||||
|
<span style="background-color: #fee2e2; color: #991b1b; padding: 2px 8px; border-radius: 4px;">[TotalLateMilestones] Milestone(s)</span>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
<tr>
|
||||||
|
<td style="padding: 12px 16px; font-size: 13px; color: #64748b;">Most Overdue Milestone</td>
|
||||||
|
<td style="padding: 12px 16px; font-size: 13px; color: #334155;">
|
||||||
|
<strong>[MostLateMilestoneName]</strong> <span style="color: #64748b; font-size: 12px;">(Due: [MostLateMilestoneDueDate])</span>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
</table>
|
||||||
|
|
||||||
|
<!-- Detailed Milestones Table Header -->
|
||||||
|
<div style="font-size: 13px; font-weight: 700; color: #1e293b; margin-bottom: 10px; text-transform: uppercase; letter-spacing: 0.5px;">
|
||||||
|
Milestone Breakdown
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Milestones Table -->
|
||||||
|
<table role="presentation" border="0" cellpadding="0" cellspacing="0" width="100%" style="border-collapse: collapse; margin-bottom: 24px; font-size: 13px; border: 1px solid #e2e8f0; border-radius: 6px; overflow: hidden;">
|
||||||
|
<thead>
|
||||||
|
<tr style="background-color: #f8fafc; text-align: left; border-bottom: 2px solid #e2e8f0;">
|
||||||
|
<th style="padding: 10px 14px; font-size: 11px; font-weight: 700; color: #475569; text-transform: uppercase;">Milestone</th>
|
||||||
|
<th style="padding: 10px 14px; font-size: 11px; font-weight: 700; color: #475569; text-transform: uppercase;">Due Date</th>
|
||||||
|
<th style="padding: 10px 14px; font-size: 11px; font-weight: 700; color: #475569; text-transform: uppercase;">Delay</th>
|
||||||
|
<th style="padding: 10px 14px; font-size: 11px; font-weight: 700; color: #475569; text-transform: uppercase;">Status</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
[LateMilestonesTable]
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
|
||||||
|
<!-- Governance & SLA Callout -->
|
||||||
|
<table role="presentation" border="0" cellpadding="0" cellspacing="0" width="100%" style="background-color: #f8fafc; border-left: 4px solid #0284c7; padding: 14px 18px; border-radius: 4px; margin-bottom: 28px;">
|
||||||
|
<tr>
|
||||||
|
<td>
|
||||||
|
<div style="font-size: 12px; font-weight: 700; color: #0369a1; text-transform: uppercase; margin-bottom: 4px;">
|
||||||
|
Disbursement & Governance Policy (BR-003 / BR-004)
|
||||||
|
</div>
|
||||||
|
<div style="font-size: 12px; color: #475569; line-height: 1.5;">
|
||||||
|
Under PFM governance regulations, tranches and disbursements are strictly gated by verified milestone deliverables. Overdue milestones may trigger project risk escalations and freeze scheduled payment releases.
|
||||||
|
</div>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
</table>
|
||||||
|
|
||||||
|
<!-- CTA Button -->
|
||||||
|
<table role="presentation" border="0" cellpadding="0" cellspacing="0" width="100%" style="margin-bottom: 28px;">
|
||||||
|
<tr>
|
||||||
|
<td align="center">
|
||||||
|
<a href="[PortalUrl]" target="_blank" style="display: inline-block; background-color: #0f172a; color: #ffffff; text-decoration: none; font-size: 14px; font-weight: 600; padding: 12px 32px; border-radius: 6px; box-shadow: 0 2px 4px rgba(15, 23, 42, 0.15);">
|
||||||
|
Open PFM Portal & Submit Evidence →
|
||||||
|
</a>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
</table>
|
||||||
|
|
||||||
|
<p style="margin: 0; font-size: 13px; color: #64748b; line-height: 1.5;">
|
||||||
|
If you have already submitted the evidence for these deliverables or have an active change request under review with your Programme Manager, you may disregard this notice.
|
||||||
|
</p>
|
||||||
|
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
|
||||||
|
<!-- Footer -->
|
||||||
|
<tr>
|
||||||
|
<td style="background-color: #f8fafc; padding: 24px 32px; border-top: 1px solid #e2e8f0; font-size: 12px; color: #94a3b8; text-align: center;">
|
||||||
|
<p style="margin: 0 0 8px 0; color: #64748b; font-weight: 600;">
|
||||||
|
Post-Fund Management (PFM) Platform • Automated Lifecycle & SLA Daemon
|
||||||
|
</p>
|
||||||
|
<p style="margin: 0 0 12px 0; line-height: 1.4;">
|
||||||
|
This is an automated contractual notification sent in accordance with your funding agreement SLA.<br>
|
||||||
|
Please do not reply directly to this email. For support, please contact your designated Programme Officer.
|
||||||
|
</p>
|
||||||
|
<p style="margin: 0; font-size: 11px; color: #cbd5e1;">
|
||||||
|
© [Year] PostFundManagement Platform. All rights reserved.
|
||||||
|
</p>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
|
||||||
|
</table>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
</table>
|
||||||
|
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
|
|
||||||
@@ -0,0 +1,195 @@
|
|||||||
|
<!DOCTYPE html>
|
||||||
|
<html lang="en">
|
||||||
|
<head>
|
||||||
|
<meta charset="UTF-8">
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||||
|
<meta http-equiv="X-UA-Compatible" content="IE=edge">
|
||||||
|
<title>Programme Oversight: Project Milestone Escalation Notice</title>
|
||||||
|
<!--[if mso]>
|
||||||
|
<style type="text/css">
|
||||||
|
body, table, td { font-family: Arial, Helvetica, sans-serif !important; }
|
||||||
|
</style>
|
||||||
|
<![endif]-->
|
||||||
|
</head>
|
||||||
|
<body style="margin: 0; padding: 0; background-color: #f8fafc; font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, Helvetica, Arial, sans-serif; -webkit-font-smoothing: antialiased; color: #1e293b; line-height: 1.6;">
|
||||||
|
|
||||||
|
<!-- Wrapper Table -->
|
||||||
|
<table role="presentation" border="0" cellpadding="0" cellspacing="0" width="100%" style="background-color: #f8fafc; padding: 32px 16px;">
|
||||||
|
<tr>
|
||||||
|
<td align="center">
|
||||||
|
<!-- Main Container Card (600px) -->
|
||||||
|
<table role="presentation" border="0" cellpadding="0" cellspacing="0" width="100%" style="max-width: 600px; background-color: #ffffff; border-radius: 12px; overflow: hidden; box-shadow: 0 4px 6px -1px rgba(0, 0, 0, 0.05), 0 2px 4px -2px rgba(0, 0, 0, 0.05); border: 1px solid #e2e8f0;">
|
||||||
|
|
||||||
|
<!-- Header Bar -->
|
||||||
|
<tr>
|
||||||
|
<td style="background-color: #0f172a; padding: 24px 32px; border-bottom: 3px solid #d97706;">
|
||||||
|
<table role="presentation" border="0" cellpadding="0" cellspacing="0" width="100%">
|
||||||
|
<tr>
|
||||||
|
<td>
|
||||||
|
<div style="font-size: 11px; font-weight: 700; color: #94a3b8; text-transform: uppercase; letter-spacing: 1.2px; margin-bottom: 4px;">
|
||||||
|
PFM Internal Governance • FR-M07-009 Escalation
|
||||||
|
</div>
|
||||||
|
<div style="font-size: 18px; font-weight: 700; color: #ffffff; letter-spacing: -0.3px;">
|
||||||
|
Programme Milestone Escalation
|
||||||
|
</div>
|
||||||
|
</td>
|
||||||
|
<td align="right" style="vertical-align: middle;">
|
||||||
|
<span style="display: inline-block; background-color: rgba(217, 119, 6, 0.2); border: 1px solid rgba(217, 119, 6, 0.6); color: #fde68a; padding: 4px 10px; border-radius: 9999px; font-size: 11px; font-weight: 600; text-transform: uppercase; letter-spacing: 0.5px;">
|
||||||
|
Internal Notice
|
||||||
|
</span>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
</table>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
|
||||||
|
<!-- Alert Banner -->
|
||||||
|
<tr>
|
||||||
|
<td style="background-color: #fffbeb; border-bottom: 1px solid #fef3c7; padding: 16px 32px;">
|
||||||
|
<table role="presentation" border="0" cellpadding="0" cellspacing="0" width="100%">
|
||||||
|
<tr>
|
||||||
|
<td style="width: 28px; vertical-align: top; padding-right: 12px;">
|
||||||
|
<div style="width: 24px; height: 24px; background-color: #d97706; border-radius: 50%; color: #ffffff; text-align: center; line-height: 24px; font-size: 13px; font-weight: bold;">
|
||||||
|
!
|
||||||
|
</div>
|
||||||
|
</td>
|
||||||
|
<td>
|
||||||
|
<div style="font-size: 14px; font-weight: 700; color: #92400e; margin-bottom: 2px;">
|
||||||
|
Milestone SLA Breach: [TotalLateMilestones] Deliverable(s) Past Due Date
|
||||||
|
</div>
|
||||||
|
<div style="font-size: 12px; color: #b45309;">
|
||||||
|
Project under your programme governance has breached milestone delivery deadlines. Automated partner reminder was dispatched.
|
||||||
|
</div>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
</table>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
|
||||||
|
<!-- Content Body -->
|
||||||
|
<tr>
|
||||||
|
<td style="padding: 32px;">
|
||||||
|
|
||||||
|
<!-- Salutation -->
|
||||||
|
<p style="margin: 0 0 16px 0; font-size: 15px; color: #334155;">
|
||||||
|
Dear <strong>[ProgrammeManagerName]</strong>,
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<!-- Context Narrative -->
|
||||||
|
<p style="margin: 0 0 24px 0; font-size: 14px; color: #475569; line-height: 1.6;">
|
||||||
|
In accordance with portfolio monitoring requirement <strong>FR-M07-009</strong>, this automated escalation notice alerts you that
|
||||||
|
<strong style="color: #0f172a;">[ProjectName]</strong> has overdue milestones requiring your oversight and possible intervention.
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<!-- Governance Context Box -->
|
||||||
|
<table role="presentation" border="0" cellpadding="0" cellspacing="0" width="100%" style="background-color: #f8fafc; border-radius: 8px; border: 1px solid #e2e8f0; margin-bottom: 24px; overflow: hidden;">
|
||||||
|
<tr>
|
||||||
|
<td colspan="2" style="background-color: #f1f5f9; padding: 10px 16px; border-bottom: 1px solid #e2e8f0; font-size: 12px; font-weight: 700; color: #475569; text-transform: uppercase; letter-spacing: 0.5px;">
|
||||||
|
Governance & Project Hierarchy
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
<tr>
|
||||||
|
<td style="padding: 12px 16px; font-size: 13px; color: #64748b; width: 40%; border-bottom: 1px solid #f1f5f9;">Programme</td>
|
||||||
|
<td style="padding: 12px 16px; font-size: 13px; font-weight: 600; color: #0f172a; border-bottom: 1px solid #f1f5f9;">[ProgrammeName]</td>
|
||||||
|
</tr>
|
||||||
|
<tr>
|
||||||
|
<td style="padding: 12px 16px; font-size: 13px; color: #64748b; border-bottom: 1px solid #f1f5f9;">Portfolio</td>
|
||||||
|
<td style="padding: 12px 16px; font-size: 13px; color: #334155; border-bottom: 1px solid #f1f5f9;">[PortfolioName]</td>
|
||||||
|
</tr>
|
||||||
|
<tr>
|
||||||
|
<td style="padding: 12px 16px; font-size: 13px; color: #64748b; border-bottom: 1px solid #f1f5f9;">Recipient Organisation</td>
|
||||||
|
<td style="padding: 12px 16px; font-size: 13px; font-weight: 600; color: #0f172a; border-bottom: 1px solid #f1f5f9;">[OrganisationName]</td>
|
||||||
|
</tr>
|
||||||
|
<tr>
|
||||||
|
<td style="padding: 12px 16px; font-size: 13px; color: #64748b; border-bottom: 1px solid #f1f5f9;">Project Title</td>
|
||||||
|
<td style="padding: 12px 16px; font-size: 13px; font-weight: 600; color: #0f172a; border-bottom: 1px solid #f1f5f9;">[ProjectName]</td>
|
||||||
|
</tr>
|
||||||
|
<tr>
|
||||||
|
<td style="padding: 12px 16px; font-size: 13px; color: #64748b; border-bottom: 1px solid #f1f5f9;">Project Duration</td>
|
||||||
|
<td style="padding: 12px 16px; font-size: 13px; color: #334155; border-bottom: 1px solid #f1f5f9;">[ProjectStartDate] — [ProjectEndDate]</td>
|
||||||
|
</tr>
|
||||||
|
<tr>
|
||||||
|
<td style="padding: 12px 16px; font-size: 13px; color: #64748b;">Overdue Status</td>
|
||||||
|
<td style="padding: 12px 16px; font-size: 13px;">
|
||||||
|
<span style="background-color: #fee2e2; color: #991b1b; padding: 2px 8px; border-radius: 4px; font-weight: 700;">
|
||||||
|
[TotalLateMilestones] Milestone(s) Overdue
|
||||||
|
</span>
|
||||||
|
<span style="color: #64748b; font-size: 12px; margin-left: 6px;">(Primary: [MostLateMilestoneName], [MostLateMilestoneDaysOverdue] days late)</span>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
</table>
|
||||||
|
|
||||||
|
<!-- Detailed Milestones Table Header -->
|
||||||
|
<div style="font-size: 13px; font-weight: 700; color: #1e293b; margin-bottom: 10px; text-transform: uppercase; letter-spacing: 0.5px;">
|
||||||
|
Delinquent Deliverables Breakdown
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Milestones Table -->
|
||||||
|
<table role="presentation" border="0" cellpadding="0" cellspacing="0" width="100%" style="border-collapse: collapse; margin-bottom: 24px; font-size: 13px; border: 1px solid #e2e8f0; border-radius: 6px; overflow: hidden;">
|
||||||
|
<thead>
|
||||||
|
<tr style="background-color: #f8fafc; text-align: left; border-bottom: 2px solid #e2e8f0;">
|
||||||
|
<th style="padding: 10px 14px; font-size: 11px; font-weight: 700; color: #475569; text-transform: uppercase;">Milestone</th>
|
||||||
|
<th style="padding: 10px 14px; font-size: 11px; font-weight: 700; color: #475569; text-transform: uppercase;">Due Date</th>
|
||||||
|
<th style="padding: 10px 14px; font-size: 11px; font-weight: 700; color: #475569; text-transform: uppercase;">Delay</th>
|
||||||
|
<th style="padding: 10px 14px; font-size: 11px; font-weight: 700; color: #475569; text-transform: uppercase;">Status</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
[LateMilestonesTable]
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
|
||||||
|
<!-- Recommended Action Guidance -->
|
||||||
|
<table role="presentation" border="0" cellpadding="0" cellspacing="0" width="100%" style="background-color: #f8fafc; border-left: 4px solid #d97706; padding: 14px 18px; border-radius: 4px; margin-bottom: 28px;">
|
||||||
|
<tr>
|
||||||
|
<td>
|
||||||
|
<div style="font-size: 12px; font-weight: 700; color: #92400e; text-transform: uppercase; margin-bottom: 4px;">
|
||||||
|
Recommended Manager Actions
|
||||||
|
</div>
|
||||||
|
<ul style="margin: 0; padding-left: 18px; font-size: 12px; color: #475569; line-height: 1.6;">
|
||||||
|
<li>Verify whether unreviewed evidence submissions are awaiting your sign-off in the Admin Console.</li>
|
||||||
|
<li>Engage recipient organisation leadership regarding timeline remediation or change requests (M15).</li>
|
||||||
|
<li>Ensure disbursements linked to gated milestones remain withheld per rule BR-003.</li>
|
||||||
|
</ul>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
</table>
|
||||||
|
|
||||||
|
<!-- CTA Button -->
|
||||||
|
<table role="presentation" border="0" cellpadding="0" cellspacing="0" width="100%" style="margin-bottom: 24px;">
|
||||||
|
<tr>
|
||||||
|
<td align="center">
|
||||||
|
<a href="[ConsoleUrl]" target="_blank" style="display: inline-block; background-color: #0f172a; color: #ffffff; text-decoration: none; font-size: 14px; font-weight: 600; padding: 12px 32px; border-radius: 6px; box-shadow: 0 2px 4px rgba(15, 23, 42, 0.15);">
|
||||||
|
Open Programme in Console →
|
||||||
|
</a>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
</table>
|
||||||
|
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
|
||||||
|
<!-- Footer -->
|
||||||
|
<tr>
|
||||||
|
<td style="background-color: #f8fafc; padding: 24px 32px; border-top: 1px solid #e2e8f0; font-size: 12px; color: #94a3b8; text-align: center;">
|
||||||
|
<p style="margin: 0 0 8px 0; color: #64748b; font-weight: 600;">
|
||||||
|
Post-Fund Management (PFM) Platform • Automated Governance & Monitoring Daemon
|
||||||
|
</p>
|
||||||
|
<p style="margin: 0 0 12px 0; line-height: 1.4;">
|
||||||
|
You received this notification because you are designated as the Programme Owner/Manager for [ProgrammeName].<br>
|
||||||
|
Automated escalation according to SRS v2.0 requirement FR-M07-009.
|
||||||
|
</p>
|
||||||
|
<p style="margin: 0; font-size: 11px; color: #cbd5e1;">
|
||||||
|
© [Year] PostFundManagement Platform. All rights reserved.
|
||||||
|
</p>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
|
||||||
|
</table>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
</table>
|
||||||
|
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
|
|
||||||
@@ -0,0 +1,199 @@
|
|||||||
|
<!DOCTYPE html>
|
||||||
|
<html lang="en">
|
||||||
|
<head>
|
||||||
|
<meta charset="UTF-8">
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||||
|
<meta http-equiv="X-UA-Compatible" content="IE=edge">
|
||||||
|
<title>Weekly Governance Audit: Project Non-Compliance Notice</title>
|
||||||
|
<!--[if mso]>
|
||||||
|
<style type="text/css">
|
||||||
|
body, table, td { font-family: Arial, Helvetica, sans-serif !important; }
|
||||||
|
</style>
|
||||||
|
<![endif]-->
|
||||||
|
</head>
|
||||||
|
<body style="margin: 0; padding: 0; background-color: #f1f5f9; font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, Helvetica, Arial, sans-serif; -webkit-font-smoothing: antialiased; color: #1e293b; line-height: 1.6;">
|
||||||
|
|
||||||
|
<!-- Wrapper Table -->
|
||||||
|
<table role="presentation" border="0" cellpadding="0" cellspacing="0" width="100%" style="background-color: #f1f5f9; padding: 32px 16px;">
|
||||||
|
<tr>
|
||||||
|
<td align="center">
|
||||||
|
<!-- Main Container Card (600px) -->
|
||||||
|
<table role="presentation" border="0" cellpadding="0" cellspacing="0" width="100%" style="max-width: 600px; background-color: #ffffff; border-radius: 12px; overflow: hidden; box-shadow: 0 4px 6px -1px rgba(0, 0, 0, 0.05), 0 2px 4px -2px rgba(0, 0, 0, 0.05); border: 1px solid #e2e8f0;">
|
||||||
|
|
||||||
|
<!-- Header Bar -->
|
||||||
|
<tr>
|
||||||
|
<td style="background-color: #0f172a; padding: 24px 32px; border-bottom: 3px solid #be123c;">
|
||||||
|
<table role="presentation" border="0" cellpadding="0" cellspacing="0" width="100%">
|
||||||
|
<tr>
|
||||||
|
<td>
|
||||||
|
<div style="font-size: 11px; font-weight: 700; color: #94a3b8; text-transform: uppercase; letter-spacing: 1.2px; margin-bottom: 4px;">
|
||||||
|
Post-Fund Management Platform (PFM) • M10 Compliance
|
||||||
|
</div>
|
||||||
|
<div style="font-size: 18px; font-weight: 700; color: #ffffff; letter-spacing: -0.3px;">
|
||||||
|
Weekly Project Compliance Audit
|
||||||
|
</div>
|
||||||
|
</td>
|
||||||
|
<td align="right" style="vertical-align: middle;">
|
||||||
|
<span style="display: inline-block; background-color: rgba(190, 18, 60, 0.2); border: 1px solid rgba(190, 18, 60, 0.6); color: #fda4af; padding: 4px 10px; border-radius: 9999px; font-size: 11px; font-weight: 600; text-transform: uppercase; letter-spacing: 0.5px;">
|
||||||
|
Non-Compliant
|
||||||
|
</span>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
</table>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
|
||||||
|
<!-- Alert Banner -->
|
||||||
|
<tr>
|
||||||
|
<td style="background-color: #fff1f2; border-bottom: 1px solid #ffe4e6; padding: 16px 32px;">
|
||||||
|
<table role="presentation" border="0" cellpadding="0" cellspacing="0" width="100%">
|
||||||
|
<tr>
|
||||||
|
<td style="width: 28px; vertical-align: top; padding-right: 12px;">
|
||||||
|
<div style="width: 24px; height: 24px; background-color: #be123c; border-radius: 50%; color: #ffffff; text-align: center; line-height: 24px; font-size: 13px; font-weight: bold;">
|
||||||
|
!
|
||||||
|
</div>
|
||||||
|
</td>
|
||||||
|
<td>
|
||||||
|
<div style="font-size: 14px; font-weight: 700; color: #9f1239; margin-bottom: 2px;">
|
||||||
|
Action Required: Project Flagged as Non-Compliant ([ComplianceScore] Met)
|
||||||
|
</div>
|
||||||
|
<div style="font-size: 12px; color: #be123c;">
|
||||||
|
Weekly automated governance audit identified unmet statutory obligations and/or overdue milestone deliverables.
|
||||||
|
</div>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
</table>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
|
||||||
|
<!-- Content Body -->
|
||||||
|
<tr>
|
||||||
|
<td style="padding: 32px;">
|
||||||
|
|
||||||
|
<!-- Salutation -->
|
||||||
|
<p style="margin: 0 0 16px 0; font-size: 15px; color: #334155;">
|
||||||
|
Dear <strong>[OrganisationName]</strong> Team,
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<!-- Narrative -->
|
||||||
|
<p style="margin: 0 0 24px 0; font-size: 14px; color: #475569; line-height: 1.6;">
|
||||||
|
During our weekly automated compliance audit, <strong style="color: #0f172a;">[ProjectName]</strong> (under programme <strong>[ProgrammeName]</strong>) did not satisfy the mandatory statutory and contractual compliance threshold. In accordance with system governance policies, immediate remediation is required.
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<!-- Project Summary Box -->
|
||||||
|
<table role="presentation" border="0" cellpadding="0" cellspacing="0" width="100%" style="background-color: #f8fafc; border-radius: 8px; border: 1px solid #e2e8f0; margin-bottom: 24px; overflow: hidden;">
|
||||||
|
<tr>
|
||||||
|
<td colspan="2" style="background-color: #f1f5f9; padding: 10px 16px; border-bottom: 1px solid #e2e8f0; font-size: 12px; font-weight: 700; color: #475569; text-transform: uppercase; letter-spacing: 0.5px;">
|
||||||
|
Audit Findings Overview
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
<tr>
|
||||||
|
<td style="padding: 12px 16px; font-size: 13px; color: #64748b; width: 40%; border-bottom: 1px solid #f1f5f9;">Project Title</td>
|
||||||
|
<td style="padding: 12px 16px; font-size: 13px; font-weight: 600; color: #0f172a; border-bottom: 1px solid #f1f5f9;">[ProjectName]</td>
|
||||||
|
</tr>
|
||||||
|
<tr>
|
||||||
|
<td style="padding: 12px 16px; font-size: 13px; color: #64748b; border-bottom: 1px solid #f1f5f9;">Programme / Portfolio</td>
|
||||||
|
<td style="padding: 12px 16px; font-size: 13px; color: #334155; border-bottom: 1px solid #f1f5f9;">[ProgrammeName] • [PortfolioName]</td>
|
||||||
|
</tr>
|
||||||
|
<tr>
|
||||||
|
<td style="padding: 12px 16px; font-size: 13px; color: #64748b; border-bottom: 1px solid #f1f5f9;">Implementation Window</td>
|
||||||
|
<td style="padding: 12px 16px; font-size: 13px; color: #334155; border-bottom: 1px solid #f1f5f9;">[ProjectStartDate] — [ProjectEndDate]</td>
|
||||||
|
</tr>
|
||||||
|
<tr>
|
||||||
|
<td style="padding: 12px 16px; font-size: 13px; color: #64748b; border-bottom: 1px solid #f1f5f9;">Compliance Verdict</td>
|
||||||
|
<td style="padding: 12px 16px; font-size: 13px; border-bottom: 1px solid #f1f5f9;">
|
||||||
|
<span style="background-color: #fee2e2; color: #991b1b; padding: 2px 8px; border-radius: 4px; font-weight: 700;">
|
||||||
|
Non-Compliant — Score: [ComplianceScore]
|
||||||
|
</span>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
<tr>
|
||||||
|
<td style="padding: 12px 16px; font-size: 13px; color: #64748b; border-bottom: 1px solid #f1f5f9;">Unmet Compliance Items</td>
|
||||||
|
<td style="padding: 12px 16px; font-size: 13px; font-weight: 600; color: #be123c; border-bottom: 1px solid #f1f5f9;">
|
||||||
|
[UnmetComplianceCount] Item(s) Pending Resolution
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
<tr>
|
||||||
|
<td style="padding: 12px 16px; font-size: 13px; color: #64748b;">Overdue Milestones</td>
|
||||||
|
<td style="padding: 12px 16px; font-size: 13px; font-weight: 600; color: #be123c;">
|
||||||
|
[UnmetMilestonesCount] Milestone(s) Overdue
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
</table>
|
||||||
|
|
||||||
|
<!-- Items Table Header -->
|
||||||
|
<div style="font-size: 13px; font-weight: 700; color: #1e293b; margin-bottom: 10px; text-transform: uppercase; letter-spacing: 0.5px;">
|
||||||
|
Outstanding Compliance & Deliverable Items
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Non-Compliance Items Table -->
|
||||||
|
<table role="presentation" border="0" cellpadding="0" cellspacing="0" width="100%" style="border-collapse: collapse; margin-bottom: 24px; font-size: 13px; border: 1px solid #e2e8f0; border-radius: 6px; overflow: hidden;">
|
||||||
|
<thead>
|
||||||
|
<tr style="background-color: #f8fafc; text-align: left; border-bottom: 2px solid #e2e8f0;">
|
||||||
|
<th style="padding: 10px 14px; font-size: 11px; font-weight: 700; color: #475569; text-transform: uppercase;">Category</th>
|
||||||
|
<th style="padding: 10px 14px; font-size: 11px; font-weight: 700; color: #475569; text-transform: uppercase;">Item / Obligation</th>
|
||||||
|
<th style="padding: 10px 14px; font-size: 11px; font-weight: 700; color: #475569; text-transform: uppercase;">Details / Due Date</th>
|
||||||
|
<th style="padding: 10px 14px; font-size: 11px; font-weight: 700; color: #475569; text-transform: uppercase;">Status</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
[NonComplianceTable]
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
|
||||||
|
<!-- Governance & Disbursement Hold Callout -->
|
||||||
|
<table role="presentation" border="0" cellpadding="0" cellspacing="0" width="100%" style="background-color: #fff1f2; border-left: 4px solid #be123c; padding: 14px 18px; border-radius: 4px; margin-bottom: 28px;">
|
||||||
|
<tr>
|
||||||
|
<td>
|
||||||
|
<div style="font-size: 12px; font-weight: 700; color: #9f1239; text-transform: uppercase; margin-bottom: 4px;">
|
||||||
|
Disbursement Hold Notice (BR-003 / AT-08)
|
||||||
|
</div>
|
||||||
|
<div style="font-size: 12px; color: #475569; line-height: 1.5;">
|
||||||
|
Under cardinal rule BR-003, all pending tranche disbursements and drawdown requests are frozen while a project carries non-compliant obligations. Please upload the required compliance verification evidence through the Partner Portal to restore good standing.
|
||||||
|
</div>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
</table>
|
||||||
|
|
||||||
|
<!-- CTA Button -->
|
||||||
|
<table role="presentation" border="0" cellpadding="0" cellspacing="0" width="100%" style="margin-bottom: 28px;">
|
||||||
|
<tr>
|
||||||
|
<td align="center">
|
||||||
|
<a href="[PortalUrl]" target="_blank" style="display: inline-block; background-color: #0f172a; color: #ffffff; text-decoration: none; font-size: 14px; font-weight: 600; padding: 12px 32px; border-radius: 6px; box-shadow: 0 2px 4px rgba(15, 23, 42, 0.15);">
|
||||||
|
Open Partner Portal & Submit Compliance Evidence →
|
||||||
|
</a>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
</table>
|
||||||
|
|
||||||
|
<p style="margin: 0; font-size: 13px; color: #64748b; line-height: 1.5;">
|
||||||
|
Once evidence is submitted, your designated Programme Officer will review and verify the documentation to restore your project's 100% compliant status.
|
||||||
|
</p>
|
||||||
|
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
|
||||||
|
<!-- Footer -->
|
||||||
|
<tr>
|
||||||
|
<td style="background-color: #f8fafc; padding: 24px 32px; border-top: 1px solid #e2e8f0; font-size: 12px; color: #94a3b8; text-align: center;">
|
||||||
|
<p style="margin: 0 0 8px 0; color: #64748b; font-weight: 600;">
|
||||||
|
Post-Fund Management (PFM) Platform • Weekly Compliance Audit Daemon
|
||||||
|
</p>
|
||||||
|
<p style="margin: 0 0 12px 0; line-height: 1.4;">
|
||||||
|
Automated statutory oversight notification generated in accordance with SRS v2.0 Module M10.<br>
|
||||||
|
Please do not reply directly to this email. For assistance, contact your Programme Officer.
|
||||||
|
</p>
|
||||||
|
<p style="margin: 0; font-size: 11px; color: #cbd5e1;">
|
||||||
|
© [Year] PostFundManagement Platform. All rights reserved.
|
||||||
|
</p>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
|
||||||
|
</table>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
</table>
|
||||||
|
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
|
|
||||||
+2519
File diff suppressed because it is too large
Load Diff
+60
@@ -0,0 +1,60 @@
|
|||||||
|
using Microsoft.EntityFrameworkCore.Migrations;
|
||||||
|
|
||||||
|
#nullable disable
|
||||||
|
|
||||||
|
namespace PostFundManagement.Infrastructure.Database.Migrations
|
||||||
|
{
|
||||||
|
/// <inheritdoc />
|
||||||
|
public partial class IncreasedLargeTextLengthTo1MB : Migration
|
||||||
|
{
|
||||||
|
/// <inheritdoc />
|
||||||
|
protected override void Up(MigrationBuilder migrationBuilder)
|
||||||
|
{
|
||||||
|
migrationBuilder.AlterColumn<string>(
|
||||||
|
name: "Findings",
|
||||||
|
table: "SiteVisits",
|
||||||
|
type: "character varying(1048576)",
|
||||||
|
maxLength: 1048576,
|
||||||
|
nullable: true,
|
||||||
|
oldClrType: typeof(string),
|
||||||
|
oldType: "character varying(4096)",
|
||||||
|
oldMaxLength: 4096,
|
||||||
|
oldNullable: true);
|
||||||
|
|
||||||
|
migrationBuilder.AlterColumn<string>(
|
||||||
|
name: "Message",
|
||||||
|
table: "Notifications",
|
||||||
|
type: "character varying(1048576)",
|
||||||
|
maxLength: 1048576,
|
||||||
|
nullable: false,
|
||||||
|
oldClrType: typeof(string),
|
||||||
|
oldType: "character varying(4096)",
|
||||||
|
oldMaxLength: 4096);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <inheritdoc />
|
||||||
|
protected override void Down(MigrationBuilder migrationBuilder)
|
||||||
|
{
|
||||||
|
migrationBuilder.AlterColumn<string>(
|
||||||
|
name: "Findings",
|
||||||
|
table: "SiteVisits",
|
||||||
|
type: "character varying(4096)",
|
||||||
|
maxLength: 4096,
|
||||||
|
nullable: true,
|
||||||
|
oldClrType: typeof(string),
|
||||||
|
oldType: "character varying(1048576)",
|
||||||
|
oldMaxLength: 1048576,
|
||||||
|
oldNullable: true);
|
||||||
|
|
||||||
|
migrationBuilder.AlterColumn<string>(
|
||||||
|
name: "Message",
|
||||||
|
table: "Notifications",
|
||||||
|
type: "character varying(4096)",
|
||||||
|
maxLength: 4096,
|
||||||
|
nullable: false,
|
||||||
|
oldClrType: typeof(string),
|
||||||
|
oldType: "character varying(1048576)",
|
||||||
|
oldMaxLength: 1048576);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
+2519
File diff suppressed because it is too large
Load Diff
+37
@@ -0,0 +1,37 @@
|
|||||||
|
using System;
|
||||||
|
using Microsoft.EntityFrameworkCore.Migrations;
|
||||||
|
|
||||||
|
#nullable disable
|
||||||
|
|
||||||
|
namespace PostFundManagement.Infrastructure.Database.Migrations
|
||||||
|
{
|
||||||
|
/// <inheritdoc />
|
||||||
|
public partial class UpdatedByIsOptionalOnEvidence : Migration
|
||||||
|
{
|
||||||
|
/// <inheritdoc />
|
||||||
|
protected override void Up(MigrationBuilder migrationBuilder)
|
||||||
|
{
|
||||||
|
migrationBuilder.AlterColumn<Guid>(
|
||||||
|
name: "UpdatedBy",
|
||||||
|
table: "Evidences",
|
||||||
|
type: "uuid",
|
||||||
|
nullable: true,
|
||||||
|
oldClrType: typeof(Guid),
|
||||||
|
oldType: "uuid");
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <inheritdoc />
|
||||||
|
protected override void Down(MigrationBuilder migrationBuilder)
|
||||||
|
{
|
||||||
|
migrationBuilder.AlterColumn<Guid>(
|
||||||
|
name: "UpdatedBy",
|
||||||
|
table: "Evidences",
|
||||||
|
type: "uuid",
|
||||||
|
nullable: false,
|
||||||
|
defaultValue: new Guid("00000000-0000-0000-0000-000000000000"),
|
||||||
|
oldClrType: typeof(Guid),
|
||||||
|
oldType: "uuid",
|
||||||
|
oldNullable: true);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
+5
-5
@@ -604,7 +604,7 @@ namespace PostFundManagement.Infrastructure.Database.Migrations
|
|||||||
b.Property<DateTime?>("UpdatedAt")
|
b.Property<DateTime?>("UpdatedAt")
|
||||||
.HasColumnType("timestamp with time zone");
|
.HasColumnType("timestamp with time zone");
|
||||||
|
|
||||||
b.Property<Guid>("UpdatedBy")
|
b.Property<Guid?>("UpdatedBy")
|
||||||
.HasColumnType("uuid");
|
.HasColumnType("uuid");
|
||||||
|
|
||||||
b.Property<int>("Version")
|
b.Property<int>("Version")
|
||||||
@@ -958,8 +958,8 @@ namespace PostFundManagement.Infrastructure.Database.Migrations
|
|||||||
|
|
||||||
b.Property<string>("Message")
|
b.Property<string>("Message")
|
||||||
.IsRequired()
|
.IsRequired()
|
||||||
.HasMaxLength(4096)
|
.HasMaxLength(1048576)
|
||||||
.HasColumnType("character varying(4096)");
|
.HasColumnType("character varying(1048576)");
|
||||||
|
|
||||||
b.Property<long>("OrganisationId")
|
b.Property<long>("OrganisationId")
|
||||||
.HasColumnType("bigint");
|
.HasColumnType("bigint");
|
||||||
@@ -1405,8 +1405,8 @@ namespace PostFundManagement.Infrastructure.Database.Migrations
|
|||||||
.HasColumnType("uuid");
|
.HasColumnType("uuid");
|
||||||
|
|
||||||
b.Property<string>("Findings")
|
b.Property<string>("Findings")
|
||||||
.HasMaxLength(4096)
|
.HasMaxLength(1048576)
|
||||||
.HasColumnType("character varying(4096)");
|
.HasColumnType("character varying(1048576)");
|
||||||
|
|
||||||
b.Property<string>("InspectorName")
|
b.Property<string>("InspectorName")
|
||||||
.IsRequired()
|
.IsRequired()
|
||||||
|
|||||||
@@ -0,0 +1,16 @@
|
|||||||
|
using PostFundManagement.Domain.Abstractions;
|
||||||
|
|
||||||
|
namespace PostFundManagement.Infrastructure.Database.Services;
|
||||||
|
|
||||||
|
public sealed class HeadlessTenantContextService : ITenantContextService
|
||||||
|
{
|
||||||
|
public string? TenantName => "Ifri Technologies";
|
||||||
|
|
||||||
|
public long TenantId => 1;
|
||||||
|
|
||||||
|
public bool ByPassTenantQueryFilter { get; set; } = true;
|
||||||
|
|
||||||
|
public bool IsAdmin => true;
|
||||||
|
|
||||||
|
public bool HasTenantFilter => true;
|
||||||
|
}
|
||||||
@@ -0,0 +1,40 @@
|
|||||||
|
# Stage 1: Runtime base
|
||||||
|
FROM mcr.microsoft.com/dotnet/aspnet:10.0 AS base
|
||||||
|
USER app
|
||||||
|
WORKDIR /app
|
||||||
|
EXPOSE 8080
|
||||||
|
|
||||||
|
# Stage 2: Build & Restore
|
||||||
|
FROM mcr.microsoft.com/dotnet/sdk:10.0 AS build
|
||||||
|
WORKDIR /src
|
||||||
|
|
||||||
|
# Copy props and csproj files for layer caching
|
||||||
|
COPY ["Directory.Build.props", "./"]
|
||||||
|
COPY ["Directory.Packages.props", "./"]
|
||||||
|
COPY ["PostFundManagement.snk", "./"]
|
||||||
|
COPY ["LICENSE", "./"]
|
||||||
|
COPY ["PostFundManagement.Jobs/PostFundManagement.Jobs.csproj", "PostFundManagement.Jobs/"]
|
||||||
|
COPY ["PostFundManagement.Application/PostFundManagement.Application.csproj", "PostFundManagement.Application/"]
|
||||||
|
COPY ["PostFundManagement.Domain/PostFundManagement.Domain.csproj", "PostFundManagement.Domain/"]
|
||||||
|
COPY ["PostFundManagement.Infrastructure/PostFundManagement.Infrastructure.csproj", "PostFundManagement.Infrastructure/"]
|
||||||
|
|
||||||
|
# Restore packages
|
||||||
|
RUN dotnet restore "PostFundManagement.Jobs/PostFundManagement.Jobs.csproj"
|
||||||
|
|
||||||
|
# Copy full source code
|
||||||
|
COPY . .
|
||||||
|
|
||||||
|
# Build from the project folder using relative filename
|
||||||
|
WORKDIR "/src/PostFundManagement.Jobs"
|
||||||
|
RUN dotnet build "PostFundManagement.Jobs.csproj" -c Release -o /app/build
|
||||||
|
|
||||||
|
# Stage 3: Publish
|
||||||
|
FROM build AS publish
|
||||||
|
ARG BUILD_CONFIGURATION=Release
|
||||||
|
RUN dotnet publish "PostFundManagement.Jobs.csproj" -c Release -o /app/publish /p:UseAppHost=false
|
||||||
|
|
||||||
|
# Stage 4: Final Runtime
|
||||||
|
FROM base AS final
|
||||||
|
WORKDIR /app
|
||||||
|
COPY --from=publish /app/publish .
|
||||||
|
ENTRYPOINT ["dotnet", "PostFundManagement.Jobs.dll"]
|
||||||
@@ -0,0 +1,32 @@
|
|||||||
|
using PostFundManagement.Application.Events;
|
||||||
|
using PostFundManagement.Domain.Abstractions;
|
||||||
|
using PostFundManagement.Domain.Configuration.Email;
|
||||||
|
|
||||||
|
namespace PostFundManagement.Jobs;
|
||||||
|
|
||||||
|
public class JobsWorker(IJobOrchestrator jobOrchestrator, IFeatureManager featureManager,
|
||||||
|
ILogger<JobsWorker> logger, IOptions<SmtpSettings> emailOptions) : BackgroundService
|
||||||
|
{
|
||||||
|
readonly SmtpSettings emailSettings = emailOptions.Value;
|
||||||
|
|
||||||
|
protected override async Task ExecuteAsync(CancellationToken stoppingToken)
|
||||||
|
{
|
||||||
|
if (!await featureManager.IsEnabledAsync(nameof(JobsWorker)))
|
||||||
|
{
|
||||||
|
logger.LogInformation("Recurring job automatic registration disabled.");
|
||||||
|
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (await featureManager.IsEnabledAsync(FeatureEmailProcessor))
|
||||||
|
await jobOrchestrator.ScheduleAsync(ProcessEmailEvent.Create(), "0 */5 * * * ?", stoppingToken);
|
||||||
|
|
||||||
|
if (await featureManager.IsEnabledAsync(FeatureLateProjectReminder))
|
||||||
|
await jobOrchestrator.ScheduleAsync(LateProjectEvent.Create(), "0 30 6 * * ?", stoppingToken);
|
||||||
|
|
||||||
|
if (await featureManager.IsEnabledAsync(FeatureNonCompliantProjectCheck))
|
||||||
|
await jobOrchestrator.ScheduleAsync(NonCompliantProjectEvent.Create(), "0 0 7 ? * MON", stoppingToken);
|
||||||
|
|
||||||
|
logger.LogInformation("Recurring jobs have been registered or updated.");
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,31 @@
|
|||||||
|
<Project Sdk="Microsoft.NET.Sdk.Worker">
|
||||||
|
|
||||||
|
<PropertyGroup>
|
||||||
|
<TargetFramework>net10.0</TargetFramework>
|
||||||
|
<Nullable>enable</Nullable>
|
||||||
|
<ImplicitUsings>enable</ImplicitUsings>
|
||||||
|
<UserSecretsId>dotnet-PostFundManagement.Jobs-97b98845-fddb-4212-b877-8dac9f7f86e3</UserSecretsId>
|
||||||
|
</PropertyGroup>
|
||||||
|
|
||||||
|
<!-- File Nesting -->
|
||||||
|
<ItemGroup>
|
||||||
|
<ProjectCapability Include="ConfigurableFileNesting" />
|
||||||
|
<ProjectCapability Include="ConfigurableFileNestingFeatureEnabled" />
|
||||||
|
</ItemGroup>
|
||||||
|
|
||||||
|
<!-- CQRS Source Generator -->
|
||||||
|
<ItemGroup>
|
||||||
|
<PackageReference Include="Mediator.SourceGenerator">
|
||||||
|
<PrivateAssets>all</PrivateAssets>
|
||||||
|
<IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>
|
||||||
|
</PackageReference>
|
||||||
|
</ItemGroup>
|
||||||
|
|
||||||
|
<ItemGroup>
|
||||||
|
<PackageReference Include="Microsoft.Extensions.Hosting" />
|
||||||
|
</ItemGroup>
|
||||||
|
|
||||||
|
<ItemGroup>
|
||||||
|
<ProjectReference Include="..\PostFundManagement.Application\PostFundManagement.Application.csproj" />
|
||||||
|
</ItemGroup>
|
||||||
|
</Project>
|
||||||
@@ -0,0 +1,40 @@
|
|||||||
|
using PostFundManagement.Application.Extensions;
|
||||||
|
using PostFundManagement.Domain.Abstractions;
|
||||||
|
using PostFundManagement.Domain.Mediator;
|
||||||
|
using PostFundManagement.Infrastructure.Database.Services;
|
||||||
|
using PostFundManagement.Jobs;
|
||||||
|
|
||||||
|
var builder = Host.CreateApplicationBuilder(args);
|
||||||
|
|
||||||
|
builder.Services.AddFeatureManagement();
|
||||||
|
|
||||||
|
builder.Services.AddSharedServices();
|
||||||
|
builder.Services.AddHostedService<JobsWorker>();
|
||||||
|
builder.Services.AddRegisterServicesWithCaching(builder.Configuration);
|
||||||
|
builder.Services.AddEndpointAndServiceCaching(builder.Configuration);
|
||||||
|
builder.Services.AddTransient<ITenantContextService, HeadlessTenantContextService>();
|
||||||
|
builder.Services.AddSkorubaApiService(builder.Configuration);
|
||||||
|
builder.Services.AddHttpContextAccessor();
|
||||||
|
|
||||||
|
builder.Services.AddMediator();
|
||||||
|
builder.Services.AddScoped(typeof(IPipelineBehavior<,>), typeof(TelemetryPipelineBehavior<,>));
|
||||||
|
builder.Services.AddScoped(typeof(IPipelineBehavior<,>), typeof(LoggingPipelineBehavior<,>));
|
||||||
|
|
||||||
|
builder.Services.AddQuartzScheduler(DefaultSchedulerName, builder.Configuration);
|
||||||
|
builder.Services.AddEmailServices(builder.Configuration);
|
||||||
|
builder.Services.AddHashServices(builder.Configuration);
|
||||||
|
builder.Services.AddGarageS3(builder.Configuration);
|
||||||
|
|
||||||
|
builder.Services.AddHttpClient();
|
||||||
|
builder.Services.AddDataProtectionDatabase(builder.Configuration);
|
||||||
|
builder.Services.AddApplicationDbContext(builder.Configuration);
|
||||||
|
|
||||||
|
var host = builder.Build();
|
||||||
|
|
||||||
|
var schedulerFactory = host.Services.GetRequiredService<ISchedulerFactory>();
|
||||||
|
var scheduler = await schedulerFactory.GetScheduler();
|
||||||
|
|
||||||
|
if (scheduler!.Status != SchedulerStatus.Running)
|
||||||
|
await scheduler.Start();
|
||||||
|
|
||||||
|
host.Run();
|
||||||
@@ -0,0 +1,12 @@
|
|||||||
|
{
|
||||||
|
"$schema": "https://json.schemastore.org/launchsettings.json",
|
||||||
|
"profiles": {
|
||||||
|
"PostFundManagement.Jobs": {
|
||||||
|
"commandName": "Project",
|
||||||
|
"dotnetRunMessages": true,
|
||||||
|
"environmentVariables": {
|
||||||
|
"DOTNET_ENVIRONMENT": "Development"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,41 @@
|
|||||||
|
{
|
||||||
|
"FeatureManagement": {
|
||||||
|
"JobsWorker": true,
|
||||||
|
"EmailProcessorJobEnabled": true,
|
||||||
|
"LateProjectReminderEnabled": true,
|
||||||
|
"ServiceCaching": false
|
||||||
|
},
|
||||||
|
"GeneralS3Settings": {
|
||||||
|
"ServiceUrl": "https://s3.garage.khongisa.co.za",
|
||||||
|
"Region": "garage",
|
||||||
|
"BucketName": "pfm",
|
||||||
|
"CdnBaseUrl": "https://pfm.cdn.khongisa.co.za"
|
||||||
|
},
|
||||||
|
"MachineIdentitySettings": {
|
||||||
|
"Authority": "https://sts.security.khongisa.co.za",
|
||||||
|
"Scope": "skoruba_identity_admin_api"
|
||||||
|
},
|
||||||
|
"Monitoring": {
|
||||||
|
"Address": "http://aspire-dashboard-service.aspire.svc.cluster.local:18889",
|
||||||
|
"ServiceName": "Pfm.Jobs.DEV"
|
||||||
|
},
|
||||||
|
"Logging": {
|
||||||
|
"LogLevel": {
|
||||||
|
"Default": "Warning",
|
||||||
|
"Quartz": "Error",
|
||||||
|
"Microsoft.AspNetCore": "Warning",
|
||||||
|
"Microsoft.EntityFrameworkCore.Database": "Error",
|
||||||
|
"Microsoft.EntityFrameworkCore.Update": "Error"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"Email": {
|
||||||
|
"SmtpServer": "smtp.example.com",
|
||||||
|
"SmtpPort": 587,
|
||||||
|
"SenderEmail": "noreply@example.com",
|
||||||
|
"SenderName": "Example"
|
||||||
|
},
|
||||||
|
"HasherSettings": {
|
||||||
|
"MinHashLength": 11
|
||||||
|
},
|
||||||
|
"AllowedHosts": "*"
|
||||||
|
}
|
||||||
@@ -1,9 +1,9 @@
|
|||||||
# GEMINI_API_KEY: Required for Gemini AI API calls.
|
# GEMINI_API_KEY: Required for Gemini AI API calls.
|
||||||
# AI Studio automatically injects this at runtime from user secrets.
|
|
||||||
# Users configure this via the Secrets panel in the AI Studio UI.
|
|
||||||
GEMINI_API_KEY="MY_GEMINI_API_KEY"
|
GEMINI_API_KEY="MY_GEMINI_API_KEY"
|
||||||
|
|
||||||
# APP_URL: The URL where this applet is hosted.
|
# APP_URL: The URL where this applet is hosted.
|
||||||
# AI Studio automatically injects this at runtime with the Cloud Run service URL.
|
|
||||||
# Used for self-referential links, OAuth callbacks, and API endpoints.
|
|
||||||
APP_URL="MY_APP_URL"
|
APP_URL="MY_APP_URL"
|
||||||
|
|
||||||
|
# HasherSettings for HashIds compatibility with backend pfm-api
|
||||||
|
VITE_HASHER_MIN_LENGTH="11"
|
||||||
|
VITE_HASHER_SALT="TGlnaHQgQ2hhcm1zLCBNaWRyYW5kQm9va3MgbWFrZSBsb3RzIG9mIG1vbmV5IGFuZCBhcmUgYSBzdWNjZXNzZnVsIHZpcmFsIHN0b3J5IGluIFNvdXRoIEFmcmljYQ=="
|
||||||
|
|||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user