Files
Meeting_Assistant/frontend/src/pages/DashboardPage.jsx
beabigegg 0fee703b84 back
2025-08-17 15:26:44 +08:00

292 lines
13 KiB
JavaScript

import React, { useState, useEffect, useMemo, useCallback } from 'react';
import { useNavigate } from 'react-router-dom';
import {
Box, Typography, Paper, Table, TableBody, TableCell, TableContainer, TableHead, TableRow,
CircularProgress, Alert, Button, TextField, TableSortLabel, Select, MenuItem, FormControl,
InputLabel, Chip, OutlinedInput, IconButton
} from '@mui/material';
import AddCircleOutlineIcon from '@mui/icons-material/AddCircleOutline';
import DeleteIcon from '@mui/icons-material/Delete';
import { getMeetings, updateMeeting, deleteMeeting, createMeeting } from '../services/api';
import { useAuth } from '../contexts/AuthContext';
import NewMeetingDialog from '../components/NewMeetingDialog';
// Helper function for sorting
function descendingComparator(a, b, orderBy) {
if (b[orderBy] < a[orderBy]) return -1;
if (b[orderBy] > a[orderBy]) return 1;
return 0;
}
function getComparator(order, orderBy) {
return order === 'desc'
? (a, b) => descendingComparator(a, b, orderBy)
: (a, b) => -descendingComparator(a, b, orderBy);
}
const DashboardPage = () => {
const navigate = useNavigate();
const { user } = useAuth();
const [meetings, setMeetings] = useState([]);
const [loading, setLoading] = useState(true);
const [error, setError] = useState('');
const [deletingIds, setDeletingIds] = useState(new Set());
const [isModalOpen, setIsModalOpen] = useState(false);
// State for filtering and searching
const [topicSearch, setTopicSearch] = useState('');
const [ownerSearch, setOwnerSearch] = useState('');
const [statusFilter, setStatusFilter] = useState([]);
// State for sorting
const [order, setOrder] = useState('asc');
const [orderBy, setOrderBy] = useState('meeting_date');
const fetchMeetings = useCallback(async () => {
try {
const data = await getMeetings();
setMeetings(data);
} catch (err) {
setError('Could not fetch meetings.');
} finally {
setLoading(false);
}
}, []);
useEffect(() => {
fetchMeetings();
}, [fetchMeetings]);
const handleCreateMeeting = async (topic, meetingDate) => {
try {
const newMeeting = await createMeeting(topic, new Date(meetingDate).toISOString());
setIsModalOpen(false);
navigate(`/meeting/${newMeeting.id}`);
} catch (err) {
throw new Error(err.response?.data?.error || "Failed to create meeting.");
}
};
const handleSortRequest = (property) => {
const isAsc = orderBy === property && order === 'asc';
setOrder(isAsc ? 'desc' : 'asc');
setOrderBy(property);
};
const handleStatusChange = async (meetingId, newStatus) => {
try {
await updateMeeting(meetingId, { status: newStatus });
fetchMeetings();
} catch (err) {
setError(`Failed to update status for meeting ${meetingId}.`);
}
};
const handleDelete = async (meetingId) => {
if (window.confirm('Are you sure you want to delete this meeting?')) {
setDeletingIds(prev => new Set(prev).add(meetingId));
try {
await deleteMeeting(meetingId);
fetchMeetings();
} catch (err) {
setError(`Failed to delete meeting ${meetingId}.`);
} finally {
setDeletingIds(prev => {
const newSet = new Set(prev);
newSet.delete(meetingId);
return newSet;
});
}
}
};
const uniqueStatuses = useMemo(() => {
const statuses = new Set(meetings.map(m => m.status));
return Array.from(statuses);
}, [meetings]);
const filteredAndSortedMeetings = useMemo(() => {
let filtered = meetings.filter(meeting => {
const topicMatch = meeting.topic.toLowerCase().includes(topicSearch.toLowerCase());
const ownerMatch = meeting.owner_name ? meeting.owner_name.toLowerCase().includes(ownerSearch.toLowerCase()) : ownerSearch === '';
const statusMatch = statusFilter.length === 0 || statusFilter.includes(meeting.status);
return topicMatch && ownerMatch && statusMatch;
});
return filtered.sort(getComparator(order, orderBy));
}, [meetings, topicSearch, ownerSearch, statusFilter, order, orderBy]);
if (loading) {
return <Box sx={{ display: 'flex', justifyContent: 'center', mt: 4 }}><CircularProgress /></Box>;
}
const headCells = [
{ id: 'topic', label: 'Topic' },
{ id: 'owner_name', label: 'Owner' },
{ id: 'meeting_date', label: 'Meeting Date' },
{ id: 'status', label: 'Status' },
{ id: 'action_item_count', label: 'Action Items' },
{ id: 'created_at', label: 'Created At' },
];
const statusColorMap = {
'Completed': 'success',
'In Progress': 'info',
'To Do': 'warning',
'Failed': 'error',
};
const allPossibleStatuses = ['To Do', 'In Progress', 'Completed', 'Failed'];
return (
<Box>
<Box sx={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', mb: 2 }}>
<Typography variant="h4" gutterBottom>Dashboard</Typography>
<Button variant="contained" startIcon={<AddCircleOutlineIcon />} onClick={() => setIsModalOpen(true)}>
New Meeting
</Button>
</Box>
<NewMeetingDialog
open={isModalOpen}
onClose={() => setIsModalOpen(false)}
onCreate={handleCreateMeeting}
/>
{error && <Alert severity="error" sx={{ mb: 2, mt: 2 }} onClose={() => setError('')}>{error}</Alert>}
<Paper sx={{ p: 2, mb: 2 }}>
<Box sx={{ display: 'flex', gap: 2, flexDirection: { xs: 'column', md: 'row' } }}>
<TextField
label="Search by Topic"
variant="outlined"
value={topicSearch}
onChange={(e) => setTopicSearch(e.target.value)}
sx={{ flex: '1 1 40%' }}
/>
<TextField
label="Search by Owner"
variant="outlined"
value={ownerSearch}
onChange={(e) => setOwnerSearch(e.target.value)}
sx={{ flex: '1 1 30%' }}
/>
<FormControl sx={{ flex: '1 1 30%' }}>
<InputLabel>Filter by Status</InputLabel>
<Select
multiple
value={statusFilter}
onChange={(e) => setStatusFilter(e.target.value)}
input={<OutlinedInput label="Filter by Status" />}
renderValue={(selected) => (
<Box sx={{ display: 'flex', flexWrap: 'wrap', gap: 0.5 }}>
{selected.map((value) => <Chip key={value} label={value} />)}
</Box>
)}
>
{uniqueStatuses.map((status) => (
<MenuItem key={status} value={status}>{status}</MenuItem>
))}
</Select>
</FormControl>
</Box>
</Paper>
<TableContainer component={Paper}>
<Table>
<TableHead>
<TableRow>
{headCells.map((headCell) => (
<TableCell key={headCell.id} sortDirection={orderBy === headCell.id ? order : false}>
<TableSortLabel
active={orderBy === headCell.id}
direction={orderBy === headCell.id ? order : 'asc'}
onClick={() => handleSortRequest(headCell.id)}
>
{headCell.label}
</TableSortLabel>
</TableCell>
))}
<TableCell align="center">Actions</TableCell>
</TableRow>
</TableHead>
<TableBody>
{filteredAndSortedMeetings.map((meeting) => {
const isOwnerOrAdmin = user && (user.role === 'admin' || String(user.id) === String(meeting.created_by_id));
const isDeleting = deletingIds.has(meeting.id);
let taipeiTime = 'N/A';
if (meeting.created_at) {
taipeiTime = new Date(meeting.created_at).toLocaleString('zh-TW', {
timeZone: 'Asia/Taipei',
year: 'numeric',
month: '2-digit',
day: '2-digit',
hour: '2-digit',
minute: '2-digit',
second: '2-digit',
hour12: false
});
}
return (
<TableRow key={meeting.id} hover>
<TableCell>{meeting.topic}</TableCell>
<TableCell>{meeting.owner_name || 'N/A'}</TableCell>
<TableCell>{meeting.meeting_date ? new Date(meeting.meeting_date).toLocaleDateString() : 'N/A'}</TableCell>
<TableCell>
<FormControl size="small" sx={{ minWidth: 120 }}>
<Select
value={meeting.status}
onChange={(e) => handleStatusChange(meeting.id, e.target.value)}
readOnly={!isOwnerOrAdmin}
IconComponent={!isOwnerOrAdmin ? () => null : undefined}
renderValue={(selected) => (
<Chip
label={selected}
color={statusColorMap[selected] || 'default'}
size="small"
/>
)}
sx={{
'& .MuiOutlinedInput-notchedOutline': { border: 'none' },
backgroundColor: 'transparent',
}}
>
{allPossibleStatuses.map(status => (
<MenuItem key={status} value={status}>
{status}
</MenuItem>
))}
</Select>
</FormControl>
</TableCell>
<TableCell>{meeting.action_item_count}</TableCell>
<TableCell>{taipeiTime}</TableCell>
<TableCell align="center">
<Box sx={{ display: 'flex', justifyContent: 'center', alignItems: 'center' }}>
<Button size="small" onClick={() => navigate(`/meeting/${meeting.id}`)}>
View Details
</Button>
{isOwnerOrAdmin ? (
<IconButton size="small" onClick={() => handleDelete(meeting.id)} color="error" disabled={isDeleting}>
{isDeleting ? <CircularProgress size={20} /> : <DeleteIcon />}
</IconButton>
) : (
// Invisible placeholder to maintain alignment
<Box sx={{ width: 34, height: 34 }} />
)}
</Box>
</TableCell>
</TableRow>
);
})}
</TableBody>
</Table>
</TableContainer>
</Box>
);
};
export default DashboardPage;