292 lines
13 KiB
JavaScript
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;
|