diff --git a/apps/as/.gitignore b/apps/as/.gitignore index fd3dbb571..e43ec0981 100644 --- a/apps/as/.gitignore +++ b/apps/as/.gitignore @@ -26,7 +26,7 @@ yarn-debug.log* yarn-error.log* # local env files -.env*.local +.env.* # vercel .vercel diff --git a/apps/as/app/api/proxy/[...url]/route.ts b/apps/as/app/api/proxy/[...url]/route.ts index 36ec17557..15abf4c72 100644 --- a/apps/as/app/api/proxy/[...url]/route.ts +++ b/apps/as/app/api/proxy/[...url]/route.ts @@ -38,13 +38,28 @@ async function handler( body = await request.json(); } - const result = await fetch(`${SERVER_API_URL}/${params.url.join("/")}`, { + const targetUrl = `${SERVER_API_URL}/${params.url.join("/")}`; + console.log(`[Proxy API] ${method} ${params.url.join("/")} -> ${targetUrl}`); + console.log(`[Proxy API] Headers:`, headers); + if (!isRefreshHandler) { + console.log(`[Proxy API] Body:`, body); + } + + const result = await fetch(targetUrl, { method, headers, body: JSON.stringify(body), }); + console.log(`[Proxy API] Response Status: ${result.status}`); + console.log(`[Proxy API] Response Headers:`, { + [ACCESS_TOKEN]: result.headers.get(ACCESS_TOKEN), + [REFRESH_TOKEN]: result.headers.get(REFRESH_TOKEN) ? '***hidden***' : undefined, + [AUTH_USER_ID]: result.headers.get(AUTH_USER_ID), + }); + if (!result.ok) { + console.error(`[Proxy API] Request failed with status ${result.status}`); return NextResponse.json({ message: "Invalid Credentials" }, { status: 401 }); } @@ -53,6 +68,7 @@ async function handler( const userId = result.headers.get(AUTH_USER_ID); if (accessToken && userId) { + console.log(`[Proxy API] Login successful for user: ${userId}`); const response = NextResponse.json({ [ACCESS_TOKEN]: accessToken, [AUTH_USER_ID]: userId, @@ -79,6 +95,7 @@ async function handler( return NextResponse.json({ message: "Invalid Credentials" }, { status: 401 }); } catch (error) { + console.error(`[Proxy API] Error caught in handler:`, error); return NextResponse.json({ message: "Server Error", error }, { status: 500 }); } } diff --git a/apps/as/src/shared/config/api.ts b/apps/as/src/shared/config/api.ts index 03946ffb9..d41297e0f 100644 --- a/apps/as/src/shared/config/api.ts +++ b/apps/as/src/shared/config/api.ts @@ -1,4 +1,7 @@ export const ENDPOINT = { + AS_INSTL_SERVICE: { + INSTL_SNCN: "/as-instl-service/api/v1/instl-sncn", + }, USER_SERVICE: { LOGIN: "/user-service/login", SILENT_REFRESH: "/user-service/api/v1/users/token/refresh", diff --git a/apps/dm/app/(landing)/reuse/_components/CaseTabsCarousel.tsx b/apps/dm/app/(landing)/reuse/_components/CaseTabsCarousel.tsx index ece8f0848..e70bc3e02 100644 --- a/apps/dm/app/(landing)/reuse/_components/CaseTabsCarousel.tsx +++ b/apps/dm/app/(landing)/reuse/_components/CaseTabsCarousel.tsx @@ -33,8 +33,8 @@ type CaseItem = { }; const TABS: { key: TabKey; label: string }[] = [ - { key: "rain", label: "빗물 재이용 사례" }, - { key: "reuse", label: "중수 재이용 사례" }, + { key: "rain", label: "빗물이용시설 사례" }, + { key: "reuse", label: "중수도 사례" }, { key: "sewage", label: "하수 재이용 사례" }, { key: "global", label: "해외 재이용 사례" }, ]; @@ -59,7 +59,7 @@ const MOCK: Record = { ], reuse: Array.from({ length: 6 }).map((_, i) => ({ id: `reuse-${i}`, - title: `중수 재이용 사례 ${i + 1}`, + title: `중수도 사례 ${i + 1}`, desc: "설명 문구가 들어갑니다. (최대 2~3줄) …", imageSrc: "/images/landing/sample_case.png", href: "#", diff --git a/apps/dm/app/(landing)/reuse/_constants/lnb.ts b/apps/dm/app/(landing)/reuse/_constants/lnb.ts index 671dabd65..f88de62bc 100644 --- a/apps/dm/app/(landing)/reuse/_constants/lnb.ts +++ b/apps/dm/app/(landing)/reuse/_constants/lnb.ts @@ -23,7 +23,7 @@ export const LNB_CASES = [ // 물 재이용 사례처럼 2depth가 있는 메뉴 export const LNB_BUSINESS: LnbItem[] = [ { - label: "빗물 재이용 사례", + label: "빗물이용시설 사례", children: [ { label: "경기도 수원시", href: "/reuse/business/rain/suwon" }, // { label: "충청남도 보령시", href: "/reuse/business/rain/bolyeong" }, @@ -31,7 +31,7 @@ export const LNB_BUSINESS: LnbItem[] = [ ], }, { - label: "중수 재이용 사례", + label: "중수도 사례", children: [{ label: "사례 목록", href: "/reuse/business/graywater" }], }, { diff --git a/apps/dm/app/(landing)/reuse/business/_components/GreywaterExampleSection.tsx b/apps/dm/app/(landing)/reuse/business/_components/GreywaterExampleSection.tsx index 374e3ddd4..35dfc1247 100644 --- a/apps/dm/app/(landing)/reuse/business/_components/GreywaterExampleSection.tsx +++ b/apps/dm/app/(landing)/reuse/business/_components/GreywaterExampleSection.tsx @@ -109,7 +109,7 @@ function InfoList({ } export default function GreywaterExampleSection() { - const title = "중수 재이용 사례"; + const title = "중수도 사례"; return (
{/* 상단 타이틀 */} diff --git a/apps/dm/app/(landing)/reuse/business/_components/RainExampleSection.tsx b/apps/dm/app/(landing)/reuse/business/_components/RainExampleSection.tsx index d73fdf1a2..0381b3ebc 100644 --- a/apps/dm/app/(landing)/reuse/business/_components/RainExampleSection.tsx +++ b/apps/dm/app/(landing)/reuse/business/_components/RainExampleSection.tsx @@ -17,7 +17,7 @@ function Hr({ className = "" }: { className?: string }) { } export default function RainExampleSection() { - const title = "빗물 재이용 사례"; + const title = "빗물이용시설 사례"; return (
{/* 상단 타이틀 */} diff --git a/apps/dm/app/(landing)/reuse/business/page.tsx b/apps/dm/app/(landing)/reuse/business/page.tsx index 9386012b1..660680c0c 100644 --- a/apps/dm/app/(landing)/reuse/business/page.tsx +++ b/apps/dm/app/(landing)/reuse/business/page.tsx @@ -9,7 +9,7 @@ import GlobalCaseSection from "./_components/GlobalCaseSection"; const menus: BusinessSubNavMenu[] = [ { key: "example-rain", - label: "빗물 재이용 사례", + label: "빗물이용시설 사례", sections: [ { id: "example-rain-suwon", label: "경기도 수원시" }, { id: "example-rain-jeju", label: "제주특별자치도" }, @@ -17,7 +17,7 @@ const menus: BusinessSubNavMenu[] = [ }, { key: "example-greywater", - label: "중수 재이용 사례", + label: "중수도 사례", defaultOpen: false, sections: [ { id: "example-greywater-ansan", label: "경기도 안산시" }, diff --git a/apps/dm/app/(landing)/reuse/business/rain/suwon/page.tsx b/apps/dm/app/(landing)/reuse/business/rain/suwon/page.tsx index 3acba860b..d96772126 100644 --- a/apps/dm/app/(landing)/reuse/business/rain/suwon/page.tsx +++ b/apps/dm/app/(landing)/reuse/business/rain/suwon/page.tsx @@ -46,7 +46,7 @@ export default function GreywaterPage() { crumbs={[ { label: "Home", href: "/reuse" }, { label: "물 재이용 사례" }, - { label: "빗물 재이용 사례" }, + { label: "빗물이용시설 사례" }, { label: "수원시", href: "/reuse/business/rain/suwon" }, ]} hideFooterLine diff --git a/apps/dm/app/(with-layout)/contents/page.tsx b/apps/dm/app/(with-layout)/contents/page.tsx new file mode 100644 index 000000000..27d0974be --- /dev/null +++ b/apps/dm/app/(with-layout)/contents/page.tsx @@ -0,0 +1,9 @@ +export const dynamic = "force-dynamic"; +import { ContentsPage } from "@/components/ui/contents"; + +export default async function Contents() { + const contsSn = 28; + const menuCd = "24"; + + return ; +} diff --git a/apps/dm/app/(with-layout)/example/_components/GlobalCaseSection.tsx b/apps/dm/app/(with-layout)/example/_components/GlobalCaseSection.tsx index 141658d3e..73492a21d 100644 --- a/apps/dm/app/(with-layout)/example/_components/GlobalCaseSection.tsx +++ b/apps/dm/app/(with-layout)/example/_components/GlobalCaseSection.tsx @@ -650,8 +650,7 @@ const globalCases: GlobalCase[] = [ depth: 1, }, - { text: "안핑을 포함한 16건 공사 완료시 628,100 CMD 공급 가능" }, - { text: "공급단가는 수질과 운영조건에 따라 다르게 책정" }, + ], }, { diff --git a/apps/dm/app/(with-layout)/example/_components/GreywaterExampleSection.tsx b/apps/dm/app/(with-layout)/example/_components/GreywaterExampleSection.tsx index b038216b6..1705a6c84 100644 --- a/apps/dm/app/(with-layout)/example/_components/GreywaterExampleSection.tsx +++ b/apps/dm/app/(with-layout)/example/_components/GreywaterExampleSection.tsx @@ -109,7 +109,7 @@ function InfoList({ } export default function GreywaterExampleSection() { - const title = "중수 재이용 사례"; + const title = "중수도 사례"; return (
{/* 상단 타이틀 */} diff --git a/apps/dm/app/(with-layout)/example/_components/RainExampleSection.tsx b/apps/dm/app/(with-layout)/example/_components/RainExampleSection.tsx index 7ced9d60e..5f975356a 100644 --- a/apps/dm/app/(with-layout)/example/_components/RainExampleSection.tsx +++ b/apps/dm/app/(with-layout)/example/_components/RainExampleSection.tsx @@ -123,7 +123,7 @@ function RainTable({ rows }: { rows: RainTableRow[] }) { } export default function RainExampleSection() { - const title = "빗물 재이용 사례"; + const title = "빗물이용시설 사례"; return (
diff --git a/apps/dm/app/(with-layout)/example/_components/SewageExampleSection.tsx b/apps/dm/app/(with-layout)/example/_components/SewageExampleSection.tsx index 7d9a5a3da..cd4c1d420 100644 --- a/apps/dm/app/(with-layout)/example/_components/SewageExampleSection.tsx +++ b/apps/dm/app/(with-layout)/example/_components/SewageExampleSection.tsx @@ -334,7 +334,7 @@ const sewageCases: SewageCase[] = [ ]; export default function SewageExampleSection() { - const title = "하수처리 재이용 사례"; + const title = "하수처리수 재이용 사례"; return (
diff --git a/apps/dm/app/(with-layout)/example/greywater/page.tsx b/apps/dm/app/(with-layout)/example/greywater/page.tsx index 154d98b06..ab76d58d7 100644 --- a/apps/dm/app/(with-layout)/example/greywater/page.tsx +++ b/apps/dm/app/(with-layout)/example/greywater/page.tsx @@ -115,11 +115,11 @@ export default function GreywaterPage() { return (
diff --git a/apps/dm/app/(with-layout)/intro/_components/RainIntroSection.tsx b/apps/dm/app/(with-layout)/intro/_components/RainIntroSection.tsx index 4de564a54..bbf1b141a 100644 --- a/apps/dm/app/(with-layout)/intro/_components/RainIntroSection.tsx +++ b/apps/dm/app/(with-layout)/intro/_components/RainIntroSection.tsx @@ -54,7 +54,7 @@ function SectionShell({ } export default function RainIntroSection() { - const title = "빗물 이용시설"; + const title = "빗물이용시설"; return (
diff --git a/apps/dm/app/(with-layout)/intro/_components/TreatedIntroSection.tsx b/apps/dm/app/(with-layout)/intro/_components/TreatedIntroSection.tsx index ff3ece0fc..19debe88f 100644 --- a/apps/dm/app/(with-layout)/intro/_components/TreatedIntroSection.tsx +++ b/apps/dm/app/(with-layout)/intro/_components/TreatedIntroSection.tsx @@ -52,7 +52,7 @@ function SectionShell({ } export default function TreatedIntroSection() { - const title = "하수처리수"; + const title = "하수처리수 재이용"; return (
diff --git a/apps/dm/app/(with-layout)/intro/page.tsx b/apps/dm/app/(with-layout)/intro/page.tsx index 02d050764..7b643e55d 100644 --- a/apps/dm/app/(with-layout)/intro/page.tsx +++ b/apps/dm/app/(with-layout)/intro/page.tsx @@ -9,7 +9,7 @@ import TreatedIntroSection from "./_components/TreatedIntroSection"; const menus: BusinessSubNavMenu[] = [ { key: "intro-law", - label: "물 재이용 법", + label: "물재이용법", sections: [ { id: "law-plan", label: "법률 제·개정 및 기본계획 수립" }, { id: "plan-performance", label: "제2차 계획 대비 실적 현황" }, @@ -21,7 +21,7 @@ const menus: BusinessSubNavMenu[] = [ }, { key: "intro-rain", - label: "빗물 이용시설", + label: "빗물이용시설", defaultOpen: false, sections: [ { id: "rain-intro", label: "빗물 재이용 소개" }, @@ -48,7 +48,7 @@ const menus: BusinessSubNavMenu[] = [ }, { key: "intro-treated", - label: "하수처리수", + label: "하수처리수 재이용", defaultOpen: false, sections: [ { id: "treatedwater-intro", label: "하수처리수 소개" }, diff --git a/apps/dm/app/(with-layout)/status/_components/SewageStatusSection.tsx b/apps/dm/app/(with-layout)/status/_components/SewageStatusSection.tsx index cd660a929..f7eb49a84 100644 --- a/apps/dm/app/(with-layout)/status/_components/SewageStatusSection.tsx +++ b/apps/dm/app/(with-layout)/status/_components/SewageStatusSection.tsx @@ -287,7 +287,7 @@ export default function SewageStatusSection() { const isSubItem = type === "red-item" || type === "blue-item"; if (index === 0) { - console.log(header.id, value); + // console.log(header.id, value); return ( {isSubItem ? ( diff --git a/apps/dm/app/(with-layout)/status/page.tsx b/apps/dm/app/(with-layout)/status/page.tsx index b18662c06..8bed3d04f 100644 --- a/apps/dm/app/(with-layout)/status/page.tsx +++ b/apps/dm/app/(with-layout)/status/page.tsx @@ -58,7 +58,7 @@ const menus: BusinessSubNavMenu[] = [ }, { id: "status-treated-usage", - label: "연도별 하수처리수 재이용 현황", + label: "용도별 하수처리수 재이용 현황", }, { id: "status-treated-facility", diff --git a/apps/dm/app/layout.tsx b/apps/dm/app/layout.tsx index 1fd9ea1cc..49c6c357c 100644 --- a/apps/dm/app/layout.tsx +++ b/apps/dm/app/layout.tsx @@ -16,7 +16,7 @@ export default function RootLayout({ 물재이용 대민 -
+
{children}
diff --git a/apps/dm/app/main/layout.tsx b/apps/dm/app/main/layout.tsx index b1304a66a..873024784 100644 --- a/apps/dm/app/main/layout.tsx +++ b/apps/dm/app/main/layout.tsx @@ -1,12 +1,10 @@ // app/main/layout.tsx import "../reuse.module.css"; -import TopNav from "@/components/TopNav"; import { StoreProvider } from "../store"; export default function ReuseLandingLayout({ children }: { children: React.ReactNode }) { return ( -
{children}
{/* TopNav 높이만큼 */}
); diff --git a/apps/dm/app/store/store.tsx b/apps/dm/app/store/store.tsx index c1d4fcdfd..3b7b2c24d 100644 --- a/apps/dm/app/store/store.tsx +++ b/apps/dm/app/store/store.tsx @@ -33,7 +33,7 @@ export const StoreZus = ( return createStore()((set) => { if (typeof window !== "undefined") { tokenManager.setToken("site-id-key", process.env.NEXT_PUBLIC_SITE_ID||"DM"); - tokenManager.setToken("site-theme-key", "green"); + tokenManager.setToken("site-theme-key", "white"); } return { diff --git a/apps/dm/components/BusinessSubNav.tsx b/apps/dm/components/BusinessSubNav.tsx index 6b4246412..d8753b5dd 100644 --- a/apps/dm/components/BusinessSubNav.tsx +++ b/apps/dm/components/BusinessSubNav.tsx @@ -1,3 +1,6 @@ +/** + * 우측 메뉴 + */ "use client"; import Link from "next/link"; @@ -47,44 +50,67 @@ export default function BusinessSubNav({ setOpenMenus(initialOpenState); }, [menus]); - useEffect(() => { - if (!allSections.length) return; +useEffect(() => { + if (!allSections.length) return; - const observer = new IntersectionObserver( - (entries) => { - const visible = entries - .filter((entry) => entry.isIntersecting) - .sort((a, b) => b.intersectionRatio - a.intersectionRatio); + const targets = allSections + .map((section) => document.getElementById(section.id)) + .filter(Boolean) as HTMLElement[]; - if (visible[0]) { - setActiveId(visible[0].target.id); - } - }, - { - rootMargin: `-${offsetTop}px 0px -55% 0px`, - threshold: [0.2, 0.35, 0.5, 0.7], + if (!targets.length) return; + + let rafId = 0; + + const updateActiveSection = () => { + const baseLine = offsetTop + 500; + let nextActiveId = targets[0]?.id ?? ""; + + for (const el of targets) { + const { top } = el.getBoundingClientRect(); + if (top <= baseLine) { + nextActiveId = el.id; + continue; } - ); - const targets = allSections - .map((section) => document.getElementById(section.id)) - .filter(Boolean) as HTMLElement[]; + break; + } - targets.forEach((el) => observer.observe(el)); + setActiveId((prev) => (prev === nextActiveId ? prev : nextActiveId)); + }; - const hash = window.location.hash.replace("#", ""); - const initialActive = - allSections.find((section) => section.id === hash)?.id ?? - allSections[0]?.id ?? - ""; + const requestUpdate = () => { + if (rafId) return; - setActiveId(initialActive); + rafId = window.requestAnimationFrame(() => { // 예약번호 반환 + updateActiveSection(); + rafId = 0; + }); + }; - return () => observer.disconnect(); - }, [allSections, offsetTop]); + const hash = window.location.hash.replace("#", ""); + const initialActive = + allSections.find((section) => section.id === hash)?.id ?? + allSections[0]?.id ?? + ""; - useEffect(() => { - if (!activeId) return; + setActiveId(initialActive); + updateActiveSection(); + + window.addEventListener("scroll", requestUpdate, { passive: true }); + window.addEventListener("resize", requestUpdate); + + return () => { + window.removeEventListener("scroll", requestUpdate); + window.removeEventListener("resize", requestUpdate); + + if (rafId) { + window.cancelAnimationFrame(rafId); + } + }; +}, [allSections, offsetTop]); + + useEffect(() => { + if (!activeId) return; const parentMenu = menus.find((menu) => (menu.sections ?? []).some((section) => section.id === activeId) @@ -93,13 +119,13 @@ export default function BusinessSubNav({ if (!parentMenu) return; setOpenMenus(() => { - const nextOpenState: Record = {}; + const nextOpenState: Record = {}; - menus.forEach((menu) => { + menus.forEach((menu) => { nextOpenState[menu.key] = menu.key === parentMenu.key; - }); + }); - return nextOpenState; + return nextOpenState; }); }, [activeId, menus]); diff --git a/apps/dm/components/CaseTabsCarousel.tsx b/apps/dm/components/CaseTabsCarousel.tsx index d0d399786..f9ad4f2a8 100644 --- a/apps/dm/components/CaseTabsCarousel.tsx +++ b/apps/dm/components/CaseTabsCarousel.tsx @@ -33,8 +33,8 @@ type CaseItem = { }; const TABS: { key: TabKey; label: string }[] = [ - { key: "rain", label: "빗물 재이용 사례" }, - { key: "greywater", label: "중수 재이용 사례" }, + { key: "rain", label: "빗물이용시설 사례" }, + { key: "greywater", label: "중수도 사례" }, { key: "sewage", label: "하수 재이용 사례" }, { key: "global", label: "해외 재이용 사례" }, ]; diff --git a/apps/dm/components/ui/contents/contents-api.ts b/apps/dm/components/ui/contents/contents-api.ts new file mode 100644 index 000000000..3a7c64639 --- /dev/null +++ b/apps/dm/components/ui/contents/contents-api.ts @@ -0,0 +1,26 @@ +// apps/dm/src/entities/content/ +// contents-api.ts +import { unstable_cache } from "next/cache"; +import { ENDPOINT } from "@/src/shared/config"; + + + +export const getCachedContentDetail = unstable_cache( + async (contsSn: number, menuCd: string) => { + const baseUrl = process.env.NEXT_PUBLIC_PROXY_HOST || "http://localhost:3015"; + const res = await fetch( + `${baseUrl}${ENDPOINT.CMS_SERVICE.CONTENTS}/${contsSn}?menuCd=${menuCd}`, + { + next: { revalidate: 3600, tags: [`content:${contsSn}`] }, + } + ); + + if (!res.ok) { + console.log(res); + throw new Error("Failed to fetch content"); + } + + return res.json(); + }, + ["dm-content-detail"], +); diff --git a/apps/dm/components/ui/contents/contentsPage.tsx b/apps/dm/components/ui/contents/contentsPage.tsx new file mode 100644 index 000000000..c823acd42 --- /dev/null +++ b/apps/dm/components/ui/contents/contentsPage.tsx @@ -0,0 +1,18 @@ +/** + * 컨텐츠 서버 페이지 + */ +import { getCachedContentDetail } from "./contents-api"; +import { ContentsPageClient } from "./contentsPageClient"; + +type ContentsPageProps = { + contsSn: number; + menuCd: string; +}; + +async function ContentsPage({ contsSn, menuCd }: ContentsPageProps) { + const initialContent = await getCachedContentDetail(contsSn, menuCd); + + return ; +} + +export { ContentsPage }; diff --git a/apps/dm/components/ui/contents/contentsPageClient.tsx b/apps/dm/components/ui/contents/contentsPageClient.tsx new file mode 100644 index 000000000..97c62bb83 --- /dev/null +++ b/apps/dm/components/ui/contents/contentsPageClient.tsx @@ -0,0 +1,37 @@ +"use client"; + +import { useEffect, useRef } from "react"; +import type { ContentsDetail } from "@/src/entities/content/model/contents-interface"; + +type ContentsPageClientProps = { + initialContent: ContentsDetail; +}; + +function ContentsPageClient({ initialContent }: ContentsPageClientProps) { + const shadowRootRef = useRef(null); + + useEffect(() => { + if (!shadowRootRef.current || !initialContent?.contsCn) return; + + const shadowRoot = + shadowRootRef.current.shadowRoot ?? + shadowRootRef.current.attachShadow({ mode: "open" }); + shadowRoot.innerHTML = ""; + + const styleElement = document.createElement("style"); + styleElement.textContent = `div { font-size: 16px; line-height: 1.5; }`; + shadowRoot.appendChild(styleElement); + + const contentElement = document.createElement("div"); + contentElement.innerHTML = initialContent.contsCn; + shadowRoot.appendChild(contentElement); + }, [initialContent]); + + return ( +
+
+
+ ); +} + +export { ContentsPageClient }; diff --git a/apps/dm/components/ui/contents/index.ts b/apps/dm/components/ui/contents/index.ts new file mode 100644 index 000000000..82a5dad4f --- /dev/null +++ b/apps/dm/components/ui/contents/index.ts @@ -0,0 +1 @@ +export { ContentsPage } from "./contentsPage"; diff --git a/apps/dm/middleware.ts b/apps/dm/middleware.ts index f4f145217..c2a228931 100644 --- a/apps/dm/middleware.ts +++ b/apps/dm/middleware.ts @@ -60,7 +60,7 @@ export function middleware(req: NextRequest) { if (pathname.startsWith("/user-service") || pathname.startsWith("/cms-service")) { - let queryParams = searchParams.toString(); + const queryParams = searchParams.toString(); const requestHeaders = new Headers(req.headers); const newUrl = `${serverApiUrl}${pathname}${queryParams ? `?${queryParams}` : ""}`; diff --git a/apps/dm/next.config.ts b/apps/dm/next.config.ts index 5b5bf85da..bbe67b0ce 100644 --- a/apps/dm/next.config.ts +++ b/apps/dm/next.config.ts @@ -48,6 +48,10 @@ const nextConfig: NextConfig = { source: "/operation-service/:path*", destination: `${subDomain}/operation-service/:path*`, }, + { + source: "/cms-service/:path*", + destination: `${subDomain}/cms-service/:path*`, + }, ], fallback: [], }; diff --git a/apps/dm/public/images/landing/intro/law_timeline.png b/apps/dm/public/images/landing/intro/law_timeline.png index 68cd72d17..56ff1c347 100644 Binary files a/apps/dm/public/images/landing/intro/law_timeline.png and b/apps/dm/public/images/landing/intro/law_timeline.png differ diff --git a/apps/dm/public/images/landing/intro/policy_timeline.png b/apps/dm/public/images/landing/intro/policy_timeline.png index 1cb90cef4..296bad776 100644 Binary files a/apps/dm/public/images/landing/intro/policy_timeline.png and b/apps/dm/public/images/landing/intro/policy_timeline.png differ diff --git a/apps/dm/src/entities/content/api/contents-service.ts b/apps/dm/src/entities/content/api/contents-service.ts new file mode 100644 index 000000000..292d9b795 --- /dev/null +++ b/apps/dm/src/entities/content/api/contents-service.ts @@ -0,0 +1,101 @@ +import { client } from "@/src/shared/api"; +import { ENDPOINT } from "@/src/shared/config"; +import type { + ContentsService, + ContentsParams, + ContentsList, + ContentsFormData, + ContentsVersionParams, + ContentsVersionList, + ContentsDetail, +} from "../model/contents-interface"; + +export class ContentsApiService implements ContentsService { + public async getContentsList(params: ContentsParams): Promise { + const response = await client.get( + `${ENDPOINT.CMS_SERVICE.CONTENTS}`, + { params }, + ); + return response.data; + } + + public async getLatestApprovedContentsInfo( + contsSn: number, + menuCd: string, + ): Promise { + const response = await client.get( + `${ENDPOINT.CMS_SERVICE.CONTENTS}/${contsSn}`, + { params: { menuCd } }, + ); + return response.data; + } + + public async getContentsVersionList( + params: ContentsVersionParams, + ): Promise { + const response = await client.get( + `${ENDPOINT.CMS_SERVICE.CONTENTS_DETAILS}`, + { params }, + ); + return response.data; + } + + public async getLatestContentsInfo( + contsSn: number, + contsVer: number, + ): Promise { + const response = await client.get( + `${ENDPOINT.CMS_SERVICE.CONTENTS_DETAILS}/${contsSn}/${contsVer}`, + ); + return response.data; + } + + public async createContents(data: ContentsFormData): Promise { + const response = await client.post( + `${ENDPOINT.CMS_SERVICE.CONTENTS_DETAILS}`, + data, + ); + return response.data; + } + + public async updateContents( + contsSn: number, + contsVer: number, + data: ContentsFormData, + ): Promise { + const response = await client.put( + `${ENDPOINT.CMS_SERVICE.CONTENTS_DETAILS}/${contsSn}/${contsVer}`, + data, + ); + return response.data; + } + + public async deleteContents( + contsSn: number, + contsVer: number, + ): Promise { + await client.delete( + `${ENDPOINT.CMS_SERVICE.CONTENTS_DETAILS}/${contsSn}/${contsVer}`, + ); + } + + public async requestApprove( + contsSn: number, + contsVer: number, + ): Promise { + await client.put( + `${ENDPOINT.CMS_SERVICE.CONTENTS_DETAILS}/${contsSn}/${contsVer}/approveRequest`, + ); + } + + public async approveContents( + contsSn: number, + contsVer: number, + ): Promise { + await client.put( + `${ENDPOINT.CMS_SERVICE.CONTENTS_DETAILS}/${contsSn}/${contsVer}/approve`, + ); + } +} + +export const ContentsApi = new ContentsApiService(); diff --git a/apps/dm/src/entities/content/index.ts b/apps/dm/src/entities/content/index.ts new file mode 100644 index 000000000..2446dd716 --- /dev/null +++ b/apps/dm/src/entities/content/index.ts @@ -0,0 +1,8 @@ +export { ContentsApi } from "./api/contents-service"; +export { + CONTENTS_APPROVE_STATUS, + contentsFormSchema, + type Contents, + type ContentsParams, + type ContentsFormData, +} from "./model/contents-interface"; diff --git a/apps/dm/src/entities/content/model/api-interface.ts b/apps/dm/src/entities/content/model/api-interface.ts new file mode 100644 index 000000000..8218bfb0d --- /dev/null +++ b/apps/dm/src/entities/content/model/api-interface.ts @@ -0,0 +1,34 @@ +interface SortConfig { + empty: boolean; + sorted: boolean; + unsorted: boolean; +} + +interface PageableConfig { + sort: SortConfig; + offset: number; + pageNumber: number; + pageSize: number; + paged: boolean; + unpaged: boolean; +} + +export interface PageableList { + content: T[]; + pageable: PageableConfig; + sort: SortConfig; + totalPages: number; + totalElements: number; + number: number; + numberOfElements: number; + size: number; + first: boolean; + last: boolean; + empty: boolean; +} + +export interface PageableParams { + page?: number; + size?: number; + sort?: string; +} diff --git a/apps/dm/src/entities/content/model/contents-interface.ts b/apps/dm/src/entities/content/model/contents-interface.ts new file mode 100644 index 000000000..474dfe97c --- /dev/null +++ b/apps/dm/src/entities/content/model/contents-interface.ts @@ -0,0 +1,105 @@ +import { z } from "zod"; +import type { PageableList, PageableParams } from "./api-interface"; + +export const CONTENTS_APPROVE_STATUS = { + DRAFT: "01", + REQUESTED: "02", + APPROVED: "99", +} as const; + +export type ContentsApproveStatus = + (typeof CONTENTS_APPROVE_STATUS)[keyof typeof CONTENTS_APPROVE_STATUS]; + +export interface ContentsParams extends PageableParams { + contsTtl?: string; + aprvContsVer?: number; + useYn?: string; +} + +export interface ContentsVersionParams extends PageableParams { + contsSn?: number; + contsCn?: string; + aprvCd?: ContentsApproveStatus; + cclCd?: string; + koglCd?: string; + tag?: string; + useYn?: string; +} + +export interface ContentsVersion { + contsSn: number; + contsVer: number; + contsCn: string; + useYn: string; +} + +export const contentsSchema = z.object({ + contsSn: z.number(), + contsTtl: z.string(), + aprvContsVer: z.number(), + useYn: z.string(), + mdfcnDt: z.string(), + mdfr: z.string(), + mdfrNm: z.string(), + aprvDt: z.string(), + autzrNm: z.string(), + workAprvCd: z.string(), + workAprvNm: z.string(), + workContsVer: z.number(), +}); + +export const contentsDetailSchema = z.object({ + contsSn: z.number(), + contsVer: z.number(), + contsTtl: z.string(), + contsCn: z.string(), + contsRmrk: z.string().nullish(), + aprvCd: z.enum([ + CONTENTS_APPROVE_STATUS.DRAFT, + CONTENTS_APPROVE_STATUS.REQUESTED, + CONTENTS_APPROVE_STATUS.APPROVED, + ]), + cclCd: z.string().nullish(), + koglCd: z.string().nullish(), + tag: z.string().nullish(), + aprvDt: z.string(), + autzr: z.string(), + useYn: z.string(), + menuPicUserUnqIdNm: z.string().nullish(), +}); + +export const contentsFormSchema = contentsDetailSchema.omit({ + contsVer: true, + aprvDt: true, + autzr: true, +}); + +export type Contents = z.infer; +export type ContentsDetail = z.infer; +export type ContentsFormData = z.infer; +export type ContentsList = PageableList; +export type ContentsVersionList = PageableList; + +export interface ContentsService { + getContentsList(params: ContentsParams): Promise; + getLatestApprovedContentsInfo( + contsSn: number, + menuCd: string, + ): Promise; + getContentsVersionList( + params: ContentsVersionParams, + ): Promise; + getLatestContentsInfo( + contsSn: number, + contsVer: number, + ): Promise; + createContents(data: ContentsFormData): Promise; + updateContents( + contsSn: number, + contsVer: number, + data: ContentsFormData, + ): Promise; + deleteContents(contsSn: number, contsVer: number): Promise; + requestApprove(contsSn: number, contsVer: number): Promise; + approveContents(contsSn: number, contsVer: number): Promise; +} diff --git a/apps/sn/app/(map-layout)/map/components/agedPipe/AgedPipePopupPage.tsx b/apps/sn/app/(map-layout)/map/components/agedPipe/AgedPipePopupPage.tsx index 3626054c7..1b6542816 100644 --- a/apps/sn/app/(map-layout)/map/components/agedPipe/AgedPipePopupPage.tsx +++ b/apps/sn/app/(map-layout)/map/components/agedPipe/AgedPipePopupPage.tsx @@ -3,13 +3,12 @@ import { useCallback, useEffect, useMemo, useState } from "react"; import { useSearchParams } from "next/navigation"; import { Button, Input } from "@common/components"; -import { UiTable, downloadExcelWithKoreanHeaders } from "@common/business_components/ui"; +import { UiTable } from "@common/business_components/ui"; import { getData } from "@/components/lib/api"; import useStore from "@/store/useStore"; import LoadingOverlay from "@/components/ui/LoadingBar"; import { AGED_PIPE_DEFAULT_FILTERS, - AGED_PIPE_EXCEL_HEADER, AGED_PIPE_LAYER_TYPE, AGED_PIPE_LIST_HEADLIST, AGED_PIPE_PAGE_SIZE, @@ -20,6 +19,7 @@ import { type AgedPipeFilters, type AgedPipeRow, } from "./agedPipeUtils"; +import { downloadAgedPipeCsv } from "./agedPipeExport"; const POPUP_ROW_SELECT_MESSAGE = "SN_AGED_PIPE_ROW_SELECT"; const POPUP_MINIMIZE_MESSAGE = "SN_AGED_PIPE_POPUP_MINIMIZE"; @@ -40,6 +40,7 @@ export default function AgedPipePopupPage() { const [currentPage, setCurrentPage] = useState(1); const [selectedIndex, setSelectedIndex] = useState(-1); const [isLoading, setIsLoading] = useState(false); + const [downloadProgressText, setDownloadProgressText] = useState(""); const sggOptions = useMemo( () => @@ -122,7 +123,7 @@ export default function AgedPipePopupPage() { return () => window.removeEventListener("message", handleFilterSyncMessage); }, [loadRows]); - const handleSearch = (page = 1) => { + const handleSearch = async (page = 1) => { if (!validateElapsedYears(Number(filters.elapsedYears))) { alert("경과년도는 1년~100년 범위 내에서 입력해주세요."); return; @@ -130,7 +131,7 @@ export default function AgedPipePopupPage() { const normalized = normalizeAgedPipeFilters(filters); setFilters(normalized); - loadRows(page, normalized); + await loadRows(page, normalized); }; const handleRowClick = (index?: number) => { @@ -156,17 +157,28 @@ export default function AgedPipePopupPage() { try { setIsLoading(true); - const response = await fetchRows(1, filters, 10000); - await downloadExcelWithKoreanHeaders({ - data: response.rows as JSON[], - fileName: `노후관로_현황_${new Date().toISOString().slice(0, 10)}`, - sheetNameArray: ["노후관로 현황"], - headerRange: "A1:J1", - headerMapping: AGED_PIPE_EXCEL_HEADER, + setDownloadProgressText("CSV export 작업을 준비하고 있습니다."); + await downloadAgedPipeCsv(filters, (job) => { + if (job.status === "QUEUED") { + setDownloadProgressText("CSV export 작업이 대기 중입니다."); + return; + } + + if (job.status === "RUNNING") { + setDownloadProgressText( + `CSV export 생성 중입니다. (${job.progressPercent ?? 0}%)`, + ); + } }); + setDownloadProgressText(""); } catch (error) { console.error("노후관로 목록 다운로드 중 오류:", error); - alert("노후관로 목록 다운로드 중 오류가 발생했습니다."); + setDownloadProgressText(""); + alert( + error instanceof Error + ? error.message + : "노후관로 CSV 다운로드 중 오류가 발생했습니다.", + ); } finally { setIsLoading(false); } @@ -202,6 +214,12 @@ export default function AgedPipePopupPage() {
+ {downloadProgressText ? ( +
+ {downloadProgressText} +
+ ) : null} +
@@ -270,7 +288,7 @@ export default function AgedPipePopupPage() { size="sm" color="primary" className="h-10 px-5 !text-[1.1rem]" - onClick={() => handleSearch(1)} + onClick={() => void handleSearch(1)} > 조회 @@ -293,7 +311,7 @@ export default function AgedPipePopupPage() { pageSize={10} total={total} cellClick={(index) => handleRowClick(index)} - onPageChange={(page) => handleSearch(page)} + onPageChange={(page) => void handleSearch(page)} curPage={currentPage} index={selectedIndex} widthFixed={true} diff --git a/apps/sn/app/(map-layout)/map/components/agedPipe/agedPipeExport.ts b/apps/sn/app/(map-layout)/map/components/agedPipe/agedPipeExport.ts new file mode 100644 index 000000000..7230ccb3f --- /dev/null +++ b/apps/sn/app/(map-layout)/map/components/agedPipe/agedPipeExport.ts @@ -0,0 +1,42 @@ +"use client"; + +import { postBlobData } from "@/components/lib/api"; +import { fileDownload } from "@/components/lib/utils/fileDownload"; +import { + AGED_PIPE_EXCEL_FIELDS, + normalizeAgedPipeFilters, + type AgedPipeFilters, +} from "./agedPipeUtils"; + +type AgedPipeExportProgress = { + fileName?: string; + progressPercent?: number; + status: "QUEUED" | "RUNNING" | "COMPLETED"; +}; + +/** 노후관로 CSV 다운로드를 요청한다. */ +export const downloadAgedPipeCsv = async ( + filters: AgedPipeFilters, + onProgress?: (progress: AgedPipeExportProgress) => void, +) => { + const normalizedFilters = normalizeAgedPipeFilters(filters); + + onProgress?.({ status: "RUNNING", progressPercent: 0 }); + + const { blob, fileName } = await postBlobData("v1/fclts/agedPipes/exportJobs/download", { + elapsedYears: normalizedFilters.elapsedYears, + ctpvCd: normalizedFilters.ctpvCd, + sggCd: normalizedFilters.sggCd, + format: "csv", + columns: [...AGED_PIPE_EXCEL_FIELDS], + }); + + const resolvedFileName = fileName || "노후관로_현황.csv"; + fileDownload(blob, resolvedFileName); + onProgress?.({ status: "COMPLETED", fileName: resolvedFileName, progressPercent: 100 }); + + return { + status: "COMPLETED" as const, + fileName: resolvedFileName, + }; +}; diff --git a/apps/sn/app/(map-layout)/map/components/agedPipe/agedPipeUtils.ts b/apps/sn/app/(map-layout)/map/components/agedPipe/agedPipeUtils.ts index 62bccc416..e273d9c1c 100644 --- a/apps/sn/app/(map-layout)/map/components/agedPipe/agedPipeUtils.ts +++ b/apps/sn/app/(map-layout)/map/components/agedPipe/agedPipeUtils.ts @@ -10,8 +10,9 @@ import { export const AGED_PIPE_LAYER_TYPE = "tb_ms_swage_ppl"; export const AGED_PIPE_DEFAULT_ELAPSED_YEARS = 30; export const AGED_PIPE_PAGE_SIZE = 10; -export const AGED_PIPE_MIN_ZOOM = 14; -export const AGED_PIPE_INVALID_INSTALL_YMD_PREFIXES = ["19000101", "19990101"]; +export const AGED_PIPE_MIN_ZOOM = 13; +export const AGED_PIPE_GUIDE_MIN_ZOOM = 13.1; +export const AGED_PIPE_INVALID_INSTALL_YMD_PREFIXES = ["19000101"]; export type AgedPipeFilters = { elapsedYears: number; @@ -46,6 +47,12 @@ export const AGED_PIPE_DEFAULT_FILTERS: AgedPipeFilters = { sggCd: "0", }; +const normalizeRegionFilterCode = (value: unknown, prefixLength: number) => { + const digits = String(value ?? "").replace(/[^0-9]/g, ""); + if (digits === "" || digits === "0") return "0"; + return digits.length > prefixLength ? digits.slice(0, prefixLength) : digits; +}; + export const AGED_PIPE_LIST_HEADLIST = [ { id: "rowNumber", title: "NO", widthPercent: 6 }, { id: "fcltMngNo", title: "시설관리번호", widthPercent: 18 }, @@ -57,8 +64,61 @@ export const AGED_PIPE_LIST_HEADLIST = [ { id: "elapsedYears", title: "경과년도", widthPercent: 12, align: "right" }, ]; +export const AGED_PIPE_EXCEL_FIELDS = [ + "ftreSmbolCd", + "fcltMngNo", + "pbadmsEmdCd", + "mapshtNo", + "mngInstCd", + "instlYmd", + "usgCd", + "mqltCd", + "fcltyShpCd", + "pdmtVl", + "wdthLen", + "vrtcLen", + "prlgVl", + "tmptDpth", + "scendDpth", + "avgGrdn", + "trflnCnt", + "exclnMltvArea", + "drtwMltvArea", + "rnyssnFvlcVl", + "clarFvlcVl", + "tmptMhlMngNo", + "scendMhlMngNo", + "instMngNo", + "fid", + "lyrTypeNm", + "swtrmtFcltCd", + "elapsedYears", + "ctpvNm", + "sggNm", +] as const; + export const AGED_PIPE_EXCEL_HEADER = { - rowNumber: "NO", + ftreSmbolCd: "지형지물부호코드", + pbadmsEmdCd: "행정읍면동코드", + mapshtNo: "도엽번호", + mngInstCd: "관리기관코드", + wdthLen: "가로길이", + vrtcLen: "세로길이", + tmptDpth: "시점깊이", + scendDpth: "종점깊이", + avgGrdn: "평균구배", + trflnCnt: "차선수", + exclnMltvArea: "우수배수면적", + drtwMltvArea: "오수배수면적", + rnyssnFvlcVl: "우기유속값", + clarFvlcVl: "맑음유속값", + tmptMhlMngNo: "시점맨홀관리번호", + scendMhlMngNo: "종점맨홀관리번호", + instMngNo: "기관관리번호", + fid: "FID", + lyrTypeNm: "레이어타입명", + swtrmtFcltCd: "시설코드", + fcltyShpCd: "시설물형태코드", fcltMngNo: "시설관리번호", instlYmd: "설치일자", pdmtVl: "관경(mm)", @@ -70,6 +130,21 @@ export const AGED_PIPE_EXCEL_HEADER = { sggNm: "시군구", }; +const getExcelColumnLetter = (columnIndex: number) => { + let current = columnIndex; + let result = ""; + + while (current > 0) { + const remainder = (current - 1) % 26; + result = String.fromCharCode(65 + remainder) + result; + current = Math.floor((current - 1) / 26); + } + + return result || "A"; +}; + +export const AGED_PIPE_EXCEL_HEADER_RANGE = `A1:${getExcelColumnLetter(AGED_PIPE_EXCEL_FIELDS.length)}1`; + const pad2 = (value: number) => String(value).padStart(2, "0"); const normalizeYmd = (value: unknown) => @@ -82,6 +157,8 @@ export const normalizeAgedPipeFilters = (filters: AgedPipeFilters): AgedPipeFilt return { ...filters, + ctpvCd: normalizeRegionFilterCode(filters.ctpvCd, 2), + sggCd: normalizeRegionFilterCode(filters.sggCd, 5), elapsedYears: Number.isFinite(elapsedYears) && elapsedYears >= 1 && elapsedYears <= 100 ? elapsedYears @@ -224,6 +301,16 @@ export const toDisplayPipeRow = ( }; }; +export const toAgedPipeExcelRow = (row: AgedPipeRow) => { + const exportRow: Record = {}; + + AGED_PIPE_EXCEL_FIELDS.forEach((field) => { + exportRow[field] = row[field] ?? "-"; + }); + + return exportRow; +}; + export const isAgedPipeLayerNode = (node: any) => { const data = node?.data ?? node; const layerName = String(data?.lyrNm ?? node?.text ?? ""); diff --git a/apps/sn/app/(map-layout)/map/components/analysisMenu/AnalysisPopupMenu.tsx b/apps/sn/app/(map-layout)/map/components/analysisMenu/AnalysisPopupMenu.tsx index f1d2590bc..53cf75946 100644 --- a/apps/sn/app/(map-layout)/map/components/analysisMenu/AnalysisPopupMenu.tsx +++ b/apps/sn/app/(map-layout)/map/components/analysisMenu/AnalysisPopupMenu.tsx @@ -1,50 +1,101 @@ "use client"; import { useState } from "react"; +import { + ChartColumnBig, + ChevronLeft, + ChevronRight, + History, + PanelsTopLeft, + PencilRuler, + Search, + Settings2, + TriangleAlert, + Waves, +} from "lucide-react"; import type { WindowPopupKey } from "@/features/windowPopup/model/popupDefinitions"; type Props = { onOpenPopup: (popupKey: WindowPopupKey) => void; + onOpenInfoSearch: () => void; }; -const ANALYSIS_POPUP_ITEMS: Array<{ key: WindowPopupKey; label: string }> = [ - { key: "pipeAnalysisPopup", label: "관로분석" }, - { key: "floodPredictionMapPopup", label: "침수예측지도" }, - { key: "floodHistoryPopup", label: "침수발생이력" }, - { key: "gisEditPopup", label: "GIS 편집" }, - { key: "gisManagementPopup", label: "GIS 관리" }, - { key: "flowRiskPopup", label: "통수 위험" }, +const ANALYSIS_POPUP_ITEMS: Array<{ + key: WindowPopupKey | "infoSearch"; + label: string; + icon: typeof ChartColumnBig; +}> = [ + { key: "infoSearch", label: "정보검색", icon: Search }, + { key: "pipeAnalysisPopup", label: "관로분석", icon: ChartColumnBig }, + { key: "floodPredictionMapPopup", label: "침수예측지도", icon: Waves }, + { key: "floodHistoryPopup", label: "침수발생이력", icon: History }, + { key: "gisEditPopup", label: "GIS 편집", icon: PencilRuler }, + { key: "gisManagementPopup", label: "GIS 관리", icon: Settings2 }, + { key: "flowRiskPopup", label: "통수 위험", icon: TriangleAlert }, ]; -export default function AnalysisPopupMenu({ onOpenPopup }: Props) { +export default function AnalysisPopupMenu({ onOpenPopup, onOpenInfoSearch }: Props) { const [isOpen, setIsOpen] = useState(true); + const [activeKey, setActiveKey] = useState(null); return ( -
+
{isOpen ? ( -
- {ANALYSIS_POPUP_ITEMS.map((item) => ( - - ))} +
+
+
+ + 분석도구 +
+
+ +
+ {ANALYSIS_POPUP_ITEMS.map((item) => { + const Icon = item.icon; + const isActive = activeKey === item.key; + + return ( + + ); + })} +
) : null}
diff --git a/apps/sn/app/(map-layout)/map/components/pipeAnalysis/PipeAnalysisDetailPopupPage.tsx b/apps/sn/app/(map-layout)/map/components/pipeAnalysis/PipeAnalysisDetailPopupPage.tsx index 200405c9a..c958d5caa 100644 --- a/apps/sn/app/(map-layout)/map/components/pipeAnalysis/PipeAnalysisDetailPopupPage.tsx +++ b/apps/sn/app/(map-layout)/map/components/pipeAnalysis/PipeAnalysisDetailPopupPage.tsx @@ -18,6 +18,7 @@ import { buildPipeAnalysisScope, normalizeNumberInput, resolveElapsedYearBucketLabel, + resolveMaterialKoreanName, resolveMaterialName, resolvePipeAnalysisTypeLabel, resolveRegionName, @@ -27,6 +28,19 @@ import { } from "./pipeAnalysisUtils"; type DetailViewRow = ReturnType[number]; +const PIPE_ANALYSIS_DETAIL_EXPORT_KEYS = [ + "rowNo", + "ctpvNm", + "sggNm", + "fcltMngNo", + "instMngNo", + "swtrmtZoneNm", + "instlYmd", + "useNm", + "pdmtVl", + "prlgVl", + "materialName", +] as const; export default function PipeAnalysisDetailPopupPage() { const searchParams = useSearchParams(); @@ -151,11 +165,25 @@ export default function PipeAnalysisDetailPopupPage() { try { setIsLoading(true); const response = await fetchRows(1, filters, Math.max(total, 1000)); + const exportRows = response.rows.map((row) => ({ + rowNo: row.rowNo, + ctpvNm: row.ctpvNm, + sggNm: row.sggNm, + fcltMngNo: row.fcltMngNo, + instMngNo: row.instMngNo, + swtrmtZoneNm: row.swtrmtZoneNm, + instlYmd: row.instlYmd, + useNm: row.useNm, + pdmtVl: row.pdmtVl, + prlgVl: row.prlgVl, + materialName: resolveMaterialKoreanName(row.materialCode) || row.materialName, + })); + const detailHeaderRange = `A1:${String.fromCharCode(64 + PIPE_ANALYSIS_DETAIL_EXPORT_KEYS.length)}1`; await downloadExcelWithKoreanHeaders({ - data: response.rows as unknown as JSON[], + data: exportRows as unknown as JSON[], fileName: `관로분석_관로현황_${buildPipeAnalysisFileDate()}`, sheetNameArray: ["관로현황"], - headerRange: "A1:K1", + headerRange: detailHeaderRange, headerMapping: PIPE_ANALYSIS_DETAIL_EXCEL_HEADERS, }); } catch (error) { diff --git a/apps/sn/app/(map-layout)/map/components/pipeAnalysis/pipeAnalysisUtils.ts b/apps/sn/app/(map-layout)/map/components/pipeAnalysis/pipeAnalysisUtils.ts index 7eeaa934b..fddb1717c 100644 --- a/apps/sn/app/(map-layout)/map/components/pipeAnalysis/pipeAnalysisUtils.ts +++ b/apps/sn/app/(map-layout)/map/components/pipeAnalysis/pipeAnalysisUtils.ts @@ -519,17 +519,69 @@ export const resolveMaterialName = (materialCode?: string) => { return materialMap.get(code) ?? code; }; +/** + * 관재질 라벨에서 영문 약어/코드를 걷어내고 엑셀 헤더용 한글명을 우선 추출한다. + * - `염화비닐관(PVC)` -> `염화비닐관` + * - `PCF관(강판보강 PE 나선형관)` -> `강판보강 나선형관` + */ +const toKoreanMaterialLabel = (value?: string) => { + const normalized = String(value ?? "").trim(); + if (!normalized) return ""; + + const withoutParenthesis = normalized + .replace(/\s*\([^)]*\)\s*/g, " ") + .replace(/\s+/g, " ") + .trim(); + const withoutAsciiToken = withoutParenthesis + .replace(/[A-Za-z0-9-]+/g, " ") + .replace(/\s+/g, " ") + .trim(); + + if (/[가-힣]/.test(withoutAsciiToken)) { + return withoutAsciiToken; + } + + if (/[가-힣]/.test(withoutParenthesis)) { + return withoutParenthesis; + } + + const parenthesisMatches = Array.from(normalized.matchAll(/\(([^)]*)\)/g)) + .map((match) => String(match[1] ?? "").trim()) + .filter(Boolean); + const koreanParenthesisText = parenthesisMatches.find((text) => /[가-힣]/.test(text)); + + if (koreanParenthesisText) { + const simplifiedParenthesisText = koreanParenthesisText + .replace(/[A-Za-z0-9-]+/g, " ") + .replace(/\s+/g, " ") + .trim(); + return simplifiedParenthesisText || koreanParenthesisText; + } + + return normalized; +}; + export const resolveMaterialColumnName = (column: PipeAnalysisMaterialColumn) => { const rawName = String(column.materialName ?? "").trim(); const rawCode = String(column.materialCode ?? "").trim(); const rawKey = String(column.key ?? "").trim(); - if (!rawName) { - return resolveMaterialName(rawCode || rawKey); - } - if (rawName === rawCode || rawName === rawKey) { - return resolveMaterialName(rawCode || rawKey); - } - return rawName; + const mappedName = resolveMaterialName(rawCode || rawKey); + const candidates = [ + toKoreanMaterialLabel(mappedName), + toKoreanMaterialLabel(rawName), + mappedName, + rawName, + rawCode, + rawKey, + ].filter(Boolean); + + return candidates[0] ?? "-"; +}; + +export const resolveMaterialKoreanName = (materialCode?: string) => { + const resolved = resolveMaterialName(materialCode); + const koreanLabel = toKoreanMaterialLabel(resolved); + return koreanLabel || resolved; }; export const resolveElapsedYearBucketLabel = (bucket?: string) => { diff --git a/apps/sn/app/(map-layout)/map/page.tsx b/apps/sn/app/(map-layout)/map/page.tsx index 635f9e1b3..7618ec521 100644 --- a/apps/sn/app/(map-layout)/map/page.tsx +++ b/apps/sn/app/(map-layout)/map/page.tsx @@ -36,11 +36,9 @@ import { ProcSearchBar } from "@common/business_components/ui/map/searchBar"; import { Button } from "@common/components"; import useSearchModalStore from "@/store/useSearchModalStore"; import useStore from "@/store/useStore"; -import { downloadExcelWithKoreanHeaders } from "@common/business_components/ui"; import AgedPipeLayerSettingPanel from "./components/agedPipe/AgedPipeLayerSettingPanel"; import type { PipeAnalysisDetailRow } from "./components/pipeAnalysis/pipeAnalysisUtils"; import { - AGED_PIPE_EXCEL_HEADER, AGED_PIPE_MIN_ZOOM, AGED_PIPE_LAYER_TYPE, AGED_PIPE_PAGE_SIZE, @@ -53,7 +51,9 @@ import { type AgedPipeFilters, type AgedPipeRow, AGED_PIPE_DEFAULT_FILTERS, + AGED_PIPE_GUIDE_MIN_ZOOM, } from "./components/agedPipe/agedPipeUtils"; +import { downloadAgedPipeCsv } from "./components/agedPipe/agedPipeExport"; import { PIPE_ANALYSIS_DETAIL_ROW_SELECT_MESSAGE } from "./components/pipeAnalysis/pipeAnalysisUtils"; import { openWindowPopup } from "@/features/windowPopup/lib/openWindowPopup"; import type { WindowPopupKey } from "@/features/windowPopup/model/popupDefinitions"; @@ -528,6 +528,7 @@ const Map = () => { const [agedPipeTotalCount, setAgedPipeTotalCount] = useState(0); const [agedPipeCurrentPage, setAgedPipeCurrentPage] = useState(1); const [isAgedPipeListLoading, setIsAgedPipeListLoading] = useState(false); + const [agedPipeDownloadProgressText, setAgedPipeDownloadProgressText] = useState(""); const { ctpv, sgg } = useStore(); const shouldFocusAgedPipeOnNextSelectRef = useRef(false); const agedPipePopupWindowRef = useRef(null); @@ -1018,21 +1019,32 @@ const Map = () => { try { setIsAgedPipeListLoading(true); - const response = await fetchAgedPipeRows(1, appliedAgedPipeFilters, 10000); - await downloadExcelWithKoreanHeaders({ - data: response.rows as JSON[], - fileName: `노후관로_현황_${new Date().toISOString().slice(0, 10)}`, - sheetNameArray: ["노후관로 현황"], - headerRange: "A1:J1", - headerMapping: AGED_PIPE_EXCEL_HEADER, + setAgedPipeDownloadProgressText("CSV export 작업을 준비하고 있습니다."); + await downloadAgedPipeCsv(appliedAgedPipeFilters, (job) => { + if (job.status === "QUEUED") { + setAgedPipeDownloadProgressText("CSV export 작업이 대기 중입니다."); + return; + } + + if (job.status === "RUNNING") { + setAgedPipeDownloadProgressText( + `CSV export 생성 중입니다. (${job.progressPercent ?? 0}%)`, + ); + } }); + setAgedPipeDownloadProgressText(""); } catch (error) { console.error("노후관로 목록 다운로드 중 오류:", error); - alert("노후관로 목록 다운로드 중 오류가 발생했습니다."); + setAgedPipeDownloadProgressText(""); + alert( + error instanceof Error + ? error.message + : "노후관로 CSV 다운로드 중 오류가 발생했습니다.", + ); } finally { setIsAgedPipeListLoading(false); } - }, [agedPipeTotalCount, appliedAgedPipeFilters, fetchAgedPipeRows]); + }, [agedPipeTotalCount, appliedAgedPipeFilters]); useEffect(() => { const handleAgedPipePopupMessage = (event: MessageEvent) => { @@ -1224,13 +1236,21 @@ const Map = () => { return ( <> -
+
{layerLoadError ? (
레이어 설정 조회 실패: {layerLoadError}
) : null} - + {agedPipeDownloadProgressText ? ( +
+ {agedPipeDownloadProgressText} +
+ ) : null} + setIsSearchModalOpen(true)} + /> { setIsSearchModalOpen={setIsSearchModalOpen} onCheckedLayersChange={handleCheckedLayersChange} onOpenPopup={handleOpenLayerPopup} - searchBar={} + searchBar={ + + } waterDepth={showWaterDepth ? WaterDepth : undefined} inlineLayerSettingView={inlineLayerSettingView} layerIconVisible={isAgedPipeLayerNode} @@ -1257,6 +1283,17 @@ const Map = () => { checkInterval={2500} checkedLayerChangeDelay={0} showZoomLevel={true} + showZoomScaleInfo={true} + showFloatingSearchButton={false} + treeTogglePosition="top" + zoomGuide={ + isAgedPipeChecked + ? { + label: "노후관로 기준", + zoom: AGED_PIPE_GUIDE_MIN_ZOOM, + } + : undefined + } defaultZoom={7.5} defaultLonLat={[127.75, 36]} @@ -1293,6 +1330,37 @@ const Map = () => {
)} +
); diff --git a/apps/sn/app/components/lib/api/index.ts b/apps/sn/app/components/lib/api/index.ts index 08d2dfbe8..28c9f4500 100644 --- a/apps/sn/app/components/lib/api/index.ts +++ b/apps/sn/app/components/lib/api/index.ts @@ -101,6 +101,46 @@ export const postData = async (url: string, data: object): Promise => { } }; +const parseDownloadFileName = (contentDisposition: string | null) => { + if (!contentDisposition) return undefined; + + const utf8Match = contentDisposition.match(/filename\*\s*=\s*UTF-8''([^;]+)/i); + if (utf8Match?.[1]) { + return decodeURIComponent(utf8Match[1].trim()); + } + + const plainMatch = contentDisposition.match(/filename\s*=\s*"?([^\";]+)"?/i); + return plainMatch?.[1]?.trim(); +}; + +export const postBlobData = async ( + url: string, + data: object, +): Promise<{ blob: Blob; fileName?: string }> => { + const res = await fetch(resolveApiUrl(url), { + method: "POST", + headers: withSiteIdHeaders({ + "Content-Type": "application/json", + }), + body: JSON.stringify(data), + }); + if (res.status === 401) { + handleUnauthorizedResponse(); + } + + if (!res.ok) { + const errorText = await res.text().catch(() => ""); + throw new Error( + `Failed to fetch blob: ${res.status} ${res.statusText}${errorText ? ` - ${errorText.slice(0, 200)}` : ""} (${url})`, + ); + } + + return { + blob: await res.blob(), + fileName: parseDownloadFileName(res.headers.get("content-disposition")), + }; +}; + export const postMutiPart = async (url: string, data: FormData): Promise => { const res = await axios.post(`${BASE_URL}/${url}`, data, { headers: DEFAULT_HEADERS, diff --git a/apps/sn/app/store/index.ts b/apps/sn/app/store/index.ts index 91577ae1a..7d761f91a 100644 --- a/apps/sn/app/store/index.ts +++ b/apps/sn/app/store/index.ts @@ -33,8 +33,8 @@ export const fetchCtpv = async (set: (state: any) => void) => { const list = res.features .map((f: any) => ({ - text: f.properties.mctpv_korn_nm, - val: f.properties.mctpv_cd, + text: String(f.properties.mctpv_korn_nm ?? "").trim(), + val: String(f.properties.mctpv_cd ?? "").trim(), })) .sort((a:any, b:any) => { // 커스텀 순서 정의 @@ -83,8 +83,8 @@ export const fetchSgg = async (set: (state: any) => void) => { const list = res.features .map((f: any) => ({ - text: f.properties.sgg_korn_nm, - val: f.properties.sgg_cd, + text: String(f.properties.sgg_korn_nm ?? "").trim(), + val: String(f.properties.sgg_cd ?? "").trim(), })) .sort((a:any, b:any) => a.text.localeCompare(b.text, 'ko')); diff --git a/apps/st/src/components/button/ApproveButton.tsx b/apps/st/src/components/button/ApproveButton.tsx index 36455f7ff..76d3618f5 100644 --- a/apps/st/src/components/button/ApproveButton.tsx +++ b/apps/st/src/components/button/ApproveButton.tsx @@ -1,7 +1,7 @@ import { Button } from "@common/components"; interface ApproveButtonProps { - approveFunc: () => void; + approveFunc: () => void | Promise; } const ApproveButton = ({ approveFunc }: ApproveButtonProps) => { diff --git a/apps/st/src/components/css/DataGrid.css b/apps/st/src/components/css/DataGrid.css index 8a2d8d928..a79e3ebd4 100644 --- a/apps/st/src/components/css/DataGrid.css +++ b/apps/st/src/components/css/DataGrid.css @@ -1,21 +1,6 @@ @tailwind components; @layer components { - .rg-grid .rg-header .rg-table .rg-header-group-cell { - @apply !bg-[#466995] !text-[#fff]; - } - .rg-grid .rg-header .rg-table { - @apply !bg-[#5987bf] !text-[#fff]; - } - - /* 상태바·체크바 헤더 */ - .rg-grid .rg-head .rg-table tr:first-child td:first-child, - .rg-grid .rg-head .rg-table tr:first-child td:nth-child(2), - .rg-grid .rg-header .rg-table tr:first-child td:first-child, - .rg-grid .rg-header .rg-table tr:first-child td:nth-child(2) { - @apply !bg-[#5987bf] !text-[#fff]; - } - .rg-grid .rg-body .rg-table .rg-data-row .rg-data-cell { @apply !bg-white; } @@ -36,6 +21,91 @@ .rg-grid .rg-body .rg-table .rg-data-row .rg-data-cell.rg-cell-vrf-prcs-ok { @apply !font-semibold !text-[#3372cf]; } + /* 개별 반려: 높이/레이아웃은 RealGrid 행에 그대로 두고, 색·테두리·정렬만 버튼 느낌 */ + .rg-grid .rg-body .rg-table .rg-data-row .rg-data-cell.rg-cell-reject-mini-btn { + text-align: center !important; + vertical-align: middle !important; + box-sizing: border-box !important; + padding-left: 8px !important; + padding-right: 8px !important; + border-radius: 0.25rem !important; + border: 1px solid rgb(248 113 113 / 0.55) !important; + background-color: rgb(254 242 242) !important; + color: rgb(185 28 28) !important; + font-size: 1.2rem !important; + font-weight: 600 !important; + white-space: nowrap !important; + cursor: pointer !important; + text-decoration: none !important; + } + .rg-grid .rg-body .rg-table .rg-data-row .rg-data-cell.rg-cell-reject-mini-btn:hover { + background-color: rgb(254 226 226) !important; + border-color: rgb(239 68 68 / 0.85) !important; + color: rgb(153 27 27) !important; + } + .rg-grid .rg-body .rg-table .rg-data-row .rg-data-cell.rg-cell-reject-mini-btn:active { + background-color: rgb(254 202 202) !important; + } + .rg-grid .rg-body .rg-table .rg-data-row .rg-data-cell.rg-cell-outline-action-btn { + text-align: center !important; + vertical-align: middle !important; + box-sizing: border-box !important; + padding-left: 8px !important; + padding-right: 8px !important; + border-radius: 0.25rem !important; + border: 1px solid rgb(148 163 184 / 0.7) !important; + background-color: rgb(248 250 252) !important; + color: rgb(51 65 85) !important; + font-size: 1.2rem !important; + font-weight: 600 !important; + white-space: nowrap !important; + cursor: pointer !important; + text-decoration: none !important; + } + .rg-grid .rg-body .rg-table .rg-data-row .rg-data-cell.rg-cell-outline-action-btn:hover { + background-color: rgb(241 245 249) !important; + border-color: rgb(100 116 139 / 0.85) !important; + color: rgb(30 41 59) !important; + } + .rg-grid .rg-body .rg-table .rg-data-row .rg-data-cell.rg-cell-outline-action-btn:active { + background-color: rgb(226 232 240) !important; + } + .rg-grid .rg-body .rg-table .rg-data-row .rg-data-cell.rg-cell-action-link { + @apply !font-semibold !text-[#2563eb] underline; + cursor: pointer !important; + } + .rg-grid .rg-body .rg-table .rg-data-row .rg-data-cell.rg-cell-stats-status-pending { + @apply !text-gray-500; + } + .rg-grid .rg-body .rg-table .rg-data-row .rg-data-cell.rg-cell-stats-status-submitted { + @apply !font-semibold; + } + .rg-grid .rg-body .rg-table .rg-data-row .rg-data-cell.rg-cell-stats-status-approved { + @apply !font-semibold; + } + .rg-grid .rg-body .rg-table .rg-data-row .rg-data-cell.rg-cell-stats-status-rejected { + @apply !font-semibold !text-[#ff6c64]; + white-space: nowrap !important; + } + .rg-grid .rg-body .rg-table .rg-data-row .rg-data-cell.rg-cell-stats-status-rejected > * { + display: inline-flex !important; + align-items: center !important; + justify-content: center; + width: max-content; + margin: 0 auto; + } + .rg-grid .rg-body .rg-table .rg-data-row .rg-data-cell.rg-cell-stats-status-rejected > *::after { + content: ""; + display: inline-block; + margin-left: 0.25rem; + vertical-align: middle; + width: 0.6125rem; + height: 0.6125rem; + border-radius: 9999px; + background: #ff6c64; + box-shadow: 0 0 0 0 rgba(255, 108, 100, 0.75); + animation: rg-cell-stats-status-dot-ping 1s cubic-bezier(0, 0, 0.2, 1) infinite; + } .rg-grid .rg-body .rg-table .rg-data-row .rg-data-cell.rg-cell-total { @apply !bg-[#e3eaf2]; } @@ -85,59 +155,12 @@ .data-grid-status-bubble > span + span { @apply ml-4 border-l border-slate-200/70 pl-4; } +} - [data-theme="black"] { - .rg-grid .rg-header .rg-table .rg-header-group-cell { - @apply !bg-[#3C4043] !text-[#fff]; - } - .rg-grid .rg-header .rg-table .rg-header-cell { - @apply !bg-[#62686d] !text-[#fff]; - } - .rg-grid .rg-head .rg-table tr:first-child td:first-child, - .rg-grid .rg-head .rg-table tr:first-child td:nth-child(2), - .rg-grid .rg-header .rg-table tr:first-child td:first-child, - .rg-grid .rg-header .rg-table tr:first-child td:nth-child(2) { - @apply !bg-[#62686d] !text-[#fff]; - } - .rg-grid .rg-body .rg-table .rg-data-row .rg-data-cell { - @apply !bg-[#545556] !text-[#fff]; - } - .rg-grid .rg-body .rg-table .rg-data-row .rg-data-cell.rg-cell-vrf-prcs-bad { - @apply !text-[#f87171]; - } - .rg-grid .rg-body .rg-table .rg-data-row .rg-data-cell.rg-cell-vrf-prcs-ok { - @apply !text-[#3372cf]; - } - } - - [data-theme="red"] { - .rg-grid .rg-header .rg-table .rg-header-group-cell { - @apply !bg-[#646469] !text-[#fff]; - } - .rg-grid .rg-header .rg-table .rg-header-cell { - @apply !bg-[#8d8d94] !text-[#fff]; - } - .rg-grid .rg-head .rg-table tr:first-child td:first-child, - .rg-grid .rg-head .rg-table tr:first-child td:nth-child(2), - .rg-grid .rg-header .rg-table tr:first-child td:first-child, - .rg-grid .rg-header .rg-table tr:first-child td:nth-child(2) { - @apply !bg-[#8d8d94] !text-[#fff]; - } - } - - [data-theme="green"] { - .rg-grid .rg-header .rg-table .rg-header-group-cell { - @apply !bg-[#518585] !text-[#fff]; - } - .rg-grid .rg-header .rg-table .rg-header-cell { - @apply !bg-[#6bafae] !text-[#fff]; - } - .rg-grid .rg-head .rg-table tr:first-child td:first-child, - .rg-grid .rg-head .rg-table tr:first-child td:nth-child(2), - .rg-grid .rg-header .rg-table tr:first-child td:first-child, - .rg-grid .rg-header .rg-table tr:first-child td:nth-child(2) { - @apply !bg-[#6bafae] !text-[#fff]; - } +@keyframes rg-cell-stats-status-dot-ping { + 75%, + 100% { + box-shadow: 0 0 0 0.5rem rgba(255, 108, 100, 0); } } @@ -164,4 +187,130 @@ .rg-grid .rg-head .rg-table tr td { border: none; border-bottom: 1px solid #e5e7eb; -} \ No newline at end of file +} + +/* + * ST 통계 그리드 헤더 색 — @layer 밖(일반 규칙). + * packages/rsone/components/grid/css/DataGrid.css 등 @layer 밖 !important와 동일 층에서 + * .rg-grid … 특이도로 덮어씀. + */ +.rg-grid .rg-header .rg-table .rg-header-group-cell { + background-color: #5987bf !important; + color: #ffffff !important; +} +.rg-grid .rg-header .rg-table { + background-color: #5987bf !important; + color: #ffffff !important; +} +.rg-grid .rg-head .rg-table tr:first-child td:first-child, +.rg-grid .rg-head .rg-table tr:first-child td:nth-child(2), +.rg-grid .rg-header .rg-table tr:first-child td:first-child, +.rg-grid .rg-header .rg-table tr:first-child td:nth-child(2) { + background-color: #5987bf !important; + color: #ffffff !important; +} +.rg-grid .rg-header .rg-cell, +.rg-grid .rg-head .rg-cell { + background-color: #5987bf !important; + color: #ffffff !important; +} + +.rg-header .rg-table tr td:hover { background: #5987bf; } + +[data-theme="black"] .rg-grid .rg-header .rg-table .rg-header-group-cell { + background-color: #3c4043 !important; + color: #ffffff !important; +} +[data-theme="black"] .rg-grid .rg-header .rg-table .rg-header-cell { + background-color: #62686d !important; + color: #ffffff !important; +} +[data-theme="black"] .rg-grid .rg-head .rg-table tr:first-child td:first-child, +[data-theme="black"] .rg-grid .rg-head .rg-table tr:first-child td:nth-child(2), +[data-theme="black"] .rg-grid .rg-header .rg-table tr:first-child td:first-child, +[data-theme="black"] .rg-grid .rg-header .rg-table tr:first-child td:nth-child(2) { + background-color: #62686d !important; + color: #ffffff !important; +} +[data-theme="black"] .rg-grid .rg-header .rg-cell, +[data-theme="black"] .rg-grid .rg-head .rg-cell { + background-color: #62686d !important; + color: #ffffff !important; +} +[data-theme="black"] .rg-header .rg-table tr td:hover { + background-color: #62686d !important; + color: #ffffff !important; +} + +[data-theme="black"] .rg-grid .rg-body .rg-table .rg-data-row .rg-data-cell { + background-color: #545556 !important; + color: #ffffff !important; +} +[data-theme="black"] .rg-grid .rg-body .rg-table .rg-data-row .rg-data-cell.rg-cell-vrf-prcs-bad { + color: #f87171 !important; +} +[data-theme="black"] .rg-grid .rg-body .rg-table .rg-data-row .rg-data-cell.rg-cell-vrf-prcs-ok { + color: #3372cf !important; +} +[data-theme="black"] .rg-grid .rg-body .rg-table .rg-data-row .rg-data-cell.rg-cell-reject-mini-btn { + background-color: rgb(127 29 29 / 0.42) !important; + border-color: rgb(248 113 113 / 0.5) !important; + color: #fecaca !important; +} +[data-theme="black"] .rg-grid .rg-body .rg-table .rg-data-row .rg-data-cell.rg-cell-reject-mini-btn:hover { + background-color: rgb(153 27 27 / 0.55) !important; + border-color: rgb(252 165 165 / 0.75) !important; + color: #fee2e2 !important; +} +[data-theme="black"] .rg-grid .rg-body .rg-table .rg-data-row .rg-data-cell.rg-cell-outline-action-btn { + background-color: rgb(71 85 105 / 0.35) !important; + border-color: rgb(148 163 184 / 0.55) !important; + color: #e2e8f0 !important; +} +[data-theme="black"] .rg-grid .rg-body .rg-table .rg-data-row .rg-data-cell.rg-cell-outline-action-btn:hover { + background-color: rgb(71 85 105 / 0.5) !important; + border-color: rgb(203 213 225 / 0.65) !important; + color: #f8fafc !important; +} + +[data-theme="red"] .rg-grid .rg-header .rg-table .rg-header-group-cell { + background-color: #646469 !important; + color: #ffffff !important; +} +[data-theme="red"] .rg-grid .rg-header .rg-table .rg-header-cell { + background-color: #8d8d94 !important; + color: #ffffff !important; +} +[data-theme="red"] .rg-grid .rg-head .rg-table tr:first-child td:first-child, +[data-theme="red"] .rg-grid .rg-head .rg-table tr:first-child td:nth-child(2), +[data-theme="red"] .rg-grid .rg-header .rg-table tr:first-child td:first-child, +[data-theme="red"] .rg-grid .rg-header .rg-table tr:first-child td:nth-child(2) { + background-color: #8d8d94 !important; + color: #ffffff !important; +} +[data-theme="red"] .rg-grid .rg-header .rg-cell, +[data-theme="red"] .rg-grid .rg-head .rg-cell { + background-color: #8d8d94 !important; + color: #ffffff !important; +} + +[data-theme="green"] .rg-grid .rg-header .rg-table .rg-header-group-cell { + background-color: #518585 !important; + color: #ffffff !important; +} +[data-theme="green"] .rg-grid .rg-header .rg-table .rg-header-cell { + background-color: #6bafae !important; + color: #ffffff !important; +} +[data-theme="green"] .rg-grid .rg-head .rg-table tr:first-child td:first-child, +[data-theme="green"] .rg-grid .rg-head .rg-table tr:first-child td:nth-child(2), +[data-theme="green"] .rg-grid .rg-header .rg-table tr:first-child td:first-child, +[data-theme="green"] .rg-grid .rg-header .rg-table tr:first-child td:nth-child(2) { + background-color: #6bafae !important; + color: #ffffff !important; +} +[data-theme="green"] .rg-grid .rg-header .rg-cell, +[data-theme="green"] .rg-grid .rg-head .rg-cell { + background-color: #6bafae !important; + color: #ffffff !important; +} diff --git a/apps/st/src/components/module/CompactDataGrid.tsx b/apps/st/src/components/module/CompactDataGrid.tsx index c96f22973..6de651a1d 100644 --- a/apps/st/src/components/module/CompactDataGrid.tsx +++ b/apps/st/src/components/module/CompactDataGrid.tsx @@ -71,6 +71,38 @@ function createNumberComparer(dp: RealGridDataProviderComparer): (field: string }; } +function escapeHtml(value: string): string { + return value + .replace(/&/g, "&") + .replace(//g, ">") + .replace(/"/g, """) + .replace(/'/g, "'"); +} + +function realGridHeaderWithTooltip( + header: T, +): T { + const tip = header.tooltip?.trim(); + if (!tip) return header; + return { + ...header, + tooltip: tip, + showTooltip: header.showTooltip ?? true, + template: + header.template ?? + `${escapeHtml(header.text)}`, + }; +} + +function resolveColumnTooltip(column: { + tooltip?: string; + header: { text: string; tooltip?: string; showTooltip?: boolean; template?: string }; +}): string | undefined { + const tip = column.tooltip?.trim() || column.header.tooltip?.trim(); + return tip && tip.length > 0 ? tip : undefined; +} + const CompactDataGrid = ({ columnInfo, fixedColumns, @@ -88,8 +120,10 @@ const CompactDataGrid = ({ }: DataGridProps) => { const { columns, columnLayout } = columnInfo; const gridRef = useRef(null); + const columnsRef = useRef(columns); const rowDataRef = useRef[]>(rowData); const cellStyleCallbackRef = useRef(cellStyleCallback); + columnsRef.current = columns; rowDataRef.current = rowData; cellStyleCallbackRef.current = cellStyleCallback; @@ -155,6 +189,66 @@ const CompactDataGrid = ({ }); gridView.header.height = 60; + if (columns.some((c) => Boolean(resolveColumnTooltip(c)))) { + (gridView.header as { showTooltip?: boolean }).showTooltip = true; + } + columns.forEach((column) => { + ( + gridView as { + setColumnProperty?: ( + col: string, + key: string, + value: unknown, + ) => void; + } + ).setColumnProperty?.( + column.name, + "header", + realGridHeaderWithTooltip({ + ...column.header, + tooltip: resolveColumnTooltip(column), + }), + ); + }); + ( + gridView as { + onShowHeaderTooltip?: ( + grid: unknown, + column: { + name?: string; + fieldName?: string; + header?: { tooltip?: string }; + }, + value: string, + layout?: { + name?: string; + fieldName?: string; + column?: string | { name?: string; fieldName?: string }; + }, + isEllipsis?: boolean, + ) => string | undefined; + } + ).onShowHeaderTooltip = (_grid, headerColumn, _value, layout) => { + const currentColumns = columnsRef.current; + const directTip = headerColumn?.header?.tooltip?.trim(); + if (directTip) return directTip; + + const layoutColumn = + typeof layout?.column === "string" + ? layout.column + : layout?.column?.name ?? layout?.column?.fieldName; + + const col = currentColumns.find( + (c) => + c.name === headerColumn?.name || + c.fieldName === headerColumn?.fieldName || + c.name === layout?.name || + c.fieldName === layout?.fieldName || + c.name === layoutColumn || + c.fieldName === layoutColumn, + ); + return col ? resolveColumnTooltip(col) : undefined; + }; // 헤더 체크박스 표시 여부 설정 gridView.setOptions({ @@ -235,7 +329,10 @@ const CompactDataGrid = ({ const colInfo: RealGridColumnInfo = { name: col.name, fieldName: col.fieldName, - header: col.header, + header: realGridHeaderWithTooltip({ + ...col.header, + tooltip: resolveColumnTooltip(col), + }), editable: col.editable, valueType: col.valueType, dataFormat: col.dataFormat, @@ -427,7 +524,10 @@ const CompactDataGrid = ({ fieldName={column.fieldName} valueType={column.valueType ? column.valueType : ValueType.TEXT} {...(column.valueType === ValueType.NUMBER && { numberFormat: column.dataFormat ? column.dataFormat : '#,##0.000' })} - header={column.header} + header={realGridHeaderWithTooltip({ + ...column.header, + tooltip: resolveColumnTooltip(column), + })} editable={column.editable} {...(column.styleName != null ? { styleName: column.styleName } diff --git a/apps/st/src/components/module/DataGrid.tsx b/apps/st/src/components/module/DataGrid.tsx index acadc607a..0ae40300a 100644 --- a/apps/st/src/components/module/DataGrid.tsx +++ b/apps/st/src/components/module/DataGrid.tsx @@ -81,6 +81,42 @@ function createNumberComparer( }; } +function escapeHtml(value: string): string { + return value + .replace(/&/g, "&") + .replace(//g, ">") + .replace(/"/g, """) + .replace(/'/g, "'"); +} + +/** + * RealGrid 헤더 툴팁: `grid.header.showTooltip`이 비어 있으면 `column.header.showTooltip === true`인 컬럼만 표시됨. + * `tooltip`이 있는 헤더는 info-circle 아이콘이 함께 보이도록 template도 자동 구성한다. + */ +function realGridHeaderWithTooltip( + header: T, +): T { + const tip = header.tooltip?.trim(); + if (!tip) return header; + return { + ...header, + tooltip: tip, + showTooltip: header.showTooltip ?? true, + template: + header.template ?? + `${escapeHtml(header.text)}`, + }; +} + +function resolveColumnTooltip(column: { + tooltip?: string; + header: { text: string; tooltip?: string; showTooltip?: boolean; template?: string }; +}): string | undefined { + const tip = column.tooltip?.trim() || column.header.tooltip?.trim(); + return tip && tip.length > 0 ? tip : undefined; +} + /** 정렬 필드 값을 숫자로 파싱 (쉼표 제거) */ function toNum(v: unknown): number { if (v == null) return NaN; @@ -342,6 +378,7 @@ const DataGrid = ({ cellTooltipCallback, onCellEdited, onRowClick, + onDataCellClick, onItemChecked, onItemAllChecked, onScrollToBottom, @@ -354,6 +391,7 @@ const DataGrid = ({ const { columns, columnLayout } = columnInfo; const gridRef = useRef(null); const fileInputRef = useRef(null); + const columnsRef = useRef(columns); const sortDirRef = useRef>({}); const originalRowsRef = useRef< Record[]> @@ -366,12 +404,14 @@ const DataGrid = ({ cellTooltipCallback, ); const onCellEditedRef = useRef(onCellEdited); + const onDataCellClickRef = useRef(onDataCellClick); const onScrollToBottomRef = useRef<(() => void) | undefined>( onScrollToBottom, ); const onItemCheckedRef = useRef(onItemChecked); const onItemAllCheckedRef = useRef(onItemAllChecked); + columnsRef.current = columns; rowDataRef.current = rowData; cellStyleCallbackRef.current = cellStyleCallback; cellTooltipCallbackRef.current = cellTooltipCallback; @@ -379,6 +419,7 @@ const DataGrid = ({ onItemCheckedRef.current = onItemChecked; onItemAllCheckedRef.current = onItemAllChecked; onCellEditedRef.current = onCellEdited; + onDataCellClickRef.current = onDataCellClick; // highlightedRowId가 변경될 때 해당 행으로 스크롤 및 선택 useEffect(() => { @@ -813,6 +854,66 @@ const DataGrid = ({ GridFitStyle.EVEN; gridView.displayOptions.rowHeight = 30; gridView.header.height = 60; + if (columns.some((c) => Boolean(resolveColumnTooltip(c)))) { + (gridView.header as { showTooltip?: boolean }).showTooltip = true; + } + columns.forEach((column) => { + ( + gridView as { + setColumnProperty?: ( + col: string, + key: string, + value: unknown, + ) => void; + } + ).setColumnProperty?.( + column.name, + "header", + realGridHeaderWithTooltip({ + ...column.header, + tooltip: resolveColumnTooltip(column), + }), + ); + }); + ( + gridView as { + onShowHeaderTooltip?: ( + grid: unknown, + column: { + name?: string; + fieldName?: string; + header?: { tooltip?: string }; + }, + value: string, + layout?: { + name?: string; + fieldName?: string; + column?: string | { name?: string; fieldName?: string }; + }, + isEllipsis?: boolean, + ) => string | undefined; + } + ).onShowHeaderTooltip = (_grid, headerColumn, _value, layout) => { + const currentColumns = columnsRef.current; + const directTip = headerColumn?.header?.tooltip?.trim(); + if (directTip) return directTip; + + const layoutColumn = + typeof layout?.column === "string" + ? layout.column + : layout?.column?.name ?? layout?.column?.fieldName; + + const col = currentColumns.find( + (c) => + c.name === headerColumn?.name || + c.fieldName === headerColumn?.fieldName || + c.name === layout?.name || + c.fieldName === layout?.fieldName || + c.name === layoutColumn || + c.fieldName === layoutColumn, + ); + return col ? resolveColumnTooltip(col) : undefined; + }; // 헤더 체크박스 표시 여부 설정 gridView.setOptions({ @@ -820,9 +921,17 @@ const DataGrid = ({ rowIndicator: { visible: false }, }); - // footer가 있는 컬럼이 있으면 footer 높이 설정 - const hasFooter = columns.some((col) => col.footer); - if (hasFooter) { + const applyFooterSettings = ( + targetGridView: typeof gridView, + ) => { + const hasFooter = columns.some((col) => col.footer); + if (!hasFooter) return; + + targetGridView.setOptions({ + summaryMode: "aggregate", + footer: { visible: true }, + }); + // footer가 배열이거나 multiRow가 true인 footer가 있으면 멀티푸터 설정 (여러 행) const hasArrayFooter = columns.some( (col) => col.footer && Array.isArray(col.footer), @@ -837,23 +946,24 @@ const DataGrid = ({ ); }); if (hasArrayFooter || hasMultiRowFooter) { - // footer 배열의 길이를 확인하여 행 수 결정 const arrayFooterLengths = columns .filter((col) => col.footer && Array.isArray(col.footer)) .map((col) => (col.footer as unknown[]).length); const maxFooterLength = arrayFooterLengths.length > 0 ? Math.max(...arrayFooterLengths) - : 2; // 기본값 2 + : 2; const footerHeights = Array.from( { length: maxFooterLength }, () => ({ height: 40 }), ); - gridView.setFooters?.(footerHeights); + targetGridView.setFooters?.(footerHeights); } else { - gridView.footer.height = 40; + targetGridView.footer.height = 40; } - } + }; + + applyFooterSettings(gridView); // 고정 컬럼 설정 if (fixedColumns !== undefined && fixedColumns > 0) { @@ -862,10 +972,6 @@ const DataGrid = ({ }); } - gridView.setOptions({ - summaryMode: "aggregate", - }); - // footerUserSpans가 있는 경우 setColumnLayout으로 설정 const checkFooterUserSpans = (layout: unknown[]): boolean => { return layout.some((item) => { @@ -888,7 +994,9 @@ const DataGrid = ({ }); }; - if (checkFooterUserSpans(columnLayout)) { + const hasFooterUserSpans = checkFooterUserSpans(columnLayout); + + if (hasFooterUserSpans) { // setColumnLayout은 그리드가 완전히 렌더링된 후 호출해야 함 setTimeout(() => { if (gridRef.current?.gridView && columnLayout) { @@ -917,7 +1025,7 @@ const DataGrid = ({ const colInfo: RealGridColumnInfo = { name: col.name, fieldName: col.fieldName, - header: col.header, + header: realGridHeaderWithTooltip(col.header), editable: col.editable, valueType: col.valueType, dataFormat: col.dataFormat, @@ -951,6 +1059,10 @@ const DataGrid = ({ (currentGridView as GridViewWithSetColumns).setColumns?.( columnsWithFooter, ); + applyFooterSettings(currentGridView); + if (hasFooterUserSpans) { + currentGridView.setColumnLayout?.(columnLayout); + } // setColumns 호출 후 footer 배열의 valueCallback을 다시 설정 setTimeout(() => { @@ -991,6 +1103,11 @@ const DataGrid = ({ ); } }); + applyFooterSettings(currentGridView); + if (hasFooterUserSpans) { + currentGridView.setColumnLayout?.(columnLayout); + } + currentGridView.refresh?.(); }, 50); } catch (e) { console.warn("Failed to set columns with footer array:", e); @@ -1066,7 +1183,11 @@ const DataGrid = ({ // 행 클릭 이벤트 + 검증 오류 셀 클릭 시 showMessage (공식 문서 방식) useEffect(() => { const gridView = gridRef.current?.gridView; - if (!gridView || (!onRowClick && !cellTooltipCallback)) return; + if ( + !gridView || + (!onRowClick && !cellTooltipCallback && !onDataCellClick) + ) + return; gridView.onCellClicked = (grid, clickData) => { if ( @@ -1085,10 +1206,10 @@ const DataGrid = ({ }; } ).getCurrent?.(); + const fieldName = + cellIndex?.fieldName ?? cellIndex?.column ?? ""; // cellTooltipCallback 있으면 해당 셀 메시지로 showMessage (공식 문서: TooltipOptions + CellIndex) if (cellTooltipCallbackRef.current && row && cellIndex) { - const fieldName = - cellIndex.fieldName ?? cellIndex.column ?? ""; const msg = cellTooltipCallbackRef.current({ row: row as Record, fieldName, @@ -1118,6 +1239,13 @@ const DataGrid = ({ ); } } + if (onDataCellClickRef.current && row !== undefined) { + onDataCellClickRef.current({ + row: (row ?? {}) as Record, + fieldName, + itemIndex: clickData.itemIndex, + }); + } if (onRowClick) onRowClick((row ?? {}) as Record); } catch (error) { @@ -1128,7 +1256,7 @@ const DataGrid = ({ return () => { if (gridView.onCellClicked) gridView.onCellClicked = undefined; }; - }, [onRowClick, cellTooltipCallback, columns]); + }, [onRowClick, onDataCellClick, cellTooltipCallback, columns]); useEffect(() => { const gridView = gridRef.current?.gridView; @@ -1334,6 +1462,48 @@ const DataGrid = ({ setTimeout(() => { const gv = gridRef.current?.gridView; if (!gv?.columnByName) return; + // 공식 문서: 헤더 툴팁 = header.showTooltip + onShowHeaderTooltip + ( + gv as { + onShowHeaderTooltip?: ( + grid: unknown, + column: { + name?: string; + fieldName?: string; + header?: { + tooltip?: string; + }; + }, + value: string, + layout?: { + name?: string; + fieldName?: string; + column?: string | { name?: string; fieldName?: string }; + }, + isEllipsis?: boolean, + ) => string | undefined; + } + ).onShowHeaderTooltip = (_grid, headerColumn, _value, layout) => { + const currentColumns = columnsRef.current; + const directTip = headerColumn?.header?.tooltip?.trim(); + if (directTip) return directTip; + + const layoutColumn = + typeof layout?.column === "string" + ? layout.column + : layout?.column?.name ?? layout?.column?.fieldName; + + const col = currentColumns.find( + (c) => + c.name === headerColumn?.name || + c.fieldName === headerColumn?.fieldName || + c.name === layout?.name || + c.fieldName === layout?.fieldName || + c.name === layoutColumn || + c.fieldName === layoutColumn, + ); + return col ? resolveColumnTooltip(col) : undefined; + }; // 공식 문서: 데이터 영역 툴팁 = renderer.showTooltip + onShowTooltip (displayOptions는 Ellipsis용이라 사용 안 함) if (cellTooltipCallbackRef.current) { ( @@ -1387,6 +1557,22 @@ const DataGrid = ({ renderer?: { showTooltip?: boolean }; } | null; if (gridCol) { + ( + gv as { + setColumnProperty?: ( + col: string, + key: string, + value: unknown, + ) => void; + } + ).setColumnProperty?.( + column.name, + "header", + realGridHeaderWithTooltip({ + ...column.header, + tooltip: resolveColumnTooltip(column), + }), + ); if (cellTooltipCallbackRef.current) { ( gv as { @@ -1456,7 +1642,10 @@ const DataGrid = ({ ? column.dataFormat : "#,##0.000", })} - header={column.header} + header={realGridHeaderWithTooltip({ + ...column.header, + tooltip: resolveColumnTooltip(column), + })} editable={column.editable} {...(column.width != null && { width: column.width, diff --git a/apps/st/src/components/module/ExceptionProcessingHistoryDialog.tsx b/apps/st/src/components/module/ExceptionProcessingHistoryDialog.tsx new file mode 100644 index 000000000..483346a6c --- /dev/null +++ b/apps/st/src/components/module/ExceptionProcessingHistoryDialog.tsx @@ -0,0 +1,617 @@ +"use client"; + +import { type Dispatch, type MutableRefObject, type SetStateAction, useCallback, useEffect, useMemo, useRef, useState } from "react"; + +import { RealGridReact } from "realgrid-react"; + +import { getUserInfo } from "../../../../../packages/common/components/src/api"; +import { useAlertStore } from "@/shared/lib"; +import { statisticsApi } from "@/entities/statistics/api/statistics-service"; +import type { + ReviewInputSaveRequest, + VerificationResultListRequest, + VerificationResultReportItem, +} from "@/entities/statistics/model/statistics-interface"; +import { + Button, + Dialog, + DialogClose, + DialogContent, + DialogFooter, + DialogHeader, + DialogTitle, + Label, + LoadingWithAnimation, + Tabs, + TabsContent, + TabsList, + TabsTrigger, + Textarea, +} from "@common/components"; + +import DataGrid from "@/components/module/DataGrid"; +import { VerificationResultHistoryPagination } from "@/components/module/VerificationResultHistoryPagination"; +import type { CellStyleCallbackParams, ColumnInfo } from "@/components/type/DataGrid.type"; + +/** 통계 입력 현황 > 행 클릭 시 예외처리(prcsYn=3) 내역만 조회 */ +export interface ExceptionProcessingHistoryQueryContext { + sewerBzmnCd: string; + ctpvNm: string; + sggNm: string; + statsYr: string; + statsCd: string; +} + +export interface ExceptionProcessingHistoryDialogProps { + open: boolean; + onOpenChange: (open: boolean) => void; + queryContext?: ExceptionProcessingHistoryQueryContext | null; + emptyMessage?: string; + /** 반려 API 성공 후 (다이얼로그 내 목록 재조회 이후) 상위 화면 등에서 호출 */ + onRejectSuccess?: () => void | Promise; +} + +type ExceptionHistoryGridRow = Record; +type ExceptionHistoryTabKey = "NV" | "IMR" | "HB"; + +const exceptionHistoryTabs: { value: ExceptionHistoryTabKey; label: string }[] = [ + { value: "NV", label: "일반검증 내역" }, + { value: "IMR", label: "I-MR 검증내역" }, + { value: "HB", label: "HB 검증내역" }, +]; + +const TITLE = "예외처리 내역 조회"; +/** 예외처리 건만 */ +const PRCS_YN_LIST = "2"; + +export type ExceptionRejectSubmitItem = { + vrfcSn: number; + statsYr: string; + sewerBzmnCd: string; + statsCd: string; + mdfrId: string; + rjctOpnnCn?: string; +}; + +function prcsYnCodeToNumber(raw: string | number | undefined): number | undefined { + if (raw === undefined || raw === null) return undefined; + if (typeof raw === "number" && Number.isFinite(raw)) return raw; + const s = String(raw).trim(); + if (s === "") return undefined; + const n = Number.parseInt(s, 10); + return Number.isFinite(n) ? n : undefined; +} + +function exceptionPrcsYnNmCellStyle({ fieldName, row }: CellStyleCallbackParams): string | undefined { + if (fieldName !== "prcsYnNm") return undefined; + const code = prcsYnCodeToNumber(row.prcsYn); + if (code === 0 || code === 4) return "rg-cell-vrf-prcs-bad"; + return "rg-cell-vrf-prcs-ok"; +} + +const exceptionHistoryColumnInfo: ColumnInfo = { + columns: [ + { name: "ctpvNm", fieldName: "ctpvNm", header: { text: "시도" }, editable: false }, + { name: "sggNm", fieldName: "sggNm", 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: "regDt", fieldName: "regDt", header: { text: "등록일시" }, editable: false }, + { name: "mdfcnDt", fieldName: "mdfcnDt", header: { text: "갱신일시" }, editable: false }, + { + name: "individualReject", + fieldName: "individualReject", + header: { text: "개별 반려" }, + editable: false, + width: 104, + styleName: "rg-cell-reject-mini-btn", + }, + { name: "prcsYn", fieldName: "prcsYn", header: { text: "검증결과상태코드" }, editable: false, visible: false }, + { name: "vrfcSn", fieldName: "vrfcSn", header: { text: "vrfcSn" }, editable: false, visible: false }, + ], + columnLayout: [ + "ctpvNm", + "sggNm", + "vrfcTrgtNm", + "rsnExpln", + "nowVlCn", + "compVlCn", + "prcsYnNm", + "pbcpAprvYn", + "regDt", + "mdfcnDt", + "individualReject", + "prcsYn", + "vrfcSn", + ], +}; + +export function ExceptionProcessingHistoryDialog({ + open, + onOpenChange, + queryContext, + emptyMessage = "표시할 예외처리 내역이 없습니다.", + onRejectSuccess, +}: ExceptionProcessingHistoryDialogProps) { + const { setMessage: alert } = useAlertStore((state) => state); + const nvGridRef = useRef(null); + const imrGridRef = useRef(null); + const hbGridRef = useRef(null); + const [rejectReasonDialogOpen, setRejectReasonDialogOpen] = useState(false); + const [pendingRejectVrfcSns, setPendingRejectVrfcSns] = useState([]); + const [rejectReasonText, setRejectReasonText] = useState(""); + const [rejectSubmitting, setRejectSubmitting] = useState(false); + + const [activeTab, setActiveTab] = useState("NV"); + const [normalEntries, setNormalEntries] = useState([]); + const [imrEntries, setImrEntries] = useState([]); + const [hbEntries, setHbEntries] = useState([]); + const [normalPageIndex, setNormalPageIndex] = useState(0); + const [imrPageIndex, setImrPageIndex] = useState(0); + const [hbPageIndex, setHbPageIndex] = useState(0); + const [normalPageCount, setNormalPageCount] = useState(1); + const [imrPageCount, setImrPageCount] = useState(1); + const [hbPageCount, setHbPageCount] = useState(1); + const [normalLoading, setNormalLoading] = useState(false); + const [imrLoading, setImrLoading] = useState(false); + const [hbLoading, setHbLoading] = useState(false); + const [errorMessage, setErrorMessage] = useState(null); + + const resetState = useCallback(() => { + setActiveTab("NV"); + setNormalEntries([]); + setImrEntries([]); + setHbEntries([]); + setNormalPageIndex(0); + setImrPageIndex(0); + setHbPageIndex(0); + setNormalPageCount(1); + setImrPageCount(1); + setHbPageCount(1); + setNormalLoading(false); + setImrLoading(false); + setHbLoading(false); + setErrorMessage(null); + setRejectReasonDialogOpen(false); + setPendingRejectVrfcSns([]); + setRejectReasonText(""); + setRejectSubmitting(false); + }, []); + + const fetchPage = useCallback( + async (vrfcMthdCd: ExceptionHistoryTabKey, pageIndex: number) => { + if (!queryContext) return null; + const param: VerificationResultListRequest = { + sewerBzmnCd: queryContext.sewerBzmnCd, + ctpvNm: queryContext.ctpvNm, + sggNm: queryContext.sggNm, + statsYr: queryContext.statsYr, + statsCd: queryContext.statsCd, + pbcpAprvYn: "N", + vrfcMthdCd, + prcsYnList: PRCS_YN_LIST, + page: pageIndex + 1, + size: 30, + }; + return statisticsApi.getVerificationResultList(param); + }, + [queryContext], + ); + + const loadInitial = useCallback(async () => { + if (!queryContext) { + setErrorMessage("조회 조건이 없어 예외처리 내역을 불러올 수 없습니다."); + return; + } + setErrorMessage(null); + setNormalLoading(true); + setImrLoading(true); + setHbLoading(true); + setNormalPageIndex(0); + setImrPageIndex(0); + setHbPageIndex(0); + try { + const [nvRes, imrRes, hbRes] = await Promise.all([ + fetchPage("NV", 0), + fetchPage("IMR", 0), + fetchPage("HB", 0), + ]); + setNormalEntries(nvRes?.data?.verifyResultList ?? []); + setImrEntries(imrRes?.data?.verifyResultList ?? []); + setHbEntries(hbRes?.data?.verifyResultList ?? []); + setNormalPageCount(nvRes?.data?.pageInfo?.totalPages ?? 1); + setImrPageCount(imrRes?.data?.pageInfo?.totalPages ?? 1); + setHbPageCount(hbRes?.data?.pageInfo?.totalPages ?? 1); + } catch { + setNormalEntries([]); + setImrEntries([]); + setHbEntries([]); + setNormalPageCount(1); + setImrPageCount(1); + setHbPageCount(1); + setErrorMessage("예외처리 내역 조회 중 오류가 발생했습니다."); + } finally { + setNormalLoading(false); + setImrLoading(false); + setHbLoading(false); + } + }, [fetchPage, queryContext]); + + const handleNormalPageChange = useCallback( + async (pageIndex: number) => { + if (pageIndex === normalPageIndex || !queryContext) return; + setErrorMessage(null); + setNormalLoading(true); + try { + const response = await fetchPage("NV", pageIndex); + setNormalEntries(response?.data?.verifyResultList ?? []); + setNormalPageCount(response?.data?.pageInfo?.totalPages ?? 1); + setNormalPageIndex(pageIndex); + } catch { + setErrorMessage("일반검증 내역 조회 중 오류가 발생했습니다."); + } finally { + setNormalLoading(false); + } + }, + [fetchPage, normalPageIndex, queryContext], + ); + + const handleImrPageChange = useCallback( + async (pageIndex: number) => { + if (pageIndex === imrPageIndex || !queryContext) return; + setErrorMessage(null); + setImrLoading(true); + try { + const response = await fetchPage("IMR", pageIndex); + setImrEntries(response?.data?.verifyResultList ?? []); + setImrPageCount(response?.data?.pageInfo?.totalPages ?? 1); + setImrPageIndex(pageIndex); + } catch { + setErrorMessage("I-MR 검증내역 조회 중 오류가 발생했습니다."); + } finally { + setImrLoading(false); + } + }, + [fetchPage, imrPageIndex, queryContext], + ); + + const handleHbPageChange = useCallback( + async (pageIndex: number) => { + if (pageIndex === hbPageIndex || !queryContext) return; + setErrorMessage(null); + setHbLoading(true); + try { + const response = await fetchPage("HB", pageIndex); + setHbEntries(response?.data?.verifyResultList ?? []); + setHbPageCount(response?.data?.pageInfo?.totalPages ?? 1); + setHbPageIndex(pageIndex); + } catch { + setErrorMessage("HB 검증내역 조회 중 오류가 발생했습니다."); + } finally { + setHbLoading(false); + } + }, + [fetchPage, hbPageIndex, queryContext], + ); + + useEffect(() => { + if (!open) { + resetState(); + return; + } + void loadInitial(); + }, [loadInitial, open, resetState]); + + const mapRows = (entries: VerificationResultReportItem[]): ExceptionHistoryGridRow[] => + entries.map((item) => ({ + ctpvNm: item.ctpvNm, + sggNm: item.sggNm, + vrfcTrgtNm: item.vrfcTrgtNm, + rsnExpln: item.rsnExpln, + nowVlCn: item.nowVlCn ?? "-", + compVlCn: item.compVlCn ?? "-", + prcsYnNm: item.prcsYnNm ?? "-", + prcsYn: item.prcsYn ?? "", + pbcpAprvYn: item.pbcpAprvYn, + regDt: (item.regDt ?? "").slice(0, 16), + mdfcnDt: (item.mdfcnDt ?? "").slice(0, 16), + individualReject: "개별 반려", + vrfcSn: item.vrfcSn, + })); + + const normalRows = useMemo(() => mapRows(normalEntries), [normalEntries]); + const imrRows = useMemo(() => mapRows(imrEntries), [imrEntries]); + const hbRows = useMemo(() => mapRows(hbEntries), [hbEntries]); + + const noopSetRows = ((_: SetStateAction[]>) => {}) as Dispatch< + SetStateAction[]> + >; + + const handleOpenRejectReasonDialog = useCallback(() => { + if (!queryContext) { + alert("조회 조건이 없습니다."); + return; + } + const rows = + activeTab === "NV" ? normalRows : activeTab === "IMR" ? imrRows : hbRows; + const gridRef = + activeTab === "NV" ? nvGridRef : activeTab === "IMR" ? imrGridRef : hbGridRef; + const gridView = gridRef.current?.gridView; + if (!gridView) { + alert("데이터가 존재하지 않습니다."); + return; + } + const checkedIndices = (gridView.getCheckedRows?.(true, false, true) ?? []) as number[]; + if (checkedIndices.length === 0) { + alert("반려할 항목을 체크해주세요."); + return; + } + const vrfcSns: number[] = []; + for (const itemIndex of checkedIndices) { + const row = rows[itemIndex]; + if (!row) continue; + const vrfcSn = Number(row.vrfcSn); + if (!Number.isFinite(vrfcSn)) continue; + vrfcSns.push(vrfcSn); + } + if (vrfcSns.length === 0) { + alert("유효한 검증번호가 있는 행을 선택해주세요."); + return; + } + setPendingRejectVrfcSns(vrfcSns); + setRejectReasonText(""); + setRejectSubmitting(false); + setRejectReasonDialogOpen(true); + }, [activeTab, alert, hbRows, imrRows, normalRows, queryContext]); + + const resetRejectReasonDialog = useCallback(() => { + setRejectReasonDialogOpen(false); + setPendingRejectVrfcSns([]); + setRejectReasonText(""); + setRejectSubmitting(false); + }, []); + + const handleRejectReasonDialogOpenChange = useCallback( + (nextOpen: boolean) => { + if (!nextOpen) resetRejectReasonDialog(); + }, + [resetRejectReasonDialog], + ); + + const handleConfirmReject = useCallback(async () => { + if (!queryContext || pendingRejectVrfcSns.length === 0) { + alert("반려할 항목이 없습니다. 다시 선택해주세요."); + resetRejectReasonDialog(); + return; + } + let userInfo; + try { + userInfo = await getUserInfo(); + } catch { + alert("사용자 정보를 불러오지 못했습니다."); + return; + } + const mdfrId = userInfo.userUnqId; + const trimmedReason = rejectReasonText.trim(); + + const items: ReviewInputSaveRequest[] = pendingRejectVrfcSns.map((vrfcSn) => ({ + type: "INPUT", + statsYr: queryContext.statsYr, + sewerBzmnCd: queryContext.sewerBzmnCd, + statsCd: queryContext.statsCd, + mdfrId, + vrfcSn: String(vrfcSn), + ...(trimmedReason ? { rjctOpnnCn: trimmedReason } : {}), + })); + + setRejectSubmitting(true); + try { + const response = await statisticsApi.saveInputReturn(items); + if (response.resultCode.toLowerCase() !== "success") { + alert(response.message ?? "반려 처리 중 오류가 발생했습니다."); + return; + } + alert("반려 처리되었습니다."); + resetRejectReasonDialog(); + await Promise.all([ + loadInitial(), + onRejectSuccess?.() ?? Promise.resolve(), + ]); + } catch { + alert("반려 처리 중 오류가 발생했습니다."); + } finally { + setRejectSubmitting(false); + } + }, [ + alert, + loadInitial, + onRejectSuccess, + pendingRejectVrfcSns, + queryContext, + rejectReasonText, + resetRejectReasonDialog, + ]); + + const handleGridRowClick = useCallback( + (rowData: Record) => { + const gridRef = + activeTab === "NV" ? nvGridRef : activeTab === "IMR" ? imrGridRef : hbGridRef; + const current = gridRef.current?.gridView?.getCurrent?.() as + | { fieldName?: string; column?: string } + | undefined; + const fieldName = current?.fieldName ?? current?.column; + if (fieldName !== "individualReject") return; + const vrfcSn = Number(rowData.vrfcSn); + if (!Number.isFinite(vrfcSn)) { + alert("유효한 검증번호가 있는 행을 선택해주세요."); + return; + } + setPendingRejectVrfcSns([vrfcSn]); + setRejectReasonText(""); + setRejectSubmitting(false); + setRejectReasonDialogOpen(true); + }, + [activeTab, alert], + ); + + const renderGrid = ( + rows: ExceptionHistoryGridRow[], + pageIndex: number, + pageCount: number, + onPageIndexChange: (page: number) => void, + loading: boolean, + gridRef: MutableRefObject, + ) => { + if (!loading && rows.length === 0) { + return

{emptyMessage}

; + } + + return ( +
+ + + {loading && ( +
+
+ +
+
+ )} +
+ ); + }; + + return ( + <> + + + + {`${TITLE} - ${queryContext?.statsYr} ${queryContext?.ctpvNm} ${queryContext?.sggNm}`} + +
+ {errorMessage && ( +

+ {errorMessage} +

+ )} + setActiveTab(value as ExceptionHistoryTabKey)} + className="w-full" + variant="default" + > + + {exceptionHistoryTabs.map((tab) => ( + + {tab.label} + + ))} + + + {renderGrid( + normalRows, + normalPageIndex, + normalPageCount, + handleNormalPageChange, + normalLoading, + nvGridRef, + )} + + + {renderGrid( + imrRows, + imrPageIndex, + imrPageCount, + handleImrPageChange, + imrLoading, + imrGridRef, + )} + + + {renderGrid( + hbRows, + hbPageIndex, + hbPageCount, + handleHbPageChange, + hbLoading, + hbGridRef, + )} + + +
+ + + + + + +
+
+ + + + + 예외처리 반려 + +
+ +