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"; import ProvisionDevice from "device/ProvisionDevice"; import { pond } from "protobuf-ts/pond"; import { useDeviceAPI, useGlobalState, useGroupAPI, useTeamAPI } from "providers"; import { useCallback, useEffect, useState } from "react"; import PageContainer from "./PageContainer"; import { useMobile } from "hooks"; import { useLocation, useNavigate, useParams } from "react-router-dom"; import { getContextKeys, getContextTypes } from "pbHelpers/Context"; import { getDeviceStateHelper } from "pbHelpers/DeviceState"; import { Device, Group, Tag } from "models"; import GroupSettings from "group/GroupSettings"; import SmartBreadcrumb from "common/SmartBreadcrumb"; import GroupActions from "group/GroupActions"; // import CircleGraphIcon from "common/CircleGraphIcon"; import DevicesSummary from "device/DevicesSummary"; import BindaptIcon from "products/Bindapt/BindaptIcon"; import { describePower } from "pbHelpers/Power"; 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"; import LoadingScreen from "app/LoadingScreen"; import TeamDialog from "teams/TeamDialog"; const useStyles = makeStyles((theme: Theme) => { return ({ buttonBox: { position: "absolute", zIndex: 1, right: theme.spacing(4), transform: "translateY(-50%)", // Move it up by half its height transition: "opacity 0.2s ease-in-out", opacity: 1, // top: 0, }, fadeBox: { opacity: 0, // Fully transparent by default transition: "opacity 0.2s ease-in-out", // Smooth transition "&:hover": { opacity: 1, // Fully opaque on hover }, }, provisionIcon: { color: blue["700"] }, cellContainer: { margin: theme.spacing(1), marginLeft: theme.spacing(2), display: "flex", width: theme.spacing(10) // justifyContent: "center", }, deviceContainer: { margin: theme.spacing(1), display: "flex", width: theme.spacing(10) }, descriptionCellContainer: { margin: theme.spacing(1), marginLeft: theme.spacing(2), display: "flex", // width: theme.spacing(26) // justifyContent: "center", }, tagsCellContainer: { margin: theme.spacing(1), marginLeft: theme.spacing(2), display: "flex", minWidth: theme.spacing(10) // justifyContent: "center", }, green: { color: green["600"] }, tab: { display: "block", overflow: "hidden", textOverflow: "ellipsis", maxWidth: theme.spacing(26), whiteSpace: "nowrap", padding: theme.spacing(1.5) } }); }); export default function Devices() { const theme = useTheme(); const isMobile = useMobile(); const classes = useStyles(); const navigate = useNavigate(); const location = useLocation(); const deviceAPI = useDeviceAPI(); const groupAPI = useGroupAPI(); const teamAPI = useTeamAPI(); const [devicesLoading, setDevicesLoading] = useState(false) const [limit, setLimit] = useState(10); const [page, setPage] = useState(0); const [doneLoading, setDoneLoading] = useState(false) const [teamsDialog, setTeamsDialog] = useState(false) const [{ user }] = useGlobalState() 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([]) const [isProvisionDialogOpen, setIsProvisionDialogOpen] = useState(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([]); const [groupLimit, ] = useState(10); const [groupPage, ] = useState(0); const [orderGroup, ] = useState<"asc" | "desc">("asc"); const [orderGroupBy, ] = useState("name"); const [searchGroup, ] = useState(""); // const [totalGroups, setTotalGroups] = useState(0); const [groupPermissions, setGroupPermissions] = useState([]) const [groupsLoaded, setGroupsLoaded] = useState(false) const groupID = useParams<{ groupID: string }>()?.groupID ?? "all"; // const [tab, setTab] = useState(groupID==="all" ? groupID : groupID); const [tab, setTab] = useState(() => { if (groupID!=="all") return groupID const stored = sessionStorage.getItem(location.pathname+"-groups-tab"); return stored !== null ? stored : "all"; }); const [selectedGroup, setSelectedGroup] = useState(undefined); const [groupSettingsMode, setGroupSettingsMode] = useState< "add" | "update" | "remove" | undefined >(undefined); const [groupSettingsIsOpen, setGroupSettingsIsOpen] = useState(false); const [fieldContains, setFieldContains] = useState>(new Map()) 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(null) const [{ as, team }] = useGlobalState() const updateGroups = (newGroups: Group[]) => { setGroups(newGroups); }; const getGroup = () => { return groups[parseInt(tab)] } useEffect(() => { setGroupPermissions([]) if (tab === "all" || !getGroup()) return groupAPI.getGroupPermissions(getGroup().id()).then(resp => { setGroupPermissions(resp.data.permissions) }) }, [tab]) const openProvisionDialog = () => { setIsProvisionDialogOpen(true); }; const closeProvisionDialog = () => { setIsProvisionDialogOpen(false); }; const removeGroupCallback = useCallback(() => { let newGroups = [...groups]; newGroups.splice(parseInt(tab), 1); setGroups(newGroups); setTab("all"); setGroupSettingsIsOpen(false); }, [groups, tab]); // Include dependencies const addGroupCallback = useCallback((groupSettings: pond.GroupSettings) => { let newGroups = [...groups]; let group = pond.Group.create({ settings: groupSettings }) newGroups.push(Group.create(group)) setGroups(newGroups); // setTab(groups.length.toString()) }, [groups, tab]); // Include dependencies const openGroupSettings = ( group: Group | undefined, mode: "add" | "update" | "remove" | undefined ) => { setGroupSettingsIsOpen(true); setGroupSettingsMode(mode); setSelectedGroup(group); }; const closeGroupSettings = (_event: any) => { setGroupSettingsIsOpen(false); setGroupSettingsMode(undefined); setSelectedGroup(undefined); }; const getKeys = () => { if (tab !== "all" && getGroup()) { let keys = getContextKeys() //keys.splice(keys.length - 1, 0, getGroup().id().toString()); keys.push(getGroup().id().toString()); return keys } else { return getContextKeys() } } const getTypes = () => { if (tab !== "all") { let types = getContextTypes() //types.splice(types.length - 1, 0, "group"); types.push("group"); return types } else { return getContextTypes() } } const loadGroups = () => { if (groupsLoading) return if (groups.length > 0) return setGroupsLoading(true) groupAPI.listGroups( groupLimit, groupPage*groupLimit, orderGroup, orderGroupBy, searchGroup, undefined, getKeys(), getTypes(), ).then(resp => { let newGroups: Group[] = [] resp.data.groups?.forEach((group: pond.Group) => { newGroups.push(Group.create(group)) }) updateGroups(newGroups) }).finally(() => { setGroupsLoading(false) setGroupsLoaded(true) }) } const loadDevices = () => { setDevicesLoading(true) if (!groupsLoaded) return deviceAPI.list( limit, page*limit, 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(newDevices) setTotal(resp.data.total) }).finally(() => { setDevicesLoading(false) setDoneLoading(true) }) } useEffect(() => { loadDevices() }, [limit, page, order, orderBy, fieldContains, search, tab, as, groupsLoaded]) useEffect(() => { loadGroups() }, [groupLimit, groupPage, orderGroup, orderGroupBy, searchGroup, as]) useEffect(() => { if (doneLoading && devices.length < 1 && groups.length < 1) { teamAPI.listTeams(1, 0).then(resp => { if (resp.data.teams.length > 0 && team.key().length < 1) { setTeamsDialog(true); } }) } }, [doneLoading, devices, groups]) const handleChange = (event: any) => { setLimit(event.target.value); }; // 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('/'); } const toDevice = (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) => { if (newValue !== "never") setTab(newValue); if (newValue === "never") openGroupSettings(undefined, "add"); }; const columns = (): Column[] => { let columns: Column[] = [ { title: "State", // cellStyle: {width: 100000}, // sortKey: "state", render: (device: Device) => { const status = device.status ?? pond.DeviceStatus.create() const deviceStateHelper = getDeviceStateHelper(status.state); return ( {/* */} {deviceStateHelper.icon} ) } }, { title: "ID", sortKey: "deviceId", render: (device: Device) => {device.settings?.deviceId} }, { title: "Device", cellStyle: { width: 160 }, sortKey: "name", render: (device: Device) => { return ( {/* // */} ) } }, { title: "Description", cellStyle: { width: 400 }, render: (device: Device) => { const description = device.settings?.description ?? "" let size = (hasCo2 && !groupGas) ? 26 : 36 return ( {description.length > size ? description.substring(0, size) + "..." : description} ) } }, { title: "Tags", sortKey: "tags", render: (device: Device) => { return ( {device.status.tags.map((tag: pond.TagSettings) => ( ))} ) } }, ] if (hasPlenums) { columns.push({ title: "Temp/Humidity", // sortKey: "hi", // disableSort: true, render: (device: Device) => { if (device.status.plenum?.temperature) { return ( ) } else { return ( <> ) } } }) } if (hasSen5x) { columns.push({ title: "Sen5X", // sortKey: "hi", // disableSort: true, render: (device: Device) => { if (device.status.sen5x?.temperature) { return ( ) } else { return ( <> ) } } }) if (separateDust) columns.push({ title: "Dust", render: (device: Device) => { if (device.status.sen5x?.temperature) { return ( ) } else { return ( <> ) } } }) } if (hasCo) { columns.push({ title: groupGas ? "Gas" : "CO", render: (device: Device) => { if (device.status.co) { return ( ) } else { return ( <> ) } } }) } if (hasCo2 && !groupGas) { columns.push({ title: "CO2", render: (device: Device) => { if (device.status.co2) { return ( ) } else { return ( <> ) } } }) } if (hasNo2 && !groupGas) { columns.push({ title: "NO2", render: (device: Device) => { if (device.status.no2) { return ( ) } else { return ( <> ) } } }) } if (hasO2 && !groupGas) { columns.push({ title: "O2", render: (device: Device) => { if (device.status.o2) { return ( ) } else { return ( <> ) } } }) } return columns } const provisionButton= () => { if (!user.allowedTo("provision")) return null return ( ) } const groupButton = () => { return ( openGroupSettings(undefined, "add")} aria-label="Create Group"> ) } const subtitle = () => { return ( <> {provisionButton()} {groupButton()} ) } const mobile = (row: Device) => { return ( {/* {GetDeviceProductIcon(row)} */} {/* {icon} */} {row.name()} Last Active: {moment(row.status.lastActive).fromNow()} {describePower(row.status.power).icon} ) } const gutter = (row: Device) => { return ( {row.settings.description} {row.status.plenum && {row.status.plenum.temperature}°C {", "} {row.status.plenum.humidity}% } ) } const preferencesButton = () => { return ( setPreferencesAnchor(event.currentTarget)}> ) } const preferencesMenu = () => { return ( setPreferencesAnchor(null)} disableAutoFocusItem> setSeparateDust(!separateDust)} sx={{ marginLeft: -0.75 }} dense > setGroupGas(!groupGas)} sx={{ marginLeft: -0.75 }} dense > ) } if (!groupsLoaded) return ( ) return( 0 ? tab : "all"} onChange={handleTabChange} variant="scrollable" scrollButtons="auto" sx={{ borderRadius: 1 }} > {groupsLoading ? }/> : groups.map((group, index) => { if (group.id()) return ( {group.name()}} key={"group-tab-"+index}/> ) })} } onClick={() => openGroupSettings(undefined, "add")} /> title={} subtitle={subtitle} stickyHeader rows={devices} renderGutter={ isMobile ? gutter : undefined } columns={columns} renderMobile={mobile} total={total} pageSize={limit} page={page} setPage={setPage} handleRowsPerPageChange={handleChange} onRowClick={toDevice} setSearchText={setSearch} isLoading={devicesLoading} actions={getGroup() && } order={order} setOrder={setOrder} orderBy={orderBy} setOrderBy={setOrderBy} endTitleElement={preferencesButton} resizeable 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 => { }) }} /> {/* This GroupSettings is used for adding */} {}} canEdit={true} groupDevices={devices} removeGroupCallback={removeGroupCallback} addGroupCallback={addGroupCallback} /> {preferencesMenu()} ) }