feat: 지붕재 데이터 조회 시 스피너 로딩처리 추가, 사이드바 지붕재적합성 페이지 이동 연결 #61

Merged
swyoo merged 3 commits from feature/suitable into dev 2025-06-05 13:49:40 +09:00
5 changed files with 81 additions and 72 deletions

View File

@ -65,9 +65,11 @@ export async function GET(request: NextRequest) {
const suitable: Suitable[] = await prisma.$queryRawUnsafe(query, pageNumber, itemPerPage) const suitable: Suitable[] = await prisma.$queryRawUnsafe(query, pageNumber, itemPerPage)
// console.log(`검색 조건 :::: 카테고리: ${category}, 키워드: ${keyword}`) return NextResponse.json(suitable, {
headers: {
return NextResponse.json(suitable) 'spinner-state': 'true',
},
})
} catch (error) { } catch (error) {
console.error('❌ 데이터 조회 중 오류가 발생했습니다:', error) console.error('❌ 데이터 조회 중 오류가 발생했습니다:', error)
return NextResponse.json({ error: '데이터 조회 중 오류가 발생했습니다' }, { status: 500 }) return NextResponse.json({ error: '데이터 조회 중 오류가 발생했습니다' }, { status: 500 })

View File

@ -6,6 +6,7 @@ import SuitableDetailPopupButton from './SuitableDetailPopupButton'
import { useSuitable } from '@/hooks/useSuitable' import { useSuitable } from '@/hooks/useSuitable'
import { usePopupController } from '@/store/popupController' import { usePopupController } from '@/store/popupController'
import { useSuitableStore } from '@/store/useSuitableStore' import { useSuitableStore } from '@/store/useSuitableStore'
import { useSpinnerStore } from '@/store/spinnerStore'
import { SUITABLE_HEAD_CODE, type Suitable, type SuitableDetail } from '@/types/Suitable' import { SUITABLE_HEAD_CODE, type Suitable, type SuitableDetail } from '@/types/Suitable'
export default function SuitableDetailPopup() { export default function SuitableDetailPopup() {
@ -15,7 +16,7 @@ export default function SuitableDetailPopup() {
const [openItems, setOpenItems] = useState<Set<number>>(new Set()) const [openItems, setOpenItems] = useState<Set<number>>(new Set())
// 아이템 열기/닫기 /* 아이템 열기/닫기 */
const toggleItemOpen = useCallback((itemId: number) => { const toggleItemOpen = useCallback((itemId: number) => {
setOpenItems((prev) => { setOpenItems((prev) => {
const newOpenItems = new Set(prev) const newOpenItems = new Set(prev)
@ -24,6 +25,11 @@ export default function SuitableDetailPopup() {
}) })
}, []) }, [])
/* 데이터 로딩 상태 처리 */
useEffect(() => {
useSpinnerStore.getState().setIsShow(isSelectedSuitablesLoading)
}, [isSelectedSuitablesLoading])
useEffect(() => { useEffect(() => {
setSelectedItemsSearching(true) setSelectedItemsSearching(true)
}, []) }, [])
@ -45,60 +51,56 @@ export default function SuitableDetailPopup() {
</div> </div>
<div className="modal-body"> <div className="modal-body">
<div className="compliance-check-pop-wrap"> <div className="compliance-check-pop-wrap">
{isSelectedSuitablesLoading ? ( {selectedSuitables?.map((item: Suitable) => (
<div>Loading...</div> <div className={`compliance-check-bx ${openItems.has(item.id) ? 'act' : ''}`} key={item.id}>
) : ( <div className="check-name-wrap">
selectedSuitables?.map((item: Suitable) => ( <div className="check-name">{item.productName}</div>
<div className={`compliance-check-bx ${openItems.has(item.id) ? 'act' : ''}`} key={item.id}> <div className="check-name-btn">
<div className="check-name-wrap"> <button className="bx-btn" onClick={() => toggleItemOpen(item.id)}></button>
<div className="check-name">{item.productName}</div>
<div className="check-name-btn">
<button className="bx-btn" onClick={() => toggleItemOpen(item.id)}></button>
</div>
</div>
<div className="compliance-check-pop-contents">
<div className="check-pop-data-wrap">
<div className="check-pop-data-tit"> </div>
<div className="check-pop-data-txt">{toCodeName(SUITABLE_HEAD_CODE.MANU_FT_CD, item.manuFtCd)}</div>
</div>
<div className="check-pop-data-wrap">
<div className="check-pop-data-tit"></div>
<div className="check-pop-data-txt">{toCodeName(SUITABLE_HEAD_CODE.ROOF_MT_CD, item.roofMtCd)}</div>
</div>
<div className="check-pop-data-wrap">
<div className="check-pop-data-tit"></div>
<div className="check-pop-data-txt">{toCodeName(SUITABLE_HEAD_CODE.ROOF_SH_CD, item.roofShCd)}</div>
</div>
<div className="check-pop-data-table-wrap">
{toSuitableDetail(item.detail).map((subItem: SuitableDetail) => (
<div className="check-pop-data-table" key={subItem.id}>
<div className="pop-data-table-head">
<div className="pop-data-table-head-name">{toCodeName(SUITABLE_HEAD_CODE.TRESTLE_MFPC_CD, subItem.trestleMfpcCd)}</div>
<div className="pop-data-table-head-icon">
<div className="compliance-icon">
<Image src={suitableCheckIcon(subItem.trestleManufacturerProductName)} width={22} height={22} alt="" />
</div>
{subItem.memo && (
<div className="compliance-icon">
<Image src={'/assets/images/sub/compliance_tip_icon.svg'} width={22} height={22} alt=""></Image>
</div>
)}
</div>
</div>
<div className="pop-data-table-body">{suitableCheckMemo(subItem.trestleManufacturerProductName)}</div>
{subItem.memo && (
<div className="pop-data-table-footer">
<div className="pop-data-table-footer-unit"></div>
<div className="pop-data-table-footer-data">{subItem.memo}</div>
</div>
)}
</div>
))}
</div>
</div> </div>
</div> </div>
)) <div className="compliance-check-pop-contents">
)} <div className="check-pop-data-wrap">
<div className="check-pop-data-tit"> </div>
<div className="check-pop-data-txt">{toCodeName(SUITABLE_HEAD_CODE.MANU_FT_CD, item.manuFtCd)}</div>
</div>
<div className="check-pop-data-wrap">
<div className="check-pop-data-tit"></div>
<div className="check-pop-data-txt">{toCodeName(SUITABLE_HEAD_CODE.ROOF_MT_CD, item.roofMtCd)}</div>
</div>
<div className="check-pop-data-wrap">
<div className="check-pop-data-tit"></div>
<div className="check-pop-data-txt">{toCodeName(SUITABLE_HEAD_CODE.ROOF_SH_CD, item.roofShCd)}</div>
</div>
<div className="check-pop-data-table-wrap">
{toSuitableDetail(item.detail).map((subItem: SuitableDetail) => (
<div className="check-pop-data-table" key={subItem.id}>
<div className="pop-data-table-head">
<div className="pop-data-table-head-name">{toCodeName(SUITABLE_HEAD_CODE.TRESTLE_MFPC_CD, subItem.trestleMfpcCd)}</div>
<div className="pop-data-table-head-icon">
<div className="compliance-icon">
<Image src={suitableCheckIcon(subItem.trestleManufacturerProductName)} width={22} height={22} alt="" />
</div>
{subItem.memo && (
<div className="compliance-icon">
<Image src={'/assets/images/sub/compliance_tip_icon.svg'} width={22} height={22} alt=""></Image>
</div>
)}
</div>
</div>
<div className="pop-data-table-body">{suitableCheckMemo(subItem.trestleManufacturerProductName)}</div>
{subItem.memo && (
<div className="pop-data-table-footer">
<div className="pop-data-table-footer-unit"></div>
<div className="pop-data-table-footer-data">{subItem.memo}</div>
</div>
)}
</div>
))}
</div>
</div>
</div>
))}
</div> </div>
<SuitableDetailPopupButton /> <SuitableDetailPopupButton />
</div> </div>

View File

@ -6,6 +6,7 @@ import SuitableButton from './SuitableButton'
import SuitableNoData from './SuitableNoData' import SuitableNoData from './SuitableNoData'
import { useSuitable } from '@/hooks/useSuitable' import { useSuitable } from '@/hooks/useSuitable'
import { useSuitableStore } from '@/store/useSuitableStore' import { useSuitableStore } from '@/store/useSuitableStore'
import { useSpinnerStore } from '@/store/spinnerStore'
import { SUITABLE_HEAD_CODE, type Suitable, type SuitableDetail } from '@/types/Suitable' import { SUITABLE_HEAD_CODE, type Suitable, type SuitableDetail } from '@/types/Suitable'
export default function SuitableList() { export default function SuitableList() {
@ -24,7 +25,7 @@ export default function SuitableList() {
const [openItems, setOpenItems] = useState<Set<number>>(new Set()) const [openItems, setOpenItems] = useState<Set<number>>(new Set())
const observerTarget = useRef<HTMLDivElement>(null) const observerTarget = useRef<HTMLDivElement>(null)
// 선택된 아이템 확인 - 메인 하위 아이템 indeterminate 확인 /* 선택된 아이템 확인 - 메인 하위 아이템 indeterminate 확인 */
const isMainIndeterminate = useMemo( const isMainIndeterminate = useMemo(
() => (mainId: number, detailCnt: number) => { () => (mainId: number, detailCnt: number) => {
const mainItem = selectedItems.get(mainId) const mainItem = selectedItems.get(mainId)
@ -34,7 +35,7 @@ export default function SuitableList() {
[selectedItems], [selectedItems],
) )
// 선택된 아이템 확인 /* 선택된 아이템 확인 */
const isItemSelected = useCallback( const isItemSelected = useCallback(
(mainId: number, detailId?: number): boolean => { (mainId: number, detailId?: number): boolean => {
const mainItem = selectedItems.get(mainId) const mainItem = selectedItems.get(mainId)
@ -45,7 +46,7 @@ export default function SuitableList() {
[selectedItems], [selectedItems],
) )
// 아이템 클릭 /* 아이템 클릭 */
const handleItemClick = useCallback( const handleItemClick = useCallback(
(mainId: number, detailId?: number, detailIds?: Set<number>): void => { (mainId: number, detailId?: number, detailIds?: Set<number>): void => {
setSelectedItemsSearching(false) setSelectedItemsSearching(false)
@ -54,7 +55,7 @@ export default function SuitableList() {
[isItemSelected, addSelectedItem, removeSelectedItem], [isItemSelected, addSelectedItem, removeSelectedItem],
) )
// 아이템 열기/닫기 /* 아이템 열기/닫기 */
const toggleItemOpen = useCallback((itemId: number) => { const toggleItemOpen = useCallback((itemId: number) => {
setOpenItems((prev) => { setOpenItems((prev) => {
const newOpenItems = new Set(prev) const newOpenItems = new Set(prev)
@ -63,7 +64,7 @@ export default function SuitableList() {
}) })
}, []) }, [])
// 아이템 렌더링 /* 아이템 렌더링 */
const renderItem = useCallback( const renderItem = useCallback(
(item: Suitable) => { (item: Suitable) => {
return ( return (
@ -115,10 +116,10 @@ export default function SuitableList() {
[isItemSelected, openItems, handleItemClick, toggleItemOpen, toCodeName, toSuitableDetail], [isItemSelected, openItems, handleItemClick, toggleItemOpen, toCodeName, toSuitableDetail],
) )
// 아이템 리스트 /* 조회 데이터 리스트 */
const suitableList = useMemo(() => suitables?.pages.flat() ?? [], [suitables?.pages]) const suitableList = useMemo(() => suitables?.pages.flat() ?? [], [suitables?.pages])
// Intersection Observer 설정 /* Intersection Observer 설정 */
useEffect(() => { useEffect(() => {
const observer = new IntersectionObserver( const observer = new IntersectionObserver(
(entries) => { (entries) => {
@ -128,7 +129,7 @@ export default function SuitableList() {
}, },
{ {
threshold: 0, threshold: 0,
rootMargin: '100px', rootMargin: `${window.innerHeight * 0.2}px`,
}, },
) )
@ -139,15 +140,18 @@ export default function SuitableList() {
return () => observer.disconnect() return () => observer.disconnect()
}, [hasNextPage, isFetchingNextPage, fetchNextPage]) }, [hasNextPage, isFetchingNextPage, fetchNextPage])
if (isLoading) return <div>Loading...</div> /* 데이터 로딩 상태 처리 */
useEffect(() => {
useSpinnerStore.getState().setIsShow(isLoading || isFetchingNextPage)
}, [isLoading, isFetchingNextPage])
/* 조회 데이터 없는 경우 */
if (!suitableList.length) return <SuitableNoData /> if (!suitableList.length) return <SuitableNoData />
return ( return (
<> <>
{suitableList.map(renderItem)} {suitableList.map(renderItem)}
<div ref={observerTarget} className="loading-indicator"> <div ref={observerTarget} />
{isFetchingNextPage && <div className="loading-more"> ...</div>}
</div>
<SuitableButton /> <SuitableButton />
</> </>
) )

View File

@ -111,7 +111,7 @@ export default function Header() {
</div> </div>
</SwiperSlide> </SwiperSlide>
<SwiperSlide> <SwiperSlide>
<div className="side-swiper-card"> <div className="side-swiper-card" onClick={() => router.push('/suitable')}>
<div className="side-swiper-icon icon01"></div> <div className="side-swiper-icon icon01"></div>
<div className="side-swiper-infor"> </div> <div className="side-swiper-infor"> </div>
</div> </div>

View File

@ -9,9 +9,10 @@ export function useAxios() {
} }
const responseHandler = (response: AxiosResponse) => { const responseHandler = (response: AxiosResponse) => {
// if (response.headers['spinner-state'] === undefined) { /* spinner 조작 커스텀이 필요한 경우 api 응답 헤더에 spinner-state: true 추가 */
useSpinnerStore.getState().setIsShow(false) if (!response.headers['spinner-state']) {
// } useSpinnerStore.getState().setIsShow(false)
}
response.data = transferResponse(response) response.data = transferResponse(response)
return response return response
} }