diff --git a/apps/channels/api_views.py b/apps/channels/api_views.py index b651081e..f0f59f29 100644 --- a/apps/channels/api_views.py +++ b/apps/channels/api_views.py @@ -187,6 +187,91 @@ class ChannelGroupViewSet(viewsets.ModelViewSet): except KeyError: return [Authenticated()] + def get_queryset(self): + """Add annotation for association counts""" + from django.db.models import Count + return ChannelGroup.objects.annotate( + channel_count=Count('channels', distinct=True), + m3u_account_count=Count('m3u_account', distinct=True) + ) + + def update(self, request, *args, **kwargs): + """Override update to check M3U associations""" + instance = self.get_object() + + # Check if group has M3U account associations + if hasattr(instance, 'm3u_account') and instance.m3u_account.exists(): + return Response( + {"error": "Cannot edit group with M3U account associations"}, + status=status.HTTP_400_BAD_REQUEST + ) + + return super().update(request, *args, **kwargs) + + def partial_update(self, request, *args, **kwargs): + """Override partial_update to check M3U associations""" + instance = self.get_object() + + # Check if group has M3U account associations + if hasattr(instance, 'm3u_account') and instance.m3u_account.exists(): + return Response( + {"error": "Cannot edit group with M3U account associations"}, + status=status.HTTP_400_BAD_REQUEST + ) + + return super().partial_update(request, *args, **kwargs) + + @swagger_auto_schema( + method="post", + operation_description="Delete all channel groups that have no associations (no channels or M3U accounts)", + responses={200: "Cleanup completed"}, + ) + @action(detail=False, methods=["post"], url_path="cleanup") + def cleanup_unused_groups(self, request): + """Delete all channel groups with no channels or M3U account associations""" + from django.db.models import Count + + # Find groups with no channels and no M3U account associations + unused_groups = ChannelGroup.objects.annotate( + channel_count=Count('channels', distinct=True), + m3u_account_count=Count('m3u_account', distinct=True) + ).filter( + channel_count=0, + m3u_account_count=0 + ) + + deleted_count = unused_groups.count() + group_names = list(unused_groups.values_list('name', flat=True)) + + # Delete the unused groups + unused_groups.delete() + + return Response({ + "message": f"Successfully deleted {deleted_count} unused channel groups", + "deleted_count": deleted_count, + "deleted_groups": group_names + }) + + 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) diff --git a/apps/channels/serializers.py b/apps/channels/serializers.py index cdc6ef60..4d1694dc 100644 --- a/apps/channels/serializers.py +++ b/apps/channels/serializers.py @@ -89,9 +89,12 @@ class StreamSerializer(serializers.ModelSerializer): # Channel Group # class ChannelGroupSerializer(serializers.ModelSerializer): + channel_count = serializers.IntegerField(read_only=True) + m3u_account_count = serializers.IntegerField(read_only=True) + class Meta: model = ChannelGroup - fields = ["id", "name"] + fields = ["id", "name", "channel_count", "m3u_account_count"] class ChannelProfileSerializer(serializers.ModelSerializer): diff --git a/frontend/src/api.js b/frontend/src/api.js index d75edfa9..e0a62160 100644 --- a/frontend/src/api.js +++ b/frontend/src/api.js @@ -250,7 +250,17 @@ export default class API { }); if (response.id) { - useChannelsStore.getState().addChannelGroup(response); + // Add association flags for new groups + const processedGroup = { + ...response, + hasChannels: false, + hasM3UAccounts: false, + canEdit: true, + canDelete: true + }; + useChannelsStore.getState().addChannelGroup(processedGroup); + // Refresh channel groups to update the UI + useChannelsStore.getState().fetchChannelGroups(); } return response; @@ -277,6 +287,38 @@ 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 cleanupUnusedChannelGroups() { + try { + const response = await request(`${host}/api/channels/groups/cleanup/`, { + method: 'POST', + }); + + // Refresh channel groups to update the UI + useChannelsStore.getState().fetchChannelGroups(); + + return response; + } catch (e) { + errorNotification('Failed to cleanup unused channel groups', e); + throw e; + } + } + static async addChannel(channel) { try { let body = null; diff --git a/frontend/src/components/ConfirmationDialog.jsx b/frontend/src/components/ConfirmationDialog.jsx index 822b46f1..8f96708d 100644 --- a/frontend/src/components/ConfirmationDialog.jsx +++ b/frontend/src/components/ConfirmationDialog.jsx @@ -27,7 +27,8 @@ const ConfirmationDialog = ({ cancelLabel = 'Cancel', actionKey, onSuppressChange, - size = 'md', // Add default size parameter - md is a medium width + size = 'md', + zIndex = 1000, }) => { const suppressWarning = useWarningsStore((s) => s.suppressWarning); const isWarningSuppressed = useWarningsStore((s) => s.isWarningSuppressed); @@ -50,7 +51,14 @@ const ConfirmationDialog = ({ }; return ( - + {message} {actionKey && ( diff --git a/frontend/src/components/forms/Channel.jsx b/frontend/src/components/forms/Channel.jsx index 452db052..64412cb4 100644 --- a/frontend/src/components/forms/Channel.jsx +++ b/frontend/src/components/forms/Channel.jsx @@ -45,6 +45,8 @@ const ChannelForm = ({ channel = null, isOpen, onClose }) => { const groupListRef = useRef(null); const channelGroups = useChannelsStore((s) => s.channelGroups); + const canEditChannelGroup = useChannelsStore((s) => s.canEditChannelGroup); + const logos = useChannelsStore((s) => s.logos); const fetchLogos = useChannelsStore((s) => s.fetchLogos); const streams = useStreamsStore((state) => state.streams); diff --git a/frontend/src/components/forms/ChannelBatch.jsx b/frontend/src/components/forms/ChannelBatch.jsx index 2ba3245c..693ebb11 100644 --- a/frontend/src/components/forms/ChannelBatch.jsx +++ b/frontend/src/components/forms/ChannelBatch.jsx @@ -32,6 +32,8 @@ const ChannelBatchForm = ({ channelIds, isOpen, onClose }) => { const groupListRef = useRef(null); const channelGroups = useChannelsStore((s) => s.channelGroups); + const canEditChannelGroup = useChannelsStore((s) => s.canEditChannelGroup); + const streamProfiles = useStreamProfilesStore((s) => s.profiles); const [channelGroupModelOpen, setChannelGroupModalOpen] = useState(false); diff --git a/frontend/src/components/forms/ChannelGroup.jsx b/frontend/src/components/forms/ChannelGroup.jsx index 18ed31c1..46641fb1 100644 --- a/frontend/src/components/forms/ChannelGroup.jsx +++ b/frontend/src/components/forms/ChannelGroup.jsx @@ -1,10 +1,17 @@ // Modal.js import React from 'react'; import API from '../../api'; -import { Flex, TextInput, Button, Modal } from '@mantine/core'; +import { Flex, TextInput, Button, Modal, Alert } from '@mantine/core'; +import { notifications } from '@mantine/notifications'; import { isNotEmpty, useForm } from '@mantine/form'; +import useChannelsStore from '../../store/channels'; const ChannelGroup = ({ channelGroup = null, isOpen, onClose }) => { + const canEditChannelGroup = useChannelsStore((s) => s.canEditChannelGroup); + + // Check if editing is allowed + const canEdit = !channelGroup || canEditChannelGroup(channelGroup.id); + const form = useForm({ mode: 'uncontrolled', initialValues: { @@ -17,6 +24,16 @@ const ChannelGroup = ({ channelGroup = null, isOpen, onClose }) => { }); const onSubmit = async () => { + // Prevent submission if editing is not allowed + if (channelGroup && !canEdit) { + notifications.show({ + title: 'Error', + message: 'Cannot edit group with M3U account associations', + color: 'red', + }); + return; + } + const values = form.getValues(); let newGroup; @@ -36,11 +53,17 @@ const ChannelGroup = ({ channelGroup = null, isOpen, onClose }) => { return ( + {channelGroup && !canEdit && ( + + This group cannot be edited because it has M3U account associations. + + )}
@@ -50,7 +73,7 @@ const ChannelGroup = ({ channelGroup = null, isOpen, onClose }) => { type="submit" variant="contained" color="primary" - disabled={form.submitting} + disabled={form.submitting || (channelGroup && !canEdit)} size="small" > Submit diff --git a/frontend/src/components/forms/GroupManager.jsx b/frontend/src/components/forms/GroupManager.jsx new file mode 100644 index 00000000..253a2b9c --- /dev/null +++ b/frontend/src/components/forms/GroupManager.jsx @@ -0,0 +1,664 @@ +import React, { useState, useEffect, useCallback, useMemo } from 'react'; +import { + Modal, + Stack, + Group, + Text, + TextInput, + Button, + ActionIcon, + Flex, + Badge, + Alert, + Divider, + ScrollArea, + useMantineTheme, + Chip, +} from '@mantine/core'; +import { + SquarePlus, + SquarePen, + SquareMinus, + Check, + X, + AlertCircle, + Database, + Tv, + Trash, + Filter +} from 'lucide-react'; +import { notifications } from '@mantine/notifications'; +import useChannelsStore from '../../store/channels'; +import useWarningsStore from '../../store/warnings'; +import ConfirmationDialog from '../ConfirmationDialog'; +import API from '../../api'; + +// Move GroupItem outside to prevent recreation on every render +const GroupItem = React.memo(({ + group, + editingGroup, + editName, + onEditNameChange, + onSaveEdit, + onCancelEdit, + onEdit, + onDelete, + groupUsage, + canEditGroup, + canDeleteGroup +}) => { + const theme = useMantineTheme(); + + const getGroupBadges = (group) => { + const usage = groupUsage[group.id]; + const badges = []; + + if (usage?.hasChannels) { + badges.push( + }> + Channels + + ); + } + + if (usage?.hasM3UAccounts) { + badges.push( + }> + M3U + + ); + } + + return badges; + }; + + return ( + + + {editingGroup === group.id ? ( + e.key === 'Enter' && onSaveEdit()} + autoFocus + /> + ) : ( + <> + {group.name} + + {getGroupBadges(group)} + + + )} + + + + {editingGroup === group.id ? ( + <> + + + + + + + + ) : ( + <> + onEdit(group)} + disabled={!canEditGroup(group)} + > + + + onDelete(group)} + disabled={!canDeleteGroup(group)} + > + + + + )} + + + ); +}); + +const GroupManager = React.memo(({ isOpen, onClose }) => { + const channelGroups = useChannelsStore((s) => s.channelGroups); + const canEditChannelGroup = useChannelsStore((s) => s.canEditChannelGroup); + const canDeleteChannelGroup = useChannelsStore((s) => s.canDeleteChannelGroup); + const isWarningSuppressed = useWarningsStore((s) => s.isWarningSuppressed); + const suppressWarning = useWarningsStore((s) => s.suppressWarning); + + 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); + const [searchTerm, setSearchTerm] = useState(''); + const [isCleaningUp, setIsCleaningUp] = useState(false); + const [showChannelGroups, setShowChannelGroups] = useState(true); + const [showM3UGroups, setShowM3UGroups] = useState(true); + const [showUnusedGroups, setShowUnusedGroups] = useState(true); + + // Confirmation dialog states + const [confirmDeleteOpen, setConfirmDeleteOpen] = useState(false); + const [groupToDelete, setGroupToDelete] = useState(null); + const [confirmCleanupOpen, setConfirmCleanupOpen] = useState(false); + + // Memoize the channel groups array to prevent unnecessary re-renders + const channelGroupsArray = useMemo(() => + Object.values(channelGroups), + [channelGroups] + ); + + // Memoize sorted groups to prevent re-sorting on every render + const sortedGroups = useMemo(() => + channelGroupsArray.sort((a, b) => a.name.localeCompare(b.name)), + [channelGroupsArray] + ); + + // Filter groups based on search term and chip filters + const filteredGroups = useMemo(() => { + let filtered = sortedGroups; + + // Apply search filter + if (searchTerm.trim()) { + filtered = filtered.filter(group => + group.name.toLowerCase().includes(searchTerm.toLowerCase()) + ); + } + + // Apply chip filters + filtered = filtered.filter(group => { + const usage = groupUsage[group.id]; + if (!usage) return false; + + const hasChannels = usage.hasChannels; + const hasM3U = usage.hasM3UAccounts; + const isUnused = !hasChannels && !hasM3U; + + // If group is unused, only show if unused groups are enabled + if (isUnused) { + return showUnusedGroups; + } + + // For groups with channels and/or M3U, show if either filter is enabled + let shouldShow = false; + if (hasChannels && showChannelGroups) shouldShow = true; + if (hasM3U && showM3UGroups) shouldShow = true; + + return shouldShow; + }); + + return filtered; + }, [sortedGroups, searchTerm, showChannelGroups, showM3UGroups, showUnusedGroups, groupUsage]); + + // Calculate filter counts + const filterCounts = useMemo(() => { + const counts = { + channels: 0, + m3u: 0, + unused: 0 + }; + + sortedGroups.forEach(group => { + const usage = groupUsage[group.id]; + if (usage) { + const hasChannels = usage.hasChannels; + const hasM3U = usage.hasM3UAccounts; + + // Count groups with channels (including those with both) + if (hasChannels) { + counts.channels++; + } + + // Count groups with M3U (including those with both) + if (hasM3U) { + counts.m3u++; + } + + // Count truly unused groups + if (!hasChannels && !hasM3U) { + counts.unused++; + } + } + }); + + return counts; + }, [sortedGroups, groupUsage]); + + const fetchGroupUsage = useCallback(async () => { + setLoading(true); + try { + // Use the actual channel group data that already has the flags + const usage = {}; + + Object.values(channelGroups).forEach(group => { + usage[group.id] = { + hasChannels: group.hasChannels ?? false, + hasM3UAccounts: group.hasM3UAccounts ?? false, + canEdit: group.canEdit ?? true, + canDelete: group.canDelete ?? true + }; + }); + + setGroupUsage(usage); + } catch (error) { + console.error('Error fetching group usage:', error); + } finally { + setLoading(false); + } + }, [channelGroups]); + + // Fetch group usage information when modal opens + useEffect(() => { + if (isOpen) { + fetchGroupUsage(); + } + }, [isOpen, fetchGroupUsage]); + + const handleEdit = useCallback((group) => { + setEditingGroup(group.id); + setEditName(group.name); + }, []); + + const handleSaveEdit = useCallback(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(''); + await fetchGroupUsage(); // Refresh usage data + } catch (error) { + notifications.show({ + title: 'Error', + message: 'Failed to update group', + color: 'red', + }); + } + }, [editName, editingGroup, fetchGroupUsage]); + + const handleCancelEdit = useCallback(() => { + setEditingGroup(null); + setEditName(''); + }, []); + + const handleCreate = useCallback(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); + await fetchGroupUsage(); // Refresh usage data + } catch (error) { + notifications.show({ + title: 'Error', + message: 'Failed to create group', + color: 'red', + }); + } + }, [newGroupName, fetchGroupUsage]); + + const executeDeleteGroup = useCallback(async (group) => { + try { + await API.deleteChannelGroup(group.id); + + notifications.show({ + title: 'Success', + message: 'Group deleted successfully', + color: 'green', + }); + + await fetchGroupUsage(); // Refresh usage data + setConfirmDeleteOpen(false); + } catch (error) { + notifications.show({ + title: 'Error', + message: 'Failed to delete group', + color: 'red', + }); + setConfirmDeleteOpen(false); + } + }, [fetchGroupUsage]); + + const handleDelete = useCallback(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; + } + + // Store group for confirmation dialog + setGroupToDelete(group); + + // Skip warning if it's been suppressed + if (isWarningSuppressed('delete-group')) { + return executeDeleteGroup(group); + } + + setConfirmDeleteOpen(true); + }, [groupUsage, isWarningSuppressed, executeDeleteGroup]); + + const executeCleanup = useCallback(async () => { + setIsCleaningUp(true); + try { + const result = await API.cleanupUnusedChannelGroups(); + + notifications.show({ + title: 'Cleanup Complete', + message: `Successfully deleted ${result.deleted_count} unused groups`, + color: 'green', + }); + + await fetchGroupUsage(); // Refresh usage data + setConfirmCleanupOpen(false); + } catch (error) { + notifications.show({ + title: 'Cleanup Failed', + message: 'Failed to cleanup unused groups', + color: 'red', + }); + setConfirmCleanupOpen(false); + } finally { + setIsCleaningUp(false); + } + }, [fetchGroupUsage]); + + const handleCleanup = useCallback(async () => { + // Skip warning if it's been suppressed + if (isWarningSuppressed('cleanup-groups')) { + return executeCleanup(); + } + + setConfirmCleanupOpen(true); + }, [isWarningSuppressed, executeCleanup]); + + const handleNewGroupNameChange = useCallback((e) => { + setNewGroupName(e.target.value); + }, []); + + const handleEditNameChange = useCallback((e) => { + setEditName(e.target.value); + }, []); + + const handleSearchChange = useCallback((e) => { + setSearchTerm(e.target.value); + }, []); + + if (!isOpen) return null; + + return ( + <> + + + } color="blue" variant="light"> + Manage channel groups. Groups associated with M3U accounts or containing channels cannot be deleted. + + + {/* Create new group section */} + + {isCreating ? ( + + e.key === 'Enter' && handleCreate()} + autoFocus + /> + + + + { + setIsCreating(false); + setNewGroupName(''); + }}> + + + + ) : ( + + )} + + {!isCreating && ( + + )} + + + + + {/* Filter Controls */} + + + + + Filter Groups + + setSearchTerm('')} + > + + + )} + /> + + + + Show: + + + + Channel Groups ({filterCounts.channels}) + + + + + + M3U Groups ({filterCounts.m3u}) + + + + Unused Groups ({filterCounts.unused}) + + + + + + + {/* Existing groups */} + + + Groups ({filteredGroups.length}{(searchTerm || !showChannelGroups || !showM3UGroups || !showUnusedGroups) && ` of ${sortedGroups.length}`}) + + + {loading ? ( + Loading group information... + ) : filteredGroups.length === 0 ? ( + + {searchTerm || !showChannelGroups || !showM3UGroups || !showUnusedGroups ? 'No groups found matching your filters' : 'No groups found'} + + ) : ( + + {filteredGroups.map((group) => ( + + ))} + + )} + + + + + + + + + + + setConfirmDeleteOpen(false)} + onConfirm={() => groupToDelete && executeDeleteGroup(groupToDelete)} + title="Confirm Group Deletion" + message={ + groupToDelete ? ( +
+ {`Are you sure you want to delete the following group? + +Name: ${groupToDelete.name} + +This action cannot be undone.`} +
+ ) : ( + 'Are you sure you want to delete this group? This action cannot be undone.' + ) + } + confirmLabel="Delete" + cancelLabel="Cancel" + actionKey="delete-group" + onSuppressChange={suppressWarning} + size="md" + zIndex={2100} + /> + + setConfirmCleanupOpen(false)} + onConfirm={executeCleanup} + title="Confirm Group Cleanup" + message={ +
+ {`Are you sure you want to cleanup all unused groups? + +This will permanently delete all groups that are not associated with any channels or M3U accounts. + +This action cannot be undone.`} +
+ } + confirmLabel="Cleanup" + cancelLabel="Cancel" + actionKey="cleanup-groups" + onSuppressChange={suppressWarning} + size="md" + zIndex={2100} + /> + + ); +}); + +export default GroupManager; diff --git a/frontend/src/components/tables/ChannelsTable.jsx b/frontend/src/components/tables/ChannelsTable.jsx index 7a9d5007..077602ad 100644 --- a/frontend/src/components/tables/ChannelsTable.jsx +++ b/frontend/src/components/tables/ChannelsTable.jsx @@ -216,6 +216,9 @@ const ChannelRowActions = React.memo( const ChannelsTable = ({ }) => { const theme = useMantineTheme(); + const channelGroups = useChannelsStore((s) => s.channelGroups); + const canEditChannelGroup = useChannelsStore((s) => s.canEditChannelGroup); + const canDeleteChannelGroup = useChannelsStore((s) => s.canDeleteChannelGroup); /** * STORES @@ -241,7 +244,6 @@ const ChannelsTable = ({ }) => { const channels = useChannelsStore((s) => s.channels); const profiles = useChannelsStore((s) => s.profiles); const selectedProfileId = useChannelsStore((s) => s.selectedProfileId); - const channelGroups = useChannelsStore((s) => s.channelGroups); const logos = useChannelsStore((s) => s.logos); const [tablePrefs, setTablePrefs] = useLocalStorage('channel-table-prefs', { pageSize: 50, diff --git a/frontend/src/components/tables/ChannelsTable/ChannelTableHeader.jsx b/frontend/src/components/tables/ChannelsTable/ChannelTableHeader.jsx index 8813ceda..72372cc7 100644 --- a/frontend/src/components/tables/ChannelsTable/ChannelTableHeader.jsx +++ b/frontend/src/components/tables/ChannelsTable/ChannelTableHeader.jsx @@ -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); @@ -284,22 +287,25 @@ const ChannelTableHeader = ({ selectedTableIds.length == 0 || authUser.user_level != USER_LEVELS.ADMIN } + onClick={() => setAssignNumbersModalOpen(true)} > - setAssignNumbersModalOpen(true)} - > - Assign #s - + Assign #s } disabled={authUser.user_level != USER_LEVELS.ADMIN} + onClick={matchEpg} > - - Auto-Match - + Auto-Match + + + } + disabled={authUser.user_level != USER_LEVELS.ADMIN} + onClick={() => setGroupManagerOpen(true)} + > + Edit Groups @@ -312,6 +318,11 @@ const ChannelTableHeader = ({ onClose={closeAssignChannelNumbersModal} /> + setGroupManagerOpen(false)} + /> + setConfirmDeleteProfileOpen(false)} diff --git a/frontend/src/store/channels.jsx b/frontend/src/store/channels.jsx index beb62fe1..40791cf4 100644 --- a/frontend/src/store/channels.jsx +++ b/frontend/src/store/channels.jsx @@ -46,16 +46,24 @@ const useChannelsStore = create((set, get) => ({ }, fetchChannelGroups: async () => { - set({ isLoading: true, error: null }); try { const channelGroups = await api.getChannelGroups(); - set({ - channelGroups: channelGroups.reduce((acc, group) => { - acc[group.id] = group; - return acc; - }, {}), - isLoading: false, - }); + + // Process groups to add association flags + const processedGroups = channelGroups.reduce((acc, group) => { + acc[group.id] = { + ...group, + hasChannels: group.channel_count > 0, + hasM3UAccounts: group.m3u_account_count > 0, + canEdit: group.m3u_account_count === 0, + canDelete: group.channel_count === 0 && group.m3u_account_count === 0 + }; + return acc; + }, {}); + + set((state) => ({ + channelGroups: processedGroups, + })); } catch (error) { console.error('Failed to fetch channel groups:', error); set({ error: 'Failed to load channel groups.', isLoading: false }); @@ -204,10 +212,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 { @@ -427,6 +443,17 @@ const useChannelsStore = create((set, get) => ({ set({ error: 'Failed to load recordings.', isLoading: false }); } }, + + // Add helper methods for validation + canEditChannelGroup: (groupIdOrGroup) => { + const groupId = typeof groupIdOrGroup === 'object' ? groupIdOrGroup.id : groupIdOrGroup; + return get().channelGroups[groupId]?.canEdit ?? true; + }, + + canDeleteChannelGroup: (groupIdOrGroup) => { + const groupId = typeof groupIdOrGroup === 'object' ? groupIdOrGroup.id : groupIdOrGroup; + return get().channelGroups[groupId]?.canDelete ?? true; + }, })); export default useChannelsStore;