685 lines
25 KiB
TypeScript
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>
|
|
);
|
|
}
|