diff --git a/.gitignore b/.gitignore index ee2cfd052..871b209f0 100644 --- a/.gitignore +++ b/.gitignore @@ -29,4 +29,5 @@ settings.json .agent apps/**/tsconfig.tsbuildinfo +packages/**/tsconfig.tsbuildinfo logs/ diff --git a/apps/cs/app/(main)/(with-base-layout)/layout.tsx b/apps/cs/app/(main)/(with-base-layout)/layout.tsx index acc583e2e..a83a524b0 100644 --- a/apps/cs/app/(main)/(with-base-layout)/layout.tsx +++ b/apps/cs/app/(main)/(with-base-layout)/layout.tsx @@ -6,7 +6,7 @@ import { BaseLayout } from "@common/components"; import type { MenuItem } from "@/entities/menu"; import { useMenuStore } from "@/shared/lib"; import { PageLayout } from "@/shared/ui/page-layout"; -import { tokenManager } from "@/entities/auth"; +import { authService } from "@/entities/auth"; export default function WithBaseLayout({ children, @@ -27,7 +27,7 @@ export default function WithBaseLayout({ return ( diff --git a/apps/cs/src/entities/auth/api/auth-service.ts b/apps/cs/src/entities/auth/api/auth-service.ts index 9837aac8e..ef08d8ee1 100644 --- a/apps/cs/src/entities/auth/api/auth-service.ts +++ b/apps/cs/src/entities/auth/api/auth-service.ts @@ -1,5 +1,6 @@ import { ACCESS_TOKEN, + AUTH_LOGIN_REDIRECT_URL, AUTH_USER_ID, CLAIM_NAME, ENDPOINT, @@ -7,9 +8,10 @@ import { import { getRemainingMs } from "@common/auth"; import { - clearAuthToken, + removeToken, setToken, setupRefreshTimer, + tokenManager, } from "../lib/token-manager"; import type { AuthPayload, AuthService } from "../model/auth-interface"; @@ -90,7 +92,7 @@ async function silentRefresh(): Promise { `[silentRefresh] Failed at ${new Date().toISOString()}`, error, ); - clearAuthToken(); + tokenManager.clearAuthToken(); if (typeof window !== "undefined") { window.dispatchEvent(new CustomEvent("auth:logout")); } @@ -99,9 +101,22 @@ async function silentRefresh(): Promise { } async function logout(): Promise { - clearAuthToken(); + try { + await fetch("/api/auth/logout", { + method: "POST", + credentials: "include", + }); + } catch (error) { + console.error("Logout API failed:", error); + } + + tokenManager.clearAuthToken(); + removeToken("site-id-key"); + removeToken("site-theme-key"); + if (typeof window !== "undefined") { window.dispatchEvent(new CustomEvent("auth:logout")); + window.location.href = AUTH_LOGIN_REDIRECT_URL; } } diff --git a/apps/cs/src/entities/auth/lib/token-manager.ts b/apps/cs/src/entities/auth/lib/token-manager.ts index 44aa888aa..89f0c7371 100644 --- a/apps/cs/src/entities/auth/lib/token-manager.ts +++ b/apps/cs/src/entities/auth/lib/token-manager.ts @@ -1,5 +1,5 @@ -import { createTokenManager } from "@common/auth"; import { authConfig } from "@/shared/config/auth"; +import { createTokenManager } from "@common/auth"; export const tokenManager = createTokenManager(authConfig); @@ -8,7 +8,6 @@ export const { setToken, removeToken, setupRefreshTimer, - clearAuthToken, getAccessToken, getRefreshToken, getUserId, diff --git a/apps/cs/src/entities/auth/model/auth-interface.ts b/apps/cs/src/entities/auth/model/auth-interface.ts index c4b2d2691..97078052e 100644 --- a/apps/cs/src/entities/auth/model/auth-interface.ts +++ b/apps/cs/src/entities/auth/model/auth-interface.ts @@ -1,10 +1 @@ -export interface AuthPayload { - userId: string; - password: string; -} - -export interface AuthService { - login(payload: AuthPayload): Promise; - silentRefresh(): Promise; - logout(): Promise; -} +export type { AuthPayload, AuthService } from "@common/auth"; diff --git a/apps/cs/src/shared/config/auth.ts b/apps/cs/src/shared/config/auth.ts index d903525af..7c35ad9f0 100644 --- a/apps/cs/src/shared/config/auth.ts +++ b/apps/cs/src/shared/config/auth.ts @@ -1,4 +1,5 @@ import type { AuthConfig } from "@common/auth"; + import { ACCESS_TOKEN, AUTH_USER_ID, diff --git a/apps/dm/.env.local b/apps/dm/.env.local index 7e0ec15fc..4cef9d965 100644 --- a/apps/dm/.env.local +++ b/apps/dm/.env.local @@ -9,16 +9,15 @@ NEXT_PUBLIC_CM_LOGIN_PAGE_URL = http://dev.onasys.co.kr:3000/auth/login NEXT_PUBLIC_SERVER_API_URL = http://dev.onasys.co.kr:8000 NEXT_PUBLIC_CS_SERVER_API_URL = http://dev.onasys.co.kr:8000 NEXT_PUBLIC_DM_SERVER_API_URL = http://con.mind-one.co.kr:11083/reuse-service/api/v1 -# NEXT_PUBLIC_FO_SERVER_API_URL = http://192.168.1.222:8883/operation-service/api/v1 -NEXT_PUBLIC_FO_SERVER_API_URL = http://con.mind-one.co.kr:11083/operation-service/api/v1 -NEXT_PUBLIC_WR_SERVER_API_URL = http://con.mind-one.co.kr:11083/reuse-service/api/v1 +# NEXT_PUBLIC_FO_SERVER_API_URL = http://con.mind-one.co.kr:11083/operation-service/api/v1 +# NEXT_PUBLIC_WR_SERVER_API_URL = http://con.mind-one.co.kr:11083/reuse-service/api/v1 NEXT_PUBLIC_GEOSERVER_URL = http://rsone.iptime.org:10010/geoserver NEXT_PUBLIC_LX_GEOSERVER_URL = http://con.mind-one.co.kr:11306/geoserver NEXT_PUBLIC_CS_TUS_UPLOAD = /attach-service/api/v1/tus/upload NEXT_PUBLIC_NGII_URL = https://map.ngii.go.kr NEXT_PUBLIC_NGII_PARAM = 04trYP9_xwLAfALjwZ-B8g&service=WMTS&request=GetTile&version=1.0.0&format=image/png&layer=korean_map&style=korean&tilematrixset=korean&tilematrix={z}&tilecol={x}&tilerow={y} NEXT_PUBLIC_GATEWAY_URL=http://con.mind-one.co.kr:11083 # 개발 서버 API -# NEXT_PUBLIC_GATEWAY_URL=http://localhost:8883 # 개발 서버 API +# NEXT_PUBLIC_GATEWAY_URL=http://localhost:8883 # local CONTENTS_INTRO = 31 CONTENTS_EXAMPLE = 32 diff --git a/apps/dm/app/(popup-layout)/map/popup/page.tsx b/apps/dm/app/(popup-layout)/map/popup/page.tsx index bdce68a87..f31c49bca 100644 --- a/apps/dm/app/(popup-layout)/map/popup/page.tsx +++ b/apps/dm/app/(popup-layout)/map/popup/page.tsx @@ -133,7 +133,9 @@ const Map = () => { //수요처 ID const urlParams = new URLSearchParams(window.location.search); const dmdInfoId = urlParams.get("dmdInfoId"); + const dmdCoSn = urlParams.get("dmdCoSn"); console.log("[하이라이트] dmdInfoId:", dmdInfoId); + console.log("[하이라이트] dmdCoSn:", dmdCoSn); //수요처 위치로 화면 이동 const maps = gisMaps.current; @@ -207,7 +209,7 @@ const Map = () => { { width: 30 } ); } else { - console.warn("수요처의 좌표정보를 찾을 수 없습니다. dmdInfoId:", dmdInfoId); + console.warn("수요처의 좌표정보를 찾을 수 없습니다. dmdInfoId:", dmdInfoId, "dmdCoSn:", dmdCoSn); } const formatNumber = (val: any) => { diff --git a/apps/dm/app/(with-layout)/apply/dmdco/page.tsx b/apps/dm/app/(with-layout)/apply/dmdco/page.tsx index a450e8582..86262be44 100644 --- a/apps/dm/app/(with-layout)/apply/dmdco/page.tsx +++ b/apps/dm/app/(with-layout)/apply/dmdco/page.tsx @@ -15,6 +15,7 @@ import { getCookie } from "@common/components/lib"; type Option = { value: string; label: string }; type SessionUser = { + dmdCoSn?: number; dmdInfoId?: string; dmdCoNm?: string; zip?: string; @@ -311,10 +312,8 @@ export default function DemandPlaceRegisterPage() { } } if (!password) return alert("기존 비밀번호를 입력해 주세요."); - - const fullDmdInfoId = bizDigits + (noWorkerNo ? "0000" : workerDigits); const payload = { - dmdInfoId: fullDmdInfoId, + dmdCoSn: editMode ? sessionUser?.dmdCoSn : undefined, dmdAplyPswd: password, }; @@ -411,6 +410,7 @@ export default function DemandPlaceRegisterPage() { const myUUID = getCookie(TOKEN_ID_COOKIE) || FALLBACK_UUID; const payload: Record = { + dmdCoSn: editMode ? sessionUser?.dmdCoSn : undefined, ctpvCd: sido, sggCd: sigungu, dmdCoNm: company.trim(), @@ -443,12 +443,14 @@ export default function DemandPlaceRegisterPage() { alert("수요처 수정 완료!"); } else { - await postFetch("/reuseFc/request", payload); + const response = await postFetch("/reuseFc/request", payload); + const responseJson = await response.json().catch(() => null); + const savedUser = responseJson?.data ?? payload; if (typeof window !== "undefined") { const nextSessionUser = { ...(sessionUser ?? {}), - ...payload, + ...savedUser, }; delete nextSessionUser.dmdAplyPswd; sessionStorage.setItem("user", JSON.stringify(nextSessionUser)); @@ -526,34 +528,7 @@ export default function DemandPlaceRegisterPage() {

- - { - setSido(v); - setSigungu(""); - }} - options={sidoOptions} - /> - - - - - - - - - +
@@ -633,7 +608,34 @@ export default function DemandPlaceRegisterPage() { + + { + setSido(v); + setSigungu(""); + }} + options={sidoOptions} + /> + + + + + + + +
{ e.preventDefault(); e.stopPropagation(); - const dmdInfoId = JSON.parse( + const sessionUser = JSON.parse( sessionStorage.getItem("user") || "{}" - )?.dmdInfoId?.toString(); + ) as { dmdInfoId?: string; dmdCoSn?: number }; + const dmdInfoId = sessionUser?.dmdInfoId?.toString() ?? ""; + const dmdCoSn = sessionUser?.dmdCoSn?.toString() ?? ""; - const url = `/map/popup?dmdInfoId=${encodeURIComponent(dmdInfoId)}`; + const url = `/map/popup?dmdInfoId=${encodeURIComponent(dmdInfoId)}&dmdCoSn=${encodeURIComponent(dmdCoSn)}`; const width = window.screen.availWidth; const height = window.screen.availHeight; const left = 0; diff --git a/apps/dm/app/(with-layout)/apply/status/page.tsx b/apps/dm/app/(with-layout)/apply/status/page.tsx index 40241ee4f..bad342e20 100644 --- a/apps/dm/app/(with-layout)/apply/status/page.tsx +++ b/apps/dm/app/(with-layout)/apply/status/page.tsx @@ -11,7 +11,7 @@ import { useEffect, useMemo, useRef, useState } from "react"; type TopField = { label: string; key: string, value: string; required?: boolean }; type PairField = { label: string; key: string, value: string }; -type SessionUser = { dmdInfoId?: string }; +type SessionUser = { dmdInfoId?: string; dmdCoSn?: number }; const PAGE_SIZE = 10; @@ -315,11 +315,13 @@ export default function DemandStatusPage() { const searchCandidate = (e: React.MouseEvent) => { e.preventDefault(); e.stopPropagation(); - const dmdInfoId = JSON.parse( + const sessionUser = JSON.parse( sessionStorage.getItem("user") || "{}" - )?.dmdInfoId?.toString(); + ) as SessionUser; + const dmdInfoId = sessionUser?.dmdInfoId?.toString() ?? ""; + const dmdCoSn = sessionUser?.dmdCoSn?.toString() ?? ""; - const url = `/map/popup?dmdInfoId=${encodeURIComponent(dmdInfoId)}`; + const url = `/map/popup?dmdInfoId=${encodeURIComponent(dmdInfoId)}&dmdCoSn=${encodeURIComponent(dmdCoSn)}`; const width = window.screen.availWidth; const height = window.screen.availHeight; const left = 0; diff --git a/apps/dm/app/store/sdmMng/index.tsx b/apps/dm/app/store/sdmMng/index.tsx index de345f0d7..0041f5a93 100644 --- a/apps/dm/app/store/sdmMng/index.tsx +++ b/apps/dm/app/store/sdmMng/index.tsx @@ -10,6 +10,7 @@ import { z } from "zod"; =====================================*/ export const sdmInfoFormSchema = z.object({ + dmdCoSn: z.number().optional(), dmdInfoId: z.string().optional(), dmndTmng: z.date().optional(), dmndQty: z.number().min(1, "요구수량을 입력해주세요"), @@ -46,6 +47,7 @@ export type SdmInfoFormData = z.infer; // 수요처 정보 export type DemandCompBizType = { index: number | ''; + dmdCoSn?: number | ''; dmdInfoId: string | ''; dmdInfoFk: string | ''; bzmnNo: string | ''; @@ -443,8 +445,10 @@ export const upHeadSdmInfoStatus: TableUpperProps[] = [ // 수요처 등록 const postSdmData = async (data: DemandCompType) => { try { - await postFetch("/reuseFc/request", data); - sessionStorage.setItem("user", JSON.stringify(data)); + const response = await postFetch("/reuseFc/request", data); + const responseJson = await response.json().catch(() => null); + const savedUser = responseJson?.data ?? data; + sessionStorage.setItem("user", JSON.stringify(savedUser)); toast.success("수요처 등록에 성공하였습니다."); return true; } catch (error) { @@ -487,6 +491,7 @@ const postSdmLoginData = async (data: DemandCompType) => { const status = jsonData.status; const loginData = jsonData.data; + // console.log(loginData); let loginSuccess = false; if (status === 200 && loginData != null) { alert("수요처 로그인에 성공하였습니다."); @@ -683,6 +688,7 @@ export const SdmReducer = (set: any): SdmActions => { return ({ ...el, index: ++index, + dmdCoSn: res.dmdCoSn, bzmnNo: res.bzmnNo, dmdCoNm: res.dmdCoNm, dmdInfoId: res.dmdInfoId, diff --git a/apps/wr/app/(with-layout)/matMng/coMng/coSttn/page.tsx b/apps/wr/app/(with-layout)/matMng/coMng/coSttn/page.tsx index 894a27c71..b56e7a230 100644 --- a/apps/wr/app/(with-layout)/matMng/coMng/coSttn/page.tsx +++ b/apps/wr/app/(with-layout)/matMng/coMng/coSttn/page.tsx @@ -176,7 +176,11 @@ export default function CoSttn() { const dmdInfoId = updateData.dmdInfoId; const dmdInfoSliceId = `${dmdInfoId.slice(0, 3)}-${dmdInfoId.slice(3, 5)}-${dmdInfoId.slice(5, 10)}-${dmdInfoId.slice(10)}` const handleConfirm = async () => { - const message = await coSttnPwInit(dmdInfoId); + if (!updateData?.dmdCoSn) { + alert("수요처 식별값이 없어 비밀번호를 초기화할 수 없습니다."); + return; + } + const message = await coSttnPwInit(Number(updateData.dmdCoSn)); alert(message); }; if (await confirm(t('isInitPw', { dmdInfoId: dmdInfoSliceId }))) { @@ -198,7 +202,7 @@ export default function CoSttn() { ? objSearch.sgg : objSearch.city; - if (updateData?.dmdInfoId) { // 수정 + if (updateData?.dmdCoSn) { // 수정 const message = await updateDemandCompany(data); alert(message); coSttnInit("1", "13", String(con)); @@ -338,7 +342,16 @@ export default function CoSttn() { ? objSearch.sgg : objSearch.city; setPageNation(page); - coSttnSearch(String(page), "13", String(con)); + if (seartchTxt && seartchTxt.trim().length > 0) { + coSttnTxtSearch( + String(page), + "13", + String(con), + seartchTxt + ); + } else { + coSttnSearch(String(page), "13", String(con)); + } }} resetSelection={isResetSelection} widthFixed={true} diff --git a/apps/wr/app/(with-layout)/ruseFcltMng/fcltReg/htdflReg/page.tsx b/apps/wr/app/(with-layout)/ruseFcltMng/fcltReg/htdflReg/page.tsx index 84f6ad87e..0dc03cc66 100644 --- a/apps/wr/app/(with-layout)/ruseFcltMng/fcltReg/htdflReg/page.tsx +++ b/apps/wr/app/(with-layout)/ruseFcltMng/fcltReg/htdflReg/page.tsx @@ -80,7 +80,11 @@ export default function Home() { }; useEffect(() => { - getHtdflBdstData(pageNation, laeSeCd); + if (searchTxt && searchTxt.trim().length > 0) { + htdflNmSearch(pageNation, laeSeCd, searchTxt); + } else { + getHtdflBdstData(pageNation, laeSeCd); + } setResetPageNo(false); }, [pageNation]); @@ -113,7 +117,7 @@ export default function Home() { className={btnClass} onClick={() => { if (searchTxt && searchTxt.length > 0) { - htdflNmSearch(String(0), searchTxt); + htdflNmSearch(1, String(0), searchTxt); } else { initHtdflReg(String(0)); } diff --git a/apps/wr/app/(with-layout)/ruseFcltMng/fcltReg/rainReg/page.tsx b/apps/wr/app/(with-layout)/ruseFcltMng/fcltReg/rainReg/page.tsx index cdbee61c3..37fcab7fc 100644 --- a/apps/wr/app/(with-layout)/ruseFcltMng/fcltReg/rainReg/page.tsx +++ b/apps/wr/app/(with-layout)/ruseFcltMng/fcltReg/rainReg/page.tsx @@ -77,6 +77,7 @@ export default function Home() { archArea: formatArchArea(item?.archArea, 1), bdstChgYn: item.chgBuilding[0]?.bdstChgNm != null ? "Y" : "N", frstArchPrmsnDt: item?.frstArchPrmsnDt || "확인불가", + lawTrgtYn: item.reuseBuilding[0]?.lawTrgtYn ?? (item as any).lawTrgtYn, cityText: item.ctpvCd === 0 ? "세종특별자치시" : (item as any).cityText, sggText: (item as any).sggText === "없음" ? "-" : (item as any).sggText, }; @@ -97,7 +98,11 @@ export default function Home() { }; useEffect(() => { - getRainData(pageNation, laeSeCd); + if (searchTxt && searchTxt.trim().length > 0) { + rainSearch(searchTxt, pageNation, laeSeCd); + } else { + getRainData(pageNation, laeSeCd); + } setResetPageNo(false); }, [pageNation]); diff --git a/apps/wr/app/(with-layout)/ruseFcltMng/fcltReg/rswtReg/page.tsx b/apps/wr/app/(with-layout)/ruseFcltMng/fcltReg/rswtReg/page.tsx index b8e7e7727..8cdfa1866 100644 --- a/apps/wr/app/(with-layout)/ruseFcltMng/fcltReg/rswtReg/page.tsx +++ b/apps/wr/app/(with-layout)/ruseFcltMng/fcltReg/rswtReg/page.tsx @@ -73,8 +73,9 @@ export default function Home() { return { ...item, archArea: formatArchArea(item?.archArea, 1), - bdstChgYn: item.chgBuilding[0]?.bdstChgNm != null ? "Y" : "N", + chBuildingYn: item.chgBuilding[0]?.bdstChgNm != null ? "Y" : "N", bdstUsgCd: item.reuseBuilding[0]?.bdstUsgCd, + instlCmptnDt: item.reuseBuilding[0]?.instlCmptnDt, cityText: item.ctpvCd === 0 ? "세종특별자치시" : (item as any).cityText, sggText: (item as any).sggText === "없음" ? "-" : (item as any).sggText, }; @@ -107,7 +108,11 @@ export default function Home() { }; useEffect(() => { - getRswtBdstData(pageNation,laeSeCd); + if (searchTxt && searchTxt.trim().length > 0) { + rswtBdstSearch(searchTxt, pageNation, laeSeCd); + } else { + getRswtBdstData(pageNation,laeSeCd); + } setResetPageNo(false); }, [pageNation]); diff --git a/apps/wr/app/store/common/searchCode/index.ts b/apps/wr/app/store/common/searchCode/index.ts index f1c46820d..ac7160865 100644 --- a/apps/wr/app/store/common/searchCode/index.ts +++ b/apps/wr/app/store/common/searchCode/index.ts @@ -14,6 +14,7 @@ export const comFcltStateExport: comFcltState = { demandCompList: [], checkDemand: { index: 0, + dmdCoSn: "", dmdInfoId: "", bzmnNo: "", dmdCoNm: "", diff --git a/apps/wr/app/store/matDmd/coMatRvw/index.tsx b/apps/wr/app/store/matDmd/coMatRvw/index.tsx index afeb36fc1..9e6aaecd7 100644 --- a/apps/wr/app/store/matDmd/coMatRvw/index.tsx +++ b/apps/wr/app/store/matDmd/coMatRvw/index.tsx @@ -104,7 +104,7 @@ export const coRvwActionsExport: (set: any) => coRvwActions = (set: any) => { // 수요처 매칭 검토 저장 coMatRvwUpdate: async ( splyCd: string, // 공공기관재사용시설아이디 - 하수재이용시설id : pbnst_ruse_fclt_id - dmdCd: string, // 수요정보id dmd_info_id + dmdCd: string, // 수요처일련번호 dmd_co_sn data: DedMatUpdateType ): Promise => { try { diff --git a/apps/wr/app/store/matDmd/coMatRvw/type.tsx b/apps/wr/app/store/matDmd/coMatRvw/type.tsx index a505777ff..802e6a8f7 100644 --- a/apps/wr/app/store/matDmd/coMatRvw/type.tsx +++ b/apps/wr/app/store/matDmd/coMatRvw/type.tsx @@ -2,6 +2,7 @@ import type { ChkClass } from "@common/business_components/ui"; import { z } from "zod"; const DemandInfoSchema = z.object({ + dmdCoSn: z.number().nullish(), dmdInfoSn: z.number(), dmdInfoId: z.string().nullish(), dmndTmng: z.coerce.date().nullish(), @@ -23,6 +24,7 @@ const DemandInfoSchema = z.object({ }); const DemandMatchingSchema = z.object({ + dmdCoSn: z.number().nullish(), dmdInfoId: z.string().nullish(), pbnstRuseFcltId: z.string().nullish(), splyFcltFlg: z.string().nullish(), @@ -39,6 +41,7 @@ const DemandMatchingSchema = z.object({ }); const DemandCoSchema = z.object({ + dmdCoSn: z.number().nullish(), dmdInfoId: z.string().nullish(), dmdCoNm: z.string().nullish(), ctpvCd: z.string().nullish(), @@ -68,6 +71,7 @@ const DemandCstSchema = z.object({ }); export const DmdCoReqSchema = z.object({ + dmdCoSn: z.number().nullish(), // 수요처일련번호 cityText: z.string().nullish(), // 시도 sggText: z.string().nullish(), // 시군구 dmdInfoSn: z.number().default(0), // 수요정보 일련번호 @@ -115,6 +119,7 @@ export type DmdMatTableType = { }; export type DedMatUpdateType = { + dmdCoSn?: number; dmdInfoSn: number; usgPrcsProcs: string; cstrnCst: number; diff --git a/apps/wr/app/store/matDmd/coSttn/index.tsx b/apps/wr/app/store/matDmd/coSttn/index.tsx index ca610c381..aa34f13a3 100644 --- a/apps/wr/app/store/matDmd/coSttn/index.tsx +++ b/apps/wr/app/store/matDmd/coSttn/index.tsx @@ -24,7 +24,7 @@ type coSttnActions = { coSttnInit: (pageNo: string, pageCnt: string, con: string) => void; coSttnSearch: (pageNo: string, pageCnt: string, con: string) => void; coSttnTxtSearch: (pageNo: string, pageCnt: string, con: string, txt: string) => void; - coSttnPwInit: (dmdInfoId: string) => Promise; + coSttnPwInit: (dmdCoSn: number) => Promise; }; export type coSttnType = coSttnState & coSttnActions; @@ -122,9 +122,9 @@ export const coSttnActionsExport: (set: any) => coSttnActions = ( console.log("error", erTxt); } }, - coSttnPwInit: async (dmdInfoId: string) => { + coSttnPwInit: async (dmdCoSn: number) => { try { - const response = await putFectch(`/reuseFc/request/chgPassword/${dmdInfoId}`,[]) + const response = await putFectch(`/reuseFc/request/chgPassword/${dmdCoSn}`,[]) if (response.status === 200) { return "비밀번호가 초기화되었습니다."; } else { diff --git a/apps/wr/app/store/ruseFcltMng/fcltReg/htdflReg/index.tsx b/apps/wr/app/store/ruseFcltMng/fcltReg/htdflReg/index.tsx index 9f1901f07..d478d363c 100644 --- a/apps/wr/app/store/ruseFcltMng/fcltReg/htdflReg/index.tsx +++ b/apps/wr/app/store/ruseFcltMng/fcltReg/htdflReg/index.tsx @@ -99,7 +99,7 @@ export type htdflFacState = { }; type htdflFacSActions = { initHtdflReg: (code: string) => void; - htdflNmSearch: (code: string | number, fcltNm:string) => void; + htdflNmSearch: (pageNation: number, code: string | number, fcltNm:string) => void; htdflBdstUpdate: (data: customHtdflBdst, code: string | number,myUUID:string) => Promise; htdflBdstReg: (data: customHtdflBdst, code: string | number,myUUID:string) => Promise; htdflBdstDelete: (data: customHtdflBdst, code: string | number) => Promise; @@ -250,9 +250,9 @@ export const htdflFacActionsExport: (set: any) => htdflFacSActions = ( } finally { } }, - htdflNmSearch :async(code:string | number, fcltNm:string) =>{ + htdflNmSearch :async(pageNation: number, code:string | number, fcltNm:string) =>{ try { - const htdflRes = await getFetchWithCount("wr", `/drain/facilityList/1/${DEFAULT_LIST_SIZE}/${code}/${fcltNm}`); + const htdflRes = await getFetchWithCount("wr", `/drain/facilityList/${pageNation}/${DEFAULT_LIST_SIZE}/${code}/${fcltNm}`); set((state: htdflFacState) => { return { ...state, diff --git a/apps/wr/app/store/ruseFcltMng/fcltReg/pbnstReg/type.tsx b/apps/wr/app/store/ruseFcltMng/fcltReg/pbnstReg/type.tsx index 3a9756693..e35e5df67 100644 --- a/apps/wr/app/store/ruseFcltMng/fcltReg/pbnstReg/type.tsx +++ b/apps/wr/app/store/ruseFcltMng/fcltReg/pbnstReg/type.tsx @@ -44,6 +44,7 @@ export type ruseFcltType = { }; export type PbnstDemandComType = { + dmdCoSn?: number | ""; dmdInfoId: string | ""; bzmnNo: string | ""; dmdCoNm: string | ""; @@ -143,6 +144,7 @@ const fcOrgwtSchema = z.object({ }); const dmdColist = z.object({ + dmdCoSn: zNumNullish, dmdInfoId: z.string().default(""), dmdCoNm: z.string().default(""), daddr: z.string(), @@ -259,9 +261,9 @@ const PrfSchema = z.object({ farmUswtr: z.number(), ugwtChr: z.number(), indstUswtr: z.number(), - regDt: z.date().default(new Date()).nullish(), + regDt: zDateNullish, rgtr: z.string().nullish(), - mdfcnDt: z.date().default(new Date()).nullish(), + mdfcnDt: zDateNullish, mdfr: z.string().nullish(), }); diff --git a/apps/wr/app/store/ruseFcltMng/fcltReg/rainReg/index.tsx b/apps/wr/app/store/ruseFcltMng/fcltReg/rainReg/index.tsx index 29c8e529d..5cc1c6f5a 100644 --- a/apps/wr/app/store/ruseFcltMng/fcltReg/rainReg/index.tsx +++ b/apps/wr/app/store/ruseFcltMng/fcltReg/rainReg/index.tsx @@ -73,7 +73,7 @@ export const upHeadList: TableUpperProps[] = [ { id: "sggText", title: "시군구", widthPercent: 8 }, { id: "bdstNm", title: "시설명", widthPercent: 15 }, { id: "daddr", title: "위치", widthPercent: 22 }, - { id: "bdsgUsgText", title: "건축물용도", widthPercent: 12 }, + { id: "bdstUsgText", title: "건축물용도", widthPercent: 12 }, { id: "frstArchPrmsnDt", title: "최초\n건축허가일", widthPercent: 9 }, { id: "archArea", title: "건축면적(㎡)", widthPercent: 9 }, { id: "lawTrgtYn", title: "법적의무대상여부", widthPercent: 8 }, diff --git a/apps/wr/app/store/ruseFcltMng/fcltSttn/rainSttn/headList.tsx b/apps/wr/app/store/ruseFcltMng/fcltSttn/rainSttn/headList.tsx index 5eae0cbfc..33bd7d43a 100644 --- a/apps/wr/app/store/ruseFcltMng/fcltSttn/rainSttn/headList.tsx +++ b/apps/wr/app/store/ruseFcltMng/fcltSttn/rainSttn/headList.tsx @@ -16,7 +16,7 @@ export const rainSttHeadListExport: z.infer = [ { id: "sggText", title: "시군구", visible: true, widthPercent: 4 }, { id: "bdstNm", title: "건축물명", visible: true, widthPercent: 6 }, { id: "daddr", title: "주소", visible: true, widthPercent: 8 }, - { id: "bdsgUsgText", title: "건축물용도", visible: true, widthPercent: 5 }, + { id: "bdstUsgText", title: "건축물용도", visible: true, widthPercent: 5 }, { id: "frstArchPrmsnDt", title: "최초건축허가일시", visible: true, widthPercent: 6 }, { id: "archArea", title: "건축면적", visible: true, widthPercent: 4 }, { id: "chBuildingYn", title: "증축・개축・재축여부", visible: true, widthPercent: 4 }, @@ -57,7 +57,7 @@ export const rainSttNodeListExport: CommonTableProps[] = [ // 1번째 줄 건축물정보 아래의 행으로 만들 헤더 { id: "bdstNm", title: "건축물명", visible: true, upName: "bdnstInfo", row: 2, rowSpan: 2, colSpan: 1, }, { id: "daddr", title: "주소", visible: true, upName: "bdnstInfo", row: 2, rowSpan: 2, colSpan: 1, }, - { id: "bdsgUsgText", title: "건축물용도", upName: "bdnstInfo", visible: true, row: 2, rowSpan: 2, colSpan: 1, }, + { id: "bdstUsgText", title: "건축물용도", upName: "bdnstInfo", visible: true, row: 2, rowSpan: 2, colSpan: 1, }, { id: "frstArchPrmsnDt", title: "최초건축허가일시", upName: "bdnstInfo", visible: true, row: 2, rowSpan: 2, colSpan: 1, }, { id: "archArea", title: "건축면적(m²)", upName: "bdnstInfo", visible: true, row: 2, rowSpan: 2, colSpan: 1, }, { id: "chBuildingYn", title: "증축・개축・재축여부", upName: "bdnstInfo", visible: true, row: 2, rowSpan: 2, colSpan: 1, }, diff --git a/apps/wr/app/store/ruseFcltMng/fcltSttn/rswtSttn/headList.tsx b/apps/wr/app/store/ruseFcltMng/fcltSttn/rswtSttn/headList.tsx index 1702154e7..2df99970e 100644 --- a/apps/wr/app/store/ruseFcltMng/fcltSttn/rswtSttn/headList.tsx +++ b/apps/wr/app/store/ruseFcltMng/fcltSttn/rswtSttn/headList.tsx @@ -28,7 +28,7 @@ export const rswtSttHeadListExport: z.infer = [ widthPercent: 8, }, { - id: "bdsgUsgText", + id: "bdstUsgText", title: "건축물용도", visible: true, widthPercent: 5, @@ -223,7 +223,7 @@ export const rswtSttNodeListExport: CommonTableProps[] = [ colSpan: 1, }, { - id: "bdsgUsgText", + id: "bdstUsgText", title: "건축물용도", upName: "bdnstInfo", visible: true, diff --git a/apps/wr/components/lib/func/common.tsx b/apps/wr/components/lib/func/common.tsx index cef465cf3..490326c74 100644 --- a/apps/wr/components/lib/func/common.tsx +++ b/apps/wr/components/lib/func/common.tsx @@ -52,7 +52,7 @@ export const tableData = (data: any, code: comCodeState) => { (cd) => cd.val === String(item.ctpvCd).trim() )?.text; if (item?.reuseBuilding?.length > 0) { - const bdsgUsgText = chCode?.bdstTotalUsgType?.find( + const bdstUsgText = chCode?.bdstTotalUsgType?.find( (cd) => cd.val === item?.reuseBuilding[0]?.bdstUsgCd )?.text; @@ -66,7 +66,7 @@ export const tableData = (data: any, code: comCodeState) => { ...item, sggText: sggText ?? "없음", cityText: cityText ?? "없음", - bdsgUsgText: bdsgUsgText, // 건물용도명 + bdstUsgText: bdstUsgText, // 건물용도명 lawTrgtYn: lawTrgtYn, // 법정대상 여부 grntchBdstCertGrdText: grntchBdstCertGrdText, // 녹색건축인증등급명 }; diff --git a/apps/wr/components/ui/form/coMat/conCostForm/conCostForm.tsx b/apps/wr/components/ui/form/coMat/conCostForm/conCostForm.tsx index 2bc1861ce..4fc26ef0a 100644 --- a/apps/wr/components/ui/form/coMat/conCostForm/conCostForm.tsx +++ b/apps/wr/components/ui/form/coMat/conCostForm/conCostForm.tsx @@ -1,7 +1,7 @@ -"use client"; /** - * 표준공사비 - */ + * 표준공사비 산정 +*/ +"use client"; import { useWrStore } from "@/app/store"; import { usageProcData } from "@/app/store/matDmd/coMatRvw/headList"; import type { @@ -286,6 +286,7 @@ export default function ConCostForm({ if (type === "mat") { if (value.demandCst) { const data: DedMatUpdateType = { + dmdCoSn: value.dmdCoSn ?? undefined, dmdInfoSn: value.dmdInfoSn, // 수요정보 일련번호 usgPrcsProcs: value.demandCst.usgPrcsProcs || "", // 용도에 따른 처리공정 cstrnCst: value.demandCst.cstrnCst || 0, // 공급관로 표준공사비(백만원) @@ -295,15 +296,22 @@ export default function ConCostForm({ rgtr: TEST_UUID, }; + const dmdCoSn = activeForm.getValues("dmdCoSn"); + + if (!dmdCoSn) { + alert("수요처 식별값이 없어 저장할 수 없습니다."); + return; + } + if(pk !==null){ if (checkFinish) { if (await confirm("검토완료 변경시 공사비는 변경할수 없습니다.\n진행하시겠습니까?")) { - const message = await coMatRvwUpdate( pk, activeForm.getValues("dmdInfoId"), data ); + const message = await coMatRvwUpdate( pk, String(dmdCoSn), data ); alert(message); } } else { - const message = await coMatRvwUpdate( pk, activeForm.getValues("dmdInfoId"), data ); + const message = await coMatRvwUpdate( pk, String(dmdCoSn), data ); alert(message); } }else{ diff --git a/apps/wr/components/ui/form/fcltReg/pbnstReg/buseReg/bugRegForm.tsx b/apps/wr/components/ui/form/fcltReg/pbnstReg/buseReg/bugRegForm.tsx index 411eebb95..1894a0532 100644 --- a/apps/wr/components/ui/form/fcltReg/pbnstReg/buseReg/bugRegForm.tsx +++ b/apps/wr/components/ui/form/fcltReg/pbnstReg/buseReg/bugRegForm.tsx @@ -256,6 +256,7 @@ export function PbnstBuseForm({ const plusArray = useCallback(() => { appendDemand({ + dmdCoSn: undefined, dmdCoNm: "", daddr: "", useUsgCd: "", @@ -596,6 +597,10 @@ export function PbnstBuseForm({ `items.${indexMng}.fcBizs.${indexBiz}.fcDemands.${index}.splyCpct`, valList.splyCpct ); + form.setValue( + `items.${indexMng}.fcBizs.${indexBiz}.fcDemands.${index}.dmdCoSn`, + valList.dmdCoSn + ); form.setValue( `items.${indexMng}.fcBizs.${indexBiz}.fcDemands.${index}.dmdInfoId`, valList.dmdInfoId?.toString() diff --git a/apps/wr/components/ui/modal/sttn/pbnstSttnModal/pbnstSttnModal.tsx b/apps/wr/components/ui/modal/sttn/pbnstSttnModal/pbnstSttnModal.tsx index fe69e0b4c..e4ea79afe 100644 --- a/apps/wr/components/ui/modal/sttn/pbnstSttnModal/pbnstSttnModal.tsx +++ b/apps/wr/components/ui/modal/sttn/pbnstSttnModal/pbnstSttnModal.tsx @@ -57,7 +57,6 @@ export function PbnstSttnModal({ data, isMap }: { data: customPbnst; isMap?: boo mode: "onChange", }); - useEffect(() => { let tableData; if (fcltTab === "fclt") { // 하수처리수 재이용시설 현황 diff --git a/apps/wr/docs/작업계획/DM 수요처 비밀번호 확인 dmdCoSn 전환_20260706.md b/apps/wr/docs/작업계획/DM 수요처 비밀번호 확인 dmdCoSn 전환_20260706.md new file mode 100644 index 000000000..1fffde156 --- /dev/null +++ b/apps/wr/docs/작업계획/DM 수요처 비밀번호 확인 dmdCoSn 전환_20260706.md @@ -0,0 +1,46 @@ +# DM 수요처 비밀번호 확인 `dmdCoSn` 전환 + +## 1. 배경 + +- DM 수요처 정보수정 화면 `apps/dm/app/(with-layout)/apply/dmdco/page.tsx` 의 기존 비밀번호 확인은 `dmdInfoId` 기준으로 `POST /reuseFc/request/checkPassword` 를 호출한다. +- 사업자등록번호(`dmdInfoId`)가 수정된 이후에는, 수정 전 기존 비밀번호를 다시 확인해야 하는 시점에 `dmdInfoId`가 바뀌어 있어 기존 row 식별이 불안정해진다. +- 현재 수요처 내부 PK는 `dmdCoSn` 이므로, 비밀번호 확인도 동일 키 기준으로 보는 것이 안전하다. + +## 2. 영향도 점검 + +확인 결과 `checkPassword` 호출 위치는 아래 1곳이다. + +- `frontend/apps/dm/app/(with-layout)/apply/dmdco/page.tsx` + +따라서 프런트 영향 범위는 DM 수요처 정보수정 화면으로 한정된다. + +백엔드 영향 범위는 아래 mapper query 1건이다. + +- `wr_back_out/src/main/resources/postgres/mapper/wr/demand_mapper.xml` + +`DemandController.checkDemandPassword()` 와 `DemandService.checkDemandPassword()` 는 입력 `DemandCo` 를 그대로 mapper 로 넘기므로, API 계약을 깨지 않고 mapper 조건만 보강하면 된다. + +## 3. 적용 방향 + +### 3.1 프런트 + +- `checkPassword` 호출 payload 에 `dmdCoSn` 포함 +- 수정 모드에서는 세션의 `dmdCoSn` 를 우선 전달 +- `dmdInfoId` 도 같이 유지하여 구세션/예외 상황 fallback 가능하게 둔다 + +### 3.2 백엔드 + +- `checkDemandPassword` SQL 을 `dmdCoSn` 우선 조회로 변경 +- `dmdCoSn` 가 없을 때만 `dmdInfoId` fallback +- 비밀번호 해시 비교 로직은 유지 + +## 4. 기대 효과 + +- 사업자등록번호 수정 전후와 무관하게 기존 비밀번호 확인 가능 +- 로그인, 단건조회, 비밀번호 초기화 등 다른 `dmdInfoId` 기반 화면은 영향 없음 +- `checkPassword` 전용 경로만 내부 PK 기준으로 안정화 + +## 5. 수정 파일 + +- `frontend/apps/dm/app/(with-layout)/apply/dmdco/page.tsx` +- `wr_back_out/src/main/resources/postgres/mapper/wr/demand_mapper.xml` diff --git a/apps/wr/docs/작업계획/dmd_info_id 제거 대응 계획_20260706.md b/apps/wr/docs/작업계획/dmd_info_id 제거 대응 계획_20260706.md new file mode 100644 index 000000000..ceff3f48d --- /dev/null +++ b/apps/wr/docs/작업계획/dmd_info_id 제거 대응 계획_20260706.md @@ -0,0 +1,211 @@ +# `dmd_info_id` 제거 대응 계획 + +## 1. 목적 + +아래 2개 테이블에서 `dmd_info_id`(사업자등록번호) 컬럼을 삭제하더라도 frontend/backend 오류가 발생하지 않도록 구조를 정리한다. + +- `sm_bs_wr.tb_wr_dmd_info_reg` +- `sm_bs_wr.tb_wr_sply_cndt_spt` + +추가 원칙: + +- 사업자등록번호는 더 이상 위 두 테이블의 직접 컬럼에 의존하지 않는다. +- 사업자등록번호 표시는 항상 수요처 마스터 `sm_bs_wr.tb_wr_ruse_fclt_dmd_co`를 join 하여 조회한다. +- 내부 연관관계 키는 `dmdCoSn`를 사용한다. + +## 2. 배경 + +현재 시스템은 `dmdCoSn` 중심 구조로 전환 중이지만, 일부 SQL/API/화면에서는 아직 `tb_wr_dmd_info_reg.dmd_info_id` 또는 `tb_wr_sply_cndt_spt.dmd_info_id`를 직접 참조하고 있다. + +이 상태에서 DB 컬럼을 먼저 삭제하면 아래 문제가 발생할 수 있다. + +- mapper SQL 실행 오류 +- resultMap 컬럼 매핑 실패 +- frontend 응답 타입 불일치 +- 화면에서 사업자등록번호 미표시 또는 수정/조회 실패 + +따라서 삭제 전 단계에서 모든 조회/저장 경로를 `dmdCoSn` 기반으로 정리하고, 사업자등록번호는 `tb_wr_ruse_fclt_dmd_co`에서만 읽도록 강제해야 한다. + +## 3. 목표 상태 + +### 3.1 식별 키 + +- 수요처 마스터 PK: `tb_wr_ruse_fclt_dmd_co.dmd_co_sn` +- 수요정보 등록 연관키: `tb_wr_dmd_info_reg.dmd_co_sn` +- 공급후보지점 연관키: `tb_wr_sply_cndt_spt.dmd_co_sn` + +### 3.2 사업자등록번호 조회 원칙 + +- 사업자등록번호는 `tb_wr_ruse_fclt_dmd_co.dmd_info_id`만 사용 +- `tb_wr_dmd_info_reg`, `tb_wr_sply_cndt_spt`에서는 더 이상 `dmd_info_id`를 select / where / join key / update 대상으로 사용하지 않음 + +### 3.3 화면/응답 원칙 + +- 응답 body에는 화면 표시용으로 `dmdInfoId`를 계속 내려줄 수 있음 +- 단, 그 값은 항상 `tb_wr_ruse_fclt_dmd_co` join 결과여야 함 +- 저장/수정/검증/매칭 내부키는 `dmdCoSn` 기준 + +## 4. 영향 범위 + +## 4.1 Backend + +주요 점검 대상: + +- `wr_back_out/src/main/resources/postgres/mapper/wr/demand_mapper.xml` +- `wr_back_out/src/main/resources/postgres/mapper/wr/public_fc_select.xml` +- `wr_back_out/src/main/java/kr/co/mindone/wrout/service/demand/DemandService.java` +- `wr_back_out/src/main/java/kr/co/mindone/wrout/service/public_fc/PublicFcService.java` +- `wr_back_out/src/main/java/kr/co/mindone/wrout/controller/DemandController.java` +- `wr_back_out/src/main/java/kr/co/mindone/wrout/object/demand/*.java` + +주의 포인트: + +- resultMap에서 `dmd_info_id` source column이 어느 테이블 기준인지 명확히 해야 함 +- `tb_wr_dmd_info_reg` / `tb_wr_sply_cndt_spt`를 직접 읽는 SQL은 사업자등록번호가 필요할 때 반드시 `tb_wr_ruse_fclt_dmd_co`를 추가 join 해야 함 +- insert/update 구문에서 삭제 예정 컬럼 `dmd_info_id`를 더 이상 쓰지 않도록 정리 필요 + +## 4.2 Frontend + +주요 점검 대상: + +- `frontend/apps/dm/**` +- `frontend/apps/wr/**` + +특히 아래 유형 점검: + +- 수요처 단건 조회 +- 수요처 로그인/세션 +- 수요처 정보수정 +- 수요정보 신청현황 +- 매칭검토/공사비/지도팝업 +- 하수재이용시설 등록/상세의 수요처 표시 + +주의 포인트: + +- frontend는 `dmdInfoId`를 표시값으로 계속 사용할 수 있음 +- 단, 응답이 어디에서 왔는지와 무관하게 내부 연산/요청 식별은 `dmdCoSn` 우선이어야 함 +- `dmd_info_id`가 `tb_wr_dmd_info_reg` 또는 `tb_wr_sply_cndt_spt`에서 내려온다고 가정하는 코드가 있으면 제거 + +## 5. 작업 단계 + +### 5.1 1단계: SQL 참조 제거 + +목표: + +- mapper SQL에서 `tb_wr_dmd_info_reg.dmd_info_id` +- mapper SQL에서 `tb_wr_sply_cndt_spt.dmd_info_id` + +직접 참조를 모두 제거한다. + +수정 원칙: + +- 사업자등록번호가 필요하면 `tb_wr_ruse_fclt_dmd_co twrfdc`를 join +- 표시 컬럼은 `twrfdc.dmd_info_id as ...` +- 내부 검색/연결 조건은 `dmd_co_sn` + +### 5.2 2단계: 저장/수정 API 정리 + +목표: + +- 수요정보 등록/수정 +- 공급후보지점 등록/수정 +- 비밀번호 확인/초기화 +- 매칭/공사비 관련 처리 + +위 경로에서 `dmd_info_id`를 내부 식별키로 더 이상 사용하지 않게 한다. + +수정 원칙: + +- path/body에 `dmdCoSn` 우선 사용 +- `dmdInfoId`는 신규 등록 중복체크나 표시용으로만 유지 +- `tb_wr_dmd_info_reg`, `tb_wr_sply_cndt_spt` insert/update 문에서 `dmd_info_id` 컬럼 제거 준비 + +### 5.3 3단계: 응답 계약 정리 + +목표: + +- frontend가 받는 응답에서는 계속 `dmdInfoId`를 사용할 수 있도록 유지 +- 단, source는 항상 `tb_wr_ruse_fclt_dmd_co` + +수정 원칙: + +- resultMap / alias에서 `master_dmd_info_id`, `display_dmd_info_id` 등 의미가 드러나게 정리 +- 중복 컬럼명 충돌 방지를 위해 alias 사용 + +### 5.4 4단계: Frontend 의존 제거 + +목표: + +- frontend가 `tb_wr_dmd_info_reg.dmd_info_id` 또는 `tb_wr_sply_cndt_spt.dmd_info_id` 존재를 전제하지 않도록 정리 + +수정 원칙: + +- 단건 식별, 수정, 비밀번호 확인, 매칭 선택값은 `dmdCoSn` +- 사업자등록번호 렌더링은 응답 `dmdInfoId` +- map popup / 상세조회 / 현황 / 등록 화면도 동일 원칙 적용 + +### 5.5 5단계: DB 컬럼 제거 + +선행 조건: + +- backend mapper / service / controller 정리 완료 +- frontend 주요 화면 검증 완료 + +실행 항목: + +- `tb_wr_dmd_info_reg.dmd_info_id` 삭제 +- `tb_wr_sply_cndt_spt.dmd_info_id` 삭제 + +주의: + +- DB 삭제는 반드시 마지막 단계 +- 삭제 전 운영/개발 DB에서 남은 참조 SQL 재검색 필요 + +## 6. 점검 체크리스트 + +### 6.1 Backend 체크 + +- `rg "twdir\\.dmd_info_id|twscs\\.dmd_info_id|dmd_info_id"` 재검색 +- 삭제 예정 2개 테이블의 `dmd_info_id`를 읽는 SQL이 남아 있지 않은지 확인 +- 단, `twrfdc.dmd_info_id` 참조는 정상 + +### 6.2 Frontend 체크 + +- DM 로그인 성공 후 세션에 `dmdCoSn` 존재 +- DM 수요처 정보수정 시 기존 비밀번호 확인 가능 +- DM 신청현황/지도팝업 정상 +- WR 수요처 관리/비밀번호 초기화/수정 정상 +- WR 매칭검토/공사비 저장 정상 +- WR 시설등록/상세의 수요처 목록 정상 + +### 6.3 API 체크 + +- 수요처 로그인 응답에 `dmdCoSn`, `dmdInfoId` 포함 +- 단건 조회 응답에 `dmdCoSn`, `dmdInfoId` 포함 +- 수요정보/매칭/시설상세 응답에서 사업자등록번호가 `twrfdc` 기준으로 내려오는지 확인 + +## 7. 위험 요소 + +- 구세션/구응답에 `dmdCoSn`가 없으면 DM 수정 흐름이 실패할 수 있음 +- 지도 highlight/feature id가 여전히 `dmdInfoId` 기반이면 일부 화면은 유지 전략이 필요 +- `SELECT *` 기반 SQL은 컬럼 충돌로 source 테이블 식별이 어려우므로 정리 필요 +- `tb_wr_sply_cndt_spt` 과거 데이터에 `dmd_co_sn` 누락이 있으면 조인 결과가 비정상일 수 있음 + +## 8. 권장 순서 + +1. backend mapper에서 삭제 예정 컬럼 참조 제거 +2. service/controller 입력 키를 `dmdCoSn` 기준으로 정리 +3. frontend 주요 화면을 `dmdCoSn + dmdInfoId` 병행 구조로 점검 +4. 응답에서 표시용 `dmdInfoId` source를 `twrfdc`로 통일 +5. 통합 테스트 +6. DB 컬럼 삭제 + +## 9. 완료 기준 + +아래 조건을 모두 만족하면 완료로 본다. + +- `tb_wr_dmd_info_reg.dmd_info_id` 삭제 후 backend 에러 없음 +- `tb_wr_sply_cndt_spt.dmd_info_id` 삭제 후 backend 에러 없음 +- frontend 주요 화면에서 사업자등록번호 표시 정상 +- 수요처 조회/수정/매칭/시설상세/지도팝업 정상 +- 내부 식별은 모두 `dmdCoSn` 기준으로 동작 diff --git a/apps/wr/docs/작업계획/수요처 사업자번호 수정 대응 계획_20260703.md b/apps/wr/docs/작업계획/수요처 사업자번호 수정 대응 계획_20260703.md new file mode 100644 index 000000000..4bd15e6e3 --- /dev/null +++ b/apps/wr/docs/작업계획/수요처 사업자번호 수정 대응 계획_20260703.md @@ -0,0 +1,428 @@ +# 수요처 사업자번호 수정 대응 계획 + +작성일: 2026-07-03 + +## 1. 배경 + +현재 WR 수요처 구조는 `tb_wr_ruse_fclt_dmd_co.dmd_info_id`를 수요처의 표시용 사업자등록번호이면서 동시에 연관관계 키처럼 사용하고 있다. + +이 구조에서는 수요처의 사업자등록번호를 수정하려면 아래 연관 테이블의 `dmd_info_id`까지 함께 수정해야 한다. + +- `tb_wr_ruse_fclt_dmd_co` 재사용시설수요회사 +- `tb_wr_dmd_info_reg` 수요정보등록 +- `tb_wr_sply_cndt_spt` 공급후보지점 +- 이를 참조하는 조회 SQL 및 프런트 화면 + +요청사항은 `tb_wr_ruse_fclt_dmd_co`에 `dmd_co_sn`(수요처일련번호)을 추가하고, 연관 테이블도 `dmd_co_sn`로 연결되도록 바꿔서 사업자등록번호 변경 시 다른 테이블 수정이 필요하지 않게 만드는 것이다. + +기준 SQL 초안은 아래 파일이다. + +- `G:\내 드라이브\07 유역하수도\208 공단 배포\sql\alter_tables_wr.sql` + +## 2. 목표 + +이번 작업의 목표는 아래 3가지다. + +1. 수요처의 내부 식별키를 `dmd_info_id`에서 `dmd_co_sn`으로 전환 +2. 사업자등록번호(`dmd_info_id`)는 수정 가능한 업무값으로만 유지 +3. 사업자등록번호를 화면/API에 보여줄 때는 항상 `dmd_co_sn` 기준으로 원본 수요처 테이블을 join해서 조회 + +정리하면: + +- 내부 연관키: `dmd_co_sn` +- 표시/로그인/입력값: `dmd_info_id` + +## 3. 현재 구조 분석 + +### 3.1 기준 테이블 + +현재 수요처 마스터 역할은 `tb_wr_ruse_fclt_dmd_co`가 맡고 있다. + +주요 컬럼: + +- `dmd_info_id`: 사업자등록번호 + 종사업자번호 조합값 +- `dmd_co_nm`: 수요처명 +- `ctpv_cd`, `sgg_cd`, `daddr`, `telno` 등 기본정보 + +현재 `alter_tables_wr.sql`은 이 테이블에 `dmd_co_sn`을 추가하고 PK를 `dmd_co_sn`으로 전환하는 1차 작업까지 포함하고 있다. + +### 3.2 연관 테이블 + +`reuse-service` 분석 결과, 사업자번호 변경 영향이 직접 가는 주요 연관 테이블은 아래와 같다. + +1. `tb_wr_dmd_info_reg` +- 수요정보 이력/상세 +- 현재 `dmd_info_id`를 보유하고 있고, 조회 시 `tb_wr_ruse_fclt_dmd_co`와 `dmd_info_id`로 join + +2. `tb_wr_sply_cndt_spt` +- 공급후보지점/매칭 정보 +- 현재 `dmd_info_id`, `dmd_info_sn`을 함께 사용 +- 일부 조회는 `dmd_info_id`, 일부 조회는 `dmd_info_sn` 기반으로 섞여 있음 + +3. 조회 전용 join 경로 +- `demand_mapper.xml` +- `public_fc_select.xml` +- 수요처 관련 화면/지도/팝업 API + +### 3.3 현재 SQL + +`demand_mapper.xml`을 보면 수요처 조인은 dmd_info_id 로 조인하는 것이다. + +- `getDemand`, `getDemandById`는 `twrfdc.dmd_info_id = twdir.dmd_info_id` +- `tb_wr_sply_cndt_spt`도 `twrfdc.dmd_info_id = twscs.dmd_info_id` +- 반면 `getDemandInfo`, `getDemandInfoListCnt`는 일부 구간에서 `twscs.dmd_info_sn = twdir.dmd_info_sn`는 수요정보의 조인이다. + +즉 현재 `dmd_info_id`와 `dmd_info_sn`이 혼용은 아니고, dmd_info_id는 사업자등록번호이며 `tb_wr_ruse_fclt_dmd_co` 재사용시설수요회사 의 pk이고, `dmd_info_sn`은 수요정보의 pk이다. + +### 3.4 API 구조 + +주요 수요처 API는 `DemandController` 아래에 있다. + +- `POST /reuseFc/request` +- `PUT /reuseFc/request` +- `POST /reuseFc/request/login` +- `GET /reuseFc/request/{demandCd}` +- `GET /reuseFc/request/{pageNo}/{pageCnt}/{sggCd}` +- `GET /reuseFc/request/infoList/...` +- `PUT /reuseFc/request/chgPassword/{dmdCd}` +- `PUT /reuseFc/request/cst/{splyCd}/{dmdCd}` + +현재 path variable 이름은 `dmdCd`, `demandCd` 등이지만 실제 의미는 대부분 `dmd_info_id`다. + +### 3.5 WR 프런트 영향 범위 + +WR에서 `dmdInfoId`를 직접 키로 쓰는 주요 영역은 아래와 같다. + +1. 수요처 현황/관리 +- `apps/wr/app/store/matDmd/coSttn/index.tsx` +- `apps/wr/app/(with-layout)/matMng/coMng/coSttn/page.tsx` + +2. 지도/팝업 +- `apps/wr/app/store/map/coSttnMap/index.tsx` +- `apps/wr/components/lib/func/mapUtil.ts` +- `apps/wr/app/(popUp-layout)/splyPopup/page.tsx` +- `apps/wr/app/(popUp-layout)/mapFeaturePopup/page.tsx` + +3. 공공하수처리시설 등록 폼 내 수요처 연결 +- `apps/wr/components/ui/form/fcltReg/pbnstReg/buseReg/bugRegForm.tsx` + +4. 공사비 검토/매칭 +- `apps/wr/components/ui/form/coMat/conCostForm/conCostForm.tsx` +- `apps/wr/app/store/matDmd/coMatRvw/index.tsx` + +### 3.6 DM 프런트 영향 범위 + +DM에서도 `dmdInfoId`를 내부키처럼 재사용하고 있다. + +1. 로그인/회원식별 +- `apps/dm/components/LoginModal.tsx` +- `apps/dm/components/ui/sdmLoginForm/component.tsx` + +2. 수요처 등록/수정 공통 컴포넌트 +- `frontend/packages/common/business_components/src/ui/sdmInsertForm/*` + +3. 신청현황/수요정보 조회 +- `apps/dm/app/store/sdmMng/index.tsx` +- `apps/dm/app/(with-layout)/apply/status/page.tsx` +- `apps/dm/app/(with-layout)/apply/dmdinfo/page.tsx` +- `apps/dm/app/(with-layout)/apply/dmdco/page.tsx` + +4. 지도 팝업 +- `apps/dm/app/(popup-layout)/map/popup/page.tsx` + +DM은 로그인 후 세션의 `dmdInfoId`를 그대로 조회키로 쓰는 흐름이 있으므로, 응답에 `dmdCoSn`을 함께 내려주고 세션에도 저장하도록 수정해야 한다. + +## 4. 목표 구조 + +### 4.1 데이터 모델 원칙 + +- `tb_wr_ruse_fclt_dmd_co.dmd_co_sn`: 수요처 마스터 PK +- `tb_wr_ruse_fclt_dmd_co.dmd_info_id`: 수정 가능한 사업자등록번호 +- `tb_wr_dmd_info_reg.dmd_co_sn`: 수요처 FK +- `tb_wr_sply_cndt_spt.dmd_co_sn`: 수요처 FK + +기존 `dmd_info_id` 컬럼은 즉시 제거하지 않는다. + +역할은 아래처럼 축소한다. + +- 입력/로그인 시 사업자번호 매칭 +- 화면 표시용 값 +- 과거 API 호환용 보조값 + +### 4.2 조회 원칙 + +사업자등록번호를 응답에 포함할 때는 `tb_wr_ruse_fclt_dmd_co`에서 가져온다. + +즉 아래 형태로 바꾸는 것이 원칙이다. + +- 기존: `twdir.dmd_info_id`, `twscs.dmd_info_id` 직접 사용 +- 변경: `twdir.dmd_co_sn = twrfdc.dmd_co_sn` 또는 `twscs.dmd_co_sn = twrfdc.dmd_co_sn` 후 `twrfdc.dmd_info_id` 선택 + +## 5. DB 작업 + +G:\내 드라이브\07 유역하수도\208 공단 배포\sql\alter_tables_wr.sql + +### 5.1 기타 테이블 + +`tb_wr_cstrn_cst`는 현재 `dmd_info_sn` 기준이므로 직접적인 `dmd_co_sn` 추가 대상은 아니다. + +다만 공사비 화면에서 사업자번호를 표시할 때는: + +- `tb_wr_cstrn_cst -> tb_wr_dmd_info_reg(dmd_info_sn) -> tb_wr_ruse_fclt_dmd_co(dmd_co_sn)` + +경로를 사용해야 한다. + +## 6. 백엔드 작업계획 + +### 6.1 도메인/DTO 수정 + +대상: + +- `DemandCo` +- `DemandInfo` +- `DemandMatching` +- `DemandMatchingInfo` + +추가/정리 방향: + +- `dmdCoSn` 필드 추가 +- `dmdInfoId`는 표시값으로 유지 +- path/body에서 내부 처리에 필요한 경우 `dmdCoSn` 우선 사용 + +### 6.2 Mapper SQL 수정 + +대상 파일: + +- `D:\iipbs\wr_back_out\src\main\resources\postgres\mapper\wr\demand_mapper.xml` +- `D:\iipbs\wr_back_out\src\main\resources\postgres\mapper\wr\public_fc_select.xml` + +핵심 수정: + +1. `getDemand`, `getDemandById` +- `tb_wr_dmd_info_reg`, `tb_wr_sply_cndt_spt` join을 `dmd_co_sn` 기준으로 변경 +- 응답의 사업자번호는 `twrfdc.dmd_info_id` 사용 + +2. `insertDemand` +- 신규 등록 후 `dmd_co_sn`까지 반환되도록 수정 검토 + +3. `insertDemandInfo`, `updateDemandInfo` +- `dmd_co_sn` 저장 +- 수요처 상세 조회 화면에서 `dmd_co_sn` 까지 조회되도록 하고 `dmdCoSn`을 수정시 보내도록 한다. + +4. `insertMatching`, `updateMatching`, `getDemandMatching` +- 조건식과 upsert key를 `dmd_co_sn` 중심으로 전환 + +5. `getDemandInfo`, `getDemandInfoListCnt` +- 사업자번호 표시 시 `twrfdc.dmd_info_id` +- 수요처 연계는 `dmd_co_sn` + +6. `public_fc_select.xml` +- 공공하수처리시설 상세의 수요처 목록 조인도 `twscs.dmd_co_sn = twrfdc.dmd_co_sn`로 전환 + +### 6.3 Service / Controller 수정 + +대상: + +- `DemandService` +- `DemandController` + +수정 방향: + +1. 로그인 +- `loginDemand()`는 사업자번호(`dmdInfoId`)로 수요처 마스터를 조회하는 흐름 유지 +- 로그인 성공 응답에 `dmdCoSn` 포함 + +2. 단건 조회 +- `GET /reuseFc/request/{demandCd}`는 기존 호환을 위해 사업자번호 기반 조회 유지 가능 +- {demandCd} 변수명을 컬럼명(dmdInfoId)으로 수정한다. +- 응답에는 반드시 `dmdCoSn` 포함 + +3. 수정 API +- `PUT /reuseFc/request` +- 사업자번호 변경 시 `tb_wr_ruse_fclt_dmd_co`만 수정하도록 처리 +- 다른 연관 테이블은 수정하지 않음 + +4. 비밀번호 초기화 +- 현재 `PUT /reuseFc/request/chgPassword/{dmdCd}`는 `dmdCoSn` 기반 +- {dmdCd} 변수명을 컬럼명(dmdCoSn)으로 수정한다. +- 화면 호환을 위해 우선 유지하되, 내부에서는 `dmd_co_sn`로 마스터 조회 후 처리 + +5. 공사비/매칭 +- `PUT /reuseFc/request/cst/{splyCd}/{dmdCd}`는 후속적으로 `dmdCoSn` 기반 API로 정리 검토 +- {dmdCd} 변수명을 컬럼명(dmdCoSn)으로 수정한다. +- 1차는 내부 변환으로 대응 가능 + +## 7. WR 프런트 작업계획 + +### 7.1 수요처 관리 화면 + +대상: + +- `apps/wr/app/store/matDmd/coSttn/index.tsx` +- `apps/wr/app/(with-layout)/matMng/coMng/coSttn/page.tsx` +- `apps/wr/app/store/matDmd/coSttn/headList.tsx` + +수정 방향: + +- 목록/상세 응답 타입에 `dmdCoSn` 추가 +- 수정 시 내부 식별은 `dmdCoSn`, 화면 표시는 `dmdInfoId` +- 사업자번호 중복 체크 로직은 신규 등록에서만 `dmdInfoId` 기준 사용 +- 수정 모드에서는 원본 `dmdCoSn` 기준으로 update 호출 + +### 7.2 지도/팝업 + +대상: + +- `apps/wr/app/store/map/coSttnMap/index.tsx` +- `apps/wr/components/lib/func/mapUtil.ts` +- `apps/wr/app/(popUp-layout)/splyPopup/page.tsx` + +수정 방향: + +- 지도 feature id가 현재 `dmdInfoId` 기반이면 유지 여부 검토 필요 +- 내부 상세조회 키는 `dmdCoSn`를 우선 사용 +- 화면/툴팁의 사업자번호 표시는 응답의 `dmdInfoId` + +주의: + +- 지도 레이어 원본 데이터 키가 당장 `dmdInfoId`라면, 1차는 지도 highlight 키는 유지하고 상세 API만 `dmdCoSn` 병행 처리하는 방식이 안전하다. + +### 7.3 공사비/매칭 화면 + +대상: + +- `apps/wr/components/ui/form/coMat/conCostForm/conCostForm.tsx` +- `apps/wr/app/store/matDmd/coMatRvw/index.tsx` + +수정 방향: + +- 공사비 저장 시 `dmdInfoId` 대신 `dmdCoSn`를 함께 전달하도록 타입 보강 +- popup open, 조회, 저장 시 내부키와 표시값을 분리 + +### 7.4 공공하수처리시설 등록 화면 내 수요처 선택 + +대상: + +- `apps/wr/components/ui/form/fcltReg/pbnstReg/buseReg/bugRegForm.tsx` + +수정 방향: + +- 검색 결과 항목은 `dmdInfoId`를 표시하되 선택값에는 `dmdCoSn` 포함 +- 재저장 시 연결 테이블에는 `dmd_co_sn` 기준으로 저장 + +## 8. DM 프런트 작업계획 + +### 8.1 로그인/세션 + +대상: + +- `apps/dm/components/LoginModal.tsx` +- `apps/dm/components/ui/sdmLoginForm/component.tsx` +- `apps/dm/app/store/sdmMng/index.tsx` + +수정 방향: + +- 로그인 입력은 기존처럼 사업자번호(`dmdInfoId`) 사용 +- 로그인 성공 응답에 `dmdCoSn`를 포함하여 세션 저장 +- 이후 조회/수정 API는 가능한 범위에서 `dmdCoSn`를 함께 사용 + +### 8.2 수요처 등록/수정 공통 폼 + +대상: + +- `frontend/packages/common/business_components/src/ui/sdmInsertForm/*` + +수정 방향: + +- updateList / return type에 `dmdCoSn` 추가 +- 수정 화면에서는 사업자번호 필드를 수정 가능하게 바꾸되, 내부 원본키는 `dmdCoSn` 유지 +- 신규 등록과 수정의 검증 로직을 분리 + +### 8.3 신청현황/상세/지도팝업 + +대상: + +- `apps/dm/app/(with-layout)/apply/status/page.tsx` +- `apps/dm/app/(with-layout)/apply/dmdinfo/page.tsx` +- `apps/dm/app/(with-layout)/apply/dmdco/page.tsx` +- `apps/dm/app/(popup-layout)/map/popup/page.tsx` + +수정 방향: + +- 세션의 `dmdInfoId`만 참조하는 흐름을 `dmdCoSn + dmdInfoId` 병행 구조로 수정 +- 사업자번호 표시 포맷은 그대로 유지 +- 단건 조회키는 backend 변경 폭에 맞춰 `dmdCoSn` 지원 여부를 우선 검토 + +## 9. 구현 순서 + +1. reuse-service DTO / mapper / service 수정 + +2. Swagger 기준 API 응답에 `dmdCoSn` 포함 확인 + +3. WR 프런트 수정 +- 수요처 관리 : 수요처 등록/수정 +- 지도/팝업 +- 공사비/매칭 +- 하수재이용시설 등록 우측 차수별정보 하단 수요처 추가 + +4. DM 프런트 수정 +- 로그인/세션 +- 등록/수정 +- 신청현황/상세 + +5. 수동 검증 + +## 10. 검증 항목 + +### 10.1 데이터 검증 + +- 기존 수요처 데이터에 `dmd_co_sn`가 모두 채워졌는지 +- `tb_wr_dmd_info_reg`, `tb_wr_sply_cndt_spt`의 `dmd_co_sn`가 누락 없이 매핑됐는지 +- 사업자번호 변경 후 연관 테이블 update 없이 조회가 정상인지 + +### 10.2 WR 검증 + +- 수요처 현황 목록 조회 정상 +- 수요처 수정 후 목록/상세/지도 팝업에 변경 사업자번호 반영 +- 공사비/매칭 저장 정상 +- 공공하수처리시설 상세의 연결 수요처 표시 정상 + +### 10.3 DM 검증 + +- 기존 사업자번호로 로그인 정상 +- 로그인 후 세션 유지 정상 +- 수요정보 등록/수정 정상 +- 신청현황/지도팝업에서 사업자번호 표시 정상 + +## 11. 리스크 및 확인 필요사항 + +### 11.1 API path의 식별자 의미 + +현재 `/reuseFc/request/{demandCd}`는 사실상 사업자번호 기반이다. + +이번 작업에서 바로 `dmdCoSn` 기반으로 path 계약을 바꾸면 WR/DM 프런트 영향이 커진다. + +권장 1차안: + +- 외부 API 계약은 최대한 유지 +- 응답 body에 `dmdCoSn` 추가 +- 내부 저장/join만 `dmdCoSn`로 전환 + +### 11.2 지도 feature key + +지도 highlight나 feature id가 `dmdInfoId`에 묶여 있을 가능성이 있다. + +따라서 지도 레이어 원천 데이터까지 같이 바꾸는지, 아니면 상세조회 키만 분리할지 먼저 판단이 필요하다. + +### 11.3 공통 컴포넌트 영향 + +`frontend/packages/common/business_components/src/ui/sdmInsertForm/*`는 WR/DM 양쪽이 함께 쓰므로, 이 부분 변경 시 두 앱을 같이 검증해야 한다. + +## 12. 결론 + +이번 작업은 단순 컬럼 1개 추가가 아니라, 수요처 식별체계를 사업자번호 기반 자연키에서 일련번호 기반 대체키로 바꾸는 작업이다. + +핵심 원칙은 아래 2줄이다. + +1. 연관관계는 `dmd_co_sn`로 묶는다. +2. 사업자등록번호는 `tb_wr_ruse_fclt_dmd_co`에서만 관리하고, 화면 표시도 항상 그 값을 join해서 사용한다. diff --git a/docs/auth-service-refactoring-guide.md b/docs/auth-service-refactoring-guide.md index 8f06974e8..bc092147d 100644 --- a/docs/auth-service-refactoring-guide.md +++ b/docs/auth-service-refactoring-guide.md @@ -29,10 +29,8 @@ 예시 코드: ```ts -export const CLAIM_NAME = - process.env.NEXT_PUBLIC_CLAIM_NAME || "Authorization"; -export const AUTH_USER_ID = - process.env.NEXT_PUBLIC_AUTH_USER_ID || "token-id"; +export const CLAIM_NAME = process.env.NEXT_PUBLIC_CLAIM_NAME || "Authorization"; +export const AUTH_USER_ID = process.env.NEXT_PUBLIC_AUTH_USER_ID || "token-id"; export const ACCESS_TOKEN = process.env.NEXT_PUBLIC_ACCESS_TOKEN || "access-token"; export const REFRESH_TOKEN = @@ -82,6 +80,7 @@ export const { setToken, removeToken, setupRefreshTimer, + clearRefreshTimer, clearAuthToken, getAccessToken, getRefreshToken, @@ -175,3 +174,14 @@ export { tokenManager } from "./lib/token-manager"; - 로그인/갱신 로직이 `getRemainingMs` 기반 타이머를 사용한다. - BaseLayout 컴포넌트에 `tokenManager`가 주입되어 있다. - 외부 참조 경로가 `entities/auth` 배럴 경로로 통일되어 있다. + +## 5) 세션 연장 Confirm 적용 (후속 단계) + +리팩토링이 완료된 서비스(`authService` + `AuthWrapper` + `ConfirmProvider`)는 [세션 연장 Confirm 적용 가이드](./session-extend-confirm-guide.md)를 따라 JWT 만료 5분 전 confirm UX를 추가한다. + +추가 체크 항목: + +- `token-manager`에서 `clearRefreshTimer`를 re-export한다. +- `AuthWrapper`에 `useSessionExtendConfirm`을 연결한다. +- `clearRefreshTimer`는 앱 `token-manager` 싱글톤을 통해 전달한다 (`authService`의 `setupRefreshTimer`와 동일 인스턴스). +- `ConfirmProvider` 하위에 `AuthWrapper`와 `ConfirmDialog`가 마운트되어 있다. diff --git a/docs/session-extend-confirm-guide.md b/docs/session-extend-confirm-guide.md new file mode 100644 index 000000000..0668f1f3d --- /dev/null +++ b/docs/session-extend-confirm-guide.md @@ -0,0 +1,245 @@ +# 세션 연장 Confirm 적용 가이드 + +## 1) 문서 목적 + +JWT 만료 **5분 전**에 `"로그인을 연장하시겠습니까?"` confirm 창을 띄우고, 사용자 선택에 따라 세션을 연장하거나 자동 갱신을 중단하는 기능을 서비스 앱에 적용하기 위한 가이드입니다. + +이 문서는 [서비스별 인증 처리 리팩토링 가이드](./auth-service-refactoring-guide.md)의 **후속 단계**로 읽습니다. +`@common/auth` 기반 `authService` + `token-manager` + `AuthWrapper` 패턴이 먼저 갖춰져 있어야 합니다. + +## 2) 선행 조건 + +아래 항목이 완료된 서비스만 이 가이드를 적용합니다. + +| 항목 | 확인 | +| -------------------------------------------------------------------------------- | ------------------------------------------------------------------- | +| `createTokenManager(authConfig)` 사용 | [3-3. 토큰 매니저 공통화](./auth-service-refactoring-guide.md) | +| `authService.login` / `silentRefresh`에서 `getRemainingMs` + `setupRefreshTimer` | [3-4. 로그인 성공 처리 표준화](./auth-service-refactoring-guide.md) | +| `authService.declineSessionExtend` 구현 | confirm 취소 시 refresh 타이머 해제 | +| `AuthWrapper`에서 새로고침 시 refresh 타이머 복구 | cs / as / st 참고 | +| `ConfirmProvider` + `ConfirmDialog` 마운트 | AppProvider | +| `ConfirmProvider` **하위**에 `BaseLayout` → `Header` 렌더링 | `useConfirm` 사용 조건 | +| `BaseLayout`에 `authService` prop 전달 | Header까지 전달됨 | + +**적용 완료 앱**: `cs`, `as`, `st` +**미적용**: `wr`, `sn`, `fo` 등 legacy `LoginApi` 패턴 앱 + +## 3) 전체 흐름 + +로그인 이후 JWT `exp`를 기준으로 **두 개의 독립 타이머**가 동작합니다. + +```mermaid +sequenceDiagram + participant Login as authService.login + participant RefreshTimer as refreshTimer + participant Header as Header + participant User as 사용자 + participant API as authService.silentRefresh + + Login->>RefreshTimer: setupRefreshTimer(exp - 1분) + Header->>Header: confirm 예약(exp - 5분) + Note over Header: 만료 5분 전 + Header->>User: 로그인을 연장하시겠습니까? + alt 확인 + User->>API: silentRefresh() + API->>RefreshTimer: 새 토큰 기준 타이머 재설정 + Header->>Header: confirm 타이머 재설정 + else 취소 + Header->>API: declineSessionExtend() + API->>RefreshTimer: clearRefreshTimer() + Note over RefreshTimer: 만료까지 자동 갱신 없음 + end +``` + +| 시점 | 동작 | 담당 | +| --------------------------- | ------------------------------------- | ------------------------------------------- | +| 로그인 / silentRefresh 성공 | 만료 1분 전 자동 `silentRefresh` 예약 | `authService` → `setupRefreshTimer` | +| 만료 5분 전 | confirm 표시 | `Header` → `useSessionExtendConfirm` | +| confirm **확인** | 즉시 `silentRefresh` + 타이머 재설정 | Header → `authService.silentRefresh` | +| confirm **취소** | 자동 `silentRefresh` 타이머 해제 | Header → `authService.declineSessionExtend` | + +### 3-1. 시간 계산 (`getRemainingMs`) + +`[packages/common/auth/jwt-utils.ts](../packages/common/auth/jwt-utils.ts)` + +```typescript +export const REFRESH_BUFFER_MS = 60_000; // silent refresh: 만료 1분 전 +export const SESSION_EXTEND_CONFIRM_BUFFER_MS = 5 * 60_000; // confirm: 만료 5분 전 + +getRemainingMs(token); // → refresh 타이머 delay +getRemainingMs(token, SESSION_EXTEND_CONFIRM_BUFFER_MS); // → confirm 타이머 delay +getRemainingMs(token, 0) > 0; // → 토큰 미만료 여부 +``` + +`bufferMs` 인자 하나로 용도를 구분합니다. 기존 `getRemainingMs(token)` 호출부는 변경하지 않아도 됩니다. + +## 4) 공통 모듈 변경 사항 + +### 4-1. `@common/auth` — `AuthService` / `clearRefreshTimer` + +`[packages/common/auth/types.ts](../packages/common/auth/types.ts)` + +```typescript +export interface AuthService { + login(payload: AuthPayload): Promise; + silentRefresh(): Promise; + declineSessionExtend(): void; // confirm 취소 시 refresh 타이머 해제 + logout(): Promise; +} +``` + +`[packages/common/auth/token-manager.ts](../packages/common/auth/token-manager.ts)` + +- `setupRefreshTimer`: 만료 1분 전 `silentRefresh` 예약 (기존) +- `clearRefreshTimer`: 예약된 refresh 타이머 해제 (신규) +- `clearAuthToken`: 내부에서 `clearRefreshTimer` 호출 + +`refreshTimer`는 `createTokenManager()` 클로저 안의 **앱당 싱글톤** 변수입니다. +`authService.declineSessionExtend()`가 내부에서 `clearRefreshTimer()`를 호출하므로, Header는 token-manager를 직접 import하지 않습니다. + +### 4-2. `@common/components` — Header + `useSessionExtendConfirm` + +`[packages/common/components/src/ui/header/index.tsx](../packages/common/components/src/ui/header/index.tsx)`에서 훅을 실행합니다. + +```typescript +useSessionExtendConfirm({ + enabled: + mounted && + isLoggedIn && + !viewOnly && + !!authService && + !isAtLoginRedirectUrl(getAuthLoginRedirectUrl()), + getAccessToken: () => getCookie(appConfig.CLAIM_NAME ?? ""), + silentRefresh: authService.silentRefresh, + clearRefreshTimer: authService.declineSessionExtend, +}); +``` + +| enabled 조건 | 이유 | +| ---------------------------- | ------------------------------- | +| `mounted` | hydration 후 타이머 시작 | +| `isLoggedIn` | 비로그인 제외 | +| `!viewOnly` | `VBaseLayout` 등 조회 전용 제외 | +| `!!authService` | legacy 앱 자동 비활성 | +| `!isAtLoginRedirectUrl(...)` | 로그인 페이지 제외 | + +세션 만료 **카운트다운 UI**와 confirm 로직이 Header에 응집됩니다. + +## 5) 서비스별 적용 단계 + +### 5-1. `auth-service`에 `declineSessionExtend` 추가 + +`[apps/cs/src/entities/auth/api/auth-service.ts](../apps/cs/src/entities/auth/api/auth-service.ts)` 참고: + +```typescript +import { clearRefreshTimer } from "../lib/token-manager"; + +function declineSessionExtend(): void { + clearRefreshTimer(); +} + +export const authService: AuthService = { + login, + silentRefresh, + declineSessionExtend, + logout, +}; +``` + +### 5-2. `BaseLayout`에 `authService` 전달 + +`[apps/cs/app/(main)/(with-base-layout)/layout.tsx](../apps/cs/app/(main)`/(with-base-layout)/layout.tsx) 참고: + +```tsx + +``` + +`BaseLayout`은 `authService`를 `Header`에 그대로 전달합니다. **AuthWrapper에서 훅을 연결하지 않습니다.** + +### 5-3. `AuthWrapper` — refresh 타이머 복구만 유지 + +`[apps/cs/src/entities/auth/lib/auth-wrapper.tsx](../apps/cs/src/entities/auth/lib/auth-wrapper.tsx)`: + +- `useSessionExtendConfirm` **사용하지 않음** +- 페이지 새로고침 시 `setupRefreshTimer(authService.silentRefresh, remainingMs)` 복구 유지 +- 인증 체크, `auth:logout` 수신 유지 + +### 5-4. `AppProvider` 구조 확인 + +`[apps/cs/src/app/providers/app-provider.tsx](../apps/cs/src/app/providers/app-provider.tsx)` 참고: + +```tsx + + + {children} + + + +``` + +`Header`는 `ConfirmProvider` 하위에서 렌더링되므로 `useConfirm()`이 동작합니다. + +## 6) 책임 분리 요약 + +| 레이어 | 파일 | 역할 | +| ------------------ | ------------------------------------ | -------------------------------------------------- | +| JWT 시간 계산 | `@common/auth` `jwt-utils.ts` | `getRemainingMs`, 버퍼 상수 | +| refresh 타이머 | `@common/auth` `token-manager.ts` | `setupRefreshTimer`, `clearRefreshTimer` | +| API 오케스트레이션 | `entities/auth/api/auth-service.ts` | login, silentRefresh, declineSessionExtend, logout | +| confirm UX | `@common/components` `Header` | 카운트다운 UI + 5분 전 confirm | +| 앱 진입점 | `entities/auth/lib/auth-wrapper.tsx` | 타이머 복구, auth 체크 | +| 레이아웃 | `app/.../layout.tsx` | `authService` → `BaseLayout` → `Header` | +| UI 쉘 | `app/providers/app-provider.tsx` | ConfirmProvider, ConfirmDialog | + +## 7) 엣지 케이스 + +| 상황 | 동작 | +| -------------------- | --------------------------------------------------------------- | +| 토큰 유효 시간 < 5분 | `confirmMs === 0`이면 페이지 진입 즉시 confirm | +| confirm **취소** | `declineSessionExtend()` + 동일 JWT에 재표시 안 함 | +| confirm **확인** | `silentRefresh()` → 새 토큰 기준 confirm/refresh 타이머 재설정 | +| `silentRefresh` 실패 | `authService`가 `auth:logout` 발행 → AuthWrapper 리다이렉트 | +| 로그아웃 | `clearAuthToken`이 refresh 타이머 정리, 훅 cleanup | +| `viewOnly` Header | confirm 비활성 | +| `authService` 미전달 | confirm 비활성 (wr/fo 등) | +| 페이지 새로고침 | AuthWrapper가 refresh 타이머 복구, Header가 confirm 타이머 복구 | + +## 8) 적용 체크리스트 + +[리팩토링 가이드 체크리스트](./auth-service-refactoring-guide.md#4-서비스별-적용-체크리스트)에 더해 아래를 확인합니다. + +- [ ] `auth-service`에 `declineSessionExtend` 구현 +- [ ] `token-manager`에서 `clearRefreshTimer` re-export +- [ ] `BaseLayout`에 `authService` prop 전달 (→ Header까지) +- [ ] `ConfirmProvider` > `AuthWrapper` > `BaseLayout` > `Header` 계층 확인 +- [ ] `AuthWrapper`에 `useSessionExtendConfirm` **없음** 확인 +- [ ] 만료 5분 전 confirm 노출 확인 +- [ ] 확인 클릭 시 `SILENT_REFRESH` API 호출 확인 +- [ ] 취소 클릭 시 만료 1분 전 자동 refresh 미발생 확인 + +## 9) 검증 방법 + +1. 로그인 후 JWT `exp`가 5분 이내가 되도록 대기하거나 테스트 토큰 사용 +2. confirm 문구 `"로그인을 연장하시겠습니까?"` 표시 확인 +3. **확인** → Network 탭에서 `SILENT_REFRESH` PUT 호출, 쿠키 갱신, confirm 재예약 +4. **취소** → 만료 1분 전까지 `SILENT_REFRESH` 자동 호출 없음 +5. 취소 후 세션 만료 → 401 / 로그인 리다이렉트 +6. 새로고침 후 refresh·confirm 타이머 복구 + +## 10) 참고 구현 + +| 앱 | Header (공통) | auth-service | BaseLayout layout | +| --- | ------------------------------------------------------------------------------------------------------------- | --------------------------------------------------------------------------------------------------- | --------------------------------------------------------------------------------------------------------- | +| cs | `[packages/common/components/src/ui/header/index.tsx](../packages/common/components/src/ui/header/index.tsx)` | `[apps/cs/src/entities/auth/api/auth-service.ts](../apps/cs/src/entities/auth/api/auth-service.ts)` | `[apps/cs/app/(main)/(with-base-layout)/layout.tsx](../apps/cs/app/(main)`/(with-base-layout)/layout.tsx) | +| as | (동일) | `[apps/as/src/entities/auth/api/auth-service.ts](../apps/as/src/entities/auth/api/auth-service.ts)` | `[apps/as/src/app/layouts/base-layout.tsx](../apps/as/src/app/layouts/base-layout.tsx)` | +| st | (동일) | `[apps/st/src/entities/auth/api/auth-service.ts](../apps/st/src/entities/auth/api/auth-service.ts)` | `[apps/st/app/(main)/(with-base-layout)/layout.tsx](../apps/st/app/(main)`/(with-base-layout)/layout.tsx) | + +## 11) 관련 문서 + +- [서비스별 인증 처리 리팩토링 가이드](./auth-service-refactoring-guide.md) — 선행 리팩토링 +- `[packages/common/auth/README.md](../packages/common/auth/README.md)` — `getRemainingMs`, token-manager API diff --git a/packages/common/auth/README.md b/packages/common/auth/README.md index 5cd54d787..fbc8742c5 100644 --- a/packages/common/auth/README.md +++ b/packages/common/auth/README.md @@ -200,17 +200,20 @@ if (expiry) { } ``` -#### `getRemainingMs(token: string): number` +#### `getRemainingMs(token: string, bufferMs?: number): number` -토큰 만료 1분 전까지 남은 시간(밀리초)을 반환합니다. +토큰 만료 `bufferMs` 전까지 남은 시간(밀리초)을 반환합니다. 기본 `bufferMs`는 `REFRESH_BUFFER_MS`(1분)입니다. ```typescript -import { getRemainingMs } from "@common/auth"; +import { + getRemainingMs, + REFRESH_BUFFER_MS, + SESSION_EXTEND_CONFIRM_BUFFER_MS, +} from "@common/auth"; const remaining = getRemainingMs(token); -if (remaining > 0) { - console.log("Token valid for:", remaining, "ms"); -} +const confirmIn = getRemainingMs(token, SESSION_EXTEND_CONFIRM_BUFFER_MS); +const isValid = getRemainingMs(token, 0) > 0; ``` ### 쿠키 유틸리티 diff --git a/packages/common/auth/index.ts b/packages/common/auth/index.ts index e069f7c6c..a3d7024da 100644 --- a/packages/common/auth/index.ts +++ b/packages/common/auth/index.ts @@ -2,7 +2,12 @@ export { createTokenManager } from "./token-manager"; export type { TokenManager } from "./token-manager"; export { getCookie, setCookie, removeCookie } from "./cookie"; -export { getJwtExpiry, getRemainingMs } from "./jwt-utils"; +export { + getJwtExpiry, + getRemainingMs, + REFRESH_BUFFER_MS, + SESSION_EXTEND_CONFIRM_BUFFER_MS, +} from "./jwt-utils"; export type { AuthConfig, diff --git a/packages/common/auth/jwt-utils.ts b/packages/common/auth/jwt-utils.ts index 05d2b0ff8..d75acfbd2 100644 --- a/packages/common/auth/jwt-utils.ts +++ b/packages/common/auth/jwt-utils.ts @@ -19,14 +19,20 @@ export function getJwtExpiry(token: string): number | null { } } +export const REFRESH_BUFFER_MS = 60_000; +export const SESSION_EXTEND_CONFIRM_BUFFER_MS = 5 * 60_000; + /** - * 토큰 만료 1분 전까지 남은 시간(ms)을 반환합니다. - * 이미 만료됐거나 파싱 실패 시 0을 반환합니다. + * 토큰 만료 bufferMs 전까지 남은 시간(ms)을 반환합니다. + * 파싱 실패 시 0. bufferMs 기본값은 silent refresh용 1분. */ -export function getRemainingMs(token: string): number { +export function getRemainingMs( + token: string, + bufferMs: number = REFRESH_BUFFER_MS, +): number { const exp = getJwtExpiry(token); if (!exp) return 0; - const remainingMs = exp * 1000 - Date.now() - 60_000; + const remainingMs = exp * 1000 - Date.now() - bufferMs; return Math.max(0, remainingMs); } diff --git a/packages/common/auth/token-manager.ts b/packages/common/auth/token-manager.ts index fed36c366..6347efcb4 100644 --- a/packages/common/auth/token-manager.ts +++ b/packages/common/auth/token-manager.ts @@ -47,11 +47,15 @@ export function createTokenManager(config: AuthConfig) { }, delayMs); }, - clearAuthToken(): void { + clearRefreshTimer(): void { if (refreshTimer) { clearTimeout(refreshTimer); refreshTimer = null; } + }, + + clearAuthToken(): void { + this.clearRefreshTimer(); this.removeToken(config.claimName); this.removeToken(config.authUserId); diff --git a/packages/common/auth/types.ts b/packages/common/auth/types.ts index 292c8edd7..424ed3db0 100644 --- a/packages/common/auth/types.ts +++ b/packages/common/auth/types.ts @@ -6,6 +6,7 @@ export interface AuthPayload { export interface AuthService { login(payload: AuthPayload): Promise; silentRefresh(): Promise; + declineSessionExtend(): void; logout(): Promise; } diff --git a/packages/common/business_components/src/ui/sdmInsertForm/form.tsx b/packages/common/business_components/src/ui/sdmInsertForm/form.tsx index 9da949bee..b68dd7b3f 100644 --- a/packages/common/business_components/src/ui/sdmInsertForm/form.tsx +++ b/packages/common/business_components/src/ui/sdmInsertForm/form.tsx @@ -10,6 +10,7 @@ import { SearchAddressModal } from "../search_address/SearchAddressModal"; // 수요처 정보 export type DemandCompType = { index: number | ""; + dmdCoSn?: number | ""; dmdInfoId: string | ""; bzmnNo: string | ""; dmdCoNm: string | ""; @@ -348,10 +349,10 @@ export function SdmRegForm({ bznNoChange(e, "dmdInfoId", field)} value={formatBusinessNumber(field.value ?? "")} - readOnly={!!updateList?.dmdInfoId} + readOnly={!!readOnly} /> ), }, @@ -367,12 +368,12 @@ export function SdmRegForm({ bznNoChange(e, "bznmTotNo", field)} // {...field} value={field.value ?? ""} - readOnly={!!updateList?.dmdInfoId} + readOnly={!!readOnly} /> ), diff --git a/packages/common/business_components/src/ui/sdmInsertForm/index.tsx b/packages/common/business_components/src/ui/sdmInsertForm/index.tsx index 372cc553e..b39e7585e 100644 --- a/packages/common/business_components/src/ui/sdmInsertForm/index.tsx +++ b/packages/common/business_components/src/ui/sdmInsertForm/index.tsx @@ -140,6 +140,7 @@ export default function SdmInsertForm({selectSidoData, selectSigunData, dataList const dmdInfoId = data.dmdInfoId + (data.bznmTotNo || '0000'); const formData = { ...data, + dmdCoSn: updateList?.dmdCoSn ?? "", dmdInfoId, bzmnNo: data.dmdInfoId, daddr: data.daddr?.trim() || "", diff --git a/packages/common/components/src/lib/index.ts b/packages/common/components/src/lib/index.ts index e00099877..7e6c4a74b 100644 --- a/packages/common/components/src/lib/index.ts +++ b/packages/common/components/src/lib/index.ts @@ -1,3 +1,4 @@ export * from "./cookie"; export * from "./utils"; export * from "./use-mobile"; +export * from "./use-session-extend-confirm"; diff --git a/packages/common/components/src/lib/use-session-extend-confirm.ts b/packages/common/components/src/lib/use-session-extend-confirm.ts new file mode 100644 index 000000000..30c08584a --- /dev/null +++ b/packages/common/components/src/lib/use-session-extend-confirm.ts @@ -0,0 +1,95 @@ +"use client"; + +import { useCallback, useEffect, useRef } from "react"; +import { useConfirm } from "@frontend-opensource/use-react-hooks"; +import { getRemainingMs, SESSION_EXTEND_CONFIRM_BUFFER_MS } from "@common/auth"; + +interface UseSessionExtendConfirmOptions { + enabled: boolean; + getAccessToken: () => string | undefined; + silentRefresh: () => Promise; + declineSessionExtend: () => void; + message?: string; + bufferMs?: number; +} + +const DEFAULT_MESSAGE = "로그인을 연장하시겠습니까?"; + +function useSessionExtendConfirm({ + enabled, + getAccessToken, + silentRefresh, + declineSessionExtend, + message = DEFAULT_MESSAGE, +}: UseSessionExtendConfirmOptions) { + const { confirm } = useConfirm(); + const confirmTimerRef = useRef | null>(null); + const dismissedTokenRef = useRef(null); + const isConfirmingRef = useRef(false); + + const scheduleConfirm = useCallback(() => { + if (confirmTimerRef.current) { + clearTimeout(confirmTimerRef.current); + confirmTimerRef.current = null; + } + + const token = getAccessToken(); + if (!token) return; + + if (dismissedTokenRef.current === token) return; + + const confirmMs = getRemainingMs(token, SESSION_EXTEND_CONFIRM_BUFFER_MS); + const isTokenValid = getRemainingMs(token, 0) > 0; + + const showConfirm = async () => { + if (isConfirmingRef.current) return; + + const currentToken = getAccessToken(); + if (!currentToken || getRemainingMs(currentToken, 0) <= 0) return; + if (dismissedTokenRef.current === currentToken) return; + + isConfirmingRef.current = true; + try { + const accepted = await confirm(message); + if (accepted) { + dismissedTokenRef.current = null; + await silentRefresh(); + scheduleConfirm(); + return; + } + + declineSessionExtend(); + dismissedTokenRef.current = currentToken; + } finally { + isConfirmingRef.current = false; + } + }; + + if (!isTokenValid) return; + + if (confirmMs <= 0) { + void showConfirm(); + return; + } + + confirmTimerRef.current = setTimeout(() => { + void showConfirm(); + }, confirmMs); + }, [message, confirm, getAccessToken, silentRefresh, declineSessionExtend]); + + useEffect(() => { + if (!enabled) return; + + scheduleConfirm(); + + return () => { + if (confirmTimerRef.current) { + clearTimeout(confirmTimerRef.current); + confirmTimerRef.current = null; + } + }; + }, [enabled, scheduleConfirm]); +} + +export { useSessionExtendConfirm }; +export type { UseSessionExtendConfirmOptions }; diff --git a/packages/common/components/src/ui/header/index.tsx b/packages/common/components/src/ui/header/index.tsx index b303b5e9f..99b7977cd 100644 --- a/packages/common/components/src/ui/header/index.tsx +++ b/packages/common/components/src/ui/header/index.tsx @@ -4,9 +4,12 @@ import { useEffect, useCallback, useState, useRef } from "react"; import { useQuery } from "@tanstack/react-query"; import { images } from "@common/assets"; import type { AuthService, TokenManager } from "@common/auth"; +import { getRemainingMs } from "@common/auth"; import { getConfig } from "@common/config"; +import { getAuthLoginRedirectUrl, isAtLoginRedirectUrl } from "@common/network"; import { ENDPOINT } from "../../config"; import { setCookie, getCookie } from "../../lib"; +import { useSessionExtendConfirm } from "../../lib/use-session-extend-confirm"; import { getPopupInfo, getUserPopupList, @@ -97,6 +100,35 @@ async function legacyLogout(tokenManager?: TokenManager) { } } +function formatRemainingMs(remainingMs: number): string { + if (remainingMs <= 0) return "만료됨"; + + const totalSeconds = Math.floor(remainingMs / 1000); + const minutes = Math.floor((totalSeconds % 3600) / 60); + const seconds = totalSeconds % 60; + + return `${minutes}분 ${seconds}초`; +} + +function useTokenRemainingMs(enabled: boolean): number { + const [remainingMs, setRemainingMs] = useState(0); + + useEffect(() => { + if (!enabled) return; + + const update = () => { + const token = getCookie(appConfig.CLAIM_NAME ?? ""); + setRemainingMs(token ? getRemainingMs(token) : 0); + }; + + update(); + const id = setInterval(update, 1000); + return () => clearInterval(id); + }, [enabled]); + + return remainingMs; +} + export function Header({ authService, tokenManager, @@ -111,6 +143,19 @@ export function Header({ const [logoWidth, setLogoWidth] = useState(0); const [isMobileMenuOpen, setIsMobileMenuOpen] = useState(false); const logoRef = useRef(null); + const remainingMs = useTokenRemainingMs(mounted && isLoggedIn && !viewOnly); + + useSessionExtendConfirm({ + enabled: + mounted && + isLoggedIn && + !viewOnly && + !!authService && + !isAtLoginRedirectUrl(getAuthLoginRedirectUrl()), + getAccessToken: () => getCookie(appConfig.CLAIM_NAME ?? ""), + silentRefresh: authService?.silentRefresh ?? (async () => {}), + declineSessionExtend: authService?.declineSessionExtend ?? (() => {}), + }); const { data: userInfo } = useQuery({ queryKey: [ENDPOINT.USER_SERVICE.USERS], @@ -261,18 +306,11 @@ export function Header({ - -
    -
  • - -
  • -
  • - 로그아웃 -
  • -
+ + )} @@ -337,18 +375,11 @@ export function Header({ - -
    -
  • - -
  • -
  • - 로그아웃 -
  • -
+ + ) @@ -359,3 +390,31 @@ export function Header({ ); } + +function UserMenuContent({ + remainingMs, + onLogout, +}: { + remainingMs: number; + onLogout: () => void; +}) { + return ( +
    +
  • + 로그인 만료까지 + + {formatRemainingMs(remainingMs)} + +
  • +
  • + +
  • +
  • + 로그아웃 +
  • +
+ ); +} diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 1d5dea9d4..d914bd2bc 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -104,10 +104,10 @@ importers: devDependencies: eslint: specifier: ^9.0.0 - version: 9.39.4(jiti@2.6.1) + version: 9.39.4(jiti@1.21.7) eslint-config-next: specifier: 15.3.8 - version: 15.3.8(eslint@9.39.4(jiti@2.6.1))(typescript@5.9.3) + version: 15.3.8(eslint@9.39.4(jiti@1.21.7))(typescript@5.9.3) postcss: specifier: ^8.4.49 version: 8.5.8 @@ -204,13 +204,13 @@ importers: version: 3.0.6 eslint: specifier: ^9.0.0 - version: 9.39.4(jiti@1.21.7) + version: 9.39.4(jiti@2.6.1) eslint-config-next: specifier: 15.3.8 - version: 15.3.8(eslint@9.39.4(jiti@1.21.7))(typescript@5.9.3) + version: 15.3.8(eslint@9.39.4(jiti@2.6.1))(typescript@5.9.3) eslint-plugin-react-hooks: specifier: ^5.0.0 - version: 5.2.0(eslint@9.39.4(jiti@1.21.7)) + version: 5.2.0(eslint@9.39.4(jiti@2.6.1)) postcss: specifier: ^8.4.49 version: 8.5.8 @@ -531,6 +531,9 @@ importers: '@common/config': specifier: workspace:* version: link:../../packages/common/config + '@common/network': + specifier: workspace:* + version: link:../../packages/common/network '@fontsource-variable/noto-sans-kr': specifier: ^5.2.5 version: 5.2.10