This commit is contained in:
sebseb7
2025-12-21 03:36:29 +01:00
parent 096fc2aa72
commit eab4241e6e
4 changed files with 558 additions and 112 deletions

View File

@@ -6,12 +6,26 @@ import {
Paper,
Divider,
Alert,
CircularProgress
CircularProgress,
Chip,
IconButton
} from '@mui/material';
import RuleCard from './RuleCard';
import RuleEditor from './RuleEditor';
import { useAuth } from './AuthContext';
// 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 [rules, setRules] = useState([]);
@@ -21,6 +35,7 @@ export default function RuleManager() {
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(() => {
@@ -73,7 +88,7 @@ export default function RuleManager() {
const seenDevices = new Set();
devices.forEach(d => {
// Add environment sensors once per device
// Add environment sensors once per device (temp, humidity)
if (!seenDevices.has(d.dev_name)) {
seenDevices.add(d.dev_name);
availableSensors.push({
@@ -88,7 +103,7 @@ export default function RuleManager() {
});
}
// Add each port as a sensor (Fan Speed, Brightness, CO2, etc.)
// 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`,
@@ -96,29 +111,36 @@ export default function RuleManager() {
});
});
// Build available outputs: Tapo plugs + device fans/lights
// 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' },
...devices
.filter(d => d.port_name === 'Fan')
.map(d => ({
id: `${d.dev_name}:fan:${d.port}`,
label: `${d.dev_name} - Fan`,
type: 'fan'
})),
...devices
.filter(d => d.port_name === 'Light')
.map(d => ({
id: `${d.dev_name}:light:${d.port}`,
label: `${d.dev_name} - Light`,
type: 'light'
}))
// 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);
@@ -172,11 +194,11 @@ export default function RuleManager() {
setSaving(true);
try {
if (editingRule) {
// Update existing rule
// Update existing rule - preserve enabled state
const res = await fetch(`api/rules/${editingRule.id}`, {
method: 'PUT',
headers: getAuthHeaders(),
body: JSON.stringify(ruleData)
body: JSON.stringify({ ...ruleData, enabled: editingRule.enabled })
});
if (!res.ok) throw new Error('Failed to update rule');
const updated = await res.json();
@@ -206,6 +228,36 @@ export default function RuleManager() {
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' }}>
@@ -250,7 +302,34 @@ export default function RuleManager() {
)}
</Box>
<Divider sx={{ mb: 3 }} />
<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)}>
@@ -258,21 +337,27 @@ export default function RuleManager() {
</Alert>
)}
{rules.length === 0 ? (
{filteredRules.length === 0 ? (
<Box sx={{ textAlign: 'center', py: 4 }}>
<Typography color="text.secondary">
No rules configured. {isAdmin && 'Click "Add Rule" to create one.'}
{rules.length === 0
? (isAdmin ? 'No rules configured. Click "Add Rule" to create one.' : 'No rules configured.')
: 'No rules match the selected filter.'
}
</Typography>
</Box>
) : (
<Box sx={{ display: 'flex', flexDirection: 'column', gap: 2 }}>
{rules.map(rule => (
{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}
/>
))}
@@ -287,6 +372,7 @@ export default function RuleManager() {
onClose={handleCloseEditor}
sensors={availableSensors}
outputs={availableOutputs}
colorTags={COLOR_TAGS}
saving={saving}
/>
)}