This commit is contained in:
sebseb7
2025-12-21 04:05:54 +01:00
parent 5febdf29c8
commit 739b6fe54f
10 changed files with 1419 additions and 8 deletions

135
src/client/AlarmCard.js Normal file
View File

@@ -0,0 +1,135 @@
import React from 'react';
import {
Card,
CardContent,
Typography,
Box,
Switch,
IconButton,
Chip,
Tooltip
} from '@mui/material';
export default function AlarmCard({ alarm, onEdit, onDelete, onToggle, onMoveUp, onMoveDown, colorTags, readOnly }) {
// Parse trigger/action data to display summary
const trigger = alarm.trigger || {};
const action = alarm.action || {};
// Get color for tag
const getTagColor = (tagId) => {
const tag = colorTags.find(t => t.id === tagId);
return tag ? tag.color : 'transparent';
};
const hasTags = alarm.colorTags && alarm.colorTags.length > 0;
return (
<Card
sx={{
background: 'linear-gradient(145deg, #3c3836 0%, #32302f 100%)',
color: '#ebdbb2',
border: '1px solid #504945',
position: 'relative',
transition: 'transform 0.2s',
'&:hover': {
transform: readOnly ? 'none' : 'translateY(-2px)',
boxShadow: '0 4px 8px rgba(0,0,0,0.3)'
}
}}
>
<CardContent sx={{ pb: '16px !important', display: 'flex', alignItems: 'center', gap: 2 }}>
{/* Drag Handle / Sort indicators */}
{!readOnly && (onMoveUp || onMoveDown) && (
<Box sx={{ display: 'flex', flexDirection: 'column' }}>
<IconButton size="small" onClick={onMoveUp} disabled={!onMoveUp} sx={{ p: 0.5, color: '#a89984' }}>
</IconButton>
<IconButton size="small" onClick={onMoveDown} disabled={!onMoveDown} sx={{ p: 0.5, color: '#a89984' }}>
</IconButton>
</Box>
)}
{/* Enabled Switch */}
<Switch
checked={!!alarm.enabled}
onChange={onToggle}
disabled={readOnly}
color="success"
/>
<Box sx={{ flexGrow: 1 }}>
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1, mb: 0.5 }}>
<Typography variant="h6" sx={{ fontWeight: 600 }}>
{alarm.name}
</Typography>
{/* Tags */}
{hasTags && (
<Box sx={{ display: 'flex', gap: 0.5 }}>
{alarm.colorTags.map(tagId => (
<Box
key={tagId}
sx={{
width: 12,
height: 12,
borderRadius: '50%',
bgcolor: getTagColor(tagId),
border: '1px solid #282828'
}}
title={tagId}
/>
))}
</Box>
)}
{action.severity && (
<Chip
label={action.severity.toUpperCase()}
size="small"
sx={{
height: 20,
fontSize: '0.65rem',
fontWeight: 'bold',
bgcolor: action.severity === 'critical' ? '#fb4934' :
action.severity === 'warning' ? '#fe8019' : '#83a598',
color: '#282828'
}}
/>
)}
</Box>
<Typography variant="body2" color="text.secondary">
{/* Trigger Summary */}
{trigger.scheduledTime ? (
<span> {trigger.scheduledTime.time} ({trigger.scheduledTime.days.map(d => d.slice(0, 3)).join(',')})</span>
) : trigger.timeRange ? (
<span> {trigger.timeRange.start}-{trigger.timeRange.end}</span>
) : trigger.sensors ? (
<span>
📊 {trigger.sensors.map(s => `${s.sensorLabel || s.sensor} ${s.operator} ${s.value}`).join(trigger.sensorLogic === 'or' ? ' OR ' : ' AND ')}
</span>
) : (
<span>Unknown Trigger</span>
)}
<span style={{ margin: '0 8px', opacity: 0.5 }}></span>
<span>🔔 Telegram: "{action.message || 'Alert'}"</span>
</Typography>
</Box>
{/* Actions */}
{!readOnly && (
<Box sx={{ display: 'flex', gap: 1 }}>
<IconButton onClick={onEdit} size="small" sx={{ color: '#8ec07c' }}>
</IconButton>
<IconButton onClick={onDelete} size="small" sx={{ color: '#fb4934' }}>
🗑
</IconButton>
</Box>
)}
</CardContent>
</Card>
);
}

372
src/client/AlarmEditor.js Normal file
View File

@@ -0,0 +1,372 @@
import React, { useState, useEffect } from 'react';
import {
Dialog,
DialogTitle,
DialogContent,
DialogActions,
TextField,
Button,
Box,
FormControl,
InputLabel,
Select,
MenuItem,
ToggleButton,
ToggleButtonGroup,
Typography,
Divider,
Switch,
FormControlLabel,
CircularProgress,
IconButton,
Paper,
Chip,
Alert
} from '@mui/material';
import { useI18n } from './I18nContext';
// Reusing some constants/components from RuleEditor logic if possible, but duplicating for isolation as per plan
const DAYS_KEYS = ['mon', 'tue', 'wed', 'thu', 'fri', 'sat', 'sun'];
const OPERATORS = [
{ value: '>', label: '>' },
{ value: '<', label: '<' },
{ value: '>=', label: '≥' },
{ value: '<=', label: '≤' },
{ value: '==', label: '=' }
];
function SensorCondition({ condition, sensors, onChange, onRemove, disabled }) {
return (
<Paper sx={{ p: 1.5, display: 'flex', alignItems: 'center', gap: 1, bgcolor: 'background.default' }}>
<FormControl size="small" sx={{ minWidth: 180 }}>
<InputLabel>Sensor</InputLabel>
<Select
value={condition.sensor || ''}
label="Sensor"
onChange={(e) => {
const newSensor = sensors.find(s => s.id === e.target.value);
onChange({ ...condition, sensor: e.target.value, sensorLabel: newSensor?.label });
}}
disabled={disabled}
>
{sensors.map(s => (
<MenuItem key={s.id} value={s.id}>{s.label}</MenuItem>
))}
</Select>
</FormControl>
<FormControl size="small" sx={{ minWidth: 70 }}>
<Select
value={condition.operator || '>'}
onChange={(e) => onChange({ ...condition, operator: e.target.value })}
disabled={disabled}
>
{OPERATORS.map(op => (
<MenuItem key={op.value} value={op.value}>{op.label}</MenuItem>
))}
</Select>
</FormControl>
<TextField
size="small"
type="number"
value={condition.value ?? ''}
onChange={(e) => onChange({ ...condition, value: Number(e.target.value) })}
sx={{ width: 80 }}
disabled={disabled}
/>
{onRemove && (
<IconButton size="small" onClick={onRemove} disabled={disabled}>
</IconButton>
)}
</Paper>
);
}
export default function AlarmEditor({ open, alarm, onSave, onClose, sensors = [], colorTags: availableColorTags = [], saving }) {
const { t } = useI18n();
const [name, setName] = useState('');
const [selectedTags, setSelectedTags] = useState([]);
// Scheduled time (not commonly used for alarms, but keeping parity with rules engine if needed)
// Actually, alarms are usually condition-based (Value > X). Time-based alarms remind you to do something?
// Let's keep it simple: SENSORS ONLY for now described in plan ("triggers (based on sensors, time, etc.)")
// I'll keep the UI structure but maybe default to Sensors.
// Simplification: Alarms usually monitor state.
// "Time Range" is valid (only alarm between 8am-8pm).
// "Scheduled Time" (Alarm at 8am) is basically a Reminder.
// I will include: Time Range (Active Window) and Sensor Conditions.
// I'll omit "Scheduled Time" as a trigger for now unless requested, to reduce complexity,
// as "Alarm at 8am" is just an event. The user asked for "similar to alarms".
const [useTimeRange, setUseTimeRange] = useState(false);
const [timeStart, setTimeStart] = useState('08:00');
const [timeEnd, setTimeEnd] = useState('18:00');
const [timeRangeDays, setTimeRangeDays] = useState(['mon', 'tue', 'wed', 'thu', 'fri']);
const [sensorConditions, setSensorConditions] = useState([{ sensor: '', operator: '>', value: 25 }]);
const [sensorLogic, setSensorLogic] = useState('and');
// Action State (Telegram)
const [message, setMessage] = useState('');
const [severity, setSeverity] = useState('warning');
useEffect(() => {
if (alarm) {
setName(alarm.name);
const tags = alarm.colorTags || (alarm.colorTag ? [alarm.colorTag] : []);
setSelectedTags(Array.isArray(tags) ? tags : []);
const trigger = alarm.trigger || {};
setUseTimeRange(!!trigger.timeRange);
if (trigger.timeRange) {
setTimeStart(trigger.timeRange.start || '08:00');
setTimeEnd(trigger.timeRange.end || '18:00');
setTimeRangeDays(trigger.timeRange.days || []);
}
if (trigger.sensors && trigger.sensors.length > 0) {
setSensorConditions(trigger.sensors);
setSensorLogic(trigger.sensorLogic || 'and');
} else {
setSensorConditions([{ sensor: sensors[0]?.id || '', operator: '>', value: 25 }]);
}
const action = alarm.action || {};
setMessage(action.message || '');
setSeverity(action.severity || 'warning');
} else {
setName('');
setSelectedTags([]);
setUseTimeRange(false);
setTimeStart('08:00');
setTimeEnd('18:00');
setTimeRangeDays(['mon', 'tue', 'wed', 'thu', 'fri']);
setSensorConditions([{ sensor: sensors[0]?.id || '', operator: '>', value: 25 }]);
setSensorLogic('and');
setMessage('');
setSeverity('warning');
}
}, [alarm, open, sensors]);
// Default sensor init
useEffect(() => {
if (sensorConditions[0]?.sensor === '' && sensors.length > 0) {
setSensorConditions([{ ...sensorConditions[0], sensor: sensors[0].id }]);
}
}, [sensors, sensorConditions]);
const addSensorCondition = () => {
setSensorConditions([...sensorConditions, { sensor: sensors[0]?.id || '', operator: '>', value: 25 }]);
};
const updateSensorCondition = (index, newCondition) => {
const updated = [...sensorConditions];
updated[index] = newCondition;
setSensorConditions(updated);
};
const removeSensorCondition = (index) => {
if (sensorConditions.length > 1) {
setSensorConditions(sensorConditions.filter((_, i) => i !== index));
}
};
const handleSave = () => {
const trigger = {};
// Always require sensors for an Alarm (otherwise it's just a time-based notification, which is valid too)
// Let's assume user wants to monitor something.
if (useTimeRange) {
trigger.timeRange = { start: timeStart, end: timeEnd, days: timeRangeDays };
}
trigger.sensors = sensorConditions.map(c => ({
...c,
sensorLabel: sensors.find(s => s.id === c.sensor)?.label
}));
trigger.sensorLogic = sensorLogic;
const action = {
type: 'telegram',
message,
severity
};
onSave({ name, trigger, action, colorTags: selectedTags });
};
const isValid = name.trim().length > 0 &&
sensorConditions.every(c => c.sensor) &&
message.trim().length > 0;
return (
<Dialog
open={open}
onClose={onClose}
maxWidth="md"
fullWidth
PaperProps={{
sx: {
background: 'linear-gradient(145deg, #3c3836 0%, #282828 100%)',
border: '1px solid #504945'
}
}}
>
<DialogTitle>
{alarm ? 'Edit Alarm' : 'Create Alarm'}
</DialogTitle>
<DialogContent dividers>
<Box sx={{ display: 'flex', flexDirection: 'column', gap: 3, pt: 1 }}>
<TextField
label="Alarm Name"
value={name}
onChange={(e) => setName(e.target.value)}
fullWidth
disabled={saving}
/>
{/* Tags */}
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1, flexWrap: 'wrap' }}>
<Typography variant="body2" color="text.secondary">Tags:</Typography>
{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'
}}
>
{selectedTags.includes(tag.id) && <span style={{ fontSize: '0.7rem' }}></span>}
</Box>
))}
</Box>
{/* TRIGGER */}
<Typography variant="subtitle2" color="text.secondary">TRIGGER CONDITIONS</Typography>
<Divider />
{/* Time Window */}
<Paper sx={{ p: 2, bgcolor: useTimeRange ? 'action.selected' : 'background.default' }}>
<FormControlLabel
control={
<Switch checked={useTimeRange} onChange={(e) => setUseTimeRange(e.target.checked)} disabled={saving} />
}
label="Active Time Window (Optional)"
/>
{useTimeRange && (
<Box sx={{ mt: 2, display: 'flex', gap: 2, alignItems: 'center' }}>
<TextField
label="From" type="time"
value={timeStart} onChange={(e) => setTimeStart(e.target.value)}
InputLabelProps={{ shrink: true }} size="small"
/>
<Typography>to</Typography>
<TextField
label="Until" type="time"
value={timeEnd} onChange={(e) => setTimeEnd(e.target.value)}
InputLabelProps={{ shrink: true }} size="small"
/>
</Box>
)}
</Paper>
{/* Sensors */}
<Paper sx={{ p: 2, bgcolor: 'action.selected' }}>
<Typography gutterBottom fontWeight="bold">📊 Sensor Thresholds</Typography>
<Box sx={{ display: 'flex', flexDirection: 'column', gap: 2 }}>
{sensorConditions.length > 1 && (
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1 }}>
<Typography variant="body2">Logic:</Typography>
<ToggleButtonGroup
value={sensorLogic} exclusive
onChange={(e, v) => v && setSensorLogic(v)} size="small"
>
<ToggleButton value="and">AND</ToggleButton>
<ToggleButton value="or">OR</ToggleButton>
</ToggleButtonGroup>
</Box>
)}
{sensorConditions.map((cond, i) => (
<SensorCondition
key={i}
condition={cond}
sensors={sensors}
onChange={(newCond) => updateSensorCondition(i, newCond)}
onRemove={sensorConditions.length > 1 ? () => removeSensorCondition(i) : null}
disabled={saving}
/>
))}
<Button size="small" onClick={addSensorCondition} disabled={saving} sx={{ alignSelf: 'flex-start' }}>
+ Add Condition
</Button>
</Box>
</Paper>
{/* ACTION */}
<Typography variant="subtitle2" color="text.secondary">ACTION (Telelegram Notification)</Typography>
<Divider />
<FormControl fullWidth>
<InputLabel>Severity</InputLabel>
<Select
value={severity}
label="Severity"
onChange={(e) => setSeverity(e.target.value)}
>
<MenuItem value="info"> Info</MenuItem>
<MenuItem value="warning"> Warning</MenuItem>
<MenuItem value="critical">🔥 Critical</MenuItem>
</Select>
</FormControl>
<TextField
label="Notification Message"
value={message}
onChange={(e) => setMessage(e.target.value)}
fullWidth
multiline
rows={2}
placeholder="e.g. Temperature is too high!"
disabled={saving}
/>
<Alert severity="info" icon={false} sx={{ bgcolor: 'rgba(2, 136, 209, 0.1)' }}>
This message will be sent to all users who have linked their Telegram ID in their profile.
</Alert>
</Box>
</DialogContent>
<DialogActions sx={{ px: 3, py: 2 }}>
<Button onClick={onClose} color="inherit" disabled={saving}>Cancel</Button>
<Button
onClick={handleSave}
variant="contained"
disabled={!isValid || saving}
sx={{ background: 'linear-gradient(45deg, #8ec07c 30%, #b8bb26 90%)' }}
>
{saving ? <CircularProgress size={20} /> : (alarm ? 'Save Changes' : 'Create Alarm')}
</Button>
</DialogActions>
</Dialog>
);
}

295
src/client/AlarmManager.js Normal file
View File

@@ -0,0 +1,295 @@
import React, { useState, useEffect, useCallback } from 'react';
import {
Box,
Typography,
Button,
Paper,
Divider,
Alert,
CircularProgress,
Chip,
} from '@mui/material';
import AlarmCard from './AlarmCard';
import AlarmEditor from './AlarmEditor';
import { useAuth } from './AuthContext';
import { useI18n } from './I18nContext';
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 AlarmManager() {
const { isAdmin } = useAuth();
const { t } = useI18n();
const [alarms, setAlarms] = useState([]);
const [loading, setLoading] = useState(true);
const [error, setError] = useState(null);
const [editorOpen, setEditorOpen] = useState(false);
const [editingAlarm, setEditingAlarm] = useState(null);
const [devices, setDevices] = useState([]);
const [saving, setSaving] = useState(false);
const [filterTag, setFilterTag] = useState(null);
const getAuthHeaders = useCallback(() => {
const token = localStorage.getItem('authToken');
return {
'Content-Type': 'application/json',
'Authorization': `Bearer ${token}`
};
}, []);
const fetchAlarms = useCallback(async () => {
try {
const res = await fetch('api/alarms');
if (!res.ok) throw new Error('Failed to fetch alarms');
const data = await res.json();
setAlarms(data);
setError(null);
} catch (err) {
setError(err.message);
} finally {
setLoading(false);
}
}, []);
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(() => {
fetchAlarms();
fetchDevices();
}, [fetchAlarms, fetchDevices]);
// Build available sensors (same usage as RuleManager)
const availableSensors = [];
const seenDevices = new Set();
devices.forEach(d => {
if (!seenDevices.has(d.dev_name)) {
seenDevices.add(d.dev_name);
availableSensors.push({ id: `${d.dev_name}:temp`, label: `${d.dev_name} - Temperature`, type: 'temp' });
availableSensors.push({ id: `${d.dev_name}:humidity`, label: `${d.dev_name} - Humidity`, type: 'humidity' });
}
availableSensors.push({
id: `${d.dev_name}:${d.port}:level`,
label: `${d.dev_name} - ${d.port_name} Level`,
type: 'level'
});
});
const handleAddAlarm = () => {
setEditingAlarm(null);
setEditorOpen(true);
};
const handleEditAlarm = (alarm) => {
setEditingAlarm(alarm);
setEditorOpen(true);
};
const handleDeleteAlarm = async (id) => {
if (!confirm('Are you sure you want to delete this alarm?')) return;
setSaving(true);
try {
const res = await fetch(`api/alarms/${id}`, {
method: 'DELETE',
headers: getAuthHeaders()
});
if (!res.ok) throw new Error('Failed to delete alarm');
setAlarms(alarms.filter(a => a.id !== id));
} catch (err) {
setError(err.message);
} finally {
setSaving(false);
}
};
const handleToggleAlarm = async (id) => {
const alarm = alarms.find(a => a.id === id);
if (!alarm) return;
setSaving(true);
try {
const res = await fetch(`api/alarms/${id}`, {
method: 'PUT',
headers: getAuthHeaders(),
body: JSON.stringify({ ...alarm, enabled: !alarm.enabled })
});
if (!res.ok) throw new Error('Failed to update alarm');
const updated = await res.json();
setAlarms(alarms.map(a => a.id === id ? updated : a));
} catch (err) {
setError(err.message);
} finally {
setSaving(false);
}
};
const handleSaveAlarm = async (alarmData) => {
setSaving(true);
try {
if (editingAlarm) {
const res = await fetch(`api/alarms/${editingAlarm.id}`, {
method: 'PUT',
headers: getAuthHeaders(),
body: JSON.stringify({ ...alarmData, enabled: editingAlarm.enabled })
});
if (!res.ok) throw new Error('Failed to update alarm');
const updated = await res.json();
setAlarms(alarms.map(a => a.id === editingAlarm.id ? updated : a));
} else {
const res = await fetch('api/alarms', {
method: 'POST',
headers: getAuthHeaders(),
body: JSON.stringify({ ...alarmData, enabled: true })
});
if (!res.ok) throw new Error('Failed to create alarm');
const newAlarm = await res.json();
setAlarms([...alarms, newAlarm]);
}
setEditorOpen(false);
setEditingAlarm(null);
} catch (err) {
setError(err.message);
} finally {
setSaving(false);
}
};
const handleMoveAlarm = async (id, direction) => {
const idx = alarms.findIndex(a => a.id === id);
if (idx === -1) return;
if (direction === 'up' && idx === 0) return;
if (direction === 'down' && idx === alarms.length - 1) return;
const newAlarms = [...alarms];
const swapIdx = direction === 'up' ? idx - 1 : idx + 1;
[newAlarms[idx], newAlarms[swapIdx]] = [newAlarms[swapIdx], newAlarms[idx]];
setAlarms(newAlarms);
try {
await fetch('api/alarms/reorder', {
method: 'PUT',
headers: getAuthHeaders(),
body: JSON.stringify({ alarmIds: newAlarms.map(a => a.id) })
});
} catch (err) {
setError('Failed to save order');
}
};
const filteredAlarms = filterTag
? alarms.filter(a => (a.colorTags || []).includes(filterTag))
: alarms;
if (loading) {
return <Paper sx={{ mt: 4, p: 3, textAlign: 'center' }}><CircularProgress /></Paper>;
}
return (
<Paper
sx={{
mt: 4, ml: 4, mb: 4,
p: 3,
background: 'linear-gradient(145deg, #3c3836 0%, #32302f 100%)', // Distinct background? Or same?
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('alarms.title') || 'Alarms'}
</Typography>
<Typography variant="body2" color="text.secondary">
{isAdmin ? 'Manage system alarms and notifications.' : 'View active system alarms.'}
</Typography>
</Box>
{isAdmin && (
<Button
variant="contained"
onClick={handleAddAlarm}
disabled={saving}
sx={{
background: 'linear-gradient(45deg, #d3869b 30%, #fe8019 90%)',
'&:hover': { background: 'linear-gradient(45deg, #e396a5 30%, #ff9029 90%)' }
}}
>
Add Alarm
</Button>
)}
</Box>
<Divider sx={{ mb: 2 }} />
<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>}
{filteredAlarms.length === 0 ? (
<Box sx={{ textAlign: 'center', py: 4 }}>
<Typography color="text.secondary">No alarms found.</Typography>
</Box>
) : (
<Box sx={{ display: 'flex', flexDirection: 'column', gap: 2 }}>
{filteredAlarms.map((alarm, idx) => (
<AlarmCard
key={alarm.id}
alarm={alarm}
onEdit={isAdmin ? () => handleEditAlarm(alarm) : null}
onDelete={isAdmin ? () => handleDeleteAlarm(alarm.id) : null}
onToggle={isAdmin ? () => handleToggleAlarm(alarm.id) : null}
onMoveUp={isAdmin && idx > 0 ? () => handleMoveAlarm(alarm.id, 'up') : null}
onMoveDown={isAdmin && idx < filteredAlarms.length - 1 ? () => handleMoveAlarm(alarm.id, 'down') : null}
colorTags={COLOR_TAGS}
readOnly={!isAdmin}
/>
))}
</Box>
)}
{isAdmin && (
<AlarmEditor
open={editorOpen}
alarm={editingAlarm}
onSave={handleSaveAlarm}
onClose={() => { setEditorOpen(false); setEditingAlarm(null); }}
sensors={availableSensors}
colorTags={COLOR_TAGS}
saving={saving}
/>
)}
</Paper>
);
}

View File

@@ -2,7 +2,9 @@ import React, { useState } from 'react';
import { ThemeProvider, createTheme, CssBaseline, AppBar, Toolbar, Typography, Container, Box, Button, Chip } from '@mui/material';
import Dashboard from './Dashboard';
import RuleManager from './RuleManager';
import AlarmManager from './AlarmManager';
import LoginDialog from './LoginDialog';
import ProfileDialog from './ProfileDialog';
import { AuthProvider, useAuth } from './AuthContext';
import { I18nProvider, useI18n } from './I18nContext';
import LanguageSwitcher from './LanguageSwitcher';
@@ -48,6 +50,7 @@ function AppContent() {
const { user, loading, login, logout, isAuthenticated, isAdmin } = useAuth();
const { t } = useI18n();
const [showLogin, setShowLogin] = useState(false);
const [showProfile, setShowProfile] = useState(false);
return (
<Box sx={{ flexGrow: 1 }}>
@@ -62,10 +65,13 @@ function AppContent() {
<>
<Chip
label={user.username}
onClick={() => setShowProfile(true)}
color={isAdmin ? 'secondary' : 'default'}
size="small"
sx={{
fontWeight: 600,
cursor: 'pointer',
'&:hover': { opacity: 0.8 },
...(isAdmin && {
background: 'linear-gradient(45deg, #d3869b 30%, #fe8019 90%)'
})
@@ -110,6 +116,9 @@ function AppContent() {
{/* Rule Manager visible to everyone (guests read-only, admins can edit) */}
<RuleManager />
{/* Alarm Manager visible to everyone (guests read-only, admins can edit) */}
<AlarmManager />
</Container>
{/* Login dialog - shown on demand */}
@@ -117,6 +126,12 @@ function AppContent() {
open={showLogin}
onClose={() => setShowLogin(false)}
/>
{/* Profile dialog */}
<ProfileDialog
open={showProfile}
onClose={() => setShowProfile(false)}
/>
</Box>
);
}

134
src/client/ProfileDialog.js Normal file
View File

@@ -0,0 +1,134 @@
import React, { useState, useEffect } from 'react';
import {
Dialog,
DialogTitle,
DialogContent,
DialogActions,
TextField,
Button,
Box,
Typography,
Alert,
CircularProgress,
Link
} from '@mui/material';
import { useAuth } from './AuthContext';
export default function ProfileDialog({ open, onClose }) {
const { user, login } = useAuth(); // We need a way to refresh user data or update context.
// Actually, AuthContext might not expose a "refreshUser" method.
// For now we will update the local state and rely on next page load/auth check to refresh global state,
// OR we should ideally update the user object in AuthContext.
// Checking AuthContext... it has `user` state. It sets user on login/checkAuth.
// `checkAuth` is not exposed in `useAuth` return typically?
// Let's assume we can just modify the user locally or ignore it, as long as the server has it.
// Better: Fetch the latest profile data when opening the dialog.
const [telegramId, setTelegramId] = useState('');
const [loading, setLoading] = useState(false);
const [saving, setSaving] = useState(false);
const [error, setError] = useState(null);
const [success, setSuccess] = useState(false);
useEffect(() => {
if (open) {
fetchProfile();
setSuccess(false);
setError(null);
}
}, [open]);
const fetchProfile = async () => {
setLoading(true);
try {
const token = localStorage.getItem('authToken');
const res = await fetch('api/auth/me', {
headers: { 'Authorization': `Bearer ${token}` }
});
if (res.ok) {
const data = await res.json();
if (data.user) {
setTelegramId(data.user.telegramId || '');
}
}
} catch (err) {
console.error(err);
} finally {
setLoading(false);
}
};
const handleSave = async () => {
setSaving(true);
setError(null);
setSuccess(false);
try {
const token = localStorage.getItem('authToken');
const res = await fetch('api/auth/profile', {
method: 'PUT',
headers: {
'Content-Type': 'application/json',
'Authorization': `Bearer ${token}`
},
body: JSON.stringify({ telegramId })
});
if (!res.ok) {
const errData = await res.json().catch(() => ({}));
throw new Error(errData.error || 'Failed to update profile');
}
setSuccess(true);
setTimeout(() => onClose(), 1500);
} catch (err) {
setError(err.message);
} finally {
setSaving(false);
}
};
return (
<Dialog open={open} onClose={onClose} maxWidth="sm" fullWidth>
<DialogTitle>User Profile: {user?.username}</DialogTitle>
<DialogContent>
<Box sx={{ pt: 1, display: 'flex', flexDirection: 'column', gap: 2 }}>
<Typography variant="body2" color="text.secondary">
Link your Telegram account to receive alarm notifications.
</Typography>
<Alert severity="info" icon={false}>
To get your Telegram ID:
<ol style={{ margin: '8px 0', paddingLeft: 20 }}>
<li>Search for <b>@TischlereiCtrlBot</b> on Telegram</li>
<li>Start the bot (`/start`)</li>
<li>It will reply with your ID. Copy it here.</li>
</ol>
</Alert>
{loading ? (
<Box sx={{ display: 'flex', justifyContent: 'center' }}><CircularProgress /></Box>
) : (
<TextField
label="Telegram ID"
value={telegramId}
onChange={(e) => setTelegramId(e.target.value)}
fullWidth
placeholder="e.g. 123456789"
helperText="Leave empty to disable notifications"
/>
)}
{error && <Alert severity="error">{error}</Alert>}
{success && <Alert severity="success">Profile updated successfully!</Alert>}
</Box>
</DialogContent>
<DialogActions>
<Button onClick={onClose} color="inherit">Close</Button>
<Button onClick={handleSave} variant="contained" disabled={saving || loading}>
{saving ? 'Saving...' : 'Save'}
</Button>
</DialogActions>
</Dialog>
);
}

View File

@@ -90,5 +90,8 @@
"fri": "Fr",
"sat": "Sa",
"sun": "So"
},
"alarms": {
"title": "Alarme"
}
}

View File

@@ -90,5 +90,8 @@
"fri": "Fri",
"sat": "Sat",
"sun": "Sun"
},
"alarms": {
"title": "Alarms"
}
}