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
@@ -20,6 +20,7 @@ import {
Sparkles,
MapPin,
FileCheck,
Lock,
} from 'lucide-react';
import {
getProjects,
@@ -194,7 +195,7 @@ export const BeneficiariesPage: React.FC = () => {
benForm.reset();
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({
@@ -216,7 +217,7 @@ export const BeneficiariesPage: React.FC = () => {
benForm.reset();
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({
@@ -231,7 +232,7 @@ export const BeneficiariesPage: React.FC = () => {
compForm.reset();
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({
@@ -251,7 +252,7 @@ export const BeneficiariesPage: React.FC = () => {
compForm.reset();
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({
@@ -265,7 +266,7 @@ export const BeneficiariesPage: React.FC = () => {
outcomeForm.reset();
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
@@ -1020,30 +1021,40 @@ export const BeneficiariesPage: React.FC = () => {
{editingComp && (
<div>
<label className="block text-xs font-semibold text-slate-300 mb-1">Verification Status</label>
<select
{...compForm.register('status')}
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"
>
{(() => {
const canApproveComp = canApproveRecord('beneficiaries', editingComp?.createdBy);
const isSelf = isMaker(editingComp?.createdBy);
return APPROVAL_STATUS_OPTIONS.map((opt) => {
const isApprovedOpt = opt.value === ApprovalStatus.Approved;
const isOptDisabled = isApprovedOpt && !canApproveComp;
let reason = '';
if (isApprovedOpt && !canApprove('beneficiaries')) {
reason = ' (Requires Update Permission)';
} else if (isApprovedOpt && isSelf) {
reason = ' (Maker-Checker: Cannot approve own record)';
}
return (
<option key={opt.value} value={opt.value} disabled={isOptDisabled}>
{opt.label}{reason}
</option>
);
});
})()}
</select>
{Number(editingComp.status) === ApprovalStatus.Approved ? (
<div
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."
>
<Lock className="w-3.5 h-3.5 text-emerald-400" />
<span>Approved (Immutable - Cannot Be Changed)</span>
</div>
) : (
<select
{...compForm.register('status')}
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"
>
{(() => {
const canApproveComp = canApproveRecord('beneficiaries', editingComp?.createdBy);
const isSelf = isMaker(editingComp?.createdBy);
return APPROVAL_STATUS_OPTIONS.map((opt) => {
const isApprovedOpt = opt.value === ApprovalStatus.Approved;
const isOptDisabled = isApprovedOpt && !canApproveComp;
let reason = '';
if (isApprovedOpt && !canApprove('beneficiaries')) {
reason = ' (Requires Update Permission)';
} else if (isApprovedOpt && isSelf) {
reason = ' (Maker-Checker: Cannot approve own record)';
}
return (
<option key={opt.value} value={opt.value} disabled={isOptDisabled}>
{opt.label}{reason}
</option>
);
});
})()}
</select>
)}
</div>
)}
@@ -20,6 +20,7 @@ import {
User,
Clock,
ArrowRight,
Lock,
} from 'lucide-react';
import {
getProjects,
@@ -238,7 +239,7 @@ export const ChangeManagementPage: React.FC = () => {
issueForm.reset();
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({
@@ -273,7 +274,7 @@ export const ChangeManagementPage: React.FC = () => {
editIssueForm.reset();
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({
@@ -297,7 +298,7 @@ export const ChangeManagementPage: React.FC = () => {
resolveIssueForm.reset();
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({
@@ -317,7 +318,7 @@ export const ChangeManagementPage: React.FC = () => {
changeForm.reset();
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({
@@ -338,12 +339,15 @@ export const ChangeManagementPage: React.FC = () => {
resolveChangeForm.reset();
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({
mutationFn: (vals: EditChangeFormValues) => {
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({
changeRequestId: editingChange.id,
title: vals.title,
@@ -359,7 +363,7 @@ export const ChangeManagementPage: React.FC = () => {
editChangeForm.reset();
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
@@ -481,6 +485,10 @@ export const ChangeManagementPage: React.FC = () => {
};
const openEditChange = async (cr: ChangeRequest) => {
if (Number(cr.status) === ApprovalStatus.Approved) {
toastError('Record Immutable', 'Approved and enacted change requests are immutable.');
return;
}
try {
// 1. Uses GET method (NOT POST)
// 2. Passes ChangeRequestId (NOT ProjectId)
@@ -1009,19 +1017,20 @@ export const ChangeManagementPage: React.FC = () => {
</td>
<td className="py-3.5 px-5 text-right whitespace-nowrap space-x-2">
<div className="inline-flex items-center gap-1.5">
<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>
{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
{Number(cr.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 rounded-lg border border-emerald-500/20 cursor-default" title="Approved and enacted change requests are immutable.">
<Lock className="w-3 h-3 text-emerald-400" />
<span>Resolved &amp; Enacted (Immutable)</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) && (
<button
onClick={() => openResolveChange(cr, ApprovalStatus.Approved)}
@@ -1035,14 +1044,6 @@ export const ChangeManagementPage: React.FC = () => {
Maker-Checker
</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>
@@ -1521,29 +1522,39 @@ export const ChangeManagementPage: React.FC = () => {
<div>
<label className="block text-xs font-semibold text-slate-300 mb-1">Governance Status *</label>
<select
{...editChangeForm.register('status')}
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"
>
<option value={ApprovalStatus.Pending}>Pending Review</option>
<option value={ApprovalStatus.Review}>Under Active Review</option>
{(() => {
const canApproveChange = canApproveRecord('change_management', editingChange?.createdBy);
const isSelf = isMaker(editingChange?.createdBy);
let reason = '';
if (!canApprove('change_management')) {
reason = ' (Requires Update Permission)';
} else if (isSelf) {
reason = ' (Maker-Checker: Cannot approve own record)';
}
return (
<option value={ApprovalStatus.Approved} disabled={!canApproveChange}>
Approved &amp; Enacted {reason}
</option>
);
})()}
<option value={ApprovalStatus.Rejected}>Rejected</option>
</select>
{editingChange && Number(editingChange.status) === ApprovalStatus.Approved ? (
<div
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."
>
<Lock className="w-3.5 h-3.5 text-emerald-400" />
<span>Approved &amp; Enacted (Immutable - Cannot Be Changed)</span>
</div>
) : (
<select
{...editChangeForm.register('status')}
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"
>
<option value={ApprovalStatus.Pending}>Pending Review</option>
<option value={ApprovalStatus.Review}>Under Active Review</option>
{(() => {
const canApproveChange = canApproveRecord('change_management', editingChange?.createdBy);
const isSelf = isMaker(editingChange?.createdBy);
let reason = '';
if (!canApprove('change_management')) {
reason = ' (Requires Update Permission)';
} else if (isSelf) {
reason = ' (Maker-Checker: Cannot approve own record)';
}
return (
<option value={ApprovalStatus.Approved} disabled={!canApproveChange}>
Approved &amp; Enacted {reason}
</option>
);
})()}
<option value={ApprovalStatus.Rejected}>Rejected</option>
</select>
)}
</div>
</div>
@@ -33,6 +33,17 @@ import { RiskHeatmapMatrix } from '../components/ui/RiskHeatmapMatrix';
import { HelpTooltip } from '../components/ui/HelpTooltip';
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 = () => {
const queryClient = useQueryClient();
const [selectedProgramme, setSelectedProgramme] = useState('All');
@@ -335,12 +346,12 @@ export const ExecutiveDashboard: React.FC = () => {
sortColumn: projectSortCol,
sortDirection: projectSortDir,
handleSort: handleProjectSort,
} = useTableSort<any>(attentionProjects, null, null, {
name: (p: any) => p.name,
programme: (p: any) => p.programme,
status: (p: any) => p.status,
risk: (p: any) => p.risk,
financialVariance: (p: any) => p.financialVariance,
} = useTableSort<AttentionProject>(attentionProjects, null, null, {
name: (p) => p.name,
programme: (p) => p.programme,
status: (p) => p.status,
risk: (p) => p.risk,
financialVariance: (p) => p.financialVariance,
});
return (
@@ -26,6 +26,7 @@ import {
ChevronUp,
CheckCircle2,
Loader2,
Lock,
} from 'lucide-react';
import {
getOrganisations,
@@ -339,8 +340,9 @@ export const GovernancePage: React.FC = () => {
setQuickUserPhone('');
setQuickUserIdNumber('');
setIsQuickAddUserOpen(false);
} catch (err: any) {
const errorDetail = err?.response?.data?.detail || err?.message || 'Failed to create user.';
} catch (err: unknown) {
const errObj = err as { response?: { data?: { detail?: string } }; message?: string };
const errorDetail = errObj?.response?.data?.detail || errObj?.message || 'Failed to create user.';
setQuickUserError(errorDetail);
toastError('Failed to create user', err);
} finally {
@@ -538,7 +540,7 @@ export const GovernancePage: React.FC = () => {
// Forms
const orgForm = useForm<OrganisationFormValues>({
resolver: zodResolver(organisationSchema) as any,
resolver: zodResolver(organisationSchema),
defaultValues: {
name: '',
registrationNo: '',
@@ -550,7 +552,7 @@ export const GovernancePage: React.FC = () => {
});
const portfolioForm = useForm<PortfolioFormValues>({
resolver: zodResolver(portfolioSchema) as any,
resolver: zodResolver(portfolioSchema),
defaultValues: {
name: '',
description: '',
@@ -560,7 +562,7 @@ export const GovernancePage: React.FC = () => {
});
const programmeForm = useForm<ProgrammeFormValues>({
resolver: zodResolver(programmeSchema) as any,
resolver: zodResolver(programmeSchema),
defaultValues: {
name: '',
portfolioId: portfolios[0]?.id ? Number(portfolios[0].id) : 201,
@@ -685,9 +687,9 @@ export const GovernancePage: React.FC = () => {
const targetUserId =
org.userId ||
(org as any).UserId ||
(org as any).organisationUser ||
(org as any).OrganisationUser ||
org.UserId ||
org.organisationUser ||
org.OrganisationUser ||
org.createdBy ||
'';
@@ -1988,33 +1990,43 @@ export const GovernancePage: React.FC = () => {
placement="top"
/>
</div>
{(() => {
const canApproveProgramme = canApproveRecord('governance', selectedProgrammeForDetail?.createdBy);
const isSelf = isMaker(selectedProgrammeForDetail?.createdBy);
{selectedProgrammeForDetail && Number(selectedProgrammeForDetail.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"
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 (
<select
{...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"
>
{APPROVAL_STATUS_OPTIONS.map((opt) => {
const isApprovedOpt = opt.value === ApprovalStatus.Approved;
const isOptDisabled = isApprovedOpt && !canApproveProgramme;
let reason = '';
if (isApprovedOpt && !canApprove('governance')) {
reason = ' (Requires Update Permission)';
} else if (isApprovedOpt && isSelf) {
reason = ' (Maker-Checker: Cannot approve own record)';
}
return (
<option key={opt.value} value={opt.value} disabled={isOptDisabled}>
{opt.label}{reason}
</option>
);
})}
</select>
);
})()}
return (
<select
{...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"
>
{APPROVAL_STATUS_OPTIONS.map((opt) => {
const isApprovedOpt = opt.value === ApprovalStatus.Approved;
const isOptDisabled = isApprovedOpt && !canApproveProgramme;
let reason = '';
if (isApprovedOpt && !canApprove('governance')) {
reason = ' (Requires Update Permission)';
} else if (isApprovedOpt && isSelf) {
reason = ' (Maker-Checker: Cannot approve own record)';
}
return (
<option key={opt.value} value={opt.value} disabled={isOptDisabled}>
{opt.label}{reason}
</option>
);
})}
</select>
);
})()
)}
</div>
<div>
@@ -25,6 +25,7 @@ import {
getAwards,
getAward,
createAward,
updateAward,
getBudgets,
getBudget,
createBudget,
@@ -52,6 +53,7 @@ import {
Disbursement,
ApprovalStatus,
CreateAwardRequest,
UpdateAwardRequest,
CreateBudgetRequest,
UpdateBudgetRequest,
CreateDisbursementRequest,
@@ -77,6 +79,15 @@ const awardSchema = z.object({
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({
projectId: z.coerce.number().min(1, 'Select project'),
name: z.string().min(3, 'Budget category name required'),
@@ -93,6 +104,7 @@ const disbursementSchema = z.object({
});
type AwardFormValues = z.infer<typeof awardSchema>;
type EditAwardFormValues = z.infer<typeof editAwardSchema>;
type BudgetFormValues = z.infer<typeof budgetSchema>;
type DisbursementFormValues = z.infer<typeof disbursementSchema>;
@@ -105,6 +117,9 @@ export const GrantsPage: React.FC = () => {
// Modals
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 [editingBudget, setEditingBudget] = useState<Budget | null>(null);
const [isEditBudgetModalOpen, setIsEditBudgetModalOpen] = useState(false);
@@ -165,7 +180,7 @@ export const GrantsPage: React.FC = () => {
// Forms
const awardForm = useForm<AwardFormValues>({
resolver: zodResolver(awardSchema) as any,
resolver: zodResolver(awardSchema),
defaultValues: {
referenceNo: '',
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>({
resolver: zodResolver(budgetSchema) as any,
resolver: zodResolver(budgetSchema),
defaultValues: {
projectId: projects[0]?.id ? Number(projects[0].id) : 401,
name: '',
@@ -186,7 +205,7 @@ export const GrantsPage: React.FC = () => {
});
const disbursementForm = useForm<DisbursementFormValues>({
resolver: zodResolver(disbursementSchema) as any,
resolver: zodResolver(disbursementSchema),
defaultValues: {
projectId: projects[0]?.id ? Number(projects[0].id) : 401,
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({
mutationFn: (data: CreateBudgetRequest) => createBudget(data),
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">
Date Registered
</SortableTh>
<th className="py-3.5 px-5 text-right font-semibold text-xs text-slate-400 uppercase tracking-wider">
Actions
</th>
</tr>
</thead>
<tbody className="divide-y divide-slate-800/50">
{loadingAwards ? (
<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...
</td>
</tr>
) : sortedAwards.length === 0 ? (
<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.'}
</td>
</tr>
@@ -878,6 +915,36 @@ export const GrantsPage: React.FC = () => {
<td className="py-3.5 px-5 text-right font-mono text-slate-400">
{formatDate(awd.createdAt)}
</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>
);
})
@@ -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-5 text-right">
<div className="flex items-center justify-end gap-1.5">
<button
type="button"
onClick={() => {
setEditingBudget(b);
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"
{Number(b.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 budget is immutable. Status cannot be modified."
>
<CheckCircle className="w-3 h-3" />
<span>Approve</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
<Lock className="w-3 h-3 text-emerald-400" />
<span>Approved (Immutable)</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>
</td>
@@ -1455,6 +1534,140 @@ export const GrantsPage: React.FC = () => {
</form>
</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 &amp; 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
isOpen={isBudgetModalOpen}
@@ -1463,7 +1676,7 @@ export const GrantsPage: React.FC = () => {
subtitle="Maintain approved expenditure breakdown and commitments"
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 className="flex items-center gap-1 mb-1.5">
<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">
Approval &amp; Compliance Status *
</label>
<select
value={selectedBudgetStatus}
onChange={(e) => setSelectedBudgetStatus(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"
>
{(() => {
const canApproveBudget = canApproveRecord('grant_financials', editingBudget?.createdBy);
const isSelf = isMaker(editingBudget?.createdBy);
let reason = '';
if (!canApprove('grant_financials')) {
reason = ' (Requires Update Permission)';
} else if (isSelf) {
reason = ' (Maker-Checker: Cannot approve own record)';
}
return (
<option value={ApprovalStatus.Approved} disabled={!canApproveBudget}>
Approved {reason}
</option>
);
})()}
<option value={ApprovalStatus.Pending}>Pending Review</option>
<option value={ApprovalStatus.Rejected}>Rejected / Blocked</option>
</select>
{Number(editingBudget?.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 - Status Cannot Be Changed)</span>
</div>
) : (
<select
value={selectedBudgetStatus}
onChange={(e) => setSelectedBudgetStatus(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"
>
{(() => {
const canApproveBudget = canApproveRecord('grant_financials', editingBudget?.createdBy);
const isSelf = isMaker(editingBudget?.createdBy);
let reason = '';
if (!canApprove('grant_financials')) {
reason = ' (Requires Update Permission)';
} else if (isSelf) {
reason = ' (Maker-Checker: Cannot approve own record)';
}
return (
<option value={ApprovalStatus.Approved} disabled={!canApproveBudget}>
Approved {reason}
</option>
);
})()}
<option value={ApprovalStatus.Pending}>Pending Review</option>
<option value={ApprovalStatus.Rejected}>Rejected / Blocked</option>
</select>
)}
</div>
<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
type="button"
disabled={updateBudgetMutation.isPending || !editingBudget}
disabled={updateBudgetMutation.isPending || !editingBudget || Number(editingBudget.status) === ApprovalStatus.Approved}
onClick={() => {
if (editingBudget) {
updateBudgetMutation.mutate({
@@ -105,18 +105,18 @@ export const MonitoringPage: React.FC = () => {
const [searchParams, setSearchParams] = useSearchParams();
const tabParam = searchParams.get('tab');
const [activeTab, setActiveTab] = useState<'contracts' | 'evidence' | 'indicators' | 'risks'>(
tabParam === 'evidence' || tabParam === 'indicators' || tabParam === 'risks' ? tabParam : 'contracts'
const [activeTab, setActiveTab] = useState<'contracts' | 'evidence' | 'risks'>(
tabParam === 'evidence' || tabParam === 'risks' ? tabParam : 'contracts'
);
useEffect(() => {
const t = searchParams.get('tab');
if (t === 'evidence' || t === 'indicators' || t === 'risks' || t === 'contracts') {
if (t === 'evidence' || t === 'risks' || t === 'contracts') {
setActiveTab(t);
}
}, [searchParams]);
const handleTabChange = (tab: 'contracts' | 'evidence' | 'indicators' | 'risks') => {
const handleTabChange = (tab: 'contracts' | 'evidence' | 'risks') => {
setActiveTab(tab);
setSearchParams((prev) => {
const next = new URLSearchParams(prev);
@@ -176,7 +176,7 @@ export const MonitoringPage: React.FC = () => {
// Forms
const indicatorForm = useForm<IndicatorFormValues>({
resolver: zodResolver(indicatorSchema) as any,
resolver: zodResolver(indicatorSchema),
defaultValues: {
projectId: projects[0]?.id ? Number(projects[0].id) : 401,
name: '',
@@ -187,7 +187,7 @@ export const MonitoringPage: React.FC = () => {
});
const riskForm = useForm<RiskFormValues>({
resolver: zodResolver(riskSchema) as any,
resolver: zodResolver(riskSchema),
defaultValues: {
projectId: projects[0]?.id ? Number(projects[0].id) : 401,
name: '',
@@ -436,15 +436,7 @@ export const MonitoringPage: React.FC = () => {
</div>
<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') && (
<button
onClick={openCreateRisk}
@@ -493,23 +485,7 @@ export const MonitoringPage: React.FC = () => {
placement="top"
/>
</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
onClick={() => handleTabChange('risks')}
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">&lt;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 &amp; 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) */}
{activeTab === 'risks' && (
<div className="space-y-6">
@@ -970,7 +735,7 @@ export const MonitoringPage: React.FC = () => {
subtitle="Logical Framework & Theory of Change metric"
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 className="flex items-center gap-1 mb-1.5">
<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'}
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 && (
<div>
<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.');
queryClient.invalidateQueries({ queryKey: ['users'] });
},
onError: (err: any) => {
onError: (err: unknown) => {
toastError('Sync Failed', err);
},
});
@@ -203,7 +203,7 @@ export const SettingsPage: React.FC = () => {
success('Backend Integration Synced', 'System records and telemetry successfully synchronized.');
queryClient.invalidateQueries({ queryKey: ['users'] });
},
onError: (err: any) => {
onError: (err: unknown) => {
toastError('Integration Sync Failed', err);
},
});
@@ -218,7 +218,7 @@ export const SettingsPage: React.FC = () => {
setSelectedUserForDetail({ ...selectedUserForDetail, status: vars.status });
}
},
onError: (err: any) => {
onError: (err: unknown) => {
toastError('Update Failed', err);
},
});
@@ -287,7 +287,7 @@ export const SettingsPage: React.FC = () => {
setInviteRecipientUuid('');
setInviteMessage('');
},
onError: (err: any) => {
onError: (err: unknown) => {
toastError('Dispatch Error', err);
},
});
@@ -19,6 +19,7 @@ import {
ShieldCheck,
Building,
Send,
Lock,
} from 'lucide-react';
import {
getProjects,
@@ -114,12 +115,15 @@ export const SiteVisitsPage: React.FC = () => {
visitForm.reset();
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({
mutationFn: (vals: SiteVisitFormValues) => {
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({
siteVisitId: editingVisit.id,
inspectorName: vals.inspectorName,
@@ -135,7 +139,7 @@ export const SiteVisitsPage: React.FC = () => {
visitForm.reset();
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
@@ -165,6 +169,10 @@ export const SiteVisitsPage: React.FC = () => {
];
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 {
const fresh = await getSiteVisit(v.id);
setEditingVisit(fresh);
@@ -446,13 +454,22 @@ export const SiteVisitsPage: React.FC = () => {
>
View & Feedback
</button>
<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>
{Number(v.status) === ApprovalStatus.Approved ? (
<span
className="p-1 text-emerald-400 cursor-default"
title="Approved site visits are verified and immutable."
>
<Lock className="w-3.5 h-3.5" />
</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>
</tr>
))
@@ -505,30 +522,40 @@ export const SiteVisitsPage: React.FC = () => {
{editingVisit && (
<div>
<label className="block text-xs font-semibold text-slate-300 mb-1">Verification Status</label>
<select
{...visitForm.register('status')}
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"
>
{(() => {
const canApproveVisit = canApproveRecord('site_visits', editingVisit?.createdBy);
const isSelf = isMaker(editingVisit?.createdBy);
return APPROVAL_STATUS_OPTIONS.map((opt) => {
const isApprovedOpt = opt.value === ApprovalStatus.Approved;
const isOptDisabled = isApprovedOpt && !canApproveVisit;
let reason = '';
if (isApprovedOpt && !canApprove('site_visits')) {
reason = ' (Requires Update Permission)';
} else if (isApprovedOpt && isSelf) {
reason = ' (Maker-Checker: Cannot approve own record)';
}
return (
<option key={opt.value} value={opt.value} disabled={isOptDisabled}>
{opt.label}{reason}
</option>
);
});
})()}
</select>
{Number(editingVisit.status) === ApprovalStatus.Approved ? (
<div
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."
>
<Lock className="w-3.5 h-3.5 text-emerald-400" />
<span>Approved &amp; Verified (Immutable - Cannot Be Changed)</span>
</div>
) : (
<select
{...visitForm.register('status')}
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"
>
{(() => {
const canApproveVisit = canApproveRecord('site_visits', editingVisit?.createdBy);
const isSelf = isMaker(editingVisit?.createdBy);
return APPROVAL_STATUS_OPTIONS.map((opt) => {
const isApprovedOpt = opt.value === ApprovalStatus.Approved;
const isOptDisabled = isApprovedOpt && !canApproveVisit;
let reason = '';
if (isApprovedOpt && !canApprove('site_visits')) {
reason = ' (Requires Update Permission)';
} else if (isApprovedOpt && isSelf) {
reason = ' (Maker-Checker: Cannot approve own record)';
}
return (
<option key={opt.value} value={opt.value} disabled={isOptDisabled}>
{opt.label}{reason}
</option>
);
});
})()}
</select>
)}
</div>
)}