Merge pull request 'Enforced approval rights' (#149) from finishline into main
Reviewed-on: #149
This commit was merged in pull request #149.
This commit is contained in:
@@ -0,0 +1,36 @@
|
|||||||
|
using PostFundManagement.Application.Abstractions.Services;
|
||||||
|
using PostFundManagement.Application.Extensions;
|
||||||
|
using PostFundManagement.Application.Services.Grants;
|
||||||
|
using PostFundManagement.Domain.Abstractions;
|
||||||
|
using PostFundManagement.Domain.Errors;
|
||||||
|
using PostFundManagement.Domain.Extensions;
|
||||||
|
|
||||||
|
namespace PostFundManagement.Api.Endpoints.Grants;
|
||||||
|
|
||||||
|
[ApiVersionTarget(1)]
|
||||||
|
public sealed class UpdateAwardEndpoint : IEndpoint
|
||||||
|
{
|
||||||
|
public void Map(IEndpointRouteBuilder builder)
|
||||||
|
{
|
||||||
|
builder.MapPut("api/grants/awards", async (UpdateAwardRequest request, HttpContext context,
|
||||||
|
IGrantsService service, CancellationToken cancellationToken = default) =>
|
||||||
|
await service.UpdateAwardAsync(request, context.GetUserId(), cancellationToken) switch
|
||||||
|
{
|
||||||
|
{ IsSuccess: true } => Results.Ok(),
|
||||||
|
var r when r.HasError<NotFoundError>() => Results.NotFound(r.Errors.ToProblems()),
|
||||||
|
var r when r.HasError<ValidationError>() => Results.BadRequest(r.Errors.ToProblems()),
|
||||||
|
var r when r.HasError<UpdateError>() => Results.BadRequest(r.Errors.ToProblems()),
|
||||||
|
var r => Results.InternalServerError(r.Errors.ToProblems())
|
||||||
|
})
|
||||||
|
.WithDescription("Updates a registered funding award")
|
||||||
|
.RequireAuthorization(Constants.MainScope)
|
||||||
|
.WithName(typeof(UpdateAwardEndpoint).ToEndpointName())
|
||||||
|
.MapToApiVersion(new ApiVersion(1))
|
||||||
|
.Produces(StatusCodes.Status200OK)
|
||||||
|
.Produces(StatusCodes.Status401Unauthorized)
|
||||||
|
.Produces<Problems>(StatusCodes.Status404NotFound)
|
||||||
|
.Produces<Problems>(StatusCodes.Status400BadRequest)
|
||||||
|
.Produces<Problems>(StatusCodes.Status500InternalServerError)
|
||||||
|
.WithTags(EndpointTags.Grants);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,4 +1,4 @@
|
|||||||
using PostFundManagement.Application.Services.Grants;
|
using PostFundManagement.Application.Services.Grants;
|
||||||
using PostFundManagement.Domain;
|
using PostFundManagement.Domain;
|
||||||
using PostFundManagement.Domain.Attributes;
|
using PostFundManagement.Domain.Attributes;
|
||||||
using PostFundManagement.Domain.Models;
|
using PostFundManagement.Domain.Models;
|
||||||
@@ -45,6 +45,10 @@ public interface IGrantsService
|
|||||||
[Audit]
|
[Audit]
|
||||||
Task<Result> CreateAwardAsync(CreateAwardRequest request, Guid userId, CancellationToken cancellationToken = default);
|
Task<Result> CreateAwardAsync(CreateAwardRequest request, Guid userId, CancellationToken cancellationToken = default);
|
||||||
|
|
||||||
|
[Log]
|
||||||
|
[Audit]
|
||||||
|
Task<Result> UpdateAwardAsync(UpdateAwardRequest request, Guid userId, CancellationToken cancellationToken = default);
|
||||||
|
|
||||||
[Log]
|
[Log]
|
||||||
[Audit]
|
[Audit]
|
||||||
Task<Result<string>> GetContractUrlAsync(string contractIdHash, CancellationToken cancellationToken = default);
|
Task<Result<string>> GetContractUrlAsync(string contractIdHash, CancellationToken cancellationToken = default);
|
||||||
|
|||||||
@@ -12,6 +12,9 @@ public sealed class GrantsCacheService(GrantsService service, IDistributedCache
|
|||||||
public async Task<Result> CreateAwardAsync(CreateAwardRequest request, Guid userId, CancellationToken cancellationToken = default) =>
|
public async Task<Result> CreateAwardAsync(CreateAwardRequest request, Guid userId, CancellationToken cancellationToken = default) =>
|
||||||
await service.CreateAwardAsync(request, userId, cancellationToken);
|
await service.CreateAwardAsync(request, userId, cancellationToken);
|
||||||
|
|
||||||
|
public async Task<Result> UpdateAwardAsync(UpdateAwardRequest request, Guid userId, CancellationToken cancellationToken = default) =>
|
||||||
|
await service.UpdateAwardAsync(request, userId, cancellationToken);
|
||||||
|
|
||||||
public async Task<Result> CreateBudgetAsync(CreateBudgetRequest request, Guid userId, CancellationToken cancellationToken = default) =>
|
public async Task<Result> CreateBudgetAsync(CreateBudgetRequest request, Guid userId, CancellationToken cancellationToken = default) =>
|
||||||
await service.CreateBudgetAsync(request, userId, cancellationToken);
|
await service.CreateBudgetAsync(request, userId, cancellationToken);
|
||||||
|
|
||||||
|
|||||||
@@ -206,6 +206,44 @@ public sealed class GrantsService(IDbContextFactory<ApplicationDbContext> contex
|
|||||||
return Result.OkIf(await context.SaveChangesAsync(cancellationToken) > 0, CreateError.Create("Failed to create award"));
|
return Result.OkIf(await context.SaveChangesAsync(cancellationToken) > 0, CreateError.Create("Failed to create award"));
|
||||||
}));
|
}));
|
||||||
|
|
||||||
|
public async Task<Result> UpdateAwardAsync(UpdateAwardRequest request, Guid userId, CancellationToken cancellationToken = default) =>
|
||||||
|
await Result.Try((Func<Task<Result>>)(async () =>
|
||||||
|
{
|
||||||
|
if (request.Amount <= 0)
|
||||||
|
return Result.Fail(ValidationError.Create("Approved amount must be greater than zero."));
|
||||||
|
|
||||||
|
await using var context = await contextFactory.CreateDbContextAsync(cancellationToken);
|
||||||
|
|
||||||
|
var existingAward = await context.Awards.FirstOrDefaultAsync(a => a.Id == request.AwardId, cancellationToken);
|
||||||
|
if (existingAward is null)
|
||||||
|
return Result.Fail(NotFoundError.Create("Award not found"));
|
||||||
|
|
||||||
|
if (existingAward.Status == ApprovalStatus.Approved)
|
||||||
|
return Result.Fail(ValidationError.Create("Approved awards are immutable and cannot be updated."));
|
||||||
|
|
||||||
|
if (!await context.Organisations.AnyAsync(o => o.Id == request.OrganisationId, cancellationToken))
|
||||||
|
return Result.Fail(NotFoundError.Create("Organisation not found"));
|
||||||
|
|
||||||
|
if (!await context.Programmes.AnyAsync(p => p.Id == request.ProgrammeId, cancellationToken))
|
||||||
|
return Result.Fail(NotFoundError.Create("Programme not found"));
|
||||||
|
|
||||||
|
existingAward.OrganisationId = request.OrganisationId;
|
||||||
|
existingAward.ProgrammeId = request.ProgrammeId;
|
||||||
|
existingAward.Amount = request.Amount;
|
||||||
|
if (request.Status.HasValue)
|
||||||
|
{
|
||||||
|
existingAward.Status = request.Status.Value;
|
||||||
|
if (request.Status.Value == ApprovalStatus.Approved)
|
||||||
|
{
|
||||||
|
existingAward.ApprovedBy = userId;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
existingAward.UpdatedBy = userId;
|
||||||
|
existingAward.UpdatedAt = DateTime.UtcNow;
|
||||||
|
|
||||||
|
return Result.OkIf(await context.SaveChangesAsync(cancellationToken) > 0, UpdateError.Create("Failed to update award"));
|
||||||
|
}));
|
||||||
|
|
||||||
public async Task<Result<string>> GetContractUrlAsync(string contractIdHash, CancellationToken cancellationToken = default) =>
|
public async Task<Result<string>> GetContractUrlAsync(string contractIdHash, CancellationToken cancellationToken = default) =>
|
||||||
await Result.Try((Func<Task<Result<string>>>)(async () =>
|
await Result.Try((Func<Task<Result<string>>>)(async () =>
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
using PostFundManagement.Domain;
|
using PostFundManagement.Domain;
|
||||||
|
|
||||||
namespace PostFundManagement.Application.Services.Grants;
|
namespace PostFundManagement.Application.Services.Grants;
|
||||||
|
|
||||||
@@ -8,6 +8,8 @@ public sealed record UpdateContractRequest([Required] long ContractId, [Required
|
|||||||
|
|
||||||
public sealed record CreateAwardRequest([Required] long OrganisationId, [Required] long ProgrammeId, [Required] decimal Amount);
|
public sealed record CreateAwardRequest([Required] long OrganisationId, [Required] long ProgrammeId, [Required] decimal Amount);
|
||||||
|
|
||||||
|
public sealed record UpdateAwardRequest([Required] long AwardId, [Required] long OrganisationId, [Required] long ProgrammeId, [Required] decimal Amount, ApprovalStatus? Status);
|
||||||
|
|
||||||
public sealed record CreateBudgetRequest([Required] long ProjectId,
|
public sealed record CreateBudgetRequest([Required] long ProjectId,
|
||||||
[Required][MaxLength(LabelLength)] string Name,
|
[Required][MaxLength(LabelLength)] string Name,
|
||||||
[Required] decimal Amount);
|
[Required] decimal Amount);
|
||||||
|
|||||||
@@ -90,14 +90,15 @@ export const AccountActivationModal: React.FC<AccountActivationModalProps> = ({
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
// If result.success is true, AuthProvider updates state, and this modal unmounts automatically!
|
// If result.success is true, AuthProvider updates state, and this modal unmounts automatically!
|
||||||
} catch (err: any) {
|
} catch (err: unknown) {
|
||||||
if (err?.response?.status === 401) {
|
const errObj = err as { response?: { status?: number }; message?: string };
|
||||||
|
if (errObj?.response?.status === 401) {
|
||||||
await onLogout();
|
await onLogout();
|
||||||
navigate('/', { replace: true });
|
navigate('/', { replace: true });
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
setErrorMessage(
|
setErrorMessage(
|
||||||
err?.message ||
|
errObj?.message ||
|
||||||
'Account activation check encountered an error. Please confirm with your administrator and try again.'
|
'Account activation check encountered an error. Please confirm with your administrator and try again.'
|
||||||
);
|
);
|
||||||
} finally {
|
} finally {
|
||||||
|
|||||||
@@ -169,8 +169,8 @@ export const FundingInstrumentsSection: React.FC<FundingInstrumentsSectionProps>
|
|||||||
setEditingInstrument(null);
|
setEditingInstrument(null);
|
||||||
if (onCloseCreateModalExternal) onCloseCreateModalExternal();
|
if (onCloseCreateModalExternal) onCloseCreateModalExternal();
|
||||||
},
|
},
|
||||||
onError: (err: any) => {
|
onError: (err: unknown) => {
|
||||||
toastError('Save Failed', err?.message || 'Failed to save funding instrument');
|
toastError('Save Failed', err);
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -166,8 +166,8 @@ export const RulesSection: React.FC<RulesSectionProps> = ({
|
|||||||
setEditingRule(null);
|
setEditingRule(null);
|
||||||
if (onCloseCreateModalExternal) onCloseCreateModalExternal();
|
if (onCloseCreateModalExternal) onCloseCreateModalExternal();
|
||||||
},
|
},
|
||||||
onError: (err: any) => {
|
onError: (err: unknown) => {
|
||||||
toastError('Save Failed', err?.message || 'Failed to save programme rule');
|
toastError('Save Failed', err);
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -527,9 +527,6 @@ export const AppLayout: React.FC = () => {
|
|||||||
{userRole}
|
{userRole}
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
<p className="text-[11px] text-slate-400 font-mono truncate max-w-[200px]" title={tokenUserEmail}>
|
|
||||||
{tokenUserEmail}
|
|
||||||
</p>
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<button
|
<button
|
||||||
|
|||||||
@@ -247,7 +247,7 @@ export const ContractsManagement: React.FC<ContractsManagementProps> = ({
|
|||||||
|
|
||||||
// Target contractId for upload
|
// Target contractId for upload
|
||||||
const contractIdToUploadAgainst =
|
const contractIdToUploadAgainst =
|
||||||
createdContract?.id ?? (createdContract as any)?.contractId ?? selectedAwardId;
|
createdContract?.id ?? selectedAwardId;
|
||||||
|
|
||||||
// Step 2: Upload PDF document to POST /v1/api/grants/contracts/{contractId}
|
// Step 2: Upload PDF document to POST /v1/api/grants/contracts/{contractId}
|
||||||
if (contractPdfFile && contractIdToUploadAgainst) {
|
if (contractPdfFile && contractIdToUploadAgainst) {
|
||||||
@@ -277,6 +277,9 @@ export const ContractsManagement: React.FC<ContractsManagementProps> = ({
|
|||||||
mutationFn: async () => {
|
mutationFn: async () => {
|
||||||
setUploadApiError(null);
|
setUploadApiError(null);
|
||||||
if (!contractForFileUpload) throw new Error('No contract selected for upload.');
|
if (!contractForFileUpload) throw new Error('No contract selected for upload.');
|
||||||
|
if (Number(contractForFileUpload.status) === ContractStatus.Active) {
|
||||||
|
throw new Error('Active contracts are legally executed and immutable. Documents cannot be modified.');
|
||||||
|
}
|
||||||
if (!selectedUploadFile) throw new Error('Please select a PDF document to upload.');
|
if (!selectedUploadFile) throw new Error('Please select a PDF document to upload.');
|
||||||
|
|
||||||
await uploadContractFile(contractForFileUpload.id, selectedUploadFile);
|
await uploadContractFile(contractForFileUpload.id, selectedUploadFile);
|
||||||
@@ -417,21 +420,21 @@ export const ContractsManagement: React.FC<ContractsManagementProps> = ({
|
|||||||
sortDirection: contractSortDir,
|
sortDirection: contractSortDir,
|
||||||
handleSort: handleContractSort,
|
handleSort: handleContractSort,
|
||||||
} = useTableSort<Contract>(filteredContracts, null, null, {
|
} = useTableSort<Contract>(filteredContracts, null, null, {
|
||||||
id: (c: any) => c.id,
|
id: (c: Contract) => c.id,
|
||||||
awardId: (c: any) => c.totalValue || c.awardId,
|
awardId: (c: Contract) => c.totalValue || c.awardId,
|
||||||
programmeId: (c: any) => {
|
programmeId: (c: Contract) => {
|
||||||
const a = getAwardForContract(c);
|
const a = getAwardForContract(c);
|
||||||
const pr = getProgrammeForAward(a);
|
const pr = getProgrammeForAward(a);
|
||||||
return pr?.name || '';
|
return pr?.name || '';
|
||||||
},
|
},
|
||||||
organisationId: (c: any) => {
|
organisationId: (c: Contract) => {
|
||||||
const a = getAwardForContract(c);
|
const a = getAwardForContract(c);
|
||||||
const o = getOrganisationForAward(a);
|
const o = getOrganisationForAward(a);
|
||||||
return o?.name || '';
|
return o?.name || '';
|
||||||
},
|
},
|
||||||
signedDate: (c: any) => c.signedDate,
|
signedDate: (c: Contract) => c.effectiveAt || c.createdAt,
|
||||||
status: (c: any) => getContractStatusBadge(c.status).label,
|
status: (c: Contract) => getContractStatusBadge(c.status).label,
|
||||||
fileUri: (c: any) => c.fileUri || '',
|
fileUri: (c: Contract) => c.signedDocumentUrl || '',
|
||||||
});
|
});
|
||||||
|
|
||||||
return (
|
return (
|
||||||
@@ -823,19 +826,21 @@ export const ContractsManagement: React.FC<ContractsManagementProps> = ({
|
|||||||
<span>Secure PDF</span>
|
<span>Secure PDF</span>
|
||||||
</button>
|
</button>
|
||||||
|
|
||||||
<button
|
{Number(contract.status) !== ContractStatus.Active && (
|
||||||
type="button"
|
<button
|
||||||
onClick={() => {
|
type="button"
|
||||||
setContractForFileUpload(contract);
|
onClick={() => {
|
||||||
setSelectedUploadFile(null);
|
setContractForFileUpload(contract);
|
||||||
setUploadApiError(null);
|
setSelectedUploadFile(null);
|
||||||
}}
|
setUploadApiError(null);
|
||||||
className="inline-flex items-center gap-1 px-2 py-1 text-[11px] text-slate-400 hover:text-slate-200 bg-slate-800 hover:bg-slate-700 rounded-lg border border-slate-700 transition cursor-pointer"
|
}}
|
||||||
title="Upload fresh PDF or replace existing contract document"
|
className="inline-flex items-center gap-1 px-2 py-1 text-[11px] text-slate-400 hover:text-slate-200 bg-slate-800 hover:bg-slate-700 rounded-lg border border-slate-700 transition cursor-pointer"
|
||||||
>
|
title="Upload fresh PDF or replace existing contract document"
|
||||||
<Upload className="w-3 h-3" />
|
>
|
||||||
<span>Replace</span>
|
<Upload className="w-3 h-3" />
|
||||||
</button>
|
<span>Replace</span>
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
<div className="text-[9px] text-slate-500 mt-1 flex items-center gap-1">
|
<div className="text-[9px] text-slate-500 mt-1 flex items-center gap-1">
|
||||||
<Lock className="w-2.5 h-2.5 text-slate-500" />
|
<Lock className="w-2.5 h-2.5 text-slate-500" />
|
||||||
@@ -845,18 +850,28 @@ export const ContractsManagement: React.FC<ContractsManagementProps> = ({
|
|||||||
|
|
||||||
{/* Actions */}
|
{/* Actions */}
|
||||||
<td className="py-3.5 px-4 text-right">
|
<td className="py-3.5 px-4 text-right">
|
||||||
<button
|
{Number(contract.status) === ContractStatus.Active ? (
|
||||||
type="button"
|
<span
|
||||||
onClick={() => {
|
className="inline-flex items-center gap-1 text-[11px] font-semibold text-emerald-400 bg-emerald-500/10 px-2.5 py-1 rounded-lg border border-emerald-500/20 cursor-default"
|
||||||
setContractToUpdate(contract);
|
title="Active contracts are executed and immutable."
|
||||||
setNewStatus(Number(contract.status));
|
>
|
||||||
setUpdateApiError(null);
|
<Lock className="w-3 h-3 text-emerald-400" />
|
||||||
}}
|
<span>Active (Locked)</span>
|
||||||
className="inline-flex items-center gap-1 px-2.5 py-1 text-[11px] font-medium text-slate-300 hover:text-white bg-slate-800/80 hover:bg-slate-700 rounded-lg border border-slate-700 transition cursor-pointer"
|
</span>
|
||||||
>
|
) : (
|
||||||
<Edit2 className="w-3 h-3" />
|
<button
|
||||||
<span>Status</span>
|
type="button"
|
||||||
</button>
|
onClick={() => {
|
||||||
|
setContractToUpdate(contract);
|
||||||
|
setNewStatus(Number(contract.status));
|
||||||
|
setUpdateApiError(null);
|
||||||
|
}}
|
||||||
|
className="inline-flex items-center gap-1 px-2.5 py-1 text-[11px] font-medium text-slate-300 hover:text-white bg-slate-800/80 hover:bg-slate-700 rounded-lg border border-slate-700 transition cursor-pointer"
|
||||||
|
>
|
||||||
|
<Edit2 className="w-3 h-3" />
|
||||||
|
<span>Status</span>
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
</td>
|
</td>
|
||||||
</tr>
|
</tr>
|
||||||
);
|
);
|
||||||
@@ -1229,34 +1244,41 @@ export const ContractsManagement: React.FC<ContractsManagementProps> = ({
|
|||||||
<label className="block text-xs font-medium text-slate-200 mb-1">
|
<label className="block text-xs font-medium text-slate-200 mb-1">
|
||||||
Contract Lifecycle Status <span className="text-rose-400">*</span>
|
Contract Lifecycle Status <span className="text-rose-400">*</span>
|
||||||
</label>
|
</label>
|
||||||
{(() => {
|
{Number(contractToUpdate?.status) === ContractStatus.Active ? (
|
||||||
const canApproveContract = canApproveRecord('grant_financials', contractToUpdate?.createdBy);
|
<div className="w-full bg-[#151b2c] border border-emerald-500/30 rounded-xl px-3 py-2 text-xs text-emerald-300 font-semibold flex items-center gap-1.5 cursor-default">
|
||||||
const isSelf = isMaker(contractToUpdate?.createdBy);
|
<Lock className="w-3.5 h-3.5 text-emerald-400" />
|
||||||
|
<span>Active / Executed (Immutable - Cannot Be Changed)</span>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
(() => {
|
||||||
|
const canApproveContract = canApproveRecord('grant_financials', contractToUpdate?.createdBy);
|
||||||
|
const isSelf = isMaker(contractToUpdate?.createdBy);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<select
|
<select
|
||||||
value={newStatus}
|
value={newStatus}
|
||||||
onChange={(e) => setNewStatus(Number(e.target.value))}
|
onChange={(e) => setNewStatus(Number(e.target.value))}
|
||||||
className="w-full bg-[#151b2c] border border-slate-700 rounded-xl px-3 py-2 text-xs text-white focus:outline-none focus:border-blue-500 font-medium"
|
className="w-full bg-[#151b2c] border border-slate-700 rounded-xl px-3 py-2 text-xs text-white focus:outline-none focus:border-blue-500 font-medium"
|
||||||
>
|
>
|
||||||
{CONTRACT_STATUS_OPTIONS.map((opt) => {
|
{CONTRACT_STATUS_OPTIONS.map((opt) => {
|
||||||
const isActiveOpt = opt.value === ContractStatus.Active;
|
const isActiveOpt = opt.value === ContractStatus.Active;
|
||||||
const isOptDisabled = isActiveOpt && !canApproveContract;
|
const isOptDisabled = isActiveOpt && !canApproveContract;
|
||||||
let reasonText = '';
|
let reasonText = '';
|
||||||
if (isActiveOpt && !canApprove('grant_financials')) {
|
if (isActiveOpt && !canApprove('grant_financials')) {
|
||||||
reasonText = ' (Requires Update Permission)';
|
reasonText = ' (Requires Update Permission)';
|
||||||
} else if (isActiveOpt && isSelf) {
|
} else if (isActiveOpt && isSelf) {
|
||||||
reasonText = ' (Maker-Checker: Cannot approve own record)';
|
reasonText = ' (Maker-Checker: Cannot approve own record)';
|
||||||
}
|
}
|
||||||
return (
|
return (
|
||||||
<option key={opt.value} value={opt.value} disabled={isOptDisabled}>
|
<option key={opt.value} value={opt.value} disabled={isOptDisabled}>
|
||||||
{opt.label}{reasonText}
|
{opt.label}{reasonText}
|
||||||
</option>
|
</option>
|
||||||
);
|
);
|
||||||
})}
|
})}
|
||||||
</select>
|
</select>
|
||||||
);
|
);
|
||||||
})()}
|
})()
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="flex items-center justify-end gap-2 pt-3 border-t border-slate-800">
|
<div className="flex items-center justify-end gap-2 pt-3 border-t border-slate-800">
|
||||||
@@ -1269,7 +1291,7 @@ export const ContractsManagement: React.FC<ContractsManagementProps> = ({
|
|||||||
</button>
|
</button>
|
||||||
<button
|
<button
|
||||||
type="submit"
|
type="submit"
|
||||||
disabled={updateStatusMutation.isPending}
|
disabled={updateStatusMutation.isPending || Number(contractToUpdate?.status) === ContractStatus.Active}
|
||||||
className="px-4 py-2 text-xs font-semibold text-white bg-blue-600 hover:bg-blue-500 disabled:opacity-50 rounded-xl shadow-lg shadow-blue-600/30 transition flex items-center gap-1.5 cursor-pointer"
|
className="px-4 py-2 text-xs font-semibold text-white bg-blue-600 hover:bg-blue-500 disabled:opacity-50 rounded-xl shadow-lg shadow-blue-600/30 transition flex items-center gap-1.5 cursor-pointer"
|
||||||
>
|
>
|
||||||
{updateStatusMutation.isPending ? (
|
{updateStatusMutation.isPending ? (
|
||||||
|
|||||||
@@ -24,6 +24,7 @@ import {
|
|||||||
FileSpreadsheet,
|
FileSpreadsheet,
|
||||||
FileCheck,
|
FileCheck,
|
||||||
RefreshCw,
|
RefreshCw,
|
||||||
|
Lock,
|
||||||
} from 'lucide-react';
|
} from 'lucide-react';
|
||||||
import {
|
import {
|
||||||
getAllEvidence,
|
getAllEvidence,
|
||||||
@@ -362,8 +363,8 @@ export const EvidenceReviewQueue: React.FC<EvidenceReviewQueueProps> = ({
|
|||||||
}
|
}
|
||||||
|
|
||||||
success('Download Initiated', `Opening official file: ${ev.fileName || `evidence_${ev.id}`}`);
|
success('Download Initiated', `Opening official file: ${ev.fileName || `evidence_${ev.id}`}`);
|
||||||
} catch (err: any) {
|
} catch (err: unknown) {
|
||||||
toastError('Download Failed', err?.message || 'Unable to retrieve file link');
|
toastError('Download Failed', err);
|
||||||
} finally {
|
} finally {
|
||||||
setIsDownloading(null);
|
setIsDownloading(null);
|
||||||
}
|
}
|
||||||
@@ -800,57 +801,65 @@ export const EvidenceReviewQueue: React.FC<EvidenceReviewQueueProps> = ({
|
|||||||
<span>Inspect</span>
|
<span>Inspect</span>
|
||||||
</button>
|
</button>
|
||||||
|
|
||||||
{canAdd('evidence_risks') && (
|
{ev.status === ApprovalStatus.Approved ? (
|
||||||
<button
|
<span
|
||||||
type="button"
|
className="inline-flex items-center gap-1.5 text-xs font-semibold px-3 py-1.5 rounded-xl border bg-emerald-500/15 text-emerald-300 border-emerald-500/30 cursor-default"
|
||||||
onClick={() =>
|
title="Approved evidence is immutable. Status cannot be modified."
|
||||||
reviewMutation.mutate({
|
|
||||||
id: ev.id,
|
|
||||||
status: ApprovalStatus.Rejected,
|
|
||||||
version: ev.version,
|
|
||||||
evidenceType: ev.type,
|
|
||||||
})
|
|
||||||
}
|
|
||||||
disabled={reviewMutation.isPending && String(reviewMutation.variables?.id) === String(ev.id)}
|
|
||||||
className={`inline-flex items-center gap-1 text-xs font-semibold px-2.5 py-1.5 rounded-xl border transition cursor-pointer ${
|
|
||||||
ev.status === ApprovalStatus.Rejected
|
|
||||||
? 'bg-rose-500/20 text-rose-300 border-rose-500/30 opacity-75'
|
|
||||||
: 'text-rose-400 hover:text-white bg-rose-500/10 hover:bg-rose-600 border border-rose-500/25'
|
|
||||||
}`}
|
|
||||||
title="Reject evidence submission"
|
|
||||||
>
|
>
|
||||||
<XCircle className="w-3.5 h-3.5" />
|
<Lock className="w-3.5 h-3.5 text-emerald-400" />
|
||||||
<span>{ev.status === ApprovalStatus.Rejected ? 'Rejected' : 'Reject'}</span>
|
<span>Approved (Immutable)</span>
|
||||||
</button>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{canApproveRecord('evidence_risks', ev.createdBy) && (
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
onClick={() =>
|
|
||||||
reviewMutation.mutate({
|
|
||||||
id: ev.id,
|
|
||||||
status: ApprovalStatus.Approved,
|
|
||||||
version: ev.version,
|
|
||||||
evidenceType: ev.type,
|
|
||||||
})
|
|
||||||
}
|
|
||||||
disabled={reviewMutation.isPending && String(reviewMutation.variables?.id) === String(ev.id)}
|
|
||||||
className={`inline-flex items-center gap-1 text-xs font-semibold px-2.5 py-1.5 rounded-xl border transition cursor-pointer ${
|
|
||||||
ev.status === ApprovalStatus.Approved
|
|
||||||
? 'bg-emerald-500/20 text-emerald-300 border-emerald-500/30 opacity-75'
|
|
||||||
: 'text-emerald-400 hover:text-white bg-emerald-500/10 hover:bg-emerald-600 border border-emerald-500/25'
|
|
||||||
}`}
|
|
||||||
title="Approve evidence submission"
|
|
||||||
>
|
|
||||||
<CheckCircle2 className="w-3.5 h-3.5" />
|
|
||||||
<span>{ev.status === ApprovalStatus.Approved ? 'Approved' : 'Approve'}</span>
|
|
||||||
</button>
|
|
||||||
)}
|
|
||||||
{canApprove('evidence_risks') && isMaker(ev.createdBy) && (
|
|
||||||
<span className="text-[10px] text-amber-400/90 italic font-mono px-1.5 py-0.5 bg-amber-500/10 border border-amber-500/20 rounded-md">
|
|
||||||
Maker-Checker (Creator)
|
|
||||||
</span>
|
</span>
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
{canAdd('evidence_risks') && (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() =>
|
||||||
|
reviewMutation.mutate({
|
||||||
|
id: ev.id,
|
||||||
|
status: ApprovalStatus.Rejected,
|
||||||
|
version: ev.version,
|
||||||
|
evidenceType: ev.type,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
disabled={reviewMutation.isPending && String(reviewMutation.variables?.id) === String(ev.id)}
|
||||||
|
className={`inline-flex items-center gap-1 text-xs font-semibold px-2.5 py-1.5 rounded-xl border transition cursor-pointer ${
|
||||||
|
ev.status === ApprovalStatus.Rejected
|
||||||
|
? 'bg-rose-500/20 text-rose-300 border-rose-500/30 opacity-75'
|
||||||
|
: 'text-rose-400 hover:text-white bg-rose-500/10 hover:bg-rose-600 border border-rose-500/25'
|
||||||
|
}`}
|
||||||
|
title="Reject evidence submission"
|
||||||
|
>
|
||||||
|
<XCircle className="w-3.5 h-3.5" />
|
||||||
|
<span>{ev.status === ApprovalStatus.Rejected ? 'Rejected' : 'Reject'}</span>
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{canApproveRecord('evidence_risks', ev.createdBy) && (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() =>
|
||||||
|
reviewMutation.mutate({
|
||||||
|
id: ev.id,
|
||||||
|
status: ApprovalStatus.Approved,
|
||||||
|
version: ev.version,
|
||||||
|
evidenceType: ev.type,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
disabled={reviewMutation.isPending && String(reviewMutation.variables?.id) === String(ev.id)}
|
||||||
|
className="inline-flex items-center gap-1 text-xs font-semibold px-2.5 py-1.5 rounded-xl border transition cursor-pointer text-emerald-400 hover:text-white bg-emerald-500/10 hover:bg-emerald-600 border border-emerald-500/25"
|
||||||
|
title="Approve evidence submission"
|
||||||
|
>
|
||||||
|
<CheckCircle2 className="w-3.5 h-3.5" />
|
||||||
|
<span>Approve</span>
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
{canApprove('evidence_risks') && isMaker(ev.createdBy) && (
|
||||||
|
<span className="text-[10px] text-amber-400/90 italic font-mono px-1.5 py-0.5 bg-amber-500/10 border border-amber-500/20 rounded-md">
|
||||||
|
Maker-Checker (Creator)
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
<button
|
<button
|
||||||
|
|||||||
@@ -74,7 +74,7 @@ export const ProjectActivitiesModal: React.FC<ProjectActivitiesModalProps> = ({
|
|||||||
});
|
});
|
||||||
|
|
||||||
const form = useForm<ActivityFormValues>({
|
const form = useForm<ActivityFormValues>({
|
||||||
resolver: zodResolver(activitySchema) as any,
|
resolver: zodResolver(activitySchema),
|
||||||
defaultValues: {
|
defaultValues: {
|
||||||
title: '',
|
title: '',
|
||||||
description: '',
|
description: '',
|
||||||
@@ -226,7 +226,7 @@ export const ProjectActivitiesModal: React.FC<ProjectActivitiesModalProps> = ({
|
|||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<form onSubmit={form.handleSubmit((data) => mutation.mutate(data as any))} className="space-y-3">
|
<form onSubmit={form.handleSubmit((data) => mutation.mutate(data))} className="space-y-3">
|
||||||
<div>
|
<div>
|
||||||
<label className="block text-xs font-semibold text-slate-300 mb-1">Activity Title *</label>
|
<label className="block text-xs font-semibold text-slate-300 mb-1">Activity Title *</label>
|
||||||
<input
|
<input
|
||||||
|
|||||||
@@ -47,7 +47,7 @@ export const ProjectFormModal: React.FC<ProjectFormModalProps> = ({
|
|||||||
const { success, error: toastError } = useToast();
|
const { success, error: toastError } = useToast();
|
||||||
|
|
||||||
const form = useForm<ProjectFormValues>({
|
const form = useForm<ProjectFormValues>({
|
||||||
resolver: zodResolver(projectSchema) as any,
|
resolver: zodResolver(projectSchema),
|
||||||
defaultValues: {
|
defaultValues: {
|
||||||
name: '',
|
name: '',
|
||||||
description: '',
|
description: '',
|
||||||
@@ -125,7 +125,7 @@ export const ProjectFormModal: React.FC<ProjectFormModalProps> = ({
|
|||||||
helpText="Register or modify a funded project. Ensure the correct parent programme and industrial sector are assigned to preserve statutory reporting compliance."
|
helpText="Register or modify a funded project. Ensure the correct parent programme and industrial sector are assigned to preserve statutory reporting compliance."
|
||||||
maxWidth="lg"
|
maxWidth="lg"
|
||||||
>
|
>
|
||||||
<form onSubmit={form.handleSubmit((data) => mutation.mutate(data as any))} className="space-y-4">
|
<form onSubmit={form.handleSubmit((data) => mutation.mutate(data))} className="space-y-4">
|
||||||
<div>
|
<div>
|
||||||
<div className="flex items-center gap-1 mb-1">
|
<div className="flex items-center gap-1 mb-1">
|
||||||
<label className="block text-xs font-semibold text-slate-300">Project Name *</label>
|
<label className="block text-xs font-semibold text-slate-300">Project Name *</label>
|
||||||
|
|||||||
@@ -63,7 +63,7 @@ export const ProjectIndicatorsModal: React.FC<ProjectIndicatorsModalProps> = ({
|
|||||||
});
|
});
|
||||||
|
|
||||||
const form = useForm<IndicatorFormValues>({
|
const form = useForm<IndicatorFormValues>({
|
||||||
resolver: zodResolver(indicatorSchema) as any,
|
resolver: zodResolver(indicatorSchema),
|
||||||
defaultValues: {
|
defaultValues: {
|
||||||
name: '',
|
name: '',
|
||||||
unitOfMeasure: UnitOfMeasure.Count,
|
unitOfMeasure: UnitOfMeasure.Count,
|
||||||
@@ -173,7 +173,7 @@ export const ProjectIndicatorsModal: React.FC<ProjectIndicatorsModalProps> = ({
|
|||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<form onSubmit={form.handleSubmit((data) => createMutation.mutate(data as any))} className="space-y-3">
|
<form onSubmit={form.handleSubmit((data) => createMutation.mutate(data))} className="space-y-3">
|
||||||
<div>
|
<div>
|
||||||
<label className="block text-xs font-semibold text-slate-300 mb-1">Indicator Definition *</label>
|
<label className="block text-xs font-semibold text-slate-300 mb-1">Indicator Definition *</label>
|
||||||
<input
|
<input
|
||||||
|
|||||||
@@ -15,6 +15,7 @@ import {
|
|||||||
TrendingUp,
|
TrendingUp,
|
||||||
X,
|
X,
|
||||||
RotateCcw,
|
RotateCcw,
|
||||||
|
Lock,
|
||||||
} from 'lucide-react';
|
} from 'lucide-react';
|
||||||
import { Modal } from '../ui/Modal';
|
import { Modal } from '../ui/Modal';
|
||||||
import { HelpTooltip } from '../ui/HelpTooltip';
|
import { HelpTooltip } from '../ui/HelpTooltip';
|
||||||
@@ -74,7 +75,7 @@ export const ProjectMilestonesModal: React.FC<ProjectMilestonesModalProps> = ({
|
|||||||
});
|
});
|
||||||
|
|
||||||
const form = useForm<MilestoneFormValues>({
|
const form = useForm<MilestoneFormValues>({
|
||||||
resolver: zodResolver(milestoneSchema) as any,
|
resolver: zodResolver(milestoneSchema),
|
||||||
defaultValues: {
|
defaultValues: {
|
||||||
name: '',
|
name: '',
|
||||||
dueAt: new Date(Date.now() + 60 * 24 * 60 * 60 * 1000).toISOString().slice(0, 10),
|
dueAt: new Date(Date.now() + 60 * 24 * 60 * 60 * 1000).toISOString().slice(0, 10),
|
||||||
@@ -210,7 +211,7 @@ export const ProjectMilestonesModal: React.FC<ProjectMilestonesModalProps> = ({
|
|||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<form onSubmit={form.handleSubmit((data) => mutation.mutate(data as any))} className="space-y-3">
|
<form onSubmit={form.handleSubmit((data) => mutation.mutate(data))} className="space-y-3">
|
||||||
<div>
|
<div>
|
||||||
<label className="block text-xs font-semibold text-slate-300 mb-1">
|
<label className="block text-xs font-semibold text-slate-300 mb-1">
|
||||||
Deliverable Name / Milestone Title *
|
Deliverable Name / Milestone Title *
|
||||||
@@ -257,33 +258,40 @@ export const ProjectMilestonesModal: React.FC<ProjectMilestonesModalProps> = ({
|
|||||||
{editingMilestone && (
|
{editingMilestone && (
|
||||||
<div>
|
<div>
|
||||||
<label className="block text-xs font-semibold text-slate-300 mb-1">Approval / Verification Status</label>
|
<label className="block text-xs font-semibold text-slate-300 mb-1">Approval / Verification Status</label>
|
||||||
{(() => {
|
{Number(editingMilestone.status) === ApprovalStatus.Approved ? (
|
||||||
const canApproveMilestone = canApproveRecord('projects_milestones', editingMilestone.createdBy);
|
<div className="w-full px-3 py-2 text-xs border border-emerald-500/30 bg-emerald-500/10 rounded-xl text-emerald-300 font-semibold flex items-center gap-1.5 cursor-default">
|
||||||
const isSelf = isMaker(editingMilestone.createdBy);
|
<Lock className="w-3.5 h-3.5 text-emerald-400" />
|
||||||
|
<span>Approved & Verified (Immutable)</span>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
(() => {
|
||||||
|
const canApproveMilestone = canApproveRecord('projects_milestones', editingMilestone.createdBy);
|
||||||
|
const isSelf = isMaker(editingMilestone.createdBy);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<select
|
<select
|
||||||
{...form.register('status')}
|
{...form.register('status')}
|
||||||
className="w-full px-3 py-2 text-xs border border-slate-700/80 rounded-xl focus:outline-none focus:ring-1 focus:ring-blue-500 bg-[#090d16] text-white font-medium"
|
className="w-full px-3 py-2 text-xs border border-slate-700/80 rounded-xl focus:outline-none focus:ring-1 focus:ring-blue-500 bg-[#090d16] text-white font-medium"
|
||||||
>
|
>
|
||||||
{APPROVAL_STATUS_OPTIONS.map((opt) => {
|
{APPROVAL_STATUS_OPTIONS.map((opt) => {
|
||||||
const isApprovedOpt = opt.value === ApprovalStatus.Approved;
|
const isApprovedOpt = opt.value === ApprovalStatus.Approved;
|
||||||
const isOptDisabled = isApprovedOpt && !canApproveMilestone;
|
const isOptDisabled = isApprovedOpt && !canApproveMilestone;
|
||||||
let reasonText = '';
|
let reasonText = '';
|
||||||
if (isApprovedOpt && !canApprove('projects_milestones')) {
|
if (isApprovedOpt && !canApprove('projects_milestones')) {
|
||||||
reasonText = ' (Requires Update Permission)';
|
reasonText = ' (Requires Update Permission)';
|
||||||
} else if (isApprovedOpt && isSelf) {
|
} else if (isApprovedOpt && isSelf) {
|
||||||
reasonText = ' (Maker-Checker: Cannot approve own record)';
|
reasonText = ' (Maker-Checker: Cannot approve own record)';
|
||||||
}
|
}
|
||||||
return (
|
return (
|
||||||
<option key={opt.value} value={opt.value} disabled={isOptDisabled}>
|
<option key={opt.value} value={opt.value} disabled={isOptDisabled}>
|
||||||
{opt.label}{reasonText}
|
{opt.label}{reasonText}
|
||||||
</option>
|
</option>
|
||||||
);
|
);
|
||||||
})}
|
})}
|
||||||
</select>
|
</select>
|
||||||
);
|
);
|
||||||
})()}
|
})()
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
|||||||
@@ -67,7 +67,7 @@ export const ProjectRisksModal: React.FC<ProjectRisksModalProps> = ({
|
|||||||
});
|
});
|
||||||
|
|
||||||
const form = useForm<RiskFormValues>({
|
const form = useForm<RiskFormValues>({
|
||||||
resolver: zodResolver(riskSchema) as any,
|
resolver: zodResolver(riskSchema),
|
||||||
defaultValues: {
|
defaultValues: {
|
||||||
name: '',
|
name: '',
|
||||||
description: '',
|
description: '',
|
||||||
@@ -202,7 +202,7 @@ export const ProjectRisksModal: React.FC<ProjectRisksModalProps> = ({
|
|||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<form onSubmit={form.handleSubmit((data) => mutation.mutate(data as any))} className="space-y-3">
|
<form onSubmit={form.handleSubmit((data) => mutation.mutate(data))} className="space-y-3">
|
||||||
<div>
|
<div>
|
||||||
<label className="block text-xs font-semibold text-slate-300 mb-1">Risk Hazard Title *</label>
|
<label className="block text-xs font-semibold text-slate-300 mb-1">Risk Hazard Title *</label>
|
||||||
<input
|
<input
|
||||||
|
|||||||
@@ -21,6 +21,7 @@ import {
|
|||||||
Sparkles,
|
Sparkles,
|
||||||
ExternalLink,
|
ExternalLink,
|
||||||
SlidersHorizontal,
|
SlidersHorizontal,
|
||||||
|
Lock,
|
||||||
} from 'lucide-react';
|
} from 'lucide-react';
|
||||||
import { Modal } from '../ui/Modal';
|
import { Modal } from '../ui/Modal';
|
||||||
import { HelpTooltip } from '../ui/HelpTooltip';
|
import { HelpTooltip } from '../ui/HelpTooltip';
|
||||||
@@ -282,36 +283,46 @@ export const ProjectViewModal: React.FC<ProjectViewModalProps> = ({
|
|||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{/* Real-time Governance Status Dropdown */}
|
{/* Real-time Governance Status Dropdown / Immutable Locked State */}
|
||||||
<div className="w-36">
|
<div className="min-w-[140px]">
|
||||||
<select
|
{Number(project.status) === ApprovalStatus.Approved ? (
|
||||||
value={project.status}
|
<div
|
||||||
disabled={!canModifyStatus}
|
className="inline-flex items-center gap-1.5 px-3 py-2 rounded-xl text-xs font-bold bg-emerald-500/15 border border-emerald-500/30 text-emerald-300 shadow-sm cursor-default"
|
||||||
onChange={(e) =>
|
title="Approved records are immutable. Status cannot be modified."
|
||||||
statusMutation.mutate({
|
>
|
||||||
projectId: project.id,
|
<Lock className="w-3.5 h-3.5 text-emerald-400" />
|
||||||
status: Number(e.target.value) as ApprovalStatus,
|
<span>Approved (Locked)</span>
|
||||||
})
|
</div>
|
||||||
}
|
) : (
|
||||||
className="w-full text-xs font-semibold bg-[#090d16] border border-slate-700/90 rounded-xl px-2.5 py-2 text-slate-200 focus:outline-none focus:ring-2 focus:ring-blue-500 cursor-pointer disabled:opacity-60 disabled:cursor-not-allowed truncate shadow-md"
|
<select
|
||||||
title="Project Governance Clearance Status"
|
value={project.status}
|
||||||
>
|
disabled={!canModifyStatus}
|
||||||
{APPROVAL_STATUS_OPTIONS.map((opt) => {
|
onChange={(e) =>
|
||||||
const isApprovedOpt = opt.value === ApprovalStatus.Approved;
|
statusMutation.mutate({
|
||||||
const isOptDisabled = isApprovedOpt && !canApproveThis;
|
projectId: project.id,
|
||||||
let reasonText = '';
|
status: Number(e.target.value) as ApprovalStatus,
|
||||||
if (isApprovedOpt && !canApprove('projects_milestones')) {
|
})
|
||||||
reasonText = ' (Requires Update)';
|
|
||||||
} else if (isApprovedOpt && isSelf) {
|
|
||||||
reasonText = ' (Maker-Checker)';
|
|
||||||
}
|
}
|
||||||
return (
|
className="w-full text-xs font-semibold bg-[#090d16] border border-slate-700/90 rounded-xl px-2.5 py-2 text-slate-200 focus:outline-none focus:ring-2 focus:ring-blue-500 cursor-pointer disabled:opacity-60 disabled:cursor-not-allowed truncate shadow-md"
|
||||||
<option key={opt.value} value={opt.value} disabled={isOptDisabled}>
|
title="Project Governance Clearance Status"
|
||||||
{opt.label}{reasonText}
|
>
|
||||||
</option>
|
{APPROVAL_STATUS_OPTIONS.map((opt) => {
|
||||||
);
|
const isApprovedOpt = opt.value === ApprovalStatus.Approved;
|
||||||
})}
|
const isOptDisabled = isApprovedOpt && !canApproveThis;
|
||||||
</select>
|
let reasonText = '';
|
||||||
|
if (isApprovedOpt && !canApprove('projects_milestones')) {
|
||||||
|
reasonText = ' (Requires Update)';
|
||||||
|
} else if (isApprovedOpt && isSelf) {
|
||||||
|
reasonText = ' (Maker-Checker)';
|
||||||
|
}
|
||||||
|
return (
|
||||||
|
<option key={opt.value} value={opt.value} disabled={isOptDisabled}>
|
||||||
|
{opt.label}{reasonText}
|
||||||
|
</option>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</select>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Action Buttons */}
|
{/* Action Buttons */}
|
||||||
|
|||||||
@@ -99,7 +99,7 @@ export class ErrorBoundary extends React.Component<Props, State> {
|
|||||||
}
|
}
|
||||||
|
|
||||||
public handleReset = () => {
|
public handleReset = () => {
|
||||||
(this as any).setState({ hasError: false, error: null });
|
this.setState({ hasError: false, error: null });
|
||||||
};
|
};
|
||||||
|
|
||||||
public render() {
|
public render() {
|
||||||
|
|||||||
@@ -81,8 +81,8 @@ export function ExportButton<T>({
|
|||||||
filename,
|
filename,
|
||||||
columns,
|
columns,
|
||||||
data,
|
data,
|
||||||
tenantName: (claims as any)?.tenant_name || (claims as any)?.tenant || `Tenant #${tenantId || 1}`,
|
tenantName: (claims?.tenant_name as string) || (claims?.tenant as string) || `Tenant #${tenantId || 1}`,
|
||||||
userEmail: (user as any)?.profile?.email || user?.email || (claims as any)?.email || '',
|
userEmail: user?.email || (claims?.email as string) || '',
|
||||||
});
|
});
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -95,8 +95,8 @@ export function ExportButton<T>({
|
|||||||
filename,
|
filename,
|
||||||
columns,
|
columns,
|
||||||
data,
|
data,
|
||||||
tenantName: (claims as any)?.tenant_name || (claims as any)?.tenant || `Tenant #${tenantId || 1}`,
|
tenantName: (claims?.tenant_name as string) || (claims?.tenant as string) || `Tenant #${tenantId || 1}`,
|
||||||
userEmail: (user as any)?.profile?.email || user?.email || (claims as any)?.email || '',
|
userEmail: user?.email || (claims?.email as string) || '',
|
||||||
});
|
});
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
@@ -165,7 +165,7 @@ export const CreateUserModal: React.FC<CreateUserModalProps> = ({
|
|||||||
queryClient.invalidateQueries({ queryKey: ['users-directory'] });
|
queryClient.invalidateQueries({ queryKey: ['users-directory'] });
|
||||||
onClose();
|
onClose();
|
||||||
},
|
},
|
||||||
onError: (err: any) => {
|
onError: (err: unknown) => {
|
||||||
toastError('User Creation Failed', err);
|
toastError('User Creation Failed', err);
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -117,8 +117,8 @@ export const UserSecurityModal: React.FC<UserSecurityModalProps> = ({
|
|||||||
return null;
|
return null;
|
||||||
}, [userRoles, userRoleStr, systemRoles]);
|
}, [userRoles, userRoleStr, systemRoles]);
|
||||||
|
|
||||||
const assignedRoleId = assignedRole?.id || (assignedRole as any)?.Id || '';
|
const assignedRoleId = assignedRole?.id || assignedRole?.Id || '';
|
||||||
const assignedRoleName = assignedRole?.name || (assignedRole as any)?.Name || '';
|
const assignedRoleName = assignedRole?.name || assignedRole?.Name || '';
|
||||||
|
|
||||||
// 4. Fetch claims derived from the user's role by hitting the endpoint
|
// 4. Fetch claims derived from the user's role by hitting the endpoint
|
||||||
const {
|
const {
|
||||||
@@ -197,9 +197,9 @@ export const UserSecurityModal: React.FC<UserSecurityModalProps> = ({
|
|||||||
throw new Error('Single-role policy: This user already has an assigned role. Please remove the existing role before assigning a new one.');
|
throw new Error('Single-role policy: This user already has an assigned role. Please remove the existing role before assigning a new one.');
|
||||||
}
|
}
|
||||||
const selectedRole = (Array.isArray(systemRoles) ? systemRoles : []).find(
|
const selectedRole = (Array.isArray(systemRoles) ? systemRoles : []).find(
|
||||||
(r) => r && (String(r.id) === String(roleId) || String((r as any).Id) === String(roleId))
|
(r) => r && (String(r.id) === String(roleId) || String(r.Id) === String(roleId))
|
||||||
);
|
);
|
||||||
const roleName = selectedRole?.name || (selectedRole as any)?.Name || '';
|
const roleName = selectedRole?.name || selectedRole?.Name || '';
|
||||||
if (selectedRole && (roleName.toLowerCase() === 'admin' || String(selectedRole.id) === '1') && hasTenantSelfHelpAccess) {
|
if (selectedRole && (roleName.toLowerCase() === 'admin' || String(selectedRole.id) === '1') && hasTenantSelfHelpAccess) {
|
||||||
throw new Error('Security policy: Users with tenant self-help access cannot assign Admin roles.');
|
throw new Error('Security policy: Users with tenant self-help access cannot assign Admin roles.');
|
||||||
}
|
}
|
||||||
@@ -214,7 +214,7 @@ export const UserSecurityModal: React.FC<UserSecurityModalProps> = ({
|
|||||||
queryClient.invalidateQueries({ queryKey: ['users'] });
|
queryClient.invalidateQueries({ queryKey: ['users'] });
|
||||||
setSelectedRoleToAdd('');
|
setSelectedRoleToAdd('');
|
||||||
},
|
},
|
||||||
onError: (err: any) => toastError('Role Assignment Failed', err),
|
onError: (err: unknown) => toastError('Role Assignment Failed', err),
|
||||||
});
|
});
|
||||||
|
|
||||||
const removeRoleMutation = useMutation({
|
const removeRoleMutation = useMutation({
|
||||||
@@ -227,14 +227,14 @@ export const UserSecurityModal: React.FC<UserSecurityModalProps> = ({
|
|||||||
refetchRoleClaims();
|
refetchRoleClaims();
|
||||||
queryClient.invalidateQueries({ queryKey: ['users'] });
|
queryClient.invalidateQueries({ queryKey: ['users'] });
|
||||||
},
|
},
|
||||||
onError: (err: any) => toastError('Role Removal Failed', err),
|
onError: (err: unknown) => toastError('Role Removal Failed', err),
|
||||||
});
|
});
|
||||||
|
|
||||||
// Session & Lockout Mutations
|
// Session & Lockout Mutations
|
||||||
const logoutMutation = useMutation({
|
const logoutMutation = useMutation({
|
||||||
mutationFn: () => forceLogoutUser(userId),
|
mutationFn: () => forceLogoutUser(userId),
|
||||||
onSuccess: () => success('Session Terminated', 'Target user session revoked. Re-authentication required.'),
|
onSuccess: () => success('Session Terminated', 'Target user session revoked. Re-authentication required.'),
|
||||||
onError: (err: any) => toastError('Logout Failed', err),
|
onError: (err: unknown) => toastError('Logout Failed', err),
|
||||||
});
|
});
|
||||||
|
|
||||||
const lockoutMutation = useMutation({
|
const lockoutMutation = useMutation({
|
||||||
@@ -246,7 +246,7 @@ export const UserSecurityModal: React.FC<UserSecurityModalProps> = ({
|
|||||||
);
|
);
|
||||||
refetchUser();
|
refetchUser();
|
||||||
},
|
},
|
||||||
onError: (err: any) => toastError('Lockout Update Failed', err),
|
onError: (err: unknown) => toastError('Lockout Update Failed', err),
|
||||||
});
|
});
|
||||||
|
|
||||||
const statusMutation = useMutation({
|
const statusMutation = useMutation({
|
||||||
@@ -256,14 +256,14 @@ export const UserSecurityModal: React.FC<UserSecurityModalProps> = ({
|
|||||||
refetchUser();
|
refetchUser();
|
||||||
queryClient.invalidateQueries({ queryKey: ['users'] });
|
queryClient.invalidateQueries({ queryKey: ['users'] });
|
||||||
},
|
},
|
||||||
onError: (err: any) => toastError('Status Update Failed', err),
|
onError: (err: unknown) => toastError('Status Update Failed', err),
|
||||||
});
|
});
|
||||||
|
|
||||||
const availableRoles = React.useMemo(() => {
|
const availableRoles = React.useMemo(() => {
|
||||||
return (Array.isArray(systemRoles) ? systemRoles : []).filter((r) => {
|
return (Array.isArray(systemRoles) ? systemRoles : []).filter((r) => {
|
||||||
if (!r) return false;
|
if (!r) return false;
|
||||||
const rId = r.id || (r as any).Id;
|
const rId = r.id || r.Id;
|
||||||
const rName = r.name || (r as any).Name || '';
|
const rName = r.name || r.Name || '';
|
||||||
if (hasTenantSelfHelpAccess && (rName.toLowerCase() === 'admin' || String(rId) === '1')) {
|
if (hasTenantSelfHelpAccess && (rName.toLowerCase() === 'admin' || String(rId) === '1')) {
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
@@ -446,7 +446,7 @@ export const UserSecurityModal: React.FC<UserSecurityModalProps> = ({
|
|||||||
<option value="">
|
<option value="">
|
||||||
{availableRoles.length === 0 ? 'No available roles' : 'Select role to assign...'}
|
{availableRoles.length === 0 ? 'No available roles' : 'Select role to assign...'}
|
||||||
</option>
|
</option>
|
||||||
{availableRoles.map((r: any) => {
|
{availableRoles.map((r) => {
|
||||||
const roleOptId = r?.id || r?.Id || String(r);
|
const roleOptId = r?.id || r?.Id || String(r);
|
||||||
const roleOptName = r?.name || r?.Name || String(r);
|
const roleOptName = r?.name || r?.Name || String(r);
|
||||||
return (
|
return (
|
||||||
@@ -485,8 +485,8 @@ export const UserSecurityModal: React.FC<UserSecurityModalProps> = ({
|
|||||||
) : (
|
) : (
|
||||||
(Array.isArray(userRoles) ? userRoles : []).map((role) => {
|
(Array.isArray(userRoles) ? userRoles : []).map((role) => {
|
||||||
if (!role) return null;
|
if (!role) return null;
|
||||||
const roleId = role.id || (role as any).Id || String(role);
|
const roleId = role.id || role.Id || String(role);
|
||||||
const roleName = role.name || (role as any).Name || String(role);
|
const roleName = role.name || role.Name || String(role);
|
||||||
return (
|
return (
|
||||||
<div key={roleId} className="p-3.5 flex items-center justify-between gap-3">
|
<div key={roleId} className="p-3.5 flex items-center justify-between gap-3">
|
||||||
<div className="min-w-0">
|
<div className="min-w-0">
|
||||||
|
|||||||
@@ -20,6 +20,7 @@ import {
|
|||||||
Sparkles,
|
Sparkles,
|
||||||
MapPin,
|
MapPin,
|
||||||
FileCheck,
|
FileCheck,
|
||||||
|
Lock,
|
||||||
} from 'lucide-react';
|
} from 'lucide-react';
|
||||||
import {
|
import {
|
||||||
getProjects,
|
getProjects,
|
||||||
@@ -194,7 +195,7 @@ export const BeneficiariesPage: React.FC = () => {
|
|||||||
benForm.reset();
|
benForm.reset();
|
||||||
success('Beneficiary Registered', 'New project beneficiary profile successfully created.');
|
success('Beneficiary Registered', 'New project beneficiary profile successfully created.');
|
||||||
},
|
},
|
||||||
onError: (err: any) => toastError('Registration Failed', err),
|
onError: (err: unknown) => toastError('Registration Failed', err),
|
||||||
});
|
});
|
||||||
|
|
||||||
const updateBenMutation = useMutation({
|
const updateBenMutation = useMutation({
|
||||||
@@ -216,7 +217,7 @@ export const BeneficiariesPage: React.FC = () => {
|
|||||||
benForm.reset();
|
benForm.reset();
|
||||||
success('Beneficiary Updated', 'Beneficiary details and targets updated.');
|
success('Beneficiary Updated', 'Beneficiary details and targets updated.');
|
||||||
},
|
},
|
||||||
onError: (err: any) => toastError('Update Failed', err),
|
onError: (err: unknown) => toastError('Update Failed', err),
|
||||||
});
|
});
|
||||||
|
|
||||||
const createCompMutation = useMutation({
|
const createCompMutation = useMutation({
|
||||||
@@ -231,7 +232,7 @@ export const BeneficiariesPage: React.FC = () => {
|
|||||||
compForm.reset();
|
compForm.reset();
|
||||||
success('Compliance Requirement Logged', 'Compliance item registered for project beneficiaries.');
|
success('Compliance Requirement Logged', 'Compliance item registered for project beneficiaries.');
|
||||||
},
|
},
|
||||||
onError: (err: any) => toastError('Creation Failed', err),
|
onError: (err: unknown) => toastError('Creation Failed', err),
|
||||||
});
|
});
|
||||||
|
|
||||||
const updateCompMutation = useMutation({
|
const updateCompMutation = useMutation({
|
||||||
@@ -251,7 +252,7 @@ export const BeneficiariesPage: React.FC = () => {
|
|||||||
compForm.reset();
|
compForm.reset();
|
||||||
success('Compliance Item Updated', 'Status and requirement record updated.');
|
success('Compliance Item Updated', 'Status and requirement record updated.');
|
||||||
},
|
},
|
||||||
onError: (err: any) => toastError('Update Failed', err),
|
onError: (err: unknown) => toastError('Update Failed', err),
|
||||||
});
|
});
|
||||||
|
|
||||||
const createOutcomeMutation = useMutation({
|
const createOutcomeMutation = useMutation({
|
||||||
@@ -265,7 +266,7 @@ export const BeneficiariesPage: React.FC = () => {
|
|||||||
outcomeForm.reset();
|
outcomeForm.reset();
|
||||||
success('Outcome Logged', 'Beneficiary outcome and impact metric registered.');
|
success('Outcome Logged', 'Beneficiary outcome and impact metric registered.');
|
||||||
},
|
},
|
||||||
onError: (err: any) => toastError('Failed to Record Outcome', err),
|
onError: (err: unknown) => toastError('Failed to Record Outcome', err),
|
||||||
});
|
});
|
||||||
|
|
||||||
// Filtered & Sorted Beneficiaries
|
// Filtered & Sorted Beneficiaries
|
||||||
@@ -1020,30 +1021,40 @@ export const BeneficiariesPage: React.FC = () => {
|
|||||||
{editingComp && (
|
{editingComp && (
|
||||||
<div>
|
<div>
|
||||||
<label className="block text-xs font-semibold text-slate-300 mb-1">Verification Status</label>
|
<label className="block text-xs font-semibold text-slate-300 mb-1">Verification Status</label>
|
||||||
<select
|
{Number(editingComp.status) === ApprovalStatus.Approved ? (
|
||||||
{...compForm.register('status')}
|
<div
|
||||||
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"
|
className="w-full bg-slate-900 border border-emerald-500/30 rounded-xl px-3 py-2 text-xs text-emerald-300 font-semibold flex items-center gap-1.5 cursor-default"
|
||||||
>
|
title="Approved compliance records are immutable."
|
||||||
{(() => {
|
>
|
||||||
const canApproveComp = canApproveRecord('beneficiaries', editingComp?.createdBy);
|
<Lock className="w-3.5 h-3.5 text-emerald-400" />
|
||||||
const isSelf = isMaker(editingComp?.createdBy);
|
<span>Approved (Immutable - Cannot Be Changed)</span>
|
||||||
return APPROVAL_STATUS_OPTIONS.map((opt) => {
|
</div>
|
||||||
const isApprovedOpt = opt.value === ApprovalStatus.Approved;
|
) : (
|
||||||
const isOptDisabled = isApprovedOpt && !canApproveComp;
|
<select
|
||||||
let reason = '';
|
{...compForm.register('status')}
|
||||||
if (isApprovedOpt && !canApprove('beneficiaries')) {
|
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"
|
||||||
reason = ' (Requires Update Permission)';
|
>
|
||||||
} else if (isApprovedOpt && isSelf) {
|
{(() => {
|
||||||
reason = ' (Maker-Checker: Cannot approve own record)';
|
const canApproveComp = canApproveRecord('beneficiaries', editingComp?.createdBy);
|
||||||
}
|
const isSelf = isMaker(editingComp?.createdBy);
|
||||||
return (
|
return APPROVAL_STATUS_OPTIONS.map((opt) => {
|
||||||
<option key={opt.value} value={opt.value} disabled={isOptDisabled}>
|
const isApprovedOpt = opt.value === ApprovalStatus.Approved;
|
||||||
{opt.label}{reason}
|
const isOptDisabled = isApprovedOpt && !canApproveComp;
|
||||||
</option>
|
let reason = '';
|
||||||
);
|
if (isApprovedOpt && !canApprove('beneficiaries')) {
|
||||||
});
|
reason = ' (Requires Update Permission)';
|
||||||
})()}
|
} else if (isApprovedOpt && isSelf) {
|
||||||
</select>
|
reason = ' (Maker-Checker: Cannot approve own record)';
|
||||||
|
}
|
||||||
|
return (
|
||||||
|
<option key={opt.value} value={opt.value} disabled={isOptDisabled}>
|
||||||
|
{opt.label}{reason}
|
||||||
|
</option>
|
||||||
|
);
|
||||||
|
});
|
||||||
|
})()}
|
||||||
|
</select>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
|||||||
@@ -20,6 +20,7 @@ import {
|
|||||||
User,
|
User,
|
||||||
Clock,
|
Clock,
|
||||||
ArrowRight,
|
ArrowRight,
|
||||||
|
Lock,
|
||||||
} from 'lucide-react';
|
} from 'lucide-react';
|
||||||
import {
|
import {
|
||||||
getProjects,
|
getProjects,
|
||||||
@@ -238,7 +239,7 @@ export const ChangeManagementPage: React.FC = () => {
|
|||||||
issueForm.reset();
|
issueForm.reset();
|
||||||
success('Issue Logged', 'New project issue created and assigned.');
|
success('Issue Logged', 'New project issue created and assigned.');
|
||||||
},
|
},
|
||||||
onError: (err: any) => toastError('Creation Failed', err),
|
onError: (err: unknown) => toastError('Creation Failed', err),
|
||||||
});
|
});
|
||||||
|
|
||||||
const updateIssueMutation = useMutation({
|
const updateIssueMutation = useMutation({
|
||||||
@@ -273,7 +274,7 @@ export const ChangeManagementPage: React.FC = () => {
|
|||||||
editIssueForm.reset();
|
editIssueForm.reset();
|
||||||
success('Issue Updated', 'Issue record and resolution status saved successfully.');
|
success('Issue Updated', 'Issue record and resolution status saved successfully.');
|
||||||
},
|
},
|
||||||
onError: (err: any) => toastError('Update Failed', err),
|
onError: (err: unknown) => toastError('Update Failed', err),
|
||||||
});
|
});
|
||||||
|
|
||||||
const resolveIssueMutation = useMutation({
|
const resolveIssueMutation = useMutation({
|
||||||
@@ -297,7 +298,7 @@ export const ChangeManagementPage: React.FC = () => {
|
|||||||
resolveIssueForm.reset();
|
resolveIssueForm.reset();
|
||||||
success('Issue Resolved', 'Resolution recorded and issue status updated.');
|
success('Issue Resolved', 'Resolution recorded and issue status updated.');
|
||||||
},
|
},
|
||||||
onError: (err: any) => toastError('Resolution Failed', err),
|
onError: (err: unknown) => toastError('Resolution Failed', err),
|
||||||
});
|
});
|
||||||
|
|
||||||
const createChangeMutation = useMutation({
|
const createChangeMutation = useMutation({
|
||||||
@@ -317,7 +318,7 @@ export const ChangeManagementPage: React.FC = () => {
|
|||||||
changeForm.reset();
|
changeForm.reset();
|
||||||
success('Change Request Submitted', 'Formal change request submitted for review.');
|
success('Change Request Submitted', 'Formal change request submitted for review.');
|
||||||
},
|
},
|
||||||
onError: (err: any) => toastError('Submission Failed', err),
|
onError: (err: unknown) => toastError('Submission Failed', err),
|
||||||
});
|
});
|
||||||
|
|
||||||
const resolveChangeMutation = useMutation({
|
const resolveChangeMutation = useMutation({
|
||||||
@@ -338,12 +339,15 @@ export const ChangeManagementPage: React.FC = () => {
|
|||||||
resolveChangeForm.reset();
|
resolveChangeForm.reset();
|
||||||
success('Change Decision Applied', 'Change request decision successfully resolved.');
|
success('Change Decision Applied', 'Change request decision successfully resolved.');
|
||||||
},
|
},
|
||||||
onError: (err: any) => toastError('Decision Failed', err),
|
onError: (err: unknown) => toastError('Decision Failed', err),
|
||||||
});
|
});
|
||||||
|
|
||||||
const updateChangeMutation = useMutation({
|
const updateChangeMutation = useMutation({
|
||||||
mutationFn: (vals: EditChangeFormValues) => {
|
mutationFn: (vals: EditChangeFormValues) => {
|
||||||
if (!editingChange) throw new Error('No change request selected');
|
if (!editingChange) throw new Error('No change request selected');
|
||||||
|
if (Number(editingChange.status) === ApprovalStatus.Approved) {
|
||||||
|
throw new Error('Approved change requests are immutable and cannot be updated.');
|
||||||
|
}
|
||||||
return updateChangeRequest({
|
return updateChangeRequest({
|
||||||
changeRequestId: editingChange.id,
|
changeRequestId: editingChange.id,
|
||||||
title: vals.title,
|
title: vals.title,
|
||||||
@@ -359,7 +363,7 @@ export const ChangeManagementPage: React.FC = () => {
|
|||||||
editChangeForm.reset();
|
editChangeForm.reset();
|
||||||
success('Change Request Updated', 'Change proposal and governance status saved.');
|
success('Change Request Updated', 'Change proposal and governance status saved.');
|
||||||
},
|
},
|
||||||
onError: (err: any) => toastError('Update Failed', err),
|
onError: (err: unknown) => toastError('Update Failed', err),
|
||||||
});
|
});
|
||||||
|
|
||||||
// Filtered & Sorted Issues
|
// Filtered & Sorted Issues
|
||||||
@@ -481,6 +485,10 @@ export const ChangeManagementPage: React.FC = () => {
|
|||||||
};
|
};
|
||||||
|
|
||||||
const openEditChange = async (cr: ChangeRequest) => {
|
const openEditChange = async (cr: ChangeRequest) => {
|
||||||
|
if (Number(cr.status) === ApprovalStatus.Approved) {
|
||||||
|
toastError('Record Immutable', 'Approved and enacted change requests are immutable.');
|
||||||
|
return;
|
||||||
|
}
|
||||||
try {
|
try {
|
||||||
// 1. Uses GET method (NOT POST)
|
// 1. Uses GET method (NOT POST)
|
||||||
// 2. Passes ChangeRequestId (NOT ProjectId)
|
// 2. Passes ChangeRequestId (NOT ProjectId)
|
||||||
@@ -1009,19 +1017,20 @@ export const ChangeManagementPage: React.FC = () => {
|
|||||||
</td>
|
</td>
|
||||||
<td className="py-3.5 px-5 text-right whitespace-nowrap space-x-2">
|
<td className="py-3.5 px-5 text-right whitespace-nowrap space-x-2">
|
||||||
<div className="inline-flex items-center gap-1.5">
|
<div className="inline-flex items-center gap-1.5">
|
||||||
<button
|
{Number(cr.status) === ApprovalStatus.Approved ? (
|
||||||
onClick={() => openEditChange(cr)}
|
<span className="inline-flex items-center gap-1 text-xs font-semibold text-emerald-400 bg-emerald-500/10 px-2.5 py-1 rounded-lg border border-emerald-500/20 cursor-default" title="Approved and enacted change requests are immutable.">
|
||||||
className="p-1 text-slate-400 hover:text-white hover:bg-slate-800 rounded-lg transition cursor-pointer"
|
<Lock className="w-3 h-3 text-emerald-400" />
|
||||||
title="Edit Change Request"
|
<span>Resolved & Enacted (Immutable)</span>
|
||||||
>
|
|
||||||
<Edit2 className="w-3.5 h-3.5" />
|
|
||||||
</button>
|
|
||||||
{cr.status === ApprovalStatus.Approved ? (
|
|
||||||
<span className="text-xs font-semibold text-emerald-400 bg-emerald-500/10 px-2.5 py-1 rounded-lg border border-emerald-500/20">
|
|
||||||
Resolved & Enacted
|
|
||||||
</span>
|
</span>
|
||||||
) : (
|
) : (
|
||||||
<>
|
<>
|
||||||
|
<button
|
||||||
|
onClick={() => openEditChange(cr)}
|
||||||
|
className="p-1 text-slate-400 hover:text-white hover:bg-slate-800 rounded-lg transition cursor-pointer"
|
||||||
|
title="Edit Change Request"
|
||||||
|
>
|
||||||
|
<Edit2 className="w-3.5 h-3.5" />
|
||||||
|
</button>
|
||||||
{canApproveRecord('change_management', cr.createdBy) && (
|
{canApproveRecord('change_management', cr.createdBy) && (
|
||||||
<button
|
<button
|
||||||
onClick={() => openResolveChange(cr, ApprovalStatus.Approved)}
|
onClick={() => openResolveChange(cr, ApprovalStatus.Approved)}
|
||||||
@@ -1035,14 +1044,6 @@ export const ChangeManagementPage: React.FC = () => {
|
|||||||
Maker-Checker
|
Maker-Checker
|
||||||
</span>
|
</span>
|
||||||
)}
|
)}
|
||||||
{canAdd('change_management') && (
|
|
||||||
<button
|
|
||||||
onClick={() => openResolveChange(cr, ApprovalStatus.Rejected)}
|
|
||||||
className="px-2.5 py-1 text-xs font-semibold text-rose-300 bg-rose-950/50 hover:bg-rose-900/60 border border-rose-700/50 rounded-lg transition cursor-pointer flex items-center gap-1"
|
|
||||||
>
|
|
||||||
<XCircle className="w-3 h-3" /> Reject
|
|
||||||
</button>
|
|
||||||
)}
|
|
||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
@@ -1521,29 +1522,39 @@ export const ChangeManagementPage: React.FC = () => {
|
|||||||
|
|
||||||
<div>
|
<div>
|
||||||
<label className="block text-xs font-semibold text-slate-300 mb-1">Governance Status *</label>
|
<label className="block text-xs font-semibold text-slate-300 mb-1">Governance Status *</label>
|
||||||
<select
|
{editingChange && Number(editingChange.status) === ApprovalStatus.Approved ? (
|
||||||
{...editChangeForm.register('status')}
|
<div
|
||||||
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-amber-500"
|
className="w-full bg-slate-900 border border-emerald-500/30 rounded-xl px-3 py-2 text-xs text-emerald-300 font-semibold flex items-center gap-1.5 cursor-default"
|
||||||
>
|
title="Approved and enacted change requests are immutable."
|
||||||
<option value={ApprovalStatus.Pending}>Pending Review</option>
|
>
|
||||||
<option value={ApprovalStatus.Review}>Under Active Review</option>
|
<Lock className="w-3.5 h-3.5 text-emerald-400" />
|
||||||
{(() => {
|
<span>Approved & Enacted (Immutable - Cannot Be Changed)</span>
|
||||||
const canApproveChange = canApproveRecord('change_management', editingChange?.createdBy);
|
</div>
|
||||||
const isSelf = isMaker(editingChange?.createdBy);
|
) : (
|
||||||
let reason = '';
|
<select
|
||||||
if (!canApprove('change_management')) {
|
{...editChangeForm.register('status')}
|
||||||
reason = ' (Requires Update Permission)';
|
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-amber-500"
|
||||||
} else if (isSelf) {
|
>
|
||||||
reason = ' (Maker-Checker: Cannot approve own record)';
|
<option value={ApprovalStatus.Pending}>Pending Review</option>
|
||||||
}
|
<option value={ApprovalStatus.Review}>Under Active Review</option>
|
||||||
return (
|
{(() => {
|
||||||
<option value={ApprovalStatus.Approved} disabled={!canApproveChange}>
|
const canApproveChange = canApproveRecord('change_management', editingChange?.createdBy);
|
||||||
Approved & Enacted {reason}
|
const isSelf = isMaker(editingChange?.createdBy);
|
||||||
</option>
|
let reason = '';
|
||||||
);
|
if (!canApprove('change_management')) {
|
||||||
})()}
|
reason = ' (Requires Update Permission)';
|
||||||
<option value={ApprovalStatus.Rejected}>Rejected</option>
|
} else if (isSelf) {
|
||||||
</select>
|
reason = ' (Maker-Checker: Cannot approve own record)';
|
||||||
|
}
|
||||||
|
return (
|
||||||
|
<option value={ApprovalStatus.Approved} disabled={!canApproveChange}>
|
||||||
|
Approved & Enacted {reason}
|
||||||
|
</option>
|
||||||
|
);
|
||||||
|
})()}
|
||||||
|
<option value={ApprovalStatus.Rejected}>Rejected</option>
|
||||||
|
</select>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
|||||||
@@ -33,6 +33,17 @@ import { RiskHeatmapMatrix } from '../components/ui/RiskHeatmapMatrix';
|
|||||||
import { HelpTooltip } from '../components/ui/HelpTooltip';
|
import { HelpTooltip } from '../components/ui/HelpTooltip';
|
||||||
import { useTableSort, SortableTh } from '../utils/tableSort';
|
import { useTableSort, SortableTh } from '../utils/tableSort';
|
||||||
|
|
||||||
|
interface AttentionProject {
|
||||||
|
id: number | string;
|
||||||
|
name: string;
|
||||||
|
programme: string;
|
||||||
|
status: string;
|
||||||
|
risk: string;
|
||||||
|
financialVariance: string;
|
||||||
|
varianceType: string;
|
||||||
|
maxScore: number;
|
||||||
|
}
|
||||||
|
|
||||||
export const ExecutiveDashboard: React.FC = () => {
|
export const ExecutiveDashboard: React.FC = () => {
|
||||||
const queryClient = useQueryClient();
|
const queryClient = useQueryClient();
|
||||||
const [selectedProgramme, setSelectedProgramme] = useState('All');
|
const [selectedProgramme, setSelectedProgramme] = useState('All');
|
||||||
@@ -335,12 +346,12 @@ export const ExecutiveDashboard: React.FC = () => {
|
|||||||
sortColumn: projectSortCol,
|
sortColumn: projectSortCol,
|
||||||
sortDirection: projectSortDir,
|
sortDirection: projectSortDir,
|
||||||
handleSort: handleProjectSort,
|
handleSort: handleProjectSort,
|
||||||
} = useTableSort<any>(attentionProjects, null, null, {
|
} = useTableSort<AttentionProject>(attentionProjects, null, null, {
|
||||||
name: (p: any) => p.name,
|
name: (p) => p.name,
|
||||||
programme: (p: any) => p.programme,
|
programme: (p) => p.programme,
|
||||||
status: (p: any) => p.status,
|
status: (p) => p.status,
|
||||||
risk: (p: any) => p.risk,
|
risk: (p) => p.risk,
|
||||||
financialVariance: (p: any) => p.financialVariance,
|
financialVariance: (p) => p.financialVariance,
|
||||||
});
|
});
|
||||||
|
|
||||||
return (
|
return (
|
||||||
|
|||||||
@@ -26,6 +26,7 @@ import {
|
|||||||
ChevronUp,
|
ChevronUp,
|
||||||
CheckCircle2,
|
CheckCircle2,
|
||||||
Loader2,
|
Loader2,
|
||||||
|
Lock,
|
||||||
} from 'lucide-react';
|
} from 'lucide-react';
|
||||||
import {
|
import {
|
||||||
getOrganisations,
|
getOrganisations,
|
||||||
@@ -339,8 +340,9 @@ export const GovernancePage: React.FC = () => {
|
|||||||
setQuickUserPhone('');
|
setQuickUserPhone('');
|
||||||
setQuickUserIdNumber('');
|
setQuickUserIdNumber('');
|
||||||
setIsQuickAddUserOpen(false);
|
setIsQuickAddUserOpen(false);
|
||||||
} catch (err: any) {
|
} catch (err: unknown) {
|
||||||
const errorDetail = err?.response?.data?.detail || err?.message || 'Failed to create user.';
|
const errObj = err as { response?: { data?: { detail?: string } }; message?: string };
|
||||||
|
const errorDetail = errObj?.response?.data?.detail || errObj?.message || 'Failed to create user.';
|
||||||
setQuickUserError(errorDetail);
|
setQuickUserError(errorDetail);
|
||||||
toastError('Failed to create user', err);
|
toastError('Failed to create user', err);
|
||||||
} finally {
|
} finally {
|
||||||
@@ -538,7 +540,7 @@ export const GovernancePage: React.FC = () => {
|
|||||||
|
|
||||||
// Forms
|
// Forms
|
||||||
const orgForm = useForm<OrganisationFormValues>({
|
const orgForm = useForm<OrganisationFormValues>({
|
||||||
resolver: zodResolver(organisationSchema) as any,
|
resolver: zodResolver(organisationSchema),
|
||||||
defaultValues: {
|
defaultValues: {
|
||||||
name: '',
|
name: '',
|
||||||
registrationNo: '',
|
registrationNo: '',
|
||||||
@@ -550,7 +552,7 @@ export const GovernancePage: React.FC = () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
const portfolioForm = useForm<PortfolioFormValues>({
|
const portfolioForm = useForm<PortfolioFormValues>({
|
||||||
resolver: zodResolver(portfolioSchema) as any,
|
resolver: zodResolver(portfolioSchema),
|
||||||
defaultValues: {
|
defaultValues: {
|
||||||
name: '',
|
name: '',
|
||||||
description: '',
|
description: '',
|
||||||
@@ -560,7 +562,7 @@ export const GovernancePage: React.FC = () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
const programmeForm = useForm<ProgrammeFormValues>({
|
const programmeForm = useForm<ProgrammeFormValues>({
|
||||||
resolver: zodResolver(programmeSchema) as any,
|
resolver: zodResolver(programmeSchema),
|
||||||
defaultValues: {
|
defaultValues: {
|
||||||
name: '',
|
name: '',
|
||||||
portfolioId: portfolios[0]?.id ? Number(portfolios[0].id) : 201,
|
portfolioId: portfolios[0]?.id ? Number(portfolios[0].id) : 201,
|
||||||
@@ -685,9 +687,9 @@ export const GovernancePage: React.FC = () => {
|
|||||||
|
|
||||||
const targetUserId =
|
const targetUserId =
|
||||||
org.userId ||
|
org.userId ||
|
||||||
(org as any).UserId ||
|
org.UserId ||
|
||||||
(org as any).organisationUser ||
|
org.organisationUser ||
|
||||||
(org as any).OrganisationUser ||
|
org.OrganisationUser ||
|
||||||
org.createdBy ||
|
org.createdBy ||
|
||||||
'';
|
'';
|
||||||
|
|
||||||
@@ -1988,33 +1990,43 @@ export const GovernancePage: React.FC = () => {
|
|||||||
placement="top"
|
placement="top"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
{(() => {
|
{selectedProgrammeForDetail && Number(selectedProgrammeForDetail.status) === ApprovalStatus.Approved ? (
|
||||||
const canApproveProgramme = canApproveRecord('governance', selectedProgrammeForDetail?.createdBy);
|
<div
|
||||||
const isSelf = isMaker(selectedProgrammeForDetail?.createdBy);
|
className="w-full px-3 py-2 text-xs border border-emerald-500/30 bg-emerald-500/10 rounded-xl text-emerald-300 font-semibold flex items-center gap-1.5 cursor-default"
|
||||||
|
title="Approved programmes are immutable."
|
||||||
|
>
|
||||||
|
<Lock className="w-3.5 h-3.5 text-emerald-400" />
|
||||||
|
<span>Approved (Immutable - Cannot Be Changed)</span>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
(() => {
|
||||||
|
const canApproveProgramme = canApproveRecord('governance', selectedProgrammeForDetail?.createdBy);
|
||||||
|
const isSelf = isMaker(selectedProgrammeForDetail?.createdBy);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<select
|
<select
|
||||||
{...programmeForm.register('status')}
|
{...programmeForm.register('status')}
|
||||||
className="w-full px-3 py-2 text-xs border border-slate-700/80 rounded-xl focus:outline-none focus:ring-1 focus:ring-blue-500 bg-[#090d16] text-white font-medium"
|
className="w-full px-3 py-2 text-xs border border-slate-700/80 rounded-xl focus:outline-none focus:ring-1 focus:ring-blue-500 bg-[#090d16] text-white font-medium"
|
||||||
>
|
>
|
||||||
{APPROVAL_STATUS_OPTIONS.map((opt) => {
|
{APPROVAL_STATUS_OPTIONS.map((opt) => {
|
||||||
const isApprovedOpt = opt.value === ApprovalStatus.Approved;
|
const isApprovedOpt = opt.value === ApprovalStatus.Approved;
|
||||||
const isOptDisabled = isApprovedOpt && !canApproveProgramme;
|
const isOptDisabled = isApprovedOpt && !canApproveProgramme;
|
||||||
let reason = '';
|
let reason = '';
|
||||||
if (isApprovedOpt && !canApprove('governance')) {
|
if (isApprovedOpt && !canApprove('governance')) {
|
||||||
reason = ' (Requires Update Permission)';
|
reason = ' (Requires Update Permission)';
|
||||||
} else if (isApprovedOpt && isSelf) {
|
} else if (isApprovedOpt && isSelf) {
|
||||||
reason = ' (Maker-Checker: Cannot approve own record)';
|
reason = ' (Maker-Checker: Cannot approve own record)';
|
||||||
}
|
}
|
||||||
return (
|
return (
|
||||||
<option key={opt.value} value={opt.value} disabled={isOptDisabled}>
|
<option key={opt.value} value={opt.value} disabled={isOptDisabled}>
|
||||||
{opt.label}{reason}
|
{opt.label}{reason}
|
||||||
</option>
|
</option>
|
||||||
);
|
);
|
||||||
})}
|
})}
|
||||||
</select>
|
</select>
|
||||||
);
|
);
|
||||||
})()}
|
})()
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div>
|
<div>
|
||||||
|
|||||||
@@ -25,6 +25,7 @@ import {
|
|||||||
getAwards,
|
getAwards,
|
||||||
getAward,
|
getAward,
|
||||||
createAward,
|
createAward,
|
||||||
|
updateAward,
|
||||||
getBudgets,
|
getBudgets,
|
||||||
getBudget,
|
getBudget,
|
||||||
createBudget,
|
createBudget,
|
||||||
@@ -52,6 +53,7 @@ import {
|
|||||||
Disbursement,
|
Disbursement,
|
||||||
ApprovalStatus,
|
ApprovalStatus,
|
||||||
CreateAwardRequest,
|
CreateAwardRequest,
|
||||||
|
UpdateAwardRequest,
|
||||||
CreateBudgetRequest,
|
CreateBudgetRequest,
|
||||||
UpdateBudgetRequest,
|
UpdateBudgetRequest,
|
||||||
CreateDisbursementRequest,
|
CreateDisbursementRequest,
|
||||||
@@ -77,6 +79,15 @@ const awardSchema = z.object({
|
|||||||
programmeId: z.coerce.number().min(1, 'Select funding programme'),
|
programmeId: z.coerce.number().min(1, 'Select funding programme'),
|
||||||
});
|
});
|
||||||
|
|
||||||
|
const editAwardSchema = z.object({
|
||||||
|
organisationId: z.coerce.number().min(1, 'Recipient organisation is required'),
|
||||||
|
programmeId: z.coerce.number().min(1, 'Funding programme is required'),
|
||||||
|
amount: z
|
||||||
|
.coerce
|
||||||
|
.number()
|
||||||
|
.positive('Approved funding amount must be strictly greater than zero (no negative or zero values)'),
|
||||||
|
});
|
||||||
|
|
||||||
const budgetSchema = z.object({
|
const budgetSchema = z.object({
|
||||||
projectId: z.coerce.number().min(1, 'Select project'),
|
projectId: z.coerce.number().min(1, 'Select project'),
|
||||||
name: z.string().min(3, 'Budget category name required'),
|
name: z.string().min(3, 'Budget category name required'),
|
||||||
@@ -93,6 +104,7 @@ const disbursementSchema = z.object({
|
|||||||
});
|
});
|
||||||
|
|
||||||
type AwardFormValues = z.infer<typeof awardSchema>;
|
type AwardFormValues = z.infer<typeof awardSchema>;
|
||||||
|
type EditAwardFormValues = z.infer<typeof editAwardSchema>;
|
||||||
type BudgetFormValues = z.infer<typeof budgetSchema>;
|
type BudgetFormValues = z.infer<typeof budgetSchema>;
|
||||||
type DisbursementFormValues = z.infer<typeof disbursementSchema>;
|
type DisbursementFormValues = z.infer<typeof disbursementSchema>;
|
||||||
|
|
||||||
@@ -105,6 +117,9 @@ export const GrantsPage: React.FC = () => {
|
|||||||
|
|
||||||
// Modals
|
// Modals
|
||||||
const [isAwardModalOpen, setIsAwardModalOpen] = useState(false);
|
const [isAwardModalOpen, setIsAwardModalOpen] = useState(false);
|
||||||
|
const [editingAward, setEditingAward] = useState<Award | null>(null);
|
||||||
|
const [isEditAwardModalOpen, setIsEditAwardModalOpen] = useState(false);
|
||||||
|
const [selectedAwardStatus, setSelectedAwardStatus] = useState<ApprovalStatus>(ApprovalStatus.Pending);
|
||||||
const [isBudgetModalOpen, setIsBudgetModalOpen] = useState(false);
|
const [isBudgetModalOpen, setIsBudgetModalOpen] = useState(false);
|
||||||
const [editingBudget, setEditingBudget] = useState<Budget | null>(null);
|
const [editingBudget, setEditingBudget] = useState<Budget | null>(null);
|
||||||
const [isEditBudgetModalOpen, setIsEditBudgetModalOpen] = useState(false);
|
const [isEditBudgetModalOpen, setIsEditBudgetModalOpen] = useState(false);
|
||||||
@@ -165,7 +180,7 @@ export const GrantsPage: React.FC = () => {
|
|||||||
|
|
||||||
// Forms
|
// Forms
|
||||||
const awardForm = useForm<AwardFormValues>({
|
const awardForm = useForm<AwardFormValues>({
|
||||||
resolver: zodResolver(awardSchema) as any,
|
resolver: zodResolver(awardSchema),
|
||||||
defaultValues: {
|
defaultValues: {
|
||||||
referenceNo: '',
|
referenceNo: '',
|
||||||
projectId: projects[0]?.id ? Number(projects[0].id) : 401,
|
projectId: projects[0]?.id ? Number(projects[0].id) : 401,
|
||||||
@@ -176,8 +191,12 @@ export const GrantsPage: React.FC = () => {
|
|||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
|
const editAwardForm = useForm<EditAwardFormValues>({
|
||||||
|
resolver: zodResolver(editAwardSchema),
|
||||||
|
});
|
||||||
|
|
||||||
const budgetForm = useForm<BudgetFormValues>({
|
const budgetForm = useForm<BudgetFormValues>({
|
||||||
resolver: zodResolver(budgetSchema) as any,
|
resolver: zodResolver(budgetSchema),
|
||||||
defaultValues: {
|
defaultValues: {
|
||||||
projectId: projects[0]?.id ? Number(projects[0].id) : 401,
|
projectId: projects[0]?.id ? Number(projects[0].id) : 401,
|
||||||
name: '',
|
name: '',
|
||||||
@@ -186,7 +205,7 @@ export const GrantsPage: React.FC = () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
const disbursementForm = useForm<DisbursementFormValues>({
|
const disbursementForm = useForm<DisbursementFormValues>({
|
||||||
resolver: zodResolver(disbursementSchema) as any,
|
resolver: zodResolver(disbursementSchema),
|
||||||
defaultValues: {
|
defaultValues: {
|
||||||
projectId: projects[0]?.id ? Number(projects[0].id) : 401,
|
projectId: projects[0]?.id ? Number(projects[0].id) : 401,
|
||||||
amount: 2500000,
|
amount: 2500000,
|
||||||
@@ -211,6 +230,21 @@ export const GrantsPage: React.FC = () => {
|
|||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
|
const updateAwardMutation = useMutation({
|
||||||
|
mutationFn: (data: UpdateAwardRequest) => updateAward(data),
|
||||||
|
onSuccess: () => {
|
||||||
|
queryClient.invalidateQueries({ queryKey: ['awards'] });
|
||||||
|
queryClient.invalidateQueries({ queryKey: ['dashboard'] });
|
||||||
|
success('Award Updated', 'Award details saved successfully.');
|
||||||
|
setIsEditAwardModalOpen(false);
|
||||||
|
setEditingAward(null);
|
||||||
|
editAwardForm.reset();
|
||||||
|
},
|
||||||
|
onError: (err: unknown) => {
|
||||||
|
toastError('Award update failed', err);
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
const budgetMutation = useMutation({
|
const budgetMutation = useMutation({
|
||||||
mutationFn: (data: CreateBudgetRequest) => createBudget(data),
|
mutationFn: (data: CreateBudgetRequest) => createBudget(data),
|
||||||
onSuccess: () => {
|
onSuccess: () => {
|
||||||
@@ -837,18 +871,21 @@ export const GrantsPage: React.FC = () => {
|
|||||||
<SortableTh column="createdAt" currentColumn={awardSortCol} direction={awardSortDir} onSort={handleAwardSort} align="right" className="py-3.5 px-5 text-right">
|
<SortableTh column="createdAt" currentColumn={awardSortCol} direction={awardSortDir} onSort={handleAwardSort} align="right" className="py-3.5 px-5 text-right">
|
||||||
Date Registered
|
Date Registered
|
||||||
</SortableTh>
|
</SortableTh>
|
||||||
|
<th className="py-3.5 px-5 text-right font-semibold text-xs text-slate-400 uppercase tracking-wider">
|
||||||
|
Actions
|
||||||
|
</th>
|
||||||
</tr>
|
</tr>
|
||||||
</thead>
|
</thead>
|
||||||
<tbody className="divide-y divide-slate-800/50">
|
<tbody className="divide-y divide-slate-800/50">
|
||||||
{loadingAwards ? (
|
{loadingAwards ? (
|
||||||
<tr>
|
<tr>
|
||||||
<td colSpan={6} className="py-8 text-center text-slate-500">
|
<td colSpan={7} className="py-8 text-center text-slate-500">
|
||||||
Loading registered awards...
|
Loading registered awards...
|
||||||
</td>
|
</td>
|
||||||
</tr>
|
</tr>
|
||||||
) : sortedAwards.length === 0 ? (
|
) : sortedAwards.length === 0 ? (
|
||||||
<tr>
|
<tr>
|
||||||
<td colSpan={6} className="py-8 text-center text-slate-500">
|
<td colSpan={7} className="py-8 text-center text-slate-500">
|
||||||
{searchTerm ? 'No awards match your search query.' : 'No registered awards found.'}
|
{searchTerm ? 'No awards match your search query.' : 'No registered awards found.'}
|
||||||
</td>
|
</td>
|
||||||
</tr>
|
</tr>
|
||||||
@@ -878,6 +915,36 @@ export const GrantsPage: React.FC = () => {
|
|||||||
<td className="py-3.5 px-5 text-right font-mono text-slate-400">
|
<td className="py-3.5 px-5 text-right font-mono text-slate-400">
|
||||||
{formatDate(awd.createdAt)}
|
{formatDate(awd.createdAt)}
|
||||||
</td>
|
</td>
|
||||||
|
<td className="py-3.5 px-5 text-right">
|
||||||
|
{Number(awd.status) === ApprovalStatus.Approved ? (
|
||||||
|
<span
|
||||||
|
className="inline-flex items-center gap-1 text-xs font-semibold text-emerald-400 bg-emerald-500/10 px-2.5 py-1.5 rounded-lg border border-emerald-500/20 cursor-default"
|
||||||
|
title="Approved award is immutable. Details cannot be modified."
|
||||||
|
>
|
||||||
|
<Lock className="w-3 h-3 text-emerald-400" />
|
||||||
|
<span>Approved (Immutable)</span>
|
||||||
|
</span>
|
||||||
|
) : (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => {
|
||||||
|
setEditingAward(awd);
|
||||||
|
setSelectedAwardStatus(Number(awd.status) as ApprovalStatus);
|
||||||
|
editAwardForm.reset({
|
||||||
|
organisationId: Number(awd.organisationId),
|
||||||
|
programmeId: Number(awd.programmeId),
|
||||||
|
amount: Number(awd.amount),
|
||||||
|
});
|
||||||
|
setIsEditAwardModalOpen(true);
|
||||||
|
}}
|
||||||
|
className="inline-flex items-center gap-1 text-xs font-semibold text-slate-200 hover:text-white bg-slate-900/80 hover:bg-slate-800 border border-slate-700/80 px-2.5 py-1.5 rounded-lg transition shadow-xs cursor-pointer"
|
||||||
|
title="Edit Award"
|
||||||
|
>
|
||||||
|
<Edit2 className="w-3 h-3" />
|
||||||
|
<span>Edit</span>
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
</td>
|
||||||
</tr>
|
</tr>
|
||||||
);
|
);
|
||||||
})
|
})
|
||||||
@@ -989,40 +1056,52 @@ export const GrantsPage: React.FC = () => {
|
|||||||
<td className="py-3.5 px-4 font-mono text-slate-400">{getUserEmail(b.createdBy)}</td>
|
<td className="py-3.5 px-4 font-mono text-slate-400">{getUserEmail(b.createdBy)}</td>
|
||||||
<td className="py-3.5 px-5 text-right">
|
<td className="py-3.5 px-5 text-right">
|
||||||
<div className="flex items-center justify-end gap-1.5">
|
<div className="flex items-center justify-end gap-1.5">
|
||||||
<button
|
{Number(b.status) === ApprovalStatus.Approved ? (
|
||||||
type="button"
|
<span
|
||||||
onClick={() => {
|
className="inline-flex items-center gap-1 text-xs font-semibold text-emerald-400 bg-emerald-500/10 px-2.5 py-1.5 rounded-lg border border-emerald-500/20 cursor-default"
|
||||||
setEditingBudget(b);
|
title="Approved budget is immutable. Status cannot be modified."
|
||||||
setSelectedBudgetStatus(b.status ?? ApprovalStatus.Approved);
|
|
||||||
setIsEditBudgetModalOpen(true);
|
|
||||||
}}
|
|
||||||
className="inline-flex items-center gap-1 text-xs font-semibold text-slate-200 hover:text-white bg-slate-900/80 hover:bg-slate-800 border border-slate-700/80 px-2.5 py-1.5 rounded-lg transition shadow-xs cursor-pointer"
|
|
||||||
title="Update Budget Status"
|
|
||||||
>
|
|
||||||
<Edit2 className="w-3 h-3" />
|
|
||||||
<span>Update</span>
|
|
||||||
</button>
|
|
||||||
{b.status !== ApprovalStatus.Approved && canApproveRecord('grant_financials', b.createdBy) && (
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
disabled={updateBudgetMutation.isPending}
|
|
||||||
onClick={() => {
|
|
||||||
updateBudgetMutation.mutate({
|
|
||||||
budgetId: b.id,
|
|
||||||
status: ApprovalStatus.Approved,
|
|
||||||
});
|
|
||||||
}}
|
|
||||||
className="inline-flex items-center gap-1 text-xs font-semibold text-emerald-300 hover:text-white bg-emerald-500/10 hover:bg-emerald-500/20 border border-emerald-500/30 px-2.5 py-1.5 rounded-lg transition shadow-xs cursor-pointer"
|
|
||||||
title="Approve Budget Line"
|
|
||||||
>
|
>
|
||||||
<CheckCircle className="w-3 h-3" />
|
<Lock className="w-3 h-3 text-emerald-400" />
|
||||||
<span>Approve</span>
|
<span>Approved (Immutable)</span>
|
||||||
</button>
|
|
||||||
)}
|
|
||||||
{b.status !== ApprovalStatus.Approved && canApprove('grant_financials') && isMaker(b.createdBy) && (
|
|
||||||
<span className="text-[10px] text-amber-400 font-mono italic px-1.5 py-0.5 bg-amber-500/10 border border-amber-500/20 rounded">
|
|
||||||
Maker-Checker
|
|
||||||
</span>
|
</span>
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => {
|
||||||
|
setEditingBudget(b);
|
||||||
|
setSelectedBudgetStatus(b.status ?? ApprovalStatus.Pending);
|
||||||
|
setIsEditBudgetModalOpen(true);
|
||||||
|
}}
|
||||||
|
className="inline-flex items-center gap-1 text-xs font-semibold text-slate-200 hover:text-white bg-slate-900/80 hover:bg-slate-800 border border-slate-700/80 px-2.5 py-1.5 rounded-lg transition shadow-xs cursor-pointer"
|
||||||
|
title="Update Budget Status"
|
||||||
|
>
|
||||||
|
<Edit2 className="w-3 h-3" />
|
||||||
|
<span>Update</span>
|
||||||
|
</button>
|
||||||
|
{canApproveRecord('grant_financials', b.createdBy) && (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
disabled={updateBudgetMutation.isPending}
|
||||||
|
onClick={() => {
|
||||||
|
updateBudgetMutation.mutate({
|
||||||
|
budgetId: b.id,
|
||||||
|
status: ApprovalStatus.Approved,
|
||||||
|
});
|
||||||
|
}}
|
||||||
|
className="inline-flex items-center gap-1 text-xs font-semibold text-emerald-300 hover:text-white bg-emerald-500/10 hover:bg-emerald-500/20 border border-emerald-500/30 px-2.5 py-1.5 rounded-lg transition shadow-xs cursor-pointer"
|
||||||
|
title="Approve Budget Line"
|
||||||
|
>
|
||||||
|
<CheckCircle className="w-3 h-3" />
|
||||||
|
<span>Approve</span>
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
{canApprove('grant_financials') && isMaker(b.createdBy) && (
|
||||||
|
<span className="text-[10px] text-amber-400 font-mono italic px-1.5 py-0.5 bg-amber-500/10 border border-amber-500/20 rounded">
|
||||||
|
Maker-Checker
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
</td>
|
</td>
|
||||||
@@ -1455,6 +1534,140 @@ export const GrantsPage: React.FC = () => {
|
|||||||
</form>
|
</form>
|
||||||
</Modal>
|
</Modal>
|
||||||
|
|
||||||
|
{/* Modal: Edit Award */}
|
||||||
|
<Modal
|
||||||
|
isOpen={isEditAwardModalOpen}
|
||||||
|
onClose={() => {
|
||||||
|
setIsEditAwardModalOpen(false);
|
||||||
|
setEditingAward(null);
|
||||||
|
}}
|
||||||
|
title={`Edit Award: ${editingAward?.referenceNo || `#${editingAward?.id}`}`}
|
||||||
|
subtitle="Approved funding award record"
|
||||||
|
helpText="Modify the funding programme, recipient organisation, and approved award allocation."
|
||||||
|
>
|
||||||
|
<form
|
||||||
|
onSubmit={editAwardForm.handleSubmit((values) => {
|
||||||
|
if (!editingAward) return;
|
||||||
|
if (Number(editingAward.status) === ApprovalStatus.Approved) {
|
||||||
|
toastError('Record Immutable', 'Approved awards cannot be modified.');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
updateAwardMutation.mutate({
|
||||||
|
awardId: editingAward.id,
|
||||||
|
organisationId: values.organisationId,
|
||||||
|
programmeId: values.programmeId,
|
||||||
|
amount: values.amount,
|
||||||
|
status: selectedAwardStatus,
|
||||||
|
});
|
||||||
|
})}
|
||||||
|
className="space-y-4"
|
||||||
|
>
|
||||||
|
<div>
|
||||||
|
<label className="block text-xs font-semibold text-slate-300 mb-1">
|
||||||
|
Funding Programme <span className="text-rose-400">*</span>
|
||||||
|
</label>
|
||||||
|
<select
|
||||||
|
{...editAwardForm.register('programmeId')}
|
||||||
|
className="w-full px-3 py-2 text-xs bg-slate-900/80 border border-slate-700/80 rounded-xl text-white focus:outline-none focus:ring-2 focus:ring-blue-500/50 focus:border-blue-500 font-medium [&>option]:bg-slate-900 [&>option]:text-white"
|
||||||
|
>
|
||||||
|
<option value="">Select Programme</option>
|
||||||
|
{programmes.map((p) => (
|
||||||
|
<option key={p.id} value={p.id}>
|
||||||
|
{p.name}
|
||||||
|
</option>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
|
{editAwardForm.formState.errors.programmeId && (
|
||||||
|
<p className="text-[11px] text-rose-400 mt-1">
|
||||||
|
{editAwardForm.formState.errors.programmeId.message}
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<label className="block text-xs font-semibold text-slate-300 mb-1">
|
||||||
|
Recipient Organisation <span className="text-rose-400">*</span>
|
||||||
|
</label>
|
||||||
|
<select
|
||||||
|
{...editAwardForm.register('organisationId')}
|
||||||
|
className="w-full px-3 py-2 text-xs bg-slate-900/80 border border-slate-700/80 rounded-xl text-white focus:outline-none focus:ring-2 focus:ring-blue-500/50 focus:border-blue-500 font-medium [&>option]:bg-slate-900 [&>option]:text-white"
|
||||||
|
>
|
||||||
|
<option value="">Select Recipient Organisation</option>
|
||||||
|
{organisations.map((o) => (
|
||||||
|
<option key={o.id} value={o.id}>
|
||||||
|
{o.name}
|
||||||
|
</option>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
|
{editAwardForm.formState.errors.organisationId && (
|
||||||
|
<p className="text-[11px] text-rose-400 mt-1">
|
||||||
|
{editAwardForm.formState.errors.organisationId.message}
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<label className="block text-xs font-semibold text-slate-300 mb-1">
|
||||||
|
Approved Award Amount (ZAR) <span className="text-rose-400">*</span>
|
||||||
|
</label>
|
||||||
|
<input
|
||||||
|
type="number"
|
||||||
|
step="0.01"
|
||||||
|
{...editAwardForm.register('amount')}
|
||||||
|
className="w-full px-3 py-2 text-xs bg-slate-900/80 border border-slate-700/80 rounded-xl text-white focus:outline-none focus:ring-2 focus:ring-blue-500/50 focus:border-blue-500 font-mono"
|
||||||
|
/>
|
||||||
|
{editAwardForm.formState.errors.amount && (
|
||||||
|
<p className="text-[11px] text-rose-400 mt-1">
|
||||||
|
{editAwardForm.formState.errors.amount.message}
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<label className="block text-xs font-semibold text-slate-300 mb-1">
|
||||||
|
Award Lifecycle Status <span className="text-rose-400">*</span>
|
||||||
|
</label>
|
||||||
|
{Number(editingAward?.status) === ApprovalStatus.Approved ? (
|
||||||
|
<div className="w-full px-3 py-2 text-xs border border-emerald-500/30 bg-emerald-500/10 rounded-xl text-emerald-300 font-semibold flex items-center gap-1.5 cursor-default">
|
||||||
|
<Lock className="w-3.5 h-3.5 text-emerald-400" />
|
||||||
|
<span>Approved (Immutable - Cannot Be Changed)</span>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<select
|
||||||
|
value={selectedAwardStatus}
|
||||||
|
onChange={(e) => setSelectedAwardStatus(Number(e.target.value) as ApprovalStatus)}
|
||||||
|
className="w-full px-3 py-2 text-xs bg-slate-900/80 border border-slate-700/80 rounded-xl text-white focus:outline-none focus:ring-2 focus:ring-blue-500/50 focus:border-blue-500 font-medium [&>option]:bg-slate-900 [&>option]:text-white"
|
||||||
|
>
|
||||||
|
<option value={ApprovalStatus.Pending}>Pending Approval</option>
|
||||||
|
<option value={ApprovalStatus.Review}>Under Review</option>
|
||||||
|
<option value={ApprovalStatus.Approved}>Approved & Activated</option>
|
||||||
|
<option value={ApprovalStatus.Rejected}>Rejected</option>
|
||||||
|
</select>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex justify-end gap-2 pt-4 border-t border-slate-800">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => {
|
||||||
|
setIsEditAwardModalOpen(false);
|
||||||
|
setEditingAward(null);
|
||||||
|
}}
|
||||||
|
className="px-4 py-2 text-xs font-semibold text-slate-300 hover:text-white bg-slate-800 hover:bg-slate-700 rounded-xl transition cursor-pointer"
|
||||||
|
>
|
||||||
|
Cancel
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="submit"
|
||||||
|
disabled={updateAwardMutation.isPending || Number(editingAward?.status) === ApprovalStatus.Approved}
|
||||||
|
className="px-4 py-2 text-xs font-semibold text-white bg-blue-600 hover:bg-blue-500 rounded-xl transition shadow-md shadow-blue-500/20 disabled:opacity-50 cursor-pointer"
|
||||||
|
>
|
||||||
|
{updateAwardMutation.isPending ? 'Saving...' : 'Save Changes'}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
</Modal>
|
||||||
|
|
||||||
{/* Modal: Add Budget */}
|
{/* Modal: Add Budget */}
|
||||||
<Modal
|
<Modal
|
||||||
isOpen={isBudgetModalOpen}
|
isOpen={isBudgetModalOpen}
|
||||||
@@ -1463,7 +1676,7 @@ export const GrantsPage: React.FC = () => {
|
|||||||
subtitle="Maintain approved expenditure breakdown and commitments"
|
subtitle="Maintain approved expenditure breakdown and commitments"
|
||||||
helpText="Establishes financial lines for project cost categories. Ensures all disbursements are mapped to audited budget ceilings."
|
helpText="Establishes financial lines for project cost categories. Ensures all disbursements are mapped to audited budget ceilings."
|
||||||
>
|
>
|
||||||
<form onSubmit={budgetForm.handleSubmit((data) => budgetMutation.mutate(data as any))} className="space-y-4">
|
<form onSubmit={budgetForm.handleSubmit((data) => budgetMutation.mutate(data as CreateBudgetRequest))} className="space-y-4">
|
||||||
<div>
|
<div>
|
||||||
<div className="flex items-center gap-1 mb-1.5">
|
<div className="flex items-center gap-1 mb-1.5">
|
||||||
<label className="block text-xs font-semibold text-slate-300">Associated Project *</label>
|
<label className="block text-xs font-semibold text-slate-300">Associated Project *</label>
|
||||||
@@ -1567,29 +1780,36 @@ export const GrantsPage: React.FC = () => {
|
|||||||
<label className="block text-xs font-semibold text-slate-300 mb-1.5">
|
<label className="block text-xs font-semibold text-slate-300 mb-1.5">
|
||||||
Approval & Compliance Status *
|
Approval & Compliance Status *
|
||||||
</label>
|
</label>
|
||||||
<select
|
{Number(editingBudget?.status) === ApprovalStatus.Approved ? (
|
||||||
value={selectedBudgetStatus}
|
<div className="w-full px-3 py-2 text-xs border border-emerald-500/30 bg-emerald-500/10 rounded-xl text-emerald-300 font-semibold flex items-center gap-1.5 cursor-default">
|
||||||
onChange={(e) => setSelectedBudgetStatus(Number(e.target.value) as ApprovalStatus)}
|
<Lock className="w-3.5 h-3.5 text-emerald-400" />
|
||||||
className="w-full px-3 py-2 text-xs bg-slate-900/80 border border-slate-700/80 rounded-xl text-white focus:outline-none focus:ring-2 focus:ring-blue-500/50 focus:border-blue-500 font-medium [&>option]:bg-slate-900 [&>option]:text-white"
|
<span>Approved (Immutable - Status Cannot Be Changed)</span>
|
||||||
>
|
</div>
|
||||||
{(() => {
|
) : (
|
||||||
const canApproveBudget = canApproveRecord('grant_financials', editingBudget?.createdBy);
|
<select
|
||||||
const isSelf = isMaker(editingBudget?.createdBy);
|
value={selectedBudgetStatus}
|
||||||
let reason = '';
|
onChange={(e) => setSelectedBudgetStatus(Number(e.target.value) as ApprovalStatus)}
|
||||||
if (!canApprove('grant_financials')) {
|
className="w-full px-3 py-2 text-xs bg-slate-900/80 border border-slate-700/80 rounded-xl text-white focus:outline-none focus:ring-2 focus:ring-blue-500/50 focus:border-blue-500 font-medium [&>option]:bg-slate-900 [&>option]:text-white"
|
||||||
reason = ' (Requires Update Permission)';
|
>
|
||||||
} else if (isSelf) {
|
{(() => {
|
||||||
reason = ' (Maker-Checker: Cannot approve own record)';
|
const canApproveBudget = canApproveRecord('grant_financials', editingBudget?.createdBy);
|
||||||
}
|
const isSelf = isMaker(editingBudget?.createdBy);
|
||||||
return (
|
let reason = '';
|
||||||
<option value={ApprovalStatus.Approved} disabled={!canApproveBudget}>
|
if (!canApprove('grant_financials')) {
|
||||||
Approved {reason}
|
reason = ' (Requires Update Permission)';
|
||||||
</option>
|
} else if (isSelf) {
|
||||||
);
|
reason = ' (Maker-Checker: Cannot approve own record)';
|
||||||
})()}
|
}
|
||||||
<option value={ApprovalStatus.Pending}>Pending Review</option>
|
return (
|
||||||
<option value={ApprovalStatus.Rejected}>Rejected / Blocked</option>
|
<option value={ApprovalStatus.Approved} disabled={!canApproveBudget}>
|
||||||
</select>
|
Approved {reason}
|
||||||
|
</option>
|
||||||
|
);
|
||||||
|
})()}
|
||||||
|
<option value={ApprovalStatus.Pending}>Pending Review</option>
|
||||||
|
<option value={ApprovalStatus.Rejected}>Rejected / Blocked</option>
|
||||||
|
</select>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="pt-3 border-t border-slate-800 flex justify-end gap-2">
|
<div className="pt-3 border-t border-slate-800 flex justify-end gap-2">
|
||||||
@@ -1605,7 +1825,7 @@ export const GrantsPage: React.FC = () => {
|
|||||||
</button>
|
</button>
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
disabled={updateBudgetMutation.isPending || !editingBudget}
|
disabled={updateBudgetMutation.isPending || !editingBudget || Number(editingBudget.status) === ApprovalStatus.Approved}
|
||||||
onClick={() => {
|
onClick={() => {
|
||||||
if (editingBudget) {
|
if (editingBudget) {
|
||||||
updateBudgetMutation.mutate({
|
updateBudgetMutation.mutate({
|
||||||
|
|||||||
@@ -105,18 +105,18 @@ export const MonitoringPage: React.FC = () => {
|
|||||||
const [searchParams, setSearchParams] = useSearchParams();
|
const [searchParams, setSearchParams] = useSearchParams();
|
||||||
|
|
||||||
const tabParam = searchParams.get('tab');
|
const tabParam = searchParams.get('tab');
|
||||||
const [activeTab, setActiveTab] = useState<'contracts' | 'evidence' | 'indicators' | 'risks'>(
|
const [activeTab, setActiveTab] = useState<'contracts' | 'evidence' | 'risks'>(
|
||||||
tabParam === 'evidence' || tabParam === 'indicators' || tabParam === 'risks' ? tabParam : 'contracts'
|
tabParam === 'evidence' || tabParam === 'risks' ? tabParam : 'contracts'
|
||||||
);
|
);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const t = searchParams.get('tab');
|
const t = searchParams.get('tab');
|
||||||
if (t === 'evidence' || t === 'indicators' || t === 'risks' || t === 'contracts') {
|
if (t === 'evidence' || t === 'risks' || t === 'contracts') {
|
||||||
setActiveTab(t);
|
setActiveTab(t);
|
||||||
}
|
}
|
||||||
}, [searchParams]);
|
}, [searchParams]);
|
||||||
|
|
||||||
const handleTabChange = (tab: 'contracts' | 'evidence' | 'indicators' | 'risks') => {
|
const handleTabChange = (tab: 'contracts' | 'evidence' | 'risks') => {
|
||||||
setActiveTab(tab);
|
setActiveTab(tab);
|
||||||
setSearchParams((prev) => {
|
setSearchParams((prev) => {
|
||||||
const next = new URLSearchParams(prev);
|
const next = new URLSearchParams(prev);
|
||||||
@@ -176,7 +176,7 @@ export const MonitoringPage: React.FC = () => {
|
|||||||
|
|
||||||
// Forms
|
// Forms
|
||||||
const indicatorForm = useForm<IndicatorFormValues>({
|
const indicatorForm = useForm<IndicatorFormValues>({
|
||||||
resolver: zodResolver(indicatorSchema) as any,
|
resolver: zodResolver(indicatorSchema),
|
||||||
defaultValues: {
|
defaultValues: {
|
||||||
projectId: projects[0]?.id ? Number(projects[0].id) : 401,
|
projectId: projects[0]?.id ? Number(projects[0].id) : 401,
|
||||||
name: '',
|
name: '',
|
||||||
@@ -187,7 +187,7 @@ export const MonitoringPage: React.FC = () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
const riskForm = useForm<RiskFormValues>({
|
const riskForm = useForm<RiskFormValues>({
|
||||||
resolver: zodResolver(riskSchema) as any,
|
resolver: zodResolver(riskSchema),
|
||||||
defaultValues: {
|
defaultValues: {
|
||||||
projectId: projects[0]?.id ? Number(projects[0].id) : 401,
|
projectId: projects[0]?.id ? Number(projects[0].id) : 401,
|
||||||
name: '',
|
name: '',
|
||||||
@@ -436,15 +436,7 @@ export const MonitoringPage: React.FC = () => {
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="flex items-center gap-2 relative z-10 self-start md:self-center flex-wrap">
|
<div className="flex items-center gap-2 relative z-10 self-start md:self-center flex-wrap">
|
||||||
{activeTab === 'indicators' && canAdd('evidence_risks') && (
|
|
||||||
<button
|
|
||||||
onClick={() => setIsIndicatorModalOpen(true)}
|
|
||||||
className="inline-flex items-center gap-1.5 px-4 py-2.5 text-xs font-semibold text-white bg-gradient-to-r from-indigo-600 to-purple-600 hover:from-indigo-500 hover:to-purple-500 rounded-xl shadow-lg shadow-indigo-600/25 transition cursor-pointer"
|
|
||||||
>
|
|
||||||
<Plus className="w-4 h-4" />
|
|
||||||
<span>Define Indicator</span>
|
|
||||||
</button>
|
|
||||||
)}
|
|
||||||
{activeTab === 'risks' && canAdd('evidence_risks') && (
|
{activeTab === 'risks' && canAdd('evidence_risks') && (
|
||||||
<button
|
<button
|
||||||
onClick={openCreateRisk}
|
onClick={openCreateRisk}
|
||||||
@@ -493,23 +485,7 @@ export const MonitoringPage: React.FC = () => {
|
|||||||
placement="top"
|
placement="top"
|
||||||
/>
|
/>
|
||||||
</button>
|
</button>
|
||||||
<button
|
|
||||||
onClick={() => handleTabChange('indicators')}
|
|
||||||
className={`px-4 py-2 text-xs font-medium rounded-xl transition flex items-center gap-2 cursor-pointer ${
|
|
||||||
activeTab === 'indicators'
|
|
||||||
? 'bg-blue-600 text-white shadow-lg shadow-blue-600/30'
|
|
||||||
: 'text-slate-400 hover:text-slate-200 hover:bg-slate-800/50'
|
|
||||||
}`}
|
|
||||||
>
|
|
||||||
<TrendingUp className="w-4 h-4" />
|
|
||||||
<span>Indicators & Actuals ({indicators.length})</span>
|
|
||||||
<HelpTooltip
|
|
||||||
title="Quantitative Indicators"
|
|
||||||
content="Baseline vs. target metric progression with automated green/amber/red performance thresholds."
|
|
||||||
size="sm"
|
|
||||||
placement="top"
|
|
||||||
/>
|
|
||||||
</button>
|
|
||||||
<button
|
<button
|
||||||
onClick={() => handleTabChange('risks')}
|
onClick={() => handleTabChange('risks')}
|
||||||
className={`px-4 py-2 text-xs font-medium rounded-xl transition flex items-center gap-2 cursor-pointer ${
|
className={`px-4 py-2 text-xs font-medium rounded-xl transition flex items-center gap-2 cursor-pointer ${
|
||||||
@@ -544,217 +520,6 @@ export const MonitoringPage: React.FC = () => {
|
|||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{/* Tab 2: Indicators Table (M08) */}
|
|
||||||
{activeTab === 'indicators' && (
|
|
||||||
<div className="space-y-4">
|
|
||||||
{/* Indicators Search and Filter Toolbar */}
|
|
||||||
<div className="flex flex-col lg:flex-row lg:items-center justify-between gap-3 bg-[#0d121f]/90 p-4 rounded-2xl border border-slate-800/80 backdrop-blur-sm">
|
|
||||||
<div className="flex flex-col sm:flex-row items-stretch sm:items-center gap-3 flex-1 flex-wrap">
|
|
||||||
{/* Search */}
|
|
||||||
<div className="relative flex-1 min-w-[200px] max-w-md">
|
|
||||||
<Search className="w-4 h-4 text-slate-500 absolute left-3 top-3" />
|
|
||||||
<input
|
|
||||||
type="text"
|
|
||||||
placeholder="Search indicators, statement, project ID..."
|
|
||||||
value={indicatorSearchTerm}
|
|
||||||
onChange={(e) => setIndicatorSearchTerm(e.target.value)}
|
|
||||||
className="w-full pl-9 pr-4 py-2.5 text-xs bg-[#090d16] border border-slate-700/80 rounded-xl focus:outline-none focus:ring-1 focus:ring-blue-500 text-slate-200 placeholder:text-slate-500 transition shadow-xs"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Project Filter */}
|
|
||||||
<div className="flex items-center gap-2">
|
|
||||||
<label className="text-xs text-slate-400 whitespace-nowrap font-medium">Project:</label>
|
|
||||||
<select
|
|
||||||
value={indicatorProjectFilter}
|
|
||||||
onChange={(e) => setIndicatorProjectFilter(e.target.value === 'all' ? 'all' : Number(e.target.value))}
|
|
||||||
className="px-3 py-2 text-xs bg-[#090d16] border border-slate-700/80 rounded-xl text-slate-200 focus:outline-none focus:ring-1 focus:ring-blue-500 [&>option]:bg-slate-900"
|
|
||||||
>
|
|
||||||
<option value="all">All Projects ({projects.length})</option>
|
|
||||||
{projects.map((p) => (
|
|
||||||
<option key={p.id} value={p.id}>
|
|
||||||
{p.name} (#{p.id})
|
|
||||||
</option>
|
|
||||||
))}
|
|
||||||
</select>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Status Filter */}
|
|
||||||
<div className="flex items-center gap-2">
|
|
||||||
<label className="text-xs text-slate-400 whitespace-nowrap flex items-center gap-1 font-medium">
|
|
||||||
<Filter className="w-3.5 h-3.5 text-slate-400" />
|
|
||||||
Status:
|
|
||||||
</label>
|
|
||||||
<select
|
|
||||||
value={indicatorStatusFilter}
|
|
||||||
onChange={(e) => setIndicatorStatusFilter(e.target.value)}
|
|
||||||
className="px-3 py-2 text-xs bg-[#090d16] border border-slate-700/80 rounded-xl text-slate-200 focus:outline-none focus:ring-1 focus:ring-blue-500 [&>option]:bg-slate-900"
|
|
||||||
>
|
|
||||||
<option value="all">All Statuses</option>
|
|
||||||
<option value="green">≥90% Target Achieved</option>
|
|
||||||
<option value="amber">70–89% On Track</option>
|
|
||||||
<option value="red"><70% Underperforming</option>
|
|
||||||
<option value="pending">Pending Actuals</option>
|
|
||||||
</select>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Unit Filter */}
|
|
||||||
<div className="flex items-center gap-2">
|
|
||||||
<label className="text-xs text-slate-400 whitespace-nowrap font-medium">Unit:</label>
|
|
||||||
<select
|
|
||||||
value={indicatorUnitFilter}
|
|
||||||
onChange={(e) => setIndicatorUnitFilter(e.target.value === 'all' ? 'all' : Number(e.target.value))}
|
|
||||||
className="px-3 py-2 text-xs bg-[#090d16] border border-slate-700/80 rounded-xl text-slate-200 focus:outline-none focus:ring-1 focus:ring-blue-500 [&>option]:bg-slate-900"
|
|
||||||
>
|
|
||||||
<option value="all">All Units</option>
|
|
||||||
{UNIT_OF_MEASURE_OPTIONS.map((opt) => (
|
|
||||||
<option key={opt.value} value={opt.value}>
|
|
||||||
{opt.label}
|
|
||||||
</option>
|
|
||||||
))}
|
|
||||||
</select>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{(indicatorSearchTerm || indicatorProjectFilter !== 'all' || indicatorStatusFilter !== 'all' || indicatorUnitFilter !== 'all') && (
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
onClick={() => {
|
|
||||||
setIndicatorSearchTerm('');
|
|
||||||
setIndicatorProjectFilter('all');
|
|
||||||
setIndicatorStatusFilter('all');
|
|
||||||
setIndicatorUnitFilter('all');
|
|
||||||
}}
|
|
||||||
className="inline-flex items-center gap-1 text-[11px] font-semibold text-slate-400 hover:text-white px-2.5 py-2 bg-slate-800 hover:bg-slate-700 rounded-xl transition cursor-pointer"
|
|
||||||
>
|
|
||||||
<RotateCcw className="w-3 h-3" />
|
|
||||||
<span>Reset</span>
|
|
||||||
</button>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Action Buttons: Refresh & Export */}
|
|
||||||
<div className="flex items-center gap-2 self-end lg:self-center">
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
onClick={() => {
|
|
||||||
refetchIndicators();
|
|
||||||
success('Refreshed', 'Latest performance metrics loaded from API.');
|
|
||||||
}}
|
|
||||||
disabled={isFetchingIndicators}
|
|
||||||
className="inline-flex items-center justify-center gap-1.5 px-3.5 py-2 text-xs font-semibold text-slate-300 bg-[#0d121f] hover:bg-slate-800 border border-slate-700/80 rounded-xl transition cursor-pointer shadow-xs disabled:opacity-50"
|
|
||||||
title="Refresh indicators from API"
|
|
||||||
>
|
|
||||||
<RefreshCw className={`w-3.5 h-3.5 text-blue-400 ${isFetchingIndicators ? 'animate-spin' : ''}`} />
|
|
||||||
<span>Refresh</span>
|
|
||||||
</button>
|
|
||||||
|
|
||||||
<ExportButton
|
|
||||||
claim="evidence_risks"
|
|
||||||
title="M&E Performance Indicators"
|
|
||||||
subtitle="Baseline and target metrics tracking for monitoring and evaluation"
|
|
||||||
filename={`pfm-indicators-${new Date().toISOString().split('T')[0]}`}
|
|
||||||
columns={indicatorExportColumns}
|
|
||||||
data={filteredIndicators}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Indicators Table Card */}
|
|
||||||
<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">
|
|
||||||
<h3 className="text-sm font-semibold text-white flex items-center gap-2">
|
|
||||||
<TrendingUp className="w-4 h-4 text-blue-400" />
|
|
||||||
Performance Indicators & Actuals Ledger
|
|
||||||
</h3>
|
|
||||||
<span className="text-xs text-slate-400 font-mono">
|
|
||||||
Showing {filteredIndicators.length} of {indicators.length} indicators
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
<div className="overflow-x-auto">
|
|
||||||
<table className="w-full text-left text-xs border-collapse">
|
|
||||||
<thead>
|
|
||||||
<tr className="bg-slate-900/60 border-b border-slate-800/80 text-slate-400 uppercase tracking-wider font-semibold">
|
|
||||||
<SortableTh column="name" currentColumn={indSortCol} direction={indSortDir} onSort={handleIndSort} className="py-3.5 px-5">
|
|
||||||
Indicator Name
|
|
||||||
</SortableTh>
|
|
||||||
<SortableTh column="projectId" currentColumn={indSortCol} direction={indSortDir} onSort={handleIndSort} className="py-3.5 px-4">
|
|
||||||
Project ID
|
|
||||||
</SortableTh>
|
|
||||||
<SortableTh column="unitOfMeasure" currentColumn={indSortCol} direction={indSortDir} onSort={handleIndSort} className="py-3.5 px-4">
|
|
||||||
Unit of Measure
|
|
||||||
</SortableTh>
|
|
||||||
<SortableTh column="baselineAmount" currentColumn={indSortCol} direction={indSortDir} onSort={handleIndSort} align="right" className="py-3.5 px-4 text-right">
|
|
||||||
Baseline
|
|
||||||
</SortableTh>
|
|
||||||
<SortableTh column="targetAmount" currentColumn={indSortCol} direction={indSortDir} onSort={handleIndSort} align="right" className="py-3.5 px-4 text-right">
|
|
||||||
Target
|
|
||||||
</SortableTh>
|
|
||||||
<SortableTh column="actualAmount" currentColumn={indSortCol} direction={indSortDir} onSort={handleIndSort} align="right" className="py-3.5 px-4 text-right">
|
|
||||||
Actual Recorded
|
|
||||||
</SortableTh>
|
|
||||||
<SortableTh column="status" currentColumn={indSortCol} direction={indSortDir} onSort={handleIndSort} className="py-3.5 px-4">
|
|
||||||
Status
|
|
||||||
</SortableTh>
|
|
||||||
<th className="py-3.5 px-5 text-right font-medium">Action</th>
|
|
||||||
</tr>
|
|
||||||
</thead>
|
|
||||||
<tbody className="divide-y divide-slate-800/50">
|
|
||||||
{loadingIndicators ? (
|
|
||||||
<tr>
|
|
||||||
<td colSpan={8} className="py-12 text-center text-slate-500">
|
|
||||||
<RefreshCw className="w-6 h-6 animate-spin mx-auto mb-2 text-blue-400" />
|
|
||||||
Loading project indicators from API...
|
|
||||||
</td>
|
|
||||||
</tr>
|
|
||||||
) : sortedIndicators.length === 0 ? (
|
|
||||||
<tr>
|
|
||||||
<td colSpan={8} className="py-12 text-center text-slate-500">
|
|
||||||
<TrendingUp className="w-8 h-8 text-slate-600 mx-auto mb-2" />
|
|
||||||
{indicatorSearchTerm || indicatorProjectFilter !== 'all' || indicatorStatusFilter !== 'all' || indicatorUnitFilter !== 'all'
|
|
||||||
? 'No indicators match your filter criteria.'
|
|
||||||
: 'No performance indicators defined yet. Click "Define Indicator" to begin.'}
|
|
||||||
</td>
|
|
||||||
</tr>
|
|
||||||
) : (
|
|
||||||
sortedIndicators.map((ind) => {
|
|
||||||
const statusObj = getIndicatorStatus(ind.actualAmount, ind.targetAmount);
|
|
||||||
return (
|
|
||||||
<tr key={ind.id} className="hover:bg-slate-800/40 transition-colors">
|
|
||||||
<td className="py-3.5 px-5 font-semibold text-white">{ind.name}</td>
|
|
||||||
<td className="py-3.5 px-4 text-slate-400 font-mono">Project #{ind.projectId}</td>
|
|
||||||
<td className="py-3.5 px-4 text-slate-300">{getUnitOfMeasureLabel(ind.unitOfMeasure)}</td>
|
|
||||||
<td className="py-3.5 px-4 text-right font-mono text-slate-400">{ind.baselineAmount}</td>
|
|
||||||
<td className="py-3.5 px-4 text-right font-mono font-bold text-white">{ind.targetAmount}</td>
|
|
||||||
<td className="py-3.5 px-4 text-right font-mono font-bold text-emerald-400 text-xs">
|
|
||||||
{ind.actualAmount}
|
|
||||||
</td>
|
|
||||||
<td className="py-3.5 px-4">
|
|
||||||
<span className={`inline-flex items-center px-2 py-0.5 rounded-full text-[10px] font-semibold border ${statusObj.badge}`}>
|
|
||||||
{statusObj.label}
|
|
||||||
</span>
|
|
||||||
</td>
|
|
||||||
<td className="py-3.5 px-5 text-right">
|
|
||||||
<button
|
|
||||||
onClick={() => {
|
|
||||||
setSelectedIndicatorForActual(ind);
|
|
||||||
setActualInput(ind.actualAmount);
|
|
||||||
}}
|
|
||||||
className="inline-flex items-center gap-1 text-xs font-semibold text-slate-300 hover:text-white bg-slate-800/80 hover:bg-slate-700 px-3 py-1.5 rounded-lg border border-slate-700/60 transition cursor-pointer"
|
|
||||||
>
|
|
||||||
Record Actual
|
|
||||||
</button>
|
|
||||||
</td>
|
|
||||||
</tr>
|
|
||||||
);
|
|
||||||
})
|
|
||||||
)}
|
|
||||||
</tbody>
|
|
||||||
</table>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{/* Tab 3: Risk Register & Heatmap (M09) */}
|
{/* Tab 3: Risk Register & Heatmap (M09) */}
|
||||||
{activeTab === 'risks' && (
|
{activeTab === 'risks' && (
|
||||||
<div className="space-y-6">
|
<div className="space-y-6">
|
||||||
@@ -970,7 +735,7 @@ export const MonitoringPage: React.FC = () => {
|
|||||||
subtitle="Logical Framework & Theory of Change metric"
|
subtitle="Logical Framework & Theory of Change metric"
|
||||||
helpText="Quantitative or qualitative KPI tracked across reporting periods against target thresholds to assess project efficacy."
|
helpText="Quantitative or qualitative KPI tracked across reporting periods against target thresholds to assess project efficacy."
|
||||||
>
|
>
|
||||||
<form onSubmit={indicatorForm.handleSubmit((data) => indicatorMutation.mutate(data as any))} className="space-y-4">
|
<form onSubmit={indicatorForm.handleSubmit((data) => indicatorMutation.mutate(data as CreateIndicatorRequest))} className="space-y-4">
|
||||||
<div>
|
<div>
|
||||||
<div className="flex items-center gap-1 mb-1.5">
|
<div className="flex items-center gap-1 mb-1.5">
|
||||||
<label className="block text-xs font-semibold text-slate-300">Project *</label>
|
<label className="block text-xs font-semibold text-slate-300">Project *</label>
|
||||||
@@ -1157,7 +922,7 @@ export const MonitoringPage: React.FC = () => {
|
|||||||
subtitle={editingRisk ? `Updating risk item #${editingRisk.id}` : 'Identify and score project delivery risks'}
|
subtitle={editingRisk ? `Updating risk item #${editingRisk.id}` : 'Identify and score project delivery risks'}
|
||||||
helpText="Risks are scored by Likelihood (1-5) x Impact (1-5) to plot on the 5x5 Heatmap. High severity risks flag alerts for portfolio governance."
|
helpText="Risks are scored by Likelihood (1-5) x Impact (1-5) to plot on the 5x5 Heatmap. High severity risks flag alerts for portfolio governance."
|
||||||
>
|
>
|
||||||
<form onSubmit={riskForm.handleSubmit((data) => riskMutation.mutate(data as any))} className="space-y-4">
|
<form onSubmit={riskForm.handleSubmit((data) => riskMutation.mutate(data))} className="space-y-4">
|
||||||
{!editingRisk && (
|
{!editingRisk && (
|
||||||
<div>
|
<div>
|
||||||
<div className="flex items-center gap-1 mb-1.5">
|
<div className="flex items-center gap-1 mb-1.5">
|
||||||
|
|||||||
File diff suppressed because it is too large
Load Diff
@@ -192,7 +192,7 @@ export const SettingsPage: React.FC = () => {
|
|||||||
success('User Sync Complete', 'Authenticated user enabled and directory synced.');
|
success('User Sync Complete', 'Authenticated user enabled and directory synced.');
|
||||||
queryClient.invalidateQueries({ queryKey: ['users'] });
|
queryClient.invalidateQueries({ queryKey: ['users'] });
|
||||||
},
|
},
|
||||||
onError: (err: any) => {
|
onError: (err: unknown) => {
|
||||||
toastError('Sync Failed', err);
|
toastError('Sync Failed', err);
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
@@ -203,7 +203,7 @@ export const SettingsPage: React.FC = () => {
|
|||||||
success('Backend Integration Synced', 'System records and telemetry successfully synchronized.');
|
success('Backend Integration Synced', 'System records and telemetry successfully synchronized.');
|
||||||
queryClient.invalidateQueries({ queryKey: ['users'] });
|
queryClient.invalidateQueries({ queryKey: ['users'] });
|
||||||
},
|
},
|
||||||
onError: (err: any) => {
|
onError: (err: unknown) => {
|
||||||
toastError('Integration Sync Failed', err);
|
toastError('Integration Sync Failed', err);
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
@@ -218,7 +218,7 @@ export const SettingsPage: React.FC = () => {
|
|||||||
setSelectedUserForDetail({ ...selectedUserForDetail, status: vars.status });
|
setSelectedUserForDetail({ ...selectedUserForDetail, status: vars.status });
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
onError: (err: any) => {
|
onError: (err: unknown) => {
|
||||||
toastError('Update Failed', err);
|
toastError('Update Failed', err);
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
@@ -287,7 +287,7 @@ export const SettingsPage: React.FC = () => {
|
|||||||
setInviteRecipientUuid('');
|
setInviteRecipientUuid('');
|
||||||
setInviteMessage('');
|
setInviteMessage('');
|
||||||
},
|
},
|
||||||
onError: (err: any) => {
|
onError: (err: unknown) => {
|
||||||
toastError('Dispatch Error', err);
|
toastError('Dispatch Error', err);
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -19,6 +19,7 @@ import {
|
|||||||
ShieldCheck,
|
ShieldCheck,
|
||||||
Building,
|
Building,
|
||||||
Send,
|
Send,
|
||||||
|
Lock,
|
||||||
} from 'lucide-react';
|
} from 'lucide-react';
|
||||||
import {
|
import {
|
||||||
getProjects,
|
getProjects,
|
||||||
@@ -114,12 +115,15 @@ export const SiteVisitsPage: React.FC = () => {
|
|||||||
visitForm.reset();
|
visitForm.reset();
|
||||||
success('Site Visit Logged', 'Field inspection recorded with inspector findings.');
|
success('Site Visit Logged', 'Field inspection recorded with inspector findings.');
|
||||||
},
|
},
|
||||||
onError: (err: any) => toastError('Logging Failed', err),
|
onError: (err: unknown) => toastError('Logging Failed', err),
|
||||||
});
|
});
|
||||||
|
|
||||||
const updateVisitMutation = useMutation({
|
const updateVisitMutation = useMutation({
|
||||||
mutationFn: (vals: SiteVisitFormValues) => {
|
mutationFn: (vals: SiteVisitFormValues) => {
|
||||||
if (!editingVisit) throw new Error('No visit selected');
|
if (!editingVisit) throw new Error('No visit selected');
|
||||||
|
if (Number(editingVisit.status) === ApprovalStatus.Approved) {
|
||||||
|
throw new Error('Approved site visits are verified and immutable. No modifications are permitted.');
|
||||||
|
}
|
||||||
return updateSiteVisit({
|
return updateSiteVisit({
|
||||||
siteVisitId: editingVisit.id,
|
siteVisitId: editingVisit.id,
|
||||||
inspectorName: vals.inspectorName,
|
inspectorName: vals.inspectorName,
|
||||||
@@ -135,7 +139,7 @@ export const SiteVisitsPage: React.FC = () => {
|
|||||||
visitForm.reset();
|
visitForm.reset();
|
||||||
success('Site Visit Updated', 'Inspection report and verification status updated.');
|
success('Site Visit Updated', 'Inspection report and verification status updated.');
|
||||||
},
|
},
|
||||||
onError: (err: any) => toastError('Update Failed', err),
|
onError: (err: unknown) => toastError('Update Failed', err),
|
||||||
});
|
});
|
||||||
|
|
||||||
// Filtered & Sorted Visits
|
// Filtered & Sorted Visits
|
||||||
@@ -165,6 +169,10 @@ export const SiteVisitsPage: React.FC = () => {
|
|||||||
];
|
];
|
||||||
|
|
||||||
const handleOpenEdit = async (v: SiteVisit) => {
|
const handleOpenEdit = async (v: SiteVisit) => {
|
||||||
|
if (Number(v.status) === ApprovalStatus.Approved) {
|
||||||
|
toastError('Record Immutable', 'Approved site visits are verified and cannot be edited.');
|
||||||
|
return;
|
||||||
|
}
|
||||||
try {
|
try {
|
||||||
const fresh = await getSiteVisit(v.id);
|
const fresh = await getSiteVisit(v.id);
|
||||||
setEditingVisit(fresh);
|
setEditingVisit(fresh);
|
||||||
@@ -446,13 +454,22 @@ export const SiteVisitsPage: React.FC = () => {
|
|||||||
>
|
>
|
||||||
View & Feedback
|
View & Feedback
|
||||||
</button>
|
</button>
|
||||||
<button
|
{Number(v.status) === ApprovalStatus.Approved ? (
|
||||||
onClick={() => handleOpenEdit(v)}
|
<span
|
||||||
className="p-1 text-slate-400 hover:text-white hover:bg-slate-800 rounded-lg transition cursor-pointer"
|
className="p-1 text-emerald-400 cursor-default"
|
||||||
title="Edit site visit"
|
title="Approved site visits are verified and immutable."
|
||||||
>
|
>
|
||||||
<Edit2 className="w-3.5 h-3.5" />
|
<Lock className="w-3.5 h-3.5" />
|
||||||
</button>
|
</span>
|
||||||
|
) : (
|
||||||
|
<button
|
||||||
|
onClick={() => handleOpenEdit(v)}
|
||||||
|
className="p-1 text-slate-400 hover:text-white hover:bg-slate-800 rounded-lg transition cursor-pointer"
|
||||||
|
title="Edit site visit"
|
||||||
|
>
|
||||||
|
<Edit2 className="w-3.5 h-3.5" />
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
</td>
|
</td>
|
||||||
</tr>
|
</tr>
|
||||||
))
|
))
|
||||||
@@ -505,30 +522,40 @@ export const SiteVisitsPage: React.FC = () => {
|
|||||||
{editingVisit && (
|
{editingVisit && (
|
||||||
<div>
|
<div>
|
||||||
<label className="block text-xs font-semibold text-slate-300 mb-1">Verification Status</label>
|
<label className="block text-xs font-semibold text-slate-300 mb-1">Verification Status</label>
|
||||||
<select
|
{Number(editingVisit.status) === ApprovalStatus.Approved ? (
|
||||||
{...visitForm.register('status')}
|
<div
|
||||||
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-emerald-500"
|
className="w-full bg-slate-900 border border-emerald-500/30 rounded-xl px-3 py-2 text-xs text-emerald-300 font-semibold flex items-center gap-1.5 cursor-default"
|
||||||
>
|
title="Approved site visits are immutable."
|
||||||
{(() => {
|
>
|
||||||
const canApproveVisit = canApproveRecord('site_visits', editingVisit?.createdBy);
|
<Lock className="w-3.5 h-3.5 text-emerald-400" />
|
||||||
const isSelf = isMaker(editingVisit?.createdBy);
|
<span>Approved & Verified (Immutable - Cannot Be Changed)</span>
|
||||||
return APPROVAL_STATUS_OPTIONS.map((opt) => {
|
</div>
|
||||||
const isApprovedOpt = opt.value === ApprovalStatus.Approved;
|
) : (
|
||||||
const isOptDisabled = isApprovedOpt && !canApproveVisit;
|
<select
|
||||||
let reason = '';
|
{...visitForm.register('status')}
|
||||||
if (isApprovedOpt && !canApprove('site_visits')) {
|
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-emerald-500"
|
||||||
reason = ' (Requires Update Permission)';
|
>
|
||||||
} else if (isApprovedOpt && isSelf) {
|
{(() => {
|
||||||
reason = ' (Maker-Checker: Cannot approve own record)';
|
const canApproveVisit = canApproveRecord('site_visits', editingVisit?.createdBy);
|
||||||
}
|
const isSelf = isMaker(editingVisit?.createdBy);
|
||||||
return (
|
return APPROVAL_STATUS_OPTIONS.map((opt) => {
|
||||||
<option key={opt.value} value={opt.value} disabled={isOptDisabled}>
|
const isApprovedOpt = opt.value === ApprovalStatus.Approved;
|
||||||
{opt.label}{reason}
|
const isOptDisabled = isApprovedOpt && !canApproveVisit;
|
||||||
</option>
|
let reason = '';
|
||||||
);
|
if (isApprovedOpt && !canApprove('site_visits')) {
|
||||||
});
|
reason = ' (Requires Update Permission)';
|
||||||
})()}
|
} else if (isApprovedOpt && isSelf) {
|
||||||
</select>
|
reason = ' (Maker-Checker: Cannot approve own record)';
|
||||||
|
}
|
||||||
|
return (
|
||||||
|
<option key={opt.value} value={opt.value} disabled={isOptDisabled}>
|
||||||
|
{opt.label}{reason}
|
||||||
|
</option>
|
||||||
|
);
|
||||||
|
});
|
||||||
|
})()}
|
||||||
|
</select>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
|||||||
@@ -37,6 +37,7 @@ import {
|
|||||||
UpdateMilestoneRequest,
|
UpdateMilestoneRequest,
|
||||||
Award,
|
Award,
|
||||||
CreateAwardRequest,
|
CreateAwardRequest,
|
||||||
|
UpdateAwardRequest,
|
||||||
Contract,
|
Contract,
|
||||||
ContractStatus,
|
ContractStatus,
|
||||||
CreateContractRequest,
|
CreateContractRequest,
|
||||||
@@ -869,6 +870,20 @@ export const createAward = async (payload: CreateAwardRequest): Promise<Award> =
|
|||||||
return res.data;
|
return res.data;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
export const updateAward = async (payload: UpdateAwardRequest): Promise<void> => {
|
||||||
|
const numericAwardId = toIntegerId(payload.awardId);
|
||||||
|
const numericOrgId = toIntegerId(payload.organisationId);
|
||||||
|
const numericProgId = toIntegerId(payload.programmeId);
|
||||||
|
|
||||||
|
await apiClient.put('/v1/api/grants/awards', {
|
||||||
|
awardId: numericAwardId,
|
||||||
|
organisationId: numericOrgId,
|
||||||
|
programmeId: numericProgId,
|
||||||
|
amount: Number(payload.amount),
|
||||||
|
...(payload.status !== undefined ? { status: Number(payload.status) } : {}),
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
export const fetchHashId = async (id64: number | string): Promise<string> => {
|
export const fetchHashId = async (id64: number | string): Promise<string> => {
|
||||||
try {
|
try {
|
||||||
const res = await apiClient.get<string>(`/v1/api/tools/hashid/${id64}`);
|
const res = await apiClient.get<string>(`/v1/api/tools/hashid/${id64}`);
|
||||||
|
|||||||
@@ -257,8 +257,10 @@ export enum UserRole {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export interface Role {
|
export interface Role {
|
||||||
id: string;
|
id?: string;
|
||||||
name: string;
|
name?: string;
|
||||||
|
Id?: string;
|
||||||
|
Name?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface Claim {
|
export interface Claim {
|
||||||
@@ -361,6 +363,9 @@ export interface Organisation {
|
|||||||
registrationNo: string | null;
|
registrationNo: string | null;
|
||||||
email: string | null;
|
email: string | null;
|
||||||
userId?: string | null;
|
userId?: string | null;
|
||||||
|
UserId?: string | null;
|
||||||
|
organisationUser?: string | null;
|
||||||
|
OrganisationUser?: string | null;
|
||||||
idNumber?: string | null;
|
idNumber?: string | null;
|
||||||
type: OrganisationType;
|
type: OrganisationType;
|
||||||
status: ActivityStatus;
|
status: ActivityStatus;
|
||||||
@@ -640,6 +645,14 @@ export interface CreateAwardRequest {
|
|||||||
fundingPeriod?: string;
|
fundingPeriod?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export interface UpdateAwardRequest {
|
||||||
|
awardId: number | string;
|
||||||
|
organisationId: number | string;
|
||||||
|
programmeId: number | string;
|
||||||
|
amount: number;
|
||||||
|
status?: ApprovalStatus;
|
||||||
|
}
|
||||||
|
|
||||||
export interface Contract {
|
export interface Contract {
|
||||||
id: number | string;
|
id: number | string;
|
||||||
tenantId?: number | string;
|
tenantId?: number | string;
|
||||||
|
|||||||
Reference in New Issue
Block a user