tags
This commit is contained in:
67
server.js
67
server.js
@@ -74,11 +74,21 @@ db.exec(`
|
|||||||
trigger_data TEXT NOT NULL,
|
trigger_data TEXT NOT NULL,
|
||||||
action_type TEXT NOT NULL,
|
action_type TEXT NOT NULL,
|
||||||
action_data TEXT NOT NULL,
|
action_data TEXT NOT NULL,
|
||||||
|
sort_order INTEGER DEFAULT 0,
|
||||||
|
color_tag TEXT DEFAULT NULL,
|
||||||
created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
|
created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
|
||||||
updated_at DATETIME DEFAULT CURRENT_TIMESTAMP
|
updated_at DATETIME DEFAULT CURRENT_TIMESTAMP
|
||||||
)
|
)
|
||||||
`);
|
`);
|
||||||
|
|
||||||
|
// Migration: Add sort_order and color_tag if they don't exist
|
||||||
|
try {
|
||||||
|
db.exec('ALTER TABLE rules ADD COLUMN sort_order INTEGER DEFAULT 0');
|
||||||
|
} catch (e) { /* column already exists */ }
|
||||||
|
try {
|
||||||
|
db.exec('ALTER TABLE rules ADD COLUMN color_tag TEXT DEFAULT NULL');
|
||||||
|
} catch (e) { /* column already exists */ }
|
||||||
|
|
||||||
const insertStmt = db.prepare(`
|
const insertStmt = db.prepare(`
|
||||||
INSERT INTO readings (dev_id, dev_name, port, port_name, temp_c, humidity, vpd, fan_speed, on_speed, off_speed)
|
INSERT INTO readings (dev_id, dev_name, port, port_name, temp_c, humidity, vpd, fan_speed, on_speed, off_speed)
|
||||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
||||||
@@ -87,17 +97,19 @@ const insertStmt = db.prepare(`
|
|||||||
const getUserByUsername = db.prepare('SELECT * FROM users WHERE username = ?');
|
const getUserByUsername = db.prepare('SELECT * FROM users WHERE username = ?');
|
||||||
|
|
||||||
// Rules prepared statements
|
// Rules prepared statements
|
||||||
const getAllRules = db.prepare('SELECT * FROM rules ORDER BY id');
|
const getAllRules = db.prepare('SELECT * FROM rules ORDER BY sort_order, id');
|
||||||
const getRuleById = db.prepare('SELECT * FROM rules WHERE id = ?');
|
const getRuleById = db.prepare('SELECT * FROM rules WHERE id = ?');
|
||||||
const insertRule = db.prepare(`
|
const insertRule = db.prepare(`
|
||||||
INSERT INTO rules (name, enabled, trigger_type, trigger_data, action_type, action_data)
|
INSERT INTO rules (name, enabled, trigger_type, trigger_data, action_type, action_data, sort_order, color_tag)
|
||||||
VALUES (?, ?, ?, ?, ?, ?)
|
VALUES (?, ?, ?, ?, ?, ?, ?, ?)
|
||||||
`);
|
`);
|
||||||
const updateRule = db.prepare(`
|
const updateRule = db.prepare(`
|
||||||
UPDATE rules SET name = ?, enabled = ?, trigger_type = ?, trigger_data = ?, action_type = ?, action_data = ?, updated_at = CURRENT_TIMESTAMP
|
UPDATE rules SET name = ?, enabled = ?, trigger_type = ?, trigger_data = ?, action_type = ?, action_data = ?, color_tag = ?, updated_at = CURRENT_TIMESTAMP
|
||||||
WHERE id = ?
|
WHERE id = ?
|
||||||
`);
|
`);
|
||||||
|
const updateRuleOrder = db.prepare('UPDATE rules SET sort_order = ? WHERE id = ?');
|
||||||
const deleteRule = db.prepare('DELETE FROM rules WHERE id = ?');
|
const deleteRule = db.prepare('DELETE FROM rules WHERE id = ?');
|
||||||
|
const getMaxSortOrder = db.prepare('SELECT COALESCE(MAX(sort_order), 0) as max_order FROM rules');
|
||||||
|
|
||||||
// --- AC INFINITY API LOGIC ---
|
// --- AC INFINITY API LOGIC ---
|
||||||
let token = null;
|
let token = null;
|
||||||
@@ -342,10 +354,23 @@ function formatRule(row) {
|
|||||||
action.type = row.action_type;
|
action.type = row.action_type;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Parse colorTags (stored as JSON array)
|
||||||
|
let colorTags = [];
|
||||||
|
if (row.color_tag) {
|
||||||
|
try {
|
||||||
|
colorTags = JSON.parse(row.color_tag);
|
||||||
|
} catch (e) {
|
||||||
|
// Backwards compat: single tag as string
|
||||||
|
colorTags = [row.color_tag];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
return {
|
return {
|
||||||
id: row.id,
|
id: row.id,
|
||||||
name: row.name,
|
name: row.name,
|
||||||
enabled: row.enabled === 1,
|
enabled: row.enabled === 1,
|
||||||
|
sortOrder: row.sort_order || 0,
|
||||||
|
colorTags,
|
||||||
trigger,
|
trigger,
|
||||||
action
|
action
|
||||||
};
|
};
|
||||||
@@ -364,7 +389,7 @@ app.get('/api/rules', (req, res) => {
|
|||||||
// POST /api/rules - admin only
|
// POST /api/rules - admin only
|
||||||
app.post('/api/rules', requireAuth, requireAdmin, (req, res) => {
|
app.post('/api/rules', requireAuth, requireAdmin, (req, res) => {
|
||||||
try {
|
try {
|
||||||
const { name, enabled, trigger, action } = req.body;
|
const { name, enabled, trigger, action, colorTags } = req.body;
|
||||||
if (!name || !trigger || !action) {
|
if (!name || !trigger || !action) {
|
||||||
return res.status(400).json({ error: 'name, trigger, and action required' });
|
return res.status(400).json({ error: 'name, trigger, and action required' });
|
||||||
}
|
}
|
||||||
@@ -374,9 +399,14 @@ app.post('/api/rules', requireAuth, requireAdmin, (req, res) => {
|
|||||||
if (trigger.timeRange && !trigger.sensors?.length) triggerType = 'time';
|
if (trigger.timeRange && !trigger.sensors?.length) triggerType = 'time';
|
||||||
if (!trigger.timeRange && trigger.sensors?.length) triggerType = 'sensor';
|
if (!trigger.timeRange && trigger.sensors?.length) triggerType = 'sensor';
|
||||||
|
|
||||||
|
// Get next sort order
|
||||||
|
const maxOrder = getMaxSortOrder.get().max_order;
|
||||||
|
const sortOrder = maxOrder + 1;
|
||||||
|
|
||||||
const triggerData = JSON.stringify(trigger);
|
const triggerData = JSON.stringify(trigger);
|
||||||
const actionData = JSON.stringify(action);
|
const actionData = JSON.stringify(action);
|
||||||
const result = insertRule.run(name, enabled ? 1 : 0, triggerType, triggerData, action.type, actionData);
|
const colorTagsData = colorTags?.length ? JSON.stringify(colorTags) : null;
|
||||||
|
const result = insertRule.run(name, enabled ? 1 : 0, triggerType, triggerData, action.type, actionData, sortOrder, colorTagsData);
|
||||||
|
|
||||||
const newRule = getRuleById.get(result.lastInsertRowid);
|
const newRule = getRuleById.get(result.lastInsertRowid);
|
||||||
res.status(201).json(formatRule(newRule));
|
res.status(201).json(formatRule(newRule));
|
||||||
@@ -394,7 +424,7 @@ app.put('/api/rules/:id', requireAuth, requireAdmin, (req, res) => {
|
|||||||
return res.status(404).json({ error: 'Rule not found' });
|
return res.status(404).json({ error: 'Rule not found' });
|
||||||
}
|
}
|
||||||
|
|
||||||
const { name, enabled, trigger, action } = req.body;
|
const { name, enabled, trigger, action, colorTags } = req.body;
|
||||||
|
|
||||||
// Determine trigger type for storage
|
// Determine trigger type for storage
|
||||||
let triggerType = 'combined';
|
let triggerType = 'combined';
|
||||||
@@ -403,7 +433,8 @@ app.put('/api/rules/:id', requireAuth, requireAdmin, (req, res) => {
|
|||||||
|
|
||||||
const triggerData = JSON.stringify(trigger);
|
const triggerData = JSON.stringify(trigger);
|
||||||
const actionData = JSON.stringify(action);
|
const actionData = JSON.stringify(action);
|
||||||
updateRule.run(name, enabled ? 1 : 0, triggerType, triggerData, action.type, actionData, id);
|
const colorTagsData = colorTags?.length ? JSON.stringify(colorTags) : null;
|
||||||
|
updateRule.run(name, enabled ? 1 : 0, triggerType, triggerData, action.type, actionData, colorTagsData, id);
|
||||||
|
|
||||||
const updated = getRuleById.get(id);
|
const updated = getRuleById.get(id);
|
||||||
res.json(formatRule(updated));
|
res.json(formatRule(updated));
|
||||||
@@ -412,6 +443,26 @@ app.put('/api/rules/:id', requireAuth, requireAdmin, (req, res) => {
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// PUT /api/rules/reorder - admin only (reorder rules)
|
||||||
|
app.put('/api/rules/reorder', requireAuth, requireAdmin, (req, res) => {
|
||||||
|
try {
|
||||||
|
const { ruleIds } = req.body; // Array of rule IDs in new order
|
||||||
|
if (!Array.isArray(ruleIds)) {
|
||||||
|
return res.status(400).json({ error: 'ruleIds array required' });
|
||||||
|
}
|
||||||
|
|
||||||
|
// Update sort_order for each rule
|
||||||
|
ruleIds.forEach((ruleId, index) => {
|
||||||
|
updateRuleOrder.run(index, ruleId);
|
||||||
|
});
|
||||||
|
|
||||||
|
const rows = getAllRules.all();
|
||||||
|
res.json(rows.map(formatRule));
|
||||||
|
} catch (error) {
|
||||||
|
res.status(500).json({ error: error.message });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
// DELETE /api/rules/:id - admin only
|
// DELETE /api/rules/:id - admin only
|
||||||
app.delete('/api/rules/:id', requireAuth, requireAdmin, (req, res) => {
|
app.delete('/api/rules/:id', requireAuth, requireAdmin, (req, res) => {
|
||||||
try {
|
try {
|
||||||
|
|||||||
@@ -19,7 +19,25 @@ const dayOrder = ['mon', 'tue', 'wed', 'thu', 'fri', 'sat', 'sun'];
|
|||||||
function TriggerSummary({ trigger }) {
|
function TriggerSummary({ trigger }) {
|
||||||
const parts = [];
|
const parts = [];
|
||||||
|
|
||||||
// Time range
|
// Scheduled time (trigger at exact time)
|
||||||
|
if (trigger.scheduledTime) {
|
||||||
|
const { time, days } = trigger.scheduledTime;
|
||||||
|
const isEveryDay = days?.length === 7;
|
||||||
|
const isWeekdays = days?.length === 5 && ['mon', 'tue', 'wed', 'thu', 'fri'].every(d => days.includes(d));
|
||||||
|
let dayText = isEveryDay ? 'daily' : isWeekdays ? 'weekdays' :
|
||||||
|
dayOrder.filter(d => days?.includes(d)).map(d => dayLabels[d]).join('');
|
||||||
|
|
||||||
|
parts.push(
|
||||||
|
<Box key="scheduled" sx={{ display: 'flex', alignItems: 'center', gap: 0.5, flexWrap: 'wrap' }}>
|
||||||
|
<Chip label="🕐" size="small" sx={{ bgcolor: '#d3869b', color: '#282828', fontWeight: 600, minWidth: 32 }} />
|
||||||
|
<Typography variant="body2">
|
||||||
|
At <strong>{time}</strong> ({dayText})
|
||||||
|
</Typography>
|
||||||
|
</Box>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Time range (active during window)
|
||||||
if (trigger.timeRange) {
|
if (trigger.timeRange) {
|
||||||
const { start, end, days } = trigger.timeRange;
|
const { start, end, days } = trigger.timeRange;
|
||||||
const isEveryDay = days?.length === 7;
|
const isEveryDay = days?.length === 7;
|
||||||
@@ -95,26 +113,29 @@ function TriggerSummary({ trigger }) {
|
|||||||
|
|
||||||
function ActionSummary({ action }) {
|
function ActionSummary({ action }) {
|
||||||
if (action.type === 'toggle') {
|
if (action.type === 'toggle') {
|
||||||
|
// Check if it's a level or binary action
|
||||||
|
const hasLevel = action.level !== undefined;
|
||||||
return (
|
return (
|
||||||
<Box sx={{ display: 'flex', alignItems: 'center', gap: 0.5 }}>
|
<Box sx={{ display: 'flex', alignItems: 'center', gap: 0.5 }}>
|
||||||
<Chip
|
<Chip
|
||||||
label={action.state ? '🔛' : '🔴'}
|
label={hasLevel ? '🎚️' : (action.state ? '🔛' : '🔴')}
|
||||||
size="small"
|
size="small"
|
||||||
sx={{ bgcolor: action.state ? '#b8bb26' : '#fb4934', color: '#282828', fontWeight: 600, minWidth: 32 }}
|
sx={{ bgcolor: hasLevel ? '#83a598' : (action.state ? '#b8bb26' : '#fb4934'), color: '#282828', fontWeight: 600, minWidth: 32 }}
|
||||||
/>
|
/>
|
||||||
<Typography variant="body2">
|
<Typography variant="body2">
|
||||||
→ <strong>{action.targetLabel || action.target}</strong> {action.state ? 'ON' : 'OFF'}
|
→ <strong>{action.targetLabel || action.target}</strong> {hasLevel ? `Level ${action.level}` : (action.state ? 'ON' : 'OFF')}
|
||||||
</Typography>
|
</Typography>
|
||||||
</Box>
|
</Box>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
if (action.type === 'keepOn') {
|
if (action.type === 'keepOn') {
|
||||||
|
const hasLevel = action.level !== undefined;
|
||||||
return (
|
return (
|
||||||
<Box sx={{ display: 'flex', alignItems: 'center', gap: 0.5 }}>
|
<Box sx={{ display: 'flex', alignItems: 'center', gap: 0.5 }}>
|
||||||
<Chip label="⏱️" size="small" sx={{ bgcolor: '#d3869b', color: '#282828', fontWeight: 600, minWidth: 32 }} />
|
<Chip label="⏱️" size="small" sx={{ bgcolor: '#d3869b', color: '#282828', fontWeight: 600, minWidth: 32 }} />
|
||||||
<Typography variant="body2">
|
<Typography variant="body2">
|
||||||
→ <strong>{action.targetLabel || action.target}</strong> ON for {action.duration}m
|
→ <strong>{action.targetLabel || action.target}</strong> {hasLevel ? `Level ${action.level}` : 'ON'} for {action.duration}m
|
||||||
</Typography>
|
</Typography>
|
||||||
</Box>
|
</Box>
|
||||||
);
|
);
|
||||||
@@ -123,7 +144,11 @@ function ActionSummary({ action }) {
|
|||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
export default function RuleCard({ rule, onEdit, onDelete, onToggle, readOnly }) {
|
export default function RuleCard({ rule, onEdit, onDelete, onToggle, onMoveUp, onMoveDown, colorTags = [], readOnly }) {
|
||||||
|
// Get list of tag colors for this rule (handle array or backwards-compat single value)
|
||||||
|
const ruleTagIds = rule.colorTags || (rule.colorTag ? [rule.colorTag] : []);
|
||||||
|
const ruleTags = ruleTagIds.map(tagId => colorTags.find(t => t.id === tagId)).filter(Boolean);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Paper
|
<Paper
|
||||||
sx={{
|
sx={{
|
||||||
@@ -132,6 +157,7 @@ export default function RuleCard({ rule, onEdit, onDelete, onToggle, readOnly })
|
|||||||
transition: 'opacity 0.2s, transform 0.2s',
|
transition: 'opacity 0.2s, transform 0.2s',
|
||||||
border: '1px solid',
|
border: '1px solid',
|
||||||
borderColor: rule.enabled ? '#504945' : '#3c3836',
|
borderColor: rule.enabled ? '#504945' : '#3c3836',
|
||||||
|
borderLeft: ruleTags.length > 0 ? `4px solid ${ruleTags[0].color}` : '1px solid #504945',
|
||||||
'&:hover': {
|
'&:hover': {
|
||||||
transform: readOnly ? 'none' : 'translateX(4px)',
|
transform: readOnly ? 'none' : 'translateX(4px)',
|
||||||
borderColor: rule.enabled ? '#8ec07c' : '#504945'
|
borderColor: rule.enabled ? '#8ec07c' : '#504945'
|
||||||
@@ -144,6 +170,19 @@ export default function RuleCard({ rule, onEdit, onDelete, onToggle, readOnly })
|
|||||||
<Typography variant="h6" sx={{ fontWeight: 600 }}>
|
<Typography variant="h6" sx={{ fontWeight: 600 }}>
|
||||||
{rule.name}
|
{rule.name}
|
||||||
</Typography>
|
</Typography>
|
||||||
|
{ruleTags.length > 0 && (
|
||||||
|
<Box sx={{ display: 'flex', gap: 0.25 }}>
|
||||||
|
{ruleTags.map(tag => (
|
||||||
|
<Box key={tag.id} sx={{
|
||||||
|
width: 10,
|
||||||
|
height: 10,
|
||||||
|
borderRadius: '50%',
|
||||||
|
bgcolor: tag.color,
|
||||||
|
flexShrink: 0
|
||||||
|
}} />
|
||||||
|
))}
|
||||||
|
</Box>
|
||||||
|
)}
|
||||||
{!rule.enabled && (
|
{!rule.enabled && (
|
||||||
<Chip label="Disabled" size="small" sx={{ bgcolor: '#504945', fontSize: '0.7rem' }} />
|
<Chip label="Disabled" size="small" sx={{ bgcolor: '#504945', fontSize: '0.7rem' }} />
|
||||||
)}
|
)}
|
||||||
@@ -156,7 +195,34 @@ export default function RuleCard({ rule, onEdit, onDelete, onToggle, readOnly })
|
|||||||
</Box>
|
</Box>
|
||||||
|
|
||||||
{!readOnly && (
|
{!readOnly && (
|
||||||
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1 }}>
|
<Box sx={{ display: 'flex', alignItems: 'center', gap: 0.5 }}>
|
||||||
|
{/* Move buttons */}
|
||||||
|
<Box sx={{ display: 'flex', flexDirection: 'column' }}>
|
||||||
|
<Tooltip title="Move up">
|
||||||
|
<span>
|
||||||
|
<IconButton
|
||||||
|
onClick={onMoveUp}
|
||||||
|
size="small"
|
||||||
|
disabled={!onMoveUp}
|
||||||
|
sx={{ p: 0.25 }}
|
||||||
|
>
|
||||||
|
▲
|
||||||
|
</IconButton>
|
||||||
|
</span>
|
||||||
|
</Tooltip>
|
||||||
|
<Tooltip title="Move down">
|
||||||
|
<span>
|
||||||
|
<IconButton
|
||||||
|
onClick={onMoveDown}
|
||||||
|
size="small"
|
||||||
|
disabled={!onMoveDown}
|
||||||
|
sx={{ p: 0.25 }}
|
||||||
|
>
|
||||||
|
▼
|
||||||
|
</IconButton>
|
||||||
|
</span>
|
||||||
|
</Tooltip>
|
||||||
|
</Box>
|
||||||
<Tooltip title={rule.enabled ? 'Disable' : 'Enable'}>
|
<Tooltip title={rule.enabled ? 'Disable' : 'Enable'}>
|
||||||
<Switch checked={rule.enabled} onChange={onToggle} color="primary" size="small" />
|
<Switch checked={rule.enabled} onChange={onToggle} color="primary" size="small" />
|
||||||
</Tooltip>
|
</Tooltip>
|
||||||
|
|||||||
@@ -44,14 +44,29 @@ const OPERATORS = [
|
|||||||
|
|
||||||
// Single sensor condition component
|
// Single sensor condition component
|
||||||
function SensorCondition({ condition, sensors, onChange, onRemove, disabled }) {
|
function SensorCondition({ condition, sensors, onChange, onRemove, disabled }) {
|
||||||
|
const selectedSensor = sensors.find(s => s.id === condition.sensor);
|
||||||
|
const isStateSensor = selectedSensor?.type === 'output-state';
|
||||||
|
|
||||||
|
// When sensor changes, reset operator to appropriate default
|
||||||
|
const handleSensorChange = (newSensorId) => {
|
||||||
|
const newSensor = sensors.find(s => s.id === newSensorId);
|
||||||
|
const newIsState = newSensor?.type === 'output-state';
|
||||||
|
onChange({
|
||||||
|
...condition,
|
||||||
|
sensor: newSensorId,
|
||||||
|
operator: newIsState ? '==' : '>',
|
||||||
|
value: newIsState ? 1 : (condition.value ?? 25)
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Paper sx={{ p: 1.5, display: 'flex', alignItems: 'center', gap: 1, bgcolor: 'background.default' }}>
|
<Paper sx={{ p: 1.5, display: 'flex', alignItems: 'center', gap: 1, bgcolor: 'background.default' }}>
|
||||||
<FormControl size="small" sx={{ minWidth: 150 }}>
|
<FormControl size="small" sx={{ minWidth: 180 }}>
|
||||||
<InputLabel>Sensor</InputLabel>
|
<InputLabel>Sensor</InputLabel>
|
||||||
<Select
|
<Select
|
||||||
value={condition.sensor || ''}
|
value={condition.sensor || ''}
|
||||||
label="Sensor"
|
label="Sensor"
|
||||||
onChange={(e) => onChange({ ...condition, sensor: e.target.value })}
|
onChange={(e) => handleSensorChange(e.target.value)}
|
||||||
disabled={disabled}
|
disabled={disabled}
|
||||||
>
|
>
|
||||||
{sensors.map(s => (
|
{sensors.map(s => (
|
||||||
@@ -59,6 +74,22 @@ function SensorCondition({ condition, sensors, onChange, onRemove, disabled }) {
|
|||||||
))}
|
))}
|
||||||
</Select>
|
</Select>
|
||||||
</FormControl>
|
</FormControl>
|
||||||
|
|
||||||
|
{isStateSensor ? (
|
||||||
|
// State sensor: is on / is off
|
||||||
|
<FormControl size="small" sx={{ minWidth: 100 }}>
|
||||||
|
<Select
|
||||||
|
value={condition.value === 1 ? 'on' : 'off'}
|
||||||
|
onChange={(e) => onChange({ ...condition, operator: '==', value: e.target.value === 'on' ? 1 : 0 })}
|
||||||
|
disabled={disabled}
|
||||||
|
>
|
||||||
|
<MenuItem value="on">is ON</MenuItem>
|
||||||
|
<MenuItem value="off">is OFF</MenuItem>
|
||||||
|
</Select>
|
||||||
|
</FormControl>
|
||||||
|
) : (
|
||||||
|
// Value sensor: numeric comparison
|
||||||
|
<>
|
||||||
<FormControl size="small" sx={{ minWidth: 70 }}>
|
<FormControl size="small" sx={{ minWidth: 70 }}>
|
||||||
<Select
|
<Select
|
||||||
value={condition.operator || '>'}
|
value={condition.operator || '>'}
|
||||||
@@ -78,6 +109,9 @@ function SensorCondition({ condition, sensors, onChange, onRemove, disabled }) {
|
|||||||
sx={{ width: 80 }}
|
sx={{ width: 80 }}
|
||||||
disabled={disabled}
|
disabled={disabled}
|
||||||
/>
|
/>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
|
||||||
{onRemove && (
|
{onRemove && (
|
||||||
<IconButton size="small" onClick={onRemove} disabled={disabled}>
|
<IconButton size="small" onClick={onRemove} disabled={disabled}>
|
||||||
❌
|
❌
|
||||||
@@ -87,14 +121,20 @@ function SensorCondition({ condition, sensors, onChange, onRemove, disabled }) {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
export default function RuleEditor({ open, rule, onSave, onClose, sensors = [], outputs = [], saving }) {
|
export default function RuleEditor({ open, rule, onSave, onClose, sensors = [], outputs = [], colorTags: availableColorTags = [], saving }) {
|
||||||
const [name, setName] = useState('');
|
const [name, setName] = useState('');
|
||||||
|
const [selectedTags, setSelectedTags] = useState([]); // array of tag ids
|
||||||
|
|
||||||
// Time range state
|
// Scheduled time state (trigger at specific time)
|
||||||
|
const [useScheduledTime, setUseScheduledTime] = useState(false);
|
||||||
|
const [scheduledTime, setScheduledTime] = useState('08:00');
|
||||||
|
const [scheduledDays, setScheduledDays] = useState(['mon', 'tue', 'wed', 'thu', 'fri']);
|
||||||
|
|
||||||
|
// Time range state (active during window)
|
||||||
const [useTimeRange, setUseTimeRange] = useState(false);
|
const [useTimeRange, setUseTimeRange] = useState(false);
|
||||||
const [timeStart, setTimeStart] = useState('08:00');
|
const [timeStart, setTimeStart] = useState('08:00');
|
||||||
const [timeEnd, setTimeEnd] = useState('18:00');
|
const [timeEnd, setTimeEnd] = useState('18:00');
|
||||||
const [days, setDays] = useState(['mon', 'tue', 'wed', 'thu', 'fri']);
|
const [timeRangeDays, setTimeRangeDays] = useState(['mon', 'tue', 'wed', 'thu', 'fri']);
|
||||||
|
|
||||||
// Sensor conditions state
|
// Sensor conditions state
|
||||||
const [useSensors, setUseSensors] = useState(false);
|
const [useSensors, setUseSensors] = useState(false);
|
||||||
@@ -105,20 +145,37 @@ export default function RuleEditor({ open, rule, onSave, onClose, sensors = [],
|
|||||||
const [actionType, setActionType] = useState('toggle');
|
const [actionType, setActionType] = useState('toggle');
|
||||||
const [target, setTarget] = useState('');
|
const [target, setTarget] = useState('');
|
||||||
const [toggleState, setToggleState] = useState(true);
|
const [toggleState, setToggleState] = useState(true);
|
||||||
|
const [outputLevel, setOutputLevel] = useState(5); // 1-10 for port outputs
|
||||||
const [duration, setDuration] = useState(15);
|
const [duration, setDuration] = useState(15);
|
||||||
|
|
||||||
|
// Check if target is a binary (on/off) output or level (1-10) output
|
||||||
|
const selectedOutput = outputs.find(o => o.id === target);
|
||||||
|
const isBinaryOutput = selectedOutput?.type === 'plug' || selectedOutput?.type === 'virtual';
|
||||||
|
|
||||||
// Reset form when rule changes or dialog opens
|
// Reset form when rule changes or dialog opens
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (rule) {
|
if (rule) {
|
||||||
setName(rule.name);
|
setName(rule.name);
|
||||||
|
// colorTags can be array or single value for backwards compat
|
||||||
|
const tags = rule.colorTags || (rule.colorTag ? [rule.colorTag] : []);
|
||||||
|
setSelectedTags(Array.isArray(tags) ? tags : []);
|
||||||
|
|
||||||
// Parse trigger
|
// Parse trigger
|
||||||
const trigger = rule.trigger || {};
|
const trigger = rule.trigger || {};
|
||||||
|
|
||||||
|
// Scheduled time
|
||||||
|
setUseScheduledTime(!!trigger.scheduledTime);
|
||||||
|
if (trigger.scheduledTime) {
|
||||||
|
setScheduledTime(trigger.scheduledTime.time || '08:00');
|
||||||
|
setScheduledDays(trigger.scheduledTime.days || []);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Time range
|
||||||
setUseTimeRange(!!trigger.timeRange);
|
setUseTimeRange(!!trigger.timeRange);
|
||||||
if (trigger.timeRange) {
|
if (trigger.timeRange) {
|
||||||
setTimeStart(trigger.timeRange.start || '08:00');
|
setTimeStart(trigger.timeRange.start || '08:00');
|
||||||
setTimeEnd(trigger.timeRange.end || '18:00');
|
setTimeEnd(trigger.timeRange.end || '18:00');
|
||||||
setDays(trigger.timeRange.days || []);
|
setTimeRangeDays(trigger.timeRange.days || []);
|
||||||
}
|
}
|
||||||
|
|
||||||
setUseSensors(!!trigger.sensors && trigger.sensors.length > 0);
|
setUseSensors(!!trigger.sensors && trigger.sensors.length > 0);
|
||||||
@@ -132,22 +189,28 @@ export default function RuleEditor({ open, rule, onSave, onClose, sensors = [],
|
|||||||
setTarget(rule.action?.target || '');
|
setTarget(rule.action?.target || '');
|
||||||
if (rule.action?.type === 'toggle') {
|
if (rule.action?.type === 'toggle') {
|
||||||
setToggleState(rule.action?.state ?? true);
|
setToggleState(rule.action?.state ?? true);
|
||||||
|
setOutputLevel(rule.action?.level ?? 5);
|
||||||
} else {
|
} else {
|
||||||
setDuration(rule.action?.duration || 15);
|
setDuration(rule.action?.duration || 15);
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
// Reset to defaults
|
// Reset to defaults
|
||||||
setName('');
|
setName('');
|
||||||
setUseTimeRange(true);
|
setSelectedTags([]);
|
||||||
|
setUseScheduledTime(true);
|
||||||
|
setScheduledTime('08:00');
|
||||||
|
setScheduledDays(['mon', 'tue', 'wed', 'thu', 'fri']);
|
||||||
|
setUseTimeRange(false);
|
||||||
setTimeStart('08:00');
|
setTimeStart('08:00');
|
||||||
setTimeEnd('18:00');
|
setTimeEnd('18:00');
|
||||||
setDays(['mon', 'tue', 'wed', 'thu', 'fri']);
|
setTimeRangeDays(['mon', 'tue', 'wed', 'thu', 'fri']);
|
||||||
setUseSensors(false);
|
setUseSensors(false);
|
||||||
setSensorConditions([{ sensor: sensors[0]?.id || '', operator: '>', value: 25 }]);
|
setSensorConditions([{ sensor: sensors[0]?.id || '', operator: '>', value: 25 }]);
|
||||||
setSensorLogic('and');
|
setSensorLogic('and');
|
||||||
setActionType('toggle');
|
setActionType('toggle');
|
||||||
setTarget(outputs[0]?.id || '');
|
setTarget(outputs[0]?.id || '');
|
||||||
setToggleState(true);
|
setToggleState(true);
|
||||||
|
setOutputLevel(5);
|
||||||
setDuration(15);
|
setDuration(15);
|
||||||
}
|
}
|
||||||
}, [rule, open, sensors, outputs]);
|
}, [rule, open, sensors, outputs]);
|
||||||
@@ -160,8 +223,12 @@ export default function RuleEditor({ open, rule, onSave, onClose, sensors = [],
|
|||||||
if (!target && outputs.length > 0) setTarget(outputs[0].id);
|
if (!target && outputs.length > 0) setTarget(outputs[0].id);
|
||||||
}, [sensors, outputs, sensorConditions, target]);
|
}, [sensors, outputs, sensorConditions, target]);
|
||||||
|
|
||||||
const handleDaysChange = (event, newDays) => {
|
const handleScheduledDaysChange = (event, newDays) => {
|
||||||
if (newDays.length > 0) setDays(newDays);
|
if (newDays.length > 0) setScheduledDays(newDays);
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleTimeRangeDaysChange = (event, newDays) => {
|
||||||
|
if (newDays.length > 0) setTimeRangeDays(newDays);
|
||||||
};
|
};
|
||||||
|
|
||||||
const addSensorCondition = () => {
|
const addSensorCondition = () => {
|
||||||
@@ -185,8 +252,11 @@ export default function RuleEditor({ open, rule, onSave, onClose, sensors = [],
|
|||||||
|
|
||||||
// Build trigger object
|
// Build trigger object
|
||||||
const trigger = {};
|
const trigger = {};
|
||||||
|
if (useScheduledTime) {
|
||||||
|
trigger.scheduledTime = { time: scheduledTime, days: scheduledDays };
|
||||||
|
}
|
||||||
if (useTimeRange) {
|
if (useTimeRange) {
|
||||||
trigger.timeRange = { start: timeStart, end: timeEnd, days };
|
trigger.timeRange = { start: timeStart, end: timeEnd, days: timeRangeDays };
|
||||||
}
|
}
|
||||||
if (useSensors && sensorConditions.length > 0) {
|
if (useSensors && sensorConditions.length > 0) {
|
||||||
trigger.sensors = sensorConditions.map(c => ({
|
trigger.sensors = sensorConditions.map(c => ({
|
||||||
@@ -196,19 +266,35 @@ export default function RuleEditor({ open, rule, onSave, onClose, sensors = [],
|
|||||||
trigger.sensorLogic = sensorLogic;
|
trigger.sensorLogic = sensorLogic;
|
||||||
}
|
}
|
||||||
|
|
||||||
const ruleData = {
|
const isBinaryTarget = selectedOutput?.type === 'plug' || selectedOutput?.type === 'virtual';
|
||||||
name,
|
|
||||||
trigger,
|
// Build action object based on output type
|
||||||
action: actionType === 'toggle'
|
let action;
|
||||||
? { type: 'toggle', target, targetLabel: selectedOutput?.label, state: toggleState }
|
if (actionType === 'toggle') {
|
||||||
: { type: 'keepOn', target, targetLabel: selectedOutput?.label, duration }
|
action = {
|
||||||
|
type: 'toggle',
|
||||||
|
target,
|
||||||
|
targetLabel: selectedOutput?.label,
|
||||||
|
...(isBinaryTarget ? { state: toggleState } : { level: outputLevel })
|
||||||
};
|
};
|
||||||
|
} else {
|
||||||
|
action = {
|
||||||
|
type: 'keepOn',
|
||||||
|
target,
|
||||||
|
targetLabel: selectedOutput?.label,
|
||||||
|
duration,
|
||||||
|
...(isBinaryTarget ? {} : { level: outputLevel })
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
const ruleData = { name, trigger, action, colorTags: selectedTags };
|
||||||
onSave(ruleData);
|
onSave(ruleData);
|
||||||
};
|
};
|
||||||
|
|
||||||
const isValid = name.trim().length > 0 &&
|
const isValid = name.trim().length > 0 &&
|
||||||
(useTimeRange || useSensors) &&
|
(useScheduledTime || useTimeRange || useSensors) &&
|
||||||
(!useTimeRange || days.length > 0) &&
|
(!useScheduledTime || scheduledDays.length > 0) &&
|
||||||
|
(!useTimeRange || timeRangeDays.length > 0) &&
|
||||||
(!useSensors || sensorConditions.every(c => c.sensor)) &&
|
(!useSensors || sensorConditions.every(c => c.sensor)) &&
|
||||||
target;
|
target;
|
||||||
|
|
||||||
@@ -241,14 +327,119 @@ export default function RuleEditor({ open, rule, onSave, onClose, sensors = [],
|
|||||||
disabled={saving}
|
disabled={saving}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
|
{/* Color Tag Picker (multi-select) */}
|
||||||
|
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1, flexWrap: 'wrap' }}>
|
||||||
|
<Typography variant="body2" color="text.secondary">Tags:</Typography>
|
||||||
|
<Box
|
||||||
|
onClick={() => setSelectedTags([])}
|
||||||
|
sx={{
|
||||||
|
width: 24,
|
||||||
|
height: 24,
|
||||||
|
borderRadius: '50%',
|
||||||
|
bgcolor: '#504945',
|
||||||
|
cursor: 'pointer',
|
||||||
|
border: selectedTags.length === 0 ? '3px solid #ebdbb2' : '2px solid #504945',
|
||||||
|
display: 'flex',
|
||||||
|
alignItems: 'center',
|
||||||
|
justifyContent: 'center',
|
||||||
|
fontSize: '0.7rem',
|
||||||
|
'&:hover': { opacity: 0.8 }
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
✕
|
||||||
|
</Box>
|
||||||
|
{availableColorTags.map(tag => (
|
||||||
|
<Box
|
||||||
|
key={tag.id}
|
||||||
|
onClick={() => {
|
||||||
|
if (selectedTags.includes(tag.id)) {
|
||||||
|
setSelectedTags(selectedTags.filter(t => t !== tag.id));
|
||||||
|
} else {
|
||||||
|
setSelectedTags([...selectedTags, tag.id]);
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
sx={{
|
||||||
|
width: 24,
|
||||||
|
height: 24,
|
||||||
|
borderRadius: '50%',
|
||||||
|
bgcolor: tag.color,
|
||||||
|
cursor: 'pointer',
|
||||||
|
border: selectedTags.includes(tag.id) ? '3px solid #ebdbb2' : '2px solid transparent',
|
||||||
|
display: 'flex',
|
||||||
|
alignItems: 'center',
|
||||||
|
justifyContent: 'center',
|
||||||
|
'&:hover': { opacity: 0.8 }
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{selectedTags.includes(tag.id) && <span style={{ fontSize: '0.7rem' }}>✓</span>}
|
||||||
|
</Box>
|
||||||
|
))}
|
||||||
|
</Box>
|
||||||
|
|
||||||
{/* TRIGGERS SECTION */}
|
{/* TRIGGERS SECTION */}
|
||||||
<Box>
|
<Box>
|
||||||
<Typography variant="subtitle2" color="text.secondary" gutterBottom>
|
<Typography variant="subtitle2" color="text.secondary" gutterBottom>
|
||||||
TRIGGERS (When to activate - conditions are combined with AND)
|
TRIGGERS (When to activate)
|
||||||
</Typography>
|
</Typography>
|
||||||
<Divider sx={{ mb: 2 }} />
|
<Divider sx={{ mb: 2 }} />
|
||||||
|
|
||||||
{/* Time Range Trigger */}
|
{/* Scheduled Time Trigger (fires at exact time) */}
|
||||||
|
<Paper sx={{ p: 2, mb: 2, bgcolor: useScheduledTime ? 'action.selected' : 'background.default' }}>
|
||||||
|
<FormControlLabel
|
||||||
|
control={
|
||||||
|
<Switch
|
||||||
|
checked={useScheduledTime}
|
||||||
|
onChange={(e) => setUseScheduledTime(e.target.checked)}
|
||||||
|
disabled={saving}
|
||||||
|
/>
|
||||||
|
}
|
||||||
|
label={<Typography fontWeight={600}>🕐 Scheduled Time (trigger at exact time)</Typography>}
|
||||||
|
/>
|
||||||
|
|
||||||
|
{useScheduledTime && (
|
||||||
|
<Box sx={{ mt: 2, display: 'flex', flexDirection: 'column', gap: 2 }}>
|
||||||
|
<TextField
|
||||||
|
label="Trigger At"
|
||||||
|
type="time"
|
||||||
|
value={scheduledTime}
|
||||||
|
onChange={(e) => setScheduledTime(e.target.value)}
|
||||||
|
InputLabelProps={{ shrink: true }}
|
||||||
|
size="small"
|
||||||
|
sx={{ width: 150 }}
|
||||||
|
disabled={saving}
|
||||||
|
/>
|
||||||
|
<Box>
|
||||||
|
<Typography variant="body2" color="text.secondary" sx={{ mb: 1 }}>
|
||||||
|
Days
|
||||||
|
</Typography>
|
||||||
|
<ToggleButtonGroup
|
||||||
|
value={scheduledDays}
|
||||||
|
onChange={handleScheduledDaysChange}
|
||||||
|
size="small"
|
||||||
|
disabled={saving}
|
||||||
|
>
|
||||||
|
{DAYS.map(day => (
|
||||||
|
<ToggleButton
|
||||||
|
key={day.key}
|
||||||
|
value={day.key}
|
||||||
|
sx={{
|
||||||
|
'&.Mui-selected': {
|
||||||
|
bgcolor: '#d3869b',
|
||||||
|
color: '#282828',
|
||||||
|
'&:hover': { bgcolor: '#e396a5' }
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{day.label}
|
||||||
|
</ToggleButton>
|
||||||
|
))}
|
||||||
|
</ToggleButtonGroup>
|
||||||
|
</Box>
|
||||||
|
</Box>
|
||||||
|
)}
|
||||||
|
</Paper>
|
||||||
|
|
||||||
|
{/* Time Range Trigger (active within window) */}
|
||||||
<Paper sx={{ p: 2, mb: 2, bgcolor: useTimeRange ? 'action.selected' : 'background.default' }}>
|
<Paper sx={{ p: 2, mb: 2, bgcolor: useTimeRange ? 'action.selected' : 'background.default' }}>
|
||||||
<FormControlLabel
|
<FormControlLabel
|
||||||
control={
|
control={
|
||||||
@@ -258,7 +449,7 @@ export default function RuleEditor({ open, rule, onSave, onClose, sensors = [],
|
|||||||
disabled={saving}
|
disabled={saving}
|
||||||
/>
|
/>
|
||||||
}
|
}
|
||||||
label={<Typography fontWeight={600}>⏰ Time Range</Typography>}
|
label={<Typography fontWeight={600}>⏰ Time Range (active during window)</Typography>}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
{useTimeRange && (
|
{useTimeRange && (
|
||||||
@@ -289,8 +480,8 @@ export default function RuleEditor({ open, rule, onSave, onClose, sensors = [],
|
|||||||
Days
|
Days
|
||||||
</Typography>
|
</Typography>
|
||||||
<ToggleButtonGroup
|
<ToggleButtonGroup
|
||||||
value={days}
|
value={timeRangeDays}
|
||||||
onChange={handleDaysChange}
|
onChange={handleTimeRangeDaysChange}
|
||||||
size="small"
|
size="small"
|
||||||
disabled={saving}
|
disabled={saving}
|
||||||
>
|
>
|
||||||
@@ -425,7 +616,11 @@ export default function RuleEditor({ open, rule, onSave, onClose, sensors = [],
|
|||||||
</Select>
|
</Select>
|
||||||
</FormControl>
|
</FormControl>
|
||||||
|
|
||||||
|
{/* Toggle action controls */}
|
||||||
{actionType === 'toggle' && (
|
{actionType === 'toggle' && (
|
||||||
|
<Box>
|
||||||
|
{isBinaryOutput ? (
|
||||||
|
// Binary output: On/Off switch
|
||||||
<FormControlLabel
|
<FormControlLabel
|
||||||
control={
|
control={
|
||||||
<Switch
|
<Switch
|
||||||
@@ -437,9 +632,56 @@ export default function RuleEditor({ open, rule, onSave, onClose, sensors = [],
|
|||||||
}
|
}
|
||||||
label={toggleState ? 'Turn ON' : 'Turn OFF'}
|
label={toggleState ? 'Turn ON' : 'Turn OFF'}
|
||||||
/>
|
/>
|
||||||
|
) : (
|
||||||
|
// Level output: 1-10 slider
|
||||||
|
<Box>
|
||||||
|
<Typography variant="body2" color="text.secondary">
|
||||||
|
Set Level: {outputLevel}
|
||||||
|
</Typography>
|
||||||
|
<Slider
|
||||||
|
value={outputLevel}
|
||||||
|
onChange={(e, val) => setOutputLevel(val)}
|
||||||
|
min={1}
|
||||||
|
max={10}
|
||||||
|
step={1}
|
||||||
|
marks={[
|
||||||
|
{ value: 1, label: '1' },
|
||||||
|
{ value: 5, label: '5' },
|
||||||
|
{ value: 10, label: '10' }
|
||||||
|
]}
|
||||||
|
valueLabelDisplay="auto"
|
||||||
|
disabled={saving}
|
||||||
|
/>
|
||||||
|
</Box>
|
||||||
|
)}
|
||||||
|
</Box>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
{/* Keep On action controls */}
|
||||||
{actionType === 'keepOn' && (
|
{actionType === 'keepOn' && (
|
||||||
|
<Box sx={{ display: 'flex', flexDirection: 'column', gap: 2 }}>
|
||||||
|
{!isBinaryOutput && (
|
||||||
|
// Level for port outputs
|
||||||
|
<Box>
|
||||||
|
<Typography variant="body2" color="text.secondary">
|
||||||
|
Set Level: {outputLevel}
|
||||||
|
</Typography>
|
||||||
|
<Slider
|
||||||
|
value={outputLevel}
|
||||||
|
onChange={(e, val) => setOutputLevel(val)}
|
||||||
|
min={1}
|
||||||
|
max={10}
|
||||||
|
step={1}
|
||||||
|
marks={[
|
||||||
|
{ value: 1, label: '1' },
|
||||||
|
{ value: 5, label: '5' },
|
||||||
|
{ value: 10, label: '10' }
|
||||||
|
]}
|
||||||
|
valueLabelDisplay="auto"
|
||||||
|
disabled={saving}
|
||||||
|
/>
|
||||||
|
</Box>
|
||||||
|
)}
|
||||||
<Box>
|
<Box>
|
||||||
<Typography variant="body2" color="text.secondary">
|
<Typography variant="body2" color="text.secondary">
|
||||||
Duration: {duration} minutes
|
Duration: {duration} minutes
|
||||||
@@ -459,6 +701,7 @@ export default function RuleEditor({ open, rule, onSave, onClose, sensors = [],
|
|||||||
disabled={saving}
|
disabled={saving}
|
||||||
/>
|
/>
|
||||||
</Box>
|
</Box>
|
||||||
|
</Box>
|
||||||
)}
|
)}
|
||||||
</Box>
|
</Box>
|
||||||
</Box>
|
</Box>
|
||||||
@@ -489,6 +732,6 @@ export default function RuleEditor({ open, rule, onSave, onClose, sensors = [],
|
|||||||
)}
|
)}
|
||||||
</Button>
|
</Button>
|
||||||
</DialogActions>
|
</DialogActions>
|
||||||
</Dialog>
|
</Dialog >
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -6,12 +6,26 @@ import {
|
|||||||
Paper,
|
Paper,
|
||||||
Divider,
|
Divider,
|
||||||
Alert,
|
Alert,
|
||||||
CircularProgress
|
CircularProgress,
|
||||||
|
Chip,
|
||||||
|
IconButton
|
||||||
} from '@mui/material';
|
} from '@mui/material';
|
||||||
import RuleCard from './RuleCard';
|
import RuleCard from './RuleCard';
|
||||||
import RuleEditor from './RuleEditor';
|
import RuleEditor from './RuleEditor';
|
||||||
import { useAuth } from './AuthContext';
|
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() {
|
export default function RuleManager() {
|
||||||
const { isAdmin } = useAuth();
|
const { isAdmin } = useAuth();
|
||||||
const [rules, setRules] = useState([]);
|
const [rules, setRules] = useState([]);
|
||||||
@@ -21,6 +35,7 @@ export default function RuleManager() {
|
|||||||
const [editingRule, setEditingRule] = useState(null);
|
const [editingRule, setEditingRule] = useState(null);
|
||||||
const [devices, setDevices] = useState([]);
|
const [devices, setDevices] = useState([]);
|
||||||
const [saving, setSaving] = useState(false);
|
const [saving, setSaving] = useState(false);
|
||||||
|
const [filterTag, setFilterTag] = useState(null); // null = show all
|
||||||
|
|
||||||
// Get auth token from localStorage
|
// Get auth token from localStorage
|
||||||
const getAuthHeaders = useCallback(() => {
|
const getAuthHeaders = useCallback(() => {
|
||||||
@@ -73,7 +88,7 @@ export default function RuleManager() {
|
|||||||
const seenDevices = new Set();
|
const seenDevices = new Set();
|
||||||
|
|
||||||
devices.forEach(d => {
|
devices.forEach(d => {
|
||||||
// Add environment sensors once per device
|
// Add environment sensors once per device (temp, humidity)
|
||||||
if (!seenDevices.has(d.dev_name)) {
|
if (!seenDevices.has(d.dev_name)) {
|
||||||
seenDevices.add(d.dev_name);
|
seenDevices.add(d.dev_name);
|
||||||
availableSensors.push({
|
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({
|
availableSensors.push({
|
||||||
id: `${d.dev_name}:${d.port}:level`,
|
id: `${d.dev_name}:${d.port}:level`,
|
||||||
label: `${d.dev_name} - ${d.port_name} 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 = [
|
const availableOutputs = [
|
||||||
|
// Tapo smart plugs
|
||||||
{ id: 'tapo-001', label: 'Tapo 001', type: 'plug' },
|
{ id: 'tapo-001', label: 'Tapo 001', type: 'plug' },
|
||||||
{ id: 'tapo-002', label: 'Tapo 002', type: 'plug' },
|
{ id: 'tapo-002', label: 'Tapo 002', type: 'plug' },
|
||||||
{ id: 'tapo-003', label: 'Tapo 003', type: 'plug' },
|
{ id: 'tapo-003', label: 'Tapo 003', type: 'plug' },
|
||||||
{ id: 'tapo-004', label: 'Tapo 004', type: 'plug' },
|
{ id: 'tapo-004', label: 'Tapo 004', type: 'plug' },
|
||||||
{ id: 'tapo-005', label: 'Tapo 005', type: 'plug' },
|
{ id: 'tapo-005', label: 'Tapo 005', type: 'plug' },
|
||||||
...devices
|
// All device ports as outputs
|
||||||
.filter(d => d.port_name === 'Fan')
|
...devices.map(d => ({
|
||||||
.map(d => ({
|
id: `${d.dev_name}:${d.port}:out`,
|
||||||
id: `${d.dev_name}:fan:${d.port}`,
|
label: `${d.dev_name} - ${d.port_name}`,
|
||||||
label: `${d.dev_name} - Fan`,
|
type: d.port_name.toLowerCase()
|
||||||
type: 'fan'
|
|
||||||
})),
|
})),
|
||||||
...devices
|
// 4 virtual channels
|
||||||
.filter(d => d.port_name === 'Light')
|
{ id: 'virtual-1', label: 'Virtual Channel 1', type: 'virtual' },
|
||||||
.map(d => ({
|
{ id: 'virtual-2', label: 'Virtual Channel 2', type: 'virtual' },
|
||||||
id: `${d.dev_name}:light:${d.port}`,
|
{ id: 'virtual-3', label: 'Virtual Channel 3', type: 'virtual' },
|
||||||
label: `${d.dev_name} - Light`,
|
{ id: 'virtual-4', label: 'Virtual Channel 4', type: 'virtual' }
|
||||||
type: 'light'
|
|
||||||
}))
|
|
||||||
];
|
];
|
||||||
|
|
||||||
|
// 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 = () => {
|
const handleAddRule = () => {
|
||||||
setEditingRule(null);
|
setEditingRule(null);
|
||||||
setEditorOpen(true);
|
setEditorOpen(true);
|
||||||
@@ -172,11 +194,11 @@ export default function RuleManager() {
|
|||||||
setSaving(true);
|
setSaving(true);
|
||||||
try {
|
try {
|
||||||
if (editingRule) {
|
if (editingRule) {
|
||||||
// Update existing rule
|
// Update existing rule - preserve enabled state
|
||||||
const res = await fetch(`api/rules/${editingRule.id}`, {
|
const res = await fetch(`api/rules/${editingRule.id}`, {
|
||||||
method: 'PUT',
|
method: 'PUT',
|
||||||
headers: getAuthHeaders(),
|
headers: getAuthHeaders(),
|
||||||
body: JSON.stringify(ruleData)
|
body: JSON.stringify({ ...ruleData, enabled: editingRule.enabled })
|
||||||
});
|
});
|
||||||
if (!res.ok) throw new Error('Failed to update rule');
|
if (!res.ok) throw new Error('Failed to update rule');
|
||||||
const updated = await res.json();
|
const updated = await res.json();
|
||||||
@@ -206,6 +228,36 @@ export default function RuleManager() {
|
|||||||
setEditingRule(null);
|
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) {
|
if (loading) {
|
||||||
return (
|
return (
|
||||||
<Paper sx={{ mt: 4, p: 3, textAlign: 'center' }}>
|
<Paper sx={{ mt: 4, p: 3, textAlign: 'center' }}>
|
||||||
@@ -250,7 +302,34 @@ export default function RuleManager() {
|
|||||||
)}
|
)}
|
||||||
</Box>
|
</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 && (
|
{error && (
|
||||||
<Alert severity="error" sx={{ mb: 2 }} onClose={() => setError(null)}>
|
<Alert severity="error" sx={{ mb: 2 }} onClose={() => setError(null)}>
|
||||||
@@ -258,21 +337,27 @@ export default function RuleManager() {
|
|||||||
</Alert>
|
</Alert>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{rules.length === 0 ? (
|
{filteredRules.length === 0 ? (
|
||||||
<Box sx={{ textAlign: 'center', py: 4 }}>
|
<Box sx={{ textAlign: 'center', py: 4 }}>
|
||||||
<Typography color="text.secondary">
|
<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>
|
</Typography>
|
||||||
</Box>
|
</Box>
|
||||||
) : (
|
) : (
|
||||||
<Box sx={{ display: 'flex', flexDirection: 'column', gap: 2 }}>
|
<Box sx={{ display: 'flex', flexDirection: 'column', gap: 2 }}>
|
||||||
{rules.map(rule => (
|
{filteredRules.map((rule, idx) => (
|
||||||
<RuleCard
|
<RuleCard
|
||||||
key={rule.id}
|
key={rule.id}
|
||||||
rule={rule}
|
rule={rule}
|
||||||
onEdit={isAdmin ? () => handleEditRule(rule) : null}
|
onEdit={isAdmin ? () => handleEditRule(rule) : null}
|
||||||
onDelete={isAdmin ? () => handleDeleteRule(rule.id) : null}
|
onDelete={isAdmin ? () => handleDeleteRule(rule.id) : null}
|
||||||
onToggle={isAdmin ? () => handleToggleRule(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}
|
readOnly={!isAdmin}
|
||||||
/>
|
/>
|
||||||
))}
|
))}
|
||||||
@@ -287,6 +372,7 @@ export default function RuleManager() {
|
|||||||
onClose={handleCloseEditor}
|
onClose={handleCloseEditor}
|
||||||
sensors={availableSensors}
|
sensors={availableSensors}
|
||||||
outputs={availableOutputs}
|
outputs={availableOutputs}
|
||||||
|
colorTags={COLOR_TAGS}
|
||||||
saving={saving}
|
saving={saving}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
|
|||||||
Reference in New Issue
Block a user