Improved compliance monitoring

This commit is contained in:
2026-10-22 15:42:36 +02:00
parent 9835121fd9
commit 299d6ff4dc
27 changed files with 4245 additions and 149 deletions
@@ -42,9 +42,9 @@ import { ExportButton } from '../components/ui/ExportButton';
import { ExportColumn } from '../utils/exportUtils';
import { useTableSort, SortableTh } from '../utils/tableSort';
import { useRbac } from '../auth/rbac';
import { ApprovalStatusBadge } from '../components/ui/StatusBadge';
import { Beneficiary, ComplianceItem, Outcome, ApprovalStatus } from '../types';
import { getApprovalStatusBadge, formatDate, APPROVAL_STATUS_OPTIONS } from '../lib/formatters';
import { ApprovalStatusBadge, ComplianceStatusBadge } from '../components/ui/StatusBadge';
import { Beneficiary, ComplianceItem, Outcome, ApprovalStatus, ComplianceStatus } from '../types';
import { getApprovalStatusBadge, getComplianceStatusBadge, formatDate, APPROVAL_STATUS_OPTIONS, COMPLIANCE_STATUS_OPTIONS } from '../lib/formatters';
const beneficiarySchema = z.object({
name: z.string().min(2, 'Beneficiary name required'),
@@ -58,11 +58,13 @@ const complianceSchema = z.object({
title: z.string().min(3, 'Compliance title required'),
requirements: z.string().min(5, 'Requirements detail required'),
status: z.coerce.number().optional(),
beneficiaryId: z.string().optional(),
});
const outcomeSchema = z.object({
name: z.string().min(3, 'Outcome name required'),
description: z.string().min(5, 'Description required'),
status: z.coerce.number().optional(),
});
type BeneficiaryFormValues = z.infer<typeof beneficiarySchema>;
@@ -224,7 +226,13 @@ export const BeneficiariesPage: React.FC = () => {
mutationFn: (vals: ComplianceFormValues) => {
const targetProjId = compTargetProjectId || (selectedProjectId !== 'all' ? selectedProjectId : projects[0]?.id);
if (!targetProjId) throw new Error('No project selected');
return createComplianceItem({ projectId: targetProjId, title: vals.title, requirements: vals.requirements });
return createComplianceItem({
projectId: targetProjId,
title: vals.title,
requirements: vals.requirements,
status: vals.status ?? ComplianceStatus.NotMet,
beneficiaryId: vals.beneficiaryId && vals.beneficiaryId !== 'none' ? Number(vals.beneficiaryId) : null,
});
},
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['complianceItems'] });
@@ -242,7 +250,8 @@ export const BeneficiariesPage: React.FC = () => {
complianceItemId: editingComp.id,
title: vals.title,
requirements: vals.requirements,
status: vals.status ?? ApprovalStatus.Pending,
status: vals.status ?? ComplianceStatus.NotMet,
beneficiaryId: vals.beneficiaryId && vals.beneficiaryId !== 'none' ? Number(vals.beneficiaryId) : null,
});
},
onSuccess: () => {
@@ -258,7 +267,12 @@ export const BeneficiariesPage: React.FC = () => {
const createOutcomeMutation = useMutation({
mutationFn: (vals: OutcomeFormValues) => {
if (!selectedBenForDetail) throw new Error('No beneficiary selected');
return createOutcome({ beneficiaryId: selectedBenForDetail.id, name: vals.name, description: vals.description });
return createOutcome({
beneficiaryId: selectedBenForDetail.id,
name: vals.name,
description: vals.description,
status: vals.status ?? ComplianceStatus.NotMet,
});
},
onSuccess: () => {
refetchOutcomes();
@@ -360,7 +374,8 @@ export const BeneficiariesPage: React.FC = () => {
compForm.reset({
title: fresh.title || '',
requirements: fresh.requirements || '',
status: fresh.status ?? ApprovalStatus.Pending,
status: fresh.status ?? ComplianceStatus.NotMet,
beneficiaryId: fresh.beneficiaryId ? String(fresh.beneficiaryId) : 'none',
});
setIsCompModalOpen(true);
} catch {
@@ -368,7 +383,8 @@ export const BeneficiariesPage: React.FC = () => {
compForm.reset({
title: c.title || '',
requirements: c.requirements || '',
status: c.status ?? ApprovalStatus.Pending,
status: c.status ?? ComplianceStatus.NotMet,
beneficiaryId: c.beneficiaryId ? String(c.beneficiaryId) : 'none',
});
setIsCompModalOpen(true);
}
@@ -868,7 +884,7 @@ export const BeneficiariesPage: React.FC = () => {
{c.requirements || 'None'}
</td>
<td className="py-3.5 px-4 whitespace-nowrap">
<ApprovalStatusBadge status={c.status} />
<ComplianceStatusBadge status={c.status} />
</td>
<td className="py-3.5 px-4 whitespace-nowrap text-slate-400">{formatDate(c.createdAt)}</td>
<td className="py-3.5 px-5 text-right whitespace-nowrap">
@@ -1018,45 +1034,36 @@ export const BeneficiariesPage: React.FC = () => {
/>
</div>
{editingComp && (
<div>
<label className="block text-xs font-semibold text-slate-300 mb-1">Verification Status</label>
{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>
)}
<div>
<label className="block text-xs font-semibold text-slate-300 mb-1">Linked Beneficiary (Optional)</label>
<select
{...compForm.register('beneficiaryId')}
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 cursor-pointer"
>
<option value="none">-- Project-Wide (All Beneficiaries) --</option>
{beneficiaries
.filter((b) => !compTargetProjectId || String(b.projectId) === String(compTargetProjectId))
.map((b) => (
<option key={b.id} value={b.id}>
{b.name} ({b.category})
</option>
))}
</select>
</div>
<div>
<label className="block text-xs font-semibold text-slate-300 mb-1">Compliance 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 cursor-pointer"
>
{COMPLIANCE_STATUS_OPTIONS.map((opt) => (
<option key={opt.value} value={opt.value}>
{opt.label}
</option>
))}
</select>
</div>
<div className="flex justify-end gap-3 pt-4 border-t border-slate-800">
<button
@@ -1194,6 +1201,20 @@ export const BeneficiariesPage: React.FC = () => {
/>
</div>
<div>
<label className="block text-xs font-semibold text-slate-300 mb-1">Fulfillment Status</label>
<select
{...outcomeForm.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 cursor-pointer"
>
{COMPLIANCE_STATUS_OPTIONS.map((opt) => (
<option key={opt.value} value={opt.value}>
{opt.label}
</option>
))}
</select>
</div>
<div className="flex justify-end gap-3 pt-4 border-t border-slate-800">
<button
type="button"