859 lines
32 KiB
TypeScript
859 lines
32 KiB
TypeScript
"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;
|