diff --git a/Frontend/src/App.tsx b/Frontend/src/App.tsx index cf192ba..a0a25b4 100644 --- a/Frontend/src/App.tsx +++ b/Frontend/src/App.tsx @@ -15,7 +15,7 @@ function App() { useEffect(() => { if (BYPASS_LOGIN) { - setIsLoading(true); + setIsLoading(false); return; } diff --git a/Frontend/src/components/AlertCard.tsx b/Frontend/src/components/AlertCard.tsx new file mode 100644 index 0000000..a0ca5b0 --- /dev/null +++ b/Frontend/src/components/AlertCard.tsx @@ -0,0 +1,103 @@ +import React from 'react'; + +interface AlertCardProps { + criticalCount: number; + warningCount: number; +} + +const AlertCard: React.FC = ({ criticalCount, warningCount }) => { + const hasCritical = criticalCount > 0; + const hasWarning = warningCount > 0; + const hasAlert = hasCritical || hasWarning; + + // ปกติ + if (!hasAlert) { + return ( +
+
+ สถานะระบบ +
+
+ + ปกติทุกสถานี +
+
+ ); + } + + // มีแจ้งเตือน + const accentColor = hasCritical ? 'var(--color-status-critical)' : 'var(--color-status-warning)'; + const bgColor = hasCritical ? 'rgba(239,68,68,0.09)' : 'rgba(255,174,0,0.08)'; + + return ( +
+
+ + + แจ้งเตือน + +
+ +
+ {hasCritical && ( +
+ + {criticalCount} + + สถานีวิกฤต +
+ )} + {hasWarning && ( +
+ + {warningCount} + + สถานีเฝ้าระวัง +
+ )} +
+
+ ); +}; + +export default AlertCard; \ No newline at end of file diff --git a/Frontend/src/components/Dashboard-StationTable.tsx b/Frontend/src/components/Dashboard-StationTable.tsx index 7330fc1..2109993 100644 --- a/Frontend/src/components/Dashboard-StationTable.tsx +++ b/Frontend/src/components/Dashboard-StationTable.tsx @@ -17,10 +17,56 @@ interface TableRowData { rainfall: string; status: "normal" | "warning" | "critical"; rawTimestamp: string; + signal: "online" | "offline"; } const ROW_LIMIT = 20; +// --- StatusBadge sub-component --- +const StatusBadge: React.FC<{ status: "normal" | "warning" | "critical" }> = ({ status }) => { + const map = { + normal: { label: "ปกติ", cls: styles.badgeNormal }, + warning: { label: "เฝ้าระวัง", cls: styles.badgeWarning }, + critical: { label: "วิกฤต", cls: styles.badgeCritical }, + }; + const { label, cls } = map[status]; + return ( + + + {label} + + ); +}; + +// --- SignalIcon sub-component --- +const SignalIcon: React.FC<{ signal: "online" | "offline" }> = ({ signal }) => { + const isOnline = signal === "online"; + return ( +
+ +
+ ); +}; + +// --- BatteryIcon sub-component --- +const BatteryIcon: React.FC<{ signal: "online" | "offline" }> = ({ signal }) => { + const isOnline = signal === "online"; + return ( +
+ +
+ ); +}; + +// --- Main Component --- const StationTable: React.FC = React.memo(({ waterData, rainData, @@ -35,7 +81,6 @@ const StationTable: React.FC = React.memo(({ const date = new Date(isoString); const today = new Date(); const isToday = date.toDateString() === today.toDateString(); - const timeStr = date .toLocaleTimeString("en-GB", { hour: "2-digit", minute: "2-digit" }) .replace(":", "."); @@ -43,16 +88,13 @@ const StationTable: React.FC = React.memo(({ day: "numeric", month: "short", }); - return `${isToday ? "Today" : dateStr}, ${timeStr}`; } catch { return isoString; } }; - const calculateStatus = ( - water: string, - ): "normal" | "warning" | "critical" => { + const calculateStatus = (water: string): "normal" | "warning" | "critical" => { const val = parseFloat(water); if (isNaN(val)) return "normal"; if (val >= 5.0) return "critical"; @@ -60,6 +102,7 @@ const StationTable: React.FC = React.memo(({ return "normal"; }; + // สมมติว่าข้อมูลจริงๆ จะมี signal field; ตอนนี้ใช้ค่า default = online for (const item of waterData) { dataMap.set(item.monitorTime, { rawTimestamp: item.monitorTime, @@ -67,6 +110,7 @@ const StationTable: React.FC = React.memo(({ waterLevel: parseFloat(item.monitorValue).toFixed(3), rainfall: "-", name: stationName, + signal: "online", }); } @@ -76,8 +120,8 @@ const StationTable: React.FC = React.memo(({ timestamp: formatDisplayTime(item.monitorTime), waterLevel: "-", name: stationName, + signal: "online", }; - existing.rainfall = parseFloat(item.monitorValue).toFixed(3); dataMap.set(item.monitorTime, existing); } @@ -100,14 +144,11 @@ const StationTable: React.FC = React.memo(({ }, [waterData, rainData, stationName]); const handleExportCSV = useCallback(() => { - const headers = [ - "Station Name,Timestamp,Water Level (m),Rainfall (mm/h),Status", - ]; + const headers = ["Station Name,Timestamp,Water Level (m),Rainfall (mm/h),Status"]; const rows = tableData.map( (row) => `${row.name},${row.rawTimestamp},${row.waterLevel},${row.rainfall},${row.status}`, ); - const csvContent = "data:text/csv;charset=utf-8," + [headers, ...rows].join("\n"); const encodedUri = encodeURI(csvContent); @@ -119,34 +160,28 @@ const StationTable: React.FC = React.memo(({ document.body.removeChild(link); }, [tableData]); + // helper: CSS class สำหรับแถว + const rowClass = (status: "normal" | "warning" | "critical") => { + if (status === "critical") return `${styles.dataRow} ${styles.rowCritical}`; + if (status === "warning") return `${styles.dataRow} ${styles.rowWarning}`; + return `${styles.dataRow} ${styles.rowNormal}`; + }; + + // helper: CSS class สำหรับตัวเลข + const valueClass = (status: "normal" | "warning" | "critical") => { + if (status === "critical") return styles.valueCritical; + if (status === "warning") return styles.valueWarning; + return styles.valueNormal; + }; + if (isLoading) { - return
Loading Data...
; + return
กำลังโหลดข้อมูล...
; } return (
-
-
@@ -154,10 +189,10 @@ const StationTable: React.FC = React.memo(({
ชื่อสถานี
เวลา
-
สัญญาณ
-
แบตเตอรี่
-
ระดับน้ำ(เมตร)
-
ปริมาณน้ำฝน(มิลลิเมตร/ชั่วโมง)
+
สัญญาณ
+
แบตเตอรี่
+
ระดับน้ำ (ม.)
+
ปริมาณน้ำฝน (มม./ชม.)
@@ -165,28 +200,31 @@ const StationTable: React.FC = React.memo(({
ไม่มีข้อมูลสถานี
) : ( tableData.map((row) => ( -
-
- {row.name} - {row.status !== "normal" && ( - - ({row.status}) - - )} +
+ {/* ชื่อสถานี + Badge */} +
+ {row.name} +
+ + {/* เวลา */}
{row.timestamp}
-
สัญญาณ
-
แบตเตอรี่
+ {/* สัญญาณ */} + + + {/* แบตเตอรี่ */} + -
{row.waterLevel}
-
{row.rainfall}
+ {/* ระดับน้ำ */} +
+ {row.waterLevel} +
+ + {/* ปริมาณน้ำฝน */} +
+ {row.rainfall} +
)) )} diff --git a/Frontend/src/components/Deviceservice.ts b/Frontend/src/components/Deviceservice.ts new file mode 100644 index 0000000..9259932 --- /dev/null +++ b/Frontend/src/components/Deviceservice.ts @@ -0,0 +1,310 @@ +export interface DeviceLatestResponse { + code: number; + monitorValue: string; + monitorTime: string; +} + +export interface DeviceRangeData { + monitorValue: string; + monitorTime: string; +} + +export interface DeviceRangeResponse { + code: number; + data: DeviceRangeData[]; +} + +export interface DeviceInfoResponse { + monitorName: string; + customName: string; + warningLevel: number; + deviceLocation: { + latitude: string; + longitude: string; + }; +} + +export interface UserDeviceInfo { + deviceId: string; + monitorName: string; + customName: string; + deviceLocation: { + latitude: string; + longitude: string; + }; +} + +export interface RainProbabilityData { + time: string; + sun: number; + mon: number; + tue: number; + wed: number; + thu: number; + fri: number; + sat: number; +} + +export interface StationDeviceInfo { + stationId: string; + stationName: string; + latitude: string; + longitude: string; + deviceId: string; + deviceName: string; + monitorItem: string; +} + +export interface StationLatestInfo extends StationDeviceInfo { + monitorValue: string; + monitorTime: string; + signal: 'online' | 'offline'; + battery: number; +} + +const API_BASE_URL = '/api/v2/device'; + +const getHeaders = () => { + const token = localStorage.getItem('accessToken'); + return { + 'Content-Type': 'application/json', + 'Authorization': token ? `Bearer ${token}` : '', + }; +}; + +const handleResponse = async (response: Response) => { + if (!response.ok) { + throw new Error(`API Error: ${response.status}`); + } + return response.json(); +}; + +// ---- Mock toggle: ควบคุมจาก .env (VITE_USE_MOCK_DATA=true) ---- +export let USE_MOCK_DATA = import.meta.env.VITE_USE_MOCK_DATA === 'true'; + +export const setUseMockData = (isMock: boolean) => { + USE_MOCK_DATA = isMock; + console.log(`System Mode changed to: ${isMock ? 'MOCK' : 'REAL API'}`); +}; + +// ---- ข้อมูล 5 สถานีลำน้ำกวง (ใช้ใน Mock mode) ---- +// threshold: critical >= 5.0 ม., warning >= 3.5 ม. +const MOCK_STATIONS_RAW = [ + { stationId: 'ST-K1', stationName: 'สถานีสะพานดำ', latitude: '18.7012', longitude: '99.0876', deviceId: 'DEV-K1', baseWater: 2.10, deviceName: 'DEV-K1', monitorItem: 'water_level' }, + { stationId: 'ST-K2', stationName: 'สถานีศาลากลางลำพูน', latitude: '18.6234', longitude: '99.0412', deviceId: 'DEV-K2', baseWater: 5.10, deviceName: 'DEV-K2', monitorItem: 'water_level' }, + { stationId: 'ST-K3', stationName: 'สถานีสะพานท่าขาม', latitude: '18.5867', longitude: '99.0232', deviceId: 'DEV-K3', baseWater: 3.60, deviceName: 'DEV-K3', monitorItem: 'water_level' }, + { stationId: 'ST-K4', stationName: 'สถานีประตูป่า', latitude: '18.5712', longitude: '98.9834', deviceId: 'DEV-K4', baseWater: 1.80, deviceName: 'DEV-K4', monitorItem: 'water_level' }, + { stationId: 'ST-K5', stationName: 'สถานีอุโมงค์', latitude: '18.5489', longitude: '98.9612', deviceId: 'DEV-K5', baseWater: 5.10, deviceName: 'DEV-K5', monitorItem: 'water_level' }, +]; + +// ---- DeviceService (real API + mock routing) ---- +export const DeviceService = { + getHistory: async ( + _deviceId: string, + _deviceSecretKey: string, + _monitorItem: string, + _start: number, + _end: number + ): Promise => { + if (USE_MOCK_DATA) { + return MockDeviceService.getHistory(_deviceId, _deviceSecretKey, _monitorItem, _start, _end); + } + const response = await fetch(`${API_BASE_URL}/batch`, { + method: 'POST', + headers: getHeaders(), + body: JSON.stringify({ + deviceList: [{ + deviceId: _deviceId, + deviceSecretKey: _deviceSecretKey, + monitorItem: _monitorItem + }], + start: _start, + end: _end + }), + }); + const result = await handleResponse(response); + const deviceResult = result.data?.find((d: { deviceId: string }) => d.deviceId === _deviceId); + if (!deviceResult?.data) return []; + return deviceResult.data + .filter((item: { monitorItem: string }) => item.monitorItem === _monitorItem) + .map(({ monitorValue, monitorTime }: { monitorValue: string; monitorTime: string }) => ({ + monitorValue, + monitorTime + })); + }, + + getStationInfo: async (deviceId: string): Promise => { + if (USE_MOCK_DATA) { + return MockDeviceService.getStationInfo(deviceId); + } + const response = await fetch('/api/v2/device/info', { + method: 'POST', + headers: getHeaders(), + body: JSON.stringify({ deviceId }), + }); + return handleResponse(response); + }, + + getUserDevices: async (): Promise => { + if (USE_MOCK_DATA) { + return MOCK_STATIONS_RAW.map(s => ({ + deviceId: s.deviceId, + monitorName: s.stationId, + customName: s.stationName, + deviceLocation: { latitude: s.latitude, longitude: s.longitude }, + })); + } + const response = await fetch('/api/v2/user/owns', { + method: 'GET', + headers: getHeaders(), + }); + const result = await handleResponse(response); + return result.deviceInfo || []; + }, + + getRainProbability: async (): Promise => { + return MockDeviceService.getRainProbability(); + }, + + // คืน StationDeviceInfo ทั้ง 5 สถานีใน mock mode + getStations: async (): Promise => { + if (USE_MOCK_DATA) { + return MockDeviceService.getStations(); + } + const response = await fetch('/api/v2/stations/', { + method: 'GET', + headers: getHeaders(), + }); + const result = await handleResponse(response); + return result.data || []; + }, + + // คืน StationLatestInfo ทั้ง 5 สถานีพร้อม monitorValue ใน mock mode + getLatestStations: async (): Promise => { + if (USE_MOCK_DATA) { + return MockDeviceService.getLatestStations(); + } + const response = await fetch('/api/v2/stations/latest', { + method: 'GET', + headers: getHeaders(), + }); + const result = await handleResponse(response); + return result.data || []; + } +}; + +// ---- MockDeviceService ---- +export const MockDeviceService = { + + getStationInfo: async (_deviceId: string): Promise => { + await new Promise(resolve => setTimeout(resolve, 300)); + const station = MOCK_STATIONS_RAW.find(s => s.deviceId === _deviceId) ?? MOCK_STATIONS_RAW[0]; + return { + monitorName: station.stationId, + customName: station.stationName, + warningLevel: 3.5, + deviceLocation: { + latitude: station.latitude, + longitude: station.longitude, + }, + }; + }, + + // สุ่มข้อมูลประวัติ 24 ชม. โดยใช้ baseWater ของแต่ละสถานีเป็นฐาน + getHistory: async ( + deviceId: string, + _deviceSecretKey: string, + monitorItem: string, + _start: number, + end: number + ): Promise => { + await new Promise(resolve => setTimeout(resolve, 400)); + + const station = MOCK_STATIONS_RAW.find(s => s.deviceId === deviceId); + const baseWater = station?.baseWater ?? 3.0; + + const mockData: DeviceRangeData[] = []; + const oneHour = 60 * 60 * 1000; + + for (let i = 23; i >= 0; i--) { + const time = end - (i * oneHour); + let value = 0; + + if (monitorItem === 'water_level') { + // สุ่มรอบ baseWater ±0.5 ม. เพื่อให้กราฟดูสมจริง + value = baseWater + (Math.random() - 0.5) * 1.0; + value = Math.max(0.1, value); + } else { + // ปริมาณฝน: สุ่มสูงขึ้นช่วงกลางวัน + const hour = new Date(time).getHours(); + const rainChance = hour >= 13 && hour <= 18 ? 0.6 : 0.2; + value = Math.random() < rainChance ? Math.random() * 25 : 0; + } + + mockData.push({ + monitorTime: new Date(time).toISOString(), + monitorValue: value.toFixed(2), + }); + } + + return mockData; + }, + + // คืน StationDeviceInfo ทั้ง 5 สถานี + getStations: async (): Promise => { + await new Promise(resolve => setTimeout(resolve, 200)); + return MOCK_STATIONS_RAW.map(s => ({ + stationId: s.stationId, + stationName: s.stationName, + latitude: s.latitude, + longitude: s.longitude, + deviceId: s.deviceId, + deviceName: s.deviceName, + monitorItem: s.monitorItem, + })); + }, + + // คืน StationLatestInfo ทั้ง 5 สถานี พร้อมค่าระดับน้ำปัจจุบัน + getLatestStations: async (): Promise => { + await new Promise(resolve => setTimeout(resolve, 300)); + const now = new Date().toISOString(); + return MOCK_STATIONS_RAW.map(s => { + // สุ่มเล็กน้อยรอบ baseWater เพื่อให้ค่าไม่ซ้ำกันทุก reload + const jitter = (Math.random() - 0.5) * 0.3; + const water = Math.max(0.1, s.baseWater + jitter); + return { + stationId: s.stationId, + stationName: s.stationName, + latitude: s.latitude, + longitude: s.longitude, + deviceId: s.deviceId, + deviceName: s.deviceName, + monitorItem: s.monitorItem, + monitorValue: water.toFixed(2), + monitorTime: now, + signal: 'online' as const, + battery: Math.floor(70 + Math.random() * 30), + }; + }); + }, + + getRainProbability: async (): Promise => { + await new Promise(resolve => setTimeout(resolve, 200)); + const rows: RainProbabilityData[] = []; + for (let h = 1; h <= 24; h++) { + const hour = h % 24; + const base = hour >= 6 && hour <= 18 ? 30 : 10; + rows.push({ + time: `${String(hour).padStart(2, '0')}:00`, + sun: Math.round(base + Math.random() * 40), + mon: Math.round(base + Math.random() * 40), + tue: Math.round(base + Math.random() * 40), + wed: Math.round(base + Math.random() * 40), + thu: Math.round(base + Math.random() * 40), + fri: Math.round(base + Math.random() * 40), + sat: Math.round(base + Math.random() * 40), + }); + } + return rows; + }, +}; \ No newline at end of file diff --git a/Frontend/src/components/LoginForm.tsx b/Frontend/src/components/LoginForm.tsx index 86752cc..9f39454 100644 --- a/Frontend/src/components/LoginForm.tsx +++ b/Frontend/src/components/LoginForm.tsx @@ -56,6 +56,38 @@ export default function LoginForm({ onLoginSuccess }: LoginFormProps) { return (
+
@@ -125,4 +157,4 @@ export default function LoginForm({ onLoginSuccess }: LoginFormProps) {
); -} +} \ No newline at end of file diff --git a/Frontend/src/components/MapGIS.tsx b/Frontend/src/components/MapGIS.tsx index c9eda28..3070f35 100644 --- a/Frontend/src/components/MapGIS.tsx +++ b/Frontend/src/components/MapGIS.tsx @@ -1,5 +1,6 @@ import { useState, useEffect, useMemo } from "react"; -import { MapContainer, TileLayer, Marker, Popup } from "react-leaflet"; +import { useNavigate } from "react-router-dom"; +import { MapContainer, TileLayer, Marker, Popup, useMap } from "react-leaflet"; import "leaflet/dist/leaflet.css"; import L from "leaflet"; import { DeviceService } from "../service/deviceService"; @@ -34,18 +35,47 @@ interface MapStation { rainfall: number; } +// ---- Auto-zoom ให้แผนที่พอดีกับหมุดทั้งหมด ---- +const UpdateMapBounds = ({ stations }: { stations: MapStation[] }) => { + const map = useMap(); + useEffect(() => { + if (stations.length === 0) return; + const bounds = L.latLngBounds(stations.map(s => [s.lat, s.lng])); + map.fitBounds(bounds, { padding: [50, 50] }); + }, [map, stations]); + return null; +}; + +const FlyToStation = ({ selectedId, stations }: { selectedId: string | null, stations: MapStation[] }) => { + const map = useMap(); + useEffect(() => { + if (selectedId) { + const target = stations.find(s => s.id === selectedId); + if (target) { + map.flyTo([target.lat, target.lng], 16, { animate: true, duration: 1.5 }); + } + } + }, [selectedId, stations, map]); + return null; +}; + + + + // ---- Main Component ---- const MapGIS = () => { - const [search, setSearch] = useState(""); - const [stations, setStations] = useState([]); + const navigate = useNavigate(); + const [search, setSearch] = useState(""); + const [stations, setStations] = useState([]); const [isLoading, setIsLoading] = useState(true); + const [selectedStationId, setSelectedStationId] = useState(null); useEffect(() => { const fetchStations = async () => { try { - const stationDevices = await DeviceService.getStations(); + const latestData = await DeviceService.getLatestStations(); - if (stationDevices.length === 0) { + if (latestData.length === 0) { setStations([]); setIsLoading(false); return; @@ -53,21 +83,27 @@ const MapGIS = () => { // Group by stationId and take first device's data for map position const uniqueStations = new Map(); - for (const s of stationDevices) { + for (const s of latestData) { if (!uniqueStations.has(s.stationId)) { const lat = parseFloat(s.latitude) || 18.78; const lng = parseFloat(s.longitude) || 99.005; - // Determine status based on water level (mock for now) - // In real app, you'd fetch the latest water level for each station + // ดึงค่าระดับน้ำจากข้อมูลล่าสุด + const waterLevel = parseFloat(s.monitorValue) || 0; + + // คำนวณสถานะตามเกณฑ์เดียวกับ Design System + let status: "normal" | "warning" | "critical" = "normal"; + if (waterLevel > 3.5) status = "critical"; + else if (waterLevel >= 2.5) status = "warning"; + uniqueStations.set(s.stationId, { id: s.stationId, name: s.stationName || 'Unknown Station', detail: `${lat.toFixed(4)}, ${lng.toFixed(4)}`, lat, lng, - status: "normal", // Could be derived from real water level - waterLevel: 0, + status, + waterLevel, rainfall: 0, }); } @@ -105,17 +141,26 @@ const MapGIS = () => {
+ + {filtered.map((s) => ( - + navigate(`/station?id=${s.id}`) + }} + >
{s.name}
@@ -129,7 +174,7 @@ const MapGIS = () => { เมตร
- ปริมาณน้ำฝนสะสม + สถานะ
@@ -177,9 +222,14 @@ const MapGIS = () => { {/* Station List */}
{filtered.map((s) => ( -
+
navigate(`/station?id=${s.id}`)} + style={{ cursor: 'pointer', background: selectedStationId === s.id ? 'var(--color-bg-surface)' : 'transparent' }} + > {s.name} - {s.detail} + {s.waterLevel.toFixed(2)} ม.
))} {filtered.length === 0 && ( diff --git a/Frontend/src/components/MapView.tsx b/Frontend/src/components/MapView.tsx index eb7a1db..a57f61e 100644 --- a/Frontend/src/components/MapView.tsx +++ b/Frontend/src/components/MapView.tsx @@ -27,6 +27,8 @@ export interface StationData { interface MapViewProps { stations: StationData[]; + selectedStationId?: string; + onStationClick?: (id: string) => void; } // --- Helper Component --- @@ -42,8 +44,21 @@ const FitBoundsToMarkers = ({ stations }: { stations: StationData[] }) => { return null; }; +const FlyToStation = ({ selectedId, stations }: { selectedId?: string, stations: StationData[] }) => { + const map = useMap(); + useEffect(() => { + if (selectedId) { + const target = stations.find(s => s.id === selectedId); + if (target) { + map.flyTo([target.lat, target.lng], 16, { animate: true, duration: 1.5 }); + } + } + }, [selectedId, stations, map]); + return null; +}; + // --- Main Component --- -function MapView({ stations = [] }: MapViewProps) { +function MapView({ stations = [], selectedStationId, onStationClick }: MapViewProps) { /* const handleViewDetails = (id: string | number) => { console.log("Navigating to sensor details:", id); @@ -64,12 +79,16 @@ function MapView({ stations = [] }: MapViewProps) { /> + {stations.map((station) => ( onStationClick?.(String(station.id)) + }} > {/* ใช้ className แทน style */} diff --git a/Frontend/src/components/MultiStationTable.tsx b/Frontend/src/components/MultiStationTable.tsx new file mode 100644 index 0000000..eb0d835 --- /dev/null +++ b/Frontend/src/components/MultiStationTable.tsx @@ -0,0 +1,187 @@ +// src/components/MultiStationTable.tsx +// ตารางที่แสดงทุกสถานี — แถวละ 1 สถานี + +import React, { useCallback } from 'react'; +import type { StationLatestInfo } from '../service/deviceService'; +import styles from '../styles/MultiStationTable.module.css'; + +interface MultiStationTableProps { + stations: StationLatestInfo[]; + isLoading: boolean; + selectedId: string; + onSelectStation: (deviceId: string) => void; + warningLevel: number; + criticalLevel: number; +} + +const calcStatus = ( + val: number, + w: number, + c: number +): 'normal' | 'warning' | 'critical' => { + if (c > 0 && val >= c) return 'critical'; + if (w > 0 && val >= w) return 'warning'; + return 'normal'; +}; + +const StatusBadge: React.FC<{ status: 'normal' | 'warning' | 'critical' }> = ({ status }) => { + const map = { + normal: { label: 'ปกติ', cls: styles.badgeNormal }, + warning: { label: 'เฝ้าระวัง', cls: styles.badgeWarning }, + critical: { label: 'วิกฤต', cls: styles.badgeCritical }, + }; + const { label, cls } = map[status]; + return ( + + + {label} + + ); +}; + +const MultiStationTable: React.FC = React.memo(({ + stations, + isLoading, + selectedId, + onSelectStation, + warningLevel, + criticalLevel, +}) => { + const handleExport = useCallback(() => { + const headers = ['ชื่อสถานี,ระดับน้ำ (ม.),สัญญาณ,แบตเตอรี่ (%),สถานะ,เวลา']; + const rows = stations.map(s => { + const val = parseFloat(s.monitorValue); + const st = isNaN(val) ? 'normal' : calcStatus(val, warningLevel, criticalLevel); + return `${s.stationName},${isNaN(val) ? '-' : val.toFixed(3)},${s.signal},${s.battery},${st},${s.monitorTime}`; + }); + const csv = 'data:text/csv;charset=utf-8,\uFEFF' + [headers, ...rows].join('\n'); + const link = document.createElement('a'); + link.href = encodeURI(csv); + link.download = `stations_${new Date().toISOString().slice(0,10)}.csv`; + document.body.appendChild(link); + link.click(); + document.body.removeChild(link); + }, [stations, warningLevel, criticalLevel]); + + if (isLoading) { + return ( +
+
กำลังโหลดข้อมูลสถานี...
+
+ ); + } + + if (stations.length === 0) { + return ( +
+
ไม่มีข้อมูลสถานี
+
+ ); + } + + return ( +
+
+ ข้อมูลสถานีทั้งหมด + +
+ +
+
ชื่อสถานี
+
เวลาล่าสุด
+
สัญญาณ
+
แบตเตอรี่
+
ระดับน้ำ (ม.)
+
สถานะ
+
+ +
+ {stations.map(s => { + const val = parseFloat(s.monitorValue); + const status = isNaN(val) ? 'normal' : calcStatus(val, warningLevel, criticalLevel); + const isSelected = s.deviceId === selectedId; + + const borderColor = + status === 'critical' ? 'var(--color-status-critical)' : + status === 'warning' ? 'var(--color-status-warning)' : + 'var(--color-status-normal)'; + + const formattedTime = s.monitorTime + ? new Date(s.monitorTime).toLocaleTimeString('th-TH', { + hour: '2-digit', minute: '2-digit' + }) + : '--:--'; + + return ( +
onSelectStation(s.deviceId)} + title="คลิกเพื่อดูกราฟสถานีนี้" + > + {/* ชื่อสถานี */} +
+ {s.stationName} + {isSelected && ( + กำลังดูกราฟ + )} +
+ + {/* เวลา */} +
{formattedTime}
+ + {/* สัญญาณ */} +
+ +
+ + {/* แบตเตอรี่ */} +
+ 20 ? 'bi-battery-full' : 'bi-battery-empty'}`} + style={{ + fontSize: 17, + color: s.battery > 20 + ? 'var(--color-status-normal)' + : 'var(--color-status-critical)', + }} + /> + {s.battery}% +
+ + {/* ระดับน้ำ */} +
+ {isNaN(val) ? '-' : val.toFixed(3)} +
+ + {/* สถานะ */} +
+ +
+
+ ); + })} +
+
+ ); +}); + +export default MultiStationTable; \ No newline at end of file diff --git a/Frontend/src/components/Navbar.tsx b/Frontend/src/components/Navbar.tsx index 1a292e1..ba358b8 100644 --- a/Frontend/src/components/Navbar.tsx +++ b/Frontend/src/components/Navbar.tsx @@ -1,3 +1,4 @@ +import { useState, useRef, useEffect } from "react"; import { Routes, Route, Link, useLocation } from "react-router-dom"; import DashboardPage from "../pages/DashboardPage"; import Station from "../pages/Station"; @@ -5,103 +6,235 @@ import styles from "../styles/NavBar.module.css"; import MapGIS from "./MapGIS"; import SettingsPage from "../pages/SettingsPage"; -// Component ส่วนเมนู -const MenuBar = () => { - const location = useLocation(); +// ---- Nav Items config ---- +const NAV_ITEMS = [ + { + path: "/", + label: "แดชบอร์ด", + icon: ( + + + + + + + ), + }, + { + path: "/map", + label: "แผนที่ GIS", + icon: ( + + + + + ), + }, + { + path: "/station", + label: "ข้อมูลสถานี", + icon: ( + + + + ), + }, + { + path: "/settings", + label: "การตั้งค่า", + icon: ( + + + + + ), + }, +]; - const getNavClass = (path: string) => { - return location.pathname === path - ? `${styles.navItem} ${styles.active}` - : styles.navItem; - }; - - return ( - + +
+ + +
+ ); +}; + +// ---- MenuBar ---- +const MenuBar = () => { + const location = useLocation(); + const [showSearch, setShowSearch] = useState(false); + const [showUser, setShowUser] = useState(false); + const [hasAlert] = useState(true); // TODO: มาจาก alertCounts จริงๆ + + const isActive = (path: string) => + path === "/" ? location.pathname === "/" : location.pathname.startsWith(path); + + return ( + <> + + + {/* Search Overlay */} + {showSearch && setShowSearch(false)} />} + ); }; -// Layout wrapper ที่รู้จัก path ปัจจุบัน +// ---- Layout ---- const Layout = () => { const location = useLocation(); - const isMap = location.pathname === "/map"; + const isMap = location.pathname === "/map"; return (
-
+
- } /> - } /> - } /> - } - /> + } /> + } /> + } /> + } />
); }; -export default Layout; +export default Layout; \ No newline at end of file diff --git a/Frontend/src/components/WaterLevelChart.tsx b/Frontend/src/components/WaterLevelChart.tsx index 8808787..6748c56 100644 --- a/Frontend/src/components/WaterLevelChart.tsx +++ b/Frontend/src/components/WaterLevelChart.tsx @@ -1,12 +1,11 @@ import React, { useMemo, useEffect } from "react"; import { AreaChart, Area, XAxis, YAxis, CartesianGrid, - Tooltip, ResponsiveContainer + Tooltip, ResponsiveContainer, ReferenceLine } from 'recharts'; import type { DeviceRangeData } from '../service/deviceService'; import styles from '../styles/WaterLevelChart.module.css'; -// --- Types & Interfaces --- interface WaterData { time: string; waterLevel: number; @@ -17,9 +16,10 @@ interface WaterLevelChartProps { waterData?: DeviceRangeData[]; rainData?: DeviceRangeData[]; onDataUpdate?: (water: number, rain: number) => void; + warningLevel?: number; + criticalLevel?: number; } -// --- Sub-components --- interface TooltipPayload { value: number; name: string; @@ -35,15 +35,14 @@ interface CustomTooltipProps { const CustomTooltip: React.FC = ({ active, payload, label }) => { if (!active || !payload?.length) return null; - return (
-

เวลา {label} น.

- {payload.map((entry: TooltipPayload, index: number) => ( -
-
+

{label} น.

+ {payload.map((entry, i) => ( +
+
- {entry.name}: {Number(entry.value).toFixed(3)} {entry.unit || ''} + {entry.name}: {Number(entry.value).toFixed(3)}
))} @@ -51,129 +50,200 @@ const CustomTooltip: React.FC = ({ active, payload, label }) ); }; -// --- Main Component --- +// Label สำหรับ ReferenceLine +const RefLabel: React.FC<{ + viewBox?: { x?: number; y?: number; width?: number }; + value: string; + color: string; +}> = ({ viewBox, value, color }) => { + const x = (viewBox?.x ?? 0) + (viewBox?.width ?? 0) - 4; + const y = (viewBox?.y ?? 0) - 6; + return ( + + {value} + + ); +}; + export const WaterLevelChart: React.FC = ({ waterData = [], rainData = [], - onDataUpdate + onDataUpdate, + warningLevel, + criticalLevel, }) => { - // Transform data from API format to chart format const chartData: WaterData[] = useMemo(() => { - if (waterData.length === 0 && rainData.length === 0) { - return []; - } + if (waterData.length === 0 && rainData.length === 0) return []; - // Combine and sort by time const allData: WaterData[] = []; - // Add water data for (const item of waterData) { - const date = new Date(item.monitorTime); - const timeStr = date.toLocaleTimeString('th-TH', { - hour: '2-digit', - minute: '2-digit' - }); + const date = new Date(item.monitorTime); + const timeStr = date.toLocaleTimeString('th-TH', { hour: '2-digit', minute: '2-digit' }); allData.push({ - time: timeStr, + time: timeStr, waterLevel: parseFloat(item.monitorValue) || 0, - rainLevel: 0 + rainLevel: 0, }); } - // Add rain data (accumulate within the same time point) for (const item of rainData) { - const date = new Date(item.monitorTime); - const timeStr = date.toLocaleTimeString('th-TH', { - hour: '2-digit', - minute: '2-digit' - }); + const date = new Date(item.monitorTime); + const timeStr = date.toLocaleTimeString('th-TH', { hour: '2-digit', minute: '2-digit' }); const existing = allData.find(d => d.time === timeStr); if (existing) { existing.rainLevel += parseFloat(item.monitorValue) || 0; } else { - allData.push({ - time: timeStr, - waterLevel: 0, - rainLevel: parseFloat(item.monitorValue) || 0 - }); + allData.push({ time: timeStr, waterLevel: 0, rainLevel: parseFloat(item.monitorValue) || 0 }); } } - // Sort by time ascending and limit to last 24 entries allData.sort((a, b) => a.time.localeCompare(b.time)); return allData.slice(-24); }, [waterData, rainData]); - // Get latest values for DataCards useEffect(() => { if (chartData.length === 0) return; - const latest = chartData[chartData.length - 1]; - if (onDataUpdate) { - onDataUpdate(latest.waterLevel, latest.rainLevel); - } + onDataUpdate?.(latest.waterLevel, latest.rainLevel); }, [chartData, onDataUpdate]); + // คำนวณ Y domain ให้ threshold line มองเห็น + const yMax = useMemo(() => { + const dataMax = Math.max(...chartData.map(d => Math.max(d.waterLevel, d.rainLevel)), 0); + const thMax = Math.max(warningLevel ?? 0, criticalLevel ?? 0); + return Math.ceil(Math.max(dataMax, thMax) * 1.15) || 10; + }, [chartData, warningLevel, criticalLevel]); + return (
+ {/* กำหนด height ตายตัวให้ ResponsiveContainer ทำงานได้ */}
- + - - - + + + + + + + - + + + + + } + cursor={{ stroke: 'var(--color-text-secondary)', strokeWidth: 1, strokeDasharray: '3 3' }} /> - } cursor={{ stroke: 'var(--color-text-secondary)', strokeWidth: 1, strokeDasharray: '3 3' }} /> + {/* เส้นเฝ้าระวัง */} + {warningLevel !== undefined && ( + } + /> + )} + {/* เส้นวิกฤต */} + {criticalLevel !== undefined && ( + } + /> + )} + + {/* Area ปริมาณฝน (ด้านหลัง) */} + + {/* Area ระดับน้ำ (ด้านหน้า) */} +
- + {/* Legend */} +
+
+ + + + + + ระดับน้ำ +
+
+ + + + + + ปริมาณฝน +
+ {warningLevel !== undefined && ( +
+ + + + ระดับเฝ้าระวัง +
+ )} + {criticalLevel !== undefined && ( +
+ + + + ระดับวิกฤต +
+ )} +
); }; -const ChartLegend = () => ( -
-
- - - - - - ปริมาณน้ำฝนสะสม -
-
-); - export default WaterLevelChart; \ No newline at end of file diff --git a/Frontend/src/data/mockData.ts b/Frontend/src/data/mockData.ts new file mode 100644 index 0000000..1eb3c69 --- /dev/null +++ b/Frontend/src/data/mockData.ts @@ -0,0 +1,87 @@ +// src/data/mockData.ts +import type { StationDeviceInfo, StationLatestInfo, DeviceRangeData, RainProbabilityData, DeviceInfoResponse } from '../service/deviceService'; + +// 1. ข้อมูลพื้นฐานสถานี +export const MOCK_STATIONS: StationDeviceInfo[] = [ + { stationId: 'ST-K1', stationName: 'สถานีสะพานดำ', latitude: '18.7012', longitude: '99.0876', deviceId: 'DEV-K1', deviceName: 'Sensor-Kuang-01', monitorItem: 'water_level' }, + { stationId: 'ST-K2', stationName: 'สถานีศาลากลางลำพูน', latitude: '18.6234', longitude: '99.0412', deviceId: 'DEV-K2', deviceName: 'Sensor-Kuang-02', monitorItem: 'water_level' }, + { stationId: 'ST-K3', stationName: 'สถานีสะพานท่าขาม', latitude: '18.5867', longitude: '99.0232', deviceId: 'DEV-K3', deviceName: 'Sensor-Kuang-03', monitorItem: 'water_level' }, + { stationId: 'ST-K4', stationName: 'สถานีประตูป่า', latitude: '18.5712', longitude: '98.9834', deviceId: 'DEV-K4', deviceName: 'Sensor-Kuang-04', monitorItem: 'water_level' }, + { stationId: 'ST-K5', stationName: 'สถานีอุโมงค์', latitude: '18.5489', longitude: '98.9612', deviceId: 'DEV-K5', deviceName: 'Sensor-Kuang-05', monitorItem: 'water_level' }, +]; + +// 2. ข้อมูลล่าสุดของแต่ละสถานี (พร้อมสถานะที่สัมพันธ์กับระดับน้ำท่วม) +export const MOCK_LATEST_STATIONS: StationLatestInfo[] = [ + { ...MOCK_STATIONS[0], monitorValue: '2.10', monitorTime: new Date().toISOString(), signal: 'online', battery: 85 }, // normal + { ...MOCK_STATIONS[1], monitorValue: '4.20', monitorTime: new Date().toISOString(), signal: 'online', battery: 60 }, // critical + { ...MOCK_STATIONS[2], monitorValue: '3.60', monitorTime: new Date(Date.now() - 3600000).toISOString(), signal: 'offline', battery: 15 }, // warning + { ...MOCK_STATIONS[3], monitorValue: '1.80', monitorTime: new Date().toISOString(), signal: 'online', battery: 95 }, // normal + { ...MOCK_STATIONS[4], monitorValue: '5.10', monitorTime: new Date().toISOString(), signal: 'online', battery: 45 }, // critical +]; + +// 3. ฟังก์ชันจำลองประวัติ (กราฟจะไม่กระโดดมั่ว จะอิงจากค่าล่าสุด) +export const generateMockHistory = (deviceId: string, monitorItem: string, start: number, end: number): DeviceRangeData[] => { + const mockData: DeviceRangeData[] = []; + const oneHour = 60 * 60 * 1000; + const hoursToGenerate = Math.min(24, Math.floor((end - start) / oneHour)) || 24; + + // หาค่าล่าสุดเพื่อเป็นฐาน ไม่ให้กราฟกระโดด + const latestInfo = MOCK_LATEST_STATIONS.find(s => s.deviceId === deviceId); + let baseWaterValue = latestInfo ? parseFloat(latestInfo.monitorValue) : 2.5; + + for (let i = 0; i < hoursToGenerate; i++) { + const time = end - (i * oneHour); + let value = 0; + + if (monitorItem === "water_level" || monitorItem === "NW_value") { + // สุ่มขึ้น/ลง ทีละนิด (0 ถึง 0.2) + const change = (Math.random() * 0.4) - 0.2; + baseWaterValue = Math.max(0, baseWaterValue + change); + value = baseWaterValue; + } else { // ฝน + value = Math.random() > 0.8 ? Math.random() * 15 : 0; + } + + mockData.push({ + monitorTime: new Date(time).toISOString(), + monitorValue: value.toFixed(2) + }); + } + + // เรียงลำดับเวลา (เพื่อให้กราฟแสดงถูกต้อง) + return mockData.sort((a, b) => new Date(a.monitorTime).getTime() - new Date(b.monitorTime).getTime()); +}; + +// 4. ฟังก์ชันข้อมูลสถานีเดี่ยว +export const getMockStationInfo = (deviceId: string): DeviceInfoResponse => { + const station = MOCK_STATIONS.find(s => s.deviceId === deviceId) || MOCK_STATIONS[2]; // Default S3 + return { + monitorName: station.monitorItem, + customName: station.stationName, + warningLevel: 1, + deviceLocation: { + latitude: station.latitude, + longitude: station.longitude + } + }; +}; + +// 5. โอกาสเกิดฝน +export const generateRainProbability = (): RainProbabilityData[] => { + const rows: RainProbabilityData[] = []; + for (let h = 1; h <= 24; h++) { + const hour = h % 24; + const base = hour >= 6 && hour <= 18 ? 30 : 10; + rows.push({ + time: `${String(hour).padStart(2, '0')}:00`, + sun: Math.round(base + Math.random() * 40), + mon: Math.round(base + Math.random() * 40), + tue: Math.round(base + Math.random() * 40), + wed: Math.round(base + Math.random() * 40), + thu: Math.round(base + Math.random() * 40), + fri: Math.round(base + Math.random() * 40), + sat: Math.round(base + Math.random() * 40), + }); + } + return rows; +}; diff --git a/Frontend/src/pages/DashboardPage.tsx b/Frontend/src/pages/DashboardPage.tsx index 34efd0f..ce31dfb 100644 --- a/Frontend/src/pages/DashboardPage.tsx +++ b/Frontend/src/pages/DashboardPage.tsx @@ -1,37 +1,49 @@ // src/pages/DashboardPage.tsx -import { useState, useCallback, useEffect, useMemo, useRef } from 'react'; +import { useState, useCallback, useEffect, useMemo } from 'react'; import StationTable from '../components/Dashboard-StationTable'; -import { DeviceService, MockDeviceService, type DeviceRangeData, type RainProbabilityData } from '../service/deviceService'; +import AlertCard from '../components/AlertCard'; +import { DeviceService, MockDeviceService, type DeviceRangeData } from '../service/deviceService'; import WaterLevelChart from '../components/WaterLevelChart'; import DataCard from '../components/DataCard'; import { STATIC_STATIONS } from '../data/stationList'; import type { StationData } from '../components/MapView'; import styles from '../styles/DashboradPage.module.css'; -// *** ตัวสลับโหมด: true = ใช้ข้อมูลจำลอง, false = ต่อ API จริง *** -const USE_MOCK_DATA = true; +// *** ตัวสลับโหมด *** +const USE_MOCK_DATA = true; + +// hardcode ค่าเริ่มต้นของสถานีหลัก (สำหรับ mock data) +// *** ค่าระดับน้ำ Threshold (ปรับตามจริง) *** +const WARNING_LEVEL = 4.5; // เมตร — เส้นเฝ้าระวัง +const CRITICAL_LEVEL = 5.0; // เมตร — เส้นวิกฤต const DashboardPage = () => { - // State ข้อมูลสถานี - กู้คืนจากโค้ดทีม const [stationName, setStationName] = useState("Loading Station..."); const [deviceId] = useState("UNKNOWN_ID"); - const [location, /*setLocation*/] = useState<{lat: number, lng: number}>({ - lat: 18.586659, - lng: 99.023166 + const [location] = useState<{lat: number, lng: number}>({ + lat: 18.586659, + lng: 99.023166, }); - // State ข้อมูล Sensor const [waterValue, setWaterValue] = useState("---"); - const [rainValue, setRainValue] = useState("---"); + const [rainValue, setRainValue] = useState("---"); - // State ตาราง History const [waterHistory, setWaterHistory] = useState([]); - const [rainHistory, setRainHistory] = useState([]); - const [probData, setProbData] = useState([]); - + const [rainHistory, setRainHistory] = useState([]); + const [isLoading, setIsLoading] = useState(false); - const probScrollRef = useRef(null); + + // --- คำนวณจำนวนสถานีวิกฤต/เฝ้าระวัง จาก waterHistory --- + const alertCounts = useMemo(() => { + // ดึงค่าล่าสุดของแต่ละสถานี (ตอนนี้มีสถานีเดียว ใช้ค่าล่าสุด) + if (waterHistory.length === 0) return { critical: 0, warning: 0 }; + + const latestValue = parseFloat(waterHistory[0]?.monitorValue ?? "0"); + if (latestValue >= CRITICAL_LEVEL) return { critical: 1, warning: 0 }; + if (latestValue >= WARNING_LEVEL) return { critical: 0, warning: 1 }; + return { critical: 0, warning: 0 }; + }, [waterHistory]); const handleDataUpdate = useCallback((water: number, rain: number) => { setWaterValue(water.toFixed(3)); @@ -42,67 +54,35 @@ const DashboardPage = () => { const fetchData = async () => { setIsLoading(true); try { - // อ่าน Environment Variables - const envDeviceId = import.meta.env.VITE_API_DEVICE_ID || "MOCK_DEVICE_001"; - const secretKey = import.meta.env.VITE_API_deviceSecretKey || "MOCK_KEY"; - const endTime = Date.now(); - const startTime = endTime - (24 * 60 * 60 * 1000); - - let infoRes; - let waterRes; - let rainRes; - let probRes; - - - - - // --- เลือกโหมด Mock หรือ Real --- - if (USE_MOCK_DATA) { - console.log("🟡 Mode: Using MOCK Data"); + const envDeviceId = import.meta.env.VITE_API_DEVICE_ID || "MOCK_DEVICE_001"; + const secretKey = import.meta.env.VITE_API_deviceSecretKey || "MOCK_KEY"; + const endTime = Date.now(); + const startTime = endTime - (24 * 60 * 60 * 1000); + + let infoRes, waterRes, rainRes; + + if (USE_MOCK_DATA) { infoRes = await MockDeviceService.getStationInfo(envDeviceId); const results = await Promise.all([ MockDeviceService.getHistory(envDeviceId, secretKey, "water_level", startTime, endTime), - MockDeviceService.getHistory(envDeviceId, secretKey, "rain_fall", startTime, endTime), - MockDeviceService.getRainProbability() + MockDeviceService.getHistory(envDeviceId, secretKey, "rain_fall", startTime, endTime), ]); - [waterRes, rainRes, probRes] = results; // ใช้การ Destructure ผลลัพธ์ + [waterRes, rainRes] = results; } else { - console.log("🟢 Mode: Using REAL API"); infoRes = await DeviceService.getStationInfo(envDeviceId); const results = await Promise.all([ DeviceService.getHistory(envDeviceId, secretKey, "water_level", startTime, endTime), - DeviceService.getHistory(envDeviceId, secretKey, "rain_fall", startTime, endTime), - DeviceService.getRainProbability() + DeviceService.getHistory(envDeviceId, secretKey, "rain_fall", startTime, endTime), ]); - [waterRes, rainRes, probRes] = results; + [waterRes, rainRes] = results; } - // --- อัปเดต State --- if (infoRes) { setStationName(infoRes.customName || infoRes.monitorName || "Unknown Station"); - // if (infoRes.deviceLocation) { - // setLocation({ - // // แก้ Bug: 118 เป็น 18 (เพราะ Latitude เกิน 90 ไม่ได้) - // lat: Number(infoRes.deviceLocation.latitude) || 18.575, - // lng: Number(infoRes.deviceLocation.longitude) || 99.008 - // }); - // } } setWaterHistory(waterRes || []); - setRainHistory(rainRes || []); - setProbData(probRes || []); - - setTimeout(() => { - if (probScrollRef.current) { - const bangkokNow = new Date().toLocaleString('en-US', { timeZone: 'Asia/Bangkok', hour: 'numeric', hour12: false }); - const currentHour = parseInt(bangkokNow, 10); - const rowIndex = currentHour >= 1 ? currentHour - 1 : 23; - const rowHeight = 26; - const headerHeight = 0; - probScrollRef.current.scrollTop = Math.max(0, rowIndex * rowHeight - headerHeight); - } - }, 100); + setRainHistory(rainRes || []); } catch (error) { console.error("Error:", error); @@ -113,80 +93,76 @@ const DashboardPage = () => { fetchData(); }, []); - // กู้คืนฟังก์ชันรวมรายชื่อสถานี (StationList) และพิกัดเตรียมไว้สำหรับหน้า Map ตามตรรกะเดิมของทีม const stationList: StationData[] = useMemo(() => { const mainStation: StationData = { id: deviceId, - name: stationName, // ชื่อที่ได้จาก API - lat: location.lat, // พิกัดที่ได้จาก API - lng: location.lng, // พิกัดที่ได้จาก API - status: 'active' + name: stationName, + lat: location.lat, + lng: location.lng, + status: 'active', }; - return [mainStation, ...STATIC_STATIONS]; }, [deviceId, stationName, location]); return (
- - {/* --- ส่วนบน: สถิติ และ เปอร์เซ็นต์ฝน --- */} + + {/* --- ส่วนบน: การ์ดสรุป --- */}
- - - -
-
- - + + + + {/* การ์ดแจ้งเตือน — ลำดับที่ 2 */} +
-
-
-
เปอร์เซ็นต์การเกิดฝน
-
-
time
-
Sun
M
Tu
W
Th
Fr
St
-
-
-
- {probData.map((row, idx) => ( - -
{row.time}
-
{row.sun}
{row.mon}
{row.tue}
-
{row.wed}
{row.thu}
{row.fri}
{row.sat}
-
- ))} -
-
-
-
- {/* --- ส่วนกลาง: กราฟเต็มจอ --- */} + {/* --- ส่วนกลาง: กราฟ — ลำดับที่ 3 (Threshold Lines) --- */}
- +
{/* --- ส่วนล่าง: ตารางข้อมูล --- */}
- + />
); -} +}; -export default DashboardPage; +export default DashboardPage; \ No newline at end of file diff --git a/Frontend/src/pages/SettingsPage.tsx b/Frontend/src/pages/SettingsPage.tsx index 2727287..4585d34 100644 --- a/Frontend/src/pages/SettingsPage.tsx +++ b/Frontend/src/pages/SettingsPage.tsx @@ -1,131 +1,520 @@ -// src/pages/SettingsPage.tsx -import { useState, useEffect } from 'react'; -import StationTable from '../components/StationTable'; -import AddSensorModal from '../components/AddSensorModal'; -import EditStationModal from '../components/EditStationModal'; +import { useState, useEffect, useCallback } from 'react'; import { DeviceService } from '../service/deviceService'; import styles from '../styles/SettingsPage.module.css'; -interface SettingsStationData { +// ---- Types ---- +interface SettingsStation { id: string; name: string; - location: string; + latitude: string; + longitude: string; status: 'normal' | 'warning' | 'critical' | 'offline'; - date: Date; - waterLevel?: string; - rainfall?: string; - alertThreshold?: string; - latitude?: string; - longitude?: string; + waterLevel: string; + warningLevel: number; // มาจาก API (devices.warningLevel) + criticalLevel: number; // warningLevel * 1.1 + signal: 'online' | 'offline'; + battery: number; } -const SettingsPage = () => { - const [stations, setStations] = useState([]); - const [isLoading, setIsLoading] = useState(true); +type SettingsTab = 'stations' | 'alerts' | 'account'; + +// ---- Sub: Sidebar ---- +const Sidebar: React.FC<{ activeTab: SettingsTab; onChange: (t: SettingsTab) => void }> = ({ + activeTab, + onChange, +}) => { + const items: { id: SettingsTab; icon: string; label: string; sub: string }[] = [ + { id: 'stations', icon: 'bi-broadcast-pin', label: 'จัดการสถานี', sub: 'เพิ่ม / แก้ไข / ลบ' }, + { id: 'alerts', icon: 'bi-bell-fill', label: 'การแจ้งเตือน', sub: 'ตั้งค่าระดับเตือน' }, + { id: 'account', icon: 'bi-person-circle', label: 'บัญชีผู้ใช้', sub: 'ข้อมูลและรหัสผ่าน' }, + ]; + + return ( + + ); +}; + +// ---- Sub: Toast Notification ---- +const Toast: React.FC<{ message: string; type: 'success' | 'error' }> = ({ message, type }) => ( +
+ + {message} +
+); + +// ---- Sub: Add Station Modal ---- +const AddStationModal: React.FC<{ + onClose: () => void; + onSuccess: (name: string) => void; +}> = ({ onClose, onSuccess }) => { + const [name, setName] = useState(''); + const [kpiKey, setKpiKey] = useState(''); + const [loading, setLoading] = useState(false); + const [errors, setErrors] = useState({ name: false, kpiKey: false }); + + const handleSubmit = async () => { + const e = { name: !name.trim(), kpiKey: !kpiKey.trim() }; + setErrors(e); + if (e.name || e.kpiKey) return; + setLoading(true); + try { + await new Promise(r => setTimeout(r, 800)); // TODO: เรียก API จริง + onSuccess(name.trim()); + } catch { + setLoading(false); + } + }; + + return ( +
+
e.stopPropagation()}> +
+ เพิ่มสถานีใหม่ + +
+ +
+
+ + setName(e.target.value)} + /> + {errors.name && กรุณาระบุชื่อสถานี} +
+ +
+ + setKpiKey(e.target.value)} + /> + {errors.kpiKey && กรุณาระบุ KPI Key} +
+
+ +
+ + +
+
+
+ ); +}; + +// ---- Tab: จัดการสถานี ---- +const StationsTab: React.FC<{ + stations: SettingsStation[]; + isLoading: boolean; + onShowToast: (msg: string, type: 'success' | 'error') => void; +}> = ({ stations, isLoading, onShowToast }) => { + const [showAddModal, setShowAddModal] = useState(false); + + const handleAddSuccess = (name: string) => { + setShowAddModal(false); + onShowToast(`เพิ่มสถานี "${name}" สำเร็จ`, 'success'); + }; + + const statusColor = (s: SettingsStation['status']) => + s === 'critical' ? 'var(--color-status-critical)' : + s === 'warning' ? 'var(--color-status-warning)' : + s === 'offline' ? '#64748b' : 'var(--color-status-normal)'; + + const statusLabel = (s: SettingsStation['status']) => + s === 'critical' ? 'วิกฤต' : s === 'warning' ? 'เฝ้าระวัง' : s === 'offline' ? 'ออฟไลน์' : 'ปกติ'; + + return ( +
+ {/* Header */} +
+
+

จัดการสถานี

+

สถานีทั้งหมด {stations.length} สถานี

+
+ +
+ + {/* Table */} + {isLoading ? ( +
+ + กำลังโหลดข้อมูลสถานี... +
+ ) : stations.length === 0 ? ( +
+ + ยังไม่มีสถานี กดปุ่ม "เพิ่มสถานี" เพื่อเริ่มต้น +
+ ) : ( +
+ + + + + + + + + + + + + {stations.map((s) => ( + + + + + + + + + ))} + +
ชื่อสถานีสัญญาณแบตเตอรี่ระดับน้ำ (ม.)ระดับเตือน (ม.)สถานะ
+
+ + {s.name} +
+
+ + + 20 ? 'bi-battery-full' : 'bi-battery-empty'}`} + style={{ color: s.battery > 20 ? 'var(--color-status-normal)' : 'var(--color-status-critical)', fontSize: 16 }} + /> + {s.battery}% + + {s.waterLevel || '-'} + + {s.warningLevel > 0 ? s.warningLevel.toFixed(2) : '-'} + + + {statusLabel(s.status)} + +
+
+ )} + + {showAddModal && ( + setShowAddModal(false)} onSuccess={handleAddSuccess} /> + )} +
+ ); +}; + +// ---- Tab: การแจ้งเตือน ---- +const AlertsTab: React.FC<{ + stations: SettingsStation[]; + isLoading: boolean; + onShowToast: (msg: string, type: 'success' | 'error') => void; +}> = ({ stations, isLoading, onShowToast }) => { + // local state: warningLevel ต่อสถานี (ก่อน save) + const [levels, setLevels] = useState>({}); + + useEffect(() => { + const init: Record = {}; + stations.forEach(s => { init[s.id] = s.warningLevel; }); + setLevels(init); + }, [stations]); + + const handleSave = async (stationId: string, name: string) => { + try { + const savedLevelsStr = localStorage.getItem('mock_warning_levels'); + const savedLevels = savedLevelsStr ? JSON.parse(savedLevelsStr) : {}; + savedLevels[stationId] = levels[stationId]; + localStorage.setItem('mock_warning_levels', JSON.stringify(savedLevels)); + + // TODO: เรียก PATCH /api/v2/stations/:id { warningLevel: levels[stationId] } + await new Promise(r => setTimeout(r, 500)); + onShowToast(`บันทึกระดับเตือนของ "${name}" สำเร็จ`, 'success'); + } catch { + onShowToast('บันทึกล้มเหลว กรุณาลองใหม่', 'error'); + } + }; - // State ควบคุม Modal - const [isAddModalOpen, setIsAddModalOpen] = useState(false); - const [isEditModalOpen, setIsEditModalOpen] = useState(false); - const [editingStation, setEditingStation] = useState(null); + if (isLoading) return ( +
+
กำลังโหลด...
+
+ ); + + return ( +
+
+
+

การแจ้งเตือน

+

ตั้งค่าระดับน้ำที่ต้องการแจ้งเตือนแต่ละสถานี

+
+
+ + {stations.length === 0 ? ( +
+ + ไม่มีสถานีให้ตั้งค่า +
+ ) : ( +
+ {stations.map((s) => { + const currentLevel = levels[s.id] ?? s.warningLevel; + const criticalLevel = parseFloat((currentLevel * 1.1).toFixed(2)); + const maxRange = Math.max(s.warningLevel * 2, 10); // Fix infinite loop bug + + return ( +
+ {/* Card Header */} +
+
+ + {s.name} +
+ + ระดับน้ำปัจจุบัน: {s.waterLevel || '-'} ม. + +
+ + {/* Slider + ค่า */} +
+
+ +
+ + setLevels(prev => ({ ...prev, [s.id]: parseFloat(e.target.value) })) + } + /> + + {currentLevel.toFixed(2)} ม. + +
+
+ +
+ +
+ + + {criticalLevel.toFixed(2)} ม. + +
+
+
+ + {/* Save button */} +
+ +
+
+ ); + })} +
+ )} +
+ ); +}; + +// ---- Tab: บัญชีผู้ใช้ ---- +const AccountTab: React.FC = () => { + const handleLogout = () => { + if (confirm('ต้องการออกจากระบบใช่หรือไม่?')) { + localStorage.clear(); + window.location.reload(); + } + }; + + return ( +
+
+
+

บัญชีผู้ใช้

+

ข้อมูลบัญชีและการจัดการสิทธิ์

+
+
+ + {/* Profile Card */} +
+
+ +
+
+
เจ้าหน้าที่เทศบาล
+
+ + ผู้ดูแลระบบ +
+
+
+ + {/* Info rows */} +
+ {[ + { icon: 'bi-building', label: 'หน่วยงาน', value: 'กองช่างสาธารณูปโภค' }, + { icon: 'bi-telephone', label: 'เบอร์ติดต่อ', value: '053-XXX-XXXX' }, + { icon: 'bi-envelope', label: 'อีเมล', value: 'admin@municipality.go.th' }, + { icon: 'bi-clock', label: 'เข้าสู่ระบบล่าสุด', value: new Date().toLocaleDateString('th-TH', { dateStyle: 'long' }) }, + ].map(({ icon, label, value }) => ( +
+ + {label} + {value} +
+ ))} +
+ + {/* Actions */} +
+ +
+
+ ); +}; + +// ---- Main Page ---- +const SettingsPage = () => { + const [activeTab, setActiveTab] = useState('stations'); + const [stations, setStations] = useState([]); + const [isLoading, setIsLoading] = useState(true); + const [toast, setToast] = useState<{ message: string; type: 'success' | 'error' } | null>(null); useEffect(() => { - const fetchStations = async () => { + const fetch = async () => { setIsLoading(true); try { - const stationDevices = await DeviceService.getStations(); + const latestData = await DeviceService.getLatestStations(); - if (stationDevices.length === 0) { - setStations([]); - setIsLoading(false); - return; - } + const map = new Map(); + for (const s of latestData) { + if (!map.has(s.stationId)) { + const wl = parseFloat(s.monitorValue) || 0; + const savedLevelsStr = localStorage.getItem('mock_warning_levels'); + const savedLevels = savedLevelsStr ? JSON.parse(savedLevelsStr) : {}; + const warningLevel = savedLevels[s.stationId] ?? 4.5; // TODO: มาจาก s.warningLevel เมื่อ backend ส่งมา + const criticalLevel = parseFloat((warningLevel * 1.1).toFixed(2)); + const status: SettingsStation['status'] = + wl >= criticalLevel ? 'critical' : + wl >= warningLevel ? 'warning' : + s.signal === 'offline' ? 'offline' : 'normal'; - // Group by stationId and transform to SettingsStationData - const uniqueStations = new Map(); - for (const s of stationDevices) { - if (!uniqueStations.has(s.stationId)) { - uniqueStations.set(s.stationId, { - id: s.stationId, - name: s.stationName || 'Unknown Station', - location: `${s.latitude}, ${s.longitude}`, - status: 'normal', - date: new Date(), - latitude: s.latitude, - longitude: s.longitude, + map.set(s.stationId, { + id: s.stationId, + name: s.stationName || 'Unknown', + latitude: s.latitude, + longitude: s.longitude, + status, + waterLevel: wl > 0 ? wl.toFixed(3) : '-', + warningLevel, + criticalLevel, + signal: s.signal, + battery: s.battery, }); } } - setStations(Array.from(uniqueStations.values())); - } catch (error) { - console.error("Error loading stations:", error); + setStations(Array.from(map.values())); + } catch (e) { + console.error('Settings fetch error:', e); } finally { setIsLoading(false); } }; - fetchStations(); + fetch(); }, []); - // ฟังก์ชันเตรียมข้อมูลก่อนส่งให้ Modal Edit - const handleEditClick = (station: SettingsStationData) => { - setEditingStation(station); - setIsEditModalOpen(true); - }; - - // ฟังก์ชันรับข้อมูลกลับมาจาก Modal Edit เพื่อคำนวณ Status และอัปเดตตาราง - const handleSaveEdit = (stationId: string, newThreshold: string) => { - const updatedStations = stations.map(station => { - if (station.id === stationId) { - const currentWater = parseFloat(station.waterLevel || '0'); - const maxLimit = parseFloat(newThreshold); - let newStatus: 'normal' | 'warning' | 'critical' | 'offline' = 'normal'; - if (!isNaN(currentWater) && !isNaN(maxLimit)) { - if (currentWater >= maxLimit) newStatus = 'critical'; - else if (currentWater >= maxLimit * 0.8) newStatus = 'warning'; - } - return { ...station, alertThreshold: newThreshold, status: newStatus }; - } - return station; - }); - setStations(updatedStations); - }; + const showToast = useCallback((message: string, type: 'success' | 'error') => { + setToast({ message, type }); + setTimeout(() => setToast(null), 3000); + }, []); return ( -
- -
-

หน้าการตั้งค่า (Demo)

- -
+
+ {/* Toast */} + {toast && } - {isLoading ? ( -
Loading Stations...
- ) : ( - - )} - - {/* Modal Add Sensor */} - setIsAddModalOpen(false)} - onSuccess={() => console.log("โหลดข้อมูลตารางใหม่หลังจากเพิ่มเสร็จ")} - /> - - {/* Modal Edit Sensor */} - setIsEditModalOpen(false)} - station={editingStation} - onSave={handleSaveEdit} - /> +
+ {/* Sidebar */} + + {/* Content */} +
+ {activeTab === 'stations' && ( + + )} + {activeTab === 'alerts' && ( + + )} + {activeTab === 'account' && } +
+
); }; -export default SettingsPage; +export default SettingsPage; \ No newline at end of file diff --git a/Frontend/src/pages/Station.tsx b/Frontend/src/pages/Station.tsx index 31d313e..7df0147 100644 --- a/Frontend/src/pages/Station.tsx +++ b/Frontend/src/pages/Station.tsx @@ -1,275 +1,298 @@ import React, { useState, useEffect, useMemo } from 'react'; +import { useSearchParams } from 'react-router-dom'; import { - AreaChart, Area, XAxis, YAxis, CartesianGrid, - Tooltip, ResponsiveContainer + ComposedChart, Area, Line, XAxis, YAxis, CartesianGrid, + Tooltip, ResponsiveContainer, ReferenceLine } from 'recharts'; import MapView from '../components/MapView'; import type { StationData as MapStationData } from '../components/MapView'; -import { DeviceService, type DeviceInfoResponse, type DeviceRangeData, type StationDeviceInfo, type StationLatestInfo } from '../service/deviceService'; +import { + DeviceService, + type DeviceInfoResponse, + type DeviceRangeData, + type StationDeviceInfo, + type StationLatestInfo, +} from '../service/deviceService'; import styles from '../styles/StationPage.module.css'; -// --- Interface สำหรับข้อมูลกราฟที่ผ่านการแปลงแล้ว --- +// ---- Types ---- interface ChartDataPoint { time: string; - value: number; + water: number | null; + rain: number | null; } -// --- Helper: แปลงข้อมูลจาก API มาเป็นรูปแบบที่กราฟต้องการ --- -const transformToChartData = (rawData: DeviceRangeData[]): ChartDataPoint[] => { - return rawData - .map((item) => { - const date = new Date(item.monitorTime); - const timeLabel = date.toLocaleTimeString('en-GB', { - hour: '2-digit', - minute: '2-digit', - }); - return { - time: timeLabel, - value: parseFloat(parseFloat(item.monitorValue).toFixed(2)), - }; - }) - .reverse(); // เรียงเวลาจากเก่าไปใหม่สำหรับกราฟ -}; - -// --- Helper: คำนวณ Status Class จากค่าระดับน้ำ --- -const getWaterStatusClass = (waterLevel: number): string => { - if (waterLevel >= 5.0) { - return styles.statusCritical; +// ---- Time Range Options (ลำดับที่ 6) ---- +type TimeRange = '6h' | '12h' | '24h' | '7d'; + +const TIME_RANGE_OPTIONS: { label: string; value: TimeRange; ms: number }[] = [ + { label: '6 ชม.', value: '6h', ms: 6 * 60 * 60 * 1000 }, + { label: '12 ชม.', value: '12h', ms: 12 * 60 * 60 * 1000 }, + { label: '24 ชม.', value: '24h', ms: 24 * 60 * 60 * 1000 }, + { label: '7 วัน', value: '7d', ms: 7 * 24 * 60 * 60 * 1000 }, +]; + +// ---- Helper: แปลงข้อมูลสองชุดมารวมกัน ---- +const mergeChartData = ( + waterData: DeviceRangeData[], + rainData: DeviceRangeData[], + rangeMs: number, +): ChartDataPoint[] => { + const now = Date.now(); + const cutoff = now - rangeMs; + + const format = (iso: string) => + new Date(iso).toLocaleTimeString('th-TH', { hour: '2-digit', minute: '2-digit' }); + + const map = new Map(); + + for (const item of waterData) { + const ts = new Date(item.monitorTime).getTime(); + if (ts < cutoff) continue; + const key = format(item.monitorTime); + const existing = map.get(key) ?? { time: key, water: null, rain: null }; + existing.water = parseFloat(parseFloat(item.monitorValue).toFixed(3)); + map.set(key, existing); } - if (waterLevel >= 4.5) { - return styles.statusWarning; + + for (const item of rainData) { + const ts = new Date(item.monitorTime).getTime(); + if (ts < cutoff) continue; + const key = format(item.monitorTime); + const existing = map.get(key) ?? { time: key, water: null, rain: null }; + existing.rain = parseFloat(parseFloat(item.monitorValue).toFixed(3)); + map.set(key, existing); } + + return Array.from(map.values()).sort((a, b) => a.time.localeCompare(b.time)); +}; + +// ---- Helper: คำนวณ Status ---- +const getWaterStatusClass = (waterLevel: number, styles: Record): string => { + if (waterLevel >= 5.0) return styles.statusCritical; + if (waterLevel >= 4.5) return styles.statusWarning; return styles.statusNormal; }; -// --- Main Component --- +// ---- Custom Tooltip ---- +const CustomTooltip: React.FC<{ + active?: boolean; + payload?: { value: number; name: string; color: string }[]; + label?: string; +}> = ({ active, payload, label }) => { + if (!active || !payload?.length) return null; + return ( +
+
{label} น.
+ {payload.map((entry, i) => ( +
+ {entry.name}: {entry.value != null ? Number(entry.value).toFixed(3) : '-'} +
+ ))} +
+ ); +}; + +// ---- Main Component ---- const StationPage: React.FC = () => { - const [searchKeyword, setSearchKeyword] = useState(''); + const [searchParams] = useSearchParams(); + const urlStationId = searchParams.get('id'); - // State ข้อมูลสถานีจาก API - const [stationInfo, setStationInfo] = useState(null); - const [stations, setStations] = useState([]); - const [latestStations, setLatestStations] = useState([]); + const [searchKeyword, setSearchKeyword] = useState(''); + const [selectedRange, setSelectedRange] = useState('24h'); // ลำดับที่ 6 - // State ข้อมูลประวัติสำหรับกราฟ - const [waterHistory, setWaterHistory] = useState([]); - const [rainHistory, setRainHistory] = useState([]); + const [stationInfo, setStationInfo] = useState(null); + const [stations, setStations] = useState([]); + const [latestStations, setLatestStations] = useState([]); + const [waterHistory, setWaterHistory] = useState([]); + const [rainHistory, setRainHistory] = useState([]); - // State สถานะการโหลด - const [isLoading, setIsLoading] = useState(true); + const [isLoading, setIsLoading] = useState(true); const [errorMessage, setErrorMessage] = useState(null); + const [activeStationId, setActiveStationId] = useState(urlStationId); - // --- ดึงข้อมูลจาก API เมื่อเปิดหน้าครั้งแรก --- useEffect(() => { - const fetchStationData = async () => { - setIsLoading(true); - setErrorMessage(null); - + const fetchLatest = async () => { try { - const secretKey = - import.meta.env.VITE_API_deviceSecretKey || "MOCK_KEY"; - const endTime = Date.now(); - const startTime = endTime - 24 * 60 * 60 * 1000; - - console.log("🟢 Station Mode: Using getLatestStations() API"); - - // Get latest stations data from API (includes latest values + signal/battery) const latestData = await DeviceService.getLatestStations(); - - if (latestData.length === 0) { - setErrorMessage("ไม่พบสถานี"); - setIsLoading(false); - return; - } - + if (latestData.length === 0) return; setLatestStations(latestData); - // Get unique stations for map const uniqueStationsMap = new Map(); for (const item of latestData) { if (!uniqueStationsMap.has(item.stationId)) { uniqueStationsMap.set(item.stationId, { - stationId: item.stationId, + stationId: item.stationId, stationName: item.stationName, - latitude: item.latitude, - longitude: item.longitude, - deviceId: item.deviceId, - deviceName: item.deviceName, - monitorItem: item.monitorItem + latitude: item.latitude, + longitude: item.longitude, + deviceId: item.deviceId, + deviceName: item.deviceName, + monitorItem: item.monitorItem, }); } } - const stationDevices = Array.from(uniqueStationsMap.values()); - setStations(stationDevices); - // Set station info from first station + const stationsArr = Array.from(uniqueStationsMap.values()); + setStations(stationsArr); + + setActiveStationId(prev => prev || urlStationId || stationsArr[0]?.stationId); + } catch (error) { + console.error('Error fetching stations:', error); + } + }; + fetchLatest(); + }, []); + + useEffect(() => { + const fetchHistory = async () => { + if (!activeStationId || latestStations.length === 0) return; + setIsLoading(true); + setErrorMessage(null); + try { + const secretKey = import.meta.env.VITE_API_deviceSecretKey || 'MOCK_KEY'; + const endTime = Date.now(); + const startTime = endTime - 7 * 24 * 60 * 60 * 1000; + + const devicesForStation = latestStations.filter(d => d.stationId === activeStationId); + if (devicesForStation.length === 0) { + setIsLoading(false); + return; + } + setStationInfo({ - monitorName: latestData[0].monitorItem, - customName: latestData[0].stationName, - warningLevel: 0, + monitorName: devicesForStation[0].monitorItem, + customName: devicesForStation[0].stationName, + warningLevel: 0, deviceLocation: { - latitude: latestData[0].latitude, - longitude: latestData[0].longitude - } + latitude: devicesForStation[0].latitude, + longitude: devicesForStation[0].longitude, + }, }); - // Fetch history for all devices (for charts) const waterData: DeviceRangeData[] = []; - const rainData: DeviceRangeData[] = []; + const rainData: DeviceRangeData[] = []; await Promise.all( - latestData.map(async (device) => { + devicesForStation.map(async (device) => { const data = await DeviceService.getHistory( - device.deviceId, - secretKey, - device.monitorItem, - startTime, - endTime + device.deviceId, secretKey, device.monitorItem, startTime, endTime ); - - const lowerMonitor = device.monitorItem.toLowerCase(); - if (lowerMonitor.includes('water') || lowerMonitor.includes('nw_')) { + const lower = device.monitorItem.toLowerCase(); + if (lower.includes('water') || lower.includes('nw_')) { waterData.push(...data); } else { rainData.push(...data); } }) ); - setWaterHistory(waterData); setRainHistory(rainData); } catch (error) { - console.error('Error fetching station data:', error); - setErrorMessage('ไม่สามารถโหลดข้อมูลสถานีได้ กรุณาลองใหม่อีกครั้ง'); + console.error('Error:', error); + setErrorMessage('ไม่สามารถโหลดข้อมูลได้ กรุณาลองใหม่'); } finally { setIsLoading(false); } }; + fetchHistory(); + }, [activeStationId, latestStations]); - fetchStationData(); - }, []); + // แปลงข้อมูลกราฟ — รวม 2 เส้น + กรองตาม timeRange (ลำดับที่ 5 + 6) + const rangeMs = useMemo( + () => TIME_RANGE_OPTIONS.find(o => o.value === selectedRange)?.ms ?? 24 * 3600 * 1000, + [selectedRange] + ); - // --- แปลงข้อมูล API มาเป็นรูปแบบที่ MapView ต้องการ --- - const mapStations: MapStationData[] = useMemo(() => { - if (stations.length === 0) { - return []; - } + const chartData = useMemo( + () => mergeChartData(waterHistory, rainHistory, rangeMs), + [waterHistory, rainHistory, rangeMs] + ); + + // warningLevel จาก API (ถ้ามี) + const warningLevel = stationInfo?.warningLevel ?? 0; - // Group by stationId and create unique stations - const uniqueStations = new Map(); + const mapStations: MapStationData[] = useMemo(() => { + const unique = new Map(); for (const s of stations) { - if (!uniqueStations.has(s.stationId)) { - uniqueStations.set(s.stationId, { - id: s.stationId, - name: s.stationName || 'Unknown Station', - lat: parseFloat(s.latitude) || 18.575, - lng: parseFloat(s.longitude) || 99.008, - status: 'active' as const, + if (!unique.has(s.stationId)) { + unique.set(s.stationId, { + id: s.stationId, + name: s.stationName || 'Unknown Station', + lat: parseFloat(s.latitude) || 18.575, + lng: parseFloat(s.longitude) || 99.008, + status: 'active', }); } } - - return Array.from(uniqueStations.values()); + return Array.from(unique.values()); }, [stations]); - // --- แปลงข้อมูลประวัติมาเป็นรูปแบบที่กราฟต้องการ --- - const waterChartData: ChartDataPoint[] = useMemo(() => { - return transformToChartData(waterHistory); - }, [waterHistory]); - - const rainChartData: ChartDataPoint[] = useMemo(() => { - return transformToChartData(rainHistory); - }, [rainHistory]); - - // --- กรองรายชื่อสถานีใน Search Panel --- - const filteredMapStations: MapStationData[] = useMemo(() => { - if (searchKeyword.trim() === '') { - return mapStations; - } - const keyword = searchKeyword.toLowerCase(); - return mapStations.filter((station) => { - return station.name.toLowerCase().includes(keyword); - }); + const filteredMapStations = useMemo(() => { + if (!searchKeyword.trim()) return mapStations; + const kw = searchKeyword.toLowerCase(); + return mapStations.filter(s => s.name.toLowerCase().includes(kw)); }, [mapStations, searchKeyword]); - // --- คำนวณค่าล่าสุดของระดับน้ำ (สำหรับแสดงในตาราง) --- const latestWaterValue = useMemo(() => { - const waterDevice = latestStations.find(s => - s.monitorItem.toLowerCase().includes('nw_') || s.monitorItem.toLowerCase().includes('water') - ); - return waterDevice?.monitorValue ? parseFloat(waterDevice.monitorValue).toFixed(3) : '-'; - }, [latestStations]); + const d = latestStations.find(s => s.stationId === activeStationId && (s.monitorItem.toLowerCase().includes('nw_') || s.monitorItem.toLowerCase().includes('water'))); + return d?.monitorValue ? parseFloat(d.monitorValue).toFixed(3) : '-'; + }, [latestStations, activeStationId]); const latestRainValue = useMemo(() => { - const rainDevice = latestStations.find(s => - s.monitorItem.toLowerCase().includes('yl_') || s.monitorItem.toLowerCase().includes('rain') - ); - return rainDevice?.monitorValue ? parseFloat(rainDevice.monitorValue).toFixed(3) : '-'; - }, [latestStations]); + const d = latestStations.find(s => s.stationId === activeStationId && (s.monitorItem.toLowerCase().includes('yl_') || s.monitorItem.toLowerCase().includes('rain'))); + return d?.monitorValue ? parseFloat(d.monitorValue).toFixed(3) : '-'; + }, [latestStations, activeStationId]); + + const latestReportTime = useMemo(() => { + const d = latestStations.find(s => s.stationId === activeStationId && (s.monitorItem.toLowerCase().includes('nw_') || s.monitorItem.toLowerCase().includes('water'))); + return d?.monitorTime || ''; + }, [latestStations, activeStationId]); const latestSignal = useMemo(() => { - const waterDevice = latestStations.find(s => - s.monitorItem.toLowerCase().includes('nw_') || s.monitorItem.toLowerCase().includes('water') - ); - return waterDevice?.signal || 'offline'; - }, [latestStations]); + const d = latestStations.find(s => s.stationId === activeStationId && (s.monitorItem.toLowerCase().includes('nw_') || s.monitorItem.toLowerCase().includes('water'))); + return d?.signal || 'offline'; + }, [latestStations, activeStationId]); const latestBattery = useMemo(() => { - const waterDevice = latestStations.find(s => - s.monitorItem.toLowerCase().includes('nw_') || s.monitorItem.toLowerCase().includes('water') - ); - return waterDevice?.battery ?? 0; - }, [latestStations]); + const d = latestStations.find(s => s.stationId === activeStationId && (s.monitorItem.toLowerCase().includes('nw_') || s.monitorItem.toLowerCase().includes('water'))); + return Number(d?.battery ?? 0); + }, [latestStations, activeStationId]); - const latestReportTime = useMemo(() => { - const waterDevice = latestStations.find(s => - s.monitorItem.toLowerCase().includes('nw_') || s.monitorItem.toLowerCase().includes('water') - ); - return waterDevice?.monitorTime || ''; - }, [latestStations]); - -/* const latestWaterStatus = useMemo(() => { - if (waterHistory.length === 0) { - return 'normal'; - } - const value = parseFloat(waterHistory[0].monitorValue); - if (value >= 5.0) return 'critical'; - if (value >= 4.5) return 'warning'; - return 'normal'; - }, [waterHistory]); */ - - // --- Render --- - if (isLoading) { - return ( -
-
กำลังโหลดข้อมูล...
-
- ); - } + // Y domain คำนวณอัตโนมัติพร้อม threshold + const waterYMax = useMemo(() => { + const vals = chartData.map(d => d.water ?? 0); + const dataMax = Math.max(...vals, warningLevel * 1.1, 1); + return Math.ceil(dataMax * 1.1); + }, [chartData, warningLevel]); - if (errorMessage) { - return ( -
-
{errorMessage}
-
- ); - } + const rainYMax = useMemo(() => { + const vals = chartData.map(d => d.rain ?? 0); + return Math.ceil(Math.max(...vals, 1) * 1.15); + }, [chartData]); + + if (isLoading) return
กำลังโหลดข้อมูล...
; + if (errorMessage) return
{errorMessage}
; return (
- {/* ==================================================== - ส่วนที่ 1: แผนที่ (ซ้าย) + Panel ค้นหาสถานี (ขวา) - ==================================================== */} + {/* ส่วนที่ 1: แผนที่ + Panel */}
- - {/* แผนที่ */}
- +
- {/* Panel ค้นหาสถานี */}
- {/* ช่องค้นหา */}
{ placeholder="ค้นหาสถานี..." className={styles.searchInput} value={searchKeyword} - onChange={(event) => setSearchKeyword(event.target.value)} + onChange={(e) => setSearchKeyword(e.target.value)} />
- {/* หัวตาราง Panel */}
ชื่อสถานี - รายละเอียดตำแหน่ง + ตำแหน่ง
- {/* รายการสถานี */}
{filteredMapStations.length > 0 ? ( filteredMapStations.map((station) => ( -
+
setActiveStationId(String(station.id))} + style={{ cursor: 'pointer', background: activeStationId === station.id ? 'var(--color-bg-surface)' : 'transparent' }} + > {station.name} {`${Number(station.lat).toFixed(4)}, ${Number(station.lng).toFixed(4)}`} @@ -305,56 +331,42 @@ const StationPage: React.FC = () => {
- {/* ==================================================== - ส่วนที่ 2: ตารางข้อมูลสถานี (แถวทรงแคปซูล) - ==================================================== */} + {/* ส่วนที่ 2: ตารางข้อมูล */}
- {/* หัวคอลัมน์ */}
ชื่อสถานี
เวลา
สัญญาณ
แบตเตอรี่
-
ระดับน้ำ(เมตร)
-
ปริมาณน้ำฝน(มิลลิเมตร/ชั่วโมง)
+
ระดับน้ำ (ม.)
+
ปริมาณน้ำฝน (มม./ชม.)
- {/* แถวข้อมูล */}
{stationInfo ? (
-
{stationInfo.customName || stationInfo.monitorName || 'Unknown Station'}
-
{latestReportTime - ? new Date(latestReportTime).toLocaleTimeString('en-GB', { - hour: '2-digit', - minute: '2-digit', - }) + ? new Date(latestReportTime).toLocaleTimeString('en-GB', { hour: '2-digit', minute: '2-digit' }) : '-'}
-
-
0 ? styles.iconGood : styles.iconBad}`}> 0 ? 'bi bi-battery-full' : 'bi bi-battery-empty'}> - {latestBattery > 0 ? '' : '0%'}
- -
+
{latestWaterValue}
- -
+
{latestRainValue}
@@ -364,102 +376,154 @@ const StationPage: React.FC = () => {
- {/* ==================================================== - ส่วนที่ 3: กราฟรายวัน (ระดับน้ำ + ปริมาณฝน) - ==================================================== */} + {/* ส่วนที่ 3: กราฟรวม 2 เส้น + Tab ช่วงเวลา (ลำดับที่ 5 + 6) */}
+
+ + {/* Header: Tab เลือกช่วงเวลา (ลำดับที่ 6) */} +
+
+ {/* Legend เส้นระดับน้ำ */} +
+ + + + + + ระดับน้ำ (ม.) +
+ {/* Legend เส้นฝน */} +
+ + + + + + ปริมาณน้ำฝน (มม.) +
+ {/* Legend เส้น warning */} + {warningLevel > 0 && ( +
+ + + + + ระดับเฝ้าระวัง + +
+ )} +
+ + {/* Time Range Tabs (ลำดับที่ 6) */} +
+ {TIME_RANGE_OPTIONS.map((opt) => ( + + ))} +
+
- {/* กราฟระดับน้ำ */} -
-
+ {/* กราฟรวม 2 เส้น dual Y-axis (ลำดับที่ 5) */} +
- + - - - + + + + + + + + - - - + + {/* แกน X */} + + + {/* แกน Y ซ้าย — ระดับน้ำ */} + `${v}ม.`} + /> + + {/* แกน Y ขวา — ปริมาณฝน */} + `${v}มม.`} + /> + + } /> + + {/* เส้นเฝ้าระวัง (ถ้ามี) */} + {warningLevel > 0 && ( + + )} + + {/* Area ระดับน้ำ */} - - -
-
-
- - - - - - ระดับน้ำ -
-
-
- {/* กราฟปริมาณฝน */} -
-
- - - - - - - - - - - - - - +
-
-
- - - - - - ปริมาณน้ำฝนสะสม -
-
-
); }; -export default StationPage; +export default StationPage; \ No newline at end of file diff --git a/Frontend/src/service/deviceService.ts b/Frontend/src/service/deviceService.ts index 68df18d..a14cb9d 100644 --- a/Frontend/src/service/deviceService.ts +++ b/Frontend/src/service/deviceService.ts @@ -1,4 +1,12 @@ +import { + MOCK_STATIONS, + MOCK_LATEST_STATIONS, + generateMockHistory, + getMockStationInfo, + generateRainProbability +} from '../data/mockData'; + export interface DeviceLatestResponse { code: number; monitorValue: string; @@ -83,7 +91,7 @@ const handleResponse = async (response: Response) => { }; // 1. Single toggle to control mock vs real API -export let USE_MOCK_DATA = false; // Set to false for real API, true for mock +export let USE_MOCK_DATA = true; // Set to false for real API, true for mock export const setUseMockData = (isMock: boolean) => { USE_MOCK_DATA = isMock; @@ -168,7 +176,8 @@ export const DeviceService = { getStations: async (): Promise => { if (USE_MOCK_DATA) { - return []; + await new Promise(resolve => setTimeout(resolve, 500)); + return MOCK_STATIONS; } const response = await fetch('/api/v2/stations/', { method: 'GET', @@ -180,7 +189,8 @@ export const DeviceService = { getLatestStations: async (): Promise => { if (USE_MOCK_DATA) { - return []; + await new Promise(resolve => setTimeout(resolve, 500)); + return MOCK_LATEST_STATIONS; } const response = await fetch('/api/v2/stations/latest', { method: 'GET', @@ -192,71 +202,22 @@ export const DeviceService = { }; export const MockDeviceService = { - // ใส่ _ นำหน้า deviceId เพราะไม่ได้ใช้ใน Logic ของ Mock - getStationInfo: async (_deviceId: string): Promise => { + getStationInfo: async (deviceId: string): Promise => { await new Promise(resolve => setTimeout(resolve, 500)); - - return { - monitorName: "MOCK-001", - customName: "Mockup Station (ลำพูน)", - warningLevel: 1, - deviceLocation: { - latitude: "18.575", - longitude: "99.008" - } - }; + return getMockStationInfo(deviceId); }, - - // ใส่ _ นำหน้าตัวแปรที่ไม่ได้ใช้ใน Mock Logic getHistory: async ( - _deviceId: string, + deviceId: string, _deviceSecretKey: string, monitorItem: string, - _start: number, - end: number // 'end' มีการใช้ในลูป เลยไม่ต้องใส่ _ + start: number, + end: number ): Promise => { await new Promise(resolve => setTimeout(resolve, 800)); - - const mockData: DeviceRangeData[] = []; - const oneHour = 60 * 60 * 1000; - - for (let i = 0; i < 24; i++) { - const time = end - (i * oneHour); - - let value = 0; - if (monitorItem === "water_level") { - value = 4.5 + Math.random(); - } else { - value = Math.random() > 0.7 ? Math.random() * 20 : 0; - } - - mockData.push({ - monitorTime: new Date(time).toISOString(), - monitorValue: value.toFixed(2) - }); - } - - return mockData; + return generateMockHistory(deviceId, monitorItem, start, end); }, - - // ... (ส่วนที่เหลือคงเดิม) ... getRainProbability: async (): Promise => { await new Promise(resolve => setTimeout(resolve, 500)); - const rows: RainProbabilityData[] = []; - for (let h = 1; h <= 24; h++) { - const hour = h % 24; - const base = hour >= 6 && hour <= 18 ? 30 : 10; - rows.push({ - time: `${String(hour).padStart(2, '0')}:00`, - sun: Math.round(base + Math.random() * 40), - mon: Math.round(base + Math.random() * 40), - tue: Math.round(base + Math.random() * 40), - wed: Math.round(base + Math.random() * 40), - thu: Math.round(base + Math.random() * 40), - fri: Math.round(base + Math.random() * 40), - sat: Math.round(base + Math.random() * 40), - }); - } - return rows; + return generateRainProbability(); } }; \ No newline at end of file diff --git a/Frontend/src/styles/Dashboard-StationTable.module.css b/Frontend/src/styles/Dashboard-StationTable.module.css index e13c51c..0adaf9a 100644 --- a/Frontend/src/styles/Dashboard-StationTable.module.css +++ b/Frontend/src/styles/Dashboard-StationTable.module.css @@ -10,9 +10,11 @@ padding: 14px 24px; border-bottom: 1px solid var(--color-border-line); font-weight: 600; - font-size: 14px; - color: var(--color-text-primary); + font-size: 13px; + color: var(--color-text-secondary); align-items: center; + letter-spacing: 0.04em; + text-transform: uppercase; } .tableBody { @@ -20,15 +22,17 @@ flex-direction: column; } +/* --- แถวข้อมูลหลัก --- */ .dataRow { display: grid; grid-template-columns: 2fr 1fr 0.5fr 0.5fr 1fr 1fr; - padding: 14px 24px; + padding: 13px 24px; border-bottom: 1px solid var(--color-border-line); font-size: 14px; color: var(--color-text-primary); align-items: center; - transition: background-color 0.2s; + transition: background-color 0.15s ease; + border-left: 3px solid transparent; } .dataRow:last-child { @@ -39,12 +43,121 @@ background-color: rgba(255, 255, 255, 0.04); } +/* --- สี Row ตามสถานะ --- */ +.dataRow.rowNormal { + border-left-color: var(--color-status-normal); +} + +.dataRow.rowWarning { + border-left-color: var(--color-status-warning); + background-color: rgba(255, 174, 0, 0.05); +} + +.dataRow.rowWarning:hover { + background-color: rgba(255, 174, 0, 0.09); +} + +.dataRow.rowCritical { + border-left-color: var(--color-status-critical); + background-color: rgba(239, 68, 68, 0.07); +} + +.dataRow.rowCritical:hover { + background-color: rgba(239, 68, 68, 0.12); +} + +/* --- Status Badge --- */ +.stationNameCell { + display: flex; + align-items: center; + gap: 10px; + min-width: 0; +} + +.stationName { + font-weight: 500; + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; +} + +.statusBadge { + display: inline-flex; + align-items: center; + gap: 5px; + padding: 2px 10px; + border-radius: 100px; + font-size: 11px; + font-weight: 700; + letter-spacing: 0.03em; + white-space: nowrap; + flex-shrink: 0; +} + +.badgeDot { + width: 6px; + height: 6px; + border-radius: 50%; + flex-shrink: 0; +} + +.badgeNormal { + background-color: rgba(16, 185, 129, 0.15); + color: var(--color-status-normal); +} + +.badgeNormal .badgeDot { + background-color: var(--color-status-normal); +} + +.badgeWarning { + background-color: rgba(255, 174, 0, 0.15); + color: var(--color-status-warning); +} + +.badgeWarning .badgeDot { + background-color: var(--color-status-warning); +} + +.badgeCritical { + background-color: rgba(239, 68, 68, 0.15); + color: var(--color-status-critical); + animation: pulseBadge 2s ease-in-out infinite; +} + +.badgeCritical .badgeDot { + background-color: var(--color-status-critical); +} + +@keyframes pulseBadge { + 0%, 100% { opacity: 1; } + 50% { opacity: 0.65; } +} + +/* --- Signal & Battery Icons --- */ .iconCell { display: flex; align-items: center; - color: var(--color-text-secondary); + justify-content: center; + gap: 4px; + font-size: 16px; } +.iconOnline { color: var(--color-status-normal); } +.iconOffline { color: var(--color-status-warning); } +.iconCritical { color: var(--color-status-critical); } + +.iconLabel { + font-size: 10px; + font-weight: 600; + opacity: 0.7; +} + +/* --- ค่าตัวเลข --- */ +.valueNormal { color: var(--color-text-primary); font-family: var(--font-data); font-weight: 600; } +.valueWarning { color: var(--color-status-warning); font-family: var(--font-data); font-weight: 700; } +.valueCritical { color: var(--color-status-critical); font-family: var(--font-data); font-weight: 700; } + .centerAlign { text-align: center; justify-content: center; @@ -52,9 +165,9 @@ .rightAlign { text-align: right; - justify-content: flex-end; } +/* --- Misc --- */ .loadingText { color: var(--color-text-secondary); text-align: center; @@ -76,7 +189,7 @@ } .exportButton { - padding: 4px 14px; + padding: 5px 16px; background-color: var(--color-text-onBrand); border: none; border-radius: 40px; @@ -86,17 +199,9 @@ font-weight: 600; color: var(--color-bg-page); letter-spacing: 0.3px; + transition: opacity 0.15s; } -.statusText { - margin-left: 8px; - font-size: 10px; -} - -.statusCritical { - color: var(--color-status-critical); -} - -.statusWarning { - color: var(--color-status-warning); -} +.exportButton:hover { + opacity: 0.85; +} \ No newline at end of file diff --git a/Frontend/src/styles/DashboradPage.module.css b/Frontend/src/styles/DashboradPage.module.css index 113d58e..71a2aa1 100644 --- a/Frontend/src/styles/DashboradPage.module.css +++ b/Frontend/src/styles/DashboradPage.module.css @@ -1,119 +1,102 @@ -/* คอนเทนเนอร์ใหญ่ของหน้าเพจแดชบอร์ด */ +/* DashboradPage.module.css */ + .container { padding: 20px 0; - background-color: #1E293B; + background-color: var(--color-bg-page); color: #ffffff; + min-height: 100vh; } +/* ---- ส่วนบน: DataCards เต็มแถว (ไม่มี prob table แล้ว) ---- */ .topSection { - display: grid; - grid-template-columns: 1.5fr 1fr; - /* แบ่งพื้นที่ฝั่งการ์ด 60% ฝั่งตารางฝน 40% */ - gap: 24px; width: 1356px; - /* ล็อกหน้ากว้างให้เท่ากับหัวข้อตารางด้านล่าง */ - margin: 0 auto 30px auto; - /* จัดกึ่งกลางหน้าจอ */ -} - -.topLeft { - display: flex; - flex-direction: column; - gap: 20px; + margin: 0 auto 24px auto; } .cardGrid { - display: flex; - gap: 20px; - flex-wrap: wrap; + display: grid; + grid-template-columns: repeat(4, 1fr); + gap: 16px; } -/* แถบตัวเลือกดรอปดาวน์ตั้งค่ากราฟ */ -.controlBar { - display: flex; - gap: 12px; +/* ---- ส่วนกลาง: กราฟ + Station Selector ---- */ +.chartSection { + width: 1356px; + margin: 0 auto 24px auto; } -.selectInput { - background-color: #222b3a; +.chartWrapper { + background: #222b3a; + border-radius: 16px; border: 1px solid #2d3748; - color: #ffffff; - padding: 8px 16px; - border-radius: 8px; - outline: none; - cursor: pointer; - font-size: 14px; + padding-bottom: 16px; } -.topRight { - flex: 1; +/* Station Selector Bar */ +.stationSelectorRow { display: flex; + align-items: center; + gap: 14px; + padding: 18px 24px 4px; } -.probTableCard { - background: var(--color-bg-surface); - border-radius: 10px; - border: 1px solid var(--color-border-line); - padding: 16px 20px; - display: flex; - flex-direction: column; - box-shadow: 0 2px 8px rgba(0, 0, 0, 0.3); - height: fit-content; - max-height: 120px; -} - -.probHeader { - font-size: 15px; - font-weight: 600; - color: var(--color-text-primary); - margin-bottom: 8px; -} - -.probGridHeader { - display: grid; - grid-template-columns: 56px repeat(7, 1fr); - gap: 6px 8px; - text-align: center; +.selectorLabel { font-size: 12px; + font-weight: 700; color: var(--color-text-secondary); - font-weight: 600; - border-bottom: 1px solid var(--color-border-line); - padding-bottom: 8px; - margin-bottom: 4px; + letter-spacing: 0.06em; + text-transform: uppercase; + white-space: nowrap; + flex-shrink: 0; } -.probTimeCol { - text-align: left; - color: var(--color-text-primary); - font-size: 12px; +/* Dropdown wrapper */ +.dropdownWrap { + display: flex; + align-items: center; + gap: 10px; + background: rgba(255,255,255,0.06); + border: 1px solid rgba(255,255,255,0.12); + border-radius: 10px; + padding: 8px 14px; + min-width: 240px; + transition: border-color 0.15s; } - -.probScrollArea { - flex: 1; - overflow-y: auto; - min-height: 0; - scrollbar-width: thin; - scrollbar-color: rgba(255, 255, 255, 0.15) transparent; +.dropdownWrap:focus-within { + border-color: rgba(0,153,255,0.5); } -.probScrollArea::-webkit-scrollbar { - width: 6px; +/* status dot ข้างหน้า dropdown */ +.dropdownDot { + width: 9px; + height: 9px; + border-radius: 50%; + flex-shrink: 0; } -.probScrollArea::-webkit-scrollbar-track { +/* select element */ +.stationDropdown { + flex: 1; background: transparent; + border: none; + outline: none; + color: var(--color-text-primary); + font-family: var(--font-ui); + font-size: 14px; + font-weight: 600; + cursor: pointer; + appearance: none; + -webkit-appearance: none; } - -.probScrollArea::-webkit-scrollbar-thumb { - background: rgba(255, 255, 255, 0.15); - border-radius: 3px; +.stationDropdown option { + background: #1e293b; + color: #f1f5f9; + font-size: 14px; } -.probGrid { - display: grid; - grid-template-columns: 60px repeat(7, 1fr); - /* คอลัมน์เวลา + คอลัมน์วันทั้ง 7 */ - gap: 12px; +/* Loading state กราฟ */ +.chartLoading { + padding: 80px 24px; text-align: center; font-size: 13px; color: #8b95a5; @@ -124,7 +107,7 @@ color: #ffffff; } -/* --- ส่วนกลาง: พื้นที่วางกราฟเทคนิคัลระดับน้ำ --- */ +/* --- ส่วนกลาง: กราฟ --- */ .chartSection { width: 1356px; margin: 0 auto 30px auto; @@ -135,16 +118,18 @@ border-radius: 16px; padding: 24px; border: 1px solid #2d3748; + /* เพิ่มความสูงนิดนึงเพื่อให้ label Threshold ไม่ถูกตัด */ + height: 340px; } -/* --- ส่วนล่างสุด: พื้นที่วางตารางข้อมูลสถานีทรงแคปซูล --- */ +/* ---- ส่วนล่าง: ตาราง ---- */ .tableSection { width: 1356px; margin: 0 auto; } +/* ---- Responsive ---- */ @media (max-width: 1400px) { - .topSection, .chartSection, .tableSection { @@ -154,12 +139,18 @@ } } -@media (max-width: 1024px) { - .topSection { - grid-template-columns: 1fr; +@media (max-width: 1200px) { + .cardGrid { + grid-template-columns: repeat(2, 1fr); } +} +@media (max-width: 768px) { .cardGrid { - flex-wrap: wrap; + grid-template-columns: 1fr; + } + .stationSelectorRow { + flex-direction: column; + align-items: flex-start; } } \ No newline at end of file diff --git a/Frontend/src/styles/Form.module.css b/Frontend/src/styles/Form.module.css index 6b2cc76..5f1fc1f 100644 --- a/Frontend/src/styles/Form.module.css +++ b/Frontend/src/styles/Form.module.css @@ -5,32 +5,62 @@ background: linear-gradient(135deg, #0d2a6b 0%, #0d1b3e 40%, #0a1628 100%); display: flex; flex-direction: column; + overflow: hidden; } -.navbar { - display: flex; - flex-direction: row; - align-items: center; - padding: 15px 43px; - gap: 10px; +/* ===== Animated Wave Background ===== */ +.waveWrapper { + position: absolute; + left: 0; + bottom: 0; width: 100%; - height: 77px; - background: rgba(0, 0, 0, 0.5); - box-sizing: border-box; - flex-shrink: 0; - z-index: 10; - position: relative; + height: 45vh; + min-height: 260px; + overflow: hidden; + line-height: 0; + z-index: 0; + pointer-events: none; } -.navLogoText { - font-family: var(--font-ui); - font-style: normal; - font-weight: 700; - font-size: 24px; - line-height: 32px; - text-align: center; - color: var(--color-text-onBrand); - margin: 0; +.waveSvg { + position: absolute; + bottom: 0; + left: 0; + display: block; + width: 200%; + height: 100%; + animation: waveDrift 22s linear infinite; +} + +.waveSvg.waveBack { + animation-duration: 34s; + animation-direction: reverse; + opacity: 0.35; +} + +.waveSvg.waveMid { + animation-duration: 26s; + opacity: 0.55; +} + +.waveSvg.waveFront { + animation-duration: 18s; + opacity: 0.9; +} + +@keyframes waveDrift { + from { + transform: translateX(0); + } + to { + transform: translateX(-50%); + } +} + +@media (prefers-reduced-motion: reduce) { + .waveSvg { + animation: none; + } } .navbar { @@ -60,6 +90,8 @@ } .mainContent { + position: relative; + z-index: 1; display: flex; flex: 1; align-items: center; diff --git a/Frontend/src/styles/MultiStationTable.module.css b/Frontend/src/styles/MultiStationTable.module.css new file mode 100644 index 0000000..23c7def --- /dev/null +++ b/Frontend/src/styles/MultiStationTable.module.css @@ -0,0 +1,185 @@ +/* MultiStationTable.module.css */ + +.container { + width: 100%; + display: flex; + flex-direction: column; + background: var(--color-bg-surface); + border-radius: 12px; + border: 1px solid var(--color-border-line); + overflow: hidden; +} + +/* ---- Header row: ชื่อ + Export ---- */ +.exportRow { + display: flex; + justify-content: space-between; + align-items: center; + padding: 14px 24px 12px; + border-bottom: 1px solid var(--color-border-line); +} + +.tableTitle { + font-size: 14px; + font-weight: 600; + color: var(--color-text-primary); +} + +.exportBtn { + padding: 5px 16px; + background: var(--color-text-onBrand); + border: none; + border-radius: 40px; + cursor: pointer; + font-family: var(--font-ui); + font-size: 12px; + font-weight: 600; + color: var(--color-bg-page); + letter-spacing: 0.3px; + transition: opacity 0.15s; +} +.exportBtn:hover { opacity: 0.85; } + +/* ---- Column Header ---- */ +.tableHeader { + display: grid; + grid-template-columns: 2fr 1fr 0.6fr 0.8fr 1fr 1.2fr; + padding: 10px 24px; + font-size: 12px; + font-weight: 700; + color: var(--color-text-secondary); + letter-spacing: 0.05em; + text-transform: uppercase; + border-bottom: 1px solid var(--color-border-line); + background: rgba(255,255,255,0.02); +} + +/* ---- Table Body ---- */ +.tableBody { + display: flex; + flex-direction: column; +} + +/* ---- Row ---- */ +.row { + display: grid; + grid-template-columns: 2fr 1fr 0.6fr 0.8fr 1fr 1.2fr; + padding: 13px 24px; + border-bottom: 1px solid var(--color-border-line); + font-size: 14px; + color: var(--color-text-primary); + align-items: center; + border-left: 3px solid transparent; + cursor: pointer; + transition: background 0.12s; +} +.row:last-child { border-bottom: none; } +.row:hover { background: rgba(255,255,255,0.04); } + +/* สีพื้นหลังตามสถานะ */ +.rowWarning { background: rgba(255, 174, 0, 0.05); } +.rowCritical { background: rgba(239, 68, 68, 0.07); } +.rowWarning:hover { background: rgba(255, 174, 0, 0.09); } +.rowCritical:hover { background: rgba(239, 68, 68, 0.11); } + +/* แถวที่กำลังเลือก */ +.rowSelected { + background: rgba(0, 153, 255, 0.08) !important; + box-shadow: inset 0 0 0 1px rgba(0, 153, 255, 0.2); +} + +/* ---- Cells ---- */ +.nameCell { + display: flex; + align-items: center; + gap: 10px; + min-width: 0; +} + +.stationName { + font-weight: 600; + font-size: 14px; + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; +} + +.selectedPill { + flex-shrink: 0; + font-size: 10px; + font-weight: 700; + padding: 2px 8px; + border-radius: 40px; + background: rgba(0, 153, 255, 0.18); + color: #0099ff; + letter-spacing: 0.03em; +} + +.center { + display: flex; + align-items: center; + justify-content: center; + gap: 4px; +} + +.batteryPct { + font-size: 11px; + color: var(--color-text-secondary); +} + +.waterValue { + font-family: var(--font-data); + font-weight: 700; + font-size: 15px; +} + +/* ---- Status Badge ---- */ +.badge { + display: inline-flex; + align-items: center; + gap: 5px; + padding: 3px 10px; + border-radius: 40px; + font-size: 11px; + font-weight: 700; +} + +.badgeDot { + width: 6px; + height: 6px; + border-radius: 50%; + flex-shrink: 0; +} + +.badgeNormal { + background: rgba(16, 185, 129, 0.15); + color: var(--color-status-normal); +} +.badgeNormal .badgeDot { background: var(--color-status-normal); } + +.badgeWarning { + background: rgba(255, 174, 0, 0.15); + color: var(--color-status-warning); +} +.badgeWarning .badgeDot { background: var(--color-status-warning); } + +.badgeCritical { + background: rgba(239, 68, 68, 0.15); + color: var(--color-status-critical); + animation: pulseBadge 2s ease-in-out infinite; +} +.badgeCritical .badgeDot { background: var(--color-status-critical); } + +@keyframes pulseBadge { + 0%, 100% { opacity: 1; } + 50% { opacity: 0.6; } +} + +/* ---- Empty / Loading ---- */ +.loadingText, +.emptyText { + padding: 32px; + text-align: center; + color: var(--color-text-secondary); + font-size: 14px; +} \ No newline at end of file diff --git a/Frontend/src/styles/NavBar.module.css b/Frontend/src/styles/NavBar.module.css index 61e2cf7..a4ec5ff 100644 --- a/Frontend/src/styles/NavBar.module.css +++ b/Frontend/src/styles/NavBar.module.css @@ -1,182 +1,392 @@ -/* components/Navbar.module.css */ +/* Layout Container */ +.layoutContainer { + width: 100%; + height: 100vh; + display: flex; + flex-direction: column; + background-color: var(--color-bg-page); + box-sizing: border-box; +} + +.contentArea { + flex: 1; + width: 100%; + max-width: 1440px; + margin: 0 auto; + padding: 24px 40px; + box-sizing: border-box; + overflow-y: auto; +} + +.contentAreaFullHeight { + flex: 1; + width: 100%; + overflow: hidden; +} +/* ==================================================== + Navbar — 64px (ลดจาก 80px) + ==================================================== */ .navbarContainer { - display: flex; - justify-content: space-between; - align-items: center; - background-color: #1a1f26; - padding: 0 40px; - width: 100%; - height: 80px; - border-bottom: 1px solid var(--color-border-line); - font-family: var(--font-ui); - box-sizing: border-box; -} - -/* 1. Logo */ + display: flex; + justify-content: space-between; + align-items: center; + background-color: #111827; + padding: 0 32px; + width: 100%; + height: 64px; + border-bottom: 1px solid rgba(255, 255, 255, 0.07); + font-family: var(--font-ui); + box-sizing: border-box; + flex-shrink: 0; + position: relative; + z-index: 100; +} + +/* ==================================================== + Logo + ==================================================== */ .logoGroup { - display: flex; - align-items: center; + display: flex; + align-items: center; + gap: 10px; + flex-shrink: 0; +} + +.logoIcon { + width: 30px; + height: 30px; + background: linear-gradient(135deg, #0099ff, #0066cc); + border-radius: 8px; + display: flex; + align-items: center; + justify-content: center; + font-size: 15px; + flex-shrink: 0; } .logoText { - color: var(--color-text-primary); - font-size: 22px; - font-weight: 700; - margin: 0; - letter-spacing: -0.5px; + color: var(--color-text-primary); + font-size: 18px; + font-weight: 700; + margin: 0; + letter-spacing: -0.4px; } -/* 2. Menu Group */ +/* ==================================================== + Menu Group — Pill Active (ลำดับที่ 8 - KEY CHANGE) + ==================================================== */ .menuGroup { - display: flex; - height: 100%; - gap: 8px; + display: flex; + align-items: center; + gap: 2px; + height: 100%; } .navItem { - display: flex; - flex-direction: column; - align-items: center; - justify-content: center; - text-decoration: none; - height: 100%; - padding: 0 20px; - border-bottom: 3px solid transparent; - transition: all 0.2s ease; - box-sizing: border-box; + display: flex; + align-items: center; + gap: 7px; + text-decoration: none; + padding: 7px 14px; + border-radius: 8px; + color: var(--color-text-secondary); + font-size: 14px; + font-weight: 500; + transition: background 0.15s, color 0.15s; + white-space: nowrap; } -.navTitle { - font-size: 15px; - font-weight: 600; - color: var(--color-text-secondary); - margin-bottom: 3px; +.navItem:hover { + background: rgba(255, 255, 255, 0.06); + color: var(--color-text-primary); } -.navSubtitle { - font-size: 11px; - color: var(--color-text-tertiary); +/* Active = pill น้ำเงิน ชัดเจนแม้ในแสงสว่าง */ +.navItem.active { + background: rgba(0, 153, 255, 0.15); + color: var(--color-brand-secondary); + font-weight: 600; } -/* Active state */ -.active { - border-bottom: 3px solid var(--color-brand-secondary); +.navLabel { + font-family: var(--font-ui); } -.active .navTitle { - color: var(--color-text-primary); +/* ==================================================== + Right Group + ==================================================== */ +.rightGroup { + display: flex; + align-items: center; + gap: 8px; + flex-shrink: 0; } -.active .navSubtitle { - color: var(--color-brand-secondary); +/* ---- Icon Button (Search / Bell) ---- */ +.iconBtn { + display: flex; + align-items: center; + justify-content: center; + width: 36px; + height: 36px; + border-radius: 8px; + background: rgba(255, 255, 255, 0.05); + border: 1px solid rgba(255, 255, 255, 0.08); + color: var(--color-text-secondary); + cursor: pointer; + transition: background 0.15s, color 0.15s; + flex-shrink: 0; } -/* Hover */ -.navItem:hover .navTitle { - color: var(--color-text-primary); +.iconBtn:hover { + background: rgba(255, 255, 255, 0.1); + color: var(--color-text-primary); } -/* 3. Right Group */ -.rightGroup { - display: flex; - align-items: center; - gap: 16px; +/* ---- Bell + Alert Dot ---- */ +.bellWrap { + position: relative; } -/* Search Box */ -.searchBox { - display: flex; - align-items: center; - background-color: #878889; - border-radius: 40px; - padding: 8px 16px; - width: 280px; - border: 1px solid var(--color-border-line); +.alertDot { + position: absolute; + top: 4px; + right: 4px; + width: 7px; + height: 7px; + border-radius: 50%; + background: var(--color-status-critical); + border: 2px solid #111827; + animation: pulseDot 2s ease-in-out infinite; } -.searchIcon { - color: #111827; - font-size: 14px; - margin-right: 8px; +@keyframes pulseDot { + 0%, 100% { transform: scale(1); opacity: 1; } + 50% { transform: scale(1.3); opacity: 0.7; } } -.searchInput { - background: transparent; - border: none; - color: var(--color-text-primary); - outline: none; - font-size: 14px; - width: 100%; - font-family: var(--font-ui); +/* ---- User Pill ---- */ +.userWrap { + position: relative; } -.searchInput::placeholder { - color: #111827; +.userBtn { + display: flex; + align-items: center; + gap: 8px; + padding: 5px 12px 5px 6px; + border-radius: 40px; + background: rgba(255, 255, 255, 0.06); + border: 1px solid rgba(255, 255, 255, 0.09); + cursor: pointer; + transition: background 0.15s; + color: var(--color-text-primary); + font-family: var(--font-ui); } -/* Log out Button - white pill */ -.profileBtn { - background-color: #ffffff; - color: #111827; - border: none; - border-radius: 40px; - padding: 10px 28px; - font-family: var(--font-ui); - font-size: 14px; - font-weight: 700; - cursor: pointer; - transition: all 0.2s ease; - white-space: nowrap; +.userBtn:hover { + background: rgba(255, 255, 255, 0.1); } -.profileBtn:hover { - background-color: #f3f4f6; +.avatar { + width: 26px; + height: 26px; + border-radius: 50%; + background: #1d4ed8; + display: flex; + align-items: center; + justify-content: center; + font-size: 10px; + font-weight: 700; + color: #ffffff; + flex-shrink: 0; } -/* Layout Wrapper */ -.layoutContainer { - width: 100%; - height: 100vh; - display: flex; - flex-direction: column; - background-color: var(--color-bg-page); - box-sizing: border-box; +.userName { + font-size: 13px; + font-weight: 500; + color: var(--color-text-primary); } -.contentArea { - flex: 1; - width: 100%; - max-width: 1440px; - margin: 0 auto; - padding: 24px 40px; - box-sizing: border-box; - overflow-y: auto; +.chevron { + color: var(--color-text-tertiary); + transition: transform 0.2s; } -.contentAreaFullHeight { - flex: 1; - width: 100%; - overflow: hidden; +.chevronUp { + transform: rotate(180deg); +} + +/* ==================================================== + User Dropdown + ==================================================== */ +.dropdown { + position: absolute; + top: calc(100% + 8px); + right: 0; + width: 220px; + background: #1e293b; + border: 1px solid rgba(255, 255, 255, 0.1); + border-radius: 12px; + box-shadow: 0 8px 24px rgba(0, 0, 0, 0.5); + z-index: 200; + overflow: hidden; + animation: fadeIn 0.12s ease-out; +} + +@keyframes fadeIn { + from { opacity: 0; transform: translateY(-6px); } + to { opacity: 1; transform: translateY(0); } +} + +.dropdownProfile { + display: flex; + align-items: center; + gap: 12px; + padding: 14px 16px; +} + +.dropdownAvatar { + width: 36px; + height: 36px; + border-radius: 50%; + background: #1d4ed8; + display: flex; + align-items: center; + justify-content: center; + font-size: 12px; + font-weight: 700; + color: #fff; + flex-shrink: 0; +} + +.dropdownName { + font-size: 14px; + font-weight: 600; + color: var(--color-text-primary); } +.dropdownRole { + font-size: 12px; + color: var(--color-text-secondary); + margin-top: 1px; +} + +.dropdownDivider { + height: 1px; + background: rgba(255, 255, 255, 0.07); + margin: 0; +} + +.dropdownItem { + display: flex; + align-items: center; + gap: 10px; + width: 100%; + padding: 12px 16px; + background: none; + border: none; + cursor: pointer; + font-family: var(--font-ui); + font-size: 13px; + color: var(--color-text-secondary); + transition: background 0.12s, color 0.12s; + text-align: left; +} + +.dropdownItem:hover { + background: rgba(239, 68, 68, 0.1); + color: var(--color-status-critical); +} + +/* ==================================================== + Search Overlay + ==================================================== */ +.searchOverlay { + position: fixed; + inset: 0; + background: rgba(0, 0, 0, 0.65); + backdrop-filter: blur(4px); + z-index: 1000; + display: flex; + align-items: flex-start; + justify-content: center; + padding-top: 120px; + animation: overlayFadeIn 0.15s ease-out; +} + +@keyframes overlayFadeIn { + from { opacity: 0; } + to { opacity: 1; } +} + +.searchModal { + width: 540px; + max-width: 90vw; + background: #1e293b; + border: 1px solid rgba(255, 255, 255, 0.12); + border-radius: 14px; + box-shadow: 0 20px 60px rgba(0, 0, 0, 0.6); + overflow: hidden; + animation: modalSlide 0.15s ease-out; +} + +@keyframes modalSlide { + from { opacity: 0; transform: translateY(-12px) scale(0.98); } + to { opacity: 1; transform: translateY(0) scale(1); } +} + +.searchInputWrap { + display: flex; + align-items: center; + gap: 12px; + padding: 16px 20px; +} + +.searchModalIcon { + color: var(--color-text-tertiary); + flex-shrink: 0; +} + +.searchModalInput { + flex: 1; + background: none; + border: none; + outline: none; + font-family: var(--font-ui); + font-size: 16px; + color: var(--color-text-primary); + caret-color: var(--color-brand-secondary); +} + +.searchModalInput::placeholder { + color: var(--color-text-tertiary); +} + +.searchEsc { + font-size: 11px; + color: var(--color-text-tertiary); + background: rgba(255, 255, 255, 0.07); + border: 1px solid rgba(255, 255, 255, 0.1); + border-radius: 5px; + padding: 3px 7px; + cursor: pointer; + font-family: var(--font-ui); + flex-shrink: 0; +} + +/* ==================================================== + Responsive + ==================================================== */ @media (max-width: 1024px) { - .searchBox { - width: 180px; - } - .menuGroup { - gap: 4px; - } + .navbarContainer { padding: 0 20px; } + .navLabel { display: none; } + .navItem { padding: 8px 10px; } + .userName { display: none; } } @media (max-width: 768px) { - .navbarContainer { - padding: 0 16px; - } - .navSubtitle { - display: none; - } - .searchBox { - display: none; - } -} + .navbarContainer { padding: 0 12px; gap: 8px; } + .logoText { display: none; } +} \ No newline at end of file diff --git a/Frontend/src/styles/SettingsPage.module.css b/Frontend/src/styles/SettingsPage.module.css index b7a5e6e..84b5ea6 100644 --- a/Frontend/src/styles/SettingsPage.module.css +++ b/Frontend/src/styles/SettingsPage.module.css @@ -1,8 +1,668 @@ +/* ---- Page wrapper ---- */ +.page { + background-color: var(--color-bg-page); + min-height: 100vh; + padding: 28px 0; +} + +/* ---- Layout: 2 คอลัมน์ ---- */ +.layout { + display: grid; + grid-template-columns: 260px 1fr; + gap: 24px; + max-width: 1356px; + margin: 0 auto; + padding: 0 24px; + box-sizing: border-box; + align-items: start; +} + /* ==================================================== - SettingsPage.module.css - หน้าการตั้งค่า — ครอบ wrapper ให้เต็มความกว้าง + Sidebar ==================================================== */ +.sidebar { + background: var(--color-bg-surface); + border-radius: 16px; + border: 1px solid var(--color-border-line); + overflow: hidden; + position: sticky; + top: 24px; +} + +.sidebarHeader { + display: flex; + align-items: center; + gap: 12px; + padding: 20px 20px 16px; + border-bottom: 1px solid var(--color-border-line); +} + +.sidebarTitle { + font-size: 17px; + font-weight: 700; + color: var(--color-text-primary); + letter-spacing: -0.01em; +} -.pageWrapper { +.sidebarNav { + display: flex; + flex-direction: column; + padding: 10px 10px; + gap: 2px; +} + +.sidebarItem { + display: flex; + align-items: center; + gap: 12px; + padding: 12px 14px; + border-radius: 10px; + background: transparent; + border: none; + cursor: pointer; + transition: background 0.15s; + text-align: left; width: 100%; } + +.sidebarItem:hover { + background: rgba(255, 255, 255, 0.05); +} + +.sidebarItemActive { + background: rgba(255, 255, 255, 0.08); + border-left: 3px solid var(--color-text-onBrand); +} + +.sidebarIcon { + font-size: 18px; + color: var(--color-text-secondary); + flex-shrink: 0; + width: 22px; + text-align: center; +} + +.sidebarItemActive .sidebarIcon { + color: var(--color-text-onBrand); +} + +.sidebarItemText { + display: flex; + flex-direction: column; + gap: 1px; +} + +.sidebarItemLabel { + font-size: 14px; + font-weight: 600; + color: var(--color-text-primary); +} + +.sidebarItemActive .sidebarItemLabel { + color: var(--color-text-onBrand); +} + +.sidebarItemSub { + font-size: 11px; + color: var(--color-text-secondary); +} + +/* ==================================================== + Content Panel + ==================================================== */ +.content { + background: var(--color-bg-surface); + border-radius: 16px; + border: 1px solid var(--color-border-line); + overflow: hidden; +} + +/* ---- Tab Section ---- */ +.tabSection { + padding: 28px 32px; +} + +.tabHeader { + display: flex; + justify-content: space-between; + align-items: flex-start; + margin-bottom: 28px; +} + +.tabTitle { + font-size: 20px; + font-weight: 700; + color: var(--color-text-primary); + margin: 0 0 4px 0; +} + +.tabDesc { + font-size: 13px; + color: var(--color-text-secondary); + margin: 0; +} + +/* ==================================================== + Buttons + ==================================================== */ +.btnPrimary { + display: inline-flex; + align-items: center; + gap: 6px; + padding: 9px 20px; + background: var(--color-text-onBrand); + color: var(--color-bg-page); + border: none; + border-radius: 40px; + font-family: var(--font-ui); + font-size: 13px; + font-weight: 700; + cursor: pointer; + transition: opacity 0.15s; +} +.btnPrimary:hover { opacity: 0.88; } +.btnPrimary:disabled { opacity: 0.5; cursor: not-allowed; } + +.btnPrimarySmall { + display: inline-flex; + align-items: center; + gap: 6px; + padding: 7px 16px; + background: var(--color-text-onBrand); + color: var(--color-bg-page); + border: none; + border-radius: 40px; + font-family: var(--font-ui); + font-size: 12px; + font-weight: 700; + cursor: pointer; + transition: opacity 0.15s; +} +.btnPrimarySmall:hover { opacity: 0.88; } + +.btnSecondary { + display: inline-flex; + align-items: center; + gap: 8px; + padding: 9px 20px; + background: rgba(255, 255, 255, 0.06); + color: var(--color-text-secondary); + border: 1px solid var(--color-border-line); + border-radius: 40px; + font-family: var(--font-ui); + font-size: 13px; + font-weight: 600; + cursor: pointer; + transition: all 0.15s; +} +.btnSecondary:hover { + background: rgba(255, 255, 255, 0.1); + color: var(--color-text-primary); +} + +/* ==================================================== + Table (จัดการสถานี) + ==================================================== */ +.tableWrap { + overflow-x: auto; + border-radius: 12px; + border: 1px solid var(--color-border-line); +} + +.table { + width: 100%; + border-collapse: collapse; + font-size: 14px; +} + +.table thead tr { + background: rgba(255, 255, 255, 0.03); + border-bottom: 1px solid var(--color-border-line); +} + +.table th { + padding: 12px 16px; + text-align: left; + font-size: 12px; + font-weight: 700; + color: var(--color-text-secondary); + letter-spacing: 0.05em; + text-transform: uppercase; + white-space: nowrap; +} + +.table tbody tr { + border-bottom: 1px solid var(--color-border-line); + transition: background 0.12s; +} + +.table tbody tr:last-child { border-bottom: none; } +.table tbody tr:hover { background: rgba(255, 255, 255, 0.03); } + +.table td { + padding: 13px 16px; + color: var(--color-text-primary); +} + +.stationNameCell { + display: flex; + align-items: center; + gap: 10px; +} + +.stationDot { + width: 8px; + height: 8px; + border-radius: 50%; + flex-shrink: 0; +} + +.stationName { + font-weight: 600; + font-size: 14px; +} + +.centerCell { + text-align: center; +} + +.batteryPct { + font-size: 11px; + color: var(--color-text-secondary); + margin-left: 4px; +} + +.statusBadge { + display: inline-flex; + align-items: center; + padding: 3px 10px; + border-radius: 40px; + font-size: 11px; + font-weight: 700; +} + +/* ==================================================== + Alert Cards (การแจ้งเตือน) + ==================================================== */ +.alertCardList { + display: flex; + flex-direction: column; + gap: 16px; +} + +.alertCard { + background: rgba(255, 255, 255, 0.03); + border: 1px solid var(--color-border-line); + border-radius: 12px; + padding: 20px 24px; + display: flex; + flex-direction: column; + gap: 16px; +} + +.alertCardHeader { + display: flex; + justify-content: space-between; + align-items: center; + flex-wrap: wrap; + gap: 8px; +} + +.alertCardTitle { + display: flex; + align-items: center; + gap: 8px; + font-size: 15px; + font-weight: 700; + color: var(--color-text-primary); +} + +.alertCardWater { + font-size: 13px; + color: var(--color-text-secondary); +} + +.sliderRow { + display: grid; + grid-template-columns: 1fr 1fr; + gap: 20px; +} + +.sliderGroup { + display: flex; + flex-direction: column; + gap: 8px; +} + +.sliderLabel { + display: flex; + align-items: center; + gap: 6px; + font-size: 12px; + font-weight: 600; + color: var(--color-text-secondary); +} + +.sliderWrapper { + display: flex; + align-items: center; + gap: 12px; +} + +/* Slider base */ +input[type="range"].sliderWarning, +input[type="range"].sliderCritical { + flex: 1; + -webkit-appearance: none; + height: 4px; + border-radius: 4px; + outline: none; + cursor: pointer; +} + +input[type="range"].sliderWarning { + background: linear-gradient( + to right, + var(--color-status-warning) 0%, + var(--color-status-warning) var(--val, 50%), + rgba(255,255,255,0.1) var(--val, 50%) + ); +} + +input[type="range"].sliderCritical { + background: linear-gradient( + to right, + var(--color-status-critical) 0%, + var(--color-status-critical) var(--val, 55%), + rgba(255,255,255,0.1) var(--val, 55%) + ); +} + +input[type="range"]::-webkit-slider-thumb { + -webkit-appearance: none; + width: 16px; + height: 16px; + border-radius: 50%; + background: #ffffff; + box-shadow: 0 1px 4px rgba(0,0,0,0.4); + cursor: pointer; +} + +.sliderValue { + font-family: var(--font-data); + font-size: 13px; + font-weight: 700; + white-space: nowrap; + min-width: 60px; + text-align: right; +} + +.alertCardFooter { + display: flex; + justify-content: flex-end; + padding-top: 4px; + border-top: 1px solid var(--color-border-line); +} + +/* ==================================================== + Account Tab + ==================================================== */ +.accountCard { + display: flex; + align-items: center; + gap: 20px; + padding: 20px 24px; + background: rgba(255, 255, 255, 0.04); + border: 1px solid var(--color-border-line); + border-radius: 12px; + margin-bottom: 24px; +} + +.accountAvatar { + width: 60px; + height: 60px; + border-radius: 50%; + background: rgba(255, 255, 255, 0.08); + display: flex; + align-items: center; + justify-content: center; + flex-shrink: 0; +} + +.accountInfo { + display: flex; + flex-direction: column; + gap: 4px; +} + +.accountName { + font-size: 18px; + font-weight: 700; + color: var(--color-text-primary); +} + +.accountRole { + display: flex; + align-items: center; + gap: 6px; + font-size: 13px; + color: var(--color-text-secondary); +} + +.accountRows { + display: flex; + flex-direction: column; + gap: 0; + border: 1px solid var(--color-border-line); + border-radius: 12px; + overflow: hidden; + margin-bottom: 28px; +} + +.accountRow { + display: flex; + align-items: center; + gap: 14px; + padding: 14px 20px; + border-bottom: 1px solid var(--color-border-line); +} + +.accountRow:last-child { border-bottom: none; } + +.accountRowLabel { + font-size: 13px; + color: var(--color-text-secondary); + width: 130px; + flex-shrink: 0; +} + +.accountRowValue { + font-size: 14px; + color: var(--color-text-primary); + font-weight: 500; +} + +.accountActions { + display: flex; + gap: 12px; +} + +/* ==================================================== + Modal + ==================================================== */ +.modalOverlay { + position: fixed; + inset: 0; + background: rgba(0, 0, 0, 0.65); + backdrop-filter: blur(4px); + display: flex; + justify-content: center; + align-items: center; + z-index: 2000; +} + +.modal { + background: var(--color-bg-surface); + border: 1px solid var(--color-border-line); + border-radius: 16px; + width: 420px; + max-width: 90vw; + box-shadow: 0 20px 60px rgba(0, 0, 0, 0.5); +} + +.modalHeader { + display: flex; + justify-content: space-between; + align-items: center; + padding: 20px 24px 16px; + border-bottom: 1px solid var(--color-border-line); +} + +.modalTitle { + font-size: 17px; + font-weight: 700; + color: var(--color-text-primary); +} + +.modalClose { + background: none; + border: none; + color: var(--color-text-secondary); + cursor: pointer; + font-size: 16px; + padding: 4px; + border-radius: 6px; + transition: color 0.15s; +} +.modalClose:hover { color: var(--color-text-primary); } + +.modalBody { + padding: 24px; + display: flex; + flex-direction: column; + gap: 16px; +} + +.modalFooter { + display: flex; + justify-content: flex-end; + gap: 10px; + padding: 16px 24px 20px; + border-top: 1px solid var(--color-border-line); +} + +/* ---- Form ---- */ +.formGroup { + display: flex; + flex-direction: column; + gap: 6px; +} + +.formLabel { + font-size: 13px; + font-weight: 600; + color: var(--color-text-secondary); +} + +.formInput { + padding: 10px 14px; + background: rgba(255, 255, 255, 0.06); + border: 1px solid var(--color-border-line); + border-radius: 8px; + color: var(--color-text-primary); + font-family: var(--font-ui); + font-size: 14px; + outline: none; + transition: border-color 0.15s; +} + +.formInput:focus { + border-color: var(--color-text-onBrand); +} + +.formInputError { + border-color: var(--color-status-critical); +} + +.formError { + font-size: 12px; + color: var(--color-status-critical); +} + +/* ==================================================== + Toast + ==================================================== */ +.toast { + position: fixed; + bottom: 28px; + right: 28px; + z-index: 3000; + display: flex; + align-items: center; + gap: 10px; + padding: 14px 20px; + border-radius: 12px; + font-size: 14px; + font-weight: 600; + box-shadow: 0 8px 24px rgba(0, 0, 0, 0.4); + animation: slideUp 0.25s ease-out; +} + +.toastSuccess { + background: rgba(16, 185, 129, 0.15); + border: 1px solid rgba(16, 185, 129, 0.4); + color: var(--color-status-normal); +} + +.toastError { + background: rgba(239, 68, 68, 0.15); + border: 1px solid rgba(239, 68, 68, 0.4); + color: var(--color-status-critical); +} + +@keyframes slideUp { + from { opacity: 0; transform: translateY(16px); } + to { opacity: 1; transform: translateY(0); } +} + +/* ==================================================== + Empty / Loading States + ==================================================== */ +.emptyState, +.loadingState { + display: flex; + flex-direction: column; + align-items: center; + justify-content: center; + gap: 12px; + padding: 60px 20px; + color: var(--color-text-secondary); + font-size: 14px; +} + +/* ==================================================== + Responsive + ==================================================== */ +@media (max-width: 1024px) { + .layout { + grid-template-columns: 1fr; + } + .sidebar { + position: static; + display: grid; + grid-template-columns: auto 1fr; + align-items: center; + } + .sidebarNav { + flex-direction: row; + padding: 8px; + } + .sliderRow { + grid-template-columns: 1fr; + } +} + +@media (max-width: 640px) { + .tabSection { padding: 20px 16px; } + .tabHeader { flex-direction: column; gap: 12px; } +} \ No newline at end of file diff --git a/Frontend/src/styles/StationPage.module.css b/Frontend/src/styles/StationPage.module.css index ea93202..6e39721 100644 --- a/Frontend/src/styles/StationPage.module.css +++ b/Frontend/src/styles/StationPage.module.css @@ -1,8 +1,3 @@ -/* ==================================================== - StationPage.module.css - หน้าข้อมูลสถานี — Dark Theme ใช้ CSS Variables จาก index.css - ==================================================== */ - /* --- Page Container --- */ .page { padding: 20px 0; @@ -11,9 +6,7 @@ min-height: 100vh; } -/* ==================================================== - ส่วนที่ 1: แผนที่ (ซ้าย) + Panel ค้นหาสถานี (ขวา) - ==================================================== */ +/* ===== ส่วนที่ 1: แผนที่ + Panel ===== */ .topSection { display: grid; grid-template-columns: 1fr 1fr; @@ -24,7 +17,6 @@ box-sizing: border-box; } -/* กล่องครอบแผนที่ */ .mapWrapper { background: #222b3a; border-radius: 16px; @@ -33,7 +25,6 @@ border: 1px solid #2d3748; } -/* Panel ค้นหาสถานี (ขวา) */ .searchPanel { background: #222b3a; border-radius: 16px; @@ -44,11 +35,7 @@ gap: 16px; } -/* ช่องค้นหา */ -.searchBarWrapper { - position: relative; - width: 100%; -} +.searchBarWrapper { position: relative; width: 100%; } .searchIcon { position: absolute; @@ -72,11 +59,8 @@ box-sizing: border-box; } -.searchInput::placeholder { - color: #8b95a5; -} +.searchInput::placeholder { color: #8b95a5; } -/* ตารางรายการสถานีใน Panel */ .panelTableHeader { display: flex; padding: 8px 16px; @@ -86,15 +70,9 @@ border-bottom: 1px solid #2d3748; } -.panelColName { - flex: 1; -} +.panelColName { flex: 1; } +.panelColDetail { flex: 2; } -.panelColDetail { - flex: 2; -} - -/* แถวรายการสถานีใน Panel */ .panelStationList { display: flex; flex-direction: column; @@ -110,26 +88,12 @@ transition: background-color 0.2s; } -.panelStationRow:hover { - background-color: #2d3748; -} - -.panelStationName { - flex: 1; - color: #ffffff; - font-size: 14px; - font-weight: 500; -} +.panelStationRow:hover { background-color: #2d3748; } -.panelStationLocation { - flex: 2; - color: #8b95a5; - font-size: 14px; -} +.panelStationName { flex: 1; color: #ffffff; font-size: 14px; font-weight: 500; } +.panelStationLocation { flex: 2; color: #8b95a5; font-size: 14px; } -/* ==================================================== - ส่วนที่ 2: ตารางข้อมูลสถานี (แถวทรงแคปซูล) - ==================================================== */ +/* ===== ส่วนที่ 2: ตาราง ===== */ .tableSection { max-width: 1356px; margin: 0 auto 30px auto; @@ -137,132 +101,68 @@ box-sizing: border-box; } -/* หัวคอลัมน์ตาราง */ .tableHeader { - display: flex; - margin: 0 auto 10px auto; + display: grid; + grid-template-columns: 40px 2fr 1fr 80px 80px 1.5fr 1.5fr; + align-items: center; + margin: 0 auto 12px auto; padding: 0 24px; - color: #ffffff; + color: #8b95a5; font-weight: 600; - font-size: 13px; + font-size: 12px; letter-spacing: 0.05em; + text-transform: uppercase; } -/* แถวข้อมูลแต่ละแถว (ทรงแคปซูล) */ -.tableBody { - display: flex; - flex-direction: column; -} +.tableBody { display: flex; flex-direction: column; } .stationRow { - height: 40px; + height: 48px; flex-shrink: 0; - border-radius: 100px; + border-radius: 12px; background: #222b3a; - display: flex; + border: 1px solid #2d3748; + display: grid; + grid-template-columns: 40px 2fr 1fr 80px 80px 1.5fr 1.5fr; align-items: center; - margin-bottom: 10px; + margin-bottom: 12px; padding: 0 24px; box-sizing: border-box; width: 100%; + transition: all 0.2s ease; } -/* --- สัดส่วน Layout คอลัมน์ --- */ -.colSetting { - width: 5%; - display: flex; - align-items: center; -} - -.colName { - width: 20%; - color: #ffffff; - font-weight: 500; - font-size: 14px; -} - -.colLocation { - width: 20%; - color: #8b95a5; - font-size: 13px; -} - -.colTime { - width: 12%; - color: #ffffff; - font-size: 14px; -} - -.colSignal { - width: 8%; - display: flex; - justify-content: center; - font-size: 16px; -} - -.colBattery { - width: 8%; - display: flex; - justify-content: center; - font-size: 16px; +.stationRow:hover { + background: #283345; + transform: translateY(-2px); + box-shadow: 0 6px 16px rgba(0,0,0,0.15); + border-color: #3b4a5e; } -.colWater { - width: 14%; - text-align: right; - font-weight: 600; - font-size: 14px; - font-family: var(--font-data); -} +.colSetting { display: flex; align-items: center; } +.colName { color: #ffffff; font-weight: 500; font-size: 14px; } +.colTime { color: #8b95a5; font-size: 14px; font-family: var(--font-data, 'Inter', monospace); } +.colSignal { display: flex; justify-content: center; font-size: 16px; } +.colBattery { display: flex; justify-content: center; font-size: 16px; } +.colWater { text-align: right; font-weight: 700; font-size: 15px; font-family: var(--font-data, 'Inter', sans-serif); } +.colRain { text-align: right; font-weight: 700; font-size: 15px; font-family: var(--font-data, 'Inter', sans-serif); } -.colRain { - width: 13%; - text-align: right; - font-weight: 600; - font-size: 14px; - font-family: var(--font-data); -} - -/* ไอคอนฟันเฟือง */ .btnSetting { - color: #8b95a5; + color: #64748b; cursor: pointer; - font-size: 15px; + font-size: 16px; transition: color 0.2s; } +.btnSetting:hover { color: #ffffff; } -.btnSetting:hover { - color: #ffffff; -} - -/* สีสถานะ — ใช้ CSS Variables จาก index.css */ -.statusNormal { - color: var(--color-status-normal); -} - -.statusWarning { - color: var(--color-status-warning); -} - -.statusCritical { - color: var(--color-status-critical); -} - -.iconGood { - color: var(--color-status-normal); -} - -.iconBad { - color: var(--color-status-warning); -} - -.iconCritical { - color: var(--color-status-critical); -} +.statusNormal { color: var(--color-status-normal); } +.statusWarning { color: var(--color-status-warning); } +.statusCritical { color: var(--color-status-critical); } +.iconGood { color: var(--color-status-normal); } +.iconBad { color: var(--color-status-warning); } +.iconCritical { color: var(--color-status-critical); } -/* ==================================================== - ส่วนที่ 3: กราฟ 2 ช่อง (ระดับน้ำ + ฝน) - ==================================================== */ +/* ===== ส่วนที่ 3: กราฟ — ลำดับที่ 5+6 ===== */ .chartSection { display: grid; grid-template-columns: 1fr 1fr; @@ -276,16 +176,56 @@ .chartCard { background: #222b3a; border-radius: 16px; - padding: 24px; + padding: 20px 24px 24px; border: 1px solid #2d3748; - min-height: 300px; } -.chartTitle { - font-size: 14px; +/* Header row: Legend (ซ้าย) + Time Tabs (ขวา) — NEW */ +.chartHeaderRow { + display: flex; + justify-content: space-between; + align-items: center; + margin-bottom: 16px; + flex-wrap: wrap; + gap: 12px; +} + +.chartLegendRow { + display: flex; + align-items: center; + gap: 16px; + flex-wrap: wrap; +} + +/* Time Range Tabs — ลำดับที่ 6 */ +.timeRangeTabs { + display: flex; + gap: 4px; + background: rgba(0, 0, 0, 0.25); + border-radius: 40px; + padding: 3px; +} + +.tabBtn { + padding: 5px 14px; + border: none; + background: transparent; + color: #8b95a5; + font-family: var(--font-ui); + font-size: 12px; font-weight: 600; + border-radius: 40px; + cursor: pointer; + transition: all 0.15s ease; + white-space: nowrap; +} + +.tabBtn:hover { color: #ffffff; } + +.tabActive { + background: #3b4a5e; color: #ffffff; - margin-bottom: 16px; + box-shadow: 0 1px 4px rgba(0, 0, 0, 0.3); } .chartBody { @@ -293,11 +233,13 @@ height: 250px; } -/* Legend ใต้กราฟ */ +/* Legend items */ .legendContainer { display: flex; justify-content: center; margin-top: 12px; + gap: 20px; + flex-wrap: wrap; } .legendItem { @@ -311,7 +253,6 @@ color: #8b95a5; } -/* ข้อความเมื่อไม่มีข้อมูล */ .emptyMessage { text-align: center; padding: 16px; @@ -319,15 +260,7 @@ font-size: 14px; } -/* ==================================================== - Responsive - ==================================================== */ +/* Responsive */ @media (max-width: 1024px) { - .topSection { - grid-template-columns: 1fr; - } - - .chartSection { - grid-template-columns: 1fr; - } -} + .topSection, .chartSection { grid-template-columns: 1fr; } +} \ No newline at end of file diff --git a/Frontend/src/styles/WaterLevelChart.module.css b/Frontend/src/styles/WaterLevelChart.module.css index 821c9cf..4229f2b 100644 --- a/Frontend/src/styles/WaterLevelChart.module.css +++ b/Frontend/src/styles/WaterLevelChart.module.css @@ -1,33 +1,27 @@ -/* WaterLevelChart.module.css */ - .chartCard { - height: 100%; /* Take full height of wrapper */ display: flex; - flex-direction: column; - /* No bg color, wrapper handles it */ + flex-direction: column; + padding: 0 0 12px 0; } +/* กำหนด height ตายตัว — ResponsiveContainer ต้องการ parent ที่มี height */ .chartBody { - flex: 1; width: 100%; - min-height: 0; - position: relative; + height: 280px; } -/* --- Tooltip --- */ .customTooltip { - background-color: #ffffff; - padding: 12px; - border: 1px solid #E5E7EB; + background: #1e293b; + border: 1px solid rgba(255,255,255,0.12); border-radius: 8px; - box-shadow: 0 4px 12px rgba(0, 0, 0, 0.1); - min-width: 140px; - z-index: 50; + padding: 10px 14px; + min-width: 150px; } .tooltipTime { - margin-bottom: 8px; + font-size: 11px; color: var(--color-text-secondary); + margin-bottom: 6px; font-weight: 600; } @@ -35,36 +29,38 @@ display: flex; align-items: center; gap: 8px; + margin-bottom: 3px; } .tooltipDot { width: 8px; height: 8px; border-radius: 50%; + flex-shrink: 0; } .tooltipValue { - font-size: 14px; + font-size: 13px; font-weight: 600; color: var(--color-text-primary); } -/* --- Legend Styles --- */ .legendContainer { display: flex; justify-content: center; - margin-top: 16px; - flex-shrink: 0; + gap: 20px; + flex-wrap: wrap; + margin-top: 10px; + padding: 0 24px; } .legendItem { display: flex; align-items: center; - gap: 8px; + gap: 6px; } .legendText { font-size: 12px; - font-weight: 600; - color: var(--color-text-primary); + color: var(--color-text-secondary); } \ No newline at end of file diff --git a/docker-compose.yml b/docker-compose.yml index 0f0657c..6339290 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -6,6 +6,8 @@ services: POSTGRES_USER: ${DB_USERNAME} POSTGRES_PASSWORD: ${DB_PASSWORD} POSTGRES_DB: ${DB_DATABASE} + ports: + - 5432:5432 volumes: - data:/var/lib/postgresql/data healthcheck: @@ -31,4 +33,4 @@ services: volumes: data: - driver: local + driver: local \ No newline at end of file