From 096a08708e2310becba56a237ef63b5cf6e3c4c4 Mon Sep 17 00:00:00 2001
From: Michael Hunteman <michael@huntm.net>
Date: Sun, 25 Aug 2024 12:44:32 -0700
Subject: Add admin dashboard

---
 client/src/components/Admin.tsx      | 29 ++++++++++++
 client/src/components/AdminLogin.tsx | 90 ++++++++++++++++++++++++++++++++++++
 client/src/components/Dashboard.tsx  | 60 ++++++++++++++++++++++++
 client/src/components/GuestLogin.tsx | 14 +++---
 client/src/components/Rsvp.tsx       |  4 +-
 client/src/components/RsvpForm.tsx   |  4 +-
 client/src/main.tsx                  | 16 +++++++
 client/src/pages.ts                  |  1 +
 client/src/slices/api/adminSlice.ts  | 53 +++++++++++++++++++++
 client/src/slices/api/guestSlice.ts  | 60 ++++++++++++++++++++++++
 client/src/slices/apiSlice.ts        | 67 ---------------------------
 client/src/slices/auth/adminSlice.ts | 29 ++++++++++++
 client/src/slices/auth/guestSlice.ts | 29 ++++++++++++
 client/src/slices/authSlice.ts       | 31 -------------
 client/src/store.ts                  | 14 ++++--
 client/src/vite-env.d.ts             |  8 ++++
 16 files changed, 395 insertions(+), 114 deletions(-)
 create mode 100644 client/src/components/Admin.tsx
 create mode 100644 client/src/components/AdminLogin.tsx
 create mode 100644 client/src/components/Dashboard.tsx
 create mode 100644 client/src/slices/api/adminSlice.ts
 create mode 100644 client/src/slices/api/guestSlice.ts
 delete mode 100644 client/src/slices/apiSlice.ts
 create mode 100644 client/src/slices/auth/adminSlice.ts
 create mode 100644 client/src/slices/auth/guestSlice.ts
 delete mode 100644 client/src/slices/authSlice.ts

(limited to 'client/src')

diff --git a/client/src/components/Admin.tsx b/client/src/components/Admin.tsx
new file mode 100644
index 0000000..6e772ab
--- /dev/null
+++ b/client/src/components/Admin.tsx
@@ -0,0 +1,29 @@
+import React from 'react';
+import { useMemo } from 'react';
+import { useLocation, Navigate, Outlet } from 'react-router-dom';
+import { useSelector } from 'react-redux';
+import CssBaseline from '@mui/material/CssBaseline';
+import NavBar from './NavBar';
+import { selectGuests } from '../slices/auth/adminSlice';
+
+const authenticate = () => {
+  const guests = useSelector(selectGuests);
+  return useMemo(() => ({ guests }), [guests]);
+};
+
+function Rsvp() {
+  const auth = authenticate();
+  const location = useLocation();
+
+  return auth?.guests ? (
+    <>
+      <CssBaseline />
+      <NavBar />
+      <Outlet context={auth?.guests} />
+    </>
+  ) : (
+    <Navigate to="/admin/login" state={{ from: location }} replace />
+  );
+}
+
+export default Rsvp;
diff --git a/client/src/components/AdminLogin.tsx b/client/src/components/AdminLogin.tsx
new file mode 100644
index 0000000..d9c1260
--- /dev/null
+++ b/client/src/components/AdminLogin.tsx
@@ -0,0 +1,90 @@
+import React from 'react';
+import { useNavigate } from 'react-router-dom';
+import { useDispatch } from 'react-redux';
+import { Button, Paper, TextField, Typography } from '@mui/material';
+import { useForm } from 'react-hook-form';
+import { setAdmin } from '../slices/auth/adminSlice';
+import { useLoginAdminMutation } from '../slices/api/adminSlice';
+import type { AdminLoginRequest } from '../slices/api/adminSlice';
+
+function GuestLogin() {
+  const dispatch = useDispatch();
+  const navigate = useNavigate();
+  const [login] = useLoginAdminMutation();
+
+  const {
+    register,
+    handleSubmit,
+    formState: { errors },
+  } = useForm<AdminLoginRequest>({
+    defaultValues: {
+      username: '',
+      password: '',
+    },
+  });
+
+  const onSubmit = async (data: AdminLoginRequest) => {
+    try {
+      dispatch(setAdmin(await login(data).unwrap()));
+      navigate('/dashboard');
+    } catch (e) {
+      console.log(e);
+    }
+  };
+
+  return (
+    <form
+      style={{
+        height: '100%',
+        width: '100%',
+        display: 'flex',
+        justifyContent: 'center',
+        alignItems: 'start',
+      }}
+      noValidate
+      onSubmit={handleSubmit(onSubmit)}
+    >
+      <Paper
+        elevation={3}
+        sx={{
+          '&:hover': { boxShadow: 8 },
+          width: { xs: '90%', md: 400 },
+          display: 'flex',
+          flexDirection: 'column',
+          justifyContent: 'center',
+          alignItems: 'center',
+          mt: 16,
+          p: 2,
+          borderRadius: '8px',
+        }}
+      >
+        <Typography variant="h6">Admin Login</Typography>
+        <TextField
+          label="Username"
+          variant="outlined"
+          margin="normal"
+          fullWidth
+          error={!!errors.username}
+          helperText={errors.username?.message}
+          required
+          {...register('username', { required: 'This field is required' })}
+        />
+        <TextField
+          label="Password"
+          variant="outlined"
+          margin="normal"
+          fullWidth
+          error={!!errors.password}
+          helperText={errors.password?.message}
+          required
+          {...register('password', { required: 'This field is required' })}
+        />
+        <Button type="submit" variant="contained" fullWidth sx={{ mt: 2 }}>
+          Log in
+        </Button>
+      </Paper>
+    </form>
+  );
+}
+
+export default GuestLogin;
diff --git a/client/src/components/Dashboard.tsx b/client/src/components/Dashboard.tsx
new file mode 100644
index 0000000..20758fc
--- /dev/null
+++ b/client/src/components/Dashboard.tsx
@@ -0,0 +1,60 @@
+import React, { useMemo } from 'react';
+import { useOutletContext } from 'react-router-dom';
+import {
+  MaterialReactTable,
+  useMaterialReactTable,
+  type MRT_ColumnDef,
+} from 'material-react-table';
+import type { Guest } from '../slices/api/adminSlice';
+
+function Dashboard() {
+  const guests: Guest[] = useOutletContext();
+  const columns = useMemo<MRT_ColumnDef<Guest>[]>(
+    () => [
+      {
+        accessorKey: 'firstName',
+        header: 'First Name',
+        size: 150,
+      },
+      {
+        accessorKey: 'lastName',
+        header: 'Last Name',
+        size: 150,
+      },
+      {
+        accessorKey: 'attendance',
+        header: 'Attendance',
+        size: 50,
+      },
+      {
+        accessorKey: 'email',
+        header: 'Email',
+        size: 150,
+      },
+      {
+        accessorKey: 'message',
+        header: 'Message',
+        size: 200,
+      },
+      {
+        accessorKey: 'partySize',
+        header: 'Party Size',
+        size: 50,
+      },
+      // {
+      //   accessorKey: 'partyList',
+      //   header: 'Party List',
+      //   size: 150,
+      // },
+    ],
+    []
+  );
+  const table = useMaterialReactTable({
+    columns,
+    data: guests,
+  });
+
+  return <MaterialReactTable table={table} />;
+}
+
+export default Dashboard;
diff --git a/client/src/components/GuestLogin.tsx b/client/src/components/GuestLogin.tsx
index cca2179..0e47384 100644
--- a/client/src/components/GuestLogin.tsx
+++ b/client/src/components/GuestLogin.tsx
@@ -3,29 +3,29 @@ import { useNavigate } from 'react-router-dom';
 import { useDispatch } from 'react-redux';
 import { Button, Paper, TextField, Typography } from '@mui/material';
 import { useForm } from 'react-hook-form';
-import { setCredentials } from '../slices/authSlice';
-import { useLoginMutation } from '../slices/apiSlice';
-import type { LoginRequest } from '../slices/apiSlice';
+import { setGuest } from '../slices/auth/guestSlice';
+import { useLoginGuestMutation } from '../slices/api/guestSlice';
+import type { GuestLoginRequest } from '../slices/api/guestSlice';
 
 function GuestLogin() {
   const dispatch = useDispatch();
   const navigate = useNavigate();
-  const [login] = useLoginMutation();
+  const [login] = useLoginGuestMutation();
 
   const {
     register,
     handleSubmit,
     formState: { errors },
-  } = useForm<LoginRequest>({
+  } = useForm<GuestLoginRequest>({
     defaultValues: {
       firstName: '',
       lastName: '',
     },
   });
 
-  const onSubmit = async (data: LoginRequest) => {
+  const onSubmit = async (data: GuestLoginRequest) => {
     try {
-      dispatch(setCredentials(await login(data).unwrap()));
+      dispatch(setGuest(await login(data).unwrap()));
       navigate('/rsvp');
     } catch (e) {
       console.log(e);
diff --git a/client/src/components/Rsvp.tsx b/client/src/components/Rsvp.tsx
index d3d9677..ab83cd7 100644
--- a/client/src/components/Rsvp.tsx
+++ b/client/src/components/Rsvp.tsx
@@ -4,10 +4,10 @@ import { useLocation, Navigate, Outlet } from 'react-router-dom';
 import { useSelector } from 'react-redux';
 import CssBaseline from '@mui/material/CssBaseline';
 import NavBar from './NavBar';
-import { selectCurrentGuest } from '../slices/authSlice';
+import { selectGuest } from '../slices/auth/guestSlice';
 
 const authenticate = () => {
-  const guest = useSelector(selectCurrentGuest);
+  const guest = useSelector(selectGuest);
   return useMemo(() => ({ guest }), [guest]);
 };
 
diff --git a/client/src/components/RsvpForm.tsx b/client/src/components/RsvpForm.tsx
index 1e03227..d72b92d 100644
--- a/client/src/components/RsvpForm.tsx
+++ b/client/src/components/RsvpForm.tsx
@@ -16,8 +16,8 @@ import {
 } from '@mui/material';
 import MailIcon from '@mui/icons-material/Mail';
 import { useForm, Controller, useFieldArray } from 'react-hook-form';
-import { useUpdateGuestMutation } from '../slices/apiSlice';
-import type { Guest } from '../slices/apiSlice';
+import { useUpdateGuestMutation } from '../slices/api/guestSlice';
+import type { Guest } from '../slices/api/guestSlice';
 
 interface StatusProps {
   isError: boolean;
diff --git a/client/src/main.tsx b/client/src/main.tsx
index 70aad60..2268d5f 100644
--- a/client/src/main.tsx
+++ b/client/src/main.tsx
@@ -11,6 +11,9 @@ import GuestLogin from './components/GuestLogin';
 import Rsvp from './components/Rsvp';
 import RsvpForm from './components/RsvpForm';
 import Home from './components/Home';
+import AdminLogin from './components/AdminLogin';
+import Admin from './components/Admin';
+import Dashboard from './components/Dashboard';
 import './main.css';
 
 const router = createBrowserRouter([
@@ -33,6 +36,10 @@ const router = createBrowserRouter([
         path: 'guest/login',
         element: <GuestLogin />,
       },
+      {
+        path: 'admin/login',
+        element: <AdminLogin />,
+      },
     ],
   },
   {
@@ -44,6 +51,15 @@ const router = createBrowserRouter([
       },
     ],
   },
+  {
+    element: <Admin />,
+    children: [
+      {
+        path: 'dashboard',
+        element: <Dashboard />,
+      },
+    ],
+  },
 ]);
 
 ReactDOM.createRoot(document.getElementById('root')!).render(
diff --git a/client/src/pages.ts b/client/src/pages.ts
index 8bf5d23..5ebb9c4 100644
--- a/client/src/pages.ts
+++ b/client/src/pages.ts
@@ -3,6 +3,7 @@ const pages = [
   { name: 'Schedule', to: '/schedule' },
   { name: 'RSVP', to: '/guest/login' },
   { name: 'Registry', to: '/registry' },
+  { name: 'Dashboard', to: '/admin/login' },
 ];
 
 export default pages;
diff --git a/client/src/slices/api/adminSlice.ts b/client/src/slices/api/adminSlice.ts
new file mode 100644
index 0000000..cd1638d
--- /dev/null
+++ b/client/src/slices/api/adminSlice.ts
@@ -0,0 +1,53 @@
+import { createApi, fetchBaseQuery } from '@reduxjs/toolkit/query/react';
+import type { RootState } from '../../store';
+
+export interface Guest {
+  id: number;
+  firstName: string;
+  lastName: string;
+  attendance: string;
+  email: string;
+  message: string;
+  partySize: number;
+  partyList: Array<PartyGuest>;
+}
+
+export interface PartyGuest {
+  firstName: string;
+  lastName: string;
+}
+
+export interface AdminLoginRequest {
+  username: string;
+  password: string;
+}
+
+export interface AdminLoginResponse {
+  guests: Guest[];
+  token: string;
+}
+
+export const adminSlice = createApi({
+  reducerPath: 'adminApi',
+  baseQuery: fetchBaseQuery({
+    baseUrl: import.meta.env.VITE_BASE_URL,
+    prepareHeaders: (headers, { getState }) => {
+      const token = (getState() as RootState).admin.token;
+      if (token) {
+        headers.set('authorization', `${token}`);
+      }
+      return headers;
+    },
+  }),
+  endpoints: (builder) => ({
+    loginAdmin: builder.mutation<AdminLoginResponse, AdminLoginRequest>({
+      query: (credentials) => ({
+        url: 'admin/login',
+        method: 'POST',
+        body: credentials,
+      }),
+    }),
+  }),
+});
+
+export const { useLoginAdminMutation } = adminSlice;
diff --git a/client/src/slices/api/guestSlice.ts b/client/src/slices/api/guestSlice.ts
new file mode 100644
index 0000000..38deb9a
--- /dev/null
+++ b/client/src/slices/api/guestSlice.ts
@@ -0,0 +1,60 @@
+import { createApi, fetchBaseQuery } from '@reduxjs/toolkit/query/react';
+import type { RootState } from '../../store';
+
+export interface GuestLoginRequest {
+  firstName: string;
+  lastName: string;
+}
+
+export interface GuestLoginResponse {
+  guest: Guest;
+  token: string;
+}
+
+export interface Guest {
+  id: number;
+  firstName: string;
+  lastName: string;
+  attendance: string;
+  email: string;
+  message: string;
+  partySize: number;
+  partyList: Array<PartyGuest>;
+}
+
+export interface PartyGuest {
+  firstName: string;
+  lastName: string;
+}
+
+export const guestSlice = createApi({
+  reducerPath: 'guestApi',
+  baseQuery: fetchBaseQuery({
+    baseUrl: import.meta.env.VITE_BASE_URL + 'guest/',
+    prepareHeaders: (headers, { getState }) => {
+      const token = (getState() as RootState).guest.token;
+      if (token) {
+        headers.set('authorization', `${token}`);
+      }
+      return headers;
+    },
+  }),
+  endpoints: (builder) => ({
+    loginGuest: builder.mutation<GuestLoginResponse, GuestLoginRequest>({
+      query: (credentials) => ({
+        url: 'login',
+        method: 'POST',
+        body: credentials,
+      }),
+    }),
+    updateGuest: builder.mutation<Guest, Guest>({
+      query: (guest) => ({
+        url: `${guest?.id}`,
+        method: 'PUT',
+        body: guest,
+      }),
+    }),
+  }),
+});
+
+export const { useLoginGuestMutation, useUpdateGuestMutation } = guestSlice;
diff --git a/client/src/slices/apiSlice.ts b/client/src/slices/apiSlice.ts
deleted file mode 100644
index 90cdc48..0000000
--- a/client/src/slices/apiSlice.ts
+++ /dev/null
@@ -1,67 +0,0 @@
-import { createApi, fetchBaseQuery } from '@reduxjs/toolkit/query/react';
-import type { RootState } from '../store';
-
-export interface LoginRequest {
-  firstName: string;
-  lastName: string;
-}
-
-export interface LoginResponse {
-  guest: Guest;
-  token: string;
-}
-
-export interface Guest {
-  id: number;
-  firstName: string;
-  lastName: string;
-  attendance: string;
-  email: string;
-  message: string;
-  partySize: number;
-  partyList: Array<PartyGuest>;
-}
-
-export interface PartyGuest {
-  firstName: string;
-  lastName: string;
-}
-
-export const apiSlice = createApi({
-  reducerPath: 'api',
-  baseQuery: fetchBaseQuery({
-    baseUrl: 'http://192.168.1.41:8080/',
-    prepareHeaders: (headers, { getState }) => {
-      const token = (getState() as RootState).auth.token;
-      if (token) {
-        headers.set('authorization', `${token}`);
-      }
-      return headers;
-    },
-  }),
-  tagTypes: ['Guest'],
-  endpoints: (builder) => ({
-    getGuests: builder.query<void, void>({
-      query: () => 'guest',
-      providesTags: ['Guest'],
-    }),
-    updateGuest: builder.mutation<Guest, Guest>({
-      query: (guest) => ({
-        url: `guest/${guest?.id}`,
-        method: 'PUT',
-        body: guest,
-        providesTags: ['Guest'],
-      }),
-    }),
-    login: builder.mutation<LoginResponse, LoginRequest>({
-      query: (credentials) => ({
-        url: 'guest/login',
-        method: 'POST',
-        body: credentials,
-      }),
-    }),
-  }),
-});
-
-export const { useGetGuestsQuery, useUpdateGuestMutation, useLoginMutation } =
-  apiSlice;
diff --git a/client/src/slices/auth/adminSlice.ts b/client/src/slices/auth/adminSlice.ts
new file mode 100644
index 0000000..8753b55
--- /dev/null
+++ b/client/src/slices/auth/adminSlice.ts
@@ -0,0 +1,29 @@
+import { createSlice } from '@reduxjs/toolkit';
+import type { PayloadAction } from '@reduxjs/toolkit';
+import type { RootState } from '../../store';
+import type { Guest } from '../api/guestSlice';
+
+type AdminAuth = {
+  guests?: Guest[];
+  token?: string;
+};
+
+const adminSlice = createSlice({
+  name: 'admin',
+  initialState: { guest: undefined, token: undefined } as AdminAuth,
+  reducers: {
+    setAdmin: (
+      state,
+      { payload: { guests, token } }: PayloadAction<AdminAuth>
+    ) => {
+      state.guests = guests;
+      state.token = token;
+    },
+  },
+});
+
+export const { setAdmin } = adminSlice.actions;
+
+export default adminSlice.reducer;
+
+export const selectGuests = (state: RootState) => state.admin.guests;
diff --git a/client/src/slices/auth/guestSlice.ts b/client/src/slices/auth/guestSlice.ts
new file mode 100644
index 0000000..701148e
--- /dev/null
+++ b/client/src/slices/auth/guestSlice.ts
@@ -0,0 +1,29 @@
+import { createSlice } from '@reduxjs/toolkit';
+import type { PayloadAction } from '@reduxjs/toolkit';
+import type { RootState } from '../../store';
+import type { Guest } from '../api/guestSlice';
+
+type GuestAuth = {
+  guest?: Guest;
+  token?: string;
+};
+
+const guestSlice = createSlice({
+  name: 'guest',
+  initialState: { guest: undefined, token: undefined } as GuestAuth,
+  reducers: {
+    setGuest: (
+      state,
+      { payload: { guest, token } }: PayloadAction<GuestAuth>
+    ) => {
+      state.guest = guest;
+      state.token = token;
+    },
+  },
+});
+
+export const { setGuest } = guestSlice.actions;
+
+export default guestSlice.reducer;
+
+export const selectGuest = (state: RootState) => state.guest.guest;
diff --git a/client/src/slices/authSlice.ts b/client/src/slices/authSlice.ts
deleted file mode 100644
index e1fec78..0000000
--- a/client/src/slices/authSlice.ts
+++ /dev/null
@@ -1,31 +0,0 @@
-import { createSlice } from '@reduxjs/toolkit';
-import type { PayloadAction } from '@reduxjs/toolkit';
-import type { RootState } from '../store';
-import type { Guest } from './apiSlice';
-
-type AuthState = {
-  guest?: Guest;
-  token?: string;
-};
-
-const authSlice = createSlice({
-  name: 'auth',
-  initialState: { guest: undefined, token: undefined } as AuthState,
-  reducers: {
-    setCredentials: (
-      state,
-      {
-        payload: { guest, token },
-      }: PayloadAction<{ guest: Guest; token: string }>
-    ) => {
-      state.guest = guest;
-      state.token = token;
-    },
-  },
-});
-
-export const { setCredentials } = authSlice.actions;
-
-export default authSlice.reducer;
-
-export const selectCurrentGuest = (state: RootState) => state.auth.guest;
diff --git a/client/src/store.ts b/client/src/store.ts
index 18b3461..c30a30d 100644
--- a/client/src/store.ts
+++ b/client/src/store.ts
@@ -1,14 +1,18 @@
 import { configureStore } from '@reduxjs/toolkit';
-import { apiSlice } from './slices/apiSlice';
-import authReducer from './slices/authSlice';
+import guestReducer from './slices/auth/guestSlice';
+import adminReducer from './slices/auth/adminSlice';
+import { guestSlice } from './slices/api/guestSlice';
+import { adminSlice } from './slices/api/adminSlice';
 
 const store = configureStore({
   reducer: {
-    [apiSlice.reducerPath]: apiSlice.reducer,
-    auth: authReducer,
+    [guestSlice.reducerPath]: guestSlice.reducer,
+    [adminSlice.reducerPath]: adminSlice.reducer,
+    guest: guestReducer,
+    admin: adminReducer,
   },
   middleware: (getDefaultMiddleware) =>
-    getDefaultMiddleware().concat(apiSlice.middleware),
+    getDefaultMiddleware().concat(guestSlice.middleware, adminSlice.middleware),
 });
 
 export default store;
diff --git a/client/src/vite-env.d.ts b/client/src/vite-env.d.ts
index 11f02fe..72eb128 100644
--- a/client/src/vite-env.d.ts
+++ b/client/src/vite-env.d.ts
@@ -1 +1,9 @@
 /// <reference types="vite/client" />
+
+interface ImportMetaEnv {
+  readonly VITE_BASE_URL: string;
+}
+
+interface ImportMeta {
+  readonly env: ImportMetaEnv;
+}
-- 
cgit v1.2.3