From 910974f81b3b0d720055fa7f09daaa9ad918df7f Mon Sep 17 00:00:00 2001 From: ysCha Date: Tue, 31 Mar 2026 10:18:46 +0900 Subject: [PATCH 1/5] =?UTF-8?q?getRoofMaterialList=20api=EC=97=90=EB=9F=AC?= =?UTF-8?q?10=EB=B2=88=EC=8B=9C=EB=8F=84?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/hooks/common/useMasterController.js | 124 +++++++++++++++++++++--- src/hooks/option/useCanvasSetting.js | 28 ++++-- src/hooks/useAxios.js | 24 +++-- src/locales/ja.json | 9 ++ src/locales/ko.json | 9 ++ 5 files changed, 164 insertions(+), 30 deletions(-) diff --git a/src/hooks/common/useMasterController.js b/src/hooks/common/useMasterController.js index b46adc81..8eb302d1 100644 --- a/src/hooks/common/useMasterController.js +++ b/src/hooks/common/useMasterController.js @@ -7,7 +7,10 @@ import { atom, useRecoilState, useRecoilValue } from 'recoil' // API 요청을 저장할 모듈 레벨 변수 (Hook 외부) -let roofMaterialPromise = null; +// 전역 Promise 캐시 +const roofMaterialPromiseCache = new Map() +let cacheKey = 'default' + /** * 마스터 컨트롤러 훅 * @returns @@ -22,24 +25,117 @@ export function useMasterController() { * 지붕재 목록 조회 * @returns */ + + const delay = (ms) => new Promise((resolve) => setTimeout(resolve, ms)) + const getRoofMaterialList = async () => { // 1. 이미 진행 중이거나 완료된 Promise가 있으면 그것을 반환 - if (roofMaterialPromise) { - return roofMaterialPromise; + if (roofMaterialPromiseCache.has(cacheKey)) { + console.log('🔄 getRoofMaterialList: Returning cached promise') + return roofMaterialPromiseCache.get(cacheKey) } - // 2. 처음 호출될 때 Promise를 생성하여 변수에 할당 - roofMaterialPromise = get({ url: '/api/v1/master/getRoofMaterialList' }) - .then((res) => { - return res; - }) - .catch((error) => { - // 에러 발생 시 다음 호출을 위해 초기화 - roofMaterialPromise = null; - throw error; - }); + console.log('🚀 getRoofMaterialList: Creating new promise') - return roofMaterialPromise; + const MAX_RETRY = 15 // 15번으로 증가 (일본 지역 대응) + const RETRY_DELAY = 2000 // 2초 간격 + const MAX_BACKOFF = 10000 // 최대 10초까지 지수 백오프 + + const fetchWithRetry = async () => { + let lastError = null + let retryCount = 0 + + for (let attempt = 0; attempt < MAX_RETRY; attempt++) { + try { + // 지수 백오프 적용: 2초, 4초, 8초, ... 최대 10초 + const backoffDelay = Math.min(RETRY_DELAY * Math.pow(2, attempt), MAX_BACKOFF) + if (attempt > 0) { + console.log(`⏳ Waiting ${backoffDelay/1000}s before retry ${attempt + 1}/${MAX_RETRY}`) + await delay(backoffDelay) + } + + console.log(`🌐 API Request ${attempt + 1}/${MAX_RETRY}: /api/v1/master/getRoofMaterialList`) + const res = await get({ url: '/api/v1/master/getRoofMaterialList' }) + console.log('✅ getRoofMaterialList: Success on attempt', attempt + 1) + + // 재시도 중이었으면 성공 메시지 표시 + if (retryCount > 0) { + swalFire({ + icon: 'success', + title: getMessage('server.restored.title'), + text: `${getMessage('server.restored.text')} (${retryCount}${getMessage('server.retries')})`, + timer: 3000, // 메시지 표시 시간 증가 + showConfirmButton: false + }) + } + + return res + } catch (error) { + lastError = error + retryCount++ + + // 500 계열만 기다렸다가 재시도 + const status = error?.response?.status + if (status && status >= 500) { + console.log(`⚠️ getRoofMaterialList: 500 error on attempt ${attempt + 1}, retrying...`) + + // 첫 번째 500 에러에만 메시지 표시 + if (attempt === 0) { + swalFire({ + icon: 'warning', + title: getMessage('server.error.title'), + text: getMessage('server.error.text'), + timer: 4000, // 경고 메시지 시간 증가 + showConfirmButton: false + }) + } + + continue + } + + // 네트워크 에러(ECONNRESET, ETIMEDOUT 등)도 재시도 + if (error.code === 'ECONNRESET' || error.code === 'ETIMEDOUT' || error.code === 'ENOTFOUND') { + console.log(`🌐 Network error on attempt ${attempt + 1}:`, error.code) + if (attempt === 0) { + swalFire({ + icon: 'warning', + title: getMessage('network.error.title'), + text: getMessage('network.error.text'), + timer: 4000, + showConfirmButton: false + }) + } + continue + } + + // 500이나 네트워크 에러가 아니면 즉시 실패 + console.log(`❌ getRoofMaterialList: Non-retryable error on attempt ${attempt + 1}:`, error) + throw error + } + } + + // 모든 재시도 실패 시 + console.log('💥 getRoofMaterialList: All retries failed') + swalFire({ + icon: 'error', + title: getMessage('server.failed.title'), + text: `${getMessage('server.failed.text')} (${MAX_RETRY}${getMessage('server.retries')})`, + confirmButtonText: getMessage('common.ok') + }) + + throw lastError + } + + const promise = fetchWithRetry() + .finally(() => { + // 성공/실패 모두 끝나면 캐시에서 제거 + console.log('🏁 getRoofMaterialList: Promise resolved, removing from cache') + roofMaterialPromiseCache.delete(cacheKey) + }) + + // 캐시에 Promise 저장 + roofMaterialPromiseCache.set(cacheKey, promise) + return promise } /** diff --git a/src/hooks/option/useCanvasSetting.js b/src/hooks/option/useCanvasSetting.js index f80b2902..8f339c47 100644 --- a/src/hooks/option/useCanvasSetting.js +++ b/src/hooks/option/useCanvasSetting.js @@ -153,12 +153,12 @@ export function useCanvasSetting(executeEffect = true) { return } - /** 초 1회만 실행하도록 처리 */ - addRoofMaterials() + // Roof materials are now initialized in Header.jsx to avoid duplicate calls }, [executeEffect]) /** - * 지붕재 초기세팅 + * 지붕재 초기세팅 (fallback - primary initialization is in Header.jsx) + * This function is only called when roof materials are needed but not yet loaded */ const addRoofMaterials = async () => { if (roofMaterials.length !== 0) { @@ -320,12 +320,26 @@ export function useCanvasSetting(executeEffect = true) { // objectNo가 전달되면 사용, 아니면 Recoil state의 correntObjectNo 사용 const targetObjectNo = objectNo || correntObjectNo - // 지붕재 데이터가 없으면 먼저 로드 + // 지붕재 데이터가 없으면 Header.jsx에서 로드될 때까지 기다림 let materials = roofMaterials if (!materials || materials.length === 0) { - logger.log('Waiting for roofMaterials to be loaded...') - materials = await addRoofMaterials() - logger.log('roofMaterials loaded:', materials) + logger.log('Waiting for roofMaterials to be loaded from Header.jsx...') + // 최대 2초간 기다림 (20번 × 100ms) + let waitCount = 0 + while ((!materials || materials.length === 0) && waitCount < 20) { + await new Promise(resolve => setTimeout(resolve, 100)) + materials = roofMaterials + waitCount++ + } + + if (!materials || materials.length === 0) { + logger.log('roofMaterials still not loaded after 2 seconds, proceeding without them') + // 비상시에만 addRoofMaterials 호출 (fallback) + materials = await addRoofMaterials() + logger.log('roofMaterials loaded via fallback:', materials) + } else { + logger.log('roofMaterials loaded from Header.jsx:', materials) + } } try { diff --git a/src/hooks/useAxios.js b/src/hooks/useAxios.js index a5c1708a..a2ddc1dc 100644 --- a/src/hooks/useAxios.js +++ b/src/hooks/useAxios.js @@ -86,15 +86,21 @@ export function useAxios(lang = '') { .get(url, option) .then((res) => res.data) .catch((error) => { - console.error('🚨 GET Error:', { - url, - method: 'GET', - timestamp: new Date().toISOString(), - errorMessage: error.message, - status: error.response?.status, - statusText: error.response?.statusText, - config: error.config - }) + // 500 에러는 재시도되므로 로깅을 줄임 + const status = error?.response?.status + if (status === 500) { + console.warn('🔄 Server Error (500): Will retry...', url) + } else { + console.error('🚨 GET Error:', { + url, + method: 'GET', + timestamp: new Date().toISOString(), + errorMessage: error.message, + status: error.response?.status, + statusText: error.response?.statusText, + config: error.config + }) + } throw error }) } diff --git a/src/locales/ja.json b/src/locales/ja.json index feb9945f..13435c2a 100644 --- a/src/locales/ja.json +++ b/src/locales/ja.json @@ -567,6 +567,15 @@ "common.finish": "完了", "common.ok": "確認", "common.cancel": "キャンセル", + "server.error.title": "サーバーエラー", + "server.error.text": "サーバーで一時的な問題が発生しました。自動的に再試行します...", + "server.restored.title": "サーバー接続復旧", + "server.restored.text": "サーバー接続が復旧しました", + "server.failed.title": "サーバー接続失敗", + "server.failed.text": "サーバー接続に失敗しました。しばらくしてから再度お試しください", + "server.retries": "回再試行", + "network.error.title": "ネットワーク接続不安定", + "network.error.text": "インターネット接続が不安定です。自動的に再試行します...", "commons.west": "西", "commons.east": "東", "commons.south": "南", diff --git a/src/locales/ko.json b/src/locales/ko.json index 7e871e60..e3403f02 100644 --- a/src/locales/ko.json +++ b/src/locales/ko.json @@ -567,6 +567,15 @@ "common.finish": "완료", "common.ok": "확인", "common.cancel": "취소", + "server.error.title": "서버 오류", + "server.error.text": "서버에 일시적인 문제가 발생했습니다. 자동으로 재시도합니다...", + "server.restored.title": "서버 연결 복구", + "server.restored.text": "서버 연결이 복구되었습니다", + "server.failed.title": "서버 연결 실패", + "server.failed.text": "서버 연결에 실패했습니다. 잠시 후 다시 시도해주세요", + "server.retries": "회 재시도", + "network.error.title": "네트워크 연결 불안", + "network.error.text": "인터넷 연결이 불안정합니다. 자동으로 재시도합니다...", "commons.west": "서", "commons.east": "동", "commons.south": "남", -- 2.47.2 From 8d9379c3df56091d378388f9171e76b2a7253e53 Mon Sep 17 00:00:00 2001 From: ysCha Date: Tue, 31 Mar 2026 10:54:18 +0900 Subject: [PATCH 2/5] =?UTF-8?q?=ED=99=95=EB=8C=80=EC=B6=95=EC=86=8C100%?= =?UTF-8?q?=EA=B0=80=EC=9A=B4=EB=8D=B0=EC=A0=95=EB=A0=AC?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/hooks/useCanvasEvent.js | 65 +++++++++++++++++++++++++++++++------ 1 file changed, 55 insertions(+), 10 deletions(-) diff --git a/src/hooks/useCanvasEvent.js b/src/hooks/useCanvasEvent.js index 7808bb5f..0c566020 100644 --- a/src/hooks/useCanvasEvent.js +++ b/src/hooks/useCanvasEvent.js @@ -15,6 +15,7 @@ export function useCanvasEvent() { const [currentObject, setCurrentObject] = useRecoilState(currentObjectState) const canvasSize = useRecoilValue(canvasSizeState) const [canvasZoom, setCanvasZoom] = useRecoilState(canvasZoomState) + const [shouldCenter, setShouldCenter] = useState(false) // 중앙 정렬 플래그 추가 const lengthTextOption = useRecoilValue(fontSelector('lengthText')) const currentMenu = useRecoilValue(currentMenuState) const { changeCorridorDimensionText } = useText() @@ -23,6 +24,17 @@ export function useCanvasEvent() { useEffect(() => { canvas?.setZoom(canvasZoom / 100) }, [canvasZoom]) + + // canvasZoom이 100으로 변경되고 shouldCenter가 true일 때만 중앙 정렬 실행 + useEffect(() => { + if (canvasZoom === 100 && canvas && shouldCenter) { + setTimeout(() => { + zoomToAllObjects() + canvas.renderAll() + setShouldCenter(false) // 실행 후 플래그 초기화 + }, 100) + } + }, [canvasZoom, shouldCenter]) useEffect(() => { attachDefaultEventOnCanvas() @@ -416,15 +428,35 @@ export function useCanvasEvent() { } const handleZoomClear = () => { + setShouldCenter(true) // 중앙 정렬 플래그 설정 setCanvasZoom(100) - - zoomToAllObjects() - canvas.renderAll() } const zoomToAllObjects = () => { - const objects = canvas.getObjects().filter((obj) => obj.visible) - if (objects.length === 0) return + const allObjects = canvas.getObjects() + // 주요 도형 객체만 필터링 (roof, arrow 등) + const objects = allObjects.filter((obj) => { + // 주요 도형 객체만 포함 + return obj.name === 'roof' || + obj.name === 'arrow' || + obj.name === 'module' || + obj.name === 'dormer' || + obj.name === 'object' + }) + + console.log('🔍 All objects:', allObjects.length) + console.log('🔍 Main objects for centering:', objects.length) + console.log('🔍 Main object details:', objects.map(obj => ({ + name: obj.name, + type: obj.type, + visible: obj.visible, + id: obj.id + }))) + + if (objects.length === 0) { + console.log('❌ No main objects found for centering') + return + } let minX = Infinity, minY = Infinity @@ -439,11 +471,24 @@ export function useCanvasEvent() { maxY = Math.max(maxY, bounds.top + bounds.height) }) - const centerX = (minX + maxX) / 2 - const centerY = (minY + maxY) / 2 - const centerPoint = new fabric.Point(centerX, centerY) - - canvas.zoomToPoint(centerPoint, 1) + const objectsCenterX = (minX + maxX) / 2 + const objectsCenterY = (minY + maxY) / 2 + + // 화면 중앙 좌표 (메뉴 높이 고려하여 Y 위치 조정) + const screenCenterX = canvas.getWidth() / 2 + const menuHeightOffset = 80 // 위쪽 메뉴 높이만큼 위로 조정 + const screenCenterY = canvas.getHeight() / 2 - menuHeightOffset + + // 객체 중심을 화면 중앙으로 이동 + const deltaX = screenCenterX - objectsCenterX + const deltaY = screenCenterY - objectsCenterY + + // canvas viewport transform 조정 + const viewportTransform = canvas.viewportTransform + viewportTransform[4] += deltaX + viewportTransform[5] += deltaY + + canvas.setViewportTransform(viewportTransform) canvas.renderAll() } -- 2.47.2 From bb775b2847a7727cbe85f0df8adf6a669c957e12 Mon Sep 17 00:00:00 2001 From: ysCha Date: Tue, 31 Mar 2026 16:27:26 +0900 Subject: [PATCH 3/5] =?UTF-8?q?errSave=20api=20=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/hooks/common/useMasterController.js | 5 +++++ src/hooks/module/useTrestle.js | 13 +++++++++++-- 2 files changed, 16 insertions(+), 2 deletions(-) diff --git a/src/hooks/common/useMasterController.js b/src/hooks/common/useMasterController.js index 8eb302d1..36f5bd61 100644 --- a/src/hooks/common/useMasterController.js +++ b/src/hooks/common/useMasterController.js @@ -359,6 +359,10 @@ export function useMasterController() { }) } + const quotationErrSave = async (params) => { + return await post({ url: '/api/v1/master/quotationErrSave', data: params }) + } + return { getRoofMaterialList, getModuleTypeItemList, @@ -374,5 +378,6 @@ export function useMasterController() { updateObjectDate, getQuotationItem, getPcsConnOptionItemList, + quotationErrSave, } } diff --git a/src/hooks/module/useTrestle.js b/src/hooks/module/useTrestle.js index 173a5cd2..19da5b4e 100644 --- a/src/hooks/module/useTrestle.js +++ b/src/hooks/module/useTrestle.js @@ -1,5 +1,5 @@ import { useRecoilValue } from 'recoil' -import { canvasState, currentAngleTypeSelector } from '@/store/canvasAtom' +import { canvasState, currentAngleTypeSelector, currentCanvasPlanState } from '@/store/canvasAtom' import { POLYGON_TYPE, TRESTLE_MATERIAL } from '@/common/common' import { moduleSelectionDataState } from '@/store/selectedModuleOptions' import { getDegreeByChon } from '@/util/canvas-util' @@ -11,6 +11,7 @@ import { useContext } from 'react' import { QcastContext } from '@/app/QcastProvider' import { useCircuitTrestle } from '@/hooks/useCirCuitTrestle' import { useMessage } from '@/hooks/useMessage' +import { loginUserStore } from '@/store/commonAtom' // 모듈간 같은 행, 열의 마진이 10 이하인 경우는 같은 행, 열로 간주 const MODULE_MARGIN = 10 @@ -18,7 +19,9 @@ const MODULE_MARGIN = 10 export const useTrestle = () => { const canvas = useRecoilValue(canvasState) const moduleSelectionData = useRecoilValue(moduleSelectionDataState) //다음으로 넘어가는 최종 데이터 - const { getQuotationItem } = useMasterController() + const { getQuotationItem, quotationErrSave } = useMasterController() + const currentCanvasPlan = useRecoilValue(currentCanvasPlanState) + const loginUserState = useRecoilValue(loginUserStore) const currentAngleType = useRecoilValue(currentAngleTypeSelector) const roofSizeSet = useRecoilValue(basicSettingState).roofSizeSet const isTrestleDisplay = useRecoilValue(trestleDisplaySelector) @@ -757,6 +760,12 @@ export const useTrestle = () => { text: result.resultMsg || getMessage('common.message.noData'), icon: 'error' }) + quotationErrSave({ + objectNo: currentCanvasPlan.objectNo, + planNo: currentCanvasPlan.planNo, + rejectRsn: result.resultMsg, + regId: loginUserState.userId, + }) clear() setViewCircuitNumberTexts(true) setIsGlobalLoading(false) -- 2.47.2 From 984401881d17f65504daeb5a7abbcf53b79053e8 Mon Sep 17 00:00:00 2001 From: ysCha Date: Tue, 31 Mar 2026 16:34:54 +0900 Subject: [PATCH 4/5] =?UTF-8?q?=ED=99=95=EB=8C=80=EC=B6=95=EC=86=8C100%?= =?UTF-8?q?=EA=B0=80=EC=9A=B4=EB=8D=B0=EC=A0=95=EB=A0=AC?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/hooks/useCanvasEvent.js | 28 ++++++++++++++++++++++++++++ 1 file changed, 28 insertions(+) diff --git a/src/hooks/useCanvasEvent.js b/src/hooks/useCanvasEvent.js index 0c566020..0a1de7c4 100644 --- a/src/hooks/useCanvasEvent.js +++ b/src/hooks/useCanvasEvent.js @@ -489,6 +489,34 @@ export function useCanvasEvent() { viewportTransform[5] += deltaY canvas.setViewportTransform(viewportTransform) + + // 브라우저 스크롤을 중앙으로 조정하여 전체 canvas를 모니터 중앙에 위치 + const canvasElement = canvas.getElement() + const canvasRect = canvasElement.getBoundingClientRect() + + // 현재 스크롤 위치 + const currentScrollTop = window.pageYOffset || document.documentElement.scrollTop + const currentScrollLeft = window.pageXOffset || document.documentElement.scrollLeft + + // canvas의 현재 화면 위치 + const canvasScreenTop = canvasRect.top + currentScrollTop + const canvasScreenLeft = canvasRect.left + currentScrollLeft + + // 모니터 중앙 + const monitorCenterX = window.innerWidth / 2 + const monitorCenterY = window.innerHeight / 2 + + // canvas가 모니터 중앙에 오도록 스크롤 위치 계산 + const targetScrollLeft = canvasScreenLeft + (canvasRect.width / 2) - monitorCenterX + const targetScrollTop = canvasScreenTop + (canvasRect.height / 2) - monitorCenterY + menuHeightOffset + + // 스크롤을 중앙으로 이동 + window.scrollTo({ + top: targetScrollTop, + left: targetScrollLeft, + behavior: 'smooth' // 부드러운 스크롤 + }) + canvas.renderAll() } -- 2.47.2 From 44aca56d7565b6508b2235f01aec94bdd9e0139f Mon Sep 17 00:00:00 2001 From: ysCha Date: Wed, 1 Apr 2026 11:28:28 +0900 Subject: [PATCH 5/5] =?UTF-8?q?=EB=AC=BC=EA=B1=B4=EA=B2=80=EC=83=89?= =?UTF-8?q?=EC=97=90=201=EC=B0=A8=EC=A0=90=20=EC=88=A8=EA=B9=80(2=EC=B0=A8?= =?UTF-8?q?=EC=A0=90=20=EB=A1=9C=EA=B7=B8=EC=9D=B8)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/components/management/StuffSearchCondition.jsx | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/src/components/management/StuffSearchCondition.jsx b/src/components/management/StuffSearchCondition.jsx index 3774fbfe..85563672 100644 --- a/src/components/management/StuffSearchCondition.jsx +++ b/src/components/management/StuffSearchCondition.jsx @@ -1166,7 +1166,7 @@ export default function StuffSearchCondition() { {getMessage('stuff.search.schSelSaleStoreId')}
-
+
{session?.storeId === 'T01' && ( )}
-
+