diff --git a/index.html b/index.html index d257f48..2a4c973 100644 --- a/index.html +++ b/index.html @@ -3,7 +3,7 @@ - ParkTrack Labeler + ParkTrack Admin diff --git a/public/parktrack.png b/public/parktrack.png new file mode 100644 index 0000000..da1a9cd Binary files /dev/null and b/public/parktrack.png differ diff --git a/src/App.tsx b/src/App.tsx index 151a566..f282dbf 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -198,7 +198,7 @@ function renderRoute(route: AppRoute, viewMode: ViewMode) { if (route === 'sources') return ; if (route === 'cameras') { return ( -
+
); diff --git a/src/api/analytics.ts b/src/api/analytics.ts index 8f8aed5..6c00977 100644 --- a/src/api/analytics.ts +++ b/src/api/analytics.ts @@ -9,22 +9,32 @@ export type AnalyticsRange = { export type AnalyticsQuery = AnalyticsRange & { partner_id?: number; - zone_ids?: Array; - camera_ids?: Array; + zone_id?: number | string; + camera_id?: number | string; granularity?: AnalyticsGranularity; forecast_created_at?: string; + status?: string; + limit?: number; top?: number; offset?: number; }; export type AnalyticsSummary = { + active_zones_count?: number | null; active_zones?: number | null; + current_occupied_count?: number | null; total_capacity?: number | null; + current_free_count?: number | null; occupied_now?: number | null; free_now?: number | null; + avg_occupancy_percent?: number | null; average_occupancy_percent?: number | null; + freshest_update_at?: string | null; newest_update_at?: string | null; oldest_update_at?: string | null; + avg_update_interval_sec?: number | null; + max_update_interval_sec?: number | null; + avg_confidence?: number | null; average_confidence?: number | null; zones?: AnalyticsZoneSummary[]; cameras?: AnalyticsCameraSummary[]; @@ -34,9 +44,12 @@ export type AnalyticsZoneSummary = { zone_id: number | string; camera_id?: number | null; capacity?: number | null; + occupied_count?: number | null; occupied?: number | null; + free_count?: number | null; free?: number | null; occupancy_percent?: number | null; + confidence_avg?: number | null; confidence?: number | null; last_update_at?: string | null; status?: AnalyticsDetectorStatus | string | null; @@ -51,23 +64,32 @@ export type AnalyticsCameraSummary = { }; export type AnalyticsUpdateFrequency = { + avg_update_interval_sec?: number | null; average_interval_seconds?: number | null; + max_update_interval_sec?: number | null; max_interval_seconds?: number | null; + freshest_update_at?: string | null; newest_update_at?: string | null; oldest_update_at?: string | null; + by_zone?: AnalyticsUpdateFrequencyItem[]; items?: AnalyticsUpdateFrequencyItem[]; }; export type AnalyticsUpdateFrequencyItem = { zone_id?: number | string | null; camera_id?: number | null; + avg_update_interval_sec?: number | null; average_interval_seconds?: number | null; + max_update_interval_sec?: number | null; max_interval_seconds?: number | null; + last_update_at?: string | null; newest_update_at?: string | null; oldest_update_at?: string | null; }; export type AnalyticsConfidence = { + granularity?: string; + avg_confidence?: number | null; average_confidence?: number | null; points?: AnalyticsConfidencePoint[]; items?: AnalyticsConfidencePoint[]; @@ -79,11 +101,16 @@ export type AnalyticsConfidencePoint = { zone_id?: number | string | null; camera_id?: number | null; confidence?: number | null; + confidence_avg?: number | null; + confidence_min?: number | null; + confidence_max?: number | null; average_confidence?: number | null; + observations_count?: number | null; observations?: number | null; }; export type AnalyticsHistory = { + granularity?: string; series?: AnalyticsSeries[]; points?: AnalyticsHistoryPoint[]; items?: AnalyticsHistoryPoint[]; @@ -102,28 +129,61 @@ export type AnalyticsHistoryPoint = { timestamp?: string; zone_id?: number | string | null; camera_id?: number | null; + occupied_count?: number | null; occupied?: number | null; + free_count?: number | null; free?: number | null; total?: number | null; capacity?: number | null; occupancy_percent?: number | null; + confidence_avg?: number | null; confidence?: number | null; + observations_count?: number | null; observations?: number | null; }; export type AnalyticsForecast = { + available?: boolean; + reason?: string | null; series?: AnalyticsSeries[]; points?: AnalyticsForecastPoint[]; items?: AnalyticsForecastPoint[]; }; export type AnalyticsForecastPoint = AnalyticsHistoryPoint & { + predicted_for?: string | null; forecast_created_at?: string | null; + model_version?: string | null; + predicted_occupied_count?: number | null; predicted_occupied?: number | null; + predicted_free_count?: number | null; predicted_free?: number | null; predicted_occupancy_percent?: number | null; }; +export type ForecastQualityMetrics = { + mae_occupied_count?: number | null; + mae_occupancy_percent?: number | null; + bias_occupancy_percent?: number | null; + points_count?: number | null; +}; + +export type ForecastQualityPoint = { + timestamp?: string; + zone_id?: number | string | null; + actual_occupied_count?: number | null; + actual_occupancy_percent?: number | null; + predicted_occupied_count?: number | null; + predicted_occupancy_percent?: number | null; + absolute_error_occupancy_percent?: number | null; +}; + +export type ForecastQualityResponse = { + granularity?: string; + metrics?: ForecastQualityMetrics; + points?: ForecastQualityPoint[]; +}; + export type AnalyticsObservationsRate = { points?: AnalyticsObservationPoint[]; items?: AnalyticsObservationPoint[]; @@ -134,6 +194,7 @@ export type AnalyticsObservationPoint = { timestamp?: string; zone_id?: number | string | null; camera_id?: number | null; + observations_count?: number | null; observations?: number | null; count?: number | null; }; @@ -143,7 +204,8 @@ export type AnalyticsDetectorStatus = | 'stale' | 'offline' | 'no_data' - | 'low_confidence'; + | 'low_confidence' + | 'error'; export type AnalyticsDetectorHealth = { items: AnalyticsDetectorHealthItem[]; @@ -153,14 +215,21 @@ export type AnalyticsDetectorHealth = { export type AnalyticsDetectorHealthItem = { zone_id: number | string; camera_id?: number | null; + camera_title?: string | null; capacity?: number | null; + occupied_count?: number | null; occupied?: number | null; + free_count?: number | null; free?: number | null; occupancy_percent?: number | null; + confidence_avg?: number | null; confidence?: number | null; last_update_at?: string | null; + sec_ago?: number | null; stale_seconds?: number | null; + avg_update_interval_sec?: number | null; average_interval_seconds?: number | null; + max_update_interval_sec?: number | null; max_interval_seconds?: number | null; status?: AnalyticsDetectorStatus | string | null; }; @@ -178,9 +247,14 @@ export type DetectionRunListItem = { finished_at?: string | null; status?: string | null; processing_time_ms?: number | null; + detected_cars_count?: number | null; cars_detected?: number | null; + occupied_count?: number | null; occupied?: number | null; + free_count?: number | null; free?: number | null; + capacity?: number | null; + confidence_avg?: number | null; confidence?: number | null; has_feedback?: boolean | null; }; @@ -188,7 +262,11 @@ export type DetectionRunListItem = { export type DetectionRunDetail = DetectionRunListItem & { model_version?: string | null; total?: number | null; + error_code?: string | null; + error_message?: string | null; error?: string | null; + raw_snapshot_url?: string | null; + annotated_snapshot_url?: string | null; raw_image_url?: string | null; annotated_image_url?: string | null; feedback?: DetectionFeedback | null; @@ -197,21 +275,25 @@ export type DetectionRunDetail = DetectionRunListItem & { export type DetectionFeedbackRating = 'correct' | 'partially_correct' | 'incorrect'; export type DetectionFeedbackErrorType = - | 'extra_car' - | 'missing_car' - | 'wrong_zone' + | 'false_positive_car' + | 'false_negative_car' + | 'wrong_zone_assignment' | 'bad_lighting' - | 'bad_angle' - | 'calibration_issue' + | 'bad_camera_angle' + | 'calibration_problem' | 'other'; export type DetectionFeedback = { feedback_id?: number | string; created_at?: string | null; updated_at?: string | null; + created_by_user_id?: number | null; + created_by_email?: string | null; user_id?: number | null; user_email?: string | null; rating?: DetectionFeedbackRating | string | null; + expected_occupied_count?: number | null; + expected_free_count?: number | null; correct_occupied?: number | null; correct_free?: number | null; error_type?: DetectionFeedbackErrorType | string | null; @@ -221,8 +303,8 @@ export type DetectionFeedback = { export type DetectionFeedbackRequest = { rating: DetectionFeedbackRating; - correct_occupied?: number | null; - correct_free?: number | null; + expected_occupied_count?: number | null; + expected_free_count?: number | null; error_type?: DetectionFeedbackErrorType | null; comment?: string | null; }; @@ -261,14 +343,18 @@ export type LegacySeriesQuery = AnalyticsRange & { granularity?: AnalyticsGranularity; }; -function analyticsQuery(query: AnalyticsQuery = {}) { +function analyticsQuery(query: AnalyticsQuery = {}, includeForecastCreatedAt = false) { const search = new URLSearchParams(); const scalarQuery = buildQuery({ partner_id: query.partner_id, + zone_id: query.zone_id, + camera_id: query.camera_id, from: query.from, to: query.to, granularity: query.granularity, - forecast_created_at: query.forecast_created_at, + forecast_created_at: includeForecastCreatedAt ? query.forecast_created_at : undefined, + status: query.status, + limit: query.limit, top: query.top, offset: query.offset }); @@ -278,9 +364,6 @@ function analyticsQuery(query: AnalyticsQuery = {}) { scalarParams.forEach((value, key) => search.set(key, value)); } - query.zone_ids?.forEach(zoneId => search.append('zone_id', String(zoneId))); - query.camera_ids?.forEach(cameraId => search.append('camera_id', String(cameraId))); - const result = search.toString(); return result ? `?${result}` : ''; } @@ -323,7 +406,7 @@ export const analyticsApi = { }, async occupancyForecast(query?: AnalyticsQuery) { - return request('GET', `/admin/analytics/occupancy-forecast${analyticsQuery(query)}`); + return request('GET', `/admin/analytics/occupancy-forecast${analyticsQuery(query, true)}`); }, async occupancyHeatmap(query?: AnalyticsQuery) { @@ -338,6 +421,10 @@ export const analyticsApi = { return request('GET', `/admin/analytics/detector-health${analyticsQuery(query)}`); }, + async forecastQuality(query?: AnalyticsQuery) { + return request('GET', `/admin/analytics/forecast-quality${analyticsQuery(query)}`); + }, + async cameraDetections(cameraId: number, query?: AnalyticsQuery) { return request('GET', `/admin/analytics/cameras/${encodeURIComponent(cameraId)}/detections${analyticsQuery(query)}`); }, diff --git a/src/api/client.ts b/src/api/client.ts index 29f3345..1acd20c 100644 --- a/src/api/client.ts +++ b/src/api/client.ts @@ -55,6 +55,7 @@ export type { DetectionRunDetail, DetectionRunList, DetectionRunListItem, + ForecastQualityResponse, LegacyForecastSeriesPoint, LegacyOccupancySeriesPoint, LegacySeriesQuery diff --git a/src/components/CamerasPage.tsx b/src/components/CamerasPage.tsx index be52b26..678b216 100644 --- a/src/components/CamerasPage.tsx +++ b/src/components/CamerasPage.tsx @@ -118,14 +118,29 @@ function YandexCamerasMap({ const center = cameraMapPoint(selectedCamera) ?? cameraMapPoint(firstCamera) ?? yandexPoint(59.9386, 30.3141); const { ymaps, map, loading, error } = useYandexMap(mapRef, { center, - zoom: selectedCamera ? 17 : 14 + zoom: selectedCamera ? 17 : 14, + syncView: false }); + useEffect(() => { + if (!map || !mapRef.current) return; + + const fitMapToContainer = () => { + map.container.fitToViewport(); + }; + const observer = new ResizeObserver(fitMapToContainer); + observer.observe(mapRef.current); + fitMapToContainer(); + + return () => observer.disconnect(); + }, [map]); + useEffect(() => { if (!map) return; + map.container.fitToViewport(); const selectedPoint = cameraMapPoint(selectedCamera); if (selectedPoint) { - map.setCenter(selectedPoint, 17, { duration: 200 }); + map.setCenter(selectedPoint, 17); return; } @@ -136,7 +151,7 @@ function YandexCamerasMap({ fitYandexMap(map, points, 14); return; } - map.setCenter(yandexPoint(59.9386, 30.3141), 14, { duration: 200 }); + map.setCenter(yandexPoint(59.9386, 30.3141), 14); }, [map, cameras, selectedCamera?.camera_id]); useEffect(() => { @@ -666,7 +681,7 @@ export default function CamerasPage() { } await container.requestFullscreen(); } catch (e: any) { - setError(`Не удалось открыть snapshot на весь экран: ${String(e?.message || e)}`); + setError(`Не удалось открыть кадр на весь экран: ${String(e?.message || e)}`); } } @@ -776,7 +791,7 @@ export default function CamerasPage() { {typeof zonesCount === 'number' ? zonesCount : '—'} - {cam.is_active === false ? 'paused' : 'active'} + {cam.is_active === false ? 'Неактивна' : 'Активна'}
); @@ -949,7 +964,7 @@ export default function CamerasPage() { @@ -962,7 +977,7 @@ export default function CamerasPage() { - {snapshot.loading &&
Загрузка snapshot...
} + {snapshot.loading &&
Загрузка кадра...
} {!snapshot.loading && snapshot.error && (
{snapshot.error}
)} diff --git a/src/components/ImageViewport.tsx b/src/components/ImageViewport.tsx index a1bc641..454235d 100644 --- a/src/components/ImageViewport.tsx +++ b/src/components/ImageViewport.tsx @@ -1,6 +1,6 @@ import { Stage, Layer, Image as KImage } from 'react-konva'; import useImage from 'use-image'; -import { useEffect, useRef, useState } from 'react'; +import { useCallback, useEffect, useRef, useState } from 'react'; import { useStore } from '@/store/useStore'; import ZoneLayer from './ZoneLayer'; import type { KonvaEventObject } from 'konva/lib/Node'; @@ -22,6 +22,24 @@ export default function ImageViewport() { viewRef.current = { scale, offsetX, offsetY }; }, [scale, offsetX, offsetY]); + const centerImage = useCallback(( + viewportWidth = size.w, + viewportHeight = size.h, + fillViewport = isFullscreen + ) => { + if (!image || viewportWidth <= 0 || viewportHeight <= 0) return; + + const scaleX = viewportWidth / image.naturalWidth; + const scaleY = viewportHeight / image.naturalHeight; + const nextScale = fillViewport + ? Math.max(scaleX, scaleY) + : Math.min(scaleX, scaleY, 1); + const nextOffsetX = (viewportWidth - image.naturalWidth * nextScale) / 2; + const nextOffsetY = (viewportHeight - image.naturalHeight * nextScale) / 2; + + setView(nextScale, nextOffsetX, nextOffsetY); + }, [image, isFullscreen, setView, size.h, size.w]); + useEffect(() => { function onResize() { if (!containerRef.current) return; @@ -48,19 +66,22 @@ export default function ImageViewport() { useEffect(() => { function onFullscreenChange() { - setIsFullscreen(document.fullscreenElement === containerRef.current); + const enteredFullscreen = document.fullscreenElement === containerRef.current; + setIsFullscreen(enteredFullscreen); window.setTimeout(() => { if (!containerRef.current) return; - setSize({ + const nextSize = { w: containerRef.current.clientWidth, h: containerRef.current.clientHeight - }); + }; + setSize(nextSize); + centerImage(nextSize.w, nextSize.h, enteredFullscreen); }, 0); } document.addEventListener('fullscreenchange', onFullscreenChange); return () => document.removeEventListener('fullscreenchange', onFullscreenChange); - }, []); + }, [centerImage]); async function toggleFullscreen() { const container = containerRef.current; @@ -228,6 +249,19 @@ export default function ImageViewport() { <>
scale: {scale.toFixed(2)} • tool: {tool}
+
))} diff --git a/src/components/TopBar.tsx b/src/components/TopBar.tsx index 8d7dd7e..fe37b24 100644 --- a/src/components/TopBar.tsx +++ b/src/components/TopBar.tsx @@ -54,7 +54,7 @@ export default function TopBar() { const img = await loadImage('/sample.png'); setImage(img, cameraId); fitToView(img); - notifyWarning('Snapshot недоступен, открыт тестовый кадр.'); + notifyWarning('Кадр с камеры недоступен, открыт тестовый кадр.'); } } catch (error) { console.error('Error loading snapshot:', error); @@ -62,10 +62,10 @@ export default function TopBar() { const img = await loadImage('/sample.png'); setImage(img, cameraId); fitToView(img); - notifyWarning('Не удалось загрузить snapshot, открыт тестовый кадр.'); + notifyWarning('Не удалось загрузить кадр с камеры, открыт тестовый кадр.'); } catch (fallbackError) { console.error('Error loading fallback image:', fallbackError); - notifyError('Не удалось загрузить snapshot и тестовый кадр.'); + notifyError('Не удалось загрузить кадр с камеры и тестовый кадр.'); } } finally { setLoadingSnapshot(false); @@ -119,7 +119,7 @@ export default function TopBar() { placeholder="http://…/frame.jpg" /> - {loadingSnapshot && Загрузка snapshot...} + {loadingSnapshot && Загрузка кадра...} )} diff --git a/src/layout/AdminShell.tsx b/src/layout/AdminShell.tsx index 996b09d..c3d5963 100644 --- a/src/layout/AdminShell.tsx +++ b/src/layout/AdminShell.tsx @@ -75,7 +75,7 @@ export default function AdminShell({ route, children }: { route: AppRoute; child
-
- -
- + + +
- + + + - + + +
- - - - - - + + - + - +
+ + {isAdmin && ( + +
+ + + + +
+ +
+ )} ); } @@ -817,63 +1093,112 @@ function AnalyticsFiltersPanel({ onRefresh: () => void; }) { return ( -
-
- + <> +
+
+
+ + +
+ - - {filters.period === 'custom' && ( - <> - - onChange(prev => ({ ...prev, from: event.target.value }))} /> - - - onChange(prev => ({ ...prev, to: event.target.value }))} /> - - - )} - - - - + + onChange(prev => ({ ...prev, forecastCreatedAt: event.target.value }))} + title="Пустое значение — последний доступный прогноз для каждой точки времени" + /> + +
- + {filters.period === 'custom' && ( +
+ + onChange(prev => ({ ...prev, from: event.target.value }))} /> + + + onChange(prev => ({ ...prev, to: event.target.value }))} /> + +
+ )}
-
+
+
onChange(prev => ({ ...prev, zoneSearch: value }))} selectedIds={filters.selectedZoneIds} - onSelectedIds={ids => onChange(prev => ({ ...prev, selectedZoneIds: ids }))} + maxSelected={1} + onSelectedIds={ids => onChange(prev => ({ + ...prev, + selectedZoneIds: ids, + selectedCameraIds: ids.length ? [] : prev.selectedCameraIds + }))} items={zones.map(zone => ({ id: String(zone.id), label: `Зона #${zone.id}`, @@ -886,7 +1211,12 @@ function AnalyticsFiltersPanel({ search={filters.cameraSearch} onSearch={value => onChange(prev => ({ ...prev, cameraSearch: value }))} selectedIds={filters.selectedCameraIds} - onSelectedIds={ids => onChange(prev => ({ ...prev, selectedCameraIds: ids }))} + maxSelected={1} + onSelectedIds={ids => onChange(prev => ({ + ...prev, + selectedZoneIds: ids.length ? [] : prev.selectedZoneIds, + selectedCameraIds: ids + }))} items={cameras.map(camera => ({ id: String(camera.camera_id), label: `#${camera.camera_id} · ${camera.title}`, @@ -894,8 +1224,12 @@ function AnalyticsFiltersPanel({ }))} emptyMessage={cameraError ?? 'Камеры не найдены'} /> +
+
+ Фокус аналитики: все данные, одна зона или одна камера. Выбор зоны очищает камеру, выбор камеры очищает зону. +
-
+ ); } @@ -905,6 +1239,7 @@ function MultiEntityPicker({ selectedIds, items, emptyMessage, + maxSelected, onSearch, onSelectedIds }: { @@ -913,6 +1248,7 @@ function MultiEntityPicker({ selectedIds: string[]; items: Array<{ id: string; label: string; meta?: string }>; emptyMessage: string; + maxSelected?: number; onSearch: (value: string) => void; onSelectedIds: (ids: string[]) => void; }) { @@ -926,6 +1262,10 @@ function MultiEntityPicker({ const allVisibleSelected = visibleIds.length > 0 && visibleSelected.length === visibleIds.length; function toggle(id: string, checked: boolean) { + if (checked && maxSelected === 1) { + onSelectedIds([id]); + return; + } const next = new Set(selectedIds); if (checked) next.add(id); else next.delete(id); @@ -948,17 +1288,23 @@ function MultiEntityPicker({ выбрано: {selectedIds.length}
onSearch(event.target.value)} placeholder="Поиск по id или названию" /> - + {maxSelected === 1 ? ( + + ) : ( + + )}
{visibleItems.map(item => (
@@ -1035,29 +1389,12 @@ function Block({ ); } -function AnalyticsBackendStub({ - title, - description -}: { - title: string; - description: string; -}) { - return ( -
-
-

{title}

- backend pending -
-
{description}
-
- ); -} - function LineChart({ series, unit, emptyMessage, granularity, + initialHiddenSeries = [], xLabel = 'Время', yLabel = unit ? `Значение, ${unit}` : 'Значение' }: { @@ -1065,10 +1402,11 @@ function LineChart({ unit?: string; emptyMessage: string; granularity?: AnalyticsGranularity; + initialHiddenSeries?: string[]; xLabel?: string; yLabel?: string; }) { - const [hidden, setHidden] = useState>(() => new Set()); + const [hidden, setHidden] = useState>(() => new Set(initialHiddenSeries)); const [tooltip, setTooltip] = useState<{ key: string; x: number; @@ -1145,9 +1483,9 @@ function LineChart({ if (point.y === null) return undefined; const pointX = toX(point, index, item.points.length); const pointY = toY(point.y); - const lines = [item.label, formatAxisDateTime(point.x), tooltipValue(point.y)]; - const tooltipWidth = Math.min(230, Math.max(128, Math.max(...lines.map(line => line.length)) * 6.4 + 18)); - const tooltipHeight = 58; + const lines = wrapTooltipLines(chartTooltipLines(item.label, point, tooltipValue(point.y))); + const tooltipWidth = Math.min(230, Math.max(128, Math.max(...lines.map(line => line.length)) * 7.2 + 24)); + const tooltipHeight = Math.max(58, 22 + lines.length * 16); let boxX = pointX + 12; let boxY = pointY - tooltipHeight - 12; if (boxX + tooltipWidth > width - padding.right) boxX = pointX - tooltipWidth - 12; @@ -1242,7 +1580,13 @@ function LineChart({ ); }))} {xTicks.map((tick, index) => ( - + {tick.label} ))} @@ -1365,7 +1709,13 @@ function BarChart({ ); })} {xTicks.map((tick, index) => ( - + {tick.label} ))} @@ -1392,11 +1742,13 @@ function BarChart({ function AnalyticsMap({ zones, cameras, - summary + summary, + health }: { zones: ParkingZone[]; cameras: Camera[]; summary?: AnalyticsSummary; + health?: AnalyticsDetectorHealth; }) { const mapRef = useRef(null); const [selected, setSelected] = useState(null); @@ -1414,13 +1766,22 @@ function AnalyticsMap({ const collection = new ymaps.GeoObjectCollection(); const boundsPoints: YandexPoint[] = []; const summaryByZone = new Map((summary?.zones ?? []).map(item => [String(item.zone_id), item])); + const healthByZone = new Map((health?.items ?? []).map(item => [String(item.zone_id), item])); zones.forEach(zone => { const points = zoneMapPoints(zone); if (points.length < 3) return; boundsPoints.push(...points); const zoneSummary = summaryByZone.get(String(zone.id)); - const color = occupancyColor(zoneSummary?.occupancy_percent, zoneSummary?.last_update_at ?? zone.occupancy_updated_at); + const zoneHealth = healthByZone.get(String(zone.id)); + const capacity = zoneHealth?.capacity ?? zoneSummary?.capacity ?? zone.capacity; + const occupied = zoneHealth?.occupied_count ?? zoneHealth?.occupied ?? zoneSummary?.occupied_count ?? zoneSummary?.occupied ?? zone.occupied; + const free = zoneHealth?.free_count ?? zoneHealth?.free ?? zoneSummary?.free_count ?? zoneSummary?.free ?? zone.free_count; + const occupancy = zoneHealth?.occupancy_percent ?? zoneSummary?.occupancy_percent ?? ( + typeof occupied === 'number' && typeof capacity === 'number' && capacity > 0 ? (occupied / capacity) * 100 : null + ); + const lastUpdate = zoneHealth?.last_update_at ?? zoneSummary?.last_update_at ?? zone.occupancy_updated_at; + const color = occupancyColor(occupancy, lastUpdate); const polygon = new ymaps.Polygon( [points], { hintContent: `Зона #${String(zone.id)}` }, @@ -1438,11 +1799,11 @@ function AnalyticsMap({ setAnalyticsRoute({ view: 'camera', cameraId: String(zone.camera_id) })], @@ -1489,17 +1850,17 @@ function AnalyticsMap({ return () => { map.geoObjects.remove(collection); }; - }, [ymaps, map, zones, cameras, summary]); + }, [ymaps, map, zones, cameras, summary, health]); return (
+
+ {selected ??
Выберите зону или камеру на карте.
} +
{loading &&
Загрузка Яндекс.Карт...
} {error &&
{error}
}
-
- {selected ??
Выберите зону или камеру на карте.
} -
); } @@ -1539,43 +1900,52 @@ function DetectorHealthTable({ items }: { items: AnalyticsDetectorHealthItem[] } } return ( -
-
- Зона - Камера - Всего - Занято - Свободно - Занятость - Уверенность модели - Последнее обновление - Возраст - Средний интервал - Макс. интервал - Статус -
-
- {items.map(item => ( - - ))} +
+
Показано зон: {items.length}
+
+
+ Зона + Камера + Статус + Всего + Занято + Свободно + Занятость + Уверенность модели + Последнее обновление + Возраст + Средний интервал + Макс. интервал +
+
+ {items.map(item => { + const status = detectorStatus(item.status); + return ( + + ); + })} +
); @@ -1591,12 +1961,12 @@ function zoneCapacity(zone?: ParkingZone, summary?: AnalyticsSummary) { if (!zone) return undefined; const zoneSummary = summary?.zones?.find(item => String(item.zone_id) === String(zone.id)); return { - capacity: zoneSummary?.capacity ?? zone.capacity, - occupied: zoneSummary?.occupied ?? zone.occupied, - free: zoneSummary?.free ?? zone.free_count, - occupancy: zoneSummary?.occupancy_percent, - confidence: zoneSummary?.confidence ?? zone.confidence, - lastUpdate: zoneSummary?.last_update_at ?? zone.occupancy_updated_at, + capacity: zoneSummary?.capacity ?? summary?.total_capacity ?? zone.capacity, + occupied: zoneSummary?.occupied_count ?? zoneSummary?.occupied ?? summary?.current_occupied_count ?? zone.occupied, + free: zoneSummary?.free_count ?? zoneSummary?.free ?? summary?.current_free_count ?? zone.free_count, + occupancy: zoneSummary?.occupancy_percent ?? summary?.avg_occupancy_percent ?? summary?.average_occupancy_percent, + confidence: zoneSummary?.confidence_avg ?? zoneSummary?.confidence ?? summary?.avg_confidence ?? summary?.average_confidence ?? zone.confidence, + lastUpdate: zoneSummary?.last_update_at ?? summary?.freshest_update_at ?? summary?.newest_update_at ?? zone.occupancy_updated_at, status: zoneSummary?.status ?? (zone.is_active === false ? 'inactive' : 'active') }; } @@ -1619,7 +1989,7 @@ function ZoneAnalyticsPage({ zoneId }: { zoneId: string }) { const [frequency, setFrequency] = useState>(emptyState); const query = useMemo(() => ({ partner_id: currentPartnerId, - zone_ids: [zoneId], + zone_id: zoneId, ...rangeForFilters({ ...defaultFilters(), period: '7d' }), granularity: '1h' }), [currentPartnerId, zoneId]); @@ -1668,13 +2038,13 @@ function ZoneAnalyticsPage({ zoneId }: { zoneId: string }) { key: 'occupied', label: 'Занято', color: '#dc2626', - points: points.map(point => ({ x: getPointTime(point), y: point.occupied ?? null, meta: point as Record })) + points: points.map(point => ({ x: getPointTime(point), y: pointOccupied(point), meta: point as Record })) }, { key: 'free', label: 'Свободно', color: '#128a45', - points: points.map(point => ({ x: getPointTime(point), y: point.free ?? null, meta: point as Record })) + points: points.map(point => ({ x: getPointTime(point), y: pointFree(point), meta: point as Record })) } ]; }, [history.data]); @@ -1703,7 +2073,7 @@ function ZoneAnalyticsPage({ zoneId }: { zoneId: string }) { - +
) :
Зона не найдена.
} @@ -1714,9 +2084,9 @@ function ZoneAnalyticsPage({ zoneId }: { zoneId: string }) {
- - - + + +
@@ -1724,7 +2094,12 @@ function ZoneAnalyticsPage({ zoneId }: { zoneId: string }) {
- + @@ -1758,6 +2133,11 @@ function ZoneGeometryPreview({ zone }: { zone: ParkingZone }) { function CameraAnalyticsPage({ cameraId }: { cameraId: string }) { const currentPartnerId = useSessionStore(state => state.currentPartnerId); + const setLabelerReturnRoute = useStore(state => state.setLabelerReturnRoute); + const setLabelerCamera = useStore(state => state.setCamera); + const loadCameraMeta = useStore(state => state.loadCameraMeta); + const loadZones = useStore(state => state.loadZones); + const setViewMode = useStore(state => state.setViewMode); const numericCameraId = Number(cameraId); const [camera, setCamera] = useState>(emptyState); const [zones, setZones] = useState>(emptyState); @@ -1768,10 +2148,10 @@ function CameraAnalyticsPage({ cameraId }: { cameraId: string }) { const [detections, setDetections] = useState>(emptyState); const query = useMemo(() => ({ partner_id: currentPartnerId, - camera_ids: [cameraId], + camera_id: cameraId, ...rangeForFilters({ ...defaultFilters(), period: '7d' }), granularity: '1h', - top: 20 + limit: 20 }), [currentPartnerId, cameraId]); useEffect(() => { @@ -1809,6 +2189,19 @@ function CameraAnalyticsPage({ cameraId }: { cameraId: string }) { }; }, [numericCameraId, currentPartnerId, query]); + function openCameraAdmin() { + navigate('cameras'); + } + + function openCameraLabeler() { + setLabelerReturnRoute('cameras'); + setLabelerCamera(String(numericCameraId)); + loadCameraMeta(numericCameraId); + loadZones(); + setViewMode('labeler'); + navigate('labeler'); + } + return (
@@ -1818,6 +2211,8 @@ function CameraAnalyticsPage({ cameraId }: { cameraId: string }) {
+ +
@@ -1829,9 +2224,9 @@ function CameraAnalyticsPage({ cameraId }: { cameraId: string }) { - - - + + +
) :
Камера не найдена.
} @@ -1850,8 +2245,8 @@ function CameraAnalyticsPage({ cameraId }: { cameraId: string }) { ('snapshot'); - const [snapshot, setSnapshot] = useState>(emptyState); - const [fullscreenUrl, setFullscreenUrl] = useState(); - const options = tab === 'annotated' ? { annotated: true, fallback_to_raw: true } : { annotated: false, fallback_to_raw: true }; + const [tab, setTab] = useState('snapshot'); + const [snapshot, setSnapshot] = useState>(emptyState); + const [fullscreen, setFullscreen] = useState(false); + const visibleRequestRef = useRef(0); - const load = useCallback(async () => { + const load = useCallback(async (targetTab: CameraSnapshotTab, force = false) => { + const requestId = ++visibleRequestRef.current; setSnapshot({ loading: true }); try { - const result = await api.getSnapshot(cameraId, options); + const result = await fetchCameraSnapshot(cameraId, targetTab, force); + if (visibleRequestRef.current !== requestId) return; setSnapshot({ loading: false, data: result }); } catch (error) { + if (visibleRequestRef.current !== requestId) return; setSnapshot({ loading: false, error: blockError(error) }); } - }, [cameraId, tab]); + }, [cameraId]); useEffect(() => { - load(); - }, [load]); + load(tab); + }, [load, tab]); + + function renderTabs(className = '') { + return ( +
+ + + +
+ ); + } return (
-
- - - -
+ {renderTabs()}
- Timestamp: {formatDateTime(snapshot.data?.captured_at)} + Снято: {formatDateTime(snapshot.data?.captured_at)}
- - + +
{snapshot.error &&
Снимок недоступен: {snapshot.error}
} @@ -1910,10 +2314,26 @@ function CameraSnapshots({ cameraId }: { cameraId: number }) { ) : !snapshot.loading && !snapshot.error ? (
Снимок недоступен
) : null} - {fullscreenUrl && ( -
- - Снимок камеры + {fullscreen && ( +
+
+ {renderTabs('fullscreen-tabs')} + Снято: {formatDateTime(snapshot.data?.captured_at)} + +
+ +
+ {snapshot.error &&
Снимок недоступен: {snapshot.error}
} + {snapshot.data?.image_url ? ( + Снимок камеры + ) : !snapshot.loading && !snapshot.error ? ( +
Снимок недоступен
+ ) : ( +
Загрузка снимка...
+ )} +
)}
@@ -1949,10 +2369,10 @@ function DetectionsTable({ detections }: { detections: DetectionRunList['items'] {formatDateTime(item.started_at)} {item.status ?? '—'} {formatMs(item.processing_time_ms)} - {formatNumber(item.cars_detected)} - {formatNumber(item.occupied)} - {formatNumber(item.free)} - {formatPercent(item.confidence)} + {formatNumber(item.detected_cars_count ?? item.cars_detected)} + {formatNumber(item.occupied_count ?? item.occupied)} + {formatNumber(item.free_count ?? item.free)} + {formatPercent(item.confidence_avg ?? item.confidence)} {item.has_feedback ? 'Да' : 'Нет'} Открыть @@ -1996,8 +2416,8 @@ function DetectionAnalyticsPage({ detectionRunId }: { detectionRunId: string }) async function saveFeedback(data: { rating: DetectionFeedbackRating; - correct_occupied?: number | null; - correct_free?: number | null; + expected_occupied_count?: number | null; + expected_free_count?: number | null; error_type?: DetectionFeedbackErrorType | null; comment?: string | null; }) { @@ -2048,12 +2468,12 @@ function DetectionAnalyticsPage({ detectionRunId }: { detectionRunId: string }) - - - - - - + + + + + +
) :
Распознавание не найдено.
}
@@ -2061,8 +2481,8 @@ function DetectionAnalyticsPage({ detectionRunId }: { detectionRunId: string }) {item && (
- - + +
)} @@ -2103,7 +2523,7 @@ function DetectionImage({ title, url }: { title: string; url?: string | null })

{title}

- +
@@ -2125,8 +2545,8 @@ function DetectionFeedbackForm({ saving: boolean; onSubmit: (data: { rating: DetectionFeedbackRating; - correct_occupied?: number | null; - correct_free?: number | null; + expected_occupied_count?: number | null; + expected_free_count?: number | null; error_type?: DetectionFeedbackErrorType | null; comment?: string | null; }) => Promise; @@ -2144,8 +2564,8 @@ function DetectionFeedbackForm({ try { await onSubmit({ rating, - correct_occupied: correctOccupied ? Number(correctOccupied) : null, - correct_free: correctFree ? Number(correctFree) : null, + expected_occupied_count: correctOccupied ? Number(correctOccupied) : null, + expected_free_count: correctFree ? Number(correctFree) : null, error_type: errorType || null, comment: comment.trim() || null }); @@ -2173,12 +2593,12 @@ function DetectionFeedbackForm({ @@ -2235,10 +2655,10 @@ function FeedbackHistory({ onClick={() => onOpen(item.feedback_id)} > {formatDateTime(item.created_at)} - {item.user_email ?? item.user_id ?? '—'} + {item.created_by_email ?? item.user_email ?? item.created_by_user_id ?? item.user_id ?? '—'} {item.rating ?? '—'} - {formatNumber(item.correct_occupied)} - {formatNumber(item.correct_free)} + {formatNumber(item.expected_occupied_count ?? item.correct_occupied)} + {formatNumber(item.expected_free_count ?? item.correct_free)} {item.error_type ?? '—'} {item.comment ?? '—'} @@ -2251,12 +2671,12 @@ function FeedbackHistory({

Подробная оценка

- + - - + +
@@ -2265,20 +2685,3 @@ function FeedbackHistory({
); } - -function AnalyticsComingSoon({ title }: { title: string }) { - return ( -
-
-
-

{title}

-

Детальная страница будет добавлена следующим коммитом.

-
- -
-
-
Контейнер детализации подключён.
-
-
- ); -} diff --git a/src/pages/AuthPage.tsx b/src/pages/AuthPage.tsx index 72acf97..ed307c8 100644 --- a/src/pages/AuthPage.tsx +++ b/src/pages/AuthPage.tsx @@ -58,7 +58,7 @@ export default function AuthPage({ mode }: { mode: 'login' | 'register' }) {
-
P
+
ParkTrack
{isRegister ? 'Регистрация' : 'Вход'}
diff --git a/src/pages/DashboardPage.tsx b/src/pages/DashboardPage.tsx index 3abe571..b39a07e 100644 --- a/src/pages/DashboardPage.tsx +++ b/src/pages/DashboardPage.tsx @@ -3,13 +3,11 @@ import { api } from '@/api/client'; import { Button } from '@/components/UiKit'; import { navigate } from '@/router/routes'; import { useSessionStore } from '@/auth/sessionStore'; -import type { Camera, HealthResponse, VersionResponse } from '@/api/client'; +import type { Camera } from '@/api/client'; import type { ParkingZone } from '@/types'; type DashboardState = { loading: boolean; - health?: HealthResponse; - version?: VersionResponse; cameras: Camera[]; zones: ParkingZone[]; error?: string; @@ -53,7 +51,6 @@ function formatDashboardLoadError(results: Array<{ } export default function DashboardPage() { - const session = useSessionStore(); const currentPartnerId = useSessionStore(state => state.currentPartnerId); const [state, setState] = useState({ loading: false, @@ -67,9 +64,7 @@ export default function DashboardPage() { async function load() { setState(prev => ({ ...prev, loading: true, error: undefined })); try { - const [health, version, cameras, zones] = await Promise.allSettled([ - api.health(), - api.version(), + const [cameras, zones] = await Promise.allSettled([ api.listCameras({ partner_id: currentPartnerId }), api.listZones({ partner_id: currentPartnerId }) ]); @@ -78,13 +73,9 @@ export default function DashboardPage() { setState({ loading: false, - health: health.status === 'fulfilled' ? health.value : undefined, - version: version.status === 'fulfilled' ? version.value : undefined, cameras: cameras.status === 'fulfilled' ? cameras.value : [], zones: zones.status === 'fulfilled' ? zones.value : [], error: formatDashboardLoadError([ - { label: 'статус API', result: health }, - { label: 'версию API', result: version }, { label: 'камеры', result: cameras, ignoreStatuses: [401, 403] }, { label: 'зоны', result: zones } ]) @@ -185,56 +176,45 @@ export default function DashboardPage() { {state.error &&
{state.error}
}
- - - - + + - +
-
-
-

Сессия

-
-
- Пользователь - {session.user?.full_name || session.user?.email || '—'} -
-
- Глобальная роль - {session.user?.global_role || '—'} -
-
- Партнёры - {session.user?.partner_memberships.filter(m => m.is_active !== false).length ?? 0} -
-
- Permissions - {session.user?.permissions.length ?? 0} -
+
+
+
+

Зоны внимания

+
-
+
+ {zoneWatchlist.map(zone => { + const freeCount = typeof zone.free_count === 'number' + ? zone.free_count + : typeof zone.occupied === 'number' + ? Math.max(0, zone.capacity - zone.occupied) + : '—'; -
-

Система

-
-
- API status - {state.health?.status ?? 'unknown'} -
-
- Database - {state.health?.database ?? 'unknown'} -
-
- API version - {state.version?.api_version ?? state.version?.version ?? 'unknown'} -
-
- Текущий partner - {currentPartnerId ?? (session.isAdmin() ? 'Все партнёры' : '—')} -
+ return ( + + ); + })} + {!zoneWatchlist.length &&
Зоны пока не загружены.
}
@@ -243,19 +223,15 @@ export default function DashboardPage() {
@@ -276,10 +252,7 @@ export default function DashboardPage() { {camera.title}
#{camera.camera_id}
-
- lat: {camera.latitude ?? '—'} - lng: {camera.longitude ?? '—'} -
+ Координаты не заданы ))} {!camerasWithoutMap.length &&
Все камеры уже привязаны к карте.
} @@ -298,11 +271,11 @@ export default function DashboardPage() { >
Зона #{String(zone.id)} -
Camera #{zone.camera_id}
+
Камера #{zone.camera_id}
- image: {(zone.image_polygon ?? zone.image_quad)?.length ?? 0}/4 - map: {zone.points.filter(point => point.latitude !== null && point.longitude !== null).length}/4 + Кадр: {(zone.image_polygon ?? zone.image_quad)?.length ?? 0}/4 + Карта: {zone.points.filter(point => point.latitude !== null && point.longitude !== null).length}/4
))} @@ -311,7 +284,7 @@ export default function DashboardPage() {
-

Камеры под рукой

+

Недавно обновлённые камеры

{staleCameras.map(camera => (
- -
-

Зоны внимания

-
- {zoneWatchlist.map(zone => { - const freeCount = typeof zone.free_count === 'number' - ? zone.free_count - : typeof zone.occupied === 'number' - ? Math.max(0, zone.capacity - zone.occupied) - : '—'; - - return ( - - ); - })} - {!zoneWatchlist.length &&
Зоны пока не загружены.
} -
-
); diff --git a/src/pages/PartnersAdminPage.tsx b/src/pages/PartnersAdminPage.tsx index 80fc4f4..eca5d2a 100644 --- a/src/pages/PartnersAdminPage.tsx +++ b/src/pages/PartnersAdminPage.tsx @@ -5,6 +5,7 @@ import { Button, Field, Input, Select } from '@/components/UiKit'; import { BulkActionBar, BulkSelectionCheckbox } from '@/components/BulkActionBar'; import { useFeedbackStore } from '@/feedback/feedbackStore'; import { validateOptionalPhone } from '@/utils/phone'; +import { formatAccessScope, formatPartnerRole } from '@/utils/accessLabels'; import type { AccessScope } from '@/types'; type AdminPartner = { @@ -414,7 +415,7 @@ export default function PartnersAdminPage() { const confirmed = await confirmAction({ title: 'Удалить партнёра?', - message: `Организация "${selectedPartner.name}" будет удалена из backend.`, + message: `Организация "${selectedPartner.name}" будет удалена без возможности восстановления.`, confirmLabel: 'Удалить', cancelLabel: 'Отмена', tone: 'danger' @@ -611,10 +612,6 @@ export default function PartnersAdminPage() {
Сотрудников
{members.length}
-
-
Current context
-
{currentPartnerId ?? 'all'}
-
@@ -717,7 +714,7 @@ export default function PartnersAdminPage() { {partner.name} {partner.slug} - {partner.is_active ? 'active' : 'inactive'} + {partner.is_active ? 'Активен' : 'Неактивен'}
))} @@ -839,11 +836,11 @@ export default function PartnersAdminPage() { {!membersLoading && canViewMembers && (
- User - Role - Read - Write - Delete + Сотрудник + Роль + Чтение + Изменение + Удаление
{members.map(member => ( @@ -854,10 +851,10 @@ export default function PartnersAdminPage() { onClick={() => setSelectedMemberUserId(member.user_id)} > {member.email} - {member.user_role} - {member.read_scope} - {member.write_scope} - {member.delete_scope} + {formatPartnerRole(member.user_role)} + {formatAccessScope(member.read_scope)} + {formatAccessScope(member.write_scope)} + {formatAccessScope(member.delete_scope)} ))} {!members.length &&
У партнёра пока нет сотрудников.
} @@ -871,7 +868,7 @@ export default function PartnersAdminPage() {

Доступ сотрудника

{selectedMember.email}
- + - + - + - +
@@ -964,40 +961,40 @@ export default function PartnersAdminPage() { ))} - + - + - + - +
diff --git a/src/pages/ProfilePage.tsx b/src/pages/ProfilePage.tsx index fd1871c..78d520f 100644 --- a/src/pages/ProfilePage.tsx +++ b/src/pages/ProfilePage.tsx @@ -4,6 +4,7 @@ import { api } from '@/api/client'; import { useFeedbackStore } from '@/feedback/feedbackStore'; import { Button, Field, Input } from '@/components/UiKit'; import { validateOptionalPhone } from '@/utils/phone'; +import { formatAccessScope, formatGlobalRole, formatPartnerRole } from '@/utils/accessLabels'; export default function ProfilePage() { const user = useSessionStore(s => s.user); @@ -24,7 +25,7 @@ export default function ProfilePage() { const [profileError, setProfileError] = useState(); const [passwordSaving, setPasswordSaving] = useState(false); const [passwordError, setPasswordError] = useState(); - const role = user?.global_role ?? '—'; + const role = formatGlobalRole(user?.global_role); const memberships = user?.partner_memberships ?? []; const hasProfileChanges = useMemo(() => ( profileForm.fullName.trim() !== (user?.full_name ?? '') @@ -153,15 +154,11 @@ export default function ProfilePage() {
- - - + - -
@@ -305,35 +302,3 @@ function Detail({ label, value }: { label: string; value: string | number }) {
); } - -function formatDate(dateStr?: string) { - if (!dateStr) return '—'; - try { - return new Date(dateStr).toLocaleString('ru-RU'); - } catch { - return dateStr; - } -} - -function formatAccessScope(scope?: string) { - const labels: Record = { - none: 'Нет доступа', - own: 'Свои', - assigned: 'Назначенные', - own_or_assigned: 'Свои и назначенные', - partner_all: 'Весь партнёр', - global_all: 'Все партнёры' - }; - return labels[scope ?? ''] ?? scope ?? '—'; -} - -function formatPartnerRole(role?: string) { - const labels: Record = { - partner_owner: 'Владелец партнёра', - partner_admin: 'Администратор партнёра', - partner_manager: 'Менеджер партнёра', - partner_analyst: 'Аналитик партнёра', - partner_viewer: 'Наблюдатель партнёра' - }; - return labels[role ?? ''] ?? role ?? '—'; -} diff --git a/src/pages/ResourcePlaceholderPage.tsx b/src/pages/ResourcePlaceholderPage.tsx index 762a4de..a494b7a 100644 --- a/src/pages/ResourcePlaceholderPage.tsx +++ b/src/pages/ResourcePlaceholderPage.tsx @@ -28,7 +28,7 @@ export default function ResourcePlaceholderPage({ {endpoints.map(endpoint => (
{endpoint}
-
Ожидает backend
+
В разработке
))} diff --git a/src/pages/SourcesPage.tsx b/src/pages/SourcesPage.tsx index ff95b3b..099ee67 100644 --- a/src/pages/SourcesPage.tsx +++ b/src/pages/SourcesPage.tsx @@ -19,6 +19,19 @@ function normalizeStatus(source: DataSource) { return source.status || 'active'; } +function formatStatus(source: DataSource) { + const status = normalizeStatus(source); + const labels: Record = { + active: 'Активен', + inactive: 'Неактивен', + paused: 'Приостановлен', + degraded: 'Есть проблемы', + pending: 'Ожидает', + error: 'Ошибка' + }; + return labels[status] ?? status; +} + export default function SourcesPage() { const currentPartnerId = useSessionStore(state => state.currentPartnerId); const canViewCameras = useSessionStore(state => state.hasPermission('cameras.view')); @@ -210,8 +223,8 @@ export default function SourcesPage() { onChange={e => setFilters(prev => ({ ...prev, status: e.target.value }))} > - - + + @@ -227,7 +240,7 @@ export default function SourcesPage() { Тип Название Статус - Entity + Объект
{filteredSources.map(source => ( @@ -243,7 +256,7 @@ export default function SourcesPage() { {source.title} - {normalizeStatus(source)} + {formatStatus(source)} {source.entity_type} #{source.entity_id} @@ -262,28 +275,28 @@ export default function SourcesPage() {

{selectedSource.title}

-
Source #{selectedSource.source_id}
+
Источник #{selectedSource.source_id}
- {normalizeStatus(selectedSource)} + {formatStatus(selectedSource)}
-
Entity type
+
Тип объекта
{selectedSource.entity_type}
-
Entity ID
+
ID объекта
{selectedSource.entity_id}
-
Partner
+
Партнёр
{selectedSource.partner_id ?? '—'}
-
Active
+
Активен
{selectedSource.is_active ? 'Да' : 'Нет'}
diff --git a/src/pages/UsersAdminPage.tsx b/src/pages/UsersAdminPage.tsx index 9f6f318..27044f7 100644 --- a/src/pages/UsersAdminPage.tsx +++ b/src/pages/UsersAdminPage.tsx @@ -5,6 +5,7 @@ import { Button, Field, Input, Select } from '@/components/UiKit'; import { BulkActionBar, BulkSelectionCheckbox } from '@/components/BulkActionBar'; import { useFeedbackStore } from '@/feedback/feedbackStore'; import { validateOptionalPhone } from '@/utils/phone'; +import { formatAccessScope, formatGlobalRole, formatPartnerRole } from '@/utils/accessLabels'; import type { AccessScope, PartnerMembership } from '@/types'; type AdminUser = { @@ -373,7 +374,7 @@ export default function UsersAdminPage() { const confirmed = await confirmAction({ title: 'Деактивировать пользователя?', - message: `Пользователь ${selectedUser.email} будет удалён из текущей таблицы backend.`, + message: `Пользователь ${selectedUser.email} будет удалён без возможности восстановления.`, confirmLabel: 'Деактивировать', cancelLabel: 'Отмена', tone: 'danger' @@ -548,7 +549,7 @@ export default function UsersAdminPage() {

Пользователи

-

Реальный список пользователей, редактирование профиля и статуса через текущий backend.

+

Пользователи, роли, статусы и доступы к партнёрским организациям.

-
Memberships
+
Доступы к партнёрам
{totalMemberships}
@@ -587,8 +588,8 @@ export default function UsersAdminPage() { @@ -673,8 +674,8 @@ export default function UsersAdminPage() { setCreateUserForm(prev => ({ ...prev, globalRole: e.target.value })); }} > - - + +
@@ -744,9 +745,9 @@ export default function UsersAdminPage() { {user.user_id} {user.email} - {user.global_role} + {formatGlobalRole(user.global_role)} - {user.is_active ? 'active' : 'inactive'} + {user.is_active ? 'Активен' : 'Неактивен'} {formatDate(user.created_at)}
@@ -764,7 +765,7 @@ export default function UsersAdminPage() {

{selectedUser.full_name || selectedUser.email}

-
User #{selectedUser.user_id}
+
Пользователь #{selectedUser.user_id}
{selectedUser.is_active ? 'Активен' : 'Неактивен'} @@ -781,11 +782,11 @@ export default function UsersAdminPage() {
{formatDate(selectedUser.updated_at)}
-
Email verified
+
Email подтверждён
{selectedUser.is_email_verified ? 'Да' : 'Нет'}
-
Memberships
+
Доступы к партнёрам
{selectedMemberships.length}
@@ -831,8 +832,8 @@ export default function UsersAdminPage() { setEditor(prev => prev ? ({ ...prev, globalRole: e.target.value }) : prev); }} > - - + + @@ -854,33 +855,33 @@ export default function UsersAdminPage() { {saveError &&
{saveError}
}
-

Partner Memberships

+

Доступы к партнёрам

{!canViewPartnerMembers && ( -
Недостаточно прав для просмотра членств в партнёрах.
+
Недостаточно прав для просмотра доступов к партнёрам.
)} - {membershipsLoading &&
Загрузка членств...
} + {membershipsLoading &&
Загрузка доступов...
} {!membershipsLoading && canViewPartnerMembers && (
- Partner - Role - Read - Write - Delete + Партнёр + Роль + Чтение + Изменение + Удаление
{selectedMemberships.map((membership, index) => (
{partnerNameById.get(membership.partner_id) ?? `Партнёр #${membership.partner_id}`} - {membership.role} - {membership.read_scope} - {membership.write_scope} - {membership.delete_scope} + {formatPartnerRole(membership.role)} + {formatAccessScope(membership.read_scope)} + {formatAccessScope(membership.write_scope)} + {formatAccessScope(membership.delete_scope)}
))} {!selectedMemberships.length && (
- Для этого пользователя backend пока не вернул членства в партнёрах. + У пользователя пока нет доступов к партнёрам.
)}
@@ -907,7 +908,7 @@ export default function UsersAdminPage() { ))} - + - + - + - +
diff --git a/src/pages/ZonesAdminPage.tsx b/src/pages/ZonesAdminPage.tsx index 6f6093c..b769eec 100644 --- a/src/pages/ZonesAdminPage.tsx +++ b/src/pages/ZonesAdminPage.tsx @@ -613,7 +613,7 @@ export default function ZonesAdminPage() { {freeCount ?? '—'} {zone.pay} - {zone.is_active === false ? 'paused' : 'active'} + {zone.is_active === false ? 'Неактивна' : 'Активна'} {formatZoneLocationType(zone.location_type)}
diff --git a/src/styles.css b/src/styles.css index fe328c1..a133b84 100644 --- a/src/styles.css +++ b/src/styles.css @@ -46,26 +46,104 @@ body { background: var(--bg); color: var(--text); font-family: Inter, system-ui, gap: 16px; } -.analytics-filter-row { +.analytics-controls-panel { + position: sticky; + top: 0; + z-index: 30; + display: grid; + gap: 10px; + background: rgba(255, 255, 255, 0.96); + backdrop-filter: blur(10px); + box-shadow: 0 10px 28px rgba(24, 33, 27, 0.14); +} + +.analytics-toolbar { display: flex; + align-items: end; + gap: 10px; flex-wrap: wrap; - gap: 12px; +} + +.analytics-refresh-control { + display: inline-grid; + grid-template-columns: max-content minmax(125px, auto); +} + +.analytics-refresh-button { + width: 46px; + min-width: 46px; + min-height: 42px; + padding: 0; + border-radius: 8px 0 0 8px; + color: var(--accent); + background: #f1f8f3; +} + +.analytics-refresh-button:hover:not(:disabled) { + color: #fff; + background: var(--accent); +} + +.analytics-refresh-icon { + width: 22px; + height: 22px; + fill: none; + stroke: currentColor; + stroke-width: 2; + stroke-linecap: round; + stroke-linejoin: round; +} + +.analytics-refresh-icon.loading { + animation: analytics-refresh-spin 0.9s linear infinite; +} + +@keyframes analytics-refresh-spin { + to { + transform: rotate(360deg); + } +} + +.analytics-toolbar-select { + min-height: 42px; + width: auto; + font-weight: 700; +} + +.analytics-refresh-select { + border-left: 0; + border-radius: 0 8px 8px 0; +} + +.analytics-period-select { + min-width: 230px; +} + +.analytics-granularity-select { + min-width: 205px; +} + +.analytics-forecast-cutoff { + min-width: 210px; +} + +.analytics-custom-range { + display: flex; align-items: end; + gap: 10px; + flex-wrap: wrap; + padding: 12px; + border: 1px solid var(--border); + border-radius: 8px; + background: var(--panel-soft); } -.analytics-toggle, .analytics-check-row { display: flex; align-items: center; gap: 10px; } -.analytics-toggle { - min-height: 42px; - color: var(--text); - font-weight: 700; -} - .analytics-picker-grid { display: grid; grid-template-columns: repeat(2, minmax(0, 1fr)); @@ -96,6 +174,28 @@ body { background: var(--bg); color: var(--text); font-family: Inter, system-ui, padding-right: 4px; } +.analytics-scope-hint { + color: var(--muted); + font-size: 13px; + line-height: 1.45; +} + +.analytics-clear-selection { + justify-self: start; + border: 1px solid var(--border); + border-radius: 8px; + background: #fff; + color: var(--text); + padding: 8px 12px; + cursor: pointer; +} + +.analytics-clear-selection:disabled { + color: var(--muted); + cursor: default; + opacity: 0.65; +} + .analytics-check-row > span { min-width: 0; display: grid; @@ -110,6 +210,17 @@ body { background: var(--bg); color: var(--text); font-family: Inter, system-ui, grid-template-columns: repeat(5, minmax(150px, 1fr)); } +.analytics-kpi-grid .metric-value { + overflow-wrap: anywhere; +} + +.analytics-kpi-exact { + margin-top: 6px; + color: var(--muted); + font-size: 11px; + line-height: 1.35; +} + .analytics-dashboard-grid { display: grid; grid-template-columns: minmax(0, 1.35fr) minmax(360px, 1fr); @@ -118,7 +229,7 @@ body { background: var(--bg); color: var(--text); font-family: Inter, system-ui, .analytics-chart-grid { display: grid; - grid-template-columns: repeat(2, minmax(0, 1fr)); + grid-template-columns: minmax(0, 1fr); gap: 18px; } @@ -234,13 +345,14 @@ body { background: var(--bg); color: var(--text); font-family: Inter, system-ui, .analytics-map-layout { display: grid; - grid-template-columns: minmax(0, 1fr) 280px; - gap: 14px; - min-height: 440px; + grid-template-rows: auto minmax(460px, 1fr); + gap: 12px; + min-height: 560px; } .analytics-map-host { - min-height: 440px; + min-height: 460px; + height: 100%; border-radius: 8px; overflow: hidden; border: 1px solid var(--border); @@ -272,31 +384,99 @@ body { background: var(--bg); color: var(--text); font-family: Inter, system-ui, gap: 2px; } -.analytics-health-table-wrap { - max-height: 440px; +.analytics-health-content { + display: grid; + gap: 8px; + min-width: 0; +} + +.analytics-health-count { + color: var(--muted); + font-size: 12px; +} + +.table-scroll.analytics-health-table-wrap { + max-height: 520px; + overflow-x: auto; + overflow-y: scroll; + overscroll-behavior: contain; + scrollbar-gutter: stable; + scrollbar-color: var(--accent) var(--panel-soft); + scrollbar-width: auto; +} + +.analytics-health-table-wrap::-webkit-scrollbar { + width: 10px; + height: 10px; +} + +.analytics-health-table-wrap::-webkit-scrollbar-track { + background: var(--panel-soft); + border-radius: 8px; +} + +.analytics-health-table-wrap::-webkit-scrollbar-thumb { + background: var(--accent); + border: 2px solid var(--panel-soft); + border-radius: 8px; +} + +.analytics-health-table-wrap .table-header { + position: sticky; + top: 0; + z-index: 2; + background: var(--panel); } .table-header.analytics-health-table, .table-row.analytics-health-table { - grid-template-columns: 90px 90px 80px 80px 90px 110px 150px 170px 90px 140px 130px 130px; - min-width: 1370px; + grid-template-columns: 70px 240px 160px 80px 80px 90px 110px 150px 170px 90px 140px 130px; + min-width: 1510px; } -.analytics-status-online { +.analytics-health-camera { + display: grid; + gap: 2px; + min-width: 0; +} + +.analytics-health-camera .small { + overflow-wrap: anywhere; +} + +.analytics-health-table .status-pill { + background: #eef1f0; + color: #46514b; +} + +.analytics-health-table .status-pill.analytics-status-online { background: #e7f7ec; color: #128a45; } -.analytics-status-stale, -.analytics-status-low_confidence { +.analytics-health-table .status-pill.analytics-status-stale { background: #fff8e6; color: #9a6700; } -.analytics-status-offline, -.analytics-status-no_data { +.analytics-health-table .status-pill.analytics-status-low_confidence { + background: #f4eafe; + color: #7e22ce; +} + +.analytics-health-table .status-pill.analytics-status-offline { background: #f3f4f6; - color: #58615d; + color: #374151; +} + +.analytics-health-table .status-pill.analytics-status-no_data { + background: #e8f1fb; + color: #1d4f91; +} + +.analytics-health-table .status-pill.analytics-status-error { + background: #fdecec; + color: #b42318; } .analytics-detail-grid { @@ -307,6 +487,10 @@ body { background: var(--bg); color: var(--text); font-family: Inter, system-ui, grid-template-columns: repeat(2, minmax(150px, 1fr)); } +.analytics-forecast-quality-metrics { + margin-bottom: 14px; +} + .analytics-zone-geometry { display: grid; gap: 12px; @@ -328,6 +512,37 @@ body { background: var(--bg); color: var(--text); font-family: Inter, system-ui, gap: 12px; } +.analytics-snapshot-tabs { + display: flex; + width: fit-content; + max-width: 100%; + padding: 3px; + gap: 3px; + border: 1px solid var(--border); + border-radius: 8px; + background: #eef6f0; + overflow-x: auto; +} + +.analytics-snapshot-tabs button { + min-height: 34px; + padding: 6px 12px; + border: 0; + border-radius: 6px; + background: transparent; + color: #4f5c55; + font: inherit; + font-weight: 700; + white-space: nowrap; + cursor: pointer; +} + +.analytics-snapshot-tabs button.active { + background: #fff; + color: var(--accent); + box-shadow: 0 1px 4px rgba(18, 35, 24, 0.12); +} + .analytics-snapshot { width: 100%; max-height: 560px; @@ -353,6 +568,67 @@ body { background: var(--bg); color: var(--text); font-family: Inter, system-ui, object-fit: contain; } +.analytics-snapshot-fullscreen { + grid-template-rows: auto minmax(0, 1fr); + place-items: stretch; + gap: 16px; + padding: 18px; +} + +.analytics-snapshot-fullscreen-toolbar { + min-width: 0; + padding-right: 56px; + display: flex; + align-items: center; + gap: 12px; + color: #fff; +} + +.analytics-snapshot-fullscreen-toolbar .analytics-snapshot-tabs { + flex: 1 1 auto; + width: auto; + background: rgba(255, 255, 255, 0.12); + border-color: rgba(255, 255, 255, 0.3); +} + +.analytics-snapshot-fullscreen-toolbar .analytics-snapshot-tabs button { + color: rgba(255, 255, 255, 0.82); +} + +.analytics-snapshot-fullscreen-toolbar .analytics-snapshot-tabs button.active { + background: #fff; + color: #102015; +} + +.analytics-snapshot-fullscreen-toolbar .button { + flex: 0 0 auto; + color: #fff; + border-color: rgba(255, 255, 255, 0.45); + background: rgba(255, 255, 255, 0.12); +} + +.analytics-snapshot-fullscreen-stage { + min-height: 0; + display: grid; + place-items: stretch; + overflow: hidden; +} + +.analytics-snapshot-fullscreen-stage img { + width: 100%; + height: 100%; + max-width: none; + max-height: none; + object-fit: cover; +} + +.analytics-snapshot-fullscreen-state { + color: #fff; + font-weight: 700; + align-self: center; + justify-self: center; +} + .fullscreen-close { position: fixed; top: 18px; @@ -368,6 +644,17 @@ body { background: var(--bg); color: var(--text); font-family: Inter, system-ui, cursor: pointer; } +@media (max-width: 760px) { + .analytics-snapshot-fullscreen-toolbar { + align-items: stretch; + flex-direction: column; + } + + .analytics-snapshot-fullscreen-toolbar .analytics-snapshot-tabs { + width: 100%; + } +} + .table-header.analytics-detections-table, .table-row.analytics-detections-table { grid-template-columns: 170px 110px 110px 90px 90px 90px 150px 90px 100px; @@ -546,11 +833,29 @@ hr { border: none; height: 1px; background: var(--border); margin: 10px 0; } align-items: center; } -.viewport-fullscreen-button { +.viewport-fullscreen-button, +.viewport-center-button { padding: 4px 8px; font-size: 12px; } +.viewport-center-button { + width: 30px; + height: 28px; + padding: 5px; + display: inline-grid; + place-items: center; +} + +.viewport-center-button svg { + width: 16px; + height: 16px; + fill: none; + stroke: currentColor; + stroke-width: 1.8; + stroke-linecap: round; +} + .konva-wrap { width: 100%; height: 100%; } .image-viewport canvas { @@ -582,7 +887,9 @@ hr { border: none; height: 1px; background: var(--border); margin: 10px 0; } grid-template-areas: "admin-sidebar admin-header" "admin-sidebar admin-content"; - min-height: 100%; + height: 100%; + min-height: 0; + overflow: hidden; background: var(--bg); } @@ -609,7 +916,10 @@ hr { border: none; height: 1px; background: var(--border); margin: 10px 0; } border-radius: 8px; display: grid; place-items: center; - background: #d8f5dd; + background-image: url("/parktrack.png"); + background-size: cover; + background-position: center; + background-repeat: no-repeat; color: #0f5f2d; font-weight: 800; } @@ -675,14 +985,19 @@ hr { border: none; height: 1px; background: var(--border); margin: 10px 0; } min-width: 0; min-height: 0; overflow: auto; - padding: 22px; + padding: 18px; +} + +.admin-content-cameras { + overflow: hidden; + padding: 12px; } .page-stack { display: flex; flex-direction: column; - gap: 18px; - max-width: 1180px; + gap: 16px; + max-width: 1440px; } .page-heading { @@ -744,6 +1059,7 @@ hr { border: none; height: 1px; background: var(--border); margin: 10px 0; } .section-panel { padding: 14px; + min-width: 0; } .section-panel h2 { @@ -1116,6 +1432,30 @@ hr { border: none; height: 1px; background: var(--border); margin: 10px 0; } overflow: hidden; } +.camera-map-grid { + grid-template-columns: minmax(340px, 380px) minmax(0, 1fr); + height: 100%; + min-height: 0; + background: var(--panel); +} + +.camera-map-grid > .camera-admin-sidebar { + min-height: 0; + overflow-y: auto; + overscroll-behavior: contain; +} + +.camera-map-grid > .canvas { + min-height: 0; + overflow: hidden; + background: var(--panel); +} + +.camera-map-grid .yandex-map-host { + min-height: 0; + background: var(--panel); +} + .camera-admin-sidebar { display: flex; flex-direction: column; @@ -1388,7 +1728,7 @@ hr { border: none; height: 1px; background: var(--border); margin: 10px 0; } width: 100%; height: 100%; min-height: 0; - object-fit: contain; + object-fit: cover; border-color: rgba(255, 255, 255, 0.18); } @@ -1457,11 +1797,35 @@ hr { border: none; height: 1px; background: var(--border); margin: 10px 0; } } .dashboard-metric-grid { - grid-template-columns: repeat(6, minmax(140px, 1fr)); + grid-template-columns: repeat(4, minmax(140px, 1fr)); +} + +.dashboard-primary-grid { + grid-template-columns: minmax(0, 1.7fr) minmax(280px, 0.8fr); } .dashboard-summary-grid { - grid-template-columns: 1.05fr 1fr 1fr; + grid-template-columns: repeat(3, minmax(0, 1fr)); +} + +.dashboard-summary-grid > * { + min-width: 0; +} + +.dashboard-panel-heading { + display: flex; + align-items: center; + justify-content: space-between; + gap: 12px; + margin-bottom: 12px; +} + +.dashboard-panel-heading h2 { + margin: 0; +} + +.dashboard-watchlist { + grid-template-columns: repeat(2, minmax(0, 1fr)); } .dashboard-summary-list, @@ -1483,16 +1847,33 @@ hr { border: none; height: 1px; background: var(--border); margin: 10px 0; } } .dashboard-list-item { + display: grid; + grid-template-columns: minmax(0, 1fr) max-content; + width: 100%; + min-width: 0; + max-width: 100%; cursor: pointer; color: inherit; text-align: left; font: inherit; } +.dashboard-list-item > :first-child { + min-width: 0; +} + +.dashboard-list-item strong, +.dashboard-list-item .small { + overflow-wrap: anywhere; +} + .dashboard-item-meta { display: grid; gap: 6px; justify-items: end; + min-width: 0; + max-width: 150px; + text-align: right; } .dashboard-action-grid { @@ -1502,8 +1883,9 @@ hr { border: none; height: 1px; background: var(--border); margin: 10px 0; } } .dashboard-action-card { - display: grid; - gap: 6px; + display: flex; + align-items: center; + min-height: 58px; padding: 14px; border: 1px solid var(--border); border-radius: 8px; @@ -1632,6 +2014,10 @@ hr { border: none; height: 1px; background: var(--border); margin: 10px 0; } padding: 12px; } + .analytics-controls-panel { + position: static; + } + .admin-nav { flex-direction: row; overflow: auto; @@ -1651,6 +2037,7 @@ hr { border: none; height: 1px; background: var(--border); margin: 10px 0; } } .dashboard-metric-grid, + .dashboard-primary-grid, .dashboard-summary-grid { grid-template-columns: 1fr; } @@ -1660,10 +2047,39 @@ hr { border: none; height: 1px; background: var(--border); margin: 10px 0; } grid-template-columns: 1fr; } + .dashboard-watchlist, .dashboard-action-grid { grid-template-columns: 1fr; } + .analytics-toolbar, + .analytics-custom-range { + align-items: stretch; + flex-direction: column; + } + + .analytics-refresh-control { + grid-template-columns: minmax(0, 1fr) minmax(125px, 0.7fr); + width: 100%; + } + + .analytics-toolbar-select, + .analytics-forecast-cutoff, + .analytics-toolbar > .col, + .analytics-custom-range > .col { + width: 100%; + } + + .dashboard-list-item { + grid-template-columns: minmax(0, 1fr); + } + + .dashboard-item-meta { + max-width: none; + justify-items: start; + text-align: left; + } + .zones-admin-grid { grid-template-columns: 1fr; } @@ -1694,4 +2110,22 @@ hr { border: none; height: 1px; background: var(--border); margin: 10px 0; } "sidebar" "canvas"; } + + .admin-content-cameras { + overflow: auto; + padding: 12px; + } + + .camera-map-grid { + height: auto; + min-height: 720px; + } + + .camera-map-grid > .camera-admin-sidebar { + overflow: visible; + } + + .camera-map-grid > .canvas { + min-height: 460px; + } } diff --git a/src/utils/accessLabels.ts b/src/utils/accessLabels.ts new file mode 100644 index 0000000..f9c6cc6 --- /dev/null +++ b/src/utils/accessLabels.ts @@ -0,0 +1,33 @@ +const globalRoleLabels: Record = { + admin: 'Администратор', + user: 'Пользователь' +}; + +const partnerRoleLabels: Record = { + partner_owner: 'Владелец партнёра', + partner_admin: 'Администратор партнёра', + partner_manager: 'Менеджер партнёра', + partner_analyst: 'Аналитик партнёра', + partner_viewer: 'Наблюдатель партнёра' +}; + +const accessScopeLabels: Record = { + none: 'Нет доступа', + own: 'Свои', + assigned: 'Назначенные', + own_or_assigned: 'Свои и назначенные', + partner_all: 'Весь партнёр', + global_all: 'Все партнёры' +}; + +export function formatGlobalRole(role?: string) { + return globalRoleLabels[role ?? ''] ?? role ?? '—'; +} + +export function formatPartnerRole(role?: string) { + return partnerRoleLabels[role ?? ''] ?? role ?? '—'; +} + +export function formatAccessScope(scope?: string) { + return accessScopeLabels[scope ?? ''] ?? scope ?? '—'; +}