import React, { useState, useMemo } from 'react'; import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query'; import { useForm } from 'react-hook-form'; import { zodResolver } from '@hookform/resolvers/zod'; import { z } from 'zod'; import { GitPullRequest, AlertOctagon, Plus, CheckCircle2, AlertCircle, RefreshCw, Search, Check, XCircle, Edit2, FolderKanban, Coins, Calendar, User, Clock, ArrowRight, Lock, } from 'lucide-react'; import { getProjects, getProjectIssues, getIssue, createIssue, updateIssue, getProjectChangeRequests, getChangeRequest, createChangeRequest, updateChangeRequest, getUsers, } from '../services/api'; import { Modal } from '../components/ui/Modal'; import { useToast } from '../components/ui/Toast'; import { HelpTooltip } from '../components/ui/HelpTooltip'; import { ExportButton } from '../components/ui/ExportButton'; import { ExportColumn } from '../utils/exportUtils'; import { useTableSort, SortableTh } from '../utils/tableSort'; import { useRbac } from '../auth/rbac'; import { ApprovalStatusBadge, PriorityBadge } from '../components/ui/StatusBadge'; import { Issue, ChangeRequest, ApprovalStatus, ActivityStatus, Priority } from '../types'; import { getApprovalStatusBadge, getPriorityBadge, formatDate, formatZAR, getUserEmail, PRIORITY_OPTIONS, APPROVAL_STATUS_OPTIONS, } from '../lib/formatters'; const issueSchema = 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'), }); 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(), }); const changeRequestSchema = 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'), 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; type EditIssueFormValues = z.infer; type ResolveIssueFormValues = z.infer; type ChangeRequestFormValues = z.infer; type EditChangeFormValues = z.infer; type ResolveChangeFormValues = z.infer; export const ChangeManagementPage: React.FC = () => { const queryClient = useQueryClient(); const { success, error: toastError } = useToast(); const { canAdd, canApprove, canExport, canApproveRecord, isMaker } = useRbac(); const [selectedProjectId, setSelectedProjectId] = useState('all'); const [activeTab, setActiveTab] = useState<'issues' | 'changes'>('issues'); const [searchTerm, setSearchTerm] = useState(''); const [priorityFilter, setPriorityFilter] = useState('all'); const [issueStatusFilter, setIssueStatusFilter] = useState('all'); const [changeStatusFilter, setChangeStatusFilter] = useState('all'); // Modals const [isIssueModalOpen, setIsIssueModalOpen] = useState(false); const [isEditIssueModalOpen, setIsEditIssueModalOpen] = useState(false); const [editingIssue, setEditingIssue] = useState(null); const [isResolveIssueModalOpen, setIsResolveIssueModalOpen] = useState(false); const [resolvingIssue, setResolvingIssue] = useState(null); const [isChangeModalOpen, setIsChangeModalOpen] = useState(false); const [isEditChangeModalOpen, setIsEditChangeModalOpen] = useState(false); const [editingChange, setEditingChange] = useState(null); const [isResolveChangeModalOpen, setIsResolveChangeModalOpen] = useState(false); const [resolvingChange, setResolvingChange] = useState(null); // Queries const { data: projects = [], isLoading: loadingProjects } = useQuery({ queryKey: ['projects'], queryFn: () => getProjects(), }); const { data: users = [] } = useQuery({ queryKey: ['users-directory'], queryFn: () => getUsers(1, 200), }); const effectiveProjectId = selectedProjectId !== 'all' ? selectedProjectId : projects[0]?.id; const { data: issues = [], isLoading: loadingIssues, refetch: refetchIssues, isFetching: fetchingIssues, } = useQuery({ queryKey: ['issues', effectiveProjectId], queryFn: () => (effectiveProjectId ? getProjectIssues(effectiveProjectId) : Promise.resolve([])), enabled: !!effectiveProjectId, }); const { data: changeRequests = [], isLoading: loadingChanges, refetch: refetchChanges, isFetching: fetchingChanges, } = useQuery({ queryKey: ['changeRequests', effectiveProjectId], queryFn: () => (effectiveProjectId ? getProjectChangeRequests(effectiveProjectId) : Promise.resolve([])), enabled: !!effectiveProjectId, }); // Forms const issueForm = useForm({ resolver: zodResolver(issueSchema), defaultValues: { title: '', description: '', priority: Priority.Medium, assignedTo: '' }, }); const editIssueForm = useForm({ resolver: zodResolver(editIssueSchema), defaultValues: { title: '', description: '', priority: Priority.Medium, assignedTo: '', status: ActivityStatus.Active, resolution: '', resolvedAt: toDateTimeLocalValue(), }, }); const watchedEditStatus = editIssueForm.watch('status'); const resolveIssueForm = useForm({ resolver: zodResolver(resolveIssueSchema), defaultValues: { resolution: '', status: ActivityStatus.Inactive }, }); const changeForm = useForm({ resolver: zodResolver(changeRequestSchema), defaultValues: { title: '', justification: '', revisedBudget: 0, revisionDate: new Date().toISOString().split('T')[0], }, }); const editChangeForm = useForm({ resolver: zodResolver(editChangeSchema), defaultValues: { title: '', justification: '', revisedBudget: 0, status: ApprovalStatus.Pending, }, }); const resolveChangeForm = useForm({ resolver: zodResolver(resolveChangeSchema), defaultValues: { status: ApprovalStatus.Approved, justification: '' }, }); // Mutations const createIssueMutation = useMutation({ mutationFn: (vals: IssueFormValues) => { if (!effectiveProjectId) throw new Error('No project selected'); return createIssue({ projectId: effectiveProjectId, title: vals.title, description: vals.description, priority: vals.priority, assignedTo: vals.assignedTo, }); }, onSuccess: () => { queryClient.invalidateQueries({ queryKey: ['issues'] }); setIsIssueModalOpen(false); issueForm.reset(); success('Issue Logged', 'New project issue created and assigned.'); }, onError: (err: unknown) => toastError('Creation Failed', err), }); const updateIssueMutation = useMutation({ 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: vals.resolution || (Number(vals.status) === ActivityStatus.Inactive ? 'Resolved' : 'Under active investigation'), resolvedAt: isoResolvedAt, status: Number(vals.status), }); }, onSuccess: () => { queryClient.invalidateQueries({ queryKey: ['issues'] }); setIsEditIssueModalOpen(false); setEditingIssue(null); editIssueForm.reset(); success('Issue Updated', 'Issue record and resolution status saved successfully.'); }, onError: (err: unknown) => toastError('Update Failed', err), }); const resolveIssueMutation = useMutation({ mutationFn: (vals: ResolveIssueFormValues) => { if (!resolvingIssue) throw new Error('No issue selected'); return updateIssue({ issueId: resolvingIssue.id, title: resolvingIssue.title || '', description: resolvingIssue.description || '', priority: resolvingIssue.priority || Priority.Medium, assignedTo: resolvingIssue.assignedTo || '', resolution: vals.resolution, resolvedAt: new Date().toISOString(), status: vals.status, }); }, onSuccess: () => { queryClient.invalidateQueries({ queryKey: ['issues'] }); setIsResolveIssueModalOpen(false); setResolvingIssue(null); resolveIssueForm.reset(); success('Issue Resolved', 'Resolution recorded and issue status updated.'); }, onError: (err: unknown) => toastError('Resolution Failed', err), }); const createChangeMutation = useMutation({ mutationFn: (vals: ChangeRequestFormValues) => { if (!effectiveProjectId) throw new Error('No project selected'); return createChangeRequest({ projectId: effectiveProjectId, title: vals.title, justification: vals.justification, revisedBudget: vals.revisedBudget, revisionDate: vals.revisionDate, }); }, onSuccess: () => { queryClient.invalidateQueries({ queryKey: ['changeRequests'] }); setIsChangeModalOpen(false); changeForm.reset(); success('Change Request Submitted', 'Formal change request submitted for review.'); }, onError: (err: unknown) => toastError('Submission Failed', err), }); const resolveChangeMutation = useMutation({ mutationFn: (vals: ResolveChangeFormValues) => { if (!resolvingChange) throw new Error('No change request selected'); return updateChangeRequest({ changeRequestId: resolvingChange.id, title: resolvingChange.title || '', justification: vals.justification || resolvingChange.justification || '', revisedBudget: resolvingChange.revisedBudget || 0, status: vals.status, }); }, onSuccess: () => { queryClient.invalidateQueries({ queryKey: ['changeRequests'] }); setIsResolveChangeModalOpen(false); setResolvingChange(null); resolveChangeForm.reset(); success('Change Decision Applied', 'Change request decision successfully resolved.'); }, 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, 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: unknown) => toastError('Update Failed', err), }); // Filtered & Sorted Issues const filteredIssues = useMemo(() => { return issues.filter((iss) => { const matchSearch = !searchTerm.trim() || (iss.title && iss.title.toLowerCase().includes(searchTerm.toLowerCase())) || (iss.description && iss.description.toLowerCase().includes(searchTerm.toLowerCase())); const matchPrio = priorityFilter === 'all' || String(iss.priority) === String(priorityFilter); const isResolved = iss.status === ActivityStatus.Inactive || !!iss.resolvedAt; const matchStatus = issueStatusFilter === 'all' || (issueStatusFilter === 'open' && !isResolved) || (issueStatusFilter === 'resolved' && isResolved); return matchSearch && matchPrio && matchStatus; }); }, [issues, searchTerm, priorityFilter, issueStatusFilter]); const { sortedData: sortedIssues, sortColumn: issueSortCol, sortDirection: issueSortDir, handleSort: handleIssueSort } = useTableSort(filteredIssues); // Filtered & Sorted Change Requests const filteredChanges = useMemo(() => { return changeRequests.filter((cr) => { const matchSearch = !searchTerm.trim() || (cr.title && cr.title.toLowerCase().includes(searchTerm.toLowerCase())) || (cr.justification && cr.justification.toLowerCase().includes(searchTerm.toLowerCase())); const matchStatus = changeStatusFilter === 'all' || (changeStatusFilter === 'approved' && cr.status === ApprovalStatus.Approved) || (changeStatusFilter === 'rejected' && cr.status === ApprovalStatus.Rejected) || (changeStatusFilter === 'pending' && (cr.status === ApprovalStatus.Pending || cr.status === ApprovalStatus.Review)); return matchSearch && matchStatus; }); }, [changeRequests, searchTerm, changeStatusFilter]); const { sortedData: sortedChanges, sortColumn: changeSortCol, sortDirection: changeSortDir, handleSort: handleChangeSort } = useTableSort(filteredChanges); // Exports const issueExportCols: ExportColumn[] = [ { header: 'Title', key: 'title' }, { header: 'Description', key: 'description' }, { header: 'Priority', key: 'priority', format: (val) => (val === Priority.Critical ? 'Critical' : 'Standard') }, { header: 'Assigned To', key: 'assignedTo', format: (val) => (val ? getUserEmail(val) : 'Unassigned') }, { header: 'Status', key: 'status', format: (val) => (val === ActivityStatus.Active ? 'Active' : 'Resolved') }, ]; const changeExportCols: ExportColumn[] = [ { header: 'Change Title', key: 'title' }, { header: 'Justification', key: 'justification' }, { header: 'Revised Budget', key: 'revisedBudget', format: (val) => formatZAR(val || 0) }, { header: 'Status', key: 'status', format: (val) => (val === ApprovalStatus.Approved ? 'Approved' : 'Pending') }, ]; const openResolveIssue = (iss: Issue) => { setResolvingIssue(iss); resolveIssueForm.reset({ resolution: iss.resolution || '', status: ActivityStatus.Inactive }); 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) => { 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) 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({ status: statusToApply, justification: `Approved by Project Governance for execution.`, }); setIsResolveChangeModalOpen(true); }; return (
{/* Header Banner */}

Change Management & Issue Resolution

Track project execution impediments, review contractual variations, and resolve change requests in-place.

{/* KPI Cards */}
Active Issues

{issues.filter((i) => i.status !== ActivityStatus.Inactive).length}

Open project impediments

Resolved Issues

{issues.filter((i) => i.status === ActivityStatus.Inactive).length}

Closed with documented resolution

Pending Changes

{changeRequests.filter((c) => c.status === ApprovalStatus.Pending || c.status === ApprovalStatus.Review).length}

Awaiting governance review

Approved Variations

{changeRequests.filter((c) => c.status === ApprovalStatus.Approved).length}

Contract amendments enacted

{/* Tabs */}
{/* Tab 1: Issues Toolbar & Content */} {activeTab === 'issues' && (
{/* Search */}
setSearchTerm(e.target.value)} className="w-full bg-slate-900 border border-slate-800 rounded-xl pl-8 pr-3 py-1.5 text-xs text-white focus:outline-none focus:border-amber-500 shadow-xs" />
{/* Priority Filter */}
{/* Status Filter */}
{/* Project Selector */}
{/* Actions: Refresh, Export, Log Issue */}
{canAdd('change_management') && ( )}
Issue Summary Priority Assigned To Status {loadingIssues ? ( ) : sortedIssues.length === 0 ? ( ) : ( sortedIssues.map((iss) => { const isResolved = iss.status === ActivityStatus.Inactive || !!iss.resolvedAt; return ( ); }) )}
Resolution Notes Actions
Loading project issues...
No issues recorded for this project.

{iss.title}

{iss.description}

{iss.assignedTo ? getUserEmail(iss.assignedTo) : 'Unassigned'}
{isResolved ? ( Resolved ) : ( Open )} {iss.resolution || Pending resolution...}
)} {/* Tab 2: Change Requests Table */} {activeTab === 'changes' && (
{/* Search */}
setSearchTerm(e.target.value)} className="w-full bg-slate-900 border border-slate-800 rounded-xl pl-8 pr-3 py-1.5 text-xs text-white focus:outline-none focus:border-amber-500 shadow-xs" />
{/* Status Filter */}
{/* Project Selector */}
{/* Actions: Refresh, Export, Request Change */}
{canAdd('change_management') && ( )}
Change Proposal Justification Revised Budget Governance Status {loadingChanges ? ( ) : sortedChanges.length === 0 ? ( ) : ( sortedChanges.map((cr) => ( )) )}
In-Place Decision
Loading change requests...
No change requests logged for this project.
{cr.title}
{cr.justification || 'None'} {formatZAR(cr.revisedBudget || 0)}
{Number(cr.status) === ApprovalStatus.Approved ? ( Resolved & Enacted (Immutable) ) : ( <> {canApproveRecord('change_management', cr.createdBy) && ( )} {canApprove('change_management') && isMaker(cr.createdBy) && ( Maker-Checker )} )}
)}
{/* Modal: Log Project Issue */} setIsIssueModalOpen(false)} title="Log Project Issue" size="md" >
createIssueMutation.mutate(d))} className="space-y-4" >
{issueForm.formState.errors.title && (

{issueForm.formState.errors.title.message}

)}