Genesis
This commit is contained in:
131
src/client/App.js
Normal file
131
src/client/App.js
Normal file
@@ -0,0 +1,131 @@
|
||||
import React, { useState } from 'react';
|
||||
import { ThemeProvider, createTheme, CssBaseline, AppBar, Toolbar, Typography, Container, Box, Button, Chip } from '@mui/material';
|
||||
import Dashboard from './Dashboard';
|
||||
import RuleManager from './RuleManager';
|
||||
import LoginDialog from './LoginDialog';
|
||||
import { AuthProvider, useAuth } from './AuthContext';
|
||||
|
||||
// Gruvbox Dark color palette
|
||||
const gruvboxDark = {
|
||||
bg0: '#282828', // background
|
||||
bg1: '#3c3836', // lighter background (cards)
|
||||
bg2: '#504945', // selection / borders
|
||||
fg: '#ebdbb2', // foreground
|
||||
fg2: '#d5c4a1', // secondary text
|
||||
aqua: '#8ec07c', // primary accent
|
||||
orange: '#fe8019', // secondary accent
|
||||
red: '#fb4934',
|
||||
green: '#b8bb26',
|
||||
yellow: '#fabd2f',
|
||||
blue: '#83a598',
|
||||
purple: '#d3869b',
|
||||
};
|
||||
|
||||
const darkTheme = createTheme({
|
||||
palette: {
|
||||
mode: 'dark',
|
||||
primary: {
|
||||
main: gruvboxDark.aqua,
|
||||
},
|
||||
secondary: {
|
||||
main: gruvboxDark.orange,
|
||||
},
|
||||
background: {
|
||||
default: gruvboxDark.bg0,
|
||||
paper: gruvboxDark.bg1,
|
||||
},
|
||||
text: {
|
||||
primary: gruvboxDark.fg,
|
||||
secondary: gruvboxDark.fg2,
|
||||
},
|
||||
divider: gruvboxDark.bg2,
|
||||
},
|
||||
});
|
||||
|
||||
function AppContent() {
|
||||
const { user, loading, login, logout, isAuthenticated, isAdmin } = useAuth();
|
||||
const [showLogin, setShowLogin] = useState(false);
|
||||
|
||||
return (
|
||||
<Box sx={{ flexGrow: 1 }}>
|
||||
<AppBar position="static">
|
||||
<Toolbar>
|
||||
<Typography variant="h6" component="div" sx={{ flexGrow: 1 }}>
|
||||
Tischlerei Dashboard
|
||||
</Typography>
|
||||
<Box sx={{ display: 'flex', alignItems: 'center', gap: 2 }}>
|
||||
{isAuthenticated ? (
|
||||
<>
|
||||
<Chip
|
||||
label={user.username}
|
||||
color={isAdmin ? 'secondary' : 'default'}
|
||||
size="small"
|
||||
sx={{
|
||||
fontWeight: 600,
|
||||
...(isAdmin && {
|
||||
background: 'linear-gradient(45deg, #d3869b 30%, #fe8019 90%)'
|
||||
})
|
||||
}}
|
||||
/>
|
||||
{isAdmin && (
|
||||
<Chip
|
||||
label="ADMIN"
|
||||
size="small"
|
||||
sx={{
|
||||
bgcolor: gruvboxDark.purple,
|
||||
color: gruvboxDark.bg0,
|
||||
fontWeight: 700
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
<Button
|
||||
color="inherit"
|
||||
onClick={logout}
|
||||
size="small"
|
||||
>
|
||||
Logout
|
||||
</Button>
|
||||
</>
|
||||
) : (
|
||||
<Button
|
||||
color="inherit"
|
||||
onClick={() => setShowLogin(true)}
|
||||
variant="outlined"
|
||||
size="small"
|
||||
sx={{ borderColor: gruvboxDark.aqua }}
|
||||
>
|
||||
🔐 Admin Login
|
||||
</Button>
|
||||
)}
|
||||
</Box>
|
||||
</Toolbar>
|
||||
</AppBar>
|
||||
<Container maxWidth="xl" sx={{ mt: 4, mb: 4 }}>
|
||||
{/* Dashboard is always visible to everyone */}
|
||||
<Dashboard />
|
||||
|
||||
{/* Rule Manager only visible to logged-in admins */}
|
||||
{isAdmin && <RuleManager />}
|
||||
</Container>
|
||||
|
||||
{/* Login dialog - shown on demand */}
|
||||
<LoginDialog
|
||||
open={showLogin}
|
||||
onClose={() => setShowLogin(false)}
|
||||
/>
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
|
||||
function App() {
|
||||
return (
|
||||
<ThemeProvider theme={darkTheme}>
|
||||
<CssBaseline />
|
||||
<AuthProvider>
|
||||
<AppContent />
|
||||
</AuthProvider>
|
||||
</ThemeProvider>
|
||||
);
|
||||
}
|
||||
|
||||
export default App;
|
||||
79
src/client/AuthContext.js
Normal file
79
src/client/AuthContext.js
Normal file
@@ -0,0 +1,79 @@
|
||||
import React, { createContext, useContext, useState, useEffect, useCallback } from 'react';
|
||||
|
||||
const AuthContext = createContext(null);
|
||||
|
||||
export function AuthProvider({ children }) {
|
||||
const [user, setUser] = useState(null);
|
||||
const [loading, setLoading] = useState(true);
|
||||
|
||||
// Check for existing session on mount
|
||||
useEffect(() => {
|
||||
const checkAuth = async () => {
|
||||
const token = localStorage.getItem('authToken');
|
||||
if (token) {
|
||||
try {
|
||||
const res = await fetch('api/auth/me', {
|
||||
headers: { 'Authorization': `Bearer ${token}` }
|
||||
});
|
||||
if (res.ok) {
|
||||
const data = await res.json();
|
||||
setUser(data.user);
|
||||
} else {
|
||||
localStorage.removeItem('authToken');
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Auth check failed:', error);
|
||||
localStorage.removeItem('authToken');
|
||||
}
|
||||
}
|
||||
setLoading(false);
|
||||
};
|
||||
checkAuth();
|
||||
}, []);
|
||||
|
||||
const login = useCallback(async (username, password) => {
|
||||
const res = await fetch('api/auth/login', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ username, password })
|
||||
});
|
||||
|
||||
if (!res.ok) {
|
||||
const data = await res.json();
|
||||
throw new Error(data.error || 'Login failed');
|
||||
}
|
||||
|
||||
const data = await res.json();
|
||||
localStorage.setItem('authToken', data.token);
|
||||
setUser(data.user);
|
||||
return data.user;
|
||||
}, []);
|
||||
|
||||
const logout = useCallback(() => {
|
||||
localStorage.removeItem('authToken');
|
||||
setUser(null);
|
||||
}, []);
|
||||
|
||||
const value = {
|
||||
user,
|
||||
loading,
|
||||
login,
|
||||
logout,
|
||||
isAuthenticated: !!user,
|
||||
isAdmin: user?.role === 'admin'
|
||||
};
|
||||
|
||||
return (
|
||||
<AuthContext.Provider value={value}>
|
||||
{children}
|
||||
</AuthContext.Provider>
|
||||
);
|
||||
}
|
||||
|
||||
export function useAuth() {
|
||||
const context = useContext(AuthContext);
|
||||
if (!context) {
|
||||
throw new Error('useAuth must be used within an AuthProvider');
|
||||
}
|
||||
return context;
|
||||
}
|
||||
92
src/client/ControllerCard.js
Normal file
92
src/client/ControllerCard.js
Normal file
@@ -0,0 +1,92 @@
|
||||
import React, { useState, useEffect } from 'react';
|
||||
import { Card, CardHeader, CardContent, Divider, Grid, Box, Typography } from '@mui/material';
|
||||
import EnvChart from './EnvChart';
|
||||
import LevelChart from './LevelChart';
|
||||
|
||||
export default function ControllerCard({ controllerName, ports, range }) {
|
||||
const [envData, setEnvData] = useState([]);
|
||||
const [portData, setPortData] = useState({});
|
||||
|
||||
const fetchData = async () => {
|
||||
try {
|
||||
if (ports.length === 0) return;
|
||||
|
||||
// Fetch all ports concurrently
|
||||
const promises = ports.map(port =>
|
||||
fetch(`api/history?devName=${encodeURIComponent(controllerName)}&port=${port.port}&range=${range}`)
|
||||
.then(res => res.json())
|
||||
.then(data => ({ port: port.port, data }))
|
||||
);
|
||||
|
||||
const results = await Promise.all(promises);
|
||||
|
||||
const newPortData = {};
|
||||
results.forEach(item => {
|
||||
newPortData[item.port] = item.data;
|
||||
});
|
||||
|
||||
setPortData(newPortData);
|
||||
|
||||
// Use the data from the first port for the Environment Chart
|
||||
// This avoids a redundant network request
|
||||
if (results.length > 0) {
|
||||
setEnvData(results[0].data);
|
||||
}
|
||||
|
||||
} catch (err) {
|
||||
console.error("Fetch error", err);
|
||||
}
|
||||
};
|
||||
|
||||
// Initial Fetch & Auto-Refresh
|
||||
useEffect(() => {
|
||||
fetchData();
|
||||
const interval = setInterval(fetchData, 60000);
|
||||
return () => clearInterval(interval);
|
||||
}, [controllerName, range]); // Depend on range, controllerName changes rarely
|
||||
|
||||
return (
|
||||
<Card sx={{ mb: 4, borderRadius: 2, boxShadow: 3 }}>
|
||||
<CardHeader
|
||||
title={controllerName}
|
||||
titleTypographyProps={{ variant: 'h5', fontWeight: 'bold', color: 'primary.main' }}
|
||||
sx={{ bgcolor: 'background.paper', borderLeft: '6px solid', borderLeftColor: 'primary.main' }}
|
||||
/>
|
||||
<CardContent>
|
||||
{/* Environment Chart */}
|
||||
<Box sx={{ height: 350, mb: 6 }}>
|
||||
<Typography variant="h6" color="text.secondary" gutterBottom>
|
||||
Environment (Temp / Humidity)
|
||||
</Typography>
|
||||
<EnvChart data={envData} range={range} />
|
||||
</Box>
|
||||
|
||||
<Divider sx={{ mt: 2, mb: 3 }} />
|
||||
|
||||
{/* Port Grid */}
|
||||
<Grid container spacing={3}>
|
||||
{ports.map((port) => {
|
||||
const isLight = port.port_name && port.port_name.toLowerCase().includes('light');
|
||||
const isCO2 = port.port_name && port.port_name.toLowerCase().includes('co2');
|
||||
const pData = portData[port.port] || [];
|
||||
|
||||
return (
|
||||
<Grid size={{ xs: 12, md: 6, lg: 4 }} key={port.port}>
|
||||
<Card variant="outlined" sx={{ bgcolor: 'background.paper' }}>
|
||||
<CardContent>
|
||||
<Typography variant="h6" gutterBottom>
|
||||
{port.port_name || `Port ${port.port}`}
|
||||
</Typography>
|
||||
<Box sx={{ height: 250 }}>
|
||||
<LevelChart data={pData} isLight={isLight} isCO2={isCO2} range={range} />
|
||||
</Box>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</Grid>
|
||||
);
|
||||
})}
|
||||
</Grid>
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
85
src/client/Dashboard.js
Normal file
85
src/client/Dashboard.js
Normal file
@@ -0,0 +1,85 @@
|
||||
import React, { useState, useEffect, useCallback } from 'react';
|
||||
import { Grid, Typography, Button, ButtonGroup, Box, Alert } from '@mui/material';
|
||||
import ControllerCard from './ControllerCard';
|
||||
|
||||
export default function Dashboard() {
|
||||
const [groupedDevices, setGroupedDevices] = useState({});
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [error, setError] = useState(null);
|
||||
const [range, setRange] = useState('day'); // 'day', 'week', 'month'
|
||||
|
||||
const fetchDevices = useCallback(async () => {
|
||||
try {
|
||||
// Robust API Base detection
|
||||
const baseUrl = window.location.pathname.endsWith('/') ? 'api/' : 'api/';
|
||||
// Actually, since we are serving from root or subpath, relative 'api/' is tricky if URL depth changes.
|
||||
// Better to use a relative path that works from the page root.
|
||||
// If page is /ac-dashboard/, fetch is /ac-dashboard/api/devices.
|
||||
|
||||
const res = await fetch('api/devices');
|
||||
if (!res.ok) throw new Error('Failed to fetch devices');
|
||||
|
||||
const devices = await res.json();
|
||||
|
||||
// Group by dev_name
|
||||
const grouped = devices.reduce((acc, dev) => {
|
||||
if (!acc[dev.dev_name]) acc[dev.dev_name] = [];
|
||||
acc[dev.dev_name].push(dev);
|
||||
return acc;
|
||||
}, {});
|
||||
|
||||
setGroupedDevices(grouped);
|
||||
setLoading(false);
|
||||
} catch (err) {
|
||||
console.error(err);
|
||||
setError(err.message);
|
||||
setLoading(false);
|
||||
}
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
fetchDevices();
|
||||
}, [fetchDevices]);
|
||||
|
||||
// Auto-refresh logic (basic rerender trigger could be added here,
|
||||
// but simpler to let ControllerCard handle data fetching internally based on props)
|
||||
|
||||
if (loading) return <Typography>Loading devices...</Typography>;
|
||||
if (error) return <Alert severity="error">{error}</Alert>;
|
||||
|
||||
return (
|
||||
<Box>
|
||||
<Box display="flex" justifyContent="flex-end" mb={3}>
|
||||
<ButtonGroup variant="contained" aria-label="outlined primary button group">
|
||||
<Button
|
||||
onClick={() => setRange('day')}
|
||||
color={range === 'day' ? 'primary' : 'inherit'}
|
||||
>
|
||||
24 Hours
|
||||
</Button>
|
||||
<Button
|
||||
onClick={() => setRange('week')}
|
||||
color={range === 'week' ? 'primary' : 'inherit'}
|
||||
>
|
||||
7 Days
|
||||
</Button>
|
||||
<Button
|
||||
onClick={() => setRange('month')}
|
||||
color={range === 'month' ? 'primary' : 'inherit'}
|
||||
>
|
||||
30 Days
|
||||
</Button>
|
||||
</ButtonGroup>
|
||||
</Box>
|
||||
|
||||
{Object.entries(groupedDevices).map(([controllerName, ports]) => (
|
||||
<ControllerCard
|
||||
key={controllerName}
|
||||
controllerName={controllerName}
|
||||
ports={ports}
|
||||
range={range}
|
||||
/>
|
||||
))}
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
98
src/client/EnvChart.js
Normal file
98
src/client/EnvChart.js
Normal file
@@ -0,0 +1,98 @@
|
||||
import React from 'react';
|
||||
import {
|
||||
Chart as ChartJS,
|
||||
CategoryScale,
|
||||
LinearScale,
|
||||
PointElement,
|
||||
LineElement,
|
||||
Title,
|
||||
Tooltip,
|
||||
Legend
|
||||
} from 'chart.js';
|
||||
import { Line } from 'react-chartjs-2';
|
||||
|
||||
ChartJS.register(
|
||||
CategoryScale,
|
||||
LinearScale,
|
||||
PointElement,
|
||||
LineElement,
|
||||
Title,
|
||||
Tooltip,
|
||||
Legend
|
||||
);
|
||||
|
||||
export default function EnvChart({ data, range }) {
|
||||
if (!data || data.length === 0) return null;
|
||||
|
||||
const formatDateLabel = (timestamp) => {
|
||||
const date = new Date(timestamp);
|
||||
if (range === 'day') {
|
||||
return date.toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' });
|
||||
} else {
|
||||
return date.toLocaleDateString([], { month: 'numeric', day: 'numeric' }) + ' ' +
|
||||
date.toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' });
|
||||
}
|
||||
};
|
||||
|
||||
const chartData = {
|
||||
labels: data.map(d => formatDateLabel(d.timestamp)),
|
||||
datasets: [
|
||||
{
|
||||
label: 'Temperature (°C)',
|
||||
data: data.map(d => d.temp_c),
|
||||
borderColor: '#ff6384',
|
||||
backgroundColor: '#ff6384',
|
||||
yAxisID: 'y',
|
||||
tension: 0.4,
|
||||
pointRadius: 0,
|
||||
borderWidth: 2
|
||||
},
|
||||
{
|
||||
label: 'Humidity (%)',
|
||||
data: data.map(d => d.humidity),
|
||||
borderColor: '#36a2eb',
|
||||
backgroundColor: '#36a2eb',
|
||||
yAxisID: 'y1',
|
||||
tension: 0.4,
|
||||
pointRadius: 0,
|
||||
borderWidth: 2
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
const options = {
|
||||
responsive: true,
|
||||
maintainAspectRatio: false,
|
||||
interaction: {
|
||||
mode: 'index',
|
||||
intersect: false,
|
||||
},
|
||||
scales: {
|
||||
x: {
|
||||
ticks: {
|
||||
maxRotation: 0,
|
||||
autoSkip: true,
|
||||
maxTicksLimit: 12
|
||||
}
|
||||
},
|
||||
y: {
|
||||
type: 'linear',
|
||||
display: true,
|
||||
position: 'left',
|
||||
title: { display: true, text: 'Temp (°C)' },
|
||||
suggestedMin: 15,
|
||||
},
|
||||
y1: {
|
||||
type: 'linear',
|
||||
display: true,
|
||||
position: 'right',
|
||||
grid: { drawOnChartArea: false },
|
||||
title: { display: true, text: 'Humidity (%)' },
|
||||
suggestedMin: 30,
|
||||
suggestedMax: 80,
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
return <Line data={chartData} options={options} />;
|
||||
}
|
||||
82
src/client/LevelChart.js
Normal file
82
src/client/LevelChart.js
Normal file
@@ -0,0 +1,82 @@
|
||||
import React from 'react';
|
||||
import {
|
||||
Chart as ChartJS,
|
||||
CategoryScale,
|
||||
LinearScale,
|
||||
PointElement,
|
||||
LineElement,
|
||||
Title,
|
||||
Tooltip,
|
||||
Legend
|
||||
} from 'chart.js';
|
||||
import { Line } from 'react-chartjs-2';
|
||||
|
||||
ChartJS.register(
|
||||
CategoryScale,
|
||||
LinearScale,
|
||||
PointElement,
|
||||
LineElement,
|
||||
Title,
|
||||
Tooltip,
|
||||
Legend
|
||||
);
|
||||
|
||||
export default function LevelChart({ data, isLight, isCO2, range }) {
|
||||
if (!data || data.length === 0) return null;
|
||||
|
||||
const formatDateLabel = (timestamp) => {
|
||||
const date = new Date(timestamp);
|
||||
if (range === 'day') {
|
||||
return date.toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' });
|
||||
} else {
|
||||
return date.toLocaleDateString([], { month: 'numeric', day: 'numeric' }) + ' ' +
|
||||
date.toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' });
|
||||
}
|
||||
};
|
||||
|
||||
// Determine label and color based on sensor type
|
||||
const levelLabel = isCO2 ? 'CO2 (ppm)' : (isLight ? 'Brightness' : 'Fan Speed');
|
||||
const levelColor = isCO2 ? '#4caf50' : (isLight ? '#ffcd56' : '#9966ff');
|
||||
|
||||
const chartData = {
|
||||
labels: data.map(d => formatDateLabel(d.timestamp)),
|
||||
datasets: [
|
||||
{
|
||||
label: levelLabel,
|
||||
data: data.map(d => d.fan_speed),
|
||||
borderColor: levelColor,
|
||||
backgroundColor: levelColor,
|
||||
stepped: !isCO2, // CO2 uses smooth lines
|
||||
tension: isCO2 ? 0.4 : 0, // Smooth for CO2, stepped for others
|
||||
borderWidth: 2,
|
||||
pointRadius: 0
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
// CO2 needs different Y-axis scale (ppm range)
|
||||
const yScale = isCO2
|
||||
? { suggestedMin: 400, suggestedMax: 2000 }
|
||||
: { suggestedMin: 0, suggestedMax: 10, ticks: { stepSize: 1 } };
|
||||
|
||||
const options = {
|
||||
responsive: true,
|
||||
maintainAspectRatio: false,
|
||||
interaction: {
|
||||
mode: 'index',
|
||||
intersect: false,
|
||||
},
|
||||
scales: {
|
||||
x: {
|
||||
ticks: {
|
||||
maxRotation: 0,
|
||||
autoSkip: true,
|
||||
maxTicksLimit: 8
|
||||
}
|
||||
},
|
||||
y: yScale
|
||||
},
|
||||
};
|
||||
|
||||
return <Line data={chartData} options={options} />;
|
||||
}
|
||||
150
src/client/LoginDialog.js
Normal file
150
src/client/LoginDialog.js
Normal file
@@ -0,0 +1,150 @@
|
||||
import React, { useState } from 'react';
|
||||
import {
|
||||
Dialog,
|
||||
DialogTitle,
|
||||
DialogContent,
|
||||
DialogActions,
|
||||
TextField,
|
||||
Button,
|
||||
Alert,
|
||||
Box,
|
||||
CircularProgress,
|
||||
InputAdornment,
|
||||
IconButton,
|
||||
Typography
|
||||
} from '@mui/material';
|
||||
import { useAuth } from './AuthContext';
|
||||
|
||||
// Simple eye icons using unicode
|
||||
const VisibilityIcon = () => <span style={{ fontSize: '1.2rem' }}>👁</span>;
|
||||
const VisibilityOffIcon = () => <span style={{ fontSize: '1.2rem' }}>👁🗨</span>;
|
||||
|
||||
export default function LoginDialog({ open, onClose }) {
|
||||
const { login } = useAuth();
|
||||
const [username, setUsername] = useState('');
|
||||
const [password, setPassword] = useState('');
|
||||
const [showPassword, setShowPassword] = useState(false);
|
||||
const [error, setError] = useState('');
|
||||
const [loading, setLoading] = useState(false);
|
||||
|
||||
const handleSubmit = async (e) => {
|
||||
e.preventDefault();
|
||||
setError('');
|
||||
setLoading(true);
|
||||
|
||||
try {
|
||||
await login(username, password);
|
||||
// Success - close dialog and reset form
|
||||
setUsername('');
|
||||
setPassword('');
|
||||
if (onClose) onClose();
|
||||
} catch (err) {
|
||||
setError(err.message);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleClose = () => {
|
||||
setError('');
|
||||
if (onClose) onClose();
|
||||
};
|
||||
|
||||
return (
|
||||
<Dialog
|
||||
open={open}
|
||||
onClose={handleClose}
|
||||
maxWidth="xs"
|
||||
fullWidth
|
||||
PaperProps={{
|
||||
sx: {
|
||||
background: 'linear-gradient(145deg, #3c3836 0%, #282828 100%)',
|
||||
borderRadius: 3,
|
||||
border: '1px solid #504945'
|
||||
}
|
||||
}}
|
||||
>
|
||||
<DialogTitle sx={{ textAlign: 'center', pb: 1 }}>
|
||||
<Typography variant="h5" component="div" sx={{ fontWeight: 600 }}>
|
||||
🔐 Dashboard Login
|
||||
</Typography>
|
||||
<Typography variant="body2" color="text.secondary" sx={{ mt: 1 }}>
|
||||
Tischlerei Automation Control
|
||||
</Typography>
|
||||
</DialogTitle>
|
||||
|
||||
<form onSubmit={handleSubmit}>
|
||||
<DialogContent>
|
||||
{error && (
|
||||
<Alert severity="error" sx={{ mb: 2 }}>
|
||||
{error}
|
||||
</Alert>
|
||||
)}
|
||||
|
||||
<TextField
|
||||
autoFocus
|
||||
margin="dense"
|
||||
label="Username"
|
||||
type="text"
|
||||
fullWidth
|
||||
variant="outlined"
|
||||
value={username}
|
||||
onChange={(e) => setUsername(e.target.value)}
|
||||
disabled={loading}
|
||||
sx={{ mb: 2 }}
|
||||
/>
|
||||
|
||||
<TextField
|
||||
margin="dense"
|
||||
label="Password"
|
||||
type={showPassword ? 'text' : 'password'}
|
||||
fullWidth
|
||||
variant="outlined"
|
||||
value={password}
|
||||
onChange={(e) => setPassword(e.target.value)}
|
||||
disabled={loading}
|
||||
InputProps={{
|
||||
endAdornment: (
|
||||
<InputAdornment position="end">
|
||||
<IconButton
|
||||
onClick={() => setShowPassword(!showPassword)}
|
||||
edge="end"
|
||||
size="small"
|
||||
>
|
||||
{showPassword ? <VisibilityOffIcon /> : <VisibilityIcon />}
|
||||
</IconButton>
|
||||
</InputAdornment>
|
||||
)
|
||||
}}
|
||||
/>
|
||||
</DialogContent>
|
||||
|
||||
<DialogActions sx={{ px: 3, pb: 3 }}>
|
||||
<Button
|
||||
type="submit"
|
||||
variant="contained"
|
||||
fullWidth
|
||||
size="large"
|
||||
disabled={loading || !username || !password}
|
||||
sx={{
|
||||
py: 1.5,
|
||||
background: 'linear-gradient(45deg, #8ec07c 30%, #b8bb26 90%)',
|
||||
'&:hover': {
|
||||
background: 'linear-gradient(45deg, #98c98a 30%, #c5c836 90%)',
|
||||
}
|
||||
}}
|
||||
>
|
||||
{loading ? (
|
||||
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1 }}>
|
||||
<CircularProgress size={20} color="inherit" />
|
||||
<span>Signing in...</span>
|
||||
</Box>
|
||||
) : (
|
||||
'Sign In'
|
||||
)}
|
||||
</Button>
|
||||
</DialogActions>
|
||||
</form>
|
||||
</Dialog>
|
||||
);
|
||||
}
|
||||
161
src/client/RuleCard.js
Normal file
161
src/client/RuleCard.js
Normal file
@@ -0,0 +1,161 @@
|
||||
import React from 'react';
|
||||
import {
|
||||
Box,
|
||||
Paper,
|
||||
Typography,
|
||||
Switch,
|
||||
IconButton,
|
||||
Chip,
|
||||
Tooltip
|
||||
} from '@mui/material';
|
||||
|
||||
// Simple icons using unicode/emoji
|
||||
const EditIcon = () => <span style={{ fontSize: '1rem' }}>✏️</span>;
|
||||
const DeleteIcon = () => <span style={{ fontSize: '1rem' }}>🗑️</span>;
|
||||
|
||||
const dayLabels = {
|
||||
mon: 'M', tue: 'T', wed: 'W', thu: 'T', fri: 'F', sat: 'S', sun: 'S'
|
||||
};
|
||||
|
||||
const dayOrder = ['mon', 'tue', 'wed', 'thu', 'fri', 'sat', 'sun'];
|
||||
|
||||
function TriggerSummary({ trigger }) {
|
||||
if (trigger.type === 'time') {
|
||||
const days = trigger.days || [];
|
||||
const isEveryDay = days.length === 7;
|
||||
const isWeekdays = days.length === 5 &&
|
||||
['mon', 'tue', 'wed', 'thu', 'fri'].every(d => days.includes(d));
|
||||
|
||||
let dayText = isEveryDay ? 'Every day' :
|
||||
isWeekdays ? 'Weekdays' :
|
||||
dayOrder.filter(d => days.includes(d)).map(d => dayLabels[d]).join(' ');
|
||||
|
||||
return (
|
||||
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1 }}>
|
||||
<Chip
|
||||
label="⏰ Time"
|
||||
size="small"
|
||||
sx={{ bgcolor: '#83a598', color: '#282828', fontWeight: 600 }}
|
||||
/>
|
||||
<Typography variant="body2">
|
||||
At <strong>{trigger.time}</strong> • {dayText}
|
||||
</Typography>
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
|
||||
if (trigger.type === 'sensor') {
|
||||
return (
|
||||
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1 }}>
|
||||
<Chip
|
||||
label="📊 Sensor"
|
||||
size="small"
|
||||
sx={{ bgcolor: '#fabd2f', color: '#282828', fontWeight: 600 }}
|
||||
/>
|
||||
<Typography variant="body2">
|
||||
When <strong>{trigger.sensor}</strong> {trigger.operator} <strong>{trigger.value}</strong>
|
||||
</Typography>
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
function ActionSummary({ action }) {
|
||||
if (action.type === 'toggle') {
|
||||
return (
|
||||
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1 }}>
|
||||
<Chip
|
||||
label={action.state ? '🔛 ON' : '🔴 OFF'}
|
||||
size="small"
|
||||
sx={{
|
||||
bgcolor: action.state ? '#b8bb26' : '#fb4934',
|
||||
color: '#282828',
|
||||
fontWeight: 600
|
||||
}}
|
||||
/>
|
||||
<Typography variant="body2">
|
||||
Turn <strong>{action.target}</strong> {action.state ? 'on' : 'off'}
|
||||
</Typography>
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
|
||||
if (action.type === 'keepOn') {
|
||||
return (
|
||||
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1 }}>
|
||||
<Chip
|
||||
label="⏱️ Timed"
|
||||
size="small"
|
||||
sx={{ bgcolor: '#d3869b', color: '#282828', fontWeight: 600 }}
|
||||
/>
|
||||
<Typography variant="body2">
|
||||
Keep <strong>{action.target}</strong> on for <strong>{action.duration} min</strong>
|
||||
</Typography>
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
export default function RuleCard({ rule, onEdit, onDelete, onToggle }) {
|
||||
return (
|
||||
<Paper
|
||||
sx={{
|
||||
p: 2,
|
||||
opacity: rule.enabled ? 1 : 0.6,
|
||||
transition: 'opacity 0.2s, transform 0.2s',
|
||||
border: '1px solid',
|
||||
borderColor: rule.enabled ? '#504945' : '#3c3836',
|
||||
'&:hover': {
|
||||
transform: 'translateX(4px)',
|
||||
borderColor: rule.enabled ? '#8ec07c' : '#504945'
|
||||
}
|
||||
}}
|
||||
>
|
||||
<Box sx={{ display: 'flex', justifyContent: 'space-between', alignItems: 'flex-start' }}>
|
||||
<Box sx={{ flex: 1 }}>
|
||||
<Box sx={{ display: 'flex', alignItems: 'center', gap: 2, mb: 1 }}>
|
||||
<Typography variant="h6" sx={{ fontWeight: 600 }}>
|
||||
{rule.name}
|
||||
</Typography>
|
||||
{!rule.enabled && (
|
||||
<Chip
|
||||
label="Disabled"
|
||||
size="small"
|
||||
sx={{ bgcolor: '#504945', fontSize: '0.7rem' }}
|
||||
/>
|
||||
)}
|
||||
</Box>
|
||||
|
||||
<Box sx={{ display: 'flex', flexDirection: 'column', gap: 1 }}>
|
||||
<TriggerSummary trigger={rule.trigger} />
|
||||
<ActionSummary action={rule.action} />
|
||||
</Box>
|
||||
</Box>
|
||||
|
||||
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1 }}>
|
||||
<Tooltip title={rule.enabled ? 'Disable rule' : 'Enable rule'}>
|
||||
<Switch
|
||||
checked={rule.enabled}
|
||||
onChange={onToggle}
|
||||
color="primary"
|
||||
/>
|
||||
</Tooltip>
|
||||
<Tooltip title="Edit rule">
|
||||
<IconButton onClick={onEdit} size="small">
|
||||
<EditIcon />
|
||||
</IconButton>
|
||||
</Tooltip>
|
||||
<Tooltip title="Delete rule">
|
||||
<IconButton onClick={onDelete} size="small" sx={{ '&:hover': { color: '#fb4934' } }}>
|
||||
<DeleteIcon />
|
||||
</IconButton>
|
||||
</Tooltip>
|
||||
</Box>
|
||||
</Box>
|
||||
</Paper>
|
||||
);
|
||||
}
|
||||
346
src/client/RuleEditor.js
Normal file
346
src/client/RuleEditor.js
Normal file
@@ -0,0 +1,346 @@
|
||||
import React, { useState, useEffect } from 'react';
|
||||
import {
|
||||
Dialog,
|
||||
DialogTitle,
|
||||
DialogContent,
|
||||
DialogActions,
|
||||
TextField,
|
||||
Button,
|
||||
Box,
|
||||
FormControl,
|
||||
InputLabel,
|
||||
Select,
|
||||
MenuItem,
|
||||
ToggleButton,
|
||||
ToggleButtonGroup,
|
||||
Typography,
|
||||
Divider,
|
||||
Slider,
|
||||
Switch,
|
||||
FormControlLabel
|
||||
} from '@mui/material';
|
||||
|
||||
const DAYS = [
|
||||
{ key: 'mon', label: 'Mon' },
|
||||
{ key: 'tue', label: 'Tue' },
|
||||
{ key: 'wed', label: 'Wed' },
|
||||
{ key: 'thu', label: 'Thu' },
|
||||
{ key: 'fri', label: 'Fri' },
|
||||
{ key: 'sat', label: 'Sat' },
|
||||
{ key: 'sun', label: 'Sun' }
|
||||
];
|
||||
|
||||
const SENSORS = ['Temperature', 'Humidity', 'CO2', 'VPD', 'Light Level'];
|
||||
const OPERATORS = [
|
||||
{ value: '>', label: 'Greater than (>)' },
|
||||
{ value: '<', label: 'Less than (<)' },
|
||||
{ value: '>=', label: 'Greater or equal (≥)' },
|
||||
{ value: '<=', label: 'Less or equal (≤)' },
|
||||
{ value: '==', label: 'Equal to (=)' }
|
||||
];
|
||||
|
||||
const OUTPUTS = [
|
||||
'Workshop Light',
|
||||
'Exhaust Fan',
|
||||
'Heater',
|
||||
'Humidifier',
|
||||
'All Outlets',
|
||||
'Grow Light',
|
||||
'Circulation Fan'
|
||||
];
|
||||
|
||||
export default function RuleEditor({ open, rule, onSave, onClose }) {
|
||||
const [name, setName] = useState('');
|
||||
const [triggerType, setTriggerType] = useState('time');
|
||||
|
||||
// Time trigger state
|
||||
const [time, setTime] = useState('08:00');
|
||||
const [days, setDays] = useState(['mon', 'tue', 'wed', 'thu', 'fri']);
|
||||
|
||||
// Sensor trigger state
|
||||
const [sensor, setSensor] = useState('Temperature');
|
||||
const [operator, setOperator] = useState('>');
|
||||
const [sensorValue, setSensorValue] = useState(25);
|
||||
|
||||
// Action state
|
||||
const [actionType, setActionType] = useState('toggle');
|
||||
const [target, setTarget] = useState('Workshop Light');
|
||||
const [toggleState, setToggleState] = useState(true);
|
||||
const [duration, setDuration] = useState(15);
|
||||
|
||||
// Reset form when rule changes
|
||||
useEffect(() => {
|
||||
if (rule) {
|
||||
setName(rule.name);
|
||||
setTriggerType(rule.trigger.type);
|
||||
|
||||
if (rule.trigger.type === 'time') {
|
||||
setTime(rule.trigger.time);
|
||||
setDays(rule.trigger.days || []);
|
||||
} else {
|
||||
setSensor(rule.trigger.sensor);
|
||||
setOperator(rule.trigger.operator);
|
||||
setSensorValue(rule.trigger.value);
|
||||
}
|
||||
|
||||
setActionType(rule.action.type);
|
||||
setTarget(rule.action.target);
|
||||
if (rule.action.type === 'toggle') {
|
||||
setToggleState(rule.action.state);
|
||||
} else {
|
||||
setDuration(rule.action.duration);
|
||||
}
|
||||
} else {
|
||||
// Reset to defaults for new rule
|
||||
setName('');
|
||||
setTriggerType('time');
|
||||
setTime('08:00');
|
||||
setDays(['mon', 'tue', 'wed', 'thu', 'fri']);
|
||||
setSensor('Temperature');
|
||||
setOperator('>');
|
||||
setSensorValue(25);
|
||||
setActionType('toggle');
|
||||
setTarget('Workshop Light');
|
||||
setToggleState(true);
|
||||
setDuration(15);
|
||||
}
|
||||
}, [rule, open]);
|
||||
|
||||
const handleDaysChange = (event, newDays) => {
|
||||
if (newDays.length > 0) {
|
||||
setDays(newDays);
|
||||
}
|
||||
};
|
||||
|
||||
const handleSave = () => {
|
||||
const ruleData = {
|
||||
name,
|
||||
trigger: triggerType === 'time'
|
||||
? { type: 'time', time, days }
|
||||
: { type: 'sensor', sensor, operator, value: sensorValue },
|
||||
action: actionType === 'toggle'
|
||||
? { type: 'toggle', target, state: toggleState }
|
||||
: { type: 'keepOn', target, duration }
|
||||
};
|
||||
onSave(ruleData);
|
||||
};
|
||||
|
||||
const isValid = name.trim().length > 0 &&
|
||||
(triggerType !== 'time' || days.length > 0);
|
||||
|
||||
return (
|
||||
<Dialog
|
||||
open={open}
|
||||
onClose={onClose}
|
||||
maxWidth="sm"
|
||||
fullWidth
|
||||
PaperProps={{
|
||||
sx: {
|
||||
background: 'linear-gradient(145deg, #3c3836 0%, #282828 100%)',
|
||||
border: '1px solid #504945'
|
||||
}
|
||||
}}
|
||||
>
|
||||
<DialogTitle>
|
||||
{rule ? '✏️ Edit Rule' : '➕ Create New Rule'}
|
||||
</DialogTitle>
|
||||
|
||||
<DialogContent dividers>
|
||||
<Box sx={{ display: 'flex', flexDirection: 'column', gap: 3, pt: 1 }}>
|
||||
{/* Rule Name */}
|
||||
<TextField
|
||||
label="Rule Name"
|
||||
value={name}
|
||||
onChange={(e) => setName(e.target.value)}
|
||||
fullWidth
|
||||
placeholder="e.g., Morning Lights"
|
||||
/>
|
||||
|
||||
{/* Trigger Section */}
|
||||
<Box>
|
||||
<Typography variant="subtitle2" color="text.secondary" gutterBottom>
|
||||
TRIGGER (When to activate)
|
||||
</Typography>
|
||||
<Divider sx={{ mb: 2 }} />
|
||||
|
||||
<FormControl fullWidth sx={{ mb: 2 }}>
|
||||
<InputLabel>Trigger Type</InputLabel>
|
||||
<Select
|
||||
value={triggerType}
|
||||
label="Trigger Type"
|
||||
onChange={(e) => setTriggerType(e.target.value)}
|
||||
>
|
||||
<MenuItem value="time">⏰ Time-based</MenuItem>
|
||||
<MenuItem value="sensor">📊 Sensor Value</MenuItem>
|
||||
</Select>
|
||||
</FormControl>
|
||||
|
||||
{triggerType === 'time' && (
|
||||
<Box sx={{ display: 'flex', flexDirection: 'column', gap: 2 }}>
|
||||
<TextField
|
||||
label="Time"
|
||||
type="time"
|
||||
value={time}
|
||||
onChange={(e) => setTime(e.target.value)}
|
||||
InputLabelProps={{ shrink: true }}
|
||||
fullWidth
|
||||
/>
|
||||
<Box>
|
||||
<Typography variant="body2" color="text.secondary" sx={{ mb: 1 }}>
|
||||
Days of Week
|
||||
</Typography>
|
||||
<ToggleButtonGroup
|
||||
value={days}
|
||||
onChange={handleDaysChange}
|
||||
size="small"
|
||||
sx={{ flexWrap: 'wrap' }}
|
||||
>
|
||||
{DAYS.map(day => (
|
||||
<ToggleButton
|
||||
key={day.key}
|
||||
value={day.key}
|
||||
sx={{
|
||||
'&.Mui-selected': {
|
||||
bgcolor: '#8ec07c',
|
||||
color: '#282828',
|
||||
'&:hover': { bgcolor: '#98c98a' }
|
||||
}
|
||||
}}
|
||||
>
|
||||
{day.label}
|
||||
</ToggleButton>
|
||||
))}
|
||||
</ToggleButtonGroup>
|
||||
</Box>
|
||||
</Box>
|
||||
)}
|
||||
|
||||
{triggerType === 'sensor' && (
|
||||
<Box sx={{ display: 'flex', flexDirection: 'column', gap: 2 }}>
|
||||
<FormControl fullWidth>
|
||||
<InputLabel>Sensor</InputLabel>
|
||||
<Select
|
||||
value={sensor}
|
||||
label="Sensor"
|
||||
onChange={(e) => setSensor(e.target.value)}
|
||||
>
|
||||
{SENSORS.map(s => (
|
||||
<MenuItem key={s} value={s}>{s}</MenuItem>
|
||||
))}
|
||||
</Select>
|
||||
</FormControl>
|
||||
<Box sx={{ display: 'flex', gap: 2 }}>
|
||||
<FormControl sx={{ minWidth: 180 }}>
|
||||
<InputLabel>Condition</InputLabel>
|
||||
<Select
|
||||
value={operator}
|
||||
label="Condition"
|
||||
onChange={(e) => setOperator(e.target.value)}
|
||||
>
|
||||
{OPERATORS.map(op => (
|
||||
<MenuItem key={op.value} value={op.value}>{op.label}</MenuItem>
|
||||
))}
|
||||
</Select>
|
||||
</FormControl>
|
||||
<TextField
|
||||
label="Value"
|
||||
type="number"
|
||||
value={sensorValue}
|
||||
onChange={(e) => setSensorValue(Number(e.target.value))}
|
||||
fullWidth
|
||||
/>
|
||||
</Box>
|
||||
</Box>
|
||||
)}
|
||||
</Box>
|
||||
|
||||
{/* Action Section */}
|
||||
<Box>
|
||||
<Typography variant="subtitle2" color="text.secondary" gutterBottom>
|
||||
ACTION (What to do)
|
||||
</Typography>
|
||||
<Divider sx={{ mb: 2 }} />
|
||||
|
||||
<FormControl fullWidth sx={{ mb: 2 }}>
|
||||
<InputLabel>Action Type</InputLabel>
|
||||
<Select
|
||||
value={actionType}
|
||||
label="Action Type"
|
||||
onChange={(e) => setActionType(e.target.value)}
|
||||
>
|
||||
<MenuItem value="toggle">🔛 Toggle On/Off</MenuItem>
|
||||
<MenuItem value="keepOn">⏱️ Keep On for X Minutes</MenuItem>
|
||||
</Select>
|
||||
</FormControl>
|
||||
|
||||
<FormControl fullWidth sx={{ mb: 2 }}>
|
||||
<InputLabel>Target Output</InputLabel>
|
||||
<Select
|
||||
value={target}
|
||||
label="Target Output"
|
||||
onChange={(e) => setTarget(e.target.value)}
|
||||
>
|
||||
{OUTPUTS.map(o => (
|
||||
<MenuItem key={o} value={o}>{o}</MenuItem>
|
||||
))}
|
||||
</Select>
|
||||
</FormControl>
|
||||
|
||||
{actionType === 'toggle' && (
|
||||
<FormControlLabel
|
||||
control={
|
||||
<Switch
|
||||
checked={toggleState}
|
||||
onChange={(e) => setToggleState(e.target.checked)}
|
||||
color="primary"
|
||||
/>
|
||||
}
|
||||
label={toggleState ? 'Turn ON' : 'Turn OFF'}
|
||||
/>
|
||||
)}
|
||||
|
||||
{actionType === 'keepOn' && (
|
||||
<Box>
|
||||
<Typography variant="body2" color="text.secondary">
|
||||
Duration: {duration} minutes
|
||||
</Typography>
|
||||
<Slider
|
||||
value={duration}
|
||||
onChange={(e, val) => setDuration(val)}
|
||||
min={1}
|
||||
max={120}
|
||||
marks={[
|
||||
{ value: 1, label: '1m' },
|
||||
{ value: 30, label: '30m' },
|
||||
{ value: 60, label: '1h' },
|
||||
{ value: 120, label: '2h' }
|
||||
]}
|
||||
valueLabelDisplay="auto"
|
||||
/>
|
||||
</Box>
|
||||
)}
|
||||
</Box>
|
||||
</Box>
|
||||
</DialogContent>
|
||||
|
||||
<DialogActions sx={{ px: 3, py: 2 }}>
|
||||
<Button onClick={onClose} color="inherit">
|
||||
Cancel
|
||||
</Button>
|
||||
<Button
|
||||
onClick={handleSave}
|
||||
variant="contained"
|
||||
disabled={!isValid}
|
||||
sx={{
|
||||
background: 'linear-gradient(45deg, #8ec07c 30%, #b8bb26 90%)',
|
||||
'&:hover': {
|
||||
background: 'linear-gradient(45deg, #98c98a 30%, #c5c836 90%)',
|
||||
}
|
||||
}}
|
||||
>
|
||||
{rule ? 'Save Changes' : 'Create Rule'}
|
||||
</Button>
|
||||
</DialogActions>
|
||||
</Dialog>
|
||||
);
|
||||
}
|
||||
173
src/client/RuleManager.js
Normal file
173
src/client/RuleManager.js
Normal file
@@ -0,0 +1,173 @@
|
||||
import React, { useState } from 'react';
|
||||
import {
|
||||
Box,
|
||||
Typography,
|
||||
Button,
|
||||
Paper,
|
||||
Divider
|
||||
} from '@mui/material';
|
||||
import RuleCard from './RuleCard';
|
||||
import RuleEditor from './RuleEditor';
|
||||
|
||||
// Initial mock rules for demonstration
|
||||
const initialRules = [
|
||||
{
|
||||
id: 1,
|
||||
name: 'Morning Light',
|
||||
enabled: true,
|
||||
trigger: {
|
||||
type: 'time',
|
||||
time: '06:30',
|
||||
days: ['mon', 'tue', 'wed', 'thu', 'fri']
|
||||
},
|
||||
action: {
|
||||
type: 'toggle',
|
||||
target: 'Workshop Light',
|
||||
state: true
|
||||
}
|
||||
},
|
||||
{
|
||||
id: 2,
|
||||
name: 'High Humidity Fan',
|
||||
enabled: true,
|
||||
trigger: {
|
||||
type: 'sensor',
|
||||
sensor: 'Humidity',
|
||||
operator: '>',
|
||||
value: 70
|
||||
},
|
||||
action: {
|
||||
type: 'keepOn',
|
||||
target: 'Exhaust Fan',
|
||||
duration: 15
|
||||
}
|
||||
},
|
||||
{
|
||||
id: 3,
|
||||
name: 'Evening Shutdown',
|
||||
enabled: false,
|
||||
trigger: {
|
||||
type: 'time',
|
||||
time: '18:00',
|
||||
days: ['mon', 'tue', 'wed', 'thu', 'fri', 'sat', 'sun']
|
||||
},
|
||||
action: {
|
||||
type: 'toggle',
|
||||
target: 'All Outlets',
|
||||
state: false
|
||||
}
|
||||
}
|
||||
];
|
||||
|
||||
export default function RuleManager() {
|
||||
const [rules, setRules] = useState(initialRules);
|
||||
const [editorOpen, setEditorOpen] = useState(false);
|
||||
const [editingRule, setEditingRule] = useState(null);
|
||||
|
||||
const handleAddRule = () => {
|
||||
setEditingRule(null);
|
||||
setEditorOpen(true);
|
||||
};
|
||||
|
||||
const handleEditRule = (rule) => {
|
||||
setEditingRule(rule);
|
||||
setEditorOpen(true);
|
||||
};
|
||||
|
||||
const handleDeleteRule = (ruleId) => {
|
||||
setRules(rules.filter(r => r.id !== ruleId));
|
||||
};
|
||||
|
||||
const handleToggleRule = (ruleId) => {
|
||||
setRules(rules.map(r =>
|
||||
r.id === ruleId ? { ...r, enabled: !r.enabled } : r
|
||||
));
|
||||
};
|
||||
|
||||
const handleSaveRule = (ruleData) => {
|
||||
if (editingRule) {
|
||||
// Update existing rule
|
||||
setRules(rules.map(r =>
|
||||
r.id === editingRule.id ? { ...r, ...ruleData } : r
|
||||
));
|
||||
} else {
|
||||
// Add new rule
|
||||
const newRule = {
|
||||
...ruleData,
|
||||
id: Math.max(0, ...rules.map(r => r.id)) + 1,
|
||||
enabled: true
|
||||
};
|
||||
setRules([...rules, newRule]);
|
||||
}
|
||||
setEditorOpen(false);
|
||||
setEditingRule(null);
|
||||
};
|
||||
|
||||
const handleCloseEditor = () => {
|
||||
setEditorOpen(false);
|
||||
setEditingRule(null);
|
||||
};
|
||||
|
||||
return (
|
||||
<Paper
|
||||
sx={{
|
||||
mt: 4,
|
||||
p: 3,
|
||||
background: 'linear-gradient(145deg, #3c3836 0%, #32302f 100%)',
|
||||
border: '1px solid #504945'
|
||||
}}
|
||||
>
|
||||
<Box sx={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', mb: 2 }}>
|
||||
<Box>
|
||||
<Typography variant="h5" sx={{ fontWeight: 600, display: 'flex', alignItems: 'center', gap: 1 }}>
|
||||
⚙️ Automation Rules
|
||||
</Typography>
|
||||
<Typography variant="body2" color="text.secondary">
|
||||
Configure triggers and actions for home automation
|
||||
</Typography>
|
||||
</Box>
|
||||
<Button
|
||||
variant="contained"
|
||||
onClick={handleAddRule}
|
||||
sx={{
|
||||
background: 'linear-gradient(45deg, #b8bb26 30%, #8ec07c 90%)',
|
||||
'&:hover': {
|
||||
background: 'linear-gradient(45deg, #c5c836 30%, #98c98a 90%)',
|
||||
}
|
||||
}}
|
||||
>
|
||||
+ Add Rule
|
||||
</Button>
|
||||
</Box>
|
||||
|
||||
<Divider sx={{ mb: 3 }} />
|
||||
|
||||
{rules.length === 0 ? (
|
||||
<Box sx={{ textAlign: 'center', py: 4 }}>
|
||||
<Typography color="text.secondary">
|
||||
No rules configured. Click "Add Rule" to create one.
|
||||
</Typography>
|
||||
</Box>
|
||||
) : (
|
||||
<Box sx={{ display: 'flex', flexDirection: 'column', gap: 2 }}>
|
||||
{rules.map(rule => (
|
||||
<RuleCard
|
||||
key={rule.id}
|
||||
rule={rule}
|
||||
onEdit={() => handleEditRule(rule)}
|
||||
onDelete={() => handleDeleteRule(rule.id)}
|
||||
onToggle={() => handleToggleRule(rule.id)}
|
||||
/>
|
||||
))}
|
||||
</Box>
|
||||
)}
|
||||
|
||||
<RuleEditor
|
||||
open={editorOpen}
|
||||
rule={editingRule}
|
||||
onSave={handleSaveRule}
|
||||
onClose={handleCloseEditor}
|
||||
/>
|
||||
</Paper>
|
||||
);
|
||||
}
|
||||
14
src/client/index.html
Normal file
14
src/client/index.html
Normal file
@@ -0,0 +1,14 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>Tischlerei Dashboard</title>
|
||||
</head>
|
||||
|
||||
<body>
|
||||
<div id="root"></div>
|
||||
</body>
|
||||
|
||||
</html>
|
||||
7
src/client/index.js
Normal file
7
src/client/index.js
Normal file
@@ -0,0 +1,7 @@
|
||||
import React from 'react';
|
||||
import { createRoot } from 'react-dom/client';
|
||||
import App from './App';
|
||||
|
||||
const container = document.getElementById('root');
|
||||
const root = createRoot(container);
|
||||
root.render(<App />);
|
||||
Reference in New Issue
Block a user