diff --git a/eslint.config.js b/eslint.config.js index 7b77760..df3033b 100644 --- a/eslint.config.js +++ b/eslint.config.js @@ -24,8 +24,6 @@ export default tseslint.config( { allowConstantExport: true }, ], '@typescript-eslint/no-explicit-any': 'off', - '@typescript-eslint/no-unused-vars': 'off', - 'no-unused-vars': 'off', 'react/prop-types': 'off', }, } diff --git a/package-lock.json b/package-lock.json index cfa0918..e370eaa 100644 --- a/package-lock.json +++ b/package-lock.json @@ -43,6 +43,7 @@ "eslint-plugin-react-hooks": "^5.0.0", "eslint-plugin-react-refresh": "^0.4.16", "globals": "^15.14.0", + "terser": "^5.40.0", "typescript": "~5.6.2", "typescript-eslint": "^8.18.2", "vite": "^6.0.5" @@ -1101,6 +1102,17 @@ "node": ">=6.0.0" } }, + "node_modules/@jridgewell/source-map": { + "version": "0.3.6", + "resolved": "https://registry.npmjs.org/@jridgewell/source-map/-/source-map-0.3.6.tgz", + "integrity": "sha512-1ZJTZebgqllO79ue2bm3rIGud/bOe0pP5BjSRCRxxYkEZS8STV7zN84UBbiYu7jy+eCKSnVIUgoWWE/tt+shMQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/gen-mapping": "^0.3.5", + "@jridgewell/trace-mapping": "^0.3.25" + } + }, "node_modules/@jridgewell/sourcemap-codec": { "version": "1.5.0", "resolved": "https://registry.npmjs.org/@jridgewell/sourcemap-codec/-/sourcemap-codec-1.5.0.tgz", @@ -2578,6 +2590,13 @@ "node": ">=8" } }, + "node_modules/buffer-from": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/buffer-from/-/buffer-from-1.1.2.tgz", + "integrity": "sha512-E+XQCRwSbaaiChtv6k6Dwgc+bx+Bs6vuKJHHl5kox/BaKbhiXzqQOwK4cO22yElGp2OCmjwVhT3HmxgyPGnJfQ==", + "dev": true, + "license": "MIT" + }, "node_modules/callsites": { "version": "3.1.0", "resolved": "https://registry.npmjs.org/callsites/-/callsites-3.1.0.tgz", @@ -2675,6 +2694,13 @@ "node": ">= 0.8" } }, + "node_modules/commander": { + "version": "2.20.3", + "resolved": "https://registry.npmjs.org/commander/-/commander-2.20.3.tgz", + "integrity": "sha512-GpVkmM8vF2vQUkj2LvZmD35JxeJOLCwJ9cUkugyk2nuhbv3+mJvpLYYt+0+USMxE+oj+ey/lJEnhZw75x/OMcQ==", + "dev": true, + "license": "MIT" + }, "node_modules/concat-map": { "version": "0.0.1", "resolved": "https://registry.npmjs.org/concat-map/-/concat-map-0.0.1.tgz", @@ -4526,6 +4552,17 @@ "node": ">=0.10.0" } }, + "node_modules/source-map-support": { + "version": "0.5.21", + "resolved": "https://registry.npmjs.org/source-map-support/-/source-map-support-0.5.21.tgz", + "integrity": "sha512-uBHU3L3czsIyYXKX88fdrGovxdSCoTGDRZ6SYXtSRxLZUzHg5P/66Ht6uoUlHu9EZod+inXhKo3qQgwXUT/y1w==", + "dev": true, + "license": "MIT", + "dependencies": { + "buffer-from": "^1.0.0", + "source-map": "^0.6.0" + } + }, "node_modules/stack-generator": { "version": "2.0.10", "resolved": "https://registry.npmjs.org/stack-generator/-/stack-generator-2.0.10.tgz", @@ -4700,6 +4737,25 @@ "react": ">=17.0" } }, + "node_modules/terser": { + "version": "5.40.0", + "resolved": "https://registry.npmjs.org/terser/-/terser-5.40.0.tgz", + "integrity": "sha512-cfeKl/jjwSR5ar7d0FGmave9hFGJT8obyo0z+CrQOylLDbk7X81nPU6vq9VORa5jU30SkDnT2FXjLbR8HLP+xA==", + "dev": true, + "license": "BSD-2-Clause", + "dependencies": { + "@jridgewell/source-map": "^0.3.3", + "acorn": "^8.14.0", + "commander": "^2.20.0", + "source-map-support": "~0.5.20" + }, + "bin": { + "terser": "bin/terser" + }, + "engines": { + "node": ">=10" + } + }, "node_modules/throttle-debounce": { "version": "3.0.1", "resolved": "https://registry.npmjs.org/throttle-debounce/-/throttle-debounce-3.0.1.tgz", diff --git a/package.json b/package.json index 412d05b..eaf7658 100644 --- a/package.json +++ b/package.json @@ -45,6 +45,7 @@ "eslint-plugin-react-hooks": "^5.0.0", "eslint-plugin-react-refresh": "^0.4.16", "globals": "^15.14.0", + "terser": "^5.40.0", "typescript": "~5.6.2", "typescript-eslint": "^8.18.2", "vite": "^6.0.5" diff --git a/src/apis/slopeManage.tsx b/src/apis/slopeManage.tsx index 6f72cf0..e7e54cc 100644 --- a/src/apis/slopeManage.tsx +++ b/src/apis/slopeManage.tsx @@ -87,4 +87,20 @@ export const slopeManageAPI = { console.log(' 경사지 이상값 조회', response.data); return response.data; }, + updateAllImg: async ({ formData, historyNumber }: UpdateAllImg) => { + const response = await api.put( + `/slopes/${historyNumber}/images`, + formData, + { + headers: { + 'Content-Type': 'multipart/form-data', + }, + } + ); + return response.data; + }, }; +export interface UpdateAllImg { + formData: FormData; + historyNumber: string; +} diff --git a/src/apis/slopeMap.tsx b/src/apis/slopeMap.tsx index 8d628a7..488fa66 100644 --- a/src/apis/slopeMap.tsx +++ b/src/apis/slopeMap.tsx @@ -1,5 +1,9 @@ import { api } from './api'; - +interface SlopeImg { + // 이미지 + url: string; // url + createdAt: Date; // 이미지 생성날짜 +} export interface Slope { managementNo: string; name: string; @@ -74,13 +78,12 @@ export interface Slope { maxVerticalHeight: string; // 최고수직고 (단위: m) longitudinalLength: string; // 종단길이 (단위: m) averageSlope: string; // 평균경사 (단위: 도) - images: [ - { - // 이미지 - url: string; // url - createdAt: Date; // 이미지 생성날짜 - } - ]; + images: { + position?: SlopeImg; + start?: SlopeImg; + overview?: SlopeImg; + end?: SlopeImg; + }; Score: string; //점수 grade: string; // 등급 }; diff --git a/src/pages/ManagePage/StepSlope/components/AddSlopeContainer.tsx b/src/pages/ManagePage/StepSlope/components/AddSlopeContainer.tsx index 1c37c70..0c1f863 100644 --- a/src/pages/ManagePage/StepSlope/components/AddSlopeContainer.tsx +++ b/src/pages/ManagePage/StepSlope/components/AddSlopeContainer.tsx @@ -107,12 +107,12 @@ const createDefaultSlope = (): Slope => { maxVerticalHeight: '', longitudinalLength: '', averageSlope: '', - images: [ - { - url: '', - createdAt: today, - }, - ], + images: { + position: { url: '', createdAt: today }, + start: { url: '', createdAt: today }, + overview: { url: '', createdAt: today }, + end: { url: '', createdAt: today }, + }, Score: '', grade: '', }, diff --git a/src/pages/ManagePage/StepSlope/components/ImgsModal.tsx b/src/pages/ManagePage/StepSlope/components/ImgsModal.tsx new file mode 100644 index 0000000..d002840 --- /dev/null +++ b/src/pages/ManagePage/StepSlope/components/ImgsModal.tsx @@ -0,0 +1,823 @@ +import styled from 'styled-components'; +import { Slope } from '../../../../apis/slopeMap'; +import { useEffect, useState, useRef } from 'react'; +import { slopeManageAPI } from '../../../../apis/slopeManage'; +import { useNotificationStore } from '../../../../hooks/notificationStore'; +import { useQueryClient } from '@tanstack/react-query'; + +interface ImgsModalProps { + isOpen: boolean; + onClose: () => void; + selectedRow: Slope | null; +} + +interface ImageData { + id: string; + file: File; + url: string; + name: string; + isNew: boolean; // 새로 추가된 이미지인지 여부 +} + +interface ImageCategory { + key: string; + name: string; +} + +// 이미지 상태 타입 +type ImageAction = 'none' | 'add' | 'delete' | 'update'; + +interface ImageState { + data: ImageData | null; + action: ImageAction; // 이 이미지에 대한 액션 + existingUrl?: string; +} + +const imageCategories: ImageCategory[] = [ + { key: 'position', name: '위치도' }, + { key: 'start', name: '시점' }, + { key: 'end', name: '종점' }, + { key: 'overview', name: '전경' }, +]; + +const ImgsModal = ({ isOpen, onClose, selectedRow }: ImgsModalProps) => { + const queryClient = useQueryClient(); + const [selectedData, setSelectedData] = useState(null); + const [categoryImages, setCategoryImages] = useState< + Record + >({ + position: { data: null, action: 'none' }, + start: { data: null, action: 'none' }, + end: { data: null, action: 'none' }, + overview: { data: null, action: 'none' }, + }); + const [deleteModalOpen, setDeleteModalOpen] = useState(false); + const [categoryToDelete, setCategoryToDelete] = useState(null); + const [deleteConfirmText, setDeleteConfirmText] = useState(''); + const [isSaving, setIsSaving] = useState(false); + const fileInputRefs = useRef>({}); + + // 알림 함수 + const showNotification = useNotificationStore( + (state) => state.showNotification + ); + + // 초기 이미지 로드 (기존 서버의 이미지가 있다면) + useEffect(() => { + if (selectedRow) { + setSelectedData(selectedRow); + + const initialImages: Record = { + position: { + data: null, // File 객체가 아니므로 null + action: 'none', + existingUrl: + selectedData?.priority?.images?.position?.url || undefined, + }, + start: { + data: null, + action: 'none', + existingUrl: selectedData?.priority?.images?.start?.url || undefined, + }, + end: { + data: null, + action: 'none', + existingUrl: selectedData?.priority?.images?.end?.url || undefined, + }, + overview: { + data: null, + action: 'none', + existingUrl: + selectedData?.priority?.images?.overview?.url || undefined, + }, + }; + + setCategoryImages(initialImages); + } + }, [selectedRow]); + // hidden input들을 생성하여 각 카테고리별 파일 업로드 처리 + const createFileInputs = () => { + return imageCategories.map((category) => ( + (fileInputRefs.current[category.key] = el)} + type="file" + accept="image/*" + onChange={handleFileUpload(category.key)} + style={{ display: 'none' }} + /> + )); + }; + + // 특정 카테고리에 이미지 추가/수정 + const addImageToCategory = (category: string, file: File) => { + const newImage: ImageData = { + id: Date.now().toString() + Math.random().toString(36).substr(2, 9), + file, + url: URL.createObjectURL(file), + name: file.name, + isNew: true, + }; + + // 기존 이미지가 있다면 URL 해제 + const existingImageState = categoryImages[category]; + if (existingImageState.data) { + URL.revokeObjectURL(existingImageState.data.url); + } + + setCategoryImages((prev) => ({ + ...prev, + [category]: { + data: newImage, + action: existingImageState.data ? '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 handleSave = async () => { + if (!selectedData?.slopeInspectionHistory?.historyNumber) { + showNotification('관리번호가 없습니다.', { severity: 'error' }); + return; + } + + setIsSaving(true); + + try { + 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, + }); + await queryClient.invalidateQueries({ + queryKey: ['slopes'], + }); + + showNotification('이미지가 성공적으로 저장되었습니다.', { + severity: 'success', + }); + onClose(); + } catch (error) { + console.error('이미지 저장 실패:', error); + showNotification('이미지 저장 중 오류가 발생했습니다.', { + severity: 'error', + }); + } finally { + setIsSaving(false); + } + }; + + // 붙여넣기 이벤트 핸들러 + 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); + setDeleteModalOpen(true); + setDeleteConfirmText(''); + }; + + // 이미지 삭제 실행 + const handleDeleteConfirm = () => { + if (categoryToDelete && deleteConfirmText === '삭제') { + const existingImageState = categoryImages[categoryToDelete]; + + // URL 해제 + if (existingImageState.data) { + URL.revokeObjectURL(existingImageState.data.url); + } + + setCategoryImages((prev) => ({ + ...prev, + [categoryToDelete]: { + data: null, + action: existingImageState.data?.isNew ? 'none' : 'delete', + }, + })); + + setDeleteModalOpen(false); + setCategoryToDelete(null); + setDeleteConfirmText(''); + } + }; + + // 삭제 모달 닫기 + const closeDeleteModal = () => { + setDeleteModalOpen(false); + setCategoryToDelete(null); + setDeleteConfirmText(''); + }; + + // 컴포넌트 언마운트 시 URL 해제 + useEffect(() => { + return () => { + Object.values(categoryImages).forEach((imageState) => { + if (imageState.data) { + URL.revokeObjectURL(imageState.data.url); + } + }); + }; + }, []); + + // 변경사항이 있는지 확인 + const hasChanges = Object.values(categoryImages).some( + (imageState) => imageState.action !== 'none' + ); + + return ( + <> + + + + +

급경사지 이미지 관리

+ × +
+
+ + + {selectedData && ( + + + 급경사지명: + {selectedData.name || '정보 없음'} + + + 관리번호: + {selectedData.managementNo} + + + 주소: + + {selectedData?.location?.province || ''} + {selectedData?.location?.city || ''} + {selectedData?.location?.district || ''} + {selectedData?.location?.address || ''} + {selectedData?.location?.mainLotNumber + ? selectedData?.location?.subLotNumber + ? ` ${selectedData?.location?.mainLotNumber}-${selectedData?.location?.subLotNumber}` + : ` ${selectedData?.location?.mainLotNumber}` + : ''} + + + + )} + + + + {imageCategories.map((category) => { + const imageState = categoryImages[category.key]; + const image = imageState.data; + + return ( + + + + {category.name} + {imageState.action !== 'none' && ( + + {imageState.action === 'add' && ' (추가)'} + {imageState.action === 'update' && ' (수정)'} + {imageState.action === 'delete' && ' (삭제 예정)'} + + )} + + + + + + 파일을 마우스로 끌어 오세요 +
+ CTRL+C/CTRL+V +
+
+ + {(image || imageState.existingUrl) && + imageState.action !== 'delete' ? ( + <> + + openDeleteModal(category.key)} + > + × + + + ) : ( + + 사진({category.name}) + {imageState.action === 'delete' &&
} + {imageState.action === 'delete' && '삭제 예정'} +
+ )} +
+
+
+ ); + })} +
+
+
+ + + + + {isSaving ? '저장 중...' : '저장'} + + 취소 + + + + {/* Hidden file inputs */} + {createFileInputs()} +
+
+ + {/* 삭제 확인 모달 */} + + + +

이미지 삭제

+ × +
+ + 이미지를 삭제하시려면 입력창에 삭제라고 입력해 + 주세요. + + + setDeleteConfirmText(e.target.value)} + /> + + + + 확인 + + 취소 + +
+
+ + ); +}; + +export default ImgsModal; +// 기본 모달 스타일 +const ModalOverlay = styled.div<{ $isOpen: boolean }>` + display: ${(props) => (props.$isOpen ? 'flex' : 'none')}; + position: fixed; + top: 0; + left: 0; + right: 0; + bottom: 0; + background-color: rgba(0, 0, 0, 0.5); + justify-content: center; + align-items: center; + z-index: 1000; +`; + +const ModalContent = styled.div` + background: white; + border-radius: 8px; + width: 100%; + max-width: 1000px; + max-height: 90vh; + overflow-y: auto; + outline: none; + display: flex; + flex-direction: column; + padding: 0; +`; + +const ModalHeader = styled.div` + display: flex; + justify-content: space-between; + align-items: center; + + h2 { + margin: 0; + font-size: 20px; + color: #111827; + } +`; + +const CloseButton = styled.button` + background: none; + border: none; + font-size: 24px; + cursor: pointer; + padding: 0; + color: #6b7280; + + &:hover { + color: #111827; + } +`; + +// 정보 섹션 스타일 +const InfoSection = styled.div` + margin-bottom: 24px; + padding: 16px; + background-color: #f9fafb; + border-radius: 8px; +`; + +const InfoItem = styled.div` + display: flex; + margin-bottom: 8px; + + &:last-child { + margin-bottom: 0; + } +`; + +const InfoLabel = styled.span` + font-weight: 600; + color: #374151; + width: 90px; + padding-right: 10px; +`; + +const InfoValue = styled.span` + color: #111827; +`; + +const AddressWrapper = styled(InfoItem)` + align-items: flex-start; +`; + +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; + color: ${(props) => { + switch (props.$action) { + case 'add': + return '#059669'; + case 'update': + return '#d97706'; + case 'delete': + return '#dc2626'; + default: + return '#6b7280'; + } + }}; +`; +const ImageCategoriesContainer = styled.div` + display: flex; + flex-direction: column; + gap: 16px; + max-height: 500px; + padding-right: 8px; + + &::-webkit-scrollbar { + width: 6px; + } + + &::-webkit-scrollbar-track { + background: #f1f1f1; + border-radius: 3px; + } + + &::-webkit-scrollbar-thumb { + background: #888; + border-radius: 3px; + } +`; + +const ImageCategory = styled.div` + border: 1px solid #e5e7eb; + border-radius: 8px; + background: white; +`; + +const CategoryHeader = styled.div` + display: flex; + justify-content: space-between; + align-items: center; + padding: 12px 16px; + background-color: #f9fafb; + border-bottom: 1px solid #e5e7eb; +`; + +const CategoryTitle = styled.span` + font-weight: 600; + color: #374151; +`; + +const CategoryContent = styled.div` + display: flex; + padding: 12px; + gap: 12px; + height: 150px; +`; + +const UploadPlaceholder = styled.div` + flex: 3; + border: 2px dashed #d1d5db; + border-radius: 6px; + padding: 20px; + text-align: center; + cursor: pointer; + background: #fafafa; + display: flex; + align-items: center; + justify-content: center; + font-size: 24px; + &:hover { + border-color: #9ca3af; + } +`; + +const PlaceholderText = styled.div` + color: #6b7280; + font-size: 16px; + line-height: 1.4; +`; + +const ImagePreviewArea = styled.div` + flex: 2; + position: relative; + border: 1px solid #e5e7eb; + border-radius: 6px; + height: 80px; + background: #f3f4f6; + display: flex; + align-items: center; + justify-content: center; + height: 100%; +`; + +const PreviewPlaceholder = styled.div` + color: #dc2626; + font-size: 12px; + text-align: center; + line-height: 1.3; + font-weight: 500; +`; + +const CategoryImage = styled.img` + width: 100%; + height: 100%; + object-fit: cover; + border-radius: 6px; +`; + +const DeleteImageButton = styled.button` + position: absolute; + top: 4px; + right: 4px; + background: rgba(0, 0, 0, 0.7); + color: white; + border: none; + border-radius: 50%; + width: 20px; + height: 20px; + cursor: pointer; + font-size: 14px; + display: flex; + align-items: center; + justify-content: center; + + &:hover { + background: rgba(0, 0, 0, 0.9); + } +`; + +// 삭제 확인 모달 스타일 +const DeleteModalOverlay = styled.div<{ $isOpen: boolean }>` + display: ${(props) => (props.$isOpen ? 'flex' : 'none')}; + position: fixed; + top: 0; + left: 0; + right: 0; + bottom: 0; + background-color: rgba(0, 0, 0, 0.5); + justify-content: center; + align-items: center; + z-index: 1100; +`; + +const DeleteModalContent = styled.div` + background: white; + padding: 24px; + border-radius: 8px; + width: 100%; + max-width: 400px; +`; + +const DeleteModalHeader = styled.div` + display: flex; + justify-content: space-between; + align-items: center; + margin-bottom: 20px; + + h3 { + margin: 0; + font-size: 18px; + color: #111827; + } +`; + +const DeleteMessage = styled.p` + margin-bottom: 20px; + line-height: 1.5; + color: #374151; +`; + +const BoldText = styled.span` + font-weight: 600; + color: #dc2626; +`; + +const DeleteInput = styled.div` + margin-bottom: 16px; + + input { + width: 100%; + padding: 8px; + border: 1px solid #ddd; + border-radius: 4px; + + &::placeholder { + color: #9ca3af; + } + } +`; + +const DeleteButtonGroup = styled.div` + display: flex; + justify-content: flex-end; + gap: 8px; + margin-top: 24px; +`; + +const Button = styled.button` + padding: 8px 16px; + border-radius: 4px; + border: none; + cursor: pointer; + + &:disabled { + background-color: #9ca3af; + cursor: not-allowed; + } +`; + +const ConfirmButton = styled(Button)` + background: #24478f; + color: white; +`; + +const CancelButton = styled(Button)` + background: #e5e7eb; + color: #374151; + + &:hover { + background: #d1d5db; + } +`; + +const FixedHeader = styled.div` + position: sticky; + top: 0; + background: white; + z-index: 10; + border-bottom: 1px solid #e5e7eb; + padding: 12px 12px; +`; + +const ScrollableContent = styled.div` + flex: 1; + overflow-y: auto; + padding: 28px 18px; +`; + +const FixedFooter = styled.div` + position: sticky; + bottom: 0; + background: white; + border-top: 1px solid #e5e7eb; + padding: 16px 16px; + z-index: 10; +`; + +const FooterButtonGroup = styled.div` + display: flex; + justify-content: flex-end; + gap: 12px; +`; + +const SaveButton = styled.button` + background: #24478f; + color: white; + border: none; + border-radius: 6px; + padding: 10px 20px; + cursor: pointer; + font-weight: 500; + + &:hover { + background: #1d3660; + } +`; diff --git a/src/pages/ManagePage/StepSlope/components/table/DataTable.tsx b/src/pages/ManagePage/StepSlope/components/table/DataTable.tsx index b61baa7..c965554 100644 --- a/src/pages/ManagePage/StepSlope/components/table/DataTable.tsx +++ b/src/pages/ManagePage/StepSlope/components/table/DataTable.tsx @@ -10,6 +10,7 @@ const DataTable: React.FC = ({ rowVirtualizer, selectedRow, setSelectedRow, + openImgsModal, }) => { const paddingTop = rowVirtualizer.getVirtualItems()[0]?.start || 0; const paddingBottom = @@ -81,6 +82,19 @@ const DataTable: React.FC = ({ ); } }} + onDoubleClick={(e) => { + // 체크박스 열이 아닌 부분을 더블클릭했을 때만 모달 열기 + if ( + !(e.target as HTMLElement).closest('input[type="checkbox"]') + ) { + // 더블클릭한 행을 선택 상태로 설정 + setSelectedRow(row.original); + // ImgsModal 열기 + if (openImgsModal) { + openImgsModal(); + } + } + }} $selected={ selectedRow?.managementNo === row.original.managementNo || row.getIsSelected?.() || diff --git a/src/pages/ManagePage/StepSlope/components/table/TableModals.tsx b/src/pages/ManagePage/StepSlope/components/table/TableModals.tsx index ba37f3c..a9da085 100644 --- a/src/pages/ManagePage/StepSlope/components/table/TableModals.tsx +++ b/src/pages/ManagePage/StepSlope/components/table/TableModals.tsx @@ -4,6 +4,7 @@ import DeleteConfirmModal from '../DeleteModal'; import EditModal from '../EditModal'; import RegionFilterModal from '../RegionFilterModal'; import { TableModalProps } from '../../../interface'; +import ImgsModal from '../ImgsModal'; const TableModals: React.FC = ({ isModalOpen, @@ -20,6 +21,8 @@ const TableModals: React.FC = ({ isEditModalOpen, closeEditModal, handleEdit, + isImgsModalOpen, + closeImgsModal, }) => { return ( <> @@ -42,6 +45,13 @@ const TableModals: React.FC = ({ onSubmit={handleEdit} selectedRow={selectedRow} /> + {isImgsModalOpen !== undefined && closeImgsModal && ( + + )} ); }; diff --git a/src/pages/ManagePage/StepSlope/components/table/coloums.ts b/src/pages/ManagePage/StepSlope/components/table/coloums.ts index ccfe3e1..95cfce8 100644 --- a/src/pages/ManagePage/StepSlope/components/table/coloums.ts +++ b/src/pages/ManagePage/StepSlope/components/table/coloums.ts @@ -31,18 +31,8 @@ export const getSlopeColumns = () => [ onChange: handleToggleRow, // 함수 자체를 전달, 호출 결과 아님 }); }, - size: 50, + size: 40, }), - columnHelper.accessor( - (_row, index) => { - return index + 1; - }, - { - id: 'index', - header: '번호', - size: 60, - } - ), columnHelper.accessor('managementNo', { header: '관리번호', size: 120, @@ -51,6 +41,43 @@ export const getSlopeColumns = () => [ header: '급경사지명', size: 150, }), + columnHelper.accessor( + (row) => row.slopeInspectionHistory.historyNumber || '', + { + id: 'historyNumber', + header: 'SMC번호', + size: 120, + } + ), + columnHelper.accessor( + (row) => (row.priority?.images?.position?.url ? 'O' : ''), + { + id: 'images.position', + header: '위치도', + size: 40, + } + ), + columnHelper.accessor( + (row) => (row.priority?.images?.start?.url ? 'O' : ''), + { + id: 'images.start', + header: '시점', + size: 40, + } + ), + columnHelper.accessor((row) => (row.priority?.images?.end?.url ? 'O' : ''), { + id: 'images.end', + header: '종점', + size: 40, + }), + columnHelper.accessor( + (row) => (row.priority?.images?.overview?.url ? 'O' : ''), + { + id: 'images.overview', + header: '전경', + size: 40, + } + ), columnHelper.accessor((row) => row.management.organization || '', { id: 'organization', header: '시행청명', diff --git a/src/pages/ManagePage/StepSlope/pages/SteepSlopeLookUp.tsx b/src/pages/ManagePage/StepSlope/pages/SteepSlopeLookUp.tsx index a21ff09..2717c5e 100644 --- a/src/pages/ManagePage/StepSlope/pages/SteepSlopeLookUp.tsx +++ b/src/pages/ManagePage/StepSlope/pages/SteepSlopeLookUp.tsx @@ -69,6 +69,10 @@ const SteepSlopeLookUp = () => { resetFilters, setSelectedRows, selectedRows, + + isImgsModalOpen, + openImgsModal, + closeImgsModal, } = useSteepSlopeStore(); // 테이블 컨테이너 ref는 훅 내에서 직접 생성 @@ -296,6 +300,8 @@ const SteepSlopeLookUp = () => { isEditModalOpen={isEditModalOpen} closeEditModal={closeEditModal} handleEdit={handleEdit} + isImgsModalOpen={isImgsModalOpen} + closeImgsModal={closeImgsModal} /> { rowVirtualizer={rowVirtualizer} selectedRow={selectedRow} setSelectedRow={setSelectedRow} + openImgsModal={openImgsModal} /> ; selectedRow: Slope | null; setSelectedRow: (row: Slope | null) => void; + openImgsModal?: () => void; } export interface TableActionProps { @@ -88,6 +89,8 @@ export interface TableModalProps { isEditModalOpen: boolean; closeEditModal: () => void; handleEdit: (updatedSlope: Slope) => void; + isImgsModalOpen?: boolean; + closeImgsModal?: () => void; } export interface TableToolbarProps { diff --git a/src/pages/MapPage/BottomSheet.tsx b/src/pages/MapPage/BottomSheet.tsx index 62fa62f..cd32f09 100644 --- a/src/pages/MapPage/BottomSheet.tsx +++ b/src/pages/MapPage/BottomSheet.tsx @@ -1,4 +1,4 @@ -import { useRef, TouchEvent } from 'react'; +import { useRef, TouchEvent, useMemo } from 'react'; import styled from 'styled-components'; import InfoTable from './components/InfoTable'; import ListContainer from './components/ListContainer'; @@ -24,9 +24,23 @@ const BottomSheet = () => { selectedMarkerId !== null ? slopeData[selectedMarkerId] : null; const startY = useRef(0); - const currentHeight = useRef(200); //현재 높이 - const isDragging = useRef(false); //드래그 상태 - const scrollWrapperRef = useRef(null); //스크롤 Wrapper 위치 + const currentHeight = useRef(200); + const isDragging = useRef(false); + const scrollWrapperRef = useRef(null); + const rafId = useRef(null); // RAF ID 저장용 + + // throttled setHeight를 useMemo로 생성 + const throttledSetHeight = useMemo(() => { + return (newHeight: number) => { + // 이미 예약된 업데이트가 있으면 스킵 + if (rafId.current) return; + + rafId.current = requestAnimationFrame(() => { + setHeight(newHeight); + rafId.current = null; // RAF 완료 후 초기화 + }); + }; + }, [setHeight]); //데스크 용 bottomsheet 높이 변동 관련 함수 const handleMouseMove = useRef((e: globalThis.MouseEvent) => { @@ -35,7 +49,7 @@ const BottomSheet = () => { const diff = startY.current - e.clientY; let newHeight = currentHeight.current + diff; newHeight = Math.max(100, Math.min(window.innerHeight * 0.8, newHeight)); - setHeight(newHeight); + throttledSetHeight(newHeight); }).current; const handleMouseUp = useRef(() => { @@ -65,7 +79,7 @@ const BottomSheet = () => { const diff = startY.current - e.touches[0].clientY; let newHeight = currentHeight.current + diff; newHeight = Math.max(100, Math.min(window.innerHeight * 0.8, newHeight)); - setHeight(newHeight); + throttledSetHeight(newHeight); }; const handleTouchStart = (e: TouchEvent) => { @@ -200,6 +214,7 @@ const BaseContainer = styled.div<{ $isDragging?: boolean; height: number }>` position: absolute; bottom: 0; left: 0; + z-index: 102; `; const ScrollWrapper = styled.div` diff --git a/src/pages/MapPage/components/ButtonGroup.tsx b/src/pages/MapPage/components/ButtonGroup.tsx index c2e6f0e..9bebdb1 100644 --- a/src/pages/MapPage/components/ButtonGroup.tsx +++ b/src/pages/MapPage/components/ButtonGroup.tsx @@ -1,6 +1,8 @@ import styled from 'styled-components'; import { useMapStore, MapTypeId } from '../../../stores/mapStore'; import MyLocationIcon from '@mui/icons-material/MyLocationRounded'; +import ExpandMoreIcon from '@mui/icons-material/ExpandMore'; +import ExpandLessIcon from '@mui/icons-material/ExpandLess'; const ButtonGroup = () => { const { @@ -9,6 +11,12 @@ const ButtonGroup = () => { moveToMyLocation, mapTypeId, setMapTypeId, + + isGradeDrawerOpen, + selectedGrade, + grades, + handleGradeSelect, + handleGradeButtonClick, } = useMapStore(); return ( @@ -36,6 +44,39 @@ const ButtonGroup = () => { + + + + + {selectedGrade || '등급'} + {isGradeDrawerOpen ? : } + + + + + {grades.map((grade) => ( + handleGradeSelect(grade)} + > + {grade} + + ))} + {selectedGrade && ( + handleGradeSelect('전체')} + > + 전체 + + )} + + ); }; @@ -49,7 +90,7 @@ const Container = styled.div` display: flex; flex-direction: column; gap: 10px; - z-index: 1000; + z-index: 100; `; // 버튼 기본 스타일을 공통으로 분리 @@ -102,3 +143,73 @@ const LocationButton = styled(BaseButton)` background-color: ${({ theme }) => theme.colors.grey[200]}; } `; + +// 등급 컨테이너 +const GradeContainer = styled.div` + position: relative; +`; + +// 등급 버튼 +const GradeButton = styled(BaseButton)<{ + $isSelect: boolean; + $isOpen: boolean; +}>` + height: 30px; + width: 100%; + font-weight: ${({ theme }) => theme.fonts.weights.bold}; + font-size: ${({ theme }) => theme.fonts.sizes.ms}; + background-color: ${({ $isSelect, theme }) => + $isSelect ? theme.colors.primaryDark : '#fff'}; + color: ${({ $isSelect, theme }) => + !$isSelect ? theme.colors.primaryDark : '#fff'}; + border-radius: ${({ $isOpen }) => ($isOpen ? '8px 8px 0 0' : '8px')}; +`; + +const GradeButtonContent = styled.div` + display: flex; + align-items: center; + justify-content: space-between; + width: 100%; + + svg { + width: 16px; + height: 16px; + } +`; + +// 등급 드로어 +const GradeDrawer = styled.div<{ $isOpen: boolean }>` + position: absolute; + top: 100%; + left: 0; + right: 0; + background-color: #fff; + border-radius: 0 0 8px 8px; + box-shadow: ${({ theme }) => theme.shadows.sm}; + overflow: hidden; + max-height: ${({ $isOpen }) => ($isOpen ? '200px' : '0')}; + transition: max-height 0.3s ease-in-out; + z-index: 1; +`; + +// 등급 옵션 +const GradeOption = styled.button<{ $isSelected: boolean }>` + width: 100%; + padding: 8px 12px; + border: none; + background-color: ${({ $isSelected, theme }) => + $isSelected ? theme.colors.primaryLight : '#fff'}; + color: ${({ $isSelected, theme }) => + $isSelected ? theme.colors.primaryDark : '#333'}; + font-size: ${({ theme }) => theme.fonts.sizes.ms}; + cursor: pointer; + transition: background-color 0.15s ease-in-out; + + &:hover { + background-color: ${({ theme }) => theme.colors.grey[100]}; + } + + &:not(:last-child) { + border-bottom: 1px solid ${({ theme }) => theme.colors.grey[200]}; + } +`; diff --git a/src/pages/MapPage/components/InfoTable.tsx b/src/pages/MapPage/components/InfoTable.tsx index 60a53b4..25859da 100644 --- a/src/pages/MapPage/components/InfoTable.tsx +++ b/src/pages/MapPage/components/InfoTable.tsx @@ -3,9 +3,10 @@ import { InfotableProps } from '../interface'; import { useMapStore } from '../../../stores/mapStore'; const InfoTable = ({ selectItem }: InfotableProps) => { - const { setSelectedMarkerId } = useMapStore(); + const { setSelectedMarkerId, setBottomSheetHeight } = useMapStore(); const onCloseInfo = () => { setSelectedMarkerId(null); + setBottomSheetHeight(200); }; if (!selectItem) return null; const grade = selectItem.priority?.grade?.includes('A') @@ -34,6 +35,40 @@ const InfoTable = ({ selectItem }: InfotableProps) => { × + + + {selectItem.priority?.images?.position?.url ? ( + 위치도 + ) : ( + 이미지 없음 + )} + 위치도 + + + {selectItem.priority?.images?.start?.url ? ( + 시점 + ) : ( + 이미지 없음 + )} + 시점 + + + {selectItem.priority?.images?.end?.url ? ( + 종점 + ) : ( + 이미지 없음 + )} + 종점 + + + {selectItem.priority?.images?.overview?.url ? ( + 전경 + ) : ( + 이미지 없음 + )} + 전경 + + @@ -128,6 +163,8 @@ const Title = styled.div` font-size: 22px; color: ${({ theme }) => theme.colors.primary}; font-weight: 600; + flex-shrink: 0; + white-space: nowrap; `; const UpperAddressValue = styled.div` @@ -208,3 +245,61 @@ const CloseButton = styled.button` const Line = styled.div` border-bottom: 1px dashed ${({ theme }) => theme.colors.grey[200]}; `; + +const ViewImgSection = styled.div` + display: grid; + grid-template-columns: repeat(4, 1fr); + gap: 10px; + padding: 12px 12px; + @media ${({ theme }) => theme.device.mobile} { + grid-template-columns: repeat(3, 1fr); + grid-template-areas: + 'position position position' + 'start end overview'; + + & > div:nth-child(1) { + grid-area: position; + } + & > div:nth-child(2) { + grid-area: start; + } + & > div:nth-child(3) { + grid-area: end; + } + & > div:nth-child(4) { + grid-area: overview; + } + } +`; + +const ImgContainer = styled.div` + display: flex; + flex-direction: column; + gap: 5px; +`; + +const Img = styled.img` + width: 100%; + aspect-ratio: 1; /* 정사각형 비율 유지 */ + object-fit: cover; + border-radius: 8px; +`; + +const ImgTag = styled.div` + font-size: ${({ theme }) => theme.fonts.sizes.ms}; + font-weight: ${({ theme }) => theme.fonts.weights.bold}; + text-align: center; + color: ${({ theme }) => theme.colors.grey[800]}; +`; +const NoImagePlaceholder = styled.div` + width: 100%; + aspect-ratio: 1; + border-radius: 8px; + border: 1px solid ${({ theme }) => theme.colors.grey[200]}; + background-color: ${({ theme }) => theme.colors.grey[100]}; + display: flex; + align-items: center; + justify-content: center; + color: ${({ theme }) => theme.colors.grey[500]}; + font-size: ${({ theme }) => theme.fonts.sizes.sm}; +`; diff --git a/src/pages/MapPage/components/map/MapComponent.tsx b/src/pages/MapPage/components/map/MapComponent.tsx index 78fb64e..f757cee 100644 --- a/src/pages/MapPage/components/map/MapComponent.tsx +++ b/src/pages/MapPage/components/map/MapComponent.tsx @@ -145,6 +145,7 @@ const MapComponent = () => { if (!userLocation) { return
지도를 로드 중입니다...
; } + return ( { : grade === 'D' ? DmarkerIcon : EmarkerIcon; + const selectedIcon = { + content: ` +
+ ${ + allTextShow || selectedMarkerId === index + ? `
+
+ ${item.name} +
+
` + : '' + } + marker +
+ `, + anchor: new navermaps.Point(24, 24), // 48px의 중심점 + }; + // 일반 마커 아이콘 설정 + const normalIcon = { + content: ` +
+ ${ + allTextShow + ? `
+
+ ${item.name} +
+
` + : '' + } + marker +
+ `, + anchor: new navermaps.Point(18, 18), // 36px의 중심점 + }; return ( { item.location.coordinates.start.coordinates[0] ) } - icon={{ - content: ` -
- ${ - selectedMarkerId === index || allTextShow - ? `
-
- ${item.name} -
-
` - : '' - } - marker -
- `, - anchor: new navermaps.Point(18, 18), - }} + icon={ + selectedMarkerId === index ? selectedIcon : normalIcon + } onClick={() => { chooseSelectItem(item, index); }} diff --git a/src/stores/mapStore.ts b/src/stores/mapStore.ts index 7a19f5f..f5686e8 100644 --- a/src/stores/mapStore.ts +++ b/src/stores/mapStore.ts @@ -15,22 +15,31 @@ export interface MapState { allTextShow: boolean; userLocation: naver.maps.LatLng | null; slopeData: Slope[]; + originalSlopeData: Slope[]; // 필터링 전 원본 데이터 searchMod: boolean; bottomSheetHeight: number; mapInstance: naver.maps.Map | null; mapTypeId: MapTypeId; - isMapReady: boolean; // 추가: 맵이 완전히 로드되었는지 확인하는 플래그 + isMapReady: boolean; + + grades: string[]; + selectedGrade: string | null; + isGradeDrawerOpen: boolean; // 액션 setSelectedMarkerId: (id: number | null) => void; setAllTextShow: (show: boolean) => void; setUserLocation: (location: naver.maps.LatLng | null) => void; setSlopeData: (data: Slope[]) => void; + setOriginalSlopeData: (data: Slope[]) => void; // 원본 데이터 설정 setSearchMod: (mod: boolean) => void; setBottomSheetHeight: (height: number) => void; setMapInstance: (map: naver.maps.Map | null) => void; setMapTypeId: (typeId: MapTypeId) => void; - setIsMapReady: (isReady: boolean) => void; // 추가: 맵 준비 상태 설정 + setIsMapReady: (isReady: boolean) => void; + + setSelectedGrade: (value: string | null) => void; + setIsGradeDrawerOpen: (value: boolean) => void; // 비즈니스 로직 fetchSlopes: () => Promise; @@ -38,6 +47,10 @@ export interface MapState { chooseSelectItem: (item: Slope, index: number) => void; moveToMyLocation: () => void; closeInfo: () => void; + + handleGradeSelect: (grade: string) => void; + handleGradeButtonClick: () => void; + applyGradeFilter: () => void; // 등급 필터 적용 함수 } export const useMapStore = create((set, get) => ({ @@ -46,27 +59,49 @@ export const useMapStore = create((set, get) => ({ allTextShow: false, userLocation: null, slopeData: [], + originalSlopeData: [], // 원본 데이터 초기화 searchMod: false, bottomSheetHeight: 200, mapInstance: null, mapTypeId: MapTypeId.NORMAL, - isMapReady: false, // 초기값: 맵 미준비 상태 + isMapReady: false, + isGradeDrawerOpen: false, + selectedGrade: null, + grades: ['A', 'B', 'C', 'D', 'E'], // 액션 setSelectedMarkerId: (id) => set({ selectedMarkerId: id }), setAllTextShow: (show) => set({ allTextShow: show }), setUserLocation: (location) => set({ userLocation: location }), setSlopeData: (data) => set({ slopeData: data }), + setOriginalSlopeData: (data) => set({ originalSlopeData: data }), // 추가 setSearchMod: (mod) => set({ searchMod: mod }), setBottomSheetHeight: (height) => set({ bottomSheetHeight: height }), setMapInstance: (map) => set({ mapInstance: map }), setMapTypeId: (typeId) => set({ mapTypeId: typeId }), setIsMapReady: (isReady) => set({ isMapReady: isReady }), + setIsGradeDrawerOpen: (value) => set({ isGradeDrawerOpen: value }), + setSelectedGrade: (value) => set({ selectedGrade: value }), + + // 등급 필터 적용 함수 추가 + applyGradeFilter: () => { + const { originalSlopeData, selectedGrade } = get(); + + if (!selectedGrade || selectedGrade === '전체') { + // 등급이 선택되지 않았거나 '전체'인 경우 원본 데이터 표시 + set({ slopeData: originalSlopeData }); + } else { + // 선택된 등급에 맞는 데이터만 필터링 + const filteredData = originalSlopeData.filter( + (slope) => slope.priority?.grade === selectedGrade + ); + set({ slopeData: filteredData }); + } + }, // 비즈니스 로직 fetchSlopes: async () => { - const { userLocation, isMapReady } = get(); - // 맵이 준비되지 않았거나 위치 정보가 없으면 중단 + const { userLocation, isMapReady, applyGradeFilter } = get(); if (!isMapReady || !userLocation?.lat() || !userLocation?.lng()) return; try { @@ -74,16 +109,32 @@ export const useMapStore = create((set, get) => ({ userLocation.lat(), userLocation.lng() ); - set({ slopeData: data || [] }); + + // 수정: 원본 데이터와 표시 데이터 모두 설정 + set({ + originalSlopeData: data || [], + slopeData: data || [], + }); + + // 등급 필터 적용 + applyGradeFilter(); } catch (error) { console.error('Error fetching slopes:', error); - set({ slopeData: [] }); + set({ + slopeData: [], + originalSlopeData: [], + }); } }, handleSearch: async (searchValue) => { - const { fetchSlopes, userLocation, mapInstance, isMapReady } = get(); - // 맵이 준비되지 않았으면 중단 + const { + fetchSlopes, + userLocation, + mapInstance, + isMapReady, + applyGradeFilter, + } = get(); if (!isMapReady) return; if (searchValue === '') { @@ -102,41 +153,52 @@ export const useMapStore = create((set, get) => ({ userLocation.lat(), userLocation.lng() ); - set({ slopeData: data || [] }); + + // 수정: 검색 결과도 원본 데이터로 저장하고 필터 적용 + set({ + originalSlopeData: data || [], + slopeData: data || [], + }); + + // 등급 필터 적용 + applyGradeFilter(); if (mapInstance && data && data.length > 0) { const coordinates = data[0].location.coordinates.start.coordinates; mapInstance.panTo( new naver.maps.LatLng(coordinates[1], coordinates[0]) ); - mapInstance.panTo( - new naver.maps.LatLng(coordinates[1], coordinates[0]) - ); } } catch (error) { console.error('Error search slopes:', error); - set({ slopeData: [] }); + set({ + slopeData: [], + originalSlopeData: [], + }); } }, chooseSelectItem: (item, index) => { const { mapInstance, selectedMarkerId, isMapReady } = get(); - // 맵이 준비되지 않았으면 중단 if (!isMapReady || !mapInstance) return; if (item) { const coordinates = item.location.coordinates.start.coordinates; mapInstance.panTo(new naver.maps.LatLng(coordinates[1], coordinates[0])); - set({ selectedMarkerId: selectedMarkerId === index ? null : index }); + const targetHeight = window.innerHeight * 0.75; + + set({ + selectedMarkerId: selectedMarkerId === index ? null : index, + bottomSheetHeight: selectedMarkerId === index ? 200 : targetHeight, + }); } }, moveToMyLocation: () => { const { mapInstance, userLocation, fetchSlopes, isMapReady } = get(); - // 맵이 준비되지 않았으면 중단 if (!isMapReady || !mapInstance || !userLocation) return; mapInstance.setZoom(15); @@ -151,4 +213,23 @@ export const useMapStore = create((set, get) => ({ closeInfo: () => { set({ selectedMarkerId: null }); }, + + handleGradeSelect: (grade: string) => { + const { setSelectedGrade, setIsGradeDrawerOpen, applyGradeFilter } = get(); + + const selectedGradeValue = grade === '전체' ? null : grade; + + setSelectedGrade(selectedGradeValue); + setIsGradeDrawerOpen(false); + + // 등급 필터 적용 + applyGradeFilter(); + + console.log('Selected grade:', grade); + }, + + handleGradeButtonClick: () => { + const { isGradeDrawerOpen, setIsGradeDrawerOpen } = get(); + setIsGradeDrawerOpen(!isGradeDrawerOpen); + }, })); diff --git a/src/stores/tableStore.ts b/src/stores/tableStore.ts index 10e7e83..e36c25e 100644 --- a/src/stores/tableStore.ts +++ b/src/stores/tableStore.ts @@ -26,6 +26,7 @@ export interface TableState { isRegionModalOpen: boolean; isDeleteModalOpen: boolean; isEditModalOpen: boolean; + isImgsModalOpen: boolean; // 선택된 행 - 단일 선택에서 다중 선택으로 변경 selectedRow: T | null; // 기존 호환성을 위해 유지 @@ -52,6 +53,8 @@ export interface TableState { closeDeleteModal: () => void; openEditModal: () => void; closeEditModal: () => void; + openImgsModal: () => void; + closeImgsModal: () => void; setSelectedRow: (row: T | null) => void; resetFilters: () => void; @@ -76,6 +79,7 @@ export function createTableStore(initialState: Partial> = {}) { isRegionModalOpen: false, isDeleteModalOpen: false, isEditModalOpen: false, + isImgsModalOpen: false, selectedRow: null, selectedRows: [], // 다중 선택 배열 초기화 @@ -130,6 +134,8 @@ export function createTableStore(initialState: Partial> = {}) { closeDeleteModal: () => set({ isDeleteModalOpen: false }), openEditModal: () => set({ isEditModalOpen: true }), closeEditModal: () => set({ isEditModalOpen: false }), + openImgsModal: () => set({ isImgsModalOpen: true }), + closeImgsModal: () => set({ isImgsModalOpen: false }), setSelectedRow: (row) => set({ selectedRow: row }), diff --git a/src/styles/theme.ts b/src/styles/theme.ts index 74b532a..daea9d5 100644 --- a/src/styles/theme.ts +++ b/src/styles/theme.ts @@ -12,6 +12,7 @@ export const theme = { 500: '#9e9e9e', 600: '#666666', 700: '#333333', + 800: '#252525', }, error: '#CD1A1A', grade: { @@ -49,7 +50,7 @@ export const theme = { lg: '0 8px 16px rgba(0, 0, 0, 0.1)', }, device: { - mobile: `screen and (min-width: 355px) and (max-width:767px)`, + mobile: `screen and (max-width:767px)`, tablet: `screen and (min-width:768px) and (max-width:1023px)`, laptop: `screen and (min-width: 1024px)`, }, diff --git a/vite.config.ts b/vite.config.ts index 6da11b5..911d31a 100644 --- a/vite.config.ts +++ b/vite.config.ts @@ -4,4 +4,13 @@ import react from '@vitejs/plugin-react-swc'; // https://vite.dev/config/ export default defineConfig({ plugins: [react()], + build: { + minify: 'terser', + terserOptions: { + compress: { + drop_console: true, + drop_debugger: true, + }, + }, + }, });