diff --git a/.gitignore b/.gitignore
index 7175c78d4..ee2cfd052 100644
--- a/.gitignore
+++ b/.gitignore
@@ -4,10 +4,7 @@
# dependencies
node_modules/
-
-# build
-.next/
-dist/
+.idea/
# build
.next
@@ -22,9 +19,14 @@ apps/fo/.next/**/*
#.env*
.qodo
+# next-agents-md
+.next-docs/
+
pnpm-lock.yaml
settings.json
.npmrc
.cursor
+.agent
-
+apps/**/tsconfig.tsbuildinfo
+logs/
diff --git a/.husky/pre-push b/.husky/pre-push
index 0ddfdb46e..2adeaead9 100755
--- a/.husky/pre-push
+++ b/.husky/pre-push
@@ -3,4 +3,8 @@ BRANCH=$(git rev-parse --abbrev-ref HEAD)
if [ "$BRANCH" != "main" ]; then
exit 0
fi
+echo "Branch: $BRANCH"
+echo "Changed packages (dry-run):"
+pnpm turbo run build --filter="...[origin/$BRANCH]" --dry-run
+echo "Starting build..."
pnpm turbo run build --filter="...[origin/$BRANCH]"
diff --git a/apps/_template/.env.example b/apps/_template/.env.example
index b2d706024..f9794a6f8 100644
--- a/apps/_template/.env.example
+++ b/apps/_template/.env.example
@@ -3,3 +3,6 @@ NEXT_PUBLIC_PORT=
NEXT_PUBLIC_PROXY_HOST=
NEXT_PUBLIC_SITE_ID=
NEXT_PUBLIC_SERVER_API_URL=
+
+# 401 시 브라우저 리다이렉트 대상
+NEXT_PUBLIC_AUTH_LOGIN_REDIRECT_URL=/auth/login
diff --git a/apps/_template/package.json b/apps/_template/package.json
index fb27f8af7..17b53990f 100644
--- a/apps/_template/package.json
+++ b/apps/_template/package.json
@@ -25,7 +25,7 @@
},
"devDependencies": {
"eslint": "^9.0.0",
- "eslint-config-next": "15.4.0",
+ "eslint-config-next": "15.3.8",
"postcss": "^8.4.49",
"prettier": "^3.4.2",
"prettier-plugin-tailwindcss": "^0.6.11",
diff --git a/apps/cm/package.json b/apps/cm/package.json
index 9d4e1da8f..bde698393 100644
--- a/apps/cm/package.json
+++ b/apps/cm/package.json
@@ -52,7 +52,7 @@
"@types/node": "^22.13.1",
"@types/webpack": "^5.28.5",
"eslint": "^9.0.0",
- "eslint-config-next": "15.4.0",
+ "eslint-config-next": "15.3.8",
"postcss": "^8.4.49",
"prettier": "^3.4.2",
"prettier-plugin-tailwindcss": "^0.6.11",
diff --git a/apps/cm/src/entities/auth/lib/auth-wrapper.tsx b/apps/cm/src/entities/auth/lib/auth-wrapper.tsx
index 1aa4f8e61..5366696f7 100644
--- a/apps/cm/src/entities/auth/lib/auth-wrapper.tsx
+++ b/apps/cm/src/entities/auth/lib/auth-wrapper.tsx
@@ -5,16 +5,15 @@ import { useRouter } from "next/navigation";
import { client } from "@/shared/api";
import type { IUserInfo } from "@/shared/store";
import { getCookie, useUserStore } from "@/shared/lib";
+import { isAtLoginRedirectUrl } from "@common/network";
import { ENDPOINT, AUTH_USER_ID } from "@/shared/config";
-import { LoginApi } from "../api/login-service";
const AuthWrapper = ({ children }: { children: React.ReactNode }) => {
const router = useRouter();
const { userInfo, setUserInfo } = useUserStore((state) => state);
async function authCheck() {
- // 로그인 페이지에서는 인증 체크를 건너뜀
- if (window.location.pathname === '/auth/login') {
+ if (isAtLoginRedirectUrl("/auth/login")) {
return;
}
@@ -35,13 +34,8 @@ const AuthWrapper = ({ children }: { children: React.ReactNode }) => {
await authCheck();
} catch (error) {
console.error(error);
-
- try {
- await LoginApi.silentRefresh();
- await authCheck();
- } catch (refreshError) {
- console.error("Token refresh failed:", refreshError);
- setUserInfo(null);
+ setUserInfo(null);
+ if (!isAtLoginRedirectUrl("/auth/login")) {
router.push("/auth/login");
}
}
diff --git a/apps/cm/src/pages/auth/login/ui/login-page.tsx b/apps/cm/src/pages/auth/login/ui/login-page.tsx
index fed7be4ad..297d28a8f 100644
--- a/apps/cm/src/pages/auth/login/ui/login-page.tsx
+++ b/apps/cm/src/pages/auth/login/ui/login-page.tsx
@@ -145,11 +145,11 @@ function LoginPage() {
- 유역하수도 통합플랫폼
+ 유역하수도통합플랫폼
- 통합관리시스템
+ 하수도 포탈
-
유역하수도 통합플랫폼에 오신 것을 환영합니다.
+
유역하수도통합플랫폼 하수도 포탈에 오신 것을 환영합니다.
diff --git a/apps/common/features/next.config.ts b/apps/common/features/next.config.ts
index 34965cf32..8a50ea611 100644
--- a/apps/common/features/next.config.ts
+++ b/apps/common/features/next.config.ts
@@ -1,6 +1,21 @@
import type { NextConfig } from "next";
const nextConfig: NextConfig = {
+ output: "standalone",
+ experimental: {
+ proxyTimeout: 600000,
+ },
+ eslint: {
+ ignoreDuringBuilds: true,
+ },
+ typescript: {
+ ignoreBuildErrors: true,
+ },
+ logging: {
+ fetches: {
+ fullUrl: true,
+ },
+ },
async rewrites() {
return {
afterFiles: [
diff --git a/apps/common/features/package.json b/apps/common/features/package.json
index 14a96b6f8..7118ae49d 100644
--- a/apps/common/features/package.json
+++ b/apps/common/features/package.json
@@ -46,7 +46,7 @@
"@types/node": "^22.13.1",
"@types/webpack": "^5.28.5",
"eslint": "^9",
- "eslint-config-next": "15.1.7",
+ "eslint-config-next": "15.3.8",
"postcss": "^8",
"prettier": "^3.4.2",
"prettier-plugin-tailwindcss": "^0.6.11",
diff --git a/apps/common/features/src/entities/auth/lib/auth-wrapper.tsx b/apps/common/features/src/entities/auth/lib/auth-wrapper.tsx
index e518f938a..d514569e0 100644
--- a/apps/common/features/src/entities/auth/lib/auth-wrapper.tsx
+++ b/apps/common/features/src/entities/auth/lib/auth-wrapper.tsx
@@ -4,13 +4,17 @@ import { useEffect } from "react";
import { client } from "@/shared/api";
import type { IUserInfo } from "@/shared/store";
import { getCookie, useUserStore } from "@/shared/lib";
+import { getAuthLoginRedirectUrl, isAtLoginRedirectUrl } from "@common/network";
import { ENDPOINT, AUTH_USER_ID } from "@/shared/config";
-import { LoginApi } from "../api/login-service";
const AuthWrapper = ({ children }: { children: React.ReactNode }) => {
const { userInfo, setUserInfo } = useUserStore((state) => state);
async function authCheck() {
+ if (isAtLoginRedirectUrl(getAuthLoginRedirectUrl())) {
+ return;
+ }
+
const userId = getCookie(AUTH_USER_ID);
if (!userId) {
throw new Error("Missing required authentication tokens");
@@ -28,8 +32,6 @@ const AuthWrapper = ({ children }: { children: React.ReactNode }) => {
await authCheck();
} catch (error) {
console.error(error);
- await LoginApi.silentRefresh();
- await authCheck();
}
})();
}, []);
diff --git a/apps/cs/.gitignore b/apps/cs/.gitignore
index b63718b61..f80a96304 100644
--- a/apps/cs/.gitignore
+++ b/apps/cs/.gitignore
@@ -38,4 +38,5 @@ yarn-error.log*
next-env.d.ts
.env*
-!.env.example
\ No newline at end of file
+!.env.example
+/start_cs.log
diff --git a/apps/cs/package.json b/apps/cs/package.json
index 03ec06fcf..27f43fcc1 100644
--- a/apps/cs/package.json
+++ b/apps/cs/package.json
@@ -52,7 +52,7 @@
"@types/node": "^22.13.1",
"@types/webpack": "^5.28.5",
"eslint": "^9.0.0",
- "eslint-config-next": "15.4.0",
+ "eslint-config-next": "15.3.8",
"postcss": "^8.4.49",
"prettier": "^3.4.2",
"prettier-plugin-tailwindcss": "^0.6.11",
diff --git a/apps/cs/src/entities/auth/lib/auth-wrapper.tsx b/apps/cs/src/entities/auth/lib/auth-wrapper.tsx
index c0be4c2dd..57ae75c34 100644
--- a/apps/cs/src/entities/auth/lib/auth-wrapper.tsx
+++ b/apps/cs/src/entities/auth/lib/auth-wrapper.tsx
@@ -1,18 +1,24 @@
"use client";
import { useEffect } from "react";
-import { useRouter } from "next/navigation";
import { client } from "@/shared/api";
import type { IUserInfo } from "@/shared/store";
import { getCookie, useUserStore } from "@/shared/lib";
-import { ENDPOINT, AUTH_USER_ID } from "@/shared/config";
-import { LoginApi } from "../api/login-service";
+import { isAtLoginRedirectUrl } from "@common/network";
+import {
+ ENDPOINT,
+ AUTH_USER_ID,
+ AUTH_LOGIN_REDIRECT_URL,
+} from "@/shared/config";
const AuthWrapper = ({ children }: { children: React.ReactNode }) => {
- const router = useRouter();
const { userInfo, setUserInfo } = useUserStore((state) => state);
async function authCheck() {
+ if (isAtLoginRedirectUrl(AUTH_LOGIN_REDIRECT_URL)) {
+ return;
+ }
+
const userId = getCookie(AUTH_USER_ID);
if (!userId) {
throw new Error("Missing required authentication tokens");
@@ -30,14 +36,9 @@ const AuthWrapper = ({ children }: { children: React.ReactNode }) => {
await authCheck();
} catch (error) {
console.error(error);
-
- try {
- await LoginApi.silentRefresh();
- await authCheck();
- } catch (refreshError) {
- console.error("Token refresh failed:", refreshError);
- setUserInfo(null);
- router.push("/auth/login");
+ setUserInfo(null);
+ if (!isAtLoginRedirectUrl(AUTH_LOGIN_REDIRECT_URL)) {
+ window.location.assign(AUTH_LOGIN_REDIRECT_URL);
}
}
})();
diff --git a/apps/cs/src/pages/loginlog/ui/loginlog-page.tsx b/apps/cs/src/pages/loginlog/ui/loginlog-page.tsx
index 7ae7990a1..d94d79c9a 100644
--- a/apps/cs/src/pages/loginlog/ui/loginlog-page.tsx
+++ b/apps/cs/src/pages/loginlog/ui/loginlog-page.tsx
@@ -39,10 +39,10 @@ function LoginlogPage() {
const [presetDateRange, setPresetDateRange] = useState("");
const [dateRange, setDateRange] = useState
(defaultDateRange);
const [userId, setUserId] = useState("");
- const [errOcrnYn, setErrOcrnYn] = useState("N");
+ const [errOcrnYn, setErrOcrnYn] = useState("ALL");
const [searchParams, setSearchParams] = useState({
userId: "",
- errOcrnYn: "N",
+ errOcrnYn: "",
searchDateFrom: dayjs().subtract(7, "day").format("YYYY-MM-DD"),
searchDateTo: dayjs().format("YYYY-MM-DD"),
sort: "lgnDt,desc",
@@ -87,7 +87,7 @@ function LoginlogPage() {
setSearchParams({
...searchParams,
userId,
- errOcrnYn,
+ errOcrnYn: errOcrnYn === "ALL" ? "" : errOcrnYn,
searchDateFrom: dateRange.from
? dayjs(dateRange.from).format("YYYY-MM-DD")
: "",
@@ -102,12 +102,12 @@ function LoginlogPage() {
const handleReset = () => {
setUserId("");
- setErrOcrnYn("N");
+ setErrOcrnYn("ALL");
setDateRange(defaultDateRange);
setSearchParams({
...searchParams,
userId: "",
- errOcrnYn: "N",
+ errOcrnYn: "",
searchDateFrom: dayjs().subtract(7, "day").format("YYYY-MM-DD"),
searchDateTo: dayjs().format("YYYY-MM-DD"),
});
@@ -180,6 +180,7 @@ function LoginlogPage() {
+ 전체
정상
오류
diff --git a/apps/cs/src/shared/config/env.ts b/apps/cs/src/shared/config/env.ts
index c6373724f..95d1a4477 100644
--- a/apps/cs/src/shared/config/env.ts
+++ b/apps/cs/src/shared/config/env.ts
@@ -2,6 +2,8 @@ export const PORT = process.env.NEXT_PUBLIC_PORT || "3000";
export const PROXY_HOST =
process.env.NEXT_PUBLIC_PROXY_HOST || `http://localhost:${PORT}`;
export const SERVER_API_URL = process.env.NEXT_PUBLIC_SERVER_API_URL;
+export const AUTH_LOGIN_REDIRECT_URL =
+ process.env.NEXT_PUBLIC_AUTH_LOGIN_REDIRECT_URL || "/auth/login";
export const SITE_ID = process.env.NEXT_PUBLIC_SITE_ID || "CS";
export const CLAIM_NAME = process.env.NEXT_PUBLIC_CLAIM_NAME || "Authorization";
export const ACCESS_TOKEN =
diff --git a/apps/dc/package.json b/apps/dc/package.json
index 747cdf91e..5835b9ab6 100644
--- a/apps/dc/package.json
+++ b/apps/dc/package.json
@@ -41,7 +41,7 @@
"@types/node": "^22.17.0",
"@types/webpack": "^5.28.5",
"eslint": "^9.0.0",
- "eslint-config-next": "15.4.0",
+ "eslint-config-next": "15.3.8",
"postcss": "^8.4.49",
"prettier": "^3.4.2",
"prettier-plugin-tailwindcss": "^0.6.11",
diff --git a/apps/dc/src/entities/auth/lib/auth-wrapper.tsx b/apps/dc/src/entities/auth/lib/auth-wrapper.tsx
index 1f196ed52..57ae75c34 100644
--- a/apps/dc/src/entities/auth/lib/auth-wrapper.tsx
+++ b/apps/dc/src/entities/auth/lib/auth-wrapper.tsx
@@ -1,20 +1,24 @@
"use client";
import { useEffect } from "react";
-import { useRouter } from "next/navigation";
import { client } from "@/shared/api";
import type { IUserInfo } from "@/shared/store";
import { getCookie, useUserStore } from "@/shared/lib";
-import { ENDPOINT, AUTH_USER_ID } from "@/shared/config";
-import { LoginApi } from "../api/login-service";
-
-const CLAIM_NAME = process.env.NEXT_PUBLIC_CLAIM_NAME || "Authorization";
+import { isAtLoginRedirectUrl } from "@common/network";
+import {
+ ENDPOINT,
+ AUTH_USER_ID,
+ AUTH_LOGIN_REDIRECT_URL,
+} from "@/shared/config";
const AuthWrapper = ({ children }: { children: React.ReactNode }) => {
- const router = useRouter();
const { userInfo, setUserInfo } = useUserStore((state) => state);
async function authCheck() {
+ if (isAtLoginRedirectUrl(AUTH_LOGIN_REDIRECT_URL)) {
+ return;
+ }
+
const userId = getCookie(AUTH_USER_ID);
if (!userId) {
throw new Error("Missing required authentication tokens");
@@ -32,14 +36,9 @@ const AuthWrapper = ({ children }: { children: React.ReactNode }) => {
await authCheck();
} catch (error) {
console.error(error);
-
- try {
- await LoginApi.silentRefresh();
- await authCheck();
- } catch (refreshError) {
- console.error("Token refresh failed:", refreshError);
- setUserInfo(null);
- router.push("/auth/login");
+ setUserInfo(null);
+ if (!isAtLoginRedirectUrl(AUTH_LOGIN_REDIRECT_URL)) {
+ window.location.assign(AUTH_LOGIN_REDIRECT_URL);
}
}
})();
diff --git a/apps/dc/src/shared/config/env.ts b/apps/dc/src/shared/config/env.ts
index 89d2c039c..9edea0e2d 100644
--- a/apps/dc/src/shared/config/env.ts
+++ b/apps/dc/src/shared/config/env.ts
@@ -3,10 +3,14 @@ export const PROXY_HOST =
process.env.NEXT_PUBLIC_PROXY_HOST || `http://localhost:${PORT}`;
export const SERVER_API_URL = process.env.NEXT_PUBLIC_SERVER_API_URL;
-export const NEXT_PUBLIC_DC_SERVICE_API_URL = process.env.NEXT_PUBLIC_DC_SERVICE_API_URL || `http://localhost:${PORT}`;
+export const NEXT_PUBLIC_DC_SERVICE_API_URL =
+ process.env.NEXT_PUBLIC_DC_SERVICE_API_URL || `http://localhost:${PORT}`;
+export const AUTH_LOGIN_REDIRECT_URL =
+ process.env.NEXT_PUBLIC_AUTH_LOGIN_REDIRECT_URL || "/auth/login";
// Flask API URL
-export const FLASK_API_URL = process.env.NEXT_PUBLIC_FLASK_API_URL || "http://localhost:5000";
+export const FLASK_API_URL =
+ process.env.NEXT_PUBLIC_FLASK_API_URL || "http://localhost:5000";
//export const SITE_ID = process.env.NEXT_PUBLIC_SITE_ID || "DC";
export const SITE_ID = "DC";
diff --git a/apps/dm/package.json b/apps/dm/package.json
index 71d504f50..844e0b1aa 100644
--- a/apps/dm/package.json
+++ b/apps/dm/package.json
@@ -29,7 +29,7 @@
"@eslint/eslintrc": "^3",
"@types/webpack": "^5.28.5",
"eslint": "^9",
- "eslint-config-next": "15.1.7",
+ "eslint-config-next": "15.3.8",
"postcss": "^8",
"tailwindcss": "^3.4.1",
"typescript": "^5"
diff --git a/apps/dm_new/app/(landing)/reuse/apply/status/page.tsx b/apps/dm_new/app/(landing)/reuse/apply/status/page.tsx
index b69b0fdd9..3282f54a5 100644
--- a/apps/dm_new/app/(landing)/reuse/apply/status/page.tsx
+++ b/apps/dm_new/app/(landing)/reuse/apply/status/page.tsx
@@ -3,6 +3,7 @@
import { useEffect, useMemo, useRef, useState } from "react";
import SubPageHeader from "../../_components/SubPageHeader";
+//import { useDmStore } from "@/app/store";
type WaterDetail = {
toc: string;
@@ -114,12 +115,17 @@ type PairField = { label: string; value: string };
export default function DemandStatusPage() {
const rows = useMemo(() => MOCK, []);
- // ✅ rows가 빈 경우도 대비 (런타임 오류 방지)
+ // rows가 빈 경우도 대비 (런타임 오류 방지)
const [selectedId, setSelectedId] = useState(rows[0]?.id ?? null);
- // ✅ 상세영역 스크롤 이동용
+ // 상세영역 스크롤 이동용
const detailRef = useRef(null);
+ // update
+ /* const updateSdmInfoData = useDmStore(
+ (state) => state.updateSdmInfoData
+ ); */
+
// ✅ selected 계산 안정화
const selected = useMemo(() => {
if (!rows.length) return null;
diff --git a/apps/dm_new/app/(popup-layout)/map/popup/page.tsx b/apps/dm_new/app/(popup-layout)/map/popup/page.tsx
index 1c625bcac..bdce68a87 100644
--- a/apps/dm_new/app/(popup-layout)/map/popup/page.tsx
+++ b/apps/dm_new/app/(popup-layout)/map/popup/page.tsx
@@ -1,6 +1,7 @@
"use client";
import Gis from "@common/business_components/ui/map/Gis";
+import "@/app/commonOrigin.css";
import { useEffect, useRef, useState } from "react";
import { mapService } from "../service";
import GisApp from "@common/business_components/ui/map/lib/gis.core";
diff --git a/apps/dm_new/app/(with-layout)/apply/register/page.tsx b/apps/dm_new/app/(with-layout)/apply/register/page.tsx
index 7a471d0fe..80faee50e 100644
--- a/apps/dm_new/app/(with-layout)/apply/register/page.tsx
+++ b/apps/dm_new/app/(with-layout)/apply/register/page.tsx
@@ -13,9 +13,10 @@ import { useRouter } from "next/navigation";
import SubPageHeader from "@/components/SubPageHeader";
import WaterQualityRefModal from "../modal/WaterQualityRefModal";
import CandidateSiteModal, { type CandidateSite } from "@/components/CandidateSiteModal";
-import { useDmStore} from "@/app/store";
+import { useDmStore } from "@/app/store";
import type { SdmInfoFormData } from "@/app/store/sdmMng";
import SimilarCaseModal, { type SimilarCaseDetail, type SimilarCaseRow } from "@/app/(landing)/reuse/apply/modal/SimilarCaseModal";
+import type { SubsInfo } from "@/app/store/map/type";
type PHRange = { min: string; max: string };
@@ -60,6 +61,7 @@ export default function DemandRegisterPage() {
const [cand1, setCand1] = useState("");
const [cand2, setCand2] = useState("");
const [cand3, setCand3] = useState("");
+ const [candidateList, setCandidateList] = useState([]);
const useTypesData = useDmStore((state) => state.Sdm.useTypesData);
const getUseTypesData = useDmStore((state) => state.getUseTypesData);
@@ -121,16 +123,38 @@ export default function DemandRegisterPage() {
getUseTypesData();
}, [getUseTypesData]);
+ useEffect(() => {
+ const handler = (event: MessageEvent) => {
+ if (!event.data) return;
+
+ if (event.data.type === "SET_SUBS_INFO") {
+ const list = event.data.payload || [];
+
+ // 최대 3개
+ setCand1(list[0]?.ruseUtztnFcltNm || "");
+ setCand2(list[1]?.ruseUtztnFcltNm || "");
+ setCand3(list[2]?.ruseUtztnFcltNm || "");
+ setCandidateList(list);
+ }
+ };
+
+ window.addEventListener("message", handler);
+
+ return () => {
+ window.removeEventListener("message", handler);
+ };
+ }, []);
+
// option
// 'req':필수 / 'num' : 숫자 / 'num+[숫자] : 자릿수
- const validation = (val, text, option :string[]=[] ) => {
- if(option.includes('req')){
- if(val.trim() === "" || val ===undefined) {
- alert(`${text} 항목은 필수 입력값입니다.`);
+ const validation = (val, text, option: string[] = []) => {
+ if (option.includes('req')) {
+ if (val.trim() === "" || val === undefined) {
+ alert(`${text} 항목은 필수 입력값입니다.`);
return false;
}
- }else{
- if(val.trim()===""){
+ } else {
+ if (val.trim() === "") {
return true;
}
}
@@ -144,8 +168,7 @@ export default function DemandRegisterPage() {
if (!regex.test(val)) {
alert(
- `${text}은(는) ${
- isDot ? "숫자 또는 소수" : "숫자"
+ `${text}은(는) ${isDot ? "숫자 또는 소수" : "숫자"
}만 입력 가능합니다.`
);
return false;
@@ -230,29 +253,29 @@ export default function DemandRegisterPage() {
});
// 데이터 저장
- const saveInfo = async() => {
- if(!validation(reqQty, '요구수량', ['req', 'numDot'])) return false;
- if(!validation(usePurpose, '사용용도', ['req'])) return false;
- if(!validation(hopePrice, '희망단가', ['req', 'numDot'])) return false;
- /* if(!noDetect && !validation(coliform, '총대장균군수', ['req','numDot'])) return false;
- if(!validation(residualCl, ['req', '결합잔류염소', 'numDot'])) return false;
- if(!validation(turbidity, '탁도', ['req', 'numDot'])) return false;
- if(!validation(bod, '생물화학적산소요구량', ['req', 'numDot'])) return false;
- if(!validation(odor, '냄새', ['req'])) return false; */
-
+ const saveInfo = async () => {
+ if (!validation(reqQty, '요구수량', ['req', 'numDot'])) return false;
+ if (!validation(usePurpose, '사용용도', ['req'])) return false;
+ if (!validation(hopePrice, '희망단가', ['req', 'numDot'])) return false;
+ /* if(!noDetect && !validation(coliform, '총대장균군수', ['req','numDot'])) return false;
+ if(!validation(residualCl, ['req', '결합잔류염소', 'numDot'])) return false;
+ if(!validation(turbidity, '탁도', ['req', 'numDot'])) return false;
+ if(!validation(bod, '생물화학적산소요구량', ['req', 'numDot'])) return false;
+ if(!validation(odor, '냄새', ['req'])) return false; */
+
// 필수는 아니지만 숫자로는 들어와야함.
- /* if(!validation(tn, '총질소', ['numDot'])) return false;
- if(!validation(residualCl, '결합잔류염소', ['numDot'])) return false;
- if(!validation(tp, '총인', ['numDot'])) return false;
- if(!validation(ph.min, 'ph', ['numDot'])) return false;
- if(!validation(ph.max, 'ph', ['numDot'])) return false;
- if(!validation(chloride, '염화물', ['numDot'])) return false;
- if(!validation(ec, '전기전도도', ['numDot'])) return false; */
-
+ /* if(!validation(tn, '총질소', ['numDot'])) return false;
+ if(!validation(residualCl, '결합잔류염소', ['numDot'])) return false;
+ if(!validation(tp, '총인', ['numDot'])) return false;
+ if(!validation(ph.min, 'ph', ['numDot'])) return false;
+ if(!validation(ph.max, 'ph', ['numDot'])) return false;
+ if(!validation(chloride, '염화물', ['numDot'])) return false;
+ if(!validation(ec, '전기전도도', ['numDot'])) return false; */
+
const userStr = sessionStorage.getItem("user");
const user = userStr ? JSON.parse(userStr) : null;
- if(user){
+ if (user) {
// 후보지 목록 (candsRef에 코드까지 보관됨)
const splyCndtList = candsRef.current
.filter((x) => x.name)
@@ -261,25 +284,25 @@ export default function DemandRegisterPage() {
ruseUtztnFcltCd: x.code,
}));
- const payload : SdmInfoFormData = {
- dmndTmng : new Date(reqDate),
- dmdInfoId : user.dmdInfoId,
- dmndQty : Number(reqQty),
- wntUntprc : Number(hopePrice),
- useUsgCd : usePurpose,
+ const payload: SdmInfoFormData = {
+ dmndTmng: new Date(reqDate),
+ dmdInfoId: user.dmdInfoId,
+ dmndQty: Number(reqQty),
+ wntUntprc: Number(hopePrice),
+ useUsgCd: usePurpose,
// 수질
- grssClfg : Number(coliform),
- tn : Number(tn)||null,
- combResdch: Number(residualCl)||null,
- tph : Number(tp)||null,
- trbt : Number(turbidity),
- ph : ph.min||''+((ph.min||ph.max)?'~':'')+ph.max||'',
- blgcOxygDmndQnt : Number(bod),
- clIonQnt : Number(chloride)||null,
- smll : odor,
- cdtnv : Number(ec)||null,
- crmty : Number(color)||null,
- rmrk : remark,
+ grssClfg: Number(coliform),
+ tn: Number(tn) || null,
+ combResdch: Number(residualCl) || null,
+ tph: Number(tp) || null,
+ trbt: Number(turbidity),
+ ph: ph.min || '' + ((ph.min || ph.max) ? '~' : '') + ph.max || '',
+ blgcOxygDmndQnt: Number(bod),
+ clIonQnt: Number(chloride) || null,
+ smll: odor,
+ cdtnv: Number(ec) || null,
+ crmty: Number(color) || null,
+ rmrk: remark,
splyCndtList, // 후보지 목록 추가
}
const success = await insertSdmInfoData(payload);
@@ -344,13 +367,13 @@ export default function DemandRegisterPage() {
const fncSetOptionValue = (e: React.ChangeEvent) => {
const value = e.target.value;
initQualityForm();
-
+
switch (value) {
// 청소화장실용도
case '01':
setTp('0.2');
setTurbidity('2');
- setPh({min:'5.8', max:'8.5'});
+ setPh({ min: '5.8', max: '8.5' });
setBod('5');
setColor('20');
break;
@@ -358,14 +381,14 @@ export default function DemandRegisterPage() {
case '02':
setTp('0.2');
setTurbidity('2');
- setPh({min:'5.8', max:'8.5'});
+ setPh({ min: '5.8', max: '8.5' });
setBod('5');
break;
// 조경용도
case '03':
setTp('0.2');
setTurbidity('2');
- setPh({min:'5.8', max:'8.5'});
+ setPh({ min: '5.8', max: '8.5' });
setBod('5');
setChloride('250');
break;
@@ -373,318 +396,318 @@ export default function DemandRegisterPage() {
case '05':
break;
// 농업용도
- case '06' :
+ case '06':
setTurbidity('5');
- setPh({min:'5.8', max:'8.5'});
+ setPh({ min: '5.8', max: '8.5' });
setBod('8');
setEc('2000');
setFarmText("이하 (** 간접:2000,직접:700 )");
break;
// 하천 등 유지보수
- case '07' :
+ case '07':
setColiform('1000');
setTn('10');
setTp('0.5');
- setPh({min:'5.8', max:'8.5'});
+ setPh({ min: '5.8', max: '8.5' });
setBod('5');
break;
// 공업
case '08':
- setPh({min:'5.8', max:'8.5'});
+ setPh({ min: '5.8', max: '8.5' });
break;
default:
console.log('잘못된 선택입니다.');
}
-
+
setUsePurpose(value);
}
return (
<>
-
-
- {/* 서브 타이틀 + 브레드크럼 */}
-
+
- {/* 컨텐츠 영역 */}
-
+ {/* 서브 타이틀 + 브레드크럼 */}
+
- {/* 기본정보 테이블 */}
-
-
-
-
+ {/* 컨텐츠 영역 */}
+
-
+ {/* 기본정보 테이블 */}
+
+
+
+
-
-
-
+
-
+
+
+
-
-
-
+
-
+
+
+
-
-
-
-
+
- {/* 요구수질 섹션 타이틀 바 */}
-
-
- 요구수질
-
- {/* 수질기준참고 모달 */}
- setOpenRef(false)}
- imageSrc="/images/landing/water_quality_ref_1.png"
- />
-
-
+
+
+
+
- {/* 요구수질 테이블 */}
-
- {/* 1행 */}
-
-
-
-
- }
- rightLabel="총질소(T-N)(mg/L)"
- rightControl={
-
- }
- />
-
-
- {/* 2행 */}
-
}
- rightLabel="총인(T-P)(mg/L)"
- rightControl={
-
- }
- />
-
-
- {/* 3행 */}
-
- }
- rightLabel="수소이온농도(pH)"
- rightControl={
-
- setPh((p) => ({ ...p, min: v }))}
- className="w-32"
- />
- ~
- setPh((p) => ({ ...p, max: v }))}
- className="w-32"
- />
-
- }
- />
-
-
- {/* 4행 */}
-
- 생물화학적산소요구량
-
- (BOD)(mg/L)
- >
- }
- /* leftRequired */
- leftControl={}
- rightLabel="염화물(mgCL/L)"
- rightControl={}
- />
-
-
- {/* 5행 */}
- setOdor(e.target.value)}
- className="h-11 w-full max-w-[200px] rounded-md border border-neutral-300 bg-white px-3 text-sm outline-none focus:border-neutral-500"
- >
-
-
-
-
- }
- rightLabel="전기전도도(µs/cm)"
- rightControl={}
- />
-
-
- {/* 6행 */}
- }
- rightLabel="비고"
- rightControl={
-
-
- {/* 하단 버튼 */}
-
-
-
+ {/* 요구수질 섹션 타이틀 바 */}
+
+
+ 요구수질
+
+ {/* 수질기준참고 모달 */}
+ setOpenRef(false)}
+ imageSrc="/images/landing/water_quality_ref_1.png"
+ />
-
+
-
setOpenSimilar(false)}
- rows={rows} // 실제 데이터
- detailsByCaseId={detailsByCaseId}
- onSearch={async (criterion) => {
- const code = getCriterionCode(criterion);
- if (!code) return;
- // 값은 상황에 맞게 (예시: usePurpose)
- await getSimilarCaseList(code, code=='S'?reqQty:code=='U'?usePurpose:'');
- }}
- />
+ {/* 요구수질 테이블 */}
+
+ {/* 1행 */}
+
setOpenCand(false)}
- initialSelected={selectedCands}
- onApply={(list) => {
- setSelectedCands(list);
+ leftControl={
+
+
+
+
+ }
+ rightLabel="총질소(T-N)(mg/L)"
+ rightControl={
+
+ }
+ />
+
- // ✅ cand1~3 입력칸에 반영(시설명만 넣는 버전)
- setCand1(list[0]?.name ?? "");
- setCand2(list[1]?.name ?? "");
- setCand3(list[2]?.name ?? "");
+ {/* 2행 */}
+ }
+ rightLabel="총인(T-P)(mg/L)"
+ rightControl={
+
+ }
+ />
+
- setOpenCand(false);
- }}
- />
+ {/* 3행 */}
+
+ }
+ rightLabel="수소이온농도(pH)"
+ rightControl={
+
+ setPh((p) => ({ ...p, min: v }))}
+ className="w-32"
+ />
+ ~
+ setPh((p) => ({ ...p, max: v }))}
+ className="w-32"
+ />
+
+ }
+ />
+
+
+ {/* 4행 */}
+
+ 생물화학적산소요구량
+
+ (BOD)(mg/L)
+ >
+ }
+ /* leftRequired */
+ leftControl={}
+ rightLabel="염화물(mgCL/L)"
+ rightControl={}
+ />
+
+
+ {/* 5행 */}
+ setOdor(e.target.value)}
+ className="h-11 w-full max-w-[200px] rounded-md border border-neutral-300 bg-white px-3 text-sm outline-none focus:border-neutral-500"
+ >
+
+
+
+
+ }
+ rightLabel="전기전도도(µs/cm)"
+ rightControl={}
+ />
+
+
+ {/* 6행 */}
+ }
+ rightLabel="비고"
+ rightControl={
+
+
+ {/* 하단 버튼 */}
+
+
+
+
+
+
+ setOpenSimilar(false)}
+ rows={rows} // 실제 데이터
+ detailsByCaseId={detailsByCaseId}
+ onSearch={async (criterion) => {
+ const code = getCriterionCode(criterion);
+ if (!code) return;
+ // 값은 상황에 맞게 (예시: usePurpose)
+ await getSimilarCaseList(code, code == 'S' ? reqQty : code == 'U' ? usePurpose : '');
+ }}
+ />
+
+ setOpenCand(false)}
+ initialSelected={selectedCands}
+ onApply={(list) => {
+ setSelectedCands(list);
+
+ // ✅ cand1~3 입력칸에 반영(시설명만 넣는 버전)
+ setCand1(list[0]?.name ?? "");
+ setCand2(list[1]?.name ?? "");
+ setCand3(list[2]?.name ?? "");
+
+ setOpenCand(false);
+ }}
+ />
>
);
}
@@ -819,7 +842,7 @@ function DateInput({
value: string;
onChange: (v: string) => void;
}) {
-
+
const ref = useRef(null);
const openPicker = () => {
@@ -863,7 +886,7 @@ function DateInput({
aria-label="달력 열기"
>
diff --git a/apps/dm_new/app/commonOrigin.css b/apps/dm_new/app/commonOrigin.css
new file mode 100644
index 000000000..fdc8d0eb8
--- /dev/null
+++ b/apps/dm_new/app/commonOrigin.css
@@ -0,0 +1,82 @@
+@import "../../../packages/common/components/src/base.css";
+
+@tailwind base;
+@tailwind components;
+@tailwind utilities;
+
+@layer base {
+ :root {
+ --background: 0 0% 100%;
+ --foreground: 0 0% 3.9%;
+ --card: 0 0% 100%;
+ --card-foreground: 0 0% 3.9%;
+ --popover: 0 0% 100%;
+ --popover-foreground: 0 0% 3.9%;
+ --primary: 0 0% 9%;
+ --primary-foreground: 0 0% 98%;
+ --secondary: 0 0% 96.1%;
+ --secondary-foreground: 0 0% 9%;
+ --muted: 0 0% 96.1%;
+ --muted-foreground: 0 0% 45.1%;
+ --accent: 0 0% 96.1%;
+ --accent-foreground: 0 0% 9%;
+ --destructive: 0 84.2% 60.2%;
+ --destructive-foreground: 0 0% 98%;
+ --border: 0 0% 89.8%;
+ --input: 0 0% 89.8%;
+ --ring: 0 0% 3.9%;
+ --chart-1: 12 76% 61%;
+ --chart-2: 173 58% 39%;
+ --chart-3: 197 37% 24%;
+ --chart-4: 43 74% 66%;
+ --chart-5: 27 87% 67%;
+ --radius: 0.5rem
+ }
+ .dark {
+ --background: 0 0% 3.9%;
+ --foreground: 0 0% 98%;
+ --card: 0 0% 3.9%;
+ --card-foreground: 0 0% 98%;
+ --popover: 0 0% 3.9%;
+ --popover-foreground: 0 0% 98%;
+ --primary: 0 0% 98%;
+ --primary-foreground: 0 0% 9%;
+ --secondary: 0 0% 14.9%;
+ --secondary-foreground: 0 0% 98%;
+ --muted: 0 0% 14.9%;
+ --muted-foreground: 0 0% 63.9%;
+ --accent: 0 0% 14.9%;
+ --accent-foreground: 0 0% 98%;
+ --destructive: 0 62.8% 30.6%;
+ --destructive-foreground: 0 0% 98%;
+ --border: 0 0% 14.9%;
+ --input: 0 0% 14.9%;
+ --ring: 0 0% 83.1%;
+ --chart-1: 220 70% 50%;
+ --chart-2: 160 60% 45%;
+ --chart-3: 30 80% 55%;
+ --chart-4: 280 65% 60%;
+ --chart-5: 340 75% 55%
+ }
+}
+
+@layer base {
+ * {
+ @apply border-border;
+ }
+ body {
+ @apply bg-background text-foreground;
+ }
+}
+
+@font-face {
+ font-family: "NotoSansKR";
+ src: url("/fonts/NotoSansKR-Regular.woff2") format("woff2");
+ font-weight: 400;
+ font-style: normal;
+ font-display: swap;
+}
+
+body {
+ font-family: "NotoSansKR", sans-serif;
+}
\ No newline at end of file
diff --git a/apps/dm_new/middleware.ts b/apps/dm_new/middleware.ts
index c69ef5e34..e55f65515 100644
--- a/apps/dm_new/middleware.ts
+++ b/apps/dm_new/middleware.ts
@@ -8,6 +8,7 @@ const serverApiUrl = process.env.NEXT_PUBLIC_CS_SERVER_API_URL;
const GEOSERVER_URL = process.env.NEXT_PUBLIC_GEOSERVER_URL || "";
const LX_GEOSERVER_URL = process.env.NEXT_PUBLIC_LX_GEOSERVER_URL || "";
const GATEWAY_URL = process.env.NEXT_PUBLIC_GATEWAY_URL || "";
+const SEWERAGE_API_URL = process.env.NEXT_PUBLIC_SEWERAGE_API_URL || "";
const BUSINESS_JUSO_API_URL = process.env.NEXT_PUBLIC_FINDADRESS_URL || "";
const AdressKey = process.env.NEXT_PRIVATE_WR_ADDRESS_KEY || "";
@@ -72,6 +73,8 @@ export function middleware(req: NextRequest) {
if (pathname.startsWith("/geoserver")) {
let queryParams = searchParams.toString();
const typename = searchParams.get("TYPENAME");
+ const startYmd = searchParams.get("START_YMD") ?? null;
+ const endYmd = searchParams.get("END_YMD") ?? null;
console.log("typename ", typename);
let newUrl = `${GEOSERVER_URL}${pathname.replace("/geoserver", "")}${queryParams ? `?${queryParams}` : ""}`;
@@ -102,6 +105,8 @@ export function middleware(req: NextRequest) {
) {
const url = req.nextUrl.clone();
url.searchParams.set("TYPENAME", typename.replace("hasudo:", ""));
+ if (startYmd) url.searchParams.set("startYmd", startYmd);
+ if (endYmd) url.searchParams.set("endYmd", endYmd);
queryParams = url.searchParams.toString();
newUrl = `${GATEWAY_URL}/sewerage-service/api/v1/map/geometry${queryParams ? `?${queryParams}` : ""}`;
}
diff --git a/apps/dm_new/package.json b/apps/dm_new/package.json
index f318baf0b..3df43f28e 100644
--- a/apps/dm_new/package.json
+++ b/apps/dm_new/package.json
@@ -29,8 +29,8 @@
"@eslint/eslintrc": "^3",
"@types/webpack": "^5.28.5",
"eslint": "^9",
- "eslint-config-next": "15.1.7",
- "postcss": "^8",
+ "eslint-config-next": "15.3.8",
+ "postcss": "^8",
"tailwindcss": "^3.4.1",
"typescript": "^5"
}
diff --git a/apps/fo/.env.local b/apps/fo/.env.local
index 1b80d302e..646d80c2c 100644
--- a/apps/fo/.env.local
+++ b/apps/fo/.env.local
@@ -16,8 +16,8 @@ NEXT_PUBLIC_DC_URL = https://apigateway.apps.rsonesoft.com/dc-service
NEXT_PUBLIC_SEWERAGE_API_URL = http://con.mind-one.co.kr:11083/sewerage-service # 개발 서버 API
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_CM_LOGIN_PAGE_URL = http://dev.onasys.co.kr:3000/auth/login
-
+# NEXT_PUBLIC_CM_LOGIN_PAGE_URL = http://dev.onasys.co.kr:3000/auth/login
+NEXT_PUBLIC_CM_LOGIN_PAGE_URL = /
#NEXT_PUBLIC_SERVER_API_URL = http://localhost:8883/operation-service
diff --git a/apps/fo/.env.mindone b/apps/fo/.env.mindone
index f014168c2..2fd7e91c2 100644
--- a/apps/fo/.env.mindone
+++ b/apps/fo/.env.mindone
@@ -7,7 +7,6 @@ NEXT_PUBLIC_REFRESH_TOKEN = refresh-token
NEXT_PUBLIC_PROXY_HOST = http://con.mind-one.co.kr:11300
NEXT_PUBLIC_SUB_DOMAIN = http://con.mind-one.co.kr:13000
NEXT_PUBLIC_CM_LOGIN_PAGE_URL = http://dev.onasys.co.kr:3000/auth/login
-#NEXT_PUBLIC_SERVER_API_URL = http://con.mind-one.co.kr:11082
NEXT_PUBLIC_SERVER_API_URL = http://con.mind-one.co.kr:11083/operation-service
NEXT_PUBLIC_WR_SERVER_API_URL = http://con.mind-one.co.kr:11083/reuse-service/
NEXT_PUBLIC_CS_SERVER_API_URL = http://dev.onasys.co.kr:8000
@@ -17,3 +16,4 @@ NEXT_PUBLIC_DC_URL = https://apigateway.apps.rsonesoft.com/dc-service
NEXT_PUBLIC_SEWERAGE_API_URL = http://con.mind-one.co.kr:11083/sewerage-service
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_CM_LOGIN_PAGE_URL = /
\ No newline at end of file
diff --git a/apps/fo/.env.production b/apps/fo/.env.production
index d6940b0e9..bd0d0f04b 100644
--- a/apps/fo/.env.production
+++ b/apps/fo/.env.production
@@ -17,3 +17,4 @@ NEXT_PUBLIC_DC_URL = http://apigateway.iipbs-was.svc.cluster.local:8000/dc-s
NEXT_PUBLIC_SEWERAGE_API_URL = http://apigateway.iipbs-was.svc.cluster.local:8000/sewerage-service
NEXT_PUBLIC_NGII_URL = http://172.29.143.87/poiact
NEXT_PUBLIC_NGII_PARAM = BDE2BEF39DAFEC293AB6730150F222AC&service=WMTS&request=GetTile&version=1.0.0&format=image/png&layer=korean_map&style=korean&tilematrixset=korean&tilematrix={z}&tilecol={y}&tilerow={x}
+NEXT_PUBLIC_CM_LOGIN_PAGE_URL = http://dev.onasys.co.kr:3000/auth/login
\ No newline at end of file
diff --git a/apps/fo/package.json b/apps/fo/package.json
index 6993fe17c..9408ca885 100644
--- a/apps/fo/package.json
+++ b/apps/fo/package.json
@@ -37,7 +37,7 @@
"devDependencies": {
"@eslint/eslintrc": "^3",
"eslint": "^9",
- "eslint-config-next": "15.1.7",
+ "eslint-config-next": "15.3.8",
"postcss": "^8",
"tailwindcss": "^3.4.17",
"typescript": "^5.6.3"
diff --git a/apps/fo/src/entities/auth/lib/auth-wrapper.tsx b/apps/fo/src/entities/auth/lib/auth-wrapper.tsx
index 864b54ce0..21fd87180 100644
--- a/apps/fo/src/entities/auth/lib/auth-wrapper.tsx
+++ b/apps/fo/src/entities/auth/lib/auth-wrapper.tsx
@@ -1,21 +1,23 @@
"use client";
import { useEffect } from "react";
-import { useRouter } from "next/navigation";
import { client } from "@/src/shared/api";
import type { IUserInfo } from "@/src/shared/store";
+import { isAtLoginRedirectUrl } from "@common/network";
import { useUserStore, getCookie } from "@/src/shared/lib";
import { ENDPOINT, AUTH_USER_ID } from "@/src/shared/config";
-import { LoginApi } from "../api/login-service";
-const LOGIN_PAGE_URL = process.env.NEXT_PUBLIC_CM_LOGIN_PAGE_URL || "http://localhost:3001/auth/login"; // 개발 환경 URL
+const LOGIN_PAGE_URL =
+ process.env.NEXT_PUBLIC_CM_LOGIN_PAGE_URL || "/auth/login";
const AuthWrapper = ({ children }: { children: React.ReactNode }) => {
- const router = useRouter();
- // const pathname = usePathname();
const { userInfo, setUserInfo } = useUserStore((state) => state);
async function authCheck() {
+ if (isAtLoginRedirectUrl(LOGIN_PAGE_URL)) {
+ return;
+ }
+
const userId = getCookie(AUTH_USER_ID);
if (!userId) {
throw new Error("Missing required authentication tokens");
@@ -33,24 +35,29 @@ const AuthWrapper = ({ children }: { children: React.ReactNode }) => {
await authCheck();
} catch (error) {
console.error(error);
-
- try {
- await LoginApi.silentRefresh();
- await authCheck();
- } catch (refreshError) {
- console.error("Token refresh failed:", refreshError);
- setUserInfo(null);
-
- const hostname = window.location.hostname;
-
- // 개발 환경에서는 localhost 도메인 사용
- if (hostname === "localhost" || hostname === "127.0.0.1" || hostname === "con.mind-one.co.kr")
- router.push("/"); // 개발시 홈으로 돌아가기
-
- // 운영 환경에서는 서브도메인을 포함한 최상위 도메인 사용
- // router.push는 같은 Next.js 앱 내에서만 작동하므로 다른 앱으로 이동할 때는 window.location.href 사용
- if (hostname.includes("hasuon.or.kr")) window.location.href = LOGIN_PAGE_URL; // 세션 만료 시 CM 로그인 페이지로 리다이렉트
+ setUserInfo(null);
+ if (isAtLoginRedirectUrl(LOGIN_PAGE_URL)) {
+ return;
}
+
+ // const hostname = window.location.hostname;
+
+ // // 개발 환경에서는 localhost 도메인 사용
+ // if (
+ // hostname === "localhost" ||
+ // hostname === "127.0.0.1" ||
+ // hostname === "con.mind-one.co.kr"
+ // ) {
+ // router.push("/"); // 개발시 홈으로 돌아가기
+ // }
+
+ // // 운영 환경에서는 서브도메인을 포함한 최상위 도메인 사용
+ // // router.push는 같은 Next.js 앱 내에서만 작동하므로 다른 앱으로 이동할 때는 window.location.href 사용
+ // if (hostname.includes("hasuon.or.kr"))
+ // window.location.assign(LOGIN_PAGE_URL); // 세션 만료 시 CM 로그인 페이지로 리다이렉트
+
+ // 개발/운영 환경에서 로그인 URL 분기는 환경 변수로 처리 (.env.*)
+ window.location.assign(LOGIN_PAGE_URL);
}
})();
}, []);
@@ -58,4 +65,4 @@ const AuthWrapper = ({ children }: { children: React.ReactNode }) => {
return <>{children}>;
};
-export { AuthWrapper };
\ No newline at end of file
+export { AuthWrapper };
diff --git a/apps/sn/.env.local b/apps/sn/.env.local
index 997f50518..1d4c11d21 100644
--- a/apps/sn/.env.local
+++ b/apps/sn/.env.local
@@ -14,7 +14,6 @@ NEXT_PUBLIC_NGII_PARAM = 04trYP9_xwLAfALjwZ-B8g&service=WMTS&request=GetTile&ver
NEXT_PUBLIC_SERVER_API_URL = http://dev.onasys.co.kr:8000
NEXT_PUBLIC_SEWERAGE_API_URL = http://con.mind-one.co.kr:11083
NEXT_PUBLIC_DC_URL = https://apigateway.apps.rsonesoft.com
-NEXT_PUBLIC_CM_LOGIN_PAGE_URL = http://dev.onasys.co.kr:3000/auth/login
-#NEXT_PUBLIC_SEWERAGE_API_URL=http://con.mind-one.co.kr:11083
-#NEXT_PUBLIC_SEWERAGE_API_URL=http://192.168.1.222:8883
+# NEXT_PUBLIC_CM_LOGIN_PAGE_URL = http://dev.onasys.co.kr:3000/auth/login
+NEXT_PUBLIC_CM_LOGIN_PAGE_URL = /
# NEXT_PUBLIC_SEWERAGE_API_URL=http://192.168.1.66:8883
diff --git a/apps/sn/.env.mindone b/apps/sn/.env.mindone
index cea5518db..847103c97 100644
--- a/apps/sn/.env.mindone
+++ b/apps/sn/.env.mindone
@@ -12,4 +12,5 @@ NEXT_PUBLIC_LX_GEOSERVER_URL = http://con.mind-one.co.kr:11306/geoserver
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_SEWERAGE_API_URL = http://con.mind-one.co.kr:11083
-NEXT_PUBLIC_DC_URL = https://apigateway.apps.rsonesoft.com
\ No newline at end of file
+NEXT_PUBLIC_DC_URL = https://apigateway.apps.rsonesoft.com
+NEXT_PUBLIC_CM_LOGIN_PAGE_URL = /
\ No newline at end of file
diff --git a/apps/sn/.env.production b/apps/sn/.env.production
index c68db7859..4a7c15edc 100644
--- a/apps/sn/.env.production
+++ b/apps/sn/.env.production
@@ -15,4 +15,4 @@ NEXT_PUBLIC_NGII_URL = http://172.29.143.87/poiact
NEXT_PUBLIC_NGII_PARAM = BDE2BEF39DAFEC293AB6730150F222AC&service=WMTS&request=GetTile&version=1.0.0&format=image/png&layer=korean_map&style=korean&tilematrixset=korean&tilematrix={z}&tilecol={y}&tilerow={x}
NEXT_PUBLIC_SEWERAGE_API_URL = http://apigateway.iipbs-was.svc.cluster.local:8000
NEXT_PUBLIC_DC_URL = http://apigateway.iipbs-was.svc.cluster.local:8000
-
+NEXT_PUBLIC_CM_LOGIN_PAGE_URL = http://dev.onasys.co.kr:3000/auth/login
diff --git a/apps/sn/package.json b/apps/sn/package.json
index 1041df157..20aa6581d 100644
--- a/apps/sn/package.json
+++ b/apps/sn/package.json
@@ -49,7 +49,7 @@
"@types/node": "^22.13.1",
"@types/webpack": "^5.28.5",
"eslint": "^9",
- "eslint-config-next": "15.1.7",
+ "eslint-config-next": "15.3.8",
"postcss": "^8",
"prettier": "^3.4.2",
"prettier-plugin-tailwindcss": "^0.6.11",
diff --git a/apps/sn/src/entities/auth/lib/auth-wrapper.tsx b/apps/sn/src/entities/auth/lib/auth-wrapper.tsx
index 1c27e6fe3..bc4a82aab 100644
--- a/apps/sn/src/entities/auth/lib/auth-wrapper.tsx
+++ b/apps/sn/src/entities/auth/lib/auth-wrapper.tsx
@@ -1,23 +1,26 @@
"use client";
import { useEffect } from "react";
-import { useRouter, usePathname } from "next/navigation";
+import { usePathname } from "next/navigation";
import { client } from "@/shared/api";
import type { IUserInfo } from "@/shared/store";
import { useUserStore } from "@/shared/lib";
import { ENDPOINT, AUTH_USER_ID } from "@/shared/config";
+import { isAtLoginRedirectUrl } from "@common/network";
import { getCookie } from "@/shared/lib";
-import { LoginApi } from "../api/login-service";
-
-const LOGIN_PAGE_URL = process.env.NEXT_PUBLIC_CM_LOGIN_PAGE_URL || "http://dev.onasys.co.kr:3000/auth/login"; // 로그인 페이지 URL
+const LOGIN_PAGE_URL =
+ process.env.NEXT_PUBLIC_CM_LOGIN_PAGE_URL || "/auth/login";
const AuthWrapper = ({ children }: { children: React.ReactNode }) => {
- const router = useRouter();
const pathname = usePathname();
const { userInfo, setUserInfo } = useUserStore((state) => state);
async function authCheck() {
+ if (isAtLoginRedirectUrl(LOGIN_PAGE_URL)) {
+ return;
+ }
+
const userId = getCookie(AUTH_USER_ID);
if (!userId) {
throw new Error("Missing required authentication tokens");
@@ -35,24 +38,27 @@ const AuthWrapper = ({ children }: { children: React.ReactNode }) => {
await authCheck();
} catch (error) {
console.error(error);
+ setUserInfo(null);
- try {
- await LoginApi.silentRefresh();
- await authCheck();
- } catch (refreshError) {
- console.error("Token refresh failed:", refreshError);
- setUserInfo(null);
+ if (isAtLoginRedirectUrl(LOGIN_PAGE_URL)) return;
- const hostname = window.location.hostname;
+ // const hostname = window.location.hostname;
- // 개발 환경에서는 localhost 도메인 사용
- if (hostname === "localhost" || hostname === "127.0.0.1" || hostname === "con.mind-one.co.kr")
- window.location.href = "/";
+ // // 개발 환경에서는 localhost 도메인 사용
+ // if (
+ // hostname === "localhost" ||
+ // hostname === "127.0.0.1" ||
+ // hostname === "con.mind-one.co.kr"
+ // )
+ // window.location.href = "/";
- // 운영 환경에서는 서브도메인을 포함한 최상위 도메인 사용
- // router.push는 같은 Next.js 앱 내에서만 작동하므로 다른 앱으로 이동할 때는 window.location.href 사용
- if (hostname.includes("hasuon.or.kr")) window.location.href = LOGIN_PAGE_URL; // 세션 만료 시 CM 로그인 페이지로 리다이렉트
- }
+ // // 운영 환경에서는 서브도메인을 포함한 최상위 도메인 사용
+ // // router.push는 같은 Next.js 앱 내에서만 작동하므로 다른 앱으로 이동할 때는 window.location.href 사용
+ // if (hostname.includes("hasuon.or.kr"))
+ // window.location.href = LOGIN_PAGE_URL; // 세션 만료 시 CM 로그인 페이지로 리다이렉트
+
+ // 개발/운영 환경에서 로그인 URL 분기는 환경 변수로 처리 (.env.*)
+ window.location.assign(LOGIN_PAGE_URL);
}
})();
}, [pathname]);
diff --git a/apps/st/.gitignore b/apps/st/.gitignore
index f06d9d40b..3f74d9ee3 100644
--- a/apps/st/.gitignore
+++ b/apps/st/.gitignore
@@ -39,4 +39,7 @@ next-env.d.ts
.env*
!.env.example
-*.md
\ No newline at end of file
+*.md
+
+# next-agents-md
+.next-docs/
diff --git a/apps/st/app/(auth)/auth/layout.tsx b/apps/st/app/(auth)/auth/layout.tsx
new file mode 100644
index 000000000..33651c590
--- /dev/null
+++ b/apps/st/app/(auth)/auth/layout.tsx
@@ -0,0 +1,17 @@
+import "@/app/styles/base.css";
+
+export const metadata = {
+ title: "유역하수도 통합 정보플랫폼",
+};
+
+export default function AuthLayout({
+ children,
+}: {
+ children: React.ReactNode;
+}) {
+ return (
+
+ {children}
+
+ );
+}
diff --git a/apps/st/app/auth/login/page.tsx b/apps/st/app/(auth)/auth/login/page.tsx
similarity index 100%
rename from apps/st/app/auth/login/page.tsx
rename to apps/st/app/(auth)/auth/login/page.tsx
diff --git a/apps/st/app/(main)/(with-base-layout)/layout.tsx b/apps/st/app/(main)/(with-base-layout)/layout.tsx
new file mode 100644
index 000000000..6faa92cc1
--- /dev/null
+++ b/apps/st/app/(main)/(with-base-layout)/layout.tsx
@@ -0,0 +1,171 @@
+"use client";
+
+import { useCallback, useEffect, useMemo } from "react";
+import { usePathname } from "next/navigation";
+
+import {
+ getTabStoreState as getGridTabStoreState,
+ useTabStore as gridTabStore,
+} from "@rsone/components";
+import { useQuery, useQueryClient } from "@tanstack/react-query";
+
+import { ENDPOINT } from "@/shared/config";
+import { getCookie, useAlertStore, useMenuStore } from "@/shared/lib";
+import { StatisticsPageLayout } from "@/shared/ui/statistics-page-layout";
+import { MenuApi } from "@/entities/menu";
+import type { MenuItem } from "@/entities/menu";
+import { BaseLayout } from "@common/components";
+import { getConfig } from "@common/config";
+
+import { getTabStoreState, useTabStore } from "@/store/tab-store";
+
+const appConfig = getConfig();
+const userId = getCookie(appConfig.AUTH_USER_ID ?? "");
+const appSiteId = getCookie("site-id-key") ?? "";
+const isLoggedIn = !!userId;
+
+export default function WithBaseLayout({
+ children,
+}: {
+ children: React.ReactNode;
+}) {
+ const pathname = usePathname();
+ const queryClient = useQueryClient();
+
+ const setCurrentMenuInfo = useMenuStore(
+ (state) => state.setCurrentMenuInfo,
+ );
+ const currentMenuInfo = useMenuStore((state) => state.currentMenuInfo);
+
+ const addTab = useTabStore((state) => state.addTab);
+ const setActiveTab = useTabStore((state) => state.setActiveTab);
+ const setMenuList = useTabStore((state) => state.setMenuList);
+ const activeTabId = useTabStore((state) => state.activeTabId);
+ const menuList = useTabStore((state) => state.menuList);
+ const tabs = useTabStore((state) => state.tabs);
+ const addTabForGrid = gridTabStore((state) => state.addTab);
+ const setActiveTabForGrid = gridTabStore((state) => state.setActiveTab);
+ const setMenuListForGrid = gridTabStore((state) => state.setMenuList);
+ const alert = useAlertStore((state) => state.setMessage);
+
+ const { data: menuListData } = useQuery({
+ queryKey: [ENDPOINT.CMS_SERVICE.MENU_ROLES],
+ queryFn: () => MenuApi.getMenuRoles(appSiteId),
+ enabled: isLoggedIn,
+ });
+
+ useEffect(() => {
+ if (menuListData) {
+ setMenuList(menuListData);
+ setMenuListForGrid(menuListData);
+ }
+ }, [menuListData, setMenuList, setMenuListForGrid]);
+
+ // 새로고침 후 persist로 앱 탭만 복원된 경우 rsone 그리드 스토어에 탭을 맞춤
+ useEffect(() => {
+ if (!menuList) return;
+ const appTabs = getTabStoreState().tabs;
+ if (appTabs.length === 0) return;
+ if (getGridTabStoreState().tabs.length > 0) return;
+
+ for (const tab of appTabs) {
+ getGridTabStoreState().addTab(tab.menuItem);
+ }
+
+ const activeId = getTabStoreState().activeTabId;
+ if (activeId) {
+ const activeTab = appTabs.find((t) => t.id === activeId);
+ const gridActiveId = activeTab?.menuItem.menuEngNm;
+ if (gridActiveId) {
+ getGridTabStoreState().setActiveTab(gridActiveId);
+ }
+ }
+ }, [menuList, tabs]);
+
+ useEffect(() => {
+ if (!pathname) return;
+
+ // pathname이 변경될 때만 실행되도록 tabs를 직접 가져옴
+ const currentTabs = getTabStoreState().tabs;
+ const tab = currentTabs.find((t) => t.path === pathname);
+ if (tab) {
+ setActiveTab(tab.id);
+ const gridActiveId = tab.menuItem.menuEngNm;
+ if (gridActiveId) {
+ setActiveTabForGrid(gridActiveId);
+ }
+ }
+ }, [pathname, setActiveTab, setActiveTabForGrid]);
+
+ // 활성 탭의 메뉴를 찾아서 urlPath를 가져옴. 탭이 전부 닫히면 ''를 넘겨 LNB에서 메뉴 강조 제거
+ const currentPath = useMemo(() => {
+ if (tabs.length === 0) return "";
+
+ if (activeTabId && menuList) {
+ // menuList에서 활성 탭의 메뉴 찾기
+ const findMenuByEngNm = (items: MenuItem[]): MenuItem | null => {
+ for (const item of items) {
+ if (item.urlPath === activeTabId) {
+ return item;
+ }
+ if (item.children) {
+ const found = findMenuByEngNm(item.children);
+ if (found) return found;
+ }
+ }
+ return null;
+ };
+
+ const activeMenu = findMenuByEngNm(menuList);
+
+ if (activeMenu?.urlPath) {
+ return activeMenu.urlPath;
+ }
+ }
+
+ // 탭에서 직접 찾기
+ if (activeTabId) {
+ const currentTabs = getTabStoreState().tabs;
+ const activeTab = currentTabs.find((t) => t.id === activeTabId);
+ if (activeTab?.menuItem?.urlPath) {
+ return activeTab.menuItem.urlPath;
+ }
+ }
+
+ // currentMenuInfo에서 가져오기
+ if (currentMenuInfo?.urlPath) {
+ return currentMenuInfo.urlPath;
+ }
+
+ // 기본값으로 pathname 사용
+ return pathname ?? "";
+ }, [tabs.length, activeTabId, menuList, currentMenuInfo, pathname]);
+
+ const handleMenuClick = useCallback(
+ (item: MenuItem) => {
+ setCurrentMenuInfo(item);
+ const addTabResult: boolean = addTab(item);
+
+ // packages/rsone의 스토어에도 동기화
+ if (addTabResult) {
+ addTabForGrid(item);
+ }
+
+ if (!addTabResult) {
+ alert(`최대 추가 할 수 있는 화면 수를 초과 하였습니다.
+ 기존 화면을 닫은 후 이용해주세요.`);
+ }
+ },
+ [setCurrentMenuInfo, addTab, addTabForGrid, alert],
+ );
+
+ return (
+
+ {children}
+
+ );
+}
diff --git a/apps/st/app/(with-base-layout)/loading.tsx b/apps/st/app/(main)/(with-base-layout)/loading.tsx
similarity index 100%
rename from apps/st/app/(with-base-layout)/loading.tsx
rename to apps/st/app/(main)/(with-base-layout)/loading.tsx
diff --git a/apps/st/app/(with-base-layout)/statistics/page.tsx b/apps/st/app/(main)/(with-base-layout)/statistics/page.tsx
similarity index 98%
rename from apps/st/app/(with-base-layout)/statistics/page.tsx
rename to apps/st/app/(main)/(with-base-layout)/statistics/page.tsx
index f32417f76..23feaf7e1 100644
--- a/apps/st/app/(with-base-layout)/statistics/page.tsx
+++ b/apps/st/app/(main)/(with-base-layout)/statistics/page.tsx
@@ -1,3 +1,2 @@
import { StatisticsPage } from "@/pages/statistics";
-
export default StatisticsPage;
diff --git a/apps/st/app/layout.tsx b/apps/st/app/(main)/layout.tsx
similarity index 100%
rename from apps/st/app/layout.tsx
rename to apps/st/app/(main)/layout.tsx
diff --git a/apps/st/app/(with-base-layout)/layout.tsx b/apps/st/app/(with-base-layout)/layout.tsx
deleted file mode 100644
index 3fe61855e..000000000
--- a/apps/st/app/(with-base-layout)/layout.tsx
+++ /dev/null
@@ -1,139 +0,0 @@
-"use client";
-
-import { useEffect, useCallback, useMemo } from "react";
-import { usePathname } from "next/navigation";
-import { useQueryClient, useQuery } from "@tanstack/react-query";
-
-import type { MenuItem } from "@/entities/menu";
-import { useMenuStore, useAlertStore } from "@/shared/lib";
-import { useTabStore, getTabStoreState } from "@/store/tab-store";
-import { useTabStore as gridTabStore } from "@rsone/components";
-import { getConfig } from "@common/config";
-import { getCookie } from "@/shared/lib";
-import { getMenuList } from "../../../../packages/common/components/src/api";
-import { ENDPOINT } from "@/shared/config";
-
-import { BaseLayout } from "@common/components";
-import { StatisticsPageLayout } from "@/shared/ui/statistics-page-layout";
-
-const appConfig = getConfig();
-const userId = getCookie(appConfig.AUTH_USER_ID ?? "");
-const appSiteId = getCookie("site-id-key") ?? "";
-const isLoggedIn = !!userId;
-
-export default function WithBaseLayout({
- children,
-}: {
- children: React.ReactNode;
-}) {
- const pathname = usePathname();
- const queryClient = useQueryClient();
-
- const setCurrentMenuInfo = useMenuStore((state) => state.setCurrentMenuInfo);
- const currentMenuInfo = useMenuStore((state) => state.currentMenuInfo);
-
- const addTab = useTabStore((state) => state.addTab);
- const setActiveTab = useTabStore((state) => state.setActiveTab);
- const setMenuList = useTabStore((state) => state.setMenuList);
- const activeTabId = useTabStore((state) => state.activeTabId);
- const menuList = useTabStore((state) => state.menuList);
- const tabs = useTabStore((state) => state.tabs);
- const addTabForGrid = gridTabStore((state) => state.addTab);
- const setActiveTabForGrid = gridTabStore((state) => state.setActiveTab);
- const setMenuListForGrid = gridTabStore((state) => state.setMenuList);
- const alert = useAlertStore((state) => state.setMessage);
-
- const { data: menuListData } = useQuery({
- queryKey: [ENDPOINT.CMS_SERVICE.MENU_ROLES],
- queryFn: () => getMenuList(appSiteId),
- enabled: isLoggedIn,
- });
-
- useEffect(() => {
- if (menuListData) {
- setMenuList(menuListData);
- setMenuListForGrid(menuListData);
- }
- }, [menuListData, setMenuList, setMenuListForGrid]);
-
- useEffect(() => {
- if (!pathname) return;
-
- // pathname이 변경될 때만 실행되도록 tabs를 직접 가져옴
- const currentTabs = getTabStoreState().tabs;
- const tab = currentTabs.find((t) => t.path === pathname);
- if (tab) {
- setActiveTab(tab.id);
- setActiveTabForGrid(tab.id);
- }
- }, [pathname, setActiveTab, setActiveTabForGrid]);
-
- // 활성 탭의 메뉴를 찾아서 urlPath를 가져옴. 탭이 전부 닫히면 ''를 넘겨 LNB에서 메뉴 강조 제거
- const currentPath = useMemo(() => {
- if (tabs.length === 0) return "";
-
- if (activeTabId && menuList) {
- // menuList에서 활성 탭의 메뉴 찾기
- const findMenuByEngNm = (items: MenuItem[]): MenuItem | null => {
- for (const item of items) {
- if (item.menuEngNm === activeTabId) {
- return item;
- }
- if (item.children) {
- const found = findMenuByEngNm(item.children);
- if (found) return found;
- }
- }
- return null;
- };
-
- const activeMenu = findMenuByEngNm(menuList);
-
- if (activeMenu?.urlPath) {
- return activeMenu.urlPath;
- }
- }
-
- // 탭에서 직접 찾기
- if (activeTabId) {
- const currentTabs = getTabStoreState().tabs;
- const activeTab = currentTabs.find((t) => t.id === activeTabId);
- if (activeTab?.menuItem?.urlPath) {
- return activeTab.menuItem.urlPath;
- }
- }
-
- // currentMenuInfo에서 가져오기
- if (currentMenuInfo?.urlPath) {
- return currentMenuInfo.urlPath;
- }
-
- // 기본값으로 pathname 사용
- return pathname ?? "";
- }, [tabs.length, activeTabId, menuList, currentMenuInfo, pathname]);
-
- const handleMenuClick = useCallback((item: MenuItem) => {
- setCurrentMenuInfo(item);
- const addTabResult: boolean = addTab(item);
-
- // packages/rsone의 스토어에도 동기화
- if (addTabResult) {
- addTabForGrid(item);
- }
-
- if (!addTabResult) {
- alert(`최대 추가 할 수 있는 화면 수를 초과 하였습니다.
- 기존 화면을 닫은 후 이용해주세요.`);
- }
- }, [setCurrentMenuInfo, addTab, addTabForGrid, alert]);
-
- return (
-
- {children}
-
- );
-}
diff --git a/apps/st/app/api/proxy/[...url]/route.ts b/apps/st/app/api/proxy/[...url]/route.ts
index 7442c702f..c9f0d3730 100644
--- a/apps/st/app/api/proxy/[...url]/route.ts
+++ b/apps/st/app/api/proxy/[...url]/route.ts
@@ -1,104 +1,106 @@
import {
- ACCESS_TOKEN,
- AUTH_USER_ID,
- CLAIM_NAME,
- REFRESH_TOKEN,
- SERVER_API_URL,
- SITE_ID,
+ ACCESS_TOKEN,
+ AUTH_USER_ID,
+ CLAIM_NAME,
+ REFRESH_TOKEN,
+ SERVER_API_URL,
+ SITE_ID,
} from "@/shared/config";
import { cookies } from "next/headers";
import { NextResponse } from "next/server";
async function handler(
- request: Request,
- params: { url: string[] },
- method: "GET" | "POST" | "PUT" | "DELETE",
+ request: Request,
+ params: { url: string[] },
+ method: "GET" | "POST" | "PUT" | "DELETE",
) {
- const cookieStore = await cookies();
- const headers: Record
= {
- "Content-Type": "application/json",
- "X-Site-Id": SITE_ID,
- };
- let body = {};
- const isRefreshHandler = params.url.includes("refresh");
+ const cookieStore = await cookies();
+ const headers: Record = {
+ "Content-Type": "application/json",
+ "X-Site-Id": SITE_ID,
+ };
+ let body = {};
+ const isRefreshHandler = params.url.includes("refresh");
- // silent refresh
- if (isRefreshHandler) {
- const refreshToken = cookieStore.get(REFRESH_TOKEN);
- headers[CLAIM_NAME] = refreshToken?.value ?? "";
+ // silent refresh
+ if (isRefreshHandler) {
+ const refreshToken = cookieStore.get(REFRESH_TOKEN);
+ headers[CLAIM_NAME] = refreshToken?.value ?? "";
- if (!headers[CLAIM_NAME]) {
- return NextResponse.json({ message: "Can't refresh" }, { status: 401 });
- }
- }
-
- try {
- if (!isRefreshHandler) {
- body = await request.json();
+ if (!headers[CLAIM_NAME]) {
+ return NextResponse.json(
+ { message: "Can't refresh" },
+ { status: 401 },
+ );
+ }
}
- const result = await fetch(`${SERVER_API_URL}/${params.url.join("/")}`, {
- method,
- headers,
- body: JSON.stringify(body),
- });
+ try {
+ if (!isRefreshHandler) {
+ body = await request.json();
+ }
- if (!result.ok) {
- return NextResponse.json(
- { message: "Invalid Credentials" },
- { status: 401 },
- );
+ const result = await fetch(
+ `${SERVER_API_URL}/${params.url.join("/")}`,
+ {
+ method,
+ headers,
+ body: JSON.stringify(body),
+ },
+ );
+
+ if (!result.ok) {
+ return NextResponse.json(
+ { message: "Invalid Credentials" },
+ { status: 401 },
+ );
+ }
+
+ const accessToken = result.headers.get(ACCESS_TOKEN);
+ const refreshToken = result.headers.get(REFRESH_TOKEN);
+ const userId = result.headers.get(AUTH_USER_ID);
+
+ if (accessToken && userId) {
+ const response = NextResponse.json({
+ [ACCESS_TOKEN]: accessToken,
+ [AUTH_USER_ID]: userId,
+ });
+
+ if (refreshToken) {
+ cookieStore.set(REFRESH_TOKEN, refreshToken, {
+ httpOnly: true,
+ sameSite: "lax",
+ path: "/",
+ });
+ }
+
+ return response;
+ }
+
+ return NextResponse.json(
+ { message: "Invalid Credentials" },
+ { status: 401 },
+ );
+ } catch (error) {
+ return NextResponse.json(
+ { message: "Server Error", error },
+ { status: 500 },
+ );
}
-
- const accessToken = result.headers.get(ACCESS_TOKEN);
- const refreshToken = result.headers.get(REFRESH_TOKEN);
- const userId = result.headers.get(AUTH_USER_ID);
-
- if (accessToken && userId) {
- const response = NextResponse.json({
- [ACCESS_TOKEN]: accessToken,
- [AUTH_USER_ID]: userId,
- });
-
-
-
- if (refreshToken) {
- cookieStore.set(REFRESH_TOKEN, refreshToken, {
- httpOnly: true,
- sameSite: "lax",
-// domain: domain,
- path: "/",
- });
- }
-
- return response;
- }
-
- return NextResponse.json(
- { message: "Invalid Credentials" },
- { status: 401 },
- );
- } catch (error) {
- return NextResponse.json(
- { message: "Server Error", error },
- { status: 500 },
- );
- }
}
-
export async function POST(
- request: Request,
- props: { params: Promise<{ url: string[] }> },
+ request: Request,
+ props: { params: Promise<{ url: string[] }> },
) {
- const params = await props.params;
- return handler(request, params, "POST");
+ const params = await props.params;
+ return handler(request, params, "POST");
}
export async function PUT(
- request: Request,
- props: { params: Promise<{ url: string[] }> },
+ request: Request,
+ props: { params: Promise<{ url: string[] }> },
) {
- const params = await props.params;
- return handler(request, params, "PUT");
+ const params = await props.params;
+ return handler(request, params, "PUT");
}
diff --git a/apps/st/eslint.config.mjs b/apps/st/eslint.config.mjs
index 18107f0ca..55ef02fb5 100644
--- a/apps/st/eslint.config.mjs
+++ b/apps/st/eslint.config.mjs
@@ -1,6 +1,7 @@
+import { FlatCompat } from "@eslint/eslintrc";
import { dirname } from "path";
import { fileURLToPath } from "url";
-import { FlatCompat } from "@eslint/eslintrc";
+import baseEsLintConfig from "../../eslint.config.mjs";
const __filename = fileURLToPath(import.meta.url);
const __dirname = dirname(__filename);
@@ -11,6 +12,7 @@ const compat = new FlatCompat({
const eslintConfig = [
...compat.extends("next/core-web-vitals", "next/typescript"),
+ ...baseEsLintConfig,
{
rules: {
"react-hooks/exhaustive-deps": "off",
diff --git a/apps/st/middleware.ts b/apps/st/middleware.ts
index 8b1ecb501..9a2a48cb1 100644
--- a/apps/st/middleware.ts
+++ b/apps/st/middleware.ts
@@ -12,10 +12,10 @@ import {
const serverApiUrl =
process.env.NEXT_PUBLIC_SERVER_API_URL ?? "http://localhost:3000";
-const linkBackendApiHost = process.env.NEXT_PUBLIC_LINK_BACKEND_API_HOST ?? "http://localhost:8080";
-
const stServiceUrl = ST_SERVICE_URL;
+const linkBackendApiHost = process.env.NEXT_PUBLIC_LINK_BACKEND_API_HOST ?? "http://localhost:8080";
+
// CSP 헤더 설정 함수 (img-src만)
function addSecurityHeaders(response: NextResponse): NextResponse {
response.headers.set("Content-Security-Policy", "img-src 'self' data: https: blob:;");
@@ -28,41 +28,46 @@ export function middleware(req: NextRequest) {
const logMessages: string[] = [
`[Middleware Request] ${new Date().toISOString()}`,
- `[Environment]\n Mode: ${process.env.NODE_ENV}\n Port: ${PORT}\n Proxy Host: ${PROXY_HOST}\n Server API URL: ${SERVER_API_URL}\n Site ID: ${SITE_ID}`,
+ `[Environment]\n Mode: ${process.env.NODE_ENV}\n Port: ${PORT}\n Proxy Host: ${PROXY_HOST}\n Server API URL: ${SERVER_API_URL}\n Site ID: ${SITE_ID}\n ST Service URL: ${stServiceUrl}`,
`[Request]\n Original Pathname: ${pathname}`,
];
- if (pathname.startsWith("/st-management-service") || pathname.startsWith("/st-input-service")) {
+ // ST Service 처리 (dc의 /server/dc-service 패턴과 동일)
+ if (pathname.startsWith("/server/st-management-service") || pathname.startsWith("/server/st-input-service")) {
const requestHeaders = new Headers(req.headers);
const token = req.cookies.get(CLAIM_NAME)?.value;
const userId = req.cookies.get(AUTH_USER_ID)?.value;
-
+
const queryParams = searchParams.toString();
- const newUrl = `${stServiceUrl}${pathname}${
+ const newUrl = `${stServiceUrl}${pathname.replace("/server", "")}${
queryParams ? `?${queryParams}` : ""
}`;
if (token) requestHeaders.set(CLAIM_NAME, token);
if (userId) requestHeaders.set(AUTH_USER_ID, userId);
-
- // 디버깅을 위한 토큰 정보 로그
- logMessages.push(` Token Debug: ${token ? 'EXISTS' : 'MISSING'}`);
- logMessages.push(` UserId Debug: ${userId ? 'EXISTS' : 'MISSING'}`);
+
+ logMessages.push(` Token Debug: ${token ? "EXISTS" : "MISSING"}`);
+ logMessages.push(` UserId Debug: ${userId ? "EXISTS" : "MISSING"}`);
logMessages.push(` ST Service Rewritten URL: ${newUrl}`);
logMessages.push("\n--------------------------------\n");
console.log(logMessages.join("\n"));
-
+
const response = NextResponse.rewrite(new URL(newUrl), {
headers: requestHeaders,
});
return addSecurityHeaders(response);
}
- if (pathname.startsWith("/server")) {
+ // 일반 Server API 처리 (ST 서비스 제외)
+ if (
+ pathname.startsWith("/server") &&
+ !pathname.startsWith("/server/st-management-service") &&
+ !pathname.startsWith("/server/st-input-service")
+ ) {
const requestHeaders = new Headers(req.headers);
const token = req.cookies.get(CLAIM_NAME)?.value;
const userId = req.cookies.get(AUTH_USER_ID)?.value;
-
+
const queryParams = searchParams.toString();
const newUrl = `${serverApiUrl}${pathname.replace("/server", "")}${
queryParams ? `?${queryParams}` : ""
@@ -138,5 +143,5 @@ export function middleware(req: NextRequest) {
}
export const config = {
- matcher: ["/server/:path*", "/attach-service/:path*", "/api/link/:path*", "/st-management-service/:path*", "/st-input-service/:path*", "/"],
+ matcher: ["/server/:path*", "/attach-service/:path*", "/api/link/:path*", "/"],
};
diff --git a/apps/st/package.json b/apps/st/package.json
index cd951af0d..0c120b1e2 100644
--- a/apps/st/package.json
+++ b/apps/st/package.json
@@ -34,6 +34,7 @@
"@types/xlsx": "^0.0.36",
"axios": "^1.8.1",
"chart.js": "^4.4.8",
+ "chartjs-plugin-zoom": "^2.2.0",
"dayjs": "^1.11.13",
"js-cookie": "^3.0.5",
"jszip": "^3.10.1",
@@ -61,7 +62,8 @@
"@types/node": "^22.13.1",
"@types/webpack": "^5.28.5",
"eslint": "^9",
- "eslint-config-next": "15.1.7",
+ "eslint-config-next": "15.3.8",
+ "eslint-plugin-react-hooks": "^5.0.0",
"postcss": "^8",
"prettier": "^3.4.2",
"prettier-plugin-tailwindcss": "^0.6.11",
diff --git a/apps/st/src/app/styles/components.css b/apps/st/src/app/styles/components.css
index 5b9742c0f..e69de29bb 100644
--- a/apps/st/src/app/styles/components.css
+++ b/apps/st/src/app/styles/components.css
@@ -1,129 +0,0 @@
-.card {
- position: relative;
- margin: 0.6rem;
- box-sizing: border-box;
- min-height: 2rem;
- border-radius: 0.5rem;
- padding: 0.6rem;
- font-size: 1.3rem;
- color: #666;
-}
-
-.card_width10 {
- width: calc(10% - 12px);
-}
-
-.card_width20 {
- width: calc(20% - 12px);
-}
-
-.card_width25 {
- width: calc(25% - 12px);
-}
-
-.card_width30 {
- width: calc(30% - 12px);
-}
-
-.card_width33 {
- width: calc(33.333% - 12px);
-}
-
-.card_width40 {
- width: calc(40% - 12px);
-}
-
-.card_width45 {
- width: calc(45% - 12px);
-}
-
-.card_width50 {
- width: calc(50% - 12px);
-}
-
-.card_width60 {
- width: calc(60% - 12px);
-}
-
-.card_width66 {
- width: calc(66.666% - 12px);
-}
-
-.card_width70 {
- width: calc(70% - 12px);
-}
-
-.card_width75 {
- width: calc(75% - 12px);
-}
-
-.card_width80 {
- width: calc(80% - 12px);
-}
-
-.card_width90 {
- width: calc(90% - 12px);
-}
-
-.card_width100 {
- width: calc(100% - 12px);
-}
-
-.card_nopadding {
- padding: 0 !important;
-}
-
-.card_nomargin {
- width: 100%;
- margin: 0 !important;
- padding: 0 !important;
-}
-
-.topline {
- border-top: 1px solid #ccc;
-}
-
-.bottomline {
- border-bottom: 1px solid #e6eef3;
- background: #fff;
-}
-
-.card-border {
- border: 1px solid #dbe2e6;
- background-color: rgba(255, 255, 255, 0.6);
- box-shadow: 0 0 10px rgba(0, 0, 0, 0.1);
-}
-
-.card-header {
- margin-top: -0.6rem;
- margin-left: -0.6rem;
- margin-right: -0.6rem;
- display: flex;
- align-items: center;
- justify-content: space-between;
- border-bottom: 1px solid #e1e7eb;
- padding: 1rem;
-}
-
-.card-footer {
- margin-left: -0.6rem;
- margin-right: -0.6rem;
- margin-top: 0.5rem;
- display: flex;
- align-items: center;
- justify-content: center;
- border-top: 1px solid #e1e7eb;
- padding-top: 1rem;
- padding-bottom: 1rem;
-}
-
-/* 블랙 테마 */
-[data-theme="black"] {
- .card-border {
- background-color: rgba(255, 255, 255, 0.065);
- border-color: #38383a;
- }
- .card-header {
- border-bottom-color: #3f4042;
- }
-}
diff --git a/apps/st/src/components/button/CancelSubmitButton.tsx b/apps/st/src/components/button/CancelSubmitButton.tsx
new file mode 100644
index 000000000..c8b7c510f
--- /dev/null
+++ b/apps/st/src/components/button/CancelSubmitButton.tsx
@@ -0,0 +1,16 @@
+import { Button } from "@common/components";
+
+interface CancelSubmitButtonProps {
+ cnacelSubmitFunc: () => void;
+}
+
+const CancelSubmitButton = ({ cnacelSubmitFunc }: CancelSubmitButtonProps) => {
+ return (
+
+ );
+};
+
+export default CancelSubmitButton;
\ No newline at end of file
diff --git a/apps/st/src/components/css/DataGrid.css b/apps/st/src/components/css/DataGrid.css
index f602400c7..68ab82f50 100644
--- a/apps/st/src/components/css/DataGrid.css
+++ b/apps/st/src/components/css/DataGrid.css
@@ -4,9 +4,24 @@
.rg-grid .rg-header .rg-table .rg-header-group-cell {
@apply !bg-[#466995] !text-[#fff];
}
- .rg-grid .rg-header .rg-table .rg-header-cell {
+ .rg-grid .rg-header .rg-table {
@apply !bg-[#5987bf] !text-[#fff];
}
+
+ /* 상태바·체크바 헤더 */
+ .rg-grid .rg-head .rg-table tr:first-child td:first-child,
+ .rg-grid .rg-head .rg-table tr:first-child td:nth-child(2),
+ .rg-grid .rg-header .rg-table tr:first-child td:first-child,
+ .rg-grid .rg-header .rg-table tr:first-child td:nth-child(2) {
+ @apply !bg-[#5987bf] !text-[#fff];
+ }
+
+ .rg-grid .rg-header .rg-table tr td,
+ .rg-grid .rg-head .rg-table tr td {
+ border: none;
+ border-bottom: 1px solid #e5e7eb;
+ }
+
.rg-grid .rg-body .rg-table .rg-data-row .rg-data-cell {
@apply !bg-white;
}
@@ -17,9 +32,30 @@
.rg-grid .rg-body .rg-table .rg-data-row .rg-data-cell.rg-cell-verify-error {
@apply !bg-[#ffe6e6] !text-[#c00];
}
+ .rg-grid .rg-body .rg-table .rg-data-row .rg-data-cell.rg-cell-verify-error-imr {
+ @apply !bg-[#fff7bf] !text-[#b45309];
+ }
.rg-grid .rg-body .rg-table .rg-data-row .rg-data-cell.rg-cell-total {
@apply !bg-[#e3eaf2];
}
+ .rg-grid .rg-body .rg-table .rg-data-row .rg-data-cell.rg-cell-rowtype-biz-sum {
+ @apply !bg-[#ffe9cf];
+ }
+ .rg-grid .rg-body .rg-table .rg-data-row .rg-data-cell.rg-cell-rowtype-other {
+ @apply !bg-[#fff7bf];
+ }
+
+ /* 진행상황: 소프트 인라인 요약 (툴바와 자연스럽게 blend) */
+ .data-grid-status-bubble {
+ @apply rounded-lg border border-slate-200/80 bg-slate-50/90 px-4 py-2.5;
+ box-shadow: none;
+ }
+ .data-grid-status-bubble span {
+ @apply text-base text-slate-700;
+ }
+ .data-grid-status-bubble span + span {
+ @apply ml-4 border-l border-slate-200/70 pl-4;
+ }
[data-theme="black"] {
.rg-grid .rg-header .rg-table .rg-header-group-cell {
@@ -28,6 +64,12 @@
.rg-grid .rg-header .rg-table .rg-header-cell {
@apply !bg-[#62686d] !text-[#fff];
}
+ .rg-grid .rg-head .rg-table tr:first-child td:first-child,
+ .rg-grid .rg-head .rg-table tr:first-child td:nth-child(2),
+ .rg-grid .rg-header .rg-table tr:first-child td:first-child,
+ .rg-grid .rg-header .rg-table tr:first-child td:nth-child(2) {
+ @apply !bg-[#62686d] !text-[#fff];
+ }
.rg-grid .rg-body .rg-table .rg-data-row .rg-data-cell {
@apply !bg-[#545556] !text-[#fff];
}
@@ -40,6 +82,12 @@
.rg-grid .rg-header .rg-table .rg-header-cell {
@apply !bg-[#8d8d94] !text-[#fff];
}
+ .rg-grid .rg-head .rg-table tr:first-child td:first-child,
+ .rg-grid .rg-head .rg-table tr:first-child td:nth-child(2),
+ .rg-grid .rg-header .rg-table tr:first-child td:first-child,
+ .rg-grid .rg-header .rg-table tr:first-child td:nth-child(2) {
+ @apply !bg-[#8d8d94] !text-[#fff];
+ }
}
[data-theme="green"] {
@@ -49,5 +97,15 @@
.rg-grid .rg-header .rg-table .rg-header-cell {
@apply !bg-[#6bafae] !text-[#fff];
}
+ .rg-grid .rg-head .rg-table tr:first-child td:first-child,
+ .rg-grid .rg-head .rg-table tr:first-child td:nth-child(2),
+ .rg-grid .rg-header .rg-table tr:first-child td:first-child,
+ .rg-grid .rg-header .rg-table tr:first-child td:nth-child(2) {
+ @apply !bg-[#6bafae] !text-[#fff];
+ }
}
}
+
+.tabs-content {
+ margin-top: 0.6rem !important;
+}
\ No newline at end of file
diff --git a/apps/st/src/components/module/CompactDataGrid.tsx b/apps/st/src/components/module/CompactDataGrid.tsx
index 4377eb762..b1f97eb14 100644
--- a/apps/st/src/components/module/CompactDataGrid.tsx
+++ b/apps/st/src/components/module/CompactDataGrid.tsx
@@ -1,9 +1,9 @@
-import { useRef, useEffect } from "react"
+import { useRef, useEffect, useLayoutEffect } from "react"
import { ValueType } from "realgrid"
import RealGrid, { GridFitStyle } from "realgrid"
import { RealGridReact, RGDataColumn } from "realgrid-react"
-import type { DataGridProps, RealGridColumnInfo, GridViewWithSetColumns, FooterItemWithCallback } from "../type/DataGrid.type"
+import type { DataGridProps, RealGridColumnInfo, GridViewWithSetColumns, FooterItemWithCallback, CellStyleCallback } from "../type/DataGrid.type"
import { getComputedValueCallback } from "@/components/util/grid/computedFieldCallbacks"
// RealGrid 타입 확장
@@ -62,15 +62,37 @@ function createNumberComparer(dp: RealGridDataProviderComparer): (field: string
};
}
-const CompactDataGrid = ({ columnInfo, fixedColumns, showHeaderCheckbox = false, rowData, setRowData }: DataGridProps) => {
+const CompactDataGrid = ({
+ columnInfo,
+ fixedColumns,
+ showHeaderCheckbox = false,
+ rowData,
+ enableSort = false,
+ // setRowData,
+ getGridDataRef,
+ cellStyleCallback,
+ onRowClick,
+}: DataGridProps) => {
const { columns, columnLayout } = columnInfo;
const gridRef = useRef(null);
+ const rowDataRef = useRef[]>(rowData);
+ const cellStyleCallbackRef = useRef(cellStyleCallback);
+ rowDataRef.current = rowData;
+ cellStyleCallbackRef.current = cellStyleCallback;
+
+ useLayoutEffect(() => {
+ if (!cellStyleCallback || !gridRef.current?.gridView) return;
+ gridRef.current.gridView.refresh();
+ }, [cellStyleCallback, rowData]);
useEffect(() => {
if (!gridRef.current?.gridView || !gridRef.current?.dataProvider) return;
const gridView = gridRef.current.gridView;
const dataProvider = gridRef.current.dataProvider;
+ if ((dataProvider as { setRows?: (r: unknown[]) => void })?.setRows && rowData) {
+ (dataProvider as { setRows: (r: unknown[]) => void }).setRows(rowData);
+ }
// setDataComparer로 숫자 정렬 (setFields 미사용 → 데이터 유실·필드 중복 오류 없음)
const dp = dataProvider as RealGridDataProviderComparer;
@@ -231,11 +253,11 @@ const CompactDataGrid = ({ columnInfo, fixedColumns, showHeaderCheckbox = false,
// footer가 단일 객체이고 valueCallback이 있는 경우
columns.forEach(col => {
if (!col.footer || Array.isArray(col.footer)) return;
-
+
try {
const column = currentGridView.columnByName(col.name);
if (!column || !column.footer) return;
-
+
if (col.footer.valueCallback) {
(column.footer as { valueCallback?: typeof col.footer.valueCallback }).valueCallback = col.footer.valueCallback;
}
@@ -243,9 +265,75 @@ const CompactDataGrid = ({ columnInfo, fixedColumns, showHeaderCheckbox = false,
console.warn(`Error setting footer valueCallback for column ${col.name}:`, e);
}
});
+
+ // cellStyleCallback이 있으면 컬럼별 styleCallback 설정
+ if (cellStyleCallbackRef.current) {
+ columns.forEach((column) => {
+ const baseStyleName = column.styleName;
+ const gridCol = currentGridView.columnByName(column.name) as {
+ styleCallback?: (g: unknown, dataCell: { itemIndex?: number; index?: { itemIndex?: number }; dataField?: string; fieldName?: string }) => string;
+ } | null;
+ if (gridCol) {
+ gridCol.styleCallback = (_g, dataCell) => {
+ const itemIndex = dataCell?.index?.itemIndex ?? dataCell?.itemIndex ?? -1;
+ const fieldName = (dataCell?.dataField ?? dataCell?.fieldName ?? column.fieldName) ?? '';
+ const data = rowDataRef.current;
+ if (itemIndex < 0 || itemIndex >= data.length) return baseStyleName ?? '';
+ const row = data[itemIndex] as Record;
+ const extra = cellStyleCallbackRef.current?.({ rowIndex: itemIndex, fieldName, row });
+ if (!extra) return baseStyleName ?? '';
+ return baseStyleName ? `${extra} ${baseStyleName}` : extra;
+ };
+ }
+ });
+ }
+ currentGridView.refresh?.();
}, 200);
- }, [columns, columnLayout, fixedColumns, rowData, showHeaderCheckbox]);
+ if (getGridDataRef) {
+ const fieldNames = columns.map((c) => c.fieldName).filter(Boolean) as string[];
+ getGridDataRef.current = () => {
+ const gv = gridRef.current?.gridView as {
+ getDataSource?: () => { getRowCount(): number; getValue(r: number, f: string): unknown };
+ } | undefined;
+ if (!gv?.getDataSource) return [];
+ const ds = gv.getDataSource();
+ if (!ds) return [];
+ const cnt = ds.getRowCount();
+ const rows: Record[] = [];
+ for (let i = 0; i < cnt; i++) {
+ const r: Record = {};
+ for (const f of fieldNames) r[f] = ds.getValue(i, f) as string | number;
+ rows.push(r);
+ }
+ return rows;
+ };
+ }
+
+ return () => {
+ if (getGridDataRef) getGridDataRef.current = null;
+ };
+ }, [columns, columnLayout, fixedColumns, rowData, showHeaderCheckbox, getGridDataRef]);
+
+ // 행 클릭 이벤트
+ useEffect(() => {
+ const gridView = gridRef.current?.gridView;
+ if (!gridView || !onRowClick) return;
+
+ gridView.onCellClicked = (grid, clickData) => {
+ if (clickData.cellType !== "data" || clickData.itemIndex === undefined) return;
+ try {
+ const row = rowDataRef.current[clickData.itemIndex];
+ onRowClick((row ?? {}) as Record);
+ } catch (error) {
+ console.error("행 데이터 가져오기 오류:", error);
+ }
+ };
+
+ return () => {
+ gridView.onCellClicked = undefined;
+ };
+ }, [onRowClick]);
return (
@@ -255,11 +343,16 @@ const CompactDataGrid = ({ columnInfo, fixedColumns, showHeaderCheckbox = false,
autoGenerateField={true}
layout={columnLayout}
rows={rowData}
- onInitialized={(grid) => {
- const dp = gridRef.current?.dataProvider as { setFields?: (fields: unknown[]) => void } | undefined;
+ onInitialized={() => {
+ const dp = gridRef.current?.dataProvider as
+ | { setFields?(f: unknown[]): void; setRows?(r: Record
[]): void }
+ | undefined;
if (dp?.setFields) {
dp.setFields(buildFieldsFromColumns(columns));
}
+ if (dp?.setRows && rowData?.length) {
+ dp.setRows(rowData);
+ }
}}
>
{columns.map((column) => (
@@ -283,7 +376,7 @@ const CompactDataGrid = ({ columnInfo, fixedColumns, showHeaderCheckbox = false,
numberFormat: column.footer.numberFormat,
} : undefined}
mergeRule={column.mergeCells ? { criteria: 'value' } : undefined}
- sortable={!column.sortdisable}
+ sortable={enableSort && !column.sortdisable}
/>
))}
diff --git a/apps/st/src/components/module/DataGrid.tsx b/apps/st/src/components/module/DataGrid.tsx
index 7d7f925ee..d85693a53 100644
--- a/apps/st/src/components/module/DataGrid.tsx
+++ b/apps/st/src/components/module/DataGrid.tsx
@@ -1,23 +1,25 @@
-import { useRef, useEffect, useLayoutEffect } from "react";
-import { ValueType } from "realgrid";
+import { useEffect, useLayoutEffect, useRef } from "react";
-import RealGrid, { GridFitStyle, type DisplayOptions } from "realgrid";
-import { RealGridReact, RGDataColumn } from "realgrid-react";
-import type {
- DataGridProps,
- RealGridColumnInfo,
- GridViewWithSetColumns,
- FooterItemWithCallback,
- ColumnLayoutItem,
- CellStyleCallback,
-} from "../type/DataGrid.type";
-import { getComputedValueCallback } from "@/components/util/grid/computedFieldCallbacks";
import JSZip from "jszip";
+import { ValueType } from "realgrid";
+import RealGrid, { type DisplayOptions, GridFitStyle } from "realgrid";
+import { RealGridReact, RGDataColumn } from "realgrid-react";
import * as XLSX from "xlsx";
import { useAlertStore } from "@/shared/lib";
-
import { Button } from "@common/components";
+
+import type {
+ CellStyleCallback,
+ CellTooltipCallback,
+ ColumnLayoutItem,
+ DataGridProps,
+ FooterItemWithCallback,
+ GridViewWithSetColumns,
+ RealGridColumnInfo,
+} from "../type/DataGrid.type";
+
+import { getComputedValueCallback } from "@/components/util/grid/computedFieldCallbacks";
import { useTabStore } from "@/store/tab-store";
// RealGrid 타입 확장
@@ -86,110 +88,6 @@ function toNum(v: unknown): number {
return parseFloat(String(v).replace(/,/g, "")) || NaN;
}
-/** 헤더에 정렬 화살표 표시 (CSS 방식) */
-function updateHeaderSortIndicator(
- gridView: {
- columnByIndex?: (
- i: number,
- ) => { fieldName?: string; name?: string } | undefined;
- columnByName?: (
- name: string,
- ) => { fieldName?: string; name?: string } | undefined;
- getColumn?: (
- i: number,
- ) => { fieldName?: string; name?: string } | undefined;
- },
- columns: { fieldName?: string; name: string }[],
- sortDir: Record,
-) {
- try {
- columns.forEach((col) => {
- if (!col.fieldName && !col.name) return;
-
- // 컬럼 찾기
- let gridCol: { fieldName?: string; name?: string } | undefined;
- for (let i = 0; i < 100; i++) {
- const c =
- gridView.columnByIndex?.(i) ?? gridView.getColumn?.(i);
- if (!c) break;
- if (
- c.fieldName === (col.fieldName || col.name) ||
- c.name === (col.fieldName || col.name)
- ) {
- gridCol = c;
- break;
- }
- }
- if (!gridCol) {
- gridCol = gridView.columnByName?.(col.name);
- }
-
- if (!gridCol) return;
-
- const fieldName =
- gridCol.fieldName || gridCol.name || col.fieldName || col.name;
- const direction = sortDir[fieldName] || sortDir[col.name] || "none";
-
- // DOM에서 헤더 요소 찾기
- const gridEl1 = document.querySelector(
- "[data-grid-id]",
- ) as HTMLElement | null;
- const gridEl2 = document.querySelector(
- ".rg-grid",
- ) as HTMLElement | null;
- const gridEl = gridEl1 || gridEl2;
- if (!gridEl) return;
-
- // 헤더 셀 찾기 (fieldName 또는 name으로)
- const headerCells = gridEl.querySelectorAll(
- '.rg-header-cell, [class*="header"]',
- );
- let targetCell: HTMLElement | null = null;
-
- headerCells.forEach((cell) => {
- const el = cell as HTMLElement;
- const text = el.textContent?.trim();
- const colName = col.name;
- const colFieldName = col.fieldName;
- if (
- text &&
- (text.includes(colName) ||
- (colFieldName && text.includes(colFieldName)))
- ) {
- targetCell = el;
- }
- });
-
- if (!targetCell) return;
-
- const headerCell = targetCell as HTMLElement;
-
- // 기존 화살표 제거
- const existingIndicator = headerCell.querySelector(
- ".rg-sort-indicator",
- ) as HTMLElement | null;
- if (existingIndicator) {
- existingIndicator.remove();
- }
-
- // 화살표 추가
- if (direction === "asc" || direction === "desc") {
- const indicator = document.createElement("span");
- indicator.className = "rg-sort-indicator";
- indicator.textContent = direction === "asc" ? "▲" : "▼";
- indicator.style.marginLeft = "4px";
- indicator.style.fontSize = "10px";
- indicator.style.color = "#666";
- indicator.style.display = "inline-block";
- indicator.style.verticalAlign = "middle";
- headerCell.appendChild(indicator);
- }
- });
- } catch (e) {
- console.warn("updateHeaderSortIndicator error:", e);
- }
-}
-
/** onSorting: mergeCells: true인 컬럼이 있으면 그룹 내 순서 유지 정렬, 없으면 RealGrid 기본 정렬 */
function createOnSortingHandler(
columns: {
@@ -215,12 +113,14 @@ function createOnSortingHandler(
),
];
// mergeCells: true인 컬럼 찾기 (시군구 등) - 그룹 필드로 사용
- // 시군구를 우선으로 찾고, 없으면 첫 번째 mergeCells 컬럼 사용
+ // 시군구(sggNm) 우선, 없으면 행정구역명(admdstNm), 없으면 첫 번째 mergeCells 컬럼
const mergeCellCols = columns.filter(
(c) => c.mergeCells === true && c.fieldName,
);
const mergeCellCol =
- mergeCellCols.find((c) => c.fieldName === "gugun") || mergeCellCols[0];
+ mergeCellCols.find((c) => c.fieldName === "sggNm") ||
+ mergeCellCols.find((c) => c.fieldName === "admdstNm") ||
+ mergeCellCols[0];
const groupField = mergeCellCol?.fieldName || null;
const orderByField = "displayYear"; // 고정값 또는 설정 가능하게 할 수 있음
const hasMergeCells = mergeCellCols.length > 0;
@@ -251,7 +151,8 @@ function createOnSortingHandler(
const col = columns.find(
(c) => c.fieldName === sortField || c.name === sortField,
);
- if (!col || col.sortdisable) return true;
+ // sortdisable 컬럼은 정렬 불가(클릭 무시)
+ if (!col || col.sortdisable) return false;
// mergeCells: true인 컬럼이 없으면 RealGrid 기본 정렬 사용
if (!hasMergeCells || !groupField) return true;
@@ -276,6 +177,7 @@ function createOnSortingHandler(
if (orig && setRows) {
setRows(orig);
setRowData(orig);
+ (g as { refresh?: () => void }).refresh?.();
}
sortDirRef.current[sortField] = "none";
return false;
@@ -294,11 +196,13 @@ function createOnSortingHandler(
);
setRows?.(sorted);
setRowData(sorted);
+ // 데이터 변경 후 merge 재적용
+ (g as { refresh?: () => void }).refresh?.();
return false;
};
}
-/** sortPreserveGroupOrder: 그룹 간에는 sortField로 정렬, 그룹 내에서는 원본 순서 유지. */
+/** sortPreserveGroupOrder: 그룹 간에는 sortField로 정렬, 그룹 내에서는 원본 순서 유지(구분 포함). */
function sortRowsGroupPreserving(
rows: Record[],
sortField: string,
@@ -415,17 +319,46 @@ const DataGrid = ({
setRowData,
getGridDataRef,
cellStyleCallback,
+ cellTooltipCallback,
onRowClick,
+ statusInfo,
+ highlightedRowId,
+ idFieldName = "id",
+ enableSort = false,
}: DataGridProps) => {
const { columns, columnLayout } = columnInfo;
const gridRef = useRef(null);
const fileInputRef = useRef(null);
const sortDirRef = useRef>({});
- const originalRowsRef = useRef[]>>({});
+ const originalRowsRef = useRef<
+ Record[]>
+ >({});
const rowDataRef = useRef[]>(rowData);
- const cellStyleCallbackRef = useRef(cellStyleCallback);
+ const cellStyleCallbackRef = useRef(
+ cellStyleCallback,
+ );
+ const cellTooltipCallbackRef = useRef(
+ cellTooltipCallback,
+ );
rowDataRef.current = rowData;
cellStyleCallbackRef.current = cellStyleCallback;
+ cellTooltipCallbackRef.current = cellTooltipCallback;
+
+ // highlightedRowId가 변경될 때 해당 행으로 스크롤 및 선택
+ useEffect(() => {
+ if (!gridRef.current?.gridView || highlightedRowId == null) return;
+
+ const gridView = gridRef.current.gridView;
+ const index = rowData.findIndex(
+ (row) => String(row[idFieldName]) === String(highlightedRowId),
+ );
+
+ if (index !== -1) {
+ // 해당 행으로 포커스 이동 및 스크롤
+ gridView.setCurrent({ itemIndex: index });
+ gridView.setFocus();
+ }
+ }, [highlightedRowId, rowData, idFieldName]);
useLayoutEffect(() => {
if (!cellStyleCallback || !gridRef.current?.gridView) return;
@@ -542,6 +475,7 @@ const DataGrid = ({
target: "local",
fileName: `${menuName}.xlsx`,
footer: "hidden",
+ allColumns: true, // 숨김(visible: false) 컬럼도 엑셀에 포함
done: () => {
// 헤더 원복
columns.forEach((col) => {
@@ -624,10 +558,10 @@ const DataGrid = ({
) {
const cell =
worksheet[
- XLSX.utils.encode_cell({
- r: rowIndex,
- c: col,
- })
+ XLSX.utils.encode_cell({
+ r: rowIndex,
+ c: col,
+ })
];
if (
cell &&
@@ -703,7 +637,7 @@ const DataGrid = ({
const col = range.s.c + colIndex;
const cell =
worksheet[
- XLSX.utils.encode_cell({ r: row, c: col })
+ XLSX.utils.encode_cell({ r: row, c: col })
];
const headerName =
headers[colIndex] || `Column${colIndex + 1}`;
@@ -826,12 +760,14 @@ const DataGrid = ({
gridView as {
onSorting?: (g: unknown, f: unknown[], d: unknown[]) => boolean;
}
- ).onSorting = createOnSortingHandler(
- columns,
- setRowData,
- sortDirRef,
- originalRowsRef,
- );
+ ).onSorting = enableSort
+ ? createOnSortingHandler(
+ columns,
+ setRowData,
+ sortDirRef,
+ originalRowsRef,
+ )
+ : () => false;
(gridView.displayOptions as DisplayOptions).fitStyle =
GridFitStyle.EVEN;
@@ -945,12 +881,14 @@ const DataGrid = ({
editable: col.editable,
valueType: col.valueType,
dataFormat: col.dataFormat,
+ ...(col.width != null && { width: col.width }),
...(col.styleName != null && {
styleName: col.styleName,
}),
...(col.mergeCells && {
mergeRule: { criteria: "value" as const },
}),
+ sortable: !col.sortdisable,
};
if (col.footer) {
@@ -999,7 +937,7 @@ const DataGrid = ({
) {
(
column.footer[
- index
+ index
] as FooterItemWithCallback
).valueCallback =
footerItem.valueCallback;
@@ -1041,6 +979,8 @@ const DataGrid = ({
);
}
});
+ // valueCallback 적용 후 푸터 셀 다시 그리기 (API 합계 등 우선 표시 반영)
+ currentGridView.refresh?.();
}, 200);
if (getGridDataRef) {
@@ -1050,11 +990,11 @@ const DataGrid = ({
getGridDataRef.current = () => {
const gv = gridRef.current?.gridView as
| {
- getDataSource?: () => {
- getRowCount(): number;
- getValue(r: number, f: string): unknown;
- };
- }
+ getDataSource?: () => {
+ getRowCount(): number;
+ getValue(r: number, f: string): unknown;
+ };
+ }
| undefined;
if (!gv?.getDataSource) return [];
const ds = gv.getDataSource();
@@ -1083,38 +1023,72 @@ const DataGrid = ({
getGridDataRef,
]);
- // 행 클릭 이벤트 설정
+ // 행 클릭 이벤트 + 검증 오류 셀 클릭 시 showMessage (공식 문서 방식)
useEffect(() => {
- if (!gridRef.current?.gridView || !onRowClick) return;
+ const gridView = gridRef.current?.gridView;
+ if (!gridView || (!onRowClick && !cellTooltipCallback)) return;
- const gridView = gridRef.current.gridView;
-
- // onCellClicked 이벤트 핸들러 설정
gridView.onCellClicked = (grid, clickData) => {
- // 데이터 셀만 처리 (헤더, 푸터 등 제외)
if (
- clickData.cellType === "data" &&
- clickData.itemIndex !== undefined
- ) {
- try {
- // 행 전체 데이터 가져오기
- const row = rowData[clickData.itemIndex];
-
- // 부모 컴포넌트로 데이터 전달
- onRowClick(row ?? {});
- } catch (error) {
- console.error("행 데이터 가져오기 오류:", error);
+ clickData.cellType !== "data" ||
+ clickData.itemIndex === undefined
+ )
+ return;
+ try {
+ const row = rowDataRef.current[clickData.itemIndex];
+ const cellIndex = (
+ grid as {
+ getCurrent?: () => {
+ itemIndex: number;
+ column?: string;
+ fieldName?: string;
+ };
+ }
+ ).getCurrent?.();
+ // cellTooltipCallback 있으면 해당 셀 메시지로 showMessage (공식 문서: TooltipOptions + CellIndex)
+ if (cellTooltipCallbackRef.current && row && cellIndex) {
+ const fieldName =
+ cellIndex.fieldName ?? cellIndex.column ?? "";
+ const msg = cellTooltipCallbackRef.current({
+ row: row as Record,
+ fieldName,
+ });
+ if (msg) {
+ const colName =
+ columns.find(
+ (c) =>
+ c.fieldName === fieldName ||
+ c.name === cellIndex.column,
+ )?.name ??
+ cellIndex.column ??
+ fieldName;
+ (
+ gridView as {
+ showMessage?: (
+ opts: {
+ message: string;
+ hideDuration?: number;
+ },
+ idx: { itemIndex: number; column: string },
+ ) => void;
+ }
+ ).showMessage?.(
+ { message: msg, hideDuration: 5000 },
+ { itemIndex: cellIndex.itemIndex, column: colName },
+ );
+ }
}
+ if (onRowClick)
+ onRowClick((row ?? {}) as Record);
+ } catch (error) {
+ console.error("행 데이터 가져오기 오류:", error);
}
};
- // cleanup: 컴포넌트 언마운트 시 이벤트 핸들러 제거
return () => {
- if (gridView.onCellClicked) {
- gridView.onCellClicked = undefined;
- }
+ if (gridView.onCellClicked) gridView.onCellClicked = undefined;
};
- }, [onRowClick]);
+ }, [onRowClick, cellTooltipCallback, columns]);
return (
@@ -1126,6 +1100,44 @@ const DataGrid = ({
onChange={handleFileChange}
/>
+ {statusInfo && (
+
+
+ 진행상황:{" "}
+ {statusInfo.laeSbmsnSttsCd === null ||
+ statusInfo.laeSbmsnSttsCd === "0"
+ ? "미작성"
+ : statusInfo.laeSbmsnSttsCd === "1"
+ ? "작성중"
+ : statusInfo.laeSbmsnSttsCd === "2"
+ ? "제출"
+ : statusInfo.laeSbmsnSttsCd === "3"
+ ? "승인"
+ : statusInfo.laeSbmsnSttsCd === "4"
+ ? "반려"
+ : "검증중"}
+
+
+ 제출일:{" "}
+ {statusInfo.laeSbmsnYmd
+ ? statusInfo.laeSbmsnYmd.length === 8 &&
+ /^\d{8}$/.test(statusInfo.laeSbmsnYmd)
+ ? `${statusInfo.laeSbmsnYmd.slice(0, 4)}-${statusInfo.laeSbmsnYmd.slice(4, 6)}-${statusInfo.laeSbmsnYmd.slice(6, 8)}`
+ : statusInfo.laeSbmsnYmd
+ : "미제출"}
+
+
+ 담당자:{" "}
+ {statusInfo.laePicNm ? statusInfo.laePicNm : "-"}
+
+
+ 연락처:{" "}
+ {statusInfo.laePicTelNo
+ ? statusInfo.laePicTelNo
+ : "-"}
+
+
+ )}
{isExcelImport && (