Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
61 changes: 16 additions & 45 deletions src/components/import/sources/FixedChannelsSource.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,7 @@ import { formatPlural } from '../../../utils/formatPlural';
import { useImportStores } from '../../../hooks/useImportStores';
import { getNextChannelNumber } from '../../../utils/importHelpers';
import { getAvailableFixedChannelSets, getChannelsForSet } from '../../../services/fixedChannels';
import { mergeOverlappingChannels, getChannelFullKey } from '../../../services/channelMerger';
import { mergeChannelSetsWithExisting } from '../../../services/channelMerger';
import { generateZoneId } from '../../../utils/zoneHelpers';
import type { Channel } from '../../../models';
import type { Zone } from '../../../models';
Expand Down Expand Up @@ -40,60 +40,31 @@ export const FixedChannelsSource: React.FC<FixedChannelsSourceProps> = ({
try {
const nextChannelNumber = getNextChannelNumber(channels);

// Generate channels for each selected set (with temporary numbers)
// Generate channels for each selected set. Each set gets a distinct
// temporary number range — the merge mapping is keyed by these numbers,
// so ranges that overlap between sets would clobber each other and
// scramble the zones built below.
const channelSets: Channel[][] = [];
const setNames: string[] = [];
let tempNumber = 1;

for (const setName of selectedFixedSets) {
// Use generic function to get channels for any set
const setChannels = getChannelsForSet(setName, 1);
const setChannels = getChannelsForSet(setName, tempNumber);

if (setChannels.length > 0) {
channelSets.push(setChannels);
setNames.push(setName);
tempNumber += setChannels.length;
}
}

// FIRST: Check against existing channels and build mapping for duplicates
// Match on ALL settings: frequency, name, mode, bandwidth, power, CTCSS/DCS
const existingChannelMap = new Map<string, number>(); // full key -> channel number
for (const ch of channels) {
const fullKey = getChannelFullKey(ch);
existingChannelMap.set(fullKey, ch.number);
}

// Merge overlapping channels (within new sets only)
const { mergedChannels, channelMapping } = mergeOverlappingChannels(channelSets, nextChannelNumber);

// Update mapping to use existing channels where ALL settings match
const finalChannelMapping = new Map<number, number>();
const channelsToAdd: Channel[] = [];

for (const newChannel of mergedChannels) {
const fullKey = getChannelFullKey(newChannel);

if (existingChannelMap.has(fullKey)) {
// This exact channel already exists - use existing channel
const existingChannelNum = existingChannelMap.get(fullKey)!;

// Update all mappings that point to this merged channel
for (const [origNum, mergedNum] of channelMapping.entries()) {
if (mergedNum === newChannel.number) {
finalChannelMapping.set(origNum, existingChannelNum);
}
}
} else {
// New unique channel (or different settings) - add it
channelsToAdd.push(newChannel);

// Copy mapping as-is for this channel
for (const [origNum, mergedNum] of channelMapping.entries()) {
if (mergedNum === newChannel.number) {
finalChannelMapping.set(origNum, newChannel.number);
}
}
}
}
// Merge overlaps within the new sets and dedupe against existing channels
// (a new channel is reused only when ALL settings match an existing one).
const { channelsToAdd, channelMapping } = mergeChannelSetsWithExisting(
channels,
channelSets,
nextChannelNumber
);

// Create zones with final channel numbers
const newZones: Zone[] = [];
Expand All @@ -103,7 +74,7 @@ export const FixedChannelsSource: React.FC<FixedChannelsSourceProps> = ({

// Map original channel numbers to final channel numbers
const zoneChannelNumbers = setChannels
.map(ch => finalChannelMapping.get(ch.number))
.map(ch => channelMapping.get(ch.number))
.filter((num): num is number => num !== undefined)
.sort((a, b) => a - b);

Expand Down
34 changes: 25 additions & 9 deletions src/components/import/sources/RptrsSource.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,7 @@ import { formatPlural } from '../../../utils/formatPlural';
import { useImportStores } from '../../../hooks/useImportStores';
import { getNextChannelNumber, selectionCardClass } from '../../../utils/importHelpers';
import { generateRptrsChannels } from '../../../services/rptrsChannels';
import { mergeOverlappingChannels } from '../../../services/channelMerger';
import { mergeChannelSetsWithExisting } from '../../../services/channelMerger';
import { convertRptrFrequency, type RptrData } from '../../../data/rptrsData';
import { SelectAllButtons } from '../SelectAllButtons';
import { Button } from '../../ui/Button';
Expand Down Expand Up @@ -88,17 +88,33 @@ export const RptrsSource: React.FC<RptrsSourceProps> = ({
return;
}

// Merge with existing channels to avoid duplicates
const mergedResult = mergeOverlappingChannels([channels, result.channels]);
setChannels(mergedResult.mergedChannels);
// Merge overlaps within the new channels and dedupe against existing ones.
// Existing channels are never renumbered — renumbering them would break
// every zone and scan list that references them.
const { channelsToAdd, channelMapping } = mergeChannelSetsWithExisting(
channels,
[result.channels],
nextChannelNumber
);
setChannels([...channels, ...channelsToAdd]);

// Add zones
const updatedZones = [...zones, ...result.zones];
setZones(updatedZones);
// Remap the generated zones through the merge mapping: a new channel that
// collapsed into another (or matched an existing channel) changed number.
const remappedZones = result.zones
.map(zone => ({
...zone,
channels: [...new Set(
zone.channels
.map(num => channelMapping.get(num))
.filter((num): num is number => num !== undefined)
)].sort((a, b) => a - b),
}))
.filter(zone => zone.channels.length > 0);
setZones([...zones, ...remappedZones]);

onGenerationResult({
channels: result.channels.length,
zones: result.zones.length,
channels: channelsToAdd.length,
zones: remappedZones.length,
});

// Clear selection
Expand Down
206 changes: 29 additions & 177 deletions src/components/rxgroups/RXGroupsList.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,8 @@ import { useRXGroupsStore } from '../../store/rxGroupsStore';
import { useQuickContactsStore } from '../../store/quickContactsStore';
import type { RXGroup } from '../../models/RXGroup';
import { ListDetailLayout } from '../ui/ListDetailLayout';
import { OrderedItemPicker } from '../ui/OrderedItemPicker';
import type { PickerItem } from '../ui/pickerItems';
import { Card } from '../ui/Card';
import { EmptyState } from '../ui/EmptyState';
import { ConfirmModal } from '../ui/ConfirmModal';
Expand Down Expand Up @@ -240,77 +242,21 @@ interface RXGroupEditorProps {
const RXGroupEditor: React.FC<RXGroupEditorProps> = ({ group, onAlert }) => {
const { updateGroup } = useRXGroupsStore();
const { contacts: talkGroups } = useQuickContactsStore();
const [searchQuery, setSearchQuery] = useState('');

const handleAddTalkGroup = (talkGroupIndex: number) => {
// Find the talk group by index to get its DMR ID (contactNumber)
const talkGroup = talkGroups.find(tg => tg.index === talkGroupIndex);
if (!talkGroup) return;

if (group.talkGroupIndices.length >= 32) {
onAlert('Maximum of 32 talk groups per RX group allowed.');
return;
}

const dmrId = talkGroup.contactNumber;
if (!group.talkGroupIndices.includes(dmrId)) {
updateGroup(group.index, {
talkGroupIndices: [...group.talkGroupIndices, dmrId].sort((a, b) => a - b),
});
}
};

const handleRemoveTalkGroup = (talkGroupIndex: number) => {
// Find the talk group by index to get its DMR ID (contactNumber)
const talkGroup = talkGroups.find(tg => tg.index === talkGroupIndex);
if (!talkGroup) return;

const dmrId = talkGroup.contactNumber;
updateGroup(group.index, {
talkGroupIndices: group.talkGroupIndices.filter(id => id !== dmrId),
});
};

const handleReorderTalkGroup = (fromIndex: number, toIndex: number) => {
// fromIndex/toIndex are array indices in groupTalkGroups array
const newPositions = [...group.talkGroupIndices];
const [removed] = newPositions.splice(fromIndex, 1);
newPositions.splice(toIndex, 0, removed);
updateGroup(group.index, { talkGroupIndices: newPositions });
};

const availableTalkGroups = talkGroups
.filter(tg => {
return !group.talkGroupIndices.includes(tg.contactNumber) &&
tg.callType === 0x04; // Only Group Call (exclude Private Call 0x03 and All Call 0x05)
})
.map(tg => tg.index)
.sort((a, b) => a - b);
// group.talkGroupIndices stores DMR IDs (contactNumber); rows display "index: name".
const talkGroupItem = (tg: (typeof talkGroups)[number]): PickerItem => ({
id: tg.contactNumber,
label: `${tg.index}: ${tg.name}`,
searchText: `${tg.index} ${tg.name} ${tg.contactNumber}`,
});

const filteredAvailableTalkGroups = searchQuery.trim()
? availableTalkGroups.filter((tgIndex) => {
const talkGroup = talkGroups.find(tg => tg.index === tgIndex);
if (!talkGroup) return false;

const query = searchQuery.toLowerCase().trim();

// Search in name
if (talkGroup.name.toLowerCase().includes(query)) return true;

// Search in index
if (talkGroup.index.toString().includes(query)) return true;

// Search in contact number
if (talkGroup.contactNumber?.toString().includes(query)) return true;

return false;
})
: availableTalkGroups;

// Find talk groups by matching DMR ID (contactNumber)
const groupTalkGroups = group.talkGroupIndices
.map(dmrId => talkGroups.find(tg => tg.contactNumber === dmrId))
.filter(tg => tg !== undefined);
const availableItems = talkGroups
.filter(tg =>
!group.talkGroupIndices.includes(tg.contactNumber) &&
tg.callType === 0x04 // Only Group Call (exclude Private Call 0x03 and All Call 0x05)
)
.sort((a, b) => a.index - b.index)
.map(talkGroupItem);

return (
<div className="p-4 space-y-4">
Expand All @@ -325,114 +271,20 @@ const RXGroupEditor: React.FC<RXGroupEditorProps> = ({ group, onAlert }) => {
placeholder="Enter group name"
/>
</div>
<div>
<h4 className="text-white font-medium mb-2">
Talk Groups in RX Group ({group.talkGroupIndices.length}/32)
</h4>
{group.talkGroupIndices.length === 0 ? (
<p className="text-cool-gray text-sm">No talk groups in this RX group</p>
) : (
<div className="space-y-1 max-h-96 overflow-y-auto">
{groupTalkGroups.map((talkGroup, index) => (
<div
key={talkGroup!.index}
className="px-3 py-2 bg-neon-cyan bg-opacity-10 border border-neon-cyan border-opacity-30 rounded flex items-center justify-between hover:bg-opacity-20"
>
<div className="flex items-center gap-2">
<span className="text-cool-gray text-xs w-8">{index + 1}.</span>
<span className="text-white text-xs">
{talkGroup!.index}: {talkGroup!.name}
</span>
</div>
<div className="flex gap-1">
{index > 0 && (
<button
onClick={() => handleReorderTalkGroup(index, index - 1)}
className="px-2 py-1 bg-deep-gray border border-neon-cyan border-opacity-30 rounded text-neon-cyan text-xs hover:bg-opacity-50"
title="Move up"
>
</button>
)}
{index < groupTalkGroups.length - 1 && (
<button
onClick={() => handleReorderTalkGroup(index, index + 1)}
className="px-2 py-1 bg-deep-gray border border-neon-cyan border-opacity-30 rounded text-neon-cyan text-xs hover:bg-opacity-50"
title="Move down"
>
</button>
)}
<button
onClick={() => {
// talkGroup.index is 1-based, handleRemoveTalkGroup expects 1-based
handleRemoveTalkGroup(talkGroup!.index);
}}
className="px-2 py-1 bg-red-600 text-white rounded text-xs hover:bg-red-700"
>
Remove
</button>
</div>
</div>
))}
</div>
)}
</div>

<div>
<h4 className="text-white font-medium mb-2">
Available Talk Groups ({filteredAvailableTalkGroups.length} of {availableTalkGroups.length})
</h4>
{availableTalkGroups.length === 0 ? (
<p className="text-cool-gray text-sm">All talk groups are in this RX group</p>
) : group.talkGroupIndices.length >= 32 ? (
<p className="text-cool-gray text-sm">RX group is full (32 talk groups maximum)</p>
) : (
<>
<div className="mb-3">
<div className="relative">
<input
type="text"
value={searchQuery}
onChange={(e) => setSearchQuery(e.target.value)}
placeholder="Search talk groups..."
className="w-full bg-transparent border border-neon-cyan border-opacity-30 rounded px-3 py-1.5 pl-9 text-white text-xs focus:outline-none focus:border-neon-cyan focus:shadow-glow-cyan"
/>
<span className="absolute left-2.5 top-1/2 transform -translate-y-1/2 text-cool-gray text-xs">
🔍
</span>
{searchQuery && (
<button
onClick={() => setSearchQuery('')}
className="absolute right-2 top-1/2 transform -translate-y-1/2 text-cool-gray hover:text-white text-sm"
title="Clear search"
>
×
</button>
)}
</div>
</div>
{filteredAvailableTalkGroups.length === 0 ? (
<p className="text-cool-gray text-sm">No talk groups match your search</p>
) : (
<div className="flex flex-wrap gap-2 max-h-80 overflow-y-auto">
{filteredAvailableTalkGroups.map((tgIndex) => {
const talkGroup = talkGroups.find(tg => tg.index === tgIndex);
return (
<button
key={tgIndex}
onClick={() => handleAddTalkGroup(tgIndex)}
className="px-3 py-1 bg-deep-gray border border-neon-cyan border-opacity-30 rounded text-white text-xs hover:bg-opacity-50 hover:border-neon-cyan transition-colors"
>
{tgIndex}: {talkGroup?.name || 'Unknown'}
</button>
);
})}
</div>
)}
</>
)}
</div>
<OrderedItemPicker
selectedIds={group.talkGroupIndices}
availableItems={availableItems}
resolveItem={(dmrId) => {
const tg = talkGroups.find(t => t.contactNumber === dmrId);
return tg ? talkGroupItem(tg) : undefined;
}}
onChange={(ids) => updateGroup(group.index, { talkGroupIndices: ids })}
maxItems={32}
itemNoun="talk group"
containerNoun="RX group"
onAlert={onAlert}
padded={false}
/>
</div>
);
};
Loading
Loading