From eab4241e6e41ee0f843a31373868f843986b6856 Mon Sep 17 00:00:00 2001 From: sebseb7 Date: Sun, 21 Dec 2025 03:36:29 +0100 Subject: [PATCH] tags --- server.js | 67 ++++++- src/client/RuleCard.js | 80 +++++++- src/client/RuleEditor.js | 389 +++++++++++++++++++++++++++++++------- src/client/RuleManager.js | 134 ++++++++++--- 4 files changed, 558 insertions(+), 112 deletions(-) diff --git a/server.js b/server.js index 734b5fc..cb10653 100644 --- a/server.js +++ b/server.js @@ -74,11 +74,21 @@ db.exec(` trigger_data TEXT NOT NULL, action_type TEXT NOT NULL, action_data TEXT NOT NULL, + sort_order INTEGER DEFAULT 0, + color_tag TEXT DEFAULT NULL, created_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(` INSERT INTO readings (dev_id, dev_name, port, port_name, temp_c, humidity, vpd, fan_speed, on_speed, off_speed) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?) @@ -87,17 +97,19 @@ const insertStmt = db.prepare(` const getUserByUsername = db.prepare('SELECT * FROM users WHERE username = ?'); // 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 insertRule = db.prepare(` - INSERT INTO rules (name, enabled, trigger_type, trigger_data, action_type, action_data) - VALUES (?, ?, ?, ?, ?, ?) + INSERT INTO rules (name, enabled, trigger_type, trigger_data, action_type, action_data, sort_order, color_tag) + VALUES (?, ?, ?, ?, ?, ?, ?, ?) `); 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 = ? `); +const updateRuleOrder = db.prepare('UPDATE rules SET sort_order = ? 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 --- let token = null; @@ -342,10 +354,23 @@ function formatRule(row) { 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 { id: row.id, name: row.name, enabled: row.enabled === 1, + sortOrder: row.sort_order || 0, + colorTags, trigger, action }; @@ -364,7 +389,7 @@ app.get('/api/rules', (req, res) => { // POST /api/rules - admin only app.post('/api/rules', requireAuth, requireAdmin, (req, res) => { try { - const { name, enabled, trigger, action } = req.body; + const { name, enabled, trigger, action, colorTags } = req.body; if (!name || !trigger || !action) { 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 = 'sensor'; + // Get next sort order + const maxOrder = getMaxSortOrder.get().max_order; + const sortOrder = maxOrder + 1; + const triggerData = JSON.stringify(trigger); 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); 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' }); } - const { name, enabled, trigger, action } = req.body; + const { name, enabled, trigger, action, colorTags } = req.body; // Determine trigger type for storage let triggerType = 'combined'; @@ -403,7 +433,8 @@ app.put('/api/rules/:id', requireAuth, requireAdmin, (req, res) => { const triggerData = JSON.stringify(trigger); 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); 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 app.delete('/api/rules/:id', requireAuth, requireAdmin, (req, res) => { try { diff --git a/src/client/RuleCard.js b/src/client/RuleCard.js index 647cfb0..d686836 100644 --- a/src/client/RuleCard.js +++ b/src/client/RuleCard.js @@ -19,7 +19,25 @@ const dayOrder = ['mon', 'tue', 'wed', 'thu', 'fri', 'sat', 'sun']; function TriggerSummary({ trigger }) { 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( + + + + At {time} ({dayText}) + + + ); + } + + // Time range (active during window) if (trigger.timeRange) { const { start, end, days } = trigger.timeRange; const isEveryDay = days?.length === 7; @@ -95,26 +113,29 @@ function TriggerSummary({ trigger }) { function ActionSummary({ action }) { if (action.type === 'toggle') { + // Check if it's a level or binary action + const hasLevel = action.level !== undefined; return ( - → {action.targetLabel || action.target} {action.state ? 'ON' : 'OFF'} + → {action.targetLabel || action.target} {hasLevel ? `Level ${action.level}` : (action.state ? 'ON' : 'OFF')} ); } if (action.type === 'keepOn') { + const hasLevel = action.level !== undefined; return ( - → {action.targetLabel || action.target} ON for {action.duration}m + → {action.targetLabel || action.target} {hasLevel ? `Level ${action.level}` : 'ON'} for {action.duration}m ); @@ -123,7 +144,11 @@ function ActionSummary({ action }) { 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 ( 0 ? `4px solid ${ruleTags[0].color}` : '1px solid #504945', '&:hover': { transform: readOnly ? 'none' : 'translateX(4px)', borderColor: rule.enabled ? '#8ec07c' : '#504945' @@ -144,6 +170,19 @@ export default function RuleCard({ rule, onEdit, onDelete, onToggle, readOnly }) {rule.name} + {ruleTags.length > 0 && ( + + {ruleTags.map(tag => ( + + ))} + + )} {!rule.enabled && ( )} @@ -156,7 +195,34 @@ export default function RuleCard({ rule, onEdit, onDelete, onToggle, readOnly }) {!readOnly && ( - + + {/* Move buttons */} + + + + + ▲ + + + + + + + ▼ + + + + diff --git a/src/client/RuleEditor.js b/src/client/RuleEditor.js index 522054a..b96dda2 100644 --- a/src/client/RuleEditor.js +++ b/src/client/RuleEditor.js @@ -44,14 +44,29 @@ const OPERATORS = [ // Single sensor condition component 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 ( - + Sensor - - - - onChange({ ...condition, value: Number(e.target.value) })} - sx={{ width: 80 }} - disabled={disabled} - /> + + {isStateSensor ? ( + // State sensor: is on / is off + + + + ) : ( + // Value sensor: numeric comparison + <> + + + + onChange({ ...condition, value: Number(e.target.value) })} + sx={{ width: 80 }} + disabled={disabled} + /> + + )} + {onRemove && ( ❌ @@ -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 [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 [timeStart, setTimeStart] = useState('08: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 const [useSensors, setUseSensors] = useState(false); @@ -105,20 +145,37 @@ export default function RuleEditor({ open, rule, onSave, onClose, sensors = [], const [actionType, setActionType] = useState('toggle'); const [target, setTarget] = useState(''); const [toggleState, setToggleState] = useState(true); + const [outputLevel, setOutputLevel] = useState(5); // 1-10 for port outputs 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 useEffect(() => { if (rule) { 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 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); if (trigger.timeRange) { setTimeStart(trigger.timeRange.start || '08:00'); setTimeEnd(trigger.timeRange.end || '18:00'); - setDays(trigger.timeRange.days || []); + setTimeRangeDays(trigger.timeRange.days || []); } setUseSensors(!!trigger.sensors && trigger.sensors.length > 0); @@ -132,22 +189,28 @@ export default function RuleEditor({ open, rule, onSave, onClose, sensors = [], setTarget(rule.action?.target || ''); if (rule.action?.type === 'toggle') { setToggleState(rule.action?.state ?? true); + setOutputLevel(rule.action?.level ?? 5); } else { setDuration(rule.action?.duration || 15); } } else { // Reset to defaults setName(''); - setUseTimeRange(true); + setSelectedTags([]); + setUseScheduledTime(true); + setScheduledTime('08:00'); + setScheduledDays(['mon', 'tue', 'wed', 'thu', 'fri']); + setUseTimeRange(false); setTimeStart('08:00'); setTimeEnd('18:00'); - setDays(['mon', 'tue', 'wed', 'thu', 'fri']); + setTimeRangeDays(['mon', 'tue', 'wed', 'thu', 'fri']); setUseSensors(false); setSensorConditions([{ sensor: sensors[0]?.id || '', operator: '>', value: 25 }]); setSensorLogic('and'); setActionType('toggle'); setTarget(outputs[0]?.id || ''); setToggleState(true); + setOutputLevel(5); setDuration(15); } }, [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); }, [sensors, outputs, sensorConditions, target]); - const handleDaysChange = (event, newDays) => { - if (newDays.length > 0) setDays(newDays); + const handleScheduledDaysChange = (event, newDays) => { + if (newDays.length > 0) setScheduledDays(newDays); + }; + + const handleTimeRangeDaysChange = (event, newDays) => { + if (newDays.length > 0) setTimeRangeDays(newDays); }; const addSensorCondition = () => { @@ -185,8 +252,11 @@ export default function RuleEditor({ open, rule, onSave, onClose, sensors = [], // Build trigger object const trigger = {}; + if (useScheduledTime) { + trigger.scheduledTime = { time: scheduledTime, days: scheduledDays }; + } if (useTimeRange) { - trigger.timeRange = { start: timeStart, end: timeEnd, days }; + trigger.timeRange = { start: timeStart, end: timeEnd, days: timeRangeDays }; } if (useSensors && sensorConditions.length > 0) { trigger.sensors = sensorConditions.map(c => ({ @@ -196,19 +266,35 @@ export default function RuleEditor({ open, rule, onSave, onClose, sensors = [], trigger.sensorLogic = sensorLogic; } - const ruleData = { - name, - trigger, - action: actionType === 'toggle' - ? { type: 'toggle', target, targetLabel: selectedOutput?.label, state: toggleState } - : { type: 'keepOn', target, targetLabel: selectedOutput?.label, duration } - }; + const isBinaryTarget = selectedOutput?.type === 'plug' || selectedOutput?.type === 'virtual'; + + // Build action object based on output type + let action; + if (actionType === 'toggle') { + 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); }; const isValid = name.trim().length > 0 && - (useTimeRange || useSensors) && - (!useTimeRange || days.length > 0) && + (useScheduledTime || useTimeRange || useSensors) && + (!useScheduledTime || scheduledDays.length > 0) && + (!useTimeRange || timeRangeDays.length > 0) && (!useSensors || sensorConditions.every(c => c.sensor)) && target; @@ -241,14 +327,119 @@ export default function RuleEditor({ open, rule, onSave, onClose, sensors = [], disabled={saving} /> + {/* Color Tag Picker (multi-select) */} + + Tags: + 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 } + }} + > + ✕ + + {availableColorTags.map(tag => ( + { + 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) && } + + ))} + + {/* TRIGGERS SECTION */} - TRIGGERS (When to activate - conditions are combined with AND) + TRIGGERS (When to activate) - {/* Time Range Trigger */} + {/* Scheduled Time Trigger (fires at exact time) */} + + setUseScheduledTime(e.target.checked)} + disabled={saving} + /> + } + label={🕐 Scheduled Time (trigger at exact time)} + /> + + {useScheduledTime && ( + + setScheduledTime(e.target.value)} + InputLabelProps={{ shrink: true }} + size="small" + sx={{ width: 150 }} + disabled={saving} + /> + + + Days + + + {DAYS.map(day => ( + + {day.label} + + ))} + + + + )} + + + {/* Time Range Trigger (active within window) */} } - label={⏰ Time Range} + label={⏰ Time Range (active during window)} /> {useTimeRange && ( @@ -289,8 +480,8 @@ export default function RuleEditor({ open, rule, onSave, onClose, sensors = [], Days @@ -425,39 +616,91 @@ export default function RuleEditor({ open, rule, onSave, onClose, sensors = [], + {/* Toggle action controls */} {actionType === 'toggle' && ( - setToggleState(e.target.checked)} - color="primary" - disabled={saving} + + {isBinaryOutput ? ( + // Binary output: On/Off switch + setToggleState(e.target.checked)} + color="primary" + disabled={saving} + /> + } + label={toggleState ? 'Turn ON' : 'Turn OFF'} /> - } - label={toggleState ? 'Turn ON' : 'Turn OFF'} - /> + ) : ( + // Level output: 1-10 slider + + + Set Level: {outputLevel} + + 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} + /> + + )} + )} + {/* Keep On action controls */} {actionType === 'keepOn' && ( - - - Duration: {duration} minutes - - setDuration(val)} - min={1} - max={120} - marks={[ - { value: 1, label: '1m' }, - { value: 30, label: '30m' }, - { value: 60, label: '1h' }, - { value: 120, label: '2h' } - ]} - valueLabelDisplay="auto" - disabled={saving} - /> + + {!isBinaryOutput && ( + // Level for port outputs + + + Set Level: {outputLevel} + + 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} + /> + + )} + + + Duration: {duration} minutes + + setDuration(val)} + min={1} + max={120} + marks={[ + { value: 1, label: '1m' }, + { value: 30, label: '30m' }, + { value: 60, label: '1h' }, + { value: 120, label: '2h' } + ]} + valueLabelDisplay="auto" + disabled={saving} + /> + )} @@ -489,6 +732,6 @@ export default function RuleEditor({ open, rule, onSave, onClose, sensors = [], )} - + ); } diff --git a/src/client/RuleManager.js b/src/client/RuleManager.js index da0190f..fe08a38 100644 --- a/src/client/RuleManager.js +++ b/src/client/RuleManager.js @@ -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 ( @@ -250,7 +302,34 @@ export default function RuleManager() { )} - + + + {/* 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)}> @@ -258,21 +337,27 @@ export default function RuleManager() { )} - {rules.length === 0 ? ( + {filteredRules.length === 0 ? ( - 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.' + } ) : ( - {rules.map(rule => ( + {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} /> ))} @@ -287,6 +372,7 @@ export default function RuleManager() { onClose={handleCloseEditor} sensors={availableSensors} outputs={availableOutputs} + colorTags={COLOR_TAGS} saving={saving} /> )}