조사사업 등록·결과 업로드 및 관로·맨홀 종합집계 화면 구현
This commit is contained in:
@@ -0,0 +1,183 @@
|
||||
import {
|
||||
getBlobData,
|
||||
getData,
|
||||
postMutiPart,
|
||||
postRawJson,
|
||||
putData,
|
||||
} from "@/components/lib/api";
|
||||
import type {
|
||||
ExmnAttachmentKey,
|
||||
ExmnBusiness,
|
||||
ExmnBusinessDetail,
|
||||
ExmnBusinessSaveRequest,
|
||||
ExmnBusinessSearchParams,
|
||||
ExmnFileType,
|
||||
ExmnImportCommitResult,
|
||||
ExmnImportValidationResult,
|
||||
ExmnJobType,
|
||||
ExmnPage,
|
||||
ExmnSectionSummaryRow,
|
||||
ExmnTemplateResult,
|
||||
PplFacility,
|
||||
PplFacilitySearchParams,
|
||||
PplSummaryRow,
|
||||
} from "../model/exmn-business-types";
|
||||
|
||||
/** 조사사업 API의 공통 기준 경로다. */
|
||||
const EXMN_BUSINESS_API_PATH = "v1/exmnBusinesses";
|
||||
|
||||
/** 공통 응답 원문 또는 data 래퍼 양쪽에서 실제 payload를 해석한다. */
|
||||
const unwrapData = <T>(response: T | { data?: T }): T =>
|
||||
typeof response === "object" && response !== null && "data" in response
|
||||
? (response as { data: T }).data
|
||||
: (response as T);
|
||||
|
||||
/** Spring Page 응답이 비어 있거나 아직 미구현이어도 안전한 빈 Page로 정규화한다. */
|
||||
const toPage = <T>(value: Partial<ExmnPage<T>> | null | undefined): ExmnPage<T> => ({
|
||||
content: Array.isArray(value?.content) ? value.content : [],
|
||||
totalElements: Number(value?.totalElements ?? 0),
|
||||
number: Number(value?.number ?? 0),
|
||||
size: Number(value?.size ?? 20),
|
||||
totalPages: Number(value?.totalPages ?? 0),
|
||||
});
|
||||
|
||||
/** 조사사업 목록을 조회조건·0-base 페이지·서버 정렬 계약으로 요청한다. */
|
||||
export const getExmnBusinesses = async (
|
||||
params: ExmnBusinessSearchParams,
|
||||
): Promise<ExmnPage<ExmnBusiness>> => {
|
||||
const query = new URLSearchParams({
|
||||
page: String(params.page),
|
||||
size: String(params.size),
|
||||
});
|
||||
if (params.bizCycl?.trim()) query.set("bizCycl", params.bizCycl.trim());
|
||||
if (params.bizYr?.trim()) query.set("bizYr", params.bizYr.trim());
|
||||
if (params.ctpvNm?.trim()) query.set("ctpvNm", params.ctpvNm.trim());
|
||||
if (params.sggNm?.trim()) query.set("sggNm", params.sggNm.trim());
|
||||
if (params.bizNm?.trim()) query.set("bizNm", params.bizNm.trim());
|
||||
if (params.sort) query.set("sort", params.sort);
|
||||
|
||||
return toPage<ExmnBusiness>(await getData(`${EXMN_BUSINESS_API_PATH}?${query}`));
|
||||
};
|
||||
|
||||
/** 선택 사업의 처리분구별 관로·맨홀 조사 상세현황을 조회한다. */
|
||||
export const getExmnSectionSummaries = async (
|
||||
exmnBizId: string,
|
||||
jobType: ExmnJobType,
|
||||
): Promise<ExmnSectionSummaryRow[]> => {
|
||||
type SectionSummaryPayload = ExmnSectionSummaryRow[] | Partial<ExmnPage<ExmnSectionSummaryRow>>;
|
||||
const response = await getData(
|
||||
`${EXMN_BUSINESS_API_PATH}/${encodeURIComponent(exmnBizId)}/sections?jobType=${jobType}`,
|
||||
) as SectionSummaryPayload | { data?: SectionSummaryPayload };
|
||||
const payload = unwrapData<SectionSummaryPayload>(response);
|
||||
if (Array.isArray(payload)) return payload;
|
||||
return Array.isArray(payload?.content) ? payload.content : [];
|
||||
};
|
||||
|
||||
/** 선택한 조사사업의 처리분구·첨부를 포함한 상세정보를 조회한다. */
|
||||
export const getExmnBusiness = async (exmnBizId: string): Promise<ExmnBusinessDetail> =>
|
||||
getData(`${EXMN_BUSINESS_API_PATH}/${encodeURIComponent(exmnBizId)}`) as Promise<ExmnBusinessDetail>;
|
||||
|
||||
/** 조사사업과 선택 처리분구별 기본 요약행을 신규 저장한다. */
|
||||
export const createExmnBusiness = async (
|
||||
request: ExmnBusinessSaveRequest,
|
||||
): Promise<ExmnBusinessDetail> =>
|
||||
unwrapData<ExmnBusinessDetail>(await postRawJson(EXMN_BUSINESS_API_PATH, request));
|
||||
|
||||
/** 기존 조사사업 기본정보와 연결 처리분구를 같은 저장 계약으로 갱신한다. */
|
||||
export const updateExmnBusiness = async (
|
||||
exmnBizId: string,
|
||||
request: ExmnBusinessSaveRequest,
|
||||
): Promise<ExmnBusinessDetail> =>
|
||||
putData(
|
||||
`${EXMN_BUSINESS_API_PATH}/${encodeURIComponent(exmnBizId)}`,
|
||||
request,
|
||||
) as Promise<ExmnBusinessDetail>;
|
||||
|
||||
/** 저장된 사업의 처리분구별 PPL/MNHL 엑셀 시트명을 생성한다. */
|
||||
export const createExmnTemplates = async (
|
||||
exmnBizId: string,
|
||||
trtdstIds: string[],
|
||||
): Promise<ExmnTemplateResult> =>
|
||||
unwrapData<ExmnTemplateResult>(await postRawJson(
|
||||
`${EXMN_BUSINESS_API_PATH}/${encodeURIComponent(exmnBizId)}/templates`,
|
||||
{ trtdstIds },
|
||||
));
|
||||
|
||||
/** 생성된 조사사업 입력 양식 파일을 내려받는다. */
|
||||
export const downloadExmnTemplates = async (exmnBizId: string): Promise<Blob> =>
|
||||
getBlobData(`${EXMN_BUSINESS_API_PATH}/${encodeURIComponent(exmnBizId)}/templates/download`);
|
||||
|
||||
/** 사업 파일별 첨부서비스 키 쌍을 조사사업에 연결한다. */
|
||||
export const updateExmnAttachments = async (
|
||||
exmnBizId: string,
|
||||
attachments: Partial<Record<ExmnFileType, ExmnAttachmentKey>>,
|
||||
): Promise<Partial<Record<ExmnFileType, ExmnAttachmentKey>>> =>
|
||||
putData(
|
||||
`${EXMN_BUSINESS_API_PATH}/${encodeURIComponent(exmnBizId)}/attachments`,
|
||||
{ attachments },
|
||||
) as Promise<Partial<Record<ExmnFileType, ExmnAttachmentKey>>>;
|
||||
|
||||
/** 원본 PPL/MNHL 파일의 업무·JSON 정합성을 서버에서 검토한다. */
|
||||
export const validateExmnImport = async (
|
||||
exmnBizId: string,
|
||||
formData: FormData,
|
||||
): Promise<ExmnImportValidationResult> =>
|
||||
postMutiPart(
|
||||
`${EXMN_BUSINESS_API_PATH}/${encodeURIComponent(exmnBizId)}/imports/validate`,
|
||||
formData,
|
||||
) as Promise<ExmnImportValidationResult>;
|
||||
|
||||
/** 검토했던 동일 파일과 첨부키를 다시 보내 시설 저장·요약 집계를 확정한다. */
|
||||
export const commitExmnImport = async (
|
||||
exmnBizId: string,
|
||||
formData: FormData,
|
||||
): Promise<ExmnImportCommitResult> =>
|
||||
postMutiPart(
|
||||
`${EXMN_BUSINESS_API_PATH}/${encodeURIComponent(exmnBizId)}/imports/commit`,
|
||||
formData,
|
||||
) as Promise<ExmnImportCommitResult>;
|
||||
|
||||
/** 선택 사업의 처리분구별 PPL 집계를 조회한다. */
|
||||
export const getPplSummaries = async (
|
||||
exmnBizId: string,
|
||||
params: { trtdstId?: string; page: number; size: number; sort?: string },
|
||||
): Promise<ExmnPage<PplSummaryRow>> => {
|
||||
const query = new URLSearchParams({ page: String(params.page), size: String(params.size) });
|
||||
if (params.trtdstId) query.set("trtdstId", params.trtdstId);
|
||||
if (params.sort) query.set("sort", params.sort);
|
||||
return toPage<PplSummaryRow>(await getData(
|
||||
`${EXMN_BUSINESS_API_PATH}/${encodeURIComponent(exmnBizId)}/summaries/ppl?${query}`,
|
||||
));
|
||||
};
|
||||
|
||||
/** 선택 사업의 PPL 시설을 처리분구·구간·GIS번호·등급으로 조회한다. */
|
||||
export const getPplFacilities = async (
|
||||
params: PplFacilitySearchParams,
|
||||
): Promise<ExmnPage<PplFacility>> => {
|
||||
const query = new URLSearchParams({
|
||||
exmnBizId: params.exmnBizId,
|
||||
jobType: "PPL",
|
||||
page: String(params.page),
|
||||
size: String(params.size),
|
||||
});
|
||||
if (params.trtdstId) query.set("trtdstId", params.trtdstId);
|
||||
if (params.sectionType) query.set("sectionType", params.sectionType);
|
||||
if (params.gisManagementNo?.trim()) query.set("gisManagementNo", params.gisManagementNo.trim());
|
||||
if (params.structuralGrade) query.set("structuralGrade", params.structuralGrade);
|
||||
if (params.operationalGrade) query.set("operationalGrade", params.operationalGrade);
|
||||
if (params.defectType && params.defectType !== "ALL") query.set("defectType", params.defectType);
|
||||
if (params.sort) query.set("sort", params.sort);
|
||||
return toPage<PplFacility>(await getData(`v1/exmnFacilities?${query}`));
|
||||
};
|
||||
|
||||
/** 현재 PPL 조회조건을 서버 엑셀로 내려받는다. */
|
||||
export const downloadPplExport = async (
|
||||
exmnBizId: string,
|
||||
trtdstId?: string,
|
||||
): Promise<Blob> => {
|
||||
const query = new URLSearchParams();
|
||||
if (trtdstId) query.set("trtdstId", trtdstId);
|
||||
const queryString = query.toString();
|
||||
const suffix = queryString ? `?${queryString}` : "";
|
||||
return getBlobData(`${EXMN_BUSINESS_API_PATH}/${encodeURIComponent(exmnBizId)}/exports/ppl${suffix}`);
|
||||
};
|
||||
@@ -1,4 +1,10 @@
|
||||
export * from "./api/exmn-business-api";
|
||||
export * from "./api/trtdst-api";
|
||||
export * from "./lib/exmn-business-validation";
|
||||
export * from "./lib/trtdst-validation";
|
||||
export * from "./model/exmn-business-types";
|
||||
export * from "./model/trtdst-types";
|
||||
export * from "./ui/ExmnRegistrationPage";
|
||||
export * from "./ui/ExmnStatusPage";
|
||||
export * from "./ui/ExmnStatusDetailPanel";
|
||||
export * from "./ui/TrtdstManagementPage";
|
||||
|
||||
@@ -0,0 +1,54 @@
|
||||
import type {
|
||||
ExmnBusinessSaveRequest,
|
||||
ExmnFileType,
|
||||
} from "../model/exmn-business-types";
|
||||
|
||||
/** 브라우저 1차 검사에서 허용하는 조사파일 최대 크기(200MB)다. */
|
||||
export const EXMN_MAX_FILE_SIZE = 200 * 1024 * 1024;
|
||||
|
||||
/** 조사사업 파일별 화면 표시명과 허용 확장자다. */
|
||||
export const EXMN_FILE_RULES: Record<ExmnFileType, { label: string; extensions: string[] }> = {
|
||||
EXMN_EXCEL: { label: "조사 결과", extensions: ["xlsx", "xls"] },
|
||||
PPL_EXMN_EXCEL: { label: "CCTV 조사 결과(관로)", extensions: ["xlsx", "xls"] },
|
||||
MNHL_EXMN_EXCEL: { label: "CCTV 조사 결과(맨홀)", extensions: ["xlsx", "xls"] },
|
||||
BIZ_REPORT: { label: "사업보고서", extensions: ["pdf"] },
|
||||
LOCATION_MAP: { label: "조사 위치도", extensions: ["pdf", "png", "jpg", "jpeg"] },
|
||||
};
|
||||
|
||||
/** 조사사업 등록 필수값·숫자 범위·처리분구 선택을 검증한다. */
|
||||
export const validateExmnBusiness = (request: ExmnBusinessSaveRequest): string | null => {
|
||||
if (!/^\d{4}$/.test(request.bizYr)) return "사업연도는 4자리 숫자로 입력해주세요.";
|
||||
if (!request.bizNm.trim()) return "사업명을 입력해주세요.";
|
||||
if (!Number.isInteger(request.bizCycl) || request.bizCycl < 0) return "사업차수는 0 이상의 정수로 입력해주세요.";
|
||||
if (!request.ctpvNm || !request.sggNm) return "시도와 시군구를 선택해주세요.";
|
||||
if (!Number.isFinite(request.prcisExmnPrlg) || request.prcisExmnPrlg < 0) return "정밀조사연장은 0 이상이어야 합니다.";
|
||||
if (!Number.isFinite(request.tpco) || request.tpco < 0) return "총사업비는 0 이상이어야 합니다.";
|
||||
if (request.trtdstIds.length === 0) return "처리분구를 1개 이상 선택해주세요.";
|
||||
return null;
|
||||
};
|
||||
|
||||
/** 선택한 단일 파일의 빈 파일·크기·확장자를 업무 구분별로 확인한다. */
|
||||
export const validateExmnFile = (fileType: ExmnFileType, file: File): string | null => {
|
||||
const rule = EXMN_FILE_RULES[fileType];
|
||||
if (file.size <= 0) return `${rule.label} 파일이 비어 있습니다.`;
|
||||
if (file.size > EXMN_MAX_FILE_SIZE) return `${rule.label} 파일은 200MB 이하여야 합니다.`;
|
||||
const extension = file.name.split(".").pop()?.toLowerCase() ?? "";
|
||||
if (!rule.extensions.includes(extension)) {
|
||||
return `${rule.label} 파일은 ${rule.extensions.map((item) => `.${item}`).join(", ")} 형식만 가능합니다.`;
|
||||
}
|
||||
return null;
|
||||
};
|
||||
|
||||
/** PPL 또는 MNHL 파일이 하나 이상 선택됐고 모든 파일이 1차 규칙을 통과하는지 확인한다. */
|
||||
export const validateExmnFiles = (
|
||||
files: Partial<Record<ExmnFileType, File>>,
|
||||
): string | null => {
|
||||
if (!files.PPL_EXMN_EXCEL && !files.MNHL_EXMN_EXCEL) {
|
||||
return "검토할 PPL 또는 MNHL 조사 엑셀을 1개 이상 선택해주세요.";
|
||||
}
|
||||
for (const [fileType, file] of Object.entries(files) as [ExmnFileType, File][]) {
|
||||
const message = validateExmnFile(fileType, file);
|
||||
if (message) return message;
|
||||
}
|
||||
return null;
|
||||
};
|
||||
@@ -0,0 +1,210 @@
|
||||
/** Spring Page 응답에서 조사사업 화면이 사용하는 공통 페이징 계약이다. */
|
||||
export interface ExmnPage<T> {
|
||||
content: T[];
|
||||
totalElements: number;
|
||||
number: number;
|
||||
size: number;
|
||||
totalPages?: number;
|
||||
}
|
||||
|
||||
/** 조사사업 목록과 선택 컨텍스트에서 사용하는 사업 기본정보다. */
|
||||
export interface ExmnBusiness {
|
||||
exmnBizId: string;
|
||||
bizYr: string;
|
||||
bizNm: string;
|
||||
bizCycl: number | null;
|
||||
ctpvNm: string;
|
||||
sggNm: string;
|
||||
trtdstCnt: number;
|
||||
prcisExmnPrlg: number;
|
||||
tpco: number;
|
||||
bizRptpAtchFileCd?: string | null;
|
||||
bizRptpAtchFileId?: string | null;
|
||||
reportYn?: "Y" | "N";
|
||||
}
|
||||
|
||||
/** 사업에 연결된 활성 처리분구 정보다. */
|
||||
export interface ExmnBusinessDistrict {
|
||||
trtdstId: string;
|
||||
trtdstNm: string;
|
||||
pplExmnSheetNm?: string | null;
|
||||
mnhlExmnSheetNm?: string | null;
|
||||
}
|
||||
|
||||
/** 파일 구분별 첨부서비스 키 쌍이다. */
|
||||
export interface ExmnAttachmentKey {
|
||||
atflCd: string;
|
||||
atflId: string;
|
||||
fileName?: string;
|
||||
}
|
||||
|
||||
/** 조사사업 상세 응답이다. */
|
||||
export interface ExmnBusinessDetail extends ExmnBusiness {
|
||||
trtdstIds: string[];
|
||||
districts: ExmnBusinessDistrict[];
|
||||
attachments?: Partial<Record<ExmnFileType, ExmnAttachmentKey>>;
|
||||
}
|
||||
|
||||
/** 사업 목록의 입력 조건과 서버 페이징·정렬 조건이다. */
|
||||
export interface ExmnBusinessSearchParams {
|
||||
bizCycl?: string;
|
||||
bizYr?: string;
|
||||
ctpvNm?: string;
|
||||
sggNm?: string;
|
||||
bizNm?: string;
|
||||
page: number;
|
||||
size: number;
|
||||
sort?: string;
|
||||
}
|
||||
|
||||
/** 사업 신규 등록·수정 요청의 공통 계약이다. */
|
||||
export interface ExmnBusinessSaveRequest {
|
||||
bizYr: string;
|
||||
bizNm: string;
|
||||
bizCycl: number;
|
||||
ctpvNm: string;
|
||||
sggNm: string;
|
||||
prcisExmnPrlg: number;
|
||||
tpco: number;
|
||||
trtdstIds: string[];
|
||||
}
|
||||
|
||||
/** 처리분구별 PPL/MNHL 양식 시트 생성 결과다. */
|
||||
export interface ExmnTemplateResult {
|
||||
pplSheetNames: string[];
|
||||
mnhlSheetNames: string[];
|
||||
}
|
||||
|
||||
/** 조사사업에서 관리하는 파일의 업무 구분이다. */
|
||||
export type ExmnFileType =
|
||||
| "EXMN_EXCEL"
|
||||
| "PPL_EXMN_EXCEL"
|
||||
| "MNHL_EXMN_EXCEL"
|
||||
| "BIZ_REPORT"
|
||||
| "LOCATION_MAP";
|
||||
|
||||
/** 파일 검토에서 원본 위치까지 식별하는 단일 오류다. */
|
||||
export interface ExmnImportValidationError {
|
||||
sheetName?: string | null;
|
||||
rowNumber?: number | null;
|
||||
trtdstId?: string | null;
|
||||
trtdstNm?: string | null;
|
||||
gisManagementNo?: string | null;
|
||||
fieldPath?: string | null;
|
||||
code: string;
|
||||
message: string;
|
||||
}
|
||||
|
||||
/** PPL/MNHL 파일 검토 결과다. */
|
||||
export interface ExmnImportValidationResult {
|
||||
valid: boolean;
|
||||
fileHash: string;
|
||||
summary: {
|
||||
pplCount: number;
|
||||
mnhlCount: number;
|
||||
};
|
||||
errors: ExmnImportValidationError[];
|
||||
}
|
||||
|
||||
/** 최종 저장 후 시설·미사용·집계 처리 건수다. */
|
||||
export interface ExmnImportCommitResult {
|
||||
savedCount: number;
|
||||
unusedCount: number;
|
||||
summaryCount: number;
|
||||
}
|
||||
|
||||
/** 처리분구별 PPL 조사 집계 행이다. */
|
||||
export interface PplSummaryRow {
|
||||
trtdstId: string;
|
||||
trtdstNm: string;
|
||||
trgtPrlg: number;
|
||||
drvngDstnc: number;
|
||||
pplExmnWholAnmlyArtclCnt: number;
|
||||
pplExmnStrctDfcCnt: number;
|
||||
pplExmnOperDfcCnt: number;
|
||||
pplExmnEtcCnt: number;
|
||||
}
|
||||
|
||||
/** 조사현황 화면에서 관로(PPL)·맨홀(MNHL)을 구분하는 작업유형이다. */
|
||||
export type ExmnJobType = "PPL" | "MNHL";
|
||||
|
||||
/** 사업 선택 후 처리분구별 관로·맨홀 집계를 표시하는 조사 상세현황 행이다. */
|
||||
export interface ExmnSectionSummaryRow {
|
||||
trtdstId: string;
|
||||
trtdstNm: string;
|
||||
trgtPrlg: number;
|
||||
drvngDstnc: number;
|
||||
pplExmnWholAnmlyArtclCnt: number;
|
||||
pplExmnStrctDfcCnt: number;
|
||||
pplExmnOperDfcCnt: number;
|
||||
pplExmnEtcCnt: number;
|
||||
mnhlExmnWholAnmlyArtclCnt: number;
|
||||
mnhlExmnStrctDfcCnt: number;
|
||||
mnhlExmnOperDfcCnt: number;
|
||||
mnhlExmnEtcCnt: number;
|
||||
}
|
||||
|
||||
/** PPL 상세 목록 조회 조건이다. */
|
||||
export interface PplFacilitySearchParams {
|
||||
exmnBizId: string;
|
||||
trtdstId?: string;
|
||||
sectionType?: string;
|
||||
gisManagementNo?: string;
|
||||
structuralGrade?: string;
|
||||
operationalGrade?: string;
|
||||
defectType?: "ALL" | "STRUCTURAL" | "OPERATIONAL" | "ETC";
|
||||
page: number;
|
||||
size: number;
|
||||
sort?: string;
|
||||
}
|
||||
|
||||
/** 관로 단위 구간의 상태·결함 상세 기록이다. */
|
||||
export interface PplUnitDetail {
|
||||
detailSeq: number;
|
||||
positionM: number;
|
||||
code: string;
|
||||
recordType: "STATUS" | "DEFECT";
|
||||
statusGroup?: string | null;
|
||||
type?: "STRUCTURAL" | "OPERATIONAL" | null;
|
||||
severity?: string | null;
|
||||
occurrencePosition?: string | null;
|
||||
infiltrationBonus?: number | null;
|
||||
continuous?: boolean | null;
|
||||
structuralScore?: number | null;
|
||||
operationalScore?: number | null;
|
||||
}
|
||||
|
||||
/** 서버가 JSONB를 정규화해 반환하는 PPL 단위 구간이다. */
|
||||
export interface PplUnitSection {
|
||||
unitSeq: number;
|
||||
startPositionM: number;
|
||||
unitLengthM: number;
|
||||
structuralScore: number;
|
||||
structuralGrade: number | string;
|
||||
operationalScore: number;
|
||||
operationalGrade: number | string;
|
||||
surveyStopReason?: string | null;
|
||||
remark?: string | null;
|
||||
details: PplUnitDetail[];
|
||||
}
|
||||
|
||||
/** PPL 시설 목록과 상세 모달에서 사용하는 정규화 응답이다. */
|
||||
export interface PplFacility {
|
||||
exmnFcltyId: string;
|
||||
exmnBizId: string;
|
||||
trtdstId: string;
|
||||
trtdstNm: string;
|
||||
rptpNo: string;
|
||||
exmnFcltyGisMngNo: string;
|
||||
upstmMnhlNo?: string | null;
|
||||
dnstmMnhlNo?: string | null;
|
||||
drgptnCd?: string | null;
|
||||
pptyCd?: string | null;
|
||||
spcfctCd?: string | null;
|
||||
prlg?: number | null;
|
||||
drvngDstnc?: number | null;
|
||||
strctDfcGrdCd?: string | null;
|
||||
operDfcGrdCd?: string | null;
|
||||
wholAnmlyArtclCnt?: number | null;
|
||||
unitSections?: PplUnitSection[];
|
||||
}
|
||||
@@ -0,0 +1,163 @@
|
||||
import type {
|
||||
ExmnBusiness,
|
||||
ExmnBusinessDetail,
|
||||
ExmnImportValidationResult,
|
||||
ExmnJobType,
|
||||
ExmnSectionSummaryRow,
|
||||
} from "./exmn-business-types";
|
||||
import type { TrtdstSettingDto } from "./trtdst-types";
|
||||
|
||||
/** 백엔드 데이터가 준비되기 전 명시적 sample=Y 화면 검증에서만 사용하는 조사사업 목록이다. */
|
||||
export const EXMN_SAMPLE_BUSINESSES: ExmnBusiness[] = [
|
||||
{
|
||||
exmnBizId: "SAMPLE-BIZ-001",
|
||||
bizYr: "2026",
|
||||
bizNm: "2026년 양평군 노후 하수관로 정밀조사사업",
|
||||
bizCycl: 3,
|
||||
ctpvNm: "경기도",
|
||||
sggNm: "양평군",
|
||||
trtdstCnt: 10,
|
||||
prcisExmnPrlg: 18.42,
|
||||
tpco: 1_250_000_000,
|
||||
reportYn: "Y",
|
||||
bizRptpAtchFileCd: "SAMPLE",
|
||||
bizRptpAtchFileId: "SAMPLE-REPORT-001",
|
||||
},
|
||||
{
|
||||
exmnBizId: "SAMPLE-BIZ-002",
|
||||
bizYr: "2025",
|
||||
bizNm: "2025년 이천시 하수관로 CCTV 조사사업",
|
||||
bizCycl: 2,
|
||||
ctpvNm: "경기도",
|
||||
sggNm: "이천시",
|
||||
trtdstCnt: 10,
|
||||
prcisExmnPrlg: 12.75,
|
||||
tpco: 890_000_000,
|
||||
reportYn: "N",
|
||||
},
|
||||
{
|
||||
exmnBizId: "SAMPLE-BIZ-003",
|
||||
bizYr: "2024",
|
||||
bizNm: "2024년 원주시 관로·맨홀 정밀조사사업",
|
||||
bizCycl: 1,
|
||||
ctpvNm: "강원특별자치도",
|
||||
sggNm: "원주시",
|
||||
trtdstCnt: 10,
|
||||
prcisExmnPrlg: 15.1,
|
||||
tpco: 1_030_000_000,
|
||||
reportYn: "Y",
|
||||
bizRptpAtchFileCd: "SAMPLE",
|
||||
bizRptpAtchFileId: "SAMPLE-REPORT-003",
|
||||
},
|
||||
{
|
||||
exmnBizId: "SAMPLE-BIZ-004",
|
||||
bizYr: "2023",
|
||||
bizNm: "2023년 청주시 하수관로 정밀조사사업",
|
||||
bizCycl: 4,
|
||||
ctpvNm: "충청북도",
|
||||
sggNm: "청주시",
|
||||
trtdstCnt: 10,
|
||||
prcisExmnPrlg: 9.86,
|
||||
tpco: 760_000_000,
|
||||
reportYn: "N",
|
||||
},
|
||||
];
|
||||
|
||||
/** 사업등록 팝업의 체크박스·가나다순 정렬을 검증하는 활성 처리분구 샘플이다. */
|
||||
export const EXMN_SAMPLE_TRTDST_SETTINGS: TrtdstSettingDto[] = [
|
||||
"갈산처리분구",
|
||||
"개군처리분구",
|
||||
"공흥처리분구",
|
||||
"국수처리분구",
|
||||
"단월처리분구",
|
||||
"덕평처리분구",
|
||||
"문호처리분구",
|
||||
"백안처리분구",
|
||||
"서종처리분구",
|
||||
"양근처리분구",
|
||||
"양동처리분구",
|
||||
"양서처리분구",
|
||||
"오빈처리분구",
|
||||
"용문처리분구",
|
||||
"용천처리분구",
|
||||
"지평처리분구",
|
||||
"청운처리분구",
|
||||
"회현처리분구",
|
||||
"강상처리분구",
|
||||
"강하처리분구",
|
||||
"옥천처리분구",
|
||||
].map((trtdstNm, index) => ({
|
||||
trtdstId: `SAMPLE-TRTDST-${String(index + 1).padStart(2, "0")}`,
|
||||
ctpvNm: "경기도",
|
||||
sggNm: "양평군",
|
||||
trtdstNm,
|
||||
useYn: "Y",
|
||||
}));
|
||||
|
||||
/** 선택 사업의 처리분구와 첨부 영역을 팝업에서 확인할 수 있도록 상세 샘플을 만든다. */
|
||||
export const getExmnSampleBusinessDetail = (
|
||||
exmnBizId: string,
|
||||
fallbackBusiness?: ExmnBusiness,
|
||||
): ExmnBusinessDetail => {
|
||||
const business = EXMN_SAMPLE_BUSINESSES.find((item) => item.exmnBizId === exmnBizId)
|
||||
?? fallbackBusiness
|
||||
?? EXMN_SAMPLE_BUSINESSES[0];
|
||||
const districts = EXMN_SAMPLE_TRTDST_SETTINGS.slice(0, business.trtdstCnt).map((item) => ({
|
||||
trtdstId: item.trtdstId,
|
||||
trtdstNm: item.trtdstNm,
|
||||
pplExmnSheetNm: `${item.trtdstNm}_PPL`,
|
||||
mnhlExmnSheetNm: `${item.trtdstNm}_MNHL`,
|
||||
}));
|
||||
|
||||
return {
|
||||
...business,
|
||||
trtdstIds: districts.map((item) => item.trtdstId),
|
||||
districts,
|
||||
};
|
||||
};
|
||||
|
||||
/** 결과등록 팝업의 데이터 검토·저장 활성 상태를 확인하는 정상 샘플 결과다. */
|
||||
export const EXMN_SAMPLE_IMPORT_VALIDATION: ExmnImportValidationResult = {
|
||||
valid: true,
|
||||
fileHash: "SAMPLE-FILE-HASH-PREVIEW",
|
||||
summary: {
|
||||
pplCount: 24,
|
||||
mnhlCount: 12,
|
||||
},
|
||||
errors: [],
|
||||
};
|
||||
|
||||
/** 조사현황의 사업 행 선택과 관로·맨홀 탭 전환을 검증하는 처리분구 집계 샘플이다. */
|
||||
export const getExmnSampleSectionSummaries = (
|
||||
exmnBizId: string,
|
||||
jobType: ExmnJobType,
|
||||
): ExmnSectionSummaryRow[] => {
|
||||
const business = EXMN_SAMPLE_BUSINESSES.find((item) => item.exmnBizId === exmnBizId)
|
||||
?? EXMN_SAMPLE_BUSINESSES[0];
|
||||
const districts = EXMN_SAMPLE_TRTDST_SETTINGS.slice(0, business.trtdstCnt);
|
||||
|
||||
return districts.map((district, index) => {
|
||||
const scale = index + 1;
|
||||
const pplStructural = 21 * scale;
|
||||
const pplOperational = 7 * scale;
|
||||
const pplEtc = 2 * scale;
|
||||
const mnhlStructural = 5 * scale;
|
||||
const mnhlOperational = 3 * scale;
|
||||
const mnhlEtc = scale;
|
||||
|
||||
return {
|
||||
trtdstId: district.trtdstId,
|
||||
trtdstNm: district.trtdstNm,
|
||||
trgtPrlg: Number((business.prcisExmnPrlg * 1000 / districts.length).toFixed(1)),
|
||||
drvngDstnc: Number((business.prcisExmnPrlg * 930 / districts.length).toFixed(1)),
|
||||
pplExmnWholAnmlyArtclCnt: jobType === "PPL" ? pplStructural + pplOperational + pplEtc : 0,
|
||||
pplExmnStrctDfcCnt: jobType === "PPL" ? pplStructural : 0,
|
||||
pplExmnOperDfcCnt: jobType === "PPL" ? pplOperational : 0,
|
||||
pplExmnEtcCnt: jobType === "PPL" ? pplEtc : 0,
|
||||
mnhlExmnWholAnmlyArtclCnt: jobType === "MNHL" ? mnhlStructural + mnhlOperational + mnhlEtc : 0,
|
||||
mnhlExmnStrctDfcCnt: jobType === "MNHL" ? mnhlStructural : 0,
|
||||
mnhlExmnOperDfcCnt: jobType === "MNHL" ? mnhlOperational : 0,
|
||||
mnhlExmnEtcCnt: jobType === "MNHL" ? mnhlEtc : 0,
|
||||
};
|
||||
});
|
||||
};
|
||||
@@ -0,0 +1,327 @@
|
||||
"use client";
|
||||
|
||||
import LoadingOverlay from "@/components/ui/LoadingBar";
|
||||
import { CtpvSggPrcsFcltSelectBox } from "@/components/ui/ctpvSggPrcsFcltSelectBox";
|
||||
import { getStartYearArray } from "@/components/ui/YearRangeSelectBox";
|
||||
import useStore from "@/store/useStore";
|
||||
import { postPopupMessage } from "@/features/windowPopup/lib/popupMessage";
|
||||
import { WINDOW_POPUP_MESSAGE_TYPE } from "@/features/windowPopup/model/types";
|
||||
import { Button, Input, Label } from "@common/components/ui";
|
||||
import { SelectBox } from "@common/business_components/ui";
|
||||
import { useEffect, useMemo, useState } from "react";
|
||||
import {
|
||||
createExmnBusiness,
|
||||
createExmnTemplates,
|
||||
downloadExmnTemplates,
|
||||
} from "../api/exmn-business-api";
|
||||
import { getTrtdstSettings } from "../api/trtdst-api";
|
||||
import { validateExmnBusiness } from "../lib/exmn-business-validation";
|
||||
import { EXMN_SAMPLE_TRTDST_SETTINGS } from "../model/exmn-sample-data";
|
||||
import type {
|
||||
ExmnBusinessDetail,
|
||||
ExmnBusinessSaveRequest,
|
||||
} from "../model/exmn-business-types";
|
||||
import type { TrtdstSettingDto } from "../model/trtdst-types";
|
||||
|
||||
const ACTION_BUTTON_CLASS =
|
||||
"rounded-sm bg-blue-600 px-4 py-2 text-sm font-medium !text-white hover:bg-blue-700 disabled:!text-white";
|
||||
const DOWNLOAD_BUTTON_CLASS =
|
||||
"green rounded-sm px-4 py-2 text-sm font-medium !text-white disabled:!text-white";
|
||||
|
||||
type Notice = { type: "success" | "error" | "info"; text: string } | null;
|
||||
|
||||
interface ExmnBusinessRegisterPopupPageProps {
|
||||
sampleMode: boolean;
|
||||
}
|
||||
|
||||
/** 처리분구 이름을 공백 제거 후 한글 가나다순으로 정렬한다. */
|
||||
const sortDistricts = (items: TrtdstSettingDto[]) => [...items].sort((left, right) =>
|
||||
left.trtdstNm.trim().localeCompare(right.trtdstNm.trim(), "ko"));
|
||||
|
||||
/** Blob을 브라우저 다운로드로 전달하고 임시 URL을 즉시 정리한다. */
|
||||
const saveBlob = (blob: Blob, fileName: string) => {
|
||||
const objectUrl = URL.createObjectURL(blob);
|
||||
const anchor = document.createElement("a");
|
||||
anchor.href = objectUrl;
|
||||
anchor.download = fileName;
|
||||
anchor.click();
|
||||
URL.revokeObjectURL(objectUrl);
|
||||
};
|
||||
|
||||
/** 공통 WindowPopup 안에서 사업정보·대상 처리분구·조사양식을 등록하는 화면이다. */
|
||||
export function ExmnBusinessRegisterPopupPage({ sampleMode }: ExmnBusinessRegisterPopupPageProps) {
|
||||
const { ctpv, sgg, fetchCtpv, fetchSgg } = useStore();
|
||||
const [bizYr, setBizYr] = useState(String(new Date().getFullYear()));
|
||||
const [bizNm, setBizNm] = useState(sampleMode ? "2026년 양평군 노후 하수관로 정밀조사사업" : "");
|
||||
const [ctpvCd, setCtpvCd] = useState("0");
|
||||
const [sggCd, setSggCd] = useState("0");
|
||||
const [ctpvNm, setCtpvNm] = useState(sampleMode ? "경기도" : "");
|
||||
const [sggNm, setSggNm] = useState(sampleMode ? "양평군" : "");
|
||||
const [prcisExmnPrlg, setPrcisExmnPrlg] = useState(sampleMode ? "18.42" : "0");
|
||||
const [districts, setDistricts] = useState<TrtdstSettingDto[]>([]);
|
||||
const [selectedDistrictIds, setSelectedDistrictIds] = useState<string[]>([]);
|
||||
const [savedBusiness, setSavedBusiness] = useState<ExmnBusinessDetail | null>(null);
|
||||
const [isLoading, setIsLoading] = useState(false);
|
||||
const [notice, setNotice] = useState<Notice>(null);
|
||||
|
||||
/** 팝업 레이아웃은 BaseLayout 초기화를 거치지 않으므로 행정구역 공통코드를 직접 준비한다. */
|
||||
useEffect(() => {
|
||||
if (ctpv.length === 0) void fetchCtpv();
|
||||
if (sgg.length === 0) void fetchSgg();
|
||||
}, [ctpv.length, fetchCtpv, fetchSgg, sgg.length]);
|
||||
|
||||
/** 샘플 팝업은 공통코드가 로드된 뒤 경기도·양평군을 선택 상태로 맞춘다. */
|
||||
useEffect(() => {
|
||||
if (!sampleMode) return;
|
||||
const sampleCtpv = ctpv.find((option) => option.text === "경기도");
|
||||
const sampleSgg = sgg.find((option) => option.text === "양평군");
|
||||
if (sampleCtpv) setCtpvCd(String(sampleCtpv.val));
|
||||
if (sampleSgg) setSggCd(String(sampleSgg.val));
|
||||
}, [ctpv, sampleMode, sgg]);
|
||||
|
||||
/** 선택 행정구역의 활성 처리분구를 읽고 화면 요구사항대로 가나다순으로 제공한다. */
|
||||
useEffect(() => {
|
||||
if (!ctpvNm || !sggNm) {
|
||||
setDistricts([]);
|
||||
setSelectedDistrictIds([]);
|
||||
return;
|
||||
}
|
||||
|
||||
if (sampleMode) {
|
||||
setDistricts(sortDistricts(EXMN_SAMPLE_TRTDST_SETTINGS.map((item) => ({
|
||||
...item,
|
||||
ctpvNm,
|
||||
sggNm,
|
||||
}))));
|
||||
setSelectedDistrictIds([]);
|
||||
return;
|
||||
}
|
||||
|
||||
let active = true;
|
||||
setIsLoading(true);
|
||||
getTrtdstSettings({ ctpvNm, sggNm, useYn: "Y" })
|
||||
.then((items) => {
|
||||
if (!active) return;
|
||||
setDistricts(sortDistricts(items));
|
||||
setSelectedDistrictIds([]);
|
||||
})
|
||||
.catch((error: unknown) => {
|
||||
if (!active) return;
|
||||
setDistricts([]);
|
||||
setNotice({
|
||||
type: "error",
|
||||
text: error instanceof Error ? error.message : "처리분구 조회 중 오류가 발생했습니다.",
|
||||
});
|
||||
})
|
||||
.finally(() => {
|
||||
if (active) setIsLoading(false);
|
||||
});
|
||||
|
||||
return () => {
|
||||
active = false;
|
||||
};
|
||||
}, [ctpvNm, sampleMode, sggNm]);
|
||||
|
||||
/** 선택 개수와 이름을 저장 요청과 양식 생성에서 동일하게 사용한다. */
|
||||
const selectedDistricts = useMemo(() => districts.filter((district) =>
|
||||
selectedDistrictIds.includes(district.trtdstId)), [districts, selectedDistrictIds]);
|
||||
|
||||
/** 현재 입력값을 조사사업 신규 저장 계약으로 변환한다. */
|
||||
const buildRequest = (): ExmnBusinessSaveRequest => ({
|
||||
bizYr: bizYr.trim(),
|
||||
bizNm: bizNm.trim(),
|
||||
// 사업차수와 총사업비는 현재 등록 화면 입력 대상이 아니므로 신규 기본값으로 전달한다.
|
||||
bizCycl: 1,
|
||||
ctpvNm,
|
||||
sggNm,
|
||||
prcisExmnPrlg: Number(prcisExmnPrlg),
|
||||
tpco: 0,
|
||||
trtdstIds: selectedDistrictIds,
|
||||
});
|
||||
|
||||
/** 실제 모드에서는 API를 호출하고 샘플 모드에서는 명시적 미리보기 사업만 부모 목록에 전달한다. */
|
||||
const handleSave = async () => {
|
||||
const request = buildRequest();
|
||||
const validationMessage = validateExmnBusiness(request);
|
||||
if (validationMessage) {
|
||||
setNotice({ type: "error", text: validationMessage });
|
||||
return;
|
||||
}
|
||||
|
||||
setIsLoading(true);
|
||||
setNotice(null);
|
||||
try {
|
||||
const created: ExmnBusinessDetail = sampleMode
|
||||
? {
|
||||
exmnBizId: `SAMPLE-BIZ-${Date.now()}`,
|
||||
...request,
|
||||
trtdstCnt: selectedDistrictIds.length,
|
||||
reportYn: "N",
|
||||
districts: selectedDistricts.map((district) => ({
|
||||
trtdstId: district.trtdstId,
|
||||
trtdstNm: district.trtdstNm,
|
||||
})),
|
||||
}
|
||||
: await createExmnBusiness(request);
|
||||
|
||||
setSavedBusiness(created);
|
||||
postPopupMessage(WINDOW_POPUP_MESSAGE_TYPE.EXMN_BUSINESS_CREATED, {
|
||||
exmnBizId: created.exmnBizId,
|
||||
sample: sampleMode ? "Y" : "N",
|
||||
business: sampleMode ? JSON.stringify(created) : "",
|
||||
});
|
||||
setNotice({
|
||||
type: "success",
|
||||
text: sampleMode
|
||||
? "샘플 사업정보를 저장했습니다. 부모 목록에서 바로 확인할 수 있습니다."
|
||||
: "사업정보와 처리분구별 기본 요약행을 저장했습니다.",
|
||||
});
|
||||
} catch (error) {
|
||||
setNotice({
|
||||
type: "error",
|
||||
text: error instanceof Error ? error.message : "사업정보 저장 중 오류가 발생했습니다.",
|
||||
});
|
||||
} finally {
|
||||
setIsLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
/** 양식 받기 한 번으로 서버 양식을 생성한 뒤 내려받는다. */
|
||||
const handleDownloadTemplates = async () => {
|
||||
if (!savedBusiness?.exmnBizId) {
|
||||
setNotice({ type: "info", text: "사업정보를 먼저 저장해주세요." });
|
||||
return;
|
||||
}
|
||||
if (sampleMode) {
|
||||
setNotice({ type: "info", text: "샘플 모드에서는 실제 양식 파일을 다운로드하지 않습니다." });
|
||||
return;
|
||||
}
|
||||
setIsLoading(true);
|
||||
setNotice(null);
|
||||
try {
|
||||
await createExmnTemplates(savedBusiness.exmnBizId, selectedDistrictIds);
|
||||
saveBlob(await downloadExmnTemplates(savedBusiness.exmnBizId), `${savedBusiness.bizNm}_조사입력양식.xlsx`);
|
||||
} catch (error) {
|
||||
setNotice({
|
||||
type: "error",
|
||||
text: error instanceof Error ? error.message : "조사 입력양식 다운로드 중 오류가 발생했습니다.",
|
||||
});
|
||||
} finally {
|
||||
setIsLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
/** 조사보고서 양식 API는 미확정 상태임을 화면에서 명시한다. */
|
||||
const handleReportTemplate = () => {
|
||||
setNotice({ type: "info", text: "조사보고서 양식 API가 확정되면 다운로드 기능을 연결합니다." });
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="min-h-dvh bg-[#f3f7fa] p-4">
|
||||
<LoadingOverlay isLoading={isLoading} />
|
||||
<header className="flex items-center justify-between rounded-t border border-[#d7e0e8] bg-white px-5 py-4">
|
||||
<h1 className="text-xl font-semibold text-gray-800">사업정보 등록</h1>
|
||||
<div className="flex items-center gap-2">
|
||||
<Button className={ACTION_BUTTON_CLASS} size="sm" disabled={isLoading || Boolean(savedBusiness)} onClick={() => void handleSave()}>저장</Button>
|
||||
<Button className={DOWNLOAD_BUTTON_CLASS} size="sm" disabled={isLoading} onClick={() => void handleDownloadTemplates()}>양식 받기</Button>
|
||||
<Button className={DOWNLOAD_BUTTON_CLASS} size="sm" disabled={isLoading} onClick={handleReportTemplate}>조사보고서 양식</Button>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<main className="space-y-3 border-x border-b border-[#d7e0e8] bg-white p-4">
|
||||
{notice && (
|
||||
<div role="alert" className={`rounded border px-4 py-3 text-sm ${
|
||||
notice.type === "error"
|
||||
? "border-red-200 bg-red-50 text-red-700"
|
||||
: notice.type === "success"
|
||||
? "border-emerald-200 bg-emerald-50 text-emerald-700"
|
||||
: "border-blue-200 bg-blue-50 text-blue-700"
|
||||
}`}>{notice.text}</div>
|
||||
)}
|
||||
|
||||
<section className="rounded border border-[#d7e0e8]">
|
||||
<div className="border-b border-[#d7e0e8] bg-[#e8f2fb] px-4 py-3 font-semibold text-gray-700">사업정보 입력</div>
|
||||
<div className="space-y-4 p-4">
|
||||
<div className="flex flex-wrap items-center gap-y-4">
|
||||
<SelectBox
|
||||
label="사업년도"
|
||||
labelClass="w-20 shrink-0 text-base"
|
||||
className="mr-7 flex items-center gap-3"
|
||||
selectClass="w-36 bg-white"
|
||||
selectArray={getStartYearArray({ placeholder: "선택" })}
|
||||
value={bizYr}
|
||||
readOnly={Boolean(savedBusiness)}
|
||||
onSelectValue={(value) => setBizYr(String(value))}
|
||||
/>
|
||||
<CtpvSggPrcsFcltSelectBox
|
||||
className="!mr-0 !justify-start"
|
||||
sido={ctpvCd}
|
||||
sigungu={sggCd}
|
||||
onSidoChange={(nextCtpvCd) => {
|
||||
if (savedBusiness) return;
|
||||
setCtpvCd(nextCtpvCd);
|
||||
setSggCd("0");
|
||||
setSggNm("");
|
||||
}}
|
||||
onSigunguChange={(nextSggCd) => {
|
||||
if (!savedBusiness) setSggCd(nextSggCd);
|
||||
}}
|
||||
setCtpvName={(nextCtpvNm) => {
|
||||
if (!savedBusiness) setCtpvNm(nextCtpvNm === "전체" ? "" : nextCtpvNm);
|
||||
}}
|
||||
setSggName={(nextSggNm) => {
|
||||
if (!savedBusiness) setSggNm(nextSggNm === "전체" ? "" : nextSggNm);
|
||||
}}
|
||||
/>
|
||||
<div className="ml-auto flex items-center gap-3" style={{ width: "280px" }}>
|
||||
<Label className="shrink-0 text-base" style={{ whiteSpace: "nowrap" }}><span className="text-red-500">* </span>정밀조사 연장</Label>
|
||||
<Input
|
||||
style={{ width: "9rem", flex: "none" }}
|
||||
type="text"
|
||||
inputMode="decimal"
|
||||
maxLength={10}
|
||||
value={prcisExmnPrlg}
|
||||
disabled={Boolean(savedBusiness)}
|
||||
onChange={(event) => {
|
||||
if (/^\d*\.?\d*$/.test(event.target.value)) setPrcisExmnPrlg(event.target.value);
|
||||
}}
|
||||
/>
|
||||
<span className="text-sm text-gray-500">km</span>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex items-center gap-3">
|
||||
<Label className="w-20 shrink-0 text-base"><span className="text-red-500">* </span>사업명</Label>
|
||||
<Input style={{ maxWidth: "480px" }} value={bizNm} maxLength={200} disabled={Boolean(savedBusiness)} onChange={(event) => setBizNm(event.target.value)} />
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section className="rounded border border-[#d7e0e8]">
|
||||
<div className="flex items-center justify-between border-b border-[#d7e0e8] bg-[#e8f2fb] px-4 py-3">
|
||||
<h2 className="font-semibold text-gray-700">사업대상 처리분구</h2>
|
||||
<span className="text-sm text-gray-600">가나다순 · {selectedDistrictIds.length}개 선택</span>
|
||||
</div>
|
||||
<div className="grid grid-cols-3 content-start gap-3 overflow-y-auto p-4" style={{ height: "21rem" }}>
|
||||
{districts.length === 0 ? (
|
||||
<p className="col-span-3 py-7 text-center text-sm text-gray-500">시도·시군구를 선택하거나 등록된 활성 처리분구를 확인해주세요.</p>
|
||||
) : districts.map((district) => (
|
||||
<label key={district.trtdstId} className="flex cursor-pointer items-center gap-2 rounded border border-gray-200 bg-white px-3 py-2.5 text-sm hover:border-blue-300 hover:bg-blue-50">
|
||||
<input
|
||||
type="checkbox"
|
||||
className="size-4 accent-blue-600"
|
||||
checked={selectedDistrictIds.includes(district.trtdstId)}
|
||||
disabled={Boolean(savedBusiness)}
|
||||
onChange={(event) => setSelectedDistrictIds((current) => event.target.checked
|
||||
? [...current, district.trtdstId]
|
||||
: current.filter((id) => id !== district.trtdstId))}
|
||||
/>
|
||||
<span>{district.trtdstNm}</span>
|
||||
</label>
|
||||
))}
|
||||
</div>
|
||||
</section>
|
||||
|
||||
</main>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,554 @@
|
||||
"use client";
|
||||
|
||||
import LoadingOverlay from "@/components/ui/LoadingBar";
|
||||
import { CtpvSggPrcsFcltSelectBox } from "@/components/ui/ctpvSggPrcsFcltSelectBox";
|
||||
import { getStartYearArray } from "@/components/ui/YearRangeSelectBox";
|
||||
import { openWindowPopup } from "@/features/windowPopup/lib/openWindowPopup";
|
||||
import { WINDOW_POPUP_MESSAGE_TYPE } from "@/features/windowPopup/model/types";
|
||||
import {
|
||||
Button,
|
||||
Input,
|
||||
Label,
|
||||
Table,
|
||||
TableBody,
|
||||
TableCell,
|
||||
TableHead,
|
||||
TableHeader,
|
||||
TableRow,
|
||||
} from "@common/components/ui";
|
||||
import { SearchDiv, SelectBox } from "@common/business_components/ui";
|
||||
import type { UiTableSort } from "@common/business_components/ui";
|
||||
import ListDiv from "@common/business_components/ui/list_div";
|
||||
import { useEffect, useMemo, useRef, useState } from "react";
|
||||
import { getExmnBusinesses } from "../api/exmn-business-api";
|
||||
import type {
|
||||
ExmnBusiness,
|
||||
ExmnBusinessSearchParams,
|
||||
} from "../model/exmn-business-types";
|
||||
import { EXMN_SAMPLE_BUSINESSES } from "../model/exmn-sample-data";
|
||||
import { ExmnStatusDetailPanel } from "./ExmnStatusDetailPanel";
|
||||
|
||||
const ACTION_BUTTON_CLASS =
|
||||
"rounded-sm bg-blue-600 px-4 py-2 text-sm font-medium !text-white hover:bg-blue-700 disabled:!text-white";
|
||||
|
||||
// 목록 페이지네이션을 제거하고 한 번에 받아 내부 스크롤로 표시할 최대 사업 건수다.
|
||||
const BUSINESS_LIST_SCROLL_FETCH_SIZE = 1000;
|
||||
|
||||
const BUSINESS_TABLE_HEAD = [
|
||||
{ id: "rowNumber", title: "NO", widthPercent: 5 },
|
||||
{ id: "bizCyclText", title: "사업차수", widthPercent: 7, sortable: true, sortKey: "bizCycl" },
|
||||
{ id: "bizYr", title: "사업년도", widthPercent: 7, sortable: true },
|
||||
{ id: "ctpvNm", title: "시도", widthPercent: 9, sortable: true },
|
||||
{ id: "sggNm", title: "시군구", widthPercent: 9, sortable: true },
|
||||
{ id: "bizNm", title: "사업명", widthPercent: 26, sortable: true },
|
||||
{ id: "trtdstCntText", title: "처리구역·분구(개소)", widthPercent: 12, sortable: true, sortKey: "trtdstCnt" },
|
||||
{ id: "prcisExmnPrlgText", title: "정밀조사 연장(km)", widthPercent: 13, sortable: true, sortKey: "prcisExmnPrlg" },
|
||||
{ id: "reportYnText", title: "보고서", widthPercent: 12, sortable: true, sortKey: "reportYn" },
|
||||
];
|
||||
|
||||
interface BusinessFilters {
|
||||
bizCycl: string;
|
||||
bizYr: string;
|
||||
ctpvCd: string;
|
||||
sggCd: string;
|
||||
ctpvNm: string;
|
||||
sggNm: string;
|
||||
bizNm: string;
|
||||
}
|
||||
|
||||
const EMPTY_FILTERS: BusinessFilters = {
|
||||
bizCycl: "",
|
||||
bizYr: "0",
|
||||
ctpvCd: "0",
|
||||
sggCd: "0",
|
||||
ctpvNm: "",
|
||||
sggNm: "",
|
||||
bizNm: "",
|
||||
};
|
||||
|
||||
type Notice = { type: "success" | "error"; text: string } | null;
|
||||
|
||||
/** 서버·샘플 목록에 같은 비교 규칙을 적용해 정렬 헤더와 실제 행 순서를 일치시킨다. */
|
||||
const sortBusinessList = (
|
||||
source: ExmnBusiness[],
|
||||
nextSort: UiTableSort | undefined,
|
||||
) => {
|
||||
if (!nextSort) return source;
|
||||
|
||||
const getSortValue = (business: ExmnBusiness) => {
|
||||
switch (nextSort.key) {
|
||||
case "bizCycl": return business.bizCycl ?? 0;
|
||||
case "bizYr": return business.bizYr;
|
||||
case "ctpvNm": return business.ctpvNm;
|
||||
case "sggNm": return business.sggNm;
|
||||
case "bizNm": return business.bizNm;
|
||||
case "trtdstCnt": return business.trtdstCnt;
|
||||
case "prcisExmnPrlg": return business.prcisExmnPrlg;
|
||||
case "reportYn": return business.reportYn ?? "N";
|
||||
default: return business.bizNm;
|
||||
}
|
||||
};
|
||||
|
||||
return [...source].sort((left, right) => {
|
||||
const leftValue = getSortValue(left);
|
||||
const rightValue = getSortValue(right);
|
||||
const comparison = typeof leftValue === "number" && typeof rightValue === "number"
|
||||
? leftValue - rightValue
|
||||
: String(leftValue).localeCompare(String(rightValue), "ko");
|
||||
return nextSort.direction === "desc" ? comparison * -1 : comparison;
|
||||
});
|
||||
};
|
||||
|
||||
/** 사업정보 조회·등록과 선택 사업의 조사결과 등록 진입을 제공하는 SFR-010-004 메인 화면이다. */
|
||||
export function ExmnRegistrationPage() {
|
||||
const pageRef = useRef<HTMLDivElement>(null);
|
||||
// 입력 중 조건과 실제 서버 조회 조건을 분리해 정렬 시 동일 조건을 재사용한다.
|
||||
const [draftFilters, setDraftFilters] = useState<BusinessFilters>(EMPTY_FILTERS);
|
||||
const [appliedFilters, setAppliedFilters] = useState<BusinessFilters>(EMPTY_FILTERS);
|
||||
const [businesses, setBusinesses] = useState<ExmnBusiness[]>([]);
|
||||
// 명시적 sample=Y 모드에서 신규 샘플 등록까지 유지하는 부모 창 전용 데이터 원본이다.
|
||||
const [sampleBusinesses, setSampleBusinesses] = useState<ExmnBusiness[]>(EXMN_SAMPLE_BUSINESSES);
|
||||
const [sampleMode, setSampleMode] = useState(false);
|
||||
const [total, setTotal] = useState(0);
|
||||
const [sort, setSort] = useState<UiTableSort>();
|
||||
// 선택 사업 ID는 서버 재조회 뒤에도 같은 행을 복원할 수 있도록 객체와 별도로 유지한다.
|
||||
const [selectedBusinessId, setSelectedBusinessId] = useState<string | null>(null);
|
||||
const [isLoading, setIsLoading] = useState(false);
|
||||
const [notice, setNotice] = useState<Notice>(null);
|
||||
|
||||
/**
|
||||
* 공통 헤더와 레이아웃은 변경하지 않고, 이 화면이 시작되는 지점부터
|
||||
* 뷰포트 하단까지만 조사관리 영역으로 사용한다.
|
||||
* 부모 높이가 확정되어야 상세 그리드의 flex-1 / overflow-y-auto가 동작한다.
|
||||
*/
|
||||
useEffect(() => {
|
||||
const pageElement = pageRef.current;
|
||||
if (!pageElement) return;
|
||||
|
||||
const previousHtmlOverflow = document.documentElement.style.overflow;
|
||||
const previousBodyOverflow = document.body.style.overflow;
|
||||
const footerElement = document.querySelector<HTMLElement>("footer");
|
||||
|
||||
const updatePageHeight = () => {
|
||||
const pageTop = pageElement.getBoundingClientRect().top;
|
||||
const footerHeight = footerElement?.getBoundingClientRect().height ?? 0;
|
||||
const bottomGap = 8;
|
||||
pageElement.style.height = `${Math.max(
|
||||
0,
|
||||
window.innerHeight - pageTop - footerHeight - bottomGap,
|
||||
)}px`;
|
||||
};
|
||||
|
||||
document.documentElement.style.overflow = "hidden";
|
||||
document.body.style.overflow = "hidden";
|
||||
updatePageHeight();
|
||||
window.addEventListener("resize", updatePageHeight);
|
||||
const footerResizeObserver = footerElement
|
||||
? new ResizeObserver(updatePageHeight)
|
||||
: null;
|
||||
footerResizeObserver?.observe(footerElement);
|
||||
|
||||
return () => {
|
||||
window.removeEventListener("resize", updatePageHeight);
|
||||
footerResizeObserver?.disconnect();
|
||||
document.documentElement.style.overflow = previousHtmlOverflow;
|
||||
document.body.style.overflow = previousBodyOverflow;
|
||||
};
|
||||
}, []);
|
||||
|
||||
/** URL에 sample=Y가 명시된 경우에만 실제 API와 분리된 화면 검증용 사업목록을 노출한다. */
|
||||
useEffect(() => {
|
||||
const enabled = new URLSearchParams(window.location.search).get("sample") === "Y";
|
||||
setSampleMode(enabled);
|
||||
if (!enabled) return;
|
||||
setBusinesses(sampleBusinesses);
|
||||
setTotal(sampleBusinesses.length);
|
||||
}, []);
|
||||
|
||||
const selectedBusiness = useMemo(
|
||||
() => businesses.find((business) => business.exmnBizId === selectedBusinessId) ?? null,
|
||||
[businesses, selectedBusinessId],
|
||||
);
|
||||
|
||||
/** 조사사업 목록 헤더의 정렬 상태를 오름차순·내림차순·기본 순서로 순환한다. */
|
||||
const handleBusinessSort = (head: (typeof BUSINESS_TABLE_HEAD)[number]) => {
|
||||
if (!("sortable" in head) || !head.sortable) return;
|
||||
|
||||
const key = "sortKey" in head && head.sortKey ? head.sortKey : head.id;
|
||||
const nextSort: UiTableSort | undefined = sort?.key !== key
|
||||
? { key, direction: "asc" }
|
||||
: sort.direction === "asc"
|
||||
? { key, direction: "desc" }
|
||||
: undefined;
|
||||
setSort(nextSort);
|
||||
void fetchBusinesses(appliedFilters, nextSort);
|
||||
};
|
||||
|
||||
/** 개발 환경에서만 샘플 데이터 모드를 명시적으로 켜고 끈다. */
|
||||
const switchSampleMode = (enabled: boolean) => {
|
||||
const url = new URL(window.location.href);
|
||||
if (enabled) url.searchParams.set("sample", "Y");
|
||||
else url.searchParams.delete("sample");
|
||||
window.location.assign(`${url.pathname}${url.search}`);
|
||||
};
|
||||
|
||||
/** 사업 목록의 숫자·행정구역·보고서 여부를 표시 전용 값으로 변환한다. */
|
||||
const businessRows = useMemo(() => businesses.map((business, index) => ({
|
||||
...business,
|
||||
rowNumber: index + 1,
|
||||
bizCyclText: business.bizCycl == null ? "-" : `${business.bizCycl}차`,
|
||||
trtdstCntText: Number(business.trtdstCnt ?? 0).toLocaleString("ko-KR"),
|
||||
prcisExmnPrlgText: Number(business.prcisExmnPrlg ?? 0).toLocaleString("ko-KR", { maximumFractionDigits: 3 }),
|
||||
reportYnText: business.reportYn === "Y" || business.bizRptpAtchFileId ? "있음" : "없음",
|
||||
})), [businesses]);
|
||||
|
||||
/** 화면 조건을 행정구역 이름과 목록 내부 스크롤용 단일 서버 Page 계약으로 변환한다. */
|
||||
const buildSearchParams = (
|
||||
filters: BusinessFilters,
|
||||
nextSort: UiTableSort | undefined,
|
||||
): ExmnBusinessSearchParams => ({
|
||||
bizCycl: filters.bizCycl || undefined,
|
||||
bizYr: filters.bizYr !== "0" ? filters.bizYr : undefined,
|
||||
ctpvNm: filters.ctpvNm || undefined,
|
||||
sggNm: filters.sggNm || undefined,
|
||||
bizNm: filters.bizNm || undefined,
|
||||
page: 0,
|
||||
size: BUSINESS_LIST_SCROLL_FETCH_SIZE,
|
||||
sort: nextSort ? `${nextSort.key},${nextSort.direction}` : undefined,
|
||||
});
|
||||
|
||||
/** 샘플 원본에 실제 조회조건과 조사사업 목록의 공통 비교 규칙을 적용한다. */
|
||||
const getSampleBusinessPage = (
|
||||
filters: BusinessFilters,
|
||||
nextSort: UiTableSort | undefined,
|
||||
) => {
|
||||
const filtered = sampleBusinesses.filter((business) => (
|
||||
(!filters.bizCycl || String(business.bizCycl ?? "") === filters.bizCycl)
|
||||
&& (filters.bizYr === "0" || business.bizYr === filters.bizYr)
|
||||
&& (!filters.ctpvNm || business.ctpvNm === filters.ctpvNm)
|
||||
&& (!filters.sggNm || business.sggNm === filters.sggNm)
|
||||
&& (!filters.bizNm || business.bizNm.includes(filters.bizNm.trim()))
|
||||
));
|
||||
return sortBusinessList(filtered, nextSort);
|
||||
};
|
||||
|
||||
/** 지정 조회조건·정렬로 스크롤 표시용 사업 목록을 읽고 선택 행을 가능한 경우 유지한다. */
|
||||
const fetchBusinesses = async (
|
||||
filters: BusinessFilters,
|
||||
nextSort = sort,
|
||||
) => {
|
||||
setIsLoading(true);
|
||||
setNotice(null);
|
||||
try {
|
||||
if (sampleMode) {
|
||||
const content = getSampleBusinessPage(filters, nextSort);
|
||||
setBusinesses(content);
|
||||
setTotal(content.length);
|
||||
setSelectedBusinessId((current) => content.some((item) => item.exmnBizId === current) ? current : null);
|
||||
return true;
|
||||
}
|
||||
const result = await getExmnBusinesses(buildSearchParams(filters, nextSort));
|
||||
const content = sortBusinessList(result.content, nextSort);
|
||||
setBusinesses(content);
|
||||
setTotal(result.totalElements);
|
||||
setSelectedBusinessId((current) => content.some((item) => item.exmnBizId === current) ? current : null);
|
||||
return true;
|
||||
} catch (error) {
|
||||
setBusinesses([]);
|
||||
setTotal(0);
|
||||
setSelectedBusinessId(null);
|
||||
setNotice({
|
||||
type: "error",
|
||||
text: error instanceof Error ? error.message : "조사사업 목록 조회 중 오류가 발생했습니다.",
|
||||
});
|
||||
return false;
|
||||
} finally {
|
||||
setIsLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
/** 입력 중 조건을 확정하고 첫 페이지를 조회한다. */
|
||||
const handleSearch = async () => {
|
||||
setAppliedFilters(draftFilters);
|
||||
await fetchBusinesses(draftFilters, sort);
|
||||
};
|
||||
|
||||
/** 공통 WindowPopup 체계로 사업정보 등록 화면을 연다. */
|
||||
const openBusinessRegister = () => {
|
||||
setNotice(null);
|
||||
const popup = openWindowPopup("exmnBusinessRegister", {
|
||||
sample: sampleMode ? "Y" : undefined,
|
||||
sourcePath: window.location.pathname,
|
||||
});
|
||||
if (!popup) {
|
||||
setNotice({ type: "error", text: "사업등록 팝업이 차단되었습니다. 브라우저 팝업 허용 설정을 확인해주세요." });
|
||||
}
|
||||
};
|
||||
|
||||
/** 선택 사업 컨텍스트를 전달해 공통 WindowPopup 결과등록 화면을 연다. */
|
||||
const openResultRegister = () => {
|
||||
if (!selectedBusiness) {
|
||||
setNotice({ type: "error", text: "사업 목록에서 대상 사업을 먼저 선택해주세요." });
|
||||
return;
|
||||
}
|
||||
setNotice(null);
|
||||
const popup = openWindowPopup("exmnResultRegister", {
|
||||
exmnBizId: selectedBusiness.exmnBizId,
|
||||
bizYr: selectedBusiness.bizYr,
|
||||
bizCycl: selectedBusiness.bizCycl ?? 0,
|
||||
bizNm: selectedBusiness.bizNm,
|
||||
ctpvNm: selectedBusiness.ctpvNm,
|
||||
sggNm: selectedBusiness.sggNm,
|
||||
trtdstCnt: selectedBusiness.trtdstCnt,
|
||||
prcisExmnPrlg: selectedBusiness.prcisExmnPrlg,
|
||||
tpco: selectedBusiness.tpco,
|
||||
reportYn: selectedBusiness.reportYn ?? "N",
|
||||
sample: sampleMode ? "Y" : undefined,
|
||||
sourcePath: window.location.pathname,
|
||||
});
|
||||
if (!popup) {
|
||||
setNotice({ type: "error", text: "결과등록 팝업이 차단되었습니다. 브라우저 팝업 허용 설정을 확인해주세요." });
|
||||
}
|
||||
};
|
||||
|
||||
/** WindowPopup의 등록·저장 완료 메시지를 받아 부모 목록과 안내 상태를 동기화한다. */
|
||||
useEffect(() => {
|
||||
const handlePopupMessage = (event: MessageEvent) => {
|
||||
if (event.origin !== window.location.origin) return;
|
||||
if (event.data?.type === WINDOW_POPUP_MESSAGE_TYPE.EXMN_BUSINESS_CREATED) {
|
||||
const payload = event.data?.payload as { exmnBizId?: string; sample?: string; business?: string } | undefined;
|
||||
if (payload?.sample === "Y" && payload.business) {
|
||||
try {
|
||||
const created = JSON.parse(payload.business) as ExmnBusiness;
|
||||
setSampleBusinesses((current) => [created, ...current.filter((item) => item.exmnBizId !== created.exmnBizId)]);
|
||||
setBusinesses((current) => [created, ...current.filter((item) => item.exmnBizId !== created.exmnBizId)]);
|
||||
setTotal((current) => current + 1);
|
||||
setSelectedBusinessId(created.exmnBizId);
|
||||
setNotice({ type: "success", text: "샘플 조사사업이 등록되었습니다." });
|
||||
} catch {
|
||||
setNotice({ type: "error", text: "샘플 사업정보를 부모 목록에 반영하지 못했습니다." });
|
||||
}
|
||||
return;
|
||||
}
|
||||
setSelectedBusinessId(payload?.exmnBizId ?? null);
|
||||
void fetchBusinesses(appliedFilters, sort).then((refreshed) => {
|
||||
if (refreshed) setNotice({ type: "success", text: "조사사업이 등록되었습니다." });
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
if (event.data?.type === WINDOW_POPUP_MESSAGE_TYPE.EXMN_RESULT_COMMITTED) {
|
||||
const payload = event.data?.payload as { sample?: string } | undefined;
|
||||
if (payload?.sample === "Y") {
|
||||
setNotice({ type: "success", text: "샘플 조사결과 저장 흐름을 완료했습니다." });
|
||||
return;
|
||||
}
|
||||
void fetchBusinesses(appliedFilters, sort).then((refreshed) => {
|
||||
if (refreshed) setNotice({ type: "success", text: "조사결과 최종 저장과 PPL/MNHL 집계를 완료했습니다." });
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
window.addEventListener("message", handlePopupMessage);
|
||||
return () => window.removeEventListener("message", handlePopupMessage);
|
||||
}, [appliedFilters, sort]);
|
||||
|
||||
return (
|
||||
<div
|
||||
ref={pageRef}
|
||||
className="sn-exmn-registration-page flex min-h-0 flex-col overflow-hidden"
|
||||
>
|
||||
<LoadingOverlay isLoading={isLoading} />
|
||||
<SearchDiv>
|
||||
<div className="flex w-full flex-wrap items-center gap-y-3 px-5 py-4">
|
||||
<div className="mr-8 flex items-center gap-2">
|
||||
<Label className="whitespace-nowrap text-xl font-medium text-gray-600">사업차수</Label>
|
||||
<Input type="number" min={0} className="h-[3.2rem] w-24 text-base" value={draftFilters.bizCycl} onChange={(event) => setDraftFilters((current) => ({ ...current, bizCycl: event.target.value }))} />
|
||||
</div>
|
||||
<SelectBox
|
||||
label="사업년도"
|
||||
labelClass="whitespace-nowrap text-xl font-medium text-gray-600"
|
||||
className="mr-8 flex items-center gap-2"
|
||||
selectClass="w-28 rounded-lg bg-white"
|
||||
selectArray={getStartYearArray({ placeholder: "전체" })}
|
||||
value={draftFilters.bizYr}
|
||||
onSelectValue={(value) => setDraftFilters((current) => ({ ...current, bizYr: String(value) }))}
|
||||
/>
|
||||
<CtpvSggPrcsFcltSelectBox
|
||||
sido={draftFilters.ctpvCd}
|
||||
sigungu={draftFilters.sggCd}
|
||||
onSidoChange={(ctpvCd) => setDraftFilters((current) => ({
|
||||
...current,
|
||||
ctpvCd,
|
||||
sggCd: "0",
|
||||
sggNm: "",
|
||||
}))}
|
||||
onSigunguChange={(sggCd) => setDraftFilters((current) => ({ ...current, sggCd }))}
|
||||
setCtpvName={(ctpvNm) => setDraftFilters((current) => ({
|
||||
...current,
|
||||
ctpvNm: ctpvNm === "전체" ? "" : ctpvNm,
|
||||
}))}
|
||||
setSggName={(sggNm) => setDraftFilters((current) => ({
|
||||
...current,
|
||||
sggNm: sggNm === "전체" ? "" : sggNm,
|
||||
}))}
|
||||
/>
|
||||
<div className="mr-8 flex items-center gap-2">
|
||||
<Label className="whitespace-nowrap text-xl font-medium text-gray-600">사업명</Label>
|
||||
<Input className="h-[3.2rem] w-52 text-base" value={draftFilters.bizNm} onChange={(event) => setDraftFilters((current) => ({ ...current, bizNm: event.target.value }))} onKeyDown={(event) => { if (event.key === "Enter") void handleSearch(); }} />
|
||||
</div>
|
||||
<div className="ml-auto flex shrink-0 items-center gap-2">
|
||||
<Button className={ACTION_BUTTON_CLASS} size="sm" disabled={isLoading} onClick={() => void handleSearch()}>조회</Button>
|
||||
<Button className={ACTION_BUTTON_CLASS} size="sm" disabled={isLoading} onClick={openBusinessRegister}>사업등록</Button>
|
||||
<Button className={ACTION_BUTTON_CLASS} size="sm" disabled={isLoading} onClick={openResultRegister}>결과등록</Button>
|
||||
</div>
|
||||
</div>
|
||||
</SearchDiv>
|
||||
|
||||
{notice && (
|
||||
<div role="alert" className={`mx-[1.2rem] mt-4 rounded border px-4 py-3 text-sm ${notice.type === "error" ? "border-red-200 bg-red-50 text-red-700" : "border-emerald-200 bg-emerald-50 text-emerald-700"}`}>{notice.text}</div>
|
||||
)}
|
||||
|
||||
<ListDiv className="mt-4 min-h-0 shrink-0 overflow-hidden">
|
||||
<div className="card-header flex items-center justify-between">
|
||||
<h3 className="text-2xl font-medium">조사사업 목록 <span className="text-base text-gray-500">총 {total.toLocaleString("ko-KR")}건</span></h3>
|
||||
{process.env.NODE_ENV === "development" && (
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={() => switchSampleMode(!sampleMode)}
|
||||
>
|
||||
{sampleMode ? "실데이터 보기" : "샘플 데이터 보기"}
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
<div className="relative" style={{ marginTop: "0.6rem" }}>
|
||||
<div
|
||||
className="overflow-x-hidden overflow-y-auto"
|
||||
style={{
|
||||
maxHeight: "16rem",
|
||||
paddingLeft: "0.6rem",
|
||||
paddingRight: "0.6rem",
|
||||
scrollbarGutter: "stable",
|
||||
}}
|
||||
>
|
||||
<Table variant="secondary" className="table-fixed">
|
||||
<colgroup>
|
||||
{BUSINESS_TABLE_HEAD.map((head) => (
|
||||
<col key={head.id} style={{ width: `${head.widthPercent}%` }} />
|
||||
))}
|
||||
</colgroup>
|
||||
<TableHeader
|
||||
className="font-bold"
|
||||
style={{ position: "static", zIndex: "auto" }}
|
||||
>
|
||||
<TableRow>
|
||||
{BUSINESS_TABLE_HEAD.map((head) => {
|
||||
const sortKey = "sortKey" in head && head.sortKey ? head.sortKey : head.id;
|
||||
const direction = sort?.key === sortKey ? sort.direction : undefined;
|
||||
const sortable = "sortable" in head && head.sortable;
|
||||
return (
|
||||
<TableHead
|
||||
key={head.id}
|
||||
className="sticky top-0 z-20 bg-[#eaf3fb] text-center"
|
||||
style={{
|
||||
borderTopWidth: 0,
|
||||
boxShadow: "inset 0 1px 0 var(--border)",
|
||||
}}
|
||||
aria-sort={
|
||||
sortable
|
||||
? direction === "asc"
|
||||
? "ascending"
|
||||
: direction === "desc"
|
||||
? "descending"
|
||||
: "none"
|
||||
: undefined
|
||||
}
|
||||
>
|
||||
{sortable ? (
|
||||
<button
|
||||
type="button"
|
||||
className="inline-flex w-full items-center justify-center gap-1.5"
|
||||
aria-label={`${head.title} 정렬`}
|
||||
onClick={() => handleBusinessSort(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>
|
||||
) : head.title}
|
||||
</TableHead>
|
||||
);
|
||||
})}
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody className="text-center">
|
||||
{businessRows.length > 0 ? businessRows.map((business) => {
|
||||
const selected = business.exmnBizId === selectedBusinessId;
|
||||
return (
|
||||
<TableRow
|
||||
key={business.exmnBizId}
|
||||
data-state={selected ? "selected" : undefined}
|
||||
className="cursor-pointer"
|
||||
onClick={() => setSelectedBusinessId(business.exmnBizId)}
|
||||
>
|
||||
{BUSINESS_TABLE_HEAD.map((head) => (
|
||||
<TableCell key={head.id} className="!p-0">
|
||||
<button
|
||||
type="button"
|
||||
className={`flex min-h-[4rem] w-full cursor-pointer items-center justify-center px-2 py-[1.2rem] text-[1.3rem] ${selected ? "font-bold" : "font-normal"}`}
|
||||
onClick={() => setSelectedBusinessId(business.exmnBizId)}
|
||||
>
|
||||
{String(business[head.id as keyof typeof business] ?? "")}
|
||||
</button>
|
||||
</TableCell>
|
||||
))}
|
||||
</TableRow>
|
||||
);
|
||||
}) : (
|
||||
<TableRow>
|
||||
<TableCell colSpan={BUSINESS_TABLE_HEAD.length} className="h-24 text-center text-gray-500">
|
||||
조회된 결과가 없습니다.
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
)}
|
||||
</TableBody>
|
||||
</Table>
|
||||
</div>
|
||||
<span
|
||||
aria-hidden="true"
|
||||
className="pointer-events-none absolute z-30"
|
||||
style={{
|
||||
right: "calc(0.6rem + 17px)",
|
||||
bottom: 0,
|
||||
left: "0.6rem",
|
||||
borderTop: "1px solid var(--border)",
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
</ListDiv>
|
||||
<ExmnStatusDetailPanel business={selectedBusiness} sampleMode={sampleMode} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,422 @@
|
||||
"use client";
|
||||
|
||||
import { handleUpload } from "@/components/lib/utils";
|
||||
import LoadingOverlay from "@/components/ui/LoadingBar";
|
||||
import { postPopupMessageAndClose } from "@/features/windowPopup/lib/popupMessage";
|
||||
import { WINDOW_POPUP_MESSAGE_TYPE } from "@/features/windowPopup/model/types";
|
||||
import { Button } from "@common/components/ui";
|
||||
import { UiTable } from "@common/business_components/ui";
|
||||
import type { HttpResponse } from "tus-js-client";
|
||||
import { useEffect, useMemo, useState } from "react";
|
||||
import {
|
||||
commitExmnImport,
|
||||
getExmnBusiness,
|
||||
updateExmnAttachments,
|
||||
validateExmnImport,
|
||||
} from "../api/exmn-business-api";
|
||||
import {
|
||||
EXMN_FILE_RULES,
|
||||
validateExmnFile,
|
||||
validateExmnFiles,
|
||||
} from "../lib/exmn-business-validation";
|
||||
import {
|
||||
EXMN_SAMPLE_IMPORT_VALIDATION,
|
||||
getExmnSampleBusinessDetail,
|
||||
} from "../model/exmn-sample-data";
|
||||
import type {
|
||||
ExmnAttachmentKey,
|
||||
ExmnBusiness,
|
||||
ExmnBusinessDetail,
|
||||
ExmnFileType,
|
||||
ExmnImportValidationResult,
|
||||
} from "../model/exmn-business-types";
|
||||
|
||||
const ACTION_BUTTON_CLASS =
|
||||
"rounded-sm bg-blue-600 px-3 py-1.5 text-sm font-medium !text-white hover:bg-blue-700 disabled:!text-white";
|
||||
|
||||
const FILE_TYPES: ExmnFileType[] = [
|
||||
"EXMN_EXCEL",
|
||||
"PPL_EXMN_EXCEL",
|
||||
"MNHL_EXMN_EXCEL",
|
||||
"BIZ_REPORT",
|
||||
"LOCATION_MAP",
|
||||
];
|
||||
|
||||
const VALIDATION_BUTTON_FILE_TYPES = new Set<ExmnFileType>([
|
||||
"EXMN_EXCEL",
|
||||
"PPL_EXMN_EXCEL",
|
||||
"MNHL_EXMN_EXCEL",
|
||||
]);
|
||||
|
||||
const ERROR_TABLE_HEAD = [
|
||||
{ id: "rowNumberText", title: "행", widthPercent: 7 },
|
||||
{ id: "sheetName", title: "시트/처리분구", widthPercent: 17 },
|
||||
{ id: "gisManagementNo", title: "GIS 관리번호", widthPercent: 16 },
|
||||
{ id: "fieldPath", title: "필드/JSON 경로", widthPercent: 24 },
|
||||
{ id: "code", title: "오류코드", widthPercent: 13 },
|
||||
{ id: "message", title: "오류사유", widthPercent: 23 },
|
||||
];
|
||||
|
||||
interface SelectedFile {
|
||||
file: File;
|
||||
fileList: FileList;
|
||||
}
|
||||
|
||||
type SelectedFiles = Partial<Record<ExmnFileType, SelectedFile>>;
|
||||
type Notice = { type: "success" | "error" | "info"; text: string } | null;
|
||||
|
||||
interface ExmnResultRegisterPopupPageProps {
|
||||
exmnBizId: string;
|
||||
sampleMode: boolean;
|
||||
businessSummary: ExmnBusiness;
|
||||
}
|
||||
|
||||
/** 선택한 조사 원본을 validate·commit 요청에 같은 필드명으로 담는다. */
|
||||
const buildImportFormData = (
|
||||
files: SelectedFiles,
|
||||
validation?: ExmnImportValidationResult | null,
|
||||
attachments?: Partial<Record<ExmnFileType, ExmnAttachmentKey>>,
|
||||
) => {
|
||||
const formData = new FormData();
|
||||
if (files.PPL_EXMN_EXCEL) formData.append("pplFile", files.PPL_EXMN_EXCEL.file);
|
||||
if (files.MNHL_EXMN_EXCEL) formData.append("mnhlFile", files.MNHL_EXMN_EXCEL.file);
|
||||
if (validation?.fileHash) formData.append("fileHash", validation.fileHash);
|
||||
if (attachments) formData.append("attachments", JSON.stringify(attachments));
|
||||
return formData;
|
||||
};
|
||||
|
||||
/** 기존 Tus 첨부서비스로 파일 한 건을 전송하고 서버 파일키를 반환한다. */
|
||||
const uploadAttachment = (
|
||||
selected: SelectedFile,
|
||||
onProgress: (percentage: string) => void,
|
||||
): Promise<ExmnAttachmentKey> => new Promise((resolve, reject) => {
|
||||
let settled = false;
|
||||
const rejectOnce = (error: Error) => {
|
||||
if (settled) return;
|
||||
settled = true;
|
||||
reject(error);
|
||||
};
|
||||
const resolveFromResponse = (response: HttpResponse) => {
|
||||
if (settled) return;
|
||||
try {
|
||||
const fileInfo = response.getHeader("X-File-Info");
|
||||
if (!fileInfo) throw new Error("첨부서비스가 파일키를 반환하지 않았습니다.");
|
||||
const parsed = JSON.parse(fileInfo) as { atflCd?: string; atflId?: string };
|
||||
if (!parsed.atflCd || !parsed.atflId) throw new Error("첨부서비스 파일키 형식이 올바르지 않습니다.");
|
||||
settled = true;
|
||||
resolve({ atflCd: parsed.atflCd, atflId: parsed.atflId, fileName: selected.file.name });
|
||||
} catch (error) {
|
||||
rejectOnce(error instanceof Error ? error : new Error("첨부파일 응답을 해석할 수 없습니다."));
|
||||
}
|
||||
};
|
||||
|
||||
handleUpload(selected.fileList, {
|
||||
handleProgress: onProgress,
|
||||
handleResponse: resolveFromResponse,
|
||||
handleError: rejectOnce,
|
||||
});
|
||||
});
|
||||
|
||||
/** 공통 WindowPopup 안에서 조사 파일 5종을 선택·검토·최종 저장하는 화면이다. */
|
||||
export function ExmnResultRegisterPopupPage({
|
||||
exmnBizId,
|
||||
sampleMode,
|
||||
businessSummary,
|
||||
}: ExmnResultRegisterPopupPageProps) {
|
||||
const [detail, setDetail] = useState<ExmnBusinessDetail | null>(null);
|
||||
const [files, setFiles] = useState<SelectedFiles>({});
|
||||
const [validation, setValidation] = useState<ExmnImportValidationResult | null>(null);
|
||||
const [isLoading, setIsLoading] = useState(false);
|
||||
const [uploadStatus, setUploadStatus] = useState("");
|
||||
const [notice, setNotice] = useState<Notice>(null);
|
||||
|
||||
/** popup layout 상위 html/body에서 생기는 문서 스크롤바를 팝업이 열린 동안 잠근다. */
|
||||
useEffect(() => {
|
||||
const previousHtmlOverflow = document.documentElement.style.overflow;
|
||||
const previousBodyOverflow = document.body.style.overflow;
|
||||
const previousBodyWidth = document.body.style.width;
|
||||
document.documentElement.style.overflow = "hidden";
|
||||
document.body.style.overflow = "hidden";
|
||||
document.body.style.width = "100%";
|
||||
return () => {
|
||||
document.documentElement.style.overflow = previousHtmlOverflow;
|
||||
document.body.style.overflow = previousBodyOverflow;
|
||||
document.body.style.width = previousBodyWidth;
|
||||
};
|
||||
}, []);
|
||||
|
||||
/** 검토 오류를 UiTable 표시용 문자열 행으로 정규화한다. */
|
||||
const validationRows = useMemo(() => (validation?.errors ?? []).map((error) => ({
|
||||
...error,
|
||||
rowNumberText: error.rowNumber == null ? "-" : String(error.rowNumber),
|
||||
sheetName: error.sheetName || error.trtdstNm || error.trtdstId || "-",
|
||||
gisManagementNo: error.gisManagementNo || "-",
|
||||
fieldPath: error.fieldPath || "-",
|
||||
})), [validation]);
|
||||
|
||||
/** 팝업 진입 시 선택 사업의 처리분구·첨부 상세를 읽는다. */
|
||||
useEffect(() => {
|
||||
if (!exmnBizId) {
|
||||
setNotice({ type: "error", text: "결과를 등록할 조사사업 식별자가 없습니다." });
|
||||
return;
|
||||
}
|
||||
if (sampleMode) {
|
||||
setDetail(getExmnSampleBusinessDetail(exmnBizId, businessSummary));
|
||||
return;
|
||||
}
|
||||
|
||||
let active = true;
|
||||
setIsLoading(true);
|
||||
getExmnBusiness(exmnBizId)
|
||||
.then((result) => {
|
||||
if (active) setDetail(result);
|
||||
})
|
||||
.catch((error: unknown) => {
|
||||
if (!active) return;
|
||||
setNotice({
|
||||
type: "error",
|
||||
text: error instanceof Error ? error.message : "선택 사업 상세 조회 중 오류가 발생했습니다.",
|
||||
});
|
||||
})
|
||||
.finally(() => {
|
||||
if (active) setIsLoading(false);
|
||||
});
|
||||
return () => {
|
||||
active = false;
|
||||
};
|
||||
}, [businessSummary, exmnBizId, sampleMode]);
|
||||
|
||||
/** 파일 변경 시 이전 검토결과를 폐기하고 확장자·크기 규칙을 즉시 확인한다. */
|
||||
const handleFileChange = (fileType: ExmnFileType, fileList: FileList | null) => {
|
||||
setValidation(null);
|
||||
setUploadStatus("");
|
||||
setFiles((current) => {
|
||||
const next = { ...current };
|
||||
delete next[fileType];
|
||||
return next;
|
||||
});
|
||||
if (!fileList?.[0]) return;
|
||||
const file = fileList[0];
|
||||
const validationMessage = validateExmnFile(fileType, file);
|
||||
if (validationMessage) {
|
||||
setNotice({ type: "error", text: validationMessage });
|
||||
return;
|
||||
}
|
||||
setFiles((current) => ({ ...current, [fileType]: { file, fileList } }));
|
||||
setNotice(null);
|
||||
};
|
||||
|
||||
/** 실제 모드는 서버 검토를 호출하고 샘플 모드는 명시적 정상 검토 결과만 화면에 제공한다. */
|
||||
const handleValidate = async () => {
|
||||
if (sampleMode) {
|
||||
setValidation(EXMN_SAMPLE_IMPORT_VALIDATION);
|
||||
setNotice({
|
||||
type: "success",
|
||||
text: `샘플 데이터 검토 완료: PPL ${EXMN_SAMPLE_IMPORT_VALIDATION.summary.pplCount}건, MNHL ${EXMN_SAMPLE_IMPORT_VALIDATION.summary.mnhlCount}건`,
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
const fileMap = Object.fromEntries(
|
||||
Object.entries(files).map(([type, selected]) => [type, selected?.file]),
|
||||
) as Partial<Record<ExmnFileType, File>>;
|
||||
const validationMessage = validateExmnFiles(fileMap);
|
||||
if (validationMessage) {
|
||||
setNotice({ type: "error", text: validationMessage });
|
||||
return;
|
||||
}
|
||||
|
||||
setIsLoading(true);
|
||||
setNotice(null);
|
||||
try {
|
||||
const result = await validateExmnImport(exmnBizId, buildImportFormData(files));
|
||||
setValidation(result);
|
||||
setNotice({
|
||||
type: result.valid ? "success" : "error",
|
||||
text: result.valid
|
||||
? `검토가 완료되었습니다. PPL ${result.summary.pplCount}건, MNHL ${result.summary.mnhlCount}건`
|
||||
: `검토 오류 ${result.errors.length}건을 확인해주세요.`,
|
||||
});
|
||||
} catch (error) {
|
||||
setValidation(null);
|
||||
setNotice({
|
||||
type: "error",
|
||||
text: error instanceof Error ? error.message : "조사파일 검토 중 오류가 발생했습니다.",
|
||||
});
|
||||
} finally {
|
||||
setIsLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
/** 모든 파일을 첨부서비스에 올리고 발급 키를 조사사업에 연결한다. */
|
||||
const uploadAndLinkAttachments = async (): Promise<Partial<Record<ExmnFileType, ExmnAttachmentKey>>> => {
|
||||
const attachments: Partial<Record<ExmnFileType, ExmnAttachmentKey>> = {};
|
||||
for (const fileType of FILE_TYPES) {
|
||||
const selected = files[fileType];
|
||||
if (!selected) continue;
|
||||
const label = EXMN_FILE_RULES[fileType].label;
|
||||
attachments[fileType] = await uploadAttachment(selected, (percentage) => {
|
||||
setUploadStatus(`${label} 업로드 ${percentage}%`);
|
||||
});
|
||||
}
|
||||
await updateExmnAttachments(exmnBizId, attachments);
|
||||
return attachments;
|
||||
};
|
||||
|
||||
/** 정상 검토된 파일을 저장하고 부모 화면에 완료 메시지를 보낸 뒤 팝업을 닫는다. */
|
||||
const handleCommit = async () => {
|
||||
if (!validation?.valid) return;
|
||||
if (sampleMode) {
|
||||
postPopupMessageAndClose(WINDOW_POPUP_MESSAGE_TYPE.EXMN_RESULT_COMMITTED, {
|
||||
exmnBizId,
|
||||
sample: "Y",
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
setIsLoading(true);
|
||||
setNotice(null);
|
||||
try {
|
||||
const attachments = await uploadAndLinkAttachments();
|
||||
await commitExmnImport(exmnBizId, buildImportFormData(files, validation, attachments));
|
||||
postPopupMessageAndClose(WINDOW_POPUP_MESSAGE_TYPE.EXMN_RESULT_COMMITTED, {
|
||||
exmnBizId,
|
||||
sample: "N",
|
||||
});
|
||||
} catch (error) {
|
||||
setNotice({
|
||||
type: "error",
|
||||
text: error instanceof Error ? error.message : "조사결과 최종 저장 중 오류가 발생했습니다.",
|
||||
});
|
||||
} finally {
|
||||
setIsLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="flex h-dvh w-full max-w-full flex-col overflow-hidden bg-[#f3f7fa] p-2">
|
||||
<LoadingOverlay isLoading={isLoading} />
|
||||
<header className="flex shrink-0 items-center justify-between rounded-t border border-[#d7e0e8] bg-white px-4 py-2.5">
|
||||
<h1 className="text-xl font-semibold text-gray-800">조사 결과 업로드</h1>
|
||||
<div>
|
||||
<Button className={ACTION_BUTTON_CLASS} size="sm" disabled={isLoading || !validation?.valid} onClick={() => void handleCommit()}>저장</Button>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<main className="min-h-0 flex-1 space-y-2 overflow-x-hidden overflow-y-auto border-x border-b border-[#d7e0e8] bg-white p-2">
|
||||
{notice && (
|
||||
<div role="alert" className={`rounded border px-4 py-3 text-sm ${
|
||||
notice.type === "error"
|
||||
? "border-red-200 bg-red-50 text-red-700"
|
||||
: notice.type === "success"
|
||||
? "border-emerald-200 bg-emerald-50 text-emerald-700"
|
||||
: "border-blue-200 bg-blue-50 text-blue-700"
|
||||
}`}>{notice.text}</div>
|
||||
)}
|
||||
|
||||
<section
|
||||
className="grid gap-8 rounded border border-[#d7e0e8] bg-[#f8fafc] px-5 py-2.5 text-sm"
|
||||
style={{ gridTemplateColumns: "0.7fr 0.8fr 1.2fr 2.3fr" }}
|
||||
>
|
||||
<div className="flex items-center gap-2">
|
||||
<strong className="shrink-0 text-gray-700">사업차수</strong>
|
||||
<span className="text-gray-600">{detail?.bizCycl ?? businessSummary.bizCycl ?? "-"}차</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<strong className="shrink-0 text-gray-700">사업년도</strong>
|
||||
<span className="text-gray-600">{detail?.bizYr ?? businessSummary.bizYr ?? "-"}</span>
|
||||
</div>
|
||||
<div className="flex min-w-0 items-center gap-2">
|
||||
<strong className="shrink-0 text-gray-700">행정구역</strong>
|
||||
<span className="truncate text-gray-600">{detail?.ctpvNm ?? businessSummary.ctpvNm} {detail?.sggNm ?? businessSummary.sggNm}</span>
|
||||
</div>
|
||||
<div className="flex min-w-0 items-center gap-2">
|
||||
<strong className="shrink-0 text-gray-700">사업명</strong>
|
||||
<span className="truncate text-gray-600" title={detail?.bizNm ?? businessSummary.bizNm}>{detail?.bizNm ?? businessSummary.bizNm ?? "-"}</span>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section className="overflow-hidden rounded border border-[#d7e0e8]">
|
||||
<div
|
||||
className="grid items-center gap-3 bg-[#e8f2fb] px-3 py-2.5 text-center text-sm font-semibold text-gray-700"
|
||||
style={{ gridTemplateColumns: "180px minmax(0, 1fr) 80px 112px" }}
|
||||
>
|
||||
<span>파일 구분</span><span>선택 파일</span><span>찾기</span><span>데이터 검토</span>
|
||||
</div>
|
||||
{FILE_TYPES.map((fileType) => {
|
||||
const rule = EXMN_FILE_RULES[fileType];
|
||||
const selected = files[fileType];
|
||||
const inputId = `exmn-popup-file-${fileType}`;
|
||||
const canValidate = VALIDATION_BUTTON_FILE_TYPES.has(fileType);
|
||||
return (
|
||||
<div
|
||||
key={fileType}
|
||||
className="grid items-center gap-3 border-t border-[#d7e0e8] px-3 py-3 text-sm"
|
||||
style={{ gridTemplateColumns: "180px minmax(0, 1fr) 80px 112px" }}
|
||||
>
|
||||
<label htmlFor={inputId} className="font-medium text-gray-700">
|
||||
{(fileType === "PPL_EXMN_EXCEL" || fileType === "MNHL_EXMN_EXCEL") && <span className="text-red-500">* </span>}
|
||||
{rule.label}
|
||||
</label>
|
||||
<div
|
||||
className={`truncate border px-3 py-1.5 ${selected
|
||||
? "border-blue-300 bg-blue-50 font-semibold text-blue-700"
|
||||
: "border-[#9fb2c4] bg-white text-gray-600"
|
||||
}`}
|
||||
title={selected?.file.name}
|
||||
>
|
||||
{selected?.file.name || `${rule.extensions.map((extension) => `.${extension}`).join(", ")} 파일`}
|
||||
</div>
|
||||
<div className="text-center">
|
||||
<input
|
||||
id={inputId}
|
||||
type="file"
|
||||
className="sr-only"
|
||||
accept={rule.extensions.map((extension) => `.${extension}`).join(",")}
|
||||
disabled={isLoading}
|
||||
onChange={(event) => handleFileChange(fileType, event.target.files)}
|
||||
/>
|
||||
<label htmlFor={inputId} className="inline-flex cursor-pointer rounded-sm bg-blue-600 px-3 py-1.5 font-medium text-white hover:bg-blue-700">찾기</label>
|
||||
</div>
|
||||
<div className="text-center">
|
||||
{canValidate ? (
|
||||
<Button className={ACTION_BUTTON_CLASS} size="sm" disabled={isLoading} onClick={() => void handleValidate()}>데이터 검토</Button>
|
||||
) : <span className="text-gray-400">-</span>}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</section>
|
||||
|
||||
{validation && (
|
||||
<section className="rounded border border-[#d7e0e8] p-4">
|
||||
<div className="mb-3 flex items-center justify-between gap-3">
|
||||
<div>
|
||||
<h2 className="font-semibold text-gray-700">데이터 검토 결과</h2>
|
||||
<p className="mt-1 text-sm text-gray-500">PPL {validation.summary.pplCount}건 · MNHL {validation.summary.mnhlCount}건</p>
|
||||
</div>
|
||||
<span style={{ maxWidth: "50%" }} className="truncate text-xs text-gray-500">검토 해시: {validation.fileHash || "-"}</span>
|
||||
</div>
|
||||
{validationRows.length > 0 ? (
|
||||
<UiTable
|
||||
headName=""
|
||||
headlist={ERROR_TABLE_HEAD}
|
||||
tableData={validationRows}
|
||||
total={validationRows.length}
|
||||
pageSize={Math.max(validationRows.length, 1)}
|
||||
curPage={1}
|
||||
widthFixed
|
||||
cellClick={undefined}
|
||||
onPageChange={() => undefined}
|
||||
/>
|
||||
) : (
|
||||
<div className="rounded border border-emerald-200 bg-emerald-50 px-4 py-5 text-center text-sm text-emerald-700">데이터 검토 오류가 없습니다.</div>
|
||||
)}
|
||||
</section>
|
||||
)}
|
||||
|
||||
{uploadStatus && <p className="text-right text-sm text-blue-700">{uploadStatus}</p>}
|
||||
</main>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,334 @@
|
||||
"use client";
|
||||
|
||||
import {
|
||||
Button,
|
||||
Table,
|
||||
TableBody,
|
||||
TableCell,
|
||||
TableHead,
|
||||
TableHeader,
|
||||
TableRow,
|
||||
} from "@common/components/ui";
|
||||
import ListDiv from "@common/business_components/ui/list_div";
|
||||
import { useEffect, useMemo, useRef, useState } from "react";
|
||||
import { getExmnSectionSummaries } from "../api/exmn-business-api";
|
||||
import type {
|
||||
ExmnBusiness,
|
||||
ExmnJobType,
|
||||
ExmnSectionSummaryRow,
|
||||
} from "../model/exmn-business-types";
|
||||
import { getExmnSampleSectionSummaries } from "../model/exmn-sample-data";
|
||||
|
||||
const ACTION_BUTTON_CLASS =
|
||||
"rounded-sm bg-blue-600 px-4 py-2 text-sm font-medium !text-white hover:bg-blue-700 disabled:!text-white";
|
||||
|
||||
const PIPE_SUMMARY_TABLE_HEAD = [
|
||||
{ id: "rowNumber", title: "NO", widthPercent: 6 },
|
||||
{ id: "trtdstNm", title: "처리분구", widthPercent: 18 },
|
||||
{ id: "trgtPrlgText", title: "대상연장(m)", widthPercent: 13 },
|
||||
{ id: "drvngDstncText", title: "주행거리(m)", widthPercent: 13 },
|
||||
{ id: "wholeCountText", title: "총 이상항목(개소)", widthPercent: 15 },
|
||||
{ id: "structuralCountText", title: "구조적결함(개소)", widthPercent: 14 },
|
||||
{ id: "operationalCountText", title: "운영적결함(개소)", widthPercent: 14 },
|
||||
{ id: "etcCountText", title: "기타", widthPercent: 7 },
|
||||
];
|
||||
|
||||
const MANHOLE_SUMMARY_TABLE_HEAD = [
|
||||
{ id: "rowNumber", title: "NO", widthPercent: 8 },
|
||||
{ id: "trtdstNm", title: "처리분구", widthPercent: 28 },
|
||||
{ id: "wholeCountText", title: "총 이상항목(개소)", widthPercent: 18 },
|
||||
{ id: "structuralCountText", title: "구조적결함(개소)", widthPercent: 17 },
|
||||
{ id: "operationalCountText", title: "운영적결함(개소)", widthPercent: 17 },
|
||||
{ id: "etcCountText", title: "기타", widthPercent: 12 },
|
||||
];
|
||||
|
||||
type Notice = { type: "info" | "error"; text: string } | null;
|
||||
|
||||
interface ExmnStatusDetailPanelProps {
|
||||
business: ExmnBusiness | null;
|
||||
sampleMode: boolean;
|
||||
}
|
||||
|
||||
const formatNumber = (value: number | null | undefined) =>
|
||||
Number(value ?? 0).toLocaleString("ko-KR", { maximumFractionDigits: 3 });
|
||||
|
||||
interface DisplaySummaryRow extends Record<string, string | number> {
|
||||
rowNumber: string | number;
|
||||
trtdstNm: string;
|
||||
trgtPrlgText: string;
|
||||
drvngDstncText: string;
|
||||
wholeCountText: string;
|
||||
structuralCountText: string;
|
||||
operationalCountText: string;
|
||||
etcCountText: string;
|
||||
}
|
||||
|
||||
/** 동일 조사현황 화면에서 선택 사업의 관로·맨홀 처리분구 집계를 펼쳐 표시한다. */
|
||||
export function ExmnStatusDetailPanel({ business, sampleMode }: ExmnStatusDetailPanelProps) {
|
||||
const [jobType, setJobType] = useState<ExmnJobType>("PPL");
|
||||
const [summaryRows, setSummaryRows] = useState<ExmnSectionSummaryRow[]>([]);
|
||||
const [isLoading, setIsLoading] = useState(false);
|
||||
const [notice, setNotice] = useState<Notice>(null);
|
||||
const summaryRequestIdRef = useRef(0);
|
||||
|
||||
useEffect(() => {
|
||||
setJobType("PPL");
|
||||
setNotice(null);
|
||||
}, [business?.exmnBizId]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!business) {
|
||||
summaryRequestIdRef.current += 1;
|
||||
setSummaryRows([]);
|
||||
setIsLoading(false);
|
||||
return;
|
||||
}
|
||||
|
||||
const requestId = summaryRequestIdRef.current + 1;
|
||||
summaryRequestIdRef.current = requestId;
|
||||
setIsLoading(true);
|
||||
setNotice(null);
|
||||
const request = sampleMode
|
||||
? Promise.resolve(getExmnSampleSectionSummaries(business.exmnBizId, jobType))
|
||||
: getExmnSectionSummaries(business.exmnBizId, jobType);
|
||||
request
|
||||
.then((rows) => {
|
||||
if (summaryRequestIdRef.current === requestId) setSummaryRows(rows);
|
||||
})
|
||||
.catch((error: unknown) => {
|
||||
if (summaryRequestIdRef.current !== requestId) return;
|
||||
setSummaryRows([]);
|
||||
setNotice({ type: "error", text: error instanceof Error ? error.message : "조사 상세현황 조회 중 오류가 발생했습니다." });
|
||||
})
|
||||
.finally(() => {
|
||||
if (summaryRequestIdRef.current === requestId) setIsLoading(false);
|
||||
});
|
||||
|
||||
return () => {
|
||||
if (summaryRequestIdRef.current === requestId) summaryRequestIdRef.current += 1;
|
||||
};
|
||||
}, [business, jobType, sampleMode]);
|
||||
|
||||
const summaryTable = useMemo(() => {
|
||||
const getCounts = (row: ExmnSectionSummaryRow) => jobType === "PPL"
|
||||
? {
|
||||
whole: row.pplExmnWholAnmlyArtclCnt,
|
||||
structural: row.pplExmnStrctDfcCnt,
|
||||
operational: row.pplExmnOperDfcCnt,
|
||||
etc: row.pplExmnEtcCnt,
|
||||
}
|
||||
: {
|
||||
whole: row.mnhlExmnWholAnmlyArtclCnt,
|
||||
structural: row.mnhlExmnStrctDfcCnt,
|
||||
operational: row.mnhlExmnOperDfcCnt,
|
||||
etc: row.mnhlExmnEtcCnt,
|
||||
};
|
||||
const totals = summaryRows.reduce((sum, row) => {
|
||||
const counts = getCounts(row);
|
||||
return {
|
||||
trgtPrlg: sum.trgtPrlg + row.trgtPrlg,
|
||||
drvngDstnc: sum.drvngDstnc + row.drvngDstnc,
|
||||
whole: sum.whole + counts.whole,
|
||||
structural: sum.structural + counts.structural,
|
||||
operational: sum.operational + counts.operational,
|
||||
etc: sum.etc + counts.etc,
|
||||
};
|
||||
}, { trgtPrlg: 0, drvngDstnc: 0, whole: 0, structural: 0, operational: 0, etc: 0 });
|
||||
const rows: DisplaySummaryRow[] = summaryRows.map((row, index) => {
|
||||
const counts = getCounts(row);
|
||||
return {
|
||||
rowNumber: index + 1,
|
||||
trtdstNm: row.trtdstNm,
|
||||
trgtPrlgText: formatNumber(row.trgtPrlg),
|
||||
drvngDstncText: formatNumber(row.drvngDstnc),
|
||||
wholeCountText: formatNumber(counts.whole),
|
||||
structuralCountText: formatNumber(counts.structural),
|
||||
operationalCountText: formatNumber(counts.operational),
|
||||
etcCountText: formatNumber(counts.etc),
|
||||
};
|
||||
});
|
||||
return {
|
||||
rows,
|
||||
totals: {
|
||||
trgtPrlgText: formatNumber(totals.trgtPrlg),
|
||||
drvngDstncText: formatNumber(totals.drvngDstnc),
|
||||
wholeCountText: formatNumber(totals.whole),
|
||||
structuralCountText: formatNumber(totals.structural),
|
||||
operationalCountText: formatNumber(totals.operational),
|
||||
etcCountText: formatNumber(totals.etc),
|
||||
} as Record<string, string>,
|
||||
};
|
||||
}, [jobType, summaryRows]);
|
||||
|
||||
const summaryColumns = jobType === "PPL" ? PIPE_SUMMARY_TABLE_HEAD : MANHOLE_SUMMARY_TABLE_HEAD;
|
||||
|
||||
const showPendingNotice = (feature: "상세보기" | "다운로드" | "지도보기") => {
|
||||
const text = feature === "상세보기"
|
||||
? "상세보기는 조사 집계 화면 확정 후 연결할 예정입니다."
|
||||
: feature === "다운로드"
|
||||
? "다운로드는 집계 데이터와 파일 계약 확정 후 연결할 예정입니다."
|
||||
: "지도보기는 현재 보류 기능이며 버튼 진입점만 표시합니다.";
|
||||
setNotice({ type: "info", text });
|
||||
};
|
||||
|
||||
/** 보이는 버튼 전체에서 조사유형을 즉시 바꾸고, 상세 조회는 별도 effect에서 수행한다. */
|
||||
const handleJobTypeChange = (nextJobType: ExmnJobType) => {
|
||||
if (nextJobType === jobType) return;
|
||||
setJobType(nextJobType);
|
||||
};
|
||||
|
||||
// PPT 3의 최초 상태처럼 사업을 선택하기 전에는 상세현황 영역 자체를 표시하지 않는다.
|
||||
if (!business) return null;
|
||||
|
||||
return (
|
||||
<ListDiv className="mt-4 flex min-h-0 flex-1 flex-col overflow-hidden">
|
||||
<div className="card-header flex min-h-[5.2rem] flex-wrap items-center gap-3">
|
||||
<h3 className="mr-3 text-2xl font-medium">조사 상세현황</h3>
|
||||
<div
|
||||
role="tablist"
|
||||
aria-label="조사 유형"
|
||||
aria-busy={isLoading}
|
||||
className="flex items-center gap-1"
|
||||
>
|
||||
{([
|
||||
{ value: "PPL", label: "관로조사" },
|
||||
{ value: "MNHL", label: "맨홀조사" },
|
||||
] as const).map((tab) => (
|
||||
<button
|
||||
key={tab.value}
|
||||
type="button"
|
||||
role="tab"
|
||||
aria-selected={jobType === tab.value}
|
||||
className={`inline-flex cursor-pointer items-center justify-center whitespace-nowrap rounded-md border text-[1.4rem] font-semibold transition-colors ring-offset-background focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 ${jobType === tab.value
|
||||
? "border-primary bg-primary text-white"
|
||||
: "border-border bg-background text-muted-foreground hover:bg-accent"
|
||||
}`}
|
||||
style={{ width: "8rem", height: "3.2rem" }}
|
||||
onClick={() => handleJobTypeChange(tab.value)}
|
||||
>
|
||||
{tab.label}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
<div className="ml-auto flex items-center gap-2">
|
||||
<Button className={ACTION_BUTTON_CLASS} size="sm" onClick={() => showPendingNotice("상세보기")}>상세보기</Button>
|
||||
<Button className={ACTION_BUTTON_CLASS} size="sm" onClick={() => showPendingNotice("다운로드")}>다운로드</Button>
|
||||
<Button className={ACTION_BUTTON_CLASS} size="sm" onClick={() => showPendingNotice("지도보기")}>지도보기</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{notice && (
|
||||
<div role="alert" className={`mx-4 mt-3 rounded border px-4 py-3 text-sm ${notice.type === "error" ? "border-red-200 bg-red-50 text-red-700" : "border-blue-200 bg-blue-50 text-blue-700"}`}>{notice.text}</div>
|
||||
)}
|
||||
|
||||
<div className="flex min-h-0 flex-1 flex-col pb-0 pt-3">
|
||||
<div className="mx-[1.2rem] mb-2 flex items-center gap-2">
|
||||
<h4 className="text-lg font-semibold text-gray-700">{jobType === "PPL" ? "관로조사" : "맨홀조사"} 종합집계표</h4>
|
||||
{isLoading && <span role="status" className="text-sm text-muted-foreground">조회 중...</span>}
|
||||
</div>
|
||||
<div className="relative mx-[1.2rem] mb-[1.2rem] min-h-0 overflow-hidden">
|
||||
<div
|
||||
className="overflow-x-hidden overflow-y-auto"
|
||||
style={{ maxHeight: "23.8rem", scrollbarGutter: "stable" }}
|
||||
>
|
||||
<div
|
||||
className="card !m-0"
|
||||
style={{ paddingTop: 0, paddingBottom: 0 }}
|
||||
>
|
||||
<Table variant="secondary" className="table-fixed">
|
||||
<colgroup>
|
||||
{summaryColumns.map((column) => (
|
||||
<col key={column.id} style={{ width: `${column.widthPercent}%` }} />
|
||||
))}
|
||||
</colgroup>
|
||||
<TableHeader
|
||||
className="font-bold"
|
||||
style={{ position: "static", zIndex: "auto" }}
|
||||
>
|
||||
<TableRow>
|
||||
{summaryColumns.map((column) => (
|
||||
<TableHead
|
||||
key={column.id}
|
||||
className="sticky top-0 z-20 bg-[#eaf3fb] text-center !py-[0.8rem]"
|
||||
style={{
|
||||
borderTopWidth: 0,
|
||||
boxShadow: "inset 0 1px 0 var(--border)",
|
||||
}}
|
||||
>
|
||||
{column.title}
|
||||
</TableHead>
|
||||
))}
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody className="text-center">
|
||||
{summaryTable.rows.length > 0 ? (
|
||||
<>
|
||||
<TableRow
|
||||
className="text-gray-700"
|
||||
style={{ backgroundColor: "#d2deef" }}
|
||||
>
|
||||
<TableCell
|
||||
colSpan={2}
|
||||
className="!py-[0.8rem] text-center !font-semibold"
|
||||
style={{ backgroundColor: "#d2deef" }}
|
||||
>
|
||||
합계
|
||||
</TableCell>
|
||||
{summaryColumns.slice(2).map((column) => (
|
||||
<TableCell
|
||||
key={`total-${column.id}`}
|
||||
className="!py-[0.8rem] text-center !font-semibold"
|
||||
style={{ backgroundColor: "#d2deef" }}
|
||||
>
|
||||
{summaryTable.totals[column.id] ?? "-"}
|
||||
</TableCell>
|
||||
))}
|
||||
</TableRow>
|
||||
{summaryTable.rows.map((row, rowIndex) => (
|
||||
<TableRow key={`${row.trtdstNm}-${rowIndex}`}>
|
||||
{summaryColumns.map((column) => (
|
||||
<TableCell
|
||||
key={`${rowIndex}-${column.id}`}
|
||||
className="!py-[0.8rem] text-center"
|
||||
style={
|
||||
rowIndex === summaryTable.rows.length - 1
|
||||
? { borderBottomWidth: 0 }
|
||||
: undefined
|
||||
}
|
||||
>
|
||||
{row[column.id]}
|
||||
</TableCell>
|
||||
))}
|
||||
</TableRow>
|
||||
))}
|
||||
</>
|
||||
) : (
|
||||
<TableRow>
|
||||
<TableCell
|
||||
colSpan={summaryColumns.length}
|
||||
className="h-24 text-center text-gray-500"
|
||||
style={{ borderBottomWidth: 0 }}
|
||||
>
|
||||
조회된 결과가 없습니다.
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
)}
|
||||
</TableBody>
|
||||
</Table>
|
||||
</div>
|
||||
</div>
|
||||
<span
|
||||
aria-hidden="true"
|
||||
className="pointer-events-none absolute z-30"
|
||||
style={{
|
||||
right: "calc(0.6rem + 17px)",
|
||||
bottom: 0,
|
||||
left: "0.6rem",
|
||||
borderTop: "1px solid var(--border)",
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</ListDiv>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,6 @@
|
||||
import { ExmnRegistrationPage } from "./ExmnRegistrationPage";
|
||||
|
||||
/** 조사현황 URL도 PPT 3·4와 동일한 사업목록·행 선택 상세 통합 화면을 사용한다. */
|
||||
export function ExmnStatusPage() {
|
||||
return <ExmnRegistrationPage />;
|
||||
}
|
||||
@@ -0,0 +1,141 @@
|
||||
"use client";
|
||||
|
||||
import { Button } from "@common/components/ui";
|
||||
import { UiModal, UiTable } from "@common/business_components/ui";
|
||||
import { useEffect, useMemo, useState } from "react";
|
||||
import type { PplFacility } from "../model/exmn-business-types";
|
||||
|
||||
const UNIT_TABLE_HEAD = [
|
||||
{ id: "unitSeq", title: "단위", widthPercent: 8 },
|
||||
{ id: "startPositionM", title: "시작위치(m)", widthPercent: 14 },
|
||||
{ id: "unitLengthM", title: "단위연장(m)", widthPercent: 14 },
|
||||
{ id: "structuralScore", title: "구조점수", widthPercent: 13 },
|
||||
{ id: "structuralGrade", title: "구조등급", widthPercent: 13 },
|
||||
{ id: "operationalScore", title: "운영점수", widthPercent: 13 },
|
||||
{ id: "operationalGrade", title: "운영등급", widthPercent: 13 },
|
||||
{ id: "detailCount", title: "기록수", widthPercent: 12 },
|
||||
];
|
||||
|
||||
const DETAIL_TABLE_HEAD = [
|
||||
{ id: "detailSeq", title: "순번", widthPercent: 6 },
|
||||
{ id: "positionM", title: "위치(m)", widthPercent: 9 },
|
||||
{ id: "code", title: "코드", widthPercent: 8 },
|
||||
{ id: "recordType", title: "기록유형", widthPercent: 10 },
|
||||
{ id: "typeText", title: "결함구분", widthPercent: 10 },
|
||||
{ id: "severityText", title: "심각도", widthPercent: 8 },
|
||||
{ id: "occurrencePositionText", title: "발생위치", widthPercent: 10 },
|
||||
{ id: "continuousText", title: "연속결함", widthPercent: 9 },
|
||||
{ id: "structuralScoreText", title: "구조점수", widthPercent: 10 },
|
||||
{ id: "operationalScoreText", title: "운영점수", widthPercent: 10 },
|
||||
{ id: "statusGroupText", title: "상태그룹", widthPercent: 10 },
|
||||
];
|
||||
|
||||
interface PplFacilityDetailModalProps {
|
||||
open: boolean;
|
||||
facility: PplFacility | null;
|
||||
onOpenChange: (open: boolean) => void;
|
||||
}
|
||||
|
||||
/** PPL_UNIT_V1 정규화 응답을 단위 구간과 상태·결함 기록으로 표시한다. */
|
||||
export function PplFacilityDetailModal({
|
||||
open,
|
||||
facility,
|
||||
onOpenChange,
|
||||
}: PplFacilityDetailModalProps) {
|
||||
// 선택 단위는 원문 배열 인덱스가 아니라 unitSeq로 보관해 서버 정렬과 무관하게 유지한다.
|
||||
const [selectedUnitSeq, setSelectedUnitSeq] = useState<number | null>(null);
|
||||
|
||||
/** 시설이 바뀌면 첫 단위 구간을 기본 선택한다. */
|
||||
useEffect(() => {
|
||||
if (!open) return;
|
||||
setSelectedUnitSeq(facility?.unitSections?.[0]?.unitSeq ?? null);
|
||||
}, [facility, open]);
|
||||
|
||||
const unitRows = useMemo(() => (facility?.unitSections ?? []).map((unit) => ({
|
||||
...unit,
|
||||
detailCount: unit.details?.length ?? 0,
|
||||
})), [facility]);
|
||||
|
||||
const selectedUnit = useMemo(
|
||||
() => facility?.unitSections?.find((unit) => unit.unitSeq === selectedUnitSeq) ?? null,
|
||||
[facility, selectedUnitSeq],
|
||||
);
|
||||
|
||||
const detailRows = useMemo(() => (selectedUnit?.details ?? []).map((detail) => ({
|
||||
...detail,
|
||||
typeText: detail.type ?? "-",
|
||||
severityText: detail.severity ?? "-",
|
||||
occurrencePositionText: detail.occurrencePosition ?? "-",
|
||||
continuousText: detail.continuous == null ? "-" : detail.continuous ? "Y" : "N",
|
||||
structuralScoreText: detail.structuralScore ?? "-",
|
||||
operationalScoreText: detail.operationalScore ?? "-",
|
||||
statusGroupText: detail.statusGroup ?? "-",
|
||||
})), [selectedUnit]);
|
||||
|
||||
return (
|
||||
<UiModal
|
||||
open={open}
|
||||
onOpenChange={onOpenChange}
|
||||
bodyClass="max-h-[94vh] max-w-[96vw] overflow-y-auto"
|
||||
headerTitle="PPL 조사시설 상세"
|
||||
headerTitleClass="text-xl font-semibold text-gray-800"
|
||||
footerContent={<div className="flex w-full justify-end"><Button variant="outline" onClick={() => onOpenChange(false)}>닫기</Button></div>}
|
||||
body={(
|
||||
<div className="space-y-5 py-2">
|
||||
<section className="grid grid-cols-4 gap-x-5 gap-y-3 rounded border bg-gray-50 p-4 text-sm">
|
||||
<div><strong>보고서번호</strong><p className="mt-1 text-gray-600">{facility?.rptpNo || "-"}</p></div>
|
||||
<div><strong>GIS 관로관리번호</strong><p className="mt-1 text-gray-600">{facility?.exmnFcltyGisMngNo || "-"}</p></div>
|
||||
<div><strong>처리분구</strong><p className="mt-1 text-gray-600">{facility?.trtdstNm || "-"}</p></div>
|
||||
<div><strong>상류/하류 맨홀</strong><p className="mt-1 text-gray-600">{facility?.upstmMnhlNo || "-"} / {facility?.dnstmMnhlNo || "-"}</p></div>
|
||||
<div><strong>배수방식</strong><p className="mt-1 text-gray-600">{facility?.drgptnCd || "-"}</p></div>
|
||||
<div><strong>관종/규격</strong><p className="mt-1 text-gray-600">{facility?.pptyCd || "-"} / {facility?.spcfctCd || "-"}</p></div>
|
||||
<div><strong>연장/주행거리</strong><p className="mt-1 text-gray-600">{facility?.prlg ?? "-"} / {facility?.drvngDstnc ?? "-"}</p></div>
|
||||
<div><strong>구조/운영 등급</strong><p className="mt-1 text-gray-600">{facility?.strctDfcGrdCd || "-"} / {facility?.operDfcGrdCd || "-"}</p></div>
|
||||
</section>
|
||||
|
||||
<section>
|
||||
<h4 className="mb-2 font-semibold text-gray-700">단위 구간</h4>
|
||||
<UiTable
|
||||
headName=""
|
||||
headlist={UNIT_TABLE_HEAD}
|
||||
tableData={unitRows}
|
||||
total={unitRows.length}
|
||||
pageSize={Math.max(unitRows.length, 1)}
|
||||
curPage={1}
|
||||
widthFixed
|
||||
index={unitRows.findIndex((unit) => unit.unitSeq === selectedUnitSeq)}
|
||||
cellClick={(index) => setSelectedUnitSeq(index == null ? null : unitRows[index]?.unitSeq ?? null)}
|
||||
onPageChange={() => undefined}
|
||||
/>
|
||||
</section>
|
||||
|
||||
<section className="rounded border bg-gray-50 p-3 text-sm text-gray-600">
|
||||
<strong>조사중단사유:</strong> {selectedUnit?.surveyStopReason || "없음"}
|
||||
<span className="mx-4 text-gray-300">|</span>
|
||||
<strong>비고:</strong> {selectedUnit?.remark || "없음"}
|
||||
</section>
|
||||
|
||||
<section>
|
||||
<h4 className="mb-2 font-semibold text-gray-700">상태·결함 상세 기록</h4>
|
||||
<UiTable
|
||||
headName=""
|
||||
headlist={DETAIL_TABLE_HEAD}
|
||||
tableData={detailRows}
|
||||
total={detailRows.length}
|
||||
pageSize={Math.max(detailRows.length, 1)}
|
||||
curPage={1}
|
||||
widthFixed
|
||||
cellClick={undefined}
|
||||
onPageChange={() => undefined}
|
||||
/>
|
||||
</section>
|
||||
|
||||
<section className="flex items-center justify-between rounded border border-amber-200 bg-amber-50 px-4 py-3 text-sm text-amber-800">
|
||||
<span>시설 보고서 파일 매핑 원천이 확정되기 전까지 보고서 조회는 제공하지 않습니다.</span>
|
||||
<Button disabled title="보고서 매핑 모델 확정 필요">보고서 보기</Button>
|
||||
</section>
|
||||
</div>
|
||||
)}
|
||||
/>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,373 @@
|
||||
"use client";
|
||||
|
||||
import LoadingOverlay from "@/components/ui/LoadingBar";
|
||||
import { Button, Input } from "@common/components/ui";
|
||||
import { UiModal, UiTable } from "@common/business_components/ui";
|
||||
import type { UiTableSort } from "@common/business_components/ui";
|
||||
import { useCallback, useEffect, useMemo, useState } from "react";
|
||||
import {
|
||||
downloadPplExport,
|
||||
getExmnBusiness,
|
||||
getPplFacilities,
|
||||
getPplSummaries,
|
||||
} from "../api/exmn-business-api";
|
||||
import type {
|
||||
ExmnBusiness,
|
||||
ExmnBusinessDetail,
|
||||
PplFacility,
|
||||
PplSummaryRow,
|
||||
} from "../model/exmn-business-types";
|
||||
import { PplFacilityDetailModal } from "./PplFacilityDetailModal";
|
||||
|
||||
const ACTION_BUTTON_CLASS =
|
||||
"rounded-sm bg-blue-600 px-4 py-2 text-sm font-medium !text-white hover:bg-blue-700 disabled:!text-white";
|
||||
|
||||
const SUMMARY_TABLE_HEAD = [
|
||||
{ id: "trtdstNm", title: "처리분구", widthPercent: 20, sortable: true },
|
||||
{ id: "trgtPrlgText", title: "대상연장(m)", widthPercent: 13, sortable: true, sortKey: "trgtPrlg" },
|
||||
{ id: "drvngDstncText", title: "주행거리(m)", widthPercent: 13, sortable: true, sortKey: "drvngDstnc" },
|
||||
{ id: "pplExmnWholAnmlyArtclCnt", title: "PPL 전체이상", widthPercent: 14, sortable: true },
|
||||
{ id: "pplExmnStrctDfcCnt", title: "구조결함", widthPercent: 13, sortable: true },
|
||||
{ id: "pplExmnOperDfcCnt", title: "운영결함", widthPercent: 13, sortable: true },
|
||||
{ id: "pplExmnEtcCnt", title: "기타", widthPercent: 14, sortable: true },
|
||||
];
|
||||
|
||||
const FACILITY_TABLE_HEAD = [
|
||||
{ id: "trtdstNm", title: "처리분구", widthPercent: 13, sortable: true },
|
||||
{ id: "rptpNo", title: "보고서번호", widthPercent: 12, sortable: true },
|
||||
{ id: "exmnFcltyGisMngNo", title: "GIS 관로관리번호", widthPercent: 18, sortable: true },
|
||||
{ id: "upstreamDownstream", title: "상류/하류 맨홀", widthPercent: 17 },
|
||||
{ id: "pipeSpec", title: "관종/규격", widthPercent: 13 },
|
||||
{ id: "prlgText", title: "연장(m)", widthPercent: 9, sortable: true, sortKey: "prlg" },
|
||||
{ id: "strctDfcGrdCdText", title: "구조등급", widthPercent: 9, sortable: true, sortKey: "strctDfcGrdCd" },
|
||||
{ id: "operDfcGrdCdText", title: "운영등급", widthPercent: 9, sortable: true, sortKey: "operDfcGrdCd" },
|
||||
];
|
||||
|
||||
const SUMMARY_CLICKABLE_HEAD_IDS = [
|
||||
"pplExmnWholAnmlyArtclCnt",
|
||||
"pplExmnStrctDfcCnt",
|
||||
"pplExmnOperDfcCnt",
|
||||
"pplExmnEtcCnt",
|
||||
];
|
||||
|
||||
type Notice = { type: "success" | "error"; text: string } | null;
|
||||
type PplDefectType = "ALL" | "STRUCTURAL" | "OPERATIONAL" | "ETC";
|
||||
|
||||
interface PplSummaryModalProps {
|
||||
open: boolean;
|
||||
business: ExmnBusiness | null;
|
||||
onOpenChange: (open: boolean) => void;
|
||||
}
|
||||
|
||||
/** 숫자 값을 목록에서 단위 없이 천단위 구분 형식으로 표시한다. */
|
||||
const formatNumber = (value: number | null | undefined) =>
|
||||
Number(value ?? 0).toLocaleString("ko-KR", { maximumFractionDigits: 3 });
|
||||
|
||||
/** Blob을 PPL 집계 엑셀 다운로드로 전달한다. */
|
||||
const saveBlob = (blob: Blob, fileName: string) => {
|
||||
const objectUrl = URL.createObjectURL(blob);
|
||||
const anchor = document.createElement("a");
|
||||
anchor.href = objectUrl;
|
||||
anchor.download = fileName;
|
||||
anchor.click();
|
||||
URL.revokeObjectURL(objectUrl);
|
||||
};
|
||||
|
||||
/** 처리분구별 PPL 집계에서 결함 수를 따라 시설 목록과 단위 상세까지 탐색하는 모달이다. */
|
||||
export function PplSummaryModal({ open, business, onOpenChange }: PplSummaryModalProps) {
|
||||
const [businessDetail, setBusinessDetail] = useState<ExmnBusinessDetail | null>(null);
|
||||
const [summaryRows, setSummaryRows] = useState<PplSummaryRow[]>([]);
|
||||
const [summaryTotal, setSummaryTotal] = useState(0);
|
||||
const [summaryPage, setSummaryPage] = useState(0);
|
||||
const [summarySort, setSummarySort] = useState<UiTableSort>();
|
||||
const [facilityRows, setFacilityRows] = useState<PplFacility[]>([]);
|
||||
const [facilityTotal, setFacilityTotal] = useState(0);
|
||||
const [facilityPage, setFacilityPage] = useState(0);
|
||||
const [facilitySort, setFacilitySort] = useState<UiTableSort>();
|
||||
// 집계 건수 클릭과 상세 조회조건이 공유하는 처리분구·결함유형 컨텍스트다.
|
||||
const [trtdstId, setTrtdstId] = useState("");
|
||||
const [sectionType, setSectionType] = useState("");
|
||||
const [gisManagementNo, setGisManagementNo] = useState("");
|
||||
const [structuralGrade, setStructuralGrade] = useState("");
|
||||
const [operationalGrade, setOperationalGrade] = useState("");
|
||||
const [defectType, setDefectType] = useState<PplDefectType>("ALL");
|
||||
const [selectedFacility, setSelectedFacility] = useState<PplFacility | null>(null);
|
||||
const [isLoading, setIsLoading] = useState(false);
|
||||
const [notice, setNotice] = useState<Notice>(null);
|
||||
|
||||
const displayedSummaryRows = useMemo(() => summaryRows.map((row) => ({
|
||||
...row,
|
||||
trgtPrlgText: formatNumber(row.trgtPrlg),
|
||||
drvngDstncText: formatNumber(row.drvngDstnc),
|
||||
})), [summaryRows]);
|
||||
|
||||
const displayedFacilityRows = useMemo(() => facilityRows.map((row) => ({
|
||||
...row,
|
||||
upstreamDownstream: `${row.upstmMnhlNo || "-"} / ${row.dnstmMnhlNo || "-"}`,
|
||||
pipeSpec: `${row.pptyCd || "-"} / ${row.spcfctCd || "-"}`,
|
||||
prlgText: row.prlg == null ? "-" : formatNumber(row.prlg),
|
||||
strctDfcGrdCdText: row.strctDfcGrdCd || "-",
|
||||
operDfcGrdCdText: row.operDfcGrdCd || "-",
|
||||
})), [facilityRows]);
|
||||
|
||||
/** 현재 상세 필터로 PPL 시설 페이지를 요청한다. */
|
||||
const fetchFacilities = useCallback(async (
|
||||
nextPage = facilityPage,
|
||||
nextSort = facilitySort,
|
||||
) => {
|
||||
if (!business?.exmnBizId) return;
|
||||
setIsLoading(true);
|
||||
setNotice(null);
|
||||
try {
|
||||
const page = await getPplFacilities({
|
||||
exmnBizId: business.exmnBizId,
|
||||
trtdstId: trtdstId || undefined,
|
||||
sectionType: sectionType || undefined,
|
||||
gisManagementNo: gisManagementNo || undefined,
|
||||
structuralGrade: structuralGrade || undefined,
|
||||
operationalGrade: operationalGrade || undefined,
|
||||
defectType,
|
||||
page: nextPage,
|
||||
size: 10,
|
||||
sort: nextSort ? `${nextSort.key},${nextSort.direction}` : undefined,
|
||||
});
|
||||
setFacilityRows(page.content);
|
||||
setFacilityTotal(page.totalElements);
|
||||
setFacilityPage(page.number);
|
||||
} catch (error) {
|
||||
setFacilityRows([]);
|
||||
setFacilityTotal(0);
|
||||
setNotice({
|
||||
type: "error",
|
||||
text: error instanceof Error ? error.message : "PPL 시설 조회 중 오류가 발생했습니다.",
|
||||
});
|
||||
} finally {
|
||||
setIsLoading(false);
|
||||
}
|
||||
}, [business, defectType, facilityPage, facilitySort, gisManagementNo, operationalGrade, sectionType, structuralGrade, trtdstId]);
|
||||
|
||||
/** 모달 진입과 집계 정렬·페이지 변경 시 사업 상세와 PPL 집계를 읽는다. */
|
||||
useEffect(() => {
|
||||
if (!open || !business?.exmnBizId) return;
|
||||
let active = true;
|
||||
setIsLoading(true);
|
||||
setNotice(null);
|
||||
Promise.all([
|
||||
getExmnBusiness(business.exmnBizId),
|
||||
getPplSummaries(business.exmnBizId, {
|
||||
trtdstId: trtdstId || undefined,
|
||||
page: summaryPage,
|
||||
size: 10,
|
||||
sort: summarySort ? `${summarySort.key},${summarySort.direction}` : undefined,
|
||||
}),
|
||||
])
|
||||
.then(([detail, page]) => {
|
||||
if (!active) return;
|
||||
setBusinessDetail(detail);
|
||||
setSummaryRows(page.content);
|
||||
setSummaryTotal(page.totalElements);
|
||||
})
|
||||
.catch((error: unknown) => {
|
||||
if (!active) return;
|
||||
setSummaryRows([]);
|
||||
setSummaryTotal(0);
|
||||
setNotice({
|
||||
type: "error",
|
||||
text: error instanceof Error ? error.message : "PPL 집계 조회 중 오류가 발생했습니다.",
|
||||
});
|
||||
})
|
||||
.finally(() => {
|
||||
if (active) setIsLoading(false);
|
||||
});
|
||||
return () => {
|
||||
active = false;
|
||||
};
|
||||
}, [business, open, summaryPage, summarySort, trtdstId]);
|
||||
|
||||
/** 새 사업을 열 때 상세 필터·페이지·선택 시설을 초기화한다. */
|
||||
useEffect(() => {
|
||||
if (!open) return;
|
||||
setSummaryPage(0);
|
||||
setSummarySort(undefined);
|
||||
setFacilityRows([]);
|
||||
setFacilityTotal(0);
|
||||
setFacilityPage(0);
|
||||
setFacilitySort(undefined);
|
||||
setTrtdstId("");
|
||||
setSectionType("");
|
||||
setGisManagementNo("");
|
||||
setStructuralGrade("");
|
||||
setOperationalGrade("");
|
||||
setDefectType("ALL");
|
||||
setSelectedFacility(null);
|
||||
}, [business, open]);
|
||||
|
||||
/** 집계의 전체·구조·운영·기타 건수를 클릭하면 해당 처리분구 시설 조회로 연결한다. */
|
||||
const handleSummaryCellClick = (index: number | undefined, headId: string) => {
|
||||
if (index == null) return;
|
||||
const row = summaryRows[index];
|
||||
if (!row) return;
|
||||
const nextDefectType: PplDefectType = headId === "pplExmnStrctDfcCnt"
|
||||
? "STRUCTURAL"
|
||||
: headId === "pplExmnOperDfcCnt"
|
||||
? "OPERATIONAL"
|
||||
: headId === "pplExmnEtcCnt"
|
||||
? "ETC"
|
||||
: "ALL";
|
||||
setTrtdstId(row.trtdstId);
|
||||
setDefectType(nextDefectType);
|
||||
setFacilityPage(0);
|
||||
setNotice(null);
|
||||
// React 상태 반영 전 값 대신 클릭 컨텍스트로 즉시 요청한다.
|
||||
setIsLoading(true);
|
||||
getPplFacilities({
|
||||
exmnBizId: business?.exmnBizId ?? "",
|
||||
trtdstId: row.trtdstId,
|
||||
sectionType: sectionType || undefined,
|
||||
gisManagementNo: gisManagementNo || undefined,
|
||||
structuralGrade: structuralGrade || undefined,
|
||||
operationalGrade: operationalGrade || undefined,
|
||||
defectType: nextDefectType,
|
||||
page: 0,
|
||||
size: 10,
|
||||
})
|
||||
.then((page) => {
|
||||
setFacilityRows(page.content);
|
||||
setFacilityTotal(page.totalElements);
|
||||
setFacilityPage(page.number);
|
||||
})
|
||||
.catch((error: unknown) => {
|
||||
setFacilityRows([]);
|
||||
setFacilityTotal(0);
|
||||
setNotice({ type: "error", text: error instanceof Error ? error.message : "PPL 시설 조회 중 오류가 발생했습니다." });
|
||||
})
|
||||
.finally(() => setIsLoading(false));
|
||||
};
|
||||
|
||||
/** 현재 처리분구 조건의 PPL 집계 엑셀을 다운로드한다. */
|
||||
const handleExport = async () => {
|
||||
if (!business) return;
|
||||
setIsLoading(true);
|
||||
setNotice(null);
|
||||
try {
|
||||
saveBlob(await downloadPplExport(business.exmnBizId, trtdstId || undefined), `${business.bizNm}_PPL집계.xlsx`);
|
||||
} catch (error) {
|
||||
setNotice({ type: "error", text: error instanceof Error ? error.message : "PPL 엑셀 다운로드 중 오류가 발생했습니다." });
|
||||
} finally {
|
||||
setIsLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<>
|
||||
<UiModal
|
||||
open={open}
|
||||
onOpenChange={onOpenChange}
|
||||
bodyClass="max-h-[94vh] max-w-[96vw] overflow-y-auto"
|
||||
headerTitle="PPL 조사 집계·상세"
|
||||
headerTitleClass="text-xl font-semibold text-gray-800"
|
||||
footerContent={<div className="flex w-full justify-end"><Button variant="outline" onClick={() => onOpenChange(false)}>닫기</Button></div>}
|
||||
body={(
|
||||
<div className="relative space-y-5 py-2">
|
||||
<LoadingOverlay isLoading={isLoading} />
|
||||
{notice && (
|
||||
<div role="alert" className={`rounded border px-4 py-3 text-sm ${notice.type === "error" ? "border-red-200 bg-red-50 text-red-700" : "border-emerald-200 bg-emerald-50 text-emerald-700"}`}>{notice.text}</div>
|
||||
)}
|
||||
<div className="rounded border bg-gray-50 px-4 py-3 text-sm text-gray-600">
|
||||
<strong>{business?.bizNm ?? "선택 사업"}</strong>
|
||||
<span className="mx-3 text-gray-300">|</span>{business?.bizYr}년 {business?.bizCycl ?? "-"}차
|
||||
<span className="mx-3 text-gray-300">|</span>{business?.ctpvNm} {business?.sggNm}
|
||||
</div>
|
||||
|
||||
<section>
|
||||
<div className="mb-2 flex items-center justify-between">
|
||||
<h4 className="font-semibold text-gray-700">처리분구별 PPL 집계</h4>
|
||||
<Button className={ACTION_BUTTON_CLASS} disabled={isLoading} onClick={() => void handleExport()}>엑셀 다운로드</Button>
|
||||
</div>
|
||||
<UiTable
|
||||
headName=""
|
||||
headlist={SUMMARY_TABLE_HEAD}
|
||||
tableData={displayedSummaryRows}
|
||||
total={summaryTotal}
|
||||
pageSize={10}
|
||||
curPage={summaryPage + 1}
|
||||
widthFixed
|
||||
clickableHeadIds={SUMMARY_CLICKABLE_HEAD_IDS}
|
||||
cellClassName={(_, headId) => SUMMARY_CLICKABLE_HEAD_IDS.includes(headId) ? "text-blue-600 underline" : ""}
|
||||
cellClick={handleSummaryCellClick}
|
||||
onPageChange={(page) => setSummaryPage(page - 1)}
|
||||
sort={summarySort}
|
||||
onSortChange={(sort) => {
|
||||
setSummarySort(sort);
|
||||
setSummaryPage(0);
|
||||
}}
|
||||
/>
|
||||
</section>
|
||||
|
||||
<section className="rounded border border-gray-200 p-4">
|
||||
<div className="grid grid-cols-6 gap-3">
|
||||
<label className="text-sm font-medium text-gray-600">처리분구
|
||||
<select className="mt-1 h-10 w-full rounded border bg-white px-2" value={trtdstId} onChange={(event) => setTrtdstId(event.target.value)}>
|
||||
<option value="">전체</option>
|
||||
{(businessDetail?.districts ?? []).map((district) => <option key={district.trtdstId} value={district.trtdstId}>{district.trtdstNm}</option>)}
|
||||
</select>
|
||||
</label>
|
||||
<label className="text-sm font-medium text-gray-600">구간 구분
|
||||
<select className="mt-1 h-10 w-full rounded border bg-white px-2" value={sectionType} onChange={(event) => setSectionType(event.target.value)}>
|
||||
<option value="">전체</option><option value="PIPE">관로</option><option value="UNIT">단위구간</option>
|
||||
</select>
|
||||
</label>
|
||||
<label className="col-span-2 text-sm font-medium text-gray-600">GIS 관로관리번호
|
||||
<Input className="mt-1 h-10 text-base" value={gisManagementNo} onChange={(event) => setGisManagementNo(event.target.value)} />
|
||||
</label>
|
||||
<label className="text-sm font-medium text-gray-600">구조등급
|
||||
<select className="mt-1 h-10 w-full rounded border bg-white px-2" value={structuralGrade} onChange={(event) => setStructuralGrade(event.target.value)}>
|
||||
<option value="">전체</option>{[1, 2, 3, 4, 5].map((grade) => <option key={grade} value={grade}>{grade}등급</option>)}
|
||||
</select>
|
||||
</label>
|
||||
<label className="text-sm font-medium text-gray-600">운영등급
|
||||
<select className="mt-1 h-10 w-full rounded border bg-white px-2" value={operationalGrade} onChange={(event) => setOperationalGrade(event.target.value)}>
|
||||
<option value="">전체</option>{[1, 2, 3, 4, 5].map((grade) => <option key={grade} value={grade}>{grade}등급</option>)}
|
||||
</select>
|
||||
</label>
|
||||
</div>
|
||||
<div className="mt-3 flex items-center justify-between">
|
||||
<span className="text-sm text-gray-500">결함 분류: {defectType}</span>
|
||||
<Button className={ACTION_BUTTON_CLASS} disabled={isLoading} onClick={() => void fetchFacilities(0)}>상세 조회</Button>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section>
|
||||
<h4 className="mb-2 font-semibold text-gray-700">PPL 시설 목록</h4>
|
||||
<UiTable
|
||||
headName=""
|
||||
headlist={FACILITY_TABLE_HEAD}
|
||||
tableData={displayedFacilityRows}
|
||||
total={facilityTotal}
|
||||
pageSize={10}
|
||||
curPage={facilityPage + 1}
|
||||
widthFixed
|
||||
cellClick={(index) => setSelectedFacility(index == null ? null : facilityRows[index] ?? null)}
|
||||
onPageChange={(page) => void fetchFacilities(page - 1)}
|
||||
sort={facilitySort}
|
||||
onSortChange={(sort) => {
|
||||
setFacilitySort(sort);
|
||||
setFacilityPage(0);
|
||||
void fetchFacilities(0, sort);
|
||||
}}
|
||||
/>
|
||||
<p className="mt-2 text-right text-sm text-gray-500">시설 행을 선택하면 단위 구간과 상태·결함 상세가 열립니다.</p>
|
||||
</section>
|
||||
</div>
|
||||
)}
|
||||
/>
|
||||
<PplFacilityDetailModal
|
||||
open={Boolean(selectedFacility)}
|
||||
facility={selectedFacility}
|
||||
onOpenChange={(nextOpen) => {
|
||||
if (!nextOpen) setSelectedFacility(null);
|
||||
}}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -1,6 +1,19 @@
|
||||
import type { WindowPopupDefinition } from "./types";
|
||||
|
||||
export const windowPopupDefinitions = {
|
||||
exmnBusinessRegister: {
|
||||
key: "exmnBusinessRegister",
|
||||
title: "사업정보 등록",
|
||||
width: 920,
|
||||
height: 620,
|
||||
},
|
||||
exmnResultRegister: {
|
||||
key: "exmnResultRegister",
|
||||
title: "조사 결과 업로드",
|
||||
width: 980,
|
||||
height: 340,
|
||||
requiredParams: ["exmnBizId"],
|
||||
},
|
||||
oldPpUpload: {
|
||||
key: "oldPpUpload",
|
||||
title: "노후 하수관로 정비사업 등록",
|
||||
|
||||
@@ -31,6 +31,8 @@ export type OpenWindowPopupParams = Record<
|
||||
>;
|
||||
|
||||
export const WINDOW_POPUP_MESSAGE_TYPE = {
|
||||
EXMN_BUSINESS_CREATED: "sn:popup:exmnBusinessCreated",
|
||||
EXMN_RESULT_COMMITTED: "sn:popup:exmnResultCommitted",
|
||||
OLD_PP_UPLOAD_COMPLETED: "sn:popup:oldPpUploadCompleted",
|
||||
FLOOD_PRE_UPLOAD_COMPLETED: "sn:popup:floodPreUploadCompleted",
|
||||
MAP_SEARCH_SELECT_FEATURE: "sn:popup:mapSearchSelectFeature",
|
||||
|
||||
@@ -1,6 +1,8 @@
|
||||
import { windowPopupDefinitions, type WindowPopupKey } from "./model/popupDefinitions";
|
||||
import type { WindowPopupRegistryItem } from "./model/types";
|
||||
import AgedPipePopupContent from "./ui/AgedPipePopupContent";
|
||||
import ExmnBusinessRegisterPopupContent from "./ui/ExmnBusinessRegisterPopupContent";
|
||||
import ExmnResultRegisterPopupContent from "./ui/ExmnResultRegisterPopupContent";
|
||||
import FloodPreTablePopupContent from "./ui/FloodPreTablePopupContent";
|
||||
import FloodPreUploadPopupContent from "./ui/FloodPreUploadPopupContent";
|
||||
import InfilChartPopupContent from "./ui/InfilChartPopupContent";
|
||||
@@ -34,6 +36,14 @@ const GisEditPopupContent = (_props: WindowPopupContentProps) => (
|
||||
);
|
||||
|
||||
export const windowPopupRegistry = {
|
||||
exmnBusinessRegister: {
|
||||
...windowPopupDefinitions.exmnBusinessRegister,
|
||||
component: ExmnBusinessRegisterPopupContent,
|
||||
},
|
||||
exmnResultRegister: {
|
||||
...windowPopupDefinitions.exmnResultRegister,
|
||||
component: ExmnResultRegisterPopupContent,
|
||||
},
|
||||
oldPpUpload: {
|
||||
...windowPopupDefinitions.oldPpUpload,
|
||||
component: OldPpUploadPopupContent,
|
||||
@@ -122,4 +132,4 @@ export const windowPopupRegistry = {
|
||||
...windowPopupDefinitions.flowRiskPopup,
|
||||
component: WaterRiskPopupContent,
|
||||
},
|
||||
} as const satisfies Record<WindowPopupKey, WindowPopupRegistryItem>;
|
||||
} as const satisfies Record<WindowPopupKey, WindowPopupRegistryItem>;
|
||||
|
||||
@@ -0,0 +1,24 @@
|
||||
"use client";
|
||||
|
||||
import { ExmnBusinessRegisterPopupPage } from "@/features/exmn/ui/ExmnBusinessRegisterPopupPage";
|
||||
import { useEffect, useState } from "react";
|
||||
import type { WindowPopupContentProps } from "../model/types";
|
||||
|
||||
/** query parameter가 단일 문자열인지 안전하게 해석한다. */
|
||||
const getParam = (value: string | string[] | undefined) =>
|
||||
Array.isArray(value) ? value[0] ?? "" : value ?? "";
|
||||
|
||||
/** 조사사업 등록 WindowPopup을 실제 업무 화면에 연결한다. */
|
||||
export default function ExmnBusinessRegisterPopupContent({ searchParams }: WindowPopupContentProps) {
|
||||
const [isMounted, setIsMounted] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
setIsMounted(true);
|
||||
}, []);
|
||||
|
||||
if (!isMounted) {
|
||||
return <div className="min-h-dvh bg-[#f3f7fa]" suppressHydrationWarning />;
|
||||
}
|
||||
|
||||
return <ExmnBusinessRegisterPopupPage sampleMode={getParam(searchParams.sample) === "Y"} />;
|
||||
}
|
||||
@@ -0,0 +1,46 @@
|
||||
"use client";
|
||||
|
||||
import { ExmnResultRegisterPopupPage } from "@/features/exmn/ui/ExmnResultRegisterPopupPage";
|
||||
import type { ExmnBusiness } from "@/features/exmn/model/exmn-business-types";
|
||||
import { useEffect, useState } from "react";
|
||||
import type { WindowPopupContentProps } from "../model/types";
|
||||
|
||||
/** query parameter가 단일 문자열인지 안전하게 해석한다. */
|
||||
const getParam = (value: string | string[] | undefined) =>
|
||||
Array.isArray(value) ? value[0] ?? "" : value ?? "";
|
||||
|
||||
/** 부모 목록이 전달한 선택 사업 요약을 팝업 표시 계약으로 복원한다. */
|
||||
const buildBusinessSummary = (searchParams: WindowPopupContentProps["searchParams"]): ExmnBusiness => ({
|
||||
exmnBizId: getParam(searchParams.exmnBizId),
|
||||
bizYr: getParam(searchParams.bizYr),
|
||||
bizNm: getParam(searchParams.bizNm),
|
||||
bizCycl: Number(getParam(searchParams.bizCycl) || 0),
|
||||
ctpvNm: getParam(searchParams.ctpvNm),
|
||||
sggNm: getParam(searchParams.sggNm),
|
||||
trtdstCnt: Number(getParam(searchParams.trtdstCnt) || 0),
|
||||
prcisExmnPrlg: Number(getParam(searchParams.prcisExmnPrlg) || 0),
|
||||
tpco: Number(getParam(searchParams.tpco) || 0),
|
||||
reportYn: getParam(searchParams.reportYn) === "Y" ? "Y" : "N",
|
||||
});
|
||||
|
||||
/** 선택 조사사업의 결과등록 WindowPopup을 실제 업로드 화면에 연결한다. */
|
||||
export default function ExmnResultRegisterPopupContent({ searchParams }: WindowPopupContentProps) {
|
||||
const [isMounted, setIsMounted] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
setIsMounted(true);
|
||||
}, []);
|
||||
|
||||
if (!isMounted) {
|
||||
return <div className="min-h-dvh bg-[#f3f7fa]" suppressHydrationWarning />;
|
||||
}
|
||||
|
||||
const businessSummary = buildBusinessSummary(searchParams);
|
||||
return (
|
||||
<ExmnResultRegisterPopupPage
|
||||
exmnBizId={businessSummary.exmnBizId}
|
||||
sampleMode={getParam(searchParams.sample) === "Y"}
|
||||
businessSummary={businessSummary}
|
||||
/>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user