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']; + } +};