Enforced approval rights

This commit is contained in:
2026-10-22 11:25:55 +02:00
parent 0261a6093e
commit 92891438f6
32 changed files with 1891 additions and 870 deletions
@@ -247,7 +247,7 @@ export const ContractsManagement: React.FC<ContractsManagementProps> = ({
// Target contractId for upload
const contractIdToUploadAgainst =
createdContract?.id ?? (createdContract as any)?.contractId ?? selectedAwardId;
createdContract?.id ?? selectedAwardId;
// Step 2: Upload PDF document to POST /v1/api/grants/contracts/{contractId}
if (contractPdfFile && contractIdToUploadAgainst) {
@@ -277,6 +277,9 @@ export const ContractsManagement: React.FC<ContractsManagementProps> = ({
mutationFn: async () => {
setUploadApiError(null);
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.');
await uploadContractFile(contractForFileUpload.id, selectedUploadFile);
@@ -417,21 +420,21 @@ export const ContractsManagement: React.FC<ContractsManagementProps> = ({
sortDirection: contractSortDir,
handleSort: handleContractSort,
} = useTableSort<Contract>(filteredContracts, null, null, {
id: (c: any) => c.id,
awardId: (c: any) => c.totalValue || c.awardId,
programmeId: (c: any) => {
id: (c: Contract) => c.id,
awardId: (c: Contract) => c.totalValue || c.awardId,
programmeId: (c: Contract) => {
const a = getAwardForContract(c);
const pr = getProgrammeForAward(a);
return pr?.name || '';
},
organisationId: (c: any) => {
organisationId: (c: Contract) => {
const a = getAwardForContract(c);
const o = getOrganisationForAward(a);
return o?.name || '';
},
signedDate: (c: any) => c.signedDate,
status: (c: any) => getContractStatusBadge(c.status).label,
fileUri: (c: any) => c.fileUri || '',
signedDate: (c: Contract) => c.effectiveAt || c.createdAt,
status: (c: Contract) => getContractStatusBadge(c.status).label,
fileUri: (c: Contract) => c.signedDocumentUrl || '',
});
return (
@@ -823,19 +826,21 @@ export const ContractsManagement: React.FC<ContractsManagementProps> = ({
<span>Secure PDF</span>
</button>
<button
type="button"
onClick={() => {
setContractForFileUpload(contract);
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"
>
<Upload className="w-3 h-3" />
<span>Replace</span>
</button>
{Number(contract.status) !== ContractStatus.Active && (
<button
type="button"
onClick={() => {
setContractForFileUpload(contract);
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"
>
<Upload className="w-3 h-3" />
<span>Replace</span>
</button>
)}
</div>
<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" />
@@ -845,18 +850,28 @@ export const ContractsManagement: React.FC<ContractsManagementProps> = ({
{/* Actions */}
<td className="py-3.5 px-4 text-right">
<button
type="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>
{Number(contract.status) === ContractStatus.Active ? (
<span
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"
title="Active contracts are executed and immutable."
>
<Lock className="w-3 h-3 text-emerald-400" />
<span>Active (Locked)</span>
</span>
) : (
<button
type="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>
</tr>
);
@@ -1229,34 +1244,41 @@ export const ContractsManagement: React.FC<ContractsManagementProps> = ({
<label className="block text-xs font-medium text-slate-200 mb-1">
Contract Lifecycle Status <span className="text-rose-400">*</span>
</label>
{(() => {
const canApproveContract = canApproveRecord('grant_financials', contractToUpdate?.createdBy);
const isSelf = isMaker(contractToUpdate?.createdBy);
{Number(contractToUpdate?.status) === ContractStatus.Active ? (
<div className="w-full bg-[#151b2c] border border-emerald-500/30 rounded-xl px-3 py-2 text-xs text-emerald-300 font-semibold flex items-center gap-1.5 cursor-default">
<Lock className="w-3.5 h-3.5 text-emerald-400" />
<span>Active / Executed (Immutable - Cannot Be Changed)</span>
</div>
) : (
(() => {
const canApproveContract = canApproveRecord('grant_financials', contractToUpdate?.createdBy);
const isSelf = isMaker(contractToUpdate?.createdBy);
return (
<select
value={newStatus}
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"
>
{CONTRACT_STATUS_OPTIONS.map((opt) => {
const isActiveOpt = opt.value === ContractStatus.Active;
const isOptDisabled = isActiveOpt && !canApproveContract;
let reasonText = '';
if (isActiveOpt && !canApprove('grant_financials')) {
reasonText = ' (Requires Update Permission)';
} else if (isActiveOpt && isSelf) {
reasonText = ' (Maker-Checker: Cannot approve own record)';
}
return (
<option key={opt.value} value={opt.value} disabled={isOptDisabled}>
{opt.label}{reasonText}
</option>
);
})}
</select>
);
})()}
return (
<select
value={newStatus}
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"
>
{CONTRACT_STATUS_OPTIONS.map((opt) => {
const isActiveOpt = opt.value === ContractStatus.Active;
const isOptDisabled = isActiveOpt && !canApproveContract;
let reasonText = '';
if (isActiveOpt && !canApprove('grant_financials')) {
reasonText = ' (Requires Update Permission)';
} else if (isActiveOpt && isSelf) {
reasonText = ' (Maker-Checker: Cannot approve own record)';
}
return (
<option key={opt.value} value={opt.value} disabled={isOptDisabled}>
{opt.label}{reasonText}
</option>
);
})}
</select>
);
})()
)}
</div>
<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
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"
>
{updateStatusMutation.isPending ? (