From a9543bb4001ef88cd0ae605c7d522dd2004ed59a Mon Sep 17 00:00:00 2001 From: nando-bingani Date: Mon, 5 Oct 2026 12:05:36 +0200 Subject: [PATCH] Add review, self-registration, info pages and the legacy site's reports - Review queue (/review) for curators; accept or reject with comments from the project page. Status badges show where each project stands, and owners see why a project was sent back. - Self sign-up (/register) with an admin page to approve or reject requests (/admin/registrations). - About, terms of use, PAIA/POPIA and license pages from the legacy site, and a footer with contact addresses. - Reports: the legacy site's charts split by project type, with data tables and CSV download, plus a map of project locations (Leaflet). - Search results can be shown as a map; projects can be downloaded. - Bulk upload links to the server's offline template. --- package-lock.json | 33 ++ package.json | 2 + src/App.jsx | 12 + src/components/BulkUploadModal.jsx | 7 +- src/components/DownloadProjectButton.jsx | 51 +++ src/components/InterventionBadge.jsx | 35 +- src/components/Layout.jsx | 14 +- src/components/Navbar.jsx | 39 +- src/components/ProjectMap.jsx | 68 ++++ src/components/ReviewPanel.jsx | 97 +++++ src/components/ReviewPanel.test.jsx | 48 +++ src/components/SiteFooter.jsx | 48 +++ src/components/StatusBadge.jsx | 22 ++ src/components/report/BarList.jsx | 10 +- src/components/report/DataTable.jsx | 25 ++ src/components/report/SeriesLegend.jsx | 14 + src/components/report/StackedBars.jsx | 62 +++ src/components/report/StackedColumns.jsx | 107 +++++ src/components/report/YearColumns.jsx | 105 ----- src/content/infoPages.js | 481 +++++++++++++++++++++++ src/index.css | 102 +++++ src/pages/AdminRegistrations.jsx | 144 +++++++ src/pages/AdminRegistrations.test.jsx | 30 ++ src/pages/Home.jsx | 97 ++++- src/pages/Home.test.jsx | 1 + src/pages/InfoPage.jsx | 97 +++++ src/pages/InfoPage.test.jsx | 34 ++ src/pages/Login.jsx | 2 +- src/pages/Register.jsx | 82 ++++ src/pages/Register.test.jsx | 33 ++ src/pages/Reports.jsx | 80 +++- src/pages/Reports.test.jsx | 17 +- src/pages/Review.jsx | 138 +++++++ src/pages/Review.test.jsx | 41 ++ src/pages/SubmissionDetails.jsx | 37 +- src/pages/SubmissionForm.jsx | 23 +- src/services/api.js | 90 ++++- src/utils/budgetRanges.js | 5 + src/utils/csv.js | 32 ++ src/utils/csv.test.js | 14 + src/utils/reviewStatus.js | 28 ++ src/utils/reviewStatus.test.js | 23 ++ src/utils/submissionPayload.js | 1 + src/utils/submissionQuery.js | 18 + src/utils/submissionQuery.test.js | 24 ++ src/utils/typeColors.js | 27 ++ src/utils/typeColors.test.js | 14 + 47 files changed, 2319 insertions(+), 195 deletions(-) create mode 100644 src/components/DownloadProjectButton.jsx create mode 100644 src/components/ProjectMap.jsx create mode 100644 src/components/ReviewPanel.jsx create mode 100644 src/components/ReviewPanel.test.jsx create mode 100644 src/components/SiteFooter.jsx create mode 100644 src/components/StatusBadge.jsx create mode 100644 src/components/report/DataTable.jsx create mode 100644 src/components/report/SeriesLegend.jsx create mode 100644 src/components/report/StackedBars.jsx create mode 100644 src/components/report/StackedColumns.jsx delete mode 100644 src/components/report/YearColumns.jsx create mode 100644 src/content/infoPages.js create mode 100644 src/pages/AdminRegistrations.jsx create mode 100644 src/pages/AdminRegistrations.test.jsx create mode 100644 src/pages/InfoPage.jsx create mode 100644 src/pages/InfoPage.test.jsx create mode 100644 src/pages/Register.jsx create mode 100644 src/pages/Register.test.jsx create mode 100644 src/pages/Review.jsx create mode 100644 src/pages/Review.test.jsx create mode 100644 src/utils/budgetRanges.js create mode 100644 src/utils/csv.js create mode 100644 src/utils/csv.test.js create mode 100644 src/utils/reviewStatus.js create mode 100644 src/utils/reviewStatus.test.js create mode 100644 src/utils/submissionQuery.test.js create mode 100644 src/utils/typeColors.js create mode 100644 src/utils/typeColors.test.js diff --git a/package-lock.json b/package-lock.json index 3b05523..3036527 100644 --- a/package-lock.json +++ b/package-lock.json @@ -8,9 +8,11 @@ "name": "nccrd-react-ui", "version": "0.0.0", "dependencies": { + "leaflet": "1.9.4", "lucide-react": "^1.46.0", "react": "^19.3.0", "react-dom": "^19.3.0", + "react-leaflet": "5.0.0", "react-router-dom": "^7.13.1" }, "devDependencies": { @@ -803,6 +805,17 @@ "url": "https://github.com/sponsors/oxc-project" } }, + "node_modules/@react-leaflet/core": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/@react-leaflet/core/-/core-3.0.0.tgz", + "integrity": "sha512-3EWmekh4Nz+pGcr+xjf0KNyYfC3U2JjnkWsh0zcqaexYqmmB5ZhH37kz41JXGmKzpaMZCnPofBBm64i+YrEvGQ==", + "license": "Hippocratic-2.1", + "peerDependencies": { + "leaflet": "^1.9.0", + "react": "^19.0.0", + "react-dom": "^19.0.0" + } + }, "node_modules/@rolldown/binding-android-arm-eabi": { "version": "1.2.8", "resolved": "https://registry.npmjs.org/@rolldown/binding-android-arm-eabi/-/binding-android-arm-eabi-1.2.8.tgz", @@ -3526,6 +3539,12 @@ "json-buffer": "3.0.1" } }, + "node_modules/leaflet": { + "version": "1.9.4", + "resolved": "https://registry.npmjs.org/leaflet/-/leaflet-1.9.4.tgz", + "integrity": "sha512-nxS1ynzJOmOlHp+iL3FyWqK89GtNL8U8rvlMOsQdTTssxZwCXh8N2NB3GDQOL+YR3XnWyZAxwQixURb+FA74PA==", + "license": "BSD-2-Clause" + }, "node_modules/levn": { "version": "0.4.1", "resolved": "https://registry.npmjs.org/levn/-/levn-0.4.1.tgz", @@ -4379,6 +4398,20 @@ "dev": true, "license": "MIT" }, + "node_modules/react-leaflet": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/react-leaflet/-/react-leaflet-5.0.0.tgz", + "integrity": "sha512-CWbTpr5vcHw5bt9i4zSlPEVQdTVcML390TjeDG0cK59z1ylexpqC6M1PJFjV8jD7CF+ACBFsLIDs6DRMoLEofw==", + "license": "Hippocratic-2.1", + "dependencies": { + "@react-leaflet/core": "^3.0.0" + }, + "peerDependencies": { + "leaflet": "^1.9.0", + "react": "^19.0.0", + "react-dom": "^19.0.0" + } + }, "node_modules/react-router": { "version": "7.18.3", "resolved": "https://registry.npmjs.org/react-router/-/react-router-7.18.3.tgz", diff --git a/package.json b/package.json index bb954a7..eb9adb0 100644 --- a/package.json +++ b/package.json @@ -11,9 +11,11 @@ "test": "vitest run" }, "dependencies": { + "leaflet": "1.9.4", "lucide-react": "^1.46.0", "react": "^19.3.0", "react-dom": "^19.3.0", + "react-leaflet": "5.0.0", "react-router-dom": "^7.13.1" }, "devDependencies": { diff --git a/src/App.jsx b/src/App.jsx index 329f5c4..f1f319f 100644 --- a/src/App.jsx +++ b/src/App.jsx @@ -15,6 +15,10 @@ const Login = lazy(() => import('./pages/Login')) const ChangePassword = lazy(() => import('./pages/ChangePassword')) const AdminCreateUser = lazy(() => import('./pages/AdminCreateUser')) const Reports = lazy(() => import('./pages/Reports')) +const Review = lazy(() => import('./pages/Review')) +const InfoPage = lazy(() => import('./pages/InfoPage')) +const Register = lazy(() => import('./pages/Register')) +const AdminRegistrations = lazy(() => import('./pages/AdminRegistrations')) const NotFound = lazy(() => import('./pages/NotFound')) function App() { @@ -26,7 +30,15 @@ function App() { } /> } /> } /> + } /> + } /> + } /> } /> + {/* Same paths as the legacy site, so existing links keep working. */} + } /> + } /> + } /> + } /> } /> } /> } /> diff --git a/src/components/BulkUploadModal.jsx b/src/components/BulkUploadModal.jsx index d88e197..4a515af 100644 --- a/src/components/BulkUploadModal.jsx +++ b/src/components/BulkUploadModal.jsx @@ -1,6 +1,6 @@ import { useState, useRef } from 'react'; import { createPortal } from 'react-dom'; -import { uploadBulkSubmissions } from '../services/api'; +import { uploadBulkSubmissions, UPLOAD_TEMPLATE_URL } from '../services/api'; import { Upload, UploadCloud, X, AlertTriangle, CheckCircle2, Activity } from 'lucide-react'; // ───────────────────────────────────────────────────────────────────────────── @@ -97,7 +97,10 @@ const BulkUploadModal = ({ onClose, onSuccess }) => { Upload a consolidated .xlsx workbook with sheets: “General project details”, “Adaptation details” (optional), “Mitigation details” (optional). Row 1 must contain column headers. - All rows are validated before any record is saved. + All rows are validated before any record is saved.{' '} + + Download the template + {' '}with dropdown lists and instructions.

{/* Drop zone */} diff --git a/src/components/DownloadProjectButton.jsx b/src/components/DownloadProjectButton.jsx new file mode 100644 index 0000000..a6fa90a --- /dev/null +++ b/src/components/DownloadProjectButton.jsx @@ -0,0 +1,51 @@ +// ───────────────────────────────────────────────────────────────────────────── +// DownloadProjectButton — downloads one project as an Excel file (same columns +// as the bulk export). `compact` renders an icon button for result cards. +// ───────────────────────────────────────────────────────────────────────────── +import { useState } from 'react'; +import { Download } from 'lucide-react'; +import { downloadExport } from '../services/api'; +import { useCurrentUser } from '../context/CurrentUserContext'; +import { singleProjectParams } from '../utils/submissionQuery'; + +const DownloadProjectButton = ({ submission, compact = false }) => { + const { user, hasPermission } = useCurrentUser(); + const [busy, setBusy] = useState(false); + const [failed, setFailed] = useState(false); + + const download = async () => { + setBusy(true); + setFailed(false); + try { + await downloadExport(singleProjectParams(submission, { + isOwner: !!user && submission.createdby === user.id, + isReviewer: hasPermission('validate-submission'), + }), 'xlsx'); + } catch (err) { + console.error(err); + setFailed(true); + } finally { + setBusy(false); + } + }; + + const label = failed ? 'Download failed, try again' : `Download ${submission.title || 'this project'} as Excel`; + return compact ? ( + + ) : ( + + ); +}; + +export default DownloadProjectButton; diff --git a/src/components/InterventionBadge.jsx b/src/components/InterventionBadge.jsx index 4a6916d..253bd14 100644 --- a/src/components/InterventionBadge.jsx +++ b/src/components/InterventionBadge.jsx @@ -1,30 +1,15 @@ // ───────────────────────────────────────────────────────────────────────────── -// InterventionBadge — coloured pill chip per intervention type (used by Home) +// InterventionBadge — project type in the shared boxed .badge style, with a +// colour dot carrying the type colour (shared with the map and charts, +// utils/typeColors.js); the text keeps the badge's readable colour. // ───────────────────────────────────────────────────────────────────────────── +import { typeColor } from '../utils/typeColors'; -/** Colour tokens for the per-submission intervention type badge. */ -const INTERVENTION_BADGE = { - Mitigation: { color: '#fbbf24', bg: 'rgba(251,191,36,0.12)' }, - Adaptation: { color: '#34d399', bg: 'rgba(52,211,153,0.12)' }, - 'Cross Cutting': { color: '#818cf8', bg: 'rgba(129,140,248,0.12)' }, -}; - -const InterventionBadge = ({ type }) => { - const style = INTERVENTION_BADGE[type] || { color: 'var(--text-muted)', bg: 'transparent' }; - return ( - - {type || 'General'} - - ); -}; +const InterventionBadge = ({ type }) => ( + + +); export default InterventionBadge; diff --git a/src/components/Layout.jsx b/src/components/Layout.jsx index a4b63ab..f3422f5 100644 --- a/src/components/Layout.jsx +++ b/src/components/Layout.jsx @@ -1,5 +1,6 @@ import { Outlet, useLocation } from 'react-router-dom'; import Navbar from './Navbar'; +import SiteFooter from './SiteFooter'; const Layout = () => { const location = useLocation(); @@ -11,18 +12,7 @@ const Layout = () => {
-
-
-

- © {new Date().getFullYear()} National Climate Change Response Database. Powered by Open Data Platform. -

-
-
+ ); }; diff --git a/src/components/Navbar.jsx b/src/components/Navbar.jsx index 7fd5922..748ed1e 100644 --- a/src/components/Navbar.jsx +++ b/src/components/Navbar.jsx @@ -1,10 +1,32 @@ import { Link, useNavigate } from 'react-router-dom'; -import { Search, UserPlus, Activity, FolderOpen } from 'lucide-react'; +import { useEffect, useState } from 'react'; +import { Search, UserPlus, Activity, FolderOpen, ClipboardCheck, BarChart3, UserCheck } from 'lucide-react'; import { useCurrentUser } from '../context/CurrentUserContext'; +import { getReviewCounts, getRegistrations } from '../services/api'; const Navbar = () => { const navigate = useNavigate(); const { user, hasPermission, isAuthenticated, logout } = useCurrentUser(); + const isReviewer = hasPermission('validate-submission'); + const [awaitingReview, setAwaitingReview] = useState(null); + const isAdmin = hasPermission('assign-role'); + const [pendingRequests, setPendingRequests] = useState(null); + + // Admins see how many account requests are waiting. + useEffect(() => { + if (!isAdmin) return; + getRegistrations('pending') + .then((list) => setPendingRequests(list.length)) + .catch((err) => console.warn('[NCCRD] Could not load account requests.', err)); + }, [isAdmin]); + + // Reviewers see how many submissions are waiting. + useEffect(() => { + if (!isReviewer) return; + getReviewCounts() + .then((counts) => setAwaitingReview(counts.awaiting_review)) + .catch((err) => console.warn('[NCCRD] Could not load review counts.', err)); + }, [isReviewer]); const handleLogout = () => { logout(); @@ -53,6 +75,9 @@ const Navbar = () => { { e.preventDefault(); handleSearchData(); }}> SEARCH DATA + + DATA REPORTS + {isAuthenticated && ( MY SUBMISSIONS @@ -63,6 +88,18 @@ const Navbar = () => { NEW SUBMISSION )} + {isReviewer && ( + + REVIEW + {awaitingReview > 0 && {awaitingReview}} + + )} + {isAdmin && ( + + REQUESTS + {pendingRequests > 0 && {pendingRequests}} + + )} {hasPermission('assign-role') && ( ADD USER diff --git a/src/components/ProjectMap.jsx b/src/components/ProjectMap.jsx new file mode 100644 index 0000000..d096165 --- /dev/null +++ b/src/components/ProjectMap.jsx @@ -0,0 +1,68 @@ +// ───────────────────────────────────────────────────────────────────────────── +// ProjectMap — where the filtered projects are, one dot per project location +// (a project can list several), coloured by type with a white ring so dots +// stay distinct on any basemap and where they overlap. Hover shows the title; +// clicking opens a popup linking to the project. +// +// Points come from GET /report/locations (see services/api.js), which takes +// the same filters as the project search. Tiles: OpenStreetMap, credited as +// its licence requires. +// ───────────────────────────────────────────────────────────────────────────── +import { MapContainer, TileLayer, CircleMarker, Tooltip, Popup } from 'react-leaflet'; +import { Link } from 'react-router-dom'; +import 'leaflet/dist/leaflet.css'; +import { countByType, typeColor } from '../utils/typeColors'; + +const SOUTH_AFRICA = { center: [-29.0, 24.7], zoom: 5 }; + +const ProjectMap = ({ projects = [], withoutLocation = 0, height = 440 }) => ( +
+
+ + + {projects.flatMap((p) => p.points.map(([lat, lon], i) => ( + + {p.title || 'Untitled project'} + + {p.title || 'Untitled project'} +
+ {p.type} +
+ View project +
+
+ )))} +
+
+
+ {countByType(projects).map(([type, n]) => ( + + + ))} + {withoutLocation > 0 && ( + + {withoutLocation.toLocaleString()} projects have no recorded location and are not shown. + + )} +
+
+); + +export default ProjectMap; diff --git a/src/components/ReviewPanel.jsx b/src/components/ReviewPanel.jsx new file mode 100644 index 0000000..cf60732 --- /dev/null +++ b/src/components/ReviewPanel.jsx @@ -0,0 +1,97 @@ +// ───────────────────────────────────────────────────────────────────────────── +// Review status for a project page. +// +// – ReviewStatusNote: tells the owner (or a reviewer) why a project isn't +// public yet, including the reviewer's reason when it wasn't accepted. +// – ReviewPanel: reviewers accept the project (publishing it) or mark it +// not accepted with a reason the submitter will see. +// ───────────────────────────────────────────────────────────────────────────── +import { useState } from 'react'; +import { CircleCheck, CircleX } from 'lucide-react'; +import { reviewSubmission } from '../services/api'; +import { reviewState } from '../utils/reviewStatus'; + +const NOTES = { + awaiting_review: 'Waiting for review. Only you and reviewers can see this project until it is accepted.', + not_accepted: 'Not accepted, so only you and reviewers can see it. Edit the project to address the comments below; saving sends it back for review.', + draft: 'Draft: not submitted for review yet. Only you and reviewers can see it.', +}; + +export const ReviewStatusNote = ({ submission }) => { + const state = reviewState(submission); + if (state === 'published') return null; + return ( +
+

{NOTES[state]}

+ {submission.submission_comments && ( +
+ {submission.submission_comments} + {submission.reviewed_by &&
{submission.reviewed_by}, reviewer
} +
+ )} +
+ ); +}; + +export const ReviewPanel = ({ submission, onReviewed }) => { + const [comments, setComments] = useState(''); + const [saving, setSaving] = useState(null); + const [error, setError] = useState(null); + const published = reviewState(submission) === 'published'; + + const decide = async (decision) => { + if (decision === 'Not accepted' && !comments.trim()) { + setError('Give a reason so the submitter knows what to fix.'); + return; + } + setSaving(decision); + setError(null); + try { + await reviewSubmission(submission.id, decision, comments); + setComments(''); + onReviewed(decision); + } catch (err) { + setError(err.message || 'Could not save the review.'); + } finally { + setSaving(null); + } + }; + + return ( +
+

Review

+

+ {published + ? 'This project is published. Marking it not accepted removes it from the public site.' + : 'Accepting publishes this project on the public site.'} +

+ +