Finishline #116
@@ -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 & 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 & 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";
|
||||
|
||||
@@ -391,6 +391,7 @@ public static class Mappers
|
||||
Id = entity.Id,
|
||||
Email = entity.Email,
|
||||
LastLoginAt = entity.LastLoginAt,
|
||||
Status = entity.Status
|
||||
Status = entity.Status,
|
||||
Role = entity.Role
|
||||
};
|
||||
}
|
||||
@@ -8,5 +8,7 @@ public class User
|
||||
|
||||
public string? Email { get; set; }
|
||||
|
||||
public string? Role { get; set; }
|
||||
|
||||
public ActivityStatus Status { get; set; }
|
||||
}
|
||||
Generated
+2457
File diff suppressed because it is too large
Load Diff
+29
@@ -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");
|
||||
}
|
||||
}
|
||||
}
|
||||
+4
@@ -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")
|
||||
|
||||
Reference in New Issue
Block a user