Refactored UI

Added Role filed to user table and endpoint implementation
This commit is contained in:
2026-09-28 10:25:00 +02:00
parent 7037ea72ca
commit ea71e55e2d
21 changed files with 3100 additions and 128 deletions
@@ -20,6 +20,7 @@ public sealed class SendInviteEndpoint : IEndpoint
await service.SendInviteAsync(request, context.GetUserId(), cancellationToken) switch
{
{ IsSuccess: true, Value: var invite } => Results.Ok(invite),
var r when r.HasError<ValidationError>() => Results.BadRequest(r.Errors.ToProblems()),
var r when r.HasError<CreateError>() => Results.BadRequest(r.Errors.ToProblems()),
var r => Results.InternalServerError(r.Errors.ToProblems())
})
@@ -29,6 +30,7 @@ public sealed class SendInviteEndpoint : IEndpoint
.MapToApiVersion(new ApiVersion(1))
.Produces<Invite>(StatusCodes.Status200OK)
.Produces<Problems>(StatusCodes.Status400BadRequest)
.Produces<Problems>(StatusCodes.Status400BadRequest)
.Produces<Problems>(StatusCodes.Status500InternalServerError)
.Produces(StatusCodes.Status401Unauthorized)
.WithTags(EndpointTags.Communications);
@@ -16,27 +16,22 @@ public class SyncUserEndpoint : IEndpoint
{
builder.MapPost("api/integration/sync", async (IJobOrchestrator jobOrchestrator,
HttpContext context, IUserService service, CancellationToken cancellationToken = default) =>
{
var result = await service.SyncUserAsync(context.GetUserId(), context.GetUserEmail(), cancellationToken);
if (result.IsSuccess)
await jobOrchestrator.SendAsync(SyncUserEvent.Create(), cancellationToken);
return result switch
{
{ IsSuccess: true } => Results.Ok(),
var r when r.HasError<CreateError>() => Results.BadRequest(r.Errors.ToProblems()),
var r => Results.InternalServerError(r.Errors.ToProblems())
};
})
.WithDescription("Trigger integration and data synchronization in the background")
.RequireAuthorization(Constants.MainScope)
.WithName(typeof(SyncUserEndpoint).ToEndpointName())
.MapToApiVersion(new ApiVersion(1))
.Produces(StatusCodes.Status200OK)
.Produces(StatusCodes.Status401Unauthorized)
.Produces<Problems>(StatusCodes.Status500InternalServerError)
.WithTags(EndpointTags.Users)
.CacheOutput();
await service.SyncUserAsync(context.GetUserId(), context.GetUserEmail(), context.GetUserRole(), cancellationToken) switch
{
{ IsSuccess: true } => Results.Ok(),
var r when r.HasError<CreateError>() => Results.BadRequest(r.Errors.ToProblems()),
var r when r.HasError<ValidationError>() => Results.BadRequest(r.Errors.ToProblems()),
var r => Results.InternalServerError(r.Errors.ToProblems())
})
.WithDescription("Trigger integration and data synchronization in the background")
.RequireAuthorization(Constants.MainScope)
.WithName(typeof(SyncUserEndpoint).ToEndpointName())
.MapToApiVersion(new ApiVersion(1))
.Produces(StatusCodes.Status200OK)
.Produces(StatusCodes.Status401Unauthorized)
.Produces<Problems>(StatusCodes.Status401Unauthorized)
.Produces<Problems>(StatusCodes.Status500InternalServerError)
.WithTags(EndpointTags.Users)
.CacheOutput();
}
}
@@ -9,7 +9,7 @@ public interface IUserService
{
[Log]
[Audit]
Task<Result> SyncUserAsync(Guid userId, string email, CancellationToken cancellationToken = default);
Task<Result> SyncUserAsync(Guid userId, string email, string role, CancellationToken cancellationToken = default);
[Log]
Task<Result<User[]>> GetUsersAsync(Pagination pagination, UserFilterRequest request, CancellationToken cancellationToken = default);
@@ -12,6 +12,8 @@ public static class General
public static string GetUserEmail(this HttpContext context) => (string)context.Items[Domain.Extensions.Constants.EmailtemKey]!;
public static string GetUserRole(this HttpContext context) => (string)context.Items[Domain.Extensions.Constants.UserRoleItemKey]!;
public static (bool Decoded, Guid userId) GetUserId(this ClaimsPrincipal principal)
{
var subClaim = principal.FindFirst(JwtRegisteredClaimNames.Sub)?.Value ?? principal.FindFirst(ClaimTypes.NameIdentifier)?.Value;
@@ -30,10 +32,19 @@ public static class General
return (decoded, emailClaim!);
}
public static Microsoft.AspNetCore.Mvc.ProblemDetails ToProblems(this IReadOnlyList<IError> errors) => new()
public static (bool Decoded, string role) GetUserRole(this ClaimsPrincipal principal)
{
var roleClaim = principal.FindFirst("role")?.Value ?? principal.FindFirst(ClaimTypes.Role)?.Value;
var decoded = !string.IsNullOrWhiteSpace(roleClaim);
return (decoded, roleClaim!);
}
public static Microsoft.AspNetCore.Mvc.ProblemDetails ToProblems(this IReadOnlyList<IError> errors, int statusCode = StatusCodes.Status400BadRequest) => new()
{
Detail = string.Join(',', errors.Select(e => e.Message)),
Status = StatusCodes.Status400BadRequest,
Status = statusCode,
Title = "Error",
};
}
@@ -15,12 +15,16 @@ public sealed class ActiveUserFilter(IIdentityService identityService) : IEndpoi
var (decoded, userId) = principal.GetUserId();
var (emailDecoded, email) = principal.GetUserEmail();
var (roleDecoded, role) = principal.GetUserRole();
if (!decoded)
return Results.Json(new { message = "Invalid user identifier in token." }, statusCode: StatusCodes.Status400BadRequest);
return Results.Json(new { message = "Invalid user identifier in token." }, statusCode: StatusCodes.Status401Unauthorized);
if (!emailDecoded)
return Results.Json(new { message = "Invalid/missing user email in token." }, statusCode: StatusCodes.Status400BadRequest);
return Results.Json(new { message = "Invalid/missing user email in token." }, statusCode: StatusCodes.Status401Unauthorized);
if (!roleDecoded)
return Results.Json(new { message = "Invalid/missing user role in token." }, statusCode: StatusCodes.Status401Unauthorized);
var path = context.HttpContext.Request.Path.Value;
@@ -34,6 +38,7 @@ public sealed class ActiveUserFilter(IIdentityService identityService) : IEndpoi
context.HttpContext.Items[UserIdItemKey] = userId;
context.HttpContext.Items[EmailtemKey] = email;
context.HttpContext.Items[UserRoleItemKey] = role;
return await next(context);
}
@@ -17,13 +17,21 @@ public sealed class CommunicationsService(IDbContextFactory<ApplicationDbContext
{
await using var context = await contextFactory.CreateDbContextAsync(cancellationToken);
var recipient = await context.TenantUsers.AsNoTracking()
.Include(i => i.User)
.Include(i => i.Tenant)
.FirstOrDefaultAsync(u => u.UserId == request.Recipient && u.Status == ActivityStatus.Active && u.Tenant!.Status == ActivityStatus.Active);
if (recipient is null)
return Result.Fail(ValidationError.Create("Tenant or User is not active, invite not sent"));
var invite = new Domain.Entities.Invite
{
OrganisationId = request.OrganisationId,
InvitedOrganisationId = request.InvitedOrganisationId,
AwardId = request.AwardId,
ContractId = request.ContractId,
Recipient = request.Recipient,
Recipient = recipient.UserId,
CreatedBy = userId,
CreatedAt = DateTime.UtcNow,
TenantId = tenantContext.TenantId
@@ -31,13 +39,13 @@ public sealed class CommunicationsService(IDbContextFactory<ApplicationDbContext
context.Invites.Add(invite);
var notificationRequest = new CreateNotificationRequest(request.InvitedOrganisationId, request.Recipient,
request.Platform, "Invitation to Onboard", string.Empty, request.Message);
var notificationRequest = new CreateNotificationRequest(request.InvitedOrganisationId, recipient.UserId,
request.Platform, "Funding award invite", recipient!.User!.Email, request.Message);
var notificationResult = await CreateNotificationAsync(notificationRequest, userId, cancellationToken);
return Result.FailIf(await context.SaveChangesAsync(cancellationToken) <= 0 && notificationResult.IsFailed,
CreateError.Create().CausedBy(notificationResult.Errors))
CreateError.Create("Failed to create invite, contact support").CausedBy(notificationResult.Errors))
.ToResult(invite.Map());
}));
@@ -60,9 +68,11 @@ public sealed class CommunicationsService(IDbContextFactory<ApplicationDbContext
{
await using var context = await contextFactory.CreateDbContextAsync(cancellationToken);
var invite = await context.Invites.AsNoTracking().FirstOrDefaultAsync(i => i.Recipient == applicantUserId, cancellationToken);
var invite = await context.Invites.AsNoTracking()
.OrderByDescending(o => o.Id)
.FirstOrDefaultAsync(i => i.Recipient == applicantUserId, cancellationToken);
return Result.FailIf(invite is null, NotFoundError.New)
return Result.FailIf(invite is null, NotFoundError.Create("No active invite found"))
.ToResult(invite!.Map());
}));
@@ -97,7 +107,8 @@ public sealed class CommunicationsService(IDbContextFactory<ApplicationDbContext
TenantId = tenantContext.TenantId
});
return Result.OkIf(await context.SaveChangesAsync(cancellationToken) > 0, CreateError.Create("Failed to create entry"));
return Result.OkIf(await context.SaveChangesAsync(cancellationToken) > 0,
CreateError.Create("Failed to create entry, contact support"));
}));
public async Task<Result> UpdateNotificationAsync(UpdateNotificationRequest request, CancellationToken cancellationToken = default) =>
@@ -114,7 +125,7 @@ public sealed class CommunicationsService(IDbContextFactory<ApplicationDbContext
.SetProperty(u => u.UpdatedAt, DateTime.UtcNow)
.SetProperty(u => u.Status, request.Status), cancellationToken);
return Result.OkIf(changedTotal > 0, UpdateError.Create($"Failed to update notification: {request.NotificationId}"));
return Result.OkIf(changedTotal > 0, UpdateError.Create($"Failed to update notification: {request.NotificationId}, contact support"));
}));
public async Task<Result<Notification[]>> GetNotificationsAsync(string organisationIdHash, CancellationToken cancellationToken = default) =>
@@ -150,7 +161,7 @@ public sealed class CommunicationsService(IDbContextFactory<ApplicationDbContext
var notification = await context.Notifications.AsNoTracking()
.FirstOrDefaultAsync(n => n.Id == decodeResult.Value, cancellationToken);
return Result.FailIf(notification is null, NotFoundError.New)
return Result.FailIf(notification is null, NotFoundError.Create("Notification not found"))
.ToResult(notification!.Map());
}));
}
@@ -61,8 +61,8 @@ public sealed class UserCacheService(UserService service, IDistributedCache cach
return Result.Ok(cached.FromJson<User[]>());
}));
public async Task<Result> SyncUserAsync(Guid userId, string email, CancellationToken cancellationToken = default) =>
await service.SyncUserAsync(userId, email, cancellationToken);
public async Task<Result> SyncUserAsync(Guid userId, string email, string role, CancellationToken cancellationToken = default) =>
await service.SyncUserAsync(userId, email, role, cancellationToken);
public async Task<Result> UpdateUserStatusAsync(Guid userId, ActivityStatus status, CancellationToken cancellationToken = default) =>
await service.UpdateUserStatusAsync(userId, status, cancellationToken);
@@ -10,11 +10,14 @@ namespace PostFundManagement.Application.Services.Users;
public sealed class UserService(IDbContextFactory<ApplicationDbContext> contextFactory, ITenantContextService tenantContext) : IUserService
{
public async Task<Result> SyncUserAsync(Guid userId, string email, CancellationToken cancellationToken = default) =>
public async Task<Result> SyncUserAsync(Guid userId, string email, string role, CancellationToken cancellationToken = default) =>
await Result.Try((Func<Task<Result>>)(async () =>
{
await using var context = await contextFactory.CreateDbContextAsync(cancellationToken);
if (string.IsNullOrWhiteSpace(role))
return Result.Fail(ValidationError.Create("Failed to sync, user role not present, contact support"));
var tenantUser = await context.TenantUsers
.Include(i => i.User)
.FirstOrDefaultAsync(u => u.UserId == userId, cancellationToken);
@@ -26,7 +29,8 @@ public sealed class UserService(IDbContextFactory<ApplicationDbContext> contextF
Id = userId,
Email = email,
Status = ActivityStatus.Active,
LastLoginAt = DateTime.UtcNow
LastLoginAt = DateTime.UtcNow,
Role = role
});
context.TenantUsers.Add(new Domain.Entities.TenantUser
@@ -42,11 +46,12 @@ public sealed class UserService(IDbContextFactory<ApplicationDbContext> contextF
{
tenantUser.User!.Email = email;
tenantUser.User.LastLoginAt = DateTime.UtcNow;
tenantUser.User.Role = role;
context.Users.Update(tenantUser.User);
}
return Result.OkIf(await context.SaveChangesAsync(cancellationToken) > 0, CreateError.New);
return Result.OkIf(await context.SaveChangesAsync(cancellationToken) > 0, CreateError.Create("Failed to sync user, contact support"));
}));
public async Task<Result<User[]>> GetUsersAsync(Pagination pagination, UserFilterRequest request, CancellationToken cancellationToken = default) =>
@@ -40,6 +40,7 @@ import { toolsService } from '../../services/toolsService';
import { Modal } from '../ui/Modal';
import { useToast } from '../ui/Toast';
import { ProblemDetailsAlert } from '../ui/ProblemDetailsAlert';
import { HelpTooltip } from '../ui/HelpTooltip';
import { ExportButton } from '../ui/ExportButton';
import { ExportColumn } from '../../utils/exportUtils';
import { useTableSort, SortableTh } from '../../utils/tableSort';
@@ -437,9 +438,16 @@ export const ContractsManagement: React.FC<ContractsManagementProps> = ({
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-4 gap-4">
<div className="bg-[#0f1523]/80 backdrop-blur-md p-5 rounded-2xl border border-slate-800/80 shadow-lg">
<div className="flex items-center justify-between">
<span className="text-xs font-semibold text-slate-400 uppercase tracking-wider">
Total Contracts
</span>
<div className="flex items-center gap-1.5">
<span className="text-xs font-semibold text-slate-400 uppercase tracking-wider">
Total Contracts
</span>
<HelpTooltip
title="Total Legal Contracts"
content="Total grant agreements and legal instruments registered across portfolios."
size="sm"
/>
</div>
<div className="w-8 h-8 rounded-lg bg-blue-500/10 text-blue-400 flex items-center justify-center border border-blue-500/20">
<FileText className="w-4 h-4" />
</div>
@@ -454,9 +462,16 @@ export const ContractsManagement: React.FC<ContractsManagementProps> = ({
<div className="bg-[#0f1523]/80 backdrop-blur-md p-5 rounded-2xl border border-slate-800/80 shadow-lg">
<div className="flex items-center justify-between">
<span className="text-xs font-semibold text-slate-400 uppercase tracking-wider">
Active Value (ZAR)
</span>
<div className="flex items-center gap-1.5">
<span className="text-xs font-semibold text-slate-400 uppercase tracking-wider">
Active Value (ZAR)
</span>
<HelpTooltip
title="Active Contract Value"
content="Cumulative financial value of legally active commitments."
size="sm"
/>
</div>
<div className="w-8 h-8 rounded-lg bg-emerald-500/10 text-emerald-400 flex items-center justify-center border border-emerald-500/20">
<ShieldCheck className="w-4 h-4" />
</div>
@@ -471,9 +486,16 @@ export const ContractsManagement: React.FC<ContractsManagementProps> = ({
<div className="bg-[#0f1523]/80 backdrop-blur-md p-5 rounded-2xl border border-slate-800/80 shadow-lg">
<div className="flex items-center justify-between">
<span className="text-xs font-semibold text-slate-400 uppercase tracking-wider">
In Review / Draft
</span>
<div className="flex items-center gap-1.5">
<span className="text-xs font-semibold text-slate-400 uppercase tracking-wider">
In Review / Draft
</span>
<HelpTooltip
title="Draft & Review Contracts"
content="Contracts currently undergoing legal review, signing, or counter-signature."
size="sm"
/>
</div>
<div className="w-8 h-8 rounded-lg bg-amber-500/10 text-amber-400 flex items-center justify-center border border-amber-500/20">
<Clock className="w-4 h-4" />
</div>
@@ -488,9 +510,16 @@ export const ContractsManagement: React.FC<ContractsManagementProps> = ({
<div className="bg-[#0f1523]/80 backdrop-blur-md p-5 rounded-2xl border border-slate-800/80 shadow-lg">
<div className="flex items-center justify-between">
<span className="text-xs font-semibold text-slate-400 uppercase tracking-wider">
Approved Awards Available
</span>
<div className="flex items-center gap-1.5">
<span className="text-xs font-semibold text-slate-400 uppercase tracking-wider">
Approved Awards Available
</span>
<HelpTooltip
title="Approved Awards"
content="Approved grant awards eligible for formal contract generation and document attachment."
size="sm"
/>
</div>
<div className="w-8 h-8 rounded-lg bg-indigo-500/10 text-indigo-400 flex items-center justify-center border border-indigo-500/20">
<Briefcase className="w-4 h-4" />
</div>
@@ -54,6 +54,7 @@ import {
} from '../../lib/formatters';
import { Modal } from '../ui/Modal';
import { useToast } from '../ui/Toast';
import { HelpTooltip } from '../ui/HelpTooltip';
import { ExportButton } from '../ui/ExportButton';
import { ExportColumn } from '../../utils/exportUtils';
@@ -419,10 +420,18 @@ export const EvidenceReviewQueue: React.FC<EvidenceReviewQueueProps> = ({
{filteredEvidence.length} {filteredEvidence.length === 1 ? 'file' : 'files'} in view
</span>
</div>
<h3 className="text-lg font-bold text-white tracking-tight flex items-center gap-2">
<FileCheck className="w-5 h-5 text-blue-400" />
Evidence Review & Verification Hub
</h3>
<div className="flex items-center gap-2">
<h3 className="text-lg font-bold text-white tracking-tight flex items-center gap-2">
<FileCheck className="w-5 h-5 text-blue-400" />
Evidence Review & Verification Hub
</h3>
<HelpTooltip
title="Deliverable Evidence Verification"
content="Inspect and verify deliverable evidence uploaded against project milestones and operational activities before authorizing completion sign-offs."
size="sm"
placement="bottom"
/>
</div>
<p className="text-xs text-slate-400 mt-1 max-w-3xl leading-relaxed">
Inspect and verify deliverable evidence uploaded by awardees against project milestones and operational activities.
Review supporting documentation, download verified files, and authorize completion sign-offs.
@@ -83,6 +83,7 @@ export const BeneficiariesPage: React.FC = () => {
const [isCompModalOpen, setIsCompModalOpen] = useState(false);
const [editingComp, setEditingComp] = useState<ComplianceItem | null>(null);
const [compTargetProjectId, setCompTargetProjectId] = useState<string>('');
const [selectedBenForDetail, setSelectedBenForDetail] = useState<Beneficiary | null>(null);
const [isDetailModalOpen, setIsDetailModalOpen] = useState(false);
@@ -109,16 +110,44 @@ export const BeneficiariesPage: React.FC = () => {
enabled: !!effectiveProjectId,
});
// Query compliance items
// Refactored compliance items data retrieval:
// Fetches from api/projects/{projectId}/complianceitems for the selected project,
// or across all projects in the portfolio when 'all' is chosen.
const {
data: complianceItems = [],
isLoading: loadingComps,
isError: isCompsError,
refetch: refetchComps,
isFetching: fetchingComps,
} = useQuery({
queryKey: ['complianceItems', effectiveProjectId],
queryFn: () => (effectiveProjectId ? getProjectComplianceItems(effectiveProjectId) : Promise.resolve([])),
enabled: !!effectiveProjectId,
queryKey: ['complianceItems', selectedProjectId, projects.map((p) => p.id).join(',')],
queryFn: async (): Promise<ComplianceItem[]> => {
if (selectedProjectId !== 'all') {
const items = await getProjectComplianceItems(selectedProjectId);
return (items || []).map((ci) => ({
...ci,
projectId: ci.projectId || selectedProjectId,
}));
}
if (!projects || projects.length === 0) return [];
const settled = await Promise.allSettled(
projects.map(async (p) => {
const items = await getProjectComplianceItems(p.id);
return (items || []).map((ci) => ({
...ci,
projectId: ci.projectId || p.id,
}));
})
);
const combined: ComplianceItem[] = [];
settled.forEach((res) => {
if (res.status === 'fulfilled' && Array.isArray(res.value)) {
combined.push(...res.value);
}
});
return combined;
},
enabled: projects.length > 0,
});
// Query outcomes for selected beneficiary
@@ -190,8 +219,9 @@ export const BeneficiariesPage: React.FC = () => {
const createCompMutation = useMutation({
mutationFn: (vals: ComplianceFormValues) => {
if (!effectiveProjectId) throw new Error('No project selected');
return createComplianceItem({ projectId: effectiveProjectId, title: vals.title, requirements: vals.requirements });
const targetProjId = compTargetProjectId || (selectedProjectId !== 'all' ? selectedProjectId : projects[0]?.id);
if (!targetProjId) throw new Error('No project selected');
return createComplianceItem({ projectId: targetProjId, title: vals.title, requirements: vals.requirements });
},
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['complianceItems'] });
@@ -285,6 +315,11 @@ export const BeneficiariesPage: React.FC = () => {
];
const compExportColumns: ExportColumn<ComplianceItem>[] = [
{
header: 'Project',
key: 'projectId',
format: (val) => projects.find((p) => String(p.id) === String(val))?.name || 'Project',
},
{ header: 'Compliance Requirement', key: 'title' },
{ header: 'Requirements Detail', key: 'requirements' },
{ header: 'Status', key: 'status', format: (val) => (val === ApprovalStatus.Approved ? 'Approved' : 'Pending Review') },
@@ -347,10 +382,18 @@ export const BeneficiariesPage: React.FC = () => {
<Users className="w-6 h-6" />
</div>
<div>
<h1 className="text-2xl sm:text-3xl font-bold tracking-tight text-white flex items-center gap-2.5">
Beneficiaries & Compliance Register
</h1>
<p className="text-xs sm:text-sm text-slate-400">
<div className="flex items-center gap-2.5 flex-wrap">
<h1 className="text-2xl sm:text-3xl font-bold tracking-tight text-white flex items-center gap-2.5">
Beneficiaries & Compliance Register
</h1>
<HelpTooltip
title="Beneficiary & Compliance Framework"
content="Record target population demographics, track real-world participant reach vs targets, and monitor statutory compliance records across projects."
size="sm"
placement="bottom"
/>
</div>
<p className="text-xs sm:text-sm text-slate-400 mt-1">
Manage target recipient demographics, track reached counts, and record verified statutory compliance items.
</p>
</div>
@@ -362,7 +405,14 @@ export const BeneficiariesPage: React.FC = () => {
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-4 gap-4">
<div className="bg-[#0f1523]/80 backdrop-blur-md p-5 rounded-2xl border border-slate-800/80 shadow-xl space-y-2">
<div className="flex items-center justify-between text-slate-400">
<span className="text-xs font-semibold uppercase tracking-wider">Beneficiary Cohorts</span>
<div className="flex items-center gap-1.5">
<span className="text-xs font-semibold uppercase tracking-wider">Beneficiary Cohorts</span>
<HelpTooltip
title="Beneficiary Cohorts"
content="Registered demographic stakeholder groups receiving direct programmatic support."
size="sm"
/>
</div>
<Users className="w-4 h-4 text-blue-400" />
</div>
<p className="text-2xl font-bold text-white">{beneficiaries.length}</p>
@@ -371,7 +421,14 @@ export const BeneficiariesPage: React.FC = () => {
<div className="bg-[#0f1523]/80 backdrop-blur-md p-5 rounded-2xl border border-slate-800/80 shadow-xl space-y-2">
<div className="flex items-center justify-between text-slate-400">
<span className="text-xs font-semibold uppercase tracking-wider">Total Target Reach</span>
<div className="flex items-center gap-1.5">
<span className="text-xs font-semibold uppercase tracking-wider">Total Target Reach</span>
<HelpTooltip
title="Target Reach"
content="Total planned participant headcount agreed upon across project targets."
size="sm"
/>
</div>
<Target className="w-4 h-4 text-indigo-400" />
</div>
<p className="text-2xl font-bold text-white">{totalTarget.toLocaleString()}</p>
@@ -380,7 +437,14 @@ export const BeneficiariesPage: React.FC = () => {
<div className="bg-[#0f1523]/80 backdrop-blur-md p-5 rounded-2xl border border-slate-800/80 shadow-xl space-y-2">
<div className="flex items-center justify-between text-slate-400">
<span className="text-xs font-semibold uppercase tracking-wider">Actual Reached</span>
<div className="flex items-center gap-1.5">
<span className="text-xs font-semibold uppercase tracking-wider">Actual Reached</span>
<HelpTooltip
title="Actual Reached Headcount"
content="Cumulative verified individual or entity participants supported to date."
size="sm"
/>
</div>
<Award className="w-4 h-4 text-emerald-400" />
</div>
<div className="flex items-baseline gap-2">
@@ -394,7 +458,14 @@ export const BeneficiariesPage: React.FC = () => {
<div className="bg-[#0f1523]/80 backdrop-blur-md p-5 rounded-2xl border border-slate-800/80 shadow-xl space-y-2">
<div className="flex items-center justify-between text-slate-400">
<span className="text-xs font-semibold uppercase tracking-wider">Compliance Items</span>
<div className="flex items-center gap-1.5">
<span className="text-xs font-semibold uppercase tracking-wider">Compliance Items</span>
<HelpTooltip
title="Compliance Items"
content="Statutory, environmental, labor, and financial audit verification requirements."
size="sm"
/>
</div>
<ShieldCheck className="w-4 h-4 text-cyan-400" />
</div>
<div className="flex items-baseline gap-2">
@@ -476,6 +547,7 @@ export const BeneficiariesPage: React.FC = () => {
onChange={(e) => setSelectedProjectId(e.target.value)}
className="bg-slate-900 border border-slate-800 rounded-xl px-3 py-1.5 text-xs text-white focus:outline-none focus:border-blue-500 max-w-[180px] truncate"
>
<option value="all">All Projects</option>
{projects.map((p) => (
<option key={p.id} value={p.id}>
{p.name}
@@ -660,6 +732,7 @@ export const BeneficiariesPage: React.FC = () => {
onChange={(e) => setSelectedProjectId(e.target.value)}
className="bg-slate-900 border border-slate-800 rounded-xl px-3 py-1.5 text-xs text-white focus:outline-none focus:border-blue-500 max-w-[180px] truncate"
>
<option value="all">All Projects</option>
{projects.map((p) => (
<option key={p.id} value={p.id}>
{p.name}
@@ -695,6 +768,7 @@ export const BeneficiariesPage: React.FC = () => {
<button
onClick={() => {
setEditingComp(null);
setCompTargetProjectId(selectedProjectId !== 'all' ? selectedProjectId : projects[0]?.id ? String(projects[0].id) : '');
compForm.reset({ title: '', requirements: '', status: ApprovalStatus.Pending });
setIsCompModalOpen(true);
}}
@@ -714,6 +788,11 @@ export const BeneficiariesPage: React.FC = () => {
<SortableTh column="title" currentColumn={compSortCol} direction={compSortDir} onSort={handleCompSort} className="py-3.5 px-5 whitespace-nowrap">
Compliance Title
</SortableTh>
{selectedProjectId === 'all' && (
<SortableTh column="projectId" currentColumn={compSortCol} direction={compSortDir} onSort={handleCompSort} className="py-3.5 px-4 whitespace-nowrap">
Project
</SortableTh>
)}
<SortableTh column="requirements" currentColumn={compSortCol} direction={compSortDir} onSort={handleCompSort} className="py-3.5 px-4 whitespace-nowrap">
Requirements
</SortableTh>
@@ -727,9 +806,25 @@ export const BeneficiariesPage: React.FC = () => {
</tr>
</thead>
<tbody className="divide-y divide-slate-800/60">
{loadingComps ? (
{isCompsError ? (
<tr>
<td colSpan={5} className="py-12 text-center text-slate-400">
<td colSpan={selectedProjectId === 'all' ? 6 : 5} className="py-12 text-center text-rose-400">
<div className="flex flex-col items-center gap-2">
<AlertCircle className="w-6 h-6 text-rose-400" />
<span>Unable to load compliance items for this project from API.</span>
<button
type="button"
onClick={() => refetchComps()}
className="mt-2 px-3 py-1 bg-slate-800 text-white rounded-lg border border-slate-700 text-xs hover:bg-slate-700 transition cursor-pointer"
>
Retry
</button>
</div>
</td>
</tr>
) : loadingComps ? (
<tr>
<td colSpan={selectedProjectId === 'all' ? 6 : 5} className="py-12 text-center text-slate-400">
<div className="flex flex-col items-center gap-2">
<div className="w-6 h-6 border-2 border-blue-500 border-t-transparent rounded-full animate-spin" />
<span>Loading compliance items...</span>
@@ -738,8 +833,10 @@ export const BeneficiariesPage: React.FC = () => {
</tr>
) : sortedComps.length === 0 ? (
<tr>
<td colSpan={5} className="py-12 text-center text-slate-400">
No compliance items recorded for this project.
<td colSpan={selectedProjectId === 'all' ? 6 : 5} className="py-12 text-center text-slate-400">
{selectedProjectId === 'all'
? 'No compliance items recorded across all projects.'
: 'No compliance items recorded for this project.'}
</td>
</tr>
) : (
@@ -751,6 +848,13 @@ export const BeneficiariesPage: React.FC = () => {
<span>{c.title}</span>
</div>
</td>
{selectedProjectId === 'all' && (
<td className="py-3.5 px-4 whitespace-nowrap text-slate-300 font-medium">
<span className="px-2 py-0.5 rounded-md bg-slate-800/90 text-slate-200 border border-slate-700/60 text-[11px]">
{projects.find((p) => String(p.id) === String(c.projectId))?.name || 'Project'}
</span>
</td>
)}
<td className="py-3.5 px-4 text-slate-300 max-w-xs truncate" title={c.requirements || ''}>
{c.requirements || 'None'}
</td>
@@ -869,6 +973,23 @@ export const BeneficiariesPage: React.FC = () => {
onSubmit={compForm.handleSubmit((d) => (editingComp ? updateCompMutation.mutate(d) : createCompMutation.mutate(d)))}
className="space-y-4"
>
{!editingComp && (
<div>
<label className="block text-xs font-semibold text-slate-300 mb-1">Target Project *</label>
<select
value={compTargetProjectId || (selectedProjectId !== 'all' ? selectedProjectId : projects[0]?.id ? String(projects[0].id) : '')}
onChange={(e) => setCompTargetProjectId(e.target.value)}
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"
>
{projects.map((p) => (
<option key={p.id} value={p.id}>
{p.name}
</option>
))}
</select>
</div>
)}
<div>
<label className="block text-xs font-semibold text-slate-300 mb-1">Compliance Title *</label>
<input
@@ -35,6 +35,7 @@ import {
} from '../services/api';
import { Modal } from '../components/ui/Modal';
import { useToast } from '../components/ui/Toast';
import { HelpTooltip } from '../components/ui/HelpTooltip';
import { ExportButton } from '../components/ui/ExportButton';
import { ExportColumn } from '../utils/exportUtils';
import { useTableSort, SortableTh } from '../utils/tableSort';
@@ -522,10 +523,18 @@ export const ChangeManagementPage: React.FC = () => {
<GitPullRequest className="w-6 h-6" />
</div>
<div>
<h1 className="text-2xl sm:text-3xl font-bold tracking-tight text-white flex items-center gap-2.5">
Change Management & Issue Resolution
</h1>
<p className="text-xs sm:text-sm text-slate-400">
<div className="flex items-center gap-2.5 flex-wrap">
<h1 className="text-2xl sm:text-3xl font-bold tracking-tight text-white flex items-center gap-2.5">
Change Management & Issue Resolution
</h1>
<HelpTooltip
title="Project Change & Issue Control"
content="Track project impediments, review contractual variations, and resolve change requests in-place through governance workflows."
size="sm"
placement="bottom"
/>
</div>
<p className="text-xs sm:text-sm text-slate-400 mt-1">
Track project execution impediments, review contractual variations, and resolve change requests in-place.
</p>
</div>
@@ -537,7 +546,14 @@ export const ChangeManagementPage: React.FC = () => {
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-4 gap-4">
<div className="bg-[#0f1523]/80 backdrop-blur-md p-5 rounded-2xl border border-slate-800/80 shadow-xl space-y-2">
<div className="flex items-center justify-between text-slate-400">
<span className="text-xs font-semibold uppercase tracking-wider">Active Issues</span>
<div className="flex items-center gap-1.5">
<span className="text-xs font-semibold uppercase tracking-wider">Active Issues</span>
<HelpTooltip
title="Active Project Issues"
content="Open project roadblocks, risks, or performance impediments requiring active mitigation."
size="sm"
/>
</div>
<AlertOctagon className="w-4 h-4 text-rose-400" />
</div>
<p className="text-2xl font-bold text-rose-400">{issues.filter((i) => i.status !== ActivityStatus.Inactive).length}</p>
@@ -546,7 +562,14 @@ export const ChangeManagementPage: React.FC = () => {
<div className="bg-[#0f1523]/80 backdrop-blur-md p-5 rounded-2xl border border-slate-800/80 shadow-xl space-y-2">
<div className="flex items-center justify-between text-slate-400">
<span className="text-xs font-semibold uppercase tracking-wider">Resolved Issues</span>
<div className="flex items-center gap-1.5">
<span className="text-xs font-semibold uppercase tracking-wider">Resolved Issues</span>
<HelpTooltip
title="Resolved Issues"
content="Impediments that have been remediated and formally closed with documented resolution notes."
size="sm"
/>
</div>
<CheckCircle2 className="w-4 h-4 text-emerald-400" />
</div>
<p className="text-2xl font-bold text-emerald-400">
@@ -557,7 +580,14 @@ export const ChangeManagementPage: React.FC = () => {
<div className="bg-[#0f1523]/80 backdrop-blur-md p-5 rounded-2xl border border-slate-800/80 shadow-xl space-y-2">
<div className="flex items-center justify-between text-slate-400">
<span className="text-xs font-semibold uppercase tracking-wider">Pending Changes</span>
<div className="flex items-center gap-1.5">
<span className="text-xs font-semibold uppercase tracking-wider">Pending Changes</span>
<HelpTooltip
title="Pending Variations"
content="Formal change requests awaiting governance review, budget validation, and sign-off."
size="sm"
/>
</div>
<GitPullRequest className="w-4 h-4 text-amber-400" />
</div>
<p className="text-2xl font-bold text-amber-400">
@@ -568,7 +598,14 @@ export const ChangeManagementPage: React.FC = () => {
<div className="bg-[#0f1523]/80 backdrop-blur-md p-5 rounded-2xl border border-slate-800/80 shadow-xl space-y-2">
<div className="flex items-center justify-between text-slate-400">
<span className="text-xs font-semibold uppercase tracking-wider">Approved Variations</span>
<div className="flex items-center gap-1.5">
<span className="text-xs font-semibold uppercase tracking-wider">Approved Variations</span>
<HelpTooltip
title="Approved Variations"
content="Contract amendments and scope adjustments approved by the governing committee."
size="sm"
/>
</div>
<Coins className="w-4 h-4 text-blue-400" />
</div>
<p className="text-2xl font-bold text-blue-400">
@@ -1,4 +1,4 @@
import React, { useState } from 'react';
import React, { useState, useMemo } from 'react';
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
import {
Shield,
@@ -36,6 +36,7 @@ import {
ChevronDown,
ChevronRight,
User as UserIcon,
RotateCcw,
} from 'lucide-react';
import {
getUsers,
@@ -114,7 +115,12 @@ export const SettingsPage: React.FC = () => {
staleTime: 5 * 60 * 1000,
});
const { data: usersList = [], isLoading: loadingUsers, refetch: refetchUsers } = useQuery({
const {
data: usersList = [],
isLoading: loadingUsers,
isFetching: isFetchingUsers,
refetch: refetchUsers,
} = useQuery({
queryKey: ['users', userStatusFilter, userEmailSearch],
queryFn: () => getUsers(1, 100, userFilter),
});
@@ -191,11 +197,32 @@ export const SettingsPage: React.FC = () => {
const [inviteMessage, setInviteMessage] = useState('');
const [invitePlatform, setInvitePlatform] = useState<NotificationPlatform>(NotificationPlatform.Email);
const { data: invitesList = [], isLoading: loadingInvites } = useQuery({
// 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),
@@ -432,6 +459,25 @@ export const SettingsPage: React.FC = () => {
handleSort: handleAuditSort,
} = useTableSort(filteredAuditLogs, 'timestamp', 'desc');
const userExportColumns: ExportColumn<User>[] = [
{ header: 'User Email', key: 'email', format: (val, u) => val || getUserEmail(u.id) || '—' },
{ header: 'User Identifier', key: 'id' },
{ header: 'Status', key: 'status', format: (val) => getActivityStatusBadge(val).label },
{ header: 'Last Login', key: 'lastLoginAt', format: (val) => (val ? formatDate(val) : 'Never') },
];
const inviteExportColumns: ExportColumn<Invite>[] = [
{ header: 'Recipient (UUID)', key: 'recipient' },
{ header: 'Channel / Platform', key: 'platform', format: (val) => getNotificationPlatformLabel(val) },
{ header: 'Invitation Message', key: 'message' },
{
header: 'Target Organisation / Grant',
key: 'invitedOrganisationId',
format: (val, inv) => `Org #${val || inv.organisationId || '—'} (Award #${inv.awardId || '—'})`,
},
{ header: 'Date Dispatched', key: 'createdAt', format: (val) => (val ? formatDate(val) : '—') },
];
const {
sortedData: sortedUsers,
sortColumn: userSortCol,
@@ -444,7 +490,7 @@ export const SettingsPage: React.FC = () => {
sortColumn: inviteSortCol,
sortDirection: inviteSortDir,
handleSort: handleInviteSort,
} = useTableSort(invitesList, 'createdAt', 'desc');
} = useTableSort(filteredInvites, 'createdAt', 'desc');
const getActionBadge = (action?: string | null) => {
const act = (action || 'GENERAL_EVENT').toUpperCase();
@@ -621,44 +667,101 @@ export const SettingsPage: React.FC = () => {
{/* ============================================================== */}
{activeTab === 'users' && (
<div className="space-y-4">
{/* Filter and Search Bar */}
<div className="bg-[#0f1523]/80 backdrop-blur-md p-4 rounded-2xl border border-slate-800/80 shadow-xl flex flex-col sm:flex-row items-center justify-between gap-3">
<div className="relative w-full sm:w-80">
<Search className="w-4 h-4 text-slate-500 absolute left-3 top-2.5" />
<input
type="text"
value={userEmailSearch}
onChange={(e) => setUserEmailSearch(e.target.value)}
placeholder="Search users by email..."
className="w-full pl-9 pr-3 py-1.5 text-xs bg-slate-900/80 border border-slate-700/80 rounded-xl text-white placeholder-slate-500 focus:outline-none focus:ring-2 focus:ring-blue-500/50"
/>
{/* Standardized Toolbar & Filter Bar directly above Users Table */}
<div className="bg-[#0f1523]/80 backdrop-blur-md p-3.5 sm:p-4 rounded-2xl border border-slate-800/80 shadow-xl flex flex-col lg:flex-row items-stretch lg:items-center justify-between gap-3">
{/* Filters on the left */}
<div className="flex items-center gap-2.5 flex-wrap flex-1">
<div className="relative flex-1 min-w-[200px] max-w-sm">
<Search className="w-3.5 h-3.5 text-slate-400 absolute left-3 top-2.5" />
<input
type="text"
value={userEmailSearch}
onChange={(e) => setUserEmailSearch(e.target.value)}
placeholder="Search users by email..."
className="w-full pl-8 pr-3 py-1.5 text-xs bg-slate-900 border border-slate-800 rounded-xl text-white placeholder-slate-500 focus:outline-none focus:border-blue-500 shadow-xs"
/>
</div>
<div className="flex items-center gap-1.5 text-xs text-slate-400">
<select
value={userStatusFilter}
onChange={(e) => setUserStatusFilter(e.target.value === 'all' ? 'all' : Number(e.target.value))}
className="text-xs bg-slate-900 border border-slate-800 rounded-xl px-3 py-1.5 text-white focus:outline-none focus:border-blue-500"
>
<option value="all">All Statuses</option>
<option value={ActivityStatus.Active}>Active</option>
<option value={ActivityStatus.Inactive}>Inactive</option>
<option value={ActivityStatus.Suspended}>Suspended</option>
</select>
</div>
{(userEmailSearch.trim() !== '' || userStatusFilter !== 'all') && (
<button
type="button"
onClick={() => {
setUserEmailSearch('');
setUserStatusFilter('all');
}}
className="inline-flex items-center gap-1 text-[11px] font-semibold text-slate-400 hover:text-slate-200 transition cursor-pointer"
>
<RotateCcw className="w-3 h-3" />
<span>Reset</span>
</button>
)}
</div>
<div className="flex items-center gap-2 w-full sm:w-auto">
<span className="text-xs font-semibold text-slate-400 whitespace-nowrap">Filter Status:</span>
<select
value={userStatusFilter}
onChange={(e) => setUserStatusFilter(e.target.value === 'all' ? 'all' : Number(e.target.value))}
className="text-xs bg-slate-900/80 border border-slate-700/80 rounded-xl px-3 py-1.5 text-white focus:outline-none focus:ring-2 focus:ring-blue-500/50 [&>option]:bg-slate-900 [&>option]:text-white"
{/* Actions on the right: Refresh, Export, Sync */}
<div className="flex items-center gap-2 flex-wrap justify-end">
<button
type="button"
onClick={() => {
refetchUsers();
success('Refreshed', 'User directory updated from API.');
}}
disabled={isFetchingUsers}
className="inline-flex items-center justify-center gap-1.5 px-3.5 py-1.5 text-xs font-semibold text-slate-300 bg-slate-900 hover:bg-slate-800 border border-slate-800 rounded-xl transition cursor-pointer shadow-xs disabled:opacity-50"
title="Force refresh user directory from API"
>
<option value="all">All Statuses</option>
<option value={ActivityStatus.Active}>Active</option>
<option value={ActivityStatus.Inactive}>Inactive</option>
<option value={ActivityStatus.Suspended}>Suspended</option>
</select>
<RefreshCw className={`w-3.5 h-3.5 text-blue-400 ${isFetchingUsers ? 'animate-spin' : ''}`} />
<span>Refresh</span>
</button>
<ExportButton
title="User Directory &amp; Access Register"
subtitle="Canonical directory of authorized users and account states"
filename={`pfm-users-directory-${new Date().toISOString().split('T')[0]}`}
columns={userExportColumns}
data={sortedUsers}
/>
<button
onClick={() => refetchUsers()}
className="p-1.5 rounded-xl border border-slate-700 bg-slate-800/80 hover:bg-slate-700 text-slate-300 transition cursor-pointer"
title="Refresh user list"
onClick={() => syncMutation.mutate()}
disabled={syncMutation.isPending}
className="inline-flex items-center gap-1.5 px-3.5 py-1.5 rounded-xl text-xs font-semibold bg-gradient-to-r from-blue-600 to-indigo-600 text-white hover:from-blue-500 hover:to-indigo-500 shadow-md shadow-blue-600/25 transition cursor-pointer disabled:opacity-50"
title="Synchronize user profile from JWT session and directory"
>
<RefreshCw className="w-4 h-4" />
<UserCheck className="w-3.5 h-3.5" />
<span>{syncMutation.isPending ? 'Syncing...' : 'Sync Directory'}</span>
</button>
</div>
</div>
{/* Users Table */}
<div className="bg-[#0f1523]/80 backdrop-blur-md rounded-2xl border border-slate-800/80 shadow-xl overflow-hidden">
<div className="p-4 border-b border-slate-800/80 flex items-center justify-between">
<div className="flex items-center gap-2">
<Users className="w-4 h-4 text-blue-400" />
<h3 className="text-sm font-semibold text-white">
Authorized User Directory ({sortedUsers.length} of {usersList.length})
</h3>
<HelpTooltip
title="User Directory"
content="Authorized system users, role memberships, and account lifecycle states."
size="sm"
placement="right"
/>
</div>
</div>
<div className="overflow-x-auto">
<table className="w-full text-left text-xs border-collapse">
<thead>
@@ -831,13 +934,100 @@ export const SettingsPage: React.FC = () => {
)}
</div>
{/* Standardized Toolbar & Filter Bar directly above Sent Invitations Table */}
<div className="bg-[#0f1523]/80 backdrop-blur-md p-3.5 sm:p-4 rounded-2xl border border-slate-800/80 shadow-xl flex flex-col lg:flex-row items-stretch lg:items-center justify-between gap-3">
{/* Filters on the left */}
<div className="flex items-center gap-2.5 flex-wrap flex-1">
<div className="relative flex-1 min-w-[200px] max-w-sm">
<Search className="w-3.5 h-3.5 text-slate-400 absolute left-3 top-2.5" />
<input
type="text"
value={inviteSearchTerm}
onChange={(e) => setInviteSearchTerm(e.target.value)}
placeholder="Search recipient UUID, message..."
className="w-full pl-8 pr-3 py-1.5 text-xs bg-slate-900 border border-slate-800 rounded-xl text-white placeholder-slate-500 focus:outline-none focus:border-blue-500 shadow-xs font-mono"
/>
</div>
<div className="flex items-center gap-1.5 text-xs text-slate-400">
<select
value={invitePlatformFilter}
onChange={(e) => setInvitePlatformFilter(e.target.value === 'all' ? 'all' : Number(e.target.value))}
className="text-xs bg-slate-900 border border-slate-800 rounded-xl px-3 py-1.5 text-white focus:outline-none focus:border-blue-500"
>
<option value="all">All Channels</option>
{NOTIFICATION_PLATFORM_OPTIONS.map((opt) => (
<option key={opt.value} value={opt.value}>
{opt.label}
</option>
))}
</select>
</div>
{(inviteSearchTerm.trim() !== '' || invitePlatformFilter !== 'all') && (
<button
type="button"
onClick={() => {
setInviteSearchTerm('');
setInvitePlatformFilter('all');
}}
className="inline-flex items-center gap-1 text-[11px] font-semibold text-slate-400 hover:text-slate-200 transition cursor-pointer"
>
<RotateCcw className="w-3 h-3" />
<span>Reset</span>
</button>
)}
</div>
{/* Actions on the right: Refresh, Export, Dispatch */}
<div className="flex items-center gap-2 flex-wrap justify-end">
<button
type="button"
onClick={() => {
refetchInvites();
success('Refreshed', 'Sent invitations ledger updated from API.');
}}
disabled={isFetchingInvites}
className="inline-flex items-center justify-center gap-1.5 px-3.5 py-1.5 text-xs font-semibold text-slate-300 bg-slate-900 hover:bg-slate-800 border border-slate-800 rounded-xl transition cursor-pointer shadow-xs disabled:opacity-50"
title="Force refresh invitations from API"
>
<RefreshCw className={`w-3.5 h-3.5 text-blue-400 ${isFetchingInvites ? 'animate-spin' : ''}`} />
<span>Refresh</span>
</button>
<ExportButton
title="Sent Communications &amp; Invitations Ledger"
subtitle="Telemetry records of candidate invitations and statutory communications dispatched"
filename={`pfm-sent-invitations-${new Date().toISOString().split('T')[0]}`}
columns={inviteExportColumns}
data={sortedInvites}
/>
<button
onClick={() => setIsSendInviteModalOpen(true)}
className="inline-flex items-center gap-1.5 px-3.5 py-1.5 rounded-xl text-xs font-semibold bg-gradient-to-r from-blue-600 to-indigo-600 text-white hover:from-blue-500 hover:to-indigo-500 shadow-md shadow-blue-600/25 transition cursor-pointer"
>
<Send className="w-3.5 h-3.5" />
<span>Dispatch Invitation</span>
</button>
</div>
</div>
{/* Sent Invites Ledger */}
<div className="bg-[#0f1523]/80 backdrop-blur-md rounded-2xl border border-slate-800/80 shadow-xl overflow-hidden">
<div className="px-5 py-3.5 border-b border-slate-800/80 bg-slate-900/40 flex items-center justify-between">
<span className="text-xs font-bold text-white uppercase tracking-wider">
Sent Invitations Ledger
</span>
<span className="text-xs text-slate-400">{invitesList.length} invitations dispatched</span>
<div className="p-4 border-b border-slate-800/80 flex items-center justify-between">
<div className="flex items-center gap-2">
<Mail className="w-4 h-4 text-blue-400" />
<h3 className="text-sm font-semibold text-white">
Sent Invitations Ledger ({sortedInvites.length} of {invitesList.length})
</h3>
<HelpTooltip
title="Sent Invitations"
content="Historical record of dispatched applicant invites, platform channels, and associated awards."
size="sm"
placement="right"
/>
</div>
</div>
<div className="overflow-x-auto">
<table className="w-full text-left text-xs border-collapse">
@@ -29,6 +29,7 @@ import {
} from '../services/api';
import { Modal } from '../components/ui/Modal';
import { useToast } from '../components/ui/Toast';
import { HelpTooltip } from '../components/ui/HelpTooltip';
import { ExportButton } from '../components/ui/ExportButton';
import { ExportColumn } from '../utils/exportUtils';
import { useTableSort, SortableTh } from '../utils/tableSort';
@@ -195,10 +196,18 @@ export const SiteVisitsPage: React.FC = () => {
<MapPin className="w-6 h-6" />
</div>
<div>
<h1 className="text-2xl sm:text-3xl font-bold tracking-tight text-white flex items-center gap-2.5">
Site Visits & Field Verification
</h1>
<p className="text-xs sm:text-sm text-slate-400">
<div className="flex items-center gap-2.5 flex-wrap">
<h1 className="text-2xl sm:text-3xl font-bold tracking-tight text-white flex items-center gap-2.5">
Site Visits & Field Verification
</h1>
<HelpTooltip
title="Field Inspection & Verification"
content="Log physical site inspection reports, track verified evidence, document findings, and record governance approvals."
size="sm"
placement="bottom"
/>
</div>
<p className="text-xs sm:text-sm text-slate-400 mt-1">
Log physical inspection reports, record field findings, and capture project feedback.
</p>
</div>
@@ -210,7 +219,14 @@ export const SiteVisitsPage: React.FC = () => {
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-4 gap-4">
<div className="bg-[#0f1523]/80 backdrop-blur-md p-5 rounded-2xl border border-slate-800/80 shadow-xl space-y-2">
<div className="flex items-center justify-between text-slate-400">
<span className="text-xs font-semibold uppercase tracking-wider">Total Site Visits</span>
<div className="flex items-center gap-1.5">
<span className="text-xs font-semibold uppercase tracking-wider">Total Site Visits</span>
<HelpTooltip
title="Total Site Visits"
content="Total number of physical on-site inspection visits recorded across all projects."
size="sm"
/>
</div>
<MapPin className="w-4 h-4 text-emerald-400" />
</div>
<p className="text-2xl font-bold text-white">{siteVisits.length}</p>
@@ -219,7 +235,14 @@ export const SiteVisitsPage: React.FC = () => {
<div className="bg-[#0f1523]/80 backdrop-blur-md p-5 rounded-2xl border border-slate-800/80 shadow-xl space-y-2">
<div className="flex items-center justify-between text-slate-400">
<span className="text-xs font-semibold uppercase tracking-wider">Approved Reports</span>
<div className="flex items-center gap-1.5">
<span className="text-xs font-semibold uppercase tracking-wider">Approved Reports</span>
<HelpTooltip
title="Approved Inspection Reports"
content="Field inspection reports formally approved and verified by project governance."
size="sm"
/>
</div>
<CheckCircle2 className="w-4 h-4 text-teal-400" />
</div>
<p className="text-2xl font-bold text-teal-400">
@@ -230,7 +253,14 @@ export const SiteVisitsPage: React.FC = () => {
<div className="bg-[#0f1523]/80 backdrop-blur-md p-5 rounded-2xl border border-slate-800/80 shadow-xl space-y-2">
<div className="flex items-center justify-between text-slate-400">
<span className="text-xs font-semibold uppercase tracking-wider">Under Review</span>
<div className="flex items-center gap-1.5">
<span className="text-xs font-semibold uppercase tracking-wider">Under Review</span>
<HelpTooltip
title="Pending Sign-Off"
content="Inspection findings and reports currently awaiting governance evaluation."
size="sm"
/>
</div>
<FileText className="w-4 h-4 text-amber-400" />
</div>
<p className="text-2xl font-bold text-amber-400">
@@ -241,7 +271,14 @@ export const SiteVisitsPage: React.FC = () => {
<div className="bg-[#0f1523]/80 backdrop-blur-md p-5 rounded-2xl border border-slate-800/80 shadow-xl space-y-2">
<div className="flex items-center justify-between text-slate-400">
<span className="text-xs font-semibold uppercase tracking-wider">Audited Projects</span>
<div className="flex items-center gap-1.5">
<span className="text-xs font-semibold uppercase tracking-wider">Audited Projects</span>
<HelpTooltip
title="Audited Projects"
content="Projects with registered physical site monitoring records and active inspection history."
size="sm"
/>
</div>
<Building className="w-4 h-4 text-blue-400" />
</div>
<p className="text-2xl font-bold text-blue-400">{projects.length}</p>
@@ -1688,8 +1688,33 @@ export const updateChangeRequest = async (payload: UpdateChangeRequest): Promise
// ==========================================
export const getProjectComplianceItems = async (projectId: string | number): Promise<ComplianceItem[]> => {
const hashedProjectId = await toolsService.resolveProjectId(projectId);
const res = await apiClient.get<ComplianceItem[]>(`/v1/api/projects/complianceitems/${hashedProjectId}`);
return res.data || [];
try {
// Primary updated route: /v1/api/projects/{projectId}/complianceitems
const res = await apiClient.get<ComplianceItem[]>(`/v1/api/projects/${hashedProjectId}/complianceitems`);
return res.data || [];
} catch (err: any) {
if (err?.response?.status === 404 || err?.response?.status === 400) {
try {
// Fallback without /v1 prefix: /api/projects/{projectId}/complianceitems
const fallbackRes = await apiClient.get<ComplianceItem[]>(`/api/projects/${hashedProjectId}/complianceitems`);
return fallbackRes.data || [];
} catch {
try {
// Unencoded project ID fallbacks
const rawIdRes = await apiClient.get<ComplianceItem[]>(`/v1/api/projects/${projectId}/complianceitems`);
return rawIdRes.data || [];
} catch {
try {
const rawApiRes = await apiClient.get<ComplianceItem[]>(`/api/projects/${projectId}/complianceitems`);
return rawApiRes.data || [];
} catch {
throw err;
}
}
}
}
throw err;
}
};
export const getComplianceItem = async (complianceItemId: string | number): Promise<ComplianceItem> => {
@@ -13,6 +13,7 @@ public sealed class UserConfiguration : IEntityTypeConfiguration<User>
builder.HasKey(pk => pk.Id);
builder.Property(f => f.Email).IsRequired().HasMaxLength(LabelLength);
builder.Property(f => f.Role).IsRequired(false).HasMaxLength(LabelLength);
builder.Property(f => f.Status).IsRequired().HasConversion<int>().HasDefaultValueSql("2")
.HasSentinel(ActivityStatus.Inactive);
builder.Property(f => f.LastLoginAt).IsRequired(false);
@@ -12,6 +12,8 @@ public static class Constants
public const string EmailtemKey = "UserEmail";
public const string UserRoleItemKey = "UserRole";
public const string UserIdItemKey = "UserId";
public const string DefaultSchedulerName = "pfm-scheduler";
+2
View File
@@ -8,5 +8,7 @@ public class User
public string? Email { get; set; }
public string? Role { get; set; }
public ActivityStatus Status { get; set; }
}
@@ -0,0 +1,29 @@
using Microsoft.EntityFrameworkCore.Migrations;
#nullable disable
namespace PostFundManagement.Infrastructure.Database.Migrations
{
/// <inheritdoc />
public partial class AddedRoleToUser : Migration
{
/// <inheritdoc />
protected override void Up(MigrationBuilder migrationBuilder)
{
migrationBuilder.AddColumn<string>(
name: "Role",
table: "Users",
type: "character varying(256)",
maxLength: 256,
nullable: true);
}
/// <inheritdoc />
protected override void Down(MigrationBuilder migrationBuilder)
{
migrationBuilder.DropColumn(
name: "Role",
table: "Users");
}
}
}
@@ -1533,6 +1533,10 @@ namespace PostFundManagement.Infrastructure.Database.Migrations
b.Property<DateTime?>("LastLoginAt")
.HasColumnType("timestamp with time zone");
b.Property<string>("Role")
.HasMaxLength(256)
.HasColumnType("character varying(256)");
b.Property<int>("Status")
.ValueGeneratedOnAdd()
.HasColumnType("integer")