Fixed UI inconsistencies
This commit is contained in:
@@ -83,6 +83,8 @@ export const ChangeManagementPage: React.FC = () => {
|
||||
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);
|
||||
@@ -276,9 +278,14 @@ export const ChangeManagementPage: React.FC = () => {
|
||||
(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);
|
||||
return matchSearch && matchPrio;
|
||||
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]);
|
||||
}, [issues, searchTerm, priorityFilter, issueStatusFilter]);
|
||||
|
||||
const { sortedData: sortedIssues, sortColumn: issueSortCol, sortDirection: issueSortDir, handleSort: handleIssueSort } =
|
||||
useTableSort(filteredIssues);
|
||||
@@ -286,13 +293,18 @@ export const ChangeManagementPage: React.FC = () => {
|
||||
// Filtered & Sorted Change Requests
|
||||
const filteredChanges = useMemo(() => {
|
||||
return changeRequests.filter((cr) => {
|
||||
return (
|
||||
const matchSearch =
|
||||
!searchTerm.trim() ||
|
||||
(cr.title && cr.title.toLowerCase().includes(searchTerm.toLowerCase())) ||
|
||||
(cr.justification && cr.justification.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]);
|
||||
}, [changeRequests, searchTerm, changeStatusFilter]);
|
||||
|
||||
const { sortedData: sortedChanges, sortColumn: changeSortCol, sortDirection: changeSortDir, handleSort: handleChangeSort } =
|
||||
useTableSort(filteredChanges);
|
||||
@@ -301,16 +313,16 @@ export const ChangeManagementPage: React.FC = () => {
|
||||
const issueExportCols: ExportColumn<Issue>[] = [
|
||||
{ header: 'Title', key: 'title' },
|
||||
{ header: 'Description', key: 'description' },
|
||||
{ header: 'Priority', accessor: (i) => (i.priority === Priority.Critical ? 'Critical' : 'Standard') },
|
||||
{ header: 'Assigned To', accessor: (i) => (i.assignedTo ? getUserEmail(i.assignedTo) : 'Unassigned') },
|
||||
{ header: 'Status', accessor: (i) => (i.status === ActivityStatus.Active ? 'Active' : 'Resolved') },
|
||||
{ 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<ChangeRequest>[] = [
|
||||
{ header: 'Change Title', key: 'title' },
|
||||
{ header: 'Justification', key: 'justification' },
|
||||
{ header: 'Revised Budget', accessor: (c) => formatZAR(c.revisedBudget || 0) },
|
||||
{ header: 'Status', accessor: (c) => (c.status === ApprovalStatus.Approved ? 'Approved' : 'Pending') },
|
||||
{ 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) => {
|
||||
@@ -348,55 +360,6 @@ export const ChangeManagementPage: React.FC = () => {
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-2.5 flex-wrap relative z-10">
|
||||
<button
|
||||
onClick={() => {
|
||||
refetchIssues();
|
||||
refetchChanges();
|
||||
}}
|
||||
disabled={fetchingIssues || fetchingChanges}
|
||||
className="flex items-center gap-2 px-3.5 py-2.5 rounded-xl text-xs font-semibold bg-slate-900/90 text-slate-300 hover:text-white border border-slate-800 hover:bg-slate-800/80 transition cursor-pointer"
|
||||
>
|
||||
<RefreshCw className={`w-3.5 h-3.5 ${fetchingIssues || fetchingChanges ? 'animate-spin text-amber-400' : ''}`} />
|
||||
<span>Refresh</span>
|
||||
</button>
|
||||
|
||||
{activeTab === 'issues' ? (
|
||||
<ExportButton data={sortedIssues} columns={issueExportCols} filename="project_issues" title="Project Issues" />
|
||||
) : (
|
||||
<ExportButton data={sortedChanges} columns={changeExportCols} filename="change_requests" title="Change Requests" />
|
||||
)}
|
||||
|
||||
<button
|
||||
onClick={() => {
|
||||
setEditingIssue(null);
|
||||
issueForm.reset({ title: '', description: '', priority: Priority.Medium, assignedTo: '' });
|
||||
setIsIssueModalOpen(true);
|
||||
}}
|
||||
className="flex items-center gap-2 px-4 py-2.5 rounded-xl text-xs font-semibold bg-gradient-to-r from-amber-600 to-orange-600 text-white hover:from-amber-500 hover:to-orange-500 shadow-lg shadow-amber-600/25 transition cursor-pointer"
|
||||
>
|
||||
<Plus className="w-4 h-4" />
|
||||
<span>Log Issue</span>
|
||||
</button>
|
||||
|
||||
<button
|
||||
onClick={() => {
|
||||
setEditingChange(null);
|
||||
changeForm.reset({
|
||||
title: '',
|
||||
justification: '',
|
||||
revisedBudget: 0,
|
||||
revisionDate: new Date().toISOString().split('T')[0],
|
||||
});
|
||||
setIsChangeModalOpen(true);
|
||||
}}
|
||||
className="flex items-center gap-2 px-4 py-2.5 rounded-xl text-xs font-semibold bg-slate-800/90 text-white hover:bg-slate-700/80 border border-slate-700 transition cursor-pointer"
|
||||
>
|
||||
<GitPullRequest className="w-4 h-4 text-blue-400" />
|
||||
<span>Request Change</span>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* KPI Cards */}
|
||||
@@ -444,68 +407,134 @@ export const ChangeManagementPage: React.FC = () => {
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Tabs & Filters */}
|
||||
{/* Tabs */}
|
||||
<div className="space-y-4">
|
||||
<div className="flex flex-col sm:flex-row sm:items-center justify-between gap-4 border-b border-slate-800 pb-3">
|
||||
<div className="flex items-center gap-2">
|
||||
<button
|
||||
onClick={() => setActiveTab('issues')}
|
||||
className={`flex items-center gap-2 px-4 py-2 rounded-xl text-xs font-semibold transition cursor-pointer ${
|
||||
activeTab === 'issues'
|
||||
? 'bg-amber-600 text-white shadow-md shadow-amber-600/30'
|
||||
: 'text-slate-400 hover:text-white hover:bg-slate-800/60'
|
||||
}`}
|
||||
>
|
||||
<AlertOctagon className="w-3.5 h-3.5" />
|
||||
<span>Project Issues ({issues.length})</span>
|
||||
</button>
|
||||
<div className="flex border-b border-slate-800 bg-[#0d121f]/80 px-2 sm:px-4 rounded-2xl backdrop-blur-sm overflow-x-auto gap-2">
|
||||
<button
|
||||
onClick={() => setActiveTab('issues')}
|
||||
className={`py-3.5 px-4 text-xs font-semibold border-b-2 transition-colors flex items-center gap-2 cursor-pointer whitespace-nowrap ${
|
||||
activeTab === 'issues'
|
||||
? 'border-amber-500 text-amber-400'
|
||||
: 'border-transparent text-slate-400 hover:text-slate-200'
|
||||
}`}
|
||||
>
|
||||
<AlertOctagon className="w-4 h-4" />
|
||||
<span>Project Issues ({issues.length})</span>
|
||||
</button>
|
||||
|
||||
<button
|
||||
onClick={() => setActiveTab('changes')}
|
||||
className={`flex items-center gap-2 px-4 py-2 rounded-xl text-xs font-semibold transition cursor-pointer ${
|
||||
activeTab === 'changes'
|
||||
? 'bg-amber-600 text-white shadow-md shadow-amber-600/30'
|
||||
: 'text-slate-400 hover:text-white hover:bg-slate-800/60'
|
||||
}`}
|
||||
>
|
||||
<GitPullRequest className="w-3.5 h-3.5" />
|
||||
<span>Change Requests ({changeRequests.length})</span>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-3 flex-wrap">
|
||||
<div className="flex items-center gap-2 text-xs text-slate-400">
|
||||
<FolderKanban className="w-3.5 h-3.5 text-amber-400" />
|
||||
<span>Project:</span>
|
||||
<select
|
||||
value={selectedProjectId}
|
||||
onChange={(e) => setSelectedProjectId(e.target.value)}
|
||||
className="bg-slate-900 border border-slate-800 rounded-xl px-3 py-1.5 text-xs text-white focus:outline-none focus:border-amber-500"
|
||||
>
|
||||
{projects.map((p) => (
|
||||
<option key={p.id} value={p.id}>
|
||||
{p.name}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<div className="relative">
|
||||
<Search className="w-3.5 h-3.5 absolute left-3 top-2.5 text-slate-400" />
|
||||
<input
|
||||
type="text"
|
||||
placeholder="Search..."
|
||||
value={searchTerm}
|
||||
onChange={(e) => setSearchTerm(e.target.value)}
|
||||
className="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 w-40 sm:w-56"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<button
|
||||
onClick={() => setActiveTab('changes')}
|
||||
className={`py-3.5 px-4 text-xs font-semibold border-b-2 transition-colors flex items-center gap-2 cursor-pointer whitespace-nowrap ${
|
||||
activeTab === 'changes'
|
||||
? 'border-amber-500 text-amber-400'
|
||||
: 'border-transparent text-slate-400 hover:text-slate-200'
|
||||
}`}
|
||||
>
|
||||
<GitPullRequest className="w-4 h-4" />
|
||||
<span>Change Requests ({changeRequests.length})</span>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Tab 1: Issues Table */}
|
||||
{/* Tab 1: Issues Toolbar & Content */}
|
||||
{activeTab === 'issues' && (
|
||||
<div className="bg-[#0f1523]/80 backdrop-blur-md rounded-2xl border border-slate-800/80 shadow-xl overflow-hidden">
|
||||
<div className="space-y-4">
|
||||
<div className="flex flex-col lg:flex-row items-stretch lg:items-center justify-between gap-3 bg-[#0d121f]/60 p-3.5 rounded-2xl border border-slate-800/80">
|
||||
<div className="flex items-center gap-2.5 flex-wrap flex-1">
|
||||
{/* Search */}
|
||||
<div className="relative flex-1 min-w-[200px] max-w-sm">
|
||||
<Search className="w-3.5 h-3.5 absolute left-3 top-2.5 text-slate-400" />
|
||||
<input
|
||||
type="text"
|
||||
placeholder="Search issues by title or details..."
|
||||
value={searchTerm}
|
||||
onChange={(e) => 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"
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Priority Filter */}
|
||||
<div className="flex items-center gap-1.5 text-xs text-slate-400">
|
||||
<select
|
||||
value={priorityFilter}
|
||||
onChange={(e) => setPriorityFilter(e.target.value)}
|
||||
className="bg-slate-900 border border-slate-800 rounded-xl px-3 py-1.5 text-xs text-white focus:outline-none focus:border-amber-500"
|
||||
>
|
||||
<option value="all">All Priorities</option>
|
||||
<option value={String(Priority.Critical)}>Critical Priority</option>
|
||||
<option value={String(Priority.High)}>High Priority</option>
|
||||
<option value={String(Priority.Medium)}>Medium Priority</option>
|
||||
<option value={String(Priority.Low)}>Low Priority</option>
|
||||
</select>
|
||||
</div>
|
||||
|
||||
{/* Status Filter */}
|
||||
<div className="flex items-center gap-1.5 text-xs text-slate-400">
|
||||
<select
|
||||
value={issueStatusFilter}
|
||||
onChange={(e) => setIssueStatusFilter(e.target.value)}
|
||||
className="bg-slate-900 border border-slate-800 rounded-xl px-3 py-1.5 text-xs text-white focus:outline-none focus:border-amber-500"
|
||||
>
|
||||
<option value="all">All Statuses</option>
|
||||
<option value="open">Open / Active</option>
|
||||
<option value="resolved">Resolved / Closed</option>
|
||||
</select>
|
||||
</div>
|
||||
|
||||
{/* Project Selector */}
|
||||
<div className="flex items-center gap-1.5 text-xs text-slate-400">
|
||||
<FolderKanban className="w-3.5 h-3.5 text-amber-400" />
|
||||
<select
|
||||
value={selectedProjectId}
|
||||
onChange={(e) => setSelectedProjectId(e.target.value)}
|
||||
className="bg-slate-900 border border-slate-800 rounded-xl px-3 py-1.5 text-xs text-white focus:outline-none focus:border-amber-500 max-w-[180px] truncate"
|
||||
>
|
||||
{projects.map((p) => (
|
||||
<option key={p.id} value={p.id}>
|
||||
{p.name}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Actions: Refresh, Export, Log Issue */}
|
||||
<div className="flex items-center gap-2 flex-wrap justify-end">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => {
|
||||
refetchIssues();
|
||||
success('Refreshed', 'Project issues updated from API.');
|
||||
}}
|
||||
disabled={fetchingIssues}
|
||||
className="flex items-center gap-1.5 px-3 py-1.5 rounded-xl text-xs font-semibold bg-slate-900 text-slate-300 hover:text-white border border-slate-800 hover:bg-slate-800 transition cursor-pointer disabled:opacity-50"
|
||||
title="Force refresh issues"
|
||||
>
|
||||
<RefreshCw className={`w-3.5 h-3.5 text-amber-400 ${fetchingIssues ? 'animate-spin' : ''}`} />
|
||||
<span>Refresh</span>
|
||||
</button>
|
||||
|
||||
<ExportButton
|
||||
data={sortedIssues}
|
||||
columns={issueExportCols}
|
||||
filename={`project_issues_${new Date().toISOString().split('T')[0]}`}
|
||||
title="Project Issues Register"
|
||||
/>
|
||||
|
||||
<button
|
||||
onClick={() => {
|
||||
setEditingIssue(null);
|
||||
issueForm.reset({ title: '', description: '', priority: Priority.Medium, assignedTo: '' });
|
||||
setIsIssueModalOpen(true);
|
||||
}}
|
||||
className="flex items-center gap-1.5 px-3.5 py-1.5 rounded-xl text-xs font-semibold bg-gradient-to-r from-amber-600 to-orange-600 text-white hover:from-amber-500 hover:to-orange-500 shadow-md shadow-amber-600/25 transition cursor-pointer"
|
||||
>
|
||||
<Plus className="w-3.5 h-3.5" />
|
||||
<span>Log Issue</span>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="bg-[#0f1523]/80 backdrop-blur-md rounded-2xl border border-slate-800/80 shadow-xl overflow-hidden">
|
||||
<div className="overflow-x-auto">
|
||||
<table className="w-full text-left border-collapse">
|
||||
<thead>
|
||||
@@ -611,11 +640,100 @@ export const ChangeManagementPage: React.FC = () => {
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Tab 2: Change Requests Table */}
|
||||
{activeTab === 'changes' && (
|
||||
<div className="bg-[#0f1523]/80 backdrop-blur-md rounded-2xl border border-slate-800/80 shadow-xl overflow-hidden">
|
||||
<div className="space-y-4">
|
||||
<div className="flex flex-col lg:flex-row items-stretch lg:items-center justify-between gap-3 bg-[#0d121f]/60 p-3.5 rounded-2xl border border-slate-800/80">
|
||||
<div className="flex items-center gap-2.5 flex-wrap flex-1">
|
||||
{/* Search */}
|
||||
<div className="relative flex-1 min-w-[200px] max-w-sm">
|
||||
<Search className="w-3.5 h-3.5 absolute left-3 top-2.5 text-slate-400" />
|
||||
<input
|
||||
type="text"
|
||||
placeholder="Search change proposal, justification..."
|
||||
value={searchTerm}
|
||||
onChange={(e) => 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"
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Status Filter */}
|
||||
<div className="flex items-center gap-1.5 text-xs text-slate-400">
|
||||
<select
|
||||
value={changeStatusFilter}
|
||||
onChange={(e) => setChangeStatusFilter(e.target.value)}
|
||||
className="bg-slate-900 border border-slate-800 rounded-xl px-3 py-1.5 text-xs text-white focus:outline-none focus:border-amber-500"
|
||||
>
|
||||
<option value="all">All Change Statuses</option>
|
||||
<option value="pending">Pending Review</option>
|
||||
<option value="approved">Approved & Enacted</option>
|
||||
<option value="rejected">Rejected</option>
|
||||
</select>
|
||||
</div>
|
||||
|
||||
{/* Project Selector */}
|
||||
<div className="flex items-center gap-1.5 text-xs text-slate-400">
|
||||
<FolderKanban className="w-3.5 h-3.5 text-amber-400" />
|
||||
<select
|
||||
value={selectedProjectId}
|
||||
onChange={(e) => setSelectedProjectId(e.target.value)}
|
||||
className="bg-slate-900 border border-slate-800 rounded-xl px-3 py-1.5 text-xs text-white focus:outline-none focus:border-amber-500 max-w-[180px] truncate"
|
||||
>
|
||||
{projects.map((p) => (
|
||||
<option key={p.id} value={p.id}>
|
||||
{p.name}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Actions: Refresh, Export, Request Change */}
|
||||
<div className="flex items-center gap-2 flex-wrap justify-end">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => {
|
||||
refetchChanges();
|
||||
success('Refreshed', 'Change requests updated from API.');
|
||||
}}
|
||||
disabled={fetchingChanges}
|
||||
className="flex items-center gap-1.5 px-3 py-1.5 rounded-xl text-xs font-semibold bg-slate-900 text-slate-300 hover:text-white border border-slate-800 hover:bg-slate-800 transition cursor-pointer disabled:opacity-50"
|
||||
title="Force refresh change requests"
|
||||
>
|
||||
<RefreshCw className={`w-3.5 h-3.5 text-amber-400 ${fetchingChanges ? 'animate-spin' : ''}`} />
|
||||
<span>Refresh</span>
|
||||
</button>
|
||||
|
||||
<ExportButton
|
||||
data={sortedChanges}
|
||||
columns={changeExportCols}
|
||||
filename={`change_requests_${new Date().toISOString().split('T')[0]}`}
|
||||
title="Change Requests Register"
|
||||
/>
|
||||
|
||||
<button
|
||||
onClick={() => {
|
||||
setEditingChange(null);
|
||||
changeForm.reset({
|
||||
title: '',
|
||||
justification: '',
|
||||
revisedBudget: 0,
|
||||
revisionDate: new Date().toISOString().split('T')[0],
|
||||
});
|
||||
setIsChangeModalOpen(true);
|
||||
}}
|
||||
className="flex items-center gap-1.5 px-3.5 py-1.5 rounded-xl text-xs font-semibold bg-slate-800 text-white hover:bg-slate-700 border border-slate-700 shadow-md transition cursor-pointer"
|
||||
>
|
||||
<GitPullRequest className="w-3.5 h-3.5 text-blue-400" />
|
||||
<span>Request Change</span>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="bg-[#0f1523]/80 backdrop-blur-md rounded-2xl border border-slate-800/80 shadow-xl overflow-hidden">
|
||||
<div className="overflow-x-auto">
|
||||
<table className="w-full text-left border-collapse">
|
||||
<thead>
|
||||
@@ -696,6 +814,7 @@ export const ChangeManagementPage: React.FC = () => {
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
|
||||
Reference in New Issue
Block a user