diff --git a/.github/workflows/front-build.yaml b/.github/workflows/front-build.yaml index 17c6acd..a560863 100644 --- a/.github/workflows/front-build.yaml +++ b/.github/workflows/front-build.yaml @@ -38,6 +38,7 @@ jobs: run: | echo "VITE_NAVER_MAP_ID=${{ secrets.VITE_NAVER_MAP_ID }}" >> .env echo "VITE_SERVER_ADDRESS=${{ secrets.VITE_SERVER_ADDRESS }}" >> .env + echo "VITE_GA_TRACKING_ID=${{ secrets.VITE_GA_TRACKING_ID }}" >> .env - name: react build run: npm run build diff --git a/package-lock.json b/package-lock.json index e370eaa..1470208 100644 --- a/package-lock.json +++ b/package-lock.json @@ -27,6 +27,7 @@ "lodash": "^4.17.21", "react": "^18.3.1", "react-dom": "^18.3.1", + "react-ga4": "^2.1.0", "react-naver-maps": "^0.1.3", "react-router-dom": "^7.1.1", "react-table": "^7.8.0", @@ -4185,6 +4186,12 @@ "react": "^18.3.1" } }, + "node_modules/react-ga4": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/react-ga4/-/react-ga4-2.1.0.tgz", + "integrity": "sha512-ZKS7PGNFqqMd3PJ6+C2Jtz/o1iU9ggiy8Y8nUeksgVuvNISbmrQtJiZNvC/TjDsqD0QlU5Wkgs7i+w9+OjHhhQ==", + "license": "MIT" + }, "node_modules/react-is": { "version": "18.3.1", "resolved": "https://registry.npmjs.org/react-is/-/react-is-18.3.1.tgz", diff --git a/package.json b/package.json index eaf7658..30b45af 100644 --- a/package.json +++ b/package.json @@ -29,6 +29,7 @@ "lodash": "^4.17.21", "react": "^18.3.1", "react-dom": "^18.3.1", + "react-ga4": "^2.1.0", "react-naver-maps": "^0.1.3", "react-router-dom": "^7.1.1", "react-table": "^7.8.0", diff --git a/src/App.tsx b/src/App.tsx index 0863e18..e7c1f95 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -5,6 +5,9 @@ import { QueryClient, QueryClientProvider } from '@tanstack/react-query'; import GlobalStyle from './styles/globalStyle'; import './styles/font.css'; import { NotificationProvider } from './components/NotificationProvider'; +import { NavermapsProvider } from 'react-naver-maps'; +import { BrowserRouter } from 'react-router-dom'; + function App() { const queryClient = new QueryClient(); return ( @@ -12,7 +15,13 @@ function App() { - + + + + + diff --git a/src/Router.tsx b/src/Router.tsx index ef2e222..d16290a 100644 --- a/src/Router.tsx +++ b/src/Router.tsx @@ -1,5 +1,6 @@ -import { NavermapsProvider } from 'react-naver-maps'; -import { BrowserRouter, Routes, Route } from 'react-router-dom'; +import { Routes, Route, useLocation } from 'react-router-dom'; +import { useEffect } from 'react'; +import ReactGA from 'react-ga4'; import LoginPage from './pages/LoginPage/LoginPage'; import MapPage from './pages/MapPage/MapPage'; import ManagePage from './pages/ManagePage/ManagePage'; @@ -11,33 +12,46 @@ import Home from './pages/ManagePage/Home'; import SteepSlopeDup from './pages/ManagePage/StepSlope/StepSlopeOutlier/SteepSlopeDup'; import SteepSlopeEmpty from './pages/ManagePage/StepSlope/StepSlopeOutlier/SteepSlopeEmpty'; const Router = () => { + const location = useLocation(); + + useEffect(() => { + ReactGA.initialize(import.meta.env.VITE_GA_TRACKING_ID, { + gtagOptions: { + debug_mode: import.meta.env.DEV, + }, + }); + }, []); + + useEffect(() => { + ReactGA.send({ + hitType: 'pageview', + page: location.pathname + location.search, + title: document.title, + }); + }, [location]); return ( - - - - } /> - } /> - }> - } /> - } /> - - } /> - } /> - - - } /> - } /> - } /> - - - } /> - } /> - - } /> - - - - + + } /> + } /> + }> + } /> + } /> + + } /> + } /> + + + } /> + } /> + } /> + + + } /> + } /> + + } /> + + ); }; export default Router; diff --git a/src/apis/slopeManage.tsx b/src/apis/slopeManage.tsx index e7e54cc..6ca9da7 100644 --- a/src/apis/slopeManage.tsx +++ b/src/apis/slopeManage.tsx @@ -99,6 +99,11 @@ export const slopeManageAPI = { ); return response.data; }, + restoreImg: async () => { + const response = await api.post('/slopes/restore'); + console.log(response); + return response.data; + }, }; export interface UpdateAllImg { formData: FormData; diff --git a/src/pages/ManagePage/StepSlope/pages/SteepSlopeAdd.tsx b/src/pages/ManagePage/StepSlope/pages/SteepSlopeAdd.tsx index b499146..b4f7c5a 100644 --- a/src/pages/ManagePage/StepSlope/pages/SteepSlopeAdd.tsx +++ b/src/pages/ManagePage/StepSlope/pages/SteepSlopeAdd.tsx @@ -1,8 +1,313 @@ +// import React, { useState, useRef, DragEvent, ChangeEvent } from 'react'; +// import styled from 'styled-components'; +// import Title from '../../components/Title'; +// import { slopeManageAPI } from '../../../../apis/slopeManage'; +// import CloudUploadRoundedIcon from '@mui/icons-material/CloudUploadRounded'; +// import { useNotificationStore } from '../../../../hooks/notificationStore'; +// import AddSlope from '../components/AddSlopeContainer'; +// import { FileInputContainerProps } from '../../interface'; + +// const SteepSlopeAdd: React.FC = () => { +// const [isDragActive, setIsDragActive] = useState(false); +// const [uploadedFile, setUploadedFile] = useState(null); +// const [fileName, setFileName] = useState(''); +// const [isUploading, setIsUploading] = useState(false); +// const fileInputRef = useRef(null); +// //전역 알림 +// const showNotification = useNotificationStore( +// (state) => state.showNotification +// ); +// // 드래그 시작 핸들러 +// const handleDragStart = (event: DragEvent): void => { +// event.preventDefault(); +// setIsDragActive(true); +// }; + +// // 드래그 종료 핸들러 +// const handleDragEnd = (event: DragEvent): void => { +// event.preventDefault(); +// setIsDragActive(false); +// }; + +// // 파일 드롭 핸들러 +// const handleDrop = (event: DragEvent): void => { +// event.preventDefault(); +// setIsDragActive(false); + +// const files = event.dataTransfer.files; +// if (files && files.length > 0) { +// handleFile(files[0]); +// } +// }; + +// // 파일 선택 핸들러 +// const handleFileSelect = (event: ChangeEvent): void => { +// const files = event.target.files; +// if (files && files.length > 0) { +// handleFile(files[0]); +// } +// }; + +// // 파일 처리 함수 +// const handleFile = (file: File): void => { +// setUploadedFile(file); +// setFileName(file.name); +// console.log('선택된 파일:', file); +// }; + +// // 파일 입력창 클릭 +// const handleContainerClick = (): void => { +// if (fileInputRef.current) { +// fileInputRef.current.click(); +// } +// }; + +// // 업로드 취소 +// const handleCancelUpload = (e: React.MouseEvent): void => { +// e.stopPropagation(); // 이벤트 버블링 방지 +// setUploadedFile(null); +// setFileName(''); +// if (fileInputRef.current) { +// fileInputRef.current.value = ''; +// } +// }; + +// // 파일 업로드 실행 +// const handleUpload = async ( +// e: React.MouseEvent +// ): Promise => { +// e.stopPropagation(); // 이벤트 버블링 방지 +// if (!uploadedFile) return; + +// setIsUploading(true); + +// try { +// console.log('파일 업로드 시작:', uploadedFile); +// const formData = new FormData(); +// formData.append('file', uploadedFile); +// const data = await slopeManageAPI.uploadExcelSlope(formData); +// showNotification(`${data.message}\n${data.count}건 추가되었습니다.`, { +// severity: 'success', +// }); +// // 업로드 성공 후 상태 초기화 +// setUploadedFile(null); +// setFileName(''); +// if (fileInputRef.current) { +// fileInputRef.current.value = ''; +// } +// } catch (error) { +// showNotification('파일 업로드 오류', { +// severity: 'error', +// }); +// console.error('파일 업로드 오류:', error); +// } finally { +// setIsUploading(false); +// } +// }; + +// const [isOpen, setIsOpen] = useState(false); + +// return ( +// <> +// setIsOpen(false)} /> +// +// +// +// setIsOpen(!isOpen)}> +// 직접 추가 +// +// +// +// ) => { +// event.preventDefault(); +// setIsDragActive(true); +// }} +// onDragLeave={handleDragEnd} +// onDrop={handleDrop} +// > +// + +// {uploadedFile ? ( +// +// {fileName} +// {(uploadedFile.size / 1024).toFixed(2)} KB +// +// +// {isUploading ? '업로드 중...' : '업로드'} +// +// 취소 +// +// +// ) : ( +// +// +// +//

클릭 혹은 파일을 이곳에 드롭하세요.

+//
+//
+// )} +//
+//
+//
+// +// ); +// }; + +// export default SteepSlopeAdd; + +// const Container = styled.div` +// width: 100%; +// height: 100%; +// display: flex; +// flex-direction: column; +// gap: 30px; +// padding-top: 20px; +// `; + +// const HeaderContainer = styled.div` +// width: 100%; +// height: 8%; +// display: flex; +// align-items: center; +// justify-content: space-between; +// padding: 0 20px; +// margin-bottom: 16px; +// `; +// const InputContainerWrapper = styled.div` +// padding: 0 20px; +// `; +// const FileInputContainer = styled.div` +// width: 100%; +// display: flex; +// flex-direction: column; +// min-height: 250px; +// height: 250px; +// border-radius: 15px; +// border: 3px dashed +// ${({ theme, $isDragActive, $hasFile }) => +// $isDragActive +// ? '#24478f' +// : $hasFile +// ? theme.colors.grey[300] +// : theme.colors.grey[200]}; +// justify-content: center; +// align-items: center; +// transition: all 0.3s ease; +// background-color: ${({ $isDragActive }) => +// $isDragActive ? 'rgba(51, 102, 255, 0.05)' : 'transparent'}; +// `; + +// // 파일이 없을 때 표시되는 영역 +// const UploadArea = styled.div` +// display: flex; +// flex-direction: column; +// justify-content: center; +// align-items: center; +// width: 100%; +// height: 100%; +// cursor: pointer; +// &:hover { +// background-color: rgba(0, 0, 0, 0.02); +// } +// `; + +// const UploadText = styled.div` +// margin-top: 15px; +// color: ${({ theme }) => theme.colors.grey[600]}; +// `; + +// const FileInfo = styled.div` +// display: flex; +// flex-direction: column; +// align-items: center; +// justify-content: center; +// width: 100%; +// height: 100%; +// `; + +// const FileName = styled.p` +// font-size: 16px; +// font-weight: 500; +// margin-bottom: 8px; +// `; + +// const FileSize = styled.p` +// font-size: 14px; +// color: ${({ theme }) => theme.colors.grey[600]}; +// margin-bottom: 15px; +// `; + +// const ButtonContainer = styled.div` +// display: flex; +// gap: 10px; +// `; + +// const UploadButton = styled.button` +// padding: 8px 16px; +// background-color: ${({ theme }) => theme.colors.primary}; +// color: white; +// border: none; +// border-radius: 4px; +// cursor: pointer; +// transition: all 0.2s ease; + +// &:hover { +// background-color: ${({ theme }) => theme.colors.primary}DD; +// } + +// &:disabled { +// background-color: ${({ theme }) => theme.colors.grey[400]}; +// cursor: not-allowed; +// } +// `; + +// const CancelButton = styled.button` +// padding: 8px 16px; +// background-color: transparent; +// border: 1px solid ${({ theme }) => theme.colors.grey[300]}; +// border-radius: 4px; +// cursor: pointer; +// transition: all 0.2s ease; + +// &:hover { +// background-color: ${({ theme }) => theme.colors.grey[100]}; +// } +// `; + +// const Button = styled.button` +// padding: 8px 16px; +// border-radius: 6px; +// font-weight: 500; +// cursor: pointer; +// transition: all 0.2s ease; +// `; + +// const SubmitButton = styled(Button)` +// background: #24478f; +// color: white; +// border: none; + +// &:hover { +// opacity: 0.9; +// } +// `; import React, { useState, useRef, DragEvent, ChangeEvent } from 'react'; import styled from 'styled-components'; import Title from '../../components/Title'; import { slopeManageAPI } from '../../../../apis/slopeManage'; import CloudUploadRoundedIcon from '@mui/icons-material/CloudUploadRounded'; +import RestoreIcon from '@mui/icons-material/Restore'; import { useNotificationStore } from '../../../../hooks/notificationStore'; import AddSlope from '../components/AddSlopeContainer'; import { FileInputContainerProps } from '../../interface'; @@ -12,11 +317,14 @@ const SteepSlopeAdd: React.FC = () => { const [uploadedFile, setUploadedFile] = useState(null); const [fileName, setFileName] = useState(''); const [isUploading, setIsUploading] = useState(false); + const [isRestoring, setIsRestoring] = useState(false); const fileInputRef = useRef(null); - //전역 알림 + + // 전역 알림 const showNotification = useNotificationStore( (state) => state.showNotification ); + // 드래그 시작 핸들러 const handleDragStart = (event: DragEvent): void => { event.preventDefault(); @@ -105,6 +413,28 @@ const SteepSlopeAdd: React.FC = () => { } }; + // 이미지 백업 복구 실행 + const handleImageRestore = async (): Promise => { + setIsRestoring(true); + + try { + const response = await slopeManageAPI.restoreImg(); + showNotification( + `이미지 복구 완료\n(복구된 이미지: ${response.summary.restoredImages}개 / 이미지와 연결된 급경사지: ${response.summary.restoredSlopes}개)`, + { + severity: 'success', + } + ); + } catch (error) { + showNotification('이미지 복구 실패', { + severity: 'error', + }); + console.error('이미지 복구 오류:', error); + } finally { + setIsRestoring(false); + } + }; + const [isOpen, setIsOpen] = useState(false); return ( @@ -117,48 +447,90 @@ const SteepSlopeAdd: React.FC = () => { 직접 추가 - - ) => { - event.preventDefault(); - setIsDragActive(true); - }} - onDragLeave={handleDragEnd} - onDrop={handleDrop} - > - - - {uploadedFile ? ( - - {fileName} - {(uploadedFile.size / 1024).toFixed(2)} KB - - - {isUploading ? '업로드 중...' : '업로드'} - - 취소 - - - ) : ( - - + {/* Excel 업로드 섹션 */} + + Excel 데이터 업로드 + ) => { + event.preventDefault(); + setIsDragActive(true); + }} + onDragLeave={handleDragEnd} + onDrop={handleDrop} + > + + + {uploadedFile ? ( + + {fileName} + + {(uploadedFile.size / 1024).toFixed(2)} KB + + + + {isUploading ? '업로드 중...' : '업로드'} + + + 취소 + + + + ) : ( + + + +

클릭 혹은 파일을 이곳에 드롭하세요.

+
+
+ )} +
+
+ + {/* 이미지 백업 복구 섹션 */} + + 이미지 백업 연결 + + + - -

클릭 혹은 파일을 이곳에 드롭하세요.

-
-
- )} -
-
+ + + 이미지 복구 + + 급경사지 데이터가 초기화 된 경우 +
+ 기존에 등록했던 이미지를 복원합니다 +
+ + + {isRestoring ? '복구 중...' : '복구 실행'} + + + {isRestoring && ( + + + + )} + + + ); @@ -182,11 +554,35 @@ const HeaderContainer = styled.div` align-items: center; justify-content: space-between; padding: 0 20px; - margin-bottom: 16px; `; -const InputContainerWrapper = styled.div` + +const ContentGrid = styled.div` + display: flex; + flex-direction: column; + gap: 48px; padding: 0 20px; `; + +const UploadSection = styled.div` + display: flex; + flex-direction: column; +`; + +const BackupSection = styled.div` + display: flex; + flex-direction: column; +`; + +const SectionTitle = styled.h2` + font-size: 18px; + font-weight: 600; + color: ${({ theme }) => theme.colors.grey[800] || '#495057'}; + margin-bottom: 16px; + display: flex; + align-items: center; + gap: 8px; +`; + const FileInputContainer = styled.div` width: 100%; display: flex; @@ -205,7 +601,134 @@ const FileInputContainer = styled.div` align-items: center; transition: all 0.3s ease; background-color: ${({ $isDragActive }) => - $isDragActive ? 'rgba(51, 102, 255, 0.05)' : 'transparent'}; + $isDragActive ? 'rgba(36, 71, 143, 0.05)' : 'transparent'}; +`; + +const BackupRestoreCard = styled.div<{ $isRestoring: boolean }>` + background: white; + border: 2px solid ${({ theme }) => theme.colors.grey[200] || '#e9ecef'}; + border-radius: 15px; + padding: 24px; + text-align: center; + transition: all 0.3s ease; + min-height: 250px; + height: 250px; + display: flex; + flex-direction: column; + justify-content: center; + align-items: center; + + &:hover { + border-color: #24478f; + box-shadow: 0 4px 12px rgba(36, 71, 143, 0.1); + } + + ${({ $isRestoring }) => + $isRestoring && + ` + border-color: #24478f; + box-shadow: 0 4px 12px rgba(36, 71, 143, 0.1); + `} +`; + +const BackupIconLarge = styled.div<{ $isRestoring: boolean }>` + width: 64px; + height: 64px; + margin: 0 auto 16px; + padding: 16px; + background: linear-gradient(135deg, #24478f, #3b82f6); + border-radius: 50%; + display: flex; + align-items: center; + justify-content: center; + + ${({ $isRestoring }) => + $isRestoring && + ` + animation: pulse 1.5s infinite; + `} + + @keyframes pulse { + 0%, + 100% { + opacity: 1; + } + 50% { + opacity: 0.5; + } + } +`; + +const BackupTitle = styled.h3` + font-size: 20px; + font-weight: 600; + color: ${({ theme }) => theme.colors.grey[900] || '#212529'}; + margin-bottom: 8px; +`; + +const BackupDescription = styled.p` + color: ${({ theme }) => theme.colors.grey[600] || '#6c757d'}; + margin-bottom: 20px; + line-height: 1.5; + font-size: 14px; +`; + +const RestoreButton = styled.button<{ $isRestoring: boolean }>` + background: linear-gradient(135deg, #24478f, #3b82f6); + color: white; + border: none; + padding: 12px 24px; + border-radius: 8px; + font-weight: 600; + cursor: pointer; + transition: all 0.3s ease; + font-size: 16px; + width: 100%; + max-width: 200px; + + &:hover:not(:disabled) { + transform: translateY(-2px); + box-shadow: 0 6px 20px rgba(36, 71, 143, 0.3); + } + + &:active { + transform: translateY(0); + } + + &:disabled { + opacity: 0.7; + cursor: not-allowed; + transform: none; + } +`; + +const ProgressContainer = styled.div` + background: ${({ theme }) => theme.colors.grey[200] || '#e9ecef'}; + border-radius: 10px; + overflow: hidden; + height: 6px; + margin-top: 16px; + width: 100%; + max-width: 200px; +`; + +const ProgressBar = styled.div` + height: 100%; + background: linear-gradient(90deg, #24478f, #3b82f6); + width: 0%; + animation: progress 2s ease-in-out infinite; + + @keyframes progress { + 0% { + width: 0%; + } + 50% { + width: 70%; + } + 100% { + width: 100%; + } + } `; // 파일이 없을 때 표시되는 영역 @@ -288,7 +811,6 @@ const CancelButton = styled.button` const Button = styled.button` padding: 8px 16px; border-radius: 6px; - font-weight: 500; cursor: pointer; transition: all 0.2s ease; `; @@ -297,7 +819,8 @@ const SubmitButton = styled(Button)` background: #24478f; color: white; border: none; - + font-size: ${({ theme }) => theme.fonts.sizes.mm}; + font-weight: ${({ theme }) => theme.fonts.weights.bold}; &:hover { opacity: 0.9; } diff --git a/src/pages/ManagePage/components/SideComponents.tsx b/src/pages/ManagePage/components/SideComponents.tsx index 88a07e7..e28fbea 100644 --- a/src/pages/ManagePage/components/SideComponents.tsx +++ b/src/pages/ManagePage/components/SideComponents.tsx @@ -41,7 +41,9 @@ const SideComponents: FC = ({ $isSelect={selectPage[2]} onClick={() => ChooseIndex(2)} > - 급경사지 추가 + + 급경사지 추가/복구 + )}