Merge branch 'AEB-5/Linking-sensors-to-structural-elements_3D' into 'main'
Linked backend data to detectors' meshes. See merge request wedeving/aerbim-www!10
This commit is contained in:
@@ -109,6 +109,26 @@ const AlertsPage: React.FC = () => {
|
||||
}
|
||||
}
|
||||
|
||||
const handleAcknowledgeToggle = async (alertId: number) => {
|
||||
try {
|
||||
const res = await fetch(`/api/update-alert/${alertId}`, { method: 'PATCH' })
|
||||
const payload = await res.json().catch(() => null)
|
||||
console.log('[AlertsPage] PATCH /api/update-alert', { id: alertId, status: res.status, payload })
|
||||
if (!res.ok) {
|
||||
throw new Error(typeof payload?.error === 'string' ? payload.error : `Update failed (${res.status})`)
|
||||
}
|
||||
// Обновить алерты
|
||||
const params = new URLSearchParams()
|
||||
if (currentObject.id) params.set('objectId', currentObject.id)
|
||||
const listRes = await fetch(`/api/get-alerts?${params.toString()}`, { cache: 'no-store' })
|
||||
const listPayload = await listRes.json().catch(() => null)
|
||||
const data = Array.isArray(listPayload?.data) ? listPayload.data : (listPayload?.data?.alerts || [])
|
||||
setAlerts(data as AlertItem[])
|
||||
} catch (e) {
|
||||
console.error('Failed to update alert:', e)
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="flex h-screen bg-[#0e111a]">
|
||||
<Sidebar
|
||||
@@ -218,6 +238,12 @@ const AlertsPage: React.FC = () => {
|
||||
}`}>
|
||||
{item.acknowledged ? 'Да' : 'Нет'}
|
||||
</span>
|
||||
<button
|
||||
onClick={() => handleAcknowledgeToggle(item.id)}
|
||||
className="ml-2 inline-flex items-center px-2 py-1 rounded text-xs font-medium bg-[#2a2e3e] text-white hover:bg-[#353a4d]"
|
||||
>
|
||||
{item.acknowledged ? 'Снять' : 'Подтвердить'}
|
||||
</button>
|
||||
</td>
|
||||
<td className="py-4">
|
||||
<div className="text-sm text-gray-300">{new Date(item.timestamp).toLocaleString('ru-RU')}</div>
|
||||
|
||||
@@ -10,19 +10,21 @@ import DetectorMenu from '../../../components/navigation/DetectorMenu'
|
||||
import Notifications from '../../../components/notifications/Notifications'
|
||||
import NotificationDetectorInfo from '../../../components/notifications/NotificationDetectorInfo'
|
||||
import dynamic from 'next/dynamic'
|
||||
import type { ModelViewerProps } from '../../../components/model/ModelViewer'
|
||||
|
||||
const ModelViewer = dynamic(() => import('../../../components/model/ModelViewer'), {
|
||||
ssr: false,
|
||||
loading: () => (
|
||||
<div className="w-full h-full flex items-center justify-center bg-[#0e111a]">
|
||||
<div className="text-gray-300 animate-pulse">Загрузка 3D-модуля…</div>
|
||||
</div>
|
||||
),
|
||||
})
|
||||
const ModelViewer = dynamic<ModelViewerProps>(() => import('../../../components/model/ModelViewer'), {
|
||||
ssr: false,
|
||||
loading: () => (
|
||||
<div className="w-full h-full flex items-center justify-center bg-[#0e111a]">
|
||||
<div className="text-gray-300 animate-pulse">Загрузка 3D-модуля…</div>
|
||||
</div>
|
||||
),
|
||||
})
|
||||
|
||||
interface DetectorType {
|
||||
detector_id: number
|
||||
name: string
|
||||
serial_number: string
|
||||
object: string
|
||||
status: string
|
||||
checked: boolean
|
||||
@@ -122,6 +124,13 @@ const NavigationPage: React.FC = () => {
|
||||
}
|
||||
|
||||
const handleDetectorMenuClick = (detector: DetectorType) => {
|
||||
// Для тестов. Выбор детектора.
|
||||
console.log('[NavigationPage] Selected detector click:', {
|
||||
detector_id: detector.detector_id,
|
||||
name: detector.name,
|
||||
serial_number: detector.serial_number,
|
||||
})
|
||||
|
||||
if (selectedDetector?.detector_id === detector.detector_id && showDetectorMenu) {
|
||||
setShowDetectorMenu(false)
|
||||
setSelectedDetector(null)
|
||||
@@ -226,12 +235,7 @@ const NavigationPage: React.FC = () => {
|
||||
})()}
|
||||
|
||||
{showFloorNavigation && showDetectorMenu && selectedDetector && (
|
||||
<DetectorMenu
|
||||
detector={selectedDetector}
|
||||
isOpen={showDetectorMenu}
|
||||
onClose={closeDetectorMenu}
|
||||
getStatusText={getStatusText}
|
||||
/>
|
||||
null
|
||||
)}
|
||||
|
||||
<header className="bg-[#161824] border-b border-gray-700 px-6 py-4">
|
||||
@@ -263,6 +267,19 @@ const NavigationPage: React.FC = () => {
|
||||
modelPath='/static-models/AerBIM_Monitor_ASM_HT_Viewer_Expo2017Astana_Level_+1430_custom_prop.glb'
|
||||
onModelLoaded={handleModelLoaded}
|
||||
onError={handleModelError}
|
||||
focusSensorId={selectedDetector?.serial_number ?? null}
|
||||
renderOverlay={({ anchor }) => (
|
||||
selectedDetector && showDetectorMenu && anchor ? (
|
||||
<DetectorMenu
|
||||
detector={selectedDetector}
|
||||
isOpen={true}
|
||||
onClose={closeDetectorMenu}
|
||||
getStatusText={getStatusText}
|
||||
compact={true}
|
||||
anchor={anchor}
|
||||
/>
|
||||
) : null
|
||||
)}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
73
frontend/app/api/get-dashboard/route.ts
Normal file
73
frontend/app/api/get-dashboard/route.ts
Normal file
@@ -0,0 +1,73 @@
|
||||
import { NextResponse, NextRequest } from 'next/server'
|
||||
import { getServerSession } from 'next-auth'
|
||||
import { authOptions } from '@/lib/auth'
|
||||
import { getToken } from 'next-auth/jwt'
|
||||
|
||||
export async function GET(req: NextRequest) {
|
||||
try {
|
||||
const session = await getServerSession(authOptions)
|
||||
const authHeader = req.headers.get('authorization') || req.headers.get('Authorization')
|
||||
const bearer = authHeader && authHeader.toLowerCase().startsWith('bearer ') ? authHeader.slice(7) : undefined
|
||||
const secret = process.env.NEXTAUTH_SECRET
|
||||
const token = await getToken({ req, secret }).catch(() => null)
|
||||
|
||||
let accessToken = session?.accessToken || bearer || (token as any)?.accessToken
|
||||
const refreshToken = session?.refreshToken || (token as any)?.refreshToken
|
||||
|
||||
if (!accessToken && refreshToken) {
|
||||
try {
|
||||
const refreshRes = await fetch(`${process.env.BACKEND_URL}/auth/refresh/`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ refresh: refreshToken }),
|
||||
})
|
||||
if (refreshRes.ok) {
|
||||
const refreshed = await refreshRes.json()
|
||||
accessToken = refreshed.access
|
||||
}
|
||||
} catch {}
|
||||
}
|
||||
|
||||
if (!accessToken) {
|
||||
return NextResponse.json({ success: false, error: 'Unauthorized' }, { status: 401 })
|
||||
}
|
||||
|
||||
const backendUrl = process.env.BACKEND_URL
|
||||
if (!backendUrl) {
|
||||
return NextResponse.json({ success: false, error: 'BACKEND_URL is not configured' }, { status: 500 })
|
||||
}
|
||||
|
||||
const url = new URL(req.url)
|
||||
const timePeriod = url.searchParams.get('time_period')
|
||||
const qs = timePeriod ? `?time_period=${encodeURIComponent(timePeriod)}` : ''
|
||||
|
||||
const res = await fetch(`${backendUrl}/account/get-dashboard/${qs}`, {
|
||||
headers: {
|
||||
'Accept': 'application/json',
|
||||
'Content-Type': 'application/json',
|
||||
Authorization: `Bearer ${accessToken}`,
|
||||
},
|
||||
cache: 'no-store',
|
||||
})
|
||||
|
||||
const text = await res.text()
|
||||
let payload: any
|
||||
try { payload = JSON.parse(text) } catch { payload = text }
|
||||
|
||||
if (!res.ok) {
|
||||
const err = typeof payload === 'string' ? payload : JSON.stringify(payload)
|
||||
return NextResponse.json({ success: false, error: `Backend dashboard error: ${err}` }, { status: res.status })
|
||||
}
|
||||
|
||||
return NextResponse.json({ success: true, data: payload })
|
||||
} catch (error) {
|
||||
console.error('Error fetching dashboard data:', error)
|
||||
return NextResponse.json(
|
||||
{
|
||||
success: false,
|
||||
error: 'Failed to fetch dashboard data',
|
||||
},
|
||||
{ status: 500 }
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -66,6 +66,7 @@ export async function GET() {
|
||||
object: objectId,
|
||||
checked: sensor.checked ?? false,
|
||||
location: sensor.zone ?? '',
|
||||
serial_number: sensor.serial_number ?? sensor.name ?? '',
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
71
frontend/app/api/update-alert/[id]/route.ts
Normal file
71
frontend/app/api/update-alert/[id]/route.ts
Normal file
@@ -0,0 +1,71 @@
|
||||
import { NextResponse, NextRequest } from 'next/server'
|
||||
import { getServerSession } from 'next-auth'
|
||||
import { authOptions } from '@/lib/auth'
|
||||
import { getToken } from 'next-auth/jwt'
|
||||
|
||||
export async function PATCH(req: NextRequest, context: { params: Promise<{ id: string }> }) {
|
||||
try {
|
||||
const session = await getServerSession(authOptions)
|
||||
const authHeader = req.headers.get('authorization') || req.headers.get('Authorization')
|
||||
const bearer = authHeader && authHeader.toLowerCase().startsWith('bearer ') ? authHeader.slice(7) : undefined
|
||||
const secret = process.env.NEXTAUTH_SECRET
|
||||
const token = await getToken({ req, secret }).catch(() => null)
|
||||
|
||||
let accessToken = session?.accessToken || bearer || (token as any)?.accessToken
|
||||
const refreshToken = session?.refreshToken || (token as any)?.refreshToken
|
||||
|
||||
if (!accessToken && refreshToken) {
|
||||
try {
|
||||
const refreshRes = await fetch(`${process.env.BACKEND_URL}/auth/refresh/`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ refresh: refreshToken }),
|
||||
})
|
||||
if (refreshRes.ok) {
|
||||
const refreshed = await refreshRes.json()
|
||||
accessToken = refreshed.access
|
||||
}
|
||||
} catch {}
|
||||
}
|
||||
|
||||
if (!accessToken) {
|
||||
return NextResponse.json({ success: false, error: 'Unauthorized' }, { status: 401 })
|
||||
}
|
||||
|
||||
const backendUrl = process.env.BACKEND_URL
|
||||
if (!backendUrl) {
|
||||
return NextResponse.json({ success: false, error: 'BACKEND_URL is not configured' }, { status: 500 })
|
||||
}
|
||||
|
||||
const { id } = await context.params
|
||||
const res = await fetch(`${backendUrl}/account/update-alert/${id}/`, {
|
||||
method: 'PATCH',
|
||||
headers: {
|
||||
'Accept': 'application/json',
|
||||
'Content-Type': 'application/json',
|
||||
Authorization: `Bearer ${accessToken}`,
|
||||
},
|
||||
cache: 'no-store',
|
||||
})
|
||||
|
||||
const text = await res.text()
|
||||
let payload: any
|
||||
try { payload = JSON.parse(text) } catch { payload = text }
|
||||
|
||||
if (!res.ok) {
|
||||
const err = typeof payload === 'string' ? payload : JSON.stringify(payload)
|
||||
return NextResponse.json({ success: false, error: `Backend update-alert error: ${err}` }, { status: res.status })
|
||||
}
|
||||
|
||||
return NextResponse.json({ success: true, data: payload })
|
||||
} catch (error) {
|
||||
console.error('Error updating alert status:', error)
|
||||
return NextResponse.json(
|
||||
{
|
||||
success: false,
|
||||
error: 'Failed to update alert status',
|
||||
},
|
||||
{ status: 500 }
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -4,6 +4,7 @@ import { persist } from 'zustand/middleware'
|
||||
export interface DetectorType {
|
||||
detector_id: number
|
||||
name: string
|
||||
serial_number: string
|
||||
object: string
|
||||
status: string
|
||||
type: string
|
||||
|
||||
@@ -13,33 +13,51 @@ interface AreaChartProps {
|
||||
data?: ChartDataPoint[]
|
||||
}
|
||||
|
||||
const AreaChart: React.FC<AreaChartProps> = ({ className = '' }) => {
|
||||
const AreaChart: React.FC<AreaChartProps> = ({ className = '', data }) => {
|
||||
const width = 635
|
||||
const height = 200
|
||||
const paddingBottom = 20
|
||||
const baselineY = height - paddingBottom
|
||||
const maxPlotHeight = height - 40
|
||||
|
||||
const safeData = (Array.isArray(data) && data.length > 0)
|
||||
? data
|
||||
: [
|
||||
{ value: 5 },
|
||||
{ value: 3 },
|
||||
{ value: 7 },
|
||||
{ value: 2 },
|
||||
{ value: 6 },
|
||||
{ value: 4 },
|
||||
{ value: 8 }
|
||||
]
|
||||
|
||||
const maxVal = Math.max(...safeData.map(d => d.value || 0), 1)
|
||||
const stepX = safeData.length > 1 ? width / (safeData.length - 1) : width
|
||||
|
||||
const points = safeData.map((d, i) => {
|
||||
const x = i * stepX
|
||||
const y = baselineY - (Math.min(d.value || 0, maxVal) / maxVal) * maxPlotHeight
|
||||
return { x, y }
|
||||
})
|
||||
|
||||
const linePath = points.map((p, i) => `${i === 0 ? 'M' : 'L'}${p.x},${p.y}`).join(' ')
|
||||
const areaPath = `${linePath} L${width},${baselineY} L0,${baselineY} Z`
|
||||
|
||||
return (
|
||||
<div className={`w-full h-full ${className}`}>
|
||||
<svg className="w-full h-full" viewBox="0 0 635 200">
|
||||
<svg className="w-full h-full" viewBox={`0 0 ${width} ${height}`}>
|
||||
<defs>
|
||||
<linearGradient id="areaGradient" x1="0" y1="0" x2="0" y2="1">
|
||||
<stop offset="0%" stopColor="rgb(42, 157, 144)" stopOpacity="0.3" />
|
||||
<stop offset="100%" stopColor="rgb(42, 157, 144)" stopOpacity="0" />
|
||||
</linearGradient>
|
||||
</defs>
|
||||
<path
|
||||
d="M0,180 L100,120 L200,140 L300,80 L400,60 L500,100 L635,90 L635,200 L0,200 Z"
|
||||
fill="url(#areaGradient)"
|
||||
/>
|
||||
<path
|
||||
d="M0,180 L100,120 L200,140 L300,80 L400,60 L500,100 L635,90"
|
||||
stroke="rgb(42, 157, 144)"
|
||||
strokeWidth="2"
|
||||
fill="none"
|
||||
/>
|
||||
<circle cx="0" cy="180" r="3" fill="rgb(42, 157, 144)" />
|
||||
<circle cx="100" cy="120" r="3" fill="rgb(42, 157, 144)" />
|
||||
<circle cx="200" cy="140" r="3" fill="rgb(42, 157, 144)" />
|
||||
<circle cx="300" cy="80" r="3" fill="rgb(42, 157, 144)" />
|
||||
<circle cx="400" cy="60" r="3" fill="rgb(42, 157, 144)" />
|
||||
<circle cx="500" cy="100" r="3" fill="rgb(42, 157, 144)" />
|
||||
<circle cx="635" cy="90" r="3" fill="rgb(42, 157, 144)" />
|
||||
<path d={areaPath} fill="url(#areaGradient)" />
|
||||
<path d={linePath} stroke="rgb(42, 157, 144)" strokeWidth="2" fill="none" />
|
||||
{points.map((p, i) => (
|
||||
<circle key={i} cx={p.x} cy={p.y} r="3" fill="rgb(42, 157, 144)" />
|
||||
))}
|
||||
</svg>
|
||||
</div>
|
||||
)
|
||||
|
||||
@@ -13,8 +13,8 @@ interface BarChartProps {
|
||||
data?: ChartDataPoint[]
|
||||
}
|
||||
|
||||
const BarChart: React.FC<BarChartProps> = ({ className = '' }) => {
|
||||
const barData = [
|
||||
const BarChart: React.FC<BarChartProps> = ({ className = '', data }) => {
|
||||
const defaultData = [
|
||||
{ value: 80, color: 'rgb(42, 157, 144)' },
|
||||
{ value: 65, color: 'rgb(42, 157, 144)' },
|
||||
{ value: 90, color: 'rgb(42, 157, 144)' },
|
||||
@@ -29,6 +29,12 @@ const BarChart: React.FC<BarChartProps> = ({ className = '' }) => {
|
||||
{ value: 80, color: 'rgb(42, 157, 144)' }
|
||||
]
|
||||
|
||||
const barData = (Array.isArray(data) && data.length > 0)
|
||||
? data.map(d => ({ value: d.value, color: d.color || 'rgb(42, 157, 144)' }))
|
||||
: defaultData
|
||||
|
||||
const maxVal = Math.max(...barData.map(b => b.value || 0), 1)
|
||||
|
||||
return (
|
||||
<div className={`w-full h-full ${className}`}>
|
||||
<svg className="w-full h-full" viewBox="0 0 635 200">
|
||||
@@ -37,7 +43,7 @@ const BarChart: React.FC<BarChartProps> = ({ className = '' }) => {
|
||||
const barWidth = 40
|
||||
const barSpacing = 12
|
||||
const x = index * (barWidth + barSpacing) + 20
|
||||
const barHeight = (bar.value / 100) * 160
|
||||
const barHeight = (bar.value / maxVal) * 160
|
||||
const y = 180 - barHeight
|
||||
|
||||
return (
|
||||
|
||||
@@ -15,55 +15,39 @@ const Dashboard: React.FC = () => {
|
||||
const objectId = currentObject?.id
|
||||
const objectTitle = currentObject?.title
|
||||
|
||||
const [detectorsArray, setDetectorsArray] = useState<any[]>([])
|
||||
const [dashboardAlerts, setDashboardAlerts] = useState<any[]>([])
|
||||
const [chartData, setChartData] = useState<{ timestamp: string; value: number }[]>([])
|
||||
|
||||
useEffect(() => {
|
||||
const loadDetectors = async () => {
|
||||
const loadDashboard = async () => {
|
||||
try {
|
||||
const res = await fetch('/api/get-detectors', { cache: 'no-store' })
|
||||
const res = await fetch('/api/get-dashboard', { cache: 'no-store' })
|
||||
if (!res.ok) return
|
||||
const payload = await res.json()
|
||||
console.log('[Dashboard] GET /api/get-detectors', { status: res.status, payload })
|
||||
const detectorsData = payload?.data?.detectors ?? {}
|
||||
const arr = Object.values(detectorsData).filter(
|
||||
(detector: any) => (objectId ? detector.object === objectId : true)
|
||||
)
|
||||
setDetectorsArray(arr as any[])
|
||||
console.log('[Dashboard] GET /api/get-dashboard', { status: res.status, payload })
|
||||
const tableData = payload?.data?.table_data ?? []
|
||||
const arr = (Array.isArray(tableData) ? tableData : [])
|
||||
.filter((a: any) => (objectTitle ? a.object === objectTitle : true))
|
||||
setDashboardAlerts(arr as any[])
|
||||
|
||||
const cd = Array.isArray(payload?.data?.chart_data) ? payload.data.chart_data : []
|
||||
setChartData(cd as any[])
|
||||
} catch (e) {
|
||||
console.error('Failed to load detectors:', e)
|
||||
console.error('Failed to load dashboard:', e)
|
||||
}
|
||||
}
|
||||
loadDetectors()
|
||||
}, [objectId])
|
||||
loadDashboard()
|
||||
}, [objectTitle])
|
||||
|
||||
const handleBackClick = () => {
|
||||
router.push('/objects')
|
||||
}
|
||||
|
||||
interface DetectorData {
|
||||
detector_id: number
|
||||
name: string
|
||||
object: string
|
||||
status: string
|
||||
type: string
|
||||
location: string
|
||||
floor: number
|
||||
checked?: boolean
|
||||
notifications: Array<{
|
||||
id: number
|
||||
type: string
|
||||
message: string
|
||||
timestamp: string
|
||||
acknowledged: boolean
|
||||
priority: string
|
||||
}>
|
||||
}
|
||||
|
||||
// Статусы
|
||||
const statusCounts = detectorsArray.reduce((acc: { critical: number; warning: number; normal: number }, detector: DetectorData) => {
|
||||
if (detector.status === '#b3261e') acc.critical++
|
||||
else if (detector.status === '#fd7c22') acc.warning++
|
||||
else if (detector.status === '#00ff00') acc.normal++
|
||||
const statusCounts = dashboardAlerts.reduce((acc: { critical: number; warning: number; normal: number }, a: any) => {
|
||||
if (a.severity === 'critical') acc.critical++
|
||||
else if (a.severity === 'warning') acc.warning++
|
||||
else acc.normal++
|
||||
return acc
|
||||
}, { critical: 0, warning: 0, normal: 0 })
|
||||
|
||||
@@ -139,14 +123,14 @@ const Dashboard: React.FC = () => {
|
||||
title="Показатель"
|
||||
subtitle="За последние 6 месяцев"
|
||||
>
|
||||
<AreaChart />
|
||||
<AreaChart data={chartData} />
|
||||
</ChartCard>
|
||||
|
||||
<ChartCard
|
||||
title="Статистика"
|
||||
subtitle="Данные за период"
|
||||
>
|
||||
<BarChart />
|
||||
<BarChart data={chartData?.map((d: any) => ({ value: d.value }))} />
|
||||
</ChartCard>
|
||||
</div>
|
||||
</div>
|
||||
@@ -174,36 +158,26 @@ const Dashboard: React.FC = () => {
|
||||
<thead>
|
||||
<tr className="border-b border-gray-700">
|
||||
<th className="text-left text-white font-medium py-3">Детектор</th>
|
||||
<th className="text-left text-white font-medium py-3">Статус</th>
|
||||
<th className="text-left text-white font-medium py-3">Местоположение</th>
|
||||
<th className="text-left text-white font-medium py-3">Проверен</th>
|
||||
<th className="text-left text-white font-medium py-3">Сообщение</th>
|
||||
<th className="text-left text-white font-medium py-3">Серьезность</th>
|
||||
<th className="text-left text-white font-medium py-3">Дата</th>
|
||||
<th className="text-left text-white font-medium py-3">Решен</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{detectorsArray.map((detector: DetectorData) => (
|
||||
<tr key={detector.detector_id} className="border-b border-gray-800">
|
||||
<td className="py-3 text-white text-sm">{detector.name}</td>
|
||||
{dashboardAlerts.map((alert: any) => (
|
||||
<tr key={alert.id} className="border-b border-gray-800">
|
||||
<td className="py-3 text-white text-sm">{alert.name}</td>
|
||||
<td className="py-3 text-gray-300 text-sm">{alert.message}</td>
|
||||
<td className="py-3">
|
||||
<div className="flex items-center gap-2">
|
||||
<div
|
||||
className={`w-3 h-3 rounded-full`}
|
||||
style={{ backgroundColor: detector.status }}
|
||||
></div>
|
||||
<span className="text-sm text-gray-300">
|
||||
{detector.status === '#b3261e' ? 'Критическое' :
|
||||
detector.status === '#fd7c22' ? 'Предупреждение' : 'Норма'}
|
||||
</span>
|
||||
</div>
|
||||
<span className={`text-sm ${alert.severity === 'critical' ? 'text-red-500' : alert.severity === 'warning' ? 'text-orange-500' : 'text-green-500'}`}>
|
||||
{alert.severity === 'critical' ? 'Критическое' : alert.severity === 'warning' ? 'Предупреждение' : 'Норма'}
|
||||
</span>
|
||||
</td>
|
||||
<td className="py-3 text-gray-400 text-sm">{detector.location}</td>
|
||||
<td className="py-3 text-gray-400 text-sm">{new Date(alert.created_at).toLocaleString()}</td>
|
||||
<td className="py-3">
|
||||
{detector.checked ? (
|
||||
<div className="flex items-center gap-1">
|
||||
<svg className="w-4 h-4 text-green-500" fill="currentColor" viewBox="0 0 20 20">
|
||||
<path fillRule="evenodd" d="M16.707 5.293a1 1 0 010 1.414l-8 8a1 1 0 01-1.414 0l-4-4a1 1 0 011.414-1.414L8 12.586l7.293-7.293a1 1 0 011.414 0z" clipRule="evenodd" />
|
||||
</svg>
|
||||
<span className="text-sm text-green-500">Да</span>
|
||||
</div>
|
||||
{alert.resolved ? (
|
||||
<span className="text-sm text-green-500">Да</span>
|
||||
) : (
|
||||
<span className="text-sm text-gray-500">Нет</span>
|
||||
)}
|
||||
@@ -217,7 +191,7 @@ const Dashboard: React.FC = () => {
|
||||
{/* Статы */}
|
||||
<div className="mt-6 grid grid-cols-4 gap-4">
|
||||
<div className="text-center">
|
||||
<div className="text-2xl font-bold text-white">{detectorsArray.length}</div>
|
||||
<div className="text-2xl font-bold text-white">{dashboardAlerts.length}</div>
|
||||
<div className="text-sm text-gray-400">Всего</div>
|
||||
</div>
|
||||
<div className="text-center">
|
||||
|
||||
@@ -11,14 +11,22 @@ import {
|
||||
Color4,
|
||||
AbstractMesh,
|
||||
Nullable,
|
||||
ImportMeshAsync
|
||||
ImportMeshAsync,
|
||||
HighlightLayer,
|
||||
Mesh,
|
||||
InstancedMesh,
|
||||
Animation,
|
||||
CubicEase,
|
||||
EasingFunction,
|
||||
Matrix,
|
||||
Viewport
|
||||
} from '@babylonjs/core'
|
||||
import '@babylonjs/loaders'
|
||||
|
||||
import LoadingSpinner from '../ui/LoadingSpinner'
|
||||
|
||||
|
||||
interface ModelViewerProps {
|
||||
export interface ModelViewerProps {
|
||||
modelPath: string
|
||||
onModelLoaded?: (modelData: {
|
||||
meshes: AbstractMesh[]
|
||||
@@ -27,13 +35,17 @@ interface ModelViewerProps {
|
||||
max: { x: number; y: number; z: number }
|
||||
}
|
||||
}) => void
|
||||
onError?: (error: string) => void
|
||||
onError?: (error: string) => void
|
||||
focusSensorId?: string | null
|
||||
renderOverlay?: (params: { anchor: { left: number; top: number } | null; info?: { name?: string; sensorId?: string } | null }) => React.ReactNode
|
||||
}
|
||||
|
||||
const ModelViewer: React.FC<ModelViewerProps> = ({
|
||||
modelPath,
|
||||
onModelLoaded,
|
||||
onError
|
||||
onError,
|
||||
focusSensorId,
|
||||
renderOverlay,
|
||||
}) => {
|
||||
const canvasRef = useRef<HTMLCanvasElement>(null)
|
||||
const engineRef = useRef<Nullable<Engine>>(null)
|
||||
@@ -43,7 +55,13 @@ const ModelViewer: React.FC<ModelViewerProps> = ({
|
||||
const [showModel, setShowModel] = useState(false)
|
||||
const isInitializedRef = useRef(false)
|
||||
const isDisposedRef = useRef(false)
|
||||
|
||||
const importedMeshesRef = useRef<AbstractMesh[]>([])
|
||||
const highlightLayerRef = useRef<HighlightLayer | null>(null)
|
||||
const chosenMeshRef = useRef<AbstractMesh | null>(null)
|
||||
const [overlayPos, setOverlayPos] = useState<{ left: number; top: number } | null>(null)
|
||||
const [overlayData, setOverlayData] = useState<{ name?: string; sensorId?: string } | null>(null)
|
||||
const [modelReady, setModelReady] = useState(false)
|
||||
|
||||
|
||||
useEffect(() => {
|
||||
isDisposedRef.current = false
|
||||
@@ -94,6 +112,9 @@ const ModelViewer: React.FC<ModelViewerProps> = ({
|
||||
const fillLight = new HemisphericLight('fillLight', new Vector3(-1, 0.5, -1), scene)
|
||||
fillLight.intensity = 0.3
|
||||
fillLight.diffuse = new Color3(0.8, 0.8, 1)
|
||||
|
||||
const hl = new HighlightLayer('highlight-layer', scene)
|
||||
highlightLayerRef.current = hl
|
||||
|
||||
const handleResize = () => {
|
||||
if (!isDisposedRef.current) {
|
||||
@@ -108,6 +129,9 @@ const ModelViewer: React.FC<ModelViewerProps> = ({
|
||||
isDisposedRef.current = true
|
||||
isInitializedRef.current = false
|
||||
window.removeEventListener('resize', handleResize)
|
||||
|
||||
highlightLayerRef.current?.dispose()
|
||||
highlightLayerRef.current = null
|
||||
if (engineRef.current) {
|
||||
engineRef.current.dispose()
|
||||
engineRef.current = null
|
||||
@@ -147,37 +171,14 @@ const ModelViewer: React.FC<ModelViewerProps> = ({
|
||||
|
||||
try {
|
||||
const result = await ImportMeshAsync(modelPath, sceneRef.current)
|
||||
|
||||
importedMeshesRef.current = result.meshes
|
||||
|
||||
clearInterval(progressInterval)
|
||||
setLoadingProgress(100)
|
||||
|
||||
console.log('GLTF Model loaded successfully!')
|
||||
console.log('\n=== IfcSensor Meshes ===')
|
||||
const sensorMeshes = (result.meshes || []).filter(m => (m.id ?? '').includes('IfcSensor'))
|
||||
console.log('IfcSensor Mesh count:', sensorMeshes.length)
|
||||
sensorMeshes.forEach(m => {
|
||||
const meta: any = (m as any).metadata
|
||||
const extras = meta?.extras ?? meta?.gltf?.extras
|
||||
console.group(`IfcSensor Mesh: ${m.id || m.name}`)
|
||||
console.log('id:', m.id)
|
||||
console.log('name:', m.name)
|
||||
console.log('uniqueId:', m.uniqueId)
|
||||
console.log('class:', typeof (m as any).getClassName === 'function' ? (m as any).getClassName() : 'Mesh')
|
||||
console.log('material:', m.material?.name)
|
||||
console.log('parent:', m.parent?.name)
|
||||
console.log('metadata:', meta)
|
||||
if (extras) console.log('extras:', extras)
|
||||
const bi = m.getBoundingInfo?.()
|
||||
const bb = bi?.boundingBox
|
||||
if (bb) {
|
||||
console.log('bounding.center:', bb.center)
|
||||
console.log('bounding.extendSize:', bb.extendSize)
|
||||
}
|
||||
const verts = (m as any).getTotalVertices?.()
|
||||
if (typeof verts === 'number') console.log('vertices:', verts)
|
||||
console.groupEnd()
|
||||
})
|
||||
|
||||
console.log('[ModelViewer] ImportMeshAsync result:', result)
|
||||
if (result.meshes.length > 0) {
|
||||
|
||||
const boundingBox = result.meshes[0].getHierarchyBoundingVectors()
|
||||
@@ -187,7 +188,10 @@ const ModelViewer: React.FC<ModelViewerProps> = ({
|
||||
const camera = sceneRef.current!.activeCamera as ArcRotateCamera
|
||||
camera.radius = maxDimension * 2
|
||||
camera.target = result.meshes[0].position
|
||||
|
||||
|
||||
importedMeshesRef.current = result.meshes
|
||||
setModelReady(true)
|
||||
|
||||
onModelLoaded?.({
|
||||
meshes: result.meshes,
|
||||
boundingBox: {
|
||||
@@ -220,6 +224,113 @@ const ModelViewer: React.FC<ModelViewerProps> = ({
|
||||
requestIdleCallback(() => loadModel(), { timeout: 50 })
|
||||
}, [modelPath, onError, onModelLoaded])
|
||||
|
||||
useEffect(() => {
|
||||
if (!sceneRef.current || isDisposedRef.current || !modelReady) return
|
||||
|
||||
const sensorId = (focusSensorId ?? '').trim()
|
||||
if (!sensorId) {
|
||||
console.log('[ModelViewer] Focus cleared (no Sensor_ID provided)')
|
||||
|
||||
highlightLayerRef.current?.removeAllMeshes()
|
||||
chosenMeshRef.current = null
|
||||
setOverlayPos(null)
|
||||
setOverlayData(null)
|
||||
return
|
||||
}
|
||||
|
||||
const allMeshes = importedMeshesRef.current || []
|
||||
const sensorMeshes = allMeshes.filter((m: any) => ((m.id ?? '').includes('IfcSensor') || (m.name ?? '').includes('IfcSensor')))
|
||||
|
||||
const chosen = sensorMeshes.find((m: any) => {
|
||||
try {
|
||||
const meta: any = (m as any)?.metadata
|
||||
const extras: any = meta?.gltf?.extras ?? meta?.extras ?? (m as any)?.extras
|
||||
const sid = extras?.Sensor_ID ?? extras?.sensor_id ?? extras?.SERIAL_NUMBER ?? extras?.serial_number
|
||||
if (sid == null) return false
|
||||
return String(sid).trim() === sensorId
|
||||
} catch {
|
||||
return false
|
||||
}
|
||||
})
|
||||
|
||||
console.log('[ModelViewer] Sensor focus', {
|
||||
requested: sensorId,
|
||||
totalImportedMeshes: allMeshes.length,
|
||||
totalSensorMeshes: sensorMeshes.length,
|
||||
chosen: chosen ? { id: chosen.id, name: chosen.name, uniqueId: chosen.uniqueId, parent: chosen.parent?.name } : null,
|
||||
source: 'result.meshes',
|
||||
})
|
||||
|
||||
const scene = sceneRef.current!
|
||||
|
||||
if (chosen) {
|
||||
const camera = scene.activeCamera as ArcRotateCamera
|
||||
const bbox = (typeof chosen.getHierarchyBoundingVectors === 'function')
|
||||
? chosen.getHierarchyBoundingVectors()
|
||||
: { min: chosen.getBoundingInfo().boundingBox.minimumWorld, max: chosen.getBoundingInfo().boundingBox.maximumWorld }
|
||||
const center = bbox.min.add(bbox.max).scale(0.5)
|
||||
const size = bbox.max.subtract(bbox.min)
|
||||
const maxDimension = Math.max(size.x, size.y, size.z)
|
||||
const targetRadius = Math.max(camera.lowerRadiusLimit ?? 2, maxDimension * 1.5)
|
||||
|
||||
scene.stopAnimation(camera)
|
||||
|
||||
const ease = new CubicEase()
|
||||
ease.setEasingMode(EasingFunction.EASINGMODE_EASEINOUT)
|
||||
const frameRate = 60
|
||||
const durationMs = 600
|
||||
const totalFrames = Math.round((durationMs / 1000) * frameRate)
|
||||
|
||||
Animation.CreateAndStartAnimation('camTarget', camera, 'target', frameRate, totalFrames, camera.target.clone(), center.clone(), Animation.ANIMATIONLOOPMODE_CONSTANT, ease)
|
||||
Animation.CreateAndStartAnimation('camRadius', camera, 'radius', frameRate, totalFrames, camera.radius, targetRadius, Animation.ANIMATIONLOOPMODE_CONSTANT, ease)
|
||||
|
||||
const hl = highlightLayerRef.current
|
||||
if (hl) {
|
||||
hl.removeAllMeshes()
|
||||
if (chosen instanceof Mesh) {
|
||||
hl.addMesh(chosen, new Color3(1, 1, 0))
|
||||
} else if (chosen instanceof InstancedMesh) {
|
||||
hl.addMesh(chosen.sourceMesh, new Color3(1, 1, 0))
|
||||
} else {
|
||||
const children = typeof (chosen as any)?.getChildMeshes === 'function' ? (chosen as any).getChildMeshes() : []
|
||||
for (const cm of children) {
|
||||
if (cm instanceof Mesh) {
|
||||
hl.addMesh(cm, new Color3(1, 1, 0))
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
chosenMeshRef.current = chosen
|
||||
setOverlayData({ name: chosen.name, sensorId })
|
||||
} else {
|
||||
highlightLayerRef.current?.removeAllMeshes()
|
||||
chosenMeshRef.current = null
|
||||
setOverlayPos(null)
|
||||
setOverlayData(null)
|
||||
}
|
||||
}, [focusSensorId, modelReady])
|
||||
|
||||
useEffect(() => {
|
||||
const scene = sceneRef.current
|
||||
if (!scene || isDisposedRef.current) return
|
||||
const observer = scene.onAfterRenderObservable.add(() => {
|
||||
const chosen = chosenMeshRef.current
|
||||
if (!chosen) return
|
||||
const engine = scene.getEngine()
|
||||
const cam = scene.activeCamera
|
||||
if (!cam) return
|
||||
const center = chosen.getBoundingInfo().boundingBox.centerWorld
|
||||
const world = Matrix.IdentityReadOnly
|
||||
const transform = scene.getTransformMatrix()
|
||||
const viewport = new Viewport(0, 0, engine.getRenderWidth(), engine.getRenderHeight())
|
||||
const projected = Vector3.Project(center, world, transform, viewport)
|
||||
setOverlayPos({ left: projected.x, top: projected.y })
|
||||
})
|
||||
return () => {
|
||||
scene.onAfterRenderObservable.remove(observer)
|
||||
}
|
||||
}, [])
|
||||
|
||||
return (
|
||||
<div className="w-full h-screen relative bg-gray-900 overflow-hidden">
|
||||
<canvas
|
||||
@@ -237,6 +348,16 @@ const ModelViewer: React.FC<ModelViewerProps> = ({
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
{renderOverlay
|
||||
? renderOverlay({ anchor: overlayPos, info: overlayData })
|
||||
: (overlayData && overlayPos && (
|
||||
<div className="absolute z-40 pointer-events-none" style={{ left: overlayPos.left, top: overlayPos.top }}>
|
||||
<div className="rounded bg-black/70 text-white text-xs px-3 py-2 shadow-lg">
|
||||
<div className="font-semibold truncate max-w-[200px]">{overlayData.name || 'Sensor'}</div>
|
||||
{overlayData.sensorId && <div className="opacity-80">ID: {overlayData.sensorId}</div>}
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -5,6 +5,7 @@ import React from 'react'
|
||||
interface DetectorType {
|
||||
detector_id: number
|
||||
name: string
|
||||
serial_number: string
|
||||
object: string
|
||||
status: string
|
||||
checked: boolean
|
||||
@@ -18,36 +19,50 @@ interface DetectorMenuProps {
|
||||
isOpen: boolean
|
||||
onClose: () => void
|
||||
getStatusText: (status: string) => string
|
||||
compact?: boolean
|
||||
anchor?: { left: number; top: number } | null
|
||||
}
|
||||
|
||||
const DetectorMenu: React.FC<DetectorMenuProps> = ({ detector, isOpen, onClose, getStatusText }) => {
|
||||
const DetectorMenu: React.FC<DetectorMenuProps> = ({ detector, isOpen, onClose, getStatusText, compact = false, anchor = null }) => {
|
||||
if (!isOpen) return null
|
||||
|
||||
return (
|
||||
<div className="absolute left-[500px] top-0 bg-[#161824] border-r border-gray-700 z-30 w-[454px]" style={{height: 'calc(100% - 73px)', top: '73px'}}>
|
||||
<div className="h-full overflow-auto p-5">
|
||||
<div className="flex items-center justify-between mb-4">
|
||||
<h3 className="text-white text-lg font-medium">
|
||||
Датч.{detector.name}
|
||||
</h3>
|
||||
<div className="flex items-center gap-2">
|
||||
<button className="bg-[rgb(27,29,41)] hover:bg-[rgb(37,39,51)] text-white px-3 py-2 rounded-[10px] text-sm font-medium transition-colors flex items-center gap-2">
|
||||
<svg className="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M9 12h6m-6 4h6m2 5H7a2 2 0 01-2-2V5a2 2 0 012-2h5.586a1 1 0 01.707.293l5.414 5.414a1 1 0 01.293.707V19a2 2 0 01-2 2z" />
|
||||
</svg>
|
||||
Отчет
|
||||
</button>
|
||||
<button className="bg-[rgb(27,29,41)] hover:bg-[rgb(37,39,51)] text-white px-3 py-2 rounded-[10px] text-sm font-medium transition-colors flex items-center gap-2">
|
||||
<svg className="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M12 8v4l3 3m6-3a9 9 0 11-18 0 9 9 0 0118 0z" />
|
||||
</svg>
|
||||
История
|
||||
</button>
|
||||
const DetailsSection: React.FC<{ compact?: boolean }> = ({ compact = false }) => (
|
||||
<div className={compact ? 'mt-2 space-y-1' : 'space-y-0 border border-[rgb(30,31,36)] rounded-lg overflow-hidden'}>
|
||||
{compact ? (
|
||||
<>
|
||||
<div className="grid grid-cols-2 gap-2">
|
||||
<div>
|
||||
<div className="text-[rgb(113,113,122)] text-[11px]">Маркировка по проекту</div>
|
||||
<div className="text-white text-xs truncate">{detector.name}</div>
|
||||
</div>
|
||||
<div>
|
||||
<div className="text-[rgb(113,113,122)] text-[11px]">Тип детектора</div>
|
||||
<div className="text-white text-xs truncate">{detector.type}</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Таблица детекторов */}
|
||||
<div className="space-y-0 border border-[rgb(30,31,36)] rounded-lg overflow-hidden">
|
||||
<div className="grid grid-cols-2 gap-2">
|
||||
<div>
|
||||
<div className="text-[rgb(113,113,122)] text-[11px]">Местоположение</div>
|
||||
<div className="text-white text-xs truncate">{detector.location}</div>
|
||||
</div>
|
||||
<div>
|
||||
<div className="text-[rgb(113,113,122)] text-[11px]">Статус</div>
|
||||
<div className="text-white text-xs truncate">{getStatusText(detector.status)}</div>
|
||||
</div>
|
||||
</div>
|
||||
<div className="grid grid-cols-2 gap-2">
|
||||
<div>
|
||||
<div className="text-[rgb(113,113,122)] text-[11px]">Временная метка</div>
|
||||
<div className="text-white text-xs truncate">Сегодня, 14:30</div>
|
||||
</div>
|
||||
<div>
|
||||
<div className="text-[rgb(113,113,122)] text-[11px]">Этаж</div>
|
||||
<div className="text-white text-xs truncate">{detector.floor}</div>
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<div className="flex">
|
||||
<div className="flex-1 p-4 border-r border-[rgb(30,31,36)]">
|
||||
<div className="text-[rgb(113,113,122)] text-sm font-medium mb-1">Маркировка по проекту</div>
|
||||
@@ -77,8 +92,71 @@ const DetectorMenu: React.FC<DetectorMenuProps> = ({ detector, isOpen, onClose,
|
||||
<div className="text-white text-sm text-right">Вчера</div>
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
|
||||
if (compact && anchor) {
|
||||
return (
|
||||
<div className="absolute z-40" style={{ left: anchor.left, top: anchor.top }}>
|
||||
<div className="rounded-[10px] bg-black/80 text-white text-xs px-3 py-2 shadow-xl min-w-[240px] max-w-[300px]">
|
||||
<div className="flex items-start justify-between gap-3">
|
||||
<div className="flex-1">
|
||||
<div className="font-semibold truncate">Датч.{detector.name}</div>
|
||||
<div className="opacity-80">{getStatusText(detector.status)}</div>
|
||||
</div>
|
||||
<button onClick={onClose} className="text-gray-300 hover:text-white transition-colors">
|
||||
<svg className="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M6 18L18 6M6 6l12 12" />
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
<div className="mt-2 grid grid-cols-2 gap-2">
|
||||
<button className="bg-[rgb(27,29,41)] hover:bg-[rgb(37,39,51)] text-white px-2 py-1 rounded-[8px] text-xs font-medium transition-colors flex items-center gap-1">
|
||||
<svg className="w-3 h-3" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M9 12h6m-6 4h6m2 5H7a2 2 0 01-2-2V5a2 2 0 012-2h5.586a1 1 0 01.707.293l5.414 5.414a1 1 0 01.293.707V19a2 2 0 01-2 2z" />
|
||||
</svg>
|
||||
Отчет
|
||||
</button>
|
||||
<button className="bg-[rgb(27,29,41)] hover:bg-[rgb(37,39,51)] text-white px-2 py-1 rounded-[8px] text-xs font-medium transition-colors flex items-center gap-1">
|
||||
<svg className="w-3 h-3" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M12 8v4l3 3m6-3a9 9 0 11-18 0 9 9 0 0118 0z" />
|
||||
</svg>
|
||||
История
|
||||
</button>
|
||||
</div>
|
||||
<DetailsSection compact={true} />
|
||||
</div>
|
||||
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="absolute left-[500px] top-0 bg-[#161824] border-r border-gray-700 z-30 w-[454px]" style={{height: 'calc(100% - 73px)', top: '73px'}}>
|
||||
<div className="h-full overflow-auto p-5">
|
||||
<div className="flex items-center justify-between mb-4">
|
||||
<h3 className="text-white text-lg font-medium">
|
||||
Датч.{detector.name}
|
||||
</h3>
|
||||
<div className="flex items-center gap-2">
|
||||
<button className="bg-[rgb(27,29,41)] hover:bg-[rgb(37,39,51)] text-white px-3 py-2 rounded-[10px] text-sm font-medium transition-colors flex items-center gap-2">
|
||||
<svg className="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M9 12h6m-6 4h6m2 5H7a2 2 0 01-2-2V5a2 2 0 012-2h5.586a1 1 0 01.707.293l5.414 5.414a1 1 0 01.293.707V19a2 2 0 01-2 2z" />
|
||||
</svg>
|
||||
Отчет
|
||||
</button>
|
||||
<button className="bg-[rgb(27,29,41)] hover:bg-[rgb(37,39,51)] text-white px-3 py-2 rounded-[10px] text-sm font-medium transition-colors flex items-center gap-2">
|
||||
<svg className="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M12 8v4l3 3m6-3a9 9 0 11-18 0 9 9 0 0118 0z" />
|
||||
</svg>
|
||||
История
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<DetailsSection />
|
||||
|
||||
<button
|
||||
onClick={onClose}
|
||||
className="absolute top-4 right-4 text-gray-400 hover:text-white transition-colors"
|
||||
|
||||
@@ -16,6 +16,7 @@ interface FloorNavigationProps {
|
||||
interface DetectorType {
|
||||
detector_id: number
|
||||
name: string
|
||||
serial_number: string
|
||||
object: string
|
||||
status: string
|
||||
checked: boolean
|
||||
|
||||
@@ -20,6 +20,7 @@ interface NotificationType {
|
||||
interface DetectorType {
|
||||
detector_id: number
|
||||
name: string
|
||||
serial_number: string
|
||||
object: string
|
||||
status: string
|
||||
type: string
|
||||
|
||||
@@ -18,6 +18,7 @@ interface NotificationType {
|
||||
interface DetectorType {
|
||||
detector_id: number
|
||||
name: string
|
||||
serial_number: string
|
||||
object: string
|
||||
status: string
|
||||
type: string
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
export interface DetectorType {
|
||||
detector_id: number
|
||||
name: string
|
||||
serial_number: string
|
||||
object: string
|
||||
status: string
|
||||
checked: boolean
|
||||
|
||||
Reference in New Issue
Block a user