679 lines
22 KiB
TypeScript
679 lines
22 KiB
TypeScript
/**
|
|
* 화면명: 준설실적 등록
|
|
* 준설 사진을 등록하고 촬영정보·위치·인접 관로를 확인하여 준설실적을 저장하는 화면이다.
|
|
*/
|
|
"use client";
|
|
|
|
import dynamic from "next/dynamic";
|
|
import { useConfirm } from "@frontend-opensource/use-react-hooks";
|
|
import { DredgingRegistrationBody } from "@/features/dredging/ui/DredgingRegistrationBody";
|
|
import { StandardPopupLayout } from "@common/business_components";
|
|
import {
|
|
Button,
|
|
Dialog,
|
|
DialogContent,
|
|
DialogFooter,
|
|
DialogHeader,
|
|
DialogTitle,
|
|
} from "@common/components";
|
|
import { useAlertStore } from "@/shared/lib";
|
|
import { useEffect, useRef, useState } from "react";
|
|
|
|
import {
|
|
findNearestPipeMatches,
|
|
isDredgingBackendEnabled,
|
|
saveDredgingRegistrations,
|
|
type AttachmentReference,
|
|
uploadDredgingPhotos,
|
|
} from "../lib/client";
|
|
import { readPhotoMetadata } from "../lib/exif";
|
|
import { useDredgingCommonCodes } from "../lib/useDredgingCommonCodes";
|
|
import { createDredgingClientUuid } from "../lib/uuid";
|
|
import { createDredgingRegistrationSession } from "../lib/registration-request";
|
|
import {
|
|
EMPTY_PHOTO_METADATA,
|
|
type DredgingDraftGroup,
|
|
type DredgingPhotoDraft,
|
|
type DredgingSaveGroup,
|
|
type GeoPoint,
|
|
} from "../lib/types";
|
|
|
|
// 무거운 OpenLayers 지도 모듈은 준설 등록 본문과 별도 chunk로 지연 로드한다.
|
|
const DredgingLocationMap = dynamic(() => import("./DredgingLocationMap"), {
|
|
ssr: false,
|
|
});
|
|
|
|
type DredgingRegisterModalProps = {
|
|
open: boolean;
|
|
onOpenChange: (open: boolean) => void;
|
|
onSaved: (groups: DredgingSaveGroup[]) => void;
|
|
presentation?: "modal" | "popup";
|
|
};
|
|
|
|
const createGroup = (): DredgingDraftGroup => ({
|
|
id: createDredgingClientUuid(),
|
|
registrationMode: "PHOTO",
|
|
photos: [],
|
|
manualLocation: null,
|
|
matchedPipe: null,
|
|
matchError: null,
|
|
isMatching: false,
|
|
});
|
|
|
|
const needsManualLocation = (group: DredgingDraftGroup) =>
|
|
group.registrationMode === "MANUAL" ||
|
|
group.photos.some((photo) => !photo.metadata.location);
|
|
|
|
const getResolvedLocation = (group: DredgingDraftGroup): GeoPoint | null => {
|
|
if (group.manualLocation) return group.manualLocation;
|
|
return (
|
|
group.photos.find((photo) => photo.metadata.location)?.metadata.location ??
|
|
null
|
|
);
|
|
};
|
|
|
|
const formatLocalDate = (date: Date) => {
|
|
const year = date.getFullYear();
|
|
const month = String(date.getMonth() + 1).padStart(2, "0");
|
|
const day = String(date.getDate()).padStart(2, "0");
|
|
return `${year}-${month}-${day}`;
|
|
};
|
|
|
|
const parseLocalDate = (value: string) => new Date(`${value}T00:00:00`);
|
|
|
|
const DredgingRegisterModal = ({
|
|
open,
|
|
onOpenChange,
|
|
onSaved,
|
|
presentation = "modal",
|
|
}: DredgingRegisterModalProps) => {
|
|
const alert = useAlertStore((state) => state.setMessage);
|
|
const { confirm } = useConfirm();
|
|
// 등록방식·위치출처·사진상태 코드값은 유지하고 CMS 코드명을 화면에 표시한다.
|
|
const {
|
|
registrationModeLabels,
|
|
locationSourceLabels,
|
|
photoStatusLabels,
|
|
} = useDredgingCommonCodes();
|
|
const [groups, setGroups] = useState<DredgingDraftGroup[]>([]);
|
|
const groupsRef = useRef<DredgingDraftGroup[]>([]);
|
|
const [activeGroupId, setActiveGroupId] = useState<string | null>(null);
|
|
const [editingLocationGroupId, setEditingLocationGroupId] = useState<
|
|
string | null
|
|
>(null);
|
|
// 지도 클릭 좌표는 적용 전까지 원본 EXIF 또는 확정 수동 위치를 덮어쓰지 않는 임시 상태다.
|
|
const [locationDraft, setLocationDraft] = useState<GeoPoint | null>(null);
|
|
// 모든 등록 그룹은 하나의 준설작업이며 화면 상단의 단일 준설일자를 공유한다.
|
|
const [representativeDredgingDate, setRepresentativeDredgingDate] =
|
|
useState("");
|
|
const [isSaving, setIsSaving] = useState(false);
|
|
const uploadedAttachmentsRef = useRef(new Map<string, AttachmentReference>());
|
|
// 재렌더와 실패 재시도 사이에 요청 ID를 유지하며 모달 재진입에서만 새 세션으로 교체한다.
|
|
const registrationSessionRef = useRef<ReturnType<
|
|
typeof createDredgingRegistrationSession
|
|
> | null>(null);
|
|
// React의 다음 렌더 전에 발생하는 연속 저장 클릭도 같은 실행으로 제한한다.
|
|
const savingRef = useRef(false);
|
|
const [statusMessage, setStatusMessage] = useState("");
|
|
|
|
const commitGroups = (
|
|
updater: (current: DredgingDraftGroup[]) => DredgingDraftGroup[],
|
|
) => {
|
|
const next = updater(groupsRef.current);
|
|
groupsRef.current = next;
|
|
setGroups(next);
|
|
};
|
|
|
|
useEffect(() => {
|
|
if (!open) return;
|
|
registrationSessionRef.current = createDredgingRegistrationSession();
|
|
uploadedAttachmentsRef.current.clear();
|
|
const initial = [createGroup()];
|
|
groupsRef.current = initial;
|
|
setGroups(initial);
|
|
setActiveGroupId(initial[0]?.id ?? null);
|
|
setEditingLocationGroupId(null);
|
|
setLocationDraft(null);
|
|
setRepresentativeDredgingDate("");
|
|
setStatusMessage("");
|
|
}, [open]);
|
|
|
|
/**
|
|
* 그룹의 모든 사진 위치 또는 수동 좌표를 서버 후보 조회 형식으로 만들고,
|
|
* 결과가 하나의 관로로 수렴하는지 확인한다.
|
|
*/
|
|
const resolvePipeMatch = async (group: DredgingDraftGroup) => {
|
|
const locations =
|
|
group.manualLocation !== null
|
|
? [
|
|
{
|
|
locationKey: `${group.id}-manual`,
|
|
...group.manualLocation,
|
|
},
|
|
]
|
|
: group.photos
|
|
.filter((photo) => photo.metadata.location)
|
|
.map((photo) => ({
|
|
locationKey: photo.key,
|
|
...photo.metadata.location!,
|
|
}));
|
|
|
|
const expectedLocationCount =
|
|
group.manualLocation !== null
|
|
? 1
|
|
: group.registrationMode === "MANUAL"
|
|
? 0
|
|
: group.photos.length;
|
|
|
|
if (
|
|
locations.length === 0 ||
|
|
(group.registrationMode === "PHOTO" &&
|
|
locations.length !== expectedLocationCount)
|
|
) {
|
|
commitGroups((current) =>
|
|
current.map((item) =>
|
|
item.id === group.id
|
|
? {
|
|
...item,
|
|
matchedPipe: null,
|
|
matchError: "지도에서 준설위치를 먼저 등록해주세요.",
|
|
isMatching: false,
|
|
}
|
|
: item,
|
|
),
|
|
);
|
|
return;
|
|
}
|
|
|
|
commitGroups((current) =>
|
|
current.map((item) =>
|
|
item.id === group.id
|
|
? { ...item, isMatching: true, matchError: null }
|
|
: item,
|
|
),
|
|
);
|
|
|
|
try {
|
|
const matches = await findNearestPipeMatches(locations);
|
|
if (matches.size !== locations.length) {
|
|
throw new Error("탐색거리 안에서 관로를 찾지 못한 위치가 있습니다.");
|
|
}
|
|
|
|
const matchedPipes = Array.from(matches.values());
|
|
const pipeKeys = new Set(
|
|
matchedPipes.map(
|
|
(pipe) => `${pipe.mngInstCd.trim()}::${pipe.fcltMngNo}`,
|
|
),
|
|
);
|
|
if (pipeKeys.size !== 1) {
|
|
throw new Error(
|
|
"사진 위치가 서로 다른 관로로 매핑됩니다. 관로 그룹을 분리해주세요.",
|
|
);
|
|
}
|
|
|
|
const matchedPipe = matchedPipes[0] ?? null;
|
|
commitGroups((current) =>
|
|
current.map((item) =>
|
|
item.id === group.id
|
|
? {
|
|
...item,
|
|
matchedPipe,
|
|
matchError: matchedPipe
|
|
? null
|
|
: "최근접 관로를 찾지 못했습니다.",
|
|
isMatching: false,
|
|
}
|
|
: item,
|
|
),
|
|
);
|
|
} catch (error) {
|
|
commitGroups((current) =>
|
|
current.map((item) =>
|
|
item.id === group.id
|
|
? {
|
|
...item,
|
|
matchedPipe: null,
|
|
matchError:
|
|
error instanceof Error
|
|
? error.message
|
|
: "최근접 관로 조회에 실패했습니다.",
|
|
isMatching: false,
|
|
}
|
|
: item,
|
|
),
|
|
);
|
|
}
|
|
};
|
|
|
|
const handleFilesChange = async (
|
|
groupId: string,
|
|
selectedFiles: File[] | null,
|
|
) => {
|
|
const files = selectedFiles ?? [];
|
|
if (files.length > 5) {
|
|
alert("관로별 사진은 최대 5장까지 선택할 수 있습니다.");
|
|
return;
|
|
}
|
|
if (editingLocationGroupId === groupId) {
|
|
cancelLocationEditing();
|
|
}
|
|
|
|
const checkingPhotos: DredgingPhotoDraft[] = files.map((file) => ({
|
|
key: createDredgingClientUuid(),
|
|
file,
|
|
metadata: EMPTY_PHOTO_METADATA,
|
|
}));
|
|
|
|
commitGroups((current) =>
|
|
current.map((group) =>
|
|
group.id === groupId
|
|
? {
|
|
...group,
|
|
photos: checkingPhotos,
|
|
manualLocation: null,
|
|
matchedPipe: null,
|
|
matchError: null,
|
|
isMatching: false,
|
|
}
|
|
: group,
|
|
),
|
|
);
|
|
|
|
const parsedPhotos = await Promise.all(
|
|
checkingPhotos.map(async (photo) => ({
|
|
...photo,
|
|
metadata: await readPhotoMetadata(photo.file),
|
|
})),
|
|
);
|
|
|
|
// 최초 사진의 촬영일을 공통 준설일자 초깃값으로만 사용한다.
|
|
// 이후 다른 사진을 추가하거나 탭을 바꿔도 사용자가 수정한 값은 덮어쓰지 않는다.
|
|
const firstPhotoDate = parsedPhotos.find(
|
|
(photo) => photo.metadata.dredgingDate,
|
|
)?.metadata.dredgingDate;
|
|
if (firstPhotoDate) {
|
|
setRepresentativeDredgingDate((current) => current || firstPhotoDate);
|
|
}
|
|
|
|
let nextGroup: DredgingDraftGroup | null = null;
|
|
commitGroups((current) =>
|
|
current.map((group) => {
|
|
if (group.id !== groupId) return group;
|
|
nextGroup = { ...group, photos: parsedPhotos };
|
|
return nextGroup;
|
|
}),
|
|
);
|
|
|
|
if (nextGroup) {
|
|
const groupToResolve: DredgingDraftGroup = nextGroup;
|
|
if (!needsManualLocation(groupToResolve)) {
|
|
await resolvePipeMatch(groupToResolve);
|
|
} else {
|
|
commitGroups((current) =>
|
|
current.map((group) =>
|
|
group.id === groupId
|
|
? {
|
|
...group,
|
|
matchError: "GPS가 없는 사진은 지도 위치 등록이 필요합니다.",
|
|
}
|
|
: group,
|
|
),
|
|
);
|
|
}
|
|
}
|
|
};
|
|
|
|
/**
|
|
* 공통 첨부영역의 중복 파일목록은 숨기고 메타정보 표를 단일 파일목록으로 사용한다.
|
|
* 표의 삭제 버튼도 공통 FileUpload와 같은 files 배열 갱신 흐름으로 연결한다.
|
|
*/
|
|
const removePhoto = (groupId: string, photoKey: string) => {
|
|
const group = groupsRef.current.find((item) => item.id === groupId);
|
|
if (!group) return;
|
|
void handleFilesChange(
|
|
groupId,
|
|
group.photos
|
|
.filter((photo) => photo.key !== photoKey)
|
|
.map((photo) => photo.file),
|
|
);
|
|
};
|
|
|
|
const changeRegistrationMode = async (
|
|
groupId: string,
|
|
registrationMode: DredgingDraftGroup["registrationMode"],
|
|
) => {
|
|
const currentGroup = groupsRef.current.find(
|
|
(group) => group.id === groupId,
|
|
);
|
|
if (!currentGroup || currentGroup.registrationMode === registrationMode) {
|
|
return;
|
|
}
|
|
const hasEnteredData =
|
|
currentGroup.photos.length > 0 || currentGroup.manualLocation !== null;
|
|
if (
|
|
hasEnteredData &&
|
|
!(await confirm(
|
|
"등록 방식을 변경하면 현재 사진과 입력한 위치가 초기화됩니다. 변경하시겠습니까?",
|
|
))
|
|
) {
|
|
return;
|
|
}
|
|
|
|
commitGroups((current) =>
|
|
current.map((group) =>
|
|
group.id === groupId
|
|
? {
|
|
...group,
|
|
registrationMode,
|
|
photos: registrationMode === "MANUAL" ? [] : group.photos,
|
|
matchedPipe: null,
|
|
matchError: null,
|
|
manualLocation: null,
|
|
}
|
|
: group,
|
|
),
|
|
);
|
|
setActiveGroupId(groupId);
|
|
setLocationDraft(null);
|
|
setEditingLocationGroupId(null);
|
|
};
|
|
|
|
const updateManualLocation = async (
|
|
groupId: string,
|
|
manualLocation: GeoPoint,
|
|
) => {
|
|
const currentGroup = groupsRef.current.find(
|
|
(group) => group.id === groupId,
|
|
);
|
|
if (!currentGroup) return;
|
|
|
|
const nextGroup = {
|
|
...currentGroup,
|
|
manualLocation,
|
|
matchedPipe: null,
|
|
matchError: null,
|
|
};
|
|
commitGroups((current) =>
|
|
current.map((group) => (group.id === groupId ? nextGroup : group)),
|
|
);
|
|
await resolvePipeMatch(nextGroup);
|
|
};
|
|
|
|
// 본문 하단 지도 편집은 현재 확정 위치를 복사한 임시 마커에서 시작한다.
|
|
const startLocationEditing = (group: DredgingDraftGroup) => {
|
|
setActiveGroupId(group.id);
|
|
setEditingLocationGroupId(group.id);
|
|
setLocationDraft(getResolvedLocation(group));
|
|
setStatusMessage("");
|
|
};
|
|
|
|
// 취소 시 적용하지 않은 지도 클릭 위치를 폐기하고 기존 위치를 유지한다.
|
|
const cancelLocationEditing = () => {
|
|
setEditingLocationGroupId(null);
|
|
setLocationDraft(null);
|
|
};
|
|
|
|
// 새 지도 좌표를 선택하면 이전 위치에서 발생한 저장 오류 안내를 즉시 비운다.
|
|
const changeLocationDraft = (point: GeoPoint) => {
|
|
setLocationDraft(point);
|
|
setStatusMessage("");
|
|
};
|
|
|
|
// 사용자가 명시적으로 적용한 위치만 확정 상태와 최근접 관로 조회에 반영한다.
|
|
const applyLocationDraft = async () => {
|
|
if (!editingLocationGroupId || !locationDraft) {
|
|
alert("지도에서 준설위치를 선택해주세요.");
|
|
return;
|
|
}
|
|
setStatusMessage("");
|
|
await updateManualLocation(editingLocationGroupId, locationDraft);
|
|
cancelLocationEditing();
|
|
};
|
|
|
|
const validateAndBuildSaveGroups = () => {
|
|
if (!representativeDredgingDate) {
|
|
throw new Error("준설일자를 입력해주세요.");
|
|
}
|
|
if (representativeDredgingDate > formatLocalDate(new Date())) {
|
|
throw new Error("미래 날짜는 준설일자로 등록할 수 없습니다.");
|
|
}
|
|
|
|
const saveGroups: DredgingSaveGroup[] = [];
|
|
for (let index = 0; index < groups.length; index += 1) {
|
|
const group = groups[index]!;
|
|
const label = `관로 ${index + 1}`;
|
|
if (group.registrationMode === "PHOTO" && group.photos.length === 0) {
|
|
throw new Error(`${label}: 사진을 선택해주세요.`);
|
|
}
|
|
if (group.photos.length > 5) {
|
|
throw new Error(`${label}: 사진은 최대 5장까지 등록할 수 있습니다.`);
|
|
}
|
|
|
|
const location = getResolvedLocation(group);
|
|
if (!location) {
|
|
throw new Error(`${label}: 지도에서 준설위치를 등록해주세요.`);
|
|
}
|
|
if (!group.matchedPipe) {
|
|
throw new Error(
|
|
`${label}: ${group.matchError ?? "최근접 관로 자동 매핑을 완료해주세요."}`,
|
|
);
|
|
}
|
|
saveGroups.push({
|
|
localId: group.id,
|
|
registrationMode: group.registrationMode,
|
|
dredgingDate: representativeDredgingDate,
|
|
location,
|
|
locationSource: group.manualLocation ? "MANUAL" : "EXIF",
|
|
pipe: group.matchedPipe,
|
|
photos: group.photos,
|
|
});
|
|
}
|
|
return saveGroups;
|
|
};
|
|
|
|
// 검증·업로드 후 등록 payload를 확정하며 HTTP 실패 시 세션 ID와 첨부를 유지한다.
|
|
const handleSave = async () => {
|
|
if (savingRef.current) return;
|
|
try {
|
|
const saveGroups = validateAndBuildSaveGroups();
|
|
savingRef.current = true;
|
|
setIsSaving(true);
|
|
setStatusMessage(
|
|
isDredgingBackendEnabled
|
|
? "공통 첨부파일 업로드를 시작합니다."
|
|
: "등록 내용을 확인하고 있습니다.",
|
|
);
|
|
|
|
const attachments = await uploadDredgingPhotos(
|
|
saveGroups,
|
|
uploadedAttachmentsRef.current,
|
|
);
|
|
uploadedAttachmentsRef.current = attachments;
|
|
setStatusMessage(
|
|
isDredgingBackendEnabled
|
|
? "준설 결과와 관로 매핑을 저장하고 있습니다."
|
|
: "준설 결과 등록을 완료했습니다.",
|
|
);
|
|
const session =
|
|
registrationSessionRef.current ?? createDredgingRegistrationSession();
|
|
registrationSessionRef.current = session;
|
|
await saveDredgingRegistrations(
|
|
session.prepare(saveGroups, attachments, isDredgingBackendEnabled),
|
|
);
|
|
// 성공 안내가 닫히기 전 재클릭에도 같은 ID·첨부를 사용한다. 다음 open에서 초기화한다.
|
|
onSaved(saveGroups);
|
|
alert("준설 결과를 등록했습니다.", () => onOpenChange(false));
|
|
} catch (error) {
|
|
const message =
|
|
error instanceof Error
|
|
? error.message
|
|
: "준설 결과 저장에 실패했습니다.";
|
|
setStatusMessage(message);
|
|
alert(message);
|
|
} finally {
|
|
savingRef.current = false;
|
|
setIsSaving(false);
|
|
}
|
|
};
|
|
|
|
const activeGroup =
|
|
groups.find((group) => group.id === activeGroupId) ?? groups[0] ?? null;
|
|
const activeResolvedLocation = activeGroup
|
|
? getResolvedLocation(activeGroup)
|
|
: null;
|
|
const isEditingActiveLocation =
|
|
activeGroup !== null && editingLocationGroupId === activeGroup.id;
|
|
// 지도에서 새로 찍은 좌표가 아직 확정 위치와 다른지 구분해 이전 좌표의 관로 오류를 노출하지 않는다.
|
|
const hasUnappliedLocationDraft = Boolean(
|
|
isEditingActiveLocation &&
|
|
locationDraft &&
|
|
(!activeResolvedLocation ||
|
|
locationDraft.longitude !== activeResolvedLocation.longitude ||
|
|
locationDraft.latitude !== activeResolvedLocation.latitude),
|
|
);
|
|
|
|
// 새 관로 등록 건을 만든 뒤 즉시 해당 건을 편집 대상으로 전환한다.
|
|
const addGroup = () => {
|
|
const nextGroup = createGroup();
|
|
commitGroups((current) => [...current, nextGroup]);
|
|
setActiveGroupId(nextGroup.id);
|
|
cancelLocationEditing();
|
|
};
|
|
|
|
// 첫 번째 등록 건은 기준 건으로 유지하고 두 번째 건부터 탭의 X로 삭제한다.
|
|
const removeGroup = (groupId: string) => {
|
|
const removeIndex = groupsRef.current.findIndex(
|
|
(group) => group.id === groupId,
|
|
);
|
|
if (removeIndex <= 0) return;
|
|
const remainingGroups = groupsRef.current.filter(
|
|
(group) => group.id !== groupId,
|
|
);
|
|
commitGroups(() => remainingGroups);
|
|
if (activeGroupId === groupId) {
|
|
setActiveGroupId(
|
|
remainingGroups[Math.max(0, removeIndex - 1)]?.id ??
|
|
remainingGroups[0]?.id ??
|
|
null,
|
|
);
|
|
}
|
|
cancelLocationEditing();
|
|
};
|
|
|
|
// 등록 건을 바꾸면 적용하지 않은 지도 임시 위치는 폐기한다.
|
|
const selectGroup = (groupId: string) => {
|
|
setActiveGroupId(groupId);
|
|
cancelLocationEditing();
|
|
};
|
|
|
|
const modalBody = (
|
|
<DredgingRegistrationBody
|
|
presentation={presentation}
|
|
groups={groups}
|
|
activeGroup={activeGroup}
|
|
representativeDredgingDate={representativeDredgingDate}
|
|
setRepresentativeDredgingDate={setRepresentativeDredgingDate}
|
|
parseLocalDate={parseLocalDate}
|
|
formatLocalDate={formatLocalDate}
|
|
getResolvedLocation={getResolvedLocation}
|
|
addGroup={addGroup}
|
|
removeGroup={removeGroup}
|
|
selectGroup={selectGroup}
|
|
changeRegistrationMode={changeRegistrationMode}
|
|
handleFilesChange={handleFilesChange}
|
|
removePhoto={removePhoto}
|
|
registrationModeLabels={registrationModeLabels}
|
|
photoStatusLabels={photoStatusLabels}
|
|
locationSourceLabels={locationSourceLabels}
|
|
isEditingActiveLocation={isEditingActiveLocation}
|
|
locationDraft={locationDraft}
|
|
applyLocationDraft={applyLocationDraft}
|
|
cancelLocationEditing={cancelLocationEditing}
|
|
startLocationEditing={startLocationEditing}
|
|
activeResolvedLocation={activeResolvedLocation}
|
|
hasUnappliedLocationDraft={hasUnappliedLocationDraft}
|
|
statusMessage={statusMessage}
|
|
changeLocationDraft={changeLocationDraft}
|
|
LocationMap={DredgingLocationMap}
|
|
/>
|
|
);
|
|
|
|
if (presentation === "popup") {
|
|
return (
|
|
<StandardPopupLayout
|
|
title="준설결과 등록"
|
|
showDefaultCancelAction={false}
|
|
actions={
|
|
<div className="flex items-center gap-2">
|
|
<Button
|
|
type="button"
|
|
disabled={isSaving}
|
|
onClick={() => {
|
|
void handleSave();
|
|
}}
|
|
data-testid="dredging-save-button"
|
|
>
|
|
{isSaving ? "저장 중..." : "저장"}
|
|
</Button>
|
|
<Button
|
|
type="button"
|
|
color="white"
|
|
disabled={isSaving}
|
|
onClick={() => window.close()}
|
|
>
|
|
취소
|
|
</Button>
|
|
</div>
|
|
}
|
|
bodyClassName="!overflow-hidden"
|
|
>
|
|
{modalBody}
|
|
</StandardPopupLayout>
|
|
);
|
|
}
|
|
|
|
return (
|
|
<>
|
|
<Dialog
|
|
open={open}
|
|
onOpenChange={(nextOpen) => {
|
|
if (!isSaving) onOpenChange(nextOpen);
|
|
}}
|
|
>
|
|
<DialogContent className="w-[92vw] !max-w-[1200px]">
|
|
<DialogHeader>
|
|
<DialogTitle>준설결과 업로드</DialogTitle>
|
|
</DialogHeader>
|
|
{open ? modalBody : null}
|
|
<DialogFooter>
|
|
<div className="flex w-full justify-end gap-2">
|
|
<Button
|
|
type="button"
|
|
size="sm"
|
|
disabled={isSaving}
|
|
onClick={() => {
|
|
void handleSave();
|
|
}}
|
|
data-testid="dredging-save-button"
|
|
>
|
|
{isSaving ? "저장 중..." : "저장"}
|
|
</Button>
|
|
<Button
|
|
type="button"
|
|
size="sm"
|
|
color="white"
|
|
disabled={isSaving}
|
|
onClick={() => onOpenChange(false)}
|
|
>
|
|
취소
|
|
</Button>
|
|
</div>
|
|
</DialogFooter>
|
|
</DialogContent>
|
|
</Dialog>
|
|
</>
|
|
);
|
|
};
|
|
|
|
export default DredgingRegisterModal;
|