조사결과 관로·맨홀 지도 레이어와 보고서 조회 연동

This commit is contained in:
kiminchan
2026-08-03 10:11:48 +09:00
parent bcf5d3caec
commit c9d85a8ce4
22 changed files with 1538 additions and 56 deletions
@@ -9,6 +9,7 @@ import type {
ExmnImportValidationResult,
ExmnJobType,
ExmnPage,
ExmnPipeReportAvailability,
ExmnSectionSummaryRow,
ExmnTemplateResult,
PplFacility,
@@ -285,7 +286,25 @@ export const downloadExmnReportTemplate = async (): Promise<Blob> =>
export const downloadExmnBusinessReport = async (attachmentId: string): Promise<Blob> =>
getBlobData(`/attach-service/api/v1/download/${encodeURIComponent(attachmentId)}`);
/** 저장된 페이지 범위를 이용해 선택 관로의 조사보고서 구간만 PDF로 조회한다. */
export const downloadExmnPipeReport = async (
exmnBizId: string,
pipeNumber: string,
): Promise<Blob> =>
getBlobData(
`${EXMN_BUSINESS_API_PATH}/${encodeURIComponent(exmnBizId)}/pipeReports/${encodeURIComponent(pipeNumber)}`,
);
/** 사업 파일별 첨부서비스 키 쌍을 조사사업에 연결한다. */
/** 팝업 생성 전에 선택 관로번호가 등록된 관로 PDF에 포함되는지 확인한다. */
export const getExmnPipeReportAvailability = async (
exmnBizId: string,
pipeNumber: string,
): Promise<ExmnPipeReportAvailability> =>
getData(
`${EXMN_BUSINESS_API_PATH}/${encodeURIComponent(exmnBizId)}/pipeReports/${encodeURIComponent(pipeNumber)}/availability`,
) as Promise<ExmnPipeReportAvailability>;
export const updateExmnAttachments = async (
exmnBizId: string,
attachments: Partial<Record<ExmnFileType, ExmnAttachmentKey>>,
@@ -115,6 +115,12 @@ export interface ExmnImportCommitResult {
summaryCount: number;
}
/** 선택 관로의 조사보고서 팝업을 열 수 있는지 확인한 결과다. */
export interface ExmnPipeReportAvailability {
available: boolean;
message?: string | null;
}
/** 처리분구별 PPL 조사 집계 행이다. */
export interface PplSummaryRow {
trtdstId: string;
@@ -287,7 +287,7 @@ export function ExmnResultRegisterPopupPage({
...current,
[fileType]: [validationMessage],
}));
setNotice({ type: "error", text: validationMessage });
setNotice(null);
return;
}
@@ -307,16 +307,7 @@ export function ExmnResultRegisterPopupPage({
...current,
[fileType]: result.errors.map((item) => item.message),
}));
const visibleErrors = result.errors.slice(0, 5).map((item) => item.message);
if (result.errors.length > visibleErrors.length) {
visibleErrors.push(`외 ${result.errors.length - visibleErrors.length}건`);
}
setNotice(result.valid
? null
: {
type: "error",
text: `검토 오류 ${result.errors.length}건\n${visibleErrors.join("\n")}`,
});
setNotice(null);
} catch (error) {
const message = error instanceof Error ? error.message : "조사파일 검토 중 오류가 발생했습니다.";
setValidationResults((current) => {
@@ -329,10 +320,7 @@ export function ExmnResultRegisterPopupPage({
...current,
[fileType]: [message],
}));
setNotice({
type: "error",
text: message,
});
setNotice(null);
} finally {
setIsLoading(false);
}
@@ -510,9 +498,8 @@ export function ExmnResultRegisterPopupPage({
{visibleValidationTypes.length > 0 && (
<section className="overflow-hidden border border-border" aria-label="데이터 검토 내역">
<div className="flex items-center justify-between bg-[#eaf3fb] px-3 py-2.5">
<div className="flex items-center bg-[#eaf3fb] px-3 py-2.5">
<h2 className="text-[1.3rem] font-medium text-label">데이터 검토 내역</h2>
<span className="text-base text-label">파일별로 실제 확인한 항목입니다.</span>
</div>
{visibleValidationTypes.map((fileType) => {
const status = fileValidationStatuses[fileType]!;
@@ -241,8 +241,21 @@ export function ExmnStatusDetailPanel({ business }: ExmnStatusDetailPanelProps)
));
};
const showMapPendingNotice = () => {
setNotice({ type: "info", text: "지도보기는 현재 보류 기능이며 버튼 진입점만 표시합니다." });
/** 선택 사업의 행정구역·사업차수·현재 조사유형을 하수관망도 초기 조회 계약으로 전달한다. */
const openExmnMap = () => {
if (!business) return;
const query = new URLSearchParams({
exmnBizId: business.exmnBizId,
ctpvNm: business.ctpvNm,
sggNm: business.sggNm,
exmnJobType: jobType,
});
if (business.bizCycl != null) query.set("bizCycl", String(business.bizCycl));
if (selectedSummaryRow) {
query.set("trtdstId", selectedSummaryRow.trtdstId);
query.set("trtdstNm", selectedSummaryRow.trtdstNm);
}
window.location.assign(`/map?${query.toString()}`);
};
/** 현재 조사유형의 종합집계와 처리분구별 전체 상세 시트를 서버 XLSX로 내려받는다. */
@@ -359,7 +372,13 @@ export function ExmnStatusDetailPanel({ business }: ExmnStatusDetailPanelProps)
>
상세보기
</Button>
<Button className={ACTION_BUTTON_CLASS} size="sm" onClick={showMapPendingNotice}>지도보기</Button>
<Button
className={ACTION_BUTTON_CLASS}
size="sm"
onClick={openExmnMap}
>
지도보기
</Button>
<Button
color="green"
className={DOWNLOAD_BUTTON_CLASS}
@@ -11,7 +11,7 @@ export const windowPopupDefinitions = {
key: "exmnResultRegister",
title: "조사 결과 업로드",
width: 1000,
height: 500,
height: 650,
requiredParams: ["exmnBizId"],
},
exmnSurveyDetail: {
@@ -21,6 +21,13 @@ export const windowPopupDefinitions = {
height: 720,
requiredParams: ["exmnBizId", "trtdstId", "trtdstNm", "jobType"],
},
exmnPipeReport: {
key: "exmnPipeReport",
title: "관로 조사보고서",
width: 1200,
height: 850,
requiredParams: ["exmnBizId", "pipeNumber"],
},
oldPpUpload: {
key: "oldPpUpload",
title: "노후 하수관로 정비사업 등록",
@@ -3,6 +3,7 @@ import type { WindowPopupRegistryItem } from "./model/types";
import AgedPipePopupContent from "./ui/AgedPipePopupContent";
import AgedPipeStatusByCtpvPopupContent from "./ui/AgedPipeStatusByCtpvPopupContent";
import ExmnBusinessRegisterPopupContent from "./ui/ExmnBusinessRegisterPopupContent";
import ExmnPipeReportPopupContent from "./ui/ExmnPipeReportPopupContent";
import ExmnResultRegisterPopupContent from "./ui/ExmnResultRegisterPopupContent";
import ExmnSurveyDetailPopupContent from "./ui/ExmnSurveyDetailPopupContent";
import FloodPreTablePopupContent from "./ui/FloodPreTablePopupContent";
@@ -50,6 +51,10 @@ export const windowPopupRegistry = {
...windowPopupDefinitions.exmnSurveyDetail,
component: ExmnSurveyDetailPopupContent,
},
exmnPipeReport: {
...windowPopupDefinitions.exmnPipeReport,
component: ExmnPipeReportPopupContent,
},
oldPpUpload: {
...windowPopupDefinitions.oldPpUpload,
component: OldPpUploadPopupContent,
@@ -0,0 +1,97 @@
"use client";
import { useEffect, useState } from "react";
import { downloadExmnPipeReport } from "@/features/exmn/api/exmn-business-api";
import type { WindowPopupContentProps } from "../model/types";
const getParam = (value: string | string[] | undefined) =>
Array.isArray(value) ? value[0] ?? "" : value ?? "";
/** 공통 Blob API 오류 문자열에서 백엔드의 사용자 안내 메시지만 추출한다. */
const getReportErrorMessage = (error: unknown) => {
if (!(error instanceof Error)) {
return "관로 조사보고서를 불러오지 못했습니다.";
}
const backendMessage = error.message.match(/"message":"((?:\\.|[^"\\])*)"/)?.[1];
if (!backendMessage) return error.message;
try {
return JSON.parse(`"${backendMessage}"`) as string;
} catch {
return backendMessage;
}
};
/** 저장된 관로별 PDF 구간을 공통 WindowPopup 안에서 표시한다. */
export default function ExmnPipeReportPopupContent({
searchParams,
}: WindowPopupContentProps) {
const exmnBizId = getParam(searchParams.exmnBizId);
const pipeNumber = getParam(searchParams.pipeNumber);
const reportNo = getParam(searchParams.reportNo);
const [reportUrl, setReportUrl] = useState("");
const [errorMessage, setErrorMessage] = useState("");
useEffect(() => {
let active = true;
let objectUrl = "";
setReportUrl("");
setErrorMessage("");
void downloadExmnPipeReport(exmnBizId, pipeNumber)
.then((blob) => {
if (!active) return;
const pdfBlob =
blob.type === "application/pdf"
? blob
: new Blob([blob], { type: "application/pdf" });
objectUrl = URL.createObjectURL(pdfBlob);
setReportUrl(objectUrl);
})
.catch((error) => {
if (!active) return;
console.warn("관로 조사보고서 조회 실패:", error);
setErrorMessage(getReportErrorMessage(error));
});
return () => {
active = false;
if (objectUrl) URL.revokeObjectURL(objectUrl);
};
}, [exmnBizId, pipeNumber]);
return (
<main className="sn-popup-page">
<header className="sn-popup-header">
<h1 className="sn-popup-title">관로 조사보고서</h1>
<span className="text-[1.05rem] text-white">
관로번호 {pipeNumber}
{reportNo ? ` · 보고서번호 ${reportNo}` : ""}
</span>
</header>
<section className="min-h-0 flex-1 bg-container p-3">
{errorMessage ? (
<div className="flex h-full items-center justify-center rounded-sm border border-red-200 bg-red-50 p-6 text-center text-[1.1rem] text-red-700">
{errorMessage}
</div>
) : reportUrl ? (
<iframe
title={`${pipeNumber} 관로 조사보고서`}
src={reportUrl}
className="h-full w-full border border-border bg-white"
/>
) : (
<div className="flex h-full items-center justify-center rounded-sm border border-border bg-white text-[1.1rem] text-muted-foreground">
조사보고서를 불러오는 중입니다.
</div>
)}
</section>
</main>
);
}