back
This commit is contained in:
174
frontend/src/pages/ActionItemPage.jsx
Normal file
174
frontend/src/pages/ActionItemPage.jsx
Normal file
@@ -0,0 +1,174 @@
|
||||
import React, { useState, useEffect } from 'react';
|
||||
import { useParams, useNavigate } from 'react-router-dom';
|
||||
import {
|
||||
Box, Typography, Paper, CircularProgress, Alert,
|
||||
TextField, Button, Select, MenuItem, FormControl, InputLabel, Grid, Link
|
||||
} from '@mui/material';
|
||||
import { getActionItemDetails, updateActionItem, uploadAttachment } from '../services/api';
|
||||
|
||||
const ActionItemPage = () => {
|
||||
const { actionId } = useParams();
|
||||
const navigate = useNavigate();
|
||||
const [actionItem, setActionItem] = useState(null);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [error, setError] = useState('');
|
||||
const [isEditing, setIsEditing] = useState(false);
|
||||
const [attachment, setAttachment] = useState(null);
|
||||
|
||||
useEffect(() => {
|
||||
const fetchActionItem = async () => {
|
||||
setLoading(true);
|
||||
try {
|
||||
const data = await getActionItemDetails(actionId);
|
||||
setActionItem(data);
|
||||
} catch (err) {
|
||||
setError(err.message || 'Could not fetch action item details.');
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
fetchActionItem();
|
||||
}, [actionId]);
|
||||
|
||||
const handleUpdate = async () => {
|
||||
if (!actionItem) return;
|
||||
setLoading(true);
|
||||
try {
|
||||
// Only send fields that are meant to be updated
|
||||
const updateData = {
|
||||
item: actionItem.item,
|
||||
action: actionItem.action,
|
||||
status: actionItem.status,
|
||||
due_date: actionItem.due_date,
|
||||
// owner_id is typically not changed from this screen, but could be added if needed
|
||||
};
|
||||
await updateActionItem(actionId, updateData);
|
||||
|
||||
if (attachment) {
|
||||
// Note: The backend needs an endpoint to handle attachment uploads for an action item.
|
||||
// This is a placeholder for that functionality.
|
||||
// await uploadAttachment(actionId, attachment);
|
||||
console.warn("Attachment upload functionality is not yet implemented on the backend.");
|
||||
}
|
||||
|
||||
setIsEditing(false);
|
||||
// Refresh data after update
|
||||
const data = await getActionItemDetails(actionId);
|
||||
setActionItem(data);
|
||||
|
||||
} catch (err) {
|
||||
setError(err.response?.data?.error || 'Failed to update action item.');
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleFileChange = (event) => {
|
||||
setAttachment(event.target.files[0]);
|
||||
};
|
||||
|
||||
if (loading) return <Box sx={{ display: 'flex', justifyContent: 'center', p: 4 }}><CircularProgress /></Box>;
|
||||
if (error) return <Alert severity="error">{error}</Alert>;
|
||||
if (!actionItem) return <Alert severity="info">No action item found.</Alert>;
|
||||
|
||||
return (
|
||||
<Box sx={{ p: 3 }}>
|
||||
<Typography variant="h4" gutterBottom>
|
||||
Action Item for: {actionItem.meeting?.topic || 'General Task'}
|
||||
</Typography>
|
||||
<Paper sx={{ p: 3 }}>
|
||||
<Grid container spacing={2}>
|
||||
<Grid item xs={12}>
|
||||
<TextField
|
||||
label="Context/Item"
|
||||
fullWidth
|
||||
multiline
|
||||
rows={2}
|
||||
value={actionItem.item || ''}
|
||||
onChange={(e) => setActionItem({ ...actionItem, item: e.target.value })}
|
||||
InputProps={{ readOnly: !isEditing }}
|
||||
variant={isEditing ? "outlined" : "filled"}
|
||||
/>
|
||||
</Grid>
|
||||
<Grid item xs={12}>
|
||||
<TextField
|
||||
label="Action"
|
||||
fullWidth
|
||||
multiline
|
||||
rows={4}
|
||||
value={actionItem.action || ''}
|
||||
onChange={(e) => setActionItem({ ...actionItem, action: e.target.value })}
|
||||
InputProps={{ readOnly: !isEditing }}
|
||||
variant={isEditing ? "outlined" : "filled"}
|
||||
/>
|
||||
</Grid>
|
||||
<Grid item xs={12} sm={6}>
|
||||
<FormControl fullWidth variant={isEditing ? "outlined" : "filled"}>
|
||||
<InputLabel>Status</InputLabel>
|
||||
<Select
|
||||
label="Status"
|
||||
value={actionItem.status || 'pending'}
|
||||
onChange={(e) => setActionItem({ ...actionItem, status: e.target.value })}
|
||||
readOnly={!isEditing}
|
||||
>
|
||||
<MenuItem value="pending">Pending</MenuItem>
|
||||
<MenuItem value="in_progress">In Progress</MenuItem>
|
||||
<MenuItem value="completed">Completed</MenuItem>
|
||||
</Select>
|
||||
</FormControl>
|
||||
</Grid>
|
||||
<Grid item xs={12} sm={6}>
|
||||
<TextField
|
||||
label="Due Date"
|
||||
type="date"
|
||||
fullWidth
|
||||
value={actionItem.due_date || ''}
|
||||
onChange={(e) => setActionItem({ ...actionItem, due_date: e.target.value })}
|
||||
InputProps={{ readOnly: !isEditing }}
|
||||
variant={isEditing ? "outlined" : "filled"}
|
||||
InputLabelProps={{ shrink: true }}
|
||||
/>
|
||||
</Grid>
|
||||
<Grid item xs={12}>
|
||||
<Typography>Owner: {actionItem.owner?.username || 'N/A'}</Typography>
|
||||
{actionItem.attachment_path && (
|
||||
<Typography>
|
||||
Attachment: <Link href={`/api/download/${actionItem.attachment_path.split('/').pop()}`} target="_blank" rel="noopener">Download</Link>
|
||||
</Typography>
|
||||
)}
|
||||
</Grid>
|
||||
{isEditing && (
|
||||
<Grid item xs={12}>
|
||||
<Button
|
||||
variant="contained"
|
||||
component="label"
|
||||
>
|
||||
Upload Attachment
|
||||
<input
|
||||
type="file"
|
||||
hidden
|
||||
onChange={handleFileChange}
|
||||
/>
|
||||
</Button>
|
||||
{attachment && <Typography sx={{ display: 'inline', ml: 2 }}>{attachment.name}</Typography>}
|
||||
</Grid>
|
||||
)}
|
||||
<Grid item xs={12} sx={{ mt: 2 }}>
|
||||
{isEditing ? (
|
||||
<Box>
|
||||
<Button onClick={handleUpdate} variant="contained" color="primary" disabled={loading}>
|
||||
{loading ? <CircularProgress size={24} /> : 'Save Changes'}
|
||||
</Button>
|
||||
<Button onClick={() => setIsEditing(false)} sx={{ ml: 2 }}>Cancel</Button>
|
||||
</Box>
|
||||
) : (
|
||||
<Button onClick={() => setIsEditing(true)} variant="contained">Edit</Button>
|
||||
)}
|
||||
</Grid>
|
||||
</Grid>
|
||||
</Paper>
|
||||
</Box>
|
||||
);
|
||||
};
|
||||
|
||||
export default ActionItemPage;
|
237
frontend/src/pages/AdminPage.jsx
Normal file
237
frontend/src/pages/AdminPage.jsx
Normal file
@@ -0,0 +1,237 @@
|
||||
import React, { useState, useEffect, useCallback } from 'react';
|
||||
import {
|
||||
Box, Typography, Paper, Table, TableBody, TableCell, TableContainer, TableHead, TableRow,
|
||||
CircularProgress, Alert, Button, Dialog, DialogActions, DialogContent, DialogTitle, TextField,
|
||||
Select, MenuItem, FormControl, InputLabel, IconButton, Tooltip
|
||||
} from '@mui/material';
|
||||
import AddCircleOutlineIcon from '@mui/icons-material/AddCircleOutline';
|
||||
import DeleteIcon from '@mui/icons-material/Delete';
|
||||
import LockResetIcon from '@mui/icons-material/LockReset';
|
||||
import { getUsers, createUser, deleteUser, changeUserPassword } from '../services/api';
|
||||
import { useAuth } from '../contexts/AuthContext';
|
||||
|
||||
const PasswordChangeDialog = ({ open, onClose, onConfirm, user }) => {
|
||||
const [password, setPassword] = useState('');
|
||||
const [confirmPassword, setConfirmPassword] = useState('');
|
||||
const [error, setError] = useState('');
|
||||
|
||||
const handleConfirm = () => {
|
||||
if (!password) {
|
||||
setError('Password cannot be empty.');
|
||||
return;
|
||||
}
|
||||
if (password !== confirmPassword) {
|
||||
setError('Passwords do not match.');
|
||||
return;
|
||||
}
|
||||
onConfirm(user.id, password);
|
||||
};
|
||||
|
||||
const handleClose = () => {
|
||||
setPassword('');
|
||||
setConfirmPassword('');
|
||||
setError('');
|
||||
onClose();
|
||||
};
|
||||
|
||||
return (
|
||||
<Dialog open={open} onClose={handleClose}>
|
||||
<DialogTitle>Change Password for {user?.username}</DialogTitle>
|
||||
<DialogContent>
|
||||
{error && <Alert severity="error" sx={{ mb: 2, mt: 1 }}>{error}</Alert>}
|
||||
<TextField autoFocus margin="dense" name="password" label="New Password" type="password" fullWidth variant="standard" value={password} onChange={(e) => setPassword(e.target.value)} />
|
||||
<TextField margin="dense" name="confirmPassword" label="Confirm New Password" type="password" fullWidth variant="standard" value={confirmPassword} onChange={(e) => setConfirmPassword(e.target.value)} />
|
||||
</DialogContent>
|
||||
<DialogActions>
|
||||
<Button onClick={handleClose}>Cancel</Button>
|
||||
<Button onClick={handleConfirm}>Confirm Change</Button>
|
||||
</DialogActions>
|
||||
</Dialog>
|
||||
);
|
||||
};
|
||||
|
||||
|
||||
const AdminPage = () => {
|
||||
const { user: currentUser } = useAuth();
|
||||
const [users, setUsers] = useState([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [error, setError] = useState('');
|
||||
const [success, setSuccess] = useState('');
|
||||
|
||||
const [isUserDialogOpen, setIsUserDialogOpen] = useState(false);
|
||||
const [newUser, setNewUser] = useState({ username: '', password: '', role: 'user' });
|
||||
|
||||
const [isPasswordDialogOpen, setIsPasswordDialogOpen] = useState(false);
|
||||
const [selectedUser, setSelectedUser] = useState(null);
|
||||
|
||||
|
||||
const fetchUsers = useCallback(async () => {
|
||||
try {
|
||||
setLoading(true);
|
||||
const data = await getUsers();
|
||||
setUsers(data);
|
||||
setError('');
|
||||
} catch (err) {
|
||||
setError(err.response?.data?.msg || 'Could not fetch users.');
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
fetchUsers();
|
||||
}, [fetchUsers]);
|
||||
|
||||
const handleOpenUserDialog = () => setIsUserDialogOpen(true);
|
||||
const handleCloseUserDialog = () => {
|
||||
setIsUserDialogOpen(false);
|
||||
setNewUser({ username: '', password: '', role: 'user' }); // Reset form
|
||||
};
|
||||
|
||||
const handleOpenPasswordDialog = (user) => {
|
||||
setSelectedUser(user);
|
||||
setIsPasswordDialogOpen(true);
|
||||
};
|
||||
const handleClosePasswordDialog = () => {
|
||||
setSelectedUser(null);
|
||||
setIsPasswordDialogOpen(false);
|
||||
};
|
||||
|
||||
const handleCreateUser = async () => {
|
||||
if (!newUser.username || !newUser.password) {
|
||||
setError('Username and password are required.');
|
||||
return;
|
||||
}
|
||||
try {
|
||||
await createUser(newUser);
|
||||
handleCloseUserDialog();
|
||||
fetchUsers(); // Refetch the list
|
||||
setSuccess('User created successfully.');
|
||||
} catch (err) {
|
||||
setError(err.response?.data?.error || 'Failed to create user.');
|
||||
}
|
||||
};
|
||||
|
||||
const handleDeleteUser = async (userId) => {
|
||||
if (window.confirm('Are you sure you want to delete this user? This will disassociate them from all meetings and action items.')) {
|
||||
try {
|
||||
await deleteUser(userId);
|
||||
fetchUsers(); // Refresh the list
|
||||
setSuccess('User deleted successfully.');
|
||||
} catch (err) {
|
||||
setError(err.response?.data?.error || 'Failed to delete user.');
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
const handlePasswordChange = async (userId, newPassword) => {
|
||||
try {
|
||||
await changeUserPassword(userId, newPassword);
|
||||
handleClosePasswordDialog();
|
||||
setSuccess('Password updated successfully.');
|
||||
} catch (err) {
|
||||
// The dialog will show specific errors, this is a fallback.
|
||||
setError(err.response?.data?.error || 'Failed to change password.');
|
||||
}
|
||||
};
|
||||
|
||||
const handleInputChange = (e) => {
|
||||
const { name, value } = e.target;
|
||||
setNewUser(prev => ({ ...prev, [name]: value }));
|
||||
};
|
||||
|
||||
if (loading) {
|
||||
return <Box sx={{ display: 'flex', justifyContent: 'center', mt: 4 }}><CircularProgress /></Box>;
|
||||
}
|
||||
|
||||
return (
|
||||
<Box>
|
||||
<Box sx={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', mb: 2 }}>
|
||||
<Typography variant="h4" gutterBottom>
|
||||
User Management
|
||||
</Typography>
|
||||
<Button variant="contained" startIcon={<AddCircleOutlineIcon />} onClick={handleOpenUserDialog}>
|
||||
New User
|
||||
</Button>
|
||||
</Box>
|
||||
|
||||
{error && <Alert severity="error" sx={{ mb: 2 }} onClose={() => setError('')}>{error}</Alert>}
|
||||
{success && <Alert severity="success" sx={{ mb: 2 }} onClose={() => setSuccess('')}>{success}</Alert>}
|
||||
|
||||
|
||||
<TableContainer component={Paper}>
|
||||
<Table>
|
||||
<TableHead>
|
||||
<TableRow>
|
||||
<TableCell>ID</TableCell>
|
||||
<TableCell>Username</TableCell>
|
||||
<TableCell>Role</TableCell>
|
||||
<TableCell>Created At</TableCell>
|
||||
<TableCell align="center">Actions</TableCell>
|
||||
</TableRow>
|
||||
</TableHead>
|
||||
<TableBody>
|
||||
{users.map((user) => (
|
||||
<TableRow key={user.id}>
|
||||
<TableCell>{user.id}</TableCell>
|
||||
<TableCell>{user.username}</TableCell>
|
||||
<TableCell>{user.role}</TableCell>
|
||||
<TableCell>{new Date(user.created_at).toLocaleString()}</TableCell>
|
||||
<TableCell align="center" sx={{ display: 'flex', justifyContent: 'center', alignItems: 'center' }}>
|
||||
<Tooltip title="Change Password">
|
||||
<IconButton color="primary" onClick={() => handleOpenPasswordDialog(user)}>
|
||||
<LockResetIcon />
|
||||
</IconButton>
|
||||
</Tooltip>
|
||||
{/* Prevent admin from deleting themselves, show placeholder otherwise */}
|
||||
{String(currentUser.id) !== String(user.id) ? (
|
||||
<Tooltip title="Delete User">
|
||||
<IconButton color="error" onClick={() => handleDeleteUser(user.id)}>
|
||||
<DeleteIcon />
|
||||
</IconButton>
|
||||
</Tooltip>
|
||||
) : (
|
||||
// Invisible placeholder to maintain alignment. An IconButton is ~40px wide.
|
||||
<Box sx={{ width: 40, height: 40 }} />
|
||||
)}
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
))}
|
||||
</TableBody>
|
||||
</Table>
|
||||
</TableContainer>
|
||||
|
||||
{/* Create User Dialog */}
|
||||
<Dialog open={isUserDialogOpen} onClose={handleCloseUserDialog}>
|
||||
<DialogTitle>Create a New User</DialogTitle>
|
||||
<DialogContent>
|
||||
<TextField autoFocus margin="dense" name="username" label="Username" type="text" fullWidth variant="standard" value={newUser.username} onChange={handleInputChange} />
|
||||
<TextField margin="dense" name="password" label="Password" type="password" fullWidth variant="standard" value={newUser.password} onChange={handleInputChange} />
|
||||
<FormControl fullWidth margin="dense" variant="standard">
|
||||
<InputLabel>Role</InputLabel>
|
||||
<Select name="role" value={newUser.role} onChange={handleInputChange}>
|
||||
<MenuItem value="user">User</MenuItem>
|
||||
<MenuItem value="admin">Admin</MenuItem>
|
||||
</Select>
|
||||
</FormControl>
|
||||
</DialogContent>
|
||||
<DialogActions>
|
||||
<Button onClick={handleCloseUserDialog}>Cancel</Button>
|
||||
<Button onClick={handleCreateUser}>Create</Button>
|
||||
</DialogActions>
|
||||
</Dialog>
|
||||
|
||||
{/* Change Password Dialog */}
|
||||
{selectedUser && (
|
||||
<PasswordChangeDialog
|
||||
open={isPasswordDialogOpen}
|
||||
onClose={handleClosePasswordDialog}
|
||||
onConfirm={handlePasswordChange}
|
||||
user={selectedUser}
|
||||
/>
|
||||
)}
|
||||
</Box>
|
||||
);
|
||||
};
|
||||
|
||||
export default AdminPage;
|
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;
|
155
frontend/src/pages/LoginPage.jsx
Normal file
155
frontend/src/pages/LoginPage.jsx
Normal file
@@ -0,0 +1,155 @@
|
||||
import React, { useState } from 'react';
|
||||
import { useAuth } from '../contexts/AuthContext';
|
||||
import { useNavigate, useLocation } from 'react-router-dom';
|
||||
import {
|
||||
Button, TextField, Container, Typography, Box, Alert, Grid, Link, Avatar, Card, CardContent, CircularProgress
|
||||
} from '@mui/material';
|
||||
import LockOutlinedIcon from '@mui/icons-material/LockOutlined';
|
||||
import { register } from '../services/api';
|
||||
|
||||
const LoginPage = () => {
|
||||
const [username, setUsername] = useState('');
|
||||
const [password, setPassword] = useState('');
|
||||
const [confirmPassword, setConfirmPassword] = useState('');
|
||||
const [error, setError] = useState('');
|
||||
const [success, setSuccess] = useState('');
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [isRegister, setIsRegister] = useState(false);
|
||||
|
||||
const { login } = useAuth();
|
||||
const navigate = useNavigate();
|
||||
const location = useLocation();
|
||||
|
||||
const from = location.state?.from?.pathname || "/";
|
||||
|
||||
const handleLogin = async (e) => {
|
||||
e.preventDefault();
|
||||
setError('');
|
||||
setSuccess('');
|
||||
setLoading(true);
|
||||
const { success, message } = await login(username, password);
|
||||
if (success) {
|
||||
navigate(from, { replace: true });
|
||||
} else {
|
||||
setError(message || 'Failed to log in');
|
||||
}
|
||||
setLoading(false);
|
||||
};
|
||||
|
||||
const handleRegister = async (e) => {
|
||||
e.preventDefault();
|
||||
if (password !== confirmPassword) {
|
||||
setError('Passwords do not match.');
|
||||
return;
|
||||
}
|
||||
setError('');
|
||||
setSuccess('');
|
||||
setLoading(true);
|
||||
try {
|
||||
await register(username, password);
|
||||
setSuccess('Account created successfully! Please log in.');
|
||||
setIsRegister(false); // Switch back to login view
|
||||
setUsername(''); // Clear fields
|
||||
setPassword('');
|
||||
setConfirmPassword('');
|
||||
} catch (err) {
|
||||
setError(err.response?.data?.error || 'Failed to create account.');
|
||||
}
|
||||
setLoading(false);
|
||||
};
|
||||
|
||||
return (
|
||||
<Container component="main" maxWidth="xs">
|
||||
<Box
|
||||
sx={{
|
||||
marginTop: 8,
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
alignItems: 'center',
|
||||
}}
|
||||
>
|
||||
<Card sx={{ padding: 2, width: '100%', mt: 3 }}>
|
||||
<CardContent sx={{ display: 'flex', flexDirection: 'column', alignItems: 'center' }}>
|
||||
<Avatar
|
||||
src="/LOGO.png"
|
||||
sx={{ m: 1, bgcolor: 'secondary.main', width: 56, height: 56 }}
|
||||
imgProps={{ style: { objectFit: 'contain' } }}
|
||||
>
|
||||
<LockOutlinedIcon />
|
||||
</Avatar>
|
||||
<Typography component="h1" variant="h5">
|
||||
AI Meeting Assistant
|
||||
</Typography>
|
||||
<Typography component="h2" variant="subtitle1" sx={{ mt: 1 }}>
|
||||
{isRegister ? 'Create Account' : 'Sign In'}
|
||||
</Typography>
|
||||
<Box component="form" onSubmit={isRegister ? handleRegister : handleLogin} noValidate sx={{ mt: 1 }}>
|
||||
<TextField
|
||||
margin="normal"
|
||||
required
|
||||
fullWidth
|
||||
id="username"
|
||||
label="Username"
|
||||
name="username"
|
||||
autoComplete="username"
|
||||
autoFocus
|
||||
value={username}
|
||||
onChange={(e) => setUsername(e.target.value)}
|
||||
/>
|
||||
<TextField
|
||||
margin="normal"
|
||||
required
|
||||
fullWidth
|
||||
name="password"
|
||||
label="Password"
|
||||
type="password"
|
||||
id="password"
|
||||
autoComplete={isRegister ? "new-password" : "current-password"}
|
||||
value={password}
|
||||
onChange={(e) => setPassword(e.target.value)}
|
||||
/>
|
||||
{isRegister && (
|
||||
<TextField
|
||||
margin="normal"
|
||||
required
|
||||
fullWidth
|
||||
name="confirmPassword"
|
||||
label="Confirm Password"
|
||||
type="password"
|
||||
id="confirmPassword"
|
||||
autoComplete="new-password"
|
||||
value={confirmPassword}
|
||||
onChange={(e) => setConfirmPassword(e.target.value)}
|
||||
/>
|
||||
)}
|
||||
{error && <Alert severity="error" sx={{ width: '100%', mt: 2 }}>{error}</Alert>}
|
||||
{success && <Alert severity="success" sx={{ width: '100%', mt: 2 }}>{success}</Alert>}
|
||||
<Button
|
||||
type="submit"
|
||||
fullWidth
|
||||
variant="contained"
|
||||
sx={{ mt: 3, mb: 2 }}
|
||||
disabled={loading}
|
||||
>
|
||||
{loading ? <CircularProgress size={24} /> : (isRegister ? 'Create Account' : 'Sign In')}
|
||||
</Button>
|
||||
<Grid container justifyContent="flex-end">
|
||||
<Grid item>
|
||||
<Link href="#" variant="body2" onClick={() => {
|
||||
setIsRegister(!isRegister);
|
||||
setError('');
|
||||
setSuccess('');
|
||||
}}>
|
||||
{isRegister ? "Already have an account? Sign In" : "Don't have an account? Sign Up"}
|
||||
</Link>
|
||||
</Grid>
|
||||
</Grid>
|
||||
</Box>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</Box>
|
||||
</Container>
|
||||
);
|
||||
};
|
||||
|
||||
export default LoginPage;
|
308
frontend/src/pages/MeetingDetailPage.jsx
Normal file
308
frontend/src/pages/MeetingDetailPage.jsx
Normal file
@@ -0,0 +1,308 @@
|
||||
import React, { useState, useEffect, useCallback } from 'react';
|
||||
import { useParams } from 'react-router-dom';
|
||||
import {
|
||||
Box, Typography, Paper, CircularProgress, Alert, Button, IconButton, Dialog, DialogTitle, DialogContent, DialogActions,
|
||||
Table, TableBody, TableCell, TableContainer, TableHead, TableRow, TextField, Select, MenuItem, FormControl, InputLabel,
|
||||
Grid, Card, CardContent
|
||||
} from '@mui/material';
|
||||
import AddCircleOutlineIcon from '@mui/icons-material/AddCircleOutline';
|
||||
import EditIcon from '@mui/icons-material/Edit';
|
||||
import DeleteIcon from '@mui/icons-material/Delete';
|
||||
import SaveIcon from '@mui/icons-material/Save';
|
||||
import CancelIcon from '@mui/icons-material/Cancel';
|
||||
import PreviewIcon from '@mui/icons-material/Preview';
|
||||
import DownloadIcon from '@mui/icons-material/Download';
|
||||
import {
|
||||
getMeetingDetails, updateMeeting, summarizeMeeting,
|
||||
getActionItemsForMeeting, createActionItem, updateActionItem, deleteActionItem, getAllUsers,
|
||||
previewActionItems, batchSaveActionItems, pollTaskStatus, uploadActionItemAttachment, downloadFile
|
||||
} from '../services/api';
|
||||
import { useAuth } from '../contexts/AuthContext';
|
||||
|
||||
const MeetingDetailPage = () => {
|
||||
const { meetingId } = useParams();
|
||||
const { user: currentUser } = useAuth();
|
||||
|
||||
const [meeting, setMeeting] = useState(null);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [error, setError] = useState('');
|
||||
const [isEditingTranscript, setIsEditingTranscript] = useState(false);
|
||||
const [isEditingSummary, setIsEditingSummary] = useState(false);
|
||||
const [editData, setEditData] = useState({});
|
||||
const [summaryTask, setSummaryTask] = useState(null);
|
||||
|
||||
const [actionItems, setActionItems] = useState([]);
|
||||
const [users, setUsers] = useState([]);
|
||||
const [editingActionItemId, setEditingActionItemId] = useState(null);
|
||||
const [editActionItemData, setEditActionItemData] = useState({});
|
||||
const [attachmentFile, setAttachmentFile] = useState(null);
|
||||
const [isAddActionItemOpen, setIsAddActionItemOpen] = useState(false);
|
||||
const [newActionItem, setNewActionItem] = useState({ action: '', owner_id: '', due_date: '', item: '' });
|
||||
const [previewedItems, setPreviewedItems] = useState([]);
|
||||
const [isPreviewLoading, setIsPreviewLoading] = useState(false);
|
||||
|
||||
const fetchMeetingData = useCallback(async () => {
|
||||
try {
|
||||
setLoading(true);
|
||||
const meetingRes = await getMeetingDetails(meetingId);
|
||||
setMeeting(meetingRes);
|
||||
setEditData(meetingRes);
|
||||
const itemsRes = await getActionItemsForMeeting(meetingId);
|
||||
setActionItems(itemsRes);
|
||||
} catch (err) {
|
||||
setError('Failed to fetch meeting data.');
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}, [meetingId]);
|
||||
|
||||
useEffect(() => {
|
||||
fetchMeetingData();
|
||||
}, [fetchMeetingData]);
|
||||
|
||||
useEffect(() => {
|
||||
const fetchUsers = async () => {
|
||||
try {
|
||||
const usersRes = await getAllUsers();
|
||||
setUsers(usersRes);
|
||||
} catch (err) { console.warn('Could not fetch user list.'); }
|
||||
};
|
||||
fetchUsers();
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
let intervalId = null;
|
||||
if (summaryTask && (summaryTask.state === 'PENDING' || summaryTask.state === 'PROGRESS')) {
|
||||
intervalId = setInterval(async () => {
|
||||
try {
|
||||
const updatedTask = await pollTaskStatus(summaryTask.status_url);
|
||||
if (['SUCCESS', 'FAILURE', 'REVOKED'].includes(updatedTask.state)) {
|
||||
clearInterval(intervalId);
|
||||
setSummaryTask(null);
|
||||
if (updatedTask.state === 'SUCCESS' && updatedTask.info.summary) {
|
||||
// Directly update the summary instead of refetching everything
|
||||
setMeeting(prevMeeting => ({...prevMeeting, summary: updatedTask.info.summary}));
|
||||
setEditData(prevEditData => ({...prevEditData, summary: updatedTask.info.summary}));
|
||||
} else {
|
||||
// Fallback to refetch if something goes wrong or task fails
|
||||
fetchMeetingData();
|
||||
}
|
||||
}
|
||||
} catch (err) {
|
||||
console.error('Polling failed:', err);
|
||||
clearInterval(intervalId);
|
||||
setSummaryTask(null);
|
||||
}
|
||||
}, 2000);
|
||||
}
|
||||
return () => clearInterval(intervalId);
|
||||
}, [summaryTask, fetchMeetingData]);
|
||||
|
||||
const handleSave = async (field, value) => {
|
||||
try {
|
||||
await updateMeeting(meetingId, { [field]: value });
|
||||
fetchMeetingData();
|
||||
return true;
|
||||
} catch (err) {
|
||||
setError(`Failed to save ${field}.`);
|
||||
return false;
|
||||
}
|
||||
};
|
||||
|
||||
const handleSaveTranscript = async () => {
|
||||
if (await handleSave('transcript', editData.transcript)) setIsEditingTranscript(false);
|
||||
};
|
||||
|
||||
const handleSaveSummary = async () => {
|
||||
if (await handleSave('summary', editData.summary)) setIsEditingSummary(false);
|
||||
};
|
||||
|
||||
const handleGenerateSummary = async () => {
|
||||
try {
|
||||
const taskInfo = await summarizeMeeting(meetingId);
|
||||
setSummaryTask({ ...taskInfo, state: 'PENDING' });
|
||||
} catch (err) {
|
||||
setError('Failed to start summary generation.');
|
||||
}
|
||||
};
|
||||
|
||||
const handlePreviewActionItems = async () => {
|
||||
const textToPreview = meeting?.summary || meeting?.transcript;
|
||||
if (!textToPreview) return;
|
||||
setIsPreviewLoading(true);
|
||||
try {
|
||||
const result = await previewActionItems(textToPreview);
|
||||
setPreviewedItems(result.items || []);
|
||||
} catch (err) {
|
||||
setError('Failed to generate action item preview.');
|
||||
} finally {
|
||||
setIsPreviewLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleBatchSave = async () => {
|
||||
if (previewedItems.length === 0) return;
|
||||
try {
|
||||
await batchSaveActionItems(meetingId, previewedItems);
|
||||
setPreviewedItems([]);
|
||||
fetchMeetingData();
|
||||
} catch (err) {
|
||||
setError('Failed to save action items.');
|
||||
}
|
||||
};
|
||||
|
||||
const handleEditActionItemClick = (item) => { setEditingActionItemId(item.id); setEditActionItemData({ ...item, due_date: item.due_date || '' }); };
|
||||
const handleCancelActionItemClick = () => { setEditingActionItemId(null); setAttachmentFile(null); };
|
||||
const handleSaveActionItemClick = async (id) => {
|
||||
try {
|
||||
await updateActionItem(id, editActionItemData);
|
||||
if (attachmentFile) await uploadActionItemAttachment(id, attachmentFile);
|
||||
setEditingActionItemId(null);
|
||||
setAttachmentFile(null);
|
||||
fetchMeetingData();
|
||||
} catch (err) {
|
||||
setError('Failed to save action item.');
|
||||
}
|
||||
};
|
||||
const handleDeleteActionItemClick = async (id) => { if (window.confirm('Are you sure?')) { try { await deleteActionItem(id); fetchMeetingData(); } catch (err) { setError('Failed to delete action item.'); }}};
|
||||
const handleAddActionItemSave = async () => { if (!newActionItem.action) { setError('Action is required.'); return; } try { const newItem = await createActionItem({ ...newActionItem, meeting_id: meetingId }); if (attachmentFile) { await uploadActionItemAttachment(newItem.id, attachmentFile); } setIsAddActionItemOpen(false); setNewActionItem({ action: '', owner_id: '', due_date: '', item: '' }); setAttachmentFile(null); fetchMeetingData(); } catch (err) { setError('Failed to create action item.'); }};
|
||||
const handleFileChange = (e) => { if (e.target.files[0]) setAttachmentFile(e.target.files[0]); };
|
||||
|
||||
if (loading) return <Box sx={{ display: 'flex', justifyContent: 'center', mt: 4 }}><CircularProgress /></Box>;
|
||||
if (!meeting) return <Alert severity="error">Meeting not found.</Alert>;
|
||||
|
||||
const canManageMeeting = currentUser && meeting && (currentUser.role === 'admin' || String(currentUser.id) === String(meeting.created_by_id));
|
||||
|
||||
return (
|
||||
<Box sx={{ p: 3 }}>
|
||||
{error && <Alert severity="error" sx={{ mb: 2 }}>{error}</Alert>}
|
||||
<Grid container spacing={3} direction="column">
|
||||
{/* Transcript Card (Full Width) */}
|
||||
<Grid item xs={12}>
|
||||
<Card>
|
||||
<CardContent>
|
||||
{isEditingTranscript ? (
|
||||
<>
|
||||
<TextField label="Transcript" multiline rows={15} fullWidth value={editData.transcript || ''} onChange={e => setEditData({...editData, transcript: e.target.value})} />
|
||||
<Box sx={{ mt: 2, display: 'flex', gap: 1 }}><Button variant="contained" onClick={handleSaveTranscript}>Save Transcript</Button><Button variant="outlined" onClick={() => { setIsEditingTranscript(false); setEditData(meeting); }}>Cancel</Button></Box>
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<Box sx={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center' }}>
|
||||
<Typography variant="h5">{meeting.topic}</Typography>
|
||||
{canManageMeeting && <Button startIcon={<EditIcon />} onClick={() => setIsEditingTranscript(true)}>Edit Transcript</Button>}
|
||||
</Box>
|
||||
<Typography variant="body1" color="text.secondary">Status: {meeting.status}</Typography>
|
||||
<Typography variant="h6" sx={{ mt: 2 }}>Transcript</Typography>
|
||||
<Paper variant="outlined" sx={{ p: 2, mt: 1, minHeight: '300px', maxHeight: 400, overflow: 'auto' }}>
|
||||
<Typography variant="body2" sx={{ whiteSpace: 'pre-wrap' }}>{meeting.transcript || 'No transcript provided. Edit to add one.'}</Typography>
|
||||
</Paper>
|
||||
</>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
</Grid>
|
||||
|
||||
{/* AI Tools Card (Full Width) */}
|
||||
<Grid item xs={12}>
|
||||
<Card>
|
||||
<CardContent>
|
||||
<Box sx={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center' }}>
|
||||
<Typography variant="h5">AI Tools</Typography>
|
||||
{(canManageMeeting && !isEditingSummary) && <Button startIcon={<EditIcon />} onClick={() => setIsEditingSummary(true)}>Edit Summary</Button>}
|
||||
</Box>
|
||||
|
||||
{canManageMeeting && <Button variant="contained" sx={{ mt: 2 }} onClick={handleGenerateSummary} disabled={!meeting.transcript || summaryTask || isEditingSummary}>{summaryTask ? 'Generating...' : 'Generate Summary'}</Button>}
|
||||
|
||||
<Typography variant="h6" sx={{ mt: 2 }}>Summary</Typography>
|
||||
{isEditingSummary ? (
|
||||
<>
|
||||
<TextField label="Summary" multiline rows={8} fullWidth value={editData.summary || ''} onChange={e => setEditData({...editData, summary: e.target.value})} sx={{ mt: 1 }} />
|
||||
<Box sx={{ mt: 2, display: 'flex', gap: 1 }}><Button variant="contained" onClick={handleSaveSummary}>Save Summary</Button><Button variant="outlined" onClick={() => { setIsEditingSummary(false); setEditData(meeting); }}>Cancel</Button></Box>
|
||||
</>
|
||||
) : (
|
||||
<Paper variant="outlined" sx={{ p: 2, mt: 1, minHeight: 215, overflow: 'auto', position: 'relative' }}>
|
||||
{summaryTask && (
|
||||
<Box sx={{ display: 'flex', alignItems: 'center', justifyContent: 'center', height: '100%', position: 'absolute', top: 0, left: 0, right: 0, bottom: 0 }}>
|
||||
<CircularProgress /><Typography sx={{ ml: 2 }}>Generating...</Typography>
|
||||
</Box>
|
||||
)}
|
||||
<Typography variant="body2" sx={{ whiteSpace: 'pre-wrap' }}>{meeting.summary || (summaryTask ? '' : 'No summary generated yet.')}</Typography>
|
||||
</Paper>
|
||||
)}
|
||||
|
||||
{canManageMeeting && (
|
||||
<Box sx={{ mt: 3 }}>
|
||||
<Button variant="outlined" startIcon={<PreviewIcon />} onClick={handlePreviewActionItems} disabled={isPreviewLoading || isEditingSummary || (!meeting.summary && !meeting.transcript)}>{isPreviewLoading ? <CircularProgress size={24} /> : "Preview Action Items"}</Button>
|
||||
{previewedItems.length > 0 && (
|
||||
<Box>
|
||||
<TableContainer component={Paper} sx={{ mt: 2 }}><Table size="small">
|
||||
<TableHead><TableRow><TableCell>Context/Item</TableCell><TableCell>Action</TableCell><TableCell>Owner</TableCell><TableCell>Due Date</TableCell></TableRow></TableHead>
|
||||
<TableBody>{previewedItems.map((item, index) => (<TableRow key={index}><TableCell>{item.item}</TableCell><TableCell>{item.action}</TableCell><TableCell>{item.owner}</TableCell><TableCell>{item.due_date}</TableCell></TableRow>))}</TableBody>
|
||||
</Table></TableContainer>
|
||||
<Button variant="contained" sx={{ mt: 2 }} onClick={handleBatchSave}>Save All to List</Button>
|
||||
</Box>
|
||||
)}
|
||||
</Box>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
</Grid>
|
||||
|
||||
{/* Action Items List Card (Full Width) */}
|
||||
<Grid item xs={12}>
|
||||
<Card>
|
||||
<CardContent>
|
||||
<Box sx={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', mb: 2 }}>
|
||||
<Typography variant="h6">Action Items</Typography>
|
||||
<Button variant="contained" startIcon={<AddCircleOutlineIcon />} onClick={() => setIsAddActionItemOpen(true)}>Add Manually</Button>
|
||||
</Box>
|
||||
<TableContainer component={Paper}>
|
||||
<Table>
|
||||
<TableHead><TableRow><TableCell>Context</TableCell><TableCell>Action</TableCell><TableCell>Owner</TableCell><TableCell>Due Date</TableCell><TableCell>Status</TableCell><TableCell>Attachment</TableCell><TableCell align="center">Actions</TableCell></TableRow></TableHead>
|
||||
<TableBody>
|
||||
{actionItems.map((item) => {
|
||||
const isEditing = editingActionItemId === item.id;
|
||||
const canEditItem = currentUser && (currentUser.role === 'admin' || String(currentUser.id) === String(item.owner_id));
|
||||
return (
|
||||
<TableRow key={item.id}>
|
||||
<TableCell>{isEditing ? <TextField name="item" defaultValue={item.item} onChange={e => setEditActionItemData({...editActionItemData, item: e.target.value})} fullWidth /> : item.item}</TableCell>
|
||||
<TableCell>{isEditing ? <TextField name="action" defaultValue={item.action} onChange={e => setEditActionItemData({...editActionItemData, action: e.target.value})} fullWidth /> : item.action}</TableCell>
|
||||
<TableCell>{isEditing ? <FormControl fullWidth><Select name="owner_id" value={editActionItemData.owner_id || ''} onChange={e => setEditActionItemData({...editActionItemData, owner_id: e.target.value})}><MenuItem value=""><em>Unassigned</em></MenuItem>{users.map(u => <MenuItem key={u.id} value={u.id}>{u.username}</MenuItem>)}</Select></FormControl> : item.owner_name}</TableCell>
|
||||
<TableCell>{isEditing ? <TextField name="due_date" type="date" defaultValue={editActionItemData.due_date} onChange={e => setEditActionItemData({...editActionItemData, due_date: e.target.value})} InputLabelProps={{ shrink: true }} fullWidth /> : item.due_date}</TableCell>
|
||||
<TableCell>{isEditing ? <Select name="status" value={editActionItemData.status} onChange={e => setEditActionItemData({...editActionItemData, status: e.target.value})} fullWidth><MenuItem value="pending">Pending</MenuItem><MenuItem value="in_progress">In Progress</MenuItem><MenuItem value="completed">Completed</MenuItem></Select> : item.status}</TableCell>
|
||||
<TableCell>
|
||||
{isEditing ? <Button component="label" size="small">Upload File<input type="file" hidden onChange={handleFileChange} /></Button> : (item.attachment_path && <IconButton onClick={() => downloadFile(item.attachment_path)}><DownloadIcon /></IconButton>)}
|
||||
{isEditing && attachmentFile && <Typography variant="caption">{attachmentFile.name}</Typography>}
|
||||
</TableCell>
|
||||
<TableCell align="center">
|
||||
{isEditing ? <Box><IconButton onClick={() => handleSaveActionItemClick(item.id)}><SaveIcon /></IconButton><IconButton onClick={handleCancelActionItemClick}><CancelIcon /></IconButton></Box> : <Box>{canEditItem && <IconButton onClick={() => handleEditActionItemClick(item)}><EditIcon /></IconButton>}{canManageMeeting && <IconButton onClick={() => handleDeleteActionItemClick(item.id)}><DeleteIcon /></IconButton>}</Box>}
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
);
|
||||
})}
|
||||
</TableBody>
|
||||
</Table>
|
||||
</TableContainer>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</Grid>
|
||||
</Grid>
|
||||
|
||||
<Dialog open={isAddActionItemOpen} onClose={() => setIsAddActionItemOpen(false)} fullWidth maxWidth="sm">
|
||||
<DialogTitle>Add New Action Item</DialogTitle>
|
||||
<DialogContent>
|
||||
<TextField label="Context/Item (Optional)" fullWidth margin="dense" value={newActionItem.item} onChange={e => setNewActionItem({...newActionItem, item: e.target.value})} />
|
||||
<TextField label="Action (Required)" fullWidth margin="dense" required value={newActionItem.action} onChange={e => setNewActionItem({...newActionItem, action: e.target.value})} />
|
||||
<FormControl fullWidth margin="dense"><InputLabel>Owner</InputLabel><Select label="Owner" value={newActionItem.owner_id} onChange={e => setNewActionItem({...newActionItem, owner_id: e.target.value})}><MenuItem value=""><em>Unassigned</em></MenuItem>{users.map(u => <MenuItem key={u.id} value={u.id}>{u.username}</MenuItem>)}</Select></FormControl>
|
||||
<TextField label="Due Date" type="date" fullWidth margin="dense" InputLabelProps={{ shrink: true }} value={newActionItem.due_date} onChange={e => setNewActionItem({...newActionItem, due_date: e.target.value})} />
|
||||
<Button component="label" sx={{ mt: 1 }}>Upload Attachment<input type="file" hidden onChange={handleFileChange} /></Button>
|
||||
{attachmentFile && <Typography variant="caption" sx={{ ml: 1 }}>{attachmentFile.name}</Typography>}
|
||||
</DialogContent>
|
||||
<DialogActions><Button onClick={() => setIsAddActionItemOpen(false)}>Cancel</Button><Button onClick={handleAddActionItemSave} variant="contained">Save</Button></DialogActions>
|
||||
</Dialog>
|
||||
</Box>
|
||||
);
|
||||
};
|
||||
|
||||
export default MeetingDetailPage;
|
228
frontend/src/pages/ProcessingPage.jsx
Normal file
228
frontend/src/pages/ProcessingPage.jsx
Normal file
@@ -0,0 +1,228 @@
|
||||
import React, { useState, useEffect, useCallback } from 'react';
|
||||
import {
|
||||
Box, Typography, Paper, Button, CircularProgress, Alert, Grid, Card, CardContent, Chip, LinearProgress, TextField, Select, MenuItem, FormControl, InputLabel, IconButton, Tooltip
|
||||
} from '@mui/material';
|
||||
import ContentCopyIcon from '@mui/icons-material/ContentCopy';
|
||||
import DownloadIcon from '@mui/icons-material/Download';
|
||||
import {
|
||||
extractAudio,
|
||||
transcribeAudio,
|
||||
translateText,
|
||||
pollTaskStatus,
|
||||
stopTask,
|
||||
downloadFile
|
||||
} from '../services/api';
|
||||
|
||||
const TaskMonitor = ({ task, onStop, title, children }) => {
|
||||
if (!task) return null;
|
||||
const colorMap = { PENDING: 'default', PROGRESS: 'info', SUCCESS: 'success', FAILURE: 'error', REVOKED: 'warning' };
|
||||
const progress = task.info?.total ? (task.info.current / task.info.total * 100) : null;
|
||||
const isRunning = task.state === 'PENDING' || task.state === 'PROGRESS';
|
||||
|
||||
return (
|
||||
<Paper sx={{ p: 2, mt: 2, border: 1, borderColor: 'divider' }}>
|
||||
<Box sx={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center' }}>
|
||||
<Typography variant="subtitle2">{title}</Typography>
|
||||
{task.state && <Chip label={task.state} color={colorMap[task.state] || 'default'} size="small" />}
|
||||
</Box>
|
||||
{task.info?.status_msg && <Typography variant="caption" sx={{ display: 'block', mt: 1 }}>{task.info.status_msg}</Typography>}
|
||||
{isRunning && !progress && <LinearProgress sx={{ mt: 1 }} />}
|
||||
{progress && <LinearProgress variant="determinate" value={progress} sx={{ mt: 1 }} />}
|
||||
{task.state === 'FAILURE' && <Alert severity="error" sx={{mt:1}}>{task.info?.error || 'Task failed.'}</Alert>}
|
||||
{isRunning &&
|
||||
<Button size="small" color="error" variant="text" onClick={() => onStop(task.task_id)} sx={{mt: 1}}>Stop Task</Button>}
|
||||
{children}
|
||||
</Paper>
|
||||
);
|
||||
};
|
||||
|
||||
const ProcessingPage = () => {
|
||||
const [tasks, setTasks] = useState({});
|
||||
const [error, setError] = useState('');
|
||||
const [copySuccess, setCopySuccess] = useState('');
|
||||
|
||||
const [extractFile, setExtractFile] = useState(null);
|
||||
const [transcribeFile, setTranscribeFile] = useState(null);
|
||||
const [transcribedText, setTranscribedText] = useState('');
|
||||
const [translateFile, setTranslateFile] = useState(null);
|
||||
const [translateTextContent, setTranslateTextContent] = useState('');
|
||||
const [translateLang, setTranslateLang] = useState('繁體中文');
|
||||
const [customLang, setCustomLang] = useState('');
|
||||
const [translatedResult, setTranslatedResult] = useState('');
|
||||
|
||||
const handleCopyToClipboard = (text, type) => {
|
||||
navigator.clipboard.writeText(text).then(() => {
|
||||
setCopySuccess(type);
|
||||
setTimeout(() => setCopySuccess(''), 2000);
|
||||
});
|
||||
};
|
||||
|
||||
const handleTranslateFileUpload = (e) => {
|
||||
const file = e.target.files[0];
|
||||
if (file) {
|
||||
setTranslateFile(file);
|
||||
const reader = new FileReader();
|
||||
reader.onload = (evt) => setTranslateTextContent(evt.target.result);
|
||||
reader.readAsText(file);
|
||||
}
|
||||
};
|
||||
|
||||
// This function now ONLY updates the main tasks object.
|
||||
const handleTaskUpdate = useCallback((key, updatedTask) => {
|
||||
setTasks(prev => ({ ...prev, [key]: updatedTask }));
|
||||
}, []);
|
||||
|
||||
// This new useEffect handles the side-effects of a task completing.
|
||||
useEffect(() => {
|
||||
const transcribeTask = tasks.transcribe;
|
||||
if (transcribeTask?.state === 'SUCCESS' && transcribeTask.info?.content) {
|
||||
setTranscribedText(transcribeTask.info.content);
|
||||
}
|
||||
|
||||
const translateTask = tasks.translate;
|
||||
if (translateTask?.state === 'SUCCESS' && translateTask.info?.content) {
|
||||
setTranslatedResult(translateTask.info.content);
|
||||
}
|
||||
}, [tasks]);
|
||||
|
||||
|
||||
useEffect(() => {
|
||||
const intervalIds = Object.entries(tasks).map(([key, task]) => {
|
||||
if (task && (task.state === 'PENDING' || task.state === 'PROGRESS')) {
|
||||
const intervalId = setInterval(async () => {
|
||||
try {
|
||||
const updatedTask = await pollTaskStatus(task.status_url);
|
||||
// Pass the full task object to avoid stale closures
|
||||
handleTaskUpdate(key, { ...task, ...updatedTask });
|
||||
if (['SUCCESS', 'FAILURE', 'REVOKED'].includes(updatedTask.state)) {
|
||||
clearInterval(intervalId);
|
||||
}
|
||||
} catch (err) {
|
||||
handleTaskUpdate(key, { ...task, state: 'FAILURE', info: { ...task.info, error: 'Polling failed.' } });
|
||||
clearInterval(intervalId);
|
||||
}
|
||||
}, 2000);
|
||||
return intervalId;
|
||||
}
|
||||
return null;
|
||||
}).filter(Boolean);
|
||||
|
||||
return () => intervalIds.forEach(clearInterval);
|
||||
}, [tasks, handleTaskUpdate]);
|
||||
|
||||
const handleStartTask = async (key, taskFn, ...args) => {
|
||||
setError('');
|
||||
setTasks(prev => ({ ...prev, [key]: { state: 'PENDING', info: { status_msg: 'Initializing...' } } }));
|
||||
try {
|
||||
const result = await taskFn(...args);
|
||||
setTasks(prev => ({ ...prev, [key]: { ...prev[key], task_id: result.task_id, status_url: result.status_url, state: 'PENDING' } }));
|
||||
} catch (err) {
|
||||
const errorMsg = err.response?.data?.error || `Failed to start ${key} task.`;
|
||||
setError(errorMsg);
|
||||
setTasks(prev => ({ ...prev, [key]: { state: 'FAILURE', info: { error: errorMsg } } }));
|
||||
}
|
||||
};
|
||||
|
||||
const handleStopTask = async (taskId) => {
|
||||
if (!taskId) return;
|
||||
try {
|
||||
await stopTask(taskId);
|
||||
const taskKey = Object.keys(tasks).find(k => tasks[k].task_id === taskId);
|
||||
if (taskKey) setTasks(prev => ({ ...prev, [taskKey]: { ...prev[taskKey], state: 'REVOKED' } }));
|
||||
} catch (err) {
|
||||
setError('Failed to stop the task.');
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<Box sx={{ p: 3 }}>
|
||||
<Typography variant="h4" gutterBottom>Processing Tools</Typography>
|
||||
{error && <Alert severity="error" sx={{ mb: 2 }}>{error}</Alert>}
|
||||
<Grid container spacing={3}>
|
||||
{/* Left Column: Extract Audio */}
|
||||
<Grid item xs={12} md={3}>
|
||||
<Card sx={{ height: '100%' }}>
|
||||
<CardContent>
|
||||
<Typography variant="h6">1. Extract Audio</Typography>
|
||||
<Typography variant="body2" color="text.secondary" sx={{mb: 2}}>Extract audio track from a video file.</Typography>
|
||||
<Button variant="contained" component="label" fullWidth>Upload Video<input type="file" hidden onChange={e => setExtractFile(e.target.files[0])} /></Button>
|
||||
{extractFile && <Typography sx={{ mt: 1, fontStyle: 'italic', textAlign: 'center' }}>{extractFile.name}</Typography>}
|
||||
<Button size="small" variant="outlined" disabled={!extractFile} onClick={() => handleStartTask('extract', extractAudio, extractFile)} sx={{ display: 'block', mt: 2, mx: 'auto' }}>Start Extraction</Button>
|
||||
<TaskMonitor task={tasks.extract} onStop={handleStopTask} title="Extraction Progress">
|
||||
{tasks.extract?.state === 'SUCCESS' && tasks.extract.info.download_filename &&
|
||||
<Button size="small" sx={{mt:1}} startIcon={<DownloadIcon />} onClick={() => downloadFile(tasks.extract.info.download_filename)}>Download Audio</Button>
|
||||
}
|
||||
</TaskMonitor>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</Grid>
|
||||
|
||||
{/* Right Column: Transcribe and Translate */}
|
||||
<Grid item xs={12} md={9}>
|
||||
<Grid container spacing={3} direction="column">
|
||||
{/* Top-Right: Transcribe */}
|
||||
<Grid item xs={12}>
|
||||
<Card>
|
||||
<CardContent>
|
||||
<Typography variant="h6">2. Transcribe Audio to Text</Typography>
|
||||
<Button variant="contained" component="label" sx={{ mt: 2 }}>Upload Audio<input type="file" hidden onChange={e => setTranscribeFile(e.target.files[0])} /></Button>
|
||||
{transcribeFile && <Typography sx={{ display: 'inline', ml: 2, fontStyle: 'italic' }}>{transcribeFile.name}</Typography>}
|
||||
<Button size="small" variant="outlined" disabled={!transcribeFile} onClick={() => handleStartTask('transcribe', transcribeAudio, transcribeFile)} sx={{ ml: 2, mt: 2 }}>Start Transcription</Button>
|
||||
<TaskMonitor task={tasks.transcribe} onStop={handleStopTask} title="Transcription Progress" />
|
||||
<Paper sx={{p:2, mt:2, minHeight: 150, overflow: 'auto', bgcolor: '#222', position: 'relative'}}>
|
||||
{(tasks.transcribe?.state === 'PENDING' || tasks.transcribe?.state === 'PROGRESS') && <Box sx={{ display: 'flex', alignItems: 'center', justifyContent: 'center', height: '100%', position: 'absolute', top: 0, left: 0, right: 0, bottom: 0 }}><CircularProgress size={30} /></Box>}
|
||||
<Typography variant="body2" sx={{whiteSpace: 'pre-wrap'}}>{transcribedText || 'Transcription will appear here.'}</Typography>
|
||||
{transcribedText && <Tooltip title={copySuccess === 'transcribed' ? 'Copied!' : 'Copy to Clipboard'}><IconButton size="small" onClick={() => handleCopyToClipboard(transcribedText, 'transcribed')} sx={{position: 'absolute', top: 5, right: 5}}><ContentCopyIcon fontSize="small" /></IconButton></Tooltip>}
|
||||
</Paper>
|
||||
{tasks.transcribe?.state === 'SUCCESS' && tasks.transcribe.info.result_path && <Button size="small" sx={{mt:1}} startIcon={<DownloadIcon />} onClick={() => downloadFile(tasks.transcribe.info.result_path)}>Download Transcript (.txt)</Button>}
|
||||
</CardContent>
|
||||
</Card>
|
||||
</Grid>
|
||||
{/* Bottom-Right: Translate */}
|
||||
<Grid item xs={12}>
|
||||
<Card>
|
||||
<CardContent>
|
||||
<Typography variant="h6">3. Translate Text</Typography>
|
||||
<TextField label="Paste Text or Upload .txt" multiline rows={4} fullWidth value={translateTextContent} onChange={e => setTranslateTextContent(e.target.value)} sx={{ mt: 2 }} />
|
||||
<Box sx={{ display: 'flex', alignItems: 'center', mt: 2, flexWrap: 'wrap' }}>
|
||||
<Button variant="contained" component="label" size="small">Upload .txt File<input type="file" accept=".txt" hidden onChange={handleTranslateFileUpload} /></Button>
|
||||
{translateFile && <Typography sx={{ ml: 2, fontStyle: 'italic' }}>{translateFile.name}</Typography>}
|
||||
<Box sx={{flexGrow: 1}} />
|
||||
<FormControl size="small" sx={{ minWidth: 150, mr: 1, mt: { xs: 2, sm: 0 } }}>
|
||||
<InputLabel>Target Language</InputLabel>
|
||||
<Select value={translateLang} label="Target Language" onChange={e => setTranslateLang(e.target.value)}>
|
||||
<MenuItem value="繁體中文">繁體中文</MenuItem>
|
||||
<MenuItem value="简体中文">简体中文</MenuItem>
|
||||
<MenuItem value="English">English</MenuItem>
|
||||
<MenuItem value="Japanese">Japanese (日本語)</MenuItem>
|
||||
<MenuItem value="Korean">Korean (한국어)</MenuItem>
|
||||
<MenuItem value="Thai">Thai (ภาษาไทย)</MenuItem>
|
||||
<MenuItem value="Vietnamese">Vietnamese (Tiếng Việt)</MenuItem>
|
||||
<MenuItem value="French">French (Français)</MenuItem>
|
||||
<MenuItem value="German">German (Deutsch)</MenuItem>
|
||||
<MenuItem value="Spanish">Spanish (Español)</MenuItem>
|
||||
<MenuItem value="Russian">Russian (Русский)</MenuItem>
|
||||
<MenuItem value="Other">Other</MenuItem>
|
||||
</Select>
|
||||
</FormControl>
|
||||
{translateLang === 'Other' && <TextField label="Specify" size="small" value={customLang} onChange={e => setCustomLang(e.target.value)} sx={{ mt: { xs: 2, sm: 0 } }} />}
|
||||
</Box>
|
||||
<Button size="small" variant="outlined" disabled={!translateTextContent || (translateLang === 'Other' && !customLang)} onClick={() => handleStartTask('translate', translateText, translateTextContent, translateLang === 'Other' ? customLang : translateLang)} sx={{ display: 'block', mt: 2 }}>Start Translation</Button>
|
||||
<TaskMonitor task={tasks.translate} onStop={handleStopTask} title="Translation Progress" />
|
||||
<Paper sx={{p:2, mt:2, minHeight: 150, overflow: 'auto', bgcolor: '#222', position: 'relative'}}>
|
||||
{(tasks.translate?.state === 'PENDING' || tasks.translate?.state === 'PROGRESS') && <Box sx={{ display: 'flex', alignItems: 'center', justifyContent: 'center', height: '100%', position: 'absolute', top: 0, left: 0, right: 0, bottom: 0 }}><CircularProgress size={30} /></Box>}
|
||||
<Typography variant="body2" sx={{whiteSpace: 'pre-wrap'}}>{translatedResult || 'Translation will appear here.'}</Typography>
|
||||
{translatedResult && <Tooltip title={copySuccess === 'translated' ? 'Copied!' : 'Copy to Clipboard'}><IconButton size="small" onClick={() => handleCopyToClipboard(translatedResult, 'translated')} sx={{position: 'absolute', top: 5, right: 5}}><ContentCopyIcon fontSize="small" /></IconButton></Tooltip>}
|
||||
</Paper>
|
||||
{tasks.translate?.state === 'SUCCESS' && tasks.translate.info.result_path && <Button size="small" sx={{mt:1}} startIcon={<DownloadIcon />} onClick={() => downloadFile(tasks.translate.info.result_path)}>Download Translation (.txt)</Button>}
|
||||
</CardContent>
|
||||
</Card>
|
||||
</Grid>
|
||||
</Grid>
|
||||
</Grid>
|
||||
</Grid>
|
||||
</Box>
|
||||
);
|
||||
};
|
||||
|
||||
export default ProcessingPage;
|
Reference in New Issue
Block a user