From 7462b8a2a70f7b9ce6b76bd1f3da9833d5c53c28 Mon Sep 17 00:00:00 2001 From: kimpra Date: Thu, 5 Dec 2024 23:46:03 +0900 Subject: [PATCH 001/207] =?UTF-8?q?feat:=20=EA=B4=80=EB=A6=AC=EC=9E=90=20?= =?UTF-8?q?=EC=A7=88=EB=AC=B8=20=ED=8E=98=EC=9D=B4=EC=A7=80=20get=20api=20?= =?UTF-8?q?=EC=97=B0=EB=8F=99=20(#266)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../questions/_api/get-admin-questions.ts | 41 +++++++++ .../_api/set-admin-question-table-body.tsx | 16 ++++ .../_hooks/query/use-admin-questions.ts | 35 ++++++++ .../_hooks/use-admin-questions-page.ts | 55 ++++++++++++ .../_ui/admin-question-state-box/index.tsx | 23 +++++ app/admin/questions/constants.ts | 17 ++++ app/admin/questions/page.module.scss | 19 +++++ app/admin/questions/page.tsx | 84 ++++++++++++++++++- app/admin/questions/types.ts | 23 +++++ 9 files changed, 312 insertions(+), 1 deletion(-) create mode 100644 app/admin/questions/_api/get-admin-questions.ts create mode 100644 app/admin/questions/_api/set-admin-question-table-body.tsx create mode 100644 app/admin/questions/_hooks/query/use-admin-questions.ts create mode 100644 app/admin/questions/_hooks/use-admin-questions-page.ts create mode 100644 app/admin/questions/_ui/admin-question-state-box/index.tsx create mode 100644 app/admin/questions/constants.ts create mode 100644 app/admin/questions/page.module.scss create mode 100644 app/admin/questions/types.ts diff --git a/app/admin/questions/_api/get-admin-questions.ts b/app/admin/questions/_api/get-admin-questions.ts new file mode 100644 index 00000000..812c2ea4 --- /dev/null +++ b/app/admin/questions/_api/get-admin-questions.ts @@ -0,0 +1,41 @@ +import axiosInstance from '@/shared/api/axios' +import { QuestionSearchConditionType, QuestionStateTapType } from '@/shared/types/questions' + +import { AdminQuestionsResponeseModel } from '../types' + +interface ArgModel { + keyword: string | null + searchCondition: QuestionSearchConditionType + stateCondition: QuestionStateTapType + page?: number + size?: number +} + +const getAdminQuestions = async ({ + keyword = null, + searchCondition, + stateCondition, + page = 1, + size = 10, +}: ArgModel) => { + try { + const res = await axiosInstance('/api/admin/questions', { + params: { + keyword, + searchCondition, + stateCondition, + page, + size, + }, + }) + + if (!res.data.isSuccess) throw new Error('Error with code' + res.data.code) + + return res.data.result + } catch (err) { + console.error(err) + throw err + } +} + +export default getAdminQuestions diff --git a/app/admin/questions/_api/set-admin-question-table-body.tsx b/app/admin/questions/_api/set-admin-question-table-body.tsx new file mode 100644 index 00000000..27b94c36 --- /dev/null +++ b/app/admin/questions/_api/set-admin-question-table-body.tsx @@ -0,0 +1,16 @@ +import { AdminQuestionsResponeseModel } from '../types' + +const setAdminQuestionTableBody = (data: AdminQuestionsResponeseModel['result']['content']) => + data.map((data, idx) => { + return [ + idx + 1, + data.title, + data.strategyName, + data.nickname, // 질문 받은 사람으로 수정 + data.nickname, + data.stateCondition, + data.questionId, + ] + }) + +export default setAdminQuestionTableBody diff --git a/app/admin/questions/_hooks/query/use-admin-questions.ts b/app/admin/questions/_hooks/query/use-admin-questions.ts new file mode 100644 index 00000000..c0c47688 --- /dev/null +++ b/app/admin/questions/_hooks/query/use-admin-questions.ts @@ -0,0 +1,35 @@ +import { useQuery } from '@tanstack/react-query' + +import { QuestionSearchConditionType, QuestionStateTapType } from '@/shared/types/questions' + +import getAdminQuestions from '../../_api/get-admin-questions' + +interface ArgModel { + keyword?: string | null + searchCondition: QuestionSearchConditionType + stateCondition: QuestionStateTapType + page?: number + size?: number +} + +const useAdminQuestions = ({ + keyword = null, + searchCondition, + stateCondition, + page = 0, + size = 10, +}: ArgModel) => { + return useQuery({ + queryKey: ['adminQuestions', [keyword, searchCondition, stateCondition, page, size]], + queryFn: () => + getAdminQuestions({ + keyword, + searchCondition, + stateCondition, + page, + size, + }), + }) +} + +export default useAdminQuestions diff --git a/app/admin/questions/_hooks/use-admin-questions-page.ts b/app/admin/questions/_hooks/use-admin-questions-page.ts new file mode 100644 index 00000000..123f74aa --- /dev/null +++ b/app/admin/questions/_hooks/use-admin-questions-page.ts @@ -0,0 +1,55 @@ +import { useState } from 'react' + +import { QuestionSearchConditionType, QuestionStateTapType } from '@/shared/types/questions' + +import { searchConditions, tabs } from '../constants' + +const useAdminQuestionsPage = () => { + const [activeTab, setActiveTab] = useState(tabs[0].id) + const [keyword, setKeyword] = useState('') + const [stateCondition, setStateCondition] = useState('ALL') + const [searchCondition, setSearchCondition] = useState('TITLE') + const initialSearchParams = { + keyword: '', + searchCondition, + } + const [searchParams, setSearchParams] = useState(initialSearchParams) + + const initializeSearchParams = () => { + setKeyword('') + setSearchCondition('TITLE') + setSearchParams(initialSearchParams) + } + + const setConditionAndKeyword = () => { + setSearchParams({ + keyword, + searchCondition, + }) + } + + const onTabChange = (id: string) => { + initializeSearchParams() + setActiveTab(id) + setStateCondition(id as QuestionStateTapType) + } + + return { + searchConditions, + tabs, + activeTab, + setActiveTab, + keyword, + setKeyword, + stateCondition, + setStateCondition, + searchCondition, + searchParams, + setSearchCondition, + setConditionAndKeyword, + onTabChange, + initializeSearchParams, + } +} + +export default useAdminQuestionsPage diff --git a/app/admin/questions/_ui/admin-question-state-box/index.tsx b/app/admin/questions/_ui/admin-question-state-box/index.tsx new file mode 100644 index 00000000..d8622297 --- /dev/null +++ b/app/admin/questions/_ui/admin-question-state-box/index.tsx @@ -0,0 +1,23 @@ +'use client' + +import { Button } from '@/shared/ui/button' + +interface Props { + userId: number +} + +const AdminQuestionStateBox = () => { + return ( + + ) +} + +export default AdminQuestionStateBox diff --git a/app/admin/questions/constants.ts b/app/admin/questions/constants.ts new file mode 100644 index 00000000..2b6c6059 --- /dev/null +++ b/app/admin/questions/constants.ts @@ -0,0 +1,17 @@ +import { DropdownOptionModel } from '@/shared/ui/dropdown/types' +import { TabItemModel } from '@/shared/ui/tabs' + +export const searchConditions: DropdownOptionModel[] = [ + { label: '제목', value: 'TITLE' }, + { label: '내용', value: 'CONTENT' }, + { label: '제목 + 내용', value: 'TITLE_OR_CONTENT' }, + { label: '트레이더명', value: 'TRADER_NAME' }, + { label: '투자자명', value: 'INVESTOR_NAME' }, + { label: '전략명', value: 'STRATEGY_NAME' }, +] + +export const tabs: Array = [ + { label: '모든 질문', id: 'ALL' }, + { label: '답변대기', id: 'WAITING' }, + { label: '답변완료', id: 'COMPLETED' }, +] diff --git a/app/admin/questions/page.module.scss b/app/admin/questions/page.module.scss new file mode 100644 index 00000000..0038b2d7 --- /dev/null +++ b/app/admin/questions/page.module.scss @@ -0,0 +1,19 @@ +.title { + margin: $header-height 0 26px 13px; +} + +.container { + padding: 42px 45px 37px; + border-radius: 8px; + margin-bottom: 42px; + background-color: $color-white; +} + +.color-primary-500 { + color: $color-orange-500; +} + +.condition-search { + display: flex; + gap: 24px; +} diff --git a/app/admin/questions/page.tsx b/app/admin/questions/page.tsx index 7fbeedf7..22bf2e3a 100644 --- a/app/admin/questions/page.tsx +++ b/app/admin/questions/page.tsx @@ -1,5 +1,87 @@ +'use client' + +import classNames from 'classnames/bind' + +import { QuestionSearchConditionType } from '@/shared/types/questions' +import Pagination from '@/shared/ui/pagination' +import { SearchInput } from '@/shared/ui/search-input' +import Select from '@/shared/ui/select' +import VerticalTable from '@/shared/ui/table/vertical' +import Tabs from '@/shared/ui/tabs' +import Title from '@/shared/ui/title' + +import AdminContentsHeader from '../_ui/admin-header' +import setAdminQuestionTableBody from './_api/set-admin-question-table-body' +import useAdminQuestions from './_hooks/query/use-admin-questions' +import useAdminQuestionsPage from './_hooks/use-admin-questions-page' +import styles from './page.module.scss' + +const cx = classNames.bind(styles) + const AdminQuestionsPage = () => { - return <> + const { + searchConditions, + tabs, + activeTab, + keyword, + setKeyword, + searchCondition, + setSearchCondition, + stateCondition, + setConditionAndKeyword, + searchParams, + onTabChange, + } = useAdminQuestionsPage() + + const { isLoading, data } = useAdminQuestions({ + ...searchParams, + stateCondition, + }) + + if (isLoading || !data) return null + + return ( + <> + + <section className={cx('container')}> + <Tabs tabs={tabs} activeTab={activeTab} onTabChange={onTabChange} /> + <AdminContentsHeader + Left={ + <span> + 총 <span className={cx('color-primary-500')}>{data?.totalElements}</span>개 + </span> + } + Right={ + <div className={cx('condition-search')}> + <Select + size="small" + options={searchConditions.map((option) => ({ + label: option.label, + value: option.value, + }))} + value={searchCondition} + onChange={(v) => { + setSearchCondition(v as QuestionSearchConditionType) + }} + /> + <SearchInput + value={keyword} + onChange={(e) => setKeyword(e.target.value)} + onSearchIconClick={setConditionAndKeyword} + /> + </div> + } + /> + <VerticalTable + tableHead={['No.', '제목', '전략명', '트레이더', '질문자', '상태', '']} + tableBody={setAdminQuestionTableBody(data.content)} + countPerPage={data.size} + currentPage={1} + /> + <Pagination currentPage={data.page} maxPage={data.totalPages} onPageChange={() => {}} /> + </section> + </> + ) } export default AdminQuestionsPage diff --git a/app/admin/questions/types.ts b/app/admin/questions/types.ts new file mode 100644 index 00000000..b3cfb992 --- /dev/null +++ b/app/admin/questions/types.ts @@ -0,0 +1,23 @@ +import { QuestionStateConditionType } from '@/shared/types/questions' +import { APIResponseBaseModel } from '@/shared/types/response' + +export interface AdminQuestionsResponeseModel extends APIResponseBaseModel<boolean> { + result: { + content: Array<{ + questionId: number + title: string + questionContent: string + strategyName: string + profileImageUrl: string + nickname: string + stateCondition: QuestionStateConditionType + createdAt: string + }> + page: number + size: number + totalElements: number + totalPages: number + first: boolean + last: boolean + } +} From c33c05ff171edb7415f8625a6aba33e69851bf03 Mon Sep 17 00:00:00 2001 From: kimpra <dustmqaksdltkfrlfdldi@gmail.com> Date: Fri, 6 Dec 2024 00:46:56 +0900 Subject: [PATCH 002/207] =?UTF-8?q?feat:=20=EA=B4=80=EB=A6=AC=EC=9E=90=20?= =?UTF-8?q?=EC=A7=88=EB=AC=B8=20=ED=8E=98=EC=9D=B4=EC=A7=80=20=ED=91=9C=20?= =?UTF-8?q?=EC=99=84=EC=84=B1=20(#266)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../_api/set-admin-question-table-body.tsx | 36 +++++++++++++++++-- .../_ui/admin-question-state-box/index.tsx | 28 +++++++++------ .../styles.module.scss | 21 +++++++++++ 3 files changed, 71 insertions(+), 14 deletions(-) create mode 100644 app/admin/questions/_ui/admin-question-state-box/styles.module.scss diff --git a/app/admin/questions/_api/set-admin-question-table-body.tsx b/app/admin/questions/_api/set-admin-question-table-body.tsx index 27b94c36..c191116e 100644 --- a/app/admin/questions/_api/set-admin-question-table-body.tsx +++ b/app/admin/questions/_api/set-admin-question-table-body.tsx @@ -1,3 +1,6 @@ +import { Button } from '@/shared/ui/button' + +import AdminQuestionStateBox from '../_ui/admin-question-state-box' import { AdminQuestionsResponeseModel } from '../types' const setAdminQuestionTableBody = (data: AdminQuestionsResponeseModel['result']['content']) => @@ -6,10 +9,37 @@ const setAdminQuestionTableBody = (data: AdminQuestionsResponeseModel['result'][ idx + 1, data.title, data.strategyName, - data.nickname, // 질문 받은 사람으로 수정 + data.questionId, // 질문 받은 사람으로 수정 data.nickname, - data.stateCondition, - data.questionId, + <AdminQuestionStateBox + questionState={data.stateCondition} + key={data.createdAt + data.nickname} + />, + <Button.ButtonGroup gap="24px" key={data.createdAt + data.nickname}> + <Button + size="small" + style={{ + width: 'fit-content', + height: '30px', + padding: '7px 16px', + borderRadius: '16px', + }} + > + 상세보기 + </Button> + <Button + variant="filled" + size="small" + style={{ + width: 'fit-content', + height: '30px', + padding: '7px 16px', + borderRadius: '16px', + }} + > + 삭제 + </Button> + </Button.ButtonGroup>, ] }) diff --git a/app/admin/questions/_ui/admin-question-state-box/index.tsx b/app/admin/questions/_ui/admin-question-state-box/index.tsx index d8622297..fa42e5c7 100644 --- a/app/admin/questions/_ui/admin-question-state-box/index.tsx +++ b/app/admin/questions/_ui/admin-question-state-box/index.tsx @@ -1,22 +1,28 @@ 'use client' -import { Button } from '@/shared/ui/button' +import classNames from 'classnames/bind' + +import { QuestionStateConditionType } from '@/shared/types/questions' + +import styles from './styles.module.scss' + +const cx = classNames.bind(styles) interface Props { - userId: number + questionState: QuestionStateConditionType } -const AdminQuestionStateBox = () => { +const AdminQuestionStateBox = ({ questionState }: Props) => { return ( - <Button - variant="filled" - // onClick={() => mutate()} - // disabled={isPending} - size="small" - style={{ padding: '7px 16px' }} + <div + className={cx( + 'container', + { waiting: questionState === 'WAITING' }, + { completed: questionState === 'COMPLETED' } + )} > - 강제삭제 - </Button> + {questionState === 'COMPLETED' ? '답변완료' : '답변대기'} + </div> ) } diff --git a/app/admin/questions/_ui/admin-question-state-box/styles.module.scss b/app/admin/questions/_ui/admin-question-state-box/styles.module.scss new file mode 100644 index 00000000..de50de4a --- /dev/null +++ b/app/admin/questions/_ui/admin-question-state-box/styles.module.scss @@ -0,0 +1,21 @@ +$color-waiting: $color-orange-600; +$color-completed: $color-indigo; + +.container { + width: fit-content; + text-align: center; + padding: 7px 16px; + border-radius: 4px; + border: 1px solid; + @include typo-c1; + + &.waiting { + color: $color-waiting; + border-color: $color-waiting; + } + + &.completed { + color: $color-completed; + border-color: $color-completed; + } +} From d71f438245a2420b16d11eddb8445565c949bbe4 Mon Sep 17 00:00:00 2001 From: James <dawn147@snu.ac.kr> Date: Sun, 8 Dec 2024 14:18:23 +0900 Subject: [PATCH 003/207] =?UTF-8?q?feat:=20=EC=9D=BC=EA=B0=84=EB=B6=84?= =?UTF-8?q?=EC=84=9D=20=EC=88=98=EC=A0=95=20=EB=B0=8F=20=EC=82=AD=EC=A0=9C?= =?UTF-8?q?=20api=20=EC=97=B0=EA=B2=B0=20(#1)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../my/_api/manage-daily-analysis.ts | 27 ++++++++ .../_hooks/query/use-get-my-daily-analysis.ts | 6 +- .../_hooks/query/use-manage-daily-analysis.ts | 67 +++++++++++++++++++ shared/types/strategy-data.ts | 11 +++ 4 files changed, 108 insertions(+), 3 deletions(-) create mode 100644 app/(dashboard)/my/_api/manage-daily-analysis.ts create mode 100644 app/(dashboard)/my/_hooks/query/use-manage-daily-analysis.ts diff --git a/app/(dashboard)/my/_api/manage-daily-analysis.ts b/app/(dashboard)/my/_api/manage-daily-analysis.ts new file mode 100644 index 00000000..3c8206dc --- /dev/null +++ b/app/(dashboard)/my/_api/manage-daily-analysis.ts @@ -0,0 +1,27 @@ +import axiosInstance from '@/shared/api/axios' + +export interface EditAnalysisPayloadModel { + date: string + transaction: number + dailyProfitLoss: number +} + +export const editAnalysis = async (strategyId: number, payload: EditAnalysisPayloadModel) => { + const response = await axiosInstance.patch( + `/api/my-strategies/${strategyId}/daily-analysis`, + payload + ) + return response.data +} + +export const deleteAnalysis = async (strategyId: number, analysisId: number) => { + const response = await axiosInstance.delete( + `/api/my-strategies/${strategyId}/daily-analysis?analysisId=${analysisId}` + ) + return response.data +} + +export const deleteAllAnalysis = async (strategyId: number) => { + const response = await axiosInstance.delete(`/api/my-strategies/${strategyId}/daily-analysis/all`) + return response.data +} diff --git a/app/(dashboard)/my/_hooks/query/use-get-my-daily-analysis.ts b/app/(dashboard)/my/_hooks/query/use-get-my-daily-analysis.ts index 3df8e5c8..83267dac 100644 --- a/app/(dashboard)/my/_hooks/query/use-get-my-daily-analysis.ts +++ b/app/(dashboard)/my/_hooks/query/use-get-my-daily-analysis.ts @@ -2,11 +2,11 @@ import { useQuery } from '@tanstack/react-query' import getMyDailyAnalysis from '../../_api/get-my-daily-analysis' -const useGetAnalysis = (strategyId: number, page: number, size: number) => { +const useGetMyDailyAnalysis = (strategyId: number, page: number, size: number) => { return useQuery({ - queryKey: ['myDailyAnalysis', strategyId, page], + queryKey: ['myDailyAnalysis', strategyId, page, size], queryFn: () => getMyDailyAnalysis(strategyId, page, size), }) } -export default useGetAnalysis +export default useGetMyDailyAnalysis diff --git a/app/(dashboard)/my/_hooks/query/use-manage-daily-analysis.ts b/app/(dashboard)/my/_hooks/query/use-manage-daily-analysis.ts new file mode 100644 index 00000000..41703ed1 --- /dev/null +++ b/app/(dashboard)/my/_hooks/query/use-manage-daily-analysis.ts @@ -0,0 +1,67 @@ +import { useMutation, useQueryClient } from '@tanstack/react-query' + +import { + EditAnalysisPayloadModel, + deleteAllAnalysis, + deleteAnalysis, + editAnalysis, +} from '../../_api/manage-daily-analysis' + +export const useMyAnalysisMutation = (strategyId: number, page: number, size: number) => { + const queryClient = useQueryClient() + const queryKey = ['myDailyAnalysis', strategyId, page, size] + + const { mutate: editAnalysisData } = useMutation({ + mutationFn: ({ payload }: { payload: EditAnalysisPayloadModel }) => { + if (!strategyId) { + throw new Error('Strategy ID is required') + } + return editAnalysis(strategyId, payload) + }, + onSuccess: () => { + queryClient.invalidateQueries({ queryKey }) + }, + onError: (error: Error) => { + console.error('Edit error:', error) + throw error + }, + }) + + const { mutate: deleteAnalysisData } = useMutation({ + mutationFn: (analysisId: number) => { + if (!strategyId) { + throw new Error('Strategy ID is required') + } + return deleteAnalysis(strategyId, analysisId) + }, + onSuccess: () => { + queryClient.invalidateQueries({ queryKey }) + }, + onError: (error: Error) => { + console.error('Delete error:', error) + throw error + }, + }) + + const { mutate: deleteAllAnalysisData } = useMutation({ + mutationFn: () => { + if (!strategyId) { + throw new Error('Strategy ID is required') + } + return deleteAllAnalysis(strategyId) + }, + onSuccess: () => { + queryClient.invalidateQueries({ queryKey }) + }, + onError: (error: Error) => { + console.error('Delete all error:', error) + throw error + }, + }) + + return { + editAnalysisData, + deleteAnalysisData, + deleteAllAnalysisData, + } +} diff --git a/shared/types/strategy-data.ts b/shared/types/strategy-data.ts index d25c4ca1..577d56d0 100644 --- a/shared/types/strategy-data.ts +++ b/shared/types/strategy-data.ts @@ -9,6 +9,17 @@ export interface DailyAnalysisModel { cumulativeProfitLossRate: number } +export interface MyDailyAnalysisModel { + dailyAnalysisId: number + dailyDate: string + transaction: number + dailyProfitLoss: number + dailyProfitLossRate: number + principal: number + cumulativeProfitLoss: number + cumulativeProfitLossRate: number +} + export interface MonthlyAnalysisModel { month: string monthlyAveragePrincipal: number From 251ad9b8512a7973116bd3a326bb907cd90f3516 Mon Sep 17 00:00:00 2001 From: James <dawn147@snu.ac.kr> Date: Sun, 8 Dec 2024 14:19:35 +0900 Subject: [PATCH 004/207] =?UTF-8?q?feat:=20=EC=97=91=EC=85=80=EB=A1=9C=20?= =?UTF-8?q?=EC=9D=BC=EA=B0=84=EB=B6=84=EC=84=9D=20=EC=97=85=EB=A1=9C?= =?UTF-8?q?=EB=93=9C=EC=8B=9C=20=EB=82=A0=EC=A7=9C=20=EB=8D=B0=EC=9D=B4?= =?UTF-8?q?=ED=84=B0=EB=A5=BC=20=EC=A0=9C=EB=8C=80=EB=A1=9C=20=EC=9D=B8?= =?UTF-8?q?=EC=8B=9D=ED=95=A0=20=EC=88=98=20=EC=9E=88=EB=8F=84=EB=A1=9D=20?= =?UTF-8?q?=EC=88=98=EC=A0=95=20(#1)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- shared/utils/excel-utils.ts | 19 +++++++++++++++---- 1 file changed, 15 insertions(+), 4 deletions(-) diff --git a/shared/utils/excel-utils.ts b/shared/utils/excel-utils.ts index 2b7d3a45..3d652816 100644 --- a/shared/utils/excel-utils.ts +++ b/shared/utils/excel-utils.ts @@ -33,9 +33,9 @@ export const processExcelFile = (file: File): Promise<RowDataModel[]> => { .slice(1) .filter((row) => row.length >= 3) .map((row) => ({ - date: parseExcelDate(row[0] as number), - transaction: Number(row[1]), - dailyProfitLoss: Number(row[2]), + date: parseExcelDate(row[0] ?? ''), + transaction: Number(row[1] ?? 0), + dailyProfitLoss: Number(row[2] ?? 0), })) resolve(rows) @@ -49,7 +49,17 @@ export const processExcelFile = (file: File): Promise<RowDataModel[]> => { }) } -const parseExcelDate = (excelDate: number): string => { +const parseExcelDate = (excelDate: number | string | null): string => { + if (excelDate === null) return '' + + const dateStr = String(excelDate) + if (dateStr.length === 8) { + const year = dateStr.substring(0, 4) + const month = dateStr.substring(4, 6) + const day = dateStr.substring(6, 8) + return `${year}-${month}-${day}` + } + if (typeof excelDate === 'number') { const date = XLSX.SSF.parse_date_code(excelDate) const year = date.y @@ -57,5 +67,6 @@ const parseExcelDate = (excelDate: number): string => { const day = String(date.d).padStart(2, '0') return `${year}-${month}-${day}` } + return String(excelDate) } From 61cec62be3e3e2d87fe4b862e4747d8d3b0c0803 Mon Sep 17 00:00:00 2001 From: James <dawn147@snu.ac.kr> Date: Sun, 8 Dec 2024 14:20:39 +0900 Subject: [PATCH 005/207] =?UTF-8?q?design:=20=ED=85=8C=EC=9D=B4=EB=B8=94?= =?UTF-8?q?=20=ED=97=A4=EB=8D=94=20=EC=8A=A4=ED=83=80=EC=9D=BC=EB=A7=81=20?= =?UTF-8?q?=EC=88=98=EC=A0=95=20=EB=B0=8F=20=EC=A3=BC=EC=84=9D=20=EC=A0=9C?= =?UTF-8?q?=EA=B1=B0=20(#1)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../analysis-container/analysis-content.tsx | 2 +- .../styles.module.scss | 48 +++++++++++++++++++ shared/ui/table/vertical/styles.module.scss | 3 ++ 3 files changed, 52 insertions(+), 1 deletion(-) create mode 100644 shared/ui/modal/edit-daily-analysis-modal.ts/styles.module.scss diff --git a/app/(dashboard)/_ui/analysis-container/analysis-content.tsx b/app/(dashboard)/_ui/analysis-container/analysis-content.tsx index 43b53475..ee9850ba 100644 --- a/app/(dashboard)/_ui/analysis-container/analysis-content.tsx +++ b/app/(dashboard)/_ui/analysis-container/analysis-content.tsx @@ -57,7 +57,6 @@ const AnalysisContent = ({ const [uploadType, setUploadType] = useState<'excel' | 'direct' | null>(null) const { isModalOpen, openModal, closeModal } = useModal() - //TODO 현재 나의 전략 일간분석 조회 권한이 없어서 안보임 const { data: myAnalysisData } = useGetMyDailyAnalysis( strategyId, currentPage, @@ -155,6 +154,7 @@ const AnalysisContent = ({ currentPage={1} countPerPage={ANALYSIS_PAGE_COUNT} isEditable={isEditable} + strategyId={strategyId} /> <Pagination currentPage={currentPage} diff --git a/shared/ui/modal/edit-daily-analysis-modal.ts/styles.module.scss b/shared/ui/modal/edit-daily-analysis-modal.ts/styles.module.scss new file mode 100644 index 00000000..392f4a25 --- /dev/null +++ b/shared/ui/modal/edit-daily-analysis-modal.ts/styles.module.scss @@ -0,0 +1,48 @@ +.upload-container { + display: flex; + flex-direction: column; + gap: 16px; + padding: 24px; +} + +.form-grid { + display: grid; + grid-template-columns: 170px 170px 160px; + gap: 24px; + align-items: flex-start; +} + +.input-group { + display: flex; + flex-direction: column; + gap: 8px; + + .label { + @include typo-b3; + color: $color-gray-800; + } + + .data-input { + width: 100%; + border-radius: 4px; + border: 1px solid $color-gray-200; + + &::placeholder { + color: $color-gray-400; + @include typo-c1; + } + } +} + +.error-message { + color: $color-orange-500; + font-size: 14px; + margin-top: 8px; +} + +.button-group { + display: flex; + justify-content: center; + gap: 24px; + margin-top: 32px; +} diff --git a/shared/ui/table/vertical/styles.module.scss b/shared/ui/table/vertical/styles.module.scss index b3b10445..435f19f3 100644 --- a/shared/ui/table/vertical/styles.module.scss +++ b/shared/ui/table/vertical/styles.module.scss @@ -20,6 +20,9 @@ } .button-container { + display: flex; + justify-content: center; + align-items: center; padding: 0; text-align: right; } From c6afc0175dbd61432f0226768c1178001627df9f Mon Sep 17 00:00:00 2001 From: James <dawn147@snu.ac.kr> Date: Sun, 8 Dec 2024 14:21:00 +0900 Subject: [PATCH 006/207] =?UTF-8?q?feat:=20=EC=9D=BC=EA=B0=84=EB=B6=84?= =?UTF-8?q?=EC=84=9D=20=EC=88=98=EC=A0=95=20=EB=AA=A8=EB=8B=AC=20=EA=B5=AC?= =?UTF-8?q?=ED=98=84=20(#1)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- app/(dashboard)/_ui/strategies-item/index.tsx | 1 + .../edit-daily-analysis-modal.ts/index.tsx | 158 +++++++++++++++++ shared/ui/table/vertical/index.tsx | 167 +++++++++++++----- 3 files changed, 284 insertions(+), 42 deletions(-) create mode 100644 shared/ui/modal/edit-daily-analysis-modal.ts/index.tsx diff --git a/app/(dashboard)/_ui/strategies-item/index.tsx b/app/(dashboard)/_ui/strategies-item/index.tsx index 63a99ae4..534a1bd4 100644 --- a/app/(dashboard)/_ui/strategies-item/index.tsx +++ b/app/(dashboard)/_ui/strategies-item/index.tsx @@ -86,6 +86,7 @@ const StrategiesItem = ({ strategiesData: data, type = 'default' }: Props) => { variant="filled" href={`/my/strategies/manage/${data.strategyId}`} className={cx('manage-button')} + onClick={(e) => e.stopPropagation()} > 관리 </LinkButton> diff --git a/shared/ui/modal/edit-daily-analysis-modal.ts/index.tsx b/shared/ui/modal/edit-daily-analysis-modal.ts/index.tsx new file mode 100644 index 00000000..342fbdac --- /dev/null +++ b/shared/ui/modal/edit-daily-analysis-modal.ts/index.tsx @@ -0,0 +1,158 @@ +'use client' + +import React, { useState } from 'react' + +import { useMyAnalysisMutation } from '@/app/(dashboard)/my/_hooks/query/use-manage-daily-analysis' +import { RegisterIcon } from '@/public/icons' +import classNames from 'classnames/bind' + +import { Button } from '@/shared/ui/button' +import { Input } from '@/shared/ui/input' +import Modal from '@/shared/ui/modal' + +import styles from './styles.module.scss' + +const cx = classNames.bind(styles) + +interface EditAnalysisModalProps { + isOpen: boolean + onClose: () => void + strategyId: number + analysisId: number + initialData: { + date: string + transaction: number + dailyProfitLoss: number + } + page: number + size: number +} + +const EditAnalysisModal = ({ + isOpen, + onClose, + strategyId, + initialData, + page, + size, +}: EditAnalysisModalProps) => { + const [formData, setFormData] = useState({ + date: initialData.date, + transaction: initialData.transaction.toString(), + dailyProfitLoss: initialData.dailyProfitLoss.toString(), + }) + const [error, setError] = useState<string>('') + + const { editAnalysisData } = useMyAnalysisMutation(strategyId, page, size) + + const handleChange = (field: 'date' | 'transaction' | 'dailyProfitLoss', value: string) => { + setFormData((prev) => ({ + ...prev, + [field]: value, + })) + setError('') + } + + const validateForm = () => { + if (!formData.date || !formData.transaction || !formData.dailyProfitLoss) { + setError('모든 필드를 입력해주세요.') + return false + } + + const dateRegex = /^\d{4}-\d{2}-\d{2}$/ + if (!dateRegex.test(formData.date)) { + setError('날짜 형식이 올바르지 않습니다. (YYYY-MM-DD)') + return false + } + + const transaction = Number(formData.transaction) + const dailyProfitLoss = Number(formData.dailyProfitLoss) + + if (isNaN(transaction) || isNaN(dailyProfitLoss)) { + setError('입출금과 일손익은 숫자여야 합니다.') + return false + } + + return true + } + + const handleSubmit = () => { + try { + if (!validateForm()) return + + editAnalysisData( + { + payload: { + date: formData.date, + transaction: Number(formData.transaction), + dailyProfitLoss: Number(formData.dailyProfitLoss), + }, + }, + { + onSuccess: () => { + onClose() + }, + onError: () => { + setError('데이터 수정 중 오류가 발생했습니다.') + }, + } + ) + } catch (error) { + setError('데이터 수정 중 오류가 발생했습니다.') + } + } + + return ( + <Modal isOpen={isOpen} size="big" icon={RegisterIcon} message="일간분석 데이터 수정"> + <div className={cx('upload-container')}> + <div className={cx('form-grid')}> + <div className={cx('input-group')}> + <label className={cx('label')}>날짜</label> + <Input + className={cx('data-input')} + name="date" + value={formData.date} + onChange={(e) => handleChange('date', e.target.value)} + placeholder="날짜를 입력해주세요." + /> + </div> + + <div className={cx('input-group')}> + <label className={cx('label')}>입출금</label> + <Input + className={cx('data-input')} + name="transaction" + value={formData.transaction} + onChange={(e) => handleChange('transaction', e.target.value)} + placeholder="입출금을 입력해주세요." + /> + </div> + + <div className={cx('input-group')}> + <label className={cx('label')}>일 손익</label> + <Input + className={cx('data-input')} + name="dailyProfitLoss" + value={formData.dailyProfitLoss} + onChange={(e) => handleChange('dailyProfitLoss', e.target.value)} + placeholder="일손익금을 입력해주세요." + /> + </div> + </div> + + {error && <p className={cx('error-message')}>{error}</p>} + + <div className={cx('button-group')}> + <Button variant="outline" onClick={onClose}> + 취소 + </Button> + <Button variant="filled" onClick={handleSubmit}> + 수정 + </Button> + </div> + </div> + </Modal> + ) +} + +export default EditAnalysisModal diff --git a/shared/ui/table/vertical/index.tsx b/shared/ui/table/vertical/index.tsx index ebd14ad9..ac3c5b81 100644 --- a/shared/ui/table/vertical/index.tsx +++ b/shared/ui/table/vertical/index.tsx @@ -1,20 +1,29 @@ -// import { TABLE_DATA } from '@/app/admin/notices/tabledata' -import { ReactNode } from 'react' +'use client' +import { ReactNode, useState } from 'react' + +import { useMyAnalysisMutation } from '@/app/(dashboard)/my/_hooks/query/use-manage-daily-analysis' import { NoticeListContentModel } from '@/app/(landing)/notices/_ui/notice-table' import classNames from 'classnames/bind' -import { DailyAnalysisModel, MonthlyAnalysisModel } from '@/shared/types/strategy-data' +import useModal from '@/shared/hooks/custom/use-modal' +import { + DailyAnalysisModel, + MonthlyAnalysisModel, + MyDailyAnalysisModel, +} from '@/shared/types/strategy-data' import { Button } from '@/shared/ui/button' import { formatNumber } from '@/shared/utils/format' import sliceArray from '@/shared/utils/slice-array' +import EditAnalysisModal from '../../modal/edit-daily-analysis-modal.ts' import styles from './styles.module.scss' const cx = classNames.bind(styles) type TableBodyDataType = | DailyAnalysisModel + | MyDailyAnalysisModel | MonthlyAnalysisModel | NoticeListContentModel | Array<ReactNode | string | number> @@ -26,6 +35,19 @@ export interface VerticalTableProps { currentPage: number isEditable?: boolean className?: string + strategyId?: number +} + +const isMyAnalysisData = (data: TableBodyDataType): data is MyDailyAnalysisModel => { + if (!data || typeof data !== 'object' || Array.isArray(data)) return false + + return ( + 'dailyAnalysisId' in data && + 'dailyDate' in data && + 'transaction' in data && + 'dailyProfitLoss' in data && + 'principal' in data + ) } const VerticalTable = ({ @@ -35,51 +57,112 @@ const VerticalTable = ({ currentPage, isEditable = false, className, + strategyId, }: VerticalTableProps) => { const hasData = tableBody.length > 0 const slicedTableBody = sliceArray(tableBody, countPerPage, currentPage) + const { isModalOpen, openModal, closeModal } = useModal() + const [selectedAnalysis, setSelectedAnalysis] = useState<MyDailyAnalysisModel | null>(null) + + const { deleteAnalysisData } = useMyAnalysisMutation(strategyId ?? 0, currentPage, countPerPage) + + const handleDelete = async (dailyAnalysisId: number) => { + if (!strategyId) return + if (window.confirm('해당 데이터를 삭제하시겠습니까?')) { + try { + await deleteAnalysisData(dailyAnalysisId) + } catch (error) { + console.error('Delete failed:', error) + alert('삭제 중 오류가 발생했습니다.') + } + } + } + + const handleEditClick = (row: TableBodyDataType) => { + if (!isMyAnalysisData(row)) { + return + } + setSelectedAnalysis(row) + openModal() + } + + const handleCloseModal = () => { + closeModal() + setSelectedAnalysis(null) + } return ( - <div className={cx('container', className)}> - <table> - <thead> - <tr> - {tableHead.map((head) => ( - <td key={head}>{head}</td> - ))} - {isEditable && <td></td>} - </tr> - </thead> - {hasData && ( - <tbody> - {slicedTableBody.map((row) => ( - <tr key={Object.values(row)[0]}> - {Object.values(row) - .slice(isEditable ? 1 : 0) - .map((data, idx) => ( - <td key={data + idx}>{formatNumber(data)}</td> - ))} - {isEditable && ( - <td className={cx('button-container')}> - <Button size="small" variant="outline" className={cx('edit-button')}> - 수정 - </Button> - <Button size="small" variant="filled" className={cx('delete-button')}> - 삭제 - </Button> - </td> - )} - </tr> - ))} - </tbody> + <> + <div className={cx('container', className)}> + <table> + <thead> + <tr> + {tableHead.map((head) => ( + <td key={head}>{head}</td> + ))} + {isEditable && <td>관리</td>} + </tr> + </thead> + {hasData && ( + <tbody> + {slicedTableBody.map((row) => ( + <tr key={Object.values(row)[0]}> + {Object.values(row) + .slice(isEditable ? 1 : 0) + .map((data, idx) => ( + <td key={data + idx}>{formatNumber(data)}</td> + ))} + {isEditable && ( + <td className={cx('button-container')}> + <Button + size="small" + variant="outline" + className={cx('edit-button')} + onClick={() => handleEditClick(row)} + > + 수정 + </Button> + <Button + size="small" + variant="filled" + className={cx('delete-button')} + onClick={() => { + if (!isMyAnalysisData(row)) return + handleDelete(row.dailyAnalysisId) + }} + > + 삭제 + </Button> + </td> + )} + </tr> + ))} + </tbody> + )} + </table> + {!hasData && ( + <div className={cx('no-data')} style={{ height: `calc(40px * ${countPerPage}` }}> + 데이터가 존재하지 않습니다. + </div> )} - </table> - {!hasData && ( - <div className={cx('no-data')} style={{ height: `calc(40px * ${countPerPage}` }}> - 데이터가 존재하지 않습니다. - </div> + </div> + + {selectedAnalysis && ( + <EditAnalysisModal + isOpen={isModalOpen} + onClose={handleCloseModal} + strategyId={strategyId ?? 0} + analysisId={selectedAnalysis.dailyAnalysisId} + initialData={{ + date: selectedAnalysis.dailyDate, + transaction: selectedAnalysis.transaction, + dailyProfitLoss: selectedAnalysis.dailyProfitLoss, + }} + page={currentPage} + size={countPerPage} + /> )} - </div> + </> ) } @@ -90,7 +173,7 @@ const Skeleton = ({ tableHead, countPerPage, isEditable = false }: Partial<Verti <thead> <tr> {tableHead?.map((head) => <td key={head}>{head}</td>)} - {isEditable && <td></td>} + {isEditable && <td>관리</td>} </tr> </thead> </table> From 84f89dd66a5b9ade6cd4bd19d4ecee5efef82bfd Mon Sep 17 00:00:00 2001 From: James <dawn147@snu.ac.kr> Date: Sun, 8 Dec 2024 14:25:15 +0900 Subject: [PATCH 007/207] =?UTF-8?q?design:=20=ED=85=8C=EC=9D=B4=EB=B8=94?= =?UTF-8?q?=20=ED=97=A4=EB=8D=94=EC=97=90=EC=84=9C=20=EA=B4=80=EB=A6=AC=20?= =?UTF-8?q?=EC=82=AD=EC=A0=9C=20(#1)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- shared/ui/table/vertical/index.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/shared/ui/table/vertical/index.tsx b/shared/ui/table/vertical/index.tsx index ac3c5b81..d28e92e5 100644 --- a/shared/ui/table/vertical/index.tsx +++ b/shared/ui/table/vertical/index.tsx @@ -100,7 +100,7 @@ const VerticalTable = ({ {tableHead.map((head) => ( <td key={head}>{head}</td> ))} - {isEditable && <td>관리</td>} + {isEditable && <td></td>} </tr> </thead> {hasData && ( From 90929a7fa4ea7014135a1ce0e73ad6abc7ed28e1 Mon Sep 17 00:00:00 2001 From: James <dawn147@snu.ac.kr> Date: Sun, 8 Dec 2024 16:16:32 +0900 Subject: [PATCH 008/207] =?UTF-8?q?feat:=20=EC=A0=84=EB=9E=B5=20=EC=82=AD?= =?UTF-8?q?=EC=A0=9C=20=EB=B0=8F=20=EC=9D=BC=EA=B0=84=20=EB=B6=84=EC=84=9D?= =?UTF-8?q?=20=EC=A0=84=EC=B2=B4=20=EC=82=AD=EC=A0=9C=20=EB=AA=A8=EB=8B=AC?= =?UTF-8?q?=20=EC=B6=94=EA=B0=80=20(#3)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../modal/daily-analysis-delete-all-modal.tsx | 33 ++++++++++++++ shared/ui/modal/strategy-delete-modal.tsx | 45 +++++++++++++++++++ 2 files changed, 78 insertions(+) create mode 100644 shared/ui/modal/daily-analysis-delete-all-modal.tsx create mode 100644 shared/ui/modal/strategy-delete-modal.tsx diff --git a/shared/ui/modal/daily-analysis-delete-all-modal.tsx b/shared/ui/modal/daily-analysis-delete-all-modal.tsx new file mode 100644 index 00000000..983203ee --- /dev/null +++ b/shared/ui/modal/daily-analysis-delete-all-modal.tsx @@ -0,0 +1,33 @@ +import { ModalAlertIcon } from '@/public/icons' +import classNames from 'classnames/bind' + +import Modal from '.' +import { Button } from '../button' +import styles from './styles.module.scss' + +const cx = classNames.bind(styles) + +interface Props { + isModalOpen: boolean + onCloseModal: () => void + onDelete: () => void + isPending: boolean +} + +const DailyAnalysisDeleteAllModal = ({ isModalOpen, onCloseModal, onDelete, isPending }: Props) => { + return ( + <Modal isOpen={isModalOpen} icon={ModalAlertIcon}> + <span className={cx('message')}>모든 일간 분석을 삭제하시겠습니까?</span> + <div className={cx('two-button')}> + <Button onClick={onCloseModal} disabled={isPending}> + 아니오 + </Button> + <Button onClick={onDelete} variant="filled" className={cx('button')} disabled={isPending}> + 예 + </Button> + </div> + </Modal> + ) +} + +export default DailyAnalysisDeleteAllModal diff --git a/shared/ui/modal/strategy-delete-modal.tsx b/shared/ui/modal/strategy-delete-modal.tsx new file mode 100644 index 00000000..d82c8428 --- /dev/null +++ b/shared/ui/modal/strategy-delete-modal.tsx @@ -0,0 +1,45 @@ +import { useDeleteMyStrategy } from '@/app/(dashboard)/my/_hooks/query/use-delete-my-strategy' +import { ModalAlertIcon } from '@/public/icons' +import classNames from 'classnames/bind' + +import Modal from '.' +import { Button } from '../button' +import styles from './styles.module.scss' + +const cx = classNames.bind(styles) + +interface Props { + isModalOpen: boolean + onCloseModal: () => void + strategyId: number +} + +const StrategyDeleteModal = ({ isModalOpen, onCloseModal, strategyId }: Props) => { + const { mutate: deleteStrategy, isPending } = useDeleteMyStrategy() + + const handleDelete = () => { + deleteStrategy(strategyId) + onCloseModal() + } + + return ( + <Modal isOpen={isModalOpen} icon={ModalAlertIcon}> + <span className={cx('message')}>해당 전략을 삭제하시겠습니까?</span> + <div className={cx('two-button')}> + <Button onClick={onCloseModal} disabled={isPending}> + 아니오 + </Button> + <Button + onClick={handleDelete} + variant="filled" + className={cx('button')} + disabled={isPending} + > + 예 + </Button> + </div> + </Modal> + ) +} + +export default StrategyDeleteModal From a36f538e13118b4161453f4ca57f91a1236a3699 Mon Sep 17 00:00:00 2001 From: James <dawn147@snu.ac.kr> Date: Sun, 8 Dec 2024 16:17:32 +0900 Subject: [PATCH 009/207] =?UTF-8?q?fix:=20=EB=B2=84=ED=8A=BC=20=EC=98=A8?= =?UTF-8?q?=ED=81=B4=EB=A6=AD=20=ED=95=A8=EC=88=98=20=ED=83=80=EC=9E=85=20?= =?UTF-8?q?=EB=B3=80=EA=B2=BD=20(#3)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- shared/ui/button/index.tsx | 2 +- shared/ui/header/_ui/header-links/index.tsx | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/shared/ui/button/index.tsx b/shared/ui/button/index.tsx index 14f40ef7..5f5073cb 100644 --- a/shared/ui/button/index.tsx +++ b/shared/ui/button/index.tsx @@ -14,7 +14,7 @@ export type ButtonVariantType = 'outline' | 'filled' interface Props extends ComponentProps<'button'> { size?: ButtonSizeType variant?: ButtonVariantType - onClick?: () => void + onClick?: (e: React.MouseEvent<HTMLButtonElement>) => void } const _Button = ({ diff --git a/shared/ui/header/_ui/header-links/index.tsx b/shared/ui/header/_ui/header-links/index.tsx index 2098605a..3c3d5782 100644 --- a/shared/ui/header/_ui/header-links/index.tsx +++ b/shared/ui/header/_ui/header-links/index.tsx @@ -20,7 +20,7 @@ const HeaderLinks = ({ isLoggedIn }: Props) => { </LinkButton> )} {isLoggedIn ? ( - <Button onClick={logout} size="small" variant="filled"> + <Button onClick={() => logout()} size="small" variant="filled"> 로그아웃 </Button> ) : ( From 316fd5a68b02e08b03e0698bbcd6e73b2b3a4da8 Mon Sep 17 00:00:00 2001 From: James <dawn147@snu.ac.kr> Date: Sun, 8 Dec 2024 16:18:01 +0900 Subject: [PATCH 010/207] =?UTF-8?q?feat:=20=EB=AA=A8=EB=8B=AC=20=EC=B6=94?= =?UTF-8?q?=EA=B0=80=EC=97=90=20=EB=94=B0=EB=A5=B8=20=EC=BD=94=EB=93=9C=20?= =?UTF-8?q?=EB=B3=80=EA=B2=BD=20=EB=B0=8F=20api=20=EC=97=B0=EA=B2=B0=20(#3?= =?UTF-8?q?)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../analysis-container/analysis-content.tsx | 29 +++++++++++++------ app/(dashboard)/_ui/strategies-item/index.tsx | 21 +++++++++++++- app/(dashboard)/my/_api/delete-my-strategy.ts | 17 +++++++++++ .../my/_hooks/query/use-add-strategy.ts | 2 +- .../my/_hooks/query/use-delete-my-strategy.ts | 18 ++++++++++++ 5 files changed, 76 insertions(+), 11 deletions(-) create mode 100644 app/(dashboard)/my/_api/delete-my-strategy.ts create mode 100644 app/(dashboard)/my/_hooks/query/use-delete-my-strategy.ts diff --git a/app/(dashboard)/_ui/analysis-container/analysis-content.tsx b/app/(dashboard)/_ui/analysis-container/analysis-content.tsx index ee9850ba..ba04595f 100644 --- a/app/(dashboard)/_ui/analysis-container/analysis-content.tsx +++ b/app/(dashboard)/_ui/analysis-container/analysis-content.tsx @@ -6,6 +6,7 @@ import { ANALYSIS_PAGE_COUNT } from '@/shared/constants/count-per-page' import useModal from '@/shared/hooks/custom/use-modal' import { Button } from '@/shared/ui/button' import AnalysisUploadModal from '@/shared/ui/modal/analysis-upload-modal' +import DailyAnalysisDeleteAllModal from '@/shared/ui/modal/daily-analysis-delete-all-modal' import Pagination from '@/shared/ui/pagination' import VerticalTable from '@/shared/ui/table/vertical' @@ -53,9 +54,14 @@ const AnalysisContent = ({ isEditable = false, }: Props) => { const { mutate } = useGetAnalysisDownload() - const [uploadType, setUploadType] = useState<'excel' | 'direct' | null>(null) + const { isModalOpen, openModal, closeModal } = useModal() + const { + isModalOpen: isDeleteModalOpen, + openModal: openDeleteModal, + closeModal: closeDeleteModal, + } = useModal() const { data: myAnalysisData } = useGetMyDailyAnalysis( strategyId, @@ -99,13 +105,11 @@ const AnalysisContent = ({ } const handleDeleteAll = async () => { - if (window.confirm('모든 데이터를 삭제하시겠습니까?')) { - try { - await deleteAllAnalysis() - } catch (error) { - console.error('Delete error:', error) - alert('데이터 삭제 중 오류가 발생했습니다.') - } + try { + await deleteAllAnalysis() + closeDeleteModal() + } catch (error) { + console.error('Delete error:', error) } } @@ -141,7 +145,7 @@ const AnalysisContent = ({ 직접 입력 </Button> </div> - <Button size="small" variant="filled" onClick={handleDeleteAll} disabled={isLoading}> + <Button size="small" variant="filled" onClick={openDeleteModal} disabled={isLoading}> 전체 삭제 </Button> </div> @@ -177,6 +181,13 @@ const AnalysisContent = ({ message={uploadType === 'excel' ? '엑셀 업로드' : '일간분석 데이터 입력'} /> )} + + <DailyAnalysisDeleteAllModal + isModalOpen={isDeleteModalOpen} + onCloseModal={closeDeleteModal} + onDelete={handleDeleteAll} + isPending={isLoading} + /> </div> ) } diff --git a/app/(dashboard)/_ui/strategies-item/index.tsx b/app/(dashboard)/_ui/strategies-item/index.tsx index 534a1bd4..c9225ede 100644 --- a/app/(dashboard)/_ui/strategies-item/index.tsx +++ b/app/(dashboard)/_ui/strategies-item/index.tsx @@ -9,6 +9,7 @@ import { StrategiesModel } from '@/shared/types/strategy-data' import { Button } from '@/shared/ui/button' import { LinkButton } from '@/shared/ui/link-button' import SigninCheckModal from '@/shared/ui/modal/signin-check-modal' +import StrategyDeleteModal from '@/shared/ui/modal/strategy-delete-modal' import { formatNumber } from '@/shared/utils/format' import AreaChart from './area-chart' @@ -27,6 +28,11 @@ const StrategiesItem = ({ strategiesData: data, type = 'default' }: Props) => { const router = useRouter() const user = useAuthStore((state) => state.user) const { isModalOpen, openModal, closeModal } = useModal() + const { + isModalOpen: isDeleteModalOpen, + openModal: openDeleteModal, + closeModal: closeDeleteModal, + } = useModal() const handleRouter = () => { if (!user) { @@ -90,7 +96,15 @@ const StrategiesItem = ({ strategiesData: data, type = 'default' }: Props) => { > 관리 </LinkButton> - <Button size="small" variant="outline" className={cx('manage-button')}> + <Button + size="small" + variant="outline" + className={cx('manage-button')} + onClick={(e) => { + e.stopPropagation() + openDeleteModal() + }} + > 삭제 </Button> </div> @@ -98,6 +112,11 @@ const StrategiesItem = ({ strategiesData: data, type = 'default' }: Props) => { )} </button> <SigninCheckModal isModalOpen={isModalOpen} onCloseModal={closeModal} /> + <StrategyDeleteModal + isModalOpen={isDeleteModalOpen} + onCloseModal={closeDeleteModal} + strategyId={data.strategyId} + /> </> ) } diff --git a/app/(dashboard)/my/_api/delete-my-strategy.ts b/app/(dashboard)/my/_api/delete-my-strategy.ts new file mode 100644 index 00000000..eb91c643 --- /dev/null +++ b/app/(dashboard)/my/_api/delete-my-strategy.ts @@ -0,0 +1,17 @@ +import axiosInstance from '@/shared/api/axios' + +export interface DeleteStrategyResponseModel { + isSuccess: boolean + message: string + result: Record<string, never> + code: number +} + +export const deleteMyStrategy = async ( + strategyId: number +): Promise<DeleteStrategyResponseModel> => { + const { data } = await axiosInstance.delete<DeleteStrategyResponseModel>( + `/api/my-strategies/${strategyId}` + ) + return data +} diff --git a/app/(dashboard)/my/_hooks/query/use-add-strategy.ts b/app/(dashboard)/my/_hooks/query/use-add-strategy.ts index 73f69c94..6ad2b446 100644 --- a/app/(dashboard)/my/_hooks/query/use-add-strategy.ts +++ b/app/(dashboard)/my/_hooks/query/use-add-strategy.ts @@ -39,7 +39,7 @@ export const useAddStrategy = () => { mutationFn: (data) => strategyApi.registerStrategy(data).then((response) => response.data), onSuccess: () => { queryClient.invalidateQueries({ - queryKey: ['addStrategies'], + queryKey: ['myStrategies'], }) router.back() }, diff --git a/app/(dashboard)/my/_hooks/query/use-delete-my-strategy.ts b/app/(dashboard)/my/_hooks/query/use-delete-my-strategy.ts new file mode 100644 index 00000000..3fafcaef --- /dev/null +++ b/app/(dashboard)/my/_hooks/query/use-delete-my-strategy.ts @@ -0,0 +1,18 @@ +import { useMutation, useQueryClient } from '@tanstack/react-query' + +import { deleteMyStrategy } from '../../_api/delete-my-strategy' + +export const useDeleteMyStrategy = () => { + const queryClient = useQueryClient() + + const { mutate, isPending } = useMutation({ + mutationFn: (strategyId: number) => deleteMyStrategy(strategyId), + onSuccess: () => { + queryClient.invalidateQueries({ + queryKey: ['myStrategies'], + }) + }, + }) + + return { mutate, isPending } +} From b519bdd3a486e40402828a3471e32c717420229e Mon Sep 17 00:00:00 2001 From: James <dawn147@snu.ac.kr> Date: Sun, 8 Dec 2024 16:59:04 +0900 Subject: [PATCH 011/207] =?UTF-8?q?fix:=20error=20->=20err=20=EB=A1=9C=20?= =?UTF-8?q?=EC=88=98=EC=A0=95=20(#3)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../analysis-container/account-content.tsx | 4 ++-- .../analysis-container/analysis-content.tsx | 4 ++-- .../my/_hooks/query/use-analysis-mutation.ts | 8 ++++---- .../_hooks/query/use-manage-daily-analysis.ts | 18 ++++++++--------- .../my/_hooks/query/use-patch-profile.ts | 4 ++-- .../my/profile/_ui/user-info/index.tsx | 12 +++++------ .../notices/_ui/notice-detail/index.tsx | 4 ++-- app/admin/notices/post/_api/post-notice.ts | 4 ++-- shared/hooks/custom/use-auth.ts | 4 ++-- shared/hooks/query/auth-queries.ts | 16 +++++++-------- shared/stores/use-auth-store.ts | 8 ++++---- .../edit-daily-analysis-modal.ts/index.tsx | 2 +- shared/ui/table/vertical/index.tsx | 4 ++-- shared/utils/excel-utils.ts | 4 ++-- shared/utils/token-utils.ts | 20 +++++++++---------- 15 files changed, 58 insertions(+), 58 deletions(-) diff --git a/app/(dashboard)/_ui/analysis-container/account-content.tsx b/app/(dashboard)/_ui/analysis-container/account-content.tsx index 5b157a51..ebc5edab 100644 --- a/app/(dashboard)/_ui/analysis-container/account-content.tsx +++ b/app/(dashboard)/_ui/analysis-container/account-content.tsx @@ -80,8 +80,8 @@ const AccountContent = ({ strategyId, currentPage, onPageChange, isEditable = fa imageIds: selectedImages, }) setSelectedImages([]) - } catch (error) { - console.error('Failed to delete images:', error) + } catch (err) { + console.error('Failed to delete images:', err) } } diff --git a/app/(dashboard)/_ui/analysis-container/analysis-content.tsx b/app/(dashboard)/_ui/analysis-container/analysis-content.tsx index ba04595f..fd3c2cd8 100644 --- a/app/(dashboard)/_ui/analysis-container/analysis-content.tsx +++ b/app/(dashboard)/_ui/analysis-container/analysis-content.tsx @@ -108,8 +108,8 @@ const AnalysisContent = ({ try { await deleteAllAnalysis() closeDeleteModal() - } catch (error) { - console.error('Delete error:', error) + } catch (err) { + console.error('Delete error:', err) } } diff --git a/app/(dashboard)/my/_hooks/query/use-analysis-mutation.ts b/app/(dashboard)/my/_hooks/query/use-analysis-mutation.ts index fae29d08..21ff8cdb 100644 --- a/app/(dashboard)/my/_hooks/query/use-analysis-mutation.ts +++ b/app/(dashboard)/my/_hooks/query/use-analysis-mutation.ts @@ -28,8 +28,8 @@ export const useAnalysisUploadMutation = ( try { const newData = await getMyDailyAnalysis(strategyId, page, size) queryClient.setQueryData(['myDailyAnalysis', strategyId], newData) - } catch (error) { - console.error('Failed to fetch updated my daily analysis data:', error) + } catch (err) { + console.error('Failed to fetch updated my daily analysis data:', err) } }, }) @@ -44,8 +44,8 @@ export const useAnalysisUploadMutation = ( try { const newData = await getMyDailyAnalysis(strategyId, page, size) queryClient.setQueryData(['myDailyAnalysis', strategyId], newData) - } catch (error) { - console.error('Failed to fetch updated my daily analysis data:', error) + } catch (err) { + console.error('Failed to fetch updated my daily analysis data:', err) } }, }) diff --git a/app/(dashboard)/my/_hooks/query/use-manage-daily-analysis.ts b/app/(dashboard)/my/_hooks/query/use-manage-daily-analysis.ts index 41703ed1..1119cdcc 100644 --- a/app/(dashboard)/my/_hooks/query/use-manage-daily-analysis.ts +++ b/app/(dashboard)/my/_hooks/query/use-manage-daily-analysis.ts @@ -21,9 +21,9 @@ export const useMyAnalysisMutation = (strategyId: number, page: number, size: nu onSuccess: () => { queryClient.invalidateQueries({ queryKey }) }, - onError: (error: Error) => { - console.error('Edit error:', error) - throw error + onError: (err: Error) => { + console.error('Edit error:', err) + throw err }, }) @@ -37,9 +37,9 @@ export const useMyAnalysisMutation = (strategyId: number, page: number, size: nu onSuccess: () => { queryClient.invalidateQueries({ queryKey }) }, - onError: (error: Error) => { - console.error('Delete error:', error) - throw error + onError: (err: Error) => { + console.error('Delete error:', err) + throw err }, }) @@ -53,9 +53,9 @@ export const useMyAnalysisMutation = (strategyId: number, page: number, size: nu onSuccess: () => { queryClient.invalidateQueries({ queryKey }) }, - onError: (error: Error) => { - console.error('Delete all error:', error) - throw error + onError: (err: Error) => { + console.error('Delete all error:', err) + throw err }, }) diff --git a/app/(dashboard)/my/_hooks/query/use-patch-profile.ts b/app/(dashboard)/my/_hooks/query/use-patch-profile.ts index 6d122247..9b2f5aa4 100644 --- a/app/(dashboard)/my/_hooks/query/use-patch-profile.ts +++ b/app/(dashboard)/my/_hooks/query/use-patch-profile.ts @@ -22,8 +22,8 @@ const usePatchUserProfile = () => { onSuccess: () => { queryClient.invalidateQueries({ queryKey: ['userProfile'] }) }, - onError: (error) => { - console.error('Error updating user profile:', error) + onError: (err) => { + console.error('Error updating user profile:', err) }, }) } diff --git a/app/(dashboard)/my/profile/_ui/user-info/index.tsx b/app/(dashboard)/my/profile/_ui/user-info/index.tsx index 0d3aa53d..ef2526cd 100644 --- a/app/(dashboard)/my/profile/_ui/user-info/index.tsx +++ b/app/(dashboard)/my/profile/_ui/user-info/index.tsx @@ -41,8 +41,8 @@ const uploadImageToS3 = async (presignedUrl: string, file: File): Promise<void> 'Content-Type': file.type, }, }) - } catch (error) { - console.error('이미지 업로드 실패:', error) + } catch (err) { + console.error('이미지 업로드 실패:', err) throw new Error('이미지 업로드에 실패했습니다') } } @@ -207,10 +207,10 @@ const UserInfo = ({ profile, isEditable = false }: Props) => { alert('프로필이 성공적으로 업데이트되었습니다.') router.push(PATH.PROFILE) - } catch (error) { - console.error('프로필 업데이트 실패:', error) - if (axios.isAxiosError(error)) { - const errorMessage = error.response?.data?.message || '프로필 업데이트에 실패했습니다.' + } catch (err) { + console.error('프로필 업데이트 실패:', err) + if (axios.isAxiosError(err)) { + const errorMessage = err.response?.data?.message || '프로필 업데이트에 실패했습니다.' alert(errorMessage) } else { alert('프로필 업데이트에 실패했습니다. 다시 시도해주세요.') diff --git a/app/(landing)/notices/_ui/notice-detail/index.tsx b/app/(landing)/notices/_ui/notice-detail/index.tsx index 7c2949a1..65324ea2 100644 --- a/app/(landing)/notices/_ui/notice-detail/index.tsx +++ b/app/(landing)/notices/_ui/notice-detail/index.tsx @@ -30,8 +30,8 @@ const NoticeDetail = ({ noticeId }: { noticeId: number }) => { link.remove() window.URL.revokeObjectURL(url) - } catch (error) { - console.error('파일 다운로드 중 오류 발생:', error) + } catch (err) { + console.error('파일 다운로드 중 오류 발생:', err) } } diff --git a/app/admin/notices/post/_api/post-notice.ts b/app/admin/notices/post/_api/post-notice.ts index 8b32f275..c33beb8f 100644 --- a/app/admin/notices/post/_api/post-notice.ts +++ b/app/admin/notices/post/_api/post-notice.ts @@ -17,8 +17,8 @@ const postNotice = async (formData: NoticeFormModel) => { if (!res.data.isSuccess) throw new Error('Error : ' + res.data.message) alert('공지 등록이 완료되었습니다.') - } catch (error) { - console.error(error) + } catch (err) { + console.error(err) alert('공지 등록 중 오류가 발생했습니다.') } } diff --git a/shared/hooks/custom/use-auth.ts b/shared/hooks/custom/use-auth.ts index 7260b911..618e674d 100644 --- a/shared/hooks/custom/use-auth.ts +++ b/shared/hooks/custom/use-auth.ts @@ -42,8 +42,8 @@ export const useAuth = () => { timeUntilExpiry, isNearExpiry: timeUntilExpiry < AUTH_TIME.ADMIN_EXPIRY_WARNING, } - } catch (error) { - console.error('Token status check failed:', error) + } catch (err) { + console.error('Token status check failed:', err) logout() return null } diff --git a/shared/hooks/query/auth-queries.ts b/shared/hooks/query/auth-queries.ts index 7964de6c..b6d7bee6 100644 --- a/shared/hooks/query/auth-queries.ts +++ b/shared/hooks/query/auth-queries.ts @@ -39,10 +39,10 @@ export const useLoginMutation = () => { isAuthenticated: true, user, }) - } catch (error) { - console.error('Login process failed:', error) + } catch (err) { + console.error('Login process failed:', err) removeAccessToken() - throw error + throw err } }, }) @@ -102,19 +102,19 @@ export const useRefreshTokenMutation = () => { isAuthenticated: true, user, }) - } catch (error) { - console.error('Token refresh process failed:', error) + } catch (err) { + console.error('Token refresh process failed:', err) removeAccessToken() useAuthStore.getState().setAuthState({ isAuthenticated: false, user: null, }) router.replace(PATH.SIGN_IN) - throw error + throw err } }, - onError: (error) => { - console.error('Token refresh mutation failed:', error) + onError: (err) => { + console.error('Token refresh mutation failed:', err) removeAccessToken() useAuthStore.getState().setAuthState({ isAuthenticated: false, diff --git a/shared/stores/use-auth-store.ts b/shared/stores/use-auth-store.ts index e53e348e..40ab6369 100644 --- a/shared/stores/use-auth-store.ts +++ b/shared/stores/use-auth-store.ts @@ -130,8 +130,8 @@ export const useAuthStore = create<AuthStoreType>()((set) => ({ }) return } - } catch (error) { - console.error('Failed to parse session user data:', error) + } catch (err) { + console.error('Failed to parse session user data:', err) sessionStorage.removeItem(STORAGE_KEYS.USER) } } @@ -146,8 +146,8 @@ export const useAuthStore = create<AuthStoreType>()((set) => ({ isKeepLoggedIn: true, }) } - } catch (error) { - console.error('Failed to parse local user data:', error) + } catch (err) { + console.error('Failed to parse local user data:', err) localStorage.removeItem(STORAGE_KEYS.USER) } } diff --git a/shared/ui/modal/edit-daily-analysis-modal.ts/index.tsx b/shared/ui/modal/edit-daily-analysis-modal.ts/index.tsx index 342fbdac..bf0df5bd 100644 --- a/shared/ui/modal/edit-daily-analysis-modal.ts/index.tsx +++ b/shared/ui/modal/edit-daily-analysis-modal.ts/index.tsx @@ -97,7 +97,7 @@ const EditAnalysisModal = ({ }, } ) - } catch (error) { + } catch (err) { setError('데이터 수정 중 오류가 발생했습니다.') } } diff --git a/shared/ui/table/vertical/index.tsx b/shared/ui/table/vertical/index.tsx index d28e92e5..5ca83d18 100644 --- a/shared/ui/table/vertical/index.tsx +++ b/shared/ui/table/vertical/index.tsx @@ -71,8 +71,8 @@ const VerticalTable = ({ if (window.confirm('해당 데이터를 삭제하시겠습니까?')) { try { await deleteAnalysisData(dailyAnalysisId) - } catch (error) { - console.error('Delete failed:', error) + } catch (err) { + console.error('Delete failed:', err) alert('삭제 중 오류가 발생했습니다.') } } diff --git a/shared/utils/excel-utils.ts b/shared/utils/excel-utils.ts index 3d652816..f26d16f5 100644 --- a/shared/utils/excel-utils.ts +++ b/shared/utils/excel-utils.ts @@ -39,8 +39,8 @@ export const processExcelFile = (file: File): Promise<RowDataModel[]> => { })) resolve(rows) - } catch (error) { - reject(error) + } catch (err) { + reject(err) } } diff --git a/shared/utils/token-utils.ts b/shared/utils/token-utils.ts index 2f795560..b23a3b71 100644 --- a/shared/utils/token-utils.ts +++ b/shared/utils/token-utils.ts @@ -38,8 +38,8 @@ export const refreshToken = async (): Promise<string | null> => { setAccessToken(newAccessToken, currentUser) pendingRefreshRequests.forEach((callback) => callback(newAccessToken)) return newAccessToken - } catch (error) { - console.error('Token refresh failed:', error) + } catch (err) { + console.error('Token refresh failed:', err) return null } finally { isRefreshInProgress = false @@ -54,8 +54,8 @@ export const isTokenExpired = (token: string): boolean => { const expiryTime = decoded.exp * 1000 return expiryTime - currentTime <= AUTH_TIME.SAFETY_MARGIN - } catch (error) { - console.error('Token expiry check failed:', error) + } catch (err) { + console.error('Token expiry check failed:', err) return true } } @@ -67,8 +67,8 @@ export const isNearExpiry = (token: string): boolean => { const expiryTime = decoded.exp * 1000 return expiryTime - currentTime < AUTH_TIME.ADMIN_EXPIRY_WARNING - } catch (error) { - console.error('Near expiry check failed:', error) + } catch (err) { + console.error('Near expiry check failed:', err) return true } } @@ -79,8 +79,8 @@ export const getEmailFromToken = (token: string | null): string | null => { try { const decoded = jwtDecode<TokenPayloadModel>(token) return decoded.email || null - } catch (error) { - console.error('Email extraction failed:', error) + } catch (err) { + console.error('Email extraction failed:', err) return null } } @@ -92,8 +92,8 @@ export const getTimeUntilExpiry = (token: string): number => { const expiryTime = decoded.exp * 1000 return Math.max(0, expiryTime - currentTime) - } catch (error) { - console.error('Time until expiry calculation failed:', error) + } catch (err) { + console.error('Time until expiry calculation failed:', err) return 0 } } From f5ef3f888135f0ce6013e65b8d850ce56afc172f Mon Sep 17 00:00:00 2001 From: nanafromjeju <nanafromjeju@gmail.com> Date: Sun, 8 Dec 2024 21:14:31 +0900 Subject: [PATCH 012/207] =?UTF-8?q?refactor:=20Input=20=EC=BB=B4=ED=8F=AC?= =?UTF-8?q?=EB=84=8C=ED=8A=B8=20rafce=20=ED=98=95=EC=8B=9D=20=EB=B3=80?= =?UTF-8?q?=EA=B2=BD=20(#5)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- app/(dashboard)/my/profile/_ui/user-info/index.tsx | 2 +- .../[questionId]/_ui/question-container/index.tsx | 2 +- app/(dashboard)/my/questions/page.tsx | 2 +- app/(dashboard)/my/strategies/add/page.tsx | 2 +- .../[strategyId]/_ui/review-container/add-review.tsx | 2 +- app/(dashboard)/strategies/_ui/search-bar/index.tsx | 2 +- app/(dashboard)/traders/page.tsx | 2 +- app/(landing)/signin/page.tsx | 2 +- app/(landing)/signup/information/page.tsx | 2 +- .../stock/stock-manage/_ui/stock-post-button/index.tsx | 2 +- .../trade/trade-manage/_ui/trade-post-button/index.tsx | 2 +- app/admin/notices/post/page.tsx | 4 ++-- app/admin/notices/tabledata.tsx | 10 +++++----- app/admin/strategies/page.tsx | 2 +- app/admin/users/page.tsx | 2 +- shared/types/svg.d.ts | 2 +- shared/ui/input/index.tsx | 4 +++- shared/ui/input/input.stories.tsx | 2 +- shared/ui/modal/add-question-modal.tsx | 4 ++-- .../analysis-upload-modal/form/direct-input-form.tsx | 2 +- shared/ui/modal/edit-daily-analysis-modal.ts/index.tsx | 2 +- shared/ui/modal/find-email-modal/index.tsx | 2 +- shared/ui/modal/find-password-modal/index.tsx | 2 +- shared/ui/search-input/index.tsx | 4 +++- shared/ui/search-input/search-input.stories.tsx | 2 +- shared/ui/textarea/index.tsx | 4 +++- shared/ui/textarea/textarea.stories.tsx | 2 +- 27 files changed, 39 insertions(+), 33 deletions(-) diff --git a/app/(dashboard)/my/profile/_ui/user-info/index.tsx b/app/(dashboard)/my/profile/_ui/user-info/index.tsx index ef2526cd..8f95dc22 100644 --- a/app/(dashboard)/my/profile/_ui/user-info/index.tsx +++ b/app/(dashboard)/my/profile/_ui/user-info/index.tsx @@ -14,7 +14,7 @@ import { checkNicknameDuplicate, checkPhoneDuplicate } from '@/shared/api/check- import { PATH } from '@/shared/constants/path' import Avatar from '@/shared/ui/avatar' import { Button } from '@/shared/ui/button' -import { Input } from '@/shared/ui/input' +import Input from '@/shared/ui/input' import { LinkButton } from '@/shared/ui/link-button' import { ProfileModel } from '../../../_api/get-profile' diff --git a/app/(dashboard)/my/questions/[questionId]/_ui/question-container/index.tsx b/app/(dashboard)/my/questions/[questionId]/_ui/question-container/index.tsx index 9150e3f7..44416d4a 100644 --- a/app/(dashboard)/my/questions/[questionId]/_ui/question-container/index.tsx +++ b/app/(dashboard)/my/questions/[questionId]/_ui/question-container/index.tsx @@ -12,7 +12,7 @@ import { useAuthStore } from '@/shared/stores/use-auth-store' import { Button } from '@/shared/ui/button' import { ErrorMessage } from '@/shared/ui/error-message' import AddQuestionModal from '@/shared/ui/modal/add-question-modal' -import { Textarea } from '@/shared/ui/textarea' +import Textarea from '@/shared/ui/textarea' import useDeleteAnswer from '../../../_hooks/query/use-delete-answer' import useDeleteQuestion from '../../../_hooks/query/use-delete-question' diff --git a/app/(dashboard)/my/questions/page.tsx b/app/(dashboard)/my/questions/page.tsx index 4c219c08..7cf2c8fa 100644 --- a/app/(dashboard)/my/questions/page.tsx +++ b/app/(dashboard)/my/questions/page.tsx @@ -6,7 +6,7 @@ import classNames from 'classnames/bind' import { QuestionSearchConditionType } from '@/shared/types/questions' import { DropdownValueType } from '@/shared/ui/dropdown/types' -import { SearchInput } from '@/shared/ui/search-input' +import SearchInput from '@/shared/ui/search-input' import Select from '@/shared/ui/select' import Title from '@/shared/ui/title' diff --git a/app/(dashboard)/my/strategies/add/page.tsx b/app/(dashboard)/my/strategies/add/page.tsx index c7999780..de4df278 100644 --- a/app/(dashboard)/my/strategies/add/page.tsx +++ b/app/(dashboard)/my/strategies/add/page.tsx @@ -10,7 +10,7 @@ import classNames from 'classnames/bind' import { Button } from '@/shared/ui/button' import BackHeader from '@/shared/ui/header/back-header' -import { Input } from '@/shared/ui/input' +import Input from '@/shared/ui/input' import Select from '@/shared/ui/select' import Title from '@/shared/ui/title' diff --git a/app/(dashboard)/strategies/[strategyId]/_ui/review-container/add-review.tsx b/app/(dashboard)/strategies/[strategyId]/_ui/review-container/add-review.tsx index 2aaf024f..bf6127a8 100644 --- a/app/(dashboard)/strategies/[strategyId]/_ui/review-container/add-review.tsx +++ b/app/(dashboard)/strategies/[strategyId]/_ui/review-container/add-review.tsx @@ -7,7 +7,7 @@ import classNames from 'classnames/bind' import useModal from '@/shared/hooks/custom/use-modal' import { Button } from '@/shared/ui/button' import { ErrorMessage } from '@/shared/ui/error-message' -import { Textarea } from '@/shared/ui/textarea' +import Textarea from '@/shared/ui/textarea' import usePatchReview from '../../_hooks/query/use-patch-review' import usePostReview from '../../_hooks/query/use-post-review' diff --git a/app/(dashboard)/strategies/_ui/search-bar/index.tsx b/app/(dashboard)/strategies/_ui/search-bar/index.tsx index bdb0a0ce..77ddba1c 100644 --- a/app/(dashboard)/strategies/_ui/search-bar/index.tsx +++ b/app/(dashboard)/strategies/_ui/search-bar/index.tsx @@ -5,7 +5,7 @@ import { useRef, useState } from 'react' import classNames from 'classnames/bind' import { Button } from '@/shared/ui/button' -import { SearchInput } from '@/shared/ui/search-input' +import SearchInput from '@/shared/ui/search-input' import useGetStrategiesSearch from '../../_hooks/query/use-get-strategies-search' import usePostStrategies from '../../_hooks/query/use-post-strategies' diff --git a/app/(dashboard)/traders/page.tsx b/app/(dashboard)/traders/page.tsx index 1d5cbef4..1e353467 100644 --- a/app/(dashboard)/traders/page.tsx +++ b/app/(dashboard)/traders/page.tsx @@ -10,7 +10,7 @@ import { PATH } from '@/shared/constants/path' import { usePagination } from '@/shared/hooks/custom/use-pagination' import { DropdownValueType } from '@/shared/ui/dropdown/types' import Pagination from '@/shared/ui/pagination' -import { SearchInput } from '@/shared/ui/search-input' +import SearchInput from '@/shared/ui/search-input' import Select from '@/shared/ui/select' import Title from '@/shared/ui/title' import TradersListCard from '@/shared/ui/traders-list-card' diff --git a/app/(landing)/signin/page.tsx b/app/(landing)/signin/page.tsx index 8ebabca1..df27a7e5 100644 --- a/app/(landing)/signin/page.tsx +++ b/app/(landing)/signin/page.tsx @@ -16,7 +16,7 @@ import { useAuthStore } from '@/shared/stores/use-auth-store' import type { LoginFormDataModel } from '@/shared/types/auth' import { Button } from '@/shared/ui/button' import Checkbox from '@/shared/ui/check-box' -import { Input } from '@/shared/ui/input' +import Input from '@/shared/ui/input' import FindEmailModal from '@/shared/ui/modal/find-email-modal' import FindPasswordModal from '@/shared/ui/modal/find-password-modal' import { validate } from '@/shared/utils/validation' diff --git a/app/(landing)/signup/information/page.tsx b/app/(landing)/signup/information/page.tsx index fca24b32..94d37f61 100644 --- a/app/(landing)/signup/information/page.tsx +++ b/app/(landing)/signup/information/page.tsx @@ -9,7 +9,7 @@ import { PATH } from '@/shared/constants/path' import { Button } from '@/shared/ui/button' import Checkbox from '@/shared/ui/check-box' import { ErrorMessage } from '@/shared/ui/error-message' -import { Input } from '@/shared/ui/input' +import Input from '@/shared/ui/input' import { LinkButton } from '@/shared/ui/link-button' import Modal from '@/shared/ui/modal' import Select from '@/shared/ui/select' diff --git a/app/admin/category/_ui/stock/stock-manage/_ui/stock-post-button/index.tsx b/app/admin/category/_ui/stock/stock-manage/_ui/stock-post-button/index.tsx index 1bb449aa..81fb854e 100644 --- a/app/admin/category/_ui/stock/stock-manage/_ui/stock-post-button/index.tsx +++ b/app/admin/category/_ui/stock/stock-manage/_ui/stock-post-button/index.tsx @@ -9,7 +9,7 @@ import classNames from 'classnames/bind' import useModal from '@/shared/hooks/custom/use-modal' import { Button } from '@/shared/ui/button' -import { Input } from '@/shared/ui/input' +import Input from '@/shared/ui/input' import Modal from '@/shared/ui/modal' import useStrategyIconPost from '../../../../shared/_hooks/use-category-icon-post' diff --git a/app/admin/category/_ui/trade/trade-manage/_ui/trade-post-button/index.tsx b/app/admin/category/_ui/trade/trade-manage/_ui/trade-post-button/index.tsx index 69808927..06d04600 100644 --- a/app/admin/category/_ui/trade/trade-manage/_ui/trade-post-button/index.tsx +++ b/app/admin/category/_ui/trade/trade-manage/_ui/trade-post-button/index.tsx @@ -9,7 +9,7 @@ import classNames from 'classnames/bind' import useModal from '@/shared/hooks/custom/use-modal' import { Button } from '@/shared/ui/button' -import { Input } from '@/shared/ui/input' +import Input from '@/shared/ui/input' import Modal from '@/shared/ui/modal' import useStrategyIconPost from '../../../../shared/_hooks/use-category-icon-post' diff --git a/app/admin/notices/post/page.tsx b/app/admin/notices/post/page.tsx index 25180303..66f03dc3 100644 --- a/app/admin/notices/post/page.tsx +++ b/app/admin/notices/post/page.tsx @@ -4,8 +4,8 @@ import classNames from 'classnames/bind' import { Button } from '@/shared/ui/button' import BackHeader from '@/shared/ui/header/back-header' -import { Input } from '@/shared/ui/input' -import { Textarea } from '@/shared/ui/textarea' +import Input from '@/shared/ui/input' +import Textarea from '@/shared/ui/textarea' import Title from '@/shared/ui/title' import FileInput from '../../_ui/file-input' diff --git a/app/admin/notices/tabledata.tsx b/app/admin/notices/tabledata.tsx index c474022d..4338d250 100644 --- a/app/admin/notices/tabledata.tsx +++ b/app/admin/notices/tabledata.tsx @@ -9,11 +9,11 @@ export const RES = { id: 2, nickname: '관리자', }, - title: '서비스 점검 안내', //공지사항 제목 - content: '점검예정.', //공지사항 내용 - createdAt: '2024-01-01 12:00:00', // 등록일 - publishedAt: '2024-11-15T09:00:00', //공개일 - createdBy: '관리자', // 등록자 + title: '서비스 점검 안내', + content: '점검예정.', + createdAt: '2024-01-01 12:00:00', + publishedAt: '2024-11-15T09:00:00', + createdBy: '관리자', }, { noticeId: 2, diff --git a/app/admin/strategies/page.tsx b/app/admin/strategies/page.tsx index 7bbfa426..f7fe9963 100644 --- a/app/admin/strategies/page.tsx +++ b/app/admin/strategies/page.tsx @@ -3,7 +3,7 @@ import classNames from 'classnames/bind' import Pagination from '@/shared/ui/pagination' -import { SearchInput } from '@/shared/ui/search-input' +import SearchInput from '@/shared/ui/search-input' import VerticalTable from '@/shared/ui/table/vertical' import Tabs from '@/shared/ui/tabs' import Title from '@/shared/ui/title' diff --git a/app/admin/users/page.tsx b/app/admin/users/page.tsx index bd0a2b67..a59d9b84 100644 --- a/app/admin/users/page.tsx +++ b/app/admin/users/page.tsx @@ -3,7 +3,7 @@ import classNames from 'classnames/bind' import Pagination from '@/shared/ui/pagination' -import { SearchInput } from '@/shared/ui/search-input' +import SearchInput from '@/shared/ui/search-input' import Select from '@/shared/ui/select' import VerticalTable from '@/shared/ui/table/vertical' import Tabs from '@/shared/ui/tabs' diff --git a/shared/types/svg.d.ts b/shared/types/svg.d.ts index 50267039..2e323377 100644 --- a/shared/types/svg.d.ts +++ b/shared/types/svg.d.ts @@ -1,4 +1,4 @@ -declare module '@/public/icons/*.svg' { +declare module '*.svg' { const content: React.FunctionComponent<React.SVGAttributes<SVGElement>> export default content } diff --git a/shared/ui/input/index.tsx b/shared/ui/input/index.tsx index 6c65a096..57a28dca 100644 --- a/shared/ui/input/index.tsx +++ b/shared/ui/input/index.tsx @@ -17,7 +17,7 @@ interface Props extends ComponentPropsWithoutRef<'input'> { isWhiteDisabled?: boolean } -export const Input = forwardRef<HTMLInputElement, Props>( +const Input = forwardRef<HTMLInputElement, Props>( ( { inputSize = 'medium', @@ -51,3 +51,5 @@ export const Input = forwardRef<HTMLInputElement, Props>( ) Input.displayName = 'Input' + +export default Input diff --git a/shared/ui/input/input.stories.tsx b/shared/ui/input/input.stories.tsx index ce01968d..55478435 100644 --- a/shared/ui/input/input.stories.tsx +++ b/shared/ui/input/input.stories.tsx @@ -1,6 +1,6 @@ import { Meta, StoryObj } from '@storybook/react' -import { Input } from './index' +import Input from './index' const meta: Meta<typeof Input> = { title: 'Components/Input', diff --git a/shared/ui/modal/add-question-modal.tsx b/shared/ui/modal/add-question-modal.tsx index 2b0ad897..b210685c 100644 --- a/shared/ui/modal/add-question-modal.tsx +++ b/shared/ui/modal/add-question-modal.tsx @@ -9,8 +9,8 @@ import classNames from 'classnames/bind' import Modal from '.' import { Button } from '../button' import { ErrorMessage } from '../error-message' -import { Input } from '../input' -import { Textarea } from '../textarea' +import Input from '../input' +import Textarea from '../textarea' import styles from './styles.module.scss' const cx = classNames.bind(styles) diff --git a/shared/ui/modal/analysis-upload-modal/form/direct-input-form.tsx b/shared/ui/modal/analysis-upload-modal/form/direct-input-form.tsx index 1c774192..9420bbab 100644 --- a/shared/ui/modal/analysis-upload-modal/form/direct-input-form.tsx +++ b/shared/ui/modal/analysis-upload-modal/form/direct-input-form.tsx @@ -6,7 +6,7 @@ import { useAnalysisUploadMutation } from '@/app/(dashboard)/my/_hooks/query/use import classNames from 'classnames/bind' import { Button } from '@/shared/ui/button' -import { Input } from '@/shared/ui/input' +import Input from '@/shared/ui/input' import styles from './styles.module.scss' diff --git a/shared/ui/modal/edit-daily-analysis-modal.ts/index.tsx b/shared/ui/modal/edit-daily-analysis-modal.ts/index.tsx index bf0df5bd..2688a104 100644 --- a/shared/ui/modal/edit-daily-analysis-modal.ts/index.tsx +++ b/shared/ui/modal/edit-daily-analysis-modal.ts/index.tsx @@ -7,7 +7,7 @@ import { RegisterIcon } from '@/public/icons' import classNames from 'classnames/bind' import { Button } from '@/shared/ui/button' -import { Input } from '@/shared/ui/input' +import Input from '@/shared/ui/input' import Modal from '@/shared/ui/modal' import styles from './styles.module.scss' diff --git a/shared/ui/modal/find-email-modal/index.tsx b/shared/ui/modal/find-email-modal/index.tsx index 9a3ab0d1..3be9d17c 100644 --- a/shared/ui/modal/find-email-modal/index.tsx +++ b/shared/ui/modal/find-email-modal/index.tsx @@ -8,7 +8,7 @@ import classNames from 'classnames/bind' import { useFindCredentials } from '@/shared/hooks/query/use-find-credentials' import { Button } from '@/shared/ui/button' -import { Input } from '@/shared/ui/input' +import Input from '@/shared/ui/input' import Modal from '@/shared/ui/modal' import styles from './styles.module.scss' diff --git a/shared/ui/modal/find-password-modal/index.tsx b/shared/ui/modal/find-password-modal/index.tsx index 54011583..b5a5a16a 100644 --- a/shared/ui/modal/find-password-modal/index.tsx +++ b/shared/ui/modal/find-password-modal/index.tsx @@ -8,7 +8,7 @@ import classNames from 'classnames/bind' import { useResetPassword } from '@/shared/hooks/custom/use-reset-password' import { Button } from '@/shared/ui/button' -import { Input } from '@/shared/ui/input' +import Input from '@/shared/ui/input' import Modal from '@/shared/ui/modal' import styles from './styles.module.scss' diff --git a/shared/ui/search-input/index.tsx b/shared/ui/search-input/index.tsx index 3ad2a59c..db2c3c41 100644 --- a/shared/ui/search-input/index.tsx +++ b/shared/ui/search-input/index.tsx @@ -14,7 +14,7 @@ interface Props extends ComponentPropsWithoutRef<'input'> { onSearchIconClick?: () => void } -export const SearchInput = forwardRef<HTMLInputElement, Props>( +const SearchInput = forwardRef<HTMLInputElement, Props>( ({ placeholder = '', onSearchIconClick, value, onChange, ...props }: Props, ref) => { return ( <div className={cx('search-input-container')}> @@ -33,3 +33,5 @@ export const SearchInput = forwardRef<HTMLInputElement, Props>( ) SearchInput.displayName = 'SearchInput' + +export default SearchInput diff --git a/shared/ui/search-input/search-input.stories.tsx b/shared/ui/search-input/search-input.stories.tsx index 1d6e163c..4298a6af 100644 --- a/shared/ui/search-input/search-input.stories.tsx +++ b/shared/ui/search-input/search-input.stories.tsx @@ -1,6 +1,6 @@ import { Meta, StoryObj } from '@storybook/react' -import { SearchInput } from './index' +import SearchInput from './index' const meta: Meta<typeof SearchInput> = { title: 'Components/SearchInput', diff --git a/shared/ui/textarea/index.tsx b/shared/ui/textarea/index.tsx index 65b00998..2468c41e 100644 --- a/shared/ui/textarea/index.tsx +++ b/shared/ui/textarea/index.tsx @@ -12,7 +12,7 @@ interface Props extends ComponentProps<'textarea'> { rows?: number } -export const Textarea = forwardRef<HTMLTextAreaElement, Props>( +const Textarea = forwardRef<HTMLTextAreaElement, Props>( ({ rows = 5, className, value, onChange, ...props }, ref) => { return ( <textarea @@ -28,3 +28,5 @@ export const Textarea = forwardRef<HTMLTextAreaElement, Props>( ) Textarea.displayName = 'Textarea' + +export default Textarea diff --git a/shared/ui/textarea/textarea.stories.tsx b/shared/ui/textarea/textarea.stories.tsx index b80f2a6d..565946d9 100644 --- a/shared/ui/textarea/textarea.stories.tsx +++ b/shared/ui/textarea/textarea.stories.tsx @@ -1,6 +1,6 @@ import { Meta, StoryObj } from '@storybook/react' -import { Textarea } from './index' +import Textarea from './index' const meta: Meta<typeof Textarea> = { title: 'Components/Textarea', From 07b411477a5f870a752d9dd6f626ec31b427ad4f Mon Sep 17 00:00:00 2001 From: deun <devdeun@gmail.com> Date: Sun, 8 Dec 2024 22:53:57 +0900 Subject: [PATCH 013/207] =?UTF-8?q?fix:=20=EA=B3=B5=EC=A7=80=EC=82=AC?= =?UTF-8?q?=ED=95=AD=20=EC=83=81=EC=84=B8=20=EC=97=94=EB=93=9C=ED=8F=AC?= =?UTF-8?q?=EC=9D=B8=ED=8A=B8=20=EC=88=98=EC=A0=95=20(#8)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- app/(landing)/notices/_api/get-notice-detail.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/app/(landing)/notices/_api/get-notice-detail.ts b/app/(landing)/notices/_api/get-notice-detail.ts index e4b677b4..81fa35c9 100644 --- a/app/(landing)/notices/_api/get-notice-detail.ts +++ b/app/(landing)/notices/_api/get-notice-detail.ts @@ -18,7 +18,7 @@ interface NoticeResponseModel { export const getNoticeDetail = async (noticeId: number): Promise<NoticeResponseModel['result']> => { try { - const response = await axiosInstance.get<NoticeResponseModel>(`/api/notice/${noticeId}`) + const response = await axiosInstance.get<NoticeResponseModel>(`/api/notices/${noticeId}`) if (response.data.isSuccess) { return response.data.result From 436123e97cb4719ebecc965ddeefad13da428dd7 Mon Sep 17 00:00:00 2001 From: deun <devdeun@gmail.com> Date: Sun, 8 Dec 2024 23:25:05 +0900 Subject: [PATCH 014/207] =?UTF-8?q?feat:=20=EA=B3=B5=EC=A7=80=EC=82=AC?= =?UTF-8?q?=ED=95=AD=20=EC=83=81=EC=84=B8=20=ED=8C=8C=EC=9D=BC=20=EB=8B=A4?= =?UTF-8?q?=EC=9A=B4=EB=A1=9C=EB=93=9C=20=ED=95=A8=EC=88=98=20=EB=B6=84?= =?UTF-8?q?=EB=A6=AC=20=EB=B0=8F=20=EC=88=98=EC=A0=95=20(#8)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- app/(landing)/notices/_api/get-notice-file.ts | 35 +++++++++++++++++++ .../notices/_ui/notice-detail/index.tsx | 32 +++++------------ 2 files changed, 43 insertions(+), 24 deletions(-) create mode 100644 app/(landing)/notices/_api/get-notice-file.ts diff --git a/app/(landing)/notices/_api/get-notice-file.ts b/app/(landing)/notices/_api/get-notice-file.ts new file mode 100644 index 00000000..b44b574b --- /dev/null +++ b/app/(landing)/notices/_api/get-notice-file.ts @@ -0,0 +1,35 @@ +'use client' + +import axios from 'axios' + +export const downloadNoticeFile = async (noticeId: number, noticeFileId: number) => { + const response = await axios.get(`/api/notice/${noticeId}/files/${noticeFileId}`, { + responseType: 'blob', + }) + return response.data +} + +export const handleNoticeFileDownload = async ( + fileName: string, + noticeId: number, + noticeFileId: number +) => { + try { + const blobData = await downloadNoticeFile(noticeId, noticeFileId) + + const blob = new Blob([blobData]) + const url = window.URL.createObjectURL(blob) + + const link = document.createElement('a') + link.href = url + link.download = fileName + document.body.appendChild(link) + link.click() + + link.remove() + window.URL.revokeObjectURL(url) + } catch (err) { + console.error('파일 다운로드 중 오류 발생:', err) + throw new Error('파일 다운로드에 실패했습니다.') + } +} diff --git a/app/(landing)/notices/_ui/notice-detail/index.tsx b/app/(landing)/notices/_ui/notice-detail/index.tsx index 65324ea2..b8644ca2 100644 --- a/app/(landing)/notices/_ui/notice-detail/index.tsx +++ b/app/(landing)/notices/_ui/notice-detail/index.tsx @@ -3,36 +3,17 @@ import { DownloadIcon } from '@/public/icons' import classNames from 'classnames/bind' -import axios from '@/shared/api/axios' - +import { handleNoticeFileDownload } from '../../_api/get-notice-file' import useNoticeDetail from '../../_hooks/use-notice-detail' import styles from './styles.module.scss' const cx = classNames.bind(styles) const NoticeDetail = ({ noticeId }: { noticeId: number }) => { - const { data: notice, isLoading } = useNoticeDetail(noticeId) - - const handleSave = async (fileName: string, noticeFileId: number) => { - try { - const response = await axios.get(`/api/files/download/${noticeFileId}`, { - responseType: 'blob', - }) - - const blob = new Blob([response.data]) - const url = window.URL.createObjectURL(blob) + const { data: notice } = useNoticeDetail(noticeId) - const link = document.createElement('a') - link.href = url - link.download = fileName - document.body.appendChild(link) - link.click() - - link.remove() - window.URL.revokeObjectURL(url) - } catch (err) { - console.error('파일 다운로드 중 오류 발생:', err) - } + const handleSave = (fileName: string, noticeFileId: number) => { + handleNoticeFileDownload(fileName, noticeId, noticeFileId) } return ( @@ -58,7 +39,10 @@ const NoticeDetail = ({ noticeId }: { noticeId: number }) => { > {file.fileName} </div> - <DownloadIcon className={cx('file-download')} onClick={() => handleSave} /> + <DownloadIcon + className={cx('file-download')} + onClick={() => handleSave(file.fileName, file.noticeFileId)} + /> </div> ))} </div> From 2676e452d5c6bd77bff0a957f96e826e9b13c549 Mon Sep 17 00:00:00 2001 From: kimpra <dustmqaksdltkfrlfdldi@gmail.com> Date: Mon, 9 Dec 2024 03:10:22 +0900 Subject: [PATCH 015/207] =?UTF-8?q?fix:=20getNotices=EC=97=90=EC=84=9C=20?= =?UTF-8?q?=EB=B6=88=ED=95=84=EC=9A=94=ED=95=9C=20=EB=B6=80=EB=B6=84=20?= =?UTF-8?q?=EC=A0=9C=EA=B1=B0=20=20(#14)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- app/(landing)/notices/_api/get-notice.ts | 18 +++++++++--------- 1 file changed, 9 insertions(+), 9 deletions(-) diff --git a/app/(landing)/notices/_api/get-notice.ts b/app/(landing)/notices/_api/get-notice.ts index f8b3ce99..69cc0dbd 100644 --- a/app/(landing)/notices/_api/get-notice.ts +++ b/app/(landing)/notices/_api/get-notice.ts @@ -1,4 +1,5 @@ import axiosInstance from '@/shared/api/axios' +import { APIResponseBaseModel } from '@/shared/types/response' export interface NoticeUserModel { userId: number @@ -13,9 +14,7 @@ export interface NoticeModel { createdAt: string } -interface NoticesResponseModel { - isSuccess: boolean - message: string +interface NoticesResponseModel extends APIResponseBaseModel<boolean> { result: { content: NoticeModel[] page: number @@ -32,13 +31,14 @@ interface Props { size?: number } -const getNotices = async ({ page = 1, size = 9 }: Props = {}): Promise< - NoticesResponseModel['result'] -> => { +const getNotices = async ({ page = 1, size = 9 }: Props = {}) => { try { - const response = await axiosInstance.get<NoticesResponseModel>( - `/api/notices?page=${page}&size=${size}` - ) + const response = await axiosInstance<NoticesResponseModel>(`/api/notices`, { + params: { + page, + size, + }, + }) return response.data.result } catch (err) { console.error(err) From 137319e198ac8cae9e9d0eb28f96ce22a8be2b88 Mon Sep 17 00:00:00 2001 From: ssumanlife <ksm9805@naver.com> Date: Mon, 9 Dec 2024 10:47:24 +0900 Subject: [PATCH 016/207] =?UTF-8?q?bug:=20=EC=A0=84=EB=9E=B5=EB=9E=AD?= =?UTF-8?q?=ED=82=B9=20=EC=BF=BC=EB=A6=AC=ED=82=A4=20=EC=B6=94=EA=B0=80=20?= =?UTF-8?q?(#15)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- app/(dashboard)/strategies/_hooks/query/use-post-strategies.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/app/(dashboard)/strategies/_hooks/query/use-post-strategies.ts b/app/(dashboard)/strategies/_hooks/query/use-post-strategies.ts index c9297832..203fddfb 100644 --- a/app/(dashboard)/strategies/_hooks/query/use-post-strategies.ts +++ b/app/(dashboard)/strategies/_hooks/query/use-post-strategies.ts @@ -13,7 +13,7 @@ const usePostStrategies = ({ searchTerms: SearchTermsModel }) => { return useQuery({ - queryKey: ['strategies'], + queryKey: ['strategies', page, size], queryFn: () => postStrategies(page, size, searchTerms), }) } From e7777d9ce17c2bdb8b2b74f2cd74eaa43438b777 Mon Sep 17 00:00:00 2001 From: James <dawn147@snu.ac.kr> Date: Mon, 9 Dec 2024 11:08:56 +0900 Subject: [PATCH 017/207] =?UTF-8?q?feat:=20=EB=A1=9C=EA=B7=B8=EC=9D=B8=20?= =?UTF-8?q?=EC=B2=B4=ED=81=AC=20=EB=AA=A8=EB=8B=AC=EC=9D=84=20authprovider?= =?UTF-8?q?=EC=99=80=20=EC=97=B0=EA=B3=84=ED=95=98=EC=97=AC=20=EC=82=AC?= =?UTF-8?q?=EC=9A=A9=20(#7)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- app/(dashboard)/_ui/strategies-item/index.tsx | 11 +--------- shared/providers/auth-provider.tsx | 20 ++++++++++++++----- shared/ui/modal/signin-check-modal.tsx | 19 ++++++------------ 3 files changed, 22 insertions(+), 28 deletions(-) diff --git a/app/(dashboard)/_ui/strategies-item/index.tsx b/app/(dashboard)/_ui/strategies-item/index.tsx index c9225ede..b78860b9 100644 --- a/app/(dashboard)/_ui/strategies-item/index.tsx +++ b/app/(dashboard)/_ui/strategies-item/index.tsx @@ -4,11 +4,9 @@ import classNames from 'classnames/bind' import { PATH } from '@/shared/constants/path' import useModal from '@/shared/hooks/custom/use-modal' -import { useAuthStore } from '@/shared/stores/use-auth-store' import { StrategiesModel } from '@/shared/types/strategy-data' import { Button } from '@/shared/ui/button' import { LinkButton } from '@/shared/ui/link-button' -import SigninCheckModal from '@/shared/ui/modal/signin-check-modal' import StrategyDeleteModal from '@/shared/ui/modal/strategy-delete-modal' import { formatNumber } from '@/shared/utils/format' @@ -26,8 +24,6 @@ interface Props { const StrategiesItem = ({ strategiesData: data, type = 'default' }: Props) => { const router = useRouter() - const user = useAuthStore((state) => state.user) - const { isModalOpen, openModal, closeModal } = useModal() const { isModalOpen: isDeleteModalOpen, openModal: openDeleteModal, @@ -35,11 +31,7 @@ const StrategiesItem = ({ strategiesData: data, type = 'default' }: Props) => { } = useModal() const handleRouter = () => { - if (!user) { - openModal() - } else { - router.push(`${PATH.STRATEGIES}/${data.strategyId}`) - } + router.push(`${PATH.STRATEGIES}/${data.strategyId}`) } return ( @@ -111,7 +103,6 @@ const StrategiesItem = ({ strategiesData: data, type = 'default' }: Props) => { </> )} </button> - <SigninCheckModal isModalOpen={isModalOpen} onCloseModal={closeModal} /> <StrategyDeleteModal isModalOpen={isDeleteModalOpen} onCloseModal={closeDeleteModal} diff --git a/shared/providers/auth-provider.tsx b/shared/providers/auth-provider.tsx index 2e29e219..1c59502f 100644 --- a/shared/providers/auth-provider.tsx +++ b/shared/providers/auth-provider.tsx @@ -1,14 +1,15 @@ 'use client' -import { ReactNode, createContext, useEffect } from 'react' +import { ReactNode, createContext, useEffect, useRef } from 'react' import { usePathname, useRouter } from 'next/navigation' import { PATH } from '@/shared/constants/path' +import useModal from '@/shared/hooks/custom/use-modal' import { getAccessToken } from '@/shared/lib/auth-tokens' +import SigninCheckModal from '@/shared/ui/modal/signin-check-modal' import { useAuth } from '../hooks/custom/use-auth' -import { useSessionExpiryWarning } from '../hooks/custom/use-session-expiry-warning' import { useAuthStore } from '../stores/use-auth-store' import { isAuthRequiredPath, isNonAuthPage } from '../utils/auth-path' @@ -22,9 +23,9 @@ export const AuthProvider = ({ children }: AuthProviderProps) => { const pathname = usePathname() const router = useRouter() const { initializeAuthState } = useAuthStore() + const { isModalOpen, openModal, closeModal } = useModal() useAuth() - const SessionExpiryWarningModal = useSessionExpiryWarning() useEffect(() => { initializeAuthState() @@ -34,7 +35,7 @@ export const AuthProvider = ({ children }: AuthProviderProps) => { const accessToken = getAccessToken() if (isAuthRequiredPath(pathname) && !accessToken) { - router.replace(`${PATH.SIGN_IN}?returnUrl=${pathname}`) + openModal() return } @@ -44,10 +45,19 @@ export const AuthProvider = ({ children }: AuthProviderProps) => { } }, [pathname, router]) + const handleLoginConfirm = () => { + closeModal() + router.push(`${PATH.SIGN_IN}?returnUrl=${pathname}`) + } + return ( <AuthContext.Provider value={null}> {children} - {SessionExpiryWarningModal} + <SigninCheckModal + isModalOpen={isModalOpen} + onCloseModal={closeModal} + onConfirm={handleLoginConfirm} + /> </AuthContext.Provider> ) } diff --git a/shared/ui/modal/signin-check-modal.tsx b/shared/ui/modal/signin-check-modal.tsx index dc1c0294..8b88ad1a 100644 --- a/shared/ui/modal/signin-check-modal.tsx +++ b/shared/ui/modal/signin-check-modal.tsx @@ -2,13 +2,9 @@ import React from 'react' -import { useRouter } from 'next/navigation' - import { ModalAlertIcon } from '@/public/icons' import classNames from 'classnames/bind' -import { PATH } from '@/shared/constants/path' - import Modal from '.' import { Button } from '../button' import styles from './styles.module.scss' @@ -18,23 +14,20 @@ const cx = classNames.bind(styles) interface Props { isModalOpen: boolean onCloseModal: () => void + onConfirm: () => void } -const SigninCheckModal = ({ isModalOpen, onCloseModal }: Props) => { - const router = useRouter() - - const handleRouter = () => { - router.push(PATH.SIGN_IN) - } - +const SigninCheckModal = ({ isModalOpen, onCloseModal, onConfirm }: Props) => { return ( <Modal isOpen={isModalOpen} icon={ModalAlertIcon}> <span className={cx('message')}> - 로그인이 필요합니다. <br /> 로그인 하시겠습니까? + 로그인이 필요합니다. + <br /> + 로그인 하시겠습니까? </span> <div className={cx('two-button')}> <Button onClick={onCloseModal}>아니오</Button> - <Button onClick={handleRouter} variant="filled" className={cx('button')}> + <Button onClick={onConfirm} variant="filled" className={cx('button')}> 예 </Button> </div> From 24e2d0fdf58e4f31fc30ef049e5e9224645a405a Mon Sep 17 00:00:00 2001 From: deun <devdeun@gmail.com> Date: Mon, 9 Dec 2024 11:09:57 +0900 Subject: [PATCH 018/207] =?UTF-8?q?feat:=20smscore=20tofixed(1)=20?= =?UTF-8?q?=EC=B2=98=EB=A6=AC=20(#17)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- app/(landing)/(home)/_ui/top-strategy-card/index.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/app/(landing)/(home)/_ui/top-strategy-card/index.tsx b/app/(landing)/(home)/_ui/top-strategy-card/index.tsx index 73907c20..3ae4806a 100644 --- a/app/(landing)/(home)/_ui/top-strategy-card/index.tsx +++ b/app/(landing)/(home)/_ui/top-strategy-card/index.tsx @@ -62,7 +62,7 @@ const SmScore = ({ score }: { score: number }) => { return ( <div className={cx('score-wrapper')}> <span className={cx('label')}>SM SCORE</span> - <span className={cx('score')}>{score}</span> + <span className={cx('score')}>{score.toFixed(1)}</span> </div> ) } From 57d48eb74adc474c6f944698c685eddc4f7cb2c5 Mon Sep 17 00:00:00 2001 From: James <dawn147@snu.ac.kr> Date: Mon, 9 Dec 2024 11:14:16 +0900 Subject: [PATCH 019/207] =?UTF-8?q?feat:=20=EB=AF=B8=EB=93=A4=EC=9B=A8?= =?UTF-8?q?=EC=96=B4=EB=A1=9C=20api=20=EC=9A=94=EC=B2=AD=20=EB=B3=B4?= =?UTF-8?q?=ED=98=B8=20=EB=A1=9C=EC=A7=81=20=EC=B6=94=EA=B0=80=20(#7)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- middleware.ts | 49 +++++++++++++++++++++++++++++++++++++++++-------- 1 file changed, 41 insertions(+), 8 deletions(-) diff --git a/middleware.ts b/middleware.ts index 3575db4e..c47c35d1 100644 --- a/middleware.ts +++ b/middleware.ts @@ -5,16 +5,41 @@ import { handleSignupMiddleware } from '@/middleware/signup' import { PATH } from '@/shared/constants/path' +const PROTECTED_API_PATHS = [ + '/api/my-strategies/', + '/api/strategies/search/trader/', + '/api/admin/', + '/api/notices/', + '/api/trader/', + '/review', +] as const + +const isProtectedApiPath = (path: string): boolean => { + return PROTECTED_API_PATHS.some((protectedPath) => { + if (protectedPath.endsWith('/')) { + return path.startsWith(protectedPath) + } + return path.includes(protectedPath) + }) +} + export const middleware = (request: NextRequest) => { const path = request.nextUrl.pathname - //TODO: api 요청 보호 - // if (path.startsWith('/api/')) { - // const authHeader = request.headers.get('access-token') - // if (!authHeader) { - // return NextResponse.json({ error: 'Unauthorized' }, { status: 401 }) - // } - // } + if (path.startsWith('/api/')) { + if (isProtectedApiPath(path)) { + const authHeader = request.headers.get('access-token') + if (!authHeader) { + return NextResponse.json( + { + error: 'Unauthorized', + message: '로그인이 필요한 요청입니다.', + }, + { status: 401 } + ) + } + } + } if (path.startsWith(PATH.SIGN_UP)) { const response = handleSignupMiddleware(request) @@ -25,5 +50,13 @@ export const middleware = (request: NextRequest) => { } export const config = { - matcher: ['/api/:path*', '/signup/:path*'], + matcher: [ + '/api/:path*', + '/signup/:path*', + '/my/:path*', + '/admin/:path*', + '/traders/:id*', + '/strategies/:path*', + '/((?!_next/static|_next/image|favicon.ico|public).*)', + ], } From 7d9eed231ffef4b10f681a71ca0600a87f9400d6 Mon Sep 17 00:00:00 2001 From: James <dawn147@snu.ac.kr> Date: Mon, 9 Dec 2024 11:15:08 +0900 Subject: [PATCH 020/207] =?UTF-8?q?feat:=20=EC=A0=84=EB=9E=B5=20=EC=83=81?= =?UTF-8?q?=EC=84=B8=20=ED=8E=98=EC=9D=B4=EC=A7=80=EB=8F=84=20=EC=9D=B8?= =?UTF-8?q?=EC=A6=9D=EB=90=9C=20=EC=82=AC=EC=9A=A9=EC=9E=90=EB=A7=8C=20?= =?UTF-8?q?=ED=99=95=EC=9D=B8=20=ED=95=A0=20=EC=88=98=20=EC=9E=88=EB=8F=84?= =?UTF-8?q?=EB=A1=9D=20=EB=B3=80=EA=B2=BD=20(#7)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- shared/constants/auth.ts | 2 +- shared/utils/auth-path.ts | 14 ++++++++++---- 2 files changed, 11 insertions(+), 5 deletions(-) diff --git a/shared/constants/auth.ts b/shared/constants/auth.ts index 475d9813..7a88df99 100644 --- a/shared/constants/auth.ts +++ b/shared/constants/auth.ts @@ -13,7 +13,7 @@ export const AUTH_TIME = { SAFETY_MARGIN: 5 * 1000, } as const -export const AUTH_REQUIRED_PATTERNS = ['/my/', '/admin', '/traders', '/strategies/'] as const +export const AUTH_REQUIRED_PATTERNS = ['/my/', '/admin', '/traders/', '/strategies/[0-9]+'] as const export const NON_AUTH_PAGES = [ PATH.SIGN_IN, diff --git a/shared/utils/auth-path.ts b/shared/utils/auth-path.ts index ced0c131..4389d40a 100644 --- a/shared/utils/auth-path.ts +++ b/shared/utils/auth-path.ts @@ -1,9 +1,15 @@ import { AUTH_REQUIRED_PATTERNS, NON_AUTH_PAGES } from '../constants/auth' -export const isAuthRequiredPath = (pathname: string): boolean => { - return AUTH_REQUIRED_PATTERNS.some((pattern) => pathname.startsWith(pattern)) +export const isAuthRequiredPath = (path: string): boolean => { + return AUTH_REQUIRED_PATTERNS.some((pattern) => { + if (pattern.includes('[0-9]+')) { + const regex = new RegExp(pattern.replace('[0-9]+', '\\d+')) + return regex.test(path) + } + return path.startsWith(pattern) + }) } -export const isNonAuthPage = (pathname: string): boolean => { - return NON_AUTH_PAGES.some((page) => pathname.startsWith(page)) +export const isNonAuthPage = (path: string): boolean => { + return NON_AUTH_PAGES.some((nonAuthPath) => path === nonAuthPath) } From 15dc20f373d6947ae0fc00cbe9bdc446275f78f5 Mon Sep 17 00:00:00 2001 From: kimpra <dustmqaksdltkfrlfdldi@gmail.com> Date: Mon, 9 Dec 2024 11:31:27 +0900 Subject: [PATCH 021/207] =?UTF-8?q?fix:=20=EA=B4=80=EB=A6=AC=EC=9E=90=20?= =?UTF-8?q?=EC=B9=B4=ED=85=8C=EA=B3=A0=EB=A6=AC=20=ED=8E=98=EC=9D=B4?= =?UTF-8?q?=EC=A7=80=20=EC=A2=85=EB=AA=A9=20=EB=B9=84=ED=99=9C=EC=84=B1?= =?UTF-8?q?=ED=99=94=20=EA=B4=80=EB=A0=A8=20=EB=B2=84=EA=B7=B8=20=ED=95=B4?= =?UTF-8?q?=EA=B2=B0=20(#22)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- ...rade-active-state.ts => use-toggle-stock-active-state.ts} | 0 .../stock-manage/_ui/stock-active-state-toggle-button.tsx | 2 +- .../trade-manage/_api/set-admin-trade-manage-table-data.tsx | 5 +++-- 3 files changed, 4 insertions(+), 3 deletions(-) rename app/admin/category/_ui/stock/stock-manage/_hooks/query/{use-toggle-trade-active-state.ts => use-toggle-stock-active-state.ts} (100%) diff --git a/app/admin/category/_ui/stock/stock-manage/_hooks/query/use-toggle-trade-active-state.ts b/app/admin/category/_ui/stock/stock-manage/_hooks/query/use-toggle-stock-active-state.ts similarity index 100% rename from app/admin/category/_ui/stock/stock-manage/_hooks/query/use-toggle-trade-active-state.ts rename to app/admin/category/_ui/stock/stock-manage/_hooks/query/use-toggle-stock-active-state.ts diff --git a/app/admin/category/_ui/stock/stock-manage/_ui/stock-active-state-toggle-button.tsx b/app/admin/category/_ui/stock/stock-manage/_ui/stock-active-state-toggle-button.tsx index 4edd4539..1a6149bf 100644 --- a/app/admin/category/_ui/stock/stock-manage/_ui/stock-active-state-toggle-button.tsx +++ b/app/admin/category/_ui/stock/stock-manage/_ui/stock-active-state-toggle-button.tsx @@ -4,7 +4,7 @@ import { CSSProperties } from 'react' import { Button } from '@/shared/ui/button' -import useToggoleStockActiveState from '../_hooks/query/use-toggle-trade-active-state' +import useToggoleStockActiveState from '../_hooks/query/use-toggle-stock-active-state' interface Props { active?: boolean diff --git a/app/admin/category/_ui/trade/trade-manage/_api/set-admin-trade-manage-table-data.tsx b/app/admin/category/_ui/trade/trade-manage/_api/set-admin-trade-manage-table-data.tsx index fa4a110e..2e7dec4a 100644 --- a/app/admin/category/_ui/trade/trade-manage/_api/set-admin-trade-manage-table-data.tsx +++ b/app/admin/category/_ui/trade/trade-manage/_api/set-admin-trade-manage-table-data.tsx @@ -1,6 +1,7 @@ import Image from 'next/image' -import StockActiveStateToggleButton from '../../../stock/stock-manage/_ui/stock-active-state-toggle-button' +import TradeActiveStateToggleButton from '../_ui/trade-active-state-toggle-button' +// import StockActiveStateToggleButton from '../../../stock/stock-manage/_ui/stock-active-state-toggle-button' import { TradeResponseModel } from '../types' const setAdminTradeManageTableData = (data: TradeResponseModel['result']) => @@ -13,7 +14,7 @@ const setAdminTradeManageTableData = (data: TradeResponseModel['result']) => height={24} key={data.tradeTypeId} />, - <StockActiveStateToggleButton stockTypeId={data.tradeTypeId} active key={data.tradeTypeId} />, + <TradeActiveStateToggleButton tradeTypeId={data.tradeTypeId} active key={data.tradeTypeId} />, ]) ?? [] export default setAdminTradeManageTableData From 1c8dd0258776e44635a9d8be5c7bb8bf64831202 Mon Sep 17 00:00:00 2001 From: kimpra <dustmqaksdltkfrlfdldi@gmail.com> Date: Mon, 9 Dec 2024 11:45:45 +0900 Subject: [PATCH 022/207] =?UTF-8?q?fix:=20useSuspenseQuery=20=EC=82=AC?= =?UTF-8?q?=EC=9A=A9=EC=9D=84=20=EC=9C=84=ED=95=9C=20axios=20baseUrl=20?= =?UTF-8?q?=EC=84=A4=EC=A0=95=20(#24)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../notices/_hook/query/get-admin-notices.ts | 18 ++++++++++++++++++ shared/api/axios.ts | 2 +- 2 files changed, 19 insertions(+), 1 deletion(-) create mode 100644 app/admin/notices/_hook/query/get-admin-notices.ts diff --git a/app/admin/notices/_hook/query/get-admin-notices.ts b/app/admin/notices/_hook/query/get-admin-notices.ts new file mode 100644 index 00000000..9fa8d853 --- /dev/null +++ b/app/admin/notices/_hook/query/get-admin-notices.ts @@ -0,0 +1,18 @@ +import getNotices from '@/app/(landing)/notices/_api/get-notice' +import { useSuspenseQuery } from '@tanstack/react-query' + +// import getNotices from '../_api/get-notice' + +interface Props { + page?: number + size?: number +} + +const useAdminNotices = ({ page, size }: Props = {}) => { + return useSuspenseQuery({ + queryKey: ['notices', page, size], + queryFn: () => getNotices({ page, size }), + }) +} + +export default useAdminNotices diff --git a/shared/api/axios.ts b/shared/api/axios.ts index cd7d98a7..92745c1b 100644 --- a/shared/api/axios.ts +++ b/shared/api/axios.ts @@ -7,7 +7,7 @@ import { isTokenExpired, refreshToken } from '@/shared/utils/token-utils' export const createAxiosInstance = (options: { withInterceptors?: boolean } = {}) => { const instance = axios.create({ - baseURL: process.env.NEXT_PUBLIC_API_HOST, + baseURL: process.env.NEXT_PUBLIC_API_HOST || 'http://localhost:3000', withCredentials: true, }) From e891b136ee8783efff8282f92f5d5845e9909235 Mon Sep 17 00:00:00 2001 From: James <dawn147@snu.ac.kr> Date: Mon, 9 Dec 2024 11:47:06 +0900 Subject: [PATCH 023/207] =?UTF-8?q?fix:=20=EB=B6=88=ED=95=84=EC=9A=94?= =?UTF-8?q?=ED=95=9C=20=EC=BD=94=EB=93=9C=20=EC=82=AD=EC=A0=9C=20=EB=B0=8F?= =?UTF-8?q?=20review=20->=20reviews=EB=A1=9C=20=EC=88=98=EC=A0=95=20(#7)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- middleware.ts | 2 +- shared/providers/auth-provider.tsx | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/middleware.ts b/middleware.ts index c47c35d1..f976445a 100644 --- a/middleware.ts +++ b/middleware.ts @@ -11,7 +11,7 @@ const PROTECTED_API_PATHS = [ '/api/admin/', '/api/notices/', '/api/trader/', - '/review', + '/reviews', ] as const const isProtectedApiPath = (path: string): boolean => { diff --git a/shared/providers/auth-provider.tsx b/shared/providers/auth-provider.tsx index 1c59502f..01152c90 100644 --- a/shared/providers/auth-provider.tsx +++ b/shared/providers/auth-provider.tsx @@ -1,6 +1,6 @@ 'use client' -import { ReactNode, createContext, useEffect, useRef } from 'react' +import { ReactNode, createContext, useEffect } from 'react' import { usePathname, useRouter } from 'next/navigation' From 9d9c82d217fde883fc015299af7f6c32f4ad730d Mon Sep 17 00:00:00 2001 From: kimpra <dustmqaksdltkfrlfdldi@gmail.com> Date: Mon, 9 Dec 2024 12:36:23 +0900 Subject: [PATCH 024/207] =?UTF-8?q?fix:=20=EA=B4=80=EB=A6=AC=EC=9E=90=20?= =?UTF-8?q?=EA=B3=B5=EC=A7=80=EC=82=AC=ED=95=AD=20suspense=20=EC=A0=81?= =?UTF-8?q?=EC=9A=A9=EC=9D=84=20=EC=9C=84=ED=95=B4=20=ED=85=8C=EC=9D=B4?= =?UTF-8?q?=EB=B8=94=20=EB=B6=84=EB=A6=AC=20(#14)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../notices/_ui/admin-notice-table/index.tsx | 60 +++++++++++++++++++ .../_ui/admin-notice-table/styles.module.scss | 21 +++++++ app/admin/notices/page.tsx | 54 +---------------- app/admin/notices/tabledata.tsx | 39 ------------ 4 files changed, 83 insertions(+), 91 deletions(-) create mode 100644 app/admin/notices/_ui/admin-notice-table/index.tsx create mode 100644 app/admin/notices/_ui/admin-notice-table/styles.module.scss delete mode 100644 app/admin/notices/tabledata.tsx diff --git a/app/admin/notices/_ui/admin-notice-table/index.tsx b/app/admin/notices/_ui/admin-notice-table/index.tsx new file mode 100644 index 00000000..f75a295b --- /dev/null +++ b/app/admin/notices/_ui/admin-notice-table/index.tsx @@ -0,0 +1,60 @@ +'use client' + +import AdminContentsHeader from '@/app/admin/_ui/admin-header' +import classNames from 'classnames/bind' + +import { Button } from '@/shared/ui/button' +import Pagination from '@/shared/ui/pagination' +import VerticalTable from '@/shared/ui/table/vertical' +import withSuspense from '@/shared/utils/with-suspense' + +import useAdminNotices from '../../_hook/query/get-admin-notices' +import NoticeDeleteButton from '../notice-delete-button' +import styles from './styles.module.scss' + +const cx = classNames.bind(styles) + +const AdminNoticeTable = () => { + const { data } = useAdminNotices() + + return ( + <> + <AdminContentsHeader + Left={ + <span> + 총 <span className={cx('colored')}>{data?.totalElements}</span>개 + </span> + } + className={cx('header')} + /> + <VerticalTable + tableHead={['No.', '제목', '내용', '작성일', '']} + tableBody={data?.content.map((data, idx) => [ + idx + 1, + data.title, + data.content.slice(0, 15), + data.createdAt.slice(0, 10), + <Button.ButtonGroup key={data.content}> + <Button + onClick={() => {}} + size="small" + className={cx('button')} + style={{ padding: '16px 7px' }} + > + 수정 + </Button> + <NoticeDeleteButton noticeId={data.noticeId} /> + </Button.ButtonGroup>, + ])} + countPerPage={10} + currentPage={1} + /> + <Pagination currentPage={1} maxPage={1} onPageChange={() => {}} /> + </> + ) +} + +export default withSuspense( + AdminNoticeTable, + <VerticalTable.Skeleton tableHead={['No.', '제목', '내용', '작성일', '']} /> +) diff --git a/app/admin/notices/_ui/admin-notice-table/styles.module.scss b/app/admin/notices/_ui/admin-notice-table/styles.module.scss new file mode 100644 index 00000000..02684f1a --- /dev/null +++ b/app/admin/notices/_ui/admin-notice-table/styles.module.scss @@ -0,0 +1,21 @@ +.container { + position: relative; + padding: 0 25px 37px; // table 기본 padding 때문에 20 뺌 + border-radius: 8px; + margin-bottom: 42px; + background-color: $color-white; +} + +.header { + padding: 40px 20px 20px; +} + +.colored { + color: $color-orange-500; +} + +.button { + width: 74px; + height: 30px; + border: 1px solid $color-gray-300; +} diff --git a/app/admin/notices/page.tsx b/app/admin/notices/page.tsx index 3553e0ce..540223ff 100644 --- a/app/admin/notices/page.tsx +++ b/app/admin/notices/page.tsx @@ -1,70 +1,20 @@ -'use client' - import classNames from 'classnames/bind' -import { Button } from '@/shared/ui/button' -import Pagination from '@/shared/ui/pagination' -import VerticalTable from '@/shared/ui/table/vertical' import Title from '@/shared/ui/title' -import AdminContentsHeader from '../_ui/admin-header' +import AdminNoticeTable from './_ui/admin-notice-table' import NoticePostButton from './_ui/notice-post-button' import styles from './page.module.scss' -import { RES } from './tabledata' const cx = classNames.bind(styles) -const TOTAL_NOTICE = RES.data.content.length - const AdminNoticesPage = () => { return ( <> <Title label="공지사항" style={{ margin: '80px 0 26px 12.6px' }} /> <div className={cx('container')}> <NoticePostButton /> - <AdminContentsHeader - Left={ - <span> - 총 <span className={cx('colored')}>{TOTAL_NOTICE}</span>개 - </span> - } - className={cx('header')} - /> - <VerticalTable - tableHead={['No.', '제목', '등록일', '작성일', '']} - tableBody={RES.data.content.map((d, idx) => [ - idx + 1, - d.title, - d.publishedAt.slice(0, 10), - d.createdAt.slice(0, 10), - <Button.ButtonGroup key={d.content}> - {/* TODO: onclick 로직 정의 */} - - <Button - onClick={() => {}} - size="small" - className={cx('button')} - style={{ padding: '16px 7px' }} - > - 수정 - </Button> - <Button - size="small" - onClick={() => {}} - variant="filled" - className={cx('button')} - style={{ padding: '16px 7px' }} - > - 삭제 - </Button> - </Button.ButtonGroup>, - ])} - // TODO: 실제 값으로 추가 - countPerPage={10} - currentPage={1} - /> - {/* TODO: 실제 값으로 추가 */} - <Pagination currentPage={1} maxPage={1} onPageChange={() => {}} /> + <AdminNoticeTable /> </div> </> ) diff --git a/app/admin/notices/tabledata.tsx b/app/admin/notices/tabledata.tsx deleted file mode 100644 index 4338d250..00000000 --- a/app/admin/notices/tabledata.tsx +++ /dev/null @@ -1,39 +0,0 @@ -export const RES = { - isSuccess: true, - message: '공지사항 목록 조회 성공', - data: { - content: [ - { - noticeId: 1, - user: { - id: 2, - nickname: '관리자', - }, - title: '서비스 점검 안내', - content: '점검예정.', - createdAt: '2024-01-01 12:00:00', - publishedAt: '2024-11-15T09:00:00', - createdBy: '관리자', - }, - { - noticeId: 2, - user: { - id: 3, - nickname: '관리자', - }, - title: '업데이트 안내', - content: '이번 업데이트 내용은', - nickname: '관리자', - createdAt: '2024-01-02 12:00:00', - publishedAt: '2024-11-15T09:00:00', - createdBy: '관리자', - }, - ], - pageable: { - pageNumber: 0, - pageSize: 20, - }, - totalPages: 5, - totalElements: 50, - }, -} From c5c75e521f7793e629963d6bd0d826349d5c6b07 Mon Sep 17 00:00:00 2001 From: kimpra <dustmqaksdltkfrlfdldi@gmail.com> Date: Mon, 9 Dec 2024 12:37:00 +0900 Subject: [PATCH 025/207] =?UTF-8?q?feat:=20=EA=B4=80=EB=A6=AC=EC=9E=90=20?= =?UTF-8?q?=EA=B3=B5=EC=A7=80=EC=82=AC=ED=95=AD=20=EC=82=AD=EC=A0=9C=20API?= =?UTF-8?q?=20=EC=97=B0=EA=B2=B0=20(#14)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- app/admin/notices/_api/delete-notice.ts | 20 +++++++++++ .../notices/_hook/query/use-delete-notice.ts | 16 +++++++++ .../_ui/notice-delete-button/index.tsx | 36 +++++++++++++++++++ .../notice-delete-button/styles.module.scss | 6 ++++ app/admin/notices/types.ts | 4 +++ 5 files changed, 82 insertions(+) create mode 100644 app/admin/notices/_api/delete-notice.ts create mode 100644 app/admin/notices/_hook/query/use-delete-notice.ts create mode 100644 app/admin/notices/_ui/notice-delete-button/index.tsx create mode 100644 app/admin/notices/_ui/notice-delete-button/styles.module.scss create mode 100644 app/admin/notices/types.ts diff --git a/app/admin/notices/_api/delete-notice.ts b/app/admin/notices/_api/delete-notice.ts new file mode 100644 index 00000000..bb1815ed --- /dev/null +++ b/app/admin/notices/_api/delete-notice.ts @@ -0,0 +1,20 @@ +import axiosInstance from '@/shared/api/axios' + +import { DeleteNoticeResponeseModel } from '../types' + +const deleteNotice = async (noticeId: number) => { + try { + const res = await axiosInstance.delete<DeleteNoticeResponeseModel>( + `/api/admin/notices/${noticeId}` + ) + + if (!res.data.isSuccess) throw new Error('Error with code' + res.data.code) + + return res.data + } catch (err) { + console.error(err) + throw err + } +} + +export default deleteNotice diff --git a/app/admin/notices/_hook/query/use-delete-notice.ts b/app/admin/notices/_hook/query/use-delete-notice.ts new file mode 100644 index 00000000..602c4744 --- /dev/null +++ b/app/admin/notices/_hook/query/use-delete-notice.ts @@ -0,0 +1,16 @@ +import { useMutation, useQueryClient } from '@tanstack/react-query' + +import deleteNotice from '../../_api/delete-notice' + +const useDeleteNotice = (noticeId: number) => { + const queryClient = useQueryClient() + + return useMutation({ + mutationFn: () => deleteNotice(noticeId), + onSuccess: () => { + queryClient.invalidateQueries({ queryKey: ['notices'] }) + }, + }) +} + +export default useDeleteNotice diff --git a/app/admin/notices/_ui/notice-delete-button/index.tsx b/app/admin/notices/_ui/notice-delete-button/index.tsx new file mode 100644 index 00000000..08705403 --- /dev/null +++ b/app/admin/notices/_ui/notice-delete-button/index.tsx @@ -0,0 +1,36 @@ +'use client' + +import classNames from 'classnames/bind' + +import { Button } from '@/shared/ui/button' + +import useDeleteNotice from '../../_hook/query/use-delete-notice' +import styles from './styles.module.scss' + +const cx = classNames.bind(styles) + +interface Props { + noticeId: number +} + +const NoticeDeleteButton = ({ noticeId }: Props) => { + const { mutate, isPending } = useDeleteNotice(noticeId) + + const onClick = () => { + mutate() + } + + return ( + <Button + size="small" + onClick={onClick} + disabled={isPending} + variant="filled" + className={cx('button')} + > + 삭제 + </Button> + ) +} + +export default NoticeDeleteButton diff --git a/app/admin/notices/_ui/notice-delete-button/styles.module.scss b/app/admin/notices/_ui/notice-delete-button/styles.module.scss new file mode 100644 index 00000000..7c9b04e0 --- /dev/null +++ b/app/admin/notices/_ui/notice-delete-button/styles.module.scss @@ -0,0 +1,6 @@ +.button { + width: 74px; + height: 30px; + padding: 16px 7px; + border: 1px solid $color-gray-300; +} diff --git a/app/admin/notices/types.ts b/app/admin/notices/types.ts new file mode 100644 index 00000000..1fc89375 --- /dev/null +++ b/app/admin/notices/types.ts @@ -0,0 +1,4 @@ +import { APIResponseBaseModel } from '@/shared/types/response' + +// eslint-disable-next-line +export interface DeleteNoticeResponeseModel extends APIResponseBaseModel<boolean> {} From 0086509ba9298e5b8ed34b9594fb80eda33f8b82 Mon Sep 17 00:00:00 2001 From: James <dawn147@snu.ac.kr> Date: Mon, 9 Dec 2024 13:09:27 +0900 Subject: [PATCH 026/207] =?UTF-8?q?refactor:=20vertical=20=EA=B3=B5?= =?UTF-8?q?=ED=86=B5=20=EC=BB=B4=ED=8F=AC=EB=84=8C=ED=8A=B8=EC=97=90?= =?UTF-8?q?=EC=84=9C=20=EB=B9=84=EC=A6=88=EB=8B=88=EC=8A=A4=20=EB=A1=9C?= =?UTF-8?q?=EC=A7=81=20=EB=B6=84=EB=A6=AC=20(#21)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../analysis-container/analysis-content.tsx | 89 +++++++++- .../_ui/analysis-container/styles.module.scss | 32 +++- shared/ui/table/vertical/index.tsx | 165 +++++------------- shared/ui/table/vertical/styles.module.scss | 11 -- 4 files changed, 155 insertions(+), 142 deletions(-) diff --git a/app/(dashboard)/_ui/analysis-container/analysis-content.tsx b/app/(dashboard)/_ui/analysis-container/analysis-content.tsx index fd3c2cd8..f6f498c9 100644 --- a/app/(dashboard)/_ui/analysis-container/analysis-content.tsx +++ b/app/(dashboard)/_ui/analysis-container/analysis-content.tsx @@ -4,14 +4,17 @@ import classNames from 'classnames/bind' import { ANALYSIS_PAGE_COUNT } from '@/shared/constants/count-per-page' import useModal from '@/shared/hooks/custom/use-modal' +import { MyDailyAnalysisModel } from '@/shared/types/strategy-data' import { Button } from '@/shared/ui/button' import AnalysisUploadModal from '@/shared/ui/modal/analysis-upload-modal' import DailyAnalysisDeleteAllModal from '@/shared/ui/modal/daily-analysis-delete-all-modal' +import EditAnalysisModal from '@/shared/ui/modal/edit-daily-analysis-modal.ts' import Pagination from '@/shared/ui/pagination' -import VerticalTable from '@/shared/ui/table/vertical' +import VerticalTable, { TableBodyDataType, isMyAnalysisData } from '@/shared/ui/table/vertical' import { useAnalysisUploadMutation } from '../../my/_hooks/query/use-analysis-mutation' import useGetMyDailyAnalysis from '../../my/_hooks/query/use-get-my-daily-analysis' +import { useMyAnalysisMutation } from '../../my/_hooks/query/use-manage-daily-analysis' import useGetAnalysis from '../../strategies/[strategyId]/_hooks/query/use-get-analysis' import useGetAnalysisDownload from '../../strategies/[strategyId]/_hooks/query/use-get-analysis-download' import styles from './styles.module.scss' @@ -55,6 +58,7 @@ const AnalysisContent = ({ }: Props) => { const { mutate } = useGetAnalysisDownload() const [uploadType, setUploadType] = useState<'excel' | 'direct' | null>(null) + const [selectedAnalysis, setSelectedAnalysis] = useState<MyDailyAnalysisModel | null>(null) const { isModalOpen, openModal, closeModal } = useModal() const { @@ -62,6 +66,11 @@ const AnalysisContent = ({ openModal: openDeleteModal, closeModal: closeDeleteModal, } = useModal() + const { + isModalOpen: isEditModalOpen, + openModal: openEditModal, + closeModal: closeEditModal, + } = useModal() const { data: myAnalysisData } = useGetMyDailyAnalysis( strategyId, @@ -82,13 +91,12 @@ const AnalysisContent = ({ currentPage, ANALYSIS_PAGE_COUNT ) + const { deleteAnalysisData } = useMyAnalysisMutation(strategyId, currentPage, ANALYSIS_PAGE_COUNT) const handleDownload = () => { mutate({ strategyId, type }) } - const tableHeader = type === 'daily' ? DAILY_TABLE_HEADER : MONTHLY_TABLE_HEADER - const handleExcelUpload = () => { setUploadType('excel') openModal() @@ -104,6 +112,11 @@ const AnalysisContent = ({ setUploadType(null) } + const handleCloseEditModal = () => { + closeEditModal() + setSelectedAnalysis(null) + } + const handleDeleteAll = async () => { try { await deleteAllAnalysis() @@ -113,8 +126,46 @@ const AnalysisContent = ({ } } + const handleDeleteAnalysis = async (dailyAnalysisId: number) => { + try { + await deleteAnalysisData(dailyAnalysisId) + } catch (err) { + console.error('Delete failed:', err) + } + } + + const renderActions = (row: TableBodyDataType) => { + if (!isMyAnalysisData(row)) return null + + return ( + <div className={cx('button-container')}> + <Button + size="small" + variant="outline" + className={cx('edit-button')} + onClick={() => { + setSelectedAnalysis(row) + openEditModal() + }} + > + 수정 + </Button> + <Button + size="small" + variant="filled" + className={cx('delete-button')} + onClick={() => handleDeleteAnalysis(row.dailyAnalysisId)} + > + 삭제 + </Button> + </div> + ) + } + + const tableHeader = type === 'daily' ? DAILY_TABLE_HEADER : MONTHLY_TABLE_HEADER + return ( - <div className={cx('table-wrapper', 'analysis')}> + <div className={cx('table-wrapper', isEditable ? 'my-analysis' : 'analysis')}> {!isEditable && analysisData && ( <Button onClick={handleDownload} @@ -139,13 +190,19 @@ const AnalysisContent = ({ <Button size="small" className={cx('upload-button')} - variant="filled" + variant="outline" onClick={handleDirectInput} > 직접 입력 </Button> </div> - <Button size="small" variant="filled" onClick={openDeleteModal} disabled={isLoading}> + <Button + size="small" + variant="filled" + onClick={openDeleteModal} + disabled={isLoading} + className={cx('delete-all-button')} + > 전체 삭제 </Button> </div> @@ -157,8 +214,8 @@ const AnalysisContent = ({ tableBody={analysisData.content} currentPage={1} countPerPage={ANALYSIS_PAGE_COUNT} - isEditable={isEditable} - strategyId={strategyId} + renderActions={isEditable ? renderActions : undefined} + hideFirstColumn={isEditable} /> <Pagination currentPage={currentPage} @@ -182,6 +239,22 @@ const AnalysisContent = ({ /> )} + {selectedAnalysis && ( + <EditAnalysisModal + isOpen={isEditModalOpen} + onClose={handleCloseEditModal} + strategyId={strategyId} + analysisId={selectedAnalysis.dailyAnalysisId} + initialData={{ + date: selectedAnalysis.dailyDate, + transaction: selectedAnalysis.transaction, + dailyProfitLoss: selectedAnalysis.dailyProfitLoss, + }} + page={currentPage} + size={ANALYSIS_PAGE_COUNT} + /> + )} + <DailyAnalysisDeleteAllModal isModalOpen={isDeleteModalOpen} onCloseModal={closeDeleteModal} diff --git a/app/(dashboard)/_ui/analysis-container/styles.module.scss b/app/(dashboard)/_ui/analysis-container/styles.module.scss index e139a8e9..a5d5f9df 100644 --- a/app/(dashboard)/_ui/analysis-container/styles.module.scss +++ b/app/(dashboard)/_ui/analysis-container/styles.module.scss @@ -2,6 +2,7 @@ padding: 20px; border-radius: 5px; background-color: $color-white; + .analysis-header { display: flex; justify-content: space-between; @@ -29,21 +30,29 @@ .table-wrapper { margin-top: 20px; position: relative; + .edit-button-container { display: flex; justify-content: space-between; margin-bottom: 30px; + .edit-button, .delete-button { padding: 7px 18px; } + .delete-button:disabled { background-color: transparent; } } + &.analysis { margin-top: 40px; } + &.my-analysis { + margin-top: 20px; + } + .excel-button { height: 30px; position: absolute; @@ -57,15 +66,18 @@ grid-template-columns: repeat(5, 1fr); grid-template-rows: repeat(1, 1fr); column-gap: 20px; + &.line { grid-template-rows: repeat(2, 1fr); row-gap: 10px; } + .image-data { display: flex; flex-direction: column; justify-content: center; align-items: center; + .image { position: relative; width: 100%; @@ -74,11 +86,13 @@ cursor: pointer; overflow: hidden; } + span { text-align: center; @include typo-c1; } } + .title-wrapper { display: flex; justify-content: center; @@ -100,13 +114,23 @@ .button-container { display: flex; justify-content: space-between; + align-items: center; height: 30px; - margin-top: -20px; - margin-bottom: 10px; + gap: 8px; + padding: 0 10px; - button.upload-button { - height: 100%; + button.upload-button, + button.delete-all-button { padding: 7px 18px; margin-right: 10px; } + + button.delete-button, + button.edit-button { + min-width: 60px; + height: 24px; + padding: 7px 16px; + border-radius: 16px; + border: 1px solid $color-gray-300; + } } diff --git a/shared/ui/table/vertical/index.tsx b/shared/ui/table/vertical/index.tsx index 5ca83d18..1aed8006 100644 --- a/shared/ui/table/vertical/index.tsx +++ b/shared/ui/table/vertical/index.tsx @@ -1,44 +1,30 @@ 'use client' -import { ReactNode, useState } from 'react' +import { ReactNode } from 'react' -import { useMyAnalysisMutation } from '@/app/(dashboard)/my/_hooks/query/use-manage-daily-analysis' import { NoticeListContentModel } from '@/app/(landing)/notices/_ui/notice-table' import classNames from 'classnames/bind' -import useModal from '@/shared/hooks/custom/use-modal' import { DailyAnalysisModel, MonthlyAnalysisModel, MyDailyAnalysisModel, } from '@/shared/types/strategy-data' -import { Button } from '@/shared/ui/button' import { formatNumber } from '@/shared/utils/format' import sliceArray from '@/shared/utils/slice-array' -import EditAnalysisModal from '../../modal/edit-daily-analysis-modal.ts' import styles from './styles.module.scss' const cx = classNames.bind(styles) -type TableBodyDataType = +export type TableBodyDataType = | DailyAnalysisModel | MyDailyAnalysisModel | MonthlyAnalysisModel | NoticeListContentModel | Array<ReactNode | string | number> -export interface VerticalTableProps { - tableHead: string[] - tableBody: TableBodyDataType[] - countPerPage: number - currentPage: number - isEditable?: boolean - className?: string - strategyId?: number -} - -const isMyAnalysisData = (data: TableBodyDataType): data is MyDailyAnalysisModel => { +export const isMyAnalysisData = (data: TableBodyDataType): data is MyDailyAnalysisModel => { if (!data || typeof data !== 'object' || Array.isArray(data)) return false return ( @@ -50,130 +36,71 @@ const isMyAnalysisData = (data: TableBodyDataType): data is MyDailyAnalysisModel ) } +export interface VerticalTableProps { + tableHead: string[] + tableBody: TableBodyDataType[] + countPerPage: number + currentPage: number + className?: string + renderActions?: (row: TableBodyDataType) => ReactNode | null + hideFirstColumn?: boolean +} + const VerticalTable = ({ tableHead, tableBody, countPerPage, currentPage, - isEditable = false, className, - strategyId, + renderActions, + hideFirstColumn = false, }: VerticalTableProps) => { const hasData = tableBody.length > 0 const slicedTableBody = sliceArray(tableBody, countPerPage, currentPage) - const { isModalOpen, openModal, closeModal } = useModal() - const [selectedAnalysis, setSelectedAnalysis] = useState<MyDailyAnalysisModel | null>(null) - - const { deleteAnalysisData } = useMyAnalysisMutation(strategyId ?? 0, currentPage, countPerPage) - - const handleDelete = async (dailyAnalysisId: number) => { - if (!strategyId) return - if (window.confirm('해당 데이터를 삭제하시겠습니까?')) { - try { - await deleteAnalysisData(dailyAnalysisId) - } catch (err) { - console.error('Delete failed:', err) - alert('삭제 중 오류가 발생했습니다.') - } - } - } - - const handleEditClick = (row: TableBodyDataType) => { - if (!isMyAnalysisData(row)) { - return - } - setSelectedAnalysis(row) - openModal() - } - - const handleCloseModal = () => { - closeModal() - setSelectedAnalysis(null) - } return ( - <> - <div className={cx('container', className)}> - <table> - <thead> - <tr> - {tableHead.map((head) => ( - <td key={head}>{head}</td> - ))} - {isEditable && <td></td>} - </tr> - </thead> - {hasData && ( - <tbody> - {slicedTableBody.map((row) => ( - <tr key={Object.values(row)[0]}> - {Object.values(row) - .slice(isEditable ? 1 : 0) - .map((data, idx) => ( - <td key={data + idx}>{formatNumber(data)}</td> - ))} - {isEditable && ( - <td className={cx('button-container')}> - <Button - size="small" - variant="outline" - className={cx('edit-button')} - onClick={() => handleEditClick(row)} - > - 수정 - </Button> - <Button - size="small" - variant="filled" - className={cx('delete-button')} - onClick={() => { - if (!isMyAnalysisData(row)) return - handleDelete(row.dailyAnalysisId) - }} - > - 삭제 - </Button> - </td> - )} - </tr> - ))} - </tbody> - )} - </table> - {!hasData && ( - <div className={cx('no-data')} style={{ height: `calc(40px * ${countPerPage}` }}> - 데이터가 존재하지 않습니다. - </div> + <div className={cx('container', className)}> + <table> + <thead> + <tr> + {tableHead.map((head) => ( + <td key={head}>{head}</td> + ))} + {renderActions && <td></td>} + </tr> + </thead> + {hasData && ( + <tbody> + {slicedTableBody.map((row) => ( + <tr key={Object.values(row)[0]}> + {Object.values(row) + .slice(hideFirstColumn ? 1 : 0) + .map((data, idx) => ( + <td key={`${data}-${idx}`}>{formatNumber(data)}</td> + ))} + {renderActions && <td className={cx('button-container')}>{renderActions(row)}</td>} + </tr> + ))} + </tbody> )} - </div> - - {selectedAnalysis && ( - <EditAnalysisModal - isOpen={isModalOpen} - onClose={handleCloseModal} - strategyId={strategyId ?? 0} - analysisId={selectedAnalysis.dailyAnalysisId} - initialData={{ - date: selectedAnalysis.dailyDate, - transaction: selectedAnalysis.transaction, - dailyProfitLoss: selectedAnalysis.dailyProfitLoss, - }} - page={currentPage} - size={countPerPage} - /> + </table> + {!hasData && ( + <div className={cx('no-data')} style={{ height: `calc(40px * ${countPerPage}` }}> + 데이터가 존재하지 않습니다. + </div> )} - </> + </div> ) } -const Skeleton = ({ tableHead, countPerPage, isEditable = false }: Partial<VerticalTableProps>) => { +const Skeleton = ({ tableHead, countPerPage, renderActions }: Partial<VerticalTableProps>) => { return ( <div className={cx('container')}> <table> <thead> <tr> {tableHead?.map((head) => <td key={head}>{head}</td>)} - {isEditable && <td>관리</td>} + {renderActions && <td>관리</td>} </tr> </thead> </table> diff --git a/shared/ui/table/vertical/styles.module.scss b/shared/ui/table/vertical/styles.module.scss index 435f19f3..f332e8a5 100644 --- a/shared/ui/table/vertical/styles.module.scss +++ b/shared/ui/table/vertical/styles.module.scss @@ -26,17 +26,6 @@ padding: 0; text-align: right; } - - .edit-button, - .delete-button { - min-width: 60px; - height: 24px; - padding: 7px 16px; - } - .edit-button { - border: 1px solid $color-gray-300; - margin-right: 8px; - } } } From 53f6ff8990cdf250654ee94ff9b782c470bb5cd3 Mon Sep 17 00:00:00 2001 From: James <dawn147@snu.ac.kr> Date: Mon, 9 Dec 2024 13:49:48 +0900 Subject: [PATCH 027/207] =?UTF-8?q?fix:=20=EC=BD=94=EB=93=9C=EB=A6=AC?= =?UTF-8?q?=EB=B7=B0=20=EB=B0=98=EC=98=81=20(#21)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../analysis-container/analysis-content.tsx | 26 ++++++++++++++----- shared/ui/table/vertical/index.tsx | 12 --------- 2 files changed, 19 insertions(+), 19 deletions(-) diff --git a/app/(dashboard)/_ui/analysis-container/analysis-content.tsx b/app/(dashboard)/_ui/analysis-container/analysis-content.tsx index f6f498c9..e17284bf 100644 --- a/app/(dashboard)/_ui/analysis-container/analysis-content.tsx +++ b/app/(dashboard)/_ui/analysis-container/analysis-content.tsx @@ -10,7 +10,7 @@ import AnalysisUploadModal from '@/shared/ui/modal/analysis-upload-modal' import DailyAnalysisDeleteAllModal from '@/shared/ui/modal/daily-analysis-delete-all-modal' import EditAnalysisModal from '@/shared/ui/modal/edit-daily-analysis-modal.ts' import Pagination from '@/shared/ui/pagination' -import VerticalTable, { TableBodyDataType, isMyAnalysisData } from '@/shared/ui/table/vertical' +import VerticalTable, { TableBodyDataType } from '@/shared/ui/table/vertical' import { useAnalysisUploadMutation } from '../../my/_hooks/query/use-analysis-mutation' import useGetMyDailyAnalysis from '../../my/_hooks/query/use-get-my-daily-analysis' @@ -21,6 +21,14 @@ import styles from './styles.module.scss' const cx = classNames.bind(styles) +interface Props { + type: 'daily' | 'monthly' + strategyId: number + currentPage: number + onPageChange: (page: number) => void + isEditable?: boolean +} + const DAILY_TABLE_HEADER = [ '날짜', '원금', @@ -41,12 +49,16 @@ const MONTHLY_TABLE_HEADER = [ '누적 수익률', ] -interface Props { - type: 'daily' | 'monthly' - strategyId: number - currentPage: number - onPageChange: (page: number) => void - isEditable?: boolean +const isMyAnalysisData = (data: TableBodyDataType): data is MyDailyAnalysisModel => { + if (!data || typeof data !== 'object' || Array.isArray(data)) return false + + return ( + 'dailyAnalysisId' in data && + 'dailyDate' in data && + 'transaction' in data && + 'dailyProfitLoss' in data && + 'principal' in data + ) } const AnalysisContent = ({ diff --git a/shared/ui/table/vertical/index.tsx b/shared/ui/table/vertical/index.tsx index 1aed8006..9d5ca19e 100644 --- a/shared/ui/table/vertical/index.tsx +++ b/shared/ui/table/vertical/index.tsx @@ -24,18 +24,6 @@ export type TableBodyDataType = | NoticeListContentModel | Array<ReactNode | string | number> -export const isMyAnalysisData = (data: TableBodyDataType): data is MyDailyAnalysisModel => { - if (!data || typeof data !== 'object' || Array.isArray(data)) return false - - return ( - 'dailyAnalysisId' in data && - 'dailyDate' in data && - 'transaction' in data && - 'dailyProfitLoss' in data && - 'principal' in data - ) -} - export interface VerticalTableProps { tableHead: string[] tableBody: TableBodyDataType[] From 8792e5bb95da1ad6a79cd11ed41225ae16348134 Mon Sep 17 00:00:00 2001 From: ssumanlife <ksm9805@naver.com> Date: Mon, 9 Dec 2024 15:19:40 +0900 Subject: [PATCH 028/207] =?UTF-8?q?design:=20=ED=85=8C=EB=B8=94=EB=A6=BF?= =?UTF-8?q?=EC=82=AC=EC=9D=B4=EC=A6=88=20=EC=9C=84=EC=A3=BC=EB=A1=9C=20?= =?UTF-8?q?=EB=B0=98=EC=9D=91=ED=98=95=20=EC=A0=81=EC=9A=A9=20(#18)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../_ui/analysis-container/analysis-chart.tsx | 4 +-- .../details-information/styles.module.scss | 36 +++++++++++++++---- .../_ui/details-side-item/index.tsx | 2 +- .../_ui/details-side-item/side-item.tsx | 16 +-------- .../_ui/details-side-item/styles.module.scss | 10 +++++- .../_ui/introduction/styles.module.scss | 5 +-- app/(dashboard)/_ui/list-header/index.tsx | 8 ++++- .../_ui/list-header/styles.module.scss | 11 +++++- app/(dashboard)/_ui/strategies-item/index.tsx | 2 +- .../strategies-item/strategies-summary.tsx | 2 +- .../_ui/strategies-item/styles.module.scss | 24 ++++++++++++- app/(dashboard)/_ui/subscriber-item/index.tsx | 22 ++++++++++-- .../_ui/subscriber-item/styles.module.scss | 9 +++++ .../_ui/review-container/styles.module.scss | 8 +++-- .../strategies/[strategyId]/page.tsx | 1 + .../strategies/_ui/search-bar/index.tsx | 13 +++---- .../_ui/search-bar/styles.module.scss | 26 +++++++++++++- .../_ui/side-container/styles.module.scss | 3 ++ app/(dashboard)/strategies/layout.module.scss | 3 ++ app/(dashboard)/strategies/page.module.scss | 11 ------ shared/ui/avatar/styles.module.scss | 10 ++++++ shared/ui/search-input/index.tsx | 12 +++++-- shared/ui/table/statistics/styles.module.scss | 8 +++++ shared/ui/table/vertical/styles.module.scss | 11 +++++- shared/ui/tabs/styles.module.scss | 3 ++ 25 files changed, 199 insertions(+), 61 deletions(-) diff --git a/app/(dashboard)/_ui/analysis-container/analysis-chart.tsx b/app/(dashboard)/_ui/analysis-container/analysis-chart.tsx index 8eff7139..e1f5c3f9 100644 --- a/app/(dashboard)/_ui/analysis-container/analysis-chart.tsx +++ b/app/(dashboard)/_ui/analysis-container/analysis-chart.tsx @@ -38,7 +38,7 @@ const AnalysisChart = ({ analysisChartData: data }: Props) => { type: 'areaspline', height: 367, backgroundColor: 'transparent', - margin: [10, 60, 10, 60], + margin: [10, 75, 10, 75], zoomType: 'x', } as Highcharts.ChartOptions, title: { text: undefined }, @@ -86,7 +86,7 @@ const AnalysisChart = ({ analysisChartData: data }: Props) => { align: 'left', verticalAlign: 'top', layout: 'vertical', - x: 40, + x: 70, y: -10, itemStyle: { color: '#4D4D4D', diff --git a/app/(dashboard)/_ui/details-information/styles.module.scss b/app/(dashboard)/_ui/details-information/styles.module.scss index 46224894..1bfee29c 100644 --- a/app/(dashboard)/_ui/details-information/styles.module.scss +++ b/app/(dashboard)/_ui/details-information/styles.module.scss @@ -5,23 +5,30 @@ } .name-container { - width: 30%; + width: 35%; height: 144px; - padding: 20px; + padding: 18px 10px; display: flex; + justify-content: center; flex-direction: column; - gap: 10px; + gap: 4px; border-radius: 5px; background-color: $color-white; .name { - @include typo-h4; + @include typo-b1; } button { - height: 36px; + height: 30px; border-radius: 8px; - background-color: $color-gray-100; + background-color: $color-gray-200; color: $color-gray-700; } + @include tablet-md { + gap: 2px; + button { + font-size: $text-b3; + } + } } .invest-container { @@ -48,6 +55,9 @@ @include typo-b3; color: $color-gray-700; margin-top: 16px; + @include tablet-md { + font-size: $text-c1; + } } } } @@ -71,12 +81,24 @@ .label { @include typo-b2; color: $color-gray-800; + @include tablet-md { + font-size: $text-b3; + } + @include tablet-sm { + font-size: $text-c1; + } } .percent { - @include typo-h3; + @include typo-h4; color: #f53500; &.minus { color: #6877ff; } + @include tablet-md { + font-size: $text-b1; + } + @include tablet-sm { + font-size: $text-b2; + } } } diff --git a/app/(dashboard)/_ui/details-side-item/index.tsx b/app/(dashboard)/_ui/details-side-item/index.tsx index c550f81a..2a51328b 100644 --- a/app/(dashboard)/_ui/details-side-item/index.tsx +++ b/app/(dashboard)/_ui/details-side-item/index.tsx @@ -43,7 +43,7 @@ const DetailsSideItem = ({ <div key={item.title}> <div className={cx('title')}>{item.title}</div> <div className={cx('data')}> - <p>{item.data}</p> + <p>{typeof item.data === 'number' ? item.data.toFixed(2) : item.data}</p> </div> </div> ))} diff --git a/app/(dashboard)/_ui/details-side-item/side-item.tsx b/app/(dashboard)/_ui/details-side-item/side-item.tsx index 265bac8e..ff0a5572 100644 --- a/app/(dashboard)/_ui/details-side-item/side-item.tsx +++ b/app/(dashboard)/_ui/details-side-item/side-item.tsx @@ -1,10 +1,7 @@ 'use client' -import { usePathname, useRouter } from 'next/navigation' - import classNames from 'classnames/bind' -import { PATH } from '@/shared/constants/path' import useModal from '@/shared/hooks/custom/use-modal' import { useAuthStore } from '@/shared/stores/use-auth-store' import Avatar from '@/shared/ui/avatar' @@ -46,12 +43,6 @@ const SideItem = ({ closeModal: guideCloseModal, } = useModal() const user = useAuthStore((state) => state.user) - const router = useRouter() - const path = usePathname() - - const handleRouter = () => { - router.push(`${PATH.MY_STRATEGIES}/manage/${strategyId}`) - } const isTrader = user?.role.includes('TRADER') @@ -66,15 +57,10 @@ const SideItem = ({ <p>{data}</p> </div> {!isMyStrategy && !isTrader && ( - <Button onClick={questionOpenModal} size="small" style={{ height: '30px' }}> + <Button onClick={questionOpenModal} size="small" style={{ padding: '5px 10px' }}> 문의하기 </Button> )} - {isMyStrategy && !path.includes('my') && ( - <Button onClick={handleRouter} size="small" style={{ height: '30px' }}> - 내 전략 관리하기 - </Button> - )} </> ) : ( <p>{formatNumber(data)}</p> diff --git a/app/(dashboard)/_ui/details-side-item/styles.module.scss b/app/(dashboard)/_ui/details-side-item/styles.module.scss index b4393c12..329a9164 100644 --- a/app/(dashboard)/_ui/details-side-item/styles.module.scss +++ b/app/(dashboard)/_ui/details-side-item/styles.module.scss @@ -15,7 +15,7 @@ .side-item, .side-items { - width: 276px; + width: 100%; background-color: $color-white; border-radius: 5px; margin-bottom: 20px; @@ -42,5 +42,13 @@ align-items: center; p { margin: 0 4px 0 11px; + @include tablet-md { + margin: 0 2px 0 4px; + } } } + +.trader-button { + height: 30px; + padding: 0; +} diff --git a/app/(dashboard)/_ui/introduction/styles.module.scss b/app/(dashboard)/_ui/introduction/styles.module.scss index 773a3369..b7a86c06 100644 --- a/app/(dashboard)/_ui/introduction/styles.module.scss +++ b/app/(dashboard)/_ui/introduction/styles.module.scss @@ -16,7 +16,7 @@ display: contents; } p { - @include typo-c1; + @include typo-b3; line-height: 18px; color: $color-gray-600; } @@ -34,7 +34,8 @@ color: $color-gray-500; background-color: transparent; svg { - margin: -3px 0 0 7px; + margin-top: -3px; + width: 28px; } } } diff --git a/app/(dashboard)/_ui/list-header/index.tsx b/app/(dashboard)/_ui/list-header/index.tsx index b8ace106..962fb45d 100644 --- a/app/(dashboard)/_ui/list-header/index.tsx +++ b/app/(dashboard)/_ui/list-header/index.tsx @@ -18,7 +18,13 @@ const ListHeader = ({ type = 'default' }: Props) => { <div className={cx('container', type)}> {LIST_HEADER[type].map((category) => ( <div key={category} className={cx('category')}> - {category} + {category === 'SM SCORE' ? ( + <> + <span>SM</span> <span>SCORE</span> + </> + ) : ( + category + )} </div> ))} </div> diff --git a/app/(dashboard)/_ui/list-header/styles.module.scss b/app/(dashboard)/_ui/list-header/styles.module.scss index 5efb4b05..27dbb8df 100644 --- a/app/(dashboard)/_ui/list-header/styles.module.scss +++ b/app/(dashboard)/_ui/list-header/styles.module.scss @@ -16,10 +16,19 @@ background-color: $color-white; border: 1px solid $color-gray-200; border-radius: 4px; + padding: 0 2px; @include typo-b2; - + @include tablet-md { + font-size: $text-b3; + } &:not(:first-child) { margin-left: 5px; } + & span:nth-child(2) { + margin-left: 2px; + @include tablet-md { + display: none; + } + } } } diff --git a/app/(dashboard)/_ui/strategies-item/index.tsx b/app/(dashboard)/_ui/strategies-item/index.tsx index b78860b9..2f4ee765 100644 --- a/app/(dashboard)/_ui/strategies-item/index.tsx +++ b/app/(dashboard)/_ui/strategies-item/index.tsx @@ -54,7 +54,7 @@ const StrategiesItem = ({ strategiesData: data, type = 'default' }: Props) => { <p>{formatNumber(data.mdd)}</p> </div> <div className={cx('sm-score')}> - <p>{data.smScore}</p> + <p>{data.smScore.toFixed(1)}</p> </div> <div className={cx('profit')}> <span>누적 수익률</span> diff --git a/app/(dashboard)/_ui/strategies-item/strategies-summary.tsx b/app/(dashboard)/_ui/strategies-item/strategies-summary.tsx index 78455cbb..d4c65e08 100644 --- a/app/(dashboard)/_ui/strategies-item/strategies-summary.tsx +++ b/app/(dashboard)/_ui/strategies-item/strategies-summary.tsx @@ -42,7 +42,7 @@ const StrategiesSummary = ({ </div> <div className={cx('total-subscribe-star')}> <p>구독 {subscriptionCount}개</p> - <p>|</p> + <span>|</span> <TotalStar averageRating={averageRating} totalElements={totalReview} textColor="black" /> </div> </div> diff --git a/app/(dashboard)/_ui/strategies-item/styles.module.scss b/app/(dashboard)/_ui/strategies-item/styles.module.scss index 6f871b2d..c1cd4b3f 100644 --- a/app/(dashboard)/_ui/strategies-item/styles.module.scss +++ b/app/(dashboard)/_ui/strategies-item/styles.module.scss @@ -31,6 +31,9 @@ .mdd, .sm-score { @include typo-b2; + @include tablet-md { + font-size: $text-b3; + } } .profit { & span { @@ -39,6 +42,9 @@ p { @include typo-b2; color: $color-orange-500; + @include tablet-md { + font-size: $text-b3; + } } } } @@ -61,6 +67,10 @@ & p { margin-left: 10px; @include typo-b2; + @include tablet-md { + font-size: $text-b3; + margin-left: 4px; + } } } .total-subscribe-star { @@ -69,9 +79,15 @@ height: 20px; gap: 6px; padding-top: 10px; - & p { + & p, + span { @include typo-c1; } + @include tablet-md { + & span { + display: none; + } + } } } @@ -80,6 +96,9 @@ background: transparent; svg { width: 36px; + @include tablet-md { + width: 30px; + } } } } @@ -93,6 +112,9 @@ display: flex; align-items: center; column-gap: 4px; + overflow: hidden; + min-height: 24px; + max-height: 48px; .icon-wrapper { .icon { position: relative; diff --git a/app/(dashboard)/_ui/subscriber-item/index.tsx b/app/(dashboard)/_ui/subscriber-item/index.tsx index 68c73bd0..0d6c69d0 100644 --- a/app/(dashboard)/_ui/subscriber-item/index.tsx +++ b/app/(dashboard)/_ui/subscriber-item/index.tsx @@ -2,7 +2,9 @@ import classNames from 'classnames/bind' +import { PATH } from '@/shared/constants/path' import { Button } from '@/shared/ui/button' +import { LinkButton } from '@/shared/ui/link-button' import styles from './styles.module.scss' @@ -11,11 +13,18 @@ const cx = classNames.bind(styles) interface Props { isMyStrategy?: boolean isSubscribed?: boolean + strategyId?: number subscribers: number onClick?: () => void } -const SubscriberItem = ({ isSubscribed, isMyStrategy = false, subscribers, onClick }: Props) => { +const SubscriberItem = ({ + isSubscribed, + isMyStrategy = false, + strategyId, + subscribers, + onClick, +}: Props) => { return ( <div className={cx('container')}> <div> @@ -23,10 +32,19 @@ const SubscriberItem = ({ isSubscribed, isMyStrategy = false, subscribers, onCli <span>| </span> <span>{subscribers}</span> </div> - {!isMyStrategy && ( + {!isMyStrategy ? ( <Button size="small" variant="filled" onClick={onClick}> {isSubscribed ? '구독취소' : '구독하기'} </Button> + ) : ( + <LinkButton + href={`${PATH.MY_STRATEGIES}/manage/${strategyId}`} + size="small" + variant="filled" + className={cx('trader-button')} + > + 관리하기 + </LinkButton> )} </div> ) diff --git a/app/(dashboard)/_ui/subscriber-item/styles.module.scss b/app/(dashboard)/_ui/subscriber-item/styles.module.scss index 4d49f1e8..79b6f3a3 100644 --- a/app/(dashboard)/_ui/subscriber-item/styles.module.scss +++ b/app/(dashboard)/_ui/subscriber-item/styles.module.scss @@ -13,4 +13,13 @@ color: $color-gray-800; margin-left: 4px; } + .trader-button { + padding: 10px 20px; + } + @include tablet-md { + padding: 0 20px; + span { + font-size: $text-b2; + } + } } diff --git a/app/(dashboard)/strategies/[strategyId]/_ui/review-container/styles.module.scss b/app/(dashboard)/strategies/[strategyId]/_ui/review-container/styles.module.scss index 02376d20..8d911d1b 100644 --- a/app/(dashboard)/strategies/[strategyId]/_ui/review-container/styles.module.scss +++ b/app/(dashboard)/strategies/[strategyId]/_ui/review-container/styles.module.scss @@ -76,13 +76,17 @@ color: $color-gray-500; font-weight: $text-normal; margin-right: 5px; + @include typo-b3; } } } .content { margin: 10px 0; - font-size: 18px; - font-weight: $text-medium; + @include typo-b2; + font-weight: $text-normal; + @include tablet-sm { + font-size: $text-b3; + } } } diff --git a/app/(dashboard)/strategies/[strategyId]/page.tsx b/app/(dashboard)/strategies/[strategyId]/page.tsx index d928a094..36b4441e 100644 --- a/app/(dashboard)/strategies/[strategyId]/page.tsx +++ b/app/(dashboard)/strategies/[strategyId]/page.tsx @@ -81,6 +81,7 @@ const StrategyDetailPage = ({ params }: { params: { strategyId: string } }) => { isSubscribed={information?.isSubscribed} subscribers={information?.subscriptionCount} onClick={openModal} + strategyId={strategyNumber} /> {hasDetailsSideData?.[0] && detailsSideData?.map((data, idx) => ( diff --git a/app/(dashboard)/strategies/_ui/search-bar/index.tsx b/app/(dashboard)/strategies/_ui/search-bar/index.tsx index 77ddba1c..5468fd31 100644 --- a/app/(dashboard)/strategies/_ui/search-bar/index.tsx +++ b/app/(dashboard)/strategies/_ui/search-bar/index.tsx @@ -41,12 +41,6 @@ const SearchBarContainer = () => { } } - const handleEnterSearch = (e: React.KeyboardEvent<HTMLInputElement>) => { - if (e.key === 'Enter') { - onSearch() - } - } - const onReset = async () => { await resetState() if (searchRef.current) { @@ -85,16 +79,17 @@ const SearchBarContainer = () => { return ( <> - <div className={cx('searchInput-wrapper')}> + <div className={cx('search-input-wrapper')}> <SearchInput ref={searchRef} + className={cx('input')} placeholder="전략명을 검색하세요." onChange={handleSearchWord} - onKeyDown={(e) => handleEnterSearch(e)} onSearchIconClick={onSearch} + maxLength={16} /> </div> - <div className={cx('searchInput-wrapper')}> + <div className={cx('search-input-wrapper')}> <SearchBarTab isMainTab={isMainTab} onChangeTab={setIsMainTab} /> {isMainTab ? ACCORDION_MENU.map((menu) => ( diff --git a/app/(dashboard)/strategies/_ui/search-bar/styles.module.scss b/app/(dashboard)/strategies/_ui/search-bar/styles.module.scss index fe548f66..dca9b24a 100644 --- a/app/(dashboard)/strategies/_ui/search-bar/styles.module.scss +++ b/app/(dashboard)/strategies/_ui/search-bar/styles.module.scss @@ -3,11 +3,16 @@ justify-content: space-between; } -.searchInput-wrapper { +.search-input-wrapper { background-color: $color-white; padding: 20px; border: 5px; margin-bottom: 10px; + .input { + @include tablet-md { + width: 180px; + } + } } .search-button-wrapper { @@ -18,9 +23,19 @@ &.initialize { width: 90px; padding: 0; + @include tablet-md { + width: 70px; + } } &.searching { width: 140px; + @include tablet-md { + width: 100px; + padding: 0; + } + } + @include tablet-md { + font-size: $text-b3; } } } @@ -40,6 +55,12 @@ background-color: transparent; color: $color-gray-700; } + @include tablet-md { + width: 100px; + height: 40px; + font-size: $text-b3; + padding: 0; + } } } @@ -163,6 +184,9 @@ color: $color-gray-600; } } + @include tablet-md { + padding: 4px; + } } } diff --git a/app/(dashboard)/strategies/_ui/side-container/styles.module.scss b/app/(dashboard)/strategies/_ui/side-container/styles.module.scss index 9a600262..7b1f75cd 100644 --- a/app/(dashboard)/strategies/_ui/side-container/styles.module.scss +++ b/app/(dashboard)/strategies/_ui/side-container/styles.module.scss @@ -3,4 +3,7 @@ position: absolute; right: 0px; top: 130px; + @include tablet-md { + width: 220px; + } } diff --git a/app/(dashboard)/strategies/layout.module.scss b/app/(dashboard)/strategies/layout.module.scss index 29e26f4b..0c23ece5 100644 --- a/app/(dashboard)/strategies/layout.module.scss +++ b/app/(dashboard)/strategies/layout.module.scss @@ -6,5 +6,8 @@ width: calc(100% - $strategy-sidebar-width); max-width: $max-width; padding-right: 10px; + @include tablet-md { + width: calc(100% - #{$strategy-sidebar-width} + 60px); + } } } diff --git a/app/(dashboard)/strategies/page.module.scss b/app/(dashboard)/strategies/page.module.scss index f53b42ce..6170bfa6 100644 --- a/app/(dashboard)/strategies/page.module.scss +++ b/app/(dashboard)/strategies/page.module.scss @@ -2,17 +2,6 @@ margin-top: 80px; } -.strategy-layout { - display: flex; - position: relative; - - .strategy { - width: calc(100% - $strategy-sidebar-width); - max-width: $max-width; - padding-right: 10px; - } -} - .skeleton-side-bar { width: $strategy-sidebar-width; position: absolute; diff --git a/shared/ui/avatar/styles.module.scss b/shared/ui/avatar/styles.module.scss index 415511fa..1f460c79 100644 --- a/shared/ui/avatar/styles.module.scss +++ b/shared/ui/avatar/styles.module.scss @@ -38,6 +38,16 @@ $svg-xxlarge: 110px; width: $svg-medium; height: $svg-medium; } + + @include tablet-md { + width: $avatar-small; + height: $avatar-small; + + svg { + width: $svg-small; + height: $svg-small; + } + } } &.large { diff --git a/shared/ui/search-input/index.tsx b/shared/ui/search-input/index.tsx index db2c3c41..a3b5639e 100644 --- a/shared/ui/search-input/index.tsx +++ b/shared/ui/search-input/index.tsx @@ -10,18 +10,26 @@ import styles from './styles.module.scss' const cx = classNames.bind(styles) interface Props extends ComponentPropsWithoutRef<'input'> { + className?: string placeholder?: string onSearchIconClick?: () => void } const SearchInput = forwardRef<HTMLInputElement, Props>( - ({ placeholder = '', onSearchIconClick, value, onChange, ...props }: Props, ref) => { + ({ placeholder = '', className, onSearchIconClick, value, onChange, ...props }: Props, ref) => { + const handleEnterSearch = (e: React.KeyboardEvent<HTMLInputElement>) => { + if (e.key === 'Enter' && onSearchIconClick) { + onSearchIconClick() + } + } + return ( - <div className={cx('search-input-container')}> + <div className={cx('search-input-container', className)}> <input ref={ref} value={value} onChange={onChange} + onKeyDown={(e) => handleEnterSearch(e)} placeholder={placeholder} className={cx('search-input')} {...props} diff --git a/shared/ui/table/statistics/styles.module.scss b/shared/ui/table/statistics/styles.module.scss index a4b15362..19452ec8 100644 --- a/shared/ui/table/statistics/styles.module.scss +++ b/shared/ui/table/statistics/styles.module.scss @@ -19,4 +19,12 @@ border: 1px solid $color-white; } } + @include tablet-md { + padding: 10px; + table { + td { + padding: 0 4px; + } + } + } } diff --git a/shared/ui/table/vertical/styles.module.scss b/shared/ui/table/vertical/styles.module.scss index f332e8a5..63d1af3f 100644 --- a/shared/ui/table/vertical/styles.module.scss +++ b/shared/ui/table/vertical/styles.module.scss @@ -11,13 +11,19 @@ } td { - padding: 0 20px; + padding: 0 4px; height: 40px; text-align: start; vertical-align: middle; border-top: 1px solid $color-gray-200; border-bottom: 1px solid $color-gray-200; } + & td:first-child { + padding-left: 20px; + @include tablet-md { + padding-left: 10px; + } + } .button-container { display: flex; @@ -27,6 +33,9 @@ text-align: right; } } + @include tablet-md { + padding: 10px; + } } .no-data { diff --git a/shared/ui/tabs/styles.module.scss b/shared/ui/tabs/styles.module.scss index 3fcdc3d9..68002f11 100644 --- a/shared/ui/tabs/styles.module.scss +++ b/shared/ui/tabs/styles.module.scss @@ -38,4 +38,7 @@ background-color: $color-orange-500; } } + @include tablet-md { + font-size: $text-b2; + } } From 9d073b9fd97507e289f4e321dc695bd377b9b44e Mon Sep 17 00:00:00 2001 From: James <dawn147@snu.ac.kr> Date: Mon, 9 Dec 2024 15:23:58 +0900 Subject: [PATCH 029/207] =?UTF-8?q?feat:=20=EC=97=91=EC=85=80=20=EC=97=85?= =?UTF-8?q?=EB=A1=9C=EB=93=9C=20=EA=B0=80=EC=9D=B4=EB=93=9C=20=EB=8B=A4?= =?UTF-8?q?=EC=9A=B4=EB=A1=9C=EB=93=9C=20=EA=B8=B0=EB=8A=A5=20=EA=B5=AC?= =?UTF-8?q?=ED=98=84=20(#27)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- ...234\353\223\234\354\204\244\353\252\205.xls" | Bin 0 -> 34816 bytes .../form/excel-upload-form.tsx | 11 ++++++++++- 2 files changed, 10 insertions(+), 1 deletion(-) create mode 100644 "public/files/\354\227\221\354\205\200\354\227\205\353\241\234\353\223\234\354\204\244\353\252\205.xls" diff --git "a/public/files/\354\227\221\354\205\200\354\227\205\353\241\234\353\223\234\354\204\244\353\252\205.xls" "b/public/files/\354\227\221\354\205\200\354\227\205\353\241\234\353\223\234\354\204\244\353\252\205.xls" new file mode 100644 index 0000000000000000000000000000000000000000..64f14ad217d3fd15bd9e66fdb38cd59b56519b96 GIT binary patch literal 34816 zcmeHw2{={3`~TeQ;u5k~h-)YNo|GjbWl5=2)<jfPs8ob3EhuY}kZ4gvC|kBlS}1#) zXc3X9XtCbk%)MQ1Tz>WY{lEX``9J@TXU;kE&O4v?op;`uIWuSOQTv4Bb;@<VH(=7C z4d}oakqIzR(J}CTDJ9MhU^<bAqLiy1yoaQJ$NxhXV8X+SWS|Ed&1)DR04!j@1Mmi3 zvvC#h48U6$tidkm4H!aI;q8nn!vCFPDM<$h4AP+-I^Gg^hXrS0-~jVTnsJiOT%>ar ziSHKayaaH9*O+R|$c`P_3t?^mFu)3{OX3SBokd7zHh9K>d{UklB#IK?11*pr4<eAO zg8)E}sVmg2B7oN!JU9Ncspp_PqTh6K|2L`seTkT6EHCp6sTiONZlNlg##Y=j<ON{y z45?T!XNFV^V46{`>Wq2uGv?KvHm?f?0~Su3S9Zo!+>EI$&=aReMm5MWtyU<n&a_&g zRA!o~f;bgG53P(50qF!0APQt*5Cw|B4oCwn*yyFe<P>X|B8Xc82L*xYvTXvZffLvQ zY-Rw^0gPb&Q~-G>dnW1Rbg~Y{u#t+)3^B7N)L^b)0Z;}QyYUh)0E8*__&pU_XToAg zCoO0<ip5ZV5g-Qx*_J3pmvk?PlZU+00N6<_Ac)F}iYnSkYoU8<DN@Xu921cfA<=d1 zqy=&NVa+U>UOj5F{jS3AN=}rRwyHYNi>9ip4rG85mAdkZlQf2e38i&(Ce-GGzQqFv z4>3wHt)+h5Tf;&Lfw{1^rApnj+G&f;Z!84Z(esb9kOhx3ki-h Y_63_2QzZhje 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 literal 0 HcmV?d00001 diff --git a/shared/ui/modal/analysis-upload-modal/form/excel-upload-form.tsx b/shared/ui/modal/analysis-upload-modal/form/excel-upload-form.tsx index cc54f5a2..a633b19d 100644 --- a/shared/ui/modal/analysis-upload-modal/form/excel-upload-form.tsx +++ b/shared/ui/modal/analysis-upload-modal/form/excel-upload-form.tsx @@ -32,7 +32,16 @@ const ExcelUploadForm = ({ strategyId, onClose }: Props) => { } const handleGuidDownload = () => { - //TODO 업로드 가이드 다운로드 api 없음 ;; + const fileName = '엑셀업로드설명.xls' + const filePath = `/files/${encodeURIComponent(fileName)}` + + const link = document.createElement('a') + link.href = filePath + link.download = fileName + + document.body.appendChild(link) + link.click() + document.body.removeChild(link) } const handleSubmit = async () => { From 5effcbe7c1b3df65cdee5b8907f997e8ba7633b2 Mon Sep 17 00:00:00 2001 From: James <dawn147@snu.ac.kr> Date: Mon, 9 Dec 2024 15:48:26 +0900 Subject: [PATCH 030/207] =?UTF-8?q?feat:=20a=20=ED=83=9C=EA=B7=B8=20?= =?UTF-8?q?=ED=99=9C=EC=9A=A9=ED=95=98=EC=97=AC=20=ED=8C=8C=EC=9D=BC=20?= =?UTF-8?q?=EB=8B=A4=EC=9A=B4=EB=A1=9C=EB=93=9C=20(#27)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../form/excel-upload-form.tsx | 21 +++++-------------- 1 file changed, 5 insertions(+), 16 deletions(-) diff --git a/shared/ui/modal/analysis-upload-modal/form/excel-upload-form.tsx b/shared/ui/modal/analysis-upload-modal/form/excel-upload-form.tsx index a633b19d..7d864554 100644 --- a/shared/ui/modal/analysis-upload-modal/form/excel-upload-form.tsx +++ b/shared/ui/modal/analysis-upload-modal/form/excel-upload-form.tsx @@ -31,19 +31,6 @@ const ExcelUploadForm = ({ strategyId, onClose }: Props) => { } } - const handleGuidDownload = () => { - const fileName = '엑셀업로드설명.xls' - const filePath = `/files/${encodeURIComponent(fileName)}` - - const link = document.createElement('a') - link.href = filePath - link.download = fileName - - document.body.appendChild(link) - link.click() - document.body.removeChild(link) - } - const handleSubmit = async () => { if (!file) return @@ -81,9 +68,11 @@ const ExcelUploadForm = ({ strategyId, onClose }: Props) => { <FileIcon /> </button> </label> - <Button variant="outline" className={cx('guide-button')} onClick={handleGuidDownload}> - 업로드 가이드 다운 - </Button> + <a href="/files/엑셀업로드설명.xls" download="엑셀업로드설명.xls"> + <Button variant="outline" className={cx('guide-button')} disabled={isLoading}> + 업로드 가이드 다운 + </Button> + </a> </div> {error && <p className={cx('error-message')}>{error}</p>} From ef52fc43ef231d375e2e119e170900de26796993 Mon Sep 17 00:00:00 2001 From: James <dawn147@snu.ac.kr> Date: Mon, 9 Dec 2024 16:08:04 +0900 Subject: [PATCH 031/207] =?UTF-8?q?fix:=20button=20=EC=95=88=EC=97=90=20a?= =?UTF-8?q?=20=ED=83=9C=EA=B7=B8=20=EB=93=A4=EC=96=B4=EA=B0=80=EB=8F=84?= =?UTF-8?q?=EB=A1=9D=20=EC=88=98=EC=A0=95=20(#27)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../analysis-upload-modal/form/excel-upload-form.tsx | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/shared/ui/modal/analysis-upload-modal/form/excel-upload-form.tsx b/shared/ui/modal/analysis-upload-modal/form/excel-upload-form.tsx index 7d864554..1f711fa1 100644 --- a/shared/ui/modal/analysis-upload-modal/form/excel-upload-form.tsx +++ b/shared/ui/modal/analysis-upload-modal/form/excel-upload-form.tsx @@ -68,11 +68,11 @@ const ExcelUploadForm = ({ strategyId, onClose }: Props) => { <FileIcon /> </button> </label> - <a href="/files/엑셀업로드설명.xls" download="엑셀업로드설명.xls"> - <Button variant="outline" className={cx('guide-button')} disabled={isLoading}> + <Button variant="outline" className={cx('guide-button')} disabled={isLoading}> + <a href="/files/엑셀업로드설명.xls" download="엑셀업로드설명.xls"> 업로드 가이드 다운 - </Button> - </a> + </a> + </Button> </div> {error && <p className={cx('error-message')}>{error}</p>} From 031e18ba29fc19bc316e04fec218f8ebad08bfa3 Mon Sep 17 00:00:00 2001 From: James <dawn147@snu.ac.kr> Date: Mon, 9 Dec 2024 16:33:50 +0900 Subject: [PATCH 032/207] =?UTF-8?q?feat:=20=EB=A1=9C=EA=B7=B8=EC=9D=B8?= =?UTF-8?q?=EC=B2=B4=ED=81=AC=20=EB=AA=A8=EB=8B=AC=20=EC=95=84=EB=8B=88?= =?UTF-8?q?=EC=98=A4=20=EB=B2=84=ED=8A=BC=20=ED=81=B4=EB=A6=AD=EC=8A=A4=20?= =?UTF-8?q?=EC=A0=84=EB=9E=B5=20=EB=9E=AD=ED=82=B9=20=ED=8E=98=EC=9D=B4?= =?UTF-8?q?=EC=A7=80=EB=A1=9C=20=EB=A6=AC=EB=8B=A4=EC=9D=B4=EB=A0=89?= =?UTF-8?q?=ED=8C=85=20(#33)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- shared/ui/modal/signin-check-modal.tsx | 11 ++++++++++- 1 file changed, 10 insertions(+), 1 deletion(-) diff --git a/shared/ui/modal/signin-check-modal.tsx b/shared/ui/modal/signin-check-modal.tsx index 8b88ad1a..93c45f80 100644 --- a/shared/ui/modal/signin-check-modal.tsx +++ b/shared/ui/modal/signin-check-modal.tsx @@ -2,9 +2,13 @@ import React from 'react' +import { useRouter } from 'next/navigation' + import { ModalAlertIcon } from '@/public/icons' import classNames from 'classnames/bind' +import { PATH } from '@/shared/constants/path' + import Modal from '.' import { Button } from '../button' import styles from './styles.module.scss' @@ -18,6 +22,11 @@ interface Props { } const SigninCheckModal = ({ isModalOpen, onCloseModal, onConfirm }: Props) => { + const router = useRouter() + const handleModalClose = () => { + router.replace(PATH.STRATEGIES) + onCloseModal() + } return ( <Modal isOpen={isModalOpen} icon={ModalAlertIcon}> <span className={cx('message')}> @@ -26,7 +35,7 @@ const SigninCheckModal = ({ isModalOpen, onCloseModal, onConfirm }: Props) => { 로그인 하시겠습니까? </span> <div className={cx('two-button')}> - <Button onClick={onCloseModal}>아니오</Button> + <Button onClick={handleModalClose}>아니오</Button> <Button onClick={onConfirm} variant="filled" className={cx('button')}> 예 </Button> From 598c10bc68a04235bb3ec174dc8c145917af9fbd Mon Sep 17 00:00:00 2001 From: deun <devdeun@gmail.com> Date: Mon, 9 Dec 2024 16:37:15 +0900 Subject: [PATCH 033/207] =?UTF-8?q?feat:=20=EA=B5=AC=EB=8F=85=20=EB=B2=84?= =?UTF-8?q?=ED=8A=BC=20=EB=88=84=EB=A5=BC=20=EB=95=8C=20=EC=BF=BC=EB=A6=AC?= =?UTF-8?q?=ED=82=A4=20=EC=B6=94=EA=B0=80=20(#29)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../strategies/_hooks/query/use-get-subscribe.ts | 7 ++++++- 1 file changed, 6 insertions(+), 1 deletion(-) diff --git a/app/(dashboard)/strategies/_hooks/query/use-get-subscribe.ts b/app/(dashboard)/strategies/_hooks/query/use-get-subscribe.ts index c50532f4..fd00acca 100644 --- a/app/(dashboard)/strategies/_hooks/query/use-get-subscribe.ts +++ b/app/(dashboard)/strategies/_hooks/query/use-get-subscribe.ts @@ -1,10 +1,15 @@ -import { useMutation } from '@tanstack/react-query' +import { useMutation, useQueryClient } from '@tanstack/react-query' import getSubscribe from '../../_api/get-subscribe' const useGetSubscribe = () => { + const queryClient = useQueryClient() + return useMutation({ mutationFn: (strategyId: number) => getSubscribe(strategyId), + onSuccess: () => { + queryClient.invalidateQueries({ queryKey: ['favoriteStrategies'] }) + }, }) } From 3facb0e4c33ce62edfb333d4b0f3104e6e9aa229 Mon Sep 17 00:00:00 2001 From: ssumanlife <ksm9805@naver.com> Date: Mon, 9 Dec 2024 16:44:09 +0900 Subject: [PATCH 034/207] =?UTF-8?q?fix:=20=EC=A4=91=EB=B3=B5=EB=90=9C=20?= =?UTF-8?q?=EC=8A=A4=ED=83=80=EC=9D=BC=20=EC=A0=9C=EA=B1=B0=20(#34)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- app/(dashboard)/_ui/strategies-item/styles.module.scss | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/app/(dashboard)/_ui/strategies-item/styles.module.scss b/app/(dashboard)/_ui/strategies-item/styles.module.scss index c1cd4b3f..79cde571 100644 --- a/app/(dashboard)/_ui/strategies-item/styles.module.scss +++ b/app/(dashboard)/_ui/strategies-item/styles.module.scss @@ -114,7 +114,7 @@ column-gap: 4px; overflow: hidden; min-height: 24px; - max-height: 48px; + max-height: 44px; .icon-wrapper { .icon { position: relative; @@ -134,7 +134,6 @@ } &.details { flex-wrap: wrap; - max-height: 50px; row-gap: 1px; } } From 08b06bb1959adc40398e6ba9c67e3055a55135ba Mon Sep 17 00:00:00 2001 From: ssumanlife <ksm9805@naver.com> Date: Mon, 9 Dec 2024 16:44:35 +0900 Subject: [PATCH 035/207] =?UTF-8?q?design:=20overflow=20=EC=8A=A4=ED=81=AC?= =?UTF-8?q?=EB=A1=A4=20=EC=A0=81=EC=9A=A9=20(#34)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../_ui/details-information/styles.module.scss | 10 +++++++++- 1 file changed, 9 insertions(+), 1 deletion(-) diff --git a/app/(dashboard)/_ui/details-information/styles.module.scss b/app/(dashboard)/_ui/details-information/styles.module.scss index 1bfee29c..db26037d 100644 --- a/app/(dashboard)/_ui/details-information/styles.module.scss +++ b/app/(dashboard)/_ui/details-information/styles.module.scss @@ -36,7 +36,7 @@ height: 144px; display: flex; gap: 20px; - padding: 30px; + padding: 20px; border-radius: 5px; background-color: $color-white; .info-item { @@ -44,6 +44,7 @@ height: 100%; border-right: 1px solid $color-gray-200; padding-right: 4px; + overflow-y: auto; &:last-child { border-right: 0; } @@ -59,6 +60,13 @@ font-size: $text-c1; } } + &::-webkit-scrollbar { + width: 6px; + } + &::-webkit-scrollbar-thumb { + background-color: $color-gray-200; + border-radius: 10px; + } } } From f89da23b7d85ac0d4b16f9d668c1cae06a3a24a6 Mon Sep 17 00:00:00 2001 From: deun <devdeun@gmail.com> Date: Mon, 9 Dec 2024 17:08:57 +0900 Subject: [PATCH 036/207] =?UTF-8?q?remove:=20=EC=82=AC=EC=9A=A9=ED=95=98?= =?UTF-8?q?=EC=A7=80=20=EC=95=8A=EB=8A=94=20=EC=BF=BC=EB=A6=AC=20=EC=82=AD?= =?UTF-8?q?=EC=A0=9C=20(#40)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../my/questions/[questionId]/_ui/question-container/index.tsx | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/app/(dashboard)/my/questions/[questionId]/_ui/question-container/index.tsx b/app/(dashboard)/my/questions/[questionId]/_ui/question-container/index.tsx index 44416d4a..f137b0d2 100644 --- a/app/(dashboard)/my/questions/[questionId]/_ui/question-container/index.tsx +++ b/app/(dashboard)/my/questions/[questionId]/_ui/question-container/index.tsx @@ -4,7 +4,6 @@ import { useRef, useState } from 'react' import { useParams, useRouter } from 'next/navigation' -import usePostQuestion from '@/app/(dashboard)/strategies/[strategyId]/_hooks/query/use-post-question' import classNames from 'classnames/bind' import { PATH } from '@/shared/constants/path' @@ -38,7 +37,7 @@ const QuestionContainer = () => { const { mutate: submitAnswer } = usePostAnswer(parseInt(questionId as string)) const { mutate: deleteAnswer } = useDeleteAnswer() const { mutate: deleteQuestion } = useDeleteQuestion() - const { mutate: postQuestion } = usePostQuestion() + const { data: questionDetails } = useGetQuestionDetails({ questionId: parseInt(questionId as string), }) From 4940e38f683aad75ac7f72591e59f06c0bb692ee Mon Sep 17 00:00:00 2001 From: deun <devdeun@gmail.com> Date: Mon, 9 Dec 2024 17:10:07 +0900 Subject: [PATCH 037/207] =?UTF-8?q?refactor:=20react=20=EC=A0=84=EC=B2=B4?= =?UTF-8?q?=20=EB=8C=80=EC=8B=A0=20Fragment=EB=A7=8C=20import=20(#40)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../[questionId]/_ui/question-detail-card/index.tsx | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/app/(dashboard)/my/questions/[questionId]/_ui/question-detail-card/index.tsx b/app/(dashboard)/my/questions/[questionId]/_ui/question-detail-card/index.tsx index e1f21245..b3b39ee0 100644 --- a/app/(dashboard)/my/questions/[questionId]/_ui/question-detail-card/index.tsx +++ b/app/(dashboard)/my/questions/[questionId]/_ui/question-detail-card/index.tsx @@ -1,6 +1,6 @@ 'use client' -import React from 'react' +import { Fragment } from 'react' import classNames from 'classnames/bind' @@ -62,10 +62,10 @@ const QuestionDetailCard = ({ </div> <div className={cx('card-contents')}> {contents.split('\n').map((line, idx) => ( - <React.Fragment key={line + idx}> + <Fragment key={line + idx}> {line} <br /> - </React.Fragment> + </Fragment> ))} </div> </div> From b281cf6ee007f93cc670fb04004750a5b20a97a7 Mon Sep 17 00:00:00 2001 From: kimpra <dustmqaksdltkfrlfdldi@gmail.com> Date: Mon, 9 Dec 2024 17:11:28 +0900 Subject: [PATCH 038/207] fix: restore branch data --- .../questions/_api/delete-admin-question.ts | 21 +++++++++++++ .../_api/set-admin-question-table-body.tsx | 27 +++++----------- .../_hooks/query/use-delete-question.ts | 18 +++++++++++ .../admin-question-delete-button/index.tsx | 31 +++++++++++++++++++ .../styles.module.scss | 6 ++++ .../_ui/admin-question-view-detail/index.tsx | 30 ++++++++++++++++++ .../styles.module.scss | 6 ++++ app/admin/questions/page.tsx | 2 +- app/admin/questions/types.ts | 17 ++++++++-- 9 files changed, 134 insertions(+), 24 deletions(-) create mode 100644 app/admin/questions/_api/delete-admin-question.ts create mode 100644 app/admin/questions/_hooks/query/use-delete-question.ts create mode 100644 app/admin/questions/_ui/admin-question-delete-button/index.tsx create mode 100644 app/admin/questions/_ui/admin-question-delete-button/styles.module.scss create mode 100644 app/admin/questions/_ui/admin-question-view-detail/index.tsx create mode 100644 app/admin/questions/_ui/admin-question-view-detail/styles.module.scss diff --git a/app/admin/questions/_api/delete-admin-question.ts b/app/admin/questions/_api/delete-admin-question.ts new file mode 100644 index 00000000..3dea6fb5 --- /dev/null +++ b/app/admin/questions/_api/delete-admin-question.ts @@ -0,0 +1,21 @@ +import axiosInstance from '@/shared/api/axios' + +import { AdminQuestionsResponeseModel } from '../types' + +interface ArgModel { + strategyId: number + questionId: number +} +const deleteAdminQuestion = async ({ strategyId, questionId }: ArgModel) => { + try { + const res = await axiosInstance.delete<AdminQuestionsResponeseModel>( + `/api/admin/strategies/${strategyId}/questions/${questionId}` + ) + if (!res.data.isSuccess) throw new Error('Error with code' + res.data.code) + return res.data.result + } catch (err) { + console.error(err) + throw err + } +} +export default deleteAdminQuestion diff --git a/app/admin/questions/_api/set-admin-question-table-body.tsx b/app/admin/questions/_api/set-admin-question-table-body.tsx index c191116e..e010bb99 100644 --- a/app/admin/questions/_api/set-admin-question-table-body.tsx +++ b/app/admin/questions/_api/set-admin-question-table-body.tsx @@ -1,5 +1,6 @@ import { Button } from '@/shared/ui/button' +import AdminQuestionDeleteButton from '../_ui/admin-question-delete-button' import AdminQuestionStateBox from '../_ui/admin-question-state-box' import { AdminQuestionsResponeseModel } from '../types' @@ -8,14 +9,11 @@ const setAdminQuestionTableBody = (data: AdminQuestionsResponeseModel['result'][ return [ idx + 1, data.title, - data.strategyName, - data.questionId, // 질문 받은 사람으로 수정 - data.nickname, - <AdminQuestionStateBox - questionState={data.stateCondition} - key={data.createdAt + data.nickname} - />, - <Button.ButtonGroup gap="24px" key={data.createdAt + data.nickname}> + data.strategy.name, + data.trader.userName, + data.investor.userName, + <AdminQuestionStateBox questionState={data.stateCondition} key={data.questionId} />, + <Button.ButtonGroup gap="24px" key={data.questionId}> <Button size="small" style={{ @@ -27,18 +25,7 @@ const setAdminQuestionTableBody = (data: AdminQuestionsResponeseModel['result'][ > 상세보기 </Button> - <Button - variant="filled" - size="small" - style={{ - width: 'fit-content', - height: '30px', - padding: '7px 16px', - borderRadius: '16px', - }} - > - 삭제 - </Button> + <AdminQuestionDeleteButton questionId={data.questionId} strategyId={data.strategy.id} /> </Button.ButtonGroup>, ] }) diff --git a/app/admin/questions/_hooks/query/use-delete-question.ts b/app/admin/questions/_hooks/query/use-delete-question.ts new file mode 100644 index 00000000..109f53c2 --- /dev/null +++ b/app/admin/questions/_hooks/query/use-delete-question.ts @@ -0,0 +1,18 @@ +import { useMutation, useQueryClient } from '@tanstack/react-query' + +import deleteAdminQuestion from '../../_api/delete-admin-question' + +interface ArgModel { + strategyId: number + questionId: number +} +const useDeleteQuestion = ({ strategyId, questionId }: ArgModel) => { + const queryClient = useQueryClient() + return useMutation({ + mutationFn: () => deleteAdminQuestion({ strategyId, questionId }), + onSuccess: () => { + queryClient.invalidateQueries({ queryKey: ['adminUsers'] }) + }, + }) +} +export default useDeleteQuestion diff --git a/app/admin/questions/_ui/admin-question-delete-button/index.tsx b/app/admin/questions/_ui/admin-question-delete-button/index.tsx new file mode 100644 index 00000000..5f86135c --- /dev/null +++ b/app/admin/questions/_ui/admin-question-delete-button/index.tsx @@ -0,0 +1,31 @@ +'use client' + +import classNames from 'classnames/bind' + +import { Button } from '@/shared/ui/button' + +import useDeleteQuestion from '../../_hooks/query/use-delete-question' +import styles from './styles.module.scss' + +const cx = classNames.bind(styles) +interface Props { + questionId: number + strategyId: number +} +const AdminQuestionDeleteButton = ({ questionId, strategyId }: Props) => { + const { mutate, isPending } = useDeleteQuestion({ strategyId, questionId }) + const onClick = () => mutate() + return ( + <Button + variant="filled" + onClick={onClick} + disabled={isPending} + size="small" + style={{ padding: '7px 16px' }} + className={cx('button')} + > + 삭제 + </Button> + ) +} +export default AdminQuestionDeleteButton diff --git a/app/admin/questions/_ui/admin-question-delete-button/styles.module.scss b/app/admin/questions/_ui/admin-question-delete-button/styles.module.scss new file mode 100644 index 00000000..c54954dc --- /dev/null +++ b/app/admin/questions/_ui/admin-question-delete-button/styles.module.scss @@ -0,0 +1,6 @@ +.button { + width: fit-content; + height: 30px; + padding: 7px 16px; + border-radius: 16px; +} diff --git a/app/admin/questions/_ui/admin-question-view-detail/index.tsx b/app/admin/questions/_ui/admin-question-view-detail/index.tsx new file mode 100644 index 00000000..e3b5c410 --- /dev/null +++ b/app/admin/questions/_ui/admin-question-view-detail/index.tsx @@ -0,0 +1,30 @@ +'use client' + +import useDeleteQuestion from '@/app/(dashboard)/my/questions/_hooks/query/use-delete-question' +import classNames from 'classnames/bind' + +import { Button } from '@/shared/ui/button' + +import styles from './styles.module.scss' + +const cx = classNames.bind(styles) +interface Props { + questionId: number + strategyId: number +} +const AdminQuestionViewDetailButton = ({ questionId, strategyId }: Props) => { + // const { mutate, isPending } = useDeleteQuestion({ strategyId, questionId }) + return ( + <Button + variant="filled" + // onClick={() => mutate()} + // disabled={isPending} + size="small" + style={{ padding: '7px 16px' }} + className={cx('button')} + > + 삭제 + </Button> + ) +} +export default AdminQuestionViewDetailButton diff --git a/app/admin/questions/_ui/admin-question-view-detail/styles.module.scss b/app/admin/questions/_ui/admin-question-view-detail/styles.module.scss new file mode 100644 index 00000000..c54954dc --- /dev/null +++ b/app/admin/questions/_ui/admin-question-view-detail/styles.module.scss @@ -0,0 +1,6 @@ +.button { + width: fit-content; + height: 30px; + padding: 7px 16px; + border-radius: 16px; +} diff --git a/app/admin/questions/page.tsx b/app/admin/questions/page.tsx index 22bf2e3a..fc130be4 100644 --- a/app/admin/questions/page.tsx +++ b/app/admin/questions/page.tsx @@ -4,7 +4,7 @@ import classNames from 'classnames/bind' import { QuestionSearchConditionType } from '@/shared/types/questions' import Pagination from '@/shared/ui/pagination' -import { SearchInput } from '@/shared/ui/search-input' +import SearchInput from '@/shared/ui/search-input' import Select from '@/shared/ui/select' import VerticalTable from '@/shared/ui/table/vertical' import Tabs from '@/shared/ui/tabs' diff --git a/app/admin/questions/types.ts b/app/admin/questions/types.ts index b3cfb992..5d6f92d9 100644 --- a/app/admin/questions/types.ts +++ b/app/admin/questions/types.ts @@ -4,12 +4,23 @@ import { APIResponseBaseModel } from '@/shared/types/response' export interface AdminQuestionsResponeseModel extends APIResponseBaseModel<boolean> { result: { content: Array<{ + strategy: { + id: number + name: string + } + investor: { + id: number + userName: string + profileImageUrl: string + } + trader: { + id: number + userName: string + profileImageUrl: string + } questionId: number title: string questionContent: string - strategyName: string - profileImageUrl: string - nickname: string stateCondition: QuestionStateConditionType createdAt: string }> From 3ac201e41b9a465db6abde0bd46246d766f2c2d8 Mon Sep 17 00:00:00 2001 From: deun <devdeun@gmail.com> Date: Mon, 9 Dec 2024 17:12:18 +0900 Subject: [PATCH 039/207] =?UTF-8?q?feat:=20question=20detail=20card=20?= =?UTF-8?q?=EC=8A=A4=ED=86=A0=EB=A6=AC=EB=B6=81=EC=97=90=20status=20?= =?UTF-8?q?=EC=B6=94=EA=B0=80=20(#40)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../_ui/question-detail-card/question-detail-card.stories.tsx | 1 + 1 file changed, 1 insertion(+) diff --git a/app/(dashboard)/my/questions/[questionId]/_ui/question-detail-card/question-detail-card.stories.tsx b/app/(dashboard)/my/questions/[questionId]/_ui/question-detail-card/question-detail-card.stories.tsx index 65d61a95..f778b621 100644 --- a/app/(dashboard)/my/questions/[questionId]/_ui/question-detail-card/question-detail-card.stories.tsx +++ b/app/(dashboard)/my/questions/[questionId]/_ui/question-detail-card/question-detail-card.stories.tsx @@ -24,6 +24,7 @@ Question.args = { nickname: '투자초보', profileImage: '', createdAt: '2024-11-03T15:00:00', + status: '답변 대기', isAuthor: false, onDelete: () => alert('삭제 버튼 클릭'), } From c2455ba36afdf837edc67258b240e88eb7a7f807 Mon Sep 17 00:00:00 2001 From: deun <devdeun@gmail.com> Date: Mon, 9 Dec 2024 17:33:43 +0900 Subject: [PATCH 040/207] =?UTF-8?q?rename:=20modal=20=ED=8C=8C=EC=9D=BC=20?= =?UTF-8?q?=EC=9C=84=EC=B9=98=20=EC=9D=B4=EB=8F=99=20(#40)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../my/questions/[questionId]/_ui/question-container/index.tsx | 2 +- .../ui/modal/question-delete-modal/index.tsx | 0 .../ui/modal/question-delete-modal}/styles.module.scss | 0 3 files changed, 1 insertion(+), 1 deletion(-) rename app/(dashboard)/my/questions/_ui/modal/question-delete-modal.tsx => shared/ui/modal/question-delete-modal/index.tsx (100%) rename {app/(dashboard)/my/questions/_ui/modal => shared/ui/modal/question-delete-modal}/styles.module.scss (100%) diff --git a/app/(dashboard)/my/questions/[questionId]/_ui/question-container/index.tsx b/app/(dashboard)/my/questions/[questionId]/_ui/question-container/index.tsx index f137b0d2..fe487769 100644 --- a/app/(dashboard)/my/questions/[questionId]/_ui/question-container/index.tsx +++ b/app/(dashboard)/my/questions/[questionId]/_ui/question-container/index.tsx @@ -11,13 +11,13 @@ import { useAuthStore } from '@/shared/stores/use-auth-store' import { Button } from '@/shared/ui/button' import { ErrorMessage } from '@/shared/ui/error-message' import AddQuestionModal from '@/shared/ui/modal/add-question-modal' +import QuestionDeleteModal from '@/shared/ui/modal/question-delete-modal' import Textarea from '@/shared/ui/textarea' import useDeleteAnswer from '../../../_hooks/query/use-delete-answer' import useDeleteQuestion from '../../../_hooks/query/use-delete-question' import useGetQuestionDetails from '../../../_hooks/query/use-get-question-details' import usePostAnswer from '../../../_hooks/query/use-post-answer' -import QuestionDeleteModal from '../../../_ui/modal/question-delete-modal' import QuestionDetailCard from '../question-detail-card' import styles from './styles.module.scss' diff --git a/app/(dashboard)/my/questions/_ui/modal/question-delete-modal.tsx b/shared/ui/modal/question-delete-modal/index.tsx similarity index 100% rename from app/(dashboard)/my/questions/_ui/modal/question-delete-modal.tsx rename to shared/ui/modal/question-delete-modal/index.tsx diff --git a/app/(dashboard)/my/questions/_ui/modal/styles.module.scss b/shared/ui/modal/question-delete-modal/styles.module.scss similarity index 100% rename from app/(dashboard)/my/questions/_ui/modal/styles.module.scss rename to shared/ui/modal/question-delete-modal/styles.module.scss From f9d129e08a862e57c9d4fc671f5b3df69479b7e9 Mon Sep 17 00:00:00 2001 From: deun <devdeun@gmail.com> Date: Mon, 9 Dec 2024 17:36:46 +0900 Subject: [PATCH 041/207] =?UTF-8?q?refactor:=20=EC=98=88=EC=99=B8=20?= =?UTF-8?q?=EC=A1=B0=EA=B1=B4=20=EB=8B=A8=EC=88=9C=ED=99=94=20(#40)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../_ui/questions-tab-content/index.tsx | 28 +++++++++---------- 1 file changed, 13 insertions(+), 15 deletions(-) diff --git a/app/(dashboard)/my/questions/_ui/questions-tab-content/index.tsx b/app/(dashboard)/my/questions/_ui/questions-tab-content/index.tsx index e7225d35..0f4f8cfe 100644 --- a/app/(dashboard)/my/questions/_ui/questions-tab-content/index.tsx +++ b/app/(dashboard)/my/questions/_ui/questions-tab-content/index.tsx @@ -42,21 +42,19 @@ const QuestionsTabContent = ({ options }: Props) => { return ( <> <ul className={cx('question-list')}> - {questionsData && - !!questionsData.length && - questionsData.map((question) => ( - <li key={question.questionId}> - <QuestionCard - questionId={question.questionId} - strategyName={question.strategyName} - title={question.title} - questionState={question.stateCondition} - contents={question.questionContent} - nickname={question.nickname} - createdAt={question.createdAt} - /> - </li> - ))} + {questionsData?.map((question) => ( + <li key={question.questionId}> + <QuestionCard + questionId={question.questionId} + strategyName={question.strategyName} + title={question.title} + questionState={question.stateCondition} + contents={question.questionContent} + nickname={question.nickname} + createdAt={question.createdAt} + /> + </li> + ))} </ul> {(!questionsData || !questionsData.length) && ( From 211fbaf7e4a88830e8b11cc141d9af594fa4da5c Mon Sep 17 00:00:00 2001 From: ssumanlife <ksm9805@naver.com> Date: Mon, 9 Dec 2024 18:24:35 +0900 Subject: [PATCH 042/207] =?UTF-8?q?feat:=20=EB=AC=B8=EC=9D=98=20=EB=82=B4?= =?UTF-8?q?=EC=97=AD=20=EA=B2=80=EC=83=89=20api=20=EC=97=B0=EA=B2=B0=20(#2?= =?UTF-8?q?66)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- app/admin/questions/page.tsx | 7 +++++-- 1 file changed, 5 insertions(+), 2 deletions(-) diff --git a/app/admin/questions/page.tsx b/app/admin/questions/page.tsx index fc130be4..05c43f0f 100644 --- a/app/admin/questions/page.tsx +++ b/app/admin/questions/page.tsx @@ -33,7 +33,7 @@ const AdminQuestionsPage = () => { onTabChange, } = useAdminQuestionsPage() - const { isLoading, data } = useAdminQuestions({ + const { isLoading, data, refetch } = useAdminQuestions({ ...searchParams, stateCondition, }) @@ -67,7 +67,10 @@ const AdminQuestionsPage = () => { <SearchInput value={keyword} onChange={(e) => setKeyword(e.target.value)} - onSearchIconClick={setConditionAndKeyword} + onSearchIconClick={() => { + setConditionAndKeyword() + refetch() + }} /> </div> } From 2b1212a809487c1615539fa48a7aa1577fbd594b Mon Sep 17 00:00:00 2001 From: ssumanlife <ksm9805@naver.com> Date: Mon, 9 Dec 2024 18:25:31 +0900 Subject: [PATCH 043/207] =?UTF-8?q?fix:=20=EC=83=81=EC=84=B8=ED=8E=98?= =?UTF-8?q?=EC=9D=B4=EC=A7=80=20=EC=9D=B4=EB=8F=99=EC=9D=84=20=EC=9C=84?= =?UTF-8?q?=ED=95=B4=20LinkButton=EC=9C=BC=EB=A1=9C=20=EC=88=98=EC=A0=95?= =?UTF-8?q?=20(#266)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../questions/_api/set-admin-question-table-body.tsx | 12 +++++++----- 1 file changed, 7 insertions(+), 5 deletions(-) diff --git a/app/admin/questions/_api/set-admin-question-table-body.tsx b/app/admin/questions/_api/set-admin-question-table-body.tsx index e010bb99..c77b1c5b 100644 --- a/app/admin/questions/_api/set-admin-question-table-body.tsx +++ b/app/admin/questions/_api/set-admin-question-table-body.tsx @@ -1,4 +1,5 @@ -import { Button } from '@/shared/ui/button' +import { PATH } from '@/shared/constants/path' +import { LinkButton } from '@/shared/ui/link-button' import AdminQuestionDeleteButton from '../_ui/admin-question-delete-button' import AdminQuestionStateBox from '../_ui/admin-question-state-box' @@ -13,8 +14,9 @@ const setAdminQuestionTableBody = (data: AdminQuestionsResponeseModel['result'][ data.trader.userName, data.investor.userName, <AdminQuestionStateBox questionState={data.stateCondition} key={data.questionId} />, - <Button.ButtonGroup gap="24px" key={data.questionId}> - <Button + <div style={{ display: 'flex', gap: '24px' }} key={data.questionId}> + <LinkButton + href={`${PATH.MY_QUESTIONS}/${data.questionId}`} size="small" style={{ width: 'fit-content', @@ -24,9 +26,9 @@ const setAdminQuestionTableBody = (data: AdminQuestionsResponeseModel['result'][ }} > 상세보기 - </Button> + </LinkButton> <AdminQuestionDeleteButton questionId={data.questionId} strategyId={data.strategy.id} /> - </Button.ButtonGroup>, + </div>, ] }) From df347c0953636949de7e3f6b813fbe438cb0edb1 Mon Sep 17 00:00:00 2001 From: ssumanlife <ksm9805@naver.com> Date: Mon, 9 Dec 2024 18:26:05 +0900 Subject: [PATCH 044/207] =?UTF-8?q?fix:=20=EC=82=AD=EC=A0=9C=20=EC=9D=B4?= =?UTF-8?q?=ED=9B=84=20=EB=8D=B0=EC=9D=B4=ED=84=B0=20=EA=B0=B1=EC=8B=A0?= =?UTF-8?q?=EC=9D=84=20=EC=9C=84=ED=95=9C=20=EC=BF=BC=EB=A6=AC=ED=82=A4=20?= =?UTF-8?q?=EC=88=98=EC=A0=95=20(#266)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- app/admin/questions/_hooks/query/use-delete-question.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/app/admin/questions/_hooks/query/use-delete-question.ts b/app/admin/questions/_hooks/query/use-delete-question.ts index 109f53c2..358470da 100644 --- a/app/admin/questions/_hooks/query/use-delete-question.ts +++ b/app/admin/questions/_hooks/query/use-delete-question.ts @@ -11,7 +11,7 @@ const useDeleteQuestion = ({ strategyId, questionId }: ArgModel) => { return useMutation({ mutationFn: () => deleteAdminQuestion({ strategyId, questionId }), onSuccess: () => { - queryClient.invalidateQueries({ queryKey: ['adminUsers'] }) + queryClient.invalidateQueries({ queryKey: ['adminQuestions'] }) }, }) } From 669b0cecabda6ec29460a43c6bb3df904ad6194f Mon Sep 17 00:00:00 2001 From: nanafromjeju <nanafromjeju@gmail.com> Date: Mon, 9 Dec 2024 19:57:13 +0900 Subject: [PATCH 045/207] =?UTF-8?q?feat:=20=ED=8A=B8=EB=A0=88=EC=9D=B4?= =?UTF-8?q?=EB=8D=94=20=ED=94=84=EB=A1=9C=ED=95=84=20API=20=EC=B6=94?= =?UTF-8?q?=EA=B0=80=20=EB=B0=8F=20BackHeader=20=EC=BB=B4=ED=8F=AC?= =?UTF-8?q?=EB=84=8C=ED=8A=B8=20=EC=88=98=EC=A0=95=20(#9)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- app/(dashboard)/traders/[traderId]/page.tsx | 33 +++++++++++++----- .../traders/_api/get-trader-details.ts | 24 ++----------- .../traders/_api/get-trader-profile.ts | 34 +++++++++++++++++++ .../traders/_hooks/use-get-trader-profile.ts | 11 ++++++ shared/ui/header/back-header/index.tsx | 17 ++++++---- shared/ui/traders-list-card/index.tsx | 2 +- 6 files changed, 84 insertions(+), 37 deletions(-) create mode 100644 app/(dashboard)/traders/_api/get-trader-profile.ts create mode 100644 app/(dashboard)/traders/_hooks/use-get-trader-profile.ts diff --git a/app/(dashboard)/traders/[traderId]/page.tsx b/app/(dashboard)/traders/[traderId]/page.tsx index 390556e1..167ab51d 100644 --- a/app/(dashboard)/traders/[traderId]/page.tsx +++ b/app/(dashboard)/traders/[traderId]/page.tsx @@ -1,51 +1,66 @@ 'use client' +import { useParams } from 'next/navigation' + import classNames from 'classnames/bind' +import { PATH } from '@/shared/constants/path' +import { usePagination } from '@/shared/hooks/custom/use-pagination' import BackHeader from '@/shared/ui/header/back-header' +import Pagination from '@/shared/ui/pagination' import Title from '@/shared/ui/title' import TradersListCard from '@/shared/ui/traders-list-card' import ListHeader from '../../_ui/list-header' import StrategiesItem from '../../_ui/strategies-item' import useGetTraderStrategies from '../_hooks/use-get-trader-details' +import useGetTraderProfile from '../_hooks/use-get-trader-profile' import styles from './page.module.scss' const cx = classNames.bind(styles) const TraderDetailPage = () => { - const traderId = 1 + const { traderId } = useParams() + const traderIdToNumber = parseInt(traderId as string) const { data: strategiesData, isLoading } = useGetTraderStrategies({ - traderId, + traderId: traderIdToNumber, + }) + const { data: traderProfile } = useGetTraderProfile(traderIdToNumber) + const { page, handlePageChange } = usePagination({ + basePath: `${PATH.TRADERS}/${traderId}`, + pageSize: 5, }) const strategies = strategiesData?.content const firstStrategy = strategies?.[0] + const totalPages = strategiesData?.totalPages - if (!firstStrategy || isLoading) { + if (!firstStrategy || isLoading || !totalPages || !traderProfile) { return null } return ( <> <div className={cx('page-container')}> - <BackHeader label={'목록으로 돌아가기'} /> + <BackHeader label={'목록으로 돌아가기'} href={PATH.TRADERS} /> <div className={cx('title')}> <Title label={'트레이더 상세보기'} /> </div> <div className={cx('card-wrapper')}> <TradersListCard - imageUrl={firstStrategy.traderImgUrl} - nickname={firstStrategy.nickname} - strategyCount={strategies.length} - subscriberCount={firstStrategy.subscriptionCount} - userId={traderId} + imageUrl={traderProfile.imageUrl} + nickname={traderProfile.nickname} + strategyCount={traderProfile.strategyCount} + subscriberCount={traderProfile.totalSubCount} + userId={traderIdToNumber} + hasButton={false} /> </div> <ListHeader /> {strategies?.map((strategy) => ( <StrategiesItem key={strategy.strategyId} strategiesData={strategy} /> ))} + <Pagination currentPage={page} maxPage={totalPages} onPageChange={handlePageChange} /> </div> </> ) diff --git a/app/(dashboard)/traders/_api/get-trader-details.ts b/app/(dashboard)/traders/_api/get-trader-details.ts index 348c2724..1ab80985 100644 --- a/app/(dashboard)/traders/_api/get-trader-details.ts +++ b/app/(dashboard)/traders/_api/get-trader-details.ts @@ -1,4 +1,5 @@ import axiosInstance from '@/shared/api/axios' +import { StrategiesModel } from '@/shared/types/strategy-data' export interface StockTypeInfoModel { stockTypeIconUrls: string[] @@ -10,30 +11,11 @@ export interface ProfitRateChartDataModel { profitRates: number[] } -export interface StrategyModel { - strategyId: number - strategyName: string - traderImgUrl: string - nickname: string - stockTypeInfo: StockTypeInfoModel - tradeTypeIconUrl: string - tradeTypeName: string - profitRateChartData: ProfitRateChartDataModel - mdd: number - smScore: number - cumulativeProfitRate: number - recentYearProfitLossRate: number - subscriptionCount: number - averageRating: number - totalReviews: number - isSubscribed: boolean -} - -interface TraderStrategiesResponseModel { +export interface TraderStrategiesResponseModel { isSuccess: boolean message: string result: { - content: StrategyModel[] + content: StrategiesModel[] page: number size: number totalElements: number diff --git a/app/(dashboard)/traders/_api/get-trader-profile.ts b/app/(dashboard)/traders/_api/get-trader-profile.ts new file mode 100644 index 00000000..114531cf --- /dev/null +++ b/app/(dashboard)/traders/_api/get-trader-profile.ts @@ -0,0 +1,34 @@ +import axiosInstance from '@/shared/api/axios' + +export interface TraderProfileModel { + userId: number + userName: string + nickname: string + imageUrl: string + strategyCount: number + totalSubCount: number +} + +export interface TraderProfileResponseModel { + isSuccess: boolean + message: string + result: TraderProfileModel + code: number +} + +export const getTraderProfile = async (traderId: number): Promise<TraderProfileModel> => { + try { + const response = await axiosInstance.get<TraderProfileResponseModel>( + `/api/users/traders/${traderId}` + ) + + if (response.data.isSuccess) { + return response.data.result + } else { + throw new Error(response.data.message || '요청 실패') + } + } catch (err) { + console.error(err) + throw new Error('트레이더 프로필 조회에 실패하였습니다.') + } +} diff --git a/app/(dashboard)/traders/_hooks/use-get-trader-profile.ts b/app/(dashboard)/traders/_hooks/use-get-trader-profile.ts new file mode 100644 index 00000000..846922f0 --- /dev/null +++ b/app/(dashboard)/traders/_hooks/use-get-trader-profile.ts @@ -0,0 +1,11 @@ +import { useQuery } from '@tanstack/react-query' + +import { getTraderProfile } from '../_api/get-trader-profile' + +const useGetTraderProfile = (traderId: number) => { + return useQuery({ + queryKey: ['traders', traderId], + queryFn: () => getTraderProfile(traderId), + }) +} +export default useGetTraderProfile diff --git a/shared/ui/header/back-header/index.tsx b/shared/ui/header/back-header/index.tsx index ed2b35ec..5f5e2e29 100644 --- a/shared/ui/header/back-header/index.tsx +++ b/shared/ui/header/back-header/index.tsx @@ -21,20 +21,25 @@ const headerStyles = { interface Props { label: string + href?: string } -const BackHeader = ({ label }: Props) => { - return <Header Left={<Left label={label} />} styles={headerStyles} /> +const BackHeader = ({ label, href }: Props) => { + return <Header Left={<Left label={label} href={href} />} styles={headerStyles} /> } -const Left = ({ label }: Props) => { +const Left = ({ label, href }: Props) => { const router = useRouter() - const onClick = () => { - router.back() + const handleClick = () => { + if (href) { + router.push(href) + } else { + router.back() + } } return ( - <button onClick={onClick} className={cx('container')}> + <button onClick={handleClick} className={cx('container')}> <BackIcon /> <span>{label}</span> </button> diff --git a/shared/ui/traders-list-card/index.tsx b/shared/ui/traders-list-card/index.tsx index 4e07ce4c..ae3a3375 100644 --- a/shared/ui/traders-list-card/index.tsx +++ b/shared/ui/traders-list-card/index.tsx @@ -10,7 +10,7 @@ const cx = classNames.bind(styles) interface Props { nickname: string - imageUrl: string + imageUrl?: string strategyCount: number subscriberCount: number userId: number From 7d10cfcb19f85e3f8744bc6030543021d7995da8 Mon Sep 17 00:00:00 2001 From: ssumanlife <ksm9805@naver.com> Date: Mon, 9 Dec 2024 21:13:19 +0900 Subject: [PATCH 046/207] =?UTF-8?q?remove:=20=EC=82=AC=EC=9A=A9=ED=95=98?= =?UTF-8?q?=EC=A7=80=20=EC=95=8A=EB=8A=94=20=ED=8C=8C=EC=9D=BC=20=EC=82=AD?= =?UTF-8?q?=EC=A0=9C=20(#42)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../_ui/analysis-container/example.ts | 123 ------------------ 1 file changed, 123 deletions(-) delete mode 100644 app/(dashboard)/_ui/analysis-container/example.ts diff --git a/app/(dashboard)/_ui/analysis-container/example.ts b/app/(dashboard)/_ui/analysis-container/example.ts deleted file mode 100644 index c1b1289d..00000000 --- a/app/(dashboard)/_ui/analysis-container/example.ts +++ /dev/null @@ -1,123 +0,0 @@ -export const analysisChartData = { - dates: ['2023-01-01', '2023-01-02', '2023-01-03', '2023-01-04', '2023-01-05', '2023-01-06'], - data: { - CURRENT_DRAWDOWN: [2000, 5660, 4000, 9000, 7000, 10000], - PRINCIPAL: [50000, 60000, 80000, 70000, 80000, 90000], - }, -} - -export const statisticsData = { - assetManagement: { - balance: 896217437, // 잔고 - cumulativeTransactionAmount: 896217437, // 누적 거래 금액 - principal: 238704360, // 원금 - operationPeriod: '2년 4월', // 운용 기간 - startDate: '2012-10-11', // 시작 일자 - endDate: '2015-03-11', // 종료 일자 (endDate) - daysSincePeakUpdate: 513, // 고점 갱신 후 경과일 - }, - profitLoss: { - cumulativeProfitAmount: 247525031, // 누적 수익 금액 - cumulativeProfitRate: 49.24, // 누적 수익률 - maxCumulativeProfitAmount: 247525031, // 최대 누적 수익 금액 - maxCumulativeProfitRate: 49.24, // 최대 누적 수익률 - averageProfitLossAmount: 336311, // 평균 손익 금액 - averageProfitLossRate: 6, // 평균 손익률 - maxDailyProfitAmount: 25257250, // 최대 일 수익 금액 - maxDailyProfitRate: 3.985, // 최대 일 수익률 - maxDailyLossAmount: -17465050, // 최대 일 손실 금액 - maxDailyLossRate: -3.95, // 최대 일 손실률 - roa: 453, // 자산 수익률 (Return on Assets) - profitFactor: 1.48, // Profit Factor - }, - ddMddInfo: { - currentDrawdown: 0, // 현재 자본 인하 금액 - currentDrawdownRate: 0, // 현재 자본 인하율 - maxDrawdown: -54832778, // 최대 자본 인하 금액 - maxDrawdownRate: -13.98, // 최대 자본 인하율 - }, - tradingInfo: { - totalTradeDays: 736, // 총 거래 일수 - totalProfitableDays: 508, // 총 이익 일수 - totalLossDays: 228, // 총 손실 일수 - currentConsecutiveLossDays: 6, // 현재 연속 손실 일수 - maxConsecutiveProfitDays: 22, // 최대 연속 이익 일수 - maxConsecutiveLossDays: 8, // 최대 연속 손실 일수 - winRate: 69, // 승률 - }, -} - -export const tableBody = [ - { - date: '2015-03-12', // 날짜 - principal: 100000000, // 원금 - transaction: 0, // 입출금 - dailyProfitLoss: 332410, // 일 손익 - dailyProfitLossRate: 0.33, // 일 수익률 - cumulativeProfitLoss: 302280, // 누적 손익 - cumulativeProfitLossRate: 0.3, // 누적 수익률 - }, - { - date: '2015-03-13', - principal: 100000000, - transaction: 0, - dailyProfitLoss: 332410, - dailyProfitLossRate: 0.33, - cumulativeProfitLoss: 302280, - cumulativeProfitLossRate: 0.3, - }, - { - date: '2015-03-14', - principal: 100000000, - transaction: 0, - dailyProfitLoss: 332410, - dailyProfitLossRate: 0.33, - cumulativeProfitLoss: 302280, - cumulativeProfitLossRate: 0.3, - }, - { - date: '2015-03-15', - principal: 100000000, - transaction: 0, - dailyProfitLoss: 332410, - dailyProfitLossRate: 0.33, - cumulativeProfitLoss: 302280, - cumulativeProfitLossRate: 0.3, - }, - { - date: '2015-03-16', - principal: 100000000, - transaction: 0, - dailyProfitLoss: 332410, - dailyProfitLossRate: 0.33, - cumulativeProfitLoss: 302280, - cumulativeProfitLossRate: 0.3, - }, - { - date: '2015-03-17', - principal: 100000000, - transaction: 0, - dailyProfitLoss: 332410, - dailyProfitLossRate: 0.33, - cumulativeProfitLoss: 302280, - cumulativeProfitLossRate: 0.3, - }, - { - date: '2015-03-18', - principal: 100000000, - transaction: 0, - dailyProfitLoss: 332410, - dailyProfitLossRate: 0.33, - cumulativeProfitLoss: 302280, - cumulativeProfitLossRate: 0.3, - }, - { - date: '2015-03-19', - principal: 100000000, - transaction: 0, - dailyProfitLoss: 332410, - dailyProfitLossRate: 0.33, - cumulativeProfitLoss: 302280, - cumulativeProfitLossRate: 0.3, - }, -] From 233271d74367e3c670926ff7140e616469573bec Mon Sep 17 00:00:00 2001 From: ssumanlife <ksm9805@naver.com> Date: Mon, 9 Dec 2024 21:16:00 +0900 Subject: [PATCH 047/207] =?UTF-8?q?fix:=20=EA=B3=B5=ED=86=B5=20=ED=83=80?= =?UTF-8?q?=EC=9E=85=20shared=EB=A1=9C=20=EB=B6=84=EB=A6=AC=20(#42)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../_ui/analysis-container/account-content.tsx | 12 ++++-------- .../my/_api/get-my-account-iamges.ts | 15 ++------------- .../[strategyId]/_api/get-account-images.ts | 17 ++++------------- shared/types/strategy-data.ts | 17 ++++++++++++++++- 4 files changed, 26 insertions(+), 35 deletions(-) diff --git a/app/(dashboard)/_ui/analysis-container/account-content.tsx b/app/(dashboard)/_ui/analysis-container/account-content.tsx index ebc5edab..639641b1 100644 --- a/app/(dashboard)/_ui/analysis-container/account-content.tsx +++ b/app/(dashboard)/_ui/analysis-container/account-content.tsx @@ -8,6 +8,7 @@ import classNames from 'classnames/bind' import { ACCOUNT_PAGE_COUNT } from '@/shared/constants/count-per-page' import useModal from '@/shared/hooks/custom/use-modal' +import { ImageDataModel } from '@/shared/types/strategy-data' import { Button } from '@/shared/ui/button' import Checkbox from '@/shared/ui/check-box' import AccountImageModal from '@/shared/ui/modal/account-image-modal' @@ -22,12 +23,6 @@ import styles from './styles.module.scss' const cx = classNames.bind(styles) -export interface ImageDataModel { - id: number - imageUrl: string - title: string -} - interface Props { strategyId: number currentPage: number @@ -85,7 +80,7 @@ const AccountContent = ({ strategyId, currentPage, onPageChange, isEditable = fa } } - if (!data || !Array.isArray(data.content) || isLoading) return null + if (!Array.isArray(data?.content) || isLoading) return null const imagesData = data.content const croppedImagesData: ImageDataModel[] = sliceArray( @@ -95,6 +90,7 @@ const AccountContent = ({ strategyId, currentPage, onPageChange, isEditable = fa ) const isTwoLines = (croppedImagesData?.length || 0) > 4 + return ( <div className={cx('table-wrapper')}> {isEditable && ( @@ -118,7 +114,7 @@ const AccountContent = ({ strategyId, currentPage, onPageChange, isEditable = fa </Button> </div> )} - {croppedImagesData && croppedImagesData.length !== 0 ? ( + {croppedImagesData?.length > 0 ? ( <> <div className={cx('account-images-container', isTwoLines && 'line')}> {croppedImagesData?.map((imageData: ImageDataModel) => ( diff --git a/app/(dashboard)/my/_api/get-my-account-iamges.ts b/app/(dashboard)/my/_api/get-my-account-iamges.ts index 297958f9..f9d3d88b 100644 --- a/app/(dashboard)/my/_api/get-my-account-iamges.ts +++ b/app/(dashboard)/my/_api/get-my-account-iamges.ts @@ -1,20 +1,9 @@ -import { ImageDataModel } from '@/app/(dashboard)/_ui/analysis-container/account-content' - import axiosInstance from '@/shared/api/axios' - -interface ResponseModel { - content: ImageDataModel - first: boolean - last: boolean - page: number - size: number - totalElements: number - totalPages: number -} +import { AccountImageDataModel } from '@/shared/types/strategy-data' const getMyAccountImages = async ( strategyId: number -): Promise<ResponseModel | null | undefined> => { +): Promise<AccountImageDataModel | null | undefined> => { try { const response = await axiosInstance.get(`/api/my-strategies/${strategyId}/account-images`) return response.data.result diff --git a/app/(dashboard)/strategies/[strategyId]/_api/get-account-images.ts b/app/(dashboard)/strategies/[strategyId]/_api/get-account-images.ts index f21a5a45..a9c748b2 100644 --- a/app/(dashboard)/strategies/[strategyId]/_api/get-account-images.ts +++ b/app/(dashboard)/strategies/[strategyId]/_api/get-account-images.ts @@ -1,18 +1,9 @@ -import { ImageDataModel } from '@/app/(dashboard)/_ui/analysis-container/account-content' - import axiosInstance from '@/shared/api/axios' +import { AccountImageDataModel } from '@/shared/types/strategy-data' -interface ResponseModel { - content: ImageDataModel - first: boolean - last: boolean - page: number - size: number - totalElements: number - totalPages: number -} - -const getAccountImages = async (strategyId: number): Promise<ResponseModel | null | undefined> => { +const getAccountImages = async ( + strategyId: number +): Promise<AccountImageDataModel | null | undefined> => { try { const response = await axiosInstance.get(`/api/strategies/${strategyId}/account-images`) return response.data.result diff --git a/shared/types/strategy-data.ts b/shared/types/strategy-data.ts index 577d56d0..7abea53a 100644 --- a/shared/types/strategy-data.ts +++ b/shared/types/strategy-data.ts @@ -1,4 +1,3 @@ -// API Request Data export interface DailyAnalysisModel { date: string principal: number @@ -93,3 +92,19 @@ export interface AnalysisDataModel { transaction: number dailyProfitLoss: number } + +export interface ImageDataModel { + id: number + imageUrl: string + title: string +} + +export interface AccountImageDataModel { + content: ImageDataModel + first: boolean + last: boolean + page: number + size: number + totalElements: number + totalPages: number +} From 1fb2cfac7ffd02096be5c086e56a21b9d4799ea6 Mon Sep 17 00:00:00 2001 From: ssumanlife <ksm9805@naver.com> Date: Mon, 9 Dec 2024 21:17:08 +0900 Subject: [PATCH 048/207] =?UTF-8?q?fix:=20=EB=B6=84=EC=84=9D=20=EC=BB=B4?= =?UTF-8?q?=ED=8F=AC=EB=84=8C=ED=8A=B8=20=EB=82=B4=EB=B6=80=EC=97=90?= =?UTF-8?q?=EC=84=9C=20=EC=82=AC=EC=9A=A9=EB=90=98=EB=8A=94=20=EC=83=81?= =?UTF-8?q?=EC=88=98,=20=ED=95=9C=20=ED=8C=8C=EC=9D=BC=EC=97=90=EC=84=9C?= =?UTF-8?q?=20=EA=B4=80=EB=A6=AC=20(#42)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../_ui/analysis-container/analysis-chart.tsx | 8 ++++--- .../analysis-container/analysis-content.tsx | 21 +----------------- .../{yaxis-options.ts => constants.ts} | 22 ++++++++++++++++++- 3 files changed, 27 insertions(+), 24 deletions(-) rename app/(dashboard)/_ui/analysis-container/{yaxis-options.ts => constants.ts} (65%) diff --git a/app/(dashboard)/_ui/analysis-container/analysis-chart.tsx b/app/(dashboard)/_ui/analysis-container/analysis-chart.tsx index e1f5c3f9..1e212f7c 100644 --- a/app/(dashboard)/_ui/analysis-container/analysis-chart.tsx +++ b/app/(dashboard)/_ui/analysis-container/analysis-chart.tsx @@ -5,8 +5,8 @@ import dynamic from 'next/dynamic' import classNames from 'classnames/bind' import Highcharts, { SeriesOptionsType } from 'highcharts' +import { CHART_SELECT_OPTIONS } from './constants' import styles from './styles.module.scss' -import { YAXIS_OPTIONS } from './yaxis-options' const HighchartsReact = dynamic(() => import('highcharts-react-official'), { ssr: false, @@ -14,7 +14,7 @@ const HighchartsReact = dynamic(() => import('highcharts-react-official'), { const cx = classNames.bind(styles) -type YAxisType = keyof typeof YAXIS_OPTIONS +type YAxisType = keyof typeof CHART_SELECT_OPTIONS interface AnalysisChartDataModel { dates: string[] @@ -30,9 +30,11 @@ interface Props { const AnalysisChart = ({ analysisChartData: data }: Props) => { const getOptionName = (sequence: number) => { const key = Object.keys(data.data)[sequence] as YAxisType | undefined - return key ? YAXIS_OPTIONS[key] : '' + return key ? CHART_SELECT_OPTIONS[key] : '' } + if (!data) return <div></div> + const chartOptions: Highcharts.Options = { chart: { type: 'areaspline', diff --git a/app/(dashboard)/_ui/analysis-container/analysis-content.tsx b/app/(dashboard)/_ui/analysis-container/analysis-content.tsx index e17284bf..6e5cc70a 100644 --- a/app/(dashboard)/_ui/analysis-container/analysis-content.tsx +++ b/app/(dashboard)/_ui/analysis-container/analysis-content.tsx @@ -17,6 +17,7 @@ import useGetMyDailyAnalysis from '../../my/_hooks/query/use-get-my-daily-analys import { useMyAnalysisMutation } from '../../my/_hooks/query/use-manage-daily-analysis' import useGetAnalysis from '../../strategies/[strategyId]/_hooks/query/use-get-analysis' import useGetAnalysisDownload from '../../strategies/[strategyId]/_hooks/query/use-get-analysis-download' +import { DAILY_TABLE_HEADER, MONTHLY_TABLE_HEADER } from './constants' import styles from './styles.module.scss' const cx = classNames.bind(styles) @@ -29,26 +30,6 @@ interface Props { isEditable?: boolean } -const DAILY_TABLE_HEADER = [ - '날짜', - '원금', - '입출금', - '일 손익', - '일 손익률', - '누적 손익', - '누적 수익률', -] - -const MONTHLY_TABLE_HEADER = [ - '날짜', - '원금', - '입출금', - '월 손익', - '월 손익률', - '누적 손익', - '누적 수익률', -] - const isMyAnalysisData = (data: TableBodyDataType): data is MyDailyAnalysisModel => { if (!data || typeof data !== 'object' || Array.isArray(data)) return false diff --git a/app/(dashboard)/_ui/analysis-container/yaxis-options.ts b/app/(dashboard)/_ui/analysis-container/constants.ts similarity index 65% rename from app/(dashboard)/_ui/analysis-container/yaxis-options.ts rename to app/(dashboard)/_ui/analysis-container/constants.ts index a9be1ddf..38b37f2c 100644 --- a/app/(dashboard)/_ui/analysis-container/yaxis-options.ts +++ b/app/(dashboard)/_ui/analysis-container/constants.ts @@ -1,4 +1,4 @@ -export const YAXIS_OPTIONS = { +export const CHART_SELECT_OPTIONS = { BALANCE: '잔고', PRINCIPAL: '원금', CUMULATIVE_TRANSACTION_AMOUNT: '누적 입출 금액', @@ -17,3 +17,23 @@ export const YAXIS_OPTIONS = { TOTAL_PROFIT: '총 이익', TOTAL_LOSS: '총 손실', } as const + +export const DAILY_TABLE_HEADER = [ + '날짜', + '원금', + '입출금', + '일 손익', + '일 손익률', + '누적 손익', + '누적 수익률', +] + +export const MONTHLY_TABLE_HEADER = [ + '날짜', + '원금', + '입출금', + '월 손익', + '월 손익률', + '누적 손익', + '누적 수익률', +] From db30605b41b6a7bd9a5ee91b5abd7a921a247273 Mon Sep 17 00:00:00 2001 From: ssumanlife <ksm9805@naver.com> Date: Mon, 9 Dec 2024 21:17:52 +0900 Subject: [PATCH 049/207] =?UTF-8?q?fix:=20=EC=98=B5=EC=85=94=EB=84=90?= =?UTF-8?q?=EB=A1=9C=20=ED=83=80=EC=9E=85=20=EC=88=98=EC=A0=95=20(#42)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- app/(dashboard)/_ui/analysis-container/statistics-content.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/app/(dashboard)/_ui/analysis-container/statistics-content.tsx b/app/(dashboard)/_ui/analysis-container/statistics-content.tsx index b70db4fb..85d3befe 100644 --- a/app/(dashboard)/_ui/analysis-container/statistics-content.tsx +++ b/app/(dashboard)/_ui/analysis-container/statistics-content.tsx @@ -14,7 +14,7 @@ interface StatisticsDataModel { } interface Props { - statisticsData: StatisticsDataModel + statisticsData?: StatisticsDataModel } const StatisticsContent = ({ statisticsData }: Props) => { From 5982c0fc14c4cc1b1286ebbd169db96a2441aca2 Mon Sep 17 00:00:00 2001 From: ssumanlife <ksm9805@naver.com> Date: Mon, 9 Dec 2024 21:19:18 +0900 Subject: [PATCH 050/207] =?UTF-8?q?fix:=20=EB=B6=88=ED=95=84=EC=9A=94?= =?UTF-8?q?=ED=95=9C=20=EB=B3=80=EC=88=98=20=EC=A0=9C=EA=B1=B0=20=EB=B0=8F?= =?UTF-8?q?=20=EC=BD=94=EB=93=9C=20=EB=8B=A8=EC=88=9C=ED=99=94=20(#42)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- app/(dashboard)/_ui/analysis-container/index.tsx | 13 +++++-------- 1 file changed, 5 insertions(+), 8 deletions(-) diff --git a/app/(dashboard)/_ui/analysis-container/index.tsx b/app/(dashboard)/_ui/analysis-container/index.tsx index c6422eff..e3b74cfc 100644 --- a/app/(dashboard)/_ui/analysis-container/index.tsx +++ b/app/(dashboard)/_ui/analysis-container/index.tsx @@ -8,13 +8,13 @@ import Select from '@/shared/ui/select' import useGetAnalysisChart from '../../strategies/[strategyId]/_hooks/query/use-get-analysis-chart' import AnalysisChart from './analysis-chart' +import { CHART_SELECT_OPTIONS } from './constants' import styles from './styles.module.scss' import TabsWithTable from './tabs-width-table' -import { YAXIS_OPTIONS } from './yaxis-options' const cx = classNames.bind(styles) -export type AnalysisChartOptionsType = keyof typeof YAXIS_OPTIONS +export type AnalysisChartOptionsType = keyof typeof CHART_SELECT_OPTIONS interface Props { strategyId: number @@ -27,12 +27,9 @@ const AnalysisContainer = ({ strategyId, type = 'default' }: Props) => { useState<AnalysisChartOptionsType>('CUMULATIVE_PROFIT_LOSS') const { data: chartData } = useGetAnalysisChart({ strategyId, firstOption, secondOption }) - const optionsToArray = Object.entries(YAXIS_OPTIONS) - const options: { value: string; label: string }[] = [] - - for (const [key, value] of optionsToArray) { - options.push({ value: key, label: value }) - } + const options = Object.entries(CHART_SELECT_OPTIONS).map((option) => { + return { value: option[0], label: option[1] } + }) return ( <div className={cx('container')}> From 9dc639d618074fa65df5485abad32df58b591d9f Mon Sep 17 00:00:00 2001 From: nanafromjeju <nanafromjeju@gmail.com> Date: Mon, 9 Dec 2024 21:52:39 +0900 Subject: [PATCH 051/207] =?UTF-8?q?design:=20=ED=8A=B8=EB=A0=88=EC=9D=B4?= =?UTF-8?q?=EB=8D=94=20=EB=AA=A9=EB=A1=9D=20=ED=8E=98=EC=9D=B4=EC=A7=80=20?= =?UTF-8?q?=EB=B0=98=EC=9D=91=ED=98=95=20=EC=A0=81=EC=9A=A9=20(#9)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- app/(dashboard)/traders/page.module.scss | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/app/(dashboard)/traders/page.module.scss b/app/(dashboard)/traders/page.module.scss index 615e9a98..e6e20e18 100644 --- a/app/(dashboard)/traders/page.module.scss +++ b/app/(dashboard)/traders/page.module.scss @@ -21,6 +21,11 @@ grid-template-columns: repeat(4, 1fr); gap: 24px 32px; margin-bottom: 30px; + + @include tablet-md { + grid-template-columns: 1fr; + gap: 16px; + } } .pagination-wrapper { From 867f90d3986b4709a0c5195eb33c7c8a960b58d0 Mon Sep 17 00:00:00 2001 From: nanafromjeju <nanafromjeju@gmail.com> Date: Mon, 9 Dec 2024 22:47:06 +0900 Subject: [PATCH 052/207] =?UTF-8?q?design:=20=ED=8A=B8=EB=A0=88=EC=9D=B4?= =?UTF-8?q?=EB=8D=94=20=ED=8E=98=EC=9D=B4=EC=A7=80=20=EB=AA=A8=EB=B0=94?= =?UTF-8?q?=EC=9D=BC=20=EB=B0=98=EC=9D=91=ED=98=95=20=EC=B6=94=EA=B0=80=20?= =?UTF-8?q?(#9)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- app/(dashboard)/traders/page.module.scss | 7 ++++++- 1 file changed, 6 insertions(+), 1 deletion(-) diff --git a/app/(dashboard)/traders/page.module.scss b/app/(dashboard)/traders/page.module.scss index e6e20e18..22f6fcce 100644 --- a/app/(dashboard)/traders/page.module.scss +++ b/app/(dashboard)/traders/page.module.scss @@ -23,7 +23,12 @@ margin-bottom: 30px; @include tablet-md { - grid-template-columns: 1fr; + grid-template-columns: repeat(2, 1fr); + gap: 16px; + } + + @include mobile { + grid-template-columns: 2fr; gap: 16px; } } From ebed5d720c5bb48388eac4096b0b898d37abab90 Mon Sep 17 00:00:00 2001 From: nanafromjeju <nanafromjeju@gmail.com> Date: Mon, 9 Dec 2024 23:29:21 +0900 Subject: [PATCH 053/207] =?UTF-8?q?design:=20=ED=8A=B8=EB=A0=88=EC=9D=B4?= =?UTF-8?q?=EB=8D=94=20=ED=8E=98=EC=9D=B4=EC=A7=80=20=EB=B0=98=EC=9D=91?= =?UTF-8?q?=ED=98=95=20SCSS=20=EC=88=98=EC=A0=95=20(#9)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- app/(dashboard)/traders/page.module.scss | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/app/(dashboard)/traders/page.module.scss b/app/(dashboard)/traders/page.module.scss index 22f6fcce..371ac7f8 100644 --- a/app/(dashboard)/traders/page.module.scss +++ b/app/(dashboard)/traders/page.module.scss @@ -28,7 +28,7 @@ } @include mobile { - grid-template-columns: 2fr; + grid-template-columns: 1fr; gap: 16px; } } From d546e9ca84e528afcf1654e85160ba4964b7eae4 Mon Sep 17 00:00:00 2001 From: deun <devdeun@gmail.com> Date: Tue, 10 Dec 2024 10:26:42 +0900 Subject: [PATCH 054/207] =?UTF-8?q?fix:=20=ED=9A=8C=EC=9B=90=EA=B0=80?= =?UTF-8?q?=EC=9E=85=20=EC=99=84=EB=A3=8C=20=EB=A9=94=EC=8B=9C=EC=A7=80=20?= =?UTF-8?q?=EC=98=A4=EB=A5=98=20=EC=88=98=EC=A0=95=20(#28)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../_ui/signup-complete-message/index.tsx | 24 ++++++++------ .../styles.module.scss | 4 +++ app/(landing)/signup/complete/page.tsx | 32 ++++++++++++++++--- 3 files changed, 46 insertions(+), 14 deletions(-) diff --git a/app/(landing)/signup/_ui/signup-complete-message/index.tsx b/app/(landing)/signup/_ui/signup-complete-message/index.tsx index 36e9964f..fe90bf62 100644 --- a/app/(landing)/signup/_ui/signup-complete-message/index.tsx +++ b/app/(landing)/signup/_ui/signup-complete-message/index.tsx @@ -15,20 +15,26 @@ const COMPLETE_MESSAGE = { } interface Props { - nickname: string - userType: UserType + nickname?: string + userType?: UserType } const SignupCompleteMessage = ({ nickname, userType }: Props) => { return ( <section className={cx('container')}> - <p>{nickname}님의 회원가입이 완료되었습니다.</p> - <p> - 지금 바로 인베스트메틱 - <Logo width={42} /> - 에서 <br /> - {COMPLETE_MESSAGE[userType]} - </p> + {nickname && userType ? ( + <> + <p>{nickname}님의 회원가입이 완료되었습니다.</p> + <p> + 지금 바로 인베스트메틱 + <Logo width={42} /> + 에서 <br /> + {COMPLETE_MESSAGE[userType]} + </p> + </> + ) : ( + <p>회원가입이 완료되었습니다.</p> + )} </section> ) } diff --git a/app/(landing)/signup/_ui/signup-complete-message/styles.module.scss b/app/(landing)/signup/_ui/signup-complete-message/styles.module.scss index 0dae4ea0..b258181e 100644 --- a/app/(landing)/signup/_ui/signup-complete-message/styles.module.scss +++ b/app/(landing)/signup/_ui/signup-complete-message/styles.module.scss @@ -7,3 +7,7 @@ margin: 0 4px; } } + +.spinner { + margin-top: 200px; +} diff --git a/app/(landing)/signup/complete/page.tsx b/app/(landing)/signup/complete/page.tsx index a9136c98..8bb478ce 100644 --- a/app/(landing)/signup/complete/page.tsx +++ b/app/(landing)/signup/complete/page.tsx @@ -7,6 +7,7 @@ import classNames from 'classnames/bind' import { PATH } from '@/shared/constants/path' import { UserType } from '@/shared/types/auth' import { LinkButton } from '@/shared/ui/link-button' +import Spinner from '@/shared/ui/spinner' import { getNicknameCookie, getUserTypeCookie } from '../_lib/cookies' import SignupCompleteMessage from '../_ui/signup-complete-message' @@ -16,19 +17,40 @@ import styles from './page.module.scss' const cx = classNames.bind(styles) const CompletePage = () => { + const [isLoading, setIsLoading] = useState(true) const [userData, setUserData] = useState<{ userType: UserType | undefined nickname: string | undefined }>({ userType: undefined, nickname: undefined }) useEffect(() => { - const userType = getUserTypeCookie() - const nickname = getNicknameCookie() - setUserData({ userType, nickname }) + const MAX_RETRIES = 5 + let count = 0 + + const checkCookies = () => { + const userType = getUserTypeCookie() + const nickname = getNicknameCookie() + + if (count > MAX_RETRIES) { + setIsLoading(false) + return + } + + if (userType && nickname) { + setUserData({ userType, nickname }) + setIsLoading(false) + return + } + + setTimeout(checkCookies, 100) + count++ + } + + checkCookies() }, []) - if (!userData.userType || !userData.nickname) { - return null + if (isLoading) { + return <Spinner className={cx('spinner')} /> } return ( From 5067cbd5e28b826a7cfbbfadbad8dfa0e1eb5973 Mon Sep 17 00:00:00 2001 From: ssumanlife <ksm9805@naver.com> Date: Tue, 10 Dec 2024 10:55:18 +0900 Subject: [PATCH 055/207] =?UTF-8?q?rename:=20=EB=A6=AC=EB=B7=B0=EB=AA=A8?= =?UTF-8?q?=EB=8B=AC=20=ED=8C=8C=EC=9D=BC=20=EC=9C=84=EC=B9=98=20=EC=9D=B4?= =?UTF-8?q?=EB=8F=99=20(#45)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../_ui/review-container/review-item.tsx | 2 +- .../_ui/review-container/styles.module.scss | 15 --------------- .../ui/modal}/review-guide-modal.tsx | 9 +++++++-- 3 files changed, 8 insertions(+), 18 deletions(-) rename {app/(dashboard)/strategies/[strategyId]/_ui/review-container => shared/ui/modal}/review-guide-modal.tsx (80%) diff --git a/app/(dashboard)/strategies/[strategyId]/_ui/review-container/review-item.tsx b/app/(dashboard)/strategies/[strategyId]/_ui/review-container/review-item.tsx index d9111cfd..8ae66872 100644 --- a/app/(dashboard)/strategies/[strategyId]/_ui/review-container/review-item.tsx +++ b/app/(dashboard)/strategies/[strategyId]/_ui/review-container/review-item.tsx @@ -6,11 +6,11 @@ import classNames from 'classnames/bind' import useModal from '@/shared/hooks/custom/use-modal' import Avatar from '@/shared/ui/avatar' +import ReviewGuideModal from '@/shared/ui/modal/review-guide-modal' import useDeleteReview from '../../_hooks/query/use-delete-review' import StarRating from '../star-rating/index' import AddReview from './add-review' -import ReviewGuideModal from './review-guide-modal' import styles from './styles.module.scss' const cx = classNames.bind(styles) diff --git a/app/(dashboard)/strategies/[strategyId]/_ui/review-container/styles.module.scss b/app/(dashboard)/strategies/[strategyId]/_ui/review-container/styles.module.scss index 8d911d1b..c7ee1a17 100644 --- a/app/(dashboard)/strategies/[strategyId]/_ui/review-container/styles.module.scss +++ b/app/(dashboard)/strategies/[strategyId]/_ui/review-container/styles.module.scss @@ -103,18 +103,3 @@ padding-left: 10px; } } - -.message { - @include typo-b1; - text-align: center; - color: $color-gray-800; - margin-bottom: 30px; -} - -.two-button { - display: flex; - gap: 10px; - & .button { - width: 90px; - } -} diff --git a/app/(dashboard)/strategies/[strategyId]/_ui/review-container/review-guide-modal.tsx b/shared/ui/modal/review-guide-modal.tsx similarity index 80% rename from app/(dashboard)/strategies/[strategyId]/_ui/review-container/review-guide-modal.tsx rename to shared/ui/modal/review-guide-modal.tsx index 92acec9e..8101d9d8 100644 --- a/app/(dashboard)/strategies/[strategyId]/_ui/review-container/review-guide-modal.tsx +++ b/shared/ui/modal/review-guide-modal.tsx @@ -15,15 +15,20 @@ const cx = classNames.bind(styles) interface Props { isModalOpen: boolean isErr: boolean + isMyStrategy?: boolean onCloseModal: () => void onChange?: () => void } -const ReviewGuideModal = ({ isModalOpen, isErr, onCloseModal, onChange }: Props) => { +const ReviewGuideModal = ({ isModalOpen, isErr, isMyStrategy, onCloseModal, onChange }: Props) => { return ( <Modal isOpen={isModalOpen} icon={ModalAlertIcon}> <span className={cx('message')}> - {isErr ? ( + {isMyStrategy ? ( + <> + 나의 전략엔 리뷰를 <br /> 등록 할 수 없습니다. + </> + ) : isErr ? ( <> 이미 등록된 리뷰가 있습니다. <br /> 리뷰는 한 번만 등록 가능합니다. </> From 8db097e53b2aafcd86353c6b94cae9771fb7ff94 Mon Sep 17 00:00:00 2001 From: ssumanlife <ksm9805@naver.com> Date: Tue, 10 Dec 2024 10:56:51 +0900 Subject: [PATCH 056/207] =?UTF-8?q?refactor:=20=EB=B6=88=ED=95=84=EC=9A=94?= =?UTF-8?q?=ED=95=9C=20=EC=98=88=EC=99=B8=EC=B2=98=EB=A6=AC=20=EC=A0=9C?= =?UTF-8?q?=EA=B1=B0=20(#45)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../_ui/review-container/review-list.tsx | 2 +- .../strategies/[strategyId]/page.tsx | 30 +++++++++---------- 2 files changed, 15 insertions(+), 17 deletions(-) diff --git a/app/(dashboard)/strategies/[strategyId]/_ui/review-container/review-list.tsx b/app/(dashboard)/strategies/[strategyId]/_ui/review-container/review-list.tsx index 1e05cef3..3cd08aa1 100644 --- a/app/(dashboard)/strategies/[strategyId]/_ui/review-container/review-list.tsx +++ b/app/(dashboard)/strategies/[strategyId]/_ui/review-container/review-list.tsx @@ -44,7 +44,7 @@ const ReviewList = ({ strategyId, reviews, totalReview, currentPage, setCurrentP starRating={review.starRating} content={review.content} isReviewer={user?.nickname === review.nickname} - isAdmin={user?.role.includes('admin') ?? false} + isAdmin={!user?.role.includes('admin')} /> ))} </ul> diff --git a/app/(dashboard)/strategies/[strategyId]/page.tsx b/app/(dashboard)/strategies/[strategyId]/page.tsx index 36b4441e..c84ba868 100644 --- a/app/(dashboard)/strategies/[strategyId]/page.tsx +++ b/app/(dashboard)/strategies/[strategyId]/page.tsx @@ -55,10 +55,6 @@ const StrategyDetailPage = ({ params }: { params: { strategyId: string } }) => { }) } - const hasDetailsSideData = detailsSideData?.map((data) => { - if (!Array.isArray(data)) return data.data !== undefined - }) - return ( <> <BackHeader label={'목록으로 돌아가기'} /> @@ -68,7 +64,10 @@ const StrategyDetailPage = ({ params }: { params: { strategyId: string } }) => { <> <DetailsInformation information={information} strategyId={strategyNumber} /> <AnalysisContainer strategyId={strategyNumber} /> - <ReviewContainer strategyId={strategyNumber} /> + <ReviewContainer + strategyId={strategyNumber} + isMyStrategy={user?.nickname === information.nickname} + /> </> )} </Suspense> @@ -83,17 +82,16 @@ const StrategyDetailPage = ({ params }: { params: { strategyId: string } }) => { onClick={openModal} strategyId={strategyNumber} /> - {hasDetailsSideData?.[0] && - detailsSideData?.map((data, idx) => ( - <div key={`${data}_${idx}`}> - <DetailsSideItem - strategyId={strategyNumber} - information={data} - isMyStrategy={user?.nickname === information.nickname} - strategyName={information.strategyName} - /> - </div> - ))} + {detailsSideData?.map((data, idx) => ( + <div key={`${data}_${idx}`}> + <DetailsSideItem + strategyId={strategyNumber} + information={data} + isMyStrategy={user?.nickname === information.nickname} + strategyName={information.strategyName} + /> + </div> + ))} </> )} </Suspense> From 788abd6478c57606537cc1cf0df2b04e9c3f48f3 Mon Sep 17 00:00:00 2001 From: ssumanlife <ksm9805@naver.com> Date: Tue, 10 Dec 2024 10:57:30 +0900 Subject: [PATCH 057/207] =?UTF-8?q?refactor:=20=EC=B0=A8=ED=8A=B8=20?= =?UTF-8?q?=EB=8D=B0=EC=9D=B4=ED=84=B0=20=EC=98=88=EC=99=B8=EC=B2=98?= =?UTF-8?q?=EB=A6=AC=20=EC=B6=94=EA=B0=80=20(#45)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- app/(dashboard)/_ui/strategies-item/area-chart.tsx | 4 +++- app/(dashboard)/_ui/strategies-item/styles.module.scss | 5 +++++ 2 files changed, 8 insertions(+), 1 deletion(-) diff --git a/app/(dashboard)/_ui/strategies-item/area-chart.tsx b/app/(dashboard)/_ui/strategies-item/area-chart.tsx index e03e012e..2a4cadc2 100644 --- a/app/(dashboard)/_ui/strategies-item/area-chart.tsx +++ b/app/(dashboard)/_ui/strategies-item/area-chart.tsx @@ -20,7 +20,9 @@ interface Props { } const AreaChart = ({ profitRateChartData: data }: Props) => { - if (!data) return <div></div> + if (!data?.dates || !data?.profitRates || data?.dates.length < 3 || data?.profitRates?.length < 3) + return <span className={cx('no-data')}>준비중..</span> + const chartOptions: Highcharts.Options = { chart: { type: 'areaspline', diff --git a/app/(dashboard)/_ui/strategies-item/styles.module.scss b/app/(dashboard)/_ui/strategies-item/styles.module.scss index c1cd4b3f..5d53e98d 100644 --- a/app/(dashboard)/_ui/strategies-item/styles.module.scss +++ b/app/(dashboard)/_ui/strategies-item/styles.module.scss @@ -138,3 +138,8 @@ row-gap: 1px; } } + +.no-data { + @include typo-b3; + color: $color-gray-600; +} From d6cb615451d12f00e1d81905a9a2f15c36378f67 Mon Sep 17 00:00:00 2001 From: ssumanlife <ksm9805@naver.com> Date: Tue, 10 Dec 2024 10:58:11 +0900 Subject: [PATCH 058/207] =?UTF-8?q?refactor:=20=EB=82=98=EC=9D=98=20?= =?UTF-8?q?=EC=A0=84=EB=9E=B5=EC=9D=BC=20=EC=8B=9C=20=EB=A6=AC=EB=B7=B0?= =?UTF-8?q?=EB=93=B1=EB=A1=9D=20=EB=B6=88=EA=B0=80=20=EC=B2=98=EB=A6=AC=20?= =?UTF-8?q?=EC=B6=94=EA=B0=80=20(#45)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../[strategyId]/_ui/review-container/add-review.tsx | 11 +++++++++-- .../[strategyId]/_ui/review-container/index.tsx | 5 +++-- 2 files changed, 12 insertions(+), 4 deletions(-) diff --git a/app/(dashboard)/strategies/[strategyId]/_ui/review-container/add-review.tsx b/app/(dashboard)/strategies/[strategyId]/_ui/review-container/add-review.tsx index bf6127a8..c191b32b 100644 --- a/app/(dashboard)/strategies/[strategyId]/_ui/review-container/add-review.tsx +++ b/app/(dashboard)/strategies/[strategyId]/_ui/review-container/add-review.tsx @@ -7,12 +7,12 @@ import classNames from 'classnames/bind' import useModal from '@/shared/hooks/custom/use-modal' import { Button } from '@/shared/ui/button' import { ErrorMessage } from '@/shared/ui/error-message' +import ReviewGuideModal from '@/shared/ui/modal/review-guide-modal' import Textarea from '@/shared/ui/textarea' import usePatchReview from '../../_hooks/query/use-patch-review' import usePostReview from '../../_hooks/query/use-post-review' import StarRating from '../star-rating/index' -import ReviewGuideModal from './review-guide-modal' import styles from './styles.module.scss' const cx = classNames.bind(styles) @@ -20,6 +20,7 @@ const cx = classNames.bind(styles) interface Props { strategyId: number reviewId?: number + isMyStrategy?: boolean isEditable?: boolean content?: string starRating?: number @@ -29,6 +30,7 @@ interface Props { const AddReview = ({ strategyId, reviewId, + isMyStrategy, isEditable = false, content, starRating, @@ -114,7 +116,12 @@ const AddReview = ({ </Button> )} </div> - <ReviewGuideModal isModalOpen={isModalOpen} isErr={isError} onCloseModal={closeModal} /> + <ReviewGuideModal + isModalOpen={isModalOpen} + isErr={isError} + isMyStrategy={isMyStrategy} + onCloseModal={closeModal} + /> </div> ) } diff --git a/app/(dashboard)/strategies/[strategyId]/_ui/review-container/index.tsx b/app/(dashboard)/strategies/[strategyId]/_ui/review-container/index.tsx index 7d3e881f..7429de6e 100644 --- a/app/(dashboard)/strategies/[strategyId]/_ui/review-container/index.tsx +++ b/app/(dashboard)/strategies/[strategyId]/_ui/review-container/index.tsx @@ -15,9 +15,10 @@ const cx = classNames.bind(styles) interface Props { strategyId: number + isMyStrategy: boolean } -const ReviewContainer = ({ strategyId }: Props) => { +const ReviewContainer = ({ strategyId, isMyStrategy }: Props) => { const [currentPage, setCurrentPage] = useState(1) const { data: reviewData } = useGetReviewsData({ strategyId, page: currentPage }) @@ -31,7 +32,7 @@ const ReviewContainer = ({ strategyId }: Props) => { totalElements={reviewData?.reviews.totalElements} /> </div> - <AddReview strategyId={strategyId} /> + <AddReview strategyId={strategyId} isMyStrategy={isMyStrategy} /> {reviewData && reviewData.reviews.content.length !== 0 ? ( <ReviewList strategyId={strategyId} From 8e7cc51b3958c7443bf9f850898331df41756298 Mon Sep 17 00:00:00 2001 From: kimpra <dustmqaksdltkfrlfdldi@gmail.com> Date: Tue, 10 Dec 2024 10:59:02 +0900 Subject: [PATCH 059/207] =?UTF-8?q?feat:=20=EA=B4=80=EB=A6=AC=EC=9E=90=20?= =?UTF-8?q?=EA=B3=B5=EC=A7=80=EC=82=AC=ED=95=AD=20=EB=93=B1=EB=A1=9D=20(#1?= =?UTF-8?q?4)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- app/admin/_ui/file-input/index.tsx | 23 +++++++++-- app/admin/notices/post/_api/post-notice.ts | 18 +++++---- .../post/_hooks/query/use-post-notice.ts | 40 +++++++++++++++++++ .../notices/post/_hooks/use-notice-post.ts | 22 ---------- app/admin/notices/post/page.module.scss | 4 ++ app/admin/notices/post/page.tsx | 8 ++-- app/admin/notices/post/types.ts | 17 +------- 7 files changed, 81 insertions(+), 51 deletions(-) create mode 100644 app/admin/notices/post/_hooks/query/use-post-notice.ts delete mode 100644 app/admin/notices/post/_hooks/use-notice-post.ts diff --git a/app/admin/_ui/file-input/index.tsx b/app/admin/_ui/file-input/index.tsx index 78d8f630..5d53a80d 100644 --- a/app/admin/_ui/file-input/index.tsx +++ b/app/admin/_ui/file-input/index.tsx @@ -14,15 +14,32 @@ const cx = classNames.bind(styles) interface Props extends ComponentPropsWithoutRef<'input'> { accept?: string preview?: string + multiple?: boolean + className?: string } -const FileInput = ({ preview, accept = '*', value, onChange, ...props }: Props) => { +const FileInput = ({ + preview, + accept = '*', + value, + onChange, + multiple = false, + className, + ...props +}: Props) => { return ( - <div className={cx('container')}> + <div className={cx('container', className)}> {preview && ( <Image width={24} height={24} src={preview} alt="Preview" className={cx('preview')} /> )} - <input type="file" accept={accept} onChange={onChange} className={cx('input')} {...props} /> + <input + type="file" + accept={accept} + multiple={multiple} + onChange={onChange} + className={cx('input')} + {...props} + /> <FileIcon className={cx('icon')} /> </div> ) diff --git a/app/admin/notices/post/_api/post-notice.ts b/app/admin/notices/post/_api/post-notice.ts index c33beb8f..49e3022e 100644 --- a/app/admin/notices/post/_api/post-notice.ts +++ b/app/admin/notices/post/_api/post-notice.ts @@ -3,15 +3,17 @@ import axiosInstance from '@/shared/api/axios' import { NoticeFormModel, PostNoticeResopnseModel } from '../types' const postNotice = async (formData: NoticeFormModel) => { - const data = new FormData() - data.append('title', formData.title) - data.append('content', formData.content) - formData.files.forEach((file) => data.append('files', file)) + const data = { + title: formData.title, + content: formData.content, + fileUrls: formData.files, + } try { - const res = await axiosInstance.post<PostNoticeResopnseModel>('/api/admin/notices', { - method: 'POST', - body: data, + const res = await axiosInstance.post<PostNoticeResopnseModel>('/api/admin/notices', data, { + headers: { + 'Content-Type': 'application/json', + }, }) if (!res.data.isSuccess) throw new Error('Error : ' + res.data.message) @@ -19,7 +21,7 @@ const postNotice = async (formData: NoticeFormModel) => { alert('공지 등록이 완료되었습니다.') } catch (err) { console.error(err) - alert('공지 등록 중 오류가 발생했습니다.') + throw err } } diff --git a/app/admin/notices/post/_hooks/query/use-post-notice.ts b/app/admin/notices/post/_hooks/query/use-post-notice.ts new file mode 100644 index 00000000..2279e8cd --- /dev/null +++ b/app/admin/notices/post/_hooks/query/use-post-notice.ts @@ -0,0 +1,40 @@ +import { useMutation } from '@tanstack/react-query' + +import axiosInstance from '@/shared/api/axios' + +import { NoticeFormModel, PostNoticeResopnseModel } from '../../types' + +const usePostNotice = () => { + return useMutation({ + mutationFn: async (formData: NoticeFormModel) => { + // Presigned URL 요청 + const { files } = formData + if (!files) return + + const uploadResponse = await axiosInstance.post<PostNoticeResopnseModel>( + '/api/admin/notices', + { + title: formData.title, + content: formData.content, + filePaths: formData?.files?.map((file) => file.name) ?? null, + sizes: formData?.files?.map((file) => file.size) ?? null, + } + ) + + const presignedUrls = uploadResponse.data.result + + // Presigned URL로 파일 업로드 + await Promise.all( + files.map((file, idx) => { + if (presignedUrls[idx]) { + return axiosInstance.put(presignedUrls[idx], file) + } else { + throw new Error(`Presigned URL이 인덱스 ${idx}에 대해 없습니다.`) + } + }) + ) + }, + }) +} + +export default usePostNotice diff --git a/app/admin/notices/post/_hooks/use-notice-post.ts b/app/admin/notices/post/_hooks/use-notice-post.ts deleted file mode 100644 index 76dc5bca..00000000 --- a/app/admin/notices/post/_hooks/use-notice-post.ts +++ /dev/null @@ -1,22 +0,0 @@ -import { useMutation, useQueryClient } from '@tanstack/react-query' - -import postNotice from '../_api/post-notice' -import { NoticeFormModel } from '../types' - -const usePostNotice = (formData: NoticeFormModel) => { - const queryClient = useQueryClient() - - return useMutation({ - mutationFn: () => postNotice(formData), - onSuccess: () => { - queryClient.invalidateQueries({ - queryKey: ['notices'], - }) - }, - onError: (err) => { - console.error('Error : ', err) - }, - }) -} - -export default usePostNotice diff --git a/app/admin/notices/post/page.module.scss b/app/admin/notices/post/page.module.scss index 59d57426..f5e7b43f 100644 --- a/app/admin/notices/post/page.module.scss +++ b/app/admin/notices/post/page.module.scss @@ -19,6 +19,10 @@ gap: 30px; } +.file-input { + width: 100%; +} + .textarea { height: 420px; diff --git a/app/admin/notices/post/page.tsx b/app/admin/notices/post/page.tsx index 66f03dc3..9bcc70e2 100644 --- a/app/admin/notices/post/page.tsx +++ b/app/admin/notices/post/page.tsx @@ -10,15 +10,15 @@ import Title from '@/shared/ui/title' import FileInput from '../../_ui/file-input' import InputField from '../../_ui/input-field' +import usePostNotice from './_hooks/query/use-post-notice' import useNoticeForm from './_hooks/use-notice-form' -import usePostNotice from './_hooks/use-notice-post' import styles from './page.module.scss' const cx = classNames.bind(styles) const AdminNoticePostPage = () => { const { formData, onInputChange } = useNoticeForm() - const { mutate: postNotice } = usePostNotice(formData) + const { mutate: postNotice } = usePostNotice() return ( <> @@ -29,7 +29,7 @@ const AdminNoticePostPage = () => { className={cx('form')} onSubmit={(e) => { e.preventDefault() - postNotice() + postNotice(formData) }} > <div className={cx('input-field')}> @@ -60,7 +60,9 @@ const AdminNoticePostPage = () => { label="파일첨부" Input={ <FileInput + className={cx('file-input')} onChange={(e) => onInputChange('files', Array.from(e.target.files || []))} + multiple /> } /> diff --git a/app/admin/notices/post/types.ts b/app/admin/notices/post/types.ts index 205d4c6e..dc046075 100644 --- a/app/admin/notices/post/types.ts +++ b/app/admin/notices/post/types.ts @@ -3,22 +3,9 @@ import { APIResponseBaseModel } from '@/shared/types/response' export interface NoticeFormModel { title: string content: string - files: File[] + files?: File[] } export interface PostNoticeResopnseModel extends APIResponseBaseModel<boolean> { - data: { - noticeId: number - user: { - id: number - nickname: string - } - title: string - content: string - createdAt: string - publishedAt: string - updatedAt: string - createdBy: string - updatedBy: string - } + result: string[] } From 04de11a1aaf9f09444a7a7ccba733e182b7dd7b7 Mon Sep 17 00:00:00 2001 From: ssumanlife <ksm9805@naver.com> Date: Tue, 10 Dec 2024 11:03:09 +0900 Subject: [PATCH 060/207] =?UTF-8?q?refactor:=20=EB=B6=88=ED=95=84=EC=9A=94?= =?UTF-8?q?=ED=95=9C=20=EC=98=B5=EC=85=94=EB=85=88=EC=B2=B4=EC=9D=B4?= =?UTF-8?q?=EB=8B=9D=20=EC=A0=9C=EA=B1=B0=20(#45)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- app/(dashboard)/_ui/strategies-item/area-chart.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/app/(dashboard)/_ui/strategies-item/area-chart.tsx b/app/(dashboard)/_ui/strategies-item/area-chart.tsx index 2a4cadc2..e3e1bfea 100644 --- a/app/(dashboard)/_ui/strategies-item/area-chart.tsx +++ b/app/(dashboard)/_ui/strategies-item/area-chart.tsx @@ -20,7 +20,7 @@ interface Props { } const AreaChart = ({ profitRateChartData: data }: Props) => { - if (!data?.dates || !data?.profitRates || data?.dates.length < 3 || data?.profitRates?.length < 3) + if (!data?.dates || !data?.profitRates || data?.dates.length < 3 || data?.profitRates.length < 3) return <span className={cx('no-data')}>준비중..</span> const chartOptions: Highcharts.Options = { From 81635ddc749c663880275e87d314154e09fb3015 Mon Sep 17 00:00:00 2001 From: ssumanlife <ksm9805@naver.com> Date: Tue, 10 Dec 2024 11:26:38 +0900 Subject: [PATCH 061/207] =?UTF-8?q?refactor:=20=EB=8D=B0=EC=9D=B4=ED=84=B0?= =?UTF-8?q?=EA=B0=80=20=EC=97=86=EC=9D=84=EC=8B=9C=20=EC=98=88=EC=99=B8?= =?UTF-8?q?=EC=B2=98=EB=A6=AC=20=EC=B6=94=EA=B0=80=20(#45)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- app/(dashboard)/_ui/analysis-container/analysis-content.tsx | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/app/(dashboard)/_ui/analysis-container/analysis-content.tsx b/app/(dashboard)/_ui/analysis-container/analysis-content.tsx index e17284bf..80c0baff 100644 --- a/app/(dashboard)/_ui/analysis-container/analysis-content.tsx +++ b/app/(dashboard)/_ui/analysis-container/analysis-content.tsx @@ -178,7 +178,7 @@ const AnalysisContent = ({ return ( <div className={cx('table-wrapper', isEditable ? 'my-analysis' : 'analysis')}> - {!isEditable && analysisData && ( + {!isEditable && analysisData?.content.length > 0 && ( <Button onClick={handleDownload} size="small" @@ -219,7 +219,7 @@ const AnalysisContent = ({ </Button> </div> )} - {analysisData ? ( + {analysisData?.content.length > 0 ? ( <> <VerticalTable tableHead={tableHeader} From 14a98c84c98f8365265da8ce92a730a198e21a47 Mon Sep 17 00:00:00 2001 From: kimpra <dustmqaksdltkfrlfdldi@gmail.com> Date: Tue, 10 Dec 2024 11:36:25 +0900 Subject: [PATCH 062/207] =?UTF-8?q?fix:=20build=20=EC=97=90=EB=9F=AC=20?= =?UTF-8?q?=ED=95=B4=EA=B2=B0=EC=9D=84=20=EC=9C=84=ED=95=B4=20baseUrl=20?= =?UTF-8?q?=EC=84=A4=EC=A0=95=20(#50)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- shared/api/axios.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/shared/api/axios.ts b/shared/api/axios.ts index 92745c1b..6c8bc6be 100644 --- a/shared/api/axios.ts +++ b/shared/api/axios.ts @@ -7,7 +7,7 @@ import { isTokenExpired, refreshToken } from '@/shared/utils/token-utils' export const createAxiosInstance = (options: { withInterceptors?: boolean } = {}) => { const instance = axios.create({ - baseURL: process.env.NEXT_PUBLIC_API_HOST || 'http://localhost:3000', + baseURL: process.env.NEXT_PUBLIC_API_HOST || 'http://15.164.90.102:8081', withCredentials: true, }) From a60582cd79b7a18dde5d5fbdbc623fe0ee744cf9 Mon Sep 17 00:00:00 2001 From: deun <devdeun@gmail.com> Date: Tue, 10 Dec 2024 12:37:54 +0900 Subject: [PATCH 063/207] =?UTF-8?q?fix:=20history=20stack=EC=9D=B4=20?= =?UTF-8?q?=EC=8C=93=EC=97=AC=20=EB=B0=9C=EC=83=9D=ED=95=9C=20=EB=92=A4?= =?UTF-8?q?=EB=A1=9C=EA=B0=80=EA=B8=B0=20=EB=AC=B8=EC=A0=9C=20=ED=95=B4?= =?UTF-8?q?=EA=B2=B0=20(#53)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- app/(dashboard)/traders/[traderId]/page.tsx | 2 +- shared/hooks/custom/use-pagination.ts | 7 +++++-- 2 files changed, 6 insertions(+), 3 deletions(-) diff --git a/app/(dashboard)/traders/[traderId]/page.tsx b/app/(dashboard)/traders/[traderId]/page.tsx index 167ab51d..41de6fcc 100644 --- a/app/(dashboard)/traders/[traderId]/page.tsx +++ b/app/(dashboard)/traders/[traderId]/page.tsx @@ -42,7 +42,7 @@ const TraderDetailPage = () => { return ( <> <div className={cx('page-container')}> - <BackHeader label={'목록으로 돌아가기'} href={PATH.TRADERS} /> + <BackHeader label={'목록으로 돌아가기'} /> <div className={cx('title')}> <Title label={'트레이더 상세보기'} /> </div> diff --git a/shared/hooks/custom/use-pagination.ts b/shared/hooks/custom/use-pagination.ts index 7e517f3f..7daa4053 100644 --- a/shared/hooks/custom/use-pagination.ts +++ b/shared/hooks/custom/use-pagination.ts @@ -23,9 +23,12 @@ export const usePagination = ({ basePath, pageSize }: Props): UsePaginationRetur useEffect(() => { if (!searchParams.size) { - router.push(`${basePath}?page=1&size=${size ?? pageSize}`) + const params = new URLSearchParams(searchParams) + params.set('page', String(page)) + params.set('size', String(size)) + router.replace(`${basePath}?${params.toString()}`) } - }, [searchParams, router, basePath, pageSize, size]) + }, [searchParams, router, basePath, pageSize, size, page]) const handlePageChange = (page: number) => { router.push(`${basePath}?page=${page}&size=${pageSize}`) From c91ccda60c2d03e61f09004e95e33e72ef4c3500 Mon Sep 17 00:00:00 2001 From: ssumanlife <ksm9805@naver.com> Date: Tue, 10 Dec 2024 12:55:42 +0900 Subject: [PATCH 064/207] =?UTF-8?q?bug:=20=EA=B2=80=EC=83=89=EB=B0=94=20?= =?UTF-8?q?=EC=97=90=EB=9F=AC=EC=9E=88=EC=9D=84=EC=8B=9C=20=EB=8D=B0?= =?UTF-8?q?=EC=9D=B4=ED=84=B0=20=EC=9A=94=EC=B2=AD=EB=90=98=EC=A7=80=20?= =?UTF-8?q?=EC=95=8A=EB=8F=84=EB=A1=9D=20=EC=B2=98=EB=A6=AC=20(#52)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../_store/use-searching-item-store.ts | 3 ++- .../strategies/_ui/search-bar/index.tsx | 20 ++++++++++++++----- 2 files changed, 17 insertions(+), 6 deletions(-) diff --git a/app/(dashboard)/strategies/_ui/search-bar/_store/use-searching-item-store.ts b/app/(dashboard)/strategies/_ui/search-bar/_store/use-searching-item-store.ts index 8f78db4c..4247dd1e 100644 --- a/app/(dashboard)/strategies/_ui/search-bar/_store/use-searching-item-store.ts +++ b/app/(dashboard)/strategies/_ui/search-bar/_store/use-searching-item-store.ts @@ -15,7 +15,7 @@ interface ActionModel { setRangeValue: (key: keyof SearchTermsModel, type: keyof RangeModel, value: number) => void setSearchWord: (searchWord: string) => void resetState: () => void - validateRangeValues: () => void + validateRangeValues: () => StateModel['errOptions'] } interface ActionsModel { @@ -95,6 +95,7 @@ const useSearchingItemStore = create<StateModel & ActionsModel>((set, get) => ({ return false }) set({ errOptions }) + return errOptions }, }, })) diff --git a/app/(dashboard)/strategies/_ui/search-bar/index.tsx b/app/(dashboard)/strategies/_ui/search-bar/index.tsx index 5468fd31..44bb1afa 100644 --- a/app/(dashboard)/strategies/_ui/search-bar/index.tsx +++ b/app/(dashboard)/strategies/_ui/search-bar/index.tsx @@ -2,8 +2,12 @@ import { useRef, useState } from 'react' +import { useRouter, useSearchParams } from 'next/navigation' + import classNames from 'classnames/bind' +import { STRATEGIES_PAGE_COUNT } from '@/shared/constants/count-per-page' +import { PATH } from '@/shared/constants/path' import { Button } from '@/shared/ui/button' import SearchInput from '@/shared/ui/search-input' @@ -27,13 +31,15 @@ interface AccordionMenuDataModel { const SearchBarContainer = () => { const [isMainTab, setIsMainTab] = useState(true) const searchTerms = useSearchingItemStore((state) => state.searchTerms) - const errOptions = useSearchingItemStore((state) => state.errOptions) - const { setSearchWord, setAlgorithm, resetState, validateRangeValues } = useSearchingItemStore( + const { setSearchWord, setAlgorithm, resetState } = useSearchingItemStore( (state) => state.actions ) const searchRef = useRef<HTMLInputElement>(null) const { data } = useGetStrategiesSearch() const { refetch } = usePostStrategies({ page: 1, size: 8, searchTerms }) + const params = useSearchParams() + const router = useRouter() + const page = parseInt(params.get('page') || '1') const handleSearchWord = () => { if (searchRef.current) { @@ -50,9 +56,13 @@ const SearchBarContainer = () => { } const onSearch = async () => { - await validateRangeValues() - if (errOptions === null || errOptions.length === 0) { - refetch() + const { validateRangeValues } = useSearchingItemStore.getState().actions + const errOptions = validateRangeValues() + if (errOptions?.length === 0) { + if (page !== 1) { + await router.replace(`${PATH.STRATEGIES}?page=1&size=${STRATEGIES_PAGE_COUNT}`) + } + await refetch() } } From 1633eb1342500134f7390c9e92e100ef95c2949f Mon Sep 17 00:00:00 2001 From: ssumanlife <ksm9805@naver.com> Date: Tue, 10 Dec 2024 12:56:37 +0900 Subject: [PATCH 065/207] =?UTF-8?q?feat:=20=EB=B2=94=EC=9C=84=EC=A7=80?= =?UTF-8?q?=EC=A0=95=20=EC=97=90=EB=9F=AC=EC=9E=88=EC=9D=84=EC=8B=9C=20?= =?UTF-8?q?=EB=B2=84=ED=8A=BC=EC=97=90=20=EC=95=84=EC=9D=B4=EC=BD=98=20?= =?UTF-8?q?=EB=B3=B4=EC=9D=B4=EB=8F=84=EB=A1=9D=20=EC=B6=94=EA=B0=80=20(#5?= =?UTF-8?q?2)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../strategies/_ui/search-bar/accordion-button.tsx | 11 ++++++++--- .../strategies/_ui/search-bar/styles.module.scss | 7 +++++++ 2 files changed, 15 insertions(+), 3 deletions(-) diff --git a/app/(dashboard)/strategies/_ui/search-bar/accordion-button.tsx b/app/(dashboard)/strategies/_ui/search-bar/accordion-button.tsx index 78752772..a033aea7 100644 --- a/app/(dashboard)/strategies/_ui/search-bar/accordion-button.tsx +++ b/app/(dashboard)/strategies/_ui/search-bar/accordion-button.tsx @@ -2,7 +2,7 @@ import { useContext } from 'react' -import { CloseIcon, OpenIcon } from '@/public/icons' +import { CloseIcon, ModalAlertIcon, OpenIcon } from '@/public/icons' import classNames from 'classnames/bind' import useSearchingItemStore from './_store/use-searching-item-store' @@ -21,6 +21,7 @@ interface Props { const AccordionButton = ({ optionId, title, size }: Props) => { const { openIds, handleButtonIds } = useContext(AccordionContext) const searchTerms = useSearchingItemStore((state) => state.searchTerms) + const errOptions = useSearchingItemStore((state) => state.errOptions) const hasOpenId = openIds?.[optionId] const clickedValue = searchTerms[optionId] @@ -30,7 +31,10 @@ const AccordionButton = ({ optionId, title, size }: Props) => { <button onClick={() => handleButtonIds(optionId, !hasOpenId)}> <p> {title} - {Array.isArray(clickedValue) && + {errOptions?.includes(optionId) ? ( + <ModalAlertIcon /> + ) : ( + Array.isArray(clickedValue) && clickedValue?.length !== 0 && (clickedValue.length !== size ? ( <span> @@ -38,7 +42,8 @@ const AccordionButton = ({ optionId, title, size }: Props) => { </span> ) : ( <span>(All)</span> - ))} + )) + )} </p> {hasOpenId ? <CloseIcon /> : <OpenIcon />} </button> diff --git a/app/(dashboard)/strategies/_ui/search-bar/styles.module.scss b/app/(dashboard)/strategies/_ui/search-bar/styles.module.scss index dca9b24a..41f8923f 100644 --- a/app/(dashboard)/strategies/_ui/search-bar/styles.module.scss +++ b/app/(dashboard)/strategies/_ui/search-bar/styles.module.scss @@ -108,6 +108,13 @@ color: $color-orange-500; margin-left: 4px; } + svg { + width: 26px; + margin: -3px 0 -8px 5px; + path { + fill: $color-orange-600; + } + } } svg { width: 26px; From e9df3c0a57f2f0a25334adeddad2bc93cdae7965 Mon Sep 17 00:00:00 2001 From: ssumanlife <ksm9805@naver.com> Date: Tue, 10 Dec 2024 12:57:10 +0900 Subject: [PATCH 066/207] =?UTF-8?q?feat:=20=EA=B2=80=EC=83=89=EB=90=9C=20?= =?UTF-8?q?=EB=8D=B0=EC=9D=B4=ED=84=B0=20=EC=97=86=EC=9D=84=20=EC=8B=9C=20?= =?UTF-8?q?ui=EC=B6=94=EA=B0=80=20(#52)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../strategies/_ui/strategy-list/index.tsx | 24 ++++++++++++------- .../_ui/strategy-list/styles.module.scss | 9 +++++++ 2 files changed, 24 insertions(+), 9 deletions(-) diff --git a/app/(dashboard)/strategies/_ui/strategy-list/index.tsx b/app/(dashboard)/strategies/_ui/strategy-list/index.tsx index 46d8113d..df6c275b 100644 --- a/app/(dashboard)/strategies/_ui/strategy-list/index.tsx +++ b/app/(dashboard)/strategies/_ui/strategy-list/index.tsx @@ -26,7 +26,7 @@ const StrategyList = () => { }) const searchTerms = useSearchingItemStore((state) => state.searchTerms) const { resetState } = useSearchingItemStore((state) => state.actions) - const { data } = usePostStrategies({ page, size, searchTerms }) + const { data, isLoading } = usePostStrategies({ page, size, searchTerms }) useEffect(() => { resetState() @@ -37,14 +37,20 @@ const StrategyList = () => { return ( <> - {strategiesData?.map((strategy) => ( - <StrategiesItem key={strategy.strategyId} strategiesData={strategy} /> - ))} - <div className={cx('pagination')}> - {totalPages && ( - <Pagination currentPage={page} maxPage={totalPages} onPageChange={handlePageChange} /> - )} - </div> + {strategiesData?.length > 0 ? ( + <> + {strategiesData.map((strategy) => ( + <StrategiesItem key={strategy.strategyId} strategiesData={strategy} /> + ))} + <div className={cx('pagination')}> + {totalPages && ( + <Pagination currentPage={page} maxPage={totalPages} onPageChange={handlePageChange} /> + )} + </div> + </> + ) : ( + !isLoading && <div className={cx('no-data')}>검색된 전략이 없습니다.</div> + )} </> ) } diff --git a/app/(dashboard)/strategies/_ui/strategy-list/styles.module.scss b/app/(dashboard)/strategies/_ui/strategy-list/styles.module.scss index f5081c9f..f70a087e 100644 --- a/app/(dashboard)/strategies/_ui/strategy-list/styles.module.scss +++ b/app/(dashboard)/strategies/_ui/strategy-list/styles.module.scss @@ -1,3 +1,12 @@ .pagination { margin-bottom: 24px; } + +.no-data { + display: flex; + justify-content: center; + margin-top: 80px; + color: $color-gray-600; + height: 200px; + @include typo-b1; +} From fadd32b355a2dbec840a1399dfb1046ff12d18ff Mon Sep 17 00:00:00 2001 From: ssumanlife <ksm9805@naver.com> Date: Tue, 10 Dec 2024 13:11:28 +0900 Subject: [PATCH 067/207] =?UTF-8?q?fix:=20=EB=B6=88=ED=95=84=EC=9A=94?= =?UTF-8?q?=ED=95=9C=20=EC=9A=94=EC=86=8C=20=EC=A0=9C=EA=B1=B0=20(#52)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../strategies/_ui/search-bar/accordion-container.tsx | 6 ++---- 1 file changed, 2 insertions(+), 4 deletions(-) diff --git a/app/(dashboard)/strategies/_ui/search-bar/accordion-container.tsx b/app/(dashboard)/strategies/_ui/search-bar/accordion-container.tsx index 7c5c4acf..3bb70ce1 100644 --- a/app/(dashboard)/strategies/_ui/search-bar/accordion-container.tsx +++ b/app/(dashboard)/strategies/_ui/search-bar/accordion-container.tsx @@ -35,10 +35,8 @@ const AccordionContainer = ({ optionId, title, panels }: Props) => { return ( <AccordionContext.Provider value={{ openIds, panelRef, handleButtonIds }}> - <div> - <AccordionButton optionId={optionId} title={title} size={panels?.length} /> - <AccordionPanel optionId={optionId} panels={panels} /> - </div> + <AccordionButton optionId={optionId} title={title} size={panels?.length} /> + <AccordionPanel optionId={optionId} panels={panels} /> </AccordionContext.Provider> ) } From 9a443c4ce83ecbf3def7c45aed742b0ea6217f88 Mon Sep 17 00:00:00 2001 From: deun <devdeun@gmail.com> Date: Tue, 10 Dec 2024 13:38:41 +0900 Subject: [PATCH 068/207] =?UTF-8?q?fix:=20cookie=20=EB=B6=88=EB=9F=AC?= =?UTF-8?q?=EC=98=A4=EA=B8=B0=20=EB=AC=B8=EC=A0=9C=20=ED=95=B4=EA=B2=B0?= =?UTF-8?q?=EC=9D=84=20=EC=9C=84=ED=95=B4=20store=20=EC=B6=94=EA=B0=80=20(?= =?UTF-8?q?#56)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../signup/_hooks/custom/use-signup-form.ts | 10 +++-- .../signup/_store/use-signup-store.ts | 32 +++++++++++++ .../signup/_ui/user-type-card/index.tsx | 3 ++ app/(landing)/signup/complete/page.tsx | 45 ++----------------- 4 files changed, 45 insertions(+), 45 deletions(-) create mode 100644 app/(landing)/signup/_store/use-signup-store.ts diff --git a/app/(landing)/signup/_hooks/custom/use-signup-form.ts b/app/(landing)/signup/_hooks/custom/use-signup-form.ts index 9e82f61e..1e9faa34 100644 --- a/app/(landing)/signup/_hooks/custom/use-signup-form.ts +++ b/app/(landing)/signup/_hooks/custom/use-signup-form.ts @@ -7,7 +7,8 @@ import { PATH } from '@/shared/constants/path' import { signup } from '../../_api/signup' import { SIGNUP_ERROR_MESSAGES } from '../../_constants/signup' -import { getUserTypeCookie, setNicknameCookie } from '../../_lib/cookies' +import { setNicknameCookie } from '../../_lib/cookies' +import useSignupStore from '../../_store/use-signup-store' import { SignupFormDataModel, SignupFormErrorsModel, @@ -45,6 +46,8 @@ const useSignupForm = () => { const [formState, setFormState] = useState<SignupFormStateModel>(initialFormState) const [isValidated, setIsValidated] = useState(false) const [isModalOpen, setIsModalOpen] = useState(false) + const userType = useSignupStore((state) => state.userType) + const { setNickname } = useSignupStore((state) => state.actions) const handleInputChange = (e: ChangeEvent<HTMLInputElement>) => { const { name, value } = e.target @@ -132,8 +135,6 @@ const useSignupForm = () => { return } - const role = getUserTypeCookie() - try { const formData: SignupFormDataModel = { name: form.name, @@ -141,7 +142,7 @@ const useSignupForm = () => { phone: form.phone, password: form.password, email: form.email, - role: role || 'INVESTOR', + role: userType, infoAgreement: form.isMarketingAgreed, birthYear: form.birthYear, birthMonth: form.birthMonth, @@ -152,6 +153,7 @@ const useSignupForm = () => { await signup(formData) setNicknameCookie(form.nickname) + setNickname(form.nickname) router.push(PATH.SIGN_UP_COMPLETE) } catch (err) { console.error('회원가입 실패:', err) diff --git a/app/(landing)/signup/_store/use-signup-store.ts b/app/(landing)/signup/_store/use-signup-store.ts new file mode 100644 index 00000000..4b3b8b28 --- /dev/null +++ b/app/(landing)/signup/_store/use-signup-store.ts @@ -0,0 +1,32 @@ +import { create } from 'zustand' + +import { UserType } from '@/shared/types/auth' + +interface StateModel { + userType: UserType + nickname: string +} + +interface ActionModel { + setUserType: (userType: UserType) => void + setNickname: (nickname: string) => void +} + +interface ActionsModel { + actions: ActionModel +} + +const initialState = { + userType: 'INVESTOR', + nickname: '', +} as const + +const useSignupStore = create<StateModel & ActionsModel>((set) => ({ + ...initialState, + actions: { + setUserType: (userType) => set((state) => ({ ...state, userType })), + setNickname: (nickname) => set((state) => ({ ...state, nickname })), + }, +})) + +export default useSignupStore diff --git a/app/(landing)/signup/_ui/user-type-card/index.tsx b/app/(landing)/signup/_ui/user-type-card/index.tsx index 7e62e318..0992deb5 100644 --- a/app/(landing)/signup/_ui/user-type-card/index.tsx +++ b/app/(landing)/signup/_ui/user-type-card/index.tsx @@ -13,6 +13,7 @@ import classNames from 'classnames/bind' import { PATH } from '@/shared/constants/path' import { UserType } from '@/shared/types/auth' +import useSignupStore from '../../_store/use-signup-store' import styles from './styles.module.scss' const cx = classNames.bind(styles) @@ -25,6 +26,7 @@ interface Props { const UserTypeCard = ({ userType, title, highlight }: Props) => { const router = useRouter() + const { setUserType } = useSignupStore((state) => state.actions) const userTypeCookie = getUserTypeCookie() const handleTypeSelect = () => { @@ -33,6 +35,7 @@ const UserTypeCard = ({ userType, title, highlight }: Props) => { } setUserTypeCookie(userType) + setUserType(userType) router.push(PATH.SIGN_UP_TERMS_OF_USE) } diff --git a/app/(landing)/signup/complete/page.tsx b/app/(landing)/signup/complete/page.tsx index 8bb478ce..0ed0d5fd 100644 --- a/app/(landing)/signup/complete/page.tsx +++ b/app/(landing)/signup/complete/page.tsx @@ -1,15 +1,11 @@ 'use client' -import { useEffect, useState } from 'react' - import classNames from 'classnames/bind' import { PATH } from '@/shared/constants/path' -import { UserType } from '@/shared/types/auth' import { LinkButton } from '@/shared/ui/link-button' -import Spinner from '@/shared/ui/spinner' -import { getNicknameCookie, getUserTypeCookie } from '../_lib/cookies' +import useSignupStore from '../_store/use-signup-store' import SignupCompleteMessage from '../_ui/signup-complete-message' import Step from '../_ui/step' import styles from './page.module.scss' @@ -17,46 +13,13 @@ import styles from './page.module.scss' const cx = classNames.bind(styles) const CompletePage = () => { - const [isLoading, setIsLoading] = useState(true) - const [userData, setUserData] = useState<{ - userType: UserType | undefined - nickname: string | undefined - }>({ userType: undefined, nickname: undefined }) - - useEffect(() => { - const MAX_RETRIES = 5 - let count = 0 - - const checkCookies = () => { - const userType = getUserTypeCookie() - const nickname = getNicknameCookie() - - if (count > MAX_RETRIES) { - setIsLoading(false) - return - } - - if (userType && nickname) { - setUserData({ userType, nickname }) - setIsLoading(false) - return - } - - setTimeout(checkCookies, 100) - count++ - } - - checkCookies() - }, []) - - if (isLoading) { - return <Spinner className={cx('spinner')} /> - } + const userType = useSignupStore((state) => state.userType) + const nickname = useSignupStore((state) => state.nickname) return ( <> <Step /> - <SignupCompleteMessage nickname={userData.nickname} userType={userData.userType} /> + <SignupCompleteMessage nickname={nickname} userType={userType} /> <div className={cx('button-wrapper')}> <LinkButton href={PATH.SIGN_IN} variant="filled"> 로그인하기 From 9d1bf81b3ba00f884a904aaf1143078050731c9e Mon Sep 17 00:00:00 2001 From: ssumanlife <ksm9805@naver.com> Date: Tue, 10 Dec 2024 13:59:03 +0900 Subject: [PATCH 069/207] =?UTF-8?q?fix:=20=EA=B2=80=EC=83=89=EC=8B=9C=20re?= =?UTF-8?q?fetch=20=EC=97=90=EB=9F=AC=20=ED=95=B4=EA=B2=B0=20(#52)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../strategies/_hooks/query/use-post-strategies.ts | 1 + app/(dashboard)/strategies/_ui/search-bar/index.tsx | 2 +- app/(dashboard)/strategies/_ui/strategy-list/index.tsx | 5 ++++- 3 files changed, 6 insertions(+), 2 deletions(-) diff --git a/app/(dashboard)/strategies/_hooks/query/use-post-strategies.ts b/app/(dashboard)/strategies/_hooks/query/use-post-strategies.ts index 203fddfb..75bd4e53 100644 --- a/app/(dashboard)/strategies/_hooks/query/use-post-strategies.ts +++ b/app/(dashboard)/strategies/_hooks/query/use-post-strategies.ts @@ -15,6 +15,7 @@ const usePostStrategies = ({ return useQuery({ queryKey: ['strategies', page, size], queryFn: () => postStrategies(page, size, searchTerms), + staleTime: 0, }) } diff --git a/app/(dashboard)/strategies/_ui/search-bar/index.tsx b/app/(dashboard)/strategies/_ui/search-bar/index.tsx index 44bb1afa..40fd7c7a 100644 --- a/app/(dashboard)/strategies/_ui/search-bar/index.tsx +++ b/app/(dashboard)/strategies/_ui/search-bar/index.tsx @@ -36,10 +36,10 @@ const SearchBarContainer = () => { ) const searchRef = useRef<HTMLInputElement>(null) const { data } = useGetStrategiesSearch() - const { refetch } = usePostStrategies({ page: 1, size: 8, searchTerms }) const params = useSearchParams() const router = useRouter() const page = parseInt(params.get('page') || '1') + const { refetch } = usePostStrategies({ page, size: 8, searchTerms }) const handleSearchWord = () => { if (searchRef.current) { diff --git a/app/(dashboard)/strategies/_ui/strategy-list/index.tsx b/app/(dashboard)/strategies/_ui/strategy-list/index.tsx index df6c275b..13d82819 100644 --- a/app/(dashboard)/strategies/_ui/strategy-list/index.tsx +++ b/app/(dashboard)/strategies/_ui/strategy-list/index.tsx @@ -2,6 +2,8 @@ import { useEffect } from 'react' +import { usePathname } from 'next/navigation' + import StrategiesItem from '@/app/(dashboard)/_ui/strategies-item' import classNames from 'classnames/bind' @@ -27,10 +29,11 @@ const StrategyList = () => { const searchTerms = useSearchingItemStore((state) => state.searchTerms) const { resetState } = useSearchingItemStore((state) => state.actions) const { data, isLoading } = usePostStrategies({ page, size, searchTerms }) + const path = usePathname() useEffect(() => { resetState() - }, []) + }, [path]) const strategiesData = data?.content as StrategiesModel[] const totalPages = (data?.totalPages as number) || null From 4a8352619af61d151cff292e79c8ef9cd86c5089 Mon Sep 17 00:00:00 2001 From: ssumanlife <ksm9805@naver.com> Date: Tue, 10 Dec 2024 15:29:03 +0900 Subject: [PATCH 070/207] =?UTF-8?q?bug:=EA=B4=80=EB=A6=AC=ED=8E=98?= =?UTF-8?q?=EC=9D=B4=EC=A7=80=EC=97=90=EC=84=9C=20=EA=B4=80=EB=A6=AC?= =?UTF-8?q?=ED=95=98=EA=B8=B0=20=EB=B2=84=ED=8A=BC=EC=97=86=EA=B2=8C=20?= =?UTF-8?q?=EC=B2=98=EB=A6=AC=20(#58)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- app/(dashboard)/_ui/subscriber-item/index.tsx | 22 ++++++++++++------- 1 file changed, 14 insertions(+), 8 deletions(-) diff --git a/app/(dashboard)/_ui/subscriber-item/index.tsx b/app/(dashboard)/_ui/subscriber-item/index.tsx index 0d6c69d0..d3dbeed2 100644 --- a/app/(dashboard)/_ui/subscriber-item/index.tsx +++ b/app/(dashboard)/_ui/subscriber-item/index.tsx @@ -1,5 +1,7 @@ 'use client' +import { usePathname } from 'next/navigation' + import classNames from 'classnames/bind' import { PATH } from '@/shared/constants/path' @@ -25,6 +27,8 @@ const SubscriberItem = ({ subscribers, onClick, }: Props) => { + const currentPath = usePathname() + return ( <div className={cx('container')}> <div> @@ -37,14 +41,16 @@ const SubscriberItem = ({ {isSubscribed ? '구독취소' : '구독하기'} </Button> ) : ( - <LinkButton - href={`${PATH.MY_STRATEGIES}/manage/${strategyId}`} - size="small" - variant="filled" - className={cx('trader-button')} - > - 관리하기 - </LinkButton> + !currentPath.includes(PATH.STRATEGIES_MANAGE) && ( + <LinkButton + href={`${PATH.MY_STRATEGIES}/manage/${strategyId}`} + size="small" + variant="filled" + className={cx('trader-button')} + > + 관리하기 + </LinkButton> + ) )} </div> ) From 7128d3da211805d9b27723dd70c503631bdb5b01 Mon Sep 17 00:00:00 2001 From: deun <devdeun@gmail.com> Date: Tue, 10 Dec 2024 22:52:33 +0900 Subject: [PATCH 071/207] =?UTF-8?q?feat:=20OG=20=EC=9D=B4=EB=AF=B8?= =?UTF-8?q?=EC=A7=80=20=EB=B0=8F=20description=20=EC=B6=94=EA=B0=80=20(#60?= =?UTF-8?q?)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- app/layout.tsx | 2 +- app/opengraph-image.alt.txt | 1 + app/opengraph-image.png | Bin 0 -> 103231 bytes app/twitter-image.alt.txt | 1 + app/twitter-image.png | Bin 0 -> 103231 bytes 5 files changed, 3 insertions(+), 1 deletion(-) create mode 100644 app/opengraph-image.alt.txt create mode 100644 app/opengraph-image.png create mode 100644 app/twitter-image.alt.txt create mode 100644 app/twitter-image.png diff --git a/app/layout.tsx b/app/layout.tsx index 5e14ea54..acae7d62 100644 --- a/app/layout.tsx +++ b/app/layout.tsx @@ -8,7 +8,7 @@ import { pretendard } from './fonts' export const metadata: Metadata = { title: 'InvestMetic', - description: '', + description: '성공적인 투자 전략을 참고하거나 공유하고 싶다면 인베스트 메틱에서!', } const RootLayout = ({ children }: { children: React.ReactNode }) => { diff --git a/app/opengraph-image.alt.txt b/app/opengraph-image.alt.txt new file mode 100644 index 00000000..7d57aebb --- /dev/null +++ b/app/opengraph-image.alt.txt @@ -0,0 +1 @@ +Investmetic \ No newline at end of file diff --git a/app/opengraph-image.png b/app/opengraph-image.png new file mode 100644 index 0000000000000000000000000000000000000000..9fc506a79d9853cac0bb25ac9a2d2d1fde930d2c GIT binary patch literal 103231 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 literal 0 HcmV?d00001 diff --git a/app/twitter-image.alt.txt b/app/twitter-image.alt.txt new file mode 100644 index 00000000..7d57aebb --- /dev/null +++ b/app/twitter-image.alt.txt @@ -0,0 +1 @@ +Investmetic \ No newline at end of file diff --git a/app/twitter-image.png b/app/twitter-image.png new file mode 100644 index 0000000000000000000000000000000000000000..9fc506a79d9853cac0bb25ac9a2d2d1fde930d2c GIT binary patch literal 103231 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 literal 0 HcmV?d00001 From aff8c7bdb4678e59befce40a6325f89d2b6ce8f6 Mon Sep 17 00:00:00 2001 From: deun <devdeun@gmail.com> Date: Tue, 10 Dec 2024 23:27:32 +0900 Subject: [PATCH 072/207] =?UTF-8?q?fix:=20metadataBase=20URL=20=EC=84=A4?= =?UTF-8?q?=EC=A0=95=20(#60)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- app/layout.tsx | 1 + 1 file changed, 1 insertion(+) diff --git a/app/layout.tsx b/app/layout.tsx index acae7d62..df550b1f 100644 --- a/app/layout.tsx +++ b/app/layout.tsx @@ -9,6 +9,7 @@ import { pretendard } from './fonts' export const metadata: Metadata = { title: 'InvestMetic', description: '성공적인 투자 전략을 참고하거나 공유하고 싶다면 인베스트 메틱에서!', + metadataBase: new URL('https://www.investmetic.co.kr'), } const RootLayout = ({ children }: { children: React.ReactNode }) => { From 87b40ece84768bcfa47fb9f848228aba3812a613 Mon Sep 17 00:00:00 2001 From: kimpra <dustmqaksdltkfrlfdldi@gmail.com> Date: Wed, 11 Dec 2024 01:08:56 +0900 Subject: [PATCH 073/207] =?UTF-8?q?feat:=20=EA=B4=80=EB=A6=AC=EC=9E=90=20?= =?UTF-8?q?=ED=9A=8C=EC=9B=90=20=EA=B4=80=EB=A6=AC=20=ED=9A=8C=EC=9B=90=20?= =?UTF-8?q?=EC=82=AD=EC=A0=9C=EC=8B=9C=20confirm=20=EB=B0=9B=EA=B8=B0=20?= =?UTF-8?q?=EC=B6=94=EA=B0=80=20(#49)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- app/admin/users/_api/get-admin-users.ts | 2 +- app/admin/users/_api/set-table-body.tsx | 18 +++++++- app/admin/users/_ui/user-delete-button.tsx | 16 ++----- .../users/_ui/user-delete-modal/index.tsx | 45 +++++++++++++++++++ app/admin/users/page.tsx | 9 +++- 5 files changed, 73 insertions(+), 17 deletions(-) create mode 100644 app/admin/users/_ui/user-delete-modal/index.tsx diff --git a/app/admin/users/_api/get-admin-users.ts b/app/admin/users/_api/get-admin-users.ts index c6e3bb02..c9bbc629 100644 --- a/app/admin/users/_api/get-admin-users.ts +++ b/app/admin/users/_api/get-admin-users.ts @@ -10,7 +10,7 @@ interface ArgModel { size?: number } -const getAdminUsers = async ({ role, condition, keyword, page = 1, size }: ArgModel) => { +const getAdminUsers = async ({ role, condition, keyword, page = 1, size = 10 }: ArgModel) => { try { const res = await axiosInstance<AdminUsersResponeseModel>('/api/admin/users', { params: { diff --git a/app/admin/users/_api/set-table-body.tsx b/app/admin/users/_api/set-table-body.tsx index 0fdff9fe..9eea38a3 100644 --- a/app/admin/users/_api/set-table-body.tsx +++ b/app/admin/users/_api/set-table-body.tsx @@ -1,10 +1,18 @@ +import { Dispatch, SetStateAction } from 'react' + import Avatar from '@/shared/ui/avatar' import RoleSelect from '../_ui/role-select' import UserDeleteButton from '../_ui/user-delete-button' import { AdminUserInfoModel } from '../types' -const setTableBody = (data: AdminUserInfoModel[]) => +interface ArgModel { + data: AdminUserInfoModel[] + openModal: () => void + setDeleteUserId: Dispatch<SetStateAction<number>> +} + +const setTableBody = ({ data, openModal, setDeleteUserId }: ArgModel) => data.map((data, idx) => { return [ idx + 1, @@ -14,7 +22,13 @@ const setTableBody = (data: AdminUserInfoModel[]) => data.email, data.phone, <RoleSelect data={data} key={data.userId} />, - <UserDeleteButton userId={data.userId} key={data.userId} />, + <UserDeleteButton + onClick={() => { + openModal() + setDeleteUserId(data.userId) + }} + key={data.userId} + />, ] }) diff --git a/app/admin/users/_ui/user-delete-button.tsx b/app/admin/users/_ui/user-delete-button.tsx index d8912595..07b5e283 100644 --- a/app/admin/users/_ui/user-delete-button.tsx +++ b/app/admin/users/_ui/user-delete-button.tsx @@ -2,23 +2,13 @@ import { Button } from '@/shared/ui/button' -import useDeleteUser from '../_hooks/query/use-delete-user' - interface Props { - userId: number + onClick: () => void } -const UserDeleteButton = ({ userId }: Props) => { - const { mutate, isPending } = useDeleteUser(userId) - +const UserDeleteButton = ({ onClick }: Props) => { return ( - <Button - variant="filled" - onClick={() => mutate()} - disabled={isPending} - size="small" - style={{ padding: '7px 16px' }} - > + <Button variant="filled" onClick={onClick} size="small" style={{ padding: '7px 16px' }}> 강제탈퇴 </Button> ) diff --git a/app/admin/users/_ui/user-delete-modal/index.tsx b/app/admin/users/_ui/user-delete-modal/index.tsx new file mode 100644 index 00000000..fb204e27 --- /dev/null +++ b/app/admin/users/_ui/user-delete-modal/index.tsx @@ -0,0 +1,45 @@ +'use client' + +import { ModalAlertIcon } from '@/public/icons' +import classNames from 'classnames/bind' + +import { Button } from '@/shared/ui/button' +import Modal from '@/shared/ui/modal' +import styles from '@/shared/ui/modal/styles.module.scss' + +import useDeleteUser from '../../_hooks/query/use-delete-user' + +const cx = classNames.bind(styles) + +interface Props { + isModalOpen: boolean + userId: number + closeModal: () => void + onConfirm?: () => void +} + +const UserDeleteModal = ({ isModalOpen, userId, closeModal }: Props) => { + const { mutate, isPending } = useDeleteUser(userId) + + return ( + <Modal isOpen={isModalOpen} icon={ModalAlertIcon}> + <span className={cx('message')}>회원을 탈퇴시키겠습니까?</span> + <div className={cx('two-button')}> + <Button onClick={closeModal}>아니오</Button> + <Button + onClick={() => { + mutate() + closeModal() + }} + disabled={isPending} + variant="filled" + className={cx('button')} + > + 예 + </Button> + </div> + </Modal> + ) +} + +export default UserDeleteModal diff --git a/app/admin/users/page.tsx b/app/admin/users/page.tsx index a59d9b84..c103bee9 100644 --- a/app/admin/users/page.tsx +++ b/app/admin/users/page.tsx @@ -1,7 +1,10 @@ 'use client' +import { useState } from 'react' + import classNames from 'classnames/bind' +import useModal from '@/shared/hooks/custom/use-modal' import Pagination from '@/shared/ui/pagination' import SearchInput from '@/shared/ui/search-input' import Select from '@/shared/ui/select' @@ -13,6 +16,7 @@ import AdminContentsHeader from '../_ui/admin-header' import setTableBody from './_api/set-table-body' import useAdminUsers from './_hooks/query/use-admin-users' import useUserSearch from './_hooks/use-user-search-page' +import UserDeleteModal from './_ui/user-delete-modal' import styles from './page.module.scss' const cx = classNames.bind(styles) @@ -33,6 +37,8 @@ const AdminUsersPage = () => { } = useUserSearch() const { isLoading, data } = useAdminUsers({ role: activeTab, condition, keyword }) + const { isModalOpen, closeModal, openModal } = useModal() + const [deleteUserId, setDeleteUserId] = useState<number>(0) if (isLoading || !data) return null @@ -70,12 +76,13 @@ const AdminUsersPage = () => { /> <VerticalTable tableHead={['No.', '프로필', '이름', '닉네임', '이메일', '전화번호', '회원분류', '탈퇴']} - tableBody={setTableBody(data?.content)} + tableBody={setTableBody({ data: data?.content, openModal, setDeleteUserId })} countPerPage={10} currentPage={1} /> <Pagination currentPage={data?.page} maxPage={data?.totalPages} onPageChange={() => {}} /> </section> + <UserDeleteModal userId={deleteUserId} isModalOpen={isModalOpen} closeModal={closeModal} /> </> ) } From 33860d1e64c62ad8058979e76074f1893c31a8e5 Mon Sep 17 00:00:00 2001 From: ssumanlife <ksm9805@naver.com> Date: Wed, 11 Dec 2024 10:31:14 +0900 Subject: [PATCH 074/207] =?UTF-8?q?fix:=20=EC=B4=9D=EB=B3=84=EC=A0=90toFix?= =?UTF-8?q?ed=20=EC=A0=81=EC=9A=A9=20(#63)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- shared/ui/total-star/index.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/shared/ui/total-star/index.tsx b/shared/ui/total-star/index.tsx index 4af98681..37bb6bdd 100644 --- a/shared/ui/total-star/index.tsx +++ b/shared/ui/total-star/index.tsx @@ -26,7 +26,7 @@ const TotalStar = ({ <div className={cx('icon')}> <Star size={size} /> </div> - <p className={cx('text')}>{averageRating}</p> + <p className={cx('text')}>{averageRating !== 0 ? averageRating.toFixed(1) : averageRating}</p> <p className={cx('text')}>({totalElements})</p> </div> ) From 068994b72b9394e3da3ba5a0ad5a3a9e22e0aa31 Mon Sep 17 00:00:00 2001 From: ssumanlife <ksm9805@naver.com> Date: Wed, 11 Dec 2024 10:50:28 +0900 Subject: [PATCH 075/207] =?UTF-8?q?bug:=20=EA=B5=AC=EB=8F=85=20=EC=83=81?= =?UTF-8?q?=ED=83=9C=20=EC=A6=89=EC=8B=9C=20=EB=B0=98=EC=98=81=EB=90=98?= =?UTF-8?q?=EB=8F=84=EB=A1=9D=20=EC=88=98=EC=A0=95=20(#65)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- app/(dashboard)/_ui/strategies-item/subscribe.tsx | 4 +--- 1 file changed, 1 insertion(+), 3 deletions(-) diff --git a/app/(dashboard)/_ui/strategies-item/subscribe.tsx b/app/(dashboard)/_ui/strategies-item/subscribe.tsx index 8e9d7a78..68a31bac 100644 --- a/app/(dashboard)/_ui/strategies-item/subscribe.tsx +++ b/app/(dashboard)/_ui/strategies-item/subscribe.tsx @@ -27,9 +27,7 @@ const Subscribe = ({ strategyId, subscriptionStatus, traderName }: Props) => { const { mutate } = useGetSubscribe() useEffect(() => { - if (subscriptionStatus) { - setIsSubscribed(true) - } + setIsSubscribed(subscriptionStatus) }, [subscriptionStatus]) const handleSubscribe = (e: React.MouseEvent) => { From c58d5dba347eaafad2ae8ce974da8debecb37e9f Mon Sep 17 00:00:00 2001 From: ssumanlife <ksm9805@naver.com> Date: Wed, 11 Dec 2024 11:19:43 +0900 Subject: [PATCH 076/207] =?UTF-8?q?fix:=20=EC=83=81=ED=83=9C=20=EB=B0=8F?= =?UTF-8?q?=20=EB=8D=B0=EC=9D=B4=ED=84=B0=20=EC=B4=88=EA=B8=B0=ED=99=94=20?= =?UTF-8?q?=EC=A0=81=EC=9A=A9=20(#65)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../strategies/_ui/strategy-list/index.tsx | 14 ++++++++------ 1 file changed, 8 insertions(+), 6 deletions(-) diff --git a/app/(dashboard)/strategies/_ui/strategy-list/index.tsx b/app/(dashboard)/strategies/_ui/strategy-list/index.tsx index 13d82819..13262c3c 100644 --- a/app/(dashboard)/strategies/_ui/strategy-list/index.tsx +++ b/app/(dashboard)/strategies/_ui/strategy-list/index.tsx @@ -2,8 +2,6 @@ import { useEffect } from 'react' -import { usePathname } from 'next/navigation' - import StrategiesItem from '@/app/(dashboard)/_ui/strategies-item' import classNames from 'classnames/bind' @@ -28,12 +26,16 @@ const StrategyList = () => { }) const searchTerms = useSearchingItemStore((state) => state.searchTerms) const { resetState } = useSearchingItemStore((state) => state.actions) - const { data, isLoading } = usePostStrategies({ page, size, searchTerms }) - const path = usePathname() + const { data, isLoading, refetch } = usePostStrategies({ page, size, searchTerms }) useEffect(() => { - resetState() - }, [path]) + handleInitialize() + }, []) + + const handleInitialize = async () => { + await resetState() + await refetch() + } const strategiesData = data?.content as StrategiesModel[] const totalPages = (data?.totalPages as number) || null From cfb5a6fe49d81e7c67059117dc7af9e845ac00b0 Mon Sep 17 00:00:00 2001 From: ssumanlife <ksm9805@naver.com> Date: Wed, 11 Dec 2024 11:20:13 +0900 Subject: [PATCH 077/207] =?UTF-8?q?fix:=20=EB=9E=9C=EB=94=A9=20=EC=B4=9D?= =?UTF-8?q?=EB=B3=84=EC=A0=90,=20=EA=B3=B5=ED=86=B5=EC=BB=B4=ED=8F=AC?= =?UTF-8?q?=EB=84=8C=ED=8A=B8=EB=A1=9C=20=EC=A0=81=EC=9A=A9=20(#65)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../(home)/_ui/top-strategy-card/index.tsx | 9 ++------- .../_ui/top-strategy-card/styles.module.scss | 14 -------------- 2 files changed, 2 insertions(+), 21 deletions(-) diff --git a/app/(landing)/(home)/_ui/top-strategy-card/index.tsx b/app/(landing)/(home)/_ui/top-strategy-card/index.tsx index 3ae4806a..634e4398 100644 --- a/app/(landing)/(home)/_ui/top-strategy-card/index.tsx +++ b/app/(landing)/(home)/_ui/top-strategy-card/index.tsx @@ -1,7 +1,7 @@ -import { StarIcon } from '@/public/icons' import classNames from 'classnames/bind' import Avatar from '@/shared/ui/avatar' +import TotalStar from '@/shared/ui/total-star' import LineChart from '../line-chart' import styles from './styles.module.scss' @@ -48,12 +48,7 @@ const ContentDetails = ({ return ( <div className={cx('content-details-wrapper')}> <span className={cx('subscription')}>{subscriptionCount.toLocaleString()}명 구독</span> - <div className={cx('rating-wrapper')}> - <StarIcon width="24px" height="24px" /> - <span> - {averageRating} ({reviewCount}) - </span> - </div> + <TotalStar averageRating={averageRating} totalElements={reviewCount} /> </div> ) } diff --git a/app/(landing)/(home)/_ui/top-strategy-card/styles.module.scss b/app/(landing)/(home)/_ui/top-strategy-card/styles.module.scss index d1d9f731..be235eac 100644 --- a/app/(landing)/(home)/_ui/top-strategy-card/styles.module.scss +++ b/app/(landing)/(home)/_ui/top-strategy-card/styles.module.scss @@ -84,20 +84,6 @@ margin-top: 2px; color: $color-gray-800; } - - .rating-wrapper { - display: flex; - align-items: center; - - svg { - color: $color-yellow; - } - - span { - margin-top: 2px; - color: $color-gray-400; - } - } } .profit-wrapper { From 00a0249f58f5253465757709dd203b82667209be Mon Sep 17 00:00:00 2001 From: James <dawn147@snu.ac.kr> Date: Wed, 11 Dec 2024 11:25:28 +0900 Subject: [PATCH 078/207] =?UTF-8?q?feat:=20=ED=94=84=EB=A1=9C=ED=95=84=20?= =?UTF-8?q?=EC=88=98=EC=A0=95=20=EB=B0=8F=20=ED=9A=8C=EC=9B=90=20=ED=83=88?= =?UTF-8?q?=ED=87=B4=20api=20=EB=B0=8F=20=EC=BB=A4=EC=8A=A4=ED=85=80=20?= =?UTF-8?q?=ED=9B=85=20=EC=B6=94=EA=B0=80=20(#6)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../my/_api/delete-user-withdrawl.ts | 13 +++ app/(dashboard)/my/_api/patch-user-profile.ts | 25 +++++ app/(dashboard)/my/_api/post-account-image.ts | 1 - .../my/_hooks/query/use-patch-user-profile.ts | 91 +++++++++++++++++++ .../my/_hooks/query/use-user-withdrawl.ts | 23 +++++ 5 files changed, 152 insertions(+), 1 deletion(-) create mode 100644 app/(dashboard)/my/_api/delete-user-withdrawl.ts create mode 100644 app/(dashboard)/my/_api/patch-user-profile.ts create mode 100644 app/(dashboard)/my/_hooks/query/use-patch-user-profile.ts create mode 100644 app/(dashboard)/my/_hooks/query/use-user-withdrawl.ts diff --git a/app/(dashboard)/my/_api/delete-user-withdrawl.ts b/app/(dashboard)/my/_api/delete-user-withdrawl.ts new file mode 100644 index 00000000..31236cbb --- /dev/null +++ b/app/(dashboard)/my/_api/delete-user-withdrawl.ts @@ -0,0 +1,13 @@ +import axiosInstance from '@/shared/api/axios' + +interface WithDrawUserResponseModel<T> { + isSuccess: boolean + message: string + result: T + code: number +} + +export const deleteUser = async () => { + const response = await axiosInstance.delete<WithDrawUserResponseModel<void>>('/api/users') + return response.data +} diff --git a/app/(dashboard)/my/_api/patch-user-profile.ts b/app/(dashboard)/my/_api/patch-user-profile.ts new file mode 100644 index 00000000..953bfe7d --- /dev/null +++ b/app/(dashboard)/my/_api/patch-user-profile.ts @@ -0,0 +1,25 @@ +import axiosInstance from '@/shared/api/axios' + +import { UserProfileModel } from '../_hooks/query/use-patch-user-profile' + +interface PatchUserProfileModel { + isSuccess: boolean + message: string + result: string + code: number +} + +const patchUserProfile = async (data: UserProfileModel) => { + try { + const response = await axiosInstance.patch<PatchUserProfileModel>( + `/api/users/mypage/profile`, + data + ) + return response.data + } catch (err) { + console.error('Error updating user profile:', err) + throw err + } +} + +export default patchUserProfile diff --git a/app/(dashboard)/my/_api/post-account-image.ts b/app/(dashboard)/my/_api/post-account-image.ts index 7e433c37..a4a5d15c 100644 --- a/app/(dashboard)/my/_api/post-account-image.ts +++ b/app/(dashboard)/my/_api/post-account-image.ts @@ -44,6 +44,5 @@ export const deleteAccountImages = async ({ `/api/my-strategies/${strategyId}/delete-account-images`, imageIds ) - console.log(response.data) return response.data } diff --git a/app/(dashboard)/my/_hooks/query/use-patch-user-profile.ts b/app/(dashboard)/my/_hooks/query/use-patch-user-profile.ts new file mode 100644 index 00000000..98b0c6e2 --- /dev/null +++ b/app/(dashboard)/my/_hooks/query/use-patch-user-profile.ts @@ -0,0 +1,91 @@ +import { useMutation, useQueryClient } from '@tanstack/react-query' + +import patchProfile from '../../_api/patch-user-profile' + +export interface UserProfileModel { + nickname?: string | null + password?: string | null + imageDto?: { + imageName: string + size: number + } | null + phone?: string | null + email?: string | null + imageChange: boolean +} + +interface UpdateProfileModel { + profileData: UserProfileModel + imageFile?: File | null +} + +interface PatchProfileResponseModel { + isSuccess: boolean + message: string + result: string + code: number +} +interface UserProfileDataModel { + nickname: string + imageUrl: string | null + phone: string | null + email: string +} + +const usePatchUserProfile = () => { + const queryClient = useQueryClient() + + return useMutation<PatchProfileResponseModel, Error, UpdateProfileModel>({ + mutationFn: async ({ profileData, imageFile }: UpdateProfileModel) => { + try { + const updateData = { + ...profileData, + ...(imageFile && { + imageDto: { + imageName: imageFile.name, + size: imageFile.size, + }, + }), + } + + const profileResponse = await patchProfile(updateData) + + if (imageFile && profileResponse.result) { + await fetch(profileResponse.result, { + method: 'PUT', + body: imageFile, + headers: { + 'Content-Type': imageFile.type, + }, + }) + } + + return profileResponse + } catch (error) { + if (error instanceof Error) { + throw new Error(`프로필 업데이트 실패: ${error.message}`) + } + throw error + } + }, + onSuccess: (data) => { + queryClient.invalidateQueries({ queryKey: ['userProfile'] }) + + if (data.result) { + const imageUrl = data.result.split('?')[0] + queryClient.setQueryData<UserProfileDataModel>(['userProfile'], (oldData) => { + if (!oldData) return oldData + return { + ...oldData, + imageUrl, + } + }) + } + }, + onError: (error: Error) => { + console.error('프로필 업데이트 실패:', error.message) + }, + }) +} + +export default usePatchUserProfile diff --git a/app/(dashboard)/my/_hooks/query/use-user-withdrawl.ts b/app/(dashboard)/my/_hooks/query/use-user-withdrawl.ts new file mode 100644 index 00000000..5491ebb5 --- /dev/null +++ b/app/(dashboard)/my/_hooks/query/use-user-withdrawl.ts @@ -0,0 +1,23 @@ +import { useRouter } from 'next/navigation' + +import { useMutation } from '@tanstack/react-query' + +import { PATH } from '@/shared/constants/path' +import { useAuth } from '@/shared/hooks/custom/use-auth' + +import { deleteUser } from '../../_api/delete-user-withdrawl' + +export const useWithdraw = () => { + const router = useRouter() + const { logout } = useAuth() + + return useMutation({ + mutationFn: deleteUser, + onSuccess: (data) => { + if (data.isSuccess) { + logout() + router.replace(PATH.HOME) + } + }, + }) +} From 2073f0c16ce6042714035f1f2da97bd5fb9209c1 Mon Sep 17 00:00:00 2001 From: James <dawn147@snu.ac.kr> Date: Wed, 11 Dec 2024 11:26:06 +0900 Subject: [PATCH 079/207] =?UTF-8?q?remove:=20=EB=B6=88=ED=95=84=EC=9A=94?= =?UTF-8?q?=ED=95=9C=20=ED=8C=8C=EC=9D=BC=20=EC=82=AD=EC=A0=9C=20(#6)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- app/(dashboard)/my/_api/patch-profile.ts | 25 ------- .../my/_hooks/query/use-patch-profile.ts | 31 --------- .../my/profile/_ui/user-withdraw/index.tsx | 41 ----------- .../_ui/user-withdraw/styles.module.scss | 68 ------------------- app/(dashboard)/my/profile/page.module.scss | 24 ------- 5 files changed, 189 deletions(-) delete mode 100644 app/(dashboard)/my/_api/patch-profile.ts delete mode 100644 app/(dashboard)/my/_hooks/query/use-patch-profile.ts delete mode 100644 app/(dashboard)/my/profile/_ui/user-withdraw/index.tsx delete mode 100644 app/(dashboard)/my/profile/_ui/user-withdraw/styles.module.scss delete mode 100644 app/(dashboard)/my/profile/page.module.scss diff --git a/app/(dashboard)/my/_api/patch-profile.ts b/app/(dashboard)/my/_api/patch-profile.ts deleted file mode 100644 index e613effb..00000000 --- a/app/(dashboard)/my/_api/patch-profile.ts +++ /dev/null @@ -1,25 +0,0 @@ -import axiosInstance from '@/shared/api/axios' - -import { UserProfileModel } from '../_hooks/query/use-patch-profile' - -interface PatchUserProfileModel { - isSuccess: boolean - message: string - result: string - code: number -} - -const patchUserProfile = async (data: UserProfileModel) => { - try { - const response = await axiosInstance.patch<PatchUserProfileModel>( - `/api/users/mypage/profile`, - data - ) - return response.data - } catch (err) { - console.error('Error updating user profile:', err) - throw err - } -} - -export default patchUserProfile diff --git a/app/(dashboard)/my/_hooks/query/use-patch-profile.ts b/app/(dashboard)/my/_hooks/query/use-patch-profile.ts deleted file mode 100644 index 9b2f5aa4..00000000 --- a/app/(dashboard)/my/_hooks/query/use-patch-profile.ts +++ /dev/null @@ -1,31 +0,0 @@ -import { useMutation, useQueryClient } from '@tanstack/react-query' - -import patchProfile from '../../_api/patch-profile' - -export interface UserProfileModel { - nickname?: string - password?: string - imageDto?: { - imageName: string - size: number - } - phone?: string - email?: string - imageChange: boolean -} - -const usePatchUserProfile = () => { - const queryClient = useQueryClient() - - return useMutation({ - mutationFn: (data: UserProfileModel) => patchProfile(data), - onSuccess: () => { - queryClient.invalidateQueries({ queryKey: ['userProfile'] }) - }, - onError: (err) => { - console.error('Error updating user profile:', err) - }, - }) -} - -export default usePatchUserProfile diff --git a/app/(dashboard)/my/profile/_ui/user-withdraw/index.tsx b/app/(dashboard)/my/profile/_ui/user-withdraw/index.tsx deleted file mode 100644 index 6e443ff4..00000000 --- a/app/(dashboard)/my/profile/_ui/user-withdraw/index.tsx +++ /dev/null @@ -1,41 +0,0 @@ -'use client' - -import { useRouter } from 'next/navigation' - -import classNames from 'classnames/bind' - -import { Button } from '@/shared/ui/button' - -import styles from './styles.module.scss' - -const cx = classNames.bind(styles) - -const UserWithdraw = () => { - const router = useRouter() - const handleWithdraw = () => {} - const handleBack = () => { - router.back() - } - return ( - <div className={cx('container')}> - <p className={cx('title')}>회원탈퇴</p> - <div className={cx('line')}></div> - <p className={cx('message')}>회원 탈퇴 메세지</p> - <div className={cx('content')}> - <div className={cx('message-wrapper')}> - <p>ㆍ탈퇴 즉시 모든 개인정보가 삭제됩니다.</p> - <p>ㆍ구독한 전략에 대한 모든 내용이 삭제됩니다.</p> - </div> - </div> - <div className={cx('button-wrapper')}> - <Button className={cx('left-button')} onClick={handleBack}> - 뒤로가기 - </Button> - <Button className={cx('right-button')} variant="filled" onClick={handleWithdraw}> - 탈퇴 - </Button> - </div> - </div> - ) -} -export default UserWithdraw diff --git a/app/(dashboard)/my/profile/_ui/user-withdraw/styles.module.scss b/app/(dashboard)/my/profile/_ui/user-withdraw/styles.module.scss deleted file mode 100644 index 35e2f626..00000000 --- a/app/(dashboard)/my/profile/_ui/user-withdraw/styles.module.scss +++ /dev/null @@ -1,68 +0,0 @@ -.container { - background-color: $color-white; - width: 897px; - height: 854px; - padding: 44px 40px; -} - -.title { - @include typo-b1; - color: $color-gray-700; - margin-bottom: 22px; -} - -.line { - width: 100%; - height: 1px; - background-color: $color-gray-300; -} - -.message { - margin-top: 69px; - text-align: center; - @include typo-h4; - color: $color-gray-700; -} - -.content { - display: flex; - justify-content: center; - align-items: center; -} - -.message-wrapper { - margin-top: 51px; - border: 1px solid $color-gray-600; - border-radius: 6px; - width: 569px; - height: 206px; - color: $color-gray-700; - display: flex; - flex-direction: column; - justify-content: center; - align-items: center; - gap: 10px; - - p { - @include typo-b2; - margin: 0; - line-height: 1.5; - } -} - -.button-wrapper { - margin-top: 58px; - display: flex; - justify-content: center; - gap: 32px; - width: 100%; - height: 40px; - - .left-button { - width: 112px; - } - - .right-button { - width: 112px; - } -} diff --git a/app/(dashboard)/my/profile/page.module.scss b/app/(dashboard)/my/profile/page.module.scss deleted file mode 100644 index 9fe14a05..00000000 --- a/app/(dashboard)/my/profile/page.module.scss +++ /dev/null @@ -1,24 +0,0 @@ -.container { - padding: 40px 28px; -} - -.title { - margin-top: 40px; - @include typo-h4; - margin-bottom: 22px; -} - -.wrapper { - display: flex; - justify-content: space-between; -} - -.user-profile { - display: flex; - flex-direction: column; -} - -.link-button { - align-self: flex-end; - margin-top: 25px; -} From 46e143b75b1b76715878e0150267a3ac22a3feba Mon Sep 17 00:00:00 2001 From: James <dawn147@snu.ac.kr> Date: Wed, 11 Dec 2024 11:26:31 +0900 Subject: [PATCH 080/207] =?UTF-8?q?feat:=20=ED=94=84=EB=A1=9C=ED=95=84=20?= =?UTF-8?q?=EC=88=98=EC=A0=95=20=EA=B4=80=EB=A0=A8=20=EC=BB=A4=EC=8A=A4?= =?UTF-8?q?=ED=85=80=20=ED=9B=85=20=EC=B6=94=EA=B0=80(#6)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../profile/_hooks/custom/use-profile-form.ts | 246 ++++++++++++++++++ .../_hooks/custom/use-profile-image.ts | 74 ++++++ 2 files changed, 320 insertions(+) create mode 100644 app/(dashboard)/my/profile/_hooks/custom/use-profile-form.ts create mode 100644 app/(dashboard)/my/profile/_hooks/custom/use-profile-image.ts diff --git a/app/(dashboard)/my/profile/_hooks/custom/use-profile-form.ts b/app/(dashboard)/my/profile/_hooks/custom/use-profile-form.ts new file mode 100644 index 00000000..bbc70e07 --- /dev/null +++ b/app/(dashboard)/my/profile/_hooks/custom/use-profile-form.ts @@ -0,0 +1,246 @@ +import { ChangeEvent, useState } from 'react' + +import { SIGNUP_ERROR_MESSAGES } from '@/app/(landing)/signup/_constants/signup' + +import { checkNicknameDuplicate, checkPhoneDuplicate } from '@/shared/api/check-duplicate' +import { isValidNickname, isValidPassword, isValidPhone } from '@/shared/utils/validation' + +import { ProfileModel } from '../../../_api/get-profile' +import { + ProfileFormErrorsModel, + ProfileFormModel, + ProfileFormStateModel, +} from '../../_ui/user-info/types' + +const initialFormState = { + isNicknameVerified: false, + isPhoneVerified: false, + isPasswordVerified: false, +} + +export const useProfileForm = (profile: ProfileModel) => { + const initialForm: ProfileFormModel = { + name: profile?.userName || '', + nickname: profile?.nickname || '', + email: profile?.email || '', + password: '', + passwordConfirm: '', + phone: profile?.phone || '', + birthDate: profile?.birthDate || '', + } + + const [form, setForm] = useState<ProfileFormModel>(initialForm) + const [formState, setFormState] = useState<ProfileFormStateModel>(initialFormState) + const [errors, setErrors] = useState<ProfileFormErrorsModel>({}) + const [hasNicknameChanged, setHasNicknameChanged] = useState(false) + const [hasPhoneChanged, setHasPhoneChanged] = useState(false) + const [successMessages, setSuccessMessages] = useState<{ [key: string]: string | null }>({ + nickname: null, + phone: null, + password: null, + }) + + const handleInputChange = (e: ChangeEvent<HTMLInputElement>) => { + const { name, value } = e.target + setForm((prev) => ({ ...prev, [name]: value })) + + if (name === 'nickname') { + const isChanged = value !== profile?.nickname + setHasNicknameChanged(isChanged) + if (isChanged) { + setFormState((prev) => ({ ...prev, isNicknameVerified: false })) + setSuccessMessages((prev) => ({ ...prev, nickname: null })) + if (value && !isValidNickname(value)) { + setErrors((prev) => ({ ...prev, nickname: SIGNUP_ERROR_MESSAGES.NICKNAME_LENGTH })) + } else { + setErrors((prev) => ({ ...prev, nickname: null })) + } + } else { + setFormState((prev) => ({ ...prev, isNicknameVerified: true })) + setErrors((prev) => ({ ...prev, nickname: null })) + } + } + + if (name === 'phone') { + const isChanged = value !== profile?.phone + setHasPhoneChanged(isChanged) + if (isChanged) { + setFormState((prev) => ({ ...prev, isPhoneVerified: false })) + setSuccessMessages((prev) => ({ ...prev, phone: null })) + if (value && !isValidPhone(value)) { + setErrors((prev) => ({ ...prev, phone: SIGNUP_ERROR_MESSAGES.PHONE_INVALID })) + } else { + setErrors((prev) => ({ ...prev, phone: null })) + } + } else { + setFormState((prev) => ({ ...prev, isPhoneVerified: true })) + setErrors((prev) => ({ ...prev, phone: null })) + } + } + + if (name === 'password' || name === 'passwordConfirm') { + setFormState((prev) => ({ ...prev, isPasswordVerified: false })) + setSuccessMessages((prev) => ({ ...prev, password: null })) + setErrors((prev) => ({ ...prev, password: null })) + } + } + + const handleNicknameCheck = async () => { + if (!isValidNickname(form.nickname)) { + setErrors((prev) => ({ ...prev, nickname: SIGNUP_ERROR_MESSAGES.NICKNAME_LENGTH })) + return + } + + try { + const response = await checkNicknameDuplicate(form.nickname) + if (response.result.isAvailable) { + setFormState((prev) => ({ ...prev, isNicknameVerified: true })) + setErrors((prev) => ({ ...prev, nickname: null })) + setSuccessMessages((prev) => ({ ...prev, nickname: '사용할 수 있는 닉네임입니다.' })) + } else { + setFormState((prev) => ({ ...prev, isNicknameVerified: false })) + setErrors((prev) => ({ ...prev, nickname: SIGNUP_ERROR_MESSAGES.NICKNAME_DUPLICATED })) + setSuccessMessages((prev) => ({ ...prev, nickname: null })) + } + } catch (err) { + console.error('닉네임 중복 확인 실패:', err) + setFormState((prev) => ({ ...prev, isNicknameVerified: false })) + setErrors((prev) => ({ ...prev, nickname: SIGNUP_ERROR_MESSAGES.NICKNAME_NOT_ALLOWED })) + setSuccessMessages((prev) => ({ ...prev, nickname: null })) + } + } + + const handlePhoneCheck = async () => { + if (!isValidPhone(form.phone)) { + setErrors((prev) => ({ ...prev, phone: SIGNUP_ERROR_MESSAGES.PHONE_INVALID })) + return + } + + try { + const response = await checkPhoneDuplicate(form.phone) + if (response.result.isAvailable) { + setFormState((prev) => ({ ...prev, isPhoneVerified: true })) + setErrors((prev) => ({ ...prev, phone: null })) + setSuccessMessages((prev) => ({ ...prev, phone: '사용할 수 있는 휴대폰 번호입니다.' })) + } else { + setFormState((prev) => ({ ...prev, isPhoneVerified: false })) + setErrors((prev) => ({ ...prev, phone: SIGNUP_ERROR_MESSAGES.PHONE_DUPLICATED })) + setSuccessMessages((prev) => ({ ...prev, phone: null })) + } + } catch (err) { + console.error('휴대폰 번호 중복 확인 실패:', err) + setFormState((prev) => ({ ...prev, isPhoneVerified: false })) + setErrors((prev) => ({ ...prev, phone: SIGNUP_ERROR_MESSAGES.PHONE_CHECK_FAILED })) + setSuccessMessages((prev) => ({ ...prev, phone: null })) + } + } + + const handlePasswordCheck = () => { + if (!form.password && !form.passwordConfirm) { + setFormState((prev) => ({ ...prev, isPasswordVerified: true })) + setErrors((prev) => ({ ...prev, password: null })) + return + } + + if (!form.password || !form.passwordConfirm) { + setErrors((prev) => ({ + ...prev, + password: SIGNUP_ERROR_MESSAGES.PASSWORD_REQUIRED, + })) + setSuccessMessages((prev) => ({ ...prev, password: null })) + return + } + + if (!isValidPassword(form.password)) { + setErrors((prev) => ({ + ...prev, + password: SIGNUP_ERROR_MESSAGES.PASSWORD_INVALID, + })) + setSuccessMessages((prev) => ({ ...prev, password: null })) + return + } + + if (form.password !== form.passwordConfirm) { + setErrors((prev) => ({ + ...prev, + password: SIGNUP_ERROR_MESSAGES.PASSWORD_MISMATCH, + })) + setSuccessMessages((prev) => ({ ...prev, password: null })) + return + } + + setFormState((prev) => ({ ...prev, isPasswordVerified: true })) + setErrors((prev) => ({ ...prev, password: null })) + setSuccessMessages((prev) => ({ ...prev, password: '비밀번호가 확인되었습니다.' })) + } + + const validateChangedFields = () => { + const errors: ProfileFormErrorsModel = {} + + if (hasNicknameChanged) { + if (!formState.isNicknameVerified) { + errors.nickname = SIGNUP_ERROR_MESSAGES.NICKNAME_CHECK_REQUIRED + } + } + + if (hasPhoneChanged) { + if (!formState.isPhoneVerified) { + errors.phone = SIGNUP_ERROR_MESSAGES.PHONE_CHECK_REQUIRED + } + } + + if (form.password || form.passwordConfirm) { + if (!formState.isPasswordVerified) { + errors.password = SIGNUP_ERROR_MESSAGES.PASSWORD_REQUIRED + } + } + + setErrors(errors) + return Object.keys(errors).length === 0 + } + + const isFormValid = (isImageVerified: boolean) => { + if (!hasNicknameChanged && !hasPhoneChanged && !form.password && !form.passwordConfirm) { + return isImageVerified + } + + if (hasNicknameChanged && !formState.isNicknameVerified) { + return false + } + + if (hasPhoneChanged && !formState.isPhoneVerified) { + return false + } + + if ((form.password || form.passwordConfirm) && !formState.isPasswordVerified) { + return false + } + + return true + } + + const getUpdatedFields = () => { + return { + nickname: hasNicknameChanged ? form.nickname : null, + phone: hasPhoneChanged ? form.phone : null, + password: form.password || null, + email: form.email, + } + } + + return { + form, + formState, + errors, + successMessages, + hasNicknameChanged, + hasPhoneChanged, + handleInputChange, + handleNicknameCheck, + handlePhoneCheck, + handlePasswordCheck, + validateChangedFields, + isFormValid, + getUpdatedFields, + } +} diff --git a/app/(dashboard)/my/profile/_hooks/custom/use-profile-image.ts b/app/(dashboard)/my/profile/_hooks/custom/use-profile-image.ts new file mode 100644 index 00000000..22fa90ef --- /dev/null +++ b/app/(dashboard)/my/profile/_hooks/custom/use-profile-image.ts @@ -0,0 +1,74 @@ +import { ChangeEvent, useEffect, useState } from 'react' + +const MAX_IMAGE_SIZE = 2 * 1024 * 1024 + +export const useProfileImage = () => { + const [selectedImage, setSelectedImage] = useState<File | null>(null) + const [previewUrl, setPreviewUrl] = useState<string | null>(null) + const [imageError, setImageError] = useState<string | null>(null) + const [isImageVerified, setIsImageVerified] = useState(false) + + useEffect(() => { + return () => { + if (previewUrl) { + URL.revokeObjectURL(previewUrl) + } + } + }, [previewUrl]) + + const validateImage = (file: File): boolean => { + setImageError(null) + setIsImageVerified(false) + + if (!file.type.startsWith('image/')) { + setImageError('이미지 파일만 업로드 가능합니다.') + return false + } + + if (file.size > MAX_IMAGE_SIZE) { + setImageError('이미지 크기는 2MB 이하여야 합니다.') + return false + } + + setIsImageVerified(true) + return true + } + + const handleImageChange = (e: ChangeEvent<HTMLInputElement>) => { + const file = e.target.files?.[0] + if (!file) return + + if (validateImage(file)) { + const objectUrl = URL.createObjectURL(file) + setPreviewUrl(objectUrl) + setSelectedImage(file) + } else { + setSelectedImage(null) + setPreviewUrl(null) + } + } + + const handleImageDelete = () => { + if (previewUrl) { + URL.revokeObjectURL(previewUrl) + } + setSelectedImage(null) + setPreviewUrl(null) + setImageError(null) + setIsImageVerified(false) + + const fileInput = document.getElementById('profile-image') as HTMLInputElement + if (fileInput) { + fileInput.value = '' + } + } + + return { + selectedImage, + previewUrl, + imageError, + isImageVerified, + handleImageChange, + handleImageDelete, + } +} From 5f86b4fbd3bcf0c16faa03529cd668b0026fb6f2 Mon Sep 17 00:00:00 2001 From: James <dawn147@snu.ac.kr> Date: Wed, 11 Dec 2024 11:26:58 +0900 Subject: [PATCH 081/207] =?UTF-8?q?feat:=20=ED=9A=8C=EC=9B=90=20=ED=83=88?= =?UTF-8?q?=ED=87=B4=20=EB=AA=A8=EB=8B=AC=20=EC=B6=94=EA=B0=80=20(#6)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- shared/ui/modal/withdraw-check-modal.tsx | 36 ++++++++++++++++++++++++ 1 file changed, 36 insertions(+) create mode 100644 shared/ui/modal/withdraw-check-modal.tsx diff --git a/shared/ui/modal/withdraw-check-modal.tsx b/shared/ui/modal/withdraw-check-modal.tsx new file mode 100644 index 00000000..4441ad63 --- /dev/null +++ b/shared/ui/modal/withdraw-check-modal.tsx @@ -0,0 +1,36 @@ +'use client' + +import { ModalAlertIcon } from '@/public/icons' +import classNames from 'classnames/bind' + +import Modal from '.' +import { Button } from '../button' +import styles from './styles.module.scss' + +const cx = classNames.bind(styles) + +interface Props { + isModalOpen: boolean + onCloseModal: () => void + onConfirm: () => void +} + +const WithdrawCheckModal = ({ isModalOpen, onCloseModal, onConfirm }: Props) => { + return ( + <Modal isOpen={isModalOpen} icon={ModalAlertIcon}> + <span className={cx('message')}> + 정말 탈퇴하시겠습니까? + <br /> + 탈퇴 시 모든 정보가 삭제됩니다. + </span> + <div className={cx('two-button')}> + <Button onClick={onCloseModal}>아니오</Button> + <Button onClick={onConfirm} variant="filled" className={cx('button')}> + 예 + </Button> + </div> + </Modal> + ) +} + +export default WithdrawCheckModal From 2e42a2d1b671511ab3145913af57f41b75506ff5 Mon Sep 17 00:00:00 2001 From: James <dawn147@snu.ac.kr> Date: Wed, 11 Dec 2024 11:28:33 +0900 Subject: [PATCH 082/207] =?UTF-8?q?feat:=20=EA=B8=B0=ED=83=80=20=EB=B3=80?= =?UTF-8?q?=EA=B2=BD=20=EC=82=AC=ED=95=AD=20(#6)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../my/profile/_ui/user-info/types.ts | 18 ++++++++++++++++++ app/(landing)/signup/_constants/signup.ts | 2 ++ 2 files changed, 20 insertions(+) diff --git a/app/(dashboard)/my/profile/_ui/user-info/types.ts b/app/(dashboard)/my/profile/_ui/user-info/types.ts index 05409966..83e7843c 100644 --- a/app/(dashboard)/my/profile/_ui/user-info/types.ts +++ b/app/(dashboard)/my/profile/_ui/user-info/types.ts @@ -16,6 +16,7 @@ export interface ProfileFormModel { export interface ProfileFormStateModel { isNicknameVerified: boolean isPhoneVerified: boolean + isPasswordVerified: boolean } export interface ProfileFormErrorsModel { @@ -24,3 +25,20 @@ export interface ProfileFormErrorsModel { passwordConfirm?: ProfileErrorMessageType | null phone?: ProfileErrorMessageType | null } + +export interface UserProfileModel { + nickName: string + phoneNum: string + password?: string + email: string + imageChange: boolean +} + +export interface ProfileModel { + userName: string + nickname: string + email: string + phone: string + birthDate: string + profileImage?: string | null +} diff --git a/app/(landing)/signup/_constants/signup.ts b/app/(landing)/signup/_constants/signup.ts index c2bcc969..178eac60 100644 --- a/app/(landing)/signup/_constants/signup.ts +++ b/app/(landing)/signup/_constants/signup.ts @@ -4,6 +4,8 @@ export const SIGNUP_ERROR_MESSAGES = { NICKNAME_LENGTH: '닉네임은 2글자 이상 10글자 이하로 입력해주세요.', NICKNAME_CHECK_REQUIRED: '닉네임 중복확인이 필요합니다.', NICKNAME_DUPLICATED: '이미 사용 중인 닉네임입니다.', + NICKNAME_NOT_ALLOWED: + '닉네임은 2~10자 이내로 설정해야 하며, 특수문자는 . _ - 만 사용할 수 있습니다.', NICKNAME_CHECK_FAILED: '닉네임 중복 확인에 실패했습니다.', NAME_MIN_LENGTH: '이름을 2자 이상 입력해주세요.', PASSWORD_REQUIRED: '비밀번호를 입력해주세요.', From 18a19a4f07ddc89b7d47a36845709c70cec55da6 Mon Sep 17 00:00:00 2001 From: James <dawn147@snu.ac.kr> Date: Wed, 11 Dec 2024 11:29:14 +0900 Subject: [PATCH 083/207] =?UTF-8?q?feat:=20=ED=94=84=EB=A1=9C=ED=95=84=20?= =?UTF-8?q?=EA=B4=80=EB=A0=A8=20=ED=8E=98=EC=9D=B4=EC=A7=80=20=ED=8D=BC?= =?UTF-8?q?=EB=B8=94=EB=A6=AC=EC=8B=B1=20=EB=B0=8F=20=EA=B8=B0=EB=8A=A5=20?= =?UTF-8?q?=EC=97=B0=EA=B2=B0=20(#6)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../my/profile/_ui/user-info/index.tsx | 361 ++++++------------ .../profile/_ui/user-info/styles.module.scss | 120 +++--- .../my/profile/_ui/user-profile/index.tsx | 5 +- .../_ui/user-profile/styles.module.scss | 2 +- app/(dashboard)/my/profile/edit/page.tsx | 26 +- .../my/profile/edit/styles.module.scss | 27 ++ app/(dashboard)/my/profile/page.tsx | 11 +- app/(dashboard)/my/profile/styles.module.scss | 27 ++ app/(dashboard)/my/profile/withdraw/page.tsx | 80 +++- .../my/profile/withdraw/styles.module.scss | 74 ++++ 10 files changed, 441 insertions(+), 292 deletions(-) create mode 100644 app/(dashboard)/my/profile/edit/styles.module.scss create mode 100644 app/(dashboard)/my/profile/styles.module.scss create mode 100644 app/(dashboard)/my/profile/withdraw/styles.module.scss diff --git a/app/(dashboard)/my/profile/_ui/user-info/index.tsx b/app/(dashboard)/my/profile/_ui/user-info/index.tsx index 8f95dc22..5ebaf0ad 100644 --- a/app/(dashboard)/my/profile/_ui/user-info/index.tsx +++ b/app/(dashboard)/my/profile/_ui/user-info/index.tsx @@ -1,16 +1,11 @@ 'use client' -import { ChangeEvent, useState } from 'react' - +import Image from 'next/image' import { useRouter } from 'next/navigation' -import { SIGNUP_ERROR_MESSAGES } from '@/app/(landing)/signup/_constants/signup' import { CameraIcon } from '@/public/icons' -import axios from 'axios' import classNames from 'classnames/bind' -import axiosInstance from '@/shared/api/axios' -import { checkNicknameDuplicate, checkPhoneDuplicate } from '@/shared/api/check-duplicate' import { PATH } from '@/shared/constants/path' import Avatar from '@/shared/ui/avatar' import { Button } from '@/shared/ui/button' @@ -18,245 +13,122 @@ import Input from '@/shared/ui/input' import { LinkButton } from '@/shared/ui/link-button' import { ProfileModel } from '../../../_api/get-profile' +import usePatchUserProfile from '../../../_hooks/query/use-patch-user-profile' +import { useProfileForm } from '../../_hooks/custom/use-profile-form' +import { useProfileImage } from '../../_hooks/custom/use-profile-image' import styles from './styles.module.scss' -import { ProfileFormErrorsModel, ProfileFormModel, ProfileFormStateModel } from './types' -import { validateProfileForm } from './utils' const cx = classNames.bind(styles) -const initialFormState = { - isNicknameVerified: false, - isPhoneVerified: false, -} - interface Props { isEditable?: boolean profile: ProfileModel } -const uploadImageToS3 = async (presignedUrl: string, file: File): Promise<void> => { - try { - await axiosInstance.put(presignedUrl, file, { - headers: { - 'Content-Type': file.type, - }, - }) - } catch (err) { - console.error('이미지 업로드 실패:', err) - throw new Error('이미지 업로드에 실패했습니다') - } -} const UserInfo = ({ profile, isEditable = false }: Props) => { const router = useRouter() - - const initialForm: ProfileFormModel = { - name: profile?.userName || '', - nickname: profile?.nickname || '', - email: profile?.email || '', - password: '', - passwordConfirm: '', - phone: profile?.phone || '', - birthDate: profile?.birthDate || '', - } - - const [form, setForm] = useState<ProfileFormModel>(initialForm) - const [formState, setFormState] = useState<ProfileFormStateModel>(initialFormState) - const [errors, setErrors] = useState<ProfileFormErrorsModel>({}) - const [isValidated, setIsValidated] = useState(false) - const [isNicknameModified, setIsNicknameModified] = useState(false) - const [isPhoneModified, setIsPhoneModified] = useState(false) - const [selectedImage, setSelectedImage] = useState<File | null>(null) - const [previewUrl, setPreviewUrl] = useState<string | null>(null) - const [isUploading, setIsUploading] = useState(false) - - const handleInputChange = (e: ChangeEvent<HTMLInputElement>) => { - const { name, value } = e.target - setForm((prev) => ({ ...prev, [name]: value })) - - if (isValidated) { - setErrors((prev) => ({ - ...prev, - [name]: null, - })) - } - - if (name === 'nickname') { - setIsNicknameModified(true) - setErrors((prev) => ({ ...prev, nickname: null })) - } - if (name === 'phone') { - setIsPhoneModified(true) - setErrors((prev) => ({ ...prev, phone: null })) - } - } - - const handleImageChange = (e: ChangeEvent<HTMLInputElement>) => { - const file = e.target.files?.[0] - if (!file) return - - if (!file.type.startsWith('image/')) { - alert('이미지 파일만 업로드가 가능합니다.') - return - } - - const maxSize = 5 * 1024 * 1024 - if (file.size > maxSize) { - alert('파일 크기는 5MB 이하여야 합니다.') - return - } - - const reader = new FileReader() - reader.onload = (e) => { - setPreviewUrl(e.target?.result as string) - } - reader.readAsDataURL(file) - - setSelectedImage(file) - } - - const handleNicknameCheck = async () => { - try { - const response = await checkNicknameDuplicate(form.nickname) - if (response.result.isAvailable) { - setFormState((prev) => ({ ...prev, isNicknameVerified: true })) - setIsNicknameModified(false) - if (errors.nickname) { - setErrors((prev) => ({ ...prev, nickname: null })) - } - } else { - setErrors((prev) => ({ ...prev, nickname: SIGNUP_ERROR_MESSAGES.NICKNAME_DUPLICATED })) - setFormState((prev) => ({ ...prev, isNicknameVerified: false })) - } - } catch (err) { - console.error('닉네임 중복 확인 실패:', err) - setErrors((prev) => ({ ...prev, nickname: SIGNUP_ERROR_MESSAGES.NICKNAME_CHECK_FAILED })) - setFormState((prev) => ({ ...prev, isNicknameVerified: false })) - } - } - - const handlePhoneCheck = async () => { - try { - const response = await checkPhoneDuplicate(form.phone) - if (response.result.isAvailable) { - setFormState((prev) => ({ ...prev, isPhoneVerified: true })) - setIsPhoneModified(false) - if (errors.phone) { - setErrors((prev) => ({ ...prev, phone: null })) - } - } else { - setErrors((prev) => ({ ...prev, phone: SIGNUP_ERROR_MESSAGES.PHONE_DUPLICATED })) - setFormState((prev) => ({ ...prev, isPhoneVerified: false })) - } - } catch (err) { - console.error('휴대폰 번호 중복 확인 실패:', err) - setErrors((prev) => ({ ...prev, phone: SIGNUP_ERROR_MESSAGES.PHONE_CHECK_FAILED })) - setFormState((prev) => ({ ...prev, isPhoneVerified: false })) - } - } - - const handleImageDelete = () => { - setSelectedImage(null) - setPreviewUrl(null) - } + const updateProfile = usePatchUserProfile() + + const { + form, + errors, + successMessages, + hasNicknameChanged, + hasPhoneChanged, + handleInputChange, + handleNicknameCheck, + handlePhoneCheck, + handlePasswordCheck, + validateChangedFields, + isFormValid, + getUpdatedFields, + } = useProfileForm(profile) + + const { + selectedImage, + previewUrl, + imageError, + isImageVerified, + handleImageChange, + handleImageDelete, + } = useProfileImage() const handleBack = () => { router.back() } const handleFormSubmit = async () => { - const formErrors = validateProfileForm( - form, - formState.isNicknameVerified, - formState.isPhoneVerified - ) - setIsValidated(true) - - if (Object.keys(formErrors).length > 0) { - setErrors(formErrors) - return - } + if (!validateChangedFields()) return try { - setIsUploading(true) - + const updatedFields = getUpdatedFields() const updateData = { - nickName: form.nickname, - phoneNum: form.phone, - password: form.password || null, - email: form.email, + ...updatedFields, imageChange: selectedImage !== null, - profileImage: selectedImage - ? { - imageName: selectedImage.name, - size: selectedImage.size, - } - : null, - } - - console.log('요청 데이터:', updateData) - - const response = await axiosInstance.patch('/api/users/mypage/profile', updateData) - - if (!response.data.isSuccess) { - throw new Error(response.data.message) } - if (selectedImage && response.data.data?.presignedUrl) { - await uploadImageToS3(response.data.data.presignedUrl, selectedImage) - } - - alert('프로필이 성공적으로 업데이트되었습니다.') - router.push(PATH.PROFILE) + await updateProfile.mutateAsync( + { + profileData: updateData, + imageFile: selectedImage, + }, + { + onSuccess: () => { + router.push(PATH.PROFILE) + }, + } + ) } catch (err) { console.error('프로필 업데이트 실패:', err) - if (axios.isAxiosError(err)) { - const errorMessage = err.response?.data?.message || '프로필 업데이트에 실패했습니다.' - alert(errorMessage) - } else { - alert('프로필 업데이트에 실패했습니다. 다시 시도해주세요.') - } - } finally { - setIsUploading(false) } } - if (!profile) { - return null - } + if (!profile) return null return ( <div className={cx('container')}> <p className={cx('title')}>개인 정보</p> <div className={cx('line')}></div> - <div className={cx('content')}> <div className={cx('content-wrapper')}> <div className={cx('left-wrapper')}> - <div className={cx('avatar-wrapper')}> - {previewUrl ? ( - <img src={previewUrl} alt="Preview" className={cx('avatar-preview')} /> + <div className={cx('avatar-wrapper', { isEditable })}> + {previewUrl || profile.imageUrl ? ( + <div className={cx('image-container')}> + <Image + src={profile.imageUrl as string} + alt="Profile" + width={200} + height={200} + className={cx('avatar-preview')} + unoptimized + /> + </div> ) : ( <Avatar size="xxlarge" /> )} - <div className={cx('camera-wrapper')}> - <input - type="file" - id="profile-image" - accept="image/*" - onChange={handleImageChange} - style={{ display: 'none' }} - /> - <label htmlFor="profile-image"> - <CameraIcon - className={cx('camera-icon')} - style={{ cursor: isUploading ? 'wait' : 'pointer' }} + {isEditable && ( + <div className={cx('camera-wrapper')}> + <input + type="file" + id="profile-image" + accept="image/*" + onChange={handleImageChange} + style={{ display: 'none' }} /> - </label> - </div> + <label htmlFor="profile-image"> + <CameraIcon + className={cx('camera-icon')} + style={{ cursor: updateProfile.isPending ? 'wait' : 'pointer' }} + /> + </label> + </div> + )} </div> {isEditable && selectedImage && ( <Button onClick={handleImageDelete}>프로필 사진 삭제</Button> )} + {imageError && <p className={cx('error-message')}>{imageError}</p>} </div> <div className={cx('right-wrapper')}> @@ -265,6 +137,7 @@ const UserInfo = ({ profile, isEditable = false }: Props) => { 개인 정보 수정 </LinkButton> )} + <div className={cx('first-row')}> <p className={cx('title')}>이름</p> <Input @@ -274,7 +147,7 @@ const UserInfo = ({ profile, isEditable = false }: Props) => { inputSize="compact" className={cx('input')} isWhiteDisabled={!isEditable} - disabled={isEditable} + disabled={true} /> </div> @@ -286,7 +159,7 @@ const UserInfo = ({ profile, isEditable = false }: Props) => { value={form.email} className={cx('input')} isWhiteDisabled={!isEditable} - disabled={isEditable} + disabled={true} /> </div> <div> @@ -301,17 +174,16 @@ const UserInfo = ({ profile, isEditable = false }: Props) => { className={cx('input')} inputSize="compact" isWhiteDisabled={!isEditable} - errorMessage={errors.phone} /> - {isEditable && <Button onClick={handlePhoneCheck}>확인</Button>} + {isEditable && ( + <Button onClick={handlePhoneCheck} disabled={!hasPhoneChanged || !form.phone}> + 확인 + </Button> + )} </div> - {formState.isPhoneVerified && !isPhoneModified && ( - <p className={cx('verified-message')}>사용할 수 있는 휴대폰 번호입니다.</p> - )} - {isPhoneModified && ( - <p className={cx('modified-message')}> - 휴대폰 번호가 수정되었습니다. 다시 확인해 주세요. - </p> + {errors.phone && <p className={cx('error-message')}>{errors.phone}</p>} + {successMessages.phone && ( + <p className={cx('success-message')}>{successMessages.phone}</p> )} </div> </div> @@ -325,7 +197,7 @@ const UserInfo = ({ profile, isEditable = false }: Props) => { value={form.birthDate} className={cx('input')} isWhiteDisabled={!isEditable} - disabled={isEditable} + disabled={true} /> </div> <div> @@ -340,17 +212,19 @@ const UserInfo = ({ profile, isEditable = false }: Props) => { onChange={handleInputChange} className={cx('input')} isWhiteDisabled={!isEditable} - errorMessage={errors.nickname} /> - {isEditable && <Button onClick={handleNicknameCheck}>확인</Button>} + {isEditable && ( + <Button + onClick={handleNicknameCheck} + disabled={!hasNicknameChanged || !form.nickname} + > + 확인 + </Button> + )} </div> - {formState.isNicknameVerified && !isNicknameModified && ( - <p className={cx('verified-message')}>사용할 수 있는 닉네임입니다.</p> - )} - {isNicknameModified && ( - <p className={cx('modified-message')}> - 닉네임이 수정되었습니다. 다시 중복확인해 주세요. - </p> + {errors.nickname && <p className={cx('error-message')}>{errors.nickname}</p>} + {successMessages.nickname && ( + <p className={cx('success-message')}>{successMessages.nickname}</p> )} </div> </div> @@ -369,21 +243,32 @@ const UserInfo = ({ profile, isEditable = false }: Props) => { onChange={handleInputChange} placeholder="비밀번호를 입력하세요" className={cx('input')} - errorMessage={errors.password} /> </div> <div> <p className={cx('title')}>비밀번호 확인</p> - <Input - id="passwordConfirm" - name="passwordConfirm" - type="password" - value={form.passwordConfirm} - onChange={handleInputChange} - placeholder="한 번 더 입력하세요" - className={cx('input')} - inputSize="compact" - /> + <div className={cx('position-wrapper')}> + <Input + id="passwordConfirm" + name="passwordConfirm" + type="password" + value={form.passwordConfirm} + onChange={handleInputChange} + placeholder="한 번 더 입력하세요" + className={cx('input')} + inputSize="compact" + /> + <Button + onClick={handlePasswordCheck} + disabled={!form.password && !form.passwordConfirm} + > + 확인 + </Button> + </div> + {errors.password && <p className={cx('error-message')}>{errors.password}</p>} + {successMessages.password && ( + <p className={cx('success-message')}>{successMessages.password}</p> + )} </div> </div> )} @@ -398,16 +283,20 @@ const UserInfo = ({ profile, isEditable = false }: Props) => { </div> {isEditable && ( <div className={cx('button-wrapper')}> - <Button className={cx('left-button')} onClick={handleBack} disabled={isUploading}> + <Button + className={cx('left-button')} + onClick={handleBack} + disabled={updateProfile.isPending} + > 뒤로가기 </Button> <Button className={cx('right-button')} variant="filled" onClick={handleFormSubmit} - disabled={isUploading} + disabled={updateProfile.isPending || !isFormValid(isImageVerified)} > - {isUploading ? '저장 중...' : '저장하기'} + {updateProfile.isPending ? '저장 중...' : '저장하기'} </Button> </div> )} diff --git a/app/(dashboard)/my/profile/_ui/user-info/styles.module.scss b/app/(dashboard)/my/profile/_ui/user-info/styles.module.scss index da2506e2..d09e7afa 100644 --- a/app/(dashboard)/my/profile/_ui/user-info/styles.module.scss +++ b/app/(dashboard)/my/profile/_ui/user-info/styles.module.scss @@ -1,8 +1,9 @@ .container { background-color: $color-white; width: 897px; - height: 854px; + height: 100%; padding: 44px 40px; + border-radius: 5px; } .title { @@ -34,49 +35,86 @@ display: flex; flex-direction: column; flex: 1; - margin-right: 50px; + margin-right: 20px; + align-items: center; } .avatar-wrapper { position: relative; display: inline-block; - margin-bottom: 35px; - - .camera-wrapper { - position: absolute; - bottom: 40px; - right: 10px; - width: 36px; - height: 36px; - transform: translate(50%, 50%); - background: $color-orange-500; - border-radius: 50%; - border: 4px solid $color-white; - cursor: pointer; - display: flex; - justify-content: center; - align-items: center; - - .camera-icon { - fill: $color-white; - width: 18px; - height: 18px; - } + margin-bottom: 20px; + margin-right: 40px; + &.isEditable { + margin-right: 10px; } } +.image-container { + width: 150px; + height: 150px; + border-radius: 50%; + overflow: hidden; + position: relative; + display: flex; + justify-content: center; + align-items: center; +} + +.avatar-preview { + width: 100%; + height: 100%; + object-fit: cover; + object-position: center; + border-radius: 50%; +} + +.camera-wrapper { + position: absolute; + bottom: 40px; + right: 10px; + width: 36px; + height: 36px; + transform: translate(50%, 50%); + background: $color-orange-500; + border-radius: 50%; + border: 4px solid $color-white; + cursor: pointer; + display: flex; + justify-content: center; + align-items: center; + + .camera-icon { + fill: $color-white; + width: 18px; + height: 18px; + } +} + +.error-message { + color: $color-orange-600; + @include typo-b3; + margin-top: 8px; +} + +.success-message { + color: $color-indigo; + @include typo-b3; + margin-top: 8px; +} + .right-wrapper { display: flex; flex-direction: column; + margin-top: -10px; .edit-button { align-self: flex-end; - margin-top: 20px; } .first-row { display: flex; flex-direction: column; + margin-top: 20px; margin-bottom: 36px; } @@ -88,34 +126,28 @@ .row { display: flex; - gap: 27px; - align-items: center; + gap: 32px; + align-items: flex-start; + justify-content: space-between; margin-bottom: 36px; - - .input { - flex: 1; - } } .password-row { display: flex; - gap: 27px; - align-items: center; + gap: 32px; + align-items: flex-start; } } .button-wrapper { - margin-top: 103px; + margin-top: 40px; display: flex; justify-content: center; gap: 32px; width: 100%; height: 40px; - .left-button { - width: 112px; - } - + .left-button, .right-button { width: 112px; } @@ -131,16 +163,16 @@ .position { display: flex; flex-direction: column; - align-items: center; - gap: 27px; + align-items: flex-start; } + .position-wrapper { display: flex; + align-items: center; gap: 12px; } -.nickname-verified { - margin-top: 0; - @include typo-b3; - color: $color-indigo; +.input { + border-radius: 4px; + border: 1px solid $color-gray-300; } diff --git a/app/(dashboard)/my/profile/_ui/user-profile/index.tsx b/app/(dashboard)/my/profile/_ui/user-profile/index.tsx index bd4e247e..ff86fb4c 100644 --- a/app/(dashboard)/my/profile/_ui/user-profile/index.tsx +++ b/app/(dashboard)/my/profile/_ui/user-profile/index.tsx @@ -10,9 +10,10 @@ interface Props { role: string nickname: string email: string + imageURL?: string | undefined } -const UserProfile = ({ role, nickname, email }: Props) => { +const UserProfile = ({ role, nickname, email, imageURL }: Props) => { return ( <div className={cx('container')}> <p className={cx('title')}>프로필 정보</p> @@ -24,7 +25,7 @@ const UserProfile = ({ role, nickname, email }: Props) => { <p className={cx('email')}>{email}</p> </div> <div className={cx('right-wrapper')}> - <Avatar size="xlarge" /> + <Avatar size="xlarge" src={imageURL} /> </div> </div> </div> diff --git a/app/(dashboard)/my/profile/_ui/user-profile/styles.module.scss b/app/(dashboard)/my/profile/_ui/user-profile/styles.module.scss index f95f67fe..fcabae34 100644 --- a/app/(dashboard)/my/profile/_ui/user-profile/styles.module.scss +++ b/app/(dashboard)/my/profile/_ui/user-profile/styles.module.scss @@ -1,8 +1,8 @@ .container { background-color: $color-white; width: 375px; - height: 280px; padding: 44px 40px; + border-radius: 5px; } .title { diff --git a/app/(dashboard)/my/profile/edit/page.tsx b/app/(dashboard)/my/profile/edit/page.tsx index 5c2b9406..94190652 100644 --- a/app/(dashboard)/my/profile/edit/page.tsx +++ b/app/(dashboard)/my/profile/edit/page.tsx @@ -1,19 +1,37 @@ 'use client' +import classNames from 'classnames/bind' + +import { PATH } from '@/shared/constants/path' +import { LinkButton } from '@/shared/ui/link-button' + import useGetProfile from '../../_hooks/query/use-get-profile' import UserInfo from '../_ui/user-info' +import UserProfile from '../_ui/user-profile' +import styles from './styles.module.scss' + +const cx = classNames.bind(styles) const MyProfileEditPage = () => { - const { data: profile, isLoading } = useGetProfile() + const { data: profile } = useGetProfile() if (!profile) { return null } return ( - <> - <UserInfo profile={profile} isEditable={true} /> - </> + <div className={cx('container')}> + <p className={cx('title')}>나의 정보</p> + <div className={cx('wrapper')}> + <UserInfo profile={profile} isEditable={true} /> + <div className={cx('user-profile')}> + <UserProfile role={profile.role} nickname={profile.nickname} email={profile.email} /> + <div className={cx('link-button')}> + <LinkButton href={PATH.PROFILE_WITHDRAW}>탈퇴하기</LinkButton> + </div> + </div> + </div> + </div> ) } diff --git a/app/(dashboard)/my/profile/edit/styles.module.scss b/app/(dashboard)/my/profile/edit/styles.module.scss new file mode 100644 index 00000000..6fd74ad3 --- /dev/null +++ b/app/(dashboard)/my/profile/edit/styles.module.scss @@ -0,0 +1,27 @@ +.container { + padding: 20px 28px; +} + +.title { + margin-top: 40px; + @include typo-h4; + margin-bottom: 22px; +} + +.wrapper { + display: flex; + flex-direction: row; + gap: 28px; + height: 100%; +} + +.user-profile { + display: flex; + flex-direction: column; + justify-content: start; +} + +.link-button { + align-self: flex-end; + margin-top: 25px; +} diff --git a/app/(dashboard)/my/profile/page.tsx b/app/(dashboard)/my/profile/page.tsx index da50ca52..f3ea794a 100644 --- a/app/(dashboard)/my/profile/page.tsx +++ b/app/(dashboard)/my/profile/page.tsx @@ -8,12 +8,12 @@ import { LinkButton } from '@/shared/ui/link-button' import useGetProfile from '../_hooks/query/use-get-profile' import UserInfo from './_ui/user-info' import UserProfile from './_ui/user-profile' -import styles from './page.module.scss' +import styles from './styles.module.scss' const cx = classNames.bind(styles) const MyProfilePage = () => { - const { data: profile, isLoading } = useGetProfile() + const { data: profile } = useGetProfile() if (!profile) { return null @@ -25,7 +25,12 @@ const MyProfilePage = () => { <div className={cx('wrapper')}> <UserInfo profile={profile} /> <div className={cx('user-profile')}> - <UserProfile role={profile.role} nickname={profile.nickname} email={profile.email} /> + <UserProfile + role={profile.role} + nickname={profile.nickname} + email={profile.email} + imageURL={profile.imageUrl ? profile.imageUrl : undefined} + /> <div className={cx('link-button')}> <LinkButton href={PATH.PROFILE_WITHDRAW}>탈퇴하기</LinkButton> </div> diff --git a/app/(dashboard)/my/profile/styles.module.scss b/app/(dashboard)/my/profile/styles.module.scss new file mode 100644 index 00000000..6fd74ad3 --- /dev/null +++ b/app/(dashboard)/my/profile/styles.module.scss @@ -0,0 +1,27 @@ +.container { + padding: 20px 28px; +} + +.title { + margin-top: 40px; + @include typo-h4; + margin-bottom: 22px; +} + +.wrapper { + display: flex; + flex-direction: row; + gap: 28px; + height: 100%; +} + +.user-profile { + display: flex; + flex-direction: column; + justify-content: start; +} + +.link-button { + align-self: flex-end; + margin-top: 25px; +} diff --git a/app/(dashboard)/my/profile/withdraw/page.tsx b/app/(dashboard)/my/profile/withdraw/page.tsx index 178574c8..80d84f88 100644 --- a/app/(dashboard)/my/profile/withdraw/page.tsx +++ b/app/(dashboard)/my/profile/withdraw/page.tsx @@ -1,7 +1,83 @@ -import UserWithdraw from '../_ui/user-withdraw' +'use client' + +import { useRouter } from 'next/navigation' + +import classNames from 'classnames/bind' + +import useModal from '@/shared/hooks/custom/use-modal' +import { Button } from '@/shared/ui/button' +import WithdrawCheckModal from '@/shared/ui/modal/withdraw-check-modal' + +import useGetProfile from '../../_hooks/query/use-get-profile' +import { useWithdraw } from '../../_hooks/query/use-user-withdrawl' +import UserProfile from '../_ui/user-profile' +import styles from './styles.module.scss' + +const cx = classNames.bind(styles) const MyProfileWithdrawPage = () => { - return <UserWithdraw /> + const router = useRouter() + const { mutate: withdraw, isPending } = useWithdraw() + const { isModalOpen, openModal, closeModal } = useModal() + + const handleWithdraw = () => { + withdraw() + closeModal() + } + + const handleBack = () => { + router.back() + } + + const { data: profile } = useGetProfile() + + if (!profile) { + return null + } + + return ( + <div className={cx('container')}> + <p className={cx('title')}>나의 정보</p> + <div className={cx('wrapper')}> + <div className={cx('content')}> + <p className={cx('withdraw-title')}>회원 탈퇴</p> + <div className={cx('message-wrapper')}> + <p className={cx('withdraw-message')}>회원 탈퇴 메세지</p> + <div className={cx('notice-list')}> + <p>ㆍ탈퇴 즉시 모든 개인정보가 삭제됩니다.</p> + <p>ㆍ구독한 전략에 대한 모든 내용이 삭제됩니다.</p> + </div> + </div> + <div className={cx('button-wrapper')}> + <Button className={cx('left-button')} onClick={handleBack} disabled={isPending}> + 뒤로가기 + </Button> + <Button + className={cx('right-button')} + variant="filled" + onClick={openModal} + disabled={isPending} + > + {isPending ? '처리중...' : '탈퇴'} + </Button> + </div> + </div> + <div className={cx('user-profile')}> + <UserProfile + role={profile.role} + nickname={profile.nickname} + email={profile.email} + imageURL={profile.imageUrl ? profile.imageUrl : undefined} + /> + </div> + </div> + <WithdrawCheckModal + isModalOpen={isModalOpen} + onCloseModal={closeModal} + onConfirm={handleWithdraw} + /> + </div> + ) } export default MyProfileWithdrawPage diff --git a/app/(dashboard)/my/profile/withdraw/styles.module.scss b/app/(dashboard)/my/profile/withdraw/styles.module.scss new file mode 100644 index 00000000..5eaed757 --- /dev/null +++ b/app/(dashboard)/my/profile/withdraw/styles.module.scss @@ -0,0 +1,74 @@ +.container { + padding: 20px 28px; +} + +.title { + margin-top: 40px; + @include typo-h4; + margin-bottom: 22px; +} + +.wrapper { + display: flex; + flex-direction: row; + gap: 28px; + height: 100%; +} + +.content { + display: flex; + flex-direction: column; + width: 100%; + height: 80vh; + background-color: $color-white; + + .withdraw-title { + @include typo-b1; + padding: 32px 0; + margin: 0 24px 36px 24px; + border-bottom: 1px solid $color-gray-300; + } +} + +.message-wrapper { + background-color: var(--color-gray-50); + padding: 24px; + border-radius: 8px; + display: flex; + flex-direction: column; + justify-content: center; + align-items: center; +} + +.withdraw-message { + @include typo-h4; + margin-bottom: 16px; +} + +.notice-list { + width: 600px; + height: 200px; + border: 1px solid $color-gray-600; + display: flex; + align-items: center; + justify-content: center; + flex-direction: column; + p { + margin-bottom: 8px; + } +} + +.button-wrapper { + display: flex; + gap: 24px; + margin-top: 24px; + justify-content: center; +} + +.left-button { + width: 120px; +} + +.right-button { + width: 120px; +} From 2f9bac4571a6a08a7838b06f0d8148fa385eb079 Mon Sep 17 00:00:00 2001 From: James <dawn147@snu.ac.kr> Date: Wed, 11 Dec 2024 11:33:27 +0900 Subject: [PATCH 084/207] fix: error -> err (#6) --- .../my/_hooks/query/use-patch-user-profile.ts | 12 ++++++------ 1 file changed, 6 insertions(+), 6 deletions(-) diff --git a/app/(dashboard)/my/_hooks/query/use-patch-user-profile.ts b/app/(dashboard)/my/_hooks/query/use-patch-user-profile.ts index 98b0c6e2..cb2c98f3 100644 --- a/app/(dashboard)/my/_hooks/query/use-patch-user-profile.ts +++ b/app/(dashboard)/my/_hooks/query/use-patch-user-profile.ts @@ -61,11 +61,11 @@ const usePatchUserProfile = () => { } return profileResponse - } catch (error) { - if (error instanceof Error) { - throw new Error(`프로필 업데이트 실패: ${error.message}`) + } catch (err) { + if (err instanceof Error) { + throw new Error(`프로필 업데이트 실패: ${err.message}`) } - throw error + throw err } }, onSuccess: (data) => { @@ -82,8 +82,8 @@ const usePatchUserProfile = () => { }) } }, - onError: (error: Error) => { - console.error('프로필 업데이트 실패:', error.message) + onError: (err: Error) => { + console.error('프로필 업데이트 실패:', err.message) }, }) } From e283f6545b218cbf38565196feb6014583fbab90 Mon Sep 17 00:00:00 2001 From: ssumanlife <ksm9805@naver.com> Date: Wed, 11 Dec 2024 12:15:26 +0900 Subject: [PATCH 085/207] =?UTF-8?q?design:=20=ED=8A=B8=EB=A0=88=EC=9D=B4?= =?UTF-8?q?=EB=8D=94=20=EB=AA=A9=EB=A1=9D=ED=8E=98=EC=9D=B4=EC=A7=80=20?= =?UTF-8?q?=EC=8A=A4=ED=83=80=EC=9D=BC=20=EC=88=98=EC=A0=95=20(#68)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- app/(dashboard)/traders/[traderId]/page.module.scss | 2 +- app/(dashboard)/traders/page.module.scss | 8 ++++---- 2 files changed, 5 insertions(+), 5 deletions(-) diff --git a/app/(dashboard)/traders/[traderId]/page.module.scss b/app/(dashboard)/traders/[traderId]/page.module.scss index c385baef..abc26c0b 100644 --- a/app/(dashboard)/traders/[traderId]/page.module.scss +++ b/app/(dashboard)/traders/[traderId]/page.module.scss @@ -1,5 +1,5 @@ .page-container { - padding: 0 15px; + padding: 0 15px 20px; } .title { diff --git a/app/(dashboard)/traders/page.module.scss b/app/(dashboard)/traders/page.module.scss index 371ac7f8..49e3f855 100644 --- a/app/(dashboard)/traders/page.module.scss +++ b/app/(dashboard)/traders/page.module.scss @@ -17,18 +17,18 @@ } .traders-list-wrapper { - display: grid; - grid-template-columns: repeat(4, 1fr); + display: flex; + justify-content: flex-start; + align-items: center; + flex-wrap: wrap; gap: 24px 32px; margin-bottom: 30px; @include tablet-md { - grid-template-columns: repeat(2, 1fr); gap: 16px; } @include mobile { - grid-template-columns: 1fr; gap: 16px; } } From cef0ae3362850af6dad7ac4312a745ae48172fbc Mon Sep 17 00:00:00 2001 From: ssumanlife <ksm9805@naver.com> Date: Wed, 11 Dec 2024 12:16:01 +0900 Subject: [PATCH 086/207] =?UTF-8?q?design:=20=EB=A1=9C=EA=B7=B8=EC=9D=B8?= =?UTF-8?q?=ED=8E=98=EC=9D=B4=EC=A7=80=20=ED=97=A4=EB=8D=94=EC=99=80=20?= =?UTF-8?q?=EA=B0=AD=20=EC=B6=94=EA=B0=80=20(#68)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- app/(landing)/signin/styles.module.scss | 1 + 1 file changed, 1 insertion(+) diff --git a/app/(landing)/signin/styles.module.scss b/app/(landing)/signin/styles.module.scss index 7d1b2e4f..222d7562 100644 --- a/app/(landing)/signin/styles.module.scss +++ b/app/(landing)/signin/styles.module.scss @@ -2,6 +2,7 @@ display: flex; justify-content: center; align-items: center; + margin-top: 150px; } .loginBox { From 2f372669ec8ad47ae1cd318724794cbf3042c22d Mon Sep 17 00:00:00 2001 From: ssumanlife <ksm9805@naver.com> Date: Wed, 11 Dec 2024 12:16:46 +0900 Subject: [PATCH 087/207] =?UTF-8?q?feat:=20=EB=82=98=EC=9D=98=20=EC=A0=84?= =?UTF-8?q?=EB=9E=B5=20=EC=97=86=EC=9D=84=EB=95=8C=20UI=20=EB=B0=8F=20?= =?UTF-8?q?=EC=8A=A4=EC=BC=88=EB=A0=88=ED=86=A4=20=EC=A0=81=EC=9A=A9=20(#6?= =?UTF-8?q?8)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../strategies/_ui/my-strategy-list/index.tsx | 5 ++++ .../_ui/my-strategy-list/styles.module.scss | 6 +++++ app/(dashboard)/my/strategies/page.tsx | 24 ++++++++++++++++--- 3 files changed, 32 insertions(+), 3 deletions(-) create mode 100644 app/(dashboard)/my/strategies/_ui/my-strategy-list/styles.module.scss diff --git a/app/(dashboard)/my/strategies/_ui/my-strategy-list/index.tsx b/app/(dashboard)/my/strategies/_ui/my-strategy-list/index.tsx index 61254eda..43433080 100644 --- a/app/(dashboard)/my/strategies/_ui/my-strategy-list/index.tsx +++ b/app/(dashboard)/my/strategies/_ui/my-strategy-list/index.tsx @@ -4,9 +4,13 @@ import { useCallback, useRef } from 'react' import StrategiesItem from '@/app/(dashboard)/_ui/strategies-item' import { useGetMyStrategyList } from '@/app/(dashboard)/my/_hooks/query/use-get-my-strategy-list' +import classNames from 'classnames/bind' import { useIntersectionObserver } from '@/shared/hooks/custom/use-intersection-observer' +import styles from './styles.module.scss' + +const cx = classNames.bind(styles) const MyStrategyList = () => { const { data: strategyData, @@ -34,6 +38,7 @@ const MyStrategyList = () => { const strategies = strategyData?.pages.flatMap((page) => page.strategies) || [] return ( <> + {!strategies.length && <p className={cx('no-strategy')}>등록된 나의 전략이 없습니다.</p>} {strategies.map((strategy) => ( <StrategiesItem key={strategy.strategyId} strategiesData={strategy} type="my" /> ))} diff --git a/app/(dashboard)/my/strategies/_ui/my-strategy-list/styles.module.scss b/app/(dashboard)/my/strategies/_ui/my-strategy-list/styles.module.scss new file mode 100644 index 00000000..f43bc937 --- /dev/null +++ b/app/(dashboard)/my/strategies/_ui/my-strategy-list/styles.module.scss @@ -0,0 +1,6 @@ +.no-strategy { + margin-top: 180px; + text-align: center; + @include typo-b1; + color: $color-gray-600; +} diff --git a/app/(dashboard)/my/strategies/page.tsx b/app/(dashboard)/my/strategies/page.tsx index 9b5e093c..79d65ece 100644 --- a/app/(dashboard)/my/strategies/page.tsx +++ b/app/(dashboard)/my/strategies/page.tsx @@ -1,7 +1,8 @@ 'use client' -import { Suspense } from 'react' +import React, { Suspense } from 'react' +import dynamic from 'next/dynamic' import { useRouter } from 'next/navigation' import classNames from 'classnames/bind' @@ -11,9 +12,16 @@ import { Button } from '@/shared/ui/button' import Title from '@/shared/ui/title' import ListHeader from '../../_ui/list-header' -import MyStrategyList from './_ui/my-strategy-list' +import StrategiesItemSkeleton from '../../_ui/strategies-item/skeleton' import styles from './styles.module.scss' +const MyStrategyList = React.lazy(() => import('./_ui/my-strategy-list')) + +const DynamicStrategySkeleton = dynamic(() => import('../../_ui/strategies-item/skeleton'), { + loading: () => <StrategiesItemSkeleton />, + ssr: false, +}) + const cx = classNames.bind(styles) const MyStrategiesPage = () => { @@ -30,11 +38,21 @@ const MyStrategiesPage = () => { </Button> </div> <ListHeader type="my" /> - <Suspense fallback={<div>Loading...</div>}> + <Suspense fallback={<Skeleton />}> <MyStrategyList /> </Suspense> </div> ) } +const Skeleton = () => { + return ( + <> + {Array.from({ length: 4 }, (_, idx) => ( + <DynamicStrategySkeleton key={idx} /> + ))} + </> + ) +} + export default MyStrategiesPage From 38f3d7477bbd1e56e3e9aee37f114dce8fff6b2c Mon Sep 17 00:00:00 2001 From: James <dawn147@snu.ac.kr> Date: Wed, 11 Dec 2024 12:34:12 +0900 Subject: [PATCH 088/207] =?UTF-8?q?fix:=20=ED=8C=8C=EC=9D=BC=EB=AA=85=20?= =?UTF-8?q?=EC=98=A4=ED=83=80=20=EC=88=98=EC=A0=95=20(#6)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../{delete-user-withdrawl.ts => delete-user-withdrawal.ts} | 0 .../query/{use-user-withdrawl.ts => use-user-withdrawal.ts} | 2 +- app/(dashboard)/my/profile/withdraw/page.tsx | 2 +- 3 files changed, 2 insertions(+), 2 deletions(-) rename app/(dashboard)/my/_api/{delete-user-withdrawl.ts => delete-user-withdrawal.ts} (100%) rename app/(dashboard)/my/_hooks/query/{use-user-withdrawl.ts => use-user-withdrawal.ts} (88%) diff --git a/app/(dashboard)/my/_api/delete-user-withdrawl.ts b/app/(dashboard)/my/_api/delete-user-withdrawal.ts similarity index 100% rename from app/(dashboard)/my/_api/delete-user-withdrawl.ts rename to app/(dashboard)/my/_api/delete-user-withdrawal.ts diff --git a/app/(dashboard)/my/_hooks/query/use-user-withdrawl.ts b/app/(dashboard)/my/_hooks/query/use-user-withdrawal.ts similarity index 88% rename from app/(dashboard)/my/_hooks/query/use-user-withdrawl.ts rename to app/(dashboard)/my/_hooks/query/use-user-withdrawal.ts index 5491ebb5..94670ab6 100644 --- a/app/(dashboard)/my/_hooks/query/use-user-withdrawl.ts +++ b/app/(dashboard)/my/_hooks/query/use-user-withdrawal.ts @@ -5,7 +5,7 @@ import { useMutation } from '@tanstack/react-query' import { PATH } from '@/shared/constants/path' import { useAuth } from '@/shared/hooks/custom/use-auth' -import { deleteUser } from '../../_api/delete-user-withdrawl' +import { deleteUser } from '../../_api/delete-user-withdrawal' export const useWithdraw = () => { const router = useRouter() diff --git a/app/(dashboard)/my/profile/withdraw/page.tsx b/app/(dashboard)/my/profile/withdraw/page.tsx index 80d84f88..866b5629 100644 --- a/app/(dashboard)/my/profile/withdraw/page.tsx +++ b/app/(dashboard)/my/profile/withdraw/page.tsx @@ -9,7 +9,7 @@ import { Button } from '@/shared/ui/button' import WithdrawCheckModal from '@/shared/ui/modal/withdraw-check-modal' import useGetProfile from '../../_hooks/query/use-get-profile' -import { useWithdraw } from '../../_hooks/query/use-user-withdrawl' +import { useWithdraw } from '../../_hooks/query/use-user-withdrawal' import UserProfile from '../_ui/user-profile' import styles from './styles.module.scss' From 3d103da5dad5f18cceaa525bc401a308bd44ea91 Mon Sep 17 00:00:00 2001 From: ssumanlife <ksm9805@naver.com> Date: Wed, 11 Dec 2024 12:48:38 +0900 Subject: [PATCH 089/207] =?UTF-8?q?feat:=20=ED=86=B5=EA=B3=84=20=ED=85=8C?= =?UTF-8?q?=EC=9D=B4=EB=B8=94=20=EB=AA=A8=EB=93=A0=20=EB=8D=B0=EC=9D=B4?= =?UTF-8?q?=ED=84=B0=20=EB=8B=A8=EC=9C=84=20=ED=91=9C=EC=8B=9C=20(#72)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- shared/ui/table/statistics/constant.ts | 5 ++++- shared/ui/table/statistics/index.tsx | 24 ++++++++++++------------ 2 files changed, 16 insertions(+), 13 deletions(-) diff --git a/shared/ui/table/statistics/constant.ts b/shared/ui/table/statistics/constant.ts index 4b6fada2..0c8f62d0 100644 --- a/shared/ui/table/statistics/constant.ts +++ b/shared/ui/table/statistics/constant.ts @@ -51,7 +51,10 @@ export const STATISTICS_DATE = [ '총 거래 일수', '총 이익 일수', '총 손실 일수', - '현재 연속 손익 일수', + '현재 연속 손실 일수', '최대 연속 이익 일수', '최대 연속 손실 일수', + '운용 기간', + '시작 일자', + '종료 일자', ] diff --git a/shared/ui/table/statistics/index.tsx b/shared/ui/table/statistics/index.tsx index af8eb75c..02fa2e9f 100644 --- a/shared/ui/table/statistics/index.tsx +++ b/shared/ui/table/statistics/index.tsx @@ -42,6 +42,16 @@ const StatisticsTable = ({ title, statisticsData }: Props) => { } ) + const formatStatisticsValue = (key: string, value: number) => { + if (STATISTICS_PERCENT.includes(key)) { + return Number(value).toFixed(2) + ' %' + } + if (STATISTICS_DATE.includes(key)) { + return formatNumber(value) + ' 일' + } + return formatNumber(value) + ' 원' + } + return ( <div className={cx('container')}> <p>{titleInKorean ?? title}</p> @@ -50,21 +60,11 @@ const StatisticsTable = ({ title, statisticsData }: Props) => { {groupedData.map((row, idx) => ( <tr key={idx}> <td>{row[0]}</td> - <td> - {STATISTICS_PERCENT.includes(row[0] as string) - ? Number(row[1]).toFixed(2) + '%' - : formatNumber(row[1])} - {STATISTICS_DATE.includes(row[0] as string) && '일'} - </td> + <td>{formatStatisticsValue(row[0] as string, row[1] as number)}</td> {row[2] && ( <> <td>{row[2]}</td> - <td> - {STATISTICS_PERCENT.includes(row[2] as string) - ? Number(row[3]).toFixed(2) + '%' - : formatNumber(row[3])} - {STATISTICS_DATE.includes(row[2] as string) && '일'} - </td> + <td>{formatStatisticsValue(row[2] as string, row[3] as number)}</td> </> )} </tr> From 8d92ac8398d518e7981134586186502346f77b32 Mon Sep 17 00:00:00 2001 From: ssumanlife <ksm9805@naver.com> Date: Wed, 11 Dec 2024 12:49:04 +0900 Subject: [PATCH 090/207] =?UTF-8?q?design:=20=ED=85=8C=EC=9D=B4=EB=B8=94?= =?UTF-8?q?=20=EC=8A=A4=ED=83=80=EC=9D=BC=20=EC=88=98=EC=A0=95=20(#72)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- shared/ui/table/statistics/styles.module.scss | 6 ++++-- shared/ui/table/vertical/styles.module.scss | 7 +++++-- 2 files changed, 9 insertions(+), 4 deletions(-) diff --git a/shared/ui/table/statistics/styles.module.scss b/shared/ui/table/statistics/styles.module.scss index 19452ec8..42b20517 100644 --- a/shared/ui/table/statistics/styles.module.scss +++ b/shared/ui/table/statistics/styles.module.scss @@ -4,7 +4,7 @@ table { width: 100%; margin-top: 10px; - font-size: $text-c1; + font-size: $text-b3; td:nth-child(2n + 1) { font-weight: $text-semibold; color: $color-gray-600; @@ -13,7 +13,7 @@ td { padding: 0 20px; width: 120px; - height: 32px; + height: 36px; text-align: start; vertical-align: middle; border: 1px solid $color-white; @@ -22,7 +22,9 @@ @include tablet-md { padding: 10px; table { + font-size: $text-c1; td { + height: 32px; padding: 0 4px; } } diff --git a/shared/ui/table/vertical/styles.module.scss b/shared/ui/table/vertical/styles.module.scss index 63d1af3f..a48ff3b3 100644 --- a/shared/ui/table/vertical/styles.module.scss +++ b/shared/ui/table/vertical/styles.module.scss @@ -4,7 +4,7 @@ table { width: 100%; - font-size: $text-c1; + font-size: $text-b3; thead { background-color: $color-gray-100; @@ -12,7 +12,7 @@ td { padding: 0 4px; - height: 40px; + height: 44px; text-align: start; vertical-align: middle; border-top: 1px solid $color-gray-200; @@ -35,6 +35,9 @@ } @include tablet-md { padding: 10px; + table { + font-size: $text-c1; + } } } From 218194a47e21651b131fc6646fd30b6427a31618 Mon Sep 17 00:00:00 2001 From: ssumanlife <ksm9805@naver.com> Date: Wed, 11 Dec 2024 12:54:12 +0900 Subject: [PATCH 091/207] =?UTF-8?q?design:=20tablet=20=EB=86=92=EC=9D=B4?= =?UTF-8?q?=EA=B0=92=20=EC=B6=94=EA=B0=80=20(#72)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- shared/ui/table/vertical/styles.module.scss | 1 + 1 file changed, 1 insertion(+) diff --git a/shared/ui/table/vertical/styles.module.scss b/shared/ui/table/vertical/styles.module.scss index a48ff3b3..0fe2577f 100644 --- a/shared/ui/table/vertical/styles.module.scss +++ b/shared/ui/table/vertical/styles.module.scss @@ -36,6 +36,7 @@ @include tablet-md { padding: 10px; table { + height: 40px; font-size: $text-c1; } } From c87c60d23aa2a5d0a962d9bd9b6aab03f938163f Mon Sep 17 00:00:00 2001 From: deun <devdeun@gmail.com> Date: Wed, 11 Dec 2024 16:13:12 +0900 Subject: [PATCH 092/207] =?UTF-8?q?design:=20=EB=AC=B8=EC=9D=98=20?= =?UTF-8?q?=EB=82=B4=EC=97=AD=20empty=20message=20=EC=8A=A4=ED=83=80?= =?UTF-8?q?=EC=9D=BC=20=EC=88=98=EC=A0=95=20(#75)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../questions/_ui/questions-tab-content/styles.module.scss | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/app/(dashboard)/my/questions/_ui/questions-tab-content/styles.module.scss b/app/(dashboard)/my/questions/_ui/questions-tab-content/styles.module.scss index 3517a7ec..f93b3fa7 100644 --- a/app/(dashboard)/my/questions/_ui/questions-tab-content/styles.module.scss +++ b/app/(dashboard)/my/questions/_ui/questions-tab-content/styles.module.scss @@ -7,8 +7,10 @@ } .empty-message { - margin: 12px 0; - @include typo-b2; + margin-top: 180px; + text-align: center; + @include typo-b1; + color: $color-gray-600; } .pagination-wrapper { From 27b50a0041a08e91ee1141b13e5d535b089816a6 Mon Sep 17 00:00:00 2001 From: ssumanlife <ksm9805@naver.com> Date: Wed, 11 Dec 2024 16:17:11 +0900 Subject: [PATCH 093/207] =?UTF-8?q?bug:=20=EC=9D=BC=EB=B0=98=EC=9C=A0?= =?UTF-8?q?=EC=A0=80=EC=97=90=EA=B2=8C=20=EC=82=AD=EC=A0=9C=EB=B2=84?= =?UTF-8?q?=ED=8A=BC=20=EB=B3=B4=EC=9D=B4=EC=A7=80=20=EC=95=8A=EB=8F=84?= =?UTF-8?q?=EB=A1=9D=20(#76)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../[strategyId]/_ui/review-container/review-item.tsx | 2 +- .../[strategyId]/_ui/review-container/review-list.tsx | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/app/(dashboard)/strategies/[strategyId]/_ui/review-container/review-item.tsx b/app/(dashboard)/strategies/[strategyId]/_ui/review-container/review-item.tsx index 8ae66872..89239429 100644 --- a/app/(dashboard)/strategies/[strategyId]/_ui/review-container/review-item.tsx +++ b/app/(dashboard)/strategies/[strategyId]/_ui/review-container/review-item.tsx @@ -24,7 +24,7 @@ interface Props { createdAt: string starRating: number isReviewer: boolean - isAdmin: boolean + isAdmin?: boolean } const ReviewItem = ({ diff --git a/app/(dashboard)/strategies/[strategyId]/_ui/review-container/review-list.tsx b/app/(dashboard)/strategies/[strategyId]/_ui/review-container/review-list.tsx index 3cd08aa1..b3d5fe14 100644 --- a/app/(dashboard)/strategies/[strategyId]/_ui/review-container/review-list.tsx +++ b/app/(dashboard)/strategies/[strategyId]/_ui/review-container/review-list.tsx @@ -44,7 +44,7 @@ const ReviewList = ({ strategyId, reviews, totalReview, currentPage, setCurrentP starRating={review.starRating} content={review.content} isReviewer={user?.nickname === review.nickname} - isAdmin={!user?.role.includes('admin')} + isAdmin={user?.role.includes('ADMIN')} /> ))} </ul> From 218151681462b417a6aaf00df7f8c4fdc20cbb54 Mon Sep 17 00:00:00 2001 From: deun <devdeun@gmail.com> Date: Wed, 11 Dec 2024 16:46:45 +0900 Subject: [PATCH 094/207] =?UTF-8?q?feat:=20=EC=8A=88=ED=8D=BC=20=EC=96=B4?= =?UTF-8?q?=EB=93=9C=EB=AF=BC=20=EA=B3=84=EC=A0=95=EC=9D=BC=EB=95=8C=20?= =?UTF-8?q?=EC=82=AC=EC=9D=B4=EB=93=9C=EB=B0=94=EC=97=90=20=EB=82=98?= =?UTF-8?q?=EC=9D=98=20=EC=A0=84=EB=9E=B5=20=ED=83=AD=20=EC=B6=94=EA=B0=80?= =?UTF-8?q?=20(#79)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- app/(dashboard)/_ui/navigation.tsx | 4 ++-- shared/types/auth.ts | 5 +++++ shared/ui/side-navigation/styles.module.scss | 2 +- 3 files changed, 8 insertions(+), 3 deletions(-) diff --git a/app/(dashboard)/_ui/navigation.tsx b/app/(dashboard)/_ui/navigation.tsx index 951168a3..d5917965 100644 --- a/app/(dashboard)/_ui/navigation.tsx +++ b/app/(dashboard)/_ui/navigation.tsx @@ -10,7 +10,7 @@ import { import { PATH } from '@/shared/constants/path' import { useAuthStore } from '@/shared/stores/use-auth-store' -import { isTrader } from '@/shared/types/auth' +import { isSuperAdmin, isTrader } from '@/shared/types/auth' import SideNavigation from '@/shared/ui/side-navigation' import NavLinkItem from '@/shared/ui/side-navigation/nav-link-item' @@ -25,7 +25,7 @@ const DashboardNavigation = () => { <NavLinkItem href={PATH.TRADERS} icon={TradersIcon}> 트레이더 목록 </NavLinkItem> - {isTrader(user) && ( + {(isTrader(user) || isSuperAdmin(user)) && ( <NavLinkItem href={PATH.MY_STRATEGIES} icon={StrategyIcon}> 나의 전략 </NavLinkItem> diff --git a/shared/types/auth.ts b/shared/types/auth.ts index 4b6e549e..97388246 100644 --- a/shared/types/auth.ts +++ b/shared/types/auth.ts @@ -59,6 +59,11 @@ export const isAdmin = (user: UserModel | null): boolean => { return user.role.includes('ADMIN') } +export const isSuperAdmin = (user: UserModel | null): boolean => { + if (!user) return false + return user.role.includes('SUPER_ADMIN') +} + export const isTrader = (user: UserModel | null): boolean => { if (!user) return false return user.role.includes('TRADER') diff --git a/shared/ui/side-navigation/styles.module.scss b/shared/ui/side-navigation/styles.module.scss index 231e10f2..8b9cbb69 100644 --- a/shared/ui/side-navigation/styles.module.scss +++ b/shared/ui/side-navigation/styles.module.scss @@ -116,6 +116,6 @@ } .email { - font-size: $text-c2; + font-size: $text-c1; } } From f9fc72809deb038b7e7bdc42b019a3bad635284a Mon Sep 17 00:00:00 2001 From: deun <devdeun@gmail.com> Date: Wed, 11 Dec 2024 17:18:35 +0900 Subject: [PATCH 095/207] =?UTF-8?q?fix:=20=EC=A0=84=EB=9E=B5=EC=9D=B4=20?= =?UTF-8?q?=EC=97=86=EC=96=B4=EB=8F=84=20=ED=8A=B8=EB=A0=88=EC=9D=B4?= =?UTF-8?q?=EB=8D=94=20=EC=83=81=EC=84=B8=20=ED=8E=98=EC=9D=B4=EC=A7=80?= =?UTF-8?q?=EA=B0=80=20=EB=9C=A8=EB=8F=84=EB=A1=9D=20=EC=88=98=EC=A0=95=20?= =?UTF-8?q?(#81)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../traders/[traderId]/page.module.scss | 11 +++++++++++ app/(dashboard)/traders/[traderId]/page.tsx | 15 +++++++++++---- 2 files changed, 22 insertions(+), 4 deletions(-) diff --git a/app/(dashboard)/traders/[traderId]/page.module.scss b/app/(dashboard)/traders/[traderId]/page.module.scss index abc26c0b..d212bc72 100644 --- a/app/(dashboard)/traders/[traderId]/page.module.scss +++ b/app/(dashboard)/traders/[traderId]/page.module.scss @@ -9,3 +9,14 @@ .card-wrapper { margin-bottom: 54px; } + +.spinner { + margin-top: 200px; +} + +.empty-message { + margin-top: 180px; + text-align: center; + @include typo-b1; + color: $color-gray-600; +} diff --git a/app/(dashboard)/traders/[traderId]/page.tsx b/app/(dashboard)/traders/[traderId]/page.tsx index 41de6fcc..9c974488 100644 --- a/app/(dashboard)/traders/[traderId]/page.tsx +++ b/app/(dashboard)/traders/[traderId]/page.tsx @@ -8,6 +8,7 @@ import { PATH } from '@/shared/constants/path' import { usePagination } from '@/shared/hooks/custom/use-pagination' import BackHeader from '@/shared/ui/header/back-header' import Pagination from '@/shared/ui/pagination' +import Spinner from '@/shared/ui/spinner' import Title from '@/shared/ui/title' import TradersListCard from '@/shared/ui/traders-list-card' @@ -32,11 +33,14 @@ const TraderDetailPage = () => { }) const strategies = strategiesData?.content - const firstStrategy = strategies?.[0] const totalPages = strategiesData?.totalPages - if (!firstStrategy || isLoading || !totalPages || !traderProfile) { - return null + if (isLoading) { + return <Spinner className={cx('spinner')} /> + } + + if (!traderProfile) { + return <p className={cx('empty-message')}>트레이더 정보를 불러오지 못했습니다.</p> } return ( @@ -60,7 +64,10 @@ const TraderDetailPage = () => { {strategies?.map((strategy) => ( <StrategiesItem key={strategy.strategyId} strategiesData={strategy} /> ))} - <Pagination currentPage={page} maxPage={totalPages} onPageChange={handlePageChange} /> + {!strategies?.length && <p className={cx('empty-message')}>등록한 전략이 없습니다.</p>} + {!!totalPages && ( + <Pagination currentPage={page} maxPage={totalPages} onPageChange={handlePageChange} /> + )} </div> </> ) From d599f85212b070dd513d0055d9db7773f118e2be Mon Sep 17 00:00:00 2001 From: James <dawn147@snu.ac.kr> Date: Wed, 11 Dec 2024 17:23:47 +0900 Subject: [PATCH 096/207] =?UTF-8?q?design:=20=EC=A0=84=EB=9E=B5=EB=93=B1?= =?UTF-8?q?=EB=A1=9D=20=ED=8E=98=EC=9D=B4=EC=A7=80=20=EB=94=94=EC=9E=90?= =?UTF-8?q?=EC=9D=B8=20=EC=88=98=EC=A0=95=20=EB=B0=8F=20=EC=97=90=EB=9F=AC?= =?UTF-8?q?=20=EC=B2=98=EB=A6=AC=20=EC=B6=94=EA=B0=80=20(#73)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- app/(dashboard)/my/strategies/add/page.tsx | 345 +++++++++--------- .../my/strategies/add/styles.module.scss | 64 +++- shared/ui/input/index.tsx | 7 +- shared/ui/input/styles.module.scss | 4 +- 4 files changed, 239 insertions(+), 181 deletions(-) diff --git a/app/(dashboard)/my/strategies/add/page.tsx b/app/(dashboard)/my/strategies/add/page.tsx index de4df278..a48e95fc 100644 --- a/app/(dashboard)/my/strategies/add/page.tsx +++ b/app/(dashboard)/my/strategies/add/page.tsx @@ -31,9 +31,9 @@ const cx = classNames.bind(styles) interface StrategyFormDataModel { strategyName: string - tradeType: string + tradeType: string | null operationCycle: OperationCycleType - stockTypes: string[] + stockTypes: number[] minimumInvestmentAmount: MinimumInvestmentAmountType description: string proposalFile?: File @@ -49,27 +49,31 @@ interface FormErrorsModel { proposalFile?: string } +const initialFormData: StrategyFormDataModel = { + strategyName: '', + tradeType: '3', + operationCycle: 'DAY', + stockTypes: [], + minimumInvestmentAmount: 'UNDER_10K', + description: '', +} + +const initialFormErrors: FormErrorsModel = { + strategyName: '', + tradeType: '', + operationCycle: '', + stockTypes: '', + minimumInvestmentAmount: '', + description: '', + proposalFile: '', +} + const StrategyAddPage = () => { const router = useRouter() const { strategyTypes, registerStrategy, isTypesLoading, isRegistering, error } = useAddStrategy() - const [formData, setFormData] = useState<StrategyFormDataModel>({ - strategyName: '', - tradeType: '', - operationCycle: 'DAY', - stockTypes: [], - minimumInvestmentAmount: 'UNDER_10K', - description: '', - }) - - const [formErrors, setFormErrors] = useState<FormErrorsModel>({ - strategyName: '', - tradeType: '', - operationCycle: '', - stockTypes: '', - minimumInvestmentAmount: '', - description: '', - }) + const [formData, setFormData] = useState<StrategyFormDataModel>(initialFormData) + const [formErrors, setFormErrors] = useState<FormErrorsModel>(initialFormErrors) const validateForm = (): boolean => { const newErrors = { @@ -81,7 +85,7 @@ const StrategyAddPage = () => { ? '최소 운용가능 금액을 선택해주세요.' : '', description: !formData.description ? '전략 소개를 입력해주세요.' : '', - proposalFile: '', + proposalFile: !formData.proposalFile ? '제안서를 업로드해주세요.' : '', } setFormErrors(newErrors) @@ -90,11 +94,12 @@ const StrategyAddPage = () => { const handleFileChange = (e: React.ChangeEvent<HTMLInputElement>) => { const file = e.target.files?.[0] - if ( + const isValidExcelFile = file && (file.type === 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet' || file.type === 'application/vnd.ms-excel') - ) { + + if (isValidExcelFile) { setFormData((prev) => ({ ...prev, proposalFile: file })) setFormErrors((prev) => ({ ...prev, proposalFile: '' })) } else { @@ -104,8 +109,7 @@ const StrategyAddPage = () => { const handleSubmit = (e: React.FormEvent<HTMLFormElement>) => { e.preventDefault() - - if (!validateForm()) return + if (!validateForm() || !formData.tradeType) return const fileInfo: ProposalFileInfoModel | undefined = formData.proposalFile ? { @@ -118,7 +122,7 @@ const StrategyAddPage = () => { strategyName: formData.strategyName, tradeTypeId: Number(formData.tradeType), operationCycle: formData.operationCycle, - stockTypeIds: formData.stockTypes.map(Number), + stockTypeIds: formData.stockTypes, minimumInvestmentAmount: formData.minimumInvestmentAmount, description: formData.description, proposalFile: fileInfo, @@ -127,7 +131,12 @@ const StrategyAddPage = () => { registerStrategy(data) } - const toggleStockType = (value: string) => { + const handleInputChange = (field: keyof StrategyFormDataModel, value: any) => { + setFormData((prev) => ({ ...prev, [field]: value })) + setFormErrors((prev) => ({ ...prev, [field]: '' })) + } + + const toggleStockType = (value: number) => { setFormData((prev) => { const newStockTypes = prev.stockTypes.includes(value) ? prev.stockTypes.filter((type) => type !== value) @@ -137,172 +146,178 @@ const StrategyAddPage = () => { setFormErrors((prev) => ({ ...prev, stockTypes: '' })) } + const renderStockTypes = () => ( + <div className={cx('stock-grid')}> + {strategyTypes?.stockTypes.map((type) => ( + <button + key={type.stockTypeId} + type="button" + onClick={() => toggleStockType(type.stockTypeId)} + className={cx('stock-item', { + selected: formData.stockTypes.includes(type.stockTypeId), + })} + > + {type.stockTypeName} + <span className={cx('marker')}> + <Image src={type.stockIconUrl} alt={type.stockTypeName} width={20} height={20} /> + </span> + </button> + ))} + </div> + ) + + const renderFileUpload = () => ( + <div className={cx('file-upload')}> + <input + type="file" + accept=".xlsx,.xls" + onChange={handleFileChange} + id="proposalFile" + className={cx('file-input')} + /> + <label htmlFor="proposalFile" className={cx('file-label')}> + <span>{formData.proposalFile?.name || '엑셀 제안서를 업로드해주세요'}</span> + <FileIcon className={cx('file-icon')} /> + </label> + </div> + ) + + if (isTypesLoading) { + return <div className={cx('loading')}>로딩 중...</div> + } + const tradeTypeOptions = strategyTypes?.tradeTypes.map((type) => ({ value: String(type.tradeTypeId), label: type.tradeTypeName, })) || [] - if (isTypesLoading) { - return <div className={cx('loading')}>로딩 중...</div> - } return ( <> <BackHeader label="전략관리로 돌아가기" /> <Title label="전략 등록" /> - <form onSubmit={handleSubmit} className={cx('form')}> - {error && <div className={cx('error')}>{error}</div>} - <div className={cx('form-row')}> - <label>전략 명칭</label> - <div className={cx('form-field')}> - <Input - value={formData.strategyName} - onChange={(e: React.ChangeEvent<HTMLInputElement>) => { - setFormData((prev) => ({ ...prev, strategyName: e.target.value })) - setFormErrors((prev) => ({ ...prev, strategyName: '' })) - }} - placeholder="전략명을 입력하세요" - errorMessage={formErrors.strategyName} - /> - </div> - </div> - <div className={cx('horizontal-wrapper')}> - <div className={cx('form-row', 'half')}> - <label>매매 유형</label> + <div className={cx('container')}> + <form onSubmit={handleSubmit} className={cx('form')}> + {error && <div className={cx('error')}>{error}</div>} + + <div className={cx('form-row')}> + <label>전략 명칭</label> <div className={cx('form-field')}> - <Select - value={formData.tradeType} - onChange={(value) => { - setFormData((prev) => ({ ...prev, tradeType: value as string })) - setFormErrors((prev) => ({ ...prev, tradeType: '' })) - }} - options={tradeTypeOptions} - placeholder="매매 유형 선택" - titleStyle={{ width: '200px', height: '50px' }} + <Input + value={formData.strategyName} + onChange={(e) => handleInputChange('strategyName', e.target.value)} + placeholder="전략명을 입력하세요" + className={cx('strategy-name-input')} + errorMessage={formErrors.strategyName} + hideErrorStyle /> - {formErrors.tradeType && ( - <div className={cx('field-error')}>{formErrors.tradeType}</div> + </div> + </div> + + <div className={cx('horizontal-wrapper')}> + <div className={cx('form-row', 'half')}> + <label>매매 유형</label> + <div className={cx('form-field')}> + <Select + value={formData.tradeType} + onChange={(value) => handleInputChange('tradeType', value)} + options={tradeTypeOptions} + placeholder="매매 유형 선택" + titleStyle={{ width: '160px', height: '45px', border: '1px solid #ccc' }} + size="large" + /> + {formErrors.tradeType && ( + <div className={cx('field-error')}>{formErrors.tradeType}</div> + )} + </div> + </div> + + <div className={cx('form-row', 'half')}> + <label>주기</label> + <div className={cx('form-field')}> + <Select + value={formData.operationCycle} + onChange={(value) => handleInputChange('operationCycle', value)} + options={operationCycleOptions} + placeholder="주기 선택" + titleStyle={{ width: '160px', height: '45px', border: '1px solid #ccc' }} + size="large" + /> + {formErrors.operationCycle && ( + <div className={cx('field-error')}>{formErrors.operationCycle}</div> + )} + </div> + </div> + </div> + + <div className={cx('form-row')}> + <label>종목</label> + <div className={cx('form-field')}> + {renderStockTypes()} + {formErrors.stockTypes && ( + <div className={cx('field-error')}>{formErrors.stockTypes}</div> )} </div> </div> - <div className={cx('form-row', 'half')}> - <label>주기</label> + <div className={cx('form-row')}> + <label>최소 운용가능 금액</label> <div className={cx('form-field')}> <Select - value={formData.operationCycle} - onChange={(value) => { - setFormData((prev) => ({ ...prev, operationCycle: value as OperationCycleType })) - setFormErrors((prev) => ({ ...prev, operationCycle: '' })) - }} - options={operationCycleOptions} - placeholder="주기 선택" - titleStyle={{ width: '200px', height: '50px' }} - containerStyle={{ width: '100%' }} + value={formData.minimumInvestmentAmount} + onChange={(value) => handleInputChange('minimumInvestmentAmount', value)} + options={minimumInvestmentAmountOptions} + placeholder="최소 운용가능 금액 선택" + titleStyle={{ width: '160px', height: '45px', border: '1px solid #ccc' }} + size="large" /> - {formErrors.operationCycle && ( - <div className={cx('field-error')}>{formErrors.operationCycle}</div> + {formErrors.minimumInvestmentAmount && ( + <div className={cx('field-error')}>{formErrors.minimumInvestmentAmount}</div> )} </div> </div> - </div> - <div className={cx('form-row')}> - <label>종목</label> - <div className={cx('form-field')}> - <div className={cx('stock-grid')}> - {strategyTypes?.stockTypes.map((type) => ( - <button - key={type.stockTypeId} - type="button" - onClick={() => toggleStockType(String(type.stockTypeId))} - className={cx('stock-item', { - selected: formData.stockTypes.includes(String(type.stockTypeId)), - })} - > - {type.stockTypeName} - <span className={cx('marker')}> - <Image - src={type.stockIconUrl} - alt={type.stockTypeName} - width={20} - height={20} - /> - </span> - </button> - ))} + + <div className={cx('form-row')}> + <label>전략 소개</label> + <div className={cx('form-field')}> + <textarea + value={formData.description} + onChange={(e) => handleInputChange('description', e.target.value)} + placeholder="내용을 입력하세요" + className={cx('description-textarea')} + /> + {formErrors.description && ( + <div className={cx('field-error')}>{formErrors.description}</div> + )} </div> - {formErrors.stockTypes && ( - <div className={cx('field-error')}>{formErrors.stockTypes}</div> - )} - </div> - </div> - <div className={cx('form-row')}> - <label>최소 운용가능 금액</label> - <div className={cx('form-field')}> - <Select - value={formData.minimumInvestmentAmount} - onChange={(value) => { - setFormData((prev) => ({ - ...prev, - minimumInvestmentAmount: value as MinimumInvestmentAmountType, - })) - setFormErrors((prev) => ({ ...prev, minimumInvestmentAmount: '' })) - }} - options={minimumInvestmentAmountOptions} - placeholder="최소 운용가능 금액 선택" - titleStyle={{ width: '200px', height: '50px' }} - containerStyle={{ width: '100%' }} - /> - {formErrors.minimumInvestmentAmount && ( - <div className={cx('field-error')}>{formErrors.minimumInvestmentAmount}</div> - )} - </div> - </div> - <div className={cx('form-row')}> - <label>전략 소개</label> - <div className={cx('form-field')}> - <Input - value={formData.description} - onChange={(e: React.ChangeEvent<HTMLInputElement>) => { - setFormData((prev) => ({ ...prev, description: e.target.value })) - setFormErrors((prev) => ({ ...prev, description: '' })) - }} - placeholder="내용을 입력하세요" - errorMessage={formErrors.description} - /> </div> - </div> - <div className={cx('form-row')}> - <label>제안서</label> - <div className={cx('form-field')}> - <div className={cx('file-upload')}> - <input - type="file" - accept=".xlsx,.xls" - onChange={handleFileChange} - id="proposalFile" - className={cx('file-input')} - /> - <label htmlFor="proposalFile" className={cx('file-label')}> - <span>{formData.proposalFile?.name || '엑셀 파일을 선택해주세요'}</span> - <FileIcon className={cx('file-icon')} /> - </label> + + <div className={cx('form-row')}> + <label>제안서</label> + <div className={cx('form-field')}> + {renderFileUpload()} + {formErrors.proposalFile && ( + <div className={cx('field-error')}>{formErrors.proposalFile}</div> + )} </div> - {formErrors.proposalFile && ( - <div className={cx('field-error')}>{formErrors.proposalFile}</div> - )} </div> - </div> - <div className={cx('button-wrapper')}> - <Button variant="outline" onClick={() => router.back()} type="button"> - 취소 - </Button> - <Button variant="filled" type="submit" disabled={isRegistering}> - {isRegistering ? '등록 중...' : '전략 등록하기'} - </Button> - </div> - </form> + + <p className={cx('notice')}> + *매매 유형, 주기, 종목, 최소운용 가능 금액은 추후 수정이 불가합니다. + </p> + + <div className={cx('button-wrapper')}> + <Button variant="outline" onClick={() => router.back()} type="button"> + 취소 + </Button> + <Button variant="filled" type="submit" disabled={isRegistering}> + {isRegistering ? '등록 중...' : '전략 등록하기'} + </Button> + </div> + </form> + </div> </> ) } + export default StrategyAddPage diff --git a/app/(dashboard)/my/strategies/add/styles.module.scss b/app/(dashboard)/my/strategies/add/styles.module.scss index 54fc4866..3c2c4cb6 100644 --- a/app/(dashboard)/my/strategies/add/styles.module.scss +++ b/app/(dashboard)/my/strategies/add/styles.module.scss @@ -1,3 +1,9 @@ +.container { + background-color: $color-white; + border-radius: 8px; + margin-top: 24px; +} + .form { max-width: 800px; margin: 0 auto; @@ -34,6 +40,15 @@ .form-field { flex: 1; + + .strategy-name-input { + border-radius: 6px; + padding-left: 12px; + + &::placeholder { + color: $color-gray-400; + } + } } .error { @@ -41,7 +56,13 @@ padding: 12px; border-radius: 4px; background-color: $color-white; - color: $color-orange-600; + color: $color-orange-700; + font-size: 14px; +} + +.field-error { + margin-top: 8px; + color: $color-orange-700; font-size: 14px; } @@ -52,18 +73,8 @@ min-height: 200px; } -.input { - width: 100%; - padding: 8px 12px; - border: 1px solid $color-gray-300; - border-radius: 4px; - font-size: 14px; - line-height: 1.5; - transition: border-color 0.2s; - - &.error { - border-color: $color-orange-600; - } +.input.error { + border-color: $color-orange-700; } .stock-grid { @@ -142,3 +153,30 @@ margin-top: 32px; gap: 24px; } + +.description-textarea { + width: 100%; + min-height: 120px; + padding: 12px; + border: 1px solid $color-gray-300; + border-radius: 4px; + resize: vertical; + font-size: 14px; + line-height: 1.5; + font-family: inherit; + + &:focus { + outline: none; + border-color: none; + } + + &::placeholder { + color: $color-gray-400; + } +} + +.notice { + @include typo-c1; + color: $color-gray-600; + margin: 36px 0; +} diff --git a/shared/ui/input/index.tsx b/shared/ui/input/index.tsx index 57a28dca..1d7bb8a4 100644 --- a/shared/ui/input/index.tsx +++ b/shared/ui/input/index.tsx @@ -15,6 +15,7 @@ interface Props extends ComponentPropsWithoutRef<'input'> { inputSize?: InputSizeType errorMessage?: string | null isWhiteDisabled?: boolean + hideErrorStyle?: boolean } const Input = forwardRef<HTMLInputElement, Props>( @@ -25,6 +26,7 @@ const Input = forwardRef<HTMLInputElement, Props>( value, errorMessage, isWhiteDisabled = false, + hideErrorStyle = false, onChange, ...props }, @@ -39,7 +41,10 @@ const Input = forwardRef<HTMLInputElement, Props>( className={cx( 'input', inputSize, - { error: !!errorMessage, 'white-disabled': isWhiteDisabled }, + { + error: !!errorMessage && !hideErrorStyle, + 'white-disabled': isWhiteDisabled, + }, className )} {...props} diff --git a/shared/ui/input/styles.module.scss b/shared/ui/input/styles.module.scss index 7210a507..6863a256 100644 --- a/shared/ui/input/styles.module.scss +++ b/shared/ui/input/styles.module.scss @@ -8,8 +8,8 @@ @include typo-b3; &:focus { - color: $color-gray-400; - border-color: $color-gray-700; + color: none; + border-color: none; } &.error { From ccff79220a431a04d1192f9992c65f0722666a7d Mon Sep 17 00:00:00 2001 From: kimpra <dustmqaksdltkfrlfdldi@gmail.com> Date: Wed, 11 Dec 2024 18:02:40 +0900 Subject: [PATCH 097/207] =?UTF-8?q?fix:=20=EA=B4=80=EB=A6=AC=EC=9E=90=20?= =?UTF-8?q?=EB=A7=A4=EB=A7=A4=20=EC=9C=A0=ED=98=95=20=ED=8E=98=EC=9D=B4?= =?UTF-8?q?=EC=A7=80=20=ED=99=9C=EC=84=B1=ED=99=94=20=EB=B2=84=ED=8A=BC=20?= =?UTF-8?q?=EB=B2=84=EA=B7=B8=20=ED=95=B4=EA=B2=B0=20(#83)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../_api/set-admin-stock-manage-table-data.tsx | 9 +++++++-- .../stock/stock-manage/_ui/active-stock-manage-table.tsx | 7 ++----- .../stock-manage/_ui/inactive-stock-manage-table.tsx | 9 ++------- .../category/_ui/trade/trade-manage/_api/get-trades.ts | 2 +- .../_api/set-admin-trade-manage-table-data.tsx | 9 +++++++-- .../trade/trade-manage/_ui/active-trade-manage-table.tsx | 9 ++------- .../trade-manage/_ui/inactive-trade-manage-table.tsx | 7 ++----- .../_ui/trade-active-state-toggle-button.tsx | 1 - 8 files changed, 23 insertions(+), 30 deletions(-) diff --git a/app/admin/category/_ui/stock/stock-manage/_api/set-admin-stock-manage-table-data.tsx b/app/admin/category/_ui/stock/stock-manage/_api/set-admin-stock-manage-table-data.tsx index c4ceb326..7c8e5a18 100644 --- a/app/admin/category/_ui/stock/stock-manage/_api/set-admin-stock-manage-table-data.tsx +++ b/app/admin/category/_ui/stock/stock-manage/_api/set-admin-stock-manage-table-data.tsx @@ -3,8 +3,9 @@ import Image from 'next/image' import StockActiveStateToggleButton from '../_ui/stock-active-state-toggle-button' import { StockResponseModel } from '../types' -const setAdminStockManageTableData = (data: StockResponseModel['result']) => +const setAdminStockManageTableData = (data: StockResponseModel['result'], isActive: boolean) => data?.content.map((data) => [ + data.stockTypeId, data.stockTypeName, <Image src={data.stockTypeIconUrl} @@ -13,7 +14,11 @@ const setAdminStockManageTableData = (data: StockResponseModel['result']) => height={24} key={data.stockTypeName} />, - <StockActiveStateToggleButton stockTypeId={data.stockTypeId} active key={data.stockTypeId} />, + <StockActiveStateToggleButton + active={isActive} + stockTypeId={data.stockTypeId} + key={data.stockTypeId} + />, ]) ?? [] export default setAdminStockManageTableData diff --git a/app/admin/category/_ui/stock/stock-manage/_ui/active-stock-manage-table.tsx b/app/admin/category/_ui/stock/stock-manage/_ui/active-stock-manage-table.tsx index 1e7c5fdf..aa0450d7 100644 --- a/app/admin/category/_ui/stock/stock-manage/_ui/active-stock-manage-table.tsx +++ b/app/admin/category/_ui/stock/stock-manage/_ui/active-stock-manage-table.tsx @@ -16,7 +16,7 @@ const ActiveStockManageTable = () => { const { data } = useStocksData('active', currentPage, TABLE_BODY_SIZE) if (!data) return null - const tableData = setAdminStockManageTableData(data) + const tableData = setAdminStockManageTableData(data, true) return ( <ManageTable @@ -31,7 +31,4 @@ const ActiveStockManageTable = () => { ) } -export default withSuspense( - ActiveStockManageTable, - <ManageTable.Skeleton active size={10} domain="종목" /> -) +export default ActiveStockManageTable diff --git a/app/admin/category/_ui/stock/stock-manage/_ui/inactive-stock-manage-table.tsx b/app/admin/category/_ui/stock/stock-manage/_ui/inactive-stock-manage-table.tsx index 602dad61..5587e2f0 100644 --- a/app/admin/category/_ui/stock/stock-manage/_ui/inactive-stock-manage-table.tsx +++ b/app/admin/category/_ui/stock/stock-manage/_ui/inactive-stock-manage-table.tsx @@ -2,8 +2,6 @@ import { useState } from 'react' -import withSuspense from '@/shared/utils/with-suspense' - import ManageTable from '../../../shared/manage-table' import setAdminStockManageTableData from '../_api/set-admin-stock-manage-table-data' import useStocksData from '../_hooks/query/use-stocks-data' @@ -16,7 +14,7 @@ const InactiveTradeManageTable = () => { const { data } = useStocksData('inactive', currentPage, TABLE_BODY_SIZE) if (!data) return null - const tableData = setAdminStockManageTableData(data) + const tableData = setAdminStockManageTableData(data, false) return ( <ManageTable @@ -30,7 +28,4 @@ const InactiveTradeManageTable = () => { ) } -export default withSuspense( - InactiveTradeManageTable, - <ManageTable.Skeleton size={10} domain="종목" /> -) +export default InactiveTradeManageTable diff --git a/app/admin/category/_ui/trade/trade-manage/_api/get-trades.ts b/app/admin/category/_ui/trade/trade-manage/_api/get-trades.ts index fbc7cb1b..cb7bef00 100644 --- a/app/admin/category/_ui/trade/trade-manage/_api/get-trades.ts +++ b/app/admin/category/_ui/trade/trade-manage/_api/get-trades.ts @@ -12,7 +12,7 @@ const getTrades = async (activateState: boolean) => { if (!res.data.isSuccess) throw new Error(res.data.message) - return res.data + return res.data.result } catch (err) { console.log('Error : ' + err) throw err diff --git a/app/admin/category/_ui/trade/trade-manage/_api/set-admin-trade-manage-table-data.tsx b/app/admin/category/_ui/trade/trade-manage/_api/set-admin-trade-manage-table-data.tsx index 2e7dec4a..fc0854af 100644 --- a/app/admin/category/_ui/trade/trade-manage/_api/set-admin-trade-manage-table-data.tsx +++ b/app/admin/category/_ui/trade/trade-manage/_api/set-admin-trade-manage-table-data.tsx @@ -4,8 +4,9 @@ import TradeActiveStateToggleButton from '../_ui/trade-active-state-toggle-butto // import StockActiveStateToggleButton from '../../../stock/stock-manage/_ui/stock-active-state-toggle-button' import { TradeResponseModel } from '../types' -const setAdminTradeManageTableData = (data: TradeResponseModel['result']) => +const setAdminTradeManageTableData = (data: TradeResponseModel['result'], isActive: boolean) => data?.map((data) => [ + data.tradeTypeId, data.tradeName, <Image src={data.tradeTypeIconUrl} @@ -14,7 +15,11 @@ const setAdminTradeManageTableData = (data: TradeResponseModel['result']) => height={24} key={data.tradeTypeId} />, - <TradeActiveStateToggleButton tradeTypeId={data.tradeTypeId} active key={data.tradeTypeId} />, + <TradeActiveStateToggleButton + active={isActive} + tradeTypeId={data.tradeTypeId} + key={data.tradeTypeId} + />, ]) ?? [] export default setAdminTradeManageTableData diff --git a/app/admin/category/_ui/trade/trade-manage/_ui/active-trade-manage-table.tsx b/app/admin/category/_ui/trade/trade-manage/_ui/active-trade-manage-table.tsx index 593164ab..bbd3b320 100644 --- a/app/admin/category/_ui/trade/trade-manage/_ui/active-trade-manage-table.tsx +++ b/app/admin/category/_ui/trade/trade-manage/_ui/active-trade-manage-table.tsx @@ -1,5 +1,3 @@ -import withSuspense from '@/shared/utils/with-suspense' - import ManageTable from '../../../shared/manage-table' import setAdminTradeManageTableData from '../_api/set-admin-trade-manage-table-data' import useTradeData from '../_hooks/query/use-trades-data' @@ -8,12 +6,9 @@ const ActiveTradeManageTable = () => { const { data } = useTradeData('active') if (!data) return null - const tableData = setAdminTradeManageTableData(data.result) + const tableData = setAdminTradeManageTableData(data, true) return <ManageTable data={tableData} active domain="매매 유형" /> } -export default withSuspense( - ActiveTradeManageTable, - <ManageTable.Skeleton active size={10} domain="매매 유형" /> -) +export default ActiveTradeManageTable diff --git a/app/admin/category/_ui/trade/trade-manage/_ui/inactive-trade-manage-table.tsx b/app/admin/category/_ui/trade/trade-manage/_ui/inactive-trade-manage-table.tsx index 922659ba..e157a1a4 100644 --- a/app/admin/category/_ui/trade/trade-manage/_ui/inactive-trade-manage-table.tsx +++ b/app/admin/category/_ui/trade/trade-manage/_ui/inactive-trade-manage-table.tsx @@ -8,12 +8,9 @@ const InactiveTradeManageTable = () => { const { data } = useTradeData('inactive') if (!data) return null - const tableData = setAdminTradeManageTableData(data.result) + const tableData = setAdminTradeManageTableData(data, false) return <ManageTable data={tableData} domain="매매 유형" /> } -export default withSuspense( - InactiveTradeManageTable, - <ManageTable.Skeleton size={10} domain="매매 유형" /> -) +export default InactiveTradeManageTable diff --git a/app/admin/category/_ui/trade/trade-manage/_ui/trade-active-state-toggle-button.tsx b/app/admin/category/_ui/trade/trade-manage/_ui/trade-active-state-toggle-button.tsx index 3ec95b75..a3996a5b 100644 --- a/app/admin/category/_ui/trade/trade-manage/_ui/trade-active-state-toggle-button.tsx +++ b/app/admin/category/_ui/trade/trade-manage/_ui/trade-active-state-toggle-button.tsx @@ -15,7 +15,6 @@ const TradeActiveStateToggleButton = ({ active, tradeTypeId }: Props) => { const { mutate, isPending } = useToggoleTradeActiveState(tradeTypeId) const onButtonClick = () => { mutate() - console.log('t', tradeTypeId) } return ( From 0a115f268de0e965a37d1b00606b82b25fcf4f8a Mon Sep 17 00:00:00 2001 From: kimpra <dustmqaksdltkfrlfdldi@gmail.com> Date: Wed, 11 Dec 2024 18:03:13 +0900 Subject: [PATCH 098/207] =?UTF-8?q?fix:=20=EA=B4=80=EB=A6=AC=EC=9E=90=20?= =?UTF-8?q?=EB=A7=A4=EB=A7=A4=20=EC=9C=A0=ED=98=95=20=ED=8E=98=EC=9D=B4?= =?UTF-8?q?=EC=A7=80=20=ED=91=9C=EC=9D=98=20No.=20index=EB=A1=9C=20?= =?UTF-8?q?=EC=88=98=EC=A0=95=20(#83)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- app/admin/category/_ui/shared/manage-table/index.tsx | 3 +-- .../_ui/shared/manage-table/utils/add-index-to-data.tsx | 7 ------- 2 files changed, 1 insertion(+), 9 deletions(-) delete mode 100644 app/admin/category/_ui/shared/manage-table/utils/add-index-to-data.tsx diff --git a/app/admin/category/_ui/shared/manage-table/index.tsx b/app/admin/category/_ui/shared/manage-table/index.tsx index ca6bb0ca..ee1c3a68 100644 --- a/app/admin/category/_ui/shared/manage-table/index.tsx +++ b/app/admin/category/_ui/shared/manage-table/index.tsx @@ -7,7 +7,6 @@ import VerticalTable from '@/shared/ui/table/vertical' import { COUNT_PER_PAGE } from './constant' import styles from './styles.module.scss' -import addIndexToData from './utils/add-index-to-data' const cx = classNames.bind(styles) @@ -38,7 +37,7 @@ const ManageTable = ({ <span className={cx('title')}>{active ? '활성화' : '비활성화'}</span> <VerticalTable tableHead={['No.', domain === '종목' ? '종목명' : '매매 유형', '분류', '상태']} - tableBody={addIndexToData(data)} + tableBody={data} countPerPage={size} currentPage={1} // 공통 컴포넌트와의 호환성 문제... /> diff --git a/app/admin/category/_ui/shared/manage-table/utils/add-index-to-data.tsx b/app/admin/category/_ui/shared/manage-table/utils/add-index-to-data.tsx deleted file mode 100644 index 5f59cf36..00000000 --- a/app/admin/category/_ui/shared/manage-table/utils/add-index-to-data.tsx +++ /dev/null @@ -1,7 +0,0 @@ -import { ReactNode } from 'react' - -const addIndexAndButton = (data: (string | number | ReactNode)[][]) => { - return data?.map((d, idx) => [idx + 1, ...d]) -} - -export default addIndexAndButton From 1518bafb44c0a5e0dbbf165a6e590ac240de51bc Mon Sep 17 00:00:00 2001 From: ssumanlife <ksm9805@naver.com> Date: Wed, 11 Dec 2024 18:40:23 +0900 Subject: [PATCH 099/207] =?UTF-8?q?bug:=20=ED=8C=8C=EC=9D=BC=20=EB=8B=A4?= =?UTF-8?q?=EC=9A=B4=EB=A1=9C=EB=93=9C=EC=8B=9C=20=ED=8C=8C=EC=9D=BC?= =?UTF-8?q?=EB=AA=85,=20=ED=99=95=EC=9E=A5=EC=9E=90=20=EB=AA=85=EC=8B=9C?= =?UTF-8?q?=EB=90=98=EB=8F=84=EB=A1=9D=20=EC=88=98=EC=A0=95=20(#87)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../_api/get-analysis-download.ts | 13 +++++--- .../_api/get-proposal-download.ts | 13 +++++--- .../[strategyId]/_api/helper-download-file.ts | 33 ++++++++++--------- 3 files changed, 36 insertions(+), 23 deletions(-) diff --git a/app/(dashboard)/strategies/[strategyId]/_api/get-analysis-download.ts b/app/(dashboard)/strategies/[strategyId]/_api/get-analysis-download.ts index 49d80032..651611a2 100644 --- a/app/(dashboard)/strategies/[strategyId]/_api/get-analysis-download.ts +++ b/app/(dashboard)/strategies/[strategyId]/_api/get-analysis-download.ts @@ -1,6 +1,8 @@ +import { AxiosError } from 'axios' + import axiosInstance from '@/shared/api/axios' -import { downloadFile } from './helper-download-file' +import { downloadFile, extractFileName } from './helper-download-file' const getAnalysisDownload = async (strategyId: number, type: 'daily' | 'monthly') => { try { @@ -10,12 +12,15 @@ const getAnalysisDownload = async (strategyId: number, type: 'daily' | 'monthly' responseType: 'blob', } ) - const blob = new Blob([response.data], { type: response.headers['content-type'] }) + const mimeType = response.headers['content-type'] || 'application/octet-stream' + const blob = new Blob([response.data], { type: mimeType }) const contentDisposition = response.headers['content-disposition'] - downloadFile(blob, contentDisposition, `${type}_분석자료`) + const fileName = extractFileName(contentDisposition, `${type}_분석자료`) + + downloadFile(blob, fileName) } catch (err) { - console.error(err) + throw new Error('분석자료 다운 실패', err as AxiosError) } } diff --git a/app/(dashboard)/strategies/[strategyId]/_api/get-proposal-download.ts b/app/(dashboard)/strategies/[strategyId]/_api/get-proposal-download.ts index 7992f065..645393ec 100644 --- a/app/(dashboard)/strategies/[strategyId]/_api/get-proposal-download.ts +++ b/app/(dashboard)/strategies/[strategyId]/_api/get-proposal-download.ts @@ -1,18 +1,23 @@ +import { AxiosError } from 'axios' + import axiosInstance from '@/shared/api/axios' -import { downloadFile } from './helper-download-file' +import { downloadFile, extractFileName } from './helper-download-file' const getProposalDownload = async (strategyId: number, name: string) => { try { const response = await axiosInstance.get(`/api/my-strategies/${strategyId}/download-proposal`, { responseType: 'blob', }) - const blob = new Blob([response.data], { type: response.headers['content-type'] }) + const mimeType = response.headers['content-type'] || 'application/octet-stream' + const blob = new Blob([response.data], { type: mimeType }) const contentDisposition = response.headers['content-disposition'] - downloadFile(blob, contentDisposition, `${name}_제안서`) + const fileName = extractFileName(contentDisposition, `${name}_제안서`) + + downloadFile(blob, fileName) } catch (err) { - console.error(err) + throw new Error('분석자료 다운 실패', err as AxiosError) } } diff --git a/app/(dashboard)/strategies/[strategyId]/_api/helper-download-file.ts b/app/(dashboard)/strategies/[strategyId]/_api/helper-download-file.ts index 3cfb1819..324712db 100644 --- a/app/(dashboard)/strategies/[strategyId]/_api/helper-download-file.ts +++ b/app/(dashboard)/strategies/[strategyId]/_api/helper-download-file.ts @@ -1,20 +1,23 @@ -export const downloadFile = ( - blob: Blob, +export const extractFileName = ( contentDisposition: string | undefined, defaultFileName: string -) => { - const url = window.URL.createObjectURL(blob) - const link = document.createElement('a') - link.href = url - - const fileName = contentDisposition - ? decodeURIComponent(contentDisposition.split('filename=')[1]) - : defaultFileName +): string => { + if (!contentDisposition) return defaultFileName - link.download = fileName - document.body.appendChild(link) - link.click() - document.body.removeChild(link) + const fileNameMatch = contentDisposition.match(/filename\*?="?([^;"]+)/i) + return fileNameMatch ? decodeURIComponent(fileNameMatch[1]) : defaultFileName +} - window.URL.revokeObjectURL(url) +export const downloadFile = (blob: Blob, fileName: string) => { + const url = window.URL.createObjectURL(blob) + try { + const link = document.createElement('a') + link.href = url + link.download = fileName + document.body.appendChild(link) + link.click() + document.body.removeChild(link) + } finally { + window.URL.revokeObjectURL(url) + } } From 0bc748ee35208b270733422c4630026355a9c469 Mon Sep 17 00:00:00 2001 From: ssumanlife <ksm9805@naver.com> Date: Wed, 11 Dec 2024 18:42:58 +0900 Subject: [PATCH 100/207] =?UTF-8?q?fix:=20=EC=97=90=EB=9F=AC=EB=AC=B8=20?= =?UTF-8?q?=EC=88=98=EC=A0=95=20(#87)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../strategies/[strategyId]/_api/get-proposal-download.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/app/(dashboard)/strategies/[strategyId]/_api/get-proposal-download.ts b/app/(dashboard)/strategies/[strategyId]/_api/get-proposal-download.ts index 645393ec..016246c2 100644 --- a/app/(dashboard)/strategies/[strategyId]/_api/get-proposal-download.ts +++ b/app/(dashboard)/strategies/[strategyId]/_api/get-proposal-download.ts @@ -17,7 +17,7 @@ const getProposalDownload = async (strategyId: number, name: string) => { downloadFile(blob, fileName) } catch (err) { - throw new Error('분석자료 다운 실패', err as AxiosError) + throw new Error('제안서 다운 실패', err as AxiosError) } } From 80c73689e04119d56d6e5f77bc942ce52e4c4cd5 Mon Sep 17 00:00:00 2001 From: kimpra <dustmqaksdltkfrlfdldi@gmail.com> Date: Wed, 11 Dec 2024 19:14:24 +0900 Subject: [PATCH 101/207] =?UTF-8?q?fix:=20=EA=B4=80=EB=A6=AC=EC=9E=90=20?= =?UTF-8?q?=ED=9A=8C=EC=9B=90=20=EA=B4=80=EB=A6=AC=20=ED=8E=98=EC=9D=B4?= =?UTF-8?q?=EC=A7=80=20=ED=8E=98=EC=9D=B4=EC=A7=80=EB=84=A4=EC=9D=B4?= =?UTF-8?q?=EC=85=98=20=EB=B2=84=EA=B7=B8=20=ED=95=B4=EA=B2=B0=20=EB=B0=8F?= =?UTF-8?q?=20=ED=85=9D=20=EC=9D=B4=EB=8F=99=20=EC=B5=9C=EC=A0=81=ED=99=94?= =?UTF-8?q?=20(#88)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- app/admin/users/_api/set-table-body.tsx | 4 +-- .../users/_hooks/use-user-search-page.ts | 25 +++++++++++++++++++ app/admin/users/page.tsx | 17 +++++++++---- 3 files changed, 39 insertions(+), 7 deletions(-) diff --git a/app/admin/users/_api/set-table-body.tsx b/app/admin/users/_api/set-table-body.tsx index 9eea38a3..7ae6ffec 100644 --- a/app/admin/users/_api/set-table-body.tsx +++ b/app/admin/users/_api/set-table-body.tsx @@ -13,9 +13,9 @@ interface ArgModel { } const setTableBody = ({ data, openModal, setDeleteUserId }: ArgModel) => - data.map((data, idx) => { + data.map((data) => { return [ - idx + 1, + data.userId, <Avatar src={data?.imageUrl ?? undefined} key={data.userId} />, data.userName, data.nickname, diff --git a/app/admin/users/_hooks/use-user-search-page.ts b/app/admin/users/_hooks/use-user-search-page.ts index 0335a970..4c4fa4cc 100644 --- a/app/admin/users/_hooks/use-user-search-page.ts +++ b/app/admin/users/_hooks/use-user-search-page.ts @@ -8,13 +8,35 @@ const useUserSearchPage = () => { const [inputValue, setInputValue] = useState('') const [keyword, setKeyword] = useState('') const [condition, setCondition] = useState<string | null>(null) + const [currentPage, setCurrentPage] = useState(1) + + const searchParams = { + role: activeTab, + condition, + keyword, + page: currentPage, + } const setConditionAndKeyword = () => { setKeyword(inputValue) setCondition(select) } + const initializeSearchParams = () => { + setCurrentPage(1) + setSelect(searchOptions[0].value) + setInputValue('') + setKeyword('') + setCondition(null) + } + + const onTabChange = (tab: string) => { + setActiveTab(tab) + initializeSearchParams() + } + return { + searchParams, searchOptions, tabs, select, @@ -28,6 +50,9 @@ const useUserSearchPage = () => { condition, setCondition, setConditionAndKeyword, + currentPage, + setCurrentPage, + onTabChange, } } diff --git a/app/admin/users/page.tsx b/app/admin/users/page.tsx index c103bee9..b8bb4aea 100644 --- a/app/admin/users/page.tsx +++ b/app/admin/users/page.tsx @@ -23,20 +23,23 @@ const cx = classNames.bind(styles) const AdminUsersPage = () => { const { + searchParams, searchOptions, tabs, select, setSelect, activeTab, - setActiveTab, inputValue, setInputValue, keyword, condition, setConditionAndKeyword, + currentPage, + setCurrentPage, + onTabChange, } = useUserSearch() - const { isLoading, data } = useAdminUsers({ role: activeTab, condition, keyword }) + const { isLoading, data } = useAdminUsers(searchParams) const { isModalOpen, closeModal, openModal } = useModal() const [deleteUserId, setDeleteUserId] = useState<number>(0) @@ -46,7 +49,7 @@ const AdminUsersPage = () => { <> <Title label="회원 관리" className={cx('title')} /> <section className={cx('container')}> - <Tabs tabs={tabs} activeTab={activeTab} onTabChange={setActiveTab} /> + <Tabs tabs={tabs} activeTab={activeTab} onTabChange={onTabChange} /> <AdminContentsHeader Left={ <span> @@ -77,10 +80,14 @@ const AdminUsersPage = () => { <VerticalTable tableHead={['No.', '프로필', '이름', '닉네임', '이메일', '전화번호', '회원분류', '탈퇴']} tableBody={setTableBody({ data: data?.content, openModal, setDeleteUserId })} - countPerPage={10} + countPerPage={data.size} currentPage={1} /> - <Pagination currentPage={data?.page} maxPage={data?.totalPages} onPageChange={() => {}} /> + <Pagination + currentPage={data?.page} + maxPage={data?.totalPages} + onPageChange={(page) => setCurrentPage(page)} + /> </section> <UserDeleteModal userId={deleteUserId} isModalOpen={isModalOpen} closeModal={closeModal} /> </> From a346735464fabbb0f923d2b136eae29e65b52e03 Mon Sep 17 00:00:00 2001 From: James <dawn147@snu.ac.kr> Date: Wed, 11 Dec 2024 19:34:31 +0900 Subject: [PATCH 102/207] =?UTF-8?q?feat:=20=ED=94=84=EB=A1=9C=ED=95=84=20?= =?UTF-8?q?=EC=9D=B4=EB=AF=B8=EC=A7=80=20=EB=B3=80=EA=B2=BD=20=EC=8B=9C=20?= =?UTF-8?q?=EC=84=A0=ED=83=9D=ED=95=9C=20=EC=82=AC=EC=A7=84=EC=9D=84=20?= =?UTF-8?q?=EC=A6=89=EC=8B=9C=20=ED=94=84=EB=A6=AC=EB=B7=B0=EB=A1=9C=20?= =?UTF-8?q?=EB=B3=BC=20=EC=88=98=20=EC=9E=88=EB=8F=84=EB=A1=9D=20=EB=B3=80?= =?UTF-8?q?=EA=B2=BD=20(#85)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- app/(dashboard)/my/profile/_ui/user-info/index.tsx | 7 +++++-- 1 file changed, 5 insertions(+), 2 deletions(-) diff --git a/app/(dashboard)/my/profile/_ui/user-info/index.tsx b/app/(dashboard)/my/profile/_ui/user-info/index.tsx index 5ebaf0ad..c668fa13 100644 --- a/app/(dashboard)/my/profile/_ui/user-info/index.tsx +++ b/app/(dashboard)/my/profile/_ui/user-info/index.tsx @@ -85,6 +85,9 @@ const UserInfo = ({ profile, isEditable = false }: Props) => { if (!profile) return null + const displayImageUrl = previewUrl || profile.imageUrl || '' + const shouldShowImage = Boolean(previewUrl || profile.imageUrl) + return ( <div className={cx('container')}> <p className={cx('title')}>개인 정보</p> @@ -93,10 +96,10 @@ const UserInfo = ({ profile, isEditable = false }: Props) => { <div className={cx('content-wrapper')}> <div className={cx('left-wrapper')}> <div className={cx('avatar-wrapper', { isEditable })}> - {previewUrl || profile.imageUrl ? ( + {shouldShowImage ? ( <div className={cx('image-container')}> <Image - src={profile.imageUrl as string} + src={displayImageUrl} alt="Profile" width={200} height={200} From 13eed38c9da94eda13bd5e6a53b183ba14768eb7 Mon Sep 17 00:00:00 2001 From: ssumanlife <ksm9805@naver.com> Date: Wed, 11 Dec 2024 21:55:54 +0900 Subject: [PATCH 103/207] =?UTF-8?q?design:=20=EC=85=80=EB=A0=89=ED=8A=B8?= =?UTF-8?q?=20large=EC=82=AC=EC=9D=B4=EC=A6=88=20=EB=B0=98=EC=9D=91?= =?UTF-8?q?=ED=98=95=20=EC=A0=81=EC=9A=A9(#91)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- shared/ui/dropdown/styles.module.scss | 12 ++++++++++++ 1 file changed, 12 insertions(+) diff --git a/shared/ui/dropdown/styles.module.scss b/shared/ui/dropdown/styles.module.scss index 15ccf8da..3011206c 100644 --- a/shared/ui/dropdown/styles.module.scss +++ b/shared/ui/dropdown/styles.module.scss @@ -2,6 +2,8 @@ $small-width: 90px; $small-height: 30px; $large-width: 160px; $large-height: 40px; +$medium-width: 124px; +$medium-height: 30px; $color-selected-text: #171717; @@ -18,6 +20,9 @@ $color-selected-text: #171717; &-large { width: $large-width; height: fit-content; + @include tablet-md { + width: $medium-width; + } } } @@ -68,6 +73,10 @@ $color-selected-text: #171717; height: $large-height; padding-left: 10.5px; padding-right: 14.5px; + @include tablet-md { + width: $medium-width; + height: $medium-height; + } } .options { @@ -92,6 +101,9 @@ $color-selected-text: #171717; &.large { width: $large-width; max-height: 200px; + @include tablet-md { + width: $medium-width; + } } } From f8f1a182b6edab34a8f3e4c5ac01e278a9045783 Mon Sep 17 00:00:00 2001 From: James <dawn147@snu.ac.kr> Date: Wed, 11 Dec 2024 22:45:09 +0900 Subject: [PATCH 104/207] =?UTF-8?q?fix:=20=EB=B6=88=ED=95=84=EC=9A=94?= =?UTF-8?q?=ED=95=9C=20=EC=BD=94=EB=93=9C=20=EC=A0=9C=EA=B1=B0=20(#95)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- next.config.mjs | 8 -------- shared/api/axios.ts | 1 - 2 files changed, 9 deletions(-) diff --git a/next.config.mjs b/next.config.mjs index c3a3ba48..9d6ab14e 100644 --- a/next.config.mjs +++ b/next.config.mjs @@ -11,14 +11,6 @@ const nextConfig = { }, async rewrites() { return [ - { - source: '/api/users/:path*', - destination: 'http://15.164.90.102:8081/api/users/:path*', - }, - { - source: '/login', - destination: 'http://15.164.90.102:8081/login', - }, { source: '/api/:path*', destination: 'http://15.164.90.102:8081/api/:path*', diff --git a/shared/api/axios.ts b/shared/api/axios.ts index 6c8bc6be..c03929d4 100644 --- a/shared/api/axios.ts +++ b/shared/api/axios.ts @@ -7,7 +7,6 @@ import { isTokenExpired, refreshToken } from '@/shared/utils/token-utils' export const createAxiosInstance = (options: { withInterceptors?: boolean } = {}) => { const instance = axios.create({ - baseURL: process.env.NEXT_PUBLIC_API_HOST || 'http://15.164.90.102:8081', withCredentials: true, }) From 56114549e4514fb74da98edf69697b8fef35b759 Mon Sep 17 00:00:00 2001 From: James <dawn147@snu.ac.kr> Date: Wed, 11 Dec 2024 22:52:11 +0900 Subject: [PATCH 105/207] =?UTF-8?q?fix:=20=EA=B4=80=EB=A6=AC=EC=9E=90=20?= =?UTF-8?q?=EC=84=B8=EC=85=98=20=EC=97=B0=EC=9E=A5=20=EB=AA=A8=EB=8B=AC=20?= =?UTF-8?q?=EB=B3=B4=EC=9D=B4=EC=A7=80=20=EC=95=8A=EB=8A=94=20=EB=B2=84?= =?UTF-8?q?=EA=B7=B8=20=EC=88=98=EC=A0=95=20(#95)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- shared/constants/auth.ts | 2 +- shared/hooks/custom/use-auth.ts | 4 +- .../custom/use-session-expiry-warning.tsx | 86 ++++++++++--------- shared/providers/auth-provider.tsx | 5 +- shared/ui/modal/index.tsx | 2 +- 5 files changed, 52 insertions(+), 47 deletions(-) diff --git a/shared/constants/auth.ts b/shared/constants/auth.ts index 7a88df99..a950e877 100644 --- a/shared/constants/auth.ts +++ b/shared/constants/auth.ts @@ -8,7 +8,7 @@ export const STORAGE_KEYS = { } as const export const AUTH_TIME = { - TOKEN_CHECK_INTERVAL: 120 * 1000, + TOKEN_CHECK_INTERVAL: 60 * 1000, ADMIN_EXPIRY_WARNING: 600 * 1000, SAFETY_MARGIN: 5 * 1000, } as const diff --git a/shared/hooks/custom/use-auth.ts b/shared/hooks/custom/use-auth.ts index 618e674d..4d545639 100644 --- a/shared/hooks/custom/use-auth.ts +++ b/shared/hooks/custom/use-auth.ts @@ -2,7 +2,7 @@ import { useCallback, useEffect } from 'react' import { usePathname } from 'next/navigation' -import { AUTH_TIME } from '@/shared/constants/auth' +import { AUTH_TIME, STORAGE_KEYS } from '@/shared/constants/auth' import { useLogoutMutation } from '@/shared/hooks/query/auth-queries' import { getAccessToken } from '@/shared/lib/auth-tokens' import { useAuthStore } from '@/shared/stores/use-auth-store' @@ -31,7 +31,7 @@ export const useAuth = () => { } if (isAdmin(user)) { - if (localStorage.getItem('access_token')) { + if (!sessionStorage.getItem(STORAGE_KEYS.ACCESS_TOKEN)) { logout() return null } diff --git a/shared/hooks/custom/use-session-expiry-warning.tsx b/shared/hooks/custom/use-session-expiry-warning.tsx index 34ed1afc..d8a1877e 100644 --- a/shared/hooks/custom/use-session-expiry-warning.tsx +++ b/shared/hooks/custom/use-session-expiry-warning.tsx @@ -1,4 +1,4 @@ -import { useCallback, useEffect, useRef } from 'react' +import { useCallback, useEffect } from 'react' import { AUTH_TIME } from '@/shared/constants/auth' import { useRefreshTokenMutation } from '@/shared/hooks/query/auth-queries' @@ -14,66 +14,68 @@ export const useSessionExpiryWarning = () => { const { checkTokenStatus } = useAuth() const { user } = useAuthStore() const { mutate: refreshToken } = useRefreshTokenMutation() - const { isModalOpen, openModal, onCloseModal } = useModalStore() - const { setWarningShown, setMinutesLeft, minutesLeft } = useSessionStore() - const warningShownRef = useRef(false) - - useEffect(() => { - if (!isModalOpen) return - - const timer = setInterval(() => { - setMinutesLeft((prev) => { - const newMinutes = Math.max(0, prev - 1) - if (newMinutes === 0) { - onCloseModal() - warningShownRef.current = false - setWarningShown(false) - } - return newMinutes - }) - }, 60000) - - return () => clearInterval(timer) - }, [isModalOpen, onCloseModal, setMinutesLeft, setWarningShown]) + const { + isModalOpen: isSessionModalOpen, + openModal: openSessionModal, + onCloseModal: closeSessionModal, + } = useModalStore() + const { isWarningShown, setWarningShown, setMinutesLeft, minutesLeft } = useSessionStore() const handleRefreshToken = useCallback(() => { refreshToken(undefined, { onSuccess: () => { - onCloseModal() - warningShownRef.current = false + closeSessionModal() setWarningShown(false) }, - onError: (error) => { - console.error('Token refresh failed in session warning:', error) + onError: (err) => { + console.error('Token refresh failed in session warning:', err) }, }) - }, [refreshToken, onCloseModal, setWarningShown]) + }, [refreshToken, closeSessionModal, setWarningShown]) - useEffect(() => { + const performCheck = useCallback(() => { if (!user || !isAdmin(user)) return - const checkAndNotify = () => { - const tokenStatus = checkTokenStatus() + const tokenStatus = checkTokenStatus() - if (tokenStatus?.isNearExpiry && !warningShownRef.current) { - const minutes = Math.floor(tokenStatus.timeUntilExpiry / 60000) - setMinutesLeft(minutes) - setWarningShown(true) - warningShownRef.current = true - openModal() - } + if (tokenStatus?.isNearExpiry && !isWarningShown) { + const minutes = Math.floor(tokenStatus.timeUntilExpiry / 60000) + setMinutesLeft(minutes) + setWarningShown(true) + openSessionModal() } + }, [user, checkTokenStatus, isWarningShown, setMinutesLeft, setWarningShown, openSessionModal]) - checkAndNotify() - const interval = setInterval(checkAndNotify, AUTH_TIME.TOKEN_CHECK_INTERVAL) + useEffect(() => { + performCheck() + }, [performCheck]) + useEffect(() => { + const interval = setInterval(performCheck, AUTH_TIME.TOKEN_CHECK_INTERVAL) return () => clearInterval(interval) - }, [checkTokenStatus, openModal, setMinutesLeft, setWarningShown, user]) + }, [performCheck]) + + useEffect(() => { + if (!isSessionModalOpen) return + + const timer = setInterval(() => { + setMinutesLeft((prev) => { + const newMinutes = Math.max(0, prev - 1) + if (newMinutes === 0) { + closeSessionModal() + setWarningShown(false) + } + return newMinutes + }) + }, 60000) + + return () => clearInterval(timer) + }, [isSessionModalOpen, closeSessionModal, setMinutesLeft, setWarningShown]) return ( <SessionExtensionModal - isModalOpen={isModalOpen} - onCloseModal={onCloseModal} + isModalOpen={isSessionModalOpen} + onCloseModal={closeSessionModal} onExtend={handleRefreshToken} minutesLeft={minutesLeft} /> diff --git a/shared/providers/auth-provider.tsx b/shared/providers/auth-provider.tsx index 01152c90..10098e32 100644 --- a/shared/providers/auth-provider.tsx +++ b/shared/providers/auth-provider.tsx @@ -6,6 +6,7 @@ import { usePathname, useRouter } from 'next/navigation' import { PATH } from '@/shared/constants/path' import useModal from '@/shared/hooks/custom/use-modal' +import { useSessionExpiryWarning } from '@/shared/hooks/custom/use-session-expiry-warning' import { getAccessToken } from '@/shared/lib/auth-tokens' import SigninCheckModal from '@/shared/ui/modal/signin-check-modal' @@ -24,6 +25,7 @@ export const AuthProvider = ({ children }: AuthProviderProps) => { const router = useRouter() const { initializeAuthState } = useAuthStore() const { isModalOpen, openModal, closeModal } = useModal() + const SessionModal = useSessionExpiryWarning() useAuth() @@ -43,7 +45,7 @@ export const AuthProvider = ({ children }: AuthProviderProps) => { router.replace(PATH.STRATEGIES) return } - }, [pathname, router]) + }, [pathname, router, openModal]) const handleLoginConfirm = () => { closeModal() @@ -58,6 +60,7 @@ export const AuthProvider = ({ children }: AuthProviderProps) => { onCloseModal={closeModal} onConfirm={handleLoginConfirm} /> + {SessionModal} </AuthContext.Provider> ) } diff --git a/shared/ui/modal/index.tsx b/shared/ui/modal/index.tsx index e4a13f9f..885a8b8d 100644 --- a/shared/ui/modal/index.tsx +++ b/shared/ui/modal/index.tsx @@ -28,7 +28,7 @@ const Modal = ({ const modalRoot = document.getElementById('modal-root') - if (!isOpen || !modalRoot) return null + if (!modalRoot) return null return createPortal( <> From 30fa6a106fa6915fcc4401be47af7970dee3c160 Mon Sep 17 00:00:00 2001 From: deun <devdeun@gmail.com> Date: Wed, 11 Dec 2024 23:52:11 +0900 Subject: [PATCH 106/207] =?UTF-8?q?design:=20=EB=AC=B8=EC=9D=98=20?= =?UTF-8?q?=EB=82=B4=EC=97=AD=20select=20medium=20=EC=82=AC=EC=9D=B4?= =?UTF-8?q?=EC=A6=88=EB=A1=9C=20=EB=B3=80=EA=B2=BD=20(#90)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- app/(dashboard)/my/questions/page.tsx | 2 +- shared/ui/dropdown/styles.module.scss | 16 ++++++++++++++++ shared/ui/dropdown/types.ts | 2 +- 3 files changed, 18 insertions(+), 2 deletions(-) diff --git a/app/(dashboard)/my/questions/page.tsx b/app/(dashboard)/my/questions/page.tsx index 7cf2c8fa..a2590400 100644 --- a/app/(dashboard)/my/questions/page.tsx +++ b/app/(dashboard)/my/questions/page.tsx @@ -64,7 +64,7 @@ const MyQuestionsPage = () => { <Title label="문의 내역" /> <div className={cx('search-wrapper')}> <Select - size="small" + size="medium" value={selectedOption} placeholder="검색 조건" onChange={setSelectedOption} diff --git a/shared/ui/dropdown/styles.module.scss b/shared/ui/dropdown/styles.module.scss index 3011206c..3b1434c5 100644 --- a/shared/ui/dropdown/styles.module.scss +++ b/shared/ui/dropdown/styles.module.scss @@ -17,6 +17,11 @@ $color-selected-text: #171717; height: fit-content; } + &-medium { + width: $medium-width; + height: fit-content; + } + &-large { width: $large-width; height: fit-content; @@ -68,6 +73,12 @@ $color-selected-text: #171717; padding: 3px 6px 3px 10px; } +.medium { + width: $medium-width; + height: $medium-height; + padding: 3px 6px 3px 10px; +} + .large { width: $large-width; height: $large-height; @@ -98,6 +109,11 @@ $color-selected-text: #171717; max-height: 180px; } + &.medium { + width: $medium-width; + max-height: 180px; + } + &.large { width: $large-width; max-height: 200px; diff --git a/shared/ui/dropdown/types.ts b/shared/ui/dropdown/types.ts index d0baaf4f..a7b1cc5f 100644 --- a/shared/ui/dropdown/types.ts +++ b/shared/ui/dropdown/types.ts @@ -7,7 +7,7 @@ export interface DropdownOptionModel { export type DropdownValueType = string | string[] | null -export type DropdownSizeType = 'small' | 'large' +export type DropdownSizeType = 'small' | 'medium' | 'large' export interface DropdownProps { size?: DropdownSizeType From 065a75ff16080f317ae069f292048cceba032856 Mon Sep 17 00:00:00 2001 From: ssumanlife <ksm9805@naver.com> Date: Thu, 12 Dec 2024 00:01:04 +0900 Subject: [PATCH 107/207] =?UTF-8?q?fix:=20=EB=84=A4=EB=B9=84=EA=B2=8C?= =?UTF-8?q?=EC=9D=B4=EC=85=98=EB=B0=94=20avatar=EC=BB=B4=ED=8F=AC=EB=84=8C?= =?UTF-8?q?=ED=8A=B8=20=EC=A0=81=EC=9A=A9=20(#97)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- shared/ui/avatar/index.tsx | 2 +- shared/ui/side-navigation/nav-link-item.tsx | 12 +++++++++--- shared/ui/side-navigation/styles.module.scss | 1 + shared/ui/side-navigation/user-navigation.tsx | 4 ++-- 4 files changed, 13 insertions(+), 6 deletions(-) diff --git a/shared/ui/avatar/index.tsx b/shared/ui/avatar/index.tsx index 349991d7..dba4522c 100644 --- a/shared/ui/avatar/index.tsx +++ b/shared/ui/avatar/index.tsx @@ -23,7 +23,7 @@ const Avatar = ({ src, size = 'small', avatarStyle }: Props) => { const [isValidImage, setIsValidImage] = useState(true) return ( - <div className={cx('avatar', size, avatarStyle)}> + <div className={cx('avatar', size)} style={avatarStyle}> {src && isValidImage ? ( <Image src={src} alt="프로필" fill onError={() => setIsValidImage(false)} /> ) : ( diff --git a/shared/ui/side-navigation/nav-link-item.tsx b/shared/ui/side-navigation/nav-link-item.tsx index 53408ed6..c074c065 100644 --- a/shared/ui/side-navigation/nav-link-item.tsx +++ b/shared/ui/side-navigation/nav-link-item.tsx @@ -7,25 +7,31 @@ import classNames from 'classnames/bind' import { PATH } from '@/shared/constants/path' +import Avatar from '../avatar' import styles from './styles.module.scss' const cx = classNames.bind(styles) interface Props { href: string - icon: React.ElementType + icon?: React.ElementType children: React.ReactNode textClassName?: keyof typeof styles + imageUrl?: string } -const NavLinkItem = ({ href, icon: Icon, textClassName, children }: Props) => { +const NavLinkItem = ({ href, icon: Icon, textClassName, children, imageUrl }: Props) => { const path = usePathname() const isActive = path.startsWith(href) && href !== PATH.PROFILE return ( <li className={cx('navigation-item')}> <Link href={href} className={cx('link', isActive && 'active')}> - <Icon className={cx('icon')} /> + {Icon ? ( + <Icon className={cx('icon')} /> + ) : ( + <Avatar size="medium" src={imageUrl} avatarStyle={{ marginLeft: '-4px' }} /> + )} <span className={cx('text', textClassName)}>{children}</span> </Link> </li> diff --git a/shared/ui/side-navigation/styles.module.scss b/shared/ui/side-navigation/styles.module.scss index 8b9cbb69..981df37b 100644 --- a/shared/ui/side-navigation/styles.module.scss +++ b/shared/ui/side-navigation/styles.module.scss @@ -110,6 +110,7 @@ .user { display: flex; flex-direction: column; + margin-left: -4px; .nickname { color: $color-gray-800; diff --git a/shared/ui/side-navigation/user-navigation.tsx b/shared/ui/side-navigation/user-navigation.tsx index c503aedd..f3020c77 100644 --- a/shared/ui/side-navigation/user-navigation.tsx +++ b/shared/ui/side-navigation/user-navigation.tsx @@ -23,11 +23,11 @@ const UserNavigation = () => { const isAdminPage = path.startsWith(PATH.ADMIN) if (!user) return null - + console.log(user) return ( <nav className={cx('user-navigation')} aria-label="사용자 메뉴"> <ul> - <NavLinkItem href={PATH.PROFILE} icon={ProfileIcon} textClassName="user"> + <NavLinkItem href={PATH.PROFILE} imageUrl={user.imageUrl} textClassName="user"> <span className={cx('nickname')}>{user.nickname}</span> <span className={cx('email')}>{user.email}</span> </NavLinkItem> From bb878b735496e635974f7ec6422d848b07f4912b Mon Sep 17 00:00:00 2001 From: James <dawn147@snu.ac.kr> Date: Thu, 12 Dec 2024 00:01:51 +0900 Subject: [PATCH 108/207] =?UTF-8?q?fix:=20=EB=A1=9C=EA=B7=B8=EC=9D=B8=20?= =?UTF-8?q?=EB=B0=95=EC=8A=A4=EA=B0=80=20=ED=99=94=EB=A9=B4=20=EC=A4=91?= =?UTF-8?q?=EC=95=99=EC=97=90=20=EA=B3=A0=EC=A0=95=EB=90=98=EB=8F=84?= =?UTF-8?q?=EB=A1=9D=20=EC=88=98=EC=A0=95=20(#100)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- app/(landing)/signin/styles.module.scss | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/app/(landing)/signin/styles.module.scss b/app/(landing)/signin/styles.module.scss index 222d7562..04d59511 100644 --- a/app/(landing)/signin/styles.module.scss +++ b/app/(landing)/signin/styles.module.scss @@ -2,7 +2,7 @@ display: flex; justify-content: center; align-items: center; - margin-top: 150px; + height: calc(100vh - $header-height); } .loginBox { From e2f985d88525d7ab7b65c23d16a8591ca74a4fea Mon Sep 17 00:00:00 2001 From: James <dawn147@snu.ac.kr> Date: Thu, 12 Dec 2024 00:03:15 +0900 Subject: [PATCH 109/207] =?UTF-8?q?fix:=20=EB=B6=88=ED=95=84=EC=9A=94?= =?UTF-8?q?=ED=95=9C=20=EC=A3=BC=EC=84=9D=20=EC=A0=9C=EA=B1=B0=20(#100)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- shared/ui/header/logo-header/index.tsx | 1 - 1 file changed, 1 deletion(-) diff --git a/shared/ui/header/logo-header/index.tsx b/shared/ui/header/logo-header/index.tsx index 8ac8b42b..01c5bd05 100644 --- a/shared/ui/header/logo-header/index.tsx +++ b/shared/ui/header/logo-header/index.tsx @@ -1,4 +1,3 @@ -// NOTE: 이름 공모전 개최 import Logo from '@/shared/ui/logo' import Header from '..' From 17f74b25674db563c69ee98f63e3aea8ff69d57d Mon Sep 17 00:00:00 2001 From: James <dawn147@snu.ac.kr> Date: Thu, 12 Dec 2024 00:47:23 +0900 Subject: [PATCH 110/207] =?UTF-8?q?fix:=20useSuspenseQuery=20->=20useQuery?= =?UTF-8?q?=EB=A1=9C=20=EB=B3=80=EA=B2=BD=20(#103)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../notices/_hook/query/get-admin-notices.ts | 6 +-- .../notices/_ui/admin-notice-table/index.tsx | 49 ++++++++++--------- 2 files changed, 28 insertions(+), 27 deletions(-) diff --git a/app/admin/notices/_hook/query/get-admin-notices.ts b/app/admin/notices/_hook/query/get-admin-notices.ts index 9fa8d853..6f68de43 100644 --- a/app/admin/notices/_hook/query/get-admin-notices.ts +++ b/app/admin/notices/_hook/query/get-admin-notices.ts @@ -1,7 +1,5 @@ import getNotices from '@/app/(landing)/notices/_api/get-notice' -import { useSuspenseQuery } from '@tanstack/react-query' - -// import getNotices from '../_api/get-notice' +import { useQuery } from '@tanstack/react-query' interface Props { page?: number @@ -9,7 +7,7 @@ interface Props { } const useAdminNotices = ({ page, size }: Props = {}) => { - return useSuspenseQuery({ + return useQuery({ queryKey: ['notices', page, size], queryFn: () => getNotices({ page, size }), }) diff --git a/app/admin/notices/_ui/admin-notice-table/index.tsx b/app/admin/notices/_ui/admin-notice-table/index.tsx index f75a295b..769d5888 100644 --- a/app/admin/notices/_ui/admin-notice-table/index.tsx +++ b/app/admin/notices/_ui/admin-notice-table/index.tsx @@ -6,7 +6,6 @@ import classNames from 'classnames/bind' import { Button } from '@/shared/ui/button' import Pagination from '@/shared/ui/pagination' import VerticalTable from '@/shared/ui/table/vertical' -import withSuspense from '@/shared/utils/with-suspense' import useAdminNotices from '../../_hook/query/get-admin-notices' import NoticeDeleteButton from '../notice-delete-button' @@ -15,7 +14,30 @@ import styles from './styles.module.scss' const cx = classNames.bind(styles) const AdminNoticeTable = () => { - const { data } = useAdminNotices() + const { data, isLoading } = useAdminNotices() + + if (isLoading) { + return <VerticalTable.Skeleton tableHead={['No.', '제목', '내용', '작성일', '']} /> + } + + const tableBody = + data?.content.map((data, idx) => [ + idx + 1, + data.title, + data.content.slice(0, 15), + data.createdAt.slice(0, 10), + <Button.ButtonGroup key={data.content}> + <Button + onClick={() => {}} + size="small" + className={cx('button')} + style={{ padding: '16px 7px' }} + > + 수정 + </Button> + <NoticeDeleteButton noticeId={data.noticeId} /> + </Button.ButtonGroup>, + ]) || [] return ( <> @@ -29,23 +51,7 @@ const AdminNoticeTable = () => { /> <VerticalTable tableHead={['No.', '제목', '내용', '작성일', '']} - tableBody={data?.content.map((data, idx) => [ - idx + 1, - data.title, - data.content.slice(0, 15), - data.createdAt.slice(0, 10), - <Button.ButtonGroup key={data.content}> - <Button - onClick={() => {}} - size="small" - className={cx('button')} - style={{ padding: '16px 7px' }} - > - 수정 - </Button> - <NoticeDeleteButton noticeId={data.noticeId} /> - </Button.ButtonGroup>, - ])} + tableBody={tableBody} countPerPage={10} currentPage={1} /> @@ -54,7 +60,4 @@ const AdminNoticeTable = () => { ) } -export default withSuspense( - AdminNoticeTable, - <VerticalTable.Skeleton tableHead={['No.', '제목', '내용', '작성일', '']} /> -) +export default AdminNoticeTable From c27fdf8a2f207e46f8603809109faeebc87d08c7 Mon Sep 17 00:00:00 2001 From: kimpra <dustmqaksdltkfrlfdldi@gmail.com> Date: Thu, 12 Dec 2024 03:31:47 +0900 Subject: [PATCH 111/207] =?UTF-8?q?feat:=20alert=20modal=20=EA=B3=B5?= =?UTF-8?q?=ED=86=B5=20=EC=BB=B4=ED=8F=AC=EB=84=8C=ED=8A=B8=20=EA=B5=AC?= =?UTF-8?q?=ED=98=84=20(#105)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- shared/ui/modal/alert-modal/index.tsx | 35 +++++++++++++++++++++++++++ 1 file changed, 35 insertions(+) create mode 100644 shared/ui/modal/alert-modal/index.tsx diff --git a/shared/ui/modal/alert-modal/index.tsx b/shared/ui/modal/alert-modal/index.tsx new file mode 100644 index 00000000..39f11825 --- /dev/null +++ b/shared/ui/modal/alert-modal/index.tsx @@ -0,0 +1,35 @@ +'use client' + +import { ModalAlertIcon } from '@/public/icons' +import classNames from 'classnames/bind' + +import { Button } from '@/shared/ui/button' +import Modal from '@/shared/ui/modal' + +import styles from '../styles.module.scss' + +const cx = classNames.bind(styles) + +interface Props { + isModalOpen: boolean + message: string + disabled?: boolean + onConfirm: () => void + onCancel: () => void +} + +const AlertModal = ({ isModalOpen, message, disabled, onConfirm, onCancel }: Props) => { + return ( + <Modal isOpen={isModalOpen} icon={ModalAlertIcon}> + <span className={cx('message')}>{message}</span> + <div className={cx('two-button')}> + <Button onClick={onCancel}>아니오</Button> + <Button onClick={onConfirm} disabled={disabled} variant="filled" className={cx('button')}> + 예 + </Button> + </div> + </Modal> + ) +} + +export default AlertModal From dede1a1b3998bde19abf2ac2a93e846e89d329ff Mon Sep 17 00:00:00 2001 From: kimpra <dustmqaksdltkfrlfdldi@gmail.com> Date: Thu, 12 Dec 2024 03:32:56 +0900 Subject: [PATCH 112/207] =?UTF-8?q?feat:=20=EA=B4=80=EB=A6=AC=EC=9E=90=20?= =?UTF-8?q?=EC=82=AC=EC=9A=A9=EC=9E=90=20=ED=8E=98=EC=9D=B4=EC=A7=80?= =?UTF-8?q?=EC=97=90=20alert=20modal=20=EC=9E=91=EC=9A=A9=20(#105)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- app/admin/users/_api/set-table-body.tsx | 14 ++--------- app/admin/users/_ui/user-delete-button.tsx | 28 ++++++++++++++++++---- app/admin/users/page.tsx | 12 +--------- 3 files changed, 26 insertions(+), 28 deletions(-) diff --git a/app/admin/users/_api/set-table-body.tsx b/app/admin/users/_api/set-table-body.tsx index 7ae6ffec..80eb1669 100644 --- a/app/admin/users/_api/set-table-body.tsx +++ b/app/admin/users/_api/set-table-body.tsx @@ -1,5 +1,3 @@ -import { Dispatch, SetStateAction } from 'react' - import Avatar from '@/shared/ui/avatar' import RoleSelect from '../_ui/role-select' @@ -8,11 +6,9 @@ import { AdminUserInfoModel } from '../types' interface ArgModel { data: AdminUserInfoModel[] - openModal: () => void - setDeleteUserId: Dispatch<SetStateAction<number>> } -const setTableBody = ({ data, openModal, setDeleteUserId }: ArgModel) => +const setTableBody = ({ data }: ArgModel) => data.map((data) => { return [ data.userId, @@ -22,13 +18,7 @@ const setTableBody = ({ data, openModal, setDeleteUserId }: ArgModel) => data.email, data.phone, <RoleSelect data={data} key={data.userId} />, - <UserDeleteButton - onClick={() => { - openModal() - setDeleteUserId(data.userId) - }} - key={data.userId} - />, + <UserDeleteButton userId={data.userId} key={data.userId} />, ] }) diff --git a/app/admin/users/_ui/user-delete-button.tsx b/app/admin/users/_ui/user-delete-button.tsx index 07b5e283..df67031e 100644 --- a/app/admin/users/_ui/user-delete-button.tsx +++ b/app/admin/users/_ui/user-delete-button.tsx @@ -1,16 +1,34 @@ 'use client' +import useModal from '@/shared/hooks/custom/use-modal' import { Button } from '@/shared/ui/button' +import AlertModal from '@/shared/ui/modal/alert-modal' + +import useDeleteUser from '../_hooks/query/use-delete-user' interface Props { - onClick: () => void + userId: number } -const UserDeleteButton = ({ onClick }: Props) => { +const UserDeleteButton = ({ userId }: Props) => { + const { closeModal, isModalOpen, openModal } = useModal() + const { mutate, isPending } = useDeleteUser(userId) + return ( - <Button variant="filled" onClick={onClick} size="small" style={{ padding: '7px 16px' }}> - 강제탈퇴 - </Button> + <> + <Button variant="filled" onClick={openModal} size="small" style={{ padding: '7px 16px' }}> + 강제탈퇴 + </Button> + <AlertModal + message={`해당 회원을\n탈퇴시키겠습니까?`} + isModalOpen={isModalOpen} + disabled={isPending} + onCancel={closeModal} + onConfirm={() => { + mutate() + }} + /> + </> ) } diff --git a/app/admin/users/page.tsx b/app/admin/users/page.tsx index b8bb4aea..74537f8f 100644 --- a/app/admin/users/page.tsx +++ b/app/admin/users/page.tsx @@ -1,10 +1,7 @@ 'use client' -import { useState } from 'react' - import classNames from 'classnames/bind' -import useModal from '@/shared/hooks/custom/use-modal' import Pagination from '@/shared/ui/pagination' import SearchInput from '@/shared/ui/search-input' import Select from '@/shared/ui/select' @@ -16,7 +13,6 @@ import AdminContentsHeader from '../_ui/admin-header' import setTableBody from './_api/set-table-body' import useAdminUsers from './_hooks/query/use-admin-users' import useUserSearch from './_hooks/use-user-search-page' -import UserDeleteModal from './_ui/user-delete-modal' import styles from './page.module.scss' const cx = classNames.bind(styles) @@ -31,17 +27,12 @@ const AdminUsersPage = () => { activeTab, inputValue, setInputValue, - keyword, - condition, setConditionAndKeyword, - currentPage, setCurrentPage, onTabChange, } = useUserSearch() const { isLoading, data } = useAdminUsers(searchParams) - const { isModalOpen, closeModal, openModal } = useModal() - const [deleteUserId, setDeleteUserId] = useState<number>(0) if (isLoading || !data) return null @@ -79,7 +70,7 @@ const AdminUsersPage = () => { /> <VerticalTable tableHead={['No.', '프로필', '이름', '닉네임', '이메일', '전화번호', '회원분류', '탈퇴']} - tableBody={setTableBody({ data: data?.content, openModal, setDeleteUserId })} + tableBody={setTableBody({ data: data?.content })} countPerPage={data.size} currentPage={1} /> @@ -89,7 +80,6 @@ const AdminUsersPage = () => { onPageChange={(page) => setCurrentPage(page)} /> </section> - <UserDeleteModal userId={deleteUserId} isModalOpen={isModalOpen} closeModal={closeModal} /> </> ) } From 51e600f59dcf79b56ec277b4344dc3f6ea3ede45 Mon Sep 17 00:00:00 2001 From: kimpra <dustmqaksdltkfrlfdldi@gmail.com> Date: Thu, 12 Dec 2024 03:33:19 +0900 Subject: [PATCH 113/207] =?UTF-8?q?feat:=20=EA=B4=80=EB=A6=AC=EC=9E=90=20?= =?UTF-8?q?=EA=B3=B5=EC=A7=80=20=ED=8E=98=EC=9D=B4=EC=A7=80=EC=97=90=20ale?= =?UTF-8?q?rt=20modal=20=EC=9E=91=EC=9A=A9=20(#105)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../notices/_ui/admin-notice-table/index.tsx | 4 +-- .../_ui/notice-delete-button/index.tsx | 35 ++++++++++++------- 2 files changed, 24 insertions(+), 15 deletions(-) diff --git a/app/admin/notices/_ui/admin-notice-table/index.tsx b/app/admin/notices/_ui/admin-notice-table/index.tsx index 769d5888..c731ccb7 100644 --- a/app/admin/notices/_ui/admin-notice-table/index.tsx +++ b/app/admin/notices/_ui/admin-notice-table/index.tsx @@ -21,8 +21,8 @@ const AdminNoticeTable = () => { } const tableBody = - data?.content.map((data, idx) => [ - idx + 1, + data?.content.map((data) => [ + data.noticeId, data.title, data.content.slice(0, 15), data.createdAt.slice(0, 10), diff --git a/app/admin/notices/_ui/notice-delete-button/index.tsx b/app/admin/notices/_ui/notice-delete-button/index.tsx index 08705403..1fd6ed0c 100644 --- a/app/admin/notices/_ui/notice-delete-button/index.tsx +++ b/app/admin/notices/_ui/notice-delete-button/index.tsx @@ -2,7 +2,9 @@ import classNames from 'classnames/bind' +import useModal from '@/shared/hooks/custom/use-modal' import { Button } from '@/shared/ui/button' +import AlertModal from '@/shared/ui/modal/alert-modal' import useDeleteNotice from '../../_hook/query/use-delete-notice' import styles from './styles.module.scss' @@ -15,21 +17,28 @@ interface Props { const NoticeDeleteButton = ({ noticeId }: Props) => { const { mutate, isPending } = useDeleteNotice(noticeId) - - const onClick = () => { - mutate() - } + const { closeModal, isModalOpen, openModal } = useModal() return ( - <Button - size="small" - onClick={onClick} - disabled={isPending} - variant="filled" - className={cx('button')} - > - 삭제 - </Button> + <> + <Button + size="small" + onClick={openModal} + disabled={isPending} + variant="filled" + className={cx('button')} + > + 삭제 + </Button> + <AlertModal + message={`해당 공지를\n삭제하시겠습니까?`} + isModalOpen={isModalOpen} + onCancel={closeModal} + onConfirm={() => { + mutate() + }} + /> + </> ) } From c7a905aaecc147b43949320a8e343cf57a585244 Mon Sep 17 00:00:00 2001 From: kimpra <dustmqaksdltkfrlfdldi@gmail.com> Date: Thu, 12 Dec 2024 03:41:06 +0900 Subject: [PATCH 114/207] =?UTF-8?q?feat:=20=EA=B4=80=EB=A6=AC=EC=9E=90=20?= =?UTF-8?q?=EB=AC=B8=EC=9D=98=20=EB=82=B4=EC=97=AD=20=ED=8E=98=EC=9D=B4?= =?UTF-8?q?=EC=A7=80=EC=97=90=20alert=20modal=20=EC=9E=91=EC=9A=A9=20(#105?= =?UTF-8?q?)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../admin-question-delete-button/index.tsx | 35 +++++++++++++------ 1 file changed, 24 insertions(+), 11 deletions(-) diff --git a/app/admin/questions/_ui/admin-question-delete-button/index.tsx b/app/admin/questions/_ui/admin-question-delete-button/index.tsx index 5f86135c..43891731 100644 --- a/app/admin/questions/_ui/admin-question-delete-button/index.tsx +++ b/app/admin/questions/_ui/admin-question-delete-button/index.tsx @@ -2,7 +2,9 @@ import classNames from 'classnames/bind' +import useModal from '@/shared/hooks/custom/use-modal' import { Button } from '@/shared/ui/button' +import AlertModal from '@/shared/ui/modal/alert-modal' import useDeleteQuestion from '../../_hooks/query/use-delete-question' import styles from './styles.module.scss' @@ -14,18 +16,29 @@ interface Props { } const AdminQuestionDeleteButton = ({ questionId, strategyId }: Props) => { const { mutate, isPending } = useDeleteQuestion({ strategyId, questionId }) - const onClick = () => mutate() + const { closeModal, isModalOpen, openModal } = useModal() + return ( - <Button - variant="filled" - onClick={onClick} - disabled={isPending} - size="small" - style={{ padding: '7px 16px' }} - className={cx('button')} - > - 삭제 - </Button> + <> + <Button + variant="filled" + onClick={openModal} + size="small" + style={{ padding: '7px 16px' }} + className={cx('button')} + > + 삭제 + </Button> + <AlertModal + message={`해당 문의내역을\n삭제하시겠습니까?`} + isModalOpen={isModalOpen} + disabled={isPending} + onCancel={closeModal} + onConfirm={() => { + mutate() + }} + /> + </> ) } export default AdminQuestionDeleteButton From bb73d0547baeea6163d4c52c4a4258566eec4d16 Mon Sep 17 00:00:00 2001 From: ssumanlife <ksm9805@naver.com> Date: Thu, 12 Dec 2024 09:56:32 +0900 Subject: [PATCH 115/207] =?UTF-8?q?fix:=20=EB=B6=88=ED=95=84=EC=9A=94?= =?UTF-8?q?=ED=95=9C=20=EC=BD=94=EB=93=9C=20=EC=82=AD=EC=A0=9C=20(#97)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- shared/ui/side-navigation/user-navigation.tsx | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/shared/ui/side-navigation/user-navigation.tsx b/shared/ui/side-navigation/user-navigation.tsx index f3020c77..edb4eb34 100644 --- a/shared/ui/side-navigation/user-navigation.tsx +++ b/shared/ui/side-navigation/user-navigation.tsx @@ -2,7 +2,7 @@ import { usePathname } from 'next/navigation' -import { ChangeIcon, ProfileIcon, SignOutIcon } from '@/public/icons' +import { ChangeIcon, SignOutIcon } from '@/public/icons' import classNames from 'classnames/bind' import { PATH } from '@/shared/constants/path' @@ -23,7 +23,7 @@ const UserNavigation = () => { const isAdminPage = path.startsWith(PATH.ADMIN) if (!user) return null - console.log(user) + return ( <nav className={cx('user-navigation')} aria-label="사용자 메뉴"> <ul> From bd515d813692a3198b2c6ef978d34533b3c4bbbe Mon Sep 17 00:00:00 2001 From: James <dawn147@snu.ac.kr> Date: Thu, 12 Dec 2024 11:38:52 +0900 Subject: [PATCH 116/207] =?UTF-8?q?fix:=20=EC=A2=85=EB=AA=A9=20=EB=B0=8F?= =?UTF-8?q?=20=EB=A7=A4=EB=A7=A4=EC=9C=A0=ED=98=95=20=EC=B6=94=EA=B0=80=20?= =?UTF-8?q?presignedUrl=20=EC=B2=98=EB=A6=AC=EB=B0=A9=EC=8B=9D=20=EB=B3=80?= =?UTF-8?q?=EA=B2=BD=20(#108)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../_api/upload-file-with-presigned-url.ts | 21 ++++++++++++------- 1 file changed, 14 insertions(+), 7 deletions(-) diff --git a/app/admin/category/_ui/shared/_api/upload-file-with-presigned-url.ts b/app/admin/category/_ui/shared/_api/upload-file-with-presigned-url.ts index eb561b65..82211852 100644 --- a/app/admin/category/_ui/shared/_api/upload-file-with-presigned-url.ts +++ b/app/admin/category/_ui/shared/_api/upload-file-with-presigned-url.ts @@ -1,11 +1,18 @@ -import axiosInstance from '@/shared/api/axios' - const uploadFileWithPresignedUrl = async (presignedUrl: string, file: File) => { - await axiosInstance.put(presignedUrl, file, { - headers: { - 'Content-Type': file.type, - }, - }) + try { + await fetch(presignedUrl, { + method: 'PUT', + body: file, + headers: { + 'Content-Type': file.type, + }, + }) + } catch (err) { + if (err instanceof Error) { + throw new Error(`File upload failed: ${err.message}`) + } + throw err + } } export default uploadFileWithPresignedUrl From 4361cdf7004ce4a32359be139005b1cb3136990a Mon Sep 17 00:00:00 2001 From: James <dawn147@snu.ac.kr> Date: Thu, 12 Dec 2024 11:57:19 +0900 Subject: [PATCH 117/207] =?UTF-8?q?fix:=20=EC=9D=BC=EA=B0=84=20=EB=B6=84?= =?UTF-8?q?=EC=84=9D=20=EC=97=91=EC=85=80=20=EC=9E=98=EB=AA=BB=20=EC=97=85?= =?UTF-8?q?=EB=A1=9C=EB=93=9C=EC=8B=9C=20=EC=83=81=EC=84=B8=ED=95=9C=20?= =?UTF-8?q?=EC=98=A4=EB=A5=98=20=EB=A9=94=EC=8B=9C=EC=A7=80=EA=B0=80=20?= =?UTF-8?q?=EB=B3=B4=EC=9D=B4=EB=8F=84=EB=A1=9D=20=EC=88=98=EC=A0=95=20(#1?= =?UTF-8?q?10)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- shared/utils/excel-utils.ts | 18 ++++++++++++++---- 1 file changed, 14 insertions(+), 4 deletions(-) diff --git a/shared/utils/excel-utils.ts b/shared/utils/excel-utils.ts index f26d16f5..c7db59ee 100644 --- a/shared/utils/excel-utils.ts +++ b/shared/utils/excel-utils.ts @@ -25,9 +25,7 @@ export const processExcelFile = (file: File): Promise<RowDataModel[]> => { }) const header = jsonData[0] - if (!header || header[0] !== '일자' || header[1] !== '입출금' || header[2] !== '일일손익') { - throw new Error('올바른 형식의 엑셀 파일이 아닙니다.') - } + validateHeaders(header) const rows: RowDataModel[] = jsonData .slice(1) @@ -52,7 +50,7 @@ export const processExcelFile = (file: File): Promise<RowDataModel[]> => { const parseExcelDate = (excelDate: number | string | null): string => { if (excelDate === null) return '' - const dateStr = String(excelDate) + const dateStr = String(excelDate).replace(/[^0-9]/g, '') if (dateStr.length === 8) { const year = dateStr.substring(0, 4) const month = dateStr.substring(4, 6) @@ -70,3 +68,15 @@ const parseExcelDate = (excelDate: number | string | null): string => { return String(excelDate) } + +const validateHeaders = (header: (string | number | null)[]) => { + const requiredHeaders = ['일자', '입출금', '일손익'] + + const missingHeaders = requiredHeaders.filter( + (requiredHeader) => !header.some((h) => h?.toString().includes(requiredHeader)) + ) + + if (missingHeaders.length > 0) { + throw new Error(`다음 헤더를 포함해야 합니다: ${missingHeaders.join(', ')}`) + } +} From ca501718844e832c2f73fc0f7a1686bcd81e6f12 Mon Sep 17 00:00:00 2001 From: James <dawn147@snu.ac.kr> Date: Thu, 12 Dec 2024 11:57:58 +0900 Subject: [PATCH 118/207] =?UTF-8?q?fix:=20=EB=A1=9C=EA=B7=B8=EC=95=84?= =?UTF-8?q?=EC=9B=83=20=EC=8B=9C=20=EA=B2=80=EC=83=89=20=EC=83=81=ED=83=9C?= =?UTF-8?q?=EB=8F=84=20=EC=B4=88=EA=B8=B0=ED=99=94=EB=90=98=EB=8F=84?= =?UTF-8?q?=EB=A1=9D=20=EC=88=98=EC=A0=95=20(#110)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- shared/hooks/query/auth-queries.ts | 3 +++ 1 file changed, 3 insertions(+) diff --git a/shared/hooks/query/auth-queries.ts b/shared/hooks/query/auth-queries.ts index b6d7bee6..ff40a7ab 100644 --- a/shared/hooks/query/auth-queries.ts +++ b/shared/hooks/query/auth-queries.ts @@ -1,5 +1,6 @@ import { useRouter } from 'next/navigation' +import useSearchingItemStore from '@/app/(dashboard)/strategies/_ui/search-bar/_store/use-searching-item-store' import { useMutation } from '@tanstack/react-query' import axios from 'axios' @@ -59,6 +60,7 @@ export const useLogoutMutation = () => { isAuthenticated: false, user: null, }) + useSearchingItemStore.getState().actions.resetState() router.replace(PATH.SIGN_IN) }, onError: (error) => { @@ -68,6 +70,7 @@ export const useLogoutMutation = () => { isAuthenticated: false, user: null, }) + useSearchingItemStore.getState().actions.resetState() router.replace(PATH.SIGN_IN) }, }) From b4905e5e87f786d6f2b73235990460a7ac3e36ae Mon Sep 17 00:00:00 2001 From: ssumanlife <ksm9805@naver.com> Date: Thu, 12 Dec 2024 12:40:53 +0900 Subject: [PATCH 119/207] =?UTF-8?q?feat:=20=EC=A0=84=EB=9E=B5=20=EC=88=98?= =?UTF-8?q?=EC=A0=95=20api=20=EC=B6=94=EA=B0=80=20(#107)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- app/(dashboard)/my/_api/post-edit-strategy.ts | 26 +++++++++++++++++++ .../my/_hooks/query/use-post-edit-strategy.ts | 20 ++++++++++++++ 2 files changed, 46 insertions(+) create mode 100644 app/(dashboard)/my/_api/post-edit-strategy.ts create mode 100644 app/(dashboard)/my/_hooks/query/use-post-edit-strategy.ts diff --git a/app/(dashboard)/my/_api/post-edit-strategy.ts b/app/(dashboard)/my/_api/post-edit-strategy.ts new file mode 100644 index 00000000..0ce4acaa --- /dev/null +++ b/app/(dashboard)/my/_api/post-edit-strategy.ts @@ -0,0 +1,26 @@ +import { AxiosError } from 'axios' + +import axiosInstance from '@/shared/api/axios' + +export interface ContentModel { + strategyName: string + description: string + proposalModified: boolean +} + +const postEditStrategy = async ( + strategyId: number, + information: ContentModel +): Promise<boolean | null> => { + try { + const response = await axiosInstance.post( + `/api/my-strategies/modify/${strategyId}`, + information + ) + return response.data.isSuccess + } catch (err) { + throw new Error('전략 정보 수정 실패', err as AxiosError) + } +} + +export default postEditStrategy diff --git a/app/(dashboard)/my/_hooks/query/use-post-edit-strategy.ts b/app/(dashboard)/my/_hooks/query/use-post-edit-strategy.ts new file mode 100644 index 00000000..59d9afcf --- /dev/null +++ b/app/(dashboard)/my/_hooks/query/use-post-edit-strategy.ts @@ -0,0 +1,20 @@ +import { QueryClient, useMutation } from '@tanstack/react-query' + +import postEditStrategy, { ContentModel } from '../../_api/post-edit-strategy' + +const usePostEditStrategy = () => { + const queryClient = new QueryClient() + return useMutation< + boolean | null | undefined, + unknown, + { strategyId: number; information: ContentModel } + >({ + mutationFn: ({ strategyId, information }: { strategyId: number; information: ContentModel }) => + postEditStrategy(strategyId, information), + onSuccess: (strategyId) => { + queryClient.invalidateQueries({ queryKey: ['strategyDetails', strategyId] }) + }, + }) +} + +export default usePostEditStrategy From 77c6b5f032cd4e655607eedb7deda60dc6b77aed Mon Sep 17 00:00:00 2001 From: ssumanlife <ksm9805@naver.com> Date: Thu, 12 Dec 2024 12:41:48 +0900 Subject: [PATCH 120/207] =?UTF-8?q?fix:=20=EC=A0=84=EB=9E=B5=20=EC=88=98?= =?UTF-8?q?=EC=A0=95=EC=9D=84=20=EC=9C=84=ED=95=9C=20Props=20=EC=B6=94?= =?UTF-8?q?=EA=B0=80=20(#107)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- app/(dashboard)/_ui/details-information/index.tsx | 12 ++++++++++-- .../_ui/details-information/invest-information.tsx | 5 +++-- app/(dashboard)/_ui/details-side-item/index.tsx | 5 ++++- app/(dashboard)/_ui/details-side-item/side-item.tsx | 4 +++- app/(dashboard)/_ui/subscriber-item/index.tsx | 4 +++- 5 files changed, 23 insertions(+), 7 deletions(-) diff --git a/app/(dashboard)/_ui/details-information/index.tsx b/app/(dashboard)/_ui/details-information/index.tsx index 3aae89cd..533efb0d 100644 --- a/app/(dashboard)/_ui/details-information/index.tsx +++ b/app/(dashboard)/_ui/details-information/index.tsx @@ -14,9 +14,15 @@ interface Props { strategyId: number information: StrategyDetailsInformationModel type?: 'default' | 'my' + isEditable?: boolean } -const DetailsInformation = ({ strategyId, information, type = 'default' }: Props) => { +const DetailsInformation = ({ + strategyId, + information, + type = 'default', + isEditable = false, +}: Props) => { const percentageToArray = [ { percent: information.cumulativeProfitRate, label: '누적 수익률' }, { percent: information.maxDrawdownRate, label: '최대 자본 인하율' }, @@ -39,14 +45,16 @@ const DetailsInformation = ({ strategyId, information, type = 'default' }: Props ]} name={information.strategyName} strategyId={strategyId} + isEditable={isEditable} /> <InvestInformation stock={information.stockTypeInfo?.stockTypeNames || []} trade={information.tradeTypeName} cycle={information.operationCycle} + isEditable={isEditable} /> </div> - <StrategyIntroduction content={information.strategyDescription} /> + <StrategyIntroduction content={information.strategyDescription} isEditable={isEditable} /> {type === 'default' && ( <div className={cx('percentage-container')}> {percentageToArray.map((data) => ( diff --git a/app/(dashboard)/_ui/details-information/invest-information.tsx b/app/(dashboard)/_ui/details-information/invest-information.tsx index 9a9a78a8..5a51b8cf 100644 --- a/app/(dashboard)/_ui/details-information/invest-information.tsx +++ b/app/(dashboard)/_ui/details-information/invest-information.tsx @@ -8,16 +8,17 @@ interface Props { stock: string[] trade: string cycle: string + isEditable?: boolean } -const InvestInformation = ({ stock, trade, cycle }: Props) => { +const InvestInformation = ({ stock, trade, cycle, isEditable = false }: Props) => { const investData = [ { title: '투자 종목', data: stock.join(',') }, { title: '매매 유형', data: trade }, { title: '투자 주기', data: cycle }, ] return ( - <div className={cx('invest-container')}> + <div className={cx('invest-container', { edit: isEditable })}> {investData.map((data, idx) => ( <div key={`${data}${idx}`} className={cx('info-item')}> <p className={cx('invest-title')}>{data.title}</p> diff --git a/app/(dashboard)/_ui/details-side-item/index.tsx b/app/(dashboard)/_ui/details-side-item/index.tsx index 2a51328b..75d520ac 100644 --- a/app/(dashboard)/_ui/details-side-item/index.tsx +++ b/app/(dashboard)/_ui/details-side-item/index.tsx @@ -25,12 +25,14 @@ interface Props { profileImage?: string isMyStrategy?: boolean strategyName?: string + isEditable?: boolean } const DetailsSideItem = ({ strategyId, information, profileImage, + isEditable = false, isMyStrategy = true, strategyName, }: Props) => { @@ -38,7 +40,7 @@ const DetailsSideItem = ({ return ( <> {isArray ? ( - <div className={cx('side-items')}> + <div className={cx('side-items', { edit: isEditable })}> {information.map((item) => ( <div key={item.title}> <div className={cx('title')}>{item.title}</div> @@ -56,6 +58,7 @@ const DetailsSideItem = ({ profileImage={profileImage} isMyStrategy={isMyStrategy} strategyName={strategyName} + isEditable={isEditable} /> )} </> diff --git a/app/(dashboard)/_ui/details-side-item/side-item.tsx b/app/(dashboard)/_ui/details-side-item/side-item.tsx index ff0a5572..e132a209 100644 --- a/app/(dashboard)/_ui/details-side-item/side-item.tsx +++ b/app/(dashboard)/_ui/details-side-item/side-item.tsx @@ -22,6 +22,7 @@ interface Props { profileImage?: string isMyStrategy?: boolean strategyName?: string + isEditable?: boolean } const SideItem = ({ @@ -31,6 +32,7 @@ const SideItem = ({ profileImage, isMyStrategy = false, strategyName, + isEditable = false, }: Props) => { const { isModalOpen: isAddQuestionModalOpen, @@ -47,7 +49,7 @@ const SideItem = ({ const isTrader = user?.role.includes('TRADER') return ( - <div className={cx('side-item')}> + <div className={cx('side-item', { edit: isEditable })}> <div className={cx('title')}>{title}</div> <div className={cx('data')}> {title === '트레이더' ? ( diff --git a/app/(dashboard)/_ui/subscriber-item/index.tsx b/app/(dashboard)/_ui/subscriber-item/index.tsx index d3dbeed2..ac2c319d 100644 --- a/app/(dashboard)/_ui/subscriber-item/index.tsx +++ b/app/(dashboard)/_ui/subscriber-item/index.tsx @@ -14,6 +14,7 @@ const cx = classNames.bind(styles) interface Props { isMyStrategy?: boolean + isEditable?: boolean isSubscribed?: boolean strategyId?: number subscribers: number @@ -22,6 +23,7 @@ interface Props { const SubscriberItem = ({ isSubscribed, + isEditable = false, isMyStrategy = false, strategyId, subscribers, @@ -30,7 +32,7 @@ const SubscriberItem = ({ const currentPath = usePathname() return ( - <div className={cx('container')}> + <div className={cx('container', { edit: isEditable })}> <div> <span>구독 </span> <span>| </span> From 52528610a2c829db7e95613e3ce68e0e54b3783b Mon Sep 17 00:00:00 2001 From: ssumanlife <ksm9805@naver.com> Date: Thu, 12 Dec 2024 12:42:34 +0900 Subject: [PATCH 121/207] =?UTF-8?q?feat:=20=EC=A0=84=EB=9E=B5=20=EC=88=98?= =?UTF-8?q?=EC=A0=95=20=EA=B8=B0=EB=8A=A5=20=EC=B6=94=EA=B0=80=20(#107)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../details-information/strategy-name-box.tsx | 29 ++++++++- .../details-information/styles.module.scss | 12 ++++ .../_ui/details-side-item/styles.module.scss | 5 ++ app/(dashboard)/_ui/introduction/index.tsx | 26 +++++++- .../_ui/subscriber-item/styles.module.scss | 5 ++ .../_store/use-edit-information-store.tsx | 37 +++++++++++ .../strategies/manage/[strategyId]/page.tsx | 63 +++++++++++++++++-- 7 files changed, 166 insertions(+), 11 deletions(-) create mode 100644 app/(dashboard)/my/strategies/manage/[strategyId]/_store/use-edit-information-store.tsx diff --git a/app/(dashboard)/_ui/details-information/strategy-name-box.tsx b/app/(dashboard)/_ui/details-information/strategy-name-box.tsx index ff29ebe5..ca14f37f 100644 --- a/app/(dashboard)/_ui/details-information/strategy-name-box.tsx +++ b/app/(dashboard)/_ui/details-information/strategy-name-box.tsx @@ -1,8 +1,14 @@ -import React from 'react' +'use client' + +/* eslint-disable react-hooks/exhaustive-deps */ +import { useEffect } from 'react' import StrategiesIcon from '@/app/(dashboard)/_ui/strategies-item/strategies-icon' import classNames from 'classnames/bind' +import Input from '@/shared/ui/input' + +import useEditInformationStore from '../../my/strategies/manage/[strategyId]/_store/use-edit-information-store' import useGetProposalDownload from '../../strategies/[strategyId]/_hooks/query/use-get-proposal-download' import styles from './styles.module.scss' @@ -13,19 +19,36 @@ interface Props { iconUrls?: string[] iconNames?: string[] name: string + isEditable?: boolean } -const StrategyNameBox = ({ strategyId, iconUrls, iconNames, name }: Props) => { +const StrategyNameBox = ({ strategyId, iconUrls, iconNames, name, isEditable = false }: Props) => { + const information = useEditInformationStore((state) => state.information) + const setStrategyName = useEditInformationStore((state) => state.actions.setStrategyName) const { mutate } = useGetProposalDownload() const handleDownload = () => { mutate({ strategyId, name }) } + useEffect(() => { + setStrategyName(name) + }, []) + return ( <div className={cx('name-container')}> <StrategiesIcon iconUrls={iconUrls} iconNames={iconNames} isDetailsPage={true} /> - <p className={cx('name')}>{name}</p> + {isEditable ? ( + <Input + onChange={(e: React.ChangeEvent<HTMLInputElement>) => setStrategyName(e.target.value)} + inputSize="small" + className={cx('name-input')} + maxLength={16} + value={information.strategyName as string} + /> + ) : ( + <p className={cx('name')}>{name}</p> + )} <button onClick={handleDownload}>제안서 다운로드</button> </div> ) diff --git a/app/(dashboard)/_ui/details-information/styles.module.scss b/app/(dashboard)/_ui/details-information/styles.module.scss index db26037d..fbec0ead 100644 --- a/app/(dashboard)/_ui/details-information/styles.module.scss +++ b/app/(dashboard)/_ui/details-information/styles.module.scss @@ -14,6 +14,13 @@ gap: 4px; border-radius: 5px; background-color: $color-white; + .input, + .name-input { + padding: 10px; + height: 30px; + width: 100%; + } + .name { @include typo-b1; } @@ -39,6 +46,11 @@ padding: 20px; border-radius: 5px; background-color: $color-white; + + &.edit { + background-color: $color-gray-200; + } + .info-item { width: 100%; height: 100%; diff --git a/app/(dashboard)/_ui/details-side-item/styles.module.scss b/app/(dashboard)/_ui/details-side-item/styles.module.scss index 329a9164..530e01ab 100644 --- a/app/(dashboard)/_ui/details-side-item/styles.module.scss +++ b/app/(dashboard)/_ui/details-side-item/styles.module.scss @@ -19,6 +19,11 @@ background-color: $color-white; border-radius: 5px; margin-bottom: 20px; + + &.edit { + background-color: $color-gray-200; + } + .title { font-weight: $text-bold; font-size: $text-b2; diff --git a/app/(dashboard)/_ui/introduction/index.tsx b/app/(dashboard)/_ui/introduction/index.tsx index b9ee51cd..cb12302a 100644 --- a/app/(dashboard)/_ui/introduction/index.tsx +++ b/app/(dashboard)/_ui/introduction/index.tsx @@ -1,27 +1,41 @@ 'use client' +/* eslint-disable react-hooks/exhaustive-deps */ import { useEffect, useRef, useState } from 'react' import { CloseIcon, OpenIcon } from '@/public/icons' import classNames from 'classnames/bind' +import Textarea from '@/shared/ui/textarea' + +import useEditInformationStore from '../../my/strategies/manage/[strategyId]/_store/use-edit-information-store' import styles from './styles.module.scss' const cx = classNames.bind(styles) interface Props { content: string + isEditable?: boolean } -const StrategyIntroduction = ({ content }: Props) => { +const StrategyIntroduction = ({ content, isEditable = false }: Props) => { const [shouldShowMore, setShouldShowMore] = useState(false) + const [editContent, setEditContent] = useState<HTMLTextAreaElement | string | null>(null) const [isOverflow, setIsOverflow] = useState(false) const contentRef = useRef<HTMLParagraphElement>(null) + const setDescription = useEditInformationStore((state) => state.actions.setDescription) useEffect(() => { checkOverflow() + setEditContent(content) }, [content]) + useEffect(() => { + if (editContent) { + setDescription(editContent as string) + } + }, [editContent]) + const checkOverflow = () => { if (contentRef.current) { setIsOverflow(contentRef.current.scrollHeight > contentRef.current.offsetHeight) @@ -32,7 +46,15 @@ const StrategyIntroduction = ({ content }: Props) => { <div className={cx('container')}> <p className={cx('title')}>전략 상세 소개</p> <div className={cx('content', { expand: shouldShowMore })}> - <p ref={contentRef}>{content}</p> + {isEditable ? ( + <Textarea + className={cx('textarea')} + value={editContent as string} + onChange={(e: React.ChangeEvent<HTMLTextAreaElement>) => setEditContent(e.target.value)} + /> + ) : ( + <p ref={contentRef}>{content}</p> + )} </div> {isOverflow && ( <div className={cx('button-wrapper')}> diff --git a/app/(dashboard)/_ui/subscriber-item/styles.module.scss b/app/(dashboard)/_ui/subscriber-item/styles.module.scss index 79b6f3a3..2ec9d8de 100644 --- a/app/(dashboard)/_ui/subscriber-item/styles.module.scss +++ b/app/(dashboard)/_ui/subscriber-item/styles.module.scss @@ -7,6 +7,11 @@ border-radius: 5px; background-color: $color-white; margin-bottom: 18px; + + &.edit { + background-color: $color-gray-200; + } + span { font-size: 18px; font-weight: $text-semibold; diff --git a/app/(dashboard)/my/strategies/manage/[strategyId]/_store/use-edit-information-store.tsx b/app/(dashboard)/my/strategies/manage/[strategyId]/_store/use-edit-information-store.tsx new file mode 100644 index 00000000..95d81e06 --- /dev/null +++ b/app/(dashboard)/my/strategies/manage/[strategyId]/_store/use-edit-information-store.tsx @@ -0,0 +1,37 @@ +import { create } from 'zustand' + +interface InformationModel { + strategyName: string | null + description: string | null +} + +interface StateModel { + information: InformationModel +} + +interface ActionModel { + actions: { + setStrategyName: (name: string) => void + setDescription: (description: string) => void + } +} + +const useEditInformationStore = create<StateModel & ActionModel>((set) => ({ + information: { + strategyName: null, + description: null, + }, + + actions: { + setStrategyName: (name) => + set((state) => ({ + information: { ...state.information, strategyName: name }, + })), + setDescription: (description) => + set((state) => ({ + information: { ...state.information, description }, + })), + }, +})) + +export default useEditInformationStore diff --git a/app/(dashboard)/my/strategies/manage/[strategyId]/page.tsx b/app/(dashboard)/my/strategies/manage/[strategyId]/page.tsx index 0e14f21c..c3510204 100644 --- a/app/(dashboard)/my/strategies/manage/[strategyId]/page.tsx +++ b/app/(dashboard)/my/strategies/manage/[strategyId]/page.tsx @@ -1,5 +1,7 @@ 'use client' +import { useState } from 'react' + import AnalysisContainer from '@/app/(dashboard)/_ui/analysis-container' import DetailsInformation from '@/app/(dashboard)/_ui/details-information' import DetailsSideItem, { @@ -15,6 +17,8 @@ import { Button } from '@/shared/ui/button' import BackHeader from '@/shared/ui/header/back-header' import Title from '@/shared/ui/title' +import usePostEditStrategy from '../../../_hooks/query/use-post-edit-strategy' +import useEditInformationStore from './_store/use-edit-information-store' import styles from './styles.module.scss' const cx = classNames.bind(styles) @@ -22,13 +26,15 @@ const cx = classNames.bind(styles) export type InformationType = { title: TitleType; data: string | number } | InformationModel[] const StrategyManagePage = ({ params }: { params: { strategyId: string } }) => { + const [isEditable, setIsEditable] = useState(false) const strategyNumber = parseInt(params.strategyId) - const { data: detailsInfoData } = useGetDetailsInformationData({ + const { data: detailsInfoData, refetch } = useGetDetailsInformationData({ strategyId: strategyNumber, }) const { data: subscribeData } = useGetDetailsInformationData({ strategyId: strategyNumber, }) + const { mutate } = usePostEditStrategy() const { detailsSideData, detailsInformationData } = detailsInfoData || {} const { detailsInformationData: subscribeInfo } = subscribeData || {} @@ -36,32 +42,77 @@ const StrategyManagePage = ({ params }: { params: { strategyId: string } }) => { if (!Array.isArray(data)) return data.data !== undefined }) + const handleUpdateInformation = () => { + const editedInformation = useEditInformationStore.getState().information + if (editedInformation.strategyName && editedInformation.description) { + const information = { + strategyName: editedInformation.strategyName, + description: editedInformation.description, + proposalModified: false, + } + mutate( + { strategyId: strategyNumber, information }, + { + onSuccess: () => { + setIsEditable(false) + refetch() + }, + } + ) + } + } + return ( <div className={cx('container')}> <BackHeader label={'나의 전략으로 돌아가기'} /> <div className={cx('header')}> <Title label={'나의 전략 관리'} /> - <Button size="small" variant="filled" className={cx('edit-button')}> - 정보 수정하기 - </Button> + {isEditable ? ( + <Button + onClick={handleUpdateInformation} + size="small" + variant="filled" + className={cx('edit-button')} + > + 저장하기 + </Button> + ) : ( + <Button + onClick={() => setIsEditable(!isEditable)} + size="small" + variant="filled" + className={cx('edit-button')} + > + 정보 수정하기 + </Button> + )} </div> <div className={cx('strategy-container')}> {detailsInformationData && ( <DetailsInformation information={detailsInformationData} strategyId={strategyNumber} + isEditable={isEditable} type="my" /> )} <AnalysisContainer type="my" strategyId={strategyNumber} /> <SideContainer hasButton={true}> {subscribeInfo && ( - <SubscriberItem subscribers={subscribeInfo?.subscriptionCount} isMyStrategy={true} /> + <SubscriberItem + subscribers={subscribeInfo?.subscriptionCount} + isEditable={isEditable} + isMyStrategy={true} + /> )} {hasDetailsSideData?.[0] && detailsSideData?.map((data, idx) => ( <div key={`${data}_${idx}`}> - <DetailsSideItem information={data} strategyId={strategyNumber} /> + <DetailsSideItem + information={data} + isEditable={isEditable} + strategyId={strategyNumber} + /> </div> ))} </SideContainer> From f467f6430c7cb668979344f0dc2db7b74c71ea88 Mon Sep 17 00:00:00 2001 From: kimpra <dustmqaksdltkfrlfdldi@gmail.com> Date: Thu, 12 Dec 2024 13:26:45 +0900 Subject: [PATCH 122/207] =?UTF-8?q?feat:=20=EA=B4=80=EB=A6=AC=EC=9E=90=20?= =?UTF-8?q?=EC=A0=84=EB=9E=B5=20=EA=B4=80=EB=A6=AC=20=ED=8E=98=EC=9D=B4?= =?UTF-8?q?=EC=A7=80=20=EA=B3=B5=EA=B0=9C=EC=97=AC=EB=B6=80=20=ED=86=A0?= =?UTF-8?q?=EA=B8=80=20=EA=B8=B0=EB=8A=A5=20=EC=B6=94=EA=B0=80=20(#93)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../strategies/_api/patch-strategy-role.ts | 26 ++++++++++++++ .../_api/set-admin-strategies-table-body.tsx | 7 ++-- .../_hooks/query/use-patch-strategy-role.ts | 20 +++++++++++ .../admin-strategies-post-button/index.tsx | 27 ++++++++++++++ .../styles.module.scss | 6 ++++ app/admin/strategies/_ui/public-select.tsx | 35 +++++++++++++++++++ app/admin/strategies/page.tsx | 3 +- 7 files changed, 120 insertions(+), 4 deletions(-) create mode 100644 app/admin/strategies/_api/patch-strategy-role.ts create mode 100644 app/admin/strategies/_hooks/query/use-patch-strategy-role.ts create mode 100644 app/admin/strategies/_ui/admin-strategies-post-button/index.tsx create mode 100644 app/admin/strategies/_ui/admin-strategies-post-button/styles.module.scss create mode 100644 app/admin/strategies/_ui/public-select.tsx diff --git a/app/admin/strategies/_api/patch-strategy-role.ts b/app/admin/strategies/_api/patch-strategy-role.ts new file mode 100644 index 00000000..032b2279 --- /dev/null +++ b/app/admin/strategies/_api/patch-strategy-role.ts @@ -0,0 +1,26 @@ +import axiosInstance from '@/shared/api/axios' + +import { StrategiesPatchResponseModel, StrategiesPublicStateType } from '../types' + +const patchAdminStrategyPublic = async ( + strategyId: number, + isPublic: StrategiesPublicStateType +) => { + try { + const res = await axiosInstance.patch<StrategiesPatchResponseModel>( + `/api/my-strategies/${strategyId}/visibility`, + { + isPublic: isPublic === 'PUBLIC' ? true : false, + } + ) + + if (!res.data.isSuccess) throw new Error('Error with code' + res.data.code) + + return res.data + } catch (err) { + console.error(err) + throw err + } +} + +export default patchAdminStrategyPublic diff --git a/app/admin/strategies/_api/set-admin-strategies-table-body.tsx b/app/admin/strategies/_api/set-admin-strategies-table-body.tsx index f28c4ba8..e2e543d3 100644 --- a/app/admin/strategies/_api/set-admin-strategies-table-body.tsx +++ b/app/admin/strategies/_api/set-admin-strategies-table-body.tsx @@ -1,14 +1,15 @@ import AdminStrategiesApproveTd from '../_ui/admin-strategies-approve-td' +import PublicSelect from '../_ui/public-select' import { StrategiesResponseModel } from '../types' const setAdminStrategiesTableBody = (data: StrategiesResponseModel['result']['content']) => - data.map((data, idx) => { + data.map((data) => { return [ - idx + 1, + data.strategyId, data.createAt, data.strategyName, data.nickname, - data.isPublic === 'PUBLIC' ? '공개' : '비공개', + <PublicSelect data={data} key={data.strategyId} />, <AdminStrategiesApproveTd isApproved={data.isApproved} strategyId={data.strategyId} diff --git a/app/admin/strategies/_hooks/query/use-patch-strategy-role.ts b/app/admin/strategies/_hooks/query/use-patch-strategy-role.ts new file mode 100644 index 00000000..31d09d74 --- /dev/null +++ b/app/admin/strategies/_hooks/query/use-patch-strategy-role.ts @@ -0,0 +1,20 @@ +import { useMutation, useQueryClient } from '@tanstack/react-query' + +import patchAdminStrategyPublic from '../../_api/patch-strategy-role' +import { StrategiesPublicStateType } from '../../types' + +const usePatchStrategyPublic = (strategyId: number, isPublic: StrategiesPublicStateType) => { + const queryClient = useQueryClient() + + return useMutation({ + mutationFn: () => patchAdminStrategyPublic(strategyId, isPublic), + onSuccess: () => { + queryClient.invalidateQueries({ queryKey: ['adminStrategies'] }) + }, + onError: () => { + alert('실패했음') + }, + }) +} + +export default usePatchStrategyPublic diff --git a/app/admin/strategies/_ui/admin-strategies-post-button/index.tsx b/app/admin/strategies/_ui/admin-strategies-post-button/index.tsx new file mode 100644 index 00000000..1af1247b --- /dev/null +++ b/app/admin/strategies/_ui/admin-strategies-post-button/index.tsx @@ -0,0 +1,27 @@ +'use client' + +import { useRouter } from 'next/navigation' + +import classNames from 'classnames/bind' + +import { Button } from '@/shared/ui/button' + +import styles from './styles.module.scss' + +const cx = classNames.bind(styles) + +const AdminStrategyPostButton = () => { + const router = useRouter() + + const onClick = () => { + router.push(`/my/strategies/add`) + } + + return ( + <Button onClick={onClick} variant="filled" size="small" className={cx('post-button')}> + 전략 등록하기 + </Button> + ) +} + +export default AdminStrategyPostButton diff --git a/app/admin/strategies/_ui/admin-strategies-post-button/styles.module.scss b/app/admin/strategies/_ui/admin-strategies-post-button/styles.module.scss new file mode 100644 index 00000000..f8fba06a --- /dev/null +++ b/app/admin/strategies/_ui/admin-strategies-post-button/styles.module.scss @@ -0,0 +1,6 @@ +.post-button { + position: absolute; + top: -55px; + right: 0; + padding: 12px 24px; +} diff --git a/app/admin/strategies/_ui/public-select.tsx b/app/admin/strategies/_ui/public-select.tsx new file mode 100644 index 00000000..257bbd4d --- /dev/null +++ b/app/admin/strategies/_ui/public-select.tsx @@ -0,0 +1,35 @@ +'use client' + +import { useState } from 'react' + +import Select from '@/shared/ui/select' + +import usePatchUserRole from '../_hooks/query/use-patch-strategy-role' +import { strategyPublicOptions } from '../constants' +// import { InvestorOptions, TraderOptions } from '../constants' +import { StrategiesPublicStateType, StrategiesResponseModel } from '../types' + +interface Props { + data: StrategiesResponseModel['result']['content'][number] +} + +const PublicSelect = ({ data }: Props) => { + const { strategyId, isPublic } = data + const [value, setValue] = useState(isPublic) + + const { mutate } = usePatchUserRole(strategyId, value) + + return ( + <Select + value={value} + onChange={(v) => { + setValue(v as StrategiesPublicStateType) + mutate() + }} + options={strategyPublicOptions} + key={strategyId} + /> + ) +} + +export default PublicSelect diff --git a/app/admin/strategies/page.tsx b/app/admin/strategies/page.tsx index f7fe9963..ad9e4e85 100644 --- a/app/admin/strategies/page.tsx +++ b/app/admin/strategies/page.tsx @@ -12,6 +12,7 @@ import AdminContentsHeader from '../_ui/admin-header' import setAdminStrategiesTableBody from './_api/set-admin-strategies-table-body' import useStrategiesData from './_hooks/query/use-strategies-data' import useAdminStrategiesPage from './_hooks/use-admin-strategies-page' +import AdminStrategyPostButton from './_ui/admin-strategies-post-button' import styles from './page.module.scss' const cx = classNames.bind(styles) @@ -31,7 +32,7 @@ const AdminStrategyPage = () => { <> <Title label="전략 관리" className={cx('title')} /> <section className={cx('container')}> - {/* <AdminPostButton label="전략 등록하기" pathname="strategies" /> */} + <AdminStrategyPostButton /> <Tabs tabs={tabs} activeTab={activeTab} onTabChange={onTabChange} /> <AdminContentsHeader Left={ From deaf8408ece6743b3c2f8077128b827a5210568e Mon Sep 17 00:00:00 2001 From: kimpra <dustmqaksdltkfrlfdldi@gmail.com> Date: Thu, 12 Dec 2024 14:17:15 +0900 Subject: [PATCH 123/207] =?UTF-8?q?feat:=20=EA=B4=80=EB=A6=AC=EC=9E=90=20?= =?UTF-8?q?=EC=A0=84=EB=9E=B5=20=EA=B4=80=EB=A6=AC=20=ED=8E=98=EC=9D=B4?= =?UTF-8?q?=EC=A7=80=20=EC=88=98=EC=A0=95=20=EB=B2=84=ED=8A=BC=20=EC=B6=94?= =?UTF-8?q?=EA=B0=80=20(#14)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../_api/set-admin-strategies-table-body.tsx | 8 +++++ .../_ui/button/strategy-edit-button.tsx | 32 +++++++++++++++++++ .../strategies/_ui/button/styles.module.scss | 6 ++++ app/admin/strategies/page.tsx | 4 +-- 4 files changed, 48 insertions(+), 2 deletions(-) create mode 100644 app/admin/strategies/_ui/button/strategy-edit-button.tsx create mode 100644 app/admin/strategies/_ui/button/styles.module.scss diff --git a/app/admin/strategies/_api/set-admin-strategies-table-body.tsx b/app/admin/strategies/_api/set-admin-strategies-table-body.tsx index f28c4ba8..501e7b6b 100644 --- a/app/admin/strategies/_api/set-admin-strategies-table-body.tsx +++ b/app/admin/strategies/_api/set-admin-strategies-table-body.tsx @@ -1,4 +1,8 @@ +import { Button } from '@/shared/ui/button' + import AdminStrategiesApproveTd from '../_ui/admin-strategies-approve-td' +import StrategyDeleteButton from '../_ui/button/strategy-delete-button copy' +import StrategyEditButton from '../_ui/button/strategy-edit-button' import { StrategiesResponseModel } from '../types' const setAdminStrategiesTableBody = (data: StrategiesResponseModel['result']['content']) => @@ -14,6 +18,10 @@ const setAdminStrategiesTableBody = (data: StrategiesResponseModel['result']['co strategyId={data.strategyId} key={data.strategyId} />, + <Button.ButtonGroup key={data.strategyId}> + <StrategyEditButton strategyId={data.strategyId} /> + <StrategyDeleteButton strategyId={data.strategyId} /> + </Button.ButtonGroup>, ] }) diff --git a/app/admin/strategies/_ui/button/strategy-edit-button.tsx b/app/admin/strategies/_ui/button/strategy-edit-button.tsx new file mode 100644 index 00000000..d77441a5 --- /dev/null +++ b/app/admin/strategies/_ui/button/strategy-edit-button.tsx @@ -0,0 +1,32 @@ +'use client' + +import { useRouter } from 'next/navigation' + +import classNames from 'classnames/bind' + +import { Button } from '@/shared/ui/button' + +import styles from './styles.module.scss' + +const cx = classNames.bind(styles) + +interface Props { + strategyId: number +} + +const StrategyEditButton = ({ strategyId }: Props) => { + const router = useRouter() + const onClick = () => { + router.push(`/my/strategies/manage/${strategyId}`) + } + + return ( + <> + <Button onClick={onClick} size="small" className={cx('button')}> + 수정 + </Button> + </> + ) +} + +export default StrategyEditButton diff --git a/app/admin/strategies/_ui/button/styles.module.scss b/app/admin/strategies/_ui/button/styles.module.scss new file mode 100644 index 00000000..7c9b04e0 --- /dev/null +++ b/app/admin/strategies/_ui/button/styles.module.scss @@ -0,0 +1,6 @@ +.button { + width: 74px; + height: 30px; + padding: 16px 7px; + border: 1px solid $color-gray-300; +} diff --git a/app/admin/strategies/page.tsx b/app/admin/strategies/page.tsx index f7fe9963..8285fd27 100644 --- a/app/admin/strategies/page.tsx +++ b/app/admin/strategies/page.tsx @@ -52,9 +52,9 @@ const AdminStrategyPage = () => { className={cx('header')} /> <VerticalTable - tableHead={['No.', '날짜', '전략명', '닉네임', '공개여부', '승인여부']} + tableHead={['No.', '날짜', '전략명', '닉네임', '공개여부', '승인여부', '']} tableBody={setAdminStrategiesTableBody(data.content)} - countPerPage={10} + countPerPage={data.size} currentPage={1} /> <Pagination currentPage={data.page} maxPage={data.totalPages} onPageChange={() => {}} /> From a4c2839ce80f40bba274ee444f4a2633ef5faad2 Mon Sep 17 00:00:00 2001 From: kimpra <dustmqaksdltkfrlfdldi@gmail.com> Date: Thu, 12 Dec 2024 14:39:23 +0900 Subject: [PATCH 124/207] =?UTF-8?q?feat:=20=EA=B4=80=EB=A6=AC=EC=9E=90=20?= =?UTF-8?q?=EC=A0=84=EB=9E=B5=20=EA=B4=80=EB=A6=AC=20=ED=8E=98=EC=9D=B4?= =?UTF-8?q?=EC=A7=80=20=EC=82=AD=EC=A0=9C=20=EB=B2=84=ED=8A=BC=20=EC=B6=94?= =?UTF-8?q?=EA=B0=80=20(#14)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- app/(dashboard)/my/_api/delete-my-strategy.ts | 6 +-- .../_hooks/query/use-admin-delete-strategy.ts | 17 ++++++++ .../button/strategy-delete-button copy.tsx | 43 +++++++++++++++++++ 3 files changed, 62 insertions(+), 4 deletions(-) create mode 100644 app/admin/strategies/_hooks/query/use-admin-delete-strategy.ts create mode 100644 app/admin/strategies/_ui/button/strategy-delete-button copy.tsx diff --git a/app/(dashboard)/my/_api/delete-my-strategy.ts b/app/(dashboard)/my/_api/delete-my-strategy.ts index eb91c643..4bf3d10e 100644 --- a/app/(dashboard)/my/_api/delete-my-strategy.ts +++ b/app/(dashboard)/my/_api/delete-my-strategy.ts @@ -1,10 +1,8 @@ import axiosInstance from '@/shared/api/axios' +import { APIResponseBaseModel } from '@/shared/types/response' -export interface DeleteStrategyResponseModel { - isSuccess: boolean - message: string +export interface DeleteStrategyResponseModel extends APIResponseBaseModel<boolean> { result: Record<string, never> - code: number } export const deleteMyStrategy = async ( diff --git a/app/admin/strategies/_hooks/query/use-admin-delete-strategy.ts b/app/admin/strategies/_hooks/query/use-admin-delete-strategy.ts new file mode 100644 index 00000000..93de62d8 --- /dev/null +++ b/app/admin/strategies/_hooks/query/use-admin-delete-strategy.ts @@ -0,0 +1,17 @@ +import { deleteMyStrategy } from '@/app/(dashboard)/my/_api/delete-my-strategy' +import { useMutation, useQueryClient } from '@tanstack/react-query' + +const useDeleteAdminStrategy = (strategyId: number) => { + const queryClient = useQueryClient() + + return useMutation({ + mutationFn: () => deleteMyStrategy(strategyId), + onSuccess: () => { + queryClient.invalidateQueries({ + queryKey: ['adminStrategies'], + }) + }, + }) +} + +export default useDeleteAdminStrategy diff --git a/app/admin/strategies/_ui/button/strategy-delete-button copy.tsx b/app/admin/strategies/_ui/button/strategy-delete-button copy.tsx new file mode 100644 index 00000000..3bdb7661 --- /dev/null +++ b/app/admin/strategies/_ui/button/strategy-delete-button copy.tsx @@ -0,0 +1,43 @@ +'use client' + +import classNames from 'classnames/bind' + +import useModal from '@/shared/hooks/custom/use-modal' +import { Button } from '@/shared/ui/button' +import AlertModal from '@/shared/ui/modal/alert-modal' + +import useDeleteAdminStrategy from '../../_hooks/query/use-admin-delete-strategy' +import styles from './styles.module.scss' + +const cx = classNames.bind(styles) + +interface Props { + strategyId: number +} + +const StrategyDeleteButton = ({ strategyId }: Props) => { + const { mutate, isPending } = useDeleteAdminStrategy(strategyId) + const { closeModal, isModalOpen, openModal } = useModal() + + return ( + <> + <Button + size="small" + onClick={openModal} + disabled={isPending} + variant="filled" + className={cx('button')} + > + 삭제 + </Button> + <AlertModal + message={`해당 전략을\n삭제하시겠습니까?`} + isModalOpen={isModalOpen} + onCancel={closeModal} + onConfirm={mutate} + /> + </> + ) +} + +export default StrategyDeleteButton From b0c8455d94e2cdcd672ae475a2c32173cfaf7568 Mon Sep 17 00:00:00 2001 From: kimpra <dustmqaksdltkfrlfdldi@gmail.com> Date: Thu, 12 Dec 2024 14:45:28 +0900 Subject: [PATCH 125/207] =?UTF-8?q?fix:=20=EA=B4=80=EB=A6=AC=EC=9E=90=20?= =?UTF-8?q?=EC=A0=84=EB=9E=B5=20=EA=B4=80=EB=A6=AC=20=ED=8E=98=EC=9D=B4?= =?UTF-8?q?=EC=A7=80=20=ED=8E=98=EC=9D=B4=EC=A7=80=EB=84=A4=EC=9D=B4?= =?UTF-8?q?=EC=85=98=20=EC=B2=98=EB=A6=AC=20(#14)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../_hooks/use-admin-strategies-page.ts | 4 ++++ app/admin/strategies/page.tsx | 18 +++++++++++++++--- 2 files changed, 19 insertions(+), 3 deletions(-) diff --git a/app/admin/strategies/_hooks/use-admin-strategies-page.ts b/app/admin/strategies/_hooks/use-admin-strategies-page.ts index 14aa42e5..642255df 100644 --- a/app/admin/strategies/_hooks/use-admin-strategies-page.ts +++ b/app/admin/strategies/_hooks/use-admin-strategies-page.ts @@ -6,6 +6,8 @@ import { AdminStrategiesTapType } from '../types' const useAdminQuestionsPage = () => { const [activeTab, setActiveTab] = useState<AdminStrategiesTapType>('ALL') const [keyword, setKeyword] = useState('') + const [currentPage, setCurrentPage] = useState(1) + const initialSearchParams = { searchWord: '', } @@ -33,6 +35,8 @@ const useAdminQuestionsPage = () => { setActiveTab, keyword, setKeyword, + currentPage, + setCurrentPage, searchParams, searchWithKeyword, onTabChange, diff --git a/app/admin/strategies/page.tsx b/app/admin/strategies/page.tsx index 8285fd27..61b1d5f7 100644 --- a/app/admin/strategies/page.tsx +++ b/app/admin/strategies/page.tsx @@ -17,8 +17,16 @@ import styles from './page.module.scss' const cx = classNames.bind(styles) const AdminStrategyPage = () => { - const { tabs, activeTab, keyword, setKeyword, searchParams, searchWithKeyword, onTabChange } = - useAdminStrategiesPage() + const { + tabs, + activeTab, + keyword, + setKeyword, + searchParams, + searchWithKeyword, + onTabChange, + setCurrentPage, + } = useAdminStrategiesPage() const { data, isLoading } = useStrategiesData({ ...searchParams, @@ -57,7 +65,11 @@ const AdminStrategyPage = () => { countPerPage={data.size} currentPage={1} /> - <Pagination currentPage={data.page} maxPage={data.totalPages} onPageChange={() => {}} /> + <Pagination + currentPage={data.page} + maxPage={data.totalPages} + onPageChange={setCurrentPage} + /> </section> </> ) From c2345be2c0bb77d3e576295734fb3a6242bba79e Mon Sep 17 00:00:00 2001 From: kimpra <dustmqaksdltkfrlfdldi@gmail.com> Date: Thu, 12 Dec 2024 15:07:45 +0900 Subject: [PATCH 126/207] =?UTF-8?q?fix:=20=EA=B4=80=EB=A6=AC=EC=9E=90=20?= =?UTF-8?q?=EA=B3=B5=EC=A7=80=20=ED=8E=98=EC=9D=B4=EC=A7=80=20=EA=B3=B5?= =?UTF-8?q?=EC=A7=80=EC=82=AC=ED=95=AD=20=EC=B6=94=EA=B0=80=20=EA=B8=B0?= =?UTF-8?q?=EB=8A=A5=20=EA=B0=9C=EC=84=A0=20(#14)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../post/_hooks/query/use-post-notice.ts | 44 ++++++++++++++----- app/admin/notices/post/page.tsx | 4 +- 2 files changed, 36 insertions(+), 12 deletions(-) diff --git a/app/admin/notices/post/_hooks/query/use-post-notice.ts b/app/admin/notices/post/_hooks/query/use-post-notice.ts index 2279e8cd..4dfba96c 100644 --- a/app/admin/notices/post/_hooks/query/use-post-notice.ts +++ b/app/admin/notices/post/_hooks/query/use-post-notice.ts @@ -1,10 +1,15 @@ -import { useMutation } from '@tanstack/react-query' +import { useRouter } from 'next/navigation' + +import { useMutation, useQueryClient } from '@tanstack/react-query' import axiosInstance from '@/shared/api/axios' import { NoticeFormModel, PostNoticeResopnseModel } from '../../types' const usePostNotice = () => { + const router = useRouter() + const queryClient = useQueryClient() + return useMutation({ mutationFn: async (formData: NoticeFormModel) => { // Presigned URL 요청 @@ -24,15 +29,34 @@ const usePostNotice = () => { const presignedUrls = uploadResponse.data.result // Presigned URL로 파일 업로드 - await Promise.all( - files.map((file, idx) => { - if (presignedUrls[idx]) { - return axiosInstance.put(presignedUrls[idx], file) - } else { - throw new Error(`Presigned URL이 인덱스 ${idx}에 대해 없습니다.`) - } - }) - ) + try { + await Promise.all( + files.map((file, idx) => { + if (presignedUrls[idx]) { + return fetch(presignedUrls[idx], { + method: 'PUT', + body: file, + headers: { + 'Content-Type': file.type, + }, + }) + } else { + throw new Error(`Presigned URL이 인덱스 ${idx}에 대해 없습니다.`) + } + }) + ) + } catch (err) { + if (err instanceof Error) { + throw new Error(`File upload failed: ${err.message}`) + } + throw err + } + }, + onSuccess: () => { + queryClient.invalidateQueries({ + queryKey: ['notices'], + }) + router.replace('/admin/notices') }, }) } diff --git a/app/admin/notices/post/page.tsx b/app/admin/notices/post/page.tsx index 9bcc70e2..e51e90f8 100644 --- a/app/admin/notices/post/page.tsx +++ b/app/admin/notices/post/page.tsx @@ -18,7 +18,7 @@ const cx = classNames.bind(styles) const AdminNoticePostPage = () => { const { formData, onInputChange } = useNoticeForm() - const { mutate: postNotice } = usePostNotice() + const { mutate: postNotice, isPending } = usePostNotice() return ( <> @@ -67,7 +67,7 @@ const AdminNoticePostPage = () => { } /> </div> - <Button size="small" type="submit" variant="filled"> + <Button disabled={isPending} size="small" type="submit" variant="filled"> 공지 등록하기 </Button> </form> From e51cccce1c38a9fcfaffce583b7fe806d06a5ce8 Mon Sep 17 00:00:00 2001 From: deun <devdeun@gmail.com> Date: Thu, 12 Dec 2024 15:23:40 +0900 Subject: [PATCH 127/207] =?UTF-8?q?fix:=20backheader=EC=97=90=20href=20?= =?UTF-8?q?=EC=B6=94=EA=B0=80=20(#116)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- app/(dashboard)/traders/[traderId]/page.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/app/(dashboard)/traders/[traderId]/page.tsx b/app/(dashboard)/traders/[traderId]/page.tsx index 9c974488..9b39f73d 100644 --- a/app/(dashboard)/traders/[traderId]/page.tsx +++ b/app/(dashboard)/traders/[traderId]/page.tsx @@ -46,7 +46,7 @@ const TraderDetailPage = () => { return ( <> <div className={cx('page-container')}> - <BackHeader label={'목록으로 돌아가기'} /> + <BackHeader label={'목록으로 돌아가기'} href={PATH.TRADERS} /> <div className={cx('title')}> <Title label={'트레이더 상세보기'} /> </div> From 3996b0b46e1de1725d9cdcd430e683a19d1be2f5 Mon Sep 17 00:00:00 2001 From: kimpra <dustmqaksdltkfrlfdldi@gmail.com> Date: Thu, 12 Dec 2024 15:43:07 +0900 Subject: [PATCH 128/207] =?UTF-8?q?fix:=20=EA=B4=80=EB=A6=AC=EC=9E=90=20?= =?UTF-8?q?=EA=B3=B5=EC=A7=80=20=ED=8E=98=EC=9D=B4=EC=A7=80=20=EA=B8=B0?= =?UTF-8?q?=EB=8A=A5=20=EA=B0=9C=EC=84=A0=20=20(#14)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../notices/_hook/use-admin-notice-page.ts | 12 +++++++ .../notices/_ui/admin-notice-table/index.tsx | 26 +++++++-------- .../notice-delete-button.tsx} | 4 +-- .../notices/_ui/button/notice-edit-button.tsx | 33 +++++++++++++++++++ .../styles.module.scss | 0 5 files changed, 59 insertions(+), 16 deletions(-) create mode 100644 app/admin/notices/_hook/use-admin-notice-page.ts rename app/admin/notices/_ui/{notice-delete-button/index.tsx => button/notice-delete-button.tsx} (94%) create mode 100644 app/admin/notices/_ui/button/notice-edit-button.tsx rename app/admin/notices/_ui/{notice-delete-button => button}/styles.module.scss (100%) diff --git a/app/admin/notices/_hook/use-admin-notice-page.ts b/app/admin/notices/_hook/use-admin-notice-page.ts new file mode 100644 index 00000000..adda38d7 --- /dev/null +++ b/app/admin/notices/_hook/use-admin-notice-page.ts @@ -0,0 +1,12 @@ +import { useState } from 'react' + +const useAdminNoticePage = () => { + const [currentPage, setCurrentPage] = useState(1) + + return { + currentPage, + setCurrentPage, + } +} + +export default useAdminNoticePage diff --git a/app/admin/notices/_ui/admin-notice-table/index.tsx b/app/admin/notices/_ui/admin-notice-table/index.tsx index c731ccb7..338b7ee9 100644 --- a/app/admin/notices/_ui/admin-notice-table/index.tsx +++ b/app/admin/notices/_ui/admin-notice-table/index.tsx @@ -8,15 +8,18 @@ import Pagination from '@/shared/ui/pagination' import VerticalTable from '@/shared/ui/table/vertical' import useAdminNotices from '../../_hook/query/get-admin-notices' -import NoticeDeleteButton from '../notice-delete-button' +import useAdminNoticePage from '../../_hook/use-admin-notice-page' +import NoticeDeleteButton from '../button/notice-delete-button' +import NoticeEditButton from '../button/notice-edit-button' import styles from './styles.module.scss' const cx = classNames.bind(styles) const AdminNoticeTable = () => { + const { currentPage, setCurrentPage } = useAdminNoticePage() const { data, isLoading } = useAdminNotices() - if (isLoading) { + if (isLoading || !data) { return <VerticalTable.Skeleton tableHead={['No.', '제목', '내용', '작성일', '']} /> } @@ -25,16 +28,9 @@ const AdminNoticeTable = () => { data.noticeId, data.title, data.content.slice(0, 15), - data.createdAt.slice(0, 10), - <Button.ButtonGroup key={data.content}> - <Button - onClick={() => {}} - size="small" - className={cx('button')} - style={{ padding: '16px 7px' }} - > - 수정 - </Button> + data.createdAt, + <Button.ButtonGroup key={data.noticeId}> + <NoticeEditButton noticeId={data.noticeId} /> <NoticeDeleteButton noticeId={data.noticeId} /> </Button.ButtonGroup>, ]) || [] @@ -55,7 +51,11 @@ const AdminNoticeTable = () => { countPerPage={10} currentPage={1} /> - <Pagination currentPage={1} maxPage={1} onPageChange={() => {}} /> + <Pagination + currentPage={currentPage} + maxPage={data?.totalPages} + onPageChange={setCurrentPage} + /> </> ) } diff --git a/app/admin/notices/_ui/notice-delete-button/index.tsx b/app/admin/notices/_ui/button/notice-delete-button.tsx similarity index 94% rename from app/admin/notices/_ui/notice-delete-button/index.tsx rename to app/admin/notices/_ui/button/notice-delete-button.tsx index 1fd6ed0c..d6fbdb8c 100644 --- a/app/admin/notices/_ui/notice-delete-button/index.tsx +++ b/app/admin/notices/_ui/button/notice-delete-button.tsx @@ -34,9 +34,7 @@ const NoticeDeleteButton = ({ noticeId }: Props) => { message={`해당 공지를\n삭제하시겠습니까?`} isModalOpen={isModalOpen} onCancel={closeModal} - onConfirm={() => { - mutate() - }} + onConfirm={mutate} /> </> ) diff --git a/app/admin/notices/_ui/button/notice-edit-button.tsx b/app/admin/notices/_ui/button/notice-edit-button.tsx new file mode 100644 index 00000000..dd1643b0 --- /dev/null +++ b/app/admin/notices/_ui/button/notice-edit-button.tsx @@ -0,0 +1,33 @@ +'use client' + +import { useRouter } from 'next/navigation' + +import classNames from 'classnames/bind' + +import { Button } from '@/shared/ui/button' + +import styles from './styles.module.scss' + +const cx = classNames.bind(styles) + +interface Props { + noticeId: number +} + +const NoticeEditButton = ({ noticeId }: Props) => { + const router = useRouter() + + const goToEditPage = () => { + router.push(`/admin/notices/${noticeId}/edit`) + } + + return ( + <> + <Button onClick={goToEditPage} size="small" className={cx('button')}> + 수정 + </Button> + </> + ) +} + +export default NoticeEditButton diff --git a/app/admin/notices/_ui/notice-delete-button/styles.module.scss b/app/admin/notices/_ui/button/styles.module.scss similarity index 100% rename from app/admin/notices/_ui/notice-delete-button/styles.module.scss rename to app/admin/notices/_ui/button/styles.module.scss From db4a0f1036ccc95c6aacd8ec35e6e907dc7d5e20 Mon Sep 17 00:00:00 2001 From: deun <devdeun@gmail.com> Date: Thu, 12 Dec 2024 16:09:40 +0900 Subject: [PATCH 129/207] =?UTF-8?q?feat:=20=EB=AC=B8=EC=9D=98=20=EB=93=B1?= =?UTF-8?q?=EB=A1=9D=EC=8B=9C=20questionList=20=EC=BF=BC=EB=A6=AC=ED=82=A4?= =?UTF-8?q?=20=EC=B6=94=EA=B0=80=20(#121)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../[strategyId]/_hooks/query/use-post-question.ts | 7 ++++++- 1 file changed, 6 insertions(+), 1 deletion(-) diff --git a/app/(dashboard)/strategies/[strategyId]/_hooks/query/use-post-question.ts b/app/(dashboard)/strategies/[strategyId]/_hooks/query/use-post-question.ts index a340a14f..aa4f4d99 100644 --- a/app/(dashboard)/strategies/[strategyId]/_hooks/query/use-post-question.ts +++ b/app/(dashboard)/strategies/[strategyId]/_hooks/query/use-post-question.ts @@ -1,4 +1,4 @@ -import { useMutation } from '@tanstack/react-query' +import { useMutation, useQueryClient } from '@tanstack/react-query' import postQuestions from '../../_api/post-question' @@ -9,9 +9,14 @@ interface Props { } const usePostQuestion = () => { + const queryClient = useQueryClient() + return useMutation({ mutationFn: ({ strategyId, title, content }: Props) => postQuestions(strategyId, title, content), + onSuccess: () => { + queryClient.invalidateQueries({ queryKey: ['questionList'] }) + }, }) } From 6cafa6d2a6f872341d4c321ff301a454aec3001a Mon Sep 17 00:00:00 2001 From: kimpra <dustmqaksdltkfrlfdldi@gmail.com> Date: Thu, 12 Dec 2024 16:11:50 +0900 Subject: [PATCH 130/207] =?UTF-8?q?fix:=20=EA=B4=80=EB=A6=AC=EC=9E=90=20?= =?UTF-8?q?=EC=A0=84=EB=9E=B5=EA=B4=80=EB=A6=AC=20=ED=85=8C=EC=9D=B4?= =?UTF-8?q?=EB=B8=94=20=EB=82=A0=EC=A7=9C=20=ED=8F=AC=EB=A9=94=ED=8C=85=20?= =?UTF-8?q?=EB=B0=8F=20format=20util=20=ED=99=95=EC=9E=A5=20(#117)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../_api/set-admin-strategies-table-body.tsx | 3 +- shared/utils/format.ts | 34 +++++++++++++++---- 2 files changed, 29 insertions(+), 8 deletions(-) diff --git a/app/admin/strategies/_api/set-admin-strategies-table-body.tsx b/app/admin/strategies/_api/set-admin-strategies-table-body.tsx index 76da81d9..95f49eae 100644 --- a/app/admin/strategies/_api/set-admin-strategies-table-body.tsx +++ b/app/admin/strategies/_api/set-admin-strategies-table-body.tsx @@ -1,4 +1,5 @@ import { Button } from '@/shared/ui/button' +import { formatDate } from '@/shared/utils/format' import AdminStrategiesApproveTd from '../_ui/admin-strategies-approve-td' import StrategyDeleteButton from '../_ui/button/strategy-delete-button copy' @@ -10,7 +11,7 @@ const setAdminStrategiesTableBody = (data: StrategiesResponseModel['result']['co data.map((data) => { return [ data.strategyId, - data.createAt, + formatDate(data.createAt), data.strategyName, data.nickname, <PublicSelect data={data} key={data.strategyId} />, diff --git a/shared/utils/format.ts b/shared/utils/format.ts index 164869e9..3148cf8b 100644 --- a/shared/utils/format.ts +++ b/shared/utils/format.ts @@ -1,16 +1,36 @@ -export const formatDateTime = (dateString: string) => { - const date = new Date(dateString) - - if (isNaN(date.getTime())) { - return dateString - } - +const splitDate = (date: Date) => { const year = date.getFullYear() const month = String(date.getMonth() + 1).padStart(2, '0') const day = String(date.getDate()).padStart(2, '0') const hours = String(date.getHours()).padStart(2, '0') const minutes = String(date.getMinutes()).padStart(2, '0') + return { + year, + month, + day, + hours, + minutes, + } +} + +export const formatDate = (dateString: string) => { + const date = new Date(dateString) + + if (isNaN(date.getTime())) return dateString + + const { year, month, day } = splitDate(date) + + return `${year}.${month}.${day}` +} + +export const formatDateTime = (dateString: string) => { + const date = new Date(dateString) + + if (isNaN(date.getTime())) return dateString + + const { year, month, day, hours, minutes } = splitDate(date) + return `${year}.${month}.${day} ${hours}:${minutes}` } From fb6c64b7a85e735439f4f69865b2d1d364fe39e5 Mon Sep 17 00:00:00 2001 From: kimpra <dustmqaksdltkfrlfdldi@gmail.com> Date: Thu, 12 Dec 2024 16:26:25 +0900 Subject: [PATCH 131/207] =?UTF-8?q?fix:=20=EA=B4=80=EB=A6=AC=EC=9E=90=20?= =?UTF-8?q?=EA=B3=B5=EC=A7=80=20=EB=AA=A9=EB=A1=9D=EC=97=90=EC=84=9C=20?= =?UTF-8?q?=EC=A0=9C=EB=AA=A9=20=EB=88=84=EB=A5=B4=EB=A9=B4=20=EC=83=81?= =?UTF-8?q?=EC=84=B8=EC=B2=B4=EC=9D=B4=EC=A7=80=EB=A1=9C=20=EA=B0=80?= =?UTF-8?q?=EB=8F=84=EB=A1=9D=20=EC=88=98=EC=A0=95=20(#14)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- app/admin/notices/_ui/admin-notice-table/index.tsx | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/app/admin/notices/_ui/admin-notice-table/index.tsx b/app/admin/notices/_ui/admin-notice-table/index.tsx index 338b7ee9..57d18508 100644 --- a/app/admin/notices/_ui/admin-notice-table/index.tsx +++ b/app/admin/notices/_ui/admin-notice-table/index.tsx @@ -1,5 +1,7 @@ 'use client' +import Link from 'next/link' + import AdminContentsHeader from '@/app/admin/_ui/admin-header' import classNames from 'classnames/bind' @@ -26,7 +28,9 @@ const AdminNoticeTable = () => { const tableBody = data?.content.map((data) => [ data.noticeId, - data.title, + <Link href={`/notices/${data.noticeId}/detail`} key={data.noticeId}> + {data.title} + </Link>, data.content.slice(0, 15), data.createdAt, <Button.ButtonGroup key={data.noticeId}> From 56dc1aa46ef442b343e472eac4e0a0f2b47e3263 Mon Sep 17 00:00:00 2001 From: James <dawn147@snu.ac.kr> Date: Thu, 12 Dec 2024 18:14:48 +0900 Subject: [PATCH 132/207] =?UTF-8?q?fix:=20=EB=A1=9C=EA=B7=B8=EC=9D=B8=20?= =?UTF-8?q?=ED=8E=98=EC=9D=B4=EC=A7=80=EC=97=90=EC=84=9C=20=EB=A1=9C?= =?UTF-8?q?=EA=B7=B8=EC=9D=B8=20=EC=B2=B4=ED=81=AC=20=EB=AA=A8=EB=8B=AC?= =?UTF-8?q?=EC=9D=B4=20=EB=9C=A8=EC=A7=80=20=EC=95=8A=EB=8F=84=EB=A1=9D=20?= =?UTF-8?q?=EC=88=98=EC=A0=95=20(#125)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- shared/providers/auth-provider.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/shared/providers/auth-provider.tsx b/shared/providers/auth-provider.tsx index 10098e32..e67331cc 100644 --- a/shared/providers/auth-provider.tsx +++ b/shared/providers/auth-provider.tsx @@ -45,7 +45,7 @@ export const AuthProvider = ({ children }: AuthProviderProps) => { router.replace(PATH.STRATEGIES) return } - }, [pathname, router, openModal]) + }, [pathname, router]) const handleLoginConfirm = () => { closeModal() From d7cce40f27cd93780b534ae689a9aba439225462 Mon Sep 17 00:00:00 2001 From: deun <devdeun@gmail.com> Date: Thu, 12 Dec 2024 18:40:51 +0900 Subject: [PATCH 133/207] =?UTF-8?q?fix:=20=EB=AC=B8=EC=9D=98=20=EB=82=B4?= =?UTF-8?q?=EC=97=AD=20=EC=88=98=EC=A0=95=EB=90=9C=20api=EC=97=90=20?= =?UTF-8?q?=EB=A7=9E=EC=B6=B0=20=EC=88=98=EC=A0=95=20(#126)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../[questionId]/_ui/question-container/index.tsx | 2 ++ .../questions/_ui/questions-tab-content/index.tsx | 9 ++++++--- shared/types/questions.ts | 15 ++++++++++++--- 3 files changed, 20 insertions(+), 6 deletions(-) diff --git a/app/(dashboard)/my/questions/[questionId]/_ui/question-container/index.tsx b/app/(dashboard)/my/questions/[questionId]/_ui/question-container/index.tsx index fe487769..2e8078ec 100644 --- a/app/(dashboard)/my/questions/[questionId]/_ui/question-container/index.tsx +++ b/app/(dashboard)/my/questions/[questionId]/_ui/question-container/index.tsx @@ -129,6 +129,7 @@ const QuestionContainer = () => { title={questionDetails.title} contents={questionDetails.content} nickname={questionDetails.nickname} + profileImage={questionDetails.profileImageUrl} createdAt={questionDetails.createdAt} status={questionDetails.state === 'WAITING' ? '답변 대기' : '답변 완료'} onDelete={handleDeleteQuestionClick} @@ -139,6 +140,7 @@ const QuestionContainer = () => { isAuthor={isTrader} contents={questionDetails.answer.content} nickname={questionDetails.answer.nickname} + profileImage={questionDetails.answer.profileImageUrl} createdAt={questionDetails.answer.createdAt} onDelete={handleDeleteAnswerClick} /> diff --git a/app/(dashboard)/my/questions/_ui/questions-tab-content/index.tsx b/app/(dashboard)/my/questions/_ui/questions-tab-content/index.tsx index 0f4f8cfe..f888d5d5 100644 --- a/app/(dashboard)/my/questions/_ui/questions-tab-content/index.tsx +++ b/app/(dashboard)/my/questions/_ui/questions-tab-content/index.tsx @@ -26,10 +26,12 @@ const QuestionsTabContent = ({ options }: Props) => { const user = useAuthStore((state) => state.user) + const userType = user?.role.includes('TRADER') ? 'TRADER' : 'INVESTOR' + const { data } = useGetMyQuestionList({ page, size: COUNT_PER_PAGE, - userType: user?.role.includes('TRADER') ? 'TRADER' : 'INVESTOR', + userType, options, }) @@ -46,11 +48,12 @@ const QuestionsTabContent = ({ options }: Props) => { <li key={question.questionId}> <QuestionCard questionId={question.questionId} - strategyName={question.strategyName} + strategyName={question.strategy.name} title={question.title} questionState={question.stateCondition} contents={question.questionContent} - nickname={question.nickname} + nickname={question.investor.userName} + profileImage={question.investor.profileImageUrl} createdAt={question.createdAt} /> </li> diff --git a/shared/types/questions.ts b/shared/types/questions.ts index 36b49b6c..d1e077af 100644 --- a/shared/types/questions.ts +++ b/shared/types/questions.ts @@ -13,15 +13,24 @@ export type QuestionSearchConditionType = | 'INVESTOR_NAME' | 'STRATEGY_NAME' +export interface QuestionUserModel { + id: number + userName: string + profileImageUrl: string +} + export interface QuestionModel { questionId: number title: string questionContent: string - strategyName: string - nickname: string - profileImageUrl: string + strategy: { + id: number + name: string + } stateCondition: QuestionStateConditionType createdAt: string + investor: QuestionUserModel + trader?: QuestionUserModel } export interface AnswerModel { From 85ecd9f35a35fa5daaf7c7b41af1e74b86ee26f5 Mon Sep 17 00:00:00 2001 From: kimpra <dustmqaksdltkfrlfdldi@gmail.com> Date: Thu, 12 Dec 2024 18:49:46 +0900 Subject: [PATCH 134/207] =?UTF-8?q?feat:=20=EA=B4=80=EB=A6=AC=EC=9E=90=20?= =?UTF-8?q?=EC=A2=85=EB=AA=A9=20=EA=B4=80=EB=A6=AC=EC=97=90=20=EC=A2=85?= =?UTF-8?q?=EB=AA=A9=20=EC=82=AD=EC=A0=9C=20=EB=B2=84=ED=8A=BC=20=EC=B6=94?= =?UTF-8?q?=EA=B0=80=20(#128)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../_api/delete-inactive-stock.ts | 20 +++++++++ .../set-admin-stock-manage-table-data.tsx | 12 ++--- .../_hooks/query/use-delete-inactive-stock.ts | 21 +++++++++ .../_ui/inactive-stock-delete-button.tsx | 45 +++++++++++++++++++ .../_ui/inactive-stock-manage-table.tsx | 2 +- .../category/_ui/stock/stock-manage/types.ts | 3 ++ 6 files changed, 97 insertions(+), 6 deletions(-) create mode 100644 app/admin/category/_ui/stock/stock-manage/_api/delete-inactive-stock.ts create mode 100644 app/admin/category/_ui/stock/stock-manage/_hooks/query/use-delete-inactive-stock.ts create mode 100644 app/admin/category/_ui/stock/stock-manage/_ui/inactive-stock-delete-button.tsx diff --git a/app/admin/category/_ui/stock/stock-manage/_api/delete-inactive-stock.ts b/app/admin/category/_ui/stock/stock-manage/_api/delete-inactive-stock.ts new file mode 100644 index 00000000..044bc1e8 --- /dev/null +++ b/app/admin/category/_ui/stock/stock-manage/_api/delete-inactive-stock.ts @@ -0,0 +1,20 @@ +import axiosInstance from '@/shared/api/axios' + +import { DeleteInactiveStockResponseModel } from '../types' + +const deleteInactiveStock = async (stockTypeId: number) => { + try { + const res = await axiosInstance.delete<DeleteInactiveStockResponseModel>( + `/api/admin/strategies/stock-type/${stockTypeId}` + ) + + if (!res.data.isSuccess) throw new Error(res.data.message) + + return res.data + } catch (err) { + console.log('Error : ' + err) + throw err + } +} + +export default deleteInactiveStock diff --git a/app/admin/category/_ui/stock/stock-manage/_api/set-admin-stock-manage-table-data.tsx b/app/admin/category/_ui/stock/stock-manage/_api/set-admin-stock-manage-table-data.tsx index 7c8e5a18..6a3f09ce 100644 --- a/app/admin/category/_ui/stock/stock-manage/_api/set-admin-stock-manage-table-data.tsx +++ b/app/admin/category/_ui/stock/stock-manage/_api/set-admin-stock-manage-table-data.tsx @@ -1,5 +1,8 @@ import Image from 'next/image' +import { Button } from '@/shared/ui/button' + +import InactiveStockDeleteButton from '../_ui/inactive-stock-delete-button' import StockActiveStateToggleButton from '../_ui/stock-active-state-toggle-button' import { StockResponseModel } from '../types' @@ -14,11 +17,10 @@ const setAdminStockManageTableData = (data: StockResponseModel['result'], isActi height={24} key={data.stockTypeName} />, - <StockActiveStateToggleButton - active={isActive} - stockTypeId={data.stockTypeId} - key={data.stockTypeId} - />, + <Button.ButtonGroup key={data.stockTypeId}> + <StockActiveStateToggleButton active={isActive} stockTypeId={data.stockTypeId} /> + {!isActive && <InactiveStockDeleteButton stockTypeId={data.stockTypeId} />} + </Button.ButtonGroup>, ]) ?? [] export default setAdminStockManageTableData diff --git a/app/admin/category/_ui/stock/stock-manage/_hooks/query/use-delete-inactive-stock.ts b/app/admin/category/_ui/stock/stock-manage/_hooks/query/use-delete-inactive-stock.ts new file mode 100644 index 00000000..c5b2a0b7 --- /dev/null +++ b/app/admin/category/_ui/stock/stock-manage/_hooks/query/use-delete-inactive-stock.ts @@ -0,0 +1,21 @@ +import { useMutation, useQueryClient } from '@tanstack/react-query' + +import deleteInactiveStock from '../../_api/delete-inactive-stock' + +const useDeleteInactiveStock = (stockTypeId: number) => { + const queryClient = useQueryClient() + + return useMutation({ + mutationFn: () => deleteInactiveStock(stockTypeId), + onSuccess: () => { + queryClient.invalidateQueries({ + queryKey: ['adminStocks'], + }) + }, + onError: (err) => { + console.error('Error : ', err) + }, + }) +} + +export default useDeleteInactiveStock diff --git a/app/admin/category/_ui/stock/stock-manage/_ui/inactive-stock-delete-button.tsx b/app/admin/category/_ui/stock/stock-manage/_ui/inactive-stock-delete-button.tsx new file mode 100644 index 00000000..a31c4db1 --- /dev/null +++ b/app/admin/category/_ui/stock/stock-manage/_ui/inactive-stock-delete-button.tsx @@ -0,0 +1,45 @@ +'use client' + +import { CSSProperties } from 'react' + +import useModal from '@/shared/hooks/custom/use-modal' +import { Button } from '@/shared/ui/button' +import AlertModal from '@/shared/ui/modal/alert-modal' + +import useDeleteInactiveStock from '../_hooks/query/use-delete-inactive-stock' + +interface Props { + stockTypeId: number +} + +const InactiveStockDeleteButton = ({ stockTypeId }: Props) => { + const { mutate, isPending } = useDeleteInactiveStock(stockTypeId) + const { closeModal, isModalOpen, openModal } = useModal() + + return ( + <> + <Button + size="small" + onClick={openModal} + disabled={isPending} + variant="filled" + style={buttonStyles} + > + 삭제 + </Button> + <AlertModal + message={`해당 종목을\n삭제하시겠습니까?`} + isModalOpen={isModalOpen} + onCancel={closeModal} + onConfirm={mutate} + /> + </> + ) +} + +const buttonStyles: CSSProperties = { + height: '30px', + padding: '7px 16px', + margin: '15px 0', +} +export default InactiveStockDeleteButton diff --git a/app/admin/category/_ui/stock/stock-manage/_ui/inactive-stock-manage-table.tsx b/app/admin/category/_ui/stock/stock-manage/_ui/inactive-stock-manage-table.tsx index 5587e2f0..9c5a59ac 100644 --- a/app/admin/category/_ui/stock/stock-manage/_ui/inactive-stock-manage-table.tsx +++ b/app/admin/category/_ui/stock/stock-manage/_ui/inactive-stock-manage-table.tsx @@ -19,7 +19,7 @@ const InactiveTradeManageTable = () => { return ( <ManageTable data={tableData} - size={TABLE_BODY_SIZE} + size={data.size} currentPage={currentPage} setCurrentPage={setCurrentPage} maxPage={data?.totalPages} diff --git a/app/admin/category/_ui/stock/stock-manage/types.ts b/app/admin/category/_ui/stock/stock-manage/types.ts index 76a8be23..fc66460c 100644 --- a/app/admin/category/_ui/stock/stock-manage/types.ts +++ b/app/admin/category/_ui/stock/stock-manage/types.ts @@ -24,6 +24,9 @@ export interface StockResponseModel extends StockResponseBaseModel<boolean> { // eslint-disable-next-line export interface ToggleStockActiveStateResponseModel extends StockResponseBaseModel<boolean> {} +// eslint-disable-next-line +export interface DeleteInactiveStockResponseModel extends StockResponseBaseModel<boolean> {} + export interface PresignedUrlResponseModel extends StockResponseBaseModel<boolean> { result: { presignedUrl: string From 93a18335f08dcc19972b83c3084abcab62f0408a Mon Sep 17 00:00:00 2001 From: deun <devdeun@gmail.com> Date: Thu, 12 Dec 2024 20:48:48 +0900 Subject: [PATCH 135/207] =?UTF-8?q?feat:=20userType=EC=97=90=20=EB=94=B0?= =?UTF-8?q?=EB=9D=BC=20=ED=94=84=EB=A1=9C=ED=95=84=20=EC=9D=B4=EB=AF=B8?= =?UTF-8?q?=EC=A7=80=20=EB=B0=8F=20=EB=8B=89=EB=84=A4=EC=9E=84=20=EC=B6=9C?= =?UTF-8?q?=EB=A0=A5=20(#126)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../_ui/questions-tab-content/index.tsx | 32 +++++++++++-------- shared/types/questions.ts | 2 +- 2 files changed, 19 insertions(+), 15 deletions(-) diff --git a/app/(dashboard)/my/questions/_ui/questions-tab-content/index.tsx b/app/(dashboard)/my/questions/_ui/questions-tab-content/index.tsx index f888d5d5..3d39b09e 100644 --- a/app/(dashboard)/my/questions/_ui/questions-tab-content/index.tsx +++ b/app/(dashboard)/my/questions/_ui/questions-tab-content/index.tsx @@ -44,20 +44,24 @@ const QuestionsTabContent = ({ options }: Props) => { return ( <> <ul className={cx('question-list')}> - {questionsData?.map((question) => ( - <li key={question.questionId}> - <QuestionCard - questionId={question.questionId} - strategyName={question.strategy.name} - title={question.title} - questionState={question.stateCondition} - contents={question.questionContent} - nickname={question.investor.userName} - profileImage={question.investor.profileImageUrl} - createdAt={question.createdAt} - /> - </li> - ))} + {questionsData?.map((question) => { + const userInfo = userType === 'TRADER' ? question.investor : question.trader + + return ( + <li key={question.questionId}> + <QuestionCard + questionId={question.questionId} + strategyName={question.strategy.name} + title={question.title} + questionState={question.stateCondition} + contents={question.questionContent} + nickname={userInfo?.userName || ''} + profileImage={userInfo?.profileImageUrl} + createdAt={question.createdAt} + /> + </li> + ) + })} </ul> {(!questionsData || !questionsData.length) && ( diff --git a/shared/types/questions.ts b/shared/types/questions.ts index d1e077af..21ae89de 100644 --- a/shared/types/questions.ts +++ b/shared/types/questions.ts @@ -29,7 +29,7 @@ export interface QuestionModel { } stateCondition: QuestionStateConditionType createdAt: string - investor: QuestionUserModel + investor?: QuestionUserModel trader?: QuestionUserModel } From 5d9d2b59f5a9c376da094e751d17549975024383 Mon Sep 17 00:00:00 2001 From: kimpra <dustmqaksdltkfrlfdldi@gmail.com> Date: Thu, 12 Dec 2024 20:53:26 +0900 Subject: [PATCH 136/207] =?UTF-8?q?fix:=20=EA=B4=80=EB=A6=AC=EC=9E=90=20?= =?UTF-8?q?=EC=A0=84=EB=9E=B5=20=EA=B4=80=EB=A6=AC=20=ED=85=8C=EC=9D=B4?= =?UTF-8?q?=EB=B8=94=20=EC=98=A4=EB=A5=98=20=ED=95=B4=EA=B2=B0=20(#131)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../_hooks/use-admin-strategies-page.ts | 19 ++++++++++--------- app/admin/strategies/page.tsx | 14 ++++++++------ 2 files changed, 18 insertions(+), 15 deletions(-) diff --git a/app/admin/strategies/_hooks/use-admin-strategies-page.ts b/app/admin/strategies/_hooks/use-admin-strategies-page.ts index 642255df..5273bf98 100644 --- a/app/admin/strategies/_hooks/use-admin-strategies-page.ts +++ b/app/admin/strategies/_hooks/use-admin-strategies-page.ts @@ -5,23 +5,24 @@ import { AdminStrategiesTapType } from '../types' const useAdminQuestionsPage = () => { const [activeTab, setActiveTab] = useState<AdminStrategiesTapType>('ALL') + const [inputValue, setInputValue] = useState('') const [keyword, setKeyword] = useState('') const [currentPage, setCurrentPage] = useState(1) - const initialSearchParams = { - searchWord: '', + const searchParams = { + searchWord: keyword, + page: currentPage, } - const [searchParams, setSearchParams] = useState(initialSearchParams) const initializeSearchParams = () => { + setInputValue('') setKeyword('') - setSearchParams(initialSearchParams) + setCurrentPage(1) } const searchWithKeyword = () => { - setSearchParams({ - searchWord: keyword, - }) + setKeyword(inputValue) + setCurrentPage(1) } const onTabChange = (id: string) => { @@ -33,8 +34,8 @@ const useAdminQuestionsPage = () => { tabs, activeTab, setActiveTab, - keyword, - setKeyword, + inputValue, + setInputValue, currentPage, setCurrentPage, searchParams, diff --git a/app/admin/strategies/page.tsx b/app/admin/strategies/page.tsx index 8fc6ffc0..05492614 100644 --- a/app/admin/strategies/page.tsx +++ b/app/admin/strategies/page.tsx @@ -21,16 +21,18 @@ const AdminStrategyPage = () => { const { tabs, activeTab, - keyword, - setKeyword, + inputValue, + setInputValue, searchParams, searchWithKeyword, onTabChange, + currentPage, setCurrentPage, } = useAdminStrategiesPage() const { data, isLoading } = useStrategiesData({ ...searchParams, + size: 8, isApproved: activeTab === 'ALL' ? undefined : 'PENDING', }) @@ -45,15 +47,15 @@ const AdminStrategyPage = () => { <AdminContentsHeader Left={ <span> - 총 <span className={cx('color-primary-500')}>{data.totalElements}</span>명 + 총 <span className={cx('color-primary-500')}>{data.totalElements}</span>개 </span> } Right={ <div> <SearchInput - value={keyword} + value={inputValue} placeholder="전략명을 입력하세요." - onChange={(e) => setKeyword(e.target.value)} + onChange={(e) => setInputValue(e.target.value)} onSearchIconClick={searchWithKeyword} /> </div> @@ -67,7 +69,7 @@ const AdminStrategyPage = () => { currentPage={1} /> <Pagination - currentPage={data.page} + currentPage={currentPage} maxPage={data.totalPages} onPageChange={setCurrentPage} /> From efb3147f32b10cf9140958b526bfec09cfef8dfb Mon Sep 17 00:00:00 2001 From: ssumanlife <ksm9805@naver.com> Date: Thu, 12 Dec 2024 22:19:59 +0900 Subject: [PATCH 137/207] =?UTF-8?q?fix:=20=EC=A0=84=EB=9E=B5=20=EB=93=B1?= =?UTF-8?q?=EB=A1=9D=EC=8B=9C=20=EC=A0=9C=EC=95=88=EC=84=9C=20=EB=93=B1?= =?UTF-8?q?=EB=A1=9D=EC=97=AC=EB=B6=80=20=EC=98=B5=EC=85=94=EB=84=90=20?= =?UTF-8?q?=EC=A0=81=EC=9A=A9=20(#130)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- app/(dashboard)/my/_api/add-strategy.ts | 2 +- app/(dashboard)/my/strategies/add/page.tsx | 5 ++--- 2 files changed, 3 insertions(+), 4 deletions(-) diff --git a/app/(dashboard)/my/_api/add-strategy.ts b/app/(dashboard)/my/_api/add-strategy.ts index d78a692d..32bfb91f 100644 --- a/app/(dashboard)/my/_api/add-strategy.ts +++ b/app/(dashboard)/my/_api/add-strategy.ts @@ -49,7 +49,7 @@ export interface StrategyModel { stockTypeIds: number[] minimumInvestmentAmount: MinimumInvestmentAmountType description: string - proposalFile?: ProposalFileInfoModel + proposalFile: ProposalFileInfoModel | null } export interface StrategyResponseModel { diff --git a/app/(dashboard)/my/strategies/add/page.tsx b/app/(dashboard)/my/strategies/add/page.tsx index a48e95fc..f4a7f5cb 100644 --- a/app/(dashboard)/my/strategies/add/page.tsx +++ b/app/(dashboard)/my/strategies/add/page.tsx @@ -85,7 +85,6 @@ const StrategyAddPage = () => { ? '최소 운용가능 금액을 선택해주세요.' : '', description: !formData.description ? '전략 소개를 입력해주세요.' : '', - proposalFile: !formData.proposalFile ? '제안서를 업로드해주세요.' : '', } setFormErrors(newErrors) @@ -111,12 +110,12 @@ const StrategyAddPage = () => { e.preventDefault() if (!validateForm() || !formData.tradeType) return - const fileInfo: ProposalFileInfoModel | undefined = formData.proposalFile + const fileInfo: ProposalFileInfoModel | null = formData.proposalFile ? { proposalFileName: formData.proposalFile.name, proposalFileSize: formData.proposalFile.size, } - : undefined + : null const data: StrategyModel = { strategyName: formData.strategyName, From 7f5cd308bfee8650c530470f9137bd61763cb67f Mon Sep 17 00:00:00 2001 From: ssumanlife <ksm9805@naver.com> Date: Thu, 12 Dec 2024 22:20:57 +0900 Subject: [PATCH 138/207] =?UTF-8?q?fix:=20=EC=88=98=EC=A0=95=EB=90=9C=20ap?= =?UTF-8?q?i=20=EB=B0=98=ED=99=98=20=EA=B0=92=20=EC=B6=94=EA=B0=80=20(#130?= =?UTF-8?q?)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- app/(dashboard)/_ui/details-information/index.tsx | 1 + shared/types/strategy-data.ts | 1 + 2 files changed, 2 insertions(+) diff --git a/app/(dashboard)/_ui/details-information/index.tsx b/app/(dashboard)/_ui/details-information/index.tsx index 533efb0d..5d3ea73a 100644 --- a/app/(dashboard)/_ui/details-information/index.tsx +++ b/app/(dashboard)/_ui/details-information/index.tsx @@ -46,6 +46,7 @@ const DetailsInformation = ({ name={information.strategyName} strategyId={strategyId} isEditable={isEditable} + hasProposal={information.hasProposal} /> <InvestInformation stock={information.stockTypeInfo?.stockTypeNames || []} diff --git a/shared/types/strategy-data.ts b/shared/types/strategy-data.ts index 7abea53a..fcadf377 100644 --- a/shared/types/strategy-data.ts +++ b/shared/types/strategy-data.ts @@ -72,6 +72,7 @@ export interface StrategyDetailsInformationModel extends BaseStrategyModel { kpRatio: number finalProfitLossDate: string createdAt: string + hasProposal: boolean } export interface StrategyCardModel { From e5aa76a9c7fd4006fdad55cf4b303a5bf6584c7f Mon Sep 17 00:00:00 2001 From: ssumanlife <ksm9805@naver.com> Date: Thu, 12 Dec 2024 22:21:31 +0900 Subject: [PATCH 139/207] =?UTF-8?q?fix:=20=EC=A0=9C=EC=95=88=EC=84=9C=20?= =?UTF-8?q?=EC=98=B5=EC=85=94=EB=84=90=20=EC=A0=81=EC=9A=A9=20(#130)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../_ui/details-information/strategy-name-box.tsx | 14 +++++++++++--- 1 file changed, 11 insertions(+), 3 deletions(-) diff --git a/app/(dashboard)/_ui/details-information/strategy-name-box.tsx b/app/(dashboard)/_ui/details-information/strategy-name-box.tsx index ca14f37f..be7b6e13 100644 --- a/app/(dashboard)/_ui/details-information/strategy-name-box.tsx +++ b/app/(dashboard)/_ui/details-information/strategy-name-box.tsx @@ -16,13 +16,21 @@ const cx = classNames.bind(styles) interface Props { strategyId: number + name: string + hasProposal: boolean iconUrls?: string[] iconNames?: string[] - name: string isEditable?: boolean } -const StrategyNameBox = ({ strategyId, iconUrls, iconNames, name, isEditable = false }: Props) => { +const StrategyNameBox = ({ + strategyId, + name, + hasProposal, + iconUrls, + iconNames, + isEditable = false, +}: Props) => { const information = useEditInformationStore((state) => state.information) const setStrategyName = useEditInformationStore((state) => state.actions.setStrategyName) const { mutate } = useGetProposalDownload() @@ -49,7 +57,7 @@ const StrategyNameBox = ({ strategyId, iconUrls, iconNames, name, isEditable = f ) : ( <p className={cx('name')}>{name}</p> )} - <button onClick={handleDownload}>제안서 다운로드</button> + {hasProposal && <button onClick={handleDownload}>제안서 다운로드</button>} </div> ) } From 1ff2abb3d55e38fafc2ded6630cea221ca4db258 Mon Sep 17 00:00:00 2001 From: ssumanlife <ksm9805@naver.com> Date: Thu, 12 Dec 2024 22:22:16 +0900 Subject: [PATCH 140/207] =?UTF-8?q?design:=20=ED=81=B4=EB=A0=88=EC=8A=A4?= =?UTF-8?q?=EB=84=A4=EC=9E=84=20=EC=A0=9C=EA=B1=B0=20(#130)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- app/(dashboard)/_ui/details-information/styles.module.scss | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/app/(dashboard)/_ui/details-information/styles.module.scss b/app/(dashboard)/_ui/details-information/styles.module.scss index fbec0ead..13136396 100644 --- a/app/(dashboard)/_ui/details-information/styles.module.scss +++ b/app/(dashboard)/_ui/details-information/styles.module.scss @@ -14,7 +14,7 @@ gap: 4px; border-radius: 5px; background-color: $color-white; - .input, + .name-input { padding: 10px; height: 30px; From 5d23107690d5565437d0a1add817903128d8f260 Mon Sep 17 00:00:00 2001 From: kimpra <dustmqaksdltkfrlfdldi@gmail.com> Date: Thu, 12 Dec 2024 23:22:25 +0900 Subject: [PATCH 141/207] =?UTF-8?q?fix:=20=EA=B4=80=EB=A6=AC=EC=9E=90=20?= =?UTF-8?q?=EA=B3=B5=EC=A7=80=EC=82=AC=ED=95=AD=20=EB=93=B1=EB=A1=9D=20?= =?UTF-8?q?=EC=88=98=EC=A0=95=20=EB=A7=88=EB=AC=B4=EB=A6=AC=20(#14)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../notices/_api/get-notice-detail.ts | 27 +++--- .../edit/_hooks/query/use-patch-notice.ts | 44 +++++++++ .../notices/[noticeId]/edit/page.module.scss | 38 ++++++++ app/admin/notices/[noticeId]/edit/page.tsx | 95 +++++++++++++++++++ app/admin/notices/[noticeId]/edit/types.ts | 5 + app/admin/notices/post/_api/post-notice.ts | 28 ------ .../post/_hooks/query/use-post-notice.ts | 40 ++------ .../notices/post/_hooks/use-notice-form.ts | 13 ++- app/admin/notices/post/types.ts | 6 -- app/admin/notices/types.ts | 6 ++ .../utils/upload-file-with-presigned-url.ts | 26 +++++ 11 files changed, 244 insertions(+), 84 deletions(-) create mode 100644 app/admin/notices/[noticeId]/edit/_hooks/query/use-patch-notice.ts create mode 100644 app/admin/notices/[noticeId]/edit/page.module.scss create mode 100644 app/admin/notices/[noticeId]/edit/page.tsx create mode 100644 app/admin/notices/[noticeId]/edit/types.ts delete mode 100644 app/admin/notices/post/_api/post-notice.ts create mode 100644 shared/utils/upload-file-with-presigned-url.ts diff --git a/app/(landing)/notices/_api/get-notice-detail.ts b/app/(landing)/notices/_api/get-notice-detail.ts index 81fa35c9..29d9991d 100644 --- a/app/(landing)/notices/_api/get-notice-detail.ts +++ b/app/(landing)/notices/_api/get-notice-detail.ts @@ -1,30 +1,27 @@ import axiosInstance from '@/shared/api/axios' +import { APIResponseBaseModel } from '@/shared/types/response' -interface NoticeResponseModel { - isSuccess: true - message: '공지사항 상세 조회' +export interface NoticeDetailResponseModel extends APIResponseBaseModel<boolean> { result: { - title: '제목' - content: '내용' - createdAt: '2024-12-06 11:24:59' + title: string + content: string + createdAt: string files: [ { - fileName: '2.jpg' - noticeFileId: 3 + fileName: string + noticeFileId: number }, ] } } -export const getNoticeDetail = async (noticeId: number): Promise<NoticeResponseModel['result']> => { +export const getNoticeDetail = async (noticeId: number) => { try { - const response = await axiosInstance.get<NoticeResponseModel>(`/api/notices/${noticeId}`) + const response = await axiosInstance.get<NoticeDetailResponseModel>(`/api/notices/${noticeId}`) - if (response.data.isSuccess) { - return response.data.result - } else { - throw new Error(response.data.message || '요청 실패') - } + if (!response.data.isSuccess) throw new Error(response.data.message || '요청 실패') + + return response.data.result } catch (err) { console.error(err) throw new Error('공지사항 조회 실패.') diff --git a/app/admin/notices/[noticeId]/edit/_hooks/query/use-patch-notice.ts b/app/admin/notices/[noticeId]/edit/_hooks/query/use-patch-notice.ts new file mode 100644 index 00000000..f3047931 --- /dev/null +++ b/app/admin/notices/[noticeId]/edit/_hooks/query/use-patch-notice.ts @@ -0,0 +1,44 @@ +import { useRouter } from 'next/navigation' + +import { NoticeFormModel } from '@/app/admin/notices/types' +import { useMutation, useQueryClient } from '@tanstack/react-query' + +import axiosInstance from '@/shared/api/axios' +import uploadFileWithPresignedUrl from '@/shared/utils/upload-file-with-presigned-url' + +import { PatchNoticeResponeseModel } from '../../types' + +const usePatchNotice = (formData: NoticeFormModel, noticeId: string) => { + const router = useRouter() + const queryClient = useQueryClient() + + return useMutation({ + mutationFn: async () => { + // Presigned URL 요청 + const uploadResponse = await axiosInstance.patch<PatchNoticeResponeseModel>( + `/api/admin/notices/${noticeId}`, + { + title: formData.title, + content: formData.content, + filePaths: formData?.files?.map((file) => file.name) ?? [], + sizes: formData?.files?.map((file) => file.size) ?? [], + } + ) + + const { files } = formData + if (!files) return + + const presignedUrls = uploadResponse.data.result + + await uploadFileWithPresignedUrl(files, presignedUrls) + }, + onSuccess: () => { + queryClient.invalidateQueries({ + queryKey: ['notices'], + }) + router.replace('/admin/notices') + }, + }) +} + +export default usePatchNotice diff --git a/app/admin/notices/[noticeId]/edit/page.module.scss b/app/admin/notices/[noticeId]/edit/page.module.scss new file mode 100644 index 00000000..f5e7b43f --- /dev/null +++ b/app/admin/notices/[noticeId]/edit/page.module.scss @@ -0,0 +1,38 @@ +.container { + padding: 86px 0 80px; + border-radius: 8px; + margin-bottom: 42px; + background-color: $color-white; +} + +.form { + display: flex; + flex-direction: column; + align-items: center; +} + +.input-field { + width: 795px; + margin-bottom: 64px; + display: flex; + flex-direction: column; + gap: 30px; +} + +.file-input { + width: 100%; +} + +.textarea { + height: 420px; + + &::placeholder { + vertical-align: middle; + } +} + +.button { + width: 74px; + height: 30px; + border: 1px solid $color-gray-300; +} diff --git a/app/admin/notices/[noticeId]/edit/page.tsx b/app/admin/notices/[noticeId]/edit/page.tsx new file mode 100644 index 00000000..9bfdb57e --- /dev/null +++ b/app/admin/notices/[noticeId]/edit/page.tsx @@ -0,0 +1,95 @@ +'use client' + +import { useEffect } from 'react' + +import { useParams } from 'next/navigation' + +import useNoticeDetail from '@/app/(landing)/notices/_hooks/use-notice-detail' +import FileInput from '@/app/admin/_ui/file-input' +import InputField from '@/app/admin/_ui/input-field' +import classNames from 'classnames/bind' + +import { Button } from '@/shared/ui/button' +import BackHeader from '@/shared/ui/header/back-header' +import Input from '@/shared/ui/input' +import Textarea from '@/shared/ui/textarea' +import Title from '@/shared/ui/title' + +import useNoticeForm from '../../post/_hooks/use-notice-form' +import usePatchNotice from './_hooks/query/use-patch-notice' +import styles from './page.module.scss' + +const cx = classNames.bind(styles) + +const AdminNoticeEditPage = () => { + const { noticeId } = useParams() + const { data } = useNoticeDetail(Number(noticeId as string)) + const { formData, setFormData, onInputChange } = useNoticeForm(data) + const { mutate } = usePatchNotice(formData, noticeId as string) + + useEffect(() => { + if (data) { + setFormData({ + title: data.title || '', + content: data.content || '', + }) + } + }, [data, setFormData]) + + return ( + <> + <BackHeader label="공지사항으로 돌아가기" /> + <Title label="공지사항 수정" style={{ margin: '0 0 26px 12.6px' }} /> + <div className={cx('container')}> + <form + className={cx('form')} + onSubmit={(e) => { + e.preventDefault() + mutate() + }} + > + <div className={cx('input-field')}> + <InputField + label="제목" + Input={ + <Input + type="text" + inputSize="full" + placeholder="제목을 입력하세요." + value={formData.title} + onChange={(e) => onInputChange('title', e.target.value)} + /> + } + /> + <InputField + label="내용" + Input={ + <Textarea + className={cx('textarea')} + placeholder="내용을 입력하세요." + value={formData.content} + onChange={(e) => onInputChange('content', e.target.value)} + /> + } + /> + <InputField + label="파일첨부" + Input={ + <FileInput + className={cx('file-input')} + onChange={(e) => onInputChange('files', Array.from(e.target.files || []))} + multiple + /> + } + /> + </div> + <Button size="small" type="submit" variant="filled"> + 공지 수정하기 + </Button> + </form> + </div> + </> + ) +} + +export default AdminNoticeEditPage diff --git a/app/admin/notices/[noticeId]/edit/types.ts b/app/admin/notices/[noticeId]/edit/types.ts new file mode 100644 index 00000000..e13d315a --- /dev/null +++ b/app/admin/notices/[noticeId]/edit/types.ts @@ -0,0 +1,5 @@ +import { APIResponseBaseModel } from '@/shared/types/response' + +export interface PatchNoticeResponeseModel extends APIResponseBaseModel<boolean> { + result: string[] +} diff --git a/app/admin/notices/post/_api/post-notice.ts b/app/admin/notices/post/_api/post-notice.ts deleted file mode 100644 index 49e3022e..00000000 --- a/app/admin/notices/post/_api/post-notice.ts +++ /dev/null @@ -1,28 +0,0 @@ -import axiosInstance from '@/shared/api/axios' - -import { NoticeFormModel, PostNoticeResopnseModel } from '../types' - -const postNotice = async (formData: NoticeFormModel) => { - const data = { - title: formData.title, - content: formData.content, - fileUrls: formData.files, - } - - try { - const res = await axiosInstance.post<PostNoticeResopnseModel>('/api/admin/notices', data, { - headers: { - 'Content-Type': 'application/json', - }, - }) - - if (!res.data.isSuccess) throw new Error('Error : ' + res.data.message) - - alert('공지 등록이 완료되었습니다.') - } catch (err) { - console.error(err) - throw err - } -} - -export default postNotice diff --git a/app/admin/notices/post/_hooks/query/use-post-notice.ts b/app/admin/notices/post/_hooks/query/use-post-notice.ts index 4dfba96c..b4919961 100644 --- a/app/admin/notices/post/_hooks/query/use-post-notice.ts +++ b/app/admin/notices/post/_hooks/query/use-post-notice.ts @@ -3,8 +3,10 @@ import { useRouter } from 'next/navigation' import { useMutation, useQueryClient } from '@tanstack/react-query' import axiosInstance from '@/shared/api/axios' +import uploadFileWithPresignedUrl from '@/shared/utils/upload-file-with-presigned-url' -import { NoticeFormModel, PostNoticeResopnseModel } from '../../types' +import { NoticeFormModel } from '../../../types' +import { PostNoticeResopnseModel } from '../../types' const usePostNotice = () => { const router = useRouter() @@ -12,45 +14,23 @@ const usePostNotice = () => { return useMutation({ mutationFn: async (formData: NoticeFormModel) => { - // Presigned URL 요청 - const { files } = formData - if (!files) return - + // Presigned URL 요청` const uploadResponse = await axiosInstance.post<PostNoticeResopnseModel>( '/api/admin/notices', { title: formData.title, content: formData.content, - filePaths: formData?.files?.map((file) => file.name) ?? null, - sizes: formData?.files?.map((file) => file.size) ?? null, + filePaths: formData?.files?.map((file) => file.name) ?? [], + sizes: formData?.files?.map((file) => file.size) ?? [], } ) + const { files } = formData + if (!files) return + const presignedUrls = uploadResponse.data.result - // Presigned URL로 파일 업로드 - try { - await Promise.all( - files.map((file, idx) => { - if (presignedUrls[idx]) { - return fetch(presignedUrls[idx], { - method: 'PUT', - body: file, - headers: { - 'Content-Type': file.type, - }, - }) - } else { - throw new Error(`Presigned URL이 인덱스 ${idx}에 대해 없습니다.`) - } - }) - ) - } catch (err) { - if (err instanceof Error) { - throw new Error(`File upload failed: ${err.message}`) - } - throw err - } + await uploadFileWithPresignedUrl(files, presignedUrls) }, onSuccess: () => { queryClient.invalidateQueries({ diff --git a/app/admin/notices/post/_hooks/use-notice-form.ts b/app/admin/notices/post/_hooks/use-notice-form.ts index f169e3d0..a7079421 100644 --- a/app/admin/notices/post/_hooks/use-notice-form.ts +++ b/app/admin/notices/post/_hooks/use-notice-form.ts @@ -1,13 +1,15 @@ import { useState } from 'react' -import { NoticeFormModel } from '../types' +import { NoticeFormModel } from '../../types' -const useNoticeForm = () => { - const [formData, setFormData] = useState<NoticeFormModel>({ +const useNoticeForm = ( + initialValue = { title: '', content: '', - files: [], - }) + // files: [], + } +) => { + const [formData, setFormData] = useState<NoticeFormModel>(initialValue) const onInputChange = (name: keyof NoticeFormModel, value: string | File[]) => { setFormData((prev) => ({ @@ -18,6 +20,7 @@ const useNoticeForm = () => { return { formData, + setFormData, onInputChange, } } diff --git a/app/admin/notices/post/types.ts b/app/admin/notices/post/types.ts index dc046075..c7b2a212 100644 --- a/app/admin/notices/post/types.ts +++ b/app/admin/notices/post/types.ts @@ -1,11 +1,5 @@ import { APIResponseBaseModel } from '@/shared/types/response' -export interface NoticeFormModel { - title: string - content: string - files?: File[] -} - export interface PostNoticeResopnseModel extends APIResponseBaseModel<boolean> { result: string[] } diff --git a/app/admin/notices/types.ts b/app/admin/notices/types.ts index 1fc89375..a4a9b047 100644 --- a/app/admin/notices/types.ts +++ b/app/admin/notices/types.ts @@ -2,3 +2,9 @@ import { APIResponseBaseModel } from '@/shared/types/response' // eslint-disable-next-line export interface DeleteNoticeResponeseModel extends APIResponseBaseModel<boolean> {} + +export interface NoticeFormModel { + title: string + content: string + files?: File[] +} diff --git a/shared/utils/upload-file-with-presigned-url.ts b/shared/utils/upload-file-with-presigned-url.ts new file mode 100644 index 00000000..e4658c4b --- /dev/null +++ b/shared/utils/upload-file-with-presigned-url.ts @@ -0,0 +1,26 @@ +const uploadFileWithPresignedUrl = async (files: File[], presignedUrls: string[]) => { + try { + await Promise.all( + files.map((file, idx) => { + if (presignedUrls[idx]) { + return fetch(presignedUrls[idx], { + method: 'PUT', + body: file, + headers: { + 'Content-Type': file.type, + }, + }) + } else { + throw new Error(`Presigned URL이 인덱스 ${idx}에 대해 없습니다.`) + } + }) + ) + } catch (err) { + if (err instanceof Error) { + throw new Error(`File upload failed: ${err.message}`) + } + throw err + } +} + +export default uploadFileWithPresignedUrl From df3245d69e67908e6712d8aea519cd9ba126dfbc Mon Sep 17 00:00:00 2001 From: kimpra <dustmqaksdltkfrlfdldi@gmail.com> Date: Fri, 13 Dec 2024 00:50:47 +0900 Subject: [PATCH 142/207] =?UTF-8?q?fix:=20query=20key=EB=93=A4=20=EC=83=81?= =?UTF-8?q?=EC=88=98=EB=A1=9C=20=EB=B6=84=EB=A6=AC=20(#136)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../my/_hooks/query/use-add-strategy.ts | 6 ++- .../my/_hooks/query/use-analysis-mutation.ts | 9 ++-- .../_hooks/query/use-delete-account-images.ts | 4 +- .../my/_hooks/query/use-delete-my-strategy.ts | 4 +- .../query/use-get-favorite-strategy-list.ts | 4 +- .../_hooks/query/use-get-my-account-image.ts | 4 +- .../_hooks/query/use-get-my-daily-analysis.ts | 4 +- .../_hooks/query/use-get-my-strategy-list.ts | 3 +- .../my/_hooks/query/use-get-profile.ts | 4 +- .../_hooks/query/use-manage-daily-analysis.ts | 4 +- .../my/_hooks/query/use-patch-user-profile.ts | 6 ++- .../my/_hooks/query/use-post-edit-strategy.ts | 4 +- .../_hooks/query/use-upload-account-images.ts | 4 +- .../_hooks/query/use-delete-answer.ts | 6 ++- .../_hooks/query/use-delete-question.ts | 6 ++- .../_hooks/query/use-get-my-question-list.ts | 3 +- .../_hooks/query/use-get-question-details.ts | 4 +- .../questions/_hooks/query/use-post-answer.ts | 6 ++- .../_hooks/query/use-delete-review.ts | 4 +- .../_hooks/query/use-get-account-images.ts | 4 +- .../_hooks/query/use-get-analysis-chart.ts | 4 +- .../_hooks/query/use-get-analysis.ts | 4 +- .../query/use-get-details-information-data.ts | 3 +- .../_hooks/query/use-get-reviews-data.ts | 4 +- .../_hooks/query/use-get-statistics.ts | 4 +- .../_hooks/query/use-patch-review.ts | 4 +- .../_hooks/query/use-post-question.ts | 4 +- .../_hooks/query/use-post-review.ts | 4 +- .../_hooks/query/use-get-strategies-search.ts | 4 +- .../_hooks/query/use-get-subscribe.ts | 4 +- .../_hooks/query/use-post-strategies.ts | 4 +- .../traders/_hooks/use-get-trader-details.ts | 4 +- .../traders/_hooks/use-get-trader-profile.ts | 4 +- .../traders/_hooks/use-get-traders.ts | 4 +- .../query/use-get-strategies-metrics.ts | 4 +- .../query/use-get-top-ranking-smscore.ts | 3 +- .../_hooks/query/use-get-top-ranking.ts | 3 +- .../_hooks/query/use-get-user-metrics.ts | 4 +- .../notices/_hooks/use-notice-detail.ts | 4 +- app/(landing)/notices/_hooks/use-notice.ts | 4 +- .../_hooks/query/use-delete-inactive-stock.ts | 4 +- .../_hooks/query/use-stocks-data.ts | 4 +- .../query/use-toggle-stock-active-state.ts | 4 +- .../_ui/stock-post-button/index.tsx | 3 +- .../query/use-toggle-trade-active-state.ts | 4 +- .../_hooks/query/use-trades-data.ts | 4 +- .../_ui/trade-post-button/index.tsx | 3 +- .../edit/_hooks/query/use-patch-notice.ts | 3 +- .../notices/_hook/query/get-admin-notices.ts | 4 +- .../notices/_hook/query/use-delete-notice.ts | 4 +- .../post/_hooks/query/use-post-notice.ts | 3 +- .../_hooks/query/use-admin-questions.ts | 3 +- .../_hooks/query/use-delete-question.ts | 4 +- .../_hooks/query/use-admin-delete-strategy.ts | 4 +- .../query/use-patch-strategy-approval.ts | 4 +- .../_hooks/query/use-patch-strategy-role.ts | 4 +- .../_hooks/query/use-strategies-data.ts | 4 +- .../users/_hooks/query/use-admin-users.ts | 4 +- .../users/_hooks/query/use-delete-user.ts | 4 +- .../users/_hooks/query/use-patch-user-role.ts | 4 +- shared/constants/query-key.ts | 44 +++++++++++++++++++ 61 files changed, 221 insertions(+), 68 deletions(-) create mode 100644 shared/constants/query-key.ts diff --git a/app/(dashboard)/my/_hooks/query/use-add-strategy.ts b/app/(dashboard)/my/_hooks/query/use-add-strategy.ts index 6ad2b446..ee4e9904 100644 --- a/app/(dashboard)/my/_hooks/query/use-add-strategy.ts +++ b/app/(dashboard)/my/_hooks/query/use-add-strategy.ts @@ -5,6 +5,8 @@ import { useRouter } from 'next/navigation' import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query' import { AxiosError } from 'axios' +import { QUERY_KEY } from '@/shared/constants/query-key' + import { StrategyModel, StrategyResponseModel, @@ -25,7 +27,7 @@ export const useAddStrategy = () => { StrategyTypeResponseModel, AxiosError<ErrorResponseModel> >({ - queryKey: ['strategyTypes'], + queryKey: [QUERY_KEY.STRATEGY_TYPE], queryFn: () => strategyApi.getStrategyTypes().then((response) => response.data), retry: false, refetchOnWindowFocus: false, @@ -39,7 +41,7 @@ export const useAddStrategy = () => { mutationFn: (data) => strategyApi.registerStrategy(data).then((response) => response.data), onSuccess: () => { queryClient.invalidateQueries({ - queryKey: ['myStrategies'], + queryKey: [QUERY_KEY.MY_STRATEGIES], }) router.back() }, diff --git a/app/(dashboard)/my/_hooks/query/use-analysis-mutation.ts b/app/(dashboard)/my/_hooks/query/use-analysis-mutation.ts index 21ff8cdb..79713780 100644 --- a/app/(dashboard)/my/_hooks/query/use-analysis-mutation.ts +++ b/app/(dashboard)/my/_hooks/query/use-analysis-mutation.ts @@ -5,6 +5,7 @@ import { } from '@/app/(dashboard)/my/_api/post-daily-analysis' import { useMutation, useQueryClient } from '@tanstack/react-query' +import { QUERY_KEY } from '@/shared/constants/query-key' import { AnalysisDataModel } from '@/shared/types/strategy-data' interface UploadMutationParamsModel { @@ -22,12 +23,12 @@ export const useAnalysisUploadMutation = ( mutationFn: ({ data }) => uploadDailyAnalysis(strategyId, data), onSuccess: async () => { queryClient.invalidateQueries({ - queryKey: ['myDailyAnalysis', strategyId], + queryKey: [QUERY_KEY.MY_DAILY_ANALYSIS, strategyId], }) try { const newData = await getMyDailyAnalysis(strategyId, page, size) - queryClient.setQueryData(['myDailyAnalysis', strategyId], newData) + queryClient.setQueryData([QUERY_KEY.MY_DAILY_ANALYSIS, strategyId], newData) } catch (err) { console.error('Failed to fetch updated my daily analysis data:', err) } @@ -38,12 +39,12 @@ export const useAnalysisUploadMutation = ( mutationFn: () => deleteAllAnalysis(strategyId), onSuccess: async () => { queryClient.invalidateQueries({ - queryKey: ['myDailyAnalysis', strategyId], + queryKey: [QUERY_KEY.MY_DAILY_ANALYSIS, strategyId], }) try { const newData = await getMyDailyAnalysis(strategyId, page, size) - queryClient.setQueryData(['myDailyAnalysis', strategyId], newData) + queryClient.setQueryData([QUERY_KEY.MY_DAILY_ANALYSIS, strategyId], newData) } catch (err) { console.error('Failed to fetch updated my daily analysis data:', err) } diff --git a/app/(dashboard)/my/_hooks/query/use-delete-account-images.ts b/app/(dashboard)/my/_hooks/query/use-delete-account-images.ts index bb376905..4400262b 100644 --- a/app/(dashboard)/my/_hooks/query/use-delete-account-images.ts +++ b/app/(dashboard)/my/_hooks/query/use-delete-account-images.ts @@ -1,5 +1,7 @@ import { useMutation, useQueryClient } from '@tanstack/react-query' +import { QUERY_KEY } from '@/shared/constants/query-key' + import { deleteAccountImages } from '../../_api/post-account-image' interface DeleteAccountImagesRequestModel { @@ -14,7 +16,7 @@ export const useDeleteAccountImages = () => { mutationFn: (request: DeleteAccountImagesRequestModel) => deleteAccountImages(request), onSuccess: (_, request) => { queryClient.invalidateQueries({ - queryKey: ['myAccountImages', request.strategyId], + queryKey: [QUERY_KEY.MY_ACCOUNT_IMAGES, request.strategyId], }) }, }) diff --git a/app/(dashboard)/my/_hooks/query/use-delete-my-strategy.ts b/app/(dashboard)/my/_hooks/query/use-delete-my-strategy.ts index 3fafcaef..38975959 100644 --- a/app/(dashboard)/my/_hooks/query/use-delete-my-strategy.ts +++ b/app/(dashboard)/my/_hooks/query/use-delete-my-strategy.ts @@ -1,5 +1,7 @@ import { useMutation, useQueryClient } from '@tanstack/react-query' +import { QUERY_KEY } from '@/shared/constants/query-key' + import { deleteMyStrategy } from '../../_api/delete-my-strategy' export const useDeleteMyStrategy = () => { @@ -9,7 +11,7 @@ export const useDeleteMyStrategy = () => { mutationFn: (strategyId: number) => deleteMyStrategy(strategyId), onSuccess: () => { queryClient.invalidateQueries({ - queryKey: ['myStrategies'], + queryKey: [QUERY_KEY.MY_STRATEGIES], }) }, }) diff --git a/app/(dashboard)/my/_hooks/query/use-get-favorite-strategy-list.ts b/app/(dashboard)/my/_hooks/query/use-get-favorite-strategy-list.ts index 7138a936..85069e01 100644 --- a/app/(dashboard)/my/_hooks/query/use-get-favorite-strategy-list.ts +++ b/app/(dashboard)/my/_hooks/query/use-get-favorite-strategy-list.ts @@ -1,5 +1,7 @@ import { useQuery } from '@tanstack/react-query' +import { QUERY_KEY } from '@/shared/constants/query-key' + import getFavoriteStrategyList from '../../_api/get-favorite-strategy-list' interface Props { @@ -9,7 +11,7 @@ interface Props { const useGetFavoriteStrategyList = ({ page, size }: Props) => { return useQuery({ - queryKey: ['favoriteStrategies', page, size], + queryKey: [QUERY_KEY.MY_FAVORITE_STRATEGIES, page, size], queryFn: () => getFavoriteStrategyList({ page, size }), }) } diff --git a/app/(dashboard)/my/_hooks/query/use-get-my-account-image.ts b/app/(dashboard)/my/_hooks/query/use-get-my-account-image.ts index 7b9d32bc..3ff17411 100644 --- a/app/(dashboard)/my/_hooks/query/use-get-my-account-image.ts +++ b/app/(dashboard)/my/_hooks/query/use-get-my-account-image.ts @@ -1,10 +1,12 @@ import { useQuery } from '@tanstack/react-query' +import { QUERY_KEY } from '@/shared/constants/query-key' + import getMyAccountImages from '../../_api/get-my-account-iamges' const useGetMyAccountImages = (strategyId: number) => { return useQuery({ - queryKey: ['myAccountImages', strategyId], + queryKey: [QUERY_KEY.MY_ACCOUNT_IMAGES, strategyId], queryFn: () => getMyAccountImages(strategyId), }) } diff --git a/app/(dashboard)/my/_hooks/query/use-get-my-daily-analysis.ts b/app/(dashboard)/my/_hooks/query/use-get-my-daily-analysis.ts index 83267dac..7908e152 100644 --- a/app/(dashboard)/my/_hooks/query/use-get-my-daily-analysis.ts +++ b/app/(dashboard)/my/_hooks/query/use-get-my-daily-analysis.ts @@ -1,10 +1,12 @@ import { useQuery } from '@tanstack/react-query' +import { QUERY_KEY } from '@/shared/constants/query-key' + import getMyDailyAnalysis from '../../_api/get-my-daily-analysis' const useGetMyDailyAnalysis = (strategyId: number, page: number, size: number) => { return useQuery({ - queryKey: ['myDailyAnalysis', strategyId, page, size], + queryKey: [QUERY_KEY.MY_DAILY_ANALYSIS, strategyId, page, size], queryFn: () => getMyDailyAnalysis(strategyId, page, size), }) } diff --git a/app/(dashboard)/my/_hooks/query/use-get-my-strategy-list.ts b/app/(dashboard)/my/_hooks/query/use-get-my-strategy-list.ts index f0f096e2..e26c3e4d 100644 --- a/app/(dashboard)/my/_hooks/query/use-get-my-strategy-list.ts +++ b/app/(dashboard)/my/_hooks/query/use-get-my-strategy-list.ts @@ -1,6 +1,7 @@ import { getMyStrategyList } from '@/app/(dashboard)/my/_api/get-my-strategy-list' import { useInfiniteQuery } from '@tanstack/react-query' +import { QUERY_KEY } from '@/shared/constants/query-key' import { StrategiesModel } from '@/shared/types/strategy-data' interface StrategiesPageModel { @@ -10,7 +11,7 @@ interface StrategiesPageModel { export const useGetMyStrategyList = () => { return useInfiniteQuery<StrategiesPageModel, Error>({ - queryKey: ['myStrategies'], + queryKey: [QUERY_KEY.MY_STRATEGIES], queryFn: async ({ pageParam = 1 }) => { const page = typeof pageParam === 'number' ? pageParam : 1 return getMyStrategyList({ page, size: 4 }) diff --git a/app/(dashboard)/my/_hooks/query/use-get-profile.ts b/app/(dashboard)/my/_hooks/query/use-get-profile.ts index 67ee9ebc..63ea4333 100644 --- a/app/(dashboard)/my/_hooks/query/use-get-profile.ts +++ b/app/(dashboard)/my/_hooks/query/use-get-profile.ts @@ -1,10 +1,12 @@ import { useQuery } from '@tanstack/react-query' +import { QUERY_KEY } from '@/shared/constants/query-key' + import { getProfile } from './../../_api/get-profile' const useGetProfile = () => { return useQuery({ - queryKey: ['userProfile'], + queryKey: [QUERY_KEY.MY_PROFILE], queryFn: getProfile, }) } diff --git a/app/(dashboard)/my/_hooks/query/use-manage-daily-analysis.ts b/app/(dashboard)/my/_hooks/query/use-manage-daily-analysis.ts index 1119cdcc..126e9372 100644 --- a/app/(dashboard)/my/_hooks/query/use-manage-daily-analysis.ts +++ b/app/(dashboard)/my/_hooks/query/use-manage-daily-analysis.ts @@ -1,5 +1,7 @@ import { useMutation, useQueryClient } from '@tanstack/react-query' +import { QUERY_KEY } from '@/shared/constants/query-key' + import { EditAnalysisPayloadModel, deleteAllAnalysis, @@ -9,7 +11,7 @@ import { export const useMyAnalysisMutation = (strategyId: number, page: number, size: number) => { const queryClient = useQueryClient() - const queryKey = ['myDailyAnalysis', strategyId, page, size] + const queryKey = [QUERY_KEY.MY_DAILY_ANALYSIS, strategyId, page, size] const { mutate: editAnalysisData } = useMutation({ mutationFn: ({ payload }: { payload: EditAnalysisPayloadModel }) => { diff --git a/app/(dashboard)/my/_hooks/query/use-patch-user-profile.ts b/app/(dashboard)/my/_hooks/query/use-patch-user-profile.ts index cb2c98f3..ab68328c 100644 --- a/app/(dashboard)/my/_hooks/query/use-patch-user-profile.ts +++ b/app/(dashboard)/my/_hooks/query/use-patch-user-profile.ts @@ -1,5 +1,7 @@ import { useMutation, useQueryClient } from '@tanstack/react-query' +import { QUERY_KEY } from '@/shared/constants/query-key' + import patchProfile from '../../_api/patch-user-profile' export interface UserProfileModel { @@ -69,11 +71,11 @@ const usePatchUserProfile = () => { } }, onSuccess: (data) => { - queryClient.invalidateQueries({ queryKey: ['userProfile'] }) + queryClient.invalidateQueries({ queryKey: [QUERY_KEY.MY_PROFILE] }) if (data.result) { const imageUrl = data.result.split('?')[0] - queryClient.setQueryData<UserProfileDataModel>(['userProfile'], (oldData) => { + queryClient.setQueryData<UserProfileDataModel>([QUERY_KEY.MY_PROFILE], (oldData) => { if (!oldData) return oldData return { ...oldData, diff --git a/app/(dashboard)/my/_hooks/query/use-post-edit-strategy.ts b/app/(dashboard)/my/_hooks/query/use-post-edit-strategy.ts index 59d9afcf..800310f7 100644 --- a/app/(dashboard)/my/_hooks/query/use-post-edit-strategy.ts +++ b/app/(dashboard)/my/_hooks/query/use-post-edit-strategy.ts @@ -1,5 +1,7 @@ import { QueryClient, useMutation } from '@tanstack/react-query' +import { QUERY_KEY } from '@/shared/constants/query-key' + import postEditStrategy, { ContentModel } from '../../_api/post-edit-strategy' const usePostEditStrategy = () => { @@ -12,7 +14,7 @@ const usePostEditStrategy = () => { mutationFn: ({ strategyId, information }: { strategyId: number; information: ContentModel }) => postEditStrategy(strategyId, information), onSuccess: (strategyId) => { - queryClient.invalidateQueries({ queryKey: ['strategyDetails', strategyId] }) + queryClient.invalidateQueries({ queryKey: [QUERY_KEY.STRATEGY_DETAILS, strategyId] }) }, }) } diff --git a/app/(dashboard)/my/_hooks/query/use-upload-account-images.ts b/app/(dashboard)/my/_hooks/query/use-upload-account-images.ts index aa7d3a76..c190fe0a 100644 --- a/app/(dashboard)/my/_hooks/query/use-upload-account-images.ts +++ b/app/(dashboard)/my/_hooks/query/use-upload-account-images.ts @@ -1,5 +1,7 @@ import { useMutation, useQueryClient } from '@tanstack/react-query' +import { QUERY_KEY } from '@/shared/constants/query-key' + import { uploadAccountImages } from '../../_api/post-account-image' interface UseUploadAccountImagesProps { @@ -40,7 +42,7 @@ export const useUploadAccountImages = ({ }, onSuccess: () => { queryClient.invalidateQueries({ - queryKey: ['myAccountImages', strategyId], + queryKey: [QUERY_KEY.MY_ACCOUNT_IMAGES, strategyId], exact: true, }) diff --git a/app/(dashboard)/my/questions/_hooks/query/use-delete-answer.ts b/app/(dashboard)/my/questions/_hooks/query/use-delete-answer.ts index 808cf3ec..475b8934 100644 --- a/app/(dashboard)/my/questions/_hooks/query/use-delete-answer.ts +++ b/app/(dashboard)/my/questions/_hooks/query/use-delete-answer.ts @@ -1,5 +1,7 @@ import { useMutation, useQueryClient } from '@tanstack/react-query' +import { QUERY_KEY } from '@/shared/constants/query-key' + import deleteAnswer, { DeleteAnswerProps } from '../../_api/delete-answer' const useDeleteAnswer = () => { @@ -9,11 +11,11 @@ const useDeleteAnswer = () => { deleteAnswer({ questionId, answerId }), onSuccess: (_, { questionId }) => { queryClient.invalidateQueries({ - queryKey: ['questionDetails', questionId], + queryKey: [QUERY_KEY.QUESTION_DETAILS, questionId], }) queryClient.invalidateQueries({ - queryKey: ['questionList'], + queryKey: [QUERY_KEY.QUESTION_LIST], }) }, }) diff --git a/app/(dashboard)/my/questions/_hooks/query/use-delete-question.ts b/app/(dashboard)/my/questions/_hooks/query/use-delete-question.ts index 85498daf..5ff10cf9 100644 --- a/app/(dashboard)/my/questions/_hooks/query/use-delete-question.ts +++ b/app/(dashboard)/my/questions/_hooks/query/use-delete-question.ts @@ -1,5 +1,7 @@ import { useMutation, useQueryClient } from '@tanstack/react-query' +import { QUERY_KEY } from '@/shared/constants/query-key' + import deleteQuestion, { DeleteQuestionProps } from '../../_api/delete-question' const useDeleteQuestion = () => { @@ -9,11 +11,11 @@ const useDeleteQuestion = () => { deleteQuestion({ questionId, strategyId }), onSuccess: (_, { questionId }) => { queryClient.invalidateQueries({ - queryKey: ['questionDetails', questionId], + queryKey: [QUERY_KEY.QUESTION_DETAILS, questionId], }) queryClient.invalidateQueries({ - queryKey: ['questionList'], + queryKey: [QUERY_KEY.QUESTION_LIST], }) }, }) diff --git a/app/(dashboard)/my/questions/_hooks/query/use-get-my-question-list.ts b/app/(dashboard)/my/questions/_hooks/query/use-get-my-question-list.ts index 2f84b48d..72bd0fdd 100644 --- a/app/(dashboard)/my/questions/_hooks/query/use-get-my-question-list.ts +++ b/app/(dashboard)/my/questions/_hooks/query/use-get-my-question-list.ts @@ -1,5 +1,6 @@ import { useQuery } from '@tanstack/react-query' +import { QUERY_KEY } from '@/shared/constants/query-key' import { UserType } from '@/shared/types/auth' import { QuestionSearchOptionsModel } from '@/shared/types/questions' @@ -14,7 +15,7 @@ interface Props { const useGetMyQuestionList = ({ page, size, userType, options }: Props) => { return useQuery({ - queryKey: ['questionList', page, size, options], + queryKey: [QUERY_KEY.QUESTION_LIST, page, size, options], queryFn: () => { const { keyword, searchCondition, stateCondition } = options return getMyQuestionList({ diff --git a/app/(dashboard)/my/questions/_hooks/query/use-get-question-details.ts b/app/(dashboard)/my/questions/_hooks/query/use-get-question-details.ts index 06f737ae..88847d6f 100644 --- a/app/(dashboard)/my/questions/_hooks/query/use-get-question-details.ts +++ b/app/(dashboard)/my/questions/_hooks/query/use-get-question-details.ts @@ -1,5 +1,7 @@ import { useQuery } from '@tanstack/react-query' +import { QUERY_KEY } from '@/shared/constants/query-key' + import getQuestionDetails from '../../_api/get-question-details' interface Props { @@ -8,7 +10,7 @@ interface Props { const useGetQuestionDetails = ({ questionId }: Props) => { return useQuery({ - queryKey: ['questionDetails', questionId], + queryKey: [QUERY_KEY.QUESTION_DETAILS, questionId], queryFn: () => getQuestionDetails({ questionId }), }) } diff --git a/app/(dashboard)/my/questions/_hooks/query/use-post-answer.ts b/app/(dashboard)/my/questions/_hooks/query/use-post-answer.ts index 0d3a64af..c6e2acb7 100644 --- a/app/(dashboard)/my/questions/_hooks/query/use-post-answer.ts +++ b/app/(dashboard)/my/questions/_hooks/query/use-post-answer.ts @@ -1,5 +1,7 @@ import { useMutation, useQueryClient } from '@tanstack/react-query' +import { QUERY_KEY } from '@/shared/constants/query-key' + import postAnswer from '../../_api/post-answer' const usePostAnswer = (questionId: number) => { @@ -8,11 +10,11 @@ const usePostAnswer = (questionId: number) => { mutationFn: (content: string) => postAnswer(questionId, content), onSuccess: () => { queryClient.invalidateQueries({ - queryKey: ['questionDetails', questionId], + queryKey: [QUERY_KEY.QUESTION_DETAILS, questionId], }) queryClient.invalidateQueries({ - queryKey: ['questionList'], + queryKey: [QUERY_KEY.QUESTION_LIST], }) }, }) diff --git a/app/(dashboard)/strategies/[strategyId]/_hooks/query/use-delete-review.ts b/app/(dashboard)/strategies/[strategyId]/_hooks/query/use-delete-review.ts index 1b9db669..48ba0546 100644 --- a/app/(dashboard)/strategies/[strategyId]/_hooks/query/use-delete-review.ts +++ b/app/(dashboard)/strategies/[strategyId]/_hooks/query/use-delete-review.ts @@ -1,5 +1,7 @@ import { useMutation, useQueryClient } from '@tanstack/react-query' +import { QUERY_KEY } from '@/shared/constants/query-key' + import deleteReview from '../../_api/delete-review' const useDeleteReview = (strategyId: number) => { @@ -8,7 +10,7 @@ const useDeleteReview = (strategyId: number) => { mutationFn: ({ strategyId, reviewId }: { strategyId: number; reviewId: number }) => deleteReview(strategyId, reviewId), onSuccess: () => { - queryClient.invalidateQueries({ queryKey: ['reviews', strategyId] }) + queryClient.invalidateQueries({ queryKey: [QUERY_KEY.STRATEGY_REVIEWS, strategyId] }) }, }) } diff --git a/app/(dashboard)/strategies/[strategyId]/_hooks/query/use-get-account-images.ts b/app/(dashboard)/strategies/[strategyId]/_hooks/query/use-get-account-images.ts index d84e32d6..8c94edab 100644 --- a/app/(dashboard)/strategies/[strategyId]/_hooks/query/use-get-account-images.ts +++ b/app/(dashboard)/strategies/[strategyId]/_hooks/query/use-get-account-images.ts @@ -1,10 +1,12 @@ import { useQuery } from '@tanstack/react-query' +import { QUERY_KEY } from '@/shared/constants/query-key' + import getAccountImages from '../../_api/get-account-images' const useGetAccountImages = (strategyId: number) => { return useQuery({ - queryKey: ['account-images', strategyId], + queryKey: [QUERY_KEY.STRATEGY_ACCOUNT_IMAGES, strategyId], queryFn: () => getAccountImages(strategyId), }) } diff --git a/app/(dashboard)/strategies/[strategyId]/_hooks/query/use-get-analysis-chart.ts b/app/(dashboard)/strategies/[strategyId]/_hooks/query/use-get-analysis-chart.ts index 4f913c85..6d057263 100644 --- a/app/(dashboard)/strategies/[strategyId]/_hooks/query/use-get-analysis-chart.ts +++ b/app/(dashboard)/strategies/[strategyId]/_hooks/query/use-get-analysis-chart.ts @@ -1,6 +1,8 @@ import { AnalysisChartOptionsType } from '@/app/(dashboard)/_ui/analysis-container' import { useQuery } from '@tanstack/react-query' +import { QUERY_KEY } from '@/shared/constants/query-key' + import getAnalysisChart from '../../_api/get-analysis-chart' interface Props { @@ -11,7 +13,7 @@ interface Props { const useGetAnalysisChart = ({ strategyId, firstOption, secondOption }: Props) => { return useQuery({ - queryKey: ['analysisChart', strategyId, firstOption, secondOption], + queryKey: [QUERY_KEY.STRATEGY_ANALYSIS_CHART, strategyId, firstOption, secondOption], queryFn: () => getAnalysisChart(strategyId, firstOption, secondOption), }) } diff --git a/app/(dashboard)/strategies/[strategyId]/_hooks/query/use-get-analysis.ts b/app/(dashboard)/strategies/[strategyId]/_hooks/query/use-get-analysis.ts index 44208c6d..70d7fe62 100644 --- a/app/(dashboard)/strategies/[strategyId]/_hooks/query/use-get-analysis.ts +++ b/app/(dashboard)/strategies/[strategyId]/_hooks/query/use-get-analysis.ts @@ -1,11 +1,13 @@ import { AnalysisTabType } from '@/app/(dashboard)/_ui/analysis-container/tabs-width-table' import { useQuery } from '@tanstack/react-query' +import { QUERY_KEY } from '@/shared/constants/query-key' + import getAnalysis from '../../_api/get-analysis' const useGetAnalysis = (strategyId: number, type: AnalysisTabType, page: number, size: number) => { return useQuery({ - queryKey: ['analysis', strategyId, type, page], + queryKey: [QUERY_KEY.STRATEGY_ANALYSIS, strategyId, type, page], queryFn: () => getAnalysis(strategyId, type, page, size), }) } diff --git a/app/(dashboard)/strategies/[strategyId]/_hooks/query/use-get-details-information-data.ts b/app/(dashboard)/strategies/[strategyId]/_hooks/query/use-get-details-information-data.ts index 87f4d668..5da9f8a8 100644 --- a/app/(dashboard)/strategies/[strategyId]/_hooks/query/use-get-details-information-data.ts +++ b/app/(dashboard)/strategies/[strategyId]/_hooks/query/use-get-details-information-data.ts @@ -1,5 +1,6 @@ import { UseQueryResult, useQuery } from '@tanstack/react-query' +import { QUERY_KEY } from '@/shared/constants/query-key' import { StrategyDetailsInformationModel } from '@/shared/types/strategy-data' import getDetailsInformation from '../../_api/get-details-information' @@ -16,7 +17,7 @@ const useGetDetailsInformationData = ({ detailsInformationData: StrategyDetailsInformationModel }> => { return useQuery({ - queryKey: ['strategyDetails', strategyId], + queryKey: [QUERY_KEY.STRATEGY_DETAILS, strategyId], queryFn: () => getDetailsInformation(strategyId), }) } diff --git a/app/(dashboard)/strategies/[strategyId]/_hooks/query/use-get-reviews-data.ts b/app/(dashboard)/strategies/[strategyId]/_hooks/query/use-get-reviews-data.ts index dec69f32..3595e636 100644 --- a/app/(dashboard)/strategies/[strategyId]/_hooks/query/use-get-reviews-data.ts +++ b/app/(dashboard)/strategies/[strategyId]/_hooks/query/use-get-reviews-data.ts @@ -1,5 +1,7 @@ import { useQuery } from '@tanstack/react-query' +import { QUERY_KEY } from '@/shared/constants/query-key' + import getReviews from '../../_api/get-reviews' interface Props { @@ -9,7 +11,7 @@ interface Props { const useGetReviewsData = ({ strategyId, page }: Props) => { return useQuery({ - queryKey: ['reviews', strategyId], + queryKey: [QUERY_KEY.STRATEGY_REVIEWS, strategyId], queryFn: () => getReviews(strategyId, page), }) } diff --git a/app/(dashboard)/strategies/[strategyId]/_hooks/query/use-get-statistics.ts b/app/(dashboard)/strategies/[strategyId]/_hooks/query/use-get-statistics.ts index b633062d..d47bbebc 100644 --- a/app/(dashboard)/strategies/[strategyId]/_hooks/query/use-get-statistics.ts +++ b/app/(dashboard)/strategies/[strategyId]/_hooks/query/use-get-statistics.ts @@ -1,10 +1,12 @@ import { useQuery } from '@tanstack/react-query' +import { QUERY_KEY } from '@/shared/constants/query-key' + import getStatistics from '../../_api/get-statistics' const useGetStatistics = (strategyId: number) => { return useQuery({ - queryKey: ['statistics', strategyId], + queryKey: [QUERY_KEY.STRATEGY_STATISTICS, strategyId], queryFn: () => getStatistics(strategyId), }) } diff --git a/app/(dashboard)/strategies/[strategyId]/_hooks/query/use-patch-review.ts b/app/(dashboard)/strategies/[strategyId]/_hooks/query/use-patch-review.ts index cc4f9b3a..31e23067 100644 --- a/app/(dashboard)/strategies/[strategyId]/_hooks/query/use-patch-review.ts +++ b/app/(dashboard)/strategies/[strategyId]/_hooks/query/use-patch-review.ts @@ -1,5 +1,7 @@ import { useMutation, useQueryClient } from '@tanstack/react-query' +import { QUERY_KEY } from '@/shared/constants/query-key' + import patchReview from '../../_api/patch-review' const usePatchReview = (strategyId: number) => { @@ -15,7 +17,7 @@ const usePatchReview = (strategyId: number) => { content: { content: string; starRating: number } }) => patchReview(strategyId, reviewId, content), onSuccess: () => { - queryClient.invalidateQueries({ queryKey: ['reviews', strategyId] }) + queryClient.invalidateQueries({ queryKey: [QUERY_KEY.STRATEGY_REVIEWS, strategyId] }) }, }) } diff --git a/app/(dashboard)/strategies/[strategyId]/_hooks/query/use-post-question.ts b/app/(dashboard)/strategies/[strategyId]/_hooks/query/use-post-question.ts index aa4f4d99..15ae8f78 100644 --- a/app/(dashboard)/strategies/[strategyId]/_hooks/query/use-post-question.ts +++ b/app/(dashboard)/strategies/[strategyId]/_hooks/query/use-post-question.ts @@ -1,5 +1,7 @@ import { useMutation, useQueryClient } from '@tanstack/react-query' +import { QUERY_KEY } from '@/shared/constants/query-key' + import postQuestions from '../../_api/post-question' interface Props { @@ -15,7 +17,7 @@ const usePostQuestion = () => { mutationFn: ({ strategyId, title, content }: Props) => postQuestions(strategyId, title, content), onSuccess: () => { - queryClient.invalidateQueries({ queryKey: ['questionList'] }) + queryClient.invalidateQueries({ queryKey: [QUERY_KEY.QUESTION_LIST] }) }, }) } diff --git a/app/(dashboard)/strategies/[strategyId]/_hooks/query/use-post-review.ts b/app/(dashboard)/strategies/[strategyId]/_hooks/query/use-post-review.ts index 668e16b6..670e99a1 100644 --- a/app/(dashboard)/strategies/[strategyId]/_hooks/query/use-post-review.ts +++ b/app/(dashboard)/strategies/[strategyId]/_hooks/query/use-post-review.ts @@ -1,5 +1,7 @@ import { useMutation, useQueryClient } from '@tanstack/react-query' +import { QUERY_KEY } from '@/shared/constants/query-key' + import postReview from '../../_api/post-review' export interface PostReviewErrModel { @@ -23,7 +25,7 @@ const usePostReview = (strategyId: number) => { content: { content: string; starRating: number } }) => postReview(strategyId, content), onSuccess: () => { - queryClient.invalidateQueries({ queryKey: ['reviews', strategyId] }) + queryClient.invalidateQueries({ queryKey: [QUERY_KEY.STRATEGY_REVIEWS, strategyId] }) }, }) } diff --git a/app/(dashboard)/strategies/_hooks/query/use-get-strategies-search.ts b/app/(dashboard)/strategies/_hooks/query/use-get-strategies-search.ts index 77574220..38b2e7db 100644 --- a/app/(dashboard)/strategies/_hooks/query/use-get-strategies-search.ts +++ b/app/(dashboard)/strategies/_hooks/query/use-get-strategies-search.ts @@ -1,10 +1,12 @@ import { useQuery } from '@tanstack/react-query' +import { QUERY_KEY } from '@/shared/constants/query-key' + import getStrategiesSearch from '../../_api/get-strategies-search' const useGetStrategiesSearch = () => { return useQuery({ - queryKey: ['strategiesSearch'], + queryKey: [QUERY_KEY.STRATEGY_SEARCH], queryFn: getStrategiesSearch, }) } diff --git a/app/(dashboard)/strategies/_hooks/query/use-get-subscribe.ts b/app/(dashboard)/strategies/_hooks/query/use-get-subscribe.ts index fd00acca..7f2fca89 100644 --- a/app/(dashboard)/strategies/_hooks/query/use-get-subscribe.ts +++ b/app/(dashboard)/strategies/_hooks/query/use-get-subscribe.ts @@ -1,5 +1,7 @@ import { useMutation, useQueryClient } from '@tanstack/react-query' +import { QUERY_KEY } from '@/shared/constants/query-key' + import getSubscribe from '../../_api/get-subscribe' const useGetSubscribe = () => { @@ -8,7 +10,7 @@ const useGetSubscribe = () => { return useMutation({ mutationFn: (strategyId: number) => getSubscribe(strategyId), onSuccess: () => { - queryClient.invalidateQueries({ queryKey: ['favoriteStrategies'] }) + queryClient.invalidateQueries({ queryKey: [QUERY_KEY.MY_FAVORITE_STRATEGIES] }) }, }) } diff --git a/app/(dashboard)/strategies/_hooks/query/use-post-strategies.ts b/app/(dashboard)/strategies/_hooks/query/use-post-strategies.ts index 75bd4e53..e27452a0 100644 --- a/app/(dashboard)/strategies/_hooks/query/use-post-strategies.ts +++ b/app/(dashboard)/strategies/_hooks/query/use-post-strategies.ts @@ -1,5 +1,7 @@ import { useQuery } from '@tanstack/react-query' +import { QUERY_KEY } from '@/shared/constants/query-key' + import postStrategies from '../../_api/post-strategies' import { SearchTermsModel } from '../../_ui/search-bar/_type/search' @@ -13,7 +15,7 @@ const usePostStrategies = ({ searchTerms: SearchTermsModel }) => { return useQuery({ - queryKey: ['strategies', page, size], + queryKey: [QUERY_KEY.STRATEGIES, page, size], queryFn: () => postStrategies(page, size, searchTerms), staleTime: 0, }) diff --git a/app/(dashboard)/traders/_hooks/use-get-trader-details.ts b/app/(dashboard)/traders/_hooks/use-get-trader-details.ts index 3193d3b6..527aca37 100644 --- a/app/(dashboard)/traders/_hooks/use-get-trader-details.ts +++ b/app/(dashboard)/traders/_hooks/use-get-trader-details.ts @@ -1,5 +1,7 @@ import { useQuery } from '@tanstack/react-query' +import { QUERY_KEY } from '@/shared/constants/query-key' + import getTraderStrategies from '../_api/get-trader-details' interface Props { @@ -8,7 +10,7 @@ interface Props { const useGetTraderStrategies = ({ traderId }: Props) => { return useQuery({ - queryKey: ['trader-strategies', traderId], + queryKey: [QUERY_KEY.TRADER_STRATEGIES, traderId], queryFn: () => getTraderStrategies({ traderId }), enabled: !!traderId, }) diff --git a/app/(dashboard)/traders/_hooks/use-get-trader-profile.ts b/app/(dashboard)/traders/_hooks/use-get-trader-profile.ts index 846922f0..b3015299 100644 --- a/app/(dashboard)/traders/_hooks/use-get-trader-profile.ts +++ b/app/(dashboard)/traders/_hooks/use-get-trader-profile.ts @@ -1,10 +1,12 @@ import { useQuery } from '@tanstack/react-query' +import { QUERY_KEY } from '@/shared/constants/query-key' + import { getTraderProfile } from '../_api/get-trader-profile' const useGetTraderProfile = (traderId: number) => { return useQuery({ - queryKey: ['traders', traderId], + queryKey: [QUERY_KEY.TRADERS, traderId], queryFn: () => getTraderProfile(traderId), }) } diff --git a/app/(dashboard)/traders/_hooks/use-get-traders.ts b/app/(dashboard)/traders/_hooks/use-get-traders.ts index d03ce64c..fff1f3c0 100644 --- a/app/(dashboard)/traders/_hooks/use-get-traders.ts +++ b/app/(dashboard)/traders/_hooks/use-get-traders.ts @@ -1,10 +1,12 @@ import { useQuery } from '@tanstack/react-query' +import { QUERY_KEY } from '@/shared/constants/query-key' + import { TradersParamsModel, getTraders } from '../_api/get-traders' const useGetTraders = ({ page, size, keyword, orderBy }: TradersParamsModel) => { return useQuery({ - queryKey: ['traders', page, size, keyword, orderBy], + queryKey: [QUERY_KEY.TRADERS, page, size, keyword, orderBy], queryFn: () => getTraders({ page, size, keyword, orderBy }), }) } diff --git a/app/(landing)/(home)/_hooks/query/use-get-strategies-metrics.ts b/app/(landing)/(home)/_hooks/query/use-get-strategies-metrics.ts index 34e94629..9116b20d 100644 --- a/app/(landing)/(home)/_hooks/query/use-get-strategies-metrics.ts +++ b/app/(landing)/(home)/_hooks/query/use-get-strategies-metrics.ts @@ -1,11 +1,13 @@ import { useQuery } from '@tanstack/react-query' +import { QUERY_KEY } from '@/shared/constants/query-key' + import { getStrategiesMetrics } from '../../_api/strategies-metrics' import { AverageMetricsChartDataModel } from '../../_ui/average-metrics-section/average-metrics-chart' const useGetStrategiesMetrics = () => { return useQuery<AverageMetricsChartDataModel>({ - queryKey: ['totalStrategiesMetrics'], + queryKey: [QUERY_KEY.TOTAL_STRATEGIES_MATRICS], queryFn: getStrategiesMetrics, }) } diff --git a/app/(landing)/(home)/_hooks/query/use-get-top-ranking-smscore.ts b/app/(landing)/(home)/_hooks/query/use-get-top-ranking-smscore.ts index 1b632f85..f62e7203 100644 --- a/app/(landing)/(home)/_hooks/query/use-get-top-ranking-smscore.ts +++ b/app/(landing)/(home)/_hooks/query/use-get-top-ranking-smscore.ts @@ -1,12 +1,13 @@ import { useQuery } from '@tanstack/react-query' +import { QUERY_KEY } from '@/shared/constants/query-key' import { StrategyCardModel } from '@/shared/types/strategy-data' import { getTopRankingSmScore } from '../../_api/top-strategies' const useGetTopRankingSmScore = () => { return useQuery<StrategyCardModel[]>({ - queryKey: ['topRankingSmScore'], + queryKey: [QUERY_KEY.TOP_RANKING_SM_SCORE], queryFn: getTopRankingSmScore, }) } diff --git a/app/(landing)/(home)/_hooks/query/use-get-top-ranking.ts b/app/(landing)/(home)/_hooks/query/use-get-top-ranking.ts index 2f8f3429..351fd5b2 100644 --- a/app/(landing)/(home)/_hooks/query/use-get-top-ranking.ts +++ b/app/(landing)/(home)/_hooks/query/use-get-top-ranking.ts @@ -1,12 +1,13 @@ import { useQuery } from '@tanstack/react-query' +import { QUERY_KEY } from '@/shared/constants/query-key' import { StrategyCardModel } from '@/shared/types/strategy-data' import { getTopRanking } from '../../_api/top-strategies' const useGetTopRanking = () => { return useQuery<StrategyCardModel[]>({ - queryKey: ['topRanking'], + queryKey: [QUERY_KEY.TOP_RANKING], queryFn: getTopRanking, }) } diff --git a/app/(landing)/(home)/_hooks/query/use-get-user-metrics.ts b/app/(landing)/(home)/_hooks/query/use-get-user-metrics.ts index 2368a017..2b996d46 100644 --- a/app/(landing)/(home)/_hooks/query/use-get-user-metrics.ts +++ b/app/(landing)/(home)/_hooks/query/use-get-user-metrics.ts @@ -1,11 +1,13 @@ import { useQuery } from '@tanstack/react-query' +import { QUERY_KEY } from '@/shared/constants/query-key' + import { getUserMetrics } from '../../_api/user-metrics' import { UserMetricsModel } from '../../types' const useGetUserMetrics = () => { return useQuery<UserMetricsModel>({ - queryKey: ['userMetrics'], + queryKey: [QUERY_KEY.USER_METRICS], queryFn: getUserMetrics, }) } diff --git a/app/(landing)/notices/_hooks/use-notice-detail.ts b/app/(landing)/notices/_hooks/use-notice-detail.ts index d64535eb..c5711c09 100644 --- a/app/(landing)/notices/_hooks/use-notice-detail.ts +++ b/app/(landing)/notices/_hooks/use-notice-detail.ts @@ -1,10 +1,12 @@ import { useQuery } from '@tanstack/react-query' +import { QUERY_KEY } from '@/shared/constants/query-key' + import { getNoticeDetail } from '../_api/get-notice-detail' const useNoticeDetail = (noticeId: number) => { return useQuery({ - queryKey: ['noticeDetail', noticeId], + queryKey: [QUERY_KEY.NOTICE_DETAIL, noticeId], queryFn: () => getNoticeDetail(noticeId), }) } diff --git a/app/(landing)/notices/_hooks/use-notice.ts b/app/(landing)/notices/_hooks/use-notice.ts index 5c01c37e..49d44e0a 100644 --- a/app/(landing)/notices/_hooks/use-notice.ts +++ b/app/(landing)/notices/_hooks/use-notice.ts @@ -1,5 +1,7 @@ import { useQuery } from '@tanstack/react-query' +import { QUERY_KEY } from '@/shared/constants/query-key' + import getNotices from '../_api/get-notice' interface Props { @@ -9,7 +11,7 @@ interface Props { const useGetNotices = ({ page, size }: Props = {}) => { return useQuery({ - queryKey: ['notices', page, size], + queryKey: [QUERY_KEY.NOTICES, page, size], queryFn: () => getNotices({ page, size }), }) } diff --git a/app/admin/category/_ui/stock/stock-manage/_hooks/query/use-delete-inactive-stock.ts b/app/admin/category/_ui/stock/stock-manage/_hooks/query/use-delete-inactive-stock.ts index c5b2a0b7..f3460fe7 100644 --- a/app/admin/category/_ui/stock/stock-manage/_hooks/query/use-delete-inactive-stock.ts +++ b/app/admin/category/_ui/stock/stock-manage/_hooks/query/use-delete-inactive-stock.ts @@ -1,5 +1,7 @@ import { useMutation, useQueryClient } from '@tanstack/react-query' +import { QUERY_KEY } from '@/shared/constants/query-key' + import deleteInactiveStock from '../../_api/delete-inactive-stock' const useDeleteInactiveStock = (stockTypeId: number) => { @@ -9,7 +11,7 @@ const useDeleteInactiveStock = (stockTypeId: number) => { mutationFn: () => deleteInactiveStock(stockTypeId), onSuccess: () => { queryClient.invalidateQueries({ - queryKey: ['adminStocks'], + queryKey: [QUERY_KEY.ADMIN_STOCKS], }) }, onError: (err) => { diff --git a/app/admin/category/_ui/stock/stock-manage/_hooks/query/use-stocks-data.ts b/app/admin/category/_ui/stock/stock-manage/_hooks/query/use-stocks-data.ts index 840f2bae..9b1a12de 100644 --- a/app/admin/category/_ui/stock/stock-manage/_hooks/query/use-stocks-data.ts +++ b/app/admin/category/_ui/stock/stock-manage/_hooks/query/use-stocks-data.ts @@ -1,5 +1,7 @@ import { useQuery } from '@tanstack/react-query' +import { QUERY_KEY } from '@/shared/constants/query-key' + import getStocks from '../../_api/get-stocks' type ArgType = 'active' | 'inactive' @@ -8,7 +10,7 @@ const useStocksData = (activateState: ArgType, page: number = 1, size: number = const isActive = activateState === 'active' ? true : false return useQuery({ - queryKey: ['adminStocks', activateState, page, size], + queryKey: [QUERY_KEY.ADMIN_STOCKS, activateState, page, size], queryFn: () => getStocks(isActive, page, size), }) } diff --git a/app/admin/category/_ui/stock/stock-manage/_hooks/query/use-toggle-stock-active-state.ts b/app/admin/category/_ui/stock/stock-manage/_hooks/query/use-toggle-stock-active-state.ts index d190e1dc..f2944a12 100644 --- a/app/admin/category/_ui/stock/stock-manage/_hooks/query/use-toggle-stock-active-state.ts +++ b/app/admin/category/_ui/stock/stock-manage/_hooks/query/use-toggle-stock-active-state.ts @@ -1,5 +1,7 @@ import { useMutation, useQueryClient } from '@tanstack/react-query' +import { QUERY_KEY } from '@/shared/constants/query-key' + import ToggleStockActiveState from '../../_api/toggle-stock-active-state' const useToggoleStockActiveState = (stockTypeId: number) => { @@ -9,7 +11,7 @@ const useToggoleStockActiveState = (stockTypeId: number) => { mutationFn: () => ToggleStockActiveState(stockTypeId), onSuccess: () => { queryClient.invalidateQueries({ - queryKey: ['adminStocks'], + queryKey: [QUERY_KEY.ADMIN_STOCKS], }) }, onError: (err) => { diff --git a/app/admin/category/_ui/stock/stock-manage/_ui/stock-post-button/index.tsx b/app/admin/category/_ui/stock/stock-manage/_ui/stock-post-button/index.tsx index 81fb854e..845a2d9b 100644 --- a/app/admin/category/_ui/stock/stock-manage/_ui/stock-post-button/index.tsx +++ b/app/admin/category/_ui/stock/stock-manage/_ui/stock-post-button/index.tsx @@ -7,6 +7,7 @@ import { RegisterIcon } from '@/public/icons' import { useQueryClient } from '@tanstack/react-query' import classNames from 'classnames/bind' +import { QUERY_KEY } from '@/shared/constants/query-key' import useModal from '@/shared/hooks/custom/use-modal' import { Button } from '@/shared/ui/button' import Input from '@/shared/ui/input' @@ -35,7 +36,7 @@ const StockPostButton = () => { try { await onSubmit(e) closeModal() - queryClient.invalidateQueries({ queryKey: ['adminStocks'] }) + queryClient.invalidateQueries({ queryKey: [QUERY_KEY.ADMIN_STOCKS] }) } catch (err) { console.error('Error : ' + err) } diff --git a/app/admin/category/_ui/trade/trade-manage/_hooks/query/use-toggle-trade-active-state.ts b/app/admin/category/_ui/trade/trade-manage/_hooks/query/use-toggle-trade-active-state.ts index c48878f5..909c2777 100644 --- a/app/admin/category/_ui/trade/trade-manage/_hooks/query/use-toggle-trade-active-state.ts +++ b/app/admin/category/_ui/trade/trade-manage/_hooks/query/use-toggle-trade-active-state.ts @@ -1,5 +1,7 @@ import { useMutation, useQueryClient } from '@tanstack/react-query' +import { QUERY_KEY } from '@/shared/constants/query-key' + import ToggleTradeActiveState from '../../_api/toggle-trade-active-state' const useToggoleTradeActiveState = (tradeTypeId: number) => { @@ -9,7 +11,7 @@ const useToggoleTradeActiveState = (tradeTypeId: number) => { mutationFn: () => ToggleTradeActiveState(tradeTypeId), onSuccess: () => { queryClient.invalidateQueries({ - queryKey: ['adminTrades'], + queryKey: [QUERY_KEY.ADMIN_TRADES], }) }, onError: (err) => { diff --git a/app/admin/category/_ui/trade/trade-manage/_hooks/query/use-trades-data.ts b/app/admin/category/_ui/trade/trade-manage/_hooks/query/use-trades-data.ts index 7d32530d..c905f6a9 100644 --- a/app/admin/category/_ui/trade/trade-manage/_hooks/query/use-trades-data.ts +++ b/app/admin/category/_ui/trade/trade-manage/_hooks/query/use-trades-data.ts @@ -1,5 +1,7 @@ import { useQuery } from '@tanstack/react-query' +import { QUERY_KEY } from '@/shared/constants/query-key' + import getTrades from '../../_api/get-trades' type ArgType = 'active' | 'inactive' @@ -8,7 +10,7 @@ const useTradeData = (activateState: ArgType) => { const isActive = activateState === 'active' ? true : false return useQuery({ - queryKey: ['adminTrades', activateState], + queryKey: [QUERY_KEY.ADMIN_TRADES, activateState], queryFn: () => getTrades(isActive), }) } diff --git a/app/admin/category/_ui/trade/trade-manage/_ui/trade-post-button/index.tsx b/app/admin/category/_ui/trade/trade-manage/_ui/trade-post-button/index.tsx index 06d04600..c8be5415 100644 --- a/app/admin/category/_ui/trade/trade-manage/_ui/trade-post-button/index.tsx +++ b/app/admin/category/_ui/trade/trade-manage/_ui/trade-post-button/index.tsx @@ -7,6 +7,7 @@ import { RegisterIcon } from '@/public/icons' import { useQueryClient } from '@tanstack/react-query' import classNames from 'classnames/bind' +import { QUERY_KEY } from '@/shared/constants/query-key' import useModal from '@/shared/hooks/custom/use-modal' import { Button } from '@/shared/ui/button' import Input from '@/shared/ui/input' @@ -30,7 +31,7 @@ const TradePostButton = () => { try { await onSubmit(e) closeModal() - queryClient.invalidateQueries({ queryKey: ['adminTrades'] }) + queryClient.invalidateQueries({ queryKey: [QUERY_KEY.ADMIN_TRADES] }) } catch (err) { console.error('Error : ' + err) } diff --git a/app/admin/notices/[noticeId]/edit/_hooks/query/use-patch-notice.ts b/app/admin/notices/[noticeId]/edit/_hooks/query/use-patch-notice.ts index f3047931..d3c416dd 100644 --- a/app/admin/notices/[noticeId]/edit/_hooks/query/use-patch-notice.ts +++ b/app/admin/notices/[noticeId]/edit/_hooks/query/use-patch-notice.ts @@ -4,6 +4,7 @@ import { NoticeFormModel } from '@/app/admin/notices/types' import { useMutation, useQueryClient } from '@tanstack/react-query' import axiosInstance from '@/shared/api/axios' +import { QUERY_KEY } from '@/shared/constants/query-key' import uploadFileWithPresignedUrl from '@/shared/utils/upload-file-with-presigned-url' import { PatchNoticeResponeseModel } from '../../types' @@ -34,7 +35,7 @@ const usePatchNotice = (formData: NoticeFormModel, noticeId: string) => { }, onSuccess: () => { queryClient.invalidateQueries({ - queryKey: ['notices'], + queryKey: [QUERY_KEY.NOTICES], }) router.replace('/admin/notices') }, diff --git a/app/admin/notices/_hook/query/get-admin-notices.ts b/app/admin/notices/_hook/query/get-admin-notices.ts index 6f68de43..1fd8516b 100644 --- a/app/admin/notices/_hook/query/get-admin-notices.ts +++ b/app/admin/notices/_hook/query/get-admin-notices.ts @@ -1,6 +1,8 @@ import getNotices from '@/app/(landing)/notices/_api/get-notice' import { useQuery } from '@tanstack/react-query' +import { QUERY_KEY } from '@/shared/constants/query-key' + interface Props { page?: number size?: number @@ -8,7 +10,7 @@ interface Props { const useAdminNotices = ({ page, size }: Props = {}) => { return useQuery({ - queryKey: ['notices', page, size], + queryKey: [QUERY_KEY.NOTICES, page, size], queryFn: () => getNotices({ page, size }), }) } diff --git a/app/admin/notices/_hook/query/use-delete-notice.ts b/app/admin/notices/_hook/query/use-delete-notice.ts index 602c4744..14012b91 100644 --- a/app/admin/notices/_hook/query/use-delete-notice.ts +++ b/app/admin/notices/_hook/query/use-delete-notice.ts @@ -1,5 +1,7 @@ import { useMutation, useQueryClient } from '@tanstack/react-query' +import { QUERY_KEY } from '@/shared/constants/query-key' + import deleteNotice from '../../_api/delete-notice' const useDeleteNotice = (noticeId: number) => { @@ -8,7 +10,7 @@ const useDeleteNotice = (noticeId: number) => { return useMutation({ mutationFn: () => deleteNotice(noticeId), onSuccess: () => { - queryClient.invalidateQueries({ queryKey: ['notices'] }) + queryClient.invalidateQueries({ queryKey: [QUERY_KEY.NOTICES] }) }, }) } diff --git a/app/admin/notices/post/_hooks/query/use-post-notice.ts b/app/admin/notices/post/_hooks/query/use-post-notice.ts index b4919961..13a6875a 100644 --- a/app/admin/notices/post/_hooks/query/use-post-notice.ts +++ b/app/admin/notices/post/_hooks/query/use-post-notice.ts @@ -3,6 +3,7 @@ import { useRouter } from 'next/navigation' import { useMutation, useQueryClient } from '@tanstack/react-query' import axiosInstance from '@/shared/api/axios' +import { QUERY_KEY } from '@/shared/constants/query-key' import uploadFileWithPresignedUrl from '@/shared/utils/upload-file-with-presigned-url' import { NoticeFormModel } from '../../../types' @@ -34,7 +35,7 @@ const usePostNotice = () => { }, onSuccess: () => { queryClient.invalidateQueries({ - queryKey: ['notices'], + queryKey: [QUERY_KEY.NOTICES], }) router.replace('/admin/notices') }, diff --git a/app/admin/questions/_hooks/query/use-admin-questions.ts b/app/admin/questions/_hooks/query/use-admin-questions.ts index c0c47688..6ed6ecfe 100644 --- a/app/admin/questions/_hooks/query/use-admin-questions.ts +++ b/app/admin/questions/_hooks/query/use-admin-questions.ts @@ -1,5 +1,6 @@ import { useQuery } from '@tanstack/react-query' +import { QUERY_KEY } from '@/shared/constants/query-key' import { QuestionSearchConditionType, QuestionStateTapType } from '@/shared/types/questions' import getAdminQuestions from '../../_api/get-admin-questions' @@ -20,7 +21,7 @@ const useAdminQuestions = ({ size = 10, }: ArgModel) => { return useQuery({ - queryKey: ['adminQuestions', [keyword, searchCondition, stateCondition, page, size]], + queryKey: [QUERY_KEY.ADMIN_QUESTIONS, [keyword, searchCondition, stateCondition, page, size]], queryFn: () => getAdminQuestions({ keyword, diff --git a/app/admin/questions/_hooks/query/use-delete-question.ts b/app/admin/questions/_hooks/query/use-delete-question.ts index 358470da..3416c1e0 100644 --- a/app/admin/questions/_hooks/query/use-delete-question.ts +++ b/app/admin/questions/_hooks/query/use-delete-question.ts @@ -1,5 +1,7 @@ import { useMutation, useQueryClient } from '@tanstack/react-query' +import { QUERY_KEY } from '@/shared/constants/query-key' + import deleteAdminQuestion from '../../_api/delete-admin-question' interface ArgModel { @@ -11,7 +13,7 @@ const useDeleteQuestion = ({ strategyId, questionId }: ArgModel) => { return useMutation({ mutationFn: () => deleteAdminQuestion({ strategyId, questionId }), onSuccess: () => { - queryClient.invalidateQueries({ queryKey: ['adminQuestions'] }) + queryClient.invalidateQueries({ queryKey: [QUERY_KEY.ADMIN_QUESTIONS] }) }, }) } diff --git a/app/admin/strategies/_hooks/query/use-admin-delete-strategy.ts b/app/admin/strategies/_hooks/query/use-admin-delete-strategy.ts index 93de62d8..8bbf8572 100644 --- a/app/admin/strategies/_hooks/query/use-admin-delete-strategy.ts +++ b/app/admin/strategies/_hooks/query/use-admin-delete-strategy.ts @@ -1,6 +1,8 @@ import { deleteMyStrategy } from '@/app/(dashboard)/my/_api/delete-my-strategy' import { useMutation, useQueryClient } from '@tanstack/react-query' +import { QUERY_KEY } from '@/shared/constants/query-key' + const useDeleteAdminStrategy = (strategyId: number) => { const queryClient = useQueryClient() @@ -8,7 +10,7 @@ const useDeleteAdminStrategy = (strategyId: number) => { mutationFn: () => deleteMyStrategy(strategyId), onSuccess: () => { queryClient.invalidateQueries({ - queryKey: ['adminStrategies'], + queryKey: [QUERY_KEY.ADMIN_STRATEGIES], }) }, }) diff --git a/app/admin/strategies/_hooks/query/use-patch-strategy-approval.ts b/app/admin/strategies/_hooks/query/use-patch-strategy-approval.ts index 802389d3..e023261f 100644 --- a/app/admin/strategies/_hooks/query/use-patch-strategy-approval.ts +++ b/app/admin/strategies/_hooks/query/use-patch-strategy-approval.ts @@ -1,5 +1,7 @@ import { useMutation, useQueryClient } from '@tanstack/react-query' +import { QUERY_KEY } from '@/shared/constants/query-key' + import patchStrategyApproval from '../../_api/patch-strategy-approval' const usePatchStrategyApproval = (strategyId: number, isApproved: 'APPROVED' | 'DENY') => { @@ -8,7 +10,7 @@ const usePatchStrategyApproval = (strategyId: number, isApproved: 'APPROVED' | ' return useMutation({ mutationFn: () => patchStrategyApproval(strategyId, isApproved), onSuccess: () => { - queryClient.invalidateQueries({ queryKey: ['adminStrategies'] }) + queryClient.invalidateQueries({ queryKey: [QUERY_KEY.ADMIN_STRATEGIES] }) }, }) } diff --git a/app/admin/strategies/_hooks/query/use-patch-strategy-role.ts b/app/admin/strategies/_hooks/query/use-patch-strategy-role.ts index 31d09d74..ece89e7e 100644 --- a/app/admin/strategies/_hooks/query/use-patch-strategy-role.ts +++ b/app/admin/strategies/_hooks/query/use-patch-strategy-role.ts @@ -1,5 +1,7 @@ import { useMutation, useQueryClient } from '@tanstack/react-query' +import { QUERY_KEY } from '@/shared/constants/query-key' + import patchAdminStrategyPublic from '../../_api/patch-strategy-role' import { StrategiesPublicStateType } from '../../types' @@ -9,7 +11,7 @@ const usePatchStrategyPublic = (strategyId: number, isPublic: StrategiesPublicSt return useMutation({ mutationFn: () => patchAdminStrategyPublic(strategyId, isPublic), onSuccess: () => { - queryClient.invalidateQueries({ queryKey: ['adminStrategies'] }) + queryClient.invalidateQueries({ queryKey: [QUERY_KEY.ADMIN_STRATEGIES] }) }, onError: () => { alert('실패했음') diff --git a/app/admin/strategies/_hooks/query/use-strategies-data.ts b/app/admin/strategies/_hooks/query/use-strategies-data.ts index 6d05e33f..e2de7d36 100644 --- a/app/admin/strategies/_hooks/query/use-strategies-data.ts +++ b/app/admin/strategies/_hooks/query/use-strategies-data.ts @@ -1,5 +1,7 @@ import { useQuery } from '@tanstack/react-query' +import { QUERY_KEY } from '@/shared/constants/query-key' + import getStrategies from '../../_api/get-strategies' import { StrategiesApprovalStateType } from '../../types' @@ -12,7 +14,7 @@ interface ArgModel { const useStrategiesData = ({ searchWord, isApproved, page, size }: ArgModel) => { return useQuery({ - queryKey: ['adminStrategies', { searchWord, isApproved, page, size }], + queryKey: [QUERY_KEY.ADMIN_STRATEGIES, { searchWord, isApproved, page, size }], queryFn: () => getStrategies({ searchWord, isApproved, page, size }), }) } diff --git a/app/admin/users/_hooks/query/use-admin-users.ts b/app/admin/users/_hooks/query/use-admin-users.ts index a6771370..e3921bc9 100644 --- a/app/admin/users/_hooks/query/use-admin-users.ts +++ b/app/admin/users/_hooks/query/use-admin-users.ts @@ -1,5 +1,7 @@ import { useQuery } from '@tanstack/react-query' +import { QUERY_KEY } from '@/shared/constants/query-key' + import getAdminUsers from '../../_api/get-admin-users' interface ArgModel { @@ -12,7 +14,7 @@ interface ArgModel { const useAdminUsers = ({ role, condition, keyword, page, size }: ArgModel) => { return useQuery({ - queryKey: ['adminUsers', [role, condition, keyword, page, size]], + queryKey: [QUERY_KEY.ADMIN_USERS, [role, condition, keyword, page, size]], queryFn: () => getAdminUsers({ role, condition, keyword, page, size }), }) } diff --git a/app/admin/users/_hooks/query/use-delete-user.ts b/app/admin/users/_hooks/query/use-delete-user.ts index 964563a2..f3fd4e71 100644 --- a/app/admin/users/_hooks/query/use-delete-user.ts +++ b/app/admin/users/_hooks/query/use-delete-user.ts @@ -1,5 +1,7 @@ import { useMutation, useQueryClient } from '@tanstack/react-query' +import { QUERY_KEY } from '@/shared/constants/query-key' + import deleteUser from '../../_api/delete-user' const useDeleteUser = (userId: number) => { @@ -8,7 +10,7 @@ const useDeleteUser = (userId: number) => { return useMutation({ mutationFn: () => deleteUser(userId), onSuccess: () => { - queryClient.invalidateQueries({ queryKey: ['adminUsers'] }) + queryClient.invalidateQueries({ queryKey: [QUERY_KEY.ADMIN_USERS] }) }, }) } diff --git a/app/admin/users/_hooks/query/use-patch-user-role.ts b/app/admin/users/_hooks/query/use-patch-user-role.ts index b163f174..933443ff 100644 --- a/app/admin/users/_hooks/query/use-patch-user-role.ts +++ b/app/admin/users/_hooks/query/use-patch-user-role.ts @@ -1,5 +1,7 @@ import { useMutation, useQueryClient } from '@tanstack/react-query' +import { QUERY_KEY } from '@/shared/constants/query-key' + import patchAdminUserRole from '../../_api/patch-user-role' import { AdminPatchUserRoleType } from '../../types' @@ -9,7 +11,7 @@ const usePatchUserRole = (userId: number, newRole: AdminPatchUserRoleType) => { return useMutation({ mutationFn: () => patchAdminUserRole(userId, newRole), onSuccess: () => { - queryClient.invalidateQueries({ queryKey: ['adminUsers'] }) + queryClient.invalidateQueries({ queryKey: [QUERY_KEY.ADMIN_USERS] }) }, }) } diff --git a/shared/constants/query-key.ts b/shared/constants/query-key.ts new file mode 100644 index 00000000..a5182b84 --- /dev/null +++ b/shared/constants/query-key.ts @@ -0,0 +1,44 @@ +export const QUERY_KEY = { + // My + MY_STRATEGIES: 'myStrategies', + MY_DAILY_ANALYSIS: 'myDailyAnalysis', + MY_ACCOUNT_IMAGES: 'myAccountImages', + MY_FAVORITE_STRATEGIES: 'myFavoriteStrategies', + MY_PROFILE: 'myProfile', + + // sTRATEGY + STRATEGIES: 'strategies', + STRATEGY_TYPE: 'strategyTypes', + STRATEGY_DETAILS: 'strategyDetails', + STRATEGY_REVIEWS: 'strategyReviews', + STRATEGY_ACCOUNT_IMAGES: 'strategyAccountImages', + STRATEGY_ANALYSIS_CHART: 'strategyAnalysisChart', + STRATEGY_ANALYSIS: 'strategyAnalysis', + STRATEGY_STATISTICS: 'strategyStatistics', + STRATEGY_SEARCH: 'strategiesSearch', + + // question + QUESTION_DETAILS: 'questionDetails', + QUESTION_LIST: 'questionList', + + // trader + TRADERS: 'traders', + TRADER_STRATEGIES: 'traderStrategies', + + // notice + NOTICES: 'notices', + NOTICE_DETAIL: 'noticeDetail', + + // admin + ADMIN_STOCKS: 'adminStocks', + ADMIN_TRADES: 'adminTrades', + ADMIN_QUESTIONS: 'adminQuestions', + ADMIN_STRATEGIES: 'adminStrategies', + ADMIN_USERS: 'adminUsers', + + // etc + TOTAL_STRATEGIES_MATRICS: 'totalStrategiesMetrics', + TOP_RANKING_SM_SCORE: 'topRankingSmScore', + TOP_RANKING: 'topRanking', + USER_METRICS: 'userMetrics', +} as const From cc29c710276e31cc26af205367aeeb30d9253846 Mon Sep 17 00:00:00 2001 From: kimpra <dustmqaksdltkfrlfdldi@gmail.com> Date: Fri, 13 Dec 2024 01:07:43 +0900 Subject: [PATCH 143/207] =?UTF-8?q?chore:=20=EC=98=A4=ED=83=80=20=EA=B5=90?= =?UTF-8?q?=EC=A0=95=20(#136)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- app/(dashboard)/my/_hooks/query/use-add-strategy.ts | 2 +- .../(home)/_hooks/query/use-get-strategies-metrics.ts | 2 +- shared/constants/query-key.ts | 4 ++-- 3 files changed, 4 insertions(+), 4 deletions(-) diff --git a/app/(dashboard)/my/_hooks/query/use-add-strategy.ts b/app/(dashboard)/my/_hooks/query/use-add-strategy.ts index ee4e9904..45b04a66 100644 --- a/app/(dashboard)/my/_hooks/query/use-add-strategy.ts +++ b/app/(dashboard)/my/_hooks/query/use-add-strategy.ts @@ -27,7 +27,7 @@ export const useAddStrategy = () => { StrategyTypeResponseModel, AxiosError<ErrorResponseModel> >({ - queryKey: [QUERY_KEY.STRATEGY_TYPE], + queryKey: [QUERY_KEY.STRATEGY_TYPES], queryFn: () => strategyApi.getStrategyTypes().then((response) => response.data), retry: false, refetchOnWindowFocus: false, diff --git a/app/(landing)/(home)/_hooks/query/use-get-strategies-metrics.ts b/app/(landing)/(home)/_hooks/query/use-get-strategies-metrics.ts index 9116b20d..245d2b1e 100644 --- a/app/(landing)/(home)/_hooks/query/use-get-strategies-metrics.ts +++ b/app/(landing)/(home)/_hooks/query/use-get-strategies-metrics.ts @@ -7,7 +7,7 @@ import { AverageMetricsChartDataModel } from '../../_ui/average-metrics-section/ const useGetStrategiesMetrics = () => { return useQuery<AverageMetricsChartDataModel>({ - queryKey: [QUERY_KEY.TOTAL_STRATEGIES_MATRICS], + queryKey: [QUERY_KEY.TOTAL_STRATEGIES_METRICS], queryFn: getStrategiesMetrics, }) } diff --git a/shared/constants/query-key.ts b/shared/constants/query-key.ts index a5182b84..08b71927 100644 --- a/shared/constants/query-key.ts +++ b/shared/constants/query-key.ts @@ -8,7 +8,7 @@ export const QUERY_KEY = { // sTRATEGY STRATEGIES: 'strategies', - STRATEGY_TYPE: 'strategyTypes', + STRATEGY_TYPES: 'strategyTypes', STRATEGY_DETAILS: 'strategyDetails', STRATEGY_REVIEWS: 'strategyReviews', STRATEGY_ACCOUNT_IMAGES: 'strategyAccountImages', @@ -37,7 +37,7 @@ export const QUERY_KEY = { ADMIN_USERS: 'adminUsers', // etc - TOTAL_STRATEGIES_MATRICS: 'totalStrategiesMetrics', + TOTAL_STRATEGIES_METRICS: 'totalStrategiesMetrics', TOP_RANKING_SM_SCORE: 'topRankingSmScore', TOP_RANKING: 'topRanking', USER_METRICS: 'userMetrics', From 3455892dac5eaa15f3ed9671efe6f7bad8ec43d8 Mon Sep 17 00:00:00 2001 From: ssumanlife <ksm9805@naver.com> Date: Fri, 13 Dec 2024 10:37:37 +0900 Subject: [PATCH 144/207] =?UTF-8?q?fix:=20textarea=20=EC=A4=84=EB=B0=94?= =?UTF-8?q?=EA=BF=88=20=EC=A0=81=EC=9A=A9=EB=90=98=EB=8F=84=EB=A1=9D=20?= =?UTF-8?q?=EC=88=98=EC=A0=95=20(#138)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- app/(dashboard)/_ui/introduction/index.tsx | 4 ++-- app/(dashboard)/_ui/introduction/styles.module.scss | 5 ++++- .../[strategyId]/_ui/review-container/review-item.tsx | 2 +- app/(landing)/notices/_ui/notice-detail/index.tsx | 4 +++- app/(landing)/notices/_ui/notice-detail/styles.module.scss | 7 ++++++- 5 files changed, 16 insertions(+), 6 deletions(-) diff --git a/app/(dashboard)/_ui/introduction/index.tsx b/app/(dashboard)/_ui/introduction/index.tsx index cb12302a..6e578a34 100644 --- a/app/(dashboard)/_ui/introduction/index.tsx +++ b/app/(dashboard)/_ui/introduction/index.tsx @@ -22,7 +22,7 @@ const StrategyIntroduction = ({ content, isEditable = false }: Props) => { const [shouldShowMore, setShouldShowMore] = useState(false) const [editContent, setEditContent] = useState<HTMLTextAreaElement | string | null>(null) const [isOverflow, setIsOverflow] = useState(false) - const contentRef = useRef<HTMLParagraphElement>(null) + const contentRef = useRef<HTMLPreElement>(null) const setDescription = useEditInformationStore((state) => state.actions.setDescription) useEffect(() => { @@ -53,7 +53,7 @@ const StrategyIntroduction = ({ content, isEditable = false }: Props) => { onChange={(e: React.ChangeEvent<HTMLTextAreaElement>) => setEditContent(e.target.value)} /> ) : ( - <p ref={contentRef}>{content}</p> + <pre ref={contentRef}>{content}</pre> )} </div> {isOverflow && ( diff --git a/app/(dashboard)/_ui/introduction/styles.module.scss b/app/(dashboard)/_ui/introduction/styles.module.scss index b7a86c06..4a541c06 100644 --- a/app/(dashboard)/_ui/introduction/styles.module.scss +++ b/app/(dashboard)/_ui/introduction/styles.module.scss @@ -15,10 +15,13 @@ &.expand { display: contents; } - p { + pre { @include typo-b3; line-height: 18px; color: $color-gray-600; + word-wrap: break-word; + word-break: break-word; + white-space: pre-wrap; } } diff --git a/app/(dashboard)/strategies/[strategyId]/_ui/review-container/review-item.tsx b/app/(dashboard)/strategies/[strategyId]/_ui/review-container/review-item.tsx index 89239429..3bfa2726 100644 --- a/app/(dashboard)/strategies/[strategyId]/_ui/review-container/review-item.tsx +++ b/app/(dashboard)/strategies/[strategyId]/_ui/review-container/review-item.tsx @@ -85,7 +85,7 @@ const ReviewItem = ({ onCancel={() => setIsEditable(false)} /> ) : ( - <div className={cx('content')}>{content}</div> + <pre className={cx('content')}>{content}</pre> )} <ReviewGuideModal isModalOpen={isModalOpen} diff --git a/app/(landing)/notices/_ui/notice-detail/index.tsx b/app/(landing)/notices/_ui/notice-detail/index.tsx index b8644ca2..66475421 100644 --- a/app/(landing)/notices/_ui/notice-detail/index.tsx +++ b/app/(landing)/notices/_ui/notice-detail/index.tsx @@ -23,7 +23,9 @@ const NoticeDetail = ({ noticeId }: { noticeId: number }) => { <div className={cx('date')}>{notice?.createdAt}</div> </div> <div className={cx('top-line')}></div> - <div className={cx('content')}>{notice?.content}</div> + <div className={cx('content')}> + <pre>{notice?.content}</pre> + </div> <div className={cx('bottom-line')}></div> <div className={cx('attach-file')}> diff --git a/app/(landing)/notices/_ui/notice-detail/styles.module.scss b/app/(landing)/notices/_ui/notice-detail/styles.module.scss index 1e1a741a..c99e3f65 100644 --- a/app/(landing)/notices/_ui/notice-detail/styles.module.scss +++ b/app/(landing)/notices/_ui/notice-detail/styles.module.scss @@ -1,6 +1,6 @@ .container { width: 100%; - height: 813px; + margin: 40px 0; padding: 52px 28px; background-color: $color-white; } @@ -32,6 +32,11 @@ padding: 0px 24px; @include typo-b2; line-height: 2; + pre { + word-wrap: break-word; + word-break: break-word; + white-space: pre-wrap; + } } .bottom-line { From 1a2b020896f6da26ce6b544d5949cfc25f258f03 Mon Sep 17 00:00:00 2001 From: ssumanlife <ksm9805@naver.com> Date: Fri, 13 Dec 2024 10:41:48 +0900 Subject: [PATCH 145/207] =?UTF-8?q?fix:=20className=20=EC=88=98=EC=A0=95?= =?UTF-8?q?=20(#138)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- app/admin/notices/post/page.module.scss | 2 +- app/admin/notices/post/page.tsx | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/app/admin/notices/post/page.module.scss b/app/admin/notices/post/page.module.scss index f5e7b43f..21a3cedc 100644 --- a/app/admin/notices/post/page.module.scss +++ b/app/admin/notices/post/page.module.scss @@ -23,7 +23,7 @@ width: 100%; } -.textarea { +.content { height: 420px; &::placeholder { diff --git a/app/admin/notices/post/page.tsx b/app/admin/notices/post/page.tsx index e51e90f8..1b8002a8 100644 --- a/app/admin/notices/post/page.tsx +++ b/app/admin/notices/post/page.tsx @@ -49,7 +49,7 @@ const AdminNoticePostPage = () => { label="내용" Input={ <Textarea - className={cx('textarea')} + className={cx('content')} placeholder="내용을 입력하세요." value={formData.content} onChange={(e) => onInputChange('content', e.target.value)} From 8e7e4adea6ff4ac5ee35f87587d0318946eef1e9 Mon Sep 17 00:00:00 2001 From: ssumanlife <ksm9805@naver.com> Date: Fri, 13 Dec 2024 10:46:08 +0900 Subject: [PATCH 146/207] =?UTF-8?q?fix:=20pre=20style=20=EC=B6=94=EA=B0=80?= =?UTF-8?q?=20(#138)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../[strategyId]/_ui/review-container/styles.module.scss | 3 +++ 1 file changed, 3 insertions(+) diff --git a/app/(dashboard)/strategies/[strategyId]/_ui/review-container/styles.module.scss b/app/(dashboard)/strategies/[strategyId]/_ui/review-container/styles.module.scss index c7ee1a17..ed0c05bb 100644 --- a/app/(dashboard)/strategies/[strategyId]/_ui/review-container/styles.module.scss +++ b/app/(dashboard)/strategies/[strategyId]/_ui/review-container/styles.module.scss @@ -82,6 +82,9 @@ } .content { margin: 10px 0; + word-wrap: break-word; + word-break: break-word; + white-space: pre-wrap; @include typo-b2; font-weight: $text-normal; @include tablet-sm { From f4dca57e3d295f53a280dd8061cce448cf421561 Mon Sep 17 00:00:00 2001 From: deun <devdeun@gmail.com> Date: Fri, 13 Dec 2024 10:51:13 +0900 Subject: [PATCH 147/207] =?UTF-8?q?fix:=20isBrowser=20=EB=B3=80=EC=88=98?= =?UTF-8?q?=EB=A5=BC=20=ED=95=A8=EC=88=98=EB=A1=9C=20=EB=B3=80=EA=B2=BD=20?= =?UTF-8?q?(#140)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- app/(landing)/signup/_lib/cookies.ts | 9 +++++---- 1 file changed, 5 insertions(+), 4 deletions(-) diff --git a/app/(landing)/signup/_lib/cookies.ts b/app/(landing)/signup/_lib/cookies.ts index 0f985ceb..4a6999a2 100644 --- a/app/(landing)/signup/_lib/cookies.ts +++ b/app/(landing)/signup/_lib/cookies.ts @@ -6,10 +6,10 @@ import { SIGN_UP_COOKIE, SignUpCookieValueType } from '../_constants/cookies' const COOKIE_EXPIRATION_MINUTES = 30 -const isBrowser = typeof window !== 'undefined' +const isBrowser = () => typeof window !== 'undefined' const setSignupCookie = (key: SignUpCookieValueType, value: string) => { - if (!isBrowser) return + if (!isBrowser()) return const expirationDate = new Date() expirationDate.setMinutes(expirationDate.getMinutes() + COOKIE_EXPIRATION_MINUTES) @@ -18,7 +18,8 @@ const setSignupCookie = (key: SignUpCookieValueType, value: string) => { } const getSignupCookie = (key: SignUpCookieValueType) => { - if (!isBrowser) return + if (!isBrowser()) return + const value = `; ${document.cookie}` const parts = value.split(`; ${key}=`) @@ -26,7 +27,7 @@ const getSignupCookie = (key: SignUpCookieValueType) => { } export const removeAllSignupCookies = () => { - if (!isBrowser) return false + if (!isBrowser()) return try { const signupCookies = Object.values(SIGN_UP_COOKIE) From a8a49e9ec58cda08496287a72cf7a26e7f3c6e73 Mon Sep 17 00:00:00 2001 From: nanafromjeju <nanafromjeju@gmail.com> Date: Sat, 14 Dec 2024 21:14:18 +0900 Subject: [PATCH 148/207] =?UTF-8?q?docs:=20README.md=20=ED=8C=8C=EC=9D=BC?= =?UTF-8?q?=20=EC=9E=91=EC=84=B1=20(#10)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- README.md | 73 ++++++++++++++++++++++++++++++++++++++++++++++--------- 1 file changed, 61 insertions(+), 12 deletions(-) diff --git a/README.md b/README.md index 488fbc69..a9ba9cef 100644 --- a/README.md +++ b/README.md @@ -1,12 +1,61 @@ -# InvestMetic - -<p align="center"> - <a href="https://6729e72ee61d8f57ca4790fb-aepxabjrdg.chromatic.com/"> - <picture> - <source media="(prefers-color-scheme: dark)" srcset="https://user-images.githubusercontent.com/263385/199832481-bbbf5961-6a26-481d-8224-51258cce9b33.png"> - <img src="https://user-images.githubusercontent.com/321738/63501763-88dbf600-c4cc-11e9-96cd-94adadc2fd72.png" alt="Storybook" width="400" /> - </picture> - - </a> - -</p> +## 프로젝트 소개 + +![2024-12-147 46 07-ezgif com-video-to-gif-converter](https://github.com/user-attachments/assets/2c6616f6-c36a-453e-abbd-41ed97064211) + +**investmetic**은 **투자 매매 전략 공유 및 +중개소셜 플랫폼 서비스** 입니다. + +![2024-12-148 40 55-ezgif com-video-to-gif-converter](https://github.com/user-attachments/assets/3fa3ecc3-90da-405a-984e-bd92f5c6d878) + +회원가입 버튼을 상단에 배치하여 즉각적인 가입을 유도하며, 사이트 이용자 수, 인기 전략, 통합 지표(SM Score) 등 주요 정보를 한눈에 확인할 수 있습니다. + +![2024-12-148 41 20-ezgif com-video-to-gif-converter](https://github.com/user-attachments/assets/2b07bbb5-8bac-42b8-855a-954ede75d697) + +전략을 랭킹 순으로 확인할 수 있으며 로그인하지 않은 사용자도 전략 목록을 자유롭게 둘러볼 수 있습니다. 검색바를 통해 매매 유형, SM Score 등 다양한 조건으로 전략을 쉽게 찾아볼 수 있습니다. + +![2024-12-148 41 42-ezgif com-video-to-gif-converter](https://github.com/user-attachments/assets/9355e74c-adcf-42a1-94d5-2a429758c7c1) + +트레이더 상세보기 페이지에서 트레이더의 전략을 확인할 수 있습니다. + +![2024-12-148 42 05-ezgif com-video-to-gif-converter](https://github.com/user-attachments/assets/76f83497-ad3d-419b-8d9f-9789b1edacf5) + +나의 전략을 확인할 수 있으며, 무한 스크롤로 데이터를 끊김 없이 탐색할 수 있습니다. + +![2024-12-148 42 28-ezgif com-video-to-gif-converter](https://github.com/user-attachments/assets/488d2806-37df-4902-9f03-77a332935911) + +내가 구독한 전략들을 한눈에 확인할 수 있습니다. + +![2024-12-148 42 48-ezgif com-video-to-gif-converter](https://github.com/user-attachments/assets/625ac057-0bae-4c65-b294-2b117d6364a4) + +문의 내역을 확인할 수 있으며, 모든 답변, 답변 대기, 답변 완료 상태로 구분됩니다. 정렬과 검색 기능을 통해 원하는 데이터를 쉽게 찾을 수 있습니다. + +![2024-12-148 43 13-ezgif com-video-to-gif-converter](https://github.com/user-attachments/assets/68aad565-dc14-4028-ba78-b08686159ed2) + +관리자 계정으로 로그인하면 회원 관리, 공지사항 등록, 종목 및 매매 유형 관리, 전략 승인 관리, 문의 내역 확인 등의 기능을 제공하며, 효율적인 사이트 운영을 지원합니다. + +## 역할분담 + +<img width="1920" alt="69" src="https://github.com/user-attachments/assets/07bb776c-f6fb-4f2d-8383-584203aa77ff" /> + +## 🛠 기술 스택 + +![기술 스택](https://github.com/user-attachments/assets/9e59205a-71c2-4e13-b19b-701b1c465334) + +## 폴더 구조 + +![fsd폴더구조](https://github.com/user-attachments/assets/4afd073c-b1e2-469f-9c49-cab5cffd74a4) + +#### FSD(Folder Structure by Feature)의 장점 + +- **모듈화와 독립성:** 기능별로 파일을 관리해 수정, 삭제, 추가가 용이 +- **명확한 구조:** 기능 단위로 폴더가 구성되어 파일을 쉽게 찾을 수 있음 +- **협업 효율성:** 작업 영역이 분리되어 충돌 없이 동시 작업 가능 +- **유지보수 용이성:** 관련 코드가 한 폴더에 모여 있어 수정 및 확장이 쉬움 + +## 타임라인 + +![타임라인](https://github.com/user-attachments/assets/ff6909a0-f2b5-45f6-8247-05309e1f3ab2) + +## 트러블슈팅 + +![트러블슈팅](https://github.com/user-attachments/assets/1403b945-cba6-425d-965d-f65834186f66) From 8128dde0d95f1ef498fc2374272449c858b4c7dd Mon Sep 17 00:00:00 2001 From: nanafromjeju <nanafromjeju@gmail.com> Date: Sun, 15 Dec 2024 20:47:46 +0900 Subject: [PATCH 149/207] =?UTF-8?q?docs:=20README.md=20=ED=8C=8C=EC=9D=BC?= =?UTF-8?q?=20=ED=85=8D=EC=8A=A4=ED=8A=B8=20=EC=B6=94=EA=B0=80=20(#10)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- README.md | 54 ++++++++++++++++++++++++++++++++++++++++++++---------- 1 file changed, 44 insertions(+), 10 deletions(-) diff --git a/README.md b/README.md index a9ba9cef..2162a217 100644 --- a/README.md +++ b/README.md @@ -1,10 +1,38 @@ -## 프로젝트 소개 +## investmetic ![2024-12-147 46 07-ezgif com-video-to-gif-converter](https://github.com/user-attachments/assets/2c6616f6-c36a-453e-abbd-41ed97064211) +<div align="center"> + +🔗 [investmetic](https://www.investmetic.co.kr/) + **investmetic**은 **투자 매매 전략 공유 및 중개소셜 플랫폼 서비스** 입니다. +## 테스트 계정 + +| **역할** | **이메일** | **비밀번호** | +| -------------------- | ------------------------- | ---------------- | +| **투자자** | investor@example.com | investor123 | +| **트레이더** | trader@example.com | trader123 | +| **트레이더(관리자)** | straderadmin@example.com | traderadmin123 | +| **관리자 (관리자)** | investoradmin@example.com | investoradmin123 | + +<br/> + +## 역할분담 + +<div align="center"> + +| [<img src="https://github.com/user-attachments/assets/22fabdef-8b38-4cdb-b0d3-02a4234d6ff5" width="95" height="95"/>](https://github.com/devdeun) | [<img src="https://github.com/user-attachments/assets/3483e2b6-2eac-419b-a7ed-e7e2306c0863" width="100" height="100"/>](https://github.com/nanafromjeju) | [<img src="https://github.com/user-attachments/assets/3250fe8b-e818-4473-9a9e-9af70adaa017" width="100" height="100"/>](https://github.com/ssumanlife) | [<img src="https://github.com/user-attachments/assets/ce565244-e952-48d3-8429-f25b5781ece2" width="95" height="95"/>](https://github.com/kimpra2989) | [<img src="https://github.com/user-attachments/assets/f57c7dc6-f8d4-43bc-be28-63d6d9c131a0" width="95" height="95"/>](https://github.com/HSjjs98) | +| :-------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------: | :------------------------------------------------------------------------------------------------------------------------------------------------------: | :----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------: | :--------------------------------------------------------------------------------------------------------------------------------------------------------: | :----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------: | +| [👑 @Deun](https://github.com/devdeun) | [@Nana](https://github.com/nanafromjeju) | [@SuMin](https://github.com/ssumanlife) | [@Kimpra](https://github.com/kimpra2989) | [@James](https://github.com/HSjjs98) | +| 프로젝트 초기 세팅 <br>(NextJS, 린트 및 Prettier, <br>기본 스타일 및 SCSS, lefthook) <br><br> 공통 컴포넌트 <br>(사이드바, 탭메뉴, 아바타,<br> 푸터, 로딩스피너, 랜딩 메인 차트) <br><br> 랜딩 페이지,<br> 회원가입 페이지,<br> 구독한 전략 페이지,<br> 문의내역 페이지,<br> 약관 페이지 <br> | 공통 컴포넌트 <br> (인풋, 모달) <br><br> 프로필 페이지 <br> 공지사항 페이지 <br> 트레이더 페이지 <br>404 페이지 | 공통 컴포넌트<br>(전략 리스트 아이템, 종목&매매 아이콘,<br> 테이블, 스켈레톤, 회원가입 스텝,<br> 전략 정보, 별점, 검색바, 사이드 정보,<br> 랭킹 차트, 분석 차트, 목록 헤더) <br><br> 전략 랭킹 모음 페이지<br>전략 상세 페이지 | 프로젝트 초기 세팅(NextJS, 배포, MSW) <br><br> 공통 컴포넌트<br> (셀렉트, 헤더)<br><br> 관리자 공지 페이지<br> 관리자 질문 페이지<br> 관리자 사용자 페이지 | 프로젝트 초기 세팅<br>(Tanstack Query, MSW)<br><br> 공통 컴포넌트<br>(페이지네이션, 버튼, 체크 박스, 랜딩 선 차트)<br><br>로그인 페이지<br> 나의 전략 페이지<br> 전략 관리 페이지<br> 전략 등록 페이지 | + +</div> + +## 페이지 소개 + ![2024-12-148 40 55-ezgif com-video-to-gif-converter](https://github.com/user-attachments/assets/3fa3ecc3-90da-405a-984e-bd92f5c6d878) 회원가입 버튼을 상단에 배치하여 즉각적인 가입을 유도하며, 사이트 이용자 수, 인기 전략, 통합 지표(SM Score) 등 주요 정보를 한눈에 확인할 수 있습니다. @@ -33,13 +61,19 @@ 관리자 계정으로 로그인하면 회원 관리, 공지사항 등록, 종목 및 매매 유형 관리, 전략 승인 관리, 문의 내역 확인 등의 기능을 제공하며, 효율적인 사이트 운영을 지원합니다. -## 역할분담 - -<img width="1920" alt="69" src="https://github.com/user-attachments/assets/07bb776c-f6fb-4f2d-8383-584203aa77ff" /> - ## 🛠 기술 스택 -![기술 스택](https://github.com/user-attachments/assets/9e59205a-71c2-4e13-b19b-701b1c465334) +| 기술 스택 | 도입 이유 | +| -------------------------------------------------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------ | +| <img src="https://img.shields.io/badge/Next.js-000000?style=for-the-badge&logo=Next.js&logoColor=white"> | (14.2.16) SSR로 SEO와 초기 로딩 속도 개선, 폴더 기반 라우팅으로 경로 자동 생성 | +| <img src="https://img.shields.io/badge/Storybook-FF4785?style=for-the-badge&logo=Storybook&logoColor=white"> | (8.4.0) 문서화로 사용 방법 및 디자인 시스템 확인, UI 변경사항을 즉각 확인하며 테스트 코드 생략 가능 | +| <img src="https://img.shields.io/badge/Sass-CC6699?style=for-the-badge&logo=Sass&logoColor=white"> | (1.80.5) Mixin으로 반복 스타일 재사용 효율성 증대, 변수 지원으로 색상·폰트 등 공통 값 관리 용이 | +| ![TypeScript](https://img.shields.io/badge/typescript-%23007ACC.svg?style=for-the-badge&logo=typescript&logoColor=white) | (5.0) 정적 타입 검사로 안정성 확보 및 런타임 에러 감소, 코드 자동완성과 명확한 타입 정의로 가독성과 유지보수성 향상 관리 | +| ![React](https://img.shields.io/badge/react-%2320232a.svg?style=for-the-badge&logo=react&logoColor=%2361DAFB) | (18) 컴포넌트 기반 아키텍처로 재사용성 극대화, 선언형 UI로 직관적이고 효율적인 개발 경험 제공 | +| ![TanStack Query](https://img.shields.io/badge/tanstack--query-FF4154?style=for-the-badge&logo=reactquery&logoColor=white) | (5.59.19) 비동기 상태 관리와 캐싱으로 데이터 요청 최적화 및 서버 상태 관리 간소화 | +| ![Zustand](https://img.shields.io/badge/zustand-2759C6.svg?style=for-the-badge&logo=zustand&logoColor=white) | (5.0.1) 가벼운 상태 관리 라이브러리로 직관적인 API와 불변성 없이도 효율적인 상태 관리 제공 | + +<br> ## 폴더 구조 @@ -47,10 +81,10 @@ #### FSD(Folder Structure by Feature)의 장점 -- **모듈화와 독립성:** 기능별로 파일을 관리해 수정, 삭제, 추가가 용이 -- **명확한 구조:** 기능 단위로 폴더가 구성되어 파일을 쉽게 찾을 수 있음 -- **협업 효율성:** 작업 영역이 분리되어 충돌 없이 동시 작업 가능 -- **유지보수 용이성:** 관련 코드가 한 폴더에 모여 있어 수정 및 확장이 쉬움 +**1. 모듈화와 독립성:** 기능별로 파일을 관리해 수정, 삭제, 추가가 용이<br> +**2. 명확한 구조:** 기능 단위로 폴더가 구성되어 파일을 쉽게 찾을 수 있음<br> +**3. 협업 효율성:** 작업 영역이 분리되어 충돌 없이 동시 작업 가능<br> +**4. 유지보수 용이성:** 관련 코드가 한 폴더에 모여 있어 수정 및 확장이 쉬움 ## 타임라인 From 97b2fcb83cad64238d7f715a7988adaeb54a3360 Mon Sep 17 00:00:00 2001 From: nanafromjeju <nanafromjeju@gmail.com> Date: Sun, 15 Dec 2024 20:53:57 +0900 Subject: [PATCH 150/207] =?UTF-8?q?docs:=20=EB=A6=AC=EB=93=9C=EB=AF=B8=20?= =?UTF-8?q?=ED=8A=B8=EB=9F=AC=EB=B8=94=EC=8A=88=ED=8C=85=20=EC=A0=9C?= =?UTF-8?q?=EA=B1=B0=20=EB=B0=8F=20=EC=98=A4=ED=83=80=20=EC=88=98=EC=A0=95?= =?UTF-8?q?=20(#10)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- README.md | 6 +----- 1 file changed, 1 insertion(+), 5 deletions(-) diff --git a/README.md b/README.md index 2162a217..db41cd99 100644 --- a/README.md +++ b/README.md @@ -79,7 +79,7 @@ ![fsd폴더구조](https://github.com/user-attachments/assets/4afd073c-b1e2-469f-9c49-cab5cffd74a4) -#### FSD(Folder Structure by Feature)의 장점 +#### FSD(Feature Sliced Design)의 장점 **1. 모듈화와 독립성:** 기능별로 파일을 관리해 수정, 삭제, 추가가 용이<br> **2. 명확한 구조:** 기능 단위로 폴더가 구성되어 파일을 쉽게 찾을 수 있음<br> @@ -89,7 +89,3 @@ ## 타임라인 ![타임라인](https://github.com/user-attachments/assets/ff6909a0-f2b5-45f6-8247-05309e1f3ab2) - -## 트러블슈팅 - -![트러블슈팅](https://github.com/user-attachments/assets/1403b945-cba6-425d-965d-f65834186f66) From fabc3779333053b8b11e2b57a3a3adf3eae4ac77 Mon Sep 17 00:00:00 2001 From: nanafromjeju <nanafromjeju@gmail.com> Date: Sun, 15 Dec 2024 23:10:42 +0900 Subject: [PATCH 151/207] =?UTF-8?q?docs:=20README.md=20=EC=9D=B4=EB=AF=B8?= =?UTF-8?q?=EC=A7=80=20=EB=B3=80=EA=B2=BD=20(#10)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- README.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/README.md b/README.md index db41cd99..37edfe6e 100644 --- a/README.md +++ b/README.md @@ -1,6 +1,6 @@ ## investmetic -![2024-12-147 46 07-ezgif com-video-to-gif-converter](https://github.com/user-attachments/assets/2c6616f6-c36a-453e-abbd-41ed97064211) +![inve](https://github.com/user-attachments/assets/04760f5b-1c52-48ee-9bd4-763b947e1899) <div align="center"> From c436edd29b2fae2fd2f5f77090c87e239c10fdd8 Mon Sep 17 00:00:00 2001 From: deun <devdeun@gmail.com> Date: Fri, 20 Dec 2024 21:41:20 +0900 Subject: [PATCH 152/207] =?UTF-8?q?feat:=20Footer=20=EC=A0=95=EB=B3=B4=20?= =?UTF-8?q?=EC=88=98=EC=A0=95=20(#143)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- shared/ui/footer/index.tsx | 7 +++---- shared/ui/footer/styles.module.scss | 2 +- 2 files changed, 4 insertions(+), 5 deletions(-) diff --git a/shared/ui/footer/index.tsx b/shared/ui/footer/index.tsx index 707489f5..4d3be6ab 100644 --- a/shared/ui/footer/index.tsx +++ b/shared/ui/footer/index.tsx @@ -18,6 +18,7 @@ const Footer = () => { <div className={cx('info')}> <strong>(주)인베스트메틱</strong> <ul className={cx('contents')}> + <li>대표이사: 박혜정</li> <li>사업자 등록 번호 ㅣ 711-86-00050</li> <li>통신판매업신고 ㅣ 제2020-서울 영등포-2864호</li> <li>특허출원번호 ㅣ 10-2016-00262203</li> @@ -26,10 +27,8 @@ const Footer = () => { <div className={cx('info')}> <strong>CONTACT</strong> <ul className={cx('contents')}> - <li> - 서울시 영등포구 당산로41길 11, <br /> - E동 1202호 - </li> + <li>서울시 영등포구 당산로41길 11, E동 1202호</li> + <li>ceo@sysmetic.co.kr</li> <li>+82-2-6338-1880</li> </ul> </div> diff --git a/shared/ui/footer/styles.module.scss b/shared/ui/footer/styles.module.scss index c3800af9..6f94d891 100644 --- a/shared/ui/footer/styles.module.scss +++ b/shared/ui/footer/styles.module.scss @@ -41,7 +41,7 @@ display: flex; flex-direction: column; justify-content: space-between; - height: 82px; + height: 100px; } } From 980561cb046c7bd6b06d83279328de9282d771c5 Mon Sep 17 00:00:00 2001 From: deun <devdeun@gmail.com> Date: Fri, 20 Dec 2024 21:42:54 +0900 Subject: [PATCH 153/207] =?UTF-8?q?feat:=20=EA=B8=B0=EC=97=85=EB=AA=85=20?= =?UTF-8?q?=EC=88=98=EC=A0=95=20(#143)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- shared/ui/footer/index.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/shared/ui/footer/index.tsx b/shared/ui/footer/index.tsx index 4d3be6ab..e7fce630 100644 --- a/shared/ui/footer/index.tsx +++ b/shared/ui/footer/index.tsx @@ -16,7 +16,7 @@ const Footer = () => { <FooterLogo /> <div className={cx('info-wrapper')}> <div className={cx('info')}> - <strong>(주)인베스트메틱</strong> + <strong>(주) 시스메틱</strong> <ul className={cx('contents')}> <li>대표이사: 박혜정</li> <li>사업자 등록 번호 ㅣ 711-86-00050</li> From 7d0387a8f8115a158b565cfca5ebdffbea9b6594 Mon Sep 17 00:00:00 2001 From: deun <devdeun@gmail.com> Date: Mon, 13 Jan 2025 16:17:06 +0900 Subject: [PATCH 154/207] =?UTF-8?q?feat:=20=EC=B2=A8=EB=B6=80=ED=8C=8C?= =?UTF-8?q?=EC=9D=BC=20=EB=AA=A9=EB=A1=9D=20=EC=95=84=EC=9D=B4=ED=85=9C=20?= =?UTF-8?q?=EC=BB=B4=ED=8F=AC=EB=84=8C=ED=8A=B8=20=EC=B6=94=EA=B0=80=20(#1?= =?UTF-8?q?45)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../notices/_ui/notice-file-item/index.tsx | 24 +++++++++++++++++++ .../_ui/notice-file-item/styles.module.scss | 12 ++++++++++ 2 files changed, 36 insertions(+) create mode 100644 app/admin/notices/_ui/notice-file-item/index.tsx create mode 100644 app/admin/notices/_ui/notice-file-item/styles.module.scss diff --git a/app/admin/notices/_ui/notice-file-item/index.tsx b/app/admin/notices/_ui/notice-file-item/index.tsx new file mode 100644 index 00000000..0119d2f4 --- /dev/null +++ b/app/admin/notices/_ui/notice-file-item/index.tsx @@ -0,0 +1,24 @@ +import classNames from 'classnames/bind' + +import styles from './styles.module.scss' + +interface Props { + name: string + id: number | string + onDeleteFile: (id: number | string) => void +} + +const cx = classNames.bind(styles) + +const NoticeFileItem = ({ name, id, onDeleteFile }: Props) => { + return ( + <li className={cx('container')}> + <span className={cx('name')}>{name}</span> + <button className={cx('button')} type="button" onClick={() => onDeleteFile(id)}> + 삭제 + </button> + </li> + ) +} + +export default NoticeFileItem diff --git a/app/admin/notices/_ui/notice-file-item/styles.module.scss b/app/admin/notices/_ui/notice-file-item/styles.module.scss new file mode 100644 index 00000000..6efe9e81 --- /dev/null +++ b/app/admin/notices/_ui/notice-file-item/styles.module.scss @@ -0,0 +1,12 @@ +.container { + display: flex; + justify-content: space-between; + align-items: center; + margin-top: 8px; + padding: 0 4px; + + .button { + color: $color-gray-800; + background-color: transparent; + } +} From cb64147180b6849626df8b4c833191c21b9f8a06 Mon Sep 17 00:00:00 2001 From: deun <devdeun@gmail.com> Date: Mon, 13 Jan 2025 16:18:33 +0900 Subject: [PATCH 155/207] =?UTF-8?q?feat:=20=EA=B3=B5=EC=A7=80=EC=82=AC?= =?UTF-8?q?=ED=95=AD=20=EC=88=98=EC=A0=95=EC=8B=9C=20=EC=B2=A8=EB=B6=80?= =?UTF-8?q?=ED=8C=8C=EC=9D=BC=20=EB=AA=A9=EB=A1=9D=20=EC=B6=9C=EB=A0=A5=20?= =?UTF-8?q?=EB=B0=8F=20=EC=82=AD=EC=A0=9C=20=EA=B0=80=EB=8A=A5=ED=95=98?= =?UTF-8?q?=EB=8F=84=EB=A1=9D=20=EA=B8=B0=EB=8A=A5=20=EA=B5=AC=ED=98=84=20?= =?UTF-8?q?(#145)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../edit/_hooks/query/use-patch-notice.ts | 32 +++++--- .../notices/[noticeId]/edit/page.module.scss | 11 +++ app/admin/notices/[noticeId]/edit/page.tsx | 77 +++++++++++++++++-- app/admin/notices/[noticeId]/edit/types.ts | 2 +- .../post/_hooks/query/use-post-notice.ts | 10 +-- .../notices/post/_hooks/use-notice-form.ts | 10 ++- app/admin/notices/post/page.tsx | 2 +- app/admin/notices/types.ts | 8 +- 8 files changed, 124 insertions(+), 28 deletions(-) diff --git a/app/admin/notices/[noticeId]/edit/_hooks/query/use-patch-notice.ts b/app/admin/notices/[noticeId]/edit/_hooks/query/use-patch-notice.ts index d3c416dd..49c7e4f9 100644 --- a/app/admin/notices/[noticeId]/edit/_hooks/query/use-patch-notice.ts +++ b/app/admin/notices/[noticeId]/edit/_hooks/query/use-patch-notice.ts @@ -7,36 +7,48 @@ import axiosInstance from '@/shared/api/axios' import { QUERY_KEY } from '@/shared/constants/query-key' import uploadFileWithPresignedUrl from '@/shared/utils/upload-file-with-presigned-url' -import { PatchNoticeResponeseModel } from '../../types' +import { PatchNoticeResponseModel } from '../../types' -const usePatchNotice = (formData: NoticeFormModel, noticeId: string) => { +const usePatchNotice = (formData: NoticeFormModel, noticeId: number) => { const router = useRouter() const queryClient = useQueryClient() return useMutation({ mutationFn: async () => { - // Presigned URL 요청 - const uploadResponse = await axiosInstance.patch<PatchNoticeResponeseModel>( + const { existingFiles, newFiles } = formData + + const allFilePaths = [ + ...(existingFiles?.map((file) => file.fileName) ?? []), + ...(newFiles?.map((file) => file.name) ?? []), + ] + + const allSizes = [ + ...(existingFiles?.map((_) => 0) ?? []), + ...(newFiles?.map((file) => file.size) ?? []), + ] + + const uploadResponse = await axiosInstance.patch<PatchNoticeResponseModel>( `/api/admin/notices/${noticeId}`, { title: formData.title, content: formData.content, - filePaths: formData?.files?.map((file) => file.name) ?? [], - sizes: formData?.files?.map((file) => file.size) ?? [], + filePaths: allFilePaths, + sizes: allSizes, } ) - const { files } = formData - if (!files) return + if (!newFiles?.length) return const presignedUrls = uploadResponse.data.result - - await uploadFileWithPresignedUrl(files, presignedUrls) + await uploadFileWithPresignedUrl(newFiles, presignedUrls) }, onSuccess: () => { queryClient.invalidateQueries({ queryKey: [QUERY_KEY.NOTICES], }) + queryClient.invalidateQueries({ + queryKey: [QUERY_KEY.NOTICE_DETAIL, noticeId], + }) router.replace('/admin/notices') }, }) diff --git a/app/admin/notices/[noticeId]/edit/page.module.scss b/app/admin/notices/[noticeId]/edit/page.module.scss index f5e7b43f..570bc4ce 100644 --- a/app/admin/notices/[noticeId]/edit/page.module.scss +++ b/app/admin/notices/[noticeId]/edit/page.module.scss @@ -36,3 +36,14 @@ height: 30px; border: 1px solid $color-gray-300; } + +.notice-files-container { + width: 640px; + margin-left: auto; + margin-top: -28px; + + .notice-file-list { + @include typo-b3; + font-weight: 500; + } +} diff --git a/app/admin/notices/[noticeId]/edit/page.tsx b/app/admin/notices/[noticeId]/edit/page.tsx index 9bfdb57e..fd98d4af 100644 --- a/app/admin/notices/[noticeId]/edit/page.tsx +++ b/app/admin/notices/[noticeId]/edit/page.tsx @@ -15,6 +15,7 @@ import Input from '@/shared/ui/input' import Textarea from '@/shared/ui/textarea' import Title from '@/shared/ui/title' +import NoticeFileItem from '../../_ui/notice-file-item' import useNoticeForm from '../../post/_hooks/use-notice-form' import usePatchNotice from './_hooks/query/use-patch-notice' import styles from './page.module.scss' @@ -24,18 +25,58 @@ const cx = classNames.bind(styles) const AdminNoticeEditPage = () => { const { noticeId } = useParams() const { data } = useNoticeDetail(Number(noticeId as string)) - const { formData, setFormData, onInputChange } = useNoticeForm(data) - const { mutate } = usePatchNotice(formData, noticeId as string) + const { formData, setFormData, onInputChange } = useNoticeForm() + const { mutate } = usePatchNotice(formData, Number(noticeId as string)) useEffect(() => { if (data) { setFormData({ title: data.title || '', content: data.content || '', + existingFiles: data.files || [], }) } }, [data, setFormData]) + const handleDeleteFile = (id: number | string) => { + if (typeof id === 'number') { + setFormData((prev) => ({ + ...prev, + existingFiles: prev.existingFiles?.filter((file) => file.noticeFileId !== id), + })) + } else { + setFormData((prev) => ({ + ...prev, + newFiles: prev.newFiles?.filter((file) => file.name !== id), + })) + } + } + + const handleFileChange = (e: React.ChangeEvent<HTMLInputElement>) => { + const selectedFiles = Array.from(e.target.files || []) + + const hasDuplicateFile = (file: File) => { + return ( + formData.existingFiles?.some((existingFile) => existingFile.fileName === file.name) || + formData.newFiles?.some((newFile) => newFile.name === file.name) + ) + } + + const uniqueFiles = selectedFiles.filter((file) => !hasDuplicateFile(file)) + const duplicateFiles = selectedFiles.filter((file) => hasDuplicateFile(file)) + + if (duplicateFiles.length > 0) { + alert('이미 추가된 파일이 있습니다.') + return + } + + if (uniqueFiles.length > 0) { + onInputChange('newFiles', [...(formData.newFiles || []), ...uniqueFiles]) + } + + e.target.value = '' + } + return ( <> <BackHeader label="공지사항으로 돌아가기" /> @@ -75,13 +116,35 @@ const AdminNoticeEditPage = () => { <InputField label="파일첨부" Input={ - <FileInput - className={cx('file-input')} - onChange={(e) => onInputChange('files', Array.from(e.target.files || []))} - multiple - /> + <FileInput className={cx('file-input')} onChange={handleFileChange} multiple /> } /> + <div className={cx('notice-files-container')}> + {formData.existingFiles && formData.existingFiles.length > 0 && ( + <ul className={cx('notice-file-list')}> + {formData.existingFiles.map((file) => ( + <NoticeFileItem + key={file.noticeFileId} + id={file.noticeFileId} + name={file.fileName} + onDeleteFile={handleDeleteFile} + /> + ))} + </ul> + )} + {formData.newFiles && formData.newFiles.length > 0 && ( + <ul className={cx('notice-file-list')}> + {formData.newFiles.map((file) => ( + <NoticeFileItem + key={file.name} + id={file.name} + name={file.name} + onDeleteFile={handleDeleteFile} + /> + ))} + </ul> + )} + </div> </div> <Button size="small" type="submit" variant="filled"> 공지 수정하기 diff --git a/app/admin/notices/[noticeId]/edit/types.ts b/app/admin/notices/[noticeId]/edit/types.ts index e13d315a..de2d586f 100644 --- a/app/admin/notices/[noticeId]/edit/types.ts +++ b/app/admin/notices/[noticeId]/edit/types.ts @@ -1,5 +1,5 @@ import { APIResponseBaseModel } from '@/shared/types/response' -export interface PatchNoticeResponeseModel extends APIResponseBaseModel<boolean> { +export interface PatchNoticeResponseModel extends APIResponseBaseModel<boolean> { result: string[] } diff --git a/app/admin/notices/post/_hooks/query/use-post-notice.ts b/app/admin/notices/post/_hooks/query/use-post-notice.ts index 13a6875a..016b9021 100644 --- a/app/admin/notices/post/_hooks/query/use-post-notice.ts +++ b/app/admin/notices/post/_hooks/query/use-post-notice.ts @@ -21,17 +21,17 @@ const usePostNotice = () => { { title: formData.title, content: formData.content, - filePaths: formData?.files?.map((file) => file.name) ?? [], - sizes: formData?.files?.map((file) => file.size) ?? [], + filePaths: formData?.newFiles?.map((file) => file.name) ?? [], + sizes: formData?.newFiles?.map((file) => file.size) ?? [], } ) - const { files } = formData - if (!files) return + const { newFiles } = formData + if (!newFiles) return const presignedUrls = uploadResponse.data.result - await uploadFileWithPresignedUrl(files, presignedUrls) + await uploadFileWithPresignedUrl(newFiles, presignedUrls) }, onSuccess: () => { queryClient.invalidateQueries({ diff --git a/app/admin/notices/post/_hooks/use-notice-form.ts b/app/admin/notices/post/_hooks/use-notice-form.ts index a7079421..0d375652 100644 --- a/app/admin/notices/post/_hooks/use-notice-form.ts +++ b/app/admin/notices/post/_hooks/use-notice-form.ts @@ -1,17 +1,21 @@ import { useState } from 'react' -import { NoticeFormModel } from '../../types' +import { NoticeFileModel, NoticeFormModel } from '../../types' const useNoticeForm = ( initialValue = { title: '', content: '', - // files: [], + existingFiles: [], + newFiles: [], } ) => { const [formData, setFormData] = useState<NoticeFormModel>(initialValue) - const onInputChange = (name: keyof NoticeFormModel, value: string | File[]) => { + const onInputChange = ( + name: keyof NoticeFormModel, + value: string | File[] | NoticeFileModel[] + ) => { setFormData((prev) => ({ ...prev, [name]: value, diff --git a/app/admin/notices/post/page.tsx b/app/admin/notices/post/page.tsx index 1b8002a8..4f90ba1c 100644 --- a/app/admin/notices/post/page.tsx +++ b/app/admin/notices/post/page.tsx @@ -61,7 +61,7 @@ const AdminNoticePostPage = () => { Input={ <FileInput className={cx('file-input')} - onChange={(e) => onInputChange('files', Array.from(e.target.files || []))} + onChange={(e) => onInputChange('newFiles', Array.from(e.target.files || []))} multiple /> } diff --git a/app/admin/notices/types.ts b/app/admin/notices/types.ts index a4a9b047..9057c1c0 100644 --- a/app/admin/notices/types.ts +++ b/app/admin/notices/types.ts @@ -6,5 +6,11 @@ export interface DeleteNoticeResponeseModel extends APIResponseBaseModel<boolean export interface NoticeFormModel { title: string content: string - files?: File[] + existingFiles?: NoticeFileModel[] + newFiles?: File[] +} + +export interface NoticeFileModel { + fileName: string + noticeFileId: number } From 74de43bc9ba9d1037d2e856bdb50ebf96c3b1f8e Mon Sep 17 00:00:00 2001 From: deun <devdeun@gmail.com> Date: Mon, 13 Jan 2025 16:54:45 +0900 Subject: [PATCH 156/207] =?UTF-8?q?feat:=20=EA=B3=B5=EC=A7=80=20=EB=93=B1?= =?UTF-8?q?=EB=A1=9D=20=ED=8E=98=EC=9D=B4=EC=A7=80=EC=97=90=20=EC=B2=A8?= =?UTF-8?q?=EB=B6=80=20=ED=8C=8C=EC=9D=BC=20=EB=AA=A9=EB=A1=9D=20=EC=B6=9C?= =?UTF-8?q?=EB=A0=A5=20(#145)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- app/admin/notices/[noticeId]/edit/page.tsx | 2 +- app/admin/notices/post/page.module.scss | 8 ++++ app/admin/notices/post/page.tsx | 51 +++++++++++++++++++--- 3 files changed, 54 insertions(+), 7 deletions(-) diff --git a/app/admin/notices/[noticeId]/edit/page.tsx b/app/admin/notices/[noticeId]/edit/page.tsx index fd98d4af..7f038976 100644 --- a/app/admin/notices/[noticeId]/edit/page.tsx +++ b/app/admin/notices/[noticeId]/edit/page.tsx @@ -66,7 +66,7 @@ const AdminNoticeEditPage = () => { const duplicateFiles = selectedFiles.filter((file) => hasDuplicateFile(file)) if (duplicateFiles.length > 0) { - alert('이미 추가된 파일이 있습니다.') + alert('이미 추가된 파일입니다.') return } diff --git a/app/admin/notices/post/page.module.scss b/app/admin/notices/post/page.module.scss index 21a3cedc..7691081b 100644 --- a/app/admin/notices/post/page.module.scss +++ b/app/admin/notices/post/page.module.scss @@ -36,3 +36,11 @@ height: 30px; border: 1px solid $color-gray-300; } + +.notice-file-list { + @include typo-b3; + width: 640px; + margin-left: auto; + margin-top: -28px; + font-weight: 500; +} diff --git a/app/admin/notices/post/page.tsx b/app/admin/notices/post/page.tsx index 4f90ba1c..00b21d04 100644 --- a/app/admin/notices/post/page.tsx +++ b/app/admin/notices/post/page.tsx @@ -10,6 +10,7 @@ import Title from '@/shared/ui/title' import FileInput from '../../_ui/file-input' import InputField from '../../_ui/input-field' +import NoticeFileItem from '../_ui/notice-file-item' import usePostNotice from './_hooks/query/use-post-notice' import useNoticeForm from './_hooks/use-notice-form' import styles from './page.module.scss' @@ -17,9 +18,38 @@ import styles from './page.module.scss' const cx = classNames.bind(styles) const AdminNoticePostPage = () => { - const { formData, onInputChange } = useNoticeForm() + const { formData, onInputChange, setFormData } = useNoticeForm() const { mutate: postNotice, isPending } = usePostNotice() + const handleDeleteFile = (id: string | number) => { + setFormData((prev) => ({ + ...prev, + newFiles: prev.newFiles?.filter((file) => file.name !== id), + })) + } + + const handleFileChange = (e: React.ChangeEvent<HTMLInputElement>) => { + const selectedFiles = Array.from(e.target.files || []) + + const hasDuplicateFile = (file: File) => { + return formData.newFiles?.some((existingFile) => existingFile.name === file.name) + } + + const uniqueFiles = selectedFiles.filter((file) => !hasDuplicateFile(file)) + const duplicateFiles = selectedFiles.filter((file) => hasDuplicateFile(file)) + + if (duplicateFiles.length > 0) { + alert('이미 추가된 파일입니다.') + return + } + + if (uniqueFiles.length > 0) { + onInputChange('newFiles', [...(formData.newFiles || []), ...uniqueFiles]) + } + + e.target.value = '' + } + return ( <> <BackHeader label="공지사항으로 돌아가기" /> @@ -59,14 +89,23 @@ const AdminNoticePostPage = () => { <InputField label="파일첨부" Input={ - <FileInput - className={cx('file-input')} - onChange={(e) => onInputChange('newFiles', Array.from(e.target.files || []))} - multiple - /> + <FileInput className={cx('file-input')} onChange={handleFileChange} multiple /> } /> + {formData.newFiles && formData.newFiles.length > 0 && ( + <ul className={cx('notice-file-list')}> + {formData.newFiles.map((file) => ( + <NoticeFileItem + key={file.name} + id={file.name} + name={file.name} + onDeleteFile={handleDeleteFile} + /> + ))} + </ul> + )} </div> + <Button disabled={isPending} size="small" type="submit" variant="filled"> 공지 등록하기 </Button> From b7fae6265c3b3973756293b5187ac9074be2437b Mon Sep 17 00:00:00 2001 From: deun <devdeun@gmail.com> Date: Mon, 13 Jan 2025 20:07:49 +0900 Subject: [PATCH 157/207] =?UTF-8?q?refactor:=20file=20handler=20=ED=9B=85?= =?UTF-8?q?=EC=9C=BC=EB=A1=9C=20=EB=B6=84=EB=A6=AC=20(#145)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- app/admin/notices/[noticeId]/edit/page.tsx | 45 +++-------------- app/admin/notices/_hook/use-file-handler.ts | 56 +++++++++++++++++++++ app/admin/notices/post/page.tsx | 35 +++---------- 3 files changed, 68 insertions(+), 68 deletions(-) create mode 100644 app/admin/notices/_hook/use-file-handler.ts diff --git a/app/admin/notices/[noticeId]/edit/page.tsx b/app/admin/notices/[noticeId]/edit/page.tsx index 7f038976..b8be4229 100644 --- a/app/admin/notices/[noticeId]/edit/page.tsx +++ b/app/admin/notices/[noticeId]/edit/page.tsx @@ -15,6 +15,7 @@ import Input from '@/shared/ui/input' import Textarea from '@/shared/ui/textarea' import Title from '@/shared/ui/title' +import useFileHandler from '../../_hook/use-file-handler' import NoticeFileItem from '../../_ui/notice-file-item' import useNoticeForm from '../../post/_hooks/use-notice-form' import usePatchNotice from './_hooks/query/use-patch-notice' @@ -27,6 +28,11 @@ const AdminNoticeEditPage = () => { const { data } = useNoticeDetail(Number(noticeId as string)) const { formData, setFormData, onInputChange } = useNoticeForm() const { mutate } = usePatchNotice(formData, Number(noticeId as string)) + const { handleDeleteFile, handleFileChange } = useFileHandler({ + formData, + setFormData, + onInputChange, + }) useEffect(() => { if (data) { @@ -38,45 +44,6 @@ const AdminNoticeEditPage = () => { } }, [data, setFormData]) - const handleDeleteFile = (id: number | string) => { - if (typeof id === 'number') { - setFormData((prev) => ({ - ...prev, - existingFiles: prev.existingFiles?.filter((file) => file.noticeFileId !== id), - })) - } else { - setFormData((prev) => ({ - ...prev, - newFiles: prev.newFiles?.filter((file) => file.name !== id), - })) - } - } - - const handleFileChange = (e: React.ChangeEvent<HTMLInputElement>) => { - const selectedFiles = Array.from(e.target.files || []) - - const hasDuplicateFile = (file: File) => { - return ( - formData.existingFiles?.some((existingFile) => existingFile.fileName === file.name) || - formData.newFiles?.some((newFile) => newFile.name === file.name) - ) - } - - const uniqueFiles = selectedFiles.filter((file) => !hasDuplicateFile(file)) - const duplicateFiles = selectedFiles.filter((file) => hasDuplicateFile(file)) - - if (duplicateFiles.length > 0) { - alert('이미 추가된 파일입니다.') - return - } - - if (uniqueFiles.length > 0) { - onInputChange('newFiles', [...(formData.newFiles || []), ...uniqueFiles]) - } - - e.target.value = '' - } - return ( <> <BackHeader label="공지사항으로 돌아가기" /> diff --git a/app/admin/notices/_hook/use-file-handler.ts b/app/admin/notices/_hook/use-file-handler.ts new file mode 100644 index 00000000..7d0d1b17 --- /dev/null +++ b/app/admin/notices/_hook/use-file-handler.ts @@ -0,0 +1,56 @@ +import { SetStateAction } from 'react' + +import { NoticeFileModel, NoticeFormModel } from '../types' + +interface UseFileHandlerProps { + formData: NoticeFormModel + setFormData: React.Dispatch<SetStateAction<NoticeFormModel>> + onInputChange: (name: keyof NoticeFormModel, value: string | File[] | NoticeFileModel[]) => void +} + +const useFileHandler = ({ formData, setFormData, onInputChange }: UseFileHandlerProps) => { + const hasDuplicateFile = (file: File) => { + return ( + formData.existingFiles?.some((existingFile) => existingFile.fileName === file.name) || + formData.newFiles?.some((newFile) => newFile.name === file.name) + ) + } + + const handleFileChange = (e: React.ChangeEvent<HTMLInputElement>) => { + const selectedFiles = Array.from(e.target.files || []) + + const uniqueFiles = selectedFiles.filter((file) => !hasDuplicateFile(file)) + const duplicateFiles = selectedFiles.filter((file) => hasDuplicateFile(file)) + + if (duplicateFiles.length > 0) { + alert('이미 추가된 파일입니다.') + } + + if (uniqueFiles.length > 0) { + onInputChange('newFiles', [...(formData.newFiles || []), ...uniqueFiles]) + } + + e.target.value = '' + } + + const handleDeleteFile = (id: number | string) => { + if (typeof id === 'number') { + setFormData((prev) => ({ + ...prev, + existingFiles: prev.existingFiles?.filter((file) => file.noticeFileId !== id), + })) + } else { + setFormData((prev) => ({ + ...prev, + newFiles: prev.newFiles?.filter((file) => file.name !== id), + })) + } + } + + return { + handleFileChange, + handleDeleteFile, + } +} + +export default useFileHandler diff --git a/app/admin/notices/post/page.tsx b/app/admin/notices/post/page.tsx index 00b21d04..470a64ea 100644 --- a/app/admin/notices/post/page.tsx +++ b/app/admin/notices/post/page.tsx @@ -10,6 +10,7 @@ import Title from '@/shared/ui/title' import FileInput from '../../_ui/file-input' import InputField from '../../_ui/input-field' +import useFileHandler from '../_hook/use-file-handler' import NoticeFileItem from '../_ui/notice-file-item' import usePostNotice from './_hooks/query/use-post-notice' import useNoticeForm from './_hooks/use-notice-form' @@ -20,35 +21,11 @@ const cx = classNames.bind(styles) const AdminNoticePostPage = () => { const { formData, onInputChange, setFormData } = useNoticeForm() const { mutate: postNotice, isPending } = usePostNotice() - - const handleDeleteFile = (id: string | number) => { - setFormData((prev) => ({ - ...prev, - newFiles: prev.newFiles?.filter((file) => file.name !== id), - })) - } - - const handleFileChange = (e: React.ChangeEvent<HTMLInputElement>) => { - const selectedFiles = Array.from(e.target.files || []) - - const hasDuplicateFile = (file: File) => { - return formData.newFiles?.some((existingFile) => existingFile.name === file.name) - } - - const uniqueFiles = selectedFiles.filter((file) => !hasDuplicateFile(file)) - const duplicateFiles = selectedFiles.filter((file) => hasDuplicateFile(file)) - - if (duplicateFiles.length > 0) { - alert('이미 추가된 파일입니다.') - return - } - - if (uniqueFiles.length > 0) { - onInputChange('newFiles', [...(formData.newFiles || []), ...uniqueFiles]) - } - - e.target.value = '' - } + const { handleDeleteFile, handleFileChange } = useFileHandler({ + formData, + onInputChange, + setFormData, + }) return ( <> From 51e67fd164d99244cb0c5d9aac7e2083ca04c42e Mon Sep 17 00:00:00 2001 From: deun <devdeun@gmail.com> Date: Mon, 13 Jan 2025 20:24:09 +0900 Subject: [PATCH 158/207] =?UTF-8?q?feat:=20=EC=A4=91=EB=B3=B5=20=ED=8C=8C?= =?UTF-8?q?=EC=9D=BC=20alert=EB=A5=BC=20=EB=AA=A8=EB=8B=AC=EB=A1=9C=20?= =?UTF-8?q?=EA=B5=90=EC=B2=B4=20(#145)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- app/admin/notices/[noticeId]/edit/page.tsx | 9 +++++- app/admin/notices/_hook/use-file-handler.ts | 15 ++++++++-- app/admin/notices/post/page.tsx | 9 +++++- shared/ui/modal/notification-modal/index.tsx | 30 ++++++++++++++++++++ 4 files changed, 59 insertions(+), 4 deletions(-) create mode 100644 shared/ui/modal/notification-modal/index.tsx diff --git a/app/admin/notices/[noticeId]/edit/page.tsx b/app/admin/notices/[noticeId]/edit/page.tsx index b8be4229..0df78275 100644 --- a/app/admin/notices/[noticeId]/edit/page.tsx +++ b/app/admin/notices/[noticeId]/edit/page.tsx @@ -12,6 +12,7 @@ import classNames from 'classnames/bind' import { Button } from '@/shared/ui/button' import BackHeader from '@/shared/ui/header/back-header' import Input from '@/shared/ui/input' +import NotificationModal from '@/shared/ui/modal/notification-modal' import Textarea from '@/shared/ui/textarea' import Title from '@/shared/ui/title' @@ -28,7 +29,7 @@ const AdminNoticeEditPage = () => { const { data } = useNoticeDetail(Number(noticeId as string)) const { formData, setFormData, onInputChange } = useNoticeForm() const { mutate } = usePatchNotice(formData, Number(noticeId as string)) - const { handleDeleteFile, handleFileChange } = useFileHandler({ + const { handleDeleteFile, handleFileChange, isModalOpen, handleCloseModal } = useFileHandler({ formData, setFormData, onInputChange, @@ -118,6 +119,12 @@ const AdminNoticeEditPage = () => { </Button> </form> </div> + + <NotificationModal + isOpen={isModalOpen} + onClose={handleCloseModal} + message="이미 추가된 파일입니다." + /> </> ) } diff --git a/app/admin/notices/_hook/use-file-handler.ts b/app/admin/notices/_hook/use-file-handler.ts index 7d0d1b17..752438fa 100644 --- a/app/admin/notices/_hook/use-file-handler.ts +++ b/app/admin/notices/_hook/use-file-handler.ts @@ -1,4 +1,6 @@ -import { SetStateAction } from 'react' +'use client' + +import { SetStateAction, useState } from 'react' import { NoticeFileModel, NoticeFormModel } from '../types' @@ -9,6 +11,8 @@ interface UseFileHandlerProps { } const useFileHandler = ({ formData, setFormData, onInputChange }: UseFileHandlerProps) => { + const [isModalOpen, setIsModalOpen] = useState(false) + const hasDuplicateFile = (file: File) => { return ( formData.existingFiles?.some((existingFile) => existingFile.fileName === file.name) || @@ -23,7 +27,8 @@ const useFileHandler = ({ formData, setFormData, onInputChange }: UseFileHandler const duplicateFiles = selectedFiles.filter((file) => hasDuplicateFile(file)) if (duplicateFiles.length > 0) { - alert('이미 추가된 파일입니다.') + setIsModalOpen(true) + return } if (uniqueFiles.length > 0) { @@ -47,9 +52,15 @@ const useFileHandler = ({ formData, setFormData, onInputChange }: UseFileHandler } } + const handleCloseModal = () => { + setIsModalOpen(false) + } + return { handleFileChange, handleDeleteFile, + handleCloseModal, + isModalOpen, } } diff --git a/app/admin/notices/post/page.tsx b/app/admin/notices/post/page.tsx index 470a64ea..19486956 100644 --- a/app/admin/notices/post/page.tsx +++ b/app/admin/notices/post/page.tsx @@ -5,6 +5,7 @@ import classNames from 'classnames/bind' import { Button } from '@/shared/ui/button' import BackHeader from '@/shared/ui/header/back-header' import Input from '@/shared/ui/input' +import NotificationModal from '@/shared/ui/modal/notification-modal' import Textarea from '@/shared/ui/textarea' import Title from '@/shared/ui/title' @@ -21,7 +22,7 @@ const cx = classNames.bind(styles) const AdminNoticePostPage = () => { const { formData, onInputChange, setFormData } = useNoticeForm() const { mutate: postNotice, isPending } = usePostNotice() - const { handleDeleteFile, handleFileChange } = useFileHandler({ + const { handleDeleteFile, handleFileChange, isModalOpen, handleCloseModal } = useFileHandler({ formData, onInputChange, setFormData, @@ -87,6 +88,12 @@ const AdminNoticePostPage = () => { 공지 등록하기 </Button> </form> + + <NotificationModal + isOpen={isModalOpen} + onClose={handleCloseModal} + message="이미 추가된 파일입니다." + /> </div> </> ) diff --git a/shared/ui/modal/notification-modal/index.tsx b/shared/ui/modal/notification-modal/index.tsx new file mode 100644 index 00000000..bb9c586a --- /dev/null +++ b/shared/ui/modal/notification-modal/index.tsx @@ -0,0 +1,30 @@ +'use client' + +import { ModalAlertIcon } from '@/public/icons' +import classNames from 'classnames/bind' + +import { Button } from '@/shared/ui/button' +import Modal from '@/shared/ui/modal' + +import styles from '../styles.module.scss' + +const cx = classNames.bind(styles) + +interface Props { + isOpen: boolean + message: string + onClose: () => void +} + +const NotificationModal = ({ isOpen, message, onClose }: Props) => { + return ( + <Modal isOpen={isOpen} icon={ModalAlertIcon}> + <span className={cx('message')}>{message}</span> + <div className={cx('two-button')}> + <Button onClick={onClose}>닫기</Button> + </div> + </Modal> + ) +} + +export default NotificationModal From 833f624b91bd07880070754eaaddf504238150d9 Mon Sep 17 00:00:00 2001 From: deun <devdeun@gmail.com> Date: Mon, 13 Jan 2025 20:29:24 +0900 Subject: [PATCH 159/207] =?UTF-8?q?fix:=20=EB=B0=B1=EC=97=94=EB=93=9C=20?= =?UTF-8?q?=EC=B8=A1=20=ED=8C=8C=EC=9D=BC=20=EC=B2=A8=EB=B6=80=20=ED=97=88?= =?UTF-8?q?=EC=9A=A9=20=ED=99=95=EC=9E=A5=EC=9E=90=20=EC=A0=81=EC=9A=A9=20?= =?UTF-8?q?(#145)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- app/admin/notices/[noticeId]/edit/page.tsx | 7 ++++++- app/admin/notices/post/page.tsx | 7 ++++++- 2 files changed, 12 insertions(+), 2 deletions(-) diff --git a/app/admin/notices/[noticeId]/edit/page.tsx b/app/admin/notices/[noticeId]/edit/page.tsx index 0df78275..0f6e4e3c 100644 --- a/app/admin/notices/[noticeId]/edit/page.tsx +++ b/app/admin/notices/[noticeId]/edit/page.tsx @@ -84,7 +84,12 @@ const AdminNoticeEditPage = () => { <InputField label="파일첨부" Input={ - <FileInput className={cx('file-input')} onChange={handleFileChange} multiple /> + <FileInput + className={cx('file-input')} + onChange={handleFileChange} + multiple + accept=".jpg,.jpeg,.png,.doc,.docx,.pptx,.ppt" + /> } /> <div className={cx('notice-files-container')}> diff --git a/app/admin/notices/post/page.tsx b/app/admin/notices/post/page.tsx index 19486956..1369fb6d 100644 --- a/app/admin/notices/post/page.tsx +++ b/app/admin/notices/post/page.tsx @@ -67,7 +67,12 @@ const AdminNoticePostPage = () => { <InputField label="파일첨부" Input={ - <FileInput className={cx('file-input')} onChange={handleFileChange} multiple /> + <FileInput + className={cx('file-input')} + onChange={handleFileChange} + multiple + accept=".jpg,.jpeg,.png,.doc,.docx,.pptx,.ppt" + /> } /> {formData.newFiles && formData.newFiles.length > 0 && ( From b0b30d933bd80b4f01ed44d107f7faa171b7dde7 Mon Sep 17 00:00:00 2001 From: James <dawn147@snu.ac.kr> Date: Tue, 14 Jan 2025 14:10:14 +0900 Subject: [PATCH 160/207] =?UTF-8?q?feat:=20=EC=A0=84=EB=9E=B5=20=EB=93=B1?= =?UTF-8?q?=EB=A1=9D=20=EC=A0=9C=EC=95=88=EC=84=9C=20=EA=B4=80=EB=A0=A8=20?= =?UTF-8?q?=EA=B8=B0=EB=8A=A5=20=EC=B6=94=EA=B0=80=20=EB=98=90=EB=8A=94=20?= =?UTF-8?q?=EB=B3=80=EA=B2=BD=20(#118)=20-=20=EC=97=91=EC=85=80=20?= =?UTF-8?q?=EB=BF=90=EB=A7=8C=20=EC=95=84=EB=8B=88=EB=9D=BC=20=EB=8B=A4?= =?UTF-8?q?=EB=A5=B8=20=ED=8C=8C=EC=9D=BC=20=ED=98=95=EC=8B=9D=20=EB=B0=9B?= =?UTF-8?q?=EB=8F=84=EB=A1=9D=20=EC=88=98=EC=A0=95=20-=20=EC=A0=84?= =?UTF-8?q?=EB=9E=B5=20=EC=A0=9C=EC=95=88=EC=84=9C=EB=A5=BC=20=EB=93=B1?= =?UTF-8?q?=EB=A1=9D=ED=95=B4=EC=A3=BC=EC=84=B8=EC=9A=94=EB=A1=9C=20placeh?= =?UTF-8?q?older=20=EC=88=98=EC=A0=95=20-=20=EC=A0=9C=EC=95=88=EC=84=9C=20?= =?UTF-8?q?=EC=98=B5=EC=85=98=20=EC=82=AC=ED=95=AD=EC=9D=B4=EB=9D=BC?= =?UTF-8?q?=EB=8A=94=20=EB=AC=B8=EA=B5=AC=20=EC=B6=94=EA=B0=80=20-=20?= =?UTF-8?q?=EC=A0=9C=EC=95=88=EC=84=9C=20=EC=97=85=EB=A1=9C=EB=93=9C?= =?UTF-8?q?=ED=95=98=EC=A7=80=20=EC=95=8A=EC=95=84=EB=8F=84=20form=20?= =?UTF-8?q?=EC=A0=9C=EC=B6=9C=20=EB=90=98=EB=8F=84=EB=A1=9D=20=EC=88=98?= =?UTF-8?q?=EC=A0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- app/(dashboard)/my/strategies/add/page.tsx | 48 ++++++++-------------- 1 file changed, 16 insertions(+), 32 deletions(-) diff --git a/app/(dashboard)/my/strategies/add/page.tsx b/app/(dashboard)/my/strategies/add/page.tsx index f4a7f5cb..5023c9a9 100644 --- a/app/(dashboard)/my/strategies/add/page.tsx +++ b/app/(dashboard)/my/strategies/add/page.tsx @@ -41,10 +41,7 @@ interface StrategyFormDataModel { interface FormErrorsModel { strategyName: string - tradeType: string - operationCycle: string stockTypes: string - minimumInvestmentAmount: string description: string proposalFile?: string } @@ -60,10 +57,7 @@ const initialFormData: StrategyFormDataModel = { const initialFormErrors: FormErrorsModel = { strategyName: '', - tradeType: '', - operationCycle: '', stockTypes: '', - minimumInvestmentAmount: '', description: '', proposalFile: '', } @@ -78,12 +72,7 @@ const StrategyAddPage = () => { const validateForm = (): boolean => { const newErrors = { strategyName: !formData.strategyName ? '전략 명칭을 입력해주세요.' : '', - tradeType: !formData.tradeType ? '매매 유형을 선택해주세요.' : '', - operationCycle: !formData.operationCycle ? '주기를 선택해주세요.' : '', stockTypes: formData.stockTypes.length === 0 ? '종목을 선택해주세요.' : '', - minimumInvestmentAmount: !formData.minimumInvestmentAmount - ? '최소 운용가능 금액을 선택해주세요.' - : '', description: !formData.description ? '전략 소개를 입력해주세요.' : '', } @@ -93,16 +82,20 @@ const StrategyAddPage = () => { const handleFileChange = (e: React.ChangeEvent<HTMLInputElement>) => { const file = e.target.files?.[0] - const isValidExcelFile = - file && - (file.type === 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet' || - file.type === 'application/vnd.ms-excel') - - if (isValidExcelFile) { + const supportedTypes = [ + 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet', + 'application/vnd.ms-excel', + 'application/pdf', + 'application/msword', + 'application/vnd.openxmlformats-officedocument.wordprocessingml.document', + 'text/plain', + ] + + if (file && supportedTypes.includes(file.type)) { setFormData((prev) => ({ ...prev, proposalFile: file })) setFormErrors((prev) => ({ ...prev, proposalFile: '' })) } else { - setFormErrors((prev) => ({ ...prev, proposalFile: '엑셀 파일만 업로드 가능합니다.' })) + setFormErrors((prev) => ({ ...prev, proposalFile: '지원되는 파일 형식이 아닙니다.' })) } } @@ -169,13 +162,13 @@ const StrategyAddPage = () => { <div className={cx('file-upload')}> <input type="file" - accept=".xlsx,.xls" + accept=".xlsx,.xls,.pdf,.doc,.docx,.txt" onChange={handleFileChange} id="proposalFile" className={cx('file-input')} /> <label htmlFor="proposalFile" className={cx('file-label')}> - <span>{formData.proposalFile?.name || '엑셀 제안서를 업로드해주세요'}</span> + <span>{formData.proposalFile?.name || '전략 제안서를 등록해주세요'}</span> <FileIcon className={cx('file-icon')} /> </label> </div> @@ -197,8 +190,6 @@ const StrategyAddPage = () => { <Title label="전략 등록" /> <div className={cx('container')}> <form onSubmit={handleSubmit} className={cx('form')}> - {error && <div className={cx('error')}>{error}</div>} - <div className={cx('form-row')}> <label>전략 명칭</label> <div className={cx('form-field')}> @@ -225,9 +216,6 @@ const StrategyAddPage = () => { titleStyle={{ width: '160px', height: '45px', border: '1px solid #ccc' }} size="large" /> - {formErrors.tradeType && ( - <div className={cx('field-error')}>{formErrors.tradeType}</div> - )} </div> </div> @@ -242,9 +230,6 @@ const StrategyAddPage = () => { titleStyle={{ width: '160px', height: '45px', border: '1px solid #ccc' }} size="large" /> - {formErrors.operationCycle && ( - <div className={cx('field-error')}>{formErrors.operationCycle}</div> - )} </div> </div> </div> @@ -270,9 +255,6 @@ const StrategyAddPage = () => { titleStyle={{ width: '160px', height: '45px', border: '1px solid #ccc' }} size="large" /> - {formErrors.minimumInvestmentAmount && ( - <div className={cx('field-error')}>{formErrors.minimumInvestmentAmount}</div> - )} </div> </div> @@ -302,9 +284,11 @@ const StrategyAddPage = () => { </div> <p className={cx('notice')}> + *제안서는 선택 사항입니다. (허용 파일: xlsx, xls, pdf, doc, docx, txt) + <br /> *매매 유형, 주기, 종목, 최소운용 가능 금액은 추후 수정이 불가합니다. </p> - + {error && <div className={cx('error')}>{error}</div>} <div className={cx('button-wrapper')}> <Button variant="outline" onClick={() => router.back()} type="button"> 취소 From 2410b2be3e1ac9fcbe06150c20dad5700df70c6b Mon Sep 17 00:00:00 2001 From: James <dawn147@snu.ac.kr> Date: Tue, 14 Jan 2025 14:10:48 +0900 Subject: [PATCH 161/207] =?UTF-8?q?style:=20=EC=97=90=EB=9F=AC=EB=A9=94?= =?UTF-8?q?=EC=8B=9C=EC=A7=80=20=EA=B4=80=EB=A0=A8=20=EC=8A=A4=ED=83=80?= =?UTF-8?q?=EC=9D=BC=20=ED=86=B5=EC=9D=BC=20(#118)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../my/strategies/add/styles.module.scss | 17 +++++++---------- shared/ui/error-message/styles.module.scss | 1 + 2 files changed, 8 insertions(+), 10 deletions(-) diff --git a/app/(dashboard)/my/strategies/add/styles.module.scss b/app/(dashboard)/my/strategies/add/styles.module.scss index 3c2c4cb6..0e4f4744 100644 --- a/app/(dashboard)/my/strategies/add/styles.module.scss +++ b/app/(dashboard)/my/strategies/add/styles.module.scss @@ -52,18 +52,14 @@ } .error { - margin-bottom: 16px; - padding: 12px; - border-radius: 4px; - background-color: $color-white; - color: $color-orange-700; - font-size: 14px; + margin-top: -1rem; } -.field-error { - margin-top: 8px; - color: $color-orange-700; - font-size: 14px; +.field-error, +.error { + color: $color-orange-800; + font-size: $text-b3; + font-weight: $text-medium; } .loading { @@ -179,4 +175,5 @@ @include typo-c1; color: $color-gray-600; margin: 36px 0; + line-height: 1.2rem; } diff --git a/shared/ui/error-message/styles.module.scss b/shared/ui/error-message/styles.module.scss index 5163dd56..82560d80 100644 --- a/shared/ui/error-message/styles.module.scss +++ b/shared/ui/error-message/styles.module.scss @@ -1,4 +1,5 @@ .error-message { + margin-top: 4px; color: $color-orange-800; font-size: $text-b3; font-weight: $text-medium; From d0f5ebb3d3c9493dfb17e6620e662ce3353e2d72 Mon Sep 17 00:00:00 2001 From: James <dawn147@snu.ac.kr> Date: Tue, 14 Jan 2025 14:15:53 +0900 Subject: [PATCH 162/207] =?UTF-8?q?feat:=20=EC=A0=9C=EC=95=88=EC=84=9C=20?= =?UTF-8?q?=ED=98=95=EC=8B=9D=20ppt=20=EC=B6=94=EA=B0=80=20(#118)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- app/(dashboard)/my/strategies/add/page.tsx | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/app/(dashboard)/my/strategies/add/page.tsx b/app/(dashboard)/my/strategies/add/page.tsx index 5023c9a9..b9c16c81 100644 --- a/app/(dashboard)/my/strategies/add/page.tsx +++ b/app/(dashboard)/my/strategies/add/page.tsx @@ -89,6 +89,8 @@ const StrategyAddPage = () => { 'application/msword', 'application/vnd.openxmlformats-officedocument.wordprocessingml.document', 'text/plain', + 'application/vnd.ms-powerpoint', + 'application/vnd.openxmlformats-officedocument.presentationml.presentation', ] if (file && supportedTypes.includes(file.type)) { @@ -162,7 +164,7 @@ const StrategyAddPage = () => { <div className={cx('file-upload')}> <input type="file" - accept=".xlsx,.xls,.pdf,.doc,.docx,.txt" + accept=".xlsx,.xls,.pdf,.doc,.docx,.txt,.ppt,.pptx" onChange={handleFileChange} id="proposalFile" className={cx('file-input')} @@ -284,7 +286,7 @@ const StrategyAddPage = () => { </div> <p className={cx('notice')}> - *제안서는 선택 사항입니다. (허용 파일: xlsx, xls, pdf, doc, docx, txt) + *제안서는 선택 사항입니다. (허용 파일: xlsx, xls, pdf, doc, docx, txt, ppt, pptx) <br /> *매매 유형, 주기, 종목, 최소운용 가능 금액은 추후 수정이 불가합니다. </p> From 90f5f9624a53a5565d955f46eb4dc05e9539411d Mon Sep 17 00:00:00 2001 From: deun <devdeun@gmail.com> Date: Tue, 14 Jan 2025 15:08:11 +0900 Subject: [PATCH 163/207] =?UTF-8?q?fix:=20=EA=B4=80=EB=A6=AC=EC=9E=90?= =?UTF-8?q?=EA=B0=80=20=EB=A6=AC=EB=B7=B0=EB=A5=BC=20=EC=82=AD=EC=A0=9C?= =?UTF-8?q?=ED=95=A0=20=EC=88=98=20=EC=9E=88=EB=8F=84=EB=A1=9D=20onClick?= =?UTF-8?q?=20=EC=9D=B4=EB=B2=A4=ED=8A=B8=20=EC=B6=94=EA=B0=80=20(#148)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../[strategyId]/_ui/review-container/review-item.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/app/(dashboard)/strategies/[strategyId]/_ui/review-container/review-item.tsx b/app/(dashboard)/strategies/[strategyId]/_ui/review-container/review-item.tsx index 3bfa2726..5867515d 100644 --- a/app/(dashboard)/strategies/[strategyId]/_ui/review-container/review-item.tsx +++ b/app/(dashboard)/strategies/[strategyId]/_ui/review-container/review-item.tsx @@ -72,7 +72,7 @@ const ReviewItem = ({ <button onClick={openModal}>삭제</button> </> )} - {!isReviewer && isAdmin && <button>삭제</button>} + {!isReviewer && isAdmin && <button onClick={openModal}>삭제</button>} </div> </div> {isEditable ? ( From cd29dbe13231dcfe8d0755abc49448c2f9e3e340 Mon Sep 17 00:00:00 2001 From: James <dawn147@snu.ac.kr> Date: Tue, 14 Jan 2025 19:21:34 +0900 Subject: [PATCH 164/207] =?UTF-8?q?feat:=20vertical=20table=EC=97=90=20?= =?UTF-8?q?=EC=8A=A4=EC=BC=88=EB=A0=88=ED=86=A4=20=EC=A0=9C=EB=8C=80?= =?UTF-8?q?=EB=A1=9C=20=EC=A0=81=EC=9A=A9=EB=90=A0=20=EC=88=98=20=EC=9E=88?= =?UTF-8?q?=EB=8F=84=EB=A1=9D=20=EC=88=98=EC=A0=95(#151)=20-=20delayedLoad?= =?UTF-8?q?ing=20=ED=9B=85=EC=9C=BC=EB=A1=9C=20=EC=8A=A4=EC=BC=88=EB=A0=88?= =?UTF-8?q?=ED=86=A4=EC=9D=B4=20=EC=B5=9C=EC=86=8C=200.5=EC=B4=88=20?= =?UTF-8?q?=EB=8F=99=EC=95=88=EC=9D=80=20=EB=B3=B4=EC=9D=BC=20=EC=88=98=20?= =?UTF-8?q?=EC=9E=88=EB=8F=84=EB=A1=9D=20=ED=95=A8=20-=20=EA=B8=B0?= =?UTF-8?q?=EC=A1=B4=20=EC=85=80=20=EB=82=B4=EC=9A=A9=20=EB=84=88=EB=B9=84?= =?UTF-8?q?=EC=97=90=20=EB=94=B0=EB=9D=BC=20=ED=85=8C=EC=9D=B4=EB=B8=94=20?= =?UTF-8?q?=EC=85=80=20=EA=B0=84=EA=B2=A9=EC=9D=B4=20=EB=B0=94=EB=80=8C?= =?UTF-8?q?=EB=8A=94=20=EB=B6=80=EB=B6=84=20=EA=B3=A0=EC=A0=95=20=EB=B9=84?= =?UTF-8?q?=EC=9C=A8=EB=A1=9C=20=EC=88=98=EC=A0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../analysis-container/analysis-content.tsx | 24 ++++++--- shared/hooks/custom/use-delayed-loading.ts | 23 ++++++++ shared/ui/table/vertical/index.tsx | 54 +++++++++++++++++-- shared/ui/table/vertical/styles.module.scss | 47 ++++++++++++++++ 4 files changed, 138 insertions(+), 10 deletions(-) create mode 100644 shared/hooks/custom/use-delayed-loading.ts diff --git a/app/(dashboard)/_ui/analysis-container/analysis-content.tsx b/app/(dashboard)/_ui/analysis-container/analysis-content.tsx index 3013552f..898668d8 100644 --- a/app/(dashboard)/_ui/analysis-container/analysis-content.tsx +++ b/app/(dashboard)/_ui/analysis-container/analysis-content.tsx @@ -3,6 +3,7 @@ import { useState } from 'react' import classNames from 'classnames/bind' import { ANALYSIS_PAGE_COUNT } from '@/shared/constants/count-per-page' +import useDelayedLoading from '@/shared/hooks/custom/use-delayed-loading' import useModal from '@/shared/hooks/custom/use-modal' import { MyDailyAnalysisModel } from '@/shared/types/strategy-data' import { Button } from '@/shared/ui/button' @@ -49,6 +50,7 @@ const AnalysisContent = ({ onPageChange, isEditable = false, }: Props) => { + const COLWIDTH = [1.7, 1.7, 1.3, 1.5, 1, 1.5, 1] const { mutate } = useGetAnalysisDownload() const [uploadType, setUploadType] = useState<'excel' | 'direct' | null>(null) const [selectedAnalysis, setSelectedAnalysis] = useState<MyDailyAnalysisModel | null>(null) @@ -65,12 +67,12 @@ const AnalysisContent = ({ closeModal: closeEditModal, } = useModal() - const { data: myAnalysisData } = useGetMyDailyAnalysis( + const { data: myAnalysisData, isLoading: isMyAnalysisLoading } = useGetMyDailyAnalysis( strategyId, currentPage, ANALYSIS_PAGE_COUNT ) - const { data: publicAnalysisData } = useGetAnalysis( + const { data: publicAnalysisData, isLoading: isPublicAnalysisLoading } = useGetAnalysis( strategyId, type, currentPage, @@ -78,8 +80,10 @@ const AnalysisContent = ({ ) const analysisData = isEditable ? myAnalysisData : publicAnalysisData + const isLoading = isEditable ? isMyAnalysisLoading : isPublicAnalysisLoading + const isDelayedLoading = useDelayedLoading(isLoading, 500) - const { deleteAllAnalysis, isLoading } = useAnalysisUploadMutation( + const { deleteAllAnalysis, isLoading: isDeleteAllLoading } = useAnalysisUploadMutation( strategyId, currentPage, ANALYSIS_PAGE_COUNT @@ -193,14 +197,21 @@ const AnalysisContent = ({ size="small" variant="filled" onClick={openDeleteModal} - disabled={isLoading} + disabled={isDeleteAllLoading} className={cx('delete-all-button')} > 전체 삭제 </Button> </div> )} - {analysisData?.content.length > 0 ? ( + {isDelayedLoading ? ( + <VerticalTable.Skeleton + tableHead={tableHeader} + countPerPage={ANALYSIS_PAGE_COUNT} + renderActions={isEditable ? renderActions : undefined} + colWidths={COLWIDTH} + /> + ) : analysisData?.content.length > 0 ? ( <> <VerticalTable tableHead={tableHeader} @@ -209,6 +220,7 @@ const AnalysisContent = ({ countPerPage={ANALYSIS_PAGE_COUNT} renderActions={isEditable ? renderActions : undefined} hideFirstColumn={isEditable} + colWidths={COLWIDTH} /> <Pagination currentPage={currentPage} @@ -252,7 +264,7 @@ const AnalysisContent = ({ isModalOpen={isDeleteModalOpen} onCloseModal={closeDeleteModal} onDelete={handleDeleteAll} - isPending={isLoading} + isPending={isDeleteAllLoading} /> </div> ) diff --git a/shared/hooks/custom/use-delayed-loading.ts b/shared/hooks/custom/use-delayed-loading.ts new file mode 100644 index 00000000..b22872b0 --- /dev/null +++ b/shared/hooks/custom/use-delayed-loading.ts @@ -0,0 +1,23 @@ +import { useEffect, useState } from 'react' + +const useDelayedLoading = (isLoading: boolean, minLoadingTime: number = 500) => { + const [isDelayedLoading, setDelayedLoading] = useState(isLoading) + + useEffect(() => { + if (isLoading) { + setDelayedLoading(true) + } + + if (!isLoading && isDelayedLoading) { + const timer = setTimeout(() => { + setDelayedLoading(false) + }, minLoadingTime) + + return () => clearTimeout(timer) + } + }, [isLoading, minLoadingTime, isDelayedLoading]) + + return isDelayedLoading +} + +export default useDelayedLoading diff --git a/shared/ui/table/vertical/index.tsx b/shared/ui/table/vertical/index.tsx index 9d5ca19e..ac912f10 100644 --- a/shared/ui/table/vertical/index.tsx +++ b/shared/ui/table/vertical/index.tsx @@ -32,6 +32,7 @@ export interface VerticalTableProps { className?: string renderActions?: (row: TableBodyDataType) => ReactNode | null hideFirstColumn?: boolean + colWidths?: number[] } const VerticalTable = ({ @@ -42,13 +43,24 @@ const VerticalTable = ({ className, renderActions, hideFirstColumn = false, + colWidths = [], }: VerticalTableProps) => { const hasData = tableBody.length > 0 const slicedTableBody = sliceArray(tableBody, countPerPage, currentPage) + const widths = colWidths.length > 0 ? colWidths : new Array(tableHead.length).fill(1) + + const totalWidth = widths.reduce((sum, width) => sum + width, 0) + return ( <div className={cx('container', className)}> <table> + <colgroup> + {widths.map((width, index) => ( + <col key={index} style={{ width: `${(width / totalWidth) * 100}%` }} /> + ))} + {renderActions && <col style={{ width: '150px' }} />} + </colgroup> <thead> <tr> {tableHead.map((head) => ( @@ -81,18 +93,52 @@ const VerticalTable = ({ ) } -const Skeleton = ({ tableHead, countPerPage, renderActions }: Partial<VerticalTableProps>) => { +const Skeleton = ({ + tableHead, + countPerPage, + renderActions, + colWidths = [], +}: Partial<VerticalTableProps>) => { + if (!tableHead || !countPerPage) return null + + const widths = colWidths.length > 0 ? colWidths : new Array(tableHead.length).fill(1) + + const totalWidth = widths.reduce((sum, width) => sum + width, 0) + return ( <div className={cx('container')}> <table> + <colgroup> + {widths.map((width, index) => ( + <col key={index} style={{ width: `${(width / totalWidth) * 100}%` }} /> + ))} + {renderActions && <col style={{ width: '150px' }} />} + </colgroup> <thead> <tr> - {tableHead?.map((head) => <td key={head}>{head}</td>)} - {renderActions && <td>관리</td>} + {tableHead.map((head) => ( + <td key={head}>{head}</td> + ))} + {renderActions && <td></td>} </tr> </thead> + <tbody> + {[...Array(countPerPage)].map((_, rowIdx) => ( + <tr key={rowIdx}> + {tableHead.map((_, colIdx) => ( + <td key={colIdx} className={cx('skeleton-cell')}> + <div className={cx('skeleton-content')} /> + </td> + ))} + {renderActions && ( + <td className={cx('button-container')}> + <div className={cx('skeleton-button')} /> + </td> + )} + </tr> + ))} + </tbody> </table> - <div className={cx('no-data')} style={{ height: `calc(40px * ${countPerPage}` }} /> </div> ) } diff --git a/shared/ui/table/vertical/styles.module.scss b/shared/ui/table/vertical/styles.module.scss index 0fe2577f..299492f5 100644 --- a/shared/ui/table/vertical/styles.module.scss +++ b/shared/ui/table/vertical/styles.module.scss @@ -5,6 +5,7 @@ table { width: 100%; font-size: $text-b3; + table-layout: fixed; thead { background-color: $color-gray-100; @@ -17,6 +18,9 @@ vertical-align: middle; border-top: 1px solid $color-gray-200; border-bottom: 1px solid $color-gray-200; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; } & td:first-child { padding-left: 20px; @@ -40,6 +44,40 @@ font-size: $text-c1; } } + + .skeleton-cell { + position: relative; + overflow: hidden; + } + + .skeleton-content { + width: 70%; + height: 16px; + margin: 4px 0; + background: linear-gradient( + 90deg, + $color-gray-200 25%, + $color-gray-300 37%, + $color-gray-200 63% + ); + background-size: 400% 100%; + animation: skeleton-loading 1.4s ease infinite; + border-radius: 4px; + } + + .skeleton-button { + width: 48px; + height: 24px; + background: linear-gradient( + 90deg, + $color-gray-200 25%, + $color-gray-300 37%, + $color-gray-200 63% + ); + background-size: 400% 100%; + animation: skeleton-loading 1.4s ease infinite; + border-radius: 4px; + } } .no-data { @@ -49,3 +87,12 @@ color: $color-gray-600; @include typo-b1; } + +@keyframes skeleton-loading { + 0% { + background-position: 100% 50%; + } + 100% { + background-position: 0 50%; + } +} From f61331ffaccb995871f9d16dee4e2052b00bfe1d Mon Sep 17 00:00:00 2001 From: James <dawn147@snu.ac.kr> Date: Tue, 14 Jan 2025 19:21:58 +0900 Subject: [PATCH 165/207] =?UTF-8?q?feat:=20=EC=9D=BC=EA=B0=84=EB=B6=84?= =?UTF-8?q?=EC=84=9D=20=EC=97=91=EC=85=80=20=EC=97=85=EB=A1=9C=EB=93=9C=20?= =?UTF-8?q?=EB=AA=A8=EB=8B=AC=EC=97=90=20=EC=95=88=EB=82=B4=20=EB=AC=B8?= =?UTF-8?q?=EA=B5=AC=20=EC=B6=94=EA=B0=80=20(#151)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../analysis-upload-modal/form/direct-input-form.tsx | 3 +++ .../analysis-upload-modal/form/excel-upload-form.tsx | 3 +++ .../modal/analysis-upload-modal/form/styles.module.scss | 8 +++++++- 3 files changed, 13 insertions(+), 1 deletion(-) diff --git a/shared/ui/modal/analysis-upload-modal/form/direct-input-form.tsx b/shared/ui/modal/analysis-upload-modal/form/direct-input-form.tsx index 9420bbab..7189f8d6 100644 --- a/shared/ui/modal/analysis-upload-modal/form/direct-input-form.tsx +++ b/shared/ui/modal/analysis-upload-modal/form/direct-input-form.tsx @@ -184,6 +184,9 @@ const DirectInputForm = ({ strategyId, onClose }: Props) => { </div> </div> ))} + <p className={cx('guide-message')}> + *일간분석 데이터 업로드 후 통계지표 집계 및 반영까지 약간의 시간이 소요될 수 있습니다. + </p> {error && <p className={cx('error-message')}>{error}</p>} diff --git a/shared/ui/modal/analysis-upload-modal/form/excel-upload-form.tsx b/shared/ui/modal/analysis-upload-modal/form/excel-upload-form.tsx index 1f711fa1..af8ac0be 100644 --- a/shared/ui/modal/analysis-upload-modal/form/excel-upload-form.tsx +++ b/shared/ui/modal/analysis-upload-modal/form/excel-upload-form.tsx @@ -74,6 +74,9 @@ const ExcelUploadForm = ({ strategyId, onClose }: Props) => { </a> </Button> </div> + <p className={cx('guide-message')}> + *일간분석 데이터 업로드 후 통계지표 집계 및 반영까지 약간의 시간이 소요될 수 있습니다. + </p> {error && <p className={cx('error-message')}>{error}</p>} diff --git a/shared/ui/modal/analysis-upload-modal/form/styles.module.scss b/shared/ui/modal/analysis-upload-modal/form/styles.module.scss index ffc52938..ab313940 100644 --- a/shared/ui/modal/analysis-upload-modal/form/styles.module.scss +++ b/shared/ui/modal/analysis-upload-modal/form/styles.module.scss @@ -54,6 +54,12 @@ border: 1px solid #797979; } +.guide-message { + @include typo-c1; + color: $color-gray-600; + line-height: 1.2rem; +} + .button-group { display: flex; justify-content: center; @@ -82,7 +88,7 @@ .data-input { width: 170px; border-radius: 4px; - border: 1px solid $color-gray-200; + border: 1px solid $color-gray-300; &::placeholder { color: $color-gray-400; @include typo-c1; From e079e39ca13f82061ab708b53b3ede67cab007cd Mon Sep 17 00:00:00 2001 From: James <dawn147@snu.ac.kr> Date: Tue, 14 Jan 2025 20:25:32 +0900 Subject: [PATCH 166/207] =?UTF-8?q?feat:=20=EC=8A=A4=EC=BC=88=EB=A0=88?= =?UTF-8?q?=ED=86=A4=20=EB=AF=B9=EC=8A=A4=EC=9D=B8=20=EC=82=AC=EC=9A=A9=20?= =?UTF-8?q?=EB=B0=8F=20=EC=95=A0=EB=8B=88=EB=A9=94=EC=9D=B4=EC=85=98=20?= =?UTF-8?q?=EC=A0=9C=EA=B1=B0=20(#151)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- shared/ui/table/vertical/styles.module.scss | 32 +++------------------ 1 file changed, 4 insertions(+), 28 deletions(-) diff --git a/shared/ui/table/vertical/styles.module.scss b/shared/ui/table/vertical/styles.module.scss index 299492f5..230001b3 100644 --- a/shared/ui/table/vertical/styles.module.scss +++ b/shared/ui/table/vertical/styles.module.scss @@ -51,31 +51,16 @@ } .skeleton-content { + @include skeleton; width: 70%; height: 16px; margin: 4px 0; - background: linear-gradient( - 90deg, - $color-gray-200 25%, - $color-gray-300 37%, - $color-gray-200 63% - ); - background-size: 400% 100%; - animation: skeleton-loading 1.4s ease infinite; - border-radius: 4px; } .skeleton-button { - width: 48px; + width: 80px; height: 24px; - background: linear-gradient( - 90deg, - $color-gray-200 25%, - $color-gray-300 37%, - $color-gray-200 63% - ); - background-size: 400% 100%; - animation: skeleton-loading 1.4s ease infinite; + background: $color-gray-200; border-radius: 4px; } } @@ -86,13 +71,4 @@ margin-top: 80px; color: $color-gray-600; @include typo-b1; -} - -@keyframes skeleton-loading { - 0% { - background-position: 100% 50%; - } - 100% { - background-position: 0 50%; - } -} +} \ No newline at end of file From e044fe3f34e29c42d643f94498c18c1b7d184836 Mon Sep 17 00:00:00 2001 From: deun <devdeun@gmail.com> Date: Thu, 16 Jan 2025 19:18:09 +0900 Subject: [PATCH 167/207] =?UTF-8?q?feat:=20=EA=B4=80=EB=A6=AC=EC=9E=90=20?= =?UTF-8?q?=ED=8E=98=EC=9D=B4=EC=A7=80=20=EB=A7=A4=EB=A7=A4=EC=9C=A0?= =?UTF-8?q?=ED=98=95=20=EC=82=AD=EC=A0=9C=20=EB=B2=84=ED=8A=BC=20=EC=B6=94?= =?UTF-8?q?=EA=B0=80=20(#153)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../_api/delete-inactive-trade.ts | 20 ++++++++ .../set-admin-trade-manage-table-data.tsx | 17 ++++--- .../_hooks/query/use-delete-inactive-trade.ts | 23 ++++++++++ .../_ui/inactive-trade-delete-button.tsx | 46 +++++++++++++++++++ .../category/_ui/trade/trade-manage/types.ts | 3 ++ 5 files changed, 103 insertions(+), 6 deletions(-) create mode 100644 app/admin/category/_ui/trade/trade-manage/_api/delete-inactive-trade.ts create mode 100644 app/admin/category/_ui/trade/trade-manage/_hooks/query/use-delete-inactive-trade.ts create mode 100644 app/admin/category/_ui/trade/trade-manage/_ui/inactive-trade-delete-button.tsx diff --git a/app/admin/category/_ui/trade/trade-manage/_api/delete-inactive-trade.ts b/app/admin/category/_ui/trade/trade-manage/_api/delete-inactive-trade.ts new file mode 100644 index 00000000..6c61e576 --- /dev/null +++ b/app/admin/category/_ui/trade/trade-manage/_api/delete-inactive-trade.ts @@ -0,0 +1,20 @@ +import axiosInstance from '@/shared/api/axios' + +import { DeleteInactiveTradeResponseModel } from '../types' + +const deleteInactiveTrade = async (tradeTypeId: number) => { + try { + const res = await axiosInstance.delete<DeleteInactiveTradeResponseModel>( + `/api/admin/strategies/trade-type/${tradeTypeId}` + ) + + if (!res.data.isSuccess) throw new Error(res.data.message) + + return res.data + } catch (err) { + console.error('Error : ' + err) + throw err + } +} + +export default deleteInactiveTrade diff --git a/app/admin/category/_ui/trade/trade-manage/_api/set-admin-trade-manage-table-data.tsx b/app/admin/category/_ui/trade/trade-manage/_api/set-admin-trade-manage-table-data.tsx index fc0854af..1697461c 100644 --- a/app/admin/category/_ui/trade/trade-manage/_api/set-admin-trade-manage-table-data.tsx +++ b/app/admin/category/_ui/trade/trade-manage/_api/set-admin-trade-manage-table-data.tsx @@ -1,7 +1,9 @@ import Image from 'next/image' +import { Button } from '@/shared/ui/button' + +import InactiveTradeDeleteButton from '../_ui/inactive-trade-delete-button' import TradeActiveStateToggleButton from '../_ui/trade-active-state-toggle-button' -// import StockActiveStateToggleButton from '../../../stock/stock-manage/_ui/stock-active-state-toggle-button' import { TradeResponseModel } from '../types' const setAdminTradeManageTableData = (data: TradeResponseModel['result'], isActive: boolean) => @@ -15,11 +17,14 @@ const setAdminTradeManageTableData = (data: TradeResponseModel['result'], isActi height={24} key={data.tradeTypeId} />, - <TradeActiveStateToggleButton - active={isActive} - tradeTypeId={data.tradeTypeId} - key={data.tradeTypeId} - />, + <Button.ButtonGroup key={data.tradeTypeId}> + <TradeActiveStateToggleButton + active={isActive} + tradeTypeId={data.tradeTypeId} + key={data.tradeTypeId} + /> + {!isActive && <InactiveTradeDeleteButton tradeTypeId={data.tradeTypeId} />} + </Button.ButtonGroup>, ]) ?? [] export default setAdminTradeManageTableData diff --git a/app/admin/category/_ui/trade/trade-manage/_hooks/query/use-delete-inactive-trade.ts b/app/admin/category/_ui/trade/trade-manage/_hooks/query/use-delete-inactive-trade.ts new file mode 100644 index 00000000..182f463b --- /dev/null +++ b/app/admin/category/_ui/trade/trade-manage/_hooks/query/use-delete-inactive-trade.ts @@ -0,0 +1,23 @@ +import { useMutation, useQueryClient } from '@tanstack/react-query' + +import { QUERY_KEY } from '@/shared/constants/query-key' + +import deleteInactiveTrade from '../../_api/delete-inactive-trade' + +const useDeleteInactiveTrade = (tradeTypeId: number) => { + const queryClient = useQueryClient() + + return useMutation({ + mutationFn: () => deleteInactiveTrade(tradeTypeId), + onSuccess: () => { + queryClient.invalidateQueries({ + queryKey: [QUERY_KEY.ADMIN_TRADES], + }) + }, + onError: (err) => { + console.error('Error : ', err) + }, + }) +} + +export default useDeleteInactiveTrade diff --git a/app/admin/category/_ui/trade/trade-manage/_ui/inactive-trade-delete-button.tsx b/app/admin/category/_ui/trade/trade-manage/_ui/inactive-trade-delete-button.tsx new file mode 100644 index 00000000..10b710ea --- /dev/null +++ b/app/admin/category/_ui/trade/trade-manage/_ui/inactive-trade-delete-button.tsx @@ -0,0 +1,46 @@ +'use client' + +import { CSSProperties } from 'react' + +import useModal from '@/shared/hooks/custom/use-modal' +import { Button } from '@/shared/ui/button' +import AlertModal from '@/shared/ui/modal/alert-modal' + +import useDeleteInactiveTrade from '../_hooks/query/use-delete-inactive-trade' + +interface Props { + tradeTypeId: number +} + +const InactiveTradeDeleteButton = ({ tradeTypeId }: Props) => { + const { mutate, isPending } = useDeleteInactiveTrade(tradeTypeId) + const { closeModal, isModalOpen, openModal } = useModal() + + return ( + <> + <Button + size="small" + onClick={openModal} + disabled={isPending} + variant="filled" + style={buttonStyles} + > + 삭제 + </Button> + <AlertModal + message={`해당 종목을\n삭제하시겠습니까?`} + isModalOpen={isModalOpen} + onCancel={closeModal} + onConfirm={mutate} + /> + </> + ) +} + +const buttonStyles: CSSProperties = { + height: '30px', + padding: '7px 16px', + margin: '15px 0', +} + +export default InactiveTradeDeleteButton diff --git a/app/admin/category/_ui/trade/trade-manage/types.ts b/app/admin/category/_ui/trade/trade-manage/types.ts index c3ce3092..dda22a85 100644 --- a/app/admin/category/_ui/trade/trade-manage/types.ts +++ b/app/admin/category/_ui/trade/trade-manage/types.ts @@ -16,6 +16,9 @@ export interface TradeResponseModel extends TradeResponseBaseModel<boolean> { // eslint-disable-next-line export interface ToggleTradeActiveStateResponseModel extends TradeResponseBaseModel<boolean> {} +// eslint-disable-next-line +export interface DeleteInactiveTradeResponseModel extends TradeResponseBaseModel<boolean> {} + export interface PresignedUrlResponseModel extends TradeResponseBaseModel<boolean> { result: { presignedUrl: string From 31c754a25e572f71ebeb25c6834890762494648c Mon Sep 17 00:00:00 2001 From: deun <devdeun@gmail.com> Date: Thu, 16 Jan 2025 19:32:25 +0900 Subject: [PATCH 168/207] =?UTF-8?q?feat:=20=EB=A7=A4=EB=A7=A4=EC=9C=A0?= =?UTF-8?q?=ED=98=95=20=EC=82=AD=EC=A0=9C=20=EC=8B=A4=ED=8C=A8=20=EB=AA=A8?= =?UTF-8?q?=EB=8B=AC=20=EC=B6=94=EA=B0=80=20(#153)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../_hooks/query/use-delete-inactive-trade.ts | 12 +++++++++- .../_ui/inactive-trade-delete-button.tsx | 23 +++++++++++++++++-- shared/ui/table/vertical/styles.module.scss | 2 +- 3 files changed, 33 insertions(+), 4 deletions(-) diff --git a/app/admin/category/_ui/trade/trade-manage/_hooks/query/use-delete-inactive-trade.ts b/app/admin/category/_ui/trade/trade-manage/_hooks/query/use-delete-inactive-trade.ts index 182f463b..84a1eca7 100644 --- a/app/admin/category/_ui/trade/trade-manage/_hooks/query/use-delete-inactive-trade.ts +++ b/app/admin/category/_ui/trade/trade-manage/_hooks/query/use-delete-inactive-trade.ts @@ -4,7 +4,15 @@ import { QUERY_KEY } from '@/shared/constants/query-key' import deleteInactiveTrade from '../../_api/delete-inactive-trade' -const useDeleteInactiveTrade = (tradeTypeId: number) => { +interface Props { + tradeTypeId: number + options: { + onSuccess?: () => void + onError?: (error: Error) => void + } +} + +const useDeleteInactiveTrade = ({ tradeTypeId, options }: Props) => { const queryClient = useQueryClient() return useMutation({ @@ -13,9 +21,11 @@ const useDeleteInactiveTrade = (tradeTypeId: number) => { queryClient.invalidateQueries({ queryKey: [QUERY_KEY.ADMIN_TRADES], }) + options?.onSuccess?.() }, onError: (err) => { console.error('Error : ', err) + options?.onError?.(err as Error) }, }) } diff --git a/app/admin/category/_ui/trade/trade-manage/_ui/inactive-trade-delete-button.tsx b/app/admin/category/_ui/trade/trade-manage/_ui/inactive-trade-delete-button.tsx index 10b710ea..399b6a18 100644 --- a/app/admin/category/_ui/trade/trade-manage/_ui/inactive-trade-delete-button.tsx +++ b/app/admin/category/_ui/trade/trade-manage/_ui/inactive-trade-delete-button.tsx @@ -5,6 +5,7 @@ import { CSSProperties } from 'react' import useModal from '@/shared/hooks/custom/use-modal' import { Button } from '@/shared/ui/button' import AlertModal from '@/shared/ui/modal/alert-modal' +import NotificationModal from '@/shared/ui/modal/notification-modal' import useDeleteInactiveTrade from '../_hooks/query/use-delete-inactive-trade' @@ -13,8 +14,21 @@ interface Props { } const InactiveTradeDeleteButton = ({ tradeTypeId }: Props) => { - const { mutate, isPending } = useDeleteInactiveTrade(tradeTypeId) const { closeModal, isModalOpen, openModal } = useModal() + const { + closeModal: closeErrorModal, + isModalOpen: isErrorModalOpen, + openModal: openErrorModal, + } = useModal() + const { mutate, isPending } = useDeleteInactiveTrade({ + tradeTypeId, + options: { + onError: () => { + closeModal() + openErrorModal() + }, + }, + }) return ( <> @@ -28,11 +42,16 @@ const InactiveTradeDeleteButton = ({ tradeTypeId }: Props) => { 삭제 </Button> <AlertModal - message={`해당 종목을\n삭제하시겠습니까?`} + message={`해당 매매유형을\n삭제하시겠습니까?`} isModalOpen={isModalOpen} onCancel={closeModal} onConfirm={mutate} /> + <NotificationModal + message={`해당 매매유형은\n삭제할 수 없습니다.`} + isOpen={isErrorModalOpen} + onClose={closeErrorModal} + /> </> ) } diff --git a/shared/ui/table/vertical/styles.module.scss b/shared/ui/table/vertical/styles.module.scss index 230001b3..43d9675b 100644 --- a/shared/ui/table/vertical/styles.module.scss +++ b/shared/ui/table/vertical/styles.module.scss @@ -71,4 +71,4 @@ margin-top: 80px; color: $color-gray-600; @include typo-b1; -} \ No newline at end of file +} From f9ed071e8035562ad88cec236d6cfe0f8ecbe989 Mon Sep 17 00:00:00 2001 From: James <dawn147@snu.ac.kr> Date: Sat, 18 Jan 2025 13:43:53 +0900 Subject: [PATCH 169/207] =?UTF-8?q?feat:=20=EC=8A=A4=EC=BC=88=EB=A0=88?= =?UTF-8?q?=ED=86=A4=20=ED=83=80=EC=9D=B4=EB=A8=B8=20=EB=A1=9C=EC=A7=81=20?= =?UTF-8?q?=EC=88=98=EC=A0=95=20(#150)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- shared/hooks/custom/use-delayed-loading.ts | 23 +++++++++++++++------- 1 file changed, 16 insertions(+), 7 deletions(-) diff --git a/shared/hooks/custom/use-delayed-loading.ts b/shared/hooks/custom/use-delayed-loading.ts index b22872b0..bfb19c88 100644 --- a/shared/hooks/custom/use-delayed-loading.ts +++ b/shared/hooks/custom/use-delayed-loading.ts @@ -1,21 +1,30 @@ -import { useEffect, useState } from 'react' +import { useEffect, useRef, useState } from 'react' const useDelayedLoading = (isLoading: boolean, minLoadingTime: number = 500) => { const [isDelayedLoading, setDelayedLoading] = useState(isLoading) + const loadingStartTime = useRef<number | null>(null) useEffect(() => { if (isLoading) { + loadingStartTime.current = Date.now() setDelayedLoading(true) + return } - if (!isLoading && isDelayedLoading) { - const timer = setTimeout(() => { - setDelayedLoading(false) - }, minLoadingTime) + if (!isLoading && loadingStartTime.current) { + const loadingDuration = Date.now() - loadingStartTime.current - return () => clearTimeout(timer) + if (loadingDuration < minLoadingTime) { + const remainingTime = minLoadingTime - loadingDuration + const timer = setTimeout(() => { + setDelayedLoading(false) + }, remainingTime) + return () => clearTimeout(timer) + } else { + setDelayedLoading(false) + } } - }, [isLoading, minLoadingTime, isDelayedLoading]) + }, [isLoading, minLoadingTime]) return isDelayedLoading } From 80433c7dde69dcf5ee0d8a0a69a20f074a5bb844 Mon Sep 17 00:00:00 2001 From: deun <devdeun@gmail.com> Date: Sat, 18 Jan 2025 21:33:17 +0900 Subject: [PATCH 170/207] =?UTF-8?q?fix:=20=ED=85=8C=EC=9D=B4=EB=B8=94?= =?UTF-8?q?=EC=97=90=EC=84=9C=20=EC=85=80=EB=A0=89=ED=8A=B8=20=EC=98=B5?= =?UTF-8?q?=EC=85=98=EC=9D=B4=20=EB=B3=B4=EC=9D=B4=EC=A7=80=20=EC=95=8A?= =?UTF-8?q?=EB=8A=94=20=EC=98=A4=EB=A5=98=20=ED=95=B4=EA=B2=B0=20(#156)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- shared/ui/table/vertical/styles.module.scss | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/shared/ui/table/vertical/styles.module.scss b/shared/ui/table/vertical/styles.module.scss index 43d9675b..1ccff246 100644 --- a/shared/ui/table/vertical/styles.module.scss +++ b/shared/ui/table/vertical/styles.module.scss @@ -29,6 +29,10 @@ } } + td:has(div[class*='dropdown']) { + overflow: visible; + } + .button-container { display: flex; justify-content: center; From 0b5021bbf41f202e7ae4329dc18f8cf52e22792d Mon Sep 17 00:00:00 2001 From: deun <devdeun@gmail.com> Date: Sat, 18 Jan 2025 21:35:45 +0900 Subject: [PATCH 171/207] =?UTF-8?q?fix:=20=ED=9A=8C=EC=9B=90=20=EB=B6=84?= =?UTF-8?q?=EB=A5=98=20select=20value=20=EC=88=98=EC=A0=95=20(#156)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- app/admin/users/_ui/role-select.tsx | 14 +++++++------- app/admin/users/constants.ts | 4 ++-- 2 files changed, 9 insertions(+), 9 deletions(-) diff --git a/app/admin/users/_ui/role-select.tsx b/app/admin/users/_ui/role-select.tsx index 7a0c870a..f217f0d4 100644 --- a/app/admin/users/_ui/role-select.tsx +++ b/app/admin/users/_ui/role-select.tsx @@ -5,8 +5,8 @@ import { useState } from 'react' import Select from '@/shared/ui/select' import usePatchUserRole from '../_hooks/query/use-patch-user-role' -import { InvestorOptions, TraderOptions } from '../constants' -import { AdminPatchUserRoleType, AdminUserInfoModel, AdminUserRoleType } from '../types' +import { investorOptions, traderOptions } from '../constants' +import { AdminUserInfoModel, AdminUserRoleType } from '../types' interface Props { data: AdminUserInfoModel @@ -16,16 +16,16 @@ const RoleSelect = ({ data }: Props) => { const { userId, role } = data const castAdmin = (role: AdminUserRoleType) => role === 'INVESTOR_ADMIN' || role === 'TRADER_ADMIN' ? 'ADMIN' : role - const [value, setValue] = useState<AdminPatchUserRoleType>(castAdmin(role)) - const options = role.includes('TRADER') ? TraderOptions : InvestorOptions + const [value, setValue] = useState<AdminUserRoleType>(role) + const options = role.includes('TRADER') ? traderOptions : investorOptions - const { mutate } = usePatchUserRole(userId, value) + const { mutate } = usePatchUserRole(userId, castAdmin(value)) return ( <Select - value={role} + value={value} onChange={(v) => { - setValue(castAdmin(v as AdminUserRoleType)) + setValue(v as AdminUserRoleType) mutate() }} options={options} diff --git a/app/admin/users/constants.ts b/app/admin/users/constants.ts index 71354f18..17ef3cc3 100644 --- a/app/admin/users/constants.ts +++ b/app/admin/users/constants.ts @@ -15,12 +15,12 @@ export const tabs: Array<TabItemModel> = [ { label: '관리자', id: 'ADMIN' }, ] -export const InvestorOptions: DropdownOptionModel[] = [ +export const investorOptions: DropdownOptionModel[] = [ { label: '일반', value: 'INVESTOR' }, { label: '관리자', value: 'INVESTOR_ADMIN' }, ] -export const TraderOptions: DropdownOptionModel[] = [ +export const traderOptions: DropdownOptionModel[] = [ { label: '트레이더', value: 'TRADER' }, { label: '관리자', value: 'TRADER_ADMIN' }, ] From 9cbdf27877abdc286145841eb51f0d2721eeaea4 Mon Sep 17 00:00:00 2001 From: James <dawn147@snu.ac.kr> Date: Sat, 25 Jan 2025 19:24:12 +0900 Subject: [PATCH 172/207] =?UTF-8?q?feat:=20=ED=94=84=EB=A1=9C=ED=95=84=20?= =?UTF-8?q?=EC=88=98=EC=A0=95=20=ED=8E=98=EC=9D=B4=EC=A7=80=EC=97=90?= =?UTF-8?q?=EC=84=9C=20=EC=A0=9C=EC=95=88=EC=84=9C=20=EC=97=85=EB=A1=9C?= =?UTF-8?q?=EB=93=9C=20=EA=B0=80=EB=8A=A5=ED=95=98=EB=8F=84=EB=A1=9D=20?= =?UTF-8?q?=EC=BD=94=EB=93=9C=20=EB=B3=80=EA=B2=BD=20(#150)=20-=20?= =?UTF-8?q?=EC=A0=9C=EC=95=88=EC=84=9C=20=EB=8B=A4=EC=9A=B4=EB=A1=9C?= =?UTF-8?q?=EB=93=9C=20=EA=B4=80=EB=A0=A8=20=EB=B2=84=EA=B7=B8=20=EC=88=98?= =?UTF-8?q?=EC=A0=95=20-=20=EC=A0=84=EB=9E=B5=20=EB=93=B1=EB=A1=9D?= =?UTF-8?q?=ED=95=A0=20=EB=95=8C=20=EC=A0=9C=EC=95=88=EC=84=9C=20S3?= =?UTF-8?q?=EC=97=90=20=EC=97=85=EB=A1=9C=EB=93=9C=20-=20=EC=A0=9C?= =?UTF-8?q?=EC=95=88=EC=84=9C=20=EA=B4=80=EB=A0=A8=20=EC=BD=94=EB=93=9C=20?= =?UTF-8?q?=EC=A0=84=EB=9E=B5=20=EC=88=98=EC=A0=95=20=EC=8A=A4=ED=86=A0?= =?UTF-8?q?=EC=96=B4=EC=97=90=20=EC=B6=94=EA=B0=80=20-=20=EA=B8=B0?= =?UTF-8?q?=ED=83=80=20=EC=9D=B8=ED=84=B0=ED=8E=98=EC=9D=B4=EC=8A=A4=20?= =?UTF-8?q?=EB=B3=80=EA=B2=BD?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../details-information/strategy-name-box.tsx | 101 +++++++++++++++--- .../details-information/styles.module.scss | 78 +++++++++++--- app/(dashboard)/my/_api/add-strategy.ts | 3 +- .../my/_api/get-proposal-file-name.ts | 34 ++++++ app/(dashboard)/my/_api/post-edit-strategy.ts | 17 ++- .../my/_hooks/query/use-add-strategy.ts | 32 +++++- .../query/use-get-proposal-file-name.ts | 14 +++ .../my/_hooks/query/use-post-edit-strategy.ts | 23 ++-- .../_store/use-edit-information-store.tsx | 51 ++++++++- .../strategies/manage/[strategyId]/page.tsx | 48 +++++++-- 10 files changed, 349 insertions(+), 52 deletions(-) create mode 100644 app/(dashboard)/my/_api/get-proposal-file-name.ts create mode 100644 app/(dashboard)/my/_hooks/query/use-get-proposal-file-name.ts diff --git a/app/(dashboard)/_ui/details-information/strategy-name-box.tsx b/app/(dashboard)/_ui/details-information/strategy-name-box.tsx index be7b6e13..0575421b 100644 --- a/app/(dashboard)/_ui/details-information/strategy-name-box.tsx +++ b/app/(dashboard)/_ui/details-information/strategy-name-box.tsx @@ -1,13 +1,15 @@ 'use client' -/* eslint-disable react-hooks/exhaustive-deps */ -import { useEffect } from 'react' +import { useEffect, useRef, useState } from 'react' import StrategiesIcon from '@/app/(dashboard)/_ui/strategies-item/strategies-icon' +import { FileIcon } from '@/public/icons' import classNames from 'classnames/bind' +import { SUPPORTED_FILE_TYPES } from '@/shared/constants/supported-file-types' import Input from '@/shared/ui/input' +import useGetProposalFileName from '../../my/_hooks/query/use-get-proposal-file-name' import useEditInformationStore from '../../my/strategies/manage/[strategyId]/_store/use-edit-information-store' import useGetProposalDownload from '../../strategies/[strategyId]/_hooks/query/use-get-proposal-download' import styles from './styles.module.scss' @@ -26,38 +28,105 @@ interface Props { const StrategyNameBox = ({ strategyId, name, - hasProposal, + hasProposal: initialHasProposal, iconUrls, iconNames, isEditable = false, }: Props) => { const information = useEditInformationStore((state) => state.information) + const proposal = useEditInformationStore((state) => state.proposal) const setStrategyName = useEditInformationStore((state) => state.actions.setStrategyName) + const setProposalFile = useEditInformationStore((state) => state.actions.setProposalFile) + const initializeProposal = useEditInformationStore((state) => state.actions.initializeProposal) + + const { refetch } = useGetProposalFileName(strategyId) const { mutate } = useGetProposalDownload() + const fileInputRef = useRef<HTMLInputElement>(null) + const [selectedFile, setSelectedFile] = useState<File | null>(null) + const [fileError, setFileError] = useState<string>('') const handleDownload = () => { mutate({ strategyId, name }) } + const handleFileChange = (e: React.ChangeEvent<HTMLInputElement>) => { + const file = e.target.files?.[0] + const supportedTypes = SUPPORTED_FILE_TYPES + + if (file && supportedTypes.includes(file.type)) { + setSelectedFile(file) + setProposalFile(file) + setFileError('') + } else { + setSelectedFile(null) + setProposalFile(null) + setFileError('지원되는 파일 형식이 아닙니다.') + } + } + + const handleProposalClick = () => { + if (fileInputRef.current) { + fileInputRef.current.click() + } + } + useEffect(() => { setStrategyName(name) - }, []) + }, [name, setStrategyName]) + + useEffect(() => { + if (isEditable) { + refetch().then((response) => { + const fileName = response.data?.result?.proposalFileName + if (fileName) { + initializeProposal(fileName) + } + }) + } + }, [isEditable, refetch, initializeProposal]) + + const displayFileName = + selectedFile?.name || proposal.proposalFileName || '등록된 제안서가 없습니다' return ( <div className={cx('name-container')}> - <StrategiesIcon iconUrls={iconUrls} iconNames={iconNames} isDetailsPage={true} /> - {isEditable ? ( - <Input - onChange={(e: React.ChangeEvent<HTMLInputElement>) => setStrategyName(e.target.value)} - inputSize="small" - className={cx('name-input')} - maxLength={16} - value={information.strategyName as string} - /> - ) : ( - <p className={cx('name')}>{name}</p> + <div className={cx('name-section')}> + <StrategiesIcon iconUrls={iconUrls} iconNames={iconNames} isDetailsPage={true} /> + {isEditable ? ( + <Input + onChange={(e: React.ChangeEvent<HTMLInputElement>) => setStrategyName(e.target.value)} + inputSize="small" + className={cx('name-input')} + maxLength={16} + value={information.strategyName as string} + /> + ) : ( + <p className={cx('name')}>{name}</p> + )} + {initialHasProposal && !isEditable && ( + <button onClick={handleDownload} className={cx('download-button')}> + 제안서 다운로드 + </button> + )} + </div> + {isEditable && ( + <div className={cx('proposal-section')}> + <input + type="file" + ref={fileInputRef} + onChange={handleFileChange} + accept=".xlsx,.xls,.pdf,.doc,.docx,.txt,.ppt,.pptx" + className={cx('file-input')} + /> + <div className={cx('proposal-input-wrapper')}> + <Input readOnly value={displayFileName} className={cx('file-name-input')} /> + <button onClick={handleProposalClick} className={cx('proposal-button')}> + <FileIcon /> + </button> + </div> + {fileError && <p className={cx('file-error')}>{fileError}</p>} + </div> )} - {hasProposal && <button onClick={handleDownload}>제안서 다운로드</button>} </div> ) } diff --git a/app/(dashboard)/_ui/details-information/styles.module.scss b/app/(dashboard)/_ui/details-information/styles.module.scss index 13136396..f3d1b32a 100644 --- a/app/(dashboard)/_ui/details-information/styles.module.scss +++ b/app/(dashboard)/_ui/details-information/styles.module.scss @@ -15,21 +15,74 @@ border-radius: 5px; background-color: $color-white; - .name-input { - padding: 10px; - height: 30px; - width: 100%; + .name-section { + .name-input { + padding: 10px; + height: 30px; + width: 45%; + margin: 6px 0; + color: $color-gray-700; + } + .name { + @include typo-b1; + margin: 10px 0; + } + button { + height: 30px; + width: 100%; + border-radius: 8px; + background-color: $color-gray-200; + color: $color-gray-700; + } } - .name { - @include typo-b1; - } - button { - height: 30px; - border-radius: 8px; - background-color: $color-gray-200; - color: $color-gray-700; + .proposal-section { + width: 100%; + + .file-input { + display: none; + } + + .proposal-input-wrapper { + position: relative; + width: 100%; + + .file-name-input { + width: 100%; + height: 30px; + background-color: $color-gray-200; + border: 0; + border-radius: 8px; + cursor: default; + color: $color-gray-700; + } + + .proposal-button { + position: absolute; + right: 16px; + top: 50%; + transform: translateY(-50%); + display: flex; + align-items: center; + justify-content: center; + color: $color-gray-700; + background: none; + @include typo-b3; + + svg { + width: 20px; + height: 20px; + } + } + } + + .file-error { + @include typo-c1; + color: $color-orange-500; + margin-top: 4px; + } } + @include tablet-md { gap: 2px; button { @@ -92,6 +145,7 @@ width: 20%; height: 108px; border-radius: 5px; + gap: 8px; background-color: $color-white; display: flex; flex-direction: column; diff --git a/app/(dashboard)/my/_api/add-strategy.ts b/app/(dashboard)/my/_api/add-strategy.ts index 32bfb91f..fa33b7a4 100644 --- a/app/(dashboard)/my/_api/add-strategy.ts +++ b/app/(dashboard)/my/_api/add-strategy.ts @@ -56,7 +56,8 @@ export interface StrategyResponseModel { isSuccess: boolean message: string result: { - presignedUrl: string + strategyId: number + presignedUrl?: string } code: number } diff --git a/app/(dashboard)/my/_api/get-proposal-file-name.ts b/app/(dashboard)/my/_api/get-proposal-file-name.ts new file mode 100644 index 00000000..32fd32bd --- /dev/null +++ b/app/(dashboard)/my/_api/get-proposal-file-name.ts @@ -0,0 +1,34 @@ +import axiosInstance from '@/shared/api/axios' + +interface StrategyDetailsResponseModel { + isSuccess: boolean + message: string + result: { + strategyName: string + tradeType: { + tradeTypeId: number + tradeTypeName: string + tradeTypeIconUrl: string + } + stockTypes: Array<{ + stockTypeId: number + stockTypeName: string + stockIconUrl: string + }> + minimumInvestmentAmount: string + operationCycle: string + proposalFileName: string + proposalFileUrl: string + isPublic: boolean + description: string + } +} + +const getProposalFileName = async (strategyId: number) => { + const response = await axiosInstance.get<StrategyDetailsResponseModel>( + `/api/my-strategies/modify/${strategyId}` + ) + return response.data +} + +export default getProposalFileName diff --git a/app/(dashboard)/my/_api/post-edit-strategy.ts b/app/(dashboard)/my/_api/post-edit-strategy.ts index 0ce4acaa..a6ddb6cc 100644 --- a/app/(dashboard)/my/_api/post-edit-strategy.ts +++ b/app/(dashboard)/my/_api/post-edit-strategy.ts @@ -5,19 +5,32 @@ import axiosInstance from '@/shared/api/axios' export interface ContentModel { strategyName: string description: string + proposalFile?: { + proposalFileName: string + proposalFileSize: number + } proposalModified: boolean } +export interface EditStrategyResponseModel { + isSuccess: boolean + message: string + result: { + presignedUrl?: string + } + code: number +} + const postEditStrategy = async ( strategyId: number, information: ContentModel -): Promise<boolean | null> => { +): Promise<EditStrategyResponseModel> => { try { const response = await axiosInstance.post( `/api/my-strategies/modify/${strategyId}`, information ) - return response.data.isSuccess + return response.data } catch (err) { throw new Error('전략 정보 수정 실패', err as AxiosError) } diff --git a/app/(dashboard)/my/_hooks/query/use-add-strategy.ts b/app/(dashboard)/my/_hooks/query/use-add-strategy.ts index 45b04a66..70320649 100644 --- a/app/(dashboard)/my/_hooks/query/use-add-strategy.ts +++ b/app/(dashboard)/my/_hooks/query/use-add-strategy.ts @@ -5,6 +5,7 @@ import { useRouter } from 'next/navigation' import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query' import { AxiosError } from 'axios' +import uploadFileWithPresignedUrl from '@/shared/api/upload-file-with-presigned-url' import { QUERY_KEY } from '@/shared/constants/query-key' import { @@ -22,6 +23,7 @@ export const useAddStrategy = () => { const router = useRouter() const queryClient = useQueryClient() const [error, setError] = useState<string | null>(null) + const [isUploading, setIsUploading] = useState(false) const { data: strategyTypes, isLoading: isTypesLoading } = useQuery< StrategyTypeResponseModel, @@ -33,12 +35,28 @@ export const useAddStrategy = () => { refetchOnWindowFocus: false, }) - const mutation = useMutation< + const registerStrategyMutation = useMutation< StrategyResponseModel, AxiosError<ErrorResponseModel>, - StrategyModel + { data: StrategyModel; file?: File } >({ - mutationFn: (data) => strategyApi.registerStrategy(data).then((response) => response.data), + mutationFn: async ({ data, file }) => { + const response = await strategyApi.registerStrategy(data) + + if (file && response.data.result?.presignedUrl) { + setIsUploading(true) + try { + await uploadFileWithPresignedUrl(response.data.result.presignedUrl, file) + } catch (err) { + console.error('File upload failed:', err) + throw new Error('파일 업로드 중 오류가 발생했습니다.') + } finally { + setIsUploading(false) + } + } + + return response.data + }, onSuccess: () => { queryClient.invalidateQueries({ queryKey: [QUERY_KEY.MY_STRATEGIES], @@ -51,11 +69,15 @@ export const useAddStrategy = () => { }, }) + const registerStrategy = async (data: StrategyModel, file?: File) => { + await registerStrategyMutation.mutateAsync({ data, file }) + } + return { strategyTypes: strategyTypes?.result, isTypesLoading, - registerStrategy: mutation.mutate, - isRegistering: mutation.isPending, + registerStrategy, + isRegistering: registerStrategyMutation.isPending || isUploading, error, } } diff --git a/app/(dashboard)/my/_hooks/query/use-get-proposal-file-name.ts b/app/(dashboard)/my/_hooks/query/use-get-proposal-file-name.ts new file mode 100644 index 00000000..7e723ae5 --- /dev/null +++ b/app/(dashboard)/my/_hooks/query/use-get-proposal-file-name.ts @@ -0,0 +1,14 @@ +import { useQuery } from '@tanstack/react-query' + +import getProposalFileName from '../../_api/get-proposal-file-name' +import { QUERY_KEY } from '@/shared/constants/query-key' + +const useGetProposalFileName = (strategyId: number) => { + return useQuery({ + queryKey: [QUERY_KEY.STRATEGY_PROPOSAL_FILE_NAME, strategyId], + queryFn: () => getProposalFileName(strategyId), + enabled: !!strategyId, + }) +} + +export default useGetProposalFileName diff --git a/app/(dashboard)/my/_hooks/query/use-post-edit-strategy.ts b/app/(dashboard)/my/_hooks/query/use-post-edit-strategy.ts index 800310f7..d078f60d 100644 --- a/app/(dashboard)/my/_hooks/query/use-post-edit-strategy.ts +++ b/app/(dashboard)/my/_hooks/query/use-post-edit-strategy.ts @@ -1,19 +1,30 @@ import { QueryClient, useMutation } from '@tanstack/react-query' +import uploadFileWithPresignedUrl from '@/shared/api/upload-file-with-presigned-url' import { QUERY_KEY } from '@/shared/constants/query-key' -import postEditStrategy, { ContentModel } from '../../_api/post-edit-strategy' +import postEditStrategy, { + ContentModel, + EditStrategyResponseModel, +} from '../../_api/post-edit-strategy' const usePostEditStrategy = () => { const queryClient = new QueryClient() + return useMutation< - boolean | null | undefined, + EditStrategyResponseModel, unknown, - { strategyId: number; information: ContentModel } + { strategyId: number; information: ContentModel; file?: File } >({ - mutationFn: ({ strategyId, information }: { strategyId: number; information: ContentModel }) => - postEditStrategy(strategyId, information), - onSuccess: (strategyId) => { + mutationFn: async ({ strategyId, information, file }) => { + const response = await postEditStrategy(strategyId, information) + + if (file && information.proposalModified && response.result.presignedUrl) { + await uploadFileWithPresignedUrl(response.result.presignedUrl, file) + } + return response + }, + onSuccess: (_, { strategyId }) => { queryClient.invalidateQueries({ queryKey: [QUERY_KEY.STRATEGY_DETAILS, strategyId] }) }, }) diff --git a/app/(dashboard)/my/strategies/manage/[strategyId]/_store/use-edit-information-store.tsx b/app/(dashboard)/my/strategies/manage/[strategyId]/_store/use-edit-information-store.tsx index 95d81e06..0240355e 100644 --- a/app/(dashboard)/my/strategies/manage/[strategyId]/_store/use-edit-information-store.tsx +++ b/app/(dashboard)/my/strategies/manage/[strategyId]/_store/use-edit-information-store.tsx @@ -5,32 +5,81 @@ interface InformationModel { description: string | null } +interface ProposalModel { + proposalFile: File | null + proposalModified: boolean + proposalFileName: string +} + interface StateModel { information: InformationModel + proposal: ProposalModel } interface ActionModel { actions: { setStrategyName: (name: string) => void setDescription: (description: string) => void + setProposalFile: (file: File | null) => void + setOriginalFileName: (fileName: string) => void + setProposalModified: (modified: boolean) => void + initializeProposal: (fileName: string) => void } } -const useEditInformationStore = create<StateModel & ActionModel>((set) => ({ +const initialState: StateModel = { information: { strategyName: null, description: null, }, + proposal: { + proposalFile: null, + proposalModified: false, + proposalFileName: '', + }, +} + +const useEditInformationStore = create<StateModel & ActionModel>((set) => ({ + ...initialState, actions: { setStrategyName: (name) => set((state) => ({ information: { ...state.information, strategyName: name }, })), + setDescription: (description) => set((state) => ({ information: { ...state.information, description }, })), + + setProposalFile: (file) => + set((state) => ({ + proposal: { + ...state.proposal, + proposalFile: file, + proposalModified: true, + }, + })), + + setOriginalFileName: (fileName) => + set((state) => ({ + proposal: { ...state.proposal, originalFileName: fileName }, + })), + + setProposalModified: (modified) => + set((state) => ({ + proposal: { ...state.proposal, proposalModified: modified }, + })), + + initializeProposal: (fileName) => + set((state) => ({ + proposal: { + ...state.proposal, + originalFileName: fileName, + proposalModified: false, + }, + })), }, })) diff --git a/app/(dashboard)/my/strategies/manage/[strategyId]/page.tsx b/app/(dashboard)/my/strategies/manage/[strategyId]/page.tsx index c3510204..df89b767 100644 --- a/app/(dashboard)/my/strategies/manage/[strategyId]/page.tsx +++ b/app/(dashboard)/my/strategies/manage/[strategyId]/page.tsx @@ -27,14 +27,17 @@ export type InformationType = { title: TitleType; data: string | number } | Info const StrategyManagePage = ({ params }: { params: { strategyId: string } }) => { const [isEditable, setIsEditable] = useState(false) + const [isSubmitting, setIsSubmitting] = useState(false) const strategyNumber = parseInt(params.strategyId) + const { data: detailsInfoData, refetch } = useGetDetailsInformationData({ strategyId: strategyNumber, }) const { data: subscribeData } = useGetDetailsInformationData({ strategyId: strategyNumber, }) - const { mutate } = usePostEditStrategy() + + const { mutate: editStrategy, isError, error } = usePostEditStrategy() const { detailsSideData, detailsInformationData } = detailsInfoData || {} const { detailsInformationData: subscribeInfo } = subscribeData || {} @@ -42,23 +45,46 @@ const StrategyManagePage = ({ params }: { params: { strategyId: string } }) => { if (!Array.isArray(data)) return data.data !== undefined }) - const handleUpdateInformation = () => { + const handleUpdateInformation = async () => { const editedInformation = useEditInformationStore.getState().information - if (editedInformation.strategyName && editedInformation.description) { + const { proposal } = useEditInformationStore.getState() + + if (!editedInformation.strategyName || !editedInformation.description) { + return + } + + setIsSubmitting(true) + try { const information = { strategyName: editedInformation.strategyName, description: editedInformation.description, - proposalModified: false, + proposalModified: proposal.proposalModified, + ...(proposal.proposalFile && { + proposalFile: { + proposalFileName: proposal.proposalFile.name, + proposalFileSize: proposal.proposalFile.size, + }, + }), } - mutate( - { strategyId: strategyNumber, information }, + + editStrategy( + { strategyId: strategyNumber, information, file: proposal.proposalFile || undefined }, { - onSuccess: () => { + onSuccess: async () => { + await refetch() + const newProposalFileName = proposal.proposalFile?.name || proposal.proposalFileName + useEditInformationStore.getState().actions.initializeProposal(newProposalFileName) setIsEditable(false) - refetch() + }, + onError: (err) => { + console.error('전략 수정 실패:', err) }, } ) + } catch (err) { + console.error('Failed to update strategy:', err) + } finally { + setIsSubmitting(false) } } @@ -73,8 +99,9 @@ const StrategyManagePage = ({ params }: { params: { strategyId: string } }) => { size="small" variant="filled" className={cx('edit-button')} + disabled={isSubmitting} > - 저장하기 + {isSubmitting ? '저장 중...' : '저장하기'} </Button> ) : ( <Button @@ -87,6 +114,9 @@ const StrategyManagePage = ({ params }: { params: { strategyId: string } }) => { </Button> )} </div> + {isError && ( + <div className={cx('error')}>{(error as Error)?.message || '오류가 발생했습니다.'}</div> + )} <div className={cx('strategy-container')}> {detailsInformationData && ( <DetailsInformation From bb3d728b38adb227ab4b0007f17e48e23e9add7e Mon Sep 17 00:00:00 2001 From: James <dawn147@snu.ac.kr> Date: Sat, 25 Jan 2025 19:26:33 +0900 Subject: [PATCH 173/207] =?UTF-8?q?rename:=20uploadFileWithPresignedUrl=20?= =?UTF-8?q?=ED=8C=8C=EC=9D=BC=20=EC=9C=84=EC=B9=98=20=EB=B3=80=EA=B2=BD=20?= =?UTF-8?q?=EB=B0=8F=20=EC=9D=B4=EB=A6=84=20=EB=B3=80=EA=B2=BD=20(#150)=20?= =?UTF-8?q?-=20@/shared=20=EB=94=94=EB=A0=89=ED=86=A0=EB=A6=AC=20=EC=95=88?= =?UTF-8?q?=EC=9C=BC=EB=A1=9C=20=EC=9D=B4=EB=8F=99=20-=20=EA=B0=99?= =?UTF-8?q?=EC=9D=80=20=EC=9D=B4=EB=A6=84=EC=9C=BC=EB=A1=9C=20export=20?= =?UTF-8?q?=EB=90=98=EC=A7=80=20=EC=95=8A=EB=8F=84=EB=A1=9D=20=EC=9D=B4?= =?UTF-8?q?=EB=A6=84=20=EB=B3=80=EA=B2=BD?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../_api => shared/api}/upload-file-with-presigned-url.ts | 0 ...th-presigned-url.ts => upload-files-with-presigned-url.ts} | 4 ++-- 2 files changed, 2 insertions(+), 2 deletions(-) rename {app/admin/category/_ui/shared/_api => shared/api}/upload-file-with-presigned-url.ts (100%) rename shared/utils/{upload-file-with-presigned-url.ts => upload-files-with-presigned-url.ts} (80%) diff --git a/app/admin/category/_ui/shared/_api/upload-file-with-presigned-url.ts b/shared/api/upload-file-with-presigned-url.ts similarity index 100% rename from app/admin/category/_ui/shared/_api/upload-file-with-presigned-url.ts rename to shared/api/upload-file-with-presigned-url.ts diff --git a/shared/utils/upload-file-with-presigned-url.ts b/shared/utils/upload-files-with-presigned-url.ts similarity index 80% rename from shared/utils/upload-file-with-presigned-url.ts rename to shared/utils/upload-files-with-presigned-url.ts index e4658c4b..2b8758bc 100644 --- a/shared/utils/upload-file-with-presigned-url.ts +++ b/shared/utils/upload-files-with-presigned-url.ts @@ -1,4 +1,4 @@ -const uploadFileWithPresignedUrl = async (files: File[], presignedUrls: string[]) => { +const uploadFilesWithPresignedUrl = async (files: File[], presignedUrls: string[]) => { try { await Promise.all( files.map((file, idx) => { @@ -23,4 +23,4 @@ const uploadFileWithPresignedUrl = async (files: File[], presignedUrls: string[] } } -export default uploadFileWithPresignedUrl +export default uploadFilesWithPresignedUrl From 05637cb4bca63853e9c08772c73d74c178aebe06 Mon Sep 17 00:00:00 2001 From: James <dawn147@snu.ac.kr> Date: Sat, 25 Jan 2025 19:28:53 +0900 Subject: [PATCH 174/207] =?UTF-8?q?refactor:=20=EA=B8=B0=ED=83=80=20?= =?UTF-8?q?=EB=B3=80=EA=B2=BD=20=EC=82=AC=ED=95=AD=20(#150)=20-=20?= =?UTF-8?q?=ED=8C=8C=EC=9D=BC=20=EC=9C=84=EC=B9=98=20=EB=B3=80=EA=B2=BD?= =?UTF-8?q?=EC=97=90=20=EB=94=B0=EB=A5=B8=20=EC=9E=84=ED=8F=AC=ED=8A=B8=20?= =?UTF-8?q?=EA=B2=BD=EB=A1=9C=20=EB=B3=80=EA=B2=BD=20-=20=EB=B6=88?= =?UTF-8?q?=ED=95=84=EC=9A=94=ED=95=9C=20=EC=BD=94=EB=93=9C=20=EC=82=AD?= =?UTF-8?q?=EC=A0=9C=20-=20=ED=97=88=EC=9A=A9=20=ED=8C=8C=EC=9D=BC=20?= =?UTF-8?q?=EB=AA=A9=EB=A1=9D=20=EC=83=81=EC=88=98=ED=99=94?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../query/use-get-proposal-file-name.ts | 3 ++- app/(dashboard)/my/strategies/add/page.tsx | 22 +++++++-------- .../_api/get-proposal-download.ts | 27 +++++++++++++++++-- .../shared/_hooks/use-category-icon-post.ts | 4 +-- .../category/_ui/stock/stock-manage/types.ts | 6 ----- .../edit/_hooks/query/use-patch-notice.ts | 2 +- shared/constants/query-key.ts | 1 + shared/constants/supported-file-types.ts | 10 +++++++ 8 files changed, 50 insertions(+), 25 deletions(-) create mode 100644 shared/constants/supported-file-types.ts diff --git a/app/(dashboard)/my/_hooks/query/use-get-proposal-file-name.ts b/app/(dashboard)/my/_hooks/query/use-get-proposal-file-name.ts index 7e723ae5..2764fb87 100644 --- a/app/(dashboard)/my/_hooks/query/use-get-proposal-file-name.ts +++ b/app/(dashboard)/my/_hooks/query/use-get-proposal-file-name.ts @@ -1,8 +1,9 @@ import { useQuery } from '@tanstack/react-query' -import getProposalFileName from '../../_api/get-proposal-file-name' import { QUERY_KEY } from '@/shared/constants/query-key' +import getProposalFileName from '../../_api/get-proposal-file-name' + const useGetProposalFileName = (strategyId: number) => { return useQuery({ queryKey: [QUERY_KEY.STRATEGY_PROPOSAL_FILE_NAME, strategyId], diff --git a/app/(dashboard)/my/strategies/add/page.tsx b/app/(dashboard)/my/strategies/add/page.tsx index b9c16c81..24ddf7f1 100644 --- a/app/(dashboard)/my/strategies/add/page.tsx +++ b/app/(dashboard)/my/strategies/add/page.tsx @@ -8,6 +8,7 @@ import { useRouter } from 'next/navigation' import { FileIcon } from '@/public/icons' import classNames from 'classnames/bind' +import { SUPPORTED_FILE_TYPES } from '@/shared/constants/supported-file-types' import { Button } from '@/shared/ui/button' import BackHeader from '@/shared/ui/header/back-header' import Input from '@/shared/ui/input' @@ -77,21 +78,12 @@ const StrategyAddPage = () => { } setFormErrors(newErrors) - return !Object.values(newErrors).some((error) => error) + return !Object.values(newErrors).some((err) => err) } const handleFileChange = (e: React.ChangeEvent<HTMLInputElement>) => { const file = e.target.files?.[0] - const supportedTypes = [ - 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet', - 'application/vnd.ms-excel', - 'application/pdf', - 'application/msword', - 'application/vnd.openxmlformats-officedocument.wordprocessingml.document', - 'text/plain', - 'application/vnd.ms-powerpoint', - 'application/vnd.openxmlformats-officedocument.presentationml.presentation', - ] + const supportedTypes = SUPPORTED_FILE_TYPES if (file && supportedTypes.includes(file.type)) { setFormData((prev) => ({ ...prev, proposalFile: file })) @@ -101,7 +93,7 @@ const StrategyAddPage = () => { } } - const handleSubmit = (e: React.FormEvent<HTMLFormElement>) => { + const handleSubmit = async (e: React.FormEvent<HTMLFormElement>) => { e.preventDefault() if (!validateForm() || !formData.tradeType) return @@ -122,7 +114,11 @@ const StrategyAddPage = () => { proposalFile: fileInfo, } - registerStrategy(data) + try { + await registerStrategy(data, formData.proposalFile) + } catch (err) { + console.error('Strategy registration failed:', err) + } } const handleInputChange = (field: keyof StrategyFormDataModel, value: any) => { diff --git a/app/(dashboard)/strategies/[strategyId]/_api/get-proposal-download.ts b/app/(dashboard)/strategies/[strategyId]/_api/get-proposal-download.ts index 016246c2..ffc5d9fb 100644 --- a/app/(dashboard)/strategies/[strategyId]/_api/get-proposal-download.ts +++ b/app/(dashboard)/strategies/[strategyId]/_api/get-proposal-download.ts @@ -4,20 +4,43 @@ import axiosInstance from '@/shared/api/axios' import { downloadFile, extractFileName } from './helper-download-file' +const TIME_OUT = 30000 const getProposalDownload = async (strategyId: number, name: string) => { try { const response = await axiosInstance.get(`/api/my-strategies/${strategyId}/download-proposal`, { responseType: 'blob', + timeout: TIME_OUT, + headers: { + Accept: 'application/octet-stream', + }, }) + + if (response.status !== 200) { + throw new Error(`Failed with status: ${response.status}`) + } + const mimeType = response.headers['content-type'] || 'application/octet-stream' + + if (!response.data || response.data.size === 0) { + throw new Error('Empty file data received') + } + const blob = new Blob([response.data], { type: mimeType }) const contentDisposition = response.headers['content-disposition'] - const fileName = extractFileName(contentDisposition, `${name}_제안서`) downloadFile(blob, fileName) } catch (err) { - throw new Error('제안서 다운 실패', err as AxiosError) + if (err instanceof AxiosError) { + console.error('API Error:', { + status: err.response?.status, + statusText: err.response?.statusText, + headers: err.response?.headers, + data: err.response?.data, + }) + throw new Error(`제안서 다운로드 실패: ${err.response?.status} ${err.response?.statusText}`) + } + throw err } } diff --git a/app/admin/category/_ui/shared/_hooks/use-category-icon-post.ts b/app/admin/category/_ui/shared/_hooks/use-category-icon-post.ts index 53f457ba..1b38f932 100644 --- a/app/admin/category/_ui/shared/_hooks/use-category-icon-post.ts +++ b/app/admin/category/_ui/shared/_hooks/use-category-icon-post.ts @@ -1,7 +1,8 @@ import { ChangeEvent, useState } from 'react' +import uploadFileWithPresignedUrl from '@/shared/api/upload-file-with-presigned-url' + import getPresignedUrl from '../_api/get-presigned-url' -import uploadFileWithPresignedUrl from '../_api/upload-file-with-presigned-url' export type DomainType = 'trade' | 'stock' @@ -27,7 +28,6 @@ const useCategoryIconPost = (domain: DomainType) => { setImage(iconImage) - // preview const reader = new FileReader() reader.onload = (e) => { setImagePreview(e.target?.result as string) diff --git a/app/admin/category/_ui/stock/stock-manage/types.ts b/app/admin/category/_ui/stock/stock-manage/types.ts index fc66460c..7af2ac5d 100644 --- a/app/admin/category/_ui/stock/stock-manage/types.ts +++ b/app/admin/category/_ui/stock/stock-manage/types.ts @@ -26,9 +26,3 @@ export interface ToggleStockActiveStateResponseModel extends StockResponseBaseMo // eslint-disable-next-line export interface DeleteInactiveStockResponseModel extends StockResponseBaseModel<boolean> {} - -export interface PresignedUrlResponseModel extends StockResponseBaseModel<boolean> { - result: { - presignedUrl: string - } -} diff --git a/app/admin/notices/[noticeId]/edit/_hooks/query/use-patch-notice.ts b/app/admin/notices/[noticeId]/edit/_hooks/query/use-patch-notice.ts index 49c7e4f9..9b0c4424 100644 --- a/app/admin/notices/[noticeId]/edit/_hooks/query/use-patch-notice.ts +++ b/app/admin/notices/[noticeId]/edit/_hooks/query/use-patch-notice.ts @@ -5,7 +5,7 @@ import { useMutation, useQueryClient } from '@tanstack/react-query' import axiosInstance from '@/shared/api/axios' import { QUERY_KEY } from '@/shared/constants/query-key' -import uploadFileWithPresignedUrl from '@/shared/utils/upload-file-with-presigned-url' +import uploadFileWithPresignedUrl from '@/shared/utils/upload-files-with-presigned-url' import { PatchNoticeResponseModel } from '../../types' diff --git a/shared/constants/query-key.ts b/shared/constants/query-key.ts index 08b71927..10ee8642 100644 --- a/shared/constants/query-key.ts +++ b/shared/constants/query-key.ts @@ -16,6 +16,7 @@ export const QUERY_KEY = { STRATEGY_ANALYSIS: 'strategyAnalysis', STRATEGY_STATISTICS: 'strategyStatistics', STRATEGY_SEARCH: 'strategiesSearch', + STRATEGY_PROPOSAL_FILE_NAME: 'proposalFileName', // question QUESTION_DETAILS: 'questionDetails', diff --git a/shared/constants/supported-file-types.ts b/shared/constants/supported-file-types.ts new file mode 100644 index 00000000..91631ae2 --- /dev/null +++ b/shared/constants/supported-file-types.ts @@ -0,0 +1,10 @@ +export const SUPPORTED_FILE_TYPES = [ + 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet', + 'application/vnd.ms-excel', + 'application/pdf', + 'application/msword', + 'application/vnd.openxmlformats-officedocument.wordprocessingml.document', + 'text/plain', + 'application/vnd.ms-powerpoint', + 'application/vnd.openxmlformats-officedocument.presentationml.presentation', +] From 8fc83099ff7182e34355ad8970f4daaf8bcedaf8 Mon Sep 17 00:00:00 2001 From: James <dawn147@snu.ac.kr> Date: Sat, 25 Jan 2025 19:52:54 +0900 Subject: [PATCH 175/207] =?UTF-8?q?bug:=20=EC=A0=84=EB=9E=B5=20=EB=93=B1?= =?UTF-8?q?=EB=A1=9D=ED=95=A0=20=EB=95=8C=20edit-information-store=20?= =?UTF-8?q?=EC=A0=9C=EC=95=88=EC=84=9C=20=EC=9D=B4=EB=A6=84=20=EC=B4=88?= =?UTF-8?q?=EA=B8=B0=ED=99=94=20(#150)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../[strategyId]/_store/use-edit-information-store.tsx | 9 ++------- 1 file changed, 2 insertions(+), 7 deletions(-) diff --git a/app/(dashboard)/my/strategies/manage/[strategyId]/_store/use-edit-information-store.tsx b/app/(dashboard)/my/strategies/manage/[strategyId]/_store/use-edit-information-store.tsx index 0240355e..e924ed80 100644 --- a/app/(dashboard)/my/strategies/manage/[strategyId]/_store/use-edit-information-store.tsx +++ b/app/(dashboard)/my/strategies/manage/[strategyId]/_store/use-edit-information-store.tsx @@ -21,7 +21,6 @@ interface ActionModel { setStrategyName: (name: string) => void setDescription: (description: string) => void setProposalFile: (file: File | null) => void - setOriginalFileName: (fileName: string) => void setProposalModified: (modified: boolean) => void initializeProposal: (fileName: string) => void } @@ -58,15 +57,11 @@ const useEditInformationStore = create<StateModel & ActionModel>((set) => ({ proposal: { ...state.proposal, proposalFile: file, + proposalFileName: file?.name || state.proposal.proposalFileName, proposalModified: true, }, })), - setOriginalFileName: (fileName) => - set((state) => ({ - proposal: { ...state.proposal, originalFileName: fileName }, - })), - setProposalModified: (modified) => set((state) => ({ proposal: { ...state.proposal, proposalModified: modified }, @@ -76,7 +71,7 @@ const useEditInformationStore = create<StateModel & ActionModel>((set) => ({ set((state) => ({ proposal: { ...state.proposal, - originalFileName: fileName, + proposalFileName: fileName, proposalModified: false, }, })), From 4b5a9c3051abd146e5c919d63c9e1d1d574aaf88 Mon Sep 17 00:00:00 2001 From: James <dawn147@snu.ac.kr> Date: Sat, 25 Jan 2025 20:13:13 +0900 Subject: [PATCH 176/207] =?UTF-8?q?bug:=20=EC=9E=84=ED=8F=AC=ED=8A=B8=20?= =?UTF-8?q?=ED=8C=8C=EC=9D=BC=EB=AA=85=20=EC=88=98=EC=A0=95=20(#159)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../notices/[noticeId]/edit/_hooks/query/use-patch-notice.ts | 4 ++-- app/admin/notices/post/_hooks/query/use-post-notice.ts | 5 ++--- 2 files changed, 4 insertions(+), 5 deletions(-) diff --git a/app/admin/notices/[noticeId]/edit/_hooks/query/use-patch-notice.ts b/app/admin/notices/[noticeId]/edit/_hooks/query/use-patch-notice.ts index 9b0c4424..c835f385 100644 --- a/app/admin/notices/[noticeId]/edit/_hooks/query/use-patch-notice.ts +++ b/app/admin/notices/[noticeId]/edit/_hooks/query/use-patch-notice.ts @@ -5,7 +5,7 @@ import { useMutation, useQueryClient } from '@tanstack/react-query' import axiosInstance from '@/shared/api/axios' import { QUERY_KEY } from '@/shared/constants/query-key' -import uploadFileWithPresignedUrl from '@/shared/utils/upload-files-with-presigned-url' +import uploadFilesWithPresignedUrl from '@/shared/utils/upload-files-with-presigned-url' import { PatchNoticeResponseModel } from '../../types' @@ -40,7 +40,7 @@ const usePatchNotice = (formData: NoticeFormModel, noticeId: number) => { if (!newFiles?.length) return const presignedUrls = uploadResponse.data.result - await uploadFileWithPresignedUrl(newFiles, presignedUrls) + await uploadFilesWithPresignedUrl(newFiles, presignedUrls) }, onSuccess: () => { queryClient.invalidateQueries({ diff --git a/app/admin/notices/post/_hooks/query/use-post-notice.ts b/app/admin/notices/post/_hooks/query/use-post-notice.ts index 016b9021..2e5b5fa0 100644 --- a/app/admin/notices/post/_hooks/query/use-post-notice.ts +++ b/app/admin/notices/post/_hooks/query/use-post-notice.ts @@ -4,7 +4,7 @@ import { useMutation, useQueryClient } from '@tanstack/react-query' import axiosInstance from '@/shared/api/axios' import { QUERY_KEY } from '@/shared/constants/query-key' -import uploadFileWithPresignedUrl from '@/shared/utils/upload-file-with-presigned-url' +import uploadFilesWithPresignedUrl from '@/shared/utils/upload-files-with-presigned-url' import { NoticeFormModel } from '../../../types' import { PostNoticeResopnseModel } from '../../types' @@ -15,7 +15,6 @@ const usePostNotice = () => { return useMutation({ mutationFn: async (formData: NoticeFormModel) => { - // Presigned URL 요청` const uploadResponse = await axiosInstance.post<PostNoticeResopnseModel>( '/api/admin/notices', { @@ -31,7 +30,7 @@ const usePostNotice = () => { const presignedUrls = uploadResponse.data.result - await uploadFileWithPresignedUrl(newFiles, presignedUrls) + await uploadFilesWithPresignedUrl(newFiles, presignedUrls) }, onSuccess: () => { queryClient.invalidateQueries({ From e8fb282aad7551c5d5bfaacb0d6065701bcdd598 Mon Sep 17 00:00:00 2001 From: deun <devdeun@gmail.com> Date: Mon, 3 Feb 2025 19:17:38 +0900 Subject: [PATCH 177/207] =?UTF-8?q?fix:=20=EC=98=A4=ED=83=80=20=EC=88=98?= =?UTF-8?q?=EC=A0=95=20(#161)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- app/admin/questions/_api/delete-admin-question.ts | 4 ++-- app/admin/questions/_api/get-admin-questions.ts | 4 ++-- app/admin/questions/_api/set-admin-question-table-body.tsx | 4 ++-- app/admin/questions/types.ts | 2 +- 4 files changed, 7 insertions(+), 7 deletions(-) diff --git a/app/admin/questions/_api/delete-admin-question.ts b/app/admin/questions/_api/delete-admin-question.ts index 3dea6fb5..d97adc99 100644 --- a/app/admin/questions/_api/delete-admin-question.ts +++ b/app/admin/questions/_api/delete-admin-question.ts @@ -1,6 +1,6 @@ import axiosInstance from '@/shared/api/axios' -import { AdminQuestionsResponeseModel } from '../types' +import { AdminQuestionsResponseModel } from '../types' interface ArgModel { strategyId: number @@ -8,7 +8,7 @@ interface ArgModel { } const deleteAdminQuestion = async ({ strategyId, questionId }: ArgModel) => { try { - const res = await axiosInstance.delete<AdminQuestionsResponeseModel>( + const res = await axiosInstance.delete<AdminQuestionsResponseModel>( `/api/admin/strategies/${strategyId}/questions/${questionId}` ) if (!res.data.isSuccess) throw new Error('Error with code' + res.data.code) diff --git a/app/admin/questions/_api/get-admin-questions.ts b/app/admin/questions/_api/get-admin-questions.ts index 812c2ea4..7ff3fedc 100644 --- a/app/admin/questions/_api/get-admin-questions.ts +++ b/app/admin/questions/_api/get-admin-questions.ts @@ -1,7 +1,7 @@ import axiosInstance from '@/shared/api/axios' import { QuestionSearchConditionType, QuestionStateTapType } from '@/shared/types/questions' -import { AdminQuestionsResponeseModel } from '../types' +import { AdminQuestionsResponseModel } from '../types' interface ArgModel { keyword: string | null @@ -19,7 +19,7 @@ const getAdminQuestions = async ({ size = 10, }: ArgModel) => { try { - const res = await axiosInstance<AdminQuestionsResponeseModel>('/api/admin/questions', { + const res = await axiosInstance<AdminQuestionsResponseModel>('/api/admin/questions', { params: { keyword, searchCondition, diff --git a/app/admin/questions/_api/set-admin-question-table-body.tsx b/app/admin/questions/_api/set-admin-question-table-body.tsx index c77b1c5b..662f2f32 100644 --- a/app/admin/questions/_api/set-admin-question-table-body.tsx +++ b/app/admin/questions/_api/set-admin-question-table-body.tsx @@ -3,9 +3,9 @@ import { LinkButton } from '@/shared/ui/link-button' import AdminQuestionDeleteButton from '../_ui/admin-question-delete-button' import AdminQuestionStateBox from '../_ui/admin-question-state-box' -import { AdminQuestionsResponeseModel } from '../types' +import { AdminQuestionsResponseModel } from '../types' -const setAdminQuestionTableBody = (data: AdminQuestionsResponeseModel['result']['content']) => +const setAdminQuestionTableBody = (data: AdminQuestionsResponseModel['result']['content']) => data.map((data, idx) => { return [ idx + 1, diff --git a/app/admin/questions/types.ts b/app/admin/questions/types.ts index 5d6f92d9..630d6639 100644 --- a/app/admin/questions/types.ts +++ b/app/admin/questions/types.ts @@ -1,7 +1,7 @@ import { QuestionStateConditionType } from '@/shared/types/questions' import { APIResponseBaseModel } from '@/shared/types/response' -export interface AdminQuestionsResponeseModel extends APIResponseBaseModel<boolean> { +export interface AdminQuestionsResponseModel extends APIResponseBaseModel<boolean> { result: { content: Array<{ strategy: { From a434ee2b5c79f07d4ff6e89105df1c7904288f9f Mon Sep 17 00:00:00 2001 From: deun <devdeun@gmail.com> Date: Mon, 3 Feb 2025 19:54:41 +0900 Subject: [PATCH 178/207] =?UTF-8?q?feat:=20table=20number=20=EA=B3=84?= =?UTF-8?q?=EC=82=B0=20=EC=9C=A0=ED=8B=B8=20=ED=95=A8=EC=88=98=20=EC=B6=94?= =?UTF-8?q?=EA=B0=80=20(#161)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- shared/utils/table.ts | 11 +++++++++++ 1 file changed, 11 insertions(+) create mode 100644 shared/utils/table.ts diff --git a/shared/utils/table.ts b/shared/utils/table.ts new file mode 100644 index 00000000..ab6016c3 --- /dev/null +++ b/shared/utils/table.ts @@ -0,0 +1,11 @@ +export const calculateTableNumber = ({ + page, + idx, + countPerPage, +}: { + page: number + idx: number + countPerPage: number +}) => { + return (page - 1) * countPerPage + (idx + 1) +} From b4ecca39a963428ab9afeb3d205f4adc13a48391 Mon Sep 17 00:00:00 2001 From: deun <devdeun@gmail.com> Date: Mon, 3 Feb 2025 19:55:50 +0900 Subject: [PATCH 179/207] =?UTF-8?q?fix:=20admin=20=ED=85=8C=EC=9D=B4?= =?UTF-8?q?=EB=B8=94=EC=9D=98=20id=EB=A5=BC=20table=20number=EB=A1=9C=20?= =?UTF-8?q?=EB=B3=80=EA=B2=BD=20(#161)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- app/(landing)/notices/_ui/notice-table/index.tsx | 3 ++- .../_api/set-admin-stock-manage-table-data.tsx | 14 +++++++++++--- .../_hooks/query/use-toggle-stock-active-state.ts | 4 ++-- .../stock-manage/_ui/active-stock-manage-table.tsx | 9 ++++++--- .../_ui/inactive-stock-manage-table.tsx | 7 ++++++- .../_ui/stock-active-state-toggle-button.tsx | 4 ++-- .../_api/set-admin-trade-manage-table-data.tsx | 14 +++++++++++--- .../trade-manage/_ui/active-trade-manage-table.tsx | 7 ++++++- .../_ui/inactive-trade-manage-table.tsx | 9 ++++++--- .../_ui/trade-active-state-toggle-button.tsx | 5 +++-- app/admin/notices/_ui/admin-notice-table/index.tsx | 9 ++++++--- .../_api/set-admin-question-table-body.tsx | 11 +++++++++-- app/admin/questions/page.tsx | 6 +++++- .../_api/set-admin-strategies-table-body.tsx | 13 ++++++++++--- app/admin/strategies/page.tsx | 6 +++++- app/admin/users/_api/set-table-body.tsx | 9 ++++++--- app/admin/users/page.tsx | 6 +++++- 17 files changed, 101 insertions(+), 35 deletions(-) diff --git a/app/(landing)/notices/_ui/notice-table/index.tsx b/app/(landing)/notices/_ui/notice-table/index.tsx index 2d0d2c2e..2b3de09d 100644 --- a/app/(landing)/notices/_ui/notice-table/index.tsx +++ b/app/(landing)/notices/_ui/notice-table/index.tsx @@ -8,6 +8,7 @@ import { PATH } from '@/shared/constants/path' import { usePagination } from '@/shared/hooks/custom/use-pagination' import Pagination from '@/shared/ui/pagination' import VerticalTable from '@/shared/ui/table/vertical' +import { calculateTableNumber } from '@/shared/utils/table' import useGetNotices from '../../_hooks/use-notice' import styles from './styles.module.scss' @@ -38,7 +39,7 @@ const NoticeTable = () => { } const notices = data.content.map((values, idx) => ({ - no: (page - 1) * COUNT_PER_PAGE + (idx + 1), + no: calculateTableNumber({ page, idx, countPerPage: COUNT_PER_PAGE }), title: <Link href={`${PATH.NOTICES}/${values.noticeId}/detail`}>{values.title}</Link>, createdAt: values.createdAt, })) diff --git a/app/admin/category/_ui/stock/stock-manage/_api/set-admin-stock-manage-table-data.tsx b/app/admin/category/_ui/stock/stock-manage/_api/set-admin-stock-manage-table-data.tsx index 6a3f09ce..0df56445 100644 --- a/app/admin/category/_ui/stock/stock-manage/_api/set-admin-stock-manage-table-data.tsx +++ b/app/admin/category/_ui/stock/stock-manage/_api/set-admin-stock-manage-table-data.tsx @@ -1,14 +1,22 @@ import Image from 'next/image' import { Button } from '@/shared/ui/button' +import { calculateTableNumber } from '@/shared/utils/table' import InactiveStockDeleteButton from '../_ui/inactive-stock-delete-button' import StockActiveStateToggleButton from '../_ui/stock-active-state-toggle-button' import { StockResponseModel } from '../types' -const setAdminStockManageTableData = (data: StockResponseModel['result'], isActive: boolean) => - data?.content.map((data) => [ - data.stockTypeId, +interface Props { + data: StockResponseModel['result'] + isActive: boolean + page: number + countPerPage: number +} + +const setAdminStockManageTableData = ({ data, isActive, page, countPerPage }: Props) => + data?.content.map((data, idx) => [ + calculateTableNumber({ page, idx, countPerPage }), data.stockTypeName, <Image src={data.stockTypeIconUrl} diff --git a/app/admin/category/_ui/stock/stock-manage/_hooks/query/use-toggle-stock-active-state.ts b/app/admin/category/_ui/stock/stock-manage/_hooks/query/use-toggle-stock-active-state.ts index f2944a12..80ba45b7 100644 --- a/app/admin/category/_ui/stock/stock-manage/_hooks/query/use-toggle-stock-active-state.ts +++ b/app/admin/category/_ui/stock/stock-manage/_hooks/query/use-toggle-stock-active-state.ts @@ -4,7 +4,7 @@ import { QUERY_KEY } from '@/shared/constants/query-key' import ToggleStockActiveState from '../../_api/toggle-stock-active-state' -const useToggoleStockActiveState = (stockTypeId: number) => { +const useToggleStockActiveState = (stockTypeId: number) => { const queryClient = useQueryClient() return useMutation({ @@ -20,4 +20,4 @@ const useToggoleStockActiveState = (stockTypeId: number) => { }) } -export default useToggoleStockActiveState +export default useToggleStockActiveState diff --git a/app/admin/category/_ui/stock/stock-manage/_ui/active-stock-manage-table.tsx b/app/admin/category/_ui/stock/stock-manage/_ui/active-stock-manage-table.tsx index aa0450d7..f8d4905c 100644 --- a/app/admin/category/_ui/stock/stock-manage/_ui/active-stock-manage-table.tsx +++ b/app/admin/category/_ui/stock/stock-manage/_ui/active-stock-manage-table.tsx @@ -2,8 +2,6 @@ import { useState } from 'react' -import withSuspense from '@/shared/utils/with-suspense' - import ManageTable from '../../../shared/manage-table' import setAdminStockManageTableData from '../_api/set-admin-stock-manage-table-data' import useStocksData from '../_hooks/query/use-stocks-data' @@ -16,7 +14,12 @@ const ActiveStockManageTable = () => { const { data } = useStocksData('active', currentPage, TABLE_BODY_SIZE) if (!data) return null - const tableData = setAdminStockManageTableData(data, true) + const tableData = setAdminStockManageTableData({ + data, + isActive: true, + page: currentPage, + countPerPage: TABLE_BODY_SIZE, + }) return ( <ManageTable diff --git a/app/admin/category/_ui/stock/stock-manage/_ui/inactive-stock-manage-table.tsx b/app/admin/category/_ui/stock/stock-manage/_ui/inactive-stock-manage-table.tsx index 9c5a59ac..20486230 100644 --- a/app/admin/category/_ui/stock/stock-manage/_ui/inactive-stock-manage-table.tsx +++ b/app/admin/category/_ui/stock/stock-manage/_ui/inactive-stock-manage-table.tsx @@ -14,7 +14,12 @@ const InactiveTradeManageTable = () => { const { data } = useStocksData('inactive', currentPage, TABLE_BODY_SIZE) if (!data) return null - const tableData = setAdminStockManageTableData(data, false) + const tableData = setAdminStockManageTableData({ + data, + isActive: false, + page: currentPage, + countPerPage: data.size, + }) return ( <ManageTable diff --git a/app/admin/category/_ui/stock/stock-manage/_ui/stock-active-state-toggle-button.tsx b/app/admin/category/_ui/stock/stock-manage/_ui/stock-active-state-toggle-button.tsx index 1a6149bf..7e7ab8ac 100644 --- a/app/admin/category/_ui/stock/stock-manage/_ui/stock-active-state-toggle-button.tsx +++ b/app/admin/category/_ui/stock/stock-manage/_ui/stock-active-state-toggle-button.tsx @@ -4,7 +4,7 @@ import { CSSProperties } from 'react' import { Button } from '@/shared/ui/button' -import useToggoleStockActiveState from '../_hooks/query/use-toggle-stock-active-state' +import useToggleStockActiveState from '../_hooks/query/use-toggle-stock-active-state' interface Props { active?: boolean @@ -12,7 +12,7 @@ interface Props { } const StockActiveStateToggleButton = ({ active, stockTypeId }: Props) => { - const { mutate, isPending } = useToggoleStockActiveState(stockTypeId) + const { mutate, isPending } = useToggleStockActiveState(stockTypeId) const onButtonClick = () => { mutate() } diff --git a/app/admin/category/_ui/trade/trade-manage/_api/set-admin-trade-manage-table-data.tsx b/app/admin/category/_ui/trade/trade-manage/_api/set-admin-trade-manage-table-data.tsx index 1697461c..b2f144c1 100644 --- a/app/admin/category/_ui/trade/trade-manage/_api/set-admin-trade-manage-table-data.tsx +++ b/app/admin/category/_ui/trade/trade-manage/_api/set-admin-trade-manage-table-data.tsx @@ -1,14 +1,22 @@ import Image from 'next/image' import { Button } from '@/shared/ui/button' +import { calculateTableNumber } from '@/shared/utils/table' import InactiveTradeDeleteButton from '../_ui/inactive-trade-delete-button' import TradeActiveStateToggleButton from '../_ui/trade-active-state-toggle-button' import { TradeResponseModel } from '../types' -const setAdminTradeManageTableData = (data: TradeResponseModel['result'], isActive: boolean) => - data?.map((data) => [ - data.tradeTypeId, +interface Props { + data: TradeResponseModel['result'] + isActive: boolean + page: number + countPerPage: number +} + +const setAdminTradeManageTableData = ({ data, isActive, page, countPerPage }: Props) => + data?.map((data, idx) => [ + calculateTableNumber({ page, idx, countPerPage }), data.tradeName, <Image src={data.tradeTypeIconUrl} diff --git a/app/admin/category/_ui/trade/trade-manage/_ui/active-trade-manage-table.tsx b/app/admin/category/_ui/trade/trade-manage/_ui/active-trade-manage-table.tsx index bbd3b320..f1b70123 100644 --- a/app/admin/category/_ui/trade/trade-manage/_ui/active-trade-manage-table.tsx +++ b/app/admin/category/_ui/trade/trade-manage/_ui/active-trade-manage-table.tsx @@ -6,7 +6,12 @@ const ActiveTradeManageTable = () => { const { data } = useTradeData('active') if (!data) return null - const tableData = setAdminTradeManageTableData(data, true) + const tableData = setAdminTradeManageTableData({ + data, + isActive: true, + page: 1, + countPerPage: 10, + }) return <ManageTable data={tableData} active domain="매매 유형" /> } diff --git a/app/admin/category/_ui/trade/trade-manage/_ui/inactive-trade-manage-table.tsx b/app/admin/category/_ui/trade/trade-manage/_ui/inactive-trade-manage-table.tsx index e157a1a4..44f2d4e8 100644 --- a/app/admin/category/_ui/trade/trade-manage/_ui/inactive-trade-manage-table.tsx +++ b/app/admin/category/_ui/trade/trade-manage/_ui/inactive-trade-manage-table.tsx @@ -1,5 +1,3 @@ -import withSuspense from '@/shared/utils/with-suspense' - import ManageTable from '../../../shared/manage-table' import setAdminTradeManageTableData from '../_api/set-admin-trade-manage-table-data' import useTradeData from '../_hooks/query/use-trades-data' @@ -8,7 +6,12 @@ const InactiveTradeManageTable = () => { const { data } = useTradeData('inactive') if (!data) return null - const tableData = setAdminTradeManageTableData(data, false) + const tableData = setAdminTradeManageTableData({ + data, + isActive: false, + page: 1, + countPerPage: 10, + }) return <ManageTable data={tableData} domain="매매 유형" /> } diff --git a/app/admin/category/_ui/trade/trade-manage/_ui/trade-active-state-toggle-button.tsx b/app/admin/category/_ui/trade/trade-manage/_ui/trade-active-state-toggle-button.tsx index a3996a5b..58e0493b 100644 --- a/app/admin/category/_ui/trade/trade-manage/_ui/trade-active-state-toggle-button.tsx +++ b/app/admin/category/_ui/trade/trade-manage/_ui/trade-active-state-toggle-button.tsx @@ -4,7 +4,7 @@ import { CSSProperties } from 'react' import { Button } from '@/shared/ui/button' -import useToggoleTradeActiveState from '../_hooks/query/use-toggle-trade-active-state' +import useToggleTradeActiveState from '../_hooks/query/use-toggle-trade-active-state' interface Props { active?: boolean @@ -12,7 +12,7 @@ interface Props { } const TradeActiveStateToggleButton = ({ active, tradeTypeId }: Props) => { - const { mutate, isPending } = useToggoleTradeActiveState(tradeTypeId) + const { mutate, isPending } = useToggleTradeActiveState(tradeTypeId) const onButtonClick = () => { mutate() } @@ -35,4 +35,5 @@ const buttonStyles: CSSProperties = { padding: '7px 16px', margin: '15px 0', } + export default TradeActiveStateToggleButton diff --git a/app/admin/notices/_ui/admin-notice-table/index.tsx b/app/admin/notices/_ui/admin-notice-table/index.tsx index 57d18508..f1a52cb0 100644 --- a/app/admin/notices/_ui/admin-notice-table/index.tsx +++ b/app/admin/notices/_ui/admin-notice-table/index.tsx @@ -8,6 +8,7 @@ import classNames from 'classnames/bind' import { Button } from '@/shared/ui/button' import Pagination from '@/shared/ui/pagination' import VerticalTable from '@/shared/ui/table/vertical' +import { calculateTableNumber } from '@/shared/utils/table' import useAdminNotices from '../../_hook/query/get-admin-notices' import useAdminNoticePage from '../../_hook/use-admin-notice-page' @@ -17,6 +18,8 @@ import styles from './styles.module.scss' const cx = classNames.bind(styles) +const COUNT_PER_PAGE = 10 + const AdminNoticeTable = () => { const { currentPage, setCurrentPage } = useAdminNoticePage() const { data, isLoading } = useAdminNotices() @@ -26,8 +29,8 @@ const AdminNoticeTable = () => { } const tableBody = - data?.content.map((data) => [ - data.noticeId, + data?.content.map((data, idx) => [ + calculateTableNumber({ page: currentPage, idx, countPerPage: COUNT_PER_PAGE }), <Link href={`/notices/${data.noticeId}/detail`} key={data.noticeId}> {data.title} </Link>, @@ -52,7 +55,7 @@ const AdminNoticeTable = () => { <VerticalTable tableHead={['No.', '제목', '내용', '작성일', '']} tableBody={tableBody} - countPerPage={10} + countPerPage={COUNT_PER_PAGE} currentPage={1} /> <Pagination diff --git a/app/admin/questions/_api/set-admin-question-table-body.tsx b/app/admin/questions/_api/set-admin-question-table-body.tsx index 662f2f32..46fabb35 100644 --- a/app/admin/questions/_api/set-admin-question-table-body.tsx +++ b/app/admin/questions/_api/set-admin-question-table-body.tsx @@ -1,14 +1,21 @@ import { PATH } from '@/shared/constants/path' import { LinkButton } from '@/shared/ui/link-button' +import { calculateTableNumber } from '@/shared/utils/table' import AdminQuestionDeleteButton from '../_ui/admin-question-delete-button' import AdminQuestionStateBox from '../_ui/admin-question-state-box' import { AdminQuestionsResponseModel } from '../types' -const setAdminQuestionTableBody = (data: AdminQuestionsResponseModel['result']['content']) => +interface Props { + data: AdminQuestionsResponseModel['result']['content'] + page: number + countPerPage: number +} + +const setAdminQuestionTableBody = ({ data, page, countPerPage }: Props) => data.map((data, idx) => { return [ - idx + 1, + calculateTableNumber({ page, idx, countPerPage }), data.title, data.strategy.name, data.trader.userName, diff --git a/app/admin/questions/page.tsx b/app/admin/questions/page.tsx index 05c43f0f..d2211290 100644 --- a/app/admin/questions/page.tsx +++ b/app/admin/questions/page.tsx @@ -77,7 +77,11 @@ const AdminQuestionsPage = () => { /> <VerticalTable tableHead={['No.', '제목', '전략명', '트레이더', '질문자', '상태', '']} - tableBody={setAdminQuestionTableBody(data.content)} + tableBody={setAdminQuestionTableBody({ + data: data.content, + page: data.page, + countPerPage: data.size, + })} countPerPage={data.size} currentPage={1} /> diff --git a/app/admin/strategies/_api/set-admin-strategies-table-body.tsx b/app/admin/strategies/_api/set-admin-strategies-table-body.tsx index 95f49eae..4e94008e 100644 --- a/app/admin/strategies/_api/set-admin-strategies-table-body.tsx +++ b/app/admin/strategies/_api/set-admin-strategies-table-body.tsx @@ -1,5 +1,6 @@ import { Button } from '@/shared/ui/button' import { formatDate } from '@/shared/utils/format' +import { calculateTableNumber } from '@/shared/utils/table' import AdminStrategiesApproveTd from '../_ui/admin-strategies-approve-td' import StrategyDeleteButton from '../_ui/button/strategy-delete-button copy' @@ -7,10 +8,16 @@ import StrategyEditButton from '../_ui/button/strategy-edit-button' import PublicSelect from '../_ui/public-select' import { StrategiesResponseModel } from '../types' -const setAdminStrategiesTableBody = (data: StrategiesResponseModel['result']['content']) => - data.map((data) => { +interface Props { + data: StrategiesResponseModel['result']['content'] + page: number + countPerPage: number +} + +const setAdminStrategiesTableBody = ({ data, page, countPerPage }: Props) => + data.map((data, idx) => { return [ - data.strategyId, + calculateTableNumber({ page, idx, countPerPage }), formatDate(data.createAt), data.strategyName, data.nickname, diff --git a/app/admin/strategies/page.tsx b/app/admin/strategies/page.tsx index 05492614..7cffc726 100644 --- a/app/admin/strategies/page.tsx +++ b/app/admin/strategies/page.tsx @@ -64,7 +64,11 @@ const AdminStrategyPage = () => { /> <VerticalTable tableHead={['No.', '날짜', '전략명', '닉네임', '공개여부', '승인여부', '']} - tableBody={setAdminStrategiesTableBody(data.content)} + tableBody={setAdminStrategiesTableBody({ + data: data.content, + page: data.page, + countPerPage: data.size, + })} countPerPage={data.size} currentPage={1} /> diff --git a/app/admin/users/_api/set-table-body.tsx b/app/admin/users/_api/set-table-body.tsx index 80eb1669..8444eed9 100644 --- a/app/admin/users/_api/set-table-body.tsx +++ b/app/admin/users/_api/set-table-body.tsx @@ -1,4 +1,5 @@ import Avatar from '@/shared/ui/avatar' +import { calculateTableNumber } from '@/shared/utils/table' import RoleSelect from '../_ui/role-select' import UserDeleteButton from '../_ui/user-delete-button' @@ -6,12 +7,14 @@ import { AdminUserInfoModel } from '../types' interface ArgModel { data: AdminUserInfoModel[] + page: number + countPerPage: number } -const setTableBody = ({ data }: ArgModel) => - data.map((data) => { +const setTableBody = ({ data, page, countPerPage }: ArgModel) => + data.map((data, idx) => { return [ - data.userId, + calculateTableNumber({ page, idx, countPerPage }), <Avatar src={data?.imageUrl ?? undefined} key={data.userId} />, data.userName, data.nickname, diff --git a/app/admin/users/page.tsx b/app/admin/users/page.tsx index 74537f8f..b138173b 100644 --- a/app/admin/users/page.tsx +++ b/app/admin/users/page.tsx @@ -70,7 +70,11 @@ const AdminUsersPage = () => { /> <VerticalTable tableHead={['No.', '프로필', '이름', '닉네임', '이메일', '전화번호', '회원분류', '탈퇴']} - tableBody={setTableBody({ data: data?.content })} + tableBody={setTableBody({ + data: data?.content, + page: data?.page, + countPerPage: data.size, + })} countPerPage={data.size} currentPage={1} /> From da343aa28bc3ae8103b946a5048459ded41b1c19 Mon Sep 17 00:00:00 2001 From: James <dawn147@snu.ac.kr> Date: Fri, 7 Feb 2025 19:03:59 +0900 Subject: [PATCH 180/207] =?UTF-8?q?refactor:=20=EB=B6=88=ED=95=84=EC=9A=94?= =?UTF-8?q?=ED=95=9C=20=EC=A3=BC=EC=84=9D=20=EC=A0=9C=EA=B1=B0=20(#163)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- app/admin/category/_ui/shared/manage-table/index.tsx | 3 +-- .../stock-manage/_ui/stock-post-button/styles.module.scss | 2 +- .../trade-manage/_ui/trade-post-button/styles.module.scss | 2 +- app/admin/notices/_ui/admin-notice-table/styles.module.scss | 2 +- app/admin/notices/page.module.scss | 2 +- app/admin/strategies/_ui/admin-strategy-public-select.tsx | 4 ---- 6 files changed, 5 insertions(+), 10 deletions(-) diff --git a/app/admin/category/_ui/shared/manage-table/index.tsx b/app/admin/category/_ui/shared/manage-table/index.tsx index ee1c3a68..abf63120 100644 --- a/app/admin/category/_ui/shared/manage-table/index.tsx +++ b/app/admin/category/_ui/shared/manage-table/index.tsx @@ -14,7 +14,6 @@ interface Props { active?: boolean domain: '종목' | '매매 유형' data: (ReactNode | string | number)[][] - //TODO: domain이 종록일 때만 쓰는 속성들 컨트롤 하는 법 고민해보기 size?: number currentPage?: number setCurrentPage?: Dispatch<SetStateAction<number>> @@ -39,7 +38,7 @@ const ManageTable = ({ tableHead={['No.', domain === '종목' ? '종목명' : '매매 유형', '분류', '상태']} tableBody={data} countPerPage={size} - currentPage={1} // 공통 컴포넌트와의 호환성 문제... + currentPage={1} /> {hasData && domain === '종목' && ( <Pagination diff --git a/app/admin/category/_ui/stock/stock-manage/_ui/stock-post-button/styles.module.scss b/app/admin/category/_ui/stock/stock-manage/_ui/stock-post-button/styles.module.scss index 2817e085..f1deaa53 100644 --- a/app/admin/category/_ui/stock/stock-manage/_ui/stock-post-button/styles.module.scss +++ b/app/admin/category/_ui/stock/stock-manage/_ui/stock-post-button/styles.module.scss @@ -6,7 +6,7 @@ } .form { - width: 236px; //이거 고정값 맞나?... + width: 236px; margin-top: 24px; } diff --git a/app/admin/category/_ui/trade/trade-manage/_ui/trade-post-button/styles.module.scss b/app/admin/category/_ui/trade/trade-manage/_ui/trade-post-button/styles.module.scss index 2817e085..f1deaa53 100644 --- a/app/admin/category/_ui/trade/trade-manage/_ui/trade-post-button/styles.module.scss +++ b/app/admin/category/_ui/trade/trade-manage/_ui/trade-post-button/styles.module.scss @@ -6,7 +6,7 @@ } .form { - width: 236px; //이거 고정값 맞나?... + width: 236px; margin-top: 24px; } diff --git a/app/admin/notices/_ui/admin-notice-table/styles.module.scss b/app/admin/notices/_ui/admin-notice-table/styles.module.scss index 02684f1a..21e24918 100644 --- a/app/admin/notices/_ui/admin-notice-table/styles.module.scss +++ b/app/admin/notices/_ui/admin-notice-table/styles.module.scss @@ -1,6 +1,6 @@ .container { position: relative; - padding: 0 25px 37px; // table 기본 padding 때문에 20 뺌 + padding: 0 25px 37px; border-radius: 8px; margin-bottom: 42px; background-color: $color-white; diff --git a/app/admin/notices/page.module.scss b/app/admin/notices/page.module.scss index 02684f1a..21e24918 100644 --- a/app/admin/notices/page.module.scss +++ b/app/admin/notices/page.module.scss @@ -1,6 +1,6 @@ .container { position: relative; - padding: 0 25px 37px; // table 기본 padding 때문에 20 뺌 + padding: 0 25px 37px; border-radius: 8px; margin-bottom: 42px; background-color: $color-white; diff --git a/app/admin/strategies/_ui/admin-strategy-public-select.tsx b/app/admin/strategies/_ui/admin-strategy-public-select.tsx index e4e1278f..269baae3 100644 --- a/app/admin/strategies/_ui/admin-strategy-public-select.tsx +++ b/app/admin/strategies/_ui/admin-strategy-public-select.tsx @@ -4,7 +4,6 @@ import { useState } from 'react' import Select from '@/shared/ui/select' -// import usePatchUserRole from '../_hooks/query/use-patch-user-role' import { strategyPublicOptions } from '../constants' import { StrategiesPublicStateType, StrategiesResponseModel } from '../types' @@ -16,14 +15,11 @@ const AdminStrategyPublicSelect = ({ data }: Props) => { const { strategyId, isPublic } = data const [value, setValue] = useState<StrategiesPublicStateType>(isPublic) - // const { mutate } = usePatchUserRole(strategyId, value) - return ( <Select value={value} onChange={(v) => { setValue(v as StrategiesPublicStateType) - // mutate() }} options={strategyPublicOptions} key={strategyId} From 5f1a00bd7da9c21d6a5c55a23fd32f298b6ee576 Mon Sep 17 00:00:00 2001 From: James <dawn147@snu.ac.kr> Date: Fri, 7 Feb 2025 19:06:19 +0900 Subject: [PATCH 181/207] =?UTF-8?q?refactor:=20=EB=B6=88=ED=95=84=EC=9A=94?= =?UTF-8?q?=ED=95=9C=20try-catch=EB=AC=B8=20=EC=A0=9C=EA=B1=B0=20(#163)=20?= =?UTF-8?q?-=20=EC=BD=98=EC=86=94=20=EB=A1=9C=EA=B7=B8=EB=8F=84=20?= =?UTF-8?q?=EA=B0=99=EC=9D=B4=20=EC=A0=9C=EA=B1=B0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../_api/delete-inactive-stock.ts | 15 ++++------ .../_ui/stock/stock-manage/_api/get-stocks.ts | 23 ++++++--------- .../_api/toggle-stock-active-state.ts | 14 ++++------ .../_ui/trade/trade-manage/_api/get-trades.ts | 19 +++++-------- .../_api/toggle-trade-active-state.ts | 14 ++++------ app/admin/strategies/_api/get-strategies.ts | 26 +++++++---------- .../_api/patch-strategy-approval.ts | 28 ++++++++----------- 7 files changed, 52 insertions(+), 87 deletions(-) diff --git a/app/admin/category/_ui/stock/stock-manage/_api/delete-inactive-stock.ts b/app/admin/category/_ui/stock/stock-manage/_api/delete-inactive-stock.ts index 044bc1e8..dcf0f73f 100644 --- a/app/admin/category/_ui/stock/stock-manage/_api/delete-inactive-stock.ts +++ b/app/admin/category/_ui/stock/stock-manage/_api/delete-inactive-stock.ts @@ -3,18 +3,13 @@ import axiosInstance from '@/shared/api/axios' import { DeleteInactiveStockResponseModel } from '../types' const deleteInactiveStock = async (stockTypeId: number) => { - try { - const res = await axiosInstance.delete<DeleteInactiveStockResponseModel>( - `/api/admin/strategies/stock-type/${stockTypeId}` - ) + const res = await axiosInstance.delete<DeleteInactiveStockResponseModel>( + `/api/admin/strategies/stock-type/${stockTypeId}` + ) - if (!res.data.isSuccess) throw new Error(res.data.message) + if (!res.data.isSuccess) throw new Error(res.data.message) - return res.data - } catch (err) { - console.log('Error : ' + err) - throw err - } + return res.data } export default deleteInactiveStock diff --git a/app/admin/category/_ui/stock/stock-manage/_api/get-stocks.ts b/app/admin/category/_ui/stock/stock-manage/_api/get-stocks.ts index aed70b79..f0d7530b 100644 --- a/app/admin/category/_ui/stock/stock-manage/_api/get-stocks.ts +++ b/app/admin/category/_ui/stock/stock-manage/_api/get-stocks.ts @@ -3,22 +3,17 @@ import axiosInstance from '@/shared/api/axios' import { StockResponseModel } from '../types' const getStocks = async (activateState: boolean, page: number = 1, size: number = 10) => { - try { - const res = await axiosInstance<StockResponseModel>('/api/admin/strategies/stock-type', { - params: { - activateState, - page, - size, - }, - }) + const res = await axiosInstance.get<StockResponseModel>('/api/admin/strategies/stock-type', { + params: { + activateState, + page, + size, + }, + }) - if (!res.data.isSuccess) throw new Error(res.data.message) + if (!res.data.isSuccess) throw new Error(res.data.message) - return res.data.result - } catch (err) { - console.log('Error : ' + err) - throw err - } + return res.data.result } export default getStocks diff --git a/app/admin/category/_ui/stock/stock-manage/_api/toggle-stock-active-state.ts b/app/admin/category/_ui/stock/stock-manage/_api/toggle-stock-active-state.ts index 43629679..d391877f 100644 --- a/app/admin/category/_ui/stock/stock-manage/_api/toggle-stock-active-state.ts +++ b/app/admin/category/_ui/stock/stock-manage/_api/toggle-stock-active-state.ts @@ -3,17 +3,13 @@ import axiosInstance from '@/shared/api/axios' import { ToggleStockActiveStateResponseModel } from '../types' const ToggleStockActiveState = async (stockTypeId: number) => { - try { - const res = await axiosInstance.patch<ToggleStockActiveStateResponseModel>( - `/api/admin/strategies/stock-type/${stockTypeId}` - ) + const res = await axiosInstance.patch<ToggleStockActiveStateResponseModel>( + `/api/admin/strategies/stock-type/${stockTypeId}` + ) - if (!res.data.isSuccess) throw new Error(res.data.message) + if (!res.data.isSuccess) throw new Error(res.data.message) - return res.data - } catch (err) { - console.log('Error : ' + err) - } + return res.data } export default ToggleStockActiveState diff --git a/app/admin/category/_ui/trade/trade-manage/_api/get-trades.ts b/app/admin/category/_ui/trade/trade-manage/_api/get-trades.ts index cb7bef00..6722bad6 100644 --- a/app/admin/category/_ui/trade/trade-manage/_api/get-trades.ts +++ b/app/admin/category/_ui/trade/trade-manage/_api/get-trades.ts @@ -3,20 +3,15 @@ import axiosInstance from '@/shared/api/axios' import { TradeResponseModel } from '../types' const getTrades = async (activateState: boolean) => { - try { - const res = await axiosInstance<TradeResponseModel>('/api/admin/strategies/trade-type', { - params: { - activateState, - }, - }) + const res = await axiosInstance.get<TradeResponseModel>('/api/admin/strategies/trade-type', { + params: { + activateState, + }, + }) - if (!res.data.isSuccess) throw new Error(res.data.message) + if (!res.data.isSuccess) throw new Error(res.data.message) - return res.data.result - } catch (err) { - console.log('Error : ' + err) - throw err - } + return res.data.result } export default getTrades diff --git a/app/admin/category/_ui/trade/trade-manage/_api/toggle-trade-active-state.ts b/app/admin/category/_ui/trade/trade-manage/_api/toggle-trade-active-state.ts index cee615a9..554db632 100644 --- a/app/admin/category/_ui/trade/trade-manage/_api/toggle-trade-active-state.ts +++ b/app/admin/category/_ui/trade/trade-manage/_api/toggle-trade-active-state.ts @@ -3,17 +3,13 @@ import axiosInstance from '@/shared/api/axios' import { ToggleTradeActiveStateResponseModel } from '../types' const ToggleTradeActiveState = async (tradeTypeId: number) => { - try { - const res = await axiosInstance.patch<ToggleTradeActiveStateResponseModel>( - `/api/admin/strategies/trade-type/${tradeTypeId}` - ) + const res = await axiosInstance.patch<ToggleTradeActiveStateResponseModel>( + `/api/admin/strategies/trade-type/${tradeTypeId}` + ) - if (!res.data.isSuccess) throw new Error(res.data.message) + if (!res.data.isSuccess) throw new Error(res.data.message) - return res.data - } catch (err) { - console.log('Error : ' + err) - } + return res.data } export default ToggleTradeActiveState diff --git a/app/admin/strategies/_api/get-strategies.ts b/app/admin/strategies/_api/get-strategies.ts index 4fb6eaa0..bdc51d73 100644 --- a/app/admin/strategies/_api/get-strategies.ts +++ b/app/admin/strategies/_api/get-strategies.ts @@ -10,23 +10,17 @@ interface ArgModel { } const getStrategies = async ({ searchWord, isApproved, page = 0, size = 10 }: ArgModel) => { - try { - const res = await axiosInstance<StrategiesResponseModel>('/api/admin/strategies', { - params: { - searchWord, - isApproved, - page, - size, - }, - }) + const res = await axiosInstance<StrategiesResponseModel>('/api/admin/strategies', { + params: { + searchWord, + isApproved, + page, + size, + }, + }) - if (!res.data.isSuccess) throw new Error(res.data.message) - - return res.data.result - } catch (err) { - console.log('Error : ' + err) - throw err - } + if (!res.data.isSuccess) throw new Error(res.data.message) + return res.data.result } export default getStrategies diff --git a/app/admin/strategies/_api/patch-strategy-approval.ts b/app/admin/strategies/_api/patch-strategy-approval.ts index df60bb65..600df408 100644 --- a/app/admin/strategies/_api/patch-strategy-approval.ts +++ b/app/admin/strategies/_api/patch-strategy-approval.ts @@ -3,24 +3,18 @@ import axiosInstance from '@/shared/api/axios' import { StrategiesResponseModel } from '../types' const patchStrategyApproval = async (strategyId: number, isApproved: 'APPROVED' | 'DENY') => { - try { - const res = await axiosInstance.patch<StrategiesResponseModel>( - `/api/admin/strategies/${strategyId}`, - null, - { - params: { - isApproved, - }, - } - ) + const res = await axiosInstance.patch<StrategiesResponseModel>( + `/api/admin/strategies/${strategyId}`, + null, + { + params: { + isApproved, + }, + } + ) - if (!res.data.isSuccess) throw new Error(res.data.message) - - return res.data.result - } catch (err) { - console.log('Error : ' + err) - throw err - } + if (!res.data.isSuccess) throw new Error(res.data.message) + return res.data.result } export default patchStrategyApproval From 320bd31df13cc710bd35bc25832efcb64a5cb429 Mon Sep 17 00:00:00 2001 From: James <dawn147@snu.ac.kr> Date: Fri, 7 Feb 2025 19:07:17 +0900 Subject: [PATCH 182/207] =?UTF-8?q?refactor:=20=EA=B8=B0=ED=83=80=20?= =?UTF-8?q?=EB=A6=AC=ED=8C=A9=ED=86=A0=EB=A7=81=20=EC=82=AC=ED=95=AD(#163)?= =?UTF-8?q?=20-=20=EB=B6=88=ED=95=84=EC=9A=94=ED=95=9C=20=EC=BD=94?= =?UTF-8?q?=EB=93=9C=20=EC=A0=9C=EA=B1=B0=20-=20any=20=ED=83=80=EC=9E=85?= =?UTF-8?q?=20=EA=B5=AC=EC=B2=B4=ED=99=94=20-=20error=EB=A5=BC=20err?= =?UTF-8?q?=EB=A1=9C=20=EB=B3=80=EA=B2=BD?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- app/(dashboard)/my/strategies/add/page.tsx | 3 +- app/admin/_ui/file-input/index.tsx | 1 - .../shared/manage-table/styles.module.scss | 3 +- .../_ui/admin-question-view-detail/index.tsx | 30 ------------------- .../styles.module.scss | 6 ---- shared/hooks/query/auth-queries.ts | 4 +-- 6 files changed, 5 insertions(+), 42 deletions(-) delete mode 100644 app/admin/questions/_ui/admin-question-view-detail/index.tsx delete mode 100644 app/admin/questions/_ui/admin-question-view-detail/styles.module.scss diff --git a/app/(dashboard)/my/strategies/add/page.tsx b/app/(dashboard)/my/strategies/add/page.tsx index 24ddf7f1..5a935bd1 100644 --- a/app/(dashboard)/my/strategies/add/page.tsx +++ b/app/(dashboard)/my/strategies/add/page.tsx @@ -10,6 +10,7 @@ import classNames from 'classnames/bind' import { SUPPORTED_FILE_TYPES } from '@/shared/constants/supported-file-types' import { Button } from '@/shared/ui/button' +import { DropdownValueType } from '@/shared/ui/dropdown/types' import BackHeader from '@/shared/ui/header/back-header' import Input from '@/shared/ui/input' import Select from '@/shared/ui/select' @@ -121,7 +122,7 @@ const StrategyAddPage = () => { } } - const handleInputChange = (field: keyof StrategyFormDataModel, value: any) => { + const handleInputChange = (field: keyof StrategyFormDataModel, value: DropdownValueType) => { setFormData((prev) => ({ ...prev, [field]: value })) setFormErrors((prev) => ({ ...prev, [field]: '' })) } diff --git a/app/admin/_ui/file-input/index.tsx b/app/admin/_ui/file-input/index.tsx index 5d53a80d..868b6516 100644 --- a/app/admin/_ui/file-input/index.tsx +++ b/app/admin/_ui/file-input/index.tsx @@ -21,7 +21,6 @@ interface Props extends ComponentPropsWithoutRef<'input'> { const FileInput = ({ preview, accept = '*', - value, onChange, multiple = false, className, diff --git a/app/admin/category/_ui/shared/manage-table/styles.module.scss b/app/admin/category/_ui/shared/manage-table/styles.module.scss index e94f4a1d..3717480c 100644 --- a/app/admin/category/_ui/shared/manage-table/styles.module.scss +++ b/app/admin/category/_ui/shared/manage-table/styles.module.scss @@ -1,6 +1,5 @@ .container { - width: 625px; - padding: 21px 20px 52px; + padding: 21px 10px 52px; background-color: $color-white; } diff --git a/app/admin/questions/_ui/admin-question-view-detail/index.tsx b/app/admin/questions/_ui/admin-question-view-detail/index.tsx deleted file mode 100644 index e3b5c410..00000000 --- a/app/admin/questions/_ui/admin-question-view-detail/index.tsx +++ /dev/null @@ -1,30 +0,0 @@ -'use client' - -import useDeleteQuestion from '@/app/(dashboard)/my/questions/_hooks/query/use-delete-question' -import classNames from 'classnames/bind' - -import { Button } from '@/shared/ui/button' - -import styles from './styles.module.scss' - -const cx = classNames.bind(styles) -interface Props { - questionId: number - strategyId: number -} -const AdminQuestionViewDetailButton = ({ questionId, strategyId }: Props) => { - // const { mutate, isPending } = useDeleteQuestion({ strategyId, questionId }) - return ( - <Button - variant="filled" - // onClick={() => mutate()} - // disabled={isPending} - size="small" - style={{ padding: '7px 16px' }} - className={cx('button')} - > - 삭제 - </Button> - ) -} -export default AdminQuestionViewDetailButton diff --git a/app/admin/questions/_ui/admin-question-view-detail/styles.module.scss b/app/admin/questions/_ui/admin-question-view-detail/styles.module.scss deleted file mode 100644 index c54954dc..00000000 --- a/app/admin/questions/_ui/admin-question-view-detail/styles.module.scss +++ /dev/null @@ -1,6 +0,0 @@ -.button { - width: fit-content; - height: 30px; - padding: 7px 16px; - border-radius: 16px; -} diff --git a/shared/hooks/query/auth-queries.ts b/shared/hooks/query/auth-queries.ts index ff40a7ab..88766b70 100644 --- a/shared/hooks/query/auth-queries.ts +++ b/shared/hooks/query/auth-queries.ts @@ -63,8 +63,8 @@ export const useLogoutMutation = () => { useSearchingItemStore.getState().actions.resetState() router.replace(PATH.SIGN_IN) }, - onError: (error) => { - console.error('Logout failed:', error) + onError: (err) => { + console.error('Logout failed:', err) removeAccessToken() useAuthStore.getState().setAuthState({ isAuthenticated: false, From a5eee1603602af021a953871d0b5257769594dbf Mon Sep 17 00:00:00 2001 From: deun <devdeun@gmail.com> Date: Sat, 8 Feb 2025 20:54:43 +0900 Subject: [PATCH 183/207] =?UTF-8?q?docs:=20README=20=EB=82=B4=EC=9A=A9=20?= =?UTF-8?q?=EC=88=98=EC=A0=95=20(#165)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- README.md | 12 +++++------- 1 file changed, 5 insertions(+), 7 deletions(-) diff --git a/README.md b/README.md index 37edfe6e..d725604f 100644 --- a/README.md +++ b/README.md @@ -1,6 +1,6 @@ ## investmetic -![inve](https://github.com/user-attachments/assets/04760f5b-1c52-48ee-9bd4-763b947e1899) +![investmetic](https://github.com/user-attachments/assets/04760f5b-1c52-48ee-9bd4-763b947e1899) <div align="center"> @@ -11,12 +11,10 @@ ## 테스트 계정 -| **역할** | **이메일** | **비밀번호** | -| -------------------- | ------------------------- | ---------------- | -| **투자자** | investor@example.com | investor123 | -| **트레이더** | trader@example.com | trader123 | -| **트레이더(관리자)** | straderadmin@example.com | traderadmin123 | -| **관리자 (관리자)** | investoradmin@example.com | investoradmin123 | +| **역할** | **이메일** | **비밀번호** | +| ------------ | -------------------- | ------------ | +| **투자자** | investor@example.com | investor123 | +| **트레이더** | trader@example.com | trader123 | <br/> From 2da9ce64c86aa9c60f0599b578c68fcce636a381 Mon Sep 17 00:00:00 2001 From: James <dawn147@snu.ac.kr> Date: Sun, 9 Feb 2025 17:48:46 +0900 Subject: [PATCH 184/207] =?UTF-8?q?refactor:=20=EC=9D=B4=EB=A9=94=EC=9D=BC?= =?UTF-8?q?=EC=9D=B4=20=EA=B8=B4=20=EA=B2=BD=EC=9A=B0=20=EC=83=9D=EB=9E=B5?= =?UTF-8?q?=20=EC=A0=81=EC=9A=A9=EB=90=98=EB=8F=84=EB=A1=9D=20=EB=B3=80?= =?UTF-8?q?=EA=B2=BD=20(#166)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- shared/ui/side-navigation/styles.module.scss | 3 +++ 1 file changed, 3 insertions(+) diff --git a/shared/ui/side-navigation/styles.module.scss b/shared/ui/side-navigation/styles.module.scss index 981df37b..aea296ee 100644 --- a/shared/ui/side-navigation/styles.module.scss +++ b/shared/ui/side-navigation/styles.module.scss @@ -117,6 +117,9 @@ } .email { + width: 90%; font-size: $text-c1; + overflow: hidden; + text-overflow: ellipsis; } } From fbd576952d0eab9e04539a194981f7e5cb285ecf Mon Sep 17 00:00:00 2001 From: James <dawn147@snu.ac.kr> Date: Sun, 9 Feb 2025 17:50:31 +0900 Subject: [PATCH 185/207] =?UTF-8?q?refactor:=20=EB=A7=88=EC=9D=B4=ED=8E=98?= =?UTF-8?q?=EC=9D=B4=EC=A7=80=EC=97=90=EC=84=9C=20=EA=B0=80=EC=9E=85=20?= =?UTF-8?q?=EC=9D=BC=EC=9E=90=20=ED=99=95=EC=9D=B8=ED=95=A0=20=EC=88=98=20?= =?UTF-8?q?=EC=9E=88=EB=8F=84=EB=A1=9D=20=EB=B3=80=EA=B2=BD=20(#166)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- app/(dashboard)/my/_api/get-profile.ts | 1 + .../profile/_hooks/custom/use-profile-form.ts | 1 + .../my/profile/_ui/user-info/index.tsx | 36 +++++++++++++------ .../profile/_ui/user-info/styles.module.scss | 9 +---- .../my/profile/_ui/user-info/types.ts | 1 + 5 files changed, 29 insertions(+), 19 deletions(-) diff --git a/app/(dashboard)/my/_api/get-profile.ts b/app/(dashboard)/my/_api/get-profile.ts index 2112cd32..761eb4ed 100644 --- a/app/(dashboard)/my/_api/get-profile.ts +++ b/app/(dashboard)/my/_api/get-profile.ts @@ -10,6 +10,7 @@ export interface ProfileModel { infoAgreement: boolean role: string birthDate: string + joinDate: string } interface ProfileResponseModel { diff --git a/app/(dashboard)/my/profile/_hooks/custom/use-profile-form.ts b/app/(dashboard)/my/profile/_hooks/custom/use-profile-form.ts index bbc70e07..2ef13bc1 100644 --- a/app/(dashboard)/my/profile/_hooks/custom/use-profile-form.ts +++ b/app/(dashboard)/my/profile/_hooks/custom/use-profile-form.ts @@ -27,6 +27,7 @@ export const useProfileForm = (profile: ProfileModel) => { passwordConfirm: '', phone: profile?.phone || '', birthDate: profile?.birthDate || '', + joinDate: profile?.joinDate || '', } const [form, setForm] = useState<ProfileFormModel>(initialForm) diff --git a/app/(dashboard)/my/profile/_ui/user-info/index.tsx b/app/(dashboard)/my/profile/_ui/user-info/index.tsx index c668fa13..596577dc 100644 --- a/app/(dashboard)/my/profile/_ui/user-info/index.tsx +++ b/app/(dashboard)/my/profile/_ui/user-info/index.tsx @@ -141,17 +141,31 @@ const UserInfo = ({ profile, isEditable = false }: Props) => { </LinkButton> )} - <div className={cx('first-row')}> - <p className={cx('title')}>이름</p> - <Input - id="name" - name="name" - value={form.name} - inputSize="compact" - className={cx('input')} - isWhiteDisabled={!isEditable} - disabled={true} - /> + <div className={cx('row')}> + <div> + <p className={cx('title')}>이름</p> + <Input + id="name" + name="name" + value={form.name} + inputSize="compact" + className={cx('input')} + isWhiteDisabled={!isEditable} + disabled={true} + /> + </div> + <div> + <p className={cx('title')}>가입일자</p> + <Input + id="joinDate" + name="joinDate" + value={form.joinDate} + inputSize="compact" + className={cx('input')} + isWhiteDisabled={!isEditable} + disabled={true} + /> + </div> </div> <div className={cx('row')}> diff --git a/app/(dashboard)/my/profile/_ui/user-info/styles.module.scss b/app/(dashboard)/my/profile/_ui/user-info/styles.module.scss index d09e7afa..913dccd2 100644 --- a/app/(dashboard)/my/profile/_ui/user-info/styles.module.scss +++ b/app/(dashboard)/my/profile/_ui/user-info/styles.module.scss @@ -109,13 +109,7 @@ .edit-button { align-self: flex-end; - } - - .first-row { - display: flex; - flex-direction: column; - margin-top: 20px; - margin-bottom: 36px; + margin-bottom: 20px; } .title { @@ -128,7 +122,6 @@ display: flex; gap: 32px; align-items: flex-start; - justify-content: space-between; margin-bottom: 36px; } diff --git a/app/(dashboard)/my/profile/_ui/user-info/types.ts b/app/(dashboard)/my/profile/_ui/user-info/types.ts index 83e7843c..4c1aed15 100644 --- a/app/(dashboard)/my/profile/_ui/user-info/types.ts +++ b/app/(dashboard)/my/profile/_ui/user-info/types.ts @@ -11,6 +11,7 @@ export interface ProfileFormModel { passwordConfirm: string phone: string birthDate: string + joinDate: string } export interface ProfileFormStateModel { From 33d8d1405b11cd8b47c769dbee9f4468f379f899 Mon Sep 17 00:00:00 2001 From: James <dawn147@snu.ac.kr> Date: Sun, 9 Feb 2025 17:50:57 +0900 Subject: [PATCH 186/207] =?UTF-8?q?refactor:=20=EA=B4=80=EB=A6=AC=EC=9E=90?= =?UTF-8?q?=20=ED=9A=8C=EC=9B=90=EA=B4=80=EB=A6=AC=20=ED=83=AD=EC=97=90?= =?UTF-8?q?=EC=84=9C=20=EA=B0=80=EC=9E=85=EC=9D=BC=EC=9E=90=20=ED=99=95?= =?UTF-8?q?=EC=9D=B8=ED=95=A0=20=EC=88=98=20=EC=9E=88=EB=8F=84=EB=A1=9D=20?= =?UTF-8?q?=EB=B3=80=EA=B2=BD=20(#166)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- app/admin/users/_api/set-table-body.tsx | 1 + app/admin/users/page.tsx | 13 ++++++++++++- app/admin/users/types.ts | 1 + 3 files changed, 14 insertions(+), 1 deletion(-) diff --git a/app/admin/users/_api/set-table-body.tsx b/app/admin/users/_api/set-table-body.tsx index 8444eed9..e54c2de5 100644 --- a/app/admin/users/_api/set-table-body.tsx +++ b/app/admin/users/_api/set-table-body.tsx @@ -18,6 +18,7 @@ const setTableBody = ({ data, page, countPerPage }: ArgModel) => <Avatar src={data?.imageUrl ?? undefined} key={data.userId} />, data.userName, data.nickname, + data.joinDate, data.email, data.phone, <RoleSelect data={data} key={data.userId} />, diff --git a/app/admin/users/page.tsx b/app/admin/users/page.tsx index b138173b..58ec8af6 100644 --- a/app/admin/users/page.tsx +++ b/app/admin/users/page.tsx @@ -69,7 +69,17 @@ const AdminUsersPage = () => { } /> <VerticalTable - tableHead={['No.', '프로필', '이름', '닉네임', '이메일', '전화번호', '회원분류', '탈퇴']} + tableHead={[ + 'No.', + '프로필', + '이름', + '닉네임', + '가입 일자', + '이메일', + '전화번호', + '회원분류', + '탈퇴', + ]} tableBody={setTableBody({ data: data?.content, page: data?.page, @@ -77,6 +87,7 @@ const AdminUsersPage = () => { })} countPerPage={data.size} currentPage={1} + colWidths={[1.2, 1.5, 1.5, 2, 2, 3, 2, 2, 2]} /> <Pagination currentPage={data?.page} diff --git a/app/admin/users/types.ts b/app/admin/users/types.ts index 40cc9fee..2d1e6289 100644 --- a/app/admin/users/types.ts +++ b/app/admin/users/types.ts @@ -12,6 +12,7 @@ export interface AdminUserInfoModel { userId: number userName: string email: string + joinDate: string imageUrl: string | null nickname: string phone: string From f901f5aea9d8e393cf6e3d972ec082b47ec7228a Mon Sep 17 00:00:00 2001 From: deun <devdeun@gmail.com> Date: Sun, 9 Feb 2025 19:26:59 +0900 Subject: [PATCH 187/207] =?UTF-8?q?feat:=20=EC=9D=BC=EA=B0=84=EB=B6=84?= =?UTF-8?q?=EC=84=9D=20=EB=B0=8F=20=EC=9B=94=EA=B0=84=EB=B6=84=EC=84=9D=20?= =?UTF-8?q?=EB=B0=B1=EB=B6=84=EC=9C=A8=20=EC=A0=81=EC=9A=A9=20(#167)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../analysis-container/analysis-content.tsx | 5 ++- .../strategies/[strategyId]/util.ts | 36 +++++++++++++++++++ shared/types/strategy-data.ts | 12 +++---- 3 files changed, 46 insertions(+), 7 deletions(-) create mode 100644 app/(dashboard)/strategies/[strategyId]/util.ts diff --git a/app/(dashboard)/_ui/analysis-container/analysis-content.tsx b/app/(dashboard)/_ui/analysis-container/analysis-content.tsx index 898668d8..c7a19a5e 100644 --- a/app/(dashboard)/_ui/analysis-container/analysis-content.tsx +++ b/app/(dashboard)/_ui/analysis-container/analysis-content.tsx @@ -18,6 +18,7 @@ import useGetMyDailyAnalysis from '../../my/_hooks/query/use-get-my-daily-analys import { useMyAnalysisMutation } from '../../my/_hooks/query/use-manage-daily-analysis' import useGetAnalysis from '../../strategies/[strategyId]/_hooks/query/use-get-analysis' import useGetAnalysisDownload from '../../strategies/[strategyId]/_hooks/query/use-get-analysis-download' +import { generateFormattedStrategyData } from '../../strategies/[strategyId]/util' import { DAILY_TABLE_HEADER, MONTHLY_TABLE_HEADER } from './constants' import styles from './styles.module.scss' @@ -83,6 +84,8 @@ const AnalysisContent = ({ const isLoading = isEditable ? isMyAnalysisLoading : isPublicAnalysisLoading const isDelayedLoading = useDelayedLoading(isLoading, 500) + const analysisContent = generateFormattedStrategyData(analysisData?.content) + const { deleteAllAnalysis, isLoading: isDeleteAllLoading } = useAnalysisUploadMutation( strategyId, currentPage, @@ -215,7 +218,7 @@ const AnalysisContent = ({ <> <VerticalTable tableHead={tableHeader} - tableBody={analysisData.content} + tableBody={analysisContent} currentPage={1} countPerPage={ANALYSIS_PAGE_COUNT} renderActions={isEditable ? renderActions : undefined} diff --git a/app/(dashboard)/strategies/[strategyId]/util.ts b/app/(dashboard)/strategies/[strategyId]/util.ts new file mode 100644 index 00000000..61a8409c --- /dev/null +++ b/app/(dashboard)/strategies/[strategyId]/util.ts @@ -0,0 +1,36 @@ +import { + DailyAnalysisModel, + MonthlyAnalysisModel, + MyDailyAnalysisModel, +} from '@/shared/types/strategy-data' +import { TableBodyDataType } from '@/shared/ui/table/vertical' + +type AnalysisModelType = DailyAnalysisModel | MyDailyAnalysisModel | MonthlyAnalysisModel +type RateKeysType<ObjectType> = keyof { + [Key in keyof ObjectType as Key extends `${string}Rate` ? Key : never]: ObjectType[Key] +} + +export const generateFormattedStrategyData = (data: AnalysisModelType[]): TableBodyDataType[] => { + if (!data || data.length === 0) return [] + + return data.map((item) => { + const transformedItem = { ...item } + + const hasRateKey = Object.keys(transformedItem).some((key) => key.endsWith('Rate')) + if (!hasRateKey) return transformedItem + + type RatePropsType = RateKeysType<AnalysisModelType> + + Object.keys(transformedItem).forEach((key) => { + if (key.endsWith('Rate')) { + const value = (transformedItem as AnalysisModelType)[key as keyof AnalysisModelType] + + if (value !== null && typeof value === 'number') { + transformedItem[key as RatePropsType] = `${(value * 100).toFixed(2)}%` + } + } + }) + + return transformedItem + }) +} diff --git a/shared/types/strategy-data.ts b/shared/types/strategy-data.ts index fcadf377..3a716177 100644 --- a/shared/types/strategy-data.ts +++ b/shared/types/strategy-data.ts @@ -3,9 +3,9 @@ export interface DailyAnalysisModel { principal: number transaction: number dailyProfitLoss: number - dailyProfitLossRate: number + dailyProfitLossRate: number | string cumulativeProfitLoss: number - cumulativeProfitLossRate: number + cumulativeProfitLossRate: number | string } export interface MyDailyAnalysisModel { @@ -13,10 +13,10 @@ export interface MyDailyAnalysisModel { dailyDate: string transaction: number dailyProfitLoss: number - dailyProfitLossRate: number + dailyProfitLossRate: number | string principal: number cumulativeProfitLoss: number - cumulativeProfitLossRate: number + cumulativeProfitLossRate: number | string } export interface MonthlyAnalysisModel { @@ -24,9 +24,9 @@ export interface MonthlyAnalysisModel { monthlyAveragePrincipal: number depositsWithdrawals: number monthlyProfitLoss: number - monthlyProfitLossRate: number + monthlyProfitLossRate: number | string cumulativeProfitLoss: number - cumulativeProfitLossRate: number + cumulativeProfitLossRate: number | string } export interface ProfitRateChartDataModel { From cc7894539c84724a412ba3b249b974665e6219cd Mon Sep 17 00:00:00 2001 From: deun <devdeun@gmail.com> Date: Sun, 9 Feb 2025 19:58:18 +0900 Subject: [PATCH 188/207] =?UTF-8?q?fix:=20=ED=86=B5=EA=B3=84=20=ED=83=AD?= =?UTF-8?q?=20=EC=98=AC=EB=B0=94=EB=A5=B8=20=EB=8B=A8=EC=9C=84=EB=A1=9C=20?= =?UTF-8?q?=EC=88=98=EC=A0=95=20(#167)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- shared/ui/table/statistics/constant.ts | 8 +++++--- shared/ui/table/statistics/index.tsx | 16 ++++++++++++++-- 2 files changed, 19 insertions(+), 5 deletions(-) diff --git a/shared/ui/table/statistics/constant.ts b/shared/ui/table/statistics/constant.ts index 0c8f62d0..4d674cca 100644 --- a/shared/ui/table/statistics/constant.ts +++ b/shared/ui/table/statistics/constant.ts @@ -35,7 +35,7 @@ export const inKoreanData = { tradingInfo: '거래 관련 정보', } as const -export const STATISTICS_PERCENT = [ +export const STATISTICS_FORMATTED_PERCENT = [ '누적 수익률', '최대 누적 수익률', '평균 손익률', @@ -46,6 +46,8 @@ export const STATISTICS_PERCENT = [ '승률', ] +export const STATISTICS_PERCENT = ['자산 수익률'] + export const STATISTICS_DATE = [ '고점 갱신 후 경과일', '총 거래 일수', @@ -55,6 +57,6 @@ export const STATISTICS_DATE = [ '최대 연속 이익 일수', '최대 연속 손실 일수', '운용 기간', - '시작 일자', - '종료 일자', ] + +export const STATISTICS_RAW = ['시작 일자', '종료 일자', 'Profit Factor'] diff --git a/shared/ui/table/statistics/index.tsx b/shared/ui/table/statistics/index.tsx index 02fa2e9f..6051054f 100644 --- a/shared/ui/table/statistics/index.tsx +++ b/shared/ui/table/statistics/index.tsx @@ -2,7 +2,13 @@ import classNames from 'classnames/bind' import { formatNumber } from '@/shared/utils/format' -import { STATISTICS_DATE, STATISTICS_PERCENT, inKoreanData } from './constant' +import { + STATISTICS_DATE, + STATISTICS_FORMATTED_PERCENT, + STATISTICS_PERCENT, + STATISTICS_RAW, + inKoreanData, +} from './constant' import styles from './styles.module.scss' const cx = classNames.bind(styles) @@ -43,12 +49,18 @@ const StatisticsTable = ({ title, statisticsData }: Props) => { ) const formatStatisticsValue = (key: string, value: number) => { - if (STATISTICS_PERCENT.includes(key)) { + if (STATISTICS_FORMATTED_PERCENT.includes(key)) { return Number(value).toFixed(2) + ' %' } + if (STATISTICS_PERCENT.includes(key)) { + return (Number(value) * 100).toFixed(2) + ' %' + } if (STATISTICS_DATE.includes(key)) { return formatNumber(value) + ' 일' } + if (STATISTICS_RAW.includes(key)) { + return formatNumber(value) + } return formatNumber(value) + ' 원' } From 738e2b9f0eb0e3fef615e12bd5b70f68b6f9fd48 Mon Sep 17 00:00:00 2001 From: deun <devdeun@gmail.com> Date: Mon, 24 Feb 2025 18:54:46 +0900 Subject: [PATCH 189/207] =?UTF-8?q?feat:=20=EC=B0=A8=ED=8A=B8=20=EA=B0=92?= =?UTF-8?q?=EC=9D=B4=200=EC=9D=BC=20=EA=B2=BD=EC=9A=B0=20=EB=8D=B0?= =?UTF-8?q?=EC=9D=B4=ED=84=B0=20=EC=88=98=EC=A0=95=20(#171)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 0이 아닌 가장 가까운 값으로 변경 --- .../average-metrics-chart.tsx | 14 ++++++++--- shared/utils/chart.ts | 24 +++++++++++++++++++ 2 files changed, 35 insertions(+), 3 deletions(-) create mode 100644 shared/utils/chart.ts diff --git a/app/(landing)/(home)/_ui/average-metrics-section/average-metrics-chart.tsx b/app/(landing)/(home)/_ui/average-metrics-section/average-metrics-chart.tsx index bbe0af43..9646d2c8 100644 --- a/app/(landing)/(home)/_ui/average-metrics-section/average-metrics-chart.tsx +++ b/app/(landing)/(home)/_ui/average-metrics-section/average-metrics-chart.tsx @@ -5,6 +5,8 @@ import dynamic from 'next/dynamic' import Highcharts from 'highcharts' import mouseWheelZoom from 'highcharts/modules/mouse-wheel-zoom' +import { processChartData } from '@/shared/utils/chart' + mouseWheelZoom(Highcharts) const HighchartsReact = dynamic(() => import('highcharts-react-official'), { @@ -25,6 +27,12 @@ interface Props { } const AverageMetricsChart = ({ data }: Props) => { + const avgReferencePriceData = processChartData(data.data.avgReferencePrice) + const highestSmScoreReferencePriceData = processChartData(data.data.highestSmScoreReferencePrice) + const highestSubscribeScoreReferencePriceData = processChartData( + data.data.highestSubscribeScoreReferencePrice + ) + const chartOptions: Highcharts.Options = { chart: { type: 'areaspline', @@ -152,7 +160,7 @@ const AverageMetricsChart = ({ data }: Props) => { { type: 'areaspline', name: '평균', - data: data.data.avgReferencePrice, + data: avgReferencePriceData, color: '#FF4F1F', yAxis: 0, stickyTracking: false, @@ -161,7 +169,7 @@ const AverageMetricsChart = ({ data }: Props) => { { type: 'spline', name: 'SM SCORE 1위', - data: data.data.highestSmScoreReferencePrice, + data: highestSmScoreReferencePriceData, color: '#6877FF', yAxis: 1, stickyTracking: false, @@ -170,7 +178,7 @@ const AverageMetricsChart = ({ data }: Props) => { { type: 'spline', name: '구독 1위', - data: data.data.highestSubscribeScoreReferencePrice, + data: highestSubscribeScoreReferencePriceData, color: '#FFE070', yAxis: 1, stickyTracking: false, diff --git a/shared/utils/chart.ts b/shared/utils/chart.ts new file mode 100644 index 00000000..09837e33 --- /dev/null +++ b/shared/utils/chart.ts @@ -0,0 +1,24 @@ +export const findNearestNonZeroValue = (arr: number[], currentIndex: number): number => { + if (arr[currentIndex] !== 0) return arr[currentIndex] + + let leftIndex = currentIndex - 1 + let rightIndex = currentIndex + 1 + + while (leftIndex >= 0 || rightIndex < arr.length) { + if (leftIndex >= 0 && arr[leftIndex] !== 0) { + return arr[leftIndex] + } + + if (rightIndex < arr.length && arr[rightIndex] !== 0) { + return arr[rightIndex] + } + leftIndex-- + rightIndex++ + } + + return 0 +} + +export const processChartData = (data: number[]): number[] => { + return data.map((num, idx) => (num === 0 ? findNearestNonZeroValue(data, idx) : num)) +} From 15182d909238929a15cf34b218601246a8bbb135 Mon Sep 17 00:00:00 2001 From: deun <devdeun@gmail.com> Date: Mon, 24 Feb 2025 18:56:07 +0900 Subject: [PATCH 190/207] =?UTF-8?q?feat:=20=EB=A1=9C=EB=94=A9=20=EC=8A=A4?= =?UTF-8?q?=ED=94=BC=EB=84=88=20=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../_ui/average-metrics-section/index.tsx | 31 +++++++++++-------- 1 file changed, 18 insertions(+), 13 deletions(-) diff --git a/app/(landing)/(home)/_ui/average-metrics-section/index.tsx b/app/(landing)/(home)/_ui/average-metrics-section/index.tsx index 5d2156d0..6595f3e0 100644 --- a/app/(landing)/(home)/_ui/average-metrics-section/index.tsx +++ b/app/(landing)/(home)/_ui/average-metrics-section/index.tsx @@ -4,41 +4,46 @@ import dynamic from 'next/dynamic' import classNames from 'classnames/bind' +import Spinner from '@/shared/ui/spinner' + import useGetStrategiesMetrics from '../../_hooks/query/use-get-strategies-metrics' import HomeSubtitle from '../home-subtitle' import styles from './styles.module.scss' const AverageMetricsChart = dynamic(() => import('./average-metrics-chart'), { ssr: false, - loading: () => <div>Loading...</div>, }) const cx = classNames.bind(styles) const AverageMetricsSection = () => { - const { data: chartData } = useGetStrategiesMetrics() + const { data: chartData, isLoading } = useGetStrategiesMetrics() - if (!chartData) { + if (!chartData && !isLoading) { return <p>차트 조회에 실패했습니다.</p> } - const startDate = chartData.dates[0] - const endDate = chartData.dates.at(-1) + const startDate = chartData?.dates[0] + const endDate = chartData?.dates.at(-1) return ( <section> <HomeSubtitle>대표 전략 통합 평균 지표</HomeSubtitle> <div className={cx('container')}> - <div className={cx('contents-wrapper')}> - <div className={cx('date-wrapper')}> - FROM <span className={cx('date')}>{startDate}</span>TO - <span className={cx('date')}>{endDate}</span> - </div> - <div className={cx('chart-wrapper')}> - <AverageMetricsChart data={chartData} /> + {isLoading && chartData === undefined ? ( + <Spinner /> + ) : chartData ? ( + <div className={cx('contents-wrapper')}> + <div className={cx('date-wrapper')}> + FROM <span className={cx('date')}>{startDate}</span>TO + <span className={cx('date')}>{endDate}</span> + </div> + <div className={cx('chart-wrapper')}> + <AverageMetricsChart data={chartData} /> + </div> </div> - </div> + ) : null} </div> </section> ) From 0eb100f65c19183eecb68c82931eabff7aa3485b Mon Sep 17 00:00:00 2001 From: deun <devdeun@gmail.com> Date: Mon, 24 Feb 2025 19:12:15 +0900 Subject: [PATCH 191/207] =?UTF-8?q?fix:=20=EB=A9=94=EC=9D=B8=20=ED=8E=98?= =?UTF-8?q?=EC=9D=B4=EC=A7=80=20=EB=A1=9C=EB=94=A9=20=EC=8A=A4=ED=94=BC?= =?UTF-8?q?=EB=84=88=20=EC=88=98=EC=A0=95=20(#171)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../_ui/average-metrics-section/index.tsx | 24 +++++---- .../(home)/_ui/top-favorite-section/index.tsx | 51 +++++++++++-------- .../(home)/_ui/top-sm-score-section/index.tsx | 41 ++++++++------- .../(home)/_ui/user-metrics-section/index.tsx | 22 ++++---- .../user-metrics-section/styles.module.scss | 2 +- 5 files changed, 80 insertions(+), 60 deletions(-) diff --git a/app/(landing)/(home)/_ui/average-metrics-section/index.tsx b/app/(landing)/(home)/_ui/average-metrics-section/index.tsx index 6595f3e0..75bab73e 100644 --- a/app/(landing)/(home)/_ui/average-metrics-section/index.tsx +++ b/app/(landing)/(home)/_ui/average-metrics-section/index.tsx @@ -31,19 +31,21 @@ const AverageMetricsSection = () => { <HomeSubtitle>대표 전략 통합 평균 지표</HomeSubtitle> <div className={cx('container')}> - {isLoading && chartData === undefined ? ( + {isLoading && !chartData ? ( <Spinner /> - ) : chartData ? ( - <div className={cx('contents-wrapper')}> - <div className={cx('date-wrapper')}> - FROM <span className={cx('date')}>{startDate}</span>TO - <span className={cx('date')}>{endDate}</span> + ) : ( + chartData && ( + <div className={cx('contents-wrapper')}> + <div className={cx('date-wrapper')}> + FROM <span className={cx('date')}>{startDate}</span>TO + <span className={cx('date')}>{endDate}</span> + </div> + <div className={cx('chart-wrapper')}> + <AverageMetricsChart data={chartData} /> + </div> </div> - <div className={cx('chart-wrapper')}> - <AverageMetricsChart data={chartData} /> - </div> - </div> - ) : null} + ) + )} </div> </section> ) diff --git a/app/(landing)/(home)/_ui/top-favorite-section/index.tsx b/app/(landing)/(home)/_ui/top-favorite-section/index.tsx index 51d3321d..9e92e105 100644 --- a/app/(landing)/(home)/_ui/top-favorite-section/index.tsx +++ b/app/(landing)/(home)/_ui/top-favorite-section/index.tsx @@ -4,6 +4,7 @@ import classNames from 'classnames/bind' import { PATH } from '@/shared/constants/path' import { LinkButton } from '@/shared/ui/link-button' +import Spinner from '@/shared/ui/spinner' import useGetTopRanking from '../../_hooks/query/use-get-top-ranking' import HomeSubtitle from '../home-subtitle' @@ -13,7 +14,7 @@ import styles from './styles.module.scss' const cx = classNames.bind(styles) const TopFavoriteSection = () => { - const { data: favoriteStrategies } = useGetTopRanking() + const { data: favoriteStrategies, isLoading } = useGetTopRanking() return ( <section className={cx('section-container')}> @@ -22,27 +23,33 @@ const TopFavoriteSection = () => { 인기 있는 전략을 확인해보세요! </HomeSubtitle> - <ul className={cx('strategy-wrapper')}> - {favoriteStrategies && - favoriteStrategies.map((strategy, idx) => ( - <li key={strategy.strategyId}> - <TopFavoriteCard - ranking={idx + 1} - nickname={strategy.nickname} - title={strategy.strategyName} - chartData={strategy.profitRateChartData} - percentageChange={strategy.cumulativeProfitRate} - subscriptionCount={strategy.subscriptionCount} - averageRating={strategy.averageRating} - reviewCount={strategy.totalReviews} - /> - </li> - ))} - </ul> - - <LinkButton href={PATH.STRATEGIES} variant="filled"> - 전략랭킹 더보기 - </LinkButton> + {isLoading && !favoriteStrategies ? ( + <Spinner /> + ) : ( + <> + <ul className={cx('strategy-wrapper')}> + {favoriteStrategies && + favoriteStrategies.map((strategy, idx) => ( + <li key={strategy.strategyId}> + <TopFavoriteCard + ranking={idx + 1} + nickname={strategy.nickname} + title={strategy.strategyName} + chartData={strategy.profitRateChartData} + percentageChange={strategy.cumulativeProfitRate} + subscriptionCount={strategy.subscriptionCount} + averageRating={strategy.averageRating} + reviewCount={strategy.totalReviews} + /> + </li> + ))} + </ul> + + <LinkButton href={PATH.STRATEGIES} variant="filled"> + 전략랭킹 더보기 + </LinkButton> + </> + )} </section> ) } diff --git a/app/(landing)/(home)/_ui/top-sm-score-section/index.tsx b/app/(landing)/(home)/_ui/top-sm-score-section/index.tsx index 52b699df..b657e363 100644 --- a/app/(landing)/(home)/_ui/top-sm-score-section/index.tsx +++ b/app/(landing)/(home)/_ui/top-sm-score-section/index.tsx @@ -2,6 +2,8 @@ import classNames from 'classnames/bind' +import Spinner from '@/shared/ui/spinner' + import useGetTopRankingSmScore from '../../_hooks/query/use-get-top-ranking-smscore' import HomeSubtitle from '../home-subtitle' import TopSmScoreCard from '../top-strategy-card/top-sm-score-card' @@ -10,28 +12,33 @@ import styles from './styles.module.scss' const cx = classNames.bind(styles) const TopSmScoreSection = () => { - const { data: topSmScoreStrategies } = useGetTopRankingSmScore() + const { data: topSmScoreStrategies, isLoading } = useGetTopRankingSmScore() return ( <section className={cx('section-container')}> <HomeSubtitle>높은 SM 스코어별로 전략을 확인해보세요!</HomeSubtitle> - <ul className={cx('strategy-wrapper')}> - {topSmScoreStrategies && - topSmScoreStrategies.map((strategy, idx) => ( - <li key={strategy.strategyId}> - <TopSmScoreCard - size={idx > 0 ? 'small' : 'large'} - ranking={idx + 1} - nickname={strategy.nickname} - title={strategy.strategyName} - chartData={strategy.profitRateChartData} - percentageChange={strategy.cumulativeProfitRate} - score={strategy.smScore} - /> - </li> - ))} - </ul> + {isLoading && !topSmScoreStrategies ? ( + <Spinner className={cx('spinner')} /> + ) : ( + topSmScoreStrategies && ( + <ul className={cx('strategy-wrapper')}> + {topSmScoreStrategies.map((strategy, idx) => ( + <li key={strategy.strategyId}> + <TopSmScoreCard + size={idx > 0 ? 'small' : 'large'} + ranking={idx + 1} + nickname={strategy.nickname} + title={strategy.strategyName} + chartData={strategy.profitRateChartData} + percentageChange={strategy.cumulativeProfitRate} + score={strategy.smScore} + /> + </li> + ))} + </ul> + ) + )} </section> ) } diff --git a/app/(landing)/(home)/_ui/user-metrics-section/index.tsx b/app/(landing)/(home)/_ui/user-metrics-section/index.tsx index aed9187d..039cd8b0 100644 --- a/app/(landing)/(home)/_ui/user-metrics-section/index.tsx +++ b/app/(landing)/(home)/_ui/user-metrics-section/index.tsx @@ -14,11 +14,7 @@ const cx = classNames.bind(styles) const UserMetricsSection = () => { const { data: metrics, isLoading } = useGetUserMetrics() - if (isLoading) { - return <Spinner className={cx('spinner')} /> - } - - if (!metrics) { + if (!metrics && !isLoading) { return null } @@ -27,10 +23,18 @@ const UserMetricsSection = () => { <HomeSubtitle>이미 이렇게 많은 사람들이 주식 전략을 공유하고, 구독하고 있어요!</HomeSubtitle> <div className={cx('card-wrapper')}> - <MetricCard count={metrics.totalTrader} label="명의 트레이더가" /> - <MetricCard count={metrics.totalStrategies} label="개의 투자 전략을 올렸어요!" /> - <MetricCard count={metrics.totalInvestor} label="명의 투자자가" /> - <MetricCard count={metrics.totalSubscribe} label="개의 전략을 구독 중이에요!" /> + {isLoading ? ( + <Spinner className={cx('spinner')} /> + ) : ( + metrics && ( + <> + <MetricCard count={metrics.totalTrader} label="명의 트레이더가" /> + <MetricCard count={metrics.totalStrategies} label="개의 투자 전략을 올렸어요!" /> + <MetricCard count={metrics.totalInvestor} label="명의 투자자가" /> + <MetricCard count={metrics.totalSubscribe} label="개의 전략을 구독 중이에요!" /> + </> + ) + )} </div> </section> ) diff --git a/app/(landing)/(home)/_ui/user-metrics-section/styles.module.scss b/app/(landing)/(home)/_ui/user-metrics-section/styles.module.scss index e7316e0d..87951411 100644 --- a/app/(landing)/(home)/_ui/user-metrics-section/styles.module.scss +++ b/app/(landing)/(home)/_ui/user-metrics-section/styles.module.scss @@ -19,5 +19,5 @@ } .spinner { - margin: 200px 0; + margin: 40px 0; } From ab630be23bc8851733f84e889dfc04cad73b0f05 Mon Sep 17 00:00:00 2001 From: James <dawn147@snu.ac.kr> Date: Tue, 25 Feb 2025 19:36:18 +0900 Subject: [PATCH 192/207] =?UTF-8?q?style:=20=EC=97=90=EB=9F=AC=EB=A9=94?= =?UTF-8?q?=EC=8B=9C=EC=A7=80=20=EC=83=89=EA=B9=94=20=ED=86=B5=EC=9D=BC=20?= =?UTF-8?q?(#173)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- app/(dashboard)/_ui/details-information/styles.module.scss | 2 +- app/(dashboard)/my/profile/_ui/user-info/styles.module.scss | 2 +- shared/ui/modal/account-register-modal/styles.module.scss | 2 +- shared/ui/modal/analysis-upload-modal/form/styles.module.scss | 2 +- shared/ui/modal/edit-daily-analysis-modal.ts/styles.module.scss | 2 +- 5 files changed, 5 insertions(+), 5 deletions(-) diff --git a/app/(dashboard)/_ui/details-information/styles.module.scss b/app/(dashboard)/_ui/details-information/styles.module.scss index f3d1b32a..db12d793 100644 --- a/app/(dashboard)/_ui/details-information/styles.module.scss +++ b/app/(dashboard)/_ui/details-information/styles.module.scss @@ -78,7 +78,7 @@ .file-error { @include typo-c1; - color: $color-orange-500; + color: $color-orange-700; margin-top: 4px; } } diff --git a/app/(dashboard)/my/profile/_ui/user-info/styles.module.scss b/app/(dashboard)/my/profile/_ui/user-info/styles.module.scss index 913dccd2..145db2a3 100644 --- a/app/(dashboard)/my/profile/_ui/user-info/styles.module.scss +++ b/app/(dashboard)/my/profile/_ui/user-info/styles.module.scss @@ -91,7 +91,7 @@ } .error-message { - color: $color-orange-600; + color: $color-orange-700; @include typo-b3; margin-top: 8px; } diff --git a/shared/ui/modal/account-register-modal/styles.module.scss b/shared/ui/modal/account-register-modal/styles.module.scss index c47acdea..3430a467 100644 --- a/shared/ui/modal/account-register-modal/styles.module.scss +++ b/shared/ui/modal/account-register-modal/styles.module.scss @@ -92,7 +92,7 @@ } .error-message { - color: $color-orange-500; + color: $color-orange-700; font-size: 14px; margin-top: 8px; } diff --git a/shared/ui/modal/analysis-upload-modal/form/styles.module.scss b/shared/ui/modal/analysis-upload-modal/form/styles.module.scss index ab313940..bd76d097 100644 --- a/shared/ui/modal/analysis-upload-modal/form/styles.module.scss +++ b/shared/ui/modal/analysis-upload-modal/form/styles.module.scss @@ -68,7 +68,7 @@ } .error-message { - color: $color-orange-500; + color: $color-orange-700; font-size: 14px; } diff --git a/shared/ui/modal/edit-daily-analysis-modal.ts/styles.module.scss b/shared/ui/modal/edit-daily-analysis-modal.ts/styles.module.scss index 392f4a25..6dc9e49e 100644 --- a/shared/ui/modal/edit-daily-analysis-modal.ts/styles.module.scss +++ b/shared/ui/modal/edit-daily-analysis-modal.ts/styles.module.scss @@ -35,7 +35,7 @@ } .error-message { - color: $color-orange-500; + color: $color-orange-700; font-size: 14px; margin-top: 8px; } From 14a1ea57a542ed38f355b24aa44f35f39d8e07c9 Mon Sep 17 00:00:00 2001 From: James <dawn147@snu.ac.kr> Date: Tue, 25 Feb 2025 19:48:37 +0900 Subject: [PATCH 193/207] =?UTF-8?q?style:=20=EA=B7=B8=EB=A6=AC=EB=93=9C=20?= =?UTF-8?q?=EC=8A=A4=ED=83=80=EC=9D=BC=20=EC=A0=81=EC=9A=A9=20(#173)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- app/(dashboard)/traders/page.module.scss | 6 ++---- 1 file changed, 2 insertions(+), 4 deletions(-) diff --git a/app/(dashboard)/traders/page.module.scss b/app/(dashboard)/traders/page.module.scss index 49e3f855..a0d1f9e0 100644 --- a/app/(dashboard)/traders/page.module.scss +++ b/app/(dashboard)/traders/page.module.scss @@ -17,10 +17,8 @@ } .traders-list-wrapper { - display: flex; - justify-content: flex-start; - align-items: center; - flex-wrap: wrap; + display: grid; + grid-template-columns: repeat(4, 1fr); gap: 24px 32px; margin-bottom: 30px; From df4ce1bab9518e6ebfd912b250ecfdc4ab878bc8 Mon Sep 17 00:00:00 2001 From: James <dawn147@snu.ac.kr> Date: Tue, 25 Feb 2025 19:49:17 +0900 Subject: [PATCH 194/207] =?UTF-8?q?bug:=20=EB=A1=9C=EA=B7=B8=EC=9D=B8=20?= =?UTF-8?q?=EC=97=90=EB=9F=AC=20=EA=B4=80=EB=A0=A8=20=EB=B2=84=EA=B7=B8=20?= =?UTF-8?q?=EC=88=98=EC=A0=95=20(#173)=20-=20=EB=B6=88=ED=95=84=EC=9A=94?= =?UTF-8?q?=ED=95=9C=20=EC=97=90=EB=9F=AC=20=EB=AC=B8=EA=B5=AC=20=EC=82=AD?= =?UTF-8?q?=EC=A0=9C?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- app/(landing)/signin/page.tsx | 7 +++---- shared/constants/error-messages.ts | 8 -------- 2 files changed, 3 insertions(+), 12 deletions(-) diff --git a/app/(landing)/signin/page.tsx b/app/(landing)/signin/page.tsx index df27a7e5..d943b71d 100644 --- a/app/(landing)/signin/page.tsx +++ b/app/(landing)/signin/page.tsx @@ -82,7 +82,6 @@ const SignInPage = () => { const response = await loginMutation.mutateAsync(formData) if (!response.data.isSuccess) { - setErrors(response.data.message || ERROR_MESSAGES.AUTH.LOGIN_FAILED) return } @@ -102,10 +101,10 @@ const SignInPage = () => { router.replace(PATH.STRATEGIES) } catch (err) { if (err instanceof AxiosError) { - if (err.response) { - setErrors(err.response.data.message) + if (!err.response?.data.isSuccess) { + setErrors(err.response?.data.message) } else { - setErrors(ERROR_MESSAGES.NETWORK.ERROR) + setErrors(ERROR_MESSAGES.AUTH.LOGIN_FAILED) } } else { setErrors(ERROR_MESSAGES.AUTH.LOGIN_FAILED) diff --git a/shared/constants/error-messages.ts b/shared/constants/error-messages.ts index 37841955..1c0fbc16 100644 --- a/shared/constants/error-messages.ts +++ b/shared/constants/error-messages.ts @@ -1,11 +1,6 @@ export const ERROR_MESSAGES = { AUTH: { - INVALID_CREDENTIALS: '이메일 또는 비밀번호가 올바르지 않습니다.', - SESSION_EXPIRED: '로그인이 만료되었습니다. 다시 로그인해주세요.', LOGIN_FAILED: '서버 오류로 로그인에 실패했습니다.', - INVALID_TOKEN: '유효하지 않은 인증입니다.', - ALREADY_LOGGED_IN: '이미 로그인되어 있습니다.', - REFRESH_FAILED: '토큰 갱신에 실패했습니다.', }, FORM: { REQUIRED_FIELDS: '이메일과 비밀번호를 입력해주세요.', @@ -13,7 +8,4 @@ export const ERROR_MESSAGES = { PASSWORD: '비밀번호는 8자 이상, 영문과 숫자를 포함해야 합니다.', PHONE: '휴대폰 번호 형식이 올바르지 않습니다.', }, - NETWORK: { - ERROR: '일시적인 오류가 발생했습니다. 잠시 후 다시 시도해주세요.', - }, } as const From 74acb959bd1e5ae8c519c9a17de7c527c7b26614 Mon Sep 17 00:00:00 2001 From: deun <devdeun@gmail.com> Date: Wed, 26 Feb 2025 16:29:32 +0900 Subject: [PATCH 195/207] =?UTF-8?q?fix:=20=EB=B6=84=EC=84=9D=20=EC=B0=A8?= =?UTF-8?q?=ED=8A=B8=EC=97=90=EC=84=9C=20=EC=A0=84=EC=B2=B4=20=EB=B2=94?= =?UTF-8?q?=EC=9C=84=EC=9D=98=20=EB=8D=B0=EC=9D=B4=ED=84=B0=EB=A5=BC=20?= =?UTF-8?q?=ED=91=9C=EC=8B=9C=ED=95=98=EB=8F=84=EB=A1=9D=20=EC=88=98?= =?UTF-8?q?=EC=A0=95=20(#177)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- app/(dashboard)/_ui/analysis-container/analysis-chart.tsx | 2 -- 1 file changed, 2 deletions(-) diff --git a/app/(dashboard)/_ui/analysis-container/analysis-chart.tsx b/app/(dashboard)/_ui/analysis-container/analysis-chart.tsx index 1e212f7c..dd8785b5 100644 --- a/app/(dashboard)/_ui/analysis-container/analysis-chart.tsx +++ b/app/(dashboard)/_ui/analysis-container/analysis-chart.tsx @@ -47,8 +47,6 @@ const AnalysisChart = ({ analysisChartData: data }: Props) => { xAxis: { visible: false, categories: data.dates, - min: data.dates.length > 30 ? data.dates.length - 30 : 0, - max: data.dates.length - 1, }, yAxis: [ { From 0ae0877ff6c4824eaa11091ca2ec9d3bd8ac536c Mon Sep 17 00:00:00 2001 From: deun <devdeun@gmail.com> Date: Wed, 26 Feb 2025 16:47:50 +0900 Subject: [PATCH 196/207] =?UTF-8?q?feat:=20admin=20=EB=AC=B8=EC=9D=98=20?= =?UTF-8?q?=EB=82=B4=EC=97=AD=20=ED=8E=98=EC=9D=B4=EC=A7=80=20=ED=8E=98?= =?UTF-8?q?=EC=9D=B4=EC=A7=80=EB=84=A4=EC=9D=B4=EC=85=98=20handler=20?= =?UTF-8?q?=ED=95=A8=EC=88=98=20=EC=B6=94=EA=B0=80=20(#178)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- app/admin/questions/page.tsx | 15 ++++++++++++++- 1 file changed, 14 insertions(+), 1 deletion(-) diff --git a/app/admin/questions/page.tsx b/app/admin/questions/page.tsx index d2211290..36c61e83 100644 --- a/app/admin/questions/page.tsx +++ b/app/admin/questions/page.tsx @@ -2,6 +2,8 @@ import classNames from 'classnames/bind' +import { PATH } from '@/shared/constants/path' +import { usePagination } from '@/shared/hooks/custom/use-pagination' import { QuestionSearchConditionType } from '@/shared/types/questions' import Pagination from '@/shared/ui/pagination' import SearchInput from '@/shared/ui/search-input' @@ -38,6 +40,11 @@ const AdminQuestionsPage = () => { stateCondition, }) + const { page, handlePageChange } = usePagination({ + basePath: PATH.ADMIN_STRATEGIES, + pageSize: data?.size || 10, + }) + if (isLoading || !data) return null return ( @@ -85,7 +92,13 @@ const AdminQuestionsPage = () => { countPerPage={data.size} currentPage={1} /> - <Pagination currentPage={data.page} maxPage={data.totalPages} onPageChange={() => {}} /> + {data.content.length > 0 && ( + <Pagination + currentPage={page} + maxPage={data.totalPages} + onPageChange={handlePageChange} + /> + )} </section> </> ) From 7adbc82e4b3bf2f9466d2777bf8c3c7fc643668e Mon Sep 17 00:00:00 2001 From: James <dawn147@snu.ac.kr> Date: Thu, 27 Feb 2025 12:13:00 +0900 Subject: [PATCH 197/207] =?UTF-8?q?feat:=20=EC=A0=9C=EC=95=88=EC=84=9C=20?= =?UTF-8?q?=EC=82=AD=EC=A0=9C=20=EA=B8=B0=EB=8A=A5=20=EB=B0=8F=20=EC=95=84?= =?UTF-8?q?=EC=9D=B4=EC=BD=98=20=EC=B6=94=EA=B0=80=20(#175)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../_ui/details-information/index.tsx | 3 +++ .../details-information/strategy-name-box.tsx | 22 +++++++++++++++++-- .../details-information/styles.module.scss | 15 +++++++++++-- app/(dashboard)/my/_api/post-edit-strategy.ts | 2 +- public/icons/index.tsx | 1 + public/icons/trashcan.svg | 3 +++ 6 files changed, 41 insertions(+), 5 deletions(-) create mode 100644 public/icons/trashcan.svg diff --git a/app/(dashboard)/_ui/details-information/index.tsx b/app/(dashboard)/_ui/details-information/index.tsx index 5d3ea73a..5a08238e 100644 --- a/app/(dashboard)/_ui/details-information/index.tsx +++ b/app/(dashboard)/_ui/details-information/index.tsx @@ -15,12 +15,14 @@ interface Props { information: StrategyDetailsInformationModel type?: 'default' | 'my' isEditable?: boolean + error?: Error } const DetailsInformation = ({ strategyId, information, type = 'default', + error, isEditable = false, }: Props) => { const percentageToArray = [ @@ -35,6 +37,7 @@ const DetailsInformation = ({ <> <div className={cx('information-top')}> <StrategyNameBox + error={error} iconUrls={[ information.tradeTypeIconUrl, ...(information.stockTypeInfo?.stockTypeIconUrls ?? []), diff --git a/app/(dashboard)/_ui/details-information/strategy-name-box.tsx b/app/(dashboard)/_ui/details-information/strategy-name-box.tsx index 0575421b..08b509c4 100644 --- a/app/(dashboard)/_ui/details-information/strategy-name-box.tsx +++ b/app/(dashboard)/_ui/details-information/strategy-name-box.tsx @@ -4,6 +4,7 @@ import { useEffect, useRef, useState } from 'react' import StrategiesIcon from '@/app/(dashboard)/_ui/strategies-item/strategies-icon' import { FileIcon } from '@/public/icons' +import { TrashcanIcon } from '@/public/icons' import classNames from 'classnames/bind' import { SUPPORTED_FILE_TYPES } from '@/shared/constants/supported-file-types' @@ -23,6 +24,7 @@ interface Props { iconUrls?: string[] iconNames?: string[] isEditable?: boolean + error?: Error } const StrategyNameBox = ({ @@ -32,12 +34,14 @@ const StrategyNameBox = ({ iconUrls, iconNames, isEditable = false, + error, }: Props) => { const information = useEditInformationStore((state) => state.information) const proposal = useEditInformationStore((state) => state.proposal) const setStrategyName = useEditInformationStore((state) => state.actions.setStrategyName) const setProposalFile = useEditInformationStore((state) => state.actions.setProposalFile) const initializeProposal = useEditInformationStore((state) => state.actions.initializeProposal) + const setProposalModified = useEditInformationStore((state) => state.actions.setProposalModified) const { refetch } = useGetProposalFileName(strategyId) const { mutate } = useGetProposalDownload() @@ -70,6 +74,16 @@ const StrategyNameBox = ({ } } + const handleProposalDelete = () => { + if (selectedFile || proposal.proposalFileName) { + setSelectedFile(null) + setProposalFile(null) + initializeProposal('') + setProposalModified(true) + setFileError('') + } + } + useEffect(() => { setStrategyName(name) }, [name, setStrategyName]) @@ -120,11 +134,15 @@ const StrategyNameBox = ({ /> <div className={cx('proposal-input-wrapper')}> <Input readOnly value={displayFileName} className={cx('file-name-input')} /> - <button onClick={handleProposalClick} className={cx('proposal-button')}> + <button onClick={handleProposalClick} className={cx('proposal-button', 'modify')}> <FileIcon /> </button> + <button onClick={handleProposalDelete} className={cx('proposal-button', 'delete')}> + <TrashcanIcon /> + </button> </div> - {fileError && <p className={cx('file-error')}>{fileError}</p>} + {error && <p className={cx('error-message')}>{error.message}</p>} + {fileError && <p className={cx('error-message')}>{fileError}</p>} </div> )} </div> diff --git a/app/(dashboard)/_ui/details-information/styles.module.scss b/app/(dashboard)/_ui/details-information/styles.module.scss index db12d793..4e0ebbbd 100644 --- a/app/(dashboard)/_ui/details-information/styles.module.scss +++ b/app/(dashboard)/_ui/details-information/styles.module.scss @@ -55,6 +55,9 @@ border-radius: 8px; cursor: default; color: $color-gray-700; + padding-right: 70px; + text-overflow: ellipsis; + padding-left: 16px; } .proposal-button { @@ -73,13 +76,21 @@ width: 20px; height: 20px; } + + &.modify { + right: 40px; + } + + &.delete { + right: 12px; + } } } - .file-error { + .error-message { @include typo-c1; color: $color-orange-700; - margin-top: 4px; + margin-top: 8px; } } diff --git a/app/(dashboard)/my/_api/post-edit-strategy.ts b/app/(dashboard)/my/_api/post-edit-strategy.ts index a6ddb6cc..e385ac18 100644 --- a/app/(dashboard)/my/_api/post-edit-strategy.ts +++ b/app/(dashboard)/my/_api/post-edit-strategy.ts @@ -32,7 +32,7 @@ const postEditStrategy = async ( ) return response.data } catch (err) { - throw new Error('전략 정보 수정 실패', err as AxiosError) + throw new Error('서버 오류로 제안서 수정에 실패했습니다.', err as AxiosError) } } diff --git a/public/icons/index.tsx b/public/icons/index.tsx index 4586fed3..4e8132de 100644 --- a/public/icons/index.tsx +++ b/public/icons/index.tsx @@ -35,3 +35,4 @@ export { default as BackIcon } from './back.svg' export { default as RegisterIcon } from './modal-register.svg' export { default as DownloadIcon } from './download.svg' export { default as CameraIcon } from './camera.svg' +export { default as TrashcanIcon } from './trashcan.svg' diff --git a/public/icons/trashcan.svg b/public/icons/trashcan.svg new file mode 100644 index 00000000..40dd4a2a --- /dev/null +++ b/public/icons/trashcan.svg @@ -0,0 +1,3 @@ +<svg width="24" height="24" viewBox="0 0 24 24" fill="black" xmlns="http://www.w3.org/2000/svg"> +<path fill-rule="evenodd" clip-rule="evenodd" d="M9 3V4H4V6H5V19C5 19.5304 5.21071 20.0391 5.58579 20.4142C5.96086 20.7893 6.46957 21 7 21H17C17.5304 21 18.0391 20.7893 18.4142 20.4142C18.7893 20.0391 19 19.5304 19 19V6H20V4H15V3H9ZM7 6H17V19H7V6ZM9 8H11V17H9V8ZM13 8H15V17H13V8Z" fill="currentColor"/> +</svg> \ No newline at end of file From e2ee3949d140ebe76d03dd9d021db4dd7a3e298b Mon Sep 17 00:00:00 2001 From: James <dawn147@snu.ac.kr> Date: Thu, 27 Feb 2025 12:13:47 +0900 Subject: [PATCH 198/207] =?UTF-8?q?refactor:=20=EA=B8=B0=ED=83=80=20?= =?UTF-8?q?=EB=B3=80=EA=B2=BD=20=EC=82=AC=ED=95=AD=20(#175)=20-=20?= =?UTF-8?q?=EC=A0=80=EC=9E=A5=20=EC=A4=91=20=EB=AC=B8=EA=B5=AC=EA=B0=80=20?= =?UTF-8?q?=EB=B3=B4=EC=9D=B4=EC=A7=80=20=EC=95=8A=EB=8A=94=20=EB=AC=B8?= =?UTF-8?q?=EC=A0=9C=20=ED=95=B4=EA=B2=B0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../my/_hooks/query/use-post-edit-strategy.ts | 4 +- .../strategies/manage/[strategyId]/page.tsx | 68 +++++++++---------- 2 files changed, 34 insertions(+), 38 deletions(-) diff --git a/app/(dashboard)/my/_hooks/query/use-post-edit-strategy.ts b/app/(dashboard)/my/_hooks/query/use-post-edit-strategy.ts index d078f60d..5b26782e 100644 --- a/app/(dashboard)/my/_hooks/query/use-post-edit-strategy.ts +++ b/app/(dashboard)/my/_hooks/query/use-post-edit-strategy.ts @@ -1,4 +1,4 @@ -import { QueryClient, useMutation } from '@tanstack/react-query' +import { useMutation, useQueryClient } from '@tanstack/react-query' import uploadFileWithPresignedUrl from '@/shared/api/upload-file-with-presigned-url' import { QUERY_KEY } from '@/shared/constants/query-key' @@ -9,7 +9,7 @@ import postEditStrategy, { } from '../../_api/post-edit-strategy' const usePostEditStrategy = () => { - const queryClient = new QueryClient() + const queryClient = useQueryClient() return useMutation< EditStrategyResponseModel, diff --git a/app/(dashboard)/my/strategies/manage/[strategyId]/page.tsx b/app/(dashboard)/my/strategies/manage/[strategyId]/page.tsx index df89b767..3636cbca 100644 --- a/app/(dashboard)/my/strategies/manage/[strategyId]/page.tsx +++ b/app/(dashboard)/my/strategies/manage/[strategyId]/page.tsx @@ -37,7 +37,7 @@ const StrategyManagePage = ({ params }: { params: { strategyId: string } }) => { strategyId: strategyNumber, }) - const { mutate: editStrategy, isError, error } = usePostEditStrategy() + const { mutateAsync: editStrategy, error } = usePostEditStrategy() const { detailsSideData, detailsInformationData } = detailsInfoData || {} const { detailsInformationData: subscribeInfo } = subscribeData || {} @@ -45,7 +45,7 @@ const StrategyManagePage = ({ params }: { params: { strategyId: string } }) => { if (!Array.isArray(data)) return data.data !== undefined }) - const handleUpdateInformation = async () => { + const handleUpdateInformation = () => { const editedInformation = useEditInformationStore.getState().information const { proposal } = useEditInformationStore.getState() @@ -54,38 +54,36 @@ const StrategyManagePage = ({ params }: { params: { strategyId: string } }) => { } setIsSubmitting(true) - try { - const information = { - strategyName: editedInformation.strategyName, - description: editedInformation.description, - proposalModified: proposal.proposalModified, - ...(proposal.proposalFile && { - proposalFile: { - proposalFileName: proposal.proposalFile.name, - proposalFileSize: proposal.proposalFile.size, - }, - }), - } - - editStrategy( - { strategyId: strategyNumber, information, file: proposal.proposalFile || undefined }, - { - onSuccess: async () => { - await refetch() - const newProposalFileName = proposal.proposalFile?.name || proposal.proposalFileName - useEditInformationStore.getState().actions.initializeProposal(newProposalFileName) - setIsEditable(false) - }, - onError: (err) => { - console.error('전략 수정 실패:', err) - }, - } - ) - } catch (err) { - console.error('Failed to update strategy:', err) - } finally { - setIsSubmitting(false) + + const information = { + strategyName: editedInformation.strategyName, + description: editedInformation.description, + proposalModified: proposal.proposalModified, + ...(proposal.proposalFile && { + proposalFile: { + proposalFileName: proposal.proposalFile.name, + proposalFileSize: proposal.proposalFile.size, + }, + }), } + + editStrategy({ + strategyId: strategyNumber, + information, + file: proposal.proposalFile || undefined, + }) + .then(async () => { + await refetch() + const newProposalFileName = proposal.proposalFile?.name || proposal.proposalFileName + useEditInformationStore.getState().actions.initializeProposal(newProposalFileName) + setIsEditable(false) + }) + .catch((err) => { + console.error('전략 수정 실패:', err) + }) + .finally(() => { + setIsSubmitting(false) + }) } return ( @@ -114,15 +112,13 @@ const StrategyManagePage = ({ params }: { params: { strategyId: string } }) => { </Button> )} </div> - {isError && ( - <div className={cx('error')}>{(error as Error)?.message || '오류가 발생했습니다.'}</div> - )} <div className={cx('strategy-container')}> {detailsInformationData && ( <DetailsInformation information={detailsInformationData} strategyId={strategyNumber} isEditable={isEditable} + error={error as Error} type="my" /> )} From 09546d86aac34baa659c58dd9130fcfe2baf0d63 Mon Sep 17 00:00:00 2001 From: James <dawn147@snu.ac.kr> Date: Thu, 27 Feb 2025 13:09:34 +0900 Subject: [PATCH 199/207] =?UTF-8?q?bug:=20=EC=84=9C=EC=B9=98=20=EB=B0=94?= =?UTF-8?q?=20=EA=B2=80=EC=83=89=20=EC=9D=B8=ED=92=8B=20=EB=B2=84=EA=B7=B8?= =?UTF-8?q?=20=ED=95=B4=EA=B2=B0=20(#176)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../strategies/_ui/search-bar/range-container.tsx | 12 +++++++----- 1 file changed, 7 insertions(+), 5 deletions(-) diff --git a/app/(dashboard)/strategies/_ui/search-bar/range-container.tsx b/app/(dashboard)/strategies/_ui/search-bar/range-container.tsx index 44aa439b..329960fc 100644 --- a/app/(dashboard)/strategies/_ui/search-bar/range-container.tsx +++ b/app/(dashboard)/strategies/_ui/search-bar/range-container.tsx @@ -16,20 +16,22 @@ const RangeContainer = ({ optionId }: Props) => { const errOptions = useSearchingItemStore((state) => state.errOptions) const searchTerms = useSearchingItemStore((state) => state.searchTerms) const { setRangeValue } = useSearchingItemStore((state) => state.actions) + const option = searchTerms?.[optionId] as RangeModel | null const handleRangeValue = (e: React.ChangeEvent<HTMLInputElement>, type: 'min' | 'max') => { - const value = Number(e.target.value) - setRangeValue(optionId, type, value) + const value = e.target.value === '' ? null : Number(e.target.value) + setRangeValue(optionId, type, value ?? 0) } - const option = searchTerms?.[optionId] as RangeModel | null + const getDisplayValue = (value: number | null | undefined): string => + value === null || value === undefined ? '' : String(value) return ( <div className={cx('range-container')}> <div className={cx('range-wrapper')}> <input className={cx('range')} - value={option?.min ?? ''} + value={getDisplayValue(option?.min)} type="number" placeholder="0" onChange={(e) => handleRangeValue(e, 'min')} @@ -37,7 +39,7 @@ const RangeContainer = ({ optionId }: Props) => { <span>~</span> <input className={cx('range')} - value={option?.max ?? ''} + value={getDisplayValue(option?.max)} type="number" placeholder="0" onChange={(e) => handleRangeValue(e, 'max')} From d5170d37684093d1acb4fb0c1eac5943e6dd82d2 Mon Sep 17 00:00:00 2001 From: deun <devdeun@gmail.com> Date: Thu, 27 Feb 2025 13:51:32 +0900 Subject: [PATCH 200/207] =?UTF-8?q?feat:=20=EB=9E=9C=EB=94=A9=20=ED=8E=98?= =?UTF-8?q?=EC=9D=B4=EC=A7=80=20=EC=A0=84=EB=9E=B5=20=EC=B9=B4=EB=93=9C?= =?UTF-8?q?=EC=97=90=20=EC=83=81=EC=84=B8=20=ED=8E=98=EC=9D=B4=EC=A7=80=20?= =?UTF-8?q?=EC=97=B0=EA=B2=B0=20(#183)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../(home)/_ui/top-favorite-section/index.tsx | 1 + .../(home)/_ui/top-sm-score-section/index.tsx | 1 + app/(landing)/(home)/_ui/top-strategy-card/index.tsx | 12 ++++++++++-- .../_ui/top-strategy-card/top-favorite-card.tsx | 3 ++- .../_ui/top-strategy-card/top-sm-score-card.tsx | 3 ++- app/(landing)/(home)/_ui/top-strategy-card/types.ts | 1 + 6 files changed, 17 insertions(+), 4 deletions(-) diff --git a/app/(landing)/(home)/_ui/top-favorite-section/index.tsx b/app/(landing)/(home)/_ui/top-favorite-section/index.tsx index 9e92e105..446b214d 100644 --- a/app/(landing)/(home)/_ui/top-favorite-section/index.tsx +++ b/app/(landing)/(home)/_ui/top-favorite-section/index.tsx @@ -32,6 +32,7 @@ const TopFavoriteSection = () => { favoriteStrategies.map((strategy, idx) => ( <li key={strategy.strategyId}> <TopFavoriteCard + id={strategy.strategyId} ranking={idx + 1} nickname={strategy.nickname} title={strategy.strategyName} diff --git a/app/(landing)/(home)/_ui/top-sm-score-section/index.tsx b/app/(landing)/(home)/_ui/top-sm-score-section/index.tsx index b657e363..a8aab7fd 100644 --- a/app/(landing)/(home)/_ui/top-sm-score-section/index.tsx +++ b/app/(landing)/(home)/_ui/top-sm-score-section/index.tsx @@ -26,6 +26,7 @@ const TopSmScoreSection = () => { {topSmScoreStrategies.map((strategy, idx) => ( <li key={strategy.strategyId}> <TopSmScoreCard + id={strategy.strategyId} size={idx > 0 ? 'small' : 'large'} ranking={idx + 1} nickname={strategy.nickname} diff --git a/app/(landing)/(home)/_ui/top-strategy-card/index.tsx b/app/(landing)/(home)/_ui/top-strategy-card/index.tsx index 634e4398..6ceec46b 100644 --- a/app/(landing)/(home)/_ui/top-strategy-card/index.tsx +++ b/app/(landing)/(home)/_ui/top-strategy-card/index.tsx @@ -1,5 +1,8 @@ +import Link from 'next/link' + import classNames from 'classnames/bind' +import { PATH } from '@/shared/constants/path' import Avatar from '@/shared/ui/avatar' import TotalStar from '@/shared/ui/total-star' @@ -15,12 +18,17 @@ import { const cx = classNames.bind(styles) interface Props { + id: number size?: CardSizeType children: React.ReactNode } -const TopStrategyCard = ({ size, children }: Props) => { - return <div className={cx('card-container', size)}>{children}</div> +const TopStrategyCard = ({ id, size, children }: Props) => { + return ( + <Link className={cx('card-container', size)} href={`${PATH.STRATEGIES}/${id}`}> + {children} + </Link> + ) } const ContentsWrapper = ({ children }: { children: React.ReactNode }) => { diff --git a/app/(landing)/(home)/_ui/top-strategy-card/top-favorite-card.tsx b/app/(landing)/(home)/_ui/top-strategy-card/top-favorite-card.tsx index c938c278..e607f405 100644 --- a/app/(landing)/(home)/_ui/top-strategy-card/top-favorite-card.tsx +++ b/app/(landing)/(home)/_ui/top-strategy-card/top-favorite-card.tsx @@ -10,6 +10,7 @@ interface Props extends TopStrategyCardCommonProps { } const TopFavoriteCard = ({ + id, ranking, nickname, title, @@ -20,7 +21,7 @@ const TopFavoriteCard = ({ reviewCount, }: Props) => { return ( - <TopStrategyCard> + <TopStrategyCard id={id}> <TopStrategyCard.ContentsWrapper> <TopStrategyCard.Content ranking={ranking} nickname={nickname} title={title} /> <TopStrategyCard.ContentDetails diff --git a/app/(landing)/(home)/_ui/top-strategy-card/top-sm-score-card.tsx b/app/(landing)/(home)/_ui/top-strategy-card/top-sm-score-card.tsx index 2285522a..6f8355fa 100644 --- a/app/(landing)/(home)/_ui/top-strategy-card/top-sm-score-card.tsx +++ b/app/(landing)/(home)/_ui/top-strategy-card/top-sm-score-card.tsx @@ -11,6 +11,7 @@ interface Props extends TopStrategyCardCommonProps { } const TopSmScoreCard = ({ + id, size = 'small', ranking, nickname, @@ -20,7 +21,7 @@ const TopSmScoreCard = ({ score, }: Props) => { return ( - <TopStrategyCard size={size}> + <TopStrategyCard size={size} id={id}> <TopStrategyCard.ContentsWrapper> <TopStrategyCard.Content ranking={ranking} nickname={nickname} title={title} /> <TopStrategyCard.SmScore score={score} /> diff --git a/app/(landing)/(home)/_ui/top-strategy-card/types.ts b/app/(landing)/(home)/_ui/top-strategy-card/types.ts index 2e2ece81..24384af6 100644 --- a/app/(landing)/(home)/_ui/top-strategy-card/types.ts +++ b/app/(landing)/(home)/_ui/top-strategy-card/types.ts @@ -22,6 +22,7 @@ export interface TopCardContentDetailsProps { } export interface TopStrategyCardCommonProps { + id: number ranking: number nickname: string title: string From 7794ab9a8594a351c8fe43e9862f402ae9f85e7d Mon Sep 17 00:00:00 2001 From: deun <devdeun@gmail.com> Date: Thu, 6 Mar 2025 10:49:53 +0900 Subject: [PATCH 201/207] =?UTF-8?q?bug:=20=EA=B4=80=EB=A6=AC=EC=9E=90=20?= =?UTF-8?q?=EB=AC=B8=EC=9D=98=20=EB=82=B4=EC=97=AD=20=EA=B2=BD=EB=A1=9C=20?= =?UTF-8?q?=EB=AC=B8=EC=A0=9C=20=ED=95=B4=EA=B2=B0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- app/admin/questions/page.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/app/admin/questions/page.tsx b/app/admin/questions/page.tsx index 36c61e83..4175337d 100644 --- a/app/admin/questions/page.tsx +++ b/app/admin/questions/page.tsx @@ -41,7 +41,7 @@ const AdminQuestionsPage = () => { }) const { page, handlePageChange } = usePagination({ - basePath: PATH.ADMIN_STRATEGIES, + basePath: PATH.ADMIN_QUESTIONS, pageSize: data?.size || 10, }) From 65ee1b988ac2754b50070e7cc419f030f55a5fe5 Mon Sep 17 00:00:00 2001 From: James <dawn147@snu.ac.kr> Date: Sat, 15 Mar 2025 15:48:56 +0900 Subject: [PATCH 202/207] =?UTF-8?q?refactor:=20=EB=B9=84=EB=B0=80=EB=B2=88?= =?UTF-8?q?=ED=98=B8=20=EA=B2=80=EC=A6=9D=20=EB=AC=B8=EA=B5=AC=EC=99=80=20?= =?UTF-8?q?=EB=A1=9C=EC=A7=81=20=EC=88=98=EC=A0=95=20(#186)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- app/(dashboard)/my/profile/_ui/user-info/index.tsx | 2 +- app/(landing)/signup/information/page.tsx | 2 +- shared/constants/error-messages.ts | 2 +- shared/utils/validation.ts | 2 +- 4 files changed, 4 insertions(+), 4 deletions(-) diff --git a/app/(dashboard)/my/profile/_ui/user-info/index.tsx b/app/(dashboard)/my/profile/_ui/user-info/index.tsx index 596577dc..86d9ad3b 100644 --- a/app/(dashboard)/my/profile/_ui/user-info/index.tsx +++ b/app/(dashboard)/my/profile/_ui/user-info/index.tsx @@ -292,7 +292,7 @@ const UserInfo = ({ profile, isEditable = false }: Props) => { {isEditable && ( <div> <p className={cx('notification')}> - * 비밀번호는 문자, 숫자 포함 6~20자로 구성되어야 합니다. + * 비밀번호는 영문과 숫자를 포함하여 6~20자로 구성되어야 합니다. </p> </div> )} diff --git a/app/(landing)/signup/information/page.tsx b/app/(landing)/signup/information/page.tsx index 94d37f61..39357d6d 100644 --- a/app/(landing)/signup/information/page.tsx +++ b/app/(landing)/signup/information/page.tsx @@ -207,7 +207,7 @@ const InformationPage = () => { className={cx('input')} errorMessage={errors.password} /> - <small>* 비밀번호는 문자, 숫자 포함 6~20자로 구성되어야 합니다.</small> + <small>* 비밀번호는 영문과 숫자 포함 6~20자로 구성되어야 합니다.</small> </div> </div> diff --git a/shared/constants/error-messages.ts b/shared/constants/error-messages.ts index 1c0fbc16..83d7f6c8 100644 --- a/shared/constants/error-messages.ts +++ b/shared/constants/error-messages.ts @@ -5,7 +5,7 @@ export const ERROR_MESSAGES = { FORM: { REQUIRED_FIELDS: '이메일과 비밀번호를 입력해주세요.', EMAIL: '이메일 형식이 올바르지 않습니다.', - PASSWORD: '비밀번호는 8자 이상, 영문과 숫자를 포함해야 합니다.', + PASSWORD: '비밀번호는 6~20자로 영문과 숫자를 포함해야 합니다.', PHONE: '휴대폰 번호 형식이 올바르지 않습니다.', }, } as const diff --git a/shared/utils/validation.ts b/shared/utils/validation.ts index b537a9e0..4d0e5942 100644 --- a/shared/utils/validation.ts +++ b/shared/utils/validation.ts @@ -2,7 +2,7 @@ import { ERROR_MESSAGES } from '@/shared/constants/error-messages' const PATTERNS = { EMAIL: /^[a-zA-Z0-9._%+-]+@[a-zA-Z0-9.-]+\.[a-zA-Z]{2,}$/, - PASSWORD: /^(?=.*[A-Za-z])(?=.*\d)[A-Za-z\d@$!%*?&]{6,}$/, + PASSWORD: /^(?=.*[A-Za-z])(?=.*\d)[A-Za-z\d!@#$%^&*(),.?":{}|<>]{6,20}$/, PHONE: /^01([0|1|6|7|8|9])([0-9]{3,4})([0-9]{4})$/, NAME: /^.{2,}$/, NICKNAME: /^.{2,10}$/, From ec1aaa5594b92aab70f23e0e003bd361e0e7e899 Mon Sep 17 00:00:00 2001 From: James <dawn147@snu.ac.kr> Date: Sat, 15 Mar 2025 15:50:59 +0900 Subject: [PATCH 203/207] =?UTF-8?q?refactor:=20=EB=B9=84=EB=B0=80=EB=B2=88?= =?UTF-8?q?=ED=98=B8=20=EC=9E=AC=EC=84=A4=EC=A0=95=20=EB=A1=9C=EC=A7=81=20?= =?UTF-8?q?=EB=A6=AC=ED=8C=A9=ED=86=A0=EB=A7=81=20(#186)=20-=20=EC=9D=B8?= =?UTF-8?q?=EC=A6=9D=20=EC=BD=94=EB=93=9C=20=EC=9C=A0=ED=9A=A8=EC=8B=9C?= =?UTF-8?q?=EA=B0=84=20=ED=91=9C=EC=8B=9C=20-=20=EC=9D=B8=EC=A6=9D?= =?UTF-8?q?=EC=BD=94=EB=93=9C=20=EB=B0=9C=EC=86=A1=20=EC=99=84=EB=A3=8C=20?= =?UTF-8?q?=EB=A9=94=EC=8B=9C=EC=A7=80=20=ED=91=9C=EC=8B=9C=20-=20?= =?UTF-8?q?=EB=B9=84=EB=B0=80=EB=B2=88=ED=98=B8=20=EC=9E=AC=EC=84=A4?= =?UTF-8?q?=EC=A0=95=EC=8B=9C=20=EC=9C=A0=ED=9A=A8=EC=84=B1=20=EA=B2=80?= =?UTF-8?q?=EC=82=AC=20=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- shared/hooks/custom/use-reset-password.ts | 167 ++++++++++++++++-- shared/ui/modal/find-password-modal/index.tsx | 90 +++++++--- .../find-password-modal/styles.module.scss | 31 +++- 3 files changed, 246 insertions(+), 42 deletions(-) diff --git a/shared/hooks/custom/use-reset-password.ts b/shared/hooks/custom/use-reset-password.ts index 4ab84642..f594c2b1 100644 --- a/shared/hooks/custom/use-reset-password.ts +++ b/shared/hooks/custom/use-reset-password.ts @@ -1,11 +1,12 @@ -import { useCallback, useState } from 'react' +import { useCallback, useEffect, useRef, useState } from 'react' import axios from 'axios' import { useFindCredentials } from '@/shared/hooks/query/use-find-credentials' +import { isValidPassword } from '@/shared/utils/validation' interface UseResetPasswordProps { - onSuccess: () => void + onSuccess: (message: string) => void onError: (message: string) => void } @@ -17,6 +18,11 @@ export const useResetPassword = ({ onSuccess, onError }: UseResetPasswordProps) password: '', passwordConfirm: '', }) + const [countdown, setCountdown] = useState(0) + const [isEmailVerified, setIsEmailVerified] = useState(false) + const [isPasswordReset, setIsPasswordReset] = useState(false) + const timerRef = useRef<NodeJS.Timeout | null>(null) + const controllerRef = useRef<AbortController | null>(null) const { authenticateMutation, resetPasswordMutation } = useFindCredentials() @@ -27,46 +33,93 @@ export const useResetPassword = ({ onSuccess, onError }: UseResetPasswordProps) [onError] ) - const memoizedOnSuccess = useCallback(() => { - onSuccess() - }, [onSuccess]) + const memoizedOnSuccess = useCallback( + (message: string) => { + onSuccess(message) + }, + [onSuccess] + ) + + const validatePassword = (password: string): { isValid: boolean; message: string } => { + if (!isValidPassword(password)) { + return { + isValid: false, + message: '비밀번호는 영문과 숫자를 포함한 6~20자리여야 합니다.', + } + } + + return { isValid: true, message: '' } + } const handleVerifyEmail = async (e?: React.FormEvent) => { e?.preventDefault() + + if (!formData.email || !formData.code) { + memoizedOnError('이메일과 인증코드를 모두 입력해주세요.') + return + } + try { const response = await authenticateMutation.mutateAsync({ email: formData.email, code: formData.code, }) + if (response.isSuccess) { - setStep(2) - memoizedOnError('') + setIsEmailVerified(true) + memoizedOnSuccess('이메일 인증에 성공했습니다.') + if (timerRef.current) { + clearInterval(timerRef.current) + timerRef.current = null + } + setCountdown(0) } else { + setIsEmailVerified(false) memoizedOnError(response.message || '인증에 실패했습니다.') } - } catch { - memoizedOnError('인증에 실패했습니다.') + } catch (error) { + setIsEmailVerified(false) + memoizedOnError('인증에 실패했습니다. 다시 시도해주세요.') } } + const handleNextStep = () => { + if (!isEmailVerified) { + memoizedOnError('이메일 인증을 먼저 진행해주세요.') + return + } + setStep(2) + memoizedOnSuccess('') + } + const handlePasswordReset = async (e: React.FormEvent) => { e.preventDefault() + + const validation = validatePassword(formData.password) + if (!validation.isValid) { + memoizedOnError(validation.message) + return + } + if (formData.password !== formData.passwordConfirm) { memoizedOnError('비밀번호가 일치하지 않습니다.') return } + try { const response = await resetPasswordMutation.mutateAsync({ email: formData.email, password: formData.password, }) + if (response.isSuccess) { - memoizedOnSuccess() + setIsPasswordReset(true) + memoizedOnSuccess('비밀번호가 성공적으로 재설정되었습니다.') } else { memoizedOnError(response.message || '비밀번호 재설정에 실패했습니다.') } - } catch { - memoizedOnError('비밀번호 재설정에 실패했습니다.') + } catch (error) { + memoizedOnError('비밀번호 재설정에 실패했습니다. 다시 시도해주세요.') } } @@ -79,17 +132,65 @@ export const useResetPassword = ({ onSuccess, onError }: UseResetPasswordProps) }, []) const handleRequestCode = useCallback(async () => { + if (!formData.email) { + memoizedOnError('이메일을 입력해주세요.') + return + } + + if (controllerRef.current) { + controllerRef.current.abort() + } + + const controller = new AbortController() + controllerRef.current = controller + try { - const response = await axios.get(`/api/users/authenticate?email=${formData.email}`) + const response = await axios.get(`/api/users/authenticate?email=${formData.email}`, { + signal: controller.signal, + }) + if (response.data.isSuccess) { - memoizedOnError('') + memoizedOnSuccess('인증코드가 발송되었습니다. 30분 내에 입력해주세요.') + setCountdown(1800) } else { memoizedOnError(response.data.message || '인증코드 발송에 실패했습니다.') } - } catch { - memoizedOnError('인증코드 발송에 실패했습니다.') + } catch (error: any) { + if (axios.isCancel(error)) { + return + } + if (error.response && error.response.data && error.response.data.message) { + memoizedOnError(error.response.data.message) + } else { + memoizedOnError('인증코드 발송에 실패했습니다.') + } + } finally { + controllerRef.current = null + } + }, [formData.email, memoizedOnError, memoizedOnSuccess]) + + useEffect(() => { + if (countdown > 0) { + timerRef.current = setInterval(() => { + setCountdown((prev) => prev - 1) + }, 1000) + } else if (timerRef.current) { + clearInterval(timerRef.current) + timerRef.current = null } - }, [formData.email, memoizedOnError]) + + return () => { + if (timerRef.current) { + clearInterval(timerRef.current) + } + } + }, [countdown]) + + const formatCountdown = () => { + const minutes = Math.floor(countdown / 60) + const seconds = countdown % 60 + return `${minutes.toString().padStart(2, '0')}:${seconds.toString().padStart(2, '0')}` + } const isPending = authenticateMutation.isPending || resetPasswordMutation.isPending @@ -100,16 +201,48 @@ export const useResetPassword = ({ onSuccess, onError }: UseResetPasswordProps) password: '', passwordConfirm: '', }) + setCountdown(0) + setIsEmailVerified(false) + setIsPasswordReset(false) + + if (controllerRef.current) { + controllerRef.current.abort() + controllerRef.current = null + } + + if (timerRef.current) { + clearInterval(timerRef.current) + timerRef.current = null + } + + memoizedOnSuccess('') + memoizedOnError('') + }, [memoizedOnSuccess, memoizedOnError]) + + useEffect(() => { + return () => { + if (timerRef.current) { + clearInterval(timerRef.current) + } + if (controllerRef.current) { + controllerRef.current.abort() + } + } }, []) return { formData, step, isPending, + countdown, + formatCountdown, + isEmailVerified, + isPasswordReset, handleInputChange, handleRequestCode, handleVerifyEmail, handlePasswordReset, + handleNextStep, setStep, resetForm, } diff --git a/shared/ui/modal/find-password-modal/index.tsx b/shared/ui/modal/find-password-modal/index.tsx index b5a5a16a..8fd12570 100644 --- a/shared/ui/modal/find-password-modal/index.tsx +++ b/shared/ui/modal/find-password-modal/index.tsx @@ -21,26 +21,40 @@ interface Props { } const FindPasswordModal = ({ isOpen, onClose }: Props) => { - const [error, setError] = useState('') + const [notice, setNotice] = useState('') + const [isSuccess, setIsSuccess] = useState(false) + const { formData, step, isPending, + countdown, + formatCountdown, + isEmailVerified, + isPasswordReset, handleInputChange, handleRequestCode, handleVerifyEmail, handlePasswordReset, + handleNextStep, setStep, resetForm, } = useResetPassword({ - onSuccess: onClose, - onError: (message) => setError(message), + onSuccess: (message) => { + setNotice(message) + setIsSuccess(true) + }, + onError: (message) => { + setNotice(message) + setIsSuccess(false) + }, }) useEffect(() => { if (!isOpen) { resetForm() - setError('') + setNotice('') + setIsSuccess(false) setStep(1) } }, [isOpen, resetForm, setStep]) @@ -53,7 +67,13 @@ const FindPasswordModal = ({ isOpen, onClose }: Props) => { className={cx('container')} > {step === 1 && ( - <form onSubmit={handleVerifyEmail} className={cx('form')}> + <form + onSubmit={(e) => { + e.preventDefault() + handleNextStep() + }} + className={cx('form')} + > <p className={cx('explanation')}>본인 인증을 위해 가입한 이메일 주소를 입력해주세요.</p> <div className={cx('input-group')}> <label>이메일 주소</label> @@ -65,13 +85,13 @@ const FindPasswordModal = ({ isOpen, onClose }: Props) => { value={formData.email} onChange={handleInputChange} placeholder="이메일을 입력하세요" - disabled={isPending} + disabled={isPending || isEmailVerified} required /> <Button type="button" onClick={handleRequestCode} - disabled={isPending || !formData.email} + disabled={isPending || !formData.email || isEmailVerified} variant="filled" > 인증 @@ -88,25 +108,36 @@ const FindPasswordModal = ({ isOpen, onClose }: Props) => { value={formData.code} onChange={handleInputChange} placeholder="인증번호를 입력하세요" - disabled={isPending} + disabled={isPending || isEmailVerified} required /> <Button type="button" onClick={handleVerifyEmail} - disabled={isPending || !formData.code} + disabled={isPending || !formData.code || isEmailVerified} variant="outline" > 확인 </Button> </div> + {countdown > 0 && !isEmailVerified && ( + <p className={cx('countdown')}>인증 코드 유효시간: {formatCountdown()}</p> + )} </div> - {error && <p className={cx('error')}>{error}</p>} + + {notice && ( + <p className={cx('notice', { success: isSuccess, error: !isSuccess })}>{notice}</p> + )} + <div className={cx('buttons')}> <Button type="button" onClick={onClose} variant="outline"> 닫기 </Button> - <Button type="submit" disabled={isPending} variant="filled"> + <Button + type="submit" + disabled={isPending || !formData.email || !formData.code || !isEmailVerified} + variant="filled" + > 다음 </Button> </div> @@ -115,7 +146,7 @@ const FindPasswordModal = ({ isOpen, onClose }: Props) => { {step === 2 && ( <form onSubmit={handlePasswordReset} className={cx('form')}> - <p className={cx('explanation')}>본인 인증을 위해 가입한 이메일 주소를 입력해주세요.</p> + <p className={cx('explanation')}>새로운 비밀번호를 설정합니다.</p> <div className={cx('input-group')}> <label>새 비밀번호</label> <Input @@ -125,9 +156,10 @@ const FindPasswordModal = ({ isOpen, onClose }: Props) => { value={formData.password} onChange={handleInputChange} placeholder="새 비밀번호를 입력하세요" - disabled={isPending} + disabled={isPending || isPasswordReset} required /> + <p className={cx('password-guide')}>* 영문과 숫자를 포함한 6~20자로 입력해주세요.</p> </div> <div className={cx('input-group')}> <label>비밀번호 확인</label> @@ -138,18 +170,34 @@ const FindPasswordModal = ({ isOpen, onClose }: Props) => { value={formData.passwordConfirm} onChange={handleInputChange} placeholder="비밀번호를 다시 입력하세요" - disabled={isPending} + disabled={isPending || isPasswordReset} required /> </div> - {error && <p className={cx('error')}>{error}</p>} + + {notice && ( + <p className={cx('notice', { success: isSuccess, error: !isSuccess })}>{notice}</p> + )} + <div className={cx('buttons')}> - <Button type="button" onClick={() => setStep(1)} variant="outline"> - 이전 - </Button> - <Button type="submit" disabled={isPending} variant="filled"> - 재설정 - </Button> + {isPasswordReset ? ( + <Button type="button" onClick={onClose} variant="filled"> + 닫기 + </Button> + ) : ( + <> + <Button type="button" onClick={onClose} variant="outline"> + 닫기 + </Button> + <Button + type="submit" + disabled={isPending || !formData.password || !formData.passwordConfirm} + variant="filled" + > + 재설정 + </Button> + </> + )} </div> </form> )} diff --git a/shared/ui/modal/find-password-modal/styles.module.scss b/shared/ui/modal/find-password-modal/styles.module.scss index 155d1825..c1ef8e3e 100644 --- a/shared/ui/modal/find-password-modal/styles.module.scss +++ b/shared/ui/modal/find-password-modal/styles.module.scss @@ -4,11 +4,13 @@ display: flex; justify-content: center; } + .explanation { color: $color-gray-800; @include typo-c1; text-align: center; } + .form { display: flex; flex-direction: column; @@ -38,17 +40,38 @@ } } -.error { - color: $color-orange-700; +.countdown { + font-size: 0.75rem; + color: $color-gray-600; + margin-top: 0.25rem; +} + +.password-guide { + font-size: 0.75rem; + color: $color-gray-600; + margin-top: 0.25rem; +} + +.notice { font-size: 0.8rem; + & + .buttons { + margin-top: 0; + } + + &.success { + color: $color-indigo; + } + + &.error { + color: $color-orange-700; + } } .buttons { display: flex; gap: 36px; - margin-top: 1rem; - justify-content: center; margin-top: 40px; + justify-content: center; button { flex: 0.2; From 9b53c680b4711596cb0eaaf313541c47578a5b02 Mon Sep 17 00:00:00 2001 From: James <dawn147@snu.ac.kr> Date: Sat, 15 Mar 2025 17:46:47 +0900 Subject: [PATCH 204/207] =?UTF-8?q?fix:=20=EB=AC=B4=ED=95=9C=EB=A3=A8?= =?UTF-8?q?=ED=94=84=20=EB=B2=84=EA=B7=B8=20=EC=88=98=EC=A0=95=20(#186)=20?= =?UTF-8?q?-=20=ED=83=80=EC=9D=B4=EB=A8=B8=20=EC=9D=98=EC=A1=B4=EC=84=B1?= =?UTF-8?q?=20=EB=B0=B0=EC=97=B4=EC=97=90=20countdown=20=EC=82=AC=EC=9A=A9?= =?UTF-8?q?=ED=95=98=EC=A7=80=20=EC=95=8A=EB=8A=94=20=EB=B0=A9=ED=96=A5?= =?UTF-8?q?=EC=9C=BC=EB=A1=9C=20=EC=88=98=EC=A0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- shared/hooks/custom/use-reset-password.ts | 38 ++++++++++++------- shared/ui/modal/find-password-modal/index.tsx | 4 +- 2 files changed, 26 insertions(+), 16 deletions(-) diff --git a/shared/hooks/custom/use-reset-password.ts b/shared/hooks/custom/use-reset-password.ts index f594c2b1..9603ca61 100644 --- a/shared/hooks/custom/use-reset-password.ts +++ b/shared/hooks/custom/use-reset-password.ts @@ -51,6 +51,26 @@ export const useResetPassword = ({ onSuccess, onError }: UseResetPasswordProps) return { isValid: true, message: '' } } + const startCountdown = useCallback((duration: number) => { + if (timerRef.current) { + clearInterval(timerRef.current) + timerRef.current = null + } + + setCountdown(duration) + + timerRef.current = setInterval(() => { + setCountdown((prev) => { + if (prev <= 1) { + clearInterval(timerRef.current as NodeJS.Timeout) + timerRef.current = null + return 0 + } + return prev - 1 + }) + }, 1000) + }, []) + const handleVerifyEmail = async (e?: React.FormEvent) => { e?.preventDefault() @@ -151,7 +171,7 @@ export const useResetPassword = ({ onSuccess, onError }: UseResetPasswordProps) if (response.data.isSuccess) { memoizedOnSuccess('인증코드가 발송되었습니다. 30분 내에 입력해주세요.') - setCountdown(1800) + startCountdown(1800) } else { memoizedOnError(response.data.message || '인증코드 발송에 실패했습니다.') } @@ -167,24 +187,16 @@ export const useResetPassword = ({ onSuccess, onError }: UseResetPasswordProps) } finally { controllerRef.current = null } - }, [formData.email, memoizedOnError, memoizedOnSuccess]) + }, [formData.email, memoizedOnError, memoizedOnSuccess, startCountdown]) useEffect(() => { - if (countdown > 0) { - timerRef.current = setInterval(() => { - setCountdown((prev) => prev - 1) - }, 1000) - } else if (timerRef.current) { - clearInterval(timerRef.current) - timerRef.current = null - } - return () => { if (timerRef.current) { clearInterval(timerRef.current) + timerRef.current = null } } - }, [countdown]) + }, []) const formatCountdown = () => { const minutes = Math.floor(countdown / 60) @@ -217,6 +229,7 @@ export const useResetPassword = ({ onSuccess, onError }: UseResetPasswordProps) memoizedOnSuccess('') memoizedOnError('') + setStep(1) }, [memoizedOnSuccess, memoizedOnError]) useEffect(() => { @@ -243,7 +256,6 @@ export const useResetPassword = ({ onSuccess, onError }: UseResetPasswordProps) handleVerifyEmail, handlePasswordReset, handleNextStep, - setStep, resetForm, } } diff --git a/shared/ui/modal/find-password-modal/index.tsx b/shared/ui/modal/find-password-modal/index.tsx index 8fd12570..50e77019 100644 --- a/shared/ui/modal/find-password-modal/index.tsx +++ b/shared/ui/modal/find-password-modal/index.tsx @@ -37,7 +37,6 @@ const FindPasswordModal = ({ isOpen, onClose }: Props) => { handleVerifyEmail, handlePasswordReset, handleNextStep, - setStep, resetForm, } = useResetPassword({ onSuccess: (message) => { @@ -55,9 +54,8 @@ const FindPasswordModal = ({ isOpen, onClose }: Props) => { resetForm() setNotice('') setIsSuccess(false) - setStep(1) } - }, [isOpen, resetForm, setStep]) + }, [isOpen]) return ( <Modal From c05d664c51a85ef974cd7c043a3c3c3cdca51860 Mon Sep 17 00:00:00 2001 From: deun <devdeun@gmail.com> Date: Fri, 28 Mar 2025 17:54:50 +0900 Subject: [PATCH 205/207] =?UTF-8?q?feat:=20GOogle=20Analytics=20=EC=97=B0?= =?UTF-8?q?=EB=8F=99=20(#189)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- app/layout.tsx | 3 +++ package-lock.json | 18 ++++++++++++++++++ package.json | 1 + 3 files changed, 22 insertions(+) diff --git a/app/layout.tsx b/app/layout.tsx index df550b1f..cc0bfa17 100644 --- a/app/layout.tsx +++ b/app/layout.tsx @@ -1,5 +1,7 @@ import type { Metadata } from 'next' +import { GoogleAnalytics } from '@next/third-parties/google' + import { QueryProvider } from '@/shared/providers' import { AuthProvider } from '@/shared/providers/auth-provider' import '@/shared/styles/global.scss' @@ -22,6 +24,7 @@ const RootLayout = ({ children }: { children: React.ReactNode }) => { <div id="modal-root" /> </AuthProvider> </QueryProvider> + <GoogleAnalytics gaId="G-KW2Z1H0QES" /> </body> </html> ) diff --git a/package-lock.json b/package-lock.json index 63ebbfdc..4c69c5a0 100644 --- a/package-lock.json +++ b/package-lock.json @@ -8,6 +8,7 @@ "name": "investmetic", "version": "0.1.0", "dependencies": { + "@next/third-parties": "^15.2.4", "@tanstack/react-query": "^5.59.19", "axios": "^1.7.7", "classnames": "^2.5.1", @@ -3397,6 +3398,18 @@ "node": ">= 10" } }, + "node_modules/@next/third-parties": { + "version": "15.2.4", + "resolved": "https://registry.npmjs.org/@next/third-parties/-/third-parties-15.2.4.tgz", + "integrity": "sha512-a8GlPnMmPymxyLOiSnh5InUsG/hw7wjU3munGoHNB+oLCPruAeoplBa9Uf/xE83WMyutyK4cbi5Ixu4uyh96Mw==", + "dependencies": { + "third-party-capital": "1.0.20" + }, + "peerDependencies": { + "next": "^13.0.0 || ^14.0.0 || ^15.0.0", + "react": "^18.2.0 || 19.0.0-rc-de68d2f4-20241204 || ^19.0.0" + } + }, "node_modules/@nodelib/fs.scandir": { "version": "2.1.5", "resolved": "https://registry.npmjs.org/@nodelib/fs.scandir/-/fs.scandir-2.1.5.tgz", @@ -14486,6 +14499,11 @@ "dev": true, "license": "MIT" }, + "node_modules/third-party-capital": { + "version": "1.0.20", + "resolved": "https://registry.npmjs.org/third-party-capital/-/third-party-capital-1.0.20.tgz", + "integrity": "sha512-oB7yIimd8SuGptespDAZnNkzIz+NWaJCu2RMsbs4Wmp9zSDUM8Nhi3s2OOcqYuv3mN4hitXc8DVx+LyUmbUDiA==" + }, "node_modules/timers-browserify": { "version": "2.0.12", "resolved": "https://registry.npmjs.org/timers-browserify/-/timers-browserify-2.0.12.tgz", diff --git a/package.json b/package.json index cb4a612d..6e95f0a9 100644 --- a/package.json +++ b/package.json @@ -14,6 +14,7 @@ "deploy-storybook": "npx chromatic --project-token $CHROMATIC_PROJECT_TOKEN" }, "dependencies": { + "@next/third-parties": "^15.2.4", "@tanstack/react-query": "^5.59.19", "axios": "^1.7.7", "classnames": "^2.5.1", From dd7f8323c6c8cf51bfb9826f6b307130335bb0e6 Mon Sep 17 00:00:00 2001 From: deun <devdeun@gmail.com> Date: Wed, 9 Apr 2025 12:40:41 +0900 Subject: [PATCH 206/207] =?UTF-8?q?design:=20=EC=8B=A4=EA=B3=84=EC=A2=8C?= =?UTF-8?q?=20=EC=9D=B4=EB=AF=B8=EC=A7=80=20width=20=EC=88=98=EC=A0=95=20(?= =?UTF-8?q?#191)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 기존 medium(600px)에서 max로 수정 - max-width가 1000px인 max 클래스 추가 --- shared/ui/modal/account-image-modal.tsx | 2 +- shared/ui/modal/styles.module.scss | 4 ++++ 2 files changed, 5 insertions(+), 1 deletion(-) diff --git a/shared/ui/modal/account-image-modal.tsx b/shared/ui/modal/account-image-modal.tsx index 723a1a23..ea085126 100644 --- a/shared/ui/modal/account-image-modal.tsx +++ b/shared/ui/modal/account-image-modal.tsx @@ -17,7 +17,7 @@ interface Props { const AccountImageModal = ({ isOpen, title, url, onClose }: Props) => { return ( - <Modal isOpen={isOpen} message={title} className={cx('medium')}> + <Modal isOpen={isOpen} message={title} className={cx('max')}> <div className={cx('image')}> <Image src={url} alt={'imageData.title'} fill sizes="100%" /> </div> diff --git a/shared/ui/modal/styles.module.scss b/shared/ui/modal/styles.module.scss index dcda2eb6..9be6de1e 100644 --- a/shared/ui/modal/styles.module.scss +++ b/shared/ui/modal/styles.module.scss @@ -40,6 +40,10 @@ &.big { width: 800px; } + &.max { + width: 100%; + max-width: 1000px; + } } .icon { From 411764f4a8d53dda1e725e940c516d8908bf32ea Mon Sep 17 00:00:00 2001 From: deun <devdeun@gmail.com> Date: Tue, 12 Aug 2025 17:44:30 +0900 Subject: [PATCH 207/207] =?UTF-8?q?fix:=20=ED=8C=8C=EC=9D=BC=EB=AA=85=20?= =?UTF-8?q?=EC=98=81=EC=96=B4=EB=A1=9C=20=EB=B3=80=EA=B2=BD?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../files/upload-guide.xls | Bin .../form/excel-upload-form.tsx | 2 +- 2 files changed, 1 insertion(+), 1 deletion(-) rename "public/files/\354\227\221\354\205\200\354\227\205\353\241\234\353\223\234\354\204\244\353\252\205.xls" => public/files/upload-guide.xls (100%) diff --git "a/public/files/\354\227\221\354\205\200\354\227\205\353\241\234\353\223\234\354\204\244\353\252\205.xls" b/public/files/upload-guide.xls similarity index 100% rename from "public/files/\354\227\221\354\205\200\354\227\205\353\241\234\353\223\234\354\204\244\353\252\205.xls" rename to public/files/upload-guide.xls diff --git a/shared/ui/modal/analysis-upload-modal/form/excel-upload-form.tsx b/shared/ui/modal/analysis-upload-modal/form/excel-upload-form.tsx index af8ac0be..8f518a26 100644 --- a/shared/ui/modal/analysis-upload-modal/form/excel-upload-form.tsx +++ b/shared/ui/modal/analysis-upload-modal/form/excel-upload-form.tsx @@ -69,7 +69,7 @@ const ExcelUploadForm = ({ strategyId, onClose }: Props) => { </button> </label> <Button variant="outline" className={cx('guide-button')} disabled={isLoading}> - <a href="/files/엑셀업로드설명.xls" download="엑셀업로드설명.xls"> + <a href="/files/upload-guide.xls" download="엑셀업로드설명.xls"> 업로드 가이드 다운 </a> </Button>