diff --git a/.github/pull_request_template.md b/.github/pull_request_template.md index 67bbe85..ceebe33 100644 --- a/.github/pull_request_template.md +++ b/.github/pull_request_template.md @@ -11,3 +11,7 @@ ex) feat(17): pull request template 작성(확인 후 지워주세요) - 구현되었는지 설명해주세요
+ +## 관련 이슈 + +Closes # diff --git a/src/components/ImgViewerModal.tsx b/src/components/ImgViewerModal.tsx new file mode 100644 index 0000000..01d5279 --- /dev/null +++ b/src/components/ImgViewerModal.tsx @@ -0,0 +1,268 @@ +import { useImgViewerStore } from '../stores/imgViewerStore'; +import styled from 'styled-components'; +import ArrowForwardIosRoundedIcon from '@mui/icons-material/ArrowForwardIosRounded'; +import ArrowBackIosRoundedIcon from '@mui/icons-material/ArrowBackIosRounded'; + +const ImgViewerModal = () => { + const { + isOpen, + onClose, + currentImageType, + currentImageUrl, + getPrevImageType, + getNextImageType, + getImagePosition, + canGoPrev, + canGoNext, + openImage, + images, + } = useImgViewerStore(); + + if (!isOpen || !currentImageType || !images) return null; + + const { current, total } = getImagePosition(); + const prevEnabled = canGoPrev(); + const nextEnabled = canGoNext(); + + // 이미지 타입별 한국어 이름 + const getImageTypeName = (type: string) => { + switch (type) { + case 'position': + return '위치도'; + case 'start': + return '시점'; + case 'end': + return '종점'; + case 'overview': + return '전경'; + default: + return ''; + } + }; + + const handlePrev = () => { + const prevType = getPrevImageType(); + if (prevType && images[prevType]?.url) { + openImage(images[prevType].url, prevType); + } + }; + + const handleNext = () => { + const nextType = getNextImageType(); + if (nextType && images[nextType]?.url) { + openImage(images[nextType].url, nextType); + } + }; + + // 키보드 이벤트 처리 + const handleKeyDown = (e: React.KeyboardEvent) => { + if (e.key === 'ArrowLeft' && prevEnabled) { + handlePrev(); + } else if (e.key === 'ArrowRight' && nextEnabled) { + handleNext(); + } else if (e.key === 'Escape') { + onClose(); + } + }; + + return ( + + e.stopPropagation()}> + + + {getImageTypeName(currentImageType)} ({current}/{total}) + + + + + + {/* 이미지 */} + { + console.error('이미지 로드 실패:', currentImageUrl); + }} + /> + + {/* 왼쪽 화살표 - MUI 아이콘 사용 */} + + + + + {/* 오른쪽 화살표 - MUI 아이콘 사용 */} + + + + + + + ); +}; + +export default ImgViewerModal; + +const ModalOverlay = styled.div` + position: fixed; + top: 0; + left: 0; + right: 0; + bottom: 0; + background-color: rgba(0, 0, 0, 0.85); + z-index: 2000; + display: flex; + flex-direction: column; + outline: none; +`; + +const ModalContainer = styled.div` + width: 100%; + height: 100%; + display: flex; + flex-direction: column; +`; + +const ModalHeader = styled.div` + display: flex; + justify-content: space-between; + align-items: center; + padding: 12px 20px; + background: linear-gradient(to bottom, rgba(0, 0, 0, 0.8), rgba(0, 0, 0, 0)); + position: absolute; + top: 0; + left: 0; + right: 0; + z-index: 20; + pointer-events: none; + + /* 헤더 요소들만 클릭 가능하게 */ + > * { + pointer-events: auto; + } +`; + +const HeaderTitle = styled.h1` + font-size: 18px; + font-weight: 600; + margin: 0; + color: white; + text-shadow: 0 2px 4px rgba(0, 0, 0, 0.8); +`; + +const CloseButton = styled.button` + background: rgba(0, 0, 0, 0.6); + border: none; + font-size: 28px; + cursor: pointer; + padding: 8px; + display: flex; + align-items: center; + justify-content: center; + color: white; + border-radius: 50%; + width: 44px; + height: 44px; + backdrop-filter: blur(10px); + transition: all 0.2s ease; + + &:hover { + background: rgba(0, 0, 0, 0.8); + transform: scale(1.1); + } +`; + +const ImageContainer = styled.div` + position: relative; + width: 100%; + height: 100vh; + display: flex; + align-items: center; + justify-content: center; + overflow: hidden; +`; + +const MainImage = styled.img` + width: 100%; + height: 100%; + object-fit: contain; + user-select: none; + pointer-events: none; +`; + +const ArrowButton = styled.button<{ + $position: 'left' | 'right'; + $enabled: boolean; +}>` + position: absolute; + ${({ $position }) => $position}: 30px; + top: 50%; + transform: translateY(-50%); + background: rgba(0, 0, 0, 0.7); + border: 2px solid rgba(255, 255, 255, 0.3); + color: ${({ $enabled }) => ($enabled ? 'white' : 'rgba(255, 255, 255, 0.3)')}; + cursor: ${({ $enabled }) => ($enabled ? 'pointer' : 'not-allowed')}; + padding: 20px; + border-radius: 50%; + backdrop-filter: blur(20px); + transition: all 0.3s ease; + z-index: 10; + + /* MUI 아이콘을 위한 스타일 조정 */ + display: flex; + align-items: center; + justify-content: center; + width: 80px; + height: 80px; + + &:hover { + background: ${({ $enabled }) => + $enabled ? 'rgba(0, 0, 0, 0.9)' : 'rgba(0, 0, 0, 0.7)'}; + border-color: ${({ $enabled }) => + $enabled ? 'rgba(255, 255, 255, 0.6)' : 'rgba(255, 255, 255, 0.3)'}; + transform: translateY(-50%) + ${({ $enabled }) => ($enabled ? 'scale(1.1)' : 'scale(1)')}; + box-shadow: ${({ $enabled }) => + $enabled ? '0 8px 32px rgba(0, 0, 0, 0.5)' : 'none'}; + } + + &:disabled { + cursor: not-allowed; + } + + /* MUI 아이콘 스타일 조정 */ + .MuiSvgIcon-root { + color: inherit; + } + + @media (max-width: 768px) { + ${({ $position }) => $position}: 15px; + width: 60px; + height: 60px; + padding: 15px; + + .MuiSvgIcon-root { + font-size: 36px !important; + } + } + + @media (max-width: 480px) { + ${({ $position }) => $position}: 10px; + width: 50px; + height: 50px; + padding: 12px; + + .MuiSvgIcon-root { + font-size: 28px !important; + } + } +`; diff --git a/src/pages/ManagePage/StepSlope/components/ImgsModal.tsx b/src/pages/ManagePage/StepSlope/components/ImgsModal.tsx index d002840..a6c2f75 100644 --- a/src/pages/ManagePage/StepSlope/components/ImgsModal.tsx +++ b/src/pages/ManagePage/StepSlope/components/ImgsModal.tsx @@ -4,6 +4,11 @@ import { useEffect, useState, useRef } from 'react'; import { slopeManageAPI } from '../../../../apis/slopeManage'; import { useNotificationStore } from '../../../../hooks/notificationStore'; import { useQueryClient } from '@tanstack/react-query'; +import { + ImageType, + useImgViewerStore, +} from '../../../../stores/imgViewerStore'; +import ImgViewerModal from '../../../../components/ImgViewerModal'; interface ImgsModalProps { isOpen: boolean; @@ -16,7 +21,7 @@ interface ImageData { file: File; url: string; name: string; - isNew: boolean; // 새로 추가된 이미지인지 여부 + isNew: boolean; } interface ImageCategory { @@ -24,12 +29,11 @@ interface ImageCategory { name: string; } -// 이미지 상태 타입 type ImageAction = 'none' | 'add' | 'delete' | 'update'; interface ImageState { data: ImageData | null; - action: ImageAction; // 이 이미지에 대한 액션 + action: ImageAction; existingUrl?: string; } @@ -55,46 +59,47 @@ const ImgsModal = ({ isOpen, onClose, selectedRow }: ImgsModalProps) => { const [categoryToDelete, setCategoryToDelete] = useState(null); const [deleteConfirmText, setDeleteConfirmText] = useState(''); const [isSaving, setIsSaving] = useState(false); + const [focusedCategory, setFocusedCategory] = useState(null); // 포커스된 카테고리 추적 const fileInputRefs = useRef>({}); - // 알림 함수 const showNotification = useNotificationStore( (state) => state.showNotification ); - // 초기 이미지 로드 (기존 서버의 이미지가 있다면) + // 초기 이미지 로드 useEffect(() => { if (selectedRow) { setSelectedData(selectedRow); const initialImages: Record = { position: { - data: null, // File 객체가 아니므로 null + data: null, action: 'none', existingUrl: - selectedData?.priority?.images?.position?.url || undefined, + selectedRow?.priority?.images?.position?.url || undefined, }, start: { data: null, action: 'none', - existingUrl: selectedData?.priority?.images?.start?.url || undefined, + existingUrl: selectedRow?.priority?.images?.start?.url || undefined, }, end: { data: null, action: 'none', - existingUrl: selectedData?.priority?.images?.end?.url || undefined, + existingUrl: selectedRow?.priority?.images?.end?.url || undefined, }, overview: { data: null, action: 'none', existingUrl: - selectedData?.priority?.images?.overview?.url || undefined, + selectedRow?.priority?.images?.overview?.url || undefined, }, }; setCategoryImages(initialImages); } }, [selectedRow]); + // hidden input들을 생성하여 각 카테고리별 파일 업로드 처리 const createFileInputs = () => { return imageCategories.map((category) => ( @@ -119,7 +124,6 @@ const ImgsModal = ({ isOpen, onClose, selectedRow }: ImgsModalProps) => { isNew: true, }; - // 기존 이미지가 있다면 URL 해제 const existingImageState = categoryImages[category]; if (existingImageState.data) { URL.revokeObjectURL(existingImageState.data.url); @@ -129,25 +133,78 @@ const ImgsModal = ({ isOpen, onClose, selectedRow }: ImgsModalProps) => { ...prev, [category]: { data: newImage, - action: existingImageState.data ? 'update' : 'add', + action: existingImageState.existingUrl ? 'update' : 'add', }, })); }; - // 파일 업로드 핸들러 const handleFileUpload = (category: string) => (e: React.ChangeEvent) => { const files = e.target.files; if (files && files[0]) { addImageToCategory(category, files[0]); } - // input 값 초기화 if (fileInputRefs.current[category]) { fileInputRefs.current[category]!.value = ''; } }; - // 저장 함수 - FormData 생성 및 API 호출 + // 수정된 붙여넣기 이벤트 핸들러 + const handlePaste = (e: React.ClipboardEvent) => { + const items = e.clipboardData?.items; + if (items) { + for (let i = 0; i < items.length; i++) { + const item = items[i]; + if (item.type.indexOf('image') !== -1) { + const file = item.getAsFile(); + if (file) { + let targetCategory = focusedCategory; + + // 포커스된 카테고리가 없거나 이미 이미지가 있는 경우, 첫 번째 빈 카테고리 찾기 + if (!targetCategory || categoryImages[targetCategory]?.data) { + const emptyCategory = imageCategories.find( + (cat) => + !categoryImages[cat.key].data && + categoryImages[cat.key].action !== 'delete' + ); + targetCategory = emptyCategory?.key || null; + } + + if (targetCategory) { + addImageToCategory(targetCategory, file); + // 붙여넣기 후 포커스 해제 + setFocusedCategory(null); + break; + } else { + showNotification('모든 카테고리에 이미지가 있습니다.', { + severity: 'warning', + }); + } + } + } + } + } + }; + + // 카테고리 클릭 핸들러 추가 + const handleCategoryClick = (category: string) => { + setFocusedCategory(category); + }; + + // 드래그 앤 드롭 핸들러 + const handleDragOver = (e: React.DragEvent) => { + e.preventDefault(); + }; + + const handleDrop = (category: string) => (e: React.DragEvent) => { + e.preventDefault(); + const files = e.dataTransfer.files; + if (files && files[0]) { + addImageToCategory(category, files[0]); + } + }; + + // 저장 함수 const handleSave = async () => { if (!selectedData?.slopeInspectionHistory?.historyNumber) { showNotification('관리번호가 없습니다.', { severity: 'error' }); @@ -160,37 +217,29 @@ const ImgsModal = ({ isOpen, onClose, selectedRow }: ImgsModalProps) => { const formData = new FormData(); const deletePositions: string[] = []; - // 각 카테고리별로 처리 Object.entries(categoryImages).forEach(([category, imageState]) => { const { data, action } = imageState; switch (action) { case 'add': case 'update': - // 새 이미지 추가 또는 기존 이미지 수정 if (data && data.file) { formData.append(category, data.file); } break; - case 'delete': - // 삭제할 카테고리 추가 deletePositions.push(category); break; - case 'none': default: - // 변경사항 없음 break; } }); - // 삭제할 포지션들 추가 deletePositions.forEach((position) => { formData.append('deletePositions', position); }); - // API 호출 await slopeManageAPI.updateAllImg({ formData, historyNumber: selectedData.slopeInspectionHistory.historyNumber, @@ -213,42 +262,6 @@ const ImgsModal = ({ isOpen, onClose, selectedRow }: ImgsModalProps) => { } }; - // 붙여넣기 이벤트 핸들러 - const handlePaste = (e: React.ClipboardEvent) => { - const items = e.clipboardData?.items; - if (items) { - for (let i = 0; i < items.length; i++) { - const item = items[i]; - if (item.type.indexOf('image') !== -1) { - const file = item.getAsFile(); - if (file) { - // 첫 번째 빈 카테고리에 추가 - const emptyCategory = imageCategories.find( - (cat) => !categoryImages[cat.key].data - ); - if (emptyCategory) { - addImageToCategory(emptyCategory.key, file); - break; - } - } - } - } - } - }; - - // 드래그 앤 드롭 핸들러 - const handleDragOver = (e: React.DragEvent) => { - e.preventDefault(); - }; - - const handleDrop = (category: string) => (e: React.DragEvent) => { - e.preventDefault(); - const files = e.dataTransfer.files; - if (files && files[0]) { - addImageToCategory(category, files[0]); - } - }; - // 이미지 삭제 확인 모달 열기 const openDeleteModal = (category: string) => { setCategoryToDelete(category); @@ -261,7 +274,6 @@ const ImgsModal = ({ isOpen, onClose, selectedRow }: ImgsModalProps) => { if (categoryToDelete && deleteConfirmText === '삭제') { const existingImageState = categoryImages[categoryToDelete]; - // URL 해제 if (existingImageState.data) { URL.revokeObjectURL(existingImageState.data.url); } @@ -270,7 +282,7 @@ const ImgsModal = ({ isOpen, onClose, selectedRow }: ImgsModalProps) => { ...prev, [categoryToDelete]: { data: null, - action: existingImageState.data?.isNew ? 'none' : 'delete', + action: existingImageState.existingUrl ? 'delete' : 'none', }, })); @@ -280,7 +292,6 @@ const ImgsModal = ({ isOpen, onClose, selectedRow }: ImgsModalProps) => { } }; - // 삭제 모달 닫기 const closeDeleteModal = () => { setDeleteModalOpen(false); setCategoryToDelete(null); @@ -298,13 +309,54 @@ const ImgsModal = ({ isOpen, onClose, selectedRow }: ImgsModalProps) => { }; }, []); - // 변경사항이 있는지 확인 const hasChanges = Object.values(categoryImages).some( (imageState) => imageState.action !== 'none' ); + const { setImageData, openImage } = useImgViewerStore(); + + const convertSlopeImagesToStoreFormat = (slopeImages: any) => { + return { + position: { + url: slopeImages?.position?.url, + createdAt: slopeImages?.position?.createdAt, + }, + start: { + url: slopeImages?.start?.url, + createdAt: slopeImages?.start?.createdAt, + }, + end: { + url: slopeImages?.end?.url, + createdAt: slopeImages?.end?.createdAt, + }, + overview: { + url: slopeImages?.overview?.url, + createdAt: slopeImages?.overview?.createdAt, + }, + }; + }; + + useEffect(() => { + if (selectedData?.priority?.images) { + const convertedImages = convertSlopeImagesToStoreFormat( + selectedData.priority.images + ); + setImageData(convertedImages); + } + }, [selectedData, setImageData]); + + const handleImageClick = (category: string) => { + const imageState = categoryImages[category]; + const imageUrl = imageState.existingUrl || imageState.data?.url; + + if (imageUrl) { + openImage(imageUrl, category as ImageType); + } + }; + return ( <> + {/* 이미지 확대 모달*/} @@ -347,12 +399,20 @@ const ImgsModal = ({ isOpen, onClose, selectedRow }: ImgsModalProps) => { {imageCategories.map((category) => { const imageState = categoryImages[category.key]; const image = imageState.data; + const isFocused = focusedCategory === category.key; return ( - + handleCategoryClick(category.key)} + > {category.name} + {isFocused && ( + (선택됨) + )} {imageState.action !== 'none' && ( {imageState.action === 'add' && ' (추가)'} @@ -366,11 +426,16 @@ const ImgsModal = ({ isOpen, onClose, selectedRow }: ImgsModalProps) => { + fileInputRefs.current[category.key]?.click() + } > 파일을 마우스로 끌어 오세요
- CTRL+C/CTRL+V + {isFocused + ? 'CTRL+V로 붙여넣기 가능' + : '클릭하여 선택 후 CTRL+V'}
@@ -380,9 +445,16 @@ const ImgsModal = ({ isOpen, onClose, selectedRow }: ImgsModalProps) => { { + e.stopPropagation(); + handleImageClick(category.key); + }} /> openDeleteModal(category.key)} + onClick={(e) => { + e.stopPropagation(); + openDeleteModal(category.key); + }} > × @@ -415,12 +487,9 @@ const ImgsModal = ({ isOpen, onClose, selectedRow }: ImgsModalProps) => { - {/* Hidden file inputs */} {createFileInputs()}
- - {/* 삭제 확인 모달 */} @@ -455,7 +524,8 @@ const ImgsModal = ({ isOpen, onClose, selectedRow }: ImgsModalProps) => { }; export default ImgsModal; -// 기본 모달 스타일 + +// 스타일 컴포넌트들 const ModalOverlay = styled.div<{ $isOpen: boolean }>` display: ${(props) => (props.$isOpen ? 'flex' : 'none')}; position: fixed; @@ -507,7 +577,6 @@ const CloseButton = styled.button` } `; -// 정보 섹션 스타일 const InfoSection = styled.div` margin-bottom: 24px; padding: 16px; @@ -543,10 +612,10 @@ const AddressValue = styled(InfoValue)` line-height: 1.4; `; -// 우측 이미지 섹션 const ImagesSection = styled.div` flex: 1; `; + const ActionIndicator = styled.span<{ $action: ImageAction }>` font-size: 12px; font-weight: 500; @@ -563,6 +632,14 @@ const ActionIndicator = styled.span<{ $action: ImageAction }>` } }}; `; + +// 포커스 인디케이터 추가 +const FocusIndicator = styled.span` + font-size: 12px; + font-weight: 500; + color: #2563eb; +`; + const ImageCategoriesContainer = styled.div` display: flex; flex-direction: column; @@ -585,10 +662,17 @@ const ImageCategoriesContainer = styled.div` } `; -const ImageCategory = styled.div` - border: 1px solid #e5e7eb; +// 포커스 스타일 추가 +const ImageCategory = styled.div<{ $isFocused?: boolean }>` + border: 1px solid ${(props) => (props.$isFocused ? '#2563eb' : '#e5e7eb')}; border-radius: 8px; - background: white; + background: ${(props) => (props.$isFocused ? '#eff6ff' : 'white')}; + cursor: pointer; + transition: all 0.2s ease; + + &:hover { + border-color: #9ca3af; + } `; const CategoryHeader = styled.div` @@ -684,7 +768,6 @@ const DeleteImageButton = styled.button` } `; -// 삭제 확인 모달 스타일 const DeleteModalOverlay = styled.div<{ $isOpen: boolean }>` display: ${(props) => (props.$isOpen ? 'flex' : 'none')}; position: fixed; diff --git a/src/pages/MapPage/components/InfoTable.tsx b/src/pages/MapPage/components/InfoTable.tsx index 25859da..8379db3 100644 --- a/src/pages/MapPage/components/InfoTable.tsx +++ b/src/pages/MapPage/components/InfoTable.tsx @@ -1,14 +1,59 @@ import styled from 'styled-components'; import { InfotableProps } from '../interface'; import { useMapStore } from '../../../stores/mapStore'; +import { useImgViewerStore } from '../../../stores/imgViewerStore'; +import { useEffect } from 'react'; +import ImgViewerModal from '../../../components/ImgViewerModal'; const InfoTable = ({ selectItem }: InfotableProps) => { const { setSelectedMarkerId, setBottomSheetHeight } = useMapStore(); + const { setImageData, openImage } = useImgViewerStore(); + const onCloseInfo = () => { setSelectedMarkerId(null); setBottomSheetHeight(200); }; + + // 컴포넌트 진입 시 이미지 데이터 설정 + const convertSlopeImagesToStoreFormat = (slopeImages: any) => { + return { + position: { + url: slopeImages?.position?.url, + createdAt: slopeImages?.position?.createdAt, + }, + start: { + url: slopeImages?.start?.url, + createdAt: slopeImages?.start?.createdAt, + }, + end: { + url: slopeImages?.end?.url, + createdAt: slopeImages?.end?.createdAt, + }, + overview: { + url: slopeImages?.overview?.url, + createdAt: slopeImages?.overview?.createdAt, + }, + }; + }; + + useEffect(() => { + if (selectItem?.priority?.images) { + const convertedImages = convertSlopeImagesToStoreFormat( + selectItem.priority.images + ); + setImageData(convertedImages); + } + }, [selectItem, setImageData]); + + // 이미지 클릭 핸들러 + const handleImageClick = (imageType: string, imageUrl?: string) => { + if (imageUrl) { + openImage(imageUrl, imageType as any); + } + }; + if (!selectItem) return null; + const grade = selectItem.priority?.grade?.includes('A') ? 'A' : selectItem.priority?.grade?.includes('B') @@ -20,123 +65,159 @@ const InfoTable = ({ selectItem }: InfotableProps) => { : 'E'; return ( - - - - {selectItem?.name || ''} - - {selectItem?.location?.province || ''} - {selectItem?.location?.city || ''} - {selectItem?.location?.district || ''} - {selectItem?.location?.address || ''} - {selectItem?.location?.mountainAddress === 'Y' ? '(산)' : ''} - - - - × - - - - {selectItem.priority?.images?.position?.url ? ( - 위치도 - ) : ( - 이미지 없음 - )} - 위치도 - - - {selectItem.priority?.images?.start?.url ? ( - 시점 - ) : ( - 이미지 없음 - )} - 시점 - - - {selectItem.priority?.images?.end?.url ? ( - 종점 - ) : ( - 이미지 없음 - )} - 종점 - - - {selectItem.priority?.images?.overview?.url ? ( - 전경 - ) : ( - 이미지 없음 - )} - 전경 - - - - - - {selectItem?.inspections?.serialNumber || ''} - - - - - - + <> + {/* 이미지 확대 모달*/} + + + + {selectItem?.name || ''} + {selectItem?.location?.province || ''} {selectItem?.location?.city || ''} {selectItem?.location?.district || ''} {selectItem?.location?.address || ''} + {selectItem?.location?.mountainAddress === 'Y' ? '(산)' : ''} + + - {selectItem?.location?.mainLotNumber - ? selectItem?.location?.subLotNumber - ? ` ${selectItem?.location?.mainLotNumber}-${selectItem?.location?.subLotNumber}` - : ` ${selectItem?.location?.mainLotNumber}` - : ''} - - - {selectItem?.location?.roadAddress - ? `(${selectItem?.location?.roadAddress})` - : ''} - - - - - - - {selectItem.priority.maxVerticalHeight} - - - - {selectItem.priority.longitudinalLength} - - - - {selectItem.priority.averageSlope} - + × + + + + {selectItem.priority?.images?.position?.url ? ( + 위치도 + handleImageClick( + 'position', + selectItem.priority.images.position?.url + ) + } + /> + ) : ( + 이미지 없음 + )} + 위치도 + + + {selectItem.priority?.images?.start?.url ? ( + 시점 + handleImageClick( + 'start', + selectItem.priority.images.start?.url + ) + } + /> + ) : ( + 이미지 없음 + )} + 시점 + + + {selectItem.priority?.images?.end?.url ? ( + 종점 + handleImageClick('end', selectItem.priority.images.end?.url) + } + /> + ) : ( + 이미지 없음 + )} + 종점 + + + {selectItem.priority?.images?.overview?.url ? ( + 전경 + handleImageClick( + 'overview', + selectItem.priority.images.overview?.url + ) + } + /> + ) : ( + 이미지 없음 + )} + 전경 + + + + + + {selectItem?.inspections?.serialNumber || ''} + - - - {selectItem.priority.Score} - - - - {grade} - + + + + + {selectItem?.location?.province || ''} + {selectItem?.location?.city || ''} + {selectItem?.location?.district || ''} + {selectItem?.location?.address || ''} - - {selectItem?.priority?.usage && ( + {selectItem?.location?.mainLotNumber + ? selectItem?.location?.subLotNumber + ? ` ${selectItem?.location?.mainLotNumber}-${selectItem?.location?.subLotNumber}` + : ` ${selectItem?.location?.mainLotNumber}` + : ''} + + + {selectItem?.location?.roadAddress + ? `(${selectItem?.location?.roadAddress})` + : ''} + + + + + + + {selectItem.priority.maxVerticalHeight} + - - {selectItem.priority.usage} + + {selectItem.priority.longitudinalLength} - )} - - - {selectItem.priority.slopeNature} - - - - {selectItem.priority.slopeType} - - - + + + {selectItem.priority.averageSlope} + + + + + {selectItem.priority.Score} + + + + {grade} + + + + {selectItem?.priority?.usage && ( + + + {selectItem.priority.usage} + + )} + + + {selectItem.priority.slopeNature} + + + + {selectItem.priority.slopeType} + + + + ); }; @@ -197,6 +278,9 @@ const Value = styled.div` font-size: ${({ theme }) => theme.fonts.sizes.ms}; color: ${({ theme }) => theme.colors.grey[700]}; font-weight: ${({ theme }) => theme.fonts.weights.medium}; + user-select: text; + -webkit-user-select: text; + -moz-user-select: text; `; const AddressWrapper = styled(InfoRow)` @@ -280,11 +364,16 @@ const ImgContainer = styled.div` const Img = styled.img` width: 100%; - aspect-ratio: 1; /* 정사각형 비율 유지 */ + aspect-ratio: 1; object-fit: cover; border-radius: 8px; -`; + cursor: pointer; + transition: transform 0.2s ease; + &:hover { + transform: scale(1.02); /* 추가: 호버 시 살짝 확대 */ + } +`; const ImgTag = styled.div` font-size: ${({ theme }) => theme.fonts.sizes.ms}; font-weight: ${({ theme }) => theme.fonts.weights.bold}; diff --git a/src/stores/imgViewerStore.ts b/src/stores/imgViewerStore.ts new file mode 100644 index 0000000..0eb930d --- /dev/null +++ b/src/stores/imgViewerStore.ts @@ -0,0 +1,127 @@ +import { create } from 'zustand'; + +export type ImageType = 'position' | 'start' | 'end' | 'overview'; + +interface ImageData { + url?: string; + createdAt?: string; +} + +interface ImgViewerStore { + isOpen: boolean; + currentImageUrl: string | null; + currentImageType: ImageType | null; + images: Record | null; // 이미지 데이터 저장 + + setIsOpen: (isOpen: boolean) => void; + onClose: () => void; + openImage: (imageUrl: string, imageType: ImageType) => void; + + // 이미지 데이터 설정 (컴포넌트 진입 시) + setImageData: (images: Record) => void; + + // 존재하는 이미지들만 순서대로 배열로 반환 + getAvailableImages: () => ImageType[]; + + // 현재 이미지의 인덱스 반환 (0부터 시작) + getCurrentIndex: () => number; + + // 이전 이미지 타입 반환 (없으면 null) + getPrevImageType: () => ImageType | null; + + // 다음 이미지 타입 반환 (없으면 null) + getNextImageType: () => ImageType | null; + + // 현재 몇 번째 / 전체 몇 개 정보 + getImagePosition: () => { current: number; total: number }; + + // 이전/다음 버튼 활성화 여부 + canGoPrev: () => boolean; + canGoNext: () => boolean; +} + +export const useImgViewerStore = create((set, get) => ({ + isOpen: false, + currentImageUrl: null, + currentImageType: null, + images: null, + + setIsOpen: (isOpen: boolean) => set({ isOpen: isOpen }), + + onClose: () => { + set({ + isOpen: false, + currentImageUrl: null, + currentImageType: null, + }); + }, + + openImage: (imageUrl: string, imageType: ImageType) => { + set({ + isOpen: true, + currentImageUrl: imageUrl, + currentImageType: imageType, + }); + }, + + setImageData: (images: Record) => { + set({ images }); + }, + + getAvailableImages: () => { + const { images } = get(); + if (!images) return []; + + const imageOrder: ImageType[] = ['position', 'start', 'end', 'overview']; + return imageOrder.filter((type) => images[type]?.url); + }, + + getCurrentIndex: () => { + const { currentImageType, getAvailableImages } = get(); + if (!currentImageType) return -1; + + const availableImages = getAvailableImages(); + return availableImages.indexOf(currentImageType); + }, + + getPrevImageType: () => { + const { getCurrentIndex, getAvailableImages } = get(); + const availableImages = getAvailableImages(); + const currentIndex = getCurrentIndex(); + + if (currentIndex <= 0) return null; + return availableImages[currentIndex - 1]; + }, + + getNextImageType: () => { + const { getCurrentIndex, getAvailableImages } = get(); + const availableImages = getAvailableImages(); + const currentIndex = getCurrentIndex(); + + if (currentIndex >= availableImages.length - 1) return null; + return availableImages[currentIndex + 1]; + }, + + getImagePosition: () => { + const { getCurrentIndex, getAvailableImages } = get(); + const availableImages = getAvailableImages(); + const currentIndex = getCurrentIndex(); + + return { + current: currentIndex + 1, // 1부터 시작 + total: availableImages.length, + }; + }, + + canGoPrev: () => { + const { getCurrentIndex } = get(); + return getCurrentIndex() > 0; + }, + + canGoNext: () => { + const { getCurrentIndex, getAvailableImages } = get(); + const availableImages = getAvailableImages(); + const currentIndex = getCurrentIndex(); + return currentIndex < availableImages.length - 1; + }, +}));