diff --git a/src/App.tsx b/src/App.tsx index 46ec20f2..26f5c5cf 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -19,6 +19,7 @@ import OwnerList from 'pages/UserManage/Owner/OwnerList'; import OwnerRequestList from 'pages/UserManage/OwnerRequest/OwnerRequestList'; import OwnerRequestDetail from 'pages/UserManage/OwnerRequest/OwnerRequestDetail'; import OwnerDetail from 'pages/UserManage/Owner/OwnerDetail'; +import ReviewList from 'pages/Services/Review/ReviewList'; function RequireAuth() { const location = useLocation(); @@ -56,6 +57,7 @@ function App() { } /> } /> } /> + } /> 404} /> diff --git a/src/components/common/ScrollUpButton/ScrollUpButton.tsx b/src/components/common/ScrollUpButton/ScrollUpButton.tsx new file mode 100644 index 00000000..487ed84f --- /dev/null +++ b/src/components/common/ScrollUpButton/ScrollUpButton.tsx @@ -0,0 +1,22 @@ +import { UpCircleOutlined } from '@ant-design/icons'; +import styled from 'styled-components'; + +const RightDownButton = styled.div` + position: fixed; + bottom: 100px; + right: 100px; + font-size: 40px; + cursor: pointer; +`; + +export default function ScrollUpButton() { + const scrollUp = () => { + window.scrollTo({ top: 0, behavior: 'smooth' }); + }; + + return ( + + + + ); +} diff --git a/src/components/common/SideNav/index.tsx b/src/components/common/SideNav/index.tsx index e7aeadae..293af77c 100644 --- a/src/components/common/SideNav/index.tsx +++ b/src/components/common/SideNav/index.tsx @@ -2,7 +2,7 @@ import { AppstoreOutlined, UserOutlined, CarOutlined, ShopOutlined, HomeOutlined, UserSwitchOutlined, UsergroupDeleteOutlined, FolderOpenOutlined, ControlOutlined, - UserAddOutlined, BoldOutlined, + UserAddOutlined, BoldOutlined, SnippetsOutlined, } from '@ant-design/icons'; import { Menu, MenuProps } from 'antd'; import { Link, useLocation, useNavigate } from 'react-router-dom'; @@ -31,6 +31,7 @@ const items: MenuProps['items'] = [ getItem('주변상점', 'service-store', , [ getItem('상점 관리', '/store', ), getItem('카테고리', '/category', ), + getItem('리뷰 관리', '/review', ), ]), getItem('버스 정보', '/bus', ), getItem('복덕방', '/room', ), diff --git a/src/constant/index.ts b/src/constant/index.ts index cf312aa3..77380161 100644 --- a/src/constant/index.ts +++ b/src/constant/index.ts @@ -2,6 +2,8 @@ export const API_PATH = process.env.REACT_APP_API_PATH; export const SECOND_PASSWORD = process.env.REACT_APP_SECOND_PASSWORD; +export const KOIN_URL = process.env.REACT_APP_API_PATH?.includes('stage') ? 'https://stage.koreatech.in' : 'https://koreatech.in'; + // 테이블 헤더 Title 매핑 export const TITLE_MAPPER: Record = { id: 'ID', diff --git a/src/model/review.model.ts b/src/model/review.model.ts new file mode 100644 index 00000000..91ec2050 --- /dev/null +++ b/src/model/review.model.ts @@ -0,0 +1,45 @@ +export interface ReviewListResponse { + total_count: number, + current_count: number, + current_page: number, + reviews: ReviewContent[] +} + +export interface ReviewContent { + reviewId: number, + rating: number, + nickName: string, + content: string, + imageUrls: string[], + menuNames: string[], + isModified: boolean, + isHaveUnhandledReport: boolean, + createdAt: string, + reports: ReportedReviewContent[] + shop: { + shopId: number, + shopName: string, + } +} + +export interface ReportedReviewContent { + reportId: number, + title: string, + content: string, + nickName: string, + status: string, +} + +export interface GetReviewListParam { + page: number; + limit: number; + isReported: boolean; +} + +export interface SetReviewParam { + id: number; + page: number; + body: { + report_status: string + } +} diff --git a/src/pages/Services/Review/ReviewCard.style.tsx b/src/pages/Services/Review/ReviewCard.style.tsx new file mode 100644 index 00000000..a803ebc5 --- /dev/null +++ b/src/pages/Services/Review/ReviewCard.style.tsx @@ -0,0 +1,57 @@ +import styled from 'styled-components'; + +export const Shortcut = styled.a` + color: '#cacaca'; + text-decoration: none; +`; + +export const Container = styled.div<{ isHandle: boolean }>` + display: flex; + flex-direction: column; + padding: 10px 15px; + background: ${(props) => (props.isHandle ? '#ff000050' : '#00BFFF10')}; + border-radius: 10px; + transition: scale 0.3s, height 0.3s; + width: 100%; + gap: 10px; +`; + +export const Row = styled.div` + display: flex; + align-items: center; + justify-content: space-between; +`; + +export const RowItem = styled.div` + display: flex; + align-items: center; + gap: 20px; +`; + +export const Item = styled.div` + width: 150px; + text-overflow: ellipsis; + white-space: nowrap; + overflow: hidden; +`; + +export const ToggleButton = styled.button` + background: transparent; + border: none; + cursor: pointer; + height: 30px; +`; + +export const MenuImage = styled.img` + width: 250px; + height: 250px; + object-fit: cover; + border-radius: 10px; +`; + +export const AroundRow = styled.div` + width: 100%; + display: flex; + justify-content: space-between; + align-items: center; +`; diff --git a/src/pages/Services/Review/ReviewCard.tsx b/src/pages/Services/Review/ReviewCard.tsx new file mode 100644 index 00000000..f1a42472 --- /dev/null +++ b/src/pages/Services/Review/ReviewCard.tsx @@ -0,0 +1,193 @@ +import { ReviewContent } from 'model/review.model'; +import { Button, message, Modal } from 'antd'; +import { useState } from 'react'; +import { CaretUpOutlined, CaretDownOutlined } from '@ant-design/icons'; +import { useDeleteReviewMutation, useSetReviewDismissedMutation } from 'store/api/review'; +import { KOIN_URL } from 'constant'; +import * as S from './ReviewCard.style'; + +interface Props { + review: ReviewContent; + currentPage: number; +} + +export default function ReviewCard({ review, currentPage }: Props) { + const [isOpen, setIsOpen] = useState(false); + const [isModalOpen, setIsModalOpen] = useState(false); + const [isReportOpen, setIsReportOpen] = useState(false); + const toggle = () => { + setIsOpen((prev) => !prev); + }; + + const [deleteReview, { + isLoading: isDeleteLoading, + isError: isDeleteError, + }] = useDeleteReviewMutation(); + const [dismissReview, { + isLoading: isDismissLoading, + isError: isDismissError, + }] = useSetReviewDismissedMutation(); + + if (isDeleteError) { + message.error('리뷰 삭제에 실패했습니다'); + } + if (isDismissError) { + message.error('리뷰 상태 변경에 실패했습니다.'); + } + + const deleteSpecificReview = () => { + deleteReview({ + id: review.reviewId, + page: currentPage, + }); + }; + + const dismissSpecificReview = () => { + dismissReview({ + id: review.reviewId, + page: currentPage, + body: { + report_status: 'DISMISSED', + }, + }); + }; + + return ( + + + + + {review.shop.shopName} + + + {review.createdAt} + + + + + 식당 페이지 바로가기 + + + + + + + + + + 별점: + {' '} + {review.rating} + + {!isOpen && ( + + {review.content} + + )} + + + {isOpen && ( + <> + +
+ 리뷰 내용: + {' '} + {review.content} +
+
+ +
+ 사진: + {' '} + {review.imageUrls.length > 0 ? review.imageUrls.map((image) => ( + + )) : '없음'} +
+
+ +
+ 이용 메뉴: + {' '} + {review.menuNames.length > 0 ? review.menuNames.map((menu) =>
{menu}
) : '미기재'} +
+
+ +
+ 수정이력: + {' '} + {review.isModified ? 'O' : 'X'} +
+
+ + + {review.isHaveUnhandledReport ? '신고정보' : '신고이력'} + + + {review.reports.length > 0 + ? ( + + + + + {review.isHaveUnhandledReport + && ( + + + + )} + + + ) : '없음'} + + )} + + {isOpen ? : } + + setIsModalOpen(false)}> + + 정말로 삭제하시겠습니까? + + + + + + + setIsReportOpen(false)} footer={null}> + {review.reports.map((report, idx) => ( + + {idx + 1} + . + {' '} + {report.content} + + ))} + +
+ ); +} diff --git a/src/pages/Services/Review/ReviewList.style.tsx b/src/pages/Services/Review/ReviewList.style.tsx new file mode 100644 index 00000000..8f18d5f8 --- /dev/null +++ b/src/pages/Services/Review/ReviewList.style.tsx @@ -0,0 +1,26 @@ +import styled from 'styled-components'; + +export const Container = styled.div` + height: 100vh; + min-width: 1000px; + display: flex; + flex-direction: column; + box-sizing: border-box; + gap: 30px; + position: relative; +`; + +export const Filter = styled.div` + display: flex; + align-items: center; + gap: 15px; +`; + +export const DataContainer = styled.div` + display: flex; + align-items: center; + flex-direction: column; + gap: 15px; + width: 100%; + margin-bottom: 30px; +`; diff --git a/src/pages/Services/Review/ReviewList.tsx b/src/pages/Services/Review/ReviewList.tsx new file mode 100644 index 00000000..9005387e --- /dev/null +++ b/src/pages/Services/Review/ReviewList.tsx @@ -0,0 +1,57 @@ +import { Checkbox, Skeleton, Pagination } from 'antd'; +import { useState } from 'react'; +import { useGetReviewListQuery } from 'store/api/review'; +import * as Common from 'styles/List.style'; +import ScrollUpButton from 'components/common/ScrollUpButton/ScrollUpButton'; +import ReviewCard from './ReviewCard'; +import * as S from './ReviewList.style'; + +const LIMIT = 10; + +export default function ReviewList() { + const [page, setPage] = useState(1); + const [isReported, setIsReported] = useState(false); + const { + data, isLoading, + } = useGetReviewListQuery({ page, limit: LIMIT, isReported }); + + const filterReportedReview = () => { + setIsReported((prev) => !prev); + setPage(1); + }; + + return ( + + + 리뷰 목록 + + {isLoading ? [1, 2, 3, 4, 5].map((key) => ) : ( + <> + + + 신고된 리뷰만 모아보기 + + + {data && data.reviews.map((review) => ( + + ))} + {data && ( + + setPage(num)} + /> + + )} + + + )} + + + ); +} diff --git a/src/store/api/review/index.ts b/src/store/api/review/index.ts new file mode 100644 index 00000000..f35d379b --- /dev/null +++ b/src/store/api/review/index.ts @@ -0,0 +1,52 @@ +import { createApi, fetchBaseQuery } from '@reduxjs/toolkit/query/react'; +import { API_PATH } from 'constant'; +import { GetReviewListParam, ReviewListResponse, SetReviewParam } from 'model/review.model'; +import { RootState } from 'store'; + +export const reviewApi = createApi({ + reducerPath: 'reviews', + tagTypes: ['reviews'], + + baseQuery: fetchBaseQuery({ + baseUrl: `${API_PATH}`, + prepareHeaders: (headers, { getState }) => { + const { token } = (getState() as RootState).auth; + if (token) { + headers.set('authorization', `Bearer ${token}`); + } + return headers; + }, + }), + + endpoints: (builder) => ({ + getReviewList: builder.query({ + query: ({ page, limit, isReported }) => ({ url: `admin/shops/reviews?page=${page}&limit=${limit}&${isReported ? 'is_reported=true' : ''}` }), + providesTags: (result, error, { page }) => [{ type: 'reviews', id: page }], + }), + + setReviewDismissed: builder.mutation({ + query({ id, body }) { + return { + url: `admin/shops/reviews/${id}`, + method: 'put', + body, + }; + }, + invalidatesTags: (result, error, { page }) => [{ type: 'reviews', id: page }], + }), + + deleteReview: builder.mutation({ + query({ id }) { + return { + url: `/admin/shops/reviews/${id}`, + method: 'delete', + }; + }, + invalidatesTags: (result, error, { page }) => [{ type: 'reviews', id: page }], + }), + }), +}); + +export const { + useGetReviewListQuery, useSetReviewDismissedMutation, useDeleteReviewMutation, +} = reviewApi; diff --git a/src/store/index.ts b/src/store/index.ts index 45fff464..b79bf7f2 100644 --- a/src/store/index.ts +++ b/src/store/index.ts @@ -11,6 +11,7 @@ import { storeMenuApi } from './api/storeMenu'; import { menuCategoriesApi } from './api/storeMenu/category'; import { ownerRequestApi } from './api/ownerRequest'; import { ownerApi } from './api/owner'; +import { reviewApi } from './api/review'; const apiList = [ authApi, @@ -24,6 +25,7 @@ const apiList = [ menuCategoriesApi, ownerRequestApi, ownerApi, + reviewApi, ]; const apiMiddleware = apiList.map((api) => api.middleware);