Enforced approval rights
This commit is contained in:
@@ -90,14 +90,15 @@ export const AccountActivationModal: React.FC<AccountActivationModalProps> = ({
|
||||
);
|
||||
}
|
||||
// If result.success is true, AuthProvider updates state, and this modal unmounts automatically!
|
||||
} catch (err: any) {
|
||||
if (err?.response?.status === 401) {
|
||||
} catch (err: unknown) {
|
||||
const errObj = err as { response?: { status?: number }; message?: string };
|
||||
if (errObj?.response?.status === 401) {
|
||||
await onLogout();
|
||||
navigate('/', { replace: true });
|
||||
return;
|
||||
}
|
||||
setErrorMessage(
|
||||
err?.message ||
|
||||
errObj?.message ||
|
||||
'Account activation check encountered an error. Please confirm with your administrator and try again.'
|
||||
);
|
||||
} finally {
|
||||
|
||||
@@ -169,8 +169,8 @@ export const FundingInstrumentsSection: React.FC<FundingInstrumentsSectionProps>
|
||||
setEditingInstrument(null);
|
||||
if (onCloseCreateModalExternal) onCloseCreateModalExternal();
|
||||
},
|
||||
onError: (err: any) => {
|
||||
toastError('Save Failed', err?.message || 'Failed to save funding instrument');
|
||||
onError: (err: unknown) => {
|
||||
toastError('Save Failed', err);
|
||||
},
|
||||
});
|
||||
|
||||
|
||||
@@ -166,8 +166,8 @@ export const RulesSection: React.FC<RulesSectionProps> = ({
|
||||
setEditingRule(null);
|
||||
if (onCloseCreateModalExternal) onCloseCreateModalExternal();
|
||||
},
|
||||
onError: (err: any) => {
|
||||
toastError('Save Failed', err?.message || 'Failed to save programme rule');
|
||||
onError: (err: unknown) => {
|
||||
toastError('Save Failed', err);
|
||||
},
|
||||
});
|
||||
|
||||
|
||||
@@ -527,9 +527,6 @@ export const AppLayout: React.FC = () => {
|
||||
{userRole}
|
||||
</span>
|
||||
</div>
|
||||
<p className="text-[11px] text-slate-400 font-mono truncate max-w-[200px]" title={tokenUserEmail}>
|
||||
{tokenUserEmail}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<button
|
||||
|
||||
@@ -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 ? (
|
||||
|
||||
@@ -24,6 +24,7 @@ import {
|
||||
FileSpreadsheet,
|
||||
FileCheck,
|
||||
RefreshCw,
|
||||
Lock,
|
||||
} from 'lucide-react';
|
||||
import {
|
||||
getAllEvidence,
|
||||
@@ -362,8 +363,8 @@ export const EvidenceReviewQueue: React.FC<EvidenceReviewQueueProps> = ({
|
||||
}
|
||||
|
||||
success('Download Initiated', `Opening official file: ${ev.fileName || `evidence_${ev.id}`}`);
|
||||
} catch (err: any) {
|
||||
toastError('Download Failed', err?.message || 'Unable to retrieve file link');
|
||||
} catch (err: unknown) {
|
||||
toastError('Download Failed', err);
|
||||
} finally {
|
||||
setIsDownloading(null);
|
||||
}
|
||||
@@ -800,57 +801,65 @@ export const EvidenceReviewQueue: React.FC<EvidenceReviewQueueProps> = ({
|
||||
<span>Inspect</span>
|
||||
</button>
|
||||
|
||||
{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"
|
||||
{ev.status === ApprovalStatus.Approved ? (
|
||||
<span
|
||||
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"
|
||||
title="Approved evidence is immutable. Status cannot be modified."
|
||||
>
|
||||
<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 ${
|
||||
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)
|
||||
<Lock className="w-3.5 h-3.5 text-emerald-400" />
|
||||
<span>Approved (Immutable)</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
|
||||
|
||||
@@ -74,7 +74,7 @@ export const ProjectActivitiesModal: React.FC<ProjectActivitiesModalProps> = ({
|
||||
});
|
||||
|
||||
const form = useForm<ActivityFormValues>({
|
||||
resolver: zodResolver(activitySchema) as any,
|
||||
resolver: zodResolver(activitySchema),
|
||||
defaultValues: {
|
||||
title: '',
|
||||
description: '',
|
||||
@@ -226,7 +226,7 @@ export const ProjectActivitiesModal: React.FC<ProjectActivitiesModalProps> = ({
|
||||
</button>
|
||||
</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>
|
||||
<label className="block text-xs font-semibold text-slate-300 mb-1">Activity Title *</label>
|
||||
<input
|
||||
|
||||
@@ -47,7 +47,7 @@ export const ProjectFormModal: React.FC<ProjectFormModalProps> = ({
|
||||
const { success, error: toastError } = useToast();
|
||||
|
||||
const form = useForm<ProjectFormValues>({
|
||||
resolver: zodResolver(projectSchema) as any,
|
||||
resolver: zodResolver(projectSchema),
|
||||
defaultValues: {
|
||||
name: '',
|
||||
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."
|
||||
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 className="flex items-center gap-1 mb-1">
|
||||
<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>({
|
||||
resolver: zodResolver(indicatorSchema) as any,
|
||||
resolver: zodResolver(indicatorSchema),
|
||||
defaultValues: {
|
||||
name: '',
|
||||
unitOfMeasure: UnitOfMeasure.Count,
|
||||
@@ -173,7 +173,7 @@ export const ProjectIndicatorsModal: React.FC<ProjectIndicatorsModalProps> = ({
|
||||
</button>
|
||||
</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>
|
||||
<label className="block text-xs font-semibold text-slate-300 mb-1">Indicator Definition *</label>
|
||||
<input
|
||||
|
||||
@@ -15,6 +15,7 @@ import {
|
||||
TrendingUp,
|
||||
X,
|
||||
RotateCcw,
|
||||
Lock,
|
||||
} from 'lucide-react';
|
||||
import { Modal } from '../ui/Modal';
|
||||
import { HelpTooltip } from '../ui/HelpTooltip';
|
||||
@@ -74,7 +75,7 @@ export const ProjectMilestonesModal: React.FC<ProjectMilestonesModalProps> = ({
|
||||
});
|
||||
|
||||
const form = useForm<MilestoneFormValues>({
|
||||
resolver: zodResolver(milestoneSchema) as any,
|
||||
resolver: zodResolver(milestoneSchema),
|
||||
defaultValues: {
|
||||
name: '',
|
||||
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>
|
||||
</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>
|
||||
<label className="block text-xs font-semibold text-slate-300 mb-1">
|
||||
Deliverable Name / Milestone Title *
|
||||
@@ -257,33 +258,40 @@ export const ProjectMilestonesModal: React.FC<ProjectMilestonesModalProps> = ({
|
||||
{editingMilestone && (
|
||||
<div>
|
||||
<label className="block text-xs font-semibold text-slate-300 mb-1">Approval / Verification Status</label>
|
||||
{(() => {
|
||||
const canApproveMilestone = canApproveRecord('projects_milestones', editingMilestone.createdBy);
|
||||
const isSelf = isMaker(editingMilestone.createdBy);
|
||||
{Number(editingMilestone.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 & Verified (Immutable)</span>
|
||||
</div>
|
||||
) : (
|
||||
(() => {
|
||||
const canApproveMilestone = canApproveRecord('projects_milestones', editingMilestone.createdBy);
|
||||
const isSelf = isMaker(editingMilestone.createdBy);
|
||||
|
||||
return (
|
||||
<select
|
||||
{...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"
|
||||
>
|
||||
{APPROVAL_STATUS_OPTIONS.map((opt) => {
|
||||
const isApprovedOpt = opt.value === ApprovalStatus.Approved;
|
||||
const isOptDisabled = isApprovedOpt && !canApproveMilestone;
|
||||
let reasonText = '';
|
||||
if (isApprovedOpt && !canApprove('projects_milestones')) {
|
||||
reasonText = ' (Requires Update Permission)';
|
||||
} else if (isApprovedOpt && 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
|
||||
{...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"
|
||||
>
|
||||
{APPROVAL_STATUS_OPTIONS.map((opt) => {
|
||||
const isApprovedOpt = opt.value === ApprovalStatus.Approved;
|
||||
const isOptDisabled = isApprovedOpt && !canApproveMilestone;
|
||||
let reasonText = '';
|
||||
if (isApprovedOpt && !canApprove('projects_milestones')) {
|
||||
reasonText = ' (Requires Update Permission)';
|
||||
} else if (isApprovedOpt && isSelf) {
|
||||
reasonText = ' (Maker-Checker: Cannot approve own record)';
|
||||
}
|
||||
return (
|
||||
<option key={opt.value} value={opt.value} disabled={isOptDisabled}>
|
||||
{opt.label}{reasonText}
|
||||
</option>
|
||||
);
|
||||
})}
|
||||
</select>
|
||||
);
|
||||
})()
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
|
||||
@@ -67,7 +67,7 @@ export const ProjectRisksModal: React.FC<ProjectRisksModalProps> = ({
|
||||
});
|
||||
|
||||
const form = useForm<RiskFormValues>({
|
||||
resolver: zodResolver(riskSchema) as any,
|
||||
resolver: zodResolver(riskSchema),
|
||||
defaultValues: {
|
||||
name: '',
|
||||
description: '',
|
||||
@@ -202,7 +202,7 @@ export const ProjectRisksModal: React.FC<ProjectRisksModalProps> = ({
|
||||
</button>
|
||||
</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>
|
||||
<label className="block text-xs font-semibold text-slate-300 mb-1">Risk Hazard Title *</label>
|
||||
<input
|
||||
|
||||
@@ -21,6 +21,7 @@ import {
|
||||
Sparkles,
|
||||
ExternalLink,
|
||||
SlidersHorizontal,
|
||||
Lock,
|
||||
} from 'lucide-react';
|
||||
import { Modal } from '../ui/Modal';
|
||||
import { HelpTooltip } from '../ui/HelpTooltip';
|
||||
@@ -282,36 +283,46 @@ export const ProjectViewModal: React.FC<ProjectViewModalProps> = ({
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Real-time Governance Status Dropdown */}
|
||||
<div className="w-36">
|
||||
<select
|
||||
value={project.status}
|
||||
disabled={!canModifyStatus}
|
||||
onChange={(e) =>
|
||||
statusMutation.mutate({
|
||||
projectId: project.id,
|
||||
status: Number(e.target.value) as ApprovalStatus,
|
||||
})
|
||||
}
|
||||
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"
|
||||
title="Project Governance Clearance Status"
|
||||
>
|
||||
{APPROVAL_STATUS_OPTIONS.map((opt) => {
|
||||
const isApprovedOpt = opt.value === ApprovalStatus.Approved;
|
||||
const isOptDisabled = isApprovedOpt && !canApproveThis;
|
||||
let reasonText = '';
|
||||
if (isApprovedOpt && !canApprove('projects_milestones')) {
|
||||
reasonText = ' (Requires Update)';
|
||||
} else if (isApprovedOpt && isSelf) {
|
||||
reasonText = ' (Maker-Checker)';
|
||||
{/* Real-time Governance Status Dropdown / Immutable Locked State */}
|
||||
<div className="min-w-[140px]">
|
||||
{Number(project.status) === ApprovalStatus.Approved ? (
|
||||
<div
|
||||
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"
|
||||
title="Approved records are immutable. Status cannot be modified."
|
||||
>
|
||||
<Lock className="w-3.5 h-3.5 text-emerald-400" />
|
||||
<span>Approved (Locked)</span>
|
||||
</div>
|
||||
) : (
|
||||
<select
|
||||
value={project.status}
|
||||
disabled={!canModifyStatus}
|
||||
onChange={(e) =>
|
||||
statusMutation.mutate({
|
||||
projectId: project.id,
|
||||
status: Number(e.target.value) as ApprovalStatus,
|
||||
})
|
||||
}
|
||||
return (
|
||||
<option key={opt.value} value={opt.value} disabled={isOptDisabled}>
|
||||
{opt.label}{reasonText}
|
||||
</option>
|
||||
);
|
||||
})}
|
||||
</select>
|
||||
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"
|
||||
title="Project Governance Clearance Status"
|
||||
>
|
||||
{APPROVAL_STATUS_OPTIONS.map((opt) => {
|
||||
const isApprovedOpt = opt.value === ApprovalStatus.Approved;
|
||||
const isOptDisabled = isApprovedOpt && !canApproveThis;
|
||||
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>
|
||||
|
||||
{/* Action Buttons */}
|
||||
|
||||
@@ -99,7 +99,7 @@ export class ErrorBoundary extends React.Component<Props, State> {
|
||||
}
|
||||
|
||||
public handleReset = () => {
|
||||
(this as any).setState({ hasError: false, error: null });
|
||||
this.setState({ hasError: false, error: null });
|
||||
};
|
||||
|
||||
public render() {
|
||||
|
||||
@@ -81,8 +81,8 @@ export function ExportButton<T>({
|
||||
filename,
|
||||
columns,
|
||||
data,
|
||||
tenantName: (claims as any)?.tenant_name || (claims as any)?.tenant || `Tenant #${tenantId || 1}`,
|
||||
userEmail: (user as any)?.profile?.email || user?.email || (claims as any)?.email || '',
|
||||
tenantName: (claims?.tenant_name as string) || (claims?.tenant as string) || `Tenant #${tenantId || 1}`,
|
||||
userEmail: user?.email || (claims?.email as string) || '',
|
||||
});
|
||||
};
|
||||
|
||||
@@ -95,8 +95,8 @@ export function ExportButton<T>({
|
||||
filename,
|
||||
columns,
|
||||
data,
|
||||
tenantName: (claims as any)?.tenant_name || (claims as any)?.tenant || `Tenant #${tenantId || 1}`,
|
||||
userEmail: (user as any)?.profile?.email || user?.email || (claims as any)?.email || '',
|
||||
tenantName: (claims?.tenant_name as string) || (claims?.tenant as string) || `Tenant #${tenantId || 1}`,
|
||||
userEmail: user?.email || (claims?.email as string) || '',
|
||||
});
|
||||
};
|
||||
|
||||
|
||||
@@ -165,7 +165,7 @@ export const CreateUserModal: React.FC<CreateUserModalProps> = ({
|
||||
queryClient.invalidateQueries({ queryKey: ['users-directory'] });
|
||||
onClose();
|
||||
},
|
||||
onError: (err: any) => {
|
||||
onError: (err: unknown) => {
|
||||
toastError('User Creation Failed', err);
|
||||
},
|
||||
});
|
||||
|
||||
@@ -117,8 +117,8 @@ export const UserSecurityModal: React.FC<UserSecurityModalProps> = ({
|
||||
return null;
|
||||
}, [userRoles, userRoleStr, systemRoles]);
|
||||
|
||||
const assignedRoleId = assignedRole?.id || (assignedRole as any)?.Id || '';
|
||||
const assignedRoleName = assignedRole?.name || (assignedRole as any)?.Name || '';
|
||||
const assignedRoleId = assignedRole?.id || assignedRole?.Id || '';
|
||||
const assignedRoleName = assignedRole?.name || assignedRole?.Name || '';
|
||||
|
||||
// 4. Fetch claims derived from the user's role by hitting the endpoint
|
||||
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.');
|
||||
}
|
||||
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) {
|
||||
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'] });
|
||||
setSelectedRoleToAdd('');
|
||||
},
|
||||
onError: (err: any) => toastError('Role Assignment Failed', err),
|
||||
onError: (err: unknown) => toastError('Role Assignment Failed', err),
|
||||
});
|
||||
|
||||
const removeRoleMutation = useMutation({
|
||||
@@ -227,14 +227,14 @@ export const UserSecurityModal: React.FC<UserSecurityModalProps> = ({
|
||||
refetchRoleClaims();
|
||||
queryClient.invalidateQueries({ queryKey: ['users'] });
|
||||
},
|
||||
onError: (err: any) => toastError('Role Removal Failed', err),
|
||||
onError: (err: unknown) => toastError('Role Removal Failed', err),
|
||||
});
|
||||
|
||||
// Session & Lockout Mutations
|
||||
const logoutMutation = useMutation({
|
||||
mutationFn: () => forceLogoutUser(userId),
|
||||
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({
|
||||
@@ -246,7 +246,7 @@ export const UserSecurityModal: React.FC<UserSecurityModalProps> = ({
|
||||
);
|
||||
refetchUser();
|
||||
},
|
||||
onError: (err: any) => toastError('Lockout Update Failed', err),
|
||||
onError: (err: unknown) => toastError('Lockout Update Failed', err),
|
||||
});
|
||||
|
||||
const statusMutation = useMutation({
|
||||
@@ -256,14 +256,14 @@ export const UserSecurityModal: React.FC<UserSecurityModalProps> = ({
|
||||
refetchUser();
|
||||
queryClient.invalidateQueries({ queryKey: ['users'] });
|
||||
},
|
||||
onError: (err: any) => toastError('Status Update Failed', err),
|
||||
onError: (err: unknown) => toastError('Status Update Failed', err),
|
||||
});
|
||||
|
||||
const availableRoles = React.useMemo(() => {
|
||||
return (Array.isArray(systemRoles) ? systemRoles : []).filter((r) => {
|
||||
if (!r) return false;
|
||||
const rId = r.id || (r as any).Id;
|
||||
const rName = r.name || (r as any).Name || '';
|
||||
const rId = r.id || r.Id;
|
||||
const rName = r.name || r.Name || '';
|
||||
if (hasTenantSelfHelpAccess && (rName.toLowerCase() === 'admin' || String(rId) === '1')) {
|
||||
return false;
|
||||
}
|
||||
@@ -446,7 +446,7 @@ export const UserSecurityModal: React.FC<UserSecurityModalProps> = ({
|
||||
<option value="">
|
||||
{availableRoles.length === 0 ? 'No available roles' : 'Select role to assign...'}
|
||||
</option>
|
||||
{availableRoles.map((r: any) => {
|
||||
{availableRoles.map((r) => {
|
||||
const roleOptId = r?.id || r?.Id || String(r);
|
||||
const roleOptName = r?.name || r?.Name || String(r);
|
||||
return (
|
||||
@@ -485,8 +485,8 @@ export const UserSecurityModal: React.FC<UserSecurityModalProps> = ({
|
||||
) : (
|
||||
(Array.isArray(userRoles) ? userRoles : []).map((role) => {
|
||||
if (!role) return null;
|
||||
const roleId = role.id || (role as any).Id || String(role);
|
||||
const roleName = role.name || (role as any).Name || String(role);
|
||||
const roleId = role.id || role.Id || String(role);
|
||||
const roleName = role.name || role.Name || String(role);
|
||||
return (
|
||||
<div key={roleId} className="p-3.5 flex items-center justify-between gap-3">
|
||||
<div className="min-w-0">
|
||||
|
||||
Reference in New Issue
Block a user