From 6ec1d3fa9f1288e86114eabf3c439fa1ea7a77c2 Mon Sep 17 00:00:00 2001 From: Daseul Kim Date: Tue, 20 May 2025 16:39:06 +0900 Subject: [PATCH 1/4] =?UTF-8?q?feat:=20=EC=A7=80=EB=B6=95=EC=9E=AC=20?= =?UTF-8?q?=EC=A0=81=ED=95=A9=EC=84=B1=20=EC=A1=B0=ED=9A=8C=20raw=20query?= =?UTF-8?q?=20=EB=B0=A9=EC=8B=9D=EC=9C=BC=EB=A1=9C=20=EB=B3=80=EA=B2=BD=20?= =?UTF-8?q?=EB=B0=8F=20=ED=85=8C=EC=8A=A4=ED=8A=B8=20=EC=BD=94=EB=93=9C=20?= =?UTF-8?q?=EC=82=AD=EC=A0=9C?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/app/api/suitable/list/route.ts | 119 +++++++------- src/app/api/suitable/list/test/route.ts | 71 -------- src/app/suitable-test/layout.tsx | 24 --- src/app/suitable-test/page.tsx | 9 - src/components/suitable/Suitable.tsx | 6 +- src/components/suitable/SuitableList.tsx | 15 +- src/components/suitable/SuitableListRaw.tsx | 173 -------------------- src/components/suitable/SuitableRaw.tsx | 118 ------------- src/hooks/useSuitable.ts | 46 +++--- src/hooks/useSuitableRaw.ts | 109 ------------ src/types/Suitable.ts | 21 +-- 11 files changed, 95 insertions(+), 616 deletions(-) delete mode 100644 src/app/api/suitable/list/test/route.ts delete mode 100644 src/app/suitable-test/layout.tsx delete mode 100644 src/app/suitable-test/page.tsx delete mode 100644 src/components/suitable/SuitableListRaw.tsx delete mode 100644 src/components/suitable/SuitableRaw.tsx delete mode 100644 src/hooks/useSuitableRaw.ts diff --git a/src/app/api/suitable/list/route.ts b/src/app/api/suitable/list/route.ts index 32eac33..edc5579 100644 --- a/src/app/api/suitable/list/route.ts +++ b/src/app/api/suitable/list/route.ts @@ -1,82 +1,75 @@ import { NextRequest, NextResponse } from 'next/server' import { prisma } from '@/libs/prisma' -import { SUITABLE_HEAD_CODE, type SuitableMain } from '@/types/Suitable' +import { type Suitable } from '@/types/Suitable' export async function GET(request: NextRequest) { try { const searchParams = request.nextUrl.searchParams + + const pageNumber = parseInt(searchParams.get('pageNumber') || '0') + const itemPerPage = parseInt(searchParams.get('itemPerPage') || '0') + if (pageNumber === 0 || itemPerPage === 0) { + return NextResponse.json({ error: '페이지 번호와 페이지당 아이템 수가 필요합니다' }, { status: 400 }) + } + + const ids = searchParams.get('ids') const category = searchParams.get('category') const keyword = searchParams.get('keyword') - let MainWhereCondition: any = {} - const whereCondition: string[] = [] - const params: string[] = [] + let query = ` + SELECT + msm.id + , msm.product_name + , msm.manu_ft_cd + , msm.roof_mt_cd + , msm.roof_sh_cd + , details.detail + FROM ms_suitable_main msm + LEFT JOIN ( + SELECT + msd.main_id + , ( + SELECT + msd_json.id + , msd_json.trestle_mfpc_cd + , msd_json.trestle_manufacturer_product_name + , msd_json.memo + FROM ms_suitable_detail msd_json + WHERE msd.main_id = msd_json.main_id + FOR JSON PATH + ) AS detail + FROM ms_suitable_detail msd + GROUP BY msd.main_id + ) AS details + ON msm.id = details.main_id + --mainIds AND details.main_id IN (:mainIds) + WHERE 1=1 + --mainIds AND msm.id IN (:mainIds) + --roofMtCd AND msm.roof_mt_cd = ':roofMtCd' + --productName AND msm.product_name LIKE '%:productName%' + ORDER BY msm.product_name + OFFSET (@P1 - 1) * @P2 ROWS + FETCH NEXT @P2 ROWS ONLY; + ` + + // 검색 조건 설정 + if (ids) { + query = query.replaceAll('--mainIds ', '') + query = query.replaceAll(':mainIds', ids) + } if (category) { - whereCondition.push(`${SUITABLE_HEAD_CODE.ROOF_MT_CD} = @P1`) - params.push(category) - MainWhereCondition[SUITABLE_HEAD_CODE.ROOF_MT_CD] = category + query = query.replace('--roofMtCd ', '') + query = query.replace(':roofMtCd', category) } if (keyword) { - whereCondition.push('PRODUCT_NAME LIKE @P2') - params.push(`%${keyword}%`) - MainWhereCondition['PRODUCT_NAME'] = { - contains: keyword, - } + query = query.replace('--productName ', '') + query = query.replace(':productName', keyword) } - const startTime = performance.now() - console.log(`쿼리 (main table) 시작 시간: ${startTime}ms`) // @ts-ignore - const suitable = await prisma.MS_SUITABLE_MAIN.findMany({ - select: { - ID: true, - PRODUCT_NAME: true, - ROOF_MT_CD: true, - }, - where: MainWhereCondition, - orderBy: { - PRODUCT_NAME: 'asc', - }, - }) + const suitable: Suitable[] = await prisma.$queryRawUnsafe(query, pageNumber, itemPerPage) - const endTime = performance.now() - console.log(`쿼리 (main table) 종료 시간: ${endTime - startTime}ms`) - - const mainIds: number[] = suitable.map((item: SuitableMain) => item.id) - - - const startTime2 = performance.now() - console.log(`쿼리 (detail table) 시작 시간: ${startTime2}ms`) - let detailQuery = ` - SELECT - msd.main_id - , ( - SELECT - msd_json.id - , msd_json.trestle_mfpc_cd - , msd_json.trestle_manufacturer_product_name - , msd_json.memo - FROM ms_suitable_detail msd_json - WHERE msd.main_id = msd_json.main_id - FOR JSON PATH - ) AS detail - FROM ms_suitable_detail msd - -- WHERE 1=1 - GROUP BY msd.main_id - ` - if (whereCondition.length > 0) { - detailQuery = detailQuery.replace('-- WHERE 1=1', `WHERE msd.main_id IN @P1`) - } - // @ts-ignore - const detail = await prisma.$queryRawUnsafe(detailQuery, ...mainIds) - - const endTime2 = performance.now() - console.log(`쿼리 (detail table) 종료 시간: ${endTime2 - startTime2}ms`) - - const endTime3 = performance.now() - console.log(`쿼리 총 실행 시간: ${endTime3 - startTime}ms`) - - return NextResponse.json({ suitable, detail }) + return NextResponse.json(suitable) } catch (error) { console.error('❌ 데이터 조회 중 오류가 발생했습니다:', error) return NextResponse.json({ error: '데이터 조회 중 오류가 발생했습니다' }, { status: 500 }) diff --git a/src/app/api/suitable/list/test/route.ts b/src/app/api/suitable/list/test/route.ts deleted file mode 100644 index e4688bd..0000000 --- a/src/app/api/suitable/list/test/route.ts +++ /dev/null @@ -1,71 +0,0 @@ -import { NextRequest, NextResponse } from 'next/server' -import { prisma } from '@/libs/prisma' -import { SUITABLE_HEAD_CODE, type Suitable } from '@/types/Suitable' - -export async function GET(request: NextRequest) { - try { - const searchParams = request.nextUrl.searchParams - const category = searchParams.get('category') - const keyword = searchParams.get('keyword') - - const whereCondition: string[] = [] - const params: string[] = [] - if (category) { - whereCondition.push(`${SUITABLE_HEAD_CODE.ROOF_MT_CD} = @P1`) - params.push(category) - } - if (keyword) { - whereCondition.push('PRODUCT_NAME LIKE @P2') - params.push(`%${keyword}%`) - } - - const startTime = performance.now() - console.log(`쿼리 시작 시간: ${startTime}ms`) - - let query = ` - SELECT - msm.id - , msm.product_name - , msm.manu_ft_cd - , msm.roof_mt_cd - , msm.roof_sh_cd - , details.detail - FROM ms_suitable_main msm - LEFT JOIN ( - SELECT - msd.main_id - , ( - SELECT - msd_json.id - , msd_json.trestle_mfpc_cd - , msd_json.trestle_manufacturer_product_name - , msd_json.memo - FROM ms_suitable_detail msd_json - WHERE msd.main_id = msd_json.main_id - FOR JSON PATH - ) AS detail - FROM ms_suitable_detail msd - GROUP BY msd.main_id - ) AS details - ON msm.id = details.main_id - -- AND details.main_id IN (#mainIds) - -- WHERE 1=1 - ORDER BY msm.product_name` - - // 검색 조건 추가 - if (whereCondition.length > 0) { - query = query.replace('-- WHERE 1=1', `WHERE ${whereCondition.join(' AND ')}`) - } - - // @ts-ignore - const suitable: Suitable[] = await prisma.$queryRawUnsafe(query, ...params) - - const endTime = performance.now() - console.log(`쿼리 실행 시간: ${endTime - startTime}ms`) - - return NextResponse.json(suitable) - } catch (error) { - console.error('❌ 데이터 조회 중 오류가 발생했습니다:', error) - return NextResponse.json({ error: '데이터 조회 중 오류가 발생했습니다' }, { status: 500 }) - } -} diff --git a/src/app/suitable-test/layout.tsx b/src/app/suitable-test/layout.tsx deleted file mode 100644 index e5e7c3f..0000000 --- a/src/app/suitable-test/layout.tsx +++ /dev/null @@ -1,24 +0,0 @@ -import type { ReactNode } from 'react' - -interface SuitableLayoutProps { - children: ReactNode -} - -export default function layout({ children }: SuitableLayoutProps) { - return ( - <> -
-
-
-
-
この適合表は参考資料として使用してください.
-
詳細やお問い合わせは1:1お問い合わせをご利用ください.
-
屋根材の選択or屋根材名を直接入力してください.
-
-
- {children} -
-
- - ) -} diff --git a/src/app/suitable-test/page.tsx b/src/app/suitable-test/page.tsx deleted file mode 100644 index a5299fe..0000000 --- a/src/app/suitable-test/page.tsx +++ /dev/null @@ -1,9 +0,0 @@ -import SuitableRaw from '@/components/suitable/SuitableRaw' - -export default function page() { - return ( - <> - - - ) -} diff --git a/src/components/suitable/Suitable.tsx b/src/components/suitable/Suitable.tsx index 36a397f..15cda44 100644 --- a/src/components/suitable/Suitable.tsx +++ b/src/components/suitable/Suitable.tsx @@ -2,13 +2,13 @@ import Image from 'next/image' import { useEffect, useState } from 'react' -import SuitableList from './SuitableList' +import SuitableListRaw from './SuitableList' import { useSuitable } from '@/hooks/useSuitable' import { useSuitableStore } from '@/store/useSuitableStore' import type { CommCode } from '@/types/CommCode' import { SUITABLE_HEAD_CODE } from '@/types/Suitable' -export default function Suitable() { +export default function SuitableRaw() { const [reference, setReference] = useState(true) const { getSuitableCommCode, refetchBySearch } = useSuitable() @@ -110,7 +110,7 @@ export default function Suitable() { - + ) diff --git a/src/components/suitable/SuitableList.tsx b/src/components/suitable/SuitableList.tsx index 18d94e5..bbb8e03 100644 --- a/src/components/suitable/SuitableList.tsx +++ b/src/components/suitable/SuitableList.tsx @@ -6,7 +6,7 @@ import SuitableButton from './SuitableButton' import SuitableNoData from './SuitableNoData' import { useSuitable } from '@/hooks/useSuitable' import { useSuitableStore } from '@/store/useSuitableStore' -import { SUITABLE_HEAD_CODE, type SuitableMain, type SuitableDetail } from '@/types/Suitable' +import { SUITABLE_HEAD_CODE, type Suitable, type SuitableDetail } from '@/types/Suitable' // 한 번에 로드할 아이템 수 const ITEMS_PER_PAGE = 100 @@ -14,9 +14,8 @@ const ITEMS_PER_PAGE = 100 export default function SuitableList() { const { toCodeName, suitableSearchResults, isSearchLoading, toSuitableDetail } = useSuitable() const { selectedItems, addSelectedItem, removeSelectedItem } = useSuitableStore() - const [openItems, setOpenItems] = useState>(new Set()) - const [visibleItems, setVisibleItems] = useState([]) + const [visibleItems, setVisibleItems] = useState([]) const [page, setPage] = useState(1) const [isLoadingMore, setIsLoadingMore] = useState(false) const observerTarget = useRef(null) @@ -32,7 +31,7 @@ export default function SuitableList() { // 초기 데이터 로드 useEffect(() => { if (suitableSearchResults) { - const initialItems = suitableSearchResults.suitable.slice(0, ITEMS_PER_PAGE) + const initialItems = suitableSearchResults.slice(0, ITEMS_PER_PAGE) setVisibleItems(initialItems) setPage(1) } @@ -46,7 +45,7 @@ export default function SuitableList() { const nextPage = page + 1 const startIndex = (nextPage - 1) * ITEMS_PER_PAGE const endIndex = startIndex + ITEMS_PER_PAGE - const nextItems = suitableSearchResults.suitable.slice(startIndex, endIndex) + const nextItems = suitableSearchResults.slice(startIndex, endIndex) if (nextItems.length > 0) { setIsLoadingMore(true) @@ -108,7 +107,7 @@ export default function SuitableList() { // 메모이제이션된 아이템 렌더링 const renderItem = useCallback( - (item: SuitableMain) => { + (item: Suitable) => { const isSelected = isItemSelected(item.id) const isOpen = openItems.has(item.id) @@ -124,7 +123,7 @@ export default function SuitableList() {
    - {toSuitableDetail(item.id).map((subItem: SuitableDetail) => ( + {toSuitableDetail(item.detail).map((subItem: SuitableDetail) => (
  • @@ -158,7 +157,7 @@ export default function SuitableList() { return
    Loading...
    } - if (!suitableSearchResults?.suitable.length) { + if (!suitableSearchResults?.length) { return } diff --git a/src/components/suitable/SuitableListRaw.tsx b/src/components/suitable/SuitableListRaw.tsx deleted file mode 100644 index 6dc7f36..0000000 --- a/src/components/suitable/SuitableListRaw.tsx +++ /dev/null @@ -1,173 +0,0 @@ -'use client' - -import Image from 'next/image' -import { useState, useEffect, useRef, useCallback, useMemo } from 'react' -import SuitableButton from './SuitableButton' -import SuitableNoData from './SuitableNoData' -import { useSuitableRaw, type Suitable } from '@/hooks/useSuitableRaw' -import { useSuitableStore } from '@/store/useSuitableStore' -import { SUITABLE_HEAD_CODE, type SuitableDetail } from '@/types/Suitable' - -// 한 번에 로드할 아이템 수 -const ITEMS_PER_PAGE = 100 - -export default function SuitableListRaw() { - const { toCodeName, suitableSearchResults, isSearchLoading, toSuitableDetail } = useSuitableRaw() - const { selectedItems, addSelectedItem, removeSelectedItem } = useSuitableStore() - const [openItems, setOpenItems] = useState>(new Set()) - const [visibleItems, setVisibleItems] = useState([]) - const [page, setPage] = useState(1) - const [isLoadingMore, setIsLoadingMore] = useState(false) - const observerTarget = useRef(null) - - // 선택된 아이템 확인 함수 메모이제이션 - const isItemSelected = useCallback( - (itemId: number) => { - return selectedItems.some((selected) => selected === itemId) - }, - [selectedItems], - ) - - // 초기 데이터 로드 - useEffect(() => { - if (suitableSearchResults) { - const initialItems = suitableSearchResults.slice(0, ITEMS_PER_PAGE) - setVisibleItems(initialItems) - setPage(1) - } - }, [suitableSearchResults]) - - // Intersection Observer 설정 - useEffect(() => { - const observer = new IntersectionObserver( - (entries) => { - if (entries[0].isIntersecting && suitableSearchResults && !isLoadingMore) { - const nextPage = page + 1 - const startIndex = (nextPage - 1) * ITEMS_PER_PAGE - const endIndex = startIndex + ITEMS_PER_PAGE - const nextItems = suitableSearchResults.slice(startIndex, endIndex) - - if (nextItems.length > 0) { - setIsLoadingMore(true) - setVisibleItems((prev) => [...prev, ...nextItems]) - setPage(nextPage) - setIsLoadingMore(false) - } - } - }, - { - threshold: 0.2, - }, - ) - - if (observerTarget.current) { - observer.observe(observerTarget.current) - } - - return () => observer.disconnect() - }, [page, suitableSearchResults, isLoadingMore]) - - const handleItemClick = useCallback( - (itemId: number) => { - isItemSelected(itemId) ? removeSelectedItem(itemId) : addSelectedItem(itemId) - }, - [isItemSelected, addSelectedItem, removeSelectedItem], - ) - - const toggleItemOpen = useCallback((itemId: number) => { - setOpenItems((prev) => { - const newOpenItems = new Set(prev) - newOpenItems.has(itemId) ? newOpenItems.delete(itemId) : newOpenItems.add(itemId) - return newOpenItems - }) - }, []) - - // TODO: 추후 지붕재 적합성 데이터 CUD 구현 시 ×, ー 데이터 관리 필요 - const suitableCheck = useCallback((value: string) => { - if (value === '×') { - return ( -
    - -
    - ) - } else if (value === 'ー') { - return ( -
    - -
    - ) - } else { - return ( -
    - -
    - ) - } - }, []) - - // 메모이제이션된 아이템 렌더링 - const renderItem = useCallback( - (item: Suitable) => { - const isSelected = isItemSelected(item.id) - const isOpen = openItems.has(item.id) - - return ( -
    -
    -
    - handleItemClick(item.id)} /> - -
    -
    - -
    -
    -
      - {toSuitableDetail(item.detail).map((subItem: SuitableDetail) => ( -
    • -
      -
      - - -
      -
      - {suitableCheck(subItem.trestleManufacturerProductName)} - {subItem.memo && ( -
      - -
      - )} -
      -
      -
    • - ))} -
    -
    - ) - }, - [isItemSelected, openItems, handleItemClick, toggleItemOpen, suitableCheck, toCodeName, toSuitableDetail], - ) - - // 메모이제이션된 아이템 리스트 - const renderedItems = useMemo(() => { - return visibleItems.map(renderItem) - }, [visibleItems, renderItem]) - - if (isSearchLoading) { - return
    Loading...
    - } - - if (!suitableSearchResults?.length) { - return - } - - return ( - <> - {renderedItems} -
    - {isLoadingMore &&
    데이터를 불러오는 중...
    } -
    - - - ) -} diff --git a/src/components/suitable/SuitableRaw.tsx b/src/components/suitable/SuitableRaw.tsx deleted file mode 100644 index d48dfea..0000000 --- a/src/components/suitable/SuitableRaw.tsx +++ /dev/null @@ -1,118 +0,0 @@ -'use client' - -import Image from 'next/image' -import { useEffect, useState } from 'react' -import SuitableListRaw from './SuitableListRaw' -import { useSuitableRaw } from '@/hooks/useSuitableRaw' -import { useSuitableStore } from '@/store/useSuitableStore' -import type { CommCode } from '@/types/CommCode' -import { SUITABLE_HEAD_CODE } from '@/types/Suitable' - -export default function SuitableRaw() { - const [reference, setReference] = useState(true) - - const { getSuitableCommCode, refetchBySearch } = useSuitableRaw() - const { suitableCommCode, selectedCategory, setSelectedCategory, searchValue, setSearchValue, setIsSearch, clearSelectedItems } = useSuitableStore() - - const handleInputSearch = async () => { - if (!searchValue.trim()) { - alert('屋根材の製品名を入力してください。') - return - } - setIsSearch(true) - refetchBySearch() - } - - const handleInputClear = () => { - setSearchValue('') - setIsSearch(false) - refetchBySearch() - } - - useEffect(() => { - refetchBySearch() - }, [selectedCategory]) - - useEffect(() => { - getSuitableCommCode() - return () => { - setSelectedCategory('') - setSearchValue('') - clearSelectedItems() - } - }, []) - - return ( -
    - 테스트1 페이지 -
    - -
    -
    -
    - setSearchValue(e.target.value)} - /> - {searchValue &&
    -
    -
    -
    -
    -
    凡例
    -
    - -
    -
    -
      -
    • -
      -
      - -
      - 設置可能 -
      -
    • -
    • -
      -
      - -
      - 設置不可 -
      -
    • -
    • -
      -
      - -
      - お問い合わせ -
      -
    • -
    • -
      -
      - -
      - 備考 -
      -
    • -
    -
    - -
    -
    - ) -} diff --git a/src/hooks/useSuitable.ts b/src/hooks/useSuitable.ts index 4bdd9b2..ece8d2e 100644 --- a/src/hooks/useSuitable.ts +++ b/src/hooks/useSuitable.ts @@ -2,19 +2,27 @@ import { useQuery } from '@tanstack/react-query' import { axiosInstance, transformObjectKeys } from '@/libs/axios' import { useSuitableStore } from '@/store/useSuitableStore' import { useCommCode } from './useCommCode' -import { SUITABLE_HEAD_CODE, type SuitableDetailGroup, type SuitableMain, type Suitable, type SuitableDetail } from '@/types/Suitable' +import { SUITABLE_HEAD_CODE, type Suitable, type SuitableDetail } from '@/types/Suitable' export function useSuitable() { const { getCommCode } = useCommCode() const { selectedCategory, searchValue, suitableCommCode, setSuitableCommCode, isSearch } = useSuitableStore() - const getSuitables = async (): Promise => { + const getSuitables = async (): Promise => { try { - const response = await axiosInstance(null).get('/api/suitable/list') + const response = await axiosInstance(null).get('/api/suitable/list', { + params: { + pageNumber: 1, + itemPerPage: 1000, + ids: '', + category: '', + keyword: '', + }, + }) return response.data } catch (error) { console.error('지붕재 데이터 로드 실패:', error) - return { suitable: [], detail: [] } + return [] } } @@ -42,12 +50,8 @@ export function useSuitable() { return commCode?.find((item) => item.code === code)?.codeJp || '' } - const toSuitableDetail = (mainId: number): SuitableDetail[] => { + const toSuitableDetail = (suitableDetailString: string): SuitableDetail[] => { try { - const suitableDetailString = suitableList?.detail.find((item) => item.mainId === mainId)?.detail - if (!suitableDetailString) { - return [] - } const suitableDetailArray = transformObjectKeys(JSON.parse(suitableDetailString)) as SuitableDetail[] if (!Array.isArray(suitableDetailArray)) { throw new Error('suitableDetailArray is not an array') @@ -59,7 +63,7 @@ export function useSuitable() { } } - const { data: suitableList, isLoading: isInitialLoading } = useQuery({ + const { data: suitableList, isLoading: isInitialLoading } = useQuery({ queryKey: ['suitables', 'list'], queryFn: async () => await getSuitables(), staleTime: 1000 * 60 * 10, // 10분 @@ -70,23 +74,19 @@ export function useSuitable() { data: suitableSearchResults, refetch: refetchBySearch, isLoading: isSearchLoading, - } = useQuery({ + } = useQuery({ queryKey: ['suitables', 'search', selectedCategory, isSearch], queryFn: async () => { if (!isSearch && !selectedCategory) { - // 검색 상태가 아니면 초기 데이터 반환 임시처리 - return isInitialLoading ? await getSuitables() : suitableList ?? { suitable: [], detail: [] } + return isInitialLoading ? await getSuitables() : suitableList ?? [] // 검색 상태가 아니면 초기 데이터 반환 임시처리 } else { - const filteredSuitable = suitableList?.suitable.filter((item: SuitableMain) => { - const categoryMatch = !selectedCategory || item.roofMtCd === selectedCategory - const searchMatch = !searchValue || item.productName.includes(searchValue) - return categoryMatch && searchMatch - }) ?? [] - const mainIds = filteredSuitable.map((item: SuitableMain) => item.id) - const filteredDetail = suitableList?.detail.filter((item: SuitableDetailGroup) => { - return mainIds.includes(item.mainId) - }) ?? [] - return { suitable: filteredSuitable, detail: filteredDetail } + return ( + suitableList?.filter((item: Suitable) => { + const categoryMatch = !selectedCategory || item.roofMtCd === selectedCategory + const searchMatch = !searchValue || item.productName.includes(searchValue) + return categoryMatch && searchMatch + }) ?? [] + ) } }, staleTime: 1000 * 60 * 10, diff --git a/src/hooks/useSuitableRaw.ts b/src/hooks/useSuitableRaw.ts deleted file mode 100644 index a962244..0000000 --- a/src/hooks/useSuitableRaw.ts +++ /dev/null @@ -1,109 +0,0 @@ -import { useQuery } from '@tanstack/react-query' -import { axiosInstance, transformObjectKeys } from '@/libs/axios' -import { useSuitableStore } from '@/store/useSuitableStore' -import { useCommCode } from './useCommCode' -import { SUITABLE_HEAD_CODE, type SuitableDetail } from '@/types/Suitable' - -export type Suitable = { - id: number - productName: string - manuFtCd: string - roofMtCd: string - roofShCd: string - detail: string -} - -export function useSuitableRaw() { - const { getCommCode } = useCommCode() - const { selectedCategory, searchValue, suitableCommCode, setSuitableCommCode, isSearch } = useSuitableStore() - - const getSuitables = async (): Promise => { - try { - const response = await axiosInstance(null).get('/api/suitable/list/test') - return response.data - } catch (error) { - console.error('지붕재 데이터 로드 실패:', error) - return [] - } - } - - // const updateSearchResults = async (selectedCategory: string | undefined, searchValue: string | undefined): Promise => { - // try { - // const response = await axiosInstance(null).get('/api/suitable/list', { params: { selectedCategory, searchValue } }) - // return response.data - // } catch (error) { - // console.error('지붕재 데이터 검색 실패:', error) - // return [] - // } - // } - - const getSuitableCommCode = () => { - const headCodes = Object.values(SUITABLE_HEAD_CODE) as SUITABLE_HEAD_CODE[] - for (const code of headCodes) { - getCommCode(code).then((res) => { - setSuitableCommCode(code, res) - }) - } - } - - const toCodeName = (headCode: string, code: string): string => { - const commCode = suitableCommCode.get(headCode) - return commCode?.find((item) => item.code === code)?.codeJp || '' - } - - const toSuitableDetail = (suitableDetailString: string): SuitableDetail[] => { - try { - const suitableDetailArray = transformObjectKeys(JSON.parse(suitableDetailString)) as SuitableDetail[] - if (!Array.isArray(suitableDetailArray)) { - throw new Error('suitableDetailArray is not an array') - } - return suitableDetailArray - } catch (error) { - console.error('지붕재 데이터 파싱 실패:', error) - return [] - } - } - - const { data: suitableList, isLoading: isInitialLoading } = useQuery({ - queryKey: ['suitables', 'list'], - queryFn: async () => await getSuitables(), - staleTime: 1000 * 60 * 10, // 10분 - gcTime: 1000 * 60 * 10, // 10분 - }) - - const { - data: suitableSearchResults, - refetch: refetchBySearch, - isLoading: isSearchLoading, - // } = useQuery({ - } = useQuery({ - queryKey: ['suitables', 'search', selectedCategory, isSearch], - queryFn: async () => { - if (!isSearch && !selectedCategory) { - return isInitialLoading ? await getSuitables() : suitableList ?? [] // 검색 상태가 아니면 초기 데이터 반환 임시처리 - } else { - return ( - suitableList?.filter((item: Suitable) => { - const categoryMatch = !selectedCategory || item.roofMtCd === selectedCategory - const searchMatch = !searchValue || item.productName.includes(searchValue) - return categoryMatch && searchMatch - }) ?? [] - ) - } - }, - staleTime: 1000 * 60 * 10, - gcTime: 1000 * 60 * 10, - enabled: true, - }) - - return { - getSuitables, - getSuitableCommCode, - toCodeName, - toSuitableDetail, - suitableList, - suitableSearchResults, - refetchBySearch, - isSearchLoading, - } -} diff --git a/src/types/Suitable.ts b/src/types/Suitable.ts index 2e3563b..e3966c8 100644 --- a/src/types/Suitable.ts +++ b/src/types/Suitable.ts @@ -25,20 +25,11 @@ export type SuitableDetail = { memo: string } -// export type Suitable = { -// id: number -// productName: string -// manuFtCd: string -// roofMtCd: string -// roofShCd: string -// detail: string -// } - -export type SuitableDetailGroup = { - mainId: number +export type Suitable = { + id: number + productName: string + manuFtCd: string + roofMtCd: string + roofShCd: string detail: string } -export type Suitable = { - suitable: SuitableMain[] - detail: SuitableDetailGroup[] -} \ No newline at end of file From 2e0ff4ae6ff089da81805113b4de46150c9f9756 Mon Sep 17 00:00:00 2001 From: Daseul Kim Date: Thu, 22 May 2025 18:16:31 +0900 Subject: [PATCH 2/4] =?UTF-8?q?feat:=20=EC=A7=80=EB=B6=95=EC=9E=AC?= =?UTF-8?q?=EC=A0=81=ED=95=A9=EC=84=B1=20=EC=A1=B0=ED=9A=8C=20=ED=8E=98?= =?UTF-8?q?=EC=9D=B4=EC=A7=95&=EC=9D=B8=ED=94=BC=EB=8B=88=ED=8B=B0?= =?UTF-8?q?=EC=BF=BC=EB=A6=AC=20=EC=A0=81=EC=9A=A9,=20=EC=B2=B4=ED=81=AC?= =?UTF-8?q?=EB=B0=95=EC=8A=A4=20=EC=84=A0=ED=83=9D=20=EC=B2=98=EB=A6=AC=20?= =?UTF-8?q?=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/app/api/suitable/list/route.ts | 13 +- src/components/suitable/Suitable.tsx | 19 ++- src/components/suitable/SuitableList.tsx | 157 +++++++++------------ src/components/suitable/SuitableNoData.tsx | 7 +- src/hooks/useSuitable.ts | 103 +++++++------- src/store/useSuitableStore.ts | 59 ++++++-- src/types/Suitable.ts | 1 + 7 files changed, 182 insertions(+), 177 deletions(-) diff --git a/src/app/api/suitable/list/route.ts b/src/app/api/suitable/list/route.ts index edc5579..005d556 100644 --- a/src/app/api/suitable/list/route.ts +++ b/src/app/api/suitable/list/route.ts @@ -12,7 +12,6 @@ export async function GET(request: NextRequest) { return NextResponse.json({ error: '페이지 번호와 페이지당 아이템 수가 필요합니다' }, { status: 400 }) } - const ids = searchParams.get('ids') const category = searchParams.get('category') const keyword = searchParams.get('keyword') @@ -20,14 +19,13 @@ export async function GET(request: NextRequest) { SELECT msm.id , msm.product_name - , msm.manu_ft_cd - , msm.roof_mt_cd - , msm.roof_sh_cd + , details.detail_cnt , details.detail FROM ms_suitable_main msm LEFT JOIN ( SELECT msd.main_id + , COUNT(msd.id) AS detail_cnt , ( SELECT msd_json.id @@ -42,9 +40,7 @@ export async function GET(request: NextRequest) { GROUP BY msd.main_id ) AS details ON msm.id = details.main_id - --mainIds AND details.main_id IN (:mainIds) WHERE 1=1 - --mainIds AND msm.id IN (:mainIds) --roofMtCd AND msm.roof_mt_cd = ':roofMtCd' --productName AND msm.product_name LIKE '%:productName%' ORDER BY msm.product_name @@ -53,10 +49,6 @@ export async function GET(request: NextRequest) { ` // 검색 조건 설정 - if (ids) { - query = query.replaceAll('--mainIds ', '') - query = query.replaceAll(':mainIds', ids) - } if (category) { query = query.replace('--roofMtCd ', '') query = query.replace(':roofMtCd', category) @@ -66,7 +58,6 @@ export async function GET(request: NextRequest) { query = query.replace(':productName', keyword) } - // @ts-ignore const suitable: Suitable[] = await prisma.$queryRawUnsafe(query, pageNumber, itemPerPage) return NextResponse.json(suitable) diff --git a/src/components/suitable/Suitable.tsx b/src/components/suitable/Suitable.tsx index 15cda44..f5f22b2 100644 --- a/src/components/suitable/Suitable.tsx +++ b/src/components/suitable/Suitable.tsx @@ -2,16 +2,16 @@ import Image from 'next/image' import { useEffect, useState } from 'react' -import SuitableListRaw from './SuitableList' +import SuitableList from './SuitableList' import { useSuitable } from '@/hooks/useSuitable' import { useSuitableStore } from '@/store/useSuitableStore' import type { CommCode } from '@/types/CommCode' import { SUITABLE_HEAD_CODE } from '@/types/Suitable' -export default function SuitableRaw() { +export default function Suitable() { const [reference, setReference] = useState(true) - const { getSuitableCommCode, refetchBySearch } = useSuitable() + const { getSuitableCommCode } = useSuitable() const { suitableCommCode, selectedCategory, setSelectedCategory, searchValue, setSearchValue, setIsSearch, clearSelectedItems } = useSuitableStore() const handleInputSearch = async () => { @@ -20,19 +20,13 @@ export default function SuitableRaw() { return } setIsSearch(true) - refetchBySearch() } const handleInputClear = () => { setSearchValue('') setIsSearch(false) - refetchBySearch() } - useEffect(() => { - refetchBySearch() - }, [selectedCategory]) - useEffect(() => { getSuitableCommCode() return () => { @@ -62,6 +56,11 @@ export default function SuitableRaw() { placeholder="屋根材 製品名を入力してください." value={searchValue} onChange={(e) => setSearchValue(e.target.value)} + onKeyDown={(e) => { + if (e.key === 'Enter') { + handleInputSearch() + } + }} /> {searchValue &&
- + ) diff --git a/src/components/suitable/SuitableList.tsx b/src/components/suitable/SuitableList.tsx index bbb8e03..d3086a5 100644 --- a/src/components/suitable/SuitableList.tsx +++ b/src/components/suitable/SuitableList.tsx @@ -1,79 +1,49 @@ 'use client' import Image from 'next/image' -import { useState, useEffect, useRef, useCallback, useMemo } from 'react' +import { useState, useEffect, useCallback, useRef, useMemo } from 'react' import SuitableButton from './SuitableButton' import SuitableNoData from './SuitableNoData' import { useSuitable } from '@/hooks/useSuitable' import { useSuitableStore } from '@/store/useSuitableStore' import { SUITABLE_HEAD_CODE, type Suitable, type SuitableDetail } from '@/types/Suitable' -// 한 번에 로드할 아이템 수 -const ITEMS_PER_PAGE = 100 - export default function SuitableList() { - const { toCodeName, suitableSearchResults, isSearchLoading, toSuitableDetail } = useSuitable() + const { toCodeName, toSuitableDetail, suitables, fetchNextPage, hasNextPage, isFetchingNextPage, isLoading } = useSuitable() const { selectedItems, addSelectedItem, removeSelectedItem } = useSuitableStore() const [openItems, setOpenItems] = useState>(new Set()) - const [visibleItems, setVisibleItems] = useState([]) - const [page, setPage] = useState(1) - const [isLoadingMore, setIsLoadingMore] = useState(false) const observerTarget = useRef(null) - // 선택된 아이템 확인 함수 메모이제이션 - const isItemSelected = useCallback( - (itemId: number) => { - return selectedItems.some((selected) => selected === itemId) + // 선택된 아이템 확인 - 메인 하위 아이템 indeterminate 확인 + const isMainIndeterminate = useMemo( + () => (mainId: number, detailCnt: number) => { + const mainItem = selectedItems.get(mainId) + if (!mainItem) return false + return mainItem.size > 0 && mainItem.size < detailCnt }, [selectedItems], ) - // 초기 데이터 로드 - useEffect(() => { - if (suitableSearchResults) { - const initialItems = suitableSearchResults.slice(0, ITEMS_PER_PAGE) - setVisibleItems(initialItems) - setPage(1) - } - }, [suitableSearchResults]) - - // Intersection Observer 설정 - useEffect(() => { - const observer = new IntersectionObserver( - (entries) => { - if (entries[0].isIntersecting && suitableSearchResults && !isLoadingMore) { - const nextPage = page + 1 - const startIndex = (nextPage - 1) * ITEMS_PER_PAGE - const endIndex = startIndex + ITEMS_PER_PAGE - const nextItems = suitableSearchResults.slice(startIndex, endIndex) - - if (nextItems.length > 0) { - setIsLoadingMore(true) - setVisibleItems((prev) => [...prev, ...nextItems]) - setPage(nextPage) - setIsLoadingMore(false) - } - } - }, - { - threshold: 0.2, - }, - ) - - if (observerTarget.current) { - observer.observe(observerTarget.current) - } - - return () => observer.disconnect() - }, [page, suitableSearchResults, isLoadingMore]) + // 선택된 아이템 확인 + const isItemSelected = useCallback( + (mainId: number, detailId?: number): boolean => { + const mainItem = selectedItems.get(mainId) + if (!mainItem) return false + if (!detailId) return true + return mainItem.has(detailId) + }, + [selectedItems], + ) + // 아이템 클릭 const handleItemClick = useCallback( - (itemId: number) => { - isItemSelected(itemId) ? removeSelectedItem(itemId) : addSelectedItem(itemId) + (mainId: number, detailId?: number): void => { + isItemSelected(mainId, detailId) ? removeSelectedItem(mainId, detailId) : addSelectedItem(mainId, detailId) }, [isItemSelected, addSelectedItem, removeSelectedItem], ) + // 아이템 열기/닫기 const toggleItemOpen = useCallback((itemId: number) => { setOpenItems((prev) => { const newOpenItems = new Set(prev) @@ -84,38 +54,26 @@ export default function SuitableList() { // TODO: 추후 지붕재 적합성 데이터 CUD 구현 시 ×, ー 데이터 관리 필요 const suitableCheck = useCallback((value: string) => { - if (value === '×') { - return ( -
- -
- ) - } else if (value === 'ー') { - return ( -
- -
- ) - } else { - return ( -
- -
- ) + const iconMap: Record = { + '×': '/assets/images/sub/compliance_x_icon.svg', + ー: '/assets/images/sub/compliance_quest_icon.svg', + default: '/assets/images/sub/compliance_check_icon.svg', } + return ( +
+ +
+ ) }, []) - // 메모이제이션된 아이템 렌더링 + // 아이템 렌더링 const renderItem = useCallback( (item: Suitable) => { - const isSelected = isItemSelected(item.id) - const isOpen = openItems.has(item.id) - return ( -
+
-
- handleItemClick(item.id)} /> +
+ handleItemClick(item.id)} />
@@ -127,7 +85,12 @@ export default function SuitableList() {
  • - + handleItemClick(item.id, subItem.id)} + />
    @@ -148,24 +111,38 @@ export default function SuitableList() { [isItemSelected, openItems, handleItemClick, toggleItemOpen, suitableCheck, toCodeName, toSuitableDetail], ) - // 메모이제이션된 아이템 리스트 - const renderedItems = useMemo(() => { - return visibleItems.map(renderItem) - }, [visibleItems, renderItem]) + // 아이템 리스트 + const suitableList = suitables?.pages.flat() ?? [] - if (isSearchLoading) { - return
    Loading...
    - } + // Intersection Observer 설정 + useEffect(() => { + const observer = new IntersectionObserver( + (entries) => { + if (entries[0].isIntersecting && hasNextPage && !isFetchingNextPage) { + fetchNextPage() + } + }, + { + threshold: 0, + rootMargin: '100px', + }, + ) - if (!suitableSearchResults?.length) { - return - } + if (observerTarget.current) { + observer.observe(observerTarget.current) + } + + return () => observer.disconnect() + }, [hasNextPage, isFetchingNextPage, fetchNextPage]) + + if (isLoading) return
    Loading...
    + if (!suitableList.length) return return ( <> - {renderedItems} + {suitableList.map(renderItem)}
    - {isLoadingMore &&
    데이터를 불러오는 중...
    } + {isFetchingNextPage &&
    데이터를 불러오는 중...
    }
    diff --git a/src/components/suitable/SuitableNoData.tsx b/src/components/suitable/SuitableNoData.tsx index 1245fbf..f427b15 100644 --- a/src/components/suitable/SuitableNoData.tsx +++ b/src/components/suitable/SuitableNoData.tsx @@ -1,11 +1,16 @@ +'use client' + +import { useRouter } from 'next/navigation' + export default function SuitableNoData() { + const router = useRouter() return ( <>
    検索結果はありません。 屋根材適合性表にない製品の情報を入力してください。 今後返信いたします。 - diff --git a/src/hooks/useSuitable.ts b/src/hooks/useSuitable.ts index ece8d2e..57ff7b5 100644 --- a/src/hooks/useSuitable.ts +++ b/src/hooks/useSuitable.ts @@ -1,24 +1,37 @@ -import { useQuery } from '@tanstack/react-query' -import { axiosInstance, transformObjectKeys } from '@/libs/axios' +import { useInfiniteQuery } from '@tanstack/react-query' +import { transformObjectKeys } from '@/libs/axios' import { useSuitableStore } from '@/store/useSuitableStore' +import { useAxios } from './useAxios' import { useCommCode } from './useCommCode' import { SUITABLE_HEAD_CODE, type Suitable, type SuitableDetail } from '@/types/Suitable' export function useSuitable() { + const { axiosInstance } = useAxios() const { getCommCode } = useCommCode() - const { selectedCategory, searchValue, suitableCommCode, setSuitableCommCode, isSearch } = useSuitableStore() + const { itemPerPage, selectedCategory, searchValue, suitableCommCode, setSuitableCommCode, isSearch } = useSuitableStore() + - const getSuitables = async (): Promise => { + const getSuitables = async ({ + pageNumber, + ids, + category, + keyword, + }: { + pageNumber?: number + ids?: string + category?: string + keyword?: string + }): Promise => { try { - const response = await axiosInstance(null).get('/api/suitable/list', { - params: { - pageNumber: 1, - itemPerPage: 1000, - ids: '', - category: '', - keyword: '', - }, - }) + const params: Record = { + pageNumber: pageNumber || 1, + itemPerPage: itemPerPage, + } + if (ids) params.ids = ids + if (category) params.category = category + if (keyword) params.keyword = keyword + + const response = await axiosInstance(null).get('/api/suitable/list', { params }) return response.data } catch (error) { console.error('지붕재 데이터 로드 실패:', error) @@ -26,16 +39,6 @@ export function useSuitable() { } } - // const updateSearchResults = async (selectedCategory: string | undefined, searchValue: string | undefined): Promise => { - // try { - // const response = await axiosInstance(null).get('/api/suitable/list', { params: { selectedCategory, searchValue } }) - // return response.data - // } catch (error) { - // console.error('지붕재 데이터 검색 실패:', error) - // return [] - // } - // } - const getSuitableCommCode = () => { const headCodes = Object.values(SUITABLE_HEAD_CODE) as SUITABLE_HEAD_CODE[] for (const code of headCodes) { @@ -63,35 +66,30 @@ export function useSuitable() { } } - const { data: suitableList, isLoading: isInitialLoading } = useQuery({ - queryKey: ['suitables', 'list'], - queryFn: async () => await getSuitables(), - staleTime: 1000 * 60 * 10, // 10분 - gcTime: 1000 * 60 * 10, // 10분 - }) - const { - data: suitableSearchResults, - refetch: refetchBySearch, - isLoading: isSearchLoading, - } = useQuery({ - queryKey: ['suitables', 'search', selectedCategory, isSearch], - queryFn: async () => { - if (!isSearch && !selectedCategory) { - return isInitialLoading ? await getSuitables() : suitableList ?? [] // 검색 상태가 아니면 초기 데이터 반환 임시처리 - } else { - return ( - suitableList?.filter((item: Suitable) => { - const categoryMatch = !selectedCategory || item.roofMtCd === selectedCategory - const searchMatch = !searchValue || item.productName.includes(searchValue) - return categoryMatch && searchMatch - }) ?? [] - ) - } + data: suitables, + fetchNextPage, + hasNextPage, + isFetchingNextPage, + isLoading, + isError, + error, + } = useInfiniteQuery({ + queryKey: ['suitables', 'list', selectedCategory, isSearch], + queryFn: async (context) => { + const pageParam = context.pageParam as number + return await getSuitables({ + pageNumber: pageParam, + ...(selectedCategory && { category: selectedCategory }), + ...(isSearch && { keyword: searchValue }), + }) }, + getNextPageParam: (lastPage: Suitable[], allPages: Suitable[][]) => { + return lastPage.length === itemPerPage ? allPages.length + 1 : undefined + }, + initialPageParam: 1, staleTime: 1000 * 60 * 10, gcTime: 1000 * 60 * 10, - enabled: true, }) return { @@ -99,9 +97,10 @@ export function useSuitable() { getSuitableCommCode, toCodeName, toSuitableDetail, - suitableList, - suitableSearchResults, - refetchBySearch, - isSearchLoading, + suitables, + fetchNextPage, + hasNextPage, + isFetchingNextPage, + isLoading, } } diff --git a/src/store/useSuitableStore.ts b/src/store/useSuitableStore.ts index 5fa4cd0..74fb115 100644 --- a/src/store/useSuitableStore.ts +++ b/src/store/useSuitableStore.ts @@ -2,6 +2,9 @@ import { create } from 'zustand' import type { CommCode } from '@/types/CommCode' interface SuitableState { + /* 초기 데이터 로드 개수*/ + itemPerPage: number + /* 공통코드 */ suitableCommCode: Map /* 공통코드 설정 */ @@ -23,21 +26,22 @@ interface SuitableState { setSearchValue: (value: string) => void /* 선택된 아이템 리스트 */ - selectedItems: number[] + selectedItems: Map> /* 선택된 아이템 추가 */ - addSelectedItem: (itemId: number) => void + addSelectedItem: (mainId: number, detailId?: number) => void /* 선택된 아이템 제거 */ - removeSelectedItem: (itemId: number) => void + removeSelectedItem: (mainId: number, detailId?: number) => void /* 선택된 아이템 모두 제거 */ clearSelectedItems: () => void } export const useSuitableStore = create((set) => ({ + itemPerPage: 100 as number, suitableCommCode: new Map() as Map, isSearch: false as boolean, selectedCategory: '' as string, searchValue: '' as string, - selectedItems: [] as number[], + selectedItems: new Map() as Map>, /* 공통코드 설정 */ setSuitableCommCode: (headCode: string, commCode: CommCode[]) => @@ -55,17 +59,46 @@ export const useSuitableStore = create((set) => ({ setSearchValue: (value: string) => set({ searchValue: value }), /* 선택된 아이템 추가 */ - addSelectedItem: (itemId: number) => - set((state) => ({ - selectedItems: state.selectedItems.some((i) => i === itemId) ? state.selectedItems : [...state.selectedItems, itemId], - })), + addSelectedItem: (mainId: number, detailId?: number) => { + if (detailId) { + // 디테일(하위) 아이템 추가 + set((state) => { + const detailSet = state.selectedItems.get(mainId) || new Set() + detailSet.add(detailId) + state.selectedItems.set(mainId, detailSet) + return { selectedItems: state.selectedItems } + }) + } else { + // 메인(상위) 아이템 추가 + set((state) => { + state.selectedItems.set(mainId, new Set()) + return { selectedItems: state.selectedItems } + }) + } + }, /* 선택된 아이템 제거 */ - removeSelectedItem: (itemId: number) => - set((state) => ({ - selectedItems: state.selectedItems.filter((i) => i !== itemId), - })), + removeSelectedItem: (mainId: number, detailId?: number) => { + set((state) => { + const newSelectedItems = new Map(state.selectedItems) + + if (!detailId) { + // 메인(상위) 아이템 제거 + newSelectedItems.delete(mainId) + return { selectedItems: newSelectedItems } + } + + // 디테일(하위) 아이템 제거 + const detailSet = state.selectedItems.get(mainId) || new Set() + detailSet.delete(detailId) + + // 디테일(하위)하위 아이템이 모두 제거되면 메인 아이템도 제거 + detailSet.size === 0 ? newSelectedItems.delete(mainId) : newSelectedItems.set(mainId, detailSet) + + return { selectedItems: newSelectedItems } + }) + }, /* 선택된 아이템 모두 제거 */ - clearSelectedItems: () => set({ selectedItems: [] }), + clearSelectedItems: () => set({ selectedItems: new Map() as Map> }), })) diff --git a/src/types/Suitable.ts b/src/types/Suitable.ts index e3966c8..270dd46 100644 --- a/src/types/Suitable.ts +++ b/src/types/Suitable.ts @@ -31,5 +31,6 @@ export type Suitable = { manuFtCd: string roofMtCd: string roofShCd: string + detailCnt: number detail: string } From a25538319acebc128b00752ce3cb426b2430f20a Mon Sep 17 00:00:00 2001 From: Daseul Kim Date: Fri, 23 May 2025 09:52:08 +0900 Subject: [PATCH 3/4] =?UTF-8?q?refactor:=20axios=20=ED=98=B8=EC=B6=9C=20?= =?UTF-8?q?=EC=88=98=EC=A0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/hooks/useCommCode.ts | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/src/hooks/useCommCode.ts b/src/hooks/useCommCode.ts index bb50240..6c78183 100644 --- a/src/hooks/useCommCode.ts +++ b/src/hooks/useCommCode.ts @@ -1,7 +1,8 @@ -import { axiosInstance } from '@/libs/axios' +import { useAxios } from './useAxios' import type { CommCode } from '@/types/CommCode' export function useCommCode() { + const { axiosInstance } = useAxios() const getCommCode = async (headCode: string): Promise => { try { const response = await axiosInstance(null).get('/api/comm-code', { params: { headCode: headCode } }) From 36d51913119c94263f07246b1d037303634e7473 Mon Sep 17 00:00:00 2001 From: Daseul Kim Date: Fri, 23 May 2025 10:45:14 +0900 Subject: [PATCH 4/4] =?UTF-8?q?feat:=20=EC=A7=80=EB=B6=95=EC=9E=AC?= =?UTF-8?q?=EC=A0=81=ED=95=A9=EC=84=B1=20=EB=A9=94=EC=9D=B8=20=EC=B2=B4?= =?UTF-8?q?=ED=81=AC=20=EC=8B=9C=20=ED=95=98=EC=9C=84=20=EC=A0=84=EC=B2=B4?= =?UTF-8?q?=EC=B2=B4=ED=81=AC=20=EC=B2=98=EB=A6=AC=20=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/components/suitable/SuitableList.tsx | 13 +++++++++---- src/hooks/useSuitable.ts | 11 ++++++++++- src/store/useSuitableStore.ts | 6 +++--- 3 files changed, 22 insertions(+), 8 deletions(-) diff --git a/src/components/suitable/SuitableList.tsx b/src/components/suitable/SuitableList.tsx index d3086a5..f55fa67 100644 --- a/src/components/suitable/SuitableList.tsx +++ b/src/components/suitable/SuitableList.tsx @@ -9,7 +9,7 @@ import { useSuitableStore } from '@/store/useSuitableStore' import { SUITABLE_HEAD_CODE, type Suitable, type SuitableDetail } from '@/types/Suitable' export default function SuitableList() { - const { toCodeName, toSuitableDetail, suitables, fetchNextPage, hasNextPage, isFetchingNextPage, isLoading } = useSuitable() + const { toCodeName, toSuitableDetail, toSuitableDetailIds, suitables, fetchNextPage, hasNextPage, isFetchingNextPage, isLoading } = useSuitable() const { selectedItems, addSelectedItem, removeSelectedItem } = useSuitableStore() const [openItems, setOpenItems] = useState>(new Set()) const observerTarget = useRef(null) @@ -37,8 +37,8 @@ export default function SuitableList() { // 아이템 클릭 const handleItemClick = useCallback( - (mainId: number, detailId?: number): void => { - isItemSelected(mainId, detailId) ? removeSelectedItem(mainId, detailId) : addSelectedItem(mainId, detailId) + (mainId: number, detailId?: number, detailIds?: Set): void => { + isItemSelected(mainId, detailId) ? removeSelectedItem(mainId, detailId) : addSelectedItem(mainId, detailId, detailIds) }, [isItemSelected, addSelectedItem, removeSelectedItem], ) @@ -73,7 +73,12 @@ export default function SuitableList() {
    - handleItemClick(item.id)} /> + handleItemClick(item.id, undefined, toSuitableDetailIds(item.detail))} + />
    diff --git a/src/hooks/useSuitable.ts b/src/hooks/useSuitable.ts index 57ff7b5..f651b4d 100644 --- a/src/hooks/useSuitable.ts +++ b/src/hooks/useSuitable.ts @@ -9,7 +9,6 @@ export function useSuitable() { const { axiosInstance } = useAxios() const { getCommCode } = useCommCode() const { itemPerPage, selectedCategory, searchValue, suitableCommCode, setSuitableCommCode, isSearch } = useSuitableStore() - const getSuitables = async ({ pageNumber, @@ -66,6 +65,15 @@ export function useSuitable() { } } + const toSuitableDetailIds = (suitableDetailString: string): Set => { + try { + return new Set(JSON.parse(suitableDetailString).map(({ id }: { id: number }) => id)) + } catch (error) { + console.error('지붕재 데이터 파싱 실패:', error) + return new Set() + } + } + const { data: suitables, fetchNextPage, @@ -97,6 +105,7 @@ export function useSuitable() { getSuitableCommCode, toCodeName, toSuitableDetail, + toSuitableDetailIds, suitables, fetchNextPage, hasNextPage, diff --git a/src/store/useSuitableStore.ts b/src/store/useSuitableStore.ts index 74fb115..74f4d0b 100644 --- a/src/store/useSuitableStore.ts +++ b/src/store/useSuitableStore.ts @@ -28,7 +28,7 @@ interface SuitableState { /* 선택된 아이템 리스트 */ selectedItems: Map> /* 선택된 아이템 추가 */ - addSelectedItem: (mainId: number, detailId?: number) => void + addSelectedItem: (mainId: number, detailId?: number, detailIds?: Set) => void /* 선택된 아이템 제거 */ removeSelectedItem: (mainId: number, detailId?: number) => void /* 선택된 아이템 모두 제거 */ @@ -59,7 +59,7 @@ export const useSuitableStore = create((set) => ({ setSearchValue: (value: string) => set({ searchValue: value }), /* 선택된 아이템 추가 */ - addSelectedItem: (mainId: number, detailId?: number) => { + addSelectedItem: (mainId: number, detailId?: number, detailIds?: Set) => { if (detailId) { // 디테일(하위) 아이템 추가 set((state) => { @@ -71,7 +71,7 @@ export const useSuitableStore = create((set) => ({ } else { // 메인(상위) 아이템 추가 set((state) => { - state.selectedItems.set(mainId, new Set()) + state.selectedItems.set(mainId, detailIds || new Set()) return { selectedItems: state.selectedItems } }) }