Fixed mismatched endpoint calling
This commit is contained in:
@@ -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 (
|
||||
|
||||
Reference in New Issue
Block a user