back
This commit is contained in:
291
frontend/src/pages/DashboardPage.jsx
Normal file
291
frontend/src/pages/DashboardPage.jsx
Normal file
@@ -0,0 +1,291 @@
|
||||
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;
|
||||
Reference in New Issue
Block a user