Merge pull request 'feat: 지붕재 데이터 조회 시 스피너 로딩처리 추가, 사이드바 지붕재적합성 페이지 이동 연결' (#61) from feature/suitable into dev

Reviewed-on: #61
This commit is contained in:
swyoo 2025-06-05 13:49:40 +09:00
commit 5feef03187
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)
// console.log(`검색 조건 :::: 카테고리: ${category}, 키워드: ${keyword}`)
return NextResponse.json(suitable)
return NextResponse.json(suitable, {
headers: {
'spinner-state': 'true',
},
})
} catch (error) {
console.error('❌ 데이터 조회 중 오류가 발생했습니다:', error)
return NextResponse.json({ error: '데이터 조회 중 오류가 발생했습니다' }, { status: 500 })

View File

@ -6,6 +6,7 @@ import SuitableDetailPopupButton from './SuitableDetailPopupButton'
import { useSuitable } from '@/hooks/useSuitable'
import { usePopupController } from '@/store/popupController'
import { useSuitableStore } from '@/store/useSuitableStore'
import { useSpinnerStore } from '@/store/spinnerStore'
import { SUITABLE_HEAD_CODE, type Suitable, type SuitableDetail } from '@/types/Suitable'
export default function SuitableDetailPopup() {
@ -15,7 +16,7 @@ export default function SuitableDetailPopup() {
const [openItems, setOpenItems] = useState<Set<number>>(new Set())
// 아이템 열기/닫기
/* 아이템 열기/닫기 */
const toggleItemOpen = useCallback((itemId: number) => {
setOpenItems((prev) => {
const newOpenItems = new Set(prev)
@ -24,6 +25,11 @@ export default function SuitableDetailPopup() {
})
}, [])
/* 데이터 로딩 상태 처리 */
useEffect(() => {
useSpinnerStore.getState().setIsShow(isSelectedSuitablesLoading)
}, [isSelectedSuitablesLoading])
useEffect(() => {
setSelectedItemsSearching(true)
}, [])
@ -45,60 +51,56 @@ export default function SuitableDetailPopup() {
</div>
<div className="modal-body">
<div className="compliance-check-pop-wrap">
{isSelectedSuitablesLoading ? (
<div>Loading...</div>
) : (
selectedSuitables?.map((item: Suitable) => (
<div className={`compliance-check-bx ${openItems.has(item.id) ? 'act' : ''}`} key={item.id}>
<div className="check-name-wrap">
<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>
{selectedSuitables?.map((item: Suitable) => (
<div className={`compliance-check-bx ${openItems.has(item.id) ? 'act' : ''}`} key={item.id}>
<div className="check-name-wrap">
<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>
<SuitableDetailPopupButton />
</div>

View File

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

View File

@ -111,7 +111,7 @@ export default function Header() {
</div>
</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-infor"> </div>
</div>

View File

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