Merge remote-tracking branch 'origin/feat/weather' into develop
# Conflicts: # src/components/layout/Sidebar.jsx # src/map/MapContainer.jsx
This commit is contained in:
커밋
1c36789612
@ -71,6 +71,8 @@
|
||||
.schInput{height: 3.5rem; font-family: 'NanumSquare', sans-serif;font-size: var(--fs-m); color: var(--white);background-color: var(--tertiary1);padding: 0 1.2rem; border: 0;}
|
||||
.schInput::placeholder { color:rgba(var(--white-rgb),.3); }
|
||||
.dateInput {background:url(../images/ico_input_cal.svg) no-repeat center right .5rem/2.4rem; padding-right: 3rem; cursor: pointer;}
|
||||
.dateInput { position: relative; }
|
||||
.dateInput::-webkit-calendar-picker-indicator { opacity: 0; position: absolute; right: 0; width: 3rem; height: 100%;cursor: pointer; }
|
||||
.dateInput::placeholder { color:var(--white); }
|
||||
|
||||
/* =========================
|
||||
@ -107,6 +109,15 @@
|
||||
.colList.lineSB li a .title {font-size: var(--fs-m); font-weight: var(--fw-bold);}
|
||||
.colList.lineSB li a .meta {font-size: var(--fs-s); font-weight: var(--fw-regular); color:rgba(var(--white-rgb),.5);}
|
||||
|
||||
/* 페이지네이션 */
|
||||
.pagination {display: flex; align-items: center; justify-content: center; gap: .5rem; padding: 1.4rem 0;}
|
||||
.pagination button {min-width: 2.8rem; height: 2.8rem; padding: 0 .6rem; border-radius: .4rem; background-color: var(--secondary1); color: var(--white); font-size: var(--fs-m); font-weight: var(--fw-bold); border: 1px solid var(--secondary3); cursor: pointer; transition: background-color .15s ease, border-color .15s ease;}
|
||||
.pagination button:hover {background-color: var(--secondary3); border-color: var(--secondary4);}
|
||||
.pagination button.on {background-color: var(--primary1); border-color: var(--primary1);}
|
||||
.pagination button.on:hover {background-color: var(--primary2); border-color: var(--primary2);}
|
||||
.pagination button.disabled {opacity: 0.4; cursor: default; pointer-events: none;}
|
||||
.pagination .ellipsis {color: rgba(var(--white-rgb), .4); font-size: var(--fs-m); padding: 0 .2rem; user-select: none;}
|
||||
|
||||
/* 아코디언리스트 */
|
||||
.accordionWrap {display: flex;flex-direction: column;transition: max-height 0.3s ease;}
|
||||
.accordionWrap .acdHeader {display: flex; justify-content: space-between; align-items: center; height: 4rem; background-color: var(--secondary1); padding: 1rem; border-bottom: .1rem solid var(--secondary3);}
|
||||
@ -292,7 +303,7 @@
|
||||
align-items: center;
|
||||
z-index: 999;
|
||||
}
|
||||
.popupUtillWrap { position: absolute;top: 50%; left: 50%;transform: translate(-50%, -50%);z-index :85;}
|
||||
.popupUtillWrap { position: fixed;top: 50%; left: 50%;transform: translate(-50%, -50%);z-index :100;}
|
||||
.popupUtill {display: flex; flex-direction: column; width: 52.5rem; height:auto;max-height: 80vh;overflow: hidden; background-color: var(--secondary2); border: .1rem solid var(--secondary3); padding:2.5rem 3rem;}
|
||||
.popupUtill > .puHeader {display: flex; justify-content: space-between; align-items: center; padding-bottom: 2rem;}
|
||||
.popupUtill > .puHeader > .title {font-weight: var(--fw-bold); font-size: var(--fs-xl);}
|
||||
|
||||
20
src/App.jsx
20
src/App.jsx
@ -35,16 +35,16 @@ export default function App() {
|
||||
/publish/* 로 접근하여 퍼블리시 결과물 미리보기
|
||||
프로덕션 빌드 시 이 라우트와 관련 모듈이 제외됨
|
||||
===================== */}
|
||||
{import.meta.env.DEV && PublishRouter && (
|
||||
<Route
|
||||
path="/publish/*"
|
||||
element={
|
||||
<Suspense fallback={<div style={{ color: '#fff', padding: '2rem' }}>Loading publish...</div>}>
|
||||
<PublishRouter />
|
||||
</Suspense>
|
||||
}
|
||||
/>
|
||||
)}
|
||||
{/*{import.meta.env.DEV && PublishRouter && (*/}
|
||||
{/* <Route*/}
|
||||
{/* path="/publish/*"*/}
|
||||
{/* element={*/}
|
||||
{/* <Suspense fallback={<div style={{ color: '#fff', padding: '2rem' }}>Loading publish...</div>}>*/}
|
||||
{/* <PublishRouter />*/}
|
||||
{/* </Suspense>*/}
|
||||
{/* }*/}
|
||||
{/* />*/}
|
||||
{/*)}*/}
|
||||
</Routes>
|
||||
</>
|
||||
);
|
||||
|
||||
32
src/api/commonApi.js
Normal file
32
src/api/commonApi.js
Normal file
@ -0,0 +1,32 @@
|
||||
/**
|
||||
* 공통코드 API
|
||||
*/
|
||||
|
||||
const COMMON_CODE_LIST_ENDPOINT = '/api/cmn/code/list/search';
|
||||
|
||||
/**
|
||||
* 공통코드 목록 조회
|
||||
*
|
||||
* @param {string} commonCodeTypeNumber - 공통코드 유형 번호
|
||||
* @returns {Promise<Array<{ commonCodeTypeName: string, commonCodeTypeNumber: string, commonCodeEtc: string }>>}
|
||||
*/
|
||||
export async function fetchCommonCodeList(commonCodeTypeNumber) {
|
||||
try {
|
||||
const response = await fetch(COMMON_CODE_LIST_ENDPOINT, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
credentials: 'include',
|
||||
body: JSON.stringify({ commonCodeTypeNumber }),
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error(`HTTP error! status: ${response.status}`);
|
||||
}
|
||||
|
||||
const result = await response.json();
|
||||
return result?.codeList || [];
|
||||
} catch (error) {
|
||||
console.error('[fetchCommonCodeList] Error:', error);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
481
src/api/satelliteApi.js
Normal file
481
src/api/satelliteApi.js
Normal file
@ -0,0 +1,481 @@
|
||||
/**
|
||||
* 위성 API
|
||||
*/
|
||||
|
||||
const SATELLITE_VIDEO_SEARCH_ENDPOINT = '/api/gis/satlit/search';
|
||||
const SATELLITE_CSV_ENDPOINT = '/api/gis/satlit/excelToJson';
|
||||
const SATELLITE_DETAIL_ENDPOINT = '/api/gis/satlit/id/search';
|
||||
const SATELLITE_UPDATE_ENDPOINT = '/api/gis/satlit/update';
|
||||
const SATELLITE_COMPANY_LIST_ENDPOINT = '/api/gis/satlit/sat-bz/all/search';
|
||||
const SATELLITE_MANAGE_LIST_ENDPOINT = '/api/gis/satlit/sat-mng/bz/search';
|
||||
const SATELLITE_SAVE_ENDPOINT = '/api/gis/satlit/save';
|
||||
const SATELLITE_COMPANY_SEARCH_ENDPOINT = '/api/gis/satlit/sat-bz/search';
|
||||
const SATELLITE_COMPANY_SAVE_ENDPOINT = '/api/gis/satlit/sat-bz/save';
|
||||
const SATELLITE_COMPANY_DETAIL_ENDPOINT = '/api/gis/satlit/sat-bz/id/search';
|
||||
const SATELLITE_COMPANY_UPDATE_ENDPOINT = '/api/gis/satlit/sat-bz/update';
|
||||
const SATELLITE_MANAGE_SEARCH_ENDPOINT = '/api/gis/satlit/sat-mng/search';
|
||||
const SATELLITE_MANAGE_SAVE_ENDPOINT = '/api/gis/satlit/sat-mng/save';
|
||||
const SATELLITE_MANAGE_DETAIL_ENDPOINT = '/api/gis/satlit/sat-mng/id/search';
|
||||
const SATELLITE_MANAGE_UPDATE_ENDPOINT = '/api/gis/satlit/sat-mng/update';
|
||||
|
||||
/**
|
||||
* 위성영상 목록 조회
|
||||
*
|
||||
* @param {Object} params
|
||||
* @param {number} params.page - 페이지 번호
|
||||
* @param {string} [params.startDate] - 촬영 시작일
|
||||
* @param {string} [params.endDate] - 촬영 종료일
|
||||
* @param {string} [params.satelliteVideoName] - 위성영상명
|
||||
* @param {string} [params.satelliteVideoTransmissionCycle] - 전송주기
|
||||
* @param {string} [params.satelliteVideoKind] - 영상 종류
|
||||
* @param {string} [params.satelliteVideoOrbit] - 위성 궤도
|
||||
* @param {string} [params.satelliteVideoOrigin] - 영상 출처
|
||||
* @returns {Promise<{ list: Array, totalPage: number }>}
|
||||
*/
|
||||
export async function fetchSatelliteVideoList({
|
||||
page,
|
||||
startDate,
|
||||
endDate,
|
||||
satelliteVideoName,
|
||||
satelliteVideoTransmissionCycle,
|
||||
satelliteVideoKind,
|
||||
satelliteVideoOrbit,
|
||||
satelliteVideoOrigin,
|
||||
}) {
|
||||
try {
|
||||
const response = await fetch(SATELLITE_VIDEO_SEARCH_ENDPOINT, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
credentials: 'include',
|
||||
body: JSON.stringify({
|
||||
page,
|
||||
startDate,
|
||||
endDate,
|
||||
satelliteVideoName,
|
||||
satelliteVideoTransmissionCycle,
|
||||
satelliteVideoKind,
|
||||
satelliteVideoOrbit,
|
||||
satelliteVideoOrigin,
|
||||
}),
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error(`HTTP error! status: ${response.status}`);
|
||||
}
|
||||
|
||||
const result = await response.json();
|
||||
|
||||
return {
|
||||
list: result?.satelliteVideoInfoList || [],
|
||||
totalPage: result?.totalPage || 0,
|
||||
};
|
||||
} catch (error) {
|
||||
console.error('[fetchSatelliteVideoList] Error:', error);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 위성영상 CSV → JSON 변환 (선박 좌표 추출)
|
||||
*
|
||||
* @param {string} csvFileName - CSV 파일명
|
||||
* @returns {Promise<Array<{ coordinates: [number, number] }>>}
|
||||
*/
|
||||
export async function fetchSatelliteCsvFeatures(csvFileName) {
|
||||
try {
|
||||
const response = await fetch(SATELLITE_CSV_ENDPOINT, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
credentials: 'include',
|
||||
body: JSON.stringify({ csvFileName }),
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error(`HTTP error! status: ${response.status}`);
|
||||
}
|
||||
|
||||
const result = await response.json();
|
||||
const data = result?.jsonData;
|
||||
if (!data) return [];
|
||||
|
||||
const parsed = typeof data === 'string' ? JSON.parse(data) : data;
|
||||
return parsed.map(({ lon, lat }) => ({
|
||||
coordinates: [parseFloat(lon), parseFloat(lat)],
|
||||
}));
|
||||
} catch (error) {
|
||||
console.error('[fetchSatelliteCsvFeatures] Error:', error);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 위성영상 상세조회
|
||||
*
|
||||
* @param {number} satelliteId - 위성 ID
|
||||
* @returns {Promise<Object>} SatelliteVideoInfoOneDto
|
||||
*/
|
||||
export async function fetchSatelliteVideoDetail(satelliteId) {
|
||||
try {
|
||||
const response = await fetch(SATELLITE_DETAIL_ENDPOINT, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
credentials: 'include',
|
||||
body: JSON.stringify({ satelliteId }),
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error(`HTTP error! status: ${response.status}`);
|
||||
}
|
||||
|
||||
const result = await response.json();
|
||||
return result?.satelliteVideoInfoById || null;
|
||||
} catch (error) {
|
||||
console.error('[fetchSatelliteVideoDetail] Error:', error);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 위성영상 수정
|
||||
*
|
||||
* @param {Object} params
|
||||
* @param {number} params.satelliteId
|
||||
* @param {number} params.satelliteManageId
|
||||
* @param {string} [params.photographDate]
|
||||
* @param {string} [params.satelliteVideoName]
|
||||
* @param {string} [params.satelliteVideoTransmissionCycle]
|
||||
* @param {string} [params.satelliteVideoKind]
|
||||
* @param {string} [params.satelliteVideoOrbit]
|
||||
* @param {string} [params.satelliteVideoOrigin]
|
||||
* @param {string} [params.photographPurpose]
|
||||
* @param {string} [params.photographMode]
|
||||
* @param {string} [params.purchaseCode]
|
||||
* @param {number} [params.purchasePrice]
|
||||
*/
|
||||
export async function updateSatelliteVideo(params) {
|
||||
try {
|
||||
const response = await fetch(SATELLITE_UPDATE_ENDPOINT, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
credentials: 'include',
|
||||
body: JSON.stringify(params),
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error(`HTTP error! status: ${response.status}`);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('[updateSatelliteVideo] Error:', error);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 사업자 목록 조회
|
||||
*
|
||||
* @returns {Promise<Array<{ companyNo: number, companyName: string }>>}
|
||||
*/
|
||||
export async function fetchSatelliteCompanyList() {
|
||||
try {
|
||||
const response = await fetch(SATELLITE_COMPANY_LIST_ENDPOINT, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
credentials: 'include',
|
||||
body: JSON.stringify({}),
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error(`HTTP error! status: ${response.status}`);
|
||||
}
|
||||
|
||||
const result = await response.json();
|
||||
return result?.satelliteCompanyNameList || [];
|
||||
} catch (error) {
|
||||
console.error('[fetchSatelliteCompanyList] Error:', error);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 사업자별 위성명 목록 조회
|
||||
*
|
||||
* @param {number} companyNo - 사업자 번호
|
||||
* @returns {Promise<Array<{ satelliteManageId: number, satelliteName: string }>>}
|
||||
*/
|
||||
export async function fetchSatelliteManageList(companyNo) {
|
||||
try {
|
||||
const response = await fetch(SATELLITE_MANAGE_LIST_ENDPOINT, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
credentials: 'include',
|
||||
body: JSON.stringify({ companyNo }),
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error(`HTTP error! status: ${response.status}`);
|
||||
}
|
||||
|
||||
const result = await response.json();
|
||||
return result?.satelliteManageInfoList || [];
|
||||
} catch (error) {
|
||||
console.error('[fetchSatelliteManageList] Error:', error);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 위성영상 등록 (multipart/form-data)
|
||||
*
|
||||
* @param {FormData} formData - 파일(tifFile, csvFile, cloudMaskFile) + 폼 필드
|
||||
*/
|
||||
export async function saveSatelliteVideo(formData) {
|
||||
try {
|
||||
const response = await fetch(SATELLITE_SAVE_ENDPOINT, {
|
||||
method: 'POST',
|
||||
credentials: 'include',
|
||||
body: formData,
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error(`HTTP error! status: ${response.status}`);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('[saveSatelliteVideo] Error:', error);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 위성 사업자 목록 검색
|
||||
*
|
||||
* @param {Object} params
|
||||
* @param {string} [params.companyTypeCode] - 사업자 분류 코드
|
||||
* @param {string} [params.companyName] - 사업자명
|
||||
* @returns {Promise<{ list: Array, totalPage: number }>}
|
||||
*/
|
||||
export async function searchSatelliteCompany({ companyTypeCode, companyName, page, limit }) {
|
||||
try {
|
||||
const response = await fetch(SATELLITE_COMPANY_SEARCH_ENDPOINT, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
credentials: 'include',
|
||||
body: JSON.stringify({ companyTypeCode, companyName, page, limit }),
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error(`HTTP error! status: ${response.status}`);
|
||||
}
|
||||
|
||||
const result = await response.json();
|
||||
return {
|
||||
list: result?.satelliteCompanySearchList || [],
|
||||
totalPage: result?.totalPage || 0,
|
||||
};
|
||||
} catch (error) {
|
||||
console.error('[searchSatelliteCompany] Error:', error);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 위성 사업자 등록
|
||||
*
|
||||
* @param {Object} params
|
||||
* @param {string} params.companyTypeCode - 사업자 분류 코드
|
||||
* @param {string} params.companyName - 사업자명
|
||||
* @param {string} params.nationalCode - 국가코드
|
||||
* @param {string} [params.location] - 소재지
|
||||
* @param {string} [params.companyDetail] - 상세내역
|
||||
*/
|
||||
export async function saveSatelliteCompany(params) {
|
||||
try {
|
||||
const response = await fetch(SATELLITE_COMPANY_SAVE_ENDPOINT, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
credentials: 'include',
|
||||
body: JSON.stringify(params),
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error(`HTTP error! status: ${response.status}`);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('[saveSatelliteCompany] Error:', error);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 위성 사업자 상세조회
|
||||
*
|
||||
* @param {number} companyNo - 사업자 번호
|
||||
* @returns {Promise<Object>} SatelliteCompanySearchDto
|
||||
*/
|
||||
export async function fetchSatelliteCompanyDetail(companyNo) {
|
||||
try {
|
||||
const response = await fetch(SATELLITE_COMPANY_DETAIL_ENDPOINT, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
credentials: 'include',
|
||||
body: JSON.stringify({ companyNo }),
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error(`HTTP error! status: ${response.status}`);
|
||||
}
|
||||
|
||||
const result = await response.json();
|
||||
return result?.satelliteCompany || null;
|
||||
} catch (error) {
|
||||
console.error('[fetchSatelliteCompanyDetail] Error:', error);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 위성 사업자 수정
|
||||
*
|
||||
* @param {Object} params
|
||||
* @param {number} params.companyNo - 사업자 번호
|
||||
* @param {string} params.companyTypeCode - 사업자 분류 코드
|
||||
* @param {string} params.companyName - 사업자명
|
||||
* @param {string} params.nationalCode - 국가코드
|
||||
* @param {string} [params.location] - 소재지
|
||||
* @param {string} [params.companyDetail] - 상세내역
|
||||
*/
|
||||
export async function updateSatelliteCompany(params) {
|
||||
try {
|
||||
const response = await fetch(SATELLITE_COMPANY_UPDATE_ENDPOINT, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
credentials: 'include',
|
||||
body: JSON.stringify(params),
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error(`HTTP error! status: ${response.status}`);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('[updateSatelliteCompany] Error:', error);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 위성관리 목록 검색
|
||||
*
|
||||
* @param {Object} params
|
||||
* @param {number} [params.companyNo] - 사업자 번호
|
||||
* @param {string} [params.satelliteName] - 위성명
|
||||
* @param {string} [params.sensorType] - 센서 타입
|
||||
* @returns {Promise<{ list: Array, totalPage: number }>}
|
||||
*/
|
||||
export async function searchSatelliteManage({ companyNo, satelliteName, sensorType, page, limit }) {
|
||||
try {
|
||||
const response = await fetch(SATELLITE_MANAGE_SEARCH_ENDPOINT, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
credentials: 'include',
|
||||
body: JSON.stringify({ companyNo, satelliteName, sensorType, page, limit }),
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error(`HTTP error! status: ${response.status}`);
|
||||
}
|
||||
|
||||
const result = await response.json();
|
||||
return {
|
||||
list: result?.satelliteManageInfoSearchList || [],
|
||||
totalPage: result?.totalPage || 0,
|
||||
};
|
||||
} catch (error) {
|
||||
console.error('[searchSatelliteManage] Error:', error);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 위성 관리 등록
|
||||
*
|
||||
* @param {Object} params
|
||||
* @param {number} params.companyNo - 사업자 번호
|
||||
* @param {string} params.satelliteName - 위성명
|
||||
* @param {string} [params.sensorType] - 센서 타입
|
||||
* @param {string} [params.photoResolution] - 촬영 해상도
|
||||
* @param {string} [params.frequency] - 주파수
|
||||
* @param {string} [params.photoDetail] - 상세내역
|
||||
*/
|
||||
export async function saveSatelliteManage(params) {
|
||||
try {
|
||||
const response = await fetch(SATELLITE_MANAGE_SAVE_ENDPOINT, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
credentials: 'include',
|
||||
body: JSON.stringify(params),
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error(`HTTP error! status: ${response.status}`);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('[saveSatelliteManage] Error:', error);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 위성 관리 상세조회
|
||||
*
|
||||
* @param {number} satelliteManageId - 위성 관리 ID
|
||||
* @returns {Promise<Object>} SatelliteManageInfoDto
|
||||
*/
|
||||
export async function fetchSatelliteManageDetail(satelliteManageId) {
|
||||
try {
|
||||
const response = await fetch(SATELLITE_MANAGE_DETAIL_ENDPOINT, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
credentials: 'include',
|
||||
body: JSON.stringify({ satelliteManageId }),
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error(`HTTP error! status: ${response.status}`);
|
||||
}
|
||||
|
||||
const result = await response.json();
|
||||
return result?.satelliteManageInfo || null;
|
||||
} catch (error) {
|
||||
console.error('[fetchSatelliteManageDetail] Error:', error);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 위성 관리 수정
|
||||
*
|
||||
* @param {Object} params
|
||||
* @param {number} params.satelliteManageId - 위성 관리 ID
|
||||
* @param {number} params.companyNo - 사업자 번호
|
||||
* @param {string} params.satelliteName - 위성명
|
||||
* @param {string} [params.sensorType] - 센서 타입
|
||||
* @param {string} [params.photoResolution] - 촬영 해상도
|
||||
* @param {string} [params.frequency] - 주파수
|
||||
* @param {string} [params.photoDetail] - 상세내역
|
||||
*/
|
||||
export async function updateSatelliteManage(params) {
|
||||
try {
|
||||
const response = await fetch(SATELLITE_MANAGE_UPDATE_ENDPOINT, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
credentials: 'include',
|
||||
body: JSON.stringify(params),
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error(`HTTP error! status: ${response.status}`);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('[updateSatelliteManage] Error:', error);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
309
src/api/weatherApi.js
Normal file
309
src/api/weatherApi.js
Normal file
@ -0,0 +1,309 @@
|
||||
/**
|
||||
* 기상해양 API
|
||||
*/
|
||||
|
||||
const SPECIAL_NEWS_ENDPOINT = '/api/gis/weather/special-news/search';
|
||||
|
||||
/**
|
||||
* 기상특보 목록 조회
|
||||
*
|
||||
* @param {Object} params
|
||||
* @param {string} params.startPresentationDate - 조회 시작일 (e.g. '2026-01-01')
|
||||
* @param {string} params.endPresentationDate - 조회 종료일 (e.g. '2026-01-31')
|
||||
* @param {number} params.page - 페이지 번호
|
||||
* @param {number} params.limit - 페이지당 항목 수
|
||||
* @returns {Promise<{ list: Array, totalPage: number }>}
|
||||
*/
|
||||
export async function fetchWeatherAlerts({ startPresentationDate, endPresentationDate, page, limit }) {
|
||||
try {
|
||||
const response = await fetch(SPECIAL_NEWS_ENDPOINT, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
credentials: 'include',
|
||||
body: JSON.stringify({
|
||||
startPresentationDate,
|
||||
endPresentationDate,
|
||||
page,
|
||||
limit,
|
||||
}),
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error(`HTTP error! status: ${response.status}`);
|
||||
}
|
||||
|
||||
const result = await response.json();
|
||||
|
||||
return {
|
||||
list: result?.specialNewsDetailList || [],
|
||||
totalPage: result?.totalPage || 0,
|
||||
};
|
||||
} catch (error) {
|
||||
console.error('[fetchWeatherAlerts] Error:', error);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
const TYPHOON_LIST_ENDPOINT = '/api/gis/weather/typhoon/list/search';
|
||||
const TYPHOON_DETAIL_ENDPOINT = '/api/gis/weather/typhoon/search';
|
||||
|
||||
/**
|
||||
* 태풍 목록 조회
|
||||
*
|
||||
* @param {Object} params
|
||||
* @param {string} params.typhoonBeginningYear - 조회 연도
|
||||
* @param {string} params.typhoonBeginningMonth - 조회 월 (빈 문자열이면 전체)
|
||||
* @param {number} params.page - 페이지 번호
|
||||
* @param {number} params.limit - 페이지당 항목 수
|
||||
* @returns {Promise<{ list: Array, totalPage: number }>}
|
||||
*/
|
||||
export async function fetchTyphoonList({ typhoonBeginningYear, typhoonBeginningMonth, page, limit }) {
|
||||
try {
|
||||
const response = await fetch(TYPHOON_LIST_ENDPOINT, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
credentials: 'include',
|
||||
body: JSON.stringify({
|
||||
typhoonBeginningYear,
|
||||
typhoonBeginningMonth,
|
||||
page,
|
||||
limit,
|
||||
}),
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error(`HTTP error! status: ${response.status}`);
|
||||
}
|
||||
|
||||
const result = await response.json();
|
||||
|
||||
const grouped = result?.typhoonList || [];
|
||||
const list = grouped.flatMap((group) => group.typhoonList || []);
|
||||
|
||||
return {
|
||||
list,
|
||||
totalPage: result?.totalPage || 0,
|
||||
};
|
||||
} catch (error) {
|
||||
console.error('[fetchTyphoonList] Error:', error);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 태풍 상세(진행정보) 조회
|
||||
*
|
||||
* @param {Object} params
|
||||
* @param {string} params.typhoonSequence - 태풍 순번
|
||||
* @param {string} params.year - 연도
|
||||
* @returns {Promise<Array>}
|
||||
*/
|
||||
export async function fetchTyphoonDetail({ typhoonSequence, year }) {
|
||||
try {
|
||||
const response = await fetch(TYPHOON_DETAIL_ENDPOINT, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
credentials: 'include',
|
||||
body: JSON.stringify({
|
||||
typhoonSequence,
|
||||
year,
|
||||
page: 1,
|
||||
limit: 10000,
|
||||
}),
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error(`HTTP error! status: ${response.status}`);
|
||||
}
|
||||
|
||||
const result = await response.json();
|
||||
|
||||
return result?.typhoonSelectDto || [];
|
||||
} catch (error) {
|
||||
console.error('[fetchTyphoonDetail] Error:', error);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
const TIDE_INFORMATION_ENDPOINT = '/api/gis/weather/tide-information/search';
|
||||
const SUNRISE_SUNSET_DETAIL_ENDPOINT = '/api/gis/weather/tide-information/observatory/detail/search';
|
||||
|
||||
/**
|
||||
* 조석정보 통합 조회 (조위관측소 + 일출몰관측지역)
|
||||
*
|
||||
* @returns {Promise<{ observatories: Array, sunriseSunsets: Array }>}
|
||||
*/
|
||||
export async function fetchTideInformation() {
|
||||
try {
|
||||
const response = await fetch(TIDE_INFORMATION_ENDPOINT, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
credentials: 'include',
|
||||
body: JSON.stringify({}),
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error(`HTTP error! status: ${response.status}`);
|
||||
}
|
||||
|
||||
const result = await response.json();
|
||||
|
||||
return {
|
||||
observatories: result?.observatorySearchDto || [],
|
||||
sunriseSunsets: result?.sunriseSunsetSearchDto || [],
|
||||
};
|
||||
} catch (error) {
|
||||
console.error('[fetchTideInformation] Error:', error);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 일출일몰 상세 조회
|
||||
*
|
||||
* @param {Object} params - SunriseSunsetSearchDto
|
||||
* @param {string} params.locationName - 지역명
|
||||
* @param {string} params.locationType - 지역 유형
|
||||
* @param {Object} params.coordinate - 좌표
|
||||
* @param {boolean} params.isChecked - 체크 여부
|
||||
* @param {Array} params.locationCoordinates - 좌표 배열
|
||||
* @returns {Promise<Object|null>} SunriseSunsetSelectDetailDto 또는 null
|
||||
*/
|
||||
export async function fetchSunriseSunsetDetail(params) {
|
||||
try {
|
||||
const response = await fetch(SUNRISE_SUNSET_DETAIL_ENDPOINT, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
credentials: 'include',
|
||||
body: JSON.stringify(params),
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error(`HTTP error! status: ${response.status}`);
|
||||
}
|
||||
|
||||
const result = await response.json();
|
||||
|
||||
return result?.sunriseSunsetSelectDetailDto?.[0] || null;
|
||||
} catch (error) {
|
||||
console.error('[fetchSunriseSunsetDetail] Error:', error);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
const OBSERVATORY_ENDPOINT = '/api/gis/weather/observatory/search';
|
||||
const OBSERVATORY_DETAIL_ENDPOINT = '/api/gis/weather/observatory/select/detail/search';
|
||||
|
||||
/**
|
||||
* 관측소 목록 조회
|
||||
*
|
||||
* @returns {Promise<Array>} ObservatorySearchDto 배열
|
||||
*/
|
||||
export async function fetchObservatoryList() {
|
||||
try {
|
||||
const response = await fetch(OBSERVATORY_ENDPOINT, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
credentials: 'include',
|
||||
body: JSON.stringify({}),
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error(`HTTP error! status: ${response.status}`);
|
||||
}
|
||||
|
||||
const result = await response.json();
|
||||
|
||||
return result?.dtoList || [];
|
||||
} catch (error) {
|
||||
console.error('[fetchObservatoryList] Error:', error);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 관측소 상세정보 조회
|
||||
*
|
||||
* @param {Object} params
|
||||
* @param {string} params.observatoryId - 관측소 ID
|
||||
* @param {string} params.toDate - 조회 기준일 (e.g. '2026-02-10')
|
||||
* @returns {Promise<Object|null>} ObservatorySelectDetailDto 또는 null
|
||||
*/
|
||||
const AIRPORT_ENDPOINT = '/api/gis/weather/airport/search';
|
||||
const AIRPORT_DETAIL_ENDPOINT = '/api/gis/weather/airport/select';
|
||||
|
||||
/**
|
||||
* 공항 목록 조회
|
||||
*
|
||||
* @returns {Promise<Array>} AirportSearchDto 배열
|
||||
*/
|
||||
export async function fetchAirportList() {
|
||||
try {
|
||||
const response = await fetch(AIRPORT_ENDPOINT, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
credentials: 'include',
|
||||
body: JSON.stringify({}),
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error(`HTTP error! status: ${response.status}`);
|
||||
}
|
||||
|
||||
const result = await response.json();
|
||||
return result?.airportSearchDto || [];
|
||||
} catch (error) {
|
||||
console.error('[fetchAirportList] Error:', error);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 공항 상세정보 조회
|
||||
*
|
||||
* @param {Object} params
|
||||
* @param {string} params.airportId - 공항 ID
|
||||
* @returns {Promise<Object|null>} AirportSelectDto 또는 null
|
||||
*/
|
||||
export async function fetchAirportDetail({ airportId }) {
|
||||
try {
|
||||
const response = await fetch(AIRPORT_DETAIL_ENDPOINT, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
credentials: 'include',
|
||||
body: JSON.stringify({ airportId }),
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error(`HTTP error! status: ${response.status}`);
|
||||
}
|
||||
|
||||
const result = await response.json();
|
||||
return result?.airportSelectDto || null;
|
||||
} catch (error) {
|
||||
console.error('[fetchAirportDetail] Error:', error);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
export async function fetchObservatoryDetail({ observatoryId, toDate }) {
|
||||
try {
|
||||
const response = await fetch(OBSERVATORY_DETAIL_ENDPOINT, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
credentials: 'include',
|
||||
body: JSON.stringify({ observatoryId, toDate }),
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error(`HTTP error! status: ${response.status}`);
|
||||
}
|
||||
|
||||
const result = await response.json();
|
||||
|
||||
return result?.observatorySelectDetail?.[0] || null;
|
||||
} catch (error) {
|
||||
console.error('[fetchObservatoryDetail] Error:', error);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
@ -19,6 +19,8 @@ import DisplayComponent from '../../component/wrap/side/DisplayComponent';
|
||||
// 구현된 페이지
|
||||
import ReplayPage from '../../pages/ReplayPage';
|
||||
import AreaSearchPage from '../../areaSearch/components/AreaSearchPage';
|
||||
import WeatherPage from '../../pages/WeatherPage';
|
||||
import SatellitePage from '../../pages/SatellitePage';
|
||||
|
||||
/**
|
||||
* 사이드바 컴포넌트
|
||||
@ -64,8 +66,8 @@ export default function Sidebar() {
|
||||
const renderPanel = () => {
|
||||
const panelMap = {
|
||||
gnb1: DisplayComponent ? <DisplayComponent {...panelProps} initialTab="filter" /> : null,
|
||||
gnb2: Panel2Component ? <Panel2Component {...panelProps} /> : null,
|
||||
gnb3: Panel3Component ? <Panel3Component {...panelProps} /> : null,
|
||||
gnb2: <SatellitePage {...panelProps} />,
|
||||
gnb3: <WeatherPage {...panelProps} />,
|
||||
gnb4: Panel4Component ? <Panel4Component {...panelProps} /> : null,
|
||||
gnb5: Panel5Component ? <Panel5Component {...panelProps} /> : null,
|
||||
gnb6: Panel6Component ? <Panel6Component {...panelProps} /> : null,
|
||||
@ -78,11 +80,11 @@ export default function Sidebar() {
|
||||
};
|
||||
|
||||
return (
|
||||
<section id="sidePanel">
|
||||
<SideNav activeKey={activeKey} onChange={handleMenuChange} />
|
||||
<div className="sidePanelContent">
|
||||
{renderPanel()}
|
||||
</div>
|
||||
</section>
|
||||
<section id="sidePanel">
|
||||
<SideNav activeKey={activeKey} onChange={handleMenuChange} />
|
||||
<div className="sidePanelContent">
|
||||
{renderPanel()}
|
||||
</div>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
|
||||
@ -7,6 +7,7 @@ import { defaults as defaultInteractions, DragBox } from 'ol/interaction';
|
||||
import { platformModifierKeyOnly } from 'ol/events/condition';
|
||||
|
||||
import { createBaseLayers } from './layers/baseLayer';
|
||||
import { satelliteLayer, csvDeckLayer } from './layers/satelliteLayer';
|
||||
import { useMapStore, BASE_MAP_TYPES } from '../stores/mapStore';
|
||||
import useShipStore from '../stores/shipStore';
|
||||
import useShipData from '../hooks/useShipData';
|
||||
@ -400,6 +401,8 @@ export default function MapContainer() {
|
||||
worldMap,
|
||||
encMap,
|
||||
darkMap,
|
||||
satelliteLayer,
|
||||
csvDeckLayer,
|
||||
eastAsiaMap,
|
||||
korMap,
|
||||
],
|
||||
@ -429,7 +432,7 @@ export default function MapContainer() {
|
||||
|
||||
const state = useShipStore.getState();
|
||||
const { features, darkSignalIds, isIntegrate, kindVisibility, sourceVisibility,
|
||||
nationalVisibility, darkSignalVisible } = state;
|
||||
nationalVisibility, darkSignalVisible } = state;
|
||||
|
||||
// 국적 코드 매핑 (shipStore.js와 동일)
|
||||
const mapNational = (code) => {
|
||||
|
||||
131
src/map/layers/satelliteLayer.js
Normal file
131
src/map/layers/satelliteLayer.js
Normal file
@ -0,0 +1,131 @@
|
||||
/**
|
||||
* 위성영상 레이어
|
||||
* - TIF 영상: OL TileLayer + GeoServer WMS
|
||||
* - CSV 선박 점: Deck.gl ScatterplotLayer + 별도 Deck 인스턴스 → OL WebGLTileLayer 래핑
|
||||
*
|
||||
* 참조: mda-react-front/src/common/targetLayer.ts (satelliteLayer, deckSatellite 등)
|
||||
* 참조: mda-react-front/src/util/satellite.ts (createSatellitePictureLayer, removeSatelliteLayer)
|
||||
*/
|
||||
import TileLayer from 'ol/layer/Tile';
|
||||
import TileWMS from 'ol/source/TileWMS';
|
||||
import WebGLTileLayer from 'ol/layer/WebGLTile';
|
||||
import { transformExtent, toLonLat } from 'ol/proj';
|
||||
import { Deck } from '@deck.gl/core';
|
||||
import { ScatterplotLayer } from '@deck.gl/layers';
|
||||
|
||||
// =====================
|
||||
// TIF 영상 레이어 (GeoServer WMS)
|
||||
// =====================
|
||||
export const satelliteLayer = new TileLayer({
|
||||
source: new TileWMS({
|
||||
url: '/geo/geoserver/mda/wms',
|
||||
params: { tiled: true, LAYERS: '' },
|
||||
}),
|
||||
className: 'satellite-map',
|
||||
zIndex: 10,
|
||||
visible: false,
|
||||
});
|
||||
|
||||
// =====================
|
||||
// CSV 선박 점 레이어 (Deck.gl ScatterplotLayer)
|
||||
// =====================
|
||||
export const csvScatterLayer = new ScatterplotLayer({
|
||||
id: 'satellite-csv-layer',
|
||||
data: [],
|
||||
getPosition: (d) => d.coordinates,
|
||||
getFillColor: [232, 232, 21],
|
||||
getRadius: 3,
|
||||
radiusUnits: 'pixels',
|
||||
pickable: false,
|
||||
});
|
||||
|
||||
export const csvDeck = new Deck({
|
||||
initialViewState: {
|
||||
longitude: 127.1388684,
|
||||
latitude: 37.4449168,
|
||||
zoom: 6,
|
||||
transitionDuration: 0,
|
||||
},
|
||||
controller: false,
|
||||
layers: [csvScatterLayer],
|
||||
});
|
||||
|
||||
export const csvDeckLayer = new WebGLTileLayer({
|
||||
source: undefined,
|
||||
zIndex: 200,
|
||||
visible: false,
|
||||
render: (frameState) => {
|
||||
const { center, zoom } = frameState.viewState;
|
||||
csvDeck.setProps({
|
||||
viewState: {
|
||||
longitude: toLonLat(center)[0],
|
||||
latitude: toLonLat(center)[1],
|
||||
zoom: zoom - 1,
|
||||
},
|
||||
});
|
||||
csvDeck.redraw();
|
||||
return csvDeck.canvas;
|
||||
},
|
||||
});
|
||||
|
||||
// =====================
|
||||
// 표출/제거 함수
|
||||
// =====================
|
||||
|
||||
/**
|
||||
* 위성영상 TIF를 지도에 표출
|
||||
* @param {import('ol/Map').default} map - OL 맵 인스턴스
|
||||
* @param {string} tifGeoName - GeoServer 레이어명
|
||||
* @param {[number,number,number,number]} extent - [minX, minY, maxX, maxY] EPSG:4326
|
||||
* @param {number} opacity - 0~1
|
||||
* @param {number} brightness - 0~200 (%)
|
||||
*/
|
||||
export function showSatelliteImage(map, tifGeoName, extent, opacity, brightness) {
|
||||
const extent3857 = transformExtent(extent, 'EPSG:4326', 'EPSG:3857');
|
||||
|
||||
const source = new TileWMS({
|
||||
url: '/geo/geoserver/mda/wms',
|
||||
params: { tiled: true, LAYERS: tifGeoName },
|
||||
hidpi: false,
|
||||
transition: 0,
|
||||
});
|
||||
|
||||
satelliteLayer.setExtent(extent3857);
|
||||
satelliteLayer.setSource(source);
|
||||
satelliteLayer.setOpacity(Number(opacity));
|
||||
satelliteLayer.setVisible(true);
|
||||
|
||||
// CSS brightness 적용
|
||||
const el = document.querySelector('.satellite-map');
|
||||
if (el) {
|
||||
el.style.filter = `brightness(${brightness}%)`;
|
||||
}
|
||||
|
||||
// 해당 영상 범위로 지도 이동
|
||||
map.getView().fit(extent3857);
|
||||
|
||||
// 타일 로딩 강제 트리거
|
||||
source.refresh();
|
||||
}
|
||||
|
||||
/**
|
||||
* CSV 선박 좌표를 Deck.gl ScatterplotLayer로 표시
|
||||
* @param {Array<{ coordinates: [number, number] }>} features
|
||||
*/
|
||||
export function showCsvFeatures(features) {
|
||||
const layer = csvScatterLayer.clone({ data: features });
|
||||
csvDeck.setProps({ layers: [layer] });
|
||||
csvDeckLayer.setVisible(true);
|
||||
}
|
||||
|
||||
/**
|
||||
* 위성영상 + CSV 레이어 제거
|
||||
*/
|
||||
export function hideSatelliteImage() {
|
||||
satelliteLayer.setVisible(false);
|
||||
satelliteLayer.setSource(null);
|
||||
|
||||
const emptyLayer = csvScatterLayer.clone({ data: [] });
|
||||
csvDeck.setProps({ layers: [emptyLayer] });
|
||||
csvDeckLayer.setVisible(false);
|
||||
}
|
||||
57
src/pages/SatellitePage.jsx
Normal file
57
src/pages/SatellitePage.jsx
Normal file
@ -0,0 +1,57 @@
|
||||
import { useState } from 'react';
|
||||
import SatelliteImageManage from '@/satellite/components/SatelliteImageManage';
|
||||
import SatelliteProviderManage from '@/satellite/components/SatelliteProviderManage';
|
||||
import SatelliteManage from '@/satellite/components/SatelliteManage';
|
||||
|
||||
const tabs = [
|
||||
{ id: 'satellite01', label: '위성영상 관리' },
|
||||
{ id: 'satellite02', label: '위성사업자 관리' },
|
||||
{ id: 'satellite03', label: '위성 관리' },
|
||||
];
|
||||
|
||||
const tabComponents = {
|
||||
satellite01: SatelliteImageManage,
|
||||
satellite02: SatelliteProviderManage,
|
||||
satellite03: SatelliteManage,
|
||||
};
|
||||
|
||||
export default function SatellitePage({ isOpen, onToggle }) {
|
||||
const [activeTab, setActiveTab] = useState('satellite01');
|
||||
|
||||
const ActiveComponent = tabComponents[activeTab];
|
||||
|
||||
return (
|
||||
<aside className={`slidePanel ${!isOpen ? 'is-closed' : ''}`}>
|
||||
{/* 탭 버튼 */}
|
||||
<div className="tabBox">
|
||||
<div className="tabDefault">
|
||||
{tabs.map((tab) => (
|
||||
<button
|
||||
key={tab.id}
|
||||
type="button"
|
||||
className={activeTab === tab.id ? 'on' : ''}
|
||||
onClick={() => setActiveTab(tab.id)}
|
||||
>
|
||||
{tab.label}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 탭 콘텐츠 */}
|
||||
{ActiveComponent && <ActiveComponent />}
|
||||
|
||||
{/* 사이드패널 토글버튼 */}
|
||||
<button
|
||||
type="button"
|
||||
className="toogle"
|
||||
aria-expanded={isOpen}
|
||||
onClick={onToggle}
|
||||
>
|
||||
<span className="blind">
|
||||
{isOpen ? '패널 접기' : '패널 열기'}
|
||||
</span>
|
||||
</button>
|
||||
</aside>
|
||||
);
|
||||
}
|
||||
63
src/pages/WeatherPage.jsx
Normal file
63
src/pages/WeatherPage.jsx
Normal file
@ -0,0 +1,63 @@
|
||||
import { useState } from 'react';
|
||||
import WeatherAlert from '@/weather/components/WeatherAlert';
|
||||
import TyphoonInfo from '@/weather/components/TyphoonInfo';
|
||||
import TidalObservation from '@/weather/components/TidalObservation';
|
||||
import TidalInfo from '@/weather/components/TidalInfo';
|
||||
import AviationWeather from '@/weather/components/AviationWeather';
|
||||
|
||||
const tabs = [
|
||||
{ id: 'weather01', label: '기상특보' },
|
||||
{ id: 'weather02', label: '태풍정보' },
|
||||
{ id: 'weather03', label: '조위관측' },
|
||||
{ id: 'weather04', label: '조석정보' },
|
||||
{ id: 'weather05', label: '항공기상' },
|
||||
];
|
||||
|
||||
const tabComponents = {
|
||||
weather01: WeatherAlert,
|
||||
weather02: TyphoonInfo,
|
||||
weather03: TidalObservation,
|
||||
weather04: TidalInfo,
|
||||
weather05: AviationWeather,
|
||||
};
|
||||
|
||||
export default function WeatherPage({ isOpen, onToggle }) {
|
||||
const [activeTab, setActiveTab] = useState('weather01');
|
||||
|
||||
const ActiveComponent = tabComponents[activeTab];
|
||||
|
||||
return (
|
||||
<aside className={`slidePanel ${!isOpen ? 'is-closed' : ''}`}>
|
||||
{/* 탭 버튼 */}
|
||||
<div className="tabBox">
|
||||
<div className="tabDefault">
|
||||
{tabs.map((tab) => (
|
||||
<button
|
||||
key={tab.id}
|
||||
type="button"
|
||||
className={activeTab === tab.id ? 'on' : ''}
|
||||
onClick={() => setActiveTab(tab.id)}
|
||||
>
|
||||
{tab.label}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 탭 콘텐츠 */}
|
||||
{ActiveComponent && <ActiveComponent />}
|
||||
|
||||
{/* 사이드패널 토글버튼 */}
|
||||
<button
|
||||
type="button"
|
||||
className="toogle"
|
||||
aria-expanded={isOpen}
|
||||
onClick={onToggle}
|
||||
>
|
||||
<span className="blind">
|
||||
{isOpen ? '패널 접기' : '패널 열기'}
|
||||
</span>
|
||||
</button>
|
||||
</aside>
|
||||
);
|
||||
}
|
||||
@ -41,9 +41,10 @@ export default function Panel3Component({ isOpen, onToggle }) {
|
||||
<label>
|
||||
<span>일자</span>
|
||||
<div className='labelRow'>
|
||||
<input type="text" className="dateInput" placeholder="연도-월-일" />
|
||||
<input type="date" className="dateInput" placeholder="연도-월-일" />
|
||||
<span>-</span>
|
||||
<input type="text"className="dateInput" placeholder="연도-월-일" /></div>
|
||||
<input type="date" className="dateInput" placeholder="연도-월-일" />
|
||||
</div>
|
||||
</label>
|
||||
</li>
|
||||
<li className="fgBtn">
|
||||
|
||||
387
src/satellite/components/SatelliteImageManage.jsx
Normal file
387
src/satellite/components/SatelliteImageManage.jsx
Normal file
@ -0,0 +1,387 @@
|
||||
import { useState, useEffect, useCallback } from 'react';
|
||||
import { fetchCommonCodeList } from '@/api/commonApi';
|
||||
import { fetchSatelliteVideoList, fetchSatelliteCsvFeatures } from '@/api/satelliteApi';
|
||||
import { useSatelliteStore } from '@/stores/satelliteStore';
|
||||
import { useMapStore } from '@/stores/mapStore';
|
||||
import { satelliteLayer, showSatelliteImage, showCsvFeatures, hideSatelliteImage } from '@/map/layers/satelliteLayer';
|
||||
import Slider from './Slider';
|
||||
import SatelliteRegisterPopup from './SatelliteRegisterPopup';
|
||||
|
||||
const LIMIT = 10;
|
||||
|
||||
export default function SatelliteImageManage() {
|
||||
const [isAccordionOpen, setIsAccordionOpen] = useState(false);
|
||||
|
||||
// 공통코드 옵션
|
||||
const [videoKindOptions, setVideoKindOptions] = useState([]);
|
||||
const [videoOriginOptions, setVideoOriginOptions] = useState([]);
|
||||
const [videoOrbitOptions, setVideoOrbitOptions] = useState([]);
|
||||
const [videoCycleOptions, setVideoCycleOptions] = useState([]);
|
||||
|
||||
// 폼 필터 state
|
||||
const [startDate, setStartDate] = useState('');
|
||||
const [endDate, setEndDate] = useState('');
|
||||
const [satelliteVideoName, setSatelliteVideoName] = useState('');
|
||||
const [satelliteVideoKind, setSatelliteVideoKind] = useState('');
|
||||
const [satelliteVideoOrigin, setSatelliteVideoOrigin] = useState('');
|
||||
const [satelliteVideoOrbit, setSatelliteVideoOrbit] = useState('');
|
||||
const [satelliteVideoTransmissionCycle, setSatelliteVideoTransmissionCycle] = useState('');
|
||||
|
||||
// 결과 state
|
||||
const [list, setList] = useState([]);
|
||||
const [page, setPage] = useState(1);
|
||||
const [totalPage, setTotalPage] = useState(0);
|
||||
const [isLoading, setIsLoading] = useState(false);
|
||||
const [error, setError] = useState(null);
|
||||
|
||||
// 지도 표출 state
|
||||
const [activeImageId, setActiveImageId] = useState(null);
|
||||
|
||||
// 상세 팝업 state
|
||||
const [detailPopupId, setDetailPopupId] = useState(null);
|
||||
|
||||
// 등록 팝업 state
|
||||
const [isRegisterOpen, setIsRegisterOpen] = useState(false);
|
||||
|
||||
// 위성 투명도/밝기 스토어
|
||||
const opacity = useSatelliteStore((s) => s.opacity);
|
||||
const brightness = useSatelliteStore((s) => s.brightness);
|
||||
const setOpacity = useSatelliteStore((s) => s.setOpacity);
|
||||
const setBrightness = useSatelliteStore((s) => s.setBrightness);
|
||||
|
||||
// 마운트 시 공통코드 로드
|
||||
useEffect(() => {
|
||||
fetchCommonCodeList('000109').then(setVideoKindOptions).catch(() => setVideoKindOptions([]));
|
||||
fetchCommonCodeList('000111').then(setVideoOriginOptions).catch(() => setVideoOriginOptions([]));
|
||||
fetchCommonCodeList('000110').then(setVideoOrbitOptions).catch(() => setVideoOrbitOptions([]));
|
||||
fetchCommonCodeList('000108').then(setVideoCycleOptions).catch(() => setVideoCycleOptions([]));
|
||||
}, []);
|
||||
|
||||
const toggleAccordion = () => setIsAccordionOpen((prev) => !prev);
|
||||
|
||||
const search = useCallback(async (targetPage) => {
|
||||
setIsLoading(true);
|
||||
setError(null);
|
||||
|
||||
try {
|
||||
const result = await fetchSatelliteVideoList({
|
||||
page: targetPage,
|
||||
startDate,
|
||||
endDate,
|
||||
satelliteVideoName,
|
||||
satelliteVideoTransmissionCycle,
|
||||
satelliteVideoKind,
|
||||
satelliteVideoOrbit,
|
||||
satelliteVideoOrigin,
|
||||
});
|
||||
setList(result.list);
|
||||
setTotalPage(result.totalPage);
|
||||
setPage(targetPage);
|
||||
} catch (err) {
|
||||
setError('위성영상 조회 중 오류가 발생했습니다.');
|
||||
setList([]);
|
||||
setTotalPage(0);
|
||||
} finally {
|
||||
setIsLoading(false);
|
||||
}
|
||||
}, [startDate, endDate, satelliteVideoName, satelliteVideoTransmissionCycle, satelliteVideoKind, satelliteVideoOrbit, satelliteVideoOrigin]);
|
||||
|
||||
const handleSearch = () => {
|
||||
search(1);
|
||||
};
|
||||
|
||||
const handlePageChange = (newPage) => {
|
||||
search(newPage);
|
||||
};
|
||||
|
||||
// 투명도 변경 (Slider → 스토어 + 레이어 실시간 반영)
|
||||
const handleOpacityChange = (v) => {
|
||||
const val = v / 100;
|
||||
setOpacity(val);
|
||||
satelliteLayer.setOpacity(val);
|
||||
};
|
||||
|
||||
// 밝기 변경 (Slider → 스토어 + CSS filter 실시간 반영)
|
||||
const handleBrightnessChange = (v) => {
|
||||
setBrightness(v);
|
||||
const el = document.querySelector('.satellite-map');
|
||||
if (el) {
|
||||
el.style.filter = `brightness(${v}%)`;
|
||||
}
|
||||
};
|
||||
|
||||
// btnMap: 위성영상 지도 표출 토글
|
||||
const handleShowOnMap = async (item) => {
|
||||
const map = useMapStore.getState().map;
|
||||
if (!map) return;
|
||||
|
||||
// 같은 영상이면 제거 (토글)
|
||||
if (activeImageId === item.satelliteManageId) {
|
||||
hideSatelliteImage();
|
||||
setActiveImageId(null);
|
||||
return;
|
||||
}
|
||||
|
||||
const { opacity: curOpacity, brightness: curBrightness } = useSatelliteStore.getState();
|
||||
const extent = [item.tifMinX, item.tifMinY, item.tifMaxX, item.tifMaxY];
|
||||
showSatelliteImage(map, item.tifGeoName, extent, curOpacity, curBrightness);
|
||||
|
||||
// CSV 선박 점 표시
|
||||
if (item.csvFileName) {
|
||||
try {
|
||||
const features = await fetchSatelliteCsvFeatures(item.csvFileName);
|
||||
showCsvFeatures(features);
|
||||
} catch {
|
||||
// CSV 없으면 무시
|
||||
}
|
||||
}
|
||||
|
||||
setActiveImageId(item.satelliteManageId);
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="tabWrap is-active">
|
||||
<div className="tabTop">
|
||||
<div className="title">위성영상 관리</div>
|
||||
|
||||
<div className="formGroup">
|
||||
<ul>
|
||||
<li>
|
||||
<label>
|
||||
<span>영상 촬영일</span>
|
||||
<div className="labelRow">
|
||||
<input
|
||||
type="date"
|
||||
className="dateInput"
|
||||
value={startDate}
|
||||
onChange={(e) => setStartDate(e.target.value)}
|
||||
/>
|
||||
<span>-</span>
|
||||
<input
|
||||
type="date"
|
||||
className="dateInput"
|
||||
value={endDate}
|
||||
onChange={(e) => setEndDate(e.target.value)}
|
||||
/>
|
||||
</div>
|
||||
</label>
|
||||
</li>
|
||||
|
||||
{/* 아코디언 — 상세검색 */}
|
||||
<div className={`accordion pt8 ${isAccordionOpen ? 'is-open' : ''}`}>
|
||||
<li>
|
||||
<label>
|
||||
<span>영상 종류</span>
|
||||
<select
|
||||
value={satelliteVideoKind}
|
||||
onChange={(e) => setSatelliteVideoKind(e.target.value)}
|
||||
>
|
||||
<option value="">전체</option>
|
||||
{videoKindOptions.map((opt) => (
|
||||
<option key={opt.commonCodeTypeNumber} value={opt.commonCodeTypeNumber}>
|
||||
{opt.commonCodeTypeName}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</label>
|
||||
<label>
|
||||
<span>영상 출처</span>
|
||||
<select
|
||||
value={satelliteVideoOrigin}
|
||||
onChange={(e) => setSatelliteVideoOrigin(e.target.value)}
|
||||
>
|
||||
<option value="">전체</option>
|
||||
{videoOriginOptions.map((opt) => (
|
||||
<option key={opt.commonCodeTypeNumber} value={opt.commonCodeTypeNumber}>
|
||||
{opt.commonCodeTypeName}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</label>
|
||||
</li>
|
||||
<li>
|
||||
<label>
|
||||
<span>위성 궤도</span>
|
||||
<select
|
||||
value={satelliteVideoOrbit}
|
||||
onChange={(e) => setSatelliteVideoOrbit(e.target.value)}
|
||||
>
|
||||
<option value="">전체</option>
|
||||
{videoOrbitOptions.map((opt) => (
|
||||
<option key={opt.commonCodeTypeNumber} value={opt.commonCodeTypeNumber}>
|
||||
{opt.commonCodeTypeName}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</label>
|
||||
<label>
|
||||
<span>주기</span>
|
||||
<select
|
||||
value={satelliteVideoTransmissionCycle}
|
||||
onChange={(e) => setSatelliteVideoTransmissionCycle(e.target.value)}
|
||||
>
|
||||
<option value="">전체</option>
|
||||
{videoCycleOptions.map((opt) => (
|
||||
<option key={opt.commonCodeTypeNumber} value={opt.commonCodeTypeNumber}>
|
||||
{opt.commonCodeTypeName}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</label>
|
||||
</li>
|
||||
</div>
|
||||
|
||||
<li>
|
||||
<label>
|
||||
<span>위성영상명</span>
|
||||
<input
|
||||
type="text"
|
||||
placeholder="위성영상명"
|
||||
value={satelliteVideoName}
|
||||
onChange={(e) => setSatelliteVideoName(e.target.value)}
|
||||
/>
|
||||
</label>
|
||||
</li>
|
||||
<li>
|
||||
<button
|
||||
type="button"
|
||||
className={`btn btnS semi btnToggle ${isAccordionOpen ? 'is-open' : ''}`}
|
||||
aria-expanded={isAccordionOpen}
|
||||
onClick={toggleAccordion}
|
||||
>
|
||||
상세검색
|
||||
{isAccordionOpen ? ' 닫기' : ' 열기'}
|
||||
</button>
|
||||
</li>
|
||||
<li className="fgBtn rowSB">
|
||||
<>
|
||||
<div className="row gap10">
|
||||
<span>투명도</span>
|
||||
<div>
|
||||
<Slider
|
||||
label="투명도 조절"
|
||||
value={Math.round(opacity * 100)}
|
||||
onChange={handleOpacityChange}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<div className="row gap10">
|
||||
<span>밝기</span>
|
||||
<div>
|
||||
<Slider
|
||||
label="밝기 조절"
|
||||
min={0}
|
||||
max={200}
|
||||
value={brightness}
|
||||
onChange={handleBrightnessChange}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
<button type="button" className="schBtn" onClick={handleSearch}>검색</button>
|
||||
</li>
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="tabBtm noSc">
|
||||
<div className="tabBtmInner">
|
||||
{/* 스크롤영역 */}
|
||||
<div className="tabBtmCnt">
|
||||
{isLoading && <div className="loading">조회 중...</div>}
|
||||
|
||||
{error && <div className="error">{error}</div>}
|
||||
|
||||
{!isLoading && !error && list.length === 0 && (
|
||||
<div className="empty">검색 결과가 없습니다.</div>
|
||||
)}
|
||||
|
||||
{!isLoading && list.length > 0 && (
|
||||
<div className="detailWrap">
|
||||
{list.map((item) => (
|
||||
<ul key={item.satelliteManageId} className="detailBox stretch">
|
||||
<li className="dbHeader">
|
||||
<div className="headerL item2">
|
||||
<span className="name">{item.satelliteVideoName}</span>
|
||||
<span className="type">{item.photographDate}</span>
|
||||
</div>
|
||||
</li>
|
||||
<li>
|
||||
<ul className="dbList">
|
||||
<li>
|
||||
<span className="label">위성명</span>
|
||||
<span className="value">{item.satelliteName}</span>
|
||||
</li>
|
||||
<li>
|
||||
<span className="label">위성영상파일</span>
|
||||
<span className="value">{item.tifFileName}</span>
|
||||
</li>
|
||||
<li>
|
||||
<span className="label">영상 종류</span>
|
||||
<span className="value">{item.satelliteVideoKind}</span>
|
||||
</li>
|
||||
<li>
|
||||
<span className="label">영상 출처</span>
|
||||
<span className="value">{item.satelliteVideoOrigin}</span>
|
||||
</li>
|
||||
</ul>
|
||||
<div className="btnArea">
|
||||
<button
|
||||
type="button"
|
||||
className="btnEdit"
|
||||
onClick={() => setDetailPopupId(item.satelliteId)}
|
||||
></button>
|
||||
<button type="button" className="btnDel"></button>
|
||||
<button
|
||||
type="button"
|
||||
className={`btnMap ${activeImageId === item.satelliteManageId ? 'is-active' : ''}`}
|
||||
onClick={() => handleShowOnMap(item)}
|
||||
></button>
|
||||
</div>
|
||||
</li>
|
||||
</ul>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{!isLoading && totalPage > 1 && (
|
||||
<div className="pagination">
|
||||
<button type="button" className={page <= 1 ? 'disabled' : ''} disabled={page <= 1} onClick={() => handlePageChange(page - 1)}><</button>
|
||||
{page > 3 && <button type="button" onClick={() => handlePageChange(1)}>1</button>}
|
||||
{page > 4 && <span className="ellipsis">...</span>}
|
||||
{Array.from({ length: 5 }, (_, i) => page - 2 + i)
|
||||
.filter((p) => p >= 1 && p <= totalPage)
|
||||
.map((p) => (
|
||||
<button key={p} type="button" className={p === page ? 'on' : ''} onClick={() => handlePageChange(p)}>{p}</button>
|
||||
))}
|
||||
{page < totalPage - 3 && <span className="ellipsis">...</span>}
|
||||
{page < totalPage - 2 && <button type="button" onClick={() => handlePageChange(totalPage)}>{totalPage}</button>}
|
||||
<button type="button" className={page >= totalPage ? 'disabled' : ''} disabled={page >= totalPage} onClick={() => handlePageChange(page + 1)}>></button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
{/* 하단버튼 영역 */}
|
||||
<div className="btnBox rowSB">
|
||||
{/*<button type="button" className="btn btnLine">위성영상 폴더 업로드</button>*/}
|
||||
<button type="button" className="btn btnLine" onClick={() => setIsRegisterOpen(true)}>위성영상 등록</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{detailPopupId && (
|
||||
<SatelliteRegisterPopup
|
||||
satelliteId={detailPopupId}
|
||||
onClose={() => setDetailPopupId(null)}
|
||||
onSaved={() => search(page)}
|
||||
/>
|
||||
)}
|
||||
|
||||
{isRegisterOpen && (
|
||||
<SatelliteRegisterPopup
|
||||
onClose={() => setIsRegisterOpen(false)}
|
||||
onSaved={() => search(page)}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
204
src/satellite/components/SatelliteManage.jsx
Normal file
204
src/satellite/components/SatelliteManage.jsx
Normal file
@ -0,0 +1,204 @@
|
||||
import { useState, useEffect, useCallback } from 'react';
|
||||
import { fetchCommonCodeList } from '@/api/commonApi';
|
||||
import { fetchSatelliteCompanyList, searchSatelliteManage } from '@/api/satelliteApi';
|
||||
import SatelliteManageRegisterPopup from './SatelliteManageRegisterPopup';
|
||||
|
||||
export default function SatelliteManage() {
|
||||
// 드롭다운 옵션
|
||||
const [companyOptions, setCompanyOptions] = useState([]);
|
||||
const [sensorTypeOptions, setSensorTypeOptions] = useState([]);
|
||||
|
||||
// 검색 폼
|
||||
const [companyNo, setCompanyNo] = useState('');
|
||||
const [satelliteName, setSatelliteName] = useState('');
|
||||
const [sensorType, setSensorType] = useState('');
|
||||
|
||||
// 결과
|
||||
const [list, setList] = useState([]);
|
||||
const [page, setPage] = useState(1);
|
||||
const [totalPage, setTotalPage] = useState(0);
|
||||
const [isLoading, setIsLoading] = useState(false);
|
||||
const [error, setError] = useState(null);
|
||||
|
||||
// 등록 팝업 state
|
||||
const [isRegisterOpen, setIsRegisterOpen] = useState(false);
|
||||
|
||||
// 상세/수정 팝업 state
|
||||
const [detailManageId, setDetailManageId] = useState(null);
|
||||
|
||||
// 마운트 시 사업자명 목록 + 센서타입 공통코드 병렬 로드
|
||||
useEffect(() => {
|
||||
fetchSatelliteCompanyList()
|
||||
.then(setCompanyOptions)
|
||||
.catch(() => setCompanyOptions([]));
|
||||
|
||||
fetchCommonCodeList('000092')
|
||||
.then(setSensorTypeOptions)
|
||||
.catch(() => setSensorTypeOptions([]));
|
||||
}, []);
|
||||
|
||||
const search = useCallback(async (targetPage) => {
|
||||
setIsLoading(true);
|
||||
setError(null);
|
||||
|
||||
try {
|
||||
const limit = 10;
|
||||
const page = targetPage != null ? targetPage : 1;
|
||||
const result = await searchSatelliteManage({
|
||||
companyNo: companyNo ? Number(companyNo) : undefined,
|
||||
satelliteName,
|
||||
sensorType,
|
||||
page,
|
||||
limit,
|
||||
});
|
||||
setList(result.list);
|
||||
setTotalPage(result.totalPage);
|
||||
setPage(targetPage);
|
||||
} catch {
|
||||
setError('위성 관리 조회 중 오류가 발생했습니다.');
|
||||
setList([]);
|
||||
setTotalPage(0);
|
||||
} finally {
|
||||
setIsLoading(false);
|
||||
}
|
||||
}, [companyNo, satelliteName, sensorType]);
|
||||
|
||||
const handleSearch = () => {
|
||||
search(1);
|
||||
};
|
||||
|
||||
const handlePageChange = (newPage) => {
|
||||
search(newPage);
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="tabWrap is-active">
|
||||
<div className="tabTop">
|
||||
<div className="title">위성 관리</div>
|
||||
<div className="formGroup">
|
||||
<ul>
|
||||
<li>
|
||||
<label>
|
||||
<span>사업자명</span>
|
||||
<select
|
||||
value={companyNo}
|
||||
onChange={(e) => setCompanyNo(e.target.value)}
|
||||
>
|
||||
<option value="">전체</option>
|
||||
{companyOptions.map((opt) => (
|
||||
<option key={opt.companyNo} value={opt.companyNo}>
|
||||
{opt.companyName}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</label>
|
||||
<label>
|
||||
<span>센서 타입</span>
|
||||
<select
|
||||
value={sensorType}
|
||||
onChange={(e) => setSensorType(e.target.value)}
|
||||
>
|
||||
<option value="">전체</option>
|
||||
{sensorTypeOptions.map((opt) => (
|
||||
<option key={opt.commonCodeTypeNumber} value={opt.commonCodeTypeNumber}>
|
||||
{opt.commonCodeTypeName}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</label>
|
||||
</li>
|
||||
<li>
|
||||
<label>
|
||||
<span>위성명</span>
|
||||
<input
|
||||
type="text"
|
||||
placeholder="위성명"
|
||||
value={satelliteName}
|
||||
onChange={(e) => setSatelliteName(e.target.value)}
|
||||
/>
|
||||
</label>
|
||||
</li>
|
||||
<li className="fgBtn">
|
||||
<button type="button" className="schBtn" onClick={handleSearch}>검색</button>
|
||||
</li>
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="tabBtm noSc">
|
||||
<div className="tabBtmInner">
|
||||
{/* 스크롤영역 */}
|
||||
<div className="tabBtmCnt">
|
||||
{isLoading && <div className="loading">조회 중...</div>}
|
||||
|
||||
{error && <div className="error">{error}</div>}
|
||||
|
||||
{!isLoading && !error && list.length === 0 && (
|
||||
<div className="empty">검색 결과가 없습니다.</div>
|
||||
)}
|
||||
|
||||
{!isLoading && list.length > 0 && (
|
||||
<div className="detailWrap">
|
||||
{list.map((item) => (
|
||||
<ul key={item.satelliteManageId} className="detailBox" style={{ cursor: 'pointer' }} onClick={() => setDetailManageId(item.satelliteManageId)}>
|
||||
<li>
|
||||
<span className="label">사업자명</span>
|
||||
<span className="value">{item.companyName}</span>
|
||||
</li>
|
||||
<li>
|
||||
<span className="label">위성명</span>
|
||||
<span className="value">{item.satelliteName}</span>
|
||||
</li>
|
||||
<li>
|
||||
<span className="label">센서 타입</span>
|
||||
<span className="value">{item.sensorType}</span>
|
||||
</li>
|
||||
<li>
|
||||
<span className="label">촬영 해상도</span>
|
||||
<span className="value">{item.photoResolution}</span>
|
||||
</li>
|
||||
</ul>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{!isLoading && totalPage > 1 && (
|
||||
<div className="pagination">
|
||||
<button type="button" className={page <= 1 ? 'disabled' : ''} disabled={page <= 1} onClick={() => handlePageChange(page - 1)}><</button>
|
||||
{page > 3 && <button type="button" onClick={() => handlePageChange(1)}>1</button>}
|
||||
{page > 4 && <span className="ellipsis">...</span>}
|
||||
{Array.from({ length: 5 }, (_, i) => page - 2 + i)
|
||||
.filter((p) => p >= 1 && p <= totalPage)
|
||||
.map((p) => (
|
||||
<button key={p} type="button" className={p === page ? 'on' : ''} onClick={() => handlePageChange(p)}>{p}</button>
|
||||
))}
|
||||
{page < totalPage - 3 && <span className="ellipsis">...</span>}
|
||||
{page < totalPage - 2 && <button type="button" onClick={() => handlePageChange(totalPage)}>{totalPage}</button>}
|
||||
<button type="button" className={page >= totalPage ? 'disabled' : ''} disabled={page >= totalPage} onClick={() => handlePageChange(page + 1)}>></button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
{/* 하단버튼 영역 */}
|
||||
<div className="btnBox">
|
||||
<button type="button" className="btn btnLine" onClick={() => setIsRegisterOpen(true)}>등록</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{isRegisterOpen && (
|
||||
<SatelliteManageRegisterPopup
|
||||
onClose={() => setIsRegisterOpen(false)}
|
||||
onSaved={() => search(page)}
|
||||
/>
|
||||
)}
|
||||
|
||||
{detailManageId && (
|
||||
<SatelliteManageRegisterPopup
|
||||
satelliteManageId={detailManageId}
|
||||
onClose={() => setDetailManageId(null)}
|
||||
onSaved={() => search(page)}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
254
src/satellite/components/SatelliteManageRegisterPopup.jsx
Normal file
254
src/satellite/components/SatelliteManageRegisterPopup.jsx
Normal file
@ -0,0 +1,254 @@
|
||||
import { useState, useEffect } from 'react';
|
||||
import {createPortal} from "react-dom";
|
||||
import { showToast } from '@/components/common/Toast';
|
||||
import { fetchCommonCodeList } from '@/api/commonApi';
|
||||
import {
|
||||
fetchSatelliteCompanyList,
|
||||
saveSatelliteManage,
|
||||
fetchSatelliteManageDetail,
|
||||
updateSatelliteManage,
|
||||
} from '@/api/satelliteApi';
|
||||
import useDraggable from '../hooks/useDraggable';
|
||||
|
||||
/**
|
||||
* 위성 관리 등록/수정 팝업
|
||||
* @param {{ satelliteManageId?: number, onClose: () => void, onSaved: () => void }} props
|
||||
* satelliteManageId가 있으면 수정 모드, 없으면 등록 모드
|
||||
*/
|
||||
export default function SatelliteManageRegisterPopup({ satelliteManageId, onClose, onSaved }) {
|
||||
const isEditMode = !!satelliteManageId;
|
||||
|
||||
const [sensorTypeOptions, setSensorTypeOptions] = useState([]);
|
||||
const [companyOptions, setCompanyOptions] = useState([]);
|
||||
|
||||
const [companyNo, setCompanyNo] = useState('');
|
||||
const [satelliteName, setSatelliteName] = useState('');
|
||||
const [sensorType, setSensorType] = useState('');
|
||||
const [photoResolution, setPhotoResolution] = useState('');
|
||||
const [frequency, setFrequency] = useState('');
|
||||
const [photoDetail, setPhotoDetail] = useState('');
|
||||
|
||||
const [isLoading, setIsLoading] = useState(false);
|
||||
const [isSaving, setIsSaving] = useState(false);
|
||||
const [error, setError] = useState(null);
|
||||
const { position, handleMouseDown } = useDraggable();
|
||||
|
||||
// 마운트 시 센서타입 공통코드 로드 + 수정 모드면 상세조회
|
||||
useEffect(() => {
|
||||
let cancelled = false;
|
||||
|
||||
async function load() {
|
||||
setIsLoading(true);
|
||||
setError(null);
|
||||
|
||||
try {
|
||||
const [codeList, companyList] = await Promise.all([
|
||||
fetchCommonCodeList('000092'),
|
||||
fetchSatelliteCompanyList(),
|
||||
]);
|
||||
if (cancelled) return;
|
||||
setSensorTypeOptions(codeList);
|
||||
setCompanyOptions(companyList);
|
||||
|
||||
if (satelliteManageId) {
|
||||
const data = await fetchSatelliteManageDetail(satelliteManageId);
|
||||
if (cancelled || !data) return;
|
||||
|
||||
setCompanyNo(data.companyNo ?? '');
|
||||
setSatelliteName(data.satelliteName || '');
|
||||
setSensorType(data.sensorType || '');
|
||||
setPhotoResolution(data.photoResolution || '');
|
||||
setFrequency(data.frequency || '');
|
||||
setPhotoDetail(data.photoDetail || '');
|
||||
}
|
||||
} catch {
|
||||
setError('데이터 조회 중 오류가 발생했습니다.');
|
||||
} finally {
|
||||
if (!cancelled) setIsLoading(false);
|
||||
}
|
||||
}
|
||||
|
||||
load();
|
||||
return () => { cancelled = true; };
|
||||
}, [satelliteManageId]);
|
||||
|
||||
const handleSave = async () => {
|
||||
if (!companyNo) {
|
||||
showToast('사업자명을 선택해주세요.');
|
||||
return;
|
||||
}
|
||||
if (!satelliteName.trim()) {
|
||||
showToast('위성명을 입력해주세요.');
|
||||
return;
|
||||
}
|
||||
|
||||
setIsSaving(true);
|
||||
setError(null);
|
||||
|
||||
try {
|
||||
if (isEditMode) {
|
||||
await updateSatelliteManage({
|
||||
satelliteManageId,
|
||||
companyNo: Number(companyNo),
|
||||
satelliteName,
|
||||
sensorType,
|
||||
photoResolution,
|
||||
frequency,
|
||||
photoDetail,
|
||||
});
|
||||
} else {
|
||||
await saveSatelliteManage({
|
||||
companyNo: Number(companyNo),
|
||||
satelliteName,
|
||||
sensorType,
|
||||
photoResolution,
|
||||
frequency,
|
||||
photoDetail,
|
||||
});
|
||||
}
|
||||
onSaved?.();
|
||||
onClose();
|
||||
} catch {
|
||||
setError('저장 중 오류가 발생했습니다.');
|
||||
} finally {
|
||||
setIsSaving(false);
|
||||
}
|
||||
};
|
||||
|
||||
return createPortal(
|
||||
<div className="popupUtillWrap" style={{ transform: `translate(calc(-50% + ${position.x}px), calc(-50% + ${position.y}px))` }}>
|
||||
<div className="popupUtill">
|
||||
<div className="puHeader" onMouseDown={handleMouseDown} style={{ cursor: 'grab' }}>
|
||||
<span className="title">
|
||||
{isEditMode ? '위성 관리 상세' : '위성 관리 등록'}
|
||||
</span>
|
||||
<button
|
||||
type="button"
|
||||
className="puClose"
|
||||
aria-label="닫기"
|
||||
onClick={onClose}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="puBody">
|
||||
{isLoading && <div className="loading">조회 중...</div>}
|
||||
{error && <div className="error">{error}</div>}
|
||||
|
||||
{!isLoading && (
|
||||
<table className="table">
|
||||
<caption>위성 관리 등록 - 사업자명, 위성명, 센서 타입, 촬영 해상도, 주파수, 상세내역에 대한 내용을 등록하는 표입니다.</caption>
|
||||
<colgroup>
|
||||
<col style={{ width: '30%' }} />
|
||||
<col style={{ width: '' }} />
|
||||
</colgroup>
|
||||
<tbody>
|
||||
<tr>
|
||||
<th scope="row">사업자명 <span className="required">*</span></th>
|
||||
<td>
|
||||
<select
|
||||
aria-label="사업자명"
|
||||
value={companyNo}
|
||||
onChange={(e) => setCompanyNo(e.target.value)}
|
||||
>
|
||||
<option value="">선택</option>
|
||||
{companyOptions.map((opt) => (
|
||||
<option key={opt.companyNo} value={opt.companyNo}>
|
||||
{opt.companyName}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<th scope="row">위성명 <span className="required">*</span></th>
|
||||
<td>
|
||||
<input
|
||||
type="text"
|
||||
placeholder="위성명"
|
||||
aria-label="위성명"
|
||||
value={satelliteName}
|
||||
onChange={(e) => setSatelliteName(e.target.value)}
|
||||
/>
|
||||
</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<th scope="row">센서 타입</th>
|
||||
<td>
|
||||
<select
|
||||
aria-label="센서 타입"
|
||||
value={sensorType}
|
||||
onChange={(e) => setSensorType(e.target.value)}
|
||||
>
|
||||
<option value="">선택</option>
|
||||
{sensorTypeOptions.map((opt) => (
|
||||
<option key={opt.commonCodeTypeNumber} value={opt.commonCodeTypeNumber}>
|
||||
{opt.commonCodeTypeName}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<th scope="row">촬영 해상도</th>
|
||||
<td>
|
||||
<input
|
||||
type="text"
|
||||
placeholder="촬영 해상도"
|
||||
aria-label="촬영 해상도"
|
||||
value={photoResolution}
|
||||
onChange={(e) => setPhotoResolution(e.target.value)}
|
||||
/>
|
||||
</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<th scope="row">주파수</th>
|
||||
<td>
|
||||
<input
|
||||
type="text"
|
||||
placeholder="주파수"
|
||||
aria-label="주파수"
|
||||
value={frequency}
|
||||
onChange={(e) => setFrequency(e.target.value)}
|
||||
/>
|
||||
</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<th scope="row">상세내역</th>
|
||||
<td>
|
||||
<textarea
|
||||
placeholder="내용을 입력하세요"
|
||||
aria-label="상세내역"
|
||||
value={photoDetail}
|
||||
onChange={(e) => setPhotoDetail(e.target.value)}
|
||||
/>
|
||||
</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="puFooter">
|
||||
<div className="popBtnWrap">
|
||||
<button
|
||||
type="button"
|
||||
className="btn basic"
|
||||
onClick={handleSave}
|
||||
disabled={isSaving}
|
||||
>
|
||||
{isSaving ? '저장 중...' : isEditMode ? '수정' : '저장'}
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
className="btn dark"
|
||||
onClick={onClose}
|
||||
>
|
||||
취소
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>,
|
||||
document.body
|
||||
);
|
||||
}
|
||||
182
src/satellite/components/SatelliteProviderManage.jsx
Normal file
182
src/satellite/components/SatelliteProviderManage.jsx
Normal file
@ -0,0 +1,182 @@
|
||||
import { useState, useEffect, useCallback } from 'react';
|
||||
import { fetchCommonCodeList } from '@/api/commonApi';
|
||||
import { searchSatelliteCompany } from '@/api/satelliteApi';
|
||||
import SatelliteProviderRegisterPopup from './SatelliteProviderRegisterPopup';
|
||||
|
||||
export default function SatelliteProviderManage() {
|
||||
// 사업자 분류 코드 옵션
|
||||
const [companyTypeOptions, setCompanyTypeOptions] = useState([]);
|
||||
|
||||
// 검색 폼
|
||||
const [companyTypeCode, setCompanyTypeCode] = useState('');
|
||||
const [companyName, setCompanyName] = useState('');
|
||||
|
||||
// 결과
|
||||
const [list, setList] = useState([]);
|
||||
const [page, setPage] = useState(1);
|
||||
const [totalPage, setTotalPage] = useState(0);
|
||||
const [isLoading, setIsLoading] = useState(false);
|
||||
const [error, setError] = useState(null);
|
||||
|
||||
// 등록 팝업 state
|
||||
const [isRegisterOpen, setIsRegisterOpen] = useState(false);
|
||||
|
||||
// 상세/수정 팝업 state
|
||||
const [detailCompanyNo, setDetailCompanyNo] = useState(null);
|
||||
|
||||
// 마운트 시 사업자 분류 공통코드 로드
|
||||
useEffect(() => {
|
||||
fetchCommonCodeList('000039')
|
||||
.then(setCompanyTypeOptions)
|
||||
.catch(() => setCompanyTypeOptions([]));
|
||||
}, []);
|
||||
|
||||
const search = useCallback(async (targetPage) => {
|
||||
setIsLoading(true);
|
||||
setError(null);
|
||||
|
||||
try {
|
||||
const limit = 10;
|
||||
const page = targetPage != null ? targetPage : 1;
|
||||
const result = await searchSatelliteCompany({
|
||||
companyTypeCode,
|
||||
companyName,
|
||||
page,
|
||||
limit,
|
||||
});
|
||||
setList(result.list);
|
||||
setTotalPage(result.totalPage);
|
||||
setPage(targetPage);
|
||||
} catch {
|
||||
setError('위성 사업자 조회 중 오류가 발생했습니다.');
|
||||
setList([]);
|
||||
setTotalPage(0);
|
||||
} finally {
|
||||
setIsLoading(false);
|
||||
}
|
||||
}, [companyTypeCode, companyName]);
|
||||
|
||||
const handleSearch = () => {
|
||||
search(1);
|
||||
};
|
||||
|
||||
const handlePageChange = (newPage) => {
|
||||
search(newPage);
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="tabWrap is-active">
|
||||
<div className="tabTop">
|
||||
<div className="title">위성사업자 관리</div>
|
||||
<div className="formGroup">
|
||||
<ul>
|
||||
<li>
|
||||
<label>
|
||||
<span>사업자 분류</span>
|
||||
<select
|
||||
value={companyTypeCode}
|
||||
onChange={(e) => setCompanyTypeCode(e.target.value)}
|
||||
>
|
||||
<option value="">전체</option>
|
||||
{companyTypeOptions.map((opt) => (
|
||||
<option key={opt.commonCodeTypeNumber} value={opt.commonCodeTypeNumber}>
|
||||
{opt.commonCodeTypeName}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</label>
|
||||
<label>
|
||||
<span>사업자명</span>
|
||||
<input
|
||||
type="text"
|
||||
placeholder="사업자명"
|
||||
value={companyName}
|
||||
onChange={(e) => setCompanyName(e.target.value)}
|
||||
/>
|
||||
</label>
|
||||
</li>
|
||||
<li className="fgBtn">
|
||||
<button type="button" className="schBtn" onClick={handleSearch}>검색</button>
|
||||
</li>
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="tabBtm noSc">
|
||||
<div className="tabBtmInner">
|
||||
{/* 스크롤영역 */}
|
||||
<div className="tabBtmCnt">
|
||||
{isLoading && <div className="loading">조회 중...</div>}
|
||||
|
||||
{error && <div className="error">{error}</div>}
|
||||
|
||||
{!isLoading && !error && list.length === 0 && (
|
||||
<div className="empty">검색 결과가 없습니다.</div>
|
||||
)}
|
||||
|
||||
{!isLoading && list.length > 0 && (
|
||||
<div className="detailWrap">
|
||||
{list.map((item) => (
|
||||
<ul key={item.companyNo} className="detailBox" style={{ cursor: 'pointer' }} onClick={() => setDetailCompanyNo(item.companyNo)}>
|
||||
<li className="dbHeader">
|
||||
<div className="headerL item1">
|
||||
<span className="name">{item.companyName}</span>
|
||||
</div>
|
||||
</li>
|
||||
<li>
|
||||
<span className="label">사업자 분류</span>
|
||||
<span className="value">{item.companyTypeName}</span>
|
||||
</li>
|
||||
<li>
|
||||
<span className="label">국가</span>
|
||||
<span className="value">{item.nationalCodeName}</span>
|
||||
</li>
|
||||
<li>
|
||||
<span className="label">소재지</span>
|
||||
<span className="value">{item.location}</span>
|
||||
</li>
|
||||
</ul>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{!isLoading && totalPage > 1 && (
|
||||
<div className="pagination">
|
||||
<button type="button" className={page <= 1 ? 'disabled' : ''} disabled={page <= 1} onClick={() => handlePageChange(page - 1)}><</button>
|
||||
{page > 3 && <button type="button" onClick={() => handlePageChange(1)}>1</button>}
|
||||
{page > 4 && <span className="ellipsis">...</span>}
|
||||
{Array.from({ length: 5 }, (_, i) => page - 2 + i)
|
||||
.filter((p) => p >= 1 && p <= totalPage)
|
||||
.map((p) => (
|
||||
<button key={p} type="button" className={p === page ? 'on' : ''} onClick={() => handlePageChange(p)}>{p}</button>
|
||||
))}
|
||||
{page < totalPage - 3 && <span className="ellipsis">...</span>}
|
||||
{page < totalPage - 2 && <button type="button" onClick={() => handlePageChange(totalPage)}>{totalPage}</button>}
|
||||
<button type="button" className={page >= totalPage ? 'disabled' : ''} disabled={page >= totalPage} onClick={() => handlePageChange(page + 1)}>></button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
{/* 하단버튼 영역 */}
|
||||
<div className="btnBox">
|
||||
<button type="button" className="btn btnLine" onClick={() => setIsRegisterOpen(true)}>등록</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{isRegisterOpen && (
|
||||
<SatelliteProviderRegisterPopup
|
||||
onClose={() => setIsRegisterOpen(false)}
|
||||
onSaved={() => search(page)}
|
||||
/>
|
||||
)}
|
||||
|
||||
{detailCompanyNo && (
|
||||
<SatelliteProviderRegisterPopup
|
||||
companyNo={detailCompanyNo}
|
||||
onClose={() => setDetailCompanyNo(null)}
|
||||
onSaved={() => search(page)}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
257
src/satellite/components/SatelliteProviderRegisterPopup.jsx
Normal file
257
src/satellite/components/SatelliteProviderRegisterPopup.jsx
Normal file
@ -0,0 +1,257 @@
|
||||
import { useState, useEffect } from 'react';
|
||||
import {createPortal} from "react-dom";
|
||||
import { showToast } from '@/components/common/Toast';
|
||||
import { fetchCommonCodeList } from '@/api/commonApi';
|
||||
import {
|
||||
saveSatelliteCompany,
|
||||
fetchSatelliteCompanyDetail,
|
||||
updateSatelliteCompany,
|
||||
} from '@/api/satelliteApi';
|
||||
import useDraggable from '../hooks/useDraggable';
|
||||
|
||||
const NATIONAL_OPTIONS = [
|
||||
{ code: '440', name: '대한민국' },
|
||||
{ code: '338', name: '미국' },
|
||||
{ code: '413', name: '중국' },
|
||||
{ code: '431', name: '일본' },
|
||||
];
|
||||
|
||||
/**
|
||||
* 위성 사업자 등록/수정 팝업
|
||||
* @param {{ companyNo?: number, onClose: () => void, onSaved: () => void }} props
|
||||
* companyNo가 있으면 수정 모드, 없으면 등록 모드
|
||||
*/
|
||||
export default function SatelliteProviderRegisterPopup({ companyNo, onClose, onSaved }) {
|
||||
const isEditMode = !!companyNo;
|
||||
|
||||
const [companyTypeOptions, setCompanyTypeOptions] = useState([]);
|
||||
|
||||
const [companyTypeCode, setCompanyTypeCode] = useState('');
|
||||
const [companyName, setCompanyName] = useState('');
|
||||
const [nationalCode, setNationalCode] = useState('');
|
||||
const [location, setLocation] = useState('');
|
||||
const [companyDetail, setCompanyDetail] = useState('');
|
||||
|
||||
const [isLoading, setIsLoading] = useState(false);
|
||||
const [isSaving, setIsSaving] = useState(false);
|
||||
const [error, setError] = useState(null);
|
||||
const { position, handleMouseDown } = useDraggable();
|
||||
|
||||
// 마운트 시 사업자 분류 공통코드 로드 + 수정 모드면 상세조회
|
||||
useEffect(() => {
|
||||
let cancelled = false;
|
||||
|
||||
async function load() {
|
||||
setIsLoading(true);
|
||||
setError(null);
|
||||
|
||||
try {
|
||||
// 공통코드 로드
|
||||
const codeList = await fetchCommonCodeList('000039');
|
||||
if (cancelled) return;
|
||||
setCompanyTypeOptions(codeList);
|
||||
|
||||
// 수정 모드: 상세조회 후 역매핑
|
||||
if (companyNo) {
|
||||
const data = await fetchSatelliteCompanyDetail(companyNo);
|
||||
if (cancelled || !data) return;
|
||||
|
||||
// companyTypeName → companyTypeCode 역매핑
|
||||
const matchedType = codeList.find(
|
||||
(opt) => opt.commonCodeTypeName === data.companyTypeName
|
||||
);
|
||||
setCompanyTypeCode(matchedType?.commonCodeTypeNumber || '');
|
||||
|
||||
// nationalCodeName → nationalCode 역매핑
|
||||
const matchedNation = NATIONAL_OPTIONS.find(
|
||||
(opt) => opt.name === data.nationalCodeName
|
||||
);
|
||||
setNationalCode(matchedNation?.code || '');
|
||||
|
||||
setCompanyName(data.companyName || '');
|
||||
setLocation(data.location || '');
|
||||
setCompanyDetail(data.companyDetail || '');
|
||||
}
|
||||
} catch {
|
||||
setError('데이터 조회 중 오류가 발생했습니다.');
|
||||
} finally {
|
||||
if (!cancelled) setIsLoading(false);
|
||||
}
|
||||
}
|
||||
|
||||
load();
|
||||
return () => { cancelled = true; };
|
||||
}, [companyNo]);
|
||||
|
||||
const handleSave = async () => {
|
||||
if (!companyTypeCode) {
|
||||
showToast('사업자 분류를 선택해주세요.');
|
||||
return;
|
||||
}
|
||||
if (!companyName.trim()) {
|
||||
showToast('사업자명을 입력해주세요.');
|
||||
return;
|
||||
}
|
||||
if (!nationalCode) {
|
||||
showToast('국가를 선택해주세요.');
|
||||
return;
|
||||
}
|
||||
|
||||
setIsSaving(true);
|
||||
setError(null);
|
||||
|
||||
try {
|
||||
if (isEditMode) {
|
||||
await updateSatelliteCompany({
|
||||
companyNo,
|
||||
companyTypeCode,
|
||||
companyName,
|
||||
nationalCode,
|
||||
location,
|
||||
companyDetail,
|
||||
});
|
||||
} else {
|
||||
await saveSatelliteCompany({
|
||||
companyTypeCode,
|
||||
companyName,
|
||||
nationalCode,
|
||||
location,
|
||||
companyDetail,
|
||||
});
|
||||
}
|
||||
onSaved?.();
|
||||
onClose();
|
||||
} catch {
|
||||
setError('저장 중 오류가 발생했습니다.');
|
||||
} finally {
|
||||
setIsSaving(false);
|
||||
}
|
||||
};
|
||||
|
||||
return createPortal(
|
||||
<div className="popupUtillWrap" style={{ transform: `translate(calc(-50% + ${position.x}px), calc(-50% + ${position.y}px))` }}>
|
||||
<div className="popupUtill">
|
||||
<div className="puHeader" onMouseDown={handleMouseDown} style={{ cursor: 'grab' }}>
|
||||
<span className="title">
|
||||
{isEditMode ? '위성 사업자 상세' : '위성 사업자 등록'}
|
||||
</span>
|
||||
<button
|
||||
type="button"
|
||||
className="puClose"
|
||||
aria-label="닫기"
|
||||
onClick={onClose}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="puBody">
|
||||
{isLoading && <div className="loading">조회 중...</div>}
|
||||
{error && <div className="error">{error}</div>}
|
||||
|
||||
{!isLoading && (
|
||||
<table className="table">
|
||||
<caption>
|
||||
위성 사업자 {isEditMode ? '상세' : '등록'} - 사업자 분류, 사업자명, 국가, 소재지, 상세내역에 대한 내용을 {isEditMode ? '조회/수정' : '등록'}하는 표입니다.
|
||||
</caption>
|
||||
<colgroup>
|
||||
<col style={{ width: '30%' }} />
|
||||
<col style={{ width: '' }} />
|
||||
</colgroup>
|
||||
<tbody>
|
||||
<tr>
|
||||
<th scope="row">사업자 분류 <span className="required">*</span></th>
|
||||
<td>
|
||||
<select
|
||||
aria-label="사업자 분류"
|
||||
value={companyTypeCode}
|
||||
onChange={(e) => setCompanyTypeCode(e.target.value)}
|
||||
>
|
||||
<option value="">선택</option>
|
||||
{companyTypeOptions.map((opt) => (
|
||||
<option key={opt.commonCodeTypeNumber} value={opt.commonCodeTypeNumber}>
|
||||
{opt.commonCodeTypeName}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<th scope="row">사업자명 <span className="required">*</span></th>
|
||||
<td>
|
||||
<input
|
||||
type="text"
|
||||
placeholder="사업자명"
|
||||
aria-label="사업자명"
|
||||
value={companyName}
|
||||
onChange={(e) => setCompanyName(e.target.value)}
|
||||
/>
|
||||
</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<th scope="row">국가 <span className="required">*</span></th>
|
||||
<td>
|
||||
<select
|
||||
aria-label="국가"
|
||||
value={nationalCode}
|
||||
onChange={(e) => setNationalCode(e.target.value)}
|
||||
>
|
||||
<option value="">선택</option>
|
||||
{NATIONAL_OPTIONS.map((opt) => (
|
||||
<option key={opt.code} value={opt.code}>
|
||||
{opt.name}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<th scope="row">소재지</th>
|
||||
<td>
|
||||
<input
|
||||
type="text"
|
||||
placeholder="소재지"
|
||||
aria-label="소재지"
|
||||
value={location}
|
||||
onChange={(e) => setLocation(e.target.value)}
|
||||
/>
|
||||
</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<th scope="row">상세내역</th>
|
||||
<td>
|
||||
<textarea
|
||||
placeholder="내용을 입력하세요"
|
||||
aria-label="상세내역"
|
||||
value={companyDetail}
|
||||
onChange={(e) => setCompanyDetail(e.target.value)}
|
||||
/>
|
||||
</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="puFooter">
|
||||
<div className="popBtnWrap">
|
||||
<button
|
||||
type="button"
|
||||
className="btn basic"
|
||||
onClick={handleSave}
|
||||
disabled={isSaving}
|
||||
>
|
||||
{isSaving ? '저장 중...' : isEditMode ? '수정' : '저장'}
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
className="btn dark"
|
||||
onClick={onClose}
|
||||
>
|
||||
취소
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>,
|
||||
document.body
|
||||
);
|
||||
}
|
||||
503
src/satellite/components/SatelliteRegisterPopup.jsx
Normal file
503
src/satellite/components/SatelliteRegisterPopup.jsx
Normal file
@ -0,0 +1,503 @@
|
||||
import { useState, useEffect } from 'react';
|
||||
import {createPortal} from "react-dom";
|
||||
import { showToast } from '@/components/common/Toast';
|
||||
import {
|
||||
fetchSatelliteCompanyList,
|
||||
fetchSatelliteManageList,
|
||||
saveSatelliteVideo,
|
||||
fetchSatelliteVideoDetail,
|
||||
updateSatelliteVideo,
|
||||
} from '@/api/satelliteApi';
|
||||
import useDraggable from '../hooks/useDraggable';
|
||||
|
||||
const truncateMiddle = (str, maxLen) => {
|
||||
if (!str) return '';
|
||||
if (str.length <= maxLen) return str;
|
||||
const keep = Math.floor((maxLen - 3) / 2);
|
||||
return str.slice(0, keep) + '...' + str.slice(str.length - keep);
|
||||
};
|
||||
|
||||
/**
|
||||
* 위성영상 등록/상세 팝업
|
||||
* @param {{ satelliteId?: number, onClose: () => void, onSaved: () => void }} props
|
||||
* satelliteId가 있으면 수정 모드, 없으면 등록 모드
|
||||
*/
|
||||
export default function SatelliteRegisterPopup({ satelliteId, onClose, onSaved }) {
|
||||
const isEditMode = !!satelliteId;
|
||||
|
||||
// 사업자/위성 드롭다운 (등록 모드 전용)
|
||||
const [companyList, setCompanyList] = useState([]);
|
||||
const [satelliteList, setSatelliteList] = useState([]);
|
||||
|
||||
// 폼 state
|
||||
const [companyNo, setCompanyNo] = useState('');
|
||||
const [satelliteManageId, setSatelliteManageId] = useState('');
|
||||
const [photographDate, setPhotographDate] = useState('');
|
||||
const [satelliteVideoName, setSatelliteVideoName] = useState('');
|
||||
const [satelliteVideoTransmissionCycle, setSatelliteVideoTransmissionCycle] = useState('');
|
||||
const [satelliteVideoKind, setSatelliteVideoKind] = useState('');
|
||||
const [satelliteVideoOrbit, setSatelliteVideoOrbit] = useState('');
|
||||
const [satelliteVideoOrigin, setSatelliteVideoOrigin] = useState('');
|
||||
const [photographPurpose, setPhotographPurpose] = useState('');
|
||||
const [photographMode, setPhotographMode] = useState('');
|
||||
const [purchaseCode, setPurchaseCode] = useState('');
|
||||
const [purchasePrice, setPurchasePrice] = useState(0);
|
||||
|
||||
// 파일 state (등록 모드 전용)
|
||||
const [tifFile, setTifFile] = useState(null);
|
||||
const [csvFile, setCsvFile] = useState(null);
|
||||
const [cloudMaskFile, setCloudMaskFile] = useState(null);
|
||||
|
||||
// 수정 모드 읽기전용 표시
|
||||
const [satelliteName, setSatelliteName] = useState('');
|
||||
const [tifFileName, setTifFileName] = useState('');
|
||||
const [csvFileName, setCsvFileName] = useState('');
|
||||
|
||||
const [isLoading, setIsLoading] = useState(false);
|
||||
const [isSaving, setIsSaving] = useState(false);
|
||||
const [error, setError] = useState(null);
|
||||
const { position, handleMouseDown } = useDraggable();
|
||||
|
||||
// 마운트 시 모드별 데이터 로드
|
||||
useEffect(() => {
|
||||
let cancelled = false;
|
||||
|
||||
if (isEditMode) {
|
||||
setIsLoading(true);
|
||||
setError(null);
|
||||
|
||||
fetchSatelliteVideoDetail(satelliteId)
|
||||
.then((data) => {
|
||||
if (cancelled || !data) return;
|
||||
|
||||
let dateValue = '';
|
||||
if (data.photographDate) {
|
||||
dateValue = typeof data.photographDate === 'string'
|
||||
? data.photographDate.substring(0, 10)
|
||||
: '';
|
||||
}
|
||||
|
||||
setSatelliteManageId(data.satelliteManageId ?? '');
|
||||
setSatelliteName(data.satelliteName || '');
|
||||
setPhotographDate(dateValue);
|
||||
setTifFileName(data.tifFileName || '');
|
||||
setCsvFileName(data.csvFileName || '');
|
||||
setSatelliteVideoName(data.satelliteVideoName || '');
|
||||
setSatelliteVideoTransmissionCycle(data.satelliteVideoTransmissionCycle || '');
|
||||
setSatelliteVideoKind(data.satelliteVideoKind || '');
|
||||
setSatelliteVideoOrbit(data.satelliteVideoOrbit || '');
|
||||
setSatelliteVideoOrigin(data.satelliteVideoOrigin || '');
|
||||
setPhotographPurpose(data.photographPurpose || '');
|
||||
setPhotographMode(data.photographMode || '');
|
||||
setPurchaseCode(data.purchaseCode || '');
|
||||
setPurchasePrice(data.purchasePrice || 0);
|
||||
})
|
||||
.catch(() => {
|
||||
if (!cancelled) setError('상세정보 조회 중 오류가 발생했습니다.');
|
||||
})
|
||||
.finally(() => {
|
||||
if (!cancelled) setIsLoading(false);
|
||||
});
|
||||
} else {
|
||||
fetchSatelliteCompanyList()
|
||||
.then(setCompanyList)
|
||||
.catch(() => setCompanyList([]));
|
||||
}
|
||||
|
||||
return () => { cancelled = true; };
|
||||
}, [satelliteId]);
|
||||
|
||||
// 사업자 변경 시 위성명 목록 재로드 (등록 모드 전용)
|
||||
useEffect(() => {
|
||||
if (isEditMode) return;
|
||||
if (!companyNo) {
|
||||
setSatelliteList([]);
|
||||
setSatelliteManageId('');
|
||||
return;
|
||||
}
|
||||
fetchSatelliteManageList(Number(companyNo))
|
||||
.then(setSatelliteList)
|
||||
.catch(() => setSatelliteList([]));
|
||||
setSatelliteManageId('');
|
||||
}, [companyNo, isEditMode]);
|
||||
|
||||
const handleSave = async () => {
|
||||
if (!isEditMode && !satelliteManageId) {
|
||||
showToast('사업자명/위성명을 선택해주세요.');
|
||||
return;
|
||||
}
|
||||
if (!photographDate) {
|
||||
showToast('영상 촬영일을 입력해주세요.');
|
||||
return;
|
||||
}
|
||||
if (!isEditMode && !tifFile) {
|
||||
showToast('위성영상파일을 선택해주세요.');
|
||||
return;
|
||||
}
|
||||
if (!satelliteVideoName.trim()) {
|
||||
showToast('위성영상명을 입력해주세요.');
|
||||
return;
|
||||
}
|
||||
|
||||
setIsSaving(true);
|
||||
setError(null);
|
||||
|
||||
try {
|
||||
if (isEditMode) {
|
||||
await updateSatelliteVideo({
|
||||
satelliteId,
|
||||
satelliteManageId,
|
||||
photographDate,
|
||||
satelliteVideoName,
|
||||
satelliteVideoTransmissionCycle,
|
||||
satelliteVideoKind,
|
||||
satelliteVideoOrbit,
|
||||
satelliteVideoOrigin,
|
||||
photographPurpose,
|
||||
photographMode,
|
||||
purchaseCode,
|
||||
purchasePrice,
|
||||
});
|
||||
} else {
|
||||
const fd = new FormData();
|
||||
if (tifFile) fd.append('tifFile', tifFile);
|
||||
if (csvFile) fd.append('csvFile', csvFile);
|
||||
if (cloudMaskFile) fd.append('cloudMaskFile', cloudMaskFile);
|
||||
fd.append('satelliteManageId', satelliteManageId);
|
||||
fd.append('photographDate', photographDate);
|
||||
fd.append('satelliteVideoName', satelliteVideoName);
|
||||
fd.append('satelliteVideoTransmissionCycle', satelliteVideoTransmissionCycle);
|
||||
fd.append('satelliteVideoKind', satelliteVideoKind);
|
||||
fd.append('satelliteVideoOrbit', satelliteVideoOrbit);
|
||||
fd.append('satelliteVideoOrigin', satelliteVideoOrigin);
|
||||
fd.append('photographPurpose', photographPurpose);
|
||||
fd.append('photographMode', photographMode);
|
||||
fd.append('purchaseCode', purchaseCode);
|
||||
fd.append('purchasePrice', purchasePrice);
|
||||
|
||||
await saveSatelliteVideo(fd);
|
||||
}
|
||||
onSaved?.();
|
||||
onClose();
|
||||
} catch {
|
||||
setError('저장 중 오류가 발생했습니다.');
|
||||
} finally {
|
||||
setIsSaving(false);
|
||||
}
|
||||
};
|
||||
|
||||
// const handlePhotoTimeFormatChange = (value) => {
|
||||
// const changedFormat = value.replace('T', '') + ':00';
|
||||
// setPhotographDate(changedFormat);
|
||||
// }
|
||||
|
||||
return createPortal(
|
||||
<div className="popupUtillWrap" style={{ transform: `translate(calc(-50% + ${position.x}px), calc(-50% + ${position.y}px))` }}>
|
||||
<div className="popupUtill w61r">
|
||||
<div className="puHeader" onMouseDown={handleMouseDown} style={{ cursor: 'grab' }}>
|
||||
<span className="title">{isEditMode ? '위성 영상 상세' : '위성 영상 등록'}</span>
|
||||
<button
|
||||
type="button"
|
||||
className="puClose"
|
||||
aria-label="닫기"
|
||||
onClick={onClose}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="puBody">
|
||||
{isLoading && <div className="loading">조회 중...</div>}
|
||||
{error && <div className="error">{error}</div>}
|
||||
|
||||
{!isLoading && (
|
||||
<table className="table">
|
||||
<caption>
|
||||
위성 영상 {isEditMode ? '상세' : '등록'} - 사업자명/위성명, 영상 촬영일, 위성영상파일, CSV 파일, 위성영상명, 영상전송 주기, 영상 종류, 위성 궤도, 영상 출처, 촬영 목적, 촬영 모드, 취득방법, 구매가격에 대한 내용을 {isEditMode ? '조회/수정' : '등록'}하는 표입니다.
|
||||
</caption>
|
||||
<colgroup>
|
||||
<col style={{ width: '125px' }} />
|
||||
<col style={{ width: '' }} />
|
||||
<col style={{ width: '125px' }} />
|
||||
<col style={{ width: '' }} />
|
||||
</colgroup>
|
||||
<tbody>
|
||||
<tr>
|
||||
<th scope="row">사업자명/위성명 {!isEditMode && <span className="required">*</span>}</th>
|
||||
<td colSpan={3}>
|
||||
{isEditMode ? (
|
||||
<span>{satelliteName}</span>
|
||||
) : (
|
||||
<div className="row flex1">
|
||||
<select
|
||||
aria-label="사업자명"
|
||||
value={companyNo}
|
||||
onChange={(e) => setCompanyNo(e.target.value)}
|
||||
>
|
||||
<option value="">선택</option>
|
||||
{companyList.map((c) => (
|
||||
<option key={c.companyNo} value={c.companyNo}>
|
||||
{c.companyName}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
<select
|
||||
aria-label="위성명"
|
||||
value={satelliteManageId}
|
||||
onChange={(e) => setSatelliteManageId(e.target.value)}
|
||||
>
|
||||
<option value="">선택</option>
|
||||
{satelliteList.map((s) => (
|
||||
<option key={s.satelliteManageId} value={s.satelliteManageId}>
|
||||
{s.satelliteName}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
)}
|
||||
</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<th scope="row">영상 촬영일 <span className="required">*</span></th>
|
||||
<td colSpan={3}>
|
||||
<input
|
||||
className="dateInput"
|
||||
type="datetime-local"
|
||||
aria-label="영상 촬영일"
|
||||
value={photographDate}
|
||||
onChange={(e) => setPhotographDate(e.target.value.replace('T', ' '))}
|
||||
step="1"
|
||||
/>
|
||||
</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<th scope="row">위성영상파일 {!isEditMode && <span className="required">*</span>}</th>
|
||||
<td colSpan={3}>
|
||||
{isEditMode ? (
|
||||
<span>{tifFileName}</span>
|
||||
) : (
|
||||
<div className="rowC">
|
||||
<div className="fileWrap">
|
||||
<input
|
||||
type="file"
|
||||
id="registerTifFile"
|
||||
className="fileInput"
|
||||
accept=".tif,.tiff,.geotiff"
|
||||
onChange={(e) => setTifFile(e.target.files[0] || null)}
|
||||
/>
|
||||
<label htmlFor="registerTifFile" className="fileLabel">
|
||||
파일 선택
|
||||
</label>
|
||||
<span className="fileName">
|
||||
{tifFile ? truncateMiddle(tifFile.name, 40) : '선택된 파일 없음'}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<th scope="row">CSV 파일</th>
|
||||
<td colSpan={3}>
|
||||
{isEditMode ? (
|
||||
<span>{csvFileName}</span>
|
||||
) : (
|
||||
<div className="rowC">
|
||||
<div className="fileWrap">
|
||||
<input
|
||||
type="file"
|
||||
id="registerCsvFile"
|
||||
className="fileInput"
|
||||
accept=".csv"
|
||||
onChange={(e) => setCsvFile(e.target.files[0] || null)}
|
||||
/>
|
||||
<label htmlFor="registerCsvFile" className="fileLabel">
|
||||
파일 선택
|
||||
</label>
|
||||
<span className="fileName">
|
||||
{csvFile ? truncateMiddle(csvFile.name, 45) : '선택된 파일 없음'}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</td>
|
||||
</tr>
|
||||
{!isEditMode && (
|
||||
<tr>
|
||||
<th scope="row">Cloud Mask 파일</th>
|
||||
<td colSpan={3}>
|
||||
<div className="rowC">
|
||||
<div className="fileWrap">
|
||||
<input
|
||||
type="file"
|
||||
id="registerCloudMaskFile"
|
||||
className="fileInput"
|
||||
onChange={(e) => setCloudMaskFile(e.target.files[0] || null)}
|
||||
/>
|
||||
<label htmlFor="registerCloudMaskFile" className="fileLabel">
|
||||
파일 선택
|
||||
</label>
|
||||
<span className="fileName">
|
||||
{cloudMaskFile ? truncateMiddle(cloudMaskFile.name, 45) : '선택된 파일 없음'}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
)}
|
||||
<tr>
|
||||
<th scope="row">위성영상명 <span className="required">*</span></th>
|
||||
<td colSpan={3}>
|
||||
<input
|
||||
type="text"
|
||||
aria-label="위성영상명"
|
||||
value={satelliteVideoName}
|
||||
onChange={(e) => setSatelliteVideoName(e.target.value)}
|
||||
/>
|
||||
</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<th scope="row">영상전송 주기</th>
|
||||
<td colSpan={3}>
|
||||
<select
|
||||
aria-label="영상전송 주기"
|
||||
value={satelliteVideoTransmissionCycle}
|
||||
onChange={(e) => setSatelliteVideoTransmissionCycle(e.target.value)}
|
||||
>
|
||||
<option value="">선택</option>
|
||||
<option value="0">0초</option>
|
||||
<option value="10">10초</option>
|
||||
<option value="30">30초</option>
|
||||
<option value="60">60초</option>
|
||||
</select>
|
||||
</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<th scope="row">영상 종류</th>
|
||||
<td colSpan={3}>
|
||||
<div className="row">
|
||||
{['VIRS', 'ICEYE_SAR', '광학', '예약', 'RF'].map((kind) => (
|
||||
<label key={kind} className="radio radioL">
|
||||
<input
|
||||
type="radio"
|
||||
name="satelliteVideoKind"
|
||||
checked={satelliteVideoKind === kind}
|
||||
onChange={() => setSatelliteVideoKind(kind)}
|
||||
/>
|
||||
<span>{kind}</span>
|
||||
</label>
|
||||
))}
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<th scope="row">위성 궤도</th>
|
||||
<td>
|
||||
<select
|
||||
aria-label="위성 궤도"
|
||||
value={satelliteVideoOrbit}
|
||||
onChange={(e) => setSatelliteVideoOrbit(e.target.value)}
|
||||
>
|
||||
<option value="">선택</option>
|
||||
<option value="저궤도">저궤도</option>
|
||||
<option value="중궤도">중궤도</option>
|
||||
<option value="정지궤도">정지궤도</option>
|
||||
<option value="기타">기타</option>
|
||||
</select>
|
||||
</td>
|
||||
<th scope="row">영상 출처</th>
|
||||
<td>
|
||||
<select
|
||||
aria-label="영상 출처"
|
||||
value={satelliteVideoOrigin}
|
||||
onChange={(e) => setSatelliteVideoOrigin(e.target.value)}
|
||||
>
|
||||
<option value="">선택</option>
|
||||
<option value="국내/자동">국내/자동</option>
|
||||
<option value="국내/수동">국내/수동</option>
|
||||
<option value="국외/수동">국외/수동</option>
|
||||
<option value="기타">기타</option>
|
||||
</select>
|
||||
</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<th scope="row">촬영 목적</th>
|
||||
<td>
|
||||
<input
|
||||
type="text"
|
||||
placeholder="촬영 목적"
|
||||
aria-label="촬영 목적"
|
||||
value={photographPurpose}
|
||||
onChange={(e) => setPhotographPurpose(e.target.value)}
|
||||
/>
|
||||
</td>
|
||||
<th scope="row">촬영 모드</th>
|
||||
<td>
|
||||
<select
|
||||
aria-label="촬영 모드"
|
||||
value={photographMode}
|
||||
onChange={(e) => setPhotographMode(e.target.value)}
|
||||
>
|
||||
<option value="">선택</option>
|
||||
<option value="스핏모드">스핏모드</option>
|
||||
<option value="스트랩모드">스트랩모드</option>
|
||||
<option value="기타">기타</option>
|
||||
</select>
|
||||
</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<th scope="row">취득방법</th>
|
||||
<td>
|
||||
<select
|
||||
aria-label="취득방법"
|
||||
value={purchaseCode}
|
||||
onChange={(e) => setPurchaseCode(e.target.value)}
|
||||
>
|
||||
<option value="">선택</option>
|
||||
<option value="무료">무료</option>
|
||||
<option value="개별구매">개별구매</option>
|
||||
<option value="단가계약">단가계약</option>
|
||||
<option value="연간계약">연간계약</option>
|
||||
<option value="기타">기타</option>
|
||||
</select>
|
||||
</td>
|
||||
<th scope="row">구매가격</th>
|
||||
<td>
|
||||
<div className="numInput">
|
||||
<input
|
||||
type="number"
|
||||
placeholder="0"
|
||||
aria-label="구매가격"
|
||||
value={purchasePrice}
|
||||
onChange={(e) => setPurchasePrice(Number(e.target.value))}
|
||||
/>
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="puFooter">
|
||||
<div className="popBtnWrap">
|
||||
<button
|
||||
type="button"
|
||||
className="btn basic"
|
||||
onClick={handleSave}
|
||||
disabled={isSaving}
|
||||
>
|
||||
{isSaving ? '저장 중...' : isEditMode ? '수정' : '저장'}
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
className="btn dark"
|
||||
onClick={onClose}
|
||||
>
|
||||
취소
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>,
|
||||
document.body
|
||||
);
|
||||
}
|
||||
34
src/satellite/components/Slider.jsx
Normal file
34
src/satellite/components/Slider.jsx
Normal file
@ -0,0 +1,34 @@
|
||||
import { useState } from "react";
|
||||
|
||||
function Slider({ label = "", min = 0, max = 100, defaultValue = 50, value: controlledValue, onChange }) {
|
||||
const [internalValue, setInternalValue] = useState(defaultValue);
|
||||
|
||||
const value = controlledValue !== undefined ? controlledValue : internalValue;
|
||||
const percent = ((value - min) / (max - min)) * 100;
|
||||
|
||||
const handleChange = (e) => {
|
||||
const v = Number(e.target.value);
|
||||
if (onChange) {
|
||||
onChange(v);
|
||||
} else {
|
||||
setInternalValue(v);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<label className="rangeWrap">
|
||||
<span className="rangeLabel">{label}</span>
|
||||
<input
|
||||
type="range"
|
||||
min={min}
|
||||
max={max}
|
||||
value={value}
|
||||
onChange={handleChange}
|
||||
style={{ "--percent": `${percent}%` }}
|
||||
aria-label={label}
|
||||
/>
|
||||
</label>
|
||||
);
|
||||
}
|
||||
|
||||
export default Slider;
|
||||
36
src/satellite/hooks/useDraggable.js
Normal file
36
src/satellite/hooks/useDraggable.js
Normal file
@ -0,0 +1,36 @@
|
||||
import { useState, useRef, useCallback, useEffect } from 'react';
|
||||
|
||||
export default function useDraggable() {
|
||||
const [position, setPosition] = useState({ x: 0, y: 0 });
|
||||
const dragging = useRef(false);
|
||||
const dragStart = useRef({ x: 0, y: 0 });
|
||||
|
||||
const handleMouseDown = useCallback((e) => {
|
||||
dragging.current = true;
|
||||
dragStart.current = {
|
||||
x: e.clientX - position.x,
|
||||
y: e.clientY - position.y,
|
||||
};
|
||||
e.preventDefault();
|
||||
}, [position]);
|
||||
|
||||
useEffect(() => {
|
||||
const handleMouseMove = (e) => {
|
||||
if (!dragging.current) return;
|
||||
setPosition({
|
||||
x: e.clientX - dragStart.current.x,
|
||||
y: e.clientY - dragStart.current.y,
|
||||
});
|
||||
};
|
||||
const handleMouseUp = () => { dragging.current = false; };
|
||||
|
||||
window.addEventListener('mousemove', handleMouseMove);
|
||||
window.addEventListener('mouseup', handleMouseUp);
|
||||
return () => {
|
||||
window.removeEventListener('mousemove', handleMouseMove);
|
||||
window.removeEventListener('mouseup', handleMouseUp);
|
||||
};
|
||||
}, []);
|
||||
|
||||
return { position, handleMouseDown };
|
||||
}
|
||||
@ -416,6 +416,30 @@
|
||||
}
|
||||
}
|
||||
|
||||
// 위성/기상 메뉴 스크롤바 다크 테마
|
||||
.tabBtm,
|
||||
.tabBtmCnt {
|
||||
&::-webkit-scrollbar {
|
||||
width: 6px;
|
||||
}
|
||||
|
||||
&::-webkit-scrollbar-track {
|
||||
background: transparent;
|
||||
}
|
||||
|
||||
&::-webkit-scrollbar-thumb {
|
||||
background: rgba(var(--white-rgb), 0.3);
|
||||
border-radius: 3px;
|
||||
|
||||
&:hover {
|
||||
background: rgba(var(--white-rgb), 0.5);
|
||||
}
|
||||
}
|
||||
|
||||
scrollbar-width: thin;
|
||||
scrollbar-color: rgba(255, 255, 255, 0.3) transparent;
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
.toogle {
|
||||
|
||||
13
src/stores/satelliteStore.js
Normal file
13
src/stores/satelliteStore.js
Normal file
@ -0,0 +1,13 @@
|
||||
import { create } from 'zustand';
|
||||
|
||||
/**
|
||||
* 위성영상 표출 상태 관리
|
||||
* - opacity: OL TileLayer 투명도 (0~1)
|
||||
* - brightness: CSS filter brightness (0~200%)
|
||||
*/
|
||||
export const useSatelliteStore = create((set) => ({
|
||||
opacity: 1,
|
||||
brightness: 100,
|
||||
setOpacity: (opacity) => set({ opacity }),
|
||||
setBrightness: (brightness) => set({ brightness }),
|
||||
}));
|
||||
267
src/weather/components/AviationWeather.jsx
Normal file
267
src/weather/components/AviationWeather.jsx
Normal file
@ -0,0 +1,267 @@
|
||||
import { useState, useEffect, useRef, useCallback } from 'react';
|
||||
import { createPortal } from 'react-dom';
|
||||
import { fetchAirportList, fetchAirportDetail } from '@/api/weatherApi';
|
||||
import { useMapStore } from '@/stores/mapStore';
|
||||
import VectorLayer from 'ol/layer/Vector';
|
||||
import VectorSource from 'ol/source/Vector';
|
||||
import Feature from 'ol/Feature';
|
||||
import { Point } from 'ol/geom';
|
||||
import { Icon, Style } from 'ol/style';
|
||||
import { fromLonLat } from 'ol/proj';
|
||||
import Overlay from 'ol/Overlay';
|
||||
|
||||
export default function AviationWeather() {
|
||||
const [airports, setAirports] = useState([]);
|
||||
const [checkedIds, setCheckedIds] = useState(new Set());
|
||||
const [isLoading, setIsLoading] = useState(false);
|
||||
const [popupData, setPopupData] = useState(null);
|
||||
|
||||
const layerRef = useRef(null);
|
||||
const sourceRef = useRef(new VectorSource());
|
||||
const overlayRef = useRef(null);
|
||||
const popupElRef = useRef(document.createElement('div'));
|
||||
|
||||
const map = useMapStore((state) => state.map);
|
||||
|
||||
const closePopup = useCallback(() => {
|
||||
overlayRef.current?.setPosition(undefined);
|
||||
setPopupData(null);
|
||||
}, []);
|
||||
|
||||
// 공항 데이터 로딩
|
||||
useEffect(() => {
|
||||
let cancelled = false;
|
||||
|
||||
async function load() {
|
||||
setIsLoading(true);
|
||||
try {
|
||||
const data = await fetchAirportList();
|
||||
if (!cancelled) {
|
||||
setAirports(data);
|
||||
}
|
||||
} catch {
|
||||
// 에러는 weatherApi 내부에서 로깅됨
|
||||
} finally {
|
||||
if (!cancelled) {
|
||||
setIsLoading(false);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
load();
|
||||
|
||||
return () => {
|
||||
cancelled = true;
|
||||
};
|
||||
}, []);
|
||||
|
||||
// 레이어 + 오버레이 생성/제거
|
||||
useEffect(() => {
|
||||
if (!map) return;
|
||||
|
||||
const layer = new VectorLayer({
|
||||
source: sourceRef.current,
|
||||
zIndex: 10,
|
||||
});
|
||||
|
||||
map.addLayer(layer);
|
||||
layerRef.current = layer;
|
||||
|
||||
const overlay = new Overlay({
|
||||
element: popupElRef.current,
|
||||
positioning: 'bottom-center',
|
||||
offset: [0, -10],
|
||||
stopEvent: true,
|
||||
});
|
||||
|
||||
map.addOverlay(overlay);
|
||||
overlayRef.current = overlay;
|
||||
|
||||
return () => {
|
||||
closePopup();
|
||||
map.removeOverlay(overlay);
|
||||
overlayRef.current = null;
|
||||
map.removeLayer(layer);
|
||||
layerRef.current = null;
|
||||
popupElRef.current?.remove();
|
||||
};
|
||||
}, [map, closePopup]);
|
||||
|
||||
// 지도 클릭 이벤트 핸들러
|
||||
useEffect(() => {
|
||||
if (!map || !layerRef.current) return;
|
||||
|
||||
const handleMapClick = (evt) => {
|
||||
const feature = map.forEachFeatureAtPixel(evt.pixel, (f) => f, {
|
||||
layerFilter: (l) => l === layerRef.current,
|
||||
});
|
||||
|
||||
if (!feature) {
|
||||
closePopup();
|
||||
return;
|
||||
}
|
||||
|
||||
const airportId = feature.get('airportId');
|
||||
const coordinate = feature.getGeometry().getCoordinates();
|
||||
|
||||
overlayRef.current.setPosition(coordinate);
|
||||
|
||||
fetchAirportDetail({ airportId })
|
||||
.then((data) => setPopupData(data))
|
||||
.catch(() => setPopupData(null));
|
||||
};
|
||||
|
||||
map.on('singleclick', handleMapClick);
|
||||
|
||||
return () => {
|
||||
map.un('singleclick', handleMapClick);
|
||||
};
|
||||
}, [map, closePopup]);
|
||||
|
||||
// 마커 동기화
|
||||
useEffect(() => {
|
||||
const source = sourceRef.current;
|
||||
source.clear();
|
||||
|
||||
if (checkedIds.size === 0 || airports.length === 0) return;
|
||||
|
||||
airports
|
||||
.filter((airport) => checkedIds.has(airport.airportId))
|
||||
.forEach((airport) => {
|
||||
const coords = airport.coordinates?.[0];
|
||||
if (!coords) return;
|
||||
|
||||
const feature = new Feature({
|
||||
geometry: new Point(fromLonLat([coords[0], coords[1]])),
|
||||
name: airport.airportName,
|
||||
airportId: airport.airportId,
|
||||
});
|
||||
|
||||
feature.setStyle(
|
||||
new Style({
|
||||
image: new Icon({
|
||||
src: '/images/ico_legend_aircraft.svg',
|
||||
scale: 1,
|
||||
anchor: [0.5, 1],
|
||||
}),
|
||||
}),
|
||||
);
|
||||
|
||||
source.addFeature(feature);
|
||||
});
|
||||
}, [checkedIds, airports]);
|
||||
|
||||
const isAllChecked = checkedIds.size === airports.length && airports.length > 0;
|
||||
|
||||
const handleToggleAll = useCallback(() => {
|
||||
setCheckedIds((prev) => {
|
||||
if (prev.size === airports.length && airports.length > 0) {
|
||||
return new Set();
|
||||
}
|
||||
return new Set(airports.map((a) => a.airportId));
|
||||
});
|
||||
}, [airports]);
|
||||
|
||||
const handleToggle = useCallback((airportId) => {
|
||||
setCheckedIds((prev) => {
|
||||
const next = new Set(prev);
|
||||
if (next.has(airportId)) next.delete(airportId);
|
||||
else next.add(airportId);
|
||||
return next;
|
||||
});
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<>
|
||||
{createPortal(
|
||||
popupData ? (
|
||||
<div
|
||||
className="popupMap osbInfo"
|
||||
style={{ position: 'relative', top: 'auto', left: 'auto', transform: 'none' }}
|
||||
>
|
||||
<div className="pmHeader">
|
||||
<div className="rowL">
|
||||
<span className="title">{popupData.airportName}</span>
|
||||
</div>
|
||||
<button className="pmClose" onClick={closePopup} />
|
||||
</div>
|
||||
<div className="pmBody">
|
||||
<ul className="osbStatus">
|
||||
<li>
|
||||
<span className="label">기온</span>
|
||||
<span className="value">{popupData.temperature ?? '-'} °C</span>
|
||||
</li>
|
||||
<li>
|
||||
<span className="label">기상</span>
|
||||
<span className="value">{popupData.presentWeatherTypeCode ?? '-'}</span>
|
||||
</li>
|
||||
<li>
|
||||
<span className="label">풍향</span>
|
||||
<span className="value">{popupData.windDirection ?? '-'} °</span>
|
||||
</li>
|
||||
<li>
|
||||
<span className="label">풍속</span>
|
||||
<span className="value">{popupData.windSpeed ?? '-'} kt</span>
|
||||
</li>
|
||||
<li>
|
||||
<span className="label">시정</span>
|
||||
<span className="value">{popupData.visibility ?? '-'}</span>
|
||||
</li>
|
||||
<li>
|
||||
<span className="label">운고</span>
|
||||
<span className="value">{popupData.ceiling ?? '-'}</span>
|
||||
</li>
|
||||
<li>
|
||||
<span className="label">강수량</span>
|
||||
<span className="value">{popupData.rainFall ?? '-'} mm</span>
|
||||
</li>
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
) : null,
|
||||
popupElRef.current,
|
||||
)}
|
||||
|
||||
<div className="tabWrap is-active">
|
||||
<div className="tabTop">
|
||||
<div className="title">항공기상</div>
|
||||
<div className="legend">
|
||||
<span className="legendTitle">항공기상 범례</span>
|
||||
<ul className="legendList">
|
||||
<li>
|
||||
<img src="/images/ico_legend_aircraft.svg" alt="공항" />
|
||||
공항
|
||||
</li>
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="tabBtm noLine">
|
||||
{isLoading && <div className="loading">데이터를 불러오는 중...</div>}
|
||||
<ul className="lineList">
|
||||
<li>
|
||||
<label className="checkbox checkL">
|
||||
<input type="checkbox" checked={isAllChecked} onChange={handleToggleAll} />
|
||||
<span>전체</span>
|
||||
</label>
|
||||
</li>
|
||||
{airports.map((airport) => (
|
||||
<li key={airport.airportId}>
|
||||
<label className="checkbox checkL">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={checkedIds.has(airport.airportId)}
|
||||
onChange={() => handleToggle(airport.airportId)}
|
||||
/>
|
||||
<span>
|
||||
{airport.airportName}({airport.airportIcaoCode})
|
||||
</span>
|
||||
</label>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
}
|
||||
349
src/weather/components/TidalInfo.jsx
Normal file
349
src/weather/components/TidalInfo.jsx
Normal file
@ -0,0 +1,349 @@
|
||||
import { useState, useEffect, useRef, useCallback } from 'react';
|
||||
import { createPortal } from 'react-dom';
|
||||
import { fetchTideInformation, fetchObservatoryDetail, fetchSunriseSunsetDetail } from '@/api/weatherApi';
|
||||
import { useMapStore } from '@/stores/mapStore';
|
||||
import VectorLayer from 'ol/layer/Vector';
|
||||
import VectorSource from 'ol/source/Vector';
|
||||
import Feature from 'ol/Feature';
|
||||
import { Point } from 'ol/geom';
|
||||
import { Icon, Style } from 'ol/style';
|
||||
import { fromLonLat } from 'ol/proj';
|
||||
import Overlay from 'ol/Overlay';
|
||||
|
||||
const TIDAL_INFO_TYPES = [
|
||||
{ type: '조위관측소', icon: '/images/ico_obsTide.svg' },
|
||||
{ type: '일출몰관측지역', icon: '/images/ico_obsSunrise.svg' },
|
||||
];
|
||||
|
||||
export default function TidalInfo() {
|
||||
const [data, setData] = useState({ observatories: [], sunriseSunsets: [] });
|
||||
const [checkedTypes, setCheckedTypes] = useState(new Set());
|
||||
const [isLoading, setIsLoading] = useState(false);
|
||||
const [popupData, setPopupData] = useState(null);
|
||||
const [popupType, setPopupType] = useState(null); // 'observatory' | 'sunrise'
|
||||
|
||||
const layerRef = useRef(null);
|
||||
const sourceRef = useRef(new VectorSource());
|
||||
const overlayRef = useRef(null);
|
||||
const popupElRef = useRef(document.createElement('div'));
|
||||
|
||||
const map = useMapStore((state) => state.map);
|
||||
|
||||
// 팝업 닫기
|
||||
const closePopup = useCallback(() => {
|
||||
overlayRef.current?.setPosition(undefined);
|
||||
setPopupData(null);
|
||||
setPopupType(null);
|
||||
}, []);
|
||||
|
||||
// 데이터 로딩
|
||||
useEffect(() => {
|
||||
let cancelled = false;
|
||||
|
||||
async function load() {
|
||||
setIsLoading(true);
|
||||
try {
|
||||
const result = await fetchTideInformation();
|
||||
if (!cancelled) {
|
||||
setData(result);
|
||||
}
|
||||
} catch {
|
||||
// 에러는 weatherApi 내부에서 로깅됨
|
||||
} finally {
|
||||
if (!cancelled) {
|
||||
setIsLoading(false);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
load();
|
||||
|
||||
return () => {
|
||||
cancelled = true;
|
||||
};
|
||||
}, []);
|
||||
|
||||
// 레이어 + 오버레이 생성/제거
|
||||
useEffect(() => {
|
||||
if (!map) return;
|
||||
|
||||
const layer = new VectorLayer({
|
||||
source: sourceRef.current,
|
||||
zIndex: 10,
|
||||
});
|
||||
|
||||
map.addLayer(layer);
|
||||
layerRef.current = layer;
|
||||
|
||||
const overlay = new Overlay({
|
||||
element: popupElRef.current,
|
||||
positioning: 'bottom-center',
|
||||
offset: [0, -10],
|
||||
stopEvent: true,
|
||||
});
|
||||
|
||||
map.addOverlay(overlay);
|
||||
overlayRef.current = overlay;
|
||||
|
||||
return () => {
|
||||
closePopup();
|
||||
map.removeOverlay(overlay);
|
||||
overlayRef.current = null;
|
||||
map.removeLayer(layer);
|
||||
layerRef.current = null;
|
||||
popupElRef.current?.remove();
|
||||
};
|
||||
}, [map, closePopup]);
|
||||
|
||||
// 지도 클릭 이벤트 핸들러
|
||||
useEffect(() => {
|
||||
if (!map || !layerRef.current) return;
|
||||
|
||||
const handleMapClick = (evt) => {
|
||||
const feature = map.forEachFeatureAtPixel(evt.pixel, (f) => f, {
|
||||
layerFilter: (l) => l === layerRef.current,
|
||||
});
|
||||
|
||||
if (!feature) {
|
||||
closePopup();
|
||||
return;
|
||||
}
|
||||
|
||||
const coordinate = feature.getGeometry().getCoordinates();
|
||||
overlayRef.current.setPosition(coordinate);
|
||||
|
||||
const type = feature.get('featureType');
|
||||
|
||||
if (type === 'observatory') {
|
||||
const observatoryId = feature.get('observatoryId');
|
||||
const today = new Date().toISOString().slice(0, 10);
|
||||
setPopupType('observatory');
|
||||
fetchObservatoryDetail({ observatoryId, toDate: today })
|
||||
.then((d) => setPopupData(d))
|
||||
.catch(() => setPopupData(null));
|
||||
} else if (type === 'sunrise') {
|
||||
const stationData = feature.get('stationData');
|
||||
setPopupType('sunrise');
|
||||
fetchSunriseSunsetDetail(stationData)
|
||||
.then((d) => setPopupData(d))
|
||||
.catch(() => setPopupData(null));
|
||||
}
|
||||
};
|
||||
|
||||
map.on('singleclick', handleMapClick);
|
||||
|
||||
return () => {
|
||||
map.un('singleclick', handleMapClick);
|
||||
};
|
||||
}, [map, closePopup]);
|
||||
|
||||
// 마커 동기화
|
||||
useEffect(() => {
|
||||
const source = sourceRef.current;
|
||||
source.clear();
|
||||
|
||||
if (checkedTypes.size === 0) return;
|
||||
|
||||
// 조위관측소 마커
|
||||
if (checkedTypes.has('조위관측소')) {
|
||||
data.observatories.forEach((station) => {
|
||||
const coord = station.coordinates?.[0];
|
||||
if (!coord) return;
|
||||
|
||||
const feature = new Feature({
|
||||
geometry: new Point(fromLonLat([coord[0], coord[1]])),
|
||||
name: station.observatoryName,
|
||||
featureType: 'observatory',
|
||||
stationData: station,
|
||||
observatoryId: station.observatoryId,
|
||||
});
|
||||
|
||||
feature.setStyle(
|
||||
new Style({
|
||||
image: new Icon({
|
||||
src: '/images/ico_obsTide.svg',
|
||||
scale: 1,
|
||||
anchor: [0.5, 1],
|
||||
}),
|
||||
}),
|
||||
);
|
||||
|
||||
source.addFeature(feature);
|
||||
});
|
||||
}
|
||||
|
||||
// 일출몰관측지역 마커
|
||||
if (checkedTypes.has('일출몰관측지역')) {
|
||||
data.sunriseSunsets.forEach((region) => {
|
||||
const coord = region.locationCoordinates?.[0];
|
||||
if (!coord) return;
|
||||
|
||||
const feature = new Feature({
|
||||
geometry: new Point(fromLonLat([coord[0], coord[1]])),
|
||||
name: region.locationName,
|
||||
featureType: 'sunrise',
|
||||
stationData: region,
|
||||
});
|
||||
|
||||
feature.setStyle(
|
||||
new Style({
|
||||
image: new Icon({
|
||||
src: '/images/ico_obsSunrise.svg',
|
||||
scale: 1,
|
||||
anchor: [0.5, 1],
|
||||
}),
|
||||
}),
|
||||
);
|
||||
|
||||
source.addFeature(feature);
|
||||
});
|
||||
}
|
||||
}, [checkedTypes, data]);
|
||||
|
||||
const handleToggle = useCallback((type) => {
|
||||
setCheckedTypes((prev) => {
|
||||
const next = new Set(prev);
|
||||
if (next.has(type)) next.delete(type);
|
||||
else next.add(type);
|
||||
return next;
|
||||
});
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<>
|
||||
{/* OL Overlay에 연결될 팝업 요소 - createPortal로 React DOM 트리 외부에 렌더링 */}
|
||||
{createPortal(
|
||||
<>
|
||||
{popupData && popupType === 'observatory' && (
|
||||
<div
|
||||
className="popupMap osbInfo"
|
||||
style={{ position: 'relative', top: 'auto', left: 'auto', transform: 'none' }}
|
||||
>
|
||||
<div className="pmHeader">
|
||||
<div className="rowL">
|
||||
<span className="title">{popupData.observatoryName}</span>
|
||||
</div>
|
||||
<button className="pmClose" onClick={closePopup} />
|
||||
</div>
|
||||
<div className="pmBody">
|
||||
<ul className="osbStatus">
|
||||
<li className="date">
|
||||
{popupData.latestTime
|
||||
? new Date(popupData.latestTime).toLocaleString('ko-KR', {
|
||||
year: 'numeric',
|
||||
month: '2-digit',
|
||||
day: '2-digit',
|
||||
hour: '2-digit',
|
||||
minute: '2-digit',
|
||||
hour12: false,
|
||||
})
|
||||
: '-'}
|
||||
</li>
|
||||
<li>
|
||||
<span className="label">조위</span>
|
||||
<span className="value">{popupData.tidalLevel ?? '-'} cm</span>
|
||||
</li>
|
||||
<li>
|
||||
<span className="label">수온</span>
|
||||
<span className="value">{popupData.seawaterTemperature ?? '-'} °C</span>
|
||||
</li>
|
||||
<li>
|
||||
<span className="label">기온</span>
|
||||
<span className="value">{popupData.temperature ?? '-'} °C</span>
|
||||
</li>
|
||||
<li>
|
||||
<span className="label">기압</span>
|
||||
<span className="value">{popupData.atmosphericPressure ?? '-'} hPa</span>
|
||||
</li>
|
||||
<li>
|
||||
<span className="label">풍향</span>
|
||||
<span className="value">{popupData.windDirection ?? '-'} deg</span>
|
||||
</li>
|
||||
<li>
|
||||
<span className="label">풍속</span>
|
||||
<span className="value">{popupData.windSpeed ?? '-'} m/s</span>
|
||||
</li>
|
||||
<li>
|
||||
<span className="label">유속방향</span>
|
||||
<span className="value">{popupData.tidalDirection ?? '-'} deg</span>
|
||||
</li>
|
||||
<li>
|
||||
<span className="label">유속</span>
|
||||
<span className="value">{popupData.tidalSpeed ?? '-'} m/s</span>
|
||||
</li>
|
||||
<li>
|
||||
<span className="label">파고</span>
|
||||
<span className="value">{popupData.waveHeight ?? '-'} m</span>
|
||||
</li>
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{popupData && popupType === 'sunrise' && (
|
||||
<div
|
||||
className="popupMap osbInfo"
|
||||
style={{ position: 'relative', top: 'auto', left: 'auto', transform: 'none' }}
|
||||
>
|
||||
<div className="pmHeader">
|
||||
<div className="rowL">
|
||||
<span className="title">{popupData.locationName}</span>
|
||||
</div>
|
||||
<button className="pmClose" onClick={closePopup} />
|
||||
</div>
|
||||
<div className="pmBody">
|
||||
<ul className="osbStatus">
|
||||
<li className="date">{popupData.date ?? '-'} ({popupData.day ?? '-'})</li>
|
||||
<li>
|
||||
<span className="label">일출</span>
|
||||
<span className="value">{popupData.sunriseTime ?? '-'}</span>
|
||||
</li>
|
||||
<li>
|
||||
<span className="label">일몰</span>
|
||||
<span className="value">{popupData.sunsetTime ?? '-'}</span>
|
||||
</li>
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</>,
|
||||
popupElRef.current,
|
||||
)}
|
||||
|
||||
<div className="tabWrap is-active">
|
||||
<div className="tabTop">
|
||||
<div className="title">조석정보</div>
|
||||
<div className="legend">
|
||||
<span className="legendTitle">조위관측 범례</span>
|
||||
<ul className="legendList">
|
||||
{TIDAL_INFO_TYPES.map(({ type, icon }) => (
|
||||
<li key={type}>
|
||||
<img src={icon} alt={type} />
|
||||
{type}
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="tabBtm">
|
||||
{isLoading && <div className="loading">데이터를 불러오는 중...</div>}
|
||||
<ul className="lineList">
|
||||
{TIDAL_INFO_TYPES.map(({ type }) => (
|
||||
<li key={type}>
|
||||
<label className="checkbox checkL">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={checkedTypes.has(type)}
|
||||
onChange={() => handleToggle(type)}
|
||||
/>
|
||||
<span>{type}</span>
|
||||
</label>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
}
|
||||
289
src/weather/components/TidalObservation.jsx
Normal file
289
src/weather/components/TidalObservation.jsx
Normal file
@ -0,0 +1,289 @@
|
||||
import { useState, useEffect, useRef, useCallback } from 'react';
|
||||
import { createPortal } from 'react-dom';
|
||||
import { fetchObservatoryList, fetchObservatoryDetail } from '@/api/weatherApi';
|
||||
import { useMapStore } from '@/stores/mapStore';
|
||||
import VectorLayer from 'ol/layer/Vector';
|
||||
import VectorSource from 'ol/source/Vector';
|
||||
import Feature from 'ol/Feature';
|
||||
import { Point } from 'ol/geom';
|
||||
import { Icon, Style } from 'ol/style';
|
||||
import { fromLonLat } from 'ol/proj';
|
||||
import Overlay from 'ol/Overlay';
|
||||
|
||||
const OBSERVATORY_TYPES = [
|
||||
{ type: '조위관측소', icon: '/images/ico_obsTide.svg' },
|
||||
{ type: '해양관측소', icon: '/images/ico_obsOcean.svg' },
|
||||
{ type: '해양관측부이', icon: '/images/ico_obsBuoy.svg' },
|
||||
{ type: '해수유동관측소', icon: '/images/ico_obsCurrent.svg' },
|
||||
{ type: '해양과학기지', icon: '/images/ico_obsScience.svg' },
|
||||
];
|
||||
|
||||
const ICON_BY_TYPE = Object.fromEntries(
|
||||
OBSERVATORY_TYPES.map(({ type, icon }) => [type, icon]),
|
||||
);
|
||||
|
||||
export default function TidalObservation() {
|
||||
const [stations, setStations] = useState([]);
|
||||
const [checkedTypes, setCheckedTypes] = useState(new Set());
|
||||
const [isLoading, setIsLoading] = useState(false);
|
||||
const [popupData, setPopupData] = useState(null);
|
||||
|
||||
const layerRef = useRef(null);
|
||||
const sourceRef = useRef(new VectorSource());
|
||||
const overlayRef = useRef(null);
|
||||
const popupElRef = useRef(document.createElement('div'));
|
||||
|
||||
const map = useMapStore((state) => state.map);
|
||||
|
||||
// 팝업 닫기
|
||||
const closePopup = useCallback(() => {
|
||||
overlayRef.current?.setPosition(undefined);
|
||||
setPopupData(null);
|
||||
}, []);
|
||||
|
||||
// 관측소 데이터 로딩
|
||||
useEffect(() => {
|
||||
let cancelled = false;
|
||||
|
||||
async function load() {
|
||||
setIsLoading(true);
|
||||
try {
|
||||
const data = await fetchObservatoryList();
|
||||
if (!cancelled) {
|
||||
setStations(data);
|
||||
}
|
||||
} catch {
|
||||
// 에러는 weatherApi 내부에서 로깅됨
|
||||
} finally {
|
||||
if (!cancelled) {
|
||||
setIsLoading(false);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
load();
|
||||
|
||||
return () => {
|
||||
cancelled = true;
|
||||
};
|
||||
}, []);
|
||||
|
||||
// 레이어 + 오버레이 생성/제거
|
||||
useEffect(() => {
|
||||
if (!map) return;
|
||||
|
||||
const layer = new VectorLayer({
|
||||
source: sourceRef.current,
|
||||
zIndex: 10,
|
||||
});
|
||||
|
||||
map.addLayer(layer);
|
||||
layerRef.current = layer;
|
||||
|
||||
const overlay = new Overlay({
|
||||
element: popupElRef.current,
|
||||
positioning: 'bottom-center',
|
||||
offset: [0, -10],
|
||||
stopEvent: true,
|
||||
});
|
||||
|
||||
map.addOverlay(overlay);
|
||||
overlayRef.current = overlay;
|
||||
|
||||
return () => {
|
||||
closePopup();
|
||||
map.removeOverlay(overlay);
|
||||
overlayRef.current = null;
|
||||
map.removeLayer(layer);
|
||||
layerRef.current = null;
|
||||
// OL이 이동시킨 요소를 DOM에서 제거
|
||||
popupElRef.current?.remove();
|
||||
};
|
||||
}, [map, closePopup]);
|
||||
|
||||
// 지도 클릭 이벤트 핸들러
|
||||
useEffect(() => {
|
||||
if (!map || !layerRef.current) return;
|
||||
|
||||
const handleMapClick = (evt) => {
|
||||
const feature = map.forEachFeatureAtPixel(evt.pixel, (f) => f, {
|
||||
layerFilter: (l) => l === layerRef.current,
|
||||
});
|
||||
|
||||
if (!feature) {
|
||||
closePopup();
|
||||
return;
|
||||
}
|
||||
|
||||
const observatoryId = feature.get('observatoryId');
|
||||
const coordinate = feature.getGeometry().getCoordinates();
|
||||
|
||||
overlayRef.current.setPosition(coordinate);
|
||||
|
||||
const today = new Date().toISOString().slice(0, 10);
|
||||
fetchObservatoryDetail({ observatoryId, toDate: today })
|
||||
.then((data) => setPopupData(data))
|
||||
.catch(() => setPopupData(null));
|
||||
};
|
||||
|
||||
map.on('singleclick', handleMapClick);
|
||||
|
||||
return () => {
|
||||
map.un('singleclick', handleMapClick);
|
||||
};
|
||||
}, [map, closePopup]);
|
||||
|
||||
// 마커 동기화
|
||||
useEffect(() => {
|
||||
const source = sourceRef.current;
|
||||
source.clear();
|
||||
|
||||
if (checkedTypes.size === 0 || stations.length === 0) return;
|
||||
|
||||
const filtered = stations.filter((s) => checkedTypes.has(s.observatoryType));
|
||||
|
||||
filtered.forEach((station) => {
|
||||
const coord = station.coordinates?.[0];
|
||||
if (!coord) return;
|
||||
|
||||
const feature = new Feature({
|
||||
geometry: new Point(fromLonLat([coord[0], coord[1]])),
|
||||
name: station.observatoryName,
|
||||
observatoryId: station.observatoryId,
|
||||
observatoryType: station.observatoryType,
|
||||
});
|
||||
|
||||
const iconSrc = ICON_BY_TYPE[station.observatoryType];
|
||||
if (iconSrc) {
|
||||
feature.setStyle(
|
||||
new Style({
|
||||
image: new Icon({
|
||||
src: iconSrc,
|
||||
scale: 1,
|
||||
anchor: [0.5, 1],
|
||||
}),
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
source.addFeature(feature);
|
||||
});
|
||||
}, [checkedTypes, stations]);
|
||||
|
||||
const handleToggle = useCallback((type) => {
|
||||
setCheckedTypes((prev) => {
|
||||
const next = new Set(prev);
|
||||
if (next.has(type)) next.delete(type);
|
||||
else next.add(type);
|
||||
return next;
|
||||
});
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<>
|
||||
{createPortal(
|
||||
popupData ? (
|
||||
<div
|
||||
className="popupMap osbInfo"
|
||||
style={{ position: 'relative', top: 'auto', left: 'auto', transform: 'none' }}
|
||||
>
|
||||
<div className="pmHeader">
|
||||
<div className="rowL">
|
||||
<span className="title">{popupData.observatoryName}</span>
|
||||
</div>
|
||||
<button className="pmClose" onClick={closePopup} />
|
||||
</div>
|
||||
<div className="pmBody">
|
||||
<ul className="osbStatus">
|
||||
<li className="date">
|
||||
{popupData.latestTime
|
||||
? new Date(popupData.latestTime).toLocaleString('ko-KR', {
|
||||
year: 'numeric',
|
||||
month: '2-digit',
|
||||
day: '2-digit',
|
||||
hour: '2-digit',
|
||||
minute: '2-digit',
|
||||
hour12: false,
|
||||
})
|
||||
: '-'}
|
||||
</li>
|
||||
<li>
|
||||
<span className="label">조위</span>
|
||||
<span className="value">{popupData.tidalLevel ?? '-'} cm</span>
|
||||
</li>
|
||||
<li>
|
||||
<span className="label">수온</span>
|
||||
<span className="value">{popupData.seawaterTemperature ?? '-'} °C</span>
|
||||
</li>
|
||||
<li>
|
||||
<span className="label">기온</span>
|
||||
<span className="value">{popupData.temperature ?? '-'} °C</span>
|
||||
</li>
|
||||
<li>
|
||||
<span className="label">기압</span>
|
||||
<span className="value">{popupData.atmosphericPressure ?? '-'} hPa</span>
|
||||
</li>
|
||||
<li>
|
||||
<span className="label">풍향</span>
|
||||
<span className="value">{popupData.windDirection ?? '-'} deg</span>
|
||||
</li>
|
||||
<li>
|
||||
<span className="label">풍속</span>
|
||||
<span className="value">{popupData.windSpeed ?? '-'} m/s</span>
|
||||
</li>
|
||||
<li>
|
||||
<span className="label">유속방향</span>
|
||||
<span className="value">{popupData.tidalDirection ?? '-'} deg</span>
|
||||
</li>
|
||||
<li>
|
||||
<span className="label">유속</span>
|
||||
<span className="value">{popupData.tidalSpeed ?? '-'} m/s</span>
|
||||
</li>
|
||||
<li>
|
||||
<span className="label">파고</span>
|
||||
<span className="value">{popupData.waveHeight ?? '-'} m</span>
|
||||
</li>
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
) : null,
|
||||
popupElRef.current,
|
||||
)}
|
||||
|
||||
<div className="tabWrap is-active">
|
||||
<div className="tabTop">
|
||||
<div className="title">조위관측</div>
|
||||
<div className="legend">
|
||||
<span className="legendTitle">조위관측 범례</span>
|
||||
<ul className="legendList">
|
||||
{OBSERVATORY_TYPES.map(({ type, icon }) => (
|
||||
<li key={type}>
|
||||
<img src={icon} alt={type} />
|
||||
{type}
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="tabBtm">
|
||||
{isLoading && <div className="loading">데이터를 불러오는 중...</div>}
|
||||
<ul className="lineList">
|
||||
{OBSERVATORY_TYPES.map(({ type }) => (
|
||||
<li key={type}>
|
||||
<label className="checkbox checkL">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={checkedTypes.has(type)}
|
||||
onChange={() => handleToggle(type)}
|
||||
/>
|
||||
<span>{type}</span>
|
||||
</label>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
}
|
||||
190
src/weather/components/TyphoonInfo.jsx
Normal file
190
src/weather/components/TyphoonInfo.jsx
Normal file
@ -0,0 +1,190 @@
|
||||
import { useState, useCallback } from 'react';
|
||||
import { fetchTyphoonList, fetchTyphoonDetail } from '@/api/weatherApi';
|
||||
|
||||
const LIMIT = 10;
|
||||
const currentYear = new Date().getFullYear();
|
||||
|
||||
const yearOptions = Array.from({ length: currentYear - 2000 + 1 }, (_, i) => currentYear - i);
|
||||
const monthOptions = Array.from({ length: 12 }, (_, i) => String(i + 1).padStart(2, '0'));
|
||||
|
||||
export default function TyphoonInfo() {
|
||||
const [year, setYear] = useState(String(currentYear));
|
||||
const [month, setMonth] = useState('');
|
||||
const [list, setList] = useState([]);
|
||||
const [page, setPage] = useState(1);
|
||||
const [totalPage, setTotalPage] = useState(0);
|
||||
const [isLoading, setIsLoading] = useState(false);
|
||||
const [error, setError] = useState(null);
|
||||
const [expandedIndex, setExpandedIndex] = useState(null);
|
||||
const [detailData, setDetailData] = useState([]);
|
||||
const [isDetailLoading, setIsDetailLoading] = useState(false);
|
||||
|
||||
const search = useCallback(async (targetPage) => {
|
||||
if (!year) return;
|
||||
|
||||
setIsLoading(true);
|
||||
setError(null);
|
||||
setExpandedIndex(null);
|
||||
|
||||
try {
|
||||
const result = await fetchTyphoonList({
|
||||
typhoonBeginningYear: year,
|
||||
typhoonBeginningMonth: month,
|
||||
page: targetPage,
|
||||
limit: LIMIT,
|
||||
});
|
||||
setList(result.list);
|
||||
setTotalPage(result.totalPage);
|
||||
setPage(targetPage);
|
||||
} catch (err) {
|
||||
setError('태풍정보 조회 중 오류가 발생했습니다.');
|
||||
setList([]);
|
||||
setTotalPage(0);
|
||||
} finally {
|
||||
setIsLoading(false);
|
||||
}
|
||||
}, [year, month]);
|
||||
|
||||
const handleSearch = () => {
|
||||
search(1);
|
||||
};
|
||||
|
||||
const handlePageChange = (newPage) => {
|
||||
search(newPage);
|
||||
};
|
||||
|
||||
const handleToggle = useCallback(async (idx, item) => {
|
||||
if (expandedIndex === idx) {
|
||||
setExpandedIndex(null);
|
||||
return;
|
||||
}
|
||||
|
||||
setExpandedIndex(idx);
|
||||
setIsDetailLoading(true);
|
||||
setDetailData([]);
|
||||
|
||||
try {
|
||||
const data = await fetchTyphoonDetail({
|
||||
typhoonSequence: item.typhoonSequence,
|
||||
year: item.typhoonBeginningYear || year,
|
||||
});
|
||||
setDetailData(data);
|
||||
} catch {
|
||||
setDetailData([]);
|
||||
} finally {
|
||||
setIsDetailLoading(false);
|
||||
}
|
||||
}, [expandedIndex, year]);
|
||||
|
||||
return (
|
||||
<div className="tabWrap is-active">
|
||||
<div className="tabTop">
|
||||
<div className="title">태풍정보</div>
|
||||
<div className="formGroup">
|
||||
<ul>
|
||||
<li>
|
||||
<label>
|
||||
<span>연도</span>
|
||||
<select value={year} onChange={(e) => setYear(e.target.value)}>
|
||||
{yearOptions.map((y) => (
|
||||
<option key={y} value={y}>{y}</option>
|
||||
))}
|
||||
</select>
|
||||
</label>
|
||||
<label>
|
||||
<span>월</span>
|
||||
<select value={month} onChange={(e) => setMonth(e.target.value)}>
|
||||
<option value="">전체</option>
|
||||
{monthOptions.map((m) => (
|
||||
<option key={m} value={m}>{m}</option>
|
||||
))}
|
||||
</select>
|
||||
</label>
|
||||
</li>
|
||||
<li className="fgBtn">
|
||||
<button type="button" className="schBtn" onClick={handleSearch}>검색</button>
|
||||
</li>
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="tabBtm">
|
||||
{isLoading && <div className="loading">조회 중...</div>}
|
||||
|
||||
{error && <div className="error">{error}</div>}
|
||||
|
||||
{!isLoading && !error && list.length === 0 && (
|
||||
<div className="empty">검색 결과가 없습니다.</div>
|
||||
)}
|
||||
|
||||
{!isLoading && list.length > 0 && (
|
||||
<>
|
||||
<ul className="colList lineSB">
|
||||
{list.map((item, idx) => {
|
||||
const isExpanded = expandedIndex === idx;
|
||||
const status = item.typhoonEndTime ? '종료' : '진행중';
|
||||
|
||||
return (
|
||||
<li key={idx} style={isExpanded ? { flexDirection: 'column', alignItems: 'stretch' } : undefined}>
|
||||
<a
|
||||
href="#"
|
||||
onClick={(e) => {
|
||||
e.preventDefault();
|
||||
handleToggle(idx, item);
|
||||
}}
|
||||
>
|
||||
<span className="title">
|
||||
{(page - 1) * LIMIT + idx + 1}. {item.typhoonName} ({status})
|
||||
</span>
|
||||
<span className="meta">
|
||||
발생일시 {item.typhoonBeginningTime} / 종료일시 {item.typhoonEndTime || '-'}
|
||||
</span>
|
||||
</a>
|
||||
|
||||
{isExpanded && (
|
||||
<div className={`acdListBox${isExpanded ? ' open' : ''}`}>
|
||||
{isDetailLoading && <div className="loading">상세 조회 중...</div>}
|
||||
|
||||
{!isDetailLoading && detailData.length === 0 && (
|
||||
<div className="empty">진행정보가 없습니다.</div>
|
||||
)}
|
||||
|
||||
{!isDetailLoading && detailData.length > 0 && (
|
||||
<ul className="acdList">
|
||||
{detailData.map((d, dIdx) => (
|
||||
<li key={dIdx}>
|
||||
<span>발표순서: {d.presentationSequence}</span>
|
||||
<span>시간간격: {d.timeInterval}</span>
|
||||
<span>발표시간: {d.presentationTime}</span>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</li>
|
||||
);
|
||||
})}
|
||||
</ul>
|
||||
|
||||
{totalPage > 1 && (
|
||||
<div className="pagination">
|
||||
<button type="button" className={page <= 1 ? 'disabled' : ''} disabled={page <= 1} onClick={() => handlePageChange(page - 1)}><</button>
|
||||
{page > 3 && <button type="button" onClick={() => handlePageChange(1)}>1</button>}
|
||||
{page > 4 && <span className="ellipsis">...</span>}
|
||||
{Array.from({ length: 5 }, (_, i) => page - 2 + i)
|
||||
.filter((p) => p >= 1 && p <= totalPage)
|
||||
.map((p) => (
|
||||
<button key={p} type="button" className={p === page ? 'on' : ''} onClick={() => handlePageChange(p)}>{p}</button>
|
||||
))}
|
||||
{page < totalPage - 3 && <span className="ellipsis">...</span>}
|
||||
{page < totalPage - 2 && <button type="button" onClick={() => handlePageChange(totalPage)}>{totalPage}</button>}
|
||||
<button type="button" className={page >= totalPage ? 'disabled' : ''} disabled={page >= totalPage} onClick={() => handlePageChange(page + 1)}>></button>
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
143
src/weather/components/WeatherAlert.jsx
Normal file
143
src/weather/components/WeatherAlert.jsx
Normal file
@ -0,0 +1,143 @@
|
||||
import { useState, useCallback } from 'react';
|
||||
import { fetchWeatherAlerts } from '@/api/weatherApi';
|
||||
|
||||
const LIMIT = 10;
|
||||
|
||||
function formatDate(date) {
|
||||
const pad = (n) => String(n).padStart(2, '0');
|
||||
return `${date.getFullYear()}-${pad(date.getMonth() + 1)}-${pad(date.getDate())}`;
|
||||
}
|
||||
|
||||
function getDefaultDates() {
|
||||
const end = new Date();
|
||||
const start = new Date();
|
||||
start.setDate(start.getDate() - 7);
|
||||
return { start: formatDate(start), end: formatDate(end) };
|
||||
}
|
||||
|
||||
const defaults = getDefaultDates();
|
||||
|
||||
export default function WeatherAlert() {
|
||||
const [startDate, setStartDate] = useState(defaults.start);
|
||||
const [endDate, setEndDate] = useState(defaults.end);
|
||||
const [list, setList] = useState([]);
|
||||
const [page, setPage] = useState(1);
|
||||
const [totalPage, setTotalPage] = useState(0);
|
||||
const [isLoading, setIsLoading] = useState(false);
|
||||
const [error, setError] = useState(null);
|
||||
|
||||
const search = useCallback(async (targetPage) => {
|
||||
if (!startDate || !endDate) return;
|
||||
if (startDate > endDate) return;
|
||||
|
||||
setIsLoading(true);
|
||||
setError(null);
|
||||
|
||||
try {
|
||||
const result = await fetchWeatherAlerts({
|
||||
startPresentationDate: startDate,
|
||||
endPresentationDate: endDate,
|
||||
page: targetPage,
|
||||
limit: LIMIT,
|
||||
});
|
||||
setList(result.list);
|
||||
setTotalPage(result.totalPage);
|
||||
setPage(targetPage);
|
||||
} catch (err) {
|
||||
setError('기상특보 조회 중 오류가 발생했습니다.');
|
||||
setList([]);
|
||||
setTotalPage(0);
|
||||
} finally {
|
||||
setIsLoading(false);
|
||||
}
|
||||
}, [startDate, endDate]);
|
||||
|
||||
const handleSearch = () => {
|
||||
search(1);
|
||||
};
|
||||
|
||||
const handlePageChange = (newPage) => {
|
||||
search(newPage);
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="tabWrap is-active">
|
||||
<div className="tabTop">
|
||||
<div className="title">기상특보</div>
|
||||
|
||||
<div className="formGroup">
|
||||
<ul>
|
||||
<li>
|
||||
<label>
|
||||
<span>일자</span>
|
||||
<div className="labelRow">
|
||||
<input
|
||||
type="date"
|
||||
className="dateInput"
|
||||
value={startDate}
|
||||
onChange={(e) => setStartDate(e.target.value)}
|
||||
/>
|
||||
<span>-</span>
|
||||
<input
|
||||
type="date"
|
||||
className="dateInput"
|
||||
value={endDate}
|
||||
onChange={(e) => setEndDate(e.target.value)}
|
||||
/>
|
||||
</div>
|
||||
</label>
|
||||
</li>
|
||||
<li className="fgBtn">
|
||||
<button type="button" className="schBtn" onClick={handleSearch}>검색</button>
|
||||
</li>
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="tabBtm">
|
||||
{isLoading && <div className="loading">조회 중...</div>}
|
||||
|
||||
{error && <div className="error">{error}</div>}
|
||||
|
||||
{!isLoading && !error && list.length === 0 && (
|
||||
<div className="empty">검색 결과가 없습니다.</div>
|
||||
)}
|
||||
|
||||
{!isLoading && list.length > 0 && (
|
||||
<>
|
||||
<ul className="colList lineSB">
|
||||
{list.map((item, idx) => (
|
||||
<li key={idx}>
|
||||
<a href="#" onClick={(e) => e.preventDefault()}>
|
||||
<span className="title">
|
||||
{(page - 1) * LIMIT + idx + 1}. {item.specialNewsKind}: {item.specialZoneName}
|
||||
</span>
|
||||
<span className="meta">
|
||||
발표일시 {item.presentationTime} / 발효일시 {item.effectivationTime}
|
||||
</span>
|
||||
</a>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
|
||||
{totalPage > 1 && (
|
||||
<div className="pagination">
|
||||
<button type="button" className={page <= 1 ? 'disabled' : ''} disabled={page <= 1} onClick={() => handlePageChange(page - 1)}><</button>
|
||||
{page > 3 && <button type="button" onClick={() => handlePageChange(1)}>1</button>}
|
||||
{page > 4 && <span className="ellipsis">...</span>}
|
||||
{Array.from({ length: 5 }, (_, i) => page - 2 + i)
|
||||
.filter((p) => p >= 1 && p <= totalPage)
|
||||
.map((p) => (
|
||||
<button key={p} type="button" className={p === page ? 'on' : ''} onClick={() => handlePageChange(p)}>{p}</button>
|
||||
))}
|
||||
{page < totalPage - 3 && <span className="ellipsis">...</span>}
|
||||
{page < totalPage - 2 && <button type="button" onClick={() => handlePageChange(totalPage)}>{totalPage}</button>}
|
||||
<button type="button" className={page >= totalPage ? 'disabled' : ''} disabled={page >= totalPage} onClick={() => handlePageChange(page + 1)}>></button>
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@ -79,7 +79,7 @@ export default ({ mode, command }) => {
|
||||
},
|
||||
// API 서버
|
||||
'/api': {
|
||||
target: env.VITE_API_URL || 'http://localhost:8080',
|
||||
target: env.VITE_TRACK_API || 'http://localhost:8090',
|
||||
changeOrigin: true,
|
||||
secure: false,
|
||||
},
|
||||
|
||||
불러오는 중...
Reference in New Issue
Block a user