Fixed mismatched endpoint calling

This commit is contained in:
2026-09-27 14:07:44 +02:00
parent 8afceda1cd
commit df167caf55
4 changed files with 599 additions and 81 deletions
@@ -24,11 +24,13 @@ import {
import {
getProjects,
getProjectBeneficiaries,
getBeneficiary,
createBeneficiary,
updateBeneficiary,
getBeneficiaryOutcomes,
createOutcome,
getProjectComplianceItems,
getComplianceItem,
createComplianceItem,
updateComplianceItem,
} from '../services/api';
@@ -288,26 +290,50 @@ export const BeneficiariesPage: React.FC = () => {
{ header: 'Status', key: 'status', format: (val) => (val === ApprovalStatus.Approved ? 'Approved' : 'Pending Review') },
];
const handleOpenEditBen = (b: Beneficiary) => {
setEditingBen(b);
benForm.reset({
name: b.name || '',
category: b.category || 'General',
location: b.location || '',
targetCount: b.targetCount || 0,
reachedCount: b.reachedCount || 0,
});
setIsBenModalOpen(true);
const handleOpenEditBen = async (b: Beneficiary) => {
try {
const fresh = await getBeneficiary(b.id);
setEditingBen(fresh);
benForm.reset({
name: fresh.name || '',
category: fresh.category || 'General',
location: fresh.location || '',
targetCount: fresh.targetCount || 0,
reachedCount: fresh.reachedCount || 0,
});
setIsBenModalOpen(true);
} catch {
setEditingBen(b);
benForm.reset({
name: b.name || '',
category: b.category || 'General',
location: b.location || '',
targetCount: b.targetCount || 0,
reachedCount: b.reachedCount || 0,
});
setIsBenModalOpen(true);
}
};
const handleOpenEditComp = (c: ComplianceItem) => {
setEditingComp(c);
compForm.reset({
title: c.title || '',
requirements: c.requirements || '',
status: c.status ?? ApprovalStatus.Pending,
});
setIsCompModalOpen(true);
const handleOpenEditComp = async (c: ComplianceItem) => {
try {
const fresh = await getComplianceItem(c.id);
setEditingComp(fresh);
compForm.reset({
title: fresh.title || '',
requirements: fresh.requirements || '',
status: fresh.status ?? ApprovalStatus.Pending,
});
setIsCompModalOpen(true);
} catch {
setEditingComp(c);
compForm.reset({
title: c.title || '',
requirements: c.requirements || '',
status: c.status ?? ApprovalStatus.Pending,
});
setIsCompModalOpen(true);
}
};
return (
@@ -24,9 +24,11 @@ import {
import {
getProjects,
getProjectIssues,
getIssue,
createIssue,
updateIssue,
getProjectChangeRequests,
getChangeRequest,
createChangeRequest,
updateChangeRequest,
getUsers,
@@ -45,6 +47,7 @@ import {
formatZAR,
getUserEmail,
PRIORITY_OPTIONS,
APPROVAL_STATUS_OPTIONS,
} from '../lib/formatters';
const issueSchema = z.object({
@@ -54,6 +57,23 @@ const issueSchema = z.object({
assignedTo: z.string().min(1, 'Assignee is required'),
});
const editIssueSchema = z.object({
title: z.string().min(3, 'Issue title required'),
description: z.string().min(5, 'Description required'),
priority: z.coerce.number().min(1).max(4),
assignedTo: z.string().min(1, 'Assignee is required'),
status: z.coerce.number(),
resolution: z.string().min(1, 'Resolution notes required'),
resolvedAt: z.string().min(1, 'Resolution date and time required'),
});
const toDateTimeLocalValue = (dateString?: string | null): string => {
const d = dateString ? new Date(dateString) : new Date();
const validDate = isNaN(d.getTime()) ? new Date() : d;
const pad = (n: number) => n.toString().padStart(2, '0');
return `${validDate.getFullYear()}-${pad(validDate.getMonth() + 1)}-${pad(validDate.getDate())}T${pad(validDate.getHours())}:${pad(validDate.getMinutes())}`;
};
const resolveIssueSchema = z.object({
resolution: z.string().min(5, 'Resolution notes required'),
status: z.coerce.number(),
@@ -66,14 +86,23 @@ const changeRequestSchema = z.object({
revisionDate: z.string().min(4, 'Revision date required'),
});
const editChangeSchema = z.object({
title: z.string().min(3, 'Title required'),
justification: z.string().min(5, 'Justification required'),
revisedBudget: z.coerce.number().min(0, 'Revised budget must be non-negative'),
status: z.coerce.number(),
});
const resolveChangeSchema = z.object({
status: z.coerce.number(),
justification: z.string().min(3, 'Decision justification required'),
});
type IssueFormValues = z.infer<typeof issueSchema>;
type EditIssueFormValues = z.infer<typeof editIssueSchema>;
type ResolveIssueFormValues = z.infer<typeof resolveIssueSchema>;
type ChangeRequestFormValues = z.infer<typeof changeRequestSchema>;
type EditChangeFormValues = z.infer<typeof editChangeSchema>;
type ResolveChangeFormValues = z.infer<typeof resolveChangeSchema>;
export const ChangeManagementPage: React.FC = () => {
@@ -89,12 +118,14 @@ export const ChangeManagementPage: React.FC = () => {
// Modals
const [isIssueModalOpen, setIsIssueModalOpen] = useState(false);
const [isEditIssueModalOpen, setIsEditIssueModalOpen] = useState(false);
const [editingIssue, setEditingIssue] = useState<Issue | null>(null);
const [isResolveIssueModalOpen, setIsResolveIssueModalOpen] = useState(false);
const [resolvingIssue, setResolvingIssue] = useState<Issue | null>(null);
const [isChangeModalOpen, setIsChangeModalOpen] = useState(false);
const [isEditChangeModalOpen, setIsEditChangeModalOpen] = useState(false);
const [editingChange, setEditingChange] = useState<ChangeRequest | null>(null);
const [isResolveChangeModalOpen, setIsResolveChangeModalOpen] = useState(false);
@@ -141,6 +172,21 @@ export const ChangeManagementPage: React.FC = () => {
defaultValues: { title: '', description: '', priority: Priority.Medium, assignedTo: '' },
});
const editIssueForm = useForm<EditIssueFormValues>({
resolver: zodResolver(editIssueSchema),
defaultValues: {
title: '',
description: '',
priority: Priority.Medium,
assignedTo: '',
status: ActivityStatus.Active,
resolution: '',
resolvedAt: toDateTimeLocalValue(),
},
});
const watchedEditStatus = editIssueForm.watch('status');
const resolveIssueForm = useForm<ResolveIssueFormValues>({
resolver: zodResolver(resolveIssueSchema),
defaultValues: { resolution: '', status: ActivityStatus.Inactive },
@@ -156,6 +202,16 @@ export const ChangeManagementPage: React.FC = () => {
},
});
const editChangeForm = useForm<EditChangeFormValues>({
resolver: zodResolver(editChangeSchema),
defaultValues: {
title: '',
justification: '',
revisedBudget: 0,
status: ApprovalStatus.Pending,
},
});
const resolveChangeForm = useForm<ResolveChangeFormValues>({
resolver: zodResolver(resolveChangeSchema),
defaultValues: { status: ApprovalStatus.Approved, justification: '' },
@@ -183,25 +239,36 @@ export const ChangeManagementPage: React.FC = () => {
});
const updateIssueMutation = useMutation({
mutationFn: (vals: IssueFormValues) => {
mutationFn: (vals: EditIssueFormValues) => {
if (!editingIssue) throw new Error('No issue selected');
let isoResolvedAt: string;
try {
isoResolvedAt = vals.resolvedAt
? (vals.resolvedAt.includes('T') && vals.resolvedAt.endsWith('Z')
? vals.resolvedAt
: new Date(vals.resolvedAt).toISOString())
: new Date().toISOString();
} catch {
isoResolvedAt = new Date().toISOString();
}
return updateIssue({
issueId: editingIssue.id,
title: vals.title,
description: vals.description,
priority: vals.priority,
assignedTo: vals.assignedTo,
resolution: editingIssue.resolution || '',
resolvedAt: editingIssue.resolvedAt || null,
status: editingIssue.status || ActivityStatus.Active,
resolution: vals.resolution || (Number(vals.status) === ActivityStatus.Inactive ? 'Resolved' : 'Under active investigation'),
resolvedAt: isoResolvedAt,
status: Number(vals.status),
});
},
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['issues'] });
setIsIssueModalOpen(false);
setIsEditIssueModalOpen(false);
setEditingIssue(null);
issueForm.reset();
success('Issue Updated', 'Issue record saved.');
editIssueForm.reset();
success('Issue Updated', 'Issue record and resolution status saved successfully.');
},
onError: (err: any) => toastError('Update Failed', err.message || 'Unable to update issue.'),
});
@@ -271,6 +338,27 @@ export const ChangeManagementPage: React.FC = () => {
onError: (err: any) => toastError('Decision Failed', err.message || 'Unable to resolve change request.'),
});
const updateChangeMutation = useMutation({
mutationFn: (vals: EditChangeFormValues) => {
if (!editingChange) throw new Error('No change request selected');
return updateChangeRequest({
changeRequestId: editingChange.id,
title: vals.title,
justification: vals.justification,
revisedBudget: vals.revisedBudget,
status: Number(vals.status),
});
},
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['changeRequests'] });
setIsEditChangeModalOpen(false);
setEditingChange(null);
editChangeForm.reset();
success('Change Request Updated', 'Change proposal and governance status saved.');
},
onError: (err: any) => toastError('Update Failed', err.message || 'Unable to update change request.'),
});
// Filtered & Sorted Issues
const filteredIssues = useMemo(() => {
return issues.filter((iss) => {
@@ -332,6 +420,88 @@ export const ChangeManagementPage: React.FC = () => {
setIsResolveIssueModalOpen(true);
};
const openEditIssue = async (iss: Issue) => {
try {
const fresh = await getIssue(iss.id);
setEditingIssue(fresh);
const initialStatus =
fresh.status !== undefined && fresh.status !== null
? Number(fresh.status)
: fresh.resolvedAt
? ActivityStatus.Inactive
: ActivityStatus.Active;
const initialResolution =
fresh.resolution && fresh.resolution.trim() !== ''
? fresh.resolution
: initialStatus === ActivityStatus.Inactive
? 'Resolved and remediated'
: 'Remediation currently in progress';
editIssueForm.reset({
title: fresh.title || '',
description: fresh.description || '',
priority: fresh.priority || Priority.Medium,
assignedTo: fresh.assignedTo || '',
status: initialStatus,
resolution: initialResolution,
resolvedAt: toDateTimeLocalValue(fresh.resolvedAt),
});
setIsEditIssueModalOpen(true);
} catch {
setEditingIssue(iss);
const initialStatus =
iss.status !== undefined && iss.status !== null
? Number(iss.status)
: iss.resolvedAt
? ActivityStatus.Inactive
: ActivityStatus.Active;
const initialResolution =
iss.resolution && iss.resolution.trim() !== ''
? iss.resolution
: initialStatus === ActivityStatus.Inactive
? 'Resolved and remediated'
: 'Remediation currently in progress';
editIssueForm.reset({
title: iss.title || '',
description: iss.description || '',
priority: iss.priority || Priority.Medium,
assignedTo: iss.assignedTo || '',
status: initialStatus,
resolution: initialResolution,
resolvedAt: toDateTimeLocalValue(iss.resolvedAt),
});
setIsEditIssueModalOpen(true);
}
};
const openEditChange = async (cr: ChangeRequest) => {
try {
// 1. Uses GET method (NOT POST)
// 2. Passes ChangeRequestId (NOT ProjectId)
const freshData = await getChangeRequest(cr.id);
setEditingChange(freshData);
editChangeForm.reset({
title: freshData.title || '',
justification: freshData.justification || '',
revisedBudget: freshData.revisedBudget ?? 0,
status: freshData.status ?? ApprovalStatus.Pending,
});
setIsEditChangeModalOpen(true);
} catch {
setEditingChange(cr);
editChangeForm.reset({
title: cr.title || '',
justification: cr.justification || '',
revisedBudget: cr.revisedBudget ?? 0,
status: cr.status ?? ApprovalStatus.Pending,
});
setIsEditChangeModalOpen(true);
}
};
const openResolveChange = (cr: ChangeRequest, statusToApply: ApprovalStatus) => {
setResolvingChange(cr);
resolveChangeForm.reset({
@@ -619,16 +789,7 @@ export const ChangeManagementPage: React.FC = () => {
Resolve
</button>
<button
onClick={() => {
setEditingIssue(iss);
issueForm.reset({
title: iss.title || '',
description: iss.description || '',
priority: iss.priority || Priority.Medium,
assignedTo: iss.assignedTo || '',
});
setIsIssueModalOpen(true);
}}
onClick={() => openEditIssue(iss)}
className="p-1 text-slate-400 hover:text-white hover:bg-slate-800 rounded-lg transition cursor-pointer"
title="Edit issue"
>
@@ -791,26 +952,35 @@ export const ChangeManagementPage: React.FC = () => {
<ApprovalStatusBadge status={cr.status} />
</td>
<td className="py-3.5 px-5 text-right whitespace-nowrap space-x-2">
{cr.status === ApprovalStatus.Approved ? (
<span className="text-xs font-semibold text-emerald-400 bg-emerald-500/10 px-2.5 py-1 rounded-lg border border-emerald-500/20">
Resolved & Enacted
</span>
) : (
<div className="inline-flex items-center gap-1.5">
<button
onClick={() => openResolveChange(cr, ApprovalStatus.Approved)}
className="px-2.5 py-1 text-xs font-semibold text-emerald-300 bg-emerald-950/50 hover:bg-emerald-900/60 border border-emerald-700/50 rounded-lg transition cursor-pointer flex items-center gap-1"
>
<Check className="w-3 h-3" /> Approve
</button>
<button
onClick={() => openResolveChange(cr, ApprovalStatus.Rejected)}
className="px-2.5 py-1 text-xs font-semibold text-rose-300 bg-rose-950/50 hover:bg-rose-900/60 border border-rose-700/50 rounded-lg transition cursor-pointer flex items-center gap-1"
>
<XCircle className="w-3 h-3" /> Reject
</button>
</div>
)}
<div 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
</span>
) : (
<>
<button
onClick={() => openResolveChange(cr, ApprovalStatus.Approved)}
className="px-2.5 py-1 text-xs font-semibold text-emerald-300 bg-emerald-950/50 hover:bg-emerald-900/60 border border-emerald-700/50 rounded-lg transition cursor-pointer flex items-center gap-1"
>
<Check className="w-3 h-3" /> Approve
</button>
<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>
</td>
</tr>
))
@@ -823,15 +993,15 @@ export const ChangeManagementPage: React.FC = () => {
)}
</div>
{/* Modal: Create/Edit Issue */}
{/* Modal: Log Project Issue */}
<Modal
isOpen={isIssueModalOpen}
onClose={() => setIsIssueModalOpen(false)}
title={editingIssue ? 'Edit Project Issue' : 'Log Project Issue'}
title="Log Project Issue"
size="md"
>
<form
onSubmit={issueForm.handleSubmit((d) => (editingIssue ? updateIssueMutation.mutate(d) : createIssueMutation.mutate(d)))}
onSubmit={issueForm.handleSubmit((d) => createIssueMutation.mutate(d))}
className="space-y-4"
>
<div>
@@ -841,6 +1011,9 @@ export const ChangeManagementPage: React.FC = () => {
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"
placeholder="e.g. Procurement delays for regional solar supplies"
/>
{issueForm.formState.errors.title && (
<p className="text-[11px] text-rose-400 mt-1">{issueForm.formState.errors.title.message}</p>
)}
</div>
<div>
@@ -851,6 +1024,9 @@ export const ChangeManagementPage: React.FC = () => {
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"
placeholder="Detail root cause and operational impact..."
/>
{issueForm.formState.errors.description && (
<p className="text-[11px] text-rose-400 mt-1">{issueForm.formState.errors.description.message}</p>
)}
</div>
<div className="grid grid-cols-2 gap-4">
@@ -880,6 +1056,9 @@ export const ChangeManagementPage: React.FC = () => {
</option>
))}
</select>
{issueForm.formState.errors.assignedTo && (
<p className="text-[11px] text-rose-400 mt-1">{issueForm.formState.errors.assignedTo.message}</p>
)}
</div>
</div>
@@ -893,10 +1072,185 @@ export const ChangeManagementPage: React.FC = () => {
</button>
<button
type="submit"
disabled={createIssueMutation.isPending || updateIssueMutation.isPending}
className="px-4 py-2 rounded-xl text-xs font-semibold bg-amber-600 hover:bg-amber-500 text-white transition shadow-md shadow-amber-600/30"
disabled={createIssueMutation.isPending}
className="px-4 py-2 rounded-xl text-xs font-semibold bg-amber-600 hover:bg-amber-500 text-white transition shadow-md shadow-amber-600/30 disabled:opacity-50 flex items-center gap-1.5"
>
{editingIssue ? 'Update Issue' : 'Log Issue'}
{createIssueMutation.isPending && <RefreshCw className="w-3.5 h-3.5 animate-spin" />}
<span>Log Issue</span>
</button>
</div>
</form>
</Modal>
{/* Modal: Edit Project Issue */}
<Modal
isOpen={isEditIssueModalOpen}
onClose={() => {
setIsEditIssueModalOpen(false);
setEditingIssue(null);
}}
title={`Edit Project Issue: ${editingIssue?.title || ''}`}
size="lg"
>
<form
onSubmit={editIssueForm.handleSubmit((d) => updateIssueMutation.mutate(d))}
className="space-y-4"
>
<div>
<label className="block text-xs font-semibold text-slate-300 mb-1">Issue Title *</label>
<input
{...editIssueForm.register('title')}
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"
placeholder="e.g. Procurement delays for regional solar supplies"
/>
{editIssueForm.formState.errors.title && (
<p className="text-[11px] text-rose-400 mt-1">{editIssueForm.formState.errors.title.message}</p>
)}
</div>
<div>
<label className="block text-xs font-semibold text-slate-300 mb-1">Description *</label>
<textarea
rows={3}
{...editIssueForm.register('description')}
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"
placeholder="Detail root cause and operational impact..."
/>
{editIssueForm.formState.errors.description && (
<p className="text-[11px] text-rose-400 mt-1">{editIssueForm.formState.errors.description.message}</p>
)}
</div>
<div className="grid grid-cols-1 sm:grid-cols-2 gap-4">
<div>
<label className="block text-xs font-semibold text-slate-300 mb-1">Priority</label>
<select
{...editIssueForm.register('priority')}
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"
>
{PRIORITY_OPTIONS.map((opt) => (
<option key={opt.value} value={opt.value}>
{opt.label}
</option>
))}
</select>
</div>
<div>
<label className="block text-xs font-semibold text-slate-300 mb-1">Assigned Person *</label>
<select
{...editIssueForm.register('assignedTo')}
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="">Select Assignee</option>
{users.map((u) => (
<option key={u.id} value={u.id}>
{u.name || u.email || u.id}
</option>
))}
</select>
{editIssueForm.formState.errors.assignedTo && (
<p className="text-[11px] text-rose-400 mt-1">{editIssueForm.formState.errors.assignedTo.message}</p>
)}
</div>
</div>
{/* Status & Resolution Lifecycle Container */}
<div className={`p-4 rounded-2xl border transition-colors space-y-4 ${
Number(watchedEditStatus) === ActivityStatus.Inactive
? 'bg-emerald-950/20 border-emerald-500/30'
: 'bg-[#0d121f] border-slate-800'
}`}>
<div className="flex items-center justify-between pb-2 border-b border-slate-800/80">
<div className="flex items-center gap-2">
<CheckCircle2 className={`w-4 h-4 ${Number(watchedEditStatus) === ActivityStatus.Inactive ? 'text-emerald-400' : 'text-amber-400'}`} />
<span className="text-xs font-semibold text-white">Status & Resolution Lifecycle</span>
</div>
<span className={`px-2.5 py-0.5 rounded-full text-[10px] font-semibold uppercase tracking-wider border ${
Number(watchedEditStatus) === ActivityStatus.Inactive
? 'bg-emerald-500/10 text-emerald-400 border-emerald-500/30'
: 'bg-amber-500/10 text-amber-400 border-amber-500/30'
}`}>
{Number(watchedEditStatus) === ActivityStatus.Inactive ? 'Resolved / Closed' : 'Active / Open'}
</span>
</div>
<div className="grid grid-cols-1 sm:grid-cols-2 gap-4">
<div>
<label className="block text-xs font-semibold text-slate-300 mb-1">Issue Status *</label>
<select
{...editIssueForm.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={ActivityStatus.Active}>Active (Open / In Progress)</option>
<option value={ActivityStatus.Inactive}>Inactive (Resolved & Closed)</option>
</select>
<p className="text-[11px] text-slate-400 mt-1">
{Number(watchedEditStatus) === ActivityStatus.Inactive
? 'Issue will be marked as closed with full resolution audit.'
: 'Issue remains active and tracked as an open impediment.'}
</p>
</div>
<div>
<div className="flex items-center justify-between mb-1">
<label className="block text-xs font-semibold text-slate-300">Resolution Date & Time *</label>
<button
type="button"
onClick={() => editIssueForm.setValue('resolvedAt', toDateTimeLocalValue())}
className="text-[11px] font-semibold text-amber-400 hover:text-amber-300 transition cursor-pointer flex items-center gap-1"
title="Set to current date and time"
>
<Clock className="w-3 h-3" />
<span>Set to Now</span>
</button>
</div>
<input
type="datetime-local"
{...editIssueForm.register('resolvedAt')}
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"
/>
{editIssueForm.formState.errors.resolvedAt && (
<p className="text-[11px] text-rose-400 mt-1">{editIssueForm.formState.errors.resolvedAt.message}</p>
)}
</div>
</div>
<div>
<label className="block text-xs font-semibold text-slate-300 mb-1">Resolution Summary & Remediation Notes *</label>
<textarea
rows={3}
{...editIssueForm.register('resolution')}
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"
placeholder={
Number(watchedEditStatus) === ActivityStatus.Inactive
? 'Explain how the issue was remediated, workarounds applied, and validation steps...'
: 'Document ongoing remediation steps, progress notes, or blocker details...'
}
/>
{editIssueForm.formState.errors.resolution && (
<p className="text-[11px] text-rose-400 mt-1">{editIssueForm.formState.errors.resolution.message}</p>
)}
</div>
</div>
<div className="flex justify-end gap-3 pt-4 border-t border-slate-800">
<button
type="button"
onClick={() => {
setIsEditIssueModalOpen(false);
setEditingIssue(null);
}}
className="px-4 py-2 rounded-xl text-xs font-semibold text-slate-400 hover:text-white hover:bg-slate-800 transition"
>
Cancel
</button>
<button
type="submit"
disabled={updateIssueMutation.isPending}
className="px-4 py-2 rounded-xl text-xs font-semibold bg-amber-600 hover:bg-amber-500 text-white transition shadow-md shadow-amber-600/30 disabled:opacity-50 flex items-center gap-1.5"
>
{updateIssueMutation.isPending && <RefreshCw className="w-3.5 h-3.5 animate-spin" />}
<span>Update Issue</span>
</button>
</div>
</form>
@@ -1050,6 +1404,88 @@ export const ChangeManagementPage: React.FC = () => {
</div>
</form>
</Modal>
{/* Modal: Edit Change Request */}
<Modal
isOpen={isEditChangeModalOpen}
onClose={() => setIsEditChangeModalOpen(false)}
title={`Edit Change Request: ${editingChange?.title || ''}`}
size="md"
>
<form
onSubmit={editChangeForm.handleSubmit((d) => updateChangeMutation.mutate(d))}
className="space-y-4"
>
<div>
<label className="block text-xs font-semibold text-slate-300 mb-1">Proposal Title *</label>
<input
{...editChangeForm.register('title')}
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"
placeholder="e.g. Scope extension for additional beneficiary training"
/>
{editChangeForm.formState.errors.title && (
<p className="text-[11px] text-rose-400 mt-1">{editChangeForm.formState.errors.title.message}</p>
)}
</div>
<div>
<label className="block text-xs font-semibold text-slate-300 mb-1">Business Justification *</label>
<textarea
rows={3}
{...editChangeForm.register('justification')}
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"
placeholder="Provide statutory and contractual justification..."
/>
{editChangeForm.formState.errors.justification && (
<p className="text-[11px] text-rose-400 mt-1">{editChangeForm.formState.errors.justification.message}</p>
)}
</div>
<div className="grid grid-cols-2 gap-4">
<div>
<label className="block text-xs font-semibold text-slate-300 mb-1">Revised Total Budget (ZAR) *</label>
<input
type="number"
{...editChangeForm.register('revisedBudget')}
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"
/>
{editChangeForm.formState.errors.revisedBudget && (
<p className="text-[11px] text-rose-400 mt-1">{editChangeForm.formState.errors.revisedBudget.message}</p>
)}
</div>
<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>
<option value={ApprovalStatus.Approved}>Approved & Enacted</option>
<option value={ApprovalStatus.Rejected}>Rejected</option>
</select>
</div>
</div>
<div className="flex justify-end gap-3 pt-4 border-t border-slate-800">
<button
type="button"
onClick={() => setIsEditChangeModalOpen(false)}
className="px-4 py-2 rounded-xl text-xs font-semibold text-slate-400 hover:text-white hover:bg-slate-800 transition"
>
Cancel
</button>
<button
type="submit"
disabled={updateChangeMutation.isPending}
className="px-4 py-2 rounded-xl text-xs font-semibold bg-amber-600 hover:bg-amber-500 text-white transition shadow-md shadow-amber-600/30"
>
{updateChangeMutation.isPending ? 'Saving...' : 'Update Change Request'}
</button>
</div>
</form>
</Modal>
</div>
);
};
@@ -23,6 +23,7 @@ import {
import {
getProjects,
getProjectSiteVisits,
getSiteVisit,
createSiteVisit,
updateSiteVisit,
} from '../services/api';
@@ -161,15 +162,27 @@ export const SiteVisitsPage: React.FC = () => {
{ header: 'Status', key: 'status', format: (val) => (val === ApprovalStatus.Approved ? 'Approved' : 'Pending') },
];
const handleOpenEdit = (v: SiteVisit) => {
setEditingVisit(v);
visitForm.reset({
inspectorName: v.inspectorName || '',
visitedAt: v.visitedAt ? v.visitedAt.split('T')[0] : new Date().toISOString().split('T')[0],
findings: v.findings || '',
status: v.status ?? ApprovalStatus.Pending,
});
setIsVisitModalOpen(true);
const handleOpenEdit = async (v: SiteVisit) => {
try {
const fresh = await getSiteVisit(v.id);
setEditingVisit(fresh);
visitForm.reset({
inspectorName: fresh.inspectorName || '',
visitedAt: fresh.visitedAt ? fresh.visitedAt.split('T')[0] : new Date().toISOString().split('T')[0],
findings: fresh.findings || '',
status: fresh.status ?? ApprovalStatus.Pending,
});
setIsVisitModalOpen(true);
} catch {
setEditingVisit(v);
visitForm.reset({
inspectorName: v.inspectorName || '',
visitedAt: v.visitedAt ? v.visitedAt.split('T')[0] : new Date().toISOString().split('T')[0],
findings: v.findings || '',
status: v.status ?? ApprovalStatus.Pending,
});
setIsVisitModalOpen(true);
}
};
return (
@@ -1549,8 +1549,18 @@ export const getProjectBeneficiaries = async (projectId: string | number): Promi
export const getBeneficiary = async (beneficiaryId: string | number): Promise<Beneficiary> => {
const hashedBeneficiaryId = await toolsService.encodeId(beneficiaryId);
const res = await apiClient.get<Beneficiary>(`/v1/api/projects/beneficiaries/${hashedBeneficiaryId}`);
return res.data;
try {
const res = await apiClient.get<Beneficiary>(`/v1/api/projects/beneficiaries/${hashedBeneficiaryId}`);
return res.data;
} catch (err: any) {
if (String(beneficiaryId) !== String(hashedBeneficiaryId)) {
try {
const fallbackRes = await apiClient.get<Beneficiary>(`/v1/api/projects/beneficiaries/${beneficiaryId}`);
return fallbackRes.data;
} catch {}
}
throw err;
}
};
export const createBeneficiary = async (payload: CreateBeneficiaryRequest): Promise<void> => {
@@ -1635,8 +1645,18 @@ export const getProjectChangeRequests = async (projectId: string | number): Prom
export const getChangeRequest = async (changeRequestId: string | number): Promise<ChangeRequest> => {
const hashedChangeReqId = await toolsService.encodeId(changeRequestId);
const res = await apiClient.get<ChangeRequest>(`/v1/api/projects/changerequests/${hashedChangeReqId}`);
return res.data;
try {
const res = await apiClient.get<ChangeRequest>(`/v1/api/projects/changerequests/${hashedChangeReqId}`);
return res.data;
} catch (err: any) {
if (String(changeRequestId) !== String(hashedChangeReqId)) {
try {
const fallbackRes = await apiClient.get<ChangeRequest>(`/v1/api/projects/changerequests/${changeRequestId}`);
return fallbackRes.data;
} catch {}
}
throw err;
}
};
export const createChangeRequest = async (payload: CreateChangeRequest): Promise<void> => {
@@ -1733,18 +1753,31 @@ export const createIssue = async (payload: CreateIssueRequest): Promise<void> =>
};
export const updateIssue = async (payload: UpdateIssueRequest): Promise<void> => {
const resolvedAtIso = payload.resolvedAt
? (payload.resolvedAt.includes('T') ? payload.resolvedAt : new Date(payload.resolvedAt).toISOString())
: null;
let resolvedAtIso: string | null = null;
if (payload.resolvedAt) {
try {
resolvedAtIso = payload.resolvedAt.includes('T') && payload.resolvedAt.endsWith('Z')
? payload.resolvedAt
: new Date(payload.resolvedAt).toISOString();
} catch {
resolvedAtIso = new Date().toISOString();
}
} else {
resolvedAtIso = new Date().toISOString();
}
const finalResolution = payload.resolution && payload.resolution.trim() !== ''
? payload.resolution
: (Number(payload.status) === ActivityStatus.Inactive ? 'Resolved' : 'Under active investigation');
await apiClient.put('/v1/api/projects/issues', {
issueId: toIntegerId(payload.issueId),
title: payload.title,
description: payload.description,
resolution: payload.resolution,
resolution: finalResolution,
resolvedAt: resolvedAtIso,
priority: Number(payload.priority),
status: Number(payload.status),
status: payload.status !== undefined ? Number(payload.status) : ActivityStatus.Active,
assignedTo: payload.assignedTo,
});
};
@@ -1772,8 +1805,18 @@ export const getProjectSiteVisits = async (projectId: string | number): Promise<
export const getSiteVisit = async (siteVisitId: string | number): Promise<SiteVisit> => {
const hashedVisitId = await toolsService.encodeId(siteVisitId);
const res = await apiClient.get<SiteVisit>(`/v1/api/projects/sitevisits/${hashedVisitId}`);
return res.data;
try {
const res = await apiClient.get<SiteVisit>(`/v1/api/projects/sitevisits/${hashedVisitId}`);
return res.data;
} catch (err: any) {
if (String(siteVisitId) !== String(hashedVisitId)) {
try {
const fallbackRes = await apiClient.get<SiteVisit>(`/v1/api/projects/sitevisits/${siteVisitId}`);
return fallbackRes.data;
} catch {}
}
throw err;
}
};
export const createSiteVisit = async (payload: CreateSiteVisitRequest): Promise<void> => {