Compare commits

..
Author SHA1 Message Date
mind-one005 b1a67f549f Merge branch 'iipbs/wr/dev' into iipbs/fo/dev 2026-06-26 11:32:56 +09:00
mind-one005 c0888ea2a4 wr: 빗물이용시설 엑셀다운 수정 2026-06-26 11:30:57 +09:00
mind-one005 1ead18b53c wr: 지도팝업 작업계획 2026-06-26 10:01:10 +09:00
mind-one005 07db5b1fbd wr: 페이징 수정 2026-06-25 18:09:04 +09:00
mind-one005 6110abebb4 wr: 페이징 오류 작업계획 2026-06-25 16:30:29 +09:00
mind-one005 05e9082831 wr: black 테마 수정 2026-06-25 15:23:04 +09:00
mind-one005 76a634cf9a fo: 테마적용1 2026-06-25 14:34:55 +09:00
mind-one005 793c673414 wr: 페이지 내 색상 정의 수정2 2026-06-24 18:16:22 +09:00
mind-one005 2a76c279eb wr: 페이지 내 색상 정의 수정1 2026-06-24 16:38:53 +09:00
mind-one005 5d1ba3fd87 wr: theme 적용1 2026-06-24 15:13:16 +09:00
mind-one005 99db9a0b77 wr: business_component css 수정1 2026-06-23 22:05:19 +09:00
mind-one005 4126bc2234 wr: 작업계획 2026-06-23 13:32:25 +09:00
mind-one005 d4f1ec095e Merge branch 'iipbs/wr/dev' into iipbs/dm/dev 2026-06-23 11:42:50 +09:00
LEEKYUHYUN bbf3ce3948 wr: 테이블 헤더 복구 2026-06-23 10:38:05 +09:00
LEEKYUHYUN fb5b8356c7 물 재이용 소개 이미지 교체 2026-06-22 18:09:16 +09:00
LEEKYUHYUN 1333bd040a 물 재이용 소개 이미지 교체 2026-06-22 18:02:57 +09:00
LEEKYUHYUN 013c07ef59 Merge branch 'iipbs/dm/dev' of https://www.onasys.co.kr:3000/ONAANDALL/frontend into iipbs/dm/dev 2026-06-22 17:55:20 +09:00
mind-one005 29fd59a321 wr: 수요처 readonly 처리 2026-06-22 17:42:10 +09:00
mind-one005 3bc152c341 dm: 용어수정 2026-06-22 16:35:19 +09:00
LEEKYUHYUN ea274f8f7d 랜딩 페이지 이미지 교체 2026-06-22 16:33:16 +09:00
LGS 7ac53e56d7 WR dashboard-black theme edit-Leeinseo 2026-06-22 13:17:12 +09:00
LEEKYUHYUN 7b1c12e952 블루 테마 카드 스타일 수정_FO 2026-06-22 13:10:50 +09:00
LGS 17dafe6530 Merge branch 'iipbs/dm/dev' of https://www.onasys.co.kr:3000/ONAANDALL/frontend into iipbs/dm/dev 2026-06-22 13:09:57 +09:00
LGS ef331363f7 Revert "[ wr ] 물재이용 매칭시스템 Black 테마 css 추가"
This reverts commit 4f10864c2c.
2026-06-22 13:09:53 +09:00
LGS 4f10864c2c [ wr ] 물재이용 매칭시스템 Black 테마 css 추가 2026-06-22 13:09:20 +09:00
Leeinseo 4b5ceb0e0a FO dashboard-red theme edit-Leeinseo 2026-06-22 13:06:30 +09:00
mind-one005 4fa50161fd wr: width 조정 2026-06-22 09:43:09 +09:00
mind-one005 3d27e07ccb fo: theme css 추가 2026-06-19 17:27:30 +09:00
mind-one005 ca1ca71a62 Merge branch 'iipbs/wr/dev' into iipbs/dm/dev 2026-06-19 16:45:02 +09:00
mind-one005 bdd6c7b804 dm: theme css 추가 2026-06-19 16:44:30 +09:00
mind-one005 04b8082f04 wr: width 고정 2026-06-19 16:15:48 +09:00
Leeinseo 7bf8ec1d35 Merge remote-tracking branch 'origin/HEAD' into iipbs/fo/dev 2026-06-19 15:59:11 +09:00
mind-one005 3451b19b96 wr: next-env.d.ts 2026-06-18 10:59:41 +09:00
mind-one005 ae7c1ea1c4 Merge branch 'iipbs/wr/dev' 2026-06-18 10:47:12 +09:00
mind-one005 9c76b955e2 wr: 오류 수정 2026-06-18 10:35:10 +09:00
mind-one005 6c4bfe2798 wr: 수요처 매칭 검토 2026-06-18 10:27:00 +09:00
mind-one005 98189cd14a dm: 총대장균군수 단위 수정 2026-06-17 17:25:18 +09:00
mind-one005 823ce262bb wr: 수요처 매칭 검토 천단위 , 건수 10건 2026-06-17 16:29:28 +09:00
mind-one005 34358400e8 wr: 지도점검 현황 팝업 table 항목명 수정 2026-06-17 14:58:41 +09:00
mind-one005 da9d069a9a wr: 총대장균군수 단위 수정 외 2026-06-17 13:32:23 +09:00
mind-one005 58a03678e4 wr: 시설명 자동검색 제거 2026-06-16 18:16:14 +09:00
mind-one005 f5c281cf86 wr: 시군구 해당 위치로 2026-06-16 15:51:44 +09:00
chanii8 c72bcea7d9 Merge branch 'iipbs/as/dev'
Deploy Frontend / deploy (push) Successful in 1m1s
2026-06-16 15:05:15 +09:00
chanii8 49cd160e76 Merge branch 'iipbs/cs/dev'
Deploy Frontend / deploy (push) Successful in 1m2s
2026-06-15 17:43:18 +09:00
chanii8 5ca93a25ca [cs] chore: 상위코드 목록 컬럼 수정 2026-06-15 17:42:55 +09:00
chanii8 55554fc781 Merge branch 'iipbs/st/dev'
Deploy Frontend / deploy (push) Successful in 3m49s
2026-06-15 16:43:55 +09:00
chanii8 8e78c10304 Merge branch 'iipbs/st/dev' of https://www.onasys.co.kr:3000/ONAANDALL/frontend into iipbs/st/dev 2026-06-15 16:43:35 +09:00
chanii8 0eb74b46ba [st] feat: 메뉴 경로에 '/cms' 포함 시 탭 로직 예외 처리 2026-06-15 16:42:38 +09:00
rsone001 f21dde8f7d [st] - tab & menu 로직 수정 2026-06-15 16:34:02 +09:00
mind-one005 1fc6707bb3 wr: 총대장균군수 단위 수정 2026-06-15 16:27:41 +09:00
rsone001 44c5d66afa Merge remote-tracking branch 'origin/feature/sein' into iipbs/st/dev 2026-06-15 15:54:04 +09:00
mind-one005 04edb49c9f wr: 지도 back 테마 적용 2026-06-15 15:48:05 +09:00
rsone001 e3eb542e59 [st] - 입력단 메뉴 작업중 2026-06-15 10:38:33 +09:00
yangsein 428b199dd3 feat:12하수및분뇨관리사업 2026-06-15 01:02:13 +09:00
yangsein 3c4a6efeea feat:10.물재이용 2026-06-15 01:01:51 +09:00
yangsein dae7024043 feat:[집계표]13.하수도 서비스수준 2026-06-15 01:00:31 +09:00
yangsein 64e5c26e8c feat: [집계표] 12.하수및 분뇨관리사업 2026-06-15 00:59:03 +09:00
yangsein 84ae60edd6 feat:[집계표] 11하수및 분뇨찌꺼기 2026-06-15 00:57:39 +09:00
yangsein d4c9989069 feat: [집계표] 물재이용 2026-06-15 00:56:20 +09:00
yangsein 6cda413913 feat:[집계표]09.하수도 재정 2026-06-15 00:55:06 +09:00
yangsein a11eaf031d feat:[집계표]08.개인하수처리시설 2026-06-15 00:52:54 +09:00
yangsein 3c701359fd feat:[집계표]분뇨처리시설 2026-06-15 00:50:54 +09:00
yangsein 85ba9fe0ab feat:[집계표]05 펌프장 2026-06-15 00:48:45 +09:00
yangsein 71077f4c8e feat:[집계표]04.하수저류시설 및 빗물 펌프장 2026-06-15 00:47:11 +09:00
yangsein 4c7ff53d78 feat:[집계표]03.하수관로 유지관리 2026-06-15 00:43:03 +09:00
mind-one005 b31691dd9c dm: 물재이용 소개, 사례 id 수정 2026-06-12 10:33:30 +09:00
mind-one005 0131cae0d7 wr: 하수재이용시설등록 후 목록 재조회 2026-06-11 17:45:58 +09:00
chanii8 f37b19eab6 Merge branch 'iipbs/cs/dev'
Deploy Frontend / deploy (push) Successful in 5m3s
2026-06-11 16:38:24 +09:00
chanii8 7c1db5e0e0 [cs] refactor: useSearchParamsState 생성 및 적용 2026-06-11 16:37:15 +09:00
mind-one005 2c495ab8fd wr: help 이미지 수정 2026-06-11 09:27:25 +09:00
dongsilove ced1b7084d wr: refresh token 원복 2026-06-10 23:22:44 +09:00
mind-one005 b0c871eff7 wr: 하수재이용시설 등록 수정 2026-06-10 15:30:37 +09:00
mind-one005 bd83fa3417 Merge remote-tracking branch 'origin/main' into iipbs/wr/dev 2026-06-10 11:36:11 +09:00
mind-one005 c0b63a655e wr: isReset 삭제 2026-06-10 11:35:09 +09:00
mind-one005 cc21aaede1 wr: 하수재이용시설현황 속도 개선 2026-06-10 11:16:10 +09:00
jcchoi 0c71c80a25 대시보드 알림림 스크롤 처리 2026-06-09 17:29:50 +09:00
rsone001 49e42757bc Merge branch 'iipbs/st/dev' of https://www.onasys.co.kr:3000/ONAANDALL/frontend into iipbs/st/dev 2026-06-09 10:34:45 +09:00
chanii8 0964f442be Merge branch 'iipbs/st/dev'
Deploy Frontend / deploy (push) Successful in 2m48s
2026-06-09 10:08:45 +09:00
chanii8 73ceeb1b38 [st] feat: 대시보드 공지사항 api 수정 2026-06-09 10:08:00 +09:00
mind-one005 814365d7de wr: common-codes error 메시지 수정 2026-06-08 11:44:12 +09:00
mind-one005 8c1a976b5b wr: isInit 2026-06-08 11:02:11 +09:00
mind-one005 66db81071e wr: 빌드오류 해결 2026-06-08 09:06:58 +09:00
mind-one005 6df6e612bc wr: 공통코드 조회 수정 2026-06-05 17:44:51 +09:00
mind-one005 3fee2a781f wr: 하수재이용시설등록 수정 2026-06-05 16:15:46 +09:00
rsone001 55839f0913 Merge branch 'main' into iipbs/st/dev 2026-06-05 10:40:29 +09:00
rsone001 f68131fc13 Merge branch 'main' of https://www.onasys.co.kr:3000/ONAANDALL/frontend 2026-06-05 10:39:50 +09:00
rsone001 7b9081a125 [st] - feat: 입력 04 작업 중 & 수정 요청 사항 적용 2026-06-05 10:35:31 +09:00
mind-one005 54d3a35b38 wr: store code 조회 위치 변경 2026-06-04 15:56:28 +09:00
rsone001 ba42b74578 Merge remote-tracking branch 'origin/iipbs/st/dev' 2026-05-18 17:21:21 +09:00
chanii8 e19057d026 Merge branch 'main' of https://www.onasys.co.kr:3000/ONAANDALL/frontend into iipbs/fo/dev 2026-03-25 14:45:25 +09:00
ljpark f4c64168d1 Merge branch 'main' into iipbs/fo/dev 2025-12-05 11:39:55 +09:00
336 changed files with 26130 additions and 9665 deletions
-20
View File
@@ -23,26 +23,6 @@
"NODE_OPTIONS": "--inspect"
}
},
{
"type": "node-terminal",
"request": "launch",
"name": "dev:as 서버 컴포넌트 디버그 (node-terminal)",
"command": "pnpm --filter as dev",
"cwd": "${workspaceFolder}"
},
{
"type": "node-terminal",
"request": "launch",
"name": "dev:as 통합 디버그 (Server + Client)",
"command": "pnpm --filter as dev",
"cwd": "${workspaceFolder}",
"serverReadyAction": {
"action": "debugWithChrome",
"pattern": "- Local:\\s+(https?://.+)",
"uriFormat": "%s",
"webRoot": "${workspaceFolder}/apps/as"
}
},
{
"type": "node",
"request": "launch",
-13
View File
@@ -1,13 +0,0 @@
import * as Sentry from "@sentry/nextjs";
export const onRouterTransitionStart = Sentry.captureRouterTransitionStart;
Sentry.init({
dsn: process.env.NEXT_PUBLIC_SENTRY_DSN,
environment: process.env.NEXT_PUBLIC_SENTRY_ENVIRONMENT ?? "local",
tracesSampleRate: 1.0,
replaysSessionSampleRate: 1.0,
replaysOnErrorSampleRate: 1.0,
integrations: [Sentry.replayIntegration()],
debug: process.env.NEXT_PUBLIC_SENTRY_DEBUG === "true",
});
-13
View File
@@ -1,13 +0,0 @@
import * as Sentry from "@sentry/nextjs";
export async function register() {
if (process.env.NEXT_RUNTIME === "nodejs") {
await import("./sentry.server.config");
}
if (process.env.NEXT_RUNTIME === "edge") {
await import("./sentry.edge.config");
}
}
export const onRequestError = Sentry.captureRequestError;
+1 -17
View File
@@ -1,24 +1,8 @@
import type { NextConfig } from "next";
import { withSentryConfig } from "@sentry/nextjs";
import path from "path";
const nextConfig: NextConfig = {
reactStrictMode: true,
transpilePackages: ["@common/assets"],
outputFileTracingRoot: path.join(__dirname, "../../"),
};
export default withSentryConfig(nextConfig, {
org: process.env.SENTRY_ORG,
project: process.env.SENTRY_PROJECT,
silent: true,
widenClientFileUpload: true,
tunnelRoute: "/monitoring",
webpack: {
treeshake: {
removeDebugLogging: process.env.NEXT_PUBLIC_SENTRY_DEBUG === "true" ? false : true,
},
automaticVercelMonitors: false,
},
});
export default nextConfig;
-7
View File
@@ -20,13 +20,6 @@
"@faker-js/faker": "^9.9.0",
"@frontend-opensource/use-react-hooks": "^1.1.4",
"@hookform/resolvers": "^4.1.3",
"@opentelemetry/api": "^1.9.1",
"@opentelemetry/context-zone": "^2.8.0",
"@opentelemetry/exporter-trace-otlp-http": "^0.219.0",
"@opentelemetry/instrumentation-fetch": "^0.219.0",
"@opentelemetry/instrumentation-xml-http-request": "^0.219.0",
"@opentelemetry/sdk-trace-web": "^2.8.0",
"@sentry/nextjs": "^10.57.0",
"@tanstack/react-query": "^5.75.5",
"@tanstack/react-query-devtools": "^5.75.5",
"@tanstack/react-table": "^8.21.2",
-8
View File
@@ -1,8 +0,0 @@
import * as Sentry from "@sentry/nextjs";
Sentry.init({
dsn: process.env.NEXT_PUBLIC_SENTRY_DSN,
environment: process.env.NEXT_PUBLIC_SENTRY_ENVIRONMENT ?? "local",
tracesSampleRate: 1.0,
debug: process.env.NEXT_PUBLIC_SENTRY_DEBUG === "true",
});
-8
View File
@@ -1,8 +0,0 @@
import * as Sentry from "@sentry/nextjs";
Sentry.init({
dsn: process.env.NEXT_PUBLIC_SENTRY_DSN,
environment: process.env.NEXT_PUBLIC_SENTRY_ENVIRONMENT ?? "local",
tracesSampleRate: 1.0,
debug: process.env.NEXT_PUBLIC_SENTRY_DEBUG === "true",
});
@@ -865,7 +865,7 @@ function CnfmViewPage({ id }: { id: string }) {
<tr key={f.atflId}>
<td>
{f.orgnlFileNm ? (
<a className="text-blue-600" href={`${PROXY_HOST}/attach-service/api/v1/download/${f.atflId}`} target="_blank" rel="noreferrer">{f.orgnlFileNm}</a>
<a className="text-blue-600" href={`${PROXY_HOST}/attach-service/api/v1/atflcd/download/${f.atflCd}`} target="_blank" rel="noreferrer">{f.orgnlFileNm}</a>
) : (
<span className="text-gray-700">{f.physFileNm || f.atflId}</span>
)}
-15
View File
@@ -20,16 +20,6 @@ async function handler(
"Content-Type": "application/json",
"X-Site-Id": SITE_ID,
};
// X-Device-Id 중계 로직 (헤더 또는 쿠키에서 획득)
const deviceId =
request.headers.get("x-device-id") ||
request.headers.get("X-Device-Id") ||
cookieStore.get("device-id")?.value;
if (deviceId) {
headers["X-Device-Id"] = deviceId;
}
let body = {};
const isRefreshHandler = params.url.includes("refresh");
@@ -55,11 +45,6 @@ async function handler(
});
if (!result.ok) {
console.error(`[BFF Proxy Error] Backend returned status ${result.status} for ${params.url.join("/")}`);
try {
const errorText = await result.text();
console.error(`[BFF Proxy Error Body] ${errorText}`);
} catch (err) {}
return NextResponse.json(
{ message: "Invalid Credentials" },
{ status: 401 },
-13
View File
@@ -1,13 +0,0 @@
import * as Sentry from "@sentry/nextjs";
export const onRouterTransitionStart = Sentry.captureRouterTransitionStart;
Sentry.init({
dsn: process.env.NEXT_PUBLIC_SENTRY_DSN,
environment: process.env.NEXT_PUBLIC_SENTRY_ENVIRONMENT ?? "local",
tracesSampleRate: 1.0,
replaysSessionSampleRate: 1.0,
replaysOnErrorSampleRate: 1.0,
integrations: [Sentry.replayIntegration()],
debug: process.env.NEXT_PUBLIC_SENTRY_DEBUG === "true",
});
-13
View File
@@ -1,13 +0,0 @@
import * as Sentry from "@sentry/nextjs";
export async function register() {
if (process.env.NEXT_RUNTIME === "nodejs") {
await import("./sentry.server.config");
}
if (process.env.NEXT_RUNTIME === "edge") {
await import("./sentry.edge.config");
}
}
export const onRequestError = Sentry.captureRequestError;
+1 -14
View File
@@ -1,5 +1,4 @@
import type { NextConfig } from "next";
import { withSentryConfig } from "@sentry/nextjs";
const subDomain =
process.env.NEXT_PUBLIC_SUB_DOMAIN || "http://localhost:13000";
@@ -59,16 +58,4 @@ const nextConfig: NextConfig = {
},
};
export default withSentryConfig(nextConfig, {
org: process.env.SENTRY_ORG,
project: process.env.SENTRY_PROJECT,
silent: true,
widenClientFileUpload: true,
tunnelRoute: "/monitoring",
webpack: {
treeshake: {
removeDebugLogging: process.env.NEXT_PUBLIC_SENTRY_DEBUG === "true" ? false : true,
},
automaticVercelMonitors: false,
},
});
export default nextConfig;
+1 -4
View File
@@ -6,8 +6,7 @@
"dev": "next dev",
"build": "next build",
"start": "next start",
"lint": "next lint",
"test:playwright": "playwright test"
"lint": "next lint"
},
"dependencies": {
"@common/assets": "workspace:*",
@@ -27,7 +26,6 @@
"@radix-ui/react-label": "^2.1.2",
"@radix-ui/react-slot": "^1.1.2",
"@radix-ui/react-tooltip": "^1.1.8",
"@sentry/nextjs": "^10.57.0",
"@tanstack/react-query": "^5.75.5",
"@tanstack/react-query-devtools": "^5.75.5",
"@tanstack/react-table": "^8.21.2",
@@ -51,7 +49,6 @@
},
"devDependencies": {
"@eslint/eslintrc": "^3.0.0",
"@playwright/test": "^1.61.1",
"@types/js-cookie": "^3.0.6",
"@types/node": "^22.13.1",
"@types/webpack": "^5.28.5",
File diff suppressed because one or more lines are too long
-60
View File
@@ -1,60 +0,0 @@
import { defineConfig, devices } from "@playwright/test";
/**
* Playwright 설정 파일
* https://playwright.dev/docs/test-configuration
*/
export default defineConfig({
testDir: "./tests",
/* 하나의 테스트 파일 내에서 병렬 실행 금지 및 직렬 실행으로 세션 상태 격리 보장 */
fullyParallel: false,
/* Redis 및 PostgreSQL 공유 자원의 세션 정렬을 위해 워커를 1개로 제한 */
workers: 1,
/* 테스트 실패 시 재시도 횟수 */
retries: 0,
/* 각 테스트 타임아웃 (30초) */
timeout: 30000,
/* 전체 테스트 실행 타임아웃 */
globalTimeout: 600000,
/* Assertion 타임아웃 */
expect: {
timeout: 5000,
},
/* 리포터 설정 (HTML 리포트 및 터미널 요약) */
reporter: [["html", { open: "never" }], ["list"]],
/* 공유 설정 */
use: {
/* 테스트 타겟 URL */
baseURL: process.env.PLAYWRIGHT_BASE_URL || "http://localhost:3000",
/* 실패한 테스트에 대해서만 트레이스 수집 */
trace: "retain-on-failure",
/* 스크린샷 캡처 설정 */
screenshot: "only-on-failure",
},
/* 프로젝트별 브라우저 구성 (기본적으로 Chromium 위주로 동작 확인) */
projects: [
{
name: "chromium",
use: { ...devices["Desktop Chrome"] },
},
{
name: "firefox",
use: { ...devices["Desktop Firefox"] },
},
{
name: "webkit",
use: { ...devices["Desktop Safari"] },
},
],
/* 로컬 서버 자동 기동 설정 */
webServer: {
command: "pnpm dev",
url: "http://localhost:3000",
reuseExistingServer: true,
timeout: 120000,
stdout: "pipe",
stderr: "pipe",
},
});
-8
View File
@@ -1,8 +0,0 @@
import * as Sentry from "@sentry/nextjs";
Sentry.init({
dsn: process.env.NEXT_PUBLIC_SENTRY_DSN,
environment: process.env.NEXT_PUBLIC_SENTRY_ENVIRONMENT ?? "local",
tracesSampleRate: 1.0,
debug: process.env.NEXT_PUBLIC_SENTRY_DEBUG === "true",
});
-8
View File
@@ -1,8 +0,0 @@
import * as Sentry from "@sentry/nextjs";
Sentry.init({
dsn: process.env.NEXT_PUBLIC_SENTRY_DSN,
environment: process.env.NEXT_PUBLIC_SENTRY_ENVIRONMENT ?? "local",
tracesSampleRate: 1.0,
debug: process.env.NEXT_PUBLIC_SENTRY_DEBUG === "true",
});
+9 -13
View File
@@ -2,10 +2,9 @@
import { useEffect } from "react";
import { usePathname, useRouter } from "next/navigation";
import { useQuery } from "@tanstack/react-query";
import { MenuAuthorityApi } from "@/entities/menu-authority/@x/menu";
import { getCookie, useAlertStore, useMenuStore } from "@/shared/lib";
import { SITE_ID, ENDPOINT } from "@/shared/config";
import { SITE_ID } from "@/shared/config";
import { MenuApi } from "../api/menu-service";
import { getCurrentMenu } from "./utils";
@@ -17,10 +16,11 @@ const MenuWrapper = ({ children }: { children: React.ReactNode }) => {
const { pageMenuAuthority, setCurrentMenuInfo, setPageMenuAuthority } =
useMenuStore((state) => state);
const { data: menuRoles } = useQuery({
queryKey: [ENDPOINT.CMS_SERVICE.MENU_ROLES],
queryFn: () => MenuApi.getMenuRoles(SITE_ID),
});
const getCurrentMenuInfo = async () => {
const menuRoles = await MenuApi.getMenuRoles(SITE_ID);
if (!menuRoles) return;
return getCurrentMenu(menuRoles, pathname ?? "");
};
const getPageMenuAuthority = async () => {
const menuCd = getCookie("menu-code-key");
@@ -35,15 +35,11 @@ const MenuWrapper = ({ children }: { children: React.ReactNode }) => {
return pageMenuAuthority?.inqAuthrtYn === "Y";
};
useEffect(() => {
if (menuRoles) {
const currentMenu = getCurrentMenu(menuRoles, pathname ?? "");
if (currentMenu) setCurrentMenuInfo(currentMenu);
}
}, [menuRoles, pathname]);
useEffect(() => {
(async () => {
const currentMenu = await getCurrentMenuInfo();
if (currentMenu) setCurrentMenuInfo(currentMenu);
const response = await getPageMenuAuthority();
if (response?.length) setPageMenuAuthority(response[0]!);
})();
+15 -11
View File
@@ -1,17 +1,29 @@
"use client";
import { useState } from "react";
import { useConfirm } from "@frontend-opensource/use-react-hooks";
import { useQuery, useQueryClient } from "@tanstack/react-query";
import {
getCoreRowModel,
type PaginationState,
type Row,
type RowSelectionState,
type Table,
type Row,
} from "@tanstack/react-table";
import { ENDPOINT } from "@/shared/config";
import { useAlertStore, useDataTable } from "@/shared/lib";
import { GroupApi } from "@/entities/group";
import { GroupUsersApi } from "@/entities/group-user";
import {
type AdminUserListResponse,
UserApi,
type UserSearchParams,
} from "@/entities/user";
import {
Button,
Checkbox,
Input,
Label,
Select,
@@ -19,18 +31,10 @@ import {
SelectItem,
SelectTrigger,
SelectValue,
Checkbox,
} from "@common/components";
import { GroupApi } from "@/entities/group";
import {
UserApi,
type UserSearchParams,
type AdminUserListResponse,
} from "@/entities/user";
import { GroupUsersApi } from "@/entities/group-user";
import { ENDPOINT } from "@/shared/config";
import { useDataTable, useAlertStore } from "@/shared/lib";
import { columns } from "../model/table-columns";
import { AddAdminDialog } from "./add-admin-dialog";
function AdminPage() {
+41 -34
View File
@@ -1,20 +1,25 @@
"use client";
import { useEffect, useState, useRef } from "react";
import { useEffect, useRef, useState } from "react";
import { useRouter } from "next/navigation";
import { useQuery } from "@tanstack/react-query";
import { keepPreviousData, useQuery } from "@tanstack/react-query";
import {
getCoreRowModel,
type OnChangeFn,
type PaginationState,
type RowSelectionState,
} from "@tanstack/react-table";
import { Button, Input, Label } from "@common/components";
import { POSTING_TYPE, PostingDialog } from "@/entities/posting";
import { ENDPOINT } from "@/shared/config";
import { useDataTable } from "@/shared/lib";
import { useDataTable, useSearchParamsState } from "@/shared/lib";
import { POSTING_TYPE, PostingDialog } from "@/entities/posting";
import { Button, Input, Label } from "@common/components";
import { BannerApi } from "../api/banner-service";
import { type Banner } from "../model/banner-interface";
import { createColumns } from "../model/banner-table-columns";
import { type Banner, type BannerParams } from "../model/banner-interface";
import { PreviewBannerDialog } from "./preview-banner-dialog";
type PostingTarget = {
@@ -22,50 +27,52 @@ type PostingTarget = {
pstgTrgtNo: number;
};
const DEFAULT_PARAMS = {
bnrTtl: "",
sort: "regDt,desc",
page: 0,
size: 10,
};
function BannerPage() {
const router = useRouter();
const imageRefs = useRef<Map<number, string>>(new Map());
const { params, setParams, resetParams } =
useSearchParamsState(DEFAULT_PARAMS);
const [rowSelection, setRowSelection] = useState<RowSelectionState>({});
const [postingTargets, setPostingTargets] = useState<PostingTarget[]>([]);
const [bnrTtl, setBnrTtl] = useState("");
const [searchParams, setSearchParams] = useState<BannerParams>({
bnrTtl: "",
sort: "regDt,desc",
});
const [pagination, setPagination] = useState<PaginationState>({
pageIndex: 0,
pageSize: 10,
});
const [bnrTtl, setBnrTtl] = useState(params.bnrTtl);
const query = useQuery({
queryKey: [ENDPOINT.CMS_SERVICE.BANNERS, searchParams, pagination],
queryKey: [ENDPOINT.CMS_SERVICE.BANNERS, params],
queryFn: () =>
BannerApi.getBannerList({
...searchParams,
page: pagination.pageIndex,
size: pagination.pageSize,
bnrTtl: params.bnrTtl,
sort: params.sort,
page: params.page,
size: params.size,
}),
placeholderData: keepPreviousData,
});
const handleSubmit = (e: React.SubmitEvent<HTMLFormElement>) => {
const pagination: PaginationState = {
pageIndex: params.page,
pageSize: params.size,
};
const handlePaginationChange: OnChangeFn<PaginationState> = (updater) => {
const next = typeof updater === "function" ? updater(pagination) : updater;
setParams({ page: next.pageIndex, size: next.pageSize });
};
const handleSubmit = (e: React.FormEvent<HTMLFormElement>) => {
e.preventDefault();
setPagination({
...pagination,
pageIndex: 0,
});
setSearchParams({
...searchParams,
bnrTtl,
});
setParams({ bnrTtl, page: 0 });
};
const handleReset = () => {
setBnrTtl("");
setSearchParams({
...searchParams,
bnrTtl: "",
});
resetParams();
};
const handleRowClick = (row: Banner) => {
@@ -92,7 +99,7 @@ function BannerPage() {
manualPagination: true,
enableMultiRowSelection: true,
getCoreRowModel: getCoreRowModel(),
onPaginationChange: setPagination,
onPaginationChange: handlePaginationChange,
onRowSelectionChange: setRowSelection,
},
dataTableOptions: {
+42 -38
View File
@@ -2,12 +2,19 @@
import { useState } from "react";
import { useRouter } from "next/navigation";
import { useQuery } from "@tanstack/react-query";
import { keepPreviousData, useQuery } from "@tanstack/react-query";
import {
getCoreRowModel,
type OnChangeFn,
type PaginationState,
type RowSelectionState,
} from "@tanstack/react-table";
import { ENDPOINT } from "@/shared/config";
import { useDataTable, useSearchParamsState } from "@/shared/lib";
import { type Board, BoardApi } from "@/entities/board";
import { CodeSelect } from "@/entities/code";
import {
Button,
Input,
@@ -18,60 +25,57 @@ import {
SelectTrigger,
SelectValue,
} from "@common/components";
import { CodeSelect } from "@/entities/code";
import { BoardApi, type BoardParams } from "@/entities/board";
import { ENDPOINT } from "@/shared/config";
import { useDataTable } from "@/shared/lib";
import { type Board } from "@/entities/board";
import { columns } from "../model/table-columns";
const DEFAULT_PARAMS = {
bbsTypeCd: "",
bbsNm: "",
sort: "regDt,desc",
page: 0,
size: 10,
};
function BbsPage() {
const router = useRouter();
const { params, setParams, resetParams } =
useSearchParamsState(DEFAULT_PARAMS);
const [rowSelection, setRowSelection] = useState<RowSelectionState>({});
const [bbsTypeCd, setBbsTypeCd] = useState("");
const [bbsNm, setBbsNm] = useState("");
const [searchParams, setSearchParams] = useState<BoardParams>({
bbsTypeCd: "",
bbsNm: "",
sort: "regDt,desc",
});
const [pagination, setPagination] = useState<PaginationState>({
pageIndex: 0,
pageSize: 10,
});
const [bbsTypeCd, setBbsTypeCd] = useState(params.bbsTypeCd);
const [bbsNm, setBbsNm] = useState(params.bbsNm);
const query = useQuery({
queryKey: [ENDPOINT.BOARD_SERVICE.BBS, searchParams, pagination],
queryKey: [ENDPOINT.BOARD_SERVICE.BBS, params],
queryFn: () =>
BoardApi.getBoardList({
...searchParams,
page: pagination.pageIndex,
size: pagination.pageSize,
bbsTypeCd: params.bbsTypeCd,
bbsNm: params.bbsNm,
sort: params.sort,
page: params.page,
size: params.size,
}),
placeholderData: keepPreviousData,
});
const handleSubmit = (e: React.SubmitEvent<HTMLFormElement>) => {
const pagination: PaginationState = {
pageIndex: params.page,
pageSize: params.size,
};
const handlePaginationChange: OnChangeFn<PaginationState> = (updater) => {
const next = typeof updater === "function" ? updater(pagination) : updater;
setParams({ page: next.pageIndex, size: next.pageSize });
};
const handleSubmit = (e: React.FormEvent<HTMLFormElement>) => {
e.preventDefault();
setPagination({
...pagination,
pageIndex: 0,
});
setSearchParams({
...searchParams,
bbsTypeCd,
bbsNm,
});
setParams({ bbsTypeCd, bbsNm, page: 0 });
};
const handleReset = () => {
setBbsTypeCd("");
setBbsNm("");
setSearchParams({
...searchParams,
bbsTypeCd: "",
bbsNm: "",
});
resetParams();
};
const handleRowClick = (row: Board) => {
@@ -89,7 +93,7 @@ function BbsPage() {
manualPagination: true,
enableMultiRowSelection: true,
getCoreRowModel: getCoreRowModel(),
onPaginationChange: setPagination,
onPaginationChange: handlePaginationChange,
onRowSelectionChange: setRowSelection,
},
dataTableOptions: {
@@ -1,8 +1,11 @@
import dayjs from "dayjs";
import { useState } from "react";
import type { ColumnDef } from "@tanstack/react-table";
import { Button } from "@common/components";
import dayjs from "dayjs";
import type { UpCode } from "@/entities/code";
import { Button } from "@common/components";
import { UpCodeManagementDialog } from "../ui/upcode-dialog";
export const columns: ColumnDef<UpCode>[] = [
@@ -24,7 +27,7 @@ export const columns: ColumnDef<UpCode>[] = [
header: "상위코드명",
},
{
accessorKey: "cdSe",
accessorKey: "cdSeNm",
header: "코드구분",
},
{
+39 -35
View File
@@ -2,60 +2,64 @@
import { useState } from "react";
import { useRouter } from "next/navigation";
import { keepPreviousData, useQuery } from "@tanstack/react-query";
import { getCoreRowModel, type PaginationState } from "@tanstack/react-table";
import { Button, Input, Label } from "@common/components";
import {
ContentsApi,
type Contents,
type ContentsParams,
} from "@/entities/content";
getCoreRowModel,
type OnChangeFn,
type PaginationState,
} from "@tanstack/react-table";
import { ENDPOINT } from "@/shared/config";
import { useDataTable } from "@/shared/lib";
import { useDataTable, useSearchParamsState } from "@/shared/lib";
import { type Contents, ContentsApi } from "@/entities/content";
import { Button, Input, Label } from "@common/components";
import { columns } from "../model/table-columns";
const DEFAULT_PARAMS = {
contsTtl: "",
sort: "regDt,desc",
page: 0,
size: 10,
};
function ContentsPage() {
const router = useRouter();
const [contsTtl, setContsTtl] = useState("");
const [searchParams, setSearchParams] = useState<ContentsParams>({
contsTtl: "",
sort: "regDt,desc",
});
const [pagination, setPagination] = useState<PaginationState>({
pageIndex: 0,
pageSize: 10,
});
const { params, setParams, resetParams } =
useSearchParamsState(DEFAULT_PARAMS);
const [contsTtl, setContsTtl] = useState(params.contsTtl);
const query = useQuery({
queryKey: [ENDPOINT.CMS_SERVICE.CONTENTS, searchParams, pagination],
queryKey: [ENDPOINT.CMS_SERVICE.CONTENTS, params],
queryFn: () =>
ContentsApi.getContentsList({
...searchParams,
page: pagination.pageIndex,
size: pagination.pageSize,
contsTtl: params.contsTtl,
sort: params.sort,
page: params.page,
size: params.size,
}),
placeholderData: keepPreviousData,
});
const handleSubmit = (e: React.SubmitEvent<HTMLFormElement>) => {
const pagination: PaginationState = {
pageIndex: params.page,
pageSize: params.size,
};
const handlePaginationChange: OnChangeFn<PaginationState> = (updater) => {
const next = typeof updater === "function" ? updater(pagination) : updater;
setParams({ page: next.pageIndex, size: next.pageSize });
};
const handleSubmit = (e: React.FormEvent<HTMLFormElement>) => {
e.preventDefault();
setPagination({
...pagination,
pageIndex: 0,
});
setSearchParams({
...searchParams,
contsTtl,
});
setParams({ contsTtl, page: 0 });
};
const handleReset = () => {
setContsTtl("");
setSearchParams({
...searchParams,
contsTtl: "",
});
resetParams();
};
const handleRowClick = (row: Contents) => {
@@ -69,7 +73,7 @@ function ContentsPage() {
state: {
pagination,
},
onPaginationChange: setPagination,
onPaginationChange: handlePaginationChange,
getCoreRowModel: getCoreRowModel(),
manualPagination: true,
},
@@ -354,7 +354,7 @@ function DashboardPage() {
<div className="card-header text-foreground">
<h3 className="text-2xl font-medium">실시간 알림</h3>
</div>
<div className="p-4">
<div className="p-4" style={{ height: "120px", overflowY: "auto" }}>
<div className="space-y-4 text-foreground">
{noticeUserList?.map((item) => (
<div
@@ -373,7 +373,7 @@ function DashboardPage() {
<div className="card-header text-foreground">
<h3 className="text-2xl font-medium">일정 알림</h3>
</div>
<div className="p-4">
<div className="p-4" style={{ height: "120px", overflowY: "auto" }}>
<div className="space-y-4 text-foreground">
{scheduleList && scheduleList.length > 0 ? (
scheduleList.map((schedule, index) => {
+7 -4
View File
@@ -1,13 +1,16 @@
"use client";
import { useState } from "react";
import { useConfirm } from "@frontend-opensource/use-react-hooks";
import { keepPreviousData, useQuery } from "@tanstack/react-query";
import { getCoreRowModel, type PaginationState } from "@tanstack/react-table";
import { useConfirm } from "@frontend-opensource/use-react-hooks";
import { Button, Input, Label } from "@common/components";
import { AttachmentApi, type AttachmentParams } from "@/entities/attachment";
import { useAlertStore, useDataTable, useMutationWrapper } from "@/shared/lib";
import { ENDPOINT } from "@/shared/config";
import { useAlertStore, useDataTable, useMutationWrapper } from "@/shared/lib";
import { AttachmentApi, type AttachmentParams } from "@/entities/attachment";
import { Button, Input, Label } from "@common/components";
import { createColumns } from "../model/table-columns";
function FilePage() {
+37 -32
View File
@@ -2,68 +2,73 @@
import { useEffect, useState } from "react";
import { useRouter } from "next/navigation";
import { useQuery } from "@tanstack/react-query";
import {
getCoreRowModel,
type OnChangeFn,
type PaginationState,
type RowSelectionState,
} from "@tanstack/react-table";
import { Button, Input, Label } from "@common/components";
import { POSTING_TYPE, PostingDialog } from "@/entities/posting";
import { ENDPOINT } from "@/shared/config";
import { useDataTable } from "@/shared/lib";
import { useDataTable, useSearchParamsState } from "@/shared/lib";
import { POSTING_TYPE, PostingDialog } from "@/entities/posting";
import { Button, Input, Label } from "@common/components";
import { ImageApi } from "../api/image-service";
import { type Image } from "../model/image-interface";
import { columns } from "../model/image-table-columns";
import { type Image, type ImageParams } from "../model/image-interface";
type PostingTarget = {
pstgTrgtNm: string;
pstgTrgtNo: number;
};
const DEFAULT_PARAMS = {
keyword: "",
sort: "regDt,desc",
page: 0,
size: 10,
};
function ImagePage() {
const router = useRouter();
const [keyword, setKeyword] = useState("");
const [searchParams, setSearchParams] = useState<ImageParams>({
keyword: "",
sort: "regDt,desc",
});
const [pagination, setPagination] = useState<PaginationState>({
pageIndex: 0,
pageSize: 10,
});
const { params, setParams, resetParams } =
useSearchParamsState(DEFAULT_PARAMS);
const [keyword, setKeyword] = useState(params.keyword);
const [rowSelection, setRowSelection] = useState<RowSelectionState>({});
const [postingTargets, setPostingTargets] = useState<PostingTarget[]>([]);
const query = useQuery({
queryKey: [ENDPOINT.CMS_SERVICE.IMAGES, searchParams, pagination],
queryKey: [ENDPOINT.CMS_SERVICE.IMAGES, params],
queryFn: () =>
ImageApi.getImageList({
...searchParams,
page: pagination.pageIndex,
size: pagination.pageSize,
keyword: params.keyword,
sort: params.sort,
page: params.page,
size: params.size,
}),
});
const handleSubmit = (e: React.SubmitEvent<HTMLFormElement>) => {
const pagination: PaginationState = {
pageIndex: params.page,
pageSize: params.size,
};
const handlePaginationChange: OnChangeFn<PaginationState> = (updater) => {
const next = typeof updater === "function" ? updater(pagination) : updater;
setParams({ page: next.pageIndex, size: next.pageSize });
};
const handleSubmit = (e: React.FormEvent<HTMLFormElement>) => {
e.preventDefault();
setPagination({
...pagination,
pageIndex: 0,
});
setSearchParams({
...searchParams,
keyword,
});
setParams({ keyword, page: 0 });
};
const handleReset = () => {
setKeyword("");
setSearchParams({
...searchParams,
keyword: "",
});
resetParams();
};
const handleRowClick = (row: Image) => {
@@ -81,7 +86,7 @@ function ImagePage() {
manualPagination: true,
enableMultiRowSelection: true,
getCoreRowModel: getCoreRowModel(),
onPaginationChange: setPagination,
onPaginationChange: handlePaginationChange,
onRowSelectionChange: setRowSelection,
},
dataTableOptions: {
+55 -47
View File
@@ -2,45 +2,76 @@
import { useState } from "react";
import { useRouter } from "next/navigation";
import { keepPreviousData, useQuery } from "@tanstack/react-query";
import { getCoreRowModel, type PaginationState } from "@tanstack/react-table";
import { Button, Input, Label } from "@common/components";
import { CodeSelect } from "@/entities/code";
import { ENDPOINT } from "@/shared/config";
import { useDataTable } from "@/shared/lib";
import {
AuthorizationsApi,
getCoreRowModel,
type OnChangeFn,
type PaginationState,
} from "@tanstack/react-table";
import { ENDPOINT } from "@/shared/config";
import { useDataTable, useSearchParamsState } from "@/shared/lib";
import {
type Authorizations,
type AuthorizationsParams,
AuthorizationsApi,
} from "@/entities/authorization";
import { CodeSelect } from "@/entities/code";
import { Button, Input, Label } from "@common/components";
import { columns } from "../model/authorizations-table-columns";
const DEFAULT_PARAMS = {
authorizationName: "",
httpMethodCode: "",
sort: "regDt,desc",
page: 0,
size: 10,
};
function ModulePage() {
const router = useRouter();
const [authorizationName, setAuthorizationName] = useState("");
const [httpMethodCode, setHttpMethodCode] = useState("");
const [searchParams, setSearchParams] = useState<AuthorizationsParams>({
authorizationName: "",
httpMethodCode: "",
sort: "regDt,desc",
});
const [pagination, setPagination] = useState<PaginationState>({
pageIndex: 0,
pageSize: 10,
});
const { params, setParams, resetParams } =
useSearchParamsState(DEFAULT_PARAMS);
const [authorizationName, setAuthorizationName] = useState(
params.authorizationName,
);
const [httpMethodCode, setHttpMethodCode] = useState(params.httpMethodCode);
const query = useQuery({
queryKey: [ENDPOINT.USER_SERVICE.AUTHORIZATIONS, searchParams, pagination],
queryKey: [ENDPOINT.USER_SERVICE.AUTHORIZATIONS, params],
queryFn: () =>
AuthorizationsApi.getAuthorizationsList({
...searchParams,
page: pagination.pageIndex,
size: pagination.pageSize,
authorizationName: params.authorizationName,
httpMethodCode: params.httpMethodCode,
sort: params.sort,
page: params.page,
size: params.size,
}),
placeholderData: keepPreviousData,
});
const pagination: PaginationState = {
pageIndex: params.page,
pageSize: params.size,
};
const handlePaginationChange: OnChangeFn<PaginationState> = (updater) => {
const next = typeof updater === "function" ? updater(pagination) : updater;
setParams({ page: next.pageIndex, size: next.pageSize });
};
const handleSubmit = (e: React.FormEvent<HTMLFormElement>) => {
e.preventDefault();
setParams({ authorizationName, httpMethodCode, page: 0 });
};
const handleReset = () => {
setAuthorizationName("");
setHttpMethodCode("");
resetParams();
};
const handleRowClick = (row: Authorizations) => {
router.push(`/module/register/${row.authorizationNo}`);
};
@@ -52,7 +83,7 @@ function ModulePage() {
state: {
pagination,
},
onPaginationChange: setPagination,
onPaginationChange: handlePaginationChange,
getCoreRowModel: getCoreRowModel(),
manualPagination: true,
},
@@ -62,29 +93,6 @@ function ModulePage() {
},
});
const handleSubmit = (e: React.SubmitEvent<HTMLFormElement>) => {
e.preventDefault();
setPagination({
...pagination,
pageIndex: 0,
});
setSearchParams({
...searchParams,
authorizationName,
httpMethodCode,
});
};
const handleReset = () => {
setAuthorizationName("");
setHttpMethodCode("");
setSearchParams({
...searchParams,
authorizationName: "",
httpMethodCode: "",
});
};
return (
<div className="card card-border !mt-6">
<div className="card card-border">
+37 -32
View File
@@ -2,70 +2,75 @@
import { useEffect, useState } from "react";
import { useRouter } from "next/navigation";
import { keepPreviousData, useQuery } from "@tanstack/react-query";
import {
getCoreRowModel,
type OnChangeFn,
type PaginationState,
type RowSelectionState,
} from "@tanstack/react-table";
import { Button, Input, Label } from "@common/components";
import { ENDPOINT } from "@/shared/config";
import { useDataTable, useSearchParamsState } from "@/shared/lib";
import { AuthorityButton } from "@/entities/menu-authority";
import { POSTING_TYPE, PostingDialog } from "@/entities/posting";
import { ENDPOINT } from "@/shared/config";
import { useDataTable } from "@/shared/lib";
import { Button, Input, Label } from "@common/components";
import { PopupApi } from "../api/popup-service";
import type { Popup } from "../model/popup-interface";
import { columns } from "../model/table-columns";
import type { Popup, PopupParams } from "../model/popup-interface";
type PostingTarget = {
pstgTrgtNm: string;
pstgTrgtNo: number;
};
const DEFAULT_PARAMS = {
keyword: "",
sort: "regDt,desc",
page: 0,
size: 10,
};
function PopupPage() {
const router = useRouter();
const [keyword, setKeyword] = useState("");
const [searchParams, setSearchParams] = useState<PopupParams>({
keyword: "",
sort: "regDt,desc",
});
const [pagination, setPagination] = useState<PaginationState>({
pageIndex: 0,
pageSize: 10,
});
const { params, setParams, resetParams } =
useSearchParamsState(DEFAULT_PARAMS);
const [keyword, setKeyword] = useState(params.keyword);
const [rowSelection, setRowSelection] = useState<RowSelectionState>({});
const [postingTargets, setPostingTargets] = useState<PostingTarget[]>([]);
const query = useQuery({
queryKey: [ENDPOINT.CMS_SERVICE.POPUPS, searchParams, pagination],
queryKey: [ENDPOINT.CMS_SERVICE.POPUPS, params],
queryFn: () =>
PopupApi.getPopupList({
...searchParams,
page: pagination.pageIndex,
size: pagination.pageSize,
keyword: params.keyword,
sort: params.sort,
page: params.page,
size: params.size,
}),
placeholderData: keepPreviousData,
});
const handleSubmit = (e: React.SubmitEvent<HTMLFormElement>) => {
const pagination: PaginationState = {
pageIndex: params.page,
pageSize: params.size,
};
const handlePaginationChange: OnChangeFn<PaginationState> = (updater) => {
const next = typeof updater === "function" ? updater(pagination) : updater;
setParams({ page: next.pageIndex, size: next.pageSize });
};
const handleSubmit = (e: React.FormEvent<HTMLFormElement>) => {
e.preventDefault();
setPagination({
...pagination,
pageIndex: 0,
});
setSearchParams({
...searchParams,
keyword,
});
setParams({ keyword, page: 0 });
};
const handleReset = () => {
setKeyword("");
setSearchParams({
...searchParams,
keyword: "",
});
resetParams();
};
const handleRowClick = (row: Popup) => {
@@ -82,7 +87,7 @@ function PopupPage() {
},
manualPagination: true,
getCoreRowModel: getCoreRowModel(),
onPaginationChange: setPagination,
onPaginationChange: handlePaginationChange,
onRowSelectionChange: setRowSelection,
},
dataTableOptions: {
+98 -82
View File
@@ -1,15 +1,35 @@
"use client";
import dayjs from "dayjs";
import { useState } from "react";
import { useRouter } from "next/navigation";
import { usePathname, useRouter, useSearchParams } from "next/navigation";
import { useConfirm } from "@frontend-opensource/use-react-hooks";
import { keepPreviousData, useQuery } from "@tanstack/react-query";
import {
getCoreRowModel,
type OnChangeFn,
type PaginationState,
type RowSelectionState,
} from "@tanstack/react-table";
import dayjs from "dayjs";
import { ENDPOINT } from "@/shared/config";
import {
useAlertStore,
useDataTable,
useSearchParamsState,
useSelectPrompt,
} from "@/shared/lib";
import { InputDateRangePicker } from "@/shared/ui/input-date-range-picker";
import { BoardApi } from "@/entities/board";
import { AuthorityButton } from "@/entities/menu-authority";
import {
type DeletePost,
deleteReasonMessages,
PostApi,
type RecentPost,
} from "@/entities/post";
import { SortSelect } from "@/widgets/post";
import {
Button,
Input,
@@ -20,76 +40,72 @@ import {
SelectTrigger,
SelectValue,
} from "@common/components";
import { SortSelect } from "@/widgets/post";
import {
PostApi,
deleteReasonMessages,
type DeletePost,
type RecentPost,
type RecentPostParams,
} from "@/entities/post";
import { BoardApi } from "@/entities/board";
import { AuthorityButton } from "@/entities/menu-authority";
import { ENDPOINT } from "@/shared/config";
import { InputDateRangePicker } from "@/shared/ui/input-date-range-picker";
import { useAlertStore, useSelectPrompt, useDataTable } from "@/shared/lib";
import { columns } from "../model/table-columns";
import { MovePostDialog } from "./move-post-dialog";
type DateRange = {
from?: Date | undefined;
to?: Date | undefined;
from?: Date | null | undefined;
to?: Date | null | undefined;
};
const selectData = Object.entries(deleteReasonMessages).map(
([label, value]) => ({ label, value }),
);
const DEFAULT_PARAMS = {
keywordType: "",
keyword: "",
bbsId: "",
delYn: "N",
frDateYmd: "",
toDateYmd: "",
sort: "regDt,desc",
page: 0,
size: 10,
};
function parseDateRange(frDateYmd: string, toDateYmd: string): DateRange {
return {
from: frDateYmd ? dayjs(frDateYmd, "YYYYMMDD").toDate() : undefined,
to: toDateYmd ? dayjs(toDateYmd, "YYYYMMDD").toDate() : undefined,
};
}
function RecentPage() {
const router = useRouter();
const pathname = usePathname();
const searchParams = useSearchParams();
const { prompt } = useSelectPrompt(selectData);
const { confirm } = useConfirm();
const alert = useAlertStore((state) => state.setMessage);
const { params, setParams, resetParams } =
useSearchParamsState(DEFAULT_PARAMS);
const [tableSort, setTableSort] = useState("regDt,desc");
const [pagination, setPagination] = useState<PaginationState>({
pageIndex: 0,
pageSize: 10,
});
const [rowSelection, setRowSelection] = useState<RowSelectionState>({});
const [presetDateRange, setPresetDateRange] = useState("");
const [dateRange, setDateRange] = useState<DateRange>({
from: undefined,
to: undefined,
});
const [keywordType, setKeywordType] = useState("");
const [keyword, setKeyword] = useState("");
const [bbsId, setBbsId] = useState<string | null>(null);
const [delYn, setDelYn] = useState("N");
const [searchParams, setSearchParams] = useState<RecentPostParams>({
keywordType: "",
keyword: "",
bbsId: null,
delYn: "N",
frDateYmd: "",
toDateYmd: "",
sort: "regDt,desc",
});
const [isDeletePostData, setIsDeletePostData] = useState(false);
const [dateRange, setDateRange] = useState<DateRange>(() =>
parseDateRange(params.frDateYmd, params.toDateYmd),
);
const [keywordType, setKeywordType] = useState(params.keywordType);
const [keyword, setKeyword] = useState(params.keyword);
const [bbsId, setBbsId] = useState(params.bbsId);
const [delYn, setDelYn] = useState(params.delYn);
const query = useQuery({
queryKey: [
ENDPOINT.BOARD_SERVICE.POSTS,
searchParams,
pagination,
tableSort,
],
queryKey: [ENDPOINT.BOARD_SERVICE.POSTS, params],
queryFn: () =>
PostApi.getRecentPostList({
...searchParams,
page: pagination.pageIndex,
size: pagination.pageSize,
sort: tableSort,
keywordType: params.keywordType,
keyword: params.keyword,
bbsId: params.bbsId ? parseInt(params.bbsId) : null,
delYn: params.delYn,
frDateYmd: params.frDateYmd,
toDateYmd: params.toDateYmd,
sort: params.sort,
page: params.page,
size: params.size,
}),
placeholderData: keepPreviousData,
});
@@ -99,9 +115,22 @@ function RecentPage() {
queryFn: () => BoardApi.getBoardList(),
});
const pagination: PaginationState = {
pageIndex: params.page,
pageSize: params.size,
};
const handlePaginationChange: OnChangeFn<PaginationState> = (updater) => {
const next = typeof updater === "function" ? updater(pagination) : updater;
setParams({ page: next.pageIndex, size: next.pageSize });
};
const isDeletePostData = params.delYn === "Y";
const handleRowClick = (row: RecentPost) => {
const from = `${pathname}${searchParams?.toString() ? `?${searchParams.toString()}` : ""}`;
router.push(
`/bbs/${row.bbsTypeCd.toLowerCase()}/${row.bbsId}/${row.pstNo}`,
`/bbs/${row.bbsTypeCd.toLowerCase()}/${row.bbsId}/${row.pstNo}?from=${encodeURIComponent(from)}`,
);
};
@@ -116,7 +145,7 @@ function RecentPage() {
manualPagination: true,
enableMultiRowSelection: true,
getCoreRowModel: getCoreRowModel(),
onPaginationChange: setPagination,
onPaginationChange: handlePaginationChange,
onRowSelectionChange: setRowSelection,
},
dataTableOptions: {
@@ -127,45 +156,32 @@ function RecentPage() {
const isSelectedRow = table.getFilteredSelectedRowModel().rows.length > 0;
const handleSubmit = (e: React.SubmitEvent<HTMLFormElement>) => {
const handleSubmit = (e: React.FormEvent<HTMLFormElement>) => {
e.preventDefault();
setIsDeletePostData(delYn === "Y");
setPagination({
...pagination,
pageIndex: 0,
});
setSearchParams({
...searchParams,
setParams({
keywordType,
keyword,
bbsId: bbsId ? parseInt(bbsId) : null,
bbsId,
delYn,
frDateYmd: dateRange?.from
? dayjs(dateRange.from).format("YYYYMMDD")
: "",
toDateYmd: dateRange?.to ? dayjs(dateRange.to).format("YYYYMMDD") : "",
page: 0,
});
};
const handleReset = () => {
setKeywordType("");
setKeyword("");
setBbsId(null);
setBbsId("");
setDelYn("N");
setPresetDateRange("");
setDateRange({
from: undefined,
to: undefined,
});
setSearchParams({
...searchParams,
keywordType: "",
keyword: "",
bbsId: null,
delYn: "N",
frDateYmd: "",
toDateYmd: "",
});
setIsDeletePostData(false);
resetParams();
};
const handlePresetDateRange = (value: string) => {
@@ -273,10 +289,7 @@ function RecentPage() {
setDateRange={handleDateRangeChange}
/>
<Label className="ml-4 mr-2 text-label">게시판</Label>
<Select
value={bbsId?.toString() || ""}
onValueChange={(value) => setBbsId(value ? value : null)}
>
<Select value={bbsId} onValueChange={setBbsId}>
<SelectTrigger className="w-[12rem]">
<SelectValue placeholder="선택" />
</SelectTrigger>
@@ -289,7 +302,7 @@ function RecentPage() {
</SelectContent>
</Select>
<Label className="ml-4 mr-2 text-label">상태</Label>
<Select value={delYn} onValueChange={(value) => setDelYn(value)}>
<Select value={delYn} onValueChange={setDelYn}>
<SelectTrigger className="w-[10rem]">
<SelectValue placeholder="선택" />
</SelectTrigger>
@@ -376,10 +389,10 @@ function RecentPage() {
</>
)}
<Select
value={table.getState().pagination.pageSize.toString()}
onValueChange={(value) => {
table.setPageSize(parseInt(value));
}}
value={params.size.toString()}
onValueChange={(value) =>
setParams({ size: parseInt(value), page: 0 })
}
>
<SelectTrigger className="h-[3.2rem] w-[12rem]">
<SelectValue placeholder="페이지 크기" />
@@ -392,7 +405,10 @@ function RecentPage() {
))}
</SelectContent>
</Select>
<SortSelect value={tableSort} onValueChange={setTableSort} />
<SortSelect
value={params.sort}
onValueChange={(value) => setParams({ sort: value })}
/>
</div>
</div>
</div>
+51 -42
View File
@@ -2,39 +2,68 @@
import { useState } from "react";
import { useRouter } from "next/navigation";
import { useQuery } from "@tanstack/react-query";
import { getCoreRowModel, type PaginationState } from "@tanstack/react-table";
import { Button, Input, Label } from "@common/components";
import { RoleApi, type Role, type RoleParams } from "@/entities/role";
import { keepPreviousData, useQuery } from "@tanstack/react-query";
import {
getCoreRowModel,
type OnChangeFn,
type PaginationState,
} from "@tanstack/react-table";
import { ENDPOINT } from "@/shared/config";
import { useAlertStore, useDataTable } from "@/shared/lib";
import { useDataTable, useSearchParamsState } from "@/shared/lib";
import { type Role, RoleApi } from "@/entities/role";
import { Button, Input, Label } from "@common/components";
import { createColumns } from "../model/table-columns";
const DEFAULT_PARAMS = {
keywordType: "roleName",
keyword: "",
sort: "regDt,desc",
page: 0,
size: 10,
};
function RolePage() {
const router = useRouter();
const alert = useAlertStore((state) => state.setMessage);
const [keyword, setKeyword] = useState("");
const [searchParams, setSearchParams] = useState<RoleParams>({
keywordType: "roleName",
keyword: "",
sort: "regDt,desc",
});
const [pagination, setPagination] = useState<PaginationState>({
pageIndex: 0,
pageSize: 10,
});
const { params, setParams, resetParams } =
useSearchParamsState(DEFAULT_PARAMS);
const [keyword, setKeyword] = useState(params.keyword);
const query = useQuery({
queryKey: [ENDPOINT.USER_SERVICE.ROLES, searchParams, pagination],
queryKey: [ENDPOINT.USER_SERVICE.ROLES, params],
queryFn: () =>
RoleApi.getRoles({
...searchParams,
page: pagination.pageIndex,
size: pagination.pageSize,
keywordType: params.keywordType,
keyword: params.keyword,
sort: params.sort,
page: params.page,
size: params.size,
}),
placeholderData: keepPreviousData,
});
const pagination: PaginationState = {
pageIndex: params.page,
pageSize: params.size,
};
const handlePaginationChange: OnChangeFn<PaginationState> = (updater) => {
const next = typeof updater === "function" ? updater(pagination) : updater;
setParams({ page: next.pageIndex, size: next.pageSize });
};
const handleSubmit = (e: React.FormEvent<HTMLFormElement>) => {
e.preventDefault();
setParams({ keyword, page: 0 });
};
const handleReset = () => {
setKeyword("");
resetParams();
};
const handleRowClick = (row: Role) => {
router.push(`/role/register/${row.roleId}`);
};
@@ -48,7 +77,7 @@ function RolePage() {
},
manualPagination: true,
getCoreRowModel: getCoreRowModel(),
onPaginationChange: setPagination,
onPaginationChange: handlePaginationChange,
},
dataTableOptions: {
onRowClick: handleRowClick,
@@ -56,26 +85,6 @@ function RolePage() {
},
});
const handleSubmit = (e: React.SubmitEvent<HTMLFormElement>) => {
e.preventDefault();
setPagination({
...pagination,
pageIndex: 0,
});
setSearchParams({
...searchParams,
keyword,
});
};
const handleReset = () => {
setKeyword("");
setSearchParams({
...searchParams,
keyword: "",
});
};
return (
<div className="card card-border !mt-6">
<div className="card card-border">
+40 -29
View File
@@ -1,54 +1,65 @@
"use client";
import "@common/assets/styles/grid.css";
import { useState } from "react";
import { useRouter } from "next/navigation";
import { keepPreviousData, useQuery } from "@tanstack/react-query";
import { getCoreRowModel, type PaginationState } from "@tanstack/react-table";
import { Button, Input, Label } from "@common/components";
import { SiteApi, type Site, type SiteParams } from "@/entities/site";
import {
getCoreRowModel,
type OnChangeFn,
type PaginationState,
} from "@tanstack/react-table";
import { ENDPOINT } from "@/shared/config";
import { useDataTable } from "@/shared/lib";
import { useDataTable, useSearchParamsState } from "@/shared/lib";
import { type Site, SiteApi } from "@/entities/site";
import { Button, Input, Label } from "@common/components";
import { columns } from "../model/table-columns";
const DEFAULT_PARAMS = {
siteNm: "",
sort: "regDt,desc",
page: 0,
size: 10,
};
function SitePage() {
const router = useRouter();
const [siteNm, setSiteNm] = useState("");
const [searchParams, setSearchParams] = useState<SiteParams>({
siteNm: "",
sort: "regDt,desc",
});
const [pagination, setPagination] = useState<PaginationState>({
pageIndex: 0,
pageSize: 10,
});
const { params, setParams, resetParams } =
useSearchParamsState(DEFAULT_PARAMS);
const [siteNm, setSiteNm] = useState(params.siteNm);
const query = useQuery({
queryKey: [ENDPOINT.CMS_SERVICE.SITES, searchParams, pagination],
queryKey: [ENDPOINT.CMS_SERVICE.SITES, params],
queryFn: () =>
SiteApi.getSiteList({
...searchParams,
page: pagination.pageIndex,
size: pagination.pageSize,
siteNm: params.siteNm,
sort: params.sort,
page: params.page,
size: params.size,
}),
placeholderData: keepPreviousData,
});
const handleSubmit = (e: React.SubmitEvent<HTMLFormElement>) => {
const pagination: PaginationState = {
pageIndex: params.page,
pageSize: params.size,
};
const handlePaginationChange: OnChangeFn<PaginationState> = (updater) => {
const next = typeof updater === "function" ? updater(pagination) : updater;
setParams({ page: next.pageIndex, size: next.pageSize });
};
const handleSubmit = (e: React.FormEvent<HTMLFormElement>) => {
e.preventDefault();
setSearchParams({
...searchParams,
siteNm,
});
setParams({ siteNm, page: 0 });
};
const handleReset = () => {
setSiteNm("");
setSearchParams({
...searchParams,
siteNm: "",
});
resetParams();
};
const handleRowClick = (row: Site) => {
@@ -62,7 +73,7 @@ function SitePage() {
state: {
pagination,
},
onPaginationChange: setPagination,
onPaginationChange: handlePaginationChange,
getCoreRowModel: getCoreRowModel(),
manualPagination: true,
},
+45 -40
View File
@@ -2,19 +2,23 @@
import { useEffect, useState } from "react";
import { useRouter } from "next/navigation";
import { keepPreviousData, useQuery } from "@tanstack/react-query";
import {
getCoreRowModel,
type OnChangeFn,
type PaginationState,
type RowSelectionState,
} from "@tanstack/react-table";
import { Button, Input, Label } from "@common/components";
import { POSTING_TYPE, PostingDialog } from "@/entities/posting";
import { AuthorityButton } from "@/entities/menu-authority";
import { ENDPOINT } from "@/shared/config";
import { useDataTable } from "@/shared/lib";
import { useDataTable, useSearchParamsState } from "@/shared/lib";
import { AuthorityButton } from "@/entities/menu-authority";
import { POSTING_TYPE, PostingDialog } from "@/entities/posting";
import { Button, Input, Label } from "@common/components";
import { SurveyApi } from "../api/survey-service";
import type { Survey, SurveyParams } from "../model/survey-interface";
import type { Survey } from "../model/survey-interface";
import { columns } from "../model/survey-table-columns";
type PostingTarget = {
@@ -22,32 +26,53 @@ type PostingTarget = {
pstgTrgtNo: number;
};
const DEFAULT_PARAMS = {
srvyTtl: "",
sort: "regDt,desc",
page: 0,
size: 10,
};
function SurveyPage() {
const router = useRouter();
const [srvyTtl, setSrvyTtl] = useState("");
const [searchParams, setSearchParams] = useState<SurveyParams>({
srvyTtl: "",
sort: "regDt,desc",
});
const [pagination, setPagination] = useState<PaginationState>({
pageIndex: 0,
pageSize: 10,
});
const { params, setParams, resetParams } =
useSearchParamsState(DEFAULT_PARAMS);
const [srvyTtl, setSrvyTtl] = useState(params.srvyTtl);
const [rowSelection, setRowSelection] = useState<RowSelectionState>({});
const [postingTargets, setPostingTargets] = useState<PostingTarget[]>([]);
const query = useQuery({
queryKey: [ENDPOINT.CMS_SERVICE.SURVEY, searchParams, pagination],
queryKey: [ENDPOINT.CMS_SERVICE.SURVEY, params],
queryFn: () =>
SurveyApi.getSurveyList({
...searchParams,
page: pagination.pageIndex,
size: pagination.pageSize,
srvyTtl: params.srvyTtl,
sort: params.sort,
page: params.page,
size: params.size,
}),
placeholderData: keepPreviousData,
});
const pagination: PaginationState = {
pageIndex: params.page,
pageSize: params.size,
};
const handlePaginationChange: OnChangeFn<PaginationState> = (updater) => {
const next = typeof updater === "function" ? updater(pagination) : updater;
setParams({ page: next.pageIndex, size: next.pageSize });
};
const handleSubmit = (e: React.FormEvent<HTMLFormElement>) => {
e.preventDefault();
setParams({ srvyTtl, page: 0 });
};
const handleReset = () => {
setSrvyTtl("");
resetParams();
};
const handleRowClick = (row: Survey) => {
router.push(`/survey/${row.srvySn}`);
};
@@ -62,7 +87,7 @@ function SurveyPage() {
},
manualPagination: true,
getCoreRowModel: getCoreRowModel(),
onPaginationChange: setPagination,
onPaginationChange: handlePaginationChange,
onRowSelectionChange: setRowSelection,
},
dataTableOptions: {
@@ -71,26 +96,6 @@ function SurveyPage() {
},
});
const handleSubmit = (e: React.SubmitEvent<HTMLFormElement>) => {
e.preventDefault();
setPagination({
...pagination,
pageIndex: 0,
});
setSearchParams({
...searchParams,
srvyTtl,
});
};
const handleReset = () => {
setSrvyTtl("");
setSearchParams({
...searchParams,
srvyTtl: "",
});
};
useEffect(() => {
const postingTargets = table
.getFilteredSelectedRowModel()
+44 -32
View File
@@ -2,58 +2,70 @@
import { useState } from "react";
import { useRouter } from "next/navigation";
import { keepPreviousData, useQuery } from "@tanstack/react-query";
import { getCoreRowModel, type PaginationState } from "@tanstack/react-table";
import { Button, Input, Label } from "@common/components";
import { CodeSelect } from "@/entities/code";
import {
getCoreRowModel,
type OnChangeFn,
type PaginationState,
} from "@tanstack/react-table";
import { ENDPOINT } from "@/shared/config";
import { useDataTable } from "@/shared/lib";
import { useDataTable, useSearchParamsState } from "@/shared/lib";
import { CodeSelect } from "@/entities/code";
import { Button, Input, Label } from "@common/components";
import { TemplateApi } from "../api/template-service";
import type { Template } from "../model/template-interface";
import { columns } from "../model/template-table-columns";
import type { Template, TemplateParams } from "../model/template-interface";
const DEFAULT_PARAMS = {
keyword: "",
tmpltSeCd: "",
sort: "regDt,desc",
page: 0,
size: 10,
};
function TemplatePage() {
const router = useRouter();
const [keyword, setKeyword] = useState("");
const [tmpltSeCd, setTmpltSeCd] = useState("");
const [searchParams, setSearchParams] = useState<TemplateParams>({
keyword: "",
tmpltSeCd: "",
sort: "regDt,desc",
});
const [pagination, setPagination] = useState<PaginationState>({
pageIndex: 0,
pageSize: 10,
});
const { params, setParams, resetParams } =
useSearchParamsState(DEFAULT_PARAMS);
const [keyword, setKeyword] = useState(params.keyword);
const [tmpltSeCd, setTmpltSeCd] = useState(params.tmpltSeCd);
const query = useQuery({
queryKey: [ENDPOINT.CMS_SERVICE.TEMPLATE, searchParams, pagination],
queryKey: [ENDPOINT.CMS_SERVICE.TEMPLATE, params],
queryFn: () =>
TemplateApi.getTemplateList({
...searchParams,
page: pagination.pageIndex,
size: pagination.pageSize,
keyword: params.keyword,
tmpltSeCd: params.tmpltSeCd,
sort: params.sort,
page: params.page,
size: params.size,
}),
placeholderData: keepPreviousData,
});
const handleSubmit = (e: React.SubmitEvent<HTMLFormElement>) => {
const pagination: PaginationState = {
pageIndex: params.page,
pageSize: params.size,
};
const handlePaginationChange: OnChangeFn<PaginationState> = (updater) => {
const next = typeof updater === "function" ? updater(pagination) : updater;
setParams({ page: next.pageIndex, size: next.pageSize });
};
const handleSubmit = (e: React.FormEvent<HTMLFormElement>) => {
e.preventDefault();
setSearchParams({
...searchParams,
keyword,
tmpltSeCd,
});
setParams({ keyword, tmpltSeCd, page: 0 });
};
const handleReset = () => {
setKeyword("");
setTmpltSeCd("");
setSearchParams({
...searchParams,
keyword: "",
tmpltSeCd: "",
});
resetParams();
};
const handleRowClick = (row: Template) => {
@@ -67,7 +79,7 @@ function TemplatePage() {
state: {
pagination,
},
onPaginationChange: setPagination,
onPaginationChange: handlePaginationChange,
getCoreRowModel: getCoreRowModel(),
manualPagination: true,
},
+44 -37
View File
@@ -2,63 +2,70 @@
import { useState } from "react";
import { useRouter } from "next/navigation";
import { keepPreviousData, useQuery } from "@tanstack/react-query";
import { getCoreRowModel, type PaginationState } from "@tanstack/react-table";
import { Button, Input, Label } from "@common/components";
import { CodeSelect } from "@/entities/code";
import {
getCoreRowModel,
type OnChangeFn,
type PaginationState,
} from "@tanstack/react-table";
import { ENDPOINT } from "@/shared/config";
import { useDataTable } from "@/shared/lib";
import { useDataTable, useSearchParamsState } from "@/shared/lib";
import { CodeSelect } from "@/entities/code";
import { Button, Input, Label } from "@common/components";
import { TermsApi } from "../api/terms-service";
import type { Terms } from "../model/terms-interface";
import { columns } from "../model/terms-table-columns";
import type { TermsParams, Terms } from "../model/terms-interface";
const DEFAULT_PARAMS = {
keyword: "",
trmsSeCd: "",
sort: "regDt,desc",
page: 0,
size: 10,
};
function TermsPage() {
const router = useRouter();
const [keyword, setKeyword] = useState("");
const [trmsSeCd, setTrmsSeCd] = useState("");
const [searchParams, setSearchParams] = useState<TermsParams>({
keyword: "",
trmsSeCd: "",
sort: "regDt,desc",
});
const [pagination, setPagination] = useState<PaginationState>({
pageIndex: 0,
pageSize: 10,
});
const { params, setParams, resetParams } =
useSearchParamsState(DEFAULT_PARAMS);
const [keyword, setKeyword] = useState(params.keyword);
const [trmsSeCd, setTrmsSeCd] = useState(params.trmsSeCd);
const query = useQuery({
queryKey: [ENDPOINT.CMS_SERVICE.TERMS, searchParams, pagination],
queryKey: [ENDPOINT.CMS_SERVICE.TERMS, params],
queryFn: () =>
TermsApi.getTermsList({
...searchParams,
page: pagination.pageIndex,
size: pagination.pageSize,
keyword: params.keyword,
trmsSeCd: params.trmsSeCd,
sort: params.sort,
page: params.page,
size: params.size,
}),
placeholderData: keepPreviousData,
});
const handleSubmit = (e: React.SubmitEvent<HTMLFormElement>) => {
const pagination: PaginationState = {
pageIndex: params.page,
pageSize: params.size,
};
const handlePaginationChange: OnChangeFn<PaginationState> = (updater) => {
const next = typeof updater === "function" ? updater(pagination) : updater;
setParams({ page: next.pageIndex, size: next.pageSize });
};
const handleSubmit = (e: React.FormEvent<HTMLFormElement>) => {
e.preventDefault();
setPagination({
...pagination,
pageIndex: 0,
});
setSearchParams({
...searchParams,
keyword,
trmsSeCd,
});
setParams({ keyword, trmsSeCd, page: 0 });
};
const handleReset = () => {
setKeyword("");
setTrmsSeCd("");
setSearchParams({
...searchParams,
keyword: "",
trmsSeCd: "",
});
resetParams();
};
const handleRowClick = (row: Terms) => {
@@ -72,7 +79,7 @@ function TermsPage() {
state: {
pagination,
},
onPaginationChange: setPagination,
onPaginationChange: handlePaginationChange,
getCoreRowModel: getCoreRowModel(),
manualPagination: true,
},
+68 -60
View File
@@ -3,19 +3,19 @@
import { useState } from "react";
import { useRouter } from "next/navigation";
import { useQuery } from "@tanstack/react-query";
import { getCoreRowModel, type PaginationState } from "@tanstack/react-table";
import { keepPreviousData, useQuery } from "@tanstack/react-query";
import {
getCoreRowModel,
type OnChangeFn,
type PaginationState,
} from "@tanstack/react-table";
import dayjs from "dayjs";
import { ENDPOINT } from "@/shared/config";
import { useDataTable } from "@/shared/lib";
import { useDataTable, useSearchParamsState } from "@/shared/lib";
import { InputDateRangePicker } from "@/shared/ui/input-date-range-picker";
import { CodeApi } from "@/entities/code";
import {
UserApi,
type UserListResponse,
type UserSearchParams,
} from "@/entities/user";
import { UserApi, type UserListResponse } from "@/entities/user";
import {
Button,
Checkbox,
@@ -35,34 +35,39 @@ type DateRange = {
to?: Date | null;
};
const DEFAULT_PARAMS = {
keywordType: "all",
keyword: "",
userStateCode: "",
aprvYn: "",
grpId: "",
frDateYmd: "",
toDateYmd: "",
sort: "regDt,desc",
page: 0,
size: 10,
};
function parseDateRange(frDateYmd: string, toDateYmd: string): DateRange {
return {
from: frDateYmd ? dayjs(frDateYmd, "YYYYMMDD").toDate() : undefined,
to: toDateYmd ? dayjs(toDateYmd, "YYYYMMDD").toDate() : undefined,
};
}
function UsersPage() {
const router = useRouter();
const [pagination, setPagination] = useState<PaginationState>({
pageIndex: 0,
pageSize: 10,
});
const { params, setParams, resetParams } =
useSearchParamsState(DEFAULT_PARAMS);
const [presetDateRange, setPresetDateRange] = useState("");
const [dateRange, setDateRange] = useState<DateRange>({
from: undefined,
to: undefined,
});
const [keywordType, setKeywordType] = useState("all");
const [keyword, setKeyword] = useState("");
const [userStateCode, setUserStateCode] = useState("");
const [aprvYn, setAprvYn] = useState("");
const [grpId, setGrpId] = useState("");
const [searchParams, setSearchParams] = useState<UserSearchParams>({
keywordType: "all",
keyword: "",
userStateCode: "",
aprvYn: "",
grpId: "",
frDateYmd: "",
toDateYmd: "",
sort: "regDt,desc",
});
const [dateRange, setDateRange] = useState<DateRange>(() =>
parseDateRange(params.frDateYmd, params.toDateYmd),
);
const [keywordType, setKeywordType] = useState(params.keywordType);
const [keyword, setKeyword] = useState(params.keyword);
const [userStateCode, setUserStateCode] = useState(params.userStateCode);
const [aprvYn, setAprvYn] = useState(params.aprvYn);
const [grpId, setGrpId] = useState(params.grpId);
const { data: userSttsStats } = useQuery({
queryKey: [ENDPOINT.USER_SERVICE.USERS, "userStatusStatistics"],
@@ -70,13 +75,21 @@ function UsersPage() {
});
const query = useQuery({
queryKey: [ENDPOINT.USER_SERVICE.USERS, searchParams, pagination],
queryKey: [ENDPOINT.USER_SERVICE.USERS, params],
queryFn: () =>
UserApi.findPageUser({
...searchParams,
page: pagination.pageIndex,
size: pagination.pageSize,
keywordType: params.keywordType,
keyword: params.keyword,
userStateCode: params.userStateCode,
aprvYn: params.aprvYn,
grpId: params.grpId,
frDateYmd: params.frDateYmd,
toDateYmd: params.toDateYmd,
sort: params.sort,
page: params.page,
size: params.size,
}),
placeholderData: keepPreviousData,
});
const { data: userStateCodes } = useQuery({
@@ -84,6 +97,16 @@ function UsersPage() {
queryFn: () => CodeApi.getCode("USERSTTS"),
});
const pagination: PaginationState = {
pageIndex: params.page,
pageSize: params.size,
};
const handlePaginationChange: OnChangeFn<PaginationState> = (updater) => {
const next = typeof updater === "function" ? updater(pagination) : updater;
setParams({ page: next.pageIndex, size: next.pageSize });
};
const handleRowClick = (row: UserListResponse) => {
router.push(`/users/${row.userUnqId}`);
};
@@ -97,7 +120,7 @@ function UsersPage() {
},
manualPagination: true,
getCoreRowModel: getCoreRowModel(),
onPaginationChange: setPagination,
onPaginationChange: handlePaginationChange,
},
dataTableOptions: {
onRowClick: handleRowClick,
@@ -105,14 +128,9 @@ function UsersPage() {
},
});
const handleSubmit = (e: React.SubmitEvent<HTMLFormElement>) => {
const handleSubmit = (e: React.FormEvent<HTMLFormElement>) => {
e.preventDefault();
setPagination({
...pagination,
pageIndex: 0,
});
setSearchParams({
...searchParams,
setParams({
keywordType,
keyword,
userStateCode,
@@ -122,6 +140,7 @@ function UsersPage() {
? dayjs(dateRange.from).format("YYYYMMDD")
: "",
toDateYmd: dateRange?.to ? dayjs(dateRange.to).format("YYYYMMDD") : "",
page: 0,
});
};
@@ -131,20 +150,12 @@ function UsersPage() {
setUserStateCode("");
setAprvYn("");
setGrpId("");
setPresetDateRange("");
setDateRange({
from: undefined,
to: undefined,
});
setSearchParams({
...searchParams,
keywordType: "all",
keyword: "",
userStateCode: "",
aprvYn: "",
grpId: "",
frDateYmd: "",
toDateYmd: "",
});
resetParams();
};
const handlePresetDateRange = (value: string) => {
@@ -351,12 +362,9 @@ function UsersPage() {
<h3 className="text-2xl font-medium text-label">회원 목록</h3>
<div className="flex gap-2">
<Select
defaultValue="regDt,desc"
value={params.sort}
onValueChange={(value) => {
setSearchParams({
...searchParams,
sort: value,
});
setParams({ sort: value });
}}
>
<SelectTrigger>
+4 -3
View File
@@ -1,8 +1,9 @@
export { getCookie, setCookie, removeCookie } from "./cookie";
export { useMutationWrapper } from "./use-mutation-wrapper";
export { getCookie, removeCookie, setCookie } from "./cookie";
export { useAlertStore } from "./use-alert-store";
export { useDataTable } from "./use-data-table";
export { useMenuStore } from "./use-menu-store";
export { useUserStore } from "./use-user-store";
export { useMutationWrapper } from "./use-mutation-wrapper";
export { usePrompt } from "./use-prompt";
export { useSearchParamsState } from "./use-search-params-state";
export { useSelectPrompt } from "./use-select-prompt";
export { useUserStore } from "./use-user-store";
@@ -0,0 +1,90 @@
"use client";
import { useCallback, useMemo, useRef } from "react";
import { useSearchParams } from "next/navigation";
/**
* 조회 조건을 URL searchParams와 동기화하는 훅.
*
* - URL이 단일 소스 오브 트루스(SSOT)이며, 새로고침·뒤로가기 시에도 조건이 유지된다.
* - 기본값과 동일한 값은 URL에서 제거해 쿼리스트링을 깨끗하게 유지한다.
* - `window.history.replaceState` 기반 shallow 갱신으로 서버 라운드트립 없이 동작한다.
* - 숫자 기본값이 주어진 키는 자동으로 `Number` 파싱한다.
*
* @example
* const { params, setParams, resetParams } = useSearchParamsState({
* keyword: "",
* page: 0,
* size: 10,
* });
*/
export function useSearchParamsState<T extends Record<string, ParamValue>>(
defaults: T,
): UseSearchParamsStateReturn<T> {
const searchParams = useSearchParams();
const defaultsRef = useRef(defaults);
const params = useMemo(
() => parseParams(searchParams, defaultsRef.current),
[searchParams],
);
const setParams = useCallback((partial: Partial<T>) => {
const next = new URLSearchParams(window.location.search);
for (const [key, value] of Object.entries(partial)) {
if (value === undefined || value === defaultsRef.current[key]) {
next.delete(key);
} else {
next.set(key, String(value));
}
}
replaceUrl(next);
}, []);
const resetParams = useCallback(() => {
const next = new URLSearchParams(window.location.search);
for (const key of Object.keys(defaultsRef.current)) {
next.delete(key);
}
replaceUrl(next);
}, []);
return { params, setParams, resetParams };
}
function parseParams<T extends Record<string, ParamValue>>(
searchParams: ReturnType<typeof useSearchParams>,
defaults: T,
): T {
const result = { ...defaults };
for (const key of Object.keys(defaults) as (keyof T)[]) {
const raw = searchParams?.get(key as string) ?? null;
if (raw === null) continue;
if (typeof defaults[key] === "number") {
const parsed = Number(raw);
if (!Number.isNaN(parsed)) result[key] = parsed as T[keyof T];
} else {
result[key] = raw as T[keyof T];
}
}
return result;
}
function replaceUrl(searchParams: URLSearchParams) {
const qs = searchParams.toString();
const url = qs
? `${window.location.pathname}?${qs}`
: window.location.pathname;
window.history.replaceState(null, "", url);
}
type ParamValue = string | number | null;
interface UseSearchParamsStateReturn<T> {
params: T;
setParams: (partial: Partial<T>) => void;
resetParams: () => void;
}
+11 -6
View File
@@ -1,11 +1,13 @@
import { toast } from "sonner";
import { useEffect } from "react";
import { useRouter, usePathname } from "next/navigation";
import { usePathname, useRouter, useSearchParams } from "next/navigation";
import { useClipboard } from "@frontend-opensource/use-react-hooks";
import { Button } from "@common/components";
import { deleteReasonMessages } from "@/entities/post";
import { AuthorityButton } from "@/entities/menu-authority";
import { toast } from "sonner";
import { useMenuStore, useSelectPrompt, useUserStore } from "@/shared/lib";
import { AuthorityButton } from "@/entities/menu-authority";
import { deleteReasonMessages } from "@/entities/post";
import { Button } from "@common/components";
type PostActionProps = {
bbsId: string;
@@ -21,11 +23,14 @@ const selectData = Object.entries(deleteReasonMessages).map(
function PostAction({ children, handleDelete }: PostActionProps) {
const router = useRouter();
const pathname = usePathname();
const searchParams = useSearchParams();
const { copyText, copied } = useClipboard();
const { prompt } = useSelectPrompt(selectData);
const bbsInfo = useMenuStore((state) => state.bbsInfo);
const userInfo = useUserStore((state) => state.userInfo);
const postPageLink = `/bbs/${bbsInfo?.bbsTypeCd.toLowerCase()}/${bbsInfo?.bbsId}`;
const from = searchParams?.get("from");
const backLink = from ?? postPageLink;
const onDelete = async () => {
const { resolved, value } = await prompt("삭제 사유를 입력해주세요.");
@@ -65,7 +70,7 @@ function PostAction({ children, handleDelete }: PostActionProps) {
size="lg"
color="white"
type="button"
onClick={() => router.push(postPageLink)}
onClick={() => router.push(backLink)}
>
목록
</Button>
-415
View File
@@ -1,415 +0,0 @@
import { test, expect } from "@playwright/test";
// 테스트 순차 실행 보장 (전 단계 상태가 영향을 줄 수 있으므로 serial 모드로 설정)
test.describe.configure({ mode: "serial" });
const adminId = process.env.TEST_ADMIN_ID || "admin1gmail";
const adminPassword = process.env.TEST_ADMIN_PW || "test1234!";
const DEVICE_PC = "device-pc-chrome-001";
const DEVICE_MOBILE = "device-mobile-safari-002";
const DEVICE_TABLET = "device-tablet-firefox-003";
// 디바이스별로 완전히 분리된 HTTP 요청 컨텍스트를 생성하여 세션 격리를 모사하는 헬퍼 함수
async function createDeviceContext(playwrightRequest: any, deviceId: string) {
return await playwrightRequest.newContext({
baseURL: process.env.PLAYWRIGHT_BASE_URL || "http://localhost:3000",
extraHTTPHeaders: {
"X-Device-Id": deviceId,
"Content-Type": "application/json",
},
});
}
// 1초 이상의 간격을 두어 토큰 발급/갱신 시 만료 시각(초)이 다르게 생성되도록 유도하는 지연 함수
const delay = (ms: number) => new Promise((resolve) => setTimeout(resolve, ms));
// 응답의 Set-Cookie 헤더 배열에서 특정 쿠키 값을 추출하는 헬퍼 함수
function getCookieValue(
headers: { name: string; value: string }[],
name: string,
): string | null {
const cookieHeaders = headers.filter(
(h) => h.name.toLowerCase() === "set-cookie",
);
for (const header of cookieHeaders) {
const parts = header.value.split(";")[0].split("=");
if (parts[0].trim() === name) {
return parts[1].trim();
}
}
return null;
}
// 응답의 Set-Cookie 헤더에서 특정 쿠키의 속성(httpOnly, sameSite 등) 존재 유무를 확인하는 헬퍼 함수
function hasCookieAttribute(
headers: { name: string; value: string }[],
name: string,
attribute: string,
): boolean {
const cookieHeaders = headers.filter(
(h) => h.name.toLowerCase() === "set-cookie",
);
for (const header of cookieHeaders) {
const parts = header.value.split(";");
const cookieName = parts[0].split("=")[0].trim();
if (cookieName === name) {
return parts
.slice(1)
.some((p) => p.trim().toLowerCase() === attribute.toLowerCase());
}
}
return false;
}
test.describe("다중 디바이스 인증 & Refresh Token Rotation (RTR) 테스트", () => {
test("1. 로그인 성공 시 httpOnly refresh-token 쿠키 설정", async ({ request }) => {
const response = await request.post("/api/proxy/user-service/login", {
data: { userId: adminId, password: adminPassword },
headers: { "X-Device-Id": DEVICE_PC },
});
expect(response.status()).toBe(200);
const headers = response.headersArray();
const refreshToken = getCookieValue(headers, "refresh-token");
expect(refreshToken).not.toBeNull();
expect(refreshToken).not.toBe("");
// httpOnly 속성이 설정되었는지 검증
const isHttpOnly = hasCookieAttribute(headers, "refresh-token", "httponly");
expect(isHttpOnly).toBe(true);
});
test("2. PC/모바일/태블릿 독립 refresh-token 발급", async ({ playwright }) => {
const pcContext = await createDeviceContext(playwright.request, DEVICE_PC);
const mobileContext = await createDeviceContext(playwright.request, DEVICE_MOBILE);
const tabletContext = await createDeviceContext(playwright.request, DEVICE_TABLET);
const pcRes = await pcContext.post("/api/proxy/user-service/login", {
data: { userId: adminId, password: adminPassword },
});
const pcToken = getCookieValue(pcRes.headersArray(), "refresh-token");
await delay(1000); // 1초 간격 지연
const mobileRes = await mobileContext.post("/api/proxy/user-service/login", {
data: { userId: adminId, password: adminPassword },
});
const mobileToken = getCookieValue(mobileRes.headersArray(), "refresh-token");
await delay(1000); // 1초 간격 지연
const tabletRes = await tabletContext.post("/api/proxy/user-service/login", {
data: { userId: adminId, password: adminPassword },
});
const tabletToken = getCookieValue(tabletRes.headersArray(), "refresh-token");
expect(pcToken).not.toBeNull();
expect(mobileToken).not.toBeNull();
expect(tabletToken).not.toBeNull();
// 다중 세션 환경에서는 디바이스마다 리프레시 토큰이 고유해야 함
expect(pcToken).not.toBe(mobileToken);
expect(pcToken).not.toBe(tabletToken);
expect(mobileToken).not.toBe(tabletToken);
await pcContext.dispose();
await mobileContext.dispose();
await tabletContext.dispose();
});
test("3. Silent Refresh RTR - 새 토큰 교체", async ({ playwright }) => {
const pcContext = await createDeviceContext(playwright.request, DEVICE_PC);
// 로그인으로 최초 토큰 발급
const loginRes = await pcContext.post("/api/proxy/user-service/login", {
data: { userId: adminId, password: adminPassword },
});
const firstToken = getCookieValue(loginRes.headersArray(), "refresh-token");
expect(firstToken).not.toBeNull();
await delay(1000); // 1초 간격 지연
// 갱신 시도 (RTR 동작)
const refreshRes = await pcContext.put("/api/proxy/user-service/api/v1/users/token/refresh");
expect(refreshRes.status()).toBe(200);
const secondToken = getCookieValue(refreshRes.headersArray(), "refresh-token");
expect(secondToken).not.toBeNull();
// 이전 토큰과 달라져야 함
expect(secondToken).not.toBe(firstToken);
await pcContext.dispose();
});
test("4. PC RTR 갱신 후 모바일 세션 독립 유지", async ({ playwright }) => {
const pcContext = await createDeviceContext(playwright.request, DEVICE_PC);
const mobileContext = await createDeviceContext(playwright.request, DEVICE_MOBILE);
await pcContext.post("/api/proxy/user-service/login", {
data: { userId: adminId, password: adminPassword },
});
await delay(1000); // 1초 간격 지연
await mobileContext.post("/api/proxy/user-service/login", {
data: { userId: adminId, password: adminPassword },
});
await delay(1000); // 1초 간격 지연
// PC 토큰을 RTR 갱신함
const pcRefresh = await pcContext.put("/api/proxy/user-service/api/v1/users/token/refresh");
expect(pcRefresh.status()).toBe(200);
// 모바일 세션은 PC와 연동되지 않고 독립적으로 정상 갱신되어야 함
const mobileRefresh = await mobileContext.put("/api/proxy/user-service/api/v1/users/token/refresh");
expect(mobileRefresh.status()).toBe(200);
await pcContext.dispose();
await mobileContext.dispose();
});
test("5. Replay Attack - 구 토큰 재주입 → 400", async ({ playwright }) => {
const pcContext = await createDeviceContext(playwright.request, DEVICE_PC);
// 로그인 후 구 토큰 확보
const loginRes = await pcContext.post("/api/proxy/user-service/login", {
data: { userId: adminId, password: adminPassword },
});
const oldToken = getCookieValue(loginRes.headersArray(), "refresh-token");
await delay(1000); // 1초 간격 지연
// 1차 갱신을 수행하여 서버(Redis)의 저장 값을 갱신
const refreshRes1 = await pcContext.put("/api/proxy/user-service/api/v1/users/token/refresh");
expect(refreshRes1.status()).toBe(200);
// 구 토큰을 명시적으로 세팅하여 재사용 시도
const replayContext = await createDeviceContext(playwright.request, DEVICE_PC);
await replayContext.storageState({
cookies: [
{
name: "refresh-token",
value: oldToken!,
domain: "localhost",
path: "/",
},
],
});
const replayRes = await replayContext.put("/api/proxy/user-service/api/v1/users/token/refresh");
// 사용 완료된 토큰 재주입은 거부되어야 함 (백엔드는 401 반환)
expect(replayRes.status()).toBe(401);
await pcContext.dispose();
await replayContext.dispose();
});
test("6. refresh-token 없이 silent refresh → 401", async ({ playwright }) => {
const cleanContext = await createDeviceContext(playwright.request, DEVICE_PC);
const res = await cleanContext.put("/api/proxy/user-service/api/v1/users/token/refresh");
// 토큰 부재 시 401 반환
expect(res.status()).toBe(401);
const body = await res.json();
expect(body.message).toBe("Can't refresh");
await cleanContext.dispose();
});
test("7. 잘못된 비밀번호 → 401", async ({ request }) => {
const res = await request.post("/api/proxy/user-service/login", {
data: { userId: adminId, password: "WrongPassword999!" },
headers: { "X-Device-Id": DEVICE_PC },
});
expect(res.status()).toBe(401);
const body = await res.json();
expect(body.message).toBe("Invalid Credentials");
});
test("8. 존재하지 않는 ID → 401", async ({ request }) => {
const res = await request.post("/api/proxy/user-service/login", {
data: { userId: "non_existent_user_xyz", password: adminPassword },
headers: { "X-Device-Id": DEVICE_PC },
});
expect(res.status()).toBe(401);
const body = await res.json();
expect(body.message).toBe("Invalid Credentials");
});
test("9. 로그아웃 후 쿠키 전체 삭제", async ({ playwright }) => {
const pcContext = await createDeviceContext(playwright.request, DEVICE_PC);
await pcContext.post("/api/proxy/user-service/login", {
data: { userId: adminId, password: adminPassword },
});
// 로그아웃 호출
const logoutRes = await pcContext.post("/api/auth/logout");
expect(logoutRes.status()).toBe(200);
// 클라이언트의 인증 쿠키들이 소멸되었는지 검증
const state = await pcContext.storageState();
const refreshCookie = state.cookies.find((c) => c.name === "refresh-token");
const accessCookie = state.cookies.find((c) => c.name === "access-token");
const userCookie = state.cookies.find((c) => c.name === "user-id");
expect(refreshCookie).toBeUndefined();
expect(accessCookie).toBeUndefined();
expect(userCookie).toBeUndefined();
await pcContext.dispose();
});
test("10. PC 로그아웃 후 모바일 세션 독립 유지", async ({ playwright }) => {
const pcContext = await createDeviceContext(playwright.request, DEVICE_PC);
const mobileContext = await createDeviceContext(playwright.request, DEVICE_MOBILE);
await pcContext.post("/api/proxy/user-service/login", {
data: { userId: adminId, password: adminPassword },
});
await mobileContext.post("/api/proxy/user-service/login", {
data: { userId: adminId, password: adminPassword },
});
// PC 로그아웃
await pcContext.post("/api/auth/logout");
// 모바일은 PC의 로그아웃과 무관하게 독립적으로 유효하게 동작해야 함
const mobileRefresh = await mobileContext.put("/api/proxy/user-service/api/v1/users/token/refresh");
expect(mobileRefresh.status()).toBe(200);
await pcContext.dispose();
await mobileContext.dispose();
});
test("11. UI 로그인 후 httpOnly 쿠키 E2E 확인", async ({ page }) => {
// UI 로그인 페이지 접속
await page.goto("/auth/login");
// Form 필드 입력
await page.locator("input#id").fill(adminId);
await page.locator("input#password").fill(adminPassword);
// 디바이스 ID 쿠키 설정 시뮬레이션
await page.context().addCookies([
{
name: "device-id",
value: DEVICE_PC,
url: "http://localhost:3000",
},
]);
// 로그인 버튼 클릭
await page.locator(".login_btn").click();
// 성공 시 대시보드 페이지 이동 대기
await page.waitForURL("**/dashboard");
// E2E 보안 요구사항: 클라이언트 브라우저 JavaScript(document.cookie)로는 httpOnly 쿠키를 읽을 수 없어야 함
const clientCookies = await page.evaluate(() => document.cookie);
expect(clientCookies).not.toContain("refresh-token");
// 실제 브라우저의 HTTP Context 쿠키에는 저장되어 있어야 함
const contextCookies = await page.context().cookies();
const refreshCookie = contextCookies.find((c) => c.name === "refresh-token");
expect(refreshCookie).toBeDefined();
expect(refreshCookie?.httpOnly).toBe(true);
});
test("12. 동일 계정 3개 브라우저 동시 로그인", async ({ playwright }) => {
const pcContext = await createDeviceContext(playwright.request, DEVICE_PC);
const mobileContext = await createDeviceContext(playwright.request, DEVICE_MOBILE);
const tabletContext = await createDeviceContext(playwright.request, DEVICE_TABLET);
const pcRes = await pcContext.post("/api/proxy/user-service/login", {
data: { userId: adminId, password: adminPassword },
});
expect(pcRes.status()).toBe(200);
const mobileRes = await mobileContext.post("/api/proxy/user-service/login", {
data: { userId: adminId, password: adminPassword },
});
expect(mobileRes.status()).toBe(200);
const tabletRes = await tabletContext.post("/api/proxy/user-service/login", {
data: { userId: adminId, password: adminPassword },
});
expect(tabletRes.status()).toBe(200);
// 3개 브라우저 세션 모두 정상 리프레시 수행 가능 확인
const pcRef = await pcContext.put("/api/proxy/user-service/api/v1/users/token/refresh");
expect(pcRef.status()).toBe(200);
const mobRef = await mobileContext.put("/api/proxy/user-service/api/v1/users/token/refresh");
expect(mobRef.status()).toBe(200);
const tabRef = await tabletContext.put("/api/proxy/user-service/api/v1/users/token/refresh");
expect(tabRef.status()).toBe(200);
await pcContext.dispose();
await mobileContext.dispose();
await tabletContext.dispose();
});
test("13. sameSite=Strict 쿠키 속성 확인", async ({ request }) => {
const response = await request.post("/api/proxy/user-service/login", {
data: { userId: adminId, password: adminPassword },
headers: { "X-Device-Id": DEVICE_PC },
});
expect(response.status()).toBe(200);
const headers = response.headersArray();
const isStrict = hasCookieAttribute(headers, "refresh-token", "samesite=strict");
expect(isStrict).toBe(true);
});
test("14. 연속 3회 RTR 갱신 - 4개 고유 토큰", async ({ playwright }) => {
const pcContext = await createDeviceContext(playwright.request, DEVICE_PC);
const tokens: string[] = [];
// 로그인 (1차 토큰 발급)
const loginRes = await pcContext.post("/api/proxy/user-service/login", {
data: { userId: adminId, password: adminPassword },
});
const t1 = getCookieValue(loginRes.headersArray(), "refresh-token");
expect(t1).not.toBeNull();
tokens.push(t1!);
await delay(1000);
// 1차 갱신 (2차 토큰 발급)
const r1 = await pcContext.put("/api/proxy/user-service/api/v1/users/token/refresh");
expect(r1.status()).toBe(200);
const t2 = getCookieValue(r1.headersArray(), "refresh-token");
expect(t2).not.toBeNull();
tokens.push(t2!);
await delay(1000);
// 2차 갱신 (3차 토큰 발급)
const r2 = await pcContext.put("/api/proxy/user-service/api/v1/users/token/refresh");
expect(r2.status()).toBe(200);
const t3 = getCookieValue(r2.headersArray(), "refresh-token");
expect(t3).not.toBeNull();
tokens.push(t3!);
await delay(1000);
// 3차 갱신 (4차 토큰 발급)
const r3 = await pcContext.put("/api/proxy/user-service/api/v1/users/token/refresh");
expect(r3.status()).toBe(200);
const t4 = getCookieValue(r3.headersArray(), "refresh-token");
expect(t4).not.toBeNull();
tokens.push(t4!);
// 4개 토큰이 모두 고유한지 검증 (Set의 중복 제거 크기로 판별)
const uniqueTokens = new Set(tokens);
expect(uniqueTokens.size).toBe(4);
await pcContext.dispose();
});
});
+2 -2
View File
@@ -18,8 +18,8 @@ NEXT_PUBLIC_SEWERAGE_API_URL = http://apigateway.iipbs-was.svc.cluster.local:800
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_GATEWAY_URL = http://apigateway.iipbs-was.svc.cluster.local:8000
CONTENTS_INTRO = 31
CONTENTS_EXAMPLE = 32
CONTENTS_INTRO = 24
CONTENTS_EXAMPLE = 25
NEXT_PUBLIC_FINDADRESS_URL=https://business.juso.go.kr/addrlink/addrLinkApi.do
NEXT_PRIVATE_WR_ADDRESS_KEY = U01TXOFVVEgyMDI1MDgyODE0MTAxNzExNjEyMjA=
@@ -365,7 +365,7 @@ export default function SimilarCaseModal({
</tr>
<tr>
<th rowSpan={2} className={thBase}>
총대장균군수<br />(개/100ml)
총대장균군수<br />(개/100mL)
</th>
<th rowSpan={2} className={thBase}>
용도별 결합 잔류염소<br />(mg/L)
@@ -428,7 +428,7 @@ export default function DemandRegisterPage() {
</FormRow>
<Divider />
<FormRow label="요구수량(m²/d)" required>
<FormRow label="요구수량(㎥/d)" required>
<TextInput value={reqQty} onChange={setReqQty} />
</FormRow>
<Divider />
@@ -438,7 +438,7 @@ export default function DemandRegisterPage() {
</FormRow>
<Divider />
<FormRow label="희망단가(원)" required>
<FormRow label="희망단가(원/㎥)" required>
<TextInput value={hopePrice} onChange={setHopePrice} />
</FormRow>
</div>
@@ -464,7 +464,7 @@ export default function DemandRegisterPage() {
<div className="border border-neutral-200 bg-white">
<QualityRow
leftLabel="총대장균군수(개/mg)"
leftLabel="총대장균군수(개/100㎖)"
leftRequired
leftControl={
<div className="flex items-center gap-4">
@@ -148,7 +148,7 @@ export default function DemandStatusPage() {
{ label: "요구시기", value: selected.detail.reqDate, required: true },
{ label: "요구수량(㎡/d)", value: selected.detail.reqQty },
{ label: "사용용도", value: selected.detail.usePurpose },
{ label: "희망단가(원)", value: selected.detail.price },
{ label: "희망단가(원/㎥)", value: selected.detail.price },
];
}, [selected]);
@@ -157,7 +157,7 @@ export default function DemandStatusPage() {
if (!selected) return [];
const w = selected.detail.water;
return [
{ label: "총대장균군수(개/100mℓ)", value: w.coliform },
{ label: "총대장균군수(개/100㎖)", value: w.coliform },
{ label: "결합잔류염소(mg/L)", value: "-" },
{ label: "탁도(NTU)", value: w.turbidity },
@@ -44,17 +44,17 @@ export default function DemandRegisterPage() {
const [coliform, setColiform] = useState<string>(""); // 총대장균군수
const [noDetect, setNoDetect] = useState<boolean>(false);
const [tn, setTn] = useState<string>("");
const [residualCl, setResidualCl] = useState<string>("");
const [tp, setTp] = useState<string>("");
const [turbidity, setTurbidity] = useState<string>("");
const [ph, setPh] = useState<PHRange>({ min: "", max: "" });
const [bod, setBod] = useState<string>("");
const [chloride, setChloride] = useState<string>("");
const [odor, setOdor] = useState<string>("불쾌하지 않을 것");
const [ec, setEc] = useState<string>("");
const [color, setColor] = useState<string>("");
const [remark, setRemark] = useState<string>("");
const [tn, setTn] = useState<string>(""); // 총질소(T-N)
const [residualCl, setResidualCl] = useState<string>(""); // 결합잔류염소
const [tp, setTp] = useState<string>(""); // 총인(T-P)
const [turbidity, setTurbidity] = useState<string>(""); // 탁도
const [ph, setPh] = useState<PHRange>({ min: "", max: "" }); // 수소이온농도(pH)
const [bod, setBod] = useState<string>(""); // 생물학적산소요구량(BOD)
const [chloride, setChloride] = useState<string>(""); // 염화물
const [odor, setOdor] = useState<string>("불쾌하지 않을 것"); // 냄새
const [ec, setEc] = useState<string>(""); // 전기전도도
const [color, setColor] = useState<string>(""); // 색도
const [remark, setRemark] = useState<string>(""); // 비고
// 공급처 후보지 선택(필수아님)
const [cand1, setCand1] = useState("");
@@ -373,7 +373,8 @@ export default function DemandRegisterPage() {
switch (value) {
// 청소화장실용도
case '01':
setTp('0.2');
setNoDetect(true); // 총대장균군수 불검출
setResidualCl('0.2'); // 잔류염소
setTurbidity('2');
setPh({ min: '5.8', max: '8.5' });
setBod('5');
@@ -381,23 +382,35 @@ export default function DemandRegisterPage() {
break;
// 세척살수용도
case '02':
setTp('0.2');
setTurbidity('2');
setColiform('1000');
setTurbidity('2'); // 탁도
setPh({ min: '5.8', max: '8.5' });
setBod('5');
break;
// 조경용도
case '03':
setTp('0.2');
setColiform('1000');
setTurbidity('2');
setPh({ min: '5.8', max: '8.5' });
setBod('5');
setChloride('250');
setChloride('250'); // 염화물
break;
// 지하수
// 친수용도
case '04':
setNoDetect(true); // 총대장균군수 불검출
setResidualCl('0.1'); // 잔류염소
setTurbidity('2');
setBod('3');
setColor('10');
setTn('10'); // 총질소
setTp('0.5'); // 총인
setPh({ min: '5.8', max: '8.5' });
setChloride('0.5');
break;
// 지하수충전
case '05':
break;
// 농업용도
// 농업용수
case '06':
setTurbidity('5');
setPh({ min: '5.8', max: '8.5' });
@@ -405,16 +418,18 @@ export default function DemandRegisterPage() {
setEc('2000');
setFarmText("이하 (** 간접:2000,직접:700 )");
break;
// 하천 등 유지보수
// 하천 등 유지용수
case '07':
setColiform('1000');
setTn('10');
setBod('5');
setTn('20');
setTp('0.5');
setPh({ min: '5.8', max: '8.5' });
setBod('5');
setChloride('0.5');
break;
// 공업
// 공업용수
case '08':
setBod('20');
setPh({ min: '5.8', max: '8.5' });
break;
default:
@@ -467,7 +482,7 @@ export default function DemandRegisterPage() {
<Divider />
<FormRow label="요구수량(m²/d)" required>
<FormRow label="요구수량(㎥/d)" required>
<TextInput value={reqQty} onChange={setReqQty} placeholder="" />
</FormRow>
@@ -490,7 +505,7 @@ export default function DemandRegisterPage() {
<Divider />
<FormRow label="희망단가(원)" required>
<FormRow label="희망단가(원/㎥)" required>
<TextInput value={hopePrice} onChange={setHopePrice} />
</FormRow>
</div>
@@ -519,7 +534,7 @@ export default function DemandRegisterPage() {
<div className="border border-neutral-200 bg-white">
{/* 1행 */}
<QualityRow
leftLabel="총대장균군수(개/mg)"
leftLabel="총대장균군수(개/100㎖)"
leftControl={
<div className="flex items-center gap-4">
@@ -30,6 +30,19 @@ const formatValue = (value: string | number | null | undefined) => {
return String(value);
};
const formatNumberWithComma = (value: string | number | null | undefined) => {
if (value === null || value === undefined || value === "") return "-";
const normalized =
typeof value === "string" ? Number(value.replace(/,/g, "")) : Number(value);
if (Number.isNaN(normalized)) {
return String(value);
}
return normalized.toLocaleString("ko-KR");
};
const formatBelow = (value: string | number | null | undefined) => {
if (value === null || value === undefined || value === "") return "-";
if (Number(value) === -1) return "불검출";
@@ -234,9 +247,9 @@ export default function DemandStatusPage() {
return [
{ label: "요구시기", key: "dmndTmng", value: formatDate(selectedSdmInfoStatus.dmndTmng), required: true },
{ label: "요구수량(㎥/d)", key: "dmndQty", value: formatValue(selectedSdmInfoStatus.dmndQty) },
{ label: "요구수량(㎥/d)", key: "dmndQty", value: formatNumberWithComma(selectedSdmInfoStatus.dmndQty) },
{ label: "사용용도", key: "useUsgCd", value: formatValue(selectedSdmInfoStatus.useUsgCd) },
{ label: "희망단가(원/㎥)", key: "wntUntprc", value: formatValue(selectedSdmInfoStatus.wntUntprc) },
{ label: "희망단가(원/㎥)", key: "wntUntprc", value: formatNumberWithComma(selectedSdmInfoStatus.wntUntprc) },
];
}, [selectedSdmInfoStatus]);
@@ -244,7 +257,7 @@ export default function DemandStatusPage() {
if (!selectedSdmInfoStatus) return [];
return [
{ label: "총대장균군수(개/100mL)", key: "grssClfg", value: formatBelow(selectedSdmInfoStatus.grssClfg) },
{ label: "총대장균군수(개/100㎖)", key: "grssClfg", value: formatBelow(selectedSdmInfoStatus.grssClfg) },
{ label: "결합잔류염소(mg/L)", key:"combResdch", value: formatBelow(selectedSdmInfoStatus.combResdch) },
{ label: "탁도(NTU)", key:"trbt", value: formatBelow(selectedSdmInfoStatus.trbt) },
@@ -520,7 +533,7 @@ export default function DemandStatusPage() {
{!selectedSdmInfoStatus ? (
<div className="mt-3 border border-neutral-200 px-6 py-10 text-center text-sm text-slate-400">
상세 정보를 표시할 신청건이 없습니다.
조회된 결과가 없습니다.
</div>
) : (
<div className="mt-3 border border-neutral-200">
+1 -1
View File
@@ -17,7 +17,7 @@ type IntroCardItem = {
const INTRO_CARDS: IntroCardItem[] = [
{
title: "물 재이용 법",
title: "물재이용법",
desc: "물의 재이용 촉진 및 지원에 관한 법률을 시행하고, 일부 법률은 개정되었습니다.",
image: card01,
href: "/intro#law-plan",
+1 -1
View File
@@ -770,7 +770,7 @@ export const SdmReducer = (set: any): SdmActions => {
"/cms-service/api/v1/lwpo-cds/?lwpoCdNm=&sort=regDt,desc&upCd=WR018&page=0&size=100"
);
const comUseTypes = upCd.content.filter(el=>el.lwpoCdNm != '친수용수').map((el: any) => ({ text: el.lwpoCdNm, val: el.lwpoCd }));
const comUseTypes = upCd.content.map((el: any) => ({ text: el.lwpoCdNm, val: el.lwpoCd }));
set((state: SdmType) => ({
Sdm: {
+8 -8
View File
@@ -83,14 +83,14 @@ const MOCK: Record<TabKey, CaseItem[]> = {
sewage: [
{
id: "sewage-1",
title: "구리시 하수 재이용",
title: "구리시 하수처리수 재이용",
desc: "고농도 미세먼지 발생 및 폭염의 급증에 따라 공공하수처리시설 발생 재이용수 일부를 도로자동청소시스템에 활용하여 미세먼지 저감 및 폭염 취약성 완화",
imageSrc: "/images/landing/business/sewage/guri_01.png",
href: "/example#example-sewage-guri",
},
{
id: "sewage-2",
title: "부천시 하수재이용",
title: "부천시 하수처리수 재이용",
desc: "굴포하수처리시설 하수처리수 재이용수 생산시설 설치 운영을 통해 여월천 등 부천 관내 하천의 건천화 방지, 하천 생태계 보존 및 주민들에게 친수 공간 제공",
imageSrc: "/images/landing/business/sewage/bucheon_01.png",
href: "/example#example-sewage-bucheon",
@@ -104,42 +104,42 @@ const MOCK: Record<TabKey, CaseItem[]> = {
},
{
id: "sewage-4",
title: "파주시 하수 재이용 시설",
title: "파주시 하수처리수 재이용",
desc: "운정 및 금촌하수처리시설의 하수처리수를 막공법으로 재처리하여 파주LCD산업단지 내 3개 업체에 공업용수를 공급(LG디스플레이, 희성전자, 엔비텍)",
imageSrc: "/images/landing/business/sewage/paju_01.png",
href: "/example#example-sewage-paju",
},
{
id: "sewage-5",
title: "포항시 하수 재이용 시설",
title: "포항시 하수처리수 재이용",
desc: "포항시 내 공단 개발 확대로 지속적이고 안정적인 공업용수 공급 추진(포스코, 동국산업 등), 포항하수처리장 하수처리수를 재처리하여 공업용수로 공급",
imageSrc: "/images/landing/business/sewage/pohang_01.png",
href: "/example#example-sewage-pohang",
},
{
id: "sewage-6",
title: "울산광역시 하수 재이용 시설",
title: "울산광역시 하수처리수 재이용",
desc: "18년 갈수기시 낙동강 수질 악화로 울산석유화학단지 용수 공급 부족을 해소하기 위해 미포산단 내 한화케미칼 등 8개사 공업용수 공급",
imageSrc: "/images/landing/business/sewage/ulsan_01.png",
href: "/example#example-sewage-ulsan",
},
{
id: "sewage-7",
title: "여수시 하수 재이용 시설",
title: "여수시 하수처리수 재이용",
desc: "수요자가 원하는 양질의 공업용수를 저렴하게 공급하여 한정된 수자원의 효율적 이용 및 지역적 물 부족 해소, 하수재이용을 통한 오염총량 저감 및 방류수역 수질개선을 위한 여수국가산단 내 공업용수 공급(GS칼텍스, YNCC 등)",
imageSrc: "/images/landing/business/sewage/yeosu_01.png",
href: "/example#example-sewage-yeosu",
},
{
id: "sewage-8",
title: "보령시 하수 재이용 시설",
title: "보령시 하수처리수 재이용",
desc: "한국중부발전(주)의 보령 및 신보령화력발전소에 10,000㎥/일의 탈황용수 공급을 위해 보령하수처리시설의 하수처리수 재이용 사업 추진, 하수처리수 재이용을 통한 수자원 절약 및 지역 물 부족 해소",
imageSrc: "/images/landing/business/sewage/boryeong_01.png",
href: "/example#example-sewage-boryeong",
},
{
id: "sewage-9",
title: "아산시 하수 재이용 시설",
title: "아산시 하수처리수 재이용",
desc: "신도시 개발에 따른 인구 급증으로 발생하는 하수를 재처리하여 탕정산업단지(삼성디스플레이)에 공업용수를 안정적으로 공급함으로써 지역기반 및 활성화를 도모",
imageSrc: "/images/landing/business/sewage/asan_01.png",
href: "/example#example-sewage-asan",
@@ -282,7 +282,7 @@
// },
// {
// name: "money",
// label: "희망단가(원)",
// label: "희망단가(원/㎥)",
// formIndex: 0,
// required: true,
// component: (field) => (
@@ -375,7 +375,7 @@
// render={({ field }) =>
// name === "money" ? (
// <div>
// <Label className="text-black">희망단가(원)*</Label>
// <Label className="text-black">희망단가(원/㎥)*</Label>
// {component(field)}
// </div>
// ) : (
@@ -411,7 +411,7 @@
// return (
// <FormUi
// label={
// !isDisabled ? `총대장균군수(개/mg)*` : "총대장균군수(개/mg)"
// !isDisabled ? `총대장균군수(개/100㎖)*` : "총대장균군수(개/100㎖)"
// }
// >
// <div className="flex items-center gap-2">
+1
View File
@@ -1,5 +1,6 @@
/// <reference types="next" />
/// <reference types="next/image-types/global" />
/// <reference path="./.next/types/routes.d.ts" />
// NOTE: This file should not be edited
// see https://nextjs.org/docs/app/api-reference/config/typescript for more information.
Binary file not shown.

Before

Width:  |  Height:  |  Size: 139 KiB

After

Width:  |  Height:  |  Size: 60 KiB

@@ -237,7 +237,7 @@ export default function FacilityTermModalHeader<T extends { msrDt: string }>({
onSelectValue={(val) => setFlowPanel(val.toString())}
className="mx-3 w-[150px]"
label=""
selectClass="w-full p-1 border rounded bg-white"
selectClass="w-full rounded border border-input bg-form p-1"
/>
</div>
</TableCell>
@@ -251,7 +251,7 @@ export default function FacilityTermModalHeader<T extends { msrDt: string }>({
mode="single"
selected={startDate}
onSelect={(date) => handleStartDateSelect(date as Date)}
className="w-full p-1 border rounded bg-white"
className="w-full rounded border border-input bg-form p-1 text-foreground"
disabled={(date) => !isDateInRanges(date)}
defaultMonth={startDate}
/>
@@ -262,7 +262,7 @@ export default function FacilityTermModalHeader<T extends { msrDt: string }>({
mode="single"
selected={endDate}
onSelect={(date) => handleEndDateSelect(date as Date)}
className="w-full p-1 border rounded bg-white"
className="w-full rounded border border-input bg-form p-1 text-foreground"
disabled={(date) => !isDateInRanges(date)}
defaultMonth={endDate}
/>
@@ -275,4 +275,4 @@ export default function FacilityTermModalHeader<T extends { msrDt: string }>({
</div>
</ListDiv>
);
}
}
@@ -219,7 +219,7 @@ export default function FacilityTermModalHeaderMonth<T extends { msrDt: string }
return (
<ListDiv className="!h-[100px]">
<div className="card !m-[1.2rem] max-h-[40rem] overflow-auto !p-0r">
<div className="card !m-[1.2rem] max-h-[40rem] overflow-auto !p-0">
<Table>
<thead>
<TableRow>
@@ -237,7 +237,7 @@ export default function FacilityTermModalHeaderMonth<T extends { msrDt: string }
onSelectValue={(val) => setFlowPanel(val.toString())}
className="mx-3 w-[150px]"
label=""
selectClass="w-full p-1 border rounded bg-white"
selectClass="w-full rounded border border-input bg-form p-1"
/>
</div>
</TableCell>
@@ -251,7 +251,7 @@ export default function FacilityTermModalHeaderMonth<T extends { msrDt: string }
mode="single"
selected={startDate}
onSelect={(date) => handleStartDateSelect(date as Date)}
className="w-full p-1 border rounded bg-white"
className="w-full rounded border border-input bg-form p-1 text-foreground"
defaultMonth={startDate}
/>
</div>
@@ -261,7 +261,7 @@ export default function FacilityTermModalHeaderMonth<T extends { msrDt: string }
mode="single"
selected={endDate}
onSelect={(date) => handleEndDateSelect(date as Date)}
className="w-full p-1 border rounded bg-white"
className="w-full rounded border border-input bg-form p-1 text-foreground"
defaultMonth={endDate}
/>
</div>
@@ -273,4 +273,4 @@ export default function FacilityTermModalHeaderMonth<T extends { msrDt: string }
</div>
</ListDiv>
);
}
}
@@ -5,55 +5,55 @@ const HeadMakeColSpan = ({ upHeadList, layoutNum }: { upHeadList: TableUpperProp
return (
layoutNum === 'A' ? (
<>
<TableRow className="bg-gray-50">{upHeadList.map((head: TableUpperProps) =>
<TableRow className="bg-secondary">{upHeadList.map((head: TableUpperProps) =>
(head.id === 'trtFrfNm' ? <TableHead className="w-1/12" rowSpan={2} key={head.id}>{head.title}</TableHead> :
head.id === 'laeNm' ? <TableHead className="w-1/12" rowSpan={2} key={head.id}>{head.title}</TableHead> :
head.id == 'fctCpc' || head.id == 'planInputWaterQlty' || head.id == 'designInputWaterQlty' ? <TableHead rowSpan={2} className="w-40" style={{ whiteSpace: 'pre-line' }} key={head.id}>{head.title}</TableHead> :
head.id === 'trstOprEtpNm' ? <TableHead className="w-1/15" rowSpan={2} key={head.id}>{head.title}</TableHead> :
head.upName === 'upChangeRe' && head.upSequnce === 1 ? <TableHead className="bg-gray-50" style={{ whiteSpace: 'pre-line' }} key={head.id} rowSpan={1} colSpan={2}>{'행정구역'}</TableHead> :
!head.upName ? <TableHead rowSpan={2} className="w-1/20 bg-gray-20" style={{ whiteSpace: 'pre-line' }} key={head.id}>{head.title}</TableHead> : null))}
head.upName === 'upChangeRe' && head.upSequnce === 1 ? <TableHead className="bg-secondary" style={{ whiteSpace: 'pre-line' }} key={head.id} rowSpan={1} colSpan={2}>{'행정구역'}</TableHead> :
!head.upName ? <TableHead rowSpan={2} className="w-1/20 bg-accent text-accent-foreground" style={{ whiteSpace: 'pre-line' }} key={head.id}>{head.title}</TableHead> : null))}
</TableRow>
<TableRow className="bg-gray-50">
<TableRow className="bg-secondary">
{upHeadList.map((head: TableUpperProps) => {
if (head.upName === 'upChangeRe') {
return <TableHead key={head.id} style={{ whiteSpace: 'pre-line' }} className="w-40 bg-gray-50">{head.title}</TableHead>;
return <TableHead key={head.id} style={{ whiteSpace: 'pre-line' }} className="w-40 bg-secondary">{head.title}</TableHead>;
}
})}
</TableRow>
</>
) : layoutNum === 'R' ? (
<>
<TableRow className="bg-gray-50">{upHeadList.map((head: TableUpperProps) =>
<TableRow className="bg-secondary">{upHeadList.map((head: TableUpperProps) =>
(head.id === 'trtFrfNm' ? <TableHead className="w-1/12" rowSpan={2} key={head.id}>{head.title}</TableHead> :
head.id === 'laeNm' ? <TableHead className="w-1/12" rowSpan={2} key={head.id}>{head.title}</TableHead> :
head.id == 'fctCpc' || head.id == 'planInputWaterQlty' || head.id == 'designInputWaterQlty' ? <TableHead rowSpan={2} className="w-40" style={{ whiteSpace: 'pre-line' }} key={head.id}>{head.title}</TableHead> :
head.id === 'trstOprEtpNm' ? <TableHead className="w-1/15" rowSpan={2} key={head.id}>{head.title}</TableHead> :
head.upName === 'upChangeRe' && head.upSequnce === 1 ? <TableHead className="w-75 bg-gray-50" style={{ whiteSpace: 'pre-line' }} key={head.id} rowSpan={1} colSpan={3}>{'유역'}</TableHead> :
!head.upName ? <TableHead rowSpan={2} className="w-1/20 bg-gray-20" style={{ whiteSpace: 'pre-line' }} key={head.id}>{head.title}</TableHead> : null))}
head.upName === 'upChangeRe' && head.upSequnce === 1 ? <TableHead className="w-75 bg-secondary" style={{ whiteSpace: 'pre-line' }} key={head.id} rowSpan={1} colSpan={3}>{'유역'}</TableHead> :
!head.upName ? <TableHead rowSpan={2} className="w-1/20 bg-accent text-accent-foreground" style={{ whiteSpace: 'pre-line' }} key={head.id}>{head.title}</TableHead> : null))}
</TableRow>
<TableRow className="bg-gray-50">
<TableRow className="bg-secondary">
{upHeadList.map((head: TableUpperProps) => {
if (head.upName === 'upChangeRe') {
return <TableHead key={head.id} style={{ whiteSpace: 'pre-line' }} className="w-40 bg-gray-50">{head.title}</TableHead>;
return <TableHead key={head.id} style={{ whiteSpace: 'pre-line' }} className="w-40 bg-secondary">{head.title}</TableHead>;
}
})}
</TableRow>
</>
) : (
<>
<TableRow className="bg-gray-50">{upHeadList.map((head: TableUpperProps) =>
<TableRow className="bg-secondary">{upHeadList.map((head: TableUpperProps) =>
(head.id === 'trtFrfNm' ? <TableHead className="w-1/12" rowSpan={2} key={head.id}>{head.title}</TableHead> :
head.id === 'laeNm' ? <TableHead className="w-1/12" rowSpan={2} key={head.id}>{head.title}</TableHead> :
head.id == 'fctCpc' || head.id == 'planInputWaterQlty' || head.id == 'designInputWaterQlty' ? <TableHead rowSpan={2} className="w-40" style={{ whiteSpace: 'pre-line' }} key={head.id}>{head.title}</TableHead> :
head.id === 'trstOprEtpNm' ? <TableHead className="w-1/15" rowSpan={2} key={head.id}>{head.title}</TableHead> :
head.upName === 'upChangeRe1' && head.upSequnce === 1 ? <TableHead className="w-75 bg-gray-50" style={{ whiteSpace: 'pre-line' }} key={head.id} rowSpan={1} colSpan={3}>{'권역'}</TableHead> :
head.upName === 'upChangeRe2' && head.upSequnce === 1 ? <TableHead className="w-75 bg-gray-50" style={{ whiteSpace: 'pre-line' }} key={head.id} rowSpan={1} colSpan={2}>{'행정구역'}</TableHead> :
!head.upName ? <TableHead rowSpan={2} className="w-1/20 bg-gray-20" style={{ whiteSpace: 'pre-line' }} key={head.id}>{head.title}</TableHead> : null))}
head.upName === 'upChangeRe1' && head.upSequnce === 1 ? <TableHead className="w-75 bg-secondary" style={{ whiteSpace: 'pre-line' }} key={head.id} rowSpan={1} colSpan={3}>{'권역'}</TableHead> :
head.upName === 'upChangeRe2' && head.upSequnce === 1 ? <TableHead className="w-75 bg-secondary" style={{ whiteSpace: 'pre-line' }} key={head.id} rowSpan={1} colSpan={2}>{'행정구역'}</TableHead> :
!head.upName ? <TableHead rowSpan={2} className="w-1/20 bg-accent text-accent-foreground" style={{ whiteSpace: 'pre-line' }} key={head.id}>{head.title}</TableHead> : null))}
</TableRow>
<TableRow className="bg-gray-50">
<TableRow className="bg-secondary">
{upHeadList.map((head: TableUpperProps) => {
if (head.upName === 'upChangeRe1' || head.upName === 'upChangeRe2') {
return <TableHead key={head.id} style={{ whiteSpace: 'pre-line' }} className="w-40 bg-gray-50">{head.title}</TableHead>;
return <TableHead key={head.id} style={{ whiteSpace: 'pre-line' }} className="w-40 bg-secondary">{head.title}</TableHead>;
}
})}
</TableRow>
@@ -99,22 +99,22 @@ export default function SearchInputText({label=undefined, setValue=()=>{}, sidoS
return (
<div className={'relative'} ref={wrapperRef}>
{label && <Label className="w-20 text-xl text-gray-600 justify-self-center">{label}</Label>}
{label && <Label className="w-20 justify-self-center text-xl text-label">{label}</Label>}
<input
type="text"
value={textValue}
onChange={handleInputChange}
onFocus={onInputFocus}
className="w-[200px] ml-6 px-3 py-2 border border-gray-300 rounded-md focus:outline-none focus:ring-2 focus:ring-blue-500"
className="ml-6 w-[200px] rounded-md border border-input bg-form px-3 py-2 text-foreground focus:outline-none focus:ring-2 focus:ring-ring"
placeholder={`${label}을(를) 입력하세요`}
/>
{isOpen && processFacilityList.length > 0 && (
<div className="absolute z-10 w-[300px] px-2 py-1 !mt-5 ml-20 bg-white border border-gray-300 rounded-md shadow-lg h-90 overflow-auto">
<div className="absolute z-10 ml-20 !mt-5 h-[90px] w-[300px] overflow-auto rounded-md border border-border bg-popover px-2 py-1 text-popover-foreground shadow-lg">
<div className="max-h-72">
{processFacilityList.map((item : facilityListProps, index : number) => (
<div
key={index}
className="w-full text-left pl-1 pr-10 py-2 hover:bg-gray-100 cursor-pointer overflow-auto"
className="w-full cursor-pointer overflow-auto py-2 pl-1 pr-10 text-left hover:bg-accent hover:text-accent-foreground"
onClick={() => handleSuggestionClick(item.bscFctCd, item.bscFctNm, Number(item.fctCpc), item.trtFrfNm, item.efflWqltStndrdNm)}
role="button"
tabIndex={0}
@@ -17,7 +17,7 @@ function UnderComment({commentStartPoint="☞", commentArray=[], divHeight="14"}
<div className={"mt-"+divHeight}>
{commentArray.map((item, index) => (
<React.Fragment key={"comment" + index}>
<span className="text-2xl font-bold text-gray-500">
<span className="text-2xl font-bold text-muted-foreground">
{commentStartPoint} {item}
</span>
<br/>
@@ -28,4 +28,4 @@ function UnderComment({commentStartPoint="☞", commentArray=[], divHeight="14"}
}
}
export default UnderComment;
export default UnderComment;
+7 -4
View File
@@ -127,15 +127,18 @@ const Map = () => {
}
const el = overlayContainerRef.current;
el.className =
"ol-custom-overlay shadow-xl rounded-2xl bg-white/95 backdrop-blur p-3 border border-gray-200 min-w-[220px] max-w-[350px] ";
"ol-custom-overlay shadow-xl rounded-2xl backdrop-blur p-3 min-w-[220px] max-w-[350px]";
el.style.backgroundColor = "color-mix(in srgb, var(--popover) 95%, transparent)";
el.style.color = "var(--popover-foreground)";
el.style.border = "1px solid var(--border)";
el.innerHTML = `
<div class="font-semibold text-lg text-gray-800 mb-2 text-center bg-green-400">${title}</div>
<table class="w-full text-base text-gray-800">
<div class="mb-2 rounded-md px-2 py-1 text-center text-lg font-semibold" style="background-color: var(--secondary); color: var(--secondary-foreground);">${title}</div>
<table class="w-full text-base" style="color: var(--foreground);">
${Object.entries(props)
.map(
([k, v]) => `
<tr>
<td class="py-1 pr-2 text-gray-500 whitespace-nowrap">${k}</td>
<td class="whitespace-nowrap py-1 pr-2" style="color: var(--muted-foreground);">${k}</td>
<td class="py-1 font-medium">${safe(v)}</td>
</tr>`
)
@@ -54,23 +54,23 @@ export default function DrainageEquipment({searchYear = '', fcltyList = []}: Dra
const HeadMakeColSpan = ({ upHeadList }: { upHeadList: TableUpperProps[] }) => {
return (
<>
<TableRow className="bg-gray-50">{upHeadList.map((head: TableUpperProps) =>
<TableRow className="bg-secondary">{upHeadList.map((head: TableUpperProps) =>
(head.id === 'part1' ? <TableHead rowSpan={3} className='w-60' key={head.id}>구분</TableHead> :
head.id === 'part2' ? <TableHead rowSpan={3} className='w-60' key={head.id}>구분</TableHead> :
head.id == 'nowSum' ? <TableHead rowSpan={2} colSpan={3} key={head.id} style={{ whiteSpace: 'pre-line' }}>{'현재\n(기준년도)'}</TableHead> :
head.id == 'stepOneSum' ? <TableHead rowSpan={1} colSpan={12} key={head.id} style={{ whiteSpace: 'pre-line' }}>{'변경(금회계획)'}</TableHead> :
head.id == 'etc' ? <TableHead rowSpan={3} key={head.id}>비고</TableHead> : null))}
</TableRow>
<TableRow className="bg-gray-50">{upHeadList.map((head: TableUpperProps) =>
<TableRow className="bg-secondary">{upHeadList.map((head: TableUpperProps) =>
(head.id === 'stepOneSum' ? <TableHead rowSpan={1} colSpan={3} key={head.id}>1단계</TableHead> :
head.id === 'stepTwoSum' ? <TableHead rowSpan={1} colSpan={3} key={head.id}>2단계</TableHead> :
head.id === 'stepThreeSum' ? <TableHead rowSpan={1} colSpan={3} key={head.id}>3단계</TableHead> :
head.id === 'stepFourSum' ? <TableHead rowSpan={1} colSpan={3} key={head.id}>4단계</TableHead> : null))}
</TableRow>
<TableRow className="bg-gray-50">
<TableRow className="bg-secondary">
{upHeadList.map((head: TableUpperProps) => {
if (head.upName === 'upChangeRe2' || head.upName === 'upChangeRe3' || head.upName === 'upChangeRe4' || head.upName === 'upChangeRe5' || head.upName === 'upChangeRe6') {
return <TableHead key={head.id} style={{ whiteSpace: 'pre-line' }} className="w-40 bg-gray-50">{head.title}</TableHead>;
return <TableHead key={head.id} style={{ whiteSpace: 'pre-line' }} className="w-40 bg-secondary">{head.title}</TableHead>;
}
})}
</TableRow>
@@ -147,7 +147,7 @@ export default function DashBoardChartModal({item='', period='', title='',modalD
if (label) {
config[label] = {
label: label,
color : "hsl(var(--chart-1))"
color : "var(--chart-1)"
};
}
return config;
@@ -32,12 +32,12 @@ const headMakeColSpanPlanFclty = (upHeadList: TableUpperProps[], planYearList: s
return ( <>
<TableRow>{
upHeadList.map((head: TableUpperProps) =>
(!head.upSequnce?<TableHead rowSpan={2}key={head.id}>{head.title}</TableHead>:
head.upName === 'upChangeRe'&& head.upSequnce=== 1?<TableHead key={head.id} rowSpan={1} colSpan={7} style={{ whiteSpace: 'pre-line' }}>{'시설계획\n(금회)'}</TableHead>:null))}
(!head.upSequnce?<TableHead className="fo-facility-info-header-surface" rowSpan={2}key={head.id}>{head.title}</TableHead>:
head.upName === 'upChangeRe'&& head.upSequnce=== 1?<TableHead className="fo-facility-info-header-surface" key={head.id} rowSpan={1} colSpan={7} style={{ whiteSpace: 'pre-line' }}>{'시설계획\n(금회)'}</TableHead>:null))}
</TableRow>
<TableRow>
{upHeadList.map((head: TableUpperProps, index: number) =>
(head.upName === 'upChangeRe'?<TableHead key={head.id} style={{ whiteSpace: 'pre-line' }}>{index===0?head.title:head.title+'\n('+planYearList[index-1]+')'}</TableHead>:null))}
(head.upName === 'upChangeRe'?<TableHead className="fo-facility-info-header-surface" key={head.id} style={{ whiteSpace: 'pre-line' }}>{index===0?head.title:head.title+'\n('+planYearList[index-1]+')'}</TableHead>:null))}
</TableRow>
</> )
}
@@ -52,20 +52,20 @@ const headMakeColSpanPlanFclty = (upHeadList: TableUpperProps[], planYearList: s
const headMakeColSpanOperationStatus = (upHeadList: TableUpperProps[], searchYear: string) => {
return ( <>
<TableRow>
<TableHead rowSpan={1} colSpan={17} style={{ whiteSpace: 'pre-line' }}>{searchYear+'년도 운영실태'}</TableHead>
<TableHead className="fo-facility-info-header-surface" rowSpan={1} colSpan={17} style={{ whiteSpace: 'pre-line' }}>{searchYear+'년도 운영실태'}</TableHead>
</TableRow>
<TableRow>{
upHeadList.map((head: TableUpperProps) =>
(head.upName === 'upChangeRe1'&&head.upSequnce===1?<TableHead key={head.id} rowSpan={2}colSpan={2} style={{ whiteSpace: 'pre-line' }}>{head.title}</TableHead>:
!head.upSequnce?<TableHead style={{ whiteSpace: 'pre-line' }} rowSpan={2}key={head.id}>{head.title}</TableHead>:
head.upName === 'upChangeRe2'&&head.upSequnce===1?<TableHead key={head.id} colSpan={3} style={{ whiteSpace: 'pre-line' }}>{'연간 운영실태'}</TableHead>:
head.upName === 'upChangeRe3'&&head.upSequnce===1?<TableHead key={head.id} colSpan={3} style={{ whiteSpace: 'pre-line' }}>{'하절기 운영실태\n(6~8월)'}</TableHead>:
head.upName === 'upChangeRe4'&&head.upSequnce===1?<TableHead key={head.id} colSpan={4} style={{ whiteSpace: 'pre-line' }}>{'동절기 운영실태\n(1~2월)'}</TableHead>:
head.upName === 'upChangeRe5'&&head.upSequnce===1?<TableHead key={head.id} colSpan={3} style={{ whiteSpace: 'pre-line' }}>{'지하수 유입량 추정'}</TableHead>:null))}
(head.upName === 'upChangeRe1'&&head.upSequnce===1?<TableHead className="fo-facility-info-header-surface" key={head.id} rowSpan={2}colSpan={2} style={{ whiteSpace: 'pre-line' }}>{head.title}</TableHead>:
!head.upSequnce?<TableHead className="fo-facility-info-header-surface" style={{ whiteSpace: 'pre-line' }} rowSpan={2}key={head.id}>{head.title}</TableHead>:
head.upName === 'upChangeRe2'&&head.upSequnce===1?<TableHead className="fo-facility-info-header-surface" key={head.id} colSpan={3} style={{ whiteSpace: 'pre-line' }}>{'연간 운영실태'}</TableHead>:
head.upName === 'upChangeRe3'&&head.upSequnce===1?<TableHead className="fo-facility-info-header-surface" key={head.id} colSpan={3} style={{ whiteSpace: 'pre-line' }}>{'하절기 운영실태\n(6~8월)'}</TableHead>:
head.upName === 'upChangeRe4'&&head.upSequnce===1?<TableHead className="fo-facility-info-header-surface" key={head.id} colSpan={4} style={{ whiteSpace: 'pre-line' }}>{'동절기 운영실태\n(1~2월)'}</TableHead>:
head.upName === 'upChangeRe5'&&head.upSequnce===1?<TableHead className="fo-facility-info-header-surface" key={head.id} colSpan={3} style={{ whiteSpace: 'pre-line' }}>{'지하수 유입량 추정'}</TableHead>:null))}
</TableRow>
<TableRow>
{upHeadList.map((head: TableUpperProps) =>
(head.upName === 'upChangeRe2'||head.upName === 'upChangeRe3'||head.upName === 'upChangeRe4'||head.upName === 'upChangeRe5'?<TableHead style={{ whiteSpace: 'pre-line' }} key={head.id}>{head.title}</TableHead>:null))}
(head.upName === 'upChangeRe2'||head.upName === 'upChangeRe3'||head.upName === 'upChangeRe4'||head.upName === 'upChangeRe5'?<TableHead className="fo-facility-info-header-surface" style={{ whiteSpace: 'pre-line' }} key={head.id}>{head.title}</TableHead>:null))}
</TableRow>
</> )
}
@@ -239,18 +239,18 @@ export default function ProcessFacilitySearch({searchYear = '', fcltyList = []}:
:((index === 6 || index === 7 || index === 8) && (head.id === 'now' || head.id === 'stepOne' || head.id === 'stepThree' || head.id === 'stepFour'))?
null
:((index === 5) && (head.id === 'provisionalApproval' || head.id === 'stepTwo'))?
<TableCell colSpan={3} className="text-center border border-[var(--border)] bg-[rgb(234,243,251)] text-[rgb(100,119,132)]" style={{ whiteSpace: 'pre-line' }} key={head.id+index}>{el[head.id]}</TableCell>
<TableCell colSpan={3} className="fo-facility-info-header-surface text-center" style={{ whiteSpace: 'pre-line' }} key={head.id+index}>{el[head.id]}</TableCell>
:((index === 5) && (head.id === 'now' || head.id === 'stepOne' || head.id === 'stepThree'|| head.id === 'stepFour'))?
null
:(index < 5 || index > 5)&& (index!= 4) && (head.id ==='provisionalApproval'||head.id ==='now'||head.id ==='stepOne'||head.id ==='stepTwo'||head.id ==='stepThree'||head.id ==='stepFour')
?<TableCell className="text-right" style={{ whiteSpace: 'pre-line' }} key={head.id+index}>{el[head.id]}</TableCell>
:(head.id === "provisionalApproval" && index === 4)
?<TableCell colSpan={6} className="text-right border border-[var(--border)] text-[rgb(100,119,132)]" style={{ whiteSpace: 'pre-line' }} key={head.id+index}>{roundNumber(String((Number(waterUsage1)+Number(waterUsage2)+Number(waterUsage3))/3), 0)}</TableCell>
?<TableCell colSpan={6} className="text-right border border-border text-label" style={{ whiteSpace: 'pre-line' }} key={head.id+index}>{roundNumber(String((Number(waterUsage1)+Number(waterUsage2)+Number(waterUsage3))/3), 0)}</TableCell>
:((head.id ==='now'||head.id ==='stepOne'||head.id ==='stepTwo'||head.id ==='stepThree'||head.id ==='stepFour') )
?null
:(head.id === 'item' && index != 5)?
<TableCell className="text-center text-[rgb(100,119,132)]" style={{ whiteSpace: 'pre-line' }} key={head.id+index}>{el[head.id]}</TableCell>
:<TableCell className="text-center border border-[var(--border)] bg-[rgb(234,243,251)] text-[rgb(100,119,132)]" style={{ whiteSpace: 'pre-line' }} key={head.id+index}>{el[head.id]}</TableCell>
<TableCell className="text-center text-label" style={{ whiteSpace: 'pre-line' }} key={head.id+index}>{el[head.id]}</TableCell>
:<TableCell className="fo-facility-info-header-surface text-center" style={{ whiteSpace: 'pre-line' }} key={head.id+index}>{el[head.id]}</TableCell>
))}
</TableRow>
))}
@@ -278,15 +278,15 @@ export default function ProcessFacilitySearch({searchYear = '', fcltyList = []}:
(head.id === 'rdiRain'&&index === 0)?<TableCell rowSpan={7} className="text-right" style={{ whiteSpace: 'pre-line' }} key={head.id+index}>{el['rdiRain']}</TableCell>:
(head.id === 'rdiRain'&&index === 7)?<TableCell rowSpan={6} className="text-right" style={{ whiteSpace: 'pre-line' }} key={head.id+index}></TableCell>:
(head.id === 'waterQlty')?<TableCell className="text-center" style={{ whiteSpace: 'pre-line' }} key={head.id+index}>{el[head.id]}</TableCell>:
((head.id === 'yearAvg' || head.id === 'yearMax' || head.id === 'yearMin') && (index>=0 && index<=4))?<TableCell className="bg-yellow-50 text-right cursor-pointer" style={{ whiteSpace: 'pre-line' }} key={head.id+index} onClick={()=>{fncLoadModal('flowRate', 'year', el['waterQlty'] as string);}}>{el[head.id]}</TableCell>:
((head.id === 'yearAvg' || head.id === 'yearMax' || head.id === 'yearMin') && (index>=5 && index<=10))?<TableCell className="bg-green-50 text-right cursor-pointer" style={{ whiteSpace: 'pre-line' }} key={head.id+index} onClick={()=>{fncLoadModal('waterQlty', 'year', el['waterQlty'] as string);}}>{el[head.id]}</TableCell>:
((head.id === 'yearAvg' || head.id === 'yearMax' || head.id === 'yearMin') && (index>=11 && index<=12))?<TableCell className="bg-blue-50 text-right cursor-pointer" style={{ whiteSpace: 'pre-line' }} key={head.id+index} onClick={()=>{fncLoadModal('chongin', 'year', el['waterQlty'] as string);}}>{el[head.id]}</TableCell>:
((head.id === 'summerAll' || head.id === 'summerThree' || head.id === 'summerRain') && (index>=0 && index<=4))?<TableCell className="bg-yellow-100 text-right cursor-pointer" style={{ whiteSpace: 'pre-line' }} key={head.id+index} onClick={()=>{fncLoadModal('flowRate', 'summer', el['waterQlty'] as string);}}>{el[head.id]}</TableCell>:
((head.id === 'summerAll' || head.id === 'summerThree' || head.id === 'summerRain') && (index>=5 && index<=10))?<TableCell className="bg-green-100 text-right cursor-pointer" style={{ whiteSpace: 'pre-line' }} key={head.id+index} onClick={()=>{fncLoadModal('waterQlty', 'summer', el['waterQlty'] as string);}}>{el[head.id]}</TableCell>:
((head.id === 'summerAll' || head.id === 'summerThree' || head.id === 'summerRain') && (index>=11 && index<=12))?<TableCell className="bg-blue-100 text-right cursor-pointer" style={{ whiteSpace: 'pre-line' }} key={head.id+index} onClick={()=>{fncLoadModal('chongin', 'summer', el['waterQlty'] as string);}}>{el[head.id]}</TableCell>:
((head.id === 'winterAll' || head.id === 'winterSunnyAvg' || head.id === 'winterSunnyMax' || head.id === 'winterSunnyMin') && (index>=0 && index<=4))?<TableCell className="bg-yellow-200 text-right cursor-pointer" style={{ whiteSpace: 'pre-line' }} key={head.id+index} onClick={()=>{fncLoadModal('flowRate', 'winter', el['waterQlty'] as string);}}>{el[head.id]}</TableCell>:
((head.id === 'winterAll' || head.id === 'winterSunnyAvg' || head.id === 'winterSunnyMax' || head.id === 'winterSunnyMin') && (index>=5 && index<=10))?<TableCell className="bg-green-200 text-right cursor-pointer" style={{ whiteSpace: 'pre-line' }} key={head.id+index} onClick={()=>{fncLoadModal('waterQlty', 'winter', el['waterQlty'] as string);}}>{el[head.id]}</TableCell>:
((head.id === 'winterAll' || head.id === 'winterSunnyAvg' || head.id === 'winterSunnyMax' || head.id === 'winterSunnyMin') && (index>=11 && index<=12))?<TableCell className="bg-blue-200 text-right cursor-pointer" style={{ whiteSpace: 'pre-line' }} key={head.id+index} onClick={()=>{fncLoadModal('chongin', 'winter', el['waterQlty'] as string);}}>{el[head.id]}</TableCell>:
((head.id === 'yearAvg' || head.id === 'yearMax' || head.id === 'yearMin') && (index>=0 && index<=4))?<TableCell className="fo-facility-info-flow-year text-right cursor-pointer" style={{ whiteSpace: 'pre-line' }} key={head.id+index} onClick={()=>{fncLoadModal('flowRate', 'year', el['waterQlty'] as string);}}>{el[head.id]}</TableCell>:
((head.id === 'yearAvg' || head.id === 'yearMax' || head.id === 'yearMin') && (index>=5 && index<=10))?<TableCell className="fo-facility-info-water-year text-right cursor-pointer" style={{ whiteSpace: 'pre-line' }} key={head.id+index} onClick={()=>{fncLoadModal('waterQlty', 'year', el['waterQlty'] as string);}}>{el[head.id]}</TableCell>:
((head.id === 'yearAvg' || head.id === 'yearMax' || head.id === 'yearMin') && (index>=11 && index<=12))?<TableCell className="fo-facility-info-phosphorus-year text-right cursor-pointer" style={{ whiteSpace: 'pre-line' }} key={head.id+index} onClick={()=>{fncLoadModal('chongin', 'year', el['waterQlty'] as string);}}>{el[head.id]}</TableCell>:
((head.id === 'summerAll' || head.id === 'summerThree' || head.id === 'summerRain') && (index>=0 && index<=4))?<TableCell className="fo-facility-info-flow-summer text-right cursor-pointer" style={{ whiteSpace: 'pre-line' }} key={head.id+index} onClick={()=>{fncLoadModal('flowRate', 'summer', el['waterQlty'] as string);}}>{el[head.id]}</TableCell>:
((head.id === 'summerAll' || head.id === 'summerThree' || head.id === 'summerRain') && (index>=5 && index<=10))?<TableCell className="fo-facility-info-water-summer text-right cursor-pointer" style={{ whiteSpace: 'pre-line' }} key={head.id+index} onClick={()=>{fncLoadModal('waterQlty', 'summer', el['waterQlty'] as string);}}>{el[head.id]}</TableCell>:
((head.id === 'summerAll' || head.id === 'summerThree' || head.id === 'summerRain') && (index>=11 && index<=12))?<TableCell className="fo-facility-info-phosphorus-summer text-right cursor-pointer" style={{ whiteSpace: 'pre-line' }} key={head.id+index} onClick={()=>{fncLoadModal('chongin', 'summer', el['waterQlty'] as string);}}>{el[head.id]}</TableCell>:
((head.id === 'winterAll' || head.id === 'winterSunnyAvg' || head.id === 'winterSunnyMax' || head.id === 'winterSunnyMin') && (index>=0 && index<=4))?<TableCell className="fo-facility-info-flow-winter text-right cursor-pointer" style={{ whiteSpace: 'pre-line' }} key={head.id+index} onClick={()=>{fncLoadModal('flowRate', 'winter', el['waterQlty'] as string);}}>{el[head.id]}</TableCell>:
((head.id === 'winterAll' || head.id === 'winterSunnyAvg' || head.id === 'winterSunnyMax' || head.id === 'winterSunnyMin') && (index>=5 && index<=10))?<TableCell className="fo-facility-info-water-winter text-right cursor-pointer" style={{ whiteSpace: 'pre-line' }} key={head.id+index} onClick={()=>{fncLoadModal('waterQlty', 'winter', el['waterQlty'] as string);}}>{el[head.id]}</TableCell>:
((head.id === 'winterAll' || head.id === 'winterSunnyAvg' || head.id === 'winterSunnyMax' || head.id === 'winterSunnyMin') && (index>=11 && index<=12))?<TableCell className="fo-facility-info-phosphorus-winter text-right cursor-pointer" style={{ whiteSpace: 'pre-line' }} key={head.id+index} onClick={()=>{fncLoadModal('chongin', 'winter', el['waterQlty'] as string);}}>{el[head.id]}</TableCell>:
null
))}
</TableRow>
@@ -60,7 +60,14 @@ export default function DashBoardChart({searchYear = '', fcltyList = []}: facili
/**
* 차트 Config
*/
const colors = ["#FF6B6B", "#4ECDC4", "#45B7D1", "#96CEB4", "#00b50c", "#D4A5A5"];
const colors = [
"var(--chart-5)",
"var(--chart-2)",
"var(--chart-1)",
"var(--chart-3)",
"var(--primary)",
"var(--chart-6)",
];
//유입하수량-강수량
const chartConfig_inflowRain = {
@@ -50,7 +50,7 @@ export default function RainManageTab({ searchYear = '', fcltyList = [] }: facil
widthFixed={true}
>
</UiTable>
<span className="text-2xl font-bold text-gray-500">☞ 일수에 강우영향일 포함됨</span>
<span className="text-2xl font-bold text-muted-foreground">☞ 일수에 강우영향일 포함됨</span>
</div>
)
}
@@ -1,5 +1,5 @@
/**
* 대시보드 - 표로보기 상단구역 페에지
* 대시보드 - 표로보기 상단구역 페이지
*
* - 대시보드 - 표로보기의 3개의 탭 상단에 조회년도, 행정정보, 시설명의 구역을 컴포넌트
*/
@@ -198,7 +198,7 @@ export default function PrecipitationModal(props: PrecipitationModalProps) {
mode="single"
selected={startDate}
onSelect={(date) => handleStartDateSelect(date as Date)}
className="w-full p-1 border rounded bg-white"
className="w-full rounded border border-input bg-form p-1 text-foreground"
disabled={(date) => !isDateInRanges(date)}
defaultMonth={startDate}
/>
@@ -209,7 +209,7 @@ export default function PrecipitationModal(props: PrecipitationModalProps) {
mode="single"
selected={endDate}
onSelect={(date) => handleEndDateSelect(date as Date)}
className="w-full p-1 border rounded bg-white"
className="w-full rounded border border-input bg-form p-1 text-foreground"
disabled={(date) => !isDateInRanges(date)}
defaultMonth={endDate}
/>
@@ -239,4 +239,4 @@ export default function PrecipitationModal(props: PrecipitationModalProps) {
</ListDiv>
</>
);
}
}
@@ -17,27 +17,27 @@ const HeadMakeColSpan = ({ upHeadList, layoutNum }: { upHeadList: TableUpperProp
return (
layoutNum === 0 ? (
<>
<TableRow className="bg-gray-50">
<TableRow className="bg-secondary">
{upHeadList.map((head: TableUpperProps) => {
if(!head.upName) {
return <TableHead className="bg-gray-50" rowSpan={2} style={{ whiteSpace: 'pre-line' }} key={head.id}>{head.title}</TableHead>
return <TableHead className="bg-secondary" rowSpan={2} style={{ whiteSpace: 'pre-line' }} key={head.id}>{head.title}</TableHead>
}else if(head.upName==="upChangeRe1"&&head.upSequnce===1) {
return <TableHead className="bg-gray-50" colSpan={2} style={{ whiteSpace: 'pre-line' }} key={head.id}>행정구역</TableHead>
return <TableHead className="bg-secondary" colSpan={2} style={{ whiteSpace: 'pre-line' }} key={head.id}>행정구역</TableHead>
}else if(head.upName==="upChangeRe3"&&head.upSequnce===1) {
return <TableHead className="bg-gray-50" colSpan={5} style={{ whiteSpace: 'pre-line' }} key={head.id}>유입</TableHead>
return <TableHead className="bg-secondary" colSpan={5} style={{ whiteSpace: 'pre-line' }} key={head.id}>유입</TableHead>
}else if(head.upName==="upChangeRe4"&&head.upSequnce===1) {
return <TableHead className="bg-gray-50" colSpan={5} style={{ whiteSpace: 'pre-line' }} key={head.id}>유출</TableHead>
return <TableHead className="bg-secondary" colSpan={5} style={{ whiteSpace: 'pre-line' }} key={head.id}>유출</TableHead>
}
})}
</TableRow>
<TableRow className="bg-gray-50">
<TableRow className="bg-secondary">
{upHeadList.map((head: TableUpperProps, idx: number) => {
if (head.upName === 'upChangeRe1' || head.upName === 'upChangeRe3' || head.upName === 'upChangeRe4') {
if (idx == 1 || idx == 2 || idx == 3) {
return <TableHead key={head.id} style={{ whiteSpace: 'pre-line' }} className="w-1/8 bg-gray-50">{head.title}</TableHead>;
return <TableHead key={head.id} style={{ whiteSpace: 'pre-line' }} className="w-1/8 bg-secondary">{head.title}</TableHead>;
}
else {
return <TableHead key={head.id} style={{ whiteSpace: 'pre-line' }} className="w-1/8 bg-gray-50">{head.title}</TableHead>;
return <TableHead key={head.id} style={{ whiteSpace: 'pre-line' }} className="w-1/8 bg-secondary">{head.title}</TableHead>;
}
}
})}
@@ -45,27 +45,27 @@ const HeadMakeColSpan = ({ upHeadList, layoutNum }: { upHeadList: TableUpperProp
</>
) : layoutNum === 1 ? (
<>
<TableRow className="bg-gray-50">
<TableRow className="bg-secondary">
{upHeadList.map((head: TableUpperProps) => {
if(!head.upName) {
return <TableHead className="bg-gray-50" rowSpan={2} style={{ whiteSpace: 'pre-line' }} key={head.id}>{head.title}</TableHead>
return <TableHead className="bg-secondary" rowSpan={2} style={{ whiteSpace: 'pre-line' }} key={head.id}>{head.title}</TableHead>
}else if(head.upName==="upChangeRe1"&&head.upSequnce===1) {
return <TableHead className="bg-gray-50" colSpan={3} style={{ whiteSpace: 'pre-line' }} key={head.id}>권역</TableHead>
return <TableHead className="bg-secondary" colSpan={3} style={{ whiteSpace: 'pre-line' }} key={head.id}>권역</TableHead>
}else if(head.upName==="upChangeRe3"&&head.upSequnce===1) {
return <TableHead className="bg-gray-50" colSpan={5} style={{ whiteSpace: 'pre-line' }} key={head.id}>유입</TableHead>
return <TableHead className="bg-secondary" colSpan={5} style={{ whiteSpace: 'pre-line' }} key={head.id}>유입</TableHead>
}else if(head.upName==="upChangeRe4"&&head.upSequnce===1) {
return <TableHead className="bg-gray-50" colSpan={5} style={{ whiteSpace: 'pre-line' }} key={head.id}>유출</TableHead>
return <TableHead className="bg-secondary" colSpan={5} style={{ whiteSpace: 'pre-line' }} key={head.id}>유출</TableHead>
}
})}
</TableRow>
<TableRow className="bg-gray-50">
<TableRow className="bg-secondary">
{upHeadList.map((head: TableUpperProps, idx: number) => {
if (head.upName === 'upChangeRe1' || head.upName === 'upChangeRe3' || head.upName === 'upChangeRe4') {
if (idx == 1 || idx == 2 || idx == 3) {
return <TableHead key={head.id} style={{ whiteSpace: 'pre-line' }} className="w-1/8 bg-gray-50">{head.title}</TableHead>;
return <TableHead key={head.id} style={{ whiteSpace: 'pre-line' }} className="w-1/8 bg-secondary">{head.title}</TableHead>;
}
else {
return <TableHead key={head.id} style={{ whiteSpace: 'pre-line' }} className="w-1/8 bg-gray-50">{head.title}</TableHead>;
return <TableHead key={head.id} style={{ whiteSpace: 'pre-line' }} className="w-1/8 bg-secondary">{head.title}</TableHead>;
}
}
})}
@@ -73,29 +73,29 @@ const HeadMakeColSpan = ({ upHeadList, layoutNum }: { upHeadList: TableUpperProp
</>
) : layoutNum === 2 ? (
<>
<TableRow className="bg-gray-50">
<TableRow className="bg-secondary">
{upHeadList.map((head: TableUpperProps) => {
if(!head.upName) {
return <TableHead className="bg-gray-50" rowSpan={2} style={{ whiteSpace: 'pre-line' }} key={head.id}>{head.title}</TableHead>
return <TableHead className="bg-secondary" rowSpan={2} style={{ whiteSpace: 'pre-line' }} key={head.id}>{head.title}</TableHead>
}else if(head.upName==="upChangeRe1"&&head.upSequnce===1) {
return <TableHead className="bg-gray-50" colSpan={3} style={{ whiteSpace: 'pre-line' }} key={head.id}>권역</TableHead>
return <TableHead className="bg-secondary" colSpan={3} style={{ whiteSpace: 'pre-line' }} key={head.id}>권역</TableHead>
}else if(head.upName==="upChangeRe2"&&head.upSequnce===1) {
return <TableHead className="bg-gray-50" colSpan={2} style={{ whiteSpace: 'pre-line' }} key={head.id}>행정구역</TableHead>
return <TableHead className="bg-secondary" colSpan={2} style={{ whiteSpace: 'pre-line' }} key={head.id}>행정구역</TableHead>
}else if(head.upName==="upChangeRe3"&&head.upSequnce===1) {
return <TableHead className="bg-gray-50" colSpan={5} style={{ whiteSpace: 'pre-line' }} key={head.id}>유입</TableHead>
return <TableHead className="bg-secondary" colSpan={5} style={{ whiteSpace: 'pre-line' }} key={head.id}>유입</TableHead>
}else if(head.upName==="upChangeRe4"&&head.upSequnce===1) {
return <TableHead className="bg-gray-50" colSpan={5} style={{ whiteSpace: 'pre-line' }} key={head.id}>유출</TableHead>
return <TableHead className="bg-secondary" colSpan={5} style={{ whiteSpace: 'pre-line' }} key={head.id}>유출</TableHead>
}
})}
</TableRow>
<TableRow className="bg-gray-50">
<TableRow className="bg-secondary">
{upHeadList.map((head: TableUpperProps, idx: number) => {
if (head.upName === 'upChangeRe1' || head.upName === 'upChangeRe2' || head.upName === 'upChangeRe3' || head.upName === 'upChangeRe4') {
if (idx == 1 || idx == 2 || idx == 3) {
return <TableHead key={head.id} style={{ whiteSpace: 'pre-line' }} className="w-1/8 bg-gray-50">{head.title}</TableHead>;
return <TableHead key={head.id} style={{ whiteSpace: 'pre-line' }} className="w-1/8 bg-secondary">{head.title}</TableHead>;
}
else {
return <TableHead key={head.id} style={{ whiteSpace: 'pre-line' }} className="w-1/8 bg-gray-50">{head.title}</TableHead>;
return <TableHead key={head.id} style={{ whiteSpace: 'pre-line' }} className="w-1/8 bg-secondary">{head.title}</TableHead>;
}
}
})}
@@ -103,25 +103,25 @@ const HeadMakeColSpan = ({ upHeadList, layoutNum }: { upHeadList: TableUpperProp
</>
) : layoutNum === 3 ? (
<>
<TableRow className="bg-gray-50">
<TableRow className="bg-secondary">
{upHeadList.map((head: TableUpperProps) => {
if(!head.upName) {
return <TableHead className="bg-gray-50" rowSpan={2} style={{ whiteSpace: 'pre-line' }} key={head.id}>{head.title}</TableHead>
return <TableHead className="bg-secondary" rowSpan={2} style={{ whiteSpace: 'pre-line' }} key={head.id}>{head.title}</TableHead>
}else if(head.upName==="upChangeRe1"&&head.upSequnce===1) {
return <TableHead className="bg-gray-50" colSpan={5} style={{ whiteSpace: 'pre-line' }} key={head.id}>유입</TableHead>
return <TableHead className="bg-secondary" colSpan={5} style={{ whiteSpace: 'pre-line' }} key={head.id}>유입</TableHead>
}else if(head.upName==="upChangeRe2"&&head.upSequnce===1) {
return <TableHead className="bg-gray-50" colSpan={5} style={{ whiteSpace: 'pre-line' }} key={head.id}>방류</TableHead>
return <TableHead className="bg-secondary" colSpan={5} style={{ whiteSpace: 'pre-line' }} key={head.id}>방류</TableHead>
}
})}
</TableRow>
<TableRow className="bg-gray-50">
<TableRow className="bg-secondary">
{upHeadList.map((head: TableUpperProps, idx: number) => {
if (head.upName === 'upChangeRe1' || head.upName === 'upChangeRe2') {
if (idx == 1 || idx == 2 || idx == 3) {
return <TableHead key={head.id} style={{ whiteSpace: 'pre-line' }} className="w-1/8 bg-gray-50">{head.title}</TableHead>;
return <TableHead key={head.id} style={{ whiteSpace: 'pre-line' }} className="w-1/8 bg-secondary">{head.title}</TableHead>;
}
else {
return <TableHead key={head.id} style={{ whiteSpace: 'pre-line' }} className="w-1/8 bg-gray-50">{head.title}</TableHead>;
return <TableHead key={head.id} style={{ whiteSpace: 'pre-line' }} className="w-1/8 bg-secondary">{head.title}</TableHead>;
}
}
})}
@@ -129,23 +129,23 @@ const HeadMakeColSpan = ({ upHeadList, layoutNum }: { upHeadList: TableUpperProp
</>
) : layoutNum === 4 ? (
<>
<TableRow className="bg-gray-50">
<TableRow className="bg-secondary">
{upHeadList.map((head: TableUpperProps) => {
if(!head.upName) {
return <TableHead className="bg-gray-50" rowSpan={2} style={{ whiteSpace: 'pre-line' }} key={head.id}>{head.title}</TableHead>
return <TableHead className="bg-secondary" rowSpan={2} style={{ whiteSpace: 'pre-line' }} key={head.id}>{head.title}</TableHead>
}else if(head.upName==="upChangeRe"&&head.upSequnce===1) {
return <TableHead className="bg-gray-50" colSpan={4} style={{ whiteSpace: 'pre-line' }} key={head.id}>방류</TableHead>
return <TableHead className="bg-secondary" colSpan={4} style={{ whiteSpace: 'pre-line' }} key={head.id}>방류</TableHead>
}
})}
</TableRow>
<TableRow className="bg-gray-50">
<TableRow className="bg-secondary">
{upHeadList.map((head: TableUpperProps, idx: number) => {
if (head.upName === 'upChangeRe') {
if (idx == 1 || idx == 2 || idx == 3) {
return <TableHead key={head.id} style={{ whiteSpace: 'pre-line' }} className="w-1/8 bg-gray-50">{head.title}</TableHead>;
return <TableHead key={head.id} style={{ whiteSpace: 'pre-line' }} className="w-1/8 bg-secondary">{head.title}</TableHead>;
}
else {
return <TableHead key={head.id} style={{ whiteSpace: 'pre-line' }} className="w-1/8 bg-gray-50">{head.title}</TableHead>;
return <TableHead key={head.id} style={{ whiteSpace: 'pre-line' }} className="w-1/8 bg-secondary">{head.title}</TableHead>;
}
}
})}
@@ -153,21 +153,21 @@ const HeadMakeColSpan = ({ upHeadList, layoutNum }: { upHeadList: TableUpperProp
</>
) : layoutNum === 5 ? ( // 총인설비 수질현황 헤더
<>
<TableRow className="bg-gray-50">
<TableRow className="bg-secondary">
{upHeadList.map((head: TableUpperProps) => {
if(!head.upName) {
return <TableHead className="w-[180px] bg-gray-50" rowSpan={2} style={{ whiteSpace: 'pre-line' }} key={head.id}>{head.title}</TableHead>
return <TableHead className="w-[180px] bg-secondary" rowSpan={2} style={{ whiteSpace: 'pre-line' }} key={head.id}>{head.title}</TableHead>
}else if(head.upName==="upChangeRe1" && head.upSequnce===1) {
return <TableHead className="bg-gray-50" colSpan={2} style={{ whiteSpace: 'pre-line' }} key={head.id}>유입</TableHead>
return <TableHead className="bg-secondary" colSpan={2} style={{ whiteSpace: 'pre-line' }} key={head.id}>유입</TableHead>
}else if(head.upName==="upChangeRe2" && head.upSequnce===1) {
return <TableHead className="bg-gray-50" colSpan={2} style={{ whiteSpace: 'pre-line' }} key={head.id}>방류</TableHead>
return <TableHead className="bg-secondary" colSpan={2} style={{ whiteSpace: 'pre-line' }} key={head.id}>방류</TableHead>
}
})}
</TableRow>
<TableRow className="bg-gray-50">
<TableRow className="bg-secondary">
{upHeadList.map((head: TableUpperProps) => {
if (head.upName === 'upChangeRe1' || head.upName === 'upChangeRe2') {
return <TableHead key={head.id} style={{ whiteSpace: 'pre-line' }} className="w-1/8 bg-gray-50">{head.title}</TableHead>;
return <TableHead key={head.id} style={{ whiteSpace: 'pre-line' }} className="w-1/8 bg-secondary">{head.title}</TableHead>;
}
})}
</TableRow>
@@ -237,7 +237,7 @@ export default function FlowRateModal(props: { fcltyName: string; facilityCd: st
const raw = row.infoRnDay;
const value = typeof raw === "string" ? Number(raw.replace(/,/g, "").trim()) : Number(raw);
if (!Number.isFinite(value)) return "";
return value > 3 ? "bg-slate-50" : "";
return value > 3 ? "fo-rain-impact-row" : "";
},
[activeTab],
);
@@ -5,77 +5,77 @@ const HeadMakeColSpan = ({ upHeadList, layoutNum }: { upHeadList: TableUpperProp
return (
layoutNum === 0 ? (
<>
<TableRow className="bg-gray-50">{upHeadList.map((head: TableUpperProps) =>
<TableRow className="bg-secondary">{upHeadList.map((head: TableUpperProps) =>
(head.id === 'trtFrfNm' ? <TableHead className="w-1/12" rowSpan={3} key={head.id}>{head.title}</TableHead> :
head.id === 'laeNm' ? <TableHead className="w-1/12" rowSpan={3} key={head.id}>{head.title}</TableHead> :
head.id == 'fctCpc' || head.id == 'planInputWaterQlty' || head.id == 'designInputWaterQlty' ? <TableHead rowSpan={3} className="w-40" style={{ whiteSpace: 'pre-line' }} key={head.id}>{head.title}</TableHead> :
head.id === 'trstOprEtpNm' ? <TableHead className="w-1/15" rowSpan={3} key={head.id}>{head.title}</TableHead> :
head.upName === 'upChangeRe' && head.upSequnce === 1 ? <TableHead className="bg-gray-50" style={{ whiteSpace: 'pre-line' }} key={head.id} rowSpan={2} colSpan={2}>{'행정구역'}</TableHead> :
head.upName === 'upChangeRe3' && head.upSequnce === 1 ? <TableHead className="bg-gray-50" style={{ whiteSpace: 'pre-line' }} key={head.id} rowSpan={2} colSpan={6}>{'증설계획'}</TableHead> :
head.upName === 'upChangeRe4' && head.upSequnce === 1 ? <TableHead className="bg-gray-50" style={{ whiteSpace: 'pre-line' }} key={head.id} rowSpan={1} colSpan={19}>{'시설계획'}</TableHead> :
!head.upName ? <TableHead rowSpan={3} className="w-1/20 bg-gray-20" style={{ whiteSpace: 'pre-line' }} key={head.id}>{head.title}</TableHead> : null))}
head.upName === 'upChangeRe' && head.upSequnce === 1 ? <TableHead className="bg-secondary" style={{ whiteSpace: 'pre-line' }} key={head.id} rowSpan={2} colSpan={2}>{'행정구역'}</TableHead> :
head.upName === 'upChangeRe3' && head.upSequnce === 1 ? <TableHead className="bg-secondary" style={{ whiteSpace: 'pre-line' }} key={head.id} rowSpan={2} colSpan={6}>{'증설계획'}</TableHead> :
head.upName === 'upChangeRe4' && head.upSequnce === 1 ? <TableHead className="bg-secondary" style={{ whiteSpace: 'pre-line' }} key={head.id} rowSpan={1} colSpan={19}>{'시설계획'}</TableHead> :
!head.upName ? <TableHead rowSpan={3} className="w-1/20 bg-accent text-accent-foreground" style={{ whiteSpace: 'pre-line' }} key={head.id}>{head.title}</TableHead> : null))}
</TableRow>
<TableRow className="bg-gray-50">
<TableRow className="bg-secondary">
{upHeadList.map((head: TableUpperProps) => (
head.upName === 'upChangeRe4' && head.upSequnce === 1 ? <TableHead className="bg-gray-50" style={{ whiteSpace: 'pre-line' }} key={head.id} rowSpan={1} colSpan={6}>{'증설계획(m³/일)'}</TableHead> :
head.upName === 'upChangeRe5' && head.upSequnce === 1 ? <TableHead className="bg-gray-50" style={{ whiteSpace: 'pre-line' }} key={head.id} rowSpan={1} colSpan={6}>{'처리인구(인)'}</TableHead> :
head.upName === 'upChangeRe6' && head.upSequnce === 1 ? <TableHead className="bg-gray-50" style={{ whiteSpace: 'pre-line' }} key={head.id} rowSpan={1} colSpan={7}>{'처리구역(k㎡)'}</TableHead> :
head.upName === 'upChangeRe4' && head.upSequnce === 1 ? <TableHead className="bg-secondary" style={{ whiteSpace: 'pre-line' }} key={head.id} rowSpan={1} colSpan={6}>{'증설계획(m³/일)'}</TableHead> :
head.upName === 'upChangeRe5' && head.upSequnce === 1 ? <TableHead className="bg-secondary" style={{ whiteSpace: 'pre-line' }} key={head.id} rowSpan={1} colSpan={6}>{'처리인구(인)'}</TableHead> :
head.upName === 'upChangeRe6' && head.upSequnce === 1 ? <TableHead className="bg-secondary" style={{ whiteSpace: 'pre-line' }} key={head.id} rowSpan={1} colSpan={7}>{'처리구역(k㎡)'}</TableHead> :
null
))}
</TableRow>
<TableRow className="bg-gray-50">
<TableRow className="bg-secondary">
{upHeadList.map((head: TableUpperProps) => {
if (head.upName === 'upChangeRe' || head.upName === 'upChangeRe1' || head.upName === 'upChangeRe2' || head.upName === 'upChangeRe3' || head.upName === 'upChangeRe4' || head.upName === 'upChangeRe5' || head.upName === 'upChangeRe6') {
return <TableHead key={head.id} style={{ whiteSpace: 'pre-line' }} className="w-40 bg-gray-50">{head.title}</TableHead>;
return <TableHead key={head.id} style={{ whiteSpace: 'pre-line' }} className="w-40 bg-secondary">{head.title}</TableHead>;
}
})}
</TableRow>
</>
) : layoutNum === 1 ? (
<>
<TableRow className="bg-gray-50">{upHeadList.map((head: TableUpperProps) =>
<TableRow className="bg-secondary">{upHeadList.map((head: TableUpperProps) =>
(head.id === 'trtFrfNm' ? <TableHead className="w-1/12" rowSpan={3} key={head.id}>{head.title}</TableHead> :
head.id === 'laeNm' ? <TableHead className="w-1/12" rowSpan={3} key={head.id}>{head.title}</TableHead> :
head.id == 'fctCpc' || head.id == 'planInputWaterQlty' || head.id == 'designInputWaterQlty' ? <TableHead rowSpan={3} className="w-40" style={{ whiteSpace: 'pre-line' }} key={head.id}>{head.title}</TableHead> :
head.id === 'trstOprEtpNm' ? <TableHead className="w-1/15" rowSpan={3} key={head.id}>{head.title}</TableHead> :
head.upName === 'upChangeRe' && head.upSequnce === 1 ? <TableHead className="w-75 bg-gray-50" style={{ whiteSpace: 'pre-line' }} key={head.id} rowSpan={2} colSpan={3}>{'유역'}</TableHead> :
head.upName === 'upChangeRe3' && head.upSequnce === 1 ? <TableHead className="bg-gray-50" style={{ whiteSpace: 'pre-line' }} key={head.id} rowSpan={2} colSpan={6}>{'증설계획'}</TableHead> :
!head.upName ? <TableHead rowSpan={3} className="w-1/20 bg-gray-20" style={{ whiteSpace: 'pre-line' }} key={head.id}>{head.title}</TableHead> : null))}
head.upName === 'upChangeRe' && head.upSequnce === 1 ? <TableHead className="w-75 bg-secondary" style={{ whiteSpace: 'pre-line' }} key={head.id} rowSpan={2} colSpan={3}>{'유역'}</TableHead> :
head.upName === 'upChangeRe3' && head.upSequnce === 1 ? <TableHead className="bg-secondary" style={{ whiteSpace: 'pre-line' }} key={head.id} rowSpan={2} colSpan={6}>{'증설계획'}</TableHead> :
!head.upName ? <TableHead rowSpan={3} className="w-1/20 bg-accent text-accent-foreground" style={{ whiteSpace: 'pre-line' }} key={head.id}>{head.title}</TableHead> : null))}
</TableRow>
<TableRow className="bg-gray-50">
<TableRow className="bg-secondary">
{upHeadList.map((head: TableUpperProps) => (
head.upName === 'upChangeRe4' && head.upSequnce === 1 ? <TableHead className="bg-gray-50" style={{ whiteSpace: 'pre-line' }} key={head.id} rowSpan={1} colSpan={6}>{'증설계획(m³/일)'}</TableHead> :
head.upName === 'upChangeRe5' && head.upSequnce === 1 ? <TableHead className="bg-gray-50" style={{ whiteSpace: 'pre-line' }} key={head.id} rowSpan={1} colSpan={6}>{'처리인구(인)'}</TableHead> :
head.upName === 'upChangeRe6' && head.upSequnce === 1 ? <TableHead className="bg-gray-50" style={{ whiteSpace: 'pre-line' }} key={head.id} rowSpan={1} colSpan={7}>{'처리구역(k㎡)'}</TableHead> :
head.upName === 'upChangeRe4' && head.upSequnce === 1 ? <TableHead className="bg-secondary" style={{ whiteSpace: 'pre-line' }} key={head.id} rowSpan={1} colSpan={6}>{'증설계획(m³/일)'}</TableHead> :
head.upName === 'upChangeRe5' && head.upSequnce === 1 ? <TableHead className="bg-secondary" style={{ whiteSpace: 'pre-line' }} key={head.id} rowSpan={1} colSpan={6}>{'처리인구(인)'}</TableHead> :
head.upName === 'upChangeRe6' && head.upSequnce === 1 ? <TableHead className="bg-secondary" style={{ whiteSpace: 'pre-line' }} key={head.id} rowSpan={1} colSpan={7}>{'처리구역(k㎡)'}</TableHead> :
null
))}
</TableRow>
<TableRow className="bg-gray-50">
<TableRow className="bg-secondary">
{upHeadList.map((head: TableUpperProps) => {
if (head.upName === 'upChangeRe' || head.upName === 'upChangeRe1' || head.upName === 'upChangeRe2' || head.upName === 'upChangeRe3' || head.upName === 'upChangeRe4' || head.upName === 'upChangeRe5' || head.upName === 'upChangeRe6') {
return <TableHead key={head.id} style={{ whiteSpace: 'pre-line' }} className="w-40 bg-gray-50">{head.title}</TableHead>;
return <TableHead key={head.id} style={{ whiteSpace: 'pre-line' }} className="w-40 bg-secondary">{head.title}</TableHead>;
}
})}
</TableRow>
</>
) : (
<>
<TableRow className="bg-gray-50">{upHeadList.map((head: TableUpperProps) =>
<TableRow className="bg-secondary">{upHeadList.map((head: TableUpperProps) =>
(head.id === 'trtFrfNm' ? <TableHead className="w-1/12" rowSpan={2} key={head.id}>{head.title}</TableHead> :
head.id === 'laeNm' ? <TableHead className="w-1/12" rowSpan={2} key={head.id}>{head.title}</TableHead> :
head.id == 'fctCpc' || head.id == 'planInputWaterQlty' || head.id == 'designInputWaterQlty' ? <TableHead rowSpan={2} className="w-40" style={{ whiteSpace: 'pre-line' }} key={head.id}>{head.title}</TableHead> :
head.id === 'trstOprEtpNm' ? <TableHead className="w-1/15" rowSpan={2} key={head.id}>{head.title}</TableHead> :
head.upName === 'upChangeRe1' && head.upSequnce === 1 ? <TableHead className="w-75 bg-gray-50" style={{ whiteSpace: 'pre-line' }} key={head.id} rowSpan={1} colSpan={3}>{'권역'}</TableHead> :
head.upName === 'upChangeRe2' && head.upSequnce === 1 ? <TableHead className="w-75 bg-gray-50" style={{ whiteSpace: 'pre-line' }} key={head.id} rowSpan={1} colSpan={2}>{'환경청'}</TableHead> :
head.upName === 'upChangeRe3' && head.upSequnce === 1 ? <TableHead className="w-75 bg-gray-50" style={{ whiteSpace: 'pre-line' }} key={head.id} rowSpan={1} colSpan={6}>{'증설계획(m³/일)'}</TableHead> :
head.upName === 'upChangeRe5' && head.upSequnce === 1 ? <TableHead className="w-75 bg-gray-50" style={{ whiteSpace: 'pre-line' }} key={head.id} rowSpan={1} colSpan={6}>{'처리인구(인)'}</TableHead> :
head.upName === 'upChangeRe6' && head.upSequnce === 1 ? <TableHead className="w-75 bg-gray-50" style={{ whiteSpace: 'pre-line' }} key={head.id} rowSpan={1} colSpan={7}>{'처리구역(k㎡)'}</TableHead> :
!head.upName ? <TableHead rowSpan={2} className="w-1/20 bg-gray-20" style={{ whiteSpace: 'pre-line' }} key={head.id}>{head.title}</TableHead> : null))}
head.upName === 'upChangeRe1' && head.upSequnce === 1 ? <TableHead className="w-75 bg-secondary" style={{ whiteSpace: 'pre-line' }} key={head.id} rowSpan={1} colSpan={3}>{'권역'}</TableHead> :
head.upName === 'upChangeRe2' && head.upSequnce === 1 ? <TableHead className="w-75 bg-secondary" style={{ whiteSpace: 'pre-line' }} key={head.id} rowSpan={1} colSpan={2}>{'환경청'}</TableHead> :
head.upName === 'upChangeRe3' && head.upSequnce === 1 ? <TableHead className="w-75 bg-secondary" style={{ whiteSpace: 'pre-line' }} key={head.id} rowSpan={1} colSpan={6}>{'증설계획(m³/일)'}</TableHead> :
head.upName === 'upChangeRe5' && head.upSequnce === 1 ? <TableHead className="w-75 bg-secondary" style={{ whiteSpace: 'pre-line' }} key={head.id} rowSpan={1} colSpan={6}>{'처리인구(인)'}</TableHead> :
head.upName === 'upChangeRe6' && head.upSequnce === 1 ? <TableHead className="w-75 bg-secondary" style={{ whiteSpace: 'pre-line' }} key={head.id} rowSpan={1} colSpan={7}>{'처리구역(k㎡)'}</TableHead> :
!head.upName ? <TableHead rowSpan={2} className="w-1/20 bg-accent text-accent-foreground" style={{ whiteSpace: 'pre-line' }} key={head.id}>{head.title}</TableHead> : null))}
</TableRow>
<TableRow className="bg-gray-50">
<TableRow className="bg-secondary">
{upHeadList.map((head: TableUpperProps) => {
if (head.upName === 'upChangeRe1' || head.upName === 'upChangeRe2' ||head.upName === 'upChangeRe3' || head.upName === 'upChangeRe4' || head.upName === 'upChangeRe5' || head.upName === 'upChangeRe6') {
return <TableHead key={head.id} style={{ whiteSpace: 'pre-line' }} className="w-40 bg-gray-50">{head.title}</TableHead>;
return <TableHead key={head.id} style={{ whiteSpace: 'pre-line' }} className="w-40 bg-secondary">{head.title}</TableHead>;
}
})}
</TableRow>
@@ -84,4 +84,4 @@ const HeadMakeColSpan = ({ upHeadList, layoutNum }: { upHeadList: TableUpperProp
);
}
export { HeadMakeColSpan };
export { HeadMakeColSpan };
@@ -116,7 +116,7 @@ export function SearchBar({ onFeatureSelect, gisMaps }: SearchBarProps) {
<SelectBox
labelClass="w-[40px]"
className="mr-10 w-[200px]"
selectClass="rounded-lg bg-white"
selectClass="rounded-lg"
label="시도"
selectArray={sidoData}
onSelectValue={(val) => onSelectValue(val.toString(), "ctpvCd")}
@@ -125,7 +125,7 @@ export function SearchBar({ onFeatureSelect, gisMaps }: SearchBarProps) {
<SelectBox
labelClass="w-[65px]"
className="mr-10 w-[200px]"
selectClass="rounded-lg bg-white"
selectClass="rounded-lg"
label="시군구"
selectArray={filteredSigunData}
onSelectValue={(val) => onSelectValue(val.toString(), "sggCd")}
@@ -134,7 +134,7 @@ export function SearchBar({ onFeatureSelect, gisMaps }: SearchBarProps) {
<SelectBox
labelClass="w-[140px]"
className="mr-3 w-[250px]"
selectClass="rounded-lg bg-white"
selectClass="rounded-lg"
label="하수처리시설"
selectArray={facilityData}
onSelectValue={(val) => onSelectValue(val.toString(), "fcltCd")}
@@ -0,0 +1,164 @@
# apps/fo 테마 적용 작업계획
작성일: 2026-06-25
대상 앱: `D:\iipbs\frontend\apps\fo`
## 1. 목적
- `apps/fo` 프로그램 페이지 내부에 직접 박혀 있는 색상 하드코딩을 식별한다.
- 가능한 범위는 `packages/common/assets/src/styles/base.css`의 CSS 변수 영향권으로 옮긴다.
- 즉 `bg-white`, `text-gray-*`, `border-gray-*`, `bg-blue-*`, `bg-green-*`, `bg-red-*` 같은 직접 색상 표현을 `background`, `foreground`, `label`, `border`, `secondary`, `accent`, `primary`, `destructive` 계열 토큰으로 치환한다.
## 2. 전제
- `apps/fo`는 [tailwind.config.ts](D:/iipbs/frontend/apps/fo/tailwind.config.ts:1)에서 공통 preset을 사용한다.
- 공통 토큰은 [packages/common/assets/src/styles/base.css](D:/iipbs/frontend/packages/common/assets/src/styles/base.css:1)에 정의되어 있다.
- `packages/common/components`, `packages/common/assets`는 기준 레이어이며, 앱 작업은 이 기준을 따르는 방향으로 진행한다.
- 단, 이번 요청의 1차 목표는 `apps/fo` 내부 코드에 박혀 있는 색상을 줄이는 것이다.
## 3. 확인된 하드코딩 유형
현재 `apps/fo`에서 확인된 대표 패턴은 아래와 같다.
- 검색 입력/선택 UI
- `apps/fo/app/components/common/SearchInput.tsx`
- `apps/fo/app/pages/map/searchBar/searchBar.tsx`
- `apps/fo/app/components/common/FacilityTermModalHeader.tsx`
- `apps/fo/app/components/common/FacilityTermModalHeaderMonth.tsx`
- 대시보드/안내문구
- `apps/fo/app/components/common/UnderComment.tsx`
- `apps/fo/app/pages/(with-layout)/dashboard/rainManage/page.tsx`
- 대시보드 표 셀 상태색
- `apps/fo/app/pages/(with-layout)/dashboard/facilityInfo/page.tsx`
이 페이지는 특별한 페이지로, 연간, 하절기, 동절기 색을 구분하여 보여주어야 한다. 따라서 text-foreground 가 보이도록 하되 연간, 하절기, 동절기 색을 apps\fo\src\app\styles 아래 테마별 css에 정의해서 보여주어야 한다.
- 지도 오버레이/팝업
- `apps/fo/app/pages/(map-layout)/map/page.tsx`
- 테스트/샘플성 버튼
- `apps/fo/app/pages/(with-layout)/test/page.tsx`
- 모달 입력창
- `apps/fo/app/pages/(with-layout)/facilityPrecipitation/modal/page.tsx`
## 4. 기본 원칙
### 4.1 바로 토큰 치환 가능한 경우
아래는 우선 토큰으로 바로 바꾼다.
- `bg-white` -> `bg-background` 또는 `bg-card` 또는 `bg-popover`
- `text-gray-900`, `text-gray-800` -> `text-foreground`
- `text-gray-700`, `text-gray-600` -> `text-label` 또는 `text-foreground`
- `text-gray-500` -> `text-muted-foreground` 또는 `text-placeholder`
- `border-gray-200`, `border-gray-300` -> `border-border`
- `focus:ring-blue-500` -> `focus:ring-ring`
- `text-blue-500`, `text-blue-600` -> `text-primary`
- `bg-blue-500`, `bg-blue-600` -> `bg-primary`
- `text-red-500`, `text-red-600` -> `text-destructive`
- `bg-red-50` 등 경고 계열 -> `bg-accent` 또는 별도 위험 상태 규칙 검토
### 4.2 바로 토큰 치환하면 안 되는 경우
아래는 의미색일 가능성이 높아 바로 토큰화하지 않는다.
- 범례 색상
- 데이터 구분용 heatmap/구간 색상
- 수질/계절/유형별 비교 색상
- 지도 오버레이에서 정보 카테고리 의미를 나타내는 색
이 경우는 다음 중 하나로 처리한다.
- 유지
- 별도 의미 클래스 부여
- 앱 전용 CSS로 상태 클래스를 만든 뒤 theme-safe surface만 토큰화
## 5. 우선순위
### 5.1 1순위: 일반 UI surface
가장 먼저 처리한다.
- 검색 입력창 배경/보더/텍스트
- 검색 결과 dropdown 배경/hover
- 팝업/모달 wrapper 배경/보더
- 제목/라벨/안내문구 색
- 버튼의 직접 blue/red/white 색상
이 영역은 theme 적용 체감이 가장 크고, 토큰 치환도 안전하다.
### 5.2 2순위: 테이블/리스트
- `selectedFcltList`
- `facilityInfo`
- modal 내부 표
- overlay 내 표 구조
이 영역은 `bg-secondary`, `bg-accent`, `text-secondary-foreground`, `border-border` 계열로 정리한다.
### 5.3 3순위: 의미색 데이터 표현
- `facilityInfo`의 계절/항목별 `bg-green-*`, `bg-blue-*`
- 지도 범례 색상
- 지도 feature overlay 강조색
- 대시보드 표 셀 상태색
- `apps/fo/app/pages/(with-layout)/dashboard/facilityInfo/page.tsx`
이 페이지는 특별한 페이지로, 연간, 하절기, 동절기 색을 구분하여 보여주어야 한다. 따라서 text-foreground 가 보이도록 하되 연간, 하절기, 동절기 색을 apps\fo\src\app\styles 아래 테마별 css에 정의해서 보여주어야 한다.
이 영역은 “없애는 것”보다 “역할을 분리하는 것”이 중요하다.
## 6. 실제 작업 순서
1. `apps/fo` 내부 하드코딩 색상 파일 목록 확정
2. 공통 UI와 데이터 의미색을 분리
3. 일반 UI 색상부터 `base.css` 토큰으로 치환
4. 필요 시 `apps/fo/app/styles/*.css`에 앱 전용 의미 클래스 추가
5. `dashboard`, `map`, `modal`, `search` 대표 화면 점검
6. 남은 의미색 영역은 별도 상태색 정책으로 정리
## 7. 권장 치환 전략
### 7.1 JSX 안에서 바로 치환
아래처럼 className을 직접 토큰 기반으로 변경한다.
- `bg-white` -> `bg-background`
- `text-gray-600` -> `text-label`
- `border-gray-300` -> `border-border`
이 방식은 빠르지만 반복이 많으면 유지보수성이 떨어진다.
### 7.2 반복되는 UI는 앱 전용 클래스 도입
예:
- `fo-search-panel`
- `fo-search-input`
- `fo-popup-card`
- `fo-empty-text`
반복되는 구조는 `apps/fo/app/styles/components*.css`에 올리고, JSX에서는 의미 클래스만 사용한다.
이 방식이 장기적으로 더 좋다.
## 8. 예상 주의사항
- `h-90` 같은 Tailwind v3 미지원 클래스가 섞여 있을 수 있다.
- `bg-white`를 `bg-background`로 바꾸면 black theme에서 contrast가 크게 달라질 수 있으므로 텍스트 색도 같이 바꿔야 한다.
- `bg-blue-*`, `bg-green-*`는 실제 status color인지 단순 강조색인지 구분하고 바꿔야 한다.
- 지도 오버레이는 HTML string으로 만드는 부분이 있으면 className 치환만으로 끝나지 않을 수 있다.
## 9. 1차 적용 추천 대상
우선 아래 파일부터 적용하는 것이 효율적이다.
1. `apps/fo/app/components/common/SearchInput.tsx`
2. `apps/fo/app/pages/map/searchBar/searchBar.tsx`
3. `apps/fo/app/components/common/UnderComment.tsx`
4. `apps/fo/app/pages/(with-layout)/dashboard/rainManage/page.tsx`
5. `apps/fo/app/components/common/FacilityTermModalHeader.tsx`
6. `apps/fo/app/components/common/FacilityTermModalHeaderMonth.tsx`
7. `apps/fo/app/pages/(with-layout)/facilityPrecipitation/modal/page.tsx`
## 10. 결론
- `apps/fo`도 `apps/wr`와 동일하게 `base.css` 토큰 중심으로 정리 가능하다.
- 1차는 일반 UI 하드코딩 제거에 집중하고, 데이터 의미색은 유지 또는 별도 정책으로 분리하는 것이 맞다.
- 가장 안전한 방향은 “반복 UI는 앱 전용 의미 클래스”, “일회성은 JSX에서 직접 토큰 치환”의 혼합 방식이다.
+1
View File
@@ -1,5 +1,6 @@
/// <reference types="next" />
/// <reference types="next/image-types/global" />
/// <reference path="./.next/types/routes.d.ts" />
// NOTE: This file should not be edited
// see https://nextjs.org/docs/app/api-reference/config/typescript for more information.
+4
View File
@@ -1,5 +1,9 @@
import "./styles/base.css";
import "./styles/components.css";
import "./styles/components-black.css";
import "./styles/components-blue.css";
import "./styles/components-green.css";
import "./styles/components-red.css";
import "@fontsource-variable/noto-sans-kr";
import { AppProvider } from "./providers";
@@ -0,0 +1,43 @@
@tailwind components;
@layer components {
.card {
@apply relative m-[0.6rem] box-border min-h-[2rem] rounded-[0.5rem] p-[0.6rem] text-[1.3rem] text-[#666];
}
.card-border {
@apply border border-[#dbe2e6] bg-[rgba(255,255,255,0.6)] shadow-blur;
}
.card-header {
@apply -mx-[0.6rem] flex items-center justify-between border-b border-b-[#e1e7eb] p-4;
}
.card-footer {
@apply -mx-[0.6rem] mt-2 flex items-center justify-center border-t border-t-[#e1e7eb] py-4;
}
[data-theme="black"] {
--fo-facility-info-flow-year-bg: color-mix(in srgb, var(--chart-4) 20%, var(--secondary));
--fo-facility-info-water-year-bg: color-mix(in srgb, var(--chart-3) 20%, var(--secondary));
--fo-facility-info-phosphorus-year-bg: color-mix(in srgb, var(--chart-1) 20%, var(--secondary));
--fo-facility-info-flow-summer-bg: color-mix(in srgb, var(--chart-4) 28%, var(--secondary));
--fo-facility-info-water-summer-bg: color-mix(in srgb, var(--chart-3) 28%, var(--secondary));
--fo-facility-info-phosphorus-summer-bg: color-mix(in srgb, var(--chart-1) 28%, var(--secondary));
--fo-facility-info-flow-winter-bg: color-mix(in srgb, var(--chart-4) 36%, var(--secondary));
--fo-facility-info-water-winter-bg: color-mix(in srgb, var(--chart-3) 36%, var(--secondary));
--fo-facility-info-phosphorus-winter-bg: color-mix(in srgb, var(--chart-1) 36%, var(--secondary));
--fo-rain-impact-row-bg: color-mix(in srgb, var(--chart-1) 16%, var(--secondary));
--fo-facility-info-header-bg: #5e7b9c;
--fo-facility-info-header-fg: #ffffff;
--fo-facility-info-header-border: var(--border);
.card-border {
@apply border-[#3f4042] bg-[rgba(255,255,255,0.065)];
}
.card-header {
@apply border-b-[#3f4042];
}
}
input[type="file"] {
@apply w-[200px] h-[40px] px-3 py-2 border border-[#d1d5db] rounded-md bg-white text-sm cursor-pointer;
}
}
@@ -0,0 +1,95 @@
@tailwind components;
@layer components {
.card {
@apply relative m-[0.6rem] box-border min-h-[2rem] rounded-[0.5rem] p-[0.6rem] text-[1.3rem] text-[#666];
}
.card-border {
@apply border border-[#dbe2e6] bg-[rgba(255,255,255,0.6)] shadow-blur;
}
.card-header {
@apply -mx-[0.6rem] flex items-center justify-between border-b border-b-[#e1e7eb] p-4;
}
.card-footer {
@apply -mx-[0.6rem] mt-2 flex items-center justify-center border-t border-t-[#e1e7eb] py-4;
}
[data-theme="blue"] {
--fo-facility-info-flow-year-bg: color-mix(in srgb, var(--chart-4) 16%, var(--background));
--fo-facility-info-water-year-bg: color-mix(in srgb, var(--chart-3) 16%, var(--background));
--fo-facility-info-phosphorus-year-bg: color-mix(in srgb, var(--chart-1) 16%, var(--background));
--fo-facility-info-flow-summer-bg: color-mix(in srgb, var(--chart-4) 24%, var(--background));
--fo-facility-info-water-summer-bg: color-mix(in srgb, var(--chart-3) 24%, var(--background));
--fo-facility-info-phosphorus-summer-bg: color-mix(in srgb, var(--chart-1) 24%, var(--background));
--fo-facility-info-flow-winter-bg: color-mix(in srgb, var(--chart-4) 32%, var(--background));
--fo-facility-info-water-winter-bg: color-mix(in srgb, var(--chart-3) 32%, var(--background));
--fo-facility-info-phosphorus-winter-bg: color-mix(in srgb, var(--chart-1) 32%, var(--background));
--fo-rain-impact-row-bg: color-mix(in srgb, var(--chart-1) 12%, var(--background));
--fo-facility-info-header-bg: #eaf3fb;
--fo-facility-info-header-fg: #647784;
--fo-facility-info-header-border: #e5e5e5;
.card-border {
@apply border-[#d8e3eb] bg-white shadow-blur;
}
.card-header {
@apply border-b-[#d8e3eb];
}
.card {
@apply text-[#4b5563];
}
.card.card-border {
box-shadow: 0 1px 8px rgba(15, 23, 42, 0.06);
}
.card.card-border .card.card-border {
@apply border-[#dde6ed] bg-white;
box-shadow: none;
}
.card.card-border input,
.card.card-border button[role="combobox"] {
@apply border-[#d8e3eb] bg-white text-[#737373];
}
.card.card-border label {
@apply text-[#666666];
}
nav[aria-label="pagination"] li a {
border-color: transparent !important;
background-color: transparent !important;
color: #0a0a0a !important;
}
nav[aria-label="pagination"] li a:hover {
background-color: #f6f6f6 !important;
color: #0a0a0a !important;
}
nav[aria-label="pagination"] li a[aria-current="page"] {
border-color: #2094fa !important;
background-color: #2094fa !important;
color: #fafafa !important;
}
nav[aria-label="pagination"] li a[aria-label="Go to previous page"],
nav[aria-label="pagination"] li a[aria-label="Go to next page"] {
display: inline-flex !important;
align-items: center !important;
justify-content: center !important;
border: 1px solid #e5e7eb !important;
color: #666666 !important;
}
}
input[type="file"] {
@apply w-[200px] h-[40px] px-3 py-2 border border-[#d1d5db] rounded-md bg-white text-sm cursor-pointer;
}
}
@@ -0,0 +1,43 @@
@tailwind components;
@layer components {
.card {
@apply relative m-[0.6rem] box-border min-h-[2rem] rounded-[0.5rem] p-[0.6rem] text-[1.3rem] text-[#666];
}
.card-border {
@apply border border-[#dbe2e6] bg-[rgba(255,255,255,0.6)] shadow-blur;
}
.card-header {
@apply -mx-[0.6rem] flex items-center justify-between border-b border-b-[#e1e7eb] p-4;
}
.card-footer {
@apply -mx-[0.6rem] mt-2 flex items-center justify-center border-t border-t-[#e1e7eb] py-4;
}
[data-theme="green"] {
--fo-facility-info-flow-year-bg: color-mix(in srgb, var(--chart-4) 15%, var(--background));
--fo-facility-info-water-year-bg: color-mix(in srgb, var(--chart-3) 15%, var(--background));
--fo-facility-info-phosphorus-year-bg: color-mix(in srgb, var(--chart-1) 15%, var(--background));
--fo-facility-info-flow-summer-bg: color-mix(in srgb, var(--chart-4) 23%, var(--background));
--fo-facility-info-water-summer-bg: color-mix(in srgb, var(--chart-3) 23%, var(--background));
--fo-facility-info-phosphorus-summer-bg: color-mix(in srgb, var(--chart-1) 23%, var(--background));
--fo-facility-info-flow-winter-bg: color-mix(in srgb, var(--chart-4) 31%, var(--background));
--fo-facility-info-water-winter-bg: color-mix(in srgb, var(--chart-3) 31%, var(--background));
--fo-facility-info-phosphorus-winter-bg: color-mix(in srgb, var(--chart-1) 31%, var(--background));
--fo-rain-impact-row-bg: color-mix(in srgb, var(--chart-3) 12%, var(--background));
--fo-facility-info-header-bg: #ebf7f7;
--fo-facility-info-header-fg: #64908f;
--fo-facility-info-header-border: var(--border);
.card-border {
@apply border-[#3f4042] bg-[rgba(255,255,255,0.065)];
}
.card-header {
@apply border-b-[#3f4042];
}
}
input[type="file"] {
@apply w-[200px] h-[40px] px-3 py-2 border border-[#d1d5db] rounded-md bg-white text-sm cursor-pointer;
}
}
+36
View File
@@ -0,0 +1,36 @@
@tailwind components;
@layer components {
.card {
@apply relative m-[0.6rem] box-border min-h-[2rem] rounded-[0.5rem] p-[0.6rem] text-[1.3rem] text-[#666];
}
.card-border {
@apply border border-[#dbe2e6] bg-[rgba(255,255,255,0.6)] shadow-blur;
}
.card-header {
@apply -mx-[0.6rem] flex items-center justify-between border-b border-b-[#e1e7eb] p-4;
}
.card-footer {
@apply -mx-[0.6rem] mt-2 flex items-center justify-center border-t border-t-[#e1e7eb] py-4;
}
[data-theme="red"] {
--fo-facility-info-flow-year-bg: color-mix(in srgb, var(--chart-4) 14%, var(--background));
--fo-facility-info-water-year-bg: color-mix(in srgb, var(--chart-3) 14%, var(--background));
--fo-facility-info-phosphorus-year-bg: color-mix(in srgb, var(--chart-1) 14%, var(--background));
--fo-facility-info-flow-summer-bg: color-mix(in srgb, var(--chart-4) 22%, var(--background));
--fo-facility-info-water-summer-bg: color-mix(in srgb, var(--chart-3) 22%, var(--background));
--fo-facility-info-phosphorus-summer-bg: color-mix(in srgb, var(--chart-1) 22%, var(--background));
--fo-facility-info-flow-winter-bg: color-mix(in srgb, var(--chart-4) 30%, var(--background));
--fo-facility-info-water-winter-bg: color-mix(in srgb, var(--chart-3) 30%, var(--background));
--fo-facility-info-phosphorus-winter-bg: color-mix(in srgb, var(--chart-1) 30%, var(--background));
--fo-rain-impact-row-bg: color-mix(in srgb, var(--destructive) 12%, var(--background));
--fo-facility-info-header-bg: #f6f6f9;
--fo-facility-info-header-fg: #808080;
--fo-facility-info-header-border: var(--border);
}
input[type="file"] {
@apply w-[200px] h-[40px] px-3 py-2 border border-[#d1d5db] rounded-md bg-white text-sm cursor-pointer;
}
}
+73 -7
View File
@@ -1,6 +1,22 @@
@tailwind components;
@layer components {
:root {
--fo-facility-info-flow-year-bg: color-mix(in srgb, var(--chart-4) 18%, var(--background));
--fo-facility-info-water-year-bg: color-mix(in srgb, var(--chart-3) 18%, var(--background));
--fo-facility-info-phosphorus-year-bg: color-mix(in srgb, var(--chart-1) 18%, var(--background));
--fo-facility-info-flow-summer-bg: color-mix(in srgb, var(--chart-4) 28%, var(--background));
--fo-facility-info-water-summer-bg: color-mix(in srgb, var(--chart-3) 28%, var(--background));
--fo-facility-info-phosphorus-summer-bg: color-mix(in srgb, var(--chart-1) 28%, var(--background));
--fo-facility-info-flow-winter-bg: color-mix(in srgb, var(--chart-4) 38%, var(--background));
--fo-facility-info-water-winter-bg: color-mix(in srgb, var(--chart-3) 38%, var(--background));
--fo-facility-info-phosphorus-winter-bg: color-mix(in srgb, var(--chart-1) 38%, var(--background));
--fo-rain-impact-row-bg: color-mix(in srgb, var(--chart-1) 10%, var(--background));
--fo-facility-info-header-bg: #eaf3fb;
--fo-facility-info-header-fg: #647784;
--fo-facility-info-header-border: var(--border);
}
.card {
@apply relative m-[0.6rem] box-border min-h-[2rem] rounded-[0.5rem] p-[0.6rem] text-[1.3rem] text-[#666];
}
@@ -14,13 +30,63 @@
@apply -mx-[0.6rem] mt-2 flex items-center justify-center border-t border-t-[#e1e7eb] py-4;
}
[data-theme="black"] {
.card-border {
@apply border-[#3f4042] bg-[rgba(255,255,255,0.065)];
}
.card-header {
@apply border-b-[#3f4042];
}
.fo-facility-info-flow-year,
.fo-facility-info-water-year,
.fo-facility-info-phosphorus-year,
.fo-facility-info-flow-summer,
.fo-facility-info-water-summer,
.fo-facility-info-phosphorus-summer,
.fo-facility-info-flow-winter,
.fo-facility-info-water-winter,
.fo-facility-info-phosphorus-winter {
color: var(--foreground);
}
.fo-facility-info-flow-year {
background-color: var(--fo-facility-info-flow-year-bg);
}
.fo-facility-info-water-year {
background-color: var(--fo-facility-info-water-year-bg);
}
.fo-facility-info-phosphorus-year {
background-color: var(--fo-facility-info-phosphorus-year-bg);
}
.fo-facility-info-flow-summer {
background-color: var(--fo-facility-info-flow-summer-bg);
}
.fo-facility-info-water-summer {
background-color: var(--fo-facility-info-water-summer-bg);
}
.fo-facility-info-phosphorus-summer {
background-color: var(--fo-facility-info-phosphorus-summer-bg);
}
.fo-facility-info-flow-winter {
background-color: var(--fo-facility-info-flow-winter-bg);
}
.fo-facility-info-water-winter {
background-color: var(--fo-facility-info-water-winter-bg);
}
.fo-facility-info-phosphorus-winter {
background-color: var(--fo-facility-info-phosphorus-winter-bg);
}
.fo-rain-impact-row td {
background-color: var(--fo-rain-impact-row-bg);
}
.fo-facility-info-header-surface {
border: 1px solid var(--fo-facility-info-header-border);
background-color: var(--fo-facility-info-header-bg);
color: var(--fo-facility-info-header-fg);
}
input[type="file"] {
+1
View File
@@ -1,5 +1,6 @@
/// <reference types="next" />
/// <reference types="next/image-types/global" />
/// <reference path="./.next/types/routes.d.ts" />
// NOTE: This file should not be edited
// see https://nextjs.org/docs/app/api-reference/config/typescript for more information.
+6
View File
@@ -1,3 +1,9 @@
import "./styles/base.css";
import "./styles/components.css";
import "./styles/components-black.css";
import "./styles/components-blue.css";
import "./styles/components-green.css";
import "./styles/components-red.css";
import { AppProvider } from "./providers";
import { cookies } from "next/headers";
@@ -0,0 +1,29 @@
@tailwind components;
@layer components {
.card {
@apply relative m-[0.6rem] box-border min-h-[2rem] rounded-[0.5rem] p-[0.6rem] text-[1.3rem] text-[#666];
}
.card-border {
@apply border border-[#dbe2e6] bg-[rgba(255,255,255,0.6)] shadow-blur;
}
.card-header {
@apply -mx-[0.6rem] flex items-center justify-between border-b border-b-[#e1e7eb] p-4;
}
.card-footer {
@apply -mx-[0.6rem] mt-2 flex items-center justify-center border-t border-t-[#e1e7eb] py-4;
}
[data-theme="black"] {
.card-border {
@apply border-[#3f4042] bg-[rgba(255,255,255,0.065)];
}
.card-header {
@apply border-b-[#3f4042];
}
}
input[type="file"] {
@apply w-[200px] h-[40px] px-3 py-2 border border-[#d1d5db] rounded-md bg-white text-sm cursor-pointer;
}
}
@@ -0,0 +1,29 @@
@tailwind components;
@layer components {
.card {
@apply relative m-[0.6rem] box-border min-h-[2rem] rounded-[0.5rem] p-[0.6rem] text-[1.3rem] text-[#666];
}
.card-border {
@apply border border-[#dbe2e6] bg-[rgba(255,255,255,0.6)] shadow-blur;
}
.card-header {
@apply -mx-[0.6rem] flex items-center justify-between border-b border-b-[#e1e7eb] p-4;
}
.card-footer {
@apply -mx-[0.6rem] mt-2 flex items-center justify-center border-t border-t-[#e1e7eb] py-4;
}
[data-theme="blue"] {
.card-border {
@apply border-[#3f4042] bg-[rgba(255,255,255,0.065)];
}
.card-header {
@apply border-b-[#3f4042];
}
}
input[type="file"] {
@apply w-[200px] h-[40px] px-3 py-2 border border-[#d1d5db] rounded-md bg-white text-sm cursor-pointer;
}
}
@@ -0,0 +1,29 @@
@tailwind components;
@layer components {
.card {
@apply relative m-[0.6rem] box-border min-h-[2rem] rounded-[0.5rem] p-[0.6rem] text-[1.3rem] text-[#666];
}
.card-border {
@apply border border-[#dbe2e6] bg-[rgba(255,255,255,0.6)] shadow-blur;
}
.card-header {
@apply -mx-[0.6rem] flex items-center justify-between border-b border-b-[#e1e7eb] p-4;
}
.card-footer {
@apply -mx-[0.6rem] mt-2 flex items-center justify-center border-t border-t-[#e1e7eb] py-4;
}
[data-theme="green"] {
.card-border {
@apply border-[#3f4042] bg-[rgba(255,255,255,0.065)];
}
.card-header {
@apply border-b-[#3f4042];
}
}
input[type="file"] {
@apply w-[200px] h-[40px] px-3 py-2 border border-[#d1d5db] rounded-md bg-white text-sm cursor-pointer;
}
}
+29
View File
@@ -0,0 +1,29 @@
@tailwind components;
@layer components {
.card {
@apply relative m-[0.6rem] box-border min-h-[2rem] rounded-[0.5rem] p-[0.6rem] text-[1.3rem] text-[#666];
}
.card-border {
@apply border border-[#dbe2e6] bg-[rgba(255,255,255,0.6)] shadow-blur;
}
.card-header {
@apply -mx-[0.6rem] flex items-center justify-between border-b border-b-[#e1e7eb] p-4;
}
.card-footer {
@apply -mx-[0.6rem] mt-2 flex items-center justify-center border-t border-t-[#e1e7eb] py-4;
}
[data-theme="red"] {
.card-border {
@apply border-[#3f4042] bg-[rgba(255,255,255,0.065)];
}
.card-header {
@apply border-b-[#3f4042];
}
}
input[type="file"] {
@apply w-[200px] h-[40px] px-3 py-2 border border-[#d1d5db] rounded-md bg-white text-sm cursor-pointer;
}
}
-9
View File
@@ -14,15 +14,6 @@
@apply -mx-[0.6rem] mt-2 flex items-center justify-center border-t border-t-[#e1e7eb] py-4;
}
[data-theme="black"] {
.card-border {
@apply border-[#3f4042] bg-[rgba(255,255,255,0.065)];
}
.card-header {
@apply border-b-[#3f4042];
}
}
input[type="file"] {
@apply w-[200px] h-[40px] px-3 py-2 border border-[#d1d5db] rounded-md bg-white text-sm cursor-pointer;
}
+2 -1
View File
@@ -4,4 +4,5 @@ NEXT_PUBLIC_PROXY_HOST=http://localhost:3001
SERVER_API_URL=http://192.168.0.102:8000
NEXT_PUBLIC_SITE_ID=ST
NEXT_PUBLIC_SUB_DOMAIN=http://localhost:3000
NEXT_PUBLIC_TINY_API_KEY=t9nc4z6inquvvd5pq1vea8wcp7lb01qmqseh6egms9vpaju2
NEXT_PUBLIC_TINY_API_KEY=t9nc4z6inquvvd5pq1vea8wcp7lb01qmqseh6egms9vpaju2
NEXT_PUBLIC_ST_DASHBOARD_BBS_ID=13
+20 -170
View File
@@ -1,54 +1,32 @@
"use client";
import { Suspense, useCallback, useEffect, useMemo, useState } from "react";
import { usePathname } from "next/navigation";
import { usePathname, useRouter } 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 { isBbsNoticeMenu } from "@/shared/lib/bbs-notice-menu";
import {
menuMatchesStatisticsTabId,
resolveStatisticsTabId,
} from "@/shared/lib/statistics-menu-url";
import { useStatisticsTabUrlSync } from "@/shared/lib/use-statistics-tab-url-sync";
import { isVerificationSelectionReportMenu } from "@/shared/lib/verification-selection-report";
import { StatisticsPageLayout } from "@/shared/ui/statistics-page-layout";
import { tokenManager } from "@/entities/auth";
import type { MenuItem } from "@/entities/menu";
import { MenuApi } from "@/entities/menu";
import { BaseLayout } from "@common/components";
import { getConfig } from "@common/config";
import { getTabStoreState, useTabStore } from "@/store/tab-store";
import VerificationSelectionReport from "@/components/statistics/VerificationSelectionReport";
import { getTabStoreState, useTabStore } from "@/store/tab-store";
const appConfig = getConfig();
@@ -58,42 +36,23 @@ const appSiteId = getCookie("site-id-key") ?? "";
const isLoggedIn = !!userId;
function WithBaseLayoutInner({
children,
}: {
children: React.ReactNode;
}) {
function WithBaseLayoutInner({ children }: { children: React.ReactNode }) {
const pathname = usePathname();
const router = useRouter();
const queryClient = useQueryClient();
/** 탭 추가 없이 메뉴만 눌렀을 때 패널 유지 (다른 메뉴 클릭해도 유지) */
const [isVerificationSelectionReportPanelOpen, setReportPanelOpen] =
useState(false);
const setCurrentMenuInfo = useMenuStore(
(state) => state.setCurrentMenuInfo,
);
const currentMenuInfo = useMenuStore((state) => state.currentMenuInfo);
const addTab = useTabStore((state) => state.addTab);
const setActiveTab = useTabStore((state) => state.setActiveTab);
@@ -108,16 +67,11 @@ function WithBaseLayoutInner({
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 { syncTabToUrl } = useStatisticsTabUrlSync({
pathname,
menuList,
@@ -129,41 +83,27 @@ function WithBaseLayoutInner({
addTabForGrid,
setCurrentMenuInfo,
});
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;
@@ -172,20 +112,13 @@ function WithBaseLayoutInner({
if (getGridTabStoreState().tabs.length > 0) return;
for (const tab of appTabs) {
getGridTabStoreState().addTab(tab.menuItem);
}
const activeId = getTabStoreState().activeTabId;
if (activeId) {
setActiveTab(activeId);
const activeTab = appTabs.find((t) => t.id === activeId);
@@ -193,155 +126,104 @@ function WithBaseLayoutInner({
const gridActiveId = activeTab?.menuItem.menuEngNm;
if (gridActiveId) {
getGridTabStoreState().setActiveTab(gridActiveId);
}
}
}, [menuList, tabs, setActiveTab]);
// 활성 탭의 메뉴를 찾아서 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) {
if (menuMatchesStatisticsTabId(item, 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) => {
if (item.urlPath.includes("/cms/")) return;
setCurrentMenuInfo(item);
if (isVerificationSelectionReportMenu(item)) {
setReportPanelOpen(true);
return;
}
if (isBbsNoticeMenu(item)) {
if (item.urlPath) {
router.push(item.urlPath);
}
return;
}
const addTabResult: boolean = addTab(item);
// packages/rsone의 스토어에도 동기화
if (addTabResult) {
addTabForGrid(item);
if (item.urlPath) {
syncTabToUrl(item.urlPath);
syncTabToUrl(resolveStatisticsTabId(item.urlPath));
}
}
if (!addTabResult) {
alert(`최대 추가 할 수 있는 화면 수(10개)를 \n초과 하였습니다.
기존 화면을 닫은 후 이용해주세요.`);
}
},
[
setCurrentMenuInfo,
addTab,
@@ -354,70 +236,38 @@ function WithBaseLayoutInner({
syncTabToUrl,
router,
],
);
return (
<BaseLayout
queryClient={queryClient}
tokenManager={tokenManager}
currentPath={currentPath}
handleMenuClick={handleMenuClick}
>
<div className="flex flex-1 h-full">
<div className="flex h-full flex-1">
{isVerificationSelectionReportPanelOpen && (
<VerificationSelectionReport
onClose={() => setReportPanelOpen(false)}
/>
)}
<StatisticsPageLayout>{children}</StatisticsPageLayout>
</div>
</BaseLayout>
);
}
export default function WithBaseLayout({
children,
}: {
children: React.ReactNode;
}) {
return (
<Suspense fallback={null}>
<WithBaseLayoutInner>{children}</WithBaseLayoutInner>
</Suspense>
);
}
+1 -1
View File
@@ -6,7 +6,7 @@
}
/* 셀 클래스가 있으면 위 기본색보다 우선 (같은 specificity + 나중 규칙) */
.rg-grid .rg-body .rg-table .rg-data-row .rg-data-cell.rg-cell-readonly {
@apply !bg-[#f9f8f8] !text-[#666];
@apply !bg-[#f0f0f0] !text-[#666];
}
.rg-grid .rg-body .rg-table .rg-data-row .rg-data-cell.rg-cell-verify-error {
@apply !bg-[#ffe6e6] !text-[#c00];
@@ -43,8 +43,10 @@ function buildFieldsFromColumns(columns: { fieldName: string; valueType?: ValueT
const hasNumeratorDenominator = "numeratorFields" in col.computed && col.computed.numeratorFields.length > 0 && col.computed.denominatorFields.length > 0;
const hasMaxOfFieldAndSum = "baseField" in col.computed && "sumFields" in col.computed && col.computed.sumFields.length > 0;
const hasDifferenceOfSums = "minuendFields" in col.computed && "subtrahendFields" in col.computed && col.computed.minuendFields.length > 0;
const hasHalfSumOfSums = col.computed.type === "halfSumOfSums" && "firstSumFields" in col.computed && "secondSumFields" in col.computed && col.computed.firstSumFields.length > 0 && col.computed.secondSumFields.length > 0;
const hasSumOfTwoHalfSumOfSums = col.computed.type === "sumOfTwoHalfSumOfSums" && "firstHalfSum" in col.computed && "secondHalfSum" in col.computed && col.computed.firstHalfSum.firstSumFields.length > 0 && col.computed.firstHalfSum.secondSumFields.length > 0 && col.computed.secondHalfSum.firstSumFields.length > 0 && col.computed.secondHalfSum.secondSumFields.length > 0;
const hasSumOfMaxGroups = "groups" in col.computed && Array.isArray(col.computed.groups) && col.computed.groups.length > 0;
if (hasFields || hasNumeratorDenominator || hasMaxOfFieldAndSum || hasDifferenceOfSums || hasSumOfMaxGroups) {
if (hasFields || hasNumeratorDenominator || hasMaxOfFieldAndSum || hasDifferenceOfSums || hasHalfSumOfSums || hasSumOfTwoHalfSumOfSums || hasSumOfMaxGroups) {
field.valueCallback = getComputedValueCallback(col.computed) as (prod: unknown, dataRow: number, fieldName: string, fieldNames: string[], values: unknown[]) => number;
}
}
+188 -76
View File
@@ -21,6 +21,7 @@ import type {
import { getComputedValueCallback } from "@/components/util/grid/computedFieldCallbacks";
import {
getExcelImportColumnFieldNames,
prepareGridColumnsForExcelExport,
restoreGridColumnsAfterExcelExport,
shouldPrepareColumnsForExcelExport,
@@ -33,7 +34,7 @@ declare module "realgrid" {
setOptions(options: {
footer?: { visible?: boolean };
summaryMode?: "aggregate" | "statistical" | "none";
checkBar?: { visible?: boolean };
checkBar?: { visible?: boolean; exclusive?: boolean };
}): void;
}
}
@@ -457,10 +458,31 @@ function sortRowsGroupPreserving(
}
/** columnInfo → RealGrid DataField 배열 (computed → valueCallback 포함, setFields용) */
function getDecimalPlacesFromDataFormat(dataFormat?: string): number | null {
if (!dataFormat) return null;
const match = dataFormat.match(/\.(0+|#+)/);
const decimalToken = match?.[1];
if (decimalToken) return decimalToken.length;
// #,##0 등 정수 포맷 — 소수부 없음 → 0자리 절삭(내림)
if (/[#0]/.test(dataFormat)) return 0;
return null;
}
function truncateByDecimalPlaces(value: number, decimalPlaces: number): number {
if (!Number.isFinite(value)) return 0;
if (decimalPlaces < 0) return value;
const factor = 10 ** decimalPlaces;
const scaled = value * factor;
// 절삭(truncate): 반올림 없이 소수점 이하를 버린다.
const truncated = scaled >= 0 ? Math.floor(scaled) : Math.ceil(scaled);
return truncated / factor;
}
function buildFieldsFromColumns(
columns: {
fieldName: string;
valueType?: ValueType;
dataFormat?: string;
datetimeFormat?: string;
editor?: { type?: string; datetimeFormat?: string };
computed?: import("../type/DataGrid.type").ComputedFieldConfig;
@@ -508,18 +530,52 @@ function buildFieldsFromColumns(
"minuendFields" in col.computed &&
"subtrahendFields" in col.computed &&
col.computed.minuendFields.length > 0;
const hasHalfSumOfSums =
col.computed.type === "halfSumOfSums" &&
"firstSumFields" in col.computed &&
"secondSumFields" in col.computed &&
col.computed.firstSumFields.length > 0 &&
col.computed.secondSumFields.length > 0;
const hasSumOfTwoHalfSumOfSums =
col.computed.type === "sumOfTwoHalfSumOfSums" &&
"firstHalfSum" in col.computed &&
"secondHalfSum" in col.computed &&
col.computed.firstHalfSum.firstSumFields.length > 0 &&
col.computed.firstHalfSum.secondSumFields.length > 0 &&
col.computed.secondHalfSum.firstSumFields.length > 0 &&
col.computed.secondHalfSum.secondSumFields.length > 0;
const hasSumOfMaxGroups =
col.computed.type === "sumOfMaxOfFieldAndSumGroups" &&
"groups" in col.computed &&
Array.isArray(col.computed.groups) &&
col.computed.groups.length > 0;
const hasPumpGrossPpqty =
col.computed.type === "pumpGrossPpqty" &&
"pumpGroups" in col.computed &&
col.computed.pumpGroups.length > 0;
const hasBodPrcsRt =
col.computed.type === "bodPrcsRt" &&
"inflwField" in col.computed &&
"dschgField" in col.computed;
const hasQuotientRatioPercent =
col.computed.type === "quotientRatioPercent" &&
"numeratorQuotient" in col.computed &&
"denominatorQuotient" in col.computed &&
col.computed.numeratorQuotient.length === 2 &&
col.computed.denominatorQuotient.length === 2;
if (
hasFields ||
hasNumeratorDenominator ||
hasMaxOfFieldAndSum ||
hasDifferenceOfSums ||
hasSumOfMaxGroups
hasHalfSumOfSums ||
hasSumOfTwoHalfSumOfSums ||
hasSumOfMaxGroups ||
hasPumpGrossPpqty ||
hasBodPrcsRt ||
hasQuotientRatioPercent
) {
field.valueCallback = getComputedValueCallback(
const computedCallback = getComputedValueCallback(
col.computed,
) as (
prod: unknown,
@@ -528,6 +584,24 @@ function buildFieldsFromColumns(
fieldNames: string[],
values: unknown[],
) => number;
const decimalPlaces = getDecimalPlacesFromDataFormat(col.dataFormat);
field.valueCallback = (
prod: unknown,
dataRow: number,
fieldName: string,
fieldNames: string[],
values: unknown[],
) => {
const computedValue = computedCallback(
prod,
dataRow,
fieldName,
fieldNames,
values,
);
if (decimalPlaces == null) return computedValue;
return truncateByDecimalPlaces(computedValue, decimalPlaces);
};
}
}
return field;
@@ -586,6 +660,7 @@ const DataGrid = ({
const onItemAllCheckedRef =
useRef<typeof onItemAllChecked>(onItemAllChecked);
const isSyncingChecksFromRowDataRef = useRef(false);
const columnsFieldsSigRef = useRef("");
columnsRef.current = columns;
rowDataRef.current = rowData;
cellStyleCallbackRef.current = cellStyleCallback;
@@ -638,20 +713,6 @@ const DataGrid = ({
.find((s) => s.menuEngNm === activeTabId)?.menuKornNm ||
"export";
// 엑셀 import 시 매칭을 위해 헤더에 fieldName 포함 (예: "합계 (swgeProcZoneInnerSm)")
const originalHeaders: Map<string, string> = new Map();
columns.forEach((col) => {
if (col.fieldName && gridView.columnByName) {
const gridCol = gridView.columnByName(col.name);
if (gridCol && gridCol.header) {
const originalText = gridCol.header.text || "";
originalHeaders.set(col.name, originalText);
const headerWithFieldName = `${originalText} (${col.fieldName})`;
gridCol.header.text = headerWithFieldName;
}
}
});
// exportGrid 호출 전에 columnLayout 정규화 (col 속성 추가)
const normalizeLayoutForExport = (
layout: ColumnLayoutItem[],
@@ -745,20 +806,6 @@ const DataGrid = ({
excelExportVisibilitySnapshot,
);
}
// 헤더 원복
columns.forEach((col) => {
if (col.fieldName && gridView.columnByName) {
const gridCol = gridView.columnByName(col.name);
if (
gridCol &&
gridCol.header &&
originalHeaders.has(col.name)
) {
gridCol.header.text =
originalHeaders.get(col.name) || "";
}
}
});
// columnLayout 원복 (footerUserSpans 유지)
if (normalizedLayout && !layoutRestored) {
layoutRestored = true;
@@ -860,36 +907,11 @@ const DataGrid = ({
: headerStartRow;
const dataStartRow = maxHeaderRowIndex + 1;
// 엑셀 컬럼 인덱스 → 그리드 fieldName 매핑
const headerToFieldNameMap = new Map<string, string>();
headers.forEach((headerText, colIndex) => {
let fieldName: string | null = null;
// 헤더에 fieldName이 포함된 경우 (예: "합계 (swgeProcZoneInnerSm)")
const fieldNameMatch = headerText.match(
/\(([a-zA-Z_][a-zA-Z0-9_]*)\)$/,
);
if (fieldNameMatch && fieldNameMatch[1]) {
const extractedFieldName = fieldNameMatch[1];
if (
columns.find(
(c) => c.fieldName === extractedFieldName,
)
) {
fieldName = extractedFieldName;
}
}
// 매칭 실패 시 원본 헤더 텍스트 사용
if (!fieldName) {
console.warn(
`엑셀 컬럼 ${colIndex + 1} (헤더: "${headerText}") 매칭 실패 → 헤더 텍스트를 fieldName으로 사용`,
);
fieldName = headerText;
}
headerToFieldNameMap.set(headerText, fieldName);
});
// 엑셀 N번째 열 → 그리드 N번째 fieldName (보내기 컬럼 순서 기준)
const excelColumnFieldNames = getExcelImportColumnFieldNames(
columns,
columnLayout,
);
// 데이터 행 읽기
const dataRows: Record<string, string | number>[] = [];
@@ -907,12 +929,8 @@ const DataGrid = ({
worksheet[
XLSX.utils.encode_cell({ r: row, c: col })
];
const headerName =
headers[colIndex] || `Column${colIndex + 1}`;
// 헤더 텍스트를 fieldName으로 변환
const fieldName =
headerToFieldNameMap.get(headerName) ||
headerName;
const fieldName = excelColumnFieldNames[colIndex];
if (!fieldName) continue;
let cellValue: string | number = "";
if (
@@ -996,6 +1014,21 @@ const DataGrid = ({
const dataProvider = gridRef.current.dataProvider;
applyDefaultGridEditOptions(gridView as GridViewWithEditOptions);
const columnsFieldsSig = columns.map((c) => c.fieldName).join("\u0001");
if (columnsFieldsSig !== columnsFieldsSigRef.current) {
columnsFieldsSigRef.current = columnsFieldsSig;
const dpForFields = dataProvider as {
setFields?: (f: unknown[]) => void;
setRows?: (r: Record<string, string | number>[]) => void;
};
try {
dpForFields.setFields?.(buildFieldsFromColumns(columns));
dpForFields.setRows?.(rowDataRef.current);
} catch (e) {
console.warn("setFields sync:", e);
}
}
// mergeCells: true인 컬럼이 있으면 onSorting에서 클라이언트 정렬로 처리
const hasMergeCells = columns.some((col) => col.mergeCells === true);
@@ -1046,6 +1079,14 @@ const DataGrid = ({
}
columns.forEach((column) => {
const gv = gridView as GridViewWithSetColumnProperty;
const exists =
typeof (
gridView as { columnByName?: (name: string) => unknown }
).columnByName === "function" &&
(gridView as { columnByName: (name: string) => unknown }).columnByName(
column.name,
) != null;
if (!exists) return;
gv.setColumnProperty?.(
column.name,
"header",
@@ -1054,6 +1095,11 @@ const DataGrid = ({
tooltip: resolveColumnTooltip(column),
}),
);
gv.setColumnProperty?.(
column.name,
"editable",
column.editable !== false,
);
applyColumnListEditorProperties(gv, column);
});
if (columns.some((c) => isDropDownEditorColumn(c))) {
@@ -1101,7 +1147,7 @@ const DataGrid = ({
// 헤더 체크박스 표시 여부 설정
gridView.setOptions({
checkBar: { visible: showHeaderCheckbox },
checkBar: { visible: showHeaderCheckbox, exclusive: false },
rowIndicator: { visible: false },
});
@@ -1181,10 +1227,63 @@ const DataGrid = ({
const hasFooterUserSpans = checkFooterUserSpans(columnLayout);
if (hasFooterUserSpans) {
const validColumnNames = new Set(columns.map((c) => c.name));
const sanitizeColumnLayout = (
layout: unknown[],
): unknown[] => {
const result: unknown[] = [];
for (const item of layout) {
if (typeof item === "string") {
if (validColumnNames.has(item)) {
result.push(item);
}
continue;
}
if (!item || typeof item !== "object") continue;
if ("column" in item) {
const columnName = (item as { column?: unknown }).column;
if (
typeof columnName === "string" &&
validColumnNames.has(columnName)
) {
result.push(item);
}
continue;
}
if ("items" in item && Array.isArray((item as { items?: unknown[] }).items)) {
const nextItems = sanitizeColumnLayout(
(item as { items: unknown[] }).items,
);
if (nextItems.length > 0) {
result.push({
...(item as Record<string, unknown>),
items: nextItems,
});
}
continue;
}
result.push(item);
}
return result;
};
// setColumnLayout은 그리드가 완전히 렌더링된 후 호출해야 함
setTimeout(() => {
if (gridRef.current?.gridView && columnLayout) {
gridRef.current.gridView.setColumnLayout?.(columnLayout);
const safeLayout = sanitizeColumnLayout(columnLayout as unknown[]);
if (safeLayout.length === 0) return;
try {
(
gridRef.current.gridView.setColumnLayout as
| ((layout: unknown) => void)
| undefined
)?.(safeLayout);
} catch (e) {
console.warn("setColumnLayout skipped due to invalid columns:", e);
}
}
}, 100);
}
@@ -1654,6 +1753,22 @@ const DataGrid = ({
layout={columnLayout}
rows={rowData}
onRGDataColumnsChanged={(grid) => {
const dp = gridRef.current?.dataProvider as
| {
setFields?: (f: unknown[]) => void;
setRows?: (
r: Record<string, string | number>[],
) => void;
}
| undefined;
try {
dp?.setFields?.(
buildFieldsFromColumns(columnsRef.current),
);
dp?.setRows?.(rowDataRef.current);
} catch (e) {
console.warn("onRGDataColumnsChanged setFields:", e);
}
reapplyListEditorsOnGrid(
grid as GridViewWithSetColumnProperty,
columnsRef.current,
@@ -1693,6 +1808,9 @@ const DataGrid = ({
dataProvider.setFields(
buildFieldsFromColumns(columns),
);
columnsFieldsSigRef.current = columns
.map((c) => c.fieldName)
.join("\u0001");
}
if (dataProvider?.setRows)
dataProvider.setRows(rowData);
@@ -1874,15 +1992,9 @@ const DataGrid = ({
{columns.map((column) => {
const baseStyleName = column.styleName;
const columnReactKey =
column.values?.length &&
isDropDownEditorColumn(column)
? `${column.name}-dropdown-${column.values.length}`
: column.name;
return (
<RGDataColumn
key={columnReactKey}
key={column.name}
name={column.name}
fieldName={column.fieldName}
valueType={
@@ -33,7 +33,7 @@ import DataGrid from "@/components/module/DataGrid";
import { VerificationResultHistoryPagination } from "@/components/module/VerificationResultHistoryPagination";
import type { CellStyleCallbackParams, ColumnInfo } from "@/components/type/DataGrid.type";
/** 통계 입력 현황 > 행 클릭 시 예외처리(prcsYn=3) 내역만 조회 */
/** 통계 입력 현황 > 행 클릭 시 사용자예외처리(prcsYn=2) 내역만 조회 */
export interface ExceptionProcessingHistoryQueryContext {
sewerBzmnCd: string;
ctpvNm: string;
@@ -92,13 +92,15 @@ function exceptionPrcsYnNmCellStyle({ fieldName, row }: CellStyleCallbackParams)
const exceptionHistoryColumnInfo: ColumnInfo = {
columns: [
{ name: "ctpvNm", fieldName: "ctpvNm", header: { text: "시도" }, editable: false },
{ name: "sggNm", fieldName: "sggNm", header: { text: "시군구" }, editable: false },
{ name: "admdstNm", fieldName: "admdstNm", header: { text: "행정구역" }, editable: false },
{ name: "vrfcTrgtNm", fieldName: "vrfcTrgtNm", header: { text: "검증대상" }, editable: false },
{ name: "rsnExpln", fieldName: "rsnExpln", header: { text: "사유" }, editable: false },
{ name: "nowVlCn", fieldName: "nowVlCn", header: { text: "입력값" }, editable: false },
{ name: "compVlCn", fieldName: "compVlCn", header: { text: "기준값" }, editable: false },
{ name: "prcsYnNm", fieldName: "prcsYnNm", header: { text: "처리여부" }, editable: false },
{ name: "pbcpAprvYn", fieldName: "pbcpAprvYn", header: { text: "공단승인여부" }, editable: false },
{ name: "stVrfRjtComNm", fieldName: "stVrfRjtComNm", header: { text: "반려상태" }, editable: false },
{ name: "prcsOpnnExpln", fieldName: "prcsOpnnExpln", header: { text: "처리의견" }, editable: false },
{ name: "rjctOpnnCn", fieldName: "rjctOpnnCn", header: { text: "반려의견" }, editable: false },
{ name: "regDt", fieldName: "regDt", header: { text: "등록일시" }, editable: false },
{ name: "mdfcnDt", fieldName: "mdfcnDt", header: { text: "갱신일시" }, editable: false },
{
@@ -114,13 +116,15 @@ const exceptionHistoryColumnInfo: ColumnInfo = {
],
columnLayout: [
"ctpvNm",
"sggNm",
"admdstNm",
"vrfcTrgtNm",
"rsnExpln",
"nowVlCn",
"compVlCn",
"prcsYnNm",
"pbcpAprvYn",
"stVrfRjtComNm",
"prcsOpnnExpln",
"rjctOpnnCn",
"regDt",
"mdfcnDt",
"individualReject",
@@ -190,9 +194,9 @@ export function ExceptionProcessingHistoryDialog({
sggNm: queryContext.sggNm,
statsYr: queryContext.statsYr,
statsCd: queryContext.statsCd,
pbcpAprvYn: "N",
vrfcMthdCd,
prcsYnList: PRCS_YN_LIST,
rejectCodeList: "0,1,2",
page: pageIndex + 1,
size: 30,
};
@@ -308,14 +312,16 @@ export function ExceptionProcessingHistoryDialog({
const mapRows = (entries: VerificationResultReportItem[]): ExceptionHistoryGridRow[] =>
entries.map((item) => ({
ctpvNm: item.ctpvNm,
sggNm: item.sggNm,
admdstNm: item.admdstNm ?? item.sggNm ?? "-",
vrfcTrgtNm: item.vrfcTrgtNm,
rsnExpln: item.rsnExpln,
nowVlCn: item.nowVlCn ?? "-",
compVlCn: item.compVlCn ?? "-",
prcsYnNm: item.prcsYnNm ?? "-",
stVrfRjtComNm: item.stVrfRjtComNm ?? "-",
prcsOpnnExpln: item.prcsOpnnExpln ?? "-",
rjctOpnnCn: item.rjctOpnnCn ?? "-",
prcsYn: item.prcsYn ?? "",
pbcpAprvYn: item.pbcpAprvYn,
regDt: (item.regDt ?? "").slice(0, 16),
mdfcnDt: (item.mdfcnDt ?? "").slice(0, 16),
individualReject: "개별 반려",
+1 -1
View File
@@ -41,7 +41,7 @@ const getSggListFetcher = async (
key: [string, string, string, string],
): Promise<ApiResponse<Sgg[]>> => {
const gugunSkipYn = doList.includes(key[3]) ? "N" : "Y";
return statisticsApi.getSggAllList(key[1], {
return statisticsApi.getSggList(key[1], {
instSeCd: key[2],
gugunSkipYn,
});
@@ -116,7 +116,7 @@ const getSidoListFetcher = async (): Promise<ApiResponse<SearchCondition>> => {
const getSggAllListFetcher = async (
key: [string, string],
): Promise<ApiResponse<Sgg[]>> => {
return statisticsApi.getSggAllList(key[1]);
return statisticsApi.getSggList(key[1]);
};
type SidoSggFacilityNameSearchFormProps = {
@@ -292,17 +292,12 @@ const SidoSggFacilityNameSearchForm = ({
return;
}
const ctpvNm = String(row.ctpvNm ?? sido);
const sggNm = String(
row.sggNm ?? (gugun !== "ALL" && gugun ? gugun : ""),
);
const params: SeweragePipeFacilityAdminAreaListRequest = {
statsYr: statsYr.trim(),
swtrmtFcltCd,
sewerBzmnCd: initialSearchValues?.bsnmCd || '',
ctpvNm,
...(doNameList.includes(ctpvNm) ? { sggNm } : {}),
ctpvNm: initialSidoValue || '',
...(doNameList.includes(initialSidoValue) ? { sggNm: initialGugunValue } : {}),
};
try {
+11 -49
View File
@@ -17,6 +17,7 @@ import SplitDataGrid from "./SplitDataGrid";
import { getComputedValueCallback } from "@/components/util/grid/computedFieldCallbacks"
import {
getExcelImportColumnFieldNames,
prepareGridColumnsForExcelExport,
restoreGridColumnsAfterExcelExport,
shouldPrepareColumnsForExcelExport,
@@ -222,8 +223,10 @@ function buildFieldsFromColumns(columns: { fieldName: string; valueType?: ValueT
const hasNumeratorDenominator = "numeratorFields" in col.computed && col.computed.numeratorFields.length > 0 && col.computed.denominatorFields.length > 0;
const hasMaxOfFieldAndSum = "baseField" in col.computed && "sumFields" in col.computed && col.computed.sumFields.length > 0;
const hasDifferenceOfSums = "minuendFields" in col.computed && "subtrahendFields" in col.computed && col.computed.minuendFields.length > 0;
const hasHalfSumOfSums = col.computed.type === "halfSumOfSums" && "firstSumFields" in col.computed && "secondSumFields" in col.computed && col.computed.firstSumFields.length > 0 && col.computed.secondSumFields.length > 0;
const hasSumOfTwoHalfSumOfSums = col.computed.type === "sumOfTwoHalfSumOfSums" && "firstHalfSum" in col.computed && "secondHalfSum" in col.computed && col.computed.firstHalfSum.firstSumFields.length > 0 && col.computed.firstHalfSum.secondSumFields.length > 0 && col.computed.secondHalfSum.firstSumFields.length > 0 && col.computed.secondHalfSum.secondSumFields.length > 0;
const hasSumOfMaxGroups = "groups" in col.computed && Array.isArray(col.computed.groups) && col.computed.groups.length > 0;
if (hasFields || hasNumeratorDenominator || hasMaxOfFieldAndSum || hasDifferenceOfSums || hasSumOfMaxGroups) {
if (hasFields || hasNumeratorDenominator || hasMaxOfFieldAndSum || hasDifferenceOfSums || hasHalfSumOfSums || hasSumOfTwoHalfSumOfSums || hasSumOfMaxGroups) {
field.valueCallback = getComputedValueCallback(col.computed) as (prod: unknown, dataRow: number, fieldName: string, fieldNames: string[], values: unknown[]) => number;
}
}
@@ -373,20 +376,6 @@ const TabDataGrid = ({
const columns = columnInfoItem.columns || [];
const columnLayout = columnInfoItem.columnLayout || [];
// 엑셀 import 시 매칭을 위해 헤더에 fieldName 포함 (예: "합계 (swgeProcZoneInnerSm)")
const originalHeaders: Map<string, string> = new Map();
columns.forEach((col) => {
if (col.fieldName && gridView.columnByName) {
const gridCol = gridView.columnByName(col.name);
if (gridCol && gridCol.header) {
const originalText = gridCol.header.text || '';
originalHeaders.set(col.name, originalText);
const headerWithFieldName = `${originalText} (${col.fieldName})`;
gridCol.header.text = headerWithFieldName;
}
}
});
// exportGrid 호출 전에 columnLayout 정규화 (col 속성 추가)
const normalizeLayoutForExport = (layout: ColumnLayoutItem[]): ColumnLayoutItem[] => {
return layout.map(item => {
@@ -452,15 +441,6 @@ const TabDataGrid = ({
if (excelExportVisibilitySnapshot) {
restoreGridColumnsAfterExcelExport(gridView, excelExportVisibilitySnapshot);
}
// 헤더 원복
columns.forEach((col) => {
if (col.fieldName && gridView.columnByName) {
const gridCol = gridView.columnByName(col.name);
if (gridCol && gridCol.header && originalHeaders.has(col.name)) {
gridCol.header.text = originalHeaders.get(col.name) || '';
}
}
});
// columnLayout 원복 (footerUserSpans 유지)
if (normalizedLayout && !layoutRestored) {
layoutRestored = true;
@@ -546,29 +526,12 @@ const TabDataGrid = ({
const maxHeaderRowIndex = headerRowIndexes.length > 0 ? Math.max(...headerRowIndexes) : headerStartRow;
const dataStartRow = maxHeaderRowIndex + 1;
// 엑셀 컬럼 인덱스 → 그리드 fieldName 매핑
const headerToFieldNameMap = new Map<string, string>();
const columns = columnInfoItem.columns || [];
headers.forEach((headerText, colIndex) => {
let fieldName: string | null = null;
// 헤더에 fieldName이 포함된 경우 (예: "합계 (swgeProcZoneInnerSm)")
const fieldNameMatch = headerText.match(/\(([a-zA-Z_][a-zA-Z0-9_]*)\)$/);
if (fieldNameMatch && fieldNameMatch[1]) {
const extractedFieldName = fieldNameMatch[1];
if (columns.find(c => c.fieldName === extractedFieldName)) {
fieldName = extractedFieldName;
}
}
// 매칭 실패 시 원본 헤더 텍스트 사용
if (!fieldName) {
console.warn(`엑셀 컬럼 ${colIndex + 1} (헤더: "${headerText}") 매칭 실패 → 헤더 텍스트를 fieldName으로 사용`);
fieldName = headerText;
}
headerToFieldNameMap.set(headerText, fieldName);
});
const columnLayout = columnInfoItem.columnLayout || [];
const excelColumnFieldNames = getExcelImportColumnFieldNames(
columns,
columnLayout,
);
// 데이터 행 읽기
const dataRows: Record<string, string | number>[] = [];
@@ -579,9 +542,8 @@ const TabDataGrid = ({
for (let colIndex = 0; colIndex < headers.length; colIndex++) {
const col = range.s.c + colIndex;
const cell = worksheet[XLSX.utils.encode_cell({ r: row, c: col })];
const headerName = headers[colIndex] || `Column${colIndex + 1}`;
// 헤더 텍스트를 fieldName으로 변환
const fieldName = headerToFieldNameMap.get(headerName) || headerName;
const fieldName = excelColumnFieldNames[colIndex];
if (!fieldName) continue;
let cellValue: string | number = '';
if (cell && cell.v !== undefined && cell.v !== null) {
@@ -92,13 +92,12 @@ const verificationSubmitResultColumnInfo: ColumnInfo = {
{ name: "vrfcMthdNm", fieldName: "vrfcMthdNm", header: { text: "구분" }, editable: false },
{ name: "vrfcMthdCd", fieldName: "vrfcMthdCd", header: { text: "검증유형코드" }, editable: false, visible: false },
{ name: "ctpvNm", fieldName: "ctpvNm", header: { text: "시도" }, editable: false },
{ name: "sggNm", fieldName: "sggNm", header: { text: "시군구" }, editable: false },
{ name: "admdstNm", fieldName: "admdstNm", header: { text: "행정구역" }, editable: false },
{ name: "vrfcTrgtNm", fieldName: "vrfcTrgtNm", header: { text: "검증대상" }, editable: false },
{ name: "rsnExpln", fieldName: "rsnExpln", header: { text: "사유" }, editable: false },
{ name: "nowVlCn", fieldName: "nowVlCn", header: { text: "입력값" }, editable: false },
{ name: "compVlCn", fieldName: "compVlCn", header: { text: "기준값" }, editable: false },
{ name: "prcsYn", fieldName: "prcsYn", header: { text: "처리여부" }, editable: false },
{ name: "pbcpAprvYn", fieldName: "pbcpAprvYn", header: { text: "공단승인여부" }, editable: false },
{ name: "regDt", fieldName: "regDt", header: { text: "등록일시" }, editable: false },
{ name: "mdfcnDt", fieldName: "mdfcnDt", header: { text: "갱신일시" }, editable: false },
{
@@ -111,13 +110,12 @@ const verificationSubmitResultColumnInfo: ColumnInfo = {
columnLayout: [
"vrfcMthdNm",
"ctpvNm",
"sggNm",
"admdstNm",
"vrfcTrgtNm",
"rsnExpln",
"nowVlCn",
"compVlCn",
"prcsYn",
"pbcpAprvYn",
"regDt",
"mdfcnDt",
"prcsOpnnExpln",
@@ -202,13 +200,12 @@ export function VerificationResultDialog({
vrfcMthdCd,
vrfcMthdNm: VRFC_MTHD_NAME_MAP[vrfcMthdCd] ?? item.vrfcMthdNm,
ctpvNm: item.ctpvNm,
sggNm: item.sggNm,
admdstNm: item.admdstNm ?? item.sggNm ?? "-",
vrfcTrgtNm: item.vrfcTrgtNm,
rsnExpln: item.rsnExpln,
nowVlCn: item.nowVlCn ?? "-",
compVlCn: item.compVlCn ?? "-",
prcsYn: PRCS_YN_LABEL[item.prcsYn] ?? item.prcsYn,
pbcpAprvYn: item.pbcpAprvYn,
prcsYn: item.prcsYnNm ?? PRCS_YN_LABEL[item.prcsYn] ?? item.prcsYn,
regDt: (item.regDt ?? "").slice(0, 16),
mdfcnDt: (item.mdfcnDt ?? "").slice(0, 16),
prcsOpnnExpln: item.prcsOpnnExpln ?? "",
@@ -226,9 +223,9 @@ export function VerificationResultDialog({
sggNm: stableQueryContext.sggNm,
statsYr: stableQueryContext.statsYr,
statsCd: stableQueryContext.statsCd,
pbcpAprvYn: "N",
vrfcMthdCd,
prcsYnList: "0",
rejectCodeList: "0,1,2",
page: page + 1,
size: 10000,
};
@@ -137,12 +137,15 @@ function verificationPrcsYnNmCellStyle({ fieldName, row }: CellStyleCallbackPara
const verificationResultColumnInfo: ColumnInfo = {
columns: [
{ name: "ctpvNm", fieldName: "ctpvNm", header: { text: "시도" }, editable: false },
{ name: "sggNm", fieldName: "sggNm", header: { text: "시군구" }, editable: false },
{ name: "admdstNm", fieldName: "admdstNm", header: { text: "행정구역" }, editable: false },
{ name: "vrfcTrgtNm", fieldName: "vrfcTrgtNm", header: { text: "검증대상" }, editable: false },
{ name: "rsnExpln", fieldName: "rsnExpln", header: { text: "사유" }, editable: false },
{ name: "nowVlCn", fieldName: "nowVlCn", header: { text: "입력값" }, editable: false },
{ name: "compVlCn", fieldName: "compVlCn", header: { text: "기준값" }, editable: false },
{ name: "prcsYnNm", fieldName: "prcsYnNm", header: { text: "처리여부" }, editable: false },
{ name: "stVrfRjtComNm", fieldName: "stVrfRjtComNm", header: { text: "반려상태" }, editable: false },
{ name: "prcsOpnnExpln", fieldName: "prcsOpnnExpln", header: { text: "처리의견" }, editable: false },
{ name: "rjctOpnnCn", fieldName: "rjctOpnnCn", header: { text: "반려의견" }, editable: false },
{ name: "regDt", fieldName: "regDt", header: { text: "등록일시" }, editable: false },
{ name: "mdfcnDt", fieldName: "mdfcnDt", header: { text: "갱신일시" }, editable: false },
{ name: "prcsYn", fieldName: "prcsYn", header: { text: "검증결과상태코드" }, editable: false, visible: false },
@@ -150,12 +153,15 @@ const verificationResultColumnInfo: ColumnInfo = {
],
columnLayout: [
"ctpvNm",
"sggNm",
"admdstNm",
"vrfcTrgtNm",
"rsnExpln",
"nowVlCn",
"compVlCn",
"prcsYnNm",
"stVrfRjtComNm",
"prcsOpnnExpln",
"rjctOpnnCn",
"regDt",
"mdfcnDt",
"prcsYn",
@@ -175,6 +181,8 @@ export interface VerificationResultHistoryDialogProps {
} | null;
title?: string;
emptyMessage?: string;
/** 예외 제출 취소 API 성공 후 부모(입력 화면)에서 메인 데이터를 다시 조회할 때 사용 */
onExceptionResetSubmitted?: () => void | Promise<void>;
}
export function VerificationResultHistoryDialog({
@@ -183,6 +191,7 @@ export function VerificationResultHistoryDialog({
queryContext,
title = "검증 결과 내역",
emptyMessage = "표시할 검증 결과가 없습니다.",
onExceptionResetSubmitted,
}: VerificationResultHistoryDialogProps) {
const stableQueryContext = useMemo(() => {
if (!queryContext) return null;
@@ -245,9 +254,9 @@ export function VerificationResultHistoryDialog({
sggNm: stableQueryContext.sggNm,
statsYr: stableQueryContext.statsYr,
statsCd: stableQueryContext.statsCd,
pbcpAprvYn: "N",
vrfcMthdCd,
prcsYnList: "0,2,3",
prcsYnList: "0,2",
rejectCodeList: "0,1,2",
page: pageIndex + 1,
size: PAGE_SIZE,
};
@@ -376,14 +385,16 @@ export function VerificationResultHistoryDialog({
vrfcSn: item.vrfcSn,
__checked: isChecked ? 1 : 0,
ctpvNm: item.ctpvNm,
sggNm: item.sggNm,
admdstNm: item.admdstNm ?? item.sggNm ?? "-",
vrfcTrgtNm: item.vrfcTrgtNm,
rsnExpln: item.rsnExpln,
nowVlCn: item.nowVlCn ?? "-",
compVlCn: item.compVlCn ?? "-",
prcsYnNm: item.prcsYnNm ?? "-",
stVrfRjtComNm: item.stVrfRjtComNm ?? "-",
prcsOpnnExpln: item.prcsOpnnExpln ?? "-",
rjctOpnnCn: item.rjctOpnnCn ?? "-",
prcsYn: item.prcsYn ?? "",
pbcpAprvYn: item.pbcpAprvYn,
regDt: (item.regDt ?? "").slice(0, 16),
mdfcnDt: (item.mdfcnDt ?? "").slice(0, 16),
};
@@ -451,12 +462,28 @@ export function VerificationResultHistoryDialog({
alert("예외 제출 취소 처리되었습니다.");
setCheckedVrfcByTab((prev) => ({ ...prev, [tab]: new Map() }));
await loadTabPage(tab, tabPages[tab].pageIndex);
try {
await onExceptionResetSubmitted?.();
} catch {
alert(
"예외 제출 취소는 완료되었으나 화면 데이터를 다시 불러오지 못했습니다. 조회로 새로고침 해주세요.",
);
}
} catch {
alert("예외 제출 취소 처리 중 오류가 발생했습니다.");
} finally {
setResetSubmitting(false);
}
}, [activeTab, alert, checkedVrfcByTab, entriesByTab, loadTabPage, stableQueryContext, tabPages]);
}, [
activeTab,
alert,
checkedVrfcByTab,
entriesByTab,
loadTabPage,
onExceptionResetSubmitted,
stableQueryContext,
tabPages,
]);
const renderActiveTabPanel = (tab: VerificationHistoryTabKey) => {
const entries = entriesByTab[tab];
@@ -34,8 +34,8 @@ export async function hasAnyPostSubmitVerificationResult(
sggNm: ctx.sggNm,
statsYr: ctx.statsYr,
statsCd: ctx.statsCd,
pbcpAprvYn: "N" as const,
prcsYnList: "0",
rejectCodeList: "0,1,2",
page: 1,
size: 1,
};
@@ -1,6 +1,6 @@
"use client";
import { useCallback, useState } from "react";
import { useCallback, useEffect, useState } from "react";
import {
type InfiniteData,
keepPreviousData,
@@ -9,12 +9,12 @@ import {
} from "@tanstack/react-query";
import { useTabStore as useGridTabStore } from "@rsone/components";
import { statisticsApi } from "@/entities/statistics/api/statistics-service";
import { verificationInputReportApi } from "@/entities/statistics/api/verification-input-report-service";
import type {
VerificationResultListRequest,
VerificationResultListResponse,
VerificationResultReportItem,
} from "@/entities/statistics/model/statistics-interface";
VerificationInputReportItem,
VerificationInputReportResponse,
VerificationInputReportTypeCountingInfo,
} from "@/entities/statistics/model/verification-input-report-interface";
import { useAlertStore, useMenuStore } from "@/shared/lib";
import {
findMenuItemByUrlPath,
@@ -59,8 +59,8 @@ const TYPE_CODE_NAME_MAP: Record<string, string> = {
const PAGE_SIZE = 100;
const SCROLL_LOAD_THRESHOLD_PX = 120;
function getNextVerificationResultPage(
lastPage: VerificationResultListResponse,
function getNextVerificationInputReportPage(
lastPage: VerificationInputReportResponse,
): number | undefined {
const pageInfo = lastPage?.data?.pageInfo;
if (!pageInfo) return undefined;
@@ -69,19 +69,29 @@ function getNextVerificationResultPage(
return undefined;
}
function flattenVerificationResults(
data: InfiniteData<VerificationResultListResponse> | undefined,
): VerificationResultReportItem[] {
function flattenVerificationInputReportResults(
data: InfiniteData<VerificationInputReportResponse> | undefined,
): VerificationInputReportItem[] {
return (
data?.pages.flatMap((page) => page?.data?.verifyResultList ?? []) ?? []
);
}
function getVerificationResultTotalCount(
data: InfiniteData<VerificationResultListResponse> | undefined,
loadedCount: number,
): number {
return data?.pages[0]?.data?.pageInfo?.totalElements ?? loadedCount;
function getVerifyTypeCountingInfo(
data: InfiniteData<VerificationInputReportResponse> | undefined,
): VerificationInputReportTypeCountingInfo | null {
return data?.pages[0]?.data?.verifyTypeCountingInfo ?? null;
}
function formatReportItemTitle(item: VerificationInputReportItem): string {
return item.statsNm?.trim() || item.vrfcTrgtNm?.trim() || item.statsCd;
}
function formatReportItemRegion(item: VerificationInputReportItem): string {
const parts = [item.ctpvNm, item.sggNm].filter(
(v): v is string => Boolean(v?.trim()),
);
return parts.length > 0 ? parts.join(" ") : "-";
}
function useVerificationReportList(
@@ -89,28 +99,31 @@ function useVerificationReportList(
statsCdQueryKey: string,
vrfcMthdCd: VerificationReportTabKey,
statsCd: string,
enabled: boolean,
) {
return useInfiniteQuery({
queryKey: [
"verificationResultList",
"verificationInputReport",
statsYr,
vrfcMthdCd,
statsCdQueryKey,
],
initialPageParam: 1,
queryFn: ({ pageParam }) =>
statisticsApi.getVerificationResultList({
verificationInputReportApi.getVerificationInputReport({
statsYr,
pbcpAprvYn: "N",
vrfcMthdCd,
page: pageParam,
size: PAGE_SIZE,
prcsYnList: "0",
vrfcMthdCd,
...(!(statsCd === "" || statsCd === "ALL") && { statsCd }),
} satisfies VerificationResultListRequest),
getNextPageParam: getNextVerificationResultPage,
}),
getNextPageParam: getNextVerificationInputReportPage,
placeholderData: keepPreviousData,
refetchOnWindowFocus: false,
// 탭 전환만으로는 재조회하지 않음(명시적 새로고침/조건 변경 시만 조회)
staleTime: Infinity,
refetchOnMount: false,
enabled,
});
}
@@ -132,28 +145,46 @@ const VerificationSelectionReport = ({
const alert = useAlertStore((s) => s.setMessage);
const [activeTab, setActiveTab] = useState<VerificationReportTabKey>("NV");
const [statsCd, setStatsCd] = useState<string>("ALL");
const [isFullReloading, setIsFullReloading] = useState(false);
const statsYr = String(dayjs().year() - 1);
const statsCdQueryKey =
statsCd === "" || statsCd === "ALL" ? "" : statsCd;
const handleStatsCdChange = useCallback(
(nextStatsCd: string) => {
setStatsCd(nextStatsCd);
setActiveTab("NV");
setIsFullReloading(true);
// 통계항목 변경 시 모든 탭 캐시 제거 후 NV부터 다시 조회
void queryClient.removeQueries({
queryKey: ["verificationInputReport", statsYr],
});
},
[queryClient, statsYr],
);
const nvQuery = useVerificationReportList(
statsYr,
statsCdQueryKey,
"NV",
statsCd,
activeTab === "NV",
);
const imrQuery = useVerificationReportList(
statsYr,
statsCdQueryKey,
"IMR",
statsCd,
activeTab === "IMR",
);
const hbQuery = useVerificationReportList(
statsYr,
statsCdQueryKey,
"HB",
statsCd,
activeTab === "HB",
);
const queryByTab = {
@@ -162,44 +193,51 @@ const VerificationSelectionReport = ({
HB: hbQuery,
} as const;
const isInitialLoading =
nvQuery.isPending || imrQuery.isPending || hbQuery.isPending;
const isRefreshing =
(nvQuery.isFetching && !nvQuery.isPending) ||
(imrQuery.isFetching && !imrQuery.isPending) ||
(hbQuery.isFetching && !hbQuery.isPending);
const error = nvQuery.error ?? imrQuery.error ?? hbQuery.error;
const activeQuery = queryByTab[activeTab];
const isRefreshing = activeQuery.isFetching && !activeQuery.isPending;
const error = activeQuery.error;
/** 통계항목 변경·최초 진입: 탭 영역 숨기고 카드 전체 로딩만 표시 */
const hideTabsSection =
isFullReloading ||
(activeTab === "NV" && !nvQuery.data && (nvQuery.isPending || nvQuery.isFetching));
useEffect(() => {
if (!isFullReloading) return;
if (nvQuery.isFetching) return;
if (nvQuery.isSuccess || nvQuery.isError) {
setIsFullReloading(false);
}
}, [isFullReloading, nvQuery.isFetching, nvQuery.isSuccess, nvQuery.isError]);
const handleRefresh = useCallback(() => {
const tabCodes: VerificationReportTabKey[] = ["NV", "IMR", "HB"];
void Promise.all(
tabCodes.map((code) =>
queryClient.resetQueries({
queryKey: [
"verificationResultList",
statsYr,
code,
statsCdQueryKey,
],
}),
),
);
}, [queryClient, statsYr, statsCdQueryKey]);
setStatsCd("ALL");
setActiveTab("NV");
setIsFullReloading(true);
void queryClient.removeQueries({
queryKey: ["verificationInputReport", statsYr],
});
}, [queryClient, statsYr]);
const listsByTab: Record<VerificationReportTabKey, VerificationResultReportItem[]> = {
NV: flattenVerificationResults(nvQuery.data),
IMR: flattenVerificationResults(imrQuery.data),
HB: flattenVerificationResults(hbQuery.data),
const listsByTab: Record<VerificationReportTabKey, VerificationInputReportItem[]> = {
NV: flattenVerificationInputReportResults(nvQuery.data),
IMR: flattenVerificationInputReportResults(imrQuery.data),
HB: flattenVerificationInputReportResults(hbQuery.data),
};
const typeCountingInfo =
getVerifyTypeCountingInfo(nvQuery.data) ??
getVerifyTypeCountingInfo(imrQuery.data) ??
getVerifyTypeCountingInfo(hbQuery.data);
const totalCountByTab: Record<VerificationReportTabKey, number> = {
NV: getVerificationResultTotalCount(nvQuery.data, listsByTab.NV.length),
IMR: getVerificationResultTotalCount(imrQuery.data, listsByTab.IMR.length),
HB: getVerificationResultTotalCount(hbQuery.data, listsByTab.HB.length),
NV: typeCountingInfo?.nvCount ?? listsByTab.NV.length,
IMR: typeCountingInfo?.imrCount ?? listsByTab.IMR.length,
HB: typeCountingInfo?.hbCount ?? listsByTab.HB.length,
};
const openInputFromStatsCd = useCallback(
(item: VerificationResultReportItem) => {
(item: VerificationInputReportItem) => {
const nav = resolveInputPageNavigationFromStatsCd(item.statsCd);
if (!nav) {
alert(
@@ -263,9 +301,21 @@ const VerificationSelectionReport = ({
[],
);
const isTabContentLoading = (tab: VerificationReportTabKey) => {
const query = queryByTab[tab];
return (
activeTab === tab &&
!hideTabsSection &&
(query.isPending ||
(query.isFetching && !query.isFetchingNextPage) ||
(!query.data && !query.error))
);
};
const renderResultList = (
typeCode: VerificationReportTabKey,
items: VerificationResultReportItem[],
items: VerificationInputReportItem[],
isTabLoading: boolean,
hasNextPage: boolean,
isFetchingNextPage: boolean,
fetchNextPage: () => void,
@@ -281,7 +331,13 @@ const VerificationSelectionReport = ({
)
}
>
{items.length === 0 && !isFetchingNextPage ? (
{isTabLoading ? (
<div className="flex h-full min-h-[8rem] items-center justify-center">
<div className="relative size-[5rem]">
<LoadingWithAnimation color="primary" />
</div>
</div>
) : items.length === 0 && !isFetchingNextPage ? (
<div className="flex h-full min-h-[8rem] items-center justify-center text-lg text-muted-foreground">
항목이 없습니다.
</div>
@@ -303,7 +359,7 @@ const VerificationSelectionReport = ({
>
<div className="mb-2 flex items-center justify-between">
<span className="font-medium">
{item.statsNm}
{formatReportItemTitle(item)}
</span>
<Badge
color={
@@ -320,7 +376,7 @@ const VerificationSelectionReport = ({
{item.rsnExpln}
</p>
<div className="flex justify-between text-lg text-muted-foreground">
<span>{item.admdstNm}</span>
<span>{formatReportItemRegion(item)}</span>
<span>
현재: {item.nowVlCn ?? "-"} / 비교:{" "}
{item.compVlCn ?? "-"}
@@ -383,25 +439,25 @@ const VerificationSelectionReport = ({
<CodeSelectWithAll
upCd="STITEM"
value={statsCd}
onValueChange={setStatsCd}
onValueChange={handleStatsCdChange}
placeholder="전체"
className="w-full"
/>
</div>
<div className="card-body p-2.5 flex-1 flex flex-col overflow-hidden relative min-h-0">
{isInitialLoading && (
{hideTabsSection && (
<div className="absolute inset-0 z-50 [data-theme='black']:bg-transparent rounded-lg p-10 flex justify-center items-center">
<div className="relative size-[5rem]">
<LoadingWithAnimation color="primary" />
</div>
</div>
)}
{error && (
{!hideTabsSection && error && (
<div className="absolute inset-0 z-50 [data-theme='black']:bg-transparent rounded-lg p-10 flex justify-center items-center">
데이터를 불러올 수 없습니다.
</div>
)}
{!isInitialLoading && !error && (
{!hideTabsSection && !error && (
<section className="flex min-h-0 flex-1 flex-col">
<Tabs
value={activeTab}
@@ -428,18 +484,14 @@ const VerificationSelectionReport = ({
value={tab.value}
className="mt-0 flex min-h-0 flex-1 flex-col p-0 data-[state=inactive]:hidden"
>
{activeTab === tab.value
? renderResultList(
tab.value,
listsByTab[tab.value],
queryByTab[tab.value]
.hasNextPage ?? false,
queryByTab[tab.value]
.isFetchingNextPage,
queryByTab[tab.value]
.fetchNextPage,
)
: null}
{renderResultList(
tab.value,
listsByTab[tab.value],
isTabContentLoading(tab.value),
queryByTab[tab.value].hasNextPage ?? false,
queryByTab[tab.value].isFetchingNextPage,
queryByTab[tab.value].fetchNextPage,
)}
</TabsContent>
))}
</Tabs>
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
@@ -1,4 +1,4 @@
import { useEffect, useRef, useState } from 'react';
import { useEffect, useRef, useState, type Dispatch, type SetStateAction } from 'react';
import { useConfirm } from '@frontend-opensource/use-react-hooks';
@@ -157,7 +157,9 @@ const PublicSewageFacilityInp = () => {
const globalProgressVisible = useGlobalProgressStore((state) => state.visible);
const [isLoading, setIsLoading] = useState<boolean>(false);
const [hasSearched, setHasSearched] = useState(false);
const [isNotApplicable, setIsNotApplicable] = useState(false);
const [isNotApplicableByTab, setIsNotApplicableByTab] = useState<Map<string, boolean>>(
() => new Map(),
);
const [showIsNotApplicable, setShowIsNotApplicable] = useState(false);
const [searchValues, setSearchValues] = useState<SearchFormValues | null>(null);
const [lastSearchedValues, setLastSearchedValues] = useState<SearchFormValues | null>(null);
@@ -175,6 +177,19 @@ const PublicSewageFacilityInp = () => {
const [mainFacilityMappingSaveDialogKey, setMainFacilityMappingSaveDialogKey] = useState(0);
const [verificationResultDialogOpen, setVerificationResultDialogOpen] = useState(false);
const [verificationHistoryOpen, setVerificationHistoryOpen] = useState(false);
const isNotApplicable = isNotApplicableByTab.get('ST00060001') ?? false;
const setIsNotApplicable: Dispatch<SetStateAction<boolean>> = (next) => {
setIsNotApplicableByTab((prev) => {
const current = prev.get('ST00060001') ?? false;
const resolved =
typeof next === 'function'
? (next as (prevState: boolean) => boolean)(current)
: next;
const nextMap = new Map(prev);
nextMap.set('ST00060001', resolved);
return nextMap;
});
};
const periodEnterableRef = useRef(true);
const searchDoneRef = useRef(false);
@@ -195,7 +210,7 @@ const PublicSewageFacilityInp = () => {
const currentData =
st60001GridGetRowsRef.current?.() ?? rowDataMap.get('ST00060001') ?? [];
if (!treatAsNoApplicable && (!currentData || currentData.length === 0)) {
alert('제출할 데이터가 없습니다.');
alert('해당사항 없음을 체크해주세요.');
return;
}
const verificationBlocked = getVerificationPeriodActionBlockedMessage(periodCheckList);
@@ -276,15 +291,15 @@ const PublicSewageFacilityInp = () => {
const currentData =
st60001GridGetRowsRef.current?.() ?? rowDataMap.get('ST00060001') ?? [];
if (!treatAsNoApplicable && (!currentData || currentData.length === 0)) {
alert('제출할 데이터가 없습니다.');
const submitList = treatAsNoApplicable ? [] : buildFacilitySaveList(currentData);
if (!treatAsNoApplicable && submitList.length === 0) {
alert('해당사항 없음을 체크해주세요.');
return;
}
try {
showGlobalProgress({ label: '서버에 요청 중...', progress: 0 });
const userInfo = await getUserInfo();
const submitList = treatAsNoApplicable ? [] : buildFacilitySaveList(currentData);
const activeTabStatus = tabStatusInfoMap.get(activeTab) ?? null;
const statsCdForTab = tabStatsCdMap.get(activeTab) ?? activeTab;
const requestData = {
@@ -515,19 +530,19 @@ const PublicSewageFacilityInp = () => {
if (!(await confirm('저장 하시겠습니까?'))) return;
if (activeTab === 'ST00060001') {
const noApplicable = getNoApplicableForRequest();
const treatAsNoApplicable = noApplicable || isNotApplicable;
const currentData =
st60001GridGetRowsRef.current?.() ?? rowDataMap.get('ST00060001') ?? [];
const saveList = treatAsNoApplicable ? [] : buildFacilitySaveList(currentData);
if (!treatAsNoApplicable && saveList.length === 0) {
alert('해당사항 없음을 체크해주세요.');
return;
}
try {
showGlobalProgress({ label: '저장 진행 중...', progress: 0 });
const userInfo = await getUserInfo();
const noApplicable = getNoApplicableForRequest();
const treatAsNoApplicable = noApplicable || isNotApplicable;
const currentData =
st60001GridGetRowsRef.current?.() ?? rowDataMap.get('ST00060001') ?? [];
if (!treatAsNoApplicable && (!currentData || currentData.length === 0)) {
alert('저장할 데이터가 없습니다.');
return;
}
const saveList = treatAsNoApplicable ? [] : buildFacilitySaveList(currentData);
const param: PublicSewageFacilitySaveRequest = {
statsYr: lastSearchedValues.year ?? '',
ctpvNm: lastSearchedValues.sido ?? '',
@@ -655,8 +670,6 @@ const PublicSewageFacilityInp = () => {
param: PublicSewageFacilityRequestParams,
searchContext: SearchFormValues | null,
): Promise<boolean> => {
// 서버 데이터로 새로 불러오는 시점이므로 "해당없음" 체크는 항상 초기화
setIsNotApplicable(false);
try {
if (tabKey === 'ST00060001') {
const response = await statisticsInputPublicSewageFacilityApiService.getPublicSewageFacility(param);
@@ -856,6 +869,11 @@ const PublicSewageFacilityInp = () => {
setLastSearchedValues(values);
searchDoneRef.current = true;
setHasSearched(true);
setIsNotApplicableByTab((prev) => {
const next = new Map(prev);
next.set('ST00060001', false);
return next;
});
setIsLoading(true);
const param = buildPublicSewageFacilityParams(values);
@@ -870,6 +888,19 @@ const PublicSewageFacilityInp = () => {
};
const handleActiveTabChange = async (tabKey: string) => {
const prevTabKey = activeTabRef.current;
if (prevTabKey === 'ST00060001') {
const latestRowsInPrevTab =
st60001GridGetRowsRef.current?.() ?? rowDataMap.get('ST00060001') ?? [];
if (latestRowsInPrevTab.length > 0 || rowDataMap.has('ST00060001')) {
setRowDataMap((prev) => {
const next = new Map(prev);
next.set('ST00060001', latestRowsInPrevTab);
return next;
});
}
}
activeTabRef.current = tabKey;
setActiveTab(tabKey);
@@ -885,7 +916,7 @@ const PublicSewageFacilityInp = () => {
const handleReset = () => {
setIsLoading(false);
setHasSearched(false);
setIsNotApplicable(false);
setIsNotApplicableByTab(new Map());
setShowIsNotApplicable(false);
setRowDataMap(new Map());
@@ -1215,7 +1246,7 @@ const PublicSewageFacilityInp = () => {
return (
<TabsContent key={tab.tabKey} value={tab.tabKey}>
<DataGrid
key={(tabEnterableForGrid || isAdminInfo) ? 'enterable' : 'readonly'}
key={tab.tabKey}
columnInfo={gridColumnInfo}
rowStartNum={tab.rowStartNum}
excelExportFileSuffix={tab.tabLabel || tab.tabKey}
@@ -1278,6 +1309,11 @@ const PublicSewageFacilityInp = () => {
open={verificationHistoryOpen}
onOpenChange={setVerificationHistoryOpen}
queryContext={verificationQueryContext}
onExceptionResetSubmitted={async () => {
const ctx = lastSearchedValues ?? searchValues;
if (!ctx || !searchParamRef.current) return;
await loadPublicSewageFacilityActiveTabData(activeTab, searchParamRef.current, ctx);
}}
/>
<SubmitDailog
open={showDailog}
File diff suppressed because it is too large Load Diff

Some files were not shown because too many files have changed in this diff Show More