Add Sensor Hub functionality with components for managing sensors, including a service for API calls, a modal for selection, and a form for adding new sensors. Updated layout to integrate SensorHub component.

This commit is contained in:
2026-02-19 14:43:35 +03:30
parent a898eccbff
commit 48bf0921c7
10 changed files with 699 additions and 1 deletions
+3
View File
@@ -11,6 +11,7 @@ import HorizontalLayout from '@layouts/HorizontalLayout'
// Component Imports // Component Imports
import Providers from '@components/Providers' import Providers from '@components/Providers'
import SensorHub from '@components/SensorHub'
import Navigation from '@components/layout/vertical/Navigation' import Navigation from '@components/layout/vertical/Navigation'
import Header from '@components/layout/horizontal/Header' import Header from '@components/layout/horizontal/Header'
import Navbar from '@components/layout/vertical/Navbar' import Navbar from '@components/layout/vertical/Navbar'
@@ -31,6 +32,7 @@ const Layout = async (props: ChildrenType) => {
return ( return (
<Providers direction={direction}> <Providers direction={direction}>
<AuthGuard> <AuthGuard>
<SensorHub>
<LayoutWrapper <LayoutWrapper
systemMode={systemMode} systemMode={systemMode}
verticalLayout={ verticalLayout={
@@ -55,6 +57,7 @@ const Layout = async (props: ChildrenType) => {
<i className='tabler-arrow-up' /> <i className='tabler-arrow-up' />
</Button> </Button>
</ScrollToTop> </ScrollToTop>
</SensorHub>
</AuthGuard> </AuthGuard>
</Providers> </Providers>
) )
+20
View File
@@ -0,0 +1,20 @@
'use client'
// React Imports
import { useEffect, type ReactNode } from 'react'
// Hook Imports
import { useSensorHub } from '@/hooks/useSensorHub'
import { Box } from '@mui/material'
import SensorHubView from '@/views/sensorHub'
interface SensorHubProps {
children: ReactNode
}
export default function SensorHub({ children }: SensorHubProps) {
const { hasSensorHub } = useSensorHub()
return <> {children}
{!hasSensorHub && <SensorHubView />}</>
}
+71
View File
@@ -0,0 +1,71 @@
'use client'
// React Imports
import { useState, useEffect, useCallback } from 'react'
const SENSOR_HUB_STORAGE_KEY = 'sensor_hub'
export interface SensorHubInfo {
id: string
[key: string]: unknown
}
export interface UseSensorHubReturn {
/** Sensor hub data from localStorage */
sensorHub: SensorHubInfo | null
/** Whether sensor_hub exists in localStorage */
hasSensorHub: boolean
/** Save sensor hub to localStorage */
setSensorHub: (data: SensorHubInfo | null) => void
/** Get headers to attach to API requests (e.g. X-Sensor-Hub-Id) */
getSensorHubHeaders: () => Record<string, string>
}
const parseSensorHub = (raw: string | null): SensorHubInfo | null => {
if (!raw) return null
try {
const parsed = JSON.parse(raw)
if (parsed && typeof parsed === 'object' && typeof parsed.id === 'string') {
return parsed as SensorHubInfo
}
} catch {
// ignore invalid JSON
}
return null
}
export const useSensorHub = (): UseSensorHubReturn => {
const [sensorHub, setSensorHubState] = useState<SensorHubInfo | null>(null)
useEffect(() => {
if (typeof window === 'undefined') return
const stored = localStorage.getItem(SENSOR_HUB_STORAGE_KEY)
setSensorHubState(parseSensorHub(stored))
}, [])
const setSensorHub = useCallback((data: SensorHubInfo | null) => {
if (typeof window === 'undefined') return
if (data === null) {
localStorage.removeItem(SENSOR_HUB_STORAGE_KEY)
setSensorHubState(null)
} else {
localStorage.setItem(SENSOR_HUB_STORAGE_KEY, JSON.stringify(data))
setSensorHubState(data)
}
}, [])
const getSensorHubHeaders = useCallback((): Record<string, string> => {
const hub = sensorHub ?? (typeof window !== 'undefined' ? parseSensorHub(localStorage.getItem(SENSOR_HUB_STORAGE_KEY)) : null)
if (!hub?.id) return {}
return {
'X-Sensor-Hub-Id': hub.id
}
}, [sensorHub])
return {
sensorHub,
hasSensorHub: sensorHub !== null,
setSensorHub,
getSensorHubHeaders
}
}
+1
View File
@@ -14,4 +14,5 @@ export * from './services/kanbanService'
export * from './services/todoService' export * from './services/todoService'
export * from './services/userManagementService' export * from './services/userManagementService'
export * from './services/rolesPermissionsService' export * from './services/rolesPermissionsService'
export * from './services/sensorHubService'
+38
View File
@@ -0,0 +1,38 @@
/**
* Sensor Hub Service
* Handles sensor hub API calls
*/
import { apiClient } from '../client'
export interface Sensor {
name: string
uuid_sensor: string
last_updated: string
[key: string]: unknown
}
export interface ListSensorsResponse {
status?: string
data: Sensor | Sensor[]
}
export const sensorHubService = {
/**
* Get list of sensors
*/
async listSensors(): Promise<Sensor[]> {
const response = await apiClient.get<ListSensorsResponse>('/api/sensor-hub/')
const data = response?.data
if (Array.isArray(data)) {
return data
}
if (data && typeof data === 'object') {
return [data as Sensor]
}
return []
}
}
+75
View File
@@ -0,0 +1,75 @@
'use client'
// React Imports
import { useState } from 'react'
// MUI Imports
import Grid from '@mui/material/Grid2'
import Button from '@mui/material/Button'
import Typography from '@mui/material/Typography'
// Component Imports
import CustomTextField from '@core/components/mui/TextField'
type FormSensorHubProps = {
onBack: () => void
}
const FormSensorHub = ({ onBack }: FormSensorHubProps) => {
const [name, setName] = useState('')
const [uuidSensor, setUuidSensor] = useState('')
const handleSubmit = (e: React.FormEvent) => {
e.preventDefault()
// TODO: Call API to add sensor
}
return (
<div className='flex flex-col gap-4'>
<div className='flex items-center gap-2 mbe-2'>
<Button
variant='text'
color='secondary'
size='small'
startIcon={<i className='tabler-arrow-right text-xl' />}
onClick={onBack}
>
بازگشت
</Button>
{/* <Typography variant='h6'>افزودن سنسور جدید</Typography> */}
</div>
<form onSubmit={handleSubmit}>
<Grid container spacing={4}>
<Grid size={{ xs: 12 }}>
<CustomTextField
fullWidth
label='نام سنسور'
placeholder='نام سنسور را وارد کنید'
value={name}
onChange={e => setName(e.target.value)}
/>
</Grid>
<Grid size={{ xs: 12 }}>
<CustomTextField
fullWidth
label='شناسه سنسور (UUID)'
placeholder='شناسه سنسور را وارد کنید'
value={uuidSensor}
onChange={e => setUuidSensor(e.target.value)}
/>
</Grid>
<Grid size={{ xs: 12 }} className='flex gap-2'>
<Button variant='tonal' color='secondary' onClick={onBack}>
انصراف
</Button>
<Button variant='contained' type='submit' startIcon={<i className='tabler-plus' />}>
ذخیره سنسور
</Button>
</Grid>
</Grid>
</form>
</div>
)
}
export default FormSensorHub
+141
View File
@@ -0,0 +1,141 @@
'use client'
// React Imports
import { useState, useEffect } from 'react'
import type { ChangeEvent } from 'react'
// MUI Imports
import Grid from '@mui/material/Grid2'
import Button from '@mui/material/Button'
import CircularProgress from '@mui/material/CircularProgress'
// Third-party Imports
import DateObject from 'react-date-object'
// Type Imports
import type { CustomInputHorizontalData } from '@core/components/custom-inputs/types'
// API Imports
import { sensorHubService } from '@/libs/api'
import type { Sensor } from '@/libs/api/services/sensorHubService'
// Component Imports
import CustomInputHorizontal from '@core/components/custom-inputs/Horizontal'
type OptionSensorHubProps = {
onConfirm?: (sensor: Sensor) => void
}
const formatLastUpdated = (dateStr: string | null | undefined): string => {
if (!dateStr) return '—'
try {
const d = new DateObject(new Date(dateStr)).convert('persian').setLocale('fa')
return d.format('YYYY/MM/DD')
} catch {
return dateStr
}
}
const sensorToOption = (sensor: Sensor, isFirst: boolean): CustomInputHorizontalData => ({
title: sensor.name,
meta: formatLastUpdated(sensor.last_updated),
content: sensor.uuid_sensor,
value: sensor.uuid_sensor,
isSelected: isFirst
})
const OptionSensorHub = ({ onConfirm }: OptionSensorHubProps) => {
const [sensors, setSensors] = useState<Sensor[]>([])
const [data, setData] = useState<CustomInputHorizontalData[]>([])
const [loading, setLoading] = useState(true)
const [selectedOption, setSelectedOption] = useState<string>('')
useEffect(() => {
const fetchSensors = async () => {
try {
setLoading(true)
const sensorsList = await sensorHubService.listSensors()
setSensors(sensorsList)
const options = sensorsList.map((s, i) => sensorToOption(s, i === 0))
setData(options)
if (options.length > 0) {
const selected = options.find(o => o.isSelected) ?? options[0]
setSelectedOption(selected.value)
}
} catch {
setSensors([])
setData([])
} finally {
setLoading(false)
}
}
fetchSensors()
}, [])
const handleOptionChange = (prop: string | ChangeEvent<HTMLInputElement>) => {
if (typeof prop === 'string') {
setSelectedOption(prop)
} else {
setSelectedOption((prop.target as HTMLInputElement).value)
}
}
if (loading) {
return (
<div className='flex items-center justify-center py-12'>
<CircularProgress />
</div>
)
}
if (data.length === 0) {
return null
}
const handleConfirm = () => {
const selected = sensors.find(s => s.uuid_sensor === selectedOption)
if (selected && onConfirm) {
onConfirm(selected)
}
}
return (
<form onSubmit={e => e.preventDefault()}>
<Grid container>
{data.map(item => (
<CustomInputHorizontal
type='radio'
key={item.value}
data={item}
gridProps={{
size: { xs: 12 },
className:
'[&:first-of-type>*]:rounded-be-none [&:last-of-type>*]:rounded-bs-none [&:nth-of-type(2)>*]:rounded-none'
}}
selected={selectedOption}
name='sensor-hub-option'
handleChange={handleOptionChange}
/>
))}
</Grid>
{onConfirm && (
<div className='flex justify-end mts-4'>
<Button
variant='contained'
sx={{mt:4}}
color='primary'
onClick={handleConfirm}
startIcon={<i className='tabler-check text-xl' />}
>
تایید
</Button>
</div>
)}
</form>
)
}
export default OptionSensorHub
+133
View File
@@ -0,0 +1,133 @@
'use client'
// React Imports
import { useState, useEffect } from 'react'
// MUI Imports
import Card from '@mui/material/Card'
import CardHeader from '@mui/material/CardHeader'
import CircularProgress from '@mui/material/CircularProgress'
// Third-party Imports
import { createColumnHelper, flexRender, getCoreRowModel, useReactTable } from '@tanstack/react-table'
import DateObject from 'react-date-object'
// API Imports
import { sensorHubService } from '@/libs/api'
import type { Sensor } from '@/libs/api/services/sensorHubService'
// Style Imports
import styles from '@core/styles/table.module.css'
const formatToShamsi = (dateStr: string | null | undefined): string => {
if (!dateStr) return '—'
try {
const d = new DateObject(new Date(dateStr)).convert('persian').setLocale('fa')
return d.format('YYYY/MM/DD')
} catch {
return dateStr
}
}
// Column Definitions
const columnHelper = createColumnHelper<Sensor>()
const columns = [
columnHelper.accessor('name', {
cell: info => info.getValue(),
header: 'Name'
}),
columnHelper.accessor('last_updated', {
cell: info => formatToShamsi(info.getValue()),
header: 'Last Update'
}),
columnHelper.accessor('uuid_sensor', {
cell: info => info.getValue(),
header: 'UUID'
})
]
const SensorHubTable = () => {
const [data, setData] = useState<Sensor[]>([])
const [loading, setLoading] = useState(true)
const [error, setError] = useState<string | null>(null)
useEffect(() => {
const fetchSensors = async () => {
try {
setLoading(true)
setError(null)
const sensors = await sensorHubService.listSensors()
setData(sensors)
} catch (err) {
setError(err instanceof Error ? err.message : 'Failed to load sensors')
setData([])
} finally {
setLoading(false)
}
}
fetchSensors()
}, [])
const table = useReactTable({
data,
columns,
getCoreRowModel: getCoreRowModel(),
filterFns: {
fuzzy: () => false
}
})
if (loading) {
return (
<Card>
<CardHeader title='Sensor Hub' />
<div className='flex items-center justify-center p-12'>
<CircularProgress />
</div>
</Card>
)
}
if (error) {
return (
<Card>
<CardHeader title='Sensor Hub' subheader={error} />
</Card>
)
}
return (
<Card>
<CardHeader title='Sensor Hub' />
<div className='overflow-x-auto'>
<table className={styles.table}>
<thead>
{table.getHeaderGroups().map(headerGroup => (
<tr key={headerGroup.id}>
{headerGroup.headers.map(header => (
<th key={header.id}>
{header.isPlaceholder ? null : flexRender(header.column.columnDef.header, header.getContext())}
</th>
))}
</tr>
))}
</thead>
<tbody>
{table.getRowModel().rows.map(row => (
<tr key={row.id}>
{row.getVisibleCells().map(cell => (
<td key={cell.id}>{flexRender(cell.column.columnDef.cell, cell.getContext())}</td>
))}
</tr>
))}
</tbody>
</table>
</div>
</Card>
)
}
export default SensorHubTable
+194
View File
@@ -0,0 +1,194 @@
'use client'
// React Imports
import { useState } from 'react'
import type { Theme } from '@mui/material/styles'
// Hook Imports
import { useSensorHub } from '@/hooks/useSensorHub'
// API Imports
import type { Sensor } from '@/libs/api/services/sensorHubService'
import Dialog from '@mui/material/Dialog'
import DialogContent from '@mui/material/DialogContent'
import DialogTitle from '@mui/material/DialogTitle'
import Drawer from '@mui/material/Drawer'
import Button from '@mui/material/Button'
import IconButton from '@mui/material/IconButton'
import Typography from '@mui/material/Typography'
import useMediaQuery from '@mui/material/useMediaQuery'
import Fade from '@mui/material/Fade'
// Component Imports
import OptionSensorHub from './OptionSensorHub'
import FormSensorHub from './FormSensorHub'
type TableModalSheetProps = {
open: boolean
onClose: () => void
}
const backdropBlurSx = { backdropFilter: 'blur(4px)' }
const transitionTimeout = { enter: 300, exit: 200 }
const DialogContentWithTransition = ({
showAddForm,
onShowAddForm,
onBack,
onConfirm
}: {
showAddForm: boolean
onShowAddForm: () => void
onBack: () => void
onConfirm: (sensor: Sensor) => void
}) => (
<Fade key={showAddForm ? 'form' : 'options'} in timeout={transitionTimeout}>
<div>
{showAddForm ? (
<FormSensorHub onBack={onBack} />
) : (
<div className='flex flex-col gap-4'>
<div className='grid grid-cols-[1fr_auto] items-center gap-4'>
<div className='flex flex-col gap-0.5'>
<Typography variant='h6' fontWeight={600}>
انتخاب سنسور
</Typography>
<Typography variant='body2' color='text.secondary' sx={{ lineHeight: 1.5 }}>
سنسور مورد نظر را انتخاب کنید یا سنسور جدید اضافه کنید
</Typography>
</div>
<Button
variant='contained'
color='primary'
startIcon={<i className='tabler-plus text-xl' />}
onClick={onShowAddForm}
>
اضافه کردن سنسور
</Button>
</div>
<OptionSensorHub onConfirm={onConfirm} />
</div>
)}
</div>
</Fade>
)
const DrawerContentWithTransition = ({
showAddForm,
onShowAddForm,
onBack,
onConfirm
}: {
showAddForm: boolean
onShowAddForm: () => void
onBack: () => void
onConfirm: (sensor: Sensor) => void
}) => (
<Fade key={showAddForm ? 'form' : 'options'} in timeout={transitionTimeout}>
<div>
{showAddForm ? (
<FormSensorHub onBack={onBack} />
) : (
<div className='flex flex-col gap-4'>
<div className='grid grid-cols-[1fr_auto] items-center gap-4'>
<div className='flex flex-col gap-0.5'>
<Typography variant='h6' fontWeight={600}>
انتخاب سنسور
</Typography>
<Typography variant='body2' color='text.secondary' sx={{ lineHeight: 1.5 }}>
سنسور مورد نظر را انتخاب کنید یا سنسور جدید اضافه کنید
</Typography>
</div>
<Button
variant='contained'
color='primary'
startIcon={<i className='tabler-plus text-xl' />}
onClick={onShowAddForm}
>
اضافه کردن سنسور
</Button>
</div>
<OptionSensorHub onConfirm={onConfirm} />
</div>
)}
</div>
</Fade>
)
const TableModalSheet = ({ open, onClose }: TableModalSheetProps) => {
const [showAddForm, setShowAddForm] = useState(false)
const isMobile = useMediaQuery((theme: Theme) => theme.breakpoints.down('sm'))
const { setSensorHub } = useSensorHub()
const handleBack = () => setShowAddForm(false)
const handleConfirm = (sensor: Sensor) => {
setSensorHub({ id: sensor.uuid_sensor, ...sensor })
onClose()
}
if (isMobile) {
return (
<Drawer
anchor='bottom'
variant='temporary'
open={open}
onClose={onClose}
ModalProps={{ keepMounted: true }}
slotProps={{
backdrop: {
sx: backdropBlurSx
}
}}
PaperProps={{
sx: {
maxHeight: '80vh',
borderTopLeftRadius: 'var(--mui-shape-borderRadius)',
borderTopRightRadius: 'var(--mui-shape-borderRadius)'
}
}}
>
<div className='flex items-center justify-between plb-4 pli-6 border-bs'>
<Typography variant='h5'>Sensor Data</Typography>
<IconButton size='small' onClick={onClose} aria-label='close'>
<i className='tabler-x text-2xl' />
</IconButton>
</div>
<div className='overflow-y-auto pli-6 plb-6' style={{ maxHeight: 'calc(80vh - 72px)' }}>
<DrawerContentWithTransition
showAddForm={showAddForm}
onShowAddForm={() => setShowAddForm(true)}
onBack={handleBack}
onConfirm={handleConfirm}
/>
</div>
</Drawer>
)
}
return (
<Dialog
fullWidth
maxWidth='lg'
open={open}
onClose={onClose}
slotProps={{
backdrop: {
sx: backdropBlurSx
}
}}
>
<DialogContent>
<DialogContentWithTransition
showAddForm={showAddForm}
onShowAddForm={() => setShowAddForm(true)}
onBack={handleBack}
onConfirm={handleConfirm}
/>
</DialogContent>
</Dialog>
)
}
export default TableModalSheet
+22
View File
@@ -0,0 +1,22 @@
'use client'
// React Imports
import { useState } from 'react'
// MUI Imports
import Button from '@mui/material/Button'
// Component Imports
import TableModalSheet from './TableModalSheet'
const SensorHubView = () => {
const [open, setOpen] = useState(true)
return (
<>
<TableModalSheet open={open} onClose={() => setOpen(false)} />
</>
)
}
export default SensorHubView