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 await service.SendInviteAsync(request, context.GetUserId(), cancellationToken) switch
{ {
{ IsSuccess: true, Value: var invite } => Results.Ok(invite), { 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 when r.HasError<CreateError>() => Results.BadRequest(r.Errors.ToProblems()),
var r => Results.InternalServerError(r.Errors.ToProblems()) var r => Results.InternalServerError(r.Errors.ToProblems())
}) })
@@ -29,6 +30,7 @@ public sealed class SendInviteEndpoint : IEndpoint
.MapToApiVersion(new ApiVersion(1)) .MapToApiVersion(new ApiVersion(1))
.Produces<Invite>(StatusCodes.Status200OK) .Produces<Invite>(StatusCodes.Status200OK)
.Produces<Problems>(StatusCodes.Status400BadRequest) .Produces<Problems>(StatusCodes.Status400BadRequest)
.Produces<Problems>(StatusCodes.Status400BadRequest)
.Produces<Problems>(StatusCodes.Status500InternalServerError) .Produces<Problems>(StatusCodes.Status500InternalServerError)
.Produces(StatusCodes.Status401Unauthorized) .Produces(StatusCodes.Status401Unauthorized)
.WithTags(EndpointTags.Communications); .WithTags(EndpointTags.Communications);
@@ -16,27 +16,22 @@ public class SyncUserEndpoint : IEndpoint
{ {
builder.MapPost("api/integration/sync", async (IJobOrchestrator jobOrchestrator, builder.MapPost("api/integration/sync", async (IJobOrchestrator jobOrchestrator,
HttpContext context, IUserService service, CancellationToken cancellationToken = default) => HttpContext context, IUserService service, CancellationToken cancellationToken = default) =>
{ await service.SyncUserAsync(context.GetUserId(), context.GetUserEmail(), context.GetUserRole(), cancellationToken) switch
var result = await service.SyncUserAsync(context.GetUserId(), context.GetUserEmail(), cancellationToken); {
{ IsSuccess: true } => Results.Ok(),
if (result.IsSuccess) var r when r.HasError<CreateError>() => Results.BadRequest(r.Errors.ToProblems()),
await jobOrchestrator.SendAsync(SyncUserEvent.Create(), cancellationToken); var r when r.HasError<ValidationError>() => Results.BadRequest(r.Errors.ToProblems()),
var r => Results.InternalServerError(r.Errors.ToProblems())
return result switch })
{ .WithDescription("Trigger integration and data synchronization in the background")
{ IsSuccess: true } => Results.Ok(), .RequireAuthorization(Constants.MainScope)
var r when r.HasError<CreateError>() => Results.BadRequest(r.Errors.ToProblems()), .WithName(typeof(SyncUserEndpoint).ToEndpointName())
var r => Results.InternalServerError(r.Errors.ToProblems()) .MapToApiVersion(new ApiVersion(1))
}; .Produces(StatusCodes.Status200OK)
}) .Produces(StatusCodes.Status401Unauthorized)
.WithDescription("Trigger integration and data synchronization in the background") .Produces<Problems>(StatusCodes.Status401Unauthorized)
.RequireAuthorization(Constants.MainScope) .Produces<Problems>(StatusCodes.Status500InternalServerError)
.WithName(typeof(SyncUserEndpoint).ToEndpointName()) .WithTags(EndpointTags.Users)
.MapToApiVersion(new ApiVersion(1)) .CacheOutput();
.Produces(StatusCodes.Status200OK)
.Produces(StatusCodes.Status401Unauthorized)
.Produces<Problems>(StatusCodes.Status500InternalServerError)
.WithTags(EndpointTags.Users)
.CacheOutput();
} }
} }
@@ -9,7 +9,7 @@ public interface IUserService
{ {
[Log] [Log]
[Audit] [Audit]
Task<Result> SyncUserAsync(Guid userId, string email, CancellationToken cancellationToken = default); Task<Result> SyncUserAsync(Guid userId, string email, string role, CancellationToken cancellationToken = default);
[Log] [Log]
Task<Result<User[]>> GetUsersAsync(Pagination pagination, UserFilterRequest request, CancellationToken cancellationToken = default); 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 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) public static (bool Decoded, Guid userId) GetUserId(this ClaimsPrincipal principal)
{ {
var subClaim = principal.FindFirst(JwtRegisteredClaimNames.Sub)?.Value ?? principal.FindFirst(ClaimTypes.NameIdentifier)?.Value; var subClaim = principal.FindFirst(JwtRegisteredClaimNames.Sub)?.Value ?? principal.FindFirst(ClaimTypes.NameIdentifier)?.Value;
@@ -30,10 +32,19 @@ public static class General
return (decoded, emailClaim!); 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)), Detail = string.Join(',', errors.Select(e => e.Message)),
Status = StatusCodes.Status400BadRequest, Status = statusCode,
Title = "Error", Title = "Error",
}; };
} }
@@ -15,12 +15,16 @@ public sealed class ActiveUserFilter(IIdentityService identityService) : IEndpoi
var (decoded, userId) = principal.GetUserId(); var (decoded, userId) = principal.GetUserId();
var (emailDecoded, email) = principal.GetUserEmail(); var (emailDecoded, email) = principal.GetUserEmail();
var (roleDecoded, role) = principal.GetUserRole();
if (!decoded) 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) 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; 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[UserIdItemKey] = userId;
context.HttpContext.Items[EmailtemKey] = email; context.HttpContext.Items[EmailtemKey] = email;
context.HttpContext.Items[UserRoleItemKey] = role;
return await next(context); return await next(context);
} }
@@ -17,13 +17,21 @@ public sealed class CommunicationsService(IDbContextFactory<ApplicationDbContext
{ {
await using var context = await contextFactory.CreateDbContextAsync(cancellationToken); 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 var invite = new Domain.Entities.Invite
{ {
OrganisationId = request.OrganisationId, OrganisationId = request.OrganisationId,
InvitedOrganisationId = request.InvitedOrganisationId, InvitedOrganisationId = request.InvitedOrganisationId,
AwardId = request.AwardId, AwardId = request.AwardId,
ContractId = request.ContractId, ContractId = request.ContractId,
Recipient = request.Recipient, Recipient = recipient.UserId,
CreatedBy = userId, CreatedBy = userId,
CreatedAt = DateTime.UtcNow, CreatedAt = DateTime.UtcNow,
TenantId = tenantContext.TenantId TenantId = tenantContext.TenantId
@@ -31,13 +39,13 @@ public sealed class CommunicationsService(IDbContextFactory<ApplicationDbContext
context.Invites.Add(invite); context.Invites.Add(invite);
var notificationRequest = new CreateNotificationRequest(request.InvitedOrganisationId, request.Recipient, var notificationRequest = new CreateNotificationRequest(request.InvitedOrganisationId, recipient.UserId,
request.Platform, "Invitation to Onboard", string.Empty, request.Message); request.Platform, "Funding award invite", recipient!.User!.Email, request.Message);
var notificationResult = await CreateNotificationAsync(notificationRequest, userId, cancellationToken); var notificationResult = await CreateNotificationAsync(notificationRequest, userId, cancellationToken);
return Result.FailIf(await context.SaveChangesAsync(cancellationToken) <= 0 && notificationResult.IsFailed, 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()); .ToResult(invite.Map());
})); }));
@@ -60,9 +68,11 @@ public sealed class CommunicationsService(IDbContextFactory<ApplicationDbContext
{ {
await using var context = await contextFactory.CreateDbContextAsync(cancellationToken); 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()); .ToResult(invite!.Map());
})); }));
@@ -97,7 +107,8 @@ public sealed class CommunicationsService(IDbContextFactory<ApplicationDbContext
TenantId = tenantContext.TenantId 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) => 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.UpdatedAt, DateTime.UtcNow)
.SetProperty(u => u.Status, request.Status), cancellationToken); .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) => 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() var notification = await context.Notifications.AsNoTracking()
.FirstOrDefaultAsync(n => n.Id == decodeResult.Value, cancellationToken); .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()); .ToResult(notification!.Map());
})); }));
} }
@@ -61,8 +61,8 @@ public sealed class UserCacheService(UserService service, IDistributedCache cach
return Result.Ok(cached.FromJson<User[]>()); return Result.Ok(cached.FromJson<User[]>());
})); }));
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 service.SyncUserAsync(userId, email, cancellationToken); await service.SyncUserAsync(userId, email, role, cancellationToken);
public async Task<Result> UpdateUserStatusAsync(Guid userId, ActivityStatus status, CancellationToken cancellationToken = default) => public async Task<Result> UpdateUserStatusAsync(Guid userId, ActivityStatus status, CancellationToken cancellationToken = default) =>
await service.UpdateUserStatusAsync(userId, status, cancellationToken); 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 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 Result.Try((Func<Task<Result>>)(async () =>
{ {
await using var context = await contextFactory.CreateDbContextAsync(cancellationToken); 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 var tenantUser = await context.TenantUsers
.Include(i => i.User) .Include(i => i.User)
.FirstOrDefaultAsync(u => u.UserId == userId, cancellationToken); .FirstOrDefaultAsync(u => u.UserId == userId, cancellationToken);
@@ -26,7 +29,8 @@ public sealed class UserService(IDbContextFactory<ApplicationDbContext> contextF
Id = userId, Id = userId,
Email = email, Email = email,
Status = ActivityStatus.Active, Status = ActivityStatus.Active,
LastLoginAt = DateTime.UtcNow LastLoginAt = DateTime.UtcNow,
Role = role
}); });
context.TenantUsers.Add(new Domain.Entities.TenantUser context.TenantUsers.Add(new Domain.Entities.TenantUser
@@ -42,11 +46,12 @@ public sealed class UserService(IDbContextFactory<ApplicationDbContext> contextF
{ {
tenantUser.User!.Email = email; tenantUser.User!.Email = email;
tenantUser.User.LastLoginAt = DateTime.UtcNow; tenantUser.User.LastLoginAt = DateTime.UtcNow;
tenantUser.User.Role = role;
context.Users.Update(tenantUser.User); 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) => 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 { Modal } from '../ui/Modal';
import { useToast } from '../ui/Toast'; import { useToast } from '../ui/Toast';
import { ProblemDetailsAlert } from '../ui/ProblemDetailsAlert'; import { ProblemDetailsAlert } from '../ui/ProblemDetailsAlert';
import { HelpTooltip } from '../ui/HelpTooltip';
import { ExportButton } from '../ui/ExportButton'; import { ExportButton } from '../ui/ExportButton';
import { ExportColumn } from '../../utils/exportUtils'; import { ExportColumn } from '../../utils/exportUtils';
import { useTableSort, SortableTh } from '../../utils/tableSort'; 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="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="bg-[#0f1523]/80 backdrop-blur-md p-5 rounded-2xl border border-slate-800/80 shadow-lg">
<div className="flex items-center justify-between"> <div className="flex items-center justify-between">
<span className="text-xs font-semibold text-slate-400 uppercase tracking-wider"> <div className="flex items-center gap-1.5">
Total Contracts <span className="text-xs font-semibold text-slate-400 uppercase tracking-wider">
</span> 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"> <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" /> <FileText className="w-4 h-4" />
</div> </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="bg-[#0f1523]/80 backdrop-blur-md p-5 rounded-2xl border border-slate-800/80 shadow-lg">
<div className="flex items-center justify-between"> <div className="flex items-center justify-between">
<span className="text-xs font-semibold text-slate-400 uppercase tracking-wider"> <div className="flex items-center gap-1.5">
Active Value (ZAR) <span className="text-xs font-semibold text-slate-400 uppercase tracking-wider">
</span> 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"> <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" /> <ShieldCheck className="w-4 h-4" />
</div> </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="bg-[#0f1523]/80 backdrop-blur-md p-5 rounded-2xl border border-slate-800/80 shadow-lg">
<div className="flex items-center justify-between"> <div className="flex items-center justify-between">
<span className="text-xs font-semibold text-slate-400 uppercase tracking-wider"> <div className="flex items-center gap-1.5">
In Review / Draft <span className="text-xs font-semibold text-slate-400 uppercase tracking-wider">
</span> 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"> <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" /> <Clock className="w-4 h-4" />
</div> </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="bg-[#0f1523]/80 backdrop-blur-md p-5 rounded-2xl border border-slate-800/80 shadow-lg">
<div className="flex items-center justify-between"> <div className="flex items-center justify-between">
<span className="text-xs font-semibold text-slate-400 uppercase tracking-wider"> <div className="flex items-center gap-1.5">
Approved Awards Available <span className="text-xs font-semibold text-slate-400 uppercase tracking-wider">
</span> 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"> <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" /> <Briefcase className="w-4 h-4" />
</div> </div>
@@ -54,6 +54,7 @@ import {
} from '../../lib/formatters'; } from '../../lib/formatters';
import { Modal } from '../ui/Modal'; import { Modal } from '../ui/Modal';
import { useToast } from '../ui/Toast'; import { useToast } from '../ui/Toast';
import { HelpTooltip } from '../ui/HelpTooltip';
import { ExportButton } from '../ui/ExportButton'; import { ExportButton } from '../ui/ExportButton';
import { ExportColumn } from '../../utils/exportUtils'; import { ExportColumn } from '../../utils/exportUtils';
@@ -419,10 +420,18 @@ export const EvidenceReviewQueue: React.FC<EvidenceReviewQueueProps> = ({
{filteredEvidence.length} {filteredEvidence.length === 1 ? 'file' : 'files'} in view {filteredEvidence.length} {filteredEvidence.length === 1 ? 'file' : 'files'} in view
</span> </span>
</div> </div>
<h3 className="text-lg font-bold text-white tracking-tight flex items-center gap-2"> <div className="flex items-center gap-2">
<FileCheck className="w-5 h-5 text-blue-400" /> <h3 className="text-lg font-bold text-white tracking-tight flex items-center gap-2">
Evidence Review & Verification Hub <FileCheck className="w-5 h-5 text-blue-400" />
</h3> 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"> <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. 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. 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 [isCompModalOpen, setIsCompModalOpen] = useState(false);
const [editingComp, setEditingComp] = useState<ComplianceItem | null>(null); const [editingComp, setEditingComp] = useState<ComplianceItem | null>(null);
const [compTargetProjectId, setCompTargetProjectId] = useState<string>('');
const [selectedBenForDetail, setSelectedBenForDetail] = useState<Beneficiary | null>(null); const [selectedBenForDetail, setSelectedBenForDetail] = useState<Beneficiary | null>(null);
const [isDetailModalOpen, setIsDetailModalOpen] = useState(false); const [isDetailModalOpen, setIsDetailModalOpen] = useState(false);
@@ -109,16 +110,44 @@ export const BeneficiariesPage: React.FC = () => {
enabled: !!effectiveProjectId, 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 { const {
data: complianceItems = [], data: complianceItems = [],
isLoading: loadingComps, isLoading: loadingComps,
isError: isCompsError,
refetch: refetchComps, refetch: refetchComps,
isFetching: fetchingComps, isFetching: fetchingComps,
} = useQuery({ } = useQuery({
queryKey: ['complianceItems', effectiveProjectId], queryKey: ['complianceItems', selectedProjectId, projects.map((p) => p.id).join(',')],
queryFn: () => (effectiveProjectId ? getProjectComplianceItems(effectiveProjectId) : Promise.resolve([])), queryFn: async (): Promise<ComplianceItem[]> => {
enabled: !!effectiveProjectId, 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 // Query outcomes for selected beneficiary
@@ -190,8 +219,9 @@ export const BeneficiariesPage: React.FC = () => {
const createCompMutation = useMutation({ const createCompMutation = useMutation({
mutationFn: (vals: ComplianceFormValues) => { mutationFn: (vals: ComplianceFormValues) => {
if (!effectiveProjectId) throw new Error('No project selected'); const targetProjId = compTargetProjectId || (selectedProjectId !== 'all' ? selectedProjectId : projects[0]?.id);
return createComplianceItem({ projectId: effectiveProjectId, title: vals.title, requirements: vals.requirements }); if (!targetProjId) throw new Error('No project selected');
return createComplianceItem({ projectId: targetProjId, title: vals.title, requirements: vals.requirements });
}, },
onSuccess: () => { onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['complianceItems'] }); queryClient.invalidateQueries({ queryKey: ['complianceItems'] });
@@ -285,6 +315,11 @@ export const BeneficiariesPage: React.FC = () => {
]; ];
const compExportColumns: ExportColumn<ComplianceItem>[] = [ 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: 'Compliance Requirement', key: 'title' },
{ header: 'Requirements Detail', key: 'requirements' }, { header: 'Requirements Detail', key: 'requirements' },
{ header: 'Status', key: 'status', format: (val) => (val === ApprovalStatus.Approved ? 'Approved' : 'Pending Review') }, { 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" /> <Users className="w-6 h-6" />
</div> </div>
<div> <div>
<h1 className="text-2xl sm:text-3xl font-bold tracking-tight text-white flex items-center gap-2.5"> <div className="flex items-center gap-2.5 flex-wrap">
Beneficiaries & Compliance Register <h1 className="text-2xl sm:text-3xl font-bold tracking-tight text-white flex items-center gap-2.5">
</h1> Beneficiaries & Compliance Register
<p className="text-xs sm:text-sm text-slate-400"> </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. Manage target recipient demographics, track reached counts, and record verified statutory compliance items.
</p> </p>
</div> </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="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="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"> <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" /> <Users className="w-4 h-4 text-blue-400" />
</div> </div>
<p className="text-2xl font-bold text-white">{beneficiaries.length}</p> <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="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"> <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" /> <Target className="w-4 h-4 text-indigo-400" />
</div> </div>
<p className="text-2xl font-bold text-white">{totalTarget.toLocaleString()}</p> <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="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"> <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" /> <Award className="w-4 h-4 text-emerald-400" />
</div> </div>
<div className="flex items-baseline gap-2"> <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="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"> <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" /> <ShieldCheck className="w-4 h-4 text-cyan-400" />
</div> </div>
<div className="flex items-baseline gap-2"> <div className="flex items-baseline gap-2">
@@ -476,6 +547,7 @@ export const BeneficiariesPage: React.FC = () => {
onChange={(e) => setSelectedProjectId(e.target.value)} 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" 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) => ( {projects.map((p) => (
<option key={p.id} value={p.id}> <option key={p.id} value={p.id}>
{p.name} {p.name}
@@ -660,6 +732,7 @@ export const BeneficiariesPage: React.FC = () => {
onChange={(e) => setSelectedProjectId(e.target.value)} 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" 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) => ( {projects.map((p) => (
<option key={p.id} value={p.id}> <option key={p.id} value={p.id}>
{p.name} {p.name}
@@ -695,6 +768,7 @@ export const BeneficiariesPage: React.FC = () => {
<button <button
onClick={() => { onClick={() => {
setEditingComp(null); setEditingComp(null);
setCompTargetProjectId(selectedProjectId !== 'all' ? selectedProjectId : projects[0]?.id ? String(projects[0].id) : '');
compForm.reset({ title: '', requirements: '', status: ApprovalStatus.Pending }); compForm.reset({ title: '', requirements: '', status: ApprovalStatus.Pending });
setIsCompModalOpen(true); 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"> <SortableTh column="title" currentColumn={compSortCol} direction={compSortDir} onSort={handleCompSort} className="py-3.5 px-5 whitespace-nowrap">
Compliance Title Compliance Title
</SortableTh> </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"> <SortableTh column="requirements" currentColumn={compSortCol} direction={compSortDir} onSort={handleCompSort} className="py-3.5 px-4 whitespace-nowrap">
Requirements Requirements
</SortableTh> </SortableTh>
@@ -727,9 +806,25 @@ export const BeneficiariesPage: React.FC = () => {
</tr> </tr>
</thead> </thead>
<tbody className="divide-y divide-slate-800/60"> <tbody className="divide-y divide-slate-800/60">
{loadingComps ? ( {isCompsError ? (
<tr> <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="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" /> <div className="w-6 h-6 border-2 border-blue-500 border-t-transparent rounded-full animate-spin" />
<span>Loading compliance items...</span> <span>Loading compliance items...</span>
@@ -738,8 +833,10 @@ export const BeneficiariesPage: React.FC = () => {
</tr> </tr>
) : sortedComps.length === 0 ? ( ) : sortedComps.length === 0 ? (
<tr> <tr>
<td colSpan={5} className="py-12 text-center text-slate-400"> <td colSpan={selectedProjectId === 'all' ? 6 : 5} className="py-12 text-center text-slate-400">
No compliance items recorded for this project. {selectedProjectId === 'all'
? 'No compliance items recorded across all projects.'
: 'No compliance items recorded for this project.'}
</td> </td>
</tr> </tr>
) : ( ) : (
@@ -751,6 +848,13 @@ export const BeneficiariesPage: React.FC = () => {
<span>{c.title}</span> <span>{c.title}</span>
</div> </div>
</td> </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 || ''}> <td className="py-3.5 px-4 text-slate-300 max-w-xs truncate" title={c.requirements || ''}>
{c.requirements || 'None'} {c.requirements || 'None'}
</td> </td>
@@ -869,6 +973,23 @@ export const BeneficiariesPage: React.FC = () => {
onSubmit={compForm.handleSubmit((d) => (editingComp ? updateCompMutation.mutate(d) : createCompMutation.mutate(d)))} onSubmit={compForm.handleSubmit((d) => (editingComp ? updateCompMutation.mutate(d) : createCompMutation.mutate(d)))}
className="space-y-4" 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> <div>
<label className="block text-xs font-semibold text-slate-300 mb-1">Compliance Title *</label> <label className="block text-xs font-semibold text-slate-300 mb-1">Compliance Title *</label>
<input <input
@@ -35,6 +35,7 @@ import {
} from '../services/api'; } from '../services/api';
import { Modal } from '../components/ui/Modal'; import { Modal } from '../components/ui/Modal';
import { useToast } from '../components/ui/Toast'; import { useToast } from '../components/ui/Toast';
import { HelpTooltip } from '../components/ui/HelpTooltip';
import { ExportButton } from '../components/ui/ExportButton'; import { ExportButton } from '../components/ui/ExportButton';
import { ExportColumn } from '../utils/exportUtils'; import { ExportColumn } from '../utils/exportUtils';
import { useTableSort, SortableTh } from '../utils/tableSort'; import { useTableSort, SortableTh } from '../utils/tableSort';
@@ -522,10 +523,18 @@ export const ChangeManagementPage: React.FC = () => {
<GitPullRequest className="w-6 h-6" /> <GitPullRequest className="w-6 h-6" />
</div> </div>
<div> <div>
<h1 className="text-2xl sm:text-3xl font-bold tracking-tight text-white flex items-center gap-2.5"> <div className="flex items-center gap-2.5 flex-wrap">
Change Management & Issue Resolution <h1 className="text-2xl sm:text-3xl font-bold tracking-tight text-white flex items-center gap-2.5">
</h1> Change Management & Issue Resolution
<p className="text-xs sm:text-sm text-slate-400"> </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. Track project execution impediments, review contractual variations, and resolve change requests in-place.
</p> </p>
</div> </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="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="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"> <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" /> <AlertOctagon className="w-4 h-4 text-rose-400" />
</div> </div>
<p className="text-2xl font-bold text-rose-400">{issues.filter((i) => i.status !== ActivityStatus.Inactive).length}</p> <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="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"> <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" /> <CheckCircle2 className="w-4 h-4 text-emerald-400" />
</div> </div>
<p className="text-2xl font-bold text-emerald-400"> <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="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"> <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" /> <GitPullRequest className="w-4 h-4 text-amber-400" />
</div> </div>
<p className="text-2xl font-bold text-amber-400"> <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="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"> <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" /> <Coins className="w-4 h-4 text-blue-400" />
</div> </div>
<p className="text-2xl font-bold text-blue-400"> <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 { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
import { import {
Shield, Shield,
@@ -36,6 +36,7 @@ import {
ChevronDown, ChevronDown,
ChevronRight, ChevronRight,
User as UserIcon, User as UserIcon,
RotateCcw,
} from 'lucide-react'; } from 'lucide-react';
import { import {
getUsers, getUsers,
@@ -114,7 +115,12 @@ export const SettingsPage: React.FC = () => {
staleTime: 5 * 60 * 1000, 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], queryKey: ['users', userStatusFilter, userEmailSearch],
queryFn: () => getUsers(1, 100, userFilter), queryFn: () => getUsers(1, 100, userFilter),
}); });
@@ -191,11 +197,32 @@ export const SettingsPage: React.FC = () => {
const [inviteMessage, setInviteMessage] = useState(''); const [inviteMessage, setInviteMessage] = useState('');
const [invitePlatform, setInvitePlatform] = useState<NotificationPlatform>(NotificationPlatform.Email); 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'], queryKey: ['invites'],
queryFn: () => getInvites(1, 100), 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({ const { data: organisations = [] } = useQuery({
queryKey: ['organisations'], queryKey: ['organisations'],
queryFn: () => getOrganisations(1, 50), queryFn: () => getOrganisations(1, 50),
@@ -432,6 +459,25 @@ export const SettingsPage: React.FC = () => {
handleSort: handleAuditSort, handleSort: handleAuditSort,
} = useTableSort(filteredAuditLogs, 'timestamp', 'desc'); } = 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 { const {
sortedData: sortedUsers, sortedData: sortedUsers,
sortColumn: userSortCol, sortColumn: userSortCol,
@@ -444,7 +490,7 @@ export const SettingsPage: React.FC = () => {
sortColumn: inviteSortCol, sortColumn: inviteSortCol,
sortDirection: inviteSortDir, sortDirection: inviteSortDir,
handleSort: handleInviteSort, handleSort: handleInviteSort,
} = useTableSort(invitesList, 'createdAt', 'desc'); } = useTableSort(filteredInvites, 'createdAt', 'desc');
const getActionBadge = (action?: string | null) => { const getActionBadge = (action?: string | null) => {
const act = (action || 'GENERAL_EVENT').toUpperCase(); const act = (action || 'GENERAL_EVENT').toUpperCase();
@@ -621,44 +667,101 @@ export const SettingsPage: React.FC = () => {
{/* ============================================================== */} {/* ============================================================== */}
{activeTab === 'users' && ( {activeTab === 'users' && (
<div className="space-y-4"> <div className="space-y-4">
{/* Filter and Search Bar */} {/* Standardized Toolbar & Filter Bar directly above Users Table */}
<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="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">
<div className="relative w-full sm:w-80"> {/* Filters on the left */}
<Search className="w-4 h-4 text-slate-500 absolute left-3 top-2.5" /> <div className="flex items-center gap-2.5 flex-wrap flex-1">
<input <div className="relative flex-1 min-w-[200px] max-w-sm">
type="text" <Search className="w-3.5 h-3.5 text-slate-400 absolute left-3 top-2.5" />
value={userEmailSearch} <input
onChange={(e) => setUserEmailSearch(e.target.value)} type="text"
placeholder="Search users by email..." value={userEmailSearch}
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" 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>
<div className="flex items-center gap-2 w-full sm:w-auto"> {/* Actions on the right: Refresh, Export, Sync */}
<span className="text-xs font-semibold text-slate-400 whitespace-nowrap">Filter Status:</span> <div className="flex items-center gap-2 flex-wrap justify-end">
<select <button
value={userStatusFilter} type="button"
onChange={(e) => setUserStatusFilter(e.target.value === 'all' ? 'all' : Number(e.target.value))} onClick={() => {
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" 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> <RefreshCw className={`w-3.5 h-3.5 text-blue-400 ${isFetchingUsers ? 'animate-spin' : ''}`} />
<option value={ActivityStatus.Active}>Active</option> <span>Refresh</span>
<option value={ActivityStatus.Inactive}>Inactive</option> </button>
<option value={ActivityStatus.Suspended}>Suspended</option>
</select> <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 <button
onClick={() => refetchUsers()} onClick={() => syncMutation.mutate()}
className="p-1.5 rounded-xl border border-slate-700 bg-slate-800/80 hover:bg-slate-700 text-slate-300 transition cursor-pointer" disabled={syncMutation.isPending}
title="Refresh user list" 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> </button>
</div> </div>
</div> </div>
{/* Users Table */} {/* Users Table */}
<div className="bg-[#0f1523]/80 backdrop-blur-md rounded-2xl border border-slate-800/80 shadow-xl overflow-hidden"> <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"> <div className="overflow-x-auto">
<table className="w-full text-left text-xs border-collapse"> <table className="w-full text-left text-xs border-collapse">
<thead> <thead>
@@ -831,13 +934,100 @@ export const SettingsPage: React.FC = () => {
)} )}
</div> </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 */} {/* Sent Invites Ledger */}
<div className="bg-[#0f1523]/80 backdrop-blur-md rounded-2xl border border-slate-800/80 shadow-xl overflow-hidden"> <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"> <div className="p-4 border-b border-slate-800/80 flex items-center justify-between">
<span className="text-xs font-bold text-white uppercase tracking-wider"> <div className="flex items-center gap-2">
Sent Invitations Ledger <Mail className="w-4 h-4 text-blue-400" />
</span> <h3 className="text-sm font-semibold text-white">
<span className="text-xs text-slate-400">{invitesList.length} invitations dispatched</span> 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>
<div className="overflow-x-auto"> <div className="overflow-x-auto">
<table className="w-full text-left text-xs border-collapse"> <table className="w-full text-left text-xs border-collapse">
@@ -29,6 +29,7 @@ import {
} from '../services/api'; } from '../services/api';
import { Modal } from '../components/ui/Modal'; import { Modal } from '../components/ui/Modal';
import { useToast } from '../components/ui/Toast'; import { useToast } from '../components/ui/Toast';
import { HelpTooltip } from '../components/ui/HelpTooltip';
import { ExportButton } from '../components/ui/ExportButton'; import { ExportButton } from '../components/ui/ExportButton';
import { ExportColumn } from '../utils/exportUtils'; import { ExportColumn } from '../utils/exportUtils';
import { useTableSort, SortableTh } from '../utils/tableSort'; import { useTableSort, SortableTh } from '../utils/tableSort';
@@ -195,10 +196,18 @@ export const SiteVisitsPage: React.FC = () => {
<MapPin className="w-6 h-6" /> <MapPin className="w-6 h-6" />
</div> </div>
<div> <div>
<h1 className="text-2xl sm:text-3xl font-bold tracking-tight text-white flex items-center gap-2.5"> <div className="flex items-center gap-2.5 flex-wrap">
Site Visits & Field Verification <h1 className="text-2xl sm:text-3xl font-bold tracking-tight text-white flex items-center gap-2.5">
</h1> Site Visits & Field Verification
<p className="text-xs sm:text-sm text-slate-400"> </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. Log physical inspection reports, record field findings, and capture project feedback.
</p> </p>
</div> </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="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="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"> <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" /> <MapPin className="w-4 h-4 text-emerald-400" />
</div> </div>
<p className="text-2xl font-bold text-white">{siteVisits.length}</p> <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="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"> <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" /> <CheckCircle2 className="w-4 h-4 text-teal-400" />
</div> </div>
<p className="text-2xl font-bold text-teal-400"> <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="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"> <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" /> <FileText className="w-4 h-4 text-amber-400" />
</div> </div>
<p className="text-2xl font-bold text-amber-400"> <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="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"> <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" /> <Building className="w-4 h-4 text-blue-400" />
</div> </div>
<p className="text-2xl font-bold text-blue-400">{projects.length}</p> <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[]> => { export const getProjectComplianceItems = async (projectId: string | number): Promise<ComplianceItem[]> => {
const hashedProjectId = await toolsService.resolveProjectId(projectId); const hashedProjectId = await toolsService.resolveProjectId(projectId);
const res = await apiClient.get<ComplianceItem[]>(`/v1/api/projects/complianceitems/${hashedProjectId}`); try {
return res.data || []; // 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> => { 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.HasKey(pk => pk.Id);
builder.Property(f => f.Email).IsRequired().HasMaxLength(LabelLength); 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") builder.Property(f => f.Status).IsRequired().HasConversion<int>().HasDefaultValueSql("2")
.HasSentinel(ActivityStatus.Inactive); .HasSentinel(ActivityStatus.Inactive);
builder.Property(f => f.LastLoginAt).IsRequired(false); builder.Property(f => f.LastLoginAt).IsRequired(false);
@@ -12,6 +12,8 @@ public static class Constants
public const string EmailtemKey = "UserEmail"; public const string EmailtemKey = "UserEmail";
public const string UserRoleItemKey = "UserRole";
public const string UserIdItemKey = "UserId"; public const string UserIdItemKey = "UserId";
public const string DefaultSchedulerName = "pfm-scheduler"; public const string DefaultSchedulerName = "pfm-scheduler";
+2
View File
@@ -8,5 +8,7 @@ public class User
public string? Email { get; set; } public string? Email { get; set; }
public string? Role { get; set; }
public ActivityStatus Status { 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") b.Property<DateTime?>("LastLoginAt")
.HasColumnType("timestamp with time zone"); .HasColumnType("timestamp with time zone");
b.Property<string>("Role")
.HasMaxLength(256)
.HasColumnType("character varying(256)");
b.Property<int>("Status") b.Property<int>("Status")
.ValueGeneratedOnAdd() .ValueGeneratedOnAdd()
.HasColumnType("integer") .HasColumnType("integer")