384 lines
14 KiB
JavaScript
384 lines
14 KiB
JavaScript
import React, { useState, useEffect, useCallback } from 'react';
|
|
import {
|
|
Box,
|
|
Typography,
|
|
Button,
|
|
Paper,
|
|
Divider,
|
|
Alert,
|
|
CircularProgress,
|
|
Chip,
|
|
IconButton
|
|
} from '@mui/material';
|
|
import RuleCard from './RuleCard';
|
|
import RuleEditor from './RuleEditor';
|
|
import { useAuth } from './AuthContext';
|
|
import { useI18n } from './I18nContext';
|
|
|
|
// 8 color tags
|
|
const COLOR_TAGS = [
|
|
{ id: 'red', label: 'Red', color: '#fb4934' },
|
|
{ id: 'orange', label: 'Orange', color: '#fe8019' },
|
|
{ id: 'yellow', label: 'Yellow', color: '#fabd2f' },
|
|
{ id: 'green', label: 'Green', color: '#b8bb26' },
|
|
{ id: 'teal', label: 'Teal', color: '#8ec07c' },
|
|
{ id: 'blue', label: 'Blue', color: '#83a598' },
|
|
{ id: 'purple', label: 'Purple', color: '#d3869b' },
|
|
{ id: 'gray', label: 'Gray', color: '#928374' }
|
|
];
|
|
|
|
export default function RuleManager() {
|
|
const { isAdmin } = useAuth();
|
|
const { t } = useI18n();
|
|
const [rules, setRules] = useState([]);
|
|
const [loading, setLoading] = useState(true);
|
|
const [error, setError] = useState(null);
|
|
const [editorOpen, setEditorOpen] = useState(false);
|
|
const [editingRule, setEditingRule] = useState(null);
|
|
const [devices, setDevices] = useState([]);
|
|
const [saving, setSaving] = useState(false);
|
|
const [filterTag, setFilterTag] = useState(null); // null = show all
|
|
|
|
// Get auth token from localStorage
|
|
const getAuthHeaders = useCallback(() => {
|
|
const token = localStorage.getItem('authToken');
|
|
return {
|
|
'Content-Type': 'application/json',
|
|
'Authorization': `Bearer ${token}`
|
|
};
|
|
}, []);
|
|
|
|
// Fetch rules from server (public endpoint)
|
|
const fetchRules = useCallback(async () => {
|
|
try {
|
|
const res = await fetch('api/rules');
|
|
if (!res.ok) {
|
|
throw new Error('Failed to fetch rules');
|
|
}
|
|
const data = await res.json();
|
|
setRules(data);
|
|
setError(null);
|
|
} catch (err) {
|
|
setError(err.message);
|
|
} finally {
|
|
setLoading(false);
|
|
}
|
|
}, []);
|
|
|
|
// Fetch devices for sensor/output selection
|
|
const fetchDevices = useCallback(async () => {
|
|
try {
|
|
const res = await fetch('api/devices');
|
|
if (res.ok) {
|
|
const data = await res.json();
|
|
setDevices(data);
|
|
}
|
|
} catch (err) {
|
|
console.error('Failed to fetch devices:', err);
|
|
}
|
|
}, []);
|
|
|
|
useEffect(() => {
|
|
fetchRules();
|
|
fetchDevices();
|
|
}, [fetchRules, fetchDevices]);
|
|
|
|
// Build available sensors
|
|
// - Environment sensors (Temp, Humidity) are per DEVICE
|
|
// - Port values (Fan Speed, Brightness, CO2, etc.) are per PORT
|
|
const availableSensors = [];
|
|
const seenDevices = new Set();
|
|
|
|
devices.forEach(d => {
|
|
// Add environment sensors once per device (temp, humidity)
|
|
if (!seenDevices.has(d.dev_name)) {
|
|
seenDevices.add(d.dev_name);
|
|
availableSensors.push({
|
|
id: `${d.dev_name}:temp`,
|
|
label: `${d.dev_name} - Temperature`,
|
|
type: 'temperature'
|
|
});
|
|
availableSensors.push({
|
|
id: `${d.dev_name}:humidity`,
|
|
label: `${d.dev_name} - Humidity`,
|
|
type: 'humidity'
|
|
});
|
|
}
|
|
|
|
// Add each port as a sensor (Fan Speed 0-10, Brightness 0-10, CO2, etc.)
|
|
availableSensors.push({
|
|
id: `${d.dev_name}:${d.port}:level`,
|
|
label: `${d.dev_name} - ${d.port_name} Level`,
|
|
type: d.port_name.toLowerCase()
|
|
});
|
|
});
|
|
|
|
// Build available outputs: Tapo plugs + ALL device ports + 4 virtual channels
|
|
const availableOutputs = [
|
|
// Tapo smart plugs
|
|
{ id: 'tapo-001', label: 'Tapo 001', type: 'plug' },
|
|
{ id: 'tapo-002', label: 'Tapo 002', type: 'plug' },
|
|
{ id: 'tapo-003', label: 'Tapo 003', type: 'plug' },
|
|
{ id: 'tapo-004', label: 'Tapo 004', type: 'plug' },
|
|
{ id: 'tapo-005', label: 'Tapo 005', type: 'plug' },
|
|
// All device ports as outputs
|
|
...devices.map(d => ({
|
|
id: `${d.dev_name}:${d.port}:out`,
|
|
label: `${d.dev_name} - ${d.port_name}`,
|
|
type: d.port_name.toLowerCase()
|
|
})),
|
|
// 4 virtual channels
|
|
{ id: 'virtual-1', label: 'Virtual Channel 1', type: 'virtual' },
|
|
{ id: 'virtual-2', label: 'Virtual Channel 2', type: 'virtual' },
|
|
{ id: 'virtual-3', label: 'Virtual Channel 3', type: 'virtual' },
|
|
{ id: 'virtual-4', label: 'Virtual Channel 4', type: 'virtual' }
|
|
];
|
|
|
|
// Add Tapo and virtual channels as sensors (on/off state)
|
|
[...availableOutputs.filter(o => o.type === 'plug' || o.type === 'virtual')].forEach(o => {
|
|
availableSensors.push({
|
|
id: `${o.id}:state`,
|
|
label: `${o.label} (State)`,
|
|
type: 'output-state'
|
|
});
|
|
});
|
|
|
|
const handleAddRule = () => {
|
|
setEditingRule(null);
|
|
setEditorOpen(true);
|
|
};
|
|
|
|
const handleEditRule = (rule) => {
|
|
setEditingRule(rule);
|
|
setEditorOpen(true);
|
|
};
|
|
|
|
const handleDeleteRule = async (ruleId) => {
|
|
if (!confirm(t('rules.deleteConfirm'))) return;
|
|
|
|
setSaving(true);
|
|
try {
|
|
const res = await fetch(`api/rules/${ruleId}`, {
|
|
method: 'DELETE',
|
|
headers: getAuthHeaders()
|
|
});
|
|
if (!res.ok) throw new Error('Failed to delete rule');
|
|
setRules(rules.filter(r => r.id !== ruleId));
|
|
} catch (err) {
|
|
setError(err.message);
|
|
} finally {
|
|
setSaving(false);
|
|
}
|
|
};
|
|
|
|
const handleToggleRule = async (ruleId) => {
|
|
const rule = rules.find(r => r.id === ruleId);
|
|
if (!rule) return;
|
|
|
|
setSaving(true);
|
|
try {
|
|
const res = await fetch(`api/rules/${ruleId}`, {
|
|
method: 'PUT',
|
|
headers: getAuthHeaders(),
|
|
body: JSON.stringify({ ...rule, enabled: !rule.enabled })
|
|
});
|
|
if (!res.ok) throw new Error('Failed to update rule');
|
|
const updated = await res.json();
|
|
setRules(rules.map(r => r.id === ruleId ? updated : r));
|
|
} catch (err) {
|
|
setError(err.message);
|
|
} finally {
|
|
setSaving(false);
|
|
}
|
|
};
|
|
|
|
const handleSaveRule = async (ruleData) => {
|
|
setSaving(true);
|
|
try {
|
|
if (editingRule) {
|
|
// Update existing rule - preserve enabled state
|
|
const res = await fetch(`api/rules/${editingRule.id}`, {
|
|
method: 'PUT',
|
|
headers: getAuthHeaders(),
|
|
body: JSON.stringify({ ...ruleData, enabled: editingRule.enabled })
|
|
});
|
|
if (!res.ok) throw new Error('Failed to update rule');
|
|
const updated = await res.json();
|
|
setRules(rules.map(r => r.id === editingRule.id ? updated : r));
|
|
} else {
|
|
// Create new rule
|
|
const res = await fetch('api/rules', {
|
|
method: 'POST',
|
|
headers: getAuthHeaders(),
|
|
body: JSON.stringify({ ...ruleData, enabled: true })
|
|
});
|
|
if (!res.ok) throw new Error('Failed to create rule');
|
|
const newRule = await res.json();
|
|
setRules([...rules, newRule]);
|
|
}
|
|
setEditorOpen(false);
|
|
setEditingRule(null);
|
|
} catch (err) {
|
|
setError(err.message);
|
|
} finally {
|
|
setSaving(false);
|
|
}
|
|
};
|
|
|
|
const handleCloseEditor = () => {
|
|
setEditorOpen(false);
|
|
setEditingRule(null);
|
|
};
|
|
|
|
// Move rule up or down
|
|
const handleMoveRule = async (ruleId, direction) => {
|
|
const idx = rules.findIndex(r => r.id === ruleId);
|
|
if (idx === -1) return;
|
|
if (direction === 'up' && idx === 0) return;
|
|
if (direction === 'down' && idx === rules.length - 1) return;
|
|
|
|
const newRules = [...rules];
|
|
const swapIdx = direction === 'up' ? idx - 1 : idx + 1;
|
|
[newRules[idx], newRules[swapIdx]] = [newRules[swapIdx], newRules[idx]];
|
|
setRules(newRules);
|
|
|
|
// Save new order to server
|
|
try {
|
|
const ruleIds = newRules.map(r => r.id);
|
|
await fetch('api/rules/reorder', {
|
|
method: 'PUT',
|
|
headers: getAuthHeaders(),
|
|
body: JSON.stringify({ ruleIds })
|
|
});
|
|
} catch (err) {
|
|
setError('Failed to save order');
|
|
}
|
|
};
|
|
|
|
// Filter rules by color tag
|
|
const filteredRules = filterTag
|
|
? rules.filter(r => (r.colorTags || []).includes(filterTag))
|
|
: rules;
|
|
|
|
if (loading) {
|
|
return (
|
|
<Paper sx={{ mt: 4, p: 3, textAlign: 'center' }}>
|
|
<CircularProgress size={24} />
|
|
<Typography sx={{ mt: 2 }}>{t('rules.loading')}</Typography>
|
|
</Paper>
|
|
);
|
|
}
|
|
|
|
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 }}>
|
|
{t('rules.title')}
|
|
</Typography>
|
|
<Typography variant="body2" color="text.secondary">
|
|
{isAdmin ? t('rules.adminDescription') : t('rules.guestDescription')}
|
|
</Typography>
|
|
</Box>
|
|
{isAdmin && (
|
|
<Button
|
|
variant="contained"
|
|
onClick={handleAddRule}
|
|
disabled={saving}
|
|
sx={{
|
|
background: 'linear-gradient(45deg, #b8bb26 30%, #8ec07c 90%)',
|
|
'&:hover': {
|
|
background: 'linear-gradient(45deg, #c5c836 30%, #98c98a 90%)',
|
|
}
|
|
}}
|
|
>
|
|
{t('rules.addRule')}
|
|
</Button>
|
|
)}
|
|
</Box>
|
|
|
|
<Divider sx={{ mb: 2 }} />
|
|
|
|
{/* Color tag filter */}
|
|
<Box sx={{ display: 'flex', gap: 1, mb: 2, flexWrap: 'wrap', alignItems: 'center' }}>
|
|
<Typography variant="body2" color="text.secondary" sx={{ mr: 1 }}>Filter:</Typography>
|
|
<Chip
|
|
label="All"
|
|
size="small"
|
|
onClick={() => setFilterTag(null)}
|
|
sx={{
|
|
bgcolor: filterTag === null ? '#ebdbb2' : '#504945',
|
|
color: filterTag === null ? '#282828' : '#ebdbb2'
|
|
}}
|
|
/>
|
|
{COLOR_TAGS.map(tag => (
|
|
<Chip
|
|
key={tag.id}
|
|
size="small"
|
|
onClick={() => setFilterTag(filterTag === tag.id ? null : tag.id)}
|
|
sx={{
|
|
bgcolor: filterTag === tag.id ? tag.color : '#504945',
|
|
color: filterTag === tag.id ? '#282828' : tag.color,
|
|
border: `2px solid ${tag.color}`,
|
|
'&:hover': { bgcolor: tag.color, color: '#282828' }
|
|
}}
|
|
/>
|
|
))}
|
|
</Box>
|
|
|
|
{error && (
|
|
<Alert severity="error" sx={{ mb: 2 }} onClose={() => setError(null)}>
|
|
{error}
|
|
</Alert>
|
|
)}
|
|
|
|
{filteredRules.length === 0 ? (
|
|
<Box sx={{ textAlign: 'center', py: 4 }}>
|
|
<Typography color="text.secondary">
|
|
{rules.length === 0
|
|
? (isAdmin ? t('rules.noRules') + ' ' + t('rules.noRulesAdmin') : t('rules.noRules'))
|
|
: 'No rules match the selected filter.'
|
|
}
|
|
</Typography>
|
|
</Box>
|
|
) : (
|
|
<Box sx={{ display: 'flex', flexDirection: 'column', gap: 2 }}>
|
|
{filteredRules.map((rule, idx) => (
|
|
<RuleCard
|
|
key={rule.id}
|
|
rule={rule}
|
|
onEdit={isAdmin ? () => handleEditRule(rule) : null}
|
|
onDelete={isAdmin ? () => handleDeleteRule(rule.id) : null}
|
|
onToggle={isAdmin ? () => handleToggleRule(rule.id) : null}
|
|
onMoveUp={isAdmin && idx > 0 ? () => handleMoveRule(rule.id, 'up') : null}
|
|
onMoveDown={isAdmin && idx < filteredRules.length - 1 ? () => handleMoveRule(rule.id, 'down') : null}
|
|
colorTags={COLOR_TAGS}
|
|
readOnly={!isAdmin}
|
|
/>
|
|
))}
|
|
</Box>
|
|
)}
|
|
|
|
{isAdmin && (
|
|
<RuleEditor
|
|
open={editorOpen}
|
|
rule={editingRule}
|
|
onSave={handleSaveRule}
|
|
onClose={handleCloseEditor}
|
|
sensors={availableSensors}
|
|
outputs={availableOutputs}
|
|
colorTags={COLOR_TAGS}
|
|
saving={saving}
|
|
/>
|
|
)}
|
|
</Paper>
|
|
);
|
|
}
|