diff --git a/src/app/api/suitable/list/route.ts b/src/app/api/suitable/list/route.ts index 005d556..e19e702 100644 --- a/src/app/api/suitable/list/route.ts +++ b/src/app/api/suitable/list/route.ts @@ -41,7 +41,7 @@ export async function GET(request: NextRequest) { ) AS details ON msm.id = details.main_id WHERE 1=1 - --roofMtCd AND msm.roof_mt_cd = ':roofMtCd' + --roofMtCd AND msm.roof_mt_cd IN (:roofMtCd) --productName AND msm.product_name LIKE '%:productName%' ORDER BY msm.product_name OFFSET (@P1 - 1) * @P2 ROWS @@ -50,8 +50,13 @@ export async function GET(request: NextRequest) { // 검색 조건 설정 if (category) { + const roofMtQuery = ` + SELECT roof_mt_cd + FROM ms_suitable_roof_material_group + WHERE roof_matl_grp_cd = ':roofMtGrpCd' + ` query = query.replace('--roofMtCd ', '') - query = query.replace(':roofMtCd', category) + query = query.replace(':roofMtCd', roofMtQuery.replace(':roofMtGrpCd', category)) } if (keyword) { query = query.replace('--productName ', '') @@ -60,6 +65,8 @@ export async function GET(request: NextRequest) { const suitable: Suitable[] = await prisma.$queryRawUnsafe(query, pageNumber, itemPerPage) + // console.log(`검색 조건 :::: 카테고리: ${category}, 키워드: ${keyword}`) + return NextResponse.json(suitable) } catch (error) { console.error('❌ 데이터 조회 중 오류가 발생했습니다:', error) diff --git a/src/app/api/suitable/pick/route.ts b/src/app/api/suitable/pick/route.ts new file mode 100644 index 0000000..922a1d3 --- /dev/null +++ b/src/app/api/suitable/pick/route.ts @@ -0,0 +1,51 @@ +import { NextRequest, NextResponse } from 'next/server' +import { prisma } from '@/libs/prisma' + +export async function GET(request: NextRequest) { + try { + const searchParams = request.nextUrl.searchParams + const category = searchParams.get('category') + const keyword = searchParams.get('keyword') + + let query = ` + SELECT + msm.id + , details.detail_id + FROM ms_suitable_main msm + LEFT JOIN ( + SELECT + msd.main_id + , STRING_AGG(msd.id, ',') AS detail_id + FROM ms_suitable_detail msd + GROUP BY msd.main_id + ) AS details + ON msm.id = details.main_id + WHERE 1=1 + --roofMtCd AND msm.roof_mt_cd IN (:roofMtCd) + --productName AND msm.product_name LIKE '%:productName%' + ; + ` + + // 검색 조건 설정 + if (category) { + const roofMtQuery = ` + SELECT roof_mt_cd + FROM ms_suitable_roof_material_group + WHERE roof_matl_grp_cd = ':roofMtGrpCd' + ` + query = query.replace('--roofMtCd ', '') + query = query.replace(':roofMtCd', roofMtQuery.replace(':roofMtGrpCd', category)) + } + if (keyword) { + query = query.replace('--productName ', '') + query = query.replace(':productName', keyword) + } + + const suitableIdSet = await prisma.$queryRawUnsafe(query) + + return NextResponse.json(suitableIdSet) + } catch (error) { + console.error('❌ 데이터 조회 중 오류가 발생했습니다:', error) + return NextResponse.json({ error: '데이터 조회 중 오류가 발생했습니다' }, { status: 500 }) + } +} diff --git a/src/app/api/suitable/route.ts b/src/app/api/suitable/route.ts index 7f7be9c..df42e1e 100644 --- a/src/app/api/suitable/route.ts +++ b/src/app/api/suitable/route.ts @@ -1,12 +1,63 @@ -import { NextResponse } from 'next/server' +import { NextRequest, NextResponse } from 'next/server' import { prisma } from '@/libs/prisma' +import { Suitable } from '@/types/Suitable' -export async function POST(request: Request) { - const body = await request.json() - // @ts-ignore - const suitables = await prisma.MS_SUITABLE.createMany({ - data: body, - }) +export async function GET(request: NextRequest) { + try { + const searchParams = request.nextUrl.searchParams - return NextResponse.json({ message: 'Suitable created successfully' }) + const ids = searchParams.get('ids') + const detailIds = searchParams.get('subIds') + + 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 + --ids AND details.main_id IN (:mainIds) + --detailIds AND details.id IN (:detailIds) + WHERE 1=1 + --ids AND msm.id IN (:mainIds) + ORDER BY msm.product_name; + ` + + // 검색 조건 설정 + if (ids) { + query = query.replaceAll('--ids ', '') + query = query.replaceAll(':mainIds', ids) + if (detailIds) { + query = query.replaceAll('--detailIds ', '') + query = query.replaceAll(':detailIds', detailIds) + } + } + + const suitable: Suitable[] = await prisma.$queryRawUnsafe(query) + + return NextResponse.json(suitable) + } catch (error) { + console.error('❌ 데이터 조회 중 오류가 발생했습니다:', error) + return NextResponse.json({ error: '데이터 조회 중 오류가 발생했습니다' }, { status: 500 }) + } } + diff --git a/src/components/popup/SuitableDetailPopup.tsx b/src/components/popup/SuitableDetailPopup.tsx index 59b7838..80524d0 100644 --- a/src/components/popup/SuitableDetailPopup.tsx +++ b/src/components/popup/SuitableDetailPopup.tsx @@ -1,4 +1,40 @@ +'use client' + +import Image from 'next/image' +import { useCallback, useEffect, useState } from 'react' +import { usePopupController } from '@/store/popupController' +import { useSuitableStore } from '@/store/useSuitableStore' +import SuitableDetailPopupButton from './SuitableDetailPopupButton' +import { useSuitable } from '@/hooks/useSuitable' +import { Suitable } from '@/types/Suitable' + export default function SuitableDetailPopup() { + const popupController = usePopupController() + const { getSuitableDetails, serializeSelectedItems } = useSuitable() + const { selectedItems } = useSuitableStore() + + const [openItems, setOpenItems] = useState>(new Set()) + const [suitableDetails, setSuitableDetails] = useState([]) + + // 아이템 열기/닫기 + const toggleItemOpen = useCallback((itemId: number) => { + setOpenItems((prev) => { + const newOpenItems = new Set(prev) + newOpenItems.has(itemId) ? newOpenItems.delete(itemId) : newOpenItems.add(itemId) + return newOpenItems + }) + }, []) + + // 선택된 아이템 상세 데이터 가져오기 + const getSelectedItemsData = async () => { + const serialized: Map = serializeSelectedItems() + setSuitableDetails(await getSuitableDetails(serialized.get('ids') ?? '', serialized.get('detailIds') ?? '')) + } + + useEffect(() => { + getSelectedItemsData() + }, []) + return (
@@ -7,23 +43,109 @@ export default function SuitableDetailPopup() {
- +
屋根材適合性詳細
- +
-
-
-
アースティ40
-
- +
+
+
+
アースティ40
+
+ +
+
+
+
+
屋根技研 支持瓦
+
㈱ダイトー
+
+
+
屋根材
+
+
+
+
金具タイプ
+
木ねじ打ち込み式
+
+
+
+
+
屋根技研 支持瓦
+
+
+ +
+
+ +
+
+
+
Dで設置可
+
+
備考
+
+ 桟木なしの場合は支持金具平ー1で設置可能。その場合水返しが高い為、レベルプレート使用。桟木ありの場合は支持金具平ー2で設置可能 +
+
+
+
+
+
屋根技研支持金具
+
+
+ +
+
+ +
+
+
+
設置不可
+
+
備考
+
入手困難
+
+
+
+
+
屋根技研YGアンカー
+
+
+ +
+
+
+
お問い合わせください
+
+
備考
+
入手困難
+
+
+
+
+
ダイドーハント支持瓦Ⅱ
+
+
+ +
+
+
+
Ⅳ (D) で設置可
+
+
備考
+
入手困難
+
+
+
-
+
diff --git a/src/components/popup/SuitableDetailPopupButton.tsx b/src/components/popup/SuitableDetailPopupButton.tsx new file mode 100644 index 0000000..52e1bae --- /dev/null +++ b/src/components/popup/SuitableDetailPopupButton.tsx @@ -0,0 +1,23 @@ +'use client' + +export default function SuitableDetailPopupButton() { + return ( +
+
+ +
+
+ +
+
+ +
+
+ ) +} diff --git a/src/components/suitable/Suitable.tsx b/src/components/suitable/Suitable.tsx index f5f22b2..1fec8fc 100644 --- a/src/components/suitable/Suitable.tsx +++ b/src/components/suitable/Suitable.tsx @@ -3,69 +3,24 @@ import Image from 'next/image' import { useEffect, useState } from 'react' import SuitableList from './SuitableList' +import SuitableSearch from './SuitableSearch' 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() { const [reference, setReference] = useState(true) - const { getSuitableCommCode } = useSuitable() - const { suitableCommCode, selectedCategory, setSelectedCategory, searchValue, setSearchValue, setIsSearch, clearSelectedItems } = useSuitableStore() - - const handleInputSearch = async () => { - if (!searchValue.trim()) { - alert('屋根材の製品名を入力してください。') - return - } - setIsSearch(true) - } - - const handleInputClear = () => { - setSearchValue('') - setIsSearch(false) - } + const { getSuitableCommCode, clearSuitableSearch } = useSuitable() useEffect(() => { getSuitableCommCode() return () => { - setSelectedCategory('') - setSearchValue('') - clearSelectedItems() + clearSuitableSearch({ items: true, category: true, keyword: true }) } }, []) return (
-
- -
-
-
- setSearchValue(e.target.value)} - onKeyDown={(e) => { - if (e.key === 'Enter') { - handleInputSearch() - } - }} - /> - {searchValue &&
-
+
diff --git a/src/components/suitable/SuitableButton.tsx b/src/components/suitable/SuitableButton.tsx index f412c89..9664f2c 100644 --- a/src/components/suitable/SuitableButton.tsx +++ b/src/components/suitable/SuitableButton.tsx @@ -1,16 +1,42 @@ 'use client' +import { usePopupController } from '@/store/popupController' +import { useSuitable } from '@/hooks/useSuitable' +import { useSuitableStore } from '@/store/useSuitableStore' + export default function SuitableButton() { + const popupController = usePopupController() + const { getSuitableIds, clearSuitableSearch } = useSuitable() + const { selectedItems, addAllSelectedItem } = useSuitableStore() + + const handleSelectAll = async () => { + addAllSelectedItem(await getSuitableIds()) + } + + const handleOpenPopup = () => { + if (selectedItems.size === 0) { + alert('屋根材を選択してください。') + return + } + popupController.setSuitableDetailPopup(true) + } + return (
- + {selectedItems.size === 0 ? ( + + ) : ( + + )}
-
diff --git a/src/components/suitable/SuitableList.tsx b/src/components/suitable/SuitableList.tsx index f55fa67..9936494 100644 --- a/src/components/suitable/SuitableList.tsx +++ b/src/components/suitable/SuitableList.tsx @@ -117,7 +117,7 @@ export default function SuitableList() { ) // 아이템 리스트 - const suitableList = suitables?.pages.flat() ?? [] + const suitableList = useMemo(() => suitables?.pages.flat() ?? [], [suitables?.pages]) // Intersection Observer 설정 useEffect(() => { diff --git a/src/components/suitable/SuitableSearch.tsx b/src/components/suitable/SuitableSearch.tsx new file mode 100644 index 0000000..8b4a883 --- /dev/null +++ b/src/components/suitable/SuitableSearch.tsx @@ -0,0 +1,76 @@ +'use client' + +import { useEffect, useState } from 'react' +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 SuitableSearch() { + const [searchValue, setSearchValue] = useState('') + + const { getSuitableCommCode, clearSuitableSearch } = useSuitable() + const { suitableCommCode, selectedCategory, setSelectedCategory, setSearchKeyword } = useSuitableStore() + + const handleInputChange = (value: string) => { + if (Array.from(value).length > 30) { + alert('検索ワードは最大30文字まで入力できます。') + setSearchValue(value.slice(0, 30)) + return + } + setSearchValue(value) + } + + const handleInputSearch = async () => { + if (!searchValue.trim()) { + alert('屋根材の製品名を入力してください。') + return + } + setSearchKeyword(searchValue) + } + + const handleInputClear = () => { + setSearchValue('') + clearSuitableSearch({ items: true, keyword: true }) + } + + useEffect(() => { + getSuitableCommCode() + return () => { + clearSuitableSearch({ items: true, category: true, keyword: true }) + } + }, []) + + return ( + <> +
+ +
+
+
+ handleInputChange(e.target.value)} + onKeyDown={(e) => { + if (e.key === 'Enter') { + handleInputSearch() + } + }} + /> + {searchValue &&
+
+ + ) +} diff --git a/src/hooks/useSuitable.ts b/src/hooks/useSuitable.ts index f651b4d..5acad45 100644 --- a/src/hooks/useSuitable.ts +++ b/src/hooks/useSuitable.ts @@ -3,21 +3,29 @@ 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' +import { SUITABLE_HEAD_CODE, type Suitable, type SuitableDetail, type SuitableIds } from '@/types/Suitable' export function useSuitable() { const { axiosInstance } = useAxios() const { getCommCode } = useCommCode() - const { itemPerPage, selectedCategory, searchValue, suitableCommCode, setSuitableCommCode, isSearch } = useSuitableStore() + const { + itemPerPage, + suitableCommCode, + setSuitableCommCode, + selectedCategory, + clearSelectedCategory, + searchKeyword, + clearSearchKeyword, + selectedItems, + clearSelectedItems, + } = useSuitableStore() const getSuitables = async ({ pageNumber, - ids, category, keyword, }: { pageNumber?: number - ids?: string category?: string keyword?: string }): Promise => { @@ -26,7 +34,6 @@ export function useSuitable() { pageNumber: pageNumber || 1, itemPerPage: itemPerPage, } - if (ids) params.ids = ids if (category) params.category = category if (keyword) params.keyword = keyword @@ -38,6 +45,31 @@ export function useSuitable() { } } + const getSuitableIds = async (): Promise => { + try { + const params: Record = {} + if (selectedCategory) params.category = selectedCategory + if (searchKeyword) params.keyword = searchKeyword + const response = await axiosInstance(null).get('/api/suitable/pick', { params }) + return response.data + } catch (error) { + console.error('지붕재 아이디 로드 실패:', error) + return [] + } + } + + const getSuitableDetails = async (ids: string, detailIds?: string): Promise => { + try { + const params: Record = { ids: ids } + if (detailIds) params.detailIds = detailIds + const response = await axiosInstance(null).get('/api/suitable', { params }) + 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) { @@ -83,13 +115,14 @@ export function useSuitable() { isError, error, } = useInfiniteQuery({ - queryKey: ['suitables', 'list', selectedCategory, isSearch], + queryKey: ['suitables', 'list', selectedCategory, searchKeyword], queryFn: async (context) => { const pageParam = context.pageParam as number + if (pageParam === 1) clearSuitableSearch({ items: true }) return await getSuitables({ pageNumber: pageParam, ...(selectedCategory && { category: selectedCategory }), - ...(isSearch && { keyword: searchValue }), + ...(searchKeyword && { keyword: searchKeyword }), }) }, getNextPageParam: (lastPage: Suitable[], allPages: Suitable[][]) => { @@ -98,10 +131,32 @@ export function useSuitable() { initialPageParam: 1, staleTime: 1000 * 60 * 10, gcTime: 1000 * 60 * 10, + enabled: selectedCategory !== '' || searchKeyword !== '', }) + const serializeSelectedItems = (): Map => { + const ids: string[] = [] + const detailIds: string[] = [] + for (const [key, value] of selectedItems) { + ids.push(String(key)) + for (const id of value) detailIds.push(String(id)) + } + return new Map([ + ['ids', ids.join(',')], + ['detailIds', detailIds.join(',')], + ]) + } + + const clearSuitableSearch = ({ items = false, category = false, keyword = false }: { items?: boolean; category?: boolean; keyword?: boolean }) => { + if (items) clearSelectedItems() + if (category) clearSelectedCategory() + if (keyword) clearSearchKeyword() + } + return { getSuitables, + getSuitableIds, + getSuitableDetails, getSuitableCommCode, toCodeName, toSuitableDetail, @@ -111,5 +166,7 @@ export function useSuitable() { hasNextPage, isFetchingNextPage, isLoading, + serializeSelectedItems, + clearSuitableSearch, } } diff --git a/src/store/useSuitableStore.ts b/src/store/useSuitableStore.ts index 74f4d0b..dafe53a 100644 --- a/src/store/useSuitableStore.ts +++ b/src/store/useSuitableStore.ts @@ -1,5 +1,6 @@ import { create } from 'zustand' import type { CommCode } from '@/types/CommCode' +import type { SuitableIds } from '@/types/Suitable' interface SuitableState { /* 초기 데이터 로드 개수*/ @@ -10,25 +11,26 @@ interface SuitableState { /* 공통코드 설정 */ setSuitableCommCode: (headCode: string, commCode: CommCode[]) => void - /* 검색 상태 */ - isSearch: boolean - /* 검색 상태 설정 */ - setIsSearch: (isSearch: boolean) => void - /* 선택된 카테고리 */ selectedCategory: string /* 선택된 카테고리 설정 */ setSelectedCategory: (category: string) => void + /* 선택된 카테고리 초기화 */ + clearSelectedCategory: () => void /* 검색 값 */ - searchValue: string + searchKeyword: string /* 검색 값 설정 */ - setSearchValue: (value: string) => void + setSearchKeyword: (value: string) => void + /* 검색 값 초기화 */ + clearSearchKeyword: () => void /* 선택된 아이템 리스트 */ selectedItems: Map> - /* 선택된 아이템 추가 */ + /* 선택 아이템 추가 */ addSelectedItem: (mainId: number, detailId?: number, detailIds?: Set) => void + /* 아이템 전체 추가 */ + addAllSelectedItem: (suitableIds: SuitableIds[]) => void /* 선택된 아이템 제거 */ removeSelectedItem: (mainId: number, detailId?: number) => void /* 선택된 아이템 모두 제거 */ @@ -38,9 +40,8 @@ interface SuitableState { export const useSuitableStore = create((set) => ({ itemPerPage: 100 as number, suitableCommCode: new Map() as Map, - isSearch: false as boolean, selectedCategory: '' as string, - searchValue: '' as string, + searchKeyword: '' as string, selectedItems: new Map() as Map>, /* 공통코드 설정 */ @@ -49,14 +50,15 @@ export const useSuitableStore = create((set) => ({ suitableCommCode: new Map(state.suitableCommCode).set(headCode, commCode), })), - /* 검색 상태 설정 */ - setIsSearch: (isSearch: boolean) => set({ isSearch }), - /* 선택된 카테고리 설정 */ setSelectedCategory: (category: string) => set({ selectedCategory: category }), + /* 선택된 카테고리 초기화 */ + clearSelectedCategory: () => set({ selectedCategory: '' }), /* 검색 값 설정 */ - setSearchValue: (value: string) => set({ searchValue: value }), + setSearchKeyword: (value: string) => set({ searchKeyword: value }), + /* 검색 값 초기화 */ + clearSearchKeyword: () => set({ searchKeyword: '' }), /* 선택된 아이템 추가 */ addSelectedItem: (mainId: number, detailId?: number, detailIds?: Set) => { @@ -77,6 +79,17 @@ export const useSuitableStore = create((set) => ({ } }, + /* 아이템 전체 추가 */ + addAllSelectedItem: (suitableIds: SuitableIds[]) => { + set(() => { + const newSelectedItems = new Map() + suitableIds.forEach((suitableId) => { + newSelectedItems.set(suitableId.id, new Set(suitableId.detailId.split(',').map(Number))) + }) + return { selectedItems: newSelectedItems } + }) + }, + /* 선택된 아이템 제거 */ removeSelectedItem: (mainId: number, detailId?: number) => { set((state) => { diff --git a/src/types/Suitable.ts b/src/types/Suitable.ts index 270dd46..a93d246 100644 --- a/src/types/Suitable.ts +++ b/src/types/Suitable.ts @@ -1,11 +1,13 @@ export enum SUITABLE_HEAD_CODE { /* 지붕재 제조사명 */ MANU_FT_CD = 'MANU_FT_CD', + /* 지붕재 그룹 종류 */ + ROOF_MATERIAL_GROUP = 'ROOF_MATL_GRP_CD', /* 지붕재 종류 */ ROOF_MT_CD = 'ROOF_MT_CD', /* 마운팅 브래킷 종류 */ ROOF_SH_CD = 'ROOF_SH_CD', - /* 마운팅 브래킷 제조사명 및 제품코드드 */ + /* 마운팅 브래킷 제조사명 및 제품코드 */ TRESTLE_MFPC_CD = 'TRESTLE_MFPC_CD', } @@ -34,3 +36,8 @@ export type Suitable = { detailCnt: number detail: string } + +export type SuitableIds = { + id: number + detailId: string +}