Fixed UI inconsistencies
This commit is contained in:
@@ -53,6 +53,7 @@ export default function App() {
|
|||||||
<Route path="/beneficiaries" element={<BeneficiariesPage />} />
|
<Route path="/beneficiaries" element={<BeneficiariesPage />} />
|
||||||
<Route path="/projects/change-management" element={<ChangeManagementPage />} />
|
<Route path="/projects/change-management" element={<ChangeManagementPage />} />
|
||||||
<Route path="/change-management" element={<ChangeManagementPage />} />
|
<Route path="/change-management" element={<ChangeManagementPage />} />
|
||||||
|
<Route path="/projects/site-visits" element={<SiteVisitsPage />} />
|
||||||
<Route path="/site-visits" element={<SiteVisitsPage />} />
|
<Route path="/site-visits" element={<SiteVisitsPage />} />
|
||||||
<Route path="/grants" element={<GrantsPage />} />
|
<Route path="/grants" element={<GrantsPage />} />
|
||||||
<Route path="/monitoring" element={<MonitoringPage />} />
|
<Route path="/monitoring" element={<MonitoringPage />} />
|
||||||
|
|||||||
@@ -73,14 +73,14 @@ const navItems: NavItem[] = [
|
|||||||
icon: GitPullRequest,
|
icon: GitPullRequest,
|
||||||
help: 'Track project issues, contractual change requests, and resolve changes in-place.',
|
help: 'Track project issues, contractual change requests, and resolve changes in-place.',
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
name: 'Site Visits',
|
||||||
|
path: '/projects/site-visits',
|
||||||
|
icon: MapPin,
|
||||||
|
help: 'Log physical site visits, field observations, inspector findings, and project feedback.',
|
||||||
|
},
|
||||||
],
|
],
|
||||||
},
|
},
|
||||||
{
|
|
||||||
name: 'Site Visits',
|
|
||||||
path: '/site-visits',
|
|
||||||
icon: MapPin,
|
|
||||||
help: 'Log physical site visits, field observations, inspector findings, and project feedback.',
|
|
||||||
},
|
|
||||||
{
|
{
|
||||||
name: 'Grant & Financials',
|
name: 'Grant & Financials',
|
||||||
path: '/grants',
|
path: '/grants',
|
||||||
|
|||||||
@@ -72,6 +72,7 @@ export const BeneficiariesPage: React.FC = () => {
|
|||||||
const [activeTab, setActiveTab] = useState<'beneficiaries' | 'compliance'>('beneficiaries');
|
const [activeTab, setActiveTab] = useState<'beneficiaries' | 'compliance'>('beneficiaries');
|
||||||
const [searchTerm, setSearchTerm] = useState('');
|
const [searchTerm, setSearchTerm] = useState('');
|
||||||
const [categoryFilter, setCategoryFilter] = useState('all');
|
const [categoryFilter, setCategoryFilter] = useState('all');
|
||||||
|
const [compStatusFilter, setCompStatusFilter] = useState('all');
|
||||||
|
|
||||||
// Modal states
|
// Modal states
|
||||||
const [isBenModalOpen, setIsBenModalOpen] = useState(false);
|
const [isBenModalOpen, setIsBenModalOpen] = useState(false);
|
||||||
@@ -144,7 +145,14 @@ export const BeneficiariesPage: React.FC = () => {
|
|||||||
const createBenMutation = useMutation({
|
const createBenMutation = useMutation({
|
||||||
mutationFn: (vals: BeneficiaryFormValues) => {
|
mutationFn: (vals: BeneficiaryFormValues) => {
|
||||||
if (!effectiveProjectId) throw new Error('No project selected');
|
if (!effectiveProjectId) throw new Error('No project selected');
|
||||||
return createBeneficiary({ ...vals, projectId: effectiveProjectId });
|
return createBeneficiary({
|
||||||
|
projectId: effectiveProjectId,
|
||||||
|
name: vals.name,
|
||||||
|
location: vals.location,
|
||||||
|
category: vals.category,
|
||||||
|
targetCount: vals.targetCount,
|
||||||
|
reachedCount: vals.reachedCount,
|
||||||
|
});
|
||||||
},
|
},
|
||||||
onSuccess: () => {
|
onSuccess: () => {
|
||||||
queryClient.invalidateQueries({ queryKey: ['beneficiaries'] });
|
queryClient.invalidateQueries({ queryKey: ['beneficiaries'] });
|
||||||
@@ -158,7 +166,14 @@ export const BeneficiariesPage: React.FC = () => {
|
|||||||
const updateBenMutation = useMutation({
|
const updateBenMutation = useMutation({
|
||||||
mutationFn: (vals: BeneficiaryFormValues) => {
|
mutationFn: (vals: BeneficiaryFormValues) => {
|
||||||
if (!editingBen) throw new Error('No beneficiary selected');
|
if (!editingBen) throw new Error('No beneficiary selected');
|
||||||
return updateBeneficiary({ ...vals, beneficiaryId: editingBen.id });
|
return updateBeneficiary({
|
||||||
|
beneficiaryId: editingBen.id,
|
||||||
|
name: vals.name,
|
||||||
|
location: vals.location,
|
||||||
|
category: vals.category,
|
||||||
|
targetCount: vals.targetCount,
|
||||||
|
reachedCount: vals.reachedCount,
|
||||||
|
});
|
||||||
},
|
},
|
||||||
onSuccess: () => {
|
onSuccess: () => {
|
||||||
queryClient.invalidateQueries({ queryKey: ['beneficiaries'] });
|
queryClient.invalidateQueries({ queryKey: ['beneficiaries'] });
|
||||||
@@ -236,13 +251,17 @@ export const BeneficiariesPage: React.FC = () => {
|
|||||||
// Filtered & Sorted Compliance Items
|
// Filtered & Sorted Compliance Items
|
||||||
const filteredComps = useMemo(() => {
|
const filteredComps = useMemo(() => {
|
||||||
return complianceItems.filter((c) => {
|
return complianceItems.filter((c) => {
|
||||||
return (
|
const matchesSearch =
|
||||||
!searchTerm.trim() ||
|
!searchTerm.trim() ||
|
||||||
(c.title && c.title.toLowerCase().includes(searchTerm.toLowerCase())) ||
|
(c.title && c.title.toLowerCase().includes(searchTerm.toLowerCase())) ||
|
||||||
(c.requirements && c.requirements.toLowerCase().includes(searchTerm.toLowerCase()))
|
(c.requirements && c.requirements.toLowerCase().includes(searchTerm.toLowerCase()));
|
||||||
);
|
const matchesStatus =
|
||||||
|
compStatusFilter === 'all' ||
|
||||||
|
(compStatusFilter === 'approved' && c.status === ApprovalStatus.Approved) ||
|
||||||
|
(compStatusFilter === 'pending' && c.status !== ApprovalStatus.Approved);
|
||||||
|
return matchesSearch && matchesStatus;
|
||||||
});
|
});
|
||||||
}, [complianceItems, searchTerm]);
|
}, [complianceItems, searchTerm, compStatusFilter]);
|
||||||
|
|
||||||
const { sortedData: sortedComps, sortColumn: compSortCol, sortDirection: compSortDir, handleSort: handleCompSort } =
|
const { sortedData: sortedComps, sortColumn: compSortCol, sortDirection: compSortDir, handleSort: handleCompSort } =
|
||||||
useTableSort(filteredComps);
|
useTableSort(filteredComps);
|
||||||
@@ -265,7 +284,7 @@ export const BeneficiariesPage: React.FC = () => {
|
|||||||
const compExportColumns: ExportColumn<ComplianceItem>[] = [
|
const compExportColumns: ExportColumn<ComplianceItem>[] = [
|
||||||
{ header: 'Compliance Requirement', key: 'title' },
|
{ header: 'Compliance Requirement', key: 'title' },
|
||||||
{ header: 'Requirements Detail', key: 'requirements' },
|
{ header: 'Requirements Detail', key: 'requirements' },
|
||||||
{ header: 'Status', accessor: (c) => (c.status === ApprovalStatus.Approved ? 'Approved' : 'Pending Review') },
|
{ header: 'Status', key: 'status', format: (val) => (val === ApprovalStatus.Approved ? 'Approved' : 'Pending Review') },
|
||||||
];
|
];
|
||||||
|
|
||||||
const handleOpenEditBen = (b: Beneficiary) => {
|
const handleOpenEditBen = (b: Beneficiary) => {
|
||||||
@@ -310,60 +329,6 @@ export const BeneficiariesPage: React.FC = () => {
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="flex items-center gap-2.5 flex-wrap relative z-10">
|
|
||||||
<button
|
|
||||||
onClick={() => {
|
|
||||||
refetchBens();
|
|
||||||
refetchComps();
|
|
||||||
}}
|
|
||||||
disabled={fetchingBens || fetchingComps}
|
|
||||||
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 ${fetchingBens || fetchingComps ? 'animate-spin text-blue-400' : ''}`} />
|
|
||||||
<span>Refresh</span>
|
|
||||||
</button>
|
|
||||||
|
|
||||||
{activeTab === 'beneficiaries' ? (
|
|
||||||
<ExportButton
|
|
||||||
data={sortedBens}
|
|
||||||
columns={benExportColumns}
|
|
||||||
filename="project_beneficiaries"
|
|
||||||
title="Beneficiaries Register"
|
|
||||||
/>
|
|
||||||
) : (
|
|
||||||
<ExportButton
|
|
||||||
data={sortedComps}
|
|
||||||
columns={compExportColumns}
|
|
||||||
filename="compliance_items"
|
|
||||||
title="Compliance Requirements"
|
|
||||||
/>
|
|
||||||
)}
|
|
||||||
|
|
||||||
<button
|
|
||||||
onClick={() => {
|
|
||||||
setEditingBen(null);
|
|
||||||
benForm.reset({ name: '', category: 'Youth & Skills', location: 'National', targetCount: 100, reachedCount: 0 });
|
|
||||||
setIsBenModalOpen(true);
|
|
||||||
}}
|
|
||||||
className="flex items-center gap-2 px-4 py-2.5 rounded-xl text-xs font-semibold bg-gradient-to-r from-blue-600 to-indigo-600 text-white hover:from-blue-500 hover:to-indigo-500 shadow-lg shadow-blue-600/25 transition cursor-pointer"
|
|
||||||
>
|
|
||||||
<Plus className="w-4 h-4" />
|
|
||||||
<span>Register Beneficiary</span>
|
|
||||||
</button>
|
|
||||||
|
|
||||||
<button
|
|
||||||
onClick={() => {
|
|
||||||
setEditingComp(null);
|
|
||||||
compForm.reset({ title: '', requirements: '', status: ApprovalStatus.Pending });
|
|
||||||
setIsCompModalOpen(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"
|
|
||||||
>
|
|
||||||
<CheckSquare className="w-4 h-4 text-emerald-400" />
|
|
||||||
<span>Add Compliance Item</span>
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* KPI Cards */}
|
{/* KPI Cards */}
|
||||||
@@ -413,67 +378,122 @@ export const BeneficiariesPage: React.FC = () => {
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Tabs & Filters */}
|
{/* Tabs */}
|
||||||
<div className="space-y-4">
|
<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 border-b border-slate-800 bg-[#0d121f]/80 px-2 sm:px-4 rounded-2xl backdrop-blur-sm overflow-x-auto gap-2">
|
||||||
<div className="flex items-center gap-2">
|
<button
|
||||||
<button
|
onClick={() => setActiveTab('beneficiaries')}
|
||||||
onClick={() => setActiveTab('beneficiaries')}
|
className={`py-3.5 px-4 text-xs font-semibold border-b-2 transition-colors flex items-center gap-2 cursor-pointer whitespace-nowrap ${
|
||||||
className={`flex items-center gap-2 px-4 py-2 rounded-xl text-xs font-semibold transition cursor-pointer ${
|
activeTab === 'beneficiaries'
|
||||||
activeTab === 'beneficiaries'
|
? 'border-blue-500 text-blue-400'
|
||||||
? 'bg-blue-600 text-white shadow-md shadow-blue-600/30'
|
: 'border-transparent text-slate-400 hover:text-slate-200'
|
||||||
: 'text-slate-400 hover:text-white hover:bg-slate-800/60'
|
}`}
|
||||||
}`}
|
>
|
||||||
>
|
<Users className="w-4 h-4" />
|
||||||
<Users className="w-3.5 h-3.5" />
|
<span>Beneficiaries Register ({beneficiaries.length})</span>
|
||||||
<span>Beneficiaries List ({beneficiaries.length})</span>
|
</button>
|
||||||
</button>
|
|
||||||
|
|
||||||
<button
|
<button
|
||||||
onClick={() => setActiveTab('compliance')}
|
onClick={() => setActiveTab('compliance')}
|
||||||
className={`flex items-center gap-2 px-4 py-2 rounded-xl text-xs font-semibold transition cursor-pointer ${
|
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 === 'compliance'
|
activeTab === 'compliance'
|
||||||
? 'bg-blue-600 text-white shadow-md shadow-blue-600/30'
|
? 'border-blue-500 text-blue-400'
|
||||||
: 'text-slate-400 hover:text-white hover:bg-slate-800/60'
|
: 'border-transparent text-slate-400 hover:text-slate-200'
|
||||||
}`}
|
}`}
|
||||||
>
|
>
|
||||||
<CheckSquare className="w-3.5 h-3.5" />
|
<CheckSquare className="w-4 h-4" />
|
||||||
<span>Compliance Items ({complianceItems.length})</span>
|
<span>Compliance Requirements ({complianceItems.length})</span>
|
||||||
</button>
|
</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-blue-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-blue-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-blue-500 w-40 sm:w-56"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Tab 1: Beneficiaries Table */}
|
{/* Tab 1: Beneficiaries Tab Toolbar & Content */}
|
||||||
{activeTab === 'beneficiaries' && (
|
{activeTab === 'beneficiaries' && (
|
||||||
|
<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 name, location..."
|
||||||
|
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-blue-500 shadow-xs"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Category Filter */}
|
||||||
|
<div className="flex items-center gap-1.5 text-xs text-slate-400">
|
||||||
|
<select
|
||||||
|
value={categoryFilter}
|
||||||
|
onChange={(e) => setCategoryFilter(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-blue-500"
|
||||||
|
>
|
||||||
|
<option value="all">All Categories</option>
|
||||||
|
<option value="Youth & Skills">Youth & Skills</option>
|
||||||
|
<option value="Women-Led">Women-Led</option>
|
||||||
|
<option value="SME / Enterprise">SME / Enterprise</option>
|
||||||
|
<option value="Agriculture & Rural">Agriculture & Rural</option>
|
||||||
|
<option value="Healthcare & Community">Healthcare & Community</option>
|
||||||
|
<option value="Education">Education</option>
|
||||||
|
<option value="General">General</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-blue-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-blue-500 max-w-[180px] truncate"
|
||||||
|
>
|
||||||
|
{projects.map((p) => (
|
||||||
|
<option key={p.id} value={p.id}>
|
||||||
|
{p.name}
|
||||||
|
</option>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Actions: Refresh, Export, Register */}
|
||||||
|
<div className="flex items-center gap-2 flex-wrap justify-end">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => {
|
||||||
|
refetchBens();
|
||||||
|
success('Refreshed', 'Beneficiaries register updated from API.');
|
||||||
|
}}
|
||||||
|
disabled={fetchingBens}
|
||||||
|
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 beneficiaries"
|
||||||
|
>
|
||||||
|
<RefreshCw className={`w-3.5 h-3.5 text-blue-400 ${fetchingBens ? 'animate-spin' : ''}`} />
|
||||||
|
<span>Refresh</span>
|
||||||
|
</button>
|
||||||
|
|
||||||
|
<ExportButton
|
||||||
|
data={sortedBens}
|
||||||
|
columns={benExportColumns}
|
||||||
|
filename={`project_beneficiaries_${new Date().toISOString().split('T')[0]}`}
|
||||||
|
title="Beneficiaries Register"
|
||||||
|
/>
|
||||||
|
|
||||||
|
<button
|
||||||
|
onClick={() => {
|
||||||
|
setEditingBen(null);
|
||||||
|
benForm.reset({ name: '', category: 'Youth & Skills', location: 'National', targetCount: 100, reachedCount: 0 });
|
||||||
|
setIsBenModalOpen(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-blue-600 to-indigo-600 text-white hover:from-blue-500 hover:to-indigo-500 shadow-md shadow-blue-600/25 transition cursor-pointer"
|
||||||
|
>
|
||||||
|
<Plus className="w-3.5 h-3.5" />
|
||||||
|
<span>Register Beneficiary</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="bg-[#0f1523]/80 backdrop-blur-md rounded-2xl border border-slate-800/80 shadow-xl overflow-hidden">
|
||||||
<div className="overflow-x-auto">
|
<div className="overflow-x-auto">
|
||||||
<table className="w-full text-left border-collapse">
|
<table className="w-full text-left border-collapse">
|
||||||
@@ -572,11 +592,94 @@ export const BeneficiariesPage: React.FC = () => {
|
|||||||
</table>
|
</table>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{/* Tab 2: Compliance Items Table */}
|
{/* Tab 2: Compliance Items Table */}
|
||||||
{activeTab === 'compliance' && (
|
{activeTab === 'compliance' && (
|
||||||
<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 requirement or title..."
|
||||||
|
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-blue-500 shadow-xs"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Status Filter */}
|
||||||
|
<div className="flex items-center gap-1.5 text-xs text-slate-400">
|
||||||
|
<select
|
||||||
|
value={compStatusFilter}
|
||||||
|
onChange={(e) => setCompStatusFilter(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-blue-500"
|
||||||
|
>
|
||||||
|
<option value="all">All Statuses</option>
|
||||||
|
<option value="approved">Approved / Satisfied</option>
|
||||||
|
<option value="pending">Pending Review</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-blue-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-blue-500 max-w-[180px] truncate"
|
||||||
|
>
|
||||||
|
{projects.map((p) => (
|
||||||
|
<option key={p.id} value={p.id}>
|
||||||
|
{p.name}
|
||||||
|
</option>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Actions: Refresh, Export, Add Compliance Item */}
|
||||||
|
<div className="flex items-center gap-2 flex-wrap justify-end">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => {
|
||||||
|
refetchComps();
|
||||||
|
success('Refreshed', 'Compliance items updated from API.');
|
||||||
|
}}
|
||||||
|
disabled={fetchingComps}
|
||||||
|
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 compliance checklist"
|
||||||
|
>
|
||||||
|
<RefreshCw className={`w-3.5 h-3.5 text-blue-400 ${fetchingComps ? 'animate-spin' : ''}`} />
|
||||||
|
<span>Refresh</span>
|
||||||
|
</button>
|
||||||
|
|
||||||
|
<ExportButton
|
||||||
|
data={sortedComps}
|
||||||
|
columns={compExportColumns}
|
||||||
|
filename={`compliance_requirements_${new Date().toISOString().split('T')[0]}`}
|
||||||
|
title="Compliance Requirements"
|
||||||
|
/>
|
||||||
|
|
||||||
|
<button
|
||||||
|
onClick={() => {
|
||||||
|
setEditingComp(null);
|
||||||
|
compForm.reset({ title: '', requirements: '', status: ApprovalStatus.Pending });
|
||||||
|
setIsCompModalOpen(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"
|
||||||
|
>
|
||||||
|
<CheckSquare className="w-3.5 h-3.5 text-emerald-400" />
|
||||||
|
<span>Add Compliance Item</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">
|
<div className="overflow-x-auto">
|
||||||
<table className="w-full text-left border-collapse">
|
<table className="w-full text-left border-collapse">
|
||||||
<thead>
|
<thead>
|
||||||
@@ -642,6 +745,7 @@ export const BeneficiariesPage: React.FC = () => {
|
|||||||
</table>
|
</table>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
</div>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
|||||||
@@ -83,6 +83,8 @@ export const ChangeManagementPage: React.FC = () => {
|
|||||||
const [activeTab, setActiveTab] = useState<'issues' | 'changes'>('issues');
|
const [activeTab, setActiveTab] = useState<'issues' | 'changes'>('issues');
|
||||||
const [searchTerm, setSearchTerm] = useState('');
|
const [searchTerm, setSearchTerm] = useState('');
|
||||||
const [priorityFilter, setPriorityFilter] = useState('all');
|
const [priorityFilter, setPriorityFilter] = useState('all');
|
||||||
|
const [issueStatusFilter, setIssueStatusFilter] = useState('all');
|
||||||
|
const [changeStatusFilter, setChangeStatusFilter] = useState('all');
|
||||||
|
|
||||||
// Modals
|
// Modals
|
||||||
const [isIssueModalOpen, setIsIssueModalOpen] = useState(false);
|
const [isIssueModalOpen, setIsIssueModalOpen] = useState(false);
|
||||||
@@ -276,9 +278,14 @@ export const ChangeManagementPage: React.FC = () => {
|
|||||||
(iss.title && iss.title.toLowerCase().includes(searchTerm.toLowerCase())) ||
|
(iss.title && iss.title.toLowerCase().includes(searchTerm.toLowerCase())) ||
|
||||||
(iss.description && iss.description.toLowerCase().includes(searchTerm.toLowerCase()));
|
(iss.description && iss.description.toLowerCase().includes(searchTerm.toLowerCase()));
|
||||||
const matchPrio = priorityFilter === 'all' || String(iss.priority) === String(priorityFilter);
|
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 } =
|
const { sortedData: sortedIssues, sortColumn: issueSortCol, sortDirection: issueSortDir, handleSort: handleIssueSort } =
|
||||||
useTableSort(filteredIssues);
|
useTableSort(filteredIssues);
|
||||||
@@ -286,13 +293,18 @@ export const ChangeManagementPage: React.FC = () => {
|
|||||||
// Filtered & Sorted Change Requests
|
// Filtered & Sorted Change Requests
|
||||||
const filteredChanges = useMemo(() => {
|
const filteredChanges = useMemo(() => {
|
||||||
return changeRequests.filter((cr) => {
|
return changeRequests.filter((cr) => {
|
||||||
return (
|
const matchSearch =
|
||||||
!searchTerm.trim() ||
|
!searchTerm.trim() ||
|
||||||
(cr.title && cr.title.toLowerCase().includes(searchTerm.toLowerCase())) ||
|
(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 } =
|
const { sortedData: sortedChanges, sortColumn: changeSortCol, sortDirection: changeSortDir, handleSort: handleChangeSort } =
|
||||||
useTableSort(filteredChanges);
|
useTableSort(filteredChanges);
|
||||||
@@ -301,16 +313,16 @@ export const ChangeManagementPage: React.FC = () => {
|
|||||||
const issueExportCols: ExportColumn<Issue>[] = [
|
const issueExportCols: ExportColumn<Issue>[] = [
|
||||||
{ header: 'Title', key: 'title' },
|
{ header: 'Title', key: 'title' },
|
||||||
{ header: 'Description', key: 'description' },
|
{ header: 'Description', key: 'description' },
|
||||||
{ header: 'Priority', accessor: (i) => (i.priority === Priority.Critical ? 'Critical' : 'Standard') },
|
{ header: 'Priority', key: 'priority', format: (val) => (val === Priority.Critical ? 'Critical' : 'Standard') },
|
||||||
{ header: 'Assigned To', accessor: (i) => (i.assignedTo ? getUserEmail(i.assignedTo) : 'Unassigned') },
|
{ header: 'Assigned To', key: 'assignedTo', format: (val) => (val ? getUserEmail(val) : 'Unassigned') },
|
||||||
{ header: 'Status', accessor: (i) => (i.status === ActivityStatus.Active ? 'Active' : 'Resolved') },
|
{ header: 'Status', key: 'status', format: (val) => (val === ActivityStatus.Active ? 'Active' : 'Resolved') },
|
||||||
];
|
];
|
||||||
|
|
||||||
const changeExportCols: ExportColumn<ChangeRequest>[] = [
|
const changeExportCols: ExportColumn<ChangeRequest>[] = [
|
||||||
{ header: 'Change Title', key: 'title' },
|
{ header: 'Change Title', key: 'title' },
|
||||||
{ header: 'Justification', key: 'justification' },
|
{ header: 'Justification', key: 'justification' },
|
||||||
{ header: 'Revised Budget', accessor: (c) => formatZAR(c.revisedBudget || 0) },
|
{ header: 'Revised Budget', key: 'revisedBudget', format: (val) => formatZAR(val || 0) },
|
||||||
{ header: 'Status', accessor: (c) => (c.status === ApprovalStatus.Approved ? 'Approved' : 'Pending') },
|
{ header: 'Status', key: 'status', format: (val) => (val === ApprovalStatus.Approved ? 'Approved' : 'Pending') },
|
||||||
];
|
];
|
||||||
|
|
||||||
const openResolveIssue = (iss: Issue) => {
|
const openResolveIssue = (iss: Issue) => {
|
||||||
@@ -348,55 +360,6 @@ export const ChangeManagementPage: React.FC = () => {
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</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>
|
</div>
|
||||||
|
|
||||||
{/* KPI Cards */}
|
{/* KPI Cards */}
|
||||||
@@ -444,68 +407,134 @@ export const ChangeManagementPage: React.FC = () => {
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Tabs & Filters */}
|
{/* Tabs */}
|
||||||
<div className="space-y-4">
|
<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 border-b border-slate-800 bg-[#0d121f]/80 px-2 sm:px-4 rounded-2xl backdrop-blur-sm overflow-x-auto gap-2">
|
||||||
<div className="flex items-center gap-2">
|
<button
|
||||||
<button
|
onClick={() => setActiveTab('issues')}
|
||||||
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 ${
|
||||||
className={`flex items-center gap-2 px-4 py-2 rounded-xl text-xs font-semibold transition cursor-pointer ${
|
activeTab === 'issues'
|
||||||
activeTab === 'issues'
|
? 'border-amber-500 text-amber-400'
|
||||||
? 'bg-amber-600 text-white shadow-md shadow-amber-600/30'
|
: 'border-transparent text-slate-400 hover:text-slate-200'
|
||||||
: 'text-slate-400 hover:text-white hover:bg-slate-800/60'
|
}`}
|
||||||
}`}
|
>
|
||||||
>
|
<AlertOctagon className="w-4 h-4" />
|
||||||
<AlertOctagon className="w-3.5 h-3.5" />
|
<span>Project Issues ({issues.length})</span>
|
||||||
<span>Project Issues ({issues.length})</span>
|
</button>
|
||||||
</button>
|
|
||||||
|
|
||||||
<button
|
<button
|
||||||
onClick={() => setActiveTab('changes')}
|
onClick={() => setActiveTab('changes')}
|
||||||
className={`flex items-center gap-2 px-4 py-2 rounded-xl text-xs font-semibold transition cursor-pointer ${
|
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'
|
activeTab === 'changes'
|
||||||
? 'bg-amber-600 text-white shadow-md shadow-amber-600/30'
|
? 'border-amber-500 text-amber-400'
|
||||||
: 'text-slate-400 hover:text-white hover:bg-slate-800/60'
|
: 'border-transparent text-slate-400 hover:text-slate-200'
|
||||||
}`}
|
}`}
|
||||||
>
|
>
|
||||||
<GitPullRequest className="w-3.5 h-3.5" />
|
<GitPullRequest className="w-4 h-4" />
|
||||||
<span>Change Requests ({changeRequests.length})</span>
|
<span>Change Requests ({changeRequests.length})</span>
|
||||||
</button>
|
</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>
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Tab 1: Issues Table */}
|
{/* Tab 1: Issues Toolbar & Content */}
|
||||||
{activeTab === 'issues' && (
|
{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">
|
<div className="overflow-x-auto">
|
||||||
<table className="w-full text-left border-collapse">
|
<table className="w-full text-left border-collapse">
|
||||||
<thead>
|
<thead>
|
||||||
@@ -611,11 +640,100 @@ export const ChangeManagementPage: React.FC = () => {
|
|||||||
</table>
|
</table>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{/* Tab 2: Change Requests Table */}
|
{/* Tab 2: Change Requests Table */}
|
||||||
{activeTab === 'changes' && (
|
{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">
|
<div className="overflow-x-auto">
|
||||||
<table className="w-full text-left border-collapse">
|
<table className="w-full text-left border-collapse">
|
||||||
<thead>
|
<thead>
|
||||||
@@ -696,6 +814,7 @@ export const ChangeManagementPage: React.FC = () => {
|
|||||||
</table>
|
</table>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
</div>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
|||||||
@@ -14,6 +14,7 @@ import {
|
|||||||
Clock,
|
Clock,
|
||||||
ShieldAlert,
|
ShieldAlert,
|
||||||
RotateCcw,
|
RotateCcw,
|
||||||
|
LayoutDashboard,
|
||||||
} from 'lucide-react';
|
} from 'lucide-react';
|
||||||
import {
|
import {
|
||||||
getDashboard,
|
getDashboard,
|
||||||
@@ -344,59 +345,65 @@ export const ExecutiveDashboard: React.FC = () => {
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="space-y-6 max-w-7xl mx-auto">
|
<div className="space-y-6 max-w-7xl mx-auto">
|
||||||
{/* Page Title & Filter Bar */}
|
{/* Header Banner */}
|
||||||
<div className="bg-[#0d121f]/90 p-5 sm:p-6 rounded-2xl border border-slate-800/80 backdrop-blur-md shadow-xl space-y-4">
|
<div className="flex flex-col md:flex-row md:items-center justify-between gap-4 bg-gradient-to-r from-[#0d1322] via-[#0f172a] to-[#0d1322] p-6 sm:p-8 rounded-3xl border border-slate-800/80 shadow-2xl relative overflow-hidden">
|
||||||
{/* Top Tier: Title, Status Badge, and Primary Actions */}
|
<div className="absolute -right-10 -bottom-10 w-60 h-60 bg-blue-600/10 rounded-full blur-3xl pointer-events-none" />
|
||||||
<div className="flex flex-col lg:flex-row lg:items-center justify-between gap-4">
|
<div className="space-y-2 relative z-10">
|
||||||
<div>
|
<div className="flex items-center gap-3">
|
||||||
<div className="flex items-center gap-2.5 flex-wrap">
|
<div className="h-12 w-12 rounded-2xl bg-blue-600/20 border border-blue-500/30 flex items-center justify-center text-blue-400 shadow-lg shadow-blue-500/10">
|
||||||
<h2 className="text-xl sm:text-2xl font-bold text-white tracking-tight">
|
<LayoutDashboard className="w-6 h-6" />
|
||||||
Executive Portfolio Dashboard
|
</div>
|
||||||
</h2>
|
<div>
|
||||||
<HelpTooltip
|
<div className="flex items-center gap-2.5 flex-wrap">
|
||||||
title="Executive Dashboard Overview"
|
<h1 className="text-2xl sm:text-3xl font-bold tracking-tight text-white flex items-center gap-2.5">
|
||||||
content="Macro-level command center showing aggregated national portfolio commitments, actual tranche disbursements, delivery pace, and critical project escalations."
|
Executive Portfolio Dashboard
|
||||||
size="sm"
|
</h1>
|
||||||
placement="bottom"
|
<HelpTooltip
|
||||||
/>
|
title="Executive Dashboard Overview"
|
||||||
<span className="inline-flex items-center gap-1.5 text-[11px] font-semibold text-blue-400 bg-blue-500/10 px-2.5 py-0.5 rounded-full border border-blue-500/30">
|
content="Macro-level command center showing aggregated national portfolio commitments, actual tranche disbursements, delivery pace, and critical project escalations."
|
||||||
<span className="w-1.5 h-1.5 rounded-full bg-blue-400 animate-pulse" />
|
size="sm"
|
||||||
Live Portfolio Feed
|
placement="bottom"
|
||||||
</span>
|
/>
|
||||||
|
<span className="inline-flex items-center gap-1.5 text-[11px] font-semibold text-blue-400 bg-blue-500/10 px-2.5 py-0.5 rounded-full border border-blue-500/30">
|
||||||
|
<span className="w-1.5 h-1.5 rounded-full bg-blue-400 animate-pulse" />
|
||||||
|
Live Portfolio Feed
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<p className="text-xs sm:text-sm text-slate-400 mt-1">
|
||||||
|
Real-time portfolio overview, disbursement tracking, risk heatmap, and projects requiring governance action.
|
||||||
|
</p>
|
||||||
</div>
|
</div>
|
||||||
<p className="text-xs sm:text-sm text-slate-400 mt-1">
|
|
||||||
Real-time portfolio overview, disbursement tracking, risk heatmap, and projects requiring governance action.
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="flex items-center gap-2.5 shrink-0 self-start lg:self-center">
|
|
||||||
{isFiltered && (
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
onClick={resetFilters}
|
|
||||||
className="inline-flex items-center gap-1.5 px-3 py-1.5 text-xs font-semibold text-amber-300 bg-amber-500/10 hover:bg-amber-500/20 border border-amber-500/30 rounded-xl transition cursor-pointer shadow-xs"
|
|
||||||
title="Reset all filters to default"
|
|
||||||
>
|
|
||||||
<RotateCcw className="w-3.5 h-3.5" />
|
|
||||||
<span>Reset Filters</span>
|
|
||||||
</button>
|
|
||||||
)}
|
|
||||||
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
onClick={refetchAll}
|
|
||||||
disabled={isFetching}
|
|
||||||
className="inline-flex items-center gap-1.5 px-3.5 py-1.5 text-xs font-semibold text-slate-200 hover:text-white bg-slate-900 hover:bg-slate-800 border border-slate-700/80 rounded-xl transition cursor-pointer shadow-xs disabled:opacity-50"
|
|
||||||
title="Refresh dashboard metrics"
|
|
||||||
>
|
|
||||||
<RefreshCw className={`w-3.5 h-3.5 ${isFetching ? 'animate-spin text-blue-400' : 'text-slate-400'}`} />
|
|
||||||
<span>{isFetching ? 'Refreshing...' : 'Refresh'}</span>
|
|
||||||
</button>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Dedicated Filter Toolbar */}
|
<div className="flex items-center gap-2.5 shrink-0 relative z-10 self-start md:self-center">
|
||||||
<div className="pt-4 border-t border-slate-800/80 flex flex-wrap items-center gap-3">
|
{isFiltered && (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={resetFilters}
|
||||||
|
className="inline-flex items-center gap-1.5 px-3.5 py-2 text-xs font-semibold text-amber-300 bg-amber-500/10 hover:bg-amber-500/20 border border-amber-500/30 rounded-xl transition cursor-pointer shadow-xs"
|
||||||
|
title="Reset all filters to default"
|
||||||
|
>
|
||||||
|
<RotateCcw className="w-3.5 h-3.5" />
|
||||||
|
<span>Reset Filters</span>
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={refetchAll}
|
||||||
|
disabled={isFetching}
|
||||||
|
className="inline-flex items-center gap-1.5 px-3.5 py-2 text-xs font-semibold text-slate-200 hover:text-white bg-slate-900/90 hover:bg-slate-800 border border-slate-700/80 rounded-xl transition cursor-pointer shadow-xs disabled:opacity-50"
|
||||||
|
title="Refresh dashboard metrics"
|
||||||
|
>
|
||||||
|
<RefreshCw className={`w-3.5 h-3.5 ${isFetching ? 'animate-spin text-blue-400' : 'text-slate-400'}`} />
|
||||||
|
<span>{isFetching ? 'Refreshing...' : 'Refresh'}</span>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Filter Toolbar */}
|
||||||
|
<div className="bg-[#0f1523]/80 backdrop-blur-md p-4 rounded-2xl border border-slate-800/80 shadow-xl flex flex-wrap items-center gap-3">
|
||||||
<div className="flex items-center gap-2 text-xs font-semibold text-slate-300 pr-1">
|
<div className="flex items-center gap-2 text-xs font-semibold text-slate-300 pr-1">
|
||||||
<Filter className="w-3.5 h-3.5 text-blue-400" />
|
<Filter className="w-3.5 h-3.5 text-blue-400" />
|
||||||
<span>Filters:</span>
|
<span>Filters:</span>
|
||||||
@@ -479,7 +486,6 @@ export const ExecutiveDashboard: React.FC = () => {
|
|||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* 6 Headline Portfolio KPI Cards */}
|
{/* 6 Headline Portfolio KPI Cards */}
|
||||||
<div className="grid grid-cols-2 md:grid-cols-3 lg:grid-cols-6 gap-4">
|
<div className="grid grid-cols-2 md:grid-cols-3 lg:grid-cols-6 gap-4">
|
||||||
|
|||||||
@@ -717,72 +717,80 @@ export const GovernancePage: React.FC = () => {
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="space-y-6 max-w-7xl mx-auto">
|
<div className="space-y-6 max-w-7xl mx-auto">
|
||||||
{/* Header */}
|
{/* Header Banner */}
|
||||||
<div className="flex flex-col sm:flex-row sm:items-center justify-between gap-4 bg-[#0d121f]/90 p-5 rounded-2xl border border-slate-800/80 backdrop-blur-md shadow-xl">
|
<div className="flex flex-col md:flex-row md:items-center justify-between gap-4 bg-gradient-to-r from-[#0d1322] via-[#0f172a] to-[#0d1322] p-6 sm:p-8 rounded-3xl border border-slate-800/80 shadow-2xl relative overflow-hidden">
|
||||||
<div>
|
<div className="absolute -right-10 -bottom-10 w-60 h-60 bg-blue-600/10 rounded-full blur-3xl pointer-events-none" />
|
||||||
<div className="flex items-center gap-2">
|
<div className="space-y-2 relative z-10">
|
||||||
<h2 className="text-xl font-bold text-white tracking-tight">
|
<div className="flex items-center gap-3">
|
||||||
Governance & Structure
|
<div className="h-12 w-12 rounded-2xl bg-blue-600/20 border border-blue-500/30 flex items-center justify-center text-blue-400 shadow-lg shadow-blue-500/10">
|
||||||
</h2>
|
<Building2 className="w-6 h-6" />
|
||||||
<span className="text-[11px] font-semibold text-blue-400 bg-blue-500/10 px-2.5 py-0.5 rounded-full border border-blue-500/30">
|
</div>
|
||||||
Directory
|
<div>
|
||||||
</span>
|
<div className="flex items-center gap-2.5 flex-wrap">
|
||||||
<HelpTooltip
|
<h1 className="text-2xl sm:text-3xl font-bold tracking-tight text-white flex items-center gap-2.5">
|
||||||
title="Statutory Governance Architecture"
|
Governance & Structure
|
||||||
content="Maintain authoritative organizational profiles, funding portfolios, and strategic programmes. Ensures compliance with national grant mandates and statutory reporting standards."
|
</h1>
|
||||||
placement="right"
|
<span className="text-[11px] font-semibold text-blue-400 bg-blue-500/10 px-2.5 py-0.5 rounded-full border border-blue-500/30">
|
||||||
/>
|
Directory
|
||||||
|
</span>
|
||||||
|
<HelpTooltip
|
||||||
|
title="Statutory Governance Architecture"
|
||||||
|
content="Maintain authoritative organizational profiles, funding portfolios, and strategic programmes. Ensures compliance with national grant mandates and statutory reporting standards."
|
||||||
|
placement="right"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<p className="text-xs sm:text-sm text-slate-400 mt-1">
|
||||||
|
Maintain Master Organisation Registry, Funder Portfolios, Strategic Programmes, Funding Instruments, and Programme Rules.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<p className="text-xs text-slate-400 mt-1">
|
|
||||||
Maintain Master Organisation Registry, Funder Portfolios, Strategic Programmes, Funding Instruments, and Programme Rules.
|
|
||||||
</p>
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Tab Switcher & CTA */}
|
{/* Tab Actions */}
|
||||||
<div className="flex items-center gap-2">
|
<div className="flex items-center gap-2 relative z-10 self-start md:self-center flex-wrap">
|
||||||
{activeTab === 'organisations' && (
|
{activeTab === 'organisations' && (
|
||||||
<button
|
<button
|
||||||
onClick={openCreateOrg}
|
onClick={openCreateOrg}
|
||||||
className="inline-flex items-center gap-1.5 px-4 py-2 text-xs font-semibold text-white bg-gradient-to-r from-blue-600 to-indigo-600 hover:from-blue-500 hover:to-indigo-500 rounded-xl shadow-md shadow-blue-500/20 transition cursor-pointer"
|
className="inline-flex items-center gap-1.5 px-4 py-2.5 text-xs font-semibold text-white bg-gradient-to-r from-blue-600 to-indigo-600 hover:from-blue-500 hover:to-indigo-500 rounded-xl shadow-lg shadow-blue-600/25 transition cursor-pointer"
|
||||||
>
|
>
|
||||||
<Plus className="w-4 h-4" />
|
<Plus className="w-4 h-4" />
|
||||||
Register Organisation
|
<span>Register Organisation</span>
|
||||||
</button>
|
</button>
|
||||||
)}
|
)}
|
||||||
{activeTab === 'portfolios' && (
|
{activeTab === 'portfolios' && (
|
||||||
<button
|
<button
|
||||||
onClick={openCreatePortfolio}
|
onClick={openCreatePortfolio}
|
||||||
className="inline-flex items-center gap-1.5 px-4 py-2 text-xs font-semibold text-white bg-gradient-to-r from-blue-600 to-indigo-600 hover:from-blue-500 hover:to-indigo-500 rounded-xl shadow-md shadow-blue-500/20 transition cursor-pointer"
|
className="inline-flex items-center gap-1.5 px-4 py-2.5 text-xs font-semibold text-white bg-gradient-to-r from-blue-600 to-indigo-600 hover:from-blue-500 hover:to-indigo-500 rounded-xl shadow-lg shadow-blue-600/25 transition cursor-pointer"
|
||||||
>
|
>
|
||||||
<Plus className="w-4 h-4" />
|
<Plus className="w-4 h-4" />
|
||||||
Create Portfolio
|
<span>Create Portfolio</span>
|
||||||
</button>
|
</button>
|
||||||
)}
|
)}
|
||||||
{activeTab === 'programmes' && (
|
{activeTab === 'programmes' && (
|
||||||
<button
|
<button
|
||||||
onClick={openCreateProgramme}
|
onClick={openCreateProgramme}
|
||||||
className="inline-flex items-center gap-1.5 px-4 py-2 text-xs font-semibold text-white bg-gradient-to-r from-blue-600 to-indigo-600 hover:from-blue-500 hover:to-indigo-500 rounded-xl shadow-md shadow-blue-500/20 transition cursor-pointer"
|
className="inline-flex items-center gap-1.5 px-4 py-2.5 text-xs font-semibold text-white bg-gradient-to-r from-blue-600 to-indigo-600 hover:from-blue-500 hover:to-indigo-500 rounded-xl shadow-lg shadow-blue-600/25 transition cursor-pointer"
|
||||||
>
|
>
|
||||||
<Plus className="w-4 h-4" />
|
<Plus className="w-4 h-4" />
|
||||||
Create Programme
|
<span>Create Programme</span>
|
||||||
</button>
|
</button>
|
||||||
)}
|
)}
|
||||||
{(activeTab === 'instruments' || activeTab === 'instructions') && (
|
{(activeTab === 'instruments' || activeTab === 'instructions') && (
|
||||||
<button
|
<button
|
||||||
onClick={() => setIsInstructionCreateTriggered(true)}
|
onClick={() => setIsInstructionCreateTriggered(true)}
|
||||||
className="inline-flex items-center gap-1.5 px-4 py-2 text-xs font-semibold text-white bg-gradient-to-r from-blue-600 to-indigo-600 hover:from-blue-500 hover:to-indigo-500 rounded-xl shadow-md shadow-blue-500/20 transition cursor-pointer"
|
className="inline-flex items-center gap-1.5 px-4 py-2.5 text-xs font-semibold text-white bg-gradient-to-r from-blue-600 to-indigo-600 hover:from-blue-500 hover:to-indigo-500 rounded-xl shadow-lg shadow-blue-600/25 transition cursor-pointer"
|
||||||
>
|
>
|
||||||
<Plus className="w-4 h-4" />
|
<Plus className="w-4 h-4" />
|
||||||
Add Funding Instrument
|
<span>Add Funding Instrument</span>
|
||||||
</button>
|
</button>
|
||||||
)}
|
)}
|
||||||
{activeTab === 'rules' && (
|
{activeTab === 'rules' && (
|
||||||
<button
|
<button
|
||||||
onClick={() => setIsRuleCreateTriggered(true)}
|
onClick={() => setIsRuleCreateTriggered(true)}
|
||||||
className="inline-flex items-center gap-1.5 px-4 py-2 text-xs font-semibold text-white bg-gradient-to-r from-blue-600 to-indigo-600 hover:from-blue-500 hover:to-indigo-500 rounded-xl shadow-md shadow-blue-500/20 transition cursor-pointer"
|
className="inline-flex items-center gap-1.5 px-4 py-2.5 text-xs font-semibold text-white bg-gradient-to-r from-blue-600 to-indigo-600 hover:from-blue-500 hover:to-indigo-500 rounded-xl shadow-lg shadow-blue-600/25 transition cursor-pointer"
|
||||||
>
|
>
|
||||||
<Plus className="w-4 h-4" />
|
<Plus className="w-4 h-4" />
|
||||||
Add Rule
|
<span>Add Rule</span>
|
||||||
</button>
|
</button>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -638,59 +638,66 @@ export const GrantsPage: React.FC = () => {
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="space-y-6 max-w-7xl mx-auto">
|
<div className="space-y-6 max-w-7xl mx-auto">
|
||||||
{/* Header & Execution Pipeline Summary */}
|
{/* Header Banner */}
|
||||||
<div className="bg-[#0f1523]/80 backdrop-blur-md p-6 rounded-2xl border border-slate-800/80 shadow-xl space-y-5">
|
<div className="flex flex-col md:flex-row md:items-center justify-between gap-4 bg-gradient-to-r from-[#0d1322] via-[#0f172a] to-[#0d1322] p-6 sm:p-8 rounded-3xl border border-slate-800/80 shadow-2xl relative overflow-hidden">
|
||||||
<div className="flex flex-col sm:flex-row sm:items-center justify-between gap-4">
|
<div className="absolute -right-10 -bottom-10 w-60 h-60 bg-emerald-600/10 rounded-full blur-3xl pointer-events-none" />
|
||||||
<div>
|
<div className="space-y-2 relative z-10">
|
||||||
<div className="flex items-center gap-2">
|
<div className="flex items-center gap-3">
|
||||||
<h2 className="text-2xl font-bold text-white tracking-tight">
|
<div className="h-12 w-12 rounded-2xl bg-emerald-600/20 border border-emerald-500/30 flex items-center justify-center text-emerald-400 shadow-lg shadow-emerald-500/10">
|
||||||
Grant & Financial Management
|
<Coins className="w-6 h-6" />
|
||||||
</h2>
|
</div>
|
||||||
<HelpTooltip
|
<div>
|
||||||
title="Grant Financial Lifecycle"
|
<div className="flex items-center gap-2.5 flex-wrap">
|
||||||
content="Enforces the statutory financial control thread: Awards Intake (M01) -> Multi-Year Budgets (M03) -> Condition-Checked Tranche Disbursements (M04). Contracts are managed under the Monitoring module."
|
<h1 className="text-2xl sm:text-3xl font-bold tracking-tight text-white flex items-center gap-2.5">
|
||||||
size="md"
|
Grant & Financial Management
|
||||||
placement="right"
|
</h1>
|
||||||
/>
|
<HelpTooltip
|
||||||
|
title="Grant Financial Lifecycle"
|
||||||
|
content="Enforces the statutory financial control thread: Awards Intake (M01) -> Multi-Year Budgets (M03) -> Condition-Checked Tranche Disbursements (M04). Contracts are managed under the Monitoring module."
|
||||||
|
size="md"
|
||||||
|
placement="right"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<p className="text-xs sm:text-sm text-slate-400 mt-1">
|
||||||
|
End-to-end controlled execution thread: Funding Award Intake → Budgeting → Condition-Checked Disbursement.
|
||||||
|
</p>
|
||||||
</div>
|
</div>
|
||||||
<p className="text-xs text-slate-400 mt-1">
|
|
||||||
End-to-end controlled execution thread: Funding Award Intake → Budgeting → Condition-Checked Disbursement.
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="flex items-center gap-2">
|
|
||||||
{activeTab === 'awards' && (
|
|
||||||
<button
|
|
||||||
onClick={openCreateAward}
|
|
||||||
className="inline-flex items-center gap-1.5 px-4 py-2 text-xs font-semibold text-white bg-gradient-to-r from-blue-600 to-indigo-600 hover:from-blue-500 hover:to-indigo-500 rounded-xl shadow-lg shadow-blue-500/20 transition cursor-pointer"
|
|
||||||
>
|
|
||||||
<Plus className="w-4 h-4" />
|
|
||||||
Register Approved Award
|
|
||||||
</button>
|
|
||||||
)}
|
|
||||||
{activeTab === 'budgets' && (
|
|
||||||
<button
|
|
||||||
onClick={() => setIsBudgetModalOpen(true)}
|
|
||||||
className="inline-flex items-center gap-1.5 px-4 py-2 text-xs font-semibold text-white bg-gradient-to-r from-blue-600 to-indigo-600 hover:from-blue-500 hover:to-indigo-500 rounded-xl shadow-lg shadow-blue-500/20 transition cursor-pointer"
|
|
||||||
>
|
|
||||||
<Plus className="w-4 h-4" />
|
|
||||||
Add Budget Line
|
|
||||||
</button>
|
|
||||||
)}
|
|
||||||
{activeTab === 'disbursements' && (
|
|
||||||
<button
|
|
||||||
onClick={openCreateDisbursement}
|
|
||||||
className="inline-flex items-center gap-1.5 px-4 py-2 text-xs font-semibold text-white bg-gradient-to-r from-blue-600 to-indigo-600 hover:from-blue-500 hover:to-indigo-500 rounded-xl shadow-lg shadow-blue-500/20 transition cursor-pointer"
|
|
||||||
>
|
|
||||||
<Plus className="w-4 h-4" />
|
|
||||||
Schedule Disbursement
|
|
||||||
</button>
|
|
||||||
)}
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Pipeline Summary Bar */}
|
<div className="flex items-center gap-2 relative z-10 self-start md:self-center">
|
||||||
<div className="grid grid-cols-1 sm:grid-cols-3 gap-3 pt-3 border-t border-slate-800/80 text-xs">
|
{activeTab === 'awards' && (
|
||||||
|
<button
|
||||||
|
onClick={openCreateAward}
|
||||||
|
className="inline-flex items-center gap-1.5 px-4 py-2.5 text-xs font-semibold text-white bg-gradient-to-r from-emerald-600 to-teal-600 hover:from-emerald-500 hover:to-teal-500 rounded-xl shadow-lg shadow-emerald-600/25 transition cursor-pointer"
|
||||||
|
>
|
||||||
|
<Plus className="w-4 h-4" />
|
||||||
|
<span>Register Approved Award</span>
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
{activeTab === 'budgets' && (
|
||||||
|
<button
|
||||||
|
onClick={() => setIsBudgetModalOpen(true)}
|
||||||
|
className="inline-flex items-center gap-1.5 px-4 py-2.5 text-xs font-semibold text-white bg-gradient-to-r from-emerald-600 to-teal-600 hover:from-emerald-500 hover:to-teal-500 rounded-xl shadow-lg shadow-emerald-600/25 transition cursor-pointer"
|
||||||
|
>
|
||||||
|
<Plus className="w-4 h-4" />
|
||||||
|
<span>Add Budget Line</span>
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
{activeTab === 'disbursements' && (
|
||||||
|
<button
|
||||||
|
onClick={openCreateDisbursement}
|
||||||
|
className="inline-flex items-center gap-1.5 px-4 py-2.5 text-xs font-semibold text-white bg-gradient-to-r from-emerald-600 to-teal-600 hover:from-emerald-500 hover:to-teal-500 rounded-xl shadow-lg shadow-emerald-600/25 transition cursor-pointer"
|
||||||
|
>
|
||||||
|
<Plus className="w-4 h-4" />
|
||||||
|
<span>Schedule Disbursement</span>
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Pipeline Summary Bar */}
|
||||||
|
<div className="grid grid-cols-1 sm:grid-cols-3 gap-3 text-xs">
|
||||||
<div className="p-3.5 rounded-xl bg-slate-900/60 border border-slate-800/80 flex items-center justify-between">
|
<div className="p-3.5 rounded-xl bg-slate-900/60 border border-slate-800/80 flex items-center justify-between">
|
||||||
<div className="flex items-center gap-1.5">
|
<div className="flex items-center gap-1.5">
|
||||||
<span className="text-slate-400 font-medium">Total Awards Registered</span>
|
<span className="text-slate-400 font-medium">Total Awards Registered</span>
|
||||||
@@ -728,7 +735,6 @@ export const GrantsPage: React.FC = () => {
|
|||||||
<span className="font-bold text-amber-300 font-mono">{formatCompactZAR(pendingDisbursementValue)}</span>
|
<span className="font-bold text-amber-300 font-mono">{formatCompactZAR(pendingDisbursementValue)}</span>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Tabs */}
|
{/* Tabs */}
|
||||||
<div className="flex gap-2 border-b border-slate-800/80 pb-3 overflow-x-auto">
|
<div className="flex gap-2 border-b border-slate-800/80 pb-3 overflow-x-auto">
|
||||||
|
|||||||
@@ -25,6 +25,7 @@ import {
|
|||||||
RefreshCw,
|
RefreshCw,
|
||||||
Search,
|
Search,
|
||||||
RotateCcw,
|
RotateCcw,
|
||||||
|
Activity,
|
||||||
} from 'lucide-react';
|
} from 'lucide-react';
|
||||||
import {
|
import {
|
||||||
getAllIndicators,
|
getAllIndicators,
|
||||||
@@ -407,50 +408,58 @@ export const MonitoringPage: React.FC = () => {
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="space-y-6 max-w-7xl mx-auto">
|
<div className="space-y-6 max-w-7xl mx-auto">
|
||||||
{/* Header */}
|
{/* Header Banner */}
|
||||||
<div className="flex flex-col sm:flex-row sm:items-center justify-between gap-4 bg-[#0d121f]/90 p-5 rounded-2xl border border-slate-800/80 backdrop-blur-md shadow-xl">
|
<div className="flex flex-col md:flex-row md:items-center justify-between gap-4 bg-gradient-to-r from-[#0d1322] via-[#0f172a] to-[#0d1322] p-6 sm:p-8 rounded-3xl border border-slate-800/80 shadow-2xl relative overflow-hidden">
|
||||||
<div>
|
<div className="absolute -right-10 -bottom-10 w-60 h-60 bg-indigo-600/10 rounded-full blur-3xl pointer-events-none" />
|
||||||
<div className="flex items-center gap-2">
|
<div className="space-y-2 relative z-10">
|
||||||
<h2 className="text-xl font-bold text-white tracking-tight">
|
<div className="flex items-center gap-3">
|
||||||
M&E, Evidence & Risk Management
|
<div className="h-12 w-12 rounded-2xl bg-indigo-600/20 border border-indigo-500/30 flex items-center justify-center text-indigo-400 shadow-lg shadow-indigo-500/10">
|
||||||
</h2>
|
<Activity className="w-6 h-6" />
|
||||||
<HelpTooltip
|
</div>
|
||||||
title="Monitoring & Evaluation Hub"
|
<div>
|
||||||
content="Track performance indicators, verify tangible deliverable evidence against statutory milestones, and maintain proactive risk mitigation plans."
|
<div className="flex items-center gap-2.5 flex-wrap">
|
||||||
placement="right"
|
<h1 className="text-2xl sm:text-3xl font-bold tracking-tight text-white flex items-center gap-2.5">
|
||||||
/>
|
M&E, Evidence & Risk Management
|
||||||
|
</h1>
|
||||||
|
<HelpTooltip
|
||||||
|
title="Monitoring & Evaluation Hub"
|
||||||
|
content="Track performance indicators, verify tangible deliverable evidence against statutory milestones, and maintain proactive risk mitigation plans."
|
||||||
|
placement="right"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<p className="text-xs sm:text-sm text-slate-400 mt-1">
|
||||||
|
Verification workflow: Evidence Upload & Review Queue, Indicator Actuals Recording, and Risk Register.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<p className="text-xs text-slate-400 mt-1">
|
|
||||||
Verification workflow: Evidence Upload & Review Queue, Indicator Actuals Recording, and Risk Register.
|
|
||||||
</p>
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="flex items-center gap-2">
|
<div className="flex items-center gap-2 relative z-10 self-start md:self-center flex-wrap">
|
||||||
{activeTab === 'contracts' && (
|
{activeTab === 'contracts' && (
|
||||||
<button
|
<button
|
||||||
onClick={() => setIsCreateContractModalOpen(true)}
|
onClick={() => setIsCreateContractModalOpen(true)}
|
||||||
className="inline-flex items-center gap-1.5 px-4 py-2 text-xs font-semibold text-white bg-gradient-to-r from-blue-600 to-indigo-600 hover:from-blue-500 hover:to-indigo-500 rounded-xl shadow-md shadow-blue-500/20 transition cursor-pointer"
|
className="inline-flex items-center gap-1.5 px-4 py-2.5 text-xs font-semibold text-white bg-gradient-to-r from-indigo-600 to-purple-600 hover:from-indigo-500 hover:to-purple-500 rounded-xl shadow-lg shadow-indigo-600/25 transition cursor-pointer"
|
||||||
>
|
>
|
||||||
<Plus className="w-4 h-4" />
|
<Plus className="w-4 h-4" />
|
||||||
Create Contract & Upload PDF
|
<span>Create Contract & Upload PDF</span>
|
||||||
</button>
|
</button>
|
||||||
)}
|
)}
|
||||||
{activeTab === 'indicators' && (
|
{activeTab === 'indicators' && (
|
||||||
<button
|
<button
|
||||||
onClick={() => setIsIndicatorModalOpen(true)}
|
onClick={() => setIsIndicatorModalOpen(true)}
|
||||||
className="inline-flex items-center gap-1.5 px-4 py-2 text-xs font-semibold text-white bg-gradient-to-r from-blue-600 to-indigo-600 hover:from-blue-500 hover:to-indigo-500 rounded-xl shadow-md shadow-blue-500/20 transition cursor-pointer"
|
className="inline-flex items-center gap-1.5 px-4 py-2.5 text-xs font-semibold text-white bg-gradient-to-r from-indigo-600 to-purple-600 hover:from-indigo-500 hover:to-purple-500 rounded-xl shadow-lg shadow-indigo-600/25 transition cursor-pointer"
|
||||||
>
|
>
|
||||||
<Plus className="w-4 h-4" />
|
<Plus className="w-4 h-4" />
|
||||||
Define Indicator
|
<span>Define Indicator</span>
|
||||||
</button>
|
</button>
|
||||||
)}
|
)}
|
||||||
{activeTab === 'risks' && (
|
{activeTab === 'risks' && (
|
||||||
<button
|
<button
|
||||||
onClick={openCreateRisk}
|
onClick={openCreateRisk}
|
||||||
className="inline-flex items-center gap-1.5 px-4 py-2 text-xs font-semibold text-white bg-gradient-to-r from-blue-600 to-indigo-600 hover:from-blue-500 hover:to-indigo-500 rounded-xl shadow-md shadow-blue-500/20 transition cursor-pointer"
|
className="inline-flex items-center gap-1.5 px-4 py-2.5 text-xs font-semibold text-white bg-gradient-to-r from-indigo-600 to-purple-600 hover:from-indigo-500 hover:to-purple-500 rounded-xl shadow-lg shadow-indigo-600/25 transition cursor-pointer"
|
||||||
>
|
>
|
||||||
<Plus className="w-4 h-4" />
|
<Plus className="w-4 h-4" />
|
||||||
Add Risk to Register
|
<span>Add Risk to Register</span>
|
||||||
</button>
|
</button>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -408,35 +408,45 @@ export const ProjectsPage: React.FC = () => {
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="space-y-6 max-w-7xl mx-auto">
|
<div className="space-y-6 max-w-7xl mx-auto">
|
||||||
{/* Page Header */}
|
{/* Header Banner */}
|
||||||
<div className="flex flex-col sm:flex-row sm:items-center justify-between gap-4 bg-[#0d121f]/90 p-5 rounded-2xl border border-slate-800/80 backdrop-blur-md shadow-xl">
|
<div className="flex flex-col md:flex-row md:items-center justify-between gap-4 bg-gradient-to-r from-[#0d1322] via-[#0f172a] to-[#0d1322] p-6 sm:p-8 rounded-3xl border border-slate-800/80 shadow-2xl relative overflow-hidden">
|
||||||
<div>
|
<div className="absolute -right-10 -bottom-10 w-60 h-60 bg-blue-600/10 rounded-full blur-3xl pointer-events-none" />
|
||||||
<div className="flex items-center gap-2">
|
<div className="space-y-2 relative z-10">
|
||||||
<h2 className="text-xl font-bold text-white tracking-tight flex items-center gap-2">
|
<div className="flex items-center gap-3">
|
||||||
Project & Milestone Management
|
<div className="h-12 w-12 rounded-2xl bg-blue-600/20 border border-blue-500/30 flex items-center justify-center text-blue-400 shadow-lg shadow-blue-500/10">
|
||||||
</h2>
|
<FolderKanban className="w-6 h-6" />
|
||||||
<HelpTooltip
|
</div>
|
||||||
title="Work Breakdown Structure (WBS)"
|
<div>
|
||||||
content="Manage operational activities, verified contractual milestone deliverables, risk registers, and M&E performance indicator targets."
|
<div className="flex items-center gap-2.5 flex-wrap">
|
||||||
size="sm"
|
<h1 className="text-2xl sm:text-3xl font-bold tracking-tight text-white flex items-center gap-2.5">
|
||||||
placement="bottom"
|
Project & Milestone Management
|
||||||
/>
|
</h1>
|
||||||
<span className="text-[11px] font-semibold text-blue-400 bg-blue-500/10 px-2.5 py-0.5 rounded-full border border-blue-500/30">
|
<HelpTooltip
|
||||||
Work Breakdown
|
title="Work Breakdown Structure (WBS)"
|
||||||
</span>
|
content="Manage operational activities, verified contractual milestone deliverables, risk registers, and M&E performance indicator targets."
|
||||||
|
size="sm"
|
||||||
|
placement="bottom"
|
||||||
|
/>
|
||||||
|
<span className="text-[11px] font-semibold text-blue-400 bg-blue-500/10 px-2.5 py-0.5 rounded-full border border-blue-500/30">
|
||||||
|
Work Breakdown
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<p className="text-xs sm:text-sm text-slate-400 mt-1">
|
||||||
|
Work breakdown structure, contractual deliverables, milestone evidence tracking, and progress calculation.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<p className="text-xs text-slate-400 mt-1">
|
|
||||||
Work breakdown structure, contractual deliverables, milestone evidence tracking, and progress calculation.
|
|
||||||
</p>
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<button
|
<div className="flex items-center gap-2.5 flex-wrap relative z-10 self-start md:self-center">
|
||||||
onClick={openCreateProject}
|
<button
|
||||||
className="inline-flex items-center gap-1.5 px-4 py-2 text-xs font-semibold text-white bg-gradient-to-r from-blue-600 to-indigo-600 hover:from-blue-500 hover:to-indigo-500 rounded-xl shadow-md shadow-blue-500/20 transition cursor-pointer self-start sm:self-auto"
|
onClick={openCreateProject}
|
||||||
>
|
className="inline-flex items-center gap-1.5 px-4 py-2.5 text-xs font-semibold text-white bg-gradient-to-r from-blue-600 to-indigo-600 hover:from-blue-500 hover:to-indigo-500 rounded-xl shadow-lg shadow-blue-600/25 transition cursor-pointer"
|
||||||
<Plus className="w-4 h-4" />
|
>
|
||||||
Create Project
|
<Plus className="w-4 h-4" />
|
||||||
</button>
|
<span>Create Project</span>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="grid grid-cols-1 lg:grid-cols-12 gap-6">
|
<div className="grid grid-cols-1 lg:grid-cols-12 gap-6">
|
||||||
|
|||||||
@@ -468,25 +468,35 @@ export const SettingsPage: React.FC = () => {
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="space-y-6 max-w-7xl mx-auto">
|
<div className="space-y-6 max-w-7xl mx-auto">
|
||||||
{/* Header */}
|
{/* Header Banner */}
|
||||||
<div className="flex flex-col sm:flex-row sm:items-center justify-between gap-4 bg-[#0f1523]/80 backdrop-blur-md p-6 rounded-2xl border border-slate-800/80 shadow-xl">
|
<div className="flex flex-col md:flex-row md:items-center justify-between gap-4 bg-gradient-to-r from-[#0d1322] via-[#0f172a] to-[#0d1322] p-6 sm:p-8 rounded-3xl border border-slate-800/80 shadow-2xl relative overflow-hidden">
|
||||||
<div>
|
<div className="absolute -right-10 -bottom-10 w-60 h-60 bg-blue-600/10 rounded-full blur-3xl pointer-events-none" />
|
||||||
<h2 className="text-2xl font-bold text-white tracking-tight flex items-center gap-2">
|
<div className="space-y-2 relative z-10">
|
||||||
<span>System Administration & Audit</span>
|
<div className="flex items-center gap-3">
|
||||||
<HelpTooltip
|
<div className="h-12 w-12 rounded-2xl bg-blue-600/20 border border-blue-500/30 flex items-center justify-center text-blue-400 shadow-lg shadow-blue-500/10">
|
||||||
title="System Administration & Audit"
|
<Shield className="w-6 h-6" />
|
||||||
content="Manage platform security, active user directory, statutory invitations, communications gateways, and comprehensive audit trail ledger."
|
</div>
|
||||||
placement="right"
|
<div>
|
||||||
/>
|
<div className="flex items-center gap-2.5 flex-wrap">
|
||||||
</h2>
|
<h1 className="text-2xl sm:text-3xl font-bold tracking-tight text-white flex items-center gap-2.5">
|
||||||
<p className="text-xs text-slate-400 mt-1">
|
System Administration & Audit
|
||||||
User access directory, statutory invitations, gateway broadcasts, and immutable system audit ledger.
|
</h1>
|
||||||
</p>
|
<HelpTooltip
|
||||||
|
title="System Administration & Audit"
|
||||||
|
content="Manage platform security, active user directory, statutory invitations, communications gateways, and comprehensive audit trail ledger."
|
||||||
|
placement="right"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<p className="text-xs sm:text-sm text-slate-400 mt-1">
|
||||||
|
User access directory, statutory invitations, gateway broadcasts, and immutable system audit ledger.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="flex items-center gap-2">
|
<div className="flex items-center gap-2 relative z-10 self-start md:self-center flex-wrap">
|
||||||
{activeTab === 'users' && (
|
{activeTab === 'users' && (
|
||||||
<div className="flex items-center gap-2">
|
<div className="flex items-center gap-2 flex-wrap">
|
||||||
<span
|
<span
|
||||||
className="hidden sm:inline-flex items-center gap-1.5 px-3 py-1.5 rounded-xl text-[11px] font-medium bg-emerald-500/10 text-emerald-400 border border-emerald-500/20"
|
className="hidden sm:inline-flex items-center gap-1.5 px-3 py-1.5 rounded-xl text-[11px] font-medium bg-emerald-500/10 text-emerald-400 border border-emerald-500/20"
|
||||||
title="User directory synchronization active in browser"
|
title="User directory synchronization active in browser"
|
||||||
@@ -497,11 +507,11 @@ export const SettingsPage: React.FC = () => {
|
|||||||
<button
|
<button
|
||||||
onClick={() => syncMutation.mutate()}
|
onClick={() => syncMutation.mutate()}
|
||||||
disabled={syncMutation.isPending}
|
disabled={syncMutation.isPending}
|
||||||
className="inline-flex items-center gap-1.5 px-4 py-2 text-xs font-semibold text-white bg-slate-800 hover:bg-slate-700 border border-slate-700/80 rounded-xl shadow-md transition cursor-pointer disabled:opacity-50"
|
className="inline-flex items-center gap-1.5 px-4 py-2.5 text-xs font-semibold text-white bg-slate-900/90 hover:bg-slate-800 border border-slate-700/80 rounded-xl shadow-md transition cursor-pointer disabled:opacity-50"
|
||||||
title="Force refresh user sync and update browser cache"
|
title="Force refresh user sync and update browser cache"
|
||||||
>
|
>
|
||||||
<RefreshCw className={`w-3.5 h-3.5 ${syncMutation.isPending ? 'animate-spin' : ''}`} />
|
<RefreshCw className={`w-3.5 h-3.5 ${syncMutation.isPending ? 'animate-spin' : ''}`} />
|
||||||
{syncMutation.isPending ? 'Syncing...' : 'Sync User Directory'}
|
<span>{syncMutation.isPending ? 'Syncing...' : 'Sync User Directory'}</span>
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
@@ -509,10 +519,10 @@ export const SettingsPage: React.FC = () => {
|
|||||||
{activeTab === 'invites' && (
|
{activeTab === 'invites' && (
|
||||||
<button
|
<button
|
||||||
onClick={() => setIsSendInviteModalOpen(true)}
|
onClick={() => setIsSendInviteModalOpen(true)}
|
||||||
className="inline-flex items-center gap-1.5 px-4 py-2 text-xs font-semibold text-white bg-gradient-to-r from-blue-600 to-indigo-600 hover:from-blue-500 hover:to-indigo-500 rounded-xl shadow-lg shadow-blue-500/20 transition cursor-pointer"
|
className="inline-flex items-center gap-1.5 px-4 py-2.5 text-xs font-semibold text-white bg-gradient-to-r from-blue-600 to-indigo-600 hover:from-blue-500 hover:to-indigo-500 rounded-xl shadow-lg shadow-blue-600/25 transition cursor-pointer"
|
||||||
>
|
>
|
||||||
<Send className="w-4 h-4" />
|
<Send className="w-4 h-4" />
|
||||||
Dispatch Invitation
|
<span>Dispatch Invitation</span>
|
||||||
</button>
|
</button>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
@@ -522,10 +532,10 @@ export const SettingsPage: React.FC = () => {
|
|||||||
setInviteOrgId(effectiveNotificationOrgId);
|
setInviteOrgId(effectiveNotificationOrgId);
|
||||||
setIsSendInviteModalOpen(true);
|
setIsSendInviteModalOpen(true);
|
||||||
}}
|
}}
|
||||||
className="inline-flex items-center gap-1.5 px-4 py-2 text-xs font-semibold text-white bg-gradient-to-r from-blue-600 to-indigo-600 hover:from-blue-500 hover:to-indigo-500 rounded-xl shadow-lg shadow-blue-500/20 transition cursor-pointer"
|
className="inline-flex items-center gap-1.5 px-4 py-2.5 text-xs font-semibold text-white bg-gradient-to-r from-blue-600 to-indigo-600 hover:from-blue-500 hover:to-indigo-500 rounded-xl shadow-lg shadow-blue-600/25 transition cursor-pointer"
|
||||||
>
|
>
|
||||||
<Plus className="w-4 h-4" />
|
<Plus className="w-4 h-4" />
|
||||||
Add Notification
|
<span>Add Notification</span>
|
||||||
</button>
|
</button>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -52,7 +52,9 @@ export const SiteVisitsPage: React.FC = () => {
|
|||||||
const { success, error: toastError } = useToast();
|
const { success, error: toastError } = useToast();
|
||||||
|
|
||||||
const [selectedProjectId, setSelectedProjectId] = useState<string>('all');
|
const [selectedProjectId, setSelectedProjectId] = useState<string>('all');
|
||||||
|
const [activeTab, setActiveTab] = useState<'visits' | 'feedback'>('visits');
|
||||||
const [searchTerm, setSearchTerm] = useState('');
|
const [searchTerm, setSearchTerm] = useState('');
|
||||||
|
const [statusFilter, setStatusFilter] = useState('all');
|
||||||
|
|
||||||
// Modals
|
// Modals
|
||||||
const [isVisitModalOpen, setIsVisitModalOpen] = useState(false);
|
const [isVisitModalOpen, setIsVisitModalOpen] = useState(false);
|
||||||
@@ -135,13 +137,17 @@ export const SiteVisitsPage: React.FC = () => {
|
|||||||
// Filtered & Sorted Visits
|
// Filtered & Sorted Visits
|
||||||
const filteredVisits = useMemo(() => {
|
const filteredVisits = useMemo(() => {
|
||||||
return siteVisits.filter((v) => {
|
return siteVisits.filter((v) => {
|
||||||
return (
|
const matchSearch =
|
||||||
!searchTerm.trim() ||
|
!searchTerm.trim() ||
|
||||||
(v.inspectorName && v.inspectorName.toLowerCase().includes(searchTerm.toLowerCase())) ||
|
(v.inspectorName && v.inspectorName.toLowerCase().includes(searchTerm.toLowerCase())) ||
|
||||||
(v.findings && v.findings.toLowerCase().includes(searchTerm.toLowerCase()))
|
(v.findings && v.findings.toLowerCase().includes(searchTerm.toLowerCase()));
|
||||||
);
|
const matchStatus =
|
||||||
|
statusFilter === 'all' ||
|
||||||
|
(statusFilter === 'approved' && v.status === ApprovalStatus.Approved) ||
|
||||||
|
(statusFilter === 'pending' && v.status !== ApprovalStatus.Approved);
|
||||||
|
return matchSearch && matchStatus;
|
||||||
});
|
});
|
||||||
}, [siteVisits, searchTerm]);
|
}, [siteVisits, searchTerm, statusFilter]);
|
||||||
|
|
||||||
const { sortedData: sortedVisits, sortColumn: visitSortCol, sortDirection: visitSortDir, handleSort: handleVisitSort } =
|
const { sortedData: sortedVisits, sortColumn: visitSortCol, sortDirection: visitSortDir, handleSort: handleVisitSort } =
|
||||||
useTableSort(filteredVisits);
|
useTableSort(filteredVisits);
|
||||||
@@ -149,9 +155,9 @@ export const SiteVisitsPage: React.FC = () => {
|
|||||||
// Exports
|
// Exports
|
||||||
const visitExportCols: ExportColumn<SiteVisit>[] = [
|
const visitExportCols: ExportColumn<SiteVisit>[] = [
|
||||||
{ header: 'Inspector Name', key: 'inspectorName' },
|
{ header: 'Inspector Name', key: 'inspectorName' },
|
||||||
{ header: 'Visit Date', accessor: (v) => formatDate(v.visitedAt) },
|
{ header: 'Visit Date', key: 'visitedAt', format: (val) => formatDate(val) },
|
||||||
{ header: 'Findings', key: 'findings' },
|
{ header: 'Findings', key: 'findings' },
|
||||||
{ header: 'Status', accessor: (v) => (v.status === ApprovalStatus.Approved ? 'Approved' : 'Pending') },
|
{ header: 'Status', key: 'status', format: (val) => (val === ApprovalStatus.Approved ? 'Approved' : 'Pending') },
|
||||||
];
|
];
|
||||||
|
|
||||||
const handleOpenEdit = (v: SiteVisit) => {
|
const handleOpenEdit = (v: SiteVisit) => {
|
||||||
@@ -185,36 +191,6 @@ export const SiteVisitsPage: React.FC = () => {
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="flex items-center gap-2.5 flex-wrap relative z-10">
|
|
||||||
<button
|
|
||||||
onClick={() => refetchVisits()}
|
|
||||||
disabled={fetchingVisits}
|
|
||||||
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 ${fetchingVisits ? 'animate-spin text-emerald-400' : ''}`} />
|
|
||||||
<span>Refresh</span>
|
|
||||||
</button>
|
|
||||||
|
|
||||||
<ExportButton data={sortedVisits} columns={visitExportCols} filename="site_visits" title="Site Visits Log" />
|
|
||||||
|
|
||||||
<button
|
|
||||||
onClick={() => {
|
|
||||||
setEditingVisit(null);
|
|
||||||
visitForm.reset({
|
|
||||||
inspectorName: '',
|
|
||||||
visitedAt: new Date().toISOString().split('T')[0],
|
|
||||||
findings: '',
|
|
||||||
status: ApprovalStatus.Pending,
|
|
||||||
});
|
|
||||||
setIsVisitModalOpen(true);
|
|
||||||
}}
|
|
||||||
className="flex items-center gap-2 px-4 py-2.5 rounded-xl text-xs font-semibold bg-gradient-to-r from-emerald-600 to-teal-600 text-white hover:from-emerald-500 hover:to-teal-500 shadow-lg shadow-emerald-600/25 transition cursor-pointer"
|
|
||||||
>
|
|
||||||
<Plus className="w-4 h-4" />
|
|
||||||
<span>Log Site Visit</span>
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* KPI Cards */}
|
{/* KPI Cards */}
|
||||||
@@ -260,118 +236,368 @@ export const SiteVisitsPage: React.FC = () => {
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Toolbar */}
|
{/* Tabs */}
|
||||||
<div className="flex flex-col sm:flex-row sm:items-center justify-between gap-4 border-b border-slate-800 pb-3">
|
<div className="space-y-4">
|
||||||
<div className="flex items-center gap-3 flex-wrap">
|
<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">
|
||||||
<div className="flex items-center gap-2 text-xs text-slate-400">
|
<button
|
||||||
<FolderKanban className="w-3.5 h-3.5 text-emerald-400" />
|
onClick={() => setActiveTab('visits')}
|
||||||
<span>Project:</span>
|
className={`py-3.5 px-4 text-xs font-semibold border-b-2 transition-colors flex items-center gap-2 cursor-pointer whitespace-nowrap ${
|
||||||
<select
|
activeTab === 'visits'
|
||||||
value={selectedProjectId}
|
? 'border-emerald-500 text-emerald-400'
|
||||||
onChange={(e) => setSelectedProjectId(e.target.value)}
|
: 'border-transparent text-slate-400 hover:text-slate-200'
|
||||||
className="bg-slate-900 border border-slate-800 rounded-xl px-3 py-1.5 text-xs text-white focus:outline-none focus:border-emerald-500"
|
}`}
|
||||||
>
|
>
|
||||||
{projects.map((p) => (
|
<MapPin className="w-4 h-4" />
|
||||||
<option key={p.id} value={p.id}>
|
<span>Site Visit Reports ({siteVisits.length})</span>
|
||||||
{p.name}
|
</button>
|
||||||
</option>
|
|
||||||
))}
|
|
||||||
</select>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="relative">
|
<button
|
||||||
<Search className="w-3.5 h-3.5 absolute left-3 top-2.5 text-slate-400" />
|
onClick={() => setActiveTab('feedback')}
|
||||||
<input
|
className={`py-3.5 px-4 text-xs font-semibold border-b-2 transition-colors flex items-center gap-2 cursor-pointer whitespace-nowrap ${
|
||||||
type="text"
|
activeTab === 'feedback'
|
||||||
placeholder="Search inspector / findings..."
|
? 'border-emerald-500 text-emerald-400'
|
||||||
value={searchTerm}
|
: 'border-transparent text-slate-400 hover:text-slate-200'
|
||||||
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-emerald-500 w-48 sm:w-64"
|
>
|
||||||
/>
|
<MessageSquare className="w-4 h-4" />
|
||||||
</div>
|
<span>Field Observations & Feedback ({siteVisits.length})</span>
|
||||||
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Table */}
|
{/* Tab 1: Visits Table View */}
|
||||||
<div className="bg-[#0f1523]/80 backdrop-blur-md rounded-2xl border border-slate-800/80 shadow-xl overflow-hidden">
|
{activeTab === 'visits' && (
|
||||||
<div className="overflow-x-auto">
|
<div className="space-y-4">
|
||||||
<table className="w-full text-left border-collapse">
|
<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">
|
||||||
<thead>
|
<div className="flex items-center gap-2.5 flex-wrap flex-1">
|
||||||
<tr className="border-b border-slate-800/80 bg-slate-900/50 text-[11px] font-semibold text-slate-400 uppercase tracking-wider">
|
{/* Search */}
|
||||||
<SortableTh column="inspectorName" currentColumn={visitSortCol} direction={visitSortDir} onSort={handleVisitSort}>
|
<div className="relative flex-1 min-w-[200px] max-w-sm">
|
||||||
Inspector Name
|
<Search className="w-3.5 h-3.5 absolute left-3 top-2.5 text-slate-400" />
|
||||||
</SortableTh>
|
<input
|
||||||
<SortableTh column="visitedAt" currentColumn={visitSortCol} direction={visitSortDir} onSort={handleVisitSort}>
|
type="text"
|
||||||
Inspection Date
|
placeholder="Search inspector name or findings..."
|
||||||
</SortableTh>
|
value={searchTerm}
|
||||||
<SortableTh column="findings" currentColumn={visitSortCol} direction={visitSortDir} onSort={handleVisitSort}>
|
onChange={(e) => setSearchTerm(e.target.value)}
|
||||||
Field Observations & Findings
|
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-emerald-500 shadow-xs"
|
||||||
</SortableTh>
|
/>
|
||||||
<SortableTh column="status" currentColumn={visitSortCol} direction={visitSortDir} onSort={handleVisitSort}>
|
</div>
|
||||||
Verification Status
|
|
||||||
</SortableTh>
|
{/* Status Filter */}
|
||||||
<th className="py-3 px-4 text-right">Actions</th>
|
<div className="flex items-center gap-1.5 text-xs text-slate-400">
|
||||||
</tr>
|
<select
|
||||||
</thead>
|
value={statusFilter}
|
||||||
<tbody className="divide-y divide-slate-800/60 text-xs">
|
onChange={(e) => setStatusFilter(e.target.value)}
|
||||||
{loadingVisits ? (
|
className="bg-slate-900 border border-slate-800 rounded-xl px-3 py-1.5 text-xs text-white focus:outline-none focus:border-emerald-500"
|
||||||
<tr>
|
>
|
||||||
<td colSpan={5} className="py-12 text-center text-slate-400">
|
<option value="all">All Verification Statuses</option>
|
||||||
<div className="flex flex-col items-center gap-2">
|
<option value="approved">Approved & Verified</option>
|
||||||
<div className="w-6 h-6 border-2 border-emerald-500 border-t-transparent rounded-full animate-spin" />
|
<option value="pending">Pending / Under Review</option>
|
||||||
<span>Loading site visit logs...</span>
|
</select>
|
||||||
</div>
|
</div>
|
||||||
</td>
|
|
||||||
</tr>
|
{/* Project Selector */}
|
||||||
) : sortedVisits.length === 0 ? (
|
<div className="flex items-center gap-1.5 text-xs text-slate-400">
|
||||||
<tr>
|
<FolderKanban className="w-3.5 h-3.5 text-emerald-400" />
|
||||||
<td colSpan={5} className="py-12 text-center text-slate-400">
|
<select
|
||||||
No site visits recorded for this project yet.
|
value={selectedProjectId}
|
||||||
</td>
|
onChange={(e) => setSelectedProjectId(e.target.value)}
|
||||||
</tr>
|
className="bg-slate-900 border border-slate-800 rounded-xl px-3 py-1.5 text-xs text-white focus:outline-none focus:border-emerald-500 max-w-[180px] truncate"
|
||||||
) : (
|
>
|
||||||
sortedVisits.map((v) => (
|
{projects.map((p) => (
|
||||||
<tr key={v.id} className="hover:bg-slate-800/30 transition">
|
<option key={p.id} value={p.id}>
|
||||||
<td className="py-3.5 px-4 font-semibold text-white">
|
{p.name}
|
||||||
<div className="flex items-center gap-2">
|
</option>
|
||||||
<User className="w-3.5 h-3.5 text-emerald-400 shrink-0" />
|
))}
|
||||||
<span>{v.inspectorName || 'Field Officer'}</span>
|
</select>
|
||||||
</div>
|
</div>
|
||||||
</td>
|
</div>
|
||||||
<td className="py-3.5 px-4 text-slate-300 font-medium">
|
|
||||||
<div className="flex items-center gap-1.5 text-slate-300">
|
{/* Actions: Refresh, Export, Log Site Visit */}
|
||||||
<Calendar className="w-3 h-3 text-slate-500" />
|
<div className="flex items-center gap-2 flex-wrap justify-end">
|
||||||
<span>{formatDate(v.visitedAt)}</span>
|
<button
|
||||||
</div>
|
type="button"
|
||||||
</td>
|
onClick={() => {
|
||||||
<td className="py-3.5 px-4 text-slate-300 max-w-sm truncate" title={v.findings || ''}>
|
refetchVisits();
|
||||||
{v.findings || 'No findings recorded.'}
|
success('Refreshed', 'Site visit logs updated from API.');
|
||||||
</td>
|
}}
|
||||||
<td className="py-3.5 px-4">{getApprovalStatusBadge(v.status)}</td>
|
disabled={fetchingVisits}
|
||||||
<td className="py-3.5 px-4 text-right space-x-2">
|
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"
|
||||||
<button
|
title="Force refresh site visits"
|
||||||
onClick={() => {
|
>
|
||||||
setSelectedVisitForDetail(v);
|
<RefreshCw className={`w-3.5 h-3.5 text-emerald-400 ${fetchingVisits ? 'animate-spin' : ''}`} />
|
||||||
setIsDetailModalOpen(true);
|
<span>Refresh</span>
|
||||||
}}
|
</button>
|
||||||
className="px-2.5 py-1 text-xs font-medium text-emerald-400 hover:text-emerald-300 bg-emerald-950/40 hover:bg-emerald-900/40 rounded-lg border border-emerald-800/40 transition cursor-pointer"
|
|
||||||
>
|
<ExportButton
|
||||||
View & Feedback
|
data={sortedVisits}
|
||||||
</button>
|
columns={visitExportCols}
|
||||||
<button
|
filename={`site_visits_${new Date().toISOString().split('T')[0]}`}
|
||||||
onClick={() => handleOpenEdit(v)}
|
title="Site Visits Register"
|
||||||
className="p-1 text-slate-400 hover:text-white hover:bg-slate-800 rounded-lg transition cursor-pointer"
|
/>
|
||||||
title="Edit site visit"
|
|
||||||
>
|
<button
|
||||||
<Edit2 className="w-3.5 h-3.5" />
|
onClick={() => {
|
||||||
</button>
|
setEditingVisit(null);
|
||||||
</td>
|
visitForm.reset({
|
||||||
|
inspectorName: '',
|
||||||
|
visitedAt: new Date().toISOString().split('T')[0],
|
||||||
|
findings: '',
|
||||||
|
status: ApprovalStatus.Pending,
|
||||||
|
});
|
||||||
|
setIsVisitModalOpen(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-emerald-600 to-teal-600 text-white hover:from-emerald-500 hover:to-teal-500 shadow-md shadow-emerald-600/25 transition cursor-pointer"
|
||||||
|
>
|
||||||
|
<Plus className="w-3.5 h-3.5" />
|
||||||
|
<span>Log Site Visit</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>
|
||||||
|
<tr className="border-b border-slate-800/80 bg-slate-900/50 text-[11px] font-semibold text-slate-400 uppercase tracking-wider">
|
||||||
|
<SortableTh column="inspectorName" currentColumn={visitSortCol} direction={visitSortDir} onSort={handleVisitSort}>
|
||||||
|
Inspector Name
|
||||||
|
</SortableTh>
|
||||||
|
<SortableTh column="visitedAt" currentColumn={visitSortCol} direction={visitSortDir} onSort={handleVisitSort}>
|
||||||
|
Inspection Date
|
||||||
|
</SortableTh>
|
||||||
|
<SortableTh column="findings" currentColumn={visitSortCol} direction={visitSortDir} onSort={handleVisitSort}>
|
||||||
|
Field Observations & Findings
|
||||||
|
</SortableTh>
|
||||||
|
<SortableTh column="status" currentColumn={visitSortCol} direction={visitSortDir} onSort={handleVisitSort}>
|
||||||
|
Verification Status
|
||||||
|
</SortableTh>
|
||||||
|
<th className="py-3 px-4 text-right">Actions</th>
|
||||||
</tr>
|
</tr>
|
||||||
))
|
</thead>
|
||||||
)}
|
<tbody className="divide-y divide-slate-800/60 text-xs">
|
||||||
</tbody>
|
{loadingVisits ? (
|
||||||
</table>
|
<tr>
|
||||||
</div>
|
<td colSpan={5} className="py-12 text-center text-slate-400">
|
||||||
|
<div className="flex flex-col items-center gap-2">
|
||||||
|
<div className="w-6 h-6 border-2 border-emerald-500 border-t-transparent rounded-full animate-spin" />
|
||||||
|
<span>Loading site visit logs...</span>
|
||||||
|
</div>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
) : sortedVisits.length === 0 ? (
|
||||||
|
<tr>
|
||||||
|
<td colSpan={5} className="py-12 text-center text-slate-400">
|
||||||
|
No site visits recorded for this project yet.
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
) : (
|
||||||
|
sortedVisits.map((v) => (
|
||||||
|
<tr key={v.id} className="hover:bg-slate-800/30 transition">
|
||||||
|
<td className="py-3.5 px-4 font-semibold text-white">
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<User className="w-3.5 h-3.5 text-emerald-400 shrink-0" />
|
||||||
|
<span>{v.inspectorName || 'Field Officer'}</span>
|
||||||
|
</div>
|
||||||
|
</td>
|
||||||
|
<td className="py-3.5 px-4 text-slate-300 font-medium">
|
||||||
|
<div className="flex items-center gap-1.5 text-slate-300">
|
||||||
|
<Calendar className="w-3 h-3 text-slate-500" />
|
||||||
|
<span>{formatDate(v.visitedAt)}</span>
|
||||||
|
</div>
|
||||||
|
</td>
|
||||||
|
<td className="py-3.5 px-4 text-slate-300 max-w-sm truncate" title={v.findings || ''}>
|
||||||
|
{v.findings || 'No findings recorded.'}
|
||||||
|
</td>
|
||||||
|
<td className="py-3.5 px-4">{getApprovalStatusBadge(v.status)}</td>
|
||||||
|
<td className="py-3.5 px-4 text-right space-x-2">
|
||||||
|
<button
|
||||||
|
onClick={() => {
|
||||||
|
setSelectedVisitForDetail(v);
|
||||||
|
setIsDetailModalOpen(true);
|
||||||
|
}}
|
||||||
|
className="px-2.5 py-1 text-xs font-medium text-emerald-400 hover:text-emerald-300 bg-emerald-950/40 hover:bg-emerald-900/40 rounded-lg border border-emerald-800/40 transition cursor-pointer"
|
||||||
|
>
|
||||||
|
View & Feedback
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
onClick={() => handleOpenEdit(v)}
|
||||||
|
className="p-1 text-slate-400 hover:text-white hover:bg-slate-800 rounded-lg transition cursor-pointer"
|
||||||
|
title="Edit site visit"
|
||||||
|
>
|
||||||
|
<Edit2 className="w-3.5 h-3.5" />
|
||||||
|
</button>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
))
|
||||||
|
)}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Tab 2: Field Observations & Feedback Cards */}
|
||||||
|
{activeTab === 'feedback' && (
|
||||||
|
<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 inspector name or findings..."
|
||||||
|
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-emerald-500 shadow-xs"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Status Filter */}
|
||||||
|
<div className="flex items-center gap-1.5 text-xs text-slate-400">
|
||||||
|
<select
|
||||||
|
value={statusFilter}
|
||||||
|
onChange={(e) => setStatusFilter(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-emerald-500"
|
||||||
|
>
|
||||||
|
<option value="all">All Verification Statuses</option>
|
||||||
|
<option value="approved">Approved & Verified</option>
|
||||||
|
<option value="pending">Pending / Under Review</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-emerald-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-emerald-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 Site Visit */}
|
||||||
|
<div className="flex items-center gap-2 flex-wrap justify-end">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => {
|
||||||
|
refetchVisits();
|
||||||
|
success('Refreshed', 'Site visit logs updated from API.');
|
||||||
|
}}
|
||||||
|
disabled={fetchingVisits}
|
||||||
|
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 site visits"
|
||||||
|
>
|
||||||
|
<RefreshCw className={`w-3.5 h-3.5 text-emerald-400 ${fetchingVisits ? 'animate-spin' : ''}`} />
|
||||||
|
<span>Refresh</span>
|
||||||
|
</button>
|
||||||
|
|
||||||
|
<ExportButton
|
||||||
|
data={sortedVisits}
|
||||||
|
columns={visitExportCols}
|
||||||
|
filename={`site_visits_${new Date().toISOString().split('T')[0]}`}
|
||||||
|
title="Site Visits Register"
|
||||||
|
/>
|
||||||
|
|
||||||
|
<button
|
||||||
|
onClick={() => {
|
||||||
|
setEditingVisit(null);
|
||||||
|
visitForm.reset({
|
||||||
|
inspectorName: '',
|
||||||
|
visitedAt: new Date().toISOString().split('T')[0],
|
||||||
|
findings: '',
|
||||||
|
status: ApprovalStatus.Pending,
|
||||||
|
});
|
||||||
|
setIsVisitModalOpen(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-emerald-600 to-teal-600 text-white hover:from-emerald-500 hover:to-teal-500 shadow-md shadow-emerald-600/25 transition cursor-pointer"
|
||||||
|
>
|
||||||
|
<Plus className="w-3.5 h-3.5" />
|
||||||
|
<span>Log Site Visit</span>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{loadingVisits ? (
|
||||||
|
<div className="py-12 text-center text-slate-400 bg-[#0f1523]/80 rounded-2xl border border-slate-800">
|
||||||
|
<div className="flex flex-col items-center gap-2">
|
||||||
|
<div className="w-6 h-6 border-2 border-emerald-500 border-t-transparent rounded-full animate-spin" />
|
||||||
|
<span>Loading observation cards...</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
) : sortedVisits.length === 0 ? (
|
||||||
|
<div className="py-12 text-center text-slate-400 bg-[#0f1523]/80 rounded-2xl border border-slate-800">
|
||||||
|
No site visit observations or feedback found matching criteria.
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
|
||||||
|
{sortedVisits.map((v) => (
|
||||||
|
<div
|
||||||
|
key={v.id}
|
||||||
|
className="bg-[#0f1523]/90 rounded-2xl border border-slate-800/80 p-5 shadow-xl hover:border-emerald-500/40 transition flex flex-col justify-between gap-4 relative overflow-hidden group"
|
||||||
|
>
|
||||||
|
<div className="space-y-3">
|
||||||
|
<div className="flex items-start justify-between gap-3">
|
||||||
|
<div className="flex items-center gap-2.5">
|
||||||
|
<div className="h-9 w-9 rounded-xl bg-emerald-500/10 border border-emerald-500/30 flex items-center justify-center text-emerald-400">
|
||||||
|
<User className="w-4 h-4" />
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<h4 className="text-sm font-bold text-white group-hover:text-emerald-300 transition">
|
||||||
|
{v.inspectorName || 'Field Auditor'}
|
||||||
|
</h4>
|
||||||
|
<div className="flex items-center gap-2 text-[11px] text-slate-400">
|
||||||
|
<Calendar className="w-3 h-3 text-slate-500" />
|
||||||
|
<span>{formatDate(v.visitedAt)}</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div>{getApprovalStatusBadge(v.status)}</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="bg-slate-900/70 p-3.5 rounded-xl border border-slate-800/60 text-xs text-slate-300 leading-relaxed max-h-36 overflow-y-auto">
|
||||||
|
<p className="font-semibold text-slate-400 text-[10px] uppercase tracking-wider mb-1 flex items-center gap-1.5">
|
||||||
|
<FileText className="w-3 h-3 text-emerald-400" /> Field Observations & Findings
|
||||||
|
</p>
|
||||||
|
{v.findings || 'No detailed findings recorded.'}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex items-center justify-between pt-2 border-t border-slate-800/60 text-xs">
|
||||||
|
<span className="text-[11px] text-slate-500 font-mono">ID #{v.id}</span>
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<button
|
||||||
|
onClick={() => {
|
||||||
|
setSelectedVisitForDetail(v);
|
||||||
|
setIsDetailModalOpen(true);
|
||||||
|
}}
|
||||||
|
className="px-3 py-1 text-xs font-semibold text-emerald-400 hover:text-emerald-300 bg-emerald-950/50 hover:bg-emerald-900/50 border border-emerald-800/50 rounded-lg transition cursor-pointer flex items-center gap-1"
|
||||||
|
>
|
||||||
|
<MessageSquare className="w-3 h-3" />
|
||||||
|
<span>Detailed Review</span>
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
onClick={() => handleOpenEdit(v)}
|
||||||
|
className="p-1 text-slate-400 hover:text-white hover:bg-slate-800 rounded-lg transition cursor-pointer"
|
||||||
|
title="Edit site visit"
|
||||||
|
>
|
||||||
|
<Edit2 className="w-3.5 h-3.5" />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Modal: Create/Edit Site Visit */}
|
{/* Modal: Create/Edit Site Visit */}
|
||||||
|
|||||||
Reference in New Issue
Block a user