Files
wolfDash/src/client/RuleManager.js
sebseb7 5febdf29c8 i18n
2025-12-21 03:46:50 +01:00

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>
);
}