Files
frontend/apps/sn/app/(with-base-layout)/oper/dredging/components/DredgingDetailPopupPage.tsx
T

859 lines
32 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"use client";
import dynamic from "next/dynamic";
import { useConfirm } from "@frontend-opensource/use-react-hooks";
import { FileUpload } from "@common/business_components/ui/file-upload";
import { StandardPopupLayout } from "@common/business_components";
import {
Button,
InputDatePicker,
Table,
TableBody,
TableCell,
TableHead,
TableRow,
} from "@common/components";
import { MS_SWAGE_PPL_CODES } from "@/app/(map-layout)/map/components/common/table";
import { useAlertStore } from "@/shared/lib";
import { useEffect, useMemo, useRef, useState } from "react";
import {
fetchDredgingPhotoBlob,
fetchDredgingPhotos,
findNearestPipeMatches,
updateDredgingRecord,
uploadDredgingPhotoDrafts,
type AttachmentReference,
} from "../lib/client";
import { readPhotoMetadata } from "../lib/exif";
import {
EMPTY_PHOTO_METADATA,
type DredgingPhoto,
type DredgingPhotoDraft,
type DredgingRecord,
type DredgingRegistrationMode,
type GeoPoint,
type PipeMatch,
} from "../lib/types";
const DredgingLocationMap = dynamic(() => import("./DredgingLocationMap"), {
ssr: false,
});
type Props = {
record: DredgingRecord;
mode?: "detail" | "edit";
onUpdated?: () => void;
};
type DredgingPhotoPreview = DredgingPhoto & {
previewUrl: string | null;
loadError: string | null;
};
type NewPhotoPreview = DredgingPhotoDraft & {
previewUrl: string;
};
const pipeMaterialNameByCode = new Map(
MS_SWAGE_PPL_CODES.map((item) => [item.val.trim(), item.text]),
);
const resolvePipeMaterialName = (value: string) => {
const normalizedValue = value.trim();
if (!normalizedValue) return "확인불가";
const normalizedCode = normalizedValue.toUpperCase();
if (!/^MOP\d+$/.test(normalizedCode)) return normalizedValue;
return pipeMaterialNameByCode.get(normalizedCode) ?? "확인불가";
};
const locationSourceLabel = {
EXIF: "사진 GPS",
MANUAL: "지도 지정",
} as const;
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`);
/** 상세 UI를 공통 기반으로 사용해 조회와 수정 상태의 시각 구조를 동일하게 유지한다. */
const DredgingDetailPopupPage = ({
record,
mode = "detail",
onUpdated,
}: Props) => {
const alert = useAlertStore((state) => state.setMessage);
const { confirm } = useConfirm();
const editable = mode === "edit";
const [photos, setPhotos] = useState<DredgingPhotoPreview[]>([]);
const [newPhotos, setNewPhotos] = useState<NewPhotoPreview[]>([]);
const newPhotoUrlsRef = useRef<string[]>([]);
const [selectedPhotoKey, setSelectedPhotoKey] = useState<string | null>(null);
const [isLoading, setIsLoading] = useState(false);
const [error, setError] = useState<string | null>(null);
const [dredgingDate, setDredgingDate] = useState(record.dredgingDate);
const [location, setLocation] = useState<GeoPoint>({
longitude: record.longitude,
latitude: record.latitude,
});
const [locationDraft, setLocationDraft] = useState<GeoPoint | null>(null);
const [isEditingLocation, setIsEditingLocation] = useState(false);
// 등록방식은 사진 필수 여부와 마스터의 등록구분을 함께 결정한다.
const [registrationMode, setRegistrationMode] =
useState<DredgingRegistrationMode>(
record.registrationMode ??
(record.photoCount === 0 && record.registrationSource === "MANUAL"
? "MANUAL"
: "PHOTO"),
);
const [locationSource, setLocationSource] = useState(
record.registrationSource,
);
const [matchError, setMatchError] = useState<string | null>(null);
const [isMatching, setIsMatching] = useState(false);
const [isSaving, setIsSaving] = useState(false);
const [statusMessage, setStatusMessage] = useState("");
const uploadedAttachmentsRef = useRef(new Map<string, AttachmentReference>());
const initialMatchedPipe = useMemo<PipeMatch>(
() => ({
fid: record.pipeFid,
mngInstCd: record.mngInstCd,
fcltMngNo: record.fcltMngNo,
pipeDiameterMm: record.pipeDiameterMm,
pipeLengthM: record.pipeLengthM,
pipeMaterial: record.pipeMaterial,
installationYear: record.installationYear,
swtrmtZoneFid: null,
swtrmtZoneNm: record.treatmentZone,
distanceMeters: record.matchDistanceMeters,
snappedLongitude: record.snappedLongitude || record.longitude,
snappedLatitude: record.snappedLatitude || record.latitude,
isMock: record.isMock,
}),
[record],
);
const [matchedPipe, setMatchedPipe] = useState<PipeMatch>(initialMatchedPipe);
useEffect(() => {
let cancelled = false;
const createdUrls: string[] = [];
const load = async () => {
setIsLoading(true);
setError(null);
setPhotos([]);
setSelectedPhotoKey(null);
try {
const items = await fetchDredgingPhotos(
record.dredgingJobSn,
record.pipeFid,
);
const previews = await Promise.all(
items.map(async (photo): Promise<DredgingPhotoPreview> => {
try {
const blob = await fetchDredgingPhotoBlob(photo.atflId);
const previewUrl = URL.createObjectURL(blob);
createdUrls.push(previewUrl);
return { ...photo, previewUrl, loadError: null };
} catch (loadError) {
return {
...photo,
previewUrl: null,
loadError:
loadError instanceof Error
? loadError.message
: "사진 원본을 불러오지 못했습니다.",
};
}
}),
);
if (!cancelled) {
setPhotos(previews);
setSelectedPhotoKey(
previews[0] ? `existing-${previews[0].photoSn}` : null,
);
}
} catch (loadError) {
if (!cancelled) {
setError(
loadError instanceof Error
? loadError.message
: "등록된 준설사진을 불러오지 못했습니다.",
);
}
} finally {
if (!cancelled) setIsLoading(false);
}
};
void load();
return () => {
cancelled = true;
createdUrls.forEach((url) => URL.revokeObjectURL(url));
};
}, [record.dredgingJobSn, record.pipeFid]);
useEffect(
() => () => {
newPhotoUrlsRef.current.forEach((url) => URL.revokeObjectURL(url));
},
[],
);
const selectedExistingPhoto = selectedPhotoKey?.startsWith("existing-")
? (photos.find(
(photo) => `existing-${photo.photoSn}` === selectedPhotoKey,
) ?? null)
: null;
const selectedNewPhoto = selectedPhotoKey?.startsWith("new-")
? (newPhotos.find((photo) => `new-${photo.key}` === selectedPhotoKey) ??
null)
: null;
const pipeInfo = [
["시도", record.ctpv || "확인불가"],
["시군구", record.sgg || "확인불가"],
[
"처리구역",
matchedPipe.swtrmtZoneNm || record.treatmentZone || "확인불가",
],
["관경", `${matchedPipe.pipeDiameterMm.toLocaleString()} mm`],
["관로연장", `${matchedPipe.pipeLengthM.toLocaleString()} m`],
["관재질", resolvePipeMaterialName(matchedPipe.pipeMaterial)],
[
"매설년도",
matchedPipe.installationYear
? `${matchedPipe.installationYear}년`
: "확인불가",
],
["위치등록", locationSourceLabel[locationSource]],
];
const pipeInfoRows = Array.from(
{ length: Math.ceil(pipeInfo.length / 2) },
(_, index) => pipeInfo.slice(index * 2, index * 2 + 2),
);
const automaticMappingValue = `${matchedPipe.distanceMeters.toFixed(2)} m`;
const totalPhotoCount = photos.length + newPhotos.length;
const handleNewFiles = async (files: File[] | null) => {
const selectedFiles = files ?? [];
if (photos.length + selectedFiles.length > 5) {
alert(
"관로별 사진은 기존 사진을 포함해 최대 5장까지 등록할 수 있습니다.",
);
return;
}
newPhotoUrlsRef.current.forEach((url) => URL.revokeObjectURL(url));
newPhotoUrlsRef.current = [];
const checking = selectedFiles.map<NewPhotoPreview>((file) => {
const previewUrl = URL.createObjectURL(file);
newPhotoUrlsRef.current.push(previewUrl);
return {
key: crypto.randomUUID(),
file,
metadata: EMPTY_PHOTO_METADATA,
previewUrl,
};
});
setNewPhotos(checking);
if (checking[0]) setSelectedPhotoKey(`new-${checking[0].key}`);
const parsed = await Promise.all(
checking.map(async (photo) => ({
...photo,
metadata: await readPhotoMetadata(photo.file),
})),
);
setNewPhotos(parsed);
};
const removeExistingPhoto = (photoSn: number) => {
const remaining = photos.filter((photo) => photo.photoSn !== photoSn);
setPhotos(remaining);
if (
registrationMode === "PHOTO" &&
remaining.length + newPhotos.length === 0
) {
setStatusMessage(
"사진 기반 등록은 사진이 한 장 이상 필요합니다. 사진을 추가하거나 사진 없이 직접 등록을 선택해주세요.",
);
}
if (selectedPhotoKey === `existing-${photoSn}`) setSelectedPhotoKey(null);
};
const removeNewPhoto = (photoKey: string) => {
const removed = newPhotos.find((photo) => photo.key === photoKey);
if (removed) {
URL.revokeObjectURL(removed.previewUrl);
newPhotoUrlsRef.current = newPhotoUrlsRef.current.filter(
(url) => url !== removed.previewUrl,
);
}
const remaining = newPhotos.filter((photo) => photo.key !== photoKey);
setNewPhotos(remaining);
if (
registrationMode === "PHOTO" &&
photos.length + remaining.length === 0
) {
setStatusMessage(
"사진 기반 등록은 사진이 한 장 이상 필요합니다. 사진을 추가하거나 사진 없이 직접 등록을 선택해주세요.",
);
}
if (selectedPhotoKey === `new-${photoKey}`) setSelectedPhotoKey(null);
};
/** 수정 중 등록방식을 바꾸고 직접 등록 선택 시 사진을 모두 삭제 대상으로 전환한다. */
const changeRegistrationMode = async (nextMode: DredgingRegistrationMode) => {
if (registrationMode === nextMode) return;
if (
nextMode === "MANUAL" &&
totalPhotoCount > 0 &&
!(await confirm(
"사진 없이 직접 등록으로 변경하면 현재 사진이 모두 삭제됩니다. 변경하시겠습니까?",
))
) {
return;
}
if (nextMode === "MANUAL") {
newPhotoUrlsRef.current.forEach((url) => URL.revokeObjectURL(url));
newPhotoUrlsRef.current = [];
setPhotos([]);
setNewPhotos([]);
setSelectedPhotoKey(null);
setLocationSource("MANUAL");
setStatusMessage(
"사진 없이 직접 등록으로 변경했습니다. 현재 위치를 확인하고 필요하면 수정해주세요.",
);
} else {
setStatusMessage(
"사진 기반 등록은 사진이 한 장 이상 필요합니다. 준설사진을 추가해주세요.",
);
}
setRegistrationMode(nextMode);
};
const startLocationEditing = () => {
setLocationDraft(location);
setIsEditingLocation(true);
setMatchError(null);
};
const cancelLocationEditing = () => {
setLocationDraft(null);
setIsEditingLocation(false);
setMatchError(null);
};
const applyLocation = async () => {
if (!locationDraft) {
alert("지도에서 변경할 준설위치를 선택해주세요.");
return;
}
setIsMatching(true);
setMatchError(null);
try {
const locationKey = `edit-${record.dredgingJobSn}-${record.pipeFid}`;
const matches = await findNearestPipeMatches([
{ locationKey, ...locationDraft },
]);
const nextPipe = matches.get(locationKey);
if (!nextPipe) {
throw new Error(
"50m 이내에 관로가 없습니다. 지도에서 준설위치를 다시 확인해주세요.",
);
}
setLocation(locationDraft);
setLocationSource("MANUAL");
setMatchedPipe(nextPipe);
setLocationDraft(null);
setIsEditingLocation(false);
setStatusMessage("변경한 위치와 최근접 관로를 확인했습니다.");
} catch (matchFailure) {
setMatchError(
matchFailure instanceof Error
? matchFailure.message
: "최근접 관로를 찾지 못했습니다.",
);
} finally {
setIsMatching(false);
}
};
const handleSave = async () => {
if (!dredgingDate) {
alert("준설일자를 입력해주세요.");
return;
}
if (dredgingDate > formatLocalDate(new Date())) {
alert("미래 날짜는 준설일자로 수정할 수 없습니다.");
return;
}
if (registrationMode === "PHOTO" && totalPhotoCount === 0) {
const message =
"사진 기반 등록은 사진이 한 장 이상 필요합니다. 사진을 추가하거나 사진 없이 직접 등록을 선택해주세요.";
setStatusMessage(message);
alert(message);
return;
}
if (isEditingLocation) {
alert("선택한 지도 위치를 먼저 적용해주세요.");
return;
}
setIsSaving(true);
try {
setStatusMessage(
newPhotos.length > 0
? "추가한 사진을 공통 첨부파일로 업로드하고 있습니다."
: "수정 내용을 저장하고 있습니다.",
);
const attachments = await uploadDredgingPhotoDrafts(
newPhotos,
uploadedAttachmentsRef.current,
);
uploadedAttachmentsRef.current = attachments;
await updateDredgingRecord(
record.dredgingJobSn,
{
dredgingDate,
originalPipeFid: record.pipeFid,
registrationMode,
location,
locationSource:
registrationMode === "MANUAL" ? "MANUAL" : locationSource,
pipe: matchedPipe,
retainedPhotoSns: photos.map((photo) => photo.photoSn),
newPhotos,
},
attachments,
);
uploadedAttachmentsRef.current.clear();
setStatusMessage("준설정보 수정을 완료했습니다.");
onUpdated?.();
alert("준설정보를 수정했습니다.", () => window.close());
} catch (saveError) {
const message =
saveError instanceof Error
? saveError.message
: "준설정보를 수정하지 못했습니다.";
setStatusMessage(message);
alert(message);
} finally {
setIsSaving(false);
}
};
return (
<StandardPopupLayout
title={`준설결과 ${editable ? "수정" : "상세"}`}
showDefaultCancelAction={false}
actions={
<div className="flex items-center gap-2">
{editable ? (
<Button
type="button"
disabled={isSaving || isMatching}
onClick={() => void handleSave()}
data-testid="dredging-edit-save-button"
>
{isSaving ? "저장 중..." : "저장"}
</Button>
) : null}
<Button
type="button"
color="white"
disabled={isSaving}
onClick={() => window.close()}
>
취소
</Button>
</div>
}
bodyClassName="sn-dredging-register block overflow-y-auto"
>
<div className="grid min-h-0 grid-cols-[minmax(44rem,48rem)_minmax(0,1fr)] gap-3">
<aside className="flex min-h-0 flex-col gap-3 pr-1">
<section className="card card-border !m-0 shrink-0 overflow-hidden !p-0">
<div className="card-header !m-0">
<h2 className="text-2xl font-medium text-label">관로 정보</h2>
<span className="truncate text-base text-muted-foreground">
관로번호 {matchedPipe.fcltMngNo || "확인불가"}
</span>
</div>
<div className="p-3">
<Table variant="secondary">
<colgroup>
<col width="18%" />
<col width="32%" />
<col width="18%" />
<col width="32%" />
</colgroup>
<TableBody>
<TableRow>
<TableHead data-required={editable || undefined}>
준설일자
</TableHead>
<TableCell className="border px-4">
{editable ? (
<div className="w-full max-w-[15.5rem]">
<InputDatePicker
value={parseLocalDate(dredgingDate)}
onChange={(date) =>
date && setDredgingDate(formatLocalDate(date))
}
className="w-full"
disabled={isSaving}
/>
</div>
) : (
dredgingDate
)}
</TableCell>
<TableHead>자동매핑</TableHead>
<TableCell
className="truncate border px-4"
title={automaticMappingValue}
>
{automaticMappingValue}
</TableCell>
</TableRow>
{pipeInfoRows.map((row, index) => {
const first = row[0];
const second = row[1];
return (
<TableRow key={`${first?.[0]}-${index}`}>
<TableHead>{first?.[0]}</TableHead>
<TableCell
className="truncate border px-4"
title={first?.[1]}
>
{first?.[1]}
</TableCell>
{second ? (
<>
<TableHead>{second[0]}</TableHead>
<TableCell
className="truncate border px-4"
title={second[1]}
>
{second[1]}
</TableCell>
</>
) : (
<TableCell colSpan={2} className="border" />
)}
</TableRow>
);
})}
</TableBody>
</Table>
</div>
</section>
<section className="card card-border !m-0 flex min-h-[36rem] flex-1 flex-col overflow-hidden !p-0">
<div className="card-header !m-0 shrink-0 gap-2">
<h2 className="text-2xl font-medium text-label">준설 사진</h2>
<div className="flex shrink-0 flex-wrap items-center justify-end gap-2">
{editable ? (
<>
<Button
type="button"
size="sm"
color={registrationMode === "PHOTO" ? "primary" : "white"}
aria-pressed={registrationMode === "PHOTO"}
onClick={() => void changeRegistrationMode("PHOTO")}
data-testid="dredging-edit-photo-mode"
>
사진 기반 등록
</Button>
<Button
type="button"
size="sm"
color={
registrationMode === "MANUAL" ? "primary" : "white"
}
aria-pressed={registrationMode === "MANUAL"}
onClick={() => void changeRegistrationMode("MANUAL")}
data-testid="dredging-edit-manual-mode"
>
사진 없이 직접 등록
</Button>
</>
) : null}
<span className="text-base text-muted-foreground">
{totalPhotoCount}/5장
</span>
</div>
</div>
{editable && registrationMode === "PHOTO" ? (
<div className="sn-dredging-photo-upload shrink-0 border-b border-border p-3">
<FileUpload
files={newPhotos.map((photo) => photo.file)}
setFiles={(files) => void handleNewFiles(files)}
fileTypes=".jpg,.jpeg,.png"
maxFiles={Math.max(1, 5 - photos.length)}
/>
</div>
) : editable ? (
<div className="bg-secondary/30 shrink-0 border-b border-border px-3 py-3 text-center text-sm text-muted-foreground">
사진 없이 지도에서 준설위치를 직접 지정합니다.
</div>
) : null}
<div className="shrink-0 border-b border-border p-3">
{isLoading ? (
<p className="flex h-[8.8rem] items-center justify-center text-sm text-muted-foreground">
등록된 사진을 불러오는 중입니다.
</p>
) : error ? (
<p
role="alert"
className="rounded-sm bg-rose-50 px-3 py-3 text-sm text-rose-700"
>
{error}
</p>
) : totalPhotoCount > 0 ? (
<div
className="space-y-1"
data-testid="dredging-detail-photo-list"
>
{photos.map((photo, index) => {
const key = `existing-${photo.photoSn}`;
const selected = key === selectedPhotoKey;
return (
<div
key={key}
className={`flex items-stretch overflow-hidden rounded-sm border border-border ${selected ? "bg-primary-transparent" : "bg-background"}`}
>
<Button
type="button"
variant="ghost"
color="white"
className="h-auto min-w-0 flex-1 justify-start rounded-none border-0 px-3 py-2 text-left"
onClick={() => setSelectedPhotoKey(key)}
aria-pressed={selected}
data-testid={`dredging-detail-photo-${photo.photoSn}`}
>
<span className="flex size-7 shrink-0 items-center justify-center rounded-full bg-secondary text-sm font-semibold text-label">
{index + 1}
</span>
<span className="flex min-w-0 flex-1 items-center gap-2">
<strong
className="min-w-0 flex-1 truncate text-sm"
title={photo.originalFileName}
>
{photo.originalFileName ||
`준설사진 ${index + 1}`}
</strong>
<span className="shrink-0 whitespace-nowrap text-sm text-muted-foreground">
{photo.capturedAt ?? "촬영일시 없음"}
</span>
</span>
</Button>
{editable ? (
<Button
type="button"
variant="destructive"
color="red"
size="icon"
aria-label={`${photo.originalFileName} 삭제`}
className="h-auto w-10 rounded-none border-y-0 border-l border-r-0 border-border p-0"
onClick={() => removeExistingPhoto(photo.photoSn)}
>
×
</Button>
) : null}
</div>
);
})}
{newPhotos.map((photo, index) => {
const key = `new-${photo.key}`;
const selected = key === selectedPhotoKey;
return (
<div
key={key}
className={`flex items-stretch overflow-hidden rounded-sm border border-border ${selected ? "bg-primary-transparent" : "bg-background"}`}
>
<Button
type="button"
variant="ghost"
color="white"
className="h-auto min-w-0 flex-1 justify-start rounded-none border-0 px-3 py-2 text-left"
onClick={() => setSelectedPhotoKey(key)}
aria-pressed={selected}
>
<span className="flex size-7 shrink-0 items-center justify-center rounded-full bg-secondary text-sm font-semibold text-label">
{photos.length + index + 1}
</span>
<span className="flex min-w-0 flex-1 items-center gap-2">
<strong
className="min-w-0 flex-1 truncate text-sm"
title={photo.file.name}
>
신규 · {photo.file.name}
</strong>
<span className="shrink-0 whitespace-nowrap text-sm text-muted-foreground">
{photo.metadata.capturedAt ?? "촬영일시 없음"}
</span>
</span>
</Button>
<Button
type="button"
variant="destructive"
color="red"
size="icon"
aria-label={`${photo.file.name} 삭제`}
className="h-auto w-10 rounded-none border-y-0 border-l border-r-0 border-border p-0"
onClick={() => removeNewPhoto(photo.key)}
>
×
</Button>
</div>
);
})}
</div>
) : (
<p className="flex h-[8.8rem] items-center justify-center text-sm text-muted-foreground">
{editable && registrationMode === "PHOTO"
? "사진을 추가하거나 사진 없이 직접 등록을 선택해주세요."
: "등록된 사진이 없습니다."}
</p>
)}
</div>
{/* 사진 미리보기는 밝은 사진의 경계를 보장하는 미디어 캔버스이므로 고정 암색을 유지한다. */}
<div
className="flex min-h-[22rem] flex-1 items-center justify-center overflow-hidden bg-slate-900 p-2"
data-testid="dredging-detail-photo-preview"
>
{selectedExistingPhoto?.previewUrl ? (
<img
src={selectedExistingPhoto.previewUrl}
alt={selectedExistingPhoto.originalFileName || "준설사진"}
className="h-full w-full object-contain"
/>
) : selectedNewPhoto ? (
<img
src={selectedNewPhoto.previewUrl}
alt={selectedNewPhoto.file.name}
className="h-full w-full object-contain"
/>
) : selectedExistingPhoto?.loadError ? (
<p className="px-4 text-center text-sm text-rose-200">
{selectedExistingPhoto.loadError}
</p>
) : (
<p className="px-4 text-center text-sm text-slate-300">
{editable && registrationMode === "MANUAL"
? "사진 없이 직접 등록 중입니다. 오른쪽 지도에서 위치를 확인해주세요."
: "사진목록에서 확인할 사진을 선택해주세요."}
</p>
)}
</div>
</section>
{editable ? (
<section className="card card-border !m-0 shrink-0 overflow-hidden !p-0">
<div className="card-header !m-0 flex-wrap gap-2">
<div className="min-w-0">
<h2 className="text-2xl font-medium text-label">
위치 및 관로
</h2>
<p
className={`mt-1 truncate text-sm ${matchError ? "text-rose-700" : "text-muted-foreground"}`}
>
자동 연결 관로 · {matchedPipe.fcltMngNo} ·{" "}
{matchError ?? `${matchedPipe.distanceMeters.toFixed(2)}m`}
</p>
</div>
<div className="flex shrink-0 gap-2">
{isEditingLocation ? (
<>
<Button
type="button"
size="sm"
disabled={!locationDraft || isMatching}
onClick={() => void applyLocation()}
>
{isMatching ? "관로 찾는 중..." : "선택 위치 적용"}
</Button>
<Button
type="button"
size="sm"
color="white"
onClick={cancelLocationEditing}
>
취소
</Button>
</>
) : (
<Button
type="button"
size="sm"
onClick={startLocationEditing}
>
위치 수정
</Button>
)}
</div>
</div>
<div
className="bg-secondary/30 px-4 py-2 text-sm text-muted-foreground"
data-testid="dredging-edit-status"
>
{statusMessage ||
"준설일자·사진·위치와 자동 연결 관로를 확인해주세요."}
</div>
</section>
) : null}
</aside>
<section
className="card card-border !m-0 flex min-h-0 min-w-0 flex-col overflow-hidden !p-0"
data-testid="dredging-detail-map"
>
<div className="card-header !m-0 shrink-0">
<h2 className="text-2xl font-medium text-label">준설 위치 지도</h2>
{editable ? (
<span className="text-sm text-muted-foreground">
위치 수정 버튼을 누른 뒤 지도에서 위치를 선택하세요.
</span>
) : null}
</div>
<div className="min-h-[56rem] flex-1 p-2">
<DredgingLocationMap
value={isEditingLocation ? locationDraft : location}
matchedPipe={
isEditingLocation && locationDraft ? null : matchedPipe
}
interactive={editable && isEditingLocation}
onChange={setLocationDraft}
statusText={
isEditingLocation
? "지도에서 변경할 준설위치를 클릭한 뒤 선택 위치 적용을 눌러주세요."
: editable
? "현재 준설위치와 자동 연결된 관로를 표시하고 있습니다."
: "준설위치와 자동 연결된 관로를 표시하고 있습니다."
}
height="h-full min-h-[56rem]"
/>
</div>
</section>
</div>
</StandardPopupLayout>
);
};
export default DredgingDetailPopupPage;