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..4e19d43 --- /dev/null +++ b/src/admin/CreateEditSite.tsx @@ -0,0 +1,487 @@ +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, siteToNewSiteRequest } 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 [identity, setIdentity] = useState(''); + 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: siteToNewSiteRequest(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 = { + identity, + name, + latitude: parseFloat(latitude), + longitude: parseFloat(longitude), + status: status as SiteStatus, + address, + cell_ids: 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)); + setIdentity(siteData.identity); + setName(siteData.name); + setLatitude(siteData.latitude.toString()); + setLongitude(siteData.longitude.toString()); + setStatus(siteData.status); + setAddress(siteData.address); + setCells( + siteData.cell_id.map((cellId: string, index: number) => ({ + id: `${Date.now()}-cell-${index}`, + 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()}-boundary-${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..65a0b3c --- /dev/null +++ b/src/admin/ListSites.tsx @@ -0,0 +1,197 @@ +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 { + identity: site._id, + name: site.name, + latitude: site.latitude, + longitude: site.longitude, + status: site.status, + address: site.address, + cell_ids: site.cell_ids, + 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 = (siteIdentity: string) => { + console.log(`Edit site with identity: ${siteIdentity}`); + const site = sites.find(s => s.identity === siteIdentity); + if (site) { + const siteData = encodeURIComponent(JSON.stringify(site)); + window.open(`/admin/new-edit-site?site=${siteData}`, '_self'); + } + }; + + const handleDelete = (siteIdentity: string) => { + const site = sites.find(s => s.identity === siteIdentity); + 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: { + identity: site.identity, + }, + }) + .then(res => { + const { data, error } = res; + if (error) { + console.error(`Failed to delete site: ${error}`); + return; + } + console.log( + `Successfully deleted site: ${site.name} (identity: ${site.identity})`, + ); + 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/leaflet-component/site-marker.ts b/src/leaflet-component/site-marker.ts index b479b0d..f441482 100644 --- a/src/leaflet-component/site-marker.ts +++ b/src/leaflet-component/site-marker.ts @@ -18,6 +18,7 @@ export function isMarkerArray(marker: any[]): marker is Marker[] { export function isSite(prop: any): prop is Site { return ( + typeof prop?.identity === 'string' || typeof prop?.name === 'string' || typeof prop?.latitude === 'number' || typeof prop?.longitude === 'number' || diff --git a/src/types.d.ts b/src/types.d.ts index e213942..d8dd57d 100644 --- a/src/types.d.ts +++ b/src/types.d.ts @@ -1,7 +1,10 @@ +type LatLng = [number, number]; + type SiteOption = { label: string; value: string; status: SiteStatus; + identity: string; }; type DeviceOption = { @@ -22,7 +25,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; @@ -36,12 +45,13 @@ type UserRow = { }; type Site = { + identity: string; name: string; latitude: number; longitude: number; status: SiteStatus; address: string; - cell_id: string[]; + cell_ids?: string[]; color?: string; boundary?: LatLng[]; }; diff --git a/src/types/api.d.ts b/src/types/api.d.ts index 07f96f8..873eec3 100644 --- a/src/types/api.d.ts +++ b/src/types/api.d.ts @@ -618,7 +618,6 @@ export interface paths { * @description Parses CSV data and stores it as both signal and measurement records. * If a group is specified, any existing data with that group will be removed first. * The CSV should include columns for date, time, coordinate, cell_id, dbm, ping, download_speed, and upload_speed. - * */ post: { parameters: { @@ -698,7 +697,6 @@ export interface paths { * @description Returns two lists: * 1. Registered users sorted by issue date (newest first) * 2. Pending users whose issue date is within the expiry display limit, sorted by issue date (newest first) - * */ post: { parameters: { @@ -825,7 +823,6 @@ export interface paths { * Uses Passport LDAP strategy which binds to the LDAP server with the provided credentials. * On success, creates a session and redirects to /api/success. * On failure, redirects to /api/failure. - * */ post: { parameters: { @@ -937,7 +934,6 @@ export interface paths { * Update site configuration * @description Updates the sites configuration file with provided data. * Requires user to be authenticated - will redirect to login page if not authenticated. - * */ post: { parameters: { @@ -1016,7 +1012,6 @@ export interface paths { * @description Creates a new user with a cryptographically secure identity using EC keys. * Generates keypairs, creates signatures, and stores user information. * Requires authentication - will redirect to login page if not authenticated. - * */ post: { parameters: { @@ -1075,6 +1070,190 @@ 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']['NewSiteRequest']; + }; + }; + 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 using its unique identity + */ + delete: { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + requestBody: { + content: { + 'application/json': { + /** + * @description The unique identifier of the site to delete + * @example 123e4567-e89b-12d3-a456-426614174000 + */ + identity: string; + }; + }; + }; + 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 { @@ -1271,6 +1450,11 @@ export interface components { package_loss: number; }; Site: { + /** + * @description Unique identifier of the site + * @example 123e4567-e89b-12d3-a456-426614174000 + */ + identity: string; /** * @description Name of the site * @example Filipino Community Center @@ -1300,23 +1484,64 @@ export interface components { */ address: string; /** @description Array of cell identifiers associated with the site */ - cell_id: string[]; + cell_ids?: string[]; /** * @description Optional color identifier for the site in hex code * @example #FF5733 */ color?: string; /** @description Optional geographical boundary coordinates defining the site perimeter as [latitude, longitude] pairs */ - boundary?: [number, number][]; + boundaries?: [number, number][]; }; - /** @example { + NewSiteRequest: { + /** + * @description Name of the site + * @example Filipino Community Center + */ + name: string; + /** + * Format: double + * @description Geographic latitude coordinate + * @example 47.681932654395915 + */ + latitude: number; + /** + * Format: double + * @description Geographic longitude coordinate + * @example -122.31829217664796 + */ + longitude: number; + /** + * @description Current status of the site + * @example active + * @enum {string} + */ + status: NewSiteRequestStatus; + /** + * @description Physical address of the site + * @example 5740 Martin Luther King Jr Way S, Seattle, WA 98118 + */ + address: string; + /** @description Array of cell identifiers associated with the site */ + cell_ids?: string[]; + /** + * @description Optional color identifier for the site in hex code + * @example #FF5733 + */ + color?: string; + /** @description Optional geographical boundary coordinates defining the site perimeter as [latitude, longitude] pairs */ + boundaries?: [number, number][]; + }; + /** + * @example { * "Filipino Community Center": { * "ping": 115.28, * "download_speed": 7.16, * "upload_speed": 8.63, * "dbm": -78.4 * } - * } */ + * } + */ SitesSummary: { [key: string]: { /** @@ -1737,3 +1962,8 @@ export enum SiteStatus { confirmed = 'confirmed', in_conversation = 'in-conversation', } +export enum NewSiteRequestStatus { + active = 'active', + confirmed = 'confirmed', + in_conversation = 'in-conversation', +} diff --git a/src/utils/siteUtils.ts b/src/utils/siteUtils.ts new file mode 100644 index 0000000..758c250 --- /dev/null +++ b/src/utils/siteUtils.ts @@ -0,0 +1,50 @@ +import { components } from '@/types/api'; + +export const siteToSchema = (site: Site): components['schemas']['Site'] => { + return { + identity: site.identity, + name: site.name, + latitude: site.latitude, + longitude: site.longitude, + status: siteStatusToSchema(site.status), + address: site.address, + cell_ids: site.cell_ids, + color: site.color, + boundaries: site.boundary, + }; +}; + +export const siteToNewSiteRequest = ( + site: Site, +): components['schemas']['NewSiteRequest'] => { + return { + name: site.name, + latitude: site.latitude, + longitude: site.longitude, + status: siteStatusToNewSiteRequestSchema(site.status), + address: site.address, + cell_ids: site.cell_ids, + color: site.color, + boundaries: 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']; + } +}; + +export const siteStatusToNewSiteRequestSchema = ( + siteStatus: SiteStatus, +): components['schemas']['NewSiteRequest']['status'] => { + if (siteStatus === 'unknown') { + throw new Error(`Invalid site status: ${siteStatus}`); + } else { + return siteStatus as components['schemas']['NewSiteRequest']['status']; + } +}; diff --git a/src/vis/LineChart.tsx b/src/vis/LineChart.tsx index d1272f1..b68b42f 100644 --- a/src/vis/LineChart.tsx +++ b/src/vis/LineChart.tsx @@ -83,7 +83,7 @@ const LineChart = ({ }, [setXAxis, setYAxis, setLines, setYTitle, setLoading]); useEffect(() => { (async () => { - const _selectedSites = selectedSites.map(ss => ss.label); + const _selectedSites = selectedSites.map(ss => ss.value); if (selectedSites.length === 0) { return; } @@ -111,9 +111,9 @@ const LineChart = ({ if (!xAxis || !yAxis || !lines || !yTitle || !lineSummary) return; (async function () { setLoading(true); - let colors: { [name: string]: string } = {}; + let colors: { [identity: string]: string } = {}; for (let site of allSites) { - colors[site.name] = site.color ?? '#000000'; + colors[site.identity] = site.color ?? '#000000'; } const data: { site: string; diff --git a/src/vis/MeasurementMap.tsx b/src/vis/MeasurementMap.tsx index 456e9da..331e249 100644 --- a/src/vis/MeasurementMap.tsx +++ b/src/vis/MeasurementMap.tsx @@ -228,25 +228,31 @@ const MeasurementMap = ({ } const summary = sitesSummary[site.name]; if (!summary) { - console.warn(`Unknown site: ${site.name}`); + console.warn(`Unknown site: ${site.name} (identity: ${site.identity})`); continue; } - _markers.set(site.name, siteMarker(site, summary, map).addTo(slayer)); + _markers.set(site.identity, siteMarker(site, summary, map).addTo(slayer)); } - _markers.forEach((marker, site) => { - if (selectedSites.some(s => s.label === site)) { + _markers.forEach((marker, siteIdentity) => { + if (selectedSites.some(s => s.value === siteIdentity)) { marker.setOpacity(1); } else { marker.setOpacity(0.5); } - if (allSites.some(s => s.name === site && s.status === 'active')) { + if ( + allSites.some(s => s.identity === siteIdentity && s.status === 'active') + ) { marker.setIcon(greenIcon); } else if ( - allSites.some(s => s.name === site && s.status === 'confirmed') + allSites.some( + s => s.identity === siteIdentity && s.status === 'confirmed', + ) ) { marker.setIcon(goldIcon); } else if ( - allSites.some(s => s.name === site && s.status === 'in-conversation') + allSites.some( + s => s.identity === siteIdentity && s.status === 'in-conversation', + ) ) { marker.setIcon(redIcon); } @@ -264,7 +270,7 @@ const MeasurementMap = ({ const { data, error } = await apiClient.GET('/api/markers', { params: { query: { - sites: selectedSites.map(ss => ss.label).join(','), + sites: selectedSites.map(ss => ss.value).join(','), devices: selectedDevices.map(ss => ss.label).join(','), timeFrom: timeFrom.toISOString(), timeTo: timeTo.toISOString(), @@ -323,7 +329,7 @@ const MeasurementMap = ({ top: bounds.top, binSizeShift: BIN_SIZE_SHIFT, zoom: DEFAULT_ZOOM, - selectedSites: selectedSites.map(ss => ss.label).join(','), + selectedSites: selectedSites.map(ss => ss.value).join(','), mapType: mapType, timeFrom: timeFrom.toISOString(), timeTo: timeTo.toISOString(), diff --git a/src/vis/SiteSelect.tsx b/src/vis/SiteSelect.tsx index 75017e2..0fa6088 100644 --- a/src/vis/SiteSelect.tsx +++ b/src/vis/SiteSelect.tsx @@ -12,10 +12,11 @@ interface SidebarProps { } const SiteSelect = (props: SidebarProps) => { - const siteOptions = props.allSites.map(({ name, status }) => ({ + const siteOptions = props.allSites.map(({ identity, name, status }) => ({ label: name, - value: name, + value: identity, status: status, + identity: identity, })); return ( diff --git a/src/vis/Vis.tsx b/src/vis/Vis.tsx index c496878..cb95e8f 100644 --- a/src/vis/Vis.tsx +++ b/src/vis/Vis.tsx @@ -155,10 +155,11 @@ export default function Vis() { return; } - const siteOptions = data.map(({ name, status }) => ({ + const siteOptions = data.map(({ identity, name, status }) => ({ label: name, - value: name, + value: identity, status: status, + identity: identity, })); setSites(data); setSiteOptions(siteOptions);