Merge branch 'main' of https://www.onasys.co.kr:3000/ONAANDALL/frontend into deploy
Deploy Frontend / deploy (push) Successful in 6m40s

This commit is contained in:
2026-04-21 18:10:26 +09:00
153 changed files with 7574 additions and 5155 deletions
+1 -1
View File
@@ -17,7 +17,7 @@ NEXT_PUBLIC_SEWERAGE_API_URL = http://con.mind-one.co.kr:11083/sewerage-service
NEXT_PUBLIC_NGII_URL = https://map.ngii.go.kr
NEXT_PUBLIC_NGII_PARAM = 04trYP9_xwLAfALjwZ-B8g&service=WMTS&request=GetTile&version=1.0.0&format=image/png&layer=korean_map&style=korean&tilematrixset=korean&tilematrix={z}&tilecol={x}&tilerow={y}
# NEXT_PUBLIC_CM_LOGIN_PAGE_URL = http://dev.onasys.co.kr:3000/auth/login
NEXT_PUBLIC_CM_LOGIN_PAGE_URL = /
NEXT_PUBLIC_CM_LOGIN_PAGE_URL = /auth/login
#NEXT_PUBLIC_SERVER_API_URL = http://localhost:8883/operation-service
+1 -1
View File
@@ -16,4 +16,4 @@ NEXT_PUBLIC_DC_URL = https://apigateway.apps.rsonesoft.com/dc-service
NEXT_PUBLIC_SEWERAGE_API_URL = http://con.mind-one.co.kr:11083/sewerage-service
NEXT_PUBLIC_NGII_URL = https://map.ngii.go.kr
NEXT_PUBLIC_NGII_PARAM = 04trYP9_xwLAfALjwZ-B8g&service=WMTS&request=GetTile&version=1.0.0&format=image/png&layer=korean_map&style=korean&tilematrixset=korean&tilematrix={z}&tilecol={x}&tilerow={y}
NEXT_PUBLIC_CM_LOGIN_PAGE_URL = /
NEXT_PUBLIC_CM_LOGIN_PAGE_URL = /auth/login
-1
View File
@@ -17,4 +17,3 @@ NEXT_PUBLIC_DC_URL = http://apigateway.iipbs-was.svc.cluster.local:8000/dc-s
NEXT_PUBLIC_SEWERAGE_API_URL = http://apigateway.iipbs-was.svc.cluster.local:8000/sewerage-service
NEXT_PUBLIC_NGII_URL = http://172.29.143.87/poiact
NEXT_PUBLIC_NGII_PARAM = BDE2BEF39DAFEC293AB6730150F222AC&service=WMTS&request=GetTile&version=1.0.0&format=image/png&layer=korean_map&style=korean&tilematrixset=korean&tilematrix={z}&tilecol={y}&tilerow={x}
NEXT_PUBLIC_CM_LOGIN_PAGE_URL = http://dev.onasys.co.kr:3000/auth/login
+2
View File
@@ -0,0 +1,2 @@
import { LoginPage } from "@/src/features/auth/login";
export default LoginPage;
+46 -2
View File
@@ -1,4 +1,28 @@
import { DEV_URL_FO, DEV_URL_WR, LOCAL_URL } from "./config/env";
import { CLAIM_NAME, SITE_ID } from "@/src/shared/config/env";
import { tokenManager } from "@/src/entities/auth/lib/token-manager";
import { DEV_URL_FO, DEV_URL_WR, LOCAL_URL } from "./config/env";
const LOGIN_PAGE_URL =
process.env.NEXT_PUBLIC_CM_LOGIN_PAGE_URL || "/auth/login";
const handleUnauthorizedResponse = () => {
if (typeof window === "undefined") return;
const remainingMs = tokenManager.getRemainingMsFromToken(CLAIM_NAME);
const isExpired = remainingMs === null || remainingMs <= 0;
if (!isExpired) return;
tokenManager.clearAuthToken();
const isLoginPage =
window.location.pathname === "/auth/login" ||
window.location.pathname.startsWith("/auth/login/");
if (!isLoginPage) {
window.location.assign(LOGIN_PAGE_URL);
}
};
export const getFetch = async (part : string, url: string) => {
let reqUrl = "";
@@ -7,8 +31,12 @@ export const getFetch = async (part : string, url: string) => {
method: "GET",
headers: {
accept: "application/json",
"X-Site-Id": SITE_ID,
}
});
if (response.status === 401) {
handleUnauthorizedResponse();
}
if(!response.ok) {
throw new Error(`오류 발생: ${response.status}`);
}
@@ -21,8 +49,12 @@ export const getFetchAreaList = async (url: string) => {
method: "GET",
headers: {
accept: "application/json",
"X-Site-Id": SITE_ID,
}
});
if (response.status === 401) {
handleUnauthorizedResponse();
}
if(!response.ok) {
throw new Error(`오류 발생: ${response.status}`);
}
@@ -37,9 +69,13 @@ export const postFetch = async (part : string, url: string, params: unknown) =>
method: "POST",
headers: {
"Content-Type": "application/json",
"X-Site-Id": SITE_ID,
},
body: JSON.stringify(params),
});
if (response.status === 401) {
handleUnauthorizedResponse();
}
if(!response.ok) {
throw new Error(`오류 발생: ${response.status}`);
}
@@ -53,9 +89,13 @@ export const putFetch = async (part : string, url: string, params: unknown) => {
method: "PUT",
headers: {
"Content-Type": "application/json",
"X-Site-Id": SITE_ID,
},
body: JSON.stringify(params),
});
if (response.status === 401) {
handleUnauthorizedResponse();
}
if(!response.ok) {
throw new Error(`오류 발생: ${response.status}`);
}
@@ -69,11 +109,15 @@ export const deleteFetch = async (part : string, url: string, params: unknown) =
method: "DELETE",
headers: {
"Content-Type": "application/json",
"X-Site-Id": SITE_ID,
},
body: JSON.stringify(params),
});
if (response.status === 401) {
handleUnauthorizedResponse();
}
if(!response.ok) {
throw new Error(`오류 발생: ${response.status}`);
}
return response;
}
}
+5 -1
View File
@@ -1,5 +1,6 @@
"use client";
import { SITE_ID } from "@/src/shared/config/env";
import Gis from "@common/business_components/ui/map/Gis";
import GisApp from "@common/business_components/ui/map/lib/gis.core";
import { useEffect, useRef, useState } from "react";
@@ -59,7 +60,10 @@ const Map = () => {
const reqUrl = `${API_URL}/lyrStngs/FO_NETWORK`;
const response = await fetch(reqUrl, {
method: "GET",
headers: { accept: "application/json" },
headers: {
accept: "application/json",
"X-Site-Id": SITE_ID,
},
});
if (!response.ok) throw new Error(`HTTP 오류! 상태: ${response.status}`);
const data = await response.json();
@@ -1,4 +1,5 @@
"use client";
import { SITE_ID } from "@/src/shared/config/env";
import React from "react";
export default function TestPage() {
@@ -6,6 +7,9 @@ export default function TestPage() {
const handleFindUser = () => {
fetch('/operation-service/api/v1/fclt/flow/1/10/2024', {
method: "GET",
headers: {
"X-Site-Id": SITE_ID,
},
}).then((data) => {
console.log(data);
});
@@ -19,4 +23,4 @@ export default function TestPage() {
</button>
</div>
);
}
}
Binary file not shown.

After

Width:  |  Height:  |  Size: 132 KiB

@@ -1,6 +1,13 @@
import { AUTH_USER_ID, CLAIM_NAME } from "@/src/shared/config";
import { getCookie, removeCookie, setCookie } from "@/src/shared/lib/cookie";
type JwtPayload = {
sub?: string;
exp?: number;
iat?: number;
[key: string]: unknown;
};
export class TokenManager {
private static instance: TokenManager;
private refreshTimer: NodeJS.Timeout | null = null;
@@ -18,6 +25,49 @@ export class TokenManager {
return getCookie(key);
}
private parseJwt(token: string): JwtPayload {
const parts = token.split(".");
if (parts.length !== 3) {
throw new Error("유효한 JWT 형식이 아닙니다.");
}
const base64Url = parts[1] ?? "";
const base64 = base64Url.replace(/-/g, "+").replace(/_/g, "/");
const padded = base64.padEnd(Math.ceil(base64.length / 4) * 4, "=");
const jsonPayload = decodeURIComponent(
atob(padded)
.split("")
.map((char) => `%${char.charCodeAt(0).toString(16).padStart(2, "0")}`)
.join("")
);
return JSON.parse(jsonPayload) as JwtPayload;
}
public getRemainingMsFromToken(key: string = CLAIM_NAME): number | null {
const token = this.getToken(key);
if (!token) {
return null;
}
try {
const payload = this.parseJwt(token);
if (typeof payload.exp !== "number") {
return null;
}
return payload.exp * 1000 - Date.now();
} catch (error) {
console.error("JWT 파싱 오류:", error);
return null;
}
}
public isExpiredToken(key: string = CLAIM_NAME): boolean {
const remainingMs = this.getRemainingMsFromToken(key);
return remainingMs === null || remainingMs <= 0;
}
public setToken(key: string, value: string): void {
setCookie(key, value, {
secure: location.protocol === "https:",
+1
View File
@@ -0,0 +1 @@
export { LoginPage } from "./ui/login-page";
@@ -0,0 +1,139 @@
"use client";
import Image from "next/image";
import { useEffect } from "react";
import { images } from "@common/assets";
import { LoginApi } from "@/src/entities/auth";
import "./login.css";
function LoginPage() {
useEffect(() => {
const hostname = window.location.hostname;
if (hostname.includes("hasuon.or.kr")) {
window.location.href =
process.env.NEXT_PUBLIC_CM_LOGIN_PAGE_URL || "/auth/login";
}
}, []);
const handleSubmit = async (e: React.FormEvent<HTMLFormElement>) => {
e.preventDefault();
const formData = new FormData(e.target as HTMLFormElement);
const userId = formData.get("id") as string;
const password = formData.get("password") as string;
await LoginApi.login({ userId, password });
window.location.href = "/pages/dashboard";
};
return (
<div className="login fx">
<div className="left_box fx">
<div className="text">
<h1>
하수도정보시스템
<br />
<span>CMS 통합관리시스템</span>
</h1>
<p>하수도정보시스템 CMS 통합관리시스템에 오신 것을 환영합니다.</p>
</div>
<div className="img">
<Image src={images.logo} width={229} height={46} alt="logo" />
</div>
</div>
<form
id="loginForm"
name="loginForm"
method="post"
onSubmit={handleSubmit}
>
<div className="login_box fx">
<ul className="fx">
<li>
<h2>ID</h2>
<div className="input_st st1">
<i className="diveicon di-account-box" />
<input
type="text"
className="id"
title="아이디를 입력하세요."
id="id"
name="id"
maxLength={12}
/>
</div>
</li>
<li>
<h2>PASSWORD</h2>
<div className="input_st st2">
<i className="diveicon di-key" />
<input
type="password"
className="input_style"
maxLength={25}
title="비밀번호를 입력하세요."
id="password"
name="password"
/>
</div>
</li>
</ul>
<div className="bottom_box fx">
<div className="checks fx">
<input
type="checkbox"
name="checkId"
title="로그인ID 저장여부"
id="checkId"
/>
<label htmlFor="checkId">아이디저장</label>
</div>
{/* <a href="javascript:fnUserSearch();" className=" pw_message fx"><i className="xi-external-link"></i>비밀번호 또는 아이디를 잃어버리셨나요?</a> */}
</div>
<button type="submit" className="login_btn fx">
<span>LOGIN</span>
</button>
<div className="footer">
<div className="copy fx">
<p>copyright © 2023 KECO. All rights reserved.</p>
</div>
<div className="fot_btn fx">
<a
href="https://keco.or.kr/web/lay1/S1T6C1348/contents.do"
target="_blank"
>
개인정보처리방침
</a>
<a
href="https://keco.or.kr/web/lay1/S1T6C1349/contents.do"
target="_blank"
>
저작권정책안내
</a>
<a
href="https://keco.or.kr/web/lay1/S1T6C1350/contents.do"
target="_blank"
>
이메일무단수집거부
</a>
</div>
<p className="addr fx">
<span>
주소 : (22689) 인천광역시 서구 환경로 42(경서동
종합환경연구단지)
</span>
<span>
전화 : (시스템) 032-590-4321, 3752, 4314 / (통 계) 032-590-3723,
3722
</span>
<span>팩스 :032-590-3729</span>
</p>
</div>
</div>
</form>
</div>
);
}
export { LoginPage };
@@ -0,0 +1,310 @@
/**
* OLYM Cascading Style Sheets
* File Name : login.css
* Description : 로그인 스타일시트
* Author : Web business Team / Choi Hyun Mi
* Email : desingtj@olym.co.kr
* Date : 2023.07.20
* Update : 2023.07.26
* Copyright(c) 2023 OLYM Communications. All Rights Reserved.
*/
html,
body {
background: #fff;
}
.fx {
display: flex;
-ms-display: flex;
-webkit-display: flex;
box-sizing: border-box;
}
.login {
width: 100%;
height: 100vh;
}
.login .left_box {
width: 100%;
background: url(/images/bg_login.jpg) no-repeat center/cover;
flex-direction: column;
justify-content: space-between;
padding: 7% 5% 5% 5%;
}
.login .left_box .text h1 {
font-size: 53px;
color: #fff;
}
.login .left_box .text h1 span {
font-weight: 800;
}
.login .left_box .text p {
margin-top: 40px;
font-size: 18px;
color: #fff;
}
.login .login_box {
width: 100%;
flex-direction: column;
justify-content: center;
padding: 4% 12% 4% 6%;
}
.login .login_box > ul {
flex-direction: column;
gap: 40px;
}
.login .login_box > ul > li h2 {
font-size: 22px;
font-weight: 600;
color: #2640a5;
margin-bottom: 14px;
}
.login .login_box > ul > li .input_st {
position: relative;
}
.login .login_box > ul > li .input_st .diveicon {
position: absolute;
top: 50%;
left: 28px;
transform: translateY(-50%);
font-family: "Xeicon";
font-size: 25px;
color: #003288;
}
.login .login_box > ul > li input {
width: 100%;
padding: 20px 20px 20px 70px;
background: #f7f7f7;
box-sizing: border-box;
border: none;
border-radius: 5px;
font-size: 18px;
font-weight: 700;
color: #000;
height: auto;
}
.login .login_box > ul > li input:focus {
outline: 1px solid #3c5fe5;
}
.login .login_box > ul > li input::placeholder {
color: #afabab;
font-weight: 500;
}
.login .login_box .bottom_box {
justify-content: space-between;
gap: 20px;
padding: 50px 0 45px 0;
}
.login .login_box .bottom_box .checks {
display: flex;
align-items: center;
gap: 8px;
}
.login .login_box .bottom_box .checks input[type="checkbox"] {
width: 18px;
height: 18px;
}
.login .login_box .bottom_box .checks label {
font-size: 14px;
}
.login .login_box .bottom_box .pw_message {
align-items: center;
gap: 5px;
color: #636363;
font-weight: 500;
border-bottom: 1px solid #ccc;
padding-bottom: 5px;
}
.login .login_box .bottom_box .pw_message i {
color: #327af5;
font-size: 20px;
}
.login .login_box .login_btn {
position: relative;
border-radius: 5px;
padding: 24px 15px;
background: linear-gradient(
to left,
#1d3969 0%,
#264078 10%,
#172558 40%,
#162254 50%,
#111c43 60%,
#111c42 100%
);
overflow: hidden;
justify-content: center;
align-items: center;
gap: 10px;
color: #fff;
cursor: pointer;
}
.login .login_box .login_btn span {
font-family: "Montserrat";
font-size: 19px;
font-weight: 700;
position: relative;
}
.login .login_box .login_btn i {
font-size: 22px;
transform: translateY(-2px);
}
.login .login_box .login_btn:before {
display: block;
content: "";
position: absolute;
top: 0;
left: 0;
width: 100%;
height: 100%;
opacity: 0;
background: linear-gradient(
to right,
#1d3969 0%,
#264078 10%,
#172558 40%,
#162254 50%,
#111c43 60%,
#111c42 100%
);
transition: opacity 0.5s;
}
.login .login_box .login_btn:hover::before {
opacity: 1;
}
.login .footer .copy {
margin-top: 36px;
justify-content: space-between;
align-items: center;
gap: 3%;
}
.login .footer .copy p {
font-size: 13px;
color: #d4d4d4;
width: 44%;
text-align: center;
}
.login .footer .copy::before,
.login .login_box .copy::after {
content: "";
width: 20%;
height: 1px;
background: #d4d4d4;
}
.login .footer .fot_btn {
gap: 20px;
margin: 30px 0 10px 0;
}
.login .footer .fot_btn a {
color: #333;
font-weight: 700;
font-size: 13px;
}
.login .footer .fot_btn a:hover {
color: #003288;
}
.login .footer .addr {
color: #666;
font-size: 13px;
gap: 3px 20px;
flex-wrap: wrap;
}
/* 비밀번호 변경 팝업창 */
.login .modal .modal_container {
width: 750px;
}
.login .pw_desc {
padding: 10px 10px 30px 10px;
color: #666;
}
.login .pw_desc p {
font-size: 1.25rem;
color: #000;
font-weight: 700;
margin-bottom: 10px;
}
.login .pw_desc span {
color: #f60;
font-weight: 700;
}
.login .modal .form_write {
border-top: 1px solid #ddd;
border-bottom: 1px solid #ddd;
padding: 20px 50px;
}
@media (max-width: 1024px) {
.login {
flex-direction: column;
height: auto;
}
.login .left_box {
padding: 30px;
flex-direction: column-reverse;
gap: 20px;
}
.login .left_box .text h1 {
font-size: 30px;
line-height: 1.2em;
}
.login .left_box .text p {
margin-top: 20px;
font-size: 14px;
}
.login .left_box .img img {
height: 40px;
}
.login .login_box {
padding: 30px;
}
.login .login_box > ul {
gap: 20px;
}
.login .login_box > ul > li h2 {
margin-bottom: 7px;
}
.login .login_box > ul > li .input_st::after {
left: 15px;
font-size: 20px;
}
.login .login_box > ul > li input {
padding: 15px 10px 15px 50px;
font-size: 16px;
}
.login .login_box .bottom_box {
flex-direction: column;
align-items: flex-start;
padding: 30px 0;
}
.login .login_box .bottom_box .id_save label {
font-size: 14px;
}
.login .login_box .login_btn {
padding: 15px;
}
.login .login_box .login_btn span {
font-size: 16px;
}
.login .login_box .login_btn i {
font-size: 18px;
transform: translateY(-1px);
}
.login .login_box .copy p {
width: 100%;
}
.login .login_box .copy::before,
.login .login_box .copy::after {
display: none;
}
.login .footer .addr {
font-size: 11px;
gap: 3px 10px;
}
.login .modal .modal_container {
width: 90%;
}
.login .pw_desc {
padding: 0 0 20px 0;
}
.login .modal .form_write {
padding: 10px;
}
}
/* // login */
+2 -2
View File
@@ -14,5 +14,5 @@ NEXT_PUBLIC_NGII_PARAM = 04trYP9_xwLAfALjwZ-B8g&service=WMTS&request=GetTile&ver
NEXT_PUBLIC_SERVER_API_URL = http://dev.onasys.co.kr:8000
NEXT_PUBLIC_DC_URL = https://apigateway.apps.rsonesoft.com
# NEXT_PUBLIC_CM_LOGIN_PAGE_URL = http://dev.onasys.co.kr:3000/auth/login
NEXT_PUBLIC_CM_LOGIN_PAGE_URL = /
NEXT_PUBLIC_SEWERAGE_API_URL=http://localhost:8883
NEXT_PUBLIC_CM_LOGIN_PAGE_URL = /auth/login
# NEXT_PUBLIC_SEWERAGE_API_URL=http://192.168.1.66:8883
+1 -1
View File
@@ -13,4 +13,4 @@ NEXT_PUBLIC_NGII_URL = https://map.ngii.go.kr
NEXT_PUBLIC_NGII_PARAM = 04trYP9_xwLAfALjwZ-B8g&service=WMTS&request=GetTile&version=1.0.0&format=image/png&layer=korean_map&style=korean&tilematrixset=korean&tilematrix={z}&tilecol={x}&tilerow={y}
NEXT_PUBLIC_SEWERAGE_API_URL = http://con.mind-one.co.kr:11083
NEXT_PUBLIC_DC_URL = https://apigateway.apps.rsonesoft.com
NEXT_PUBLIC_CM_LOGIN_PAGE_URL = /
NEXT_PUBLIC_CM_LOGIN_PAGE_URL = /auth/login
-1
View File
@@ -15,4 +15,3 @@ NEXT_PUBLIC_NGII_URL = http://172.29.143.87/poiact
NEXT_PUBLIC_NGII_PARAM = BDE2BEF39DAFEC293AB6730150F222AC&service=WMTS&request=GetTile&version=1.0.0&format=image/png&layer=korean_map&style=korean&tilematrixset=korean&tilematrix={z}&tilecol={y}&tilerow={x}
NEXT_PUBLIC_SEWERAGE_API_URL = http://apigateway.iipbs-was.svc.cluster.local:8000
NEXT_PUBLIC_DC_URL = http://apigateway.iipbs-was.svc.cluster.local:8000
NEXT_PUBLIC_CM_LOGIN_PAGE_URL = http://dev.onasys.co.kr:3000/auth/login
@@ -18,7 +18,7 @@ import { Button, TableHead, TableRow } from '@common/components/ui';
import React, { useState, useEffect } from 'react';
import { untrtSwgRainCol3, untrtSwgRainCol4, untrtSwgRainCol5 } from '../common/table';
import { VerticalTable } from '@/components/ui/verticalTable';
import { getData } from "@/components/lib/api";
import { getRawJson } from "@/components/lib/api";
import { formatDisplayData } from '@/components/lib/utils/formatDisplayValue';
import { Label } from '@radix-ui/react-label';
@@ -65,22 +65,7 @@ const Ssc = ({ featureData, isMainSsc, chmbrXcnts, chmbrYdnts, onSaveClick }: {
size: '3',
});
let result;
const res = await fetch(`/sewerage-service/api/v1/onsemiro/getSwgRainsByGeomety?${queryParams.toString()}`, {
method: "GET",
});
if (res.ok) {
// 응답이 비어있는지 확인
const contentLength = res.headers.get('content-length');
if (contentLength === '0') result = null;
// 응답 텍스트를 먼저 가져와서 빈 문자열인지 확인
const text = await res.text();
if (!text || text.trim() === '') result = null;
// JSON 파싱 시도
result = JSON.parse(text);
}
const result = await getRawJson(`/sewerage-service/api/v1/onsemiro/getSwgRainsByGeomety?${queryParams.toString()}`);
// helper: "YYYY-MM-DD HH:MM:SS" -> "MM-DD HH:MM"
const formatNoYearNoSec = (val: any) => {
@@ -138,22 +123,7 @@ const Ssc = ({ featureData, isMainSsc, chmbrXcnts, chmbrYdnts, onSaveClick }: {
size: '3',
});
let result;
const res = await fetch(`/sewerage-service/api/v1/onsemiro/swgRain/${adstDstrCd}/${manageNo}/mntrngs?${queryParams.toString()}`, {
method: "GET",
});
if (res.ok) {
// 응답이 비어있는지 확인
const contentLength = res.headers.get('content-length');
if (contentLength === '0') result = null;
// 응답 텍스트를 먼저 가져와서 빈 문자열인지 확인
const text = await res.text();
if (!text || text.trim() === '') result = null;
// JSON 파싱 시도
result = JSON.parse(text);
}
const result = await getRawJson(`/sewerage-service/api/v1/onsemiro/swgRain/${adstDstrCd}/${manageNo}/mntrngs?${queryParams.toString()}`);
// 각 아이템 전처리
const processedDataArray = result.content.map((data: any) => {
@@ -355,4 +325,4 @@ const Ssc = ({ featureData, isMainSsc, chmbrXcnts, chmbrYdnts, onSaveClick }: {
)
}
export default Ssc
export default Ssc
@@ -15,40 +15,20 @@
import { VerticalTable } from '@/components/ui/verticalTable';
import { useEffect, useState } from 'react';
import GisApp from "@common/business_components/ui/map/lib/gis.core";
import { getData } from '@/components/lib/api';
// 상류관로 보기 함수 (FeatureClickEventPopup에서 사용)
export async function searchUpperPpls(gisMap: any, fcltMngNo: any) {
const BASE_URL = "/sewerage-service/api";
const res = await fetch(`${BASE_URL}/v1/fcltmhl/upperPpls/${fcltMngNo}`, {
method: "GET",
});
let data: any = null;
if (res.ok) {
try {
// 응답이 비어있는지 확인
const contentLength = res.headers.get("content-length");
if (contentLength === "0") {
alert(`해당 맨홀의 상류관로를 찾을 수 없습니다.`);
return;
}
try {
data = await getData(`v1/fcltmhl/upperPpls/${fcltMngNo}`);
} catch (error) {
console.error("JSON parsing error:", error);
alert(`해당 맨홀의 상류관로를 찾을 수 없습니다.`);
return;
}
// 응답 텍스트를 먼저 가져와서 빈 문자열인지 확인
const text = await res.text();
if (!text || text.trim() === "") {
alert(`해당 맨홀의 상류관로를 찾을 수 없습니다.`);
return;
}
// JSON 파싱 시도
const response = JSON.parse(text);
data = response.data;
} catch (error) {
console.error("JSON parsing error:", error);
alert(`해당 맨홀의 상류관로를 찾을 수 없습니다.`);
return;
}
} else {
if (!data) {
alert(`해당 맨홀의 상류관로를 찾을 수 없습니다.`);
return;
}
@@ -143,4 +123,4 @@ const Smh = ({ featureData, onSaveClick }: { featureData: any, onSaveClick?: ()
)
}
export default Smh;
export default Smh;
+2 -17
View File
@@ -18,7 +18,7 @@
"use client";
import Gis from "@common/business_components/ui/map/Gis";
import { getData } from "../../components/lib/api";
import { getRawJson } from "../../components/lib/api";
import { useCallback, useEffect, useRef, useState } from "react";
import GisApp from "@common/business_components/ui/map/lib/gis.core";
import SearchModal from "./searchModal/searchModal";
@@ -43,22 +43,7 @@ const Map = () => {
const fetchData = async () => {
try {
let data;
const res = await fetch("/sewerage-service/api/v1/map/lyrStngs/SN_NETWORK", {
method: "GET",
});
if (res.ok) {
// 응답이 비어있는지 확인
const contentLength = res.headers.get('content-length');
if (contentLength === '0') data = null;
// 응답 텍스트를 먼저 가져와서 빈 문자열인지 확인
const text = await res.text();
if (!text || text.trim() === '') data = null;
// JSON 파싱 시도
data = JSON.parse(text);
}
const data = await getRawJson("/sewerage-service/api/v1/map/lyrStngs/SN_NETWORK");
setTreeData(data);
} catch (error) {
console.error('JSON parsing error:', error);
@@ -21,6 +21,7 @@
// FileUpload.js
import React, { useState } from 'react';
import axios from 'axios';
import { SITE_ID } from '@/shared/config';
const SEWERAGE_URL = "/sewerage-service/api";
function FileUpload() {
@@ -49,6 +50,7 @@ function FileUpload() {
method: "POST",
headers: {
"Content-Type": "application/json",
"X-Site-Id": SITE_ID,
},
body: JSON.stringify({authToken: auth})
}).then((data) => {
@@ -58,7 +60,11 @@ function FileUpload() {
const handleFindUserOna = () => {
const auth = getCookie("Authorization");
if (auth) console.log("쿠키 auth : " + auth);
fetch("/server/user-service/api/v1/users/65a00f65-8460-49af-98ec-042977e56f4b").then((data) => {
fetch("/server/user-service/api/v1/users/65a00f65-8460-49af-98ec-042977e56f4b", {
headers: {
"X-Site-Id": SITE_ID,
},
}).then((data) => {
console.log(data);
});
};
@@ -86,7 +92,11 @@ function FileUpload() {
formData.append('inpFile', file);
try {
const response = await axios.post('http://localhost:8883/sewerage-service/api/v1/rdiiAnls/savewithfile', formData);
const response = await axios.post('http://localhost:8883/sewerage-service/api/v1/rdiiAnls/savewithfile', formData, {
headers: {
"X-Site-Id": SITE_ID,
},
});
alert('업로드 성공: ' + response.data.message);
} catch (err) {
@@ -17,6 +17,7 @@ import { useState, useEffect } from 'react'
import LoadingOverlay from '@/components/ui/LoadingBar'
import { UITab, UiTable } from '@common/business_components/ui'
import * as XLSX from 'xlsx';
import { getBlobData } from '@/components/lib/api';
// import { Table, TableBody, TableHeader } from "@common/components/ui";
// import { HeadMakeFloodPre } from './headMakeFloodPre';
// import { InputTableBody } from './inputTableBody';
@@ -54,12 +55,7 @@ const TableModal: React.FC<TableModalProps> = ({ fileNm, atflId }) => {
return false
}
const response = await fetch(`/attach-service/api/v1/download/${atflId}`);
if (!response.ok) {
throw new Error('파일을 가져오는 중 오류가 발생했습니다.');
}
const blob = await response.blob();
const blob = await getBlobData(`/attach-service/api/v1/download/${atflId}`);
const file = new File([blob], fileNm, {
type: blob.type || 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet'
@@ -267,4 +263,4 @@ const TableModal: React.FC<TableModalProps> = ({ fileNm, atflId }) => {
)
};
export default TableModal;
export default TableModal;
@@ -19,6 +19,7 @@ import { UITab, UiTable } from '@common/business_components/ui'
import * as XLSX from 'xlsx';
import { HeadMakeColSpan } from '@common/business_components/ui/table/HeadMakeColSpan';
import { oldPpTable2HeadList, oldPpTable2NodeList, oldPpTable6HeadList, oldPpTable6NodeList } from './headList';
import { getBlobData } from '@/components/lib/api';
const TABS = [
{ val: "1", text: "raw data" },
@@ -63,12 +64,7 @@ const TableModal: React.FC<TableModalProps> = ({ fileNm, atflId }) => {
return false;
}
const response = await fetch(`/attach-service/api/v1/download/${atflId}`);
if (!response.ok) {
throw new Error('파일을 가져오는 중 오류가 발생했습니다.');
}
const blob = await response.blob();
const blob = await getBlobData(`/attach-service/api/v1/download/${atflId}`);
const file = new File([blob], fileNm, {
type: blob.type || 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet'
@@ -476,4 +472,4 @@ const TableModal: React.FC<TableModalProps> = ({ fileNm, atflId }) => {
)
};
export default TableModal;
export default TableModal;
+2
View File
@@ -0,0 +1,2 @@
import { LoginPage } from "@/features/auth/login";
export default LoginPage;
@@ -1,10 +1,12 @@
import { NextRequest, NextResponse } from "next/server";
import { tokenManager } from "@/entities/auth/lib/token-manager";
export async function GET(request: NextRequest) {
const hostname = request.nextUrl.hostname;
const redirectUrl = process.env.NEXT_PUBLIC_CM_LOGIN_PAGE_URL || "https://portal.hasuon.or.kr";
if (hostname === "localhost" || hostname === "127.0.0.1" || hostname === "con.mind-one.co.kr") {
tokenManager.clearAuthToken();
return NextResponse.redirect(new URL("/", request.url)); // 로컬 및 개발 환경
} else {
return NextResponse.redirect(new URL(redirectUrl)); // 공단 환경
+116 -9
View File
@@ -11,13 +11,51 @@
*/
import { useEffect, useState } from "react";
import axios from 'axios';
import { CLAIM_NAME, SITE_ID } from "@/shared/config";
import { tokenManager } from "@/entities/auth/lib/token-manager";
const BASE_URL = '/sewerage-service/api';
const DEFAULT_HEADERS = {
"X-Site-Id": SITE_ID,
};
const LOGIN_PAGE_URL =
process.env.NEXT_PUBLIC_CM_LOGIN_PAGE_URL || "/auth/login";
const withSiteIdHeaders = (headers: HeadersInit = {}) => ({
...headers,
...DEFAULT_HEADERS,
});
const resolveApiUrl = (url: string) =>
url.startsWith('/') ? `${url}` : `${BASE_URL}/${url}`;
const handleUnauthorizedResponse = () => {
if (typeof window === "undefined") return;
const remainingMs = tokenManager.getRemainingMsFromToken(CLAIM_NAME);
const isExpired = remainingMs === null || remainingMs <= 0;
if (!isExpired) return;
tokenManager.clearAuthToken();
const isLoginPage =
window.location.pathname === "/auth/login" ||
window.location.pathname.startsWith("/auth/login/");
if (!isLoginPage) {
window.location.assign(LOGIN_PAGE_URL);
}
};
export const getData = async (url: string): Promise<any> => {
const res = await fetch(url.startsWith('/') ? `${url}` : `${BASE_URL}/${url}`, {
const res = await fetch(resolveApiUrl(url), {
method: "GET",
headers: withSiteIdHeaders(),
});
if (res.status === 401) {
handleUnauthorizedResponse();
}
if (res.ok) {
try {
// 응답이 비어있는지 확인
@@ -47,12 +85,15 @@ export const getData = async (url: string): Promise<any> => {
export const postData = async (url: string, data: object): Promise<any> => {
const options: RequestInit = {
method: "POST",
headers: {
headers: withSiteIdHeaders({
"Content-Type": "application/json",
},
}),
body: JSON.stringify(data),
};
const res = await fetch(`${BASE_URL}/${url}`, options);
if (res.status === 401) {
handleUnauthorizedResponse();
}
if (res.ok) {
const resData = await res.json();
console.log('postData response:', resData);
@@ -62,6 +103,7 @@ export const postData = async (url: string, data: object): Promise<any> => {
export const postMutiPart = async (url: string, data: FormData): Promise<any> => {
const res = await axios.post(`${BASE_URL}/${url}`, data, {
headers: DEFAULT_HEADERS,
timeout: 600000, // 10분
});
console.log('postMultiPart response:', res);
@@ -71,12 +113,15 @@ export const postMutiPart = async (url: string, data: FormData): Promise<any> =>
export const putData = async (url: string, data: object): Promise<any> => {
const options: RequestInit = {
method: "PUT",
headers: {
headers: withSiteIdHeaders({
"Content-Type": "application/json",
},
}),
body: JSON.stringify(data),
};
const res = await fetch(`${BASE_URL}/${url}`, options);
if (res.status === 401) {
handleUnauthorizedResponse();
}
const resData = await res.json();
console.log('putData response:', resData);
return resData.data;
@@ -85,21 +130,75 @@ export const putData = async (url: string, data: object): Promise<any> => {
export const deleteData = async (url: string, data?: object): Promise<any> => {
const options: RequestInit = {
method: "DELETE",
headers: {
headers: withSiteIdHeaders({
"Content-Type": "application/json",
},
}),
body: data ? JSON.stringify(data) : undefined,
};
const res = await fetch(url.startsWith('/') ? `${url}` : `${BASE_URL}/${url}`, options);
const res = await fetch(resolveApiUrl(url), options);
if (res.status === 401) {
handleUnauthorizedResponse();
}
if (res.status === 204) return true
return res;
};
export const getRawJson = async (url: string): Promise<any> => {
const res = await fetch(resolveApiUrl(url), {
method: "GET",
headers: withSiteIdHeaders(),
});
if (res.status === 401) {
handleUnauthorizedResponse();
}
if (!res.ok) {
throw new Error('Failed to fetch data');
}
const contentLength = res.headers.get('content-length');
if (contentLength === '0') {
return null;
}
const text = await res.text();
if (!text || text.trim() === '') {
return null;
}
try {
return JSON.parse(text);
} catch (error) {
console.error('JSON parsing error:', error);
throw new Error('Invalid JSON response from server');
}
};
export const getBlobData = async (url: string): Promise<Blob> => {
const res = await fetch(resolveApiUrl(url), {
method: "GET",
headers: withSiteIdHeaders(),
});
if (res.status === 401) {
handleUnauthorizedResponse();
}
if (!res.ok) {
throw new Error('Failed to fetch data');
}
return res.blob();
};
// DC API 호출
export const getDcData = async (url: string, resType: string = "json"): Promise<any> => {
const res = await fetch('/dc-service' + url, {
method: "GET",
headers: withSiteIdHeaders(),
});
if (res.status === 401) {
handleUnauthorizedResponse();
}
if (res.ok) {
let data;
if (resType === "json") data = await res.json();
@@ -114,7 +213,11 @@ export const getDcData = async (url: string, resType: string = "json"): Promise<
export const getCmsData = async (url: string, data?: object): Promise<any> => {
const res = await fetch('/cms-service' + url, {
method: "GET",
headers: withSiteIdHeaders(),
});
if (res.status === 401) {
handleUnauthorizedResponse();
}
if (res.ok) {
const data = await res.json();
return data;
@@ -126,7 +229,11 @@ export const getCmsData = async (url: string, data?: object): Promise<any> => {
export const getGeoData = async (url: string, data?: object): Promise<any> => {
const res = await fetch('/geoserver' + url, {
method: "GET",
headers: withSiteIdHeaders(),
});
if (res.status === 401) {
handleUnauthorizedResponse();
}
if (res.ok) {
const data = await res.json();
return data;
@@ -157,4 +264,4 @@ export const useCookieAuth = () => {
}, []);
return data;
};
};
@@ -13,6 +13,7 @@
* - 첨부파일 삭제및 Soft Delete 삭제(첨부파일코드로 삭제): DELETE /api/v1/atfl/attachments/atflCd/{atflCd} (attach-service)
*/
import { getCookie } from "@/shared/lib";
import { SITE_ID } from "@/shared/config";
import { ENDPOINT } from "../api/attachment/attachment-service";
const AUTH_USER_ID = process.env.NEXT_PUBLIC_AUTH_USER_ID || "token-id";
@@ -43,6 +44,7 @@ export const handleDelete = async (
method: "DELETE",
headers: {
"Content-Type": "application/json",
"X-Site-Id": SITE_ID,
[CLAIM_NAME]: getCookie(CLAIM_NAME) ?? "",
[AUTH_USER_ID]: getCookie(AUTH_USER_ID) ?? "",
},
@@ -118,4 +120,4 @@ export const handleDelete = async (
// options?.handleDeleteError?.(deleteError);
// throw deleteError;
// }
// };
// };
@@ -10,10 +10,15 @@
* - Fetch 기반 HTTP 요청 처리
*/
import { SITE_ID } from "@/shared/config";
export const handleDownload = async (api: string, fileNm: string) => {
try {
const response = await fetch(api, {
method: 'GET',
headers: {
"X-Site-Id": SITE_ID,
},
});
if (!response.ok) throw new Error('파일 다운로드 실패');
@@ -34,4 +39,4 @@ export const fileDownload = (data: Blob, fileName: string) => { // Blob데이터
// 메모리 해제
link.remove();
window.URL.revokeObjectURL(url);
}
}
@@ -7,7 +7,7 @@ import { downloadExcelWithKoreanHeaders } from "@common/business_components/ui";
import Stroke from "ol/style/Stroke.js";
import Style from "ol/style/Style.js";
import useStore from "@/store/useStore";
import { getData } from "@/components/lib/api";
import { getRawJson } from "@/components/lib/api";
import AgedPipeFilterPanel from "./AgedPipeFilterPanel";
import AgedPipeDetailPanel from "./AgedPipeDetailPanel";
import AgedPipeListModal from "./AgedPipeListModal";
@@ -102,19 +102,7 @@ const Map = () => {
useEffect(() => {
const fetchData = async () => {
try {
let data;
const response = await fetch("/sewerage-service/api/v1/map/lyrStngs/SN_NETWORK", {
method: "GET",
});
if (response.ok) {
const contentLength = response.headers.get("content-length");
if (contentLength === "0") data = null;
const text = await response.text();
if (!text || text.trim() === "") data = null;
else data = JSON.parse(text);
}
const data = await getRawJson("/sewerage-service/api/v1/map/lyrStngs/SN_NETWORK");
setTreeData(data);
} catch (error) {
console.error("노후관로 레이어 설정 조회 중 오류:", error);
@@ -18,7 +18,7 @@ import { Button, TableHead, TableRow } from '@common/components/ui';
import React, { useState, useEffect } from 'react';
import { untrtSwgRainCol3, untrtSwgRainCol4, untrtSwgRainCol5 } from '../common/table';
import { VerticalTable } from '@/components/ui/verticalTable';
import { getData } from "@/components/lib/api";
import { getRawJson } from "@/components/lib/api";
import { formatDisplayData } from '@/components/lib/utils/formatDisplayValue';
import { Label } from '@radix-ui/react-label';
@@ -65,22 +65,7 @@ const Ssc = ({ featureData, isMainSsc, chmbrXcnts, chmbrYdnts, onSaveClick }: {
size: '3',
});
let result;
const res = await fetch(`/sewerage-service/api/v1/onsemiro/getSwgRainsByGeomety?${queryParams.toString()}`, {
method: "GET",
});
if (res.ok) {
// 응답이 비어있는지 확인
const contentLength = res.headers.get('content-length');
if (contentLength === '0') result = null;
// 응답 텍스트를 먼저 가져와서 빈 문자열인지 확인
const text = await res.text();
if (!text || text.trim() === '') result = null;
// JSON 파싱 시도
result = JSON.parse(text);
}
const result = await getRawJson(`/sewerage-service/api/v1/onsemiro/getSwgRainsByGeomety?${queryParams.toString()}`);
// helper: "YYYY-MM-DD HH:MM:SS" -> "MM-DD HH:MM"
const formatNoYearNoSec = (val: any) => {
@@ -138,22 +123,7 @@ const Ssc = ({ featureData, isMainSsc, chmbrXcnts, chmbrYdnts, onSaveClick }: {
size: '3',
});
let result;
const res = await fetch(`/sewerage-service/api/v1/onsemiro/swgRain/${adstDstrCd}/${manageNo}/mntrngs?${queryParams.toString()}`, {
method: "GET",
});
if (res.ok) {
// 응답이 비어있는지 확인
const contentLength = res.headers.get('content-length');
if (contentLength === '0') result = null;
// 응답 텍스트를 먼저 가져와서 빈 문자열인지 확인
const text = await res.text();
if (!text || text.trim() === '') result = null;
// JSON 파싱 시도
result = JSON.parse(text);
}
const result = await getRawJson(`/sewerage-service/api/v1/onsemiro/swgRain/${adstDstrCd}/${manageNo}/mntrngs?${queryParams.toString()}`);
// 각 아이템 전처리
const processedDataArray = result.content.map((data: any) => {
@@ -355,4 +325,4 @@ const Ssc = ({ featureData, isMainSsc, chmbrXcnts, chmbrYdnts, onSaveClick }: {
)
}
export default Ssc
export default Ssc
@@ -15,40 +15,20 @@
import { VerticalTable } from '@/components/ui/verticalTable';
import { useEffect, useState } from 'react';
import GisApp from "@common/business_components/ui/map/lib/gis.core";
import { getData } from '@/components/lib/api';
// 상류관로 보기 함수 (FeatureClickEventPopup에서 사용)
export async function searchUpperPpls(gisMap: any, fcltMngNo: any) {
const BASE_URL = "/sewerage-service/api";
const res = await fetch(`${BASE_URL}/v1/fcltmhl/upperPpls/${fcltMngNo}`, {
method: "GET",
});
let data: any = null;
if (res.ok) {
try {
// 응답이 비어있는지 확인
const contentLength = res.headers.get("content-length");
if (contentLength === "0") {
alert(`해당 맨홀의 상류관로를 찾을 수 없습니다.`);
return;
}
try {
data = await getData(`v1/fcltmhl/upperPpls/${fcltMngNo}`);
} catch (error) {
console.error("JSON parsing error:", error);
alert(`해당 맨홀의 상류관로를 찾을 수 없습니다.`);
return;
}
// 응답 텍스트를 먼저 가져와서 빈 문자열인지 확인
const text = await res.text();
if (!text || text.trim() === "") {
alert(`해당 맨홀의 상류관로를 찾을 수 없습니다.`);
return;
}
// JSON 파싱 시도
const response = JSON.parse(text);
data = response.data;
} catch (error) {
console.error("JSON parsing error:", error);
alert(`해당 맨홀의 상류관로를 찾을 수 없습니다.`);
return;
}
} else {
if (!data) {
alert(`해당 맨홀의 상류관로를 찾을 수 없습니다.`);
return;
}
@@ -143,4 +123,4 @@ const Smh = ({ featureData, onSaveClick }: { featureData: any, onSaveClick?: ()
)
}
export default Smh;
export default Smh;
+2 -17
View File
@@ -18,7 +18,7 @@
"use client";
import Gis from "@common/business_components/ui/map/Gis";
import { getData } from "../../../components/lib/api";
import { getRawJson } from "../../../components/lib/api";
import { useCallback, useEffect, useRef, useState } from "react";
import GisApp from "@common/business_components/ui/map/lib/gis.core";
import SearchModal from "./searchModal/searchModal";
@@ -43,22 +43,7 @@ const Map = () => {
const fetchData = async () => {
try {
let data;
const res = await fetch("/sewerage-service/api/v1/map/lyrStngs/SN_NETWORK", {
method: "GET",
});
if (res.ok) {
// 응답이 비어있는지 확인
const contentLength = res.headers.get('content-length');
if (contentLength === '0') data = null;
// 응답 텍스트를 먼저 가져와서 빈 문자열인지 확인
const text = await res.text();
if (!text || text.trim() === '') data = null;
// JSON 파싱 시도
data = JSON.parse(text);
}
const data = await getRawJson("/sewerage-service/api/v1/map/lyrStngs/SN_NETWORK");
setTreeData(data);
} catch (error) {
console.error('JSON parsing error:', error);
Binary file not shown.

After

Width:  |  Height:  |  Size: 132 KiB

+12 -1
View File
@@ -5,6 +5,7 @@ import {
ACCESS_TOKEN,
AUTH_USER_ID,
CLAIM_NAME,
SITE_ID,
} from "@/shared/config";
import type { LoginService, ILogin } from "../model/login-interface";
import { tokenManager } from "../lib/token-manager";
@@ -23,8 +24,13 @@ export class LoginApiService implements LoginService {
public async login(payload: ILogin): Promise<void> {
try {
tokenManager.clearAuthToken();
const response = await fetch(`/api/proxy${ENDPOINT.USER_SERVICE.LOGIN}`, {
method: "POST",
headers: {
"Content-Type": "application/json",
"X-Site-Id": SITE_ID,
},
body: JSON.stringify({
userId: payload.userId,
password: payload.password,
@@ -57,7 +63,12 @@ export class LoginApiService implements LoginService {
try {
const response = await fetch(
`/api/proxy${ENDPOINT.USER_SERVICE.SILENT_REFRESH}`,
{ method: "PUT" },
{
method: "PUT",
headers: {
"X-Site-Id": SITE_ID,
},
},
);
const data = await response.json();
@@ -8,6 +8,7 @@ import { useUserStore } from "@/shared/lib";
import { ENDPOINT, AUTH_USER_ID } from "@/shared/config";
import { isAtLoginRedirectUrl } from "@common/network";
import { getCookie } from "@/shared/lib";
import { tokenManager } from "./token-manager";
const LOGIN_PAGE_URL =
process.env.NEXT_PUBLIC_CM_LOGIN_PAGE_URL || "/auth/login";
@@ -38,6 +39,7 @@ const AuthWrapper = ({ children }: { children: React.ReactNode }) => {
await authCheck();
} catch (error) {
console.error(error);
setUserInfo(null);
if (isAtLoginRedirectUrl(LOGIN_PAGE_URL)) return;
@@ -1,6 +1,13 @@
import { AUTH_USER_ID, CLAIM_NAME } from "@/shared/config";
import { getCookie, setCookie, removeCookie } from "@/shared/lib";;
type JwtPayload = {
sub?: string;
exp?: number;
iat?: number;
[key: string]: unknown;
};
export class TokenManager {
private static instance: TokenManager;
private refreshTimer: NodeJS.Timeout | null = null;
@@ -18,6 +25,49 @@ export class TokenManager {
return getCookie(key);
}
private parseJwt(token: string): JwtPayload {
const parts = token.split(".");
if (parts.length !== 3) {
throw new Error("유효한 JWT 형식이 아닙니다.");
}
const base64Url = parts[1] ?? "";
const base64 = base64Url.replace(/-/g, "+").replace(/_/g, "/");
const padded = base64.padEnd(Math.ceil(base64.length / 4) * 4, "=");
const jsonPayload = decodeURIComponent(
atob(padded)
.split("")
.map((char) => `%${char.charCodeAt(0).toString(16).padStart(2, "0")}`)
.join("")
);
return JSON.parse(jsonPayload) as JwtPayload;
}
public getRemainingMsFromToken(key: string = CLAIM_NAME): number | null {
const token = this.getToken(key);
if (!token) {
return null;
}
try {
const payload = this.parseJwt(token);
if (typeof payload.exp !== "number") {
return null;
}
return payload.exp * 1000 - Date.now();
} catch (error) {
console.error("JWT 파싱 오류:", error);
return null;
}
}
public isExpiredToken(key: string = CLAIM_NAME): boolean {
const remainingMs = this.getRemainingMsFromToken(key);
return remainingMs === null || remainingMs <= 0;
}
public setToken(key: string, value: string): void {
// 환경에 따른 도메인 설정
//const domain = this.getCookieDomain();
+1
View File
@@ -0,0 +1 @@
export { LoginPage } from "./ui/login-page";
@@ -0,0 +1,139 @@
"use client";
import Image from "next/image";
import { useEffect } from "react";
import { images } from "@common/assets";
import { LoginApi } from "@/entities/auth";
import "./login.css";
function LoginPage() {
useEffect(() => {
const hostname = window.location.hostname;
if (hostname.includes("hasuon.or.kr")) {
window.location.href =
process.env.NEXT_PUBLIC_CM_LOGIN_PAGE_URL || "/auth/login";
}
}, []);
const handleSubmit = async (e: React.FormEvent<HTMLFormElement>) => {
e.preventDefault();
const formData = new FormData(e.target as HTMLFormElement);
const userId = formData.get("id") as string;
const password = formData.get("password") as string;
await LoginApi.login({ userId, password });
window.location.href = "/map";
};
return (
<div className="login fx">
<div className="left_box fx">
<div className="text">
<h1>
하수도정보시스템
<br />
<span>CMS 통합관리시스템</span>
</h1>
<p>하수도정보시스템 CMS 통합관리시스템에 오신 것을 환영합니다.</p>
</div>
<div className="img">
<Image src={images.logo} width={229} height={46} alt="logo" />
</div>
</div>
<form
id="loginForm"
name="loginForm"
method="post"
onSubmit={handleSubmit}
>
<div className="login_box fx">
<ul className="fx">
<li>
<h2>ID</h2>
<div className="input_st st1">
<i className="diveicon di-account-box" />
<input
type="text"
className="id"
title="아이디를 입력하세요."
id="id"
name="id"
maxLength={12}
/>
</div>
</li>
<li>
<h2>PASSWORD</h2>
<div className="input_st st2">
<i className="diveicon di-key" />
<input
type="password"
className="input_style"
maxLength={25}
title="비밀번호를 입력하세요."
id="password"
name="password"
/>
</div>
</li>
</ul>
<div className="bottom_box fx">
<div className="checks fx">
<input
type="checkbox"
name="checkId"
title="로그인ID 저장여부"
id="checkId"
/>
<label htmlFor="checkId">아이디저장</label>
</div>
{/* <a href="javascript:fnUserSearch();" className=" pw_message fx"><i className="xi-external-link"></i>비밀번호 또는 아이디를 잃어버리셨나요?</a> */}
</div>
<button type="submit" className="login_btn fx">
<span>LOGIN</span>
</button>
<div className="footer">
<div className="copy fx">
<p>copyright © 2023 KECO. All rights reserved.</p>
</div>
<div className="fot_btn fx">
<a
href="https://keco.or.kr/web/lay1/S1T6C1348/contents.do"
target="_blank"
>
개인정보처리방침
</a>
<a
href="https://keco.or.kr/web/lay1/S1T6C1349/contents.do"
target="_blank"
>
저작권정책안내
</a>
<a
href="https://keco.or.kr/web/lay1/S1T6C1350/contents.do"
target="_blank"
>
이메일무단수집거부
</a>
</div>
<p className="addr fx">
<span>
주소 : (22689) 인천광역시 서구 환경로 42(경서동
종합환경연구단지)
</span>
<span>
전화 : (시스템) 032-590-4321, 3752, 4314 / (통 계) 032-590-3723,
3722
</span>
<span>팩스 :032-590-3729</span>
</p>
</div>
</div>
</form>
</div>
);
}
export { LoginPage };
@@ -0,0 +1,310 @@
/**
* OLYM Cascading Style Sheets
* File Name : login.css
* Description : 로그인 스타일시트
* Author : Web business Team / Choi Hyun Mi
* Email : desingtj@olym.co.kr
* Date : 2023.07.20
* Update : 2023.07.26
* Copyright(c) 2023 OLYM Communications. All Rights Reserved.
*/
html,
body {
background: #fff;
}
.fx {
display: flex;
-ms-display: flex;
-webkit-display: flex;
box-sizing: border-box;
}
.login {
width: 100%;
height: 100vh;
}
.login .left_box {
width: 100%;
background: url(/images/bg_login.jpg) no-repeat center/cover;
flex-direction: column;
justify-content: space-between;
padding: 7% 5% 5% 5%;
}
.login .left_box .text h1 {
font-size: 53px;
color: #fff;
}
.login .left_box .text h1 span {
font-weight: 800;
}
.login .left_box .text p {
margin-top: 40px;
font-size: 18px;
color: #fff;
}
.login .login_box {
width: 100%;
flex-direction: column;
justify-content: center;
padding: 4% 12% 4% 6%;
}
.login .login_box > ul {
flex-direction: column;
gap: 40px;
}
.login .login_box > ul > li h2 {
font-size: 22px;
font-weight: 600;
color: #2640a5;
margin-bottom: 14px;
}
.login .login_box > ul > li .input_st {
position: relative;
}
.login .login_box > ul > li .input_st .diveicon {
position: absolute;
top: 50%;
left: 28px;
transform: translateY(-50%);
font-family: "Xeicon";
font-size: 25px;
color: #003288;
}
.login .login_box > ul > li input {
width: 100%;
padding: 20px 20px 20px 70px;
background: #f7f7f7;
box-sizing: border-box;
border: none;
border-radius: 5px;
font-size: 18px;
font-weight: 700;
color: #000;
height: auto;
}
.login .login_box > ul > li input:focus {
outline: 1px solid #3c5fe5;
}
.login .login_box > ul > li input::placeholder {
color: #afabab;
font-weight: 500;
}
.login .login_box .bottom_box {
justify-content: space-between;
gap: 20px;
padding: 50px 0 45px 0;
}
.login .login_box .bottom_box .checks {
display: flex;
align-items: center;
gap: 8px;
}
.login .login_box .bottom_box .checks input[type="checkbox"] {
width: 18px;
height: 18px;
}
.login .login_box .bottom_box .checks label {
font-size: 14px;
}
.login .login_box .bottom_box .pw_message {
align-items: center;
gap: 5px;
color: #636363;
font-weight: 500;
border-bottom: 1px solid #ccc;
padding-bottom: 5px;
}
.login .login_box .bottom_box .pw_message i {
color: #327af5;
font-size: 20px;
}
.login .login_box .login_btn {
position: relative;
border-radius: 5px;
padding: 24px 15px;
background: linear-gradient(
to left,
#1d3969 0%,
#264078 10%,
#172558 40%,
#162254 50%,
#111c43 60%,
#111c42 100%
);
overflow: hidden;
justify-content: center;
align-items: center;
gap: 10px;
color: #fff;
cursor: pointer;
}
.login .login_box .login_btn span {
font-family: "Montserrat";
font-size: 19px;
font-weight: 700;
position: relative;
}
.login .login_box .login_btn i {
font-size: 22px;
transform: translateY(-2px);
}
.login .login_box .login_btn:before {
display: block;
content: "";
position: absolute;
top: 0;
left: 0;
width: 100%;
height: 100%;
opacity: 0;
background: linear-gradient(
to right,
#1d3969 0%,
#264078 10%,
#172558 40%,
#162254 50%,
#111c43 60%,
#111c42 100%
);
transition: opacity 0.5s;
}
.login .login_box .login_btn:hover::before {
opacity: 1;
}
.login .footer .copy {
margin-top: 36px;
justify-content: space-between;
align-items: center;
gap: 3%;
}
.login .footer .copy p {
font-size: 13px;
color: #d4d4d4;
width: 44%;
text-align: center;
}
.login .footer .copy::before,
.login .login_box .copy::after {
content: "";
width: 20%;
height: 1px;
background: #d4d4d4;
}
.login .footer .fot_btn {
gap: 20px;
margin: 30px 0 10px 0;
}
.login .footer .fot_btn a {
color: #333;
font-weight: 700;
font-size: 13px;
}
.login .footer .fot_btn a:hover {
color: #003288;
}
.login .footer .addr {
color: #666;
font-size: 13px;
gap: 3px 20px;
flex-wrap: wrap;
}
/* 비밀번호 변경 팝업창 */
.login .modal .modal_container {
width: 750px;
}
.login .pw_desc {
padding: 10px 10px 30px 10px;
color: #666;
}
.login .pw_desc p {
font-size: 1.25rem;
color: #000;
font-weight: 700;
margin-bottom: 10px;
}
.login .pw_desc span {
color: #f60;
font-weight: 700;
}
.login .modal .form_write {
border-top: 1px solid #ddd;
border-bottom: 1px solid #ddd;
padding: 20px 50px;
}
@media (max-width: 1024px) {
.login {
flex-direction: column;
height: auto;
}
.login .left_box {
padding: 30px;
flex-direction: column-reverse;
gap: 20px;
}
.login .left_box .text h1 {
font-size: 30px;
line-height: 1.2em;
}
.login .left_box .text p {
margin-top: 20px;
font-size: 14px;
}
.login .left_box .img img {
height: 40px;
}
.login .login_box {
padding: 30px;
}
.login .login_box > ul {
gap: 20px;
}
.login .login_box > ul > li h2 {
margin-bottom: 7px;
}
.login .login_box > ul > li .input_st::after {
left: 15px;
font-size: 20px;
}
.login .login_box > ul > li input {
padding: 15px 10px 15px 50px;
font-size: 16px;
}
.login .login_box .bottom_box {
flex-direction: column;
align-items: flex-start;
padding: 30px 0;
}
.login .login_box .bottom_box .id_save label {
font-size: 14px;
}
.login .login_box .login_btn {
padding: 15px;
}
.login .login_box .login_btn span {
font-size: 16px;
}
.login .login_box .login_btn i {
font-size: 18px;
transform: translateY(-1px);
}
.login .login_box .copy p {
width: 100%;
}
.login .login_box .copy::before,
.login .login_box .copy::after {
display: none;
}
.login .footer .addr {
font-size: 11px;
gap: 3px 10px;
}
.login .modal .modal_container {
width: 90%;
}
.login .pw_desc {
padding: 0 0 20px 0;
}
.login .modal .form_write {
padding: 10px;
}
}
/* // login */
@@ -1,3 +1,4 @@
"use client";
import { createContext, useState } from "react";
interface PromptContext {
+74 -74
View File
@@ -1,76 +1,76 @@
{
"name": "st",
"version": "0.1.0",
"private": true,
"scripts": {
"dev": "next dev",
"build": "next build",
"start": "next start",
"lint": "next lint"
},
"dependencies": {
"@common/assets": "workspace:*",
"@common/auth": "workspace:*",
"@common/components": "workspace:*",
"@common/config": "workspace:*",
"@common/network": "workspace:*",
"@rsone/components": "workspace:*",
"@faker-js/faker": "^9.9.0",
"@fontsource-variable/noto-sans-kr": "^5.2.5",
"@frontend-opensource/use-react-hooks": "^1.1.4",
"@fullcalendar/core": "^6.1.17",
"@fullcalendar/daygrid": "^6.1.17",
"@fullcalendar/interaction": "^6.1.17",
"@fullcalendar/react": "^6.1.17",
"@fullcalendar/timegrid": "^6.1.17",
"@hookform/resolvers": "^4.1.3",
"@minoru/react-dnd-treeview": "3.5.1-alpha.1",
"@radix-ui/react-icons": "^1.3.2",
"@radix-ui/react-label": "^2.1.2",
"@radix-ui/react-slot": "^1.1.2",
"@radix-ui/react-tooltip": "^1.1.8",
"@tanstack/react-query": "^5.67.1",
"@tanstack/react-query-devtools": "^5.75.5",
"@tanstack/react-table": "^8.21.2",
"@tinymce/tinymce-react": "^5.1.1",
"@types/xlsx": "^0.0.36",
"axios": "^1.8.1",
"chart.js": "^4.4.8",
"chartjs-plugin-zoom": "^2.2.0",
"dayjs": "^1.11.13",
"js-cookie": "^3.0.5",
"jszip": "^3.10.1",
"lucide-react": "^0.474.0",
"next": "15.3.8",
"next-qrcode": "^2.5.1",
"next-themes": "^0.4.6",
"react-chartjs-2": "^5.3.0",
"react-dnd": "^16.0.1",
"react-hook-form": "^7.54.2",
"realgrid": "^2.9.5",
"realgrid-react": "^0.9.7",
"remixicon": "^4.7.0",
"sonner": "^2.0.1",
"swr": "^2.3.7",
"tailwindcss-animate": "^1.0.7",
"tus-js-client": "^4.3.1",
"xlsx": "^0.18.5",
"zod": "^3.24.2",
"zustand": "^5.0.3"
},
"devDependencies": {
"@eslint/eslintrc": "^3",
"@types/js-cookie": "^3.0.6",
"@types/node": "^22.13.1",
"@types/webpack": "^5.28.5",
"eslint": "^9",
"eslint-config-next": "15.3.8",
"eslint-plugin-react-hooks": "^5.0.0",
"postcss": "^8",
"prettier": "^3.4.2",
"prettier-plugin-tailwindcss": "^0.6.11",
"steiger": "^0.5.5",
"tailwindcss": "^3.4.1",
"typescript": "^5"
}
"name": "st",
"version": "0.1.0",
"private": true,
"scripts": {
"dev": "next dev",
"build": "next build",
"start": "next start",
"lint": "next lint"
},
"dependencies": {
"@common/assets": "workspace:*",
"@common/auth": "workspace:*",
"@common/components": "workspace:*",
"@common/config": "workspace:*",
"@common/network": "workspace:*",
"@rsone/components": "workspace:*",
"@faker-js/faker": "^9.9.0",
"@fontsource-variable/noto-sans-kr": "^5.2.5",
"@frontend-opensource/use-react-hooks": "^1.1.4",
"@fullcalendar/core": "^6.1.17",
"@fullcalendar/daygrid": "^6.1.17",
"@fullcalendar/interaction": "^6.1.17",
"@fullcalendar/react": "^6.1.17",
"@fullcalendar/timegrid": "^6.1.17",
"@hookform/resolvers": "^4.1.3",
"@minoru/react-dnd-treeview": "3.5.1-alpha.1",
"@radix-ui/react-icons": "^1.3.2",
"@radix-ui/react-label": "^2.1.2",
"@radix-ui/react-slot": "^1.1.2",
"@radix-ui/react-tooltip": "^1.1.8",
"@tanstack/react-query": "^5.67.1",
"@tanstack/react-query-devtools": "^5.75.5",
"@tanstack/react-table": "^8.21.2",
"@tinymce/tinymce-react": "^5.1.1",
"@types/xlsx": "^0.0.36",
"axios": "^1.8.1",
"chart.js": "^4.4.8",
"chartjs-plugin-zoom": "^2.2.0",
"dayjs": "^1.11.13",
"js-cookie": "^3.0.5",
"jszip": "^3.10.1",
"lucide-react": "^0.474.0",
"next": "15.3.8",
"next-qrcode": "^2.5.1",
"next-themes": "^0.4.6",
"react-chartjs-2": "^5.3.0",
"react-dnd": "^16.0.1",
"react-hook-form": "^7.54.2",
"realgrid": "^2.9.5",
"realgrid-react": "^0.9.7",
"remixicon": "^4.7.0",
"sonner": "^2.0.1",
"swr": "^2.3.7",
"tailwindcss-animate": "^1.0.7",
"tus-js-client": "^4.3.1",
"xlsx": "^0.18.5",
"zod": "^3.24.2",
"zustand": "^5.0.3"
},
"devDependencies": {
"@eslint/eslintrc": "^3",
"@types/js-cookie": "^3.0.6",
"@types/node": "^22.13.1",
"@types/webpack": "^5.28.5",
"eslint": "^9",
"eslint-config-next": "15.3.8",
"eslint-plugin-react-hooks": "^5.0.0",
"postcss": "^8",
"prettier": "^3.4.2",
"prettier-plugin-tailwindcss": "^0.6.11",
"steiger": "^0.5.5",
"tailwindcss": "^3.4.1",
"typescript": "^5"
}
}
@@ -12,6 +12,7 @@ import { AlertDialog } from "@/shared/ui/alert-dialog";
import { PromptDialog } from "@/shared/ui/prompt-dialog";
import { ConfirmDialog } from "@/shared/ui/confirm-dialog";
import { SelectPromptDialog } from "@/shared/ui/select-prompt-dialog";
import { GlobalProgressOverlay } from "@/shared/ui/global-progress-overlay";
import { PromptProvider, StoreProvider } from "@/shared/providers";
import { AlertDialog as RsoneAlertDialog } from "@rsone/components";
@@ -36,6 +37,7 @@ const AppProvider = ({ children }: Readonly<{ children: React.ReactNode }>) => {
<PromptDialog />
<ConfirmDialog />
<SelectPromptDialog />
<GlobalProgressOverlay />
<Toaster position="top-center" />
</AuthWrapper>
</ConfirmProvider>
@@ -1,7 +1,7 @@
import { Button } from "@common/components";
interface ReviewOpinionButtonProps {
reviewOpinionFunc: () => void;
reviewOpinionFunc: () => void | Promise<void>;
}
const ReviewOpinionButton = ({ reviewOpinionFunc }: ReviewOpinionButtonProps) => {
@@ -0,0 +1,16 @@
import { Button } from "@common/components";
interface VerificationResultButtonProps {
VerificationResultButtonFunc: () => void;
}
const VerificationResultButton = ({ VerificationResultButtonFunc }: VerificationResultButtonProps) => {
return (
<Button color="white" className="h-[3.2rem]" onClick={VerificationResultButtonFunc}>
<i className="diveicon di-list" />
<span>지난 검증 결과</span>
</Button>
);
};
export default VerificationResultButton;
+6 -6
View File
@@ -16,12 +16,6 @@
@apply !bg-[#5987bf] !text-[#fff];
}
.rg-grid .rg-header .rg-table tr td,
.rg-grid .rg-head .rg-table tr td {
border: none;
border-bottom: 1px solid #e5e7eb;
}
.rg-grid .rg-body .rg-table .rg-data-row .rg-data-cell {
@apply !bg-white;
}
@@ -138,4 +132,10 @@
.rg-grid .rg-body .rg-table tr.rg-alternate-row td,
.rg-grid .rg-body .rg-table tr.rg-alternate-row .rg-data-cell {
background-color: #ffffff !important;
}
.rg-grid .rg-header .rg-table tr td,
.rg-grid .rg-head .rg-table tr td {
border: none;
border-bottom: 1px solid #e5e7eb;
}
+144 -55
View File
@@ -311,6 +311,7 @@ function buildFieldsFromColumns(
const DataGrid = ({
columnInfo,
rowStartNum,
excelExportFileSuffix,
isExcelImport = true,
isExcelExport = true,
fixedColumns,
@@ -318,13 +319,19 @@ const DataGrid = ({
rowData,
setRowData,
getGridDataRef,
getGridRef,
cellStyleCallback,
cellTooltipCallback,
onCellEdited,
onRowClick,
onItemChecked,
onItemAllChecked,
onScrollToBottom,
statusInfo,
highlightedRowId,
idFieldName = "id",
enableSort = false,
gridHeight = 65, // vh 단위
}: DataGridProps) => {
const { columns, columnLayout } = columnInfo;
const gridRef = useRef<RealGridReact>(null);
@@ -340,9 +347,20 @@ const DataGrid = ({
const cellTooltipCallbackRef = useRef<CellTooltipCallback | undefined>(
cellTooltipCallback,
);
const onCellEditedRef = useRef<typeof onCellEdited>(onCellEdited);
const onScrollToBottomRef = useRef<(() => void) | undefined>(
onScrollToBottom,
);
const onItemCheckedRef = useRef<typeof onItemChecked>(onItemChecked);
const onItemAllCheckedRef =
useRef<typeof onItemAllChecked>(onItemAllChecked);
rowDataRef.current = rowData;
cellStyleCallbackRef.current = cellStyleCallback;
cellTooltipCallbackRef.current = cellTooltipCallback;
onScrollToBottomRef.current = onScrollToBottom;
onItemCheckedRef.current = onItemChecked;
onItemAllCheckedRef.current = onItemAllChecked;
onCellEditedRef.current = onCellEdited;
// highlightedRowId가 변경될 때 해당 행으로 스크롤 및 선택
useEffect(() => {
@@ -470,10 +488,14 @@ const DataGrid = ({
gridView.setColumnLayout?.(normalizedLayout);
}
const exportBaseName = excelExportFileSuffix
? `${menuName}_${excelExportFileSuffix}`
: menuName;
gridView.exportGrid({
type: "excel",
target: "local",
fileName: `${menuName}.xlsx`,
fileName: `${exportBaseName}.xlsx`,
footer: "hidden",
allColumns: true, // 숨김(visible: false) 컬럼도 엑셀에 포함
done: () => {
@@ -558,10 +580,10 @@ const DataGrid = ({
) {
const cell =
worksheet[
XLSX.utils.encode_cell({
r: rowIndex,
c: col,
})
XLSX.utils.encode_cell({
r: rowIndex,
c: col,
})
];
if (
cell &&
@@ -637,7 +659,7 @@ const DataGrid = ({
const col = range.s.c + colIndex;
const cell =
worksheet[
XLSX.utils.encode_cell({ r: row, c: col })
XLSX.utils.encode_cell({ r: row, c: col })
];
const headerName =
headers[colIndex] || `Column${colIndex + 1}`;
@@ -761,13 +783,13 @@ const DataGrid = ({
onSorting?: (g: unknown, f: unknown[], d: unknown[]) => boolean;
}
).onSorting = enableSort
? createOnSortingHandler(
columns,
setRowData,
sortDirRef,
originalRowsRef,
)
: () => false;
? createOnSortingHandler(
columns,
setRowData,
sortDirRef,
originalRowsRef,
)
: () => false;
(gridView.displayOptions as DisplayOptions).fitStyle =
GridFitStyle.EVEN;
@@ -937,7 +959,7 @@ const DataGrid = ({
) {
(
column.footer[
index
index
] as FooterItemWithCallback
).valueCallback =
footerItem.valueCallback;
@@ -990,11 +1012,11 @@ const DataGrid = ({
getGridDataRef.current = () => {
const gv = gridRef.current?.gridView as
| {
getDataSource?: () => {
getRowCount(): number;
getValue(r: number, f: string): unknown;
};
}
getDataSource?: () => {
getRowCount(): number;
getValue(r: number, f: string): unknown;
};
}
| undefined;
if (!gv?.getDataSource) return [];
const ds = gv.getDataSource();
@@ -1090,6 +1112,74 @@ const DataGrid = ({
};
}, [onRowClick, cellTooltipCallback, columns]);
useEffect(() => {
const gridView = gridRef.current?.gridView;
if (!gridView || !onScrollToBottom) return;
gridView.onScrollToBottom = () => {
onScrollToBottomRef.current?.();
};
return () => {
if (gridView.onScrollToBottom)
gridView.onScrollToBottom = undefined;
};
}, [onScrollToBottom]);
useEffect(() => {
const gridView = gridRef.current?.gridView;
if (!gridView) return;
if (onItemChecked) {
gridView.onItemChecked = (_grid, itemIndex, checked) => {
onItemCheckedRef.current?.(itemIndex, checked);
};
} else {
gridView.onItemChecked = undefined;
}
if (onItemAllChecked) {
gridView.onItemAllChecked = (_grid, checked) => {
onItemAllCheckedRef.current?.(checked);
};
} else {
gridView.onItemAllChecked = undefined;
}
return () => {
if (gridView.onItemChecked) gridView.onItemChecked = undefined;
if (gridView.onItemAllChecked)
gridView.onItemAllChecked = undefined;
};
}, [onItemChecked, onItemAllChecked]);
useEffect(() => {
const gridView = gridRef.current?.gridView;
if (!gridView) return;
gridView.onCellEdited = (grid, itemIndex, _row, field) => {
const fieldName = columns[field]?.fieldName;
if (!fieldName) return;
const newValue = grid.getValue(itemIndex, fieldName);
const prevRow = rowDataRef.current[itemIndex];
const oldValue = prevRow?.[fieldName] ?? "";
onCellEditedRef.current?.({
itemIndex,
fieldName,
oldValue,
newValue,
});
};
return () => {
if (gridView.onCellEdited) {
gridView.onCellEdited = undefined;
}
};
}, [columns, setRowData]);
return (
<div className="mx-[0.6rem] flex flex-col gap-[0.6rem]">
<input
@@ -1105,23 +1195,23 @@ const DataGrid = ({
<span>
진행상황:{" "}
{statusInfo.laeSbmsnSttsCd === null ||
statusInfo.laeSbmsnSttsCd === "0"
statusInfo.laeSbmsnSttsCd === "0"
? "미작성"
: statusInfo.laeSbmsnSttsCd === "1"
? "작성중"
: statusInfo.laeSbmsnSttsCd === "2"
? "제출"
: statusInfo.laeSbmsnSttsCd === "3"
? "승인"
: statusInfo.laeSbmsnSttsCd === "4"
? "반려"
: "검증중"}
? "작성중"
: statusInfo.laeSbmsnSttsCd === "2"
? "제출"
: statusInfo.laeSbmsnSttsCd === "3"
? "승인"
: statusInfo.laeSbmsnSttsCd === "4"
? "반려"
: "검증중"}
</span>
<span>
제출일:{" "}
{statusInfo.laeSbmsnYmd
? statusInfo.laeSbmsnYmd.length === 8 &&
/^\d{8}$/.test(statusInfo.laeSbmsnYmd)
/^\d{8}$/.test(statusInfo.laeSbmsnYmd)
? `${statusInfo.laeSbmsnYmd.slice(0, 4)}-${statusInfo.laeSbmsnYmd.slice(4, 6)}-${statusInfo.laeSbmsnYmd.slice(6, 8)}`
: statusInfo.laeSbmsnYmd
: "미제출"}
@@ -1159,12 +1249,11 @@ const DataGrid = ({
</Button>
)}
</div>
<div className="h-[65vh]">
<div style={{ height: `${gridHeight}vh` }}>
<RealGridReact
ref={gridRef}
autoGenerateField={true}
layout={columnLayout}
rows={rowData}
onInitialized={(grid) => {
if (!grid) return;
(
@@ -1176,33 +1265,33 @@ const DataGrid = ({
) => boolean;
}
).onSorting = enableSort
? createOnSortingHandler(
columns,
setRowData,
sortDirRef,
originalRowsRef,
)
: () => false;
? createOnSortingHandler(
columns,
setRowData,
sortDirRef,
originalRowsRef,
)
: () => false;
const dataProvider = gridRef.current
?.dataProvider as unknown as
| {
setFields?(f: unknown[]): void;
setRows?(
r: Record<string, string | number>[],
): void;
getJsonRow?(
r: number,
): Record<string, unknown>;
}
setFields?(f: unknown[]): void;
setRows?(
r: Record<string, string | number>[],
): void;
getJsonRow?(
r: number,
): Record<string, unknown>;
}
| undefined;
if (dataProvider?.setFields) {
dataProvider.setFields(
buildFieldsFromColumns(columns),
);
}
if (dataProvider?.setRows && rowData?.length) {
if (dataProvider?.setRows)
dataProvider.setRows(rowData);
}
if (getGridRef) getGridRef.current = gridRef.current;
const gridView = gridRef.current?.gridView ?? grid;
if (!gridView?.columnByName) return;
setTimeout(() => {
@@ -1346,14 +1435,14 @@ const DataGrid = ({
})}
footer={
column.footer &&
!Array.isArray(column.footer)
!Array.isArray(column.footer)
? {
text: column.footer.text,
expression:
column.footer.expression,
numberFormat:
column.footer.numberFormat,
}
text: column.footer.text,
expression:
column.footer.expression,
numberFormat:
column.footer.numberFormat,
}
: undefined
}
mergeRule={
+50 -30
View File
@@ -1,52 +1,66 @@
"use client";
import { useState, useEffect } from "react";
import { useEffect, useState } from "react";
import useSWR from "swr";
import {
Button,
Input,
Label,
Select,
SelectTrigger,
SelectContent,
SelectItem,
SelectValue,
Checkbox,
} from "@common/components";
import { useAlertStore } from "@/shared/lib";
import {
type SearchFormProps,
type SearchFormValues,
defaultSearchFormProps,
} from "../type/SearchForm.type";
import { statisticsAPi } from "@/entities/statistics/api/statistics-service";
import { useAlertStore, useUserStore } from "@/shared/lib";
import { statisticsApi } from "@/entities/statistics/api/statistics-service";
import type {
ApiResponse,
SearchCondition,
Sgg,
} from "@/entities/statistics/model/statistics-interface";
import {
Button,
Checkbox,
Input,
Label,
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from "@common/components";
const getSidoListFetcher = async (): Promise<ApiResponse<SearchCondition>> => {
return statisticsAPi.getSidoList();
import {
defaultSearchFormProps,
type SearchFormProps,
type SearchFormValues,
} from "../type/SearchForm.type";
import { doList } from "../util/column/input/DoList";
const getSidoListFetcher = async (
key: [string, string],
): Promise<ApiResponse<SearchCondition>> => {
return statisticsApi.getSidoList({ instSeCd: key[1] });
};
const getSggListFetcher = async (
key: [string, string],
key: [string, string, string, string],
): Promise<ApiResponse<Sgg[]>> => {
return statisticsAPi.getSggList(key[1]);
const gugunSkipYn = doList.includes(key[3]) ? "N" : "Y";
return statisticsApi.getSggAllList(key[1], {
instSeCd: key[2],
gugunSkipYn,
});
};
const SearchForm = (props: SearchFormProps = {}) => {
const { setMessage: alert } = useAlertStore((state) => state);
const userInfo = useUserStore((state) => state.userInfo);
const mergedProps = { ...defaultSearchFormProps, ...props };
const instSeCd = userInfo?.instSeCd ?? "";
const sidoSelectText =
props.sidoSelectText && props.sidoSelectText.trim() !== ""
? props.sidoSelectText
: "선택";
const currentYear = new Date().getFullYear();
const defaultYear = currentYear - 1;
const { data: sidoList } = useSWR<ApiResponse<SearchCondition>>(
"sido-list",
["sido-list", instSeCd],
getSidoListFetcher,
{
revalidateOnFocus: false,
@@ -77,8 +91,10 @@ const SearchForm = (props: SearchFormProps = {}) => {
isDetail: false,
});
const sggKey: [string, string] | null =
values.sido && values.sido !== "ALL" ? ["sgg-list", values.sido] : null;
const sggKey: [string, string, string, string] | null =
values.sido && values.sido !== "ALL"
? ["sgg-list", values.sido, instSeCd, values.bsnmCd || ""]
: null;
const { data: sggList } = useSWR<ApiResponse<Sgg[]>>(
sggKey,
getSggListFetcher,
@@ -102,7 +118,7 @@ const SearchForm = (props: SearchFormProps = {}) => {
);
const sggOptions = sggList?.data ?? [];
const handleSubmit = (e: React.SubmitEvent<HTMLFormElement>) => {
const handleSubmit = (e: React.FormEvent<HTMLFormElement>) => {
e.preventDefault();
props.onSubmit?.(values);
};
@@ -163,10 +179,14 @@ const SearchForm = (props: SearchFormProps = {}) => {
onValueChange={handleSidoChange}
>
<SelectTrigger className="w-[12rem]">
<SelectValue placeholder="선택"></SelectValue>
<SelectValue
placeholder={sidoSelectText}
></SelectValue>
</SelectTrigger>
<SelectContent>
<SelectItem value="ALL">선택</SelectItem>
<SelectItem value="ALL">
{sidoSelectText}
</SelectItem>
{sidoList?.data?.sidoList?.map((sido) => (
<SelectItem
key={sido.adstDstrCd}
@@ -19,7 +19,7 @@ import CompactDataGrid from "./CompactDataGrid";
import SaveButton from "../button/SaveButton";
import { useAlertStore } from "@/shared/lib";
import { statisticsAPi } from "@/entities/statistics/api/statistics-service";
import { statisticsApi } from "@/entities/statistics/api/statistics-service";
import type {
ApiResponse,
SearchCondition,
@@ -91,13 +91,13 @@ const adminRegionColumnInfo: ColumnInfo = {
};
const getSidoListFetcher = async (): Promise<ApiResponse<SearchCondition>> => {
return statisticsAPi.getSidoList();
return statisticsApi.getSidoList();
};
const getSggAllListFetcher = async (
key: [string, string],
): Promise<ApiResponse<Sgg[]>> => {
return statisticsAPi.getSggAllList(key[1]);
return statisticsApi.getSggAllList(key[1]);
};
type SidoSggFacilityNameSearchFormProps = {
@@ -0,0 +1,40 @@
"use client";
import {
Button,
Dialog,
DialogClose,
DialogContent,
DialogFooter,
DialogHeader,
DialogTitle,
} from "@common/components";
export interface VerificationResultDialogProps {
open: boolean;
onOpenChange: (open: boolean) => void;
}
export function VerificationResultDialog({
open,
onOpenChange,
}: VerificationResultDialogProps) {
return (
<Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent className="max-w-[640px]" aria-describedby={undefined}>
<DialogHeader>
<DialogTitle>검증 결과</DialogTitle>
</DialogHeader>
<div className="min-h-[120px]" />
<DialogFooter>
<DialogClose asChild>
<Button type="button" color="gray">
닫기
</Button>
</DialogClose>
</DialogFooter>
</DialogContent>
</Dialog>
);
}
@@ -0,0 +1,276 @@
"use client";
import { type Dispatch, type SetStateAction, useCallback, useEffect, useMemo, useState } from "react";
import {
Button,
Dialog,
DialogClose,
DialogContent,
DialogFooter,
DialogHeader,
DialogTitle,
LoadingWithAnimation,
} from "@common/components";
import DataGrid from "@/components/module/DataGrid";
import { VerificationResultHistoryPagination } from "@/components/module/VerificationResultHistoryPagination";
import type { ColumnInfo } from "@/components/type/DataGrid.type";
import { statisticsApi } from "@/entities/statistics/api/statistics-service";
import type { VerificationResultListRequest, VerificationResultReportItem } from "@/entities/statistics/model/statistics-interface";
type VerificationResultGridRow = Record<string, string | number>;
const PRCS_YN_LABEL: Record<string, string> = {
"0": "미통과",
"1": "통과",
"2": "예외처리",
"3": "반려",
};
const verificationResultColumnInfo: 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: "prcsYn", fieldName: "prcsYn", header: { text: "처리여부" }, editable: false },
{ name: "pbcpAprvYn", fieldName: "pbcpAprvYn", header: { text: "공단승인여부" }, editable: false },
{ name: "regDt", fieldName: "regDt", header: { text: "등록일시" }, editable: false },
{ name: "mdfcnDt", fieldName: "mdfcnDt", header: { text: "갱신일시" }, editable: false },
],
columnLayout: ["ctpvNm", "sggNm", "vrfcTrgtNm", "rsnExpln", "nowVlCn", "compVlCn", "prcsYn", "pbcpAprvYn", "regDt", "mdfcnDt"],
};
export interface VerificationResultHistoryDialogProps {
open: boolean;
onOpenChange: (open: boolean) => void;
queryContext?: {
sewerBzmnCd: string;
ctpvNm: string;
sggNm: string;
statsYr: string;
statsCd: string;
} | null;
title?: string;
emptyMessage?: string;
}
export function VerificationResultHistoryDialog({
open,
onOpenChange,
queryContext,
title = "검증 결과 내역",
emptyMessage = "표시할 검증 결과가 없습니다.",
}: VerificationResultHistoryDialogProps) {
const [normalEntries, setNormalEntries] = useState<VerificationResultReportItem[]>([]);
const [imrEntries, setImrEntries] = useState<VerificationResultReportItem[]>([]);
const [normalPageIndex, setNormalPageIndex] = useState(0);
const [imrPageIndex, setImrPageIndex] = useState(0);
const [normalPageCount, setNormalPageCount] = useState(1);
const [imrPageCount, setImrPageCount] = useState(1);
const [normalLoading, setNormalLoading] = useState(false);
const [imrLoading, setImrLoading] = useState(false);
const [errorMessage, setErrorMessage] = useState<string | null>(null);
const resetState = useCallback(() => {
setNormalEntries([]);
setImrEntries([]);
setNormalPageIndex(0);
setImrPageIndex(0);
setNormalPageCount(1);
setImrPageCount(1);
setNormalLoading(false);
setImrLoading(false);
setErrorMessage(null);
}, []);
const fetchVerificationResultPage = useCallback(
async (vrfcMthdCd: "NV" | "IMR", 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: "0,2,3",
page: pageIndex + 1,
size: 30,
};
return statisticsApi.getVerificationResultList(param);
},
[queryContext],
);
const loadInitial = useCallback(async () => {
if (!queryContext) {
setErrorMessage("조회 조건이 없어 검증 결과를 불러올 수 없습니다.");
return;
}
setErrorMessage(null);
setNormalLoading(true);
setImrLoading(true);
setNormalPageIndex(0);
setImrPageIndex(0);
try {
const [nvRes, imrRes] = await Promise.all([
fetchVerificationResultPage("NV", 0),
fetchVerificationResultPage("IMR", 0),
]);
setNormalEntries(nvRes?.data?.verifyResultList ?? []);
setImrEntries(imrRes?.data?.verifyResultList ?? []);
setNormalPageCount(nvRes?.data?.pageInfo?.totalPages ?? 1);
setImrPageCount(imrRes?.data?.pageInfo?.totalPages ?? 1);
} catch {
setNormalEntries([]);
setImrEntries([]);
setNormalPageCount(1);
setImrPageCount(1);
setErrorMessage("검증 결과 내역 조회 중 오류가 발생했습니다.");
} finally {
setNormalLoading(false);
setImrLoading(false);
}
}, [fetchVerificationResultPage, queryContext]);
const handleNormalPageChange = useCallback(
async (pageIndex: number) => {
if (pageIndex === normalPageIndex || !queryContext) return;
setErrorMessage(null);
setNormalLoading(true);
try {
const response = await fetchVerificationResultPage("NV", pageIndex);
setNormalEntries(response?.data?.verifyResultList ?? []);
setNormalPageCount(response?.data?.pageInfo?.totalPages ?? 1);
setNormalPageIndex(pageIndex);
} catch {
setErrorMessage("일반검증 내역 조회 중 오류가 발생했습니다.");
} finally {
setNormalLoading(false);
}
},
[fetchVerificationResultPage, normalPageIndex, queryContext],
);
const handleImrPageChange = useCallback(
async (pageIndex: number) => {
if (pageIndex === imrPageIndex || !queryContext) return;
setErrorMessage(null);
setImrLoading(true);
try {
const response = await fetchVerificationResultPage("IMR", pageIndex);
setImrEntries(response?.data?.verifyResultList ?? []);
setImrPageCount(response?.data?.pageInfo?.totalPages ?? 1);
setImrPageIndex(pageIndex);
} catch {
setErrorMessage("I-MR 검증내역 조회 중 오류가 발생했습니다.");
} finally {
setImrLoading(false);
}
},
[fetchVerificationResultPage, imrPageIndex, queryContext],
);
useEffect(() => {
if (!open) {
resetState();
return;
}
void loadInitial();
}, [loadInitial, open, resetState]);
const mapRows = (entries: VerificationResultReportItem[]): VerificationResultGridRow[] =>
entries.map((item) => ({
ctpvNm: item.ctpvNm,
sggNm: item.sggNm,
vrfcTrgtNm: item.vrfcTrgtNm,
rsnExpln: item.rsnExpln,
nowVlCn: item.nowVlCn ?? "-",
compVlCn: item.compVlCn ?? "-",
prcsYn: PRCS_YN_LABEL[item.prcsYn] ?? item.prcsYn,
pbcpAprvYn: item.pbcpAprvYn,
regDt: (item.regDt ?? "").slice(0, 16),
mdfcnDt: (item.mdfcnDt ?? "").slice(0, 16),
}));
const normalRows = useMemo(() => mapRows(normalEntries), [normalEntries]);
const imrRows = useMemo(() => mapRows(imrEntries), [imrEntries]);
const noopSetRows = ((_: SetStateAction<Record<string, string | number>[]>) => {}) as Dispatch<
SetStateAction<Record<string, string | number>[]>
>;
const renderGrid = (
rows: VerificationResultGridRow[],
pageIndex: number,
pageCount: number,
onPageIndexChange: (page: number) => void,
loading: boolean,
) => {
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={verificationResultColumnInfo}
rowData={rows}
setRowData={noopSetRows}
isExcelImport={false}
isExcelExport={false}
showHeaderCheckbox={false}
enableSort={false}
/>
<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>
)}
<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>
</div>
<DialogFooter>
<DialogClose asChild>
<Button type="button" color="gray">
닫기
</Button>
</DialogClose>
</DialogFooter>
</DialogContent>
</Dialog>
);
}
@@ -0,0 +1,99 @@
"use client";
import {
ChevronLeftIcon,
ChevronRightIcon,
DoubleArrowLeftIcon,
DoubleArrowRightIcon,
} from "@radix-ui/react-icons";
import { Button, cn } from "@common/components";
export type VerificationResultHistoryPaginationProps = {
pageCount: number;
pageIndex: number;
onPageIndexChange: (pageIndex: number) => void;
className?: string;
};
export function VerificationResultHistoryPagination({
pageCount,
pageIndex: currentPage,
onPageIndexChange,
className,
}: VerificationResultHistoryPaginationProps) {
const maxVisiblePages = 10;
let startPage = Math.max(0, currentPage - maxVisiblePages / 2);
const endPage = Math.min(pageCount, startPage + maxVisiblePages);
if (endPage - startPage < maxVisiblePages) {
startPage = Math.max(0, endPage - maxVisiblePages);
}
const pageNumbers = Array(endPage - startPage)
.fill(0)
.map((_, i) => startPage + i);
const canPrevious = currentPage > 0;
const canNext = pageCount > 0 && currentPage < pageCount - 1;
return (
<div className={cn("mt-2 flex items-center justify-center gap-1 rounded-md border border-border/70 bg-muted/20 px-2 py-2", className)}>
<Button
variant="ghost"
color="white"
className="hidden size-8 p-0 lg:flex"
onClick={() => onPageIndexChange(0)}
disabled={!canPrevious}
>
<span className="sr-only">첫 페이지로</span>
<DoubleArrowLeftIcon className="size-4" />
</Button>
<Button
variant="ghost"
color="white"
className="size-8 p-0"
onClick={() => onPageIndexChange(currentPage - 1)}
disabled={!canPrevious}
>
<span className="sr-only">이전 페이지로</span>
<ChevronLeftIcon className="size-4" />
</Button>
<div className="flex items-center justify-center text-sm font-medium">
{pageNumbers.map((pageNumber) => (
<Button
key={pageNumber}
variant="ghost"
className={cn(
"inline-block h-8 min-w-8 border border-transparent !bg-transparent px-2 py-1 text-foreground hover:!bg-accent",
currentPage === pageNumber &&
"!bg-primary !font-bold !text-primary-foreground hover:!bg-primary",
)}
onClick={() => onPageIndexChange(pageNumber)}
>
{pageNumber + 1}
</Button>
))}
</div>
<Button
variant="ghost"
color="white"
className="size-8 p-0"
onClick={() => onPageIndexChange(currentPage + 1)}
disabled={!canNext}
>
<span className="sr-only">다음 페이지로</span>
<ChevronRightIcon className="size-4" />
</Button>
<Button
variant="ghost"
color="white"
className="hidden size-8 p-0 lg:flex"
onClick={() => onPageIndexChange(Math.max(0, pageCount - 1))}
disabled={!canNext}
>
<span className="sr-only">마지막 페이지로</span>
<DoubleArrowRightIcon className="size-4" />
</Button>
</div>
);
}
@@ -1,14 +1,16 @@
import { useState, useMemo, useRef } from "react";
import SearchForm from "@/components/module/SearchForm";
import TabDataGrid from "@/components/module/TabDataGrid";
import DataGrid from "@/components/module/DataGrid";
import SplitDataGrid from "@/components/module/SplitDataGrid";
import GridTopActionButton from "@/components/module/GridTopActionButton";
import SubmitButton from "@/components/button/SumitButton";
import SaveButton from "@/components/button/SaveButton";
import LastYearDataButton from "@/components/button/LastYearDataButton";
import SubmitExceptionButton from "@/components/button/SubmitExceptionButton";
import { SewagePipeInpColumnInfoList } from "@/components/util/column/input/sewagePipe/SewagePipeInpColumn";
import type { ColumnInfoList, ColumnConfig } from "@/components/type/DataGrid.type";
import type { ColumnInfo, ColumnInfoList, ColumnConfig } from "@/components/type/DataGrid.type";
import type { SearchFormValues } from "@/components/type/SearchForm.type";
import { useAlertStore } from "@/shared/lib";
import { useAdminStore } from "@/store/admin-store";
@@ -17,20 +19,13 @@ import { useConfirm } from "@frontend-opensource/use-react-hooks";
import { doList } from "@/components/util/column/input/DoList";
import { statisticsInputSewPipeApiService } from "@/entities/statistics/api/input/statistics-sewage-pipe-input-service";
import type {
SeweragePipeInputAdminAreaConnectionItem,
SeweragePipeInputFacilityConnectionItem,
SeweragePipeInputFactInstallStatusItem,
SeweragePipeInputManholeStatusItem,
SeweragePipeInputPipeDiameterStatusItem,
SeweragePipeInputPipeTypeStatusItem,
SeweragePipeInputRegionalFactStatusItem,
SeweragePipeInputRegionalFactStatusSaveRequest,
SeweragePipeInputStatusInfo,
SeweragePipeRequestParams,
} from "@/entities/statistics/model/input/statistics-sewage-pipe-input-interface";
import { RegionalFactStatusInpColumn } from "@/components/util/column/input/sewagePipe/RegionalFactStatusInpColumn";
import { toGridRows } from "@/components/util/grid/gridFunction";
import { LoadingWithAnimation } from "@common/components";
import { LoadingWithAnimation, Tabs, TabsContent, TabsList, TabsTrigger } from "@common/components";
import { getUserInfo } from "../../../../../../packages/common/components/src/api";
const applyEnterableFromStatus = (
@@ -62,6 +57,23 @@ function buildSewagePipeSearchParam(
};
}
/** ST00020006(연결)은 행이 좌·우 그리드 키에만 있음 */
function tabRowDataExists(
m: Map<string, Record<string, string | number>[]>,
tabKey: string,
): boolean {
if (tabKey === "ST00020006") {
const left = m.get("treatmentPlantConnectionInp");
const right = m.get("adminAreaConnectionInp");
return (
(Array.isArray(left) && left.length > 0) ||
(Array.isArray(right) && right.length > 0)
);
}
const rows = m.get(tabKey);
return Array.isArray(rows) && rows.length > 0;
}
// 하수도 통계 관리 > 02. 하수관로
const SewagePipeInp = () => {
const [rowDataMap, setRowDataMap] = useState<Map<string, Record<string, string | number>[]>>(new Map());
@@ -128,7 +140,7 @@ const SewagePipeInp = () => {
});
}, [isEnterable, isAdminInfo]);
/** mergeIntoExistingMap: true면 지난연도(PRE) 등 — 현재 탭만 갱신, 나머지 탭 캐시 유지 */
/** mergeIntoExistingMap: true면 기존 맵에 병합(다른 탭 rowData·검색 후 다른 탭 캐시 유지). false면 맵 전체 교체 */
const loadSewagePipeActiveTabData = async (
tabKey: string,
searchParam: SeweragePipeRequestParams,
@@ -290,7 +302,6 @@ const SewagePipeInp = () => {
const checkEnterable = periodResponse.data;
periodEnterableRef.current = checkEnterable;
setRowDataMap(new Map());
lazyTabLoggedRef.current.clear();
setIsLoading(true);
@@ -301,7 +312,7 @@ const SewagePipeInp = () => {
setHasSearched(true);
setLastSearchedYear(values.year ?? null);
await loadSewagePipeActiveTabData(activeTab, searchParam, values, checkEnterable, false, true);
await loadSewagePipeActiveTabData(activeTab, searchParam, values, checkEnterable, true, true);
} catch {
alert('데이터 조회 중 오류가 발생했습니다.');
} finally {
@@ -312,8 +323,7 @@ const SewagePipeInp = () => {
const handleActiveTabChange = async (tabKey: string) => {
setActiveTab(tabKey);
const activeTabData = rowDataMap.get(tabKey);
const hasActiveTabData = Array.isArray(activeTabData) && activeTabData.length > 0;
const hasActiveTabData = tabRowDataExists(rowDataMap, tabKey);
const canLazyLoad =
searchParamRef.current &&
@@ -324,86 +334,20 @@ const SewagePipeInp = () => {
if (!canLazyLoad) return;
const searchParam = searchParamRef.current;
if (!searchParam) return;
const values = lastSearchedValues ?? searchValues;
if (!searchParam || !values) return;
try {
setIsLoading(true);
let response;
if (tabKey === 'ST00020001') {
response = await statisticsInputSewPipeApiService.getRegionalFactStatus(searchParam);
} else if (tabKey === 'ST00020002') {
response = await statisticsInputSewPipeApiService.getFactInstallStatus(searchParam);
} else if (tabKey === 'ST00020003') {
response = await statisticsInputSewPipeApiService.getPipeTypeStatus(searchParam);
} else if (tabKey === 'ST00020004') {
response = await statisticsInputSewPipeApiService.getPipeDiameterStatus(searchParam);
} else if (tabKey === 'ST00020005') {
response = await statisticsInputSewPipeApiService.getManholeStatus(searchParam);
} else if (tabKey === 'ST00020006') {
response = await statisticsInputSewPipeApiService.getConnectionStatus(searchParam);
}
lazyTabLoggedRef.current.add(tabKey);
if (response?.status === 'success') {
setStatusInfo(response.data.statusInfo);
setStatsCd(response.data.commonInfo.statsCd);
applyEnterableFromStatus(response.data.statusInfo, periodEnterableRef.current, setIsEnterable);
setRowDataMap(prev => {
const next = new Map(prev);
if (tabKey === 'ST00020001') {
const commonInfo = response.data.commonInfo;
const convertedData = (response.data.regionalFactStatusList ?? []).map((d: SeweragePipeInputRegionalFactStatusItem) => ({
ctpvNm: commonInfo.ctpvNm,
...d
}));
next.set(tabKey, toGridRows(convertedData));
} else if (tabKey === 'ST00020002') {
const commonInfo = response.data.commonInfo;
const convertedData = (response.data.factInstallStatusList ?? []).map((d: SeweragePipeInputFactInstallStatusItem) => ({
ctpvNm: commonInfo.ctpvNm,
...d
}));
next.set(tabKey, toGridRows(convertedData));
} else if (tabKey === 'ST00020003') {
const commonInfo = response.data.commonInfo;
const convertedData = (response.data.pipeTypeStatusList ?? []).map((d: SeweragePipeInputPipeTypeStatusItem) => ({
ctpvNm: commonInfo.ctpvNm,
...d
}));
next.set(tabKey, toGridRows(convertedData));
} else if (tabKey === 'ST00020004') {
const commonInfo = response.data.commonInfo;
const convertedData = (response.data.pipeDiameterStatusList ?? []).map((d: SeweragePipeInputPipeDiameterStatusItem) => ({
ctpvNm: commonInfo.ctpvNm,
...d
}));
next.set(tabKey, toGridRows(convertedData));
} else if (tabKey === 'ST00020005') {
const commonInfo = response.data.commonInfo;
const convertedData = (response.data.manholeStatusList ?? []).map((d: SeweragePipeInputManholeStatusItem) => ({
ctpvNm: commonInfo.ctpvNm,
...d
}));
next.set(tabKey, toGridRows(convertedData));
} else if (tabKey === 'ST00020006') {
const commonInfo = response.data.commonInfo;
const facilityConvertedData = (response.data.facilityConnectionList ?? []).map((d: SeweragePipeInputFacilityConnectionItem) => ({
...d
}));
const adminConvertedData = (response.data.adminAreaConnectionList ?? []).map((d: SeweragePipeInputAdminAreaConnectionItem) => ({
...d,
ctpvNm: d.ctpvNm || commonInfo.ctpvNm,
}));
next.set('treatmentPlantConnectionInp', toGridRows(facilityConvertedData));
next.set('adminAreaConnectionInp', toGridRows(adminConvertedData));
}
return next;
});
} else {
alert(response?.message || '데이터 조회 중 오류가 발생했습니다.');
}
const ok = await loadSewagePipeActiveTabData(
tabKey,
searchParam,
values,
periodEnterableRef.current,
true,
false,
);
if (ok) lazyTabLoggedRef.current.add(tabKey);
} catch {
alert('데이터 조회 중 오류가 발생했습니다.');
} finally {
@@ -543,8 +487,9 @@ const SewagePipeInp = () => {
const showSaveInToolbar = (isEnterable || isAdminInfo) && gugunOkOrNotDoList;
const showEnterableToolbarButtons = isEnterable && gugunOkOrNotDoList;
/** TabDataGrid는 rowDataMap 사용 — 행이 하나라도 있을 때만 툴바 카드 의미 있음 */
/** rowDataMap — 행이 하나라도 있을 때만 툴바·엑셀 의미 있음 */
const hasRowData = Array.from(rowDataMap.values()).some((rows) => rows.length > 0);
const excelToolbarEnabled = hasRowData && isEnterable && gugunOkOrNotDoList;
const showActionToolbarCard =
(hasRowData || hasSearched) &&
@@ -573,16 +518,132 @@ const SewagePipeInp = () => {
<LoadingWithAnimation color="primary" />
</div>
</div>}
<TabDataGrid
columnInfo={columnInfo}
rowDataMap={rowDataMap}
setRowDataMap={setRowDataMap}
activeTab={activeTab}
onActiveTabChange={handleActiveTabChange}
isExcelImport={hasRowData && isEnterable && gugunOkOrNotDoList}
facilitySearchStatsYr={lastSearchedYear}
getGridDataRef={getGridDataRef}
/>
<div className="flex flex-col gap-[0.6rem] mx-[0.6rem]">
<Tabs
value={activeTab}
onValueChange={(v) => void handleActiveTabChange(v)}
className="w-full"
variant="default"
>
<TabsList>
{columnInfo.map((info) => (
<TabsTrigger key={info.tabKey} value={info.tabKey}>
{info.tabLabel || info.tabKey}
</TabsTrigger>
))}
</TabsList>
{columnInfo.map((info) => {
const tabKey = info.tabKey;
if (info.isSplitGrid && info.left && info.right) {
const leftKey = info.left.tabKey || `${tabKey}_left`;
const rightKey = info.right.tabKey || `${tabKey}_right`;
const leftRowData = rowDataMap.get(leftKey) ?? [];
const rightRowData = rowDataMap.get(rightKey) ?? [];
const splitTopButton = info.right.topButton;
const showSplitTopButton =
Boolean(splitTopButton?.text) &&
(splitTopButton?.visible ?? true) &&
excelToolbarEnabled;
const leftColumnInfo: ColumnInfo = {
columns: info.left.columns ?? [],
columnLayout: info.left.columnLayout ?? [],
};
const rightColumnInfo: ColumnInfo = {
columns: info.right.columns ?? [],
columnLayout: info.right.columnLayout ?? [],
};
return (
<TabsContent key={tabKey} value={tabKey}>
<div className="flex flex-col gap-[0.6rem]">
<div className="flex justify-end gap-[0.6rem]">
{showSplitTopButton && (
<GridTopActionButton
text={splitTopButton?.text ?? ""}
onClick={splitTopButton?.onClick}
statsYr={lastSearchedYear ?? undefined}
/>
)}
</div>
<SplitDataGrid
leftColumnInfo={leftColumnInfo}
rightColumnInfo={rightColumnInfo}
leftFixedColumns={info.left.fixedColumns}
rightFixedColumns={info.right.fixedColumns}
showHeaderCheckbox={false}
enableSort={false}
leftRowData={leftRowData}
rightRowData={rightRowData}
setLeftRowData={(updater) => {
setRowDataMap((prev) => {
const next = new Map(prev);
const cur = next.get(leftKey) ?? [];
const n =
typeof updater === "function"
? updater(cur)
: updater;
next.set(leftKey, n);
return next;
});
}}
setRightRowData={(updater) => {
setRowDataMap((prev) => {
const next = new Map(prev);
const cur = next.get(rightKey) ?? [];
const n =
typeof updater === "function"
? updater(cur)
: updater;
next.set(rightKey, n);
return next;
});
}}
/>
</div>
</TabsContent>
);
}
if (!info.columns || !info.columnLayout) {
return null;
}
const rowData = rowDataMap.get(tabKey) ?? [];
const gridColumnInfo: ColumnInfo = {
columns: info.columns,
columnLayout: info.columnLayout,
};
return (
<TabsContent key={tabKey} value={tabKey}>
<DataGrid
columnInfo={gridColumnInfo}
rowStartNum={info.rowStartNum}
excelExportFileSuffix={info.tabLabel || tabKey}
rowData={rowData}
setRowData={(updater) => {
setRowDataMap((prev) => {
const next = new Map(prev);
const cur = next.get(tabKey) ?? [];
const n =
typeof updater === "function" ? updater(cur) : updater;
next.set(tabKey, n);
return next;
});
}}
isExcelImport={excelToolbarEnabled}
isExcelExport
showHeaderCheckbox={false}
fixedColumns={info.fixedColumns}
enableSort={false}
getGridDataRef={tabKey === "ST00020001" ? getGridDataRef : undefined}
/>
</TabsContent>
);
})}
</Tabs>
</div>
</>
);
};
@@ -6,16 +6,18 @@ import DataGrid from "@/components/module/DataGrid";
import SubmitButton from "@/components/button/SumitButton";
import SaveButton from "@/components/button/SaveButton";
import LastYearDataButton from "@/components/button/LastYearDataButton";
import SubmitExceptionButton from "@/components/button/SubmitExceptionButton";
import CancelSubmitButton from "@/components/button/CancelSubmitButton";
import VerificationResultButton from "@/components/button/VerificationResultButton";
import { VerificationResultHistoryDialog } from "@/components/module/VerificationResultHistoryDialog";
import { VerificationResultDialog } from "@/components/module/VerificationResultDialog";
import { LoadingWithAnimation } from "@common/components";
import { ConfirmDialog } from "@/shared/ui/confirm-dialog";
import SubmitDailog from "./SubmitDailog";
import { useAlertStore } from "@/shared/lib";
import { useAlertStore, useGlobalProgressStore } from "@/shared/lib";
import { useAdminStore } from "@/store/admin-store";
import { statisticsInputSewSupplyRateApi } from "@/entities/statistics/api/input/statistics-input-sew-supply-rate-service";
import { statisticsAPi } from "@/entities/statistics/api/statistics-service";
import { statisticsApi } from "@/entities/statistics/api/statistics-service";
import { toGridRows } from "@/components/util/grid/gridFunction";
import { getFooterSummary } from "@/components/util/grid/footerSummary";
import { getUserInfo } from "../../../../../../packages/common/components/src/api";
@@ -30,7 +32,11 @@ import type {
SewerageSupplyRateSaveRequest,
SewerageSupplyRateStatusInfo,
} from "@/entities/statistics/model/input/statistics-sewage-supply-rate-input-interface";
import type { cancelSubmitRequest, SubmitExceptionRequest } from "@/entities/statistics/model/statistics-interface";
import type {
cancelSubmitRequest,
ImrStatusResponse,
SubmitExceptionRequest,
} from "@/entities/statistics/model/statistics-interface";
import type { StatisticsInputSubmitInfoFormData } from "@/entities/statistics/model/statistics-input-submit-info-schema";
import { SeweragePenetrationRateInpColumn } from "@/components/util/column/input/SeweragePenetrationRateInpColumn";
import { doList } from "@/components/util/column/input/DoList";
@@ -45,6 +51,32 @@ const hasVerificationErrors = (verification?: SewerageSupplyRateVerificationResu
return Object.values(verification).some((errors) => Array.isArray(errors) && errors.length > 0);
};
const sleep = (ms: number) => new Promise<void>((resolve) => setTimeout(resolve, ms));
/** IMR 상태가 COMPLETED 이고 job_prog 가 100일 때까지 폴링 */
async function waitForImrJobCompleted(
jobId: string,
getStatus: (id: string) => Promise<ImrStatusResponse>,
options?: {
pollIntervalMs?: number;
maxAttempts?: number;
onProgress?: (jobProgress: number) => void;
},
): Promise<void> {
const pollIntervalMs = options?.pollIntervalMs ?? 2000;
const maxAttempts = options?.maxAttempts ?? 150;
for (let i = 0; i < maxAttempts; i++) {
const res = await getStatus(jobId);
options?.onProgress?.(Math.max(0, Math.min(100, Number(res.job_prog) || 0)));
if (res.status === "COMPLETED" && Number(res.job_prog) === 100) return;
if (res.status === "FAILED" || res.status === "ERROR" || res.status === "CANCELLED") {
throw new Error(res.error_msg || `IMR 검증이 ${res.status} 상태로 종료되었습니다.`);
}
await sleep(pollIntervalMs);
}
throw new Error("IMR 검증 완료 대기 시간이 초과되었습니다.");
}
function getSewerageSupplyRateParams(
values: SearchFormValues,
overrides?: { yr?: string; demoYr?: string; mode?: string }
@@ -69,11 +101,17 @@ const SeweragePenetrationRateInp = () => {
const [lastSearchedValues, setLastSearchedValues] = useState<SearchFormValues | null>(null);
const [statusInfo, setStatusInfo] = useState<SewerageSupplyRateStatusInfo | null>(null);
const [showDailog, setShowDailog] = useState<boolean>(false);
const [verificationHistoryOpen, setVerificationHistoryOpen] = useState(false);
const [verificationResultDialogOpen, setVerificationResultDialogOpen] = useState(false);
/** 예외제출 버튼으로 연 다이얼로그인지 (확인 시 executeSubmit 대신 SubmitExceptionFunc 경로) */
const [isExceptionSubmitFlow, setIsExceptionSubmitFlow] = useState(false);
const [statsCd, setStatsCd] = useState<string>('');
const [footerFromApi, setFooterFromApi] = useState<{ sewerBzmnWholPpltnCnt?: number; sewerBzmnDomplWholPpltnCnt?: number; sewerBzmnFrgnrWholPpltnCnt?: number; sewerBzmnArea?: number } | null>(null);
const { confirm } = useConfirm();
const showGlobalProgress = useGlobalProgressStore((state) => state.show);
const setGlobalProgress = useGlobalProgressStore((state) => state.setProgress);
const hideGlobalProgress = useGlobalProgressStore((state) => state.hide);
const globalProgressVisible = useGlobalProgressStore((state) => state.visible);
const { isAdminInfo } = useAdminStore();
@@ -88,6 +126,7 @@ const SeweragePenetrationRateInp = () => {
});
// 최상위 관리자일땐 모든 상황에서 edit 가능하게 수정 필요.
const columnInfo = useMemo(() => {
const { columns, columnLayout } = SeweragePenetrationRateInpColumn;
const base = (isEnterable || isAdminInfo) ? columns : columns.map((col) => ({ ...col, editable: false as const }));
@@ -212,7 +251,7 @@ const SeweragePenetrationRateInp = () => {
try {
setIsLoading(true);
const response = await statisticsAPi.submitException(params);
const response = await statisticsApi.submitException(params);
if (response.status === 'success') {
await fetchSewerageSupplyRate(getSewerageSupplyRateParams(searchValues ?? {}), false);
@@ -271,7 +310,7 @@ const SeweragePenetrationRateInp = () => {
const executeSubmit = async (submitInfo: StatisticsInputSubmitInfoFormData) => {
try {
setIsLoading(true);
showGlobalProgress({ label: "제출 진행 중...", progress: 0 });
const userInfo = await getUserInfo();
const currentData = getGridDataRef.current?.();
@@ -348,39 +387,62 @@ const SeweragePenetrationRateInp = () => {
const response = await statisticsInputSewSupplyRateApi.submitSewerageSupplyRate(requestData);
if (response.status === 'success') {
const imrParam = {
user_id: userInfo.userUnqId,
sewer_bzmn_cd: searchValues?.bsnmCd ?? '',
stats_cd: statsCd,
stats_yr: searchValues?.year ?? '',
lae_sbmsn_stts_cd: (statusInfo?.laeSbmsnSttsCd === '4' ? '4' : '2'),
};
setGlobalProgress(50);
if (response.data.finalStatus === '5') {
const imrParam = {
user_id: userInfo.userUnqId,
sewer_bzmn_cd: searchValues?.bsnmCd ?? '',
stats_cd: statsCd,
stats_yr: searchValues?.year ?? '',
lae_sbmsn_stts_cd: (statusInfo?.laeSbmsnSttsCd === '4' ? '4' : '2'),
gen_verify_stts_cd: response.data.nvpassed ? '1' : '2',
};
try {
await statisticsAPi.callImrService(imrParam);
} catch {
await fetchSewerageSupplyRate(getSewerageSupplyRateParams(searchValues ?? {}), true);
setIsLoading(false);
alert('IMR 검증 중 오류가 발생했습니다.\n관리자에게 문의하세요.');
return;
try {
const imrResponse = await statisticsApi.callImrService(imrParam);
if (imrResponse.job_id) {
await waitForImrJobCompleted(imrResponse.job_id, (id) =>
statisticsApi.getImrStatus(id),
{
onProgress: (jobProgress) => {
setGlobalProgress(50 + Math.round(jobProgress / 2));
},
},
);
}
setGlobalProgress(100);
await fetchSewerageSupplyRate(getSewerageSupplyRateParams(searchValues ?? {}), true);
setVerificationResultDialogOpen(true);
} catch (e) {
await fetchSewerageSupplyRate(getSewerageSupplyRateParams(searchValues ?? {}), true);
setIsLoading(false);
const msg =
e instanceof Error && e.message
? e.message
: 'IMR 검증 중 오류가 발생했습니다.\n관리자에게 문의하세요.';
alert(msg);
return;
}
} else {
setGlobalProgress(100);
await fetchSewerageSupplyRate(getSewerageSupplyRateParams(searchValues ?? {}), false);
alert('제출되었습니다.');
}
await fetchSewerageSupplyRate(getSewerageSupplyRateParams(searchValues ?? {}), false);
alert('제출되었습니다.');
} else {
alert(response.message || '데이터 제출 중 오류가 발생했습니다.');
}
} catch {
alert('데이터 제출 중 오류가 발생했습니다.');
} finally {
setIsLoading(false);
hideGlobalProgress();
}
};
const saveFunc = async () => {
try {
if (await confirm('저장 하시겠습니까?')) {
setIsLoading(true);
showGlobalProgress({ label: "저장 진행 중...", progress: 0 });
const userInfo = await getUserInfo();
const currentData = getGridDataRef.current?.();
@@ -465,23 +527,8 @@ const SeweragePenetrationRateInp = () => {
const response = await statisticsInputSewSupplyRateApi.saveSewerageSupplyRate(requestData);
if (response.status === 'success') {
const imrParam = {
user_id: userInfo.userUnqId,
sewer_bzmn_cd: searchValues?.bsnmCd ?? '',
stats_cd: statsCd,
stats_yr: searchValues?.year ?? '',
lae_sbmsn_stts_cd: (statusInfo?.laeSbmsnSttsCd === '4' ? '3' : '1'),
};
try {
await statisticsAPi.callImrService(imrParam);
} catch {
await fetchSewerageSupplyRate(getSewerageSupplyRateParams(searchValues ?? {}), true);
setIsLoading(false);
alert('IMR 검증 중 오류가 발생했습니다.\n관리자에게 문의하세요.');
return;
}
setGlobalProgress(50);
setGlobalProgress(100);
await fetchSewerageSupplyRate(getSewerageSupplyRateParams(searchValues ?? {}), true);
alert('저장되었습니다.');
} else {
@@ -493,7 +540,7 @@ const SeweragePenetrationRateInp = () => {
} catch {
alert('데이터 저장 중 오류가 발생했습니다.');
} finally {
setIsLoading(false);
hideGlobalProgress();
}
};
@@ -512,7 +559,7 @@ const SeweragePenetrationRateInp = () => {
statsCd: statsCd,
};
const response = await statisticsAPi.cancelSubmit(requestData);
const response = await statisticsApi.cancelSubmit(requestData);
if (response.status === 'success') {
fetchSewerageSupplyRate(getSewerageSupplyRateParams(searchValues ?? {}), true);
@@ -526,6 +573,14 @@ const SeweragePenetrationRateInp = () => {
}
};
const openVerificationHistoryDialog = () => {
if (!searchValues?.year || !searchValues?.sido || !searchValues?.bsnmCd || !statsCd) {
alert("먼저 조회를 진행해주세요.");
return;
}
setVerificationHistoryOpen(true);
};
const gugunOkOrNotDoList = doList.includes(lastSearchedValues?.bsnmCd || '')
? Boolean(lastSearchedValues?.gugun && lastSearchedValues.gugun !== 'ALL')
: true;
@@ -547,12 +602,11 @@ const SeweragePenetrationRateInp = () => {
<div className="flex justify-end items-center gap-[0.6rem] mx-[0.6rem]">
{showEnterableToolbarButtons && (
<>
{isAdminInfo && statusInfo && statusInfo.laeSbmsnSttsCd !== '0' && (
<SubmitExceptionButton SubmitExceptionFunc={() => submitFunc(true)} />
)}
<VerificationResultButton
VerificationResultButtonFunc={openVerificationHistoryDialog}
/>
<LastYearDataButton getLastYearDataFunc={() => getLastYearDataFunc(searchValues ?? {})} />
<SubmitButton submitFunc={() => submitFunc(false)} />
</>
)}
{statusInfo?.laeSbmsnSttsCd === '2' && <CancelSubmitButton cnacelSubmitFunc={cnacelSubmitFunc} />}
@@ -560,7 +614,7 @@ const SeweragePenetrationRateInp = () => {
</div>
</div>
)}
{isLoading && <div className="absolute inset-0 z-50 [data-theme='black']:bg-transparent rounded-lg p-10 flex justify-center items-center">
{isLoading && !globalProgressVisible && <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>
@@ -593,7 +647,7 @@ const SeweragePenetrationRateInp = () => {
return errors
.map((e) => {
const prefix = e.vrfcCd === 'IMR' ? 'IMR검증 - ' : '일반검증 - ';
return `${prefix}${e.rsnExpln}`;
return `${prefix}${e.rsnExpln}(검증 기준값: ${e.compVlCn} / 현재 입력값: ${e.nowVlCn})`;
})
.filter(Boolean)
.join('\n');
@@ -614,6 +668,25 @@ const SeweragePenetrationRateInp = () => {
submitMode={isExceptionSubmitFlow ? 'exception' : 'normal'}
isLoading={isLoading}
/>
<VerificationResultHistoryDialog
open={verificationHistoryOpen}
onOpenChange={setVerificationHistoryOpen}
queryContext={
searchValues?.year && searchValues?.sido && searchValues?.bsnmCd && statsCd
? {
sewerBzmnCd: searchValues.bsnmCd,
ctpvNm: searchValues.sido,
sggNm: searchValues.gugun === "ALL" ? "" : (searchValues.gugun ?? ""),
statsYr: searchValues.year,
statsCd,
}
: null
}
/>
<VerificationResultDialog
open={verificationResultDialogOpen}
onOpenChange={setVerificationResultDialogOpen}
/>
</>
);
};
@@ -1,22 +1,25 @@
import { useState, useEffect } from "react";
import { useEffect, useState } from "react";
import useSWR, { useSWRConfig } from "swr";
import SearchForm from "@/components/module/SearchForm";
import { useTabStore } from "@/store/tab-store";
import DataGrid from "@/components/module/DataGrid";
import ApproveButton from "@/components/button/ApproveButton";
import RejectButton from "@/components/button/RejectButton";
import { LoadingWithAnimation } from "@common/components";
import { StatsInputStatusInpColumn } from "@/components/util/column/input/StatsInputStatusInpColumn";
import { useAlertStore } from "@/shared/lib";
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 { SearchFormValues } from "@/components/type/SearchForm.type";
import { useAlertStore } from "@/shared/lib";
import { toGridRows } from "@/components/util/grid/gridFunction";
import type { StatsInputStatusListApiResponse } from "@/entities/statistics/model/input/statistics-input-status-interface";
import { LoadingWithAnimation } from "@common/components";
const getStatsInputStatusFetcher = async (key: [string, StatsInputStatusListRequestParams]): Promise<StatsInputStatusListApiResponse> => {
import ApproveButton from "@/components/button/ApproveButton";
import RejectButton from "@/components/button/RejectButton";
import DataGrid from "@/components/module/DataGrid";
import SearchForm from "@/components/module/SearchForm";
import type { SearchFormValues } from "@/components/type/SearchForm.type";
import { StatsInputStatusInpColumn } from "@/components/util/column/input/StatsInputStatusInpColumn";
import { toGridRows } from "@/components/util/grid/gridFunction";
import { useTabStore } from "@/store/tab-store";
const getStatsInputStatusFetcher = async (
key: [string, StatsInputStatusListRequestParams],
): Promise<StatsInputStatusListApiResponse> => {
return StatisticsInputStatusApi.getStatisticsInputStatusList(key[1]);
};
@@ -24,10 +27,15 @@ const getStatsInputStatusFetcher = async (key: [string, StatsInputStatusListRequ
const StatsInputStatusInp = () => {
const { setMessage: alert } = useAlertStore((state) => state);
const activeTabId = useTabStore((state) => state.activeTabId);
const [rowData, setRowData] = useState<Record<string, string | number>[]>([]);
const [searchParams, setSearchParams] = useState<StatsInputStatusListRequestParams | null>(null);
const [rowData, setRowData] = useState<Record<string, string | number>[]>(
[],
);
const [searchParams, setSearchParams] =
useState<StatsInputStatusListRequestParams | null>(null);
const swrKey = searchParams ? (["stats-input-status", searchParams] as const) : null;
const swrKey = searchParams
? (["stats-input-status", searchParams] as const)
: null;
const { mutate } = useSWRConfig();
const { isLoading } = useSWR<StatsInputStatusListApiResponse>(
swrKey,
@@ -49,7 +57,7 @@ const StatsInputStatusInp = () => {
);
useEffect(() => {
if (activeTabId === 'StatsInputStatusInp' && swrKey) {
if (activeTabId === "StatsInputStatusInp" && swrKey) {
mutate(swrKey);
}
}, [activeTabId]);
@@ -63,7 +71,7 @@ const StatsInputStatusInp = () => {
ctpvNm: values.sido,
sewerBzmnCd: values.bsnmCd,
sggNm: values.gugun === "ALL" ? "" : values.gugun,
statsYr: values.year,
statsYr: values.year ?? "",
};
setSearchParams(params);
mutate(["stats-input-status", params]);
@@ -87,22 +95,24 @@ const StatsInputStatusInp = () => {
<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]">
<div className="m-[0.6rem] flex items-center justify-end gap-[0.6rem]">
<RejectButton rejectFunc={rejectFunc} />
<ApproveButton approveFunc={approveFunc} />
</div>
</div>
{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" />
{isLoading && (
<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" />
</div>
</div>
</div>}
<DataGrid
columnInfo={ StatsInputStatusInpColumn }
rowStartNum={2}
fixedColumns={3}
rowData={rowData}
setRowData={setRowData}
)}
<DataGrid
columnInfo={StatsInputStatusInpColumn}
rowStartNum={2}
fixedColumns={3}
rowData={rowData}
setRowData={setRowData}
isExcelImport={false}
showHeaderCheckbox={true}
/>
@@ -111,4 +121,4 @@ const StatsInputStatusInp = () => {
);
};
export default StatsInputStatusInp;
export default StatsInputStatusInp;
@@ -2,13 +2,30 @@
import { useCallback, useEffect, useState } from "react";
import dayjs from "dayjs";
import useSWR from "swr";
import { useAlertStore } from "@/shared/lib";
import { CodeSelectWithAll } from "@/entities/code";
import { statisticsAPi } from "@/entities/statistics/api/statistics-service";
import { statisticsApi } from "@/entities/statistics/api/statistics-service";
import type {
ApiResponse,
SearchCondition,
Sgg,
StatisticsLog,
StatisticsLogRequest,
} from "@/entities/statistics/model/statistics-interface";
import { Button, Label, LoadingWithAnimation } from "@common/components";
import {
Button,
InputDatePicker,
Label,
LoadingWithAnimation,
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from "@common/components";
import DataGrid from "@/components/module/DataGrid";
import { LogHistoryDetailDialog } from "@/components/statistics/management/LogHistoryDetailDialog";
@@ -18,9 +35,23 @@ import { toGridRows } from "@/components/util/grid/gridFunction";
const PAGE_SIZE = 100;
const getSidoListFetcher = async (): Promise<ApiResponse<SearchCondition>> => {
return statisticsApi.getSidoList();
};
const getSggListFetcher = async (
key: [string, string],
): Promise<ApiResponse<Sgg[]>> => {
return statisticsApi.getSggList(key[1]);
};
const LogHistory = () => {
// const { setMessage: alert } = useAlertStore((state) => state);
const { setMessage: alert } = useAlertStore((state) => state);
const [statsCd, setStatsCd] = useState<string>("");
const [sido, setSido] = useState<string>("ALL");
const [gugun, setGugun] = useState<string>("ALL");
const [bsnmCd, setBsnmCd] = useState<string>("");
const [statsDataChgComCd, setStatsDataChgComCd] = useState<string>("ALL");
const [isLoading, setIsLoading] = useState<boolean>(false);
const [rowData, setRowData] = useState<Record<string, string | number>[]>(
[],
@@ -32,49 +63,148 @@ const LogHistory = () => {
string,
string | number
> | null>(null);
const [startDate, setStartDate] = useState<Date | undefined>(() =>
dayjs().subtract(1, "month").startOf("day").toDate(),
);
const [endDate, setEndDate] = useState<Date | undefined>(() =>
dayjs().endOf("day").toDate(),
);
const { data: sidoList } = useSWR<ApiResponse<SearchCondition>>(
"log-history-sido-list",
getSidoListFetcher,
{
revalidateOnFocus: false,
revalidateOnReconnect: false,
revalidateIfStale: false,
dedupingInterval: 2000,
shouldRetryOnError: false,
onSuccess: (data) => {
if (data.status !== "success") {
alert(`시도 목록을 불러오는데 실패했습니다.
새로고침 후 다시 시도해주세요.`);
}
},
onError: () => {
alert(`시도 목록을 불러오는데 실패했습니다.
새로고침 후 다시 시도해주세요.`);
},
},
);
const sggKey: [string, string] | null =
sido && sido !== "ALL" ? ["log-history-sgg-list", sido] : null;
const { data: sggList } = useSWR<ApiResponse<Sgg[]>>(
sggKey,
getSggListFetcher,
{
revalidateOnFocus: false,
revalidateOnReconnect: false,
revalidateIfStale: false,
dedupingInterval: 2000,
shouldRetryOnError: false,
onSuccess: (data) => {
if (data.status !== "success") {
alert(`시군구 목록을 불러오는데 실패했습니다.
새로고침 후 다시 시도해주세요.`);
}
},
onError: () => {
alert(`시군구 목록을 불러오는데 실패했습니다.
새로고침 후 다시 시도해주세요.`);
},
},
);
const sggOptions = sggList?.data ?? [];
const handleSidoChange = (value: string) => {
const selectedBsnm =
sidoList?.data.sidoList.find((v) => v.sido === value)?.bsnmCd ?? "";
setSido(value);
setGugun("ALL");
setBsnmCd(selectedBsnm);
};
const handleSggChange = (value: string) => {
const listForBsnm = sggList?.data ?? [];
const selectedBsnm =
value === "ALL"
? (sidoList?.data.sidoList.find((v) => v.sido === sido)
?.bsnmCd ?? "")
: (listForBsnm.find((v) => v.gugun === value)?.bsnmCd ?? "");
setGugun(value);
setBsnmCd(selectedBsnm);
};
const loadLogs = useCallback(
async (page1Based: number) => {
const param: StatisticsLogRequest = {
statsCd: statsCd === "" || statsCd === "ALL" ? "" : statsCd,
page: page1Based,
size: PAGE_SIZE,
startDate: startDate
? dayjs(startDate).format("YYYY-MM-DD")
: "",
endDate: endDate ? dayjs(endDate).format("YYYY-MM-DD") : "",
...(!(statsCd === "" || statsCd === "ALL") && { statsCd }),
...(sido && sido !== "ALL" && { ctpvNm: sido }),
...(gugun && gugun !== "ALL" && { sggNm: gugun }),
...(bsnmCd && { sewerBzmnCd: bsnmCd }),
...(statsDataChgComCd &&
statsDataChgComCd !== "ALL" && {
statsDataChgComCd,
}),
};
setIsLoading(true);
try {
setIsLoading(true);
const response = await statisticsAPi.getLogList(param);
if (response.status === "success") {
const lostList = response.data?.statsLogList ?? [];
const convertedData: StatisticsLog[] = lostList.map(
(item: StatisticsLog) => ({
...item,
dmndFwkNm:
item.dmndFwkNm === "save"
? "저장"
: item.dmndFwkNm === "submit"
? "제출"
: "예외 제출",
}),
);
const response = await statisticsApi.getLogList(param);
setRowData(toGridRows(convertedData));
setTotalPages(response.data?.pageInfo?.totalPages ?? 0);
setPageIndex(Math.max(0, page1Based - 1));
} else {
alert(
response.message || "데이터 조회 중 오류가 발생했습니다.",
);
if (response.status === "success") {
const lostList = response.data?.statsLogList ?? [];
setRowData(toGridRows(lostList));
setTotalPages(response.data?.pageInfo?.totalPages ?? 0);
setPageIndex(Math.max(0, page1Based - 1));
} else {
alert(
response.message ||
"데이터 조회 중 오류가 발생했습니다.",
);
}
} catch {
alert("데이터 조회 중 오류가 발생했습니다.");
} finally {
setIsLoading(false);
}
},
[statsCd],
[
statsCd,
startDate,
endDate,
sido,
gugun,
bsnmCd,
statsDataChgComCd,
alert,
],
);
const handleSearch = async (e: React.SubmitEvent<HTMLFormElement>) => {
const handleSearch = async (e: React.FormEvent<HTMLFormElement>) => {
e.preventDefault();
await loadLogs(1);
};
const handleReset = () => {
setStatsCd("");
setSido("ALL");
setGugun("ALL");
setBsnmCd("");
setStatsDataChgComCd("ALL");
setStartDate(dayjs().subtract(1, "month").startOf("day").toDate());
setEndDate(dayjs().endOf("day").toDate());
};
const handleRowClick = (row: Record<string, string | number>) => {
setSelectedLogRow(row);
setDetailDialogOpen(true);
@@ -86,14 +216,58 @@ const LogHistory = () => {
};
useEffect(() => {
loadLogs(1);
void loadLogs(1);
}, []);
return (
<div className="relative">
<div className="card card-border">
<form onSubmit={handleSearch}>
<div className="flex items-center gap-8 p-2">
<div className="flex flex-wrap items-center gap-8 p-2">
<div className="flex items-center gap-2">
<Label className="text-label">시도</Label>
<Select
value={sido}
onValueChange={handleSidoChange}
>
<SelectTrigger className="w-[12rem]">
<SelectValue placeholder="전체" />
</SelectTrigger>
<SelectContent>
<SelectItem value="ALL">전체</SelectItem>
{sidoList?.data?.sidoList?.map((item) => (
<SelectItem
key={item.adstDstrCd}
value={item.sido}
>
{item.sido}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
<div className="flex items-center gap-2">
<Label className="text-label">시군구</Label>
<Select
value={gugun}
onValueChange={handleSggChange}
>
<SelectTrigger className="w-[12rem]">
<SelectValue placeholder="전체" />
</SelectTrigger>
<SelectContent>
<SelectItem value="ALL">전체</SelectItem>
{sggOptions.map((sgg) => (
<SelectItem
key={sgg.adstDstrCd}
value={sgg.gugun}
>
{sgg.gugun}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
<div className="flex items-center gap-2">
<Label className="text-label">통계항목</Label>
<CodeSelectWithAll
@@ -104,11 +278,36 @@ const LogHistory = () => {
className="w-[16rem]"
/>
</div>
<div className="flex items-center gap-2">
<Label className="text-label">요청 기능</Label>
<CodeSelectWithAll
upCd="STATSDATACHG"
value={statsDataChgComCd}
onValueChange={setStatsDataChgComCd}
placeholder="전체"
className="w-[16rem]"
/>
</div>
<div className="flex items-center gap-2">
<Label className="mx-2 text-label">시작일</Label>
<InputDatePicker
value={startDate}
onChange={setStartDate}
className="w-[14rem]"
/>
<Label className="mx-2 text-label">종료일</Label>
<InputDatePicker
value={endDate}
onChange={setEndDate}
className="w-[14rem]"
/>
</div>
<div className="ml-auto flex items-center gap-2">
<Button
color="green"
className="h-[3.2rem] w-[96px]"
type="button"
onClick={handleReset}
>
초기화
</Button>
@@ -12,7 +12,7 @@ import {
DialogTitle,
} from "@common/components";
import { statisticsAPi } from "@/entities/statistics/api/statistics-service";
import { statisticsApi } from "@/entities/statistics/api/statistics-service";
export interface LogHistoryDetailDialogProps {
open: boolean;
@@ -30,7 +30,7 @@ export function LogHistoryDetailDialog({
const sn = row.statsLogSn;
void (async () => {
const response = await statisticsAPi.getLogDetail(
const response = await statisticsApi.getLogDetail(
sn !== undefined && sn !== null ? String(sn) : "",
);
console.log(response.data);
@@ -0,0 +1,99 @@
"use client";
import { useEffect, useState } from "react";
import {
Button,
Dialog,
DialogClose,
DialogContent,
DialogFooter,
DialogHeader,
DialogTitle,
Label,
Textarea,
} from "@common/components";
export interface ReturnReasonDialogProps {
open: boolean;
onOpenChange: (open: boolean) => void;
onSave?: (reason: string) => void | Promise<void>;
}
export function ReturnReasonDialog({
open,
onOpenChange,
onSave,
}: ReturnReasonDialogProps) {
const [reason, setReason] = useState("");
const [isSaving, setIsSaving] = useState(false);
const [reasonError, setReasonError] = useState(false);
useEffect(() => {
if (!open) return;
setReason("");
setReasonError(false);
}, [open]);
const handleSave = async () => {
if (reason.trim() === "") {
setReasonError(true);
return;
}
try {
setIsSaving(true);
await onSave?.(reason);
onOpenChange(false);
} finally {
setIsSaving(false);
}
};
return (
<Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent className="max-w-[56rem]" aria-describedby={undefined}>
<DialogHeader>
<DialogTitle>반려 사유</DialogTitle>
</DialogHeader>
<div className="flex flex-col gap-4 py-2">
<div className="flex flex-col gap-2">
<Label className="text-label">반려 사유</Label>
<Textarea
value={reason}
onChange={(e) => {
setReason(e.target.value);
if (reasonError) {
setReasonError(false);
}
}}
className="min-h-[12rem] resize-y !text-[1.3rem] !leading-6"
placeholder="반려 사유를 입력하세요."
disabled={isSaving}
/>
{reasonError && (
<p className="text-[1.2rem] text-red-500">
반려 사유를 작성해주세요.
</p>
)}
</div>
</div>
<DialogFooter>
<DialogClose asChild>
<Button type="button" color="gray" disabled={isSaving}>
취소
</Button>
</DialogClose>
<Button
type="button"
color="primary"
disabled={isSaving}
onClick={() => void handleSave()}
>
저장
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
);
}
@@ -0,0 +1,168 @@
"use client";
import { useEffect, useState } from "react";
import {
Button,
Dialog,
DialogClose,
DialogContent,
DialogFooter,
DialogHeader,
DialogTitle,
Label,
Textarea,
} from "@common/components";
import { getUserInfo } from "../../../../../../packages/common/components/src/api";
import type { ReviewOpinionSaveRequest } from "@/entities/statistics/model/statistics-interface";
import { useAlertStore } from "@/shared/lib";
export type ReviewOpinionDialogContext = Pick<
ReviewOpinionSaveRequest,
"statsYr" | "sewerBzmnCd" | "statsCd"
>;
export interface ReviewOpinionDialogProps {
open: boolean;
onOpenChange: (open: boolean) => void;
/** 조회 성공 후의 통계연도·사업자코드·통계코드 (없으면 저장 불가) */
context: ReviewOpinionDialogContext | null;
/** 다이얼로그 열릴 때 검토의견 초기값 */
initialReviewOpinion?: string;
/** 다이얼로그 열릴 때 처리내역 초기값 */
initialProcessHistory?: string;
/** 저장 시 생성된 param 전달 (API 호출 등) */
onSave?: (param: ReviewOpinionSaveRequest) => void | Promise<void>;
}
export function ReviewOpinionDialog({
open,
onOpenChange,
context,
initialReviewOpinion = "",
initialProcessHistory = "",
onSave,
}: ReviewOpinionDialogProps) {
const { setMessage: alert } = useAlertStore((state) => state);
const [reviewOpinion, setReviewOpinion] = useState("");
const [processHistory, setProcessHistory] = useState("");
const [isSaving, setIsSaving] = useState(false);
const [reviewOpinionError, setReviewOpinionError] = useState(false);
const resetForm = () => {
setReviewOpinion("");
setProcessHistory("");
setReviewOpinionError(false);
};
const handleDialogOpenChange = (nextOpen: boolean) => {
if (!nextOpen) resetForm();
onOpenChange(nextOpen);
};
useEffect(() => {
if (!open) return;
setReviewOpinion(initialReviewOpinion);
setProcessHistory(initialProcessHistory);
}, [open, initialReviewOpinion, initialProcessHistory]);
const handleSave = async () => {
if (reviewOpinion.trim() === "") {
setReviewOpinionError(true);
return;
}
if (
context == null ||
context.statsYr === "" ||
context.sewerBzmnCd === ""
) {
alert("시도·시군구·년도로 조회한 뒤 검토의견을 저장할 수 있습니다.");
return;
}
try {
setIsSaving(true);
let userInfo: Awaited<ReturnType<typeof getUserInfo>>;
try {
userInfo = await getUserInfo();
} catch {
alert("사용자 정보를 불러오지 못했습니다. 다시 시도해주세요.");
return;
}
const param: ReviewOpinionSaveRequest = {
statsYr: context.statsYr,
sewerBzmnCd: context.sewerBzmnCd,
statsCd: context.statsCd,
splmntOpnnCn: reviewOpinion,
wrterOpnnCn: processHistory,
mdfrId: userInfo.userUnqId,
};
await onSave?.(param);
resetForm();
onOpenChange(false);
} catch {
/* 저장 실패 시 부모에서 alert 후 throw — 다이얼로그는 유지 */
} finally {
setIsSaving(false);
}
};
return (
<Dialog open={open} onOpenChange={handleDialogOpenChange}>
<DialogContent className="max-w-[56rem]" aria-describedby={undefined}>
<DialogHeader>
<DialogTitle>검토의견작성</DialogTitle>
</DialogHeader>
<div className="flex flex-col gap-4 py-2">
<div className="flex flex-col gap-2">
<Label className="text-label">검토의견</Label>
<Textarea
value={reviewOpinion}
onChange={(e) => {
setReviewOpinion(e.target.value);
if (reviewOpinionError) {
setReviewOpinionError(false);
}
}}
className="min-h-[10rem] resize-y !text-[1.3rem] !leading-6"
placeholder="검토의견을 입력하세요."
disabled={isSaving}
/>
{reviewOpinionError && (
<p className="text-[1.2rem] text-red-500">
검토의견을 작성해주세요.
</p>
)}
</div>
<div className="flex flex-col gap-2">
<Label className="text-label">처리내역</Label>
<Textarea
value={processHistory}
onChange={(e) => setProcessHistory(e.target.value)}
className="min-h-[10rem] resize-y !text-[1.3rem] !leading-6"
placeholder="처리내역을 입력하세요."
disabled={isSaving}
/>
</div>
</div>
<DialogFooter>
<DialogClose asChild>
<Button type="button" color="gray" disabled={isSaving}>
취소
</Button>
</DialogClose>
<Button
type="button"
color="primary"
disabled={isSaving}
onClick={() => void handleSave()}
>
저장
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
);
}
@@ -1,17 +1,17 @@
import { useMemo, useRef,useState } from "react";
import { useMemo, useRef, useState } from "react";
import { useAlertStore } from "@/shared/lib";
import { statisticsManagementApi } from "@/entities/statistics/api/statistics-management-service";
import type { SewerPipeMtcRequestParams } from "@/entities/statistics/model/statistics-sewage-pipe-mtc-review-interface";
import { LoadingWithAnimation } from "@common/components";
import { LoadingWithAnimation, Tabs, TabsContent, TabsList, TabsTrigger } from "@common/components";
import ApproveButton from "@/components/button/ApproveButton";
import ReturnButton from "@/components/button/ReturnButton";
import ReviewOpinionButton from "@/components/button/ReviewOpinionButton";
import SaveButton from "@/components/button/SaveButton";
import SearchForm from "@/components/module/SearchForm";
import TabDataGrid from "@/components/module/TabDataGrid";
import type { ColumnConfig,ColumnInfoList } from "@/components/type/DataGrid.type";
import DataGrid from "@/components/module/DataGrid";
import type { ColumnConfig, ColumnInfo, ColumnInfoList } from "@/components/type/DataGrid.type";
import type { SearchFormValues } from "@/components/type/SearchForm.type";
import { SewagePipeMaintainColumnInfoList } from "@/components/util/column/review/SewagePipeMaintainRvwColumn";
import { toGridRows } from "@/components/util/grid/gridFunction";
@@ -79,8 +79,8 @@ const SewagePipeMaintainRvw = () => {
};
const handleSearch = async (values: SearchFormValues) => {
if (!values.sido || values.sido === 'ALL') {
alert('시도를 선택해주세요.');
if (!values.sido || values.sido === "ALL") {
alert("시도를 선택해주세요.");
return;
}
@@ -91,26 +91,27 @@ const SewagePipeMaintainRvw = () => {
statsYr: values.year,
sewerBzmnCd: values.bsnmCd,
ctpvNm: values.sido,
sggNm: values.gugun === 'ALL' ? '' : values.gugun
sggNm: values.gugun === "ALL" ? "" : values.gugun,
};
searchParamRef.current = searchParam;
searchDoneRef.current = true;
lazyTabLoggedRef.current.clear();
setRowDataMap(new Map());
const response = await fetchSewagePipeMaintainReviewTab(activeTab, searchParam);
if (response?.status === 'success' && response.data) {
if (response?.status === "success" && response.data) {
const listData = getListDataByTab(activeTab, response.data);
setRowDataMap(
new Map([[activeTab, toGridRows(listData)]]),
);
setRowDataMap((prev) => {
const next = new Map(prev);
next.set(activeTab, toGridRows(listData));
return next;
});
} else {
alert(response?.message || "데이터가 없습니다.");
}
} catch {
alert('데이터 조회 중 오류가 발생했습니다.');
alert("데이터 조회 중 오류가 발생했습니다.");
} finally {
setIsLoading(false);
}
@@ -177,14 +178,60 @@ const SewagePipeMaintainRvw = () => {
</div>
)}
<TabDataGrid
columnInfo={columnInfo}
isExcelImport={false}
rowDataMap={rowDataMap}
setRowDataMap={setRowDataMap}
activeTab={activeTab}
onActiveTabChange={handleActiveTabChange}
/>
<div className="flex flex-col gap-[0.6rem] mx-[0.6rem]">
<Tabs
value={activeTab}
onValueChange={(v) => void handleActiveTabChange(v)}
className="w-full"
variant="default"
>
<TabsList>
{columnInfo.map((info) => (
<TabsTrigger key={info.tabKey} value={info.tabKey}>
{info.tabLabel || info.tabKey}
</TabsTrigger>
))}
</TabsList>
{columnInfo.map((info) => {
const tabKey = info.tabKey;
if (!info.columns || !info.columnLayout) {
return null;
}
const rowData = rowDataMap.get(tabKey) ?? [];
const gridColumnInfo: ColumnInfo = {
columns: info.columns,
columnLayout: info.columnLayout,
};
return (
<TabsContent key={tabKey} value={tabKey}>
<DataGrid
columnInfo={gridColumnInfo}
rowStartNum={info.rowStartNum}
excelExportFileSuffix={info.tabLabel || tabKey}
rowData={rowData}
setRowData={(updater) => {
setRowDataMap((prev) => {
const next = new Map(prev);
const cur = next.get(tabKey) ?? [];
const n =
typeof updater === "function" ? updater(cur) : updater;
next.set(tabKey, n);
return next;
});
}}
isExcelImport={false}
isExcelExport
showHeaderCheckbox={false}
fixedColumns={info.fixedColumns}
enableSort={false}
/>
</TabsContent>
);
})}
</Tabs>
</div>
</div>
);
};
@@ -1,17 +1,17 @@
import { useMemo, useRef,useState } from "react";
import { useRef, useState } from "react";
import { useAlertStore } from "@/shared/lib";
import { statisticsManagementApi } from "@/entities/statistics/api/statistics-management-service";
import type { SewagePipeReviewRequestParams } from "@/entities/statistics/model/statistics-sewage-pipe-review-interface";
import { LoadingWithAnimation } from "@common/components";
import { LoadingWithAnimation, Tabs, TabsContent, TabsList, TabsTrigger } from "@common/components";
import ApproveButton from "@/components/button/ApproveButton";
import ReturnButton from "@/components/button/ReturnButton";
import ReviewOpinionButton from "@/components/button/ReviewOpinionButton";
import SaveButton from "@/components/button/SaveButton";
import SearchForm from "@/components/module/SearchForm";
import TabDataGrid from "@/components/module/TabDataGrid";
import type { ColumnConfig,ColumnInfoList } from "@/components/type/DataGrid.type";
import DataGrid from "@/components/module/DataGrid";
import type { ColumnInfo } 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";
@@ -48,17 +48,6 @@ const SewagePipeRvw = () => {
const searchDoneRef = useRef(false);
const lazyTabLoggedRef = useRef<Set<string>>(new Set());
const columnInfo = useMemo(() => {
return SewagePipeColumnInfoListRvw.map((tab: ColumnInfoList) => ({
...tab,
columns: (tab.columns ?? []).map((col: ColumnConfig) => ({
...col,
editable: false as const,
styleName: col.styleName ?? ("rg-cell-readonly" as const),
})),
}));
}, []);
const handleReset = () => {
setRowDataMap(new Map());
setActiveTab(firstTabKey);
@@ -86,14 +75,15 @@ const SewagePipeRvw = () => {
searchParamRef.current = searchParam;
searchDoneRef.current = true;
lazyTabLoggedRef.current.clear();
setRowDataMap(new Map());
const response = await fetchSewagePipeReviewTab(activeTab, searchParam);
if (response?.status === "success" && response.data) {
setRowDataMap(
new Map([[activeTab, toGridRows(response.data as object[])]]),
);
setRowDataMap((prev) => {
const next = new Map(prev);
next.set(activeTab, toGridRows(response.data as object[]));
return next;
});
} else {
alert(response?.message || "데이터 조회 중 오류가 발생했습니다.");
}
@@ -163,14 +153,60 @@ const SewagePipeRvw = () => {
</div>
)}
<TabDataGrid
columnInfo={columnInfo}
isExcelImport={false}
rowDataMap={rowDataMap}
setRowDataMap={setRowDataMap}
activeTab={activeTab}
onActiveTabChange={handleActiveTabChange}
/>
<div className="flex flex-col gap-[0.6rem] mx-[0.6rem]">
<Tabs
value={activeTab}
onValueChange={(v) => void handleActiveTabChange(v)}
className="w-full"
variant="default"
>
<TabsList>
{SewagePipeColumnInfoListRvw.map((info) => (
<TabsTrigger key={info.tabKey} value={info.tabKey}>
{info.tabLabel || info.tabKey}
</TabsTrigger>
))}
</TabsList>
{SewagePipeColumnInfoListRvw.map((info) => {
const tabKey = info.tabKey;
if (!info.columns || !info.columnLayout) {
return null;
}
const rowData = rowDataMap.get(tabKey) ?? [];
const gridColumnInfo: ColumnInfo = {
columns: info.columns,
columnLayout: info.columnLayout,
};
return (
<TabsContent key={tabKey} value={tabKey}>
<DataGrid
columnInfo={gridColumnInfo}
rowStartNum={info.rowStartNum}
excelExportFileSuffix={info.tabLabel || tabKey}
rowData={rowData}
setRowData={(updater) => {
setRowDataMap((prev) => {
const next = new Map(prev);
const cur = next.get(tabKey) ?? [];
const n =
typeof updater === "function" ? updater(cur) : updater;
next.set(tabKey, n);
return next;
});
}}
isExcelImport={false}
isExcelExport
showHeaderCheckbox={false}
fixedColumns={info.fixedColumns}
enableSort={false}
/>
</TabsContent>
);
})}
</Tabs>
</div>
</div>
);
};
@@ -5,13 +5,22 @@ import ReviewOpinionButton from "@/components/button/ReviewOpinionButton";
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 { getUserInfo } from "../../../../../../packages/common/components/src/api";
import { useAlertStore } from "@/shared/lib";
import { useConfirm } from "@frontend-opensource/use-react-hooks";
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 { statisticsApi } from "@/entities/statistics/api/statistics-service";
import { statisticsManagementApi } from "@/entities/statistics/api/statistics-management-service";
import type { SearchFormValues } from "@/components/type/SearchForm.type";
import { toGridRows } from "@/components/util/grid/gridFunction";
import {
ReviewOpinionDialog,
type ReviewOpinionDialogContext,
} from "@/components/statistics/review/ReviewOpinionDialog";
// 하수도 통계 검토 01.하수도 보급률
const SeweragePenetrationRateRvw = () => {
@@ -19,6 +28,14 @@ const SeweragePenetrationRateRvw = () => {
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 [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;
@@ -35,14 +52,14 @@ const SeweragePenetrationRateRvw = () => {
}, []);
const { showReviewOpinionButton, showReturnButton, showSubmitButton } = useMemo(() => {
const lae = statsStatusInfo.laeSbmsnSttsCd != null ? String(statsStatusInfo.laeSbmsnSttsCd) : "";
const pic = statsStatusInfo.statPicSbmsnSttsCd != null ? String(statsStatusInfo.statPicSbmsnSttsCd) : "";
const laeIs3 = lae === "3";
const reviewSubmitVisible = laeIs3 && (pic === "0" || pic === "1");
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: laeIs3 && pic !== "3",
showReturnButton: submitIs3 && prvn !== "3",
};
}, [statsStatusInfo]);
@@ -52,6 +69,10 @@ const SeweragePenetrationRateRvw = () => {
const handleReset = () => {
setRowData([]);
setStatsStatusInfo({});
setReviewOpinionContext(null);
setLatestReviewOpinion("");
setLatestProcessHistory("");
setLastSearchValues(null);
};
const handleSearch = async (values: SearchFormValues) => {
@@ -60,6 +81,7 @@ const SeweragePenetrationRateRvw = () => {
alert('시도를 선택해주세요.');
return;
}
setLastSearchValues(values);
try {
setIsLoading(true);
@@ -80,9 +102,23 @@ const SeweragePenetrationRateRvw = () => {
}));
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));
} else {
setRowData([]);
setReviewOpinionContext(null);
setLatestReviewOpinion("");
setLatestProcessHistory("");
alert(response.message || "데이터가 없습니다.");
}
} catch (error) {
@@ -93,11 +129,120 @@ const SeweragePenetrationRateRvw = () => {
}
};
const submitFunc = () => console.log('submit');
const submitFunc = async () => {
if (reviewOpinionContext == null) {
alert("조회 후 제출할 수 있습니다.");
return;
}
const reviewOpinionFunc = () => console.log('reviewOpinionFunc');
if (!(await confirm("제출 하시겠습니까?"))) {
return;
}
const returnFunc = () => console.log('returnFunc');
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("제출 중 오류가 발생했습니다.");
}
};
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 (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("반려 처리 중 오류가 발생했습니다.");
}
}
};
return (
<div className="relative">
@@ -131,6 +276,20 @@ const SeweragePenetrationRateRvw = () => {
rowData={rowData}
setRowData={setRowData}
/>
<ReviewOpinionDialog
open={reviewOpinionOpen}
onOpenChange={setReviewOpinionOpen}
context={reviewOpinionContext}
initialReviewOpinion={latestReviewOpinion}
initialProcessHistory={latestProcessHistory}
onSave={saveReviewOpinion}
/>
<ReturnReasonDialog
open={returnDialogOpen}
onOpenChange={setReturnDialogOpen}
onSave={handleSaveReturnReason}
/>
</div>
);
};
@@ -1,23 +1,54 @@
import { useState, useMemo } from "react";
import { createColumnHelper, getCoreRowModel, useReactTable } from "@tanstack/react-table";
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 ReviewOpinionButton from "@/components/button/ReviewOpinionButton";
import ReturnButton from "@/components/button/ReturnButton";
import { LoadingWithAnimation } from "@common/components";
import {
Button,
Dialog,
DialogContent,
DialogFooter,
DialogHeader,
DialogTitle,
LoadingWithAnimation,
DataTable
} from "@common/components";
import { useAlertStore } from "@/shared/lib";
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 { toGridRows } from "@/components/util/grid/gridFunction";
import type { SearchFormValues } from "@/components/type/SearchForm.type";
type ReviewHistoryRow = ReviewHistoryResponse["data"][number];
const checkPrvnStatusCd = (code: string) => {
return (code === '0' || code === '1') ? '미작성' : (code === '2' ? '제출완료' : (code === '3' ? '승인완료' : '반려'));
};
const checkSubmitStatusCd = (code: string) => {
return code === '0' ? '미작성' : (code === '1' ? '작성중' : (code === '2' ? '제출완료' : (code === '3' ? '승인완료' : '보완요청')));
};
// 하수도 통계 검토 00. 통계 입력 현황
const StatsInputStatusRvw = () => {
const { setMessage: alert } = useAlertStore((state) => state);
const [rowData, setRowData] = useState<Record<string, string | number>[]>([]);
const [historyRowData, setHistoryRowData] = useState<ReviewHistoryRow[]>([]);
const [isLoading, setIsLoading] = useState<boolean>(false);
const [searchedYear, setSearchedYear] = useState<string>("");
const [reviewHistoryOpen, setReviewHistoryOpen] = useState<boolean>(false);
const [historySearchInfo, setHistorySearchInfo] = useState<{
ctpvNm: string;
sggNm: string;
tabNm: string;
}>({
ctpvNm: '',
sggNm: '',
tabNm: '',
});
const columnInfo = useMemo(() => {
const { columns, columnLayout } = StatsInputStatusInfoList;
@@ -30,8 +61,30 @@ const StatsInputStatusRvw = () => {
};
}, []);
const reviewHistoryColumns = useMemo(() => {
const col = createColumnHelper<ReviewHistoryRow>();
return [
col.accessor("logDt", {
header: "등록일",
cell: (info) => info.getValue(),
}),
col.accessor("historyContent", {
header: "내용",
cell: (info) => info.getValue(),
}),
];
}, []);
const historyTable = useReactTable({
data: historyRowData,
columns: reviewHistoryColumns,
getCoreRowModel: getCoreRowModel(),
getRowId: (_row, index) => String(index),
});
const handleReset = () => {
setRowData([]);
setSearchedYear("");
};
const handleSearch = async (values: SearchFormValues) => {
@@ -42,24 +95,29 @@ const StatsInputStatusRvw = () => {
try {
setIsLoading(true);
setSearchedYear(values.year ?? "");
const response = await statisticsManagementApi.getStatsInputStatusList({
ctpvNm: values.sido,
sggNm: values.gugun === 'ALL' ? '' : values.gugun,
statsYr: values.year,
sggNm: values.gugun === 'ALL' ? '' : values.gugun,
statsYr: values.year,
sewerBzmnCd: values.bsnmCd
});
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(rawList));
setRowData(toGridRows(convertedData));
} else {
setRowData([]);
alert(response.message || "조회된 데이터가 없습니다.");
}
} catch (error) {
console.error("입력현황 조회 중 에러 발생:", error);
} catch {
alert('데이터 조회 중 오류가 발생했습니다.');
setRowData([]);
} finally {
@@ -68,20 +126,40 @@ const StatsInputStatusRvw = () => {
};
const approveFunc = () => console.log('approveFunc');
const saveFunc = () => console.log('submit');
const reviewOpinionFunc = () => console.log('reviewOpinionFunc');
const returnFunc = () => console.log('returnFunc');
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 ?? ""),
};
try {
const response = await statisticsManagementApi.getReviewHistory(params);
if (response.status === 'success') {
setHistoryRowData(response.data);
setReviewHistoryOpen(true);
} else {
alert(response.message || '검토 이력 조회 중 오류가 발생했습니다.');
}
} catch {
alert('검토 이력 조회 중 오류가 발생했습니다.');
}
};
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]">
<ReviewOpinionButton reviewOpinionFunc={reviewOpinionFunc} />
<ApproveButton approveFunc={approveFunc} />
<ReturnButton returnFunc={returnFunc} />
<SaveButton saveFunc={saveFunc} />
</div>
</div>
@@ -96,12 +174,43 @@ const StatsInputStatusRvw = () => {
<DataGrid
columnInfo={columnInfo}
isExcelImport={false}
showHeaderCheckbox={true}
rowStartNum={2}
showHeaderCheckbox={true}
rowStartNum={2}
fixedColumns={3}
rowData={rowData}
setRowData={setRowData}
onRowClick={handleRowClick}
/>
<Dialog open={reviewHistoryOpen} onOpenChange={setReviewHistoryOpen}>
<DialogContent
className="flex max-h-[85vh] max-w-[48rem] flex-col gap-4"
aria-describedby={undefined}
>
<DialogHeader className="shrink-0">
<DialogTitle>
{(historySearchInfo.sggNm ? `${historySearchInfo.ctpvNm} ${historySearchInfo.sggNm} - ${searchedYear}년 ${historySearchInfo.tabNm} 검토이력` : `${historySearchInfo.ctpvNm} - ${searchedYear}년 ${historySearchInfo.tabNm} 검토이력`)}
</DialogTitle>
</DialogHeader>
{historyRowData.length === 0 ?
<div className="text-2xl h-40 flex items-center justify-center">
검토 이력이 없습니다.
</div>
:
<div className="min-h-0 flex-1 overflow-y-auto overflow-x-auto">
<DataTable table={historyTable} />
</div>
}
<DialogFooter className="shrink-0">
<Button
type="button"
color="gray"
onClick={() => setReviewHistoryOpen(false)}
>
닫기
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
</div>
);
};
@@ -2,20 +2,22 @@ import { useMemo, useState } from "react";
import { useAlertStore } from "@/shared/lib";
import { statisticsTotalStatusApi } from "@/entities/statistics/api/statistics-total-service";
import { LoadingWithAnimation } from "@common/components";
import { LoadingWithAnimation, Tabs, TabsContent, TabsList, TabsTrigger } from "@common/components";
import SearchForm from "@/components/module/SearchForm";
import TabDataGrid from "@/components/module/TabDataGrid";
import type { ColumnConfig, ColumnInfoList } from "@/components/type/DataGrid.type";
import DataGrid from "@/components/module/DataGrid";
import type { ColumnConfig, ColumnInfo, ColumnInfoList } from "@/components/type/DataGrid.type";
import type { SearchFormValues } from "@/components/type/SearchForm.type";
import { SewagePipeColumnInfoListTot } from "@/components/util/column/total/SewagePipeTotColumn";
import { toGridRows } from "@/components/util/grid/gridFunction";
const firstTabKey = SewagePipeColumnInfoListTot[0]?.tabKey ?? "ST00020001";
const SewagePipeTot = () => {
const { setMessage: alert } = useAlertStore((state) => state);
const [rowDataMap, setRowDataMap] = useState<Map<string, Record<string, string | number>[]>>(new Map());
const [isLoading, setIsLoading] = useState(false);
const [activeTab, setActiveTab] = useState("ST00020001");
const [activeTab, setActiveTab] = useState(firstTabKey);
const columnInfo = useMemo(
() =>
@@ -32,11 +34,11 @@ const SewagePipeTot = () => {
const handleReset = () => {
setRowDataMap(new Map());
setActiveTab("ST00020001");
setActiveTab(firstTabKey);
};
const handleSearch = async (values: SearchFormValues) => {
setActiveTab("ST00020001");
setActiveTab(firstTabKey);
setRowDataMap(new Map());
try {
@@ -76,20 +78,66 @@ const SewagePipeTot = () => {
</div>
</div>
)}
<TabDataGrid
columnInfo={columnInfo}
isExcelImport={false}
rowDataMap={rowDataMap}
setRowDataMap={setRowDataMap}
activeTab={activeTab}
onActiveTabChange={setActiveTab}
cellStyleCallback={({ row }) => {
const rowType = String(row.rowType ?? "");
if (rowType === "DETAIL") return undefined;
if (rowType === "BIZ_SUM") return "rg-cell-rowtype-biz-sum";
return "rg-cell-rowtype-other";
}}
/>
<div className="flex flex-col gap-[0.6rem] mx-[0.6rem]">
<Tabs
value={activeTab}
onValueChange={setActiveTab}
className="w-full"
variant="default"
>
<TabsList>
{columnInfo.map((info) => (
<TabsTrigger key={info.tabKey} value={info.tabKey}>
{info.tabLabel || info.tabKey}
</TabsTrigger>
))}
</TabsList>
{columnInfo.map((info) => {
const tabKey = info.tabKey;
if (!info.columns || !info.columnLayout) {
return null;
}
const rowData = rowDataMap.get(tabKey) ?? [];
const gridColumnInfo: ColumnInfo = {
columns: info.columns,
columnLayout: info.columnLayout,
};
return (
<TabsContent key={tabKey} value={tabKey}>
<DataGrid
columnInfo={gridColumnInfo}
rowStartNum={info.rowStartNum}
excelExportFileSuffix={info.tabLabel || tabKey}
rowData={rowData}
setRowData={(updater) => {
setRowDataMap((prev) => {
const next = new Map(prev);
const cur = next.get(tabKey) ?? [];
const n =
typeof updater === "function" ? updater(cur) : updater;
next.set(tabKey, n);
return next;
});
}}
isExcelImport={false}
isExcelExport
showHeaderCheckbox={false}
fixedColumns={info.fixedColumns}
enableSort={false}
cellStyleCallback={({ row }) => {
const rowType = String(row.rowType ?? "");
if (rowType === "DETAIL") return undefined;
if (rowType === "BIZ_SUM") return "rg-cell-rowtype-biz-sum";
return "rg-cell-rowtype-other";
}}
/>
</TabsContent>
);
})}
</Tabs>
</div>
</div>
);
};
@@ -44,7 +44,7 @@ const SeweragePenetrationRateTot = () => {
return (
<>
<SearchForm isDetail={true} onSubmit={handleSearch} />
<SearchForm isDetail={true} onSubmit={handleSearch} sidoSelectText="전체" />
{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" />
@@ -1,10 +1,123 @@
import SearchForm from "../../module/SearchForm";
import { useEffect, useMemo, useState } from "react";
import { useAlertStore } from "@/shared/lib";
import { statisticsTotalStatusApi } from "@/entities/statistics/api/statistics-total-service";
import { toGridRows } from "@/components/util/grid/gridFunction";
import SearchForm from "@/components/module/SearchForm";
import TabDataGrid from "@/components/module/TabDataGrid";
import { LoadingWithAnimation } from "@common/components";
import type { ColumnConfig, ColumnInfoList } from "@/components/type/DataGrid.type";
import type { SearchFormValues } from "@/components/type/SearchForm.type";
import { ComplaintInpColumn } from "@/components/util/column/total/sewerageServiceLevel/ComplaintTotColumn";
import { ResidentFriendlyFactTotColumn } from "@/components/util/column/total/sewerageServiceLevel/ResidentFriendlyFactTotColumn";
const SewerageServiceLevelInpTabList = [
ComplaintInpColumn,
ResidentFriendlyFactTotColumn,
];
const SewerageServiceLevelTot = () => {
const { setMessage: alert } = useAlertStore((state) => state);
const [rowDataMap, setRowDataMap] = useState<Map<string, Record<string, string | number>[]>>(new Map());
const [isLoading, setIsLoading] = useState<boolean>(false);
const [activeTab, setActiveTab] = useState(ComplaintInpColumn.tabKey);
const [lastSearchValues, setLastSearchValues] = useState<SearchFormValues | null>(null);
const columnInfo = useMemo(
() =>
SewerageServiceLevelInpTabList.map((tab: ColumnInfoList) => ({
...tab,
columns: (tab.columns ?? []).map((col: ColumnConfig) => ({
...col,
editable: false as const,
styleName: col.styleName ?? ("rg-cell-readonly" as const),
})),
})),
[],
);
const handleReset = () => {
setRowDataMap(new Map());
setActiveTab(ComplaintInpColumn.tabKey);
setLastSearchValues(null);
};
const fetchByTab = async (tabKey: string, values: SearchFormValues) => {
try {
setIsLoading(true);
const param = {
ctpvNm: values.sido === "ALL" ? "" : values.sido,
sggNm: values.gugun === "ALL" ? "" : values.gugun,
sewerBzmnCd: values.bsnmCd,
statsYr: values.year,
rowDataYn: values.isDetail,
};
let response;
if (tabKey === ResidentFriendlyFactTotColumn.tabKey) {
response =
await statisticsTotalStatusApi.getSewerageServiceLevelResidentFriendlyFact(param);
} else {
response =
await statisticsTotalStatusApi.getSewerageServiceLevelComplaint(param);
}
if (response.status === "success" && response.data) {
setRowDataMap((prev) => {
const next = new Map(prev);
next.set(tabKey, toGridRows(response.data as object[]));
return next;
});
} else {
alert(response.message || "데이터 조회 중 오류가 발생했습니다.");
}
} catch {
alert("데이터 조회 중 오류가 발생했습니다.");
} finally {
setIsLoading(false);
}
};
const handleSearch = async (values: SearchFormValues) => {
setLastSearchValues(values);
setRowDataMap(new Map());
await fetchByTab(activeTab, values);
};
useEffect(() => {
if (lastSearchValues == null) return;
if (rowDataMap.has(activeTab)) return;
void fetchByTab(activeTab, lastSearchValues);
}, [activeTab]);
return (
<>
<SearchForm />
</>
<div className="relative">
<SearchForm isDetail={true} onSubmit={handleSearch} onReset={handleReset} sidoSelectText="전체" />
{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>
)}
<TabDataGrid
columnInfo={columnInfo}
isExcelImport={false}
rowDataMap={rowDataMap}
setRowDataMap={setRowDataMap}
activeTab={activeTab}
onActiveTabChange={setActiveTab}
cellStyleCallback={({ row }) => {
const rowType = String(row.rowType ?? "");
if (rowType === "DETAIL") return undefined;
if (rowType === "BIZ_SUM") return "rg-cell-rowtype-biz-sum";
return "rg-cell-rowtype-other";
}}
/>
</div>
);
};
File diff suppressed because it is too large Load Diff
+109 -75
View File
@@ -1,6 +1,7 @@
import React from "react";
import { ValueType } from "realgrid";
import type { RealGridReact } from "realgrid-react";
import { type SewerageSupplyRateStatusInfo } from "@/entities/statistics/model/input/statistics-sewage-supply-rate-input-interface";
@@ -18,30 +19,30 @@ export type RealGridColumnInfo = {
mergeRule?: { criteria: "value" | string };
sortable?: boolean;
footer?:
| {
text?: string;
expression?: "sum" | "avg" | "count" | "max" | "min";
numberFormat?: string;
valueCallback?: (
grid: GridView,
column: DataColumn,
footerIndex: number,
columnFooter: ColumnFooter,
value: number | string | undefined,
) => number | string;
}
| Array<{
text?: string;
expression?: "sum" | "avg" | "count" | "max" | "min";
numberFormat?: string;
valueCallback?: (
grid: GridView,
column: DataColumn,
footerIndex: number,
columnFooter: ColumnFooter,
value: number | string | undefined,
) => number | string;
}>;
| {
text?: string;
expression?: "sum" | "avg" | "count" | "max" | "min";
numberFormat?: string;
valueCallback?: (
grid: GridView,
column: DataColumn,
footerIndex: number,
columnFooter: ColumnFooter,
value: number | string | undefined,
) => number | string;
}
| Array<{
text?: string;
expression?: "sum" | "avg" | "count" | "max" | "min";
numberFormat?: string;
valueCallback?: (
grid: GridView,
column: DataColumn,
footerIndex: number,
columnFooter: ColumnFooter,
value: number | string | undefined,
) => number | string;
}>;
};
/** setColumns 메서드가 있는 GridView */
@@ -110,21 +111,21 @@ export type DataFieldValueCallback = (
export type ComputedFieldConfig =
| { fields: string[]; type: "sum" | "avg" }
| {
numeratorFields: string[];
denominatorFields: string[];
type: "pblswrgPopltnSuprtRate";
}
numeratorFields: string[];
denominatorFields: string[];
type: "pblswrgPopltnSuprtRate";
}
| {
numeratorFields: string[];
denominatorFields: string[];
type: "adtrtPopltnSuprtRate";
}
numeratorFields: string[];
denominatorFields: string[];
type: "adtrtPopltnSuprtRate";
}
| {
numeratorFields: string[];
denominatorFields: string[];
thirdFields: string[];
type: "swrgInstlrtRate";
};
numeratorFields: string[];
denominatorFields: string[];
thirdFields: string[];
type: "swrgInstlrtRate";
};
export type ColumnConfig = {
name: string;
@@ -147,31 +148,31 @@ export type ColumnConfig = {
valueType?: ValueType;
dataFormat?: string;
footer?:
| {
text?: string;
expression?: "sum" | "avg" | "count" | "max" | "min";
numberFormat?: string;
valueCallback?: (
grid: GridView,
column: DataColumn,
footerIndex: number,
columnFooter: ColumnFooter,
value: number | string | undefined,
) => number | string;
multiRow?: boolean; // 여러 행 footer 사용 여부
}
| Array<{
text?: string;
expression?: "sum" | "avg" | "count" | "max" | "min";
numberFormat?: string;
valueCallback?: (
grid: GridView,
column: DataColumn,
footerIndex: number,
columnFooter: ColumnFooter,
value: number | string | undefined,
) => number | string;
}>; // RealGrid에서 footer를 배열로 설정할 수 있음
| {
text?: string;
expression?: "sum" | "avg" | "count" | "max" | "min";
numberFormat?: string;
valueCallback?: (
grid: GridView,
column: DataColumn,
footerIndex: number,
columnFooter: ColumnFooter,
value: number | string | undefined,
) => number | string;
multiRow?: boolean; // 여러 행 footer 사용 여부
}
| Array<{
text?: string;
expression?: "sum" | "avg" | "count" | "max" | "min";
numberFormat?: string;
valueCallback?: (
grid: GridView,
column: DataColumn,
footerIndex: number,
columnFooter: ColumnFooter,
value: number | string | undefined,
) => number | string;
}>; // RealGrid에서 footer를 배열로 설정할 수 있음
};
export type FooterUserSpan = {
@@ -183,16 +184,16 @@ export type FooterUserSpan = {
export type ColumnLayoutItem =
| string
| {
name: string;
hideChildHeaders?: boolean;
header: { text: string };
items: (string | ColumnLayoutItem)[];
footerUserSpans?: FooterUserSpan[]; // footer 병합 설정
}
name: string;
hideChildHeaders?: boolean;
header: { text: string };
items: (string | ColumnLayoutItem)[];
footerUserSpans?: FooterUserSpan[]; // footer 병합 설정
}
| {
column: string; // 컬럼 이름 (footerUserSpans 사용 시)
footerUserSpans?: FooterUserSpan[]; // footer 병합 설정
};
column: string; // 컬럼 이름 (footerUserSpans 사용 시)
footerUserSpans?: FooterUserSpan[]; // footer 병합 설정
};
export type ColumnInfo = {
columns: ColumnConfig[];
@@ -202,7 +203,10 @@ export type ColumnInfo = {
export type GridTopButtonConfig = {
visible?: boolean;
text: string;
onClick?: (helpers: { openDialog: (title?: string) => void; closeDialog: () => void }) => void;
onClick?: (helpers: {
openDialog: (title?: string) => void;
closeDialog: () => void;
}) => void;
};
export type ColumnInfoList = {
@@ -225,15 +229,35 @@ export type CellStyleCallbackParams = {
row: Record<string, string | number>;
};
export type CellStyleCallback = (params: CellStyleCallbackParams) => string | undefined;
export type CellStyleCallback = (
params: CellStyleCallbackParams,
) => string | undefined;
/** 셀 hover 시 툴팁 문구 (반환 시 해당 문구 표시, 검증 오류 메시지 등) */
export type CellTooltipCallbackParams = { row: Record<string, string | number>; fieldName: string };
export type CellTooltipCallback = (params: CellTooltipCallbackParams) => string | undefined;
export type CellTooltipCallbackParams = {
row: Record<string, string | number>;
fieldName: string;
};
export type CellTooltipCallback = (
params: CellTooltipCallbackParams,
) => string | undefined;
/** 셀 편집 완료 시 호출되는 콜백 (RealGrid onEditRowChanged) */
export type CellEditedCallbackParams = {
itemIndex: number;
fieldName: string;
oldValue: string | number;
newValue: string | number;
};
export type CellEditedCallback = (
params: CellEditedCallbackParams,
) => void;
export type DataGridProps = {
columnInfo: ColumnInfo;
rowStartNum?: number; // 헤더 행 개수 (1부터 시작, 예: 2면 1행과 2행까지 헤더로 간주)
/** 있으면보내기 파일명: `{메뉴명}_{suffix}.xlsx` (TabDataGrid의 탭 라벨 접미사와 동일) */
excelExportFileSuffix?: string;
isExcelImport?: boolean; // 엑셀 가져오기 유무
isExcelExport?: boolean;
onRowsChange?: (rows: Record<string, string | number>[]) => void;
@@ -247,11 +271,20 @@ export type DataGridProps = {
cellStyleCallback?: CellStyleCallback;
/** 셀 hover 시 툴팁 문구 (예: verificationResult의 rsnExpln) */
cellTooltipCallback?: CellTooltipCallback;
/** 셀 편집 완료 시 호출 (RealGrid onEditRowChanged) */
onCellEdited?: CellEditedCallback;
/** 저장 등 시점에 그리드 현재 데이터를 가져올 수 있도록 할당 (편집 반영된 값) */
getGridDataRef?: {
current: (() => Record<string, string | number>[]) | null;
};
/** 외부에서 gridRef에 직접 접근하기 위한 ref (setRows/addRows/setValue 직접 호출용) */
getGridRef?: React.MutableRefObject<RealGridReact | null>;
onRowClick?: (rowData: Record<string, string | number>) => void; // 행 클릭 이벤트
/** CheckBar 행 체크 변경 */
onItemChecked?: (itemIndex: number, checked: boolean) => void;
/** CheckBar 헤더 전체 선택/해제 */
onItemAllChecked?: (checked: boolean) => void;
onScrollToBottom?: () => void;
statusInfo?: SewerageSupplyRateStatusInfo | null;
/** 강조 및 스크롤할 행의 ID */
highlightedRowId?: string | number | null;
@@ -263,6 +296,7 @@ export type DataGridProps = {
compactFullWidth?: boolean;
/** CompactDataGrid 전용: 그리드 영역 높이 Tailwind 클래스 */
compactGridHeightClass?: string;
gridHeight?: number;
};
export type TabDataGridProps = {
@@ -10,6 +10,7 @@ export type SearchFormValues = {
export type SearchFormProps = {
sido?: boolean;
sidoSelectText?: string;
gugun?: boolean;
year?: boolean;
order?: boolean;
@@ -22,7 +23,9 @@ export type SearchFormProps = {
defaultValues?: SearchFormValues;
};
export const defaultSearchFormProps: Required<Omit<SearchFormProps, 'onSubmit' | 'onReset' | 'onValuesChange' | 'defaultValues'>> = {
export const defaultSearchFormProps: Required<
Omit<SearchFormProps, "onSubmit" | "onReset" | "onValuesChange" | "defaultValues" | "sidoSelectText">
> = {
sido: true,
gugun: true,
year: true,
@@ -2,20 +2,26 @@ import { ValueType } from "realgrid";
export const LogHistoryColumn = {
columns: [
{ name: 'statsLogSn', fieldName: 'statsLogSn', header: { text: '일련번호' }, editable: false, valueType: ValueType.NUMBER, dataFormat: '#,##0' },
{ name: 'ctpvNm', fieldName: 'ctpvNm', header: { text: '시도' }, editable: false },
{ 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: 'tblNm', fieldName: 'tblNm', header: { text: '테이블명' }, visible: false, editable: false },
{ name: 'dmndFwkNm', fieldName: 'dmndFwkNm', header: { text: '요청 기능명' }, editable: false },
{ name: 'statsDataChgComNm', fieldName: 'statsDataChgComNm', header: { text: '요청 기능명' }, editable: false },
{ name: 'rgtrId', fieldName: 'rgtrId', header: { text: '등록자' }, editable: false, visible: false },
{ name: 'regDt', fieldName: 'regDt', header: { text: '등록일' }, editable: false },
],
columnLayout: [
'statsLogSn',
'ctpvNm',
'sggNm',
'statsYr',
'menuNm',
'tblNm',
'dmndFwkNm',
'statsDataChgComNm',
'rgtrId',
'regDt'
'regDt',
'statsLogSn',
]
};
@@ -0,0 +1,77 @@
import { ValueType } from "realgrid";
// 하수도 통계 관리 > 13. 하수도 서비스 수준 > tab 1: 민원
export const ComplaintInpColumn = {
columns: [
{ name: "ctpvNm", fieldName: "ctpvNm", header: { text: "시도" } },
{ name: "sggNm", fieldName: "sggNm", header: { text: "시군구" } },
{ name: "admdstNm", fieldName: "admdstNm", header: { text: "행정구역명" } },
{
name: "swtrmtPpltnCnt",
fieldName: "swtrmtPpltnCnt",
header: { text: "하수처리장민원건수(건)" },
valueType: ValueType.NUMBER,
dataFormat: "#,##0",
},
{
name: "whol",
fieldName: "whol",
header: { text: "합계" },
valueType: ValueType.NUMBER,
dataFormat: "#,##0",
},
{ name: "odorCnt", fieldName: "odorCnt", header: { text: "악취" }, valueType: ValueType.NUMBER, dataFormat: "#,##0" },
{ name: "swagePplCnt", fieldName: "swagePplCnt", header: { text: "하수관거" }, valueType: ValueType.NUMBER, dataFormat: "#,##0" },
{ name: "dschgWqCnt", fieldName: "dschgWqCnt", header: { text: "방류수수질" }, valueType: ValueType.NUMBER, dataFormat: "#,##0" },
{ name: "ldsCnt", fieldName: "ldsCnt", header: { text: "퇴적토" }, valueType: ValueType.NUMBER, dataFormat: "#,##0" },
{ name: "plltnAcdntCnt", fieldName: "plltnAcdntCnt", header: { text: "오염사고" }, valueType: ValueType.NUMBER, dataFormat: "#,##0" },
{ name: "sewerCrgAmt", fieldName: "sewerCrgAmt", header: { text: "하수도요금" }, valueType: ValueType.NUMBER, dataFormat: "#,##0" },
{ name: "etcCnt", fieldName: "etcCnt", header: { text: "기타" }, valueType: ValueType.NUMBER, dataFormat: "#,##0" },
{
name: "crtrPpltnCvlcptEvntcsCnt",
fieldName: "crtrPpltnCvlcptEvntcsCnt",
header: { text: "인구10만명당 민원건수(건)" },
valueType: ValueType.NUMBER,
dataFormat: "#,##0.00",
},
{ name: "rmrk", fieldName: "rmrk", header: { text: "비고" } },
{ name: "statsYr", fieldName: "statsYr", header: { text: "통계연도" }, visible: false },
{ name: "admdstCd", fieldName: "admdstCd", header: { text: "행정코드" }, visible: false },
{ name: "rowType", fieldName: "rowType", header: { text: "행타입" }, visible: false },
].map((col) => ({ ...col, editable: false })),
columnLayout: [
{
name: "groupA",
hideChildHeaders: false,
header: { text: "행정구역" },
items: ["ctpvNm", "sggNm", "admdstNm"],
},
{
name: "groupB",
hideChildHeaders: false,
header: { text: "민원건수(건)" },
items: [
"whol",
"odorCnt",
"swagePplCnt",
"dschgWqCnt",
"ldsCnt",
"plltnAcdntCnt",
"sewerCrgAmt",
"etcCnt",
],
},
"swtrmtPpltnCnt",
"crtrPpltnCvlcptEvntcsCnt",
"rmrk",
"statsYr",
"admdstCd",
"rowType",
],
rowStartNum: 2,
fixedColumns: 1,
tabKey: "ST00130001",
tabLabel: "민원",
};
@@ -0,0 +1,81 @@
import { ValueType } from "realgrid";
// 하수도 통계 관리 > 13. 하수도 서비스 수준 > tab 2: 주민친화시설
export const ResidentFriendlyFactTotColumn = {
columns: [
{ name: "ctpvNm", fieldName: "ctpvNm", header: { text: "시도" } },
{ name: "sggNm", fieldName: "sggNm", header: { text: "시군구" } },
{ name: "admdstNm", fieldName: "admdstNm", header: { text: "행정구역명" } },
{ name: "swtrmtFcltNm", fieldName: "swtrmtFcltNm", header: { text: "처리장명" } },
{
name: "fcltCpct",
fieldName: "fcltCpct",
header: { text: "시설용량(m³/일)" },
valueType: ValueType.NUMBER,
dataFormat: "#,##0",
},
{
name: "tmplntArea",
fieldName: "tmplntArea",
header: { text: "처리장면적(㎡)" },
valueType: ValueType.NUMBER,
dataFormat: "#,##0.00",
},
{
name: "whol",
fieldName: "whol",
header: { text: "총계" },
valueType: ValueType.NUMBER,
dataFormat: "#,##0",
},
{ name: "brkFcltArea", fieldName: "brkFcltArea", header: { text: "휴게시설" }, valueType: ValueType.NUMBER, dataFormat: "#,##0" },
{ name: "childFrshpSpacArea", fieldName: "childFrshpSpacArea", header: { text: "어린이놀이공간" }, valueType: ValueType.NUMBER, dataFormat: "#,##0" },
{ name: "bdySportsFcltArea", fieldName: "bdySportsFcltArea", header: { text: "체력단련시설" }, valueType: ValueType.NUMBER, dataFormat: "#,##0" },
{ name: "sportsFcltArea", fieldName: "sportsFcltArea", header: { text: "운동시설" }, valueType: ValueType.NUMBER, dataFormat: "#,##0" },
{ name: "pkltArea", fieldName: "pkltArea", header: { text: "주차장" }, valueType: ValueType.NUMBER, dataFormat: "#,##0" },
{ name: "eduMultiFcltArea", fieldName: "eduMultiFcltArea", header: { text: "교육 및 문화시설" }, valueType: ValueType.NUMBER, dataFormat: "#,##0" },
{ name: "wtlndMakeArea", fieldName: "wtlndMakeArea", header: { text: "습지조성(생태공원)" }, valueType: ValueType.NUMBER, dataFormat: "#,##0" },
{ name: "etcCnt", fieldName: "etcCnt", header: { text: "기타" }, valueType: ValueType.NUMBER, dataFormat: "#,##0" },
{ name: "rmrk", fieldName: "rmrk", header: { text: "비고" } },
{ name: "statsYr", fieldName: "statsYr", header: { text: "통계연도" }, visible: false },
{ name: "admdstCd", fieldName: "admdstCd", header: { text: "행정코드" }, visible: false },
{ name: "rowType", fieldName: "rowType", header: { text: "행타입" }, visible: false },
].map((col) => ({ ...col, editable: false })),
columnLayout: [
{
name: "groupA",
hideChildHeaders: false,
header: { text: "행정구역" },
items: ["ctpvNm", "sggNm", "admdstNm"],
},
"swtrmtFcltNm",
"fcltCpct",
"tmplntArea",
{
name: "groupB",
hideChildHeaders: false,
header: { text: "주민친화시설 면적(㎡)" },
items: [
"whol",
"brkFcltArea",
"childFrshpSpacArea",
"bdySportsFcltArea",
"sportsFcltArea",
"pkltArea",
"eduMultiFcltArea",
"wtlndMakeArea",
"etcCnt",
],
},
"rmrk",
"statsYr",
"admdstCd",
"rowType",
],
rowStartNum: 2,
fixedColumns: 3,
tabKey: "ST00130002",
tabLabel: "주민친화시설",
};
@@ -1,127 +1,169 @@
import { stClient } from "@/shared/api/client";
import { ENDPOINT } from "@/shared/config";
import type {
import type {
CheckVerificationNameRequest,
CheckVerificationNameResponse,
CopyVerifyDataRequest,
DeleteVrfcMasterResponse,
GeneralverificationDetailResponse,
GeneralverificationInfoResponse,
GeneralverificationRequest,
GeneralverificationResponse,
GeneralverificationService,
GeneralverificationInfoResponse,
CheckVerificationNameRequest,
CheckVerificationNameResponse,
UpdateVrfcMasterRequest,
UpdateVrfcMasterResponse,
RegistVrfcMasterRequest,
RegistVrfcMasterResponse,
DeleteVrfcMasterResponse,
VrfcOperatorsResponse,
GeneralverificationDetailResponse,
UpdateVrfcMasterRequest,
UpdateVrfcMasterResponse,
VerifyColumnListRequest,
VerifyColumnListResponse,
VerifyDetailRegistRequest,
VerifyDetailUpdateRequest,
CopyVerifyDataRequest,
VerifyResultListRequest,
VerifyResultListResponse,
VrfcOperatorsResponse,
} from "../model/general-verification-interface";
export class GeneralverificationApiService implements GeneralverificationService {
public async getGeneralverificationList(param: GeneralverificationRequest): Promise<GeneralverificationResponse> {
const response = await stClient.get<GeneralverificationResponse> (
public async getGeneralverificationList(
param: GeneralverificationRequest,
): Promise<GeneralverificationResponse> {
const response = await stClient.get<GeneralverificationResponse>(
`${ENDPOINT.ST_SERVICE.INPUTVERIFY}/masters`,
{ params: param },
);
return response.data;
}
public async getGeneralverificationInfo(param: number): Promise<GeneralverificationInfoResponse> {
const response = await stClient.get<GeneralverificationInfoResponse> (
public async getGeneralverificationInfo(
param: number,
): Promise<GeneralverificationInfoResponse> {
const response = await stClient.get<GeneralverificationInfoResponse>(
`${ENDPOINT.ST_SERVICE.INPUTVERIFY}/masters/${param}`,
);
return response.data;
}
public async checkVerificationName(param: CheckVerificationNameRequest): Promise<CheckVerificationNameResponse> {
const response = await stClient.get<CheckVerificationNameResponse> (
public async checkVerificationName(
param: CheckVerificationNameRequest,
): Promise<CheckVerificationNameResponse> {
const response = await stClient.get<CheckVerificationNameResponse>(
`${ENDPOINT.ST_SERVICE.INPUTVERIFY}/check-name`,
{ params: param },
);
return response.data;
}
public async UpdateVrfcMaster(param: UpdateVrfcMasterRequest): Promise<UpdateVrfcMasterResponse> {
const response = await stClient.put<UpdateVrfcMasterResponse> (
public async UpdateVrfcMaster(
param: UpdateVrfcMasterRequest,
): Promise<UpdateVrfcMasterResponse> {
const response = await stClient.put<UpdateVrfcMasterResponse>(
`${ENDPOINT.ST_SERVICE.INPUTVERIFY}/masters/${param.vrfcSn}`,
param,
);
return response.data;
}
public async RegistVrfcMaster(param: RegistVrfcMasterRequest): Promise<RegistVrfcMasterResponse> {
const response = await stClient.post<RegistVrfcMasterResponse> (
public async RegistVrfcMaster(
param: RegistVrfcMasterRequest,
): Promise<RegistVrfcMasterResponse> {
const response = await stClient.post<RegistVrfcMasterResponse>(
`${ENDPOINT.ST_SERVICE.INPUTVERIFY}/master`,
param,
);
return response.data;
}
public async DeleteVrfcMaster(param: number): Promise<DeleteVrfcMasterResponse> {
const response = await stClient.delete<DeleteVrfcMasterResponse> (
public async DeleteVrfcMaster(
param: number,
): Promise<DeleteVrfcMasterResponse> {
const response = await stClient.delete<DeleteVrfcMasterResponse>(
`${ENDPOINT.ST_SERVICE.INPUTVERIFY}/${param}`,
);
return response.data;
}
public async getVrfcOperators(): Promise<VrfcOperatorsResponse> {
const response = await stClient.get<VrfcOperatorsResponse> (
const response = await stClient.get<VrfcOperatorsResponse>(
`${ENDPOINT.ST_SERVICE.INPUTVERIFY}/operators`,
);
return response.data;
}
public async getVrfcDetail(param: number): Promise<GeneralverificationDetailResponse> {
const response = await stClient.get<GeneralverificationDetailResponse> (
public async getVrfcDetail(
param: number,
): Promise<GeneralverificationDetailResponse> {
const response = await stClient.get<GeneralverificationDetailResponse>(
`${ENDPOINT.ST_SERVICE.INPUTVERIFY}/${param}`,
);
return response.data;
}
public async getVerifyColumnList(param: VerifyColumnListRequest): Promise<VerifyColumnListResponse> {
const response = await stClient.get<VerifyColumnListResponse> (
param.refStatsCd ?
`${ENDPOINT.ST_SERVICE.INPUTVERIFY}/columns/${param.trgtStatsCd}/${param.vrfcDtlSn}/?refStatsCd=${param.refStatsCd}`
:
`${ENDPOINT.ST_SERVICE.INPUTVERIFY}/columns/${param.trgtStatsCd}/${param.vrfcDtlSn}`,
public async getVerifyColumnList(
param: VerifyColumnListRequest,
): Promise<VerifyColumnListResponse> {
const response = await stClient.get<VerifyColumnListResponse>(
param.refStatsCd
? `${ENDPOINT.ST_SERVICE.INPUTVERIFY}/columns/${param.trgtStatsCd}/${param.vrfcDtlSn}/?refStatsCd=${param.refStatsCd}`
: `${ENDPOINT.ST_SERVICE.INPUTVERIFY}/columns/${param.trgtStatsCd}/${param.vrfcDtlSn}`,
);
return response.data;
}
public async getSelectedVerifyColumnList(param: string): Promise<VerifyColumnListResponse> {
const response = await stClient.get<VerifyColumnListResponse> (
public async getSelectedVerifyColumnList(
param: string,
): Promise<VerifyColumnListResponse> {
const response = await stClient.get<VerifyColumnListResponse>(
`${ENDPOINT.ST_SERVICE.INPUTVERIFY}/columns/${param}`,
);
return response.data;
}
public async RegistVrfcDetail(param: VerifyDetailRegistRequest): Promise<RegistVrfcMasterResponse> {
const response = await stClient.post<RegistVrfcMasterResponse> (
public async RegistVrfcDetail(
param: VerifyDetailRegistRequest,
): Promise<RegistVrfcMasterResponse> {
const response = await stClient.post<RegistVrfcMasterResponse>(
`${ENDPOINT.ST_SERVICE.INPUTVERIFY}/${param.vrfcSn}/details`,
param
param,
);
return response.data;
}
public async UpdateVrfcDetail(param: VerifyDetailUpdateRequest): Promise<RegistVrfcMasterResponse> {
const response = await stClient.put<RegistVrfcMasterResponse> (
public async UpdateVrfcDetail(
param: VerifyDetailUpdateRequest,
): Promise<RegistVrfcMasterResponse> {
const response = await stClient.put<RegistVrfcMasterResponse>(
`${ENDPOINT.ST_SERVICE.INPUTVERIFY}/${param.vrfcSn}/details`,
param
param,
);
return response.data;
}
public async CopyVrfcData(param: CopyVerifyDataRequest): Promise<RegistVrfcMasterResponse> {
const response = await stClient.post<RegistVrfcMasterResponse> (
public async CopyVrfcData(
param: CopyVerifyDataRequest,
): Promise<RegistVrfcMasterResponse> {
const response = await stClient.post<RegistVrfcMasterResponse>(
`${ENDPOINT.ST_SERVICE.INPUTVERIFY}/${param.vrfcSn}/copy?targetStatsCd=${param.targetStatsCd}&rgtrId=${param.rgtrId}`,
);
return response.data;
}
public async getVerifyResultList(
param: VerifyResultListRequest,
): Promise<VerifyResultListResponse> {
const response = await stClient.get<VerifyResultListResponse>(
`${ENDPOINT.ST_SERVICE.INPUTVERIFY}/results`,
{
params: {
...param.searchDto,
page: param.page,
size: param.size,
},
},
);
return response.data;
}
}
export const generalverificationApiService = new GeneralverificationApiService();
export const generalverificationApiService =
new GeneralverificationApiService();
@@ -7,7 +7,8 @@ import {
type SewerageSupplyRateApiResponse,
type CheckInputPeriodResponse,
type SewerageSupplyRateSaveRequest,
type SewerageSupplyRateSaveApiResponse,
type SewerageSupplyRateSubmitResponse,
type SewerageSupplyRateSaveResponse,
} from "../../model/input/statistics-sewage-supply-rate-input-interface";
export class StatisticsInputSewSupplyRateApiService implements StatisticsInputSewSupplyRateService {
@@ -26,16 +27,16 @@ export class StatisticsInputSewSupplyRateApiService implements StatisticsInputSe
return response.data;
}
public async saveSewerageSupplyRate(params: SewerageSupplyRateSaveRequest): Promise<SewerageSupplyRateSaveApiResponse> {
const response = await stClient.post<SewerageSupplyRateSaveApiResponse>(
public async saveSewerageSupplyRate(params: SewerageSupplyRateSaveRequest): Promise<SewerageSupplyRateSaveResponse> {
const response = await stClient.post<SewerageSupplyRateSaveResponse>(
`${ENDPOINT.ST_SERVICE.INPUT}/sewerage-supply-rate/save`,
params,
);
return response.data;
}
public async submitSewerageSupplyRate(params: SewerageSupplyRateSaveRequest): Promise<SewerageSupplyRateSaveApiResponse> {
const response = await stClient.post<SewerageSupplyRateSaveApiResponse>(
public async submitSewerageSupplyRate(params: SewerageSupplyRateSaveRequest): Promise<SewerageSupplyRateSubmitResponse> {
const response = await stClient.post<SewerageSupplyRateSubmitResponse>(
`${ENDPOINT.ST_SERVICE.INPUT}/sewerage-supply-rate/submit`,
params,
);
@@ -1,14 +1,16 @@
import { ENDPOINT } from "@/shared/config";
import { stClient } from "@/shared/api/client";
import { ENDPOINT } from "@/shared/config";
import type {
StatisticsInputStatusService,
StatsInputStatusListRequestParams,
StatsInputStatusListApiResponse,
StatsInputStatusListRequestParams,
} from "../../model/input/statistics-input-status-interface";
export class StatisticsInputStatusApiService implements StatisticsInputStatusService {
public async getStatisticsInputStatusList(param: StatsInputStatusListRequestParams): Promise<StatsInputStatusListApiResponse> {
public async getStatisticsInputStatusList(
param: StatsInputStatusListRequestParams,
): Promise<StatsInputStatusListApiResponse> {
const response = await stClient.get<StatsInputStatusListApiResponse>(
`${ENDPOINT.ST_SERVICE.INPUT}/stats-input-status`,
{ params: param },
@@ -17,4 +19,4 @@ export class StatisticsInputStatusApiService implements StatisticsInputStatusSer
}
}
export const StatisticsInputStatusApi = new StatisticsInputStatusApiService();
export const StatisticsInputStatusApi = new StatisticsInputStatusApiService();
@@ -11,7 +11,7 @@ import type {
SeweragePipeInputPipeDiameterResponse,
SeweragePipeInputPipeTypeResponse,
SeweragePipeInputRegionalFactStatusSaveRequest,
SeweragePipeInputRegionalFactStatusSaveApiResponse,
SeweragePipeInputRegionalFactStatusSaveResponse,
SeweragePipeInputRegionalResponse,
SeweragePipeRequestParams,
StatisticsInputSewPipeService,
@@ -82,8 +82,8 @@ export class StatisticsInputSewPipeApiService implements StatisticsInputSewPipeS
return response.data;
}
public async saveRegionalFactStatus(param: SeweragePipeInputRegionalFactStatusSaveRequest): Promise<SeweragePipeInputRegionalFactStatusSaveApiResponse> {
const response = await stClient.post<SeweragePipeInputRegionalFactStatusSaveApiResponse>(
public async saveRegionalFactStatus(param: SeweragePipeInputRegionalFactStatusSaveRequest): Promise<SeweragePipeInputRegionalFactStatusSaveResponse> {
const response = await stClient.post<SeweragePipeInputRegionalFactStatusSaveResponse>(
`${ENDPOINT.ST_SERVICE.INPUT}/sewage-pipe/regional-facility-status/save`,
param,
);
+100 -1
View File
@@ -1,10 +1,24 @@
import { useQuery } from "@tanstack/react-query";
import { useInfiniteQuery, useQuery } from "@tanstack/react-query";
import { ENDPOINT } from "@/shared/config";
import { generalverificationApiService } from "@/entities/statistics/api/general-verification-service";
import { StatisticsInputStatusApi } from "@/entities/statistics/api/input/statistics-input-status-service";
import type { VerifyResultPageData } from "@/entities/statistics/model/general-verification-interface";
import type {
StatisticsVerificationFilterState,
StatisticsVerificationSubFilterState,
} from "@/entities/statistics/model/statistics-verification-report/statistics-verification-report-types";
import { stAnlsMstApi } from "./statistics-analysis-master-api";
import { stAnlsRsltApi } from "./statistics-analysis-result-api";
const DETAIL_PAGE_SIZE = 100;
interface StatisticsVerificationDetailSubmittedParams {
main: StatisticsVerificationFilterState;
sub: StatisticsVerificationSubFilterState;
}
export const useStAnlsSidoCombo = () => {
return useQuery({
queryKey: [ENDPOINT.STATVERIFY_SERVICE.ST_ANLS_MST, "sido"],
@@ -58,3 +72,88 @@ export const useStAnlsChartData = (
enabled: !!anlsSn && !!vrfcTrgtSn,
});
};
export const useStatsVrfcRptData = (
submittedMainFilters: StatisticsVerificationFilterState | null,
) => {
return useQuery({
queryKey: [
ENDPOINT.ST_SERVICE.INPUT,
"statistics-input-status",
submittedMainFilters?.statsYr ?? "",
submittedMainFilters?.ctpvNm ?? "",
submittedMainFilters?.sggNm ?? "",
submittedMainFilters?.sewerBzmnCd ?? "",
submittedMainFilters?.statsCd ?? "",
],
queryFn: async () => {
if (!submittedMainFilters) return [];
const response =
await StatisticsInputStatusApi.getStatisticsInputStatusList(
submittedMainFilters,
);
if (response.status !== "success")
throw new Error(response.message);
return response.data ?? [];
},
enabled: Boolean(submittedMainFilters),
refetchOnWindowFocus: false,
});
};
export const useStatsVrfcRsltData = (
submittedDetailParams: StatisticsVerificationDetailSubmittedParams | null,
) => {
return useInfiniteQuery({
queryKey: [
ENDPOINT.ST_SERVICE.INPUTVERIFY,
"verify-results",
submittedDetailParams?.main.statsYr ?? "",
submittedDetailParams?.main.ctpvNm ?? "",
submittedDetailParams?.main.sggNm ?? "",
submittedDetailParams?.main.sewerBzmnCd ?? "",
submittedDetailParams?.main.statsCd ?? "",
submittedDetailParams?.sub.prcsYnList ?? "",
submittedDetailParams?.sub.pbcpAprvYn ?? "",
],
initialPageParam: 1,
queryFn: async ({ pageParam }) => {
if (!submittedDetailParams)
return {
verifyResultList: [],
pageInfo: {
currentPage: 0,
size: DETAIL_PAGE_SIZE,
totalElements: 0,
totalPages: 0,
},
} satisfies VerifyResultPageData;
const response =
await generalverificationApiService.getVerifyResultList({
page: pageParam,
size: DETAIL_PAGE_SIZE,
searchDto: {
statsYr: submittedDetailParams.main.statsYr,
ctpvNm: submittedDetailParams.main.ctpvNm,
sggNm: submittedDetailParams.main.sggNm,
sewerBzmnCd: submittedDetailParams.main.sewerBzmnCd,
statsCd: submittedDetailParams.main.statsCd,
prcsYnList: submittedDetailParams.sub.prcsYnList,
pbcpAprvYn: submittedDetailParams.sub.pbcpAprvYn,
},
});
if (response.status !== "success")
throw new Error(response.message);
return response.data;
},
getNextPageParam: (lastPage) => {
const { currentPage, totalPages } = lastPage.pageInfo;
if (currentPage + 1 <= totalPages) return currentPage + 1;
return undefined;
},
enabled: Boolean(submittedDetailParams),
refetchOnWindowFocus: false,
});
};
@@ -0,0 +1,33 @@
import { stClient } from "@/shared/api/client";
import { ENDPOINT } from "@/shared/config";
import type {
ApprovalResponse,
ApproveRequest,
RejectRequest,
StatisticsApprovalService,
} from "../model/statistics-approval-interface";
class StatisticsApprovalApiService implements StatisticsApprovalService {
public async approveStats(
params: ApproveRequest[],
): Promise<ApprovalResponse> {
const response = await stClient.post<ApprovalResponse>(
`${ENDPOINT.ST_SERVICE.APPROVAL}/approve?type=VRFC`,
params,
);
return response.data;
}
public async rejectStats(
params: RejectRequest[],
): Promise<ApprovalResponse> {
const response = await stClient.post<ApprovalResponse>(
`${ENDPOINT.ST_SERVICE.APPROVAL}/reject?type=VRFC`,
params,
);
return response.data;
}
}
export const statisticsApprovalApi = new StatisticsApprovalApiService();
@@ -2,8 +2,10 @@ import { ENDPOINT } from "@/shared/config";
import { stClient } from "@/shared/api/client";
import {
type StatsInputStatusReviewApiResponse
import type {
StatsInputStatusReviewApiResponse,
ReviewHistoryRequest,
ReviewHistoryResponse,
} from "../model/statistics-input-review-interface";
import {
type SewerageSupplyRateReviewRequestParams,
@@ -190,6 +192,14 @@ export class StatisticsManagementApiService {
);
return response.data;
}
public async getReviewHistory(params: ReviewHistoryRequest): Promise<ReviewHistoryResponse> {
const response = await stClient.get<ReviewHistoryResponse>(
`${ENDPOINT.ST_SERVICE.REVIEW}/history`,
{ params }
);
return response.data;
}
}
// 통합 인스턴스 수출
@@ -1,72 +1,106 @@
import { client, stClient } from "@/shared/api/client";
import { ENDPOINT } from "@/shared/config";
import { stClient, client } from "@/shared/api/client";
import {
type StatisticsService,
type SearchCondition,
type ApiResponse,
type Sgg,
type cancelSubmitRequest,
type cancelSubmitResponse,
type getReviewOpinionDetailRequest,
type getReviewOpinionDetailResponse,
type ImrRequest,
type SubmitExceptionRequest,
type SubmitExceptionResponse,
type ReviewOpinionSaveRequest,
type ReviewOpinionSaveResponse,
type ReviewReturnSaveRequest,
type ReviewReturnSaveResponse,
type ReviewSubmitRequest,
type ReviewSubmitResponse,
type VerificationResultListRequest,
type VerificationResultListResponse,
type SearchCondition,
type Sgg,
type StatisticsLogDetailResponse,
type StatisticsLogRequest,
type StatisticsLogResponse,
type StatisticsLogDetailResponse,
type StatisticsService,
type SubmitExceptionRequest,
type SubmitExceptionResponse,
type ImrResponse,
type ImrStatusResponse,
} from "../model/statistics-interface";
export class StatisticsAPiService implements StatisticsService {
public async getSidoList(): Promise<ApiResponse<SearchCondition>> {
export class StatisticsApiService implements StatisticsService {
public async getSidoList(params?: {
instSeCd?: string;
}): Promise<ApiResponse<SearchCondition>> {
const response = await stClient.get<ApiResponse<SearchCondition>>(
`${ENDPOINT.ST_SERVICE.COMMON}/search-condition`,
{ params },
);
return response.data;
}
public async getSggList(sidoName: string): Promise<ApiResponse<Sgg[]>> {
public async getSggList(
sidoName: string,
params?: { instSeCd?: string; gugunSkipYn?: "Y" | "N" },
): Promise<ApiResponse<Sgg[]>> {
const response = await stClient.get<ApiResponse<Sgg[]>>(
`${ENDPOINT.ST_SERVICE.COMMON}/sgg`,
{ params: { sidoName } },
{ params: { sidoName, ...params } },
);
return response.data;
}
public async getSggAllList(sidoName: string): Promise<ApiResponse<Sgg[]>> {
public async getSggAllList(
sidoName: string,
params?: { instSeCd?: string; gugunSkipYn?: "Y" | "N" },
): Promise<ApiResponse<Sgg[]>> {
const response = await stClient.get<ApiResponse<Sgg[]>>(
`${ENDPOINT.ST_SERVICE.COMMON}/sgg-all`,
{ params: { sidoName } },
{ params: { sidoName, ...params } },
);
return response.data;
}
// 입력 - 제출 취소
public async cancelSubmit(param: cancelSubmitRequest): Promise<cancelSubmitResponse> {
public async cancelSubmit(
param: cancelSubmitRequest,
): Promise<cancelSubmitResponse> {
const response = await stClient.put<cancelSubmitResponse>(
`${ENDPOINT.ST_SERVICE.INPUTCOMMON}/cancel-submit`,
param
param,
);
return response.data;
}
public async callImrService(param: ImrRequest): Promise<null> {
const response = await client.post<null>(
public async callImrService(param: ImrRequest): Promise<ImrResponse> {
const response = await client.post<ImrResponse>(
`${ENDPOINT.STATVERIFY_SERVICE.IMR}`,
param
param,
);
return response.data;
}
public async getImrStatus(param: string): Promise<ImrStatusResponse> {
const response = await client.get<ImrStatusResponse>(
`${ENDPOINT.STATVERIFY_SERVICE.IMR}/status/${param}`,
);
return response.data;
}
// 예외 제출
public async submitException(param: SubmitExceptionRequest): Promise<SubmitExceptionResponse> {
public async submitException(
param: SubmitExceptionRequest,
): Promise<SubmitExceptionResponse> {
const response = await stClient.post<SubmitExceptionResponse>(
`${ENDPOINT.ST_SERVICE.INPUTCOMMON}/submit-exception`,
param
param,
);
return response.data;
}
public async getLogList(param: StatisticsLogRequest): Promise<StatisticsLogResponse> {
public async getLogList(
param: StatisticsLogRequest,
): Promise<StatisticsLogResponse> {
const response = await stClient.get<StatisticsLogResponse>(
`${ENDPOINT.ST_SERVICE.INPUTCOMMON}/logs`,
{ params: param },
@@ -74,12 +108,62 @@ export class StatisticsAPiService implements StatisticsService {
return response.data;
}
public async getLogDetail(param: string): Promise<StatisticsLogDetailResponse> {
public async getLogDetail(
param: string,
): Promise<StatisticsLogDetailResponse> {
const response = await stClient.get<StatisticsLogDetailResponse>(
`${ENDPOINT.ST_SERVICE.INPUTCOMMON}/logs/${param}`,
);
return response.data;
}
public async saveReviewOpinion(
param: ReviewOpinionSaveRequest,
): Promise<ReviewOpinionSaveResponse> {
const response = await stClient.post<ReviewOpinionSaveResponse>(
`${ENDPOINT.ST_SERVICE.REVIEW}/opinion-save`,
param,
);
return response.data;
}
public async getReviewOpinionDetail(
param: getReviewOpinionDetailRequest,
): Promise<getReviewOpinionDetailResponse> {
const response = await stClient.get<getReviewOpinionDetailResponse>(
`${ENDPOINT.ST_SERVICE.REVIEW}/opinion-select`,
{ params: param },
);
return response.data;
}
public async saveReviewReturn(
param: ReviewReturnSaveRequest,
): Promise<ReviewReturnSaveResponse> {
const response = await stClient.post<ReviewReturnSaveResponse>(
`${ENDPOINT.ST_SERVICE.REVIEW}/return`,
param,
);
return response.data;
}
public async submitReview(
param: ReviewSubmitRequest,
): Promise<ReviewSubmitResponse> {
const response = await stClient.post<ReviewSubmitResponse>(
`${ENDPOINT.ST_SERVICE.REVIEW}/submit`,
param,
);
return response.data;
}
public async getVerificationResultList(param: VerificationResultListRequest): Promise<VerificationResultListResponse> {
const response = await stClient.get<VerificationResultListResponse>(
`${ENDPOINT.ST_SERVICE.INPUTVERIFY}/results`,
{ params: param },
);
return response.data;
}
}
export const statisticsAPi = new StatisticsAPiService();
export const statisticsApi = new StatisticsApiService();
@@ -3,6 +3,11 @@ import { stClient } from "@/shared/api/client";
import type { SewerageSupplyRateRequest, SewerageSupplyRateResponse } from "../model/statistics-total/statistics-sewage-supply-rate-total-interface";
import type { SewagePipeTotalRequest, SewagePipeTotalResponse } from "../model/statistics-total/statistics-sewage-pipe-total-interface";
import type {
SewerageServiceLevelComplaintResponse,
SewerageServiceLevelRequest,
SewerageServiceLevelResidentFriendlyFactResponse
} from "../model/statistics-total/statistics-service-level-total-interface";
export class StatisticsTotalApiService {
public async getSewerageSupplyRateList(params: SewerageSupplyRateRequest): Promise<SewerageSupplyRateResponse> {
@@ -20,6 +25,22 @@ export class StatisticsTotalApiService {
);
return response.data;
}
public async getSewerageServiceLevelComplaint(params: SewerageServiceLevelRequest): Promise<SewerageServiceLevelComplaintResponse> {
const response = await stClient.get<SewerageServiceLevelComplaintResponse> (
`${ENDPOINT.ST_SERVICE.TOTAL}/sewage-complaint`,
{ params: params }
);
return response.data;
}
public async getSewerageServiceLevelResidentFriendlyFact(params: SewerageServiceLevelRequest): Promise<SewerageServiceLevelResidentFriendlyFactResponse> {
const response = await stClient.get<SewerageServiceLevelResidentFriendlyFactResponse> (
`${ENDPOINT.ST_SERVICE.TOTAL}/resident-friendly-facility`,
{ params: params }
);
return response.data;
}
}
export const statisticsTotalStatusApi = new StatisticsTotalApiService();
+22
View File
@@ -1,3 +1,10 @@
export {
useStAnlsSidoCombo,
useStAnlsSigunguCombo,
useStatsVrfcRptData,
useStatsVrfcRsltData,
} from "./api/queries";
export { statisticsApprovalApi } from "./api/statistics-approval-service";
export {
type AnalysisHistoryListApiResponse,
type AnalysisRequestPayload,
@@ -7,6 +14,7 @@ export {
type JobStatusDto,
type OutlierRecordDto,
} from "./model/analysis/analysis-interface";
export { type VerifyResultItem } from "./model/general-verification-interface";
export {
IMR_DETAIL_VISIBLE_COLUMNS_BY_STATS_CD,
IMR_DETAIL_VISIBLE_COLUMNS_DEFAULT,
@@ -25,6 +33,7 @@ export {
type ImrLogResponse,
} from "./model/imr/imr-interface";
export { imrVerificationHistoryColumns } from "./model/imr/imr-verification-history-columns";
export { type StatsInputStatusList } from "./model/input/statistics-input-status-interface";
export {
type StAnlsDtlListApiResponse,
type StAnlsDtlResponse,
@@ -54,6 +63,19 @@ export {
} from "./model/statistics-analysis/statistics-analysis-result-interface";
export { verificationHistoryColumns } from "./model/statistics-analysis/verification-history-columns";
export { getVerificationListColumns } from "./model/statistics-analysis/verification-list-columns";
export {
type ApprovalResponse,
type ApproveRequest,
type RejectRequest,
} from "./model/statistics-approval-interface";
export {
statisticsVerificationReportColumnInfo,
statisticsVerificationResultColumnInfo,
} from "./model/statistics-verification-report/statistics-verification-report-columns";
export {
type StatisticsVerificationFilterState,
type StatisticsVerificationSubFilterState,
} from "./model/statistics-verification-report/statistics-verification-report-types";
export {
hbArticleColumnInfo,
imrArticleColumnInfo,
@@ -86,7 +86,7 @@ export interface VrfcOperatorsResponse {
opertSn: string;
opertSymbl: string;
opertKornNm: string;
}[]
}[];
}
/** 검증 상세 - 공통 정보 */
@@ -165,48 +165,133 @@ export interface SelectedVerifyColumnListResponseData {
export interface SelectedVerifyColumnListResponse {
status: string;
message?: string;
data: VerifyColumnListResponseData
data: VerifyColumnListResponseData;
}
export interface VerifyDetailRegistData {
trgtStatsCd: string;
refStatsCd: string | null;
kornArtopnEqtnCn: string,
engArtopnEqtnCn: string,
kornArtopnEqtnCn: string;
engArtopnEqtnCn: string;
cmprOpert: string | null;
eqtnCombSeq: number
eqtnCombSeq: number;
}
export interface VerifyDetailRegistRequest {
vrfcSn: number | null;
detailList: VerifyDetailRegistData[]
detailList: VerifyDetailRegistData[];
rgtrId: string;
}
export interface VerifyDetailUpdateRequest {
vrfcSn: number | null;
detailList: VerifyDetailRegistData[]
mdfrId: string;
detailList: VerifyDetailRegistData[];
mdfrId: string;
}
export interface CopyVerifyDataRequest {
vrfcSn?: number | null;
targetStatsCd : string;
targetStatsCd: string;
rgtrId: string;
}
export interface VerifyResultSearchRequest {
sewerBzmnCd?: string | null;
ctpvNm?: string | null;
sggNm?: string | null;
statsYr: string;
statsCd?: string | null;
vrfcMthdCd?: string;
prcsYnList?: string; // 검증 상태 리스트 (0: 미검증, 1: 수정완료, 2:예외통과, 3: 반려)
pbcpAprvYn?: string; // 공단 승인 여부 (Y/N)
}
export interface VerifyResultListRequest {
searchDto: VerifyResultSearchRequest;
page?: number;
size?: number;
}
export interface VerifyResultItem {
statsYr?: string;
statsCd?: string;
statsNm?: string;
sewerBzmnCd?: string;
ctpvNm?: string;
sggNm?: string;
admdstNm?: string;
vrfcSn?: number;
vrfcMthdCd?: string;
vrfcMthdNm?: string;
vrfcTrgtNm?: string;
regDt?: string;
rsnExpln?: string;
nowVlCn?: string;
compVlCn?: string;
prcsYn?: string;
prcsOpnnExpln?: string;
prcrId?: string;
rjctOpnnCn?: string;
pbcpAprvYn?: string;
mdfrId?: string;
mdfcnDt?: string;
}
export interface VerifyResultPageInfo {
totalElements: number;
totalPages: number;
currentPage: number;
size: number;
}
export interface VerifyResultPageData {
verifyResultList: VerifyResultItem[];
pageInfo: VerifyResultPageInfo;
}
export interface VerifyResultListResponse {
status: string;
message?: string;
data: VerifyResultPageData;
code?: string;
httpStatus?: number;
}
export interface GeneralverificationService {
getGeneralverificationList(param: GeneralverificationRequest): Promise<GeneralverificationResponse>;
getGeneralverificationInfo(param: number): Promise<GeneralverificationInfoResponse>;
checkVerificationName(param: CheckVerificationNameRequest): Promise<CheckVerificationNameResponse>;
UpdateVrfcMaster(param: UpdateVrfcMasterRequest): Promise<UpdateVrfcMasterResponse>;
RegistVrfcMaster(param: RegistVrfcMasterRequest): Promise<RegistVrfcMasterResponse>;
getGeneralverificationList(
param: GeneralverificationRequest,
): Promise<GeneralverificationResponse>;
getGeneralverificationInfo(
param: number,
): Promise<GeneralverificationInfoResponse>;
checkVerificationName(
param: CheckVerificationNameRequest,
): Promise<CheckVerificationNameResponse>;
UpdateVrfcMaster(
param: UpdateVrfcMasterRequest,
): Promise<UpdateVrfcMasterResponse>;
RegistVrfcMaster(
param: RegistVrfcMasterRequest,
): Promise<RegistVrfcMasterResponse>;
DeleteVrfcMaster(param: number): Promise<DeleteVrfcMasterResponse>;
getVrfcOperators(): Promise<VrfcOperatorsResponse>;
getVrfcDetail(param: number): Promise<GeneralverificationDetailResponse>;
getVerifyColumnList(param: VerifyColumnListRequest): Promise<VerifyColumnListResponse>;
getSelectedVerifyColumnList(param: string): Promise<VerifyColumnListResponse>;
RegistVrfcDetail(param: VerifyDetailRegistRequest): Promise<RegistVrfcMasterResponse>;
UpdateVrfcDetail(param: VerifyDetailUpdateRequest): Promise<RegistVrfcMasterResponse>;
CopyVrfcData(param: CopyVerifyDataRequest): Promise<RegistVrfcMasterResponse>;
}
getVerifyColumnList(
param: VerifyColumnListRequest,
): Promise<VerifyColumnListResponse>;
getSelectedVerifyColumnList(
param: string,
): Promise<VerifyColumnListResponse>;
RegistVrfcDetail(
param: VerifyDetailRegistRequest,
): Promise<RegistVrfcMasterResponse>;
UpdateVrfcDetail(
param: VerifyDetailUpdateRequest,
): Promise<RegistVrfcMasterResponse>;
CopyVrfcData(
param: CopyVerifyDataRequest,
): Promise<RegistVrfcMasterResponse>;
getVerifyResultList(
param: VerifyResultListRequest,
): Promise<VerifyResultListResponse>;
}
@@ -117,7 +117,7 @@ export const imrDetailGridColumnInfo: ColumnInfo = {
header: { text: "당해년도" },
width: 100,
valueType: ValueType.NUMBER,
dataFormat: "#,##0",
dataFormat: "#,##0.##",
styleName: "right-column",
},
],
@@ -1,12 +1,11 @@
import type { ApiResponse } from "../statistics-interface";
export interface StatsInputStatusListRequestParams {
ctpvNm?: string;
sggNm?: string;
sewerBzmnCd?: string;
statsYr?: string;
mode?: string;
statsCd?: string;
ctpvNm?: string | null;
sggNm?: string | null;
sewerBzmnCd?: string | null;
statsYr: string;
statsCd?: string | null;
}
export interface StatsInputStatusList {
@@ -1,5 +1,3 @@
import type { SewerageSupplyRateSaveApiResponse } from "./statistics-sewage-supply-rate-input-interface";
export interface SeweragePipeRequestParams {
ctpvNm?: string;
sggNm?: string;
@@ -119,8 +117,11 @@ export interface SeweragePipeInputRegionalFactStatusSaveRequest {
saveList: SeweragePipeInputRegionalFactStatusSaveItem[];
}
/** 지역별 시설현황 저장 API 응답 — 보급률 저장과 동일 래핑 */
export type SeweragePipeInputRegionalFactStatusSaveApiResponse = SewerageSupplyRateSaveApiResponse;
export interface SeweragePipeInputRegionalFactStatusSaveResponse {
data: string;
message?: string;
status: string;
}
/** 시설별 설치 현황 — 목록 항목 */
export interface SeweragePipeInputFactInstallStatusItem {
@@ -519,5 +520,5 @@ export interface StatisticsInputSewPipeService {
getFacilityAdminAreaList(param: SeweragePipeFacilityAdminAreaListRequest): Promise<SeweragePipeFacilityAdminAreaListResponse>;
saveRegionalFactStatus(
param: SeweragePipeInputRegionalFactStatusSaveRequest,
): Promise<SeweragePipeInputRegionalFactStatusSaveApiResponse>;
): Promise<SeweragePipeInputRegionalFactStatusSaveResponse>;
}
@@ -36,6 +36,8 @@ export interface SewerageSupplyRateCommonInfo {
export interface VerificationResultItem {
vrfcCd: string;
rsnExpln: string;
nowVlCn: string;
compVlCn: string;
}
/** 필드명 → 검증 결과 배열. 예: { popltnSm: [{ type, rsn }], wstrPrmTrt: [...] } */
@@ -150,12 +152,20 @@ export interface SewerageSupplyRateSaveRequest {
superAdmin?: string;
}
export interface SewerageSupplyRateSaveData {
httpStatus?: number;
code?: string;
export interface SewerageSupplyRateSaveResponse {
data: string;
message?: string;
status: string;
}
export type SewerageSupplyRateSaveApiResponse = ApiResponse<SewerageSupplyRateSaveData>;
export interface SewerageSupplyRateSubmitResponse {
data: {
finalStatus: string;
nvpassed: boolean;
}
message?: string;
status: string;
}
// 하수도 통계 입력 기간 조회
export interface CheckInputPeriodResponse {
@@ -167,6 +177,6 @@ export interface CheckInputPeriodResponse {
export interface StatisticsInputSewSupplyRateService {
getSewerageSupplyRateList(params: SewerageSupplyRateRequestParams): Promise<SewerageSupplyRateApiResponse>;
checkInputPeriod(params: string): Promise<CheckInputPeriodResponse>;
saveSewerageSupplyRate(params: SewerageSupplyRateSaveRequest): Promise<SewerageSupplyRateSaveApiResponse>;
submitSewerageSupplyRate(params: SewerageSupplyRateSaveRequest): Promise<SewerageSupplyRateSaveApiResponse>;
saveSewerageSupplyRate(params: SewerageSupplyRateSaveRequest): Promise<SewerageSupplyRateSaveResponse>;
submitSewerageSupplyRate(params: SewerageSupplyRateSaveRequest): Promise<SewerageSupplyRateSubmitResponse>;
}
@@ -0,0 +1,31 @@
export interface ApproveRequest {
statsYr: string;
sewerBzmnCd: string;
statsCd: string;
mdfrId: string;
lastCfmtnPicNm?: string;
lastCfmtnPicTelNo?: string;
lastCfmtnPicDeptNm?: string;
lastCfmtnPicJbgdNm?: string;
}
export interface RejectRequest {
vrfcSn: number;
statsYr: string;
sewerBzmnCd: string;
statsCd: string;
mdfrId: string;
rjctOpnnCn?: string;
}
export interface ApprovalResponse {
resultCode: string;
message: string;
updatedStts: string;
exceptionCnt: number;
}
export interface StatisticsApprovalService {
approveStats(params: ApproveRequest[]): Promise<ApprovalResponse>;
rejectStats(params: RejectRequest[]): Promise<ApprovalResponse>;
}
@@ -27,6 +27,21 @@ export type StatsInputStatusReviewApiResponse = ApiResponse<StatsInputStatusRevi
};
export interface ReviewHistoryRequest {
statsCd: string;
statsYr: string;
sewerBzmnCd: string;
}
export interface ReviewHistoryResponse {
status: string;
message?: string;
data: {
logDt: string;
historyContent: string;
}[]
}
export interface StatsInputStatusReviewService {
getStatsInputStatusList(params: {
yr?: string;
@@ -34,4 +49,5 @@ export interface StatsInputStatusReviewService {
sido?: string;
gugun?: string;
}): Promise<StatsInputStatusReviewApiResponse>;
getReviewHistory(param: ReviewHistoryRequest): Promise<ReviewHistoryResponse>;
}
@@ -41,6 +41,91 @@ export interface ImrRequest {
stats_cd: string;
stats_yr: string;
lae_sbmsn_stts_cd: string;
gen_verify_stts_cd?: string;
}
export interface ImrResponse {
anls_sn: number;
job_id: string;
total_otlr_cnt: number;
status_code: string;
error_msg: string;
processing_time_ms: number;
job_prog: number;
queue_position: number;
}
export interface ImrStatusResponse {
job_id: string;
anls_sn: number;
status: string;
job_prog: number;
total_otlr_cnt: number;
total_nml_cnt: number;
total_excl_cnt: number;
processing_time_ms: number;
error_msg: string;
queue_position: string;
}
export interface VerificationResultListRequest {
statsYr: string;
sewerBzmnCd?: string;
ctpvNm?: string;
sggNm?: string;
statsCd?: string;
/** 검증 방법 코드 (예: IMR) */
vrfcMthdCd?: string;
/** 처리여부 코드 목록, 콤마 구분 */
prcsYnList?: string;
pbcpAprvYn?: string;
page?: number;
size?: number;
}
/** 검증 결과 리포트 목록 한 행 (`verifyResultList` 원소) */
export interface VerificationResultReportItem {
statsYr: string;
statsCd: string;
statsNm: string;
sewerBzmnCd: string;
ctpvNm: string;
sggNm: string;
admdstNm: string;
vrfcSn: number;
vrfcMthdCd: string;
vrfcMthdNm: string;
vrfcTrgtNm: string;
regDt: string;
rsnExpln: string;
nowVlCn: string;
compVlCn: string | null;
prcsYn: string;
prcsOpnnExpln: string | null;
prcrId: string | null;
rjctOpnnCn: string | null;
pbcpAprvYn: string;
mdfrId: string | null;
mdfcnDt: string;
}
export interface VerificationResultListPageInfo {
totalElements: number;
totalPages: number;
currentPage: number;
size: number;
}
export interface VerificationResultListData {
verifyResultList: VerificationResultReportItem[];
pageInfo: VerificationResultListPageInfo;
}
/** 검증 결과 리포트 조회 API 응답 */
export interface VerificationResultListResponse {
status: string;
message?: string;
data?: VerificationResultListData;
}
export interface SubmitExceptionRequest {
@@ -71,7 +156,7 @@ export interface StatisticsLog {
menuNm?: string;
tabNm?: string;
tblNm?: string;
dmndFwkNm?: string;
statsDataChgComCd?: string;
rgtrId?: string;
regDt?: string;
}
@@ -80,6 +165,15 @@ export interface StatisticsLogRequest {
page: number;
size: number;
statsCd?: string;
startDate?: string;
endDate?: string;
/** 시도명 */
ctpvNm?: string;
/** 시군구명 */
sggNm?: string;
sewerBzmnCd?: string;
/** 요청 기능(통계 데이터 변경 공통코드) */
statsDataChgComCd?: string;
}
export interface StatisticsLogResponse {
@@ -114,13 +208,79 @@ export interface StatisticsLogDetailResponse {
data?: StatisticsLogDetailRow[];
}
export interface getReviewOpinionDetailRequest {
statsYr: string;
sewerBzmnCd: string;
statsCd: string;
}
export interface ReviewOpinionDetailItem {
statsYr: string;
sewerBzmnCd: string;
statsCd: string;
splmntOpnnCn: string | null;
wrterOpnnCn: string | null;
}
export interface getReviewOpinionDetailResponse {
status: string;
message?: string;
data?: ReviewOpinionDetailItem[];
}
/** 검토의견 저장 API body */
export interface ReviewOpinionSaveRequest {
statsYr: string;
sewerBzmnCd: string;
statsCd: string;
splmntOpnnCn: string;
wrterOpnnCn: string;
mdfrId: string;
}
export interface ReviewOpinionSaveResponse {
status: string;
message?: string;
}
export interface ReviewReturnSaveRequest {
statsYr: string;
sewerBzmnCd: string;
statsCd: string;
rjctOpnnCn: string;
mdfrId: string;
}
export interface ReviewReturnSaveResponse {
status: string;
message?: string;
}
export interface ReviewSubmitRequest {
statsYr: string;
sewerBzmnCd: string;
statsCd: string;
mdfrId: string;
}
export interface ReviewSubmitResponse {
status: string;
message?: string;
}
export interface StatisticsService {
getSidoList(): Promise<ApiResponse<SearchCondition>>;
getSggList(sidoName: string): Promise<ApiResponse<Sgg[]>>;
getSggAllList(sidoName: string): Promise<ApiResponse<Sgg[]>>;
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[]>>;
cancelSubmit(param: cancelSubmitRequest): Promise<cancelSubmitResponse>;
callImrService(param: ImrRequest): Promise<null>;
callImrService(param: ImrRequest): Promise<ImrResponse>;
submitException(param: SubmitExceptionRequest): Promise<SubmitExceptionResponse>;
getLogList(param: StatisticsLogRequest): Promise<StatisticsLogResponse>;
getLogDetail(param: string): Promise<StatisticsLogDetailResponse>;
saveReviewOpinion(param: ReviewOpinionSaveRequest): Promise<ReviewOpinionSaveResponse>;
getReviewOpinionDetail(param: getReviewOpinionDetailRequest): Promise<getReviewOpinionDetailResponse>;
saveReviewReturn(param: ReviewReturnSaveRequest): Promise<ReviewReturnSaveResponse>;
submitReview(param :ReviewSubmitRequest): Promise<ReviewSubmitResponse>;
getVerificationResultList(param: VerificationResultListRequest): Promise<VerificationResultListResponse>;
getImrStatus(param: string): Promise<ImrStatusResponse>;
}
@@ -55,10 +55,11 @@ export interface SewerageSupplyRateReviewRequestParams {
export interface statsStatusInfo {
statsCd?: string;
laeSbmsnSttsCd?: string;
statPicSbmsnSttsCd?: string;
submitStatusCd?: string;
prvnStatusCd?: string;
rjctOpnnCn?: string;
splmntOpnnCn?: string
splmntOpnnCn?: string;
wrterOpnnCn?: string;
}
export interface SewerageSupplyRateReviewResponseData {
@@ -0,0 +1,61 @@
export interface SewerageServiceLevelRequest {
ctpvNm?: string;
sggNm?: string;
statsYr?: string;
sewerBzmnCd?: string;
rowDataYn?: boolean;
}
export interface SewerageServiceLevelComplaintTotalItem {
statsYr: string;
ctpvNm: string;
sggNm: string | null;
admdstCd: string;
admdstNm: string;
rowType: string;
swtrmtPpltnCnt: number;
whol: number;
odorCnt: number;
swagePplCnt: number;
dschgWqCnt: number;
ldsCnt: number;
plltnAcdntCnt: number;
sewerCrgAmt: number;
etcCnt: number;
crtrPpltnCvlcptEvntcsCnt: number;
rmrk: string | null;
}
export interface SewerageServiceLevelComplaintResponse {
status: string;
message?: string;
data: SewerageServiceLevelComplaintTotalItem[];
}
export interface SewerageServiceLevelResidentFriendlyFactTotalItem {
statsYr: string;
ctpvNm: string;
sggNm: string;
admdstCd: string;
admdstNm: string;
rowType: string;
swtrmtFcltNm: string;
fcltCpct: number;
tmplntArea: number;
whol: number;
brkFcltArea: number;
childFrshpSpacArea: number;
bdySportsFcltArea: number;
sportsFcltArea: number;
pkltArea: number;
eduMultiFcltArea: number;
wtlndMakeArea: number;
etcCnt: number;
rmrk: string;
}
export interface SewerageServiceLevelResidentFriendlyFactResponse {
status: string;
message?: string;
data: SewerageServiceLevelResidentFriendlyFactTotalItem[];
}
@@ -2,70 +2,231 @@ import type { ColumnInfo } from "@/components/type/DataGrid.type";
const READ_ONLY_STYLE = "rg-cell-readonly";
export const statisticsVerificationSummaryColumnInfo: ColumnInfo = {
export const statisticsVerificationReportColumnInfo: ColumnInfo = {
columns: [
{ name: "summaryId", fieldName: "summaryId", header: { text: "summaryId" }, visible: false },
{ name: "sido", fieldName: "sido", header: { text: "시도" }, styleName: READ_ONLY_STYLE },
{ name: "sigungu", fieldName: "sigungu", header: { text: "시군구" }, styleName: READ_ONLY_STYLE },
{ name: "reportType", fieldName: "reportType", header: { text: "구분" }, styleName: READ_ONLY_STYLE },
{ name: "progressStatus", fieldName: "progressStatus", header: { text: "진행상태" }, width: 120, styleName: READ_ONLY_STYLE },
{ name: "writerName", fieldName: "writerName", header: { text: "작성자" }, styleName: READ_ONLY_STYLE },
{ name: "writerContact", fieldName: "writerContact", header: { text: "작성자연락처" }, width: 130, styleName: READ_ONLY_STYLE },
{ name: "writerDept", fieldName: "writerDept", header: { text: "부서" }, styleName: READ_ONLY_STYLE },
{ name: "writerPosition", fieldName: "writerPosition", header: { text: "직급" }, styleName: READ_ONLY_STYLE },
{ name: "approverName", fieldName: "approverName", header: { text: "승인자" }, styleName: READ_ONLY_STYLE },
{ name: "approverDept", fieldName: "approverDept", header: { text: "부서" }, styleName: READ_ONLY_STYLE },
{ name: "approverPosition", fieldName: "approverPosition", header: { text: "직급" }, styleName: READ_ONLY_STYLE },
{ name: "returnReason", fieldName: "returnReason", header: { text: "반려사유" }, width: 220, styleName: READ_ONLY_STYLE },
{
name: "ctpvNm",
fieldName: "ctpvNm",
header: { text: "시도" },
styleName: READ_ONLY_STYLE,
},
{
name: "sggNm",
fieldName: "sggNm",
header: { text: "시군구" },
styleName: READ_ONLY_STYLE,
},
{
name: "menuTabName",
fieldName: "menuTabName",
header: { text: "구분" },
width: 150,
styleName: READ_ONLY_STYLE,
},
{
name: "locgStatusNm",
fieldName: "locgStatusNm",
header: { text: "진행상태" },
width: 120,
styleName: READ_ONLY_STYLE,
},
{
name: "laePicNm",
fieldName: "laePicNm",
header: { text: "작성자" },
styleName: READ_ONLY_STYLE,
},
{
name: "laePicTelNo",
fieldName: "laePicTelNo",
header: { text: "작성자연락처" },
width: 130,
styleName: READ_ONLY_STYLE,
},
{
name: "laeKbrdrDeptNm",
fieldName: "laeKbrdrDeptNm",
header: { text: "부서" },
styleName: READ_ONLY_STYLE,
},
{
name: "laeKbrdrJbgdNm",
fieldName: "laeKbrdrJbgdNm",
header: { text: "직급" },
styleName: READ_ONLY_STYLE,
},
{
name: "laeIdfrNm",
fieldName: "laeIdfrNm",
header: { text: "승인자" },
styleName: READ_ONLY_STYLE,
},
{
name: "laeIdfrDeptNm",
fieldName: "laeIdfrDeptNm",
header: { text: "부서" },
styleName: READ_ONLY_STYLE,
},
{
name: "laeIdfrJbgdNm",
fieldName: "laeIdfrJbgdNm",
header: { text: "직급" },
styleName: READ_ONLY_STYLE,
},
{
name: "rjctOpnnCn",
fieldName: "rjctOpnnCn",
header: { text: "반려사유" },
width: 220,
styleName: READ_ONLY_STYLE,
},
{
name: "sewerBzmnCd",
fieldName: "sewerBzmnCd",
header: { text: "sewerBzmnCd" },
visible: false,
},
{
name: "statsCd",
fieldName: "statsCd",
header: { text: "statsCd" },
visible: false,
},
],
columnLayout: [
"summaryId",
"sido",
"sigungu",
"reportType",
"progressStatus",
"writerName",
"writerContact",
"writerDept",
"writerPosition",
"approverName",
"approverDept",
"approverPosition",
"returnReason",
"ctpvNm",
"sggNm",
"menuTabName",
"locgStatusNm",
"laePicNm",
"laePicTelNo",
"laeKbrdrDeptNm",
"laeKbrdrJbgdNm",
"laeIdfrNm",
"laeIdfrDeptNm",
"laeIdfrJbgdNm",
"rjctOpnnCn",
"sewerBzmnCd",
"statsCd",
],
};
export const statisticsVerificationDetailColumnInfo: ColumnInfo = {
export const statisticsVerificationResultColumnInfo: ColumnInfo = {
columns: [
{ name: "detailId", fieldName: "detailId", header: { text: "detailId" }, visible: false },
{ name: "summaryId", fieldName: "summaryId", header: { text: "summaryId" }, visible: false },
{ name: "sido", fieldName: "sido", header: { text: "시도" }, styleName: READ_ONLY_STYLE },
{ name: "sigungu", fieldName: "sigungu", header: { text: "시군구" }, styleName: READ_ONLY_STYLE },
{ name: "indicator", fieldName: "indicator", header: { text: "지표" }, width: 140, styleName: READ_ONLY_STYLE },
{ name: "detailItem", fieldName: "detailItem", header: { text: "세부항목" }, width: 180, styleName: READ_ONLY_STYLE },
{ name: "verifyDate", fieldName: "verifyDate", header: { text: "검증날짜" }, width: 110, styleName: READ_ONLY_STYLE },
{ name: "errorDetail", fieldName: "errorDetail", header: { text: "오류내역" }, width: 200, styleName: READ_ONLY_STYLE },
{ name: "inputValue", fieldName: "inputValue", header: { text: "입력값" }, width: 90, styleName: READ_ONLY_STYLE },
{ name: "compareValue", fieldName: "compareValue", header: { text: "비교값" }, width: 90, styleName: READ_ONLY_STYLE },
{ name: "status", fieldName: "status", header: { text: "상태" }, width: 90, styleName: READ_ONLY_STYLE },
{ name: "exceptionReason", fieldName: "exceptionReason", header: { text: "예외사유" }, width: 170, styleName: READ_ONLY_STYLE },
{ name: "returnOpinion", fieldName: "returnOpinion", header: { text: "반려의견" }, width: 170, styleName: READ_ONLY_STYLE },
{ name: "approvalYn", fieldName: "approvalYn", header: { text: "승인여부" }, width: 90, styleName: READ_ONLY_STYLE },
{
name: "ctpvNm",
fieldName: "ctpvNm",
header: { text: "시도" },
styleName: READ_ONLY_STYLE,
editable: false,
},
{
name: "sggNm",
fieldName: "sggNm",
header: { text: "시군구" },
styleName: READ_ONLY_STYLE,
editable: false,
},
{
name: "statsNm",
fieldName: "statsNm",
header: { text: "지표" },
width: 140,
styleName: READ_ONLY_STYLE,
editable: false,
},
{
name: "vrfcTrgtNm",
fieldName: "vrfcTrgtNm",
header: { text: "세부항목" },
width: 180,
styleName: READ_ONLY_STYLE,
editable: false,
},
{
name: "regDt",
fieldName: "regDt",
header: { text: "검증날짜" },
width: 150,
styleName: READ_ONLY_STYLE,
editable: false,
},
{
name: "rsnExpln",
fieldName: "rsnExpln",
header: { text: "오류내역" },
width: 200,
styleName: READ_ONLY_STYLE,
editable: false,
},
{
name: "nowVlCn",
fieldName: "nowVlCn",
header: { text: "입력값" },
width: 90,
styleName: READ_ONLY_STYLE,
editable: false,
},
{
name: "compVlCn",
fieldName: "compVlCn",
header: { text: "비교값" },
width: 90,
styleName: READ_ONLY_STYLE,
editable: false,
},
{
name: "prcsYn",
fieldName: "prcsYn",
header: { text: "상태" },
width: 90,
styleName: READ_ONLY_STYLE,
editable: false,
},
{
name: "prcsOpnnExpln",
fieldName: "prcsOpnnExpln",
header: { text: "예외사유" },
width: 170,
styleName: READ_ONLY_STYLE,
editable: false,
},
{
name: "rjctOpnnCn",
fieldName: "rjctOpnnCn",
header: { text: "반려의견" },
width: 170,
editable: true,
},
{
name: "pbcpAprvYn",
fieldName: "pbcpAprvYn",
header: { text: "승인여부" },
width: 90,
styleName: READ_ONLY_STYLE,
editable: false,
},
{
name: "vrfcSn",
fieldName: "vrfcSn",
header: { text: "vrfcSn" },
visible: false,
editable: false,
},
],
columnLayout: [
"detailId",
"summaryId",
"sido",
"sigungu",
"indicator",
"detailItem",
"verifyDate",
"errorDetail",
"inputValue",
"compareValue",
"status",
"exceptionReason",
"returnOpinion",
"approvalYn",
"ctpvNm",
"sggNm",
"statsNm",
"vrfcTrgtNm",
"regDt",
"rsnExpln",
"nowVlCn",
"compVlCn",
"prcsYn",
"prcsOpnnExpln",
"rjctOpnnCn",
"pbcpAprvYn",
"vrfcSn",
],
};
@@ -1,288 +0,0 @@
import { fakerKO as faker } from "@faker-js/faker";
import type {
StatisticsVerificationDetailRow,
StatisticsVerificationFilterState,
StatisticsVerificationOption,
StatisticsVerificationSubFilterState,
StatisticsVerificationSummaryRow,
} from "./statistics-verification-report-types";
const NETWORK_DELAY_MS = 300;
export const SIDO_OPTIONS: StatisticsVerificationOption[] = [
{ label: "전체", value: "ALL" },
{ label: "서울특별시", value: "SEOUL" },
{ label: "부산광역시", value: "BUSAN" },
{ label: "대구광역시", value: "DAEGU" },
{ label: "경기도", value: "GYEONGGI" },
];
const SIGUNGU_OPTIONS_BY_SIDO: Record<string, StatisticsVerificationOption[]> =
{
ALL: [{ label: "전체", value: "ALL" }],
SEOUL: [
{ label: "전체", value: "ALL" },
{ label: "중구", value: "SEOUL_JUNGGU" },
{ label: "강남구", value: "SEOUL_GANGNAM" },
],
BUSAN: [
{ label: "전체", value: "ALL" },
{ label: "해운대구", value: "BUSAN_HAEUNDAE" },
{ label: "수영구", value: "BUSAN_SUYEONG" },
],
DAEGU: [
{ label: "전체", value: "ALL" },
{ label: "중구", value: "DAEGU_JUNGGU" },
{ label: "달서구", value: "DAEGU_DALSEO" },
],
GYEONGGI: [
{ label: "전체", value: "ALL" },
{ label: "수원시", value: "GYEONGGI_SUWON" },
{ label: "성남시", value: "GYEONGGI_SEONGNAM" },
],
};
export const STATS_INDICATOR_OPTIONS: StatisticsVerificationOption[] = [
{ label: "통계항목", value: "ALL" },
{ label: "하수도 보급률", value: "SEWERAGE_SUPPLY" },
{ label: "관로 보급 현황", value: "PIPE_STATUS" },
{ label: "분류식 비율", value: "SEPARATED_RATIO" },
];
export const STATUS_OPTIONS: StatisticsVerificationOption[] = [
{ label: "수정완료", value: "DONE" },
{ label: "검토중", value: "REVIEW" },
{ label: "오류", value: "ERROR" },
];
export const APPROVAL_STATUS_OPTIONS: StatisticsVerificationOption[] = [
{ label: "전체", value: "ALL" },
{ label: "승인완료", value: "APPROVED" },
{ label: "반려", value: "REJECTED" },
{ label: "대기", value: "PENDING" },
];
export const DEFAULT_FILTERS: StatisticsVerificationFilterState = {
sido: "ALL",
sigungu: "ALL",
year: "ALL",
statsIndicator: "ALL",
};
export const DEFAULT_SUB_FILTERS: StatisticsVerificationSubFilterState = {
status: "DONE",
approvalStatus: "ALL",
};
export function getYearOptions(): StatisticsVerificationOption[] {
const thisYear = new Date().getFullYear();
return [
{ label: "전체", value: "ALL" },
...Array.from({ length: 5 }, (_, index) => {
const year = String(thisYear - index - 1);
return { label: year, value: year };
}),
];
}
export function getSigunguOptions(
sido: string,
): StatisticsVerificationOption[] {
return SIGUNGU_OPTIONS_BY_SIDO[sido] ?? [];
}
export async function fetchStatisticsVerificationSummaryMock(
filters: StatisticsVerificationFilterState,
): Promise<StatisticsVerificationSummaryRow[]> {
await wait();
faker.seed(20260416);
const rows = Array.from({ length: 12 }, (_, index) => {
const sido = pickSidoName(filters.sido);
const sigungu = pickSigunguName(filters.sido, filters.sigungu);
const progressStatus = faker.helpers.arrayElement([
"승인대기",
"승인완료",
"반려",
]);
return {
summaryId: index + 1,
sido,
sigungu,
reportType: faker.helpers.arrayElement([
"정기보고",
"수정보고",
"보완보고",
]),
progressStatus,
writerName: faker.person.lastName() + faker.person.firstName(),
writerContact: faker.phone.number({ style: "national" }),
writerDept: faker.helpers.arrayElement([
"통계팀",
"하수행정과",
"정책기획과",
]),
writerPosition: faker.helpers.arrayElement([
"주무관",
"팀장",
"과장",
]),
approverName:
progressStatus === "승인대기"
? "-"
: faker.person.lastName() + faker.person.firstName(),
approverDept:
progressStatus === "승인대기"
? "-"
: faker.helpers.arrayElement(["검증운영팀", "하수행정과"]),
approverPosition:
progressStatus === "승인대기"
? "-"
: faker.helpers.arrayElement(["팀장", "과장"]),
returnReason:
progressStatus === "반려"
? faker.helpers.arrayElement([
"오류내역 확인 필요",
"예외사유 보강 필요",
"입력값 검증 재요청",
])
: "-",
};
});
return rows.filter((row) => {
if (filters.sido !== "ALL" && row.sido !== pickSidoName(filters.sido))
return false;
if (filters.sigungu !== "ALL") {
const currentSigungu = pickSigunguName(
filters.sido,
filters.sigungu,
);
if (row.sigungu !== currentSigungu) return false;
}
return true;
});
}
export async function fetchStatisticsVerificationDetailMock(
filters: StatisticsVerificationFilterState,
subFilters: StatisticsVerificationSubFilterState,
selectedSummaryId?: number | null,
): Promise<StatisticsVerificationDetailRow[]> {
await wait();
faker.seed(20260417);
const rows = Array.from({ length: 24 }, (_, index) => {
const approvalYn = faker.helpers.arrayElement(["승인", "미승인"]);
const status = faker.helpers.arrayElement([
"수정완료",
"검토중",
"오류",
]);
const summaryId = faker.number.int({ min: 1, max: 12 });
return {
detailId: index + 1,
summaryId,
sido: pickSidoName(filters.sido),
sigungu: pickSigunguName(filters.sido, filters.sigungu),
indicator: pickIndicator(filters.statsIndicator),
detailItem: faker.helpers.arrayElement([
"처리구역면적",
"처리인구",
"분류식관거연장",
"하수관로보급률",
]),
verifyDate: faker.date
.recent({ days: 20 })
.toISOString()
.slice(0, 10),
errorDetail: faker.helpers.arrayElement([
"전년 대비 편차 초과",
"입력값 누락",
"상호검증 불일치",
]),
inputValue: faker.number.int({ min: 100, max: 5000 }),
compareValue: faker.number.int({ min: 100, max: 5000 }),
status,
exceptionReason:
status === "오류"
? faker.lorem.sentence({ min: 1, max: 2 })
: "-",
returnOpinion:
approvalYn === "미승인"
? faker.helpers.arrayElement([
"산식 재검토 필요",
"근거자료 보강 요청",
"담당자 확인 필요",
])
: "-",
approvalYn,
};
});
return rows.filter((row) => {
if (selectedSummaryId && row.summaryId !== selectedSummaryId)
return false;
if (subFilters.status === "DONE" && row.status !== "수정완료")
return false;
if (subFilters.status === "REVIEW" && row.status !== "검토중")
return false;
if (subFilters.status === "ERROR" && row.status !== "오류")
return false;
if (
subFilters.approvalStatus === "APPROVED" &&
row.approvalYn !== "승인"
)
return false;
if (
subFilters.approvalStatus === "REJECTED" &&
row.approvalYn !== "미승인"
)
return false;
return true;
});
}
function wait() {
return new Promise((resolve) => setTimeout(resolve, NETWORK_DELAY_MS));
}
function pickSidoName(value: string): string {
if (value === "ALL")
return faker.helpers.arrayElement([
"서울특별시",
"부산광역시",
"대구광역시",
"경기도",
]);
const matched = SIDO_OPTIONS.find((option) => option.value === value);
return matched?.label ?? "서울특별시";
}
function pickSigunguName(sido: string, sigungu: string): string {
if (sigungu !== "ALL") {
const options = getSigunguOptions(sido);
const matched = options.find((option) => option.value === sigungu);
if (matched) return matched.label;
}
const options = getSigunguOptions(sido).filter(
(option) => option.value !== "ALL",
);
if (options.length === 0) return "전체";
return faker.helpers.arrayElement(options).label;
}
function pickIndicator(indicator: string): string {
if (indicator === "ALL")
return faker.helpers.arrayElement(
STATS_INDICATOR_OPTIONS.filter((item) => item.value !== "ALL"),
).label;
const matched = STATS_INDICATOR_OPTIONS.find(
(option) => option.value === indicator,
);
return matched?.label ?? "통계항목";
}
@@ -1,14 +1,10 @@
export interface StatisticsVerificationFilterState {
sido: string;
sigungu: string;
year: string;
statsIndicator: string;
}
import type { VerifyResultSearchRequest } from "../general-verification-interface";
import type { StatsInputStatusListRequestParams } from "../input/statistics-input-status-interface";
export interface StatisticsVerificationSubFilterState {
status: string;
approvalStatus: string;
}
export type StatisticsVerificationFilterState =
StatsInputStatusListRequestParams;
export type StatisticsVerificationSubFilterState = VerifyResultSearchRequest;
export interface StatisticsVerificationOption {
label: string;
+2 -1
View File
@@ -72,7 +72,8 @@ export const ENDPOINT = {
REVIEW: "/st-management-service/api/v1/review",
INPUTCOMMON: "/st-input-service/api/v1/common",
INPUTVERIFY: "/st-input-service/api/v1/verify",
TOTAL: "/st-management-service/api/v1/stats"
TOTAL: "/st-management-service/api/v1/stats",
APPROVAL: "/st-management-service/api/stats",
},
STATVERIFY_SERVICE: {
VRFCTGT_MNG: "/statverify-service/api/v1/vrfc-trgt-mng",
+1
View File
@@ -2,6 +2,7 @@ export { getCookie, setCookie, removeCookie } from "./cookie";
export { useMutationWrapper } from "./use-mutation-wrapper";
export { useAlertStore } from "./use-alert-store";
export { useDataTable } from "./use-data-table";
export { useGlobalProgressStore } from "./use-global-progress-store";
export { useMenuStore } from "./use-menu-store";
export { useUserStore } from "./use-user-store";
export { usePrompt } from "./use-prompt";
@@ -0,0 +1,16 @@
import { useContext } from "react";
import { useStore } from "zustand";
import type { GlobalProgressStore } from "../store/global-progress-store";
import { StoreContext } from "../providers/store-provider";
export const useGlobalProgressStore = <T>(
selector: (store: GlobalProgressStore) => T,
): T => {
const storeContext = useContext(StoreContext);
if (!storeContext) {
throw new Error("useGlobalProgressStore must be used within StoreProvider");
}
return useStore(storeContext.globalProgressStore, selector);
};
@@ -4,6 +4,7 @@ import { createContext, useRef } from "react";
import {
createAlertStore,
createAttachmentStore,
createGlobalProgressStore,
createMenuStore,
createUserStore,
} from "../store";
@@ -11,6 +12,7 @@ import {
type StoreContextType = {
alertStore: ReturnType<typeof createAlertStore>;
attachmentStore: ReturnType<typeof createAttachmentStore>;
globalProgressStore: ReturnType<typeof createGlobalProgressStore>;
menuStore: ReturnType<typeof createMenuStore>;
userStore: ReturnType<typeof createUserStore>;
};
@@ -25,6 +27,7 @@ export const StoreProvider = ({ children }: { children: React.ReactNode }) => {
storeRef.current = {
alertStore: createAlertStore(),
attachmentStore: createAttachmentStore(),
globalProgressStore: createGlobalProgressStore(),
menuStore: createMenuStore(),
userStore: createUserStore(),
};
@@ -0,0 +1,45 @@
import { createStore } from "zustand/vanilla";
export type GlobalProgressState = {
visible: boolean;
label: string;
progress: number;
};
export type GlobalProgressActions = {
show: (payload?: { label?: string; progress?: number }) => void;
setLabel: (label: string) => void;
setProgress: (progress: number) => void;
hide: () => void;
};
export type GlobalProgressStore = GlobalProgressState & GlobalProgressActions;
export const defaultGlobalProgressState: GlobalProgressState = {
visible: false,
label: "진행 중...",
progress: 0,
};
export const createGlobalProgressStore = (
initState: GlobalProgressState = defaultGlobalProgressState,
) => {
return createStore<GlobalProgressStore>()((set) => ({
...initState,
show: (payload) =>
set(() => ({
visible: true,
label: payload?.label ?? defaultGlobalProgressState.label,
progress: Math.max(0, Math.min(100, payload?.progress ?? 0)),
})),
setLabel: (label) => set(() => ({ label })),
setProgress: (progress) =>
set(() => ({ progress: Math.max(0, Math.min(100, progress)) })),
hide: () =>
set(() => ({
visible: false,
progress: 0,
label: defaultGlobalProgressState.label,
})),
}));
};
+1
View File
@@ -1,4 +1,5 @@
export { createAlertStore } from "./alert-store";
export { createAttachmentStore } from "./attachment-store";
export { createGlobalProgressStore } from "./global-progress-store";
export { createMenuStore, type BbsType } from "./menu-store";
export { createUserStore, type IUserInfo } from "./user-store";
+2
View File
@@ -23,6 +23,8 @@ export interface IUserInfo {
addr: string;
daddr: string;
laeSeCd: string;
/** 기관 구분 코드 — 통계 공통 조회 등 query `instSeCd`로 전달 */
instSeCd?: string;
ogdpSeCd: string;
aprvYn: string;
autzrId: string;

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