Merge branch 'iipbs/as/dev' of https://www.onasys.co.kr:3000/ONAANDALL/frontend into iipbs/as/dev

This commit is contained in:
2026-05-18 17:40:49 +09:00
121 changed files with 6060 additions and 1237 deletions
+1 -1
View File
@@ -26,7 +26,7 @@ yarn-debug.log*
yarn-error.log*
# local env files
.env*.local
.env.*
# vercel
.vercel
+18 -1
View File
@@ -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 });
}
}
+3
View File
@@ -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",
@@ -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<TabKey, CaseItem[]> = {
],
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: "#",
@@ -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" }],
},
{
@@ -109,7 +109,7 @@ function InfoList({
}
export default function GreywaterExampleSection() {
const title = "중수 재이용 사례";
const title = "중수도 사례";
return (
<div className="bg-white">
{/* 상단 타이틀 */}
@@ -17,7 +17,7 @@ function Hr({ className = "" }: { className?: string }) {
}
export default function RainExampleSection() {
const title = "빗물 재이용 사례";
const title = "빗물이용시설 사례";
return (
<div className="bg-white">
{/* 상단 타이틀 */}
@@ -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: "경기도 안산시" },
@@ -46,7 +46,7 @@ export default function GreywaterPage() {
crumbs={[
{ label: "Home", href: "/reuse" },
{ label: "물 재이용 사례" },
{ label: "빗물 재이용 사례" },
{ label: "빗물이용시설 사례" },
{ label: "수원시", href: "/reuse/business/rain/suwon" },
]}
hideFooterLine
@@ -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 <ContentsPage contsSn={contsSn} menuCd={menuCd} />;
}
@@ -650,8 +650,7 @@ const globalCases: GlobalCase[] = [
depth: 1,
},
{ text: "안핑을 포함한 16건 공사 완료시 628,100 CMD 공급 가능" },
{ text: "공급단가는 수질과 운영조건에 따라 다르게 책정" },
],
},
{
@@ -109,7 +109,7 @@ function InfoList({
}
export default function GreywaterExampleSection() {
const title = "중수 재이용 사례";
const title = "중수도 사례";
return (
<div className="bg-white">
{/* 상단 타이틀 */}
@@ -123,7 +123,7 @@ function RainTable({ rows }: { rows: RainTableRow[] }) {
}
export default function RainExampleSection() {
const title = "빗물 재이용 사례";
const title = "빗물이용시설 사례";
return (
<div className="bg-white">
@@ -334,7 +334,7 @@ const sewageCases: SewageCase[] = [
];
export default function SewageExampleSection() {
const title = "하수처리 재이용 사례";
const title = "하수처리수 재이용 사례";
return (
<div className="bg-white">
@@ -115,11 +115,11 @@ export default function GreywaterPage() {
return (
<div className="bg-white">
<SubPageHeader
title="중수 재이용 사례"
title="중수도 사례"
crumbs={[
{ label: "Home", href: "/reuse" },
{ label: "물 재이용 사례" },
{ label: "중수 재이용 사례" },
{ label: "중수도 사례" },
]}
hideFooterLine
hideGuideText
+6 -3
View File
@@ -1,3 +1,6 @@
/**
* 물 재이용 사례
*/
import SubPageHeader from "@/components/SubPageHeader";
import BusinessSubNav, { type BusinessSubNavMenu } from "@/components/BusinessSubNav";
@@ -9,7 +12,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 +20,7 @@ const menus: BusinessSubNavMenu[] = [
},
{
key: "example-greywater",
label: "중수 재이용 사례",
label: "중수도 사례",
defaultOpen: false,
sections: [
{ id: "example-greywater-ansan", label: "경기도 안산시" },
@@ -27,7 +30,7 @@ const menus: BusinessSubNavMenu[] = [
},
{
key: "example-sewage",
label: "하수처리 재이용 사례",
label: "하수처리수 재이용 사례",
defaultOpen: false,
sections: [
{ id: "example-sewage-guri", label: "경기도 구리시" },
@@ -24,11 +24,11 @@ export default function RainPage() {
<BusinessSubNav />
<SubPageHeader
title="빗물 재이용 사례"
title="빗물이용시설 사례"
crumbs={[
{ label: "Home", href: "/main" },
{ label: "물 재이용 사례", href: "/example/rain" },
{ label: "빗물 재이용 사례" },
{ label: "빗물이용시설 사례" },
]}
hideFooterLine
hideGuideText
@@ -56,7 +56,7 @@ function SectionShell({
}
export default function LawIntroSection() {
const title = "물 재이용 법";
const title = "물재이용법";
return (
<div className="bg-white">
@@ -54,7 +54,7 @@ function SectionShell({
}
export default function RainIntroSection() {
const title = "빗물 이용시설";
const title = "빗물이용시설";
return (
<div className="mt-[120px] bg-white">
@@ -52,7 +52,7 @@ function SectionShell({
}
export default function TreatedIntroSection() {
const title = "하수처리수";
const title = "하수처리수 재이용";
return (
<div className="mt-[120px] bg-white">
+3 -3
View File
@@ -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: "하수처리수 소개" },
@@ -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 (
<td key={`${rowIdx}-${header.id}`} className={`${tableStyles.td} relative`}>
{isSubItem ? (
+1 -1
View File
@@ -58,7 +58,7 @@ const menus: BusinessSubNavMenu[] = [
},
{
id: "status-treated-usage",
label: "연도별 하수처리수 재이용 현황",
label: "용도별 하수처리수 재이용 현황",
},
{
id: "status-treated-facility",
+1 -1
View File
@@ -16,7 +16,7 @@ export default function RootLayout({
<title>물재이용 대민</title>
</head>
<body>
<div className="min-w-[1400px] overflow-x-auto">
<div className="min-w-[1400px]">
{children}
</div>
</body>
-2
View File
@@ -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 (
<StoreProvider>
<TopNav />
<div className="pt-[83px]">{children}</div> {/* TopNav 높이만큼 */}
</StoreProvider>
);
+1 -1
View File
@@ -33,7 +33,7 @@ export const StoreZus = (
return createStore<DmStore>()((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 {
+59 -33
View File
@@ -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<string, boolean> = {};
const nextOpenState: Record<string, boolean> = {};
menus.forEach((menu) => {
menus.forEach((menu) => {
nextOpenState[menu.key] = menu.key === parentMenu.key;
});
});
return nextOpenState;
return nextOpenState;
});
}, [activeId, menus]);
+2 -2
View File
@@ -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: "해외 재이용 사례" },
];
@@ -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"],
);
@@ -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 <ContentsPageClient initialContent={initialContent} />;
}
export { ContentsPage };
@@ -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<HTMLDivElement>(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 (
<div className="card card-border !mt-6 !p-6">
<div ref={shadowRootRef} />
</div>
);
}
export { ContentsPageClient };
+1
View File
@@ -0,0 +1 @@
export { ContentsPage } from "./contentsPage";
+1 -1
View File
@@ -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}` : ""}`;
+4
View File
@@ -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: [],
};
Binary file not shown.

Before

Width:  |  Height:  |  Size: 129 KiB

After

Width:  |  Height:  |  Size: 126 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 292 KiB

After

Width:  |  Height:  |  Size: 284 KiB

@@ -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<ContentsList> {
const response = await client.get<ContentsList>(
`${ENDPOINT.CMS_SERVICE.CONTENTS}`,
{ params },
);
return response.data;
}
public async getLatestApprovedContentsInfo(
contsSn: number,
menuCd: string,
): Promise<ContentsDetail> {
const response = await client.get<ContentsDetail>(
`${ENDPOINT.CMS_SERVICE.CONTENTS}/${contsSn}`,
{ params: { menuCd } },
);
return response.data;
}
public async getContentsVersionList(
params: ContentsVersionParams,
): Promise<ContentsVersionList> {
const response = await client.get<ContentsVersionList>(
`${ENDPOINT.CMS_SERVICE.CONTENTS_DETAILS}`,
{ params },
);
return response.data;
}
public async getLatestContentsInfo(
contsSn: number,
contsVer: number,
): Promise<ContentsDetail> {
const response = await client.get<ContentsDetail>(
`${ENDPOINT.CMS_SERVICE.CONTENTS_DETAILS}/${contsSn}/${contsVer}`,
);
return response.data;
}
public async createContents(data: ContentsFormData): Promise<void> {
const response = await client.post<void>(
`${ENDPOINT.CMS_SERVICE.CONTENTS_DETAILS}`,
data,
);
return response.data;
}
public async updateContents(
contsSn: number,
contsVer: number,
data: ContentsFormData,
): Promise<void> {
const response = await client.put<void>(
`${ENDPOINT.CMS_SERVICE.CONTENTS_DETAILS}/${contsSn}/${contsVer}`,
data,
);
return response.data;
}
public async deleteContents(
contsSn: number,
contsVer: number,
): Promise<void> {
await client.delete(
`${ENDPOINT.CMS_SERVICE.CONTENTS_DETAILS}/${contsSn}/${contsVer}`,
);
}
public async requestApprove(
contsSn: number,
contsVer: number,
): Promise<void> {
await client.put(
`${ENDPOINT.CMS_SERVICE.CONTENTS_DETAILS}/${contsSn}/${contsVer}/approveRequest`,
);
}
public async approveContents(
contsSn: number,
contsVer: number,
): Promise<void> {
await client.put(
`${ENDPOINT.CMS_SERVICE.CONTENTS_DETAILS}/${contsSn}/${contsVer}/approve`,
);
}
}
export const ContentsApi = new ContentsApiService();
+8
View File
@@ -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";
@@ -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<T> {
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;
}
@@ -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<typeof contentsSchema>;
export type ContentsDetail = z.infer<typeof contentsDetailSchema>;
export type ContentsFormData = z.infer<typeof contentsFormSchema>;
export type ContentsList = PageableList<Contents>;
export type ContentsVersionList = PageableList<ContentsVersion>;
export interface ContentsService {
getContentsList(params: ContentsParams): Promise<ContentsList>;
getLatestApprovedContentsInfo(
contsSn: number,
menuCd: string,
): Promise<ContentsDetail>;
getContentsVersionList(
params: ContentsVersionParams,
): Promise<ContentsVersionList>;
getLatestContentsInfo(
contsSn: number,
contsVer: number,
): Promise<ContentsDetail>;
createContents(data: ContentsFormData): Promise<void>;
updateContents(
contsSn: number,
contsVer: number,
data: ContentsFormData,
): Promise<void>;
deleteContents(contsSn: number, contsVer: number): Promise<void>;
requestApprove(contsSn: number, contsVer: number): Promise<void>;
approveContents(contsSn: number, contsVer: number): Promise<void>;
}
@@ -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() {
</div>
</header>
{downloadProgressText ? (
<div className="border-b border-slate-200 bg-emerald-50 px-5 py-2 text-[1rem] text-emerald-700">
{downloadProgressText}
</div>
) : null}
<section className="overflow-y-auto p-4">
<div className="grid grid-cols-1 gap-3 border-b border-slate-200 bg-slate-50 pb-4 md:grid-cols-[160px_320px_320px_auto] md:items-end md:justify-between">
<label className="block md:max-w-[160px]">
@@ -221,7 +239,7 @@ export default function AgedPipePopupPage() {
});
}}
onKeyDown={(event) => {
if (event.key === "Enter") handleSearch(1);
if (event.key === "Enter") void handleSearch(1);
}}
/>
</label>
@@ -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)}
>
조회
</Button>
@@ -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}
@@ -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,
};
};
@@ -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<string, unknown> = {};
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 ?? "");
@@ -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<WindowPopupKey | "infoSearch" | null>(null);
return (
<div className="absolute right-[0.35rem] top-[53%] z-[70]">
<div className="absolute right-[0.55rem] top-[20px] z-[45]">
<div className="relative">
{isOpen ? (
<div className="absolute right-[21px] top-1/2 flex w-[86px] -translate-y-1/2 flex-col gap-[3px]">
{ANALYSIS_POPUP_ITEMS.map((item) => (
<button
key={item.key}
type="button"
className="min-h-[40px] rounded-md border border-primary bg-primary px-2 py-1 text-center text-[1rem] font-semibold leading-[1.2] text-white shadow transition hover:brightness-95"
onClick={() => onOpenPopup(item.key)}
>
<span className="break-keep whitespace-normal">{item.label}</span>
</button>
))}
<div className="absolute right-[28px] w-[132px] overflow-hidden rounded-[6px] border border-[#d8dee8] bg-[rgba(255,255,255,0.96)] shadow-[0_8px_18px_rgba(15,23,42,0.14)]">
<div className="flex h-[36px] items-center gap-2 border-b border-[#e5eaf1] bg-[#f8fafc] px-[10px] text-[13px] font-bold text-[#1f2937]">
<div className="flex items-center gap-2">
<PanelsTopLeft className="h-4 w-4 text-[#1e88e5]" />
<span>분석도구</span>
</div>
</div>
<div className="flex flex-col gap-1 p-[6px]">
{ANALYSIS_POPUP_ITEMS.map((item) => {
const Icon = item.icon;
const isActive = activeKey === item.key;
return (
<button
key={item.key}
type="button"
className={`relative flex h-[34px] items-center gap-[7px] rounded-[5px] border px-[8px] pl-[10px] text-left text-[12px] font-semibold transition ${
isActive
? "border-[#b9dcff] bg-[#eaf4ff] font-bold text-[#0f5fa8] before:absolute before:left-0 before:top-[7px] before:bottom-[7px] before:w-[3px] before:rounded-r-[2px] before:bg-[#1e88e5]"
: "border-transparent bg-white text-[#374151] hover:border-[#cfe5ff] hover:bg-[#eef6ff] hover:text-[#1e3a5f]"
}`}
onClick={() => {
setActiveKey(item.key);
if (item.key === "infoSearch") {
onOpenInfoSearch();
return;
}
onOpenPopup(item.key);
}}
>
<Icon
className={`h-[15px] w-[15px] shrink-0 ${
isActive ? "text-[#1e88e5]" : "text-[#64748b]"
}`}
/>
<span className="break-keep whitespace-normal">{item.label}</span>
</button>
);
})}
</div>
</div>
) : null}
<button
type="button"
className="flex h-[36px] w-[18px] items-center justify-center rounded-sm border border-[#2f5c9b] bg-white text-[0.9rem] font-bold text-[#2f5c9b] shadow transition hover:bg-slate-50"
className="absolute right-0 top-0 flex h-[36px] w-[20px] items-center justify-center rounded-[4px] border border-[#d8dee8] bg-[rgba(255,255,255,0.96)] text-[#64748b] shadow-[0_8px_18px_rgba(15,23,42,0.14)] transition hover:bg-[#eaf4ff] hover:text-[#1e88e5]"
onClick={() => setIsOpen((value) => !value)}
title={isOpen ? "분석 메뉴 접기" : "분석 메뉴 펼치기"}
aria-label={isOpen ? "분석 메뉴 접기" : "분석 메뉴 펼치기"}
>
{isOpen ? ">" : "<"}
{isOpen ? <ChevronRight className="h-4 w-4" /> : <ChevronLeft className="h-4 w-4" />}
</button>
</div>
</div>
@@ -18,6 +18,7 @@ import {
buildPipeAnalysisScope,
normalizeNumberInput,
resolveElapsedYearBucketLabel,
resolveMaterialKoreanName,
resolveMaterialName,
resolvePipeAnalysisTypeLabel,
resolveRegionName,
@@ -27,6 +28,19 @@ import {
} from "./pipeAnalysisUtils";
type DetailViewRow = ReturnType<typeof toPipeAnalysisDetailViewRows>[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) {
@@ -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) => {
+82 -14
View File
@@ -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<Window | null>(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 (
<>
<div className="bg-container relative h-[calc(100dvh-5rem)] overflow-hidden">
<div className="sn-map-control-layout bg-container relative h-[calc(100dvh-5rem)] overflow-hidden">
{layerLoadError ? (
<div className="absolute left-4 top-4 z-[60] max-w-[520px] rounded border border-red-200 bg-white px-4 py-3 text-sm text-red-700 shadow">
레이어 설정 조회 실패: {layerLoadError}
</div>
) : null}
<AnalysisPopupMenu onOpenPopup={openAnalysisPopup} />
{agedPipeDownloadProgressText ? (
<div className="absolute left-4 top-20 z-[60] max-w-[520px] rounded border border-emerald-200 bg-white px-4 py-3 text-sm text-emerald-700 shadow">
{agedPipeDownloadProgressText}
</div>
) : null}
<AnalysisPopupMenu
onOpenPopup={openAnalysisPopup}
onOpenInfoSearch={() => setIsSearchModalOpen(true)}
/>
<Gis
treeData={treeData}
isSearch={true}
@@ -1247,7 +1267,13 @@ const Map = () => {
setIsSearchModalOpen={setIsSearchModalOpen}
onCheckedLayersChange={handleCheckedLayersChange}
onOpenPopup={handleOpenLayerPopup}
searchBar={<ProcSearchBar gisMaps={gisMaps} facilityTypes={facilityTypes} />}
searchBar={
<ProcSearchBar
gisMaps={gisMaps}
facilityTypes={facilityTypes}
sidoSearchZoom={isAgedPipeChecked ? AGED_PIPE_GUIDE_MIN_ZOOM : undefined}
/>
}
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 = () => {
</Button>
</div>
)}
<style jsx global>{`
.sn-map-control-layout .mapbtn {
top: auto;
right: 15px;
bottom: 451px;
}
.sn-map-control-layout .custom-toolbar {
position: static;
}
.sn-map-control-layout .tool-bar {
top: auto;
right: 15px;
bottom: 248px;
}
.sn-map-control-layout .zoom-level-control {
right: 16px;
bottom: 56px;
}
.sn-map-control-layout .zoom-level-summary {
right: 56px;
bottom: -15px;
}
.sn-map-control-layout .ol-scale-line {
display: none;
}
`}</style>
</div>
</>
);
+40
View File
@@ -101,6 +101,46 @@ export const postData = async (url: string, data: object): Promise<any> => {
}
};
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<any> => {
const res = await axios.post(`${BASE_URL}/${url}`, data, {
headers: DEFAULT_HEADERS,
+4 -4
View File
@@ -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'));
@@ -1,7 +1,7 @@
import { Button } from "@common/components";
interface ApproveButtonProps {
approveFunc: () => void;
approveFunc: () => void | Promise<void>;
}
const ApproveButton = ({ approveFunc }: ApproveButtonProps) => {
+217 -68
View File
@@ -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;
}
}
/*
* 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;
}
@@ -71,6 +71,38 @@ function createNumberComparer(dp: RealGridDataProviderComparer): (field: string
};
}
function escapeHtml(value: string): string {
return value
.replace(/&/g, "&amp;")
.replace(/</g, "&lt;")
.replace(/>/g, "&gt;")
.replace(/"/g, "&quot;")
.replace(/'/g, "&#39;");
}
function realGridHeaderWithTooltip<T extends { text: string; tooltip?: string; showTooltip?: boolean; template?: string }>(
header: T,
): T {
const tip = header.tooltip?.trim();
if (!tip) return header;
return {
...header,
tooltip: tip,
showTooltip: header.showTooltip ?? true,
template:
header.template ??
`<span style="display:inline-flex;align-items:center;justify-content:center;gap:4px;width:100%;"><span>${escapeHtml(header.text)}</span><i class="diveicon di-info-circle" aria-hidden="true"></i></span>`,
};
}
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<RealGridReact>(null);
const columnsRef = useRef(columns);
const rowDataRef = useRef<Record<string, string | number>[]>(rowData);
const cellStyleCallbackRef = useRef<CellStyleCallback | undefined>(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 }
+208 -19
View File
@@ -81,6 +81,42 @@ function createNumberComparer(
};
}
function escapeHtml(value: string): string {
return value
.replace(/&/g, "&amp;")
.replace(/</g, "&lt;")
.replace(/>/g, "&gt;")
.replace(/"/g, "&quot;")
.replace(/'/g, "&#39;");
}
/**
* RealGrid 헤더 툴팁: `grid.header.showTooltip`이 비어 있으면 `column.header.showTooltip === true`인 컬럼만 표시됨.
* `tooltip`이 있는 헤더는 info-circle 아이콘이 함께 보이도록 template도 자동 구성한다.
*/
function realGridHeaderWithTooltip<T extends { text: string; tooltip?: string; showTooltip?: boolean; template?: string }>(
header: T,
): T {
const tip = header.tooltip?.trim();
if (!tip) return header;
return {
...header,
tooltip: tip,
showTooltip: header.showTooltip ?? true,
template:
header.template ??
`<span style="display:inline-flex;align-items:center;justify-content:center;gap:4px;width:100%;"><span>${escapeHtml(header.text)}</span><i class="diveicon di-info-circle" aria-hidden="true"></i></span>`,
};
}
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<RealGridReact>(null);
const fileInputRef = useRef<HTMLInputElement>(null);
const columnsRef = useRef(columns);
const sortDirRef = useRef<Record<string, "asc" | "desc" | "none">>({});
const originalRowsRef = useRef<
Record<string, Record<string, string | number>[]>
@@ -366,12 +404,14 @@ const DataGrid = ({
cellTooltipCallback,
);
const onCellEditedRef = useRef<typeof onCellEdited>(onCellEdited);
const onDataCellClickRef = useRef<typeof onDataCellClick>(onDataCellClick);
const onScrollToBottomRef = useRef<(() => void) | undefined>(
onScrollToBottom,
);
const onItemCheckedRef = useRef<typeof onItemChecked>(onItemChecked);
const onItemAllCheckedRef =
useRef<typeof onItemAllChecked>(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<string, string | number>,
fieldName,
@@ -1118,6 +1239,13 @@ const DataGrid = ({
);
}
}
if (onDataCellClickRef.current && row !== undefined) {
onDataCellClickRef.current({
row: (row ?? {}) as Record<string, string | number>,
fieldName,
itemIndex: clickData.itemIndex,
});
}
if (onRowClick)
onRowClick((row ?? {}) as Record<string, string | number>);
} 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,
@@ -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<void>;
}
type ExceptionHistoryGridRow = Record<string, string | number>;
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<RealGridReact | null>(null);
const imrGridRef = useRef<RealGridReact | null>(null);
const hbGridRef = useRef<RealGridReact | null>(null);
const [rejectReasonDialogOpen, setRejectReasonDialogOpen] = useState(false);
const [pendingRejectVrfcSns, setPendingRejectVrfcSns] = useState<number[]>([]);
const [rejectReasonText, setRejectReasonText] = useState("");
const [rejectSubmitting, setRejectSubmitting] = useState(false);
const [activeTab, setActiveTab] = useState<ExceptionHistoryTabKey>("NV");
const [normalEntries, setNormalEntries] = useState<VerificationResultReportItem[]>([]);
const [imrEntries, setImrEntries] = useState<VerificationResultReportItem[]>([]);
const [hbEntries, setHbEntries] = useState<VerificationResultReportItem[]>([]);
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<string | null>(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<Record<string, string | number>[]>) => {}) as Dispatch<
SetStateAction<Record<string, string | number>[]>
>;
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<string, string | number>) => {
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<RealGridReact | null>,
) => {
if (!loading && rows.length === 0) {
return <p className="p-6 text-center text-[1.3rem] text-muted-foreground">{emptyMessage}</p>;
}
return (
<div className="relative space-y-2">
<DataGrid
columnInfo={exceptionHistoryColumnInfo}
rowData={rows}
setRowData={noopSetRows}
isExcelImport={false}
isExcelExport={false}
showHeaderCheckbox={true}
enableSort={false}
getGridRef={gridRef}
cellStyleCallback={exceptionPrcsYnNmCellStyle}
onRowClick={handleGridRowClick}
/>
<VerificationResultHistoryPagination
pageCount={Math.max(pageCount, 1)}
pageIndex={Math.max(pageIndex, 0)}
onPageIndexChange={onPageIndexChange}
className="mx-2 mb-2"
/>
{loading && (
<div className="absolute inset-0 z-10 rounded-md [data-theme='black']:bg-transparent bg-white/40 flex items-center justify-center">
<div className="relative size-[5rem]">
<LoadingWithAnimation color="primary" />
</div>
</div>
)}
</div>
);
};
return (
<>
<Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent className="max-w-[80%]" aria-describedby={undefined}>
<DialogHeader>
<DialogTitle>{`${TITLE} - ${queryContext?.statsYr} ${queryContext?.ctpvNm} ${queryContext?.sggNm}`}</DialogTitle>
</DialogHeader>
<div className="max-h-[85vh] space-y-5 overflow-auto">
{errorMessage && (
<p className="rounded-md border border-destructive/40 bg-destructive/10 px-3 py-2 text-[1.2rem] text-destructive">
{errorMessage}
</p>
)}
<Tabs
value={activeTab}
onValueChange={(value) => setActiveTab(value as ExceptionHistoryTabKey)}
className="w-full"
variant="default"
>
<TabsList>
{exceptionHistoryTabs.map((tab) => (
<TabsTrigger key={tab.value} value={tab.value}>
{tab.label}
</TabsTrigger>
))}
</TabsList>
<TabsContent value="NV" className="rounded-md border border-border">
{renderGrid(
normalRows,
normalPageIndex,
normalPageCount,
handleNormalPageChange,
normalLoading,
nvGridRef,
)}
</TabsContent>
<TabsContent value="IMR" className="rounded-md border border-border">
{renderGrid(
imrRows,
imrPageIndex,
imrPageCount,
handleImrPageChange,
imrLoading,
imrGridRef,
)}
</TabsContent>
<TabsContent value="HB" className="rounded-md border border-border">
{renderGrid(
hbRows,
hbPageIndex,
hbPageCount,
handleHbPageChange,
hbLoading,
hbGridRef,
)}
</TabsContent>
</Tabs>
</div>
<DialogFooter>
<Button
type="button"
color="red"
onClick={handleOpenRejectReasonDialog}
disabled={rejectSubmitting}
>
일괄 반려
</Button>
<DialogClose asChild>
<Button type="button" color="gray">
닫기
</Button>
</DialogClose>
</DialogFooter>
</DialogContent>
</Dialog>
<Dialog open={rejectReasonDialogOpen} onOpenChange={handleRejectReasonDialogOpenChange}>
<DialogContent className="max-w-[56rem]" aria-describedby={undefined}>
<DialogHeader>
<DialogTitle>예외처리 반려</DialogTitle>
</DialogHeader>
<div className="space-y-2 py-2">
<Label htmlFor="exception-reject-reason" className="text-label">
반려사유 (선택, 체크한 모든 행에 동일 적용)
</Label>
<Textarea
id="exception-reject-reason"
value={rejectReasonText}
onChange={(e) => setRejectReasonText(e.target.value)}
rows={4}
className="min-h-[8rem] p-2 !text-xl"
placeholder='미입력 시 "검증 리포트를 확인하여 주시기 바랍니다." 자동 주입'
disabled={rejectSubmitting}
/>
</div>
<DialogFooter>
<Button
type="button"
color="red"
disabled={rejectSubmitting}
onClick={() => void handleConfirmReject()}
>
반려 확정
</Button>
<DialogClose asChild>
<Button type="button" color="gray" disabled={rejectSubmitting}>
취소
</Button>
</DialogClose>
</DialogFooter>
</DialogContent>
</Dialog>
</>
);
}
@@ -109,6 +109,8 @@ export interface VerificationResultDialogProps {
queryContext?: VerificationResultDialogQueryContext | null;
title?: string;
emptyMessage?: string;
/** 예외 제출 API 성공 후 부모(입력 화면)에서 메인 데이터를 다시 조회할 때 사용 */
onExceptionSubmitted?: () => void | Promise<void>;
}
export function VerificationResultDialog({
@@ -117,6 +119,7 @@ export function VerificationResultDialog({
queryContext,
title = "검증 결과",
emptyMessage = "표시할 검증 결과가 없습니다.",
onExceptionSubmitted,
}: VerificationResultDialogProps) {
/** 부모가 매 렌더마다 새 객체를 넘기면 queryContext 참조만 바뀌어 loadInitial이 재실행되므로 필드 값 기준으로 안정화 */
const stableQueryContext = useMemo((): VerificationResultDialogQueryContext | null => {
@@ -385,6 +388,11 @@ export function VerificationResultDialog({
});
if (response.status === "success") {
alert("예외 제출 처리되었습니다.");
try {
await onExceptionSubmitted?.();
} catch {
alert("예외 제출은 완료되었으나 화면 데이터를 다시 불러오지 못했습니다. 조회로 새로고침 해주세요.");
}
onOpenChange(false);
} else {
alert(response.message ?? "예외 제출 처리 중 오류가 발생했습니다.");
@@ -396,9 +404,10 @@ export function VerificationResultDialog({
alert,
imrCheckedIndices,
imrRows,
loadInitial,
normalCheckedIndices,
normalRows,
onExceptionSubmitted,
onOpenChange,
stableQueryContext,
]);
@@ -13,6 +13,10 @@ import {
DialogHeader,
DialogTitle,
LoadingWithAnimation,
Tabs,
TabsContent,
TabsList,
TabsTrigger,
} from "@common/components";
import DataGrid from "@/components/module/DataGrid";
@@ -20,6 +24,13 @@ import { VerificationResultHistoryPagination } from "@/components/module/Verific
import type { CellStyleCallbackParams, ColumnInfo } from "@/components/type/DataGrid.type";
type VerificationResultGridRow = Record<string, string | number>;
type VerificationHistoryTabKey = "NV" | "IMR" | "HB";
const verificationHistoryTabs: { value: VerificationHistoryTabKey; label: string }[] = [
{ value: "NV", label: "일반검증 내역" },
{ value: "IMR", label: "I-MR 검증내역" },
{ value: "HB", label: "HB 검증내역" },
];
function prcsYnCodeToNumber(raw: string | number | undefined): number | undefined {
if (raw === undefined || raw === null) return undefined;
@@ -34,7 +45,7 @@ function prcsYnCodeToNumber(raw: string | number | undefined): number | undefine
function verificationPrcsYnNmCellStyle({ 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";
if (code === 0 || code === 3) return "rg-cell-vrf-prcs-bad";
return "rg-cell-vrf-prcs-ok";
}
@@ -76,30 +87,40 @@ export function VerificationResultHistoryDialog({
title = "검증 결과 내역",
emptyMessage = "표시할 검증 결과가 없습니다.",
}: VerificationResultHistoryDialogProps) {
const [activeTab, setActiveTab] = useState<VerificationHistoryTabKey>("NV");
const [normalEntries, setNormalEntries] = useState<VerificationResultReportItem[]>([]);
const [imrEntries, setImrEntries] = useState<VerificationResultReportItem[]>([]);
const [hbEntries, setHbEntries] = useState<VerificationResultReportItem[]>([]);
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<string | null>(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);
}, []);
const fetchVerificationResultPage = useCallback(
async (vrfcMthdCd: "NV" | "IMR", pageIndex: number) => {
async (vrfcMthdCd: VerificationHistoryTabKey, pageIndex: number) => {
if (!queryContext) return null;
const param: VerificationResultListRequest = {
sewerBzmnCd: queryContext.sewerBzmnCd,
@@ -126,26 +147,34 @@ export function VerificationResultHistoryDialog({
setErrorMessage(null);
setNormalLoading(true);
setImrLoading(true);
setHbLoading(true);
setNormalPageIndex(0);
setImrPageIndex(0);
setHbPageIndex(0);
try {
const [nvRes, imrRes] = await Promise.all([
const [nvRes, imrRes, hbRes] = await Promise.all([
fetchVerificationResultPage("NV", 0),
fetchVerificationResultPage("IMR", 0),
fetchVerificationResultPage("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);
}
}, [fetchVerificationResultPage, queryContext]);
@@ -187,6 +216,25 @@ export function VerificationResultHistoryDialog({
[fetchVerificationResultPage, imrPageIndex, queryContext],
);
const handleHbPageChange = useCallback(
async (pageIndex: number) => {
if (pageIndex === hbPageIndex || !queryContext) return;
setErrorMessage(null);
setHbLoading(true);
try {
const response = await fetchVerificationResultPage("HB", pageIndex);
setHbEntries(response?.data?.verifyResultList ?? []);
setHbPageCount(response?.data?.pageInfo?.totalPages ?? 1);
setHbPageIndex(pageIndex);
} catch {
setErrorMessage("HB 검증내역 조회 중 오류가 발생했습니다.");
} finally {
setHbLoading(false);
}
},
[fetchVerificationResultPage, hbPageIndex, queryContext],
);
useEffect(() => {
if (!open) {
resetState();
@@ -212,6 +260,7 @@ export function VerificationResultHistoryDialog({
const normalRows = useMemo(() => mapRows(normalEntries), [normalEntries]);
const imrRows = useMemo(() => mapRows(imrEntries), [imrEntries]);
const hbRows = useMemo(() => mapRows(hbEntries), [hbEntries]);
const noopSetRows = ((_: SetStateAction<Record<string, string | number>[]>) => {}) as Dispatch<
SetStateAction<Record<string, string | number>[]>
@@ -269,14 +318,24 @@ export function VerificationResultHistoryDialog({
{errorMessage}
</p>
)}
<section className="rounded-md border border-border">
<h3 className="border-b border-border px-4 py-2 text-[1.35rem] font-semibold">일반검증 내역</h3>
{renderGrid(normalRows, normalPageIndex, normalPageCount, handleNormalPageChange, normalLoading)}
</section>
<section className="rounded-md border border-border">
<h3 className="border-b border-border px-4 py-2 text-[1.35rem] font-semibold">I-MR 검증내역</h3>
{renderGrid(imrRows, imrPageIndex, imrPageCount, handleImrPageChange, imrLoading)}
</section>
<Tabs value={activeTab} onValueChange={(value) => setActiveTab(value as VerificationHistoryTabKey)} className="w-full" variant="default">
<TabsList>
{verificationHistoryTabs.map((tab) => (
<TabsTrigger key={tab.value} value={tab.value}>
{tab.label}
</TabsTrigger>
))}
</TabsList>
<TabsContent value="NV" className="rounded-md border border-border">
{renderGrid(normalRows, normalPageIndex, normalPageCount, handleNormalPageChange, normalLoading)}
</TabsContent>
<TabsContent value="IMR" className="rounded-md border border-border">
{renderGrid(imrRows, imrPageIndex, imrPageCount, handleImrPageChange, imrLoading)}
</TabsContent>
<TabsContent value="HB" className="rounded-md border border-border">
{renderGrid(hbRows, hbPageIndex, hbPageCount, handleHbPageChange, hbLoading)}
</TabsContent>
</Tabs>
</div>
<DialogFooter>
<DialogClose asChild>
@@ -12,6 +12,7 @@ import {
DialogHeader,
DialogTitle,
Input,
InputDatePicker,
Label,
} from "@common/components";
@@ -36,27 +37,43 @@ export interface MainFacilityMappingSaveDialogProps {
onConfirm?: (values: MainFacilityMappingSaveDialogValues) => void | Promise<void>;
}
function isValidYmd(ymd: string): boolean {
if (!/^\d{8}$/.test(ymd)) return false;
const y = Number(ymd.slice(0, 4));
const m = Number(ymd.slice(4, 6));
const d = Number(ymd.slice(6, 8));
const dt = new Date(y, m - 1, d);
return dt.getFullYear() === y && dt.getMonth() === m - 1 && dt.getDate() === d;
function formatDateToYmd(d: Date | undefined): string | null {
if (!d || Number.isNaN(d.getTime())) return null;
const y = d.getFullYear();
const m = String(d.getMonth() + 1).padStart(2, "0");
const day = String(d.getDate()).padStart(2, "0");
return `${y}${m}${day}`;
}
function validatePrxyCtrtCn(value: string): string | null {
const t = value.trim();
if (!t) return "위탁계약기간을 입력해 주세요.";
/** `YYYYMMDD` → `Date` (로컬 자정) */
function parseYmdToDate(ymd: string): Date | undefined {
const s = ymd.replace(/\D/g, "");
if (s.length !== 8) return undefined;
const y = Number(s.slice(0, 4));
const mo = Number(s.slice(4, 6)) - 1;
const day = Number(s.slice(6, 8));
const d = new Date(y, mo, day);
if (d.getFullYear() !== y || d.getMonth() !== mo || d.getDate() !== day) return undefined;
return d;
}
function parseInitialPrxyCtrtCnToDates(initial: string): { start: Date | undefined; end: Date | undefined } {
const t = initial.trim();
if (!/^\d{8}~\d{8}$/.test(t)) {
return "위탁계약기간은 YYYYMMDD~YYYYMMDD 형식으로 입력해 주세요. (예: 20240101~20281231)";
return { start: undefined, end: undefined };
}
const start = t.slice(0, 8);
const end = t.slice(9, 17);
if (!isValidYmd(start) || !isValidYmd(end)) {
return "시작일 또는 종료일이 유효한 날짜가 아닙니다.";
const start = parseYmdToDate(t.slice(0, 8));
const end = parseYmdToDate(t.slice(9, 17));
return { start, end };
}
function validateContractRange(start: Date | undefined, end: Date | undefined): string | null {
const startYmd = formatDateToYmd(start);
const endYmd = formatDateToYmd(end);
if (!startYmd || !endYmd) {
return "시작일과 종료일을 선택해 주세요.";
}
if (start > end) {
if (startYmd > endYmd) {
return "시작일이 종료일보다 늦을 수 없습니다.";
}
return null;
@@ -75,7 +92,8 @@ export function MainFacilityMappingSaveDialog({
const [prxyBzentyNmCn, setPrxyBzentyNmCn] = useState("");
const [cnsgnCstInput, setCnsgnCstInput] = useState("");
const [wholNopeInput, setWholNopeInput] = useState("");
const [prxyCtrtCn, setPrxyCtrtCn] = useState("");
const [prxyCtrtStart, setPrxyCtrtStart] = useState<Date | undefined>(undefined);
const [prxyCtrtEnd, setPrxyCtrtEnd] = useState<Date | undefined>(undefined);
const [isSubmitting, setIsSubmitting] = useState(false);
useEffect(() => {
@@ -94,7 +112,9 @@ export function MainFacilityMappingSaveDialog({
? ""
: String(initialWholNope),
);
setPrxyCtrtCn((initialPrxyCtrtCn ?? "").trim());
const { start, end } = parseInitialPrxyCtrtCnToDates(initialPrxyCtrtCn ?? "");
setPrxyCtrtStart(start);
setPrxyCtrtEnd(end);
}
}, [open, initialPrxyBzentyNmCn, initialCnsgnCstVl, initialWholNope, initialPrxyCtrtCn]);
@@ -103,7 +123,8 @@ export function MainFacilityMappingSaveDialog({
setPrxyBzentyNmCn("");
setCnsgnCstInput("");
setWholNopeInput("");
setPrxyCtrtCn("");
setPrxyCtrtStart(undefined);
setPrxyCtrtEnd(undefined);
}
onOpenChange(nextOpen);
};
@@ -121,18 +142,21 @@ export function MainFacilityMappingSaveDialog({
alert("전체 인원은 0 이상의 정수로 입력해 주세요.");
return;
}
const prxyCtrtErr = validatePrxyCtrtCn(prxyCtrtCn);
const prxyCtrtErr = validateContractRange(prxyCtrtStart, prxyCtrtEnd);
if (prxyCtrtErr) {
alert(prxyCtrtErr);
return;
}
const startYmd = formatDateToYmd(prxyCtrtStart)!;
const endYmd = formatDateToYmd(prxyCtrtEnd)!;
const prxyCtrtCnParam = `${startYmd}~${endYmd}`;
try {
setIsSubmitting(true);
await onConfirm?.({
prxyBzentyNmCn: prxyBzentyNmCn.trim(),
cnsgnCstVl,
wholNope,
prxyCtrtCn: prxyCtrtCn.trim(),
prxyCtrtCn: prxyCtrtCnParam,
});
} finally {
setIsSubmitting(false);
@@ -193,14 +217,26 @@ export function MainFacilityMappingSaveDialog({
</div>
<div className="flex flex-col gap-2">
<Label className="text-label">위탁계약기간</Label>
<Input
value={prxyCtrtCn}
onChange={(e) => setPrxyCtrtCn(e.target.value)}
className="!text-[1.3rem]"
placeholder="YYYYMMDD~YYYYMMDD (예: 20240101~20281231)"
autoComplete="off"
disabled={isSubmitting}
/>
<div className="flex flex-col gap-3 sm:flex-row sm:items-end sm:gap-4">
<div className="flex min-w-0 flex-1 flex-col gap-2">
<Label className="text-label text-[1.2rem]">시작일</Label>
<InputDatePicker
value={prxyCtrtStart}
onChange={setPrxyCtrtStart}
className="w-full"
disabled={isSubmitting}
/>
</div>
<div className="flex min-w-0 flex-1 flex-col gap-2">
<Label className="text-label text-[1.2rem]">종료일</Label>
<InputDatePicker
value={prxyCtrtEnd}
onChange={setPrxyCtrtEnd}
className="w-full"
disabled={isSubmitting}
/>
</div>
</div>
</div>
</div>
<DialogFooter>
@@ -1,8 +1,8 @@
import { useRef, useState } from 'react';
import { useEffect, useRef, useState } from 'react';
import { useConfirm } from '@frontend-opensource/use-react-hooks';
import { useAlertStore, useGlobalProgressStore } from '@/shared/lib';
import { useAlertStore, useGlobalProgressStore, useStatisticsInputToolbarGroupGate } from '@/shared/lib';
import { statisticsInputSewSupplyRateApi } from '@/entities/statistics/api/input/statistics-input-sew-supply-rate-service';
import { statisticsInputPublicSewageFacilityApiService } from '@/entities/statistics/api/input/statistics-public-sewage-facility-input-service';
import { statisticsApi } from '@/entities/statistics/api/statistics-service';
@@ -18,6 +18,7 @@ import {
type CheckInputPeriodItem,
getVerificationPeriodActionBlockedMessage,
isInputPeriodCurrent,
type SewerageSupplyRateVerificationResult,
} from '@/entities/statistics/model/input/statistics-sewage-supply-rate-input-interface';
import type { StatisticsInputSubmitInfoFormData } from '@/entities/statistics/model/statistics-input-submit-info-schema';
import type { cancelSubmitRequest, ImrStatusResponse, SewerageStatusInfo } from '@/entities/statistics/model/statistics-interface';
@@ -25,7 +26,7 @@ import { LoadingWithAnimation, Tabs, TabsContent, TabsList, TabsTrigger } from '
import { getUserInfo } from '../../../../../../packages/common/components/src/api';
import { MainFacilityMappingSaveDialog } from './MainFacilityMappingSaveDialog';
import { MainFacilityMappingSaveDialog, type MainFacilityMappingSaveDialogValues } from './MainFacilityMappingSaveDialog';
import SubmitDailog from './SubmitDailog';
import CancelSubmitButton from '@/components/button/CancelSubmitButton';
@@ -46,6 +47,10 @@ import { PublicSewageFactInpColumnList } from '@/components/util/column/input/Pu
import { toGridRows } from '@/components/util/grid/gridFunction';
import { useAdminStore } from '@/store/admin-store';
type GridRowWithVerification = Record<string, string | number> & {
verificationResult?: SewerageSupplyRateVerificationResult;
};
type TabToolbarSnapshot = {
showSaveInToolbar: boolean;
showEnterableToolbarButtons: boolean;
@@ -131,11 +136,16 @@ type MainFacilityMappingSaveDraft = {
initialPrxyCtrtCn: string;
};
const PUBLIC_SEWAGE_FACILITY_COLUMN_GUIDE_TAB_KEYS = Array.from(
new Set(PublicSewageFactInpColumnList.map((tab) => tab.tabKey).filter(Boolean)),
);
// 하수도 통계 관리 > 06. 공공하수처리시설
const PublicSewageFacilityInp = () => {
const { setMessage: alert } = useAlertStore((state) => state);
const { confirm } = useConfirm();
const { isAdminInfo } = useAdminStore();
const toolbarGroupAllowed = useStatisticsInputToolbarGroupGate();
const showGlobalProgress = useGlobalProgressStore((state) => state.show);
const setGlobalProgress = useGlobalProgressStore((state) => state.setProgress);
const setGlobalProgressLabel = useGlobalProgressStore((state) => state.setLabel);
@@ -149,6 +159,8 @@ const PublicSewageFacilityInp = () => {
const [lastSearchedValues, setLastSearchedValues] = useState<SearchFormValues | null>(null);
const [tabStatusInfoMap, setTabStatusInfoMap] = useState<Map<string, SewerageStatusInfo | null>>(new Map());
const [tabStatsCdMap, setTabStatsCdMap] = useState<Map<string, string>>(new Map());
/** 탭키별 `engColNm -> columnGuide` 캐시 */
const [columnGuidesByTab, setColumnGuidesByTab] = useState<Record<string, Record<string, string>>>({});
const [toolbarSnapshotByTab, setToolbarSnapshotByTab] = useState<Map<string, TabToolbarSnapshot>>(() => new Map());
const [inputPeriodEnterable, setInputPeriodEnterable] = useState(true);
const [periodCheckList, setPeriodCheckList] = useState<CheckInputPeriodItem[]>([]);
@@ -164,7 +176,7 @@ const PublicSewageFacilityInp = () => {
const searchDoneRef = useRef(false);
/** ST00060001만: RealGrid 편집값이 state에 없을 수 있어 저장/제출 시 그리드에서 직접 읽음 */
const st60001GridGetRowsRef = useRef<(() => Record<string, string | number>[]) | null>(null);
const submitFunc = () => {
void (async () => {
if (activeTab !== 'ST00060001') {
@@ -404,6 +416,13 @@ const PublicSewageFacilityInp = () => {
.filter((sn) => sn !== '');
const wholNopeFromRow = Number(selectedMain.wholNope ?? 0);
const deleteOnly = saveList.length === 0 && deleteList.length > 0;
const nothingToPersist = saveList.length === 0 && deleteList.length === 0;
if (nothingToPersist) {
alert('저장할 변경 사항이 없습니다.');
return;
}
mainFacilityMappingSaveDraftRef.current = {
statsYr: lastSearchedValues.year ?? '',
sewerBzmnCd: lastSearchedValues.bsnmCd ?? '',
@@ -418,6 +437,16 @@ const PublicSewageFacilityInp = () => {
initialWholNope: Number.isFinite(wholNopeFromRow) ? wholNopeFromRow : 0,
initialPrxyCtrtCn: String(selectedMain.prxyCtrtCn ?? ''),
};
if (deleteOnly) {
const draft = mainFacilityMappingSaveDraftRef.current;
const ok = await runMainFacilityMappingSave(draft, null);
if (ok) {
mainFacilityMappingSaveDraftRef.current = null;
}
return;
}
setMainFacilityMappingSaveDialogKey((k) => k + 1);
setMainFacilityMappingSaveDialogOpen(true);
return;
@@ -489,6 +518,36 @@ const PublicSewageFacilityInp = () => {
const firstTabKey = PublicSewageFactInpColumnList[0]?.tabKey ?? 'ST00060001';
const searchParamRef = useRef<PublicSewageFacilityRequestParams | null>(null);
useEffect(() => {
let cancelled = false;
void (async () => {
const entries = await Promise.all(
PUBLIC_SEWAGE_FACILITY_COLUMN_GUIDE_TAB_KEYS.map(async (tabKey) => {
try {
const res = await statisticsApi.getColumnGuides(tabKey);
if (res.status !== 'success' || !Array.isArray(res.data)) {
return [tabKey, {}] as const;
}
const next: Record<string, string> = {};
for (const item of res.data) {
const key = item.engColNm?.trim();
const guide = item.columnGuide?.trim();
if (key && guide) next[key] = guide;
}
return [tabKey, next] as const;
} catch {
return [tabKey, {}] as const;
}
}),
);
if (cancelled) return;
setColumnGuidesByTab(Object.fromEntries(entries));
})();
return () => {
cancelled = true;
};
}, []);
const commitToolbarSnapshotForLoadedTab = (
tabKey: string,
statusInfo: SewerageStatusInfo | null,
@@ -630,42 +689,40 @@ const PublicSewageFacilityInp = () => {
}
};
const handleMainFacilityMappingSaveConfirm = async (input: {
prxyBzentyNmCn: string;
cnsgnCstVl: number;
wholNope: number;
prxyCtrtCn: string;
}) => {
const draft = mainFacilityMappingSaveDraftRef.current;
if (!draft || !searchParamRef.current || !lastSearchedValues) {
setMainFacilityMappingSaveDialogOpen(false);
mainFacilityMappingSaveDraftRef.current = null;
return;
const runMainFacilityMappingSave = async (
draft: MainFacilityMappingSaveDraft,
dialogInput: MainFacilityMappingSaveDialogValues | null,
): Promise<boolean> => {
if (!searchParamRef.current || !lastSearchedValues) {
return false;
}
try {
showGlobalProgress({ label: '저장 진행 중...', progress: 0 });
const userInfo = await getUserInfo();
const param: PublicSewageMainFacilityMappingSaveRequest = {
const base = {
statsYr: draft.statsYr,
sewerBzmnCd: draft.sewerBzmnCd,
admdstCd: draft.admdstCd,
admdstNm: draft.admdstNm,
mainFcltSn: draft.mainFcltSn,
mainFcltNm: draft.mainFcltNm,
prxyBzentyNmCn: input.prxyBzentyNmCn,
cnsgnCstVl: input.cnsgnCstVl,
wholNope: input.wholNope,
prxyCtrtCn: input.prxyCtrtCn,
userId: userInfo.userUnqId,
saveList: draft.saveList,
deleteList: draft.deleteList,
};
const param: PublicSewageMainFacilityMappingSaveRequest = dialogInput
? {
...base,
prxyBzentyNmCn: dialogInput.prxyBzentyNmCn,
cnsgnCstVl: dialogInput.cnsgnCstVl,
wholNope: dialogInput.wholNope,
prxyCtrtCn: dialogInput.prxyCtrtCn,
}
: { ...base };
const response =
await statisticsInputPublicSewageFacilityApiService.saveMainFacilityMapping(param);
if (response.status === 'success') {
setGlobalProgress(50);
setMainFacilityMappingSaveDialogOpen(false);
mainFacilityMappingSaveDraftRef.current = null;
await loadPublicSewageFacilityActiveTabData(
'ST00060002',
searchParamRef.current,
@@ -673,16 +730,32 @@ const PublicSewageFacilityInp = () => {
);
setGlobalProgress(100);
alert('저장되었습니다.');
} else {
alert(response.message || '데이터 저장 중 오류가 발생했습니다.');
return true;
}
alert(response.message || '데이터 저장 중 오류가 발생했습니다.');
return false;
} catch {
alert('데이터 저장 중 오류가 발생했습니다.');
return false;
} finally {
hideGlobalProgress();
}
};
const handleMainFacilityMappingSaveConfirm = async (input: MainFacilityMappingSaveDialogValues) => {
const draft = mainFacilityMappingSaveDraftRef.current;
if (!draft || !searchParamRef.current || !lastSearchedValues) {
setMainFacilityMappingSaveDialogOpen(false);
mainFacilityMappingSaveDraftRef.current = null;
return;
}
const ok = await runMainFacilityMappingSave(draft, input);
if (ok) {
setMainFacilityMappingSaveDialogOpen(false);
mainFacilityMappingSaveDraftRef.current = null;
}
};
const handleSearch = async (values: SearchFormValues) => {
if (!values.sido || values.sido === 'ALL') {
alert('시도를 선택해주세요.');
@@ -783,7 +856,7 @@ const PublicSewageFacilityInp = () => {
const requestData: cancelSubmitRequest = {
sewerBzmnCd: lastSearchedValues?.bsnmCd ?? '',
ctpvNm: lastSearchedValues?.sido ?? '',
sggNm: lastSearchedValues?.gugun ?? '',
sggNm: lastSearchedValues?.gugun === 'ALL' ? '' : lastSearchedValues?.gugun ?? '',
statsYr: lastSearchedValues?.year ?? '',
mdfrId: userInfo.userUnqId,
statsCd: statsCdForCancel,
@@ -811,8 +884,15 @@ const PublicSewageFacilityInp = () => {
const getGridColumnInfo = (
tab: ColumnInfoList,
guideTabKey?: string,
): ColumnInfo => ({
columns: tab.columns ?? [],
columns: (tab.columns ?? []).map((col) => {
const guide = columnGuidesByTab[guideTabKey ?? tab.tabKey]?.[col.fieldName]?.trim();
return {
...col,
tooltip: guide && guide.length > 0 ? guide : undefined,
};
}),
columnLayout: tab.columnLayout ?? [],
});
@@ -843,7 +923,11 @@ const PublicSewageFacilityInp = () => {
alert(response.message || '매핑 대상 조회 중 오류가 발생했습니다.');
return;
}
const mappedTargetRows = (response.data ?? []).map((item) => ({
const rawList = response.data ?? [];
if (rawList.length === 0) {
alert('해당 시설은 중심처리시설의 위탁시설로 연결되어있습니다.');
}
const mappedTargetRows = rawList.map((item) => ({
...item,
__checked: item.mappedYn === 'Y' ? 'Y' : '',
}));
@@ -876,7 +960,14 @@ const PublicSewageFacilityInp = () => {
inputPeriodEnterable,
isAdminInfo,
);
const { showSaveInToolbar, showEnterableToolbarButtons, showActionToolbarCard } = activeTabToolbar;
const {
showSaveInToolbar: baseShowSaveInToolbar,
showEnterableToolbarButtons: baseShowEnterableToolbarButtons,
showActionToolbarCard: baseShowActionToolbarCard,
} = activeTabToolbar;
const showSaveInToolbar = baseShowSaveInToolbar && toolbarGroupAllowed;
const showEnterableToolbarButtons = baseShowEnterableToolbarButtons && toolbarGroupAllowed;
const showActionToolbarCard = baseShowActionToolbarCard && toolbarGroupAllowed;
const isMainFacilityTab = activeTab === 'ST00060002';
const openVerificationHistoryDialog = () => {
@@ -913,7 +1004,7 @@ const PublicSewageFacilityInp = () => {
onValuesChange={setSearchValues}
/>
{showActionToolbarCard && (
<div className="card !py-0">
<div className="card !py-0">
<div className="flex justify-end items-center gap-[0.6rem] mx-[0.6rem]">
<VerificationResultButton
VerificationResultButtonFunc={openVerificationHistoryDialog}
@@ -921,8 +1012,8 @@ const PublicSewageFacilityInp = () => {
{showEnterableToolbarButtons && !isMainFacilityTab && (
<>
{showIsNotApplicable && <NotApplicableCheckButton isCheck={isNotApplicable} setIsCheck={setIsNotApplicable} />}
<UpdateOperateDataButton updateOperateDataButtonFunc={updateOperateDataButtonFunc} />
<SubmitButton submitFunc={submitFunc} />
<UpdateOperateDataButton updateOperateDataButtonFunc={updateOperateDataButtonFunc} />
<SubmitButton submitFunc={submitFunc} />
</>
)}
{!isMainFacilityTab && activeTabStatus?.laeSbmsnSttsCd === '2' && (
@@ -937,7 +1028,7 @@ const PublicSewageFacilityInp = () => {
<div className="relative size-[5rem]">
<LoadingWithAnimation color="primary" />
</div>
</div>
</div>
)}
<Tabs value={activeTab} onValueChange={(v) => void handleActiveTabChange(v)}>
<TabsList>
@@ -955,8 +1046,8 @@ const PublicSewageFacilityInp = () => {
return (
<TabsContent key={tab.tabKey} value={tab.tabKey}>
<SplitDataGrid
leftColumnInfo={getGridColumnInfo(tab.left)}
rightColumnInfo={getGridColumnInfo(tab.right)}
leftColumnInfo={getGridColumnInfo(tab.left, tab.tabKey)}
rightColumnInfo={getGridColumnInfo(tab.right, tab.tabKey)}
leftFixedColumns={tab.left.fixedColumns}
rightFixedColumns={tab.right.fixedColumns}
showHeaderCheckbox={true}
@@ -1062,6 +1153,33 @@ const PublicSewageFacilityInp = () => {
}
showHeaderCheckbox={false}
enableSort={false}
cellStyleCallback={({ row, fieldName }) => {
const verification = (row as GridRowWithVerification).verificationResult;
if (!verification) return;
const errors = verification[fieldName];
if (Array.isArray(errors) && errors.length > 0) {
const hasImr = errors.some((e) => e.vrfcCd === "IMR" || e.vrfcCd === "HB");
return hasImr ? "rg-cell-verify-error-imr" : "rg-cell-verify-error";
}
}}
cellTooltipCallback={({ row, fieldName }) => {
const verification = (row as GridRowWithVerification).verificationResult;
if (!verification) return;
const errors = verification[fieldName];
if (Array.isArray(errors) && errors.length > 0) {
return errors
.map((e) => {
const prefix =
e.vrfcCd === "IMR" ? "IMR검증 - " : (e.vrfcCd === "NV" ? "일반검증 - " : "HB검증 - ");
const comp = e.compVlCn ?? "";
const now = e.nowVlCn ?? "";
return `${prefix}${e.rsnExpln}
(검증 기준값: ${comp} / 현재 입력값: ${now})`;
})
.filter(Boolean)
.join("\n");
}
}}
/>
</TabsContent>
);
@@ -1085,6 +1203,11 @@ const PublicSewageFacilityInp = () => {
open={verificationResultDialogOpen}
onOpenChange={setVerificationResultDialogOpen}
queryContext={verificationQueryContext}
onExceptionSubmitted={async () => {
const ctx = lastSearchedValues ?? searchValues;
if (!ctx || !searchParamRef.current) return;
await loadPublicSewageFacilityActiveTabData(activeTab, searchParamRef.current, ctx);
}}
/>
<MainFacilityMappingSaveDialog
key={`main-facility-mapping-save-${mainFacilityMappingSaveDialogKey}`}
@@ -2,7 +2,7 @@ import { useEffect, useLayoutEffect,useMemo, useRef, useState } from "react";
import { useConfirm } from "@frontend-opensource/use-react-hooks";
import { useAlertStore, useGlobalProgressStore } from "@/shared/lib";
import { useAlertStore, useGlobalProgressStore, useStatisticsInputToolbarGroupGate } from "@/shared/lib";
import {
STATISTICS_INPUT_URL_PATH,
verificationSearchToFormValues,
@@ -192,11 +192,14 @@ type GridRowWithVerification = Record<string, string | number> & {
};
const REGIONAL_FACT_TAB_KEY = "ST00020001";
const SEWAGE_PIPE_COLUMN_GUIDE_TAB_KEYS = Array.from(
new Set(SewagePipeInpColumnInfoList.map((tab) => tab.tabKey).filter(Boolean)),
);
function hasRegionalFactVerificationErrors(verification?: SeweragePipeInputVerificationResult | null): boolean {
if (!verification) return false;
return Object.values(verification).some((errors) => Array.isArray(errors) && errors.length > 0);
}
// function hasRegionalFactVerificationErrors(verification?: SeweragePipeInputVerificationResult | null): boolean {
// if (!verification) return false;
// return Object.values(verification).some((errors) => Array.isArray(errors) && errors.length > 0);
// }
const sleep = (ms: number) => new Promise<void>((resolve) => setTimeout(resolve, ms));
@@ -355,6 +358,8 @@ const SewagePipeInp = () => {
const [tabStatsCdMap, setTabStatsCdMap] = useState<Map<string, string>>(new Map());
const [verificationHistoryOpen, setVerificationHistoryOpen] = useState(false);
const [periodCheckList, setPeriodCheckList] = useState<CheckInputPeriodItem[]>([]);
/** 탭키별 `engColNm -> columnGuide` 캐시 */
const [columnGuidesByTab, setColumnGuidesByTab] = useState<Record<string, Record<string, string>>>({});
/** API 연결 전에도 검색 후 툴바 등 UI를 쓸 수 있게 함 */
const [hasSearched, setHasSearched] = useState(false);
/** checkInputPeriod — columnInfo useMemo가 입력기간 변경을 반영하도록 state로도 유지 */
@@ -370,6 +375,7 @@ const SewagePipeInp = () => {
const hideGlobalProgress = useGlobalProgressStore((state) => state.hide);
const globalProgressVisible = useGlobalProgressStore((state) => state.visible);
const { isAdminInfo } = useAdminStore();
const toolbarGroupAllowed = useStatisticsInputToolbarGroupGate();
const getGridDataRef = useRef<(() => Record<string, string | number>[]) | null>(null);
const getFactInstallGridDataRef = useRef<(() => Record<string, string | number>[]) | null>(null);
@@ -417,6 +423,37 @@ const SewagePipeInp = () => {
}
}, [activeTabId, inputFocusNonce]);
useEffect(() => {
if (activeTabId !== STATISTICS_INPUT_URL_PATH.SEWAGE_PIPE) return;
let cancelled = false;
void (async () => {
const entries = await Promise.all(
SEWAGE_PIPE_COLUMN_GUIDE_TAB_KEYS.map(async (tabKey) => {
try {
const res = await statisticsApi.getColumnGuides(tabKey);
if (res.status !== "success" || !Array.isArray(res.data)) {
return [tabKey, {}] as const;
}
const next: Record<string, string> = {};
for (const item of res.data) {
const key = item.engColNm?.trim();
const guide = item.columnGuide?.trim();
if (key && guide) next[key] = guide;
}
return [tabKey, next] as const;
} catch {
return [tabKey, {}] as const;
}
}),
);
if (cancelled) return;
setColumnGuidesByTab(Object.fromEntries(entries));
})();
return () => {
cancelled = true;
};
}, [activeTabId]);
const syncTopStatusIfStillOnTab = (loadedTabKey: string, nextStatsCd: string) => {
if (loadedTabKey === activeTabRef.current) {
setStatsCd(nextStatsCd);
@@ -445,6 +482,14 @@ const SewagePipeInp = () => {
};
const columnInfo = useMemo(() => {
const applyColumnGuides = (cols: ColumnConfig[] | undefined, tabKey: string) =>
(cols ?? []).map((col: ColumnConfig) => {
const guide = columnGuidesByTab[tabKey]?.[col.fieldName]?.trim();
return {
...col,
tooltip: guide && guide.length > 0 ? guide : undefined,
};
});
return SewagePipeInpColumnInfoList.map((tab: ColumnInfoList) => {
const canEdit =
computeTabEnterable(tabStatusInfoMap.get(tab.tabKey) ?? null, inputPeriodEnterable) || isAdminInfo;
@@ -458,16 +503,16 @@ const SewagePipeInp = () => {
if (tab.left && tab.right) {
return {
...tab,
left: { ...tab.left, columns: applyEditable(tab.left.columns) },
right: { ...tab.right, columns: applyEditable(tab.right.columns) },
left: { ...tab.left, columns: applyColumnGuides(applyEditable(tab.left.columns), tab.tabKey) },
right: { ...tab.right, columns: applyColumnGuides(applyEditable(tab.right.columns), tab.tabKey) },
};
}
return {
...tab,
columns: applyEditable(tab.columns),
columns: applyColumnGuides(applyEditable(tab.columns), tab.tabKey),
};
});
}, [tabStatusInfoMap, isAdminInfo, inputPeriodEnterable]);
}, [tabStatusInfoMap, isAdminInfo, inputPeriodEnterable, columnGuidesByTab]);
const commitToolbarSnapshotForLoadedTab = (
tabKey: string,
@@ -797,27 +842,27 @@ const SewagePipeInp = () => {
}
};
const getTabGridRowsForVerification = (tabKey: string): Record<string, string | number>[] => {
switch (tabKey) {
case "ST00020001":
return getGridDataRef.current?.() ?? [];
case "ST00020002":
return getFactInstallGridDataRef.current?.() ?? [];
case "ST00020003":
return getPipeTypeGridDataRef.current?.() ?? [];
case "ST00020004":
return getPipeDiameterGridDataRef.current?.() ?? [];
case "ST00020005":
return getManholeGridDataRef.current?.() ?? [];
case "ST00020006":
return [
...(rowDataMap.get("treatmentPlantConnectionInp") ?? []),
...(rowDataMap.get("adminAreaConnectionInp") ?? []),
];
default:
return [];
}
};
// const getTabGridRowsForVerification = (tabKey: string): Record<string, string | number>[] => {
// switch (tabKey) {
// case "ST00020001":
// return getGridDataRef.current?.() ?? [];
// case "ST00020002":
// return getFactInstallGridDataRef.current?.() ?? [];
// case "ST00020003":
// return getPipeTypeGridDataRef.current?.() ?? [];
// case "ST00020004":
// return getPipeDiameterGridDataRef.current?.() ?? [];
// case "ST00020005":
// return getManholeGridDataRef.current?.() ?? [];
// case "ST00020006":
// return [
// ...(rowDataMap.get("treatmentPlantConnectionInp") ?? []),
// ...(rowDataMap.get("adminAreaConnectionInp") ?? []),
// ];
// default:
// return [];
// }
// };
const SubmitExceptionFunc = async (submitInfo: StatisticsInputSubmitInfoFormData) => {
const tabKey = activeTabRef.current;
@@ -859,21 +904,21 @@ const SewagePipeInp = () => {
};
const submitFunc = async (isException: boolean) => {
const tabKey = activeTabRef.current;
// const tabKey = activeTabRef.current;
const verificationBlocked = getVerificationPeriodActionBlockedMessage(periodCheckList);
if (verificationBlocked) {
alert(verificationBlocked);
return;
}
if (!isException) {
const currentData = getTabGridRowsForVerification(tabKey);
const rowsWithVerificationErrors = currentData.filter((d) =>
hasRegionalFactVerificationErrors((d as GridRowWithVerification).verificationResult),
);
if (rowsWithVerificationErrors.length > 0) {
alert("데이터에 오류가 있습니다.\n수정 후 제출해주세요.");
return;
}
// const currentData = getTabGridRowsForVerification(tabKey);
// const rowsWithVerificationErrors = currentData.filter((d) =>
// hasRegionalFactVerificationErrors((d as GridRowWithVerification).verificationResult),
// );
// if (rowsWithVerificationErrors.length > 0) {
// alert("데이터에 오류가 있습니다.\n수정 후 제출해주세요.");
// return;
// }
if (await confirm("제출 하시겠습니까?")) {
setIsExceptionSubmitFlow(false);
setShowDailog(true);
@@ -1251,7 +1296,7 @@ const SewagePipeInp = () => {
const requestData: cancelSubmitRequest = {
sewerBzmnCd: searchValues?.bsnmCd ?? '',
ctpvNm: searchValues?.sido ?? '',
sggNm: searchValues?.gugun ?? '',
sggNm: searchValues?.gugun === "ALL" ? "" : (searchValues?.gugun ?? ""),
statsYr: searchValues?.year ?? '',
mdfrId: userInfo.userUnqId,
statsCd: tabStatsCdMap.get(activeTab) ?? statsCd,
@@ -1739,10 +1784,12 @@ const SewagePipeInp = () => {
showEnterableToolbarButtons: baseShowEnterableToolbarButtons,
showActionToolbarCard: baseShowActionToolbarCard,
} = activeTabToolbar;
const showSaveInToolbar = !hideToolbarForConnectionTab && baseShowSaveInToolbar;
const showSaveInToolbar =
!hideToolbarForConnectionTab && baseShowSaveInToolbar && toolbarGroupAllowed;
const showEnterableToolbarButtons =
!hideToolbarForConnectionTab && baseShowEnterableToolbarButtons;
const showActionToolbarCard = !hideToolbarForConnectionTab && baseShowActionToolbarCard;
!hideToolbarForConnectionTab && baseShowEnterableToolbarButtons && toolbarGroupAllowed;
const showActionToolbarCard =
!hideToolbarForConnectionTab && baseShowActionToolbarCard && toolbarGroupAllowed;
return (
<>
@@ -1934,7 +1981,7 @@ const SewagePipeInp = () => {
if (!verification) return;
const errors = verification[fieldName];
if (Array.isArray(errors) && errors.length > 0) {
const hasImr = errors.some((e) => e.vrfcCd === "IMR");
const hasImr = errors.some((e) => e.vrfcCd === "IMR" || e.vrfcCd === "HB");
return hasImr ? "rg-cell-verify-error-imr" : "rg-cell-verify-error";
}
}}
@@ -1946,10 +1993,11 @@ const SewagePipeInp = () => {
return errors
.map((e) => {
const prefix =
e.vrfcCd === "IMR" ? "IMR검증 - " : "일반검증 - ";
e.vrfcCd === "IMR" ? "IMR검증 - " : (e.vrfcCd === "NV" ? "일반검증 - " : "HB검증 - ");
const comp = e.compVlCn ?? "";
const now = e.nowVlCn ?? "";
return `${prefix}${e.rsnExpln}(검증 기준값: ${comp} / 현재 입력값: ${now})`;
return `${prefix}${e.rsnExpln}
(검증 기준값: ${comp} / 현재 입력값: ${now})`;
})
.filter(Boolean)
.join("\n");
@@ -2008,6 +2056,12 @@ const SewagePipeInp = () => {
}
: null
}
onExceptionSubmitted={async () => {
const values = lastSearchedValues ?? searchValues;
if (!values?.year || !values?.sido || !values?.bsnmCd) return;
const refreshedParam = buildSewagePipeSearchParam(values);
await loadSewagePipeActiveTabData(activeTab, refreshedParam, values, true, false);
}}
/>
</>
);
@@ -211,7 +211,7 @@ const SewagePipeMaintainInp = () => {
if (!job) return;
postVerificationSearchRef.current = null;
void handleSearch(job.values, { forcedTabKey: job.tabKey });
// eslint-disable-next-line react-hooks/exhaustive-deps -- 검색폼 remount 직후 1회 자동 조회
}, [searchFormRemountKey]);
const handleActiveTabChange = async (tabKey: string) => {
@@ -1,8 +1,8 @@
import { useEffect,useLayoutEffect, useMemo, useRef, useState } from "react";
import { useEffect, useLayoutEffect, useMemo, useRef, useState } from "react";
import { useConfirm } from "@frontend-opensource/use-react-hooks";
import { useAlertStore, useGlobalProgressStore } from "@/shared/lib";
import { useAlertStore, useGlobalProgressStore, useStatisticsInputToolbarGroupGate } from "@/shared/lib";
import {
STATISTICS_INPUT_URL_PATH,
verificationSearchToFormValues,
@@ -57,10 +57,13 @@ type GridRowWithVerification = Record<string, string | number> & {
verificationResult?: SewerageSupplyRateVerificationResult;
};
const hasVerificationErrors = (verification?: SewerageSupplyRateVerificationResult | null): boolean => {
if (!verification) return false;
return Object.values(verification).some((errors) => Array.isArray(errors) && errors.length > 0);
};
/** 컬럼 가이드 API 조회용 통계코드 (하수도 보급률) */
const SEWERAGE_PENETRATION_COLUMN_GUIDE_STATS_CD = "ST00010001";
// const hasVerificationErrors = (verification?: SewerageSupplyRateVerificationResult | null): boolean => {
// if (!verification) return false;
// return Object.values(verification).some((errors) => Array.isArray(errors) && errors.length > 0);
// };
const sleep = (ms: number) => new Promise<void>((resolve) => setTimeout(resolve, ms));
@@ -101,6 +104,14 @@ function getSewerageSupplyRateParams(
};
}
function normalizeGridString(value: unknown): string | null {
if (typeof value !== "string") {
return null;
}
return value.trim() === "" ? null : value;
}
// 하수도 통계 관리 > 01. 하수도 보급률
const SeweragePenetrationRateInp = () => {
const { setMessage: alert } = useAlertStore((state) => state);
@@ -119,6 +130,8 @@ const SeweragePenetrationRateInp = () => {
/** 예외제출 버튼으로 연 다이얼로그인지 (확인 시 executeSubmit 대신 SubmitExceptionFunc 경로) */
const [isExceptionSubmitFlow, setIsExceptionSubmitFlow] = useState(false);
const [statsCd, setStatsCd] = useState<string>('');
/** `engColNm` → `columnGuide` (헤더 hover) */
const [columnGuidesByEngCol, setColumnGuidesByEngCol] = useState<Record<string, string>>({});
const [footerFromApi, setFooterFromApi] = useState<{ sewerBzmnWholPpltnCnt?: number; sewerBzmnDomplWholPpltnCnt?: number; sewerBzmnFrgnrWholPpltnCnt?: number; sewerBzmnArea?: number } | null>(null);
const { confirm } = useConfirm();
const showGlobalProgress = useGlobalProgressStore((state) => state.show);
@@ -149,7 +162,37 @@ const SeweragePenetrationRateInp = () => {
setSearchFormRemountKey((k) => k + 1);
}, [activeTabId, inputFocusNonce]);
useEffect(() => {
if (activeTabId !== STATISTICS_INPUT_URL_PATH.SEWERAGE_PENETRATION_RATE) {
return;
}
let cancelled = false;
void (async () => {
try {
const res = await statisticsApi.getColumnGuides(SEWERAGE_PENETRATION_COLUMN_GUIDE_STATS_CD);
if (cancelled) return;
if (res.status !== "success" || !Array.isArray(res.data)) {
setColumnGuidesByEngCol({});
return;
}
const next: Record<string, string> = {};
for (const item of res.data) {
const key = item.engColNm?.trim();
const guide = item.columnGuide?.trim();
if (key && guide) next[key] = guide;
}
setColumnGuidesByEngCol(next);
} catch {
if (!cancelled) setColumnGuidesByEngCol({});
}
})();
return () => {
cancelled = true;
};
}, [activeTabId]);
const { isAdminInfo } = useAdminStore();
const toolbarGroupAllowed = useStatisticsInputToolbarGroupGate();
const getGridDataRef = useRef<(() => Record<string, string | number>[]) | null>(null);
@@ -167,9 +210,17 @@ const SeweragePenetrationRateInp = () => {
const { columns, columnLayout } = SeweragePenetrationRateInpColumn;
const base = (isEnterable || isAdminInfo) ? columns : columns.map((col) => ({ ...col, editable: false as const }));
const withHeaderTooltips = base.map((col) => {
const guide = columnGuidesByEngCol[col.fieldName]?.trim();
return {
...col,
tooltip: guide && guide.length > 0 ? guide : undefined,
};
});
return {
columnLayout,
columns: base.map((col) => {
columns: withHeaderTooltips.map((col) => {
if (col.fieldName === 'pbadmsWholPpltnCnt') return { ...col, footer: footerWithApiDefault('sewerBzmnWholPpltnCnt', '#,##0') };
if (col.fieldName === 'domplWholPpltnCnt') return { ...col, footer: footerWithApiDefault('sewerBzmnDomplWholPpltnCnt', '#,##0') };
if (col.fieldName === 'frgnrWholPpltnCnt') return { ...col, footer: footerWithApiDefault('sewerBzmnFrgnrWholPpltnCnt', '#,##0') };
@@ -178,7 +229,7 @@ const SeweragePenetrationRateInp = () => {
return col;
}),
};
}, [isEnterable, isAdminInfo, footerFromApi]);
}, [isEnterable, isAdminInfo, footerFromApi, columnGuidesByEngCol]);
const handleReset = () => {
setRowData([]);
@@ -276,7 +327,7 @@ const SeweragePenetrationRateInp = () => {
if (!values) return;
postVerificationSearchRef.current = null;
void handleSearch(values);
}, [searchFormRemountKey]);
const SubmitExceptionFunc = async (submitInfo: StatisticsInputSubmitInfoFormData) => {
@@ -291,7 +342,7 @@ const SeweragePenetrationRateInp = () => {
...submitInfo,
sewerBzmnCd: searchValues?.bsnmCd ?? '',
ctpvNm: searchValues?.sido ?? '',
sggNm: searchValues?.gugun ?? '',
sggNm: searchValues?.gugun === "ALL" ? "" : (searchValues?.gugun ?? ''),
statsYr: searchValues?.year ?? '',
userId: userInfo.userUnqId,
statsCd: statsCd,
@@ -336,15 +387,15 @@ const SeweragePenetrationRateInp = () => {
const submitFunc = async (isException: boolean) => {
if (!isException) {
const isError = rowData.filter((d) =>
hasVerificationErrors((d as GridRowWithVerification).verificationResult)
);
// const isError = rowData.filter((d) =>
// hasVerificationErrors((d as GridRowWithVerification).verificationResult)
// );
if (isError.length > 0) {
alert(`데이터에 오류가 있습니다.
수정 후 제출해주세요.`);
return;
}
// if (isError.length > 0) {
// alert(`데이터에 오류가 있습니다.
// 수정 후 제출해주세요.`);
// return;
// }
if (await confirm('제출 하시겠습니까?')) {
setIsExceptionSubmitFlow(false);
setShowDailog(true);
@@ -396,11 +447,12 @@ const SeweragePenetrationRateInp = () => {
};
const convertedData: SewerageSupplyRateSaveItem[] = currentData.map((d) => ({
sggNm: (d.sggNm as string) || null,
admdstCd: d.admdstCd as string,
admdstNm: d.admdstNm as string,
trbtrNm: d.trbtrNm as string,
spcCtpnRgnCn: d.spcCtpnRgnCn === '해당없음' ? null : (d.spcCtpnRgnCn as string | null),
sggNm: normalizeGridString(d.sggNm),
admdstCd: normalizeGridString(d.admdstCd),
admdstNm: normalizeGridString(d.admdstNm),
trbtrNm: normalizeGridString(d.trbtrNm),
spcCtpnRgnCn:
d.spcCtpnRgnCn === '해당없음' ? null : normalizeGridString(d.spcCtpnRgnCn),
ts1SwtrmtPpltnCnt: formatNumber(d.ts1SwtrmtPpltnCnt, 'ts1SwtrmtPpltnCnt'),
ts2SwtrmtPpltnCnt: formatNumber(d.ts2SwtrmtPpltnCnt, 'ts2SwtrmtPpltnCnt'),
ts3SwtrmtPpltnCnt: formatNumber(d.ts3SwtrmtPpltnCnt, 'ts3SwtrmtPpltnCnt'),
@@ -413,7 +465,7 @@ const SeweragePenetrationRateInp = () => {
drtwPrcsPpltnCnt: formatNumber(d.drtwPrcsPpltnCnt, 'drtwPrcsPpltnCnt'),
spctnkPpltnCnt: formatNumber(d.spctnkPpltnCnt, 'spctnkPpltnCnt'),
staaExcpArea: formatNumber(d.staaExcpArea, 'staaExcpArea'),
rmrk: (d.rmrk as string) ?? '',
rmrk: normalizeGridString(d.rmrk),
wholPpltnCnt: formatNumber(d.wholPpltnCnt, 'wholPpltnCnt'),
wholArea: formatNumber(d.wholArea, 'wholArea'),
wholSwtrmtPpltnCnt: formatNumber(d.wholSwtrmtPpltnCnt, 'wholSwtrmtPpltnCnt'),
@@ -428,7 +480,7 @@ const SeweragePenetrationRateInp = () => {
const requestData: SewerageSupplyRateSaveRequest = {
sewerBzmnCd: searchValues?.bsnmCd ?? '',
ctpvNm: searchValues?.sido ?? '',
sggNm: searchValues?.gugun ?? '',
sggNm: searchValues?.gugun === "ALL" ? "" : (searchValues?.gugun ?? ''),
statsYr: searchValues?.year ?? '',
userId: userInfo.userUnqId,
submitList: convertedData,
@@ -542,11 +594,14 @@ const SeweragePenetrationRateInp = () => {
const convertedData: SewerageSupplyRateSaveItem[] = currentData.map((d) => ({
// 행정구역
sggNm: (d.sggNm as string) || null, // 시군구
admdstCd: d.admdstCd as string, // 행정코드
admdstNm: d.admdstNm as string, // 행정구역명
trbtrNm: d.trbtrNm as string, // 지류
spcCtpnRgnCn: d.spcCtpnRgnCn === '해당없음' ? null : (d.spcCtpnRgnCn as string | null), // 특별대책지역
sggNm: normalizeGridString(d.sggNm), // 시군구
admdstCd: normalizeGridString(d.admdstCd), // 행정코드
admdstNm: normalizeGridString(d.admdstNm), // 행정구역명
trbtrNm: normalizeGridString(d.trbtrNm), // 지류
spcCtpnRgnCn:
d.spcCtpnRgnCn === '해당없음'
? null
: normalizeGridString(d.spcCtpnRgnCn), // 특별대책지역
// 공공하수처리인구(명) - 물리적/생물학적/고도처리
ts1SwtrmtPpltnCnt: formatNumber(d.ts1SwtrmtPpltnCnt, 'ts1SwtrmtPpltnCnt'), // 물리적처리
ts2SwtrmtPpltnCnt: formatNumber(d.ts2SwtrmtPpltnCnt, 'ts2SwtrmtPpltnCnt'), // 생물학적처리
@@ -562,7 +617,7 @@ const SeweragePenetrationRateInp = () => {
drtwPrcsPpltnCnt: formatNumber(d.drtwPrcsPpltnCnt, 'drtwPrcsPpltnCnt'), // 오수처리인구
spctnkPpltnCnt: formatNumber(d.spctnkPpltnCnt, 'spctnkPpltnCnt'), // 정화조인구
staaExcpArea: formatNumber(d.staaExcpArea, 'staaExcpArea'), // 하수처리구역 외 면적(㎢)
rmrk: (d.rmrk as string) ?? '', // 비고
rmrk: normalizeGridString(d.rmrk), // 비고
// 합계·소계(계산필드 저장용)
wholPpltnCnt: formatNumber(d.wholPpltnCnt, 'wholPpltnCnt'), // 총인구(명)
wholArea: formatNumber(d.wholArea, 'wholArea'), // 총면적(㎢)
@@ -579,7 +634,7 @@ const SeweragePenetrationRateInp = () => {
const requestData: SewerageSupplyRateSaveRequest = {
sewerBzmnCd: searchValues?.bsnmCd ?? '', // 하수도 사업자 코드
ctpvNm: searchValues?.sido ?? '', // 시도
sggNm: searchValues?.gugun ?? '',
sggNm: searchValues?.gugun === "ALL" ? "" : (searchValues?.gugun ?? ''),
statsYr: searchValues?.year ?? '', // 통계 연도
userId: userInfo.userUnqId, // 사용자
saveList: convertedData, // 저장 데이터
@@ -624,7 +679,7 @@ const SeweragePenetrationRateInp = () => {
const requestData: cancelSubmitRequest = {
sewerBzmnCd: searchValues?.bsnmCd ?? '',
ctpvNm: searchValues?.sido ?? '',
sggNm: searchValues?.gugun ?? '',
sggNm: searchValues?.gugun === "ALL" ? "" : (searchValues?.gugun ?? ""),
statsYr: searchValues?.year ?? '',
mdfrId: userInfo.userUnqId,
statsCd: statsCd,
@@ -656,12 +711,15 @@ const SeweragePenetrationRateInp = () => {
? Boolean(lastSearchedValues?.gugun && lastSearchedValues.gugun !== 'ALL')
: true;
const showSaveInToolbar = (isEnterable || isAdminInfo) && gugunOkOrNotDoList;
const showEnterableToolbarButtons = isEnterable && gugunOkOrNotDoList;
const baseShowSaveInToolbar = (isEnterable || isAdminInfo) && gugunOkOrNotDoList;
const baseShowEnterableToolbarButtons = isEnterable && gugunOkOrNotDoList;
const showSaveInToolbar = baseShowSaveInToolbar && toolbarGroupAllowed;
const showEnterableToolbarButtons = baseShowEnterableToolbarButtons && toolbarGroupAllowed;
/** 카드는 저장/제출줄/제출취소 중 하나라도 보일 때만 (관리자만으로 빈 카드 방지) */
const showActionToolbarCard =
rowData.length > 0 &&
(showSaveInToolbar ||
toolbarGroupAllowed &&
(baseShowSaveInToolbar ||
(statusInfo?.laeSbmsnSttsCd === '2' && (isAdminInfo || (isEnterable && gugunOkOrNotDoList))));
return (
@@ -712,7 +770,7 @@ const SeweragePenetrationRateInp = () => {
const errors = verification[fieldName];
if (Array.isArray(errors) && errors.length > 0) {
// IMR 검증은 노란색으로 구분
const hasImr = errors.some((e) => e.vrfcCd === 'IMR');
const hasImr = errors.some((e) => e.vrfcCd === 'IMR' || e.vrfcCd === 'HB');
return hasImr ? 'rg-cell-verify-error-imr' : 'rg-cell-verify-error';
}
}}
@@ -723,8 +781,9 @@ const SeweragePenetrationRateInp = () => {
if (Array.isArray(errors) && errors.length > 0) {
return errors
.map((e) => {
const prefix = e.vrfcCd === 'IMR' ? 'IMR검증 - ' : '일반검증 - ';
return `${prefix}${e.rsnExpln}(검증 기준값: ${e.compVlCn} / 현재 입력값: ${e.nowVlCn})`;
const prefix = e.vrfcCd === 'IMR' ? 'IMR검증 - ' : (e.vrfcCd === 'NM' ? '일반검증 - ' : 'HB검증 -');
return `${prefix}${e.rsnExpln}
(검증 기준값: ${e.compVlCn} / 현재 입력값: ${e.nowVlCn})`;
})
.filter(Boolean)
.join('\n');
@@ -777,6 +836,11 @@ const SeweragePenetrationRateInp = () => {
}
: null
}
onExceptionSubmitted={async () => {
const values = lastSearchedValues ?? searchValues;
if (!values) return;
await handleSearch(values);
}}
/>
</>
);
@@ -1,18 +1,35 @@
import { useEffect, useState } from "react";
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
import { useConfirm } from "@frontend-opensource/use-react-hooks";
import { RealGridReact } from "realgrid-react";
import useSWR, { useSWRConfig } from "swr";
import { useAlertStore } from "@/shared/lib";
import { useAlertStore, useStatisticsInputStatusConfirmGroupGate } from "@/shared/lib";
import { statisticsInputSewSupplyRateApi } from "@/entities/statistics/api/input/statistics-input-sew-supply-rate-service";
import { StatisticsInputStatusApi } from "@/entities/statistics/api/input/statistics-input-status-service";
import type { StatsInputStatusListRequestParams } from "@/entities/statistics/model/input/statistics-input-status-interface";
import type { StatsInputStatusListApiResponse } from "@/entities/statistics/model/input/statistics-input-status-interface";
import { LoadingWithAnimation } from "@common/components";
import { statisticsApi } from "@/entities/statistics/api/statistics-service";
import {
isInputPeriodCurrent,
type CheckInputPeriodItem,
} from "@/entities/statistics/model/input/statistics-sewage-supply-rate-input-interface";
import type { StatsInputStatusApproveItem } from "@/entities/statistics/model/statistics-interface";
import type {
StatsInputStatusListApiResponse,
StatsInputStatusListRequestParams,
} from "@/entities/statistics/model/input/statistics-input-status-interface";
import { getUserInfo } from "../../../../../../packages/common/components/src/api";
import { LoadingWithAnimation } from "@common/components";
import ApproveButton from "@/components/button/ApproveButton";
import RejectButton from "@/components/button/RejectButton";
import DataGrid from "@/components/module/DataGrid";
import type { DataCellClickCallbackParams } from "@/components/type/DataGrid.type";
import {
ExceptionProcessingHistoryDialog,
type ExceptionProcessingHistoryQueryContext,
} from "@/components/module/ExceptionProcessingHistoryDialog";
import SearchForm from "@/components/module/SearchForm";
import type { SearchFormValues } from "@/components/type/SearchForm.type";
import { doList } from "@/components/util/column/input/DoList";
import { StatsInputStatusInpColumn } from "@/components/util/column/input/StatsInputStatusInpColumn";
import { toGridRows } from "@/components/util/grid/gridFunction";
import { useTabStore } from "@/store/tab-store";
@@ -25,13 +42,27 @@ const getStatsInputStatusFetcher = async (
// 하수도 통계 관리 > 00. 통계 입력 현황
const StatsInputStatusInp = () => {
const { setMessage: alert } = useAlertStore((state) => state);
const { confirm } = useConfirm();
const activeTabId = useTabStore((state) => state.activeTabId);
const [rowData, setRowData] = useState<Record<string, string | number>[]>(
[],
);
const [searchParams, setSearchParams] =
useState<StatsInputStatusListRequestParams | null>(null);
const [lastSearchedValues, setLastSearchedValues] =
useState<SearchFormValues | null>(null);
const [hasCompletedSearch, setHasCompletedSearch] = useState(false);
const periodCheckListRef = useRef<CheckInputPeriodItem[]>([]);
const inputPeriodCurrentRef = useRef(false);
const gridRef = useRef<RealGridReact | null>(null);
const [exceptionHistoryOpen, setExceptionHistoryOpen] = useState(false);
const [exceptionHistoryContext, setExceptionHistoryContext] = useState<
ExceptionProcessingHistoryQueryContext | null
>(null);
const [isApproveLoading, setIsApproveLoading] = useState(false);
const confirmGroupToolbarAllowed = useStatisticsInputStatusConfirmGroupGate();
const swrKey = searchParams
? (["stats-input-status", searchParams] as const)
@@ -44,13 +75,22 @@ const StatsInputStatusInp = () => {
revalidateOnFocus: true,
onSuccess: (data) => {
if (data.status === "success" && data.data) {
setRowData(toGridRows(data.data));
setRowData(
toGridRows(data.data).map((r) => ({
...r,
exceptionSubmitHistory: "예외제출내역",
})),
);
setHasCompletedSearch(true);
} else {
setHasCompletedSearch(false);
setRowData([]);
alert(data.message ?? "조회에 실패했습니다.");
}
},
onError: () => {
alert("데이터 조회 중 오류가 발생했습니다.");
setHasCompletedSearch(false);
setRowData([]);
},
},
@@ -62,45 +102,184 @@ const StatsInputStatusInp = () => {
}
}, [activeTabId]);
const handleSearch = (values: SearchFormValues) => {
const handleSearch = async (values: SearchFormValues) => {
if (values.sido === "" || values.sido === "ALL") {
alert("시도를 선택해주세요.");
return;
}
const params: StatsInputStatusListRequestParams = {
ctpvNm: values.sido,
sewerBzmnCd: values.bsnmCd,
sggNm: values.gugun === "ALL" ? "" : values.gugun,
statsYr: values.year ?? "",
};
setSearchParams(params);
mutate(["stats-input-status", params]);
try {
const response = await statisticsInputSewSupplyRateApi.checkInputPeriod(values.year || "");
if (response.status !== "success") {
alert(response.message ?? "입력기간 확인에 실패했습니다.");
return;
}
const periods = Array.isArray(response.data) ? response.data : [];
periodCheckListRef.current = periods;
const checkEnterable = isInputPeriodCurrent(periods);
inputPeriodCurrentRef.current = checkEnterable;
const params: StatsInputStatusListRequestParams = {
ctpvNm: values.sido,
sewerBzmnCd: values.bsnmCd,
sggNm: values.gugun === "ALL" ? "" : values.gugun,
statsYr: values.year ?? "",
};
setHasCompletedSearch(false);
setLastSearchedValues(values);
setSearchParams(params);
mutate(["stats-input-status", params]);
} catch {
alert("데이터 조회 중 오류가 발생했습니다.");
}
};
const handleReset = () => {
setSearchParams(null);
setRowData([]);
setLastSearchedValues(null);
setHasCompletedSearch(false);
periodCheckListRef.current = [];
inputPeriodCurrentRef.current = false;
setExceptionHistoryOpen(false);
setExceptionHistoryContext(null);
setIsApproveLoading(false);
};
const rejectFunc = () => {
// 보완 요청 로직
const approveFunc = async () => {
if (rowData.length === 0) {
alert("조회된 통계 항목이 없습니다.");
return;
}
if (!lastSearchedValues?.year?.trim() || !lastSearchedValues?.bsnmCd?.trim()) {
alert("먼저 조회를 진행해주세요.");
return;
}
const hasInvalidStatus = rowData.some((row) =>
["0", "1", "4"].includes(String(row.laeSbmsnSttsCd ?? "")),
);
if (hasInvalidStatus) {
alert("미작성이나 미제출, 혹은 보완요청(반려) 상태인 항목이 있습니다.");
return;
}
if (!(await confirm("조회된 통계 항목을 승인하시겠습니까?"))) {
return;
}
setIsApproveLoading(true);
try {
const userInfo = await getUserInfo();
const mdfrId = (userInfo.userUnqId ?? "").trim();
if (!mdfrId) {
alert("수정자 ID를 확인할 수 없습니다.");
return;
}
const statsYr = lastSearchedValues.year.trim();
const sewerBzmnCdDefault = lastSearchedValues.bsnmCd.trim();
const approveItems: StatsInputStatusApproveItem[] = rowData
.map((row) => {
const statsCd = String(row.statsCd ?? "").trim();
const sewerBzmnCd = String(row.sewerBzmnCd ?? sewerBzmnCdDefault).trim();
return { statsYr, sewerBzmnCd, statsCd, mdfrId };
})
.filter((item) => item.statsCd.length > 0);
if (approveItems.length === 0) {
alert("통계 코드(statsCd)가 있는 행이 없습니다.");
return;
}
const response = await statisticsApi.approveStatsInputStatus({ list: approveItems }, "INPUT");
if (response.resultCode.toLocaleLowerCase() === "success") {
if (searchParams) {
await mutate(["stats-input-status", searchParams]);
}
alert(response.message ?? "승인되었습니다.");
} else {
alert(response.message ?? "승인에 실패했습니다.");
}
} catch {
alert("승인 처리 중 오류가 발생했습니다.");
} finally {
setIsApproveLoading(false);
}
};
const approveFunc = () => {
// 승인 요청 로직
};
const openExceptionHistoryForRow = useCallback(
(row: Record<string, string | number>) => {
const ctx = lastSearchedValues;
if (!ctx?.year || !ctx?.sido || !ctx?.bsnmCd) {
alert("먼저 조회를 진행해주세요.");
return;
}
const statsCd = String(row.statsCd ?? "").trim();
if (!statsCd) {
alert("통계 코드가 없어 예외처리 내역을 조회할 수 없습니다.");
return;
}
setExceptionHistoryContext({
statsYr: ctx.year ?? "",
sewerBzmnCd: ctx.bsnmCd ?? "",
ctpvNm: ctx.sido ?? "",
sggNm: ctx.gugun === "ALL" ? "" : (ctx.gugun ?? ""),
statsCd,
});
setExceptionHistoryOpen(true);
},
[alert, lastSearchedValues],
);
const handleExceptionSubmitHistoryCellClick = useCallback(
({ fieldName, row }: DataCellClickCallbackParams) => {
if (fieldName !== "exceptionSubmitHistory") return;
openExceptionHistoryForRow(row);
},
[openExceptionHistoryForRow],
);
const handleExceptionHistoryRejectSuccess = useCallback(async () => {
if (!searchParams) return;
await mutate(["stats-input-status", searchParams]);
}, [mutate, searchParams]);
const gugunOkOrNotDoList = doList.includes(lastSearchedValues?.bsnmCd || "")
? Boolean(lastSearchedValues?.gugun && lastSearchedValues.gugun !== "ALL")
: true;
const allRowsLaeSbmsnApproved = useMemo(
() =>
rowData.length > 0 &&
rowData.every((row) => String(row.laeSbmsnSttsCd ?? "") === "3"),
[rowData],
);
const showActionToolbarCard =
hasCompletedSearch &&
rowData.length > 0 &&
gugunOkOrNotDoList &&
inputPeriodCurrentRef.current &&
!allRowsLaeSbmsnApproved &&
confirmGroupToolbarAllowed;
return (
<>
<div className="relative">
<SearchForm onSubmit={handleSearch} onReset={handleReset} />
<div className="card !py-0">
<div className="m-[0.6rem] flex items-center justify-end gap-[0.6rem]">
<RejectButton rejectFunc={rejectFunc} />
<ApproveButton approveFunc={approveFunc} />
{showActionToolbarCard && (
<div className="card !py-0">
<div className="m-[0.6rem] flex items-center justify-end gap-[0.6rem]">
<ApproveButton approveFunc={approveFunc} />
</div>
</div>
</div>
{isLoading && (
)}
{(isLoading || isApproveLoading) && (
<div className="[data-theme='black']:bg-transparent absolute inset-0 z-50 flex items-center justify-center rounded-lg p-10">
<div className="relative size-[5rem]">
<LoadingWithAnimation color="primary" />
@@ -114,9 +293,37 @@ const StatsInputStatusInp = () => {
rowData={rowData}
setRowData={setRowData}
isExcelImport={false}
showHeaderCheckbox={true}
showHeaderCheckbox={false}
getGridRef={gridRef}
onDataCellClick={handleExceptionSubmitHistoryCellClick}
cellStyleCallback={({ row, fieldName }) => {
if (fieldName !== "laeSbmsnSttsNm") {
return undefined;
}
const statusCd = String(row.laeSbmsnSttsCd ?? "");
if (statusCd === "0" || statusCd === "1") {
return "rg-cell-stats-status-pending";
}
if (statusCd === "2") {
return "rg-cell-stats-status-submitted";
}
if (statusCd === "3") {
return "rg-cell-stats-status-approved";
}
if (statusCd === "4") {
return "rg-cell-stats-status-rejected";
}
return undefined;
}}
/>
</div>
<ExceptionProcessingHistoryDialog
open={exceptionHistoryOpen}
onOpenChange={setExceptionHistoryOpen}
queryContext={exceptionHistoryContext}
onRejectSuccess={handleExceptionHistoryRejectSuccess}
/>
</>
);
};
@@ -8,6 +8,7 @@ import useSWR from "swr";
import { useAlertStore } from "@/shared/lib";
import { CodeSelectWithAll } from "@/entities/code";
import { statisticsApi } from "@/entities/statistics/api/statistics-service";
// import type { StatisticsLogResponse } from "@/entities/statistics/model/statistics-interface";
import type {
ApiResponse,
SearchCondition,
@@ -129,7 +130,7 @@ const LogHistory = () => {
const selectedBsnm =
value === "ALL"
? (sidoList?.data.sidoList.find((v) => v.sido === sido)
?.bsnmCd ?? "")
?.bsnmCd ?? "")
: (listForBsnm.find((v) => v.gugun === value)?.bsnmCd ?? "");
setGugun(value);
setBsnmCd(selectedBsnm);
@@ -150,8 +151,8 @@ const LogHistory = () => {
...(bsnmCd && { sewerBzmnCd: bsnmCd }),
...(statsDataChgComCd &&
statsDataChgComCd !== "ALL" && {
statsDataChgComCd,
}),
statsDataChgComCd,
}),
};
try {
@@ -160,15 +161,19 @@ const LogHistory = () => {
const response = await statisticsApi.getLogList(param);
if (response.status === "success") {
const lostList = response.data?.statsLogList ?? [];
const logListRaw = response.data?.statsLogList ?? [];
const logList = logListRaw.map((l) => ({
...l,
sggNm: String(l.sggNm ?? "").trim() || "-",
}));
setRowData(toGridRows(lostList));
setRowData(toGridRows(logList));
setTotalPages(response.data?.pageInfo?.totalPages ?? 0);
setPageIndex(Math.max(0, page1Based - 1));
} else {
alert(
response.message ||
"데이터 조회 중 오류가 발생했습니다.",
"데이터 조회 중 오류가 발생했습니다.",
);
}
} catch {
@@ -331,7 +336,7 @@ const LogHistory = () => {
columnInfo={LogHistoryColumn}
isExcelImport={false}
rowData={rowData}
setRowData={() => {}}
setRowData={() => { }}
onRowClick={handleRowClick}
/>
<LogHistoryDetailDialog
@@ -1,4 +1,4 @@
import { useRef, useState } from "react";
import { useCallback, useRef, useState } from "react";
import { useAlertStore } from "@/shared/lib";
import { statisticsApi } from "@/entities/statistics/api/statistics-service";
@@ -15,10 +15,11 @@ import {
import DataGrid from "@/components/module/DataGrid";
import SearchForm from "@/components/module/SearchForm";
import type { ColumnConfig } from "@/components/type/DataGrid.type";
import type { ColumnConfig, DataCellClickCallbackParams } from "@/components/type/DataGrid.type";
import type { SearchFormValues } from "@/components/type/SearchForm.type";
import { toGridRows } from "@/components/util/grid/gridFunction";
/** API `YYYYMMDD` 등 → Date */
function seTypeToDisplayLabel(seType: StatsInputPeriodItem["seType"]): string {
if (seType === "INPT") return "입력";
if (seType === "VRFC") return "검증";
@@ -32,8 +33,7 @@ function rowSeTypeToCode(row: Record<string, string | number>): "INPT" | "VRFC"
return "INPT";
}
/** API `YYYYMMDD` 등 → Date */
function parseYmdToDate(ymd: unknown): Date | undefined {
function parseYmdToDate(ymd: string): Date | undefined {
const s = String(ymd ?? "").replace(/\D/g, "");
if (s.length !== 8) return undefined;
const y = Number(s.slice(0, 4));
@@ -47,6 +47,14 @@ function parseYmdToDate(ymd: unknown): Date | undefined {
return dt;
}
function formatYmdForDisplay(ymd: string): string {
const s = String(ymd ?? "").replace(/\D/g, "");
if (s.length !== 8) return String(ymd ?? "");
return `${s.slice(0, 4)}-${s.slice(4, 6)}-${s.slice(6, 8)}`;
}
const PERIOD_DETAIL_FIELD = "detailInfo";
const StatisticsInputManagement = () => {
const { setMessage: alert } = useAlertStore((state) => state);
const [isLoading, setIsLoading] = useState(false);
@@ -71,15 +79,18 @@ const StatisticsInputManagement = () => {
setIsLoading(true);
const params: StatsInputPeriodListRequest = {
statsYr: values.year,
...(values.clsfCd && values.clsfCd !== "ALL"
? { seType: values.clsfCd }
: {}),
};
if (values.clsfCd && values.clsfCd !== "ALL") {
params.seType = values.clsfCd;
}
const res = await statisticsApi.getStatsInputPeriodList(params);
if (res.status === "success" && Array.isArray(res.data)) {
const rowsForGrid = res.data.map((row) => ({
...row,
seType: seTypeToDisplayLabel(row.seType),
inptBgngYmd: formatYmdForDisplay(row.inptBgngYmd),
inptEndYmd: formatYmdForDisplay(row.inptEndYmd),
[PERIOD_DETAIL_FIELD]: "상세정보",
}));
setRowData(toGridRows(rowsForGrid));
} else {
@@ -111,22 +122,59 @@ const StatisticsInputManagement = () => {
setPeriodDialogOpen(true);
};
const handleRowClick = (row: Record<string, string | number>) => {
const seCdRaw = row.seCd;
const seCdNum =
typeof seCdRaw === "number"
? seCdRaw
: parseInt(String(seCdRaw ?? ""), 10);
setPeriodDialogMode("edit");
setPeriodDialogInitial({
statsYr: String(row.statsYr ?? ""),
seType: rowSeTypeToCode(row),
seCd: Number.isFinite(seCdNum) ? seCdNum : 0,
inptBgngYmd: parseYmdToDate(row.inptBgngYmd),
inptEndYmd: parseYmdToDate(row.inptEndYmd),
});
setPeriodDialogOpen(true);
};
const openPeriodDetailForRow = useCallback(
async (row: Record<string, string | number>) => {
const seCdRaw = row.seCd;
const seCdNum =
typeof seCdRaw === "number"
? seCdRaw
: parseInt(String(seCdRaw ?? ""), 10);
const seCd = Number.isFinite(seCdNum) ? seCdNum : 0;
const statsYr = String(row.statsYr ?? "");
const seType = rowSeTypeToCode(row);
try {
setIsLoading(true);
const res = await statisticsApi.getStatsInputPeriodDetail({
statsYr,
seType,
seCd,
});
if (res.status !== "success" || !res.data) {
alert(res.message || "상세 정보를 조회하지 못했습니다.");
return;
}
const detail = res.data;
setPeriodDialogMode("edit");
setPeriodDialogInitial({
statsYr: String(detail.statsYr ?? statsYr),
seType: detail.seType === "VRFC" ? "VRFC" : "INPT",
seCd:
typeof detail.seCd === "number" && Number.isFinite(detail.seCd)
? detail.seCd
: seCd,
inptBgngYmd: parseYmdToDate(detail.inptBgngYmd),
inptEndYmd: parseYmdToDate(detail.inptEndYmd),
});
setPeriodDialogOpen(true);
} catch {
alert("상세 정보 조회 중 오류가 발생했습니다.");
} finally {
setIsLoading(false);
}
},
[alert],
);
const handlePeriodDetailCellClick = useCallback(
({ fieldName, row }: DataCellClickCallbackParams) => {
if (fieldName !== PERIOD_DETAIL_FIELD) return;
void openPeriodDetailForRow(row);
},
[openPeriodDetailForRow],
);
const columns: ColumnConfig[] = [
{
@@ -159,6 +207,14 @@ const StatisticsInputManagement = () => {
header: { text: "종료일" },
editable: false,
},
{
name: PERIOD_DETAIL_FIELD,
fieldName: PERIOD_DETAIL_FIELD,
header: { text: "상세정보" },
editable: false,
width: 118,
styleName: "rg-cell-outline-action-btn",
},
];
const columnLayout = [
@@ -167,6 +223,7 @@ const StatisticsInputManagement = () => {
"seCd",
"inptBgngYmd",
"inptEndYmd",
PERIOD_DETAIL_FIELD,
];
return (
@@ -198,7 +255,7 @@ const StatisticsInputManagement = () => {
setRowData={setRowData}
isExcelImport={false}
isExcelExport={false}
onRowClick={handleRowClick}
onDataCellClick={handlePeriodDetailCellClick}
/>
<StatisticsInputPeriodDialog
open={periodDialogOpen}
@@ -140,11 +140,13 @@ export function StatisticsInputPeriodDialog({
const param: StatsInputPeriodSaveRequest = {
statsYr: yr,
seType,
seCd: seCdParsed,
inptBgngYmd: bgng,
inptEndYmd: end,
rgtrId: userInfo.userUnqId,
};
if (!isCreate) {
param.seCd = seCdParsed;
}
const res = await statisticsApi.saveStatsInputPeriod(param);
if (res.status === "success") {
alert(`${isCreate ? '등록 되었습니다.' : '수정 되었습니다.'}`);
@@ -243,7 +245,7 @@ export function StatisticsInputPeriodDialog({
<Select
value={seType ?? undefined}
onValueChange={(v) => setSeType(v as "INPT" | "VRFC")}
disabled={isSubmitting}
disabled={isSubmitting || mode === "edit"}
>
<SelectTrigger className="w-full">
<SelectValue placeholder="선택" />
@@ -3,37 +3,92 @@ import { useState } from "react";
import DataGrid from "@/components/module/DataGrid";
import SearchForm from "@/components/module/SearchForm";
import type { ColumnConfig } from "@/components/type/DataGrid.type";
import type { SearchFormValues } from "@/components/type/SearchForm.type";
import { toGridRows } from "@/components/util/grid/gridFunction";
import { statisticsApi } from "@/entities/statistics/api/statistics-service";
import { useAlertStore } from "@/shared/lib";
import { LoadingWithAnimation } from "@common/components";
const StatisticsReviewHistory = () => {
const { setMessage: alert } = useAlertStore((state) => state);
const [rowData, setRowData] = useState<Record<string, string | number>[]>([]);
const [isLoading, setIsLoading] = useState(false);
const columns: ColumnConfig[] = [
{ name: 'sido', fieldName: '시도', header: { text: '시도' }, mergeCells: true, editable: false, sortdisable: true },
{ name: 'sgg', fieldName: '시군구', header: { text: '시군구' }, mergeCells: true, editable: false, sortdisable: true },
{ name: '구분', fieldName: '구분', header: { text: '구분' }, editable: false, sortdisable: true },
{ name: '광역자치단체', fieldName: '광역자치단체', header: { text: '광역자치단체' }, editable: false, sortdisable: true },
{ name: '공단', fieldName: '공단', header: { text: '공단' }, editable: false, sortdisable: true },
{ name: '처리내역', fieldName: '처리내역', header: { text: '처리내역' }, editable: false, sortdisable: true },
{ name: "statsYr", fieldName: "statsYr", header: { text: "연도" }, editable: false },
{ name: "ctpvNm", fieldName: "ctpvNm", header: { text: "시도" }, editable: false },
{ name: "sggNm", fieldName: "sggNm", header: { text: "시군구" }, editable: false },
{ name: "sewerBzmnCd", fieldName: "sewerBzmnCd", header: { text: "하수사업자코드" }, editable: false },
{ name: "statsCd", fieldName: "statsCd", header: { text: "통계코드" }, editable: false },
{ name: "menuNm", fieldName: "menuNm", header: { text: "통계항목" }, editable: false },
{ name: "chckerOpinionSm", fieldName: "chckerOpinionSm", header: { text: "검토자의견" }, editable: false },
{ name: "wrterOpinionSm", fieldName: "wrterOpinionSm", header: { text: "처리내역" }, editable: false },
];
const columnLayout = [
'sido',
'sgg',
'구분',
{
name: 'groupA',
hideChildHeaders: false,
header: { text: '검토내역' },
items: ['광역자치단체', '공단', '처리내역'],
},
"statsYr",
"ctpvNm",
"sggNm",
"sewerBzmnCd",
"statsCd",
"menuNm",
"chckerOpinionSm",
"wrterOpinionSm",
];
const handleSearch = async (values: SearchFormValues) => {
if (!values.year?.trim()) {
alert("년도를 선택해주세요.");
return;
}
try {
setIsLoading(true);
const res = await statisticsApi.getStatiscicsManagementReviewHistory({
statsYr: values.year.trim(),
...(values.bsnmCd?.trim() ? { sewerBzmnCd: values.bsnmCd.trim() } : {}),
...(values.sido && values.sido !== "ALL" ? { ctpvNm: values.sido } : {}),
});
if (res.status === "success" && Array.isArray(res.data)) {
setRowData(toGridRows(res.data));
} else {
setRowData([]);
alert(res.message || "조회된 데이터가 없습니다.");
}
} catch {
setRowData([]);
alert("데이터 조회 중 오류가 발생했습니다.");
} finally {
setIsLoading(false);
}
};
const handleReset = () => {
setRowData([]);
};
return (
<>
<SearchForm />
<DataGrid columnInfo={{ columns, columnLayout }} rowStartNum={2} fixedColumns={3} rowData={rowData} setRowData={setRowData} isExcelImport={false} />
</>
<div className="relative">
<SearchForm onSubmit={handleSearch} onReset={handleReset} />
{isLoading && (
<div className="absolute inset-0 z-50 [data-theme='black']:bg-transparent rounded-lg p-10 flex justify-center items-center">
<div className="relative size-[5rem]">
<LoadingWithAnimation color="primary" />
</div>
</div>
)}
<DataGrid
columnInfo={{ columns, columnLayout }}
rowStartNum={2}
fixedColumns={3}
rowData={rowData}
setRowData={setRowData}
isExcelImport={false}
enableSort={false}
/>
</div>
);
};
export default StatisticsReviewHistory;
export default StatisticsReviewHistory;
@@ -1,47 +1,281 @@
import { useState } from "react";
import { useMemo, useState } from "react";
import { DataGrid } from "@rsone/components";
import { LoadingWithAnimation } from "@common/components";
import { useAlertStore, useUserStore } from "@/shared/lib";
import { statisticsApi } from "@/entities/statistics/api/statistics-service";
import { statisticsManagementApi } from "@/entities/statistics/api/statistics-management-service";
import type { PublicSewageFacilityReviewItem } from "@/entities/statistics/model/statistics-public-sewage-facility-review-interface";
import type { StatsStatusInfo } from "@/entities/statistics/model/statistics-sewage-pipe-review-interface";
import type {
ReviewOpinionSaveRequest,
ReviewerInfoRequest,
} from "@/entities/statistics/model/statistics-interface";
import DataGrid from "@/components/module/DataGrid";
import SearchForm from "@/components/module/SearchForm";
import SaveButton from "@/components/button/SaveButton";
import ApproveButton from "@/components/button/ApproveButton";
import SubmitButton from "@/components/button/SumitButton";
import ReviewOpinionButton from "@/components/button/ReviewOpinionButton";
import ReturnButton from "@/components/button/ReturnButton";
import { ReturnReasonDialog } from "@/components/statistics/review/ReturnReasonDialog";
import { ReviewSubmitDialog } from "@/components/statistics/review/ReviewSubmitDialog";
import {
ReviewOpinionDialog,
type ReviewOpinionDialogContext,
} from "@/components/statistics/review/ReviewOpinionDialog";
import type { SearchFormValues } from "@/components/type/SearchForm.type";
import { toGridRows } from "@/components/util/grid/gridFunction";
import { PublicSewageFacilityInfoList } from "@/components/util/column/review/PublicSewageFacilityRvwColumn";
// 하수도 통계 관리 06.공공하수처리시설
const PublicSewageFacilityRvw = () => {
const { setMessage: alert } = useAlertStore((state) => state);
const userInfo = useUserStore((state) => state.userInfo);
const [rowData, setRowData] = useState<Record<string, string | number>[]>([]);
const [isLoading, setIsLoading] = useState<boolean>(false);
const [statsStatusInfo, setStatsStatusInfo] = useState<StatsStatusInfo>({});
const [reviewOpinionOpen, setReviewOpinionOpen] = useState(false);
const [returnDialogOpen, setReturnDialogOpen] = useState(false);
const [submitDialogOpen, setSubmitDialogOpen] = useState(false);
const [reviewOpinionContext, setReviewOpinionContext] =
useState<ReviewOpinionDialogContext | null>(null);
const [latestReviewOpinion, setLatestReviewOpinion] = useState("");
const [latestProcessHistory, setLatestProcessHistory] = useState("");
const [lastSearchValues, setLastSearchValues] = useState<SearchFormValues | null>(null);
const approveFunc = () => {
console.log('approveFunc');
const columnInfo = useMemo(() => {
const { columns, columnLayout, fixedColumns, rowStartNum } = PublicSewageFacilityInfoList;
return {
columnLayout,
fixedColumns,
rowStartNum,
columns: columns.map((col) => ({
...col,
editable: false as const,
})),
};
}, []);
const { showReviewOpinionButton, showReturnButton, showSubmitButton } = useMemo(() => {
const submit = statsStatusInfo.submitStatusCd != null ? String(statsStatusInfo.submitStatusCd) : "";
const prvn = statsStatusInfo.prvnStatusCd != null ? String(statsStatusInfo.prvnStatusCd) : "";
const submitIs3 = submit === "3";
const reviewSubmitVisible = submitIs3 && (prvn === "0" || prvn === "1");
return {
showReviewOpinionButton: reviewSubmitVisible,
showSubmitButton: reviewSubmitVisible,
showReturnButton: submitIs3 && prvn !== "3",
};
}, [statsStatusInfo]);
const showActionBar =
showReviewOpinionButton || showReturnButton || showSubmitButton;
const submitDialogContext = useMemo<ReviewerInfoRequest | null>(() => {
if (reviewOpinionContext == null) {
return null;
}
const instSeCd = userInfo?.instSeCd ?? "";
if (!instSeCd) {
return null;
}
return {
statsYr: reviewOpinionContext.statsYr,
sewerBzmnCd: reviewOpinionContext.sewerBzmnCd,
statsCd: reviewOpinionContext.statsCd,
instSeCd,
};
}, [reviewOpinionContext, userInfo?.instSeCd]);
const handleReset = () => {
setRowData([]);
setStatsStatusInfo({});
setReviewOpinionContext(null);
setLatestReviewOpinion("");
setLatestProcessHistory("");
setLastSearchValues(null);
setSubmitDialogOpen(false);
};
const saveFunc = () => {
console.log('submit');
const handleSearch = async (values: SearchFormValues) => {
if (!values.sido || values.sido === "ALL") {
alert("시도를 선택해주세요.");
return;
}
setLastSearchValues(values);
try {
setIsLoading(true);
const response = await statisticsManagementApi.getPublicSewageFacilityReviewList({
ctpvNm: values.sido,
sggNm: values.gugun === "ALL" ? "" : values.gugun,
sewerBzmnCd: values.bsnmCd,
statsYr: values.year,
swtrmtFcltNm: values.fctName,
});
if (response.status === "success" && response.data) {
const rawList = response.data.publicSewageList;
const convertedData: PublicSewageFacilityReviewItem[] = rawList.map((item) => ({
...item,
impdFcltPstnCn: item.fcltPstnCn ?? "",
prxyBzentyNmCn: item.prxyBzentyNmCn ?? "",
prxyCtrtCn: item.prxyCtrtCn ?? "",
intgPrcsFcltCn: item.intgPrcsFcltCn ?? "",
rmrk: item.rmrk ?? "",
})) as PublicSewageFacilityReviewItem[];
setStatsStatusInfo(response.data.statsStatusInfo);
setReviewOpinionContext({
statsYr: values.year ?? "",
sewerBzmnCd: values.bsnmCd ?? "",
statsCd: response.data.statsStatusInfo.statsCd ?? "",
});
setLatestReviewOpinion(
response.data.statsStatusInfo.splmntOpnnCn ?? "",
);
setLatestProcessHistory(
response.data.statsStatusInfo.wrterOpnnCn ?? "",
);
setRowData(toGridRows(convertedData as unknown as object[]));
} else {
setRowData([]);
setStatsStatusInfo({});
setReviewOpinionContext(null);
setLatestReviewOpinion("");
setLatestProcessHistory("");
alert(response.message || "데이터가 없습니다.");
}
} catch (error) {
console.error("공공하수처리시설 조회 중 에러 발생:", error);
alert("데이터 조회 중 오류가 발생했습니다.");
} finally {
setIsLoading(false);
}
};
const reviewOpinionFunc = () => {
console.log('reviewOpinionFunc');
const submitFunc = () => {
if (submitDialogContext == null) {
alert("조회 후 제출할 수 있습니다.");
return;
}
setSubmitDialogOpen(true);
};
const returnFunc = () => {
console.log('returnFunc');
const reviewOpinionFunc = async () => {
if (reviewOpinionContext == null) {
alert("조회 후 검토의견을 작성할 수 있습니다.");
return;
}
try {
const detailRes = await statisticsApi.getReviewOpinionDetail({
statsYr: reviewOpinionContext.statsYr,
sewerBzmnCd: reviewOpinionContext.sewerBzmnCd,
statsCd: reviewOpinionContext.statsCd,
});
if (detailRes.status !== "success") {
alert(detailRes.message || "검토의견 조회에 실패했습니다.");
return;
}
const row = detailRes.data?.[0];
setLatestReviewOpinion(row?.splmntOpnnCn ?? "");
setLatestProcessHistory(row?.wrterOpnnCn ?? "");
setReviewOpinionOpen(true);
} catch {
alert("검토의견 조회 중 오류가 발생했습니다.");
}
};
const saveReviewOpinion = async (param: ReviewOpinionSaveRequest) => {
try {
const response = await statisticsApi.saveReviewOpinion(param);
if (response.status !== "success") {
alert(response.message || "저장에 실패했습니다.");
return;
}
alert(response.message || "저장되었습니다.");
setStatsStatusInfo((prev) => ({
...prev,
splmntOpnnCn: param.splmntOpnnCn,
wrterOpnnCn: param.wrterOpnnCn,
}));
setLatestReviewOpinion(param.splmntOpnnCn);
setLatestProcessHistory(param.wrterOpnnCn);
} catch {
alert("저장 중 오류가 발생했습니다.");
}
};
const returnFunc = () => setReturnDialogOpen(true);
const handleSaveReturnReason = async () => {
if (lastSearchValues != null) {
await handleSearch(lastSearchValues);
}
};
return (
<>
<SearchForm fctName={true} />
<div className="card !py-0">
<div className="flex justify-end items-center gap-[0.6rem] m-[0.6rem]">
<ReviewOpinionButton reviewOpinionFunc={reviewOpinionFunc} />
<ApproveButton approveFunc={approveFunc} />
<ReturnButton returnFunc={returnFunc} />
<SaveButton saveFunc={saveFunc} />
<div className="relative">
<SearchForm fctName={true} onSubmit={handleSearch} onReset={handleReset} />
{showActionBar && (
<div className="card !py-0">
<div className="flex justify-end items-center gap-[0.6rem] m-[0.6rem]">
{showReviewOpinionButton && (
<ReviewOpinionButton reviewOpinionFunc={reviewOpinionFunc} />
)}
{showReturnButton && <ReturnButton returnFunc={returnFunc} />}
{showSubmitButton && <SubmitButton submitFunc={submitFunc} />}
</div>
</div>
</div>
<DataGrid columnInfo={PublicSewageFacilityInfoList} isExcelImport={false} rowStartNum={3} fixedColumns={3} rowData={rowData} setRowData={setRowData} />
</>
)}
{isLoading && (
<div className="absolute inset-0 z-50 [data-theme='black']:bg-transparent rounded-lg p-10 flex justify-center items-center">
<div className="relative size-[5rem]">
<LoadingWithAnimation color="primary" />
</div>
</div>
)}
<DataGrid
columnInfo={columnInfo}
isExcelImport={false}
fixedColumns={columnInfo.fixedColumns}
rowStartNum={columnInfo.rowStartNum}
rowData={rowData}
setRowData={setRowData}
/>
<ReviewOpinionDialog
open={reviewOpinionOpen}
onOpenChange={setReviewOpinionOpen}
context={reviewOpinionContext}
initialReviewOpinion={latestReviewOpinion}
initialProcessHistory={latestProcessHistory}
onSave={saveReviewOpinion}
/>
<ReviewSubmitDialog
open={submitDialogOpen}
onOpenChange={setSubmitDialogOpen}
context={submitDialogContext}
onSubmitted={async () => {
if (lastSearchValues != null) {
await handleSearch(lastSearchValues);
}
}}
/>
<ReturnReasonDialog
open={returnDialogOpen}
onOpenChange={setReturnDialogOpen}
context={reviewOpinionContext}
onSave={handleSaveReturnReason}
/>
</div>
);
};
@@ -1,6 +1,8 @@
"use client";
import { useEffect, useState } from "react";
import { getUserInfo } from "../../../../../../packages/common/components/src/api";
import { useConfirm } from "@frontend-opensource/use-react-hooks";
import {
Button,
@@ -14,25 +16,47 @@ import {
Textarea,
} from "@common/components";
import type { ReviewReturnSaveRequest } from "@/entities/statistics/model/statistics-interface";
import { statisticsApi } from "@/entities/statistics/api/statistics-service";
import { useAlertStore } from "@/shared/lib";
export type ReturnReasonDialogContext = Pick<
ReviewReturnSaveRequest,
"statsYr" | "sewerBzmnCd" | "statsCd"
>;
export interface ReturnReasonDialogProps {
open: boolean;
onOpenChange: (open: boolean) => void;
onSave?: (reason: string) => void | Promise<void>;
context: ReturnReasonDialogContext | null;
onSave?: () => void | Promise<void>;
}
export function ReturnReasonDialog({
open,
onOpenChange,
context,
onSave,
}: ReturnReasonDialogProps) {
const { setMessage: alert } = useAlertStore((state) => state);
const { confirm } = useConfirm();
const [reason, setReason] = useState("");
const [isSaving, setIsSaving] = useState(false);
const [reasonError, setReasonError] = useState(false);
useEffect(() => {
if (!open) return;
const resetForm = () => {
setReason("");
setReasonError(false);
};
const handleDialogOpenChange = (nextOpen: boolean) => {
if (!nextOpen) resetForm();
onOpenChange(nextOpen);
};
useEffect(() => {
if (!open) return;
resetForm();
}, [open]);
const handleSave = async () => {
@@ -41,24 +65,65 @@ export function ReturnReasonDialog({
return;
}
if (
context == null ||
context.statsYr === "" ||
context.sewerBzmnCd === "" ||
context.statsCd === ""
) {
alert("시도·시군구·년도로 조회한 뒤 반려 처리할 수 있습니다.");
return;
}
if (!(await confirm("반려 처리 하시겠습니까?"))) {
return;
}
try {
setIsSaving(true);
await onSave?.(reason);
let userInfo: Awaited<ReturnType<typeof getUserInfo>>;
try {
userInfo = await getUserInfo();
} catch {
alert("사용자 정보를 불러오지 못했습니다. 다시 시도해주세요.");
return;
}
const param: ReviewReturnSaveRequest = {
type: "REVIEW",
statsYr: context.statsYr,
sewerBzmnCd: context.sewerBzmnCd,
statsCd: context.statsCd,
rjctOpnnCn: reason,
mdfrId: userInfo.userUnqId,
};
const response = await statisticsApi.saveReviewReturn(param);
if (response.resultCode.toLowerCase() !== "success") {
alert(response.message || "반려 처리 중 오류가 발생했습니다.");
return;
}
alert("반려 처리 되었습니다.");
await onSave?.();
resetForm();
onOpenChange(false);
} catch {
alert("반려 처리 중 오류가 발생했습니다.");
} finally {
setIsSaving(false);
}
};
return (
<Dialog open={open} onOpenChange={onOpenChange}>
<Dialog open={open} onOpenChange={handleDialogOpenChange}>
<DialogContent className="max-w-[56rem]" aria-describedby={undefined}>
<DialogHeader>
<DialogTitle>반려 사유</DialogTitle>
<DialogTitle>반려</DialogTitle>
</DialogHeader>
<div className="flex flex-col gap-4 py-2">
<div className="flex flex-col gap-2">
<Label className="text-label">반려 사유</Label>
<Label className="text-label mb-2">반려 사유</Label>
<Textarea
value={reason}
onChange={(e) => {
@@ -86,11 +151,11 @@ export function ReturnReasonDialog({
</DialogClose>
<Button
type="button"
color="primary"
color="red"
disabled={isSaving}
onClick={() => void handleSave()}
>
저장
반려 제출
</Button>
</DialogFooter>
</DialogContent>
@@ -115,9 +115,9 @@ export function ReviewOpinionDialog({
<DialogHeader>
<DialogTitle>검토의견작성</DialogTitle>
</DialogHeader>
<div className="flex flex-col gap-4 py-2">
<div className="flex flex-col gap-8 py-2">
<div className="flex flex-col gap-2">
<Label className="text-label">검토의견</Label>
<Label className="text-label mb-2">검토의견</Label>
<Textarea
value={reviewOpinion}
onChange={(e) => {
@@ -137,7 +137,7 @@ export function ReviewOpinionDialog({
)}
</div>
<div className="flex flex-col gap-2">
<Label className="text-label">처리내역</Label>
<Label className="text-label mb-2">처리내역</Label>
<Textarea
value={processHistory}
onChange={(e) => setProcessHistory(e.target.value)}
@@ -0,0 +1,196 @@
"use client";
import { useEffect, useState } from "react";
import { getUserInfo } from "../../../../../../packages/common/components/src/api";
import {
Button,
Dialog,
DialogContent,
DialogFooter,
DialogHeader,
DialogTitle,
Input,
Label,
LoadingWithAnimation,
Textarea,
} from "@common/components";
import { statisticsApi } from "@/entities/statistics/api/statistics-service";
import type {
ReviewerInfoRequest,
ReviewerInfoResponse,
ReviewSubmitRequest,
} from "@/entities/statistics/model/statistics-interface";
import { useAlertStore } from "@/shared/lib";
export interface ReviewSubmitDialogProps {
open: boolean;
onOpenChange: (open: boolean) => void;
context: ReviewerInfoRequest | null;
onSubmitted?: () => void | Promise<void>;
}
export function ReviewSubmitDialog({
open,
onOpenChange,
context,
onSubmitted,
}: ReviewSubmitDialogProps) {
const { setMessage: alert } = useAlertStore((state) => state);
const [reviewerInfo, setReviewerInfo] =
useState<ReviewerInfoResponse["data"] | null>(null);
const [isLoading, setIsLoading] = useState(false);
const [isSubmitting, setIsSubmitting] = useState(false);
useEffect(() => {
if (!open) {
setReviewerInfo(null);
setIsLoading(false);
return;
}
if (context == null) {
setReviewerInfo(null);
return;
}
let cancelled = false;
const loadReviewerInfo = async () => {
try {
setIsLoading(true);
const response = await statisticsApi.getReviewerInfo(context);
if (cancelled) return;
if (response.status !== "success" || !response.data) {
alert("제출 정보를 불러오지 못했습니다.");
setReviewerInfo(null);
return;
}
setReviewerInfo(response.data);
} catch {
if (!cancelled) {
alert("제출 정보를 불러오지 못했습니다.");
setReviewerInfo(null);
}
} finally {
if (!cancelled) {
setIsLoading(false);
}
}
};
void loadReviewerInfo();
return () => {
cancelled = true;
};
}, [open, context, alert]);
const handleConfirm = async () => {
if (context == null || reviewerInfo == null) {
alert("제출 정보를 확인한 뒤 다시 시도해주세요.");
return;
}
try {
setIsSubmitting(true);
const userInfo = await getUserInfo();
const submitParam: ReviewSubmitRequest = {
statsYr: context.statsYr,
sewerBzmnCd: context.sewerBzmnCd,
statsCd: context.statsCd,
mdfrId: userInfo.userUnqId,
splmntOpnnCn: reviewerInfo.splmntOpnnCn ?? "",
wrterOpnnCn: reviewerInfo.wrterOpnnCn ?? "",
};
const response = await statisticsApi.submitReview(submitParam);
if (response.status !== "success") {
alert(response.message || "제출에 실패했습니다.");
return;
}
alert(response.message || "제출되었습니다.");
onOpenChange(false);
await onSubmitted?.();
} catch {
alert("제출 중 오류가 발생했습니다.");
} finally {
setIsSubmitting(false);
}
};
return (
<Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent className="max-w-[64rem]" aria-describedby={undefined}>
<DialogHeader>
<DialogTitle>제출 정보</DialogTitle>
</DialogHeader>
{(isLoading || isSubmitting) && (
<div className="absolute inset-0 z-50 flex items-center justify-center rounded-lg p-10 [data-theme='black']:bg-transparent">
<div className="relative size-[5rem]">
<LoadingWithAnimation color="primary" />
</div>
</div>
)}
<div className="grid grid-cols-2 gap-4 py-2">
<div className="flex flex-col gap-2">
<Label className="text-label">검토자명</Label>
<Input value={reviewerInfo?.userNm ?? "-"} readOnly />
</div>
<div className="flex flex-col gap-2">
<Label className="text-label">연락처</Label>
<Input value={reviewerInfo?.telOffrm ?? "-"} readOnly />
</div>
<div className="flex flex-col gap-2">
<Label className="text-label">부서</Label>
<Input value={reviewerInfo?.dpmNm ?? "-"} readOnly />
</div>
<div className="flex flex-col gap-2">
<Label className="text-label">직책</Label>
<Input value={reviewerInfo?.cps ?? "-"} readOnly />
</div>
<div className="col-span-2 flex flex-col gap-2">
<Label className="text-label">검토의견</Label>
<Textarea
value={reviewerInfo?.splmntOpnnCn ?? "-"}
readOnly
className="min-h-[10rem] p-2 resize-none !text-xl"
/>
</div>
<div className="col-span-2 flex flex-col gap-2">
<Label className="text-label">처리내역</Label>
<Textarea
value={reviewerInfo?.wrterOpnnCn ?? "-"}
readOnly
className="min-h-[10rem] p-2 resize-none !text-xl"
/>
</div>
</div>
<DialogFooter>
<Button
type="button"
color="primary"
onClick={() => void handleConfirm()}
disabled={isLoading || isSubmitting || reviewerInfo == null}
>
제출
</Button>
<Button
type="button"
color="gray"
onClick={() => onOpenChange(false)}
disabled={isLoading || isSubmitting}
>
취소
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
);
}
@@ -1,11 +1,16 @@
import { useMemo, useRef, useState } from "react";
import { useAlertStore } from "@/shared/lib";
import { useAlertStore, useUserStore } from "@/shared/lib";
import { statisticsManagementApi } from "@/entities/statistics/api/statistics-management-service";
import { statisticsApi } from "@/entities/statistics/api/statistics-service";
import type {
SewagePipeReviewRequestParams,
StatsStatusInfo,
} from "@/entities/statistics/model/statistics-sewage-pipe-review-interface";
import type {
ReviewerInfoRequest,
ReviewOpinionSaveRequest,
} from "@/entities/statistics/model/statistics-interface";
import { LoadingWithAnimation, Tabs, TabsContent, TabsList, TabsTrigger } from "@common/components";
import SubmitButton from "@/components/button/SumitButton";
@@ -13,13 +18,58 @@ import ReturnButton from "@/components/button/ReturnButton";
import ReviewOpinionButton from "@/components/button/ReviewOpinionButton";
import DataGrid from "@/components/module/DataGrid";
import SearchForm from "@/components/module/SearchForm";
import type { ColumnInfo } from "@/components/type/DataGrid.type";
import { ReturnReasonDialog } from "@/components/statistics/review/ReturnReasonDialog";
import { ReviewSubmitDialog } from "@/components/statistics/review/ReviewSubmitDialog";
import {
ReviewOpinionDialog,
type ReviewOpinionDialogContext,
} from "@/components/statistics/review/ReviewOpinionDialog";
import type {
ColumnConfig,
ColumnInfo,
ColumnInfoList,
ColumnLayoutItem,
} from "@/components/type/DataGrid.type";
import type { SearchFormValues } from "@/components/type/SearchForm.type";
import { SewagePipeColumnInfoListRvw } from "@/components/util/column/review/SewagePipeRvwColumn";
import { toGridRows } from "@/components/util/grid/gridFunction";
const firstTabKey = SewagePipeColumnInfoListRvw[0]?.tabKey ?? "ST00020001";
function cloneColumnLayoutItem(item: ColumnLayoutItem): ColumnLayoutItem {
if (typeof item === "string") return item;
if ("column" in item) {
return {
column: item.column,
...(item.footerUserSpans && {
footerUserSpans: item.footerUserSpans.map((span) => ({ ...span })),
}),
};
}
return {
...item,
header: { ...item.header },
items: item.items.map((child) =>
typeof child === "string" ? child : cloneColumnLayoutItem(child),
),
...(item.footerUserSpans && {
footerUserSpans: item.footerUserSpans.map((span) => ({ ...span })),
}),
};
}
function cloneColumnConfig(col: ColumnConfig): ColumnConfig {
return {
...col,
header: { ...col.header },
...(col.footer && {
footer: Array.isArray(col.footer)
? col.footer.map((item) => ({ ...item }))
: { ...col.footer },
}),
};
}
async function fetchSewagePipeReviewTab(
tabKey: string,
searchParam: SewagePipeReviewRequestParams,
@@ -36,6 +86,9 @@ async function fetchSewagePipeReviewTab(
if (tabKey === "ST00020004") {
return statisticsManagementApi.getSewagePipeDiamList(searchParam);
}
if (tabKey === "ST00020005") {
return await statisticsManagementApi.getSewagePipeManholeList(searchParam);
}
return null;
}
@@ -56,6 +109,8 @@ function extractReviewRowsFromResponseData(
return Array.isArray(d.pipeTypeList) ? (d.pipeTypeList as object[]) : [];
case "ST00020004":
return Array.isArray(d.pipeDiamList) ? (d.pipeDiamList as object[]) : [];
case "ST00020005":
return Array.isArray(d.manholeList) ? (d.manholeList as object[]) : [];
default:
return [];
}
@@ -70,9 +125,14 @@ function extractStatsStatusInfo(data: unknown): StatsStatusInfo | null {
// 하수도 통계 검토 02.하수관로
const SewagePipeRvw = () => {
const { setMessage: alert } = useAlertStore((state) => state);
const userInfo = useUserStore((state) => state.userInfo);
const [rowDataMap, setRowDataMap] = useState<Map<string, Record<string, string | number>[]>>(new Map());
const [isLoading, setIsLoading] = useState<boolean>(false);
const [activeTab, setActiveTab] = useState<string>(firstTabKey);
const [reviewOpinionOpen, setReviewOpinionOpen] = useState(false);
const [returnDialogOpen, setReturnDialogOpen] = useState(false);
const [submitDialogOpen, setSubmitDialogOpen] = useState(false);
const [lastSearchValues, setLastSearchValues] = useState<SearchFormValues | null>(null);
/** 탭별 조회 응답의 statsStatusInfo — 활성 탭에 맞춰 버튼 노출 (SewagePipeInp의 tabStatusInfoMap과 동일 패턴) */
const [tabStatsStatusInfoMap, setTabStatsStatusInfoMap] = useState<
Map<string, StatsStatusInfo>
@@ -81,9 +141,53 @@ const SewagePipeRvw = () => {
const searchParamRef = useRef<SewagePipeReviewRequestParams | null>(null);
const searchDoneRef = useRef(false);
const lazyTabLoggedRef = useRef<Set<string>>(new Set());
const activeStatsStatusInfo = useMemo(
() => tabStatsStatusInfoMap.get(activeTab) ?? {},
[tabStatsStatusInfoMap, activeTab],
);
const columnInfo = useMemo<ColumnInfoList[]>(
() =>
SewagePipeColumnInfoListRvw.map((tab) => ({
...tab,
columns: (tab.columns ?? []).map((col) => cloneColumnConfig(col)),
columnLayout: (tab.columnLayout ?? []).map((item) =>
cloneColumnLayoutItem(item),
),
})),
[],
);
const reviewOpinionContext = useMemo<ReviewOpinionDialogContext | null>(() => {
if (lastSearchValues == null) return null;
return {
statsYr: lastSearchValues.year ?? "",
sewerBzmnCd: lastSearchValues.bsnmCd ?? "",
statsCd: activeStatsStatusInfo.statsCd ?? "",
};
}, [activeStatsStatusInfo.statsCd, lastSearchValues]);
const latestReviewOpinion = activeStatsStatusInfo.splmntOpnnCn ?? "";
const latestProcessHistory = activeStatsStatusInfo.wrterOpnnCn ?? "";
const submitDialogContext = useMemo<ReviewerInfoRequest | null>(() => {
if (lastSearchValues == null) return null;
const statsYr = lastSearchValues.year ?? "";
const sewerBzmnCd = lastSearchValues.bsnmCd ?? "";
const statsCd = activeStatsStatusInfo.statsCd ?? "";
const instSeCd = userInfo?.instSeCd ?? "";
if (!statsYr || !sewerBzmnCd || !statsCd || !instSeCd) {
return null;
}
return {
statsYr,
sewerBzmnCd,
statsCd,
instSeCd,
};
}, [activeStatsStatusInfo.statsCd, lastSearchValues, userInfo?.instSeCd]);
const { showReviewOpinionButton, showReturnButton, showSubmitButton } = useMemo(() => {
const statsStatusInfo = tabStatsStatusInfoMap.get(activeTab) ?? {};
const statsStatusInfo = activeStatsStatusInfo;
const submit =
statsStatusInfo.submitStatusCd != null
? String(statsStatusInfo.submitStatusCd)
@@ -97,7 +201,7 @@ const SewagePipeRvw = () => {
showSubmitButton: reviewSubmitVisible,
showReturnButton: submitIs3 && prvn !== "3",
};
}, [tabStatsStatusInfoMap, activeTab]);
}, [activeStatsStatusInfo]);
const showActionBar =
showReviewOpinionButton || showReturnButton || showSubmitButton;
@@ -106,6 +210,10 @@ const SewagePipeRvw = () => {
setRowDataMap(new Map());
setActiveTab(firstTabKey);
setTabStatsStatusInfoMap(new Map());
setReviewOpinionOpen(false);
setReturnDialogOpen(false);
setSubmitDialogOpen(false);
setLastSearchValues(null);
searchParamRef.current = null;
searchDoneRef.current = false;
lazyTabLoggedRef.current.clear();
@@ -119,6 +227,7 @@ const SewagePipeRvw = () => {
try {
setIsLoading(true);
setLastSearchValues(values);
const searchParam: SewagePipeReviewRequestParams = {
statsYr: values.year,
@@ -204,6 +313,79 @@ const SewagePipeRvw = () => {
}
};
const submitFunc = () => {
if (submitDialogContext == null) {
alert("조회 후 제출할 수 있습니다.");
return;
}
setSubmitDialogOpen(true);
};
const reviewOpinionFunc = async () => {
if (reviewOpinionContext == null) {
alert("조회 후 검토의견을 작성할 수 있습니다.");
return;
}
try {
const detailRes = await statisticsApi.getReviewOpinionDetail({
statsYr: reviewOpinionContext.statsYr,
sewerBzmnCd: reviewOpinionContext.sewerBzmnCd,
statsCd: reviewOpinionContext.statsCd,
});
if (detailRes.status !== "success") {
alert(detailRes.message || "검토의견 조회에 실패했습니다.");
return;
}
const row = detailRes.data?.[0];
setTabStatsStatusInfoMap((prev) => {
const next = new Map(prev);
next.set(activeTab, {
...(next.get(activeTab) ?? {}),
splmntOpnnCn: row?.splmntOpnnCn ?? "",
wrterOpnnCn: row?.wrterOpnnCn ?? "",
});
return next;
});
setReviewOpinionOpen(true);
} catch {
alert("검토의견 조회 중 오류가 발생했습니다.");
}
};
const saveReviewOpinion = async (param: ReviewOpinionSaveRequest) => {
try {
const response = await statisticsApi.saveReviewOpinion(param);
if (response.status !== "success") {
alert(response.message || "저장에 실패했습니다.");
return;
}
alert(response.message || "저장되었습니다.");
setTabStatsStatusInfoMap((prev) => {
const next = new Map(prev);
next.set(activeTab, {
...(next.get(activeTab) ?? {}),
splmntOpnnCn: param.splmntOpnnCn,
wrterOpnnCn: param.wrterOpnnCn,
});
return next;
});
} catch {
alert("저장 중 오류가 발생했습니다.");
}
};
const returnFunc = () => setReturnDialogOpen(true);
const handleSaveReturnReason = async () => {
if (lastSearchValues != null) {
await handleSearch(lastSearchValues);
}
};
return (
<div className="relative">
<SearchForm onSubmit={handleSearch} onReset={handleReset} />
@@ -212,11 +394,11 @@ const SewagePipeRvw = () => {
<div className="card !py-0">
<div className="flex justify-end items-center gap-[0.6rem] m-[0.6rem]">
{showReviewOpinionButton && (
<ReviewOpinionButton reviewOpinionFunc={() => {}} />
<ReviewOpinionButton reviewOpinionFunc={reviewOpinionFunc} />
)}
{showReturnButton && <ReturnButton returnFunc={() => {}} />}
{showReturnButton && <ReturnButton returnFunc={returnFunc} />}
{showSubmitButton && (
<SubmitButton submitFunc={() => {}} />
<SubmitButton submitFunc={() => submitFunc()} />
)}
</div>
</div>
@@ -237,13 +419,13 @@ const SewagePipeRvw = () => {
variant="default"
>
<TabsList>
{SewagePipeColumnInfoListRvw.map((info) => (
{columnInfo.map((info) => (
<TabsTrigger key={info.tabKey} value={info.tabKey}>
{info.tabLabel || info.tabKey}
</TabsTrigger>
))}
</TabsList>
{SewagePipeColumnInfoListRvw.map((info) => {
{columnInfo.map((info) => {
const tabKey = info.tabKey;
if (!info.columns || !info.columnLayout) {
return null;
@@ -283,6 +465,30 @@ const SewagePipeRvw = () => {
})}
</Tabs>
</div>
<ReviewOpinionDialog
open={reviewOpinionOpen}
onOpenChange={setReviewOpinionOpen}
context={reviewOpinionContext}
initialReviewOpinion={latestReviewOpinion}
initialProcessHistory={latestProcessHistory}
onSave={saveReviewOpinion}
/>
<ReviewSubmitDialog
open={submitDialogOpen}
onOpenChange={setSubmitDialogOpen}
context={submitDialogContext}
onSubmitted={async () => {
if (lastSearchValues != null) {
await handleSearch(lastSearchValues);
}
}}
/>
<ReturnReasonDialog
open={returnDialogOpen}
onOpenChange={setReturnDialogOpen}
context={reviewOpinionContext}
onSave={handleSaveReturnReason}
/>
</div>
);
};
@@ -6,13 +6,15 @@ import ReturnButton from "@/components/button/ReturnButton";
import SubmitButton from "@/components/button/SumitButton";
import { LoadingWithAnimation } from "@common/components";
import { ReturnReasonDialog } from "@/components/statistics/review/ReturnReasonDialog";
import { ReviewSubmitDialog } from "@/components/statistics/review/ReviewSubmitDialog";
import { getUserInfo } from "../../../../../../packages/common/components/src/api";
import { useAlertStore } from "@/shared/lib";
import { useConfirm } from "@frontend-opensource/use-react-hooks";
import { useAlertStore, useUserStore } from "@/shared/lib";
import { SeweragePenetrationRateInfoList } from "@/components/util/column/review/SeweragePenetrationRateRvwColumn";
import type { SewerageSupplyRateReviewItem, statsStatusInfo } from "@/entities/statistics/model/statistics-sewerage-penetration-review-interface";
import type { ReviewOpinionSaveRequest } from "@/entities/statistics/model/statistics-interface";
import type {
ReviewOpinionSaveRequest,
ReviewerInfoRequest,
} from "@/entities/statistics/model/statistics-interface";
import { statisticsApi } from "@/entities/statistics/api/statistics-service";
import { statisticsManagementApi } from "@/entities/statistics/api/statistics-management-service";
import type { SearchFormValues } from "@/components/type/SearchForm.type";
@@ -25,17 +27,18 @@ import {
// 하수도 통계 검토 01.하수도 보급률
const SeweragePenetrationRateRvw = () => {
const { setMessage: alert } = useAlertStore((state) => state);
const userInfo = useUserStore((state) => state.userInfo);
const [rowData, setRowData] = useState<Record<string, string | number>[]>([]);
const [isLoading, setIsLoading] = useState<boolean>(false);
const [statsStatusInfo, setStatsStatusInfo] = useState<statsStatusInfo>({});
const [reviewOpinionOpen, setReviewOpinionOpen] = useState(false);
const [returnDialogOpen, setReturnDialogOpen] = useState(false);
const [submitDialogOpen, setSubmitDialogOpen] = useState(false);
const [reviewOpinionContext, setReviewOpinionContext] =
useState<ReviewOpinionDialogContext | null>(null);
const [latestReviewOpinion, setLatestReviewOpinion] = useState("");
const [latestProcessHistory, setLatestProcessHistory] = useState("");
const [lastSearchValues, setLastSearchValues] = useState<SearchFormValues | null>(null);
const { confirm } = useConfirm();
const columnInfo = useMemo(() => {
const { columns, columnLayout, fixedColumns, rowStartNum } = SeweragePenetrationRateInfoList;
@@ -66,6 +69,24 @@ const SeweragePenetrationRateRvw = () => {
const showActionBar =
showReviewOpinionButton || showReturnButton || showSubmitButton;
const submitDialogContext = useMemo<ReviewerInfoRequest | null>(() => {
if (reviewOpinionContext == null) {
return null;
}
const instSeCd = userInfo?.instSeCd ?? "";
if (!instSeCd) {
return null;
}
return {
statsYr: reviewOpinionContext.statsYr,
sewerBzmnCd: reviewOpinionContext.sewerBzmnCd,
statsCd: reviewOpinionContext.statsCd,
instSeCd,
};
}, [reviewOpinionContext, userInfo?.instSeCd]);
const handleReset = () => {
setRowData([]);
setStatsStatusInfo({});
@@ -129,37 +150,13 @@ const SeweragePenetrationRateRvw = () => {
}
};
const submitFunc = async () => {
if (reviewOpinionContext == null) {
const submitFunc = () => {
if (submitDialogContext == null) {
alert("조회 후 제출할 수 있습니다.");
return;
}
if (!(await confirm("제출 하시겠습니까?"))) {
return;
}
try {
const userInfo = await getUserInfo();
const response = await statisticsApi.submitReview({
statsYr: reviewOpinionContext.statsYr,
sewerBzmnCd: reviewOpinionContext.sewerBzmnCd,
statsCd: reviewOpinionContext.statsCd,
mdfrId: userInfo.userUnqId,
});
if (response.status !== "success") {
alert(response.message || "제출에 실패했습니다.");
return;
}
alert(response.message || "제출되었습니다.");
if (lastSearchValues != null) {
await handleSearch(lastSearchValues);
}
} catch {
alert("제출 중 오류가 발생했습니다.");
}
setSubmitDialogOpen(true);
};
const reviewOpinionFunc = async () => {
@@ -211,36 +208,9 @@ const SeweragePenetrationRateRvw = () => {
const returnFunc = () => setReturnDialogOpen(true);
const handleSaveReturnReason = async (reason: string) => {
if (reviewOpinionContext == null) {
alert("조회 후 반려할 수 있습니다.");
return;
}
if (await confirm('반려 처리 하시겠습니까?')) {
try {
const userInfo = await getUserInfo();
const response = await statisticsApi.saveReviewReturn({
statsYr: reviewOpinionContext.statsYr,
sewerBzmnCd: reviewOpinionContext.sewerBzmnCd,
statsCd: reviewOpinionContext.statsCd,
rjctOpnnCn: reason,
mdfrId: userInfo.userUnqId,
});
if (response.status !== "success") {
alert(response.message || "반려 처리 중 오류가 발생했습니다.");
return;
}
alert(response.message || "반려 처리 되었습니다.");
if (lastSearchValues != null) {
await handleSearch(lastSearchValues);
}
} catch {
alert("반려 처리 중 오류가 발생했습니다.");
}
const handleSaveReturnReason = async () => {
if (lastSearchValues != null) {
await handleSearch(lastSearchValues);
}
};
@@ -285,9 +255,20 @@ const SeweragePenetrationRateRvw = () => {
initialProcessHistory={latestProcessHistory}
onSave={saveReviewOpinion}
/>
<ReviewSubmitDialog
open={submitDialogOpen}
onOpenChange={setSubmitDialogOpen}
context={submitDialogContext}
onSubmitted={async () => {
if (lastSearchValues != null) {
await handleSearch(lastSearchValues);
}
}}
/>
<ReturnReasonDialog
open={returnDialogOpen}
onOpenChange={setReturnDialogOpen}
context={reviewOpinionContext}
onSave={handleSaveReturnReason}
/>
</div>
@@ -1,9 +1,12 @@
import { useState, useMemo } from "react";
import { useCallback, useMemo, useState } from "react";
import { useConfirm } from "@frontend-opensource/use-react-hooks";
import { createColumnHelper, getCoreRowModel, useReactTable } from "@tanstack/react-table";
import DataGrid from "@/components/module/DataGrid";
import type { DataCellClickCallbackParams } from "@/components/type/DataGrid.type";
import SearchForm from "@/components/module/SearchForm";
import ApproveButton from "@/components/button/ApproveButton";
import { doList } from "@/components/util/column/input/DoList";
import {
Button,
Dialog,
@@ -16,11 +19,17 @@ import {
} from "@common/components";
import { useAlertStore } from "@/shared/lib";
import { statisticsApi } from "@/entities/statistics/api/statistics-service";
import type { StatsInputStatusApproveItem } from "@/entities/statistics/model/statistics-interface";
import { StatsInputStatusInfoList } from "@/components/util/column/review/StatsInputStatusRvwColumn";
import { statisticsManagementApi } from "@/entities/statistics/api/statistics-management-service";
import type { ReviewHistoryResponse } from "@/entities/statistics/model/statistics-input-review-interface";
import type {
ReviewHistoryResponse,
StatsInputStatusReviewItem,
} from "@/entities/statistics/model/statistics-input-review-interface";
import { toGridRows } from "@/components/util/grid/gridFunction";
import type { SearchFormValues } from "@/components/type/SearchForm.type";
import { getUserInfo } from "../../../../../../packages/common/components/src/api";
type ReviewHistoryRow = ReviewHistoryResponse["data"][number];
@@ -32,13 +41,28 @@ const checkSubmitStatusCd = (code: string) => {
return code === '0' ? '미작성' : (code === '1' ? '작성중' : (code === '2' ? '제출완료' : (code === '3' ? '승인완료' : '보완요청')));
};
const isStatsInputStatusRowApprovable = (row: Record<string, string | number>) =>
String(row.submitStatusCd ?? "") === "3" && (String(row.prvnStatusCd ?? "") === "2" || String(row.prvnStatusCd ?? "") === "3");
const mapSewerageListToGridRows = (rawList: StatsInputStatusReviewItem[]) =>
rawList.map((r) => ({
...r,
prvnStatusNm: checkPrvnStatusCd(r.prvnStatusCd || "0"),
submitStatusNm: checkSubmitStatusCd(r.submitStatusCd || "0"),
reviewHistory: "검토이력",
}));
// 하수도 통계 검토 00. 통계 입력 현황
const StatsInputStatusRvw = () => {
const { setMessage: alert } = useAlertStore((state) => state);
const { confirm } = useConfirm();
const [rowData, setRowData] = useState<Record<string, string | number>[]>([]);
const [historyRowData, setHistoryRowData] = useState<ReviewHistoryRow[]>([]);
const [isLoading, setIsLoading] = useState<boolean>(false);
const [isApproveLoading, setIsApproveLoading] = useState(false);
const [searchedYear, setSearchedYear] = useState<string>("");
const [lastSearchedValues, setLastSearchedValues] = useState<SearchFormValues | null>(null);
const [hasCompletedSearch, setHasCompletedSearch] = useState(false);
const [reviewHistoryOpen, setReviewHistoryOpen] = useState<boolean>(false);
const [historySearchInfo, setHistorySearchInfo] = useState<{
ctpvNm: string;
@@ -85,6 +109,9 @@ const StatsInputStatusRvw = () => {
const handleReset = () => {
setRowData([]);
setSearchedYear("");
setLastSearchedValues(null);
setHasCompletedSearch(false);
setIsApproveLoading(false);
};
const handleSearch = async (values: SearchFormValues) => {
@@ -96,6 +123,8 @@ const StatsInputStatusRvw = () => {
try {
setIsLoading(true);
setSearchedYear(values.year ?? "");
setLastSearchedValues(values);
setHasCompletedSearch(false);
const response = await statisticsManagementApi.getStatsInputStatusList({
ctpvNm: values.sido,
@@ -106,64 +135,174 @@ const StatsInputStatusRvw = () => {
if (response.status === 'success' && response.data) {
const rawList = response.data.sewerageInputList ?? [];
const convertedData = rawList.map((r) => ({
...r,
prvnStatusNm: checkPrvnStatusCd(r.prvnStatusCd || '0'),
submitStatusNm: checkSubmitStatusCd(r.submitStatusCd || '0')
}));
setRowData(toGridRows(convertedData));
setRowData(toGridRows(mapSewerageListToGridRows(rawList)));
} else {
setRowData([]);
setHasCompletedSearch(false);
alert(response.message || "조회된 데이터가 없습니다.");
return;
}
setHasCompletedSearch(true);
} catch {
alert('데이터 조회 중 오류가 발생했습니다.');
setRowData([]);
setHasCompletedSearch(false);
} finally {
setIsLoading(false);
}
};
const approveFunc = () => console.log('approveFunc');
const approveFunc = async () => {
if (rowData.length === 0) {
alert("조회된 통계 항목이 없습니다.");
return;
}
if (!lastSearchedValues?.year?.trim() || !lastSearchedValues?.bsnmCd?.trim()) {
alert("먼저 조회를 진행해주세요.");
return;
}
const handleRowClick = async (row: Record<string, string | number>) => {
setHistorySearchInfo({
ctpvNm: String(row.ctpvNm ?? ""),
sggNm: String(row.sggNm ?? ""),
tabNm: String(row.tabNm ?? ""),
});
const params = {
statsYr: searchedYear,
sewerBzmnCd: String(row.sewerBzmnCd ?? ""),
statsCd: String(row.statsCd ?? ""),
};
if (!rowData.every(isStatsInputStatusRowApprovable)) {
alert("지자체 진행상태가 승인완료이고 진행상태가 제출완료인 항목만 승인할 수 있습니다.");
return;
}
if (!(await confirm("조회된 통계 항목을 승인하시겠습니까?"))) {
return;
}
setIsApproveLoading(true);
try {
const response = await statisticsManagementApi.getReviewHistory(params);
const userInfo = await getUserInfo();
const mdfrId = (userInfo.userUnqId ?? "").trim();
if (!mdfrId) {
alert("수정자 ID를 확인할 수 없습니다.");
return;
}
if (response.status === 'success') {
setHistoryRowData(response.data);
setReviewHistoryOpen(true);
const statsYr = lastSearchedValues.year.trim();
const sewerBzmnCdDefault = lastSearchedValues.bsnmCd.trim();
const approveItems: StatsInputStatusApproveItem[] = rowData
.map((row) => {
const statsCd = String(row.statsCd ?? "").trim();
const sewerBzmnCd = String(row.sewerBzmnCd ?? sewerBzmnCdDefault).trim();
return { statsYr, sewerBzmnCd, statsCd, mdfrId };
})
.filter((item) => item.statsCd.length > 0);
if (approveItems.length === 0) {
alert("통계 코드(statsCd)가 있는 행이 없습니다.");
return;
}
const response = await statisticsApi.approveStatsInputStatus({ list: approveItems }, "REVIEW");
if (response.resultCode.toLocaleLowerCase() === "success") {
try {
const listResponse = await statisticsManagementApi.getStatsInputStatusList({
ctpvNm: lastSearchedValues.sido,
sggNm: lastSearchedValues.gugun === "ALL" ? "" : lastSearchedValues.gugun,
statsYr: lastSearchedValues.year ?? "",
sewerBzmnCd: lastSearchedValues.bsnmCd,
});
if (listResponse.status === "success" && listResponse.data) {
const rawList = listResponse.data.sewerageInputList ?? [];
setRowData(toGridRows(mapSewerageListToGridRows(rawList)));
}
} catch {
/* 목록 갱신 실패는 별도 안내 없이 생략 */
}
alert(response.message ?? "승인되었습니다.");
} else {
alert(response.message || '검토 이력 조회 중 오류가 발생했습니다.');
alert(response.message ?? "승인에 실패했습니다.");
}
} catch {
alert('검토 이력 조회 중 오류가 발생했습니다.');
alert("승인 처리 중 오류가 발생했습니다.");
} finally {
setIsApproveLoading(false);
}
};
const gugunOkOrNotDoList = doList.includes(lastSearchedValues?.bsnmCd || "")
? Boolean(lastSearchedValues?.gugun && lastSearchedValues.gugun !== "ALL")
: true;
const allSubmitStatusApproved = useMemo(
() =>
rowData.length > 0 &&
rowData.every((row) => String(row.submitStatusCd ?? "") === "3"),
[rowData],
);
/** 진행상태(prvn) 코드가 모두 승인완료(3) */
const allPrvnStatusApproved = useMemo(
() =>
rowData.length > 0 &&
rowData.every((row) => String(row.prvnStatusCd ?? "") === "3"),
[rowData],
);
const showActionToolbarCard =
hasCompletedSearch &&
rowData.length > 0 &&
gugunOkOrNotDoList &&
allSubmitStatusApproved &&
!allPrvnStatusApproved;
const openReviewHistoryForRow = useCallback(
async (row: Record<string, string | number>) => {
if (!searchedYear) {
alert("먼저 조회를 진행해주세요.");
return;
}
setHistorySearchInfo({
ctpvNm: String(row.ctpvNm ?? ""),
sggNm: String(row.sggNm ?? ""),
tabNm: String(row.tabNm ?? ""),
});
const params = {
statsYr: searchedYear,
sewerBzmnCd: String(row.sewerBzmnCd ?? ""),
statsCd: String(row.statsCd ?? ""),
};
try {
const response = await statisticsManagementApi.getReviewHistory(params);
if (response.status === "success") {
setHistoryRowData(response.data);
setReviewHistoryOpen(true);
} else {
alert(response.message || "검토 이력 조회 중 오류가 발생했습니다.");
}
} catch {
alert("검토 이력 조회 중 오류가 발생했습니다.");
}
},
[alert, searchedYear],
);
const handleReviewHistoryCellClick = useCallback(
({ fieldName, row }: DataCellClickCallbackParams) => {
if (fieldName !== "reviewHistory") return;
void openReviewHistoryForRow(row);
},
[openReviewHistoryForRow],
);
return (
<div className="relative">
<SearchForm onSubmit={handleSearch} onReset={handleReset} />
<div className="card !py-0">
<div className="flex justify-end items-center gap-[0.6rem] m-[0.6rem]">
<ApproveButton approveFunc={approveFunc} />
{showActionToolbarCard && (
<div className="card !py-0">
<div className="flex justify-end items-center gap-[0.6rem] m-[0.6rem]">
<ApproveButton approveFunc={approveFunc} />
</div>
</div>
</div>
)}
{isLoading && (
{(isLoading || isApproveLoading) && (
<div className="absolute inset-0 z-50 [data-theme='black']:bg-transparent rounded-lg p-10 flex justify-center items-center">
<div className="relative size-[5rem]">
<LoadingWithAnimation color="primary" />
@@ -174,12 +313,49 @@ const StatsInputStatusRvw = () => {
<DataGrid
columnInfo={columnInfo}
isExcelImport={false}
showHeaderCheckbox={true}
showHeaderCheckbox={false}
rowStartNum={2}
fixedColumns={3}
rowData={rowData}
setRowData={setRowData}
onRowClick={handleRowClick}
onDataCellClick={handleReviewHistoryCellClick}
cellStyleCallback={({ row, fieldName }) => {
if (fieldName === "submitStatusNm") {
const statusCd = String(row.submitStatusCd ?? "");
if (statusCd === "0" || statusCd === "1") {
return "rg-cell-stats-status-pending";
}
if (statusCd === "2") {
return "rg-cell-stats-status-submitted";
}
if (statusCd === "3") {
return "rg-cell-stats-status-approved";
}
if (statusCd === "4") {
return "rg-cell-stats-status-rejected";
}
return undefined;
} else if (fieldName === "prvnStatusNm") {
const statusCd = String(row.prvnStatusCd ?? "");
if (statusCd === "0" || statusCd === "1") {
return "rg-cell-stats-status-pending";
}
if (statusCd === "2") {
return "rg-cell-stats-status-submitted";
}
if (statusCd === "3") {
return "rg-cell-stats-status-approved";
}
if (statusCd === "4") {
return "rg-cell-stats-status-rejected";
}
return undefined;
} else {
return undefined;
}
}}
/>
<Dialog open={reviewHistoryOpen} onOpenChange={setReviewHistoryOpen}>
<DialogContent
@@ -8,7 +8,7 @@ import SearchForm from "@/components/module/SearchForm";
import DataGrid from "@/components/module/DataGrid";
import type { ColumnInfo } from "@/components/type/DataGrid.type";
import type { SearchFormValues } from "@/components/type/SearchForm.type";
import { SewagePipeColumnInfoListTot } from "@/components/util/column/total/SewagePipeTotColumn";
import { SewagePipeColumnInfoListTot, getST00020003Config } from "@/components/util/column/total/SewagePipeTotColumn";
import { toGridRows } from "@/components/util/grid/gridFunction";
import type { SewagePipeTotalRequest } from "@/entities/statistics/model/statistics-total/statistics-sewage-pipe-total-interface";
import CellColorInfo from "./CellColorInfo";
@@ -46,7 +46,6 @@ const SewagePipeTot = () => {
lastSearchedValuesRef.current = null;
};
/** SewagePipeInp의 `loadSewagePipeActiveTabData`와 동일 역할: 탭키별 API 호출 후 `rowDataMap` 반영 */
const loadSewagePipeTotTabData = async (
tabKey: string,
values: SearchFormValues,
@@ -107,7 +106,8 @@ const SewagePipeTot = () => {
alert(response.message || "데이터 조회 중 오류가 발생했습니다.");
return false;
}
if (tabKey === "ST00020005") {
if (tabKey === "ST00020005") {
const response = await statisticsTotalStatusApi.getSewagePipePipeMnhleStatus(param);
if (response.status === "success" && response.data) {
applyRows(toGridRows(response.data as object[]));
@@ -124,7 +124,6 @@ const SewagePipeTot = () => {
try {
setIsLoading(true);
// SewagePipeInp과 동일: 검색 시 활성 탭 데이터만 조회·맵 병합
let ok = false;
if (
activeTab === "ST00020001" ||
@@ -175,8 +174,6 @@ const SewagePipeTot = () => {
}
return;
}
console.log("[SewagePipeTot] tabKey (API 미연결):", tabKey);
};
return (
@@ -206,22 +203,29 @@ const SewagePipeTot = () => {
</TabsList>
{columnInfo.map((info) => {
const tabKey = info.tabKey;
if (!info.columns || !info.columnLayout) {
// ★ [동적 처리] ST00020003인 경우 함수를 통해 컬럼 정보를 실시간으로 생성
const currentYear = lastSearchedValuesRef.current?.year || new Date().getFullYear();
const finalTabConfig = tabKey === "ST00020003"
? getST00020003Config(currentYear)
: info;
if (!finalTabConfig.columns || !finalTabConfig.columnLayout) {
return null;
}
const rowData = rowDataMap.get(tabKey) ?? [];
const gridColumnInfo: ColumnInfo = {
columns: info.columns,
columnLayout: info.columnLayout,
columns: finalTabConfig.columns,
columnLayout: finalTabConfig.columnLayout,
};
return (
<TabsContent key={tabKey} value={tabKey}>
<DataGrid
columnInfo={gridColumnInfo}
rowStartNum={info.rowStartNum}
excelExportFileSuffix={info.tabLabel || tabKey}
rowStartNum={finalTabConfig.rowStartNum}
excelExportFileSuffix={finalTabConfig.tabLabel || tabKey}
rowData={rowData}
setRowData={(updater) => {
setRowDataMap((prev) => {
@@ -236,7 +240,7 @@ const SewagePipeTot = () => {
isExcelImport={false}
isExcelExport
showHeaderCheckbox={false}
fixedColumns={info.fixedColumns}
fixedColumns={finalTabConfig.fixedColumns}
enableSort={false}
cellStyleCallback={({ row }) => {
const rowType = String(row.rowType ?? '');
@@ -255,4 +259,4 @@ const SewagePipeTot = () => {
);
};
export default SewagePipeTot;
export default SewagePipeTot;
@@ -4,11 +4,13 @@ import type { Dispatch, SetStateAction } from "react";
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
import { useConfirm } from "@frontend-opensource/use-react-hooks";
import { useQuery } from "@tanstack/react-query";
import dayjs from "dayjs";
import type { RealGridReact } from "realgrid-react";
import { ENDPOINT } from "@/shared/config";
import { useAlertStore, useMutationWrapper, useUserStore } from "@/shared/lib";
import { CodeSelect } from "@/entities/code";
import { CodeApi, CodeSelect } from "@/entities/code";
import {
type ApproveRequest,
type RejectRequest,
@@ -129,21 +131,15 @@ const DEFAULT_FILTERS: StatisticsVerificationFilterState = {
statsYr: String(dayjs().year() - 1),
sewerBzmnCd: null,
statsCd: null,
laeSbmsnSttsCd: "2",
laeSbmsnSttsCd: "3",
};
const DEFAULT_SUB_FILTERS: StatisticsVerificationSubFilterState = {
...DEFAULT_FILTERS,
prcsYnList: "1,2,3",
prcsYnList: "1,2",
pbcpAprvYn: "N",
};
const STATUS_OPTIONS = [
{ label: "수정완료", value: "1" },
{ label: "예외통과", value: "2" },
{ label: "반려", value: "3" },
];
const APPROVAL_STATUS_OPTIONS = [
{ label: "승인", value: "Y" },
{ label: "미승인", value: "N" },
@@ -195,6 +191,18 @@ function StatisticsVerificationReport() {
[filters, subFilters],
);
const { data: statusOptions } = useQuery({
queryKey: [ENDPOINT.CMS_SERVICE.CODE, "STINPTEXCT"],
queryFn: () => CodeApi.getCode("STINPTEXCT"),
select: (data) =>
data
.filter(({ lwpoCd }) => !["0", "4"].includes(lwpoCd))
.map(({ lwpoCdNm: label, lwpoCd: value }) => ({
label,
value,
})),
});
const statisticsVerificationReportQuery = useStatsVrfcRptData(filters);
const statisticsVerificationResultQuery =
useStatsVrfcRsltData(detailParams);
@@ -460,8 +468,6 @@ function StatisticsVerificationReport() {
async function handleReject() {
const commonParam = {
statsYr: filters.statsYr,
sewerBzmnCd: filters.sewerBzmnCd ?? "",
statsCd: filters.statsCd ?? "",
mdfrId: userInfo?.userUnqId ?? "",
};
@@ -488,6 +494,8 @@ function StatisticsVerificationReport() {
...commonParam,
vrfcSn: Number.isNaN(vrfcSn) ? 0 : vrfcSn,
rjctOpnnCn: String(row.rjctOpnnCn ?? ""),
sewerBzmnCd: String(row.sewerBzmnCd ?? ""),
statsCd: String(row.statsCd ?? ""),
};
});
@@ -594,7 +602,7 @@ function StatisticsVerificationReport() {
label="상태"
value={subFilters.prcsYnList}
onChange={handleSubFilterChange("prcsYnList")}
options={STATUS_OPTIONS}
options={statusOptions ?? []}
/>
<FilterSelect
label="승인상태"
@@ -691,7 +699,7 @@ function FilterCombobox({
onChange(list.length > 0 ? list.join(",") : undefined);
}}
>
<ComboboxChips ref={anchor} className="max-w-[26rem]">
<ComboboxChips ref={anchor} className="max-w-[32rem]">
<ComboboxValue>
{(values: string[]) => (
<>
+17 -3
View File
@@ -17,7 +17,7 @@ export type DataGridStatusBannerInfo = {
export type RealGridColumnInfo = {
name: string;
fieldName: string;
header: { text: string };
header: { text: string; tooltip?: string; showTooltip?: boolean; template?: string };
editable?: boolean;
valueType?: import("realgrid").ValueType;
dataFormat?: string;
@@ -154,7 +154,9 @@ export type ComputedFieldConfig =
export type ColumnConfig = {
name: string;
fieldName: string;
header: { text: string };
header: { text: string; tooltip?: string; showTooltip?: boolean; template?: string };
/** 헤더 hover 시 보여줄 컬럼 가이드 문구 */
tooltip?: string;
/** 계산 필드: fields 합계/평균 등. valueCallback으로 dataProvider.setFields 시 전달 */
computed?: ComputedFieldConfig;
editable?: boolean; // 셀 편집 가능 여부
@@ -210,7 +212,7 @@ export type ColumnLayoutItem =
| {
name: string;
hideChildHeaders?: boolean;
header: { text: string };
header: { text: string; tooltip?: string; showTooltip?: boolean; template?: string };
items: (string | ColumnLayoutItem)[];
footerUserSpans?: FooterUserSpan[]; // footer 병합 설정
}
@@ -277,6 +279,16 @@ export type CellEditedCallback = (
params: CellEditedCallbackParams,
) => void;
/** 데이터 셀 클릭 시 컬럼(필드)까지 전달 — 특정 컬럼만 반응할 때 사용 */
export type DataCellClickCallbackParams = {
row: Record<string, string | number>;
fieldName: string;
itemIndex: number;
};
export type DataCellClickCallback = (
params: DataCellClickCallbackParams,
) => void;
export type DataGridProps = {
columnInfo: ColumnInfo;
rowStartNum?: number; // 헤더 행 개수 (1부터 시작, 예: 2면 1행과 2행까지 헤더로 간주)
@@ -304,6 +316,8 @@ export type DataGridProps = {
/** 외부에서 gridRef에 직접 접근하기 위한 ref (setRows/addRows/setValue 직접 호출용) */
getGridRef?: React.MutableRefObject<RealGridReact | null>;
onRowClick?: (rowData: Record<string, string | number>) => void; // 행 클릭 이벤트
/** 데이터 셀 클릭 시 행 + 클릭한 필드명 전달 (`onRowClick`과 함께 쓸 수 있음) */
onDataCellClick?: DataCellClickCallback;
/** CheckBar 행 체크 변경 */
onItemChecked?: (itemIndex: number, checked: boolean) => void;
/** CheckBar 헤더 전체 선택/해제 */
@@ -10,8 +10,15 @@ export const StatsInputStatusInpColumn = {
{ name: 'sggNm', fieldName: 'sggNm', header: { text: '시군구' }, editable: false },
{ name: 'statsCd', fieldName: 'statsCd', header: { text: '메뉴코드' }, visible: false, editable: false },
{ name: 'menuTabName', fieldName: 'menuTabName', header: { text: '구분' }, editable: false, width: 220 },
{ name: 'locgStatusNm', fieldName: 'locgStatusNm', header: { text: '진행상황' }, editable: false },
// { name: 'inputRate', fieldName: 'inputRate', header: { text: '작성률' }, editable: false, valueType: ValueType.NUMBER, dataFormat: '#,##0' },
{
name: 'exceptionSubmitHistory',
fieldName: 'exceptionSubmitHistory',
header: { text: '예외제출내역' },
editable: false,
width: 118,
styleName: 'rg-cell-outline-action-btn',
},
{ name: 'laeSbmsnSttsNm', fieldName: 'laeSbmsnSttsNm', header: { text: '진행상황' }, editable: false },
{ name: 'laePicNm', fieldName: 'laePicNm', header: { text: '작성자' }, editable: false },
{ name: 'laePicTelNo', fieldName: 'laePicTelNo', header: { text: '작성자연락처' }, editable: false },
{ name: 'laeKbrdrDeptNm', fieldName: 'laeKbrdrDeptNm', header: { text: '부서' }, editable: false },
@@ -20,6 +27,7 @@ export const StatsInputStatusInpColumn = {
{ name: 'laeIdfrDeptNm', fieldName: 'laeIdfrDeptNm', header: { text: '부서' }, editable: false },
{ name: 'laeIdfrJbgdNm', fieldName: 'laeIdfrJbgdNm', header: { text: '직제' }, editable: false },
{ name: 'rjctOpnnCn', fieldName: 'rjctOpnnCn', header: { text: '반려사유' }, editable: false },
{ name: 'laeSbmsnSttsCd', fieldName: 'laeSbmsnSttsCd', header: { text: '진행상황' }, editable: false, visible: false },
],
columnLayout: [
@@ -27,13 +35,13 @@ export const StatsInputStatusInpColumn = {
'sggNm',
'statsCd',
'menuTabName',
'exceptionSubmitHistory',
{
name: 'groupA',
hideChildHeaders: false,
header: { text: '하수도사업자' },
items: [
'locgStatusNm',
// 'inputRate',
'laeSbmsnSttsNm',
'laePicNm',
'laePicTelNo',
'laeKbrdrDeptNm',
@@ -41,7 +49,8 @@ export const StatsInputStatusInpColumn = {
'laeIdfrNm',
'laeIdfrDeptNm',
'laeIdfrJbgdNm',
'rjctOpnnCn'
'rjctOpnnCn',
'laeSbmsnSttsCd'
],
},
]
@@ -6,7 +6,7 @@ export const LogHistoryColumn = {
{ name: 'sggNm', fieldName: 'sggNm', header: { text: '시군구' }, editable: false },
{ name: 'statsYr', fieldName: 'statsYr', header: { text: '연도' }, editable: false },
{ name: 'statsLogSn', fieldName: 'statsLogSn', header: { text: '일련번호' }, editable: false, visible: false, valueType: ValueType.NUMBER, dataFormat: '#,##0' },
{ name: 'menuNm', fieldName: 'menuNm', header: { text: '메뉴명' }, editable: false },
{ name: 'menuNm', fieldName: 'menuNm', header: { text: '통계항목' }, editable: false },
{ name: 'tblNm', fieldName: 'tblNm', header: { text: '테이블명' }, visible: false, editable: false },
{ name: 'statsDataChgComNm', fieldName: 'statsDataChgComNm', header: { text: '요청 기능명' }, editable: false },
{ name: 'rgtrId', fieldName: 'rgtrId', header: { text: '등록자' }, editable: false, visible: false },
@@ -1,142 +1,190 @@
import { ValueType } from "realgrid";
import {
footerAvgDecimalArrayByDivision,
footerDecimal1ArrayByDivision,
footerDecimal3ArrayByDivision,
footerIntArrayByDivision,
} from "@/components/util/grid/footerSummary";
import type { GridView } from "@/components/type/DataGrid.type";
const getDivisionFooterLabel = (grid: GridView, divisionIndex: number, withSuffix: boolean) => {
const dataSource = grid.getDataSource();
const uniqueValues = new Set<string>();
for (let i = 0; i < dataSource.getRowCount(); i++) {
const val = dataSource.getValue(i, "displayYear");
if (val) uniqueValues.add(String(val));
}
const divisionValues = Array.from(uniqueValues);
if (divisionValues.length <= divisionIndex) {
return withSuffix ? "합계" : "";
}
const label = divisionValues[divisionIndex] ?? "";
return withSuffix ? `${label}합계` : label;
};
// 하수도 통계 관리 06.공공하수처리시설
export const rawPublicSewageFacilityInfoList = {
columns: [
/* 1. 기본 정보 */
{ name: 'sido', fieldName: '시도', header: { text: '시도' } },
{ name: 'sgg', fieldName: '시군구', header: { text: '시군구' } },
{ name: 'GBN', fieldName: '구분', header: { text: '구분' } },
{ name: 'ADDR', fieldName: '소재지', header: { text: '소재지' } },
{ name: 'FCT_NM_GROUP', fieldName: '시설명(500m3/일이상/미만)', header: { text: '시설명(500m3/일이상/미만)' } },
{
name: 'ctpvNm',
fieldName: 'ctpvNm',
header: { text: '시도' },
footer: [
{ valueCallback: (grid: GridView) => getDivisionFooterLabel(grid, 0, true) },
{ valueCallback: (grid: GridView) => getDivisionFooterLabel(grid, 1, true) },
],
},
{ name: 'sggNm', fieldName: 'sggNm', header: { text: '시군구' } },
{
name: 'displayYear',
fieldName: 'displayYear',
header: { text: '구분' },
footer: [
{ valueCallback: (grid: GridView) => getDivisionFooterLabel(grid, 0, false) },
{ valueCallback: (grid: GridView) => getDivisionFooterLabel(grid, 1, false) },
],
},
{ name: 'swtrmtFcltNm', fieldName: 'swtrmtFcltNm', header: { text: '시설명(500m³/일 이상/미만)' } },
{ name: 'impdFcltPstnCn', fieldName: 'impdFcltPstnCn', header: { text: '소재지' } },
/* 2. 시설용량 (㎥/일) */
{ name: 'FCT_CPC_GROUP', fieldName: '시설용량(500m3/일이상/미만)_용량', header: { text: '시설용량(500m3/일이상/미만)' }, valueType: ValueType.NUMBER, numberFormat: '#,##0.0' },
{ name: 'FCT_CPC_PHY', fieldName: '물리적_용량', header: { text: '물리적' }, valueType: ValueType.NUMBER, numberFormat: '#,##0.0' },
{ name: 'FCT_CPC_BIO', fieldName: '생물학적_용량', header: { text: '생물학적' }, valueType: ValueType.NUMBER, numberFormat: '#,##0.0' },
{ name: 'FCT_CPC_ADV', fieldName: '고도_용량', header: { text: '고도' }, valueType: ValueType.NUMBER, numberFormat: '#,##0.0' },
{ name: 'prcsFcltCpct', fieldName: 'prcsFcltCpct', header: { text: '시설용량(500m³/일 이상/미만)' }, footer: footerDecimal1ArrayByDivision, valueType: ValueType.NUMBER, numberFormat: '#,##0.0' },
{ name: 'prcsFcltPhysCpct', fieldName: 'prcsFcltPhysCpct', header: { text: '물리적' }, footer: footerDecimal1ArrayByDivision, valueType: ValueType.NUMBER, numberFormat: '#,##0.0' },
{ name: 'prcsFcltBoilCpct', fieldName: 'prcsFcltBoilCpct', header: { text: '생물학적' }, footer: footerDecimal1ArrayByDivision, valueType: ValueType.NUMBER, numberFormat: '#,##0.0' },
{ name: 'prcsFcltAltdCpct', fieldName: 'prcsFcltAltdCpct', header: { text: '고도' }, footer: footerDecimal1ArrayByDivision, valueType: ValueType.NUMBER, numberFormat: '#,##0.0' },
/* 3. 유입하수량 (㎥/일) */
{ name: 'IN_QTY_GROUP', fieldName: '유입하수량(500m3/일이상/미만)', header: { text: '유입하수량(500m3/일이상/미만)' }, valueType: ValueType.NUMBER, numberFormat: '#,##0.0' },
{ name: 'IN_QTY_PHY', fieldName: '물리적_유입', header: { text: '물리적' }, valueType: ValueType.NUMBER, numberFormat: '#,##0.0' },
{ name: 'IN_QTY_BIO', fieldName: '생물학적_유입', header: { text: '생물학적' }, valueType: ValueType.NUMBER, numberFormat: '#,##0.0' },
{ name: 'IN_QTY_ADV', fieldName: '고도_유입', header: { text: '고도' }, valueType: ValueType.NUMBER, numberFormat: '#,##0.0' },
{ name: 'inflwSwageQnt', fieldName: 'inflwSwageQnt', header: { text: '유입하수량(500m³/일 이상/미만)' }, footer: footerDecimal1ArrayByDivision, valueType: ValueType.NUMBER, numberFormat: '#,##0.0' },
{ name: 'inflwSwagePhysQnt', fieldName: 'inflwSwagePhysQnt', header: { text: '물리적' }, footer: footerDecimal1ArrayByDivision, valueType: ValueType.NUMBER, numberFormat: '#,##0.0' },
{ name: 'inflwSwageBoilQnt', fieldName: 'inflwSwageBoilQnt', header: { text: '생물학적' }, footer: footerDecimal1ArrayByDivision, valueType: ValueType.NUMBER, numberFormat: '#,##0.0' },
{ name: 'inflwSwageAltdQnt', fieldName: 'inflwSwageAltdQnt', header: { text: '고도' }, footer: footerDecimal1ArrayByDivision, valueType: ValueType.NUMBER, numberFormat: '#,##0.0' },
/* 4. 방류량 (㎥/일) */
{ name: 'OUT_QTY_GROUP', fieldName: '방류량(500m3/일이상/미만)', header: { text: '방류량(500m3/일이상/미만)' }, valueType: ValueType.NUMBER, numberFormat: '#,##0.0' },
{ name: 'OUT_QTY_PHY', fieldName: '물리적_방류', header: { text: '물리적' }, valueType: ValueType.NUMBER, numberFormat: '#,##0.0' },
{ name: 'OUT_QTY_BIO', fieldName: '생물학적_방류', header: { text: '생물학적' }, valueType: ValueType.NUMBER, numberFormat: '#,##0.0' },
{ name: 'OUT_QTY_ADV', fieldName: '고도_방류', header: { text: '고도' }, valueType: ValueType.NUMBER, numberFormat: '#,##0.0' },
{ name: 'prcsQnt', fieldName: 'prcsQnt', header: { text: '방류량(500m³/일 이상/미만)' }, footer: footerDecimal1ArrayByDivision, valueType: ValueType.NUMBER, numberFormat: '#,##0.0' },
{ name: 'prcsPhysCpct', fieldName: 'prcsPhysCpct', header: { text: '물리적' }, footer: footerDecimal1ArrayByDivision, valueType: ValueType.NUMBER, numberFormat: '#,##0.0' },
{ name: 'prcsBoilCpct', fieldName: 'prcsBoilCpct', header: { text: '생물학적' }, footer: footerDecimal1ArrayByDivision, valueType: ValueType.NUMBER, numberFormat: '#,##0.0' },
{ name: 'prcsAltdCpct', fieldName: 'prcsAltdCpct', header: { text: '고도' }, footer: footerDecimal1ArrayByDivision, valueType: ValueType.NUMBER, numberFormat: '#,##0.0' },
/* 5. 수질 및 처리효율 */
{ name: 'IN_BOD', fieldName: '유입BOD', header: { text: '유입BOD' }, valueType: ValueType.NUMBER, numberFormat: '#,##0.0' },
{ name: 'OUT_BOD', fieldName: '방류BOD', header: { text: '방류BOD' }, valueType: ValueType.NUMBER, numberFormat: '#,##0.0' },
{ name: 'IN_TOC', fieldName: '유입TOC', header: { text: '유입TOC' }, valueType: ValueType.NUMBER, numberFormat: '#,##0.0' },
{ name: 'OUT_TOC', fieldName: '방류TOC', header: { text: '방류TOC' }, valueType: ValueType.NUMBER, numberFormat: '#,##0.0' },
{ name: 'IN_SS', fieldName: '유입SS', header: { text: '유입SS' }, valueType: ValueType.NUMBER, numberFormat: '#,##0.0' },
{ name: 'OUT_SS', fieldName: '방류SS', header: { text: '방류SS' }, valueType: ValueType.NUMBER, numberFormat: '#,##0.0' },
{ name: 'IN_TN', fieldName: '유입T-N', header: { text: '유입T-N' }, valueType: ValueType.NUMBER, numberFormat: '#,##0.000' },
{ name: 'OUT_TN', fieldName: '방류T-N', header: { text: '방류T-N' }, valueType: ValueType.NUMBER, numberFormat: '#,##0.000' },
{ name: 'IN_TP', fieldName: '유입T-P', header: { text: '유입T-P' }, valueType: ValueType.NUMBER, numberFormat: '#,##0.000' },
{ name: 'OUT_TP', fieldName: '방류T-P', header: { text: '방류T-P' }, valueType: ValueType.NUMBER, numberFormat: '#,##0.000' },
{ name: 'IN_COLI', fieldName: '유입총대장균군수', header: { text: '유입총대장균군수' }, valueType: ValueType.NUMBER, numberFormat: '#,##0.0' },
{ name: 'OUT_COLI', fieldName: '방대총대장균군수', header: { text: '방대총대장균군수' }, valueType: ValueType.NUMBER, numberFormat: '#,##0.0' },
{ name: 'TRT_EFF', fieldName: '처리효율(%)', header: { text: '처리효율(%)' }, valueType: ValueType.NUMBER, numberFormat: '#,##0.0' },
{ name: 'TRT_LOD', fieldName: '처리부하량(kgBOD/D)', header: { text: '처리부하량(kgBOD/D)' }, valueType: ValueType.NUMBER, numberFormat: '#,##0.0' },
{ name: 'TRT_MTHD', fieldName: '처리방법', header: { text: '처리방법' } },
{ name: 'inflwBodVl', fieldName: 'inflwBodVl', header: { text: '유입BOD' }, footer: footerDecimal1ArrayByDivision, valueType: ValueType.NUMBER, numberFormat: '#,##0.0' },
{ name: 'dschgBodVl', fieldName: 'dschgBodVl', header: { text: '방류BOD' }, footer: footerDecimal1ArrayByDivision, valueType: ValueType.NUMBER, numberFormat: '#,##0.0' },
{ name: 'inflwTocQnt', fieldName: 'inflwTocQnt', header: { text: '유입TOC' }, footer: footerDecimal1ArrayByDivision, valueType: ValueType.NUMBER, numberFormat: '#,##0.0' },
{ name: 'dschgTocQnt', fieldName: 'dschgTocQnt', header: { text: '방류TOC' }, footer: footerDecimal1ArrayByDivision, valueType: ValueType.NUMBER, numberFormat: '#,##0.0' },
{ name: 'inflwSsQnt', fieldName: 'inflwSsQnt', header: { text: '유입SS' }, footer: footerDecimal1ArrayByDivision, valueType: ValueType.NUMBER, numberFormat: '#,##0.0' },
{ name: 'dschgSsQnt', fieldName: 'dschgSsQnt', header: { text: '방류SS' }, footer: footerDecimal1ArrayByDivision, valueType: ValueType.NUMBER, numberFormat: '#,##0.0' },
{ name: 'inflwTnVl', fieldName: 'inflwTnVl', header: { text: '유입T-N' }, footer: footerDecimal3ArrayByDivision, valueType: ValueType.NUMBER, numberFormat: '#,##0.000' },
{ name: 'dschgTnVl', fieldName: 'dschgTnVl', header: { text: '방류T-N' }, footer: footerDecimal3ArrayByDivision, valueType: ValueType.NUMBER, numberFormat: '#,##0.000' },
{ name: 'inflwTpVl', fieldName: 'inflwTpVl', header: { text: '유입T-P' }, footer: footerDecimal3ArrayByDivision, valueType: ValueType.NUMBER, numberFormat: '#,##0.000' },
{ name: 'dschgTpVl', fieldName: 'dschgTpVl', header: { text: '방류T-P' }, footer: footerDecimal3ArrayByDivision, valueType: ValueType.NUMBER, numberFormat: '#,##0.000' },
{ name: 'inflwGrssClfgVl', fieldName: 'inflwGrssClfgVl', header: { text: '유입총대장균군수' }, footer: footerDecimal1ArrayByDivision, valueType: ValueType.NUMBER, numberFormat: '#,##0.0' },
{ name: 'dschgGrssClfgVl', fieldName: 'dschgGrssClfgVl', header: { text: '방류총대장균군수' }, footer: footerDecimal1ArrayByDivision, valueType: ValueType.NUMBER, numberFormat: '#,##0.0' },
{ name: 'prcsRt', fieldName: 'prcsRt', header: { text: '처리효율(%)' }, footer: footerAvgDecimalArrayByDivision, valueType: ValueType.NUMBER, numberFormat: '#,##0.0' },
{ name: 'prcsLodQnt', fieldName: 'prcsLodQnt', header: { text: '처리부하량(kgBOD/D)' }, footer: footerDecimal1ArrayByDivision, valueType: ValueType.NUMBER, numberFormat: '#,##0.0' },
{ name: 'prcsMthdExpln', fieldName: 'prcsMthdExpln', header: { text: '처리방법' } },
/* 6. 연계처리량 */
{ name: 'LNK_EXC', fieldName: '분뇨', header: { text: '분뇨' }, valueType: ValueType.NUMBER, numberFormat: '#,##0.0' },
{ name: 'LNK_LVS', fieldName: '축산', header: { text: '축산' }, valueType: ValueType.NUMBER, numberFormat: '#,##0.0' },
{ name: 'LNK_LCH', fieldName: '침출수', header: { text: '침출수' }, valueType: ValueType.NUMBER, numberFormat: '#,##0.0' },
{ name: 'LNK_ETC', fieldName: '기타_연계', header: { text: '기타' }, valueType: ValueType.NUMBER, numberFormat: '#,##0.0' },
{ name: 'wholHmnQnt', fieldName: 'wholHmnQnt', header: { text: '분뇨' }, footer: footerDecimal1ArrayByDivision, valueType: ValueType.NUMBER, numberFormat: '#,##0.0' },
{ name: 'wholLvstQnt', fieldName: 'wholLvstQnt', header: { text: '축산' }, footer: footerDecimal1ArrayByDivision, valueType: ValueType.NUMBER, numberFormat: '#,##0.0' },
{ name: 'wholEfntQnt', fieldName: 'wholEfntQnt', header: { text: '침출수' }, footer: footerDecimal1ArrayByDivision, valueType: ValueType.NUMBER, numberFormat: '#,##0.0' },
{ name: 'wholEtcQnt', fieldName: 'wholEtcQnt', header: { text: '기타' }, footer: footerDecimal1ArrayByDivision, valueType: ValueType.NUMBER, numberFormat: '#,##0.0' },
/* 7. 상세 시설 정보 */
{ name: 'TEL_NO', fieldName: '전화번호', header: { text: '전화번호' } },
{ name: 'COMP_DT', fieldName: '준공일', header: { text: '준공일' } },
{ name: 'RN_DT', fieldName: '가동개시일', header: { text: '가동개시일' } },
{ name: 'BSNS_CT', fieldName: '사업비(백만원)', header: { text: '사업비(백만원)' }, valueType: ValueType.NUMBER, numberFormat: '#,##0.0' },
{ name: 'DSCH_DSF_MTHD', fieldName: '방류수소독방법', header: { text: '방류수소독방법' } },
{ name: 'telNo', fieldName: 'telNo', header: { text: '전화번호' } },
{ name: 'cmptnStrtYmd', fieldName: 'cmptnStrtYmd', header: { text: '준공일' } },
{ name: 'oprtngStrtYmd', fieldName: 'oprtngStrtYmd', header: { text: '가동개시일' } },
{ name: 'wholBizVl', fieldName: 'wholBizVl', header: { text: '사업비' }, footer: footerDecimal1ArrayByDivision, valueType: ValueType.NUMBER, numberFormat: '#,##0.0' },
{ name: 'eflntDsnfMthdExpln', fieldName: 'eflntDsnfMthdExpln', header: { text: '방류수 소독방법' } },
/* 8. 방류수역 */
{ name: 'WTR_SYS', fieldName: '수계', header: { text: '수계' } },
{ name: 'STRM', fieldName: '지류', header: { text: '지류' } },
{ name: 'DET_UNIT_ZONE', fieldName: '세부단위구역', header: { text: '세부단위구역' } },
{ name: 'RGN_GBN', fieldName: '지역구분', header: { text: '지역구분' } },
{ name: 'MID_ZONE_NM', fieldName: '명칭', header: { text: '명칭' } },
{ name: 'MID_ZONE_BOD', fieldName: '목표수질(BOD)', header: { text: '목표수질(BOD)' } },
{ name: 'wtsysNm', fieldName: 'wtsysNm', header: { text: '수계' } },
{ name: 'trbtrNm', fieldName: 'trbtrNm', header: { text: '지류' } },
{ name: 'dtlUnitZoneCn', fieldName: 'dtlUnitZoneCn', header: { text: '세부단위구역' } },
{ name: 'rgnSeNm', fieldName: 'rgnSeNm', header: { text: '지역구분' } },
{ name: 'msirbCn', fieldName: 'msirbCn', header: { text: '명칭' } },
{ name: 'goalWqNo', fieldName: 'goalWqNo', header: { text: '목표수질(BOD)' } },
/* 9. 에너지 및 환경 */
{ name: 'ENG_SLF_RT', fieldName: '에너지자립율(%)', header: { text: '에너지자립율(%)' }, valueType: ValueType.NUMBER, numberFormat: '#,##0.0' },
{ name: 'ENG_SLF_QTY', fieldName: '에너지자체생산량(TOE)', header: { text: '에너지자체생산량(TOE)' }, valueType: ValueType.NUMBER, numberFormat: '#,##0.0' },
{ name: 'ENG_TOT_SUM', fieldName: '계(TOE)', header: { text: '계(TOE)' }, valueType: ValueType.NUMBER, numberFormat: '#,##0.0' },
{ name: 'ENG_TOT_PWR', fieldName: '총전력사용량(KWh)', header: { text: '총전력사용량(KWh)' }, valueType: ValueType.NUMBER, numberFormat: '#,##0.0' },
{ name: 'ENG_TOT_SLF', fieldName: '자체에너지사용량(TOE)', header: { text: '자체에너지사용량(TOE)' }, valueType: ValueType.NUMBER, numberFormat: '#,##0.0' },
{ name: 'CO2_EMISS', fieldName: '하수처리량당CO2배출량(KgCO2/m3)', header: { text: '하수처리량당CO2배출량(KgCO2/m3)' }, valueType: ValueType.NUMBER, numberFormat: '#,##0.000' },
{ name: 'engySrlneRt', fieldName: 'engySrlneRt', header: { text: '에너지자립률(%)' }, footer: footerAvgDecimalArrayByDivision, valueType: ValueType.NUMBER, numberFormat: '#,##0.0' },
{ name: 'engyItslfPrdctnQnt', fieldName: 'engyItslfPrdctnQnt', header: { text: '에너지자체생산량(TOE)' }, footer: footerDecimal1ArrayByDivision, valueType: ValueType.NUMBER, numberFormat: '#,##0.0' },
{ name: 'anlEngyWholUseQnt', fieldName: 'anlEngyWholUseQnt', header: { text: '계(TOE)' }, footer: footerDecimal1ArrayByDivision, valueType: ValueType.NUMBER, numberFormat: '#,##0.0' },
{ name: 'anlElpwrUseQnt', fieldName: 'anlElpwrUseQnt', header: { text: '총 전력사용량(kWh)' }, footer: footerDecimal1ArrayByDivision, valueType: ValueType.NUMBER, numberFormat: '#,##0.0' },
{ name: 'itslfEngyUseQnt', fieldName: 'itslfEngyUseQnt', header: { text: '자체 에너지사용량(TOE)' }, footer: footerDecimal1ArrayByDivision, valueType: ValueType.NUMBER, numberFormat: '#,##0.0' },
{ name: 'swtrmtEmsnQnt', fieldName: 'swtrmtEmsnQnt', header: { text: '하수처리량당 CO2배출량(kgCO2/m³)' }, footer: footerAvgDecimalArrayByDivision, valueType: ValueType.NUMBER, numberFormat: '#,##0.000' },
/* 10. 운영 및 위탁 */
{ name: 'OPR_MBY', fieldName: '운영주체', header: { text: '운영주체' } },
{ name: 'ENTRPS_NM', fieldName: '위탁업체명', header: { text: '위탁업체명' } },
{ name: 'ENTRPS_CT', fieldName: '위탁비용(백만원/년)', header: { text: '위탁비용(백만원/년)' }, valueType: ValueType.NUMBER, numberFormat: '#,##0.0' },
{ name: 'ENTRPS_TERM', fieldName: '위탁계약기간', header: { text: '위탁계약기간' } },
{ name: 'CTR_TRT_FCT', fieldName: '중심처리시설', header: { text: '중심처리시설' } },
{ name: 'EMP_CNT', fieldName: '직원총수(명)', header: { text: '직원총수(명)' }, valueType: ValueType.NUMBER, numberFormat: '#,##0' },
{ name: 'ACC_CNT', fieldName: '안전사고건수', header: { text: '안전사고건수' }, valueType: ValueType.NUMBER, numberFormat: '#,##0' },
{ name: 'RMK', fieldName: '비고', header: { text: '비고' } },
{ name: 'operMnbdCn', fieldName: 'operMnbdCn', header: { text: '운영주체(자체/공기업/민간대행)' } },
{ name: 'prxyBzentyNmCn', fieldName: 'prxyBzentyNmCn', header: { text: '위탁업체명' } },
{ name: 'cnsgnCstVl', fieldName: 'cnsgnCstVl', header: { text: '위탁비용(백만원/년)' }, footer: footerDecimal1ArrayByDivision, valueType: ValueType.NUMBER, numberFormat: '#,##0.0' },
{ name: 'prxyCtrtCn', fieldName: 'prxyCtrtCn', header: { text: '위탁계약기간' } },
{ name: 'intgPrcsFcltCn', fieldName: 'intgPrcsFcltCn', header: { text: '중심처리시설' } },
{ name: 'wholNope', fieldName: 'wholNope', header: { text: '직원총수(명)' }, footer: footerIntArrayByDivision, valueType: ValueType.NUMBER, numberFormat: '#,##0' },
{ name: 'sftyAcdntEvntcsCnt', fieldName: 'sftyAcdntEvntcsCnt', header: { text: '안전사고건수' }, footer: footerIntArrayByDivision, valueType: ValueType.NUMBER, numberFormat: '#,##0' },
{ name: 'rmrk', fieldName: 'rmrk', header: { text: '비고' } },
],
columnLayout: [
{ name: 'groupA', header: { text: '행정구역' }, items: ['sido', 'sgg'] },
'FCT_NM_GROUP', 'GBN', 'ADDR', 'FCT_CPC_GROUP',
{
name: 'groupA',
hideChildHeaders: false,
header: { text: '행정구역' },
items: [{ column: 'ctpvNm', footerUserSpans: [{ colspan: 3 }, { colspan: 3 }] }, 'sggNm'],
},
'displayYear', 'swtrmtFcltNm', 'impdFcltPstnCn', 'prcsFcltCpct',
{
name: 'groupB',
header: { text: '시설용량(㎥/일)' },
items: ['FCT_CPC_PHY', 'FCT_CPC_BIO', 'FCT_CPC_ADV'],
items: ['prcsFcltPhysCpct', 'prcsFcltBoilCpct', 'prcsFcltAltdCpct'],
},
'IN_QTY_GROUP',
'inflwSwageQnt',
{
name: 'groupC',
header: { text: '유입하수량(㎥/일)' },
items: ['IN_QTY_PHY', 'IN_QTY_BIO', 'IN_QTY_ADV'],
items: ['inflwSwagePhysQnt', 'inflwSwageBoilQnt', 'inflwSwageAltdQnt'],
},
'OUT_QTY_GROUP',
'prcsQnt',
{
name: 'groupD',
header: { text: '방류량(㎥/일)' },
items: ['OUT_QTY_PHY', 'OUT_QTY_BIO', 'OUT_QTY_ADV'],
items: ['prcsPhysCpct', 'prcsBoilCpct', 'prcsAltdCpct'],
},
'IN_BOD', 'OUT_BOD', 'IN_TOC', 'OUT_TOC', 'IN_SS', 'OUT_SS', 'IN_TN', 'OUT_TN', 'IN_TP', 'OUT_TP', 'IN_COLI', 'OUT_COLI',
'TRT_EFF', 'TRT_LOD', 'TRT_MTHD',
'inflwBodVl', 'dschgBodVl', 'inflwTocQnt', 'dschgTocQnt', 'inflwSsQnt', 'dschgSsQnt', 'inflwTnVl', 'dschgTnVl', 'inflwTpVl', 'dschgTpVl', 'inflwGrssClfgVl', 'dschgGrssClfgVl',
'prcsRt', 'prcsLodQnt', 'prcsMthdExpln',
{
name: 'groupE',
header: { text: '연계처리량(㎥/일)' },
items: ['LNK_EXC', 'LNK_LVS', 'LNK_LCH', 'LNK_ETC'],
items: ['wholHmnQnt', 'wholLvstQnt', 'wholEfntQnt', 'wholEtcQnt'],
},
'TEL_NO', 'COMP_DT', 'RN_DT', 'BSNS_CT', 'DSCH_DSF_MTHD',
'telNo', 'cmptnStrtYmd', 'oprtngStrtYmd', 'wholBizVl', 'eflntDsnfMthdExpln',
{
name: 'groupF',
header: { text: '방류수역' },
items: ['WTR_SYS', 'STRM', 'DET_UNIT_ZONE', 'RGN_GBN',
items: ['wtsysNm', 'trbtrNm', 'dtlUnitZoneCn', 'rgnSeNm',
{
name: 'groupG',
header: { text: '중권역' },
items: ['MID_ZONE_NM', 'MID_ZONE_BOD'],
items: ['msirbCn', 'goalWqNo'],
},
],
},
'ENG_SLF_RT', 'ENG_SLF_QTY',
'engySrlneRt', 'engyItslfPrdctnQnt',
{
name: 'groupH',
header: { text: '연간 총 에너지 사용량(TOE)' },
items: ['ENG_TOT_SUM', 'ENG_TOT_PWR', 'ENG_TOT_SLF'],
items: ['anlEngyWholUseQnt', 'anlElpwrUseQnt', 'itslfEngyUseQnt'],
},
'CO2_EMISS', 'OPR_MBY',
'swtrmtEmsnQnt', 'operMnbdCn',
{
name: 'groupI',
header: { text: '위탁현황' },
items: ['ENTRPS_NM', 'ENTRPS_CT', 'ENTRPS_TERM'],
items: ['prxyBzentyNmCn', 'cnsgnCstVl', 'prxyCtrtCn'],
},
'CTR_TRT_FCT', 'EMP_CNT', 'ACC_CNT', 'RMK'
]
'intgPrcsFcltCn', 'wholNope', 'sftyAcdntEvntcsCnt', 'rmrk'
],
fixedColumns: 3,
rowStartNum: 3
};
// 최종 Export 객체 (일괄 editable: false 적용)
@@ -144,6 +192,7 @@ export const PublicSewageFacilityInfoList = {
...rawPublicSewageFacilityInfoList,
columns: rawPublicSewageFacilityInfoList.columns.map(col => ({
...col,
editable: false
editable: false,
styleName: "rg-cell-readonly"
}))
};
@@ -12,7 +12,7 @@ import {
const currentYear = dayjs().year() - 1;
const startYear = 1996;
const totalCount = currentYear - startYear + 1 + 1;
const totalCount = currentYear - startYear + 1;
// 하수도 통계 관리 02. 하수관로
const rawList = [
@@ -398,7 +398,7 @@ const rawList = [
...Array.from({ length: totalCount }, (_, i) => {
const dtoNum = totalCount - i;
const dtoNum = i + 1;
const wholField = `yy${dtoNum}PrlgWhol`;
const lyngField = `yy${dtoNum}LyngPrlg`;
@@ -426,7 +426,8 @@ const rawList = [
{ name: "summaryGroup", header: { text: "총계" }, items: ["pipeByPrstWhol", "lyngPrlg", "dmltnPrlg"] },
...Array.from({ length: totalCount }, (_, i) => {
const dtoNum = totalCount - i;
// const dtoNum = totalCount - i;
const dtoNum = i + 1;
const yearLabel = i === 0 ? "1996년이전" : `${startYear + (i - 1)}년`;
return {
@@ -550,7 +551,311 @@ const rawList = [
'etcPdmtPrlg',
'rmrk'
]
}
},
{
tabKey: "ST00020005",
tabLabel: "맨홀 현황",
rowStartNum: 3,
fixedColumns: 3,
columns: [
{
name: "ctpvNm",
fieldName: "ctpvNm",
header: { text: "시도" },
mergeCells: true,
sortdisable: true,
footer: [
{
valueCallback: (grid: GridView) => {
const divisionIndex = 0;
const dataSource = grid.getDataSource();
const uniqueValues = new Set<string>();
for (let i = 0; i < dataSource.getRowCount(); i++) {
const val = dataSource.getValue(i, "displayYear");
if (val) uniqueValues.add(String(val));
}
const divisionValues = Array.from(uniqueValues);
return divisionValues.length > divisionIndex ? divisionValues[divisionIndex] + "합계" : "합계";
}
},
{
valueCallback: (grid: GridView) => {
const divisionIndex = 1;
const dataSource = grid.getDataSource();
const uniqueValues = new Set<string>();
for (let i = 0; i < dataSource.getRowCount(); i++) {
const val = dataSource.getValue(i, "displayYear");
if (val) uniqueValues.add(String(val));
}
const divisionValues = Array.from(uniqueValues);
return divisionValues.length > divisionIndex ? divisionValues[divisionIndex] + "합계" : "합계";
}
}
]
},
{ name: "sggNm", fieldName: "sggNm", header: { text: "시군구" }, mergeCells: true, sortdisable: true },
{ name: "admdstNm", fieldName: "admdstNm", header: { text: "행정구역명" }, mergeCells: true, sortdisable: true },
{ name: "emphsMngSeNm", fieldName: "emphsMngSeNm", header: { text: "집중강우\n중점관리\n구역구분" }, mergeCells: true },
{
name: 'displayYear',
fieldName: 'displayYear',
header: { text: '구분' },
sortdisable: true,
footer: [
{
valueCallback: (grid: GridView) => {
const divisionIndex = 0;
const dataSource = grid.getDataSource();
const uniqueValues = new Set<string>();
for (let i = 0; i < dataSource.getRowCount(); i++) {
const val = dataSource.getValue(i, 'displayYear');
if (val) uniqueValues.add(String(val));
}
const divisionValues = Array.from(uniqueValues);
return divisionValues.length > divisionIndex ? divisionValues[divisionIndex] + '합계' : '';
}
},
{
valueCallback: (grid: GridView) => {
const divisionIndex = 1;
const dataSource = grid.getDataSource();
const uniqueValues = new Set<string>();
for (let i = 0; i < dataSource.getRowCount(); i++) {
const val = dataSource.getValue(i, 'displayYear');
if (val) uniqueValues.add(String(val));
}
const divisionValues = Array.from(uniqueValues);
return divisionValues.length > divisionIndex ? divisionValues[divisionIndex] + '합계' : '';
}
}
]
},
{ name: "totalWholMnhlCnt", fieldName: "totalWholMnhlCnt", header: { text: "계" }, footer: footerIntArrayByDivision, valueType: ValueType.NUMBER, dataFormat: "#,##0", editable: false },
{ name: "totalStep1MnhlCnt", fieldName: "totalStep1MnhlCnt", header: { text: "주철" }, footer: footerIntArrayByDivision, valueType: ValueType.NUMBER, dataFormat: "#,##0", editable: false },
{ name: "totalStep2MnhlCnt", fieldName: "totalStep2MnhlCnt", header: { text: "주철(조화)" }, footer: footerIntArrayByDivision, valueType: ValueType.NUMBER, dataFormat: "#,##0", editable: false },
{ name: "totalStep3MnhlCnt", fieldName: "totalStep3MnhlCnt", header: { text: "콘크리트" }, footer: footerIntArrayByDivision, valueType: ValueType.NUMBER, dataFormat: "#,##0", editable: false },
{ name: "totalStep4MnhlCnt", fieldName: "totalStep4MnhlCnt", header: { text: "기타" }, footer: footerIntArrayByDivision, valueType: ValueType.NUMBER, dataFormat: "#,##0", editable: false },
{ name: "totalStep5MnhlCnt", fieldName: "totalStep5MnhlCnt", header: { text: "추락방지시설\n설치맨홀(개)" }, footer: footerIntArrayByDivision, valueType: ValueType.NUMBER, dataFormat: "#,##0", editable: false },
// ── 2015년 이전 (yy14) ──────────────────────────────────
{ name: "yy14WholMnhlCnt", fieldName: "yy14WholMnhlCnt", header: { text: "계" }, footer: footerIntArrayByDivision, valueType: ValueType.NUMBER, dataFormat: "#,##0", editable: false },
{ name: "yy14Step1MnhlCnt", fieldName: "yy14Step1MnhlCnt", header: { text: "주철" }, footer: footerIntArrayByDivision, valueType: ValueType.NUMBER, dataFormat: "#,##0", editable: false },
{ name: "yy14Step2MnhlCnt", fieldName: "yy14Step2MnhlCnt", header: { text: "주철(조화)" }, footer: footerIntArrayByDivision, valueType: ValueType.NUMBER, dataFormat: "#,##0", editable: false },
{ name: "yy14Step3MnhlCnt", fieldName: "yy14Step3MnhlCnt", header: { text: "콘크리트" }, footer: footerIntArrayByDivision, valueType: ValueType.NUMBER, dataFormat: "#,##0", editable: false },
{ name: "yy14Step4MnhlCnt", fieldName: "yy14Step4MnhlCnt", header: { text: "기타" }, footer: footerIntArrayByDivision, valueType: ValueType.NUMBER, dataFormat: "#,##0", editable: false },
{ name: "yy14Step5MnhlCnt", fieldName: "yy14Step5MnhlCnt", header: { text: "추락방지시설\n설치맨홀(개)" }, footer: footerIntArrayByDivision, valueType: ValueType.NUMBER, dataFormat: "#,##0", editable: false },
// ── 2015년 (yy15) ───────────────────────────────────────
{ name: "yy15WholMnhlCnt", fieldName: "yy15WholMnhlCnt", header: { text: "계" }, footer: footerIntArrayByDivision, valueType: ValueType.NUMBER, dataFormat: "#,##0", editable: false },
{ name: "yy15Step1MnhlCnt", fieldName: "yy15Step1MnhlCnt", header: { text: "주철" }, footer: footerIntArrayByDivision, valueType: ValueType.NUMBER, dataFormat: "#,##0", editable: false },
{ name: "yy15Step2MnhlCnt", fieldName: "yy15Step2MnhlCnt", header: { text: "주철(조화)" }, footer: footerIntArrayByDivision, valueType: ValueType.NUMBER, dataFormat: "#,##0", editable: false },
{ name: "yy15Step3MnhlCnt", fieldName: "yy15Step3MnhlCnt", header: { text: "콘크리트" }, footer: footerIntArrayByDivision, valueType: ValueType.NUMBER, dataFormat: "#,##0", editable: false },
{ name: "yy15Step4MnhlCnt", fieldName: "yy15Step4MnhlCnt", header: { text: "기타" }, footer: footerIntArrayByDivision, valueType: ValueType.NUMBER, dataFormat: "#,##0", editable: false },
{ name: "yy15Step5MnhlCnt", fieldName: "yy15Step5MnhlCnt", header: { text: "추락방지시설\n설치맨홀(개)" }, footer: footerIntArrayByDivision, valueType: ValueType.NUMBER, dataFormat: "#,##0", editable: false },
// ── 2016년 (yy16) ───────────────────────────────────────
{ name: "yy16WholMnhlCnt", fieldName: "yy16WholMnhlCnt", header: { text: "계" }, footer: footerIntArrayByDivision, valueType: ValueType.NUMBER, dataFormat: "#,##0", editable: false },
{ name: "yy16Step1MnhlCnt", fieldName: "yy16Step1MnhlCnt", header: { text: "주철" }, footer: footerIntArrayByDivision, valueType: ValueType.NUMBER, dataFormat: "#,##0", editable: false },
{ name: "yy16Step2MnhlCnt", fieldName: "yy16Step2MnhlCnt", header: { text: "주철(조화)" }, footer: footerIntArrayByDivision, valueType: ValueType.NUMBER, dataFormat: "#,##0", editable: false },
{ name: "yy16Step3MnhlCnt", fieldName: "yy16Step3MnhlCnt", header: { text: "콘크리트" }, footer: footerIntArrayByDivision, valueType: ValueType.NUMBER, dataFormat: "#,##0", editable: false },
{ name: "yy16Step4MnhlCnt", fieldName: "yy16Step4MnhlCnt", header: { text: "기타" }, footer: footerIntArrayByDivision, valueType: ValueType.NUMBER, dataFormat: "#,##0", editable: false },
{ name: "yy16Step5MnhlCnt", fieldName: "yy16Step5MnhlCnt", header: { text: "추락방지시설\n설치맨홀(개)" }, footer: footerIntArrayByDivision, valueType: ValueType.NUMBER, dataFormat: "#,##0", editable: false },
// ── 2017년 (yy17) ───────────────────────────────────────
{ name: "yy17WholMnhlCnt", fieldName: "yy17WholMnhlCnt", header: { text: "계" }, footer: footerIntArrayByDivision, valueType: ValueType.NUMBER, dataFormat: "#,##0", editable: false },
{ name: "yy17Step1MnhlCnt", fieldName: "yy17Step1MnhlCnt", header: { text: "주철" }, footer: footerIntArrayByDivision, valueType: ValueType.NUMBER, dataFormat: "#,##0", editable: false },
{ name: "yy17Step2MnhlCnt", fieldName: "yy17Step2MnhlCnt", header: { text: "주철(조화)" }, footer: footerIntArrayByDivision, valueType: ValueType.NUMBER, dataFormat: "#,##0", editable: false },
{ name: "yy17Step3MnhlCnt", fieldName: "yy17Step3MnhlCnt", header: { text: "콘크리트" }, footer: footerIntArrayByDivision, valueType: ValueType.NUMBER, dataFormat: "#,##0", editable: false },
{ name: "yy17Step4MnhlCnt", fieldName: "yy17Step4MnhlCnt", header: { text: "기타" }, footer: footerIntArrayByDivision, valueType: ValueType.NUMBER, dataFormat: "#,##0", editable: false },
{ name: "yy17Step5MnhlCnt", fieldName: "yy17Step5MnhlCnt", header: { text: "추락방지시설\n설치맨홀(개)" }, footer: footerIntArrayByDivision, valueType: ValueType.NUMBER, dataFormat: "#,##0", editable: false },
// ── 2018년 (yy18) ───────────────────────────────────────
{ name: "yy18WholMnhlCnt", fieldName: "yy18WholMnhlCnt", header: { text: "계" }, footer: footerIntArrayByDivision, valueType: ValueType.NUMBER, dataFormat: "#,##0", editable: false },
{ name: "yy18Step1MnhlCnt", fieldName: "yy18Step1MnhlCnt", header: { text: "주철" }, footer: footerIntArrayByDivision, valueType: ValueType.NUMBER, dataFormat: "#,##0", editable: false },
{ name: "yy18Step2MnhlCnt", fieldName: "yy18Step2MnhlCnt", header: { text: "주철(조화)" }, footer: footerIntArrayByDivision, valueType: ValueType.NUMBER, dataFormat: "#,##0", editable: false },
{ name: "yy18Step3MnhlCnt", fieldName: "yy18Step3MnhlCnt", header: { text: "콘크리트" }, footer: footerIntArrayByDivision, valueType: ValueType.NUMBER, dataFormat: "#,##0", editable: false },
{ name: "yy18Step4MnhlCnt", fieldName: "yy18Step4MnhlCnt", header: { text: "기타" }, footer: footerIntArrayByDivision, valueType: ValueType.NUMBER, dataFormat: "#,##0", editable: false },
{ name: "yy18Step5MnhlCnt", fieldName: "yy18Step5MnhlCnt", header: { text: "추락방지시설\n설치맨홀(개)" }, footer: footerIntArrayByDivision, valueType: ValueType.NUMBER, dataFormat: "#,##0", editable: false },
// ── 2019년 (yy19) ───────────────────────────────────────
{ name: "yy19WholMnhlCnt", fieldName: "yy19WholMnhlCnt", header: { text: "계" }, footer: footerIntArrayByDivision, valueType: ValueType.NUMBER, dataFormat: "#,##0", editable: false },
{ name: "yy19Step1MnhlCnt", fieldName: "yy19Step1MnhlCnt", header: { text: "주철" }, footer: footerIntArrayByDivision, valueType: ValueType.NUMBER, dataFormat: "#,##0", editable: false },
{ name: "yy19Step2MnhlCnt", fieldName: "yy19Step2MnhlCnt", header: { text: "주철(조화)" }, footer: footerIntArrayByDivision, valueType: ValueType.NUMBER, dataFormat: "#,##0", editable: false },
{ name: "yy19Step3MnhlCnt", fieldName: "yy19Step3MnhlCnt", header: { text: "콘크리트" }, footer: footerIntArrayByDivision, valueType: ValueType.NUMBER, dataFormat: "#,##0", editable: false },
{ name: "yy19Step4MnhlCnt", fieldName: "yy19Step4MnhlCnt", header: { text: "기타" }, footer: footerIntArrayByDivision, valueType: ValueType.NUMBER, dataFormat: "#,##0", editable: false },
{ name: "yy19Step5MnhlCnt", fieldName: "yy19Step5MnhlCnt", header: { text: "추락방지시설\n설치맨홀(개)" }, footer: footerIntArrayByDivision, valueType: ValueType.NUMBER, dataFormat: "#,##0", editable: false },
// ── 2020년 (yy20) ───────────────────────────────────────
{ name: "yy20WholMnhlCnt", fieldName: "yy20WholMnhlCnt", header: { text: "계" }, footer: footerIntArrayByDivision, valueType: ValueType.NUMBER, dataFormat: "#,##0", editable: false },
{ name: "yy20Step1MnhlCnt", fieldName: "yy20Step1MnhlCnt", header: { text: "주철" }, footer: footerIntArrayByDivision, valueType: ValueType.NUMBER, dataFormat: "#,##0", editable: false },
{ name: "yy20Step2MnhlCnt", fieldName: "yy20Step2MnhlCnt", header: { text: "주철(조화)" }, footer: footerIntArrayByDivision, valueType: ValueType.NUMBER, dataFormat: "#,##0", editable: false },
{ name: "yy20Step3MnhlCnt", fieldName: "yy20Step3MnhlCnt", header: { text: "콘크리트" }, footer: footerIntArrayByDivision, valueType: ValueType.NUMBER, dataFormat: "#,##0", editable: false },
{ name: "yy20Step4MnhlCnt", fieldName: "yy20Step4MnhlCnt", header: { text: "기타" }, footer: footerIntArrayByDivision, valueType: ValueType.NUMBER, dataFormat: "#,##0", editable: false },
{ name: "yy20Step5MnhlCnt", fieldName: "yy20Step5MnhlCnt", header: { text: "추락방지시설\n설치맨홀(개)" }, footer: footerIntArrayByDivision, valueType: ValueType.NUMBER, dataFormat: "#,##0", editable: false },
// ── 2021년 (yy21) ───────────────────────────────────────
{ name: "yy21WholMnhlCnt", fieldName: "yy21WholMnhlCnt", header: { text: "계" }, footer: footerIntArrayByDivision, valueType: ValueType.NUMBER, dataFormat: "#,##0", editable: false },
{ name: "yy21Step1MnhlCnt", fieldName: "yy21Step1MnhlCnt", header: { text: "주철" }, footer: footerIntArrayByDivision, valueType: ValueType.NUMBER, dataFormat: "#,##0", editable: false },
{ name: "yy21Step2MnhlCnt", fieldName: "yy21Step2MnhlCnt", header: { text: "주철(조화)" }, footer: footerIntArrayByDivision, valueType: ValueType.NUMBER, dataFormat: "#,##0", editable: false },
{ name: "yy21Step3MnhlCnt", fieldName: "yy21Step3MnhlCnt", header: { text: "콘크리트" }, footer: footerIntArrayByDivision, valueType: ValueType.NUMBER, dataFormat: "#,##0", editable: false },
{ name: "yy21Step4MnhlCnt", fieldName: "yy21Step4MnhlCnt", header: { text: "기타" }, footer: footerIntArrayByDivision, valueType: ValueType.NUMBER, dataFormat: "#,##0", editable: false },
{ name: "yy21Step5MnhlCnt", fieldName: "yy21Step5MnhlCnt", header: { text: "추락방지시설\n설치맨홀(개)" }, footer: footerIntArrayByDivision, valueType: ValueType.NUMBER, dataFormat: "#,##0", editable: false },
// ── 2022년 (yy22) ───────────────────────────────────────
{ name: "yy22WholMnhlCnt", fieldName: "yy22WholMnhlCnt", header: { text: "계" }, footer: footerIntArrayByDivision, valueType: ValueType.NUMBER, dataFormat: "#,##0", editable: false },
{ name: "yy22Step1MnhlCnt", fieldName: "yy22Step1MnhlCnt", header: { text: "주철" }, footer: footerIntArrayByDivision, valueType: ValueType.NUMBER, dataFormat: "#,##0", editable: false },
{ name: "yy22Step2MnhlCnt", fieldName: "yy22Step2MnhlCnt", header: { text: "주철(조화)" }, footer: footerIntArrayByDivision, valueType: ValueType.NUMBER, dataFormat: "#,##0", editable: false },
{ name: "yy22Step3MnhlCnt", fieldName: "yy22Step3MnhlCnt", header: { text: "콘크리트" }, footer: footerIntArrayByDivision, valueType: ValueType.NUMBER, dataFormat: "#,##0", editable: false },
{ name: "yy22Step4MnhlCnt", fieldName: "yy22Step4MnhlCnt", header: { text: "기타" }, footer: footerIntArrayByDivision, valueType: ValueType.NUMBER, dataFormat: "#,##0", editable: false },
{ name: "yy22Step5MnhlCnt", fieldName: "yy22Step5MnhlCnt", header: { text: "추락방지시설\n설치맨홀(개)" }, footer: footerIntArrayByDivision, valueType: ValueType.NUMBER, dataFormat: "#,##0", editable: false },
// ── 2023년 (yy23) ───────────────────────────────────────
{ name: "yy23WholMnhlCnt", fieldName: "yy23WholMnhlCnt", header: { text: "계" }, footer: footerIntArrayByDivision, valueType: ValueType.NUMBER, dataFormat: "#,##0", editable: false },
{ name: "yy23Step1MnhlCnt", fieldName: "yy23Step1MnhlCnt", header: { text: "주철" }, footer: footerIntArrayByDivision, valueType: ValueType.NUMBER, dataFormat: "#,##0", editable: false },
{ name: "yy23Step2MnhlCnt", fieldName: "yy23Step2MnhlCnt", header: { text: "주철(조화)" }, footer: footerIntArrayByDivision, valueType: ValueType.NUMBER, dataFormat: "#,##0", editable: false },
{ name: "yy23Step3MnhlCnt", fieldName: "yy23Step3MnhlCnt", header: { text: "콘크리트" }, footer: footerIntArrayByDivision, valueType: ValueType.NUMBER, dataFormat: "#,##0", editable: false },
{ name: "yy23Step4MnhlCnt", fieldName: "yy23Step4MnhlCnt", header: { text: "기타" }, footer: footerIntArrayByDivision, valueType: ValueType.NUMBER, dataFormat: "#,##0", editable: false },
{ name: "yy23Step5MnhlCnt", fieldName: "yy23Step5MnhlCnt", header: { text: "추락방지시설\n설치맨홀(개)" }, footer: footerIntArrayByDivision, valueType: ValueType.NUMBER, dataFormat: "#,##0", editable: false },
// ── 2024년 (yy24) ───────────────────────────────────────
{ name: "yy24WholMnhlCnt", fieldName: "yy24WholMnhlCnt", header: { text: "계" }, footer: footerIntArrayByDivision, valueType: ValueType.NUMBER, dataFormat: "#,##0", editable: false },
{ name: "yy24Step1MnhlCnt", fieldName: "yy24Step1MnhlCnt", header: { text: "주철" }, footer: footerIntArrayByDivision, valueType: ValueType.NUMBER, dataFormat: "#,##0", editable: false },
{ name: "yy24Step2MnhlCnt", fieldName: "yy24Step2MnhlCnt", header: { text: "주철(조화)" }, footer: footerIntArrayByDivision, valueType: ValueType.NUMBER, dataFormat: "#,##0", editable: false },
{ name: "yy24Step3MnhlCnt", fieldName: "yy24Step3MnhlCnt", header: { text: "콘크리트" }, footer: footerIntArrayByDivision, valueType: ValueType.NUMBER, dataFormat: "#,##0", editable: false },
{ name: "yy24Step4MnhlCnt", fieldName: "yy24Step4MnhlCnt", header: { text: "기타" }, footer: footerIntArrayByDivision, valueType: ValueType.NUMBER, dataFormat: "#,##0", editable: false },
{ name: "yy24Step5MnhlCnt", fieldName: "yy24Step5MnhlCnt", header: { text: "추락방지시설\n설치맨홀(개)" }, footer: footerIntArrayByDivision, valueType: ValueType.NUMBER, dataFormat: "#,##0", editable: false },
// ── 2025년 (yy25) ───────────────────────────────────────
{ name: "yy25WholMnhlCnt", fieldName: "yy25WholMnhlCnt", header: { text: "계" }, footer: footerIntArrayByDivision, valueType: ValueType.NUMBER, dataFormat: "#,##0", editable: false },
{ name: "yy25Step1MnhlCnt", fieldName: "yy25Step1MnhlCnt", header: { text: "주철" }, footer: footerIntArrayByDivision, valueType: ValueType.NUMBER, dataFormat: "#,##0", editable: false },
{ name: "yy25Step2MnhlCnt", fieldName: "yy25Step2MnhlCnt", header: { text: "주철(조화)" }, footer: footerIntArrayByDivision, valueType: ValueType.NUMBER, dataFormat: "#,##0", editable: false },
{ name: "yy25Step3MnhlCnt", fieldName: "yy25Step3MnhlCnt", header: { text: "콘크리트" }, footer: footerIntArrayByDivision, valueType: ValueType.NUMBER, dataFormat: "#,##0", editable: false },
{ name: "yy25Step4MnhlCnt", fieldName: "yy25Step4MnhlCnt", header: { text: "기타" }, footer: footerIntArrayByDivision, valueType: ValueType.NUMBER, dataFormat: "#,##0", editable: false },
{ name: "yy25Step5MnhlCnt", fieldName: "yy25Step5MnhlCnt", header: { text: "추락방지시설\n설치맨홀(개)" }, footer: footerIntArrayByDivision, valueType: ValueType.NUMBER, dataFormat: "#,##0", editable: false },
{ name: "rmrk", fieldName: "rmrk", header: { text: "비고" }, editable: false },
],
columnLayout: [
{
name: "addrGroup",
header: { text: "행정구역" },
items: [
{ column: "ctpvNm", footerUserSpans: [{ colspan: 5 }, { colspan: 5 }] },
"sggNm",
"admdstNm",
],
},
"emphsMngSeNm",
"displayYear",
{
name: "totGroup",
header: { text: "총계" },
items: [
{ name: "totMtrlGroup", header: { text: "맨홀뚜껑 재질(개)" }, items: ["totalWholMnhlCnt", "totalStep1MnhlCnt", "totalStep2MnhlCnt", "totalStep3MnhlCnt", "totalStep4MnhlCnt"] },
"totalStep5MnhlCnt",
],
},
{
name: "yy14Group",
header: { text: "2015년 이전" }, // ← _14Y
items: [
{ name: "yy14MtrlGroup", header: { text: "맨홀뚜껑 재질(개)" }, items: ["yy14WholMnhlCnt", "yy14Step1MnhlCnt", "yy14Step2MnhlCnt", "yy14Step3MnhlCnt", "yy14Step4MnhlCnt"] },
"yy14Step5MnhlCnt",
],
},
{
name: "yy15Group",
header: { text: "2015년" }, // ← _15Y
items: [
{ name: "yy15MtrlGroup", header: { text: "맨홀뚜껑 재질(개)" }, items: ["yy15WholMnhlCnt", "yy15Step1MnhlCnt", "yy15Step2MnhlCnt", "yy15Step3MnhlCnt", "yy15Step4MnhlCnt"] },
"yy15Step5MnhlCnt",
],
},
{
name: "yy16Group",
header: { text: "2016년" }, // ← _16Y
items: [
{ name: "yy16MtrlGroup", header: { text: "맨홀뚜껑 재질(개)" }, items: ["yy16WholMnhlCnt", "yy16Step1MnhlCnt", "yy16Step2MnhlCnt", "yy16Step3MnhlCnt", "yy16Step4MnhlCnt"] },
"yy16Step5MnhlCnt",
],
},
{
name: "yy17Group",
header: { text: "2017년" },
items: [
{ name: "yy17MtrlGroup", header: { text: "맨홀뚜껑 재질(개)" }, items: ["yy17WholMnhlCnt", "yy17Step1MnhlCnt", "yy17Step2MnhlCnt", "yy17Step3MnhlCnt", "yy17Step4MnhlCnt"] },
"yy17Step5MnhlCnt",
],
},
{
name: "yy18Group",
header: { text: "2018년" },
items: [
{ name: "yy18MtrlGroup", header: { text: "맨홀뚜껑 재질(개)" }, items: ["yy18WholMnhlCnt", "yy18Step1MnhlCnt", "yy18Step2MnhlCnt", "yy18Step3MnhlCnt", "yy18Step4MnhlCnt"] },
"yy18Step5MnhlCnt",
],
},
{
name: "yy19Group",
header: { text: "2019년" },
items: [
{ name: "yy19MtrlGroup", header: { text: "맨홀뚜껑 재질(개)" }, items: ["yy19WholMnhlCnt", "yy19Step1MnhlCnt", "yy19Step2MnhlCnt", "yy19Step3MnhlCnt", "yy19Step4MnhlCnt"] },
"yy19Step5MnhlCnt",
],
},
{
name: "yy20Group",
header: { text: "2020년" },
items: [
{ name: "yy20MtrlGroup", header: { text: "맨홀뚜껑 재질(개)" }, items: ["yy20WholMnhlCnt", "yy20Step1MnhlCnt", "yy20Step2MnhlCnt", "yy20Step3MnhlCnt", "yy20Step4MnhlCnt"] },
"yy20Step5MnhlCnt",
],
},
{
name: "yy21Group",
header: { text: "2021년" },
items: [
{ name: "yy21MtrlGroup", header: { text: "맨홀뚜껑 재질(개)" }, items: ["yy21WholMnhlCnt", "yy21Step1MnhlCnt", "yy21Step2MnhlCnt", "yy21Step3MnhlCnt", "yy21Step4MnhlCnt"] },
"yy21Step5MnhlCnt",
],
},
{
name: "yy22Group",
header: { text: "2022년" },
items: [
{ name: "yy22MtrlGroup", header: { text: "맨홀뚜껑 재질(개)" }, items: ["yy22WholMnhlCnt", "yy22Step1MnhlCnt", "yy22Step2MnhlCnt", "yy22Step3MnhlCnt", "yy22Step4MnhlCnt"] },
"yy22Step5MnhlCnt",
],
},
{
name: "yy23Group",
header: { text: "2023년" },
items: [
{ name: "yy23MtrlGroup", header: { text: "맨홀뚜껑 재질(개)" }, items: ["yy23WholMnhlCnt", "yy23Step1MnhlCnt", "yy23Step2MnhlCnt", "yy23Step3MnhlCnt", "yy23Step4MnhlCnt"] },
"yy23Step5MnhlCnt",
],
},
{
name: "yy24Group",
header: { text: "2024년" },
items: [
{ name: "yy24MtrlGroup", header: { text: "맨홀뚜껑 재질(개)" }, items: ["yy24WholMnhlCnt", "yy24Step1MnhlCnt", "yy24Step2MnhlCnt", "yy24Step3MnhlCnt", "yy24Step4MnhlCnt"] },
"yy24Step5MnhlCnt",
],
},
{
name: "yy25Group",
header: { text: "2025년" },
items: [
{ name: "yy25MtrlGroup", header: { text: "맨홀뚜껑 재질(개)" }, items: ["yy25WholMnhlCnt", "yy25Step1MnhlCnt", "yy25Step2MnhlCnt", "yy25Step3MnhlCnt", "yy25Step4MnhlCnt"] },
"yy25Step5MnhlCnt",
],
},
"rmrk",
],
},
];
export const SewagePipeColumnInfoListRvw = rawList.map(tab => ({
@@ -9,6 +9,13 @@ const StatsInputStatusInfoColumnRaw = {
{ name: 'sewerBzmnCd', fieldName: 'sewerBzmnCd', header: { text: '사업자코드' }, visible: false },
{ name: 'statsCd', fieldName: 'statsCd', header: { text: '메뉴코드' }, visible: false },
{ name: 'tabNm', fieldName: 'tabNm', header: { text: '구분' }, width: 220 },
{
name: 'reviewHistory',
fieldName: 'reviewHistory',
header: { text: '검토이력' },
width: 118,
styleName: 'rg-cell-outline-action-btn',
},
{ name: 'chargerNm', fieldName: 'chargerNm', header: { text: '제출자' } },
{ name: 'chargerCttpc', fieldName: 'chargerCttpc', header: { text: '연락처' } },
@@ -22,6 +29,7 @@ const StatsInputStatusInfoColumnRaw = {
'ctpvNm',
'sggNm',
'tabNm',
'reviewHistory',
'chargerNm',
'chargerCttpc',
'submitStatusNm',
@@ -38,6 +46,5 @@ export const StatsInputStatusInfoList = {
columns: StatsInputStatusInfoColumnRaw.columns.map(col => ({
...col,
editable: false,
styleName: "rg-cell-readonly"
}))
};
@@ -1,5 +1,7 @@
import { ValueType } from "realgrid";
import type { ColumnInfoList } from "@/components/type/DataGrid.type";
/**
* 하수도 통계 관리 > 02. 하수관로 (합계)
* 항목은 검토(SewagePipeRvwColumn)와 동일하나 footer·mergeCells·구분(displayYear)·비고(rmrk) 제외.
@@ -7,7 +9,7 @@ import { ValueType } from "realgrid";
*/
/** ST00020003 관종별: 입력 `SeweragePipeInputPipeTypeStatusItem`·합계 `SewagePipePipeTypeFacilityStatusItem`과 동일 (yy1=1996년 이전 … yy30) */
const YY_PIPE_TYPE_FACILITY_MAX = 30;
// const YY_PIPE_TYPE_FACILITY_MAX = 30;
const rawSewagePipeTotList = [
{
@@ -605,103 +607,6 @@ const rawSewagePipeTotList = [
},
],
},
{
tabKey: "ST00020003",
tabLabel: "관종별 현황",
rowStartNum: 2,
fixedColumns: 2,
columns: [
{ name: "ctpvNm", fieldName: "ctpvNm", header: { text: "시도" }, editable: false },
{ name: "sggNm", fieldName: "sggNm", header: { text: "시군구" }, editable: false },
{ name: "admdstNm", fieldName: "admdstNm", header: { text: "행정구역명" }, editable: false },
{ name: "swrgPipeNm", fieldName: "swrgPipeNm", header: { text: "관종" }, editable: false },
{
name: "pipeByPrstWholSum",
fieldName: "pipeByPrstWhol",
header: { text: "계" },
valueType: ValueType.NUMBER,
dataFormat: "#,##0.0",
editable: false,
},
{
name: "lyngPrlgSum",
fieldName: "lyngPrlg",
header: { text: "매설연장(m)" },
valueType: ValueType.NUMBER,
dataFormat: "#,##0.0",
editable: false,
},
{
name: "dmltnPrlgSum",
fieldName: "dmltnPrlg",
header: { text: "철거연장(m)" },
valueType: ValueType.NUMBER,
dataFormat: "#,##0.0",
editable: false,
},
...Array.from({ length: YY_PIPE_TYPE_FACILITY_MAX }, (_, i) => {
const yyIdx = i + 1;
const wholField = `yy${yyIdx}PrlgWhol`;
const lyngField = `yy${yyIdx}LyngPrlg`;
const dmltnField = `yy${yyIdx}DmltnPrlg`;
return [
{
name: wholField,
fieldName: wholField,
header: { text: "계" },
valueType: ValueType.NUMBER,
dataFormat: "#,##0.0",
editable: false,
},
{
name: lyngField,
fieldName: lyngField,
header: { text: "매설연장(m)" },
valueType: ValueType.NUMBER,
dataFormat: "#,##0.0",
editable: false,
},
{
name: dmltnField,
fieldName: dmltnField,
header: { text: "철거연장(m)" },
valueType: ValueType.NUMBER,
dataFormat: "#,##0.0",
editable: false,
},
];
}).flat(),
],
columnLayout: [
{
name: "areaGroup",
header: { text: "행정구역" },
items: ["ctpvNm", "sggNm", "admdstNm"],
},
"swrgPipeNm",
{
name: "summaryGroup",
header: { text: "총계" },
items: ["pipeByPrstWholSum", "lyngPrlgSum", "dmltnPrlgSum"],
},
{
name: "year1996Before",
header: { text: "1996년이전" },
items: ["yy1PrlgWhol", "yy1LyngPrlg", "yy1DmltnPrlg"],
},
...Array.from({ length: YY_PIPE_TYPE_FACILITY_MAX - 1 }, (_, i) => {
const yyIdx = i + 2;
const year = 1994 + yyIdx;
return {
name: `group_yy${yyIdx}`,
header: { text: `${year}년` },
items: [`yy${yyIdx}PrlgWhol`, `yy${yyIdx}LyngPrlg`, `yy${yyIdx}DmltnPrlg`],
};
}),
],
},
{
tabKey: "ST00020004",
tabLabel: "관경별 현황",
@@ -1019,13 +924,72 @@ const rawSewagePipeTotList = [
],
},
"rmrk",
],
],
},
];
export const SewagePipeColumnInfoListTot = rawSewagePipeTotList.map((tab) => ({
// 2. 동적 컬럼(ST00020003) 생성 함수
export const getST00020003Config = (statsYr: string | number) => {
const startYear = 1995;
const endYear = Number(statsYr) || new Date().getFullYear();
return {
tabKey: "ST00020003",
tabLabel: "관종별 현황",
rowStartNum: 2,
fixedColumns: 2,
columns: [
{ name: "ctpvNm", fieldName: "ctpvNm", header: { text: "시도" }, editable: false },
{ name: "sggNm", fieldName: "sggNm", header: { text: "시군구" }, editable: false },
{ name: "admdstNm", fieldName: "admdstNm", header: { text: "행정구역명" }, editable: false },
{ name: "swrgPipeNm", fieldName: "swrgPipeNm", header: { text: "관종" }, editable: false },
{ name: "pipeByPrstWholSum", fieldName: "pipeByPrstWhol", header: { text: "계" }, valueType: ValueType.NUMBER, dataFormat: "#,##0.0", editable: false },
{ name: "lyngPrlgSum", fieldName: "lyngPrlg", header: { text: "매설연장(m)" }, valueType: ValueType.NUMBER, dataFormat: "#,##0.0", editable: false },
{ name: "dmltnPrlgSum", fieldName: "dmltnPrlg", header: { text: "철거연장(m)" }, valueType: ValueType.NUMBER, dataFormat: "#,##0.0", editable: false },
...(() => {
const yearColumns = [];
for (let yr = startYear; yr <= endYear; yr++) {
yearColumns.push(
{ name: `prlg_whol_${yr}`, fieldName: `prlg_whol_${yr}`, header: { text: "계" }, valueType: ValueType.NUMBER, dataFormat: "#,##0.0", editable: false },
{ name: `lyng_prlg_${yr}`, fieldName: `lyng_prlg_${yr}`, header: { text: "매설연장(m)" }, valueType: ValueType.NUMBER, dataFormat: "#,##0.0", editable: false },
{ name: `dmltn_prlg_${yr}`, fieldName: `dmltn_prlg_${yr}`, header: { text: "철거연장(m)" }, valueType: ValueType.NUMBER, dataFormat: "#,##0.0", editable: false }
);
}
return yearColumns;
})(),
],
columnLayout: [
{ name: "areaGroup", header: { text: "행정구역" }, items: ["ctpvNm", "sggNm", "admdstNm"] },
"swrgPipeNm",
{ name: "summaryGroup", header: { text: "총계" }, items: ["pipeByPrstWholSum", "lyngPrlgSum", "dmltnPrlgSum"] },
...(() => {
const layouts = [];
for (let yr = startYear; yr <= endYear; yr++) {
layouts.push({
name: `group_${yr}`,
header: { text: yr === 1995 ? "1996년이전" : `${yr}년` },
items: [`prlg_whol_${yr}`, `lyng_prlg_${yr}`, `dmltn_prlg_${yr}`],
});
}
return layouts;
})(),
],
};
};
export const SewagePipeColumnInfoListTot: ColumnInfoList[] = rawSewagePipeTotList.map((tab) => ({
...tab,
columns: tab.columns.map((col) => ({ ...col, editable: false as const })),
}));
const st00020003PlaceholderTab: ColumnInfoList = {
tabKey: "ST00020003",
tabLabel: "관종별 현황",
rowStartNum: 2,
fixedColumns: 2,
columns: [],
columnLayout: [],
};
SewagePipeColumnInfoListTot.splice(2, 0, st00020003PlaceholderTab);
@@ -0,0 +1,72 @@
import { client } from "@/shared/api";
import { ENDPOINT } from "@/shared/config";
import type {
GroupUserService,
GroupUserSearchParams,
GroupUserListResponseDto,
GroupUserDetailResponse,
PageGroupUserList,
GroupUserSaveData,
GroupUserUpdateData,
} from "../model/group-user-interface";
export class GroupUsersApiService implements GroupUserService {
// 그룹 사용자 목록 조회
public async findPageGroupUser(
params: GroupUserSearchParams,
): Promise<PageGroupUserList> {
const response = await client.get<PageGroupUserList>(
`${ENDPOINT.CMS_SERVICE.GROUP_USERS}`,
{ params },
);
return response.data;
}
// 그룹 사용자 단건 조회
public async findByGroupAndUserId(
grpSn: number,
userUnqId: string,
): Promise<GroupUserDetailResponse> {
const response = await client.get<GroupUserDetailResponse>(
`${ENDPOINT.CMS_SERVICE.GROUP_USERS}/${grpSn}/${userUnqId}`,
);
return response.data;
}
// 그룹 사용자 저장
public async saveGroupUser(
data: GroupUserSaveData[],
): Promise<GroupUserListResponseDto[]> {
const response = await client.post<GroupUserListResponseDto[]>(
`${ENDPOINT.CMS_SERVICE.GROUP_USERS}`,
data,
);
return response.data;
}
// 그룹 사용자 수정
public async updateGroupUser(
grpSn: number,
userUnqId: string,
data: GroupUserUpdateData,
): Promise<GroupUserDetailResponse> {
const response = await client.put<GroupUserDetailResponse>(
`${ENDPOINT.CMS_SERVICE.GROUP_USERS}/${grpSn}/${userUnqId}`,
data,
);
return response.data;
}
// 그룹 사용자 삭제
public async deleteGroupUser(
groupId: string,
userUnqId: string,
): Promise<boolean> {
const response = await client.delete<boolean>(
`${ENDPOINT.CMS_SERVICE.GROUP_USERS}/${groupId}/${userUnqId}`,
);
return response.data;
}
}
export const GroupUsersApi = new GroupUsersApiService();
+5
View File
@@ -0,0 +1,5 @@
export { GroupUsersApi } from "./api/group-user-service";
export {
type GroupUserListResponseDto,
type GroupUserSaveData,
} from "./model/group-user-interface";
@@ -0,0 +1,55 @@
import { GroupUsersApi } from "../api/group-user-service";
export const GRP_ST_INPUT = "GRP_ST_INPUT";
export const GRP_ST_CONFIRM = "GRP_ST_CONFIRM";
export const GRP_ADMIN = "GRP_ADMIN";
const PAGE_SIZE = 500;
/**
* 지정한 그룹(`keyword`)마다 `findPageGroupUser`를 호출하고,
* 응답 목록이 비어 있지 않으며 로그인 사용자 `userUnqId`와 일치하는 행이 있으면 true.
*/
export async function checkPageGroupUserMembershipForUser(
userName: string,
userUnqId: string,
groupKeywords: readonly string[],
): Promise<boolean> {
const uid = userUnqId.trim();
const nm = userName.trim();
if (!uid || !nm) return false;
const paramsBase = { userNm: nm, page: 0, size: PAGE_SIZE };
for (const keyword of groupKeywords) {
const page = await GroupUsersApi.findPageGroupUser({
...paramsBase,
keyword,
});
const rows = page.content ?? [];
if (rows.length > 0 && rows.some((r) => String(r.userUnqId ?? "").trim() === uid)) {
return true;
}
}
return false;
}
/**
* 통계 입력 화면 상단 액션 — `GRP_ST_INPUT` / `GRP_ADMIN`.
*/
export async function checkStatisticsInputToolbarGroupMembership(
userName: string,
userUnqId: string,
): Promise<boolean> {
return checkPageGroupUserMembershipForUser(userName, userUnqId, [GRP_ST_INPUT, GRP_ADMIN]);
}
/**
* 통계 입력 현황 승인 등 — `GRP_ST_CONFIRM` / `GRP_ADMIN`.
*/
export async function checkStatisticsInputStatusConfirmGroupMembership(
userName: string,
userUnqId: string,
): Promise<boolean> {
return checkPageGroupUserMembershipForUser(userName, userUnqId, [GRP_ST_CONFIRM, GRP_ADMIN]);
}
@@ -0,0 +1,79 @@
import type { PageableList, PageableParams } from "@/shared/model";
// 그룹 사용자 검색 파라미터 인터페이스
export interface GroupUserSearchParams extends PageableParams {
keyword?: string;
userNm?: string;
}
// 그룹 사용자 목록 응답 인터페이스
export interface GroupUserListResponseDto {
mode: string;
groupId: string;
userUnqId: string;
userId: string;
userName: string;
ogdpSeCd: string;
ogdpSeNm: string;
mphn: string;
}
// 그룹 사용자 페이지 응답 인터페이스
export type PageGroupUserList = PageableList<GroupUserListResponseDto>;
// 그룹 사용자 상세 응답 인터페이스
export interface GroupUserDetailResponse {
grpSn: number;
userUnqId: string;
userId: string;
userName: string;
email: string;
mphn: string;
regDt: string;
rgtr: string;
mdfcnDt: string;
mdfr: string;
rgtrNm: string;
mdfrNm: string;
}
// 그룹 사용자 저장 데이터 인터페이스
export interface GroupUserSaveData {
mode: string;
groupId: string;
userUnqId: string;
}
// 그룹 사용자 수정 데이터 인터페이스
export interface GroupUserUpdateData {
userUnqId: string;
}
// 그룹 사용자 서비스 인터페이스
export interface GroupUserService {
// 그룹 사용자 목록 조회
findPageGroupUser: (
params: GroupUserSearchParams,
) => Promise<PageGroupUserList>;
// 그룹 사용자 단건 조회
findByGroupAndUserId: (
grpSn: number,
userUnqId: string,
) => Promise<GroupUserDetailResponse>;
// 그룹 사용자 저장
saveGroupUser: (
data: GroupUserSaveData[],
) => Promise<GroupUserListResponseDto[]>;
// 그룹 사용자 수정
updateGroupUser: (
grpSn: number,
userUnqId: string,
data: GroupUserUpdateData,
) => Promise<GroupUserDetailResponse>;
// 그룹 사용자 삭제
deleteGroupUser: (groupId: string, userUnqId: string) => Promise<boolean>;
}
@@ -17,6 +17,7 @@ import {
type SewagePipeFacilityApiResponse,
type SewerPipeTypeApiResponse,
type SewerPipeDiamApiResponse,
type SewagePipeManholeApiResponse,
} from "../model/statistics-sewage-pipe-review-interface";
import {
type SewerPipeMtcApiResponse,
@@ -30,6 +31,10 @@ import {
import {
type MediationPumpStationApiResponse,
} from "../model/statistics-pumping-station-review-interface";
import {
type PublicSewageFacilityReviewApiResponse,
type PublicSewageFacilityReviewRequestParams,
} from "../model/statistics-public-sewage-facility-review-interface";
export class StatisticsManagementApiService {
@@ -61,6 +66,20 @@ export class StatisticsManagementApiService {
return response.data;
}
/**
* 06. 공공하수처리시설 검토 목록 조회
* @param params (sido, gugun, yr, bsnmCd, fctName)
*/
public async getPublicSewageFacilityReviewList(
params: PublicSewageFacilityReviewRequestParams
): Promise<PublicSewageFacilityReviewApiResponse> {
const response = await stClient.get<PublicSewageFacilityReviewApiResponse>(
`${ENDPOINT.ST_SERVICE.REVIEW}/public-sewage`,
{ params }
);
return response.data;
}
/**
* 02. 하수관로 지역별 시설현황 목록 조회 (Tab1)
* @param params (sido, gugun, yr, bsnmCd)
@@ -117,6 +136,20 @@ export class StatisticsManagementApiService {
return response.data;
}
/**
* 02. 하수관로 맨홀 현황 조회 (Tab 5)
* @param params (sido, gugun, yr, bsnmCd)
*/
public async getSewagePipeManholeList(
params: SewagePipeReviewRequestParams
): Promise<SewagePipeManholeApiResponse> {
const response = await stClient.get<SewagePipeManholeApiResponse>(
`${ENDPOINT.ST_SERVICE.REVIEW}/sewer-pipe/manhole`,
{ params }
);
return response.data;
}
/**
* 03. 하수관로 유지관리 개보수 현황 조회
* @param params (yr, bsnmCd, sido, gugun)
@@ -10,6 +10,9 @@ import {
type ImrRequest,
type ImrResponse,
type ImrStatusResponse,
type ReviewerInfoRequest,
type ReviewerInfoResponse,
type ReviewInputSaveRequest,
type ReviewOpinionSaveRequest,
type ReviewOpinionSaveResponse,
type ReviewReturnSaveRequest,
@@ -18,16 +21,24 @@ import {
type ReviewSubmitResponse,
type SearchCondition,
type Sgg,
type StatiscicsManagementReviewHistoryRequest,
type StatiscicsManagementReviewHistoryResponse,
type StatisticsColumnGuideListResponse,
type StatisticsLogDetailResponse,
type StatisticsLogRequest,
type StatisticsLogResponse,
type StatisticsService,
type StatsInputPeriodDeleteApiResponse,
type StatsInputPeriodDeleteRequest,
type StatsInputPeriodDetailRequest,
type StatsInputPeriodDetailResponse,
type StatsInputPeriodListApiResponse,
type StatsInputPeriodListRequest,
type StatsInputPeriodSaveApiResponse,
type StatsInputPeriodSaveRequest,
type StatsInputStatusApproveBatchRequest,
type StatsInputStatusApproveKind,
type StatsInputStatusApproveResponse,
type SubmitExceptionRequest,
type SubmitExceptionResponse,
type SubmitVerifyExceptionRequest,
@@ -80,6 +91,14 @@ export class StatisticsApiService implements StatisticsService {
return response.data;
}
public async getStatsInputPeriodDetail(params: StatsInputPeriodDetailRequest): Promise<StatsInputPeriodDetailResponse> {
const response = await stClient.get<StatsInputPeriodDetailResponse>(
`${ENDPOINT.ST_SERVICE.DASHBOARD}/schedule-info/detail`,
{ params },
);
return response.data;
}
/** 통계 입력 기간 저장 — 경로는 백엔드 스펙에 맞게 수정 */
public async saveStatsInputPeriod(
param: StatsInputPeriodSaveRequest,
@@ -158,6 +177,17 @@ export class StatisticsApiService implements StatisticsService {
return response.data;
}
/** 컬럼 가이드 목록 조회 — GET `.../common/column-guides?statsCd=` */
public async getColumnGuides(
param: string,
): Promise<StatisticsColumnGuideListResponse> {
const response = await stClient.get<StatisticsColumnGuideListResponse>(
`${ENDPOINT.ST_SERVICE.INPUTCOMMON}/column-guides`,
{ params: { statsCd: param } },
);
return response.data;
}
public async saveReviewOpinion(
param: ReviewOpinionSaveRequest,
): Promise<ReviewOpinionSaveResponse> {
@@ -181,9 +211,61 @@ export class StatisticsApiService implements StatisticsService {
public async saveReviewReturn(
param: ReviewReturnSaveRequest,
): Promise<ReviewReturnSaveResponse> {
const { type, ...body } = param;
const response = await stClient.post<ReviewReturnSaveResponse>(
`${ENDPOINT.ST_SERVICE.REVIEW}/return`,
param,
`${ENDPOINT.ST_SERVICE.STATS}/reject`,
body,
{ params: { type } },
);
return response.data;
}
/** 입력 반려 — JSON body는 `ReviewInputSaveRequest`(type 제외) 항목의 배열, `type`은 쿼리 */
public async saveInputReturn(
items: ReviewInputSaveRequest[],
): Promise<ReviewReturnSaveResponse> {
if (items.length === 0) {
throw new Error("saveInputReturn: 항목이 비어 있습니다.");
}
const [first] = items;
if (!first) {
throw new Error("saveInputReturn: 항목이 비어 있습니다.");
}
const type = first.type;
if (!items.every((i) => i.type === type)) {
throw new Error("saveInputReturn: 모든 항목의 type이 같아야 합니다.");
}
const body = items.map(({ type: _t, rjctOpnnCn, ...rest }) => {
const row: Record<string, string> = {
statsYr: rest.statsYr,
sewerBzmnCd: rest.sewerBzmnCd,
statsCd: rest.statsCd,
mdfrId: rest.mdfrId,
};
if (rest.vrfcSn != null && String(rest.vrfcSn).trim() !== "") {
row.vrfcSn = String(rest.vrfcSn);
}
const trimmed = rjctOpnnCn?.trim();
if (trimmed) row.rjctOpnnCn = trimmed;
return row;
});
const response = await stClient.post<ReviewReturnSaveResponse>(
`${ENDPOINT.ST_SERVICE.STATS}/reject`,
body,
{ params: { type } },
);
return response.data;
}
/** 통계 입력 현황 일괄 승인 — 쿼리 `type`(기본 `INPUT`), body는 `{ list }` */
public async approveStatsInputStatus(
param: StatsInputStatusApproveBatchRequest,
type: StatsInputStatusApproveKind = "INPUT",
): Promise<StatsInputStatusApproveResponse> {
const response = await stClient.post<StatsInputStatusApproveResponse>(
`${ENDPOINT.ST_SERVICE.STATS}/approve`,
param.list,
{ params: { type } },
);
return response.data;
}
@@ -213,6 +295,22 @@ export class StatisticsApiService implements StatisticsService {
);
return response.data;
}
public async getReviewerInfo(param: ReviewerInfoRequest): Promise<ReviewerInfoResponse> {
const response = await stClient.get<ReviewerInfoResponse>(
`${ENDPOINT.ST_SERVICE.REVIEW}/reviewer-info`,
{ params: param },
);
return response.data;
}
public async getStatiscicsManagementReviewHistory(param: StatiscicsManagementReviewHistoryRequest): Promise<StatiscicsManagementReviewHistoryResponse> {
const response = await stClient.get<StatiscicsManagementReviewHistoryResponse>(
`${ENDPOINT.ST_SERVICE.REVIEW}/stats-review`,
{ params: param },
);
return response.data;
}
}
export const statisticsApi = new StatisticsApiService();
+6
View File
@@ -74,6 +74,12 @@ export {
type ImrLogResponse,
} from "./model/imr/imr-interface";
export { imrVerificationHistoryColumns } from "./model/imr/imr-verification-history-columns";
export {
type StatsInputStatusApproveBatchRequest,
type StatsInputStatusApproveItem,
type StatsInputStatusApproveKind,
type StatsInputStatusApproveResponse,
} from "./model/statistics-interface";
export { type StatsInputStatusList } from "./model/input/statistics-input-status-interface";
export { userListColumns } from "./model/notice-talk/notice-talk-sending-columns";
export {
@@ -230,6 +230,7 @@ export interface VerifyResultItem {
nowVlCn?: string;
compVlCn?: string;
prcsYn?: string;
prcsYnNm?: string;
prcsOpnnExpln?: string;
prcrId?: string;
rjctOpnnCn?: string;
@@ -15,8 +15,8 @@ export interface StatsInputStatusList {
statsCd?: string;
sewerBzmnCd?: string;
menuTabName?: string;
locgStatusNm?: string;
inputRate?: number;
laeSbmsnSttsNm?: string;
laeSbmsnSttsCd?: string;
laePicNm?: string;
laePicTelNo?: string;
laeKbrdrDeptNm?: string;
@@ -187,10 +187,11 @@ export interface PublicSewageMainFacilityMappingSaveRequest {
admdstNm: string;
mainFcltSn: string;
mainFcltNm: string;
prxyBzentyNmCn: string;
cnsgnCstVl: number;
wholNope: number;
prxyCtrtCn: string;
/** 매핑 추가(saveList 있음) 시 입력. 매핑 삭제만 할 때는 요청에서 생략 가능 */
prxyBzentyNmCn?: string;
cnsgnCstVl?: number;
wholNope?: number;
prxyCtrtCn?: string;
userId: string;
saveList: PublicSewageMainFacilityMappingSaveItem[];
deleteList: string[];
@@ -87,9 +87,9 @@ export interface SewerageSupplyRateRequestParams {
/** 하수도 보급률 저장 API 요청 body (한 행) */
export interface SewerageSupplyRateSaveItem {
sggNm: string | null;
admdstCd: string;
admdstNm: string;
trbtrNm: string;
admdstCd: string | null;
admdstNm: string | null;
trbtrNm: string | null;
spcCtpnRgnCn: string | null;
ts1SwtrmtPpltnCnt: number;
ts2SwtrmtPpltnCnt: number;
@@ -103,7 +103,7 @@ export interface SewerageSupplyRateSaveItem {
drtwPrcsPpltnCnt: number;
spctnkPpltnCnt: number;
staaExcpArea: number;
rmrk: string;
rmrk: string | null;
wholPpltnCnt: number;
wholArea: number;
wholSwtrmtPpltnCnt: number;
@@ -35,11 +35,23 @@ export interface ApiResponse<T> {
message?: string;
}
/** 통계 컬럼 가이드(영문 컬럼명·한글명·유형·계산 여부·가이드 문구) 한 행 */
export interface StatisticsColumnGuideItem {
engColNm: string;
korColNm: string;
colType: string;
isCalcCol: string;
columnGuide: string;
}
/** 컬럼 가이드 정보 조회 응답 (`data`: 컬럼 가이드 배열) */
export type StatisticsColumnGuideListResponse = ApiResponse<StatisticsColumnGuideItem[]>;
/** 통계 입력 관리 등 — 연도·구분·분류(입력/검증)별 입력 기간 한 행 */
export interface StatsInputPeriodItem {
statsYr: string;
seCd: number;
seType: "INPT" | "VRFC";
seType: string;
inptBgngYmd: string;
inptEndYmd: string;
}
@@ -50,15 +62,26 @@ export type StatsInputPeriodListApiResponse = ApiResponse<StatsInputPeriodItem[]
/** 통계 입력 기간 목록 조회 요청 쿼리 */
export interface StatsInputPeriodListRequest {
statsYr?: string;
/** 전체 / 입력 / 검증 (SearchForm `clsfCd`와 동일) */
seType?: "ALL" | "INPT" | "VRFC";
seType?: string;
}
export interface StatsInputPeriodDetailRequest {
statsYr: string;
seType: string;
seCd: number;
}
export interface StatsInputPeriodDetailResponse {
status: string;
message?: string;
data: StatsInputPeriodItem;
}
/** 통계 입력 기간 등록·수정 등 요청 본문 */
export interface StatsInputPeriodSaveRequest {
statsYr: string;
seType: string;
seCd: number;
seCd?: number;
/** 시작일 (백엔드 형식 문자열, 예: YYYYMMDD) */
inptBgngYmd: string;
/** 종료일 (백엔드 형식 문자열, 예: YYYYMMDD) */
@@ -69,8 +92,15 @@ export interface StatsInputPeriodSaveRequest {
/** 통계 입력 기간 저장 응답 */
export type StatsInputPeriodSaveApiResponse = ApiResponse<unknown>;
/** 통계 입력 기간 삭제 요청 (필드 구성은 저장과 동일) */
export type StatsInputPeriodDeleteRequest = StatsInputPeriodSaveRequest;
/** 통계 입력 기간 삭제 요청 */
export interface StatsInputPeriodDeleteRequest {
statsYr: string;
seType: string;
seCd: number;
inptBgngYmd: string;
inptEndYmd: string;
rgtrId: string;
}
/** 통계 입력 기간 삭제 응답 */
export type StatsInputPeriodDeleteApiResponse = ApiResponse<unknown>;
@@ -215,6 +245,10 @@ export interface StatisticsLog {
statsDataChgComCd?: string;
rgtrId?: string;
regDt?: string;
ctpvNm?: string;
sggNm?: string;
statsDataChgComNm?: string;
statsYr?: string;
}
export interface StatisticsLogRequest {
@@ -300,16 +334,31 @@ export interface ReviewOpinionSaveResponse {
}
export interface ReviewReturnSaveRequest {
type: "REVIEW";
statsYr: string;
sewerBzmnCd: string;
statsCd: string;
rjctOpnnCn: string;
mdfrId: string;
vrfcSn?: string;
}
export interface ReviewReturnSaveResponse {
status: string;
message?: string;
exceptionCnt: number;
message: string;
resultCode: string;
updatedStts: string;
}
export interface ReviewInputSaveRequest {
type: "INPUT";
statsYr: string;
sewerBzmnCd: string;
statsCd: string;
/** 미입력·빈 문자열이면 요청 body에 포함하지 않음 */
rjctOpnnCn?: string;
mdfrId: string;
vrfcSn?: string;
}
export interface ReviewSubmitRequest {
@@ -317,6 +366,8 @@ export interface ReviewSubmitRequest {
sewerBzmnCd: string;
statsCd: string;
mdfrId: string;
splmntOpnnCn: string;
wrterOpnnCn: string;
}
export interface ReviewSubmitResponse {
@@ -324,6 +375,30 @@ export interface ReviewSubmitResponse {
message?: string;
}
/** 통계 일괄 승인 `/stats/approve` 쿼리 파라미터 `type` */
export type StatsInputStatusApproveKind = "INPUT" | "REVIEW";
/** 통계 입력 현황 승인 요청 항목 (행 단위) */
export interface StatsInputStatusApproveItem {
statsYr: string;
sewerBzmnCd: string;
statsCd: string;
mdfrId: string;
}
/** 통계 입력 현황 일괄 승인 API 요청 본문 */
export interface StatsInputStatusApproveBatchRequest {
list: StatsInputStatusApproveItem[];
}
/** 통계 입력 현황 일괄 승인 API 응답 */
export interface StatsInputStatusApproveResponse {
exceptionCnt: number;
message: string;
resultCode: string;
updatedStts: string;
}
export interface SubmitVerifyExceptionItem {
vrfcSn: number;
prcsOpnnExpln: string;
@@ -342,13 +417,57 @@ export interface SubmitVerifyExceptionResponse {
message?: string;
}
export interface ReviewerInfoRequest {
sewerBzmnCd: string;
statsYr: string;
statsCd: string;
instSeCd : string;
}
export interface ReviewerInfoResponse {
status: string;
data: {
userNm: string;
telOffrm: string;
dpmNm: string;
cps: string;
splmntOpnnCn: string;
wrterOpnnCn: string;
}
}
export interface StatiscicsManagementReviewHistoryRequest {
statsYr: string;
sewerBzmnCd?: string;
ctpvNm?: string;
statsCd?: string;
}
/** 통계관리 검토 이력 조회 API `data` 배열 요소 */
export interface StatiscicsManagementReviewHistoryItem {
statsYr: string;
ctpvNm: string;
sggNm: string;
sewerBzmnCd: string;
statsCd: string;
menuNm: string;
wrterOpinionSm: string | null;
chckerOpinionSm: string | null;
returnOpinionSm: string | null;
}
export interface StatiscicsManagementReviewHistoryResponse {
status: string;
message?: string;
data: StatiscicsManagementReviewHistoryItem[];
}
export interface StatisticsService {
getSidoList(params?: { instSeCd?: string }): Promise<ApiResponse<SearchCondition>>;
getSggList(sidoName: string, params?: { instSeCd?: string; gugunSkipYn?: "Y" | "N" }): Promise<ApiResponse<Sgg[]>>;
getSggAllList(sidoName: string, params?: { instSeCd?: string; gugunSkipYn?: "Y" | "N" }): Promise<ApiResponse<Sgg[]>>;
getStatsInputPeriodList(
params?: StatsInputPeriodListRequest,
): Promise<StatsInputPeriodListApiResponse>;
getStatsInputPeriodList(params?: StatsInputPeriodListRequest): Promise<StatsInputPeriodListApiResponse>;
getStatsInputPeriodDetail(param: StatsInputPeriodDetailRequest): Promise<StatsInputPeriodDetailResponse>;
saveStatsInputPeriod(param: StatsInputPeriodSaveRequest): Promise<StatsInputPeriodSaveApiResponse>;
deleteStatsInputPeriod(param: StatsInputPeriodDeleteRequest): Promise<StatsInputPeriodDeleteApiResponse>;
cancelSubmit(param: cancelSubmitRequest): Promise<cancelSubmitResponse>;
@@ -359,8 +478,16 @@ export interface StatisticsService {
saveReviewOpinion(param: ReviewOpinionSaveRequest): Promise<ReviewOpinionSaveResponse>;
getReviewOpinionDetail(param: getReviewOpinionDetailRequest): Promise<getReviewOpinionDetailResponse>;
saveReviewReturn(param: ReviewReturnSaveRequest): Promise<ReviewReturnSaveResponse>;
saveInputReturn(items: ReviewInputSaveRequest[]): Promise<ReviewReturnSaveResponse>;
approveStatsInputStatus(
param: StatsInputStatusApproveBatchRequest,
type?: StatsInputStatusApproveKind,
): Promise<StatsInputStatusApproveResponse>;
submitReview(param: ReviewSubmitRequest): Promise<ReviewSubmitResponse>;
getVerificationResultList(param: VerificationResultListRequest): Promise<VerificationResultListResponse>;
getImrStatus(param: string): Promise<ImrStatusResponse>;
SubmitVerifyException(param: SubmitVerifyExceptionRequest): Promise<SubmitVerifyExceptionResponse>;
getColumnGuides(param: string): Promise<StatisticsColumnGuideListResponse>;
getReviewerInfo(param: ReviewerInfoRequest): Promise<ReviewerInfoResponse>;
getStatiscicsManagementReviewHistory(param: StatiscicsManagementReviewHistoryRequest): Promise<StatiscicsManagementReviewHistoryResponse>;
}
@@ -0,0 +1,92 @@
import type { ApiResponse } from "./statistics-interface";
import type { StatsStatusInfo } from "./statistics-sewage-pipe-review-interface";
export interface PublicSewageFacilityReviewItem {
ctpvNm: string;
sggNm: string;
statsYr: string;
admdstCd: string;
admdstNm: string;
swtrmtFcltNm: string;
displayYear: string;
rowType: "OLD" | "CURR";
fcltPstnCn: string | null;
prcsFcltCpct: number;
prcsFcltPhysCpct: number;
prcsFcltBoilCpct: number;
prcsFcltAltdCpct: number;
inflwSwageQnt: number;
inflwSwagePhysQnt: number;
inflwSwageBoilQnt: number;
inflwSwageAltdQnt: number;
prcsQnt: number;
prcsPhysCpct: number;
prcsBoilCpct: number;
prcsAltdCpct: number;
inflwBodVl: number;
dschgBodVl: number;
inflwTocQnt: number;
dschgTocQnt: number;
inflwSsQnt: number;
dschgSsQnt: number;
inflwTnVl: number;
dschgTnVl: number;
inflwTpVl: number;
dschgTpVl: number;
inflwGrssClfgVl: number;
dschgGrssClfgVl: number;
prcsRt: number;
prcsLodQnt: number;
prcsMthdExpln: string | null;
wholHmnQnt: number;
wholLvstQnt: number;
wholEfntQnt: number;
wholEtcQnt: number;
telNo: string | null;
cmptnStrtYmd: string | null;
oprtngStrtYmd: string | null;
wholBizVl: number;
eflntDsnfMthdExpln: string | null;
wtsysNm: string | null;
trbtrNm: string | null;
dtlUnitZoneCn: string | null;
rgnSeNm: string | null;
msirbCn: string | null;
goalWqNo: string | null;
engySrlneRt: number;
engyItslfPrdctnQnt: number;
anlEngyWholUseQnt: number;
anlElpwrUseQnt: number;
itslfEngyUseQnt: number;
swtrmtEmsnQnt: number;
operMnbdCn: string | null;
prxyBzentyNmCn: string | null;
cnsgnCstVl: number;
prxyCtrtCn: string | null;
intgPrcsFcltCn: string | null;
wholNope: number;
sftyAcdntEvntcsCnt: number;
rmrk: string | null;
}
export interface PublicSewageFacilityReviewResponseData {
publicSewageList: PublicSewageFacilityReviewItem[];
statsStatusInfo: StatsStatusInfo;
}
export interface PublicSewageFacilityReviewRequestParams {
ctpvNm?: string;
sggNm?: string;
statsYr?: string;
sewerBzmnCd?: string;
swtrmtFcltNm?: string;
}
export type PublicSewageFacilityReviewApiResponse =
ApiResponse<PublicSewageFacilityReviewResponseData>;
export interface PublicSewageFacilityReviewService {
getPublicSewageFacilityReviewList(
params: PublicSewageFacilityReviewRequestParams,
): Promise<PublicSewageFacilityReviewApiResponse>;
}
@@ -244,6 +244,118 @@ export interface SewagePipeDiamService {
getSewagePipeDiamList(params: SewagePipeReviewRequestParams): Promise<SewerPipeDiamApiResponse>;
}
/**
* 02. 하수관로 맨홀 현황 아이템 (Tab 5)
*/
export interface SewagePipeReviewManholeItem {
ctpvNm: string;
sggNm: string;
admdstNm: string;
admdstCd: string;
statsYr: string;
displayYear: string;
rowType: 'OLD' | 'CURR';
emphsMngSeCd: string;
emphsMngSeNm?: string;
yy14WholMnhlCnt: number;
yy14Step1MnhlCnt: number;
yy14Step2MnhlCnt: number;
yy14Step3MnhlCnt: number;
yy14Step4MnhlCnt: number;
yy14Step5MnhlCnt: number;
yy15WholMnhlCnt: number;
yy15Step1MnhlCnt: number;
yy15Step2MnhlCnt: number;
yy15Step3MnhlCnt: number;
yy15Step4MnhlCnt: number;
yy15Step5MnhlCnt: number;
yy16WholMnhlCnt: number;
yy16Step1MnhlCnt: number;
yy16Step2MnhlCnt: number;
yy16Step3MnhlCnt: number;
yy16Step4MnhlCnt: number;
yy16Step5MnhlCnt: number;
yy17WholMnhlCnt: number;
yy17Step1MnhlCnt: number;
yy17Step2MnhlCnt: number;
yy17Step3MnhlCnt: number;
yy17Step4MnhlCnt: number;
yy17Step5MnhlCnt: number;
yy18WholMnhlCnt: number;
yy18Step1MnhlCnt: number;
yy18Step2MnhlCnt: number;
yy18Step3MnhlCnt: number;
yy18Step4MnhlCnt: number;
yy18Step5MnhlCnt: number;
yy19WholMnhlCnt: number;
yy19Step1MnhlCnt: number;
yy19Step2MnhlCnt: number;
yy19Step3MnhlCnt: number;
yy19Step4MnhlCnt: number;
yy19Step5MnhlCnt: number;
yy20WholMnhlCnt: number;
yy20Step1MnhlCnt: number;
yy20Step2MnhlCnt: number;
yy20Step3MnhlCnt: number;
yy20Step4MnhlCnt: number;
yy20Step5MnhlCnt: number;
yy21WholMnhlCnt: number;
yy21Step1MnhlCnt: number;
yy21Step2MnhlCnt: number;
yy21Step3MnhlCnt: number;
yy21Step4MnhlCnt: number;
yy21Step5MnhlCnt: number;
yy22WholMnhlCnt: number;
yy22Step1MnhlCnt: number;
yy22Step2MnhlCnt: number;
yy22Step3MnhlCnt: number;
yy22Step4MnhlCnt: number;
yy22Step5MnhlCnt: number;
yy23WholMnhlCnt: number;
yy23Step1MnhlCnt: number;
yy23Step2MnhlCnt: number;
yy23Step3MnhlCnt: number;
yy23Step4MnhlCnt: number;
yy23Step5MnhlCnt: number;
yy24WholMnhlCnt: number;
yy24Step1MnhlCnt: number;
yy24Step2MnhlCnt: number;
yy24Step3MnhlCnt: number;
yy24Step4MnhlCnt: number;
yy24Step5MnhlCnt: number;
yy25WholMnhlCnt: number;
yy25Step1MnhlCnt: number;
yy25Step2MnhlCnt: number;
yy25Step3MnhlCnt: number;
yy25Step4MnhlCnt: number;
yy25Step5MnhlCnt: number;
rmrk: string | null;
}
export interface SewagePipeManholeResponseData {
manholeList: SewagePipeReviewManholeItem[];
statsStatusInfo: StatsStatusInfo;
}
export type SewagePipeManholeApiResponse = ApiResponse<SewagePipeManholeResponseData>;
export interface SewagePipeManholeService {
getSewagePipeManholeList(params: SewagePipeReviewRequestParams): Promise<SewagePipeManholeApiResponse>;
}
export interface StatsStatusInfo {
statsCd?: string;
submitStatusCd?: string;
@@ -102,99 +102,7 @@ export interface SewagePipeFactInstallStatusResponse {
/** ST00020003 합계 — `GET .../sewer-pipe/pipe-type-facility-status` */
export interface SewagePipePipeTypeFacilityStatusItem {
pipeByPrstWhol: number;
lyngPrlg: number;
dmltnPrlg: number;
yy1PrlgWhol: number;
yy1LyngPrlg: number;
yy1DmltnPrlg: number;
yy2PrlgWhol: number;
yy2LyngPrlg: number;
yy2DmltnPrlg: number;
yy3PrlgWhol: number;
yy3LyngPrlg: number;
yy3DmltnPrlg: number;
yy4PrlgWhol: number;
yy4LyngPrlg: number;
yy4DmltnPrlg: number;
yy5PrlgWhol: number;
yy5LyngPrlg: number;
yy5DmltnPrlg: number;
yy6PrlgWhol: number;
yy6LyngPrlg: number;
yy6DmltnPrlg: number;
yy7PrlgWhol: number;
yy7LyngPrlg: number;
yy7DmltnPrlg: number;
yy8PrlgWhol: number;
yy8LyngPrlg: number;
yy8DmltnPrlg: number;
yy9PrlgWhol: number;
yy9LyngPrlg: number;
yy9DmltnPrlg: number;
yy10PrlgWhol: number;
yy10LyngPrlg: number;
yy10DmltnPrlg: number;
yy11PrlgWhol: number;
yy11LyngPrlg: number;
yy11DmltnPrlg: number;
yy12PrlgWhol: number;
yy12LyngPrlg: number;
yy12DmltnPrlg: number;
yy13PrlgWhol: number;
yy13LyngPrlg: number;
yy13DmltnPrlg: number;
yy14PrlgWhol: number;
yy14LyngPrlg: number;
yy14DmltnPrlg: number;
yy15PrlgWhol: number;
yy15LyngPrlg: number;
yy15DmltnPrlg: number;
yy16PrlgWhol: number;
yy16LyngPrlg: number;
yy16DmltnPrlg: number;
yy17PrlgWhol: number;
yy17LyngPrlg: number;
yy17DmltnPrlg: number;
yy18PrlgWhol: number;
yy18LyngPrlg: number;
yy18DmltnPrlg: number;
yy19PrlgWhol: number;
yy19LyngPrlg: number;
yy19DmltnPrlg: number;
yy20PrlgWhol: number;
yy20LyngPrlg: number;
yy20DmltnPrlg: number;
yy21PrlgWhol: number;
yy21LyngPrlg: number;
yy21DmltnPrlg: number;
yy22PrlgWhol: number;
yy22LyngPrlg: number;
yy22DmltnPrlg: number;
yy23PrlgWhol: number;
yy23LyngPrlg: number;
yy23DmltnPrlg: number;
yy24PrlgWhol: number;
yy24LyngPrlg: number;
yy24DmltnPrlg: number;
yy25PrlgWhol: number;
yy25LyngPrlg: number;
yy25DmltnPrlg: number;
yy26PrlgWhol: number;
yy26LyngPrlg: number;
yy26DmltnPrlg: number;
yy27PrlgWhol: number;
yy27LyngPrlg: number;
yy27DmltnPrlg: number;
yy28PrlgWhol: number;
yy28LyngPrlg: number;
yy28DmltnPrlg: number;
yy29PrlgWhol: number;
yy29LyngPrlg: number;
yy29DmltnPrlg: number;
yy30PrlgWhol: number;
yy30LyngPrlg: number;
yy30DmltnPrlg: number;
// 고정 필드
ctpvNm: string;
sggNm: string | null;
admdstCd: string;
@@ -203,7 +111,19 @@ export interface SewagePipePipeTypeFacilityStatusItem {
statsYr: string;
rowType: string;
rmrk: string | null;
}
// 총계 필드
pipeByPrstWhol: number;
lyngPrlg: number;
dmltnPrlg: number;
/**
* 연도별 동적 데이터 필드
"prlg_whol_1995", "lyng_prlg_1995", "dmltn_prlg_1995",
*/
[key: string]: string | number | null;
}
export interface SewagePipePipeTypeFacilityStatusResponse {
status: string;
@@ -9,12 +9,14 @@ export const statisticsVerificationReportColumnInfo: ColumnInfo = {
fieldName: "ctpvNm",
header: { text: "시도" },
styleName: READ_ONLY_STYLE,
editable: false,
},
{
name: "sggNm",
fieldName: "sggNm",
header: { text: "시군구" },
styleName: READ_ONLY_STYLE,
editable: false,
},
{
name: "menuTabName",
@@ -22,19 +24,14 @@ export const statisticsVerificationReportColumnInfo: ColumnInfo = {
header: { text: "구분" },
width: 150,
styleName: READ_ONLY_STYLE,
},
{
name: "locgStatusNm",
fieldName: "locgStatusNm",
header: { text: "진행상태" },
width: 120,
styleName: READ_ONLY_STYLE,
editable: false,
},
{
name: "laePicNm",
fieldName: "laePicNm",
header: { text: "작성자" },
styleName: READ_ONLY_STYLE,
editable: false,
},
{
name: "laePicTelNo",
@@ -42,36 +39,42 @@ export const statisticsVerificationReportColumnInfo: ColumnInfo = {
header: { text: "작성자연락처" },
width: 130,
styleName: READ_ONLY_STYLE,
editable: false,
},
{
name: "laeKbrdrDeptNm",
fieldName: "laeKbrdrDeptNm",
header: { text: "부서" },
styleName: READ_ONLY_STYLE,
editable: false,
},
{
name: "laeKbrdrJbgdNm",
fieldName: "laeKbrdrJbgdNm",
header: { text: "직급" },
styleName: READ_ONLY_STYLE,
editable: false,
},
{
name: "laeIdfrNm",
fieldName: "laeIdfrNm",
header: { text: "승인자" },
styleName: READ_ONLY_STYLE,
editable: false,
},
{
name: "laeIdfrDeptNm",
fieldName: "laeIdfrDeptNm",
header: { text: "부서" },
styleName: READ_ONLY_STYLE,
editable: false,
},
{
name: "laeIdfrJbgdNm",
fieldName: "laeIdfrJbgdNm",
header: { text: "직급" },
styleName: READ_ONLY_STYLE,
editable: false,
},
{
name: "rjctOpnnCn",
@@ -79,6 +82,7 @@ export const statisticsVerificationReportColumnInfo: ColumnInfo = {
header: { text: "반려사유" },
width: 220,
styleName: READ_ONLY_STYLE,
editable: false,
},
{
name: "sewerBzmnCd",
@@ -97,7 +101,6 @@ export const statisticsVerificationReportColumnInfo: ColumnInfo = {
"ctpvNm",
"sggNm",
"menuTabName",
"locgStatusNm",
"laePicNm",
"laePicTelNo",
"laeKbrdrDeptNm",
@@ -176,8 +179,8 @@ export const statisticsVerificationResultColumnInfo: ColumnInfo = {
editable: false,
},
{
name: "prcsYn",
fieldName: "prcsYn",
name: "prcsYnNm",
fieldName: "prcsYnNm",
header: { text: "상태" },
width: 90,
styleName: READ_ONLY_STYLE,
@@ -223,7 +226,7 @@ export const statisticsVerificationResultColumnInfo: ColumnInfo = {
"rsnExpln",
"nowVlCn",
"compVlCn",
"prcsYn",
"prcsYnNm",
"prcsOpnnExpln",
"rjctOpnnCn",
"pbcpAprvYn",
@@ -17,11 +17,13 @@ interface VrfcReportPanelProps {
const TYPE_CODE_NAME_MAP: Record<string, string> = {
NV: "일반",
IMR: "I-MR",
HB: "HB",
};
const TYPE_CODE_COLOR_MAP: Record<string, BadgeProps["color"]> = {
NV: "gray",
IMR: "red",
HB: "blue",
};
export function VrfcReportPanel({ params }: VrfcReportPanelProps) {
+1
View File
@@ -70,6 +70,7 @@ export const ENDPOINT = {
COMMON: "/st-management-service/api/v1/common",
INPUT: "/st-input-service/api/v1/input",
REVIEW: "/st-management-service/api/v1/review",
STATS: "/st-management-service/api/stats",
INPUTCOMMON: "/st-input-service/api/v1/common",
INPUTVERIFY: "/st-input-service/api/v1/verify",
TOTAL: "/st-management-service/api/v1/stats",

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