tags
This commit is contained in:
@@ -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}
|
||||
/>
|
||||
)}
|
||||
|
||||
Reference in New Issue
Block a user