Compare commits
199
Commits
finishline
..
main
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
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 | ||
|
|
d9b65010cf | ||
|
|
bc441c1255 | ||
|
|
6e786ca796 | ||
|
|
90631d22ae | ||
|
|
010f2d5eaa | ||
|
|
b8f5ebf69a | ||
|
|
957e75a853 | ||
|
|
8a06a86eb3 | ||
|
|
f8f826979d | ||
|
|
c4a0469bae | ||
|
|
a3f461478c | ||
|
|
cc62696017 | ||
|
|
d4598e0c6c | ||
|
|
43656e848a | ||
|
|
3a3245b643 | ||
|
|
fdaf12a183 | ||
|
|
6cdd03a02f | ||
|
|
1d1060d031 | ||
|
|
b0c89362d0 | ||
|
|
dee9ccf0b1 | ||
|
|
d9b3e80f7c | ||
|
|
8688c752d6 | ||
|
|
937a5595d8 | ||
|
|
58ce606410 | ||
|
|
a98bfa4308 | ||
|
|
6e56a4c223 | ||
|
|
e38edd24a0 | ||
|
|
1ac0018926 | ||
|
|
5ad59f6569 | ||
|
|
71a0abf0b2 | ||
|
|
627885e36b | ||
|
|
447ffb366e | ||
|
|
a9bb319eb1 | ||
|
|
bbba869cdd | ||
|
|
5ae2622454 | ||
|
|
bd0f03344d | ||
|
|
1af860fdc1 | ||
|
|
c04ae9a933 | ||
|
|
0e853811a9 | ||
|
|
3a3d5daefc | ||
|
|
fe9b9052e9 | ||
|
|
d25545ddad | ||
|
|
7effc32498 | ||
|
|
8909bcf5c4 | ||
|
|
411e603f51 | ||
|
|
138ef84973 | ||
|
|
312721bf51 | ||
|
|
dd59294911 | ||
|
|
28a3a17a15 | ||
|
|
4c2b0cd7d5 | ||
|
|
275fd109e6 | ||
|
|
248cc2fa2d | ||
|
|
761d14beb9 | ||
|
|
931e1a8698 | ||
|
|
aff58caeb0 | ||
|
|
1c5080bf04 | ||
|
|
1fb157aa0a | ||
|
|
0adfae1616 | ||
|
|
4856b6e6f4 | ||
|
|
0ba47269aa | ||
|
|
5e25fd7a11 | ||
|
|
f8fff04065 | ||
|
|
aca5882ad3 | ||
|
|
6b027193d1 | ||
|
|
81702a4bf8 | ||
|
|
5eeb1cbfc7 | ||
|
|
fb00986f59 | ||
|
|
0338737385 | ||
|
|
82c0936b4c | ||
|
|
5d2a886f6f | ||
|
|
dc4047ca6d | ||
|
|
46fe14d9ef | ||
|
|
f17368d939 | ||
|
|
7aea3fa8de | ||
|
|
d43eb17aa3 | ||
|
|
01547d8c3f | ||
|
|
18dd9131cb | ||
|
|
f82a5cef76 | ||
|
|
614730caab | ||
|
|
9823cd7ea6 | ||
|
|
a7a68fc290 | ||
|
|
4dbc74e488 | ||
|
|
d658497545 | ||
|
|
b947959724 | ||
|
|
58126e7573 | ||
|
|
2fede443cc | ||
|
|
80f220fcae | ||
|
|
06e2c3ee6a | ||
|
|
3c0fd8844c | ||
|
|
b7c135f0fc | ||
|
|
db053e3e5f | ||
|
|
d7177e1a72 | ||
|
|
c67041faea | ||
|
|
0e8a5e1169 | ||
|
|
41fdb067fe | ||
|
|
b4b687dfcc | ||
|
|
f200ccbf60 | ||
|
|
27f57150e1 | ||
|
|
79c63107e9 | ||
|
|
d945731252 | ||
|
|
c979a4632e | ||
|
|
9eed3cff6b | ||
|
|
6db1a2f367 | ||
|
|
06d9952cd7 | ||
|
|
92d481f75d | ||
|
|
1c36602995 | ||
|
|
5ae0c500ac | ||
|
|
db04f800e9 | ||
|
|
e7a19611f0 | ||
|
|
5ad24da7cf | ||
|
|
156dae40b7 | ||
|
|
1b67d9cf8b | ||
|
|
1bd05e64e5 | ||
|
|
303dba3c6f | ||
|
|
1dd4f54a1d | ||
|
|
fae1e080c4 | ||
|
|
4de23d27b3 | ||
|
|
d4bce12dbb | ||
|
|
299b876e1c | ||
|
|
dde1dbe056 | ||
|
|
b81e6feb45 | ||
|
|
4138a1941f | ||
|
|
17af31d810 | ||
|
|
b85cf6a1cb | ||
|
|
81ddc11746 | ||
|
|
5b94672ff5 | ||
|
|
c535392ef1 | ||
|
|
0607de1802 | ||
|
|
4c1e52e452 | ||
|
|
2414d74d1c | ||
|
|
93d9661a7f | ||
|
|
dba401fe94 | ||
|
|
42d73fe6cb | ||
|
|
1475887a7f | ||
|
|
730664d9c4 | ||
|
|
f38e569a34 | ||
|
|
7e79171f97 | ||
|
|
d7b868f2c1 | ||
|
|
d43a09b371 | ||
|
|
0dc0de1502 | ||
|
|
de47dd4a4f | ||
|
|
2810cc669b | ||
|
|
734af8c86f | ||
|
|
a754c42d14 | ||
|
|
bd7ca9372f | ||
|
|
0d01d91ab4 | ||
|
|
2c817a8818 | ||
|
|
4c3a96917b | ||
|
|
e5c3a75795 | ||
|
|
1bc7e65960 | ||
|
|
094126d84b | ||
|
|
2506241a25 | ||
|
|
cfe3530639 | ||
|
|
985557bee4 | ||
|
|
f0b8b3051a | ||
|
|
6c88b9e533 | ||
|
|
7d67408d2c | ||
|
|
d1d58a6183 | ||
|
|
60ad154841 | ||
|
|
2c13170859 | ||
|
|
d87c8aa3c9 | ||
|
|
6af7815ad1 | ||
|
|
a169f6c3f6 | ||
|
|
65ea753c19 | ||
|
|
e6d2663265 | ||
|
|
91b5814b60 | ||
|
|
0c4c848568 | ||
|
|
fb05150ebf | ||
|
|
2f9c20020b | ||
|
|
a771957db3 | ||
|
|
46267a2a34 | ||
|
|
a7f360cc2a | ||
|
|
67c08b40ca |
@@ -11,7 +11,7 @@ public class GetProjectEvidenceEndpoint : IEndpoint
|
||||
{
|
||||
public void Map(IEndpointRouteBuilder builder)
|
||||
{
|
||||
builder.MapGet("api/evidence/{projectId}", async (string projectId, IEvidenceService service,
|
||||
builder.MapGet("api/evidence/projects/{projectId}", async (string projectId, IEvidenceService service,
|
||||
CancellationToken cancellationToken = default) =>
|
||||
await service.GetProjectEvidencesAsync(projectId, cancellationToken) switch
|
||||
{
|
||||
|
||||
@@ -12,7 +12,14 @@ public sealed class CreateOutcomeEndpoint : IEndpoint
|
||||
{
|
||||
public void Map(IEndpointRouteBuilder builder)
|
||||
{
|
||||
builder.MapPost("api/projects/beneficiaries/outcomes", HandleCreateAsync)
|
||||
builder.MapPost("api/projects/beneficiaries/outcomes", async (CreateOutcomeRequest request, HttpContext context,
|
||||
IProjectService service, CancellationToken cancellationToken = default) =>
|
||||
await service.CreateOutcomeAsync(request, context.GetUserId(), cancellationToken) switch
|
||||
{
|
||||
{ IsSuccess: true } => Results.Ok(),
|
||||
var r when r.HasError<CreateError>() => Results.BadRequest(r.Errors.ToProblems()),
|
||||
var r => Results.InternalServerError(r.Errors.ToProblems())
|
||||
})
|
||||
.WithDescription("Add a new beneficiary outcome")
|
||||
.RequireAuthorization(Constants.MainScope)
|
||||
.WithName(typeof(CreateOutcomeEndpoint).ToEndpointName())
|
||||
@@ -22,25 +29,5 @@ public sealed class CreateOutcomeEndpoint : IEndpoint
|
||||
.Produces<Problems>(StatusCodes.Status500InternalServerError)
|
||||
.Produces(StatusCodes.Status401Unauthorized)
|
||||
.WithTags(EndpointTags.Projects);
|
||||
|
||||
builder.MapPost("api/projects/beneficiaris/outcomes", HandleCreateAsync)
|
||||
.WithDescription("Add a new beneficiary outcome (legacy route)")
|
||||
.RequireAuthorization(Constants.MainScope)
|
||||
.WithName(typeof(CreateOutcomeEndpoint).ToEndpointName("TypoAlias"))
|
||||
.MapToApiVersion(new ApiVersion(1))
|
||||
.Produces(StatusCodes.Status200OK)
|
||||
.Produces<Problems>(StatusCodes.Status400BadRequest)
|
||||
.Produces<Problems>(StatusCodes.Status500InternalServerError)
|
||||
.Produces(StatusCodes.Status401Unauthorized)
|
||||
.WithTags(EndpointTags.Projects);
|
||||
}
|
||||
|
||||
private static async Task<IResult> HandleCreateAsync(CreateOutcomeRequest request, HttpContext context,
|
||||
IProjectService service, CancellationToken cancellationToken = default) =>
|
||||
await service.CreateOutcomeAsync(request, context.GetUserId(), cancellationToken) switch
|
||||
{
|
||||
{ IsSuccess: true } => Results.Ok(),
|
||||
var r when r.HasError<CreateError>() => Results.BadRequest(r.Errors.ToProblems()),
|
||||
var r => Results.InternalServerError(r.Errors.ToProblems())
|
||||
};
|
||||
}
|
||||
|
||||
@@ -25,7 +25,6 @@ public interface ICommunicationsService
|
||||
Task<Result> CreateNotificationAsync(CreateNotificationRequest request, Guid userId, CancellationToken cancellationToken = default);
|
||||
|
||||
[Log]
|
||||
[Audit]
|
||||
Task<Result> UpdateNotificationAsync(UpdateNotificationRequest request, CancellationToken cancellationToken = default);
|
||||
|
||||
[Log]
|
||||
|
||||
@@ -20,6 +20,7 @@ public sealed record InviteRequest([Required] long OrganisationId,
|
||||
[Required] long InvitedOrganisationId,
|
||||
[Required] long AwardId,
|
||||
[Required] long ContractId,
|
||||
[Required] long ProjectId,
|
||||
[Required] Guid Recipient,
|
||||
[Required][MaxLength(LabelLength)] string Message,
|
||||
[Required] NotificationPlatform Platform);
|
||||
|
||||
@@ -182,7 +182,7 @@ public sealed class GovernanceService(IDbContextFactory<ApplicationDbContext> co
|
||||
if (!await context.Users.AnyAsync(u => u.Id == request.OrganisationUser, cancellationToken))
|
||||
return Result.Fail(ValidationError.Create("Organisation user not found"));
|
||||
|
||||
if (!await context.TenantUsers.AnyAsync(u => u.UserId == request.OrganisationUser && u.TenantId == tenantContext.TenantId, cancellationToken))
|
||||
if (!tenantContext.IsAdmin && !await context.TenantUsers.AnyAsync(u => u.UserId == request.OrganisationUser && u.TenantId == tenantContext.TenantId, cancellationToken))
|
||||
return Result.Fail(ValidationError.Create("Organisation user does not belong to the current tenant"));
|
||||
|
||||
var changedTotal = await context.Organisations
|
||||
@@ -192,6 +192,7 @@ public sealed class GovernanceService(IDbContextFactory<ApplicationDbContext> co
|
||||
.SetProperty(u => u.RegistrationNo, request.RegistrationNo.Clean())
|
||||
.SetProperty(u => u.Email, request.Email.ToLower(CultureInfo.InvariantCulture).Clean())
|
||||
.SetProperty(u => u.Type, request.Type)
|
||||
.SetProperty(u => u.UserId, request.OrganisationUser)
|
||||
.SetProperty(u => u.Status, request.Status), cancellationToken);
|
||||
|
||||
var tenantOrganisation = await context.TenantOrganisations.AsNoTracking().FirstOrDefaultAsync(f => f.OrganisationId == request.OrganisationId, cancellationToken);
|
||||
|
||||
@@ -159,12 +159,7 @@ public sealed class GrantsService(IDbContextFactory<ApplicationDbContext> contex
|
||||
await using var context = await contextFactory.CreateDbContextAsync(cancellationToken);
|
||||
|
||||
if (!await context.Projects.AnyAsync(p => p.Id == request.ProjectId, cancellationToken))
|
||||
return Result.Fail(NotFoundError.New);
|
||||
|
||||
var hasApprovedAward = await context.Awards
|
||||
.AnyAsync(a => a.ProjectId == request.ProjectId && a.Status == ApprovalStatus.Approved, cancellationToken);
|
||||
if (!hasApprovedAward)
|
||||
return Result.Fail(ValidationError.Create("Cannot create budget: The project's linked award is not approved or does not exist."));
|
||||
return Result.Fail(NotFoundError.New);
|
||||
|
||||
var budget = new Domain.Entities.Budget
|
||||
{
|
||||
|
||||
@@ -20,27 +20,6 @@ public sealed class ProjectService(IDbContextFactory<ApplicationDbContext> conte
|
||||
if (!await context.Users.AnyAsync(u => u.Id == userId, cancellationToken))
|
||||
return Result.Fail(NotFoundError.New);
|
||||
|
||||
if (request.Status == ApprovalStatus.Approved)
|
||||
{
|
||||
var hasUnmetCompliance = await context.ComplianceItems
|
||||
.AnyAsync(c => c.ProjectId == request.ProjectId && c.Status == ComplianceStatus.NotMet, cancellationToken);
|
||||
if (hasUnmetCompliance)
|
||||
return Result.Fail(ValidationError.Create("Project cannot be approved because some compliance items are not met."));
|
||||
|
||||
var projectBeneficiaryIds = await context.Beneficiaries
|
||||
.Where(b => b.ProjectId == request.ProjectId)
|
||||
.Select(b => b.Id)
|
||||
.ToListAsync(cancellationToken);
|
||||
|
||||
if (projectBeneficiaryIds.Count > 0)
|
||||
{
|
||||
var hasUnmetOutcomes = await context.Outcomes
|
||||
.AnyAsync(o => projectBeneficiaryIds.Contains(o.BeneficiaryId) && o.Status == ComplianceStatus.NotMet, cancellationToken);
|
||||
if (hasUnmetOutcomes)
|
||||
return Result.Fail(ValidationError.Create("Project cannot be approved because some beneficiary outcomes are not met."));
|
||||
}
|
||||
}
|
||||
|
||||
var changedTotal = await context.Projects
|
||||
.Where(f => f.Id == request.ProjectId)
|
||||
.ExecuteUpdateAsync(f => f
|
||||
|
||||
@@ -51,8 +51,10 @@ public sealed partial class HashService(IHashids hasher) : IService
|
||||
|
||||
int[] decoded = hasher.Decode(hash);
|
||||
|
||||
return Result.FailIf(decoded.Length <= 0, ValidationError.Create("Invalid or modified Int hash token."))
|
||||
.ToResult(decoded[0]);
|
||||
if (decoded.Length <= 0)
|
||||
return Result.Fail<int>(ValidationError.Create("Invalid or modified Int hash token."));
|
||||
|
||||
return Result.Ok(decoded[0]);
|
||||
}
|
||||
|
||||
public Result<long> DecodeLongIdHash(string hash)
|
||||
@@ -62,8 +64,10 @@ public sealed partial class HashService(IHashids hasher) : IService
|
||||
|
||||
long[] decoded = hasher.DecodeLong(hash);
|
||||
|
||||
return Result.FailIf(decoded.Length <= 0, ValidationError.Create("Invalid or modified Long hash token."))
|
||||
.ToResult(decoded[0]);
|
||||
if (decoded.Length <= 0)
|
||||
return Result.Fail<long>(ValidationError.Create("Invalid or modified Long hash token."));
|
||||
|
||||
return Result.Ok(decoded[0]);
|
||||
}
|
||||
|
||||
public async Task<Result<string>> DecodeHexHash(string hex) =>
|
||||
|
||||
@@ -30,7 +30,7 @@ OIDC_CLIENT_ID="${VITE_OIDC_CLIENT_ID:-${OIDC_CLIENT_ID:-${SecuritySettings__Cli
|
||||
OIDC_SCOPE="${VITE_OIDC_SCOPE:-${OIDC_SCOPE:-${SecuritySettings__Scope:-${SecuritySettings_Scope:-${SCOPE:-}}}}}"
|
||||
|
||||
# 6. Hasher Settings
|
||||
HASHER_MIN_LENGTH="${VITE_HASHER_MIN_LENGTH:-${HASHER_MIN_LENGTH:-${HasherSettings__MinLength:-${HasherSettings_MinLength:-11}}}}}"
|
||||
HASHER_MIN_LENGTH="${VITE_HASHER_MIN_LENGTH:-${HASHER_MIN_LENGTH:-${HasherSettings__MinLength:-${HasherSettings_MinLength:-11}}}}"
|
||||
HASHER_SALT="${VITE_HASHER_SALT:-${HASHER_SALT:-${HasherSettings__Salt:-${HasherSettings_Salt:-TGlnaHQgQ2hhcm1zLCBNaWRyYW5kQm9va3MgbWFrZSBsb3RzIG9mIG1vbmV5IGFuZCBhcmUgYSBzdWNjZXNzZnVsIHZpcmFsIHN0b3J5IGluIFNvdXRoIEFmcmljYQ==}}}}"
|
||||
|
||||
if [ -d "$TARGET_DIR" ]; then
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import React, { useState } from 'react';
|
||||
import { NavLink, Outlet, useNavigate, Link } from 'react-router-dom';
|
||||
import { NavLink, Outlet, useNavigate, Link, useLocation } from 'react-router-dom';
|
||||
import { NotificationBell } from './NotificationBell';
|
||||
import {
|
||||
Inbox,
|
||||
@@ -144,6 +144,27 @@ export const AppLayout: React.FC = () => {
|
||||
const [sidebarOpen, setSidebarOpen] = useState(true);
|
||||
const [userMenuOpen, setUserMenuOpen] = useState(false);
|
||||
const navigate = useNavigate();
|
||||
const location = useLocation();
|
||||
const [collapsedSections, setCollapsedSections] = useState<Record<string, boolean>>({});
|
||||
|
||||
const isSectionCollapsed = (path: string, subItems?: SubNavItem[]): boolean => {
|
||||
if (collapsedSections[path] !== undefined) {
|
||||
return collapsedSections[path];
|
||||
}
|
||||
if (subItems?.some((s) => location.pathname.startsWith(s.path))) {
|
||||
return false;
|
||||
}
|
||||
return false;
|
||||
};
|
||||
|
||||
const toggleSection = (path: string, e: React.MouseEvent) => {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
setCollapsedSections((prev) => ({
|
||||
...prev,
|
||||
[path]: !isSectionCollapsed(path),
|
||||
}));
|
||||
};
|
||||
|
||||
const handleLogout = async () => {
|
||||
await logout();
|
||||
@@ -186,7 +207,6 @@ export const AppLayout: React.FC = () => {
|
||||
useQuery({
|
||||
queryKey: ['users-directory'],
|
||||
queryFn: () => getUsers(1, 200),
|
||||
staleTime: 5 * 60 * 1000,
|
||||
});
|
||||
|
||||
// Query tenant endpoint to resolve tenant name if not provided in claims or profile
|
||||
@@ -194,7 +214,6 @@ export const AppLayout: React.FC = () => {
|
||||
queryKey: ['active-tenant-header', tenantId],
|
||||
queryFn: () => getTenant(tenantId!),
|
||||
enabled: !!tenantId && !claims?.tenant && !user?.tenant && !authTenantName,
|
||||
staleTime: 5 * 60 * 1000,
|
||||
});
|
||||
|
||||
// Ensure current logged-in user is registered in the email cache
|
||||
@@ -346,26 +365,45 @@ export const AppLayout: React.FC = () => {
|
||||
);
|
||||
}
|
||||
|
||||
const isCollapsed = isSectionCollapsed(item.path, item.subItems);
|
||||
|
||||
return (
|
||||
<div key={item.path} className="space-y-1">
|
||||
<NavLink
|
||||
to={item.path}
|
||||
end={hasSub}
|
||||
className={({ isActive }) =>
|
||||
`flex items-center gap-3 px-3.5 py-2.5 rounded-xl text-xs font-medium transition-all min-w-0 ${
|
||||
isActive
|
||||
? 'bg-gradient-to-r from-blue-600 to-indigo-600 text-white shadow-md shadow-blue-600/25 font-semibold'
|
||||
: 'text-slate-400 hover:text-slate-100 hover:bg-slate-800/60'
|
||||
}`
|
||||
}
|
||||
title={item.name}
|
||||
>
|
||||
<Icon className="w-4.5 h-4.5 shrink-0" />
|
||||
<span className="truncate flex-1">{item.name}</span>
|
||||
</NavLink>
|
||||
<div className="flex items-center gap-1">
|
||||
<NavLink
|
||||
to={item.path}
|
||||
end={hasSub}
|
||||
className={({ isActive }) =>
|
||||
`flex-1 flex items-center gap-3 px-3.5 py-2.5 rounded-xl text-xs font-medium transition-all min-w-0 ${
|
||||
isActive
|
||||
? 'bg-gradient-to-r from-blue-600 to-indigo-600 text-white shadow-md shadow-blue-600/25 font-semibold'
|
||||
: 'text-slate-400 hover:text-slate-100 hover:bg-slate-800/60'
|
||||
}`
|
||||
}
|
||||
title={item.name}
|
||||
>
|
||||
<Icon className="w-4.5 h-4.5 shrink-0" />
|
||||
<span className="truncate flex-1">{item.name}</span>
|
||||
</NavLink>
|
||||
{hasSub && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={(e) => toggleSection(item.path, e)}
|
||||
className="p-2 text-slate-400 hover:text-white hover:bg-slate-800/80 rounded-lg transition-colors cursor-pointer"
|
||||
title={isCollapsed ? `Expand ${item.name}` : `Collapse ${item.name}`}
|
||||
aria-label={isCollapsed ? `Expand ${item.name}` : `Collapse ${item.name}`}
|
||||
>
|
||||
<ChevronDown
|
||||
className={`w-3.5 h-3.5 transition-transform duration-200 ${
|
||||
isCollapsed ? '-rotate-90 text-slate-500' : 'rotate-0 text-slate-300'
|
||||
}`}
|
||||
/>
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Sub-items under parent item */}
|
||||
{hasSub && (
|
||||
{hasSub && !isCollapsed && (
|
||||
<div className="pl-6 pr-1 py-1 space-y-1 border-l border-slate-800/80 ml-5">
|
||||
{item.subItems?.map((subItem) => {
|
||||
const SubIcon = subItem.icon;
|
||||
|
||||
@@ -1247,7 +1247,7 @@ export const ContractsManagement: React.FC<ContractsManagementProps> = ({
|
||||
{Number(contractToUpdate?.status) === ContractStatus.Active ? (
|
||||
<div className="w-full bg-[#151b2c] border border-emerald-500/30 rounded-xl px-3 py-2 text-xs text-emerald-300 font-semibold flex items-center gap-1.5 cursor-default">
|
||||
<Lock className="w-3.5 h-3.5 text-emerald-400" />
|
||||
<span>Active / Executed (Immutable - Cannot Be Changed)</span>
|
||||
<span>Active / Executed</span>
|
||||
</div>
|
||||
) : (
|
||||
(() => {
|
||||
|
||||
@@ -807,7 +807,7 @@ export const EvidenceReviewQueue: React.FC<EvidenceReviewQueueProps> = ({
|
||||
title="Approved evidence is immutable. Status cannot be modified."
|
||||
>
|
||||
<Lock className="w-3.5 h-3.5 text-emerald-400" />
|
||||
<span>Approved (Immutable)</span>
|
||||
<span>Approved</span>
|
||||
</span>
|
||||
) : (
|
||||
<>
|
||||
|
||||
@@ -58,7 +58,6 @@ export const UserSearchPicker: React.FC<UserSearchPickerProps> = ({
|
||||
const { data: users = [], isLoading } = useQuery<User[]>({
|
||||
queryKey: ['users-directory'],
|
||||
queryFn: () => getUsers(1, 200),
|
||||
staleTime: 5 * 60 * 1000,
|
||||
});
|
||||
|
||||
// Filter active users and exclude the current logged-in user so they can never message themselves
|
||||
|
||||
@@ -37,7 +37,7 @@ const activitySchema = z.object({
|
||||
title: z.string().min(3, 'Activity title required'),
|
||||
description: z.string().min(5, 'Description required'),
|
||||
category: z.string().min(2, 'Category required'),
|
||||
amount: z.coerce.number().min(1, 'Amount must be positive'),
|
||||
amount: z.coerce.number().min(0, 'Amount must be non-negative'),
|
||||
milestoneId: z.string().optional().nullable(),
|
||||
performedAt: z.string().optional().nullable(),
|
||||
});
|
||||
|
||||
@@ -20,6 +20,7 @@ import {
|
||||
getProjectIndicators,
|
||||
getProjectRisks,
|
||||
getBudgets,
|
||||
getDisbursements,
|
||||
} from '../../services/api';
|
||||
import { Project, ApprovalStatus, Budget } from '../../types';
|
||||
import {
|
||||
@@ -86,9 +87,21 @@ export const ProjectExpandedRow: React.FC<ProjectExpandedRowProps> = ({
|
||||
);
|
||||
const approvedBudgetTotal = approvedBudgets.reduce((sum, b) => sum + (Number(b.amount) || 0), 0);
|
||||
|
||||
const { data: disbursements = [] } = useQuery({
|
||||
queryKey: ['disbursements', projectId],
|
||||
queryFn: () => getDisbursements(1, 100),
|
||||
});
|
||||
|
||||
const projectDisbursements = disbursements.filter(
|
||||
(d) => String(d.projectId) === String(projectId) && (Number(d.status) === ApprovalStatus.Approved || Number(d.status) === 3)
|
||||
);
|
||||
const totalDisbursed = projectDisbursements.reduce((sum, d) => sum + (Number(d.amount) || 0), 0);
|
||||
|
||||
const verifiedMilestonesCount = milestones.filter((m) => m.status === ApprovalStatus.Approved).length;
|
||||
const milestoneProgress = milestones.length > 0 ? Math.round((verifiedMilestonesCount / milestones.length) * 100) : 0;
|
||||
const totalActivityBudget = activities.reduce((sum, a) => sum + (Number(a.amount) || 0), 0);
|
||||
const projectValue = totalActivityBudget - totalDisbursed;
|
||||
const projectVariance = approvedBudgetTotal - projectValue;
|
||||
|
||||
return (
|
||||
<div className="p-4 sm:p-5 lg:p-6 bg-[#090d16]/95 border-t border-b border-blue-500/20 space-y-6 animate-in fade-in duration-200 w-full min-w-0">
|
||||
@@ -108,6 +121,19 @@ export const ProjectExpandedRow: React.FC<ProjectExpandedRowProps> = ({
|
||||
<span className="text-xs text-emerald-400 font-mono font-semibold shrink-0">
|
||||
Approved Budget: {formatZAR(approvedBudgetTotal)}
|
||||
</span>
|
||||
<span className="text-slate-600">•</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>
|
||||
|
||||
@@ -33,6 +33,7 @@ import {
|
||||
getProjectIndicators,
|
||||
getProjectRisks,
|
||||
getBudgets,
|
||||
getDisbursements,
|
||||
updateProjectStatus,
|
||||
} from '../../services/api';
|
||||
import { Project, ApprovalStatus, Programme } from '../../types';
|
||||
@@ -150,11 +151,23 @@ export const ProjectViewModal: React.FC<ProjectViewModalProps> = ({
|
||||
);
|
||||
const approvedBudgetTotal = approvedBudgets.reduce((sum, b) => sum + (Number(b.amount) || 0), 0);
|
||||
|
||||
// Milestone Progress
|
||||
// Milestone Progress & Disbursements
|
||||
const verifiedMilestonesCount = milestones.filter((m) => m.status === ApprovalStatus.Approved).length;
|
||||
const milestoneProgress = milestones.length > 0 ? Math.round((verifiedMilestonesCount / milestones.length) * 100) : 0;
|
||||
const totalActivityBudget = activities.reduce((sum, a) => sum + (Number(a.amount) || 0), 0);
|
||||
|
||||
const { data: disbursements = [] } = useQuery({
|
||||
queryKey: ['disbursements', project?.id],
|
||||
queryFn: () => getDisbursements(1, 100),
|
||||
enabled: !!project?.id,
|
||||
});
|
||||
const projectDisbursements = disbursements.filter(
|
||||
(d) => String(d.projectId) === String(project?.id) && (Number(d.status) === ApprovalStatus.Approved || Number(d.status) === 3)
|
||||
);
|
||||
const totalDisbursed = projectDisbursements.reduce((sum, d) => sum + (Number(d.amount) || 0), 0);
|
||||
const projectValue = totalActivityBudget - totalDisbursed;
|
||||
const projectVariance = approvedBudgetTotal - projectValue;
|
||||
|
||||
// Schedule Risk
|
||||
const scheduleRisk = useMemo(() => {
|
||||
if (!project) return null;
|
||||
@@ -244,20 +257,20 @@ export const ProjectViewModal: React.FC<ProjectViewModalProps> = ({
|
||||
Budget: {formatZAR(approvedBudgetTotal)}
|
||||
</span>
|
||||
<span className="text-slate-600">•</span>
|
||||
<span className="text-blue-400 font-mono font-bold">
|
||||
Project Value: {formatZAR(totalActivityBudget)}
|
||||
<span className="text-blue-400 font-mono font-bold" title={`Activities Total: ${formatZAR(totalActivityBudget)} minus Disbursements: ${formatZAR(totalDisbursed)}`}>
|
||||
Project Value: {formatZAR(projectValue)}
|
||||
</span>
|
||||
<span className="text-slate-600">•</span>
|
||||
<span
|
||||
className={`font-mono font-bold px-2 py-0.5 rounded text-[11px] border ${
|
||||
approvedBudgetTotal - totalActivityBudget >= 0
|
||||
projectVariance >= 0
|
||||
? 'text-emerald-400 bg-emerald-500/10 border-emerald-500/30'
|
||||
: 'text-rose-400 bg-rose-500/10 border-rose-500/30'
|
||||
}`}
|
||||
title="Approved Budget minus Total Project Value"
|
||||
title={`Approved Budget: ${formatZAR(approvedBudgetTotal)} | Net Project Value: ${formatZAR(projectValue)} (Activities: ${formatZAR(totalActivityBudget)} - Disbursements: ${formatZAR(totalDisbursed)}) | Variance: ${projectVariance >= 0 ? '+' : ''}${formatZAR(projectVariance)}`}
|
||||
>
|
||||
Variance: {approvedBudgetTotal - totalActivityBudget >= 0 ? '+' : ''}
|
||||
{formatZAR(approvedBudgetTotal - totalActivityBudget)}
|
||||
Variance: {projectVariance >= 0 ? '+' : ''}
|
||||
{formatZAR(projectVariance)}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -32,6 +32,7 @@ import { clearCachedUserRbac, fetchAndCacheRoleClaims } from '../../services/rol
|
||||
import { HelpTooltip } from '../ui/HelpTooltip';
|
||||
import { ObfuscatedIdNumber } from '../ui/ObfuscatedIdNumber';
|
||||
import { decodeIdNumberHex } from '../../utils/hashids';
|
||||
import { useAuth } from '../../auth/AuthProvider';
|
||||
import { User, Role, Claim, ActivityStatus } from '../../types';
|
||||
import { formatDate, getActivityStatusBadge, getUserRoleBadge } from '../../lib/formatters';
|
||||
import { useRbac, ROLE_CLAIM_DEFINITIONS } from '../../auth/rbac';
|
||||
@@ -49,9 +50,15 @@ export const UserSecurityModal: React.FC<UserSecurityModalProps> = ({
|
||||
}) => {
|
||||
const queryClient = useQueryClient();
|
||||
const { success, error: toastError } = useToast();
|
||||
const { user: currentAuthUser } = useAuth();
|
||||
const { canApprove, canAdd, canView, canAccess, isUnrestricted } = useRbac();
|
||||
const hasTenantSelfHelpAccess = canAccess('tenant_self_service') || canView('tenant_self_service');
|
||||
|
||||
const userId = user?.id || '';
|
||||
const isSelf =
|
||||
Boolean(currentAuthUser?.id && userId && String(currentAuthUser.id).toLowerCase() === String(userId).toLowerCase()) ||
|
||||
Boolean(currentAuthUser?.email && user?.email && currentAuthUser.email.toLowerCase() === user.email.toLowerCase());
|
||||
|
||||
const canManageRolesAndClaims =
|
||||
canApprove('settings_users') ||
|
||||
canApprove('tenant_management') ||
|
||||
@@ -63,18 +70,17 @@ export const UserSecurityModal: React.FC<UserSecurityModalProps> = ({
|
||||
hasTenantSelfHelpAccess ||
|
||||
isUnrestricted;
|
||||
const canChangeStatus =
|
||||
canAdd('settings_users') ||
|
||||
canAdd('tenant_management') ||
|
||||
canAdd('tenant_self_service') ||
|
||||
canApprove('settings_users') ||
|
||||
isUnrestricted;
|
||||
!isSelf &&
|
||||
(canAdd('settings_users') ||
|
||||
canAdd('tenant_management') ||
|
||||
canAdd('tenant_self_service') ||
|
||||
canApprove('settings_users') ||
|
||||
isUnrestricted);
|
||||
|
||||
const [activeTab, setActiveTab] = useState<'overview' | 'roles' | 'claims' | 'sessions'>('overview');
|
||||
|
||||
const [selectedRoleToAdd, setSelectedRoleToAdd] = useState('');
|
||||
|
||||
const userId = user?.id || '';
|
||||
|
||||
// 1. Fetch fresh user details
|
||||
const { data: freshUser, isLoading: loadingUser, refetch: refetchUser } = useQuery<User | null>({
|
||||
queryKey: ['user-detail', userId],
|
||||
@@ -260,7 +266,12 @@ export const UserSecurityModal: React.FC<UserSecurityModalProps> = ({
|
||||
});
|
||||
|
||||
const statusMutation = useMutation({
|
||||
mutationFn: (newStatus: ActivityStatus) => updateUserStatus(userId, newStatus),
|
||||
mutationFn: (newStatus: ActivityStatus) => {
|
||||
if (isSelf) {
|
||||
throw new Error('Maker-Checker Violation: You cannot alter your own account activity status.');
|
||||
}
|
||||
return updateUserStatus(userId, newStatus);
|
||||
},
|
||||
onSuccess: () => {
|
||||
success('Status Updated', 'User activity status changed.');
|
||||
refetchUser();
|
||||
@@ -396,7 +407,14 @@ export const UserSecurityModal: React.FC<UserSecurityModalProps> = ({
|
||||
</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
{effectiveUser?.status === ActivityStatus.Active ? (
|
||||
{isSelf ? (
|
||||
<span
|
||||
className="px-2.5 py-1 text-[11px] font-semibold text-slate-400 bg-slate-900 border border-slate-800 rounded-lg"
|
||||
title="Maker-Checker policy: Administrators cannot change their own account activity status"
|
||||
>
|
||||
Self-Account (Protected)
|
||||
</span>
|
||||
) : effectiveUser?.status === ActivityStatus.Active ? (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => statusMutation.mutate(ActivityStatus.Inactive)}
|
||||
|
||||
@@ -6,6 +6,7 @@ import {
|
||||
getNotifications,
|
||||
createNotification,
|
||||
markNotificationAsRead,
|
||||
archiveNotification,
|
||||
updateNotification,
|
||||
getCurrentUserTenantId,
|
||||
} from '../services/api';
|
||||
@@ -124,7 +125,6 @@ export function useInAppNotifications() {
|
||||
},
|
||||
// Live polling every 30 seconds
|
||||
refetchInterval: 30000,
|
||||
staleTime: 10000,
|
||||
});
|
||||
|
||||
// InApp notifications (strictly platform === InApp 5) for Bell & Counters
|
||||
@@ -137,7 +137,10 @@ export function useInAppNotifications() {
|
||||
// Active in-app notifications in inbox (excluding trashed and drafts)
|
||||
const inboxNotifications = useMemo(() => {
|
||||
return inAppNotifications.filter(
|
||||
n => !trashedIds.includes(String(n.id)) && Number(n.status) !== NotificationStatus.Draft
|
||||
n =>
|
||||
!trashedIds.includes(String(n.id)) &&
|
||||
Number(n.status) !== NotificationStatus.Archived &&
|
||||
Number(n.status) !== NotificationStatus.Draft
|
||||
);
|
||||
}, [inAppNotifications, trashedIds]);
|
||||
|
||||
@@ -168,7 +171,9 @@ export function useInAppNotifications() {
|
||||
|
||||
// Trashed notifications (In-App only)
|
||||
const trashedNotifications = useMemo(() => {
|
||||
return inAppNotifications.filter(n => trashedIds.includes(String(n.id)));
|
||||
return inAppNotifications.filter(
|
||||
n => trashedIds.includes(String(n.id)) || Number(n.status) === NotificationStatus.Archived
|
||||
);
|
||||
}, [inAppNotifications, trashedIds]);
|
||||
|
||||
// Mutation to mark notification as read
|
||||
@@ -211,8 +216,8 @@ export function useInAppNotifications() {
|
||||
});
|
||||
}, [starredStorageKey]);
|
||||
|
||||
// Delete message (move to trash)
|
||||
const deleteMessage = useCallback((id: string | number) => {
|
||||
// Delete message (NotificationStatus == Archived & move to trash)
|
||||
const deleteMessage = useCallback(async (id: string | number) => {
|
||||
const strId = String(id);
|
||||
setTrashedIds(prev => {
|
||||
const next = prev.includes(strId) ? prev : [...prev, strId];
|
||||
@@ -222,10 +227,22 @@ export function useInAppNotifications() {
|
||||
} catch {}
|
||||
return next;
|
||||
});
|
||||
}, [trashStorageKey]);
|
||||
|
||||
// Restore from trash
|
||||
const restoreMessage = useCallback((id: string | number) => {
|
||||
queryClient.setQueryData<Notification[]>(['inapp-notifications', userId], (old = []) =>
|
||||
old.map(n => (String(n.id) === strId ? { ...n, status: NotificationStatus.Archived } : n))
|
||||
);
|
||||
|
||||
try {
|
||||
await archiveNotification(id);
|
||||
} catch (err) {
|
||||
console.warn('[deleteMessage] Error archiving notification on server:', err);
|
||||
} finally {
|
||||
queryClient.invalidateQueries({ queryKey: ['inapp-notifications'] });
|
||||
}
|
||||
}, [trashStorageKey, userId, queryClient]);
|
||||
|
||||
// Restore from trash / unarchive
|
||||
const restoreMessage = useCallback(async (id: string | number) => {
|
||||
const strId = String(id);
|
||||
setTrashedIds(prev => {
|
||||
const next = prev.filter(x => x !== strId);
|
||||
@@ -235,7 +252,19 @@ export function useInAppNotifications() {
|
||||
} catch {}
|
||||
return next;
|
||||
});
|
||||
}, [trashStorageKey]);
|
||||
|
||||
queryClient.setQueryData<Notification[]>(['inapp-notifications', userId], (old = []) =>
|
||||
old.map(n => (String(n.id) === strId ? { ...n, status: NotificationStatus.Read } : n))
|
||||
);
|
||||
|
||||
try {
|
||||
await markNotificationAsRead(id);
|
||||
} catch (err) {
|
||||
console.warn('[restoreMessage] Error restoring notification on server:', err);
|
||||
} finally {
|
||||
queryClient.invalidateQueries({ queryKey: ['inapp-notifications'] });
|
||||
}
|
||||
}, [trashStorageKey, userId, queryClient]);
|
||||
|
||||
// Permanent delete
|
||||
const permanentDelete = useCallback((id: string | number) => {
|
||||
|
||||
@@ -80,6 +80,7 @@ export const NOTIFICATION_STATUS_OPTIONS = [
|
||||
{ value: NotificationStatus.Cancelled, label: 'Cancelled' },
|
||||
{ value: NotificationStatus.Bounced, label: 'Bounced' },
|
||||
{ value: NotificationStatus.Draft, label: 'Draft' },
|
||||
{ value: NotificationStatus.Archived, label: 'Archived' },
|
||||
];
|
||||
|
||||
export const NOTIFICATION_PLATFORM_OPTIONS = [
|
||||
@@ -510,6 +511,8 @@ export const getEmailStatusBadge = (status?: EmailStatuses | number | null) => {
|
||||
export const getNotificationStatusBadge = (status?: NotificationStatus | number | null) => {
|
||||
const s = Number(status);
|
||||
switch (s) {
|
||||
case NotificationStatus.Archived:
|
||||
return { label: 'Archived', className: 'bg-slate-800 text-slate-300 border-slate-700 font-semibold' };
|
||||
case NotificationStatus.Draft:
|
||||
return { label: 'Draft', className: 'bg-purple-50 text-purple-800 border-purple-300 font-semibold' };
|
||||
case NotificationStatus.Delivered:
|
||||
|
||||
@@ -4,15 +4,38 @@ import { bumpMutationEpoch } from '../services/httpClient';
|
||||
export const queryClient = new QueryClient({
|
||||
defaultOptions: {
|
||||
queries: {
|
||||
staleTime: 1000 * 60 * 2, // 2 minutes fresh in memory
|
||||
gcTime: 1000 * 60 * 10, // 10 minutes cache retention
|
||||
refetchOnWindowFocus: false,
|
||||
refetchOnReconnect: true,
|
||||
// Remove caching from all pages: zero stale time and zero cache retention
|
||||
// Whenever a component needs data, it must call its corresponding endpoint(s)
|
||||
staleTime: 0,
|
||||
gcTime: 0,
|
||||
refetchOnMount: 'always',
|
||||
refetchOnWindowFocus: 'always',
|
||||
refetchOnReconnect: 'always',
|
||||
retry: 1,
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
// Retain caching ONLY for sync status, auth token, roles and grants
|
||||
const RETAINED_CACHE_OPTIONS = {
|
||||
staleTime: 5 * 60 * 1000,
|
||||
gcTime: 10 * 60 * 1000,
|
||||
refetchOnMount: false,
|
||||
refetchOnWindowFocus: false,
|
||||
};
|
||||
|
||||
queryClient.setQueryDefaults(['roles'], RETAINED_CACHE_OPTIONS);
|
||||
queryClient.setQueryDefaults(['system-roles'], RETAINED_CACHE_OPTIONS);
|
||||
queryClient.setQueryDefaults(['role-claims'], RETAINED_CACHE_OPTIONS);
|
||||
queryClient.setQueryDefaults(['user-roles'], RETAINED_CACHE_OPTIONS);
|
||||
queryClient.setQueryDefaults(['user-claims'], RETAINED_CACHE_OPTIONS);
|
||||
queryClient.setQueryDefaults(['grants'], RETAINED_CACHE_OPTIONS);
|
||||
queryClient.setQueryDefaults(['user-grants'], RETAINED_CACHE_OPTIONS);
|
||||
queryClient.setQueryDefaults(['user-sync'], RETAINED_CACHE_OPTIONS);
|
||||
queryClient.setQueryDefaults(['sync-status'], RETAINED_CACHE_OPTIONS);
|
||||
queryClient.setQueryDefaults(['auth'], RETAINED_CACHE_OPTIONS);
|
||||
queryClient.setQueryDefaults(['auth-token'], RETAINED_CACHE_OPTIONS);
|
||||
|
||||
/**
|
||||
* Invalidates queries while simultaneously bumping the mutation epoch.
|
||||
* This guarantees that the background refetch generates a fresh query parameter (_v)
|
||||
|
||||
@@ -353,6 +353,12 @@ export const BeneficiariesPage: React.FC = () => {
|
||||
const updateCompMutation = useMutation({
|
||||
mutationFn: (vals: ComplianceFormValues) => {
|
||||
if (!editingComp) throw new Error('No item selected');
|
||||
if (
|
||||
(vals.status === ComplianceStatus.Met || vals.status === ComplianceStatus.Exceeded) &&
|
||||
isMaker(editingComp.createdBy)
|
||||
) {
|
||||
throw new Error('Maker-Checker Violation: You created this compliance item and cannot approve it.');
|
||||
}
|
||||
return updateComplianceItem({
|
||||
complianceItemId: editingComp.id,
|
||||
title: vals.title,
|
||||
@@ -399,6 +405,12 @@ export const BeneficiariesPage: React.FC = () => {
|
||||
const updateOutcomeMutation = useMutation({
|
||||
mutationFn: (vals: OutcomeFormValues) => {
|
||||
if (!editingOutcome) throw new Error('No outcome selected for update');
|
||||
if (
|
||||
(vals.status === ComplianceStatus.Met || vals.status === ComplianceStatus.Exceeded) &&
|
||||
isMaker(editingOutcome.createdBy)
|
||||
) {
|
||||
throw new Error('Maker-Checker Violation: You created this outcome record and cannot approve or certify fulfillment.');
|
||||
}
|
||||
return updateOutcome({
|
||||
outcomeId: editingOutcome.id,
|
||||
name: vals.name,
|
||||
@@ -1467,11 +1479,15 @@ export const BeneficiariesPage: React.FC = () => {
|
||||
{...compForm.register('status')}
|
||||
className="w-full bg-slate-900 border border-slate-800 rounded-xl px-3 py-2 text-xs text-white focus:outline-none focus:border-blue-500 cursor-pointer"
|
||||
>
|
||||
{COMPLIANCE_STATUS_OPTIONS.map((opt) => (
|
||||
<option key={opt.value} value={opt.value}>
|
||||
{opt.label}
|
||||
</option>
|
||||
))}
|
||||
{COMPLIANCE_STATUS_OPTIONS.map((opt) => {
|
||||
const isApprovedOpt = opt.value === ComplianceStatus.Met || opt.value === ComplianceStatus.Exceeded;
|
||||
const makerRestricted = editingComp && isApprovedOpt && isMaker(editingComp.createdBy);
|
||||
return (
|
||||
<option key={opt.value} value={opt.value} disabled={makerRestricted}>
|
||||
{opt.label} {makerRestricted ? '(Maker-Checker: Restricted)' : ''}
|
||||
</option>
|
||||
);
|
||||
})}
|
||||
</select>
|
||||
</div>
|
||||
|
||||
@@ -1680,11 +1696,15 @@ export const BeneficiariesPage: React.FC = () => {
|
||||
{...outcomeForm.register('status')}
|
||||
className="w-full bg-slate-900 border border-slate-800 rounded-xl px-3 py-2 text-xs text-white focus:outline-none focus:border-blue-500 cursor-pointer"
|
||||
>
|
||||
{COMPLIANCE_STATUS_OPTIONS.map((opt) => (
|
||||
<option key={opt.value} value={opt.value}>
|
||||
{opt.label}
|
||||
</option>
|
||||
))}
|
||||
{COMPLIANCE_STATUS_OPTIONS.map((opt) => {
|
||||
const isMetOpt = opt.value === ComplianceStatus.Met || opt.value === ComplianceStatus.Exceeded;
|
||||
const makerRestricted = editingOutcome && isMetOpt && isMaker(editingOutcome.createdBy);
|
||||
return (
|
||||
<option key={opt.value} value={opt.value} disabled={makerRestricted}>
|
||||
{opt.label} {makerRestricted ? '(Maker-Checker: Restricted)' : ''}
|
||||
</option>
|
||||
);
|
||||
})}
|
||||
</select>
|
||||
</div>
|
||||
|
||||
|
||||
@@ -1020,7 +1020,7 @@ export const ChangeManagementPage: React.FC = () => {
|
||||
{Number(cr.status) === ApprovalStatus.Approved ? (
|
||||
<span className="inline-flex items-center gap-1 text-xs font-semibold text-emerald-400 bg-emerald-500/10 px-2.5 py-1 rounded-lg border border-emerald-500/20 cursor-default" title="Approved and enacted change requests are immutable.">
|
||||
<Lock className="w-3 h-3 text-emerald-400" />
|
||||
<span>Resolved & Enacted (Immutable)</span>
|
||||
<span>Resolved & Enacted</span>
|
||||
</span>
|
||||
) : (
|
||||
<>
|
||||
@@ -1528,7 +1528,7 @@ export const ChangeManagementPage: React.FC = () => {
|
||||
title="Approved and enacted change requests are immutable."
|
||||
>
|
||||
<Lock className="w-3.5 h-3.5 text-emerald-400" />
|
||||
<span>Approved & Enacted (Immutable - Cannot Be Changed)</span>
|
||||
<span>Approved & Enacted</span>
|
||||
</div>
|
||||
) : (
|
||||
<select
|
||||
|
||||
@@ -11,6 +11,7 @@ import {
|
||||
Edit2,
|
||||
Mail,
|
||||
Shield,
|
||||
ShieldCheck,
|
||||
Layers,
|
||||
ChevronRight,
|
||||
Search,
|
||||
@@ -24,9 +25,11 @@ import {
|
||||
UserPlus,
|
||||
ChevronDown,
|
||||
ChevronUp,
|
||||
CheckCircle,
|
||||
CheckCircle2,
|
||||
Loader2,
|
||||
Lock,
|
||||
AlertTriangle,
|
||||
} from 'lucide-react';
|
||||
import {
|
||||
getOrganisations,
|
||||
@@ -69,6 +72,7 @@ import { ExportButton } from '../components/ui/ExportButton';
|
||||
import { ExportColumn } from '../utils/exportUtils';
|
||||
import { useAuth } from '../auth/AuthProvider';
|
||||
import { useRbac } from '../auth/rbac';
|
||||
import { bumpMutationEpoch } from '../services/httpClient';
|
||||
import { useTableSort, SortableTh } from '../utils/tableSort';
|
||||
import { FundingInstrumentsSection, InstructionsSection } from '../components/governance/InstructionsSection';
|
||||
import { RulesSection } from '../components/governance/RulesSection';
|
||||
@@ -110,6 +114,7 @@ const organisationSchema = z.object({
|
||||
registrationNo: z.string().min(4, 'Registration number is mandatory'),
|
||||
email: z.string().email('Enter a valid email address'),
|
||||
organisationUser: z.string().min(1, 'Please select a linked executive or manager user'),
|
||||
idNumber: z.string().optional(),
|
||||
type: z.coerce.number().min(1).max(27),
|
||||
status: z.coerce.number().optional(),
|
||||
});
|
||||
@@ -134,8 +139,8 @@ type ProgrammeFormValues = z.infer<typeof programmeSchema>;
|
||||
|
||||
export const GovernancePage: React.FC = () => {
|
||||
const queryClient = useQueryClient();
|
||||
const { sub, user, claims } = useAuth();
|
||||
const { canAdd, canApprove, canExport, canApproveRecord, isMaker } = useRbac();
|
||||
const { sub, user, claims, roles, tenantId } = useAuth();
|
||||
const { canAdd, canApprove, canExport, canApproveRecord, isMaker, isUnrestricted } = useRbac();
|
||||
const currentUserId = sub || user?.id || '';
|
||||
const { success, error: toastError } = useToast();
|
||||
|
||||
@@ -241,7 +246,6 @@ export const GovernancePage: React.FC = () => {
|
||||
const { data: usersDirectory = [] } = useQuery({
|
||||
queryKey: ['users-directory'],
|
||||
queryFn: () => getUsers(1, 200),
|
||||
staleTime: 5 * 60 * 1000,
|
||||
});
|
||||
|
||||
const { data: systemRoles = [] } = useQuery<Role[]>({
|
||||
@@ -263,6 +267,37 @@ export const GovernancePage: React.FC = () => {
|
||||
});
|
||||
}, [usersDirectory]);
|
||||
|
||||
const modalExecutiveUsers = React.useMemo(() => {
|
||||
const list: User[] = [...executiveAndManagerUsers];
|
||||
const linkedUserId =
|
||||
editingOrg?.userId ||
|
||||
editingOrg?.UserId ||
|
||||
'';
|
||||
|
||||
if (linkedUserId) {
|
||||
const linkedStr = String(linkedUserId).toLowerCase();
|
||||
const alreadyIncluded = list.some((u) => u.id && String(u.id).toLowerCase() === linkedStr);
|
||||
if (!alreadyIncluded) {
|
||||
const inDirectory = usersDirectory.find((u) => u.id && String(u.id).toLowerCase() === linkedStr);
|
||||
if (inDirectory) {
|
||||
list.unshift(inDirectory);
|
||||
} else {
|
||||
const email = getUserEmail(linkedUserId);
|
||||
const hasRealEmail = email && email !== '—' && email.includes('@') && email !== String(linkedUserId);
|
||||
list.unshift({
|
||||
id: String(linkedUserId),
|
||||
email: hasRealEmail ? email : String(linkedUserId),
|
||||
role: 'Executive',
|
||||
status: ActivityStatus.Active,
|
||||
lastLoginAt: null,
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return list;
|
||||
}, [executiveAndManagerUsers, usersDirectory, editingOrg?.id, editingOrg?.userId]);
|
||||
|
||||
// Quick Add Executive User Subform state
|
||||
const [isQuickAddUserOpen, setIsQuickAddUserOpen] = useState(false);
|
||||
const [quickUserEmail, setQuickUserEmail] = useState('');
|
||||
@@ -560,11 +595,23 @@ export const GovernancePage: React.FC = () => {
|
||||
registrationNo: '',
|
||||
email: '',
|
||||
organisationUser: '',
|
||||
idNumber: '',
|
||||
type: OrganisationType.Enterprise,
|
||||
status: ActivityStatus.Active,
|
||||
status: ActivityStatus.Inactive,
|
||||
},
|
||||
});
|
||||
|
||||
const watchedOrgUser = orgForm.watch('organisationUser');
|
||||
const selectedExecutiveUser = React.useMemo(() => {
|
||||
if (!watchedOrgUser) return null;
|
||||
const str = String(watchedOrgUser).toLowerCase();
|
||||
return (
|
||||
usersDirectory.find((u) => u.id && String(u.id).toLowerCase() === str) ||
|
||||
modalExecutiveUsers.find((u) => u.id && String(u.id).toLowerCase() === str) ||
|
||||
null
|
||||
);
|
||||
}, [watchedOrgUser, usersDirectory, modalExecutiveUsers]);
|
||||
|
||||
const portfolioForm = useForm<PortfolioFormValues>({
|
||||
resolver: zodResolver(portfolioSchema),
|
||||
defaultValues: {
|
||||
@@ -580,7 +627,7 @@ export const GovernancePage: React.FC = () => {
|
||||
defaultValues: {
|
||||
name: '',
|
||||
portfolioId: portfolios[0]?.id ? Number(portfolios[0].id) : 201,
|
||||
status: ApprovalStatus.Approved,
|
||||
status: ApprovalStatus.Pending,
|
||||
ownerEmail: effectiveUserEmail,
|
||||
},
|
||||
});
|
||||
@@ -589,11 +636,20 @@ export const GovernancePage: React.FC = () => {
|
||||
const orgMutation = useMutation({
|
||||
mutationFn: (data: OrganisationFormValues) => {
|
||||
if (editingOrg) {
|
||||
if (!data.idNumber || !data.idNumber.trim()) {
|
||||
orgForm.setError('idNumber', {
|
||||
type: 'manual',
|
||||
message: 'Government ID number is required to assign this executive user.',
|
||||
});
|
||||
throw new Error('Executive user ID number is required when updating an organisation.');
|
||||
}
|
||||
|
||||
return updateOrganisation({
|
||||
organisationId: editingOrg.id,
|
||||
name: data.name,
|
||||
registrationNo: data.registrationNo,
|
||||
email: data.email,
|
||||
idNumber: data.idNumber.trim(),
|
||||
organisationUser: data.organisationUser,
|
||||
type: Number(data.type),
|
||||
status: data.status !== undefined ? Number(data.status) : ActivityStatus.Active,
|
||||
@@ -603,11 +659,13 @@ export const GovernancePage: React.FC = () => {
|
||||
name: data.name,
|
||||
registrationNo: data.registrationNo,
|
||||
email: data.email,
|
||||
idNumber: data.idNumber?.trim() || null,
|
||||
organisationUser: data.organisationUser,
|
||||
type: Number(data.type),
|
||||
});
|
||||
},
|
||||
onSuccess: () => {
|
||||
bumpMutationEpoch();
|
||||
queryClient.invalidateQueries({ queryKey: ['organisations'] });
|
||||
success(editingOrg ? 'Organisation updated' : 'Organisation registered successfully');
|
||||
setIsOrgModalOpen(false);
|
||||
@@ -639,6 +697,7 @@ export const GovernancePage: React.FC = () => {
|
||||
});
|
||||
},
|
||||
onSuccess: () => {
|
||||
bumpMutationEpoch();
|
||||
queryClient.invalidateQueries({ queryKey: ['portfolios'] });
|
||||
success(editingPortfolio ? 'Portfolio updated successfully' : 'Portfolio created successfully');
|
||||
setIsPortfolioModalOpen(false);
|
||||
@@ -650,15 +709,43 @@ export const GovernancePage: React.FC = () => {
|
||||
},
|
||||
});
|
||||
|
||||
const approveProgrammeMutation = useMutation({
|
||||
mutationFn: async (pr: Programme) => {
|
||||
if (isMaker(pr.createdBy)) {
|
||||
throw new Error('Maker-Checker policy: Record creator cannot approve their own programme.');
|
||||
}
|
||||
return updateProgramme({
|
||||
programmeId: pr.id,
|
||||
name: pr.name || '',
|
||||
portfolioId: Number(pr.portfolioId),
|
||||
status: ApprovalStatus.Approved,
|
||||
ownerId: pr.ownedBy,
|
||||
});
|
||||
},
|
||||
onSuccess: () => {
|
||||
bumpMutationEpoch();
|
||||
queryClient.invalidateQueries({ queryKey: ['programmes'] });
|
||||
queryClient.invalidateQueries({ queryKey: ['programme'] });
|
||||
success('Programme Approved', 'Programme governance status successfully updated to Approved.');
|
||||
},
|
||||
onError: (err: unknown) => {
|
||||
toastError('Programme Approval Failed', err);
|
||||
},
|
||||
});
|
||||
|
||||
const programmeMutation = useMutation({
|
||||
mutationFn: (data: ProgrammeFormValues) => {
|
||||
const resolvedOwnerId = resolveOwnerId(data.ownerEmail, editingProgramme?.ownedBy);
|
||||
if (editingProgramme) {
|
||||
const targetStatus = (data.status !== undefined ? Number(data.status) : 1) as ApprovalStatus;
|
||||
if (targetStatus === ApprovalStatus.Approved && isMaker(editingProgramme.createdBy)) {
|
||||
throw new Error('Maker-Checker policy violation: Record creators cannot approve their own programmes.');
|
||||
}
|
||||
return updateProgramme({
|
||||
programmeId: editingProgramme.id,
|
||||
name: data.name,
|
||||
portfolioId: Number(data.portfolioId),
|
||||
status: (data.status !== undefined ? Number(data.status) : 1) as ApprovalStatus,
|
||||
status: targetStatus,
|
||||
ownerId: resolvedOwnerId,
|
||||
});
|
||||
}
|
||||
@@ -669,7 +756,9 @@ export const GovernancePage: React.FC = () => {
|
||||
});
|
||||
},
|
||||
onSuccess: () => {
|
||||
bumpMutationEpoch();
|
||||
queryClient.invalidateQueries({ queryKey: ['programmes'] });
|
||||
queryClient.invalidateQueries({ queryKey: ['programme'] });
|
||||
success(editingProgramme ? 'Programme updated successfully' : 'Programme created successfully');
|
||||
setIsProgrammeModalOpen(false);
|
||||
setEditingProgramme(null);
|
||||
@@ -688,8 +777,9 @@ export const GovernancePage: React.FC = () => {
|
||||
registrationNo: '',
|
||||
email: '',
|
||||
organisationUser: defaultUser?.id || '',
|
||||
idNumber: defaultUser?.idNumber || '',
|
||||
type: OrganisationType.Enterprise,
|
||||
status: ActivityStatus.Active,
|
||||
status: ActivityStatus.Inactive,
|
||||
});
|
||||
setIsQuickAddUserOpen(false);
|
||||
setQuickUserError(null);
|
||||
@@ -702,26 +792,51 @@ export const GovernancePage: React.FC = () => {
|
||||
const targetUserId =
|
||||
org.userId ||
|
||||
org.UserId ||
|
||||
org.organisationUser ||
|
||||
org.OrganisationUser ||
|
||||
org.createdBy ||
|
||||
'';
|
||||
|
||||
const matchedUser = targetUserId
|
||||
? usersDirectory.find((u) => u.id && String(u.id).toLowerCase() === String(targetUserId).toLowerCase())
|
||||
: null;
|
||||
let selectedUserId = '';
|
||||
let matchedUser: User | undefined;
|
||||
if (targetUserId) {
|
||||
const targetStr = String(targetUserId).toLowerCase();
|
||||
matchedUser = usersDirectory.find(
|
||||
(u) =>
|
||||
(u.id && String(u.id).toLowerCase() === targetStr) ||
|
||||
(u.email && u.email.toLowerCase() === targetStr)
|
||||
);
|
||||
|
||||
const selectedUserId = matchedUser?.id || targetUserId || '';
|
||||
if (matchedUser) {
|
||||
selectedUserId = matchedUser.id;
|
||||
if (matchedUser.email) {
|
||||
registerUserEmail(matchedUser.id, matchedUser.email);
|
||||
}
|
||||
} else {
|
||||
selectedUserId = String(targetUserId);
|
||||
const email = getUserEmail(targetUserId);
|
||||
if (email && email !== '—' && email.includes('@')) {
|
||||
registerUserEmail(selectedUserId, email);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const resolvedIdNumber = matchedUser?.idNumber || org.idNumber || '';
|
||||
|
||||
orgForm.reset({
|
||||
name: org.name || '',
|
||||
registrationNo: org.registrationNo || '',
|
||||
email: org.email || '',
|
||||
organisationUser: selectedUserId,
|
||||
idNumber: resolvedIdNumber,
|
||||
type: org.type || OrganisationType.Enterprise,
|
||||
status: org.status ?? ActivityStatus.Active,
|
||||
});
|
||||
|
||||
if (selectedUserId && !resolvedIdNumber) {
|
||||
orgForm.setError('idNumber', {
|
||||
type: 'manual',
|
||||
message: 'This executive user has no ID number on file. An ID number is required to update the organisation.',
|
||||
});
|
||||
}
|
||||
|
||||
setIsQuickAddUserOpen(false);
|
||||
setQuickUserError(null);
|
||||
setIsOrgModalOpen(true);
|
||||
@@ -755,7 +870,7 @@ export const GovernancePage: React.FC = () => {
|
||||
programmeForm.reset({
|
||||
name: '',
|
||||
portfolioId: portfolios[0]?.id ? Number(portfolios[0].id) : 201,
|
||||
status: ApprovalStatus.Approved,
|
||||
status: ApprovalStatus.Pending,
|
||||
ownerEmail: effectiveUserEmail,
|
||||
});
|
||||
setIsProgrammeModalOpen(true);
|
||||
@@ -833,6 +948,7 @@ export const GovernancePage: React.FC = () => {
|
||||
status: (p) => getApprovalStatusBadge(p.status).label,
|
||||
createdAt: (p) => p.createdAt,
|
||||
ownedBy: (p) => getUserEmail(p.ownedBy),
|
||||
createdBy: (p) => getUserEmail(p.createdBy),
|
||||
});
|
||||
|
||||
// Export Columns Definitions
|
||||
@@ -863,6 +979,7 @@ export const GovernancePage: React.FC = () => {
|
||||
format: (id) => programmes.filter((pr) => String(pr.portfolioId) === String(id)).length,
|
||||
},
|
||||
{ header: 'Owner Email', key: 'ownedBy', format: (val) => getUserEmail(val) },
|
||||
{ header: 'Created By (Maker)', key: 'createdBy', format: (val) => getUserEmail(val) },
|
||||
{ header: 'Creation Date', key: 'createdAt', format: (val) => formatDate(val) },
|
||||
{ header: 'Description', key: 'description', format: (val) => val || '—' },
|
||||
];
|
||||
@@ -881,6 +998,7 @@ export const GovernancePage: React.FC = () => {
|
||||
{ header: 'Governance Status', key: 'status', format: (val) => getApprovalStatusBadge(val).label },
|
||||
{ header: 'Creation Date', key: 'createdAt', format: (val) => formatDate(val) },
|
||||
{ header: 'Owner Email', key: 'ownedBy', format: (val) => getUserEmail(val) },
|
||||
{ header: 'Created By (Maker)', key: 'createdBy', format: (val) => getUserEmail(val) },
|
||||
];
|
||||
|
||||
return (
|
||||
@@ -1150,6 +1268,11 @@ export const GovernancePage: React.FC = () => {
|
||||
<Mail className="w-3.5 h-3.5 text-slate-500" />
|
||||
{org.email}
|
||||
</span>
|
||||
{org.userId && (
|
||||
<span className="text-[10px] text-blue-400/90 font-mono block mt-0.5" title={`Executive User: ${org.userId}`}>
|
||||
Exec: {getUserEmail(org.userId)}
|
||||
</span>
|
||||
)}
|
||||
</td>
|
||||
<td className="py-3.5 px-4">
|
||||
<span className={`inline-flex items-center px-2.5 py-0.5 rounded-full text-[10px] font-semibold border ${statusBadge.className}`}>
|
||||
@@ -1382,19 +1505,22 @@ export const GovernancePage: React.FC = () => {
|
||||
<SortableTh column="ownedBy" currentColumn={progSortCol} direction={progSortDir} onSort={handleProgSort} className="py-3.5 px-4">
|
||||
Owner Email
|
||||
</SortableTh>
|
||||
<SortableTh column="createdBy" currentColumn={progSortCol} direction={progSortDir} onSort={handleProgSort} className="py-3.5 px-4">
|
||||
Created By
|
||||
</SortableTh>
|
||||
<th className="py-3.5 px-5 text-right font-medium">Actions</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody className="divide-y divide-slate-800/60">
|
||||
{loadingProgrammes ? (
|
||||
<tr>
|
||||
<td colSpan={6} className="py-8 text-center text-slate-500">
|
||||
<td colSpan={7} className="py-8 text-center text-slate-500">
|
||||
Loading programmes...
|
||||
</td>
|
||||
</tr>
|
||||
) : sortedProgrammes.length === 0 ? (
|
||||
<tr>
|
||||
<td colSpan={6} className="py-8 text-center text-slate-500">
|
||||
<td colSpan={7} className="py-8 text-center text-slate-500">
|
||||
No programmes found matching query.
|
||||
</td>
|
||||
</tr>
|
||||
@@ -1402,6 +1528,7 @@ export const GovernancePage: React.FC = () => {
|
||||
sortedProgrammes.map((pr) => {
|
||||
const port = portfolios.find((p) => String(p.id) === String(pr.portfolioId));
|
||||
const statusBadge = getApprovalStatusBadge(pr.status);
|
||||
const isApproved = Number(pr.status) === ApprovalStatus.Approved;
|
||||
return (
|
||||
<tr key={pr.id} className="hover:bg-slate-800/40 transition-colors">
|
||||
<td className="py-3.5 px-5">
|
||||
@@ -1420,8 +1547,49 @@ export const GovernancePage: React.FC = () => {
|
||||
<td className="py-3.5 px-4 font-mono text-slate-300 font-medium text-[11px]">
|
||||
{getUserEmail(pr.ownedBy)}
|
||||
</td>
|
||||
<td className="py-3.5 px-4 font-mono text-slate-400 text-[11px]">
|
||||
{getUserEmail(pr.createdBy)}
|
||||
</td>
|
||||
<td className="py-3.5 px-5 text-right">
|
||||
<div className="flex items-center justify-end gap-1.5">
|
||||
<div className="flex items-center justify-end gap-1.5 flex-wrap sm:flex-nowrap">
|
||||
{isApproved ? (
|
||||
<span
|
||||
className="inline-flex items-center gap-1 text-xs font-semibold text-emerald-400 bg-emerald-500/10 px-2.5 py-1 rounded-lg border border-emerald-500/20 cursor-default"
|
||||
title="Approved programmes cannot be modified."
|
||||
>
|
||||
<Lock className="w-3 h-3 text-emerald-400" />
|
||||
<span>Approved</span>
|
||||
</span>
|
||||
) : (
|
||||
<>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => openEditProgramme(pr)}
|
||||
className="inline-flex items-center gap-1 text-xs font-semibold text-slate-200 hover:text-white bg-slate-900/80 hover:bg-slate-800 border border-slate-700/80 px-2.5 py-1 rounded-lg transition shadow-xs cursor-pointer"
|
||||
title="Edit Programme"
|
||||
>
|
||||
<Edit2 className="w-3 h-3" />
|
||||
Edit
|
||||
</button>
|
||||
{canApproveRecord('governance', pr.createdBy) && (
|
||||
<button
|
||||
type="button"
|
||||
disabled={approveProgrammeMutation.isPending}
|
||||
onClick={() => approveProgrammeMutation.mutate(pr)}
|
||||
className="inline-flex items-center gap-1 text-xs font-semibold text-emerald-300 hover:text-white bg-emerald-500/10 hover:bg-emerald-500/20 border border-emerald-500/30 px-2.5 py-1 rounded-lg transition shadow-xs cursor-pointer"
|
||||
title="Approve Programme"
|
||||
>
|
||||
<CheckCircle className="w-3 h-3" />
|
||||
<span>Approve</span>
|
||||
</button>
|
||||
)}
|
||||
{canApprove('governance') && isMaker(pr.createdBy) && (
|
||||
<span className="text-[10px] text-amber-400 font-mono italic px-1.5 py-0.5 bg-amber-500/10 border border-amber-500/20 rounded">
|
||||
Maker-Checker: Record creator cannot approve
|
||||
</span>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => {
|
||||
@@ -1458,14 +1626,6 @@ export const GovernancePage: React.FC = () => {
|
||||
<Scale className="w-3 h-3" />
|
||||
Rules
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => openEditProgramme(pr)}
|
||||
className="inline-flex items-center gap-1 text-xs font-semibold text-slate-200 hover:text-white bg-slate-900/80 hover:bg-slate-800 border border-slate-700/80 px-2.5 py-1 rounded-lg transition shadow-xs cursor-pointer"
|
||||
>
|
||||
<Edit2 className="w-3 h-3" />
|
||||
Edit
|
||||
</button>
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
@@ -1611,11 +1771,11 @@ export const GovernancePage: React.FC = () => {
|
||||
<div className="flex items-center justify-between mb-1">
|
||||
<div className="flex items-center gap-1">
|
||||
<label className="block text-xs font-semibold text-slate-300">
|
||||
Linked Executive / Manager User *
|
||||
Responsible Executive User *
|
||||
</label>
|
||||
<HelpTooltip
|
||||
title="Designated Organisation User"
|
||||
content="Only users with an Executive or Manager role can be assigned to an organisation."
|
||||
title="Executive / Organisation User"
|
||||
content="The Executive user responsible for this organisation. Stored as userId in the organisation record and submitted as organisationUser to the API."
|
||||
size="sm"
|
||||
placement="top"
|
||||
/>
|
||||
@@ -1636,11 +1796,36 @@ export const GovernancePage: React.FC = () => {
|
||||
)}
|
||||
</div>
|
||||
<select
|
||||
{...orgForm.register('organisationUser')}
|
||||
{...orgForm.register('organisationUser', {
|
||||
onChange: (e) => {
|
||||
const selectedId = e.target.value;
|
||||
if (!selectedId) {
|
||||
orgForm.setValue('idNumber', '');
|
||||
return;
|
||||
}
|
||||
const str = String(selectedId).toLowerCase();
|
||||
const matched =
|
||||
usersDirectory.find((u) => u.id && String(u.id).toLowerCase() === str) ||
|
||||
modalExecutiveUsers.find((u) => u.id && String(u.id).toLowerCase() === str);
|
||||
|
||||
if (matched?.idNumber && matched.idNumber.trim()) {
|
||||
orgForm.setValue('idNumber', matched.idNumber.trim(), { shouldValidate: true });
|
||||
orgForm.clearErrors('idNumber');
|
||||
} else {
|
||||
orgForm.setValue('idNumber', '', { shouldValidate: true });
|
||||
if (editingOrg) {
|
||||
orgForm.setError('idNumber', {
|
||||
type: 'manual',
|
||||
message: 'Selected user has no ID number on file. Please enter their ID number to enable executive assignment.',
|
||||
});
|
||||
}
|
||||
}
|
||||
},
|
||||
})}
|
||||
className="w-full px-3 py-2 text-xs border border-slate-700/80 rounded-xl focus:outline-none focus:ring-1 focus:ring-blue-500 bg-[#090d16] text-white font-medium"
|
||||
>
|
||||
<option value="">-- Select linked executive or manager account --</option>
|
||||
{executiveAndManagerUsers.map((u) => (
|
||||
{modalExecutiveUsers.map((u) => (
|
||||
<option key={u.id} value={u.id}>
|
||||
{u.email || u.id} ({u.role || 'Executive'})
|
||||
</option>
|
||||
@@ -1654,9 +1839,61 @@ export const GovernancePage: React.FC = () => {
|
||||
No Executive or Manager user found. Create one below to continue.
|
||||
</p>
|
||||
)}
|
||||
{watchedOrgUser && selectedExecutiveUser && selectedExecutiveUser.idNumber && selectedExecutiveUser.idNumber.trim() && (
|
||||
<div className="flex items-center gap-1.5 text-[11px] text-emerald-400 mt-1">
|
||||
<CheckCircle className="w-3.5 h-3.5 text-emerald-400 shrink-0" />
|
||||
<span>ID on file: <strong className="font-mono text-emerald-300">{selectedExecutiveUser.idNumber}</strong></span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Missing ID Number Alert */}
|
||||
{watchedOrgUser && selectedExecutiveUser && (!selectedExecutiveUser.idNumber || !selectedExecutiveUser.idNumber.trim()) && (
|
||||
<div className="p-3 rounded-xl border border-amber-500/30 bg-amber-500/10 text-amber-300 text-xs flex items-start gap-2.5">
|
||||
<AlertTriangle className="w-4 h-4 text-amber-400 shrink-0 mt-0.5" />
|
||||
<div className="space-y-1">
|
||||
<p className="font-semibold text-amber-200">
|
||||
Missing Executive ID Number
|
||||
</p>
|
||||
<p className="text-[11px] text-amber-300/90 leading-relaxed">
|
||||
The selected executive (<strong className="font-mono text-white">{selectedExecutiveUser.email || watchedOrgUser}</strong>) does not have a Government ID Number on file. An ID number is required to assign this executive user; otherwise the update will be ignored. Please enter their ID number below.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Executive User ID Number Input */}
|
||||
<div>
|
||||
<div className="flex items-center justify-between mb-1">
|
||||
<div className="flex items-center gap-1">
|
||||
<label className="block text-xs font-semibold text-slate-300">
|
||||
Executive ID Number {editingOrg ? '*' : '(Optional)'}
|
||||
</label>
|
||||
<HelpTooltip
|
||||
title="Executive ID Requirement"
|
||||
content="Government Issued ID or Passport of the executive. Required when updating to persist the executive user link; otherwise the update will be ignored."
|
||||
size="sm"
|
||||
placement="top"
|
||||
/>
|
||||
</div>
|
||||
{selectedExecutiveUser?.idNumber && (
|
||||
<span className="text-[10px] text-emerald-400 font-mono">Sourced from profile</span>
|
||||
)}
|
||||
</div>
|
||||
<input
|
||||
type="text"
|
||||
{...orgForm.register('idNumber')}
|
||||
className={`w-full px-3 py-2 text-xs border rounded-xl focus:outline-none focus:ring-1 focus:ring-blue-500 text-white bg-[#090d16] font-mono ${
|
||||
orgForm.formState.errors.idNumber ? 'border-rose-500 ring-1 ring-rose-500' : 'border-slate-700/80'
|
||||
}`}
|
||||
placeholder="Enter 13-digit ID or Passport Number"
|
||||
/>
|
||||
{orgForm.formState.errors.idNumber && (
|
||||
<p className="text-[10px] text-rose-500 mt-1">{orgForm.formState.errors.idNumber.message}</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Inline Collapsible Subform to Create Executive User */}
|
||||
{isQuickAddUserOpen && !editingOrg && (
|
||||
<div className="p-3.5 rounded-xl border border-blue-500/30 bg-blue-950/20 space-y-3">
|
||||
@@ -2004,18 +2241,18 @@ export const GovernancePage: React.FC = () => {
|
||||
placement="top"
|
||||
/>
|
||||
</div>
|
||||
{selectedProgrammeForDetail && Number(selectedProgrammeForDetail.status) === ApprovalStatus.Approved ? (
|
||||
{editingProgramme && Number(editingProgramme.status) === ApprovalStatus.Approved ? (
|
||||
<div
|
||||
className="w-full px-3 py-2 text-xs border border-emerald-500/30 bg-emerald-500/10 rounded-xl text-emerald-300 font-semibold flex items-center gap-1.5 cursor-default"
|
||||
title="Approved programmes are immutable."
|
||||
title="Approved programmes cannot be modified."
|
||||
>
|
||||
<Lock className="w-3.5 h-3.5 text-emerald-400" />
|
||||
<span>Approved (Immutable - Cannot Be Changed)</span>
|
||||
<span>Approved</span>
|
||||
</div>
|
||||
) : (
|
||||
(() => {
|
||||
const canApproveProgramme = canApproveRecord('governance', selectedProgrammeForDetail?.createdBy);
|
||||
const isSelf = isMaker(selectedProgrammeForDetail?.createdBy);
|
||||
const canApproveProgramme = canApproveRecord('governance', editingProgramme?.createdBy);
|
||||
const isSelf = isMaker(editingProgramme?.createdBy);
|
||||
|
||||
return (
|
||||
<select
|
||||
@@ -2460,11 +2697,17 @@ export const GovernancePage: React.FC = () => {
|
||||
<span className="text-slate-300">{getOrganisationTypeLabel(orgFreshDetail?.type ?? selectedOrgForDetail?.type ?? OrganisationType.Enterprise)}</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-2 min-w-0">
|
||||
<span className="text-slate-400 font-semibold text-[11px] uppercase tracking-wider shrink-0">Linked User:</span>
|
||||
<span className="text-slate-400 font-semibold text-[11px] uppercase tracking-wider shrink-0">Executive User:</span>
|
||||
<span className="text-blue-400 font-mono text-[11px] truncate">
|
||||
{getUserEmail(orgFreshDetail?.userId || selectedOrgForDetail?.userId) || orgFreshDetail?.userId || selectedOrgForDetail?.userId || '—'}
|
||||
</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-2 min-w-0">
|
||||
<span className="text-slate-400 font-semibold text-[11px] uppercase tracking-wider shrink-0">Created By:</span>
|
||||
<span className="text-slate-300 font-mono text-[11px] truncate">
|
||||
{getUserEmail(orgFreshDetail?.createdBy || selectedOrgForDetail?.createdBy) || orgFreshDetail?.createdBy || selectedOrgForDetail?.createdBy || '—'}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -2717,13 +2960,38 @@ export const GovernancePage: React.FC = () => {
|
||||
<span className="text-slate-400 font-semibold block text-[11px] uppercase tracking-wider mb-1">Programme Title</span>
|
||||
<span className="text-white font-semibold text-sm block">{programmeFreshDetail?.name || selectedProgrammeForDetail?.name}</span>
|
||||
</div>
|
||||
<span className={`inline-flex items-center px-2.5 py-0.5 rounded-full text-[10px] font-semibold border whitespace-nowrap ${getApprovalStatusBadge(programmeFreshDetail?.status ?? selectedProgrammeForDetail?.status ?? ApprovalStatus.Approved).className}`}>
|
||||
{getApprovalStatusBadge(programmeFreshDetail?.status ?? selectedProgrammeForDetail?.status ?? ApprovalStatus.Approved).label}
|
||||
</span>
|
||||
<div className="flex items-center gap-2">
|
||||
<span className={`inline-flex items-center px-2.5 py-0.5 rounded-full text-[10px] font-semibold border whitespace-nowrap ${getApprovalStatusBadge(programmeFreshDetail?.status ?? selectedProgrammeForDetail?.status ?? ApprovalStatus.Approved).className}`}>
|
||||
{getApprovalStatusBadge(programmeFreshDetail?.status ?? selectedProgrammeForDetail?.status ?? ApprovalStatus.Approved).label}
|
||||
</span>
|
||||
{selectedProgrammeForDetail && Number(programmeFreshDetail?.status ?? selectedProgrammeForDetail.status) !== ApprovalStatus.Approved && (
|
||||
canApproveRecord('governance', selectedProgrammeForDetail.createdBy) ? (
|
||||
<button
|
||||
type="button"
|
||||
disabled={approveProgrammeMutation.isPending}
|
||||
onClick={async () => {
|
||||
await approveProgrammeMutation.mutateAsync(selectedProgrammeForDetail);
|
||||
setIsProgrammeDetailModalOpen(false);
|
||||
setSelectedProgrammeForDetail(null);
|
||||
}}
|
||||
className="inline-flex items-center gap-1 text-xs font-semibold text-emerald-300 hover:text-white bg-emerald-500/10 hover:bg-emerald-500/20 border border-emerald-500/30 px-2.5 py-1 rounded-lg transition shadow-xs cursor-pointer"
|
||||
>
|
||||
<CheckCircle className="w-3.5 h-3.5" />
|
||||
<span>Approve Programme</span>
|
||||
</button>
|
||||
) : (
|
||||
isMaker(selectedProgrammeForDetail.createdBy) && canApprove('governance') && (
|
||||
<span className="text-[10px] text-amber-400 font-mono italic px-1.5 py-0.5 bg-amber-500/10 border border-amber-500/20 rounded">
|
||||
Maker-Checker: Record creator cannot approve
|
||||
</span>
|
||||
)
|
||||
)
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Core Details Grid: Owner & Creation Date */}
|
||||
<div className="grid grid-cols-1 sm:grid-cols-2 gap-3 text-xs">
|
||||
{/* Core Details Grid: Owner, Maker, Creation Date */}
|
||||
<div className="grid grid-cols-1 sm:grid-cols-3 gap-3 text-xs">
|
||||
{/* Creation Date */}
|
||||
<div className="p-3 bg-[#080d1a] rounded-lg border border-slate-800 flex items-center gap-2.5">
|
||||
<Calendar className="w-4 h-4 text-slate-400 shrink-0" />
|
||||
@@ -2745,6 +3013,17 @@ export const GovernancePage: React.FC = () => {
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Maker */}
|
||||
<div className="p-3 bg-[#080d1a] rounded-lg border border-slate-800 flex items-center gap-2.5">
|
||||
<ShieldCheck className="w-4 h-4 text-slate-400 shrink-0" />
|
||||
<div className="min-w-0 flex-1">
|
||||
<span className="text-slate-500 font-medium block text-[10px] uppercase tracking-wider">Created By (Maker)</span>
|
||||
<span className="font-mono text-slate-200 text-xs truncate block" title={getUserEmail(programmeFreshDetail?.createdBy || selectedProgrammeForDetail?.createdBy)}>
|
||||
{getUserEmail(programmeFreshDetail?.createdBy || selectedProgrammeForDetail?.createdBy)}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Metrics: Total Rules and Total Instructions */}
|
||||
|
||||
@@ -47,6 +47,7 @@ import { ExportButton } from '../components/ui/ExportButton';
|
||||
import { ExportColumn } from '../utils/exportUtils';
|
||||
import { useTableSort, SortableTh } from '../utils/tableSort';
|
||||
import { useRbac } from '../auth/rbac';
|
||||
import { bumpMutationEpoch } from '../services/httpClient';
|
||||
import {
|
||||
Award,
|
||||
Budget,
|
||||
@@ -123,7 +124,7 @@ export const GrantsPage: React.FC = () => {
|
||||
const [isBudgetModalOpen, setIsBudgetModalOpen] = useState(false);
|
||||
const [editingBudget, setEditingBudget] = useState<Budget | null>(null);
|
||||
const [isEditBudgetModalOpen, setIsEditBudgetModalOpen] = useState(false);
|
||||
const [selectedBudgetStatus, setSelectedBudgetStatus] = useState<ApprovalStatus>(ApprovalStatus.Approved);
|
||||
const [selectedBudgetStatus, setSelectedBudgetStatus] = useState<ApprovalStatus>(ApprovalStatus.Pending);
|
||||
const [isDisbursementModalOpen, setIsDisbursementModalOpen] = useState(false);
|
||||
const [editingDisbursement, setEditingDisbursement] = useState<Disbursement | null>(null);
|
||||
|
||||
@@ -233,6 +234,7 @@ export const GrantsPage: React.FC = () => {
|
||||
const updateAwardMutation = useMutation({
|
||||
mutationFn: (data: UpdateAwardRequest) => updateAward(data),
|
||||
onSuccess: () => {
|
||||
bumpMutationEpoch();
|
||||
queryClient.invalidateQueries({ queryKey: ['awards'] });
|
||||
queryClient.invalidateQueries({ queryKey: ['dashboard'] });
|
||||
success('Award Updated', 'Award details saved successfully.');
|
||||
@@ -248,6 +250,7 @@ export const GrantsPage: React.FC = () => {
|
||||
const budgetMutation = useMutation({
|
||||
mutationFn: (data: CreateBudgetRequest) => createBudget(data),
|
||||
onSuccess: () => {
|
||||
bumpMutationEpoch();
|
||||
queryClient.invalidateQueries({ queryKey: ['budgets'] });
|
||||
success('Budget line established', 'Budget line created against project');
|
||||
setIsBudgetModalOpen(false);
|
||||
@@ -261,6 +264,7 @@ export const GrantsPage: React.FC = () => {
|
||||
const updateBudgetMutation = useMutation({
|
||||
mutationFn: (data: UpdateBudgetRequest) => updateBudget(data),
|
||||
onSuccess: () => {
|
||||
bumpMutationEpoch();
|
||||
queryClient.invalidateQueries({ queryKey: ['budgets'] });
|
||||
success('Budget Updated', 'Budget compliance and approval status updated.');
|
||||
setIsEditBudgetModalOpen(false);
|
||||
@@ -287,6 +291,7 @@ export const GrantsPage: React.FC = () => {
|
||||
});
|
||||
},
|
||||
onSuccess: () => {
|
||||
bumpMutationEpoch();
|
||||
queryClient.invalidateQueries({ queryKey: ['disbursements'] });
|
||||
success(
|
||||
editingDisbursement ? 'Disbursement Updated' : 'Disbursement Scheduled',
|
||||
@@ -497,6 +502,7 @@ export const GrantsPage: React.FC = () => {
|
||||
const approveMutation = useMutation({
|
||||
mutationFn: (id: string | number) => approveDisbursement(id),
|
||||
onSuccess: () => {
|
||||
bumpMutationEpoch();
|
||||
queryClient.invalidateQueries({ queryKey: ['disbursements'] });
|
||||
queryClient.invalidateQueries({ queryKey: ['dashboard'] });
|
||||
success(
|
||||
@@ -637,8 +643,14 @@ export const GrantsPage: React.FC = () => {
|
||||
handleSort: handleAwardSort,
|
||||
} = useTableSort(filteredAwards, null, null, {
|
||||
referenceNo: (a) => a.referenceNo,
|
||||
organisationId: (a) => a.organisationName || `Org #${a.organisationId}`,
|
||||
programmeId: (a) => a.programmeName || `Programme #${a.programmeId}`,
|
||||
organisationId: (a) => {
|
||||
const org = organisations.find((o) => String(o.id) === String(a.organisationId));
|
||||
return (org?.name || a.organisationName || `Organisation #${a.organisationId}`).toLowerCase();
|
||||
},
|
||||
programmeId: (a) => {
|
||||
const prog = programmes.find((p) => String(p.id) === String(a.programmeId));
|
||||
return (prog?.name || a.programmeName || `Programme #${a.programmeId}`).toLowerCase();
|
||||
},
|
||||
amount: (a) => a.amount,
|
||||
status: (a) => getApprovalStatusBadge(a.status).label,
|
||||
createdAt: (a) => a.createdAt,
|
||||
@@ -651,7 +663,10 @@ export const GrantsPage: React.FC = () => {
|
||||
handleSort: handleBudgetSort,
|
||||
} = useTableSort(filteredBudgets, null, null, {
|
||||
name: (b) => b.name,
|
||||
projectId: (b) => b.projectId,
|
||||
projectId: (b) => {
|
||||
const proj = projects.find((p) => String(p.id) === String(b.projectId));
|
||||
return (proj?.name || `Project #${b.projectId}`).toLowerCase();
|
||||
},
|
||||
amount: (b) => b.amount,
|
||||
createdAt: (b) => b.createdAt,
|
||||
status: (b) => getApprovalStatusBadge(b.status ?? ApprovalStatus.Approved).label,
|
||||
@@ -665,8 +680,15 @@ export const GrantsPage: React.FC = () => {
|
||||
handleSort: handleDisbSort,
|
||||
} = useTableSort(filteredDisbursements, null, null, {
|
||||
id: (d) => d.id,
|
||||
projectId: (d) => d.projectId,
|
||||
milestoneId: (d) => d.milestoneId || 0,
|
||||
projectId: (d) => {
|
||||
const proj = projects.find((p) => String(p.id) === String(d.projectId));
|
||||
return (proj?.name || `Project #${d.projectId}`).toLowerCase();
|
||||
},
|
||||
milestoneId: (d) => {
|
||||
if (!d.milestoneId) return 'Advance Tranche';
|
||||
const ms = milestones.find((m) => String(m.id) === String(d.milestoneId));
|
||||
return (ms?.name || `Milestone #${d.milestoneId}`).toLowerCase();
|
||||
},
|
||||
amount: (d) => d.amount,
|
||||
paymentReference: (d) => d.paymentReference || '',
|
||||
status: (d) => getApprovalStatusBadge(d.status).label,
|
||||
@@ -826,8 +848,10 @@ export const GrantsPage: React.FC = () => {
|
||||
<div className="flex items-center gap-2 self-end sm:self-center">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => {
|
||||
refetchAwards();
|
||||
onClick={async () => {
|
||||
bumpMutationEpoch();
|
||||
await queryClient.invalidateQueries({ queryKey: ['awards'] });
|
||||
await refetchAwards();
|
||||
success('Refreshed', 'Awards intake list refreshed from API.');
|
||||
}}
|
||||
disabled={isFetchingAwards}
|
||||
@@ -899,10 +923,28 @@ export const GrantsPage: React.FC = () => {
|
||||
<span className="text-[10px] text-slate-500 font-mono">System ID: #{awd.id}</span>
|
||||
</td>
|
||||
<td className="py-3.5 px-4 text-slate-200 font-medium">
|
||||
{awd.organisationName || `Org #${awd.organisationId}`}
|
||||
{(() => {
|
||||
const org = organisations.find((o) => String(o.id) === String(awd.organisationId));
|
||||
const orgName = org?.name || awd.organisationName || (awd.organisationId ? `Organisation #${awd.organisationId}` : '—');
|
||||
return (
|
||||
<div>
|
||||
<span className="text-white block font-medium">{orgName}</span>
|
||||
{awd.organisationId && <span className="text-[10px] text-slate-500 font-mono">ID: #{awd.organisationId}</span>}
|
||||
</div>
|
||||
);
|
||||
})()}
|
||||
</td>
|
||||
<td className="py-3.5 px-4 text-slate-400">
|
||||
{awd.programmeName || `Programme #${awd.programmeId}`}
|
||||
{(() => {
|
||||
const prog = programmes.find((p) => String(p.id) === String(awd.programmeId));
|
||||
const progName = prog?.name || awd.programmeName || (awd.programmeId ? `Programme #${awd.programmeId}` : '—');
|
||||
return (
|
||||
<div>
|
||||
<span className="text-slate-200 block">{progName}</span>
|
||||
{awd.programmeId && <span className="text-[10px] text-slate-500 font-mono">Programme #{awd.programmeId}</span>}
|
||||
</div>
|
||||
);
|
||||
})()}
|
||||
</td>
|
||||
<td className="py-3.5 px-4 text-right font-mono font-bold text-white text-xs">
|
||||
{formatZAR(awd.amount)}
|
||||
@@ -919,10 +961,10 @@ export const GrantsPage: React.FC = () => {
|
||||
{Number(awd.status) === ApprovalStatus.Approved ? (
|
||||
<span
|
||||
className="inline-flex items-center gap-1 text-xs font-semibold text-emerald-400 bg-emerald-500/10 px-2.5 py-1.5 rounded-lg border border-emerald-500/20 cursor-default"
|
||||
title="Approved award is immutable. Details cannot be modified."
|
||||
title="Approved award details cannot be modified."
|
||||
>
|
||||
<Lock className="w-3 h-3 text-emerald-400" />
|
||||
<span>Approved (Immutable)</span>
|
||||
<span>Approved</span>
|
||||
</span>
|
||||
) : (
|
||||
<button
|
||||
@@ -973,8 +1015,10 @@ export const GrantsPage: React.FC = () => {
|
||||
<div className="flex items-center gap-2 self-end sm:self-center">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => {
|
||||
refetchBudgets();
|
||||
onClick={async () => {
|
||||
bumpMutationEpoch();
|
||||
await queryClient.invalidateQueries({ queryKey: ['budgets'] });
|
||||
await refetchBudgets();
|
||||
success('Refreshed', 'Budget line categories refreshed from API.');
|
||||
}}
|
||||
disabled={isFetchingBudgets}
|
||||
@@ -1004,7 +1048,7 @@ export const GrantsPage: React.FC = () => {
|
||||
Budget Line Category
|
||||
</SortableTh>
|
||||
<SortableTh column="projectId" currentColumn={budgetSortCol} direction={budgetSortDir} onSort={handleBudgetSort} className="py-3.5 px-4">
|
||||
Project ID
|
||||
Project
|
||||
</SortableTh>
|
||||
<SortableTh column="amount" currentColumn={budgetSortCol} direction={budgetSortDir} onSort={handleBudgetSort} align="right" className="py-3.5 px-4 text-right">
|
||||
Approved Amount (ZAR)
|
||||
@@ -1043,7 +1087,18 @@ export const GrantsPage: React.FC = () => {
|
||||
<span className="font-semibold text-white block">{b.name}</span>
|
||||
<span className="text-[10px] text-slate-500 font-mono">ID: #{b.id}</span>
|
||||
</td>
|
||||
<td className="py-3.5 px-4 text-slate-400 font-mono">Project #{b.projectId}</td>
|
||||
<td className="py-3.5 px-4 text-slate-200">
|
||||
{(() => {
|
||||
const proj = projects.find((p) => String(p.id) === String(b.projectId));
|
||||
const projName = proj?.name || `Project #${b.projectId}`;
|
||||
return (
|
||||
<div>
|
||||
<span className="font-medium text-white block">{projName}</span>
|
||||
<span className="text-[10px] text-slate-500 font-mono">ID: #{b.projectId}</span>
|
||||
</div>
|
||||
);
|
||||
})()}
|
||||
</td>
|
||||
<td className="py-3.5 px-4 text-right font-mono font-bold text-white">
|
||||
{formatZAR(b.amount)}
|
||||
</td>
|
||||
@@ -1059,10 +1114,10 @@ export const GrantsPage: React.FC = () => {
|
||||
{Number(b.status) === ApprovalStatus.Approved ? (
|
||||
<span
|
||||
className="inline-flex items-center gap-1 text-xs font-semibold text-emerald-400 bg-emerald-500/10 px-2.5 py-1.5 rounded-lg border border-emerald-500/20 cursor-default"
|
||||
title="Approved budget is immutable. Status cannot be modified."
|
||||
title="Approved budget status cannot be modified."
|
||||
>
|
||||
<Lock className="w-3 h-3 text-emerald-400" />
|
||||
<span>Approved (Immutable)</span>
|
||||
<span>Approved</span>
|
||||
</span>
|
||||
) : (
|
||||
<>
|
||||
@@ -1133,8 +1188,10 @@ export const GrantsPage: React.FC = () => {
|
||||
<div className="flex items-center gap-2 self-end sm:self-center">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => {
|
||||
refetchDisbursements();
|
||||
onClick={async () => {
|
||||
bumpMutationEpoch();
|
||||
await queryClient.invalidateQueries({ queryKey: ['disbursements'] });
|
||||
await refetchDisbursements();
|
||||
success('Refreshed', 'Disbursement tranches refreshed from API.');
|
||||
}}
|
||||
disabled={isFetchingDisbursements}
|
||||
@@ -1172,7 +1229,7 @@ export const GrantsPage: React.FC = () => {
|
||||
Disbursement ID
|
||||
</SortableTh>
|
||||
<SortableTh column="projectId" currentColumn={disbSortCol} direction={disbSortDir} onSort={handleDisbSort} className="py-3.5 px-4">
|
||||
Project ID
|
||||
Project
|
||||
</SortableTh>
|
||||
<SortableTh column="milestoneId" currentColumn={disbSortCol} direction={disbSortDir} onSort={handleDisbSort} className="py-3.5 px-4">
|
||||
Milestone Association
|
||||
@@ -1211,9 +1268,32 @@ export const GrantsPage: React.FC = () => {
|
||||
<td className="py-3.5 px-5 font-mono font-bold text-white">
|
||||
DSB-{d.id}
|
||||
</td>
|
||||
<td className="py-3.5 px-4 text-slate-300 font-mono">Project #{d.projectId}</td>
|
||||
<td className="py-3.5 px-4 text-slate-200">
|
||||
{(() => {
|
||||
const proj = projects.find((p) => String(p.id) === String(d.projectId));
|
||||
const projName = proj?.name || `Project #${d.projectId}`;
|
||||
return (
|
||||
<div>
|
||||
<span className="font-medium text-white block">{projName}</span>
|
||||
<span className="text-[10px] text-slate-500 font-mono">Project ID: #{d.projectId}</span>
|
||||
</div>
|
||||
);
|
||||
})()}
|
||||
</td>
|
||||
<td className="py-3.5 px-4 text-slate-400 font-medium">
|
||||
{d.milestoneId ? `Milestone #${d.milestoneId}` : 'Initial Advance Tranche'}
|
||||
{(() => {
|
||||
if (!d.milestoneId) {
|
||||
return <span className="text-slate-400 italic">Initial Advance Tranche</span>;
|
||||
}
|
||||
const ms = milestones.find((m) => String(m.id) === String(d.milestoneId));
|
||||
const msName = ms?.name || `Milestone #${d.milestoneId}`;
|
||||
return (
|
||||
<div>
|
||||
<span className="font-medium block">{msName}</span>
|
||||
<span className="text-[10px] text-slate-500 font-mono">Milestone ID: #{d.milestoneId}</span>
|
||||
</div>
|
||||
);
|
||||
})()}
|
||||
</td>
|
||||
<td className="py-3.5 px-4 text-right font-mono font-bold text-white text-xs">
|
||||
{formatZAR(d.amount)}
|
||||
@@ -1549,7 +1629,14 @@ export const GrantsPage: React.FC = () => {
|
||||
onSubmit={editAwardForm.handleSubmit((values) => {
|
||||
if (!editingAward) return;
|
||||
if (Number(editingAward.status) === ApprovalStatus.Approved) {
|
||||
toastError('Record Immutable', 'Approved awards cannot be modified.');
|
||||
toastError('Record Approved', 'Approved awards cannot be modified.');
|
||||
return;
|
||||
}
|
||||
if (
|
||||
selectedAwardStatus === ApprovalStatus.Approved &&
|
||||
!canApproveRecord('grant_financials', editingAward.createdBy)
|
||||
) {
|
||||
toastError('Maker-Checker Violation', 'Record creators cannot approve their own award records.');
|
||||
return;
|
||||
}
|
||||
updateAwardMutation.mutate({
|
||||
@@ -1630,7 +1717,7 @@ export const GrantsPage: React.FC = () => {
|
||||
{Number(editingAward?.status) === ApprovalStatus.Approved ? (
|
||||
<div className="w-full px-3 py-2 text-xs border border-emerald-500/30 bg-emerald-500/10 rounded-xl text-emerald-300 font-semibold flex items-center gap-1.5 cursor-default">
|
||||
<Lock className="w-3.5 h-3.5 text-emerald-400" />
|
||||
<span>Approved (Immutable - Cannot Be Changed)</span>
|
||||
<span>Approved</span>
|
||||
</div>
|
||||
) : (
|
||||
<select
|
||||
@@ -1640,7 +1727,21 @@ export const GrantsPage: React.FC = () => {
|
||||
>
|
||||
<option value={ApprovalStatus.Pending}>Pending Approval</option>
|
||||
<option value={ApprovalStatus.Review}>Under Review</option>
|
||||
<option value={ApprovalStatus.Approved}>Approved & 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>
|
||||
</select>
|
||||
)}
|
||||
@@ -1783,7 +1884,7 @@ export const GrantsPage: React.FC = () => {
|
||||
{Number(editingBudget?.status) === ApprovalStatus.Approved ? (
|
||||
<div className="w-full px-3 py-2 text-xs border border-emerald-500/30 bg-emerald-500/10 rounded-xl text-emerald-300 font-semibold flex items-center gap-1.5 cursor-default">
|
||||
<Lock className="w-3.5 h-3.5 text-emerald-400" />
|
||||
<span>Approved (Immutable - Status Cannot Be Changed)</span>
|
||||
<span>Approved</span>
|
||||
</div>
|
||||
) : (
|
||||
<select
|
||||
|
||||
@@ -42,7 +42,6 @@ export const InboxPage: React.FC = () => {
|
||||
const { data: directoryUsers = [] } = useQuery<User[]>({
|
||||
queryKey: ['users-directory'],
|
||||
queryFn: () => getUsers(1, 200),
|
||||
staleTime: 5 * 60 * 1000,
|
||||
});
|
||||
|
||||
const {
|
||||
@@ -149,7 +148,11 @@ export const InboxPage: React.FC = () => {
|
||||
}
|
||||
}
|
||||
} else if (!selectedMessage && folderMessages.length > 0 && activeFolder !== 'drafts') {
|
||||
setSelectedMessage(folderMessages[0]);
|
||||
const first = folderMessages[0];
|
||||
setSelectedMessage(first);
|
||||
if (activeFolder === 'inbox' && Number(first.status) !== NotificationStatus.Read) {
|
||||
markAsRead(first.id);
|
||||
}
|
||||
}
|
||||
}, [targetIdParam, folderMessages, activeFolder]);
|
||||
|
||||
@@ -666,6 +669,22 @@ export const InboxPage: React.FC = () => {
|
||||
>
|
||||
<Star className={`w-3.5 h-3.5 ${isStarred ? 'fill-amber-400' : ''}`} />
|
||||
</button>
|
||||
|
||||
<button
|
||||
type="button"
|
||||
onClick={e => {
|
||||
e.stopPropagation();
|
||||
deleteMessage(item.id);
|
||||
if (selectedMessage?.id === item.id) {
|
||||
setSelectedMessage(null);
|
||||
}
|
||||
}}
|
||||
className="p-0.5 rounded text-slate-400 hover:text-rose-400 transition cursor-pointer"
|
||||
title="Delete message (Archive)"
|
||||
>
|
||||
<Trash2 className="w-3.5 h-3.5" />
|
||||
</button>
|
||||
|
||||
<span className="text-[10px] text-slate-400 font-mono">
|
||||
{formatDate(item.createdAt)}
|
||||
</span>
|
||||
|
||||
@@ -42,6 +42,7 @@ import {
|
||||
recordIndicatorActual,
|
||||
getBudgets,
|
||||
getActivities,
|
||||
getDisbursements,
|
||||
} from '../services/api';
|
||||
import { Modal } from '../components/ui/Modal';
|
||||
import { HelpTooltip } from '../components/ui/HelpTooltip';
|
||||
@@ -242,6 +243,11 @@ export const ProjectsPage: React.FC = () => {
|
||||
queryFn: () => getBudgets(1, 100),
|
||||
});
|
||||
|
||||
const { data: allDisbursements = [] } = useQuery({
|
||||
queryKey: ['disbursements'],
|
||||
queryFn: () => getDisbursements(1, 200),
|
||||
});
|
||||
|
||||
const { data: allProjectActivities = new Map<string | number, any[]>() } = useQuery({
|
||||
queryKey: ['allProjectActivities', projects.map((p) => p.id)],
|
||||
queryFn: async () => {
|
||||
@@ -311,6 +317,18 @@ export const ProjectsPage: React.FC = () => {
|
||||
return map;
|
||||
}, [allBudgets]);
|
||||
|
||||
// Disbursed capital per project
|
||||
const disbursedByProject = useMemo(() => {
|
||||
const map = new Map<string | number, number>();
|
||||
allDisbursements.forEach((d) => {
|
||||
if (Number(d.status) === ApprovalStatus.Approved || Number(d.status) === 3) {
|
||||
const current = map.get(d.projectId) || 0;
|
||||
map.set(d.projectId, current + (Number(d.amount) || 0));
|
||||
}
|
||||
});
|
||||
return map;
|
||||
}, [allDisbursements]);
|
||||
|
||||
// Filtered Projects
|
||||
const filteredProjects = useMemo(() => {
|
||||
return projects.filter((p) => {
|
||||
@@ -380,9 +398,22 @@ export const ProjectsPage: React.FC = () => {
|
||||
return amt > 0 ? formatZAR(amt) : 'R 0';
|
||||
},
|
||||
},
|
||||
{
|
||||
header: 'Project Variance (ZAR)',
|
||||
key: 'id',
|
||||
format: (_, p) => {
|
||||
const approvedBudget = approvedBudgetsByProject.get(p.id) || 0;
|
||||
const pActs = allProjectActivities.get(p.id) || [];
|
||||
const pActsTotal = pActs.reduce((sum, a) => sum + (Number(a.amount) || 0), 0);
|
||||
const pDisbursedTotal = disbursedByProject.get(p.id) || 0;
|
||||
const projectValue = pActsTotal - pDisbursedTotal;
|
||||
const pVariance = approvedBudget - projectValue;
|
||||
return `${pVariance >= 0 ? '+' : ''}${formatZAR(pVariance)}`;
|
||||
},
|
||||
},
|
||||
{ header: 'Approval Status', key: 'status', format: (val) => getApprovalStatusBadge(val).label },
|
||||
{ header: 'Created By', key: 'createdBy', format: (val) => getUserEmail(val) },
|
||||
], [programmeMap, milestonesByProject, approvedBudgetsByProject]);
|
||||
], [programmeMap, milestonesByProject, approvedBudgetsByProject, allProjectActivities, disbursedByProject]);
|
||||
|
||||
// Table Sorting
|
||||
const customExtractors = {
|
||||
@@ -404,6 +435,14 @@ export const ProjectsPage: React.FC = () => {
|
||||
if (risk.isAtRisk) return 2;
|
||||
return 3;
|
||||
},
|
||||
variance: (p: Project) => {
|
||||
const approvedBudget = approvedBudgetsByProject.get(p.id) || 0;
|
||||
const pActs = allProjectActivities.get(p.id) || [];
|
||||
const pActsTotal = pActs.reduce((sum, a) => sum + (Number(a.amount) || 0), 0);
|
||||
const pDisbursedTotal = disbursedByProject.get(p.id) || 0;
|
||||
const projectValue = pActsTotal - pDisbursedTotal;
|
||||
return approvedBudget - projectValue;
|
||||
},
|
||||
};
|
||||
|
||||
const { sortedData: sortedProjects, sortColumn, sortDirection, handleSort } = useTableSort(
|
||||
@@ -1007,17 +1046,17 @@ export const ProjectsPage: React.FC = () => {
|
||||
/>
|
||||
</div>
|
||||
</SortableTh>
|
||||
<th className="py-3.5 px-4 text-right font-semibold whitespace-nowrap w-36">
|
||||
<SortableTh column="variance" currentColumn={sortColumn} direction={sortDirection} onSort={handleSort} align="right" className="py-3.5 px-4 text-right whitespace-nowrap w-36">
|
||||
<div className="flex items-center justify-end gap-1">
|
||||
<span>Project Variance</span>
|
||||
<HelpTooltip
|
||||
title="Project Variance"
|
||||
content="Approved allocated budget minus scheduled milestone activity values. Positive (green) indicates within budget constraints; negative (red) indicates cost overrun."
|
||||
content="Approved allocated budget minus net project value (milestone activity commitments minus disbursements). Positive (green) indicates remaining budget headroom; negative (red) indicates cost overrun."
|
||||
size="sm"
|
||||
placement="bottom"
|
||||
/>
|
||||
</div>
|
||||
</th>
|
||||
</SortableTh>
|
||||
<th className="py-3.5 px-4 text-right font-semibold whitespace-nowrap w-56">Actions</th>
|
||||
</tr>
|
||||
</thead>
|
||||
@@ -1212,7 +1251,9 @@ export const ProjectsPage: React.FC = () => {
|
||||
const approvedBudget = approvedBudgetsByProject.get(p.id) || 0;
|
||||
const pActs = allProjectActivities.get(p.id) || [];
|
||||
const pActsTotal = pActs.reduce((sum, a) => sum + (Number(a.amount) || 0), 0);
|
||||
const pVariance = approvedBudget - pActsTotal;
|
||||
const pDisbursedTotal = disbursedByProject.get(p.id) || 0;
|
||||
const projectValue = pActsTotal - pDisbursedTotal;
|
||||
const pVariance = approvedBudget - projectValue;
|
||||
const isPos = pVariance >= 0;
|
||||
return (
|
||||
<td className="py-3.5 px-4 text-right whitespace-nowrap w-36 font-mono font-bold" onClick={(e) => e.stopPropagation()}>
|
||||
@@ -1222,7 +1263,7 @@ export const ProjectsPage: React.FC = () => {
|
||||
? 'text-emerald-400 bg-emerald-500/10 border-emerald-500/30'
|
||||
: 'text-rose-400 bg-rose-500/10 border-rose-500/30'
|
||||
}`}
|
||||
title={`Approved Budget: ${formatZAR(approvedBudget)} | Milestone Activities: ${formatZAR(pActsTotal)} | Variance: ${isPos ? '+' : ''}${formatZAR(pVariance)}`}
|
||||
title={`Approved Budget: ${formatZAR(approvedBudget)} | Milestone Activities: ${formatZAR(pActsTotal)} | Disbursements: ${formatZAR(pDisbursedTotal)} | Project Value: ${formatZAR(projectValue)} | Variance: ${isPos ? '+' : ''}${formatZAR(pVariance)}`}
|
||||
>
|
||||
{isPos ? `+${formatZAR(pVariance)}` : `-${formatZAR(Math.abs(pVariance))}`}
|
||||
</span>
|
||||
|
||||
@@ -57,6 +57,7 @@ import {
|
||||
getOrganisations,
|
||||
getAwards,
|
||||
getContracts,
|
||||
getProjects,
|
||||
getAuditLogs,
|
||||
getNotifications,
|
||||
notifyInviteApproved,
|
||||
@@ -79,6 +80,8 @@ import {
|
||||
NotificationStatus,
|
||||
NotificationPlatform,
|
||||
ActivityStatus,
|
||||
ApprovalStatus,
|
||||
ContractStatus,
|
||||
User,
|
||||
Invite,
|
||||
InviteRequest,
|
||||
@@ -110,7 +113,7 @@ export const SettingsPage: React.FC = () => {
|
||||
|
||||
const [activeTab, setActiveTab] = useState<'users' | 'invites' | 'notifications' | 'audit'>('users');
|
||||
|
||||
const { canView, canAdd, canExport } = useRbac();
|
||||
const { canView, canAdd, canExport, canApproveRecord, isMaker } = useRbac();
|
||||
const canViewUsers = canView('settings_users') || canView('tenant_self_service');
|
||||
const canViewInvites = canView('settings_invites');
|
||||
const canViewNotifications = canView('settings_notifications');
|
||||
@@ -152,7 +155,6 @@ export const SettingsPage: React.FC = () => {
|
||||
const { data: usersDirectory = [] } = useQuery({
|
||||
queryKey: ['users-directory'],
|
||||
queryFn: () => getUsers(1, 200),
|
||||
staleTime: 5 * 60 * 1000,
|
||||
});
|
||||
|
||||
const {
|
||||
@@ -241,6 +243,7 @@ export const SettingsPage: React.FC = () => {
|
||||
const [invitedOrgId, setInvitedOrgId] = useState<string | number>('');
|
||||
const [inviteAwardId, setInviteAwardId] = useState<string | number>('');
|
||||
const [inviteContractId, setInviteContractId] = useState<string | number>('');
|
||||
const [inviteProjectId, setInviteProjectId] = useState<string | number>('');
|
||||
const [inviteRecipientUuid, setInviteRecipientUuid] = useState('');
|
||||
const [inviteMessage, setInviteMessage] = useState('');
|
||||
const [invitePlatform, setInvitePlatform] = useState<NotificationPlatform>(NotificationPlatform.Email);
|
||||
@@ -253,6 +256,10 @@ export const SettingsPage: React.FC = () => {
|
||||
);
|
||||
|
||||
const handleApproveInvite = async (inv: Invite, idx: number) => {
|
||||
if (isMaker(inv.createdBy)) {
|
||||
toastError('Maker-Checker Violation', 'You cannot approve an invitation that you created.');
|
||||
return;
|
||||
}
|
||||
const inviteKey = inv.id ? String(inv.id) : `inv_${idx}`;
|
||||
markInviteAsApprovedInStorage(inviteKey, currentTenantId);
|
||||
setApprovedInviteIds((prev) => (prev.includes(inviteKey) ? prev : [...prev, inviteKey]));
|
||||
@@ -270,32 +277,6 @@ export const SettingsPage: React.FC = () => {
|
||||
);
|
||||
};
|
||||
|
||||
// Invites filtering & search
|
||||
const [inviteSearchTerm, setInviteSearchTerm] = useState('');
|
||||
const [invitePlatformFilter, setInvitePlatformFilter] = useState<'all' | number>('all');
|
||||
|
||||
const {
|
||||
data: invitesList = [],
|
||||
isLoading: loadingInvites,
|
||||
isFetching: isFetchingInvites,
|
||||
refetch: refetchInvites,
|
||||
} = useQuery({
|
||||
queryKey: ['invites'],
|
||||
queryFn: () => getInvites(1, 100),
|
||||
});
|
||||
|
||||
const filteredInvites = useMemo(() => {
|
||||
return invitesList.filter((inv) => {
|
||||
const matchSearch =
|
||||
!inviteSearchTerm.trim() ||
|
||||
(inv.recipient && inv.recipient.toLowerCase().includes(inviteSearchTerm.toLowerCase())) ||
|
||||
(inv.message && inv.message.toLowerCase().includes(inviteSearchTerm.toLowerCase()));
|
||||
const matchPlatform =
|
||||
invitePlatformFilter === 'all' || Number(inv.platform) === Number(invitePlatformFilter);
|
||||
return matchSearch && matchPlatform;
|
||||
});
|
||||
}, [invitesList, inviteSearchTerm, invitePlatformFilter]);
|
||||
|
||||
const { data: organisations = [] } = useQuery({
|
||||
queryKey: ['organisations'],
|
||||
queryFn: () => getOrganisations(1, 50),
|
||||
@@ -311,6 +292,104 @@ export const SettingsPage: React.FC = () => {
|
||||
queryFn: () => getContracts(1, 50),
|
||||
});
|
||||
|
||||
const { data: projects = [] } = useQuery({
|
||||
queryKey: ['projects'],
|
||||
queryFn: () => getProjects(1, 100),
|
||||
});
|
||||
|
||||
// Only awards that are Approved AND have an Approved/Active contract are eligible for invitations
|
||||
const eligibleAwards = useMemo(() => {
|
||||
return awards.filter((aw) => {
|
||||
const isApprovedAward = Number(aw.status) === ApprovalStatus.Approved;
|
||||
if (!isApprovedAward) return false;
|
||||
const hasApprovedContract = contracts.some(
|
||||
(c) =>
|
||||
String(c.awardId) === String(aw.id) &&
|
||||
(Number(c.status) === ContractStatus.Active || Number(c.status) === ContractStatus.Executed)
|
||||
);
|
||||
return hasApprovedContract;
|
||||
});
|
||||
}, [awards, contracts]);
|
||||
|
||||
const handleSelectAward = (awardId: string | number) => {
|
||||
setInviteAwardId(awardId);
|
||||
const selected = eligibleAwards.find((a) => String(a.id) === String(awardId));
|
||||
if (selected) {
|
||||
setInvitedOrgId(selected.organisationId);
|
||||
const matchingContract = contracts.find(
|
||||
(c) =>
|
||||
String(c.awardId) === String(selected.id) &&
|
||||
(Number(c.status) === ContractStatus.Active || Number(c.status) === ContractStatus.Executed)
|
||||
);
|
||||
if (matchingContract) {
|
||||
setInviteContractId(matchingContract.id);
|
||||
}
|
||||
setInviteProjectId(selected.projectId || '');
|
||||
}
|
||||
};
|
||||
|
||||
React.useEffect(() => {
|
||||
if (isSendInviteModalOpen) {
|
||||
if (eligibleAwards.length > 0) {
|
||||
const currentAward =
|
||||
eligibleAwards.find((a) => String(a.id) === String(inviteAwardId)) || eligibleAwards[0];
|
||||
setInviteAwardId(currentAward.id);
|
||||
setInvitedOrgId(currentAward.organisationId);
|
||||
const matchingContract = contracts.find(
|
||||
(c) =>
|
||||
String(c.awardId) === String(currentAward.id) &&
|
||||
(Number(c.status) === ContractStatus.Active || Number(c.status) === ContractStatus.Executed)
|
||||
);
|
||||
if (matchingContract) {
|
||||
setInviteContractId(matchingContract.id);
|
||||
}
|
||||
setInviteProjectId(currentAward.projectId || '');
|
||||
} else {
|
||||
setInviteAwardId('');
|
||||
setInvitedOrgId('');
|
||||
setInviteContractId('');
|
||||
setInviteProjectId('');
|
||||
}
|
||||
}
|
||||
}, [isSendInviteModalOpen, eligibleAwards, contracts]);
|
||||
|
||||
// Invites filtering & search
|
||||
const [inviteSearchTerm, setInviteSearchTerm] = useState('');
|
||||
const [invitePlatformFilter, setInvitePlatformFilter] = useState<'all' | number>('all');
|
||||
|
||||
const {
|
||||
data: invitesList = [],
|
||||
isLoading: loadingInvites,
|
||||
isFetching: isFetchingInvites,
|
||||
refetch: refetchInvites,
|
||||
} = useQuery({
|
||||
queryKey: ['invites'],
|
||||
queryFn: () => getInvites(1, 100),
|
||||
});
|
||||
|
||||
const getStoredInvitePlatform = (inviteId?: string | number): NotificationPlatform | undefined => {
|
||||
if (!inviteId) return undefined;
|
||||
try {
|
||||
const raw = localStorage.getItem(`pfm_invite_platform_${inviteId}`);
|
||||
return raw !== null ? (Number(raw) as NotificationPlatform) : undefined;
|
||||
} catch {
|
||||
return undefined;
|
||||
}
|
||||
};
|
||||
|
||||
const storeInvitePlatform = (inviteId: string | number, platform: NotificationPlatform) => {
|
||||
try {
|
||||
localStorage.setItem(`pfm_invite_platform_${inviteId}`, String(platform));
|
||||
} catch {}
|
||||
};
|
||||
|
||||
const resolveUserEmail = (userIdOrUuid?: string | null): string => {
|
||||
if (!userIdOrUuid) return '—';
|
||||
const u = usersDirectory.find((usr) => String(usr.id).toLowerCase() === String(userIdOrUuid).toLowerCase());
|
||||
const email = u?.email || getUserEmail(userIdOrUuid);
|
||||
return email && email !== '—' ? email : String(userIdOrUuid);
|
||||
};
|
||||
|
||||
const sendInviteMutation = useMutation({
|
||||
mutationFn: (payload: InviteRequest) => sendInvite(payload),
|
||||
onSuccess: (data, variables) => {
|
||||
@@ -320,8 +399,10 @@ export const SettingsPage: React.FC = () => {
|
||||
setInviteRecipientUuid('');
|
||||
setInviteMessage('');
|
||||
|
||||
const invId = (data as any)?.id ? String((data as any).id) : `inv_${Date.now()}`;
|
||||
storeInvitePlatform(invId, variables.platform);
|
||||
|
||||
if (inviteAutoApprove) {
|
||||
const invId = (data as any)?.id ? String((data as any).id) : `inv_${Date.now()}`;
|
||||
markInviteAsApprovedInStorage(invId, currentTenantId);
|
||||
setApprovedInviteIds((prev) => (prev.includes(invId) ? prev : [...prev, invId]));
|
||||
|
||||
@@ -333,6 +414,7 @@ export const SettingsPage: React.FC = () => {
|
||||
organisationId: variables.organisationId,
|
||||
awardId: variables.awardId,
|
||||
contractId: variables.contractId,
|
||||
projectId: variables.projectId,
|
||||
message: variables.message,
|
||||
},
|
||||
}).then(() => {
|
||||
@@ -418,6 +500,38 @@ export const SettingsPage: React.FC = () => {
|
||||
handleSort: handleNotifSort,
|
||||
} = useTableSort(filteredNotifications, 'createdAt', 'desc');
|
||||
|
||||
const resolveInvitePlatform = (inv: Invite): NotificationPlatform => {
|
||||
if (inv.platform !== undefined && inv.platform !== null) {
|
||||
return Number(inv.platform) as NotificationPlatform;
|
||||
}
|
||||
const stored = getStoredInvitePlatform(inv.id);
|
||||
if (stored !== undefined) return stored;
|
||||
|
||||
const linkedNotif = fetchedNotifications.find(
|
||||
(n) =>
|
||||
(inv.recipient && n.recipient?.toLowerCase() === inv.recipient?.toLowerCase()) ||
|
||||
(inv.invitedOrganisationId && String(n.organisationId) === String(inv.invitedOrganisationId))
|
||||
);
|
||||
if (linkedNotif?.platform !== undefined && linkedNotif.platform !== null) {
|
||||
return Number(linkedNotif.platform) as NotificationPlatform;
|
||||
}
|
||||
return NotificationPlatform.Email;
|
||||
};
|
||||
|
||||
const filteredInvites = useMemo(() => {
|
||||
return invitesList.filter((inv) => {
|
||||
const recipientEmail = resolveUserEmail(inv.recipient).toLowerCase();
|
||||
const matchSearch =
|
||||
!inviteSearchTerm.trim() ||
|
||||
(inv.recipient && inv.recipient.toLowerCase().includes(inviteSearchTerm.toLowerCase())) ||
|
||||
recipientEmail.includes(inviteSearchTerm.toLowerCase());
|
||||
const resolvedPlatform = resolveInvitePlatform(inv);
|
||||
const matchPlatform =
|
||||
invitePlatformFilter === 'all' || Number(resolvedPlatform) === Number(invitePlatformFilter);
|
||||
return matchSearch && matchPlatform;
|
||||
});
|
||||
}, [invitesList, inviteSearchTerm, invitePlatformFilter, fetchedNotifications, usersDirectory]);
|
||||
|
||||
// ==========================================
|
||||
// 4. Audit Log Ledger State
|
||||
// ==========================================
|
||||
@@ -486,9 +600,22 @@ export const SettingsPage: React.FC = () => {
|
||||
{
|
||||
header: 'Changes / Arguments',
|
||||
key: 'changes',
|
||||
format: (val) => (Array.isArray(val) ? val.join('; ') : val || '—'),
|
||||
format: (val) => {
|
||||
if (!val) return '—';
|
||||
let str = Array.isArray(val) ? val.join('; ') : typeof val === 'object' ? JSON.stringify(val) : String(val);
|
||||
str = str.replace(/\r\n|\r|\n/g, ' ').replace(/\s{2,}/g, ' ').trim();
|
||||
return str.length > 250 ? `${str.slice(0, 247)}...` : str;
|
||||
},
|
||||
},
|
||||
{
|
||||
header: 'Error Details',
|
||||
key: 'error',
|
||||
format: (val) => {
|
||||
if (!val) return '—';
|
||||
let str = String(val).replace(/\r\n|\r|\n/g, ' ').replace(/\s{2,}/g, ' ').trim();
|
||||
return str.length > 250 ? `${str.slice(0, 247)}...` : str;
|
||||
},
|
||||
},
|
||||
{ header: 'Error Details', key: 'error', format: (val) => val || '—' },
|
||||
];
|
||||
|
||||
const uniqueAuditEntities = Array.from(
|
||||
@@ -558,13 +685,25 @@ export const SettingsPage: React.FC = () => {
|
||||
];
|
||||
|
||||
const inviteExportColumns: ExportColumn<Invite>[] = [
|
||||
{ header: 'Recipient (UUID)', key: 'recipient' },
|
||||
{ header: 'Channel / Platform', key: 'platform', format: (val) => getNotificationPlatformLabel(val) },
|
||||
{ header: 'Invitation Message', key: 'message' },
|
||||
{
|
||||
header: 'Recipient Email',
|
||||
key: 'recipient',
|
||||
format: (val) => resolveUserEmail(val),
|
||||
},
|
||||
{
|
||||
header: 'Channel / Platform',
|
||||
key: 'platform',
|
||||
format: (_val, inv) => getNotificationPlatformLabel(resolveInvitePlatform(inv)),
|
||||
},
|
||||
{
|
||||
header: 'Target Organisation / Grant',
|
||||
key: 'invitedOrganisationId',
|
||||
format: (val, inv) => `Org #${val || inv.organisationId || '—'} (Award #${inv.awardId || '—'})`,
|
||||
format: (val, inv) => {
|
||||
const orgId = val || inv.organisationId;
|
||||
const org = organisations.find((o) => String(o.id) === String(orgId));
|
||||
const orgName = org?.name || `Organisation #${orgId || '—'}`;
|
||||
return `${orgName} • Award #${inv.awardId || '—'}`;
|
||||
},
|
||||
},
|
||||
{ header: 'Date Dispatched', key: 'createdAt', format: (val) => (val ? formatDate(val) : '—') },
|
||||
];
|
||||
@@ -590,7 +729,15 @@ export const SettingsPage: React.FC = () => {
|
||||
sortColumn: inviteSortCol,
|
||||
sortDirection: inviteSortDir,
|
||||
handleSort: handleInviteSort,
|
||||
} = useTableSort(filteredInvites, 'createdAt', 'desc');
|
||||
} = useTableSort(filteredInvites, 'createdAt', 'desc', {
|
||||
recipient: (inv: Invite) => resolveUserEmail(inv.recipient),
|
||||
platform: (inv: Invite) => getNotificationPlatformLabel(resolveInvitePlatform(inv)),
|
||||
invitedOrganisationId: (inv: Invite) => {
|
||||
const orgId = inv.invitedOrganisationId || inv.organisationId;
|
||||
const org = organisations.find((o) => String(o.id) === String(orgId));
|
||||
return org?.name || `Organisation #${orgId || ''}`;
|
||||
},
|
||||
});
|
||||
|
||||
const getActionBadge = (action?: string | null) => {
|
||||
const act = (action || 'GENERAL_EVENT').toUpperCase();
|
||||
@@ -1181,14 +1328,11 @@ export const SettingsPage: React.FC = () => {
|
||||
<thead>
|
||||
<tr className="bg-slate-900/60 border-b border-slate-800/80 text-slate-400 uppercase tracking-wider font-semibold">
|
||||
<SortableTh column="recipient" currentColumn={inviteSortCol} direction={inviteSortDir} onSort={handleInviteSort} className="py-3.5 px-5">
|
||||
Recipient (UUID)
|
||||
Recipient Email
|
||||
</SortableTh>
|
||||
<SortableTh column="platform" currentColumn={inviteSortCol} direction={inviteSortDir} onSort={handleInviteSort} className="py-3.5 px-4">
|
||||
Channel
|
||||
</SortableTh>
|
||||
<SortableTh column="message" currentColumn={inviteSortCol} direction={inviteSortDir} onSort={handleInviteSort} className="py-3.5 px-4">
|
||||
Message
|
||||
</SortableTh>
|
||||
<SortableTh column="invitedOrganisationId" currentColumn={inviteSortCol} direction={inviteSortDir} onSort={handleInviteSort} className="py-3.5 px-4">
|
||||
Target Org / Grant
|
||||
</SortableTh>
|
||||
@@ -1206,13 +1350,13 @@ export const SettingsPage: React.FC = () => {
|
||||
<tbody className="divide-y divide-slate-800/50">
|
||||
{loadingInvites ? (
|
||||
<tr>
|
||||
<td colSpan={7} className="py-8 text-center text-slate-500">
|
||||
<td colSpan={6} className="py-8 text-center text-slate-500">
|
||||
Loading invitations ledger...
|
||||
</td>
|
||||
</tr>
|
||||
) : sortedInvites.length === 0 ? (
|
||||
<tr>
|
||||
<td colSpan={7} className="py-8 text-center text-slate-500">
|
||||
<td colSpan={6} className="py-8 text-center text-slate-500">
|
||||
No invitations recorded yet. Use “Dispatch Invitation” to send one.
|
||||
</td>
|
||||
</tr>
|
||||
@@ -1222,22 +1366,34 @@ export const SettingsPage: React.FC = () => {
|
||||
approvedInviteIds.includes(String(inv.id)) ||
|
||||
approvedInviteIds.includes(`inv_${idx}`) ||
|
||||
approvedInviteIds.includes(String(idx));
|
||||
const resolvedPlatform = resolveInvitePlatform(inv);
|
||||
const userEmail = resolveUserEmail(inv.recipient);
|
||||
const orgId = inv.invitedOrganisationId || inv.organisationId;
|
||||
const org = organisations.find((o) => String(o.id) === String(orgId));
|
||||
const orgName = org?.name || `Organisation #${orgId || '—'}`;
|
||||
const isCreator = isMaker(inv.createdBy);
|
||||
|
||||
return (
|
||||
<tr key={idx} className="hover:bg-slate-800/40 transition-colors">
|
||||
<td className="py-3.5 px-5 font-mono text-slate-300 text-[11px]">
|
||||
{inv.recipient}
|
||||
<td className="py-3.5 px-5 text-slate-200 text-xs">
|
||||
<div className="font-medium text-white flex items-center gap-1.5">
|
||||
<Mail className="w-3.5 h-3.5 text-blue-400 shrink-0" />
|
||||
<span className="truncate">{userEmail}</span>
|
||||
</div>
|
||||
<div className="font-mono text-slate-500 text-[10px] truncate max-w-xs mt-0.5">
|
||||
{inv.recipient}
|
||||
</div>
|
||||
</td>
|
||||
<td className="py-3.5 px-4">
|
||||
<span className="inline-flex items-center px-2 py-0.5 rounded text-[10px] font-semibold bg-blue-500/10 text-blue-400 border border-blue-500/20">
|
||||
{getNotificationPlatformLabel(inv.platform)}
|
||||
{getNotificationPlatformLabel(resolvedPlatform)}
|
||||
</span>
|
||||
</td>
|
||||
<td className="py-3.5 px-4 text-white font-medium max-w-xs truncate">
|
||||
{inv.message}
|
||||
</td>
|
||||
<td className="py-3.5 px-4 text-slate-400 text-[11px]">
|
||||
Org #{inv.invitedOrganisationId || inv.organisationId} • Award #{inv.awardId}
|
||||
<td className="py-3.5 px-4 text-slate-300 text-xs">
|
||||
<div className="font-medium text-white">{orgName}</div>
|
||||
<div className="text-slate-400 text-[11px] mt-0.5">
|
||||
Award #{inv.awardId || '—'}
|
||||
</div>
|
||||
</td>
|
||||
<td className="py-3.5 px-4">
|
||||
{isApproved ? (
|
||||
@@ -1261,7 +1417,7 @@ export const SettingsPage: React.FC = () => {
|
||||
<Check className="w-3 h-3" />
|
||||
Partner Notified
|
||||
</span>
|
||||
) : (
|
||||
) : canApproveRecord('settings_invites', inv.createdBy) ? (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => handleApproveInvite(inv, idx)}
|
||||
@@ -1271,6 +1427,17 @@ export const SettingsPage: React.FC = () => {
|
||||
<Check className="w-3 h-3" />
|
||||
<span>Approve</span>
|
||||
</button>
|
||||
) : isCreator ? (
|
||||
<span
|
||||
className="inline-flex items-center gap-1 px-2 py-0.5 rounded text-[10px] font-semibold bg-amber-500/10 text-amber-400 border border-amber-500/20"
|
||||
title="Maker-Checker: You created this invite and cannot approve it"
|
||||
>
|
||||
Maker (Creator)
|
||||
</span>
|
||||
) : (
|
||||
<span className="text-[11px] text-slate-500">
|
||||
Pending Review
|
||||
</span>
|
||||
)}
|
||||
</td>
|
||||
</tr>
|
||||
@@ -2491,15 +2658,20 @@ export const SettingsPage: React.FC = () => {
|
||||
<form
|
||||
onSubmit={(e) => {
|
||||
e.preventDefault();
|
||||
if (!inviteAwardId) {
|
||||
toastError('Policy Violation', 'An approved award with an approved contract must be selected.');
|
||||
return;
|
||||
}
|
||||
if (!inviteRecipientUuid || !inviteMessage) {
|
||||
toastError('Validation Error', 'Recipient UUID and message content are mandatory.');
|
||||
toastError('Validation Error', 'Recipient identifier and message content are mandatory.');
|
||||
return;
|
||||
}
|
||||
sendInviteMutation.mutate({
|
||||
organisationId: inviteOrgId || (organisations[0]?.id ? Number(organisations[0].id) : 101),
|
||||
invitedOrganisationId: invitedOrgId || (organisations[1]?.id ? Number(organisations[1].id) : 102),
|
||||
awardId: inviteAwardId || (awards[0]?.id ? Number(awards[0].id) : 301),
|
||||
contractId: inviteContractId || (contracts[0]?.id ? Number(contracts[0].id) : 401),
|
||||
awardId: inviteAwardId,
|
||||
contractId: inviteContractId,
|
||||
projectId: inviteProjectId || 0,
|
||||
recipient: inviteRecipientUuid,
|
||||
message: inviteMessage,
|
||||
platform: invitePlatform,
|
||||
@@ -2507,18 +2679,51 @@ export const SettingsPage: React.FC = () => {
|
||||
}}
|
||||
className="space-y-4"
|
||||
>
|
||||
<div>
|
||||
<div className="flex items-center gap-1 mb-1.5">
|
||||
<label className="block text-xs font-semibold text-slate-300">
|
||||
Recipient User UUID *
|
||||
</label>
|
||||
<HelpTooltip
|
||||
title="Recipient UUID"
|
||||
content="Unique directory user identifier or email for targeted delivery."
|
||||
size="sm"
|
||||
placement="right"
|
||||
/>
|
||||
{eligibleAwards.length === 0 ? (
|
||||
<div className="p-3.5 rounded-xl bg-amber-500/10 border border-amber-500/30 text-amber-300 text-xs flex items-start gap-2.5">
|
||||
<RotateCcw className="w-4 h-4 shrink-0 text-amber-400 mt-0.5" />
|
||||
<div>
|
||||
<span className="font-semibold block text-white">No Approved Awards Eligible for Invitation</span>
|
||||
<span className="text-[11px] text-amber-300/90 leading-relaxed block mt-0.5">
|
||||
System governance policy strictly requires that invitations may only be dispatched for awards that have been formally <strong>Approved</strong> and whose associated contract is <strong>Approved & Active</strong>.
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
<div className="space-y-1.5">
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="flex items-center gap-1">
|
||||
<label className="block text-xs font-semibold text-slate-300">
|
||||
Recipient User *
|
||||
</label>
|
||||
<HelpTooltip
|
||||
title="Recipient Directory Identifier"
|
||||
content="Select a registered recipient user from directory or enter their unique user UUID."
|
||||
size="sm"
|
||||
placement="right"
|
||||
/>
|
||||
</div>
|
||||
{usersDirectory.length > 0 && (
|
||||
<span className="text-[10px] text-slate-400">Quick-select from registered users</span>
|
||||
)}
|
||||
</div>
|
||||
{usersDirectory.length > 0 && (
|
||||
<select
|
||||
value={usersDirectory.some((u) => String(u.id).toLowerCase() === inviteRecipientUuid.toLowerCase()) ? inviteRecipientUuid : ''}
|
||||
onChange={(e) => {
|
||||
if (e.target.value) setInviteRecipientUuid(e.target.value);
|
||||
}}
|
||||
className="w-full px-3 py-2 text-xs bg-slate-900/80 border border-slate-700/80 rounded-xl text-white focus:outline-none focus:ring-2 focus:ring-blue-500/50 [&>option]:bg-slate-900 [&>option]:text-white mb-1"
|
||||
>
|
||||
<option value="">-- Choose from Registered Users --</option>
|
||||
{usersDirectory.map((u) => (
|
||||
<option key={u.id} value={u.id}>
|
||||
{u.name ? `${u.name} — ` : ''}{u.email} ({u.id})
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
)}
|
||||
<input
|
||||
type="text"
|
||||
value={inviteRecipientUuid}
|
||||
@@ -2529,6 +2734,98 @@ export const SettingsPage: React.FC = () => {
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-2 gap-3">
|
||||
<div>
|
||||
<div className="flex items-center gap-1 mb-1.5">
|
||||
<label className="block text-xs font-semibold text-slate-300">
|
||||
Approved Award *
|
||||
</label>
|
||||
<HelpTooltip
|
||||
title="Approved Award Required"
|
||||
content="Only awards with status Approved and an Active/Approved contract are eligible for invitations."
|
||||
size="sm"
|
||||
placement="top"
|
||||
/>
|
||||
</div>
|
||||
<select
|
||||
value={inviteAwardId}
|
||||
onChange={(e) => handleSelectAward(e.target.value)}
|
||||
disabled={eligibleAwards.length === 0}
|
||||
className="w-full px-3 py-2 text-xs bg-slate-900/80 border border-slate-700/80 rounded-xl text-white focus:outline-none focus:ring-2 focus:ring-blue-500/50 [&>option]:bg-slate-900 [&>option]:text-white disabled:opacity-50"
|
||||
required
|
||||
>
|
||||
{eligibleAwards.length === 0 ? (
|
||||
<option value="">No approved awards available</option>
|
||||
) : (
|
||||
eligibleAwards.map((aw) => {
|
||||
const recipientOrg = organisations.find((o) => String(o.id) === String(aw.organisationId));
|
||||
return (
|
||||
<option key={aw.id} value={aw.id}>
|
||||
Award #{aw.id} — {recipientOrg?.name || `Org #${aw.organisationId}`}
|
||||
</option>
|
||||
);
|
||||
})
|
||||
)}
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<div className="flex items-center gap-1 mb-1.5">
|
||||
<label className="block text-xs font-semibold text-slate-300">
|
||||
Approved Contract *
|
||||
</label>
|
||||
<HelpTooltip
|
||||
title="Associated Approved Contract"
|
||||
content="The active/approved contract linked to the selected award."
|
||||
size="sm"
|
||||
placement="top"
|
||||
/>
|
||||
</div>
|
||||
<input
|
||||
type="text"
|
||||
value={inviteContractId ? `Contract #${inviteContractId} (Approved & Active)` : 'No active contract'}
|
||||
readOnly
|
||||
className="w-full px-3 py-2 text-xs bg-slate-900/50 border border-slate-800 rounded-xl text-slate-300 font-mono focus:outline-none cursor-not-allowed"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-2 gap-3">
|
||||
<div>
|
||||
<div className="flex items-center gap-1 mb-1.5">
|
||||
<label className="block text-xs font-semibold text-slate-300">
|
||||
Target Organisation (Recipient Org)
|
||||
</label>
|
||||
</div>
|
||||
<input
|
||||
type="text"
|
||||
value={(() => {
|
||||
const o = organisations.find((org) => String(org.id) === String(invitedOrgId));
|
||||
return o ? `${o.name} (#${o.id})` : invitedOrgId ? `Org #${invitedOrgId}` : '—';
|
||||
})()}
|
||||
readOnly
|
||||
className="w-full px-3 py-2 text-xs bg-slate-900/50 border border-slate-800 rounded-xl text-slate-300 focus:outline-none cursor-not-allowed"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<div className="flex items-center gap-1 mb-1.5">
|
||||
<label className="block text-xs font-semibold text-slate-300">
|
||||
Associated Project
|
||||
</label>
|
||||
</div>
|
||||
<input
|
||||
type="text"
|
||||
value={(() => {
|
||||
const prj = projects.find((p) => String(p.id) === String(inviteProjectId));
|
||||
return prj ? `${prj.name || 'Project'} (#${prj.id})` : inviteProjectId ? `Project #${inviteProjectId}` : 'Initial Award Project';
|
||||
})()}
|
||||
readOnly
|
||||
className="w-full px-3 py-2 text-xs bg-slate-900/50 border border-slate-800 rounded-xl text-slate-300 focus:outline-none cursor-not-allowed"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-2 gap-3">
|
||||
<div>
|
||||
<div className="flex items-center gap-1 mb-1.5">
|
||||
@@ -2558,7 +2855,7 @@ export const SettingsPage: React.FC = () => {
|
||||
<div>
|
||||
<div className="flex items-center gap-1 mb-1.5">
|
||||
<label className="block text-xs font-semibold text-slate-300">
|
||||
Originating Organisation
|
||||
Originating Authority
|
||||
</label>
|
||||
<HelpTooltip
|
||||
title="Inviting Authority"
|
||||
@@ -2581,58 +2878,6 @@ export const SettingsPage: React.FC = () => {
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-2 gap-3">
|
||||
<div>
|
||||
<div className="flex items-center gap-1 mb-1.5">
|
||||
<label className="block text-xs font-semibold text-slate-300">
|
||||
Associated Award
|
||||
</label>
|
||||
<HelpTooltip
|
||||
title="Linked Grant Award"
|
||||
content="Optional award attachment to link recipient permissions directly to grant administration."
|
||||
size="sm"
|
||||
placement="top"
|
||||
/>
|
||||
</div>
|
||||
<select
|
||||
value={inviteAwardId}
|
||||
onChange={(e) => setInviteAwardId(e.target.value)}
|
||||
className="w-full px-3 py-2 text-xs bg-slate-900/80 border border-slate-700/80 rounded-xl text-white focus:outline-none focus:ring-2 focus:ring-blue-500/50 [&>option]:bg-slate-900 [&>option]:text-white"
|
||||
>
|
||||
{awards.map((aw) => (
|
||||
<option key={aw.id} value={aw.id}>
|
||||
Award #{aw.id}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<div className="flex items-center gap-1 mb-1.5">
|
||||
<label className="block text-xs font-semibold text-slate-300">
|
||||
Associated Contract
|
||||
</label>
|
||||
<HelpTooltip
|
||||
title="Linked Contract"
|
||||
content="Optional bilateral agreement linking the recipient to contract execution."
|
||||
size="sm"
|
||||
placement="top"
|
||||
/>
|
||||
</div>
|
||||
<select
|
||||
value={inviteContractId}
|
||||
onChange={(e) => setInviteContractId(e.target.value)}
|
||||
className="w-full px-3 py-2 text-xs bg-slate-900/80 border border-slate-700/80 rounded-xl text-white focus:outline-none focus:ring-2 focus:ring-blue-500/50 [&>option]:bg-slate-900 [&>option]:text-white"
|
||||
>
|
||||
{contracts.map((ctr) => (
|
||||
<option key={ctr.id} value={ctr.id}>
|
||||
Contract #{ctr.id}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<div className="flex items-center gap-1 mb-1.5">
|
||||
<label className="block text-xs font-semibold text-slate-300">
|
||||
@@ -2681,7 +2926,7 @@ export const SettingsPage: React.FC = () => {
|
||||
</button>
|
||||
<button
|
||||
type="submit"
|
||||
disabled={sendInviteMutation.isPending}
|
||||
disabled={sendInviteMutation.isPending || eligibleAwards.length === 0}
|
||||
className="px-4 py-2 text-xs font-semibold text-white bg-gradient-to-r from-blue-600 to-indigo-600 hover:from-blue-500 hover:to-indigo-500 rounded-xl shadow-lg shadow-blue-500/20 transition disabled:opacity-50"
|
||||
>
|
||||
{sendInviteMutation.isPending ? 'Sending...' : 'Send Invitation'}
|
||||
|
||||
@@ -528,7 +528,7 @@ export const SiteVisitsPage: React.FC = () => {
|
||||
title="Approved site visits are immutable."
|
||||
>
|
||||
<Lock className="w-3.5 h-3.5 text-emerald-400" />
|
||||
<span>Approved & Verified (Immutable - Cannot Be Changed)</span>
|
||||
<span>Approved & Verified</span>
|
||||
</div>
|
||||
) : (
|
||||
<select
|
||||
|
||||
@@ -68,14 +68,12 @@ const TenantOrganisationsSubList: React.FC<{ tenantId: number | string; tenantNa
|
||||
} = useQuery<TenantOrganisation[]>({
|
||||
queryKey: ['tenant-organisations', tenantId],
|
||||
queryFn: () => getTenantOrganisations(tenantId),
|
||||
staleTime: 30000,
|
||||
});
|
||||
|
||||
// Fetch tenant users directory to resolve linked user email per organisation
|
||||
const { data: tenantUsers = [] } = useQuery<TenantUser[]>({
|
||||
queryKey: ['tenant-users', tenantId],
|
||||
queryFn: () => getTenantUsers(tenantId),
|
||||
staleTime: 5 * 60 * 1000,
|
||||
});
|
||||
|
||||
React.useEffect(() => {
|
||||
@@ -261,7 +259,6 @@ const TenantUsersSubList: React.FC<{
|
||||
const { data: tenantUsers = [], isLoading, refetch } = useQuery<TenantUser[]>({
|
||||
queryKey: ['tenant-users', tenantId],
|
||||
queryFn: () => getTenantUsers(tenantId),
|
||||
staleTime: 30000,
|
||||
});
|
||||
|
||||
const { canAdd } = useRbac();
|
||||
@@ -460,7 +457,6 @@ export const TenantManagementPage: React.FC = () => {
|
||||
} = useQuery<Tenant[]>({
|
||||
queryKey: ['tenants-list'],
|
||||
queryFn: () => getTenants(),
|
||||
staleTime: 30000,
|
||||
});
|
||||
|
||||
// Forms
|
||||
|
||||
@@ -259,7 +259,7 @@ export const updateOrganisation = async (payload: UpdateOrganisationRequest): Pr
|
||||
name: payload.name,
|
||||
registrationNo: payload.registrationNo,
|
||||
email: payload.email,
|
||||
idNumber: payload.idNumber,
|
||||
idNumber: payload.idNumber || null,
|
||||
organisationUser: payload.organisationUser,
|
||||
type: Number(payload.type),
|
||||
status: orgStatus,
|
||||
@@ -442,84 +442,21 @@ export const getProgrammeValue = async (programmeId: string | number): Promise<n
|
||||
}
|
||||
};
|
||||
|
||||
const LOCAL_INSTRUCTIONS_KEY = 'pfm_local_instructions';
|
||||
export const purgeStoredInstructionDuplicates = (_apiItems?: Instrument[]): void => {};
|
||||
|
||||
export const purgeStoredInstructionDuplicates = (apiItems: Instrument[]): void => {
|
||||
if (typeof window === 'undefined' || !Array.isArray(apiItems) || apiItems.length === 0) return;
|
||||
try {
|
||||
const raw = localStorage.getItem(LOCAL_INSTRUCTIONS_KEY);
|
||||
if (!raw) return;
|
||||
const local: Instrument[] = JSON.parse(raw);
|
||||
if (!Array.isArray(local) || local.length === 0) return;
|
||||
export const getStoredInstructions = (): Instrument[] => [];
|
||||
|
||||
const apiIds = new Set(apiItems.map((i) => String(i.id)));
|
||||
const apiKeys = new Set(apiItems.map((i) => `${i.programmeId}::${(i.name || '').toLowerCase().trim()}`));
|
||||
|
||||
const remaining = local.filter(
|
||||
(l) => !apiIds.has(String(l.id)) && !apiKeys.has(`${l.programmeId}::${(l.name || '').toLowerCase().trim()}`)
|
||||
);
|
||||
|
||||
if (remaining.length !== local.length) {
|
||||
localStorage.setItem(LOCAL_INSTRUCTIONS_KEY, JSON.stringify(remaining));
|
||||
}
|
||||
} catch (e) {
|
||||
console.warn('Failed to purge stored instruction duplicates', e);
|
||||
}
|
||||
};
|
||||
|
||||
export const getStoredInstructions = (): Instrument[] => {
|
||||
if (typeof window === 'undefined') return [];
|
||||
try {
|
||||
const raw = localStorage.getItem(LOCAL_INSTRUCTIONS_KEY);
|
||||
return raw ? JSON.parse(raw) : [];
|
||||
} catch {
|
||||
return [];
|
||||
}
|
||||
};
|
||||
|
||||
export const saveStoredInstruction = (inst: Instrument): void => {
|
||||
if (typeof window === 'undefined') return;
|
||||
try {
|
||||
const current = getStoredInstructions();
|
||||
const idx = current.findIndex(
|
||||
(i) => String(i.id) === String(inst.id) ||
|
||||
(String(i.programmeId) === String(inst.programmeId) && (i.name || '').trim().toLowerCase() === (inst.name || '').trim().toLowerCase())
|
||||
);
|
||||
if (idx >= 0) {
|
||||
current[idx] = { ...current[idx], ...inst, updatedAt: new Date().toISOString() };
|
||||
} else {
|
||||
current.unshift(inst);
|
||||
}
|
||||
localStorage.setItem(LOCAL_INSTRUCTIONS_KEY, JSON.stringify(current));
|
||||
} catch (e) {
|
||||
console.warn('Failed to save stored instruction', e);
|
||||
}
|
||||
};
|
||||
export const saveStoredInstruction = (_inst?: Instrument): void => {};
|
||||
|
||||
export const getInstruments = async (programmeId: string | number): Promise<Instrument[]> => {
|
||||
let apiItems: Instrument[] = [];
|
||||
let apiSucceeded = false;
|
||||
try {
|
||||
const hashedProgId = await toolsService.resolveProgrammeId(programmeId);
|
||||
const res = await apiClient.get<Instrument[]>(`/v1/api/governance/instruments/${hashedProgId}`);
|
||||
if (Array.isArray(res.data)) {
|
||||
apiItems = res.data;
|
||||
apiSucceeded = true;
|
||||
}
|
||||
return Array.isArray(res.data) ? res.data : [];
|
||||
} catch (err) {
|
||||
console.warn(`[getInstruments] Could not fetch instruments from API for prog ${programmeId}:`, err);
|
||||
return [];
|
||||
}
|
||||
|
||||
if (apiSucceeded) {
|
||||
purgeStoredInstructionDuplicates(apiItems);
|
||||
return apiItems;
|
||||
}
|
||||
|
||||
// Fallback to local items only when API failed
|
||||
const localItems = getStoredInstructions().filter(
|
||||
(item) => String(item.programmeId) === String(programmeId)
|
||||
);
|
||||
return localItems;
|
||||
};
|
||||
|
||||
export const getInstructions = getInstruments;
|
||||
@@ -535,27 +472,17 @@ export const getAllInstruments = async (programmesList?: Programme[]): Promise<I
|
||||
}
|
||||
|
||||
const map = new Map<string, Instrument>();
|
||||
let hasApiData = false;
|
||||
|
||||
if (progList && progList.length > 0) {
|
||||
const results = await Promise.allSettled(
|
||||
progList.map((pr) => getInstruments(pr.id))
|
||||
);
|
||||
results.forEach((res) => {
|
||||
if (res.status === 'fulfilled' && Array.isArray(res.value) && res.value.length > 0) {
|
||||
hasApiData = true;
|
||||
if (res.status === 'fulfilled' && Array.isArray(res.value)) {
|
||||
res.value.forEach((inst) => map.set(String(inst.id), inst));
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
if (hasApiData || map.size > 0) {
|
||||
const allApiInstruments = Array.from(map.values());
|
||||
purgeStoredInstructionDuplicates(allApiInstruments);
|
||||
return allApiInstruments;
|
||||
}
|
||||
|
||||
return getStoredInstructions();
|
||||
return Array.from(map.values());
|
||||
};
|
||||
|
||||
export const getAllInstructions = getAllInstruments;
|
||||
@@ -569,40 +496,8 @@ export const createInstrument = async (payload: CreateInstrumentRequest): Promis
|
||||
description: payload.description,
|
||||
};
|
||||
|
||||
try {
|
||||
const res = await apiClient.post<Instrument>('/v1/api/governance/instruments', body);
|
||||
if (res.data && res.data.id) {
|
||||
purgeStoredInstructionDuplicates([res.data]);
|
||||
return res.data;
|
||||
}
|
||||
} catch (err: any) {
|
||||
console.warn('[createInstrument] API create failed, falling back to local storage:', err);
|
||||
const newId = Date.now();
|
||||
const fallbackInstruction: Instrument = {
|
||||
id: newId,
|
||||
programmeId: toIntegerId(payload.programmeId),
|
||||
code: code,
|
||||
name: payload.name,
|
||||
description: payload.description,
|
||||
status: payload.status !== undefined ? Number(payload.status) : ActivityStatus.Active,
|
||||
createdBy: payload.userId || 'system',
|
||||
createdAt: new Date().toISOString(),
|
||||
};
|
||||
saveStoredInstruction(fallbackInstruction);
|
||||
return fallbackInstruction;
|
||||
}
|
||||
|
||||
// If API returned 200 without full body, return formatted item without saving duplicate to localStorage
|
||||
return {
|
||||
id: Date.now(),
|
||||
programmeId: toIntegerId(payload.programmeId),
|
||||
code: code,
|
||||
name: payload.name,
|
||||
description: payload.description,
|
||||
status: payload.status !== undefined ? Number(payload.status) : ActivityStatus.Active,
|
||||
createdBy: payload.userId || 'system',
|
||||
createdAt: new Date().toISOString(),
|
||||
};
|
||||
const res = await apiClient.post<Instrument>('/v1/api/governance/instruments', body);
|
||||
return res.data;
|
||||
};
|
||||
|
||||
export const createInstruction = createInstrument;
|
||||
@@ -619,32 +514,7 @@ export const updateInstrument = async (payload: UpdateInstrumentRequest): Promis
|
||||
status: Number(payload.status),
|
||||
};
|
||||
|
||||
try {
|
||||
const res = await apiClient.put<Instrument>('/v1/api/governance/instruments', body);
|
||||
if (res.data && res.data.id) {
|
||||
return res.data;
|
||||
}
|
||||
} catch (err) {
|
||||
console.warn('[updateInstrument] API update failed:', err);
|
||||
}
|
||||
|
||||
const existing = getStoredInstructions().find((i) => String(i.id) === String(targetId));
|
||||
const updatedInstrument: Instrument = {
|
||||
id: targetId!,
|
||||
programmeId: payload.programmeId ? toIntegerId(payload.programmeId) : (existing?.programmeId || 0),
|
||||
code: payload.code || existing?.code || null,
|
||||
name: payload.name,
|
||||
description: payload.description,
|
||||
status: Number(payload.status),
|
||||
createdBy: existing?.createdBy || 'system',
|
||||
createdAt: existing?.createdAt || new Date().toISOString(),
|
||||
updatedBy: payload.userId || 'system',
|
||||
updatedAt: new Date().toISOString(),
|
||||
};
|
||||
|
||||
if (existing) {
|
||||
saveStoredInstruction(updatedInstrument);
|
||||
}
|
||||
const res = await apiClient.put<Instrument>('/v1/api/governance/instruments', body);
|
||||
if (isApprovedOrActiveStatus(payload.status)) {
|
||||
notifyTenantManagersAndExecutivesOnRecordStatusChange({
|
||||
recordType: 'Funding Instrument',
|
||||
@@ -653,7 +523,7 @@ export const updateInstrument = async (payload: UpdateInstrumentRequest): Promis
|
||||
title: payload.name,
|
||||
}).catch(console.warn);
|
||||
}
|
||||
return updatedInstrument;
|
||||
return res.data;
|
||||
};
|
||||
|
||||
export const updateInstruction = updateInstrument;
|
||||
@@ -1359,13 +1229,13 @@ export const getMilestoneEvidence = async (milestoneId: string | number): Promis
|
||||
export const getProjectEvidence = async (projectId: string | number): Promise<Evidence[]> => {
|
||||
try {
|
||||
const hashedProjectId = await toolsService.resolveProjectId(projectId);
|
||||
const res = await apiClient.get<Evidence[]>(`/v1/api/evidence/${hashedProjectId}`);
|
||||
const res = await apiClient.get<Evidence[]>(`/v1/api/evidence/projects/${hashedProjectId}`);
|
||||
return res.data || [];
|
||||
} catch (err: any) {
|
||||
// If hashed path parameter returns 404 or 400 and projectId differs, attempt with raw ID
|
||||
if ((err?.response?.status === 404 || err?.response?.status === 400) && String(projectId) !== '') {
|
||||
try {
|
||||
const rawRes = await apiClient.get<Evidence[]>(`/v1/api/evidence/${projectId}`);
|
||||
const rawRes = await apiClient.get<Evidence[]>(`/v1/api/evidence/projects/${projectId}`);
|
||||
return rawRes.data || [];
|
||||
} catch (fallbackErr: any) {
|
||||
if (fallbackErr?.response?.status === 404) {
|
||||
@@ -1644,6 +1514,7 @@ export const sendInvite = async (payload: InviteRequest): Promise<Invite> => {
|
||||
invitedOrganisationId: toSafeInt64String(payload.invitedOrganisationId),
|
||||
awardId: toSafeInt64String(payload.awardId),
|
||||
contractId: toSafeInt64String(payload.contractId),
|
||||
projectId: toSafeInt64String(payload.projectId || 0),
|
||||
recipient: payload.recipient,
|
||||
message: payload.message,
|
||||
platform: payload.platform !== undefined ? Number(payload.platform) : 1,
|
||||
@@ -1731,7 +1602,9 @@ export const createNotification = async (payload: CreateNotificationRequest): Pr
|
||||
subject: payload.subject || null,
|
||||
destination: payload.destination || 'In-App User Inbox',
|
||||
message: payload.message,
|
||||
status: payload.status !== undefined ? payload.status : undefined,
|
||||
status: payload.status !== undefined
|
||||
? payload.status
|
||||
: (Number(payload.platform) === NotificationPlatform.InApp ? NotificationStatus.Delivered : undefined),
|
||||
});
|
||||
};
|
||||
|
||||
@@ -1753,6 +1626,13 @@ export const markNotificationAsRead = async (notificationId: number | string): P
|
||||
};
|
||||
|
||||
// ==========================================
|
||||
export const archiveNotification = async (notificationId: number | string): Promise<void> => {
|
||||
await updateNotification({
|
||||
notificationId: toIntegerId(notificationId),
|
||||
status: NotificationStatus.Archived,
|
||||
});
|
||||
};
|
||||
|
||||
// 9. Governance: Tenants APIs
|
||||
// ==========================================
|
||||
export const getTenants = async (): Promise<Tenant[]> => {
|
||||
@@ -2246,75 +2126,20 @@ export const updateSiteVisit = async (payload: UpdateSiteVisitRequest): Promise<
|
||||
}
|
||||
};
|
||||
|
||||
const LOCAL_RULES_KEY = 'pfm_local_rules';
|
||||
export const purgeStoredRuleDuplicates = (_apiRules?: Rule[]): void => {};
|
||||
|
||||
export const purgeStoredRuleDuplicates = (apiRules: Rule[]): void => {
|
||||
if (typeof window === 'undefined' || !Array.isArray(apiRules) || apiRules.length === 0) return;
|
||||
try {
|
||||
const raw = localStorage.getItem(LOCAL_RULES_KEY);
|
||||
if (!raw) return;
|
||||
const local: Rule[] = JSON.parse(raw);
|
||||
if (!Array.isArray(local) || local.length === 0) return;
|
||||
export const getStoredRules = (): Rule[] => [];
|
||||
|
||||
const apiIds = new Set(apiRules.map((r) => String(r.id)));
|
||||
const apiKeys = new Set(apiRules.map((r) => `${r.programmeId}::${(r.name || '').toLowerCase().trim()}`));
|
||||
|
||||
const remaining = local.filter(
|
||||
(l) => !apiIds.has(String(l.id)) && !apiKeys.has(`${l.programmeId}::${(l.name || '').toLowerCase().trim()}`)
|
||||
);
|
||||
|
||||
if (remaining.length !== local.length) {
|
||||
localStorage.setItem(LOCAL_RULES_KEY, JSON.stringify(remaining));
|
||||
}
|
||||
} catch (e) {
|
||||
console.warn('Failed to purge stored rule duplicates', e);
|
||||
}
|
||||
};
|
||||
|
||||
export const getStoredRules = (): Rule[] => {
|
||||
if (typeof window === 'undefined') return [];
|
||||
try {
|
||||
const raw = localStorage.getItem(LOCAL_RULES_KEY);
|
||||
return raw ? JSON.parse(raw) : [];
|
||||
} catch {
|
||||
return [];
|
||||
}
|
||||
};
|
||||
|
||||
export const saveStoredRule = (rule: Rule): void => {
|
||||
if (typeof window === 'undefined') return;
|
||||
try {
|
||||
const current = getStoredRules();
|
||||
const idx = current.findIndex(
|
||||
(r) => String(r.id) === String(rule.id) ||
|
||||
(String(r.programmeId) === String(rule.programmeId) && (r.name || '').trim().toLowerCase() === (rule.name || '').trim().toLowerCase())
|
||||
);
|
||||
if (idx >= 0) {
|
||||
current[idx] = { ...current[idx], ...rule, updatedAt: new Date().toISOString() };
|
||||
} else {
|
||||
current.unshift(rule);
|
||||
}
|
||||
localStorage.setItem(LOCAL_RULES_KEY, JSON.stringify(current));
|
||||
} catch (e) {
|
||||
console.warn('Failed to save stored rule', e);
|
||||
}
|
||||
};
|
||||
export const saveStoredRule = (_rule?: Rule): void => {};
|
||||
|
||||
// ==========================================
|
||||
// 15. Execution: Programme Rules APIs
|
||||
// ==========================================
|
||||
export const getRule = async (ruleId: string | number): Promise<Rule> => {
|
||||
try {
|
||||
const hashedRuleId = await toolsService.encodeId(ruleId);
|
||||
const res = await apiClient.get<Rule>(`/v1/api/execution/rules/${hashedRuleId}`);
|
||||
if (res.data) {
|
||||
purgeStoredRuleDuplicates([res.data]);
|
||||
return res.data;
|
||||
}
|
||||
} catch {
|
||||
const localRule = getStoredRules().find((r) => String(r.id) === String(ruleId));
|
||||
if (localRule) return localRule;
|
||||
throw new Error('Rule not found');
|
||||
const hashedRuleId = await toolsService.encodeId(ruleId);
|
||||
const res = await apiClient.get<Rule>(`/v1/api/execution/rules/${hashedRuleId}`);
|
||||
if (res.data) {
|
||||
return res.data;
|
||||
}
|
||||
throw new Error('Rule not found');
|
||||
};
|
||||
@@ -2332,38 +2157,16 @@ export const getRules = async (
|
||||
pagination: Pagination = { page: 1, pageSize: 100 },
|
||||
programmeId?: string | number
|
||||
): Promise<Rule[]> => {
|
||||
let apiRules: Rule[] = [];
|
||||
let apiSucceeded = false;
|
||||
try {
|
||||
const res = await apiClient.post<Rule[]>('/v1/api/execution/programmes/rules', {
|
||||
index: pagination.index ? toIntegerId(pagination.index) : 0,
|
||||
page: pagination.page || 1,
|
||||
pageSize: pagination.pageSize || 100,
|
||||
});
|
||||
if (Array.isArray(res.data)) {
|
||||
apiRules = res.data;
|
||||
apiSucceeded = true;
|
||||
}
|
||||
} catch (err) {
|
||||
console.warn('[getRules] Error fetching from backend API:', err);
|
||||
}
|
||||
|
||||
if (apiSucceeded) {
|
||||
purgeStoredRuleDuplicates(apiRules);
|
||||
let result = apiRules;
|
||||
if (programmeId != null && programmeId !== '' && programmeId !== 'all') {
|
||||
result = result.filter((r) => String(r.programmeId) === String(programmeId));
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
// Fallback to local storage only if API call failed
|
||||
const localRules = getStoredRules();
|
||||
let combined = localRules;
|
||||
const res = await apiClient.post<Rule[]>('/v1/api/execution/programmes/rules', {
|
||||
index: pagination.index ? toIntegerId(pagination.index) : 0,
|
||||
page: pagination.page || 1,
|
||||
pageSize: pagination.pageSize || 100,
|
||||
});
|
||||
let result = Array.isArray(res.data) ? res.data : [];
|
||||
if (programmeId != null && programmeId !== '' && programmeId !== 'all') {
|
||||
combined = combined.filter((r) => String(r.programmeId) === String(programmeId));
|
||||
result = result.filter((r) => String(r.programmeId) === String(programmeId));
|
||||
}
|
||||
return combined;
|
||||
return result;
|
||||
};
|
||||
|
||||
export const createRule = async (payload: CreateRuleRequest): Promise<Rule> => {
|
||||
@@ -2375,70 +2178,21 @@ export const createRule = async (payload: CreateRuleRequest): Promise<Rule> => {
|
||||
description: payload.description,
|
||||
};
|
||||
|
||||
try {
|
||||
const res = await apiClient.post<Rule>('/v1/api/execution/rules', body);
|
||||
if (res.data && res.data.id) {
|
||||
purgeStoredRuleDuplicates([res.data]);
|
||||
return res.data;
|
||||
}
|
||||
} catch (err) {
|
||||
console.warn('[createRule] API create failed, falling back to local storage:', err);
|
||||
const newId = Date.now();
|
||||
const fallbackRule: Rule = {
|
||||
id: newId,
|
||||
programmeId: toIntegerId(payload.programmeId),
|
||||
name: payload.name,
|
||||
description: payload.description,
|
||||
status: ActivityStatus.Active,
|
||||
version: 1,
|
||||
createdBy: validUserId,
|
||||
createdAt: new Date().toISOString(),
|
||||
};
|
||||
saveStoredRule(fallbackRule);
|
||||
return fallbackRule;
|
||||
}
|
||||
|
||||
return {
|
||||
id: Date.now(),
|
||||
programmeId: toIntegerId(payload.programmeId),
|
||||
name: payload.name,
|
||||
description: payload.description,
|
||||
status: ActivityStatus.Active,
|
||||
version: 1,
|
||||
createdBy: validUserId,
|
||||
createdAt: new Date().toISOString(),
|
||||
};
|
||||
const res = await apiClient.post<Rule>('/v1/api/execution/rules', body);
|
||||
return res.data;
|
||||
};
|
||||
|
||||
export const updateRule = async (payload: UpdateRuleRequest): Promise<void> => {
|
||||
const validUserId = toValidUuid(payload.userId);
|
||||
try {
|
||||
await apiClient.put('/v1/api/execution/rules', {
|
||||
ruleId: toIntegerId(payload.ruleId),
|
||||
userId: validUserId,
|
||||
name: payload.name,
|
||||
description: payload.description,
|
||||
status: Number(payload.status),
|
||||
version: Number(payload.version || 1),
|
||||
...(payload.programmeId ? { programmeId: toIntegerId(payload.programmeId) } : {}),
|
||||
});
|
||||
} catch (err) {
|
||||
console.warn('[updateRule] API update failed:', err);
|
||||
const existing = getStoredRules().find((r) => String(r.id) === String(payload.ruleId));
|
||||
const updatedRule: Rule = {
|
||||
id: payload.ruleId,
|
||||
programmeId: payload.programmeId ? toIntegerId(payload.programmeId) : (existing?.programmeId || 0),
|
||||
name: payload.name,
|
||||
description: payload.description,
|
||||
status: Number(payload.status),
|
||||
version: Number(payload.version || 1),
|
||||
createdBy: existing?.createdBy || validUserId,
|
||||
createdAt: existing?.createdAt || new Date().toISOString(),
|
||||
updatedBy: validUserId,
|
||||
updatedAt: new Date().toISOString(),
|
||||
};
|
||||
saveStoredRule(updatedRule);
|
||||
}
|
||||
await apiClient.put('/v1/api/execution/rules', {
|
||||
ruleId: toIntegerId(payload.ruleId),
|
||||
userId: validUserId,
|
||||
name: payload.name,
|
||||
description: payload.description,
|
||||
status: Number(payload.status),
|
||||
version: Number(payload.version || 1),
|
||||
...(payload.programmeId ? { programmeId: toIntegerId(payload.programmeId) } : {}),
|
||||
});
|
||||
};
|
||||
|
||||
// ==========================================
|
||||
|
||||
@@ -60,6 +60,8 @@ export const apiClient = axios.create({
|
||||
headers: {
|
||||
'Content-Type': 'application/json; version=1',
|
||||
Accept: 'application/json; version=1',
|
||||
'Cache-Control': 'no-cache, no-store, must-revalidate',
|
||||
Pragma: 'no-cache',
|
||||
},
|
||||
timeout: 15000,
|
||||
});
|
||||
@@ -81,34 +83,16 @@ apiClient.interceptors.request.use(
|
||||
config.url?.includes('amazonaws.com') ||
|
||||
config.url?.includes('garage');
|
||||
|
||||
// Attach mutation epoch or explicit timestamp to GET requests for OutputCache management
|
||||
// Always attach freshness timestamp and mutation epoch to internal GET requests
|
||||
// to guarantee zero stale data, bypass browser HTTP caches, and prevent stale OutputCache hits
|
||||
if (config.method?.toUpperCase() === 'GET' && !isExternal) {
|
||||
const headers = config.headers || {};
|
||||
const hasNoCacheHeader =
|
||||
headers['Cache-Control'] === 'no-cache' ||
|
||||
headers['cache-control'] === 'no-cache' ||
|
||||
headers['Pragma'] === 'no-cache';
|
||||
|
||||
if (hasNoCacheHeader) {
|
||||
// Explicit freshness requested: attach fresh timestamp
|
||||
if (config.params instanceof URLSearchParams) {
|
||||
config.params.set('_t', String(Date.now()));
|
||||
} else {
|
||||
config.params = config.params || {};
|
||||
config.params._t = Date.now();
|
||||
}
|
||||
if (config.params instanceof URLSearchParams) {
|
||||
config.params.set('_t', String(Date.now()));
|
||||
config.params.set('_v', String(getMutationEpoch()));
|
||||
} else {
|
||||
// Standard GET: attach current mutation epoch
|
||||
if (config.params instanceof URLSearchParams) {
|
||||
if (!config.params.has('_v') && !config.params.has('_t')) {
|
||||
config.params.set('_v', String(getMutationEpoch()));
|
||||
}
|
||||
} else {
|
||||
config.params = config.params || {};
|
||||
if (!config.params._v && !config.params._t) {
|
||||
config.params._v = getMutationEpoch();
|
||||
}
|
||||
}
|
||||
config.params = config.params || {};
|
||||
config.params._t = Date.now();
|
||||
config.params._v = getMutationEpoch();
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -10,15 +10,8 @@ export interface TenantLockoutResult {
|
||||
statusCode?: number;
|
||||
}
|
||||
|
||||
let cachedTenantLockout: {
|
||||
key: string;
|
||||
result: TenantLockoutResult;
|
||||
timestamp: number;
|
||||
} | null = null;
|
||||
const LOCKOUT_CACHE_TTL_MS = 60 * 1000;
|
||||
|
||||
export function clearTenantLockoutCache(): void {
|
||||
cachedTenantLockout = null;
|
||||
// Caching removed to guarantee fresh tenant state on every verification
|
||||
}
|
||||
|
||||
export function isStatusActive(status: any): boolean {
|
||||
@@ -88,15 +81,7 @@ export async function verifyTenantLockout(
|
||||
candidateId = tenantClaim;
|
||||
}
|
||||
|
||||
const cacheKey = String(candidateId ?? tenantClaim ?? 'default-1').trim().toLowerCase();
|
||||
if (!forceRefresh && cachedTenantLockout && cachedTenantLockout.key === cacheKey) {
|
||||
if (
|
||||
cachedTenantLockout.result.isActive &&
|
||||
Date.now() - cachedTenantLockout.timestamp < LOCKOUT_CACHE_TTL_MS
|
||||
) {
|
||||
return cachedTenantLockout.result;
|
||||
}
|
||||
}
|
||||
// Fresh check: query endpoint directly without cache
|
||||
|
||||
let tenantRes: Tenant | null = null;
|
||||
let lastError: any = null;
|
||||
@@ -209,10 +194,6 @@ export async function verifyTenantLockout(
|
||||
isActive: true,
|
||||
tenant: tenantObj,
|
||||
};
|
||||
cachedTenantLockout = {
|
||||
key: cacheKey,
|
||||
result: successResult,
|
||||
timestamp: Date.now(),
|
||||
};
|
||||
// Direct return without caching
|
||||
return successResult;
|
||||
}
|
||||
|
||||
@@ -29,6 +29,7 @@ export enum NotificationStatus {
|
||||
Cancelled = 8,
|
||||
Bounced = 9,
|
||||
Draft = 10,
|
||||
Archived = 11,
|
||||
}
|
||||
|
||||
export enum NotificationPlatform {
|
||||
@@ -685,11 +686,13 @@ export interface CreateContractRequest {
|
||||
|
||||
export interface UpdateContractRequest {
|
||||
contractId: number | string;
|
||||
projectId?: number | string;
|
||||
status: ContractStatus;
|
||||
}
|
||||
|
||||
export interface ExecuteContractRequest {
|
||||
contractId: number | string;
|
||||
projectId?: number | string;
|
||||
externalSignatureId: string;
|
||||
signedDocumentUrl: string;
|
||||
}
|
||||
@@ -838,6 +841,7 @@ export interface Invite {
|
||||
invitedOrganisationId: number | string;
|
||||
awardId: number | string;
|
||||
contractId: number | string;
|
||||
projectId?: number | string;
|
||||
recipient: string;
|
||||
createdBy: string;
|
||||
updatedBy?: string | null;
|
||||
@@ -852,6 +856,7 @@ export interface InviteRequest {
|
||||
invitedOrganisationId: number | string;
|
||||
awardId: number | string;
|
||||
contractId: number | string;
|
||||
projectId?: number | string;
|
||||
recipient: string;
|
||||
message: string;
|
||||
platform: NotificationPlatform;
|
||||
|
||||
@@ -146,6 +146,10 @@ export async function exportToSpreadsheet<T>(options: ExportOptions<T>): Promise
|
||||
} else {
|
||||
formatted = rawVal ?? '';
|
||||
}
|
||||
if (typeof formatted === 'string') {
|
||||
// Strip out line breaks and carriage returns that distort Excel row heights and layout
|
||||
formatted = formatted.replace(/\r\n|\r|\n/g, ' ').replace(/\s{2,}/g, ' ').trim();
|
||||
}
|
||||
rowObj[col.header] = formatted;
|
||||
});
|
||||
return rowObj;
|
||||
@@ -166,9 +170,10 @@ export async function exportToSpreadsheet<T>(options: ExportOptions<T>): Promise
|
||||
} else {
|
||||
formatted = String(rawVal ?? '');
|
||||
}
|
||||
formatted = formatted.replace(/\r\n|\r|\n/g, ' ').replace(/\s{2,}/g, ' ').trim();
|
||||
return Math.max(max, formatted.length);
|
||||
}, headerLen);
|
||||
return { wch: Math.min(Math.max(maxDataLen + 4, 12), 50) };
|
||||
return { wch: Math.min(Math.max(maxDataLen + 4, 12), 45) };
|
||||
});
|
||||
worksheet['!cols'] = colWidths;
|
||||
|
||||
|
||||
@@ -33,8 +33,8 @@ public sealed class EvidenceConfiguration : IEntityTypeConfiguration<Evidence>
|
||||
builder.Property(f => f.IndicatorId).IsRequired();
|
||||
builder.Property(f => f.ActivityId).IsRequired();
|
||||
builder.Property(f => f.CreatedBy).IsRequired();
|
||||
builder.Property(f => f.UpdatedBy).IsRequired();
|
||||
builder.Property(f => f.CreatedAt).IsRequired().HasDefaultValueSql("now()");
|
||||
builder.Property(f => f.UpdatedBy).IsRequired(false);
|
||||
builder.Property(f => f.UpdatedAt).IsRequired(false);
|
||||
builder.Property(f => f.Version).IsRequired().HasDefaultValue(1);
|
||||
builder.Property(f => f.DocumentUrl).IsRequired();
|
||||
|
||||
@@ -24,6 +24,7 @@ public enum NotificationStatus : int
|
||||
Cancelled = 8,
|
||||
Bounced = 9,
|
||||
Draft = 10,
|
||||
Archived = 11,
|
||||
}
|
||||
|
||||
public enum NotificationPlatform : int
|
||||
|
||||
+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);
|
||||
}
|
||||
}
|
||||
}
|
||||
+1
-1
@@ -604,7 +604,7 @@ namespace PostFundManagement.Infrastructure.Database.Migrations
|
||||
b.Property<DateTime?>("UpdatedAt")
|
||||
.HasColumnType("timestamp with time zone");
|
||||
|
||||
b.Property<Guid>("UpdatedBy")
|
||||
b.Property<Guid?>("UpdatedBy")
|
||||
.HasColumnType("uuid");
|
||||
|
||||
b.Property<int>("Version")
|
||||
|
||||
@@ -1,9 +1,9 @@
|
||||
# GEMINI_API_KEY: Required for Gemini AI API calls.
|
||||
# AI Studio automatically injects this at runtime from user secrets.
|
||||
# Users configure this via the Secrets panel in the AI Studio UI.
|
||||
GEMINI_API_KEY="MY_GEMINI_API_KEY"
|
||||
|
||||
# APP_URL: The URL where this applet is hosted.
|
||||
# AI Studio automatically injects this at runtime with the Cloud Run service URL.
|
||||
# Used for self-referential links, OAuth callbacks, and API endpoints.
|
||||
APP_URL="MY_APP_URL"
|
||||
|
||||
# HasherSettings for HashIds compatibility with backend pfm-api
|
||||
VITE_HASHER_MIN_LENGTH="11"
|
||||
VITE_HASHER_SALT="TGlnaHQgQ2hhcm1zLCBNaWRyYW5kQm9va3MgbWFrZSBsb3RzIG9mIG1vbmV5IGFuZCBhcmUgYSBzdWNjZXNzZnVsIHZpcmFsIHN0b3J5IGluIFNvdXRoIEFmcmljYQ=="
|
||||
|
||||
@@ -29,17 +29,15 @@ OIDC_CLIENT_ID="${VITE_OIDC_CLIENT_ID:-${OIDC_CLIENT_ID:-${SecuritySettings__Cli
|
||||
# 5. OIDC Scope
|
||||
OIDC_SCOPE="${VITE_OIDC_SCOPE:-${OIDC_SCOPE:-${SecuritySettings__Scope:-${SecuritySettings_Scope:-${SCOPE:-}}}}}"
|
||||
|
||||
# 6. Hasher Settings
|
||||
HASHER_MIN_LENGTH="${VITE_HASHER_MIN_LENGTH:-${HASHER_MIN_LENGTH:-${HasherSettings__MinLength:-${HasherSettings_MinLength:-}}}}}"
|
||||
# 6. Hasher Settings (Fix: Exactly 4 closing braces matching the 4 open variable expansions)
|
||||
HASHER_MIN_LENGTH="${VITE_HASHER_MIN_LENGTH:-${HASHER_MIN_LENGTH:-${HasherSettings__MinLength:-${HasherSettings_MinLength:-11}}}}"
|
||||
|
||||
if [ -d "$TARGET_DIR" ]; then
|
||||
echo "[docker-entrypoint] Generating runtime configuration files in $TARGET_DIR..."
|
||||
|
||||
# Generate env-config.js for instant, synchronous window.__RUNTIME_CONFIG__ initialization
|
||||
# Public SPA clients use Authorization Code flow with PKCE; client secrets and hasher salts must NEVER be injected or exposed here.
|
||||
# Public SPA clients: salt secrets and client secrets must NEVER be injected or exposed in public static files.
|
||||
cat <<EOF > "$TARGET_DIR/env-config.js"
|
||||
// Generated dynamically at container startup from deployment environment variables
|
||||
// Note: Secrets such as client secrets, private keys, or hashing salts must NEVER be exposed here.
|
||||
window.__RUNTIME_CONFIG__ = {
|
||||
API_BASE_URL: "${API_BASE_URL}",
|
||||
VITE_API_BASE_URL: "${API_BASE_URL}",
|
||||
|
||||
@@ -10,6 +10,6 @@
|
||||
"BaseUrl": ""
|
||||
},
|
||||
"HasherSettings": {
|
||||
"MinHashLength": ""
|
||||
"MinHashLength": "11"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1 +1,9 @@
|
||||
window.__RUNTIME_CONFIG__ = window.__RUNTIME_CONFIG__ || {};
|
||||
// Runtime configuration for development or container environments
|
||||
// Note: Secret values (like hashing salt or client secrets) are not exposed here.
|
||||
window.__RUNTIME_CONFIG__ = window.__RUNTIME_CONFIG__ || {
|
||||
HASHER_MIN_LENGTH: "11",
|
||||
VITE_HASHER_MIN_LENGTH: "11",
|
||||
HasherSettings: {
|
||||
MinHashLength: "11"
|
||||
}
|
||||
};
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -1,23 +1,33 @@
|
||||
import React, { useState } from 'react';
|
||||
import { api, extractApiErrors } from '../../services/api';
|
||||
import { Activity, Milestone, Project, EvidenceType } from '../../services/api/models';
|
||||
import {
|
||||
X,
|
||||
UploadCloud,
|
||||
FileText,
|
||||
CheckCircle2,
|
||||
AlertTriangle,
|
||||
Loader2,
|
||||
Plus,
|
||||
import React, { useState, useEffect } from 'react';
|
||||
import { createPortal } from 'react-dom';
|
||||
import { useQuery } from '@tanstack/react-query';
|
||||
import {
|
||||
uploadEvidenceFile,
|
||||
extractApiErrors,
|
||||
toIntegerId,
|
||||
getProjectIndicators,
|
||||
recordIndicatorActual,
|
||||
} from '../../services/api';
|
||||
import { Activity, Milestone, Project, Indicator } from '../../services/api/models';
|
||||
import { EVIDENCE_TYPE_OPTIONS } from '../../lib/formatters';
|
||||
import {
|
||||
X,
|
||||
UploadCloud,
|
||||
FileText,
|
||||
CheckCircle2,
|
||||
AlertTriangle,
|
||||
Loader2,
|
||||
Plus,
|
||||
Sparkles,
|
||||
Link as LinkIcon
|
||||
Target,
|
||||
BarChart3,
|
||||
} from 'lucide-react';
|
||||
|
||||
interface EvidenceUploadModalProps {
|
||||
isOpen: boolean;
|
||||
onClose: () => void;
|
||||
project: Project;
|
||||
milestone?: Milestone;
|
||||
milestone?: Milestone | null;
|
||||
activity: Activity;
|
||||
onEvidenceUploaded: () => void;
|
||||
}
|
||||
@@ -32,14 +42,45 @@ export const EvidenceUploadModal: React.FC<EvidenceUploadModalProps> = ({
|
||||
}) => {
|
||||
const [selectedFile, setSelectedFile] = useState<File | null>(null);
|
||||
const [title, setTitle] = useState('');
|
||||
const [documentUrl, setDocumentUrl] = useState('');
|
||||
const [evidenceType, setEvidenceType] = useState<EvidenceType>(1); // Invoice / Document
|
||||
const [evidenceType, setEvidenceType] = useState<number>(4); // Default: FinancialInvoiceOrReceipt
|
||||
const [description, setDescription] = useState('');
|
||||
const [selectedIndicatorId, setSelectedIndicatorId] = useState<string>('');
|
||||
const [actualAmount, setActualAmount] = useState<string>('');
|
||||
const [isSubmitting, setIsSubmitting] = useState(false);
|
||||
const [errorMessages, setErrorMessages] = useState<string[]>([]);
|
||||
const [successMessage, setSuccessMessage] = useState<string | null>(null);
|
||||
|
||||
if (!isOpen) return null;
|
||||
const projectId = project?.id || 0;
|
||||
|
||||
// Requirement #4: Fetch linked indicators for project & activity
|
||||
const { data: projectIndicators = [], isLoading: isLoadingIndicators } = useQuery<Indicator[]>({
|
||||
queryKey: ['evidence-project-indicators', String(projectId)],
|
||||
queryFn: () => getProjectIndicators(projectId),
|
||||
enabled: isOpen && Boolean(projectId),
|
||||
});
|
||||
|
||||
// Prioritize indicators directly linked to this activity, then project indicators
|
||||
const availableIndicators = React.useMemo(() => {
|
||||
if (!projectIndicators || projectIndicators.length === 0) return [];
|
||||
const directMatches = projectIndicators.filter(
|
||||
ind => ind.activityId && String(ind.activityId) === String(activity.id)
|
||||
);
|
||||
if (directMatches.length > 0) return directMatches;
|
||||
return projectIndicators;
|
||||
}, [projectIndicators, activity.id]);
|
||||
|
||||
// Auto-select first indicator if available
|
||||
useEffect(() => {
|
||||
if (availableIndicators.length > 0 && !selectedIndicatorId) {
|
||||
const firstId = String(availableIndicators[0].id);
|
||||
setSelectedIndicatorId(firstId);
|
||||
if (availableIndicators[0].actualAmount !== undefined) {
|
||||
setActualAmount(String(availableIndicators[0].actualAmount || ''));
|
||||
}
|
||||
}
|
||||
}, [availableIndicators, selectedIndicatorId]);
|
||||
|
||||
if (!isOpen || typeof document === 'undefined') return null;
|
||||
|
||||
const handleFileChange = (e: React.ChangeEvent<HTMLInputElement>) => {
|
||||
if (e.target.files && e.target.files[0]) {
|
||||
@@ -51,66 +92,94 @@ export const EvidenceUploadModal: React.FC<EvidenceUploadModalProps> = ({
|
||||
}
|
||||
};
|
||||
|
||||
const selectedIndicatorObj = availableIndicators.find(
|
||||
i => String(i.id) === String(selectedIndicatorId)
|
||||
);
|
||||
|
||||
const handleSubmit = async (e: React.FormEvent) => {
|
||||
e.preventDefault();
|
||||
setErrorMessages([]);
|
||||
setSuccessMessage(null);
|
||||
|
||||
if (!title.trim() && !selectedFile && !documentUrl.trim()) {
|
||||
setErrorMessages(['Please provide an evidence title, file upload, or document URL.']);
|
||||
if (!selectedFile) {
|
||||
setErrorMessages(['A supporting document file is required. Please choose a file to upload.']);
|
||||
return;
|
||||
}
|
||||
|
||||
const numProjectId = toIntegerId(project.id);
|
||||
const numActivityId = toIntegerId(activity.id);
|
||||
const numMilestoneId = toIntegerId(milestone?.id || activity.milestoneId || 0);
|
||||
const numIndicatorId = toIntegerId(selectedIndicatorId);
|
||||
|
||||
// Requirement #4: IndicatorId must not be 0 to maintain referential integrity in database
|
||||
if (!numIndicatorId || numIndicatorId <= 0) {
|
||||
setErrorMessages([
|
||||
'A valid linked Indicator is mandatory for evidence verification. Please select an indicator from the list.',
|
||||
]);
|
||||
return;
|
||||
}
|
||||
|
||||
if (!actualAmount.trim() || isNaN(Number(actualAmount))) {
|
||||
setErrorMessages([
|
||||
'Recording an Actual amount/metric against the selected indicator is mandatory. Please provide a numeric value.',
|
||||
]);
|
||||
return;
|
||||
}
|
||||
|
||||
if (!numProjectId || !numActivityId) {
|
||||
setErrorMessages(['Cannot upload evidence: Missing projectId or activityId reference.']);
|
||||
return;
|
||||
}
|
||||
|
||||
setIsSubmitting(true);
|
||||
|
||||
try {
|
||||
// Build upload payload
|
||||
// 1. Build multipart/form-data adhering strictly to EvidenceService.CreateEvidenceAsync
|
||||
// Requirement #7: When uploading, IForm does not need the userId field in it; API detects userId from JWT sub
|
||||
const formData = new FormData();
|
||||
if (selectedFile) {
|
||||
formData.append('file', selectedFile);
|
||||
}
|
||||
formData.append('title', title.trim() || selectedFile?.name || 'Evidence Deliverable');
|
||||
formData.append('projectId', String(project.id || ''));
|
||||
if (milestone?.id) formData.append('milestoneId', String(milestone.id));
|
||||
if (activity?.id) formData.append('activityId', String(activity.id));
|
||||
formData.append('type', String(evidenceType));
|
||||
if (documentUrl.trim()) formData.append('documentUrl', documentUrl.trim());
|
||||
if (description.trim()) formData.append('description', description.trim());
|
||||
|
||||
// Also support JSON payload fallback if server expects JSON body
|
||||
try {
|
||||
await api.evidence.createEvidenceFile(formData);
|
||||
} catch (err: any) {
|
||||
// Fallback: Attempt JSON payload if multipart failed or route expects JSON
|
||||
const jsonPayload = {
|
||||
projectId: project.id,
|
||||
milestoneId: milestone?.id,
|
||||
activityId: activity.id,
|
||||
title: title.trim() || selectedFile?.name || 'Evidence Deliverable',
|
||||
type: evidenceType,
|
||||
documentUrl: documentUrl.trim() || (selectedFile ? `s3://evidence/${selectedFile.name}` : undefined),
|
||||
description: description.trim(),
|
||||
};
|
||||
await api.evidence.createEvidenceFile(jsonPayload);
|
||||
formData.append('file', selectedFile);
|
||||
formData.append('projectId', String(numProjectId));
|
||||
formData.append('activityId', String(numActivityId));
|
||||
formData.append('milestoneId', String(numMilestoneId));
|
||||
formData.append('indicatorId', String(numIndicatorId));
|
||||
formData.append('type', String(Number(evidenceType) || 4));
|
||||
formData.append('title', title.trim() || selectedFile.name);
|
||||
if (description.trim()) {
|
||||
formData.append('description', description.trim());
|
||||
}
|
||||
|
||||
setSuccessMessage('Evidence successfully uploaded and linked to activity!');
|
||||
// 2. Upload evidence file
|
||||
await uploadEvidenceFile(formData);
|
||||
|
||||
// 3. Requirement #4: Record Actual against linked indicator
|
||||
const parsedActual = Number(actualAmount);
|
||||
if (!isNaN(parsedActual)) {
|
||||
try {
|
||||
await recordIndicatorActual(numIndicatorId, parsedActual);
|
||||
} catch (indErr) {
|
||||
console.warn('[Record Indicator Actual Warning]:', indErr);
|
||||
}
|
||||
}
|
||||
|
||||
setSuccessMessage('Evidence successfully uploaded and linked to activity with recorded indicator actual!');
|
||||
setTimeout(() => {
|
||||
onEvidenceUploaded();
|
||||
onClose();
|
||||
}, 1000);
|
||||
}, 800);
|
||||
} catch (err: any) {
|
||||
console.error('[Evidence Upload Error]:', err);
|
||||
const errors = extractApiErrors(err);
|
||||
setErrorMessages(errors);
|
||||
setErrorMessages(
|
||||
errors.length > 0 ? errors : ['Failed to upload evidence file. Please verify parameters and try again.']
|
||||
);
|
||||
} finally {
|
||||
setIsSubmitting(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="fixed inset-0 z-50 flex items-center justify-center p-4 bg-slate-950/80 backdrop-blur-md animate-in fade-in duration-200">
|
||||
<div className="relative w-full max-w-lg rounded-3xl border border-white/15 bg-slate-900/95 p-6 shadow-2xl backdrop-blur-2xl text-slate-100 overflow-hidden">
|
||||
return createPortal(
|
||||
<div className="fixed inset-0 z-[9999] flex items-center justify-center p-4 bg-slate-950/80 backdrop-blur-md animate-in fade-in duration-200 overflow-y-auto">
|
||||
<div className="relative w-full max-w-lg rounded-3xl border border-white/15 bg-slate-900/95 p-6 shadow-2xl backdrop-blur-2xl text-slate-100 overflow-hidden my-auto">
|
||||
{/* Ambient background glow */}
|
||||
<div className="absolute top-0 right-0 -mr-16 -mt-16 w-60 h-60 bg-emerald-500/10 rounded-full blur-3xl pointer-events-none" />
|
||||
|
||||
@@ -124,6 +193,7 @@ export const EvidenceUploadModal: React.FC<EvidenceUploadModalProps> = ({
|
||||
<h3 className="text-base font-bold text-white">Upload Activity Deliverable</h3>
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
onClick={onClose}
|
||||
className="p-1.5 rounded-xl hover:bg-white/10 text-slate-400 hover:text-white transition-colors cursor-pointer"
|
||||
>
|
||||
@@ -146,12 +216,12 @@ export const EvidenceUploadModal: React.FC<EvidenceUploadModalProps> = ({
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Error Alerts (Comma-delimited error list from ProblemDetails) */}
|
||||
{/* Error Alerts */}
|
||||
{errorMessages.length > 0 && (
|
||||
<div className="mb-4 p-3.5 rounded-2xl bg-rose-950/60 border border-rose-500/30 text-rose-200 text-xs space-y-1">
|
||||
<div className="flex items-center gap-1.5 font-bold text-rose-300">
|
||||
<AlertTriangle className="w-4 h-4 text-rose-400 shrink-0" />
|
||||
<span>Validation Notice ({errorMessages.length} issue{errorMessages.length > 1 ? 's' : ''}):</span>
|
||||
<span>Upload Notice ({errorMessages.length} issue{errorMessages.length > 1 ? 's' : ''}):</span>
|
||||
</div>
|
||||
<ul className="list-disc pl-5 space-y-0.5 text-[11px] text-rose-200/90 font-mono">
|
||||
{errorMessages.map((msg, idx) => (
|
||||
@@ -173,10 +243,13 @@ export const EvidenceUploadModal: React.FC<EvidenceUploadModalProps> = ({
|
||||
<form onSubmit={handleSubmit} className="space-y-4 text-xs">
|
||||
{/* File Dropzone */}
|
||||
<div>
|
||||
<label className="block text-slate-300 font-medium mb-1.5">Evidence File (Document, Receipt, Report)</label>
|
||||
<label className="block text-slate-300 font-medium mb-1.5">
|
||||
Evidence File (Document, Invoice, Certificate, Photo) <span className="text-emerald-400">*</span>
|
||||
</label>
|
||||
<div className="relative border-2 border-dashed border-white/15 hover:border-emerald-500/50 rounded-2xl p-4 text-center bg-white/[0.02] hover:bg-emerald-500/[0.02] transition-colors cursor-pointer">
|
||||
<input
|
||||
type="file"
|
||||
required
|
||||
onChange={handleFileChange}
|
||||
className="absolute inset-0 w-full h-full opacity-0 cursor-pointer"
|
||||
/>
|
||||
@@ -193,23 +266,88 @@ export const EvidenceUploadModal: React.FC<EvidenceUploadModalProps> = ({
|
||||
</div>
|
||||
) : (
|
||||
<div>
|
||||
<span className="font-medium text-slate-300">Click to upload file</span>
|
||||
<p className="text-[11px] text-slate-500 mt-0.5">PDF, DOCX, XLSX, PNG, JPG (Max 25MB)</p>
|
||||
<span className="font-medium text-slate-300">Click to choose document</span>
|
||||
<p className="text-[11px] text-slate-500 mt-0.5">
|
||||
PDF, DOCX, XLSX, PNG, JPG (Multiple uploads supported)
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Requirement #4: Mandatory Indicator Selection & Actual Amount Recording */}
|
||||
<div className="p-3.5 rounded-2xl bg-slate-950/80 border border-emerald-500/30 space-y-3">
|
||||
<div className="flex items-center gap-2 text-emerald-400 font-semibold text-xs">
|
||||
<Target className="w-4 h-4" />
|
||||
<span>Linked Indicator & Performance Actual (Mandatory)</span>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="block text-slate-300 font-medium mb-1">
|
||||
Select Linked Indicator <span className="text-emerald-400">*</span>
|
||||
</label>
|
||||
{isLoadingIndicators ? (
|
||||
<div className="text-[11px] text-slate-400 py-1">Loading indicators...</div>
|
||||
) : availableIndicators.length === 0 ? (
|
||||
<div className="text-[11px] text-amber-300 bg-amber-950/40 border border-amber-500/30 p-2.5 rounded-xl">
|
||||
No indicators currently recorded for this project. Please contact programme management or select standard output.
|
||||
</div>
|
||||
) : (
|
||||
<select
|
||||
required
|
||||
value={selectedIndicatorId}
|
||||
onChange={e => setSelectedIndicatorId(e.target.value)}
|
||||
className="w-full px-3 py-2 rounded-xl bg-slate-900 border border-white/10 text-slate-100 focus:outline-none focus:border-emerald-500/50 cursor-pointer"
|
||||
>
|
||||
<option value="">-- Choose Indicator for this Deliverable --</option>
|
||||
{availableIndicators.map(ind => (
|
||||
<option key={ind.id} value={ind.id} className="bg-slate-900 text-slate-100">
|
||||
{ind.name || `Indicator #${ind.id}`} (Target: {ind.targetAmount} • Baseline: {ind.baselineAmount})
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="block text-slate-300 font-medium mb-1">
|
||||
Actual Amount / Metric Recorded <span className="text-emerald-400">*</span>
|
||||
</label>
|
||||
<div className="relative">
|
||||
<input
|
||||
type="number"
|
||||
step="any"
|
||||
min="0"
|
||||
required
|
||||
placeholder={
|
||||
selectedIndicatorObj
|
||||
? `Target is ${selectedIndicatorObj.targetAmount}`
|
||||
: 'e.g. 10 or 1500'
|
||||
}
|
||||
value={actualAmount}
|
||||
onChange={e => setActualAmount(e.target.value)}
|
||||
className="w-full px-3.5 py-2 rounded-xl bg-slate-900 border border-white/10 text-slate-100 placeholder:text-slate-500 focus:outline-none focus:border-emerald-500/50"
|
||||
/>
|
||||
<BarChart3 className="w-4 h-4 text-emerald-400 absolute right-3 top-1/2 -translate-y-1/2 pointer-events-none" />
|
||||
</div>
|
||||
{selectedIndicatorObj && (
|
||||
<p className="text-[11px] text-slate-400 mt-1">
|
||||
Baseline: {selectedIndicatorObj.baselineAmount} • Target: {selectedIndicatorObj.targetAmount} • Current Actual: {selectedIndicatorObj.actualAmount}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Evidence Title & Type */}
|
||||
<div className="grid grid-cols-1 sm:grid-cols-2 gap-3">
|
||||
<div>
|
||||
<label className="block text-slate-300 font-medium mb-1">Evidence Title</label>
|
||||
<input
|
||||
type="text"
|
||||
placeholder="e.g. Audit Signoff Dossier"
|
||||
placeholder="e.g. Activity Verification Proof"
|
||||
value={title}
|
||||
onChange={(e) => setTitle(e.target.value)}
|
||||
onChange={e => setTitle(e.target.value)}
|
||||
className="w-full px-3 py-2 rounded-xl bg-slate-950/70 border border-white/10 text-slate-200 placeholder:text-slate-500 focus:outline-none focus:border-emerald-500/50"
|
||||
/>
|
||||
</div>
|
||||
@@ -217,42 +355,26 @@ export const EvidenceUploadModal: React.FC<EvidenceUploadModalProps> = ({
|
||||
<label className="block text-slate-300 font-medium mb-1">Evidence Type</label>
|
||||
<select
|
||||
value={evidenceType}
|
||||
onChange={(e) => setEvidenceType(Number(e.target.value) as EvidenceType)}
|
||||
onChange={e => setEvidenceType(Number(e.target.value))}
|
||||
className="w-full px-3 py-2 rounded-xl bg-slate-950/70 border border-white/10 text-slate-200 focus:outline-none focus:border-emerald-500/50 cursor-pointer"
|
||||
>
|
||||
<option value={0}>Document</option>
|
||||
<option value={1}>Invoice</option>
|
||||
<option value={2}>Receipt</option>
|
||||
<option value={3}>Site Photo</option>
|
||||
<option value={4}>Verification Report</option>
|
||||
<option value={5}>Other Deliverable</option>
|
||||
{EVIDENCE_TYPE_OPTIONS.map(opt => (
|
||||
<option key={opt.value} value={opt.value} className="bg-slate-900 text-slate-100">
|
||||
{opt.label}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* External Secure URL (Optional) */}
|
||||
<div>
|
||||
<label className="block text-slate-300 font-medium mb-1">Document Link / S3 URI (Optional)</label>
|
||||
<div className="relative">
|
||||
<LinkIcon className="w-3.5 h-3.5 absolute left-3 top-1/2 -translate-y-1/2 text-slate-400" />
|
||||
<input
|
||||
type="text"
|
||||
placeholder="https://... or s3://..."
|
||||
value={documentUrl}
|
||||
onChange={(e) => setDocumentUrl(e.target.value)}
|
||||
className="w-full pl-8 pr-3 py-2 rounded-xl bg-slate-950/70 border border-white/10 text-slate-200 placeholder:text-slate-500 focus:outline-none focus:border-emerald-500/50"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Description / Notes */}
|
||||
<div>
|
||||
<label className="block text-slate-300 font-medium mb-1">Deliverable Notes</label>
|
||||
<label className="block text-slate-300 font-medium mb-1">Deliverable Notes (Optional)</label>
|
||||
<textarea
|
||||
rows={2}
|
||||
placeholder="Provide verification notes or audit references..."
|
||||
value={description}
|
||||
onChange={(e) => setDescription(e.target.value)}
|
||||
onChange={e => setDescription(e.target.value)}
|
||||
className="w-full px-3 py-2 rounded-xl bg-slate-950/70 border border-white/10 text-slate-200 placeholder:text-slate-500 focus:outline-none focus:border-emerald-500/50 resize-none"
|
||||
/>
|
||||
</div>
|
||||
@@ -280,13 +402,14 @@ export const EvidenceUploadModal: React.FC<EvidenceUploadModalProps> = ({
|
||||
) : (
|
||||
<>
|
||||
<Plus className="w-4 h-4" />
|
||||
<span>Upload & Attach Evidence</span>
|
||||
<span>Upload & Record Actual</span>
|
||||
</>
|
||||
)}
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
</div>,
|
||||
document.body
|
||||
);
|
||||
};
|
||||
|
||||
@@ -0,0 +1,483 @@
|
||||
import React, { useState, useMemo } from 'react';
|
||||
import { createPortal } from 'react-dom';
|
||||
import { useQuery } from '@tanstack/react-query';
|
||||
import {
|
||||
X,
|
||||
ShieldAlert,
|
||||
ShieldCheck,
|
||||
AlertTriangle,
|
||||
Scale,
|
||||
FileCheck,
|
||||
Info,
|
||||
Calendar,
|
||||
User,
|
||||
Users,
|
||||
CheckCircle2,
|
||||
Clock,
|
||||
Layers,
|
||||
Sparkles,
|
||||
Filter,
|
||||
MapPin,
|
||||
Target,
|
||||
} from 'lucide-react';
|
||||
import {
|
||||
Project,
|
||||
Milestone,
|
||||
Activity,
|
||||
ComplianceItem,
|
||||
Risk,
|
||||
Rule,
|
||||
Beneficiary,
|
||||
ComplianceStatus,
|
||||
} from '../../services/api/models';
|
||||
import {
|
||||
getProjectComplianceItems,
|
||||
getProjectBeneficiaries,
|
||||
getProjectRisks,
|
||||
getRules,
|
||||
} from '../../services/api';
|
||||
import { formatDate } from '../../lib/formatters';
|
||||
|
||||
interface GovernanceInfoModalProps {
|
||||
isOpen: boolean;
|
||||
onClose: () => void;
|
||||
project: Project;
|
||||
milestone?: Milestone | null;
|
||||
activity?: Activity | null;
|
||||
initialTab?: 'compliance' | 'beneficiaries' | 'risks' | 'rules';
|
||||
}
|
||||
|
||||
export const GovernanceInfoModal: React.FC<GovernanceInfoModalProps> = ({
|
||||
isOpen,
|
||||
onClose,
|
||||
project,
|
||||
milestone,
|
||||
activity,
|
||||
initialTab = 'compliance',
|
||||
}) => {
|
||||
const [activeTab, setActiveTab] = useState<'compliance' | 'beneficiaries' | 'risks' | 'rules'>(initialTab);
|
||||
const [selectedBeneficiaryFilter, setSelectedBeneficiaryFilter] = useState<string>('ALL');
|
||||
|
||||
const projectId = project?.id || 0;
|
||||
|
||||
// 1. Fetch compliance items for project
|
||||
const { data: complianceItems = [], isLoading: isLoadingCompliance } = useQuery<ComplianceItem[]>({
|
||||
queryKey: ['governance-compliance', String(projectId)],
|
||||
queryFn: () => getProjectComplianceItems(projectId),
|
||||
enabled: isOpen && Boolean(projectId),
|
||||
});
|
||||
|
||||
// 2. Fetch beneficiaries for project (Requirement: explore compliance items by beneficiary)
|
||||
const { data: projectBeneficiaries = [], isLoading: isLoadingBeneficiaries } = useQuery<Beneficiary[]>({
|
||||
queryKey: ['governance-beneficiaries', String(projectId)],
|
||||
queryFn: () => getProjectBeneficiaries(projectId),
|
||||
enabled: isOpen && Boolean(projectId),
|
||||
});
|
||||
|
||||
// 3. Fetch risks for project
|
||||
const { data: projectRisks = [], isLoading: isLoadingRisks } = useQuery<Risk[]>({
|
||||
queryKey: ['governance-risks', String(projectId)],
|
||||
queryFn: () => getProjectRisks(projectId),
|
||||
enabled: isOpen && Boolean(projectId),
|
||||
});
|
||||
|
||||
// 4. Fetch associated governance rules
|
||||
const { data: governanceRules = [], isLoading: isLoadingRules } = useQuery<Rule[]>({
|
||||
queryKey: ['governance-rules'],
|
||||
queryFn: () => getRules({ page: 1, pageSize: 100 }),
|
||||
enabled: isOpen,
|
||||
});
|
||||
|
||||
// Map of beneficiary by ID for fast lookup
|
||||
const beneficiaryMap = useMemo(() => {
|
||||
const map = new Map<string, Beneficiary>();
|
||||
for (const b of projectBeneficiaries) {
|
||||
if (b && b.id !== undefined && b.id !== null) {
|
||||
map.set(String(b.id), b);
|
||||
}
|
||||
}
|
||||
return map;
|
||||
}, [projectBeneficiaries]);
|
||||
|
||||
// Filter compliance items by selected beneficiary
|
||||
const filteredComplianceItems = useMemo(() => {
|
||||
if (selectedBeneficiaryFilter === 'ALL') {
|
||||
return complianceItems;
|
||||
}
|
||||
if (selectedBeneficiaryFilter === 'PROJECT_WIDE') {
|
||||
return complianceItems.filter(
|
||||
item => !item.beneficiaryId || Number(item.beneficiaryId) === 0
|
||||
);
|
||||
}
|
||||
return complianceItems.filter(
|
||||
item => String(item.beneficiaryId) === selectedBeneficiaryFilter
|
||||
);
|
||||
}, [complianceItems, selectedBeneficiaryFilter]);
|
||||
|
||||
if (!isOpen || typeof document === 'undefined') return null;
|
||||
|
||||
const renderComplianceStatus = (status?: number) => {
|
||||
const s = Number(status);
|
||||
if (s === ComplianceStatus.Met || s === ComplianceStatus.Exceeded) {
|
||||
return (
|
||||
<span className="inline-flex items-center gap-1 px-2.5 py-0.5 rounded-full text-[10px] font-bold bg-emerald-500/15 text-emerald-300 border border-emerald-500/30">
|
||||
<CheckCircle2 className="w-3 h-3 text-emerald-400" />
|
||||
<span>{s === ComplianceStatus.Exceeded ? 'Exceeded' : 'Compliant'}</span>
|
||||
</span>
|
||||
);
|
||||
}
|
||||
return (
|
||||
<span className="inline-flex items-center gap-1 px-2.5 py-0.5 rounded-full text-[10px] font-bold bg-rose-500/15 text-rose-300 border border-rose-500/30">
|
||||
<AlertTriangle className="w-3 h-3 text-rose-400" />
|
||||
<span>Action Required (Not Met)</span>
|
||||
</span>
|
||||
);
|
||||
};
|
||||
|
||||
return createPortal(
|
||||
<div className="fixed inset-0 z-[9999] flex items-center justify-center p-4 sm:p-6 bg-slate-950/85 backdrop-blur-md animate-in fade-in duration-200 overflow-y-auto">
|
||||
<div className="relative w-full max-w-4xl rounded-3xl border border-white/10 bg-slate-900 shadow-2xl p-6 sm:p-8 space-y-6 my-auto text-slate-100 max-h-[90vh] flex flex-col">
|
||||
{/* Modal Header */}
|
||||
<div className="flex items-start justify-between border-b border-white/[0.08] pb-4 shrink-0">
|
||||
<div className="space-y-1">
|
||||
<div className="flex items-center gap-2 flex-wrap">
|
||||
<span className="inline-flex items-center gap-1 px-2.5 py-0.5 rounded-md text-[10px] font-mono font-bold bg-emerald-500/15 text-emerald-300 border border-emerald-500/30">
|
||||
<ShieldAlert className="w-3 h-3 text-emerald-400" />
|
||||
<span>Governance & Compliance Guidance</span>
|
||||
</span>
|
||||
{activity ? (
|
||||
<span className="text-[11px] text-slate-400 font-mono">Activity: {activity.title}</span>
|
||||
) : milestone ? (
|
||||
<span className="text-[11px] text-slate-400 font-mono">Milestone: {milestone.name}</span>
|
||||
) : (
|
||||
<span className="text-[11px] text-slate-400 font-mono">Project: {project?.name || (project as any)?.title || `#${project?.id || 'PFM'}`}</span>
|
||||
)}
|
||||
</div>
|
||||
<h3 className="text-lg sm:text-xl font-bold text-white tracking-tight">
|
||||
Project Compliance Items, Beneficiaries & Associated Rules
|
||||
</h3>
|
||||
<p className="text-xs text-slate-400">
|
||||
Contractual obligations, beneficiary-linked compliance criteria, risk registers, and institutional rules.
|
||||
</p>
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
onClick={onClose}
|
||||
className="p-1.5 rounded-xl text-slate-400 hover:text-white hover:bg-white/[0.06] transition-colors cursor-pointer shrink-0"
|
||||
>
|
||||
<X className="w-5 h-5" />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Tab Navigation */}
|
||||
<div className="flex items-center gap-2 border-b border-white/[0.08] pb-3 shrink-0 flex-wrap">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setActiveTab('compliance')}
|
||||
className={`flex items-center gap-2 px-3.5 py-2 rounded-xl text-xs font-semibold transition-all cursor-pointer ${
|
||||
activeTab === 'compliance'
|
||||
? 'bg-emerald-500/20 text-emerald-300 border border-emerald-500/40 shadow-sm'
|
||||
: 'text-slate-400 hover:text-white hover:bg-white/[0.04]'
|
||||
}`}
|
||||
>
|
||||
<FileCheck className="w-4 h-4" />
|
||||
<span>Compliance Items ({complianceItems.length})</span>
|
||||
</button>
|
||||
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setActiveTab('beneficiaries')}
|
||||
className={`flex items-center gap-2 px-3.5 py-2 rounded-xl text-xs font-semibold transition-all cursor-pointer ${
|
||||
activeTab === 'beneficiaries'
|
||||
? 'bg-cyan-500/20 text-cyan-300 border border-cyan-500/40 shadow-sm'
|
||||
: 'text-slate-400 hover:text-white hover:bg-white/[0.04]'
|
||||
}`}
|
||||
>
|
||||
<Users className="w-4 h-4" />
|
||||
<span>Beneficiaries ({projectBeneficiaries.length})</span>
|
||||
</button>
|
||||
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setActiveTab('risks')}
|
||||
className={`flex items-center gap-2 px-3.5 py-2 rounded-xl text-xs font-semibold transition-all cursor-pointer ${
|
||||
activeTab === 'risks'
|
||||
? 'bg-amber-500/20 text-amber-300 border border-amber-500/40 shadow-sm'
|
||||
: 'text-slate-400 hover:text-white hover:bg-white/[0.04]'
|
||||
}`}
|
||||
>
|
||||
<AlertTriangle className="w-4 h-4" />
|
||||
<span>Risk Register ({projectRisks.length})</span>
|
||||
</button>
|
||||
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setActiveTab('rules')}
|
||||
className={`flex items-center gap-2 px-3.5 py-2 rounded-xl text-xs font-semibold transition-all cursor-pointer ${
|
||||
activeTab === 'rules'
|
||||
? 'bg-teal-500/20 text-teal-300 border border-teal-500/40 shadow-sm'
|
||||
: 'text-slate-400 hover:text-white hover:bg-white/[0.04]'
|
||||
}`}
|
||||
>
|
||||
<Scale className="w-4 h-4" />
|
||||
<span>Associated Rules ({governanceRules.length})</span>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Tab Content Panels */}
|
||||
<div className="flex-1 overflow-y-auto space-y-4 pr-1 [scrollbar-width:thin] [scrollbar-color:#334155_transparent]">
|
||||
{/* TAB 1: COMPLIANCE ITEMS (With Beneficiary Explorer) */}
|
||||
{activeTab === 'compliance' && (
|
||||
<div className="space-y-4">
|
||||
{/* Beneficiary Filter Bar */}
|
||||
<div className="p-3.5 rounded-2xl bg-slate-950/70 border border-white/[0.08] flex flex-col sm:flex-row sm:items-center justify-between gap-3">
|
||||
<div className="flex items-center gap-2 text-xs text-slate-300">
|
||||
<Filter className="w-4 h-4 text-emerald-400 shrink-0" />
|
||||
<span className="font-semibold">Explore by Beneficiary:</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<select
|
||||
value={selectedBeneficiaryFilter}
|
||||
onChange={e => setSelectedBeneficiaryFilter(e.target.value)}
|
||||
className="px-3 py-1.5 rounded-xl bg-slate-900 border border-white/10 text-xs text-slate-200 focus:outline-none focus:border-emerald-500/50 cursor-pointer"
|
||||
>
|
||||
<option value="ALL">All Compliance Items ({complianceItems.length})</option>
|
||||
<option value="PROJECT_WIDE">
|
||||
Project-Wide General Items (
|
||||
{
|
||||
complianceItems.filter(
|
||||
i => !i.beneficiaryId || Number(i.beneficiaryId) === 0
|
||||
).length
|
||||
}
|
||||
)
|
||||
</option>
|
||||
{projectBeneficiaries.map(b => {
|
||||
const count = complianceItems.filter(
|
||||
i => String(i.beneficiaryId) === String(b.id)
|
||||
).length;
|
||||
return (
|
||||
<option key={b.id} value={String(b.id)}>
|
||||
Beneficiary: {b.name || `Beneficiary #${b.id}`} ({count} items)
|
||||
</option>
|
||||
);
|
||||
})}
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{isLoadingCompliance ? (
|
||||
<div className="p-8 text-center text-xs text-slate-400">Loading compliance requirements...</div>
|
||||
) : filteredComplianceItems.length === 0 ? (
|
||||
<div className="p-8 rounded-2xl bg-slate-950/60 border border-white/5 text-center text-xs text-slate-400 space-y-2">
|
||||
<FileCheck className="w-8 h-8 text-slate-500 mx-auto" />
|
||||
<p>No compliance items found matching this beneficiary filter.</p>
|
||||
<p className="text-[11px] text-slate-500">
|
||||
Switch to "All Compliance Items" to inspect project-wide statutory deliverables.
|
||||
</p>
|
||||
</div>
|
||||
) : (
|
||||
filteredComplianceItems.map((item, idx) => {
|
||||
const linkedBeneficiary = item.beneficiaryId
|
||||
? beneficiaryMap.get(String(item.beneficiaryId))
|
||||
: null;
|
||||
return (
|
||||
<div
|
||||
key={item.id || idx}
|
||||
className="p-4 rounded-2xl bg-slate-950/70 border border-white/[0.08] space-y-2.5"
|
||||
>
|
||||
<div className="flex flex-col sm:flex-row sm:items-center justify-between gap-2">
|
||||
<div className="flex items-center gap-2 flex-wrap">
|
||||
<span className="font-semibold text-sm text-slate-100">
|
||||
{item.title || `Compliance Item #${idx + 1}`}
|
||||
</span>
|
||||
{renderComplianceStatus(item.status)}
|
||||
{linkedBeneficiary ? (
|
||||
<span className="inline-flex items-center gap-1 px-2.5 py-0.5 rounded-md text-[10px] font-mono bg-cyan-950/80 border border-cyan-500/30 text-cyan-300">
|
||||
<Users className="w-3 h-3 text-cyan-400" />
|
||||
<span>Beneficiary: {linkedBeneficiary.name}</span>
|
||||
</span>
|
||||
) : (
|
||||
<span className="inline-flex items-center gap-1 px-2.5 py-0.5 rounded-md text-[10px] font-mono bg-slate-900 border border-white/10 text-slate-400">
|
||||
<span>Project-Wide General</span>
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
<div className="text-[11px] text-slate-400 flex items-center gap-1 font-mono">
|
||||
<Calendar className="w-3.5 h-3.5 text-slate-500" />
|
||||
<span>Created: {formatDate(item.createdAt)}</span>
|
||||
</div>
|
||||
</div>
|
||||
{item.requirements && (
|
||||
<p className="text-xs text-slate-300 leading-relaxed">{item.requirements}</p>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
})
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* TAB 2: BENEFICIARIES LISTING & EXPLORATION */}
|
||||
{activeTab === 'beneficiaries' && (
|
||||
<div className="space-y-3">
|
||||
{isLoadingBeneficiaries ? (
|
||||
<div className="p-8 text-center text-xs text-slate-400">Loading project beneficiaries...</div>
|
||||
) : projectBeneficiaries.length === 0 ? (
|
||||
<div className="p-8 rounded-2xl bg-slate-950/60 border border-white/5 text-center text-xs text-slate-400 space-y-2">
|
||||
<Users className="w-8 h-8 text-slate-500 mx-auto" />
|
||||
<p>No individual beneficiary cohorts registered for this project.</p>
|
||||
<p className="text-[11px] text-slate-500">
|
||||
Beneficiaries are captured during project execution and onboarding.
|
||||
</p>
|
||||
</div>
|
||||
) : (
|
||||
projectBeneficiaries.map(ben => {
|
||||
const linkedItems = complianceItems.filter(
|
||||
ci => String(ci.beneficiaryId) === String(ben.id)
|
||||
);
|
||||
return (
|
||||
<div
|
||||
key={ben.id}
|
||||
className="p-4 rounded-2xl bg-slate-950/70 border border-white/[0.08] space-y-3"
|
||||
>
|
||||
<div className="flex flex-col sm:flex-row sm:items-center justify-between gap-2">
|
||||
<div className="flex items-center gap-2.5 flex-wrap">
|
||||
<div className="p-2 rounded-xl bg-cyan-500/10 text-cyan-400">
|
||||
<Users className="w-4 h-4" />
|
||||
</div>
|
||||
<div>
|
||||
<h4 className="text-sm font-bold text-slate-100">{ben.name || 'Unnamed Beneficiary'}</h4>
|
||||
<p className="text-[11px] text-slate-400 font-mono">
|
||||
Category: {ben.category || 'Direct Participant'}
|
||||
{ben.location && ` • Location: ${ben.location}`}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-3 text-xs">
|
||||
<div className="text-right">
|
||||
<span className="text-[10px] text-slate-500 block uppercase font-bold">Target / Reached</span>
|
||||
<span className="text-emerald-400 font-mono font-semibold">
|
||||
{ben.reachedCount ?? 0} / {ben.targetCount ?? 0}
|
||||
</span>
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => {
|
||||
setSelectedBeneficiaryFilter(String(ben.id));
|
||||
setActiveTab('compliance');
|
||||
}}
|
||||
className="px-3 py-1.5 rounded-xl bg-cyan-500/15 hover:bg-cyan-500/25 border border-cyan-500/30 text-cyan-300 text-[11px] font-semibold transition-colors cursor-pointer"
|
||||
>
|
||||
Explore Compliance ({linkedItems.length})
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* TAB 3: RISK REGISTER */}
|
||||
{activeTab === 'risks' && (
|
||||
<div className="space-y-3">
|
||||
{isLoadingRisks ? (
|
||||
<div className="p-8 text-center text-xs text-slate-400">Loading risk register...</div>
|
||||
) : projectRisks.length === 0 ? (
|
||||
<div className="p-8 rounded-2xl bg-slate-950/60 border border-white/5 text-center text-xs text-slate-400 space-y-2">
|
||||
<ShieldCheck className="w-8 h-8 text-emerald-500 mx-auto" />
|
||||
<p>No high-severity risks recorded for this project.</p>
|
||||
<p className="text-[11px] text-slate-500">
|
||||
Maintain milestone delivery cadence to preserve low-risk standing.
|
||||
</p>
|
||||
</div>
|
||||
) : (
|
||||
projectRisks.map((risk, idx) => (
|
||||
<div
|
||||
key={risk.id || idx}
|
||||
className="p-4 rounded-2xl bg-slate-950/70 border border-white/[0.08] space-y-2.5"
|
||||
>
|
||||
<div className="flex flex-col sm:flex-row sm:items-center justify-between gap-2">
|
||||
<h4 className="font-semibold text-sm text-slate-100">
|
||||
{risk.title || `Risk Item #${idx + 1}`}
|
||||
</h4>
|
||||
<div className="flex items-center gap-2 text-[10px] font-mono">
|
||||
<span className="px-2 py-0.5 rounded-md bg-amber-500/15 text-amber-300 border border-amber-500/30">
|
||||
Likelihood: {risk.likelihood ?? 'Medium'}
|
||||
</span>
|
||||
<span className="px-2 py-0.5 rounded-md bg-rose-500/15 text-rose-300 border border-rose-500/30">
|
||||
Impact: {risk.impact ?? 'Moderate'}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
{risk.description && (
|
||||
<p className="text-xs text-slate-300 leading-relaxed">{risk.description}</p>
|
||||
)}
|
||||
{risk.mitigation && (
|
||||
<div className="text-[11px] text-teal-300 bg-teal-950/40 border border-teal-500/20 p-2.5 rounded-xl">
|
||||
<strong>Mitigation Strategy:</strong> {risk.mitigation}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
))
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* TAB 4: ASSOCIATED GOVERNANCE RULES */}
|
||||
{activeTab === 'rules' && (
|
||||
<div className="space-y-3">
|
||||
{isLoadingRules ? (
|
||||
<div className="p-8 text-center text-xs text-slate-400">Loading institutional rules...</div>
|
||||
) : governanceRules.length === 0 ? (
|
||||
<div className="p-8 rounded-2xl bg-slate-950/60 border border-white/5 text-center text-xs text-slate-400 space-y-2">
|
||||
<Scale className="w-8 h-8 text-slate-500 mx-auto" />
|
||||
<p>Standard PFM Funding Portfolio Governance Rules apply.</p>
|
||||
<p className="text-[11px] text-slate-500">
|
||||
All disbursements gate against verified milestone deliverables and evidence.
|
||||
</p>
|
||||
</div>
|
||||
) : (
|
||||
governanceRules.map((rule, idx) => (
|
||||
<div
|
||||
key={rule.id || idx}
|
||||
className="p-4 rounded-2xl bg-slate-950/70 border border-white/[0.08] space-y-2"
|
||||
>
|
||||
<div className="flex items-center justify-between gap-2">
|
||||
<h4 className="font-semibold text-sm text-slate-100">
|
||||
{rule.name || `Rule #${idx + 1}`}
|
||||
</h4>
|
||||
<span className="text-[10px] font-mono px-2 py-0.5 rounded-md bg-slate-800 text-slate-300 border border-white/10">
|
||||
v{rule.version || 1}
|
||||
</span>
|
||||
</div>
|
||||
{rule.description && (
|
||||
<p className="text-xs text-slate-300 leading-relaxed">{rule.description}</p>
|
||||
)}
|
||||
</div>
|
||||
))
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Modal Footer */}
|
||||
<div className="flex items-center justify-between pt-3 border-t border-white/[0.08] shrink-0">
|
||||
<span className="text-[11px] text-slate-500 font-mono">
|
||||
PFM Governance Architecture • Beneficiary & Compliance Tracking
|
||||
</span>
|
||||
<button
|
||||
type="button"
|
||||
onClick={onClose}
|
||||
className="px-5 py-2 rounded-xl bg-slate-800 hover:bg-slate-700 text-slate-200 text-xs font-semibold transition-colors cursor-pointer"
|
||||
>
|
||||
Close
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>,
|
||||
document.body
|
||||
);
|
||||
};
|
||||
File diff suppressed because it is too large
Load Diff
@@ -1,37 +1,32 @@
|
||||
import React, { useState, useMemo } from 'react';
|
||||
import {
|
||||
Copy,
|
||||
Check,
|
||||
Mail,
|
||||
MessageSquare,
|
||||
Code2,
|
||||
Eye,
|
||||
ShieldCheck,
|
||||
} from 'lucide-react';
|
||||
import { Copy, Check } from 'lucide-react';
|
||||
import { NotificationPlatform } from '../../types';
|
||||
import { parseMarkdownToHtml, isHtmlContent, stripMarkdown } from '../../lib/markdownUtils';
|
||||
|
||||
export interface MessageViewerProps {
|
||||
content: string;
|
||||
content?: string;
|
||||
message?: string;
|
||||
platform?: NotificationPlatform | number | string;
|
||||
subject?: string;
|
||||
className?: string;
|
||||
showFormatHeader?: boolean;
|
||||
maxHeightClass?: string;
|
||||
variant?: string;
|
||||
createdDate?: string;
|
||||
}
|
||||
|
||||
export const MessageViewer: React.FC<MessageViewerProps> = ({
|
||||
content,
|
||||
message,
|
||||
platform,
|
||||
subject,
|
||||
className = '',
|
||||
showFormatHeader = true,
|
||||
maxHeightClass = 'max-h-[60vh]',
|
||||
maxHeightClass = 'max-h-[65vh]',
|
||||
}) => {
|
||||
const rawContent = (content !== undefined && content !== null ? content : message) ?? '';
|
||||
const [copied, setCopied] = useState(false);
|
||||
const [showRaw, setShowRaw] = useState(false);
|
||||
|
||||
// Determine whether this message represents an Email (HTML) or In-App (Markup)
|
||||
// Determine whether this message represents an Email (HTML) or In-App (Markdown)
|
||||
const isEmail = useMemo(() => {
|
||||
if (
|
||||
platform === NotificationPlatform.Email ||
|
||||
@@ -41,148 +36,81 @@ export const MessageViewer: React.FC<MessageViewerProps> = ({
|
||||
) {
|
||||
return true;
|
||||
}
|
||||
// If platform is not explicitly InApp, detect if content is structured HTML
|
||||
if (
|
||||
platform !== NotificationPlatform.InApp &&
|
||||
platform !== 5 &&
|
||||
platform !== '5' &&
|
||||
String(platform).toLowerCase() !== 'inapp' &&
|
||||
isHtmlContent(content)
|
||||
isHtmlContent(rawContent)
|
||||
) {
|
||||
return true;
|
||||
}
|
||||
return false;
|
||||
}, [platform, content]);
|
||||
}, [platform, rawContent]);
|
||||
|
||||
const isInApp = useMemo(() => {
|
||||
if (
|
||||
platform === NotificationPlatform.InApp ||
|
||||
platform === 5 ||
|
||||
platform === '5' ||
|
||||
String(platform).toLowerCase() === 'inapp'
|
||||
) {
|
||||
return true;
|
||||
}
|
||||
return !isEmail;
|
||||
}, [platform, isEmail]);
|
||||
|
||||
// Convert the message to its respective format:
|
||||
// - Email: HTML rendered
|
||||
// - In-App: Markup (Markdown) parsed to HTML
|
||||
// Requirement #1: In-App message contents encoded as markdown, rendered for user; Email in HTML
|
||||
// Requirement #8: User is none the wiser of raw form or format in any Messages UI
|
||||
const renderedHtml = useMemo(() => {
|
||||
if (!content) return '<p class="text-slate-400 italic">No message content</p>';
|
||||
|
||||
// Format 1: Email => HTML
|
||||
if (isEmail) {
|
||||
if (isHtmlContent(content)) {
|
||||
return content.replace(/<script\b[^<]*(?:(?!<\/script>)<[^<]*)*<\/script>/gi, '');
|
||||
}
|
||||
return parseMarkdownToHtml(content);
|
||||
if (!rawContent || !rawContent.trim()) {
|
||||
return '<p class="text-slate-400 italic">No message content</p>';
|
||||
}
|
||||
|
||||
// Format 2: In-App => Markup (Markdown)
|
||||
return parseMarkdownToHtml(content);
|
||||
}, [content, isEmail]);
|
||||
if (isEmail) {
|
||||
if (isHtmlContent(rawContent)) {
|
||||
return rawContent.replace(/<script\b[^<]*(?:(?!<\/script>)<[^<]*)*<\/script>/gi, '');
|
||||
}
|
||||
return parseMarkdownToHtml(rawContent);
|
||||
}
|
||||
|
||||
// In-App: parsed from Markdown to styled HTML
|
||||
return parseMarkdownToHtml(rawContent);
|
||||
}, [rawContent, isEmail]);
|
||||
|
||||
const handleCopy = () => {
|
||||
const textToCopy = showRaw ? content : stripMarkdown(content) || content;
|
||||
const textToCopy = stripMarkdown(rawContent) || rawContent;
|
||||
navigator.clipboard.writeText(textToCopy);
|
||||
setCopied(true);
|
||||
setTimeout(() => setCopied(false), 2000);
|
||||
};
|
||||
|
||||
return (
|
||||
<div className={`flex flex-col rounded-2xl bg-slate-900/50 border border-white/10 overflow-hidden w-full max-w-full ${className}`}>
|
||||
{/* Format Header & Controls Bar */}
|
||||
{showFormatHeader && (
|
||||
<div className="px-4 py-2.5 border-b border-white/[0.08] bg-[#0b101d]/90 flex items-center justify-between text-xs text-slate-400 gap-3 shrink-0">
|
||||
<div className="flex items-center gap-2 flex-wrap">
|
||||
{isEmail ? (
|
||||
<span className="inline-flex items-center gap-1.5 px-2.5 py-1 rounded-lg bg-cyan-500/10 border border-cyan-500/20 text-cyan-300 font-semibold text-[11px]">
|
||||
<Mail className="w-3.5 h-3.5 text-cyan-400" />
|
||||
<span>Email (HTML)</span>
|
||||
</span>
|
||||
) : (
|
||||
<span className="inline-flex items-center gap-1.5 px-2.5 py-1 rounded-lg bg-emerald-500/10 border border-emerald-500/20 text-emerald-300 font-semibold text-[11px]">
|
||||
<MessageSquare className="w-3.5 h-3.5 text-emerald-400" />
|
||||
<span>In-App (Markup)</span>
|
||||
</span>
|
||||
)}
|
||||
<div className={`flex flex-col rounded-2xl bg-slate-900/60 border border-white/10 overflow-hidden w-full max-w-full ${className}`}>
|
||||
{/* Top action toolbar - clean without technical format metadata */}
|
||||
<div className="px-5 py-3 border-b border-white/[0.08] bg-[#0b101d]/90 flex items-center justify-between text-xs text-slate-400 gap-3 shrink-0">
|
||||
<span className="text-xs font-semibold text-slate-200 truncate">
|
||||
{subject || 'Message Content'}
|
||||
</span>
|
||||
|
||||
<span className="text-[11px] text-slate-400 hidden sm:inline">
|
||||
{isEmail ? 'Rendered as HTML Email' : 'Converted from Markup to Formatted View'}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-1.5 shrink-0">
|
||||
{/* View Mode Toggle: Formatted vs Raw */}
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setShowRaw(!showRaw)}
|
||||
className="flex items-center gap-1 px-2 py-1 rounded-lg bg-slate-800/80 hover:bg-slate-700 text-slate-300 hover:text-white text-[11px] font-medium transition cursor-pointer border border-white/10"
|
||||
title={showRaw ? 'Switch to Formatted view' : 'View Raw message content'}
|
||||
>
|
||||
{showRaw ? (
|
||||
<>
|
||||
<Eye className="w-3 h-3 text-emerald-400" />
|
||||
<span>Formatted</span>
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<Code2 className="w-3 h-3" />
|
||||
<span>Raw</span>
|
||||
</>
|
||||
)}
|
||||
</button>
|
||||
|
||||
{/* Copy Button */}
|
||||
<button
|
||||
type="button"
|
||||
onClick={handleCopy}
|
||||
className="flex items-center gap-1 px-2 py-1 rounded-lg bg-slate-800/80 hover:bg-slate-700 text-slate-300 hover:text-white text-[11px] font-medium transition cursor-pointer border border-white/10"
|
||||
title="Copy message content"
|
||||
>
|
||||
{copied ? (
|
||||
<>
|
||||
<Check className="w-3 h-3 text-emerald-400" />
|
||||
<span className="text-emerald-300">Copied</span>
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<Copy className="w-3 h-3" />
|
||||
<span>Copy</span>
|
||||
</>
|
||||
)}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Message Body Content - with explicit max height, max width, word wrapping, and automatic scrollbars */}
|
||||
<div className={`w-full max-w-full p-4 sm:p-6 overflow-x-auto overflow-y-auto ${maxHeightClass} text-sm leading-relaxed text-slate-200 [scrollbar-width:thin] [scrollbar-color:#334155_transparent]`}>
|
||||
{showRaw ? (
|
||||
<pre className="font-mono text-xs text-slate-300 whitespace-pre-wrap break-words [word-break:break-word] bg-[#080d18] p-4 rounded-xl border border-white/5 max-w-full overflow-x-auto [scrollbar-width:thin] [scrollbar-color:#334155_transparent]">
|
||||
{content}
|
||||
</pre>
|
||||
) : (
|
||||
<div
|
||||
className="prose prose-invert max-w-full w-full break-words [word-break:break-word] text-xs sm:text-sm leading-relaxed text-slate-200 [&_h1]:text-white [&_h1]:text-xl [&_h1]:font-bold [&_h1]:mb-3 [&_h2]:text-white [&_h2]:text-lg [&_h2]:font-bold [&_h2]:mb-2 [&_h3]:text-white [&_h3]:text-base [&_h3]:font-bold [&_h3]:mb-2 [&_h4]:text-white [&_h4]:font-semibold [&_a]:text-emerald-400 [&_a:hover]:underline [&_ul]:list-disc [&_ul]:pl-5 [&_ul]:my-2 [&_ol]:list-decimal [&_ol]:pl-5 [&_ol]:my-2 [&_li]:text-slate-300 [&_table]:w-full [&_table]:max-w-full [&_table]:border-collapse [&_table]:my-3 [&_th]:border [&_th]:border-slate-700 [&_th]:bg-slate-800/80 [&_th]:p-2.5 [&_th]:break-words [&_td]:border [&_td]:border-slate-800 [&_td]:p-2.5 [&_td]:break-words [&_blockquote]:border-l-4 [&_blockquote]:border-emerald-500 [&_blockquote]:pl-4 [&_blockquote]:italic [&_blockquote]:my-2 [&_code]:bg-slate-800 [&_code]:px-1.5 [&_code]:py-0.5 [&_code]:rounded [&_code]:text-emerald-300 [&_code]:break-all [&_pre]:max-w-full [&_pre]:overflow-x-auto [&_img]:max-w-full [&_img]:h-auto [&_iframe]:max-w-full"
|
||||
dangerouslySetInnerHTML={{ __html: renderedHtml }}
|
||||
/>
|
||||
)}
|
||||
{/* Copy Clean Text */}
|
||||
<button
|
||||
type="button"
|
||||
onClick={handleCopy}
|
||||
className="flex items-center gap-1.5 px-3 py-1.5 rounded-xl bg-slate-800/90 hover:bg-slate-700 text-slate-300 hover:text-white text-xs font-medium transition cursor-pointer border border-white/10"
|
||||
title="Copy message content"
|
||||
>
|
||||
{copied ? (
|
||||
<>
|
||||
<Check className="w-3.5 h-3.5 text-emerald-400" />
|
||||
<span className="text-emerald-300">Copied</span>
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<Copy className="w-3.5 h-3.5 text-slate-400" />
|
||||
<span>Copy</span>
|
||||
</>
|
||||
)}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Footer Info Stamp */}
|
||||
<div className="px-5 py-2.5 border-t border-white/[0.08] bg-[#090e1a] flex items-center justify-between text-[11px] text-slate-400 shrink-0">
|
||||
<div className="flex items-center gap-1.5">
|
||||
<ShieldCheck className="w-3.5 h-3.5 text-emerald-400" />
|
||||
<span>PFM Communications • {isEmail ? 'Email HTML Format' : 'In-App Markup Format'}</span>
|
||||
</div>
|
||||
<span className="font-mono text-[10px] text-slate-400">
|
||||
{stripMarkdown(content).length} characters
|
||||
</span>
|
||||
{/* Rendered Body - Prose & Markdown styling */}
|
||||
<div
|
||||
className={`w-full max-w-full p-5 sm:p-7 overflow-x-auto overflow-y-auto ${maxHeightClass} text-sm leading-relaxed text-slate-200 [scrollbar-width:thin] [scrollbar-color:#334155_transparent]`}
|
||||
>
|
||||
<div
|
||||
className="prose prose-invert max-w-full w-full break-words [word-break:break-word] text-xs sm:text-sm leading-relaxed text-slate-200 [&_h1]:text-white [&_h1]:text-xl [&_h1]:font-bold [&_h1]:mb-3 [&_h2]:text-white [&_h2]:text-lg [&_h2]:font-bold [&_h2]:mb-2 [&_h3]:text-white [&_h3]:text-base [&_h3]:font-bold [&_h3]:mb-2 [&_h4]:text-white [&_h4]:font-semibold [&_a]:text-emerald-400 [&_a:hover]:underline [&_ul]:list-disc [&_ul]:pl-5 [&_ul]:my-2 [&_ol]:list-decimal [&_ol]:pl-5 [&_ol]:my-2 [&_li]:text-slate-300 [&_table]:w-full [&_table]:max-w-full [&_table]:border-collapse [&_table]:my-3 [&_th]:border [&_th]:border-slate-700 [&_th]:bg-slate-800/80 [&_th]:p-2.5 [&_th]:break-words [&_td]:border [&_td]:border-slate-800 [&_td]:p-2.5 [&_td]:break-words [&_blockquote]:border-l-4 [&_blockquote]:border-emerald-500 [&_blockquote]:pl-4 [&_blockquote]:italic [&_blockquote]:my-2 [&_code]:bg-slate-800 [&_code]:px-1.5 [&_code]:py-0.5 [&_code]:rounded [&_code]:text-emerald-300 [&_code]:break-all [&_pre]:max-w-full [&_pre]:overflow-x-auto [&_img]:max-w-full [&_img]:h-auto [&_iframe]:max-w-full"
|
||||
dangerouslySetInnerHTML={{ __html: renderedHtml }}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
|
||||
+287
@@ -0,0 +1,287 @@
|
||||
import React, { useState, useRef, useEffect } from 'react';
|
||||
import { createPortal } from 'react-dom';
|
||||
import {
|
||||
X,
|
||||
Send,
|
||||
Bold,
|
||||
Italic,
|
||||
Underline,
|
||||
Heading2,
|
||||
List,
|
||||
ListOrdered,
|
||||
Quote,
|
||||
Minus,
|
||||
MessageSquare,
|
||||
Sparkles,
|
||||
Eraser,
|
||||
} from 'lucide-react';
|
||||
import { htmlToMarkdown } from '../../lib/markdownUtils';
|
||||
|
||||
interface PartnerMessageComposerModalProps {
|
||||
isOpen: boolean;
|
||||
onClose: () => void;
|
||||
recipientEmail: string;
|
||||
recipientName?: string;
|
||||
organisationName?: string;
|
||||
initialSubject?: string;
|
||||
onSend: (subject: string, markdownMessage: string) => Promise<void>;
|
||||
isSending?: boolean;
|
||||
}
|
||||
|
||||
export const PartnerMessageComposerModal: React.FC<PartnerMessageComposerModalProps> = ({
|
||||
isOpen,
|
||||
onClose,
|
||||
recipientEmail,
|
||||
recipientName,
|
||||
organisationName,
|
||||
initialSubject = '',
|
||||
onSend,
|
||||
isSending = false,
|
||||
}) => {
|
||||
const [subject, setSubject] = useState(initialSubject);
|
||||
const [errorMessage, setErrorMessage] = useState<string | null>(null);
|
||||
const editorRef = useRef<HTMLDivElement>(null);
|
||||
|
||||
useEffect(() => {
|
||||
setSubject(initialSubject);
|
||||
setErrorMessage(null);
|
||||
}, [initialSubject, isOpen]);
|
||||
|
||||
if (!isOpen || typeof document === 'undefined') return null;
|
||||
|
||||
const applyFormat = (command: string, value: string | undefined = undefined) => {
|
||||
const editor = editorRef.current;
|
||||
if (!editor) return;
|
||||
editor.focus();
|
||||
document.execCommand(command, false, value);
|
||||
};
|
||||
|
||||
const handleClearFormat = () => {
|
||||
applyFormat('removeFormat');
|
||||
};
|
||||
|
||||
const handleSubmit = async (e: React.FormEvent) => {
|
||||
e.preventDefault();
|
||||
setErrorMessage(null);
|
||||
|
||||
if (!subject.trim()) {
|
||||
setErrorMessage('Please enter a message subject.');
|
||||
return;
|
||||
}
|
||||
|
||||
const htmlContent = editorRef.current ? editorRef.current.innerHTML : '';
|
||||
const textContent = editorRef.current ? editorRef.current.innerText.trim() : '';
|
||||
|
||||
if (!textContent && !htmlContent.trim()) {
|
||||
setErrorMessage('Please write your message in the composer space.');
|
||||
return;
|
||||
}
|
||||
|
||||
// Requirement #1: In-App messages are encoded as Markdown
|
||||
// Requirement #8: User is none the wiser of raw format
|
||||
const markdown = htmlToMarkdown(htmlContent);
|
||||
|
||||
try {
|
||||
await onSend(subject.trim(), markdown);
|
||||
onClose();
|
||||
} catch (err: any) {
|
||||
setErrorMessage(err?.message || 'Failed to dispatch message. Please try again.');
|
||||
}
|
||||
};
|
||||
|
||||
return createPortal(
|
||||
<div className="fixed inset-0 z-[9999] flex items-center justify-center p-4 sm:p-6 bg-slate-950/85 backdrop-blur-md animate-in fade-in duration-200 overflow-y-auto">
|
||||
<div className="relative w-full max-w-2xl rounded-3xl border border-white/10 bg-slate-900 shadow-2xl p-6 sm:p-7 space-y-5 my-auto text-slate-100">
|
||||
{/* Modal Header */}
|
||||
<div className="flex items-center justify-between border-b border-white/[0.08] pb-4">
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="p-2.5 rounded-2xl bg-emerald-500/15 border border-emerald-500/30 text-emerald-400">
|
||||
<MessageSquare className="w-5 h-5" />
|
||||
</div>
|
||||
<div>
|
||||
<h3 className="text-base sm:text-lg font-bold text-white tracking-tight">
|
||||
{initialSubject.startsWith('Re:') ? 'Reply Message' : 'Compose Message'}
|
||||
</h3>
|
||||
<p className="text-xs text-slate-400">
|
||||
Direct transmission to your programme funding administration
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
onClick={onClose}
|
||||
className="p-1.5 rounded-xl text-slate-400 hover:text-white hover:bg-white/[0.06] transition-colors cursor-pointer"
|
||||
>
|
||||
<X className="w-5 h-5" />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{errorMessage && (
|
||||
<div className="p-3 rounded-xl bg-rose-500/15 border border-rose-500/30 text-rose-300 text-xs">
|
||||
{errorMessage}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<form onSubmit={handleSubmit} className="space-y-4">
|
||||
{/* Recipient Field */}
|
||||
<div>
|
||||
<label className="block text-xs font-semibold text-slate-300 mb-1">
|
||||
To (Recipient Support Email)
|
||||
</label>
|
||||
<input
|
||||
type="text"
|
||||
disabled
|
||||
value={recipientEmail || 'support@ifri.khongisa.co.za'}
|
||||
className="w-full px-3.5 py-2.5 rounded-xl bg-slate-950 border border-white/10 text-xs font-mono text-emerald-400 select-none"
|
||||
/>
|
||||
{(organisationName || recipientName) && (
|
||||
<p className="text-[11px] text-slate-400 mt-1">
|
||||
Organisation: <span className="text-slate-200">{organisationName || recipientName}</span>
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Subject Field */}
|
||||
<div>
|
||||
<label className="block text-xs font-semibold text-slate-300 mb-1">Subject</label>
|
||||
<input
|
||||
type="text"
|
||||
required
|
||||
value={subject}
|
||||
onChange={e => setSubject(e.target.value)}
|
||||
placeholder="e.g. Milestone 2 Deliverable Verification Enquiry"
|
||||
className="w-full px-3.5 py-2.5 rounded-xl bg-slate-950 border border-white/10 text-xs text-white placeholder-slate-500 focus:outline-none focus:border-emerald-500/50"
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* WYSIWYG Message Composition Space (Requirement #2 & #8) */}
|
||||
<div>
|
||||
<label className="block text-xs font-semibold text-slate-300 mb-1">
|
||||
Message Composition Space
|
||||
</label>
|
||||
|
||||
<div className="rounded-2xl border border-white/10 bg-slate-950 overflow-hidden shadow-inner focus-within:border-emerald-500/50 transition">
|
||||
{/* WYSIWYG Toolbar */}
|
||||
<div className="px-3 py-2 border-b border-white/[0.08] bg-slate-900/80 flex flex-wrap items-center gap-1 text-slate-300">
|
||||
<button
|
||||
type="button"
|
||||
onMouseDown={e => e.preventDefault()}
|
||||
onClick={() => applyFormat('bold')}
|
||||
className="p-1.5 rounded-lg hover:text-white hover:bg-white/[0.08] transition cursor-pointer"
|
||||
title="Bold"
|
||||
>
|
||||
<Bold className="w-4 h-4" />
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onMouseDown={e => e.preventDefault()}
|
||||
onClick={() => applyFormat('italic')}
|
||||
className="p-1.5 rounded-lg hover:text-white hover:bg-white/[0.08] transition cursor-pointer"
|
||||
title="Italic"
|
||||
>
|
||||
<Italic className="w-4 h-4" />
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onMouseDown={e => e.preventDefault()}
|
||||
onClick={() => applyFormat('underline')}
|
||||
className="p-1.5 rounded-lg hover:text-white hover:bg-white/[0.08] transition cursor-pointer"
|
||||
title="Underline"
|
||||
>
|
||||
<Underline className="w-4 h-4" />
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onMouseDown={e => e.preventDefault()}
|
||||
onClick={() => applyFormat('formatBlock', 'H3')}
|
||||
className="p-1.5 rounded-lg hover:text-white hover:bg-white/[0.08] transition cursor-pointer"
|
||||
title="Heading"
|
||||
>
|
||||
<Heading2 className="w-4 h-4" />
|
||||
</button>
|
||||
|
||||
<span className="w-px h-4 bg-white/10 mx-1" />
|
||||
|
||||
<button
|
||||
type="button"
|
||||
onMouseDown={e => e.preventDefault()}
|
||||
onClick={() => applyFormat('insertUnorderedList')}
|
||||
className="p-1.5 rounded-lg hover:text-white hover:bg-white/[0.08] transition cursor-pointer"
|
||||
title="Bullet List"
|
||||
>
|
||||
<List className="w-4 h-4" />
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onMouseDown={e => e.preventDefault()}
|
||||
onClick={() => applyFormat('insertOrderedList')}
|
||||
className="p-1.5 rounded-lg hover:text-white hover:bg-white/[0.08] transition cursor-pointer"
|
||||
title="Numbered List"
|
||||
>
|
||||
<ListOrdered className="w-4 h-4" />
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onMouseDown={e => e.preventDefault()}
|
||||
onClick={() => applyFormat('formatBlock', 'BLOCKQUOTE')}
|
||||
className="p-1.5 rounded-lg hover:text-white hover:bg-white/[0.08] transition cursor-pointer"
|
||||
title="Quote Block"
|
||||
>
|
||||
<Quote className="w-4 h-4" />
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onMouseDown={e => e.preventDefault()}
|
||||
onClick={() => applyFormat('insertHorizontalRule')}
|
||||
className="p-1.5 rounded-lg hover:text-white hover:bg-white/[0.08] transition cursor-pointer"
|
||||
title="Horizontal Divider"
|
||||
>
|
||||
<Minus className="w-4 h-4" />
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onMouseDown={e => e.preventDefault()}
|
||||
onClick={handleClearFormat}
|
||||
className="p-1.5 rounded-lg hover:text-white hover:bg-white/[0.08] transition cursor-pointer ml-auto"
|
||||
title="Clear Formatting"
|
||||
>
|
||||
<Eraser className="w-4 h-4 text-slate-400" />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Rich ContentEditable Area */}
|
||||
<div
|
||||
ref={editorRef}
|
||||
contentEditable
|
||||
className="w-full min-h-[160px] max-h-[300px] p-4 text-xs sm:text-sm text-slate-100 leading-relaxed overflow-y-auto focus:outline-none [scrollbar-width:thin] [scrollbar-color:#334155_transparent] prose prose-invert max-w-none [&_blockquote]:border-l-4 [&_blockquote]:border-emerald-500 [&_blockquote]:pl-3 [&_blockquote]:italic [&_ul]:list-disc [&_ul]:pl-5 [&_ol]:list-decimal [&_ol]:pl-5"
|
||||
data-placeholder="Write your message here..."
|
||||
tabIndex={0}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Modal Actions */}
|
||||
<div className="flex items-center justify-end gap-3 pt-3 border-t border-white/[0.08]">
|
||||
<button
|
||||
type="button"
|
||||
onClick={onClose}
|
||||
className="px-4 py-2.5 rounded-xl bg-slate-800 hover:bg-slate-700 text-slate-300 text-xs font-semibold transition-colors cursor-pointer"
|
||||
>
|
||||
Cancel
|
||||
</button>
|
||||
<button
|
||||
type="submit"
|
||||
disabled={isSending}
|
||||
className="px-5 py-2.5 rounded-xl bg-emerald-600 hover:bg-emerald-500 text-white font-semibold text-xs flex items-center gap-2 shadow-lg shadow-emerald-500/20 transition-all cursor-pointer disabled:opacity-50"
|
||||
>
|
||||
<Send className="w-3.5 h-3.5" />
|
||||
<span>{isSending ? 'Sending...' : 'Send Message'}</span>
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>,
|
||||
document.body
|
||||
);
|
||||
};
|
||||
|
||||
@@ -5,8 +5,11 @@ import {
|
||||
getUserNotifications,
|
||||
getNotifications,
|
||||
createNotification,
|
||||
updateNotification,
|
||||
archiveNotification,
|
||||
markNotificationAsRead,
|
||||
getCurrentUserTenantId,
|
||||
resolveUserIdByEmail,
|
||||
} from '../services/api';
|
||||
import {
|
||||
Notification,
|
||||
@@ -113,7 +116,6 @@ export function useInAppNotifications() {
|
||||
return list || [];
|
||||
},
|
||||
refetchInterval: 30000,
|
||||
staleTime: 10000,
|
||||
});
|
||||
|
||||
const inAppNotifications = useMemo(() => {
|
||||
@@ -124,7 +126,10 @@ export function useInAppNotifications() {
|
||||
|
||||
const inboxNotifications = useMemo(() => {
|
||||
return inAppNotifications.filter(
|
||||
n => !trashedIds.includes(String(n.id)) && Number(n.status) !== NotificationStatus.Draft
|
||||
n =>
|
||||
!trashedIds.includes(String(n.id)) &&
|
||||
Number(n.status) !== NotificationStatus.Archived &&
|
||||
Number(n.status) !== NotificationStatus.Draft
|
||||
);
|
||||
}, [inAppNotifications, trashedIds]);
|
||||
|
||||
@@ -151,7 +156,9 @@ export function useInAppNotifications() {
|
||||
}, [inAppNotifications, starredIds, trashedIds]);
|
||||
|
||||
const trashedNotifications = useMemo(() => {
|
||||
return inAppNotifications.filter(n => trashedIds.includes(String(n.id)));
|
||||
return inAppNotifications.filter(
|
||||
n => trashedIds.includes(String(n.id)) || Number(n.status) === NotificationStatus.Archived
|
||||
);
|
||||
}, [inAppNotifications, trashedIds]);
|
||||
|
||||
const markAsReadMutation = useMutation({
|
||||
@@ -191,7 +198,8 @@ export function useInAppNotifications() {
|
||||
});
|
||||
}, [starredStorageKey]);
|
||||
|
||||
const deleteMessage = useCallback((id: string | number) => {
|
||||
// Delete message (NotificationStatus == Archived & move to trash)
|
||||
const deleteMessage = useCallback(async (id: string | number) => {
|
||||
const strId = String(id);
|
||||
setTrashedIds(prev => {
|
||||
const next = prev.includes(strId) ? prev : [...prev, strId];
|
||||
@@ -201,9 +209,22 @@ export function useInAppNotifications() {
|
||||
} catch {}
|
||||
return next;
|
||||
});
|
||||
}, [trashStorageKey]);
|
||||
|
||||
const restoreMessage = useCallback((id: string | number) => {
|
||||
queryClient.setQueryData<Notification[]>(['inapp-notifications', userId], (old = []) =>
|
||||
old.map(n => (String(n.id) === strId ? { ...n, status: NotificationStatus.Archived } : n))
|
||||
);
|
||||
|
||||
try {
|
||||
await archiveNotification(id);
|
||||
} catch (err) {
|
||||
console.warn('[deleteMessage] Error archiving notification on server:', err);
|
||||
} finally {
|
||||
queryClient.invalidateQueries({ queryKey: ['inapp-notifications'] });
|
||||
}
|
||||
}, [trashStorageKey, userId, queryClient]);
|
||||
|
||||
// Restore from trash / unarchive
|
||||
const restoreMessage = useCallback(async (id: string | number) => {
|
||||
const strId = String(id);
|
||||
setTrashedIds(prev => {
|
||||
const next = prev.filter(x => x !== strId);
|
||||
@@ -213,7 +234,19 @@ export function useInAppNotifications() {
|
||||
} catch {}
|
||||
return next;
|
||||
});
|
||||
}, [trashStorageKey]);
|
||||
|
||||
queryClient.setQueryData<Notification[]>(['inapp-notifications', userId], (old = []) =>
|
||||
old.map(n => (String(n.id) === strId ? { ...n, status: NotificationStatus.Read } : n))
|
||||
);
|
||||
|
||||
try {
|
||||
await markNotificationAsRead(id);
|
||||
} catch (err) {
|
||||
console.warn('[restoreMessage] Error restoring notification on server:', err);
|
||||
} finally {
|
||||
queryClient.invalidateQueries({ queryKey: ['inapp-notifications'] });
|
||||
}
|
||||
}, [trashStorageKey, userId, queryClient]);
|
||||
|
||||
const permanentDelete = useCallback((id: string | number) => {
|
||||
const strId = String(id);
|
||||
@@ -270,8 +303,9 @@ export function useInAppNotifications() {
|
||||
|
||||
const sendMessageMutation = useMutation({
|
||||
mutationFn: async (payload: {
|
||||
recipientId: string;
|
||||
recipientEmail: string;
|
||||
recipientId?: string;
|
||||
recipientEmail?: string;
|
||||
recipient?: string;
|
||||
recipientName?: string;
|
||||
subject: string;
|
||||
message: string;
|
||||
@@ -281,11 +315,23 @@ export function useInAppNotifications() {
|
||||
}) => {
|
||||
const orgId = payload.organisationId || getCurrentUserTenantId() || 1;
|
||||
const targetPlatform = payload.platform || NotificationPlatform.InApp;
|
||||
const formattedDestination = `To: ${payload.recipientEmail} | From: ${userEmail || 'Partner User'}`;
|
||||
const email = payload.recipientEmail || (payload.recipient?.includes('@') ? payload.recipient : '') || 'support@ifri.khongisa.co.za';
|
||||
let id = payload.recipientId && payload.recipientId !== '00000000-0000-0000-0000-000000000000'
|
||||
? payload.recipientId
|
||||
: (!payload.recipient?.includes('@') && payload.recipient !== '00000000-0000-0000-0000-000000000000' ? payload.recipient : '');
|
||||
|
||||
if (!id && email) {
|
||||
id = (await resolveUserIdByEmail(email, orgId)) || '';
|
||||
}
|
||||
|
||||
if (!id) {
|
||||
id = '00000000-0000-0000-0000-000000000000';
|
||||
}
|
||||
const formattedDestination = `To: ${email} | From: ${userEmail || 'Partner User'}`;
|
||||
|
||||
const req: CreateNotificationRequest = {
|
||||
organisationId: orgId,
|
||||
recipient: payload.recipientId,
|
||||
recipient: id,
|
||||
platform: targetPlatform,
|
||||
subject: payload.subject,
|
||||
destination: formattedDestination,
|
||||
@@ -298,7 +344,7 @@ export function useInAppNotifications() {
|
||||
const sentItem: Notification = {
|
||||
id: `sent_${Date.now()}`,
|
||||
organisationId: orgId,
|
||||
recipient: payload.recipientId,
|
||||
recipient: payload.recipientId || payload.recipient || "",
|
||||
platform: targetPlatform,
|
||||
status: NotificationStatus.Sent,
|
||||
subject: payload.subject,
|
||||
|
||||
@@ -93,6 +93,7 @@ export const NOTIFICATION_STATUS_OPTIONS = [
|
||||
{ value: NotificationStatus.Cancelled, label: 'Cancelled' },
|
||||
{ value: NotificationStatus.Bounced, label: 'Bounced' },
|
||||
{ value: NotificationStatus.Draft, label: 'Draft' },
|
||||
{ value: NotificationStatus.Archived, label: 'Archived' },
|
||||
];
|
||||
|
||||
export const NOTIFICATION_PLATFORM_OPTIONS = [
|
||||
@@ -462,14 +463,36 @@ export const getOrganisationTypeLabel = (type: OrganisationType | number): strin
|
||||
return opt ? opt.label : 'Organisation';
|
||||
};
|
||||
|
||||
export const getIndustrySectorLabel = (sector: IndustrySector | number): string => {
|
||||
const opt = INDUSTRY_SECTOR_OPTIONS.find((o) => o.value === Number(sector));
|
||||
return opt ? opt.label : 'Industry Sector';
|
||||
export const getIndustrySectorLabel = (sector?: IndustrySector | number | string | null): string => {
|
||||
if (sector === undefined || sector === null || sector === '') return '';
|
||||
const num = Number(sector);
|
||||
if (!isNaN(num)) {
|
||||
const opt = INDUSTRY_SECTOR_OPTIONS.find((o) => o.value === num);
|
||||
if (opt) return opt.label;
|
||||
}
|
||||
const str = String(sector).trim().toLowerCase();
|
||||
for (const opt of INDUSTRY_SECTOR_OPTIONS) {
|
||||
if (opt.label.toLowerCase() === str) return opt.label;
|
||||
const enumKey = (IndustrySector as any)[opt.value];
|
||||
if (enumKey && String(enumKey).toLowerCase() === str) return opt.label;
|
||||
}
|
||||
return String(sector).replace(/([a-z])([A-Z])/g, '$1 $2');
|
||||
};
|
||||
|
||||
export const getProjectTypeLabel = (type: ProjectType | number): string => {
|
||||
const opt = PROJECT_TYPE_OPTIONS.find((o) => o.value === Number(type));
|
||||
return opt ? opt.label : 'Project Type';
|
||||
export const getProjectTypeLabel = (type?: ProjectType | number | string | null): string => {
|
||||
if (type === undefined || type === null || type === '') return '';
|
||||
const num = Number(type);
|
||||
if (!isNaN(num)) {
|
||||
const opt = PROJECT_TYPE_OPTIONS.find((o) => o.value === num);
|
||||
if (opt) return opt.label;
|
||||
}
|
||||
const str = String(type).trim().toLowerCase();
|
||||
for (const opt of PROJECT_TYPE_OPTIONS) {
|
||||
if (opt.label.toLowerCase() === str) return opt.label;
|
||||
const enumKey = (ProjectType as any)[opt.value];
|
||||
if (enumKey && String(enumKey).toLowerCase() === str) return opt.label;
|
||||
}
|
||||
return String(type).replace(/([a-z])([A-Z])/g, '$1 $2');
|
||||
};
|
||||
|
||||
export const getEvidenceTypeLabel = (type: EvidenceType | number): string => {
|
||||
@@ -514,6 +537,8 @@ export const getEmailStatusBadge = (status?: EmailStatuses | number | null) => {
|
||||
export const getNotificationStatusBadge = (status?: NotificationStatus | number | null) => {
|
||||
const s = Number(status);
|
||||
switch (s) {
|
||||
case NotificationStatus.Archived:
|
||||
return { label: 'Archived', className: 'bg-slate-800 text-slate-300 border-slate-700 font-semibold' };
|
||||
case NotificationStatus.Draft:
|
||||
return { label: 'Draft', className: 'bg-purple-50 text-purple-800 border-purple-300 font-semibold' };
|
||||
case NotificationStatus.Delivered:
|
||||
|
||||
@@ -149,3 +149,79 @@ export function parseMarkdownToHtml(markdown: string): string {
|
||||
return html;
|
||||
}
|
||||
|
||||
|
||||
|
||||
/**
|
||||
* Converts formatted HTML from WYSIWYG composition into clean, semantic Markdown.
|
||||
* Preserves headings, bold, italic, blockquotes, lists, dividers, and links.
|
||||
*/
|
||||
export function htmlToMarkdown(html: string): string {
|
||||
if (!html) return '';
|
||||
let md = html
|
||||
.replace(/<script\b[^<]*(?:(?!<\/script>)<[^<]*)*<\/script>/gi, '')
|
||||
.replace(/<style\b[^<]*(?:(?!<\/style>)<[^<]*)*<\/style>/gi, '');
|
||||
|
||||
// Line breaks & paragraph breaks
|
||||
md = md.replace(/<br\s*\/?>/gi, '\n');
|
||||
md = md.replace(/<\/p>/gi, '\n\n');
|
||||
md = md.replace(/<p[^>]*>/gi, '');
|
||||
|
||||
// Headers
|
||||
md = md.replace(/<h1[^>]*>(.*?)<\/h1>/gis, '# $1\n\n');
|
||||
md = md.replace(/<h2[^>]*>(.*?)<\/h2>/gis, '## $1\n\n');
|
||||
md = md.replace(/<h3[^>]*>(.*?)<\/h3>/gis, '### $1\n\n');
|
||||
md = md.replace(/<h4[^>]*>(.*?)<\/h4>/gis, '#### $1\n\n');
|
||||
|
||||
// Bold / Italic / Underline
|
||||
md = md.replace(/<(strong|b)[^>]*>(.*?)<\/\1>/gis, '**$2**');
|
||||
md = md.replace(/<(em|i)[^>]*>(.*?)<\/\1>/gis, '*$2*');
|
||||
md = md.replace(/<u[^>]*>(.*?)<\/u>/gis, '$1');
|
||||
|
||||
// Blockquote
|
||||
md = md.replace(/<blockquote[^>]*>(.*?)<\/blockquote>/gis, (_match, p1) => {
|
||||
return (
|
||||
p1
|
||||
.trim()
|
||||
.split('\n')
|
||||
.map((l: string) => `> ${l.trim()}`)
|
||||
.join('\n') + '\n\n'
|
||||
);
|
||||
});
|
||||
|
||||
// Unordered Lists
|
||||
md = md.replace(/<ul[^>]*>(.*?)<\/ul>/gis, (_match, p1) => {
|
||||
return p1.replace(/<li[^>]*>(.*?)<\/li>/gis, '- $1\n') + '\n';
|
||||
});
|
||||
|
||||
// Ordered Lists
|
||||
md = md.replace(/<ol[^>]*>(.*?)<\/ol>/gis, (_match, p1) => {
|
||||
let index = 1;
|
||||
return p1.replace(/<li[^>]*>(.*?)<\/li>/gis, () => `${index++}. $1\n`) + '\n';
|
||||
});
|
||||
|
||||
// Horizontal Rules
|
||||
md = md.replace(/<hr\s*\/?>/gi, '\n---\n\n');
|
||||
|
||||
// Links
|
||||
md = md.replace(/<a\b[^>]*href=["']([^"']*)["'][^>]*>(.*?)<\/a>/gis, '[$2]($1)');
|
||||
|
||||
// Divs
|
||||
md = md.replace(/<\/div>/gi, '\n');
|
||||
md = md.replace(/<div[^>]*>/gi, '');
|
||||
|
||||
// Strip any remaining HTML tags
|
||||
md = md.replace(/<[^>]+>/g, '');
|
||||
|
||||
// Unescape standard HTML entities
|
||||
md = md
|
||||
.replace(/ /g, ' ')
|
||||
.replace(/&/g, '&')
|
||||
.replace(/</g, '<')
|
||||
.replace(/>/g, '>')
|
||||
.replace(/"/g, '"')
|
||||
.replace(/'/g, "'");
|
||||
|
||||
// Collapse 3+ newlines into 2
|
||||
return md.replace(/\n{3,}/g, '\n\n').trim();
|
||||
}
|
||||
|
||||
|
||||
@@ -4,15 +4,38 @@ import { bumpMutationEpoch } from '../services/httpClient';
|
||||
export const queryClient = new QueryClient({
|
||||
defaultOptions: {
|
||||
queries: {
|
||||
staleTime: 1000 * 60 * 2, // 2 minutes fresh in memory
|
||||
gcTime: 1000 * 60 * 10, // 10 minutes cache retention
|
||||
refetchOnWindowFocus: false,
|
||||
refetchOnReconnect: true,
|
||||
// Remove caching from all pages: zero stale time and zero cache retention
|
||||
// Whenever a component needs data, it must call its corresponding endpoint(s)
|
||||
staleTime: 0,
|
||||
gcTime: 0,
|
||||
refetchOnMount: 'always',
|
||||
refetchOnWindowFocus: 'always',
|
||||
refetchOnReconnect: 'always',
|
||||
retry: 1,
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
// Retain caching ONLY for sync status, auth token, roles and grants
|
||||
const RETAINED_CACHE_OPTIONS = {
|
||||
staleTime: 5 * 60 * 1000,
|
||||
gcTime: 10 * 60 * 1000,
|
||||
refetchOnMount: false,
|
||||
refetchOnWindowFocus: false,
|
||||
};
|
||||
|
||||
queryClient.setQueryDefaults(['roles'], RETAINED_CACHE_OPTIONS);
|
||||
queryClient.setQueryDefaults(['system-roles'], RETAINED_CACHE_OPTIONS);
|
||||
queryClient.setQueryDefaults(['role-claims'], RETAINED_CACHE_OPTIONS);
|
||||
queryClient.setQueryDefaults(['user-roles'], RETAINED_CACHE_OPTIONS);
|
||||
queryClient.setQueryDefaults(['user-claims'], RETAINED_CACHE_OPTIONS);
|
||||
queryClient.setQueryDefaults(['grants'], RETAINED_CACHE_OPTIONS);
|
||||
queryClient.setQueryDefaults(['user-grants'], RETAINED_CACHE_OPTIONS);
|
||||
queryClient.setQueryDefaults(['user-sync'], RETAINED_CACHE_OPTIONS);
|
||||
queryClient.setQueryDefaults(['sync-status'], RETAINED_CACHE_OPTIONS);
|
||||
queryClient.setQueryDefaults(['auth'], RETAINED_CACHE_OPTIONS);
|
||||
queryClient.setQueryDefaults(['auth-token'], RETAINED_CACHE_OPTIONS);
|
||||
|
||||
/**
|
||||
* Invalidates queries while simultaneously bumping the mutation epoch.
|
||||
*/
|
||||
|
||||
@@ -374,7 +374,7 @@ export const evidenceApi = {
|
||||
apiClient.get<Models.Evidence[]>(`/v1/api/evidence/milestone/${hashParam(milestoneId)}`),
|
||||
|
||||
getProjectEvidence: (projectId: string | number) =>
|
||||
apiClient.get<Models.Evidence[]>(`/v1/api/evidence/${hashParam(projectId)}`),
|
||||
apiClient.get<Models.Evidence[]>(`/v1/api/evidence/projects/${hashParam(projectId)}`),
|
||||
|
||||
createMilestoneRequest: (payload: Models.CreateMilestoneRequest) =>
|
||||
apiClient.post<Models.Milestone>('/v1/api/evidence/milestones', payload),
|
||||
|
||||
@@ -109,6 +109,7 @@ import {
|
||||
UpdateRuleRequest,
|
||||
Pagination,
|
||||
Notification,
|
||||
NotificationPlatform,
|
||||
NotificationStatus,
|
||||
Role,
|
||||
Claim,
|
||||
@@ -810,6 +811,7 @@ export const updateActivity = async (payload: UpdateActivityRequest): Promise<Ac
|
||||
: null;
|
||||
|
||||
const body = {
|
||||
activityId: toIntegerId(payload.activityId),
|
||||
projectId: toIntegerId(payload.projectId),
|
||||
milestoneId: toNullableIntegerId(payload.milestoneId),
|
||||
category: payload.category || 'Operations',
|
||||
@@ -830,6 +832,46 @@ export const updateActivity = async (payload: UpdateActivityRequest): Promise<Ac
|
||||
}
|
||||
};
|
||||
|
||||
|
||||
export const submitActivityForApproval = async (
|
||||
activity: Activity,
|
||||
projectId: number | string,
|
||||
evidences?: Evidence[]
|
||||
): Promise<void> => {
|
||||
if (evidences && evidences.length > 0) {
|
||||
for (const ev of evidences) {
|
||||
if (Number(ev.status) === ApprovalStatus.Pending) {
|
||||
try {
|
||||
await updateEvidence({
|
||||
evidenceId: ev.id,
|
||||
version: ev.version || 1,
|
||||
evidenceType: (ev as any).evidenceType ?? ev.type,
|
||||
status: ApprovalStatus.Review,
|
||||
});
|
||||
} catch (e) {
|
||||
console.warn('Could not update evidence status', ev.id, e);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
try {
|
||||
await updateActivity({
|
||||
activityId: activity.id,
|
||||
projectId,
|
||||
milestoneId: activity.milestoneId,
|
||||
category: activity.category || 'Operations',
|
||||
amount: activity.amount || 0,
|
||||
title: activity.title || 'Activity',
|
||||
description: activity.description || '',
|
||||
performedAt: new Date().toISOString(),
|
||||
status: ApprovalStatus.Review,
|
||||
});
|
||||
} catch (e) {
|
||||
console.warn('Could not update activity fulfillment record', e);
|
||||
}
|
||||
};
|
||||
|
||||
export const getMilestones = async (projectId: string | number): Promise<Milestone[]> => {
|
||||
const hashedProjectId = await toolsService.resolveProjectId(projectId);
|
||||
try {
|
||||
@@ -1377,12 +1419,12 @@ export const getMilestoneEvidence = async (milestoneId: string | number): Promis
|
||||
export const getProjectEvidence = async (projectId: string | number): Promise<Evidence[]> => {
|
||||
try {
|
||||
const hashedProjectId = await toolsService.resolveProjectId(projectId);
|
||||
const res = await apiClient.get<Evidence[]>(`/v1/api/evidence/${hashedProjectId}`);
|
||||
const res = await apiClient.get<Evidence[]>(`/v1/api/evidence/projects/${hashedProjectId}`);
|
||||
return res.data || [];
|
||||
} catch (err: any) {
|
||||
if ((err?.response?.status === 404 || err?.response?.status === 400) && String(projectId) !== '') {
|
||||
try {
|
||||
const rawRes = await apiClient.get<Evidence[]>(`/v1/api/evidence/${projectId}`);
|
||||
const rawRes = await apiClient.get<Evidence[]>(`/v1/api/evidence/projects/${projectId}`);
|
||||
return rawRes.data || [];
|
||||
} catch (fallbackErr: any) {
|
||||
if (fallbackErr?.response?.status === 404) {
|
||||
@@ -1857,7 +1899,9 @@ export const createNotification = async (payload: CreateNotificationRequest): Pr
|
||||
subject: payload.subject || null,
|
||||
destination: payload.destination || 'In-App User Inbox',
|
||||
message: payload.message,
|
||||
status: payload.status !== undefined ? payload.status : undefined,
|
||||
status: payload.status !== undefined
|
||||
? payload.status
|
||||
: (Number(payload.platform) === NotificationPlatform.InApp ? NotificationStatus.Delivered : undefined),
|
||||
});
|
||||
};
|
||||
|
||||
@@ -1879,6 +1923,13 @@ export const markNotificationAsRead = async (notificationId: number | string): P
|
||||
};
|
||||
|
||||
// ==========================================
|
||||
export const archiveNotification = async (notificationId: number | string): Promise<void> => {
|
||||
await updateNotification({
|
||||
notificationId: toIntegerId(notificationId),
|
||||
status: NotificationStatus.Archived,
|
||||
});
|
||||
};
|
||||
|
||||
// 9. Governance: Tenants APIs
|
||||
// ==========================================
|
||||
export const getTenants = async (): Promise<Tenant[]> => {
|
||||
@@ -1955,6 +2006,91 @@ export const getTenantUsers = async (tenantId: string | number): Promise<TenantU
|
||||
};
|
||||
|
||||
// ==========================================
|
||||
/**
|
||||
* Resolves a recipient user GUID by email address across TenantUsers and Directory.
|
||||
* Automatically caches any found user emails and IDs in local memory.
|
||||
*/
|
||||
export const resolveUserIdByEmail = async (
|
||||
email: string,
|
||||
hintTenantOrOrgId?: number | string
|
||||
): Promise<string | undefined> => {
|
||||
if (!email || typeof email !== 'string' || !email.includes('@')) {
|
||||
return undefined;
|
||||
}
|
||||
const cleanEmail = email.trim().toLowerCase();
|
||||
|
||||
// 1. Fast cache check
|
||||
const cachedId = getUserIdByEmail(cleanEmail);
|
||||
if (cachedId && cachedId !== '00000000-0000-0000-0000-000000000000') {
|
||||
return cachedId;
|
||||
}
|
||||
|
||||
const matchInTenantUsers = (tenantUsers: TenantUser[]): string | undefined => {
|
||||
if (!Array.isArray(tenantUsers)) return undefined;
|
||||
let match: string | undefined = undefined;
|
||||
for (const tu of tenantUsers) {
|
||||
const uId = tu.userId || tu.user?.id;
|
||||
const uEmail = tu.user?.email || (tu as any)?.email;
|
||||
if (uId && uEmail) {
|
||||
registerUserEmail(uId, uEmail);
|
||||
if (String(uEmail).trim().toLowerCase() === cleanEmail) {
|
||||
match = String(uId).trim();
|
||||
}
|
||||
}
|
||||
}
|
||||
return match;
|
||||
};
|
||||
|
||||
// 2. Query tenant users with hintTenantOrOrgId
|
||||
if (hintTenantOrOrgId) {
|
||||
try {
|
||||
const users = await getTenantUsers(hintTenantOrOrgId);
|
||||
const match = matchInTenantUsers(users);
|
||||
if (match) return match;
|
||||
} catch {}
|
||||
}
|
||||
|
||||
// 3. Query all tenants from getTenants and their users
|
||||
try {
|
||||
const tenants = await getTenants();
|
||||
for (const t of tenants) {
|
||||
if (hintTenantOrOrgId && String(t.id) === String(hintTenantOrOrgId)) continue;
|
||||
try {
|
||||
const users = await getTenantUsers(t.id);
|
||||
const match = matchInTenantUsers(users);
|
||||
if (match) return match;
|
||||
} catch {}
|
||||
}
|
||||
} catch {}
|
||||
|
||||
// 4. Try standard fallback tenant IDs
|
||||
for (const tid of [1, 2, 3]) {
|
||||
if (hintTenantOrOrgId && String(tid) === String(hintTenantOrOrgId)) continue;
|
||||
try {
|
||||
const users = await getTenantUsers(tid);
|
||||
const match = matchInTenantUsers(users);
|
||||
if (match) return match;
|
||||
} catch {}
|
||||
}
|
||||
|
||||
// 5. Query user directory fallback
|
||||
try {
|
||||
const dirUsers = await apiClient.post<User[]>('/v1/api/users/1/200', {});
|
||||
if (Array.isArray(dirUsers.data)) {
|
||||
for (const u of dirUsers.data) {
|
||||
if (u && u.id && u.email) {
|
||||
registerUserEmail(u.id, u.email);
|
||||
if (u.email.trim().toLowerCase() === cleanEmail) {
|
||||
return String(u.id).trim();
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
} catch {}
|
||||
|
||||
return undefined;
|
||||
};
|
||||
|
||||
// 10. Projects: Beneficiaries & Outcomes APIs
|
||||
// ==========================================
|
||||
export const getProjectBeneficiaries = async (projectId: string | number): Promise<Beneficiary[]> => {
|
||||
|
||||
@@ -39,6 +39,8 @@ export const apiClient = axios.create({
|
||||
headers: {
|
||||
'Content-Type': 'application/json; version=1',
|
||||
Accept: 'application/json; version=1',
|
||||
'Cache-Control': 'no-cache, no-store, must-revalidate',
|
||||
Pragma: 'no-cache',
|
||||
},
|
||||
timeout: 15000,
|
||||
});
|
||||
@@ -58,31 +60,16 @@ apiClient.interceptors.request.use(
|
||||
config.url?.includes('amazonaws.com') ||
|
||||
config.url?.includes('garage');
|
||||
|
||||
// Always attach freshness timestamp and mutation epoch to internal GET requests
|
||||
// to guarantee zero stale data, bypass browser HTTP caches, and prevent stale OutputCache hits
|
||||
if (config.method?.toUpperCase() === 'GET' && !isExternal) {
|
||||
const headers = config.headers || {};
|
||||
const hasNoCacheHeader =
|
||||
headers['Cache-Control'] === 'no-cache' ||
|
||||
headers['cache-control'] === 'no-cache' ||
|
||||
headers['Pragma'] === 'no-cache';
|
||||
|
||||
if (hasNoCacheHeader) {
|
||||
if (config.params instanceof URLSearchParams) {
|
||||
config.params.set('_t', String(Date.now()));
|
||||
} else {
|
||||
config.params = config.params || {};
|
||||
config.params._t = Date.now();
|
||||
}
|
||||
if (config.params instanceof URLSearchParams) {
|
||||
config.params.set('_t', String(Date.now()));
|
||||
config.params.set('_v', String(getMutationEpoch()));
|
||||
} else {
|
||||
if (config.params instanceof URLSearchParams) {
|
||||
if (!config.params.has('_v') && !config.params.has('_t')) {
|
||||
config.params.set('_v', String(getMutationEpoch()));
|
||||
}
|
||||
} else {
|
||||
config.params = config.params || {};
|
||||
if (!config.params._v && !config.params._t) {
|
||||
config.params._v = getMutationEpoch();
|
||||
}
|
||||
}
|
||||
config.params = config.params || {};
|
||||
config.params._t = Date.now();
|
||||
config.params._v = getMutationEpoch();
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -5,13 +5,17 @@ const env = (typeof import.meta !== 'undefined' && import.meta.env
|
||||
? import.meta.env
|
||||
: {}) as Record<string, string | undefined>;
|
||||
|
||||
export const DEFAULT_HASHER_SALT =
|
||||
'TGlnaHQgQ2hhcm1zLCBNaWRyYW5kQm9va3MgbWFrZSBsb3RzIG9mIG1vbmV5IGFuZCBhcmUgYSBzdWNjZXNzZnVsIHZpcmFsIHN0b3J5IGluIFNvdXRoIEFmcmljYQ==';
|
||||
export const DEFAULT_MIN_HASH_LENGTH = 11;
|
||||
|
||||
/**
|
||||
* Deterministic Hashids configuration matching pfm-api HasherSettings.
|
||||
* Ensures 100% interoperability with the C# Hashids.net NuGet package on the backend.
|
||||
*/
|
||||
export const HASHER_SETTINGS = {
|
||||
minHashLength: Number(env.VITE_HASHER_MIN_LENGTH) || 11,
|
||||
salt: (env.VITE_HASHER_SALT as string) || '',
|
||||
minHashLength: Number(env.VITE_HASHER_MIN_LENGTH) || DEFAULT_MIN_HASH_LENGTH,
|
||||
salt: (env.VITE_HASHER_SALT as string) || DEFAULT_HASHER_SALT,
|
||||
};
|
||||
|
||||
let hasher = new Hashids(HASHER_SETTINGS.salt, HASHER_SETTINGS.minHashLength);
|
||||
@@ -21,9 +25,13 @@ export function updateHasherSettings(salt?: string, minLength?: number | string)
|
||||
if (salt && typeof salt === 'string' && salt.trim() !== '') {
|
||||
HASHER_SETTINGS.salt = salt.trim();
|
||||
updated = true;
|
||||
} else if (!HASHER_SETTINGS.salt) {
|
||||
HASHER_SETTINGS.salt = DEFAULT_HASHER_SALT;
|
||||
updated = true;
|
||||
}
|
||||
if (minLength !== undefined && minLength !== null && minLength !== '') {
|
||||
const parsed = Number(minLength);
|
||||
const cleanMin = String(minLength).replace(/[^0-9]/g, '');
|
||||
const parsed = Number(cleanMin);
|
||||
if (!isNaN(parsed) && parsed > 0) {
|
||||
HASHER_SETTINGS.minHashLength = parsed;
|
||||
updated = true;
|
||||
@@ -34,8 +42,9 @@ export function updateHasherSettings(salt?: string, minLength?: number | string)
|
||||
}
|
||||
}
|
||||
|
||||
export function isAlreadyHashId(candidate: string): boolean {
|
||||
export function isAlreadyHashId(candidate: string | null | undefined): boolean {
|
||||
if (
|
||||
!candidate ||
|
||||
typeof candidate !== 'string' ||
|
||||
candidate.length < HASHER_SETTINGS.minHashLength ||
|
||||
/^\d+$/.test(candidate)
|
||||
@@ -47,20 +56,28 @@ export function isAlreadyHashId(candidate: string): boolean {
|
||||
if (!Array.isArray(decoded) || decoded.length === 0) {
|
||||
return false;
|
||||
}
|
||||
return hasher.encode(decoded as number[]) === candidate;
|
||||
return hasher.encode(decoded as any) === candidate;
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
export function encodeHashIdSync(id: number | string | null | undefined): string {
|
||||
export function encodeHashIdSync(id: number | string | bigint | null | undefined): string {
|
||||
if (id === undefined || id === null || id === '') {
|
||||
return '';
|
||||
}
|
||||
|
||||
if (typeof id === 'bigint') {
|
||||
if (id < 0n) return '';
|
||||
return hasher.encode(id);
|
||||
}
|
||||
|
||||
if (typeof id === 'number') {
|
||||
if (isNaN(id) || !isFinite(id) || id < 0) return '';
|
||||
return hasher.encode(id);
|
||||
if (id <= Number.MAX_SAFE_INTEGER) {
|
||||
return hasher.encode(id);
|
||||
}
|
||||
return hasher.encode(BigInt(Math.trunc(id)));
|
||||
}
|
||||
|
||||
const str = String(id).trim();
|
||||
@@ -72,10 +89,8 @@ export function encodeHashIdSync(id: number | string | null | undefined): string
|
||||
|
||||
if (/^\d+$/.test(str)) {
|
||||
try {
|
||||
const num = parseInt(str, 10);
|
||||
if (!isNaN(num) && num >= 0) {
|
||||
return hasher.encode(num);
|
||||
}
|
||||
// JavaScript hashids v2 natively supports BigInt, matching C# Hashids.net EncodeLong without int64 precision loss
|
||||
return hasher.encode(BigInt(str));
|
||||
} catch {
|
||||
// Fall through
|
||||
}
|
||||
@@ -84,22 +99,37 @@ export function encodeHashIdSync(id: number | string | null | undefined): string
|
||||
return str;
|
||||
}
|
||||
|
||||
export function decodeHashIdSync(hashId: string | number | null | undefined): number | string {
|
||||
export function decodeHashIdSync(hashId: string | number | bigint | null | undefined): number | string {
|
||||
if (hashId === undefined || hashId === null || hashId === '') return '';
|
||||
if (typeof hashId === 'number') return hashId;
|
||||
if (typeof hashId === 'bigint') return hashId.toString();
|
||||
|
||||
const str = String(hashId).trim();
|
||||
if (!str) return '';
|
||||
|
||||
if (/^-?\d+$/.test(str)) {
|
||||
const parsed = parseInt(str, 10);
|
||||
return isNaN(parsed) ? str : parsed;
|
||||
try {
|
||||
const b = BigInt(str);
|
||||
if (b <= BigInt(Number.MAX_SAFE_INTEGER) && b >= BigInt(Number.MIN_SAFE_INTEGER)) {
|
||||
return Number(b);
|
||||
}
|
||||
return str;
|
||||
} catch {
|
||||
return str;
|
||||
}
|
||||
}
|
||||
|
||||
try {
|
||||
const decoded = hasher.decode(str);
|
||||
if (Array.isArray(decoded) && decoded.length > 0) {
|
||||
return Number(decoded[0]);
|
||||
const val = decoded[0];
|
||||
if (typeof val === 'bigint') {
|
||||
if (val <= BigInt(Number.MAX_SAFE_INTEGER) && val >= BigInt(Number.MIN_SAFE_INTEGER)) {
|
||||
return Number(val);
|
||||
}
|
||||
return val.toString();
|
||||
}
|
||||
return Number(val);
|
||||
}
|
||||
} catch {
|
||||
// If decoding fails, return raw string
|
||||
@@ -108,11 +138,14 @@ export function decodeHashIdSync(hashId: string | number | null | undefined): nu
|
||||
return str;
|
||||
}
|
||||
|
||||
export function decodeToNumberSync(hashId: number | string | null | undefined): number {
|
||||
export function decodeToNumberSync(hashId: number | string | bigint | null | undefined): number {
|
||||
if (hashId === null || hashId === undefined || hashId === '') return 0;
|
||||
if (typeof hashId === 'number') {
|
||||
return isNaN(hashId) ? 0 : Math.trunc(hashId);
|
||||
}
|
||||
if (typeof hashId === 'bigint') {
|
||||
return Number(hashId);
|
||||
}
|
||||
const str = String(hashId).trim();
|
||||
if (/^-?\d+$/.test(str)) {
|
||||
const parsed = parseInt(str, 10);
|
||||
@@ -129,19 +162,19 @@ export function decodeToNumberSync(hashId: number | string | null | undefined):
|
||||
}
|
||||
|
||||
export const toolsService = {
|
||||
encodeIdSync(id: number | string | null | undefined): string {
|
||||
encodeIdSync(id: number | string | bigint | null | undefined): string {
|
||||
return encodeHashIdSync(id);
|
||||
},
|
||||
|
||||
decodeIdSync(hashId: string | number | null | undefined): number | string {
|
||||
decodeIdSync(hashId: string | number | bigint | null | undefined): number | string {
|
||||
return decodeHashIdSync(hashId);
|
||||
},
|
||||
|
||||
decodeToNumberSync(hashId: number | string | null | undefined): number {
|
||||
decodeToNumberSync(hashId: number | string | bigint | null | undefined): number {
|
||||
return decodeToNumberSync(hashId);
|
||||
},
|
||||
|
||||
async encodeId(id: number | string | null | undefined, _context = 'general'): Promise<string> {
|
||||
async encodeId(id: number | string | bigint | null | undefined, _context = 'general'): Promise<string> {
|
||||
return encodeHashIdSync(id);
|
||||
},
|
||||
|
||||
@@ -149,35 +182,35 @@ export const toolsService = {
|
||||
return decodeHashIdSync(hashId);
|
||||
},
|
||||
|
||||
async resolveProjectId(projectId: number | string): Promise<string> {
|
||||
async resolveProjectId(projectId: number | string | bigint): Promise<string> {
|
||||
return encodeHashIdSync(projectId);
|
||||
},
|
||||
|
||||
async resolveOrganisationId(organisationId: number | string): Promise<string> {
|
||||
async resolveOrganisationId(organisationId: number | string | bigint): Promise<string> {
|
||||
return encodeHashIdSync(organisationId);
|
||||
},
|
||||
|
||||
async resolveProgrammeId(programmeId: number | string): Promise<string> {
|
||||
async resolveProgrammeId(programmeId: number | string | bigint): Promise<string> {
|
||||
return encodeHashIdSync(programmeId);
|
||||
},
|
||||
|
||||
async resolvePortfolioId(portfolioId: number | string): Promise<string> {
|
||||
async resolvePortfolioId(portfolioId: number | string | bigint): Promise<string> {
|
||||
return encodeHashIdSync(portfolioId);
|
||||
},
|
||||
|
||||
async resolveAwardId(awardId: number | string): Promise<string> {
|
||||
async resolveAwardId(awardId: number | string | bigint): Promise<string> {
|
||||
return encodeHashIdSync(awardId);
|
||||
},
|
||||
|
||||
async resolveContractId(contractId: number | string): Promise<string> {
|
||||
async resolveContractId(contractId: number | string | bigint): Promise<string> {
|
||||
return encodeHashIdSync(contractId);
|
||||
},
|
||||
|
||||
async resolveActivityId(activityId: number | string): Promise<string> {
|
||||
async resolveActivityId(activityId: number | string | bigint): Promise<string> {
|
||||
return encodeHashIdSync(activityId);
|
||||
},
|
||||
|
||||
async resolveMilestoneId(milestoneId: number | string): Promise<string> {
|
||||
async resolveMilestoneId(milestoneId: number | string | bigint): Promise<string> {
|
||||
return encodeHashIdSync(milestoneId);
|
||||
},
|
||||
|
||||
@@ -200,7 +233,7 @@ export const toolsService = {
|
||||
},
|
||||
};
|
||||
|
||||
export function useHashId(id?: number | string | null, context = 'general') {
|
||||
export function useHashId(id?: number | string | bigint | null, context = 'general') {
|
||||
return useQuery({
|
||||
queryKey: ['hashId', context, id !== undefined && id !== null ? String(id) : ''],
|
||||
queryFn: () => Promise.resolve(encodeHashIdSync(id)),
|
||||
|
||||
@@ -29,6 +29,7 @@ export enum NotificationStatus {
|
||||
Cancelled = 8,
|
||||
Bounced = 9,
|
||||
Draft = 10,
|
||||
Archived = 11,
|
||||
}
|
||||
|
||||
export enum NotificationPlatform {
|
||||
|
||||
@@ -4,9 +4,13 @@ const env = (typeof import.meta !== 'undefined' && import.meta.env
|
||||
? import.meta.env
|
||||
: {}) as Record<string, string | undefined>;
|
||||
|
||||
export const DEFAULT_HASHER_SALT =
|
||||
'TGlnaHQgQ2hhcm1zLCBNaWRyYW5kQm9va3MgbWFrZSBsb3RzIG9mIG1vbmV5IGFuZCBhcmUgYSBzdWNjZXNzZnVsIHZpcmFsIHN0b3J5IGluIFNvdXRoIEFmcmljYQ==';
|
||||
export const DEFAULT_MIN_HASH_LENGTH = 11;
|
||||
|
||||
export const HASHER_SETTINGS = {
|
||||
minHashLength: Number(env.VITE_HASHER_MIN_LENGTH) || 11,
|
||||
salt: (env.VITE_HASHER_SALT as string) || '',
|
||||
minHashLength: Number(env.VITE_HASHER_MIN_LENGTH) || DEFAULT_MIN_HASH_LENGTH,
|
||||
salt: (env.VITE_HASHER_SALT as string) || DEFAULT_HASHER_SALT,
|
||||
};
|
||||
|
||||
let hasher = new Hashids(HASHER_SETTINGS.salt, HASHER_SETTINGS.minHashLength);
|
||||
@@ -16,9 +20,13 @@ export function updateHasherSettings(salt?: string, minLength?: number | string)
|
||||
if (salt && typeof salt === 'string' && salt.trim() !== '') {
|
||||
HASHER_SETTINGS.salt = salt.trim();
|
||||
updated = true;
|
||||
} else if (!HASHER_SETTINGS.salt) {
|
||||
HASHER_SETTINGS.salt = DEFAULT_HASHER_SALT;
|
||||
updated = true;
|
||||
}
|
||||
if (minLength !== undefined && minLength !== null && minLength !== '') {
|
||||
const parsed = Number(minLength);
|
||||
const cleanMin = String(minLength).replace(/[^0-9]/g, '');
|
||||
const parsed = Number(cleanMin);
|
||||
if (!isNaN(parsed) && parsed > 0) {
|
||||
HASHER_SETTINGS.minHashLength = parsed;
|
||||
updated = true;
|
||||
@@ -46,7 +54,7 @@ export function isHashId(candidate: string | null | undefined): boolean {
|
||||
if (
|
||||
Array.isArray(decodedNumbers) &&
|
||||
decodedNumbers.length > 0 &&
|
||||
hasher.encode(decodedNumbers as number[]) === candidate
|
||||
hasher.encode(decodedNumbers as any) === candidate
|
||||
) {
|
||||
return true;
|
||||
}
|
||||
@@ -149,23 +157,24 @@ export function toSafeInt64String(val: string | number | bigint | null | undefin
|
||||
|
||||
/**
|
||||
* Deterministically encodes a numeric ID or 64-bit integer into a standard HashID string.
|
||||
* Uses BigInt for full int64 range, interoperable with C# Hashids.net EncodeLong.
|
||||
*/
|
||||
export function encodeEntityId(id: string | number | bigint | null | undefined): string {
|
||||
if (id === undefined || id === null || id === '') return '';
|
||||
|
||||
const str = typeof id === 'bigint' ? id.toString() : String(id).trim();
|
||||
if (typeof id === 'bigint') {
|
||||
if (id < 0n) return '';
|
||||
return hasher.encode(id);
|
||||
}
|
||||
|
||||
const str = String(id).trim();
|
||||
if (!str) return '';
|
||||
|
||||
if (isHashId(str)) return str;
|
||||
|
||||
if (/^\d+$/.test(str)) {
|
||||
try {
|
||||
const num = Number(str);
|
||||
if (num <= Number.MAX_SAFE_INTEGER && num >= 0) {
|
||||
return hasher.encode(num);
|
||||
}
|
||||
const bigHex = BigInt(str).toString(16);
|
||||
return hasher.encodeHex(bigHex);
|
||||
return hasher.encode(BigInt(str));
|
||||
} catch {
|
||||
// Fall through
|
||||
}
|
||||
@@ -189,18 +198,9 @@ export function decodeEntityId(hashId: string | number | null | undefined): stri
|
||||
if (Array.isArray(decoded) && decoded.length > 0) {
|
||||
return decoded[0].toString();
|
||||
}
|
||||
const decodedHex = hasher.decodeHex(str);
|
||||
if (decodedHex) {
|
||||
try {
|
||||
return BigInt('0x' + decodedHex).toString();
|
||||
} catch {
|
||||
return decodedHex;
|
||||
}
|
||||
}
|
||||
} catch {
|
||||
// Return raw
|
||||
}
|
||||
|
||||
return str;
|
||||
}
|
||||
|
||||
|
||||
+16
@@ -0,0 +1,16 @@
|
||||
/// <reference types="vite/client" />
|
||||
|
||||
interface ImportMetaEnv {
|
||||
readonly VITE_OIDC_AUTHORITY?: string;
|
||||
readonly VITE_OIDC_AUDIENCE?: string;
|
||||
readonly VITE_OIDC_CLIENT_ID?: string;
|
||||
readonly VITE_API_BASE_URL?: string;
|
||||
readonly VITE_HASHER_SALT?: string;
|
||||
readonly VITE_HASHER_MIN_LENGTH?: string;
|
||||
readonly [key: string]: any;
|
||||
}
|
||||
|
||||
interface ImportMeta {
|
||||
readonly env: ImportMetaEnv;
|
||||
}
|
||||
|
||||
@@ -4,7 +4,6 @@
|
||||
"experimentalDecorators": true,
|
||||
"useDefineForClassFields": false,
|
||||
"module": "ESNext",
|
||||
"types": ["vite/client"],
|
||||
"lib": [
|
||||
"ES2022",
|
||||
"DOM",
|
||||
|
||||
Reference in New Issue
Block a user