mirror of
https://github.com/Dispatcharr/Dispatcharr.git
synced 2026-07-22 09:37:57 +00:00
Implement group management features: add GroupManager component.
This commit is contained in:
parent
3006209ecc
commit
fcce1a36b2
5 changed files with 413 additions and 2 deletions
|
|
@ -187,6 +187,26 @@ class ChannelGroupViewSet(viewsets.ModelViewSet):
|
|||
except KeyError:
|
||||
return [Authenticated()]
|
||||
|
||||
def destroy(self, request, *args, **kwargs):
|
||||
"""Override destroy to check for associations before deletion"""
|
||||
instance = self.get_object()
|
||||
|
||||
# Check if group has associated channels
|
||||
if instance.channels.exists():
|
||||
return Response(
|
||||
{"error": "Cannot delete group with associated channels"},
|
||||
status=status.HTTP_400_BAD_REQUEST
|
||||
)
|
||||
|
||||
# Check if group has M3U account associations
|
||||
if hasattr(instance, 'm3u_account') and instance.m3u_account.exists():
|
||||
return Response(
|
||||
{"error": "Cannot delete group with M3U account associations"},
|
||||
status=status.HTTP_400_BAD_REQUEST
|
||||
)
|
||||
|
||||
return super().destroy(request, *args, **kwargs)
|
||||
|
||||
|
||||
# ─────────────────────────────────────────────────────────
|
||||
# 3) Channel Management (CRUD)
|
||||
|
|
|
|||
|
|
@ -277,6 +277,22 @@ export default class API {
|
|||
}
|
||||
}
|
||||
|
||||
static async deleteChannelGroup(id) {
|
||||
try {
|
||||
await request(`${host}/api/channels/groups/${id}/`, {
|
||||
method: 'DELETE',
|
||||
});
|
||||
|
||||
// Remove from store after successful deletion
|
||||
useChannelsStore.getState().removeChannelGroup(id);
|
||||
|
||||
return true;
|
||||
} catch (e) {
|
||||
errorNotification('Failed to delete channel group', e);
|
||||
throw e;
|
||||
}
|
||||
}
|
||||
|
||||
static async addChannel(channel) {
|
||||
try {
|
||||
let body = null;
|
||||
|
|
|
|||
350
frontend/src/components/forms/GroupManager.jsx
Normal file
350
frontend/src/components/forms/GroupManager.jsx
Normal file
|
|
@ -0,0 +1,350 @@
|
|||
import React, { useState, useEffect } from 'react';
|
||||
import {
|
||||
Modal,
|
||||
Stack,
|
||||
Group,
|
||||
Text,
|
||||
TextInput,
|
||||
Button,
|
||||
ActionIcon,
|
||||
Flex,
|
||||
Badge,
|
||||
Alert,
|
||||
Divider,
|
||||
ScrollArea,
|
||||
} from '@mantine/core';
|
||||
import {
|
||||
SquarePlus,
|
||||
SquarePen,
|
||||
Trash2,
|
||||
Check,
|
||||
X,
|
||||
AlertCircle,
|
||||
Database,
|
||||
Tv
|
||||
} from 'lucide-react';
|
||||
import { notifications } from '@mantine/notifications';
|
||||
import useChannelsStore from '../../store/channels';
|
||||
import API from '../../api';
|
||||
|
||||
const GroupManager = ({ isOpen, onClose }) => {
|
||||
const channelGroups = useChannelsStore((s) => s.channelGroups);
|
||||
const [editingGroup, setEditingGroup] = useState(null);
|
||||
const [editName, setEditName] = useState('');
|
||||
const [newGroupName, setNewGroupName] = useState('');
|
||||
const [isCreating, setIsCreating] = useState(false);
|
||||
const [groupUsage, setGroupUsage] = useState({});
|
||||
const [loading, setLoading] = useState(false);
|
||||
|
||||
// Fetch group usage information when modal opens
|
||||
useEffect(() => {
|
||||
if (isOpen) {
|
||||
fetchGroupUsage();
|
||||
}
|
||||
}, [isOpen]);
|
||||
|
||||
const fetchGroupUsage = async () => {
|
||||
setLoading(true);
|
||||
try {
|
||||
// This would ideally be a dedicated API endpoint, but we'll use the existing data
|
||||
// For now, we'll determine usage based on the group having associated data
|
||||
const usage = {};
|
||||
|
||||
// Check which groups have channels or M3U associations
|
||||
// This is a simplified check - in a real implementation you'd want a dedicated API
|
||||
Object.values(channelGroups).forEach(group => {
|
||||
usage[group.id] = {
|
||||
hasChannels: false, // Would need API call to check
|
||||
hasM3UAccounts: false, // Would need API call to check
|
||||
canEdit: true, // Assume editable unless proven otherwise
|
||||
canDelete: true // Assume deletable unless proven otherwise
|
||||
};
|
||||
});
|
||||
|
||||
setGroupUsage(usage);
|
||||
} catch (error) {
|
||||
console.error('Error fetching group usage:', error);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleEdit = (group) => {
|
||||
setEditingGroup(group.id);
|
||||
setEditName(group.name);
|
||||
};
|
||||
|
||||
const handleSaveEdit = async () => {
|
||||
if (!editName.trim()) {
|
||||
notifications.show({
|
||||
title: 'Error',
|
||||
message: 'Group name cannot be empty',
|
||||
color: 'red',
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
await API.updateChannelGroup({
|
||||
id: editingGroup,
|
||||
name: editName.trim(),
|
||||
});
|
||||
|
||||
notifications.show({
|
||||
title: 'Success',
|
||||
message: 'Group updated successfully',
|
||||
color: 'green',
|
||||
});
|
||||
|
||||
setEditingGroup(null);
|
||||
setEditName('');
|
||||
} catch (error) {
|
||||
notifications.show({
|
||||
title: 'Error',
|
||||
message: 'Failed to update group',
|
||||
color: 'red',
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
const handleCancelEdit = () => {
|
||||
setEditingGroup(null);
|
||||
setEditName('');
|
||||
};
|
||||
|
||||
const handleCreate = async () => {
|
||||
if (!newGroupName.trim()) {
|
||||
notifications.show({
|
||||
title: 'Error',
|
||||
message: 'Group name cannot be empty',
|
||||
color: 'red',
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
await API.addChannelGroup({
|
||||
name: newGroupName.trim(),
|
||||
});
|
||||
|
||||
notifications.show({
|
||||
title: 'Success',
|
||||
message: 'Group created successfully',
|
||||
color: 'green',
|
||||
});
|
||||
|
||||
setNewGroupName('');
|
||||
setIsCreating(false);
|
||||
fetchGroupUsage(); // Refresh usage data
|
||||
} catch (error) {
|
||||
notifications.show({
|
||||
title: 'Error',
|
||||
message: 'Failed to create group',
|
||||
color: 'red',
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
const handleDelete = async (group) => {
|
||||
const usage = groupUsage[group.id];
|
||||
|
||||
if (usage && (!usage.canDelete || usage.hasChannels || usage.hasM3UAccounts)) {
|
||||
notifications.show({
|
||||
title: 'Cannot Delete',
|
||||
message: 'This group is associated with channels or M3U accounts and cannot be deleted',
|
||||
color: 'orange',
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
await API.deleteChannelGroup(group.id);
|
||||
|
||||
notifications.show({
|
||||
title: 'Success',
|
||||
message: 'Group deleted successfully',
|
||||
color: 'green',
|
||||
});
|
||||
|
||||
fetchGroupUsage(); // Refresh usage data
|
||||
} catch (error) {
|
||||
notifications.show({
|
||||
title: 'Error',
|
||||
message: 'Failed to delete group',
|
||||
color: 'red',
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
const getGroupBadges = (group) => {
|
||||
const usage = groupUsage[group.id];
|
||||
const badges = [];
|
||||
|
||||
if (usage?.hasChannels) {
|
||||
badges.push(
|
||||
<Badge key="channels" size="xs" color="blue" leftSection={<Tv size={10} />}>
|
||||
Channels
|
||||
</Badge>
|
||||
);
|
||||
}
|
||||
|
||||
if (usage?.hasM3UAccounts) {
|
||||
badges.push(
|
||||
<Badge key="m3u" size="xs" color="purple" leftSection={<Database size={10} />}>
|
||||
M3U
|
||||
</Badge>
|
||||
);
|
||||
}
|
||||
|
||||
return badges;
|
||||
};
|
||||
|
||||
const canEditGroup = (group) => {
|
||||
const usage = groupUsage[group.id];
|
||||
return usage?.canEdit !== false; // Default to true if no usage data
|
||||
};
|
||||
|
||||
const canDeleteGroup = (group) => {
|
||||
const usage = groupUsage[group.id];
|
||||
return usage?.canDelete !== false && !usage?.hasChannels && !usage?.hasM3UAccounts;
|
||||
};
|
||||
|
||||
if (!isOpen) return null;
|
||||
|
||||
return (
|
||||
<Modal
|
||||
opened={isOpen}
|
||||
onClose={onClose}
|
||||
title="Group Manager"
|
||||
size="md"
|
||||
scrollAreaComponent={ScrollArea.Autosize}
|
||||
>
|
||||
<Stack>
|
||||
<Alert icon={<AlertCircle size={16} />} color="blue" variant="light">
|
||||
Manage channel groups. Groups associated with M3U accounts or containing channels cannot be deleted.
|
||||
</Alert>
|
||||
|
||||
{/* Create new group section */}
|
||||
<Stack>
|
||||
<Text size="sm" fw={600}>Create New Group</Text>
|
||||
<Group>
|
||||
{isCreating ? (
|
||||
<>
|
||||
<TextInput
|
||||
placeholder="Enter group name"
|
||||
value={newGroupName}
|
||||
onChange={(e) => setNewGroupName(e.target.value)}
|
||||
style={{ flex: 1 }}
|
||||
onKeyPress={(e) => e.key === 'Enter' && handleCreate()}
|
||||
/>
|
||||
<ActionIcon color="green" onClick={handleCreate}>
|
||||
<Check size={16} />
|
||||
</ActionIcon>
|
||||
<ActionIcon color="gray" onClick={() => {
|
||||
setIsCreating(false);
|
||||
setNewGroupName('');
|
||||
}}>
|
||||
<X size={16} />
|
||||
</ActionIcon>
|
||||
</>
|
||||
) : (
|
||||
<Button
|
||||
leftSection={<SquarePlus size={16} />}
|
||||
variant="light"
|
||||
size="sm"
|
||||
onClick={() => setIsCreating(true)}
|
||||
>
|
||||
Add Group
|
||||
</Button>
|
||||
)}
|
||||
</Group>
|
||||
</Stack>
|
||||
|
||||
<Divider />
|
||||
|
||||
{/* Existing groups */}
|
||||
<Stack>
|
||||
<Text size="sm" fw={600}>Existing Groups ({Object.keys(channelGroups).length})</Text>
|
||||
|
||||
{loading ? (
|
||||
<Text size="sm" c="dimmed">Loading group information...</Text>
|
||||
) : Object.keys(channelGroups).length === 0 ? (
|
||||
<Text size="sm" c="dimmed">No groups found</Text>
|
||||
) : (
|
||||
<Stack gap="xs">
|
||||
{Object.values(channelGroups)
|
||||
.sort((a, b) => a.name.localeCompare(b.name))
|
||||
.map((group) => (
|
||||
<Group key={group.id} justify="space-between" p="sm" style={{
|
||||
border: '1px solid #e0e0e0',
|
||||
borderRadius: '4px',
|
||||
backgroundColor: editingGroup === group.id ? '#f8f9fa' : 'transparent'
|
||||
}}>
|
||||
<Stack gap={4} style={{ flex: 1 }}>
|
||||
{editingGroup === group.id ? (
|
||||
<TextInput
|
||||
value={editName}
|
||||
onChange={(e) => setEditName(e.target.value)}
|
||||
size="sm"
|
||||
onKeyPress={(e) => e.key === 'Enter' && handleSaveEdit()}
|
||||
/>
|
||||
) : (
|
||||
<>
|
||||
<Text size="sm" fw={500}>{group.name}</Text>
|
||||
<Group gap={4}>
|
||||
{getGroupBadges(group)}
|
||||
</Group>
|
||||
</>
|
||||
)}
|
||||
</Stack>
|
||||
|
||||
<Group gap="xs">
|
||||
{editingGroup === group.id ? (
|
||||
<>
|
||||
<ActionIcon color="green" size="sm" onClick={handleSaveEdit}>
|
||||
<Check size={14} />
|
||||
</ActionIcon>
|
||||
<ActionIcon color="gray" size="sm" onClick={handleCancelEdit}>
|
||||
<X size={14} />
|
||||
</ActionIcon>
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<ActionIcon
|
||||
color="blue"
|
||||
size="sm"
|
||||
onClick={() => handleEdit(group)}
|
||||
disabled={!canEditGroup(group)}
|
||||
>
|
||||
<SquarePen size={14} />
|
||||
</ActionIcon>
|
||||
<ActionIcon
|
||||
color="red"
|
||||
size="sm"
|
||||
onClick={() => handleDelete(group)}
|
||||
disabled={!canDeleteGroup(group)}
|
||||
>
|
||||
<Trash2 size={14} />
|
||||
</ActionIcon>
|
||||
</>
|
||||
)}
|
||||
</Group>
|
||||
</Group>
|
||||
))}
|
||||
</Stack>
|
||||
)}
|
||||
</Stack>
|
||||
|
||||
<Divider />
|
||||
|
||||
<Flex justify="flex-end">
|
||||
<Button variant="default" onClick={onClose}>
|
||||
Close
|
||||
</Button>
|
||||
</Flex>
|
||||
</Stack>
|
||||
</Modal>
|
||||
);
|
||||
};
|
||||
|
||||
export default GroupManager;
|
||||
|
|
@ -25,6 +25,7 @@ import {
|
|||
SquareMinus,
|
||||
SquarePen,
|
||||
SquarePlus,
|
||||
Settings,
|
||||
} from 'lucide-react';
|
||||
import API from '../../../api';
|
||||
import { notifications } from '@mantine/notifications';
|
||||
|
|
@ -32,6 +33,7 @@ import useChannelsStore from '../../../store/channels';
|
|||
import useAuthStore from '../../../store/auth';
|
||||
import { USER_LEVELS } from '../../../constants';
|
||||
import AssignChannelNumbersForm from '../../forms/AssignChannelNumbers';
|
||||
import GroupManager from '../../forms/GroupManager';
|
||||
import ConfirmationDialog from '../../ConfirmationDialog';
|
||||
import useWarningsStore from '../../../store/warnings';
|
||||
|
||||
|
|
@ -105,6 +107,7 @@ const ChannelTableHeader = ({
|
|||
|
||||
const [channelNumAssignmentStart, setChannelNumAssignmentStart] = useState(1);
|
||||
const [assignNumbersModalOpen, setAssignNumbersModalOpen] = useState(false);
|
||||
const [groupManagerOpen, setGroupManagerOpen] = useState(false);
|
||||
const [confirmDeleteProfileOpen, setConfirmDeleteProfileOpen] = useState(false);
|
||||
const [profileToDelete, setProfileToDelete] = useState(null);
|
||||
|
||||
|
|
@ -301,6 +304,15 @@ const ChannelTableHeader = ({
|
|||
<Text size="xs">Auto-Match</Text>
|
||||
</UnstyledButton>
|
||||
</Menu.Item>
|
||||
|
||||
<Menu.Item
|
||||
leftSection={<Settings size={18} />}
|
||||
disabled={authUser.user_level != USER_LEVELS.ADMIN}
|
||||
>
|
||||
<UnstyledButton size="xs" onClick={() => setGroupManagerOpen(true)}>
|
||||
<Text size="xs">Edit Groups</Text>
|
||||
</UnstyledButton>
|
||||
</Menu.Item>
|
||||
</Menu.Dropdown>
|
||||
</Menu>
|
||||
</Flex>
|
||||
|
|
@ -312,6 +324,11 @@ const ChannelTableHeader = ({
|
|||
onClose={closeAssignChannelNumbersModal}
|
||||
/>
|
||||
|
||||
<GroupManager
|
||||
isOpen={groupManagerOpen}
|
||||
onClose={() => setGroupManagerOpen(false)}
|
||||
/>
|
||||
|
||||
<ConfirmationDialog
|
||||
opened={confirmDeleteProfileOpen}
|
||||
onClose={() => setConfirmDeleteProfileOpen(false)}
|
||||
|
|
|
|||
|
|
@ -204,10 +204,18 @@ const useChannelsStore = create((set, get) => ({
|
|||
|
||||
updateChannelGroup: (channelGroup) =>
|
||||
set((state) => ({
|
||||
...state.channelGroups,
|
||||
[channelGroup.id]: channelGroup,
|
||||
channelGroups: {
|
||||
...state.channelGroups,
|
||||
[channelGroup.id]: channelGroup,
|
||||
},
|
||||
})),
|
||||
|
||||
removeChannelGroup: (groupId) =>
|
||||
set((state) => {
|
||||
const { [groupId]: removed, ...remainingGroups } = state.channelGroups;
|
||||
return { channelGroups: remainingGroups };
|
||||
}),
|
||||
|
||||
fetchLogos: async () => {
|
||||
set({ isLoading: true, error: null });
|
||||
try {
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue