Files
frontend/apps/sn/src/features/exmn/ui/exmn-result-register-popup-page.tsx
T

685 lines
25 KiB
TypeScript

"use client";
import { handleUpload } from "@/components/lib/utils";
import LoadingOverlay from "@/components/ui/loading-bar";
import { postPopupMessageAndClose } from "@/features/window-popup/lib/popup-message";
import { WINDOW_POPUP_MESSAGE_TYPE } from "@/features/window-popup/model/types";
import {
StandardFormField,
StandardFormSection,
StandardPopupLayout,
} from "@common/business_components";
import { Button, Dialog, DialogContent, DialogFooter, DialogClose } from "@common/components";
import { useConfirm } from "@frontend-opensource/use-react-hooks";
import type { HttpResponse } from "tus-js-client";
import { useEffect, useState } from "react";
import {
commitExmnImport,
getExmnBusiness,
updateExmnAttachments,
validateExmnImport,
} from "../api/exmn-business-api";
import {
EXMN_FILE_RULES,
getExmnTemplatePrerequisiteMessage,
validateExmnFile,
validateExmnFiles,
} from "../lib/exmn-business-validation";
import type {
ExmnAttachmentKey,
ExmnBusiness,
ExmnBusinessDetail,
ExmnFileType,
ExmnImportValidationResult,
} from "../model/exmn-business-types";
const FILE_TYPES: ExmnFileType[] = [
"EXMN_EXCEL",
"PPL_EXMN_PDF",
"MNHL_EXMN_PDF",
"BIZ_REPORT",
"LOCATION_MAP",
];
const VALIDATION_FILE_TYPES: ExmnFileType[] = [
"EXMN_EXCEL",
"PPL_EXMN_PDF",
"MNHL_EXMN_PDF",
];
const VALIDATION_BUTTON_FILE_TYPES = new Set<ExmnFileType>(
VALIDATION_FILE_TYPES,
);
/** 파일별로 실제 수행하는 검토 항목과 아직 적용하지 않은 범위를 사용자 문구로 정의한다. */
const VALIDATION_CHECK_GUIDES: Partial<
Record<ExmnFileType, { checkedItems: string[]; pendingNote?: string }>
> = {
EXMN_EXCEL: {
checkedItems: [
"처리분구별 관로·맨홀 시트와 필수값",
"관로·맨홀 번호 중복",
"관로번호와 본단위구간 연결",
"결함코드·점수·등급 및 저장 가능 여부",
],
},
PPL_EXMN_PDF: {
checkedItems: [
"PDF 관로번호 텍스트 추출",
"PDF 관로번호의 조사결과 Excel 포함 여부",
],
},
MNHL_EXMN_PDF: {
checkedItems: ["PDF 파일 형식", "조사결과 Excel 구조 및 저장 가능 여부"],
pendingNote: "맨홀보고서의 맨홀번호에는 실제 GIS 맨홀번호를 입력해주세요. 보고서 열람 시 번호를 대조하며, 업로드 검토에는 PDF 내부 번호 대조가 포함되지 않습니다.",
},
};
interface SelectedFile {
file: File;
fileList: FileList;
}
type SelectedFiles = Partial<Record<ExmnFileType, SelectedFile>>;
type Notice = { type: "success" | "error" | "info"; text: string } | null;
type FileValidationStatus = "success" | "error";
type FileValidationResults = Partial<
Record<ExmnFileType, ExmnImportValidationResult>
>;
type FileValidationMessages = Partial<Record<ExmnFileType, string[]>>;
interface ExmnResultRegisterPopupPageProps {
exmnBizId: string;
businessSummary: ExmnBusiness;
}
/** 선택한 조사 원본을 validate·commit 요청에 같은 필드명으로 담는다. */
const buildImportFormData = (
files: SelectedFiles,
validation?: ExmnImportValidationResult | null,
validationType?: ExmnFileType,
) => {
const formData = new FormData();
if (files.EXMN_EXCEL) formData.append("exmnFile", files.EXMN_EXCEL.file);
if (files.PPL_EXMN_PDF) formData.append("pplFile", files.PPL_EXMN_PDF.file);
if (files.MNHL_EXMN_PDF)
formData.append("mnhlFile", files.MNHL_EXMN_PDF.file);
if (validation?.fileHash) formData.append("fileHash", validation.fileHash);
if (validationType) formData.append("validationType", validationType);
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,
businessSummary,
}: ExmnResultRegisterPopupPageProps) {
const { confirm } = useConfirm();
// 양식 준비 안내만 넓은 모달로 표시해 다른 공통 알림창의 크기는 유지한다.
const [templateNotice, setTemplateNotice] = useState("");
const [detail, setDetail] = useState<ExmnBusinessDetail | null>(null);
const [files, setFiles] = useState<SelectedFiles>({});
/** 파일별 서버 검토 결과를 보관해 다른 파일 검토 후에도 결과와 건수를 유지한다. */
const [validationResults, setValidationResults] =
useState<FileValidationResults>({});
const [fileValidationStatuses, setFileValidationStatuses] = useState<
Partial<Record<ExmnFileType, FileValidationStatus>>
>({});
/** 서버 호출 전 오류와 예외 메시지도 해당 파일의 검토 내역에 남긴다. */
const [fileValidationMessages, setFileValidationMessages] =
useState<FileValidationMessages>({});
const [uploadStatus, setUploadStatus] = useState("");
const [isLoading, setIsLoading] = useState(false);
const [notice, setNotice] = useState<Notice>(null);
/** 맨홀 PDF 하나만 선택한 경우 기존 조사자료의 첨부 교체로 처리한다. */
const isManholeOnly = Boolean(files.MNHL_EXMN_PDF) && Object.keys(files).length === 1;
/** 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;
};
}, []);
/** 팝업 진입 시 선택 사업의 처리분구·첨부 상세를 읽는다. */
useEffect(() => {
if (!exmnBizId) {
setNotice({
type: "error",
text: "결과를 등록할 조사사업 식별자가 없습니다.",
});
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;
};
}, [exmnBizId]);
/** 검토 후 사업이 수정된 경우도 업로드 전에 발견하도록 서버의 최신 양식 연결 상태를 확인한다. */
const ensureTemplateReady = async () => {
const latestDetail = await getExmnBusiness(exmnBizId);
setDetail(latestDetail);
const message = getExmnTemplatePrerequisiteMessage(latestDetail);
if (message) {
setIsLoading(false);
setTemplateNotice(message);
return false;
}
return true;
};
/** 파일 변경 시 이전 검토결과를 폐기하고 확장자·크기 규칙을 즉시 확인한다. */
const handleFileChange = (
fileType: ExmnFileType,
fileList: FileList | null,
) => {
setUploadStatus("");
const invalidatedTypes: ExmnFileType[] =
fileType === "EXMN_EXCEL"
? ["EXMN_EXCEL", "PPL_EXMN_PDF", "MNHL_EXMN_PDF"]
: [fileType];
setFileValidationStatuses((current) => {
const next = { ...current };
invalidatedTypes.forEach((type) => delete next[type]);
return next;
});
setValidationResults((current) => {
const next = { ...current };
invalidatedTypes.forEach((type) => delete next[type]);
return next;
});
setFileValidationMessages((current) => {
const next = { ...current };
invalidatedTypes.forEach((type) => delete next[type]);
return next;
});
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 uploadAndLinkAttachments = async () => {
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}%`);
});
}
if (Object.keys(attachments).length > 0) {
await updateExmnAttachments(exmnBizId, attachments);
}
};
/** 선택한 파일을 서버 검토 API로 전달하고 파일별 결과를 화면에 반영한다. */
const handleValidate = async (fileType: ExmnFileType) => {
if (!files[fileType]) {
setNotice({
type: "error",
text: `${EXMN_FILE_RULES[fileType].label} 파일을 먼저 선택해주세요.`,
});
return;
}
const fileMap = Object.fromEntries(
Object.entries(files).map(([type, selected]) => [type, selected?.file]),
) as Partial<Record<ExmnFileType, File>>;
const validationMessage = isManholeOnly
? validateExmnFile("MNHL_EXMN_PDF", files.MNHL_EXMN_PDF!.file)
: validateExmnFiles(fileMap);
if (validationMessage) {
setFileValidationStatuses((current) => ({
...current,
[fileType]: "error",
}));
setValidationResults((current) => {
const next = { ...current };
delete next[fileType];
return next;
});
setFileValidationMessages((current) => ({
...current,
[fileType]: [validationMessage],
}));
setNotice(null);
return;
}
setIsLoading(true);
setNotice(null);
try {
const result = await validateExmnImport(
exmnBizId,
buildImportFormData(files, null, fileType),
);
setValidationResults((current) => ({ ...current, [fileType]: result }));
setFileValidationStatuses((current) => ({
...current,
[fileType]: result.valid ? "success" : "error",
}));
setFileValidationMessages((current) => ({
...current,
[fileType]: result.errors.map((item) => item.message),
}));
setNotice(null);
} catch (error) {
const message =
error instanceof Error
? error.message
: "조사파일 검토 중 오류가 발생했습니다.";
setValidationResults((current) => {
const next = { ...current };
delete next[fileType];
return next;
});
setFileValidationStatuses((current) => ({
...current,
[fileType]: "error",
}));
setFileValidationMessages((current) => ({
...current,
[fileType]: [message],
}));
setNotice(null);
} finally {
setIsLoading(false);
}
};
/** 정상 검토된 조사결과 Excel을 저장하고 부모 화면에 완료 메시지를 보낸 뒤 팝업을 닫는다. */
const handleCommit = async () => {
const excelValidation = validationResults.EXMN_EXCEL;
if (!canCommit) return;
setIsLoading(true);
setNotice(null);
try {
if (!isManholeOnly && !(await ensureTemplateReady())) return;
// 로딩 화면이 저장 확인창을 가리지 않도록 사전 확인과 업로드 상태를 나눈다.
setIsLoading(false);
if (!(await confirm("조사결과를 저장하시겠습니까?"))) return;
setIsLoading(true);
if (isManholeOnly) {
// 검토 이후 기존 조사자료가 변경됐을 수 있어 첨부 업로드 직전에 다시 대조한다.
const result = await validateExmnImport(exmnBizId, buildImportFormData(files, null, "MNHL_EXMN_PDF"));
if (!result.valid) throw new Error(result.errors.map((item) => item.message).join("\n"));
}
await uploadAndLinkAttachments();
if (!isManholeOnly) await commitExmnImport(
exmnBizId,
buildImportFormData(files, excelValidation),
);
postPopupMessageAndClose(
WINDOW_POPUP_MESSAGE_TYPE.EXMN_RESULT_COMMITTED,
{
exmnBizId,
},
);
} catch (error) {
const message = error instanceof Error
? error.message : "조사결과 최종 저장 중 오류가 발생했습니다.";
// 사전 확인 이후 다른 창에서 사업이 수정돼 서버가 차단한 경우도 동일한 알림으로 안내한다.
if (message.includes("조사양식 받기")) {
setIsLoading(false);
setTemplateNotice(message);
} else {
setNotice({ type: "error", text: message });
}
} finally {
setIsLoading(false);
}
};
/** 검토를 한 파일만 입력 순서대로 하단 검토 내역에 표시한다. */
const visibleValidationTypes = VALIDATION_FILE_TYPES.filter(
(fileType) => fileValidationStatuses[fileType],
);
const excelValidation = validationResults.EXMN_EXCEL;
/** 단독 첨부 교체와 신규 Excel 결과 저장의 필수 검토 조건을 분리한다. */
const canCommit = isManholeOnly
? fileValidationStatuses.MNHL_EXMN_PDF === "success" && Boolean(validationResults.MNHL_EXMN_PDF?.valid)
: Boolean(excelValidation?.valid) && fileValidationStatuses.EXMN_EXCEL === "success"
&& fileValidationStatuses.PPL_EXMN_PDF === "success";
return (
<StandardPopupLayout
title="조사 결과 업로드"
showDefaultCancelAction={false}
actions={
<>
<Button
type="button"
color="primary"
disabled={
isLoading || !canCommit
}
onClick={() => void handleCommit()}
>
저장
</Button>
<Button
type="button"
color="white"
disabled={isLoading}
onClick={() => window.close()}
>
취소
</Button>
</>
}
bodyClassName="space-y-3 overflow-x-hidden"
>
<LoadingOverlay isLoading={isLoading} />
<Dialog open={!!templateNotice} onOpenChange={() => setTemplateNotice("")}>
<DialogContent aria-describedby="exmn-template-notice" className="!w-[46rem] !max-w-[calc(100vw-3.2rem)]">
<div className="py-10">
<p id="exmn-template-notice" className="whitespace-pre-line break-keep text-center text-[1.4rem] leading-relaxed text-foreground [text-wrap:balance]">
{templateNotice}
</p>
</div>
<DialogFooter className="!justify-center">
<DialogClose asChild><Button>확인</Button></DialogClose>
</DialogFooter>
</DialogContent>
</Dialog>
{notice && notice.type !== "success" && (
<div
role="alert"
className={`shrink-0 whitespace-pre-line rounded-sm border px-4 py-3 text-[1.3rem] ${
notice.type === "error"
? "border-red-200 bg-red-50 text-red-700"
: "border-primary bg-primary-transparent text-primary"
}`}
>
{notice.text}
</div>
)}
<StandardFormSection title="선택 조사사업" columns={2}>
<StandardFormField label="사업차수" columns={2}>
<p className="flex min-h-[3.2rem] items-center text-[1.3rem] font-medium text-label">
{detail?.bizCycl ?? businessSummary.bizCycl ?? "-"}차
</p>
</StandardFormField>
<StandardFormField label="사업년도" columns={2}>
<p className="flex min-h-[3.2rem] items-center text-[1.3rem] font-medium text-label">
{detail?.bizYr ?? businessSummary.bizYr ?? "-"}
</p>
</StandardFormField>
<StandardFormField label="행정구역" columns={2}>
<p
className="flex min-h-[3.2rem] min-w-0 items-center truncate text-[1.3rem] font-medium text-label"
title={`${detail?.ctpvNm ?? businessSummary.ctpvNm} ${detail?.sggNm ?? businessSummary.sggNm}`}
>
{detail?.ctpvNm ?? businessSummary.ctpvNm}{" "}
{detail?.sggNm ?? businessSummary.sggNm}
</p>
</StandardFormField>
<StandardFormField label="사업명" columns={2}>
<p
className="flex min-h-[3.2rem] min-w-0 items-center truncate text-[1.3rem] font-medium text-label"
title={detail?.bizNm ?? businessSummary.bizNm}
>
{detail?.bizNm ?? businessSummary.bizNm ?? "-"}
</p>
</StandardFormField>
</StandardFormSection>
<section className="shrink-0 overflow-hidden rounded-sm border border-border bg-background">
<div
className="grid items-center gap-3 bg-primary px-3 py-3 text-center text-[1.3rem] font-semibold text-primary-foreground"
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 validationStatus = fileValidationStatuses[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-border px-3 py-3 text-[1.3rem] text-label"
style={{ gridTemplateColumns: "180px minmax(0, 1fr) 80px 112px" }}
>
<label htmlFor={inputId} className="font-medium text-label">
{(fileType === "EXMN_EXCEL" ||
fileType === "PPL_EXMN_PDF" ||
fileType === "MNHL_EXMN_PDF") && (
<span className="text-red-500">* </span>
)}
{rule.label}
</label>
<div
className={`flex min-w-0 items-center gap-2 rounded-md border px-3 py-1.5 ${
validationStatus === "success"
? "border-emerald-300 bg-emerald-50 font-semibold text-emerald-700"
: validationStatus === "error"
? "border-red-300 bg-red-50 font-semibold text-red-700"
: selected
? "border-primary bg-primary-transparent font-semibold text-primary"
: "border-border bg-background text-label"
}`}
title={selected?.file.name}
>
<span className="min-w-0 flex-1 truncate">
{selected?.file.name ||
`${rule.extensions.map((extension) => `.${extension}`).join(", ")} 파일`}
</span>
{validationStatus && (
<span className="shrink-0 text-xs">
{validationStatus === "success" ? "검토 완료" : "검토 실패"}
</span>
)}
</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)
}
/>
<Button
type="button"
size="sm"
className="w-full px-3"
disabled={isLoading}
onClick={() => document.getElementById(inputId)?.click()}
>
찾기
</Button>
</div>
<div className="text-center">
{canValidate ? (
<Button
className="w-full px-3"
size="sm"
disabled={isLoading}
onClick={() => void handleValidate(fileType)}
>
데이터 검토
</Button>
) : (
<span className="text-muted-foreground">-</span>
)}
</div>
</div>
);
})}
</section>
{visibleValidationTypes.length > 0 && (
<section
className="shrink-0 overflow-hidden rounded-sm border border-border bg-background"
aria-label="데이터 검토 내역"
>
<div className="flex items-center border-b border-border bg-container px-4 py-3">
<h2 className="text-[1.3rem] font-semibold text-label">
데이터 검토 내역
</h2>
</div>
{visibleValidationTypes.map((fileType) => {
const status = fileValidationStatuses[fileType]!;
const result = validationResults[fileType];
const messages = fileValidationMessages[fileType] ?? [];
const guide = fileType === "MNHL_EXMN_PDF" && isManholeOnly
? { checkedItems: ["PDF 맨홀번호 판독", "기존 조사자료에 연결된 실제 GIS 맨홀번호와 대조"], pendingNote: "저장 시 기존 조사자료는 유지하고 맨홀 보고서만 교체합니다." }
: VALIDATION_CHECK_GUIDES[fileType];
const countText =
fileType === "EXMN_EXCEL"
? `관로 ${result?.summary.pplCount ?? 0}건 · 맨홀 ${result?.summary.mnhlCount ?? 0}건`
: fileType === "PPL_EXMN_PDF"
? `Excel 관로 ${result?.summary.pplCount ?? 0}건 기준`
: "";
return (
<div
key={fileType}
className="grid items-start gap-3 border-t border-border px-3 py-2.5 text-[1.3rem] text-label"
style={{ gridTemplateColumns: "180px 88px minmax(0, 1fr)" }}
>
<span className="font-medium text-label">
{EXMN_FILE_RULES[fileType].label}
</span>
<span
className={`inline-flex w-fit rounded-full px-2.5 py-1 text-xs font-semibold ${
status === "success"
? "bg-emerald-100 text-emerald-700"
: "bg-red-100 text-red-700"
}`}
>
{status === "success" ? "검토 완료" : "검토 실패"}
</span>
<div className="min-w-0">
{status === "success" && countText && (
<p className="mb-1 font-medium text-label">{countText}</p>
)}
<p className="leading-5 text-label">
{guide?.checkedItems.join(" · ")}
</p>
{guide?.pendingNote && (
<p className="mt-1 text-amber-700">{guide.pendingNote}</p>
)}
{status === "error" && messages.length > 0 && (
<p
className="mt-1 truncate text-red-700"
title={messages.join("\n")}
>
{messages[0]}
{messages.length > 1
? ` 외 ${messages.length - 1}건`
: ""}
</p>
)}
</div>
</div>
);
})}
</section>
)}
{uploadStatus && (
<p className="text-right text-[1.3rem] text-primary">{uploadStatus}</p>
)}
</StandardPopupLayout>
);
}