import './styles/App.css';
import AppRoutes from './app/routes';
+import React from 'react';
+import { BrowserRouter as Router } from 'react-router-dom';
+import { SnackbarProvider } from 'notistack';
+import { CssBaseline, ThemeProvider, createTheme } from '@mui/material';
-function App() {
+const theme = createTheme({
+ palette: {
+ primary: { main: '#1976d2' },
+ secondary: { main: '#dc004e' },
+ },
+});
+
+const App = () => {
return (
- <div className="App">
- <header className="app-header">
- </header>
- { AppRoutes() }
- </div>
+ <ThemeProvider theme={theme}>
+ <CssBaseline />
+ <SnackbarProvider maxSnack={3}>
+ <Router>
+ <AppRoutes />
+ </Router>
+ </SnackbarProvider>
+ </ThemeProvider>
);
-}
+};
export default App;
--- /dev/null
+/**
+ * @file Reusable header component with navigation
+ */
+
+import React, { useContext } from 'react';
+import { AppBar, Toolbar, Typography, Button, Box } from '@mui/material';
+import { useNavigate } from 'react-router-dom';
+import { AuthContext } from './AuthContext';
+
+const AppHeader = () => {
+ const navigate = useNavigate();
+ const { user, logout } = useContext(AuthContext);
+
+ const navItems = [
+ { label: 'Dashboard', path: '/', roles: ['User', 'Admin'] },
+ { label: 'Messages', path: '/messages', roles: ['User', 'Admin'] },
+ { label: 'Message Groups', path: '/message-groups', roles: ['User', 'Admin'] },
+ { label: 'Media', path: '/media', roles: ['User', 'Admin'] },
+ { label: 'Posts', path: '/posts', roles: ['User', 'Admin'] },
+ { label: 'Docker', path: '/docker', roles: ['Admin'] },
+ ];
+
+ return (
+ <AppBar position="static" className="bg-blue-600">
+ <Toolbar className="container">
+ <Typography variant="h6" component="div" className="flex-grow">
+ PHS Admin
+ </Typography>
+ <Box>
+ {navItems.map((item) => (
+ (!user || item.roles.some(role => user.roles?.includes(role))) && (
+ <Button
+ key={item.label}
+ color="inherit"
+ onClick={() => navigate(item.path)}
+ className="mx-2"
+ >
+ {item.label}
+ </Button>
+ )
+ ))}
+ {user ? (
+ <Button color="inherit" onClick={logout}>
+ Logout
+ </Button>
+ ) : (
+ <Button color="inherit" onClick={() => navigate('/login')}>
+ Login
+ </Button>
+ )}
+ </Box>
+ </Toolbar>
+ </AppBar>
+ );
+};
+
+export default AppHeader;
\ No newline at end of file
--- /dev/null
+/**
+ * @file Authentication context for user and role data
+ */
+
+import React, { createContext, useState, useEffect } from 'react';
+import { useNavigate } from 'react-router-dom';
+import { useSnackbar } from 'notistack';
+import { AuthService } from '../services/auth';
+
+export const AuthContext = createContext();
+
+export const AuthProvider = ({ children }) => {
+ const [user, setUser] = useState(null);
+ const navigate = useNavigate();
+ const { enqueueSnackbar } = useSnackbar();
+
+ useEffect(() => {
+ const fetchUser = async () => {
+ try {
+ const token = localStorage.getItem('token');
+ if (token) {
+ const response = await AuthService.getCurrentUser();
+ setUser(response.user);
+ }
+ } catch (error) {
+ enqueueSnackbar(`Error fetching user: ${error.message}`, { variant: 'error' });
+ }
+ };
+ fetchUser();
+ }, [enqueueSnackbar]);
+
+ const login = async (email, password) => {
+ try {
+ const response = await AuthService.login(email, password);
+ if (response.success) {
+ localStorage.setItem('token', response.token);
+ setUser(response.user);
+ navigate('/');
+ return true;
+ }
+ return false;
+ } catch (error) {
+ enqueueSnackbar(`Login error: ${error.message}`, { variant: 'error' });
+ return false;
+ }
+ };
+
+ const logout = () => {
+ localStorage.removeItem('token');
+ setUser(null);
+ navigate('/login');
+ };
+
+ return (
+ <AuthContext.Provider value={{ user, setUser, login, logout }}>
+ {children}
+ </AuthContext.Provider>
+ );
+};
\ No newline at end of file
--- /dev/null
+/**
+ * @file Component for creating a new media item
+ */
+
+import React from 'react';
+import { useForm, Controller } from 'react-hook-form';
+import { yupResolver } from '@hookform/resolvers/yup';
+import * as yup from 'yup';
+import { TextField, MenuItem, Button } from '@mui/material';
+import { useSnackbar } from 'notistack';
+import { useNavigate } from 'react-router-dom';
+import { MediaService } from '../services';
+
+const schema = yup.object({
+ user_id: yup.number().required('User ID is required'),
+ file_path: yup.string().required('File path is required'),
+ file_type: yup.string().required('File type is required'),
+ visibility: yup.string().oneOf(['private', 'family', 'public']).required('Visibility is required'),
+}).required();
+
+const MediaForm = () => {
+ const { enqueueSnackbar } = useSnackbar();
+ const navigate = useNavigate();
+ const { control, handleSubmit, formState: { errors } } = useForm({
+ resolver: yupResolver(schema),
+ });
+
+ const onSubmit = async (data) => {
+ try {
+ await MediaService.createMedia(data);
+ enqueueSnackbar('Media created successfully', { variant: 'success' });
+ navigate('/media');
+ } catch (error) {
+ enqueueSnackbar(`Error creating media: ${error.message}`, { variant: 'error' });
+ }
+ };
+
+ return (
+ <div className="container">
+ <h2 className="text-2xl font-bold mb-4">Upload Media</h2>
+ <form onSubmit={handleSubmit(onSubmit)} className="space-y-4">
+ <Controller
+ name="user_id"
+ control={control}
+ defaultValue=""
+ render={({ field }) => (
+ <TextField
+ {...field}
+ label="User ID"
+ type="number"
+ fullWidth
+ error={!!errors.user_id}
+ helperText={errors.user_id?.message}
+ className="mb-4"
+ />
+ )}
+ />
+ <Controller
+ name="file_path"
+ control={control}
+ defaultValue=""
+ render={({ field }) => (
+ <TextField
+ {...field}
+ label="File Path"
+ fullWidth
+ error={!!errors.file_path}
+ helperText={errors.file_path?.message}
+ className="mb-4"
+ />
+ )}
+ />
+ <Controller
+ name="file_type"
+ control={control}
+ defaultValue=""
+ render={({ field }) => (
+ <TextField
+ {...field}
+ label="File Type"
+ select
+ fullWidth
+ error={!!errors.file_type}
+ helperText={errors.file_type?.message}
+ className="mb-4"
+ >
+ <MenuItem value="image">Image</MenuItem>
+ <MenuItem value="video">Video</MenuItem>
+ </TextField>
+ )}
+ />
+ <Controller
+ name="visibility"
+ control={control}
+ defaultValue=""
+ render={({ field }) => (
+ <TextField
+ {...field}
+ label="Visibility"
+ select
+ fullWidth
+ error={!!errors.visibility}
+ helperText={errors.visibility?.message}
+ className="mb-4"
+ >
+ <MenuItem value="private">Private</MenuItem>
+ <MenuItem value="family">Family</MenuItem>
+ <MenuItem value="public">Public</MenuItem>
+ </TextField>
+ )}
+ />
+ <Button type="submit" variant="contained" color="primary" fullWidth>
+ Upload Media
+ </Button>
+ </form>
+ </div>
+ );
+};
+
+export default MediaForm;
\ No newline at end of file
--- /dev/null
+/**
+ * @file Component for displaying media in a table
+ */
+
+import React, { useMemo, useState } from 'react';
+import { MaterialReactTable } from 'material-react-table';
+import { Button } from '@mui/material';
+import { useSnackbar } from 'notistack';
+import { useNavigate } from 'react-router-dom';
+import { mediaService } from '../services';
+import { formatDate } from '../utils';
+
+const MediaTable = () => {
+ const { enqueueSnackbar } = useSnackbar();
+ const navigate = useNavigate();
+ const [mediaItems, setMediaItems] = useState([]);
+
+ // Fetch media on mount
+ React.useEffect(() => {
+ const fetchMedia = async () => {
+ try {
+ const response = await mediaService.getMedia();
+ setMediaItems(response.data);
+ } catch (error) {
+ enqueueSnackbar(`Error fetching media: ${error.message}`, { variant: 'error' });
+ }
+ };
+ fetchMedia();
+ }, [enqueueSnackbar]);
+
+ const columns = useMemo(() => [
+ {
+ accessorKey: 'id',
+ header: 'ID',
+ size: 80,
+ },
+ {
+ accessorKey: 'user_id',
+ header: 'User ID',
+ size: 100,
+ },
+ {
+ accessorKey: 'file_path',
+ header: 'File Path',
+ size: 200,
+ },
+ {
+ accessorKey: 'file_type',
+ header: 'Type',
+ size: 100,
+ },
+ {
+ accessorKey: 'visibility',
+ header: 'Visibility',
+ size: 100,
+ },
+ {
+ accessorKey: 'created_at',
+ header: 'Created At',
+ size: 150,
+ Cell: ({ cell }) => formatDate(cell.getValue()),
+ },
+ ], []);
+
+ return (
+ <div className="container">
+ <h2 className="text-2xl font-bold mb-4">Media</h2>
+ <Button
+ variant="contained"
+ color="primary"
+ onClick={() => navigate('/media/new')}
+ className="mb-4"
+ >
+ Upload Media
+ </Button>
+ <MaterialReactTable
+ columns={columns}
+ data={mediaItems}
+ enableColumnActions={false}
+ enableColumnFilters={true}
+ enablePagination={true}
+ enableSorting={true}
+ muiTablePaperProps={{
+ className: 'shadow-md rounded-lg',
+ }}
+ muiTableHeadCellProps={{
+ className: 'table-header',
+ }}
+ muiTableBodyCellProps={{
+ className: 'table-row',
+ }}
+ />
+ </div>
+ );
+};
+
+export default MediaTable;
\ No newline at end of file
--- /dev/null
+/**
+ * @file Component for creating a new message
+ */
+
+import React from 'react';
+import { useForm, Controller } from 'react-hook-form';
+import { yupResolver } from '@hookform/resolvers/yup';
+import * as yup from 'yup';
+import { TextField, Button } from '@mui/material';
+import { useSnackbar } from 'notistack';
+import { useNavigate } from 'react-router-dom';
+import { messageService } from '../services';
+
+const schema = yup.object({
+ recipient_id: yup.number().nullable().when('group_id', {
+ is: (group_id) => !group_id,
+ then: yup.number().required('Either recipient_id or group_id is required'),
+ }),
+ group_id: yup.number().nullable().when('recipient_id', {
+ is: (recipient_id) => !recipient_id,
+ then: yup.number().required('Either recipient_id or group_id is required'),
+ }),
+ content: yup.string().required('Content is required'),
+}).required();
+
+const MessageForm = () => {
+ const { enqueueSnackbar } = useSnackbar();
+ const navigate = useNavigate();
+ const { control, handleSubmit, formState: { errors } } = useForm({
+ resolver: yupResolver(schema),
+ });
+
+ const onSubmit = async (data) => {
+ try {
+ await messageService.createMessage(data);
+ enqueueSnackbar('Message created successfully', { variant: 'success' });
+ navigate('/messages');
+ } catch (error) {
+ enqueueSnackbar(`Error creating message: ${error.message}`, { variant: 'error' });
+ }
+ };
+
+ return (
+ <div className="container">
+ <h2 className="text-2xl font-bold mb-4">New Message</h2>
+ <form onSubmit={handleSubmit(onSubmit)} className="space-y-4">
+ <Controller
+ name="recipient_id"
+ control={control}
+ defaultValue=""
+ render={({ field }) => (
+ <TextField
+ {...field}
+ label="Recipient ID"
+ type="number"
+ fullWidth
+ error={!!errors.recipient_id}
+ helperText={errors.recipient_id?.message}
+ className="mb-4"
+ />
+ )}
+ />
+ <Controller
+ name="group_id"
+ control={control}
+ defaultValue=""
+ render={({ field }) => (
+ <TextField
+ {...field}
+ label="Group ID"
+ type="number"
+ fullWidth
+ error={!!errors.group_id}
+ helperText={errors.group_id?.message}
+ className="mb-4"
+ />
+ )}
+ />
+ <Controller
+ name="content"
+ control={control}
+ defaultValue=""
+ render={({ field }) => (
+ <TextField
+ {...field}
+ label="Content"
+ multiline
+ rows={4}
+ fullWidth
+ error={!!errors.content}
+ helperText={errors.content?.message}
+ className="mb-4"
+ />
+ )}
+ />
+ <Button type="submit" variant="contained" color="primary" fullWidth>
+ Send Message
+ </Button>
+ </form>
+ </div>
+ );
+};
+
+export default MessageForm;
\ No newline at end of file
--- /dev/null
+/**
+ * @file Component for creating a new message group
+ */
+
+import React from 'react';
+import { useForm, Controller } from 'react-hook-form';
+import { yupResolver } from '@hookform/resolvers/yup';
+import * as yup from 'yup';
+import { TextField, Button } from '@mui/material';
+import { useSnackbar } from 'notistack';
+import { useNavigate } from 'react-router-dom';
+import { MessageGroupService } from '../services';
+
+const schema = yup.object({
+ name: yup.string().required('Name is required'),
+ created_by_id: yup.number().required('Created by ID is required'),
+}).required();
+
+const MessageGroupForm = () => {
+ const { enqueueSnackbar } = useSnackbar();
+ const navigate = useNavigate();
+ const { control, handleSubmit, formState: { errors } } = useForm({
+ resolver: yupResolver(schema),
+ });
+
+ const onSubmit = async (data) => {
+ try {
+ await MessageGroupService.createMessageGroup(data);
+ enqueueSnackbar('Message group created successfully', { variant: 'success' });
+ navigate('/message-groups');
+ } catch (error) {
+ enqueueSnackbar(`Error creating message group: ${error.message}`, { variant: 'error' });
+ }
+ };
+
+ return (
+ <div className="container">
+ <h2 className="text-2xl font-bold mb-4">New Message Group</h2>
+ <form onSubmit={handleSubmit(onSubmit)} className="space-y-4">
+ <Controller
+ name="name"
+ control={control}
+ defaultValue=""
+ render={({ field }) => (
+ <TextField
+ {...field}
+ label="Group Name"
+ fullWidth
+ error={!!errors.name}
+ helperText={errors.name?.message}
+ className="mb-4"
+ />
+ )}
+ />
+ <Controller
+ name="created_by_id"
+ control={control}
+ defaultValue=""
+ render={({ field }) => (
+ <TextField
+ {...field}
+ label="Created By ID"
+ type="number"
+ fullWidth
+ error={!!errors.created_by_id}
+ helperText={errors.created_by_id?.message}
+ className="mb-4"
+ />
+ )}
+ />
+ <Button type="submit" variant="contained" color="primary" fullWidth>
+ Create Group
+ </Button>
+ </form>
+ </div>
+ );
+};
+
+export default MessageGroupForm;
\ No newline at end of file
--- /dev/null
+/**
+ * @file Component for adding a message group member
+ */
+
+import React from 'react';
+import { useForm, Controller } from 'react-hook-form';
+import { yupResolver } from '@hookform/resolvers/yup';
+import * as yup from 'yup';
+import { TextField, Button } from '@mui/material';
+import { useSnackbar } from 'notistack';
+import { useNavigate, useParams } from 'react-router-dom';
+import { MessageGroupMembersService } from '../services';
+
+const schema = yup.object({
+ user_id: yup.number().required('User ID is required'),
+}).required();
+
+const MessageGroupMemberForm = () => {
+ const { group_id } = useParams();
+ const { enqueueSnackbar } = useSnackbar();
+ const navigate = useNavigate();
+ const { control, handleSubmit, formState: { errors } } = useForm({
+ resolver: yupResolver(schema),
+ });
+
+ const onSubmit = async (data) => {
+ try {
+ await MessageGroupMembersService.addMember({ ...data, group_id });
+ enqueueSnackbar('Member added successfully', { variant: 'success' });
+ navigate(`/message-groups/${group_id}/members`);
+ } catch (error) {
+ enqueueSnackbar(`Error adding member: ${error.message}`, { variant: 'error' });
+ }
+ };
+
+ return (
+ <div className="container">
+ <h2 className="text-2xl font-bold mb-4">Add Member to Group</h2>
+ <form onSubmit={handleSubmit(onSubmit)} className="space-y-4">
+ <Controller
+ name="user_id"
+ control={control}
+ defaultValue=""
+ render={({ field }) => (
+ <TextField
+ {...field}
+ label="User ID"
+ type="number"
+ fullWidth
+ error={!!errors.user_id}
+ helperText={errors.user_id?.message}
+ className="mb-4"
+ />
+ )}
+ />
+ <Button type="submit" variant="contained" color="primary" fullWidth>
+ Add Member
+ </Button>
+ </form>
+ </div>
+ );
+};
+
+export default MessageGroupMemberForm;
\ No newline at end of file
--- /dev/null
+/**
+ * @file Component for displaying message group members in a table
+ */
+
+import React, { useMemo, useState } from 'react';
+import { MaterialReactTable } from 'material-react-table';
+import { Button } from '@mui/material';
+import { useSnackbar } from 'notistack';
+import { useParams, useNavigate } from 'react-router-dom';
+import { messageGroupMembersService } from '../services';
+
+const MessageGroupMembersTable = () => {
+ const { group_id } = useParams();
+ const { enqueueSnackbar } = useSnackbar();
+ const navigate = useNavigate();
+ const [members, setMembers] = useState([]);
+
+ // Fetch members on mount
+ React.useEffect(() => {
+ const fetchMembers = async () => {
+ try {
+ const response = await messageGroupMembersService.getMembersByGroupId(group_id);
+ setMembers(response.data);
+ } catch (error) {
+ enqueueSnackbar(`Error fetching group members: ${error.message}`, { variant: 'error' });
+ }
+ };
+ fetchMembers();
+ }, [group_id, enqueueSnackbar]);
+
+ // Remove member
+ const handleRemoveMember = async (user_id) => {
+ try {
+ await messageGroupMembersService.removeMember({ user_id, group_id });
+ setMembers(members.filter(member => member.user_id !== user_id));
+ enqueueSnackbar('Member removed successfully', { variant: 'success' });
+ } catch (error) {
+ enqueueSnackbar(`Error removing member: ${error.message}`, { variant: 'error' });
+ }
+ };
+
+ const columns = useMemo(() => [
+ {
+ accessorKey: 'user_id',
+ header: 'User ID',
+ size: 100,
+ },
+ {
+ accessorKey: 'group_id',
+ header: 'Group ID',
+ size: 100,
+ },
+ {
+ accessorKey: 'user_id',
+ header: 'Actions',
+ size: 100,
+ Cell: ({ cell }) => (
+ <Button
+ variant="outlined"
+ color="error"
+ onClick={() => handleRemoveMember(cell.getValue())}
+ >
+ Remove
+ </Button>
+ ),
+ },
+ ], [members]);
+
+ return (
+ <div className="container">
+ <h2 className="text-2xl font-bold mb-4">Message Group Members</h2>
+ <Button
+ variant="contained"
+ color="primary"
+ onClick={() => navigate(`/message-groups/${group_id}/members/new`)}
+ className="mb-4"
+ >
+ Add Member
+ </Button>
+ <MaterialReactTable
+ columns={columns}
+ data={members}
+ enableColumnActions={false}
+ enableColumnFilters={true}
+ enablePagination={true}
+ enableSorting={true}
+ muiTablePaperProps={{
+ className: 'shadow-md rounded-lg',
+ }}
+ muiTableHeadCellProps={{
+ className: 'table-header',
+ }}
+ muiTableBodyCellProps={{
+ className: 'table-row',
+ }}
+ />
+ </div>
+ );
+};
+
+export default MessageGroupMembersTable;
\ No newline at end of file
--- /dev/null
+/**
+ * @file Component for displaying message groups in a table
+ */
+
+import React, { useMemo, useState } from 'react';
+import { MaterialReactTable } from 'material-react-table';
+import { Button } from '@mui/material';
+import { useSnackbar } from 'notistack';
+import { useNavigate } from 'react-router-dom';
+import { messageGroupService } from '../services';
+import { formatDate } from '../utils';
+
+const MessageGroupsTable = () => {
+ const { enqueueSnackbar } = useSnackbar();
+ const navigate = useNavigate();
+ const [messageGroups, setMessageGroups] = useState([]);
+
+ // Fetch message groups on mount
+ React.useEffect(() => {
+ const fetchMessageGroups = async () => {
+ try {
+ const response = await messageGroupService.getMessageGroups();
+ setMessageGroups(response.data);
+ } catch (error) {
+ enqueueSnackbar(`Error fetching message groups: ${error.message}`, { variant: 'error' });
+ }
+ };
+ fetchMessageGroups();
+ }, [enqueueSnackbar]);
+
+ const columns = useMemo(() => [
+ {
+ accessorKey: 'id',
+ header: 'ID',
+ size: 80,
+ },
+ {
+ accessorKey: 'name',
+ header: 'Name',
+ size: 200,
+ },
+ {
+ accessorKey: 'created_by_id',
+ header: 'Created By',
+ size: 100,
+ },
+ {
+ accessorKey: 'created_at',
+ header: 'Created At',
+ size: 150,
+ Cell: ({ cell }) => formatDate(cell.getValue()),
+ },
+ {
+ accessorKey: 'id',
+ header: 'Actions',
+ size: 150,
+ Cell: ({ cell }) => (
+ <div className="space-x-2">
+ <Button
+ variant="outlined"
+ color="primary"
+ onClick={() => navigate(`/message-groups/${cell.getValue()}/members`)}
+ >
+ View Members
+ </Button>
+ <Button
+ variant="outlined"
+ color="primary"
+ onClick={() => navigate(`/messages?group_id=${cell.getValue()}`)}
+ >
+ View Messages
+ </Button>
+ </div>
+ ),
+ },
+ ], []);
+
+ return (
+ <div className="container">
+ <h2 className="text-2xl font-bold mb-4">Message Groups</h2>
+ <Button
+ variant="contained"
+ color="primary"
+ onClick={() => navigate('/message-groups/new')}
+ className="mb-4"
+ >
+ New Group
+ </Button>
+ <MaterialReactTable
+ columns={columns}
+ data={messageGroups}
+ enableColumnActions={false}
+ enableColumnFilters={true}
+ enablePagination={true}
+ enableSorting={true}
+ muiTablePaperProps={{
+ className: 'shadow-md rounded-lg',
+ }}
+ muiTableHeadCellProps={{
+ className: 'table-header',
+ }}
+ muiTableBodyCellProps={{
+ className: 'table-row',
+ }}
+ />
+ </div>
+ );
+};
+
+export default MessageGroupsTable;
\ No newline at end of file
--- /dev/null
+/**
+ * @file Component for displaying messages in a table
+ */
+
+import React, { useMemo, useState } from 'react';
+import { MaterialReactTable } from 'material-react-table';
+import { Button, IconButton } from '@mui/material';
+import { MarkEmailRead } from '@mui/icons-material';
+import { useSnackbar } from 'notistack';
+import { useNavigate } from 'react-router-dom';
+import { messageService } from '../services';
+import { formatDate } from '../utils';
+
+const MessagesTable = ({ groupId, recipientId }) => {
+ const { enqueueSnackbar } = useSnackbar();
+ const navigate = useNavigate();
+ const [messages, setMessages] = useState([]);
+
+ // Fetch messages on mount
+ React.useEffect(() => {
+ const fetchMessages = async () => {
+ try {
+ let response;
+ if (groupId) {
+ response = await messageService.getMessagesByGroupId(groupId);
+ } else if (recipientId) {
+ response = await messageService.getMessagesByRecipientId(recipientId);
+ } else {
+ response = await messageService.getMessages();
+ }
+ setMessages(response.data);
+ } catch (error) {
+ enqueueSnackbar(`Error fetching messages: ${error.message}`, { variant: 'error' });
+ }
+ };
+ fetchMessages();
+ }, [enqueueSnackbar, groupId, recipientId]);
+
+ // Mark message as read
+ const handleMarkAsRead = async (messageId) => {
+ try {
+ const response = await messageService.markMessageAsRead(messageId);
+ setMessages(messages.map(msg => msg.id === messageId ? response.data : msg));
+ enqueueSnackbar('Message marked as read', { variant: 'success' });
+ } catch (error) {
+ enqueueSnackbar(`Error marking message as read: ${error.message}`, { variant: 'error' });
+ }
+ };
+
+ const columns = useMemo(() => [
+ {
+ accessorKey: 'sender_id',
+ header: 'Sender ID',
+ size: 100,
+ },
+ {
+ accessorKey: 'content',
+ header: 'Content',
+ size: 300,
+ },
+ {
+ accessorKey: 'read',
+ header: 'Read',
+ size: 80,
+ Cell: ({ cell }) => (cell.getValue() ? 'Yes' : 'No'),
+ },
+ {
+ accessorKey: 'read_at',
+ header: 'Read At',
+ size: 150,
+ Cell: ({ cell }) => formatDate(cell.getValue()),
+ },
+ {
+ accessorKey: 'created_at',
+ header: 'Created At',
+ size: 150,
+ Cell: ({ cell }) => formatDate(cell.getValue()),
+ },
+ {
+ accessorKey: 'id',
+ header: 'Actions',
+ size: 100,
+ Cell: ({ cell }) => (
+ !cell.row.original.read && (
+ <IconButton
+ color="primary"
+ onClick={() => handleMarkAsRead(cell.getValue())}
+ title="Mark as Read"
+ >
+ <MarkEmailRead />
+ </IconButton>
+ )
+ ),
+ },
+ ], [messages]);
+
+ return (
+ <div className="container">
+ <h2 className="text-2xl font-bold mb-4">Messages</h2>
+ <Button
+ variant="contained"
+ color="primary"
+ onClick={() => navigate('/messages/new')}
+ className="mb-4"
+ >
+ New Message
+ </Button>
+ <MaterialReactTable
+ columns={columns}
+ data={messages}
+ enableColumnActions={false}
+ enableColumnFilters={true}
+ enablePagination={true}
+ enableSorting={true}
+ muiTablePaperProps={{
+ className: 'shadow-md rounded-lg',
+ }}
+ muiTableHeadCellProps={{
+ className: 'table-header',
+ }}
+ muiTableBodyCellProps={{
+ className: 'table-row',
+ }}
+ />
+ </div>
+ );
+};
+
+export default MessagesTable;
\ No newline at end of file
--- /dev/null
+/**
+ * @file Component for creating a new post
+ */
+
+import React from 'react';
+import { useForm, Controller } from 'react-hook-form';
+import { yupResolver } from '@hookform/resolvers/yup';
+import * as yup from 'yup';
+import { TextField, MenuItem, Button } from '@mui/material';
+import { useSnackbar } from 'notistack';
+import { useNavigate } from 'react-router-dom';
+import { PostService } from '../services';
+
+const schema = yup.object({
+ user_id: yup.number().required('User ID is required'),
+ title: yup.string().required('Title is required'),
+ content: yup.string().required('Content is required'),
+ post_type: yup.string().oneOf(['blog', 'vlog']).required('Post type is required'),
+ visibility: yup.string().oneOf(['private', 'family', 'public']).required('Visibility is required'),
+}).required();
+
+const PostForm = () => {
+ const { enqueueSnackbar } = useSnackbar();
+ const navigate = useNavigate();
+ const { control, handleSubmit, formState: { errors } } = useForm({
+ resolver: yupResolver(schema),
+ });
+
+ const onSubmit = async (data) => {
+ try {
+ await PostService.createPost(data);
+ enqueueSnackbar('Post created successfully', { variant: 'success' });
+ navigate('/posts');
+ } catch (error) {
+ enqueueSnackbar(`Error creating post: ${error.message}`, { variant: 'error' });
+ }
+ };
+
+ return (
+ <div className="container">
+ <h2 className="text-2xl font-bold mb-4">Create Post</h2>
+ <form onSubmit={handleSubmit(onSubmit)} className="space-y-4">
+ <Controller
+ name="user_id"
+ control={control}
+ defaultValue=""
+ render={({ field }) => (
+ <TextField
+ {...field}
+ label="User ID"
+ type="number"
+ fullWidth
+ error={!!errors.user_id}
+ helperText={errors.user_id?.message}
+ className="mb-4"
+ />
+ )}
+ />
+ <Controller
+ name="title"
+ control={control}
+ defaultValue=""
+ render={({ field }) => (
+ <TextField
+ {...field}
+ label="Title"
+ fullWidth
+ error={!!errors.title}
+ helperText={errors.title?.message}
+ className="mb-4"
+ />
+ )}
+ />
+ <Controller
+ name="content"
+ control={control}
+ defaultValue=""
+ render={({ field }) => (
+ <TextField
+ {...field}
+ label="Content"
+ multiline
+ rows={6}
+ fullWidth
+ error={!!errors.content}
+ helperText={errors.content?.message}
+ className="mb-4"
+ />
+ )}
+ />
+ <Controller
+ name="post_type"
+ control={control}
+ defaultValue=""
+ render={({ field }) => (
+ <TextField
+ {...field}
+ label="Post Type"
+ select
+ fullWidth
+ error={!!errors.post_type}
+ helperText={errors.post_type?.message}
+ className="mb-4"
+ >
+ <MenuItem value="blog">Blog</MenuItem>
+ <MenuItem value="vlog">Vlog</MenuItem>
+ </TextField>
+ )}
+ />
+ <Controller
+ name="visibility"
+ control={control}
+ defaultValue=""
+ render={({ field }) => (
+ <TextField
+ {...field}
+ label="Visibility"
+ select
+ fullWidth
+ error={!!errors.visibility}
+ helperText={errors.visibility?.message}
+ className="mb-4"
+ >
+ <MenuItem value="private">Private</MenuItem>
+ <MenuItem value="family">Family</MenuItem>
+ <MenuItem value="public">Public</MenuItem>
+ </TextField>
+ )}
+ />
+ <Button type="submit" variant="contained" color="primary" fullWidth>
+ Create Post
+ </Button>
+ </form>
+ </div>
+ );
+};
+
+export default PostForm;
\ No newline at end of file
--- /dev/null
+/**
+ * @file Component for displaying posts in a table
+ */
+
+import React, { useMemo, useState } from 'react';
+import { MaterialReactTable } from 'material-react-table';
+import { Button } from '@mui/material';
+import { useSnackbar } from 'notistack';
+import { useNavigate } from 'react-router-dom';
+import { postService } from '../services';
+import { formatDate } from '../utils';
+
+const PostsTable = () => {
+ const { enqueueSnackbar } = useSnackbar();
+ const navigate = useNavigate();
+ const [posts, setPosts] = useState([]);
+
+ // Fetch posts on mount
+ React.useEffect(() => {
+ const fetchPosts = async () => {
+ try {
+ const response = await postService.getPosts();
+ setPosts(response.data);
+ } catch (error) {
+ enqueueSnackbar(`Error fetching posts: ${error.message}`, { variant: 'error' });
+ }
+ };
+ fetchPosts();
+ }, [enqueueSnackbar]);
+
+ const columns = useMemo(() => [
+ {
+ accessorKey: 'id',
+ header: 'ID',
+ size: 80,
+ },
+ {
+ accessorKey: 'user_id',
+ header: 'User ID',
+ size: 100,
+ },
+ {
+ accessorKey: 'title',
+ header: 'Title',
+ size: 200,
+ },
+ {
+ accessorKey: 'content',
+ header: 'Content',
+ size: 300,
+ },
+ {
+ accessorKey: 'post_type',
+ header: 'Type',
+ size: 100,
+ },
+ {
+ accessorKey: 'visibility',
+ header: 'Visibility',
+ size: 100,
+ },
+ {
+ accessorKey: 'created_at',
+ header: 'Created At',
+ size: 150,
+ Cell: ({ cell }) => formatDate(cell.getValue()),
+ },
+ ], []);
+
+ return (
+ <div className="container">
+ <h2 className="text-2xl font-bold mb-4">Posts</h2>
+ <Button
+ variant="contained"
+ color="primary"
+ onClick={() => navigate('/posts/new')}
+ className="mb-4"
+ >
+ Create Post
+ </Button>
+ <MaterialReactTable
+ columns={columns}
+ data={posts}
+ enableColumnActions={false}
+ enableColumnFilters={true}
+ enablePagination={true}
+ enableSorting={true}
+ muiTablePaperProps={{
+ className: 'shadow-md rounded-lg',
+ }}
+ muiTableHeadCellProps={{
+ className: 'table-header',
+ }}
+ muiTableBodyCellProps={{
+ className: 'table-row',
+ }}
+ />
+ </div>
+ );
+};
+
+export default PostsTable;
\ No newline at end of file
import { Route, Routes } from 'react-router-dom';
import { Login, NotFound, Dashboard, Git, VPN, Docker } from './views';
import { VPNProvider } from './components/';
+import MessagesView from './views/Messages/MessagesView';
+import MessageFormView from './views/Messages/MessageFormView';
+import ChatView from './views/Messages/ChatView';
+import MessageGroupsView from './views/MessageGroups/MessageGroupsView';
+import MessageGroupFormView from './views/MessageGroups/MessageGroupFormView';
+import MessageGroupMembersView from './views/MessageGroupMembers/MessageGroupMembersView';
+import MessageGroupMemberFormView from './views/MessageGroupMembers/MessageGroupMemberFormView';
+import MediaView from './views/Media/MediaView';
+import MediaFormView from './views/Media/MediaFormView';
+import PostsView from './views/Posts/PostsView';
+import PostFormView from './views/Posts/PostFormView';
export default function AppRoutes() {
return (
<Route path="/git" element={ <Git /> } />
<Route path="/vpn" element={ <VPNProvider><VPN/></VPNProvider> } />
<Route path="/docker" element={ <Docker /> } />
+ <Route path="/messages" element={<MessagesView />} />
+ <Route path="/messages/new" element={<MessageFormView />} />
+ <Route path="/messages/group/:group_id" element={<ChatView />} />
+ <Route path="/messages/recipient/:recipient_id" element={<ChatView />} />
+ <Route path="/message-groups" element={<MessageGroupsView />} />
+ <Route path="/message-groups/new" element={<MessageGroupFormView />} />
+ <Route path="/message-groups/:group_id/members" element={<MessageGroupMembersView />} />
+ <Route path="/message-groups/:group_id/members/new" element={<MessageGroupMemberFormView />} />
+ <Route path="/media" element={<MediaView />} />
+ <Route path="/media/new" element={<MediaFormView />} />
+ <Route path="/posts" element={<PostsView />} />
+ <Route path="/posts/new" element={<PostFormView />} />
<Route path="*" element={ <NotFound /> } />
</Routes>
);
--- /dev/null
+/**
+ * @file Authentication service for login API calls
+ */
+
+import axios from 'axios';
+
+const BASE_URL = 'http://localhost:3601';
+
+const getAuthHeaders = () => ({
+ headers: { Authorization: `Bearer ${localStorage.getItem('token')}` },
+});
+
+export const AuthService = {
+ async login(email, password) {
+ const response = await axios.post(`${BASE_URL}/auth/authenticate`, { email, password });
+ return response.data;
+ },
+ async getCurrentUser() {
+ const response = await axios.get(`${BASE_URL}/user/1`, getAuthHeaders());
+ return { user: response.data };
+ },
+};
\ No newline at end of file
import GitService from './Git/GitService';
import VPNService from './VPN/VPNService';
+
+export { MessageService } from './messages';
+export { MediaService } from './media';
+export { MessageGroupService } from './message_groups';
+export { MessageGroupMembersService } from './message_group_members';
+export { PostService } from './posts';
export { DockerService, GitService, VPNService };
\ No newline at end of file
--- /dev/null
+/**
+ * @file Media service for API calls
+ */
+
+import axios from 'axios';
+
+const BASE_URL = 'http://localhost:3601';
+
+const getAuthHeaders = () => ({
+ headers: { Authorization: `Bearer ${localStorage.getItem('token')}` },
+});
+
+export const MediaService = {
+ async getMedia(params = {}) {
+ return await axios.get(`${BASE_URL}/media`, { ...getAuthHeaders(), params });
+ },
+ async getMediaByFilePath(file_path) {
+ return await axios.get(`${BASE_URL}/media/file_path/${file_path}`, getAuthHeaders());
+ },
+ async getMediaById(id) {
+ return await axios.get(`${BASE_URL}/media/${id}`, getAuthHeaders());
+ },
+ async createMedia(data) {
+ return await axios.post(`${BASE_URL}/media/create`, data, getAuthHeaders());
+ },
+ async updateMedia(id, data) {
+ return await axios.put(`${BASE_URL}/media/${id}`, data, getAuthHeaders());
+ },
+ async softDeleteMedia(id, data) {
+ return await axios.put(`${BASE_URL}/media/${id}/soft_delete`, data, getAuthHeaders());
+ },
+};
\ No newline at end of file
--- /dev/null
+/**
+ * @file Message group members service for API calls
+ */
+
+import axios from 'axios';
+
+const BASE_URL = 'http://localhost:3601';
+
+const getAuthHeaders = () => ({
+ headers: { Authorization: `Bearer ${localStorage.getItem('token')}` },
+});
+
+export const MessageGroupMembersService = {
+ async getMembersByGroupId(group_id, params = {}) {
+ return await axios.get(`${BASE_URL}/message_group_members/group/${group_id}`, { ...getAuthHeaders(), params });
+ },
+ async getMemberByIds(group_id, user_id) {
+ return await axios.get(`${BASE_URL}/message_group_members/ids/${group_id}/${user_id}`, getAuthHeaders());
+ },
+ async addMember(data) {
+ return await axios.post(`${BASE_URL}/message_group_members/add`, data, getAuthHeaders());
+ },
+ async removeMember(data) {
+ return await axios.delete(`${BASE_URL}/message_group_members/remove`, { ...getAuthHeaders(), data });
+ },
+};
\ No newline at end of file
--- /dev/null
+/**
+ * @file Message group service for API calls
+ */
+
+import axios from 'axios';
+
+const BASE_URL = 'http://localhost:3601';
+
+const getAuthHeaders = () => ({
+ headers: { Authorization: `Bearer ${localStorage.getItem('token')}` },
+});
+
+export const MessageGroupService = {
+ async getMessageGroups(params = {}) {
+ return await axios.get(`${BASE_URL}/message_group`, { ...getAuthHeaders(), params });
+ },
+ async getMessageGroup(id) {
+ return await axios.get(`${BASE_URL}/message_group/${id}`, getAuthHeaders());
+ },
+ async createMessageGroup(data) {
+ return await axios.post(`${BASE_URL}/message_group/create`, data, getAuthHeaders());
+ },
+ async updateMessageGroup(id, data) {
+ return await axios.put(`${BASE_URL}/message_group/${id}`, data, getAuthHeaders());
+ },
+};
\ No newline at end of file
--- /dev/null
+/**
+ * @file Message service for API calls
+ */
+
+import axios from 'axios';
+
+const BASE_URL = 'http://localhost:3601';
+
+const getAuthHeaders = () => ({
+ headers: { Authorization: `Bearer ${localStorage.getItem('token')}` },
+});
+
+export const MessageService = {
+ async getMessages(params = {}) {
+ return await axios.get(`${BASE_URL}/message`, { ...getAuthHeaders(), params });
+ },
+ async getMessagesByGroupId(group_id, params = {}) {
+ return await axios.get(`${BASE_URL}/message/group/${group_id}`, { ...getAuthHeaders(), params });
+ },
+ async getMessagesByRecipientId(recipient_id, params = {}) {
+ return await axios.get(`${BASE_URL}/message/recipient/${recipient_id}`, { ...getAuthHeaders(), params });
+ },
+ async getMessage(id) {
+ return await axios.get(`${BASE_URL}/message/${id}`, getAuthHeaders());
+ },
+ async createMessage(data) {
+ return await axios.post(`${BASE_URL}/message/create`, data, getAuthHeaders());
+ },
+ async markMessageAsRead(id) {
+ return await axios.put(`${BASE_URL}/message/${id}/mark_as_read`, {}, getAuthHeaders());
+ },
+};
\ No newline at end of file
--- /dev/null
+/**
+ * @file Post service for API calls
+ */
+
+import axios from 'axios';
+
+const BASE_URL = 'http://localhost:3601';
+
+const getAuthHeaders = () => ({
+ headers: { Authorization: `Bearer ${localStorage.getItem('token')}` },
+});
+
+export const PostService = {
+ async getPosts(params = {}) {
+ return await axios.get(`${BASE_URL}/post`, { ...getAuthHeaders(), params });
+ },
+ async getPostByTitle(title) {
+ return await axios.get(`${BASE_URL}/post/title/${title}`, getAuthHeaders());
+ },
+ async getPost(id) {
+ return await axios.get(`${BASE_URL}/post/${id}`, getAuthHeaders());
+ },
+ async createPost(data) {
+ return await axios.post(`${BASE_URL}/post/create`, data, getAuthHeaders());
+ },
+ async updatePost(id, data) {
+ return await axios.put(`${BASE_URL}/post/${id}`, data, getAuthHeaders());
+ },
+ async softDeletePost(id, data) {
+ return await axios.put(`${BASE_URL}/post/${id}/soft_delete`, data, getAuthHeaders());
+ },
+};
\ No newline at end of file
-import React, { useState, useEffect } from 'react';
-import { useMaterialReactTable, MaterialReactTable } from 'material-react-table';
-import { Container, IconButton } from '@mui/material';
-import { VisibilityOutlined } from '@mui/icons-material';
-// import { FormService } from '../../services';
-// import { FormDetail } from '../../components';
-import { getFormattedDate } from '../../utils';
-
-const Dashboard = () => {
- const [data, setData] = useState( [] );
- const [selectedFormID, setSelectedFormID] = useState( null );
- const [detailOpen, setDetailOpen] = useState( false );
- const [selectedForm, setSelectedForm] = useState( null );
+/**
+ * @file Dashboard view with navigation to main features
+ */
+import React, { useContext } from 'react';
+import { Container, Grid, Card, CardContent, Typography, Button } from '@mui/material';
+import { useNavigate } from 'react-router-dom';
+import { AuthContext } from '../../components/AuthContext';
+import AppHeader from '../../components/AppHeader';
- // useEffect( () => {
- // FormService.get( null, null ).then( response => {
- // console.log('response', response);
- // setData(response);
- // });
- // }, [] );
- //
- // useEffect( () => {
- // if (selectedFormID) {
- // FormService.get( null, selectedFormID ).then( response => {
- // setSelectedForm( response );
- // });
- // }
- // }, [selectedFormID] );
-
- const table = useMaterialReactTable({
- columns: [
- {
- header: 'Actions',
- id: 'woActions',
- enableSorting: false,
- enableGlobalFilter: false,
- enableColumnFilter: false,
- enableGrouping: false,
- enableEditing: false,
- size: 150,
- accessorFn: (row) => {
- const showSelectedModal = () => {
- setSelectedFormID(row.id)
- setDetailOpen(true);
- };
+const Dashboard = () => {
+ const navigate = useNavigate();
+ const { user } = useContext(AuthContext);
- return(
- <IconButton size='small'
- color='primary'
- onClick={ showSelectedModal }>
- <VisibilityOutlined fontSize="inherit" />
- </IconButton>
- )
- },
- },
- {
- header: 'ID',
- accessorKey: 'id', //access nested data with dot notation
- },
- {
- header:'Unit#',
- accessorKey: 'unit_number', //simple function
- },
- {
- header: 'Employee Name',
- accessorKey: 'employee_name'
- },
- {
- header: 'Employee Number',
- accessorKey: 'employee_number'
- },
- {
- header: 'Email Address',
- accessorKey: 'email_address'
- },
- {
- header: 'Photos',
- accessorKey: 'photo_count'
- },
- {
- header: 'Date',
- accessorKey: 'created_at',
- accessorFn: (row) => {
- return getFormattedDate(row.created_at);
- }
- }
- ],
- data
- });
- return <Container maxWidth='xl' >
+ const features = [
+ { title: 'Messages', path: '/messages', description: 'View and send messages', roles: ['User', 'Admin'] },
+ { title: 'Message Groups', path: '/message-groups', description: 'Manage group chats', roles: ['User', 'Admin'] },
+ { title: 'Media', path: '/media', description: 'Upload and view media', roles: ['User', 'Admin'] },
+ { title: 'Posts', path: '/posts', description: 'Create and view posts', roles: ['User', 'Admin'] },
+ { title: 'Docker', path: '/docker', description: 'Manage Docker containers', roles: ['Admin'] },
+ ];
- {/*<FormDetail isOpen={detailOpen} setOpen={setDetailOpen} inspection={selectedForm} setSelectedForm={setSelectedForm} setSelectedFormID={setSelectedFormID}/>*/}
- <MaterialReactTable table={table} />
- </Container>;
+ return (
+ <div>
+ <AppHeader />
+ <Container maxWidth="xl" className="container">
+ <Typography variant="h4" className="text-3xl font-bold mb-6 mt-4">
+ Welcome to PHS Admin{user ? `, ${user.first_name || 'User ' + user.id}` : ''}
+ </Typography>
+ <Grid container spacing={3}>
+ {features.map((feature) => (
+ (!user || feature.roles.some(role => user.roles?.includes(role))) && (
+ <Grid item xs={12} sm= {6} md={4} key={feature.title}>
+ <Card className="shadow-md rounded-lg">
+ <CardContent>
+ <Typography variant="h6" className="font-semibold">
+ {feature.title}
+ </Typography>
+ <Typography variant="body2" className="text-gray-600 mb-4">
+ {feature.description}
+ </Typography>
+ <Button
+ variant="contained"
+ color="primary"
+ onClick={() => navigate(feature.path)}
+ >
+ Go to {feature.title}
+ </Button>
+ </CardContent>
+ </Card>
+ </Grid>
+ )
+ ))}
+ </Grid>
+ </Container>
+ </div>
+ );
};
export default Dashboard;
\ No newline at end of file
-import { useState } from 'react';
+/**
+ * @file Login view with authentication form
+ */
+
+import React, { useContext } from 'react';
+import { Container, TextField, Button, Typography, Box } from '@mui/material';
+import { useForm, Controller } from 'react-hook-form';
import { yupResolver } from '@hookform/resolvers/yup';
-import { Controller, useForm } from 'react-hook-form';
-import {
- Button,
- Checkbox,
- FormControl,
- FormControlLabel,
- FormHelperText,
- TextField,
- Typography,
- Box,
- Paper,
- InputAdornment, IconButton
-} from '@mui/material';
import * as yup from 'yup';
-import _ from '../../../@lodash';
-// import jwtService from '../../auth/services/jwtService';
-import { Visibility, VisibilityOff } from '@mui/icons-material';
+import { useSnackbar } from 'notistack';
import { useNavigate } from 'react-router-dom';
+import { AuthContext } from '../../components/AuthContext';
+import AppHeader from '../../components/AppHeader';
-/**
- * Form Validation Schema
- */
-const schema = yup.object().shape( {
- email:yup.string().email( 'You must enter a valid email' ).required( 'You must enter a email' ),
- password:yup
- .string()
- .required( 'Please enter your password.' )
- .min( 4, 'Password is too short - must be at least 4 chars.' ),
-} );
+const schema = yup.object({
+ email: yup.string().email('Invalid email').required('Email is required'),
+ password: yup.string().required('Password is required'),
+}).required();
-const defaultValues = {
- email:'',
- password:'',
- remember:true,
-};
-
-function SignInPage() {
- const { control, formState, handleSubmit, setError } = useForm( {
- mode:'onChange',
- defaultValues,
- resolver:yupResolver( schema ),
- } );
- const [showPassword, setShowPassword] = useState( false );
- const [signInError, setSignInError] = useState(null);
+const Login = () => {
+ const { enqueueSnackbar } = useSnackbar();
const navigate = useNavigate();
- const { isValid, dirtyFields, errors } = formState;
-
- function onSubmit( { email, password } ) {
- setSignInError(null);
- /*jwtService
- .signInWithEmailAndPassword( email, password )
- .then( data => {
- if(data.requires_2fa) {
- navigate('/sign-in-verify', { state: { data: data } });
- }
- } )
- .catch( (error) => {
- const message = error?.response?.data?.message;
- console.log(`Sign-in error: ${error}, sub error: ${message}`);
- setSignInError(message);
- } );*/
- }
+ const { login } = useContext(AuthContext);
+ const { control, handleSubmit, formState: { errors } } = useForm({
+ resolver: yupResolver(schema),
+ });
+
+ const onSubmit = async (data) => {
+ const success = await login(data.email, data.password);
+ if (success) {
+ enqueueSnackbar('Login successful', { variant: 'success' });
+ } else {
+ enqueueSnackbar('Invalid credentials', { variant: 'error' });
+ }
+ };
return (
- <div
- className="flex flex-col sm:flex-row items-center md:items-start sm:justify-center md:justify-start flex-1 min-w-0">
- <Paper
- className="h-full sm:h-auto md:flex md:items-center md:justify-end w-full sm:w-auto md:h-full md:w-1/2 py-8 px-16 sm:p-48 md:p-64 sm:rounded-2xl md:rounded-none sm:shadow md:shadow-none ltr:border-r-1 rtl:border-l-1">
- <div className="w-full max-w-320 sm:w-320 mx-auto sm:mx-0">
-
- <Typography fontSize={'2rem'} className="mt-32 text-4xl font-extrabold tracking-tight leading-tight">
- Sign in
- </Typography>
-
- {signInError &&
- <FormHelperText id="validation-helper-text" className="mt-24 mb-24 text-lg" error>
- {signInError}
- </FormHelperText>
- }
-
- <form
- name="loginForm"
- noValidate
- className="flex flex-col justify-center w-full mt-32"
- onSubmit={ handleSubmit( onSubmit ) }
- >
- <Controller
- name="employee_number"
- control={ control }
- render={ ( { field } ) => (
- <TextField
- { ...field }
- className="mb-24"
- label="Employee Number"
- autoFocus
- type="text"
- error={ !!errors.employee_number }
- helperText={ errors?.employee_number?.message }
- variant="outlined"
- required
- fullWidth
- />
- ) }
- />
-
- <Controller
- name="password"
- control={ control }
- render={ ( { field } ) => (
- <TextField
- { ...field }
- className="mb-6"
- label="Password"
- type={ showPassword ? 'text' : 'password' }
- error={ !!errors.password }
- inputProps={{
- 'aria-describedby': errors.password ? 'password-helper-text' : undefined,
- }}
- InputProps={{
- endAdornment:
- <InputAdornment position='end' >
- <IconButton aria-label='toggle password visibility' onClick={ () => setShowPassword( prevState => !prevState ) }>
- { showPassword ? <VisibilityOff/> : <Visibility/> }
- </IconButton>
- </InputAdornment>
- }}
- variant="outlined"
- required
- fullWidth
- />
- ) }
- />
- {errors?.password && (
- <FormHelperText id="password-helper-text" className="mb-24" error>
- {errors.password.message}
- </FormHelperText>
+ <div>
+ <AppHeader />
+ <Container maxWidth="sm" className="container">
+ <Typography variant="h4" className="text-3xl font-bold mb-6 mt-4">
+ Login to PHS Admin
+ </Typography>
+ <Box component="form" onSubmit={handleSubmit(onSubmit)} className="space-y-4">
+ <Controller
+ name="email"
+ control={control}
+ defaultValue=""
+ render={({ field }) => (
+ <TextField
+ {...field}
+ label="Email"
+ fullWidth
+ error={!!errors.email}
+ helperText={errors.email?.message}
+ className="mb-4"
+ />
)}
-
- <div className="flex flex-col sm:flex-row items-center justify-center sm:justify-between">
- <Controller
- name="remember"
- control={ control }
- render={ ( { field } ) => (
- <FormControl>
- <FormControlLabel
- label="Remember me"
- control={ <Checkbox size="small" { ...field } /> }
- />
- </FormControl>
- ) }
+ />
+ <Controller
+ name="password"
+ control={control}
+ defaultValue=""
+ render={({ field }) => (
+ <TextField
+ {...field}
+ label="Password"
+ type="password"
+ fullWidth
+ error={!!errors.password}
+ helperText={errors.password?.message}
+ className="mb-4"
/>
- </div>
-
- <Button
- variant="contained"
- color="secondary"
- className=" w-full mt-16"
- aria-label="Sign in"
- disabled={ _.isEmpty( dirtyFields ) || !isValid }
- type="submit"
- size="large"
- >Sign in</Button>
-
- <Button
- variant="outlined"
- color="primary"
- className=" w-full mt-16"
- aria-label="Forgot Password"
- size="large"
- onClick={() => {
- navigate('/forgot-password');
- }}
- >Forgot Password</Button>
-
- </form>
- </div>
- </Paper>
-
- <Box
- className="relative hidden md:flex flex-auto items-center justify-center h-full p-64 lg:px-112 overflow-hidden"
- sx={ { backgroundColor:'primary.main' } }
- >
- <svg
- className="absolute inset-0 pointer-events-none"
- viewBox="0 0 960 540"
- width="100%"
- height="100%"
- preserveAspectRatio="xMidYMax slice"
- xmlns="http://www.w3.org/2000/svg"
- >
- <Box
- component="g"
- sx={ { color:'primary.light' } }
- className="opacity-20"
- fill="none"
- stroke="currentColor"
- strokeWidth="100"
- >
- <circle r="234" cx="196" cy="23"/>
- <circle r="234" cx="790" cy="491"/>
- </Box>
- </svg>
- <Box
- component="svg"
- className="absolute -top-64 -right-64 opacity-20"
- sx={ { color:'primary.light' } }
- viewBox="0 0 220 192"
- width="220px"
- height="192px"
- fill="none"
- >
- <defs>
- <pattern
- id="837c3e70-6c3a-44e6-8854-cc48c737b659"
- x="0"
- y="0"
- width="20"
- height="20"
- patternUnits="userSpaceOnUse"
- >
- <rect x="0" y="0" width="4" height="4" fill="currentColor"/>
- </pattern>
- </defs>
- <rect width="220" height="192" fill="url(#837c3e70-6c3a-44e6-8854-cc48c737b659)"/>
+ )}
+ />
+ <Button type="submit" variant="contained" color="primary" fullWidth>
+ Login
+ </Button>
</Box>
-
- <div className="z-10 relative w-full max-w-2xl">
- <div className="text-7xl font-bold leading-none text-grey-100">
- <div>Please Login</div>
- </div>
- <div className="mt-24 text-lg tracking-tight leading-6 text-grey-400">
- Enter your company employee number and password to log in.
- </div>
- </div>
- </Box>
+ </Container>
</div>
);
-}
+};
-export default SignInPage;
+export default Login;
\ No newline at end of file
--- /dev/null
+/**
+ * @file View for creating a new media item
+ */
+
+import React from 'react';
+import MediaForm from '../../components/MediaForm';
+
+const MediaFormView = () => {
+ return (
+ <div className="container">
+ <MediaForm />
+ </div>
+ );
+};
+
+export default MediaFormView;
\ No newline at end of file
--- /dev/null
+/**
+ * @file View for displaying media
+ */
+
+import React from 'react';
+import MediaTable from '../../components/MediaTable';
+
+const MediaView = () => {
+ return (
+ <div className="container">
+ <MediaTable />
+ </div>
+ );
+};
+
+export default MediaView;
\ No newline at end of file
--- /dev/null
+/**
+ * @file View for adding a message group member
+ */
+
+import React from 'react';
+import MessageGroupMemberForm from '../../components/MessageGroupMemberForm';
+
+const MessageGroupMemberFormView = () => {
+ return (
+ <div className="container">
+ <MessageGroupMemberForm />
+ </div>
+ );
+};
+
+export default MessageGroupMemberFormView;
\ No newline at end of file
--- /dev/null
+/**
+ * @file View for displaying message group members
+ */
+
+import React from 'react';
+import MessageGroupMembersTable from '../../components/MessageGroupMembersTable';
+
+const MessageGroupMembersView = () => {
+ return (
+ <div className="container">
+ <MessageGroupMembersTable />
+ </div>
+ );
+};
+
+export default MessageGroupMembersView;
\ No newline at end of file
--- /dev/null
+/**
+ * @file View for creating a new message group
+ */
+
+import React from 'react';
+import MessageGroupForm from '../../components/MessageGroupForm';
+
+const MessageGroupFormView = () => {
+ return (
+ <div className="container">
+ <MessageGroupForm />
+ </div>
+ );
+};
+
+export default MessageGroupFormView;
\ No newline at end of file
--- /dev/null
+/**
+ * @file View for displaying message groups
+ */
+
+import React from 'react';
+import MessageGroupsTable from '../../components/MessageGroupsTable';
+
+const MessageGroupsView = () => {
+ return (
+ <div className="container">
+ <MessageGroupsTable />
+ </div>
+ );
+};
+
+export default MessageGroupsView;
\ No newline at end of file
--- /dev/null
+/**
+ * @file View for displaying messages in a chat-like interface
+ */
+
+import React, { useState, useEffect, useRef } from 'react';
+import { TextField, Button, Typography } from '@mui/material';
+import { useSnackbar } from 'notistack';
+import { useParams, useNavigate } from 'react-router-dom';
+import { MessageService } from '../../services';
+import { formatDate, truncateContent } from '../../utils';
+
+const ChatView = () => {
+ const { group_id, recipient_id } = useParams();
+ const { enqueueSnackbar } = useSnackbar();
+ const navigate = useNavigate();
+ const [messages, setMessages] = useState([]);
+ const [newMessage, setNewMessage] = useState('');
+ const messagesEndRef = useRef(null);
+
+ // Fetch messages
+ useEffect(() => {
+ const fetchMessages = async () => {
+ try {
+ let response;
+ if (group_id) {
+ response = await MessageService.getMessagesByGroupId(group_id);
+ } else if (recipient_id) {
+ response = await MessageService.getMessagesByRecipientId(recipient_id);
+ }
+ setMessages(response.data);
+ } catch (error) {
+ enqueueSnackbar(`Error fetching messages: ${error.message}`, { variant: 'error' });
+ }
+ };
+ fetchMessages();
+ }, [group_id, recipient_id, enqueueSnackbar]);
+
+ // Scroll to bottom on new messages
+ useEffect(() => {
+ messagesEndRef.current?.scrollIntoView({ behavior: 'smooth' });
+ }, [messages]);
+
+ // Send message
+ const handleSendMessage = async () => {
+ if (!newMessage.trim()) return;
+ try {
+ const data = {
+ sender_id: JSON.parse(localStorage.getItem('user'))?.id || 1, // Adjust based on auth
+ group_id: group_id ? parseInt(group_id) : null,
+ recipient_id: recipient_id ? parseInt(recipient_id) : null,
+ content: newMessage,
+ };
+ const response = await MessageService.createMessage(data);
+ setMessages([...messages, response.data]);
+ setNewMessage('');
+ enqueueSnackbar('Message sent successfully', { variant: 'success' });
+ } catch (error) {
+ enqueueSnackbar(`Error sending message: ${error.message}`, { variant: 'error' });
+ }
+ };
+
+ // Mark message as read
+ const handleMarkAsRead = async (messageId) => {
+ try {
+ const user = JSON.parse(localStorage.getItem('user')) || { id: 1 }; // Adjust based on auth
+ const message = messages.find(msg => msg.id === messageId);
+ if (user.id !== message.recipient_id && !group_id) {
+ enqueueSnackbar('Only the recipient can mark this message as read', { variant: 'error' });
+ return;
+ }
+ const response = await MessageService.markMessageAsRead(messageId);
+ setMessages(messages.map(msg => msg.id === messageId ? response.data : msg));
+ enqueueSnackbar('Message marked as read', { variant: 'success' });
+ } catch (error) {
+ enqueueSnackbar(`Error marking message as read: ${error.message}`, { variant: 'error' });
+ }
+ };
+
+ return (
+ <div className="chat-container">
+ <h2 className="text-2xl font-bold mb-4">
+ {group_id ? `Group Chat (ID: ${group_id})` : `Direct Chat (Recipient ID: ${recipient_id})`}
+ </h2>
+ <div className="h-96 overflow-y-auto mb-4">
+ {messages.map((msg) => (
+ <div
+ key={msg.id}
+ className={`chat-message ${msg.sender_id === (JSON.parse(localStorage.getItem('user'))?.id || 1) ? 'chat-message-sent' : 'chat-message-received'}`}
+ onClick={() => !msg.read && handleMarkAsRead(msg.id)}
+ >
+ <Typography variant="body2" color="textSecondary">
+ Sender: {msg.sender_id} | {formatDate(msg.created_at)}
+ </Typography>
+ <Typography>{truncateContent(msg.content, 200)}</Typography>
+ {msg.read && (
+ <Typography variant="caption" color="textSecondary">
+ Read at: {formatDate(msg.read_at)}
+ </Typography>
+ )}
+ </div>
+ ))}
+ <div ref={messagesEndRef} />
+ </div>
+ <div className="flex space-x-2">
+ <TextField
+ value={newMessage}
+ onChange={(e) => setNewMessage(e.target.value)}
+ placeholder="Type a message..."
+ fullWidth
+ multiline
+ rows={2}
+ />
+ <Button variant="contained" color="primary" onClick={handleSendMessage}>
+ Send
+ </Button>
+ </div>
+ </div>
+ );
+};
+
+export default ChatView;
\ No newline at end of file
--- /dev/null
+/**
+ * @file View for creating a new message
+ */
+
+import React from 'react';
+import MessageForm from '../../components/MessageForm';
+
+const MessageFormView = () => {
+ return (
+ <div className="container">
+ <MessageForm />
+ </div>
+ );
+};
+
+export default MessageFormView;
\ No newline at end of file
--- /dev/null
+/**
+ * @file View for displaying messages
+ */
+
+import React from 'react';
+import { useSearchParams } from 'react-router-dom';
+import MessagesTable from '../../components/MessagesTable';
+
+const MessagesView = () => {
+ const [searchParams] = useSearchParams();
+ const groupId = searchParams.get('group_id');
+ const recipientId = searchParams.get('recipient_id');
+
+ return (
+ <div className="container">
+ <MessagesTable groupId={groupId} recipientId={recipientId} />
+ </div>
+ );
+};
+
+export default MessagesView;
\ No newline at end of file
--- /dev/null
+/**
+ * @file View for creating a new post
+ */
+
+import React from 'react';
+import PostForm from '../../components/PostForm';
+
+const PostFormView = () => {
+ return (
+ <div className="container">
+ <PostForm />
+ </div>
+ );
+};
+
+export default PostFormView;
\ No newline at end of file
--- /dev/null
+/**
+ * @file View for displaying posts
+ */
+
+import React from 'react';
+import PostsTable from '../../components/PostsTable';
+
+const PostsView = () => {
+ return (
+ <div className="container">
+ <PostsTable />
+ </div>
+ );
+};
+
+export default PostsView;
\ No newline at end of file
setRevokeOpen( true );
}}><BlockIcon/></IconButton>
<IconButton size='small' color='warning' title='Suspend' onClick={() => {
+ console.log( row.original );
setRevokeClientName( row.original.clientName );
setSuspendOpen( true );
}}><AccessTimeOutlined/></IconButton>
.App-link {
color: #61dafb;
}
-
-@keyframes App-logo-spin {
- from {
- transform: rotate(0deg);
- }
- to {
- transform: rotate(360deg);
- }
-}