준설관리 기능 보완

This commit is contained in:
2026-09-04 15:24:17 +09:00
parent 922861c872
commit 34ba09ce1b
4 changed files with 159 additions and 30 deletions
@@ -14,6 +14,7 @@ import {
TableRow,
} from "@common/components";
import { MS_SWAGE_PPL_CODES } from "@/app/(map-layout)/map/components/common/table";
import { fileDownload } from "@/components/lib/utils/fileDownload";
import { useAlertStore } from "@/shared/lib";
import { useEffect, useMemo, useRef, useState } from "react";
@@ -119,6 +120,8 @@ const DredgingDetailPopupPage = ({
const [matchError, setMatchError] = useState<string | null>(null);
const [isMatching, setIsMatching] = useState(false);
const [isSaving, setIsSaving] = useState(false);
// 원본 다운로드 중 연속 클릭을 막고 선택한 사진의 다운로드 진행 상태를 표시한다.
const [isDownloadingPhoto, setIsDownloadingPhoto] = useState(false);
const [statusMessage, setStatusMessage] = useState("");
const uploadedAttachmentsRef = useRef(new Map<string, AttachmentReference>());
@@ -221,6 +224,31 @@ const DredgingDetailPopupPage = ({
null)
: null;
/** 저장된 사진은 공통 첨부 원본을 받고, 아직 저장하지 않은 신규 사진은 로컬 File을 내려받는다. */
const handlePhotoDownload = async () => {
// 다운로드 중 선택을 바꾸더라도 클릭한 시점의 파일과 파일명을 함께 유지한다.
const existingPhoto = selectedExistingPhoto;
const newPhoto = selectedNewPhoto;
if (isDownloadingPhoto || (!existingPhoto && !newPhoto)) return;
setIsDownloadingPhoto(true);
try {
if (newPhoto) {
fileDownload(newPhoto.file, newPhoto.file.name);
} else if (existingPhoto) {
const original = await fetchDredgingPhotoBlob(existingPhoto.atflId);
fileDownload(
original,
existingPhoto.originalFileName || `준설사진_${existingPhoto.photoSn}`,
);
}
} catch {
alert("사진을 다운로드하지 못했습니다. 잠시 후 다시 시도해주세요.");
} finally {
setIsDownloadingPhoto(false);
}
};
const pipeInfo = [
["시도", record.ctpv || "확인불가"],
["시군구", record.sgg || "확인불가"],
@@ -481,10 +509,10 @@ const DredgingDetailPopupPage = ({
</Button>
</div>
}
bodyClassName="sn-dredging-register block overflow-y-auto"
bodyClassName="sn-dredging-register !overflow-hidden"
>
<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">
<div className="grid h-full min-h-0 flex-1 grid-cols-[minmax(44rem,48rem)_minmax(0,1fr)] gap-3">
<aside className="flex min-h-0 min-w-0 flex-col gap-3 overflow-y-auto overscroll-contain pr-1 [scrollbar-gutter:stable]">
<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>
@@ -562,7 +590,11 @@ const DredgingDetailPopupPage = ({
</div>
</section>
<section className="card card-border !m-0 flex min-h-[36rem] flex-1 flex-col overflow-hidden !p-0">
{/* 사진 수·원본 크기와 무관하게 남은 화면 높이를 사용하며, 작은 창에서만 좌측 정보가 스크롤된다. */}
<section
className="card card-border !m-0 flex !min-h-[40rem] flex-1 flex-col overflow-hidden !p-0"
data-testid="dredging-detail-photo-panel"
>
<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">
@@ -599,7 +631,7 @@ const DredgingDetailPopupPage = ({
</div>
{editable && registrationMode === "PHOTO" ? (
<div className="sn-dredging-photo-upload shrink-0 border-b border-border p-3">
<div className="sn-dredging-photo-upload flex h-[5.2rem] shrink-0 items-center border-b border-border p-3">
<FileUpload
files={newPhotos.map((photo) => photo.file)}
setFiles={(files) => void handleNewFiles(files)}
@@ -608,14 +640,18 @@ const DredgingDetailPopupPage = ({
/>
</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 className="bg-secondary/30 flex h-[5.2rem] shrink-0 items-center justify-center 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">
{/* 목록의 높이와 스크롤바 자리를 예약해 추가·삭제·로딩 상태에도 미리보기 위치를 유지한다. */}
<div
className="h-[12rem] shrink-0 overflow-y-auto overscroll-contain border-b border-border p-3 [scrollbar-gutter:stable]"
data-testid="dredging-detail-photo-list-viewport"
>
{isLoading ? (
<p className="flex h-[8.8rem] items-center justify-center text-sm text-muted-foreground">
<p className="flex h-full items-center justify-center text-sm text-muted-foreground">
등록된 사진을 불러오는 중입니다.
</p>
) : error ? (
@@ -729,7 +765,7 @@ const DredgingDetailPopupPage = ({
})}
</div>
) : (
<p className="flex h-[8.8rem] items-center justify-center text-sm text-muted-foreground">
<p className="flex h-full items-center justify-center text-sm text-muted-foreground">
{editable && registrationMode === "PHOTO"
? "사진을 추가하거나 사진 없이 직접 등록을 선택해주세요."
: "등록된 사진이 없습니다."}
@@ -737,22 +773,41 @@ const DredgingDetailPopupPage = ({
)}
</div>
{/* 사진 미리보기는 밝은 사진의 경계를 보장하는 미디어 캔버스이므로 고정 암색을 유지한다. */}
{/* 사진 미리보기는 밝은 사진의 경계를 보장하는 미디어 캔버스이므로 고정 암색을 유지한다.
이미지를 절대 배치해 원본 고유 크기가 부모 높이를 늘리지 않도록 한다. */}
<div
className="flex min-h-[22rem] flex-1 items-center justify-center overflow-hidden bg-slate-900 p-2"
className="relative flex min-h-0 flex-1 items-center justify-center overflow-hidden bg-slate-900 p-2"
data-testid="dredging-detail-photo-preview"
>
{/* 미리보기 위에 겹쳐 배치해 다운로드 버튼이 고정 사진 영역의 높이를 바꾸지 않게 한다. */}
<div className="absolute right-2 top-2 z-10">
<Button
type="button"
size="sm"
color="white"
disabled={
isLoading ||
isSaving ||
isDownloadingPhoto ||
(!selectedExistingPhoto && !selectedNewPhoto)
}
onClick={() => void handlePhotoDownload()}
data-testid="dredging-photo-download"
>
{isDownloadingPhoto ? "다운로드 중..." : "사진 다운로드"}
</Button>
</div>
{selectedExistingPhoto?.previewUrl ? (
<img
src={selectedExistingPhoto.previewUrl}
alt={selectedExistingPhoto.originalFileName || "준설사진"}
className="h-full w-full object-contain"
className="absolute inset-0 h-full w-full object-contain p-2"
/>
) : selectedNewPhoto ? (
<img
src={selectedNewPhoto.previewUrl}
alt={selectedNewPhoto.file.name}
className="h-full w-full object-contain"
className="absolute inset-0 h-full w-full object-contain p-2"
/>
) : selectedExistingPhoto?.loadError ? (
<p className="px-4 text-center text-sm text-rose-200">
@@ -814,7 +869,7 @@ const DredgingDetailPopupPage = ({
</div>
</div>
<div
className="bg-secondary/30 px-4 py-2 text-sm text-muted-foreground"
className="bg-secondary/30 h-[5.2rem] overflow-y-auto px-4 py-2 text-sm text-muted-foreground"
data-testid="dredging-edit-status"
>
{statusMessage ||
@@ -836,7 +891,7 @@ const DredgingDetailPopupPage = ({
</span>
) : null}
</div>
<div className="min-h-[56rem] flex-1 p-2">
<div className="min-h-0 flex-1 p-2">
<DredgingLocationMap
value={isEditingLocation ? locationDraft : location}
matchedPipe={
@@ -851,7 +906,7 @@ const DredgingDetailPopupPage = ({
? "현재 준설위치와 자동 연결된 관로를 표시하고 있습니다."
: "준설위치와 자동 연결된 관로를 표시하고 있습니다."
}
height="h-full min-h-[56rem]"
height="h-full min-h-0"
/>
</div>
</section>
@@ -558,7 +558,7 @@ const DredgingRegisterModal = ({
className={
presentation === "popup"
? "sn-dredging-register flex h-full min-h-0 flex-col overflow-hidden"
: "sn-dredging-register"
: "sn-dredging-register flex h-[min(72rem,70dvh)] min-h-0 flex-col overflow-hidden"
}
>
<div className="bg-secondary/40 mb-3 flex shrink-0 flex-wrap items-center gap-x-4 gap-y-1 rounded-sm border border-border px-4 py-2 text-sm text-label">
@@ -576,7 +576,7 @@ const DredgingRegisterModal = ({
groups.findIndex((group) => group.id === activeGroup.id) + 1
}`}
>
<aside className="flex min-h-0 min-w-0 flex-col gap-3 pr-1">
<aside className="flex min-h-0 min-w-0 flex-col gap-3 overflow-y-auto overscroll-contain pr-1 [scrollbar-gutter:stable]">
<section className="card card-border !m-0 shrink-0 overflow-hidden !p-0">
<div className="card-header !m-0">
<h3 className="text-2xl font-medium text-label">등록 정보</h3>
@@ -684,7 +684,11 @@ const DredgingRegisterModal = ({
</div>
</section>
<section className="card card-border !m-0 shrink-0 overflow-hidden !p-0">
{/* 등록 건별 사진 수가 달라도 목록 카드 높이를 고정해 지도와 하단 정보가 밀리지 않게 한다. */}
<section
className="card card-border !m-0 flex h-[28rem] shrink-0 flex-col overflow-hidden !p-0"
data-testid="dredging-register-photo-panel"
>
<div className="card-header !m-0 flex-wrap gap-2">
<div>
<h3 className="text-2xl font-medium text-label">준설 사진</h3>
@@ -725,10 +729,10 @@ const DredgingRegisterModal = ({
</Button>
</div>
</div>
<div className="p-3">
<div className="flex min-h-0 flex-1 flex-col p-3">
{activeGroup.registrationMode === "PHOTO" ? (
<>
<div className="sn-dredging-photo-upload">
<div className="sn-dredging-photo-upload shrink-0">
<FileUpload
files={activeGroup.photos.map((photo) => photo.file)}
setFiles={(files) => {
@@ -740,7 +744,10 @@ const DredgingRegisterModal = ({
</div>
{activeGroup.photos.length > 0 ? (
<div className="mt-2 rounded-sm border border-border">
<div
className="mt-2 min-h-0 flex-1 overflow-y-auto overscroll-contain rounded-sm border border-border [scrollbar-gutter:stable]"
data-testid="dredging-register-photo-list-viewport"
>
<Table className="table-fixed" variant="default">
<TableHeader>
<TableRow>
@@ -750,7 +757,9 @@ const DredgingRegisterModal = ({
</TableHead>
<TableHead className="w-[16%]">위치</TableHead>
<TableHead className="w-[17%]">상태</TableHead>
<TableHead className="w-[8%]">삭제</TableHead>
<TableHead className="w-[8%] whitespace-nowrap px-1">
삭제
</TableHead>
</TableRow>
</TableHeader>
<TableBody>
@@ -779,9 +788,10 @@ const DredgingRegisterModal = ({
? "GPS"
: "지정 필요"}
</TableCell>
<TableCell className="px-3">
<TableCell className="overflow-hidden px-3">
<span
className={`whitespace-nowrap rounded-sm px-2 py-1 text-sm font-medium ${
title={statusLabel(photo.metadata.status)}
className={`inline-block max-w-full truncate rounded-sm px-2 py-1 align-middle text-sm font-medium ${
photo.metadata.status === "COMPLETE"
? "bg-emerald-100 text-emerald-800"
: "bg-amber-100 text-amber-800"
@@ -812,14 +822,14 @@ const DredgingRegisterModal = ({
</Table>
</div>
) : (
<div className="bg-secondary/30 mt-2 rounded-sm border border-dashed border-border px-3 py-2 text-center text-sm text-muted-foreground">
<div className="bg-secondary/30 mt-2 flex min-h-0 flex-1 items-center justify-center rounded-sm border border-dashed border-border px-3 py-2 text-center text-sm text-muted-foreground">
준설사진을 선택하면 촬영일과 위치정보를 자동으로
확인합니다.
</div>
)}
</>
) : (
<div className="bg-secondary/30 rounded-sm border border-dashed border-border px-3 py-3 text-center text-sm text-muted-foreground">
<div className="bg-secondary/30 flex min-h-0 flex-1 items-center justify-center rounded-sm border border-dashed border-border px-3 py-3 text-center text-sm text-muted-foreground">
사진 없이 지도에서 준설위치를 직접 지정합니다.
</div>
)}
@@ -945,7 +955,7 @@ const DredgingRegisterModal = ({
위치 선택 지도
</h3>
</div>
<div className="min-h-[52rem] flex-1 p-2">
<div className="min-h-0 flex-1 p-2">
<DredgingLocationMap
value={
isEditingActiveLocation
@@ -966,7 +976,7 @@ const DredgingRegisterModal = ({
: "위치등록 버튼을 누른 뒤 지도에서 준설위치를 선택해주세요."
}
onChange={changeLocationDraft}
height="h-full min-h-[52rem]"
height="h-full min-h-0"
/>
</div>
</section>
@@ -1002,7 +1012,7 @@ const DredgingRegisterModal = ({
</Button>
</div>
}
bodyClassName="block overflow-y-auto"
bodyClassName="!overflow-hidden"
>
{modalBody}
</StandardPopupLayout>
@@ -371,3 +371,45 @@ Chrome 확장의 파일 URL 접근 권한이 꺼져 있어 이번 회귀에서 E
- 검증 화면: `COMMON_DOCS_ROOT/작업내용/SFR-010-012-준설관리/검증/20260828/03-수정화면-촬영일자-날짜만.png`.
- 사용자 실행 중인 3002 개발서버를 보호하기 위해 Next build는 수행하지 않았다.
## 16. 2026-09-04 사진 영역 높이 고정 검증
- 실제 로컬 등록·수정 팝업 DOM에서 합성 가로/세로/정사각형 사진으로 검증했다. 수정 사진 조회 응답은 테스트용 ID에만 가로챈 응답을 사용했고, 신규 파일은 로컬 선택만 했다. 저장·업로드·DB 변경은 수행하지 않았다.
- 수정 전 1440×900에서 이미지 전환 시 미리보기 높이는 220 → 1872 → 475.5px, 팝업 스크롤 높이는 883 → 2535 → 1139px로 변했다.
| 검증 항목 | 결과 |
| --- | --- |
| 기존 사진 가로·세로·정사각 전환 | 같은 창 크기에서 미리보기 높이·위치 동일 |
| 1920×1080 수정 팝업 | 미리보기 360px 유지, 전체 스크롤 초과 0px |
| 1440×900 수정 팝업 | 미리보기 180px 유지, 전체 스크롤 초과 0px |
| 1366×768 수정 팝업 | 미리보기 최소 180px 유지, 좌측 정보만 스크롤, 전체 스크롤 초과 0px |
| 기존 5장 삭제 후 0장·신규 세로/가로 사진 선택 | 미리보기 높이 유지 |
| 빈 사진 목록·조회 실패 응답 | 예약된 사진 목록·미리보기 높이 유지 |
| 등록 사진 0~5장·등록 건 1/2 전환 | 사진 카드 280px 유지, 지도·팝업 전체 스크롤 초과 0px |
| 긴 사진 목록 | 내부 스크롤로 5번째 사진까지 접근 확인 |
| TypeScript | `tsc --noEmit --incremental false --ignoreDeprecations 5.0` 종료코드 0 |
| Prettier | 변경 두 TSX 파일 포맷 적용 |
- 증빙: `COMMON_DOCS_ROOT/작업내용/SFR-010-012-준설관리/검증/20260904/01-photo-preview-fixed.png`, `02-registration-photo-list-fixed.png`.
- 사용자 실행 3002 서버를 종료·재시작하지 않았고 Next build는 생략했다. 낮은 창 높이에서 좌측 정보 스크롤은 정보 잘림을 방지하기 위해 유지한다.
## 17. 2026-09-04 사진 다운로드 검증
| 검증 항목 | 결과 |
| --- | --- |
| 실제 등록 사진 다운로드 | 목록에서 관로 4115732847 선택 후 수정 팝업에서 `test2.jpg` 다운로드 성공 |
| 원본 보존 | 실제 파일 2,219,776바이트. 미리보기용 공통 첨부 원본과 다운로드 파일 SHA-256 일치 |
| 선택 사진 전환 | 합성 파일 1·2를 선택해 각각 해당 파일명으로 다운로드 |
| 한글·공백·확장자 | `준설 사진_원본1.png`, `준설 사진_원본2.png` 유지 |
| 합성 원본 바이트 | 가로·세로 테스트 PNG와 다운로드 파일 SHA-256 일치 |
| 진행 상태 | 다운로드 처리 중 비활성화 및 완료 후 재활성화 |
| 미선택 | 선택한 사진을 화면에서 제거하면 다운로드 버튼 비활성화 |
| 실패·재시도 | 합성 HTTP 500에서 사용자용 안내 표시, HTML 오류 본문 미노출, 재시도 성공 |
| 신규 사진 | 아직 저장하지 않은 `tall.png` 원본 다운로드, 첨부서비스 추가 호출 0건, 원본 해시 일치 |
| 고정 레이아웃 | 1440×900에서 다운로드 전후 미리보기 높이 180px 유지 |
| TypeScript | SN 전체 `tsc --noEmit --incremental false --ignoreDeprecations 5.0` 종료코드 0 |
- 합성 사진 조회·실패 응답은 테스트 ID에만 적용했다. 실제 등록 사진 다운로드는 대체 응답 없이 기존 서버로 검증했다.
- 준설 저장·삭제는 수행하지 않았고 검사에 사용한 다운로드 사본은 정리했다.
- 화면 증빙: `COMMON_DOCS_ROOT/작업내용/SFR-010-012-준설관리/검증/20260904/03-photo-download.png` (합성 사진).
- 사용자 실행 3002 서버는 유지했으며 Next build는 실행하지 않았다.
@@ -353,3 +353,25 @@
- 존재하지 않는 날짜나 빈 값은 기존 화면별 fallback(`-`, `촬영일시 없음`)을 유지한다.
- EXIF 원본 `capturedAt`, 첨부정보와 백엔드 저장 payload는 변경하지 않고 화면 렌더링만 변경했다.
- 추적 근거는 요구사항 `SFR-010-012`, 화면설계서 슬라이드 53의 `SN_MN040201`이며 세부 시간 숨김은 사용자 지시에 따른 표출 보완이다.
## 22. 2026-09-04 사진 영역 높이 고정
- 수정 팝업의 높이가 제한되지 않은 grid/flex와 일반 흐름의 이미지 때문에 원본 이미지 비율이 사진·지도 영역 높이를 바꾸는 문제를 확인했다.
- 팝업 본문과 좌우 영역을 창 높이에 맞추고 사진은 고정된 미리보기 안에서 `object-contain`으로 비율을 유지한다. 이미지 고유 크기는 부모 크기 계산에 참여하지 않는다.
- 기존·신규 사진 목록은 12rem 높이를 예약하고 목록 내부만 스크롤한다. 로딩·빈 목록·오류 상태도 같은 공간을 사용한다.
- 등록 화면 사진 카드는 28rem 높이로 고정한다. 사진 0~5장, 등록 건 전환, 수동 등록에서도 카드 높이를 유지한다.
- 작은 창에서는 좌측 정보만 내부 스크롤하며 지도와 팝업 전체 높이는 유지한다. 수정 상태 안내 높이도 고정해 마지막 사진 삭제 안내가 미리보기를 밀지 않게 했다.
- 변경 범위는 `DredgingDetailPopupPage.tsx`, `DredgingRegisterModal.tsx`이며 공통 패키지·API·DB·업로드와 저장 처리에는 변경이 없다.
- 추적 근거: 요구사항정의서 sheet3 G84:I85의 `SFR-010-012/013`, 화면설계서 53~55번 슬라이드의 `SN_MN040201/040202`. 사진 높이 고정은 사용자 지시에 따른 UI 보완이다.
- 롤백은 위 두 화면의 레이아웃 클래스 변경을 되돌리는 것으로 가능하며 데이터 이관은 필요하지 않다.
## 23. 2026-09-04 선택 사진 원본 다운로드
- 수정 화면의 사진 미리보기 우측 상단에 `사진 다운로드` 버튼을 추가했다. 기존 고정 사진 영역 위에 겹쳐 배치하므로 영역 높이는 변하지 않는다.
- 저장된 사진은 기존 `fetchDredgingPhotoBlob`을 통해 공통 첨부서비스 `/attach-service/api/v1/download/{atflId}` 원본을 받아 SN `fileDownload` 유틸리티로 저장한다. 서버 원본파일명과 확장자를 유지한다.
- 저장 전 신규 사진은 로컬 `File`을 그대로 내려받으며 공통 첨부 업로드나 준설 저장을 호출하지 않는다.
- 미선택·로딩·저장·다운로드 진행 중에는 버튼을 비활성화한다. 다운로드 중 선택을 바꾸어도 클릭 당시 사진과 파일명을 함께 유지한다.
- 실패 시 공통 안내창에 사용자용 메시지만 표시하며 재시도가 가능하다. 서버 오류 본문은 화면에 출력하지 않는다.
- 변경 범위는 `DredgingDetailPopupPage.tsx`와 기능 문서다. 공통 컴포넌트·유틸리티, 백엔드 API, DB 스키마는 변경하지 않았다.
- 근거: 원본 `SFR-010-012/013`, 화면 `SN_MN040201`의 사진 확인 흐름에 사용자가 요청한 다운로드 동작을 추가했다.
- 롤백은 선택 사진 다운로드 핸들러·진행 상태·버튼만 제거하면 되며 앞선 사진 영역 고정 변경은 유지할 수 있다.