Merge branch 'staging_environment' into hybrid_inventory_control

This commit is contained in:
csawatzky 2025-05-27 10:38:26 -06:00
commit d17be025cb
40 changed files with 2223 additions and 206 deletions

View file

@ -1117,6 +1117,7 @@ export default function Bins(props: Props) {
<MenuItem
onClick={() => {
setOpenQrGenerator(true);
setBinMenuAnchorEl(null)
}}>
<Typography>Generate QR Codes</Typography>
</MenuItem>

View file

@ -14,7 +14,6 @@ import { pond, quack } from "protobuf-ts/pond";
import { cloneDeep } from "lodash";
import DeviceOverview from "device/DeviceOverview";
import { DeviceAvailabilityMap, FindAvailablePositions, OffsetAvailabilityMap } from "pbHelpers/DeviceAvailability";
import AddComponentManualDialog from "component/AddComponentManualDialog";
import ComponentCard from "component/ComponentCard";
import { sameComponentID, sortComponents } from "pbHelpers/Component";
import { isController } from "pbHelpers/ComponentType";

View file

@ -23,6 +23,7 @@ import {
} from "hooks";
import InteractionChip from "interactions/InteractionChip";
import InteractionSettings from "interactions/InteractionSettings";
import { cloneDeep } from "lodash";
// import MaterialTable from "material-table";
import { Component, Device, deviceScope, Group, Interaction } from "models";
import { convertedUnitMeasurement, MeasurementsFor, UnitMeasurement } from "models/UnitMeasurement";
@ -450,7 +451,6 @@ export default function DeviceComponent() {
// });
})
.catch((err: any) => {
// resolve({
// data: [],
// page: 0,
@ -613,6 +613,32 @@ export default function DeviceComponent() {
pageSize={pageSize}
setPage={setPage}
handleRowsPerPageChange={handleRowsPerPageChange}
loadMore={() => {
let currentRows = cloneDeep(rows)
let newOffset = rows.length
componentAPI.listUnitMeasurements(
deviceID,
componentID,
startDate,
endDate,
pageSize,
newOffset,
order,
undefined,
getContextKeys(),
getContextTypes(),
showErrors,
as)
.then(resp => {
let newRows = UnitMeasurement.convertMeasurements(
resp.data.measurements.map(um => UnitMeasurement.any(um, user))
)
setRows(currentRows.concat(newRows))
}).catch(err => {
})
}}
/>
</Grid>
);

View file

@ -301,6 +301,9 @@ export default function DeviceSupport() {
<MenuItem value={pond.DevicePlatform.DEVICE_PLATFORM_V2_CELLULAR_BLUE}>
V2 Cellular Blue
</MenuItem>
<MenuItem value={pond.DevicePlatform.DEVICE_PLATFORM_V2_ETHERNET_BLUE}>
V2 Ethernet Blue
</MenuItem>
</Select>
</FormControl>
<Button color="primary" onClick={() => updateDevicePlatform(platform)}>

View file

@ -1,5 +1,5 @@
import { DeveloperBoard as ProvisionIcon, LibraryAdd as CreateGroupIcon } from "@mui/icons-material";
import { Box, Chip, Grid2, IconButton, Skeleton, Tab, Tabs, Theme, Tooltip, Typography, useTheme } from "@mui/material";
import { DeveloperBoard as ProvisionIcon, LibraryAdd as CreateGroupIcon, Tune } from "@mui/icons-material";
import { Box, Checkbox, Chip, Grid2, IconButton, ListItemIcon, ListItemText, Menu, MenuItem, Skeleton, Tab, Tabs, Theme, Tooltip, Typography, useTheme } from "@mui/material";
import { blue, green, orange } from "@mui/material/colors";
import { makeStyles } from "@mui/styles";
import ResponsiveTable, { Column } from "common/ResponsiveTable";
@ -24,6 +24,9 @@ import { Tag as TagUI } from "common/Tag";
import moment from "moment";
import StatusPlenum from "common/StatusPlenum";
import StatusSen5x from "common/StatusSen5x";
import StatusDust from "common/StatusDust";
import StatusGas from "common/StatusGas";
import { cloneDeep } from "lodash";
const useStyles = makeStyles((theme: Theme) => {
return ({
@ -57,14 +60,14 @@ const useStyles = makeStyles((theme: Theme) => {
margin: theme.spacing(1),
marginLeft: theme.spacing(2),
display: "flex",
width: theme.spacing(32)
// width: theme.spacing(26)
// justifyContent: "center",
},
tagsCellContainer: {
margin: theme.spacing(1),
marginLeft: theme.spacing(2),
display: "flex",
width: theme.spacing(24)
width: theme.spacing(18)
// justifyContent: "center",
},
green: {
@ -92,14 +95,40 @@ export default function Devices() {
const [devicesLoading, setDevicesLoading] = useState(false)
const [limit, setLimit] = useState(10);
const [page, setPage] = useState(0);
const [order, ] = useState<"asc" | "desc">("asc");
const [orderBy, ] = useState("name");
const [order, setOrder] = useState<"asc" | "desc">(() => {
// Load from sessionStorage on initial render
const savedOrder = sessionStorage.getItem('order');
return (savedOrder === "asc" || savedOrder === "desc") ? savedOrder : "asc";
});
// Save to sessionStorage whenever order changes
useEffect(() => {
sessionStorage.setItem('order', order);
}, [order]);
const [orderBy, setOrderBy] = useState(() => {
// Load from sessionStorage on initial render
const savedOrder = sessionStorage.getItem('orderBy');
return (savedOrder && savedOrder?.length > 0) ? savedOrder : "name";
});
// Save to sessionStorage whenever order changes
useEffect(() => {
sessionStorage.setItem('orderBy', orderBy);
}, [orderBy]);
// const [orderBy, setOrderBy] = useState("name");
const [search, setSearch] = useState("");
const [total, setTotal] = useState(0);
const [devices, setDevices] = useState<Device[]>([])
const [isProvisionDialogOpen, setIsProvisionDialogOpen] = useState<boolean>(false);
const [hasPlenums, setHasPlenums] = useState(false)
const [hasSen5x, setHasSen5x] = useState(false)
const [hasCo, setHasCo] = useState(false)
const [hasCo2, setHasCo2] = useState(false)
const [hasNo2, setHasNo2] = useState(false)
const [hasO2, setHasO2] = useState(false)
const [groupsLoading, setGroupsLoading] = useState(false)
const [groups, setGroups] = useState<Group[]>([]);
@ -122,6 +151,26 @@ export default function Devices() {
const [groupSettingsIsOpen, setGroupSettingsIsOpen] = useState<boolean>(false);
const [fieldContains, setFieldContains] = useState<Map<string, string>>(new Map<string, string>())
const [separateDust, setSeparateDust] = useState(() => {
const stored = localStorage.getItem('separateDust');
return stored !== null ? stored === 'true' : false;
});
useEffect(() => {
localStorage.setItem('separateDust', separateDust.toString());
}, [separateDust]);
const [groupGas, setGroupGas] = useState(() => {
const stored = localStorage.getItem('groupGas');
return stored !== null ? stored === 'true' : false;
});
useEffect(() => {
localStorage.setItem('groupGas', groupGas.toString());
}, [groupGas]);
const [preferencesAnchor, setPreferencesAnchor] = useState<any>(null)
const [{ as }] = useGlobalState()
@ -250,12 +299,12 @@ export default function Devices() {
).then(resp => {
let newDevices: Device[] = []
resp.data.devices.forEach(device => {
if (device.status?.plenum?.temperature) {
setHasPlenums(true)
}
if (device.status?.sen5x?.temperature) {
setHasSen5x(true)
}
if (device.status?.plenum?.temperature) setHasPlenums(true)
if (device.status?.sen5x?.temperature) setHasSen5x(true)
if (device.status?.o2) setHasO2(true)
if (device.status?.co) setHasCo(true)
if (device.status?.co2) setHasCo2(true)
if (device.status?.no2) setHasNo2(true)
newDevices.push(Device.create(device))
})
setDevices(newDevices)
@ -277,15 +326,40 @@ export default function Devices() {
setLimit(event.target.value);
};
const prependToUrl = (prependage: number | string) => {
const basePath = location.pathname.replace(/\/$/, "");
return(`/${prependage}/${basePath}`.replace(/\/{2,}/g, "/").replace(/(\/[^/]+)\/\1+/g, "$1"));
};
// const prependToUrl = (prependage: number | string) => {
// const basePath = location.pathname.replace(/\/$/, "");
// return(`/${prependage}/${basePath}`.replace(/\/{2,}/g, "/").replace(/(\/[^/]+)\/\1+/g, "$1"));
// };
function insertGroupContext(groupID: string, deviceID: string): string {
const path = location.pathname
const segments = path.split('/').filter(Boolean);
const deviceIndex = segments.findIndex(segment => segment === 'devices');
if (deviceIndex === -1) {
throw new Error('Path does not contain "devices"');
}
// Build the new segments
const newSegments = [
...segments.slice(0, deviceIndex),
'groups',
groupID,
...segments.slice(deviceIndex),
deviceID
];
return '/' + newSegments.join('/');
}
// useEffect(() => {
// console.log(devices)
// }, [devices])
const toDevice = (device: Device) => {
let url = prependToUrl(getGroup() ? "groups/" + getGroup().id() : "")
url = url + "/" + device.id()
navigate(url, { replace: true, state: {device: device} })
let url = getGroup() ? insertGroupContext(getGroup().id().toString(), device.id().toString()) : ""
if (url.length < 1) url = device.id().toString()
navigate(url, { state: {device: device} })
};
const handleTabChange = (_event: React.SyntheticEvent, newValue: string) => {
@ -294,9 +368,10 @@ export default function Devices() {
};
const columns = (): Column<Device>[] => {
let columns = [
let columns: Column<Device>[] = [
{
title: "State",
// sortKey: "state",
render: (device: Device) => {
const status = device.status ?? pond.DeviceStatus.create()
const deviceStateHelper = getDeviceStateHelper(status.state);
@ -309,12 +384,14 @@ export default function Devices() {
},
{
title: "ID",
sortKey: "deviceId",
render: (device: Device) => <span className={classes.cellContainer}>
{device.settings?.deviceId}
</span>
},
{
title: "Device",
sortKey: "name",
render: (device: Device) => {
return (
<Box className={classes.cellContainer}>
@ -331,12 +408,13 @@ export default function Devices() {
title: "Description",
render: (device: Device) => {
const description = device.settings?.description ?? ""
let size = (hasCo2 && !groupGas) ? 26 : 36
return (
<Box className={classes.descriptionCellContainer}>
<Tooltip title={device.settings?.description}>
<Box className={classes.descriptionCellContainer} width={size*6}>
<Tooltip title={description}>
<span>
{description.length > 36
? description.substring(0, 36) + "..."
{description.length > size
? description.substring(0, size) + "..."
: description}
</span>
</Tooltip>
@ -346,6 +424,7 @@ export default function Devices() {
},
{
title: "Tags",
sortKey: "tags",
render: (device: Device) => {
return (
<Grid2 container spacing={1} className={classes.tagsCellContainer}>
@ -362,6 +441,8 @@ export default function Devices() {
if (hasPlenums) {
columns.push({
title: "Plenum",
// sortKey: "hi",
// disableSort: true,
render: (device: Device) => {
if (device.status.plenum?.temperature) {
return (
@ -380,11 +461,101 @@ export default function Devices() {
if (hasSen5x) {
columns.push({
title: "Sen5X",
// sortKey: "hi",
// disableSort: true,
render: (device: Device) => {
if (device.status.sen5x?.temperature) {
return (
<Box sx={{ marginLeft: 1 }}>
<StatusSen5x device={device} />
<StatusSen5x device={device} noDust={separateDust} />
</Box>
)
} else {
return (
<></>
)
}
}
})
if (separateDust) columns.push({
title: "Dust",
render: (device: Device) => {
if (device.status.sen5x?.temperature) {
return (
<Box sx={{ marginLeft: 1 }}>
<StatusDust device={device} />
</Box>
)
} else {
return (
<></>
)
}
}
})
}
if (hasCo) {
columns.push({
title: groupGas ? "Gas" : "CO",
render: (device: Device) => {
if (device.status.co) {
return (
<Box sx={{ marginLeft: 1 }}>
<StatusGas device={device} gasType={groupGas ? "all" : "co"}/>
</Box>
)
} else {
return (
<></>
)
}
}
})
}
if (hasCo2 && !groupGas) {
columns.push({
title: "CO2",
render: (device: Device) => {
if (device.status.co2) {
return (
<Box sx={{ marginLeft: 1 }}>
<StatusGas device={device} gasType={"co2"}/>
</Box>
)
} else {
return (
<></>
)
}
}
})
}
if (hasNo2 && !groupGas) {
columns.push({
title: "NO2",
render: (device: Device) => {
if (device.status.no2) {
return (
<Box sx={{ marginLeft: 1 }}>
<StatusGas device={device} gasType={"no2"}/>
</Box>
)
} else {
return (
<></>
)
}
}
})
}
if (hasO2 && !groupGas) {
columns.push({
title: "O2",
render: (device: Device) => {
if (device.status.o2) {
return (
<Box sx={{ marginLeft: 1 }}>
<StatusGas device={device} gasType={"o2"}/>
</Box>
)
} else {
@ -487,6 +658,51 @@ export default function Devices() {
)
}
const preferencesButton = () => {
return (
<IconButton onClick={(event) => setPreferencesAnchor(event.currentTarget)}>
<Tune />
</IconButton>
)
}
const preferencesMenu = () => {
return (
<Menu
id="userMenu"
anchorEl={preferencesAnchor}
open={preferencesAnchor !== null}
onClose={() => setPreferencesAnchor(null)}
disableAutoFocusItem>
<MenuItem
onClick={() => setSeparateDust(!separateDust)}
sx={{ marginLeft: -0.75 }}
dense
>
<ListItemIcon>
<Checkbox
checked={!separateDust}
/>
</ListItemIcon>
<ListItemText primary={"Group Dust"} />
</MenuItem>
<MenuItem
onClick={() => setGroupGas(!groupGas)}
sx={{ marginLeft: -0.75 }}
dense
>
<ListItemIcon>
<Checkbox
checked={groupGas}
/>
</ListItemIcon>
<ListItemText primary={"Group Gas"} />
</MenuItem>
</Menu>
)
}
return(
<PageContainer padding={isMobile ? 0 : 2}>
<Box
@ -532,6 +748,46 @@ export default function Devices() {
setSearchText={setSearch}
isLoading={devicesLoading}
actions={getGroup() && <GroupActions removeCallback={removeGroupCallback} group={getGroup()} permissions={groupPermissions} devices={devices} refreshCallback={loadDevices}/>}
order={order}
setOrder={setOrder}
orderBy={orderBy}
setOrderBy={setOrderBy}
endTitleElement={preferencesButton}
loadMore={()=>{
let currentRows = cloneDeep(devices)
deviceAPI.list(
limit,
currentRows.length,
order,
orderBy,
undefined,
undefined,
undefined,
undefined,
undefined,
undefined,
undefined,
getKeys(),
getTypes(),
fieldContains,
search,
as
).then(resp => {
let newDevices: Device[] = []
resp.data.devices.forEach(device => {
if (device.status?.plenum?.temperature) setHasPlenums(true)
if (device.status?.sen5x?.temperature) setHasSen5x(true)
if (device.status?.o2) setHasO2(true)
if (device.status?.co) setHasCo(true)
if (device.status?.co2) setHasCo2(true)
if (device.status?.no2) setHasNo2(true)
newDevices.push(Device.create(device))
})
setDevices(currentRows.concat(newDevices))
}).catch(err => {
})
}}
/>
<ProvisionDevice
isOpen={isProvisionDialogOpen}
@ -550,6 +806,7 @@ export default function Devices() {
removeGroupCallback={removeGroupCallback}
addGroupCallback={addGroupCallback}
/>
{preferencesMenu()}
</PageContainer>
)
}

View file

@ -39,6 +39,7 @@ import { useGlobalState } from "providers";
import { useCallback, useEffect, useState } from "react";
import { downloadFile } from "utils/download";
import { or } from "utils/types";
import { cloneDeep } from "lodash";
const useStyles = makeStyles((theme: Theme) => {
const themeType = useThemeType()
@ -463,6 +464,22 @@ export default function Firmware() {
columns={columns(width === "xs" || width === "sm")}
setPage={(page)=>{setTablePage(page)}}
handleRowsPerPageChange={(e)=>{setPageSize(e.target.value)}}
loadMore={() => {
let current = cloneDeep(tableData)
firmwareAPI.listFirmware(pageSize, current.length, "desc", "version", searchText)
.then((response) => {
let newRows: FirmwareModel[] = or(response.data.firmware, []).map(
(firmwareData: any) => {
return FirmwareModel.create(pond.Firmware.fromObject(firmwareData));
}
);
setTableData(current.concat(newRows))
})
.catch(() => {
//failed to load
error("Failed to load more firmware versions")
});
}}
/>
</Box>
<UploadFirmware

View file

@ -14,6 +14,7 @@ import { appendToUrl } from "navigation/Router";
import { getDeviceStateHelper } from "pbHelpers/DeviceState";
import { makeStyles } from "@mui/styles";
import { blue, green } from "@mui/material/colors";
import { cloneDeep } from "lodash";
const useStyles = makeStyles((theme: Theme) => {
return ({
@ -241,6 +242,36 @@ export default function GroupPage() {
onRowClick={toDevice}
setSearchText={setSearch}
isLoading={loadingDevices}
loadMore={()=>{
let current = cloneDeep(devices)
setLoadingDevices(true)
deviceAPI.list(
limit,
current.length,
order,
orderBy,
search,
undefined,
undefined,
undefined,
undefined,
undefined,
undefined,
getKeys(),
getTypes(),
undefined,
undefined,
as
).then(resp => {
let newDevices: Device[] = [];
resp.data.devices.forEach(device => {
newDevices.push(Device.create(device))
})
setDevices(current.concat(newDevices))
}).finally(() => {
setLoadingDevices(false)
})
}}
/>
</PageContainer>
)

View file

@ -13,6 +13,7 @@ import { or } from "utils/types";
import PageContainer from "./PageContainer";
import GroupSettings from "group/GroupSettings";
import { green } from "@mui/material/colors";
import { cloneDeep } from "lodash";
const useStyles = makeStyles((theme: Theme) => {
const isMobile = useMobile()
@ -163,6 +164,15 @@ export default function GroupsPage() {
isLoading={loading}
setSearchText={setSearch}
onRowClick={handleRowClick}
loadMore={()=>{
let current = cloneDeep(groups)
setLoading(true)
groupAPI.listGroups(limit, current.length, order, orderBy, search).then(resp => {
setGroups(or(current.concat(resp.data.groups), current))
}).finally(() => {
setLoading(false)
})
}}
/>
<GroupSettings
// initialGroup={selectedGroup}

View file

@ -3,6 +3,7 @@ import DeviceLinkDrawer from "common/DeviceLinkDrawer";
import ResponsiveTable, { Column } from "common/ResponsiveTable";
//import { getTableIcons } from "common/ResponsiveTable";
import { useMobile } from "hooks";
import { cloneDeep } from "lodash";
//import MaterialTable from "material-table";
import { Device } from "models";
import { ObjectHeater } from "models/ObjectHeater";
@ -109,17 +110,19 @@ export default function Heaters() {
setPage={(page)=>{setTablePage(page)}}
handleRowsPerPageChange={handleChange}
onRowClick={goToHeater}
/>
)
};
const mobileList = () => {
return (
<List>
{heaters.map(h => (
<ObjectHeaterCard
key={h.heater?.key}
heaterData={h}
loadMore={()=>{
let current = cloneDeep(heaters)
objectHeaterAPI
.listObjectHeatersPageData(pageSize, current.length, undefined, undefined, searchText, as)
.then(resp => {
setHeaters(heaters.concat(resp.data.heaterData));
});
}}
renderMobile={(row)=>{
return (
<ObjectHeaterCard
key={row.heater?.key}
heaterData={row}
linkDevice={(heater, linkedDevices) => {
setHeaterToLink(heater);
setOpenLinkDrawer(true);
@ -131,9 +134,10 @@ export default function Heaters() {
setDevices(d);
}}
/>
))}
</List>
);
)
}}
/>
)
};
return (
@ -180,7 +184,7 @@ export default function Heaters() {
}}
pulse={heaters.length < 1}
/>
{isMobile ? mobileList() : desktopTable()}
{desktopTable()}
</PageContainer>
);
}

View file

@ -14,6 +14,7 @@ import ResponsiveTable, { Column } from "common/ResponsiveTable";
import { useNavigate } from "react-router-dom";
import { or } from "utils";
import { useGlobalState } from "providers";
import { cloneDeep } from "lodash";
interface MineRow {
mine: pond.Mine;
@ -110,6 +111,24 @@ export default function Mines() {
hideKeys
isLoading={isLoading}
onRowClick={onRowClick}
loadMore={()=>{
let current = cloneDeep(mineTableData)
setIsLoading(true);
mineAPI
.listMines(pageSize, current.length, "desc", undefined, searchValue, false, as)
.then(resp => {
let newMines: MineRow[] = [];
resp.data.mines.forEach(mine => {
newMines.push({
mine: mine
});
});
setMineTableData(current.concat(newMines));
})
.finally(() => {
setIsLoading(false);
});
}}
/>
<AddMine open={mineDialog} closeCallback={closeCallback} refreshCallback={load} />
</PageContainer>

View file

@ -1,4 +1,4 @@
import { Avatar, Box, Card, Grid2 as Grid, Switch, Tab, Tabs, Tooltip, Typography } from "@mui/material";
import { Avatar, Box, Button, Card, Grid2 as Grid, Switch, Tab, Tabs, Tooltip, Typography } from "@mui/material";
import { Group } from "@mui/icons-material";
//import MaterialTable from "material-table";
import { Site, User } from "models";
@ -12,6 +12,7 @@ import { useMobile } from "hooks";
import JobsiteIcon from "products/Construction/JobSiteIcon";
import ResponsiveTable, { Column } from "common/ResponsiveTable";
import { useNavigate } from "react-router-dom";
import { cloneDeep } from "lodash";
interface SiteWithData {
site: Site;
@ -46,7 +47,7 @@ export default function Sites() {
.listSitesPageData(pageSize, tablePage * pageSize, "asc", "siteName", undefined, as)
.then(resp => {
setTotal(resp.data.total);
buildTableData(resp.data.sites);
setSites(buildTableData(resp.data.sites));
})
.finally(() => {
setLoading(false);
@ -71,7 +72,7 @@ export default function Sites() {
data.push(newTableData);
}
});
setSites([...data]);
return data;
};
useEffect(() => {
@ -186,6 +187,19 @@ export default function Sites() {
rows={sites}
handleRowsPerPageChange={handleTablePage}
onRowClick={goToSite}
loadMore={()=>{
let current = cloneDeep(sites)
setLoading(true);
siteAPI
.listSitesPageData(pageSize, current.length, "asc", "siteName", undefined, as)
.then(resp => {
let newData = buildTableData(resp.data.sites);
setSites(current.concat(newData))
})
.finally(() => {
setLoading(false);
});
}}
/>
)
};
@ -208,6 +222,20 @@ export default function Sites() {
setCurrentTab(newValue);
};
const loadMore = () => {
let current = cloneDeep(sites)
setLoading(true);
siteAPI
.listSitesPageData(pageSize, current.length, "asc", "siteName", undefined, as)
.then(resp => {
let newData = buildTableData(resp.data.sites);
setSites(current.concat(newData))
})
.finally(() => {
setLoading(false);
});
}
const mobileView = () => {
return (
<React.Fragment>
@ -230,7 +258,7 @@ export default function Sites() {
siteCards.push(
<Card
key={site.site.key()}
style={{ margin: 10, padding: 5 }}
style={{ margin: 10, padding: 5 }}
onClick={() => {
goToSite(site);
}}>
@ -297,6 +325,9 @@ export default function Sites() {
return siteCards;
})}
</TabPanelMine>
{sites.length < total &&
<Button onClick={loadMore} variant="contained" color="primary" fullWidth>Load More</Button>
}
</React.Fragment>
);
};