노후관로 조회 기능 보완
This commit is contained in:
@@ -18,11 +18,13 @@ import {
|
||||
AGED_PIPE_LIST_HEADLIST,
|
||||
AGED_PIPE_PAGE_SIZE,
|
||||
buildAgedPipeCqlFilters,
|
||||
getAgedPipeManagementName,
|
||||
normalizeAgedPipeFilters,
|
||||
toDisplayPipeRow,
|
||||
validateElapsedYears,
|
||||
type AgedPipeFilters,
|
||||
type AgedPipeRow,
|
||||
type AgedPipeManagementAgency,
|
||||
} from "./agedPipeUtils";
|
||||
import { downloadAgedPipeExcel } from "./agedPipeExport";
|
||||
import MapPopupDataTable from "@/features/windowPopup/ui/MapPopupDataTable";
|
||||
@@ -53,6 +55,15 @@ export default function AgedPipePopupPage() {
|
||||
const [appliedFilters, setAppliedFilters] =
|
||||
useState<AgedPipeFilters>(initialFilters);
|
||||
const [rows, setRows] = useState<AgedPipeRow[]>([]);
|
||||
// 관리기관명 조회가 늦게 완료되어도 기존 목록을 재조회하지 않고 표시만 갱신한다.
|
||||
const [managementAgencies, setManagementAgencies] = useState<AgedPipeManagementAgency[]>([]);
|
||||
const displayRows = useMemo(
|
||||
() => rows.map((row) => ({
|
||||
...row,
|
||||
mngInstNm: getAgedPipeManagementName(row, managementAgencies),
|
||||
})),
|
||||
[rows, managementAgencies],
|
||||
);
|
||||
const [total, setTotal] = useState(0);
|
||||
const [currentPage, setCurrentPage] = useState(1);
|
||||
const [isLoading, setIsLoading] = useState(false);
|
||||
@@ -73,6 +84,19 @@ export default function AgedPipePopupPage() {
|
||||
if (sgg.length === 0) fetchSgg();
|
||||
}, [ctpv.length, fetchCtpv, fetchSgg, sgg.length]);
|
||||
|
||||
useEffect(() => {
|
||||
let active = true;
|
||||
// 기존 GIS 관리 화면과 같은 기관 목록을 사용하고, 실패 시 관로 조회는 유지한다.
|
||||
getData("v1/fcltPpl/mngInsts")
|
||||
.then((response) => {
|
||||
if (active) setManagementAgencies(Array.isArray(response) ? response : []);
|
||||
})
|
||||
.catch(() => {
|
||||
if (active) alert("관리기관명을 불러오지 못해 관리기관 코드로 표시합니다.");
|
||||
});
|
||||
return () => { active = false; };
|
||||
}, [alert]);
|
||||
|
||||
const fetchRows = useCallback(
|
||||
async (page: number, nextFilters: AgedPipeFilters, size: number) => {
|
||||
const normalized = normalizeAgedPipeFilters(nextFilters);
|
||||
@@ -247,31 +271,6 @@ export default function AgedPipePopupPage() {
|
||||
) : null}
|
||||
|
||||
<PopupSearchPanel contentClassName="flex flex-wrap items-end gap-x-5 gap-y-3">
|
||||
<label className="flex items-center gap-2">
|
||||
<span className="whitespace-nowrap text-[1.3rem] text-label">
|
||||
경과년도
|
||||
</span>
|
||||
<Input
|
||||
className="w-[12rem]"
|
||||
type="text"
|
||||
inputMode="numeric"
|
||||
pattern="[0-9]*"
|
||||
autoComplete="off"
|
||||
value={String(filters.elapsedYears ?? "")}
|
||||
onChange={(event) => {
|
||||
const digits = event.target.value.replace(/[^0-9]/g, "");
|
||||
setFilters({
|
||||
...filters,
|
||||
elapsedYears:
|
||||
digits === "" ? ("" as unknown as number) : Number(digits),
|
||||
});
|
||||
}}
|
||||
onKeyDown={(event) => {
|
||||
if (event.key === "Enter") void handleSearch(1);
|
||||
}}
|
||||
/>
|
||||
</label>
|
||||
|
||||
<label className="flex items-center gap-2">
|
||||
<span className="whitespace-nowrap text-[1.3rem] text-label">
|
||||
시도
|
||||
@@ -316,6 +315,31 @@ export default function AgedPipePopupPage() {
|
||||
</select>
|
||||
</label>
|
||||
|
||||
<label className="flex items-center gap-2">
|
||||
<span className="whitespace-nowrap text-[1.3rem] text-label">
|
||||
경과년도
|
||||
</span>
|
||||
<Input
|
||||
className="w-[12rem]"
|
||||
type="text"
|
||||
inputMode="numeric"
|
||||
pattern="[0-9]*"
|
||||
autoComplete="off"
|
||||
value={String(filters.elapsedYears ?? "")}
|
||||
onChange={(event) => {
|
||||
const digits = event.target.value.replace(/[^0-9]/g, "");
|
||||
setFilters({
|
||||
...filters,
|
||||
elapsedYears:
|
||||
digits === "" ? ("" as unknown as number) : Number(digits),
|
||||
});
|
||||
}}
|
||||
onKeyDown={(event) => {
|
||||
if (event.key === "Enter") void handleSearch(1);
|
||||
}}
|
||||
/>
|
||||
</label>
|
||||
|
||||
<div className="ml-auto flex items-center gap-2">
|
||||
<Button
|
||||
type="button"
|
||||
@@ -341,7 +365,7 @@ export default function AgedPipePopupPage() {
|
||||
</PopupSearchPanel>
|
||||
|
||||
<MapPopupDataTable
|
||||
rows={rows as Array<AgedPipeRow & Record<string, unknown>>}
|
||||
rows={displayRows}
|
||||
headlist={AGED_PIPE_LIST_HEADLIST}
|
||||
title="노후관로 목록"
|
||||
total={total}
|
||||
|
||||
@@ -56,15 +56,34 @@ const normalizeRegionFilterCode = (value: unknown, prefixLength: number) => {
|
||||
|
||||
export const AGED_PIPE_LIST_HEADLIST = [
|
||||
{ id: "rowNumber", title: "순번", widthPercent: 6 },
|
||||
{ id: "fcltMngNo", title: "시설관리번호", widthPercent: 18 },
|
||||
{ id: "fcltMngNo", title: "시설관리번호", widthPercent: 16 },
|
||||
{ id: "mngInstNm", title: "관리기관", widthPercent: 14 },
|
||||
{ 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: "pdmtVl", title: "관경(mm)", widthPercent: 9 },
|
||||
{ id: "prlgVl", title: "연장(m)", widthPercent: 9 },
|
||||
{ id: "usgCd", title: "용도", widthPercent: 10 },
|
||||
{ id: "mqltCd", title: "재질", widthPercent: 16 },
|
||||
];
|
||||
|
||||
/** 기존 관리기관 조회 API의 코드와 기관명만 목록 표시용으로 사용한다. */
|
||||
export type AgedPipeManagementAgency = {
|
||||
mngInstCd: string;
|
||||
lwrInstNm?: string | null;
|
||||
};
|
||||
|
||||
/** 명칭이 없는 기관은 원본 코드를 표시하며, 행의 관리기관 코드는 변경하지 않는다. */
|
||||
export const getAgedPipeManagementName = (
|
||||
row: PipeSource,
|
||||
agencies: AgedPipeManagementAgency[],
|
||||
): string => {
|
||||
const directName = String(row.mngInstNm ?? row.mng_inst_nm ?? "").trim();
|
||||
if (directName && directName !== "-") return directName;
|
||||
const code = String(row.mngInstCd ?? row.mng_inst_cd ?? "").trim();
|
||||
const agency = agencies.find((item) => String(item.mngInstCd).trim() === code);
|
||||
return String(agency?.lwrInstNm ?? "").trim() || code || "-";
|
||||
};
|
||||
|
||||
export const AGED_PIPE_EXCEL_FIELDS = [
|
||||
"ftreSmbolCd",
|
||||
"fcltMngNo",
|
||||
|
||||
@@ -0,0 +1,55 @@
|
||||
# 노후관로 목록 조회조건 및 표시항목 변경
|
||||
|
||||
## 목적과 근거
|
||||
|
||||
- 2026-09-23 사용자 요청: 조회조건을 시도, 시군구, 경과년도 순으로 변경하고 관리기관, 설치일자, 경과년도를 연속 배치한다.
|
||||
- 원본 요구사항정의서 `3. (기능)요구사항정의 및 추적표` 87~90행, SFR-011-001~004 확인. 목록과 경과년도 조회는 88행 SFR-011-002와 연결된다.
|
||||
- 원본 화면설계서 슬라이드 8, 화면 ID `SN_MN010102`, 노후관로관리 확인. 기존 필터는 경과년도/시도/시군구이며 관리기관 컬럼은 없다. 이번 명시 요청을 우선 적용했다.
|
||||
- 원본 요구사항의 기본값 20년과 화면설계/현재 코드의 30년 차이는 기존 사항이다. 이번 요청은 배치와 표시항목 변경이므로 기본값과 계산 기준은 수정하지 않았다.
|
||||
|
||||
## 수정 파일과 이유
|
||||
|
||||
- `app/(map-layout)/map/components/agedPipe/AgedPipePopupPage.tsx`: 검색 JSX를 시도/시군구/경과년도 순으로 이동했다. 입력 이벤트, 조회조건, 초기화 및 페이지 처리 함수는 유지했다. 기존 `v1/fcltPpl/mngInsts` API를 한 번 조회해 목록 표시용 기관명을 연결한다. 기관 목록 수신 후 표시만 갱신하며 관로 목록을 다시 요청하지 않는다.
|
||||
- `app/(map-layout)/map/components/agedPipe/agedPipeUtils.ts`: 헤더에 `mngInstNm`을 추가하고 설치일자 뒤로 경과년도를 옮겼다. 9개 컬럼 폭 합계를 100%로 조정했다. 원본 기관명 우선, 코드에 맞는 `lwrInstNm`, 원본 코드, `-` 순으로 표시한다. 행 식별용 관리기관 코드는 변경하지 않는다.
|
||||
- `scripts/aged-pipe-list.test.cjs`: 컬럼 순서/폭, 기관명 표시/대체값, 검색조건 순서 회귀 테스트 3개를 추가했다.
|
||||
|
||||
```text
|
||||
조회조건: 시도 → 시군구 → 경과년도
|
||||
목록: 순번 → 시설관리번호 → 관리기관 → 설치일자 → 경과년도
|
||||
→ 관경(mm) → 연장(m) → 용도 → 재질
|
||||
```
|
||||
|
||||
## 영향 범위
|
||||
|
||||
- SN 노후관로 목록 팝업만 변경했다. 다른 앱과 공유 컴포넌트, 인증, 환경, 의존성, 배포 설정은 수정하지 않았다.
|
||||
- 기존 조회 API를 재사용하며 백엔드와 DB 변경은 없다. 관리기관 조회 실패 시 안내 후 원본 코드를 표시한다.
|
||||
- 엑셀은 기존 전체 속성 다운로드를 유지한다. 지도 행 선택의 식별값과 경과년도 계산도 유지한다.
|
||||
|
||||
## 검증
|
||||
|
||||
- `node --test scripts/aged-pipe-list.test.cjs`: 3개 통과.
|
||||
- `pnpm exec tsc --noEmit --incremental false --pretty false`: 종료 코드 0, 오류 없음. 개발 서버가 실행 중이므로 전체 빌드는 수행하지 않았다.
|
||||
- `git diff --check`: 통과(기존 Git 설정의 LF/CRLF 안내만 출력).
|
||||
- 실제 로컬 브라우저 `http://localhost:3002/popup/agedPipePopup`에서 확인:
|
||||
- 검색조건 시도/시군구/경과년도, 9개 목록 컬럼 순서 정상.
|
||||
- 서울특별시/대전광역시 관리기관명, 설치일자, 경과년도 실데이터 표시 확인.
|
||||
- 대전광역시/40년 조회 및 시군구 옵션 연계 확인.
|
||||
- 유성구 선택 후 조회된 데이터 없음 상태 확인.
|
||||
- 초기화 시 전체/전체/30년 복귀 및 목록 재조회 확인.
|
||||
- 2페이지 이동 시 순번 11~20 및 관리기관명 유지 확인.
|
||||
- 사용자 개발 서버 PID 43172(3002)는 종료·재시작하지 않았다. 별도 서버 및 임시 파일을 생성하지 않았다. 원시 캡처/로그는 파일로 저장하지 않았다. 정식 회귀 테스트와 이 결과 문서는 유지한다.
|
||||
|
||||
## 롤백 및 남은 사항
|
||||
|
||||
- 두 구현 파일에서 이번 diff만 역적용하면 이전 표시로 복구할 수 있다. 후속 변경이 있으면 파일 전체를 되돌리지 않는다.
|
||||
- 실데이터 중 설치일자 `19000000`이 경과년도 126으로 표시되는 기존 날짜 검증 문제가 관찰됐다. 이번 배치 변경에서는 원본 데이터와 날짜 판정 규칙을 임의 수정하지 않았다.
|
||||
- 엑셀 파일 생성 및 부모 지도창으로 위치 이동은 이번 변경에서 재검증하지 않았다.
|
||||
|
||||
## 후속 변경: 목록 팝업 중앙 배치
|
||||
|
||||
- 사용자 요청에 따라 `src/features/windowPopup/model/popupDefinitions.ts`의 `agedPipePopup`에서 `placement`, `alignTop`, `offsetLeft`, `offsetTop` 예외를 제거했다. 공통 열기 함수의 중앙 배치 기본값을 적용한다. 크기 1160×820과 다른 팝업 설정은 유지했다.
|
||||
- 기존 화면설계서 슬라이드 8의 목록 기능을 유지하며, 창 위치는 이번 명시 요청을 적용했다.
|
||||
- `scripts/aged-pipe-list.test.cjs`에 중앙 배치/크기 유지 검증을 추가했다. 전체 4개 테스트 및 `git diff --check` 통과.
|
||||
- 로컬 브라우저에서 로그인 및 지도 진입을 확인했으나, 확인 시점의 지도에는 레이어 목록보기 버튼이 노출되지 않아 실제 새 창 위치는 미검증이다. 코드 기본값과 설정에 대한 자동 검증으로 구분해 기록한다.
|
||||
- 이미 열린 이름 지정 팝업은 브라우저가 위치를 유지할 수 있으므로 창을 닫고 다시 열어 확인한다. 배포 및 사용자 서버 재시작은 수행하지 않았다.
|
||||
- 롤백은 해당 팝업의 이전 위치 설정 네 항목만 복구한다. 임시 파일 생성·삭제 없음.
|
||||
@@ -0,0 +1,64 @@
|
||||
const assert = require('node:assert/strict');
|
||||
const fs = require('node:fs');
|
||||
const path = require('node:path');
|
||||
const vm = require('node:vm');
|
||||
const test = require('node:test');
|
||||
const ts = require('typescript');
|
||||
|
||||
const directory = path.resolve(__dirname, '../app/(map-layout)/map/components/agedPipe');
|
||||
const source = fs.readFileSync(path.join(directory, 'agedPipeUtils.ts'), 'utf8');
|
||||
const compiled = ts.transpileModule(source, {
|
||||
compilerOptions: { module: ts.ModuleKind.CommonJS },
|
||||
}).outputText;
|
||||
const exportsValue = {};
|
||||
vm.runInNewContext(compiled, {
|
||||
exports: exportsValue,
|
||||
require: (id) => {
|
||||
if (id.endsWith('/formatDisplayValue')) return { formatDisplayData: (row) => row };
|
||||
if (id === '../common/table') return { MS_SWAGE_PPL_CODES: [], MS_SWAGE_PPL_USE_CODES: [] };
|
||||
throw new Error(`Unexpected import: ${id}`);
|
||||
},
|
||||
});
|
||||
const { AGED_PIPE_LIST_HEADLIST: headers, getAgedPipeManagementName: name } = exportsValue;
|
||||
|
||||
test('list preserves existing columns and groups agency, installation date and age', () => {
|
||||
assert.deepEqual(Array.from(headers, (column) => column.id), [
|
||||
'rowNumber', 'fcltMngNo', 'mngInstNm', 'instlYmd', 'elapsedYears',
|
||||
'pdmtVl', 'prlgVl', 'usgCd', 'mqltCd',
|
||||
]);
|
||||
assert.equal(headers.reduce((total, column) => total + column.widthPercent, 0), 100);
|
||||
});
|
||||
|
||||
test('agency name uses existing API names without changing source codes', () => {
|
||||
const agencies = [{ mngInstCd: '123', lwrInstNm: '검증기관' }];
|
||||
const row = { mngInstCd: '123' };
|
||||
assert.equal(name(row, agencies), '검증기관');
|
||||
assert.equal(row.mngInstCd, '123');
|
||||
assert.equal(name({ mng_inst_cd: ' 123 ' }, agencies), '검증기관');
|
||||
assert.equal(name({ mngInstNm: '원본기관', mngInstCd: '123' }, agencies), '원본기관');
|
||||
assert.equal(name({ mngInstNm: '-', mngInstCd: '123' }, agencies), '검증기관');
|
||||
assert.equal(name({ mngInstCd: '999' }, agencies), '999');
|
||||
assert.equal(name({}, agencies), '-');
|
||||
});
|
||||
|
||||
test('search controls are ordered province, municipality, elapsed years', () => {
|
||||
const page = fs.readFileSync(path.join(directory, 'AgedPipePopupPage.tsx'), 'utf8');
|
||||
const panel = page.slice(page.indexOf('<PopupSearchPanel'), page.indexOf('</PopupSearchPanel>'));
|
||||
const labels = Array.from(panel.matchAll(/<span[^>]*>\s*([^<]+?)\s*<\/span>/g), (match) => match[1].trim());
|
||||
assert.deepEqual(labels, ['시도', '시군구', '경과년도']);
|
||||
});
|
||||
|
||||
test('aged pipe popup uses the common centered placement without changing its size', () => {
|
||||
const definitions = fs.readFileSync(path.resolve(__dirname, '../src/features/windowPopup/model/popupDefinitions.ts'), 'utf8');
|
||||
const exported = {};
|
||||
vm.runInNewContext(ts.transpileModule(definitions, {
|
||||
compilerOptions: { module: ts.ModuleKind.CommonJS },
|
||||
}).outputText, { exports: exported });
|
||||
const popup = exported.windowPopupDefinitions.agedPipePopup;
|
||||
assert.equal(popup.width, 1160);
|
||||
assert.equal(popup.height, 820);
|
||||
assert.equal(popup.placement ?? 'center', 'center');
|
||||
assert.equal(popup.alignTop ?? false, false);
|
||||
assert.equal(popup.offsetLeft ?? 0, 0);
|
||||
assert.equal(popup.offsetTop ?? 0, 0);
|
||||
});
|
||||
@@ -140,10 +140,7 @@ export const windowPopupDefinitions = {
|
||||
title: "노후 하수관로 목록",
|
||||
width: 1160,
|
||||
height: 820,
|
||||
placement: "browser-left",
|
||||
alignTop: true,
|
||||
offsetLeft: 0,
|
||||
offsetTop: 70,
|
||||
// 다른 목록 팝업과 동일한 공통 중앙 배치를 사용한다.
|
||||
},
|
||||
sinkholePopup: {
|
||||
key: "sinkholePopup",
|
||||
|
||||
Reference in New Issue
Block a user