그리드 공통 정렬 기능 확대 적용
This commit is contained in:
@@ -4,6 +4,7 @@ import { useCallback, useEffect, useMemo, useState } from "react";
|
||||
import { useSearchParams } from "next/navigation";
|
||||
import { Button, Input } from "@common/components";
|
||||
import { UiTable } from "@common/business_components/ui";
|
||||
import type { UiTableSort } from "@common/business_components/ui";
|
||||
import { getData } from "@/components/lib/api";
|
||||
import useStore from "@/store/useStore";
|
||||
import LoadingOverlay from "@/components/ui/LoadingBar";
|
||||
@@ -25,6 +26,27 @@ const POPUP_ROW_SELECT_MESSAGE = "SN_AGED_PIPE_ROW_SELECT";
|
||||
const POPUP_MINIMIZE_MESSAGE = "SN_AGED_PIPE_POPUP_MINIMIZE";
|
||||
const POPUP_FILTER_SYNC_MESSAGE = "SN_AGED_PIPE_FILTER_SYNC";
|
||||
|
||||
const AGED_PIPE_API_SORT_KEYS: Record<string, string> = {
|
||||
rowNumber: "rowNumber",
|
||||
fcltMngNo: "fclt_mng_no",
|
||||
instlYmd: "instl_ymd",
|
||||
pdmtVl: "pdmt_vl",
|
||||
prlgVl: "prlg_vl",
|
||||
usgCd: "usg_cd",
|
||||
mqltCd: "mqlt_cd",
|
||||
};
|
||||
|
||||
const resolveAgedPipeApiSort = (sort?: UiTableSort) => {
|
||||
if (!sort) return undefined;
|
||||
if (sort.key === "elapsedYears") {
|
||||
const installDateDirection = sort.direction === "asc" ? "desc" : "asc";
|
||||
return `instl_ymd,${installDateDirection}`;
|
||||
}
|
||||
|
||||
const apiKey = AGED_PIPE_API_SORT_KEYS[sort.key];
|
||||
return apiKey ? `${apiKey},${sort.direction}` : undefined;
|
||||
};
|
||||
|
||||
export default function AgedPipePopupPage() {
|
||||
const searchParams = useSearchParams();
|
||||
const { ctpv, sgg, fetchCtpv, fetchSgg } = useStore();
|
||||
@@ -41,6 +63,7 @@ export default function AgedPipePopupPage() {
|
||||
const [selectedIndex, setSelectedIndex] = useState(-1);
|
||||
const [isLoading, setIsLoading] = useState(false);
|
||||
const [downloadProgressText, setDownloadProgressText] = useState("");
|
||||
const [sort, setSort] = useState<UiTableSort>();
|
||||
|
||||
const sggOptions = useMemo(
|
||||
() =>
|
||||
@@ -56,14 +79,20 @@ export default function AgedPipePopupPage() {
|
||||
}, [ctpv.length, fetchCtpv, fetchSgg, sgg.length]);
|
||||
|
||||
const fetchRows = useCallback(
|
||||
async (page: number, nextFilters: AgedPipeFilters, size: number) => {
|
||||
async (
|
||||
page: number,
|
||||
nextFilters: AgedPipeFilters,
|
||||
size: number,
|
||||
activeSort?: UiTableSort,
|
||||
) => {
|
||||
const normalized = normalizeAgedPipeFilters(nextFilters);
|
||||
const queryParams = new URLSearchParams({
|
||||
lyrTypeNm: AGED_PIPE_LAYER_TYPE,
|
||||
page: String(page - 1),
|
||||
size: String(size),
|
||||
sort: "string",
|
||||
});
|
||||
const apiSort = resolveAgedPipeApiSort(activeSort);
|
||||
if (apiSort) queryParams.set("sort", apiSort);
|
||||
const cqlFilters = buildAgedPipeCqlFilters(normalized);
|
||||
if (cqlFilters.length > 0) {
|
||||
queryParams.append("cqlFiter", cqlFilters.join(" and "));
|
||||
@@ -72,21 +101,27 @@ export default function AgedPipePopupPage() {
|
||||
const response = await getData(`v1/fclts?${queryParams.toString()}`);
|
||||
const responseRows = Array.isArray(response?.data) ? response.data : [];
|
||||
|
||||
return {
|
||||
rows: responseRows.map((item: Record<string, unknown>, index: number) =>
|
||||
toDisplayPipeRow(item, index, page, ctpv, sgg),
|
||||
),
|
||||
total: Number(response?.count ?? 0),
|
||||
};
|
||||
const total = Number(response?.count ?? 0);
|
||||
const descendingSequence =
|
||||
activeSort?.key === "rowNumber" && activeSort.direction === "desc";
|
||||
const rows = responseRows.map((item: Record<string, unknown>, index: number) => {
|
||||
const row = toDisplayPipeRow(item, index, page, ctpv, sgg);
|
||||
if (descendingSequence) {
|
||||
row.rowNumber = total - ((page - 1) * size + index);
|
||||
}
|
||||
return row;
|
||||
});
|
||||
|
||||
return { rows, total };
|
||||
},
|
||||
[ctpv, sgg],
|
||||
);
|
||||
|
||||
const loadRows = useCallback(
|
||||
async (page = 1, nextFilters = filters) => {
|
||||
async (page = 1, nextFilters = filters, activeSort = sort) => {
|
||||
setIsLoading(true);
|
||||
try {
|
||||
const response = await fetchRows(page, nextFilters, AGED_PIPE_PAGE_SIZE);
|
||||
const response = await fetchRows(page, nextFilters, AGED_PIPE_PAGE_SIZE, activeSort);
|
||||
setRows(response.rows);
|
||||
setTotal(response.total);
|
||||
setCurrentPage(page);
|
||||
@@ -98,7 +133,7 @@ export default function AgedPipePopupPage() {
|
||||
setIsLoading(false);
|
||||
}
|
||||
},
|
||||
[fetchRows, filters],
|
||||
[fetchRows, filters, sort],
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
@@ -149,6 +184,12 @@ export default function AgedPipePopupPage() {
|
||||
);
|
||||
};
|
||||
|
||||
const handleSortChange = (nextSort?: UiTableSort) => {
|
||||
setSort(nextSort);
|
||||
setSelectedIndex(-1);
|
||||
void loadRows(1, filters, nextSort);
|
||||
};
|
||||
|
||||
const handleDownload = async () => {
|
||||
if (total === 0) {
|
||||
alert("다운로드할 데이터가 없습니다.");
|
||||
@@ -327,6 +368,8 @@ export default function AgedPipePopupPage() {
|
||||
widthFixed={true}
|
||||
isHideCount={true}
|
||||
className="!m-0 overflow-x-hidden"
|
||||
sort={sort}
|
||||
onSortChange={handleSortChange}
|
||||
/>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
@@ -54,14 +54,14 @@ const normalizeRegionFilterCode = (value: unknown, prefixLength: number) => {
|
||||
};
|
||||
|
||||
export const AGED_PIPE_LIST_HEADLIST = [
|
||||
{ id: "rowNumber", title: "NO", widthPercent: 6 },
|
||||
{ id: "fcltMngNo", title: "시설관리번호", widthPercent: 18 },
|
||||
{ id: "instlYmd", title: "설치일자", widthPercent: 12 },
|
||||
{ id: "pdmtVl", title: "관경(mm)", widthPercent: 10 },
|
||||
{ id: "prlgVl", title: "연장(m)", widthPercent: 10 },
|
||||
{ id: "usgCd", title: "용도", widthPercent: 12 },
|
||||
{ id: "mqltCd", title: "재질", widthPercent: 18 },
|
||||
{ id: "elapsedYears", title: "경과년도", widthPercent: 8, align: "right" },
|
||||
{ id: "rowNumber", title: "NO", widthPercent: 6, sortable: true },
|
||||
{ id: "fcltMngNo", title: "시설관리번호", widthPercent: 18, sortable: true },
|
||||
{ id: "instlYmd", title: "설치일자", widthPercent: 12, sortable: true },
|
||||
{ id: "pdmtVl", title: "관경(mm)", widthPercent: 10, sortable: true },
|
||||
{ id: "prlgVl", title: "연장(m)", widthPercent: 10, sortable: true },
|
||||
{ id: "usgCd", title: "용도", widthPercent: 12, sortable: true },
|
||||
{ id: "mqltCd", title: "재질", widthPercent: 18, sortable: true },
|
||||
{ id: "elapsedYears", title: "경과년도", widthPercent: 8, align: "right", sortable: true },
|
||||
];
|
||||
|
||||
export const AGED_PIPE_EXCEL_FIELDS = [
|
||||
|
||||
@@ -2,6 +2,7 @@
|
||||
|
||||
import { useCallback, useEffect, useState } from "react";
|
||||
import { DateMonthPicker, UiTable, downloadExcelWithKoreanHeaders } from "@common/business_components/ui";
|
||||
import type { UiHeadProps, UiTableSort } from "@common/business_components/ui";
|
||||
import { Button } from "@common/components";
|
||||
|
||||
import { getData } from "@/components/lib/api";
|
||||
@@ -30,6 +31,17 @@ const INFTWT_RESULT_HEADLIST = [
|
||||
{ id: "rgtrNm", title: "분석자" },
|
||||
{ id: "scsYn", title: "분석성공여부" },
|
||||
] as const;
|
||||
// 화면 표시명과 분석 Entity 필드가 다른 열은 서버 정렬 키를 명시한다.
|
||||
const INFTWT_RESULT_SORT_KEYS: Record<string, string> = {
|
||||
ctpvNm: "ctpvCd",
|
||||
sggNm: "sggCd",
|
||||
};
|
||||
const INFTWT_RESULT_SORTABLE_HEADLIST: UiHeadProps[] = INFTWT_RESULT_HEADLIST.map((head) => ({
|
||||
...head,
|
||||
// 분석자명은 목록 조회 후 외부 사용자 서비스에서 보강되므로 전체 결과 정렬에서 제외한다.
|
||||
sortable: head.id !== "rgtrNm",
|
||||
sortKey: INFTWT_RESULT_SORT_KEYS[head.id] ?? head.id,
|
||||
}));
|
||||
|
||||
type InftwtResultFilters = {
|
||||
ctpvCd: string;
|
||||
@@ -91,6 +103,7 @@ export default function InftwtResultPopupPage() {
|
||||
const [currentPage, setCurrentPage] = useState(1);
|
||||
const [selectedIndex, setSelectedIndex] = useState(-1);
|
||||
const [isLoading, setIsLoading] = useState(false);
|
||||
const [sort, setSort] = useState<UiTableSort>();
|
||||
|
||||
useEffect(() => {
|
||||
if (ctpv.length === 0) void fetchCtpv();
|
||||
@@ -98,12 +111,17 @@ export default function InftwtResultPopupPage() {
|
||||
}, [ctpv.length, fetchCtpv, fetchSgg, sgg.length]);
|
||||
|
||||
const fetchRows = useCallback(
|
||||
async (page: number, nextFilters: InftwtResultFilters) => {
|
||||
async (page: number, nextFilters: InftwtResultFilters, nextSort?: UiTableSort) => {
|
||||
const queryParams = new URLSearchParams({
|
||||
page: String(page - 1),
|
||||
size: String(INFTWT_RESULT_PAGE_SIZE),
|
||||
sort: "inftwtAnlsSn,desc",
|
||||
scsYn: "Y",
|
||||
});
|
||||
const requestSort = nextSort?.key === "rowNumber"
|
||||
? { key: "inftwtAnlsSn", direction: nextSort.direction === "asc" ? "desc" : "asc" }
|
||||
: nextSort;
|
||||
if (requestSort) queryParams.append("sort", `${requestSort.key},${requestSort.direction}`);
|
||||
if (requestSort?.key !== "inftwtAnlsSn") queryParams.append("sort", "inftwtAnlsSn,desc");
|
||||
|
||||
if (nextFilters.aplcnYmFrom) queryParams.set("aplcnYmFrom", nextFilters.aplcnYmFrom);
|
||||
if (nextFilters.aplcnYmTo) queryParams.set("aplcnYmTo", nextFilters.aplcnYmTo);
|
||||
@@ -113,6 +131,7 @@ export default function InftwtResultPopupPage() {
|
||||
|
||||
const response = await getData(`v1/inftwtAnls?${queryParams.toString()}`);
|
||||
const content = Array.isArray(response?.content) ? response.content : [];
|
||||
const totalElements = Number(response?.totalElements ?? content.length);
|
||||
|
||||
const nextRows = content
|
||||
.map((item: Record<string, unknown>, index: number) => {
|
||||
@@ -122,7 +141,9 @@ export default function InftwtResultPopupPage() {
|
||||
|
||||
return {
|
||||
...(formattedItem as Record<string, unknown>),
|
||||
rowNumber: (page - 1) * INFTWT_RESULT_PAGE_SIZE + index + 1,
|
||||
rowNumber: nextSort?.key === "rowNumber" && nextSort.direction === "desc"
|
||||
? totalElements - ((page - 1) * INFTWT_RESULT_PAGE_SIZE + index)
|
||||
: (page - 1) * INFTWT_RESULT_PAGE_SIZE + index + 1,
|
||||
ctpvNm: ctpvOption ? ctpvOption.text : String(item?.ctpvCd ?? "-"),
|
||||
sggNm: sggOption ? sggOption.text : String(item?.sggCd ?? "-"),
|
||||
scsYn: String(item?.scsYn ?? "N"),
|
||||
@@ -131,17 +152,17 @@ export default function InftwtResultPopupPage() {
|
||||
.filter((item: InftwtResultRow) => item.scsYn === "Y");
|
||||
|
||||
setRows(nextRows);
|
||||
setTotal(Number(response?.totalElements ?? nextRows.length));
|
||||
setTotal(totalElements);
|
||||
setCurrentPage(page);
|
||||
},
|
||||
[ctpv, sgg],
|
||||
);
|
||||
|
||||
const loadRows = useCallback(
|
||||
async (page = 1, nextFilters = filters) => {
|
||||
async (page = 1, nextFilters = filters, nextSort = sort) => {
|
||||
setIsLoading(true);
|
||||
try {
|
||||
await fetchRows(page, nextFilters);
|
||||
await fetchRows(page, nextFilters, nextSort);
|
||||
} catch (error) {
|
||||
console.error("침입수 분석결과 목록 조회 중 오류:", error);
|
||||
alert("침입수 분석결과 목록을 불러오지 못했습니다.");
|
||||
@@ -151,7 +172,7 @@ export default function InftwtResultPopupPage() {
|
||||
setIsLoading(false);
|
||||
}
|
||||
},
|
||||
[fetchRows, filters],
|
||||
[fetchRows, filters, sort],
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
@@ -165,6 +186,14 @@ export default function InftwtResultPopupPage() {
|
||||
await loadRows(page, filters);
|
||||
};
|
||||
|
||||
/** Applies server sorting while keeping the current search filters. */
|
||||
const handleSortChange = async (nextSort?: UiTableSort) => {
|
||||
setSort(nextSort);
|
||||
setSelectedIndex(-1);
|
||||
setSelectedRow(null);
|
||||
await loadRows(1, filters, nextSort);
|
||||
};
|
||||
|
||||
const handleRowClick = (index?: number) => {
|
||||
if (typeof index !== "number") return;
|
||||
const row = rows[index];
|
||||
@@ -317,7 +346,7 @@ export default function InftwtResultPopupPage() {
|
||||
<UiTable<InftwtResultRow>
|
||||
headName={<>{`분석이력 총 ${total.toLocaleString()}건`}</>}
|
||||
tableData={rows}
|
||||
headlist={INFTWT_RESULT_HEADLIST as unknown as { id: string; title: string }[]}
|
||||
headlist={INFTWT_RESULT_SORTABLE_HEADLIST}
|
||||
pageSize={INFTWT_RESULT_PAGE_SIZE}
|
||||
total={total}
|
||||
cellClick={(index) => handleRowClick(index)}
|
||||
@@ -330,6 +359,8 @@ export default function InftwtResultPopupPage() {
|
||||
curPage={currentPage}
|
||||
index={selectedIndex}
|
||||
widthFixed={true}
|
||||
sort={sort}
|
||||
onSortChange={(nextSort) => void handleSortChange(nextSort)}
|
||||
/>
|
||||
</div>
|
||||
|
||||
|
||||
+73
-24
@@ -4,6 +4,7 @@ import { useCallback, useEffect, useMemo, useRef, useState } from "react";
|
||||
import { useSearchParams } from "next/navigation";
|
||||
import { Button, Input } from "@common/components";
|
||||
import { UiTable, downloadExcelWithKoreanHeaders } from "@common/business_components/ui";
|
||||
import type { UiTableSort } from "@common/business_components/ui";
|
||||
import { getData } from "@/components/lib/api";
|
||||
import LoadingOverlay from "@/components/ui/LoadingBar";
|
||||
import useStore from "@/store/useStore";
|
||||
@@ -28,6 +29,7 @@ import {
|
||||
} from "./pipeAnalysisUtils";
|
||||
|
||||
type DetailViewRow = ReturnType<typeof toPipeAnalysisDetailViewRows>[number];
|
||||
const PIPE_ANALYSIS_DETAIL_PAGE_SIZE = 10;
|
||||
const PIPE_ANALYSIS_DETAIL_EXPORT_KEYS = [
|
||||
"rowNo",
|
||||
"ctpvNm",
|
||||
@@ -41,6 +43,16 @@ const PIPE_ANALYSIS_DETAIL_EXPORT_KEYS = [
|
||||
"prlgVl",
|
||||
"materialName",
|
||||
] as const;
|
||||
// 표시명 열은 원천 코드로, NO 열은 서버의 전체 순번 계약으로 정렬한다.
|
||||
const PIPE_ANALYSIS_DETAIL_SORT_KEYS: Record<string, string> = {
|
||||
useNm: "useCd",
|
||||
materialName: "materialCode",
|
||||
};
|
||||
const PIPE_ANALYSIS_DETAIL_SORTABLE_HEADLIST = PIPE_ANALYSIS_DETAIL_HEADLIST.map((head) => ({
|
||||
...head,
|
||||
sortable: true,
|
||||
sortKey: PIPE_ANALYSIS_DETAIL_SORT_KEYS[head.id] ?? head.id,
|
||||
}));
|
||||
|
||||
const resolveErrorMessage = (error: unknown, fallbackMessage: string) =>
|
||||
error instanceof Error && error.message.trim() ? error.message : fallbackMessage;
|
||||
@@ -66,10 +78,24 @@ export default function PipeAnalysisDetailPopupPage() {
|
||||
scope,
|
||||
),
|
||||
);
|
||||
const [appliedFilters, setAppliedFilters] = useState<PipeAnalysisDetailFilters>(() =>
|
||||
applyScopeToDetailFilters(
|
||||
{
|
||||
...PIPE_ANALYSIS_DETAIL_DEFAULT_FILTERS,
|
||||
ctpvCd: searchParams.get("ctpvCd") ?? PIPE_ANALYSIS_DETAIL_DEFAULT_FILTERS.ctpvCd,
|
||||
sggCd: searchParams.get("sggCd") ?? PIPE_ANALYSIS_DETAIL_DEFAULT_FILTERS.sggCd,
|
||||
swtrmtZoneNm: searchParams.get("swtrmtZoneNm") ?? PIPE_ANALYSIS_DETAIL_DEFAULT_FILTERS.swtrmtZoneNm,
|
||||
materialCode: baseMaterialCode,
|
||||
},
|
||||
scope,
|
||||
),
|
||||
);
|
||||
const [rows, setRows] = useState<DetailViewRow[]>([]);
|
||||
const [total, setTotal] = useState(0);
|
||||
const [currentPage, setCurrentPage] = useState(1);
|
||||
const [selectedIndex, setSelectedIndex] = useState(-1);
|
||||
const [sort, setSort] = useState<UiTableSort>();
|
||||
const [appliedSort, setAppliedSort] = useState<UiTableSort>();
|
||||
const [isLoading, setIsLoading] = useState(false);
|
||||
const initializedRef = useRef(false);
|
||||
|
||||
@@ -80,10 +106,11 @@ export default function PipeAnalysisDetailPopupPage() {
|
||||
|
||||
useEffect(() => {
|
||||
setFilters((current) => applyScopeToDetailFilters(current, scope));
|
||||
setAppliedFilters((current) => applyScopeToDetailFilters(current, scope));
|
||||
}, [scope]);
|
||||
|
||||
const fetchRows = useCallback(
|
||||
async (page: number, nextFilters: PipeAnalysisDetailFilters, size: number) => {
|
||||
async (page: number, nextFilters: PipeAnalysisDetailFilters, size: number, nextSort?: UiTableSort) => {
|
||||
const params = new URLSearchParams({
|
||||
analysisType: baseAnalysisType,
|
||||
page: String(page - 1),
|
||||
@@ -98,6 +125,7 @@ export default function PipeAnalysisDetailPopupPage() {
|
||||
if (nextFilters.useCd !== "0") params.set("useCd", nextFilters.useCd);
|
||||
if (nextFilters.diameterFrom) params.set("diameterFrom", nextFilters.diameterFrom);
|
||||
if (nextFilters.diameterTo) params.set("diameterTo", nextFilters.diameterTo);
|
||||
if (nextSort) params.set("sort", `${nextSort.key},${nextSort.direction}`);
|
||||
if (scope.regionCode) params.set("scopeRegionCode", scope.regionCode);
|
||||
|
||||
const response = await getData(`v1/fclts/ppl-analysis/details?${params.toString()}`);
|
||||
@@ -112,13 +140,15 @@ export default function PipeAnalysisDetailPopupPage() {
|
||||
);
|
||||
|
||||
const loadRows = useCallback(
|
||||
async (page = 1, nextFilters = filters) => {
|
||||
async (page = 1, nextFilters = filters, nextSort = sort) => {
|
||||
setIsLoading(true);
|
||||
try {
|
||||
const response = await fetchRows(page, nextFilters, 20);
|
||||
const response = await fetchRows(page, nextFilters, PIPE_ANALYSIS_DETAIL_PAGE_SIZE, nextSort);
|
||||
setRows(response.rows);
|
||||
setTotal(response.total);
|
||||
setCurrentPage(page);
|
||||
setAppliedFilters(nextFilters);
|
||||
setAppliedSort(nextSort);
|
||||
} catch (error) {
|
||||
console.error("관로현황 조회 중 오류:", error);
|
||||
alert(resolveErrorMessage(error, "관로현황 조회 중 오류가 발생했습니다."));
|
||||
@@ -128,21 +158,35 @@ export default function PipeAnalysisDetailPopupPage() {
|
||||
setIsLoading(false);
|
||||
}
|
||||
},
|
||||
[fetchRows, filters],
|
||||
[fetchRows, filters, sort],
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
if (initializedRef.current) return;
|
||||
if (ctpv.length === 0 || sgg.length === 0) return;
|
||||
initializedRef.current = true;
|
||||
void loadRows(1, applyScopeToDetailFilters(filters, scope));
|
||||
const initialFilters = applyScopeToDetailFilters(filters, scope);
|
||||
void loadRows(1, initialFilters);
|
||||
}, [ctpv.length, filters, loadRows, scope, sgg.length]);
|
||||
|
||||
const handleSearch = (page = 1) => {
|
||||
const handleSearch = () => {
|
||||
setSelectedIndex(-1);
|
||||
void loadRows(page, applyScopeToDetailFilters(filters, scope));
|
||||
const nextFilters = applyScopeToDetailFilters(filters, scope);
|
||||
void loadRows(1, nextFilters, sort);
|
||||
};
|
||||
|
||||
/** Keeps the last searched filters and sort when the user moves pages. */
|
||||
const handlePageChange = (page: number) => {
|
||||
setSelectedIndex(-1);
|
||||
void loadRows(page, appliedFilters, appliedSort);
|
||||
};
|
||||
|
||||
/** Reloads the first server page with the selected grid sort. */
|
||||
const handleSortChange = (nextSort?: UiTableSort) => {
|
||||
setSort(nextSort);
|
||||
setSelectedIndex(-1);
|
||||
void loadRows(1, appliedFilters, nextSort);
|
||||
};
|
||||
const handleRowClick = (index?: number) => {
|
||||
if (typeof index !== "number") return;
|
||||
const row = rows[index];
|
||||
@@ -167,7 +211,7 @@ export default function PipeAnalysisDetailPopupPage() {
|
||||
|
||||
try {
|
||||
setIsLoading(true);
|
||||
const response = await fetchRows(1, filters, Math.max(total, 1000));
|
||||
const response = await fetchRows(1, appliedFilters, Math.max(total, 1000), appliedSort);
|
||||
const exportRows = response.rows.map((row) => ({
|
||||
rowNo: row.rowNo,
|
||||
ctpvNm: row.ctpvNm,
|
||||
@@ -253,7 +297,7 @@ export default function PipeAnalysisDetailPopupPage() {
|
||||
<h1 className="sn-popup-title">관로현황{titleSuffix}</h1>
|
||||
</header>
|
||||
|
||||
<section className="sn-popup-section overflow-y-auto">
|
||||
<section className="sn-popup-section overflow-y-hidden">
|
||||
<div className="grid grid-cols-1 gap-3 border-b border-border bg-secondary pb-4 md:grid-cols-[minmax(0,1fr)_minmax(0,1fr)_110px_110px_auto] md:items-end">
|
||||
<label className="block">
|
||||
<div className="mb-1 text-[1.05rem] font-medium text-label">용도</div>
|
||||
@@ -307,7 +351,7 @@ export default function PipeAnalysisDetailPopupPage() {
|
||||
}))
|
||||
}
|
||||
onKeyDown={(event) => {
|
||||
if (event.key === "Enter") handleSearch(1);
|
||||
if (event.key === "Enter") handleSearch();
|
||||
}}
|
||||
/>
|
||||
</label>
|
||||
@@ -324,7 +368,7 @@ export default function PipeAnalysisDetailPopupPage() {
|
||||
}))
|
||||
}
|
||||
onKeyDown={(event) => {
|
||||
if (event.key === "Enter") handleSearch(1);
|
||||
if (event.key === "Enter") handleSearch();
|
||||
}}
|
||||
/>
|
||||
</label>
|
||||
@@ -335,7 +379,7 @@ export default function PipeAnalysisDetailPopupPage() {
|
||||
size="sm"
|
||||
color="primary"
|
||||
className="h-10 px-5 !text-[1.1rem]"
|
||||
onClick={() => handleSearch(1)}
|
||||
onClick={handleSearch}
|
||||
>
|
||||
조회
|
||||
</Button>
|
||||
@@ -351,18 +395,23 @@ export default function PipeAnalysisDetailPopupPage() {
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<UiTable<DetailViewRow>
|
||||
headName={<>관로현황 조회 결과 총 {total.toLocaleString()}건</>}
|
||||
tableData={rows}
|
||||
headlist={PIPE_ANALYSIS_DETAIL_HEADLIST}
|
||||
pageSize={20}
|
||||
total={total}
|
||||
cellClick={(index) => handleRowClick(index)}
|
||||
onPageChange={(page) => handleSearch(page)}
|
||||
curPage={currentPage}
|
||||
index={selectedIndex}
|
||||
widthFixed={true}
|
||||
/>
|
||||
<div className="min-h-0 min-w-0 flex-1 overflow-x-hidden overflow-y-auto pt-2">
|
||||
<UiTable<DetailViewRow>
|
||||
headName={<>관로현황 조회 결과 총 {total.toLocaleString()}건</>}
|
||||
tableData={rows}
|
||||
headlist={PIPE_ANALYSIS_DETAIL_SORTABLE_HEADLIST}
|
||||
pageSize={PIPE_ANALYSIS_DETAIL_PAGE_SIZE}
|
||||
total={total}
|
||||
cellClick={(index) => handleRowClick(index)}
|
||||
onPageChange={handlePageChange}
|
||||
curPage={currentPage}
|
||||
index={selectedIndex}
|
||||
widthFixed={true}
|
||||
className="!m-0 overflow-x-hidden"
|
||||
sort={sort}
|
||||
onSortChange={handleSortChange}
|
||||
/>
|
||||
</div>
|
||||
</section>
|
||||
</main>
|
||||
);
|
||||
|
||||
@@ -3,6 +3,7 @@
|
||||
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
|
||||
import { Button, Table, TableBody, TableCell, TableHeader, TableHead, TableRow } from "@common/components";
|
||||
import { UiTable, downloadExcelWithKoreanHeaders } from "@common/business_components/ui";
|
||||
import type { UiTableSort } from "@common/business_components/ui";
|
||||
import UiPageNations from "@common/business_components/ui/table/uiPageNations";
|
||||
import { getData } from "@/components/lib/api";
|
||||
import LoadingOverlay from "@/components/ui/LoadingBar";
|
||||
@@ -34,6 +35,13 @@ const SUMMARY_FILE_PREFIX: Record<string, string> = {
|
||||
YEAR_MATERIAL: "관로분석_경과년도별_관재질별",
|
||||
};
|
||||
const PIPE_ANALYSIS_SUMMARY_PAGE_SIZE = 10;
|
||||
const YEAR_MATERIAL_SORTABLE_KEYS = new Set([
|
||||
"rowNo",
|
||||
"ctpvNm",
|
||||
"sggNm",
|
||||
"swtrmtZoneNm",
|
||||
"totalLengthKm",
|
||||
]);
|
||||
|
||||
const toExcelColumnName = (index: number) => {
|
||||
let current = index;
|
||||
@@ -61,6 +69,87 @@ const formatGridNumber = (value: unknown) => {
|
||||
|
||||
const resolveErrorMessage = (error: unknown, fallbackMessage: string) =>
|
||||
error instanceof Error && error.message.trim() ? error.message : fallbackMessage;
|
||||
const KOREAN_GRID_COLLATOR = new Intl.Collator("ko", {
|
||||
numeric: true,
|
||||
sensitivity: "base",
|
||||
});
|
||||
|
||||
/** Sorts the complete summary result before client-side pagination. */
|
||||
const sortSummaryRows = (
|
||||
sourceRows: PipeAnalysisSummaryViewRow[],
|
||||
sort?: UiTableSort,
|
||||
) => {
|
||||
if (!sort) return sourceRows;
|
||||
|
||||
const sortableRows = sourceRows.filter((row) => !row.__isNoZone && !row.__isSummaryTotal);
|
||||
const pinnedRows = sourceRows.filter((row) => row.__isNoZone || row.__isSummaryTotal);
|
||||
|
||||
sortableRows.sort((left, right) => {
|
||||
const leftValue = left[sort.key];
|
||||
const rightValue = right[sort.key];
|
||||
const leftEmpty = leftValue === null || leftValue === undefined || leftValue === "";
|
||||
const rightEmpty = rightValue === null || rightValue === undefined || rightValue === "";
|
||||
|
||||
if (leftEmpty || rightEmpty) {
|
||||
if (leftEmpty && rightEmpty) return 0;
|
||||
return leftEmpty ? 1 : -1;
|
||||
}
|
||||
|
||||
const leftNumber = typeof leftValue === "number" ? leftValue : Number.NaN;
|
||||
const rightNumber = typeof rightValue === "number" ? rightValue : Number.NaN;
|
||||
const compared = Number.isFinite(leftNumber) && Number.isFinite(rightNumber)
|
||||
? leftNumber - rightNumber
|
||||
: KOREAN_GRID_COLLATOR.compare(String(leftValue), String(rightValue));
|
||||
|
||||
return sort.direction === "asc" ? compared : -compared;
|
||||
});
|
||||
|
||||
return [...sortableRows, ...pinnedRows];
|
||||
};
|
||||
|
||||
const getYearMaterialGroupSortValue = (
|
||||
group: PipeAnalysisYearMaterialGroup,
|
||||
key: string,
|
||||
) => {
|
||||
switch (key) {
|
||||
case "rowNo":
|
||||
return group.rowNo;
|
||||
case "ctpvNm":
|
||||
return group.ctpvNm;
|
||||
case "sggNm":
|
||||
return group.sggNm;
|
||||
case "swtrmtZoneNm":
|
||||
return group.swtrmtZoneNm;
|
||||
case "totalLengthKm":
|
||||
return group.totalLengthKm;
|
||||
default:
|
||||
return group.rows.reduce((sum, row) => sum + Number(row.metrics[key] ?? 0), 0);
|
||||
}
|
||||
};
|
||||
|
||||
const sortYearMaterialGroups = (
|
||||
groups: PipeAnalysisYearMaterialGroup[],
|
||||
sort?: UiTableSort,
|
||||
) => {
|
||||
if (!sort || !YEAR_MATERIAL_SORTABLE_KEYS.has(sort.key)) return groups;
|
||||
|
||||
const sortedGroups = [...groups];
|
||||
sortedGroups.sort((left, right) => {
|
||||
const leftValue = getYearMaterialGroupSortValue(left, sort.key);
|
||||
const rightValue = getYearMaterialGroupSortValue(right, sort.key);
|
||||
const compared = typeof leftValue === "number" && typeof rightValue === "number"
|
||||
? leftValue - rightValue
|
||||
: KOREAN_GRID_COLLATOR.compare(String(leftValue), String(rightValue));
|
||||
|
||||
if (compared !== 0) {
|
||||
return sort.direction === "asc" ? compared : -compared;
|
||||
}
|
||||
|
||||
return KOREAN_GRID_COLLATOR.compare(left.swtrmtZoneNm, right.swtrmtZoneNm);
|
||||
});
|
||||
|
||||
return sortedGroups;
|
||||
};
|
||||
|
||||
export default function PipeAnalysisPopupPage() {
|
||||
const { ctpv, sgg, fetchCtpv, fetchSgg } = useStore();
|
||||
@@ -76,6 +165,7 @@ export default function PipeAnalysisPopupPage() {
|
||||
const [rows, setRows] = useState<PipeAnalysisSummaryViewRow[]>([]);
|
||||
const [zoneOptions, setZoneOptions] = useState<Array<{ value: string; label: string }>>([]);
|
||||
const [summaryCurrentPage, setSummaryCurrentPage] = useState(1);
|
||||
const [sort, setSort] = useState<UiTableSort>();
|
||||
const [isLoading, setIsLoading] = useState(false);
|
||||
const initializedRef = useRef(false);
|
||||
|
||||
@@ -183,7 +273,7 @@ export default function PipeAnalysisPopupPage() {
|
||||
const ranges: string[] = [];
|
||||
let currentRow = 2;
|
||||
|
||||
yearMaterialGroups.forEach((group) => {
|
||||
orderedYearMaterialGroups.forEach((group) => {
|
||||
const rowSpan = group.rows.length;
|
||||
if (rowSpan > 1) {
|
||||
["A", "B", "C", "D", "E"].forEach((column) => {
|
||||
@@ -197,7 +287,7 @@ export default function PipeAnalysisPopupPage() {
|
||||
})()
|
||||
: [];
|
||||
const exportRows = appliedFilters.analysisType === "YEAR_MATERIAL"
|
||||
? yearMaterialGroups.flatMap((group) =>
|
||||
? orderedYearMaterialGroups.flatMap((group) =>
|
||||
group.rows.map((yearRow, rowIndex) => {
|
||||
const exportRow: Record<string, unknown> = {
|
||||
rowNo: rowIndex === 0 ? group.rowNo : "",
|
||||
@@ -215,7 +305,7 @@ export default function PipeAnalysisPopupPage() {
|
||||
return exportRow;
|
||||
}),
|
||||
)
|
||||
: rows.map((row) => {
|
||||
: sortedRows.map((row) => {
|
||||
const exportRow: Record<string, unknown> = {};
|
||||
exportHeadList.forEach((head) => {
|
||||
exportRow[head.id] = row[head.id];
|
||||
@@ -238,7 +328,7 @@ export default function PipeAnalysisPopupPage() {
|
||||
|
||||
const handleCellClick = (index?: number, columnId?: string) => {
|
||||
if (typeof index !== "number" || !columnId) return;
|
||||
const row = rows[index];
|
||||
const row = pagedRows[index];
|
||||
if (!row) return;
|
||||
if (row.__isNoZone || row.__isSummaryTotal) return;
|
||||
const cellFilter = row.__cellFilters[columnId];
|
||||
@@ -275,16 +365,34 @@ export default function PipeAnalysisPopupPage() {
|
||||
};
|
||||
|
||||
const headList = useMemo(
|
||||
() => buildPipeAnalysisSummaryHeadList(appliedFilters.analysisType, materialColumns),
|
||||
() => buildPipeAnalysisSummaryHeadList(appliedFilters.analysisType, materialColumns).map((head) => ({ ...head, sortable: true })),
|
||||
[appliedFilters.analysisType, materialColumns],
|
||||
);
|
||||
|
||||
const sortedRows = useMemo(
|
||||
() => sortSummaryRows(rows, sort),
|
||||
[rows, sort],
|
||||
);
|
||||
|
||||
const yearMaterialGroups = useMemo(
|
||||
() => appliedFilters.analysisType === "YEAR_MATERIAL"
|
||||
? buildPipeAnalysisYearMaterialGroups(rows, materialColumns)
|
||||
: [],
|
||||
[appliedFilters.analysisType, materialColumns, rows],
|
||||
);
|
||||
const sortedYearMaterialGroups = useMemo(
|
||||
() => sortYearMaterialGroups(yearMaterialGroups, sort),
|
||||
[sort, yearMaterialGroups],
|
||||
);
|
||||
const orderedYearMaterialGroups = useMemo(
|
||||
() => sortedYearMaterialGroups.map((group, index) => ({
|
||||
...group,
|
||||
rowNo: sort?.key === "rowNo" && sort.direction === "desc"
|
||||
? sortedYearMaterialGroups.length - index
|
||||
: index + 1,
|
||||
})),
|
||||
[sort, sortedYearMaterialGroups],
|
||||
);
|
||||
|
||||
const useHorizontalScrollTable = appliedFilters.analysisType !== "YEAR";
|
||||
const summaryPageOffset = (summaryCurrentPage - 1) * PIPE_ANALYSIS_SUMMARY_PAGE_SIZE;
|
||||
@@ -292,19 +400,19 @@ export default function PipeAnalysisPopupPage() {
|
||||
() =>
|
||||
appliedFilters.analysisType === "YEAR_MATERIAL"
|
||||
? rows
|
||||
: rows.slice(summaryPageOffset, summaryPageOffset + PIPE_ANALYSIS_SUMMARY_PAGE_SIZE),
|
||||
[appliedFilters.analysisType, rows, summaryPageOffset],
|
||||
: sortedRows.slice(summaryPageOffset, summaryPageOffset + PIPE_ANALYSIS_SUMMARY_PAGE_SIZE),
|
||||
[appliedFilters.analysisType, rows, sortedRows, summaryPageOffset],
|
||||
);
|
||||
const visibleYearMaterialGroups = useMemo(
|
||||
() =>
|
||||
yearMaterialGroups.slice(
|
||||
orderedYearMaterialGroups.slice(
|
||||
summaryPageOffset,
|
||||
summaryPageOffset + PIPE_ANALYSIS_SUMMARY_PAGE_SIZE,
|
||||
),
|
||||
[summaryPageOffset, yearMaterialGroups],
|
||||
[orderedYearMaterialGroups, summaryPageOffset],
|
||||
);
|
||||
const summaryTotalCount = appliedFilters.analysisType === "YEAR_MATERIAL"
|
||||
? yearMaterialGroups.length
|
||||
? orderedYearMaterialGroups.length
|
||||
: rows.length;
|
||||
|
||||
const sggOptions = useMemo(
|
||||
@@ -450,8 +558,57 @@ export default function PipeAnalysisPopupPage() {
|
||||
className={`border border-border bg-secondary px-4 py-[1.2rem] text-[1.3rem] font-medium text-label ${
|
||||
head.align === "right" ? "text-right" : "text-center"
|
||||
}`}
|
||||
aria-sort={
|
||||
head.id === "elapsedYearLabel"
|
||||
? undefined
|
||||
: sort?.key === head.id
|
||||
? sort.direction === "asc"
|
||||
? "ascending"
|
||||
: "descending"
|
||||
: "none"
|
||||
}
|
||||
>
|
||||
{head.title}
|
||||
{!YEAR_MATERIAL_SORTABLE_KEYS.has(head.id) ? (
|
||||
head.title
|
||||
) : (
|
||||
<button
|
||||
type="button"
|
||||
className="inline-flex w-full items-center justify-center gap-1.5"
|
||||
aria-label={`${head.title} 정렬`}
|
||||
onClick={() => {
|
||||
if (sort?.key !== head.id) {
|
||||
setSort({ key: head.id, direction: "asc" });
|
||||
setSummaryCurrentPage(1);
|
||||
return;
|
||||
}
|
||||
if (sort.direction === "asc") {
|
||||
setSort({ key: head.id, direction: "desc" });
|
||||
setSummaryCurrentPage(1);
|
||||
return;
|
||||
}
|
||||
setSort(undefined);
|
||||
setSummaryCurrentPage(1);
|
||||
}}
|
||||
>
|
||||
<span>{head.title}</span>
|
||||
{sort?.key === head.id ? (
|
||||
<span
|
||||
className="inline-flex min-w-2 justify-center text-[8px] leading-none text-[#1d4ed8]"
|
||||
aria-hidden="true"
|
||||
>
|
||||
{sort.direction === "asc" ? "▲" : "▼"}
|
||||
</span>
|
||||
) : (
|
||||
<span
|
||||
className="inline-flex flex-col items-center gap-px text-[6px] leading-none text-[#475569]"
|
||||
aria-hidden="true"
|
||||
>
|
||||
<span>▲</span>
|
||||
<span>▼</span>
|
||||
</span>
|
||||
)}
|
||||
</button>
|
||||
)}
|
||||
</TableHead>
|
||||
))}
|
||||
</TableRow>
|
||||
@@ -515,10 +672,10 @@ export default function PipeAnalysisPopupPage() {
|
||||
</TableBody>
|
||||
</Table>
|
||||
</div>
|
||||
{yearMaterialGroups.length > PIPE_ANALYSIS_SUMMARY_PAGE_SIZE ? (
|
||||
{orderedYearMaterialGroups.length > PIPE_ANALYSIS_SUMMARY_PAGE_SIZE ? (
|
||||
<div className="flex justify-center">
|
||||
<UiPageNations
|
||||
total={yearMaterialGroups.length}
|
||||
total={orderedYearMaterialGroups.length}
|
||||
cur={summaryCurrentPage}
|
||||
pageSize={PIPE_ANALYSIS_SUMMARY_PAGE_SIZE}
|
||||
onPageChange={setSummaryCurrentPage}
|
||||
@@ -552,6 +709,11 @@ export default function PipeAnalysisPopupPage() {
|
||||
curPage={summaryCurrentPage}
|
||||
widthFixed={!useHorizontalScrollTable}
|
||||
isHideCount={true}
|
||||
sort={sort}
|
||||
onSortChange={(nextSort) => {
|
||||
setSort(nextSort);
|
||||
setSummaryCurrentPage(1);
|
||||
}}
|
||||
cellClassName={(row, columnId) =>
|
||||
!row.__isNoZone && row.__cellFilters[columnId]
|
||||
? "cursor-pointer font-semibold text-blue-700 underline underline-offset-2"
|
||||
|
||||
@@ -2,6 +2,7 @@
|
||||
|
||||
import { useCallback, useEffect, useState } from "react";
|
||||
import { DateMonthPicker, UiTable, downloadExcelWithKoreanHeaders } from "@common/business_components/ui";
|
||||
import type { UiHeadProps, UiTableSort } from "@common/business_components/ui";
|
||||
import { Button } from "@common/components";
|
||||
|
||||
import { getData } from "@/components/lib/api";
|
||||
@@ -32,6 +33,17 @@ const RDII_RESULT_HEADLIST = [
|
||||
{ id: "rgtrNm", title: "분석자" },
|
||||
{ id: "scsYn", title: "분석성공여부" },
|
||||
] as const;
|
||||
// 화면 표시명과 분석 Entity 필드가 다른 열은 서버 정렬 키를 명시한다.
|
||||
const RDII_RESULT_SORT_KEYS: Record<string, string> = {
|
||||
ctpvNm: "ctpvCd",
|
||||
sggNm: "sggCd",
|
||||
};
|
||||
const RDII_RESULT_SORTABLE_HEADLIST: UiHeadProps[] = RDII_RESULT_HEADLIST.map((head) => ({
|
||||
...head,
|
||||
// 분석자명은 목록 조회 후 외부 사용자 서비스에서 보강되므로 전체 결과 정렬에서 제외한다.
|
||||
sortable: head.id !== "rgtrNm",
|
||||
sortKey: RDII_RESULT_SORT_KEYS[head.id] ?? head.id,
|
||||
}));
|
||||
|
||||
type RdiiResultFilters = {
|
||||
ctpvCd: string;
|
||||
@@ -93,6 +105,7 @@ export default function RdiiResultPopupPage() {
|
||||
const [currentPage, setCurrentPage] = useState(1);
|
||||
const [selectedIndex, setSelectedIndex] = useState(-1);
|
||||
const [isLoading, setIsLoading] = useState(false);
|
||||
const [sort, setSort] = useState<UiTableSort>();
|
||||
|
||||
useEffect(() => {
|
||||
if (ctpv.length === 0) void fetchCtpv();
|
||||
@@ -100,12 +113,17 @@ export default function RdiiResultPopupPage() {
|
||||
}, [ctpv.length, fetchCtpv, fetchSgg, sgg.length]);
|
||||
|
||||
const fetchRows = useCallback(
|
||||
async (page: number, nextFilters: RdiiResultFilters) => {
|
||||
async (page: number, nextFilters: RdiiResultFilters, nextSort?: UiTableSort) => {
|
||||
const queryParams = new URLSearchParams({
|
||||
page: String(page - 1),
|
||||
size: String(RDII_RESULT_PAGE_SIZE),
|
||||
sort: "rainInfwtAnlsSn,desc",
|
||||
scsYn: "Y",
|
||||
});
|
||||
const requestSort = nextSort?.key === "rowNumber"
|
||||
? { key: "rainInfwtAnlsSn", direction: nextSort.direction === "asc" ? "desc" : "asc" }
|
||||
: nextSort;
|
||||
if (requestSort) queryParams.append("sort", `${requestSort.key},${requestSort.direction}`);
|
||||
if (requestSort?.key !== "rainInfwtAnlsSn") queryParams.append("sort", "rainInfwtAnlsSn,desc");
|
||||
|
||||
if (nextFilters.aplcnYmFrom) queryParams.set("aplcnYmFrom", nextFilters.aplcnYmFrom);
|
||||
if (nextFilters.aplcnYmTo) queryParams.set("aplcnYmTo", nextFilters.aplcnYmTo);
|
||||
@@ -115,6 +133,7 @@ export default function RdiiResultPopupPage() {
|
||||
|
||||
const response = await getData(`v1/rdiiAnls?${queryParams.toString()}`);
|
||||
const content = Array.isArray(response?.content) ? response.content : [];
|
||||
const totalElements = Number(response?.totalElements ?? content.length);
|
||||
|
||||
const nextRows = content
|
||||
.map((item: Record<string, unknown>, index: number) => {
|
||||
@@ -124,7 +143,9 @@ export default function RdiiResultPopupPage() {
|
||||
|
||||
return {
|
||||
...(formattedItem as Record<string, unknown>),
|
||||
rowNumber: (page - 1) * RDII_RESULT_PAGE_SIZE + index + 1,
|
||||
rowNumber: nextSort?.key === "rowNumber" && nextSort.direction === "desc"
|
||||
? totalElements - ((page - 1) * RDII_RESULT_PAGE_SIZE + index)
|
||||
: (page - 1) * RDII_RESULT_PAGE_SIZE + index + 1,
|
||||
ctpvNm: ctpvOption ? ctpvOption.text : String(item?.ctpvCd ?? "-"),
|
||||
sggNm: sggOption ? sggOption.text : String(item?.sggCd ?? "-"),
|
||||
scsYn: String(item?.scsYn ?? "N"),
|
||||
@@ -133,17 +154,17 @@ export default function RdiiResultPopupPage() {
|
||||
.filter((item: RdiiResultRow) => item.scsYn === "Y");
|
||||
|
||||
setRows(nextRows);
|
||||
setTotal(Number(response?.totalElements ?? nextRows.length));
|
||||
setTotal(totalElements);
|
||||
setCurrentPage(page);
|
||||
},
|
||||
[ctpv, sgg],
|
||||
);
|
||||
|
||||
const loadRows = useCallback(
|
||||
async (page = 1, nextFilters = filters) => {
|
||||
async (page = 1, nextFilters = filters, nextSort = sort) => {
|
||||
setIsLoading(true);
|
||||
try {
|
||||
await fetchRows(page, nextFilters);
|
||||
await fetchRows(page, nextFilters, nextSort);
|
||||
} catch (error) {
|
||||
console.error("RDII 분석결과 목록 조회 중 오류:", error);
|
||||
alert("RDII 분석결과 목록을 불러오지 못했습니다.");
|
||||
@@ -153,7 +174,7 @@ export default function RdiiResultPopupPage() {
|
||||
setIsLoading(false);
|
||||
}
|
||||
},
|
||||
[fetchRows, filters],
|
||||
[fetchRows, filters, sort],
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
@@ -167,6 +188,14 @@ export default function RdiiResultPopupPage() {
|
||||
await loadRows(page, filters);
|
||||
};
|
||||
|
||||
/** Applies server sorting while keeping the current search filters. */
|
||||
const handleSortChange = async (nextSort?: UiTableSort) => {
|
||||
setSort(nextSort);
|
||||
setSelectedIndex(-1);
|
||||
setSelectedRow(null);
|
||||
await loadRows(1, filters, nextSort);
|
||||
};
|
||||
|
||||
const handleRowClick = (index?: number) => {
|
||||
if (typeof index !== "number") return;
|
||||
const row = rows[index];
|
||||
@@ -319,7 +348,7 @@ export default function RdiiResultPopupPage() {
|
||||
<UiTable<RdiiResultRow>
|
||||
headName={<>{`분석이력 총 ${total.toLocaleString()}건`}</>}
|
||||
tableData={rows}
|
||||
headlist={RDII_RESULT_HEADLIST as unknown as { id: string; title: string }[]}
|
||||
headlist={RDII_RESULT_SORTABLE_HEADLIST}
|
||||
pageSize={RDII_RESULT_PAGE_SIZE}
|
||||
total={total}
|
||||
cellClick={(index) => handleRowClick(index)}
|
||||
@@ -332,6 +361,8 @@ export default function RdiiResultPopupPage() {
|
||||
curPage={currentPage}
|
||||
index={selectedIndex}
|
||||
widthFixed={true}
|
||||
sort={sort}
|
||||
onSortChange={(nextSort) => void handleSortChange(nextSort)}
|
||||
/>
|
||||
</div>
|
||||
|
||||
|
||||
@@ -4,6 +4,7 @@ import { useCallback, useEffect, useMemo, useState } from "react";
|
||||
import { useSearchParams } from "next/navigation";
|
||||
import { Button, Input } from "@common/components";
|
||||
import { UiTable, downloadExcelWithKoreanHeaders } from "@common/business_components/ui";
|
||||
import type { UiHeadProps, UiTableSort } from "@common/business_components/ui";
|
||||
import { getRawJson } from "@/components/lib/api";
|
||||
import useStore from "@/store/useStore";
|
||||
import LoadingOverlay from "@/components/ui/LoadingBar";
|
||||
@@ -37,18 +38,18 @@ type SinkholeTableRow = SinkholeListItem & {
|
||||
coordStatusDisplay: string;
|
||||
};
|
||||
|
||||
const HEADLIST = [
|
||||
{ id: "acdntNo", title: "사고번호", widthPercent: 17 },
|
||||
{ id: "ocrnYmd", title: "발생일자", widthPercent: 10 },
|
||||
{ id: "fullAddress", title: "주소", widthPercent: 20 },
|
||||
{ id: "sizeWidthDisplay", title: "발생규모폭", widthPercent: 8 },
|
||||
{ id: "sizeLengthDisplay", title: "발생규모연장", widthPercent: 8 },
|
||||
{ id: "sizeDepthDisplay", title: "발생규모깊이", widthPercent: 8 },
|
||||
{ id: "detailCauseDisplay", title: "상세발생원인", widthPercent: 11 },
|
||||
{ id: "restoreStatus", title: "복구상태", widthPercent: 8 },
|
||||
{ id: "coordStatusDisplay", title: "좌표보정상태", widthPercent: 9 },
|
||||
{ id: "restoreCompletedYmdDisplay", title: "복구완료일자", widthPercent: 11 },
|
||||
] as const;
|
||||
const HEADLIST: UiHeadProps[] = [
|
||||
{ id: "acdntNo", title: "사고번호", widthPercent: 15, sortable: true },
|
||||
{ id: "ocrnYmd", title: "발생일자", widthPercent: 10, sortable: true },
|
||||
{ id: "fullAddress", title: "주소", widthPercent: 20, sortable: true },
|
||||
{ id: "sizeWidthDisplay", title: "발생규모폭", widthPercent: 8, sortable: true, sortKey: "sizeWidth" },
|
||||
{ id: "sizeLengthDisplay", title: "발생규모연장", widthPercent: 9, sortable: true, sortKey: "sizeLength" },
|
||||
{ id: "sizeDepthDisplay", title: "발생규모깊이", widthPercent: 9, sortable: true, sortKey: "sizeDepth" },
|
||||
{ id: "detailCauseDisplay", title: "상세발생원인", widthPercent: 11, sortable: true, sortKey: "detailCause" },
|
||||
{ id: "restoreStatus", title: "복구상태", widthPercent: 8, sortable: true },
|
||||
{ id: "coordStatusDisplay", title: "좌표보정상태", widthPercent: 9, sortable: true, sortKey: "coordStatus" },
|
||||
{ id: "restoreCompletedYmdDisplay", title: "복구완료일자", widthPercent: 11, sortable: true, sortKey: "restoreCompletedYmd" },
|
||||
];
|
||||
|
||||
const toDisplay = (value: unknown) => {
|
||||
const normalized = String(value ?? "").trim();
|
||||
@@ -142,6 +143,8 @@ export default function SinkholePopupPage() {
|
||||
const [currentPage, setCurrentPage] = useState(1);
|
||||
const [selectedIndex, setSelectedIndex] = useState(-1);
|
||||
const [isLoading, setIsLoading] = useState(false);
|
||||
const [sort, setSort] = useState<UiTableSort>();
|
||||
const [appliedSort, setAppliedSort] = useState<UiTableSort>();
|
||||
const sggOptions = useMemo(
|
||||
() =>
|
||||
filters.ctpvCd === "0"
|
||||
@@ -162,6 +165,7 @@ export default function SinkholePopupPage() {
|
||||
pageSize = PAGE_SIZE,
|
||||
options?: {
|
||||
includeCoordStatus?: boolean;
|
||||
sort?: UiTableSort;
|
||||
},
|
||||
) => {
|
||||
const queryString = buildSinkholeListQueryString(
|
||||
@@ -185,15 +189,18 @@ export default function SinkholePopupPage() {
|
||||
);
|
||||
|
||||
const loadRows = useCallback(
|
||||
async (page = 1, nextFilters = filters) => {
|
||||
async (page: number, nextFilters: SinkholeFilters, nextSort?: UiTableSort) => {
|
||||
setIsLoading(true);
|
||||
try {
|
||||
const normalized = normalizeSinkholeFilters(nextFilters);
|
||||
const response = await fetchRows(page, normalized);
|
||||
const response = await fetchRows(page, normalized, PAGE_SIZE, { sort: nextSort });
|
||||
setRows(response.rows);
|
||||
setTotal(response.total);
|
||||
setCurrentPage(page);
|
||||
setAppliedFilters(normalized);
|
||||
setSort(nextSort);
|
||||
setAppliedSort(nextSort);
|
||||
setSelectedIndex(-1);
|
||||
window.opener?.postMessage(
|
||||
{
|
||||
type: SINKHOLE_FILTER_SYNC_MESSAGE,
|
||||
@@ -210,11 +217,11 @@ export default function SinkholePopupPage() {
|
||||
setIsLoading(false);
|
||||
}
|
||||
},
|
||||
[fetchRows, filters],
|
||||
[fetchRows],
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
void loadRows(1, filters);
|
||||
void loadRows(1, filters, undefined);
|
||||
// 최초 로딩은 한 번만 수행한다.
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, []);
|
||||
@@ -222,7 +229,17 @@ export default function SinkholePopupPage() {
|
||||
const handleSearch = async (page = 1) => {
|
||||
const normalized = normalizeSinkholeFilters(filters);
|
||||
setFilters(normalized);
|
||||
await loadRows(page, normalized);
|
||||
await loadRows(page, normalized, sort);
|
||||
};
|
||||
|
||||
/** 현재 적용된 검색 조건을 유지한 채 서버 정렬 후 첫 페이지를 조회한다. */
|
||||
const handleSortChange = async (nextSort?: UiTableSort) => {
|
||||
await loadRows(1, appliedFilters, nextSort);
|
||||
};
|
||||
|
||||
/** 페이지 이동은 입력 중인 조건이 아니라 마지막 적용 조건과 정렬을 사용한다. */
|
||||
const handlePageChange = async (page: number) => {
|
||||
await loadRows(page, appliedFilters, appliedSort);
|
||||
};
|
||||
|
||||
const handleRowClick = (index?: number) => {
|
||||
@@ -255,6 +272,7 @@ export default function SinkholePopupPage() {
|
||||
for (let page = 1; page <= totalPages; page += 1) {
|
||||
const response = await fetchRows(page, normalized, DOWNLOAD_BATCH_SIZE, {
|
||||
includeCoordStatus: false,
|
||||
sort: appliedSort,
|
||||
});
|
||||
downloadItems.push(...response.items);
|
||||
}
|
||||
@@ -405,15 +423,17 @@ export default function SinkholePopupPage() {
|
||||
<UiTable<SinkholeTableRow>
|
||||
headName={<>지반침하 조회 결과 총 {total.toLocaleString()}건</>}
|
||||
tableData={rows}
|
||||
headlist={HEADLIST as any}
|
||||
headlist={HEADLIST}
|
||||
pageSize={PAGE_SIZE}
|
||||
total={total}
|
||||
cellClick={(index) => handleRowClick(index)}
|
||||
onPageChange={(page) => void handleSearch(page)}
|
||||
onPageChange={(page) => void handlePageChange(page)}
|
||||
curPage={currentPage}
|
||||
index={selectedIndex}
|
||||
widthFixed={true}
|
||||
isHideCount={true}
|
||||
sort={sort}
|
||||
onSortChange={(nextSort) => void handleSortChange(nextSort)}
|
||||
/>
|
||||
</section>
|
||||
</main>
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
"use client";
|
||||
|
||||
import type { SelectDataType } from "@common/business_components";
|
||||
import type { UiTableSort } from "@common/business_components/ui";
|
||||
import type { IUserInfo } from "@/shared/store";
|
||||
|
||||
export const SINKHOLE_CONTROL_NODE_ID = "SN_SINKHOLE";
|
||||
@@ -251,6 +252,7 @@ export const buildSinkholeListQueryString = (
|
||||
size = 20,
|
||||
options?: {
|
||||
includeCoordStatus?: boolean;
|
||||
sort?: UiTableSort;
|
||||
},
|
||||
) => {
|
||||
const params = new URLSearchParams({
|
||||
@@ -274,6 +276,9 @@ export const buildSinkholeListQueryString = (
|
||||
if (options?.includeCoordStatus === false) {
|
||||
params.set("includeCoordStatus", "false");
|
||||
}
|
||||
if (options?.sort) {
|
||||
params.set("sort", `${options.sort.key},${options.sort.direction}`);
|
||||
}
|
||||
|
||||
return params.toString();
|
||||
};
|
||||
|
||||
@@ -23,15 +23,17 @@ import useStore from '@/store/useStore';
|
||||
import { CtpvSggPrcsFcltSelectBox } from '@/components/ui/ctpvSggPrcsFcltSelectBox';
|
||||
import useSearchModalStore from '@/store/useSearchModalStore';
|
||||
import { useReportTabStore } from '@/store/useReportTabStore';
|
||||
import type { UiTableSort } from '@common/business_components/ui';
|
||||
import type { FlattenedFolder, ReportDataFile } from './useReportTab';
|
||||
|
||||
// 하수도정비 기본계획 보고서
|
||||
export const reportTableHeadlist = [
|
||||
{ id: 'no', title: 'NO' },
|
||||
{ id: 'sido', title: '시도' },
|
||||
{ id: 'sigungu', title: '시군구' },
|
||||
{ id: 'approvalYear', title: '승인년도' },
|
||||
{ id: 'plantZoneGubun', title: '구분' },
|
||||
{ id: 'reportGubun', title: '보고서구분' },
|
||||
{ id: 'no', title: 'NO', sortable: true },
|
||||
{ id: 'sido', title: '시도', sortable: true },
|
||||
{ id: 'sigungu', title: '시군구', sortable: true },
|
||||
{ id: 'approvalYear', title: '승인년도', sortable: true },
|
||||
{ id: 'plantZoneGubun', title: '구분', sortable: true },
|
||||
{ id: 'reportGubun', title: '보고서구분', sortable: true },
|
||||
{ id: 'fileName', title: '파일명' },
|
||||
{ id: 'regDate', title: '등록일자' },
|
||||
];
|
||||
@@ -53,6 +55,51 @@ const getApprovalYearNumber = (value: string | number | undefined) => {
|
||||
return Number.isFinite(parsed) ? parsed : null;
|
||||
};
|
||||
|
||||
const REPORT_COLLATOR = new Intl.Collator('ko', { numeric: true, sensitivity: 'base' });
|
||||
const REPORT_PAGE_SIZE = 10;
|
||||
const REPORT_METADATA_CONCURRENCY = 8;
|
||||
const REPORT_METADATA_TIMEOUT_MS = 8000;
|
||||
const DISABLED_REPORT_SORT_KEYS = new Set(['fileName', 'regDate']);
|
||||
|
||||
type ReportFileMetadata = Pick<FlattenedFolder,
|
||||
'fileName' | 'regDate' | 'fileSn' | 'filePath' | 'fileCount' | 'downloadFileTitle'>;
|
||||
|
||||
const EMPTY_REPORT_METADATA: ReportFileMetadata = {
|
||||
fileName: '-',
|
||||
regDate: '',
|
||||
fileSn: '',
|
||||
filePath: '',
|
||||
fileCount: 0,
|
||||
downloadFileTitle: '',
|
||||
};
|
||||
|
||||
// 파일 정보 추가 조회 전에도 존재하는 분류 트리 필드만 전체 데이터 기준으로 정렬한다.
|
||||
const compareReportRows = (left: FlattenedFolder, right: FlattenedFolder, sort: UiTableSort) => {
|
||||
const leftValue = left[sort.key];
|
||||
const rightValue = right[sort.key];
|
||||
const leftEmpty = leftValue === null || leftValue === undefined || String(leftValue).trim() === '';
|
||||
const rightEmpty = rightValue === null || rightValue === undefined || String(rightValue).trim() === '';
|
||||
|
||||
if (leftEmpty || rightEmpty) {
|
||||
if (leftEmpty && rightEmpty) return Number(left.no ?? 0) - Number(right.no ?? 0);
|
||||
return leftEmpty ? 1 : -1;
|
||||
}
|
||||
|
||||
const comparison = sort.key === 'approvalYear'
|
||||
? Number(getApprovalYearNumber(leftValue) ?? 0) - Number(getApprovalYearNumber(rightValue) ?? 0)
|
||||
: REPORT_COLLATOR.compare(String(leftValue), String(rightValue));
|
||||
if (comparison === 0) return Number(left.no ?? 0) - Number(right.no ?? 0);
|
||||
return sort.direction === 'asc' ? comparison : -comparison;
|
||||
};
|
||||
|
||||
const resolveReportSort = (sort?: UiTableSort) => {
|
||||
if (!sort) return undefined;
|
||||
if (DISABLED_REPORT_SORT_KEYS.has(sort.key)) {
|
||||
return undefined;
|
||||
}
|
||||
return sort;
|
||||
};
|
||||
|
||||
const ReportTab: React.FC<ReportTabProps> = ({ open, isSearch = true, ctpvNm, sggNm }) => {
|
||||
const [isResetPage, setIsResetPage] = useState<boolean>(false);
|
||||
const [isResetSelection, setIsResetSelection] = useState<boolean>(false);
|
||||
@@ -62,6 +109,8 @@ const ReportTab: React.FC<ReportTabProps> = ({ open, isSearch = true, ctpvNm, sg
|
||||
const [plantZoneGubun, setPlantZoneGubun] = useState('0');
|
||||
const [reportGubun, setReportGubun] = useState('0');
|
||||
const hasAutoFetchedRef = useRef(false);
|
||||
const fileMetadataCacheRef = useRef<Map<string, ReportFileMetadata>>(new Map());
|
||||
const fileMetadataPromiseCacheRef = useRef<Map<string, Promise<ReportFileMetadata>>>(new Map());
|
||||
|
||||
const {
|
||||
tableData,
|
||||
@@ -73,16 +122,23 @@ const ReportTab: React.FC<ReportTabProps> = ({ open, isSearch = true, ctpvNm, sg
|
||||
setTableCount,
|
||||
setIsLoading,
|
||||
setYearFilter,
|
||||
setCurPage
|
||||
setCurPage,
|
||||
sort,
|
||||
setSort,
|
||||
} = useSearchModalStore();
|
||||
const { ctpv, sgg } = useStore();
|
||||
const {
|
||||
dcTree: reportOriginData,
|
||||
fetchDcTree
|
||||
} = useReportTabStore();
|
||||
const { REPORT_TYPES, flattenFolders, addFileName, downloadReportFile } = useReportTab();
|
||||
const { REPORT_TYPES, flattenFolders, getDcFiles, downloadReportFile } = useReportTab();
|
||||
|
||||
const flattenedReportData = useMemo(() => flattenFolders(reportOriginData), [reportOriginData]);
|
||||
|
||||
useEffect(() => {
|
||||
fileMetadataCacheRef.current.clear();
|
||||
fileMetadataPromiseCacheRef.current.clear();
|
||||
}, [reportOriginData]);
|
||||
const plantZoneOptions = useMemo(() => {
|
||||
const uniqueOptions = Array.from(
|
||||
new Set(
|
||||
@@ -113,9 +169,10 @@ const ReportTab: React.FC<ReportTabProps> = ({ open, isSearch = true, ctpvNm, sg
|
||||
setTableData([]);
|
||||
setTableCount(0);
|
||||
setCurPage(1);
|
||||
setSort(undefined);
|
||||
setIsResetPage(true);
|
||||
setIsResetSelection(true);
|
||||
}, [open, setCurPage, setTableCount, setTableData, setYearFilter]);
|
||||
}, [open, setCurPage, setSort, setTableCount, setTableData, setYearFilter]);
|
||||
|
||||
useEffect(() => {
|
||||
const newCtpvCd = ctpv.find((n) => n.text === ctpvNm)?.val || '0';
|
||||
@@ -171,11 +228,94 @@ const ReportTab: React.FC<ReportTabProps> = ({ open, isSearch = true, ctpvNm, sg
|
||||
})();
|
||||
};
|
||||
|
||||
const fetchFilteredData = async (page = curPage) => {
|
||||
const buildReportMetadata = (fileArr: ReportDataFile[]): ReportFileMetadata => {
|
||||
if (fileArr.length === 0) {
|
||||
return EMPTY_REPORT_METADATA;
|
||||
}
|
||||
|
||||
const primaryFile = fileArr[0];
|
||||
return {
|
||||
fileName: fileArr.length === 1
|
||||
? primaryFile?.fileTitle ?? '-'
|
||||
: `${primaryFile?.fileTitle ?? '대표 파일'} 외 ${fileArr.length - 1}건`,
|
||||
regDate: primaryFile?.regDt ?? '',
|
||||
fileSn: primaryFile?.fileSn ?? '',
|
||||
filePath: primaryFile?.filePath ?? '',
|
||||
fileCount: fileArr.length,
|
||||
downloadFileTitle: primaryFile?.fileTitle ?? '',
|
||||
};
|
||||
};
|
||||
|
||||
const resolveReportMetadata = (item: FlattenedFolder) => {
|
||||
const key = String(item.clsfSystmSn ?? '');
|
||||
if (!key) {
|
||||
return Promise.resolve(EMPTY_REPORT_METADATA);
|
||||
}
|
||||
|
||||
const cached = fileMetadataCacheRef.current.get(key);
|
||||
if (cached) {
|
||||
return Promise.resolve(cached);
|
||||
}
|
||||
|
||||
const pending = fileMetadataPromiseCacheRef.current.get(key);
|
||||
if (pending) {
|
||||
return pending;
|
||||
}
|
||||
|
||||
const promise = Promise.race<ReportDataFile[] | never>([
|
||||
getDcFiles(key),
|
||||
new Promise<never>((_, reject) => {
|
||||
window.setTimeout(() => reject(new Error(`Report metadata timeout: ${key}`)), REPORT_METADATA_TIMEOUT_MS);
|
||||
}),
|
||||
])
|
||||
.then((fileArr) => buildReportMetadata(fileArr))
|
||||
.catch((error) => {
|
||||
console.warn('보고서 파일 메타데이터 조회 실패:', key, error);
|
||||
return EMPTY_REPORT_METADATA;
|
||||
})
|
||||
.then((metadata) => {
|
||||
fileMetadataCacheRef.current.set(key, metadata);
|
||||
fileMetadataPromiseCacheRef.current.delete(key);
|
||||
return metadata;
|
||||
});
|
||||
|
||||
fileMetadataPromiseCacheRef.current.set(key, promise);
|
||||
return promise;
|
||||
};
|
||||
|
||||
const enrichReportRows = async (data: FlattenedFolder[], concurrency = REPORT_METADATA_CONCURRENCY) => {
|
||||
const missingRows = data.filter((item) => {
|
||||
const key = String(item.clsfSystmSn ?? '');
|
||||
return key && !fileMetadataCacheRef.current.has(key);
|
||||
});
|
||||
|
||||
if (missingRows.length > 0) {
|
||||
let cursor = 0;
|
||||
const workerCount = Math.min(concurrency, missingRows.length);
|
||||
await Promise.all(Array.from({ length: workerCount }, async () => {
|
||||
while (cursor < missingRows.length) {
|
||||
const currentIndex = cursor;
|
||||
cursor += 1;
|
||||
const currentRow = missingRows[currentIndex];
|
||||
if (currentRow) {
|
||||
await resolveReportMetadata(currentRow);
|
||||
}
|
||||
}
|
||||
}));
|
||||
}
|
||||
|
||||
return data.map((item) => ({
|
||||
...item,
|
||||
...(fileMetadataCacheRef.current.get(String(item.clsfSystmSn ?? '')) ?? EMPTY_REPORT_METADATA),
|
||||
}));
|
||||
};
|
||||
|
||||
const fetchFilteredData = async (page = curPage, requestedSort = sort) => {
|
||||
setIsLoading(true);
|
||||
setIsResetSelection(true);
|
||||
|
||||
try {
|
||||
const activeSort = resolveReportSort(requestedSort);
|
||||
let filterReportData = flattenedReportData;
|
||||
|
||||
if (ctpvCd !== '0') {
|
||||
@@ -206,13 +346,29 @@ const ReportTab: React.FC<ReportTabProps> = ({ open, isSearch = true, ctpvNm, sg
|
||||
});
|
||||
}
|
||||
|
||||
setTableCount(filterReportData.length || 0);
|
||||
const totalCount = filterReportData.length || 0;
|
||||
setTableCount(totalCount);
|
||||
|
||||
const pagedReportData = filterReportData
|
||||
.slice((page - 1) * 10, page * 10)
|
||||
.map((item, index) => ({ ...item, no: (page - 1) * 10 + index + 1 }));
|
||||
const pageOffset = (page - 1) * REPORT_PAGE_SIZE;
|
||||
const pageStart = (page - 1) * REPORT_PAGE_SIZE;
|
||||
const pageEnd = page * REPORT_PAGE_SIZE;
|
||||
|
||||
let reportRows: FlattenedFolder[];
|
||||
const sortedReportData = activeSort
|
||||
? [...filterReportData].sort((left, right) => compareReportRows(left, right, activeSort))
|
||||
: filterReportData;
|
||||
|
||||
const pagedReportData = sortedReportData
|
||||
.slice(pageStart, pageEnd)
|
||||
.map((item, index) => ({
|
||||
...item,
|
||||
no: activeSort?.key === 'no' && activeSort.direction === 'desc'
|
||||
? sortedReportData.length - (pageOffset + index)
|
||||
: pageOffset + index + 1,
|
||||
}));
|
||||
|
||||
reportRows = await enrichReportRows(pagedReportData);
|
||||
|
||||
const reportRows = await addFileName(pagedReportData);
|
||||
setTableData(reportRows || []);
|
||||
setShowTable(true);
|
||||
setIsResetSelection(false);
|
||||
@@ -225,6 +381,18 @@ const ReportTab: React.FC<ReportTabProps> = ({ open, isSearch = true, ctpvNm, sg
|
||||
}
|
||||
};
|
||||
|
||||
// 보고서 분류 원본 전체를 다시 정렬한 뒤 첫 페이지를 표시한다.
|
||||
const handleSortChange = (nextSort?: UiTableSort) => {
|
||||
setSort(nextSort);
|
||||
setIsResetPage(true);
|
||||
setIsResetSelection(true);
|
||||
if (curPage === 1) {
|
||||
void fetchFilteredData(1, nextSort);
|
||||
return;
|
||||
}
|
||||
setCurPage(1);
|
||||
};
|
||||
|
||||
return (
|
||||
<>
|
||||
<div className="relative mt-4 rounded-lg border px-5 pt-5 pb-3">
|
||||
@@ -298,6 +466,9 @@ const ReportTab: React.FC<ReportTabProps> = ({ open, isSearch = true, ctpvNm, sg
|
||||
onPageChange={setCurPage}
|
||||
resetPageNo={isResetPage}
|
||||
resetSelection={isResetSelection}
|
||||
curPage={curPage}
|
||||
sort={sort}
|
||||
onSortChange={handleSortChange}
|
||||
/>
|
||||
</div>
|
||||
</>
|
||||
|
||||
@@ -26,12 +26,76 @@ import useSearchModal from './useSearchModal';
|
||||
import useBasicTab from './useBasicTab';
|
||||
import useOperationTab from './useOperationTab';
|
||||
import { openWindowPopup } from '@/features/windowPopup/lib/openWindowPopup';
|
||||
import type { UiHeadProps, UiTableSort } from '@common/business_components/ui';
|
||||
|
||||
type SearchModalProps = {
|
||||
renderMode?: 'launcher' | 'popup';
|
||||
autoSearchReady?: boolean;
|
||||
};
|
||||
|
||||
// 기본정보 표시 필드 중 GeoServer 원천 속성으로 정확히 정렬할 수 있는 필드만 매핑한다.
|
||||
const BASIC_SORT_KEYS: Record<string, Record<string, string>> = {
|
||||
tb_ms_swage_ppl: {
|
||||
rowNumber: 'rowNumber',
|
||||
fcltMngNo: 'fclt_mng_no', instMngNo: 'inst_mng_no', instlYmd: 'instl_ymd',
|
||||
ctpvNm: 'pbadms_emd_cd',
|
||||
sggNm: 'pbadms_emd_cd', swtrmt_zone_nm: 'swtrmt_zone_nm', usgCd: 'usg_cd',
|
||||
pdmtVl: 'pdmt_vl', prlgVl: 'prlg_vl', tmptDpth: 'tmpt_dpth',
|
||||
tmptMhlMngNo: 'tmpt_mhl_mng_no', scendDpth: 'scend_dpth',
|
||||
scendMhlMngNo: 'scend_mhl_mng_no', vrtcLen: 'vrtc_len', wdthLen: 'wdth_len',
|
||||
fcltyShpCd: 'fclty_shp_cd', mqltCd: 'mqlt_cd',
|
||||
},
|
||||
tb_il_swage_prcs_fclt: {
|
||||
rowNumber: 'rowNumber',
|
||||
swtrmtFcltNm: 'swtrmt_fclt_nm', fcltCpct: 'fclt_cpct', rgnExpln: 'rgn_expln',
|
||||
oprtngStrtYmd: 'oprtng_strt_ymd', laeNm: 'lae_nm', sareaNm: 'sarea_nm',
|
||||
rvrNm: 'rvr_nm', dschgWtstNm: 'dschg_wtst_nm', swtrmtPpltnCnt: 'swtrmt_ppltn_cnt',
|
||||
rprsMsrcCn: 'rprs_msrc_cn',
|
||||
},
|
||||
tb_ms_swage_prcs_zone: {
|
||||
rowNumber: 'rowNumber',
|
||||
swtrmtZoneNm: 'swtrmt_zone_nm', ctpvNm: 'ctpv_nm', sggNm: 'sgg_nm',
|
||||
swtrmtFcltNm: 'swtrmt_fclt_nm',
|
||||
},
|
||||
tb_ms_swage_mhl: {
|
||||
rowNumber: 'rowNumber',
|
||||
fcltMngNo: 'fclt_mng_no', instlYmd: 'instl_ymd', swageMhlDiamVl: 'swage_mhl_diam_vl',
|
||||
ctpvNm: 'pbadms_emd_cd', sggNm: 'pbadms_emd_cd', usgCd: 'usg_cd',
|
||||
wdthLen: 'wdth_len', vrtcLen: 'vrtc_len', altdVl: 'altd_vl', dpthlVl: 'dpthl_vl',
|
||||
},
|
||||
tb_ms_swage_pmstn: {
|
||||
rowNumber: 'rowNumber',
|
||||
fcltMngNo: 'fclt_mng_no', swagePmstnNm: 'swage_pmstn_nm', instlYmd: 'instl_ymd',
|
||||
ctpvNm: 'pbadms_emd_cd', sggNm: 'pbadms_emd_cd',
|
||||
dailyPrcsCpct: 'daily_prcs_cpct', maxImpndCpct: 'max_impnd_cpct', altdVl: 'altd_vl',
|
||||
wtlvVl: 'wtlv_vl', exclnWpmpAbltVl: 'excln_wpmp_ablt_vl',
|
||||
},
|
||||
tb_ms_stofcbr: {
|
||||
rowNumber: 'rowNumber',
|
||||
fcltMngNo: 'fclt_mng_no', mapshtNo: 'mapsht_no', instlYmd: 'instl_ymd',
|
||||
ctpvNm: 'pbadms_emd_cd', sggNm: 'pbadms_emd_cd',
|
||||
worflwWallHgt: 'worflw_wall_hgt', swageMhlMngNo: 'swage_mhl_mng_no', drtnAngl: 'drtn_angl',
|
||||
},
|
||||
};
|
||||
|
||||
const OPERATION_LIST_SORT_KEYS: Record<string, string> = {
|
||||
rowNumber: 'rowNumber', bizInfoNm: 'bizInfoNm', flfmtBgngYmd: 'flfmtBgngYmd',
|
||||
flfmtEndYmd: 'flfmtEndYmd', flfmtYr: 'flfmtYr', bizTypeCd: 'bizTypeCd', bizAmt: 'bizAmt',
|
||||
};
|
||||
|
||||
const OPERATION_DETAIL_SORT_KEYS: Record<string, string> = {
|
||||
rowNumber: 'rowNumber',
|
||||
fcltMngNo: 'fclt_mng_no',
|
||||
swtrmt_zone_nm: 'swtrmt_zone_nm',
|
||||
usgCd: 'usg_cd',
|
||||
mqltCd: 'mqlt_cd',
|
||||
pdmtVl: 'pdmt_vl',
|
||||
prlgVl: 'prlg_vl',
|
||||
instlYmd: 'instl_ymd',
|
||||
strc_stts_grd: 'strc_stts_grd',
|
||||
oper_stts_grd: 'oper_stts_grd',
|
||||
};
|
||||
|
||||
const SearchModal: React.FC<SearchModalProps> = ({ renderMode = 'launcher', autoSearchReady = false }) => {
|
||||
const {
|
||||
showTable,
|
||||
@@ -47,8 +111,11 @@ const SearchModal: React.FC<SearchModalProps> = ({ renderMode = 'launcher', auto
|
||||
setActiveTab,
|
||||
setCurPage, setDetailCurPage,
|
||||
setIsResetPage, setIsResetDetailPage,
|
||||
setIsResetSelection,
|
||||
setDetailBizId,
|
||||
setIsResetDetailSelection,
|
||||
sort, detailSort,
|
||||
setSort, setDetailSort,
|
||||
resetFilters
|
||||
} = useSearchModalStore();
|
||||
const { TABS, viewMultipleFeature, fetchFilteredData } = useSearchModal();
|
||||
@@ -69,31 +136,71 @@ const SearchModal: React.FC<SearchModalProps> = ({ renderMode = 'launcher', auto
|
||||
const hasAutoSearchedRef = useRef(false);
|
||||
const hasInitializedTabAutoSearchRef = useRef(false);
|
||||
|
||||
const dynamicHeadlist: { id: string, title: string }[] = useMemo(() => {
|
||||
const dynamicHeadlist: UiHeadProps[] = useMemo(() => {
|
||||
if (activeTab === 'basic') {
|
||||
if (basicType === 'tb_ms_swage_ppl') return MsSwagePplCol2;
|
||||
if (basicType === 'tb_il_swage_prcs_fclt') return SwtrmtFcltCol2;
|
||||
if (basicType === 'tb_ms_swage_prcs_zone') return SwtrmtPrcsZoneCol2;
|
||||
if (basicType === 'tb_ms_swage_mhl') return MsSwageMhlCol2;
|
||||
if (basicType === 'tb_ms_swage_pmstn') return ExclnSewagePumpCol2;
|
||||
if (basicType === 'tb_ms_stofcbr') return stofcbrCol2;
|
||||
return [];
|
||||
const headlist = basicType === 'tb_ms_swage_ppl' ? MsSwagePplCol2
|
||||
: basicType === 'tb_il_swage_prcs_fclt' ? SwtrmtFcltCol2
|
||||
: basicType === 'tb_ms_swage_prcs_zone' ? SwtrmtPrcsZoneCol2
|
||||
: basicType === 'tb_ms_swage_mhl' ? MsSwageMhlCol2
|
||||
: basicType === 'tb_ms_swage_pmstn' ? ExclnSewagePumpCol2
|
||||
: basicType === 'tb_ms_stofcbr' ? stofcbrCol2 : [];
|
||||
const sortKeys = BASIC_SORT_KEYS[basicType] ?? {};
|
||||
return headlist.map((head) => sortKeys[head.id]
|
||||
? { ...head, sortable: true, sortKey: sortKeys[head.id] }
|
||||
: head);
|
||||
}
|
||||
if (activeTab === 'operation') {
|
||||
return OPERATION_TABLE_HEADLIST.map((head) => OPERATION_LIST_SORT_KEYS[head.id]
|
||||
? { ...head, sortable: true, sortKey: OPERATION_LIST_SORT_KEYS[head.id] }
|
||||
: head);
|
||||
}
|
||||
if (activeTab === 'operation') return OPERATION_TABLE_HEADLIST;
|
||||
|
||||
return [];
|
||||
}, [activeTab, basicType, workType, OPERATION_TABLE_HEADLIST]);
|
||||
|
||||
const dynamicHeadlist2: { id: string, title: string }[] = useMemo(() => {
|
||||
const dynamicHeadlist2: UiHeadProps[] = useMemo(() => {
|
||||
if (activeTab === 'operation') {
|
||||
if (workType === WORK_TYPES[0]?.val) return MAINTENANACE_TABLE_HEADLIST;
|
||||
if (workType === WORK_TYPES[1]?.val) return CLEANING_TABLE_HEADLIST;
|
||||
if (workType === WORK_TYPES[2]?.val) return INSPECTION_TABLE_HEADLIST;
|
||||
const headlist = workType === WORK_TYPES[0]?.val ? MAINTENANACE_TABLE_HEADLIST
|
||||
: workType === WORK_TYPES[1]?.val ? CLEANING_TABLE_HEADLIST
|
||||
: workType === WORK_TYPES[2]?.val ? INSPECTION_TABLE_HEADLIST : [];
|
||||
return headlist.map((head) => OPERATION_DETAIL_SORT_KEYS[head.id]
|
||||
? { ...head, sortable: true, sortKey: OPERATION_DETAIL_SORT_KEYS[head.id] }
|
||||
: head);
|
||||
}
|
||||
|
||||
return [];
|
||||
}, [activeTab, workType, WORK_TYPES, MAINTENANACE_TABLE_HEADLIST, CLEANING_TABLE_HEADLIST, INSPECTION_TABLE_HEADLIST]);
|
||||
|
||||
// 목록 정렬 변경 시 첫 페이지에서 같은 검색조건으로 다시 조회한다.
|
||||
const handleSortChange = (nextSort?: UiTableSort) => {
|
||||
setSort(nextSort);
|
||||
setIsResetPage(true);
|
||||
setIsResetSelection(true);
|
||||
setIsResetDetailPage(true);
|
||||
setDetailSort(undefined);
|
||||
setDetailBizId(0);
|
||||
if (curPage === 1) {
|
||||
void fetchFilteredData(nextSort);
|
||||
return;
|
||||
}
|
||||
setCurPage(1);
|
||||
};
|
||||
|
||||
// 유지관리 상세 정렬은 상단 사업 목록과 분리하고 선택된 사업 범위만 재조회한다.
|
||||
const handleDetailSortChange = (nextSort?: UiTableSort) => {
|
||||
setDetailSort(nextSort);
|
||||
setIsResetDetailPage(true);
|
||||
setIsResetDetailSelection(true);
|
||||
if (!detailBizId) {
|
||||
return;
|
||||
}
|
||||
if (detailCurPage === 1) {
|
||||
void handleOperationPageNation(Number(detailBizId), nextSort);
|
||||
return;
|
||||
}
|
||||
setDetailCurPage(1);
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
if (renderMode !== 'launcher' || !isSearchModalOpen) return;
|
||||
|
||||
@@ -179,6 +286,8 @@ const SearchModal: React.FC<SearchModalProps> = ({ renderMode = 'launcher', auto
|
||||
resetPageNo={isResetPage}
|
||||
resetSelection={isResetSelection}
|
||||
curPage={curPage}
|
||||
sort={sort}
|
||||
onSortChange={handleSortChange}
|
||||
headButton={
|
||||
<div className='flex'>
|
||||
<Button className="px-4 py-2 !mr-[1.2rem] text-white rounded-sm text-lg font-medium primary" size="sm" onClick={viewMultipleFeature}>지도에서 보기</Button>
|
||||
@@ -207,6 +316,8 @@ const SearchModal: React.FC<SearchModalProps> = ({ renderMode = 'launcher', auto
|
||||
resetPageNo={isResetPage}
|
||||
resetSelection={isResetSelection}
|
||||
curPage={curPage}
|
||||
sort={sort}
|
||||
onSortChange={handleSortChange}
|
||||
headButton={
|
||||
<Button className="px-4 py-2 !mr-[1.2rem] text-white rounded-sm text-sm font-medium green" size="sm" onClick={operationExcelDownload}>파일 다운로드</Button>
|
||||
}
|
||||
@@ -229,6 +340,8 @@ const SearchModal: React.FC<SearchModalProps> = ({ renderMode = 'launcher', auto
|
||||
resetPageNo={isResetDetailPage}
|
||||
resetSelection={isResetDetailSelection}
|
||||
curPage={detailCurPage}
|
||||
sort={detailSort}
|
||||
onSortChange={handleDetailSortChange}
|
||||
headButton={
|
||||
<div className='flex'>
|
||||
<Button className="px-4 py-2 !mr-[1.2rem] text-white rounded-sm text-lg font-medium primary" size="sm" onClick={viewMultipleFeature}>지도에서 보기</Button>
|
||||
|
||||
@@ -33,12 +33,23 @@ import { swagePmstnCqlFilters } from './swagePmstn';
|
||||
import { stofcbrCqlFilters } from './stofcbr';
|
||||
import { postPopupMessageAndClose } from '@/features/windowPopup/lib/popupMessage';
|
||||
import { WINDOW_POPUP_MESSAGE_TYPE } from '@/features/windowPopup/model/types';
|
||||
import type { UiTableSort } from '@common/business_components/ui';
|
||||
|
||||
const isPopupWindow = () =>
|
||||
typeof window !== "undefined" &&
|
||||
!!window.opener &&
|
||||
window.location.pathname.startsWith("/popup/");
|
||||
|
||||
const DISABLED_BASIC_SORT_KEYS = new Set(['flow_direction_basis']);
|
||||
|
||||
const resolveBasicSort = (basicType: string, sort?: UiTableSort) => {
|
||||
if (!sort) return undefined;
|
||||
if (basicType === 'tb_ms_swage_ppl' && DISABLED_BASIC_SORT_KEYS.has(sort.key)) {
|
||||
return undefined;
|
||||
}
|
||||
return sort;
|
||||
};
|
||||
|
||||
|
||||
export default function useBasicTab() {
|
||||
const {
|
||||
@@ -54,14 +65,16 @@ export default function useBasicTab() {
|
||||
const { ctpv, sgg } = useStore();
|
||||
|
||||
|
||||
const fetchBasicFilteredData = async () => {
|
||||
const fetchBasicFilteredData = async (requestedSort?: UiTableSort) => {
|
||||
const activeSort = resolveBasicSort(basicType, requestedSort ?? useSearchModalStore.getState().sort);
|
||||
let cqlFilters: string[] = [];
|
||||
const queryParams = new URLSearchParams({
|
||||
lyrTypeNm: basicType,
|
||||
page: `${curPage - 1}`,
|
||||
size: '10',
|
||||
sort: 'string',
|
||||
});
|
||||
if (basicType === 'tb_ms_swage_ppl') queryParams.set('includeFlowDirectionBasis', 'true');
|
||||
if (activeSort) queryParams.set('sort', `${activeSort.key},${activeSort.direction}`);
|
||||
|
||||
if (basicType === 'tb_ms_swage_ppl') cqlFilters = swagePplCqlFilters(); // 하수관로
|
||||
else if (basicType === 'tb_il_swage_prcs_fclt') cqlFilters = swagePrcsFcltCqlFilters(); // 하수처리시설
|
||||
@@ -72,6 +85,7 @@ export default function useBasicTab() {
|
||||
if (cqlFilters.length > 0) queryParams.append('cqlFiter', cqlFilters.join(' and '));
|
||||
|
||||
const res = await getData(`v1/fclts?${queryParams.toString()}`);
|
||||
const totalCount = Number(res.count ?? 0);
|
||||
|
||||
// 🎯 각 아이템을 개별적으로 포맷팅하면서 일련번호 추가
|
||||
const dataWithRowNumber = (res.data || []).map((item: any, index: number) => {
|
||||
@@ -103,7 +117,9 @@ export default function useBasicTab() {
|
||||
// 추가 처리 및 일련번호 추가
|
||||
return {
|
||||
...formattedItem,
|
||||
rowNumber: (curPage - 1) * 10 + index + 1, // 1부터 시작하는 일련번호 추가
|
||||
rowNumber: activeSort?.key === 'rowNumber' && activeSort.direction === 'desc'
|
||||
? totalCount - ((curPage - 1) * 10 + index)
|
||||
: (curPage - 1) * 10 + index + 1,
|
||||
scsYn: item.scsYn === null || item.scsYn === undefined ? 'N' : item?.scsYn, // scsYn이 null이면 'N'으로 변경
|
||||
mqltCd: matchedMqltCode ? matchedMqltCode.text : item?.mqltCd,
|
||||
usgCd: matchedMhlUsgCode ? matchedMhlUsgCode.text : (usgCdObj ? usgCdObj.text : item?.usgCd),
|
||||
@@ -115,8 +131,7 @@ export default function useBasicTab() {
|
||||
});
|
||||
|
||||
setTableData(dataWithRowNumber);
|
||||
// console.log(tableData);
|
||||
setTableCount(res.count || []);
|
||||
setTableCount(totalCount);
|
||||
};
|
||||
|
||||
// 기본정보 테이블 cellClick 핸들러
|
||||
@@ -169,6 +184,9 @@ export default function useBasicTab() {
|
||||
lyrTypeNm: basicType,
|
||||
size: '10000',
|
||||
});
|
||||
if (basicType === 'tb_ms_swage_ppl') queryParams.set('includeFlowDirectionBasis', 'true');
|
||||
const activeSort = resolveBasicSort(basicType, useSearchModalStore.getState().sort);
|
||||
if (activeSort) queryParams.set('sort', `${activeSort.key},${activeSort.direction}`);
|
||||
|
||||
let cqlFilters: string[] = [];
|
||||
let headerMapping: { [key: string]: string } = {};
|
||||
@@ -230,7 +248,7 @@ export default function useBasicTab() {
|
||||
const ctpvMap = new Map(ctpv.map(code => [code.val?.trim(), code.text]));
|
||||
const sggMap = new Map(sgg.map(code => [code.val?.trim(), code.text]));
|
||||
|
||||
const mappedContent = (res.data || []).map((item: any) => {
|
||||
let mappedContent = (res.data || []).map((item: any) => {
|
||||
const formattedItem = formatDisplayData(item);
|
||||
|
||||
let ctpvCd = item?.ctpvCd ?? formattedItem?.ctpvCd ?? '';
|
||||
|
||||
@@ -25,12 +25,29 @@ import useSearchModalStore from '@/store/useSearchModalStore';
|
||||
import useStore from '@/store/useStore';
|
||||
import { postPopupMessageAndClose } from '@/features/windowPopup/lib/popupMessage';
|
||||
import { WINDOW_POPUP_MESSAGE_TYPE } from '@/features/windowPopup/model/types';
|
||||
import type { UiTableSort } from '@common/business_components/ui';
|
||||
|
||||
const isPopupWindow = () =>
|
||||
typeof window !== "undefined" &&
|
||||
!!window.opener &&
|
||||
window.location.pathname.startsWith("/popup/");
|
||||
|
||||
// Spring Pageable 단일 정렬 계약을 정보검색 요청 파라미터에 추가한다.
|
||||
const appendSortParameter = (
|
||||
request: Record<string, string>,
|
||||
sort: UiTableSort | undefined,
|
||||
scope: 'list' | 'facility',
|
||||
) => {
|
||||
if (!sort) return;
|
||||
const requestSort = sort.key === 'rowNumber'
|
||||
? {
|
||||
key: scope === 'list' ? 'bizId' : 'ast_id',
|
||||
direction: sort.direction === 'asc' ? 'desc' : 'asc',
|
||||
}
|
||||
: sort;
|
||||
request.sort = `${requestSort.key},${requestSort.direction}`;
|
||||
};
|
||||
|
||||
|
||||
export default function useOperationTab() {
|
||||
const OPERATION_TABLE_SIZE = 5;
|
||||
@@ -47,6 +64,8 @@ export default function useOperationTab() {
|
||||
setTableData, setDetailTableData,
|
||||
setTableCount, setDetailTableCount,
|
||||
setDetailCurPage,
|
||||
sort, detailSort,
|
||||
setDetailSort,
|
||||
setIsLoading, setIsDetailLoading,
|
||||
setIsResetDetailPage,
|
||||
setIsResetDetailSelection,
|
||||
@@ -136,23 +155,27 @@ export default function useOperationTab() {
|
||||
|
||||
// JSON.parse 사용으로 문자열 -> 배열 변환
|
||||
RequestDTO.workTypes = JSON.parse(workType.replace(/'/g, '"'));
|
||||
appendSortParameter(RequestDTO, sort, 'list');
|
||||
|
||||
const params = new URLSearchParams(RequestDTO).toString();
|
||||
|
||||
const res = await getData(`v1/bizInfo?${params}`);
|
||||
const totalElements = Number(res.totalElements ?? 0);
|
||||
|
||||
const mappedContent = (res.content || []).map((item: any, index: number) => {
|
||||
const formattedItem = formatDisplayData(item);
|
||||
const matched = BIZ_TY_CD.find(code => code.lwpoCd === item.bizTypeCd);
|
||||
return {
|
||||
...formattedItem,
|
||||
rowNumber: (curPage - 1) * OPERATION_TABLE_SIZE + index + 1, // 1부터 시작하는 일련번호 추가
|
||||
rowNumber: sort?.key === 'rowNumber' && sort.direction === 'desc'
|
||||
? totalElements - ((curPage - 1) * OPERATION_TABLE_SIZE + index)
|
||||
: (curPage - 1) * OPERATION_TABLE_SIZE + index + 1,
|
||||
bizTypeCd: matched ? matched.lwpoCdNm : item.bizTypeCd,
|
||||
};
|
||||
});
|
||||
|
||||
setTableData(mappedContent || []);
|
||||
setTableCount(res.totalElements || 0);
|
||||
setTableCount(totalElements);
|
||||
setDetailTableData([]);
|
||||
setDetailTableCount(0);
|
||||
};
|
||||
@@ -162,6 +185,7 @@ export default function useOperationTab() {
|
||||
setIsResetDetailPage(true); // 페이지를 1로 초기화
|
||||
setIsResetDetailSelection(true); // 선택 초기화
|
||||
setIsResetDetailPage(true); // 디테일 페이지를 초기화
|
||||
setDetailSort(undefined); // 새 사업을 선택하면 이전 상세 정렬을 해제한다.
|
||||
|
||||
if (typeof index === "number") {
|
||||
setIsDetailLoading(true);
|
||||
@@ -169,7 +193,7 @@ export default function useOperationTab() {
|
||||
setDetailCurPage(1); // 상세 페이지 초기화
|
||||
try {
|
||||
const RequestDTO: Record<string, string> = {
|
||||
page: `${detailCurPage - 1}`,
|
||||
page: '0',
|
||||
size: `${OPERATION_TABLE_SIZE}`,
|
||||
bizId: tableData[index % OPERATION_TABLE_SIZE].bizId,
|
||||
};
|
||||
@@ -185,7 +209,7 @@ export default function useOperationTab() {
|
||||
const mqltCdObj = MS_SWAGE_PPL_CODES.find(code => code.val === item?.mqltCd?.trim());
|
||||
return {
|
||||
...formattedItem,
|
||||
rowNumber: (detailCurPage - 1) * OPERATION_TABLE_SIZE + detailIndex + 1,
|
||||
rowNumber: detailIndex + 1,
|
||||
usgCd: usgCdObj ? usgCdObj.text : item.usgCd,
|
||||
mqltCd: mqltCdObj ? mqltCdObj.text : item.mqltCd,
|
||||
swtrmt_zone_nm: item.swtrmt_zone_nm ? item.swtrmt_zone_nm : '-',
|
||||
@@ -232,7 +256,7 @@ export default function useOperationTab() {
|
||||
};
|
||||
|
||||
// 유지관리 정보 디테일 테이블 페이지네이션 핸들러
|
||||
const handleOperationPageNation = async (index?: number | undefined) => {
|
||||
const handleOperationPageNation = async (index?: number | undefined, requestedSort?: UiTableSort) => {
|
||||
if (typeof index === "number") {
|
||||
setIsDetailLoading(true);
|
||||
try {
|
||||
@@ -241,7 +265,10 @@ export default function useOperationTab() {
|
||||
size: `${OPERATION_TABLE_SIZE}`,
|
||||
bizId: String(detailBizId),
|
||||
};
|
||||
const activeDetailSort = requestedSort ?? useSearchModalStore.getState().detailSort;
|
||||
appendSortParameter(RequestDTO, activeDetailSort, 'facility');
|
||||
const res = await getData(`v1/bizInfo/fclts?${new URLSearchParams(RequestDTO).toString()}`);
|
||||
const totalElements = Number(res.count ?? 0);
|
||||
|
||||
const mappedData = (res.data || []).map((item: any, detailIndex: number) => {
|
||||
const formattedItem = formatDisplayData(item);
|
||||
@@ -250,7 +277,9 @@ export default function useOperationTab() {
|
||||
const mqltCdObj = MS_SWAGE_PPL_CODES.find(code => code.val === item?.mqltCd?.trim());
|
||||
return {
|
||||
...formattedItem,
|
||||
rowNumber: (detailCurPage - 1) * OPERATION_TABLE_SIZE + detailIndex + 1,
|
||||
rowNumber: activeDetailSort?.key === 'rowNumber' && activeDetailSort.direction === 'desc'
|
||||
? totalElements - ((detailCurPage - 1) * OPERATION_TABLE_SIZE + detailIndex)
|
||||
: (detailCurPage - 1) * OPERATION_TABLE_SIZE + detailIndex + 1,
|
||||
usgCd: usgCdObj ? usgCdObj.text : item.usgCd,
|
||||
mqltCd: mqltCdObj ? mqltCdObj.text : item.mqltCd,
|
||||
swtrmt_zone_nm: item.swtrmt_zone_nm ? item.swtrmt_zone_nm : '-',
|
||||
@@ -258,7 +287,7 @@ export default function useOperationTab() {
|
||||
});
|
||||
|
||||
setDetailTableData(mappedData);
|
||||
setDetailTableCount(res.count || 0);
|
||||
setDetailTableCount(totalElements);
|
||||
} catch (error) {
|
||||
console.error('유지관리 정보 페이지네이션 에러:', error);
|
||||
} finally {
|
||||
@@ -309,6 +338,7 @@ export default function useOperationTab() {
|
||||
|
||||
// JSON.parse 사용으로 문자열 -> 배열 변환
|
||||
RequestDTO.workTypes = JSON.parse(workType.replace(/'/g, '"'));
|
||||
appendSortParameter(RequestDTO, sort, 'list');
|
||||
|
||||
const params = new URLSearchParams(RequestDTO).toString();
|
||||
const res = await getData(`v1/bizInfo?${params}`);
|
||||
@@ -385,6 +415,7 @@ export default function useOperationTab() {
|
||||
size: '100',
|
||||
bizId: String(detailBizId),
|
||||
};
|
||||
appendSortParameter(RequestDTO, detailSort, 'facility');
|
||||
|
||||
const res = await getData(`v1/bizInfo/fclts?${new URLSearchParams(RequestDTO).toString()}`);
|
||||
|
||||
|
||||
@@ -21,6 +21,7 @@ import useBasicTab from './useBasicTab';
|
||||
import useOperationTab from './useOperationTab';
|
||||
import { postPopupMessageAndClose } from '@/features/windowPopup/lib/popupMessage';
|
||||
import { WINDOW_POPUP_MESSAGE_TYPE } from '@/features/windowPopup/model/types';
|
||||
import type { UiTableSort } from '@common/business_components/ui';
|
||||
|
||||
const isPopupWindow = () =>
|
||||
typeof window !== 'undefined' &&
|
||||
@@ -54,7 +55,7 @@ export default function useSearchModal() {
|
||||
];
|
||||
|
||||
// 검색 API 호출 함수
|
||||
const fetchFilteredData = async () => {
|
||||
const fetchFilteredData = async (requestedSort?: UiTableSort) => {
|
||||
const {
|
||||
activeTab: currentTab,
|
||||
curPage,
|
||||
@@ -73,7 +74,9 @@ export default function useSearchModal() {
|
||||
setIsLoading,
|
||||
setIsResetSelection,
|
||||
setIsResetDetailSelection,
|
||||
sort,
|
||||
} = useSearchModalStore.getState();
|
||||
const activeSort = requestedSort ?? sort;
|
||||
|
||||
setIsLoading(true);
|
||||
setIsResetSelection(true);
|
||||
@@ -81,7 +84,7 @@ export default function useSearchModal() {
|
||||
|
||||
try {
|
||||
if (currentTab === 'basic') {
|
||||
await fetchBasicFilteredData();
|
||||
await fetchBasicFilteredData(activeSort);
|
||||
} else if (currentTab === 'operation') {
|
||||
const requestDto: Record<string, string> = {
|
||||
page: `${curPage - 1}`,
|
||||
@@ -105,22 +108,31 @@ export default function useSearchModal() {
|
||||
requestDto.fromflfmtEndYmd = startDate;
|
||||
requestDto.toflfmtEndYmd = endDate;
|
||||
requestDto.workTypes = JSON.parse(workType.replace(/'/g, '"'));
|
||||
if (activeSort) {
|
||||
const requestSort = activeSort.key === 'rowNumber'
|
||||
? { key: 'bizId', direction: activeSort.direction === 'asc' ? 'desc' : 'asc' }
|
||||
: activeSort;
|
||||
requestDto.sort = `${requestSort.key},${requestSort.direction}`;
|
||||
}
|
||||
|
||||
const params = new URLSearchParams(requestDto).toString();
|
||||
const res = await getData(`v1/bizInfo?${params}`);
|
||||
const totalElements = Number(res.totalElements ?? 0);
|
||||
|
||||
const mappedContent = (res.content || []).map((item: any, index: number) => {
|
||||
const formattedItem = formatDisplayData(item);
|
||||
const matched = BIZ_TY_CD.find((code) => code.lwpoCd === item.bizTypeCd);
|
||||
return {
|
||||
...formattedItem,
|
||||
rowNumber: (curPage - 1) * OPERATION_TABLE_SIZE + index + 1,
|
||||
rowNumber: activeSort?.key === 'rowNumber' && activeSort.direction === 'desc'
|
||||
? totalElements - ((curPage - 1) * OPERATION_TABLE_SIZE + index)
|
||||
: (curPage - 1) * OPERATION_TABLE_SIZE + index + 1,
|
||||
bizTypeCd: matched ? matched.lwpoCdNm : item.bizTypeCd,
|
||||
};
|
||||
});
|
||||
|
||||
setTableData(mappedContent || []);
|
||||
setTableCount(res.totalElements || 0);
|
||||
setTableCount(totalElements);
|
||||
setDetailTableData([]);
|
||||
setDetailTableCount(0);
|
||||
}
|
||||
|
||||
@@ -32,6 +32,7 @@ import { getUserInfo } from "@common/components/api";
|
||||
import { openWindowPopup } from '@/features/windowPopup/lib/openWindowPopup';
|
||||
import { WINDOW_POPUP_MESSAGE_TYPE } from '@/features/windowPopup/model/types';
|
||||
import { SearchDatePicker } from '../_components/SearchDatePicker';
|
||||
import type { UiTableSort } from '@common/business_components/ui';
|
||||
|
||||
|
||||
const Page = () => {
|
||||
@@ -52,6 +53,7 @@ const Page = () => {
|
||||
const [isLoading, setIsLoading] = useState(true) //로딩 상태
|
||||
const [resetSelection, setResetSelection] = useState<boolean>(false); // 선택 초기화 상태
|
||||
const [isResetPage, setIsResetPage] = useState<boolean>(false);
|
||||
const [sort, setSort] = useState<UiTableSort>();
|
||||
|
||||
const { data: userInfo } = useQuery({
|
||||
queryKey: [ENDPOINT.USER_SERVICE.USERS],
|
||||
@@ -59,11 +61,6 @@ const Page = () => {
|
||||
enabled: true,
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
setCurPage(1)
|
||||
fetchData()
|
||||
}, [])
|
||||
|
||||
useEffect(() => {
|
||||
const handleMessage = (event: MessageEvent) => {
|
||||
if (event.origin !== window.location.origin) return;
|
||||
@@ -73,7 +70,7 @@ const Page = () => {
|
||||
|
||||
window.addEventListener("message", handleMessage);
|
||||
return () => window.removeEventListener("message", handleMessage);
|
||||
}, [fromDate, toDate, pjNm, curPage]);
|
||||
}, [fromDate, toDate, pjNm, curPage, sort]);
|
||||
|
||||
// 페이지 변경 시마다 데이터 fetch
|
||||
useEffect(() => {
|
||||
@@ -83,13 +80,16 @@ const Page = () => {
|
||||
|
||||
// 조회 버튼 클릭 시 1페이지로 이동 및 데이터 초기화
|
||||
const handleSearch: () => Promise<void> = async () => {
|
||||
setCurPage(1); // 페이지를 1로 초기화
|
||||
setIsResetPage(true); // 페이지를 초기화
|
||||
await fetchData();
|
||||
if (curPage === 1) {
|
||||
await fetchData(1, sort);
|
||||
return;
|
||||
}
|
||||
setCurPage(1); // 페이지 변경 effect에서 한 번만 조회
|
||||
};
|
||||
|
||||
// 조회
|
||||
const fetchData = async () => {
|
||||
const fetchData = async (page = curPage, activeSort = sort) => {
|
||||
setIsLoading(true)
|
||||
setResetSelection(true);
|
||||
// searchCmmnCd('SITEKNDCD')
|
||||
@@ -99,13 +99,18 @@ const Page = () => {
|
||||
toRegDt: formatDate(toDate),
|
||||
sbmrgPrevntBizNm: pjNm,
|
||||
auth: AUTH,
|
||||
page: curPage - 1,
|
||||
size: '10'
|
||||
page: page - 1,
|
||||
size: '10',
|
||||
sort: activeSort ? `${activeSort.key},${activeSort.direction}` : undefined,
|
||||
})
|
||||
// 일련번호를 추가한 데이터로 변환 (전체 데이터 기준으로 일련번호 계산)
|
||||
const descendingSequence =
|
||||
activeSort?.key === 'sbmrgPrevntBizSn' && activeSort.direction === 'desc';
|
||||
const dataWithRowNumber = (data || []).map((item: any, index: number) => ({
|
||||
...item,
|
||||
rowNumber: (curPage - 1) * 10 + index + 1, // 페이지 기준 일련번호 계산
|
||||
rowNumber: descendingSequence
|
||||
? total - ((page - 1) * 10 + index)
|
||||
: (page - 1) * 10 + index + 1,
|
||||
}));
|
||||
setTableData(dataWithRowNumber)
|
||||
setTotalData(total)
|
||||
@@ -120,6 +125,17 @@ const Page = () => {
|
||||
}
|
||||
}
|
||||
|
||||
const handleSortChange = (nextSort?: UiTableSort) => {
|
||||
setSort(nextSort);
|
||||
setIsResetPage(true);
|
||||
setResetSelection(true);
|
||||
if (curPage === 1) {
|
||||
void fetchData(1, nextSort);
|
||||
return;
|
||||
}
|
||||
setCurPage(1);
|
||||
};
|
||||
|
||||
// 파일 다운로드
|
||||
const isChckFile = () => {
|
||||
if (fileNm === '' || fileNm === undefined) {
|
||||
@@ -253,6 +269,8 @@ const Page = () => {
|
||||
onPageChange={setCurPage}
|
||||
resetSelection={resetSelection}
|
||||
resetPageNo={isResetPage}
|
||||
sort={sort}
|
||||
onSortChange={handleSortChange}
|
||||
/>
|
||||
</ListDiv>
|
||||
</>
|
||||
|
||||
@@ -13,11 +13,13 @@
|
||||
* - 첨부파일 다운로드(파일ID): GET /attach-service/api/v1/download/{atflId} (attach-service)
|
||||
*/
|
||||
'use client'
|
||||
import { useState, useEffect } from 'react'
|
||||
import { useState, useEffect, useMemo } from 'react'
|
||||
import LoadingOverlay from '@/components/ui/LoadingBar'
|
||||
import { UITab, UiTable } from '@common/business_components/ui'
|
||||
import * as XLSX from 'xlsx';
|
||||
import { getBlobData } from '@/components/lib/api';
|
||||
import type { UiTableSort } from '@common/business_components/ui';
|
||||
import { makeSortableHeaders, sortLocalTableRows } from '../tableSort';
|
||||
// import { Table, TableBody, TableHeader } from "@common/components/ui";
|
||||
// import { HeadMakeFloodPre } from './headMakeFloodPre';
|
||||
// import { InputTableBody } from './inputTableBody';
|
||||
@@ -40,6 +42,17 @@ const TableModal: React.FC<TableModalProps> = ({ fileNm, atflId }) => {
|
||||
const [resultTableHeader, setResultTableHeader] = useState<{ id: string; title: string, mergeCell?: boolean }[]>([]) //테이블 헤더
|
||||
const [resultTableData, setResultTableData] = useState<any>([{}]) //테이블 데이터
|
||||
const [isLoading, setIsLoading] = useState(false) //로딩 상태
|
||||
const [managementSort, setManagementSort] = useState<UiTableSort>();
|
||||
const [resultSort, setResultSort] = useState<UiTableSort>();
|
||||
|
||||
const sortedManagementTableData = useMemo(
|
||||
() => sortLocalTableRows(managementTableData, managementSort),
|
||||
[managementSort, managementTableData],
|
||||
);
|
||||
const sortedResultTableData = useMemo(
|
||||
() => sortLocalTableRows(resultTableData, resultSort),
|
||||
[resultSort, resultTableData],
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
isChckFile();
|
||||
@@ -86,7 +99,7 @@ const TableModal: React.FC<TableModalProps> = ({ fileNm, atflId }) => {
|
||||
title: String(value).replace(/\r\n/g, '\n')
|
||||
}));
|
||||
|
||||
setManagementTableHeader(filteredManagementHeader);
|
||||
setManagementTableHeader(makeSortableHeaders(filteredManagementHeader));
|
||||
|
||||
const managementData = Array.isArray(jsonDataBySheet['관리SHEET'])
|
||||
? jsonDataBySheet['관리SHEET'].slice(8)
|
||||
@@ -108,7 +121,7 @@ const TableModal: React.FC<TableModalProps> = ({ fileNm, atflId }) => {
|
||||
title: '',
|
||||
mergeCell: false
|
||||
});
|
||||
setResultTableHeader(dynamicResultHeader);
|
||||
setResultTableHeader(makeSortableHeaders(dynamicResultHeader));
|
||||
|
||||
const resultData = Array.isArray(jsonDataBySheet['운영관리현황 점검결과'])
|
||||
? jsonDataBySheet['운영관리현황 점검결과'].slice(3)
|
||||
@@ -229,13 +242,15 @@ const TableModal: React.FC<TableModalProps> = ({ fileNm, atflId }) => {
|
||||
<div className="min-w-max">
|
||||
<UiTable
|
||||
headName={'관리SHEET'}
|
||||
tableData={managementTableData}
|
||||
tableData={sortedManagementTableData}
|
||||
headlist={managementTableHeader}
|
||||
pageSize={10}
|
||||
total={0}
|
||||
cellClick={undefined}
|
||||
onPageChange={() => { }}
|
||||
className="whitespace-nowrap"
|
||||
sort={managementSort}
|
||||
onSortChange={setManagementSort}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
@@ -248,13 +263,15 @@ const TableModal: React.FC<TableModalProps> = ({ fileNm, atflId }) => {
|
||||
<LoadingOverlay isLoading={isLoading} />
|
||||
<UiTable
|
||||
headName={'운영관리현황 점검결과'}
|
||||
tableData={resultTableData}
|
||||
tableData={sortedResultTableData}
|
||||
headlist={resultTableHeader}
|
||||
pageSize={10}
|
||||
total={0}
|
||||
className={"overflow-x-auto overflow-y-auto"}
|
||||
cellClick={undefined}
|
||||
onPageChange={() => { }}
|
||||
sort={resultSort}
|
||||
onSortChange={setResultSort}
|
||||
/>
|
||||
{/* <Table variant="secondary">
|
||||
<TableHeader className="font-bold">
|
||||
|
||||
@@ -10,19 +10,19 @@
|
||||
* - 동적 테이블 렌더링을 위한 ID와 타이틀 매핑
|
||||
*/
|
||||
export const tableHeadlist = [
|
||||
{ id: 'rowNumber', title: '일련번호' },
|
||||
{ id: 'sbmrgPrevntBizNm', title: '파일명' },
|
||||
{ id: 'mngYr', title: '년도' },
|
||||
{ id: 'mngQu', title: '분기' },
|
||||
{ id: 'regDt', title: '등록 일자' },
|
||||
{ id: 'rowNumber', title: '일련번호', sortable: true, sortKey: 'sbmrgPrevntBizSn' },
|
||||
{ id: 'sbmrgPrevntBizNm', title: '파일명', sortable: true },
|
||||
{ id: 'mngYr', title: '년도', sortable: true },
|
||||
{ id: 'mngQu', title: '분기', sortable: true },
|
||||
{ id: 'regDt', title: '등록 일자', sortable: true },
|
||||
{ id: 'rgtrNm', title: '등록자' }
|
||||
|
||||
]
|
||||
export const tableHeadlistOldPp = [
|
||||
{ id: 'rowNumber', title: '일련번호' },
|
||||
{ id: 'dcrpPplMtncBizNm', title: '파일명' },
|
||||
{ id: 'mngYr', title: '년도' },
|
||||
{ id: 'mngQu', title: '분기' },
|
||||
{ id: 'regDt', title: '등록 일자' },
|
||||
{ id: 'rowNumber', title: '일련번호', sortable: true, sortKey: 'dcrpPplMtncBizSn' },
|
||||
{ id: 'dcrpPplMtncBizNm', title: '파일명', sortable: true },
|
||||
{ id: 'mngYr', title: '년도', sortable: true },
|
||||
{ id: 'mngQu', title: '분기', sortable: true },
|
||||
{ id: 'regDt', title: '등록 일자', sortable: true },
|
||||
{ id: 'rgtrNm', title: '등록자' }
|
||||
]
|
||||
]
|
||||
|
||||
@@ -32,6 +32,7 @@ import { getUserInfo } from "@common/components/api";
|
||||
import { openWindowPopup } from '@/features/windowPopup/lib/openWindowPopup';
|
||||
import { WINDOW_POPUP_MESSAGE_TYPE } from '@/features/windowPopup/model/types';
|
||||
import { SearchDatePicker } from '../_components/SearchDatePicker';
|
||||
import type { UiTableSort } from '@common/business_components/ui';
|
||||
|
||||
|
||||
const Page = () => {
|
||||
@@ -50,6 +51,7 @@ const Page = () => {
|
||||
const [isLoading, setIsLoading] = useState(true) //로딩 상태
|
||||
const [resetSelection, setResetSelection] = useState<boolean>(false); // 선택 초기화 상태
|
||||
const [isResetPage, setIsResetPage] = useState<boolean>(false);
|
||||
const [sort, setSort] = useState<UiTableSort>();
|
||||
|
||||
const { data: userInfo } = useQuery({
|
||||
queryKey: [ENDPOINT.USER_SERVICE.USERS],
|
||||
@@ -57,11 +59,6 @@ const Page = () => {
|
||||
enabled: true,
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
setCurPage(1)
|
||||
fetchData()
|
||||
}, [])
|
||||
|
||||
useEffect(() => {
|
||||
const handleMessage = (event: MessageEvent) => {
|
||||
if (event.origin !== window.location.origin) return;
|
||||
@@ -71,7 +68,7 @@ const Page = () => {
|
||||
|
||||
window.addEventListener("message", handleMessage);
|
||||
return () => window.removeEventListener("message", handleMessage);
|
||||
}, [fromDate, toDate, pjNm, curPage]);
|
||||
}, [fromDate, toDate, pjNm, curPage, sort]);
|
||||
|
||||
// 페이지 변경 시마다 데이터 fetch
|
||||
useEffect(() => {
|
||||
@@ -81,13 +78,16 @@ const Page = () => {
|
||||
|
||||
// 조회 버튼 클릭 시 1페이지로 이동 및 데이터 초기화
|
||||
const handleSearch: () => Promise<void> = async () => {
|
||||
setCurPage(1); // 페이지를 1로 초기화
|
||||
setIsResetPage(true); // 페이지를 초기화
|
||||
await fetchData();
|
||||
if (curPage === 1) {
|
||||
await fetchData(1, sort);
|
||||
return;
|
||||
}
|
||||
setCurPage(1); // 페이지 변경 effect에서 한 번만 조회
|
||||
};
|
||||
|
||||
// 데이터 조회
|
||||
const fetchData = async () => {
|
||||
const fetchData = async (page = curPage, activeSort = sort) => {
|
||||
setIsLoading(true)
|
||||
setResetSelection(true);
|
||||
try {
|
||||
@@ -96,13 +96,18 @@ const Page = () => {
|
||||
toRegDt: formatDate(toDate),
|
||||
dcrpPplMtncBizNm: pjNm,
|
||||
auth: AUTH,
|
||||
page: curPage - 1,
|
||||
size: '10'
|
||||
page: page - 1,
|
||||
size: '10',
|
||||
sort: activeSort ? `${activeSort.key},${activeSort.direction}` : undefined,
|
||||
})
|
||||
// 일련번호를 추가한 데이터로 변환 (전체 데이터 기준으로 일련번호 계산)
|
||||
const descendingSequence =
|
||||
activeSort?.key === 'dcrpPplMtncBizSn' && activeSort.direction === 'desc';
|
||||
const dataWithRowNumber = (data || []).map((item: any, index: number) => ({
|
||||
...item,
|
||||
rowNumber: (curPage - 1) * 10 + index + 1, // 페이지 기준 일련번호 계산
|
||||
rowNumber: descendingSequence
|
||||
? total - ((page - 1) * 10 + index)
|
||||
: (page - 1) * 10 + index + 1,
|
||||
}));
|
||||
setTableData(dataWithRowNumber)
|
||||
setTotalData(total)
|
||||
@@ -117,6 +122,17 @@ const Page = () => {
|
||||
}
|
||||
}
|
||||
|
||||
const handleSortChange = (nextSort?: UiTableSort) => {
|
||||
setSort(nextSort);
|
||||
setIsResetPage(true);
|
||||
setResetSelection(true);
|
||||
if (curPage === 1) {
|
||||
void fetchData(1, nextSort);
|
||||
return;
|
||||
}
|
||||
setCurPage(1);
|
||||
};
|
||||
|
||||
// 파일 다운로드
|
||||
const isChckFile = () => {
|
||||
if (fileNm === '' || fileNm === undefined) {
|
||||
@@ -249,6 +265,9 @@ const Page = () => {
|
||||
}}
|
||||
onPageChange={setCurPage}
|
||||
resetSelection={resetSelection}
|
||||
resetPageNo={isResetPage}
|
||||
sort={sort}
|
||||
onSortChange={handleSortChange}
|
||||
/>
|
||||
</ListDiv>
|
||||
</>
|
||||
|
||||
@@ -13,13 +13,15 @@
|
||||
* - 첨부파일 다운로드(파일ID): GET /attach-service/api/v1/download/{atflId} (attach-service)
|
||||
*/
|
||||
'use client'
|
||||
import { useState, useEffect } from 'react'
|
||||
import { useState, useEffect, useMemo } from 'react'
|
||||
import LoadingOverlay from '@/components/ui/LoadingBar'
|
||||
import { UITab, UiTable } from '@common/business_components/ui'
|
||||
import * as XLSX from 'xlsx';
|
||||
import { HeadMakeColSpan } from '@common/business_components/ui/table/HeadMakeColSpan';
|
||||
import { oldPpTable2HeadList, oldPpTable2NodeList, oldPpTable6HeadList, oldPpTable6NodeList } from './headList';
|
||||
import { getBlobData } from '@/components/lib/api';
|
||||
import type { UiHeadProps, UiTableSort } from '@common/business_components/ui';
|
||||
import { getNextTableSort, makeSortableHeaders, sortLocalTableRows } from '../tableSort';
|
||||
|
||||
const TABS = [
|
||||
{ val: "1", text: "raw data" },
|
||||
@@ -30,6 +32,11 @@ const TABS = [
|
||||
{ val: "6", text: "4.정비사업최종" },
|
||||
];
|
||||
|
||||
const sortableTable3HeadList = makeSortableHeaders(oldPpTable2HeadList);
|
||||
const sortableTable3NodeList = makeSortableHeaders(oldPpTable2NodeList);
|
||||
const sortableTable6HeadList = makeSortableHeaders(oldPpTable6HeadList);
|
||||
const sortableTable6NodeList = makeSortableHeaders(oldPpTable6NodeList);
|
||||
|
||||
interface TableModalProps {
|
||||
fileNm: string;
|
||||
atflId: string;
|
||||
@@ -49,6 +56,27 @@ const TableModal: React.FC<TableModalProps> = ({ fileNm, atflId }) => {
|
||||
const [tableData5, setTableData5] = useState<any>([{}])
|
||||
const [tableData6, setTableData6] = useState<any>([{}])
|
||||
const [isLoading, setIsLoading] = useState(false)
|
||||
const [tableSorts, setTableSorts] = useState<Record<string, UiTableSort | undefined>>({});
|
||||
|
||||
const sortedTableData = useMemo(
|
||||
() => ({
|
||||
'1': sortLocalTableRows(tableData1, tableSorts['1']),
|
||||
'2': sortLocalTableRows(tableData2, tableSorts['2']),
|
||||
'3': sortLocalTableRows(tableData3, tableSorts['3']),
|
||||
'4': sortLocalTableRows(tableData4, tableSorts['4']),
|
||||
'5': sortLocalTableRows(tableData5, tableSorts['5']),
|
||||
'6': sortLocalTableRows(tableData6, tableSorts['6']),
|
||||
}),
|
||||
[tableData1, tableData2, tableData3, tableData4, tableData5, tableData6, tableSorts],
|
||||
);
|
||||
|
||||
const setTableSort = (tab: string, nextSort?: UiTableSort) => {
|
||||
setTableSorts((current) => ({ ...current, [tab]: nextSort }));
|
||||
};
|
||||
|
||||
const handleCustomHeadSort = (tab: string, head: UiHeadProps) => {
|
||||
setTableSort(tab, getNextTableSort(head, tableSorts[tab]));
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
isChckFile();
|
||||
@@ -111,7 +139,7 @@ const TableModal: React.FC<TableModalProps> = ({ fileNm, atflId }) => {
|
||||
id: "rowNumber",
|
||||
title: '일련번호'
|
||||
});
|
||||
setTableHeader1(filteredHeader1);
|
||||
setTableHeader1(makeSortableHeaders(filteredHeader1));
|
||||
|
||||
const data1 = Array.isArray(jsonDataBySheet['raw data'])
|
||||
? jsonDataBySheet['raw data'].slice(2)
|
||||
@@ -150,7 +178,7 @@ const TableModal: React.FC<TableModalProps> = ({ fileNm, atflId }) => {
|
||||
id: "rowNumber",
|
||||
title: '일련번호'
|
||||
});
|
||||
setTableHeader2(filteredHeader2);
|
||||
setTableHeader2(makeSortableHeaders(filteredHeader2));
|
||||
|
||||
const data2 = Array.isArray(jsonDataBySheet['1.정밀조사'])
|
||||
? jsonDataBySheet['1.정밀조사'].slice(2)
|
||||
@@ -193,7 +221,7 @@ const TableModal: React.FC<TableModalProps> = ({ fileNm, atflId }) => {
|
||||
id: "rowNumber",
|
||||
title: '일련번호'
|
||||
});
|
||||
setTableHeader4(filteredHeader4);
|
||||
setTableHeader4(makeSortableHeaders(filteredHeader4));
|
||||
|
||||
const data4 = Array.isArray(jsonDataBySheet[' 3.정비사업'])
|
||||
? jsonDataBySheet[' 3.정비사업'].slice(2)
|
||||
@@ -225,7 +253,7 @@ const TableModal: React.FC<TableModalProps> = ({ fileNm, atflId }) => {
|
||||
id: "rowNumber",
|
||||
title: '일련번호'
|
||||
});
|
||||
setTableHeader5(filteredHeader5);
|
||||
setTableHeader5(makeSortableHeaders(filteredHeader5));
|
||||
|
||||
const data5 = Array.isArray(jsonDataBySheet['4.정비사업요약'])
|
||||
? jsonDataBySheet['4.정비사업요약'].slice(3)
|
||||
@@ -361,12 +389,14 @@ const TableModal: React.FC<TableModalProps> = ({ fileNm, atflId }) => {
|
||||
<div className="min-w-max">
|
||||
<UiTable
|
||||
headName={'raw data'}
|
||||
tableData={tableData1}
|
||||
tableData={sortedTableData['1']}
|
||||
headlist={tableHeader1}
|
||||
pageSize={10}
|
||||
total={0}
|
||||
cellClick={undefined}
|
||||
onPageChange={() => { }}
|
||||
sort={tableSorts['1']}
|
||||
onSortChange={(nextSort) => setTableSort('1', nextSort)}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
@@ -380,13 +410,15 @@ const TableModal: React.FC<TableModalProps> = ({ fileNm, atflId }) => {
|
||||
<div className="min-w-[150%]">
|
||||
<UiTable
|
||||
headName={'1.정밀조사'}
|
||||
tableData={tableData2}
|
||||
tableData={sortedTableData['2']}
|
||||
headlist={tableHeader2}
|
||||
pageSize={10}
|
||||
total={0}
|
||||
className={"overflow-x-auto overflow-y-auto"}
|
||||
cellClick={undefined}
|
||||
onPageChange={() => { }}
|
||||
sort={tableSorts['2']}
|
||||
onSortChange={(nextSort) => setTableSort('2', nextSort)}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
@@ -400,15 +432,21 @@ const TableModal: React.FC<TableModalProps> = ({ fileNm, atflId }) => {
|
||||
<div className="min-w-[550%]">
|
||||
<UiTable
|
||||
headName={'2.정밀조사_정비사업연계'}
|
||||
tableData={tableData3}
|
||||
headlist={oldPpTable2HeadList}
|
||||
tableData={sortedTableData['3']}
|
||||
headlist={sortableTable3HeadList}
|
||||
pageSize={10}
|
||||
total={0}
|
||||
className={"overflow-x-auto overflow-y-auto"}
|
||||
cellClick={undefined}
|
||||
onPageChange={() => { }}
|
||||
sort={tableSorts['3']}
|
||||
onSortChange={(nextSort) => setTableSort('3', nextSort)}
|
||||
>
|
||||
<HeadMakeColSpan upHeadList={oldPpTable2NodeList} />
|
||||
<HeadMakeColSpan
|
||||
upHeadList={sortableTable3NodeList}
|
||||
sort={tableSorts['3']}
|
||||
onSortChange={(head) => handleCustomHeadSort('3', head)}
|
||||
/>
|
||||
</UiTable>
|
||||
</div>
|
||||
</div>
|
||||
@@ -422,13 +460,15 @@ const TableModal: React.FC<TableModalProps> = ({ fileNm, atflId }) => {
|
||||
<div className="min-w-[160%]">
|
||||
<UiTable
|
||||
headName={'3.정비사업'}
|
||||
tableData={tableData4}
|
||||
tableData={sortedTableData['4']}
|
||||
headlist={tableHeader4}
|
||||
pageSize={10}
|
||||
total={0}
|
||||
className={"overflow-x-auto overflow-y-auto"}
|
||||
cellClick={undefined}
|
||||
onPageChange={() => { }}
|
||||
sort={tableSorts['4']}
|
||||
onSortChange={(nextSort) => setTableSort('4', nextSort)}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
@@ -442,13 +482,15 @@ const TableModal: React.FC<TableModalProps> = ({ fileNm, atflId }) => {
|
||||
<div className="min-w-[130%]">
|
||||
<UiTable
|
||||
headName={'4.정비사업요약'}
|
||||
tableData={tableData5}
|
||||
tableData={sortedTableData['5']}
|
||||
headlist={tableHeader5}
|
||||
pageSize={10}
|
||||
total={0}
|
||||
className={"overflow-x-auto overflow-y-auto"}
|
||||
cellClick={undefined}
|
||||
onPageChange={() => { }}
|
||||
sort={tableSorts['5']}
|
||||
onSortChange={(nextSort) => setTableSort('5', nextSort)}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
@@ -462,15 +504,21 @@ const TableModal: React.FC<TableModalProps> = ({ fileNm, atflId }) => {
|
||||
<div className="min-w-[200%]">
|
||||
<UiTable
|
||||
headName={'4.정비사업최종'}
|
||||
tableData={tableData6}
|
||||
headlist={oldPpTable6HeadList}
|
||||
tableData={sortedTableData['6']}
|
||||
headlist={sortableTable6HeadList}
|
||||
pageSize={10}
|
||||
total={0}
|
||||
className={"overflow-x-auto overflow-y-auto"}
|
||||
cellClick={undefined}
|
||||
onPageChange={() => { }}
|
||||
sort={tableSorts['6']}
|
||||
onSortChange={(nextSort) => setTableSort('6', nextSort)}
|
||||
>
|
||||
<HeadMakeColSpan upHeadList={oldPpTable6NodeList} />
|
||||
<HeadMakeColSpan
|
||||
upHeadList={sortableTable6NodeList}
|
||||
sort={tableSorts['6']}
|
||||
onSortChange={(head) => handleCustomHeadSort('6', head)}
|
||||
/>
|
||||
</UiTable>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -0,0 +1,71 @@
|
||||
import type { UiHeadProps, UiTableSort } from "@common/business_components/ui";
|
||||
|
||||
const TABLE_SORT_COLLATOR = new Intl.Collator("ko-KR", {
|
||||
numeric: true,
|
||||
sensitivity: "base",
|
||||
});
|
||||
|
||||
const isEmptySortValue = (value: unknown) =>
|
||||
value === null ||
|
||||
value === undefined ||
|
||||
String(value).trim() === "" ||
|
||||
String(value).trim() === "-";
|
||||
|
||||
const toComparableNumber = (value: unknown) => {
|
||||
const normalized = String(value ?? "")
|
||||
.trim()
|
||||
.replace(/,/g, "")
|
||||
.replace(/%$/, "");
|
||||
|
||||
if (!/^-?\d+(?:\.\d+)?$/.test(normalized)) return undefined;
|
||||
const number = Number(normalized);
|
||||
return Number.isFinite(number) ? number : undefined;
|
||||
};
|
||||
|
||||
/** 업로드된 엑셀의 실제 데이터 컬럼만 공통 UiTable 정렬 대상으로 만든다. */
|
||||
export const makeSortableHeaders = <T extends UiHeadProps>(headers: T[]) =>
|
||||
headers.map((header) => ({ ...header, sortable: true }));
|
||||
|
||||
/** 서버 페이징이 없는 엑셀 상세 그리드의 전체 행을 안정 정렬한다. */
|
||||
export const sortLocalTableRows = <T extends Record<string, unknown>>(
|
||||
rows: T[],
|
||||
sort?: UiTableSort,
|
||||
) => {
|
||||
if (!sort) return rows;
|
||||
|
||||
return rows
|
||||
.map((row, originalIndex) => ({ row, originalIndex }))
|
||||
.sort((leftEntry, rightEntry) => {
|
||||
const leftValue = leftEntry.row[sort.key];
|
||||
const rightValue = rightEntry.row[sort.key];
|
||||
const leftEmpty = isEmptySortValue(leftValue);
|
||||
const rightEmpty = isEmptySortValue(rightValue);
|
||||
|
||||
if (leftEmpty || rightEmpty) {
|
||||
if (leftEmpty && rightEmpty) return leftEntry.originalIndex - rightEntry.originalIndex;
|
||||
return leftEmpty ? 1 : -1;
|
||||
}
|
||||
|
||||
const leftNumber = toComparableNumber(leftValue);
|
||||
const rightNumber = toComparableNumber(rightValue);
|
||||
const comparison =
|
||||
leftNumber !== undefined && rightNumber !== undefined
|
||||
? leftNumber - rightNumber
|
||||
: TABLE_SORT_COLLATOR.compare(String(leftValue), String(rightValue));
|
||||
|
||||
if (comparison === 0) return leftEntry.originalIndex - rightEntry.originalIndex;
|
||||
return sort.direction === "asc" ? comparison : -comparison;
|
||||
})
|
||||
.map(({ row }) => row);
|
||||
};
|
||||
|
||||
/** children으로 다단 헤더를 전달하는 UiTable에서도 동일한 3상태 정렬을 사용한다. */
|
||||
export const getNextTableSort = (
|
||||
head: UiHeadProps,
|
||||
currentSort?: UiTableSort,
|
||||
): UiTableSort | undefined => {
|
||||
const key = head.sortKey ?? head.id;
|
||||
if (currentSort?.key !== key) return { key, direction: "asc" };
|
||||
if (currentSort.direction === "asc") return { key, direction: "desc" };
|
||||
return undefined;
|
||||
};
|
||||
@@ -11,6 +11,7 @@
|
||||
* - 데이터 변경 감지 및 불필요한 재렌더링 방지
|
||||
*/
|
||||
import { create } from "zustand";
|
||||
import type { UiTableSort } from "@common/business_components/ui";
|
||||
|
||||
|
||||
// 날짜 조합 함수
|
||||
@@ -50,6 +51,10 @@ export interface SearchModalState {
|
||||
// 페이지네이션
|
||||
curPage: number;
|
||||
detailCurPage: number;
|
||||
// 기본정보·유지관리 사업·보고서 목록이 공유하는 현재 단일 정렬 상태
|
||||
sort?: UiTableSort;
|
||||
// 유지관리 상세 목록의 현재 단일 정렬 상태
|
||||
detailSort?: UiTableSort;
|
||||
// 로딩 상태
|
||||
isLoading: boolean;
|
||||
isDetailLoading: boolean;
|
||||
@@ -93,6 +98,8 @@ export interface SearchModalState {
|
||||
setDetailTableCount: (count: number) => void;
|
||||
setCurPage: (page: number) => void;
|
||||
setDetailCurPage: (page: number) => void;
|
||||
setSort: (sort?: UiTableSort) => void;
|
||||
setDetailSort: (sort?: UiTableSort) => void;
|
||||
setIsLoading: (loading: boolean) => void;
|
||||
setIsDetailLoading: (loading: boolean) => void;
|
||||
setIsResetPage: (reset: boolean) => void;
|
||||
@@ -128,6 +135,8 @@ const useSearchModalStore = create<SearchModalState>((set) => ({
|
||||
detailTableCount: 0,
|
||||
curPage: 1,
|
||||
detailCurPage: 1,
|
||||
sort: undefined,
|
||||
detailSort: undefined,
|
||||
isLoading: false,
|
||||
isDetailLoading: false,
|
||||
isResetPage: false,
|
||||
@@ -161,6 +170,8 @@ const useSearchModalStore = create<SearchModalState>((set) => ({
|
||||
setDetailTableCount: (count) => set({ detailTableCount: count }),
|
||||
setCurPage: (page) => set({ curPage: page }),
|
||||
setDetailCurPage: (page) => set({ detailCurPage: page }),
|
||||
setSort: (sort) => set({ sort }),
|
||||
setDetailSort: (detailSort) => set({ detailSort }),
|
||||
setIsLoading: (loading) => set({ isLoading: loading }),
|
||||
setIsDetailLoading: (loading) => set({ isDetailLoading: loading }),
|
||||
setIsResetPage: (reset) => set({ isResetPage: reset }),
|
||||
@@ -200,6 +211,8 @@ const useSearchModalStore = create<SearchModalState>((set) => ({
|
||||
detailTableCount: 0,
|
||||
curPage: 1,
|
||||
detailCurPage: 1,
|
||||
sort: undefined,
|
||||
detailSort: undefined,
|
||||
isResetPage: true,
|
||||
isResetDetailPage: true,
|
||||
isResetSelection: true,
|
||||
@@ -208,4 +221,4 @@ const useSearchModalStore = create<SearchModalState>((set) => ({
|
||||
}),
|
||||
}));
|
||||
|
||||
export default useSearchModalStore;
|
||||
export default useSearchModalStore;
|
||||
|
||||
@@ -25,3 +25,10 @@
|
||||
## 6. 판단
|
||||
- 프론트 수정 범위 내에서 신규 타입 오류를 만들지는 않았다.
|
||||
- 실제 장애 해소 여부의 최종 판정은 백엔드 반영본과 함께 브라우저 재조회 확인이 필요하다.
|
||||
|
||||
## 7. 2026-07-16 관로현황 페이징 유지보수 검토
|
||||
- 코드 리뷰로 `PipeAnalysisDetailPopupPage.tsx`의 조회, 페이지 이동, 정렬 변경, 다운로드가 각각 `appliedFilters`, `appliedSort`를 공유하도록 연결된 것을 확인했다.
|
||||
- 페이지 이동은 마지막 조회 조건을 유지하고, 정렬 변경은 마지막 조회 조건으로 1페이지 재조회하도록 분리되어 기존 페이징 계약을 복구했다.
|
||||
- 입력 중인 필터를 바로 읽던 기존 흐름 대신 `조회` 시점에만 적용 조건을 갱신하므로, 미적용 초안 필터 때문에 상세 팝업 결과 수나 페이지 상태가 흔들리던 회귀를 방지한다.
|
||||
- 상세 팝업은 페이지당 10건과 별도 테이블 스크롤 영역으로 조정되어 현재 팝업 높이에서 페이지네이션이 화면 하단에 노출되는 구조로 바뀌었다.
|
||||
- 브라우저 실동작 최종 확인은 사용자 수동 검증 기준으로 진행한다.
|
||||
|
||||
@@ -55,3 +55,13 @@
|
||||
- 사용자 스크린샷 기준 위치 체감 확인
|
||||
- 최종 크기(`850 x 600`) 상태에서 표 하단 공백 체감 확인
|
||||
- 필요 시 문서만 현재 운영값 기준으로 재동기화
|
||||
|
||||
## 5. 2026-07-20 목록 정렬 검증
|
||||
|
||||
- 8개 헤더의 `sortable` 설정과 controlled `sort` 연결을 정적으로 확인했다.
|
||||
- 표시 필드와 WFS 원천 필드 매핑, 경과년도와 설치일자의 반대 방향 변환을 확인했다.
|
||||
- 정렬 변경 시 1페이지 재조회와 선택 행 초기화를 확인했다.
|
||||
- 프론트 저장소 `git diff --check`를 통과했다.
|
||||
- TypeScript 검사는 로컬 `node_modules` 읽기 권한 제한으로 미실행했다.
|
||||
- 사용자 수동 확인 기준으로 실제 GeoServer 실데이터 조회와 노후관로 목록 정렬 동작이 정상 확인됐다.
|
||||
- Codex 자동 브라우저 검증은 미실행이며, 사용자 수동 확인 결과를 실동작 검증 근거로 기록한다.
|
||||
|
||||
@@ -0,0 +1,65 @@
|
||||
# 공통 UiTable 컬럼 정렬 통합 검증 계획
|
||||
|
||||
## 1. 검증 범위
|
||||
|
||||
- 공통 `UiTable`의 optional 정렬 계약과 기존 화면 하위 호환
|
||||
- 단일·다단 헤더의 leaf 컬럼 판정, 아이콘, 키보드 버튼, `aria-sort`
|
||||
- 프로토타입 화면과 서버 API 사이의 `sort=field,direction` 계약
|
||||
- 정렬 변경, 페이지 이동, 선택 행 초기화, 다운로드 순서 유지
|
||||
- 허용 필드 검증, null 배치, 동일값 보조 정렬
|
||||
|
||||
## 2. 공통 검증 기준
|
||||
|
||||
- 정렬 props가 없는 기존 화면은 렌더링과 클릭 동작이 유지되어야 한다.
|
||||
- 정렬 가능한 헤더만 `asc -> desc -> undefined` 순서로 동작해야 한다.
|
||||
- 공통 컴포넌트가 `tableData`를 직접 변경하지 않아야 한다.
|
||||
- 화면 표시 필드가 아닌 API 원천 필드가 `sortKey`로 전송되어야 한다.
|
||||
- 정렬 변경 시 첫 페이지 이동과 선택 해제가 함께 수행되어야 한다.
|
||||
|
||||
## 3. 서버 연동 검증 기준
|
||||
|
||||
- 허용된 9개 필드의 asc/desc 요청이 처리되어야 한다.
|
||||
- 미허용 필드, 잘못된 방향과 형식은 400 응답 대상이어야 한다.
|
||||
- 날짜·문자·숫자의 null 값은 뒤에 배치되어야 한다.
|
||||
- 동일한 정렬값이 많아도 발생일자·사고번호 보조 정렬로 페이지가 안정적이어야 한다.
|
||||
- 일반 목록, 좌표상태 필터 조회와 다운로드 경로가 동일한 정렬 계약을 사용해야 한다.
|
||||
|
||||
## 4. 회귀 검증
|
||||
|
||||
- 공통 비즈니스 컴포넌트 ESM/DTS 빌드
|
||||
- SN 변경 파일 TypeScript 검사
|
||||
- 실행 중 개발 서버와 `.next`가 충돌하지 않는 조건에서 SN 빌드
|
||||
- BE `compileJava`
|
||||
- `fo`, `wr` 공통 패키지 의존 회귀 확인
|
||||
|
||||
## 5. 수동 검증
|
||||
|
||||
- 로그인 가능한 `/map`에서 오름차순, 내림차순, 기본순서 전환을 확인한다.
|
||||
- 1페이지 마지막 행과 2페이지 첫 행의 연속성을 확인한다.
|
||||
- 정렬 상태가 페이지 이동과 엑셀 다운로드에도 유지되는지 확인한다.
|
||||
- 좌표보정상태 필터와 정렬을 함께 사용해 후처리 필터 이후 순서를 확인한다.
|
||||
|
||||
## 6. 2026-07-15 추가 검증 계획
|
||||
|
||||
- 사고번호 오름차순·내림차순 요청과 페이지 연속성을 확인한다.
|
||||
- 미선택 `▲/▼`, 오름차순 `▲`, 내림차순 `▼` 표시와 축소된 크기를 확인한다.
|
||||
- 기본 팝업 폭에서 발생규모연장·깊이가 한 줄이고, 좁은 창에서는 자연스럽게 줄바꿈되는지 확인한다.
|
||||
- 주소 오름차순 결과가 `C` collation의 UTF-8 한글 음절 순서인 `가→나→다`와 일치하는지 실 DB에서 확인한다.
|
||||
|
||||
## 7. 2026-07-16 Repository 리팩터링 검증 계획
|
||||
|
||||
- 9개 허용 필드가 각각 asc/desc 단일 정렬식을 생성하는지 확인한다.
|
||||
- 모든 사용자 정렬 뒤에 `ocrn_ymd desc, acdnt_no desc` 보조 정렬이 유지되는지 확인한다.
|
||||
- 주소 정렬식이 기존 전체 주소 조합과 PostgreSQL `C` collation을 유지하는지 확인한다.
|
||||
- 검색조건과 `limit`, `offset`이 기존 이름과 값으로 바인딩되는지 확인한다.
|
||||
- 미허용 키가 SQL 생성 전에 거절되고 생성 SQL에 `sortKey`, `sortDirection`, 정렬용 `CASE`가 남지 않는지 확인한다.
|
||||
- `compileJava`와 Repository 단위 테스트를 수행하고, 실 DB·브라우저 순서 검증은 기존 후속 항목으로 유지한다.
|
||||
|
||||
## 8. 2026-07-16 정보검색 1단계 검증 계획
|
||||
|
||||
- 기본정보 6개 레이어에서 활성화된 원천 필드만 WFS `sortBy`로 변환되고 미허용·미지정 필드는 기존 GeoServer 순서를 유지하는지 확인한다.
|
||||
- 유지관리 사업 목록은 5개 허용 필드와 `bizId desc` 기본·보조 정렬을 확인한다.
|
||||
- 유지관리 상세는 관리번호, 처리구역, 구조적 상태등급, 운영적 상태등급만 정렬되고 같은 값에는 `ast_id desc`가 적용되는지 확인한다.
|
||||
- 보고서는 분류 트리 전체 정렬 후 페이징되는지, 파일명·등록일자에는 정렬 버튼이 없는지 확인한다.
|
||||
- 모든 서버 조회형 목록에서 정렬 변경 시 0페이지 요청, 선택 초기화, 정렬 해제 시 기존 기본순서 복귀를 확인한다.
|
||||
- 기본정보·유지관리 다운로드가 화면과 같은 정렬 파라미터를 사용하는지 확인한다.
|
||||
@@ -0,0 +1,93 @@
|
||||
# 공통 UiTable 컬럼 정렬 통합 검증 결과
|
||||
|
||||
## 1. 수행 결과
|
||||
|
||||
- 공통 `@common/business_components` ESM/DTS 빌드는 프로토타입 구현 라운드에서 성공했다.
|
||||
- SN 변경 파일 기준 신규 TypeScript 오류는 확인되지 않았다.
|
||||
- SN 전체 TypeScript 검사는 기존 지도·차트·HTML 경로의 선행 오류 9건으로 종료 코드 1이었다.
|
||||
- SN Next 빌드는 180초 제한 안에 종료되지 않아 타임아웃 처리했다.
|
||||
- 백엔드 `compileJava`는 현재 HEAD 병합 후 다시 수행해 성공했다.
|
||||
- 프론트와 백엔드 저장소 모두 미해결 충돌과 충돌 마커가 없고 `git diff --check`를 통과했다.
|
||||
|
||||
## 2. 정적 계약 확인
|
||||
|
||||
- 공통 정렬 타입이 ESM/DTS 출력 대상에 포함되는 구조를 확인했다.
|
||||
- 정렬 불가 컬럼에는 `sortable`이 없고 표시 필드는 `sortKey`로 API 원천 필드에 매핑된다.
|
||||
- 백엔드는 허용목록 외 필드를 Repository 전달 전에 거절한다.
|
||||
- 커스텀 Repository는 enum에서 선택된 고정 SQL 표현식만 사용하고 사용자 컬럼 문자열을 SQL에 직접 연결하지 않는다.
|
||||
- DB 스키마와 원천 데이터 변경은 없다.
|
||||
|
||||
## 3. 현재 판정
|
||||
|
||||
- 공통 컴포넌트, 현재 구현 범위 화면, 백엔드 정렬 계약과 컴파일 검증은 완료했다.
|
||||
- 현재 구현 범위의 브라우저 동작은 사용자 수동 검증 기준으로 완료했다.
|
||||
- 미구현 화면의 정렬 동작, `fo`, `wr` 직접 회귀 빌드, 미적용 화면의 추가 검증은 후속 범위다.
|
||||
|
||||
## 4. 문서 정리 결과
|
||||
|
||||
- 지반침하정보 관련 MD는 각 저장소의 HEAD 상태로 복구했다.
|
||||
- 정렬 기능의 구현·검증 이력은 이 공통 기능 문서 세트로 통합했다.
|
||||
|
||||
## 5. 2026-07-15 추가 검증 결과
|
||||
|
||||
- 공통 `@common/business_components` ESM/DTS 빌드가 성공했다.
|
||||
- 백엔드 `compileJava`가 성공했다.
|
||||
- 2026-07-15 당시 사고번호가 프론트 정렬 key와 백엔드 허용목록·고정 CASE SQL에 동일하게 연결된 것을 확인했으며, 2026-07-16 enum 전환 후에도 같은 계약을 유지한다.
|
||||
- 미선택과 선택 상태가 각각 축소된 `▲/▼`, `▲`, `▼`로 렌더링되는 것을 정적으로 확인했다.
|
||||
- 기본 팝업 폭은 컬럼 폭 조정으로 한 줄 표시를 유도하고, 고정 nowrap 없이 좁은 창의 줄바꿈을 유지한다.
|
||||
- 주소 전체 문자열에 PostgreSQL `C` collation이 명시된 것을 확인했다.
|
||||
- Next 개발 서버와 `.next`는 건드리지 않았고 브라우저·실 DB 화면 검증은 사용자 실행 환경에서 남아 있다.
|
||||
|
||||
## 6. 2026-07-16 Repository 리팩터링 검증 결과
|
||||
|
||||
- `./gradlew.bat compileJava --console=plain`: 성공.
|
||||
- `./gradlew.bat test --tests iipbs.il.domain.SinkholeAccidentRepositoryImplTest --console=plain`: 성공.
|
||||
- 단위 테스트에서 9개 허용 필드의 asc/desc, `NULLS LAST`, 발생일자·사고번호 보조 정렬, 주소 전체 문자열과 `C` collation을 확인했다.
|
||||
- 검색조건 6개와 `limit`, `offset`의 기존 바인딩을 확인했다.
|
||||
- 생성 SQL에 정렬용 `CASE`, `sortKey`, `sortDirection`이 없고 미허용 키가 SQL 생성 전에 거절되는 것을 확인했다.
|
||||
- API·DTO·프론트·DB 변경은 없으며 실 DB 페이지 경계와 브라우저 상호작용 검증은 기존 후속 항목으로 남는다.
|
||||
|
||||
## 7. 2026-07-16 정보검색 1단계 검증 결과
|
||||
|
||||
- 프론트·백엔드 저장소에서 `git diff --check`를 통과했다.
|
||||
- 정적 검토로 네 목록의 `sortable`/`sortKey`, controlled 상태, 첫 페이지 초기화, API·다운로드 정렬 파라미터 연결을 확인했다.
|
||||
- 하수관로 기본정보의 `유하방향 근거`는 현재 정렬 대상에서 제외한 것을 확인했다.
|
||||
- 제외 이유는 파생값 컬럼이라 기존 목록 정렬·페이징 계약을 병목 없이 만족하려면 백엔드 정식 정렬 지원이 필요하기 때문이다.
|
||||
- 기본계획 보고서는 전체 필터 결과를 정렬한 다음 10건씩 자르고, 그 뒤 현재 페이지 파일 메타데이터를 조회하는 순서를 확인했다.
|
||||
- 보고서 `파일명`, `등록일자`는 현재 정렬 대상에서 제외한 것을 확인했다.
|
||||
- 제외 이유는 기본 분류 트리 응답에 없는 파일 메타데이터를 분류별 추가 호출로 채워야 해, 기존 목록 정렬 계약과 성능을 동시에 만족하려면 `dc-service`의 일괄 메타데이터 지원이 필요하기 때문이다.
|
||||
- 백엔드 `compileJava`와 `FcltWfsSortPolicyTest`, `BizInfoSortPolicyTest`가 성공했다.
|
||||
- TypeScript 검사는 저장소 `node_modules` 읽기 권한 요청이 승인되지 않아 미실행했다. 코드 오류로 실패한 결과와 구분한다.
|
||||
- 로컬 SN 서버는 3002 포트에서 확인했지만 인앱 브라우저가 사용자 프로필 경로 권한으로 시작되지 않아 화면 자동 검증은 미실행했다.
|
||||
- 실 GeoServer WFS 정렬, 실 DB Native Query 페이지 경계, 브라우저의 `asc -> desc -> undefined` 상호작용은 후속 수동 검증 항목이다.
|
||||
|
||||
## 8. 2026-07-16 현재 구현 범위 브라우저 확인
|
||||
|
||||
- 사용자 수동 검증 기준으로 현재 구현된 정렬 화면의 브라우저 동작은 정상 확인됐다.
|
||||
- 확인 완료 범위는 지반침하정보, 정보검색 기본정보, 정보검색 유지관리 사업 목록, 정보검색 유지관리 상세, 정보검색 기본계획 보고서, 침입수 분석결과, RDII 분석결과, 관로분석 상세다.
|
||||
- 관로분석 요약은 `YEAR`, `MATERIAL` 유형의 정렬 동작을 현재 구현 범위로 보고 확인 완료 범위에 포함한다.
|
||||
- `YEAR_MATERIAL` 요약 분기, 노후관로 팝업, 보고서 파일 팝업, 구형 I/I 결과 페이지, 구형 운영 화면은 정렬 미구현 범위라 이번 브라우저 확인 대상에서 제외했다.
|
||||
- 본 문서의 자동 브라우저 미실행 기록은 Codex 인앱 검증 부재를 뜻하며, 사용자 수동 검증 완료와는 별개로 유지한다.
|
||||
|
||||
## 9. 2026-07-20 운영정보·노후관로 정적 검증
|
||||
|
||||
- 프론트 저장소 `git diff --check`를 통과했다.
|
||||
- 도시침수 예방사업과 노후하수관로 정비사업 목록의 5개 서버 정렬 키, 첫 페이지 초기화, 선택 초기화, 정렬 해제 시 기본 정렬 복귀를 정적으로 확인했다.
|
||||
- 두 목록의 외부 사용자명 `등록자`는 정렬 불가 헤더로 유지한 것을 확인했다.
|
||||
- 도시침수 엑셀 2개 탭과 노후하수관로 엑셀 6개 탭은 원본 배열을 변경하지 않는 전체행 안정 정렬을 사용한다.
|
||||
- 노후하수관로 다단 헤더는 leaf 컬럼에만 정렬 버튼이 표시되도록 공통 `HeadMakeColSpan` 계약을 연결했다.
|
||||
- 노후관로 목록의 8개 표시 컬럼과 WFS 허용 원천 필드 매핑을 확인했고, 경과년도 정렬 방향을 설치일자 기준으로 반전하는 것을 확인했다.
|
||||
- `pnpm --filter sn exec tsc --noEmit --pretty false`는 비대화형 환경에서 `node_modules` 재설치 확인이 중단됐다.
|
||||
- 설치된 TypeScript 5.9.3을 직접 실행하고 `ignoreDeprecations`만 CLI에서 5.0으로 보정해 전체 타입 검사를 수행했다.
|
||||
- 이번 변경의 `reportTab.tsx` strict 인덱스 오류 1건을 수정한 뒤 재검사했으며, 출력에는 기존 지도·침수이력·HTML·차트 경로 오류 9건만 남았다.
|
||||
- 공통 `@common/business_components` ESM/DTS 빌드가 성공했다.
|
||||
- 사용자 수동 확인 기준으로 운영정보의 도시침수 예방사업·노후하수관로 정비사업 그리드와 노후관로 목록의 실데이터 정렬 동작이 정상 확인됐다.
|
||||
- Codex 자동 브라우저 검증은 수행하지 않았으며, 이번 실동작 판정은 사용자 수동 확인 결과를 근거로 기록한다.
|
||||
- 커밋 전 정적 검토에서 기본계획 보고서가 현재 페이지 외 메타데이터를 예열하지 않는 것을 확인했다.
|
||||
- 침입수·RDII `분석자`와 운영정보 `등록자`는 외부 사용자명 보강 컬럼이므로 잘못된 UUID 정렬 대신 정렬 불가 헤더로 유지한다.
|
||||
|
||||
## 10. 2026-07-20 노후관로 성능 회귀 보완
|
||||
|
||||
- 노후관로 요청에는 `includeFlowDirectionBasis`가 없고, 정보검색 기본정보의 하수관로 요청에만 `true`가 전달되는 것을 정적으로 확인했다.
|
||||
- 따라서 노후관로 정렬·페이지 이동마다 유하방향 근거 CTE 배치 조회가 추가 실행되지 않는다.
|
||||
- 실제 응답시간 비교는 변경된 백엔드 재기동 후 사용자 통합 환경에서 확인한다.
|
||||
@@ -23,6 +23,9 @@
|
||||
- 집계 결과는 10개 단위 클라이언트 페이징으로 잘라 렌더링한다.
|
||||
- `경과년도&재질`은 rowSpan 구조를 유지하기 위해 처리구역 10묶음 기준으로 페이지를 나눈다.
|
||||
- 모든 분석구분 헤더에 현재 집계 건수를 `N건` 형식으로 노출한다.
|
||||
- `경과년도&재질` 표는 `경과년도` 열만 제외하고 `NO`, `시도`, `시군구`, `처리구역`, `합계`, 관재질 열 정렬을 지원한다.
|
||||
- `경과년도&재질`의 관재질 열 정렬은 처리구역 그룹 내 연도별 값을 합산한 총계 기준으로 동작한다.
|
||||
- `경과년도&재질` 엑셀 다운로드와 페이지네이션도 현재 정렬 순서를 따른다.
|
||||
- `PipeAnalysisDetailPopupPage`
|
||||
- 상세 조회/다운로드 실패 시 실제 `Error.message`를 우선 alert에 노출한다.
|
||||
|
||||
@@ -35,3 +38,11 @@
|
||||
## 6. 리스크 및 메모
|
||||
- `getData` 공통 유틸 수정이라 다른 화면에서도 이제 서버 메시지가 더 직접적으로 노출된다.
|
||||
- 서버 메시지 문구가 사용자 친화적이지 않은 API는 추후 별도 화면 단에서 다듬을 수 있다.
|
||||
|
||||
## 7. 2026-07-16 관로현황 페이징 유지보수
|
||||
- `PipeAnalysisDetailPopupPage.tsx`에 `appliedFilters`, `appliedSort` 상태를 추가해 마지막 조회에 실제 적용된 필터·정렬 조건을 별도로 보관한다.
|
||||
- `조회` 버튼은 입력 중인 필터를 scope 규칙으로 정규화한 뒤 1페이지부터 다시 조회하고, 페이지 이동은 입력 중인 값이 아니라 마지막 조회 조건을 그대로 사용한다.
|
||||
- 헤더 정렬 변경도 마지막 조회 조건 기준으로 1페이지 재조회하도록 바꿔 페이징과 정렬이 서로 조건을 덮어쓰지 않게 했다.
|
||||
- 엑셀 다운로드는 화면에 마지막으로 적용된 필터·정렬과 같은 조건으로 전체 상세 데이터를 다시 조회한다.
|
||||
- `관로현황`은 페이지당 10건으로 조정하고, 팝업 본문은 필터 영역과 테이블 영역을 분리해 페이지네이션이 현재 창 높이 안에서 바로 보이도록 정리했다.
|
||||
- 이번 수정으로 `관로현황` 팝업의 기존 페이징 기능을 유지하면서, 사용자가 필터 입력만 바꿔 둔 상태에서 페이지 이동이나 정렬을 눌러도 미적용 초안 값이 섞이지 않고 페이지네이션도 다시 노출되도록 정리했다.
|
||||
|
||||
@@ -52,9 +52,18 @@
|
||||
- 후속 정리로 선택 레이어 설정에는 침수심 범례 본문만 남기고, 지도 우측 하단에 따로 뜨던 기존 범례 박스는 제거했다.
|
||||
- 추가 구조 보정으로 토글 핸들을 패널 본체와 분리된 고정 핸들로 바꿔, 닫았을 때도 화면 왼쪽 가장자리에 다시 열기 버튼이 남도록 정리했다.
|
||||
|
||||
### 3.4 2026-07-20 노후관로 목록 정렬
|
||||
|
||||
- 목록의 `NO`, `시설관리번호`, `설치일자`, `관경`, `연장`, `용도`, `재질`, `경과년도`를 정렬 가능 컬럼으로 전환했다.
|
||||
- 현재 페이지 배열만 정렬하지 않고 `GET /api/v1/fclts`의 `sort=field,direction`을 사용해 전체 필터 결과를 WFS에서 정렬한 뒤 페이지를 조회한다.
|
||||
- 화면 필드는 GeoServer 원천 필드인 `fclt_mng_no`, `instl_ymd`, `pdmt_vl`, `prlg_vl`, `usg_cd`, `mqlt_cd`로 제한 매핑했다.
|
||||
- 경과년도는 설치일자에서 계산되므로 경과년도 오름차순 요청을 설치일자 내림차순으로, 내림차순 요청을 설치일자 오름차순으로 변환한다.
|
||||
- `NO` 내림차순은 전체 건수 기준 번호가 감소하도록 표시하고, 정렬 변경 시 첫 페이지와 선택 행을 초기화한다.
|
||||
|
||||
## 4. 실제 수정 파일
|
||||
|
||||
- `FE_APP_ROOT/app/(map-layout)/map/components/agedPipe/AgedPipePopupPage.tsx`
|
||||
- `FE_APP_ROOT/app/(map-layout)/map/components/agedPipe/agedPipeUtils.ts`
|
||||
- `FE_APP_ROOT/src/features/windowPopup/model/popupDefinitions.ts`
|
||||
- `FE_APP_ROOT/src/features/windowPopup/model/types.ts`
|
||||
- `FE_APP_ROOT/src/features/windowPopup/lib/openWindowPopup.ts`
|
||||
|
||||
@@ -0,0 +1,131 @@
|
||||
# 공통 UiTable 컬럼 정렬 통합 구현 기록
|
||||
|
||||
## 1. 문서 목적
|
||||
|
||||
- 공통 `UiTable` 컬럼 정렬 기능의 구현 이력을 기능 단위로 관리한다.
|
||||
- 지반침하정보는 최초 프로토타입 적용 화면이며, 정렬 기능 자체의 문서 소유자는 이 공통 기능 폴더다.
|
||||
|
||||
## 2. 공통 구현 계약
|
||||
|
||||
- `UiTable`은 데이터를 직접 정렬하지 않고 optional controlled 정렬 상태와 변경 이벤트만 제공한다.
|
||||
- `sortable`, `sortKey`, `sort`, `onSortChange`는 모두 선택 속성으로 기존 사용처의 동작을 유지한다.
|
||||
- 정렬 상태는 `asc -> desc -> undefined` 순서로 순환한다.
|
||||
- 실제 데이터 컬럼인 leaf 헤더만 정렬 버튼과 `aria-sort`를 표시한다.
|
||||
- 표시 필드와 API 필드가 다르면 `sortKey`로 원천 필드를 지정한다.
|
||||
|
||||
## 3. 구현 파일
|
||||
|
||||
### 3.1 공통 프론트
|
||||
|
||||
- `FE_BC_ROOT/src/ui/table/types.ts`
|
||||
- `FE_BC_ROOT/src/ui/table/index.tsx`
|
||||
- `FE_BC_ROOT/src/ui/table/HeadMakeColSpan.tsx`
|
||||
|
||||
### 3.2 최초 프로토타입 적용
|
||||
|
||||
- `FE_APP_ROOT/app/(map-layout)/map/components/sinkhole/SinkholePopupPage.tsx`
|
||||
- `FE_APP_ROOT/app/(map-layout)/map/components/sinkhole/sinkholeTypes.ts`
|
||||
- `BE_REPO_ROOT/src/main/java/iipbs/il/domain/SinkholeAccidentRepository.java`
|
||||
- `BE_REPO_ROOT/src/main/java/iipbs/sewerage/api/sinkhole/dto/SinkholeListSearchDto.java`
|
||||
- `BE_REPO_ROOT/src/main/java/iipbs/sewerage/service/sinkhole/SinkholeQueryService.java`
|
||||
|
||||
## 4. 프로토타입 적용 결과
|
||||
|
||||
- 사고번호, 발생일자, 주소, 규모 3종, 상세발생원인, 복구상태, 복구완료일자에 서버 정렬을 연결했다.
|
||||
- 사고번호를 정렬 대상에 추가했고, 현재 프론트 코드 기준으로 좌표보정상태 헤더도 `sortable: true`를 사용한다.
|
||||
- 정렬 변경 시 1페이지로 이동하고 선택 행을 초기화한다.
|
||||
- 페이지 이동과 엑셀 다운로드는 마지막 적용 검색조건과 정렬조건을 유지한다.
|
||||
- 백엔드는 `sort=field,direction`을 파싱하고 9개 허용 필드를 enum으로 검증한 뒤 커스텀 Repository에서 고정 SQL 표현식 하나만 정렬에 사용한다.
|
||||
- 사용자 정렬 뒤에는 발생일자·사고번호 내림차순 보조 정렬을 적용한다.
|
||||
- API 응답 DTO와 DB 스키마는 변경하지 않았다.
|
||||
|
||||
## 5. 문서 소유권
|
||||
|
||||
- 지반침하정보 설계·작업카드·통합구현·검증 문서는 현재 HEAD 상태를 유지한다.
|
||||
- 프로토타입 적용의 상세 설계, 구현, 검증 이력은 `공통-UiTable-컬럼정렬` 문서 세트에서 관리한다.
|
||||
- 향후 다른 화면에 적용해도 해당 업무 문서에는 필요한 연동 계약만 기록하고 공통 동작을 중복 설명하지 않는다.
|
||||
|
||||
## 6. 남은 리스크
|
||||
|
||||
- 로그인 가능한 통합 환경에서 실제 asc/desc/default 순환과 페이지 경계 연속성을 확인해야 한다.
|
||||
- 공통 패키지를 사용하는 `fo`, `wr`의 직접 회귀 빌드는 후속 검증 대상이다.
|
||||
|
||||
## 7. 흡수한 임시 기록
|
||||
|
||||
- 지반침하정보 공통 설계, FE 작업카드·통합구현기록·통합검증계획·결과에 임시 추가됐던 정렬 섹션
|
||||
- 지반침하정보 BE 설계·통합구현기록·통합검증계획·결과에 임시 추가됐던 정렬 섹션
|
||||
- 위 원본 문서는 모두 각 저장소의 HEAD 상태로 복구했다.
|
||||
|
||||
## 8. 2026-07-15 사고번호 정렬·헤더 표시 보완
|
||||
|
||||
- 사고번호에 `sortable: true`를 적용하고 컬럼 폭을 `17`에서 `15`로 줄였다.
|
||||
- 발생규모연장과 발생규모깊이 폭을 각각 `8`에서 `9`로 늘렸다.
|
||||
- 사고번호에서 줄인 폭을 발생규모연장과 발생규모깊이에 각각 배분해 기본 팝업 폭에서는 한 줄, 좁은 창에서는 기존 줄바꿈을 유지했다.
|
||||
- 미선택 상태는 `▲/▼`, 오름차순은 `▲`, 내림차순은 `▼`로 표시한다.
|
||||
- 2026-07-15 당시 백엔드 허용 필드와 Repository 고정 CASE 정렬에 `acdntNo`를 추가했으며, 2026-07-16 커스텀 Repository 전환 후에도 같은 키를 유지한다.
|
||||
- 주소 정렬은 전체 주소 문자열에 PostgreSQL `C` collation을 적용해 한글 음절의 가나다 순서를 고정했다.
|
||||
|
||||
## 9. 2026-07-16 지반침하 백엔드 정렬 구현 정리
|
||||
|
||||
- 프론트의 `UiTableSort`, 정렬 가능한 9개 헤더, `sort=field,direction` 요청과 페이지·선택 초기화 동작은 변경하지 않았다.
|
||||
- 백엔드는 `SinkholeAccidentSortField` enum으로 API 키와 고정 SQL 표현식을 통합했다.
|
||||
- `SinkholeAccidentRepositoryImpl` 커스텀 조회가 검증된 정렬식 하나만 동적으로 추가해 기존 정적 SQL의 asc/desc `CASE` 중복을 제거한다.
|
||||
- 검색조건, 좌표상태 후처리, 일반 목록, 다운로드, 기본 정렬, null 배치와 보조 정렬은 기존 계약을 유지한다.
|
||||
|
||||
## 10. 2026-07-16 정보검색 1단계 적용
|
||||
|
||||
- `searchModal.tsx`에서 기본정보 레이어별 원천 필드, 유지관리 사업 Entity 필드, 관련시설 Native Query 필드를 정렬 가능 헤더로 제한했다.
|
||||
- 기본정보는 화면 필드 ID를 WFS 원천 속성명으로 매핑해 `GET /api/v1/fclts`의 Spring `sort` 파라미터로 전달한다.
|
||||
- 하수관로 `유하방향 근거(flowDirectionBasis)`는 현재 정렬 대상에서 제외했다.
|
||||
- 이유는 GeoServer 원천 정렬 컬럼이 아니라 관로 상세 병합 단계의 파생값이라, 기존 목록 조회·전체 결과 정렬·페이징 계약을 병목 없이 만족시키는 백엔드 정식 구현이 추가로 필요하기 때문이다.
|
||||
- 유지관리 사업·상세는 각각 독립된 controlled 정렬 상태를 사용하고 `GET /api/v1/bizInfo`, `GET /api/v1/bizInfo/fclts`에 전달한다.
|
||||
- 새 사업을 선택하면 상세 정렬과 상세 페이지를 초기화하고 첫 상세 요청은 항상 0페이지를 사용한다.
|
||||
- 기본정보·유지관리 목록·상세 엑셀 다운로드에도 현재 정렬조건을 유지한다.
|
||||
- 기본계획 보고서는 시도, 시군구, 승인년도, 구분, 보고서구분, 파일명, 등록일자를 정렬할 수 있다.
|
||||
- 파일명·등록일자는 현재 정렬 대상에서 제외했다.
|
||||
- 이유는 기본 분류 트리 응답에 없는 파일 메타데이터를 분류별 추가 호출로 보강해야 해서, 기존 목록 정렬 계약을 유지하면서도 병목 없이 동작하려면 `dc-service`의 대표 파일 메타데이터 일괄 제공 또는 서버 정렬 지원이 필요하기 때문이다.
|
||||
- 코드 표시명 매핑 필드와 GeoServer 병합 전용 후처리 필드는 원천 필드가 안정적으로 연결된 경우에만 정렬 대상으로 노출한다.
|
||||
|
||||
## 11. 2026-07-16 현재 코드 기준 적용 범위 정리
|
||||
|
||||
- 공통 `UiTable` 정렬 계약은 `packages/common/business_components/src/ui/table`에 반영되어 `sortable`, `sortKey`, controlled `sort`, `onSortChange`, `aria-sort`를 공통으로 제공한다.
|
||||
- 지도형 팝업 기준 구현 완료 화면은 지반침하정보, 정보검색 기본정보, 정보검색 유지관리 사업 목록, 정보검색 유지관리 상세, 정보검색 기본계획 보고서, 침입수 분석결과, RDII 분석결과, 관로분석 상세다.
|
||||
- 관로분석 요약은 `YEAR`, `MATERIAL` 유형에서 클라이언트 정렬이 동작하고, 다운로드는 현재 정렬된 요약 결과를 기준으로 유지한다.
|
||||
- 침입수 분석결과와 RDII 분석결과는 `rowNumber` 요청을 각각 `inftwtAnlsSn desc`, `rainInfwtAnlsSn desc` 기반의 안정 정렬로 변환해 화면 번호와 서버 정렬을 맞춘다.
|
||||
- 정보검색 기본정보·유지관리 목록·유지관리 상세는 정렬 변경 시 1페이지로 초기화하고, 엑셀 다운로드에도 마지막 정렬조건을 유지한다.
|
||||
- 보고서 탭은 전체 분류 트리를 먼저 정렬한 뒤 페이지를 자르고, 현재 페이지에 필요한 파일 메타데이터만 보강한다.
|
||||
|
||||
## 12. 2026-07-16 미구현·후속 적용 대상
|
||||
|
||||
- `app/(map-layout)/map/components/agedPipe/AgedPipePopupPage.tsx`: `UiTable`는 사용하지만 정렬 상태, 헤더 `sortable`, API 정렬 파라미터, CSV 다운로드 정렬 유지가 없다.
|
||||
- `src/features/windowPopup/ui/ReportFilesPopupContent.tsx`: 파일 목록 팝업은 페이지네이션만 있고 `fileTitle`, `regDt` 기준 정렬이 없다.
|
||||
- `app/(map-layout)/map/components/pipeAnalysis/PipeAnalysisPopupPage.tsx`: `YEAR_MATERIAL` 분기는 커스텀 `Table`을 직접 렌더링해 헤더 클릭 정렬이 아직 없다.
|
||||
- `app/(with-base-layout)/ii/result/page.tsx`, `app/(with-base-layout)/ii/rdiiResult/page.tsx`: 구형 I/I 결과 페이지는 기본 `sort=...desc`만 사용하고 사용자 헤더 정렬은 없다.
|
||||
- `app/(with-base-layout)/oper/floodPre/page.tsx`, `app/(with-base-layout)/oper/floodPre/tableModal.tsx`, `app/(with-base-layout)/oper/oldPp/page.tsx`, `app/(with-base-layout)/oper/oldPp/tableModal.tsx`, `app/(map-layout)/map/components/popupComponent/Ssc.tsx`: `UiTable` 사용처이지만 현재 정렬 연결 흔적이 없다.
|
||||
- `app/html/**` 하위 프로토타입 HTML은 서비스 코드 정렬 적용 범위에서 제외했다.
|
||||
|
||||
## 13. 2026-07-20 운영정보·노후관로 적용
|
||||
|
||||
- 운영정보 `도시침수 예방사업`, `노후하수관로 정비사업`의 사업정보 등록현황은 `일련번호`, `파일명`, `년도`, `분기`, `등록일자`를 기존 Spring `Pageable sort`로 전체 데이터 정렬한다.
|
||||
- 두 사업 목록의 `일련번호`는 화면 생성값 대신 각 사업 PK를 `sortKey`로 사용하고, 내림차순일 때 표시 번호도 전체 건수부터 감소하도록 맞췄다.
|
||||
- `등록자`는 페이지 조회 후 사용자 서비스에서 이름을 보강하는 파생값이므로 현재 정렬 대상에서 제외했다. UUID 정렬이나 현재 페이지 클라이언트 정렬로 잘못된 전체 순서를 만들지 않는다.
|
||||
- 도시침수 예방사업의 엑셀 상세 2개 탭과 노후하수관로 정비사업의 엑셀 상세 6개 탭은 파일을 한 번 파싱한 전체 행을 브라우저에서 정렬한다.
|
||||
- 엑셀 상세 정렬은 추가 API 호출 없이 숫자와 한글·문자열을 구분하고, 빈 값은 방향과 무관하게 뒤에 유지하며, 동일값은 원본 순서를 유지한다.
|
||||
- 노후하수관로 정비사업의 다단 헤더 2개 탭도 그룹 헤더가 아닌 실제 leaf 컬럼에 공통 `▲/▼` 정렬 UI를 연결했다.
|
||||
- 노후관로 목록은 `NO`, `시설관리번호`, `설치일자`, `관경`, `연장`, `용도`, `재질`, `경과년도` 8개 컬럼을 서버 WFS 정렬에 연결했다.
|
||||
- 노후관로 `경과년도` 오름차순은 설치일자 내림차순, 경과년도 내림차순은 설치일자 오름차순으로 변환해 계산값 의미와 서버 정렬 방향을 맞췄다.
|
||||
- 운영정보 목록의 중복 최초 조회와 검색 시 중복 호출을 제거해 정렬 추가로 인한 불필요한 네트워크 요청이 발생하지 않도록 했다.
|
||||
- 구형 I/I·RDII 분석결과 화면과 SSC는 사용자 결정에 따라 이번 완료 범위에서 제외한다.
|
||||
|
||||
## 14. 2026-07-20 커밋 전 최종 점검
|
||||
|
||||
- 기본계획 보고서는 정렬·페이징 후 현재 페이지 10건의 파일 메타데이터만 조회하고, 전체 분류 메타데이터 백그라운드 예열은 제거했다.
|
||||
- 침입수·RDII `분석자`는 페이지 조회 후 외부 사용자 서비스에서 이름을 보강하므로 UUID 기준의 잘못된 전체 정렬을 노출하지 않는다.
|
||||
- 외부 사용자명 정렬은 운영정보 `등록자`와 동일하게 서버 조인 또는 일괄 이름 정렬 지원이 준비될 때 재검토한다.
|
||||
|
||||
## 15. 2026-07-20 노후관로 정렬 조회 성능 분리
|
||||
|
||||
- 노후관로와 정보검색 하수관로가 같은 `GET /api/v1/fclts`를 사용하더라도 필요한 후처리는 서로 다르므로 유하방향 근거 보강을 선택 기능으로 분리했다.
|
||||
- 정보검색 기본정보의 하수관로 목록만 `includeFlowDirectionBasis=true`를 전달해 현재 페이지의 유하방향 근거를 배치 보강한다.
|
||||
- 노후관로 목록은 해당 파라미터를 전달하지 않아 페이징·CQL 필터·WFS 서버 정렬과 기존 시설 정보 병합만 수행한다.
|
||||
- 정렬 정확도를 위해 전체 필터 결과의 서버 정렬은 유지하며, 현재 페이지 배열만 정렬하는 임시 우회는 사용하지 않는다.
|
||||
@@ -1,71 +1,120 @@
|
||||
import { TableHead, TableRow } from "@common/components/ui";
|
||||
import type { UiHeadProps, UiTableSort } from "./types";
|
||||
|
||||
interface TableUpperProps {
|
||||
id: string;
|
||||
title: string;
|
||||
upName?: string;
|
||||
upSequnce?: number;
|
||||
row? : number;
|
||||
rowSpan?: number;
|
||||
colSpan?: number;
|
||||
visible?:boolean;
|
||||
widthPercent?: number;
|
||||
}
|
||||
type HeadMakeColSpanProps = {
|
||||
upHeadList: UiHeadProps[];
|
||||
sort?: UiTableSort;
|
||||
onSortChange?: (head: UiHeadProps) => void;
|
||||
};
|
||||
|
||||
const HeadMakeColSpan = ({ upHeadList }: { upHeadList: TableUpperProps[] }) => {
|
||||
const maxRow = upHeadList?.reduce((max, head) => Math.max(max, head.row || 0), 0);
|
||||
//upHeadList.forEach((item) => item.visible ?item.visible:true)
|
||||
const HeadMakeColSpan = ({
|
||||
upHeadList,
|
||||
sort,
|
||||
onSortChange,
|
||||
}: HeadMakeColSpanProps) => {
|
||||
const maxRow = upHeadList?.reduce(
|
||||
(max, head) => Math.max(max, head.row || 0),
|
||||
0,
|
||||
);
|
||||
|
||||
/** 마지막 헤더 행까지 차지하는 실제 데이터 컬럼인지 판별한다. */
|
||||
const isLeafHead = (head: UiHeadProps) =>
|
||||
(head.row ?? 0) + (head.rowSpan ?? 1) - 1 >= maxRow;
|
||||
|
||||
/** 현재 정렬 상태에 맞는 헤더 버튼과 방향 아이콘을 만든다. */
|
||||
const renderHeadContent = (head: UiHeadProps) => {
|
||||
const key = head.sortKey ?? head.id;
|
||||
const direction = sort?.key === key ? sort.direction : undefined;
|
||||
const sortable = Boolean(head.sortable && isLeafHead(head) && onSortChange);
|
||||
|
||||
if (!sortable) return head.title;
|
||||
|
||||
if(maxRow == 0){
|
||||
return (
|
||||
<>
|
||||
<TableRow className="bg-gray-50">
|
||||
{upHeadList
|
||||
.filter((item) => item.visible != false)
|
||||
.map((head: TableUpperProps) => {
|
||||
return (
|
||||
<TableHead
|
||||
key={head.id}
|
||||
colSpan={head.colSpan || 1}
|
||||
rowSpan={head.rowSpan || 1}
|
||||
style={{ whiteSpace: 'pre-line' }}
|
||||
className=""
|
||||
>
|
||||
{head.title}
|
||||
</TableHead>
|
||||
);
|
||||
})}
|
||||
</TableRow>
|
||||
</>
|
||||
<button
|
||||
type="button"
|
||||
className="inline-flex w-full items-center justify-center gap-1.5"
|
||||
aria-label={`${head.title} 정렬`}
|
||||
onClick={() => onSortChange?.(head)}
|
||||
>
|
||||
<span>{head.title}</span>
|
||||
{direction === "asc" ? (
|
||||
<span
|
||||
className="inline-flex min-w-2 justify-center text-[8px] leading-none text-[#1d4ed8]"
|
||||
aria-hidden="true"
|
||||
>
|
||||
▲
|
||||
</span>
|
||||
) : direction === "desc" ? (
|
||||
<span
|
||||
className="inline-flex min-w-2 justify-center text-[8px] leading-none text-[#1d4ed8]"
|
||||
aria-hidden="true"
|
||||
>
|
||||
▼
|
||||
</span>
|
||||
) : (
|
||||
<span
|
||||
className="inline-flex flex-col items-center gap-px text-[6px] leading-none text-[#475569]"
|
||||
aria-hidden="true"
|
||||
>
|
||||
<span>▲</span>
|
||||
<span>▼</span>
|
||||
</span>
|
||||
)}
|
||||
</button>
|
||||
);
|
||||
}else{
|
||||
};
|
||||
|
||||
/** 공통 속성과 접근성 정렬 상태를 포함한 헤더 셀을 렌더링한다. */
|
||||
const renderHead = (head: UiHeadProps, key: string) => {
|
||||
const sortKey = head.sortKey ?? head.id;
|
||||
const direction = sort?.key === sortKey ? sort.direction : undefined;
|
||||
const sortable = Boolean(head.sortable && isLeafHead(head) && onSortChange);
|
||||
|
||||
return (
|
||||
<>
|
||||
{Array.from({ length: maxRow + 1 }, (_, rowIndex) => (
|
||||
<TableRow key={rowIndex+ " headerRow"} className="bg-gray-50">
|
||||
{upHeadList
|
||||
.filter((item) => item.visible != false)
|
||||
.map((head: TableUpperProps, idx) => {
|
||||
if (head.row === rowIndex) {
|
||||
return (
|
||||
<TableHead
|
||||
key={head.id?head.id:rowIndex+"_"+idx}
|
||||
colSpan={head.colSpan || 1}
|
||||
rowSpan={head.rowSpan || 1}
|
||||
style={{ whiteSpace: 'pre-line' }}
|
||||
className="bg-gray-50"
|
||||
>
|
||||
{head.title}
|
||||
</TableHead>
|
||||
);
|
||||
}
|
||||
})}
|
||||
</TableRow>
|
||||
))}
|
||||
</>
|
||||
<TableHead
|
||||
key={key}
|
||||
colSpan={head.colSpan || 1}
|
||||
rowSpan={head.rowSpan || 1}
|
||||
style={{ whiteSpace: "pre-line" }}
|
||||
className="bg-gray-50"
|
||||
aria-sort={
|
||||
sortable
|
||||
? direction === "asc"
|
||||
? "ascending"
|
||||
: direction === "desc"
|
||||
? "descending"
|
||||
: "none"
|
||||
: undefined
|
||||
}
|
||||
>
|
||||
{renderHeadContent(head)}
|
||||
</TableHead>
|
||||
);
|
||||
};
|
||||
|
||||
if (maxRow === 0) {
|
||||
return (
|
||||
<TableRow className="bg-gray-50">
|
||||
{upHeadList
|
||||
.filter((item) => item.visible !== false)
|
||||
.map((head) => renderHead(head, head.id))}
|
||||
</TableRow>
|
||||
);
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
{Array.from({ length: maxRow + 1 }, (_, rowIndex) => (
|
||||
<TableRow key={`${rowIndex} headerRow`} className="bg-gray-50">
|
||||
{upHeadList
|
||||
.filter((item) => item.visible !== false && item.row === rowIndex)
|
||||
.map((head, index) =>
|
||||
renderHead(head, head.id || `${rowIndex}_${index}`),
|
||||
)}
|
||||
</TableRow>
|
||||
))}
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
export { HeadMakeColSpan };
|
||||
|
||||
@@ -12,6 +12,10 @@ import { useEffect, useState, useRef } from "react";
|
||||
import { HeadMakeColSpan } from "./HeadMakeColSpan";
|
||||
import UiPageNations from "./uiPageNations";
|
||||
import { Check } from "lucide-react";
|
||||
import type { UiHeadProps, UiTableSort } from "./types";
|
||||
|
||||
export type { UiHeadProps, UiTableSort, UiTableSortDirection } from "./types";
|
||||
|
||||
export type ChkClass = {
|
||||
chk: boolean;
|
||||
readonly: boolean;
|
||||
@@ -38,17 +42,8 @@ type UiTableProps<T extends object> = {
|
||||
widthFixed?: boolean;
|
||||
isHideCount?: boolean; // 총 건수 default false
|
||||
clickableHeadIds?: string[];
|
||||
};
|
||||
|
||||
type UiHeadProps = {
|
||||
id: string;
|
||||
title: string;
|
||||
align?: string;
|
||||
visible?: boolean;
|
||||
row?: number;
|
||||
widthPercent?: number;
|
||||
rowSpan?: number;
|
||||
colSpan?: number;
|
||||
sort?: UiTableSort;
|
||||
onSortChange?: (sort: UiTableSort | undefined) => void;
|
||||
};
|
||||
|
||||
export function UiTable<T extends object = Record<string, unknown>>({
|
||||
@@ -72,6 +67,8 @@ export function UiTable<T extends object = Record<string, unknown>>({
|
||||
widthFixed = false,
|
||||
isHideCount = false,
|
||||
clickableHeadIds,
|
||||
sort,
|
||||
onSortChange,
|
||||
}: UiTableProps<T>) {
|
||||
const [cur, setCur] = useState<number>(curPage);
|
||||
const [idx, setIdx] = useState<number>(index);
|
||||
@@ -124,6 +121,22 @@ export function UiTable<T extends object = Record<string, unknown>>({
|
||||
setIdx(index);
|
||||
};
|
||||
|
||||
/** 정렬 가능한 헤더를 asc, desc, 기본 순서로 순환시킨다. */
|
||||
const handleSortChange = (head: UiHeadProps) => {
|
||||
if (!head.sortable || !onSortChange) return;
|
||||
|
||||
const key = head.sortKey ?? head.id;
|
||||
if (sort?.key !== key) {
|
||||
onSortChange({ key, direction: "asc" });
|
||||
return;
|
||||
}
|
||||
if (sort.direction === "asc") {
|
||||
onSortChange({ key, direction: "desc" });
|
||||
return;
|
||||
}
|
||||
onSortChange(undefined);
|
||||
};
|
||||
|
||||
const getLeafCols = (list: UiHeadProps[]) => {
|
||||
const maxRow = list?.reduce((max, head) => Math.max(max, head.row || 0), 0);
|
||||
return list
|
||||
@@ -169,7 +182,15 @@ export function UiTable<T extends object = Record<string, unknown>>({
|
||||
""
|
||||
)}
|
||||
<TableHeader ref={headerRef} className="font-bold">
|
||||
{children ? children : <HeadMakeColSpan upHeadList={headlist} />}
|
||||
{children ? (
|
||||
children
|
||||
) : (
|
||||
<HeadMakeColSpan
|
||||
upHeadList={headlist}
|
||||
sort={sort}
|
||||
onSortChange={handleSortChange}
|
||||
/>
|
||||
)}
|
||||
</TableHeader>
|
||||
<TableBody className="text-center">
|
||||
{tableData.length > 0 ? (
|
||||
|
||||
@@ -0,0 +1,19 @@
|
||||
export type UiTableSortDirection = "asc" | "desc";
|
||||
|
||||
export type UiTableSort = {
|
||||
key: string;
|
||||
direction: UiTableSortDirection;
|
||||
};
|
||||
|
||||
export type UiHeadProps = {
|
||||
id: string;
|
||||
title: string;
|
||||
align?: string;
|
||||
visible?: boolean;
|
||||
row?: number;
|
||||
widthPercent?: number;
|
||||
rowSpan?: number;
|
||||
colSpan?: number;
|
||||
sortable?: boolean;
|
||||
sortKey?: string;
|
||||
};
|
||||
Reference in New Issue
Block a user