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, } from 'lucide-react'; import { getProjects, getProjectIssues, createIssue, updateIssue, getProjectChangeRequests, createChangeRequest, updateChangeRequest, getUsers, } from '../services/api'; import { Modal } from '../components/ui/Modal'; import { useToast } from '../components/ui/Toast'; import { ExportButton } from '../components/ui/ExportButton'; import { ExportColumn } from '../utils/exportUtils'; import { useTableSort, SortableTh } from '../utils/tableSort'; import { Issue, ChangeRequest, ApprovalStatus, ActivityStatus, Priority } from '../types'; import { getApprovalStatusBadge, getPriorityBadge, formatDate, formatZAR, getUserEmail, PRIORITY_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 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 resolveChangeSchema = z.object({ status: z.coerce.number(), justification: z.string().min(3, 'Decision justification required'), }); type IssueFormValues = z.infer; type ResolveIssueFormValues = z.infer; type ChangeRequestFormValues = z.infer; type ResolveChangeFormValues = z.infer; export const ChangeManagementPage: React.FC = () => { const queryClient = useQueryClient(); const { success, error: toastError } = useToast(); 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 [editingIssue, setEditingIssue] = useState(null); const [isResolveIssueModalOpen, setIsResolveIssueModalOpen] = useState(false); const [resolvingIssue, setResolvingIssue] = useState(null); const [isChangeModalOpen, setIsChangeModalOpen] = 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 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 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: any) => toastError('Creation Failed', err.message || 'Unable to log issue.'), }); const updateIssueMutation = useMutation({ mutationFn: (vals: IssueFormValues) => { if (!editingIssue) throw new Error('No issue selected'); 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, }); }, onSuccess: () => { queryClient.invalidateQueries({ queryKey: ['issues'] }); setIsIssueModalOpen(false); setEditingIssue(null); issueForm.reset(); success('Issue Updated', 'Issue record saved.'); }, onError: (err: any) => toastError('Update Failed', err.message || 'Unable to update issue.'), }); 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: any) => toastError('Resolution Failed', err.message || 'Unable to resolve issue.'), }); 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: any) => toastError('Submission Failed', err.message || 'Unable to submit change request.'), }); 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: any) => toastError('Decision Failed', err.message || 'Unable to resolve change request.'), }); // 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 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 */}
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}

{getPriorityBadge(iss.priority)}
{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 */}
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)} {getApprovalStatusBadge(cr.status)} {cr.status === ApprovalStatus.Approved ? ( Resolved & Enacted ) : (
)}
)}
{/* Modal: Create/Edit Issue */} setIsIssueModalOpen(false)} title={editingIssue ? 'Edit Project Issue' : 'Log Project Issue'} size="md" >
(editingIssue ? updateIssueMutation.mutate(d) : createIssueMutation.mutate(d)))} className="space-y-4" >