Files
frontend/apps/sn/app/(with-base-layout)/oper/dredging/components/DredgingRegisterModal.tsx
T
2026-09-15 10:00:42 +09:00

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;