361 lines
10 KiB
TypeScript
361 lines
10 KiB
TypeScript
import { useState, useEffect } from 'react';
|
||
import { groupApi } from '../api/groups';
|
||
import { deviceApi } from '../api/devices';
|
||
import { Group, Device } from '../api/types';
|
||
|
||
export function GroupsPage() {
|
||
const [groups, setGroups] = useState<Group[]>([]);
|
||
const [loading, setLoading] = useState(true);
|
||
const [error, setError] = useState<string | null>(null);
|
||
const [showModal, setShowModal] = useState(false);
|
||
const [editingGroup, setEditingGroup] = useState<Group | null>(null);
|
||
const [selectedGroup, setSelectedGroup] = useState<Group | null>(null);
|
||
|
||
useEffect(() => {
|
||
loadGroups();
|
||
}, []);
|
||
|
||
const loadGroups = async () => {
|
||
try {
|
||
setLoading(true);
|
||
const data = await groupApi.getAll();
|
||
setGroups(data);
|
||
setError(null);
|
||
} catch (err) {
|
||
setError('Failed to load groups');
|
||
} finally {
|
||
setLoading(false);
|
||
}
|
||
};
|
||
|
||
const handleDelete = async (id: string) => {
|
||
if (!confirm('Are you sure you want to delete this group?')) return;
|
||
|
||
try {
|
||
await groupApi.delete(id);
|
||
loadGroups();
|
||
} catch (err) {
|
||
alert('Failed to delete group. It may have active schedules.');
|
||
}
|
||
};
|
||
|
||
const openCreateModal = () => {
|
||
setEditingGroup(null);
|
||
setShowModal(true);
|
||
};
|
||
|
||
const openEditModal = (group: Group) => {
|
||
setEditingGroup(group);
|
||
setShowModal(true);
|
||
};
|
||
|
||
if (loading) return <div className="loading">Loading groups...</div>;
|
||
|
||
return (
|
||
<div>
|
||
<div className="page-header">
|
||
<h2>Groups</h2>
|
||
<button className="btn btn-primary" onClick={openCreateModal}>
|
||
Create Group
|
||
</button>
|
||
</div>
|
||
|
||
{error && <div className="error-message">{error}</div>}
|
||
|
||
<table>
|
||
<thead>
|
||
<tr>
|
||
<th>Name</th>
|
||
<th>Devices</th>
|
||
<th>Actions</th>
|
||
</tr>
|
||
</thead>
|
||
<tbody>
|
||
{groups.map((group) => (
|
||
<tr key={group.id}>
|
||
<td>{group.name}</td>
|
||
<td>{group.devices.length} device(s)</td>
|
||
<td>
|
||
<button
|
||
className="btn btn-small btn-secondary"
|
||
onClick={() => setSelectedGroup(group)}
|
||
style={{ marginRight: '5px' }}
|
||
>
|
||
Control
|
||
</button>
|
||
<button
|
||
className="btn btn-small btn-secondary"
|
||
onClick={() => openEditModal(group)}
|
||
style={{ marginRight: '5px' }}
|
||
>
|
||
Edit
|
||
</button>
|
||
<button
|
||
className="btn btn-small btn-danger"
|
||
onClick={() => handleDelete(group.id)}
|
||
>
|
||
Delete
|
||
</button>
|
||
</td>
|
||
</tr>
|
||
))}
|
||
</tbody>
|
||
</table>
|
||
|
||
{showModal && (
|
||
<GroupModal
|
||
group={editingGroup}
|
||
onClose={() => setShowModal(false)}
|
||
onSave={() => {
|
||
setShowModal(false);
|
||
loadGroups();
|
||
}}
|
||
/>
|
||
)}
|
||
|
||
{selectedGroup && (
|
||
<GroupControlModal
|
||
group={selectedGroup}
|
||
onClose={() => setSelectedGroup(null)}
|
||
/>
|
||
)}
|
||
</div>
|
||
);
|
||
}
|
||
|
||
interface GroupModalProps {
|
||
group: Group | null;
|
||
onClose: () => void;
|
||
onSave: () => void;
|
||
}
|
||
|
||
function GroupModal({ group, onClose, onSave }: GroupModalProps) {
|
||
const [name, setName] = useState(group?.name || '');
|
||
const [devices, setDevices] = useState<Device[]>([]);
|
||
const [selectedDeviceIds, setSelectedDeviceIds] = useState<string[]>(
|
||
group?.devices.map(d => d.id) || []
|
||
);
|
||
const [submitting, setSubmitting] = useState(false);
|
||
|
||
useEffect(() => {
|
||
loadDevices();
|
||
}, []);
|
||
|
||
const loadDevices = async () => {
|
||
const data = await deviceApi.getAll();
|
||
setDevices(data);
|
||
};
|
||
|
||
const handleToggleDevice = (deviceId: string) => {
|
||
setSelectedDeviceIds(prev =>
|
||
prev.includes(deviceId)
|
||
? prev.filter(id => id !== deviceId)
|
||
: [...prev, deviceId]
|
||
);
|
||
};
|
||
|
||
const handleSubmit = async (e: React.FormEvent) => {
|
||
e.preventDefault();
|
||
|
||
if (selectedDeviceIds.length === 0) {
|
||
alert('Please select at least one device');
|
||
return;
|
||
}
|
||
|
||
try {
|
||
setSubmitting(true);
|
||
|
||
if (group) {
|
||
await groupApi.update(group.id, { name, deviceIds: selectedDeviceIds });
|
||
} else {
|
||
await groupApi.create({ name, deviceIds: selectedDeviceIds });
|
||
}
|
||
|
||
onSave();
|
||
} catch (err) {
|
||
alert('Failed to save group');
|
||
} finally {
|
||
setSubmitting(false);
|
||
}
|
||
};
|
||
|
||
return (
|
||
<div className="modal" onClick={onClose}>
|
||
<div className="modal-content" onClick={(e) => e.stopPropagation()}>
|
||
<div className="modal-header">
|
||
<h3>{group ? 'Edit Group' : 'Create Group'}</h3>
|
||
<button className="close-btn" onClick={onClose}>×</button>
|
||
</div>
|
||
|
||
<form onSubmit={handleSubmit}>
|
||
<div className="form-group">
|
||
<label>Name *</label>
|
||
<input
|
||
type="text"
|
||
value={name}
|
||
onChange={(e) => setName(e.target.value)}
|
||
required
|
||
/>
|
||
</div>
|
||
|
||
<div className="form-group">
|
||
<label>Devices *</label>
|
||
<div className="checkbox-group">
|
||
{devices.map(device => (
|
||
<label key={device.id} className="checkbox-item">
|
||
<input
|
||
type="checkbox"
|
||
checked={selectedDeviceIds.includes(device.id)}
|
||
onChange={() => handleToggleDevice(device.id)}
|
||
/>
|
||
{device.name} ({device.ipAddress})
|
||
</label>
|
||
))}
|
||
</div>
|
||
</div>
|
||
|
||
<div className="form-actions">
|
||
<button type="button" className="btn btn-secondary" onClick={onClose}>
|
||
Cancel
|
||
</button>
|
||
<button type="submit" className="btn btn-primary" disabled={submitting}>
|
||
{submitting ? 'Saving...' : 'Save'}
|
||
</button>
|
||
</div>
|
||
</form>
|
||
</div>
|
||
</div>
|
||
);
|
||
}
|
||
|
||
interface GroupControlModalProps {
|
||
group: Group;
|
||
onClose: () => void;
|
||
}
|
||
|
||
function GroupControlModal({ group, onClose }: GroupControlModalProps) {
|
||
const [presetId, setPresetId] = useState('');
|
||
const [playlistPresets, setPlaylistPresets] = useState('');
|
||
const [playlistDur, setPlaylistDur] = useState('');
|
||
const [playlistTransition, setPlaylistTransition] = useState('');
|
||
const [playlistRepeat, setPlaylistRepeat] = useState('0');
|
||
const [playlistEnd, setPlaylistEnd] = useState('');
|
||
|
||
const handleApplyPreset = async () => {
|
||
if (!presetId) {
|
||
alert('Please enter a preset ID');
|
||
return;
|
||
}
|
||
|
||
try {
|
||
const result = await groupApi.applyPreset(group.id, parseInt(presetId));
|
||
alert(`Success: ${result.results.success.length}, Failed: ${result.results.failed.length}`);
|
||
} catch (err) {
|
||
alert('Failed to apply preset');
|
||
}
|
||
};
|
||
|
||
const handleApplyPlaylist = async () => {
|
||
if (!playlistPresets) {
|
||
alert('Please enter preset IDs');
|
||
return;
|
||
}
|
||
|
||
try {
|
||
const ps = playlistPresets.split(',').map(s => parseInt(s.trim()));
|
||
const dur = playlistDur ? playlistDur.split(',').map(s => parseInt(s.trim())) : undefined;
|
||
const transition = playlistTransition ? playlistTransition.split(',').map(s => parseInt(s.trim())) : undefined;
|
||
|
||
const result = await groupApi.applyPlaylist(group.id, {
|
||
ps,
|
||
dur: dur && dur.length === 1 ? dur[0] : dur,
|
||
transition: transition && transition.length === 1 ? transition[0] : transition,
|
||
repeat: parseInt(playlistRepeat),
|
||
end: playlistEnd ? parseInt(playlistEnd) : undefined,
|
||
});
|
||
|
||
alert(`Success: ${result.results.success.length}, Failed: ${result.results.failed.length}`);
|
||
} catch (err) {
|
||
alert('Failed to apply playlist');
|
||
}
|
||
};
|
||
|
||
return (
|
||
<div className="modal" onClick={onClose}>
|
||
<div className="modal-content" onClick={(e) => e.stopPropagation()}>
|
||
<div className="modal-header">
|
||
<h3>Control Group: {group.name}</h3>
|
||
<button className="close-btn" onClick={onClose}>×</button>
|
||
</div>
|
||
|
||
<div className="card">
|
||
<h3>Apply Preset</h3>
|
||
<div className="form-group">
|
||
<label>Preset ID</label>
|
||
<input
|
||
type="number"
|
||
value={presetId}
|
||
onChange={(e) => setPresetId(e.target.value)}
|
||
placeholder="e.g., 1"
|
||
/>
|
||
</div>
|
||
<button className="btn btn-primary" onClick={handleApplyPreset}>
|
||
Apply Preset
|
||
</button>
|
||
</div>
|
||
|
||
<div className="card">
|
||
<h3>Apply Playlist</h3>
|
||
<div className="form-group">
|
||
<label>Preset IDs (comma-separated) *</label>
|
||
<input
|
||
type="text"
|
||
value={playlistPresets}
|
||
onChange={(e) => setPlaylistPresets(e.target.value)}
|
||
placeholder="e.g., 1,2,3"
|
||
/>
|
||
</div>
|
||
<div className="form-group">
|
||
<label>Duration (tenths of seconds, comma-separated or single value)</label>
|
||
<input
|
||
type="text"
|
||
value={playlistDur}
|
||
onChange={(e) => setPlaylistDur(e.target.value)}
|
||
placeholder="e.g., 30,30,30 or 30"
|
||
/>
|
||
</div>
|
||
<div className="form-group">
|
||
<label>Transition (comma-separated or single value)</label>
|
||
<input
|
||
type="text"
|
||
value={playlistTransition}
|
||
onChange={(e) => setPlaylistTransition(e.target.value)}
|
||
placeholder="e.g., 0"
|
||
/>
|
||
</div>
|
||
<div className="form-group">
|
||
<label>Repeat (0 = infinite)</label>
|
||
<input
|
||
type="number"
|
||
value={playlistRepeat}
|
||
onChange={(e) => setPlaylistRepeat(e.target.value)}
|
||
/>
|
||
</div>
|
||
<div className="form-group">
|
||
<label>End Preset ID</label>
|
||
<input
|
||
type="number"
|
||
value={playlistEnd}
|
||
onChange={(e) => setPlaylistEnd(e.target.value)}
|
||
placeholder="Optional"
|
||
/>
|
||
</div>
|
||
<button className="btn btn-primary" onClick={handleApplyPlaylist}>
|
||
Apply Playlist
|
||
</button>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
);
|
||
}
|