Files
frontend/apps/sn/app/(map-layout)/map/searchModal/reportTab.tsx
T
2026-07-09 15:57:30 +09:00

312 lines
10 KiB
TypeScript

/**
* ➡️ 경로 : 하수관망도 메인 페이지 (오른쪽 돋보기 모양 클릭) - 검색 모달 창 내 하수도정비기본계획보고서 탭
*
* 📄 Report Tab
* Next.js App Router (app/) 기반 검색 모달 보고서 탭 컴포넌트
*
* - 하수도정비 기본계획 보고서 목록 표시 및 필터링
* - 시도/시군구/구분/보고서 구분/승인년도 기반 검색 기능
* - 테이블 데이터 표시 및 페이지네이션 지원
* - 파일명 클릭 시 대표 파일 바로 다운로드
* - 탭 진입 시 기본 조건 자동 조회 지원
* - Zustand를 통한 모달 상태 및 데이터 관리
* - 로딩 오버레이 및 선택 초기화 기능
*/
'use client';
import React, { useEffect, useMemo, useRef, useState } from 'react';
import { Button, Label } from '@common/components';
import { YearRangeSelectBox } from '@/components/ui/YearRangeSelectBox';
import { SelectBox, UiTable } from '@common/business_components/ui';
import LoadingOverlay from '@/components/ui/LoadingBar';
import useReportTab from './useReportTab';
import useStore from '@/store/useStore';
import { CtpvSggPrcsFcltSelectBox } from '@/components/ui/ctpvSggPrcsFcltSelectBox';
import useSearchModalStore from '@/store/useSearchModalStore';
import { useReportTabStore } from '@/store/useReportTabStore';
// 하수도정비 기본계획 보고서
export const reportTableHeadlist = [
{ id: 'no', title: 'NO' },
{ id: 'sido', title: '시도' },
{ id: 'sigungu', title: '시군구' },
{ id: 'approvalYear', title: '승인년도' },
{ id: 'plantZoneGubun', title: '구분' },
{ id: 'reportGubun', title: '보고서구분' },
{ id: 'fileName', title: '파일명' },
{ id: 'regDate', title: '등록일자' },
];
interface ReportTabProps {
open?: boolean;
isSearch?: boolean;
ctpvNm?: string;
sggNm?: string;
}
const normalizeYear = (value: string) => {
if (!value || value === '0' || value === '9999') return '';
return value;
};
const getApprovalYearNumber = (value: string | number | undefined) => {
const normalized = String(value ?? '').replace('년', '').trim();
const parsed = Number(normalized);
return Number.isFinite(parsed) ? parsed : null;
};
const ReportTab: React.FC<ReportTabProps> = ({ open, isSearch = true, ctpvNm, sggNm }) => {
const [isResetPage, setIsResetPage] = useState<boolean>(false);
const [isResetSelection, setIsResetSelection] = useState<boolean>(false);
const [showTable, setShowTable] = useState(false);
const [ctpvCd, setCtpvCd] = useState('0');
const [sggCd, setSggCd] = useState('0');
const [plantZoneGubun, setPlantZoneGubun] = useState('0');
const [reportGubun, setReportGubun] = useState('0');
const hasAutoFetchedRef = useRef(false);
const {
tableData,
tableCount,
isLoading,
yearFilter,
curPage,
setTableData,
setTableCount,
setIsLoading,
setYearFilter,
setCurPage
} = useSearchModalStore();
const { ctpv, sgg } = useStore();
const {
dcTree: reportOriginData,
fetchDcTree
} = useReportTabStore();
const { REPORT_TYPES, flattenFolders, addFileName, downloadReportFile } = useReportTab();
const flattenedReportData = useMemo(() => flattenFolders(reportOriginData), [reportOriginData]);
const plantZoneOptions = useMemo(() => {
const uniqueOptions = Array.from(
new Set(
flattenedReportData
.map((item) => String(item.plantZoneGubun ?? '').trim())
.filter((item) => item.length > 0)
)
).sort((a, b) => a.localeCompare(b, 'ko'));
return [
{ val: '0', text: '전체' },
...uniqueOptions.map((item) => ({ val: item, text: item })),
];
}, [flattenedReportData]);
useEffect(() => {
void fetchDcTree('기본계획');
}, [fetchDcTree]);
useEffect(() => {
hasAutoFetchedRef.current = false;
setCtpvCd('0');
setSggCd('0');
setPlantZoneGubun('0');
setReportGubun('0');
setYearFilter({ start: '', end: '' });
setShowTable(false);
setTableData([]);
setTableCount(0);
setCurPage(1);
setIsResetPage(true);
setIsResetSelection(true);
}, [open, setCurPage, setTableCount, setTableData, setYearFilter]);
useEffect(() => {
const newCtpvCd = ctpv.find((n) => n.text === ctpvNm)?.val || '0';
const newSggCd = sgg.find((n) => n.text === sggNm)?.val || '0';
setCtpvCd(newCtpvCd);
setSggCd(newSggCd);
}, [ctpv, ctpvNm, sgg, sggNm]);
useEffect(() => {
if (!open || flattenedReportData.length === 0 || hasAutoFetchedRef.current) return;
hasAutoFetchedRef.current = true;
void fetchFilteredData(1);
}, [flattenedReportData.length, open]);
useEffect(() => {
setIsResetPage(false);
if (showTable) {
void fetchFilteredData();
}
}, [curPage]);
const handleCtpvCdChange = (sidoId: string) => {
setCtpvCd(sidoId);
setSggCd('0');
};
const handleSearch = () => {
hasAutoFetchedRef.current = true;
setCurPage(1);
void fetchFilteredData(1);
setIsResetPage(true);
setIsResetSelection(false);
setShowTable(true);
};
const handleReportCellClick = (index: number | undefined, headId: string) => {
if (headId !== 'fileName') return;
if (typeof index !== 'number' || !tableData[index]) return;
const reportData = tableData[index];
if (!reportData.fileSn) return;
void (async () => {
setIsLoading(true);
try {
await downloadReportFile(reportData);
} finally {
setIsLoading(false);
}
})();
};
const fetchFilteredData = async (page = curPage) => {
setIsLoading(true);
setIsResetSelection(true);
try {
let filterReportData = flattenedReportData;
if (ctpvCd !== '0') {
const sidoOption = ctpv.find((n) => String(n.val).trim() === ctpvCd.trim());
filterReportData = filterReportData.filter((item) => item.sido === sidoOption?.text);
}
if (sggCd !== '0') {
const sigunguOption = sgg.find((n) => String(n.val) === String(sggCd));
filterReportData = filterReportData.filter((item) => item.sigungu === sigunguOption?.text);
}
if (plantZoneGubun !== '0') {
filterReportData = filterReportData.filter((item) => String(item.plantZoneGubun ?? '') === plantZoneGubun);
}
if (reportGubun !== '0') {
const reportGubunOption = REPORT_TYPES.find((n) => n.val === reportGubun);
filterReportData = filterReportData.filter((item) => item.reportGubun === reportGubunOption?.text);
}
const normalizedStartYear = normalizeYear(yearFilter.start);
const normalizedEndYear = normalizeYear(yearFilter.end);
if (normalizedStartYear || normalizedEndYear) {
filterReportData = filterReportData.filter((item) => {
const approvalYear = getApprovalYearNumber(item.approvalYear);
if (approvalYear === null) return false;
if (normalizedStartYear && approvalYear < Number(normalizedStartYear)) return false;
if (normalizedEndYear && approvalYear > Number(normalizedEndYear)) return false;
return true;
});
}
setTableCount(filterReportData.length || 0);
const pagedReportData = filterReportData
.slice((page - 1) * 10, page * 10)
.map((item, index) => ({ ...item, no: (page - 1) * 10 + index + 1 }));
const reportRows = await addFileName(pagedReportData);
setTableData(reportRows || []);
setShowTable(true);
setIsResetSelection(false);
} catch (err) {
console.error('API ERROR:', err);
setTableData([]);
setIsResetSelection(false);
} finally {
setIsLoading(false);
}
};
return (
<>
<div className="relative mt-4 rounded-lg border px-5 pt-5 pb-3">
<div className="overflow-x-auto">
<div className="flex min-w-max items-center gap-4 whitespace-nowrap">
{isSearch && (
<CtpvSggPrcsFcltSelectBox
className="shrink-0"
sido={ctpvCd}
sigungu={sggCd}
onSidoChange={(sidoId: string) => { handleCtpvCdChange(sidoId); }}
onSigunguChange={setSggCd}
setIsLoading={setIsLoading}
/>
)}
<YearRangeSelectBox
className="shrink-0 !mr-1"
selectClass="w-[78px] flex-shrink-0 rounded-lg bg-field text-foreground"
start={yearFilter.start}
end={yearFilter.end}
onChange={(range) => setYearFilter({
start: normalizeYear(range.start),
end: normalizeYear(range.end),
})}
startLabel="승인년도"
endLabel=""
/>
<Label className="mr-2 whitespace-nowrap text-xl font-medium text-label">구분</Label>
<SelectBox
className="w-36 shrink-0"
selectClass="rounded-lg bg-field text-foreground"
label=""
selectArray={plantZoneOptions}
value={plantZoneGubun}
onSelectValue={(val) => setPlantZoneGubun(String(val))}
selectContentClass="z-[101]"
/>
<Label className="mr-2 whitespace-nowrap text-xl font-medium text-label">보고서 구분</Label>
<SelectBox
className="w-36 shrink-0"
selectClass="rounded-lg bg-field text-foreground"
label=""
selectArray={[...REPORT_TYPES]}
value={reportGubun}
onSelectValue={(val) => setReportGubun(String(val))}
selectContentClass="z-[101]"
/>
<div className="ml-auto shrink-0 pl-4">
<Button className="text-base py-2" onClick={handleSearch}>조회</Button>
</div>
</div>
</div>
</div>
<div className="mt-3">
<LoadingOverlay isLoading={isLoading} />
<UiTable
className="!m-0"
clickableHeadIds={["fileName"]}
headName={<>조회 결과</>}
tableData={tableData}
headlist={reportTableHeadlist}
pageSize={10}
total={tableCount}
cellClick={handleReportCellClick}
cellClassName={(row, headId) => {
if (headId !== 'fileName') return '';
if (!row.fileSn) return '!text-gray-400';
return '!text-left !text-blue-600 underline underline-offset-4 hover:!text-blue-800';
}}
onPageChange={setCurPage}
resetPageNo={isResetPage}
resetSelection={isResetSelection}
/>
</div>
</>
);
};
export default React.memo(ReportTab);