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 ( {t('rules.loading')} ); } return ( {t('rules.title')} {isAdmin ? t('rules.adminDescription') : t('rules.guestDescription')} {isAdmin && ( )} {/* Color tag filter */} Filter: setFilterTag(null)} sx={{ bgcolor: filterTag === null ? '#ebdbb2' : '#504945', color: filterTag === null ? '#282828' : '#ebdbb2' }} /> {COLOR_TAGS.map(tag => ( 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' } }} /> ))} {error && ( setError(null)}> {error} )} {filteredRules.length === 0 ? ( {rules.length === 0 ? (isAdmin ? t('rules.noRules') + ' ' + t('rules.noRulesAdmin') : t('rules.noRules')) : 'No rules match the selected filter.' } ) : ( {filteredRules.map((rule, idx) => ( 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} /> ))} )} {isAdmin && ( )} ); }