diff --git a/src/@types/models/getSearch.ts b/src/@types/models/getSearch.ts new file mode 100644 index 0000000..e26f213 --- /dev/null +++ b/src/@types/models/getSearch.ts @@ -0,0 +1,30 @@ +import { AssetGroupSymbol, AssetTypeSymbol } from "./assetSymbol"; +import { CategoryTypeSymbol } from "./categoryTypeSymbol"; + +/** + * 검색 조회 Response Interface + */ + +export type GetSearchItems = GetSearchItem[]; + +export type GetSearchItem = { + accountId: number; + categoryName: string; + assetGroup: AssetGroupSymbol; + assetType: AssetTypeSymbol; + assetName: string; + amount: number; + transactionType: CategoryTypeSymbol; + transactionDetail: string; + transactedAt: string; + address: string; + memo: string; + imageIds: number[]; + recurringType: string; + isInstallment: boolean; + installmentMonth: number; + createdAt: string; + updatedAt: string; + latitude: string; + longitude: string; +}; diff --git a/src/components/Common/DayIncomeExpenseInfo/index.tsx b/src/components/Common/DayIncomeExpenseInfo/index.tsx index 682f4bb..711d2bd 100644 --- a/src/components/Common/DayIncomeExpenseInfo/index.tsx +++ b/src/components/Common/DayIncomeExpenseInfo/index.tsx @@ -1,11 +1,12 @@ +import { GetSearchItem } from "@/src/@types/models/getSearch"; import { theme } from "../../../assets/theme"; import { AccountsData } from "../DayIncomeExpenseInfos"; import { DayIncomeExpenseInfoUI } from "./style"; import { ChangeNumberForAccounting, ChangeTime } from "@/src/assets/util"; interface DayIncomeExpenseInfoProps { - onClick: (item: AccountsData) => void; - item: AccountsData; + onClick: (item: AccountsData | GetSearchItem) => void; + item: AccountsData | GetSearchItem; } function DayIncomeExpenseInfo({ onClick, item }: DayIncomeExpenseInfoProps) { diff --git a/src/components/Common/DayIncomeExpenseInfos/index.tsx b/src/components/Common/DayIncomeExpenseInfos/index.tsx index a00f9d4..eee5d73 100644 --- a/src/components/Common/DayIncomeExpenseInfos/index.tsx +++ b/src/components/Common/DayIncomeExpenseInfos/index.tsx @@ -1,4 +1,9 @@ +import { useRecoilState } from "recoil"; import DayIncomeExpenseInfo from "../DayIncomeExpenseInfo"; +import { searchResultDataAtom } from "@/src/hooks/recoil/searchResultData"; +import { searchKeywordClickedState } from "@/src/hooks/recoil/searchKeywordClickedState"; +import { GetSearchItem } from "@/src/@types/models/getSearch"; +import { DayIncomeExpenseInfosUI } from "./style"; export interface AccountsData { accountId: number; @@ -56,14 +61,29 @@ function DayIncomeExpenseInfos() { }, ]; - const eventHandler = (item: AccountsData) => { + const [searchResultData] = useRecoilState(searchResultDataAtom); + const [searchKeywordClicked] = useRecoilState(searchKeywordClickedState); + + const eventHandler = (item: AccountsData | GetSearchItem) => { console.log("item: ", item); }; return ( - + ); } diff --git a/src/components/Common/DayIncomeExpenseInfos/style.ts b/src/components/Common/DayIncomeExpenseInfos/style.ts new file mode 100644 index 0000000..9e2581c --- /dev/null +++ b/src/components/Common/DayIncomeExpenseInfos/style.ts @@ -0,0 +1,20 @@ +import styled from "@emotion/styled"; + +const ContainerUl = styled.ul` + width: 100%; + height: 100%; + overflow: auto; +`; + +const Nothing = styled.div` + position: absolute; + left: 50%; + top: 50%; + transform: translate(-50%, 50%); + display: flex; + justify-content: center; + align-itmes: center; + font-size: 13px; +`; + +export const DayIncomeExpenseInfosUI = { ContainerUl, Nothing } as const; diff --git a/src/components/SearchInput/index.tsx b/src/components/SearchInput/index.tsx index a2ec4ec..fc6c593 100644 --- a/src/components/SearchInput/index.tsx +++ b/src/components/SearchInput/index.tsx @@ -1,35 +1,85 @@ import SearchSVG from "@/public/icon/Search.svg"; import CancleSVG from "@/public/icon/Cancle.svg"; import { SearchInputUI } from "./style"; -import { ChangeEvent, useState } from "react"; +import { ChangeEvent, useEffect, useState } from "react"; +import { AccountBookAPI } from "@/src/core/api/accountBook"; +import { useRecoilState } from "recoil"; +import { + autoCompleteAtom, + getSearchAtom, +} from "@/src/hooks/recoil/useGetSearch"; +import { autoCompleteDatasAtom } from "@/src/hooks/recoil/autoCompleteResults"; +import { showKeywordResultsaAtom } from "@/src/hooks/recoil/showKeywordResultsState"; function SearchInput() { const [showCancleButton, setShowCancleButton] = useState(false); - const [keyword, setKeyword] = useState(""); - const handleFocus = () => { - setShowCancleButton(true); - }; + const [autoKeyword, setAutoKeyword] = useRecoilState(autoCompleteAtom); + const [, setAutoCompleteDatas] = useRecoilState(autoCompleteDatasAtom); + const [, setShowKeywordResults] = useRecoilState(showKeywordResultsaAtom); + const [getSearchParams, setGetSearchParams] = useRecoilState(getSearchAtom); const handleChange = (e: ChangeEvent) => { - const newValue = e.currentTarget.value; + const newValue = e.target.value; console.log("new value: ", newValue); - setKeyword(newValue); + + setAutoKeyword(prev => ({ + ...prev, + query: newValue, + })); + + if (autoKeyword.query.length >= 0) { + setShowCancleButton(true); + } else if (autoKeyword.query.length <= 0) { + setShowCancleButton(false); + } }; const handleCancleButtonClick = () => { - setKeyword(""); + setAutoKeyword(prev => ({ + ...prev, + query: "", + })); + setGetSearchParams(prev => ({ + ...prev, + categoryName: "", + })); }; + useEffect(() => { + const debounce = setTimeout(() => { + const getAutoCompleteDatas = async () => { + try { + const response = await AccountBookAPI.getAutocomplete( + autoKeyword.limit, + autoKeyword.query + ); + if (response.status === 200) { + console.log("자동완성 검색 성공: ", response.data); + setAutoCompleteDatas(response.data); + setShowKeywordResults(true); + } + } catch (error) { + console.log("자동완성 검색 실패: ", error); + } + }; + + if (autoKeyword.query.length > 0) getAutoCompleteDatas(); + }, 200); + + return () => { + clearTimeout(debounce); + }; + }, [autoKeyword.query, setAutoCompleteDatas]); + return ( 돋보기 {showCancleButton && ( diff --git a/src/components/SearchKeywordResults/index.tsx b/src/components/SearchKeywordResults/index.tsx new file mode 100644 index 0000000..6b3d6a3 --- /dev/null +++ b/src/components/SearchKeywordResults/index.tsx @@ -0,0 +1,73 @@ +import { useRecoilState } from "recoil"; +import { SearchKeywordResultsUI } from "./style"; +import { + autoCompleteAtom, + getSearchAtom, +} from "@/src/hooks/recoil/useGetSearch"; +import { autoCompleteDatasAtom } from "@/src/hooks/recoil/autoCompleteResults"; +import { AccountBookAPI } from "@/src/core/api/accountBook"; +import { useEffect } from "react"; +import { searchResultDataAtom } from "@/src/hooks/recoil/searchResultData"; +import { showKeywordResultsaAtom } from "@/src/hooks/recoil/showKeywordResultsState"; +import { searchKeywordClickedState } from "@/src/hooks/recoil/searchKeywordClickedState"; + +function SearchKeywordResults() { + const [getSearchParams, setGetSearchParams] = useRecoilState(getSearchAtom); + const [autoKeyword] = useRecoilState(autoCompleteAtom); + const [autoCompleteDatas] = useRecoilState(autoCompleteDatasAtom); + const [, setSearchResultData] = useRecoilState(searchResultDataAtom); + const [showKeywordResults, setShowKeywordResults] = useRecoilState( + showKeywordResultsaAtom + ); + const [, setSearchKeywordClicked] = useRecoilState(searchKeywordClickedState); + + const handleKeywordClick = (item: string) => { + setGetSearchParams(prev => ({ + ...prev, + categoryName: item, + })); + setSearchKeywordClicked(true); + setShowKeywordResults(false); + }; + + useEffect(() => { + const serachData = async () => { + try { + const response = await AccountBookAPI.getSearch( + getSearchParams.categoryName + ); + if (response.status === 200) { + console.log("검색 성공: ", response.data); + setSearchResultData(response.data); + } + } catch (error) { + console.log("getSearch Error: ", error); + } + }; + + serachData(); + }, [getSearchParams.categoryName]); + + return ( + <> + {autoKeyword.query.length > 0 && + autoCompleteDatas && + showKeywordResults && ( + + {autoCompleteDatas.map((item, index) => ( +
  • handleKeywordClick(item)}> + + {item} + + + 카테고리 + +
  • + ))} +
    + )} + + ); +} + +export default SearchKeywordResults; diff --git a/src/components/SearchLists/style.ts b/src/components/SearchKeywordResults/style.ts similarity index 68% rename from src/components/SearchLists/style.ts rename to src/components/SearchKeywordResults/style.ts index 83998ba..a6f5d94 100644 --- a/src/components/SearchLists/style.ts +++ b/src/components/SearchKeywordResults/style.ts @@ -3,11 +3,13 @@ import { theme } from "@/src/assets/theme"; const containerWidth = "100% - 40px"; -const ContainerUl = styled.div` +const ContainerUl = styled.ul` width: calc(${containerWidth}); + max-height: 350px; ${theme.border_radius}; box-shadow: 0px 4px 4px 0px rgba(0, 0, 0, 0.25); background-color: ${theme.font_color.white}; + overflow: auto; position: absolute; top: 140px; // 77(hearder)+46+17 @@ -16,18 +18,18 @@ const ContainerUl = styled.div` li { height: 50px; + padding: 0 20px; + display: flex; + justify-content: space-between; + align-items: center; cursor: pointer; - // &:hover { - // background-color: rgba(25, 118, 210, 0.04); - // } + &:hover { + background-color: rgba(25, 118, 210, 0.04); - & > div:first-of-type { - height: inherit; - padding: 0 20px; - display: flex; - justify-content: space-between; - align-items: center; + & > div:first-of-type { + font-weight: bold; + } } } `; @@ -49,4 +51,4 @@ const Badge = styled.div` align-items: center; `; -export const SearchListsUI = { ContainerUl, Name, Badge } as const; +export const SearchKeywordResultsUI = { ContainerUl, Name, Badge } as const; diff --git a/src/components/SearchLists/index.tsx b/src/components/SearchLists/index.tsx deleted file mode 100644 index 898487e..0000000 --- a/src/components/SearchLists/index.tsx +++ /dev/null @@ -1,16 +0,0 @@ -import { SearchListsUI } from "./style"; - -function SearchLists() { - return ( - -
  • -
    - 카테고리 이름 - 카테고리 -
    -
  • -
    - ); -} - -export default SearchLists; diff --git a/src/components/SearchResults/index.tsx b/src/components/SearchResults/index.tsx index fbf98dd..3947db8 100644 --- a/src/components/SearchResults/index.tsx +++ b/src/components/SearchResults/index.tsx @@ -1,10 +1,11 @@ import DayIncomeExpenseInfos from "../Common/DayIncomeExpenseInfos"; +import { SearchResultsUI } from "./style"; function SearchResults() { return ( -
    + -
    + ); } diff --git a/src/components/SearchResults/style.ts b/src/components/SearchResults/style.ts index 9451ba4..de6c534 100644 --- a/src/components/SearchResults/style.ts +++ b/src/components/SearchResults/style.ts @@ -1,4 +1,11 @@ import styled from "@emotion/styled"; -import { theme } from "@/src/assets/theme"; +import { HeaderHeight, NavigationItemsHeight } from "@/src/assets/height"; -export const SearchResultsUI = {} as const; +const searchResutlsHeight = `100% - ${HeaderHeight}px - 80px - ${NavigationItemsHeight}px`; + +const Container = styled.div` + width: 100%; + height: calc(${searchResutlsHeight}); +`; + +export const SearchResultsUI = { Container } as const; diff --git a/src/core/api/accountBook.ts b/src/core/api/accountBook.ts index bc38ca4..48b3d96 100644 --- a/src/core/api/accountBook.ts +++ b/src/core/api/accountBook.ts @@ -112,19 +112,38 @@ export const AccountBookAPI = { /** COMPLETED: getSearch GET 요청하기 */ getSearch: ( categoryName: string, - endDate: string, - keyword: string, - limit: number, - maxPrice: number, - minPrice: number, - page: number, - startDate: string + endDate?: string, + keyword?: string, + maxPrice?: number, + minPrice?: number, + startDate?: string, + page?: number | undefined, + limit?: number | undefined ) => { - return APIInstance.get( - ACCOUNTS + - `/search?categoryName=${categoryName}&endDate=${endDate}&keyword=${keyword}&limit=${limit}&maxPrice=${maxPrice}&minPrice=${minPrice}&page=${page}&startDate=${startDate}` - ); + // 쿼리 스트링을 담을 객체 + const queryParams: Record = { + categoryName, + }; + + // 파라미터가 존재하면 값 추가 + if (page !== undefined) { + queryParams.page = page.toString(); + } + if (limit !== undefined) { + queryParams.limit = limit.toString(); + } + + // 객체를 쿼리 스트링으로 변환 + const queryString = Object.entries(queryParams) + .map(([key, value]) => `${key}=${encodeURIComponent(value)}`) + .join("&"); + + // 최종 URL 생성 + const url = `${ACCOUNTS}/search?${queryString}`; + + return APIInstance.get(url); }, + /** COMPLETED: getTransactionStatistics GET 요청하기 */ getTransactionStatistics: ( endDate: string, diff --git a/src/hooks/recoil/autoCompleteResults.ts b/src/hooks/recoil/autoCompleteResults.ts new file mode 100644 index 0000000..b31219d --- /dev/null +++ b/src/hooks/recoil/autoCompleteResults.ts @@ -0,0 +1,6 @@ +import { atom } from "recoil"; + +export const autoCompleteDatasAtom = atom({ + key: "autoCompleteResultsAtom", + default: [], +}); diff --git a/src/hooks/recoil/searchKeywordClickedState.ts b/src/hooks/recoil/searchKeywordClickedState.ts new file mode 100644 index 0000000..de616d7 --- /dev/null +++ b/src/hooks/recoil/searchKeywordClickedState.ts @@ -0,0 +1,6 @@ +import { atom } from "recoil"; + +export const searchKeywordClickedState = atom({ + key: "searchKeywodClickedState", + default: false, +}); diff --git a/src/hooks/recoil/searchResultData.ts b/src/hooks/recoil/searchResultData.ts new file mode 100644 index 0000000..e9b17fe --- /dev/null +++ b/src/hooks/recoil/searchResultData.ts @@ -0,0 +1,7 @@ +import { GetSearchItems } from "@/src/@types/models/getSearch"; +import { atom } from "recoil"; + +export const searchResultDataAtom = atom({ + key: "searchResultDataAtom", + default: [], +}); diff --git a/src/hooks/recoil/showKeywordResultsState.ts b/src/hooks/recoil/showKeywordResultsState.ts new file mode 100644 index 0000000..24cf41d --- /dev/null +++ b/src/hooks/recoil/showKeywordResultsState.ts @@ -0,0 +1,6 @@ +import { atom } from "recoil"; + +export const showKeywordResultsaAtom = atom({ + key: "showKeywordResultsaAtom", + default: false, +}); diff --git a/src/hooks/recoil/useGetSearch.ts b/src/hooks/recoil/useGetSearch.ts new file mode 100644 index 0000000..6ec14af --- /dev/null +++ b/src/hooks/recoil/useGetSearch.ts @@ -0,0 +1,43 @@ +import { atom } from "recoil"; + +export interface GetSearchAtomProps { + categoryName: string; + endDate: string; + keyword: string; + limit: number; + maxPrice: number; + minPrice: number; + page: number; + startDate: string; +} + +export interface AutoCompleteAtomProps { + limit: number; + query: string; +} + +const initialGetSearchAtom = { + categoryName: "", + endDate: "", + keyword: "", + limit: 1, + maxPrice: 0, + minPrice: 0, + page: 0, + startDate: "", +}; + +const getSearchAtom = atom({ + key: "getSearchAtom", + default: initialGetSearchAtom, +}); + +const autoCompleteAtom = atom({ + key: "autoCompleteAtom", + default: { + limit: 19, + query: "", + }, +}); + +export { initialGetSearchAtom, getSearchAtom, autoCompleteAtom }; diff --git a/src/pages/AccountPage/index.tsx b/src/pages/AccountPage/index.tsx index de643ad..22a1540 100644 --- a/src/pages/AccountPage/index.tsx +++ b/src/pages/AccountPage/index.tsx @@ -3,8 +3,16 @@ import ChosenYearMonth from "@/src/components/Common/ChosenYearMonth"; import FinancialSummary from "@/src/components/Common/FinancialSummary"; import FixedCircleButton from "@/src/components/Common/FixedCircleButton"; import Header from "@/src/components/Common/Header"; +import { useNavigate } from "react-router-dom"; function AccountPage() { + const navigate = useNavigate(); + + const handleSearchButton = () => { + navigate("/search"); + }; + const handleFilterButton = () => {}; + return ( <>
    diff --git a/src/pages/SearchPage/index.tsx b/src/pages/SearchPage/index.tsx index 8c297d0..8a510f8 100644 --- a/src/pages/SearchPage/index.tsx +++ b/src/pages/SearchPage/index.tsx @@ -1,6 +1,6 @@ import Header from "@/src/components/Common/Header"; import SearchInput from "@/src/components/SearchInput"; -import SearchLists from "@/src/components/SearchLists"; +import SearchKeywordResults from "@/src/components/SearchKeywordResults"; import SearchResults from "@/src/components/SearchResults"; function SearchPage() { @@ -15,7 +15,7 @@ function SearchPage() { isMoreButton={false} /> - + );