New api and zone management; highligh occlusion and highlighAll functionality; improved search in reports and alerts history + autofill; refactored alert panel
This commit is contained in:
@@ -5,13 +5,14 @@ import { useRouter, useSearchParams } from 'next/navigation'
|
||||
import Sidebar from '../../../components/ui/Sidebar'
|
||||
import useNavigationStore from '../../store/navigationStore'
|
||||
import DetectorList from '../../../components/alerts/DetectorList'
|
||||
import AlertsList from '../../../components/alerts/AlertsList'
|
||||
import ExportMenu from '../../../components/ui/ExportMenu'
|
||||
import { useSession } from 'next-auth/react'
|
||||
|
||||
const AlertsPage: React.FC = () => {
|
||||
const router = useRouter()
|
||||
const searchParams = useSearchParams()
|
||||
const { currentObject, setCurrentObject } = useNavigationStore()
|
||||
const { currentObject, setCurrentObject, selectedDetector } = useNavigationStore()
|
||||
const [selectedDetectors, setSelectedDetectors] = useState<number[]>([])
|
||||
const { data: session } = useSession()
|
||||
|
||||
@@ -22,10 +23,9 @@ const AlertsPage: React.FC = () => {
|
||||
timestamp: string
|
||||
acknowledged: boolean
|
||||
priority: string
|
||||
detector_id?: number
|
||||
detector_id?: string
|
||||
detector_name?: string
|
||||
location?: string
|
||||
object?: string
|
||||
}
|
||||
const [alerts, setAlerts] = useState<AlertItem[]>([])
|
||||
|
||||
@@ -40,6 +40,13 @@ const AlertsPage: React.FC = () => {
|
||||
}
|
||||
}, [urlObjectId, urlObjectTitle, currentObject.id, setCurrentObject])
|
||||
|
||||
// Auto-select detector when it comes from navigation store
|
||||
useEffect(() => {
|
||||
if (selectedDetector && !selectedDetectors.includes(selectedDetector.detector_id)) {
|
||||
setSelectedDetectors(prev => [...prev, selectedDetector.detector_id])
|
||||
}
|
||||
}, [selectedDetector, selectedDetectors])
|
||||
|
||||
useEffect(() => {
|
||||
const loadAlerts = async () => {
|
||||
try {
|
||||
@@ -55,7 +62,11 @@ const AlertsPage: React.FC = () => {
|
||||
})
|
||||
const data = Array.isArray(payload?.data) ? payload.data : (payload?.data?.alerts || [])
|
||||
console.log('[AlertsPage] parsed alerts:', data)
|
||||
setAlerts(data as AlertItem[])
|
||||
console.log('[AlertsPage] Sample alert structure:', data[0])
|
||||
console.log('[AlertsPage] Alerts with detector_id:', data.filter((alert: any) => alert.detector_id).length)
|
||||
|
||||
console.log('[AlertsPage] using transformed alerts:', data)
|
||||
setAlerts(data)
|
||||
} catch (e) {
|
||||
console.error('Failed to load alerts:', e)
|
||||
}
|
||||
@@ -177,87 +188,15 @@ const AlertsPage: React.FC = () => {
|
||||
objectId={objectId || undefined}
|
||||
selectedDetectors={selectedDetectors}
|
||||
onDetectorSelect={handleDetectorSelect}
|
||||
initialSearchTerm={selectedDetector?.detector_id.toString() || ''}
|
||||
/>
|
||||
|
||||
{/* История тревог */}
|
||||
<div className="mt-6 bg-[#161824] rounded-[20px] p-6">
|
||||
<div className="mb-4 flex items-center justify-between">
|
||||
<h2 className="text-xl font-semibold text-white">История тревог</h2>
|
||||
<span className="text-sm text-gray-400">Всего: {alerts.length}</span>
|
||||
</div>
|
||||
<div className="overflow-x-auto">
|
||||
<table className="w-full">
|
||||
<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>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{alerts.map((item) => (
|
||||
<tr key={item.id} className="border-b border-gray-700 hover:bg-gray-800/50 transition-colors">
|
||||
<td className="py-4">
|
||||
<div className="text-sm font-medium text-white">{item.detector_name || 'Детектор'}</div>
|
||||
{item.detector_id ? (
|
||||
<div className="text-sm text-gray-400">ID: {item.detector_id}</div>
|
||||
) : null}
|
||||
</td>
|
||||
<td className="py-4">
|
||||
<div className="flex items-center gap-2">
|
||||
<div
|
||||
className="w-3 h-3 rounded-full"
|
||||
style={{ backgroundColor: item.type === 'critical' ? '#b3261e' : item.type === 'warning' ? '#fd7c22' : '#00ff00' }}
|
||||
></div>
|
||||
<span className="text-sm text-gray-300">
|
||||
{item.type === 'critical' ? 'Критический' : item.type === 'warning' ? 'Предупреждение' : 'Информация'}
|
||||
</span>
|
||||
</div>
|
||||
</td>
|
||||
<td className="py-4">
|
||||
<div className="text-sm text-white">{item.message}</div>
|
||||
</td>
|
||||
<td className="py-4">
|
||||
<div className="text-sm text-white">{item.location || '-'}</div>
|
||||
</td>
|
||||
<td className="py-4">
|
||||
<span
|
||||
className="inline-flex items-center px-2.5 py-0.5 rounded-full text-xs font-medium text-white"
|
||||
style={{ backgroundColor: item.priority === 'high' ? '#b3261e' : item.priority === 'medium' ? '#fd7c22' : '#00ff00' }}
|
||||
>
|
||||
{item.priority === 'high' ? 'Высокий' : item.priority === 'medium' ? 'Средний' : 'Низкий'}
|
||||
</span>
|
||||
</td>
|
||||
<td className="py-4">
|
||||
<span className={`inline-flex items-center px-2.5 py-0.5 rounded-full text-xs font-medium ${
|
||||
item.acknowledged ? 'bg-green-600/20 text-green-300 ring-1 ring-green-600/40' : 'bg-red-600/20 text-red-300 ring-1 ring-red-600/40'
|
||||
}`}>
|
||||
{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>
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
{alerts.length === 0 && (
|
||||
<tr>
|
||||
<td colSpan={7} className="py-8 text-center text-gray-400">Записей не найдено</td>
|
||||
</tr>
|
||||
)}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
<div className="mt-8">
|
||||
<AlertsList
|
||||
alerts={alerts}
|
||||
onAcknowledgeToggle={handleAcknowledgeToggle}
|
||||
initialSearchTerm={selectedDetector?.detector_id.toString() || ''}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -107,6 +107,41 @@ const NavigationPage: React.FC = () => {
|
||||
const [detectorsError, setDetectorsError] = useState<string | null>(null)
|
||||
const [modelError, setModelError] = useState<string | null>(null)
|
||||
const [isModelReady, setIsModelReady] = useState(false)
|
||||
const [focusedSensorId, setFocusedSensorId] = useState<string | null>(null)
|
||||
const [highlightAllSensors, setHighlightAllSensors] = useState(false)
|
||||
|
||||
useEffect(() => {
|
||||
if (selectedDetector === null && selectedAlert === null) {
|
||||
setFocusedSensorId(null);
|
||||
}
|
||||
}, [selectedDetector, selectedAlert]);
|
||||
|
||||
// Управление выделением всех сенсоров при открытии/закрытии меню Sensors
|
||||
useEffect(() => {
|
||||
console.log('[NavigationPage] showSensors changed:', showSensors, 'modelReady:', isModelReady)
|
||||
if (showSensors && isModelReady) {
|
||||
// При открытии меню Sensors - выделяем все сенсоры (только если модель готова)
|
||||
console.log('[NavigationPage] Setting highlightAllSensors to TRUE')
|
||||
setHighlightAllSensors(true)
|
||||
setFocusedSensorId(null)
|
||||
} else if (!showSensors) {
|
||||
// При закрытии меню Sensors - сбрасываем выделение
|
||||
console.log('[NavigationPage] Setting highlightAllSensors to FALSE')
|
||||
setHighlightAllSensors(false)
|
||||
}
|
||||
}, [showSensors, isModelReady])
|
||||
|
||||
// Дополнительный эффект для задержки выделения сенсоров при открытии меню
|
||||
useEffect(() => {
|
||||
if (showSensors && isModelReady) {
|
||||
const timer = setTimeout(() => {
|
||||
console.log('[NavigationPage] Delayed highlightAllSensors to TRUE')
|
||||
setHighlightAllSensors(true)
|
||||
}, 500) // Задержка 500мс для полной инициализации модели
|
||||
|
||||
return () => clearTimeout(timer)
|
||||
}
|
||||
}, [showSensors, isModelReady])
|
||||
|
||||
const urlObjectId = searchParams.get('objectId')
|
||||
const urlObjectTitle = searchParams.get('objectTitle')
|
||||
@@ -133,10 +168,10 @@ const NavigationPage: React.FC = () => {
|
||||
}, [selectedModelPath]);
|
||||
|
||||
useEffect(() => {
|
||||
if (urlObjectId && urlObjectTitle && (!currentObject.id || currentObject.id !== urlObjectId)) {
|
||||
setCurrentObject(urlObjectId, urlObjectTitle)
|
||||
if (urlObjectId && (!currentObject.id || currentObject.id !== urlObjectId)) {
|
||||
setCurrentObject(urlObjectId, urlObjectTitle ?? currentObject.title ?? undefined)
|
||||
}
|
||||
}, [urlObjectId, urlObjectTitle, currentObject.id, setCurrentObject])
|
||||
}, [urlObjectId, urlObjectTitle, currentObject.id, currentObject.title, setCurrentObject])
|
||||
|
||||
useEffect(() => {
|
||||
const loadDetectors = async () => {
|
||||
@@ -177,18 +212,28 @@ const NavigationPage: React.FC = () => {
|
||||
return
|
||||
}
|
||||
|
||||
if (selectedDetector?.detector_id === detector.detector_id && showDetectorMenu) {
|
||||
setShowDetectorMenu(false)
|
||||
setSelectedDetector(null)
|
||||
if (selectedDetector?.serial_number === detector.serial_number && showDetectorMenu) {
|
||||
closeDetectorMenu()
|
||||
} else {
|
||||
setSelectedDetector(detector)
|
||||
setShowDetectorMenu(true)
|
||||
setFocusedSensorId(detector.serial_number)
|
||||
setShowAlertMenu(false)
|
||||
setSelectedAlert(null)
|
||||
// При открытии меню детектора - сбрасываем множественное выделение
|
||||
setHighlightAllSensors(false)
|
||||
}
|
||||
}
|
||||
|
||||
const closeDetectorMenu = () => {
|
||||
setShowDetectorMenu(false)
|
||||
setSelectedDetector(null)
|
||||
setFocusedSensorId(null)
|
||||
setSelectedAlert(null)
|
||||
// При закрытии меню детектора из Sensors - выделяем все сенсоры снова
|
||||
if (showSensors) {
|
||||
setHighlightAllSensors(true)
|
||||
}
|
||||
}
|
||||
|
||||
const handleNotificationClick = (notification: NotificationType) => {
|
||||
@@ -209,6 +254,12 @@ const NavigationPage: React.FC = () => {
|
||||
const closeAlertMenu = () => {
|
||||
setShowAlertMenu(false)
|
||||
setSelectedAlert(null)
|
||||
setFocusedSensorId(null)
|
||||
setSelectedDetector(null)
|
||||
// При закрытии меню алерта из Sensors - выделяем все сенсоры снова
|
||||
if (showSensors) {
|
||||
setHighlightAllSensors(true)
|
||||
}
|
||||
}
|
||||
|
||||
const handleAlertClick = (alert: AlertType) => {
|
||||
@@ -219,19 +270,86 @@ const NavigationPage: React.FC = () => {
|
||||
)
|
||||
|
||||
if (detector) {
|
||||
console.log('[NavigationPage] Found detector for alert:', detector)
|
||||
|
||||
setSelectedAlert(alert)
|
||||
setShowAlertMenu(true)
|
||||
|
||||
setSelectedDetector(detector)
|
||||
|
||||
console.log('[NavigationPage] Showing AlertMenu for alert:', alert.detector_name)
|
||||
if (selectedAlert?.id === alert.id && showAlertMenu) {
|
||||
closeAlertMenu()
|
||||
} else {
|
||||
setSelectedAlert(alert)
|
||||
setShowAlertMenu(true)
|
||||
setFocusedSensorId(detector.serial_number)
|
||||
setShowDetectorMenu(false)
|
||||
setSelectedDetector(null)
|
||||
// При открытии меню алерта - сбрасываем множественное выделение
|
||||
setHighlightAllSensors(false)
|
||||
console.log('[NavigationPage] Showing AlertMenu for alert:', alert.detector_name)
|
||||
}
|
||||
} else {
|
||||
console.warn('[NavigationPage] Could not find detector for alert:', alert.detector_id)
|
||||
}
|
||||
}
|
||||
|
||||
const handleSensorSelection = (serialNumber: string | null) => {
|
||||
if (serialNumber === null) {
|
||||
setFocusedSensorId(null);
|
||||
closeDetectorMenu();
|
||||
closeAlertMenu();
|
||||
// If we're in Sensors menu and no sensor is selected, highlight all sensors
|
||||
if (showSensors) {
|
||||
setHighlightAllSensors(true);
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
if (focusedSensorId === serialNumber) {
|
||||
setFocusedSensorId(null);
|
||||
closeDetectorMenu();
|
||||
closeAlertMenu();
|
||||
// If we're in Sensors menu and deselected the current sensor, highlight all sensors
|
||||
if (showSensors) {
|
||||
setHighlightAllSensors(true);
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
// При выборе конкретного сенсора - сбрасываем множественное выделение
|
||||
setHighlightAllSensors(false)
|
||||
|
||||
const detector = Object.values(detectorsData?.detectors || {}).find(
|
||||
(d) => d.serial_number === serialNumber
|
||||
);
|
||||
|
||||
if (detector) {
|
||||
if (showFloorNavigation || showListOfDetectors) {
|
||||
handleDetectorMenuClick(detector);
|
||||
} else if (detector.notifications && detector.notifications.length > 0) {
|
||||
const sortedNotifications = [...detector.notifications].sort((a, b) => {
|
||||
const priorityOrder: { [key: string]: number } = { critical: 0, warning: 1, info: 2 };
|
||||
return priorityOrder[a.priority.toLowerCase()] - priorityOrder[b.priority.toLowerCase()];
|
||||
});
|
||||
const notification = sortedNotifications[0];
|
||||
const alert: AlertType = {
|
||||
...notification,
|
||||
detector_id: detector.detector_id,
|
||||
detector_name: detector.name,
|
||||
location: detector.location,
|
||||
object: detector.object,
|
||||
status: detector.status,
|
||||
type: notification.type || 'info',
|
||||
};
|
||||
handleAlertClick(alert);
|
||||
} else {
|
||||
handleDetectorMenuClick(detector);
|
||||
}
|
||||
} else {
|
||||
setFocusedSensorId(null);
|
||||
closeDetectorMenu();
|
||||
closeAlertMenu();
|
||||
// If we're in Sensors menu and no valid detector found, highlight all sensors
|
||||
if (showSensors) {
|
||||
setHighlightAllSensors(true);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
const getStatusText = (status: string) => {
|
||||
const s = (status || '').toLowerCase()
|
||||
switch (s) {
|
||||
@@ -371,9 +489,9 @@ const NavigationPage: React.FC = () => {
|
||||
</button>
|
||||
<nav className="flex items-center gap-2 text-sm">
|
||||
<span className="text-gray-400">Дашборд</span>
|
||||
<span className="text-gray-600">/</span>
|
||||
<span className="text-gray-600">{'/'}</span>
|
||||
<span className="text-white">{objectTitle || 'Объект'}</span>
|
||||
<span className="text-gray-600">/</span>
|
||||
<span className="text-gray-600">{'/'}</span>
|
||||
<span className="text-white">Навигация</span>
|
||||
</nav>
|
||||
</div>
|
||||
@@ -414,7 +532,7 @@ const NavigationPage: React.FC = () => {
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<ModelViewer
|
||||
<ModelViewer
|
||||
key={selectedModelPath || 'no-model'}
|
||||
modelPath={selectedModelPath}
|
||||
onSelectModel={(path) => {
|
||||
@@ -425,7 +543,11 @@ const NavigationPage: React.FC = () => {
|
||||
}}
|
||||
onModelLoaded={handleModelLoaded}
|
||||
onError={handleModelError}
|
||||
focusSensorId={selectedDetector?.serial_number ?? selectedAlert?.detector_id?.toString() ?? null}
|
||||
activeMenu={showSensors ? 'sensors' : showFloorNavigation ? 'floor' : showListOfDetectors ? 'detectors' : null}
|
||||
focusSensorId={focusedSensorId}
|
||||
highlightAllSensors={highlightAllSensors}
|
||||
isSensorSelectionEnabled={showSensors || showFloorNavigation || showListOfDetectors}
|
||||
onSensorPick={handleSensorSelection}
|
||||
renderOverlay={({ anchor }) => (
|
||||
<>
|
||||
{selectedAlert && showAlertMenu && anchor ? (
|
||||
@@ -449,12 +571,12 @@ const NavigationPage: React.FC = () => {
|
||||
) : null}
|
||||
</>
|
||||
)}
|
||||
/>
|
||||
)}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
|
||||
@@ -10,11 +10,27 @@ import { useRouter } from 'next/navigation'
|
||||
const transformRawToObjectData = (raw: any): ObjectData => {
|
||||
const rawId = raw?.id ?? raw?.object_id ?? raw?.uuid ?? raw?.name
|
||||
const object_id = typeof rawId === 'number' ? `object_${rawId}` : String(rawId ?? '')
|
||||
|
||||
// Если объект имеет числовой идентификатор, возвращаем его в виде строки с префиксом 'object_'
|
||||
const deriveTitle = (): string => {
|
||||
const t = (raw?.title || '').toString().trim()
|
||||
if (t) return t
|
||||
const idStr = String(rawId ?? '').toString()
|
||||
const numMatch = typeof rawId === 'number'
|
||||
? rawId
|
||||
: (() => { const m = idStr.match(/\d+/); return m ? Number(m[0]) : undefined })()
|
||||
if (typeof numMatch === 'number' && !Number.isNaN(numMatch)) {
|
||||
return `Объект ${numMatch}`
|
||||
}
|
||||
// Если объект не имеет числовой идентификатор, возвращаем его строковый идентификатор
|
||||
return idStr ? `Объект ${idStr}` : `Объект ${object_id}`
|
||||
}
|
||||
|
||||
return {
|
||||
object_id,
|
||||
title: raw?.title ?? `Объект ${object_id}`,
|
||||
title: deriveTitle(),
|
||||
description: raw?.description ?? `Описание объекта ${raw?.title ?? object_id}`,
|
||||
image: raw?.image ?? '/images/test_image.png',
|
||||
image: raw?.image ?? null,
|
||||
location: raw?.location ?? raw?.address ?? 'Не указано',
|
||||
floors: Number(raw?.floors ?? 0),
|
||||
area: String(raw?.area ?? ''),
|
||||
|
||||
@@ -13,7 +13,7 @@ const ReportsPage: React.FC = () => {
|
||||
const router = useRouter()
|
||||
const searchParams = useSearchParams()
|
||||
const { data: session } = useSession()
|
||||
const { currentObject, setCurrentObject } = useNavigationStore()
|
||||
const { currentObject, setCurrentObject, selectedDetector } = useNavigationStore()
|
||||
const [selectedDetectors, setSelectedDetectors] = useState<number[]>([])
|
||||
const [detectorsData, setDetectorsData] = useState<any>({ detectors: {} })
|
||||
|
||||
@@ -28,6 +28,13 @@ const ReportsPage: React.FC = () => {
|
||||
}
|
||||
}, [urlObjectId, urlObjectTitle, currentObject.id, setCurrentObject])
|
||||
|
||||
// Автовыбор detector id из стора
|
||||
useEffect(() => {
|
||||
if (selectedDetector && !selectedDetectors.includes(selectedDetector.detector_id)) {
|
||||
setSelectedDetectors(prev => [...prev, selectedDetector.detector_id])
|
||||
}
|
||||
}, [selectedDetector, selectedDetectors])
|
||||
|
||||
useEffect(() => {
|
||||
const loadDetectors = async () => {
|
||||
try {
|
||||
@@ -139,15 +146,21 @@ const ReportsPage: React.FC = () => {
|
||||
|
||||
<ExportMenu onExport={handleExport} />
|
||||
</div>
|
||||
|
||||
{/* Selection table to choose detectors to include in the report */}
|
||||
|
||||
<div className="mb-6">
|
||||
<DetectorList objectId={objectId || undefined} selectedDetectors={selectedDetectors} onDetectorSelect={handleDetectorSelect} />
|
||||
<DetectorList
|
||||
objectId={objectId || undefined}
|
||||
selectedDetectors={selectedDetectors}
|
||||
onDetectorSelect={handleDetectorSelect}
|
||||
initialSearchTerm={selectedDetector?.detector_id.toString() || ''}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Existing notifications-based list */}
|
||||
|
||||
<div className="mt-6">
|
||||
<ReportsList detectorsData={detectorsData} />
|
||||
<ReportsList
|
||||
detectorsData={detectorsData}
|
||||
initialSearchTerm={selectedDetector?.detector_id.toString() || ''}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -95,6 +95,7 @@ export async function GET(req: NextRequest) {
|
||||
const priority = severity === 'critical' ? 'high' : severity === 'warning' ? 'medium' : 'low'
|
||||
return {
|
||||
id: a.id,
|
||||
detector_id: a.detector_id,
|
||||
detector_name: a.name || a.detector_name,
|
||||
message: a.message,
|
||||
type,
|
||||
|
||||
@@ -45,15 +45,22 @@ export async function POST(req: NextRequest) {
|
||||
})
|
||||
}
|
||||
|
||||
const body = await req.json().catch(() => ({ })) as { format?: 'csv' | 'pdf', hours?: number }
|
||||
const body = await req.json().catch(() => ({ })) as { format?: 'csv' | 'pdf', hours?: number, detector_ids?: number[] }
|
||||
const reportFormat = (body.format || '').toLowerCase()
|
||||
|
||||
const url = new URL(req.url)
|
||||
const qpFormat = (url.searchParams.get('format') || '').toLowerCase()
|
||||
const qpHoursRaw = url.searchParams.get('hours')
|
||||
const qpDetectorIds = url.searchParams.get('detector_ids')
|
||||
const qpHours = qpHoursRaw ? Number(qpHoursRaw) : undefined
|
||||
const finalFormat = reportFormat || qpFormat
|
||||
const finalHours = typeof body.hours === 'number' ? body.hours : (typeof qpHours === 'number' && !Number.isNaN(qpHours) ? qpHours : 168)
|
||||
const finalDetectorIds = body.detector_ids || (qpDetectorIds ? qpDetectorIds.split(',').map(id => Number(id)) : undefined)
|
||||
|
||||
const requestBody: any = { report_format: finalFormat, hours: finalHours }
|
||||
if (finalDetectorIds && finalDetectorIds.length > 0) {
|
||||
requestBody.detector_ids = finalDetectorIds
|
||||
}
|
||||
|
||||
let backendRes = await fetch(`${backendUrl}/account/get-reports/`, {
|
||||
method: 'POST',
|
||||
@@ -61,7 +68,7 @@ export async function POST(req: NextRequest) {
|
||||
'Content-Type': 'application/json',
|
||||
Authorization: `Bearer ${accessToken}`,
|
||||
},
|
||||
body: JSON.stringify({ report_format: finalFormat, hours: finalHours }),
|
||||
body: JSON.stringify(requestBody),
|
||||
})
|
||||
|
||||
if (!backendRes.ok && backendRes.status === 404) {
|
||||
|
||||
184
frontend/app/api/get-zones/route.ts
Normal file
184
frontend/app/api/get-zones/route.ts
Normal file
@@ -0,0 +1,184 @@
|
||||
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 = secret ? (await getToken({ req, secret }).catch(() => null)) : null
|
||||
|
||||
let accessToken: string | undefined = session?.accessToken || bearer || (token as any)?.accessToken
|
||||
const refreshToken: string | undefined = 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
|
||||
} else {
|
||||
const errorText = await refreshRes.text()
|
||||
let errorData: { error?: string; detail?: string; code?: string } = {}
|
||||
try {
|
||||
errorData = JSON.parse(errorText)
|
||||
} catch {
|
||||
errorData = { error: errorText }
|
||||
}
|
||||
|
||||
const errorMessage = (errorData.error as string) || (errorData.detail as string) || ''
|
||||
if (typeof errorMessage === 'string' &&
|
||||
(errorMessage.includes('Token is expired') ||
|
||||
errorMessage.includes('expired') ||
|
||||
errorData.code === 'token_not_valid')) {
|
||||
console.warn('Refresh token expired, user needs to re-authenticate')
|
||||
} else {
|
||||
console.error('Token refresh failed:', errorData.error || errorData.detail || 'Unknown error')
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Error during token refresh:', error)
|
||||
}
|
||||
}
|
||||
|
||||
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 { searchParams } = new URL(req.url)
|
||||
const objectId = searchParams.get('objectId')
|
||||
if (!objectId) {
|
||||
return NextResponse.json({ success: false, error: 'objectId query parameter is required' }, { status: 400 })
|
||||
}
|
||||
|
||||
// Нормализуем objectId с фронтенда вида "object_2" к числовому идентификатору бэкенда "2"
|
||||
const normalizedObjectId = /^object_(\d+)$/.test(objectId) ? objectId.replace(/^object_/, '') : objectId
|
||||
|
||||
const zonesRes = await fetch(`${backendUrl}/account/get-zones/?objectId=${encodeURIComponent(normalizedObjectId)}`, {
|
||||
headers: {
|
||||
'Accept': 'application/json',
|
||||
'Content-Type': 'application/json',
|
||||
Authorization: `Bearer ${accessToken}`,
|
||||
},
|
||||
cache: 'no-store',
|
||||
})
|
||||
|
||||
const payloadText = await zonesRes.text()
|
||||
let payload: any
|
||||
try { payload = JSON.parse(payloadText) } catch { payload = payloadText }
|
||||
|
||||
// Отладка: наблюдаем статус ответа и предполагаемую длину
|
||||
console.log(
|
||||
'[api/get-zones] objectId=%s normalized=%s status=%d payloadType=%s length=%d',
|
||||
objectId,
|
||||
normalizedObjectId,
|
||||
zonesRes.status,
|
||||
typeof payload,
|
||||
Array.isArray((payload as any)?.data)
|
||||
? (payload as any).data.length
|
||||
: Array.isArray(payload)
|
||||
? payload.length
|
||||
: 0
|
||||
)
|
||||
|
||||
if (!zonesRes.ok) {
|
||||
if (payload && typeof payload === 'object') {
|
||||
if (payload.code === 'token_not_valid' ||
|
||||
(payload.detail && typeof payload.detail === 'string' && (payload.detail.includes('Token is expired') || payload.detail.includes('Given token not valid'))) ||
|
||||
(payload.messages && Array.isArray(payload.messages) && payload.messages.some((msg: any) =>
|
||||
msg.message && typeof msg.message === 'string' && msg.message.includes('Token is expired')
|
||||
))) {
|
||||
console.warn('Access token expired, user needs to re-authenticate')
|
||||
return NextResponse.json({
|
||||
success: false,
|
||||
error: 'Authentication required - please log in again'
|
||||
}, { status: 401 })
|
||||
}
|
||||
}
|
||||
|
||||
// Резервный путь: пробуем получить список объектов и извлечь зоны для указанного objectId
|
||||
try {
|
||||
const objectsRes = await fetch(`${backendUrl}/account/get-objects/`, {
|
||||
headers: {
|
||||
'Accept': 'application/json',
|
||||
'Content-Type': 'application/json',
|
||||
Authorization: `Bearer ${accessToken}`,
|
||||
},
|
||||
cache: 'no-store',
|
||||
})
|
||||
|
||||
const objectsText = await objectsRes.text()
|
||||
let objectsPayload: any
|
||||
try { objectsPayload = JSON.parse(objectsText) } catch { objectsPayload = objectsText }
|
||||
|
||||
if (objectsRes.ok) {
|
||||
const objectsList: any[] = Array.isArray(objectsPayload?.data) ? objectsPayload.data
|
||||
: (Array.isArray(objectsPayload) ? objectsPayload : (Array.isArray(objectsPayload?.objects) ? objectsPayload.objects : []))
|
||||
|
||||
const target = objectsList.find((o: any) => (
|
||||
o?.id === normalizedObjectId || o?.object_id === normalizedObjectId || o?.slug === normalizedObjectId || o?.identifier === normalizedObjectId
|
||||
))
|
||||
|
||||
const rawZones: any[] = target?.zones || target?.zone_list || target?.areas || target?.Зоны || []
|
||||
const normalized = Array.isArray(rawZones) ? rawZones.map((z: any, idx: number) => ({
|
||||
id: z?.id ?? z?.zone_id ?? `${normalizedObjectId}_zone_${idx}`,
|
||||
name: z?.name ?? z?.zone_name ?? `Зона ${idx + 1}`,
|
||||
floor: typeof z?.floor === 'number' ? z.floor : (typeof z?.level === 'number' ? z.level : 0),
|
||||
image_path: z?.image_path ?? z?.image ?? z?.preview_image ?? null,
|
||||
model_path: z?.model_path ?? z?.model ?? z?.modelUrl ?? null,
|
||||
order: typeof z?.order === 'number' ? z.order : 0,
|
||||
sensors: Array.isArray(z?.sensors) ? z.sensors : (Array.isArray(z?.detectors) ? z.detectors : []),
|
||||
})) : []
|
||||
|
||||
// Отладка: длина массива в резервном пути
|
||||
console.log('[api/get-zones:fallback] normalized length=%d', normalized.length)
|
||||
|
||||
// Возвращаем успешный ответ с нормализованными зонами (может быть пустой массив)
|
||||
return NextResponse.json({ success: true, data: normalized }, { status: 200 })
|
||||
}
|
||||
} catch (fallbackErr) {
|
||||
console.warn('Fallback get-objects failed:', fallbackErr)
|
||||
}
|
||||
|
||||
// Если дошли до сюда, возвращаем успешный ответ с пустым списком, чтобы не ломать UI
|
||||
return NextResponse.json({ success: true, data: [] }, { status: 200 })
|
||||
}
|
||||
|
||||
// Распаковываем массив зон от бэкенда в плоский список в поле data
|
||||
const zonesData: any[] = Array.isArray((payload as any)?.data)
|
||||
? (payload as any).data
|
||||
: Array.isArray(payload)
|
||||
? (payload as any)
|
||||
: Array.isArray((payload as any)?.zones)
|
||||
? (payload as any).zones
|
||||
: []
|
||||
|
||||
return NextResponse.json({ success: true, data: zonesData }, { status: 200 })
|
||||
// Нормализация: при необходимости используем запасной image_path на стороне клиента
|
||||
return NextResponse.json({ success: true, data: payload })
|
||||
} catch (error) {
|
||||
console.error('Error fetching zones data:', error)
|
||||
return NextResponse.json(
|
||||
{
|
||||
success: false,
|
||||
error: 'Failed to fetch zones data',
|
||||
},
|
||||
{ status: 500 }
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -1,5 +1,6 @@
|
||||
import { create } from 'zustand'
|
||||
import { persist } from 'zustand/middleware'
|
||||
import type { Zone } from '@/app/types'
|
||||
|
||||
export interface DetectorType {
|
||||
detector_id: number
|
||||
@@ -55,29 +56,40 @@ export interface NavigationStore {
|
||||
navigationHistory: string[]
|
||||
currentSubmenu: string | null
|
||||
currentModelPath: string | null
|
||||
|
||||
|
||||
// Состояния Зон
|
||||
currentZones: Zone[]
|
||||
zonesCache: Record<string, Zone[]>
|
||||
zonesLoading: boolean
|
||||
zonesError: string | null
|
||||
|
||||
showMonitoring: boolean
|
||||
showFloorNavigation: boolean
|
||||
showNotifications: boolean
|
||||
showListOfDetectors: boolean
|
||||
showSensors: boolean
|
||||
|
||||
|
||||
selectedDetector: DetectorType | null
|
||||
showDetectorMenu: boolean
|
||||
selectedNotification: NotificationType | null
|
||||
showNotificationDetectorInfo: boolean
|
||||
selectedAlert: AlertType | null
|
||||
showAlertMenu: boolean
|
||||
|
||||
|
||||
setCurrentObject: (id: string | undefined, title: string | undefined) => void
|
||||
clearCurrentObject: () => void
|
||||
addToHistory: (path: string) => void
|
||||
goBack: () => string | null
|
||||
setCurrentModelPath: (path: string) => void
|
||||
|
||||
|
||||
setCurrentSubmenu: (submenu: string | null) => void
|
||||
clearSubmenu: () => void
|
||||
|
||||
|
||||
// Действия с зонами
|
||||
loadZones: (objectId: string) => Promise<void>
|
||||
setZones: (zones: Zone[]) => void
|
||||
clearZones: () => void
|
||||
|
||||
openMonitoring: () => void
|
||||
closeMonitoring: () => void
|
||||
openFloorNavigation: () => void
|
||||
@@ -88,20 +100,20 @@ export interface NavigationStore {
|
||||
closeListOfDetectors: () => void
|
||||
openSensors: () => void
|
||||
closeSensors: () => void
|
||||
|
||||
|
||||
closeAllMenus: () => void
|
||||
|
||||
clearSelections: () => void
|
||||
|
||||
setSelectedDetector: (detector: DetectorType | null) => void
|
||||
setShowDetectorMenu: (show: boolean) => void
|
||||
setSelectedNotification: (notification: NotificationType | null) => void
|
||||
setShowNotificationDetectorInfo: (show: boolean) => void
|
||||
setSelectedAlert: (alert: AlertType | null) => void
|
||||
setShowAlertMenu: (show: boolean) => void
|
||||
|
||||
|
||||
isOnNavigationPage: () => boolean
|
||||
getCurrentRoute: () => string | null
|
||||
getActiveSidebarItem: () => number
|
||||
PREFERRED_MODEL: string
|
||||
}
|
||||
|
||||
const useNavigationStore = create<NavigationStore>()(
|
||||
@@ -114,13 +126,18 @@ const useNavigationStore = create<NavigationStore>()(
|
||||
navigationHistory: [],
|
||||
currentSubmenu: null,
|
||||
currentModelPath: null,
|
||||
|
||||
|
||||
currentZones: [],
|
||||
zonesCache: {},
|
||||
zonesLoading: false,
|
||||
zonesError: null,
|
||||
|
||||
showMonitoring: false,
|
||||
showFloorNavigation: false,
|
||||
showNotifications: false,
|
||||
showListOfDetectors: false,
|
||||
showSensors: false,
|
||||
|
||||
|
||||
selectedDetector: null,
|
||||
showDetectorMenu: false,
|
||||
selectedNotification: null,
|
||||
@@ -128,8 +145,6 @@ const useNavigationStore = create<NavigationStore>()(
|
||||
selectedAlert: null,
|
||||
showAlertMenu: false,
|
||||
|
||||
PREFERRED_MODEL: 'AerBIM-Monitor_ASM-HT-Viewer_Expo2017Astana_20250910',
|
||||
|
||||
setCurrentObject: (id: string | undefined, title: string | undefined) =>
|
||||
set({ currentObject: { id, title } }),
|
||||
|
||||
@@ -164,18 +179,64 @@ const useNavigationStore = create<NavigationStore>()(
|
||||
|
||||
clearSubmenu: () =>
|
||||
set({ currentSubmenu: null }),
|
||||
|
||||
openMonitoring: () => set({
|
||||
showMonitoring: true,
|
||||
showFloorNavigation: false,
|
||||
showNotifications: false,
|
||||
showListOfDetectors: false,
|
||||
currentSubmenu: 'monitoring',
|
||||
showDetectorMenu: false,
|
||||
selectedDetector: null,
|
||||
showNotificationDetectorInfo: false,
|
||||
selectedNotification: null
|
||||
}),
|
||||
|
||||
loadZones: async (objectId: string) => {
|
||||
const cache = get().zonesCache
|
||||
const cached = cache[objectId]
|
||||
const hasCached = Array.isArray(cached) && cached.length > 0
|
||||
if (hasCached) {
|
||||
// Показываем кэшированные зоны сразу, но обновляем в фоне
|
||||
set({ currentZones: cached, zonesLoading: true, zonesError: null })
|
||||
} else {
|
||||
set({ zonesLoading: true, zonesError: null })
|
||||
}
|
||||
try {
|
||||
const res = await fetch(`/api/get-zones?objectId=${encodeURIComponent(objectId)}`, { cache: 'no-store' })
|
||||
const text = await res.text()
|
||||
let payload: string | Record<string, unknown>
|
||||
try { payload = JSON.parse(text) } catch { payload = text }
|
||||
if (!res.ok) throw new Error(typeof payload === 'string' ? payload : (payload?.error as string || 'Не удалось получить зоны'))
|
||||
const zones: Zone[] = typeof payload === 'string' ? [] :
|
||||
Array.isArray(payload?.data) ? payload.data as Zone[] :
|
||||
(payload?.data && typeof payload.data === 'object' && 'zones' in payload.data ? (payload.data as { zones?: Zone[] }).zones :
|
||||
payload?.zones ? payload.zones as Zone[] : []) || []
|
||||
const normalized = zones.map((z) => ({
|
||||
...z,
|
||||
image_path: z.image_path ?? null,
|
||||
}))
|
||||
set((state) => ({
|
||||
currentZones: normalized,
|
||||
zonesCache: { ...state.zonesCache, [objectId]: normalized },
|
||||
zonesLoading: false,
|
||||
zonesError: null,
|
||||
}))
|
||||
} catch (e: unknown) {
|
||||
set({ zonesLoading: false, zonesError: (e as Error)?.message || 'Ошибка при загрузке зон' })
|
||||
}
|
||||
},
|
||||
|
||||
setZones: (zones: Zone[]) => set({ currentZones: zones }),
|
||||
clearZones: () => set({ currentZones: [] }),
|
||||
|
||||
openMonitoring: () => {
|
||||
set({
|
||||
showMonitoring: true,
|
||||
showFloorNavigation: false,
|
||||
showNotifications: false,
|
||||
showListOfDetectors: false,
|
||||
currentSubmenu: 'monitoring',
|
||||
showDetectorMenu: false,
|
||||
selectedDetector: null,
|
||||
showNotificationDetectorInfo: false,
|
||||
selectedNotification: null,
|
||||
zonesError: null // Очищаем ошибку зон при открытии мониторинга
|
||||
})
|
||||
const objId = get().currentObject.id
|
||||
if (objId) {
|
||||
// Вызываем загрузку зон сразу, но обновляем в фоне
|
||||
get().loadZones(objId)
|
||||
}
|
||||
},
|
||||
|
||||
closeMonitoring: () => set({
|
||||
showMonitoring: false,
|
||||
@@ -254,22 +315,26 @@ const useNavigationStore = create<NavigationStore>()(
|
||||
selectedDetector: null,
|
||||
currentSubmenu: null
|
||||
}),
|
||||
|
||||
closeAllMenus: () => set({
|
||||
showMonitoring: false,
|
||||
showFloorNavigation: false,
|
||||
showNotifications: false,
|
||||
showListOfDetectors: false,
|
||||
showSensors: false,
|
||||
showDetectorMenu: false,
|
||||
|
||||
closeAllMenus: () => {
|
||||
set({
|
||||
showMonitoring: false,
|
||||
showFloorNavigation: false,
|
||||
showNotifications: false,
|
||||
showListOfDetectors: false,
|
||||
showSensors: false,
|
||||
currentSubmenu: null,
|
||||
});
|
||||
get().clearSelections();
|
||||
},
|
||||
|
||||
clearSelections: () => set({
|
||||
selectedDetector: null,
|
||||
showNotificationDetectorInfo: false,
|
||||
selectedNotification: null,
|
||||
showAlertMenu: false,
|
||||
showDetectorMenu: false,
|
||||
selectedAlert: null,
|
||||
currentSubmenu: null
|
||||
showAlertMenu: false,
|
||||
}),
|
||||
|
||||
|
||||
setSelectedDetector: (detector: DetectorType | null) => set({ selectedDetector: detector }),
|
||||
setShowDetectorMenu: (show: boolean) => set({ showDetectorMenu: show }),
|
||||
setSelectedNotification: (notification: NotificationType | null) => set({ selectedNotification: notification }),
|
||||
|
||||
@@ -1,2 +1,2 @@
|
||||
import type { ValidationRules, ValidationErrors, User, UserState } from './types/index'
|
||||
export type { ValidationRules, ValidationErrors, User, UserState }
|
||||
import type { ValidationRules, ValidationErrors, User, UserState, Zone } from './types/index'
|
||||
export type { ValidationRules, ValidationErrors, User, UserState, Zone }
|
||||
@@ -64,3 +64,18 @@ export interface TextInputProps {
|
||||
error?: string
|
||||
min?: string
|
||||
}
|
||||
|
||||
export interface Zone {
|
||||
id: number
|
||||
name: string
|
||||
floor: number
|
||||
image_path?: string | null
|
||||
model_path?: string | null
|
||||
order?: number
|
||||
sensors?: Array<{
|
||||
id: number
|
||||
name: string
|
||||
serial_number: string
|
||||
sensor_type: string
|
||||
}>
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user