Merge pull request #44 from Nez27/feat/impl-booking-page

Add booking page
This commit is contained in:
Loi Phan
2023-11-30 10:16:43 +07:00
committed by GitHub
12 changed files with 643 additions and 84 deletions
+26 -21
View File
@@ -2,35 +2,33 @@ import { BrowserRouter, Navigate, Route, Routes } from 'react-router-dom';
import isPropValid from '@emotion/is-prop-valid';
import { StyleSheetManager } from 'styled-components';
import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
import { useEffect, useMemo, useReducer } from 'react';
// Components
import AppLayout from './components/AppLayout';
import Toast from './components/Toast';
// Pages
import User from './pages/User';
import Dashboard from './pages/Dashboard';
import Booking from './pages/Booking';
import Room from './pages/Room';
import NotFound from './pages/NotFound';
// Constants
import * as PATH from './constants/path';
import Toast from './components/Toast';
// Hooks
import {
UserRoomAvailableContext,
initialState,
reducer,
} from '@context/UserRoomAvailableContext';
import { useEffect, useMemo, useReducer } from 'react';
// Services
import { getUserNotBooked } from '@service/userServices';
import { getRoomsAvailable } from '@service/roomServices';
import { getAllUsers } from '@service/userServices';
import { getAllRooms } from '@service/roomServices';
const queryClient = new QueryClient({
defaultOptions: {
queries: {
staleTime: 60 * 1000,
},
},
});
const queryClient = new QueryClient();
function App() {
const [{ usersAvailable, roomsAvailable }, dispatch] = useReducer(
@@ -41,16 +39,26 @@ function App() {
// Init list user and room available
useEffect(() => {
const load = async () => {
const tempUser = await getUserNotBooked();
const tempUser = await getAllUsers({
sortBy: 'id',
orderBy: 'asc',
phoneSearch: '',
page: 0,
});
if (tempUser) {
dispatch({ type: 'initUser', payload: tempUser });
dispatch({ type: 'initUser', payload: tempUser.data });
}
const tempRoom = await getRoomsAvailable();
const tempRoom = await getAllRooms({
sortBy: 'id',
orderBy: 'asc',
roomSearch: '',
page: 0,
});
if (tempRoom) {
dispatch({ type: 'initRoom', payload: tempRoom });
dispatch({ type: 'initRoom', payload: tempRoom.data });
}
};
@@ -68,11 +76,8 @@ function App() {
<BrowserRouter>
<Routes>
<Route element={<AppLayout />}>
<Route
index
element={<Navigate replace to={PATH.DASHBOARD} />}
/>
<Route path={PATH.DASHBOARD} element={<Dashboard />} />
<Route index element={<Navigate replace to={PATH.BOOKING} />} />
<Route path={PATH.BOOKING} element={<Booking />} />
<Route path={PATH.USER} element={<User />} />
<Route path={PATH.ROOM} element={<Room />} />
</Route>
@@ -4,7 +4,8 @@ const StyledSearch = styled.input`
border-radius: var(--radius-sm);
border: 1px solid var(--border-color);
font-size: var(--fs-sm-x);
padding: 5px 10px;
padding: 10px 10px;
`;
export { StyledSearch };
+2 -7
View File
@@ -48,6 +48,7 @@ const FORM = {
DELETE: 'delete',
USER: 'user-form',
ROOM: 'room-form',
BOOKING: 'booking-form',
CHECKOUT: 'checkout',
};
@@ -57,10 +58,4 @@ const REGEX = {
NUMBER: /^[0-9]*$/,
};
export {
USER_PAGE,
ROOM_PAGE,
FORM,
REGEX,
ORDERBY_OPTIONS,
};
export { USER_PAGE, ROOM_PAGE, FORM, REGEX, ORDERBY_OPTIONS };
@@ -0,0 +1,299 @@
import { useCallback, useMemo } from 'react';
import { FormProvider, useForm } from 'react-hook-form';
// Styled
import Input from '@commonStyle/Input.ts';
// Constants
import { REQUIRED_FIELD_ERROR } from '@constant/formValidateMessage.ts';
// Components
import Form from '@component/Form/index.tsx';
import Select, { ISelectOptions } from '@component/Select/index.tsx';
// Hooks
import { useCreateBooking } from '@hook/bookings/useCreateBooking.ts';
import { useUpdateBooking } from '@hook/bookings/useUpdateBooking.ts';
import { useUserRoomAvailable } from '@hook/useUserRoomAvailable.ts';
// Helpers
import {
convertCurrencyToNumber,
formatCurrency,
getDayDiff,
} from '@helper/helper.ts';
// Services
import { getRoomById, updateRoomStatus } from '@service/roomServices.ts';
import { updateUserBookedStatus } from '@service/userServices.ts';
// Types
import { IBooking, TBookingResponse } from '@type/booking.ts';
interface IBookingFormProp {
onCloseModal?: () => void;
booking?: TBookingResponse;
}
interface IBookingForm extends Omit<IBooking, 'amount'> {
amount: string;
}
const BookingForm = ({ onCloseModal, booking }: IBookingFormProp) => {
const { roomsAvailable, usersAvailable, dispatch } = useUserRoomAvailable();
const { isCreating, createBooking } = useCreateBooking();
const { isUpdating, updateBooking } = useUpdateBooking();
const isLoading = isCreating || isUpdating;
const { id: editId, ...editValues } = { ...booking };
const formMethods = useForm<IBookingForm>({
defaultValues: editId
? {
startDate: editValues.startDate,
endDate: editValues.endDate,
roomId: editValues.rooms?.id,
userId: editValues.users?.id,
amount: formatCurrency(editValues.amount!),
}
: {},
});
const {
register,
handleSubmit,
reset,
formState: { errors, isDirty, isValid },
trigger,
getValues,
setValue,
} = formMethods;
// Submit form
const onSubmit = async (newBooking: IBookingForm) => {
if (!editId) {
// Add request
createBooking(
{ ...newBooking, amount: convertCurrencyToNumber(newBooking.amount) },
{
onSuccess: async () => {
reset();
onCloseModal?.();
// Update room, user available in global state
dispatch!({
type: 'updateStatusUser',
payload: [{ id: newBooking.userId, isBooked: true }],
});
dispatch!({
type: 'updateStatusRoom',
payload: [{ id: newBooking.roomId, status: true }],
});
// Update room, user available in server
await updateRoomStatus(newBooking.roomId, true);
await updateUserBookedStatus(newBooking.userId, true);
},
}
);
} else {
// Edit request
newBooking.id = editId!;
updateBooking(
{ ...newBooking, amount: convertCurrencyToNumber(newBooking.amount) },
{
onSuccess: async () => {
reset();
onCloseModal?.();
// Update room status in global state
// Reset status old room
dispatch!({
type: 'updateStatusRoom',
payload: [{ id: booking!.rooms!.id, status: false }],
});
// Change status new room
dispatch!({
type: 'updateStatusRoom',
payload: [{ id: newBooking.roomId, status: true }],
});
// Update room status in server
// Reset status old room
await updateRoomStatus(booking!.rooms!.id, false);
// Change status new room
await updateRoomStatus(newBooking.roomId, true);
},
}
);
}
};
// Init user, room available to options choice
const userOptions = useMemo(() => {
const options: ISelectOptions[] = [];
usersAvailable?.forEach((item) => {
if (booking?.users?.id === item.id || !item.isBooked) {
options.push({
label: item.name!,
value: item.id.toString(),
});
}
});
return options;
}, [usersAvailable, booking?.users?.id]);
const roomOptions = useMemo(() => {
const options: ISelectOptions[] = [];
roomsAvailable?.forEach((item) => {
if (booking?.rooms?.id === item.id || !item.status) {
options.push({
label: item.name!,
value: item.id.toString(),
});
}
});
return options;
}, [roomsAvailable, booking?.rooms?.id]);
// Calculate the final price
const computePrice = useCallback(async () => {
setValue('amount', 'Loading...');
const startDateValue = getValues('startDate');
const endDateValue = getValues('endDate');
const roomValue = getValues('roomId');
if (startDateValue && endDateValue && roomValue) {
const daysDiff = getDayDiff(
new Date(startDateValue),
new Date(endDateValue)
);
// Fetch room data
const room = await getRoomById(roomValue.toString());
const amount = daysDiff * room.price;
setValue('amount', formatCurrency(amount), { shouldValidate: true });
}
}, [getValues, setValue]);
const startDateValidate = useMemo(
() => new Date().toISOString().split('T')[0],
[]
);
const endDateValidate = () => {
if (getValues('startDate')) {
const date = new Date(getValues('startDate'));
date.setDate(date.getDate() + 1);
return date.toISOString().split('T')[0];
}
return;
};
return (
<FormProvider {...formMethods}>
<Form onSubmit={handleSubmit(onSubmit)}>
<Form.Row label="User" error={errors?.userId?.message}>
{userOptions.length ? (
<Select
ariaLabel="user-select"
options={userOptions}
id="userId"
disable={Boolean(booking?.users)}
optionsConfigForm={{
valueAsNumber: true,
onChange: () => trigger('userId'),
}}
/>
) : (
<p>No user available</p>
)}
</Form.Row>
<Form.Row label="Room" error={errors?.roomId?.message}>
{roomOptions.length ? (
<Select
ariaLabel="user-select"
options={roomOptions}
id="roomId"
optionsConfigForm={{
valueAsNumber: true,
onChange: () => trigger('roomId'),
}}
/>
) : (
<p>No room available</p>
)}
</Form.Row>
<Form.Row label="Start Date" error={errors?.startDate?.message}>
<Input
type="date"
id="startDate"
min={startDateValidate}
{...register('startDate', {
required: REQUIRED_FIELD_ERROR,
onChange: () => {
trigger('startDate');
computePrice();
},
})}
/>
</Form.Row>
<Form.Row label="End Date" error={errors?.startDate?.message}>
<Input
type="date"
id="endDate"
min={endDateValidate()}
disabled={!getValues('startDate')}
{...register('endDate', {
required: REQUIRED_FIELD_ERROR,
onChange: () => {
trigger('endDate');
computePrice();
},
})}
/>
</Form.Row>
<Form.Row label="Amount">
<Input
type="text"
id="amount"
{...register('amount', {
required: REQUIRED_FIELD_ERROR,
onChange: () => trigger('amount'),
})}
readOnly
/>
</Form.Row>
<Form.Action>
<Form.Button
type="submit"
name="submit"
disabled={!isDirty || !isValid || isLoading}
>
{
!editId
? 'Add'
: 'Save'
}
</Form.Button>
<Form.Button type="button" variations="secondary" onClick={onCloseModal}>
Close
</Form.Button>
</Form.Action>
</Form>
</FormProvider>
);
};
export default BookingForm;
@@ -0,0 +1,120 @@
import toast from 'react-hot-toast';
import { useCallback, useMemo } from 'react';
// Helpers
import { formatCurrency } from '@helper/helper';
// Types
import { TBookingResponse } from '@type/booking';
// Components
import Modal from '@component/Modal';
import Table from '@component/Table';
import Menus from '@component/Menus';
import { RiEditBoxFill } from 'react-icons/ri';
import { TbArrowNarrowRight } from 'react-icons/tb';
import { ImExit } from 'react-icons/im';
import BookingForm from './BookingForm';
import ConfirmMessage from '@component/ConfirmMessage';
// Hooks
import useCheckOut from '@hook/bookings/useCheckout';
// Constants
import { FORM } from '@constant/commons';
interface IBookingRow {
booking: TBookingResponse;
}
const BookingRow = ({ booking }: IBookingRow) => {
const { checkOutBooking } = useCheckOut();
const {
id,
users,
startDate,
endDate,
rooms,
amount,
status
} = booking;
const statusText = status
? 'Check in'
: 'Check out';
const formattedPrice = useMemo(() => formatCurrency(amount), [amount]);
const renderEditBtn = useCallback(
(onCloseModal: () => void) => (
<Menus.Button onClick={onCloseModal} icon={<RiEditBoxFill />}>
Edit
</Menus.Button>
),
[]
);
const renderCheckOutBtn = useCallback(
(onCloseModal: () => void) => (
<Menus.Button onClick={onCloseModal} icon={<ImExit />}>
Checkout
</Menus.Button>
),
[]
);
const handleClickCheckOutBtn = useCallback(() => {
if (booking.status) {
checkOutBooking({
idBooking: booking.id,
roomId: booking!.rooms!.id,
userId: booking!.users!.id,
});
} else {
toast.error('User already checkout!');
}
}, [booking, checkOutBooking]);
return (
<Table.Row>
<div>{users?.name}</div>
<div style={{ display: 'flex', alignItems: 'center' }}>
{startDate} &nbsp; <TbArrowNarrowRight />
&nbsp; {endDate}
</div>
<div>{rooms?.name}</div>
<div>{formattedPrice}</div>
<div>{statusText}</div>
<div>
<Modal>
<Menus.Menu>
<Menus.Toggle id={id.toString()} />
<Menus.List id={id.toString()}>
<Modal.Open
modalName={FORM.EDIT}
renderChildren={renderEditBtn}
/>
<Modal.Open
modalName={FORM.CHECKOUT}
renderChildren={renderCheckOutBtn}
/>
</Menus.List>
<Modal.Window name={FORM.EDIT} title="Edit Booking">
<BookingForm booking={booking} key={booking.id} />
</Modal.Window>
<Modal.Window name={FORM.CHECKOUT} title="Checkout">
<ConfirmMessage
message={`Are you sure to checkout this user? '${booking.users?.name}'`}
onConfirm={handleClickCheckOutBtn}
/>
</Modal.Window>
</Menus.Menu>
</Modal>
</div>
</Table.Row>
);
};
export default BookingRow;
@@ -0,0 +1,73 @@
import { useCallback } from 'react';
// Components
import Menus from '@component/Menus';
import Table from '@component/Table';
import Message from '@component/Message';
import Search from '@component/Search';
import Pagination from '@component/Pagination';
import BookingRow from './BookingRow';
// Styled
import { StyledOperationTable } from './styled';
import Direction from '@commonStyle/Direction';
import Spinner from '@commonStyle/Spinner';
// Hooks
import { useBookings } from '@hook/bookings/useBookings';
// Types
import { TBookingResponse } from '@type/booking';
const BookingTable = () => {
const columnName = [
'User',
'Date',
'Room',
'Amount',
'Status'
];
const {
isLoading,
bookings,
count
} = useBookings();
const renderBookingRow = useCallback(
(booking: TBookingResponse) => (
<BookingRow booking={booking} key={booking.id} />
),
[]
);
return (
<>
<Direction>
<StyledOperationTable>
<Search setPlaceHolder="Search by name..." />
</StyledOperationTable>
{isLoading && <Spinner />}
{bookings && bookings.length ? (
<Menus>
<Table columns="15% 25% 20% 15% 10% 10% 5%">
<Table.Header headerColumn={columnName} />
<Table.Body<TBookingResponse>
data={bookings}
render={renderBookingRow}
/>
<Table.Footer>
<Pagination count={count!} />
</Table.Footer>
</Table>
</Menus>
) : (
!isLoading && <Message>No data to show here!</Message>
)}
</Direction>
</>
);
};
export default BookingTable;
@@ -0,0 +1,38 @@
// Styled
import Direction from '@commonStyle/Direction';
import { StyledBooking, Title } from './styled';
import Button from '@commonStyle/Button';
// Components
import Modal from '@component/Modal';
import BookingTable from './BookingTable';
import BookingForm from './BookingForm';
// Constants
import { FORM } from '@constant/commons';
const Booking = () => {
return (
<StyledBooking>
<Direction type="horizontal">
<Title>List Booking</Title>
<Modal>
<Modal.Open
modalName={FORM.BOOKING}
renderChildren={(onCloseModal) => (
<Button onClick={onCloseModal}>Add booking</Button>
)}
/>
<Modal.Window name={FORM.BOOKING} title="Add form">
<BookingForm />
</Modal.Window>
</Modal>
</Direction>
<BookingTable />
</StyledBooking>
);
};
export default Booking;
@@ -0,0 +1,24 @@
import styled from 'styled-components';
const StyledBooking = styled.main`
padding: 20px;
padding-bottom: 100px;
display: flex;
flex-direction: column;
gap: 30px;
`;
const Title = styled.h2`
font-size: var(--fs-md);
color: var(--dark-text);
text-transform: capitalize;
`;
const StyledOperationTable = styled.div`
display: flex;
gap: 30px;
justify-content: flex-end;
`;
export { StyledBooking, Title, StyledOperationTable };
-23
View File
@@ -1,23 +0,0 @@
import styled from 'styled-components';
// Hooks
import { useUserRoomAvailable } from '@hook/useUserRoomAvailable';
const StyledDashboard = styled.main`
padding: 20px;
`;
const Dashboard = () => {
const { roomsAvailable, usersAvailable } = useUserRoomAvailable();
console.log('Rooms Available: ', roomsAvailable);
console.log('Users available: ', usersAvailable);
return (
<StyledDashboard>
<p>This page currently still develop. Please try again later!</p>
</StyledDashboard>
);
};
export default Dashboard;
+1 -2
View File
@@ -27,7 +27,7 @@ interface IRoomRow {
const RoomRow = ({ room }: IRoomRow) => {
const { id, name, price, status } = room;
const { isDeleting, deleteRoom } = useDeleteRoom();
const { deleteRoom } = useDeleteRoom();
const statusText = status
? 'Unavailable'
: 'Available';
@@ -78,7 +78,6 @@ const RoomRow = ({ room }: IRoomRow) => {
<Modal.Window name={FORM.DELETE} title="Delete Room">
<ConfirmMessage
disabled={isDeleting}
message={`Are you sure to delete ${name}?`}
onConfirm={() => deleteRoom(id)}
/>
+17 -19
View File
@@ -10,27 +10,25 @@ import Button from '@commonStyle/Button';
const Room = () => {
return (
<>
<StyledRoom>
<Direction type="horizontal">
<Title>List Room</Title>
<StyledRoom>
<Direction type="horizontal">
<Title>List Room</Title>
<Modal>
<Modal.Open
modalName="room-form"
renderChildren={(onCloseModal) => (
<Button onClick={onCloseModal}>Add room</Button>
)}
/>
<Modal.Window name="room-form" title="Add form">
<RoomForm />
</Modal.Window>
</Modal>
</Direction>
<Modal>
<Modal.Open
modalName="room-form"
renderChildren={(onCloseModal) => (
<Button onClick={onCloseModal}>Add room</Button>
)}
/>
<Modal.Window name="room-form" title="Add form">
<RoomForm />
</Modal.Window>
</Modal>
</Direction>
<RoomTable />
</StyledRoom>
</>
<RoomTable />
</StyledRoom>
);
};
+41 -11
View File
@@ -5,9 +5,13 @@ import Table from '@component/Table';
import Menus from '@component/Menus';
import UserForm from './UserForm';
// Types
import { IUser } from '@type/users';
import { FORM } from '@constant/commons';
import { HiTrash } from 'react-icons/hi';
import { useCallback } from 'react';
import ConfirmMessage from '@component/ConfirmMessage';
import { useDeleteUser } from '@hook/users/useDeleteUser';
interface IUserRow {
user: IUser;
@@ -15,17 +19,35 @@ interface IUserRow {
const UserRow = ({ user }: IUserRow) => {
const { id, name, phone, isBooked } = user;
const { deleteUser } = useDeleteUser();
const renderEditBtn = useCallback(
(onCloseModal: () => void) => (
<Menus.Button onClick={onCloseModal} icon={<RiEditBoxFill />}>
Edit
</Menus.Button>
),
[]
);
const renderDeleteBtn = useCallback(
(onCloseModal: () => void) => (
<Menus.Button icon={<HiTrash />} onClick={onCloseModal}>
Delete
</Menus.Button>
),
[]
);
return (
<Table.Row>
<div>{id}</div>
<div>{name}</div>
<div>{phone}</div>
<div>{
isBooked
? 'Yes'
isBooked
? 'Yes'
: 'No'
}</div>
}</div>
<div>
<Modal>
<Menus.Menu>
@@ -33,18 +55,26 @@ const UserRow = ({ user }: IUserRow) => {
<Menus.List id={id.toString()}>
<Modal.Open
modalName="edit"
renderChildren={(onCloseModal) => (
<Menus.Button onClick={onCloseModal} icon={<RiEditBoxFill />}>
Edit
</Menus.Button>
)}
modalName={FORM.EDIT}
renderChildren={renderEditBtn}
/>
<Modal.Open
modalName={FORM.DELETE}
renderChildren={renderDeleteBtn}
/>
</Menus.List>
<Modal.Window name="edit" title="Edit user">
<Modal.Window name={FORM.EDIT} title="Edit user">
<UserForm user={user} />
</Modal.Window>
<Modal.Window name={FORM.DELETE} title="Delete User">
<ConfirmMessage
message={`Are you sure to delete ${name}?`}
onConfirm={() => deleteUser(id)}
/>
</Modal.Window>
</Menus.Menu>
</Modal>
</div>