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

This commit is contained in:
2026-04-28 15:07:14 +09:00
213 changed files with 11394 additions and 5844 deletions
+3 -2
View File
@@ -1,7 +1,8 @@
# apps/cs/src/shared/config/env.ts 참고
NEXT_PUBLIC_PORT=3000
NEXT_PUBLIC_PROXY_HOST=http://localhost:3000
SERVER_API_URL=http://192.168.0.102:8000
NEXT_PUBLIC_SITE_ID=CS
NEXT_PUBLIC_SUB_DOMAIN=http://localhost:3000
NEXT_PUBLIC_SERVER_API_URL=http://192.168.0.102:8000
NEXT_PUBLIC_TINY_API_KEY=t9nc4z6inquvvd5pq1vea8wcp7lb01qmqseh6egms9vpaju2
NEXT_PUBLIC_TINY_API_KEY=t9nc4z6inquvvd5pq1vea8wcp7lb01qmqseh6egms9vpaju2
NEXT_PUBLIC_TINY_BASE_URL=
+17 -8
View File
@@ -1,21 +1,25 @@
import { NextRequest, NextResponse } from "next/server";
import {
CLAIM_NAME,
AUTH_USER_ID,
CLAIM_NAME,
PORT,
PROXY_HOST,
SERVER_API_URL,
SITE_ID,
} from "./src/shared/config";
const serverApiUrl =
process.env.NEXT_PUBLIC_SERVER_API_URL ?? "http://localhost:3000";
const serverApiUrl = process.env.SERVER_API_URL ?? "http://localhost:3000";
const linkBackendApiHost = process.env.NEXT_PUBLIC_LINK_BACKEND_API_HOST ?? "http://localhost:8080";
const linkBackendApiHost =
process.env.NEXT_PUBLIC_LINK_BACKEND_API_HOST ?? "http://localhost:8080";
// CSP 헤더 설정 함수 (img-src만)
function addSecurityHeaders(response: NextResponse): NextResponse {
response.headers.set("Content-Security-Policy", "img-src 'self' data: https: blob:;");
response.headers.set(
"Content-Security-Policy",
"img-src 'self' data: https: blob:;",
);
return response;
}
@@ -66,7 +70,7 @@ export function middleware(req: NextRequest) {
const requestHeaders = new Headers(req.headers);
const token = req.cookies.get(CLAIM_NAME)?.value;
const userId = req.cookies.get(AUTH_USER_ID)?.value;
// /api/link 경로를 제거하고 백엔드 API 경로로 변환
const apiPath = pathname.replace("/api/link", "");
const queryParams = searchParams.toString();
@@ -94,11 +98,16 @@ export function middleware(req: NextRequest) {
logMessages.push("\n--------------------------------\n");
// console.log(logMessages.join("\n"));
const response = NextResponse.next();
return addSecurityHeaders(response);
}
export const config = {
matcher: ["/server/:path*", "/attach-service/:path*", "/api/link/:path*", "/"],
matcher: [
"/server/:path*",
"/attach-service/:path*",
"/api/link/:path*",
"/",
],
};
@@ -1,4 +1,4 @@
import { SERVER_API_URL } from "@/shared/config";
import { TINY_BASE_URL } from "@/shared/config";
export const editorBaseConfig = {
// 폐쇄망 환경에서는 API 키 없이 자체 호스팅 모드 사용
@@ -7,7 +7,7 @@ export const editorBaseConfig = {
licenseKey: "gpl",
init: {
language: "ko_KR",
language_url: '/tinymce/langs/ko_KR.js', // 직접 호스팅한 경우 경로 지정
language_url: "/tinymce/langs/ko_KR.js", // 직접 호스팅한 경우 경로 지정
height: 500,
// 폐쇄망 환경을 위한 설정
base_url: "/tinymce",
@@ -38,7 +38,7 @@ export const editorBaseConfig = {
"undo redo | blocks fontfamily fontsize | bold italic underline strikethrough forecolor backcolor | alignleft aligncenter alignright alignjustify | bullist numlist blockquote | table",
"link image",
],
images_upload_base_path: SERVER_API_URL,
images_upload_base_path: TINY_BASE_URL,
file_picker_types: "image",
// 폐쇄망 환경을 위한 추가 설정
content_css: "/tinymce/skins/content/default/content.min.css",
@@ -43,7 +43,7 @@ const GroupsDialog = memo(
const { data: group } = useQuery({
queryKey: [ENDPOINT.CMS_SERVICE.GROUPS, groupId],
queryFn: () => GroupApi.findGroupById(groupId!),
enabled: !!groupId && open, // open 시점 groupId가 있을 때만 상세 조회 API 호출
enabled: !isRegistration,
});
const { mutateAsync: createGroup } = useMutationWrapper({
@@ -92,8 +92,9 @@ const GroupsDialog = memo(
};
useEffect(() => {
if (open) form.reset();
}, [open]);
// 그룹 등록 및 취소 시 폼 초기화
if (isRegistration && !open) form.reset();
}, [groupId, open]);
return (
<Dialog open={open} onOpenChange={setOpen} {...props}>
+7 -1
View File
@@ -16,7 +16,13 @@ type RoleSelectProps = React.ComponentPropsWithoutRef<typeof Select> & {
function RoleSelect({ mngrYn = "N", ...props }: RoleSelectProps) {
const { data: roles } = useQuery({
queryKey: [ENDPOINT.USER_SERVICE.ROLES],
queryFn: () => RoleApi.getRoles(),
queryFn: () =>
RoleApi.getRoles({
keywordType: "useYn",
keyword: "Y",
page: 0,
size: 100,
}),
});
const options = roles?.content.filter((role) => role.mngrYn === mngrYn);
+2 -18
View File
@@ -3,13 +3,8 @@ import { useState } from "react";
import type { Role } from "@/entities/role";
import type { ColumnDef } from "@tanstack/react-table";
import { MappingRoleDialog } from "../ui/mapping-role-dialog";
import { Button } from "@common/components";
export const createColumns = ({
deleteRole,
}: {
deleteRole: (roleId: string) => void;
}): ColumnDef<Role>[] => [
export const createColumns = (): ColumnDef<Role>[] => [
{
id: "no",
header: "NO",
@@ -44,18 +39,7 @@ export const createColumns = ({
{
id: "action",
header: "관리",
cell: ({ row }) => (
<div className="flex items-center justify-center gap-2">
<MappingRoleButton roleId={row.original.roleId} />
<Button
size="sm"
color="red"
onClick={() => deleteRole(row.original.roleId)}
>
삭제
</Button>
</div>
),
cell: ({ row }) => <MappingRoleButton roleId={row.original.roleId} />,
},
];
+1 -13
View File
@@ -24,13 +24,6 @@ function ModifyPage({ roleId }: { roleId: string }) {
},
});
const { mutateAsync: deleteRole } = useMutationWrapper({
mutationFn: () => RoleApi.deleteRole(roleId),
onSuccess: () => {
alert("삭제되었습니다.", () => router.push("/role"));
},
});
const form = useForm<RoleFormData>({
resolver: zodResolver(roleFormSchema),
values: {
@@ -47,12 +40,7 @@ function ModifyPage({ roleId }: { roleId: string }) {
<div className="card-header">
<h3 className="text-2xl font-medium text-label">권한 수정</h3>
</div>
<RoleForm
roleId={roleId}
form={form}
handleSave={updateRole}
handleDelete={deleteRole}
/>
<RoleForm roleId={roleId} form={form} handleSave={updateRole} />
</div>
);
}
-1
View File
@@ -26,7 +26,6 @@ type RoleFormProps = {
roleId?: string;
form: UseFormReturn<RoleFormData>;
handleSave: (data: RoleFormData) => Promise<void>;
handleDelete?: () => Promise<void>;
};
function RoleForm({ roleId, form, handleSave, ...props }: RoleFormProps) {
+3 -11
View File
@@ -7,7 +7,7 @@ import { getCoreRowModel, type PaginationState } from "@tanstack/react-table";
import { Button, Input, Label } from "@common/components";
import { RoleApi, type Role, type RoleParams } from "@/entities/role";
import { ENDPOINT } from "@/shared/config";
import { useAlertStore, useDataTable, useMutationWrapper } from "@/shared/lib";
import { useAlertStore, useDataTable } from "@/shared/lib";
import { createColumns } from "../model/table-columns";
function RolePage() {
@@ -16,6 +16,7 @@ function RolePage() {
const [keyword, setKeyword] = useState("");
const [searchParams, setSearchParams] = useState<RoleParams>({
keywordType: "roleName",
keyword: "",
sort: "regDt,desc",
});
@@ -34,13 +35,6 @@ function RolePage() {
}),
});
const { mutateAsync: deleteRole } = useMutationWrapper({
mutationFn: (roleId: string) => RoleApi.deleteRole(roleId),
onSuccess: () => {
alert("삭제되었습니다.", query.refetch);
},
});
const handleRowClick = (row: Role) => {
router.push(`/role/register/${row.roleId}`);
};
@@ -48,9 +42,7 @@ function RolePage() {
const { tableComponent } = useDataTable({
query,
tableOptions: {
columns: createColumns({
deleteRole,
}),
columns: createColumns(),
state: {
pagination,
},
+2 -1
View File
@@ -1,7 +1,7 @@
export const PORT = process.env.NEXT_PUBLIC_PORT || "3000";
export const PROXY_HOST =
process.env.NEXT_PUBLIC_PROXY_HOST || `http://localhost:${PORT}`;
export const SERVER_API_URL = process.env.NEXT_PUBLIC_SERVER_API_URL;
export const SERVER_API_URL = process.env.SERVER_API_URL;
export const AUTH_LOGIN_REDIRECT_URL =
process.env.NEXT_PUBLIC_AUTH_LOGIN_REDIRECT_URL || "/auth/login";
export const SITE_ID = process.env.NEXT_PUBLIC_SITE_ID || "CS";
@@ -11,4 +11,5 @@ export const ACCESS_TOKEN =
export const REFRESH_TOKEN =
process.env.NEXT_PUBLIC_REFRESH_TOKEN || "refresh-token";
export const AUTH_USER_ID = process.env.NEXT_PUBLIC_AUTH_USER_ID || "token-id";
export const TINY_BASE_URL = process.env.NEXT_PUBLIC_TINY_BASE_URL;
export const COOKIE_DOMAIN = process.env.NEXT_PUBLIC_COOKIE_DOMAIN;
+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 */
+3 -2
View File
@@ -12,7 +12,8 @@ NEXT_PUBLIC_LX_GEOSERVER_URL = http://con.mind-one.co.kr:11306/geoserver
NEXT_PUBLIC_NGII_URL = https://map.ngii.go.kr
NEXT_PUBLIC_NGII_PARAM = 04trYP9_xwLAfALjwZ-B8g&service=WMTS&request=GetTile&version=1.0.0&format=image/png&layer=korean_map&style=korean&tilematrixset=korean&tilematrix={z}&tilecol={x}&tilerow={y}
NEXT_PUBLIC_SERVER_API_URL = http://dev.onasys.co.kr:8000
NEXT_PUBLIC_SEWERAGE_API_URL = http://localhost:8883
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
+8
View File
@@ -34,6 +34,14 @@ export default function MapLayout({
const { fetchCtpv, fetchSgg, fetchBIZ_SE_CD, fetchBIZ_TY_CD } = useStore();
useEffect(() => {
document.body.classList.add("sn-map-page-lock");
return () => {
document.body.classList.remove("sn-map-page-lock");
};
}, []);
useEffect(() => {
fetchCtpv();
fetchSgg();
@@ -0,0 +1,30 @@
import { redirect } from "next/navigation";
type LegacyAgedPipePopupPageProps = {
searchParams: Promise<Record<string, string | string[] | undefined>>;
};
export default async function LegacyAgedPipePopupPage({
searchParams,
}: LegacyAgedPipePopupPageProps) {
const resolvedSearchParams = await searchParams;
const nextSearchParams = new URLSearchParams();
Object.entries(resolvedSearchParams).forEach(([key, value]) => {
if (Array.isArray(value)) {
value.forEach((item) => nextSearchParams.append(key, item));
return;
}
if (typeof value === "string") {
nextSearchParams.set(key, value);
}
});
const queryString = nextSearchParams.toString();
redirect(
queryString
? `/popup/agedPipePopup?${queryString}`
: "/popup/agedPipePopup",
);
}
@@ -0,0 +1,81 @@
"use client";
import { useEffect, useState } from "react";
import type { AgedPipeFilters } from "./agedPipeUtils";
type Props = {
filters: AgedPipeFilters;
onFiltersChange: (filters: AgedPipeFilters) => void;
onApply: (filters?: AgedPipeFilters) => void;
};
export default function AgedPipeLayerSettingPanel({
filters,
onFiltersChange,
onApply,
}: Props) {
const [isCollapsed, setIsCollapsed] = useState(false);
const [elapsedYearsText, setElapsedYearsText] = useState(String(filters.elapsedYears ?? ""));
useEffect(() => {
setElapsedYearsText(String(filters.elapsedYears ?? ""));
}, [filters.elapsedYears]);
const commitElapsedYears = () => {
const nextFilters = {
...filters,
elapsedYears: elapsedYearsText === "" ? ("" as unknown as number) : Number(elapsedYearsText),
};
onFiltersChange(nextFilters);
onApply(nextFilters);
};
return (
<div className="overflow-hidden rounded-sm border border-red-100 bg-red-50/35">
<div className="relative flex min-h-[30px] items-center justify-center border-b border-red-100 bg-white px-8">
<button
type="button"
className="absolute left-2 top-1/2 h-[18px] w-[18px] -translate-y-1/2 bg-transparent text-[1.2rem] leading-none text-[#555]"
onClick={() => setIsCollapsed((value) => !value)}
title={isCollapsed ? "펼치기" : "접기"}
>
{isCollapsed ? "›" : "⌄"}
</button>
<span className="text-[1.2rem] font-semibold text-red-700">노후관로 설정</span>
</div>
{!isCollapsed && (
<div className="px-2 py-2">
<div className="flex min-h-[30px] items-center gap-1 whitespace-nowrap text-[1.2rem] text-[#333]">
<label htmlFor="aged-pipe-elapsed-years">경과년도(년)</label>
<input
id="aged-pipe-elapsed-years"
className="h-[22px] w-14 border border-[#ccd6df] px-1 text-center text-[1.2rem]"
type="text"
inputMode="numeric"
pattern="[0-9]*"
autoComplete="off"
value={elapsedYearsText}
onFocus={(event) => event.currentTarget.select()}
onChange={(event) => {
const digits = event.target.value.replace(/[^0-9]/g, "");
setElapsedYearsText(digits);
}}
onKeyDown={(event) => {
if (event.key === "Enter") commitElapsedYears();
}}
/>
<button
type="button"
className="grid h-[22px] w-[22px] place-items-center rounded-full bg-[#31a83b] text-[1.4rem] font-bold leading-none text-white"
onClick={commitElapsedYears}
title="적용"
>
✓
</button>
</div>
</div>
)}
</div>
);
}
@@ -0,0 +1,304 @@
"use client";
import { useCallback, useEffect, useMemo, useState } from "react";
import { useSearchParams } from "next/navigation";
import { Button, Input } from "@common/components";
import { UiTable, downloadExcelWithKoreanHeaders } from "@common/business_components/ui";
import { getData } from "@/components/lib/api";
import useStore from "@/store/useStore";
import LoadingOverlay from "@/components/ui/LoadingBar";
import {
AGED_PIPE_DEFAULT_FILTERS,
AGED_PIPE_EXCEL_HEADER,
AGED_PIPE_LAYER_TYPE,
AGED_PIPE_LIST_HEADLIST,
AGED_PIPE_PAGE_SIZE,
buildAgedPipeCqlFilters,
normalizeAgedPipeFilters,
toDisplayPipeRow,
validateElapsedYears,
type AgedPipeFilters,
type AgedPipeRow,
} from "./agedPipeUtils";
const POPUP_ROW_SELECT_MESSAGE = "SN_AGED_PIPE_ROW_SELECT";
const POPUP_MINIMIZE_MESSAGE = "SN_AGED_PIPE_POPUP_MINIMIZE";
const POPUP_FILTER_SYNC_MESSAGE = "SN_AGED_PIPE_FILTER_SYNC";
export default function AgedPipePopupPage() {
const searchParams = useSearchParams();
const { ctpv, sgg, fetchCtpv, fetchSgg } = useStore();
const [filters, setFilters] = useState<AgedPipeFilters>(() =>
normalizeAgedPipeFilters({
elapsedYears: Number(searchParams.get("elapsedYears") ?? AGED_PIPE_DEFAULT_FILTERS.elapsedYears),
ctpvCd: searchParams.get("ctpvCd") ?? AGED_PIPE_DEFAULT_FILTERS.ctpvCd,
sggCd: searchParams.get("sggCd") ?? AGED_PIPE_DEFAULT_FILTERS.sggCd,
}),
);
const [rows, setRows] = useState<AgedPipeRow[]>([]);
const [total, setTotal] = useState(0);
const [currentPage, setCurrentPage] = useState(1);
const [selectedIndex, setSelectedIndex] = useState(-1);
const [isLoading, setIsLoading] = useState(false);
const sggOptions = useMemo(
() =>
filters.ctpvCd === "0"
? sgg
: sgg.filter((item) => String(item.val).startsWith(filters.ctpvCd.slice(0, 2))),
[filters.ctpvCd, sgg],
);
useEffect(() => {
if (ctpv.length === 0) fetchCtpv();
if (sgg.length === 0) fetchSgg();
}, [ctpv.length, fetchCtpv, fetchSgg, sgg.length]);
const fetchRows = useCallback(
async (page: number, nextFilters: AgedPipeFilters, size: number) => {
const normalized = normalizeAgedPipeFilters(nextFilters);
const queryParams = new URLSearchParams({
lyrTypeNm: AGED_PIPE_LAYER_TYPE,
page: String(page - 1),
size: String(size),
sort: "string",
});
const cqlFilters = buildAgedPipeCqlFilters(normalized);
if (cqlFilters.length > 0) {
queryParams.append("cqlFiter", cqlFilters.join(" and "));
}
const response = await getData(`v1/fclts?${queryParams.toString()}`);
const responseRows = Array.isArray(response?.data) ? response.data : [];
return {
rows: responseRows.map((item: Record<string, unknown>, index: number) =>
toDisplayPipeRow(item, index, page, ctpv, sgg),
),
total: Number(response?.count ?? 0),
};
},
[ctpv, sgg],
);
const loadRows = useCallback(
async (page = 1, nextFilters = filters) => {
setIsLoading(true);
try {
const response = await fetchRows(page, nextFilters, AGED_PIPE_PAGE_SIZE);
setRows(response.rows);
setTotal(response.total);
setCurrentPage(page);
} catch (error) {
console.error("노후관로 목록 조회 중 오류:", error);
setRows([]);
setTotal(0);
} finally {
setIsLoading(false);
}
},
[fetchRows, filters],
);
useEffect(() => {
loadRows(1, filters);
// 최초 로딩만 수행한다. 이후 조회는 버튼/페이지 변경으로 처리한다.
// eslint-disable-next-line react-hooks/exhaustive-deps
}, []);
useEffect(() => {
const handleFilterSyncMessage = (event: MessageEvent) => {
if (event.origin !== window.location.origin) return;
if (event.data?.type !== POPUP_FILTER_SYNC_MESSAGE) return;
if (!event.data?.filters) return;
const normalized = normalizeAgedPipeFilters(event.data.filters as AgedPipeFilters);
setFilters(normalized);
setSelectedIndex(-1);
loadRows(1, normalized);
};
window.addEventListener("message", handleFilterSyncMessage);
return () => window.removeEventListener("message", handleFilterSyncMessage);
}, [loadRows]);
const handleSearch = (page = 1) => {
if (!validateElapsedYears(Number(filters.elapsedYears))) {
alert("경과년도는 1년~100년 범위 내에서 입력해주세요.");
return;
}
const normalized = normalizeAgedPipeFilters(filters);
setFilters(normalized);
loadRows(page, normalized);
};
const handleRowClick = (index?: number) => {
if (typeof index !== "number") return;
const row = rows[index];
if (!row) return;
setSelectedIndex(index);
window.opener?.postMessage(
{
type: POPUP_ROW_SELECT_MESSAGE,
row,
},
window.location.origin,
);
};
const handleDownload = async () => {
if (total === 0) {
alert("다운로드할 데이터가 없습니다.");
return;
}
try {
setIsLoading(true);
const response = await fetchRows(1, filters, 10000);
await downloadExcelWithKoreanHeaders({
data: response.rows as JSON[],
fileName: `노후관로_현황_${new Date().toISOString().slice(0, 10)}`,
sheetNameArray: ["노후관로 현황"],
headerRange: "A1:J1",
headerMapping: AGED_PIPE_EXCEL_HEADER,
});
} catch (error) {
console.error("노후관로 목록 다운로드 중 오류:", error);
alert("노후관로 목록 다운로드 중 오류가 발생했습니다.");
} finally {
setIsLoading(false);
}
};
const handleMinimize = () => {
window.opener?.postMessage(
{
type: POPUP_MINIMIZE_MESSAGE,
filters: normalizeAgedPipeFilters(filters),
total,
},
window.location.origin,
);
window.close();
};
return (
<main className="flex h-screen flex-col overflow-hidden bg-white">
<LoadingOverlay isLoading={isLoading} />
<header className="flex min-h-[44px] items-center justify-between border-b border-[#dbe2e6] bg-primary px-5 py-3">
<h1 className="text-[1.45rem] font-semibold text-white">노후관로 현황</h1>
<div className="flex items-center gap-2">
<Button
type="button"
color="white"
size="xs"
className="border border-white/30 px-3 py-1 text-[1.05rem] text-white"
onClick={handleMinimize}
>
최소화
</Button>
</div>
</header>
<section className="overflow-y-auto p-4">
<div className="grid grid-cols-1 gap-3 border-b border-slate-200 bg-slate-50 pb-4 md:grid-cols-[140px_minmax(0,1fr)_140px_minmax(0,1fr)_auto_auto]">
<label className="block">
<div className="mb-1 text-[1.05rem] font-medium text-slate-700">경과년도</div>
<Input
className="h-10 !text-[1.1rem]"
type="text"
inputMode="numeric"
pattern="[0-9]*"
autoComplete="off"
value={String(filters.elapsedYears ?? "")}
onChange={(event) => {
const digits = event.target.value.replace(/[^0-9]/g, "");
setFilters({
...filters,
elapsedYears: digits === "" ? ("" as unknown as number) : Number(digits),
});
}}
onKeyDown={(event) => {
if (event.key === "Enter") handleSearch(1);
}}
/>
</label>
<label className="block">
<div className="mb-1 text-[1.05rem] font-medium text-slate-700">시도</div>
<select
className="h-10 w-full rounded-md border border-slate-300 px-3 text-[1.1rem]"
value={filters.ctpvCd}
onChange={(event) =>
setFilters({
...filters,
ctpvCd: event.target.value,
sggCd: "0",
})
}
>
<option value="0">전체</option>
{ctpv.map((item) => (
<option key={item.val} value={String(item.val)}>
{item.text}
</option>
))}
</select>
</label>
<label className="block">
<div className="mb-1 text-[1.05rem] font-medium text-slate-700">시군구</div>
<select
className="h-10 w-full rounded-md border border-slate-300 px-3 text-[1.1rem]"
value={filters.sggCd}
onChange={(event) => setFilters({ ...filters, sggCd: event.target.value })}
>
<option value="0">전체</option>
{sggOptions.map((item) => (
<option key={item.val} value={String(item.val)}>
{item.text}
</option>
))}
</select>
</label>
<div className="flex flex-wrap gap-2 md:col-span-3 md:justify-end">
<Button
type="button"
size="sm"
color="primary"
className="h-10 px-5 !text-[1.1rem]"
onClick={() => handleSearch(1)}
>
조회
</Button>
<Button
type="button"
size="sm"
color="green"
className="h-10 px-5 !text-[1.1rem]"
onClick={handleDownload}
>
다운로드
</Button>
</div>
</div>
<UiTable<AgedPipeRow>
headName={<>노후관로 조회 결과 총 {total.toLocaleString()}건</>}
tableData={rows}
headlist={AGED_PIPE_LIST_HEADLIST}
pageSize={10}
total={total}
cellClick={(index) => handleRowClick(index)}
onPageChange={(page) => handleSearch(page)}
curPage={currentPage}
index={selectedIndex}
widthFixed={true}
/>
</section>
</main>
);
}
@@ -0,0 +1,236 @@
"use client";
import type { SelectDataType } from "@common/business_components";
import { formatDisplayData } from "@/components/lib/utils/formatDisplayValue";
import {
MS_SWAGE_PPL_CODES,
MS_SWAGE_PPL_USE_CODES,
} from "../common/table";
export const AGED_PIPE_LAYER_TYPE = "tb_ms_swage_ppl";
export const AGED_PIPE_DEFAULT_ELAPSED_YEARS = 30;
export const AGED_PIPE_PAGE_SIZE = 10;
export const AGED_PIPE_MIN_ZOOM = 14;
export const AGED_PIPE_INVALID_INSTALL_YMD_PREFIXES = ["19000101", "19990101"];
export type AgedPipeFilters = {
elapsedYears: number;
ctpvCd: string;
sggCd: string;
};
export type AgedPipeRow = {
fid: string;
lyrTypeNm: string;
rowNumber: number;
fcltMngNo: string;
instlYmd: string;
pdmtVl: string | number;
prlgVl: string | number;
usgCd: string;
mqltCd: string;
elapsedYears: number | string;
ctpvNm?: string;
sggNm?: string;
[key: string]: unknown;
};
type PipeSource = Record<string, unknown>;
const useMap = new Map(MS_SWAGE_PPL_USE_CODES.map((item) => [item.val.trim(), item.text]));
const materialMap = new Map(MS_SWAGE_PPL_CODES.map((item) => [item.val.trim(), item.text]));
export const AGED_PIPE_DEFAULT_FILTERS: AgedPipeFilters = {
elapsedYears: AGED_PIPE_DEFAULT_ELAPSED_YEARS,
ctpvCd: "0",
sggCd: "0",
};
export const AGED_PIPE_LIST_HEADLIST = [
{ id: "rowNumber", title: "NO", widthPercent: 6 },
{ id: "fcltMngNo", title: "시설관리번호", widthPercent: 18 },
{ id: "instlYmd", title: "설치일자", widthPercent: 12 },
{ id: "pdmtVl", title: "관경(mm)", widthPercent: 10 },
{ id: "prlgVl", title: "연장(m)", widthPercent: 10 },
{ id: "usgCd", title: "용도", widthPercent: 14 },
{ id: "mqltCd", title: "재질", widthPercent: 18 },
{ id: "elapsedYears", title: "경과년도", widthPercent: 12, align: "right" },
];
export const AGED_PIPE_EXCEL_HEADER = {
rowNumber: "NO",
fcltMngNo: "시설관리번호",
instlYmd: "설치일자",
pdmtVl: "관경(mm)",
prlgVl: "연장(m)",
usgCd: "용도",
mqltCd: "재질",
elapsedYears: "경과년도",
ctpvNm: "시도",
sggNm: "시군구",
};
const pad2 = (value: number) => String(value).padStart(2, "0");
const normalizeYmd = (value: unknown) =>
String(value ?? "").replace(/[^0-9]/g, "").slice(0, 8);
const getToday = () => new Date();
export const normalizeAgedPipeFilters = (filters: AgedPipeFilters): AgedPipeFilters => {
const elapsedYears = Math.trunc(Number(filters.elapsedYears));
return {
...filters,
elapsedYears:
Number.isFinite(elapsedYears) && elapsedYears >= 1 && elapsedYears <= 100
? elapsedYears
: AGED_PIPE_DEFAULT_ELAPSED_YEARS,
};
};
export const validateElapsedYears = (value: number) =>
Number.isInteger(value) && value >= 1 && value <= 100;
export const getCutoffYmd = (elapsedYears: number) => {
const today = getToday();
const cutoff = new Date(today.getFullYear() - elapsedYears, today.getMonth(), today.getDate());
return `${cutoff.getFullYear()}${pad2(cutoff.getMonth() + 1)}${pad2(cutoff.getDate())}`;
};
export const getElapsedYears = (value: unknown) => {
const normalized = normalizeYmd(value);
if (normalized.length < 4 || isInvalidInstallYmd(value)) return null;
const year = Number(normalized.slice(0, 4));
const month = Number(normalized.slice(4, 6) || "1");
const day = Number(normalized.slice(6, 8) || "1");
if (!year) return null;
const today = getToday();
let elapsed = today.getFullYear() - year;
if (
today.getMonth() + 1 < month ||
(today.getMonth() + 1 === month && today.getDate() < day)
) {
elapsed -= 1;
}
return Math.max(elapsed, 0);
};
export const isInvalidInstallYmd = (value: unknown) => {
const normalized = normalizeYmd(value);
return (
normalized.length < 8 ||
AGED_PIPE_INVALID_INSTALL_YMD_PREFIXES.some((prefix) => normalized.startsWith(prefix))
);
};
export const getElapsedYearsDisplayText = (value: unknown) => {
const elapsedYears = getElapsedYears(value);
return elapsedYears == null ? "확인불가" : `${elapsedYears}년`;
};
export const buildAgedPipeCqlFilters = (filters: AgedPipeFilters) => {
const normalized = normalizeAgedPipeFilters(filters);
const cqlFilters = [
"instl_ymd IS NOT NULL",
...AGED_PIPE_INVALID_INSTALL_YMD_PREFIXES.map((prefix) => `instl_ymd NOT LIKE '${prefix}%'`),
`instl_ymd <= '${getCutoffYmd(normalized.elapsedYears)}'`,
];
if (normalized.sggCd !== "0") {
cqlFilters.push(`pbadms_emd_cd LIKE '${normalized.sggCd}%'`);
} else if (normalized.ctpvCd !== "0") {
cqlFilters.push(`pbadms_emd_cd LIKE '${normalized.ctpvCd}%'`);
}
return cqlFilters;
};
const getRegionCode = (source: PipeSource) =>
String(source.pbadmsEmdCd ?? source.pbadms_emd_cd ?? "").trim();
export const matchesAgedPipeFilters = (source: PipeSource, filters: AgedPipeFilters) => {
const normalized = normalizeAgedPipeFilters(filters);
const instlYmd = normalizeYmd(source.instl_ymd ?? source.instlYmd);
if (!instlYmd || isInvalidInstallYmd(instlYmd) || instlYmd > getCutoffYmd(normalized.elapsedYears)) return false;
const regionCode = getRegionCode(source);
if (normalized.sggCd !== "0") return regionCode.startsWith(normalized.sggCd);
if (normalized.ctpvCd !== "0") return regionCode.startsWith(normalized.ctpvCd);
return true;
};
const resolveRegionName = (
code: string,
directName: unknown,
options: SelectDataType[],
prefixLength: number,
) => {
const direct = String(directName ?? "").trim();
if (direct) return direct;
const prefix = code.slice(0, prefixLength);
return options.find((item) => String(item.val).trim() === prefix)?.text ?? "";
};
export const toDisplayPipeRow = (
source: PipeSource,
index: number,
page: number,
ctpvOptions: SelectDataType[],
sggOptions: SelectDataType[],
): AgedPipeRow => {
const formatted = formatDisplayData(source) as PipeSource;
const codePrefix = getRegionCode(source);
const instlYmd = formatted.instlYmd ?? source.instlYmd ?? formatted.instl_ymd ?? source.instl_ymd;
const invalidInstallYmd = isInvalidInstallYmd(source.instlYmd ?? source.instl_ymd ?? instlYmd);
const trimmedUse = String(source.usgCd ?? source.usg_cd ?? "").trim();
const trimmedMaterial = String(source.mqltCd ?? source.mqlt_cd ?? "").trim();
const pdmtVl = formatted.pdmtVl ?? source.pdmtVl ?? source.pdmt_vl ?? "-";
const prlgVl = formatted.prlgVl ?? source.prlgVl ?? source.prlg_vl ?? "-";
const fcltMngNo = String(formatted.fcltMngNo ?? source.fcltMngNo ?? source.fclt_mng_no ?? "-");
return {
...source,
...formatted,
fid: String(source.fid ?? ""),
lyrTypeNm: String(source.lyrTypeNm ?? AGED_PIPE_LAYER_TYPE),
rowNumber: (page - 1) * AGED_PIPE_PAGE_SIZE + index + 1,
fcltMngNo,
swtrmtFcltCd: source.swtrmtFcltCd ?? fcltMngNo,
instlYmd: invalidInstallYmd ? "확인불가" : String(instlYmd ?? "-"),
pdmtVl: typeof pdmtVl === "number" ? pdmtVl : String(pdmtVl),
prlgVl: typeof prlgVl === "number" ? prlgVl : String(prlgVl),
usgCd: useMap.get(trimmedUse) ?? String(formatted.usgCd ?? source.usgCd ?? source.usg_cd ?? "-"),
mqltCd:
materialMap.get(trimmedMaterial) ??
String(formatted.mqltCd ?? source.mqltCd ?? source.mqlt_cd ?? "-"),
elapsedYears: getElapsedYears(source.instlYmd ?? source.instl_ymd) ?? "확인불가",
ctpvNm: resolveRegionName(
codePrefix,
formatted.ctpvNm ?? source.ctpvNm ?? source.ctpv_nm,
ctpvOptions,
2,
),
sggNm: resolveRegionName(
codePrefix,
formatted.sggNm ?? source.sggNm ?? source.sgg_nm,
sggOptions,
5,
),
};
};
export const isAgedPipeLayerNode = (node: any) => {
const data = node?.data ?? node;
const layerName = String(data?.lyrNm ?? node?.text ?? "");
const layerTypeName = String(data?.lyrTyNm ?? "");
return (
layerTypeName === "tb_old_pipe" ||
layerName.includes("노후관로")
);
};
@@ -3,6 +3,7 @@ export const MsSwagePplCol = [
{ id: 'fclt_mng_no', title: '시설관리번호' },
{ id: 'inst_mng_no', title: '기관관리번호' },
{ id: 'instl_ymd', title: '설치일자' },
{ id: 'elapsedYears', title: '경과년도' },
{ id: 'mapsht_no', title: '도엽번호' },
{ id: 'pdmt_vl', title: '관경(mm)' },
{ id: 'prlg_vl', title: '연장(m)' },
@@ -13,29 +13,30 @@
* - 위경도로 강우시 미처리 하수 목록 조회: GET /api/v1/onsemiro/getSwgRainsByGeomety (sewerage-service)
* - 강우시 월류 하수 관리 모니터링 조회: GET /api/v1/onsemiro/swgRain/{adstDstrCd}/{manageNo}/mntrngs (sewerage-service)
*/
import { UiModal, UiTable } from '@common/business_components/ui';
'use client';
import { UiTable } from '@common/business_components/ui';
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';
import { openWindowPopup } from '@/features/windowPopup/lib/openWindowPopup';
const inputColumn = [
{ id: "prrtymngYn", title: "중점여부", type: "select", options: ["우수토실", "중점우수토실"] },
{ id: "swtrmtZoneNm", title: "처리구역", type: "input" },
];
const Ssc = ({ featureData, isMainSsc, chmbrXcnts, chmbrYdnts, onSaveClick }: {
const Ssc = ({ featureData, isMainSsc, chmbrXcnts, chmbrYdnts, onSaveClick, renderMode = 'inline' }: {
featureData: any,
isMainSsc?: boolean,
chmbrXcnts?: string,
chmbrYdnts?: string,
onSaveClick?: () => void
onSaveClick?: () => void,
renderMode?: 'inline' | 'popup'
}) => {
const [isModalOpen, setIsModalOpen] = useState(false);
const [isDataLoaded, setIsDataLoaded] = useState(false);
const [formData, setFormData] = useState<any>({});
const [rainMeasureData, setRainMeasureData] = useState<any>({}); // 강우, 측정 정보
const [mntrngsData, setMntrngsData] = useState<any[]>([]); // 수량/수질측정 정보
@@ -65,22 +66,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 +124,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) => {
@@ -181,14 +152,15 @@ const Ssc = ({ featureData, isMainSsc, chmbrXcnts, chmbrYdnts, onSaveClick }: {
}
}, [chmbrXcnts, chmbrYdnts]);
const openModal = () => {
setIsModalOpen(true);
};
const openDetailPopup = () => {
const popupWindow = openWindowPopup("sscDetail", {
chmbrXcnts,
chmbrYdnts,
sourcePath: window.location.pathname,
});
const closeModal = () => {
setIsModalOpen(false);
if (!isDataLoaded) {
setTimeout(() => setIsDataLoaded(true), 100);
if (!popupWindow) {
alert('팝업이 차단되었습니다. 브라우저 팝업 허용 설정을 확인해주세요.');
}
};
@@ -322,37 +294,28 @@ const Ssc = ({ featureData, isMainSsc, chmbrXcnts, chmbrYdnts, onSaveClick }: {
/>
)}
{isMainSsc && (
{isMainSsc && renderMode !== 'popup' && (
<div>
<div className="card-header">
<h3 className="text-2xl mx-3 font-medium text-label">중점우수토실 운영정보</h3>
</div>
<Button
className='mx-auto block w-64 mt-3 py-2 text-white rounded-sm text-sm font-medium hover:bg-blue-700 bg-blue-600'
onClick={openModal}
onClick={openDetailPopup}
>
상세보기
</Button>
</div>
)}
<UiModal
open={isModalOpen}
onOpenChange={setIsModalOpen}
bodyClass={`
h-[100vh]
w-[60vw]
max-w-none
max-h-none
overflow-y-auto
z-[101]
`}
body={renderModalBody()}
noHeaderYn='Y'
noFooterYn='Y'
/>
{isMainSsc && renderMode === 'popup' && (
<div className="min-h-dvh bg-white p-4">
<h1 className="mb-3 text-2xl font-semibold text-gray-900">중점우수토실 운영정보</h1>
{renderModalBody()}
</div>
)}
</div>
)
}
export default Ssc
export default Ssc
@@ -9,28 +9,37 @@
* - 조회 버튼을 통한 모달 표시 및 ReportTab 연동
*/
'use client'
import React, { useState } from 'react'
import React from 'react'
import { VerticalTable } from '@/components/ui/verticalTable';
import { UiModal } from '@common/business_components/ui';
import { Button } from '@common/components/ui';
import ReportTab from '../../searchModal/reportTab';
import { openWindowPopup } from '@/features/windowPopup/lib/openWindowPopup';
// 하수처리시설
const headList = [
{ id: "title", title: "하수도정비 기본계획보고서" },
];
const SwtrmtFclt = ({ gisObject }: { gisObject?: any }) => {
const [isModalOpen, setIsModalOpen] = useState(false);
const SwtrmtFclt = ({ gisObject }: { gisObject: any }) => {
const ctpvNm = gisObject?.ctpv_nm;
const sggNm = gisObject?.sgg_nm;
const handleOpenReportPopup = () => {
const popupWindow = openWindowPopup("reportTab", {
ctpvNm,
sggNm,
sourcePath: window.location.pathname,
});
if (!popupWindow) {
alert('팝업이 차단되었습니다. 브라우저 팝업 허용 설정을 확인해주세요.');
}
};
return (
<div>
<VerticalTable
children={{
title: <Button className="gray" onClick={() => setIsModalOpen(true)}>조회</Button>
title: <Button className="gray" onClick={handleOpenReportPopup}>조회</Button>
}}
headName='디지털 자료관 연계정보'
tableData={[]}
@@ -43,29 +52,6 @@ const SwtrmtFclt = ({ gisObject }: { gisObject?: any }) => {
throw new Error("Function not implemented.");
}}
/>
<UiModal
open={isModalOpen}
onOpenChange={setIsModalOpen}
headerTitle="하수도정비 기본계획보고서"
headerTitleClass="text-3xl font-semibold text-gray-700"
bodyClass={`
h-[100vh]
w-[60vw]
max-w-[60vw]
max-h-[100vh]
overflow-hidden
z-[101]
`}
body={
<ReportTab
open={isModalOpen}
isSearch={false}
ctpvNm={ctpvNm}
sggNm={sggNm}
/>
}
useDirectRender={true}
/>
</div >
)
}
@@ -7,14 +7,12 @@ import {
CHART_FORM,
type ChartConfig,
MainChart,
UiModal,
} from '@common/business_components';
import { SelectBox } from '@common/business_components/ui';
import { Button, Input, Label } from '@common/components';
import type { TInftwtAnls } from './aplcnYms';
import * as fn from './aplcnYms';
import { useInfilChartModalStore } from '../chartModalStore';
type ModalOpenProp = {
pplMngNo?: string; // 관로관리번호
@@ -28,7 +26,6 @@ type DataType = {
const INITIAL_ANALYSIS_OPTIONS = [{ val: 0, text: '선택' }];
const InfilChart = ({ pplMngNo = '' }: ModalOpenProp) => {
const { isOpen: isModalOpen, setIsOpen: setIsModalOpen } = useInfilChartModalStore();
const [selectedAplcnYm, setSelectedAplcnYm] = useState('');
const [inftwtAnlss, setInftwtAnlss] = useState<TInftwtAnls[]>([]);
@@ -66,12 +63,6 @@ const InfilChart = ({ pplMngNo = '' }: ModalOpenProp) => {
setInftwtQty('0');
};
useEffect(() => {
if (!isModalOpen) {
resetState(); // 모달이 닫힐 때 리셋
}
}, [isModalOpen]);
const handleAplcnYmChange = async (val: string | number) => {
const selectedValue = String(val);
@@ -276,21 +267,10 @@ const InfilChart = ({ pplMngNo = '' }: ModalOpenProp) => {
);
return (
<UiModal
open={isModalOpen}
onOpenChange={setIsModalOpen}
headerTitle=""
bodyClass={`
w-[78vw]
max-w-none
min-w-[1000px]
min-h-[72vh]
overflow-visible
z-[50]
`}
body={renderBody()}
noFooterYn="Y"
/>
<div className="min-h-dvh bg-white p-4">
<h1 className="mb-3 text-2xl font-semibold text-gray-900">침입수 분석</h1>
{renderBody()}
</div>
);
};
@@ -7,14 +7,12 @@ import {
CHART_FORM,
type ChartConfig,
MainChart,
UiModal,
} from '@common/business_components';
import { SelectBox } from '@common/business_components/ui';
import { Button, Input, Label } from '@common/components';
import type { TRdiiAnls } from './aplcnYms';
import * as fn from './aplcnYms';
import { useRdiiChartModalStore } from '../chartModalStore';
type ModalOpenProp = {
pplMngNo?: string; // 관로관리번호
@@ -29,7 +27,6 @@ type LineBarChartData = {
const INITIAL_ANALYSIS_OPTIONS = [{ val: 0, text: '선택' }];
const RdiiChart = ({ pplMngNo = '' }: ModalOpenProp) => {
const { isOpen: isModalOpen, setIsOpen: setIsModalOpen } = useRdiiChartModalStore();
const [aplcnYm, setAplcnYm] = useState('');
const [rdiiAnlss, setRdiiAnlss] = useState<TRdiiAnls[]>([]);
@@ -72,12 +69,6 @@ const RdiiChart = ({ pplMngNo = '' }: ModalOpenProp) => {
setTotalVolume('0');// 총체적 m³/mm 초기화
};
useEffect(() => {
if (!isModalOpen) {
resetState();
}
}, [isModalOpen]);
const handleAplcnYmChange = async (val: string | number) => {
const selectedValue = String(val);
@@ -336,21 +327,10 @@ const RdiiChart = ({ pplMngNo = '' }: ModalOpenProp) => {
);
return (
<UiModal
open={isModalOpen}
onOpenChange={setIsModalOpen}
headerTitle=""
bodyClass={`
w-[78vw]
max-w-none
min-w-[1100px]
min-h-[72vh]
overflow-visible
z-[50]
`}
body={renderBody()}
noFooterYn="Y"
/>
<div className="min-h-dvh bg-white p-4">
<h1 className="mb-3 text-2xl font-semibold text-gray-900">RDII 분석</h1>
{renderBody()}
</div>
);
};
@@ -1,16 +0,0 @@
import { create } from 'zustand';
interface ModalStore {
isOpen: boolean;
setIsOpen: (open: boolean) => void;
}
export const useInfilChartModalStore = create<ModalStore>((set) => ({
isOpen: false,
setIsOpen: (open: boolean) => set({ isOpen: open }),
}));
export const useRdiiChartModalStore = create<ModalStore>((set) => ({
isOpen: false,
setIsOpen: (open: boolean) => set({ isOpen: open }),
}));
@@ -23,7 +23,6 @@
*/
'use client'
import { getData, postData } from "@/components/lib/api";
import Modal from "@/components/ui/modal";
import { VerticalTable } from "@/components/ui/verticalTable";
import { useGisContext } from "@common/business_components/ui/map/Gis";
import { Button } from "@common/components/ui";
@@ -34,13 +33,12 @@ import Stroke from "ol/style/Stroke.js";
import CircleStyle from "ol/style/Circle.js";
import { getLayerContentComponent } from "../common";
import { SwtrmtFcltCol, SwtrmtFcltCol2, SwtrmtPrcsZoneCol, SwtrmtPrcsZoneCol2, untrtSwgRainCol1, untrtSwgRainCol2, MsSwagePplCol, MsSwagePplCol2, ExclnSewagePumpCol, ExclnSewagePumpCol2, DrtwSewagePumpCol, DrtwSewagePumpCol2, MsSwageMhlCol, MsSwageMhlCol2, stofcbrCol, stofcbrCol2, MS_SWAGE_MHL_CODES, DRTW_SEWAGE_PUMP_USE_CODES } from "../common/table";
import InfilChart from './chart/infilChart';
import RdiiChart from './chart/rdiiChart';
import { useInfilChartModalStore, useRdiiChartModalStore } from "./chartModalStore";
import { formatDisplayData } from '@/components/lib/utils/formatDisplayValue';
import { codeMapper } from "../common/codeMapper";
import LoadingOverlay from '@/components/ui/LoadingBar';
import { searchUpperPpls } from "./smh";
import { getElapsedYearsDisplayText, isInvalidInstallYmd } from "../agedPipe/agedPipeUtils";
import { openWindowPopup } from '@/features/windowPopup/lib/openWindowPopup';
const linkedFacilityHighlightStyle = new Style({
image: new CircleStyle({
@@ -63,7 +61,6 @@ const linkedFacilityHighlightStyle = new Style({
zIndex: 9999,
});
const FeatureClickEventPopup = ({ onClose, featureData }: { onClose: () => void, featureData: any }) => {
const { gisMap } = useGisContext() as unknown as { checkedLayerIds: React.MutableRefObject<number[]>; treeData: { lyrSn: number }[] | null; gisCore: any; gisMap: any; };
const [layerNm, setLayerNm] = useState<string>(""); // 레이어 이름 상태
@@ -74,12 +71,9 @@ const FeatureClickEventPopup = ({ onClose, featureData }: { onClose: () => void,
const [buttonChk, setButtonChk] = useState(true)
const [fetchData, setFetchData] = useState<any>(); // api 호출여부
const contentRef = useRef<HTMLDivElement>(null);
const [isModalOpen, setIsModalOpen] = useState(false); // 모달 열기 상태
const [isMainSsc, setIsMainSsc] = useState(false); // 중점우수토실 여부
const [isDrtwSewagePump, setIsDrtwSewagePump] = useState(false); // 오수펌프장 여부
// 🎯 InfilChart 및 RdiiChart 관련 상태 추가
const { setIsOpen: setIsModalInfil } = useInfilChartModalStore();
const { setIsOpen: setIsModalRdii } = useRdiiChartModalStore();
const [isClicked, setIsClicked] = useState(true); // 클릭 여부 상태
const [isLoading, setIsLoading] = useState(false); // 로딩 상태
const [chmbrXcnts, setChmbrXcnts] = useState<string>(''); // 로딩 상태
@@ -88,7 +82,7 @@ const FeatureClickEventPopup = ({ onClose, featureData }: { onClose: () => void,
const linkedFacilityOriginalStyleRef = useRef<any>(null);
// 🎯 관로관리번호 추출
const pplMngNo = featureData?.values_?.fclt_mng_no || "";
const pplMngNo = featureData?.values_?.fclt_mng_no || featureData?.fcltMngNo || "";
const featureId = typeof featureData.getId === 'function'
? featureData.getId()
@@ -258,7 +252,7 @@ const FeatureClickEventPopup = ({ onClose, featureData }: { onClose: () => void,
case 'tb_ms_swage_ppl': {
result = '하수관로';
setHeadlist(MsSwagePplCol2)
setLayerSpecificNm(featureData.swtrmtFcltCd);
setLayerSpecificNm(featureData.fcltMngNo ?? featureData.swtrmtFcltCd);
break;
}
case 'tb_ms_swage_mhl': {
@@ -341,6 +335,14 @@ const FeatureClickEventPopup = ({ onClose, featureData }: { onClose: () => void,
// 🎯 기본 데이터 포맷팅
const formattedData = formatDisplayData(data);
if (selectedLayerName === '하수관로') {
const installYmd = data.instl_ymd ?? data.instlYmd;
formattedData.elapsedYears = getElapsedYearsDisplayText(installYmd);
if (isInvalidInstallYmd(installYmd)) {
formattedData.instl_ymd = "확인불가";
formattedData.instlYmd = "확인불가";
}
}
setFetchData(formattedData);
setTableData(formattedData);
@@ -407,8 +409,8 @@ const FeatureClickEventPopup = ({ onClose, featureData }: { onClose: () => void,
// 자체입력정보 API 엔드포인트 선택
let apiEndpoint = '';
const fcltMngNo = featureData?.values_?.fclt_mng_no || null;
const mngInstCd = featureData?.values_?.mng_inst_cd || null;
const fcltMngNo = featureData?.values_?.fclt_mng_no || featureData?.fcltMngNo || null;
const mngInstCd = featureData?.values_?.mng_inst_cd || featureData?.mngInstCd || null;
if (selectedLayerName === '하수관로') apiEndpoint = `v1/fcltppl/${fcltMngNo}/${mngInstCd}`;
else if (selectedLayerName === '우수토실') apiEndpoint = `v1/fcltstofcbr/${fcltMngNo}/${mngInstCd}`;
else if (selectedLayerName === '하수펌프장') apiEndpoint = `v1/fcltpump/${fcltMngNo}/${mngInstCd}`;
@@ -461,16 +463,9 @@ const FeatureClickEventPopup = ({ onClose, featureData }: { onClose: () => void,
// 저장 시 모달 띄우기
const handleSaveClick = () => {
setIsModalOpen(true); // 모달 열기
};
const handleModalSave = () => {
setIsModalOpen(false); // 모달 닫기
btnClickEvent(); // 저장 이벤트 실행
};
const handleModalClose = () => {
setIsModalOpen(false); // 모달 닫기
const shouldSave = window.confirm('정보를 저장하시겠습니까?');
if (!shouldSave) return;
btnClickEvent();
};
// 저장 버튼 이벤트
@@ -557,14 +552,26 @@ const FeatureClickEventPopup = ({ onClose, featureData }: { onClose: () => void,
{/* 하수관로일 때만 Infil/RDII 버튼 표시 */}
{layerNm === '하수관로' && (
<>
<Button onClick={() => setIsModalInfil(true)}
<Button
onClick={() => {
const popupWindow = openWindowPopup("infilChart", { pplMngNo });
if (!popupWindow) {
alert('팝업이 차단되었습니다. 브라우저 팝업 허용 설정을 확인해주세요.');
}
}}
className="py-1 text-xl text-blue-600 border border-blue-600 rounded hover:bg-gray-700"
color="white"
size="sm"
>
침입수분석
</Button>
<Button onClick={() => setIsModalRdii(true)}
<Button
onClick={() => {
const popupWindow = openWindowPopup("rdiiChart", { pplMngNo });
if (!popupWindow) {
alert('팝업이 차단되었습니다. 브라우저 팝업 허용 설정을 확인해주세요.');
}
}}
className="py-1 text-xl text-gray-600 border border-gray-600 rounded hover:bg-gray-700 mr-4"
color="white"
size="sm"
@@ -598,7 +605,11 @@ const FeatureClickEventPopup = ({ onClose, featureData }: { onClose: () => void,
</div>
<div className="overflow-y-auto overflow-x-hidden flex-1" ref={contentRef}>
<VerticalTable
headName='기본정보'
headName={
layerNm === '하수관로' && tableData?.elapsedYears
? <>기본정보 <span className="ml-2 rounded-sm bg-red-50 px-2 py-0.5 text-[1.1rem] font-semibold text-red-700">경과년도 {tableData.elapsedYears}</span></>
: '기본정보'
}
tableName1={layerNm === '중점우수토실' ? "측정지점" : undefined}
tableData={[tableData]}
headlist={headlist}
@@ -617,7 +628,6 @@ const FeatureClickEventPopup = ({ onClose, featureData }: { onClose: () => void,
total2={0}
isEditable2={buttonChk}
/>
<Modal isModalOpen={isModalOpen} title="자체입력정보 저장" setIsModalOpen={setIsModalOpen} closeModal={handleModalClose} saveModal={handleModalSave}><div className="text-lg">정보를 저장하시겠습니까?</div></Modal>
{ContentComponent &&
<ContentComponent
featureData={fetchData}
@@ -631,9 +641,6 @@ const FeatureClickEventPopup = ({ onClose, featureData }: { onClose: () => void,
/>
}
</div>
<InfilChart pplMngNo={pplMngNo} />
<RdiiChart pplMngNo={pplMngNo} />
</div>
)
}
@@ -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;
+441 -27
View File
@@ -18,52 +18,176 @@
"use client";
import Gis from "@common/business_components/ui/map/Gis";
import { getData } from "../../components/lib/api";
import { useCallback, useEffect, useRef, useState } from "react";
import { getData, getRawJson } from "../../components/lib/api";
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
import GisApp from "@common/business_components/ui/map/lib/gis.core";
import SearchModal from "./searchModal/searchModal";
import FeatureClickEventPopup from "./components/popupComponent/featureClickEventPopup";
import { WaterDepth, WATER_DEPTH_LAYER_TYPES } from "./waterDepth/waterDepth";
import { ProcSearchBar } from "@common/business_components/ui/map/searchBar";
import { Button } from "@common/components";
import useSearchModalStore from "@/store/useSearchModalStore";
import useStore from "@/store/useStore";
import { downloadExcelWithKoreanHeaders } from "@common/business_components/ui";
import AgedPipeLayerSettingPanel from "./components/agedPipe/AgedPipeLayerSettingPanel";
import {
AGED_PIPE_EXCEL_HEADER,
AGED_PIPE_MIN_ZOOM,
AGED_PIPE_LAYER_TYPE,
AGED_PIPE_PAGE_SIZE,
buildAgedPipeCqlFilters,
isAgedPipeLayerNode,
matchesAgedPipeFilters,
normalizeAgedPipeFilters,
toDisplayPipeRow,
validateElapsedYears,
type AgedPipeFilters,
type AgedPipeRow,
AGED_PIPE_DEFAULT_FILTERS,
} from "./components/agedPipe/agedPipeUtils";
import { openWindowPopup } from "@/features/windowPopup/lib/openWindowPopup";
import { WINDOW_POPUP_MESSAGE_TYPE } from "@/features/windowPopup/model/types";
const AGED_PIPE_CONTROL_NODE_ID = "SN_AGED_PIPE";
const AGED_PIPE_HIGHLIGHT_LAYER_ID = "SN_AGED_PIPE_HIGHLIGHT";
const AGED_PIPE_ROW_SELECT_MESSAGE = "SN_AGED_PIPE_ROW_SELECT";
const AGED_PIPE_POPUP_MINIMIZE_MESSAGE = "SN_AGED_PIPE_POPUP_MINIMIZE";
const AGED_PIPE_FILTER_SYNC_MESSAGE = "SN_AGED_PIPE_FILTER_SYNC";
const AGED_PIPE_FOCUS_PADDING: [number, number, number, number] = [120, 120, 120, 120];
const DEFAULT_FEATURE_FIT_PADDING: [number, number, number, number] = [50, 50, 50, 50];
const appendAgedPipeControlNode = (data: any[]) => {
if (!Array.isArray(data)) return data;
if (data.some((item) => String(item.lyrSn) === AGED_PIPE_CONTROL_NODE_ID || item.lyrNm === "노후관로")) {
return data;
}
return [
...data,
{
lyrSn: AGED_PIPE_CONTROL_NODE_ID,
id: AGED_PIPE_CONTROL_NODE_ID,
lyrNm: "노후관로",
upperLyrSn: null,
upId: null,
sortOrdr: "4",
drawOrdr: null,
flterSql: null,
gobjTy: "",
lyrTyNm: "tb_old_pipe",
useYn: "Y",
minViewSize: null,
iconNm: null,
iconSize: null,
lblAtrbNm: null,
fontNm: null,
fontIntnColor: null,
fontExtnColor: null,
fontExtnWdth: null,
lnWdth: null,
lnColor: null,
polgnColor: null,
initEprssYn: "Y",
clickPsblYn: "N",
exprsYn: "Y",
cmncNm: "all",
srvcNm: "wfs",
actlLyrYn: "N",
clckZoom: null,
children: null,
},
];
};
const Map = () => {
const { isSearchModalOpen, setIsSearchModalOpen } = useSearchModalStore();
const [treeData, setTreeData] = useState([]); // 초기값을 객체 또는 null로 설정
const [treeData, setTreeData] = useState<any[]>([]); // 초기값을 객체 또는 null로 설정
const [layerLoadError, setLayerLoadError] = useState<string | null>(null);
const gisMaps = useRef<any | null>(null);
const [isOpen, setIsOpen] = useState(false); // 팝업 열기/닫기 상태
const [featureData, setFeatureData] = useState(null); // 선택된 피쳐 데이터
const [featureData, setFeatureData] = useState<any>(null); // 선택된 피쳐 데이터
const [showWaterDepth, setShowWaterDepth] = useState(false); // WaterDepth 표시 여부
const [isPlaying, setIsPlaying] = useState(false);
const [mapReady, setMapReady] = useState(false);
const [isAgedPipeChecked, setIsAgedPipeChecked] = useState(false);
const [draftAgedPipeFilters, setDraftAgedPipeFilters] =
useState<AgedPipeFilters>(AGED_PIPE_DEFAULT_FILTERS);
const [appliedAgedPipeFilters, setAppliedAgedPipeFilters] =
useState<AgedPipeFilters>(AGED_PIPE_DEFAULT_FILTERS);
const [isAgedPipeListOpen, setIsAgedPipeListOpen] = useState(false);
const [isAgedPipeListMinimized, setIsAgedPipeListMinimized] = useState(false);
const [agedPipeRows, setAgedPipeRows] = useState<AgedPipeRow[]>([]);
const [agedPipeTotalCount, setAgedPipeTotalCount] = useState(0);
const [agedPipeCurrentPage, setAgedPipeCurrentPage] = useState(1);
const [isAgedPipeListLoading, setIsAgedPipeListLoading] = useState(false);
const { ctpv, sgg } = useStore();
const shouldFocusAgedPipeOnNextSelectRef = useRef(false);
const agedPipePopupWindowRef = useRef<Window | null>(null);
useEffect(() => {
fetchData();
}, []);
useEffect(() => {
const timer = window.setInterval(() => {
if (gisMaps.current) {
setMapReady(true);
window.clearInterval(timer);
}
}, 300);
const handlePopupMessage = async (event: MessageEvent) => {
if (event.origin !== window.location.origin) return;
const data = event.data;
const core = GisApp.Module.layer as any;
if (data?.type === WINDOW_POPUP_MESSAGE_TYPE.MAP_SEARCH_SELECT_FEATURE) {
const layerNm = data.payload?.layerNm;
const fid = data.payload?.fid;
if (!layerNm || !fid || !core?.showSearchResultsFromGeoJSON) return;
await core.showSearchResultsFromGeoJSON(layerNm, fid);
return;
}
if (data?.type === WINDOW_POPUP_MESSAGE_TYPE.MAP_SEARCH_SHOW_MULTIPLE) {
const layerNm = data.payload?.layerNm;
const fidsRaw = data.payload?.fids;
if (!layerNm || !fidsRaw || !core?.showMultipleSearchResultsFromGeoJSON) return;
let fids: string[] = [];
try {
fids = JSON.parse(fidsRaw);
} catch (error) {
console.error("다중 시설 메시지 파싱 실패:", error);
return;
}
await core.showMultipleSearchResultsFromGeoJSON(layerNm, fids);
}
};
window.addEventListener("message", handlePopupMessage);
return () => {
window.clearInterval(timer);
window.removeEventListener("message", handlePopupMessage);
};
}, []);
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);
}
setTreeData(data);
const data = await getRawJson("/sewerage-service/api/v1/map/lyrStngs/SN_NETWORK");
setTreeData(appendAgedPipeControlNode(data));
setLayerLoadError(null);
} catch (error) {
console.error('JSON parsing error:', error);
// JSON 파싱에 실패한 경우, 원본 텍스트 또는 null 반환
throw new Error('Invalid JSON response from server');
const message =
error instanceof Error ? error.message : "레이어 설정 조회 중 알 수 없는 오류가 발생했습니다.";
console.error("레이어 설정 조회 실패:", error);
setTreeData([]);
setLayerLoadError(message);
}
};
@@ -112,13 +236,23 @@ const Map = () => {
if (geometry) {
let extent = geometry.getExtent();
const layerTypeName = typeof foundFeature.getId === "function"
? String(foundFeature.getId()).split(".")[0]
: data?.lyrTypeNm;
const shouldUseAgedPipeFocus =
shouldFocusAgedPipeOnNextSelectRef.current && layerTypeName === AGED_PIPE_LAYER_TYPE;
map.getView().fit(extent, {
duration: 300,
padding: [50, 50, 50, 50],
maxZoom: 16,
padding: shouldUseAgedPipeFocus ? AGED_PIPE_FOCUS_PADDING : DEFAULT_FEATURE_FIT_PADDING,
maxZoom: shouldUseAgedPipeFocus ? 18 : 16,
constrainResolution: true,
nearest: true,
});
if (shouldUseAgedPipeFocus) {
shouldFocusAgedPipeOnNextSelectRef.current = false;
}
}
select.selectFeature(foundFeature, true);
} else {
@@ -131,6 +265,176 @@ const Map = () => {
};
const facilityTypes = [{ text: "하수처리시설", val: "tb_il_swage_prcs_fclt" }];
const fetchAgedPipeRows = useCallback(
async (page: number, filters: AgedPipeFilters, size: number) => {
const normalized = normalizeAgedPipeFilters(filters);
const queryParams = new URLSearchParams({
lyrTypeNm: AGED_PIPE_LAYER_TYPE,
page: String(page - 1),
size: String(size),
sort: "string",
});
const cqlFilters = buildAgedPipeCqlFilters(normalized);
if (cqlFilters.length > 0) {
queryParams.append("cqlFiter", cqlFilters.join(" and "));
}
const response = await getData(`v1/fclts?${queryParams.toString()}`);
const rows = Array.isArray(response?.data) ? response.data : [];
return {
rows: rows.map((item: Record<string, unknown>, index: number) =>
toDisplayPipeRow(item, index, page, ctpv, sgg),
),
total: Number(response?.count ?? 0),
};
},
[ctpv, sgg],
);
const loadAgedPipeList = useCallback(
async (page = 1, filters = appliedAgedPipeFilters) => {
setIsAgedPipeListLoading(true);
try {
const response = await fetchAgedPipeRows(page, filters, AGED_PIPE_PAGE_SIZE);
setAgedPipeRows(response.rows);
setAgedPipeTotalCount(response.total);
setAgedPipeCurrentPage(page);
} catch (error) {
console.error("노후관로 목록 조회 중 오류:", error);
setAgedPipeRows([]);
setAgedPipeTotalCount(0);
} finally {
setIsAgedPipeListLoading(false);
}
},
[appliedAgedPipeFilters, fetchAgedPipeRows],
);
const syncAgedPipePopupFilters = useCallback((filters: AgedPipeFilters) => {
const popup = agedPipePopupWindowRef.current;
if (!popup || popup.closed) {
agedPipePopupWindowRef.current = null;
return;
}
popup.postMessage(
{
type: AGED_PIPE_FILTER_SYNC_MESSAGE,
filters: normalizeAgedPipeFilters(filters),
},
window.location.origin,
);
}, []);
const handleApplyAgedPipeFilters = useCallback((filters = draftAgedPipeFilters) => {
if (!validateElapsedYears(Number(filters.elapsedYears))) {
alert("경과년도는 1년~100년 범위 내에서 입력해주세요.");
return;
}
const normalized = normalizeAgedPipeFilters(filters);
setDraftAgedPipeFilters(normalized);
setAppliedAgedPipeFilters(normalized);
syncAgedPipePopupFilters(normalized);
if (isAgedPipeListOpen) {
loadAgedPipeList(1, normalized);
}
}, [draftAgedPipeFilters, isAgedPipeListOpen, loadAgedPipeList, syncAgedPipePopupFilters]);
const openAgedPipePopupWindow = useCallback(() => {
const popup = openWindowPopup("agedPipePopup", {
elapsedYears: String(appliedAgedPipeFilters.elapsedYears),
ctpvCd: appliedAgedPipeFilters.ctpvCd,
sggCd: appliedAgedPipeFilters.sggCd,
});
if (!popup) {
alert("브라우저 팝업이 차단되었습니다. 팝업 허용 후 다시 시도해주세요.");
return null;
}
agedPipePopupWindowRef.current = popup;
setIsAgedPipeListOpen(true);
setIsAgedPipeListMinimized(false);
return popup;
}, [appliedAgedPipeFilters]);
const openAgedPipeList = useCallback(() => {
openAgedPipePopupWindow();
}, [openAgedPipePopupWindow]);
const handleAgedPipeRowDoubleClick = useCallback(
async (row: AgedPipeRow) => {
try {
shouldFocusAgedPipeOnNextSelectRef.current = true;
const core = GisApp.Module.layer as any;
await core.showSearchResultsFromGeoJSON(AGED_PIPE_LAYER_TYPE, row.fid);
} catch (error) {
shouldFocusAgedPipeOnNextSelectRef.current = false;
console.error("노후관로 위치 이동 중 오류:", error);
}
setFeatureData(row as any);
setIsOpen(true);
setIsSearchModalOpen(false);
},
[setIsSearchModalOpen],
);
const handleAgedPipeDownload = useCallback(async () => {
if (agedPipeTotalCount === 0) {
alert("다운로드할 데이터가 없습니다.");
return;
}
try {
setIsAgedPipeListLoading(true);
const response = await fetchAgedPipeRows(1, appliedAgedPipeFilters, 10000);
await downloadExcelWithKoreanHeaders({
data: response.rows as JSON[],
fileName: `노후관로_현황_${new Date().toISOString().slice(0, 10)}`,
sheetNameArray: ["노후관로 현황"],
headerRange: "A1:J1",
headerMapping: AGED_PIPE_EXCEL_HEADER,
});
} catch (error) {
console.error("노후관로 목록 다운로드 중 오류:", error);
alert("노후관로 목록 다운로드 중 오류가 발생했습니다.");
} finally {
setIsAgedPipeListLoading(false);
}
}, [agedPipeTotalCount, appliedAgedPipeFilters, fetchAgedPipeRows]);
useEffect(() => {
const handleAgedPipePopupMessage = (event: MessageEvent) => {
if (event.origin !== window.location.origin) return;
if (event.data?.type === AGED_PIPE_ROW_SELECT_MESSAGE) {
if (!event.data?.row) return;
handleAgedPipeRowDoubleClick(event.data.row as AgedPipeRow);
return;
}
if (event.data?.type === AGED_PIPE_POPUP_MINIMIZE_MESSAGE) {
agedPipePopupWindowRef.current = null;
if (event.data?.filters) {
const normalized = normalizeAgedPipeFilters(event.data.filters as AgedPipeFilters);
setDraftAgedPipeFilters(normalized);
setAppliedAgedPipeFilters(normalized);
}
setAgedPipeTotalCount(Number(event.data?.total ?? agedPipeTotalCount));
setIsAgedPipeListMinimized(true);
}
};
window.addEventListener("message", handleAgedPipePopupMessage);
return () => window.removeEventListener("message", handleAgedPipePopupMessage);
}, [agedPipeTotalCount, handleAgedPipeRowDoubleClick]);
// WaterDepth 표시 여부를 결정하는 함수
const handleWaterDepthVisibility = useCallback((checkedIds: string[], checkedNodesData: any[]) => {
const waterDepthLayers = checkedNodesData.filter(node =>
@@ -144,12 +448,88 @@ const Map = () => {
// 체크된 레이어 변경 처리 함수
const handleCheckedLayersChange = useCallback((checkedIds: string[], checkedNodesData: any[]) => {
handleWaterDepthVisibility(checkedIds, checkedNodesData);
setIsAgedPipeChecked(checkedNodesData.some((node) => isAgedPipeLayerNode(node)));
}, [handleWaterDepthVisibility]);
const handleOpenLayerPopup = useCallback(
(_layerNm: string, node?: any) => {
if (isAgedPipeLayerNode(node)) {
openAgedPipeList();
return false;
}
return true;
},
[openAgedPipeList],
);
const inlineLayerSettingView = useCallback(
(node: any) => {
if (!isAgedPipeChecked || !isAgedPipeLayerNode(node)) return null;
return (
<AgedPipeLayerSettingPanel
filters={draftAgedPipeFilters}
onFiltersChange={setDraftAgedPipeFilters}
onApply={handleApplyAgedPipeFilters}
/>
);
},
[draftAgedPipeFilters, handleApplyAgedPipeFilters, isAgedPipeChecked],
);
useEffect(() => {
if (!mapReady || !gisMaps.current) return;
const core = GisApp.Module.core as any;
core?.deleteLayer?.(AGED_PIPE_HIGHLIGHT_LAYER_ID);
if (!isAgedPipeChecked) return;
const baseLayerCode = Object.values(GisApp.layerCode ?? {}).find(
(item: any) => item?.lyrTyNm === AGED_PIPE_LAYER_TYPE && item?.actlLyrYn === "Y",
) as any;
if (!baseLayerCode) return;
const layerCode = {
...baseLayerCode,
id: AGED_PIPE_HIGHLIGHT_LAYER_ID,
lyrSn: AGED_PIPE_HIGHLIGHT_LAYER_ID,
lyrNm: "노후관로",
lyrTyNm: AGED_PIPE_LAYER_TYPE,
gobjTy: "line",
actlLyrYn: "Y",
exprsYn: "Y",
clickPsblYn: "Y",
cmncNm: "bbox",
srvcNm: "wfs",
flterSql: buildAgedPipeCqlFilters(appliedAgedPipeFilters).join(" and "),
lnWdth: "4",
lnColor: "rgba(235, 40, 40, 0.82)",
drawOrdr: "9999",
minViewSize: String(AGED_PIPE_MIN_ZOOM),
};
const layer = core?.addWfsLayer?.(layerCode);
layer?.set?.("id", AGED_PIPE_HIGHLIGHT_LAYER_ID);
layer?.set?.("lyrNm", "노후관로");
layer?.set?.("lyrTyNm", AGED_PIPE_LAYER_TYPE);
return () => {
core?.deleteLayer?.(AGED_PIPE_HIGHLIGHT_LAYER_ID);
};
}, [appliedAgedPipeFilters, isAgedPipeChecked, mapReady]);
return (
<>
<div className="bg-container">
<div className="bg-container relative h-[calc(100dvh-5rem)] overflow-hidden">
{layerLoadError ? (
<div className="absolute left-4 top-4 z-[60] max-w-[520px] rounded border border-red-200 bg-white px-4 py-3 text-sm text-red-700 shadow">
레이어 설정 조회 실패: {layerLoadError}
</div>
) : null}
<Gis
treeData={treeData}
isSearch={true}
@@ -165,8 +545,11 @@ const Map = () => {
isSearchModalOpen={isSearchModalOpen}
setIsSearchModalOpen={setIsSearchModalOpen}
onCheckedLayersChange={handleCheckedLayersChange}
onOpenPopup={handleOpenLayerPopup}
searchBar={<ProcSearchBar gisMaps={gisMaps} facilityTypes={facilityTypes} />}
waterDepth={showWaterDepth ? WaterDepth : undefined}
inlineLayerSettingView={inlineLayerSettingView}
layerIconVisible={isAgedPipeLayerNode}
isPlaying={isPlaying}
setIsPlaying={setIsPlaying as any}
targetLayerTypes={WATER_DEPTH_LAYER_TYPES as any}
@@ -176,6 +559,37 @@ const Map = () => {
defaultZoom={7.5}
defaultLonLat={[127.75, 36]}
/>
{isAgedPipeListMinimized && (
<div className="fixed left-[480px] top-[68px] z-[90] flex min-w-[250px] items-center gap-3 rounded-sm border border-red-200 bg-white/95 px-3 py-2 shadow-[0_8px_22px_rgba(15,23,42,0.22)] backdrop-blur">
<div className="flex h-8 w-8 shrink-0 items-center justify-center rounded-sm bg-red-50">
<span className="block h-4 w-4 rounded-sm border-2 border-red-500 bg-red-500/20" />
</div>
<div className="min-w-0 flex-1">
<div className="text-[1.25rem] font-semibold leading-tight text-red-700">노후관로 현황</div>
<div className="text-[1rem] leading-tight text-slate-500">
{appliedAgedPipeFilters.elapsedYears}년 이상 · {agedPipeTotalCount.toLocaleString()}건
</div>
</div>
<Button
type="button"
color="primary"
size="xs"
className="h-7 px-3"
onClick={openAgedPipeList}
>
열기
</Button>
<Button
type="button"
color="white"
size="xs"
className="h-7 px-3"
onClick={() => setIsAgedPipeListMinimized(false)}
>
닫기
</Button>
</div>
)}
</div>
</>
);
@@ -1,108 +0,0 @@
/**
* ➡️ 경로 : 하수관망도 메인 페이지 (오른쪽 돋보기 모양 클릭) - 검색 모달 창 내 하수도정비기본계획보고서 탭 (셀 클릭)
*
* 📄 Report Files Modal
* Next.js App Router (app/) 기반 보고서 파일 모달 컴포넌트
*
* - 하수도정비 기본계획 보고서 파일 목록 표시
* - 파일명 및 등록일자 정보 테이블 출력
* - 페이지네이션 및 로딩 오버레이 지원
* - 파일 클릭 이벤트 처리 및 상세 조회
* - Zustand를 통한 모달 상태 및 데이터 관리
* - 모달 열림/닫힘 시 데이터 초기화 기능
*/
'use client'
import React, { useEffect } from 'react';
import LoadingOverlay from '@/components/ui/LoadingBar';
import useReportTab from './useReportTab';
import { useReportTabStore } from '@/store/useReportTabStore';
import { UiTable } from '@common/business_components/ui';
import { UiModal } from "@common/business_components";
import useSearchModalStore from '@/store/useSearchModalStore';
type ModalOpenProp = { clsfSystmSn: string } // 자료관 기본계획 일련번호
const ReportFilesModal: React.FC<ModalOpenProp> = ({ clsfSystmSn }: ModalOpenProp) => {
// 하수도정비 기본계획 보고서 파일목록
const REPORT_FILES_HEADLIST = [
{ id: "no", title: "NO" },
{ id: "fileTitle", title: "파일명" },
{ id: "regDt", title: "등록일자" },
];
const {
curPage, setCurPage,
isLoading,
isResetPage,
isResetSelection,
setIsResetPage,
setIsResetSelection
} = useSearchModalStore();
const {
isOpen: isModalOpen,
files,
dcFiles: filesOrigin,
setIsOpen: setIsModalOpen,
setFiles,
fetchDcFiles
} = useReportTabStore();
const { getFiles, handleCellClick } = useReportTab();
useEffect(() => {
fetchDcFiles(clsfSystmSn);
}, [clsfSystmSn]);
useEffect(() => {
if (isModalOpen === true) {
getFiles(); // 파일 목록 조회
} else {
setFiles([]); // 모달이 닫힐 때 파일 목록 초기화
setCurPage(1); // 페이지를 1로 초기화
setIsResetPage(true); // 페이지를 초기화
setIsResetSelection(true); // 선택 초기화
}
}, [filesOrigin, isModalOpen, curPage]);
const renderBody = () => (
<>
<div className="flex-1 px-2 pb-2">
<LoadingOverlay isLoading={isLoading} />
<UiTable
headName={(() => {
return (<>조회 결과 ({filesOrigin.length.toLocaleString('ko-KR')})건</>);
})()
}
tableData={files}
headlist={REPORT_FILES_HEADLIST}
pageSize={10}
total={filesOrigin.length}
cellClick={handleCellClick}
onPageChange={setCurPage}
resetPageNo={isResetPage}
resetSelection={isResetSelection}
/>
</div>
</>
);
return (
<UiModal
open={isModalOpen}
onOpenChange={setIsModalOpen}
headerTitle=""
bodyClass={`
min-h-[50vh] max-h-[90dvh]
max-w-[750px]
h-auto w-auto
overflow-auto
z-[5000]
flex items-start
`}
body={renderBody()}
noFooterYn='Y'
/>
);
};
export default React.memo(ReportFilesModal);
@@ -22,7 +22,7 @@ import useStore from '@/store/useStore';
import { CtpvSggPrcsFcltSelectBox } from '@/components/ui/ctpvSggPrcsFcltSelectBox';
import useSearchModalStore from '@/store/useSearchModalStore';
import { useReportTabStore } from '@/store/useReportTabStore';
import ReportFilesModal from './reportFilesModal'; // 파일목록모달
import { openWindowPopup } from '@/features/windowPopup/lib/openWindowPopup';
// 하수도정비 기본계획 보고서
@@ -56,7 +56,6 @@ const ReportTab: React.FC<ReportTabProps> = ({ open, isSearch = true, ctpvNm, sg
const [ctpvCd, setCtpvCd] = useState('0');
const [sggCd, setSggCd] = useState('0');
const [reportGubun, setReportGubun] = useState("0");
const [clsfSystmSn, setClsfSystmSn] = useState(''); // 자료관 기본계획 일련번호
const {
tableData,
@@ -72,8 +71,6 @@ const ReportTab: React.FC<ReportTabProps> = ({ open, isSearch = true, ctpvNm, sg
} = useSearchModalStore();
const { ctpv, sgg } = useStore();
const {
isOpen: isReportFilesModalOpen,
setIsOpen: setIsReportFilesModalOpen,
dcTree: reportOriginData,
fetchDcTree
} = useReportTabStore(); // 모달 상태 관리 스토어
@@ -139,8 +136,14 @@ const ReportTab: React.FC<ReportTabProps> = ({ open, isSearch = true, ctpvNm, sg
if (typeof index === "number" && tableData[index]) {
const reportData = tableData[index];
if (reportData.fileSn !== "") {
setClsfSystmSn(reportData.clsfSystmSn); // 자료관 기본계획 일련번호 설정
setIsReportFilesModalOpen(true); // 모달 열기
const popupWindow = openWindowPopup("reportFiles", {
clsfSystmSn: reportData.clsfSystmSn,
sourcePath: window.location.pathname,
});
if (!popupWindow) {
alert('팝업이 차단되었습니다. 브라우저 팝업 허용 설정을 확인해주세요.');
}
}
}
};
@@ -263,10 +266,8 @@ const ReportTab: React.FC<ReportTabProps> = ({ open, isSearch = true, ctpvNm, sg
/>
</div>
</div>
{/* 모달 컴포넌트 */}
{isReportFilesModalOpen && <ReportFilesModal clsfSystmSn={clsfSystmSn} />}
</>
);
};
export default React.memo(ReportTab);
export default React.memo(ReportTab);
@@ -11,21 +11,26 @@
* - Zustand를 통한 검색 모달 상태 관리
* - 로딩 오버레이 및 선택 초기화 지원
*/
'use client';
import LoadingOverlay from '@/components/ui/LoadingBar';
import { UiModal, UITab, UiTable } from '@common/business_components/ui';
import { UITab, UiTable } from '@common/business_components/ui';
import { Button } from '@common/components/ui';
import React, { useEffect, useMemo } from 'react';
import React, { useEffect, useMemo, useRef } from 'react';
import { Toaster } from "react-hot-toast";
import useSearchModalStore from '@/store/useSearchModalStore';
import { ExclnSewagePumpCol2, MsSwageMhlCol2, MsSwagePplCol2, stofcbrCol2, SwtrmtFcltCol2, SwtrmtPrcsZoneCol2 } from '../components/common/table';
import BasicTab, { BASIC_TYPES } from './basicTab';
import OperationTab from './operationTab';
import ReportTab from './reportTab';
import useSearchModal from './useSearchModal';
import useBasicTab from './useBasicTab';
import useOperationTab from './useOperationTab';
import { openWindowPopup } from '@/features/windowPopup/lib/openWindowPopup';
const SearchModal: React.FC = () => {
type SearchModalProps = {
renderMode?: "launcher" | "popup";
};
const SearchModal: React.FC<SearchModalProps> = ({ renderMode = "launcher" }) => {
const {
showTable,
isSearchModalOpen, setIsSearchModalOpen,
@@ -59,6 +64,7 @@ const SearchModal: React.FC = () => {
operationResultExcelDownload,
handleOperationPageNation
} = useOperationTab();
const hasAutoOpenedReportRef = useRef(false);
const dynamicHeadlist: { id: string, title: string }[] = useMemo(() => {
@@ -87,6 +93,42 @@ const SearchModal: React.FC = () => {
return [];
}, [activeTab, workType]);
useEffect(() => {
if (renderMode !== "launcher" || !isSearchModalOpen) return;
const popupWindow = openWindowPopup("mapSearch", {
sourcePath: window.location.pathname,
});
if (!popupWindow) {
alert('팝업이 차단되었습니다. 브라우저 팝업 허용 설정을 확인해주세요.');
}
setIsSearchModalOpen(false);
}, [renderMode, isSearchModalOpen]);
const openReportTabPopup = () => {
const popupWindow = openWindowPopup("reportTab", {
sourcePath: window.location.pathname,
});
if (!popupWindow) {
alert('?앹뾽??李⑤떒?섏뿀?듬땲?? 釉뚮씪?곗? ?앹뾽 ?덉슜 ?ㅼ젙???뺤씤?댁<?몄슂.');
}
};
useEffect(() => {
if (activeTab !== 'report') {
hasAutoOpenedReportRef.current = false;
return;
}
if (hasAutoOpenedReportRef.current) return;
openReportTabPopup();
hasAutoOpenedReportRef.current = true;
}, [activeTab]);
useEffect(() => {
resetFilters();
}, [activeTab, basicType, workType]);
@@ -119,7 +161,23 @@ const SearchModal: React.FC = () => {
/>
{activeTab === 'basic' && (<BasicTab />)} {/* 기본정보 탭 */}
{activeTab === 'operation' && (<OperationTab />)} {/* 유지관리 정보 탭 */}
{activeTab === 'report' && (<ReportTab open={isSearchModalOpen} />)} {/* 하수도정비기본계획보고서 탭 */}
{activeTab === 'report' && (
<div className="mt-4 flex h-[79dvh] w-full items-center justify-center rounded-lg border bg-white p-6">
<div className="flex max-w-[520px] flex-col items-center text-center">
<h2 className="mb-3 text-2xl font-semibold text-gray-900">
하수도정비 기본계획보고서
</h2>
<p className="mb-5 text-lg leading-8 text-gray-600">
보고서 상세는 모달 내부 탭 대신
<br />
전용 윈도우 팝업으로 열리도록 전환되었습니다.
</p>
<Button className="px-6 py-2 text-white" onClick={openReportTabPopup}>
전용 팝업 열기
</Button>
</div>
</div>
)} {/* 하수도정비기본계획보고서 탭 */}
{/* 📋 결과 리스트 */}
@@ -214,22 +272,16 @@ const SearchModal: React.FC = () => {
</>
);
if (renderMode === "launcher") {
return null;
}
return (
<div className='overflow-x-auto overflow-y-hidden'>
<UiModal
open={isSearchModalOpen}
onOpenChange={setIsSearchModalOpen}
bodyClass={`
h-[100dvh]
w-[90dvw]
max-w-[1220px]
max-h-[970px]
overflow-y-hidden
z-[101]
`}
body={renderBody()}
useDirectRender={true}
/>
<div className='h-dvh overflow-x-auto overflow-y-hidden bg-white p-4'>
<h1 className="mb-3 text-2xl font-semibold text-gray-900">관망도 검색</h1>
<div className="h-[calc(100dvh-5.6rem)] w-full overflow-y-hidden rounded-lg border bg-white p-4">
{renderBody()}
</div>
<Toaster position="top-center"></Toaster>
</div>
);
@@ -31,6 +31,13 @@ import { swagePrcsZoneCqlFilters } from './swagePrcsZone';
import { swageMhlCqlFilters } from './swageMhl';
import { swagePmstnCqlFilters } from './swagePmstn';
import { stofcbrCqlFilters } from './stofcbr';
import { postPopupMessageAndClose } from '@/features/windowPopup/lib/popupMessage';
import { WINDOW_POPUP_MESSAGE_TYPE } from '@/features/windowPopup/model/types';
const isPopupWindow = () =>
typeof window !== "undefined" &&
!!window.opener &&
window.location.pathname.startsWith("/popup/");
export default function useBasicTab() {
@@ -115,9 +122,21 @@ export default function useBasicTab() {
// 기본정보 테이블 cellClick 핸들러
const handleBasicCellClick = async (index?: number | undefined, headId?: String) => {
if (typeof index === "number") {
const row = tableData[index];
if (isPopupWindow()) {
postPopupMessageAndClose(
WINDOW_POPUP_MESSAGE_TYPE.MAP_SEARCH_SELECT_FEATURE,
{
layerNm: row.lyrTypeNm,
fid: String(row.fid),
},
);
return;
}
setIsLoading(true);
const row = tableData[index];
const layerNm = row.lyrTypeNm;
const fid = row.fid;
const core = GisApp.Module.layer as any;
@@ -23,6 +23,13 @@ import { confirmToast } from '@/components/ui/toast'
import { toast } from "react-hot-toast";
import useSearchModalStore from '@/store/useSearchModalStore';
import useStore from '@/store/useStore';
import { postPopupMessageAndClose } from '@/features/windowPopup/lib/popupMessage';
import { WINDOW_POPUP_MESSAGE_TYPE } from '@/features/windowPopup/model/types';
const isPopupWindow = () =>
typeof window !== "undefined" &&
!!window.opener &&
window.location.pathname.startsWith("/popup/");
export default function useOperationTab() {
@@ -200,9 +207,21 @@ export default function useOperationTab() {
// 유지관리 정보 디테일 테이블 cellClick 핸들러
const handleOperationResultCellClick = async (index?: number | undefined, headId?: String) => {
if (typeof index === "number") {
const row = detailTableData[index];
if (isPopupWindow()) {
postPopupMessageAndClose(
WINDOW_POPUP_MESSAGE_TYPE.MAP_SEARCH_SELECT_FEATURE,
{
layerNm: row.lyrTypeNm,
fid: String(row.fid),
},
);
return;
}
setIsLoading(true);
const row = detailTableData[index];
const layerNm = row.lyrTypeNm;
const fid = row.fid;
const core = GisApp.Module.layer as any;
@@ -434,6 +453,20 @@ export default function useOperationTab() {
}
};
const emitMultipleFeaturesToMap = () => {
if (detailTableData.length === 0 || !isPopupWindow()) return false;
const row = detailTableData[0];
postPopupMessageAndClose(
WINDOW_POPUP_MESSAGE_TYPE.MAP_SEARCH_SHOW_MULTIPLE,
{
layerNm: row.lyrTypeNm,
fids: JSON.stringify(detailTableData.map((item) => item.fid)),
},
);
return true;
};
return {
WORK_TYPES,
@@ -446,7 +479,8 @@ export default function useOperationTab() {
handleOperationCellClick,
handleOperationResultCellClick,
handleOperationPageNation,
emitMultipleFeaturesToMap,
operationExcelDownload,
operationResultExcelDownload
};
};
};
@@ -19,6 +19,13 @@ import GisApp from "@common/business_components/ui/map/lib/gis.core";
import useSearchModalStore from '../../../store/useSearchModalStore';
import useBasicTab from './useBasicTab';
import useOperationTab from './useOperationTab';
import { postPopupMessageAndClose } from '@/features/windowPopup/lib/popupMessage';
import { WINDOW_POPUP_MESSAGE_TYPE } from '@/features/windowPopup/model/types';
const isPopupWindow = () =>
typeof window !== "undefined" &&
!!window.opener &&
window.location.pathname.startsWith("/popup/");
export default function useSearchModal() {
const {
@@ -132,6 +139,18 @@ export default function useSearchModal() {
const viewMultipleFeature = async () => {
if (activeTab === 'basic') {
if (!(tableData.length === 0)) {
if (isPopupWindow()) {
const row = tableData[0];
postPopupMessageAndClose(
WINDOW_POPUP_MESSAGE_TYPE.MAP_SEARCH_SHOW_MULTIPLE,
{
layerNm: row.lyrTypeNm,
fids: JSON.stringify(tableData.map((item) => item.fid)),
},
);
return;
}
setIsLoading(true);
const row = tableData[0];
@@ -144,6 +163,18 @@ export default function useSearchModal() {
}
} else if (activeTab === 'operation') {
if (!(detailTableData.length === 0)) {
if (isPopupWindow()) {
const row = detailTableData[0];
postPopupMessageAndClose(
WINDOW_POPUP_MESSAGE_TYPE.MAP_SEARCH_SHOW_MULTIPLE,
{
layerNm: row.lyrTypeNm,
fids: JSON.stringify(detailTableData.map((item) => item.fid)),
},
);
return;
}
setIsLoading(true);
const row = detailTableData[0];
+11
View File
@@ -0,0 +1,11 @@
export default function PopupLayout({
children,
}: {
children: React.ReactNode;
}) {
return (
<main className="min-h-dvh bg-white text-foreground">
{children}
</main>
);
}
@@ -0,0 +1,30 @@
import { isWindowPopupKey } from "@/features/windowPopup/model/popupDefinitions";
import { windowPopupRegistry } from "@/features/windowPopup/registry";
type PopupPageProps = {
params: Promise<{
popupKey: string;
}>;
searchParams: Promise<Record<string, string | string[] | undefined>>;
};
export default async function PopupPage({
params,
searchParams,
}: PopupPageProps) {
const { popupKey } = await params;
const resolvedSearchParams = await searchParams;
if (!isWindowPopupKey(popupKey)) {
return (
<section className="flex min-h-dvh flex-col items-center justify-center gap-4 p-8 text-center">
<h1 className="text-2xl font-semibold">등록되지 않은 팝업입니다.</h1>
<p className="text-base text-gray-600">popupKey: {popupKey}</p>
</section>
);
}
const PopupComponent = windowPopupRegistry[popupKey].component;
return <PopupComponent searchParams={resolvedSearchParams} />;
}
@@ -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,22 +17,20 @@
* - 첨부파일 삭제및 Soft Delete 삭제: DELETE /api/v1/atfl/attachments/{atflId} (attach-service)
*/
'use client'
import { deleteData, postData, useCookieAuth } from '@/components/lib/api'
import { deleteData, useCookieAuth } from '@/components/lib/api'
import { searchData } from '@/components/lib/common/apiHelpers'
import { formatDate, handleDownload } from '@/components/lib/utils'
import LoadingOverlay from '@/components/ui/LoadingBar'
import { SearchDiv, SearchFormLeft, SearchFormRight, UiTable, UiModal } from '@common/business_components/ui'
import { SearchDiv, SearchFormLeft, SearchFormRight, UiTable } from '@common/business_components/ui'
import ListDiv from '@common/business_components/ui/list_div'
import { Button, DatePicker, Input, Label } from '@common/components/ui'
import { useEffect, useState } from 'react'
import { tableHeadlist } from '..'
import OperFileUploadForm from '../../../components/ui/operFileUploadForm'
import { tokenManager } from '@/entities/auth/lib/token-manager';
import { AUTH_USER_ID } from "@/shared/config";
import { useQuery } from "@tanstack/react-query";
import { ENDPOINT } from "@common/components/config";
import { getUserInfo } from "@common/components/api";
import TableModal from './tableModal';
import { openWindowPopup } from '@/features/windowPopup/lib/openWindowPopup';
import { WINDOW_POPUP_MESSAGE_TYPE } from '@/features/windowPopup/model/types';
const Page = () => {
@@ -51,8 +49,6 @@ const Page = () => {
const [curPage, setCurPage] = useState(1) //현재 페이지
const [totalData, setTotalData] = useState(0) //총 데이터 수
const [isLoading, setIsLoading] = useState(true) //로딩 상태
const [isUploadModalOpen, setIsUploadModalOpen] = useState(false) // 등록 모달 상태
const [isTableModalOpen, setIsTableModalOpen] = useState(false) // 테이블 모달 상태
const [resetSelection, setResetSelection] = useState<boolean>(false); // 선택 초기화 상태
const [isResetPage, setIsResetPage] = useState<boolean>(false);
@@ -67,6 +63,17 @@ const Page = () => {
fetchData()
}, [])
useEffect(() => {
const handleMessage = (event: MessageEvent) => {
if (event.origin !== window.location.origin) return;
if (event.data?.type !== WINDOW_POPUP_MESSAGE_TYPE.FLOOD_PRE_UPLOAD_COMPLETED) return;
handleSearch();
};
window.addEventListener("message", handleMessage);
return () => window.removeEventListener("message", handleMessage);
}, [fromDate, toDate, pjNm, curPage]);
// 페이지 변경 시마다 데이터 fetch
useEffect(() => {
setIsResetPage(false);
@@ -140,27 +147,24 @@ const Page = () => {
}
}
// 현황 등록
const registFile = async (fileName: string, year: string, quater: string, cd: string, id: string) => {
const json = {
mngYr: year,
mngQu: quater,
atflId: id,
atflCd: cd,
rgtr: tokenManager.getToken(AUTH_USER_ID) || null,
sbmrgPrevntBizNm: fileName
}
const data = await postData('v1/sbmrgPrevnt', json)
if (data) {
alert('파일이 등록되었습니다.');
setIsUploadModalOpen(false);
handleSearch();
// 등록 버튼 클릭 핸들러
const handleRegisterClick = () => {
const popupWindow = openWindowPopup("floodPreUpload");
if (!popupWindow) {
alert('팝업이 차단되었습니다. 브라우저 팝업 허용 설정을 확인해주세요.');
}
}
// 등록 버튼 클릭 핸들러
const handleRegisterClick = () => {
setIsUploadModalOpen(true);
const handleTablePopupOpen = (fileName: string, id: string) => {
const popupWindow = openWindowPopup("floodPreTable", {
fileNm: fileName,
atflId: id,
sourcePath: window.location.pathname,
});
if (!popupWindow) {
alert('팝업이 차단되었습니다. 브라우저 팝업 허용 설정을 확인해주세요.');
}
}
@@ -246,7 +250,7 @@ const Page = () => {
setFileNm(fileName);
setAtflId(id);
setSbmrgPrevntBizSn(tableData[index]?.sbmrgPrevntBizSn);
setIsTableModalOpen(true);
handleTablePopupOpen(fileName, id);
}
}}
onPageChange={setCurPage}
@@ -254,40 +258,8 @@ const Page = () => {
resetPageNo={isResetPage}
/>
</ListDiv>
{/* 파일 업로드 모달 */}
<UiModal
open={isUploadModalOpen}
onOpenChange={setIsUploadModalOpen}
bodyClass="w-auto max-w-[90vw] min-w-[600px] h-auto"
noHeaderYn="N"
noFooterYn="Y"
body={
<OperFileUploadForm
title='도시침수 예방사업 추진현황 등록'
onSubmit={async (file, year, quater, cd, id) => {
await registFile(file, year, quater, cd, id)
}}
/>
}
/>
{/* 엑셀 데이터 테이블 모달 */}
<UiModal
open={isTableModalOpen}
onOpenChange={setIsTableModalOpen}
bodyClass={`
h-[100dvh]
w-[90dvw]
max-w-[100dvw]
max-h-[100dvh]
overflow-hidden
z-[101]
flex flex-col gap-[10px]
`}
body={<TableModal fileNm={fileNm} atflId={atflId} />}
useDirectRender={true}
/>
</>
)
}
export default Page
export default Page
@@ -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,13 @@ 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('파일을 가져오는 중 오류가 발생했습니다.');
if (atflId === '' || atflId === undefined) {
alert('첨부파일 ID가 없어 파일을 불러올 수 없습니다.');
setIsLoading(false);
return false;
}
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'
@@ -114,6 +116,8 @@ const TableModal: React.FC<TableModalProps> = ({ fileNm, atflId }) => {
const filteredResultData = filterEmptyRows(resultData);
setResultTableData(filteredResultData);
} catch (error) {
const message = error instanceof Error ? error.message : 'Unknown error';
console.error('FloodPre attachment fetch details:', { atflId, fileNm, message });
console.error('파일 처리 중 오류 발생:', error);
alert('파일 처리 중 오류가 발생했습니다.');
} finally {
@@ -267,4 +271,4 @@ const TableModal: React.FC<TableModalProps> = ({ fileNm, atflId }) => {
)
};
export default TableModal;
export default TableModal;
@@ -17,22 +17,20 @@
* - 첨부파일 삭제및 Soft Delete 삭제: DELETE /api/v1/atfl/attachments/{atflId} (attach-service)
*/
'use client'
import { deleteData, postData, useCookieAuth } from '@/components/lib/api'
import { deleteData, useCookieAuth } from '@/components/lib/api'
import { searchData } from '@/components/lib/common/apiHelpers'
import { formatDate, handleDownload } from '@/components/lib/utils'
import LoadingOverlay from '@/components/ui/LoadingBar'
import { SearchDiv, SearchFormLeft, SearchFormRight, UiTable, UiModal } from '@common/business_components/ui'
import { SearchDiv, SearchFormLeft, SearchFormRight, UiTable } from '@common/business_components/ui'
import ListDiv from '@common/business_components/ui/list_div'
import { Button, DatePicker, Input, Label } from '@common/components/ui'
import { useEffect, useState } from 'react'
import { tableHeadlistOldPp } from '..'
import OperFileUploadForm from '../../../components/ui/operFileUploadForm'
import { tokenManager } from '@/entities/auth/lib/token-manager';
import { AUTH_USER_ID } from "@/shared/config";
import { useQuery } from "@tanstack/react-query";
import { ENDPOINT } from "@common/components/config";
import { getUserInfo } from "@common/components/api";
import TableModal from './tableModal';
import { openWindowPopup } from '@/features/windowPopup/lib/openWindowPopup';
import { WINDOW_POPUP_MESSAGE_TYPE } from '@/features/windowPopup/model/types';
const Page = () => {
@@ -49,8 +47,6 @@ const Page = () => {
const [curPage, setCurPage] = useState(1)
const [totalData, setTotalData] = useState(0) //총 데이터 수
const [isLoading, setIsLoading] = useState(true) //로딩 상태
const [isModalOpen, setIsModalOpen] = useState(false) // 등록 모달 상태
const [isTableModalOpen, setIsTableModalOpen] = useState(false) // 테이블 모달 상태
const [resetSelection, setResetSelection] = useState<boolean>(false); // 선택 초기화 상태
const [isResetPage, setIsResetPage] = useState<boolean>(false);
@@ -65,6 +61,17 @@ const Page = () => {
fetchData()
}, [])
useEffect(() => {
const handleMessage = (event: MessageEvent) => {
if (event.origin !== window.location.origin) return;
if (event.data?.type !== WINDOW_POPUP_MESSAGE_TYPE.OLD_PP_UPLOAD_COMPLETED) return;
handleSearch();
};
window.addEventListener("message", handleMessage);
return () => window.removeEventListener("message", handleMessage);
}, [fromDate, toDate, pjNm, curPage]);
// 페이지 변경 시마다 데이터 fetch
useEffect(() => {
setIsResetPage(false);
@@ -137,27 +144,25 @@ const Page = () => {
}
}
// 파일 등록
const registFile = async (fileNm: string, year: string, quater: string, cd: string, id: string) => {
const json = {
mngYr: year,
mngQu: quater,
atflCd: cd,
atflId: id,
rgtr: tokenManager.getToken(AUTH_USER_ID) || null,
dcrpPplMtncBizNm: fileNm
}
const data = await postData('v1/dcrpPpl', json);
if (data) {
alert('파일이 등록되었습니다.');
setIsModalOpen(false);
handleSearch();
// 등록 버튼 클릭 핸들러
const handleRegisterClick = () => {
const popupWindow = openWindowPopup("oldPpUpload");
if (!popupWindow) {
alert('팝업이 차단되었습니다. 브라우저 팝업 허용 설정을 확인해주세요.');
}
}
// 등록 버튼 클릭 핸들러
const handleRegisterClick = () => {
setIsModalOpen(true);
// 엑셀 데이터는 브라우저 윈도우 팝업에서 독립 조회
const handleTablePopupOpen = (fileName: string, id: string) => {
const popupWindow = openWindowPopup("oldPpTable", {
fileNm: fileName,
atflId: id,
sourcePath: window.location.pathname,
});
if (!popupWindow) {
alert('팝업이 차단되었습니다. 브라우저 팝업 허용 설정을 확인해주세요.');
}
}
@@ -242,47 +247,15 @@ const Page = () => {
setFileNm(fileName);
setAtflId(id);
setDcrpPplMtncBizSn(tableData[index]?.dcrpPplMtncBizSn);
setIsTableModalOpen(true);
handleTablePopupOpen(fileName, id);
}
}}
onPageChange={setCurPage}
resetSelection={resetSelection}
/>
</ListDiv>
{/* 파일 업로드 모달 */}
<UiModal
open={isModalOpen}
onOpenChange={setIsModalOpen}
bodyClass="w-auto max-w-[90vw] min-w-[600px] h-auto"
noHeaderYn="N"
noFooterYn="Y"
body={
<OperFileUploadForm
title='노후 하수관로 정비사업 추진현황 등록'
onSubmit={async (file, year, quater, cd, id) => {
await registFile(file, year, quater, cd, id)
}}
/>
}
/>
{/* 엑셀 데이터 테이블 모달 */}
<UiModal
open={isTableModalOpen}
onOpenChange={setIsTableModalOpen}
bodyClass={`
h-[100dvh]
w-[90dvw]
max-w-[100dvw]
max-h-[100dvh]
overflow-hidden
z-[101]
flex flex-col gap-[10px]
`}
body={<TableModal fileNm={fileNm} atflId={atflId} />}
useDirectRender={true}
/>
</>
)
}
export default Page
export default Page
@@ -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,13 @@ 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('파일을 가져오는 중 오류가 발생했습니다.');
if (atflId === '' || atflId === undefined) {
alert('첨부파일 ID가 없어 파일을 불러올 수 없습니다.');
setIsLoading(false);
return false;
}
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'
@@ -246,6 +248,8 @@ const TableModal: React.FC<TableModalProps> = ({ fileNm, atflId }) => {
}));
setTableData6(filteredData6);
} catch (error) {
const message = error instanceof Error ? error.message : 'Unknown error';
console.error('OldPp attachment fetch details:', { atflId, fileNm, message });
console.error('파일 처리 중 오류 발생:', error);
alert('파일 처리 중 오류가 발생했습니다.');
} finally {
@@ -476,4 +480,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)); // 공단 환경
+122 -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,81 @@ 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) {
const errorText = await res.text().catch(() => "");
throw new Error(
`Failed to fetch data: ${res.status} ${res.statusText}${errorText ? ` - ${errorText.slice(0, 200)}` : ""}`,
);
}
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: ${url}`);
}
};
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) {
const errorText = await res.text().catch(() => "");
throw new Error(
`Failed to fetch blob: ${res.status} ${res.statusText}${errorText ? ` - ${errorText.slice(0, 200)}` : ""} (${url})`,
);
}
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 +219,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 +235,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 +270,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);
}
}
-45
View File
@@ -1,45 +0,0 @@
/**
* 📄 Modal Component
* 모달 컴포넌트
*
* - 모달 열기/닫기 상태 관리
* - 제목 및 내용 표시
* - 저장 및 닫기 버튼 지원
* - 반응형 크기 조정 (최대/최소 너비 및 높이)
* - 오버레이 및 z-index를 통한 최상위 레이어 배치
* - 접근성 지원 (aria-describedby)
*/
import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogFooter, DialogOverlay } from '@common/components/ui'
import React from 'react'
interface ModalProps {
isModalOpen: boolean;
setIsModalOpen: (open: boolean) => void;
title?: string;
children: React.ReactNode;
closeModal: () => void;
saveModal?: () => void;
description?: string;
}
const Modal: React.FC<ModalProps> = ({ isModalOpen, setIsModalOpen, title, children, closeModal, saveModal }) => {
return (
<Dialog open={isModalOpen} onOpenChange={setIsModalOpen}>
<DialogOverlay className='z-[200]' />
<DialogContent className="w-auto max-w-[1200px] min-w-[400px] min-h-[150px] h-auto max-h-[450px] overflow-auto z-[1000]" aria-describedby={undefined}>
{title ? <DialogHeader>
<DialogTitle>{title}</DialogTitle>
</DialogHeader> : null}
{children}
<DialogFooter>
<div className='flex justify-end gap-2'>
{saveModal ? <button className="px-4 py-2 bg-primary text-white rounded" onClick={saveModal}>저장</button> : null}
<button className="px-4 py-2 bg-gray-600 text-white rounded" onClick={closeModal}>닫기</button>
</div>
</DialogFooter>
</DialogContent>
</Dialog>
)
}
export default Modal
@@ -1,6 +1,7 @@
"use client";
import { Button } from "@common/components";
import { VerticalTable } from "@/components/ui/verticalTable";
import {
AGED_PIPE_DETAIL_FIELDS,
getElapsedYears,
@@ -16,39 +17,48 @@ export default function AgedPipeDetailPanel({ featureData, onClose }: Props) {
const source = featureData?.values_ ?? featureData ?? {};
const facilityNo = String(getPipeDisplayValue(source, "fclt_mng_no") ?? "-");
const elapsedYears = getElapsedYears(source.instl_ymd ?? source.instlYmd);
const detailFields = [
...AGED_PIPE_DETAIL_FIELDS,
{ id: "elapsedYears", title: "경과년도" },
];
const tableData = detailFields.reduce<Record<string, string>>((acc, field) => {
acc[field.id] =
field.id === "elapsedYears"
? elapsedYears === null
? "확인불가"
: `${elapsedYears}년`
: String(getPipeDisplayValue(source, field.id) ?? "-");
return acc;
}, {});
return (
<div className="absolute right-0 top-0 z-[50] flex h-[calc(100vh-5rem)] w-[320px] flex-col overflow-hidden border-l border-slate-200 bg-white shadow-[-12px_0_24px_rgba(15,23,42,0.1)]">
<div className="flex items-start justify-between border-b border-slate-200 bg-slate-900 px-4 py-3">
<div className="absolute right-0 top-0 z-[50] flex h-[calc(100vh-5rem)] w-[350px] flex-col overflow-hidden border-l border-gray-300 bg-white">
<div className="card-header relative flex items-center justify-between bg-primary p-4">
<div>
<div className="text-[1.45rem] font-semibold text-white">노후관로 상세</div>
<div className="mt-1 text-[1.05rem] text-slate-300">시설관리번호 {facilityNo}</div>
<div className="mt-1 text-[1.05rem] text-amber-300">
경과년도 {elapsedYears ?? "-"}년
<div className="text-2xl font-medium text-white text-label">
하수관로 정보 <br />관리번호 : {facilityNo}
</div>
</div>
<Button
type="button"
color="white"
size="xs"
className="border border-white/30 px-3 py-1 text-[1.05rem] text-white"
className="text-white hover:text-gray-300"
onClick={onClose}
>
닫기
✕
</Button>
</div>
<div className="overflow-y-auto px-4 py-3">
<div className="space-y-2">
{AGED_PIPE_DETAIL_FIELDS.map((field) => (
<div key={field.id} className="rounded-lg border border-slate-200 px-3 py-2.5">
<div className="text-[1rem] font-medium text-slate-500">{field.title}</div>
<div className="mt-1 break-words text-[1.15rem] leading-6 text-slate-900">
{String(getPipeDisplayValue(source, field.id) ?? "-")}
</div>
</div>
))}
</div>
<div className="flex-1 overflow-y-auto overflow-x-hidden">
<VerticalTable
headName="기본정보"
tableData={[tableData]}
headlist={detailFields}
pageSize={20}
total={0}
cellClick={undefined}
onPageChange={() => undefined}
/>
</div>
</div>
);
@@ -1,7 +1,6 @@
"use client";
import { Button, Input } from "@common/components";
import useStore from "@/store/useStore";
import { useState } from "react";
import type { AgedPipeFilters } from "./agedPipeUtils";
type OverlayProps = {
@@ -13,89 +12,62 @@ type OverlayProps = {
};
type Props = OverlayProps & {
title?: string;
filters: AgedPipeFilters;
onFiltersChange: (filters: AgedPipeFilters) => void;
onApply: () => void;
};
export default function AgedPipeFilterPanel({
title = "지반침하정보",
filters,
onFiltersChange,
onApply,
}: Props) {
const { ctpv, sgg } = useStore();
const sggOptions = filters.ctpvCd === "0"
? sgg
: sgg.filter((item) => String(item.val).startsWith(filters.ctpvCd.slice(0, 2)));
const [isCollapsed, setIsCollapsed] = useState(false);
return (
<div className="w-[280px] rounded-xl border border-slate-200 bg-white/95 p-3 shadow-[0_14px_30px_rgba(15,23,42,0.16)] backdrop-blur">
<div className="mb-2">
<div className="text-[1.35rem] font-semibold text-slate-900">노후관로 기준</div>
<div className="mt-1 text-[1.05rem] leading-5 text-slate-500">
경과년수 이상 관로만 지도와 목록에 반영합니다.
<div className="overflow-hidden rounded-sm border border-[#bdbdbd] bg-white">
<div className="relative flex min-h-[34px] items-center justify-center border-b border-[#eeeeee] bg-white px-8">
<button
type="button"
className="absolute left-2 top-1/2 h-[18px] w-[18px] -translate-y-1/2 bg-transparent text-[1.2rem] leading-none text-[#555]"
onClick={() => setIsCollapsed((value) => !value)}
title={isCollapsed ? "펼치기" : "접기"}
>
{isCollapsed ? "›" : "⌄"}
</button>
<span className="text-[1.3rem] font-semibold text-[#4b5563]">{title}</span>
</div>
{!isCollapsed && (
<div className="px-3 py-2">
<div className="flex min-h-[30px] items-center gap-1 whitespace-nowrap text-[1.2rem] text-[#333]">
<label htmlFor="aged-pipe-elapsed-years">경과년도(년)</label>
<input
id="aged-pipe-elapsed-years"
className="h-[22px] w-10 border border-[#ccd6df] px-1 text-center text-[1.2rem]"
inputMode="numeric"
value={String(filters.elapsedYears)}
onChange={(event) => {
const next = Number(event.target.value.replace(/[^0-9]/g, "") || "0");
onFiltersChange({ ...filters, elapsedYears: next });
}}
onKeyDown={(event) => {
if (event.key === "Enter") onApply();
}}
/>
<button
type="button"
className="grid h-[22px] w-[22px] place-items-center rounded-full bg-[#31a83b] text-[1.4rem] font-bold leading-none text-white"
onClick={onApply}
title="적용"
>
✓
</button>
</div>
</div>
</div>
<div className="space-y-2.5">
<label className="block">
<div className="mb-1 text-[1.05rem] font-medium text-slate-700">경과년수</div>
<Input
className="h-10 !text-[1.15rem]"
inputMode="numeric"
value={String(filters.elapsedYears)}
onChange={(event) => {
const next = Number(event.target.value.replace(/[^0-9]/g, "") || "0");
onFiltersChange({ ...filters, elapsedYears: next });
}}
onKeyDown={(event) => {
if (event.key === "Enter") onApply();
}}
/>
</label>
<label className="block">
<div className="mb-1 text-[1.05rem] font-medium text-slate-700">시도</div>
<select
className="h-10 w-full rounded-md border border-slate-300 px-3 text-[1.1rem]"
value={filters.ctpvCd}
onChange={(event) =>
onFiltersChange({
...filters,
ctpvCd: event.target.value,
sggCd: "0",
})
}
>
<option value="0">전체</option>
{ctpv.map((item) => (
<option key={item.val} value={String(item.val)}>
{item.text}
</option>
))}
</select>
</label>
<label className="block">
<div className="mb-1 text-[1.05rem] font-medium text-slate-700">시군구</div>
<select
className="h-10 w-full rounded-md border border-slate-300 px-3 text-[1.1rem]"
value={filters.sggCd}
onChange={(event) => onFiltersChange({ ...filters, sggCd: event.target.value })}
>
<option value="0">전체</option>
{sggOptions.map((item) => (
<option key={item.val} value={String(item.val)}>
{item.text}
</option>
))}
</select>
</label>
</div>
<Button className="mt-3 h-10 w-full !text-[1.15rem]" onClick={onApply}>
GIS 반영
</Button>
)}
</div>
);
}
@@ -64,14 +64,11 @@ export default function AgedPipeListModal({
return (
<div className="pointer-events-none absolute inset-0 z-[70] flex items-center justify-center px-6 py-8">
<div className="pointer-events-auto relative flex max-h-[82vh] w-[min(1120px,94vw)] flex-col overflow-hidden rounded-2xl border border-slate-300 bg-white shadow-[0_28px_80px_rgba(15,23,42,0.28)]">
<div className="pointer-events-auto relative flex max-h-[82vh] w-[min(1120px,94vw)] flex-col overflow-hidden border border-[#dbe2e6] bg-white shadow-[0_0_12px_rgba(0,0,0,0.25)]">
<LoadingOverlay isLoading={isLoading} />
<div className="flex items-center justify-between border-b border-slate-200 bg-slate-900 px-5 py-3">
<div className="flex items-center justify-between border-b border-[#dbe2e6] bg-primary px-5 py-3">
<div>
<div className="text-[1.45rem] font-semibold text-white">노후관로 목록</div>
<div className="mt-1 text-[1rem] text-slate-300">
지도 위에 띄워지는 윈도우형 팝업 미리보기
</div>
</div>
<Button
type="button"
@@ -85,7 +82,7 @@ export default function AgedPipeListModal({
</div>
<div className="relative overflow-y-auto p-4">
<div className="grid grid-cols-1 gap-3 pb-4 md:grid-cols-[110px_minmax(0,1fr)_110px_minmax(0,1fr)_auto_auto]">
<div className="grid grid-cols-1 gap-3 bg-slate-50 pb-4 md:grid-cols-[110px_minmax(0,1fr)_110px_minmax(0,1fr)_auto_auto]">
<label className="block">
<div className="mb-1 text-[1.05rem] font-medium text-slate-700">경과년수</div>
<Input
@@ -141,7 +138,7 @@ export default function AgedPipeListModal({
</label>
<div className="flex flex-wrap gap-2 md:col-span-3 md:justify-end">
<Button type="button" size="sm" className="h-10 px-5 !text-[1.1rem]" onClick={() => onSearch(1)}>
<Button type="button" size="sm" color="primary" className="h-10 px-5 !text-[1.1rem]" onClick={() => onSearch(1)}>
조회
</Button>
<Button
@@ -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 { getData, 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);
@@ -333,12 +321,12 @@ const Map = () => {
};
}, [appliedFilters, mapReady]);
const FilterOverlay = useMemo(
const LayerSettingView = useMemo(
() =>
function FilterOverlayComponent(props: any) {
function LayerSettingComponent() {
return (
<AgedPipeFilterPanel
{...props}
title="지반침하정보"
filters={draftFilters}
onFiltersChange={setDraftFilters}
onApply={handleApplyFilters}
@@ -368,7 +356,7 @@ const Map = () => {
setIsSearchModalOpen={setIsSearchModalOpen}
onCheckedLayersChange={() => undefined}
searchBar={<></>}
waterDepth={FilterOverlay}
layerSettingView={LayerSettingView}
showZoomLevel={true}
defaultZoom={7.5}
defaultLonLat={[127.75, 36]}
@@ -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
@@ -23,7 +23,6 @@
*/
'use client'
import { getData, postData } from "@/components/lib/api";
import Modal from "@/components/ui/modal";
import { VerticalTable } from "@/components/ui/verticalTable";
import { useGisContext } from "@common/business_components/ui/map/Gis";
import { Button } from "@common/components/ui";
@@ -74,7 +73,6 @@ const FeatureClickEventPopup = ({ onClose, featureData }: { onClose: () => void,
const [buttonChk, setButtonChk] = useState(true)
const [fetchData, setFetchData] = useState<any>(); // api 호출여부
const contentRef = useRef<HTMLDivElement>(null);
const [isModalOpen, setIsModalOpen] = useState(false); // 모달 열기 상태
const [isMainSsc, setIsMainSsc] = useState(false); // 중점우수토실 여부
const [isDrtwSewagePump, setIsDrtwSewagePump] = useState(false); // 오수펌프장 여부
// 🎯 InfilChart 및 RdiiChart 관련 상태 추가
@@ -459,18 +457,11 @@ const FeatureClickEventPopup = ({ onClose, featureData }: { onClose: () => void,
};
}, [featureData, gisMap]);
// 저장 시 모달 띄우기
// Use the browser confirm dialog for lightweight save confirmation.
const handleSaveClick = () => {
setIsModalOpen(true); // 모달 열기
};
const handleModalSave = () => {
setIsModalOpen(false); // 모달 닫기
btnClickEvent(); // 저장 이벤트 실행
};
const handleModalClose = () => {
setIsModalOpen(false); // 모달 닫기
const shouldSave = window.confirm('정보를 저장하시겠습니까?');
if (!shouldSave) return;
void btnClickEvent();
};
// 저장 버튼 이벤트
@@ -617,7 +608,6 @@ const FeatureClickEventPopup = ({ onClose, featureData }: { onClose: () => void,
total2={0}
isEditable2={buttonChk}
/>
<Modal isModalOpen={isModalOpen} title="자체입력정보 저장" setIsModalOpen={setIsModalOpen} closeModal={handleModalClose} saveModal={handleModalSave}><div className="text-lg">정보를 저장하시겠습니까?</div></Modal>
{ContentComponent &&
<ContentComponent
featureData={fetchData}
@@ -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);
File diff suppressed because it is too large Load Diff
+18 -2
View File
@@ -12,7 +12,11 @@
* - 앱 전체의 공통 스타일 및 스크립트 적용
*/
import type { Metadata } from "next";
import { App } from "@/app";
import "@fontsource-variable/noto-sans-kr";
import { cookies } from "next/headers";
import { AppProvider } from "./providers";
import "../src/app/styles/base.css";
import "../src/app/styles/components.css";
export const metadata: Metadata = {
title: "디지털 하수관로",
@@ -24,4 +28,16 @@ export const metadata: Metadata = {
}
};
export default App;
export default async function RootLayout({
children,
}: Readonly<{ children: React.ReactNode }>) {
const theme = (await cookies()).get("site-theme-key");
return (
<html lang="ko" data-theme={theme?.value}>
<body>
<AppProvider>{children}</AppProvider>
</body>
</html>
);
}
+1
View File
@@ -0,0 +1 @@
export { AppProvider } from "../src/app/providers";
Binary file not shown.

After

Width:  |  Height:  |  Size: 132 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 14 KiB

-4
View File
@@ -1,7 +1,3 @@
import "./styles/base.css";
import "./styles/components.css";
import "@fontsource-variable/noto-sans-kr";
import { AppProvider } from "./providers";
import { cookies } from "next/headers";
+4
View File
@@ -10,3 +10,7 @@ body {
font-family: "Noto Sans KR", sans-serif;
font-size: 10px;
}
body.sn-map-page-lock {
overflow: hidden;
}
+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 */
+14
View File
@@ -0,0 +1,14 @@
export { openWindowPopup } from "./lib/openWindowPopup";
export {
isWindowPopupKey,
windowPopupDefinitions,
type WindowPopupKey,
} from "./model/popupDefinitions";
export { windowPopupRegistry } from "./registry";
export type {
OpenWindowPopupParams,
WindowPopupContentProps,
WindowPopupMessageType,
WindowPopupSearchParams,
} from "./model/types";
export { WINDOW_POPUP_MESSAGE_TYPE } from "./model/types";
@@ -0,0 +1,61 @@
import {
isWindowPopupKey,
windowPopupDefinitions,
type WindowPopupKey,
} from "../model/popupDefinitions";
import type { OpenWindowPopupParams } from "../model/types";
const buildQueryString = (params: OpenWindowPopupParams = {}) => {
const searchParams = new URLSearchParams();
Object.entries(params).forEach(([key, value]) => {
if (value === null || value === undefined || value === "") return;
searchParams.set(key, String(value));
});
const queryString = searchParams.toString();
return queryString ? `?${queryString}` : "";
};
const buildPopupFeatures = (width: number, height: number) => {
const screenLeft = window.screenX ?? window.screenLeft ?? 0;
const screenTop = window.screenY ?? window.screenTop ?? 0;
const outerWidth = window.outerWidth || window.screen.width;
const outerHeight = window.outerHeight || window.screen.height;
const left = Math.max(0, screenLeft + Math.round((outerWidth - width) / 2));
const top = Math.max(0, screenTop + Math.round((outerHeight - height) / 2));
return [
`width=${width}`,
`height=${height}`,
`left=${left}`,
`top=${top}`,
"resizable=yes",
"scrollbars=yes",
"status=no",
"location=no",
"menubar=no",
"toolbar=no",
].join(",");
};
export const openWindowPopup = (
popupKey: WindowPopupKey,
params?: OpenWindowPopupParams,
) => {
if (typeof window === "undefined") return null;
if (!isWindowPopupKey(popupKey)) return null;
const definition = windowPopupDefinitions[popupKey];
const popupUrl = `/popup/${popupKey}${buildQueryString(params)}`;
const popupFeatures = buildPopupFeatures(definition.width, definition.height);
const popupWindow = window.open(
popupUrl,
`sn-popup-${popupKey}`,
popupFeatures,
);
popupWindow?.focus();
return popupWindow;
};
@@ -0,0 +1,24 @@
import type { WindowPopupMessageType } from "../model/types";
export const postPopupMessage = (
type: WindowPopupMessageType,
payload?: Record<string, string>,
) => {
if (typeof window === "undefined" || !window.opener) return;
window.opener.postMessage(
{
type,
payload,
},
window.location.origin,
);
};
export const postPopupMessageAndClose = (
type: WindowPopupMessageType,
payload?: Record<string, string>,
) => {
postPopupMessage(type, payload);
window.close();
};
@@ -0,0 +1,82 @@
import type { WindowPopupDefinition } from "./types";
export const windowPopupDefinitions = {
oldPpUpload: {
key: "oldPpUpload",
title: "노후 하수관로 정비사업 등록",
width: 860,
height: 360,
},
oldPpTable: {
key: "oldPpTable",
title: "노후 하수관로 정비사업 상세",
width: 1400,
height: 900,
requiredParams: ["fileNm", "atflId"],
},
floodPreUpload: {
key: "floodPreUpload",
title: "도시침수 예방사업 등록",
width: 860,
height: 360,
},
floodPreTable: {
key: "floodPreTable",
title: "도시침수 예방사업 상세",
width: 1400,
height: 900,
requiredParams: ["fileNm", "atflId"],
},
reportTab: {
key: "reportTab",
title: "하수도정비 기본계획보고서",
width: 1280,
height: 920,
},
reportFiles: {
key: "reportFiles",
title: "보고서 파일 목록",
width: 860,
height: 720,
requiredParams: ["clsfSystmSn"],
},
mapSearch: {
key: "mapSearch",
title: "관망도 검색",
width: 1280,
height: 980,
},
infilChart: {
key: "infilChart",
title: "침입수 분석",
width: 1360,
height: 980,
requiredParams: ["pplMngNo"],
},
rdiiChart: {
key: "rdiiChart",
title: "RDII 분석",
width: 1360,
height: 980,
requiredParams: ["pplMngNo"],
},
sscDetail: {
key: "sscDetail",
title: "중점우수토실 운영정보",
width: 1180,
height: 980,
requiredParams: ["chmbrXcnts", "chmbrYdnts"],
},
agedPipePopup: {
key: "agedPipePopup",
title: "노후 하수관로 관리 ",
width: 1180,
height: 760,
},
} as const satisfies Record<string, WindowPopupDefinition>;
export type WindowPopupKey = keyof typeof windowPopupDefinitions;
export const isWindowPopupKey = (key: string): key is WindowPopupKey => {
return key in windowPopupDefinitions;
};
@@ -0,0 +1,37 @@
import type { ComponentType } from "react";
export type WindowPopupSearchParams = Record<
string,
string | string[] | undefined
>;
export type WindowPopupContentProps = {
searchParams: WindowPopupSearchParams;
};
export type WindowPopupDefinition = {
key: string;
title: string;
width: number;
height: number;
requiredParams?: string[];
};
export type WindowPopupRegistryItem = WindowPopupDefinition & {
component: ComponentType<WindowPopupContentProps>;
};
export type OpenWindowPopupParams = Record<
string,
string | number | boolean | null | undefined
>;
export const WINDOW_POPUP_MESSAGE_TYPE = {
OLD_PP_UPLOAD_COMPLETED: "sn:popup:oldPpUploadCompleted",
FLOOD_PRE_UPLOAD_COMPLETED: "sn:popup:floodPreUploadCompleted",
MAP_SEARCH_SELECT_FEATURE: "sn:popup:mapSearchSelectFeature",
MAP_SEARCH_SHOW_MULTIPLE: "sn:popup:mapSearchShowMultiple",
} as const;
export type WindowPopupMessageType =
(typeof WINDOW_POPUP_MESSAGE_TYPE)[keyof typeof WINDOW_POPUP_MESSAGE_TYPE];
@@ -0,0 +1,60 @@
import { windowPopupDefinitions, type WindowPopupKey } from "./model/popupDefinitions";
import type { WindowPopupRegistryItem } from "./model/types";
import AgedPipePopupContent from "./ui/AgedPipePopupContent";
import FloodPreTablePopupContent from "./ui/FloodPreTablePopupContent";
import FloodPreUploadPopupContent from "./ui/FloodPreUploadPopupContent";
import InfilChartPopupContent from "./ui/InfilChartPopupContent";
import OldPpUploadPopupContent from "./ui/OldPpUploadPopupContent";
import OldPpTablePopupContent from "./ui/OldPpTablePopupContent";
import RdiiChartPopupContent from "./ui/RdiiChartPopupContent";
import ReportFilesPopupContent from "./ui/ReportFilesPopupContent";
import ReportTabPopupContent from "./ui/ReportTabPopupContent";
import SearchModalPopupContent from "./ui/SearchModalPopupContent";
import SscDetailPopupContent from "./ui/SscDetailPopupContent";
export const windowPopupRegistry = {
oldPpUpload: {
...windowPopupDefinitions.oldPpUpload,
component: OldPpUploadPopupContent,
},
oldPpTable: {
...windowPopupDefinitions.oldPpTable,
component: OldPpTablePopupContent,
},
floodPreUpload: {
...windowPopupDefinitions.floodPreUpload,
component: FloodPreUploadPopupContent,
},
floodPreTable: {
...windowPopupDefinitions.floodPreTable,
component: FloodPreTablePopupContent,
},
reportTab: {
...windowPopupDefinitions.reportTab,
component: ReportTabPopupContent,
},
reportFiles: {
...windowPopupDefinitions.reportFiles,
component: ReportFilesPopupContent,
},
mapSearch: {
...windowPopupDefinitions.mapSearch,
component: SearchModalPopupContent,
},
infilChart: {
...windowPopupDefinitions.infilChart,
component: InfilChartPopupContent,
},
rdiiChart: {
...windowPopupDefinitions.rdiiChart,
component: RdiiChartPopupContent,
},
sscDetail: {
...windowPopupDefinitions.sscDetail,
component: SscDetailPopupContent,
},
agedPipePopup: {
...windowPopupDefinitions.agedPipePopup,
component: AgedPipePopupContent,
},
} as const satisfies Record<WindowPopupKey, WindowPopupRegistryItem>;
@@ -0,0 +1,10 @@
import { Suspense } from "react";
import AgedPipePopupPage from "@/app/(map-layout)/map/components/agedPipe/AgedPipePopupPage";
export default function AgedPipePopupContent() {
return (
<Suspense fallback={null}>
<AgedPipePopupPage />
</Suspense>
);
}
@@ -0,0 +1,36 @@
import TableModal from "@/app/(with-base-layout)/oper/floodPre/tableModal";
import { windowPopupDefinitions } from "../model/popupDefinitions";
import type { WindowPopupContentProps } from "../model/types";
import PopupMissingParams from "./PopupMissingParams";
import { getPopupParamValue } from "./popupContentUtils";
export default function FloodPreTablePopupContent({
searchParams,
}: WindowPopupContentProps) {
const fileNm = getPopupParamValue(searchParams.fileNm);
const atflId = getPopupParamValue(searchParams.atflId);
const missingParams = [
!fileNm ? "fileNm" : "",
!atflId ? "atflId" : "",
].filter(Boolean);
if (missingParams.length > 0) {
return (
<PopupMissingParams
title={windowPopupDefinitions.floodPreTable.title}
missingParams={missingParams}
/>
);
}
return (
<div className="flex h-dvh flex-col overflow-hidden bg-white p-4">
<h1 className="mb-3 shrink-0 text-2xl font-semibold">
{windowPopupDefinitions.floodPreTable.title}
</h1>
<div className="min-h-0 flex-1 overflow-hidden">
<TableModal fileNm={fileNm} atflId={atflId} />
</div>
</div>
);
}
@@ -0,0 +1,42 @@
"use client";
import { postData } from "@/components/lib/api";
import OperFileUploadForm from "@/components/ui/operFileUploadForm";
import { tokenManager } from "@/entities/auth/lib/token-manager";
import { AUTH_USER_ID } from "@/shared/config";
import { postPopupMessageAndClose } from "../lib/popupMessage";
import { windowPopupDefinitions } from "../model/popupDefinitions";
import { WINDOW_POPUP_MESSAGE_TYPE } from "../model/types";
export default function FloodPreUploadPopupContent() {
const handleSubmit = async (
fileNm: string,
year: string,
quater: string,
cd: string,
id: string,
) => {
const data = await postData("v1/sbmrgPrevnt", {
mngYr: year,
mngQu: quater,
atflId: id,
atflCd: cd,
rgtr: tokenManager.getToken(AUTH_USER_ID) || null,
sbmrgPrevntBizNm: fileNm,
});
if (data) {
alert("파일이 등록되었습니다.");
postPopupMessageAndClose(WINDOW_POPUP_MESSAGE_TYPE.FLOOD_PRE_UPLOAD_COMPLETED);
}
};
return (
<div className="min-h-dvh bg-white p-4">
<OperFileUploadForm
title={windowPopupDefinitions.floodPreUpload.title}
onSubmit={handleSubmit}
/>
</div>
);
}
@@ -0,0 +1,22 @@
import InfilChart from "@/app/(map-layout)/map/components/popupComponent/chart/infilChart";
import { windowPopupDefinitions } from "../model/popupDefinitions";
import type { WindowPopupContentProps } from "../model/types";
import PopupMissingParams from "./PopupMissingParams";
import { getPopupParamValue } from "./popupContentUtils";
export default function InfilChartPopupContent({
searchParams,
}: WindowPopupContentProps) {
const pplMngNo = getPopupParamValue(searchParams.pplMngNo);
if (!pplMngNo) {
return (
<PopupMissingParams
title={windowPopupDefinitions.infilChart.title}
missingParams={["pplMngNo"]}
/>
);
}
return <InfilChart pplMngNo={pplMngNo} />;
}
@@ -0,0 +1,36 @@
import TableModal from "@/app/(with-base-layout)/oper/oldPp/tableModal";
import { windowPopupDefinitions } from "../model/popupDefinitions";
import type { WindowPopupContentProps } from "../model/types";
import PopupMissingParams from "./PopupMissingParams";
import { getPopupParamValue } from "./popupContentUtils";
export default function OldPpTablePopupContent({
searchParams,
}: WindowPopupContentProps) {
const fileNm = getPopupParamValue(searchParams.fileNm);
const atflId = getPopupParamValue(searchParams.atflId);
const missingParams = [
!fileNm ? "fileNm" : "",
!atflId ? "atflId" : "",
].filter(Boolean);
if (missingParams.length > 0) {
return (
<PopupMissingParams
title={windowPopupDefinitions.oldPpTable.title}
missingParams={missingParams}
/>
);
}
return (
<div className="flex h-dvh flex-col overflow-hidden bg-white p-4">
<h1 className="mb-3 shrink-0 text-2xl font-semibold">
{windowPopupDefinitions.oldPpTable.title}
</h1>
<div className="min-h-0 flex-1 overflow-hidden">
<TableModal fileNm={fileNm} atflId={atflId} />
</div>
</div>
);
}
@@ -0,0 +1,42 @@
"use client";
import { postData } from "@/components/lib/api";
import OperFileUploadForm from "@/components/ui/operFileUploadForm";
import { tokenManager } from "@/entities/auth/lib/token-manager";
import { AUTH_USER_ID } from "@/shared/config";
import { postPopupMessageAndClose } from "../lib/popupMessage";
import { windowPopupDefinitions } from "../model/popupDefinitions";
import { WINDOW_POPUP_MESSAGE_TYPE } from "../model/types";
export default function OldPpUploadPopupContent() {
const handleSubmit = async (
fileNm: string,
year: string,
quater: string,
cd: string,
id: string,
) => {
const data = await postData("v1/dcrpPpl", {
mngYr: year,
mngQu: quater,
atflCd: cd,
atflId: id,
rgtr: tokenManager.getToken(AUTH_USER_ID) || null,
dcrpPplMtncBizNm: fileNm,
});
if (data) {
alert("파일이 등록되었습니다.");
postPopupMessageAndClose(WINDOW_POPUP_MESSAGE_TYPE.OLD_PP_UPLOAD_COMPLETED);
}
};
return (
<div className="min-h-dvh bg-white p-4">
<OperFileUploadForm
title={windowPopupDefinitions.oldPpUpload.title}
onSubmit={handleSubmit}
/>
</div>
);
}
@@ -0,0 +1,19 @@
export default function PopupMissingParams({
title,
missingParams,
}: {
title: string;
missingParams: string[];
}) {
return (
<section className="flex min-h-dvh flex-col items-center justify-center gap-4 p-8 text-center">
<h1 className="text-2xl font-semibold">{title}</h1>
<p className="text-base text-gray-600">
팝업을 여는 데 필요한 값이 누락되었습니다.
</p>
<p className="text-sm text-gray-500">
누락된 값: {missingParams.join(", ")}
</p>
</section>
);
}
@@ -0,0 +1,22 @@
import RdiiChart from "@/app/(map-layout)/map/components/popupComponent/chart/rdiiChart";
import { windowPopupDefinitions } from "../model/popupDefinitions";
import type { WindowPopupContentProps } from "../model/types";
import PopupMissingParams from "./PopupMissingParams";
import { getPopupParamValue } from "./popupContentUtils";
export default function RdiiChartPopupContent({
searchParams,
}: WindowPopupContentProps) {
const pplMngNo = getPopupParamValue(searchParams.pplMngNo);
if (!pplMngNo) {
return (
<PopupMissingParams
title={windowPopupDefinitions.rdiiChart.title}
missingParams={["pplMngNo"]}
/>
);
}
return <RdiiChart pplMngNo={pplMngNo} />;
}
@@ -0,0 +1,121 @@
"use client";
import { useEffect, useState } from "react";
import LoadingOverlay from "@/components/ui/LoadingBar";
import { getDcData } from "@/components/lib/api";
import { UiTable } from "@common/business_components/ui";
import { windowPopupDefinitions } from "../model/popupDefinitions";
import type { WindowPopupContentProps } from "../model/types";
import PopupMissingParams from "./PopupMissingParams";
import { getPopupParamValue } from "./popupContentUtils";
type ReportDataFile = {
no?: number;
fileTitle: string;
regDt: string;
fileSn: string;
filePath: string;
};
const REPORT_FILES_HEADLIST = [
{ id: "no", title: "NO" },
{ id: "fileTitle", title: "파일명" },
{ id: "regDt", title: "등록일자" },
];
export default function ReportFilesPopupContent({
searchParams,
}: WindowPopupContentProps) {
const clsfSystmSn = getPopupParamValue(searchParams.clsfSystmSn);
const [files, setFiles] = useState<ReportDataFile[]>([]);
const [allFiles, setAllFiles] = useState<ReportDataFile[]>([]);
const [curPage, setCurPage] = useState(1);
const [isLoading, setIsLoading] = useState(false);
useEffect(() => {
if (!clsfSystmSn) return;
const fetchFiles = async () => {
setIsLoading(true);
try {
const result = await getDcData(`/api/v1/file?clsfSystmSn=${clsfSystmSn}`);
const normalized = (result ?? []).map((item: ReportDataFile, index: number) => ({
...item,
no: index + 1,
}));
setAllFiles(normalized);
} catch (error) {
console.error("보고서 파일 목록 조회 실패:", error);
setAllFiles([]);
} finally {
setIsLoading(false);
}
};
fetchFiles();
}, [clsfSystmSn]);
useEffect(() => {
const paged = allFiles.slice((curPage - 1) * 10, curPage * 10);
setFiles(paged);
}, [allFiles, curPage]);
const handleDownload = async (fileSn: string, fileTitle: string, filePath: string) => {
if (!filePath) {
alert("filePath 정보가 없으므로 다운로드가 불가합니다.");
return;
}
try {
const responseBlob = await getDcData(
`/api/v1/fileDownload?filePath=${encodeURIComponent(filePath)}`,
"blob",
);
const objectUrl = URL.createObjectURL(responseBlob);
const anchor = document.createElement("a");
anchor.href = objectUrl;
anchor.download = fileTitle;
document.body.appendChild(anchor);
anchor.click();
anchor.remove();
URL.revokeObjectURL(objectUrl);
} catch (error) {
console.error("보고서 파일 다운로드 실패:", error);
alert("파일 다운로드 중 오류가 발생했습니다.");
}
};
if (!clsfSystmSn) {
return (
<PopupMissingParams
title={windowPopupDefinitions.reportFiles.title}
missingParams={["clsfSystmSn"]}
/>
);
}
return (
<div className="flex h-dvh flex-col overflow-hidden bg-white p-4">
<h1 className="mb-3 shrink-0 text-2xl font-semibold">
{windowPopupDefinitions.reportFiles.title}
</h1>
<div className="min-h-0 flex-1 overflow-auto">
<LoadingOverlay isLoading={isLoading} />
<UiTable
headName={`조회 결과 (${allFiles.length.toLocaleString("ko-KR")})건`}
tableData={files}
headlist={REPORT_FILES_HEADLIST}
pageSize={10}
total={allFiles.length}
onPageChange={setCurPage}
cellClick={(index) => {
if (typeof index !== "number" || !files[index]) return;
const target = files[index];
handleDownload(target.fileSn, target.fileTitle, target.filePath);
}}
/>
</div>
</div>
);
}
@@ -0,0 +1,27 @@
import ReportTab from "@/app/(map-layout)/map/searchModal/reportTab";
import { windowPopupDefinitions } from "../model/popupDefinitions";
import type { WindowPopupContentProps } from "../model/types";
import { getPopupParamValue } from "./popupContentUtils";
export default function ReportTabPopupContent({
searchParams,
}: WindowPopupContentProps) {
const ctpvNm = getPopupParamValue(searchParams.ctpvNm);
const sggNm = getPopupParamValue(searchParams.sggNm);
return (
<div className="flex h-dvh flex-col overflow-hidden bg-white p-4">
<h1 className="mb-3 shrink-0 text-2xl font-semibold">
{windowPopupDefinitions.reportTab.title}
</h1>
<div className="min-h-0 flex-1 overflow-hidden">
<ReportTab
open={true}
isSearch={false}
ctpvNm={ctpvNm || undefined}
sggNm={sggNm || undefined}
/>
</div>
</div>
);
}
@@ -0,0 +1,5 @@
import SearchModal from "@/app/(map-layout)/map/searchModal/searchModal";
export default function SearchModalPopupContent() {
return <SearchModal renderMode="popup" />;
}
@@ -0,0 +1,36 @@
import Ssc from "@/app/(map-layout)/map/components/popupComponent/Ssc";
import { windowPopupDefinitions } from "../model/popupDefinitions";
import type { WindowPopupContentProps } from "../model/types";
import PopupMissingParams from "./PopupMissingParams";
import { getPopupParamValue } from "./popupContentUtils";
export default function SscDetailPopupContent({
searchParams,
}: WindowPopupContentProps) {
const chmbrXcnts = getPopupParamValue(searchParams.chmbrXcnts);
const chmbrYdnts = getPopupParamValue(searchParams.chmbrYdnts);
const missingParams = [
!chmbrXcnts ? "chmbrXcnts" : "",
!chmbrYdnts ? "chmbrYdnts" : "",
].filter(Boolean);
if (missingParams.length > 0) {
return (
<PopupMissingParams
title={windowPopupDefinitions.sscDetail.title}
missingParams={missingParams}
/>
);
}
return (
<Ssc
featureData={null}
isMainSsc={true}
chmbrXcnts={chmbrXcnts}
chmbrYdnts={chmbrYdnts}
renderMode="popup"
/>
);
}
@@ -0,0 +1,6 @@
export const getPopupParamValue = (
value: string | string[] | undefined,
): string => {
if (Array.isArray(value)) return value[0] ?? "";
return value ?? "";
};
@@ -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) => {

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