diff --git a/app/(dashboard)/strategies/[strategyId]/page.tsx b/app/(dashboard)/strategies/[strategyId]/page.tsx index e733e5bf..605d948a 100644 --- a/app/(dashboard)/strategies/[strategyId]/page.tsx +++ b/app/(dashboard)/strategies/[strategyId]/page.tsx @@ -34,7 +34,7 @@ const StrategyDetailPage = ({ params }: { params: { strategyId: string } }) => { {detailsInformationData && } - + {hasDetailsSideData?.[0] && detailsSideData?.map((data, idx) => ( diff --git a/app/(dashboard)/strategies/_ui/search-bar/_hooks/use-accordion-button.ts b/app/(dashboard)/strategies/_ui/search-bar/_hooks/use-accordion-button.ts new file mode 100644 index 00000000..bf65d5df --- /dev/null +++ b/app/(dashboard)/strategies/_ui/search-bar/_hooks/use-accordion-button.ts @@ -0,0 +1,18 @@ +import { useRef, useState } from 'react' + +export interface ButtonIdStateModel { + [key: string]: boolean +} + +const useAccordionButton = () => { + const [openIds, setOpenIds] = useState(null) + const panelRef = useRef(null) + + const handleButtonIds = (id: string, isOpen: boolean) => { + setOpenIds((prev) => ({ ...prev, [id]: isOpen })) + } + + return { panelRef, openIds, handleButtonIds } +} + +export default useAccordionButton diff --git a/app/(dashboard)/strategies/_ui/search-bar/_hooks/use-accordion-context.ts b/app/(dashboard)/strategies/_ui/search-bar/_hooks/use-accordion-context.ts new file mode 100644 index 00000000..49707f96 --- /dev/null +++ b/app/(dashboard)/strategies/_ui/search-bar/_hooks/use-accordion-context.ts @@ -0,0 +1,11 @@ +import { useContext } from 'react' + +import { AccordionContext } from '../accordion-container' + +export const useAccordionContext = () => { + const context = useContext(AccordionContext) + if (!context) { + throw new Error('검색 메뉴 로드 실패') + } + return context +} 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 new file mode 100644 index 00000000..a9353af0 --- /dev/null +++ b/app/(dashboard)/strategies/_ui/search-bar/_store/use-searching-item-store.ts @@ -0,0 +1,89 @@ +import { create } from 'zustand' + +import { AlgorithmItemType, RangeModel, SearchTermsModel } from '../_type/search' +import { isRangeModel } from '../_utils/type-validate' + +interface StateModel { + searchTerms: SearchTermsModel + errOptions: (keyof SearchTermsModel)[] | null +} + +interface ActionModel { + setAlgorithm: (algorithm: AlgorithmItemType) => void + setPanelItem: (key: keyof SearchTermsModel, item: string) => void + setRangeValue: (key: keyof SearchTermsModel, type: keyof RangeModel, value: number) => void + resetState: () => void + validateRangeValues: () => void +} + +interface ActionsModel { + actions: ActionModel +} + +const initialState = { + searchWord: null, + tradeTypeNames: null, + operationCycles: null, + stockTypeNames: null, + durations: null, + profitRanges: null, + principalRange: null, + mddRange: null, + smScoreRange: null, + algorithmType: null, +} + +const useSearchingItemStore = create((set, get) => ({ + searchTerms: { + ...initialState, + }, + errOptions: [], + + actions: { + setAlgorithm: (algorithm) => + set((state) => ({ + searchTerms: { ...state.searchTerms, algorithmType: algorithm }, + })), + + setPanelItem: (key, item) => + set((state) => { + const currentItems = state.searchTerms[key] + if (Array.isArray(currentItems)) { + const updatedItems = currentItems.includes(item) + ? currentItems.filter((i) => i !== item) + : [...currentItems, item] + return { searchTerms: { ...state.searchTerms, [key]: [...updatedItems] } } + } + return { searchTerms: { ...state.searchTerms, [key]: [item] } } + }), + + setRangeValue: (key, type, value) => + set((state) => ({ + searchTerms: { + ...state.searchTerms, + [key]: { ...(state.searchTerms[key] as RangeModel), [type]: value }, + }, + })), + + resetState: () => set(() => ({ searchTerms: { ...initialState }, errOptions: [] })), + + validateRangeValues: () => { + const { searchTerms } = get() + const rangeOptions: (keyof SearchTermsModel)[] = [ + 'principalRange', + 'mddRange', + 'smScoreRange', + ] + const errOptions = rangeOptions.filter((option) => { + const value = searchTerms[option] + if (value !== null && isRangeModel(value)) { + return value.min > value.max + } + return false + }) + set({ errOptions }) + }, + }, +})) + +export default useSearchingItemStore diff --git a/app/(dashboard)/strategies/_ui/search-bar/_type/search.ts b/app/(dashboard)/strategies/_ui/search-bar/_type/search.ts new file mode 100644 index 00000000..3bacc688 --- /dev/null +++ b/app/(dashboard)/strategies/_ui/search-bar/_type/search.ts @@ -0,0 +1,19 @@ +export type AlgorithmItemType = 'EFFICIENT_STRATEGY' | 'ATTACK_STRATEGY' | 'DEFENSIVE_STRATE' + +export interface SearchTermsModel { + searchWord: string | null + tradeTypeNames: string[] | null + operationCycles: string[] | null + stockTypeNames: string[] | null + durations: string[] | null + profitRanges: string[] | null + principalRange: RangeModel | null + mddRange: RangeModel | null + smScoreRange: RangeModel | null + algorithmType: AlgorithmItemType | null +} + +export interface RangeModel { + min: number + max: number +} diff --git a/app/(dashboard)/strategies/_ui/search-bar/_utils/type-validate.ts b/app/(dashboard)/strategies/_ui/search-bar/_utils/type-validate.ts new file mode 100644 index 00000000..7f71ad9f --- /dev/null +++ b/app/(dashboard)/strategies/_ui/search-bar/_utils/type-validate.ts @@ -0,0 +1,12 @@ +import { RangeModel } from '../_type/search' + +export const isRangeModel = (value: unknown): value is RangeModel => { + return ( + typeof value === 'object' && + value !== null && + 'min' in value && + 'max' in value && + typeof (value as RangeModel).min === 'number' && + typeof (value as RangeModel).max === 'number' + ) +} diff --git a/app/(dashboard)/strategies/_ui/search-bar/accordion-button.tsx b/app/(dashboard)/strategies/_ui/search-bar/accordion-button.tsx new file mode 100644 index 00000000..78752772 --- /dev/null +++ b/app/(dashboard)/strategies/_ui/search-bar/accordion-button.tsx @@ -0,0 +1,49 @@ +'use client' + +import { useContext } from 'react' + +import { CloseIcon, OpenIcon } from '@/public/icons' +import classNames from 'classnames/bind' + +import useSearchingItemStore from './_store/use-searching-item-store' +import { SearchTermsModel } from './_type/search' +import { AccordionContext } from './accordion-container' +import styles from './styles.module.scss' + +const cx = classNames.bind(styles) + +interface Props { + optionId: keyof SearchTermsModel + title: string + size?: number +} + +const AccordionButton = ({ optionId, title, size }: Props) => { + const { openIds, handleButtonIds } = useContext(AccordionContext) + const searchTerms = useSearchingItemStore((state) => state.searchTerms) + + const hasOpenId = openIds?.[optionId] + const clickedValue = searchTerms[optionId] + + return ( +
+ +
+ ) +} + +export default AccordionButton diff --git a/app/(dashboard)/strategies/_ui/search-bar/accordion-container.tsx b/app/(dashboard)/strategies/_ui/search-bar/accordion-container.tsx new file mode 100644 index 00000000..53b6246c --- /dev/null +++ b/app/(dashboard)/strategies/_ui/search-bar/accordion-container.tsx @@ -0,0 +1,43 @@ +'use client' + +import { createContext } from 'react' + +import useAccordionButton, { ButtonIdStateModel } from './_hooks/use-accordion-button' +import { SearchTermsModel } from './_type/search' +import AccordionButton from './accordion-button' +import AccordionPanel from './accordion-panel' + +interface AccordionContextModel { + panelRef: React.RefObject + openIds: ButtonIdStateModel | null + handleButtonIds: (id: string, open: boolean) => void +} + +const initialState: AccordionContextModel = { + panelRef: { current: null }, + openIds: null, + handleButtonIds: () => {}, +} + +export const AccordionContext = createContext(initialState) + +interface Props { + optionId: keyof SearchTermsModel + title: string + panels?: string[] +} + +const AccordionContainer = ({ optionId, title, panels }: Props) => { + const { openIds, panelRef, handleButtonIds } = useAccordionButton() + + return ( + +
+ + +
+
+ ) +} + +export default AccordionContainer diff --git a/app/(dashboard)/strategies/_ui/search-bar/accordion-panel.tsx b/app/(dashboard)/strategies/_ui/search-bar/accordion-panel.tsx new file mode 100644 index 00000000..249f9bad --- /dev/null +++ b/app/(dashboard)/strategies/_ui/search-bar/accordion-panel.tsx @@ -0,0 +1,79 @@ +'use client' + +import { useContext, useEffect, useState } from 'react' + +import { CheckedCircleIcon } from '@/public/icons' +import classNames from 'classnames/bind' + +import useSearchingItemStore from './_store/use-searching-item-store' +import { SearchTermsModel } from './_type/search' +import { AccordionContext } from './accordion-container' +import RangeContainer from './range-container' +import styles from './styles.module.scss' + +/* eslint-disable react-hooks/exhaustive-deps */ + +const cx = classNames.bind(styles) + +interface Props { + optionId: keyof SearchTermsModel + panels?: string[] +} + +const AccordionPanel = ({ optionId, panels }: Props) => { + const { openIds, panelRef } = useContext(AccordionContext) + const [panelHeight, setPanelHeight] = useState(null) + const [isClose, setIsClose] = useState(false) + const searchTerms = useSearchingItemStore((state) => state.searchTerms) + const { setPanelItem } = useSearchingItemStore((state) => state.actions) + + useEffect(() => { + if (panelRef.current && hasOpenId) { + const panelHeight = panelRef.current.clientHeight + 32 * (panels?.length || 1) + setPanelHeight(panelHeight) + panelRef.current.style.setProperty('--panel-height', `${panelHeight}px`) + } + + if (!hasOpenId) { + setIsClose(true) + const timeout = setTimeout(() => { + setIsClose(false) + setPanelHeight(null) + }, 300) + return () => clearTimeout(timeout) + } + }, [openIds, panelRef, optionId]) + + const hasOpenId = openIds?.[optionId] + const clickedValue = searchTerms[optionId] + + return ( + <> + {hasOpenId !== undefined && ( +
+ {panels + ? hasOpenId && + panels?.map((panel, idx) => ( + + )) + : hasOpenId && } +
+ )} + + ) +} + +export default AccordionPanel diff --git a/app/(dashboard)/strategies/_ui/search-bar/algorithm-item.tsx b/app/(dashboard)/strategies/_ui/search-bar/algorithm-item.tsx new file mode 100644 index 00000000..10606e92 --- /dev/null +++ b/app/(dashboard)/strategies/_ui/search-bar/algorithm-item.tsx @@ -0,0 +1,27 @@ +'use client' + +import classNames from 'classnames/bind' + +import { AlgorithmItemType } from './_type/search' +import styles from './styles.module.scss' + +const cx = classNames.bind(styles) + +interface Props { + name: AlgorithmItemType + clickedAlgorithm: AlgorithmItemType | null + onChange: (algorithm: AlgorithmItemType) => void +} + +const AlgorithmItem = ({ name, clickedAlgorithm, onChange }: Props) => { + return ( + + ) +} + +export default AlgorithmItem diff --git a/app/(dashboard)/strategies/_ui/search-bar/index.tsx b/app/(dashboard)/strategies/_ui/search-bar/index.tsx new file mode 100644 index 00000000..6633f981 --- /dev/null +++ b/app/(dashboard)/strategies/_ui/search-bar/index.tsx @@ -0,0 +1,94 @@ +'use client' + +import { useState } from 'react' + +import classNames from 'classnames/bind' + +import { Button } from '@/shared/ui/button' +import { SearchInput } from '@/shared/ui/search-input' + +import useSearchingItemStore from './_store/use-searching-item-store' +import { SearchTermsModel } from './_type/search' +import AccordionContainer from './accordion-container' +import AlgorithmItem from './algorithm-item' +import SearchBarTab from './search-bar-tab' +import styles from './styles.module.scss' + +const cx = classNames.bind(styles) + +const ALGORITHM_MENU = [ + { EFFICIENT_STRATEGY: '효율형 전략' }, + { ATTACK_STRATEGY: '공격형 전략' }, + { DEFENSIVE_STRATEGY: '방어형 전략' }, +] +interface AccordionMenuDataModel { + id: keyof SearchTermsModel + title: string + panels?: string[] +} +const SearchBarContainer = () => { + const [isMainTab, setIsMainTab] = useState(true) + const searchTerms = useSearchingItemStore((state) => state.searchTerms) + const { setAlgorithm, resetState, validateRangeValues } = useSearchingItemStore( + (state) => state.actions + ) + + const ACCORDION_MENU: AccordionMenuDataModel[] = [ + { id: 'tradeTypeNames', title: '매매 유형', panels: ['수동', '자동', '반자동'] }, + { id: 'operationCycles', title: '운용 주기', panels: ['데이', '포지션'] }, + { id: 'stockTypeNames', title: '운영 종목', panels: ['선물', '해외', '국내'] }, + { id: 'durations', title: '기간', panels: ['1년 이하', '1년 ~ 2년', '2년 ~ 3년', '3년 이상'] }, + { + id: 'profitRanges', + title: '수익률', + panels: ['10% 이하', '10% ~ 20%', '20% ~ 30%', '30% 이상'], + }, + { id: 'principalRange', title: '원금' }, + { id: 'mddRange', title: 'MDD' }, + { id: 'smScoreRange', title: 'SM SCORE' }, + ] + + return ( + <> +
+ +
+
+ + {isMainTab + ? ACCORDION_MENU.map((menu) => ( + + )) + : ALGORITHM_MENU.map((menu) => { + return Object.entries(menu).map(([key, value]) => ( + + )) + })} +
+ + +
+
+ + ) +} + +export default SearchBarContainer diff --git a/app/(dashboard)/strategies/_ui/search-bar/range-container.tsx b/app/(dashboard)/strategies/_ui/search-bar/range-container.tsx new file mode 100644 index 00000000..f83ea5f5 --- /dev/null +++ b/app/(dashboard)/strategies/_ui/search-bar/range-container.tsx @@ -0,0 +1,46 @@ +'use client' + +import classNames from 'classnames/bind' + +import useSearchingItemStore from './_store/use-searching-item-store' +import { SearchTermsModel } from './_type/search' +import styles from './styles.module.scss' + +const cx = classNames.bind(styles) + +interface Props { + optionId: keyof SearchTermsModel +} + +const RangeContainer = ({ optionId }: Props) => { + const errOptions = useSearchingItemStore((state) => state.errOptions) + const { setRangeValue } = useSearchingItemStore((state) => state.actions) + + const handleRangeValue = (e: React.ChangeEvent, type: 'min' | 'max') => { + const value = Number(e.target.value) + setRangeValue(optionId, type, value) + } + + return ( +
+
+ handleRangeValue(e, 'min')} + /> + ~ + handleRangeValue(e, 'max')} + /> +
+ {errOptions?.includes(optionId) &&

최소 값은 최대 값보다 작아야합니다.

} +
+ ) +} + +export default RangeContainer diff --git a/app/(dashboard)/strategies/_ui/search-bar/search-bar-tab.tsx b/app/(dashboard)/strategies/_ui/search-bar/search-bar-tab.tsx new file mode 100644 index 00000000..a2c98b6a --- /dev/null +++ b/app/(dashboard)/strategies/_ui/search-bar/search-bar-tab.tsx @@ -0,0 +1,33 @@ +import classNames from 'classnames/bind' + +import { Button } from '@/shared/ui/button' + +import styles from './styles.module.scss' + +const cx = classNames.bind(styles) + +interface Props { + isMainTab: boolean + onChangeTab: (isMainTab: boolean) => void +} + +const SearchBarTab = ({ isMainTab, onChangeTab }: Props) => { + return ( +
+ + +
+ ) +} + +export default SearchBarTab diff --git a/app/(dashboard)/strategies/_ui/search-bar/styles.module.scss b/app/(dashboard)/strategies/_ui/search-bar/styles.module.scss new file mode 100644 index 00000000..fe548f66 --- /dev/null +++ b/app/(dashboard)/strategies/_ui/search-bar/styles.module.scss @@ -0,0 +1,185 @@ +@mixin item-align { + display: flex; + justify-content: space-between; +} + +.searchInput-wrapper { + background-color: $color-white; + padding: 20px; + border: 5px; + margin-bottom: 10px; +} + +.search-button-wrapper { + @include item-align; + margin-top: 20px; + .button { + height: 40px; + &.initialize { + width: 90px; + padding: 0; + } + &.searching { + width: 140px; + } + } +} + +.tab-container { + @include item-align; + margin-bottom: 20px; + .button { + border: 0; + width: 118px; + height: 48px; + &.main-on { + background-color: $color-orange-500; + color: $color-white; + } + &.main-off { + background-color: transparent; + color: $color-gray-700; + } + } +} + +.algorithm-button { + width: 100%; + padding: 10.8px 20px; + margin-bottom: 5px; + border-radius: 5px; + background-color: transparent; + box-shadow: 0px 1px 3px rgba(0, 0, 0, 0.25); + @include typo-b3; + color: $color-gray-600; + &:hover { + background-color: $color-orange-100; + } + &.active { + background-color: $color-orange-600; + color: $color-white; + } +} + +.accordion-button, +.panel-wrapper { + padding: 2px 0; + margin-bottom: 5px; + border-radius: 5px; + overflow: hidden; + button { + @include item-align; + width: 100%; + padding: 4px 20px; + align-items: center; + background-color: transparent; + } +} + +.accordion-button { + border: 1px solid $color-gray-200; + background-color: $color-gray-100; + button { + p { + @include typo-c1; + color: $color-gray-800; + span { + color: $color-orange-500; + margin-left: 4px; + } + } + svg { + width: 26px; + path { + fill: #171717; + } + } + } + &:hover { + border: 1px solid $color-orange-300; + } + &.active { + border: 1px solid $color-orange-500; + box-shadow: 0px 0px 2px rgba(255, 119, 82, 1); + } +} + +.panel-wrapper { + display: none; + box-shadow: 0px 1px 3px rgba(0, 0, 0, 0.25); + button { + p { + @include typo-c1; + color: $color-gray-600; + } + svg, + svg circle { + width: 24px; + .checked { + fill: $color-orange-600; + } + } + &.active { + svg, + svg circle { + fill: $color-orange-600; + } + } + &:hover { + background-color: $color-orange-100; + } + } + &.open { + display: block; + animation: accordionDown 0.3s cubic-bezier(0.2, 0.2, 0.2, 0.6); + } + &.close { + display: block; + animation: accordionUp 0.3s cubic-bezier(0.2, 0.2, 0.2, 0.6); + } + .range-container { + padding: 4px 20px; + p { + @include typo-c1; + color: $color-orange-800; + margin-top: 2px; + } + .range-wrapper { + @include item-align; + @include typo-c1; + align-items: center; + .range { + width: 80px; + height: 24px; + border-radius: 2px; + border: 1px solid $color-gray-300; + padding: 0 4px; + &::-webkit-outer-spin-button, + &::-webkit-inner-spin-button { + display: none; + } + } + span { + color: $color-gray-600; + } + } + } +} + +@keyframes accordionDown { + from { + height: 0; + } + to { + height: var(--panel-height); + } +} + +@keyframes accordionUp { + from { + height: var(--panel-height); + } + to { + height: 0; + } +} diff --git a/app/(dashboard)/strategies/_ui/side-container/index.tsx b/app/(dashboard)/strategies/_ui/side-container/index.tsx index 3bc6e425..59939156 100644 --- a/app/(dashboard)/strategies/_ui/side-container/index.tsx +++ b/app/(dashboard)/strategies/_ui/side-container/index.tsx @@ -5,11 +5,12 @@ import styles from './styles.module.scss' const cx = classNames.bind(styles) interface Props { + isFixed?: boolean children: React.ReactNode } -const SideContainer = ({ children }: Props) => { - return +const SideContainer = ({ isFixed = false, children }: Props) => { + return } export default SideContainer diff --git a/app/(dashboard)/strategies/_ui/side-container/styles.module.scss b/app/(dashboard)/strategies/_ui/side-container/styles.module.scss index 092744ff..294c8355 100644 --- a/app/(dashboard)/strategies/_ui/side-container/styles.module.scss +++ b/app/(dashboard)/strategies/_ui/side-container/styles.module.scss @@ -1,6 +1,10 @@ .side-bar { width: $strategy-sidebar-width; - position: fixed; - right: 28px; + position: absolute; + right: 0px; top: 130px; + &.fixed { + position: fixed; + right: 28px; + } } diff --git a/app/(dashboard)/strategies/page.tsx b/app/(dashboard)/strategies/page.tsx index d8d768c7..eebaff44 100644 --- a/app/(dashboard)/strategies/page.tsx +++ b/app/(dashboard)/strategies/page.tsx @@ -4,6 +4,7 @@ import classNames from 'classnames/bind' import Title from '@/shared/ui/title' +import SearchBarContainer from './_ui/search-bar' import SideContainer from './_ui/side-container' import StrategyList from './_ui/strategy-list' import styles from './page.module.scss' @@ -18,7 +19,7 @@ const StrategiesPage = () => { -

Search-Bar

+
)