Press n or j to go to the next uncovered block, b, p or k for the previous block.
| 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 123 124 125 126 127 128 129 130 131 132 133 134 135 136 137 138 139 140 141 142 143 144 145 146 147 148 149 150 151 152 153 154 155 156 157 158 159 160 161 162 163 164 165 166 167 168 169 170 171 172 173 174 175 176 177 178 179 180 181 182 183 184 185 186 187 188 189 190 191 192 193 194 195 196 197 198 199 200 201 202 203 204 205 206 207 208 209 210 211 212 213 214 215 216 217 218 219 220 221 222 223 224 225 226 227 228 229 230 231 232 233 234 235 236 237 238 239 240 241 242 243 244 245 246 247 248 249 250 251 252 253 254 255 256 257 258 259 260 261 262 263 264 265 266 267 268 269 270 271 272 273 274 275 276 277 278 279 280 281 282 283 284 285 286 287 288 289 290 291 292 293 294 295 296 297 298 299 300 301 302 303 304 305 306 307 308 309 310 311 312 313 314 315 316 317 318 319 320 321 322 323 324 325 326 327 328 329 330 331 332 333 334 335 336 337 338 339 340 341 342 343 344 345 346 347 348 349 350 351 352 353 354 355 356 357 358 359 | 1x 1x 29x 29x 29x 29x 29x 29x 29x 29x 29x 29x 29x 29x 29x 22x 29x 29x 18x 18x 18x 29x 18x 29x 1x 29x 4x 29x 2x 1x 2x 29x 29x 29x 29x 3x 3x 1x | /**
* Topic Backlog Manager Component (Story 5.2 - AC1-8)
*
* Main interface for topic selection with:
* - Searchable topic list with filtering
* - Heat map visualization
* - Staleness scoring and color coding
* - Duplicate detection and warnings
* - Topic selection for events
*/
import React, { useState, useMemo, useEffect } from 'react';
import { keyframes } from '@emotion/react';
import { useNavigate } from 'react-router-dom';
import {
Container,
Grid,
Paper,
Typography,
Box,
Alert,
Button,
ToggleButtonGroup,
ToggleButton,
} from '@mui/material';
import {
Add as AddIcon,
ViewModule as HeatMapIcon,
ViewList as ListIcon,
BubbleChart as BubbleChartIcon,
} from '@mui/icons-material';
import { useTranslation } from 'react-i18next';
import { BATbernLoader } from '@components/shared/BATbernLoader';
import { useTopics, useTopic } from '@/hooks/useTopics';
import { useEvent, useEvents } from '@/hooks/useEvents';
import { useUserList } from '@/hooks/useUserManagement/useUserList';
import type { Event } from '@/types/event.types';
import { TopicFilterPanel } from './TopicFilterPanel';
import { TopicList } from './TopicList';
import { TopicDetailsPanel } from './TopicDetailsPanel';
import { CreateTopicModal } from './CreateTopicModal';
import { SpeakerBrainstormingPanel } from '@/components/SpeakerBrainstormingPanel/SpeakerBrainstormingPanel';
import { MultiTopicHeatMap } from '@/components/TopicHeatMap';
import type { Topic, TopicFilters } from '@/types/topic.types';
// Blob-shaped wobble animation for the BlobSelector navigation button
const blobWobble = keyframes`
0%, 100% { border-radius: 60% 40% 55% 45% / 45% 50% 50% 55%; }
25% { border-radius: 50% 50% 40% 60% / 55% 45% 45% 55%; }
50% { border-radius: 40% 60% 55% 45% / 50% 55% 50% 45%; }
75% { border-radius: 55% 45% 45% 55% / 40% 60% 55% 45%; }
`;
type ViewMode = 'heatMap' | 'list';
export interface TopicBacklogManagerProps {
eventCode?: string; // Optional: if provided, enables topic selection for event
onTopicSelected?: (topicId: string) => void;
}
export const TopicBacklogManager: React.FC<TopicBacklogManagerProps> = ({
eventCode,
onTopicSelected,
}) => {
const { t } = useTranslation('organizer');
const navigate = useNavigate();
const [filters, setFilters] = useState<TopicFilters>({
page: 1,
limit: 20,
sort: '-stalenessScore', // Default: sort by staleness descending (safest first)
include: 'history', // Include usage history for heat map visualization
});
const [selectedTopic, setSelectedTopic] = useState<Topic | null>(null);
const [topicConfirmed, setTopicConfirmed] = useState(false);
const [createModalOpen, setCreateModalOpen] = useState(false);
const [editingTopic, setEditingTopic] = useState<Topic | null>(null);
const [viewMode, setViewMode] = useState<ViewMode>('list');
// Fetch topics with current filters
const { data, isLoading, isError, error } = useTopics(filters);
// Fetch event details if eventCode is provided
const { data: eventData } = useEvent(eventCode);
// Fetch pre-assigned topic if event has topicCode (Story 5.2 - Topic preselection)
// ADR-003: Event now includes topicCode (meaningful identifier) for frontend use
const { data: preassignedTopic } = useTopic(eventData?.topicCode || '', 'history');
// Fetch organizer users for speaker assignment (Story 5.2)
const { data: organizersData } = useUserList({
filters: { role: ['ORGANIZER'] },
pagination: { page: 1, limit: 100 },
});
// Preselect topic when event loads with topicId
useEffect(() => {
Iif (preassignedTopic && !selectedTopic) {
setSelectedTopic(preassignedTopic);
setTopicConfirmed(true); // Also confirm topic to show speaker panel
}
}, [preassignedTopic, selectedTopic]);
// Fetch all events for heat map (to show event numbers and titles)
const { data: allEventsData } = useEvents({ page: 1, limit: 1000 }); // Fetch many events for lookup
// Create event lookup map (eventNumber -> Event info)
const eventLookup = useMemo(() => {
const map = new Map<number, { title: string }>();
Iif (allEventsData?.data) {
allEventsData.data.forEach((event: Event) => {
map.set(event.eventNumber, {
title: event.title,
});
});
}
return map;
}, [allEventsData]);
// Transform organizers from API to format needed by SpeakerBrainstormingPanel
const organizers = useMemo(() => {
Eif (!organizersData?.data) return [];
return organizersData.data.map((user) => ({
id: user.id,
name: `${user.firstName} ${user.lastName}`.trim() || user.id,
}));
}, [organizersData]);
const handleFilterChange = (newFilters: TopicFilters) => {
setFilters((prev) => ({ ...prev, ...newFilters, page: 1 })); // Reset to page 1 on filter change
};
const handleTopicSelect = (topic: Topic) => {
setSelectedTopic(topic);
};
const handleTopicConfirm = (topicId: string) => {
if (onTopicSelected) {
onTopicSelected(topicId);
}
setTopicConfirmed(true); // Show speaker brainstorming panel
};
const handleSpeakerBrainstormComplete = () => {
// Navigate to event page with speakers tab if eventCode exists, otherwise to events dashboard
if (eventCode) {
navigate(`/organizer/events/${eventCode}?tab=speakers`);
} else {
navigate('/organizer/events');
}
};
const handleEditTopic = (topic: Topic) => {
setEditingTopic(topic);
setCreateModalOpen(true);
};
const handleModalClose = () => {
setCreateModalOpen(false);
setEditingTopic(null);
};
return (
<Container maxWidth="xl" data-testid="topic-backlog-manager">
{/* Title section - always visible, shows event context if available */}
<Box sx={{ mb: 3 }}>
<Typography variant="h4" component="h1" gutterBottom>
{eventCode && eventData
? t('topicBacklog.breadcrumbs.topicSelection', 'Topic Selection')
: t('topicBacklog.title', 'Topic Backlog Manager')}
</Typography>
{eventCode && eventData && (
<Typography variant="body1" color="text.secondary">
{t(
'topicBacklog.subtitle',
'Select topics from the backlog with intelligent suggestions and staleness detection'
)}
</Typography>
)}
</Box>
{/* View Mode Toggle and Create Topic button */}
<Box sx={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', mb: 2 }}>
<ToggleButtonGroup
value={viewMode}
exclusive
onChange={(_event, newMode) => {
Eif (newMode !== null) {
setViewMode(newMode);
}
}}
aria-label={t('topicBacklog.viewMode.label', 'View mode')}
size="small"
>
<ToggleButton
value="heatMap"
aria-label={t('topicBacklog.viewMode.heatMap', 'Heat Map')}
data-testid="view-mode-heatmap"
>
<HeatMapIcon sx={{ mr: 0.5 }} />
{t('topicBacklog.viewMode.heatMap', 'Heat Map')}
</ToggleButton>
<ToggleButton
value="list"
aria-label={t('topicBacklog.viewMode.list', 'List')}
data-testid="view-mode-list"
>
<ListIcon sx={{ mr: 0.5 }} />
{t('topicBacklog.viewMode.list', 'List')}
</ToggleButton>
</ToggleButtonGroup>
<Box sx={{ display: 'flex', gap: 1 }}>
{eventCode && (
<Box
component="button"
onClick={() => navigate(`/organizer/events/${eventCode}/topic-blob`)}
data-testid="blob-selector-button"
sx={{
animation: `${blobWobble} 4s ease-in-out infinite`,
borderRadius: '60% 40% 55% 45% / 45% 50% 50% 55%',
background: 'linear-gradient(135deg, #0d1b3e 0%, #1565c0 100%)',
color: '#fff',
border: '1px solid rgba(100,181,246,0.45)',
px: 3,
py: 1.1,
cursor: 'pointer',
display: 'inline-flex',
alignItems: 'center',
gap: 1,
fontSize: '0.875rem',
fontWeight: 500,
fontFamily: 'inherit',
boxShadow: '0 0 14px rgba(66,165,245,0.4), inset 0 0 10px rgba(255,255,255,0.05)',
transition: 'box-shadow 0.25s, filter 0.25s',
'&:hover': {
boxShadow: '0 0 24px rgba(66,165,245,0.7), inset 0 0 16px rgba(255,255,255,0.08)',
filter: 'brightness(1.15)',
},
'&:active': { filter: 'brightness(0.88)' },
}}
>
<BubbleChartIcon sx={{ fontSize: '1.2rem' }} />
{t('navigation.blobSelector', 'Blob Selector')}
</Box>
)}
<Button
variant="contained"
startIcon={<AddIcon />}
onClick={() => setCreateModalOpen(true)}
data-testid="new-topic-button"
>
{t('topicBacklog.createNew', 'Create New Topic')}
</Button>
</Box>
</Box>
<Grid container spacing={3}>
{/* Left Panel: Filters (3 cols → 2 cols when speaker panel visible) */}
<Grid size={{ xs: 12, md: eventCode && topicConfirmed ? 2 : 3 }}>
<TopicFilterPanel filters={filters} onFilterChange={handleFilterChange} />
</Grid>
{/* Center Panel: Topic List/Heat Map/Board (5 cols → 4 cols when speaker panel visible) */}
<Grid size={{ xs: 12, md: eventCode && topicConfirmed ? 4 : 5 }}>
{isLoading && (
<Paper
sx={{
p: 2,
height: '70vh',
display: 'flex',
justifyContent: 'center',
alignItems: 'center',
}}
>
<BATbernLoader size={96} />
</Paper>
)}
{isError && (
<Paper sx={{ p: 2, height: '70vh' }}>
<Alert severity="error">
{t('topicBacklog.error.loadFailed', 'Failed to load topics')}: {error?.message}
</Alert>
</Paper>
)}
{data && (
<>
{/* Heat Map View */}
{viewMode === 'heatMap' && (
<MultiTopicHeatMap
topics={data.data}
selectedTopicId={selectedTopic?.topicCode}
onTopicSelect={handleTopicSelect}
eventLookup={eventLookup}
/>
)}
{/* List View */}
{viewMode === 'list' && (
<Paper sx={{ p: 2, height: '70vh', overflow: 'auto' }}>
<TopicList
topics={data.data}
selectedTopicId={selectedTopic?.topicCode}
onTopicSelect={handleTopicSelect}
pagination={data.pagination}
onPageChange={(page) => setFilters((prev) => ({ ...prev, page }))}
/>
</Paper>
)}
</>
)}
</Grid>
{/* Right Panel: Topic Details (4 cols → 3 cols when speaker panel visible) */}
<Grid size={{ xs: 12, md: eventCode && topicConfirmed ? 3 : 4 }}>
{selectedTopic ? (
<TopicDetailsPanel
topic={selectedTopic}
eventCode={eventCode}
onTopicConfirm={handleTopicConfirm}
onEditTopic={handleEditTopic}
/>
) : (
<Paper sx={{ p: 3, textAlign: 'center' }}>
<Typography variant="body1" color="text.secondary">
{t('topicBacklog.selectPrompt', 'Select a topic to view details')}
</Typography>
</Paper>
)}
</Grid>
{/* NEW: Speaker Brainstorming Panel (3 cols, only shown when topic confirmed) */}
{eventCode && topicConfirmed && selectedTopic && (
<Grid size={{ xs: 12, md: 3 }}>
<SpeakerBrainstormingPanel
eventCode={eventCode}
organizers={organizers}
onContinue={handleSpeakerBrainstormComplete}
/>
</Grid>
)}
</Grid>
{/* Create/Edit Topic Modal */}
<CreateTopicModal
open={createModalOpen}
topic={editingTopic}
onClose={handleModalClose}
onSuccess={() => {
// Topics list will auto-refresh via query invalidation
}}
/>
</Container>
);
};
export default TopicBacklogManager;
|