diff --git a/package-lock.json b/package-lock.json
index 6f6909d..a6f3811 100644
--- a/package-lock.json
+++ b/package-lock.json
@@ -26,6 +26,7 @@
"qrcode.react": "^4.2.0",
"react": "^19.0.0",
"react-dom": "^19.0.0",
+ "react-pick-color": "^2.0.0",
"react-router": "^7.3.0",
"react-router-dom": "^7.3.0",
"react-select": "^5.10.1",
@@ -9105,6 +9106,18 @@
"react": "^19.0.0"
}
},
+ "node_modules/react-pick-color": {
+ "version": "2.0.0",
+ "resolved": "https://registry.npmjs.org/react-pick-color/-/react-pick-color-2.0.0.tgz",
+ "integrity": "sha512-GLYyUN1k60cxkrizqRDqfmCBNP6vJZDam5TfCMMxgxPjNul9zmunAZAJ8x9wy1yMb1NqMa/MI2np7oDQLCEbDg==",
+ "dependencies": {
+ "tinycolor2": "^1.4.1"
+ },
+ "peerDependencies": {
+ "react": ">=16.8.0",
+ "react-dom": ">=16.8.0"
+ }
+ },
"node_modules/react-router": {
"version": "7.3.0",
"resolved": "https://registry.npmjs.org/react-router/-/react-router-7.3.0.tgz",
@@ -10414,6 +10427,11 @@
"url": "https://github.com/sponsors/sindresorhus"
}
},
+ "node_modules/tinycolor2": {
+ "version": "1.6.0",
+ "resolved": "https://registry.npmjs.org/tinycolor2/-/tinycolor2-1.6.0.tgz",
+ "integrity": "sha512-XPaBkWQJdsf3pLKJV9p4qN/S+fm2Oj8AIPo1BTUhg5oxkvm9+SVEGFdhyOz7tTdUTfvxMiAs4sp6/eZO2Ew+pw=="
+ },
"node_modules/to-regex-range": {
"version": "5.0.1",
"resolved": "https://registry.npmjs.org/to-regex-range/-/to-regex-range-5.0.1.tgz",
@@ -16862,6 +16880,14 @@
"scheduler": "^0.25.0"
}
},
+ "react-pick-color": {
+ "version": "2.0.0",
+ "resolved": "https://registry.npmjs.org/react-pick-color/-/react-pick-color-2.0.0.tgz",
+ "integrity": "sha512-GLYyUN1k60cxkrizqRDqfmCBNP6vJZDam5TfCMMxgxPjNul9zmunAZAJ8x9wy1yMb1NqMa/MI2np7oDQLCEbDg==",
+ "requires": {
+ "tinycolor2": "^1.4.1"
+ }
+ },
"react-router": {
"version": "7.3.0",
"resolved": "https://registry.npmjs.org/react-router/-/react-router-7.3.0.tgz",
@@ -17727,6 +17753,11 @@
"convert-hrtime": "^5.0.0"
}
},
+ "tinycolor2": {
+ "version": "1.6.0",
+ "resolved": "https://registry.npmjs.org/tinycolor2/-/tinycolor2-1.6.0.tgz",
+ "integrity": "sha512-XPaBkWQJdsf3pLKJV9p4qN/S+fm2Oj8AIPo1BTUhg5oxkvm9+SVEGFdhyOz7tTdUTfvxMiAs4sp6/eZO2Ew+pw=="
+ },
"to-regex-range": {
"version": "5.0.1",
"resolved": "https://registry.npmjs.org/to-regex-range/-/to-regex-range-5.0.1.tgz",
diff --git a/package.json b/package.json
index e2f889a..7c0db0b 100644
--- a/package.json
+++ b/package.json
@@ -23,6 +23,7 @@
"qrcode.react": "^4.2.0",
"react": "^19.0.0",
"react-dom": "^19.0.0",
+ "react-pick-color": "^2.0.0",
"react-router": "^7.3.0",
"react-router-dom": "^7.3.0",
"react-select": "^5.10.1",
diff --git a/src/ListItems.tsx b/src/ListItems.tsx
index 3458980..1fce4d0 100644
--- a/src/ListItems.tsx
+++ b/src/ListItems.tsx
@@ -7,6 +7,7 @@ import ManageAccountsIcon from '@mui/icons-material/ManageAccounts';
import SyncIcon from '@mui/icons-material/Sync';
import HomeIcon from '@mui/icons-material/Home';
import AdminPanelSettingsIcon from '@mui/icons-material/AdminPanelSettings';
+import EditIcon from '@mui/icons-material/Edit';
import { ListItemButton } from '@mui/material';
export const mainListItems = (
@@ -18,7 +19,7 @@ export const mainListItems = (
- window.open('/admin/edit-site', '_self')}>
+ window.open('/admin/list-sites', '_self')}>
diff --git a/src/admin/AdminBody.tsx b/src/admin/AdminBody.tsx
index 730ce4b..068dbbb 100644
--- a/src/admin/AdminBody.tsx
+++ b/src/admin/AdminBody.tsx
@@ -1,6 +1,8 @@
import UserPage from './UserPage';
import EditSite from './EditSite';
import EditData from './EditData';
+import ListSites from './ListSites';
+import CreateEditSite from './CreateEditSite';
interface AdminBodyProps {
page: AdminPage;
@@ -14,6 +16,12 @@ export default function AdminBody(props: AdminBodyProps) {
return ;
case 'edit-data':
return ;
+ case 'list-sites':
+ return ;
+ case 'create-site':
+ return ;
+ case 'new-edit-site':
+ return ;
default:
return Error
;
}
diff --git a/src/admin/CreateEditSite.tsx b/src/admin/CreateEditSite.tsx
new file mode 100644
index 0000000..7d42add
--- /dev/null
+++ b/src/admin/CreateEditSite.tsx
@@ -0,0 +1,484 @@
+import React, { useState, useEffect } from 'react';
+import {
+ Box,
+ Container,
+ Paper,
+ Typography,
+ TextField,
+ Button,
+ IconButton,
+ Select,
+ MenuItem,
+ FormControl,
+ InputLabel,
+ Checkbox,
+ FormControlLabel,
+ Divider,
+} from '@mui/material';
+import {
+ ArrowBack as ArrowBackIcon,
+ Add as AddIcon,
+ Delete as DeleteIcon,
+} from '@mui/icons-material';
+import ColorPicker from 'react-pick-color';
+import { apiClient } from '@/utils/fetch';
+import { siteToSchema } from '@/utils/siteUtils';
+
+interface CellEntry {
+ id: string;
+ cellId: string;
+}
+
+interface BoundaryPoint {
+ id: string;
+ lat: string;
+ lng: string;
+}
+
+interface CreateEditSiteProps {
+ mode: 'create' | 'edit';
+}
+
+export default function CreateEditSite({ mode }: CreateEditSiteProps) {
+ const [name, setName] = useState('');
+ const [longitude, setLongitude] = useState('');
+ const [latitude, setLatitude] = useState('');
+ const [status, setStatus] = useState('');
+ const [address, setAddress] = useState('');
+ const [cells, setCells] = useState([]);
+ const [colorEnabled, setColorEnabled] = useState(true);
+ const [colorValue, setColorValue] = useState('#fff');
+ const [boundaryEnabled, setBoundaryEnabled] = useState(true);
+ const [boundaryPoints, setBoundaryPoints] = useState([]);
+
+ const editSite = (site: Site) => {
+ return apiClient
+ .PUT('/secure/edit-sites', {
+ body: siteToSchema(site),
+ })
+ .then(res => {
+ const { data, error } = res;
+ if (error) {
+ console.error(`Failed to edit site: ${error}`);
+ return Promise.reject(error);
+ }
+ console.log(`Successfully edited site: ${site.name}`);
+ return data;
+ })
+ .catch(err => {
+ console.error(`Error editing site: ${err}`);
+ return Promise.reject(err);
+ });
+ };
+
+ const createSite = (site: Site) => {
+ return apiClient
+ .POST('/secure/edit-sites', {
+ body: siteToSchema(site),
+ })
+ .then(res => {
+ const { data, error } = res;
+ if (error) {
+ console.error(`Failed to create site: ${error}`);
+ return Promise.reject(error);
+ }
+ console.log(`Successfully created site: ${site.name}`);
+ return data;
+ })
+ .catch(err => {
+ console.error(`Error creating site: ${err}`);
+ return Promise.reject(err);
+ });
+ };
+
+ const handleBack = () => {
+ console.log('Navigate back');
+ window.open('/admin/list-sites', '_self');
+ };
+
+ const handleSave = () => {
+ console.log('Save site');
+ if (validateSite()) {
+ const site: Site = {
+ name,
+ latitude: parseFloat(latitude),
+ longitude: parseFloat(longitude),
+ status: status as SiteStatus,
+ address,
+ cell_id: cells.map(cell => cell.cellId),
+ color: colorEnabled ? colorValue : undefined,
+ boundary: boundaryEnabled
+ ? boundaryPoints.map(point => [
+ parseFloat(point.lat),
+ parseFloat(point.lng),
+ ])
+ : undefined,
+ };
+ const savePromise = mode === 'edit' ? editSite(site) : createSite(site);
+
+ savePromise.then(() => {
+ handleBack();
+ });
+ }
+ };
+
+ const addCell = () => {
+ const newCell: CellEntry = {
+ id: Date.now().toString(),
+ cellId: '',
+ };
+ setCells([...cells, newCell]);
+ };
+
+ const deleteCell = (id: string) => {
+ setCells(cells.filter(cell => cell.id !== id));
+ };
+
+ const updateCellId = (id: string, cellId: string) => {
+ setCells(cells.map(cell => (cell.id === id ? { ...cell, cellId } : cell)));
+ };
+
+ const addBoundaryPoint = () => {
+ const newPoint: BoundaryPoint = {
+ id: Date.now().toString(),
+ lat: '',
+ lng: '',
+ };
+ setBoundaryPoints([...boundaryPoints, newPoint]);
+ };
+
+ const deleteBoundaryPoint = (id: string) => {
+ setBoundaryPoints(boundaryPoints.filter(point => point.id !== id));
+ };
+
+ const updateBoundaryPoint = (
+ id: string,
+ field: 'lat' | 'lng',
+ value: string,
+ ) => {
+ setBoundaryPoints(
+ boundaryPoints.map(point =>
+ point.id === id ? { ...point, [field]: value } : point,
+ ),
+ );
+ };
+
+ const validateSite = (): boolean => {
+ if (name === '') {
+ alert('Name is required');
+ return false;
+ }
+ if (
+ longitude === '' ||
+ isNaN(Number(longitude)) ||
+ Number(longitude) < -180 ||
+ Number(longitude) > 180
+ ) {
+ alert('Valid Longitude is required');
+ return false;
+ }
+ if (
+ latitude === '' ||
+ isNaN(Number(latitude)) ||
+ Number(latitude) < -90 ||
+ Number(latitude) > 90
+ ) {
+ alert('Valid Latitude is required');
+ return false;
+ }
+ if (status === '') {
+ alert('Status is required');
+ return false;
+ }
+ if (address === '') {
+ alert('Address is required');
+ return false;
+ }
+ if (cells.length === 0) {
+ alert('At least one Cell ID is required');
+ return false;
+ }
+ if (boundaryEnabled) {
+ for (const point of boundaryPoints) {
+ if (
+ point.lat === '' ||
+ isNaN(Number(point.lat)) ||
+ Number(point.lat) < -90 ||
+ Number(point.lat) > 90
+ ) {
+ alert('Valid Latitude for Boundary Point is required');
+ return false;
+ }
+ if (
+ point.lng === '' ||
+ isNaN(Number(point.lng)) ||
+ Number(point.lng) < -180 ||
+ Number(point.lng) > 180
+ ) {
+ alert('Valid Longitude for Boundary Point is required');
+ return false;
+ }
+ }
+ }
+ return true;
+ };
+
+ useEffect(() => {
+ if (mode === 'edit') {
+ const urlParams = new URLSearchParams(window.location.search);
+ const siteParam = urlParams.get('site');
+ if (siteParam) {
+ try {
+ const siteData = JSON.parse(decodeURIComponent(siteParam));
+ setName(siteData.name);
+ setLatitude(siteData.latitude.toString());
+ setLongitude(siteData.longitude.toString());
+ setStatus(siteData.status);
+ setAddress(siteData.address);
+ setCells(
+ siteData.cell_id.map((cellId: string) => ({
+ id: Date.now().toString() + cellId,
+ cellId: cellId,
+ })),
+ );
+ if (siteData.color) {
+ setColorEnabled(true);
+ setColorValue(siteData.color);
+ }
+ if (siteData.boundary) {
+ setBoundaryEnabled(true);
+ setBoundaryPoints(
+ siteData.boundary
+ .filter(
+ (point: [number, number]) =>
+ point && point[0] !== null && point[1] !== null,
+ )
+ .map((point: [number, number], index: number) => ({
+ id: Date.now().toString() + index,
+ lat: point[0].toString(),
+ lng: point[1].toString(),
+ })),
+ );
+ }
+ } catch (error) {
+ console.error('Failed to parse site data from URL:', error);
+ }
+ }
+ }
+ }, [mode]);
+
+ return (
+
+
+
+
+
+
+
+
+
+ setName(e.target.value)}
+ sx={{ mb: 2 }}
+ />
+
+
+ setLongitude(e.target.value)}
+ />
+ setLatitude(e.target.value)}
+ />
+
+
+
+ Status
+
+
+
+ setAddress(e.target.value)}
+ sx={{ mb: 2 }}
+ />
+
+
+
+
+ Cells
+
+
+
+
+
+
+ {cells.map(cell => (
+
+
+ Cell ID
+
+ updateCellId(cell.id, e.target.value)}
+ sx={{ flexGrow: 1 }}
+ />
+
+
+ ))}
+
+
+
+
+
+ setColorEnabled(e.target.checked)}
+ />
+ }
+ label='Color'
+ />
+ {colorEnabled && (
+ setColorValue(color.hex)}
+ />
+ )}
+
+
+
+ setBoundaryEnabled(e.target.checked)}
+ />
+ }
+ label='Boundary'
+ />
+ {boundaryEnabled && (
+
+
+
+ )}
+
+
+ {boundaryEnabled &&
+ boundaryPoints.map(point => (
+
+
+ (Lat, Long)
+
+
+ updateBoundaryPoint(point.id, 'lat', e.target.value)
+ }
+ sx={{ flexGrow: 1 }}
+ />
+
+ updateBoundaryPoint(point.id, 'lng', e.target.value)
+ }
+ sx={{ flexGrow: 1 }}
+ />
+
+
+ ))}
+
+
+
+ );
+}
diff --git a/src/admin/ListSites.tsx b/src/admin/ListSites.tsx
new file mode 100644
index 0000000..028f61c
--- /dev/null
+++ b/src/admin/ListSites.tsx
@@ -0,0 +1,192 @@
+import { useEffect, useState } from 'react';
+import {
+ Box,
+ Container,
+ Paper,
+ Typography,
+ Button,
+ Fab,
+ List,
+ ListItem,
+ ListItemText,
+} from '@mui/material';
+import { Add as AddIcon } from '@mui/icons-material';
+import { apiClient } from '@/utils/fetch';
+import { siteToSchema } from '@/utils/siteUtils';
+
+const parseSitesFromJSON = (jsonString: string): Site[] => {
+ try {
+ const parsed = JSON.parse(jsonString);
+
+ if (!Array.isArray(parsed)) {
+ throw new Error('Invalid format: response should be an array of sites');
+ }
+
+ const sites: Site[] = parsed.map((site: any): Site => {
+ return {
+ name: site.name,
+ latitude: site.latitude,
+ longitude: site.longitude,
+ status: site.status,
+ address: site.address,
+ cell_id: site.cell_id,
+ color: site.color,
+ boundary:
+ site.boundary?.map(
+ (point: any) => [point[0], point[1]] as [number, number],
+ ) ?? undefined,
+ };
+ });
+
+ return sites;
+ } catch (error) {
+ console.error('Failed to parse sites JSON:', error);
+ return [];
+ }
+};
+
+export default function ListSites() {
+ const [sites, setSites] = useState([]);
+ const handleEdit = (siteName: string) => {
+ console.log(`Edit site with ID: ${siteName}`);
+ const site = sites.find(s => s.name === siteName);
+ if (site) {
+ const siteData = encodeURIComponent(JSON.stringify(site));
+ window.open(`/admin/new-edit-site?site=${siteData}`, '_self');
+ }
+ };
+
+ const handleDelete = (siteName: string) => {
+ const site = sites.find(s => s.name === siteName);
+ if (site) {
+ const confirmed = window.confirm(
+ `Are you sure you want to delete "${site.name}"?`,
+ );
+ if (confirmed) {
+ deleteSite(site);
+ reloadSites();
+ }
+ }
+ };
+
+ const handleAdd = () => {
+ console.log('Add new site');
+ window.open('/admin/create-site', '_self');
+ };
+
+ const reloadSites = () => {
+ apiClient
+ .GET('/api/sites')
+ .then(res => {
+ const { data, error } = res;
+ if (error || !data) {
+ console.log(`Unable to query sites: ${error}`);
+ return;
+ }
+ setSites(parseSitesFromJSON(JSON.stringify(data)));
+ })
+ .catch(err => {
+ return ;
+ });
+ };
+ useEffect(() => {
+ reloadSites();
+ });
+
+ const deleteSite = (site: Site) => {
+ apiClient
+ .DELETE('/secure/edit-sites', {
+ body: siteToSchema(site),
+ })
+ .then(res => {
+ const { data, error } = res;
+ if (error) {
+ console.error(`Failed to delete site: ${error}`);
+ return;
+ }
+ console.log(`Successfully deleted site: ${site.name}`);
+ reloadSites();
+ })
+ .catch(err => {
+ console.error(`Error deleting site: ${err}`);
+ });
+ };
+
+ return (
+
+
+
+ Site Management
+
+
+
+ {sites.map(site => (
+
+
+ {site.name}
+
+ }
+ sx={{ flexGrow: 1 }}
+ />
+
+
+
+
+
+ ))}
+
+
+
+
+
+
+ );
+}
diff --git a/src/index.tsx b/src/index.tsx
index 793b884..2fe99f9 100644
--- a/src/index.tsx
+++ b/src/index.tsx
@@ -23,6 +23,18 @@ root.render(
} />
} />
} />
+ }
+ />
+ }
+ />
+ }
+ />
}
diff --git a/src/types.d.ts b/src/types.d.ts
index e213942..bd2f21a 100644
--- a/src/types.d.ts
+++ b/src/types.d.ts
@@ -1,3 +1,5 @@
+type LatLng = [number, number];
+
type SiteOption = {
label: string;
value: string;
@@ -22,7 +24,13 @@ type DisplayOption = {
type SiteStatus = 'active' | 'confirmed' | 'in-conversation' | 'unknown';
-type AdminPage = 'users' | 'edit-site' | 'edit-data';
+type AdminPage =
+ | 'users'
+ | 'edit-site'
+ | 'edit-data'
+ | 'list-sites'
+ | 'create-site'
+ | 'new-edit-site';
type UserRow = {
identity: string;
diff --git a/src/types/api.d.ts b/src/types/api.d.ts
index 07f96f8..454747e 100644
--- a/src/types/api.d.ts
+++ b/src/types/api.d.ts
@@ -1075,6 +1075,184 @@ export interface paths {
patch?: never;
trace?: never;
};
+ '/secure/edit-sites': {
+ parameters: {
+ query?: never;
+ header?: never;
+ path?: never;
+ cookie?: never;
+ };
+ get?: never;
+ /**
+ * Update an existing site
+ * @description Updates an existing site with the provided information
+ */
+ put: {
+ parameters: {
+ query?: never;
+ header?: never;
+ path?: never;
+ cookie?: never;
+ };
+ requestBody: {
+ content: {
+ 'application/json': components['schemas']['Site'];
+ };
+ };
+ responses: {
+ /** @description Site successfully updated */
+ 200: {
+ headers: {
+ [name: string]: unknown;
+ };
+ content: {
+ 'text/plain': string;
+ };
+ };
+ /** @description Bad request - invalid site data */
+ 400: {
+ headers: {
+ [name: string]: unknown;
+ };
+ content: {
+ 'text/plain': string;
+ };
+ };
+ /** @description Unauthorized - User not logged in */
+ 401: {
+ headers: {
+ [name: string]: unknown;
+ };
+ content: {
+ 'text/plain': string;
+ };
+ };
+ /** @description Server error while updating site */
+ 500: {
+ headers: {
+ [name: string]: unknown;
+ };
+ content: {
+ 'text/plain': string;
+ };
+ };
+ };
+ };
+ /**
+ * Add a new site
+ * @description Creates a new site with the provided information
+ */
+ post: {
+ parameters: {
+ query?: never;
+ header?: never;
+ path?: never;
+ cookie?: never;
+ };
+ requestBody: {
+ content: {
+ 'application/json': components['schemas']['Site'];
+ };
+ };
+ responses: {
+ /** @description Site successfully created */
+ 201: {
+ headers: {
+ [name: string]: unknown;
+ };
+ content: {
+ 'text/plain': string;
+ };
+ };
+ /** @description Bad request - invalid site data */
+ 400: {
+ headers: {
+ [name: string]: unknown;
+ };
+ content: {
+ 'text/plain': string;
+ };
+ };
+ /** @description Unauthorized - User not logged in */
+ 401: {
+ headers: {
+ [name: string]: unknown;
+ };
+ content: {
+ 'text/plain': string;
+ };
+ };
+ /** @description Server error while creating site */
+ 500: {
+ headers: {
+ [name: string]: unknown;
+ };
+ content: {
+ 'text/plain': string;
+ };
+ };
+ };
+ };
+ /**
+ * Delete a site
+ * @description Removes an existing site from the system
+ */
+ delete: {
+ parameters: {
+ query?: never;
+ header?: never;
+ path?: never;
+ cookie?: never;
+ };
+ requestBody: {
+ content: {
+ 'application/json': components['schemas']['Site'];
+ };
+ };
+ responses: {
+ /** @description Site successfully deleted */
+ 200: {
+ headers: {
+ [name: string]: unknown;
+ };
+ content: {
+ 'text/plain': string;
+ };
+ };
+ /** @description Bad request - invalid site data */
+ 400: {
+ headers: {
+ [name: string]: unknown;
+ };
+ content: {
+ 'text/plain': string;
+ };
+ };
+ /** @description Unauthorized - User not logged in */
+ 401: {
+ headers: {
+ [name: string]: unknown;
+ };
+ content: {
+ 'text/plain': string;
+ };
+ };
+ /** @description Server error while deleting site */
+ 500: {
+ headers: {
+ [name: string]: unknown;
+ };
+ content: {
+ 'text/plain': string;
+ };
+ };
+ };
+ };
+ options?: never;
+ head?: never;
+ patch?: never;
+ trace?: never;
+ };
}
export type webhooks = Record;
export interface components {
diff --git a/src/utils/siteUtils.ts b/src/utils/siteUtils.ts
new file mode 100644
index 0000000..b27d7ed
--- /dev/null
+++ b/src/utils/siteUtils.ts
@@ -0,0 +1,24 @@
+import { components } from '@/types/api';
+
+export const siteToSchema = (site: Site): components['schemas']['Site'] => {
+ return {
+ name: site.name,
+ latitude: site.latitude,
+ longitude: site.longitude,
+ status: siteStatusToSchema(site.status),
+ address: site.address,
+ cell_id: site.cell_id,
+ color: site.color,
+ boundary: site.boundary,
+ };
+};
+
+export const siteStatusToSchema = (
+ siteStatus: SiteStatus,
+): components['parameters']['SiteStatus'] => {
+ if (siteStatus === 'unknown') {
+ throw new Error(`Invalid site status: ${siteStatus}`);
+ } else {
+ return siteStatus as components['parameters']['SiteStatus'];
+ }
+};