This commit is contained in:
2025-12-10 17:52:17 +09:00
73 changed files with 6645 additions and 627 deletions
+2 -1
View File
@@ -22,4 +22,5 @@ apps/fo/.next/**/*
#.env*
.qodo
pnpm-lock.yaml
pnpm-lock.yaml
settings.json
+2 -1
View File
@@ -7,4 +7,5 @@ NEXT_PUBLIC_SERVER_API_URL=http://dev.onasys.co.kr:8000
NEXT_PUBLIC_LINK_BACKEND_API_HOST=http://rsone.iptime.org:10014
NEXT_PUBLIC_TINY_API_KEY=t9nc4z6inquvvd5pq1vea8wcp7lb01qmqseh6egms9vpaju2
NEXT_PUBLIC_SERVER_API_URL2=http://con.mind-one.co.kr:11083/
# NEXT_PUBLIC_SERVER_API_URL2=http://con.mind-one.co.kr:11083/
NEXT_PUBLIC_SERVER_API_URL2=http://192.168.1.222:8883/
+36 -25
View File
@@ -14,11 +14,11 @@ const serverApiUrl =
const serverApiUrl2 =
process.env.NEXT_PUBLIC_SERVER_API_URL2 ?? serverApiUrl;
const serverApiUrl3 =
process.env.NEXT_PUBLIC_SERVER_API_URL3 ?? serverApiUrl;
// const serverApiUrl3 =
// process.env.NEXT_PUBLIC_SERVER_API_URL3 ?? serverApiUrl;
const serverApiUrl4 =
process.env.NEXT_PUBLIC_SERVER_API_URL4 ?? serverApiUrl;
// const serverApiUrl4 =
// process.env.NEXT_PUBLIC_SERVER_API_URL4 ?? serverApiUrl;
const serverApiUrl5 =
process.env.NEXT_PUBLIC_SERVER_API_URL5 ?? serverApiUrl;
@@ -33,19 +33,6 @@ export function middleware(req: NextRequest) {
`[Request]\n Original Pathname: ${pathname}`,
];
if (pathname.startsWith("/server")) {
const queryParams = searchParams.toString();
const newUrl = `${serverApiUrl}${pathname.replace("/server", "")}${
queryParams ? `?${queryParams}` : ""
}`;
logMessages.push(` Rewritten URL: ${newUrl}`);
logMessages.push("\n--------------------------------\n");
console.log(logMessages.join("\n"));
return NextResponse.rewrite(new URL(newUrl));
}
if (pathname.startsWith("/attach-service")) {
const requestHeaders = new Headers(req.headers);
const token = req.cookies.get(CLAIM_NAME)?.value;
@@ -57,7 +44,7 @@ export function middleware(req: NextRequest) {
logMessages.push(` Rewritten URL: ${newUrl}`);
logMessages.push("\n--------------------------------\n");
console.log(logMessages.join("\n"));
// console.log(logMessages.join("\n"));
return NextResponse.rewrite(new URL(newUrl), {
headers: requestHeaders,
@@ -65,11 +52,14 @@ export function middleware(req: NextRequest) {
}
if (pathname.startsWith("/sewerage-service")) {
if (pathname.startsWith("/server/sewerage-service")) {
const requestHeaders = new Headers(req.headers);
const token = req.cookies.get(CLAIM_NAME)?.value;
const userId = req.cookies.get(AUTH_USER_ID)?.value;
const newUrl = `${serverApiUrl2}${pathname}`;
// const decodedQuery = toDecodedQueryString(searchParams);
const newUrl = `${serverApiUrl2}${pathname.replace("/server/", "")}${searchParams ? `?${searchParams}` : ""}`;
// const newUrl = `${serverApiUrl2}${pathname}`;
if (token) requestHeaders.set(CLAIM_NAME, token);
if (userId) requestHeaders.set(AUTH_USER_ID, userId);
@@ -87,7 +77,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;
const newUrl = `${serverApiUrl3}${pathname}`;
const newUrl = `${serverApiUrl2}${pathname}`;
if (token) requestHeaders.set(CLAIM_NAME, token);
if (userId) requestHeaders.set(AUTH_USER_ID, userId);
@@ -101,11 +91,12 @@ export function middleware(req: NextRequest) {
});
}
if (pathname.startsWith("/operation-service")) {
if (pathname.startsWith("/server/operation-service")) {
const requestHeaders = new Headers(req.headers);
const token = req.cookies.get(CLAIM_NAME)?.value;
const userId = req.cookies.get(AUTH_USER_ID)?.value;
const newUrl = `${serverApiUrl4}${pathname}`;
// const newUrl = `${serverApiUrl2}${pathname.replace("/server/", "")}`;
const newUrl = `${serverApiUrl2}${pathname.replace("/server/", "")}${searchParams ? `?${searchParams}` : ""}`;
if (token) requestHeaders.set(CLAIM_NAME, token);
if (userId) requestHeaders.set(AUTH_USER_ID, userId);
@@ -163,11 +154,25 @@ export function middleware(req: NextRequest) {
logMessages.push(` User Service Rewritten URL: ${newUrl}`);
logMessages.push("\n--------------------------------\n");
console.log(logMessages.join("\n"));
// console.log(logMessages.join("\n"));
return NextResponse.rewrite(new URL(newUrl));
}
if (pathname.startsWith("/server")) {
const queryParams = searchParams.toString();
const newUrl = `${serverApiUrl}${pathname.replace("/server", "")}${
queryParams ? `?${queryParams}` : ""
}`;
logMessages.push(` Rewritten URL: ${newUrl}`);
logMessages.push("\n--------------------------------\n");
// console.log(logMessages.join("\n"));
return NextResponse.rewrite(new URL(newUrl));
}
if (pathname === "/") {
return NextResponse.redirect(new URL("/dashboard", req.url));
}
@@ -178,5 +183,11 @@ export function middleware(req: NextRequest) {
}
export const config = {
matcher: ["/server/:path*", "/attach-service/:path*", "/user-service/:path*", "/"],
matcher: ["/server/:path*",
"/attach-service/:path*",
"/user-service/:path*",
"/sewerage-service/:path*",
"/reuse-service/:path*",
"/operation-service/:path*",
"/"],
};
@@ -0,0 +1,148 @@
// apps/cm/src/pages/dashboard/api/fo-service.ts
import { client } from "@/shared/api";
import { createStore } from "zustand/vanilla";
import { ENDPOINT } from "@/shared/config";
import type { OperFcltContent, ReuseStatusContent, OperationService } from "../model/fo-interface";
export type OperFcltState = {
operFcltStatus: OperFcltContent[] | null;
};
export type OperFcltStateActions = {
setOperFcltStatus: (operFcltStatus: OperFcltContent[] | null) => void;
};
export type OperFcltStore = OperFcltState & OperFcltStateActions;
export type ReuseState = {
reuseStatus: ReuseStatusContent[] | null;
};
export type ReuseStateActions = {
setReuseStatus: (reuseStatus: ReuseStatusContent[] | null) => void;
};
export type ReuseStore = ReuseState & ReuseStateActions;
export type YearState = {
year: string | null;
};
export type YearStateActions = {
setYear: (year: string | null) => void;
};
export type YearStore = YearState & YearStateActions;
export const defaultInitOperFcltState: OperFcltState = {
operFcltStatus: null,
};
export const createOperFcltStore = (initState: OperFcltState = defaultInitOperFcltState) =>
createStore<OperFcltStore>()((set) => ({
...initState,
setOperFcltStatus: (operFcltStatus) => set(() => ({ operFcltStatus })),
}));
const initialReuseStatusData: ReuseStatusContent[] = [
{ yr: '2021', sewage: 7335803, reclaimed: 254805510.3, rainwater: 14049224.5 },
{ yr: '2022', sewage: 7378907, reclaimed: 243457788.8, rainwater: 15128093.4 },
{ yr: '2023', sewage: 7537151, reclaimed: 222475997, rainwater: 13944732.5 },
{ yr: '2024', sewage: 7479136, reclaimed: 265625917.7, rainwater: 13305309.5 },
];
export const defaultInitReuseState: ReuseState = {
reuseStatus: initialReuseStatusData,
};
export const createReuseStore = (initState: ReuseState = defaultInitReuseState) =>
createStore<ReuseStore>()((set) => ({
...initState,
setReuseStatus: (reuseStatus) => set(() => ({ reuseStatus })),
}));
export const defaultYearInitState: YearState = {
year: null,
};
export const createYearStore = (initState: YearState = defaultYearInitState) =>
createStore<YearStore>()((set) => ({
...initState,
setYear: (year) => set(() => ({ year })),
}));
// 전역으로 공유할 store 인스턴스
export const operFcltStore = createOperFcltStore();
// 전역으로 공유할 store 인스턴스
export const reuseStore = createReuseStore();
// 전역으로 공유할 store 인스턴스
export const yearStore = createYearStore();
export class OperationApiService implements OperationService {
public async getStandardYear(): Promise<string> {
const { year, setYear } = yearStore.getState();
if (year) {
return year;
}
const response = await client.get<{
status: number;
data: string;
count: number;
}>(ENDPOINT.OPERATION_SERVICE.MAIN_YEAR);
const newYear = response.data.data || '2024';
setYear(newYear);
return newYear;
}
// 수계별 시설운영 개소수 및 가동률
public async getDashOperCntRt(year: string): Promise<OperFcltContent[]> {
const { operFcltStatus, setOperFcltStatus } = operFcltStore.getState();
// 간단한 캐싱: 데이터가 이미 있으면 API 호출 생략
if (operFcltStatus && operFcltStatus.length > 0) {
return operFcltStatus;
}
const response = await client.get<{
status: number;
data: OperFcltContent[];
count: number;
}>(ENDPOINT.OPERATION_SERVICE.OPER_CNTRT, {
params: {
year,
},
});
const list = (response.data.data ?? []).map((item) => ({
...item,
cnt: Number(item.cnt),
fctCpc: Number(item.fctCpc),
}));
setOperFcltStatus(list);
return list;
}
// 여러 컴포넌트에서 호출해도 서버 호출은 1번만 되도록 캐싱
public async getReuseStatus(): Promise<ReuseStatusContent[]> {
// API 호출 시에는 항상 최신 데이터를 가져오도록 캐싱 로직을 제거하거나 수정할 수 있습니다.
// 여기서는 기존 캐싱 로직을 유지하되, 초기 데이터가 아닌 경우에만 API를 호출하도록 합니다.
const { reuseStatus, setReuseStatus } = reuseStore.getState();
if (reuseStatus && reuseStatus.length > 0 && reuseStatus !== defaultInitReuseState.reuseStatus) {
return reuseStatus;
}
const response = await client.get<{
status: number;
data: ReuseStatusContent[];
count: number;
}>(ENDPOINT.OPERATION_SERVICE.REUSE_STATUS);
const list = (response.data.data ?? []).map((item) => ({
...item,
reclaimed: Number(item.reclaimed),
rainwater: Number(item.rainwater),
}));
setReuseStatus(list);
return list;
}
}
export const OperationApi = new OperationApiService();
@@ -0,0 +1,69 @@
// apps/cm/src/pages/dashboard/api/sn-service.ts
import { client } from "@/shared/api";
import { createStore } from "zustand/vanilla";
import { ENDPOINT } from "@/shared/config";
import type { PplLengthContent, SewerageService } from "../model/sn-interface";
export type SnState = {
snStatus: PplLengthContent[] | null;
};
export type SnStateActions = {
setSnStatus: (snStatus: PplLengthContent[] | null) => void;
};
export type SnStore = SnState & SnStateActions;
export const defaultInitState: SnState = {
snStatus: null,
};
export const createSnStore = (initState: SnState = defaultInitState) =>
createStore<SnStore>()((set) => ({
...initState,
setSnStatus: (snStatus) => set(() => ({ snStatus })),
}));
// 전역으로 공유할 store 인스턴스
// export const snStore = createSnStore();
export class SewerageApiService implements SewerageService {
// 기간별 관로 총길이
public async getPplLength(instlYmdFrom: string, instlYmdTo: string): Promise<PplLengthContent[]> {
const response = await client.get<{
PplLengthList: PplLengthContent[];
}>(ENDPOINT.SEWERAGE_SERVICE.PPL_LENGTH, {
params: {
instlYmdFrom,
instlYmdTo,
},
});
const list = (response.data.PplLengthList ?? []).map((item) => ({
...item,
sumPrlgVl: Number(item.sumPrlgVl),
}));
return list;
}
// 기간별 용도별 관로 총길이 : useCd - SBA004 오수관, SBA003 우수관
public async getPplUseLength(instlYmdFrom: string, instlYmdTo: string, useCd: string): Promise<PplLengthContent[]> {
const response = await client.get<{
PplLengthList: PplLengthContent[];
}>(ENDPOINT.SEWERAGE_SERVICE.PPL_USE_LENGTH, {
params: {
instlYmdFrom,
instlYmdTo,
useCd,
},
});
const list = (response.data.PplLengthList ?? []).map((item) => ({
...item,
sumPrlgVl: Number(item.sumPrlgVl),
}));
return list;
}
}
export const SewerageApi = new SewerageApiService();
@@ -1,5 +1,5 @@
import React, { useEffect, useRef } from "react";
import styles from "./KoreaBasinMap.module.css"; // 추가
import styles from "./korea-basin-map.module.css"; // 추가
const SVG_URL = "/images/region_map.svg"; // public 폴더에 region_map.svg 위치 필요
@@ -0,0 +1,17 @@
export interface ReuseStatusContent {
yr: string;
sewage: number;
reclaimed: number;
rainwater: number;
}
export interface OperFcltContent {
rvbsn: string; // 유역
cnt: number; // 개소수
fctCpc: number; // 가동률 (용량)
gubun: string; // 구분
}
export interface OperationService {
getReuseStatus(): Promise<ReuseStatusContent[]>;
}
@@ -0,0 +1,9 @@
export interface PplLengthContent {
zoneNm: string;
sumPrlgVl: number;
}
export interface SewerageService {
getPplLength(ymdFrom: string, ymdTo: string, useCd: string): Promise<PplLengthContent[]>;
getPplUseLength(ymdFrom: string, ymdTo: string, useCd: string): Promise<PplLengthContent[]>;
}
@@ -0,0 +1,36 @@
"use client";
import React, { useState } from "react";
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from "@common/components";
interface BasinSelectProps {
selectedBasin: string;
onValueChange: (value: string) => void;
}
const BasinSelect: React.FC<BasinSelectProps> = ({ selectedBasin, onValueChange }) => {
const basins = ["한강", "금강", "낙동강", "영산강"];
return (
<Select value={selectedBasin} onValueChange={onValueChange}>
<SelectTrigger className="w-[100px] h-8 bg-blue-500 text-white font-bold text-md border-none hover:bg-blue-600">
<SelectValue placeholder="유역 선택" />
</SelectTrigger>
<SelectContent>
{basins.map((basin) => (
<SelectItem key={basin} value={basin}>
{basin}
</SelectItem>
))}
</SelectContent>
</Select>
);
};
export default BasinSelect;
+23 -110
View File
@@ -4,7 +4,7 @@ import { useRouter } from "next/navigation";
import { tokenManager } from "@/entities/auth/lib/token-manager";
import { client } from "@/shared/api";
import { ENDPOINT } from "@/shared/config";
import { KoreaBasinMap } from "../lib/KoreaBasinMap";
import { KoreaBasinMap } from "../lib/korea-basin-map";
import {
Chart as ChartJS,
CategoryScale,
@@ -17,7 +17,13 @@ import {
Tooltip,
ArcElement,
} from "chart.js";
import { Doughnut } from "react-chartjs-2";
// import { Doughnut } from "react-chartjs-2";
import SnPplStatus from "./sn-ppl-status";
import SnOldPpl from "./sn-old-ppl";
import FoFcltCntRt from "./fo-fclt-cnt-rt";
import FoWqltInflEffl from "./fo-wqlt-Infl-effl";
import ReuseChart from "./reuse-chart";
import ReuseStatus from "./reuse-status";
// Chart.js 등록
ChartJS.register(
@@ -108,121 +114,28 @@ function DashboardPage() {
<div className="grid grid-cols-8 gap-1">
{/* 첫번쨰 열 */}
<div className="col-span-2 h-250 !p-0">
{/* 전국 하수처리시설 가동률 */}
<div className="card card-border">
<div className="card-header text-foreground">
<h3 className="text-2xl font-medium">전국 하수처리시설 가동률</h3>
</div>
<div className="h-60 p-4">
<Doughnut
data={{
datasets: [{
data: [75, 25], // 75% 가동률
backgroundColor: ['#00DFE5', '#f5f5f5'],
borderWidth: 0
}]
}}
options={{
responsive: true,
maintainAspectRatio: false,
cutout: '70%',
circumference: 180, // 반원
rotation: -90, // 왼쪽에서 시작
plugins: {
legend: {
display: false
},
tooltip: {
enabled: false
}
}
}}
plugins={[{
id: 'centerText',
afterDraw: (chart) => {
const ctx = chart.ctx;
const centerX = chart.width / 2;
const centerY = chart.height / 2;
// 가동률 텍스트
ctx.save();
ctx.font = 'bold 24px Arial';
ctx.fillStyle = '#00DFE5';
ctx.textAlign = 'center';
ctx.textBaseline = 'middle';
ctx.fillText('75%', centerX, centerY + 10);
// 라벨 텍스트
ctx.font = '12px Arial';
ctx.fillStyle = '#666';
ctx.fillText('가동률', centerX, centerY + 30);
ctx.restore();
}
}]}
/>
</div>
</div>
{/* 전국 하수처리시설 개소수 및 가동률 */}
<FoFcltCntRt />
{/* 오수관로 및 우수관로 현황 */}
<div className="card card-border">
<div className="card-header text-foreground">
<h3 className="text-2xl font-medium">오수관로 및 우수관로 현황</h3>
</div>
<div className="h-60 p-4"></div>
</div>
<SnPplStatus />
{/* 하수처리수 재이용 */}
<div className="card card-border">
<div className="card-header text-foreground">
<h3 className="text-2xl font-medium">하수처리수 재이용</h3>
</div>
<div className="h-60 p-4"></div>
</div>
{/* 하수처리수 재이용 (m³) */}
<ReuseChart />
</div>
{/* 두번쨰 열 */}
<div className="col-span-2 h-250 !p-0">
{/* 하수처리시설(개소) */}
<div className="card card-border">
<div className="card-header text-foreground">
<h3 className="text-2xl font-medium">하수처리시설(개소)</h3>
</div>
<div className="grid grid-cols-2 gap-1 h-60 !p-0">
<div className="card card-border">
<div className="card-header text-foreground text-center">
<h3 className="text-l font-medium" style={{textAlign: "center", width: "100%"}}>500㎥ 미만(개소)</h3>
</div>
<div style={{fontSize: "38px", textAlign: "center", fontWeight: "600", lineHeight: "1.2", color: "#0ec58a", padding: "2px 0"}}>3,567 <span style={{fontSize: "14px", color: "#000000"}}>개소</span></div>
</div>
<div className="card card-border">
<div className="card-header text-foreground text-center">
<h3 className="text-l font-medium" style={{textAlign: "center", width: "100%"}}>500㎥ 이상(개소)</h3>
</div>
<div style={{fontSize: "38px", textAlign: "center", fontWeight: "600", lineHeight: "1.2", color: "#0ec58a", padding: "2px 0"}}>767 <span style={{fontSize: "14px", color: "#000000"}}>개소</span></div>
</div>
<div className="col-span-2" style={{width: "100%", textAlign: "right"}}>기준년월 : 2025.09 </div>
</div>
</div>
{/* 노후하수관로현황 */}
<div className="card card-border">
<div className="card-header text-foreground">
<h3 className="text-2xl font-medium">노후하수관로현황</h3>
</div>
<div className="h-70 p-4">
</div>
</div>
{/* 수계별 유입ㆍ방류수질 분석 */}
<FoWqltInflEffl />
{/* 재이용시설(개소) */}
<div className="card card-border">
<div className="card-header text-foreground">
<h3 className="text-2xl font-medium">재이용시설(개소)</h3>
</div>
<div className="h-70 p-4">
</div>
</div>
{/* 노후하수관로현황 */}
<SnOldPpl />
{/* 물재이용 현황 (재이용량, m³) */}
<ReuseStatus />
</div>
{/* 지도영역 */}
<div className="col-span-3 h-200 !p-0 relative z-10">
<div className="card card-border">
@@ -267,12 +180,12 @@ function DashboardPage() {
{/* 두번째 라인 */}
<div className="grid grid-cols-4 gap-1">
<div className="card card-border"id="grouping-statistics">
<div className="card card-border" id="grouping-statistics">
<div className="card-header text-foreground">
<h3 className="text-2xl font-medium">지원현황</h3>
</div>
<div className="h-70 p-4">
</div>
</div>
<div className="card card-border">
@@ -0,0 +1,303 @@
"use client";
import { useEffect, useState } from "react";
import { useRouter } from "next/navigation";
import { tokenManager } from "@/entities/auth/lib/token-manager";
import { client } from "@/shared/api";
import { ENDPOINT } from "@/shared/config";
import { KoreaBasinMap } from "../lib/korea-basin-map";
import {
Chart as ChartJS,
CategoryScale,
LinearScale,
PointElement,
LineElement,
BarElement,
Filler,
Legend,
Tooltip,
ArcElement,
} from "chart.js";
import { Doughnut } from "react-chartjs-2";
// Chart.js 등록
ChartJS.register(
CategoryScale,
LinearScale,
PointElement,
LineElement,
BarElement,
Filler,
Legend,
Tooltip,
ArcElement
);
interface UserInfo {
email?: string;
userId?: string;
userName?: string;
twoFactorEnabled?: boolean;
}
function DashboardPage() {
const [userInfo, setUserInfo] = useState<UserInfo | null>(null);
const [error, setError] = useState("");
const router = useRouter();
useEffect(() => {
const fetchUserInfo = async () => {
console.log("fetchUserInfo called");
try {
// tokenManager를 통해 토큰 확인
const accessToken = tokenManager.getToken("Authorization");
const userId = tokenManager.getToken("token-id");
if (!accessToken || !userId) {
console.log("No tokens found, redirecting to login");
setError("로그인이 필요합니다.");
router.push("/auth/login");
return;
}
console.log("Fetching user info with userId:", userId);
// client를 사용하여 API 호출 (자동 토큰 처리)
const response = await client.get(
`${ENDPOINT.USER_SERVICE.USERS}/${userId}`,
);
if (response.data) {
console.log("User info received:", response.data);
setUserInfo(response.data);
} else {
setError("사용자 정보를 불러올 수 없습니다.");
}
} catch (error) {
console.error("Error fetching user info:", error);
if (error instanceof Error && error.message.includes("401")) {
setError("인증이 만료되었습니다. 다시 로그인해주세요.");
tokenManager.clearAuthToken();
router.push("/auth/login");
} else {
setError("네트워크 오류가 발생했습니다.");
}
}
};
fetchUserInfo();
}, [router]);
const handleLogout = () => {
console.log("Logging out...");
// localStorage 정리
localStorage.removeItem("tempToken");
// tokenManager를 통해 쿠키 정리
tokenManager.clearAuthToken();
router.push("/auth/logout");
};
const handleChangePassword = () => {
router.push("/otp?next=change-password");
};
return (
<div className="p-6">
<div className="flex flex-col gap-1">
{/* 첫번째 라인 */}
<div className="grid grid-cols-8 gap-1">
{/* 첫번쨰 열 */}
<div className="col-span-2 h-250 !p-0">
{/* 전국 하수처리시설 가동률 */}
<div className="card card-border">
<div className="card-header text-foreground">
<h3 className="text-2xl font-medium">전국 하수처리시설 가동률</h3>
</div>
<div className="h-60 p-4">
<Doughnut
data={{
datasets: [{
data: [75, 25], // 75% 가동률
backgroundColor: ['#00DFE5', '#f5f5f5'],
borderWidth: 0
}]
}}
options={{
responsive: true,
maintainAspectRatio: false,
cutout: '70%',
circumference: 180, // 반원
rotation: -90, // 왼쪽에서 시작
plugins: {
legend: {
display: false
},
tooltip: {
enabled: false
}
}
}}
plugins={[{
id: 'centerText',
afterDraw: (chart) => {
const ctx = chart.ctx;
const centerX = chart.width / 2;
const centerY = chart.height / 2;
// 가동률 텍스트
ctx.save();
ctx.font = 'bold 24px Arial';
ctx.fillStyle = '#00DFE5';
ctx.textAlign = 'center';
ctx.textBaseline = 'middle';
ctx.fillText('75%', centerX, centerY + 10);
// 라벨 텍스트
ctx.font = '12px Arial';
ctx.fillStyle = '#666';
ctx.fillText('가동률', centerX, centerY + 30);
ctx.restore();
}
}]}
/>
</div>
</div>
{/* 오수관로 및 우수관로 현황 */}
<div className="card card-border">
<div className="card-header text-foreground">
<h3 className="text-2xl font-medium">오수관로 및 우수관로 현황</h3>
</div>
<div className="h-60 p-4"></div>
</div>
{/* 하수처리수 재이용 */}
<div className="card card-border">
<div className="card-header text-foreground">
<h3 className="text-2xl font-medium">하수처리수 재이용</h3>
</div>
<div className="h-60 p-4"></div>
</div>
</div>
{/* 두번쨰 열 */}
<div className="col-span-2 h-250 !p-0">
{/* 하수처리시설(개소) */}
<div className="card card-border">
<div className="card-header text-foreground">
<h3 className="text-2xl font-medium">하수처리시설(개소)</h3>
</div>
<div className="grid grid-cols-2 gap-1 h-60 !p-0">
<div className="card card-border">
<div className="card-header text-foreground text-center">
<h3 className="text-l font-medium" style={{textAlign: "center", width: "100%"}}>500㎥ 미만(개소)</h3>
</div>
<div style={{fontSize: "38px", textAlign: "center", fontWeight: "600", lineHeight: "1.2", color: "#0ec58a", padding: "2px 0"}}>3,567 <span style={{fontSize: "14px", color: "#000000"}}>개소</span></div>
</div>
<div className="card card-border">
<div className="card-header text-foreground text-center">
<h3 className="text-l font-medium" style={{textAlign: "center", width: "100%"}}>500㎥ 이상(개소)</h3>
</div>
<div style={{fontSize: "38px", textAlign: "center", fontWeight: "600", lineHeight: "1.2", color: "#0ec58a", padding: "2px 0"}}>767 <span style={{fontSize: "14px", color: "#000000"}}>개소</span></div>
</div>
<div className="col-span-2" style={{width: "100%", textAlign: "right"}}>기준년월 : 2025.09 </div>
</div>
</div>
{/* 노후하수관로현황 */}
<div className="card card-border">
<div className="card-header text-foreground">
<h3 className="text-2xl font-medium">노후하수관로현황</h3>
</div>
<div className="h-70 p-4">
</div>
</div>
{/* 재이용시설(개소) */}
<div className="card card-border">
<div className="card-header text-foreground">
<h3 className="text-2xl font-medium">재이용시설(개소)</h3>
</div>
<div className="h-70 p-4">
</div>
</div>
</div>
{/* 지도영역 */}
<div className="col-span-3 h-200 !p-0 relative z-10">
<div className="card card-border">
<div className="card-header text-foreground">
<h3 className="text-2xl font-medium">유역별 정보</h3>
</div>
<KoreaBasinMap />
</div>
</div>
{/* 사용자 정보 카드 */}
<div className="card card-border col-span-1 h-80 p-10">
<h2 className="mb-6 text-2xl">사용자 정보</h2>
{error && <div className="mb-4 text-red-500">{error}</div>}
{userInfo ? (
<div className="mb-4">
<div>
<b>아이디:</b> {userInfo.userId}
</div>
<div>
<b>이메일:</b> {userInfo.email || userInfo.userId}
</div>
<div>
<b>이름:</b> {userInfo.userName || "-"}
</div>
<div>
<b>2FA 활성화:</b>{" "}
{userInfo.twoFactorEnabled ? "예" : "아니오"}
</div>
</div>
) : (
!error && <div>사용자 정보를 불러오는 중...</div>
)}
<button className="btn mb-2 w-full" onClick={handleLogout}>
로그아웃
</button>
<button className="btn w-full" onClick={handleChangePassword}>
패스워드 변경
</button>
</div>
</div>
{/* 두번째 라인 */}
<div className="grid grid-cols-4 gap-1">
<div className="card card-border"id="grouping-statistics">
<div className="card-header text-foreground">
<h3 className="text-2xl font-medium">지원현황</h3>
</div>
<div className="h-70 p-4">
</div>
</div>
<div className="card card-border">
<div className="card-header text-foreground">
<h3 className="text-2xl font-medium">자료관 자료현황</h3>
</div>
<div className="h-70 p-4">
</div>
</div>
<div className="card card-border">
<div className="card-header text-foreground">
<h3 className="text-2xl font-medium">공지사항</h3>
</div>
<div className="h-70 p-4">
</div>
</div>
{/* 배너 영역 */}
<div className="card card-border">
</div>
</div>
</div>
</div>
);
}
export { DashboardPage };
@@ -0,0 +1,142 @@
"use client";
/**
* 수계별 시설운영 개소수 및 가동률
*/
import React, { useEffect, useState, useMemo } from 'react';
import { useStore } from "zustand";
import BasinSelect from "./basIn-select";
import { Doughnut } from "react-chartjs-2";
// import type { OperFcltContent } from "../model/fo-interface";
import { OperationApi, operFcltStore } from "../api/fo-service";
const FoFcltCntRt: React.FC = () => {
const [year, setYear] = useState<string | null>(null);
const [selectedBasin, setSelectedBasin] = useState<string>("한강");
const operFcltStatus = useStore(operFcltStore, (state) => state.operFcltStatus);
useEffect(() => {
const fetchData = async () => {
try {
const standardYear = await OperationApi.getStandardYear();
setYear(standardYear);
await OperationApi.getDashOperCntRt(standardYear);
} catch (e) {
console.error("시설운영 데이터 조회 실패", e);
}
};
fetchData();
}, []);
const displayData = useMemo(() => {
if (!operFcltStatus) {
return { facilityCount: 0, operatingRate: 0, chartData: [0, 100] };
}
const basinData = operFcltStatus.filter(d => d.rvbsn === selectedBasin);
const operatingData = basinData.find(d => d.gubun === 'R');
const nonOperatingData = basinData.find(d => d.gubun === 'A');
const facilityCount = operatingData?.cnt ?? 0;
const operatingCapacity = operatingData?.fctCpc ?? 0;
const nonOperatingCapacity = nonOperatingData?.fctCpc ?? 0;
const totalCapacity = operatingCapacity + nonOperatingCapacity;
const operatingRate = totalCapacity > 0 ? Math.round((operatingCapacity / totalCapacity) * 100) : 0;
return {
facilityCount,
operatingRate,
chartData: [operatingCapacity, nonOperatingCapacity],
};
}, [operFcltStatus, selectedBasin]);
return (
<div className="card card-border">
<div className="card-header text-foreground">
<h3 className="text-2xl font-medium">수계별 시설운영 개소수 및 가동률</h3>
<BasinSelect selectedBasin={selectedBasin} onValueChange={setSelectedBasin} />
</div>
<div className="h-60 p-4">
<div className="flex gap-2 h-full">
{/* 왼쪽: 개소수 카드 */}
<div className="flex-1">
<div className="card card-border h-full flex flex-col">
<div className="card-header text-foreground text-center">
<h3 className="text-l font-medium mx-auto">개소수 ({year}년도)</h3>
</div>
<div className="flex items-center justify-center flex-1">
<div style={{ fontSize: "38px", textAlign: "center", fontWeight: "600", lineHeight: "1.2", color: "#0ec58a" }}>
{displayData.facilityCount.toLocaleString()} <span style={{ fontSize: "14px", color: "#000000" }}>개소</span>
</div>
</div>
</div>
</div>
{/* 오른쪽: Doughnut 차트 */}
<div className="flex-1">
<div className="card card-border h-full flex flex-col">
<div className="card-header text-foreground text-center">
<h3 className="text-l font-medium mx-auto">가동률 ({year}년도)</h3>
</div>
<div className="flex-1 flex items-start justify-center">
<div style={{ width: "100px", height: "100px", display: "flex", alignItems: "flex-start", justifyContent: "center" }}>
<Doughnut
data={{
datasets: [{
data: displayData.chartData,
backgroundColor: ['#00DFE5', '#f5f5f5'],
borderWidth: 0
}]
}}
options={{
responsive: true,
maintainAspectRatio: true,
cutout: '60%',
circumference: 180, // 반원
rotation: -90, // 왼쪽에서 시작
layout: {
padding: 0,
},
plugins: {
legend: {
display: false
},
tooltip: {
enabled: false
}
}
}}
plugins={[{
id: 'centerText',
afterDraw: (chart) => {
const ctx = chart.ctx;
const centerX = chart.width / 2;
const centerY = chart.height / 2;
// 가동률 텍스트
ctx.save();
ctx.font = 'bold 16px Arial';
ctx.fillStyle = '#00DFE5';
ctx.textAlign = 'center';
ctx.textBaseline = 'middle';
ctx.fillText(`${displayData.operatingRate}%`, centerX, centerY + 10);
// 라벨 텍스트
ctx.font = '10px Arial';
ctx.fillStyle = '#666';
ctx.fillText('가동률', centerX, centerY + 28);
ctx.restore();
}
}]}
/>
</div>
</div>
</div>
</div>
</div>
</div>
</div>
);
};
export default FoFcltCntRt;
@@ -0,0 +1,229 @@
"use client";
/**
* 수계별 유입ㆍ방류수질 분석
*/
import React, { useState } from 'react';
import { Line } from 'react-chartjs-2';
import BasinSelect from "./basIn-select";
import {
Chart as ChartJS,
CategoryScale,
LinearScale,
PointElement,
LineElement,
Title,
Tooltip,
Legend,
type ChartOptions,
} from 'chart.js';
// Chart.js 등록
ChartJS.register(
CategoryScale,
LinearScale,
PointElement,
LineElement,
Title,
Tooltip,
Legend
);
type WaterQualityType = 'BOD' | 'TOC' | 'SS' | 'TP' | 'TN';
interface ChartData {
labels: string[];
inflowData: number[];
inflowAverage: number[];
effluentData: number[];
effluentAverage: number[];
}
const FoWqltInflEffl: React.FC = () => {
const [selectedTab, setSelectedTab] = useState<WaterQualityType>('BOD');
// 데이터 (실제로는 API에서 가져올 데이터)
const bodData: ChartData = {
labels: ['2020', '2021', '2022', '2023', '2024'],
inflowData: [2255.6, 2282.2, 2304.3, 5156.6, 4620.4],
inflowAverage: [2255.6, 2282.2, 2304.3, 2304.3, 2304.3],
effluentData: [55, 47.5, 52, 79.7, 83.9],
effluentAverage: [55, 47.5, 52, 52, 52],
};
const tabs: WaterQualityType[] = ['BOD', 'TOC', 'SS', 'TP', 'TN'];
// 차트 공통 옵션
const getChartOptions = (yAxisLabel: string): ChartOptions<'line'> => ({
responsive: true,
maintainAspectRatio: false,
interaction: {
mode: 'index',
intersect: false,
},
plugins: {
legend: {
display: true,
position: 'bottom',
labels: {
usePointStyle: true,
boxWidth: 6,
color: '#000',
font: {
size: 8,
},
},
},
tooltip: {
enabled: true,
titleFont: { size: 10 },
bodyFont: { size: 10 },
},
},
scales: {
x: {
grid: {
color: 'rgba(255, 255, 255, 0.1)',
tickLength: 4, // 눈금 길이 축소
},
ticks: {
color: '#000',
font: {
size: 8, // 폰트 크기 축소
},
padding: 0,
},
},
y: {
grid: {
color: 'rgba(255, 255, 255, 0.1)',
},
ticks: {
color: '#000',
font: {
size: 8, // 폰트 크기 축소
},
maxTicksLimit: 4, // 눈금 개수 제한
padding: 2,
},
title: {
display: false,
},
},
},
layout: {
padding: {
top: 5,
bottom: 0,
left: 0,
right: 5
}
}
});
// 유입 데이터 차트 설정
const inflowChartData = {
labels: bodData.labels,
datasets: [
{
label: '유입BOD (mg/L)',
data: bodData.inflowData,
borderColor: '#00D4FF',
backgroundColor: '#00D4FF',
borderWidth: 1,
pointRadius: 2,
pointHoverRadius: 4,
tension: 0.4,
},
{
label: '유입 평균 (mg/L)',
data: bodData.inflowAverage,
borderColor: '#FF1493',
backgroundColor: '#FF1493',
borderWidth: 1,
borderDash: [10, 5],
pointRadius: 2,
pointHoverRadius: 4,
tension: 0,
},
],
};
// 발류 데이터 차트 설정
const effluentChartData = {
labels: bodData.labels,
datasets: [
{
label: '발류BOD (mg/L)',
data: bodData.effluentData,
borderColor: '#00FF88',
backgroundColor: '#00FF88',
borderWidth: 1,
pointRadius: 2,
pointHoverRadius: 4,
tension: 0.4,
},
{
label: '발류 평균 (mg/L)',
data: bodData.effluentAverage,
borderColor: '#FFB800',
backgroundColor: '#FFB800',
borderWidth: 1,
borderDash: [10, 5],
pointRadius: 1,
pointHoverRadius: 4,
tension: 0,
},
],
};
return (
<div className="card card-border">
<div className="card-header text-foreground">
<h3 className="text-2xl font-medium">수계별 유입ㆍ방류수질 분석</h3>
<BasinSelect />
</div>
<div className="h-60">
<div style={{ padding: '4px', height: '100%', display: 'flex', flexDirection: 'column' }}>
{/* 탭 메뉴 */}
<div style={{ display: 'flex', gap: '20px', borderBottom: '1px solid #000000', flexShrink: 0 }}>
{tabs.map((tab) => (
<button
key={tab}
onClick={() => setSelectedTab(tab)}
style={{
background: 'none',
border: 'none',
color: selectedTab === tab ? '#00D4FF' : '#6b7c93',
fontSize: '8px',
fontWeight: selectedTab === tab ? 'bold' : 'normal',
cursor: 'pointer',
borderBottom: selectedTab === tab ? '3px solid #00D4FF' : 'none',
transition: 'all 0.3s',
}}
>
{tab}
</button>
))}
</div>
{/* 차트 영역 */}
<div style={{ display: 'flex', flexDirection: 'column',flex: 1, minHeight: 0 }}>
{/* 유입 차트 */}
<div style={{ flex: 1, position: 'relative', minHeight: 0 }}>
<Line data={inflowChartData} options={getChartOptions('mg/L')} />
</div>
{/* 발류 차트 */}
<div style={{ flex: 1, position: 'relative', minHeight: 0 }}>
<Line data={effluentChartData} options={getChartOptions('mg/L')} />
</div>
</div>
</div>
</div>
</div>
);
};
export default FoWqltInflEffl;
@@ -0,0 +1,213 @@
"use client";
/**
* 물재이용 차트
*/
import React, { useState, useEffect, useRef } from 'react';
import { useStore } from "zustand";
import { Line } from 'react-chartjs-2';
import { Button } from "@common/components";
import type { ReuseStatusContent } from "../model/fo-interface";
import { OperationApi, reuseStore } from "../api/fo-service";
import {
Chart as ChartJS,
CategoryScale,
LinearScale,
PointElement,
LineElement,
Title,
Tooltip,
Legend,
Filler,
type ChartOptions
} from 'chart.js';
// Chart.js 플러그인 등록
ChartJS.register(
CategoryScale,
LinearScale,
PointElement,
LineElement,
Title,
Tooltip,
Legend,
Filler // 영역 채우기를 위해 필요
);
const ReuseChart: React.FC = () => {
const [activeIndex, setActiveIndex] = useState(0);
const intervalRef = useRef<NodeJS.Timeout | null>(null);
const reuseStatusData = useStore(
reuseStore,
(state) => state.reuseStatus) as ReuseStatusContent[];
useEffect(() => {
OperationApi.getReuseStatus().catch((e) => {
console.error("물재이용 차트 데이터 조회 실패", e);
});
}, []);
const labels = reuseStatusData?.map((r) => r.yr);
const sewageData = reuseStatusData?.map((r) => r.sewage);
const reclaimedData = reuseStatusData?.map((r) => r.reclaimed);
const rainwaterData = reuseStatusData?.map((r) => r.rainwater);
const commonChartData = {
datasets: [
{
label: '재이용량',
data: sewageData, // 기본은 하수처리수
borderColor: '#00D4FF', // 선 색상 (밝은 하늘색)
backgroundColor: 'rgba(0, 212, 255, 0.1)', // 채우기 색상 (투명도 있는 하늘색)
borderWidth: 3,
pointBackgroundColor: '#00D4FF',
pointBorderColor: '#fff',
pointBorderWidth: 2,
pointRadius: 6,
pointHoverRadius: 8,
fill: true, // 아래 영역 채우기 활성화
tension: 0.4, // 곡선 부드럽게
},
],
};
const commonChartOptions: ChartOptions<'line'> = {
maintainAspectRatio: false,
plugins: {
legend: {
display: false, // 범례 숨김 (이미지에 없음)
},
tooltip: {
callbacks: {
label: (context) => {
let label = context.dataset.label || '';
if (label) {
label += ': ';
}
if (context.parsed.y !== null) {
label += new Intl.NumberFormat('ko-KR').format(context.parsed.y) + '㎥';
}
return label;
}
}
}
},
scales: {
x: {
grid: {
display: true,
color: '#f0f0f0', // 연한 회색 그리드
},
ticks: {
color: '#666',
}
},
y: {
min: 1100000, // Y축 최소값 설정 (그래프 모양을 위해)
grid: {
color: '#f0f0f0',
},
ticks: {
color: '#666',
callback: function(value) {
return new Intl.NumberFormat('ko-KR').format(Number(value)) + '㎥';
}
}
}
},
interaction: {
mode: 'index',
intersect: false,
},
};
const chartItems = [
{
title: "하수처리수 재이용량 (m³)",
data: {
...commonChartData,
labels,
datasets: [{ ...commonChartData.datasets[0], data: sewageData }],
},
options: commonChartOptions,
},
{
title: "중수도 재이용량 (m³)",
data: {
...commonChartData,
labels,
datasets: [{ ...commonChartData.datasets[0], data: reclaimedData }],
},
options: commonChartOptions,
},
{
title: "빗물 재이용량 (m³)",
data: {
...commonChartData,
labels,
datasets: [{ ...commonChartData.datasets[0], data: rainwaterData }],
},
options: commonChartOptions,
},
];
const startSlider = () => {
intervalRef.current = setInterval(() => {
setActiveIndex(prevIndex => (prevIndex + 1) % chartItems.length);
}, 20000);
};
const stopSlider = () => {
if (intervalRef.current) {
clearInterval(intervalRef.current);
}
};
useEffect(() => {
startSlider();
return () => stopSlider();
}, []);
const handleIndicatorClick = (index: number) => {
stopSlider();
setActiveIndex(index);
startSlider();
};
return (
<div className="card card-border">
<div className="card-header text-foreground">
<h3 className="text-2xl font-medium">{chartItems[activeIndex]?.title}</h3>
<Button variant="outline" size="sm" className="text-white h-8 py-0 ml-auto">더보기</Button>
</div>
<div className="h-60 p-4 relative overflow-hidden">
<div
className="flex transition-transform duration-500 ease-in-out h-full"
style={{ transform: `translateX(-${activeIndex * 100}%)` }}
>
{chartItems.map((chart, index) => (
<div key={index} className="w-full flex-shrink-0 h-full">
<Line data={chart.data} options={chart.options as ChartOptions<'line'>} />
</div>
))}
</div>
<div className="absolute bottom-4 left-1/2 -translate-x-1/2 flex space-x-2">
{chartItems.map((_, index) => (
<button
key={index}
onClick={() => handleIndicatorClick(index)}
className={`w-3 h-3 rounded-full cursor-pointer transition-colors ${
activeIndex === index ? 'bg-blue-500' : 'bg-gray-300'
}`}
aria-label={`Go to slide ${index + 1}`}
/>
))}
</div>
</div>
</div>
);
};
export default ReuseChart;
@@ -0,0 +1,81 @@
"use client";
/**
* 물재이용 현황
*/
import React, { useEffect } from 'react';
import { useStore } from "zustand";
import { Button } from "@common/components";
import type { ReuseStatusContent } from "../model/fo-interface";
import { OperationApi, reuseStore} from "../api/fo-service";
const ReuseStatus: React.FC = () => {
// const [reuseStatusData, setReuseStatusData] = useState<ReuseStatusContent[]>([]);
const reuseStatusData = useStore(
reuseStore,
(state) => state.reuseStatus ) as ReuseStatusContent[];
useEffect(() => {
OperationApi.getReuseStatus().catch((e) => {
console.error("물재이용 현황 데이터 조회 실패", e);
});
}, []);
// 숫자 포맷 함수 (천단위 쉼표)
const formatNumber = (num: number): string => {
return new Intl.NumberFormat('ko-KR').format(num);
};
return (
<div className="card card-border">
<div className="card-header text-foreground">
<h3 className="text-2xl font-medium">물재이용 현황 (재이용량, m³)</h3>
<Button variant="outline" size="sm" className="text-white h-8 py-0 ml-auto">더보기</Button>
</div>
<div className="h-70 p-4">
<div className="w-full h-full flex items-center justify-center">
<table className="w-full border-collapse text-md">
<thead>
<tr>
<th className="bg-blue-500 text-white font-bold py-1 px-3 border border-gray-300">
년도
</th>
<th className="bg-blue-500 text-white font-bold py-1 px-3 border border-gray-300">
하수재이용량
</th>
<th className="bg-blue-500 text-white font-bold py-1 px-3 border border-gray-300">
중수도이용량
</th>
<th className="bg-blue-500 text-white font-bold py-1 px-3 border border-gray-300">
빗물이용량
</th>
</tr>
</thead>
<tbody>
{reuseStatusData && reuseStatusData.length > 0 && reuseStatusData?.map((row, index) => (
<tr key={index} className="hover:bg-gray-50">
<td className="bg-blue-100 font-semibold text-center py-1 px-3 border border-gray-300">
{row.yr}
</td>
<td className="text-center py-1 px-3 border border-gray-300 bg-white">
{formatNumber(row.sewage)}
</td>
<td className="text-center py-1 px-3 border border-gray-300 bg-white">
{formatNumber(row.reclaimed)}
</td>
<td className="text-center py-1 px-3 border border-gray-300 bg-white">
{formatNumber(row.rainwater)}
</td>
</tr>
))}
</tbody>
</table>
</div>
</div>
</div>
);
};
export default ReuseStatus;
@@ -0,0 +1,180 @@
"use client";
/**
* 노후 하수관로 현황
*/
import React, { useEffect, useState } from "react";
import {
Chart as ChartJS,
CategoryScale,
LinearScale,
BarElement,
Title,
Tooltip,
Legend,
type ChartOptions,
} from "chart.js";
import { Bar } from "react-chartjs-2";
import { Button } from "@common/components";
import type { PplLengthContent } from "../model/sn-interface";
import { SewerageApi } from "../api/sn-service";
interface ChartDataSet {
label: string;
data: (number | undefined)[];
backgroundColor: string;
borderRadius?: number;
}
// ChartJS 컴포넌트 등록
ChartJS.register(CategoryScale, LinearScale, BarElement, Title, Tooltip, Legend);
export default function SnOldPpl() {
const [oldPpl2025Data, setOldPpl2025Data] = useState<PplLengthContent[]>([]);
const [oldPpl2530Data, setOldPpl2530Data] = useState<PplLengthContent[]>([]);
const [oldPpl30Data, setOldPpl30Data] = useState<PplLengthContent[]>([]);
const [chartData, setChartData] = useState<{
labels: string[];
datasets: ChartDataSet[];
}>({ labels: [], datasets: [] });
useEffect(() => {
const currentYear = new Date().getFullYear();
const ranges = {
"20-25년": { from: `${currentYear - 25 + 1}0101`, to: `${currentYear - 20}1231`, setter: setOldPpl2025Data },
"25-30년": { from: `${currentYear - 30 + 1}0101`, to: `${currentYear - 25}1231`, setter: setOldPpl2530Data },
"30년이상": { from: "19010101", to: `${currentYear - 30}1231`, setter: setOldPpl30Data },
};
Object.entries(ranges).forEach(([label, range]) => {
SewerageApi.getPplLength(range.from, range.to)
.then(range.setter)
.catch((e) => console.error(`${label} 데이터 조회 실패`, e));
});
}, []);
useEffect(() => {
const allData = [...oldPpl2025Data, ...oldPpl2530Data, ...oldPpl30Data];
if (allData.length === 0) return;
const allZones = Array.from(new Set(allData.map(d => d.zoneNm)));
const createDataset = (label: string, data: PplLengthContent[], color: string) => {
const dataMap = new Map(data.map(item => [item.zoneNm, item.sumPrlgVl]));
console.log(dataMap);
return {
label,
data: allZones.map(zone => dataMap.get(zone) ?? 0),
backgroundColor: color,
borderRadius: 4,
};
};
const newDatasets: ChartDataSet[] = [];
if (oldPpl2025Data.length > 0) {
newDatasets.push(createDataset('20-25년', oldPpl2025Data, '#3B82F6'));
}
if (oldPpl2530Data.length > 0) {
newDatasets.push(createDataset('25-30년', oldPpl2530Data, '#EF4444'));
}
if (oldPpl30Data.length > 0) {
newDatasets.push(createDataset('30년이상', oldPpl30Data, '#F87171'));
}
setChartData({
labels: allZones,
datasets: newDatasets,
});
}, [oldPpl2025Data, oldPpl2530Data, oldPpl30Data]);
// 각 지역별 총 길이 계산
const totalLengths = chartData.labels.map((label, i) => {
return chartData.datasets.reduce((sum, dataset) => {
const value = dataset.data[i];
return sum + (typeof value === 'number' ? value : 0);
}, 0);
});
// 차트 옵션 설정
const options: ChartOptions<"bar"> = {
responsive: true,
maintainAspectRatio: false,
scales: {
x: {
grid: {
display: true,
},
},
y: {
beginAtZero: true,
min: 0,
max: 10000,
ticks: {
stepSize: 2000,
callback: function (value) {
return Number(value).toLocaleString() + 'km';
}
},
grid: {
color: 'rgba(200, 200, 200, 0.3)',
}
}
},
plugins: {
legend: {
position: 'top',
align: 'center', // ⬅ 가운데 정렬
labels: {
boxWidth: 15,
padding: 20, // ⬅ 범례 항목 간 여백 확대
usePointStyle: false,
},
},
tooltip: {
callbacks: {
label: function (context) {
return `${context.dataset.label}: ${Number(context.parsed.y).toLocaleString()}km`;
}
}
},
},
// 총 길이 데이터 표시를 위한 애니메이션 후 함수 설정
animation: {
onComplete: function (animation) {
const chart = animation.chart;
const ctx = chart.ctx;
// 각 데이터셋의 마지막 데이터셋의 인덱스
if (chartData.datasets.length === 0) return;
// const lastMeta = chart.getDatasetMeta(chartData.datasets.length - 1);
// 텍스트 스타일 설정
ctx.font = '11px Arial';
ctx.fillStyle = '#333';
ctx.textAlign = 'center';
// 각 지역별로 총 길이 표시
chartData.labels.forEach((_, i) => {
const xPos = chart.scales.x ? chart.scales.x.getPixelForTick(i) : 0;
const yPos = chart.chartArea.top; // 차트 영역 최상단 위에 표시
// 막대 위에 총 길이 텍스트 표시
if (totalLengths[i]) ctx.fillText(`총 ${totalLengths[i]?.toLocaleString()}km`, xPos, yPos);
});
}
}
};
return (
<div className="card card-border">
<div className="card-header text-foreground">
<h3 className="text-2xl font-medium">노후 하수관로 현황</h3>
<Button variant="outline" size="sm" className="text-white h-8 py-0 ml-auto">더보기</Button>
</div>
<div className="h-70 p-0 flex items-center justify-center">
<div style={{ width: "100%", maxWidth: 400, height: 131 }}>
<Bar options={options} data={chartData} />
</div>
</div>
</div>
);
}
@@ -0,0 +1,202 @@
"use client";
/**
* 오수관로 및 우수관로 현황
*/
import React, { useEffect, useState } from "react";
import {
Chart as ChartJS,
CategoryScale,
LinearScale,
BarElement,
Title,
Tooltip,
Legend,
type ChartOptions,
} from "chart.js";
import { Bar } from "react-chartjs-2";
import { Button } from "@common/components";
import type { PplLengthContent } from "../model/sn-interface";
import { SewerageApi } from "../api/sn-service";
interface ChartDataSet {
label: string;
data: (number | undefined)[];
backgroundColor: string;
borderColor?: string;
}
ChartJS.register(CategoryScale, LinearScale, BarElement, Title, Tooltip, Legend);
export default function SnPplStatus() {
const [sewageData, setSewageData] = useState<PplLengthContent[]>([]);
const [stormwaterData, setStormwaterData] = useState<PplLengthContent[]>([]);
const [totalData, setTotalData] = useState<PplLengthContent[]>([]);
const [chartData, setChartData] = useState<{
labels: string[];
datasets: ChartDataSet[];
}>({ labels: [], datasets: [] });
useEffect(() => {
const ymdFrom = "19010101";
const ymdTo = "20241231";
SewerageApi.getPplUseLength(ymdFrom, ymdTo, "SBA004")
.then((apiResult) => {
// getPplUseLength의 결과(apiResult)를
// setSewageData 함수의 인자로 넣어서 호출합니다.
// console.log(apiResult);
setSewageData(apiResult);
})
.catch((e) => console.error("오수관 데이터 조회 실패", e));
SewerageApi.getPplUseLength(ymdFrom, ymdTo, "SBA003")
.then(setStormwaterData)
.catch((e) => console.error("우수관 데이터 조회 실패", e));
SewerageApi.getPplLength(ymdFrom, ymdTo)
.then((apiResult) => {
// getPplLength 결과(apiResult)를
// setTotalData 함수의 인자로 넣어서 호출합니다.
// console.log(apiResult);
setTotalData(apiResult);
})
.catch((e) => console.error("전체 관로 데이터 조회 실패", e));
}, []);
// 데이터가 변경될 때마다 차트를 다시 계산
useEffect(() => {
// 오수관 또는 우수관 데이터가 없으면 차트를 그리지 않음
if (sewageData.length === 0 && stormwaterData.length === 0) return;
const allZones = new Set([
...sewageData.map(d => d.zoneNm),
...stormwaterData.map(d => d.zoneNm),
...totalData.map(d => d.zoneNm)
]);
const labels = Array.from(allZones);
const dataByZone: { [key: string]: { total: number; sewage: number; stormwater: number } } = {};
labels.forEach(zone => {dataByZone[zone] = { total: 0, sewage: 0, stormwater: 0 }; });
totalData.forEach(item => {
if (!dataByZone[item.zoneNm]) {
dataByZone[item.zoneNm] = { total: 0, sewage: 0, stormwater: 0 };
}
dataByZone[item.zoneNm]!.total = item.sumPrlgVl;
});
sewageData.forEach(item => {
if (!dataByZone[item.zoneNm]) {
dataByZone[item.zoneNm] = { total: 0, sewage: 0, stormwater: 0 };
}
dataByZone[item.zoneNm]!.sewage = item.sumPrlgVl;
});
stormwaterData.forEach(item => {
if (!dataByZone[item.zoneNm]) {
dataByZone[item.zoneNm] = { total: 0, sewage: 0, stormwater: 0 };
}
dataByZone[item.zoneNm]!.stormwater = item.sumPrlgVl;
});
const datasets: ChartDataSet[] = [];
// 오수관로 데이터셋
datasets.push({
label: "오수관로",
data: labels.map(zone => dataByZone[zone]?.sewage),
backgroundColor: "rgba(180, 240, 190, 0.95)",
});
// 우수관로 데이터셋
datasets.push({
label: "우수관로",
data: labels.map(zone => dataByZone[zone]?.stormwater),
backgroundColor: "rgba(170, 210, 245, 0.95)",
});
// 전체 데이터가 도착했다면 '기타' 데이터셋을 계산하고 추가
if (totalData.length > 0) {
datasets.unshift({ // '기타'를 맨 아래에 쌓기 위해 unshift 사용
label: "기타",
data: labels.map(zone => {
const zoneData = dataByZone[zone] ?? { total: 0, sewage: 0, stormwater: 0 };
return zoneData.total - zoneData.sewage - zoneData.stormwater;
}),
backgroundColor: "rgba(255, 178, 150, 0.9)",
});
}
setChartData({ labels, datasets });
}, [sewageData, stormwaterData, totalData]);
// 총합(라벨당) 계산
const totals = chartData.labels.map((_, i) =>
chartData.datasets.reduce((s, ds) => s + (ds.data[i] as number), 0)
);
const options: ChartOptions<"bar"> = {
responsive: true,
maintainAspectRatio: false,
plugins: {
legend: {
position: "top",
},
tooltip: {
callbacks: {
label: (ctx) => `${ctx.dataset.label}: ${Number(ctx.parsed.y).toLocaleString()}km`,
},
},
},
scales: {
x: {
stacked: true,
},
y: {
stacked: true,
beginAtZero: true,
ticks: {
callback: (value) => `${Number(value).toLocaleString()}km`,
},
},
},
// 각 스택 위에 총합 표시
animation: {
onComplete: function (animation) {
const chart = animation.chart;
const ctx = chart.ctx;
const lastMeta = chart.getDatasetMeta(chartData.datasets.length - 1);
ctx.save();
ctx.fillStyle = "#111";
ctx.font = "gray bold 12px Arial";
ctx.textAlign = "center";
totals.forEach((total, i) => {
const bar = lastMeta.data[i];
if (!bar) return;
const x = bar.x;
// y는 마지막 스택의 y 좌표(스택의 최상단)에서 조금 위로
const y = bar.y - 8;
ctx.fillText(`총 ${total.toLocaleString()}km`, x, y);
});
ctx.restore();
},
},
};
//const data = { labels, datasets };
return (
<div className="card card-border">
<div className="card-header text-foreground">
<h3 className="text-2xl font-medium">오수관로 및 우수관로 현황</h3>
<Button variant="outline" size="sm" className="text-white h-8 py-0 ml-auto">더보기</Button>
</div>
<div className="h-60 p-4">
<div style={{ width: "100%", maxWidth: 400, height: 150 }}>
<Bar options={options} data={chartData} />
</div>
</div>
</div>
);
}
+7
View File
@@ -70,9 +70,16 @@ export const ENDPOINT = {
POST_EVALUATION: "/board-service/api/v1/pstEvl",
},
SEWERAGE_SERVICE: {
PPL_LENGTH: "/sewerage-service/api/v1/ms/pplLength",
PPL_USE_LENGTH: "/sewerage-service/api/v1/ms/pplUseLength",
PPL_COUNTS: "/sewerage-service/api/v1/ms/pplCounts",
OLD_PPL_COUNTS: "/sewerage-service/api/v1/ms/oldPplCounts",
},
OPERATION_SERVICE: {
MAIN_YEAR: "/operation-service/api/v1/dashBoard/main/year",
OPER_CNTRT: "/operation-service/api/v1/dashBoard/main/oper/cntrt",
REUSE_STATUS: "/operation-service/api/v1/dashBoard/main/reuse/status",
},
IC_SERVICE: {
GROUPING_STATISTICS: "/ic-service/api/v1/nti/grouping-statistics",
},
+1
View File
@@ -9,6 +9,7 @@ NEXT_PUBLIC_PROXY_HOST = http://localhost:3005
NEXT_PUBLIC_DM_SERVER_API_URL = http://con.mind-one.co.kr:11083/reuse-service/api/v1
NEXT_PUBLIC_SERVER_API_URL = http://dev.onasys.co.kr:8000
NEXT_PUBLIC_FO_SERVER_API_URL = http://con.mind-one.co.kr:11083/operation-service/api/v1
# NEXT_PUBLIC_FO_SERVER_API_URL = http://192.168.1.222:8883/operation-service/api/v1
NEXT_PUBLIC_CS_SERVER_API_URL = http://dev.onasys.co.kr:8000
NEXT_PUBLIC_GEOSERVER_URL = http://rsone.iptime.org:10010/geoserver
NEXT_PUBLIC_LX_GEOSERVER_URL = http://con.mind-one.co.kr:11306/geoserver
@@ -28,11 +28,12 @@ export default function WrStatusPage() {
const { Common, dmStatus, getDmStatusData,initializeCommonData} = useDmStore((state) => state);
const { statusData,facilityTotals ,chartData} = dmStatus;
const { year:defaultYear, statusData,facilityTotals ,chartData} = dmStatus;
const [selectedRegion, setSelectedRegion] = useState<string | null>(null);
const [hoveredRegion, setHoveredRegion] = useState<string | null>(null);
const [tooltipPosition, setTooltipPosition] = useState<{ x: number; y: number } | null>(null);
const [chartOption, setChartOption] = useState<WrStatusChartProps>({} as WrStatusChartProps)
const [chartOption, setChartOption] = useState<WrStatusChartProps>({} as WrStatusChartProps);
const svgContainerRef = useRef<HTMLDivElement>(null);
const colorLegend = [
{ label: '하수', color: '#FF6B6B', key: 'sewage' },
@@ -40,7 +41,8 @@ export default function WrStatusPage() {
{ label: '중수도', color: '#45B7D1', key: 'reclaimed' },
];
const facilityTotalsColor = ['#FF6B6B', '#4ECDC4', '#45B7D1', '#FED16A'];
const defaultYear= '2023'
// const [defaultYear, setDefaultYear] = useState<string>("");
// const defaultYear= '2023';
const handleMarkerClick = (regionId: string) => {
@@ -151,11 +153,10 @@ export default function WrStatusPage() {
useEffect(() => {
if(Common.selectSidoData&&Common.selectSidoData.length>1)
getDmStatusData(Common.selectSidoData);
},[Common.selectSidoData])
if(Common.selectSidoData&&Common.selectSidoData.length>1) {
getDmStatusData(Common.selectSidoData);
}
},[Common.selectSidoData, getDmStatusData])
useEffect(() => {
const loadSVG = async () => {
+9 -4
View File
@@ -8,11 +8,12 @@ import type { SelectDataType } from "../common";
import { makeRegion } from "@/app/common/comFunction";
export type DmStatusActions = {
getDmStatusData(cityCd: SelectDataType[]): void;
getDmStatusData(cityCd: SelectDataType[]): void; // 데이터의 연도 반환
};
export type DmStatusType = {
dmStatus: {
year : string;
statusData: DmStatusDataType[];
facilityTotals: string[];
chartData: DmStatusChartType[];
@@ -21,6 +22,7 @@ export type DmStatusType = {
export const dmStatusState: DmStatusType = {
dmStatus: {
year : '',
statusData: [],
facilityTotals: [],
chartData: [],
@@ -32,6 +34,7 @@ export const DmStatusReducer = (set: any): DmStatusActions => {
return {
getDmStatusData: async (cityCd: SelectDataType[]) => {
const data = await getFetch(fetchType.FO, "/dashBoard/daemin");
const year = data.data[0]?.year; // API 응답에서 year 값을 할당합니다.
console.log('cityCd ', cityCd)
const dataSum = data.data.reduce(
(acc: DmStatusDataType, item: DmStatusDataType) => {
@@ -73,7 +76,7 @@ export const DmStatusReducer = (set: any): DmStatusActions => {
);
data.data.unshift(dataSum);
console.log("dataSum ", dataSum);
//console.log("dataSum ", dataSum);
const facilityTotals = [
"하수재이용시설" + "(총" + dataSum?.sewageCnt + "개소)",
"빗물재이용시설" + "(총" + dataSum?.rainwaterCnt + "개소)",
@@ -101,8 +104,8 @@ export const DmStatusReducer = (set: any): DmStatusActions => {
}
).filter((item): item is DmStatusBoardType => item !== undefined);
console.log("boardData ", boardData);
//console.log("boardData ", boardData);
const chartData = data.data.map((item) => ({
label: makeRegion(
cityCd.find((city) => item.region === city.val)?.text ?? ""
@@ -116,11 +119,13 @@ export const DmStatusReducer = (set: any): DmStatusActions => {
...state,
dmStatus: {
...state.dmStatus,
year : year,
statusData: boardData,
chartData: chartData,
facilityTotals: facilityTotals,
},
}));
},
};
};
+1 -1
View File
@@ -18,7 +18,7 @@
"js-cookie": "^3.0.5",
"js-sha512": "^0.9.0",
"lucide-react": "^0.474.0",
"next": "15.1.7",
"next": "15.3.6",
"react-hook-form": "^7.54.2",
"react-hot-toast": "^2.5.2",
"tailwind-merge": "^3.0.1",
+1 -1
View File
@@ -5,7 +5,7 @@ NEXT_PUBLIC_AUTH_USER_ID = token-id
NEXT_PUBLIC_ACCESS_TOKEN = access-token
NEXT_PUBLIC_REFRESH_TOKEN = refresh-token
NEXT_PUBLIC_PROXY_HOST = http://localhost:3000
NEXT_PUBLIC_CM_LOGIN_PAGE_URL = http://localhost:3001/auth/login
#NEXT_PUBLIC_CM_LOGIN_PAGE_URL = http://localhost:3001/auth/login
NEXT_PUBLIC_SERVER_API_URL = http://con.mind-one.co.kr:11083/operation-service
NEXT_PUBLIC_WR_SERVER_API_URL = http://con.mind-one.co.kr:11083/reuse-service/
NEXT_PUBLIC_CS_SERVER_API_URL = http://dev.onasys.co.kr:8000
+8 -1
View File
@@ -11,11 +11,18 @@ export default function Home() {
const checkToken = getCookie(AUTH_USER_ID);
useEffect(() => {
if (!checkToken) {
// 외부 URL로 이동해야 할 경우 location.replace를 사용합니다.
if (window.location.hostname.includes("hasuon.or.kr")) {
location.replace(process.env.NEXT_PUBLIC_CM_LOGIN_PAGE_URL||'/pages/dashboard');
return; // 페이지 이동 후에는 더 이상 로직을 실행하지 않습니다.
}
async function doLogin() {
try {
await LoginApi.login({ userId: "CLEANWATER", password: "gktneh12!@" });
location.href = "/pages/dashboard";
router.push("/pages/dashboard"); // location.href 대신 router.push 사용
} catch (err) {
console.error("로그인 실패", err);
}
@@ -210,7 +210,7 @@ export default function DashBoard() {
}
];
// 버퍼에 있는 엑셀데이터 전달 후 json으로 변환
await filePickedBlobArray(blobRet, '시설정보및운영현황황', sheetParameter);
await filePickedBlobArray(blobRet, '시설정보및운영현황', sheetParameter);
});
};
break;
@@ -181,11 +181,11 @@ export default function PrecipitationModal(props: PrecipitationModalProps) {
<div className="!m-[0.5rem] max-h-[40rem] overflow-auto">
<Table>
<thead>
<TableRow>
<TableRow className="border-b-0">
<TableHead className="w-1/6">{'시설명'}</TableHead>
<TableCell className="w-1/3">{props.fcltyName}</TableCell>
<TableCell className="w-1/3 !border-b-0">{props.fcltyName}</TableCell>
<TableHead className="w-1/6">{'년월'}</TableHead>
<TableCell className="w-1/3">
<TableCell className="w-1/3 !border-b-0">
<div className="flex">
<div ref={startPickerRef} className="flex justify-center">
<DatePickerRange
@@ -80,7 +80,7 @@ export default function FacilityPrecipitation() {
// 초기화 변수
const [resetPage, setResetPage] = useState<boolean>(false);
// 표 너비
const [widthSize, setWidthSize] = useState<string>("w-[120%]");
// const [widthSize, setWidthSize] = useState<string>("w-[120%]");
// 구분별 그리드 헤더
const [upheadList, setUpheadList] = useState<TableUpperProps[]>(upHeadList1);
// 용량별
@@ -97,7 +97,7 @@ export default function FacilityPrecipitation() {
*/
// 초기화
const resetVal = () => {
setWidthSize("w-[120%]");
// setWidthSize("w-[120%]");
setUpheadList(upHeadList1)
// 상태값 설정
setFcltCpct(selectUpdownData[0]?.val || "0");
@@ -184,7 +184,7 @@ export default function FacilityPrecipitation() {
}, [operationValue, fcltyName, searchFacilityPrecipitationList, searchYear, fcltCpct]);
const chkVal = useCallback((returntype:ReturnType) => {
setWidthSize(returntype.partValue=='A' ?"w-[120%]":"w-[130%]")
// setWidthSize(returntype.partValue=='A' ?"w-[120%]":"w-[130%]")
setUpheadList(returntype.partValue == 'R' ? upHeadList2 : returntype.partValue == 'G'?upHeadList3:upHeadList1);
setResetPage(true);
}, [upHeadList1, upHeadList2, upHeadList3]);
@@ -228,8 +228,8 @@ export default function FacilityPrecipitation() {
ref={ref} isAdmin={false}/>
<ListDiv>
<LoadingOverlay isLoading={isLoading} />
<div className="h-full w-full overflow-x-scroll">
<div className={widthSize}>
<div className="h-full w-full overflow-x-scroll min-[1200px]:overflow-x-hidden">
<div className="w-[120%] min-[1200px]:w-[100%]">
<UiTable cellClick={(index: number | undefined) => index !== undefined && cellClick(index)} headName={"시설별 강수현황"} tableData={mainList} headlist={upheadList} pageSize={10} total={total} resetPageNo={resetPage} onPageChange={handlePageChange} widthFixed={true} />
</div>
<UnderComment divHeight={"3"} commentArray={["강우일은 3mm 이상만 해당 / 강우영향일은 강우일 및 강우일 후 연속 2일간으로 정의"]} />
@@ -53,7 +53,7 @@ export default function ProcessFacilitySearch() {
// 그리드 관련
const [resetPage, setResetPage] = useState<boolean>(false);
const searchInputRef = useRef<HTMLDivElement & HTMLInputElement>(null);
const [widthSize, setWidthSize] = useState<string>("w-[120%]");
// const [widthSize, setWidthSize] = useState<string>("w-[120%]");
const [upheadList, setUpheadList] = useState<TableUpperProps[]>(upHeadList1);
const [fcltyName, setFcltyName] = useState<string>("");
const [operationValue, setOperationValue] = useState<string>("0");
@@ -91,7 +91,7 @@ export default function ProcessFacilitySearch() {
}, [operationValue, fcltyName, searchFacilityWaterQltyList, searchYearValue, fcltCpct]);
const resetVal = () => {
setWidthSize("w-[120%]");
// setWidthSize("w-[120%]");
setUpheadList(upHeadList1)
// 상태값 설정
setFcltCpct(selectUpdownData[0]?.val || "0");
@@ -164,7 +164,7 @@ export default function ProcessFacilitySearch() {
}, [excelList, excelTrigger]);
const chkVal = useCallback((returntype:ReturnType) => {
setWidthSize(returntype.partValue=='A' ?"w-[120%]":"w-[130%]")
// setWidthSize(returntype.partValue=='A' ?"w-[120%]":"w-[130%]")
setUpheadList(returntype.partValue == 'R' ? upHeadList2 : returntype.partValue == 'G'?upHeadList3:upHeadList1);
setResetPage(true);
}, [upHeadList1, upHeadList2, upHeadList3]);
@@ -205,8 +205,8 @@ export default function ProcessFacilitySearch() {
ref={ref} isAdmin={false} initSidoValue={"11"}/>
<ListDiv>
<LoadingOverlay isLoading={isLoading} />
<div className="h-full w-full overflow-x-scroll">
<div className={widthSize}>
<div className="h-full w-full overflow-x-scroll min-[1200px]:overflow-x-hidden">
<div className="w-[120%] min-[1200px]:w-[100%]">
<UiTable cellClick={(index: number | undefined) => index !== undefined && cellClick(index)} headName={"시설별 수질현황"} tableData={mainList} headlist={upheadList} pageSize={10} total={total} resetPageNo={resetPage} onPageChange={handlePageChange} widthFixed={true} />
</div>
</div>
@@ -58,7 +58,7 @@ export default function ProcessFacilitySearch() {
const [fcltyName, setFcltyName] = useState<string>("");
const [operationValue, setOperationValue] = useState<string>("0");
const [searchYearValue, setSearchYearValue] = useState<string>(selectSearchYear[0]?.val || "0");
const [widthSize, setWidthSize] = useState<string>("w-[120%]");
// const [widthSize, setWidthSize] = useState<string>("w-[120%]");
const [upheadList, setUpheadList] = useState<TableUpperProps[]>(upHeadList1);
const [resetPage, setResetPage] = useState<boolean>(false);
const prevExcelListRef = useRef<ExcelType[] | null>(null);
@@ -96,7 +96,7 @@ export default function ProcessFacilitySearch() {
}, [searchFlowRateList, searchYearValue, fcltCpct, operationValue, fcltyName]);
const resetVal = () => {
setWidthSize("w-[120%]");
// setWidthSize("w-[120%]");
setUpheadList(upHeadList1)
// 상태값 설정
setSearchYearValue(selectSearchYear[0]?.val || "0");
@@ -170,7 +170,7 @@ export default function ProcessFacilitySearch() {
});
const chkVal = useCallback((returntype:ReturnType) => {
setWidthSize(returntype.partValue=='A' ?"w-[120%]":"w-[130%]")
// setWidthSize(returntype.partValue=='A' ?"w-[120%]":"w-[130%]")
setUpheadList(returntype.partValue == 'R' ? upHeadList2 : returntype.partValue == 'G'?upHeadList3:upHeadList1);
setResetPage(true);
}, [upHeadList1, upHeadList2, upHeadList3]);
@@ -206,8 +206,8 @@ export default function ProcessFacilitySearch() {
ref={ref} isAdmin={false} initSidoValue={'11'}/>
<ListDiv>
<LoadingOverlay isLoading={isLoading} />
<div className="h-full w-full overflow-x-scroll">
<div className={widthSize}>
<div className="h-full w-full overflow-x-scroll min-[1200px]:overflow-x-hidden">
<div className="w-[120%] min-[1200px]:w-[100%]">
<UiTable cellClick={(index: number | undefined) => index !== undefined && cellClick(index)} headName={"시설별 유량현황"} tableData={flowRateList} headlist={upheadList} pageSize={10} total={total} resetPageNo={resetPage} onPageChange={handlePageChange} widthFixed={true} />
</div>
</div>
@@ -75,7 +75,7 @@ export default function PlanSewage() {
// 초기화 변수
const [resetPage, setResetPage] = useState<boolean>(false);
// 표 너비
const [widthSize, setWidthSize] = useState<string>("w-[120%]");
// const [widthSize, setWidthSize] = useState<string>("w-[120%]");
// 구분별 그리드 헤더
const [upheadList, setUpheadList] = useState<TableUpperProps[]>(upHeadList1);
// 용량별
@@ -95,7 +95,7 @@ export default function PlanSewage() {
*/
// 초기화
const resetVal = () => {
setWidthSize("w-[120%]");
// setWidthSize("w-[120%]");
setUpheadList(upHeadList1);
// 상태값 설정
setFcltCpct(selectUpdownData[0]?.val || "0");
@@ -171,7 +171,7 @@ export default function PlanSewage() {
}, [isInit, initializeCommonData, searchPlanSewageList, selectSearchYear, initTrigger, searchPlanSewage]);
const chkVal = useCallback((returntype:ReturnType) => {
setWidthSize(returntype.partValue=='A' ?"w-[120%]":"w-[130%]")
// setWidthSize(returntype.partValue=='A' ?"w-[120%]":"w-[130%]")
setUpheadList(returntype.partValue == 'R' ? upHeadList2 : returntype.partValue == 'G'?upHeadList3:upHeadList1);
setResetPage(true);
}, [upHeadList1, upHeadList2, upHeadList3]);
@@ -215,8 +215,8 @@ export default function PlanSewage() {
ref={ref} isAdmin={false} initSidoValue={"11"}/>
<ListDiv>
<LoadingOverlay isLoading={isLoading} />
<div className="h-full w-full overflow-x-scroll">
<div className={widthSize}>
<div className="h-full w-full overflow-x-scroll min-[1200px]:overflow-x-hidden">
<div className="w-[120%] min-[1200px]:w-[100%]">
<UiTable cellClick={ undefined } headName={"계획하수량 산정지표 현황"} tableData={mainList} headlist={upheadList} pageSize={10} total={total} resetPageNo={resetPage} onPageChange={handlePageChange} widthFixed={true} />
</div>
<UnderComment divHeight={"5"} commentArray={commentArray} />
@@ -161,8 +161,8 @@ export default function ProcessArea() {
/>
<ListDiv>
<LoadingOverlay isLoading={isLoading} />
<div className="h-full w-full overflow-x-scroll">
<div className="w-[180%]">
<div className="h-full w-full overflow-x-scroll min-[1900px]:overflow-x-hidden">
<div className="w-[160%] min-[1900px]:w-[100%]">
<UiTable cellClick={(index: number | undefined) => index !== undefined && cellClick(index)} headName={"처리구역정보"} tableData={mainList} headlist={upHeadList1} pageSize={10} total={total} resetPageNo={resetPage} onPageChange={handlePageChange} widthFixed={true} />
</div>
</div>
@@ -87,7 +87,7 @@ export default function ProcessFacilitySearch() {
const [resetPage, setResetPage] = useState<boolean>(false);
const [partValue, setPartValue] = useState<string>("A");
const [widthSize, setWidthSize] = useState<string>("w-[120%]");
// const [widthSize, setWidthSize] = useState<string>("w-[120%]");
const [upHeadList, setUpheadList] = useState(upHeadList1);
/**
@@ -135,15 +135,15 @@ export default function ProcessFacilitySearch() {
useEffect(() => {
switch (partValue) {
case 'A':
setWidthSize("w-[130%]");
// setWidthSize("w-[130%]");
setUpheadList(upHeadList1);
break;
case 'R':
setWidthSize("w-[140%]");
// setWidthSize("w-[140%]");
setUpheadList(upHeadList2);
break;
case 'G':
setWidthSize("w-[150%]");
// setWidthSize("w-[150%]");
setUpheadList(upHeadList3);
break;
}
@@ -270,8 +270,8 @@ export default function ProcessFacilitySearch() {
ref={ref} isAdmin={false} />
<ListDiv>
<LoadingOverlay isLoading={isLoading} />
<div className="h-full w-full overflow-x-scroll">
<div className={widthSize}>
<div className="h-full w-full overflow-x-scroll min-[1600px]:overflow-x-hidden">
<div className="w-[120%] min-[1600px]:w-[100%]">
<UiTable
cellClick={(index: number | undefined) => index !== undefined && cellClick(index)}
headName={"처리시설 시설정보"}
+5 -5
View File
@@ -73,7 +73,7 @@ export default function Rdi() {
// 초기화 변수
const [resetPage, setResetPage] = useState<boolean>(false);
// 표 너비
const [widthSize, setWidthSize] = useState<string>("w-[130%]");
// const [widthSize, setWidthSize] = useState<string>("w-[130%]");
// 구분별 그리드 헤더
const [upheadList, setUpheadList] = useState<TableUpperProps[]>(upHeadList1);
// 용량별
@@ -93,7 +93,7 @@ export default function Rdi() {
*/
// 초기화
const resetVal = () => {
setWidthSize("w-[120%]");
// setWidthSize("w-[120%]");
setUpheadList(upHeadList1)
// 상태값 설정
@@ -168,7 +168,7 @@ export default function Rdi() {
}, [isInit, initializeCommonData, searchRdiList, selectSearchYear, searchRdi, initTrigger]);
const chkVal = useCallback((returntype:ReturnType) => {
setWidthSize(returntype.partValue=='A' ?"w-[130%]":"w-[150%]")
// setWidthSize(returntype.partValue=='A' ?"w-[130%]":"w-[150%]")
setUpheadList(returntype.partValue == 'R' ? upHeadList2 : returntype.partValue == 'G'?upHeadList3:upHeadList1);
setResetPage(true);
}, [upHeadList1, upHeadList2, upHeadList3]);
@@ -213,8 +213,8 @@ export default function Rdi() {
ref={ref} isAdmin={false} initSidoValue={"11"}/>
<ListDiv>
<LoadingOverlay isLoading={isLoading} />
<div className="h-full w-full overflow-x-scroll">
<div className={widthSize}>
<div className="h-full w-full overflow-x-scroll min-[1200px]:overflow-x-hidden">
<div className="w-[120%] min-[1200px]:w-[100%]">
<UiTable cellClick={ undefined } headName={"RDII추정"} tableData={mainList} headlist={upheadList} pageSize={10} total={total} resetPageNo={resetPage} onPageChange={handlePageChange} widthFixed={true} />
</div>
<UnderComment divHeight={"0"} commentArray={commentArray} />
@@ -90,15 +90,15 @@ export default function SewageOccurModal(props: { fcltyName: string; facilityCd
return (
<>
<ListDiv className="!h-[80px] !mt-10">
<div className="card !m-[1.2rem] max-h-[40rem] overflow-auto !p-0r">
<ListDiv className="!mt-5 !h-[80px]">
<div className="!m-[0.5rem] max-h-[40rem] overflow-auto">
<Table>
<thead>
<TableRow className="">
<TableHead className="w-1/6">{'시설명'}</TableHead>
<TableCell className="w-1/3">{props.fcltyName}</TableCell>
<TableCell className="w-1/3 !border-b-0">{props.fcltyName}</TableCell>
<TableHead className="w-1/6">{'년월'}</TableHead>
<TableCell className="w-1/3">
<TableCell className="w-1/3 !border-b-0">
<DateMonthPicker
field={{
value: startYearMonth || (() => {
@@ -83,7 +83,7 @@ export default function PlanSewage() {
// 초기화 변수
const [resetPage, setResetPage] = useState<boolean>(false);
// 표 너비
const [widthSize, setWidthSize] = useState<string>("w-[120%]");
//const [widthSize, setWidthSize] = useState<string>("w-[120%]");
// 구분별 그리드 헤더
const [upheadList, setUpheadList] = useState<TableUpperProps[]>(upHeadList1);
// 용량별
@@ -102,7 +102,7 @@ export default function PlanSewage() {
*/
// 초기화
const resetVal = () => {
setWidthSize("w-[120%]");
// setWidthSize("w-[120%]");
setUpheadList(upHeadList1)
// 상태값 설정
setFcltCpct(selectUpdownData[0]?.val || "0");
@@ -189,7 +189,7 @@ export default function PlanSewage() {
}, [fcltCpct, operationValue, fcltyName, searchSewageOccurList, searchYear]);
const chkVal = useCallback((returntype:ReturnType) => {
setWidthSize(returntype.partValue=='A' ?"w-[120%]":"w-[130%]")
// setWidthSize(returntype.partValue=='A' ?"w-[120%]":"w-[130%]")
setUpheadList(returntype.partValue == 'R' ? upHeadList2 : returntype.partValue == 'G'?upHeadList3:upHeadList1);
setResetPage(true);
}, [upHeadList1, upHeadList2, upHeadList3]);
@@ -236,8 +236,8 @@ export default function PlanSewage() {
/>
<ListDiv>
<LoadingOverlay isLoading={isLoading} />
<div className="h-full w-full overflow-x-scroll">
<div className={widthSize}>
<div className="h-full w-full overflow-x-scroll min-[1200px]:overflow-x-hidden">
<div className="w-[120%] min-[1200px]:w-[100%]">
<UiTable cellClick={(index: number | undefined) => index !== undefined && cellClick(index)} headName={"오수발생량 추정"} tableData={mainList} headlist={upheadList} pageSize={10} total={total} resetPageNo={resetPage} onPageChange={handlePageChange} widthFixed={true} />
</div>
</div>
@@ -88,7 +88,7 @@ export default function UnderWaterInput() {
// 초기화 변수
const [resetPage, setResetPage] = useState<boolean>(false);
// 표 너비
const [widthSize, setWidthSize] = useState<string>("w-[120%]");
// const [widthSize, setWidthSize] = useState<string>("w-[120%]");
// 구분별 그리드 헤더
const [upheadList, setUpheadList] = useState<TableUpperProps[]>(upHeadList1);
// 용량별
@@ -107,7 +107,7 @@ export default function UnderWaterInput() {
*/
// 초기화
const resetVal = () => {
setWidthSize("w-[120%]");
// setWidthSize("w-[120%]");
setUpheadList(upHeadList1)
// 상태값 설정
setFcltCpct(selectUpdownData[0]?.val || "0");
@@ -193,7 +193,7 @@ export default function UnderWaterInput() {
}, [fcltCpct, operationValue, fcltyName, searchUnderWaterList, searchYear]);
const chkVal = useCallback((returntype:ReturnType) => {
setWidthSize(returntype.partValue=='A' ?"w-[120%]":"w-[130%]")
// setWidthSize(returntype.partValue=='A' ?"w-[120%]":"w-[130%]")
setUpheadList(returntype.partValue == 'R' ? upHeadList2 : returntype.partValue == 'G'?upHeadList3:upHeadList1);
setResetPage(true);
}, [upHeadList1, upHeadList2, upHeadList3]);
@@ -240,8 +240,8 @@ export default function UnderWaterInput() {
/>
<ListDiv>
<LoadingOverlay isLoading={isLoading} />
<div className="h-full w-full overflow-x-scroll">
<div className={widthSize}>
<div className="h-full w-full overflow-x-scroll min-[1200px]:overflow-x-hidden">
<div className="w-[120%] min-[1200px]:w-[100%]">
<UiTable cellClick={(index: number | undefined) => index !== undefined && cellClick(index)} headName={"지하수유입량 추정"} tableData={mainList} headlist={upheadList} pageSize={10} total={total} resetPageNo={resetPage} onPageChange={handlePageChange} widthFixed={true}/>
</div>
<UnderComment commentArray={commentArray} />
+157 -181
View File
@@ -234,14 +234,21 @@ const upHeadListFclty:TableUpperProps[] = [
const fcltyList : mainFcltyProps[] = [
]
let planYearList : string[] = [
"2021",
"2022",
"2023",
"2024",
"2025",
"2026"
]
function getPlanYearList(): string[] {
const currentYear = new Date().getFullYear(); // 올해
const startYear = currentYear - 4; // 5년 전
const endYear = currentYear + 1; // 내년
const years: string[] = [];
for (let y = startYear; y <= endYear; y++) {
years.push(String(y)); // 혹은 y.toString()
}
return years;
}
let planYearList : string[] = getPlanYearList();
const upHeadListPlanFclty : TableUpperProps[] = [
{ id: "item", title: "", upName:"upChangeRe", upSequnce:1 },
@@ -253,6 +260,7 @@ const upHeadListPlanFclty : TableUpperProps[] = [
{ id: "stepFour", title: "4단계", upName:"upChangeRe", upSequnce:7 },
]
// 시설계획 좌측 상단
const planFcltyList:planFcltyListProps[] = [
{
item : "1.증설계획\n(㎥/일)",
@@ -781,61 +789,45 @@ const calcWaterQltyData = async (data: any[], searchYear : string|undefined, fcl
if(item.tpInflWqltTp != null && item.tpInflWqltTp != 0){
noNullWinterDay+=1;
}
const inflbod = nullToZero(item.inflAssctBfrWqltBod);
const infltn = nullToZero(item.inflAssctBfrWqltTn);
const infltp = nullToZero(item.inflAssctBfrWqltTp);
const effbod = nullToZero(item.efflWqltBod);
const efftn = nullToZero(item.efflWqltTn);
const efftp = nullToZero(item.efflWqltTp);
const tpInfl = nullToZero(item.tpInflWqltTp);
totalInflBodAvgWinter += inflbod;
totalInflTnAvgWinter += infltn;
totalInflTpAvgWinter += infltp;
totalEfflBodAvgWinter += effbod;
totalEfflTnAvgWinter += efftn;
totalEfflTpAvgWinter += efftp;
totalTpInflAvgWinter += tpInfl;
totalInflBodAvgWinter += nullToZero(item.inflAssctBfrWqltBod);
totalInflTnAvgWinter += nullToZero(item.inflAssctBfrWqltTn);
totalInflTpAvgWinter += nullToZero(item.inflAssctBfrWqltTp);
totalEfflBodAvgWinter += nullToZero(item.efflWqltBod);
totalEfflTnAvgWinter += nullToZero(item.efflWqltTn);
totalEfflTpAvgWinter += nullToZero(item.efflWqltTp);
totalTpInflAvgWinter += nullToZero(item.tpInflWqltTp);
}else if(month === '06' || month === '07' || month === '08'){
summerDay += 1;
if(item.tpInflWqltTp != null && item.tpInflWqltTp != 0){
noNullSummerDay+=1;
}
const inflbod = nullToZero(item.inflAssctBfrWqltBod);
const infltn = nullToZero(item.inflAssctBfrWqltTn);
const infltp = nullToZero(item.inflAssctBfrWqltTp);
const effbod = nullToZero(item.efflWqltBod);
const efftn = nullToZero(item.efflWqltTn);
const efftp = nullToZero(item.efflWqltTp);
const tpInfl = nullToZero(item.tpInflWqltTp);
totalInflBodAvgSummer += inflbod;
totalInflTnAvgSummer += infltn;
totalInflTpAvgSummer += infltp;
totalEfflBodAvgSummer += effbod;
totalEfflTnAvgSummer += efftn;
totalEfflTpAvgSummer += efftp;
totalTpInflAvgSummer += tpInfl;
totalInflBodAvgSummer += nullToZero(item.inflAssctBfrWqltBod);
totalInflTnAvgSummer += nullToZero(item.inflAssctBfrWqltTn);
totalInflTpAvgSummer += nullToZero(item.inflAssctBfrWqltTp);
totalEfflBodAvgSummer += nullToZero(item.efflWqltBod);
totalEfflTnAvgSummer += nullToZero(item.efflWqltTn);
totalEfflTpAvgSummer += nullToZero(item.efflWqltTp);
totalTpInflAvgSummer += nullToZero(item.tpInflWqltTp);
}
});
orgData[5].winterAll = qltyDataSetting(totalInflBodAvgWinter, winterDay, 1);
orgData[6].winterAll = qltyDataSetting(totalEfflBodAvgWinter, winterDay, 1);
orgData[7].winterAll = qltyDataSetting(totalInflTnAvgWinter, winterDay, 3);
orgData[8].winterAll = qltyDataSetting(totalEfflTnAvgWinter, winterDay, 3);
orgData[9].winterAll = qltyDataSetting(totalInflTpAvgWinter, winterDay, 3);
orgData[10].winterAll = qltyDataSetting(totalEfflTpAvgWinter, winterDay, 3);
orgData[5].winterAll = qltyDataSetting(totalInflBodAvgWinter, winterDay, 1); // 동절기 유입BOD
orgData[6].winterAll = qltyDataSetting(totalEfflBodAvgWinter, winterDay, 1); // 동절기 방류BOD
orgData[7].winterAll = qltyDataSetting(totalInflTnAvgWinter, winterDay, 3); // 동절기 유입TN
orgData[8].winterAll = qltyDataSetting(totalEfflTnAvgWinter, winterDay, 3); // 동절기 방류TN
orgData[9].winterAll = qltyDataSetting(totalInflTpAvgWinter, winterDay, 3); // 동절기 유입TP
orgData[10].winterAll = qltyDataSetting(totalEfflTpAvgWinter, winterDay, 3); // 동절기 방류TP
//총인설비
orgData[11].winterAll = qltyDataSetting(totalTpInflAvgWinter, noNullWinterDay, 3);
orgData[12].winterAll = qltyDataSetting(totalEfflTpAvgWinter, winterDay, 3);
orgData[5].summerAll = qltyDataSetting(totalInflBodAvgSummer, summerDay, 1);
orgData[6].summerAll = qltyDataSetting(totalEfflBodAvgSummer, summerDay, 1);
orgData[7].summerAll = qltyDataSetting(totalInflTnAvgSummer, summerDay, 3);
orgData[8].summerAll = qltyDataSetting(totalEfflTnAvgSummer, summerDay, 3);
orgData[9].summerAll = qltyDataSetting(totalInflTpAvgSummer, summerDay, 3);
orgData[10].summerAll = qltyDataSetting(totalEfflTpAvgSummer, summerDay, 3);
orgData[5].summerAll = qltyDataSetting(totalInflBodAvgSummer, summerDay, 1); // 하절기 유입BOD
orgData[6].summerAll = qltyDataSetting(totalEfflBodAvgSummer, summerDay, 1); // 하절기 방류BOD
orgData[7].summerAll = qltyDataSetting(totalInflTnAvgSummer, summerDay, 3); // 하절기 유입TN
orgData[8].summerAll = qltyDataSetting(totalEfflTnAvgSummer, summerDay, 3); // 하절기 방류TN
orgData[9].summerAll = qltyDataSetting(totalInflTpAvgSummer, summerDay, 3); // 하절기 유입TP
orgData[10].summerAll = qltyDataSetting(totalEfflTpAvgSummer, summerDay, 3); // 하절기 방류TP
//총인설비
orgData[11].summerAll = qltyDataSetting(totalTpInflAvgSummer, noNullSummerDay, 3);
@@ -849,37 +841,37 @@ const calcWaterQltyData = async (data: any[], searchYear : string|undefined, fcl
let noNullChungchunWinterDay = 0;
// (1-2) 하절기
let chungchunInflBodAvgSummer = 0;
let chungchunInflTnAvgSummer = 0;
let chungchunInflTpAvgSummer = 0;
let chungchunEfflBodAvgSummer = 0;
let chungchunEfflTnAvgSummer = 0;
let chungchunEfflTpAvgSummer = 0;
let chungchunTpInflAvgSummer = 0;
let chungchunInflBodAvgSummer = 0; // 하절기 청천시 유입BOD
let chungchunInflTnAvgSummer = 0; // 하절기 청천시 유입TN
let chungchunInflTpAvgSummer = 0; // 하절기 청천시 유입TP
let chungchunEfflBodAvgSummer = 0; // 하절기 청천시 방류BOD
let chungchunEfflTnAvgSummer = 0; // 하절기 청천시 방류TN
let chungchunEfflTpAvgSummer = 0; // 하절기 청천시 방류TP
let chungchunTpInflAvgSummer = 0; // 하절기 청천시 총인설비 유입TP
// (1-3) 동절기 (최대, 최소, 평균 다 필요)
let chungchunInflBodAvg = 0;
let chungchunInflTnAvg = 0;
let chungchunInflTpAvg = 0;
let chungchunTpInflAvg = 0;
let chungchunInflTnAvg = 0;
let chungchunInflTpAvg = 0;
let chungchunTpInflAvg = 0;
let chungchunInflBodMax = 0;
let chungchunInflTnMax= 0;
let chungchunInflTpMax = 0;
let chungchunTpInflMax = 0;
let chungchunInflTnMax = 0;
let chungchunInflTpMax = 0;
let chungchunTpInflMax = 0;
let chungchunInflBodMin = 99999999;
let chungchunInflTnMin = 99999999;
let chungchunInflTpMin = 99999999;
let chungchunTpInflMin = 99999999;
let chungchunInflTnMin = 99999999;
let chungchunInflTpMin = 99999999;
let chungchunTpInflMin = 99999999;
let chungchunEfflBodAvg = 0;
let chungchunEfflTnAvg = 0;
let chungchunEfflTpAvg = 0;
let chungchunEfflTnAvg = 0;
let chungchunEfflTpAvg = 0;
let chungchunEfflBodMax = 0;
let chungchunEfflTnMax= 0;
let chungchunEfflTpMax = 0;
let chungchunEfflTnMax = 0;
let chungchunEfflTpMax = 0;
let chungchunEfflBodMin = 99999999;
let chungchunEfflTnMin = 99999999;
let chungchunEfflTpMin = 99999999;
let chungchunEfflTnMin = 99999999;
let chungchunEfflTpMin = 99999999;
// 청천시
qltyChungchunData.forEach((item: any) => {
@@ -891,57 +883,49 @@ const calcWaterQltyData = async (data: any[], searchYear : string|undefined, fcl
}
const inflbod = nullToZero(item.inflAssctBfrWqltBod);
const infltn = nullToZero(item.inflAssctBfrWqltTn);
const infltp = nullToZero(item.inflAssctBfrWqltTp);
const effbod = nullToZero(item.efflWqltBod);
const efftn = nullToZero(item.efflWqltTn);
const efftp = nullToZero(item.efflWqltTp);
const tpInfl = nullToZero(item.tpInflWqltTp);
const infltn = nullToZero(item.inflAssctBfrWqltTn);
const infltp = nullToZero(item.inflAssctBfrWqltTp);
const effbod = nullToZero(item.efflWqltBod);
const efftn = nullToZero(item.efflWqltTn);
const efftp = nullToZero(item.efflWqltTp);
const tpInfl = nullToZero(item.tpInflWqltTp);
chungchunInflBodAvg += inflbod;
chungchunInflTnAvg += infltn;
chungchunInflTpAvg += infltp;
chungchunTpInflAvg += tpInfl;
chungchunInflTnAvg += infltn;
chungchunInflTpAvg += infltp;
chungchunEfflBodAvg += effbod;
chungchunEfflTnAvg += efftn;
chungchunEfflTpAvg += efftp;
chungchunEfflTnAvg += efftn;
chungchunEfflTpAvg += efftp;
chungchunTpInflAvg += tpInfl;
if(chungchunInflBodMax < inflbod) chungchunInflBodMax = inflbod;
if(chungchunInflTnMax < infltn) chungchunInflTnMax = infltn;
if(chungchunInflTpMax < infltp) chungchunInflTpMax = infltp;
if(chungchunTpInflMax < tpInfl) chungchunTpInflMax = tpInfl;
if(chungchunEfflBodMax < effbod) chungchunEfflBodMax = effbod;
if(chungchunEfflTnMax < efftn) chungchunEfflTnMax = efftn;
if(chungchunEfflTpMax < efftp) chungchunEfflTpMax = efftp;
if(chungchunInflTnMax < infltn) chungchunInflTnMax = infltn;
if(chungchunInflTpMax < infltp) chungchunInflTpMax = infltp;
if(chungchunEfflBodMax < effbod) chungchunEfflBodMax = effbod;
if(chungchunEfflTnMax < efftn) chungchunEfflTnMax = efftn;
if(chungchunEfflTpMax < efftp) chungchunEfflTpMax = efftp;
if(chungchunTpInflMax < tpInfl) chungchunTpInflMax = tpInfl;
if(chungchunInflBodMin > inflbod) chungchunInflBodMin = inflbod;
if(chungchunInflTnMin > infltn) chungchunInflTnMin = infltn;
if(chungchunInflTpMin > infltp) chungchunInflTpMin = infltp;
if(chungchunTpInflMin > tpInfl) chungchunTpInflMin = tpInfl;
if(chungchunEfflBodMin > effbod) chungchunEfflBodMin = effbod;
if(chungchunEfflTnMin > efftn) chungchunEfflTnMin = efftn;
if(chungchunEfflTpMin > efftp) chungchunEfflTpMin = efftp;
if(chungchunInflTnMin > infltn) chungchunInflTnMin = infltn;
if(chungchunInflTpMin > infltp) chungchunInflTpMin = infltp;
if(chungchunEfflBodMin > effbod) chungchunEfflBodMin = effbod;
if(chungchunEfflTnMin > efftn) chungchunEfflTnMin = efftn;
if(chungchunEfflTpMin > efftp) chungchunEfflTpMin = efftp;
if(chungchunTpInflMin > tpInfl) chungchunTpInflMin = tpInfl;
}else if(month === '06' || month === '07' || month === '08'){
chungchunSummerDay += 1;
if(item.tpInflWqltTp != null && item.tpInflWqltTp != 0){
noNullChungchunSummerDay+=1;
}
const inflbod = nullToZero(item.inflAssctBfrWqltBod);
const infltn = nullToZero(item.inflAssctBfrWqltTn);
const infltp = nullToZero(item.inflAssctBfrWqltTp);
const effbod = nullToZero(item.efflWqltBod);
const efftn = nullToZero(item.efflWqltTn);
const efftp = nullToZero(item.efflWqltTp);
const tpInfl = nullToZero(item.tpInflWqltTp);
chungchunInflBodAvgSummer += inflbod;
chungchunInflTnAvgSummer += infltn;
chungchunInflTpAvgSummer += infltp;
chungchunEfflBodAvgSummer += effbod;
chungchunEfflTnAvgSummer += efftn;
chungchunEfflTpAvgSummer += efftp;
chungchunTpInflAvgSummer += tpInfl;
chungchunInflBodAvgSummer += nullToZero(item.inflAssctBfrWqltBod);
chungchunInflTnAvgSummer += nullToZero(item.inflAssctBfrWqltTn);
chungchunInflTpAvgSummer += nullToZero(item.inflAssctBfrWqltTp);
chungchunEfflBodAvgSummer += nullToZero(item.efflWqltBod);
chungchunEfflTnAvgSummer += nullToZero(item.efflWqltTn);
chungchunEfflTpAvgSummer += nullToZero(item.efflWqltTp);
chungchunTpInflAvgSummer += nullToZero(item.tpInflWqltTp);
}
});
@@ -1009,21 +993,13 @@ const calcWaterQltyData = async (data: any[], searchYear : string|undefined, fcl
noNullRainSummerDay+=1;
}
const inflbod = nullToZero(item.inflAssctBfrWqltBod);
const infltn = nullToZero(item.inflAssctBfrWqltTn);
const infltp = nullToZero(item.inflAssctBfrWqltTp);
const effbod = nullToZero(item.efflWqltBod);
const efftn = nullToZero(item.efflWqltTn);
const efftp = nullToZero(item.efflWqltTp);
const tpInfl = nullToZero(item.tpInflWqltTp);
rainInflBodAvg += inflbod;
rainInflTnAvg += infltn;
rainInflTpAvg += infltp;
rainEfflBodAvg += effbod;
rainEfflTnAvg += efftn;
rainEfflTpAvg += efftp;
rainTpInflAvg += tpInfl;
rainInflBodAvg += nullToZero(item.inflAssctBfrWqltBod);
rainInflTnAvg += nullToZero(item.inflAssctBfrWqltTn);
rainInflTpAvg += nullToZero(item.inflAssctBfrWqltTp);
rainEfflBodAvg += nullToZero(item.efflWqltBod);
rainEfflTnAvg += nullToZero(item.efflWqltTn);
rainEfflTpAvg += nullToZero(item.efflWqltTp);
rainTpInflAvg += nullToZero(item.tpInflWqltTp);
}
});
@@ -1367,15 +1343,15 @@ export const dashBoardReducer:(set:(fn: (state: DashBoardType) => DashBoardType)
planYearList.push(rtnPlanData?rtnPlanData.planYrDc3.toString():"");
planYearList.push(rtnPlanData?rtnPlanData.planYrDc4.toString():"");
if(index === 0){
el.provisionalApproval = rtnPlanData?rtnPlanData.planSwageQntSumDt4:'';
el.now = rtnPlanData?rtnPlanData.planSwageQntSum:'';
el.stepOne = rtnPlanData?rtnPlanData.planSwageQntSumDc1:'';
el.stepTwo = rtnPlanData?rtnPlanData.planSwageQntSumDc2:'';
el.stepThree = rtnPlanData?rtnPlanData.planSwageQntSumDc3:'';
el.stepFour = rtnPlanData?rtnPlanData.planSwageQntSumDc4:'';
if(index === 0){ // 증설계획(㎥/일)
el.provisionalApproval = rtnPlanData?rtnPlanData.planSwageQntSumDt4:''; // 기승인4단계
el.now = rtnPlanData?rtnPlanData.planSwageQntSum:''; // 현재
el.stepOne = rtnPlanData?rtnPlanData.planSwageQntSumDc1:''; // 1단계
el.stepTwo = rtnPlanData?rtnPlanData.planSwageQntSumDc2:''; // 2단계
el.stepThree = rtnPlanData?rtnPlanData.planSwageQntSumDc3:''; // 3단계
el.stepFour = rtnPlanData?rtnPlanData.planSwageQntSumDc4:''; // 4단계
}
if(index === 1){
if(index === 1){ // 처리인구(인)
el.provisionalApproval = rtnPlanData?rtnPlanData.swtrmtPpltnCntDt4:'';
el.now = rtnPlanData?rtnPlanData.swtrmtPpltnCnt:'';
el.stepOne = rtnPlanData?rtnPlanData.swtrmtPpltnCntDc1:'';
@@ -1383,7 +1359,7 @@ export const dashBoardReducer:(set:(fn: (state: DashBoardType) => DashBoardType)
el.stepThree = rtnPlanData?rtnPlanData.swtrmtPpltnCntDc3:'';
el.stepFour = rtnPlanData?rtnPlanData.swtrmtPpltnCntDc4:'';
}
if(index === 2){
if(index === 2){ // 처리구역(km²)
el.provisionalApproval = rtnPlanData?rtnPlanData.swtrmtZoneAreaDt4:'';
el.now = rtnPlanData?rtnPlanData.swtrmtZoneArea:'';
el.stepOne = rtnPlanData?rtnPlanData.swtrmtZoneAreaDc1:'';
@@ -1391,13 +1367,13 @@ export const dashBoardReducer:(set:(fn: (state: DashBoardType) => DashBoardType)
el.stepThree = rtnPlanData?rtnPlanData.swtrmtZoneAreaDc3:'';
el.stepFour = rtnPlanData?rtnPlanData.swtrmtZoneAreaDc4:'';
}
if(index === 4){
if(index === 4){ // 물사용량
el.provisionalApproval = rtnPlanData?rtnPlanData.ugwtUseSum:'';
}
if(index === 2){
if(index === 2){ // 처리구역(km²)
dataFormSettingPlan(el, 1);
}else if(index == 4){
}else if(index == 4){ // 물사용량
}else{
dataFormSettingPlan(el, 0);
}
@@ -1423,40 +1399,40 @@ export const dashBoardReducer:(set:(fn: (state: DashBoardType) => DashBoardType)
isLoadingOperationStatus: true,
},
}));
const res = await fetchOperationStatus(searchYear, fcltyCd);
const res = await fetchOperationStatus(searchYear, fcltyCd); // /dashBoard/mngInfo/
const data = res.data;
let yearMaxSumFlow = 1;
let yearMinSumFlow = 1;
let yearAvgSumFlow = 1;
let summerAll = 1;
let summerThree = 1;
let summerRain = 1;
let winterAll = 1;
let winterSunnyMin = 1;
let winterSunnyAvg = 1;
let winterSunnyMax = 1;
let yearMaxSumFlow = 1; // 연간운영실태 최대
let yearMinSumFlow = 1; // 연간운영실태 최소
let yearAvgSumFlow = 1; // 연간운영실태 평균
let summerAll = 1; // 하절기 전기간
let summerThree = 1; // 하절기
let summerRain = 1; // 하절기
let winterAll = 1; // 동절기 전기간
let winterSunnyMin = 1; // 동절기 청천 최소
let winterSunnyAvg = 1; // 동절기 청천 평균
let winterSunnyMax = 1; // 동절기 청천 최대
// 최대, 최소 총 유입량 (1년 하루 합계의 최대, 최소)
// 하절기 청천시, 강우시 재계산 리턴값
let summerChungchunFlow = 1
let summerChungchunReturnAmnt = 1;
let summerChungchunEfflAmnt = 1;
let summerRainInflAmnt = 1;
let summerRainReturnAmnt = 1;
let summerRainEfflAmnt = 1;
let summerChungchunFlow = 1; // 하절기 청천
let summerChungchunReturnAmnt = 1; // 하절기 청천
let summerChungchunEfflAmnt = 1; // 하절기 청천
let summerRainInflAmnt = 1; // 하절기 강우시
let summerRainReturnAmnt = 1; // 하절기 강우시
let summerRainEfflAmnt = 1; // 하절기 강우시
// 동절기 청천시 최대, 최소, 평균 리턴값
let winterChungchunFlowAvg = 1 ;
let winterChungchunFlowMax = 1 ;
let winterChungchunFlowMin = 1 ;
let winterChungchunReturnAvg = 1 ;
let winterChungchunReturnMax = 1 ;
let winterChungchunReturnMin = 1 ;
let winterChungchunEfflAvg = 1 ;
let winterChungchunEfflMax = 1 ;
let winterChungchunEfflMin = 1 ;
let winterChungchunFlowAvg = 1; // 동절기 청천 하수유입량 평균
let winterChungchunFlowMax = 1; // 동절기 청천 하수유입량 최대
let winterChungchunFlowMin = 1; // 동절기 청천 하수유입량 최소
let winterChungchunReturnAvg = 1; // 동절기 청천 총인반류수 평균
let winterChungchunReturnMax = 1; // 동절기 청천 총인반류수 최대
let winterChungchunReturnMin = 1; // 동절기 청천 총인반류수 최소
let winterChungchunEfflAvg = 1; // 동절기 청천 방류량 평균
let winterChungchunEfflMax = 1; // 동절기 청천 방류량 최대
let winterChungchunEfflMin = 1; // 동절기 청천 방류량 최소
const clone = (arr: any[]) => arr?.map(v => ({ ...v })) ?? [];
@@ -1467,19 +1443,19 @@ export const dashBoardReducer:(set:(fn: (state: DashBoardType) => DashBoardType)
if (operationalStatusList?.[0]) {
// 연간
const yearAvg = data.yearData===null ? '' : (
(data.yearData.AVG_INFL_AMNT?data.yearData.AVG_INFL_AMNT:0) +
(data.yearData.AVG_SUM_FLOW?data.yearData.AVG_SUM_FLOW:0) +
(data.yearData.AVG_RETURN_AMNT?data.yearData.AVG_RETURN_AMNT:0)
(data.yearData.AVG_INFL_AMNT?data.yearData.AVG_INFL_AMNT:0) + // 실제유입량(반류수제거)(m³/일) 평균
(data.yearData.AVG_SUM_FLOW?data.yearData.AVG_SUM_FLOW:0) + // api 주석처리되어 있음.
(data.yearData.AVG_RETURN_AMNT?data.yearData.AVG_RETURN_AMNT:0) // 반류수유량(m³/일) 평균
);
const yearMax = data.yearData===null ? '' : (
(data.yearData.MAX_INFL_AMNT?data.yearData.MAX_INFL_AMNT:0) +
(data.yearData.MAX_SUM_FLOW?data.yearData.MAX_SUM_FLOW:0) +
(data.yearData.MAX_RETURN_AMNT?data.yearData.MAX_RETURN_AMNT:0)
(data.yearData.MAX_INFL_AMNT?data.yearData.MAX_INFL_AMNT:0) + // 실제유입량(반류수제거)(m³/일) 최대
(data.yearData.MAX_SUM_FLOW?data.yearData.MAX_SUM_FLOW:0) + // api 주석처리되어 있음.
(data.yearData.MAX_RETURN_AMNT?data.yearData.MAX_RETURN_AMNT:0) // 반류수유량(m³/일) 최대
);
const yearMin = data.yearData===null ? '' : (
(data.yearData.MIN_INFL_AMNT?data.yearData.MIN_INFL_AMNT:0) +
(data.yearData.MIN_SUM_FLOW?data.yearData.MIN_SUM_FLOW:0) +
(data.yearData.MIN_RETURN_AMNT?data.yearData.MIN_RETURN_AMNT:0)
(data.yearData.MIN_INFL_AMNT?data.yearData.MIN_INFL_AMNT:0) + // 실제유입량(반류수제거)(m³/일) 최소
(data.yearData.MIN_SUM_FLOW?data.yearData.MIN_SUM_FLOW:0) + // api 주석처리되어 있음.
(data.yearData.MIN_RETURN_AMNT?data.yearData.MIN_RETURN_AMNT:0) // 반류수유량(m³/일) 최소
);
operationalStatusList[0].yearAvg = yearAvg;
@@ -1490,31 +1466,31 @@ export const dashBoardReducer:(set:(fn: (state: DashBoardType) => DashBoardType)
(data.summer.AVG_INFL_AMNT ? data.summer.AVG_INFL_AMNT : 0) +
(data.summer.AVG_SUM_FLOW ? data.summer.AVG_SUM_FLOW : 0)
);
operationalStatusList[0].summerThree = data.summerRainy === null ? '' : (
operationalStatusList[0].summerThree = data.summerRainy === null ? '' : ( // 하절기 강우시
(data.summerRainy.AVG_INFL_AMNT ? data.summerRainy.AVG_INFL_AMNT : 0) +
(data.summerRainy.AVG_SUM_FLOW ? data.summerRainy.AVG_SUM_FLOW : 0)
);
operationalStatusList[0].summerRain = data.summerSunny === null ? '' : (
operationalStatusList[0].summerRain = data.summerSunny === null ? '' : ( // 하절기 청천시
(data.summerSunny.AVG_INFL_AMNT ? data.summerSunny.AVG_INFL_AMNT : 0) +
(data.summerSunny.AVG_SUM_FLOW ? data.summerSunny.AVG_SUM_FLOW : 0)
);
// 겨울
operationalStatusList[0].winterAll = data.winter === null ? '' : (
operationalStatusList[0].winterAll = data.winter === null ? '' : ( // 동절기 전기간
(data.winter.AVG_INFL_AMNT ? data.winter.AVG_INFL_AMNT : 0) +
(data.winter.AVG_SUM_FLOW ? data.winter.AVG_SUM_FLOW : 0) +
(data.winter.AVG_TP_INFL_WQLT_TP ? data.winter.AVG_TP_INFL_WQLT_TP : 0)
);
operationalStatusList[0].winterSunnyMin = data.winterSunny === null ? '' : (
operationalStatusList[0].winterSunnyMin = data.winterSunny === null ? '' : (// 동절기 청천 최소
(data.winterSunny.MIN_INFL_AMNT ? data.winterSunny.MIN_INFL_AMNT : 0) +
(data.winterSunny.MIN_SUM_FLOW ? data.winterSunny.MIN_SUM_FLOW : 0) +
(data.winterSunny.MIN_TP_INFL_WQLT_TP ? data.winterSunny.MIN_TP_INFL_WQLT_TP : 0)
);
operationalStatusList[0].winterSunnyAvg = data.winterSunny === null ? '' : (
operationalStatusList[0].winterSunnyAvg = data.winterSunny === null ? '' : (// 동절기 청천 평균
(data.winterSunny.AVG_INFL_AMNT ? data.winterSunny.AVG_INFL_AMNT : 0) +
(data.winterSunny.AVG_SUM_FLOW ? data.winterSunny.AVG_SUM_FLOW : 0) +
(data.winterSunny.AVG_TP_INFL_WQLT_TP ? data.winterSunny.AVG_TP_INFL_WQLT_TP : 0)
);
operationalStatusList[0].winterSunnyMax = data.winterSunny === null ? '' : (
operationalStatusList[0].winterSunnyMax = data.winterSunny === null ? '' : (// 동절기 청천 최대
(data.winterSunny.MAX_INFL_AMNT ? data.winterSunny.MAX_INFL_AMNT : 0) +
(data.winterSunny.MAX_SUM_FLOW ? data.winterSunny.MAX_SUM_FLOW : 0) +
(data.winterSunny.MAX_TP_INFL_WQLT_TP ? data.winterSunny.MAX_TP_INFL_WQLT_TP : 0)
@@ -1547,7 +1523,7 @@ export const dashBoardReducer:(set:(fn: (state: DashBoardType) => DashBoardType)
// 연계처리수,지하수유입량
const bscFctCd = fcltyCd;
const res2 = await fetchFlowRateModalList(bscFctCd, searchYear);
const res2 = await fetchFlowRateModalList(bscFctCd, searchYear); // /fclt/flow/
yearDay = res2.data?res2.data.length:1;
res2.data.forEach((item: any) => {
yearSumFlow += item.sumFlow && item.sumFlow !== 'x' ? Number(item.sumFlow.toString().trim()) : 0;
@@ -1909,10 +1885,10 @@ export const dashBoardReducer:(set:(fn: (state: DashBoardType) => DashBoardType)
const rtnData = await calcWaterQltyData(operationalStatusList, searchYear, fcltyCd);
// 총인설비가 없을 경우 제외
console.log('총인시설 여부 : ', data.yearData.TP_FCT_YN);
console.log('총인시설 여부 : ', data.yearData?.TP_FCT_YN);
console.log('rtnData : ', rtnData);
if(data.yearData.TP_FCT_YN !== 'Y') {
if(data.yearData?.TP_FCT_YN !== 'Y') {
operationalStatusList = rtnData.slice(0, 11);
}else{
operationalStatusList = rtnData;
@@ -165,14 +165,12 @@ const fetchFacilityPrecipitationList = async (pageNo: number, pageSize: number,
});
const rainDetailListRes: Record<string, any[]> = {};
const promises: Promise<any>[] = [];
const results: any[] = [];
for (let idx = 0; idx < fcltCdList.length; idx += 100) {
const slicedItems = fcltCdList.slice(idx, idx + 100);
// 각 fetch 호출을 Promise 배열에 담기
promises.push(fetchFacilityRainDetailList(searchYear, slicedItems));
const slicedItems = fcltCdList.slice(idx, idx + 100);
const result = await fetchFacilityRainDetailList(searchYear, slicedItems);
results.push(result);
}
// 모든 요청을 병렬로 실행
const results = await Promise.all(promises);
//console.log(results);
// 결과를 합치기
for (const res of results) {
+21 -21
View File
@@ -132,32 +132,32 @@ const upHeadList1:TableUpperProps[] = [
{ id: "A", title: "행정구역", colSpan:2, rowSpan:2, row:0 },
{ id: "ctpvNm", title: "시도", upName:"upChangeRe", row:2, widthPercent:4 },
{ id: "sggNm", title: "시군구", upName:"upChangeRe", row:2, widthPercent:4 },
{ id: "swtrmtFcltNm", title: "시설명", rowSpan:3, row:0, widthPercent: 8 },
{ id: "location", title: "주소", rowSpan:3, row:0 },
{ id: "swtrmtFcltNm", title: "시설명", rowSpan:3, row:0, widthPercent: 6 },
{ id: "location", title: "주소", rowSpan:3, row:0, widthPercent: 12 },
{ id: "fctCpc", title: "시설용량(m³/일)", align:"right", rowSpan:3, row:0, widthPercent: 5 },
{ id: "B", title: "시설계획", colSpan:19, row: 0 },
{ id: "C", title: "증설계획(m³/일)", colSpan:6, row: 1 },
{ id: "planSwageQntSumDt4", title: "기승인\n4단계", row:2, align:"right", widthPercent: 2.5 },
{ id: "planSwageQntSum", title: "현재", row:2, align:"right", widthPercent: 2.5 },
{ id: "planSwageQntSumDc1", title: "1단계", row:2, align:"right", widthPercent: 2.5 },
{ id: "planSwageQntSumDc2", title: "2단계", row:2, align:"right", widthPercent: 2.5 },
{ id: "planSwageQntSumDc3", title: "3단계", row:2, align:"right", widthPercent: 2.5 },
{ id: "planSwageQntSumDc4", title: "4단계", row:2, align:"right", widthPercent: 2.5 },
{ id: "planSwageQntSumDt4", title: "기승인\n4단계", row:2, align:"right", widthPercent: 3.5 },
{ id: "planSwageQntSum", title: "현재", row:2, align:"right", widthPercent: 3.5 },
{ id: "planSwageQntSumDc1", title: "1단계", row:2, align:"right", widthPercent: 3.5 },
{ id: "planSwageQntSumDc2", title: "2단계", row:2, align:"right", widthPercent: 3.5 },
{ id: "planSwageQntSumDc3", title: "3단계", row:2, align:"right", widthPercent: 3.5 },
{ id: "planSwageQntSumDc4", title: "4단계", row:2, align:"right", widthPercent: 3.5 },
{ id: "D", title: "처리인구(인)", colSpan:6, row:1 },
{ id: "swtrmtPpltnCntDt4", title: "기승인\n4단계", row:2, align:"right", widthPercent: 2.5 },
{ id: "swtrmtPpltnCnt", title: "현재", row:2, align:"right", widthPercent: 2.5 },
{ id: "swtrmtPpltnCntDc1", title: "1단계", row:2, align:"right", widthPercent: 2.5 },
{ id: "swtrmtPpltnCntDc2", title: "2단계", row:2, align:"right", widthPercent: 2.5 },
{ id: "swtrmtPpltnCntDc3", title: "3단계", row:2, align:"right", widthPercent: 2.5 },
{ id: "swtrmtPpltnCntDc4", title: "4단계", row:2, align:"right", widthPercent: 2.5 },
{ id: "swtrmtPpltnCntDt4", title: "기승인\n4단계", row:2, align:"right", widthPercent: 3.5 },
{ id: "swtrmtPpltnCnt", title: "현재", row:2, align:"right", widthPercent: 3.5 },
{ id: "swtrmtPpltnCntDc1", title: "1단계", row:2, align:"right", widthPercent: 3.5 },
{ id: "swtrmtPpltnCntDc2", title: "2단계", row:2, align:"right", widthPercent: 3.5 },
{ id: "swtrmtPpltnCntDc3", title: "3단계", row:2, align:"right", widthPercent: 3.5 },
{ id: "swtrmtPpltnCntDc4", title: "4단계", row:2, align:"right", widthPercent: 3.5 },
{ id: "E", title: "처리구역(km²)", colSpan:7, row:1 },
{ id: "swtrmtZoneAreaDt4", title: "기승인\n4단계", row:2, align:"right", widthPercent: 2.5 },
{ id: "swtrmtZoneArea", title: "현재", row:2, align:"right", widthPercent: 2.5 },
{ id: "swtrmtZoneAreaDc1", title: "1단계", row:2, align:"right", widthPercent: 2.5 },
{ id: "swtrmtZoneAreaDc2", title: "2단계", row:2, align:"right", widthPercent: 2.5 },
{ id: "swtrmtZoneAreaDc3", title: "3단계", row:2, align:"right", widthPercent: 2.5 },
{ id: "swtrmtZoneAreaDc4", title: "4단계", row:2, align:"right", widthPercent: 2.5 },
{ id: "trtDstr", title: "처리구역", row:2 }
{ id: "swtrmtZoneAreaDt4", title: "기승인\n4단계", row:2, align:"right", widthPercent: 3.5 },
{ id: "swtrmtZoneArea", title: "현재", row:2, align:"right", widthPercent: 3.5 },
{ id: "swtrmtZoneAreaDc1", title: "1단계", row:2, align:"right", widthPercent: 3.5 },
{ id: "swtrmtZoneAreaDc2", title: "2단계", row:2, align:"right", widthPercent: 3.5 },
{ id: "swtrmtZoneAreaDc3", title: "3단계", row:2, align:"right", widthPercent: 3.5 },
{ id: "swtrmtZoneAreaDc4", title: "4단계", row:2, align:"right", widthPercent: 3.5 },
{ id: "trtDstr", title: "처리구역", row:2}
];
const processAreaList:ProcessAreaProps[] = [
+204 -77
View File
@@ -1,5 +1,5 @@
import { getFetch } from "@/app/components/api";
import CalcRainEffect from "@/app/components/common/CalcRainEffect";
import { getFetch, postFetch } from "@/app/components/api";
// import CalcRainEffect from "@/app/components/common/CalcRainEffect";
import { roundNumber } from "@/app/components/common/DataForm";
import { OSU_PERCENT } from "../common";
import type { TableUpperProps } from "../dashboard";
@@ -34,12 +34,20 @@ export type SewageOccurProps = {
}
export type modalTableType = { msrDt: string,waterUsage: number,swageChangePercent: number,estimationSewage: number};
export type SewageOccurDetailProps = {
cnt : number,
cnt : number,
sum : number,
yr:string,
usege:number
usage:number, // 일평균 유입량
cntWtrUsg : number, // 물사용량 일자수
sumWtrUsg : number, // 물사용량 합
avgWtrUsg:number, // 일평균 물사용량
monWtrUsg:number, // 월 물사용량
}
export type WtrUsgYmProps = {
bscFctCd : string, // 처리구역시설코드
levyYm : string, // 부과년월
wtrUsg : number, // 월별 물사용량(m³/월)
}
// 검색단
const LabelArray:string[] = ['구분', '시도', '시군구','용량별', '시설명', '조회기간', '시설상태', '권역', '단위유역', '중권역', '환경청별'];
@@ -121,6 +129,27 @@ const sewageOccurList:SewageOccurProps[] = []
const tempSewageOccurList:modalTableType[] = []
/**
* yyyymm 형태의 문자열을 받아 해당 월의 일수를 반환
* @param {string} yyyymm - 예: "202502", "202412"
* @returns {number} 해당 월의 일수 (예: 28, 29, 30, 31)
*/
function getDaysInMonth(yyyymm) {
if (typeof yyyymm !== 'string' || yyyymm.length !== 6) {
throw new Error("입력은 'yyyymm' 형식의 문자열이어야 합니다. 예: '202502'");
}
const year = parseInt(yyyymm.slice(0, 4), 10); // "2025" -> 2025
const month = parseInt(yyyymm.slice(4, 6), 10); // "02" -> 2
if (isNaN(year) || isNaN(month) || month < 1 || month > 12) {
throw new Error("유효한 년/월이 아닙니다.");
}
// Date(year, month, 0) -> 전달의 마지막 날
// month에 '다음달'을 넣고, 일(day)에 0을 주면 해당 월의 마지막 날짜가 됨
return new Date(year, month, 0).getDate();
}
/**====================================
* Store - API
====================================*/
@@ -138,18 +167,39 @@ const tempSewageOccurList:modalTableType[] = []
return { data: res.data, count: res.count }
}*/
const fetchSweageOccurListFlow = async(fcltyCd : string, searchYear : string) => {
const url = '/fclt/flow/' + fcltyCd + '/' + searchYear;
const res = await getFetch('fo', url);
return res;
}
// const fetchSweageOccurListFlow = async(fcltyCd : string, searchYear : string) => {
// const url = '/fclt/flow/' + fcltyCd + '/' + searchYear;
// const res = await getFetch('fo', url);
// return res;
// }
// 시설들 유량 정보 조회
export const fetchSweageOccurListFlow = async (searchYear: string|undefined, fcltCdList: string[]): Promise<SewageOccurProps | null> => {
try {
const res = await postFetch('fo', '/fclt/flow/'+searchYear, fcltCdList);
return res;
} catch (error) {
console.error('시설정보 오수발생량 상세 데이터를 가져오는데 실패했습니다:', error);
return null;
}
};
// 시설별 물사용량 정보 조회
export const fetchWtrUsgYmList = async (searchYear: string|undefined, fcltCdList: string[]): Promise<WtrUsgYmProps | null> => {
try {
const res = await postFetch('fo', '/fclt/flow/wtrUsgYm/'+searchYear, fcltCdList);
return res;
} catch (error) {
console.error('시설들 월별 물사용량 데이터를 가져오는데 실패했습니다:', error);
return null;
}
};
/**====================================
* Store - expaort
====================================*/
export type SewageOccurActions = {
searchSewageOccurList: (pageNo: number, pageSize: number, searchYear: string, part?: string, nameCd?: string, fcltCpct?: string, operationValue?: string, fcltyNm?: string) => void;
searchSewageOccurDetailList: (bscFctCd: string, searchYear: string, startYearMonth?: string, endYearMonth?: string) => void;
searchSewageOccurDetailList: (bscFctCd: string, searchYear: string, startYm?: string, endYm?: string) => void;
}
export type SewageOccurType = {
@@ -201,15 +251,15 @@ export const sewageOccurInitState: SewageOccurType = {
}
// 병렬 처리 제한용 헬퍼
async function processInBatches(items, batchSize, callback) {
const results:any = [];
for (let i = 0; i < items.length; i += batchSize) {
const batch = items.slice(i, i + batchSize);
const batchResults = await Promise.all(batch.map(callback));
results.push(...batchResults);
}
return results;
}
// async function processInBatches(items, batchSize, callback) {
// const results:any = [];
// for (let i = 0; i < items.length; i += batchSize) {
// const batch = items.slice(i, i + batchSize);
// const batchResults = await Promise.all(batch.map(callback));
// results.push(...batchResults);
// }
// return results;
// }
export type SewageOccurStore = SewageOccurType & SewageOccurActions;
@@ -253,53 +303,94 @@ export const sewageOccurReducer:(set:any)=>SewageOccurActions=(set: any) => {
}));
}
const fcltylist = facilityInitData.data;
const calData = await processInBatches(fcltylist, 20, async (item: SewageOccurProps, index: number) => {
const bscFctCd = item.bscFctCd;
const res = await fetchSweageOccurListFlow(bscFctCd, searchYear).catch(() => {
console.log('Error fetching flow data for facility code:', bscFctCd);
return { data: [], count: 0 };
const fcltylist = facilityInitData.data; // 시설목록
const fcltCdList : string[] = []; // 시설코드목록
const sewageOccurPropsList : SewageOccurProps[] = []; // 오수발생량시설목록
fcltylist.forEach(item => {
fcltCdList.push(item.bscFctCd);
sewageOccurPropsList.push({
bscFctCd : item.bscFctCd,
sidoNm : item.sido,
sggNm : item.gugun,
lclgvrnmntNm : '',
bscFctNm : item.bscFctNm,
daddr : item.daddr,
fctCpc : parseFloat(item.fctCpc.toString().replace(/,/g, '')),
waterUsage : 0,
swageChangePercent : 0,
estimationSewage : 0,
sewageInputYear : 0,
sewageInputChungchun : 0,
rgnNm : item.rgnNm,
rvbsn : item.rvbsn,
lgbsn : item.lgbsn,
mdmbsn : item.mdmbsn,
msrDt : '',
});
});
const soccurDetailRes : any = await fetchSweageOccurListFlow(searchYear, fcltCdList);
const wtrUsgYmRes : any = await fetchWtrUsgYmList(searchYear, fcltCdList);
// const wtrUsgMap : Record<string, number> = {};
// wtrUsgRes.data.forEach(fclt => {
// wtrUsgMap[fclt.bscFctCd] = fclt.wtrUsg;
// });
for (const item of sewageOccurPropsList) {
const detailList = soccurDetailRes?.data[item.bscFctCd];
const wtrUsgYmList = wtrUsgYmRes?.data[item.bscFctCd];
item.sidoNm = item.sidoNm || '';
item.sggNm = item.sggNm || '';
item.fctCpc = parseFloat(item.fctCpc.toString().replace(/,/g, ''));
item.swageChangePercent = 90;
let sumChungcunDay = 0; // 청천일수
let chungcunSumFlow = 0;// 청천시 하수유입량
let totWaterUsage = 0; // 연간 하수유입량
let rainEffectDay = 0;
detailList?.forEach(item2 => {
let sumChungcunDay = 0;
let chungcunSumFlow = 0;
let totWaterUsage = 0;
// 강우영향일 계산 (강우량이 3 초과이면 무조건 강우영향일)
const rainEffectData = CalcRainEffect(res.data);
rainEffectData.forEach((item: FlowDataItem) => {
if(Number(item.rainEffectDay) === 0){
chungcunSumFlow += Number(item.inflAmnt);
if (item2.infoRnDay == null || item2.infoRnDay == 'x') item2.infoRnDay = 0;
if(item2.infoRnDay >= 3) {
rainEffectDay = 2;
}else{
rainEffectDay--;
}
if(rainEffectDay<0){ //청천시
chungcunSumFlow += item2.inflAmnt && item2.inflAmnt !== 'x' ? Number(item2.inflAmnt.toString().trim()) : 0;//Number(item2.inflAmnt);
sumChungcunDay += 1;
}
totWaterUsage += Number(item.inflAmnt);
totWaterUsage += Number(item2.inflAmnt);
});
let wtrUsgYearTotal = 0; // 연간 물사용량
let wtrUsgYearDays = 0; // 연간 물사용 일수
wtrUsgYmList?.forEach(item3 => {
const days = getDaysInMonth(item3.levyYm);
wtrUsgYearTotal += Number(item3.wtrUsg);
wtrUsgYearDays += days;
});
const avg = res.count == 0 ? 0 : totWaterUsage /res.count;
item.waterUsage = parseFloat((avg * 0.65).toFixed(2));
item.sewageInputYear = parseFloat(avg.toFixed(2));
item.sewageInputChungchun = parseFloat((chungcunSumFlow===0?0:(chungcunSumFlow / sumChungcunDay)).toFixed(2));
item.estimationSewage = parseFloat((item.waterUsage * (item.swageChangePercent/100)).toFixed(2));
// console.log("totWaterUsage:", totWaterUsage);
const count = detailList ? detailList.length : 0;
const avg = count == 0 ? 0 : totWaterUsage / count; // 일평균 유입량
item.waterUsage = parseFloat((avg * 0.65).toFixed(2)); // 물사용량 = 일평균 유입량 * 65%
if(wtrUsgYearTotal > 0 && wtrUsgYearDays > 0) {
item.waterUsage = wtrUsgYearDays>0? wtrUsgYearTotal / wtrUsgYearDays: 0; // 일평균 물사용량
}
item.sewageInputYear = parseFloat(avg.toFixed(2)); // 연간 하수유입량(m³/일)
item.sewageInputChungchun = parseFloat((chungcunSumFlow===0?0:(chungcunSumFlow / sumChungcunDay)).toFixed(2)); // 청천시 하수유입량
item.estimationSewage = parseFloat((item.waterUsage * OSU_PERCENT).toFixed(2)); // 오수발생량 추정(m³/일)
if ( item.bscFctCd === '11200PB001R' ) {
console.log('chungcunSumFlow:', chungcunSumFlow);
console.log('sumChungcunDay:', sumChungcunDay);
}
// 반올림 처리
["fctCpc", "waterUsage", "swageChangePercent", "estimationSewage", "sewageInputYear", "sewageInputChungchun"].forEach(
(key) => (item[key] = roundNumber(item[key], 0))
);
return {...item, index:index};
});
}
if(pageSize >= 5000){
const excelDownList: ExcelType[] = [];
calData.forEach((item: SewageOccurProps) => {
sewageOccurPropsList.forEach((item: SewageOccurProps) => {
excelDownList.push({
시설코드: item.bscFctCd,
권역: item.rvbsn,
@@ -331,7 +422,7 @@ export const sewageOccurReducer:(set:any)=>SewageOccurActions=(set: any) => {
...state,
SewageOccur: {
...state.SewageOccur,
sewageOccurList : calData,
sewageOccurList : sewageOccurPropsList,
sewageOccurCount : facilityInitData.count,
isLoading: false,
excelList: [],
@@ -423,24 +514,39 @@ export const sewageOccurReducer:(set:any)=>SewageOccurActions=(set: any) => {
}); */
},
searchSewageOccurDetailList: async (bscFctCd: string, searchYear: string, startYearMonth?: string, endYearMonth?: string) => {
searchSewageOccurDetailList: async (bscFctCd: string, searchYear: string, startYm?: string, endYm?: string) => {
const fcltCdList = [bscFctCd];
const wtrUsgRes : any = await fetchWtrUsgYmList(searchYear, fcltCdList);
const res = await getFetch('fo', '/fclt/flow/' + bscFctCd + '/' + searchYear);
const monthData: SewageOccurDetailProps[] = [];
let tot = 0, cnt= 0;
if(startYearMonth) {
if(startYm) {
res.data = res.data.filter((item: FlowDataItem) => {
const month = item.msrDt?.substring(0, 6);
return month >= startYearMonth;
return month >= startYm;
});
if (wtrUsgRes && wtrUsgRes.data && wtrUsgRes.data[bscFctCd]) {
wtrUsgRes.data = wtrUsgRes.data.filter((item: WtrUsgYmProps) => {
const month = item.levyYm?.substring(0, 6);
return month >= startYm;
});
}
}
if(endYearMonth) {
if(endYm) {
res.data = res.data.filter((item: FlowDataItem) => {
const month = item.msrDt?.substring(0, 6);
return month <= endYearMonth;
return month <= endYm;
});
if (wtrUsgRes && wtrUsgRes.data && wtrUsgRes.data[bscFctCd]) {
wtrUsgRes.data = wtrUsgRes.data.filter((item: WtrUsgYmProps) => {
const month = item.levyYm?.substring(0, 6);
return month <= endYm;
});
}
}
// 유입량
res.data.forEach((item: FlowDataItem) => {
const month = item.msrDt?.substring(4, 6);
const monthIdx = month ? Number(month) - 1 : -1;
@@ -449,12 +555,31 @@ export const sewageOccurReducer:(set:any)=>SewageOccurActions=(set: any) => {
monthData[monthIdx].sum += Number(item.inflAmnt);
monthData[monthIdx].cnt++;
} else {
monthData[monthIdx] = {cnt:1, sum:Number(item.inflAmnt), yr:item.msrDt.substring(0,4), usege:0};
monthData[monthIdx] = {cnt:1, sum:Number(item.inflAmnt), yr:item.msrDt.substring(0,4)
, usage:0, cntWtrUsg:0, sumWtrUsg:0, avgWtrUsg:0, monWtrUsg:0};
}
}
tot += Number(item.inflAmnt)
cnt++
})
});
// 물사용량
let totWtrUsg = 0;
let cntWtrUsg = 0;
if (wtrUsgRes && wtrUsgRes.data && wtrUsgRes.data[bscFctCd]) {
wtrUsgRes.data.forEach((item: WtrUsgYmProps) => {
const month = item.levyYm?.substring(4, 6);
const monthIdx = month ? Number(month) - 1 : -1;
if (monthIdx >= 0) {
if (typeof monthData[monthIdx] === 'object') {
monthData[monthIdx].sumWtrUsg += Number(item.wtrUsg);
monthData[monthIdx].cntWtrUsg += getDaysInMonth(item.levyYm);
}
}
totWtrUsg += Number(item.wtrUsg);
cntWtrUsg += getDaysInMonth(item.levyYm);
});
}
const tableData:modalTableType[] = []
@@ -462,42 +587,44 @@ export const sewageOccurReducer:(set:any)=>SewageOccurActions=(set: any) => {
let minWaterUsage = 9999999999;
let avgWaterUsage = 0;
monthData.forEach((item, index) => {
item.usege = item.sum/item.cnt *0.65
item.avgWtrUsg = item.sum/item.cnt *0.65; // 일평균 물사용량 = 일평균유입수 * 0.65
if (item.sumWtrUsg > 0 && item.cntWtrUsg > 0) item.avgWtrUsg = item.sumWtrUsg / item.cntWtrUsg;
tableData.push({
msrDt: item.yr+'-'+(index+1>=10?String(index+1):'0'+(index+1)),
waterUsage: Math.round(item.usege*1) /1,
swageChangePercent: (Math.round((item.usege/item.usege*OSU_PERCENT) * 100) / 100) * 100,
estimationSewage: Math.round(item.usege*OSU_PERCENT * 1) / 1
waterUsage: Math.round(item.avgWtrUsg*1) /1,
swageChangePercent: OSU_PERCENT*100,//(Math.round((item.usage/item.usage*OSU_PERCENT) * 100) / 100) * 100,
estimationSewage: Math.round(item.avgWtrUsg*OSU_PERCENT * 1) / 1
})
if (item.usege > maxWaterUsage) {
maxWaterUsage = item.usege;
if (item.avgWtrUsg > maxWaterUsage) {
maxWaterUsage = item.avgWtrUsg;
}
if (item.usege < minWaterUsage) {
minWaterUsage = item.usege;
if (item.avgWtrUsg < minWaterUsage) {
minWaterUsage = item.avgWtrUsg;
}
});
avgWaterUsage = tot/cnt*0.65
if (totWtrUsg > 0 && cntWtrUsg > 0) avgWaterUsage = totWtrUsg / cntWtrUsg;
const newData= [
{
msrDt: '평균',
waterUsage: Math.round(avgWaterUsage*1) /1,
swageChangePercent: 90,
estimationSewage: Math.round(avgWaterUsage*0.9*1) /1,
waterUsage: roundNumber(Math.round(avgWaterUsage*1) /1,0),
swageChangePercent: OSU_PERCENT*100,
estimationSewage: roundNumber(Math.round(avgWaterUsage*OSU_PERCENT*1) /1,0),
},
{
msrDt: '최대',
waterUsage: Math.round(maxWaterUsage*100) /100,
swageChangePercent: 90,
estimationSewage: Math.round(maxWaterUsage*0.9*1) /1,
waterUsage: roundNumber(Math.round(maxWaterUsage*100) /100,0),
swageChangePercent: OSU_PERCENT*100,
estimationSewage: roundNumber(Math.round(maxWaterUsage*OSU_PERCENT*1) /1, 0),
},
{
msrDt: '최소',
waterUsage: Math.round(minWaterUsage*100) /100,
swageChangePercent: 90,
estimationSewage: Math.round(minWaterUsage*0.9*1) /1,
waterUsage: roundNumber(Math.round(minWaterUsage*100) /100,0),
swageChangePercent: OSU_PERCENT*100,
estimationSewage: roundNumber(Math.round(minWaterUsage*OSU_PERCENT*1) /1,0),
},
...tableData
]
+303 -102
View File
@@ -1,8 +1,8 @@
import { getFetch } from "@/app/components/api";
import { getFetch, postFetch } from "@/app/components/api";
import DataForm, { roundNumber } from "@/app/components/common/DataForm";
import type { TableUpperProps } from "../dashboard";
import type { ExcelType, UnderWaterInputModalProps, UnderWaterProps } from "./types";
import CalcRainEffect from "@/app/components/common/CalcRainEffect";
import type { ExcelType, UnderWaterInputModalProps, UnderWaterProps, WtrUsgYmProps } from "./types";
// import CalcRainEffect from "@/app/components/common/CalcRainEffect";
import { OSU_PERCENT } from "../common";
import { fetchFacilityListInit } from "../processFacility";
export type { TableUpperProps };
@@ -12,7 +12,13 @@ export type SewageOccurDetailProps = {
cnt : number,
sum : number,
yr:string,
usege:number
usage:number, // 일평균 유입량
sunnyCnt : number,
sunnyInflAmnt : number,
sunnyAvgInflAmnt : number, // 청천시 일평균 유입량
cntWtrUsg : number, // 물사용량 일자수
sumWtrUsg : number, // 물사용량 합
avgWtrUsg:number, // 일평균 물사용량
}
/**====================================
* Store - 타입
@@ -203,11 +209,32 @@ const fetchUnderWaterList = async (pageNo: number, pageSize: number, searchYear:
//return { data: underWaterList, count: 2 };
}*/
const fetchUnderWaterListFlow = async(fcltyCd : string, searchYear : string) => {
const url = '/fclt/flow/' + fcltyCd + '/' + searchYear;
const res = await getFetch('fo', url);
return res;
}
// const fetchUnderWaterListFlow = async(fcltyCd : string, searchYear : string) => {
// const url = '/fclt/flow/' + fcltyCd + '/' + searchYear;
// const res = await getFetch('fo', url);
// return res;
// }
// 시설들 유량 정보 조회
export const fetchDetailListFlow = async (searchYear: string|undefined, fcltCdList: string[]): Promise<UnderWaterProps | null> => {
try {
const res = await postFetch('fo', '/fclt/flow/'+searchYear, fcltCdList);
return res;
} catch (error) {
console.error('시설정보 오수발생량 상세 데이터를 가져오는데 실패했습니다:', error);
return null;
}
};
// 시설들 월별 물사용량 정보 조회
export const fetchWtrUsgYmList = async (searchYear: string|undefined, fcltCdList: string[]): Promise<WtrUsgYmProps | null> => {
try {
const res = await postFetch('fo', '/fclt/flow/wtrUsgYm/'+searchYear, fcltCdList);
return res;
} catch (error) {
console.error('시설들 월별 물사용량 데이터를 가져오는데 실패했습니다:', error);
return null;
}
};
/**====================================
* Store - expaort
@@ -274,14 +301,35 @@ const dataFormSetting=(item : UnderWaterProps)=> {
}
// 병렬 처리 제한용 헬퍼
async function processInBatches(items, batchSize, callback) {
const results :any = [];
for (let i = 0; i < items.length; i += batchSize) {
const batch = items.slice(i, i + batchSize);
const batchResults = await Promise.all(batch.map(callback));
results.push(...batchResults);
// async function processInBatches(items, batchSize, callback) {
// const results :any = [];
// for (let i = 0; i < items.length; i += batchSize) {
// const batch = items.slice(i, i + batchSize);
// const batchResults = await Promise.all(batch.map(callback));
// results.push(...batchResults);
// }
// return results;
// }
/**
* yyyymm 형태의 문자열을 받아 해당 월의 일수를 반환
* @param {string} yyyymm - 예: "202502", "202412"
* @returns {number} 해당 월의 일수 (예: 28, 29, 30, 31)
*/
function getDaysInMonth(yyyymm) {
if (typeof yyyymm !== 'string' || yyyymm.length !== 6) {
throw new Error("입력은 'yyyymm' 형식의 문자열이어야 합니다. 예: '202502'");
}
return results;
const year = parseInt(yyyymm.slice(0, 4), 10); // "2025" -> 2025
const month = parseInt(yyyymm.slice(4, 6), 10); // "02" -> 2
if (isNaN(year) || isNaN(month) || month < 1 || month > 12) {
throw new Error("유효한 년/월이 아닙니다.");
}
// Date(year, month, 0) -> 전달의 마지막 날
// month에 '다음달'을 넣고, 일(day)에 0을 주면 해당 월의 마지막 날짜가 됨
return new Date(year, month, 0).getDate();
}
export type UnderWaterStore = UnderWaterType & UnderWaterActions;
@@ -313,68 +361,152 @@ export const underWaterReducer:(set:(fn: (state: UnderWaterType) => UnderWaterTy
}
const fcltylist = facilityInitData.data;
const calData = await processInBatches(fcltylist, 20, async (item: any, index: number) => {
const bscFctCd = item.bscFctCd;
const res = await fetchUnderWaterListFlow(bscFctCd, searchYear).catch((error) => {
console.log('Error fetching flow data for facility code:', bscFctCd, error);
return { data: [], count: 0 }; // 에러 발생 시 빈 데이터 반환
const fcltCdList : string[] = []; // 시설코드목록
const underWaterPropsList : UnderWaterProps[] = []; // 오수발생량시설목록
fcltylist.forEach(item => {
fcltCdList.push(item.bscFctCd);
underWaterPropsList.push({
bscFctCd : item.bscFctCd,
sidoNm : item.sido,
sggNm : item.gugun,
lclgvrnmntNm : '',
bscFctNm : item.bscFctNm,
daddr : item.daddr,
fctCpc : parseFloat(item.fctCpc.toString().replace(/,/g, '')),
inflAmnt : 0,
waterUsage : 0,
underWaterInputYear : 0,
underWaterInputSummer : 0,
underWaterInputWinter : 0,
properYn : '',
infoRnDay : 0,
rgnNm : item.rgnNm,
rvbsn : item.rvbsn,
lgbsn : item.lgbsn,
mdmbsn : item.mdmbsn,
msrDt : '',
});
item.sidoNm = item.sido || '';
item.sggNm = item.gugun || '';
item.fctCpc = parseFloat(item.fctCpc.toString().replace(/,/g, ''));
});
const soccurDetailRes : any = await fetchDetailListFlow(searchYear, fcltCdList);
const wtrUsgYmRes : any = await fetchWtrUsgYmList(searchYear, fcltCdList);
for (const item of underWaterPropsList) {
let chungcunSumFlow = 0;
const detailList = soccurDetailRes?.data[item.bscFctCd];
const wtrUsgYmList = wtrUsgYmRes?.data[item.bscFctCd];
let chungcunSumFlow = 0; // 청천유입량
let sumChungcunDay = 0; // 청천일수
let sumSummerChungcunDay = 0; // 하절기 청천일수
let sumSummerChungcunFlow = 0; // 하절기 청천유입량
let sumWinterChungcunDay = 0; // 동절기 청천일수
let sumWinterChungcunFlow = 0; // 동절기 청천유입량
let totWaterUsage = 0; // 연간 하수유입량
let totSummerUsage = 0; // 하절기 하수유입량
let totWinterUsage = 0; // 동절기 하수유입량
let totSummerDays = 0; // 하절기 일수
let totWinterDays = 0; // 동절기 일수
const summerMonth = ['06', '07', '08'];
const winterMonth = ['01', '02'];
let rainEffectDay = 0;
detailList?.forEach(item2 => {
let sumSummerChungcunDay = 0;
let sumSummerChungcunFlow = 0;
if (item2.infoRnDay == null || item2.infoRnDay.trim() == 'x') item2.infoRnDay = 0;
if(Number(item2.infoRnDay) >= 3) {
rainEffectDay = 2;
}else{
rainEffectDay--;
}
if(rainEffectDay<0){ //청천시
chungcunSumFlow += Number(item2.inflAmnt);
sumChungcunDay += 1;
let sumWinterChungcunDay = 0;
let sumWinterChungcunFlow = 0;
const rainEffectData = CalcRainEffect(res.data, 'SUNNY');
rainEffectData.forEach((flowItem: FlowDataItem) => {
chungcunSumFlow += flowItem.inflAmnt && flowItem.inflAmnt !== 'x' ? Number(flowItem.inflAmnt.toString().trim()) : 0;
if(flowItem.msrDt.substring(4,6) ==='06' || flowItem.msrDt.substring(4,6) ==='07' || flowItem.msrDt.substring(4,6) ==='08'){
sumSummerChungcunDay += 1;
sumSummerChungcunFlow += flowItem.inflAmnt && flowItem.inflAmnt !== 'x' ? Number(flowItem.inflAmnt.toString().trim()) : 0;
}else if(flowItem.msrDt.substring(4,6) ==='01' || flowItem.msrDt.substring(4,6) ==='02'){
sumWinterChungcunDay += 1;
sumWinterChungcunFlow += flowItem.inflAmnt && flowItem.inflAmnt !== 'x' ? Number(flowItem.inflAmnt.toString().trim()) : 0;
if(item2.msrDt.substring(4,6) ==='06' || item2.msrDt.substring(4,6) ==='07' || item2.msrDt.substring(4,6) ==='08'){
// 하절기 6,7,8월
sumSummerChungcunDay += 1;
sumSummerChungcunFlow += Number(item2.inflAmnt); //item2.inflAmnt && item2.inflAmnt !== 'x' ? Number(item2.inflAmnt.toString().trim()) : 0;
}else if(item2.msrDt.substring(4,6) ==='01' || item2.msrDt.substring(4,6) ==='02'){
// 동절기 1,2월
sumWinterChungcunDay += 1;
sumWinterChungcunFlow += Number(item2.inflAmnt); //item2.inflAmnt && item2.inflAmnt !== 'x' ? Number(item2.inflAmnt.toString().trim()) : 0;
}
}
totWaterUsage += Number(item2.inflAmnt);
if(item2.msrDt.substring(4,6) ==='06' || item2.msrDt.substring(4,6) ==='07' || item2.msrDt.substring(4,6) ==='08'){
// 하절기 6,7,8월
totSummerDays += 1;
totSummerUsage += Number(item2.inflAmnt); //item2.inflAmnt && item2.inflAmnt !== 'x' ? Number(item2.inflAmnt.toString().trim()) : 0;
}else if(item2.msrDt.substring(4,6) ==='01' || item2.msrDt.substring(4,6) ==='02'){
// 동절기 1,2월
totWinterDays += 1;
totWinterUsage += Number(item2.inflAmnt); //item2.inflAmnt && item2.inflAmnt !== 'x' ? Number(item2.inflAmnt.toString().trim()) : 0;
}
});
let totWaterInflow = 0
const rainEffectTot = CalcRainEffect(res.data);
rainEffectTot.forEach((flowItem: FlowDataItem) => {
totWaterInflow += flowItem.inflAmnt && flowItem.inflAmnt !== 'x' ? Number(flowItem.inflAmnt.toString().trim()) : 0;
const count = detailList ? detailList.length : 0;
const avg = count == 0 ? 0 : totWaterUsage / count; // 일평균 유입량
let wtrUsgYearTotal = 0; // 연간 물사용량
let summerWtrUsgTot = 0; // 하절기 물사용량
let winterWtrUsgTot = 0; // 동절기 물사용량
let wtrUsgYearDays = 0; // 연간 물사용 일수
let summerWtrUsgDays = 0;// 하절기 물사용 일수
let winterWtrUsgDays = 0;// 동절기 물사용 일수
wtrUsgYmList?.forEach(item3 => {
const days = getDaysInMonth(item3.levyYm);
wtrUsgYearTotal += Number(item3.wtrUsg);
wtrUsgYearDays += days;
if(summerMonth.includes(item3.levyYm.slice(-2))){
summerWtrUsgTot += Number(item3.wtrUsg);
summerWtrUsgDays += days;
}else if(winterMonth.includes(item3.levyYm.slice(-2))){
winterWtrUsgTot += Number(item3.wtrUsg);
winterWtrUsgDays += days;
}
});
const avg = res.count === 0 ? 0 : totWaterInflow / res.count;
const usage = chungcunSumFlow&&chungcunSumFlow>0?chungcunSumFlow / rainEffectData.length:0;
const summerUsage = sumSummerChungcunFlow?sumSummerChungcunFlow / sumSummerChungcunDay:0;
const winterUsage = sumWinterChungcunFlow?sumWinterChungcunFlow / sumWinterChungcunDay:0;
item.inflAmnt = roundNumber(avg, 0);
item.underWaterInputYear = Math.round((usage-(usage*0.65*OSU_PERCENT)) * 1) / 1;
item.underWaterInputSummer = Math.round((summerUsage-(summerUsage*0.65*OSU_PERCENT)) * 1) / 1;
item.underWaterInputWinter = Math.round((winterUsage-(winterUsage*0.65*OSU_PERCENT)) * 1) / 1;
item.properYn = Math.round( (Number(usage-(usage*0.65*OSU_PERCENT)) / Number(item.inflAmnt)) * 100 * 1 ) / 1 > 20?'초과유입':'적정';
return {...item, index:index};
});
if (wtrUsgYearTotal > 0) { // 물사용량이 있는 경우
const usage = chungcunSumFlow&&chungcunSumFlow>0?chungcunSumFlow / sumChungcunDay:0; // 청천시 일평균 유입량
const summerUsage = sumSummerChungcunFlow?sumSummerChungcunFlow / sumSummerChungcunDay:0; // 하절기 청천시 일평균 유입량
const winterUsage = sumWinterChungcunFlow?sumWinterChungcunFlow / sumWinterChungcunDay:0; // 동절기 청천시 일평균 유입량
const wtrUsg = wtrUsgYearDays>0? wtrUsgYearTotal / wtrUsgYearDays: 0; // 일평균 물사용량
const summerWtrUsg = summerWtrUsgTot?summerWtrUsgTot / summerWtrUsgDays:0; // 하절기 일평균 물사용량
const winterWtrUsg = winterWtrUsgTot?winterWtrUsgTot / winterWtrUsgDays:0; // 동절기 일평균 물사용량
item.inflAmnt = roundNumber(avg, 0);
item.underWaterInputYear = Math.round((usage-(wtrUsg*OSU_PERCENT)) * 1) / 1; // 연간지하수유입량(m³/일) = 청천시 실제유입량 - (물사용량 × 오수전환율)
item.underWaterInputSummer = Math.round((summerUsage-(summerWtrUsg*OSU_PERCENT)) * 1) / 1; // 하절기지하수유입량(m³/일)
item.underWaterInputWinter = Math.round((winterUsage-(winterWtrUsg*OSU_PERCENT)) * 1) / 1; // 동절기지하수유입량(m³/일)
item.properYn = Math.round( (Number(usage-(wtrUsg*OSU_PERCENT)) / Number(item.inflAmnt)) * 100 * 1 ) / 1 > 20?'초과유입':'적정'; // 적정여부
} else { // 물사용량이 없는 경우, 일평균 유입량으로 물사용량 추정(물사용량 = 유입량*0.65)
const usage = chungcunSumFlow&&chungcunSumFlow>0?chungcunSumFlow / sumChungcunDay:0; // 청천시 일평균 유입량
const summerUsage = sumSummerChungcunFlow?sumSummerChungcunFlow / sumSummerChungcunDay:0; // 하절기 청천시 일평균 유입량
const winterUsage = sumWinterChungcunFlow?sumWinterChungcunFlow / sumWinterChungcunDay:0; // 동절기 청천시 일평균 유입량
const wtrUsg = avg * 0.65; // 일평균 물사용량
const summerWtrUsg = totSummerDays>0? (totSummerUsage / totSummerDays)*0.65:0; // 하절기 일평균 물사용량
const winterWtrUsg = totWinterDays>0? (totWinterUsage / totWinterDays)*0.65:0; // 동절기 일평균 물사용량
item.inflAmnt = roundNumber(avg, 0);
//item.underWaterInputYear = Math.round((usage-(usage*0.65*OSU_PERCENT)) * 1) / 1; // 연간지하수유입량(m³/일)
item.underWaterInputYear = Math.round((usage-(wtrUsg*OSU_PERCENT)) * 1) / 1; // 연간지하수유입량(m³/일) = 청천시 실제유입량 - (물사용량 × 오수전환율)
item.underWaterInputSummer = Math.round((summerUsage-(summerWtrUsg*OSU_PERCENT)) * 1) / 1; // 하절기지하수유입량(m³/일) = 청천시 실제유입량 - (물사용량 × 오수전환율)
item.underWaterInputWinter = Math.round((winterUsage-(winterWtrUsg*OSU_PERCENT)) * 1) / 1; // 동절기지하수유입량(m³/일) = 청천시 실제유입량 - (물사용량 × 오수전환율)
item.properYn = Math.round( (Number(usage-(wtrUsg*OSU_PERCENT)) / Number(item.inflAmnt)) * 100 * 1 ) / 1 > 20?'초과유입':'적정'; // 적정여부
// 반올림 처리
// ["fctCpc", "inflAmnt", "underWaterInputYear", "underWaterInputSummer", "underWaterInputWinter"].forEach(
// (key) => (item[key] = roundNumber(item[key], 0))
// );
}
}
if(pageSize>= 5000){
const excelDownList: ExcelType[] = [];
calData.forEach((item : any) => {
underWaterPropsList.forEach((item : any) => {
dataFormSetting(item)
item.fctCpc = parseFloat(item.fctCpc.toString().replace(/,/g, ''));
/* item.sidoNm = item.lclgvrnmntNm.split(' ')[0]?.toString() || '';
item.sggNm = item.lclgvrnmntNm.split(' ')[1]?.toString() || ''; */
excelDownList.push({
시설코드: item.bscFctCd,
권역: item.rvbsn,
@@ -406,7 +538,7 @@ export const underWaterReducer:(set:(fn: (state: UnderWaterType) => UnderWaterTy
...state,
UnderWater: {
...state.UnderWater,
underWaterList: calData,
underWaterList: underWaterPropsList,
underWaterCount: facilityInitData.count,
excelList: [],
isLoading : false,
@@ -416,79 +548,148 @@ export const underWaterReducer:(set:(fn: (state: UnderWaterType) => UnderWaterTy
},
unserWaterModal:async (bscFctCd, searchYear, startYm, endYm)=>{
const fcltCdList = [bscFctCd];
const wtrUsgRes : any = await fetchWtrUsgYmList(searchYear, fcltCdList);
const res = await getFetch('fo', '/fclt/flow/' + bscFctCd + '/' + searchYear);
const monthData: SewageOccurDetailProps[] = [];
const calData = CalcRainEffect(res.data, 'SUNNY');
let tot = 0;
let cnt = 0;
calData.forEach((item: FlowDataItem) => {
const month = item.msrDt?.substring(4, 6);
let tot = 0, cnt = 0;
let sunnyTot = 0, sunnyCnt = 0;
if(startYm) {
res.data = res.data.filter((item: FlowDataItem) => {
const month = item.msrDt?.substring(0, 6);
return month >= startYm;
});
if (wtrUsgRes && wtrUsgRes.data && wtrUsgRes.data[bscFctCd]) {
wtrUsgRes.data = wtrUsgRes.data.filter((item: WtrUsgYmProps) => {
const month = item.levyYm?.substring(0, 6);
return month >= startYm;
});
}
}
if(endYm) {
res.data = res.data.filter((item: FlowDataItem) => {
const month = item.msrDt?.substring(0, 6);
return month <= endYm;
});
if (wtrUsgRes && wtrUsgRes.data && wtrUsgRes.data[bscFctCd]) {
wtrUsgRes.data = wtrUsgRes.data.filter((item: WtrUsgYmProps) => {
const month = item.levyYm?.substring(0, 6);
return month <= endYm;
});
}
}
let rainEffectDay = 0;
res.data?.forEach(item2 => {
if (item2.infoRnDay == null || item2.infoRnDay.trim() == 'x') item2.infoRnDay = 0;
if(Number(item2.infoRnDay) >= 3) {
rainEffectDay = 2;
}else{
rainEffectDay--;
}
const month = item2.msrDt?.substring(4, 6);
const monthIdx = month ? Number(month) - 1 : -1;
if (monthIdx >= 0 && (monthIdx>=Number(startYm.substring(4,6))-1 && monthIdx <= Number(endYm.substring(4, 6))-1)) {
if (typeof monthData[monthIdx] === 'object') {
monthData[monthIdx].sum += Number(item.inflAmnt);
monthData[monthIdx].sum += Number(item2.inflAmnt); // 유입량 합
monthData[monthIdx].cnt++;
} else {
monthData[monthIdx] = {cnt:1, sum:Number(item.inflAmnt), yr:item.msrDt.substring(0,4), usege:0};
monthData[monthIdx] = {cnt:1, sum:Number(item2.inflAmnt), yr:item2.msrDt.substring(0,4)
, usage:0, sunnyCnt:0, sunnyInflAmnt:0, sunnyAvgInflAmnt: 0, cntWtrUsg:0, sumWtrUsg:0, avgWtrUsg:0};
}
}
}
if(month >= startYm.substring(4, 6) && month <= endYm.substring(4, 6)){
cnt++;
tot += item.inflAmnt && item.inflAmnt !== 'x' ? Number(item.inflAmnt.toString().trim()) : 0;
tot += item2.inflAmnt && item2.inflAmnt !== 'x' ? Number(item2.inflAmnt.toString().trim()) : 0;
}
if(rainEffectDay<0) { // 청천시
if (typeof monthData[monthIdx] === 'object') {
monthData[monthIdx].sunnyInflAmnt += Number(item2.inflAmnt); // 청천 유입량 합
monthData[monthIdx].sunnyCnt++;
}else {
monthData[monthIdx] = {cnt:1, sum:Number(item2.inflAmnt), yr:item2.msrDt.substring(0,4)
, usage:0, sunnyCnt:0, sunnyInflAmnt:0, sunnyAvgInflAmnt: 0, cntWtrUsg:0, sumWtrUsg:0, avgWtrUsg:0};
}
if (month >= startYm.substring(4, 6) && month <= endYm.substring(4, 6)){
sunnyCnt++;
sunnyTot += item2.inflAmnt && item2.inflAmnt !== 'x' ? Number(item2.inflAmnt.toString().trim()) : 0;
}
}
});
// console.log(monthData);
// 물사용량
let totWtrUsg = 0;
let cntWtrUsg = 0;
if (wtrUsgRes && wtrUsgRes.data && wtrUsgRes.data[bscFctCd]) {
wtrUsgRes.data.forEach((item: WtrUsgYmProps) => {
const month = item.levyYm?.substring(4, 6);
const monthIdx = month ? Number(month) - 1 : -1;
const days = getDaysInMonth(item.levyYm);
if (monthIdx >= 0) {
if (typeof monthData[monthIdx] === 'object') {
monthData[monthIdx].sumWtrUsg += Number(item.wtrUsg);
monthData[monthIdx].cntWtrUsg += days;
}
}
totWtrUsg += Number(item.wtrUsg);
cntWtrUsg += days;
});
}
const tableData:UnderWaterInputModalProps[] = []
let maxWaterUsage = 0;
let minWaterUsage = 9999999999;
let avgWaterUsage = 0;
let maxSunnyInflAmnt = 0;
let minSunnyInflAmnt = 9999999999;
let avgSunnyInflAmnt = 0;
monthData.forEach((item, index) => {
item.usege = item.sum/item.cnt
item.usage = item.sum/item.cnt; // 일평균 유입량
item.avgWtrUsg = item.sum/item.cnt * 0.65; // 일평균 물사용량
if (item.sumWtrUsg > 0 && item.cntWtrUsg > 0) item.avgWtrUsg = item.sumWtrUsg / item.cntWtrUsg; // 일평균 물사용량
item.sunnyAvgInflAmnt = item.sunnyInflAmnt / item.sunnyCnt; // 청천 일평균 유입량
tableData.push({
msrDt: item.yr+'-'+(index+1>=10?String(index+1):'0'+(index+1)),
realSewageInput:Math.round(item.usege*1) /1,
sewageOccur:Math.round(item.usege*0.65*OSU_PERCENT * 1) / 1,
underWaterInput:Math.round((item.usege-(item.usege*0.65*OSU_PERCENT)) * 1) / 1,
realSewageInput:Math.round(item.sunnyAvgInflAmnt*1) /1, // 청천 실제 하수유입량
sewageOccur:Math.round(item.avgWtrUsg*OSU_PERCENT * 1) / 1,// 오수발생량
underWaterInput:Math.round((item.sunnyAvgInflAmnt-(item.avgWtrUsg*OSU_PERCENT)) * 1) / 1, // 지하수 유입량 : 하수처리장 청천시 실제유입량 - (물사용량 × 오수전환율)
})
if (item.usege > maxWaterUsage) {
maxWaterUsage = item.usege;
}
if (item.usege < minWaterUsage) {
minWaterUsage = item.usege;
}
if (item.avgWtrUsg > maxWaterUsage) maxWaterUsage = item.avgWtrUsg;
if (item.sunnyAvgInflAmnt > maxSunnyInflAmnt) maxSunnyInflAmnt = item.sunnyAvgInflAmnt;
if (item.avgWtrUsg < minWaterUsage) minWaterUsage = item.avgWtrUsg;
if (item.sunnyAvgInflAmnt < minSunnyInflAmnt) minSunnyInflAmnt = item.sunnyAvgInflAmnt;
});
if(monthData.length == 0){
maxWaterUsage = 0;
avgWaterUsage = 0;
minWaterUsage = 0;
maxWaterUsage = 0; minWaterUsage = 0; avgWaterUsage = 0;
maxSunnyInflAmnt = 0; minSunnyInflAmnt = 0; avgSunnyInflAmnt = 0;
}
avgWaterUsage = tot/cnt;
avgWaterUsage = cnt>0? tot/cnt * 0.65:0; // 일평균 물사용량
if (totWtrUsg > 0 && cntWtrUsg > 0) avgWaterUsage = totWtrUsg / cntWtrUsg; // 일평균 물사용량
avgSunnyInflAmnt = (sunnyCnt>0)?sunnyTot/sunnyCnt:0; // 청천 일평균 유입량
const newData= [
{
msrDt: '평균',
realSewageInput:Math.round(avgWaterUsage*1) /1,
sewageOccur:Math.round(avgWaterUsage*0.65*OSU_PERCENT * 1) / 1,
underWaterInput: Math.round((avgWaterUsage-(avgWaterUsage*0.65*OSU_PERCENT)) * 1) / 1
realSewageInput:Math.round(avgSunnyInflAmnt*1) /1, // 청천시 실제 하수유입량
sewageOccur:Math.round(avgWaterUsage*OSU_PERCENT * 1) / 1, // 오수발생량
underWaterInput: Math.round((avgSunnyInflAmnt-(avgWaterUsage*OSU_PERCENT)) * 1) / 1 // 지하수 유입량 : 하수처리장 청천시 실제유입량 - (물사용량 × 오수전환율)
//underWaterInput:Math.round((avgWaterUsage-(avgWaterUsage*0.65*0.8)) * 100) / 100,
},
{
msrDt: '최대',
realSewageInput: Math.round(maxWaterUsage*1) /1,
sewageOccur: Math.round(maxWaterUsage*0.65*OSU_PERCENT * 1) / 1,
underWaterInput: Math.round((maxWaterUsage-(maxWaterUsage*0.65*OSU_PERCENT)) * 1) / 1,
realSewageInput: Math.round(maxSunnyInflAmnt*1) /1, // 청천시 실제 하수유입량
sewageOccur: Math.round(maxWaterUsage*OSU_PERCENT * 1) / 1, // 오수발생량
underWaterInput: Math.round((maxSunnyInflAmnt-(maxWaterUsage*OSU_PERCENT)) * 1) / 1, // 지하수 유입량 : 하수처리장 청천시 실제유입량 - (물사용량 × 오수전환율)
},
{
msrDt: '최소',
realSewageInput: Math.round(minWaterUsage*1) /1,
sewageOccur: Math.round(minWaterUsage*0.65*OSU_PERCENT * 1) / 1,
underWaterInput: Math.round((minWaterUsage-(minWaterUsage*0.65*OSU_PERCENT)) * 1) / 1,
realSewageInput: Math.round(minSunnyInflAmnt*1) /1, // 청천시 실제 하수유입량
sewageOccur: Math.round(minWaterUsage*OSU_PERCENT * 1) / 1, // 오수발생량
underWaterInput: Math.round((minSunnyInflAmnt-(minWaterUsage*OSU_PERCENT)) * 1) / 1, // 지하수 유입량 : 하수처리장 청천시 실제유입량 - (물사용량 × 오수전환율)
},
...tableData
]
+19 -11
View File
@@ -1,21 +1,29 @@
export type UnderWaterProps = {
bscFctCd : string,
bscFctCd : string, // 처리구역시설코드
lclgvrnmntNm : string,
sidoNm : string,
sggNm : string,
bscFctNm : string,
daddr : string,
fctCpc : number,
underWaterInputYear : number,
underWaterInputSummer : number,
underWaterInputWinter : number,
sidoNm : string, // 시도
sggNm : string, // 시군구
bscFctNm : string, // 시설명
daddr : string, // 주소
fctCpc : number, // 시설용량
waterUsage : number, // 물사용량(m³/일)
underWaterInputYear : number, // 연간 지하수유입량 추정(m³/일)
underWaterInputSummer : number, // 하절기 지하수유입량 추정(m³/일)
underWaterInputWinter : number, // 동절기 지하수유입량 추정(m³/일)
infoRnDay : number,
rgnNm : string,
rvbsn : string,
lgbsn : string,
mdmbsn : string,
inflAmnt : number | '',
properYn : string | '',
inflAmnt : number, // 연평균유입하수량(m³/일)
properYn : string | '', // 적정여부
msrDt : string, // 측정일자
}
export type WtrUsgYmProps = {
bscFctCd : string, // 처리구역시설코드
levyYm : string, // 부과년월
wtrUsg : number, // 월별 물사용량(m³/월)
}
export interface UnderWaterInterface {
bscFctCd : string,
+1 -1
View File
@@ -15,7 +15,7 @@
"@tailwindcss/vite": "^4.0.10",
"html2canvas": "^1.4.1",
"js-cookie": "^3.0.5",
"next": "15.1.7",
"next": "15.3.6",
"select": "^1.1.2",
"webpack": "^5.98.0",
"zustand": "^5.0.3",
+1 -1
View File
@@ -26,7 +26,7 @@
"dayjs": "^1.11.13",
"js-cookie": "^3.0.5",
"moment": "^2.30.1",
"next": "15.1.7",
"next": "15.3.6",
"react-chartjs-2": "^5.3.0",
"react-hot-toast": "^2.5.2",
"react-dnd": "^16.0.1",
+2 -1
View File
@@ -18,7 +18,8 @@ NEXT_PUBLIC_SEWERAGE_API_URL = http://con.mind-one.co.kr:11083/sewerage-service
NEXT_PUBLIC_NGII_URL = https://map.ngii.go.kr
NEXT_PUBLIC_NGII_PARAM = 04trYP9_xwLAfALjwZ-B8g&service=WMTS&request=GetTile&version=1.0.0&format=image/png&layer=korean_map&style=korean&tilematrixset=korean&tilematrix={z}&tilecol={x}&tilerow={y}
NEXT_PUBLIC_CM_LOGIN_PAGE_URL = http://dev.onasys.co.kr:3000/auth/login
NEXT_PUBLIC_HASUDOINFO = https://www.hasudoinfo.or.kr
NEXT_PUBLIC_HASUDOINFO_IN = https://www.hasudoinfo.or.kr
NEXT_PUBLIC_FINDADRESS_URL = https://business.juso.go.kr/addrlink/addrLinkApi.do
NEXT_PRIVATE_WR_ADDRESS_KEY = devU01TX0FVVEgyMDI1MDgyNTEwMzkwNDExNjEwNTM=
REACT_EDITOR = atom
+2 -1
View File
@@ -15,6 +15,7 @@ NEXT_PUBLIC_WR_SERVER_API_URL = http://con.mind-one.co.kr:11083/reuse-service/ap
NEXT_PUBLIC_SEWERAGE_API_URL = http://con.mind-one.co.kr:11083/sewerage-service # 개발 서버 API
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_HASUDOINFO = https://www.hasudoinfo.or.kr
NEXT_PUBLIC_HASUDOINFO_IN = https://www.hasudoinfo.or.kr
NEXT_PUBLIC_FINDADRESS_URL=https://business.juso.go.kr/addrlink/addrLinkApi.do
NEXT_PRIVATE_WR_ADDRESS_KEY = devU01TX0FVVEgyMDI1MDgyNTEwMzkwNDExNjEwNTM=
+2 -1
View File
@@ -17,6 +17,7 @@ NEXT_PUBLIC_WR_SERVER_API_URL = http://apigateway.iipbs-was.svc.cluster.local:80
NEXT_PUBLIC_SEWERAGE_API_URL = http://apigateway.iipbs-was.svc.cluster.local:8000/sewerage-service
NEXT_PUBLIC_NGII_URL = http://172.29.143.87
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_HASUDOINFO = https://www.hasudoinfo.or.kr
NEXT_PUBLIC_HASUDOINFO_IN = http://10.101.163.102:8889
NEXT_PUBLIC_FINDADRESS_URL=https://business.juso.go.kr/addrlink/addrLinkApi.do
NEXT_PRIVATE_WR_ADDRESS_KEY = U01TXOFVVEgyMDI1MDgyODE0MTAxNzExNjEyMjA=
@@ -20,7 +20,7 @@ export default function HasuStatPage() {
const data = await res.json();
const ip: string = data?.ip || "";
if (didCancel) return;
alert("Client IP:" + ip);
//alert("Client IP:" + ip);
const is10 = /^10\./.test(ip);
const is172 = /^172\./.test(ip);
@@ -29,9 +29,11 @@ export default function HasuStatPage() {
// Use location.replace so user can't go back to this page
if (is10||is172) {
// redirect to internal IP
window.open('http://10.101.163.102:8888/cms/lay1/WS10000014/program.do', '_blank');
// alert(process.env.NEXT_PUBLIC_HASUDOINFO_IN +'/cms/lay1/WS10000014/program.do');
window.open(process.env.NEXT_PUBLIC_HASUDOINFO_IN +'/cms/lay1/WS10000014/program.do', '_blank');
} else {
window.open('https://www.hasudoinfo.or.kr/cms/lay1/WS10000014/program.do', '_blank');
// alert(process.env.NEXT_PUBLIC_HASUDOINFO +'/cms/lay1/WS10000014/program.do');
window.open(process.env.NEXT_PUBLIC_HASUDOINFO +'/cms/lay1/WS10000014/program.do', '_blank');
}
window.history.back();
} catch {
+2
View File
@@ -0,0 +1,2 @@
import { LoginPage } from "@/src/features/auth/login";
export default LoginPage;
+13 -11
View File
@@ -1,6 +1,6 @@
"use client";
import { LoginApi } from "../src/entities/auth";
// import { LoginApi } from "../src/entities/auth";
import { useEffect } from "react";
import { getCookie } from "../src/shared/lib";
import { AUTH_USER_ID } from "../src/shared/config";
@@ -16,16 +16,18 @@ export default function Home() {
if (hostname === "localhost" || hostname === "127.0.0.1" || hostname === "con.mind-one.co.kr") {
if (!checkToken) {
async function doLogin() {
try {
await LoginApi.login({ userId: "CLEANWATER", password: "gktneh12!@" });
window.location.href = "/map/mainMap";
} catch (err) {
console.error("로그인 실패", err);
}
}
doLogin();
} else router.push("/map/mainMap");
// async function doLogin() {
// try {
// await LoginApi.login({ userId: "CLEANWATER", password: "gktneh12!@" });
// window.location.href = "/map/mainMap";
// } catch (err) {
// console.error("로그인 실패", err);
// }
// }
// doLogin();
router.push("/auth/login");
} else
router.push("/map/mainMap");
} else router.push("/map/mainMap");
}, []);
}
+4 -2
View File
@@ -14,10 +14,12 @@
"@common/assets": "workspace:*",
"@common/business_components": "workspace:*",
"@common/components": "workspace:*",
"@hookform/resolvers": "^4.1.3",
"@fontsource-variable/noto-sans-kr": "^5.2.5",
"@frontend-opensource/use-react-hooks": "^1.1.4",
"@hookform/resolvers": "^4.1.3",
"@minoru/react-dnd-treeview": "3.5.1-alpha.1",
"@radix-ui/react-label": "^2.1.2",
"@radix-ui/react-slot": "^1.1.2",
"@tanstack/react-query": "^5.67.1",
"@tanstack/react-query-devtools": "^5.75.5",
"@tanstack/react-table": "^8.21.2",
@@ -28,7 +30,7 @@
"js-cookie": "^3.0.5",
"lucide-react": "^0.474.0",
"moment": "^2.30.1",
"next": "15.1.7",
"next": "15.3.6",
"ol": "^7.3.0",
"ol-ext": "^4.0.11",
"proj4": "^2.9.0",
Binary file not shown.

After

Width:  |  Height:  |  Size: 132 KiB

@@ -1,7 +1,8 @@
"use client";
import { useEffect } from "react";
import { useRouter, usePathname } from "next/navigation";
import { useRouter } from "next/navigation";
import { usePathname } from "next/navigation";
import { client } from "../../../shared/api";
import type { IUserInfo } from "../../../shared/store";
import { useUserStore } from "../../../shared/lib";
@@ -42,16 +43,17 @@ const AuthWrapper = ({ children }: { children: React.ReactNode }) => {
} catch (refreshError) {
console.error("Token refresh failed:", refreshError);
setUserInfo(null);
const hostname = window.location.hostname;
// 개발 환경에서는 localhost 도메인 사용
if (hostname === "localhost" || hostname === "127.0.0.1" || hostname === "con.mind-one.co.kr")
router.push("/"); // 개발시 홈으로 돌아가기
router.push("/auth/login");
// 운영 환경에서는 서브도메인을 포함한 최상위 도메인 사용
// router.push는 같은 Next.js 앱 내에서만 작동하므로 다른 앱으로 이동할 때는 window.location.href 사용
if (hostname.includes("hasuon.or.kr")) window.location.href = LOGIN_PAGE_URL; // 세션 만료 시 CM 로그인 페이지로 리다이렉트
if (hostname.includes("hasuon.or.kr"))
window.location.href = LOGIN_PAGE_URL; // 세션 만료 시 CM 로그인 페이지로 리다이렉트
}
}
})();
@@ -0,0 +1,225 @@
import { client } from "@/src/shared/api";
import { ENDPOINT } from "@/src/shared/config";
import type {
UserService,
UserSearchParams,
UserList,
UserDetailResponse,
UserSttsStatsList,
UserUpdateData,
UserPasswordUpdateData,
UserFindPasswordUpdateData,
UserPasswordMatchData,
UserUpdateInfoData,
UserSaveData,
UserFindPasswordSaveData,
UserVerifyData,
UserEmailData,
UserIdData,
AdminUserList,
} from "../model/user-interface";
export class UserApiService implements UserService {
// 사용자 목록 조회
public async findPageUser(params: UserSearchParams): Promise<UserList> {
const response = await client.get<UserList>(
`${ENDPOINT.USER_SERVICE.USERS}`,
{ params },
);
return response.data;
}
// 사용자 단건 조회
public async findByUserUnqId(userUnqId: string): Promise<UserDetailResponse> {
const response = await client.get<UserDetailResponse>(
`${ENDPOINT.USER_SERVICE.USERS}/${userUnqId}`,
);
return response.data;
}
// 로그인한 사용자 정보 조회
public async findByUserUnqIdByLogin(): Promise<UserDetailResponse> {
const response = await client.get<UserDetailResponse>(
`${ENDPOINT.USER_SERVICE.USERS}/info/me`,
);
return response.data;
}
// 사용자 회원상태 통계 조회
public async selectUserSttsStats(): Promise<UserSttsStatsList> {
const response = await client.get<UserSttsStatsList>(
`${ENDPOINT.USER_SERVICE.USERS}/stts/stats`,
);
return response.data;
}
// 사용자 저장
public async saveUser(data: UserSaveData): Promise<string> {
const response = await client.post<string>(
`${ENDPOINT.USER_SERVICE.USERS}`,
data,
);
return response.data;
}
// 사용자 수정
public async updateUser(
userUnqId: string,
data: UserUpdateData,
): Promise<string> {
const response = await client.put<string>(
`${ENDPOINT.USER_SERVICE.USERS}/${userUnqId}`,
data,
);
return response.data;
}
// 사용자 삭제
public async deleteUser(userUnqId: string): Promise<boolean> {
const response = await client.delete<boolean>(
`${ENDPOINT.USER_SERVICE.USERS}/delete/${userUnqId}`,
);
return response.data;
}
// 사용자 계정잠금 처리
public async lockUser(userUnqId: string): Promise<boolean> {
const response = await client.put<boolean>(
`${ENDPOINT.USER_SERVICE.USERS}/lock/${userUnqId}`,
);
return response.data;
}
// 사용자 계정잠금 해제처리
public async unlockUser(userUnqId: string): Promise<boolean> {
const response = await client.put<boolean>(
`${ENDPOINT.USER_SERVICE.USERS}/unlock/${userUnqId}`,
);
return response.data;
}
// 리프레시 토큰 정보 재발급
public async refreshToken(): Promise<void> {
await client.put(`${ENDPOINT.USER_SERVICE.USERS}/token/refresh`);
}
// 사용자 비밀번호 변경
public async updatePassword(data: UserPasswordUpdateData): Promise<boolean> {
const response = await client.put<boolean>(
`${ENDPOINT.USER_SERVICE.USERS}/password/update`,
data,
);
return response.data;
}
// 사용자 비밀번호 초기화(관리자용)
public async initializePassword(
userUnqId: string,
data: UserPasswordUpdateData,
): Promise<boolean> {
const response = await client.put<boolean>(
`${ENDPOINT.USER_SERVICE.USERS}/password/initialize/${userUnqId}`,
data,
);
return response.data;
}
// 사용자 비밀번호 찾기 변경
public async changePassword(
data: UserFindPasswordUpdateData,
): Promise<boolean> {
const response = await client.put<boolean>(
`${ENDPOINT.USER_SERVICE.USERS}/password/change`,
data,
);
return response.data;
}
// 사용자 비밀번호 확인
public async matchPassword(data: UserPasswordMatchData): Promise<boolean> {
const response = await client.post<boolean>(
`${ENDPOINT.USER_SERVICE.USERS}/password/match`,
data,
);
return response.data;
}
// 사용자 비밀번호 찾기
public async findPasswordUser(
data: UserFindPasswordSaveData,
): Promise<boolean> {
const response = await client.post<boolean>(
`${ENDPOINT.USER_SERVICE.USERS}/password/find`,
data,
);
return response.data;
}
// 사용자 비밀번호 찾기 유효성 확인
public async validPassword(token: string): Promise<boolean> {
const response = await client.get<boolean>(
`${ENDPOINT.USER_SERVICE.USERS}/password/valid/${token}`,
);
return response.data;
}
// 사용자 회원정보 변경
public async updateInfo(
userUnqId: string,
data: UserUpdateInfoData,
): Promise<string> {
const response = await client.put<string>(
`${ENDPOINT.USER_SERVICE.USERS}/info/${userUnqId}`,
data,
);
return response.data;
}
// 사용자 승인처리
public async applyUser(userUnqId: string): Promise<boolean> {
const response = await client.put<boolean>(
`${ENDPOINT.USER_SERVICE.USERS}/apply/${userUnqId}`,
);
return response.data;
}
// 사용자 회원탈퇴
public async leaveUser(data: UserVerifyData): Promise<boolean> {
const response = await client.post<boolean>(
`${ENDPOINT.USER_SERVICE.USERS}/leave`,
data,
);
return response.data;
}
// 사용자 이메일 중복 체크
public async existsEmail(data: UserEmailData): Promise<boolean> {
const response = await client.post<boolean>(
`${ENDPOINT.USER_SERVICE.USERS}/exists`,
data,
);
return response.data;
}
// 사용자 아이디 중복 체크
public async existsUserId(data: UserIdData): Promise<boolean> {
const response = await client.post<boolean>(
`${ENDPOINT.USER_SERVICE.USERS}/existsUserId`,
data,
);
return response.data;
}
// 관리자 목록 조회
public async findPageAdminUser(
params: UserSearchParams,
): Promise<AdminUserList> {
const response = await client.get<AdminUserList>(
`${ENDPOINT.USER_SERVICE.USERS}/admin/list`,
{ params },
);
return response.data;
}
}
export const UserApi = new UserApiService();
+17
View File
@@ -0,0 +1,17 @@
export { UserApi } from "./api/user-service";
export {
USER_STATE_CODE,
userSaveSchema,
userPasswordUpdateSchema,
userUpdateSchema,
type UserStateCode,
type UserSearchParams,
type UserListResponse,
type UserSaveData,
type UserDetailResponse,
type UserPasswordUpdateData,
type UserUpdateData,
type AdminUserList,
type AdminUserListResponse,
} from "./model/user-interface";
export { PasswordResetDialog } from "./ui/password-reset-dialog";
@@ -0,0 +1,362 @@
import { z } from "zod";
import type { PageableList, PageableParams } from "@/src/shared/model";
export const USER_STATE_CODE = {
대기: "00",
정상: "01",
정지: "07",
탈퇴: "08",
삭제: "09",
} as const;
export type UserStateCode =
(typeof USER_STATE_CODE)[keyof typeof USER_STATE_CODE];
// 사용자 검색 파라미터
export interface UserSearchParams extends PageableParams {
keywordType?: string;
keyword?: string;
userStateCode?: string;
aprvYn?: string;
grpId?: string;
frDateYmd?: string;
toDateYmd?: string;
userName?: string;
}
// 사용자 목록 응답 인터페이스
export interface UserListResponse {
userId: string;
userName: string;
email: string;
roleId: string;
roleName: string;
userStateCode: UserStateCode;
userStateCodeName: string;
lastLoginDate: string;
loginFailCount: number;
userUnqId: string;
mphn: string;
telno: string;
aprvYn: string;
regDt: string;
laeSeCd: string;
laeSeNm: string;
ogdpSeCd: string;
ogdpSeNm: string;
instSeCd: string;
instSeNm: string;
aprvYmd: string;
}
// 사용자 상세 응답 인터페이스
export interface UserDetailResponse {
regDt: string;
mdfcnDt: string;
rgtr: string;
mdfr: string;
userUnqId: string;
userId: string;
userName: string;
email: string;
encryptedPassword: string;
refreshToken: string;
userStateCode: UserStateCode;
lastLoginDate: string;
loginFailCount: number;
pswdHnt: string;
pswdAns: string;
mphn: string;
telno: string;
zip: string;
addr: string;
daddr: string;
laeSeCd: string;
ogdpSeCd: string;
aprvYn: string;
autzrId: string;
expryYmd: string;
lastPswdChgDt: string;
googleId: string;
kakaoId: string;
naverId: string;
isSocialUser: boolean;
hasPassword: boolean;
twoFactorEnabled: boolean;
adminRoleYn: string;
instSeCd: string;
aprvYmd: string;
enoCd: string;
dtlInstCd: string;
laeSeNm: string;
ogdpSeNm: string;
instSeNm: string;
userStateNm: string;
pswdHntNm: string;
ecoNm: string;
dtlInstNm: string;
}
// 사용자 상태 통계 응답 인터페이스
export interface UserSttsStatsResponse {
sttsCd: string;
sttsNm: string;
cnt: number;
}
// 관리자 목록 응답 인터페이스
export interface AdminUserListResponse {
userUnqId: string;
userId: string;
userName: string;
groupId: string;
groupNm: string;
userStateCode: string;
userStateCodeName: string;
laeSeCd: string;
laeSeNm: string;
ogdpSeCd: string;
ogdpSeNm: string;
instSeCd: string;
instSeNm: string;
dtlInstCd: string;
dtlInstNm: string;
mdfr: string;
mdfrNm: string;
mdfcnDt: string;
}
// 사용자 수정 요청 스키마
export const userUpdateSchema = z.object({
userId: z.string().optional(),
userName: z.string(),
email: z.string().email().optional(),
password: z
.string()
.regex(
/^(?=.*[a-zA-Z])(?=.*\d)(?=.*[!@#$%^&*()_+\-=[\]{};':"\\|,.<>/?]).{8,20}$/,
"비밀번호는 8~20자, 영문/숫자/특수기호를 모두 포함해야 합니다.",
)
.optional(),
roleId: z.string().optional(),
userStateCode: z.string(),
pswdHnt: z.string().max(10),
pswdAns: z.string().max(100).optional(),
mphn: z.string().max(11).optional(),
telno: z.string().max(11).optional(),
zip: z.string().max(5).optional(),
addr: z.string().max(200).optional(),
daddr: z.string().max(200).optional(),
laeSeCd: z.string().max(10),
ogdpSeCd: z.string().max(10),
aprvYn: z.string(),
autzrId: z.string().uuid().optional(),
expryYmd: z.string().max(8),
instSeCd: z.string().optional(),
dtlInstCd: z.string().optional(),
aprvYmd: z.string().max(8).optional(),
enoCd: z.string().optional(),
});
// 사용자 비밀번호 변경 스키마
export const userPasswordUpdateSchema = z
.object({
password: z.string().optional(),
provider: z.string().optional(),
token: z.string().optional(),
newPassword: z
.string()
.regex(
/^(?=.*[a-zA-Z])(?=.*\d)(?=.*[!@#$%^&*()_+\-=[\]{};':"\\|,.<>/?]).{8,20}$/,
"비밀번호는 8~20자, 영문/숫자/특수기호를 모두 포함해야 합니다.",
),
newPasswordConfirm: z.string(),
})
.refine((data) => data.newPassword === data.newPasswordConfirm, {
message: "비밀번호가 일치하지 않습니다.",
path: ["newPasswordConfirm"],
});
// 사용자 비밀번호 찾기 변경 스키마
export const userFindPasswordUpdateSchema = z.object({
tokenValue: z.string(),
password: z
.string()
.regex(
/^(?=.*[a-zA-Z])(?=.*\d)(?=.*[!@#$%^&*()_+\-=[\]{};':"\\|,.<>/?]).{8,20}$/,
"비밀번호는 8~20자, 영문/숫자/특수기호를 모두 포함해야 합니다.",
),
});
// 사용자 비밀번호 확인 스키마
export const userPasswordMatchSchema = z.object({
password: z
.string()
.regex(
/^(?=.*[a-zA-Z])(?=.*\d)(?=.*[!@#$%^&*()_+\-=[\]{};':"\\|,.<>/?]).{8,20}$/,
"비밀번호는 8~20자, 영문/숫자/특수기호를 모두 포함해야 합니다.",
),
});
// 사용자 회원정보 변경 스키마
export const userUpdateInfoSchema = z.object({
password: z
.string()
.regex(
/^(?=.*[a-zA-Z])(?=.*\d)(?=.*[!@#$%^&*()_+\-=[\]{};':"\\|,.<>/?]).{8,20}$/,
"비밀번호는 8~20자, 영문/숫자/특수기호를 모두 포함해야 합니다.",
)
.optional(),
provider: z.string().optional(),
token: z.string().optional(),
userName: z.string(),
email: z.string().email().optional(),
});
// 사용자 저장 스키마
export const userSaveSchema = z.object({
userId: z.string(),
userName: z.string(),
email: z.string().email().optional(),
password: z
.string()
.regex(
/^(?=.*[a-zA-Z])(?=.*\d)(?=.*[!@#$%^&*()_+\-=[\]{};':"\\|,.<>/?]).{8,20}$/,
"비밀번호는 8~20자, 영문/숫자/특수기호를 모두 포함해야 합니다.",
),
pswdHnt: z.string().max(10),
pswdAns: z.string().max(100).optional(),
mphn: z.string().max(11).optional(),
telno: z.string().max(11).optional(),
zip: z.string().max(5).optional(),
addr: z.string().max(200).optional(),
daddr: z.string().max(200).optional(),
laeSeCd: z.string().max(10),
ogdpSeCd: z.string().max(10),
expryYmd: z.string().max(8),
instSeCd: z.string().optional(),
dtlInstCd: z.string().optional(),
enoCd: z.string().optional(),
});
// 사용자 비밀번호 찾기 저장 스키마
export const userFindPasswordSaveSchema = z.object({
userName: z.string(),
emailAddr: z.string().email(),
mainUrl: z.string(),
changePasswordUrl: z.string(),
});
// 사용자 인증 요청 스키마
export const userVerifySchema = z.object({
password: z
.string()
.regex(/((?=.*[0-9])(?=.*[a-zA-Z])(?=.*\W)(?=\S+$).{8,20})|()/)
.optional(),
provider: z.string().optional(),
token: z.string().optional(),
});
// 사용자 이메일 요청 스키마
export const userEmailSchema = z.object({
userId: z.string().optional(),
email: z.string().email(),
});
// 사용자 ID 요청 스키마
export const userIdSchema = z.object({
userId: z.string(),
});
// Zod 스키마 타입 정의
export type UserUpdateData = z.infer<typeof userUpdateSchema>;
export type UserPasswordUpdateData = z.infer<typeof userPasswordUpdateSchema>;
export type UserFindPasswordUpdateData = z.infer<
typeof userFindPasswordUpdateSchema
>;
export type UserPasswordMatchData = z.infer<typeof userPasswordMatchSchema>;
export type UserUpdateInfoData = z.infer<typeof userUpdateInfoSchema>;
export type UserSaveData = z.infer<typeof userSaveSchema>;
export type UserFindPasswordSaveData = z.infer<
typeof userFindPasswordSaveSchema
>;
export type UserVerifyData = z.infer<typeof userVerifySchema>;
export type UserEmailData = z.infer<typeof userEmailSchema>;
export type UserIdData = z.infer<typeof userIdSchema>;
// 페이지네이션 타입 정의
export type UserList = PageableList<UserListResponse>;
export type UserSttsStatsList = UserSttsStatsResponse[];
export type AdminUserList = PageableList<AdminUserListResponse>;
// 사용자 서비스 인터페이스
export interface UserService {
// 사용자 목록 조회
findPageUser: (params: UserSearchParams) => Promise<UserList>;
// 사용자 단건 조회
findByUserUnqId: (userUnqId: string) => Promise<UserDetailResponse>;
// 로그인한 사용자 정보 조회
findByUserUnqIdByLogin: () => Promise<UserDetailResponse>;
// 사용자 회원상태 통계 조회
selectUserSttsStats: () => Promise<UserSttsStatsList>;
// 사용자 저장
saveUser: (data: UserSaveData) => Promise<string>;
// 사용자 수정
updateUser: (userUnqId: string, data: UserUpdateData) => Promise<string>;
// 사용자 삭제
deleteUser: (userUnqId: string) => Promise<boolean>;
// 사용자 계정잠금 처리
lockUser: (userUnqId: string) => Promise<boolean>;
// 사용자 계정잠금 해제처리
unlockUser: (userUnqId: string) => Promise<boolean>;
// 리프레시 토큰 정보 재발급
refreshToken: () => Promise<void>;
// 사용자 비밀번호 변경
updatePassword: (data: UserPasswordUpdateData) => Promise<boolean>;
// 사용자 비밀번호 초기화(관리자용)
initializePassword: (
userUnqId: string,
data: UserPasswordUpdateData,
) => Promise<boolean>;
// 사용자 비밀번호 찾기 변경
changePassword: (data: UserFindPasswordUpdateData) => Promise<boolean>;
// 사용자 비밀번호 확인
matchPassword: (data: UserPasswordMatchData) => Promise<boolean>;
// 사용자 비밀번호 찾기
findPasswordUser: (data: UserFindPasswordSaveData) => Promise<boolean>;
// 사용자 비밀번호 찾기 유효성 확인
validPassword: (token: string) => Promise<boolean>;
// 사용자 회원정보 변경
updateInfo: (userUnqId: string, data: UserUpdateInfoData) => Promise<string>;
// 사용자 승인처리
applyUser: (userUnqId: string) => Promise<boolean>;
// 사용자 회원탈퇴
leaveUser: (data: UserVerifyData) => Promise<boolean>;
// 사용자 이메일 중복 체크
existsEmail: (data: UserEmailData) => Promise<boolean>;
// 사용자 아이디 중복 체크
existsUserId: (data: UserIdData) => Promise<boolean>;
// 관리자 목록 조회
findPageAdminUser: (params: UserSearchParams) => Promise<AdminUserList>;
}
@@ -0,0 +1,124 @@
"use client";
import { useState, type ReactNode } from "react";
import { useForm } from "react-hook-form";
import { zodResolver } from "@hookform/resolvers/zod";
import {
Dialog,
DialogContent,
DialogHeader,
DialogTitle,
DialogFooter,
DialogClose,
Button,
Input,
DialogTrigger,
} from "@common/components";
import {
UserApi,
userPasswordUpdateSchema,
type UserPasswordUpdateData,
} from "@/src/entities/user";
import {
Form,
FormControl,
FormField,
FormItem,
FormLabel,
FormMessage,
} from "@/src/shared/ui/form";
import { useAlertStore, useMutationWrapper } from "@/src/shared/lib";
interface PasswordResetDialogProps {
userUnqId: string;
children: ReactNode;
}
export function PasswordResetDialog({
userUnqId,
children,
}: PasswordResetDialogProps) {
const alert = useAlertStore((state) => state.setMessage);
const [open, setOpen] = useState(false);
const form = useForm<UserPasswordUpdateData>({
resolver: zodResolver(userPasswordUpdateSchema),
defaultValues: {
newPassword: "",
newPasswordConfirm: "",
provider: "password",
},
});
const { mutate: onPasswordReset } = useMutationWrapper({
mutationFn: (data: UserPasswordUpdateData) =>
UserApi.initializePassword(userUnqId, data),
onSuccess: () => {
alert("비밀번호가 초기화되었습니다.", () => {
setOpen(false);
form.reset();
});
},
});
return (
<Dialog open={open} onOpenChange={setOpen}>
<DialogTrigger asChild>{children}</DialogTrigger>
<DialogContent>
<DialogHeader>
<DialogTitle>비밀번호 초기화</DialogTitle>
</DialogHeader>
<Form {...form}>
<form
onSubmit={form.handleSubmit((data) => onPasswordReset(data))}
className="space-y-6"
>
<FormField
control={form.control}
name="newPassword"
render={({ field }) => (
<FormItem>
<FormLabel>새 비밀번호</FormLabel>
<FormControl>
<Input
{...field}
type="password"
className="!text-[1.3rem]"
/>
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name="newPasswordConfirm"
render={({ field }) => (
<FormItem>
<FormLabel>새 비밀번호 확인</FormLabel>
<FormControl>
<Input
{...field}
type="password"
className="!text-[1.3rem]"
/>
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<DialogFooter>
<DialogClose asChild>
<Button type="button" className="white">
취소
</Button>
</DialogClose>
<Button type="submit">확인</Button>
</DialogFooter>
</form>
</Form>
</DialogContent>
</Dialog>
);
}
+1
View File
@@ -0,0 +1 @@
export { LoginPage } from "./ui/login-page";
@@ -0,0 +1,153 @@
import { useState } from "react";
import { ACCESS_TOKEN, AUTH_USER_ID, REFRESH_TOKEN, CLAIM_NAME } from "@/src/shared/config";
import { tokenManager } from "@/src/entities/auth/lib/token-manager";
import {
Button,
Dialog,
DialogContent,
} from "@common/components";
interface OtpModalProps {
onClose: () => void;
onSuccess: () => void;
open: boolean;
onOpenChange: (open: boolean) => void;
}
export default function OtpModal({ onClose, onSuccess, open, onOpenChange }: OtpModalProps) {
const [otp, setOtp] = useState("");
const [error, setError] = useState("");
const [isLoading, setIsLoading] = useState(false);
const handleSubmit = async () => {
try {
setIsLoading(true);
setError("");
const tempToken = localStorage.getItem("tempToken");
console.log("tempToken from localStorage:", tempToken);
if (!tempToken) {
setError("임시 토큰이 없습니다. 로그인을 다시 시도해주세요.");
return;
}
// Proxy를 통해 OTP 인증 처리
const response = await fetch(`/api/proxy/user-service/api/v1/auth/2fa/verify`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
otpCode: otp,
tempToken
}),
});
if (!response.ok) {
const errorData = await response.json();
let errorMsg = errorData.message || 'OTP 인증 실패';
if (Array.isArray(errorData.errors) && errorData.errors.length > 0) {
errorMsg = errorData.errors.map((e: { message: string }) => e.message).join(', ');
}
throw new Error(errorMsg);
}
const data = await response.json();
console.log("OTP verification response:", data);
console.log("Response headers:", Object.fromEntries(response.headers.entries()));
console.log("Response data keys:", Object.keys(data));
console.log("Full response data:", JSON.stringify(data, null, 2));
// 2FA 응답에서는 access-token만 있고 token-id는 없을 수 있음
const accessToken = data[ACCESS_TOKEN] || data.accessToken || data.access_token || data.token || data['ss-token'];
console.log("Extracted accessToken:", !!accessToken);
console.log("Token value - accessToken:", accessToken?.substring(0, 20) + "...");
// access-token이 있는 경우 저장
if (accessToken) {
console.log("Saving access token from OTP verification");
tokenManager.setToken(CLAIM_NAME, accessToken); // Authorization으로 저장
// 리프레시 토큰도 확인
const refreshToken = data[REFRESH_TOKEN] || data.refreshToken || data.refresh_token;
if (refreshToken) {
console.log("Saving refresh token");
tokenManager.setToken(REFRESH_TOKEN, refreshToken);
}
// userId는 원래 로그인 응답에서 가져온 값 사용
const originalUserId = data.userId;
if (originalUserId) {
console.log("Saving userId from original login response");
tokenManager.setToken(AUTH_USER_ID, originalUserId);
}
console.log("All tokens saved successfully");
} else {
console.log("No access token in OTP response");
console.log("Available data keys:", Object.keys(data));
console.log("Data values:", Object.entries(data).map(([k, v]) => `${k}: ${typeof v === 'string' ? v.substring(0, 20) + '...' : v}`));
}
onSuccess();
} catch (error: unknown) {
console.error("OTP verification error:", error);
if (error instanceof Error) {
setError(error.message);
} else {
setError("OTP 인증 실패");
}
} finally {
setIsLoading(false);
}
};
const handleClose = () => {
onOpenChange(false);
onClose();
};
return (
<Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent className="max-w-md">
<div className="pt-8 text-center">
<h2 className="text-[1.3rem] font-semibold mb-6">OTP 인증</h2>
<div className="space-y-4">
<input
className="w-full px-3 py-2 border border-gray-300 rounded-md focus:outline-none focus:ring-2 focus:ring-blue-500 focus:border-transparent text-lg"
placeholder="OTP 코드를 입력하세요"
value={otp}
onChange={(e) => setOtp(e.target.value)}
disabled={isLoading}
/>
{error && (
<div className="text-red-500 text-sm text-center">
{error}
</div>
)}
<div className="flex space-x-2 pt-4">
<Button
onClick={handleSubmit}
disabled={isLoading || !otp.trim()}
className="flex-1"
>
{isLoading ? "인증 중..." : "인증"}
</Button>
<Button
variant="outline"
onClick={handleClose}
disabled={isLoading}
className="flex-1"
>
닫기
</Button>
</div>
</div>
</div>
</DialogContent>
</Dialog>
);
}
@@ -0,0 +1,701 @@
"use client";
import { useState, useEffect } from "react";
import type { UserSaveData } from "@/src/entities/user";
import { ENDPOINT, BASE_URL } from "@/src/shared/config";
import { SignupClient } from "@/src/shared/api/signup-client";
import "./signup-modal.css";
interface SignupModalProps {
open: boolean;
onOpenChange: (open: boolean) => void;
onClose: () => void;
onSuccess: () => void;
}
function SignupModal({ open, onClose, onSuccess }: SignupModalProps) {
const [currentStep, setCurrentStep] = useState<'agreement' | 'signup'>('agreement');
const [agreed, setAgreed] = useState(false);
const [formData, setFormData] = useState({
userId: "",
userName: "",
email: "",
password: "",
confirmPassword: "",
pswdHnt: "",
pswdAns: "",
mphn: "",
telno: "",
zip: "",
addr: "",
daddr: "",
laeSeCd: "",
ogdpSeCd: "",
expryYmd: "",
instSeCd: "",
dtlInstCd: "",
enoCd: "",
});
const [errors, setErrors] = useState<Record<string, string>>({});
const [isLoading, setIsLoading] = useState(false);
const [isCheckingId, setIsCheckingId] = useState(false);
const [isCheckingEmail, setIsCheckingEmail] = useState(false);
const [idAvailable, setIdAvailable] = useState<boolean | null>(null);
const [emailAvailable, setEmailAvailable] = useState<boolean | null>(null);
// 모달이 닫힐 때 초기화
useEffect(() => {
if (!open) {
setCurrentStep('agreement');
setAgreed(false);
setFormData({
userId: "",
userName: "",
email: "",
password: "",
confirmPassword: "",
pswdHnt: "",
pswdAns: "",
mphn: "",
telno: "",
zip: "",
addr: "",
daddr: "",
laeSeCd: "",
ogdpSeCd: "",
expryYmd: "",
instSeCd: "",
dtlInstCd: "",
enoCd: "",
});
setErrors({});
setIdAvailable(null);
setEmailAvailable(null);
}
}, [open]);
const handleAgreementChange = (e: React.ChangeEvent<HTMLInputElement>) => {
setAgreed(e.target.checked);
};
const handleNextStep = () => {
if (agreed) {
setCurrentStep('signup');
}
};
const handleInputChange = (e: React.ChangeEvent<HTMLInputElement | HTMLSelectElement>) => {
const { name, value } = e.target;
setFormData(prev => ({ ...prev, [name]: value }));
// 에러 초기화
if (errors[name]) {
setErrors(prev => ({ ...prev, [name]: "" }));
}
};
const validateForm = () => {
const newErrors: Record<string, string> = {};
// 필수 필드 검증
if (!formData.userId) newErrors.userId = "아이디를 입력해주세요.";
if (!formData.userName) newErrors.userName = "이름을 입력해주세요.";
if (!formData.password) newErrors.password = "비밀번호를 입력해주세요.";
if (!formData.confirmPassword) newErrors.confirmPassword = "비밀번호 확인을 입력해주세요.";
if (!formData.pswdHnt) newErrors.pswdHnt = "비밀번호 힌트를 입력해주세요.";
if (!formData.laeSeCd) newErrors.laeSeCd = "법인구분을 선택해주세요.";
if (!formData.ogdpSeCd) newErrors.ogdpSeCd = "기관구분을 선택해주세요.";
// 비밀번호 확인
if (formData.password && formData.confirmPassword && formData.password !== formData.confirmPassword) {
newErrors.confirmPassword = "비밀번호가 일치하지 않습니다.";
}
// 비밀번호 정규식 검증
const passwordRegex = /^(?=.*[a-zA-Z])(?=.*\d)(?=.*[!@#$%^&*()_+\-=[\]{};':"\\|,.<>/?]).{8,20}$/;
if (formData.password && !passwordRegex.test(formData.password)) {
newErrors.password = "비밀번호는 8~20자, 영문/숫자/특수기호를 모두 포함해야 합니다.";
}
// 이메일 형식 검증
if (formData.email && !/^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(formData.email)) {
newErrors.email = "올바른 이메일 형식을 입력해주세요.";
}
// 중복 체크 검증
if (idAvailable === false) newErrors.userId = "이미 사용 중인 아이디입니다.";
if (emailAvailable === false) newErrors.email = "이미 사용 중인 이메일입니다.";
setErrors(newErrors);
const isValid = Object.keys(newErrors).length === 0;
console.log("폼 검증 결과:", { isValid, errors: newErrors, formData });
return isValid;
};
const checkUserIdDuplicate = async () => {
if (!formData.userId) {
setErrors(prev => ({ ...prev, userId: "아이디를 입력해주세요." }));
return;
}
setIsCheckingId(true);
try {
// SignupClient를 통해 중복 체크 API 호출
const signupClient = SignupClient.getInstance({
baseURL: BASE_URL,
});
const response = await signupClient.post<boolean>(
`${ENDPOINT.USER_SERVICE.USERS}/existsUserId`,
{ userId: formData.userId }
);
const exists = response.data;
setIdAvailable(!exists);
if (exists) {
setErrors(prev => ({ ...prev, userId: "이미 사용 중인 아이디입니다." }));
} else {
setErrors(prev => ({ ...prev, userId: "" }));
}
} catch (error) {
console.error("아이디 중복 체크 오류:", error);
setErrors(prev => ({ ...prev, userId: "아이디 중복 체크 중 오류가 발생했습니다." }));
} finally {
setIsCheckingId(false);
}
};
const checkEmailDuplicate = async () => {
if (!formData.email) {
setErrors(prev => ({ ...prev, email: "이메일을 입력해주세요." }));
return;
}
setIsCheckingEmail(true);
try {
// SignupClient를 통해 중복 체크 API 호출
const signupClient = SignupClient.getInstance({
baseURL: BASE_URL,
});
const response = await signupClient.post<boolean>(
`${ENDPOINT.USER_SERVICE.USERS}/exists`,
{ email: formData.email }
);
const exists = response.data;
setEmailAvailable(!exists);
if (exists) {
setErrors(prev => ({ ...prev, email: "이미 사용 중인 이메일입니다." }));
} else {
setErrors(prev => ({ ...prev, email: "" }));
}
} catch (error) {
console.error("이메일 중복 체크 오류:", error);
setErrors(prev => ({ ...prev, email: "이메일 중복 체크 중 오류가 발생했습니다." }));
} finally {
setIsCheckingEmail(false);
}
};
const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault();
console.log("회원가입 버튼 클릭됨");
if (!validateForm()) {
console.log("폼 검증 실패");
return;
}
setIsLoading(true);
try {
const signupData: UserSaveData = {
userId: formData.userId,
userName: formData.userName,
email: formData.email || undefined,
password: formData.password,
pswdHnt: formData.pswdHnt,
pswdAns: formData.pswdAns || undefined,
mphn: formData.mphn || undefined,
telno: formData.telno || undefined,
zip: formData.zip || undefined,
addr: formData.addr || undefined,
daddr: formData.daddr || undefined,
laeSeCd: formData.laeSeCd,
ogdpSeCd: formData.ogdpSeCd,
expryYmd: "",
instSeCd: formData.instSeCd || undefined,
dtlInstCd: formData.dtlInstCd || undefined,
enoCd: formData.enoCd || undefined,
};
// SignupClient를 통해 익명사용자 회원가입 API 호출
const signupClient = SignupClient.getInstance({
baseURL: BASE_URL
});
const response = await signupClient.post(
`${ENDPOINT.USER_SERVICE.USERS}/anonymouse/users/join`,
signupData
);
console.log("회원가입 성공:", response.data);
// 성공 시 모달 닫기 및 콜백 호출
onSuccess();
onClose();
// 폼 초기화
setFormData({
userId: "",
userName: "",
email: "",
password: "",
confirmPassword: "",
pswdHnt: "",
pswdAns: "",
mphn: "",
telno: "",
zip: "",
addr: "",
daddr: "",
laeSeCd: "",
ogdpSeCd: "",
expryYmd: "",
instSeCd: "",
dtlInstCd: "",
enoCd: "",
});
setErrors({});
setIdAvailable(null);
setEmailAvailable(null);
} catch (error: unknown) {
console.error("회원가입 오류:", error);
if (error instanceof Error) {
setErrors(prev => ({ ...prev, submit: error.message }));
} else {
setErrors(prev => ({ ...prev, submit: "회원가입에 실패했습니다." }));
}
} finally {
setIsLoading(false);
}
};
if (!open) return null;
return (
<div className="modal-overlay" onClick={onClose}>
<div className="modal-content" onClick={(e) => e.stopPropagation()}>
<div className="modal-header">
<h2>{currentStep === 'agreement' ? '개인정보제공동의' : '회원가입'}</h2>
<button className="close-button" onClick={onClose}>×</button>
</div>
{currentStep === 'agreement' ? (
<div className="agreement-content">
<div className="agreement-text">
<h3>개인정보 수집 및 이용 동의</h3>
<p>회원가입을 위해 다음과 같이 개인정보를 수집 및 이용합니다.</p>
<h4>1. 수집하는 개인정보 항목</h4>
<ul>
<li>필수항목: 아이디, 이름, 비밀번호, 비밀번호 힌트, 법인구분, 기관구분</li>
<li>선택항목: 이메일, 휴대폰번호, 전화번호, 주소, 기관코드, 상세기관코드, 사업자등록번호</li>
</ul>
<h4>2. 개인정보의 수집 및 이용목적</h4>
<ul>
<li>회원가입 및 관리</li>
<li>서비스 제공 및 운영</li>
<li>고객상담 및 문의응답</li>
</ul>
<h4>3. 개인정보의 보유 및 이용기간</h4>
<p>회원탈퇴 시까지 (단, 관련 법령에 따라 보존이 필요한 경우 해당 기간까지)</p>
<h4>4. 동의 거부권 및 거부 시 불이익</h4>
<p>개인정보 수집 및 이용에 대한 동의를 거부할 수 있으며, 동의 거부 시 회원가입이 제한됩니다.</p>
</div>
<div className="agreement-checkbox">
<label>
<input
type="checkbox"
checked={agreed}
onChange={handleAgreementChange}
/>
<span>개인정보 수집 및 이용에 동의합니다.</span>
</label>
</div>
<div className="agreement-actions">
<button
type="button"
onClick={onClose}
className="cancel-button"
>
취소
</button>
<button
type="button"
onClick={handleNextStep}
className="next-button"
disabled={!agreed}
>
다음
</button>
</div>
</div>
) : (
<form onSubmit={handleSubmit} className="signup-form">
<div className="form-section">
<h3>기본 정보</h3>
<div className="form-row">
<div className="form-group">
<label htmlFor="userId">아이디 *</label>
<div className="input-with-button">
<input
type="text"
id="userId"
name="userId"
value={formData.userId}
onChange={handleInputChange}
placeholder="아이디를 입력하세요"
maxLength={12}
disabled={isLoading}
/>
<button
type="button"
onClick={checkUserIdDuplicate}
disabled={isCheckingId || isLoading || !formData.userId}
className="check-button"
>
{isCheckingId ? "확인중..." : "중복확인"}
</button>
</div>
{idAvailable === true && <span className="success-message">사용 가능한 아이디입니다.</span>}
{errors.userId && <span className="error-message">{errors.userId}</span>}
</div>
</div>
<div className="form-row">
<div className="form-group">
<label htmlFor="userName">이름 *</label>
<input
type="text"
id="userName"
name="userName"
value={formData.userName}
onChange={handleInputChange}
placeholder="이름을 입력하세요"
disabled={isLoading}
/>
{errors.userName && <span className="error-message">{errors.userName}</span>}
</div>
</div>
<div className="form-row">
<div className="form-group">
<label htmlFor="email">이메일</label>
<div className="input-with-button">
<input
type="email"
id="email"
name="email"
value={formData.email}
onChange={handleInputChange}
placeholder="이메일을 입력하세요"
disabled={isLoading}
/>
<button
type="button"
onClick={checkEmailDuplicate}
disabled={isCheckingEmail || isLoading || !formData.email}
className="check-button"
>
{isCheckingEmail ? "확인중..." : "중복확인"}
</button>
</div>
{emailAvailable === true && <span className="success-message">사용 가능한 이메일입니다.</span>}
{errors.email && <span className="error-message">{errors.email}</span>}
</div>
</div>
<div className="form-row">
<div className="form-group">
<label htmlFor="password">비밀번호 *</label>
<input
type="password"
id="password"
name="password"
value={formData.password}
onChange={handleInputChange}
placeholder="8~20자, 영문/숫자/특수기호 포함"
maxLength={20}
disabled={isLoading}
/>
{errors.password && <span className="error-message">{errors.password}</span>}
</div>
</div>
<div className="form-row">
<div className="form-group">
<label htmlFor="confirmPassword">비밀번호 확인 *</label>
<input
type="password"
id="confirmPassword"
name="confirmPassword"
value={formData.confirmPassword}
onChange={handleInputChange}
placeholder="비밀번호를 다시 입력하세요"
maxLength={20}
disabled={isLoading}
/>
{errors.confirmPassword && <span className="error-message">{errors.confirmPassword}</span>}
</div>
</div>
<div className="form-row">
<div className="form-group">
<label htmlFor="pswdHnt">비밀번호 힌트 *</label>
<input
type="text"
id="pswdHnt"
name="pswdHnt"
value={formData.pswdHnt}
onChange={handleInputChange}
placeholder="비밀번호 힌트를 입력하세요"
maxLength={10}
disabled={isLoading}
/>
{errors.pswdHnt && <span className="error-message">{errors.pswdHnt}</span>}
</div>
</div>
<div className="form-row">
<div className="form-group">
<label htmlFor="pswdAns">비밀번호 답변</label>
<input
type="text"
id="pswdAns"
name="pswdAns"
value={formData.pswdAns}
onChange={handleInputChange}
placeholder="비밀번호 답변을 입력하세요"
maxLength={100}
disabled={isLoading}
/>
{errors.pswdAns && <span className="error-message">{errors.pswdAns}</span>}
</div>
</div>
</div>
<div className="form-section">
<h3>연락처 정보</h3>
<div className="form-row">
<div className="form-group">
<label htmlFor="mphn">휴대폰 번호</label>
<input
type="tel"
id="mphn"
name="mphn"
value={formData.mphn}
onChange={handleInputChange}
placeholder="휴대폰 번호를 입력하세요"
maxLength={11}
disabled={isLoading}
/>
{errors.mphn && <span className="error-message">{errors.mphn}</span>}
</div>
</div>
<div className="form-row">
<div className="form-group">
<label htmlFor="telno">전화번호</label>
<input
type="tel"
id="telno"
name="telno"
value={formData.telno}
onChange={handleInputChange}
placeholder="전화번호를 입력하세요"
maxLength={11}
disabled={isLoading}
/>
{errors.telno && <span className="error-message">{errors.telno}</span>}
</div>
</div>
</div>
<div className="form-section">
<h3>주소 정보</h3>
<div className="form-row">
<div className="form-group">
<label htmlFor="zip">우편번호</label>
<input
type="text"
id="zip"
name="zip"
value={formData.zip}
onChange={handleInputChange}
placeholder="우편번호를 입력하세요"
maxLength={5}
disabled={isLoading}
/>
{errors.zip && <span className="error-message">{errors.zip}</span>}
</div>
</div>
<div className="form-row">
<div className="form-group">
<label htmlFor="addr">주소</label>
<input
type="text"
id="addr"
name="addr"
value={formData.addr}
onChange={handleInputChange}
placeholder="주소를 입력하세요"
maxLength={200}
disabled={isLoading}
/>
{errors.addr && <span className="error-message">{errors.addr}</span>}
</div>
</div>
<div className="form-row">
<div className="form-group">
<label htmlFor="daddr">상세주소</label>
<input
type="text"
id="daddr"
name="daddr"
value={formData.daddr}
onChange={handleInputChange}
placeholder="상세주소를 입력하세요"
maxLength={200}
disabled={isLoading}
/>
{errors.daddr && <span className="error-message">{errors.daddr}</span>}
</div>
</div>
</div>
<div className="form-section">
<h3>기관 정보</h3>
<div className="form-row">
<div className="form-group">
<label htmlFor="laeSeCd">법인구분 *</label>
<select
id="laeSeCd"
name="laeSeCd"
value={formData.laeSeCd}
onChange={handleInputChange}
disabled={isLoading}
>
<option value="">법인구분을 선택하세요</option>
<option value="01">공공기관</option>
<option value="02">민간기업</option>
<option value="03">개인</option>
</select>
{errors.laeSeCd && <span className="error-message">{errors.laeSeCd}</span>}
</div>
</div>
<div className="form-row">
<div className="form-group">
<label htmlFor="ogdpSeCd">기관구분 *</label>
<select
id="ogdpSeCd"
name="ogdpSeCd"
value={formData.ogdpSeCd}
onChange={handleInputChange}
disabled={isLoading}
>
<option value="">기관구분을 선택하세요</option>
<option value="01">환경부</option>
<option value="02">지방자치단체</option>
<option value="03">공공기관</option>
<option value="04">민간기업</option>
</select>
{errors.ogdpSeCd && <span className="error-message">{errors.ogdpSeCd}</span>}
</div>
</div>
<div className="form-row">
<div className="form-group">
<label htmlFor="instSeCd">기관코드</label>
<input
type="text"
id="instSeCd"
name="instSeCd"
value={formData.instSeCd}
onChange={handleInputChange}
placeholder="기관코드를 입력하세요"
maxLength={10}
disabled={isLoading}
/>
{errors.instSeCd && <span className="error-message">{errors.instSeCd}</span>}
</div>
</div>
<div className="form-row">
<div className="form-group">
<label htmlFor="dtlInstCd">상세기관코드</label>
<input
type="text"
id="dtlInstCd"
name="dtlInstCd"
value={formData.dtlInstCd}
onChange={handleInputChange}
placeholder="상세기관코드를 입력하세요"
maxLength={10}
disabled={isLoading}
/>
{errors.dtlInstCd && <span className="error-message">{errors.dtlInstCd}</span>}
</div>
</div>
</div>
{errors.submit && (
<div className="form-row">
<div className="error-message submit-error">{errors.submit}</div>
</div>
)}
<div className="form-actions">
<button
type="button"
onClick={() => setCurrentStep('agreement')}
className="back-button"
disabled={isLoading}
>
이전
</button>
<button
type="button"
onClick={onClose}
className="cancel-button"
disabled={isLoading}
>
취소
</button>
<button
type="submit"
className="submit-button"
disabled={isLoading}
>
{isLoading ? "가입 중..." : "회원가입"}
</button>
</div>
</form>
)}
</div>
</div>
);
}
export default SignupModal;
@@ -0,0 +1,198 @@
import { useEffect, useState } from "react";
import Image from "next/image";
import {
Button,
Dialog,
DialogContent,
} from "@common/components";
interface TwoFAModalProps {
onClose: () => void;
onSuccess: () => void;
open: boolean;
onOpenChange: (open: boolean) => void;
}
export default function TwoFAModal({ onClose, onSuccess, open, onOpenChange }: TwoFAModalProps) {
const [qrImage, setQrImage] = useState("");
const [secret, setSecret] = useState("");
const [otp, setOtp] = useState("");
const [error, setError] = useState("");
const [isLoading, setIsLoading] = useState(false);
const [isSetupLoading, setIsSetupLoading] = useState(true);
useEffect(() => {
const fetchQR = async () => {
try {
setIsSetupLoading(true);
setError("");
const tempToken = localStorage.getItem("tempToken");
if (!tempToken) {
setError("임시 토큰이 없습니다");
return;
}
// Proxy를 통해 2FA setup 처리
const response = await fetch(`/api/proxy/user-service/api/v1/auth/2fa/setup`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ tempToken }),
});
if (!response.ok) {
const errorData = await response.json();
let errorMsg = errorData.message || 'QR코드 발급 실패';
if (Array.isArray(errorData.errors) && errorData.errors.length > 0) {
errorMsg = errorData.errors.map((e: { message: string }) => e.message).join(', ');
}
throw new Error(errorMsg);
}
const data = await response.json();
console.log("2FA setup response:", data);
console.log("Response headers:", Object.fromEntries(response.headers.entries()));
// Proxy 응답에서 QR 데이터 추출
setQrImage(data.qrImage);
setSecret(data.secret);
} catch (error: unknown) {
console.error("2FA setup error:", error);
if (error instanceof Error) {
setError(error.message);
} else {
setError("QR코드 발급 실패");
}
} finally {
setIsSetupLoading(false);
}
};
if (open) {
fetchQR();
}
}, [open]);
const handleEnable2FA = async () => {
try {
setIsLoading(true);
setError("");
const tempToken = localStorage.getItem("tempToken");
if (!tempToken) {
setError("임시 토큰이 없습니다");
return;
}
// Proxy를 통해 2FA enable 처리
const response = await fetch(`/api/proxy/user-service/api/v1/auth/2fa/enable`, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'Authorization': tempToken,
},
body: JSON.stringify({
otpCode: otp,
tempToken: tempToken,
secret: secret,
}),
});
if (!response.ok) {
const errorData = await response.json();
let errorMsg = errorData.message || '2FA 등록 실패';
if (Array.isArray(errorData.errors) && errorData.errors.length > 0) {
errorMsg = errorData.errors.map((e: { message: string }) => e.message).join(', ');
}
throw new Error(errorMsg);
}
const data = await response.json();
console.log("2FA enable response:", data);
console.log("Response headers:", Object.fromEntries(response.headers.entries()));
console.log("Response data keys:", Object.keys(data));
// 2FA 설정 완료 후에는 토큰을 저장하지 않음 (실제 로그인은 OTP에서 처리)
console.log("2FA setup completed successfully");
onSuccess();
} catch (error: unknown) {
console.error("2FA enable error:", error);
if (error instanceof Error) {
setError(error.message);
} else {
setError("2FA 등록 실패");
}
} finally {
setIsLoading(false);
}
};
const handleClose = () => {
onOpenChange(false);
onClose();
};
return (
<Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent className="max-w-md">
<div className="pt-8 text-center">
<h2 className="text-[1.3rem] font-semibold mb-6">2FA 등록</h2>
{isSetupLoading ? (
<div className="py-8">
<div className="text-gray-500">QR코드 로딩 중...</div>
</div>
) : (
<>
{qrImage && (
<div className="flex justify-center mb-4">
<Image
src={`data:image/png;base64,${qrImage}`}
alt="QR코드"
width={200}
height={200}
/>
</div>
)}
<div className="space-y-4">
<input
className="w-full px-3 py-2 border border-gray-300 rounded-md focus:outline-none focus:ring-2 focus:ring-blue-500 focus:border-transparent text-lg"
placeholder="OTP 코드를 입력하세요"
value={otp}
onChange={(e) => setOtp(e.target.value)}
disabled={isLoading}
/>
{error && (
<div className="text-red-500 text-sm text-center">
{error}
</div>
)}
<div className="flex space-x-2 pt-4">
<Button
onClick={handleEnable2FA}
disabled={isLoading || !otp.trim()}
className="flex-1"
>
{isLoading ? "2FA 활성화 중..." : "2FA 활성화"}
</Button>
<Button
variant="outline"
onClick={handleClose}
disabled={isLoading}
className="flex-1"
>
닫기
</Button>
</div>
</div>
</>
)}
</div>
</DialogContent>
</Dialog>
);
}
@@ -0,0 +1,901 @@
"use client";
import React, { useState, useEffect } from "react";
import { SignupClient } from "@/src/shared/api/signup-client";
import { BASE_URL, ENDPOINT } from "@/src/shared/config";
import "./user-signup-modal.css";
interface UserSignupModalProps {
open: boolean;
onClose: () => void;
onSuccess: () => void;
}
interface UserSignupData {
userId: string;
userName: string;
email?: string;
password: string;
pswdHnt: string;
pswdAns?: string;
mphn?: string;
telno?: string;
zip?: string;
addr?: string;
daddr?: string;
laeSeCd: string;
ogdpSeCd: string;
instSeCd?: string;
dtlInstCd?: string;
enoCd?: string;
}
// 익명 사용자용 CodeSelect 컴포넌트
interface AnonymousCodeSelectProps {
upCd: string;
value: string;
onValueChange: (value: string) => void;
disabled?: boolean;
placeholder?: string;
}
function AnonymousCodeSelect({
upCd,
value,
onValueChange,
disabled,
placeholder = "선택",
}: AnonymousCodeSelectProps) {
const [codes, setCodes] = useState<
Array<{ lwpoCd: string; lwpoCdNm: string }>
>([]);
const [isLoading, setIsLoading] = useState(false);
useEffect(() => {
const fetchCodes = async () => {
if (!upCd) return;
setIsLoading(true);
try {
const signupClient = SignupClient.getInstance({
baseURL: BASE_URL,
});
const response = await signupClient.get(
`${ENDPOINT.CMS_SERVICE.CODE}/${upCd}`,
);
if (response.data && Array.isArray(response.data)) {
setCodes(response.data);
} else {
setCodes([]);
}
} catch (error) {
console.error(`공통 코드 로드 실패 (${upCd}):`, error);
setCodes([]);
} finally {
setIsLoading(false);
}
};
fetchCodes();
}, [upCd]);
return (
<select
value={value}
onChange={(e) => onValueChange(e.target.value)}
disabled={disabled || isLoading}
className="user-signup-select"
>
<option value="">{isLoading ? "로딩 중..." : placeholder}</option>
{codes.map((code) => (
<option key={code.lwpoCd} value={code.lwpoCd}>
{code.lwpoCdNm}
</option>
))}
</select>
);
}
function UserSignupModal({ open, onClose, onSuccess }: UserSignupModalProps) {
const [currentStep, setCurrentStep] = useState<"agreement" | "signup">(
"agreement",
);
const [agreed, setAgreed] = useState(false);
const [formData, setFormData] = useState({
userId: "",
userName: "",
email: "",
password: "",
confirmPassword: "",
pswdHnt: "",
pswdAns: "",
mphn: "",
telno: "",
zip: "",
addr: "",
daddr: "",
laeSeCd: "",
ogdpSeCd: "",
instSeCd: "",
dtlInstCd: "",
enoCd: "",
});
const [errors, setErrors] = useState<Record<string, string>>({});
const [isLoading, setIsLoading] = useState(false);
const [isCheckingId, setIsCheckingId] = useState(false);
const [isCheckingEmail, setIsCheckingEmail] = useState(false);
const [idAvailable, setIdAvailable] = useState<boolean | null>(null);
const [emailAvailable, setEmailAvailable] = useState<boolean | null>(null);
// 모달이 닫힐 때 초기화
useEffect(() => {
if (!open) {
setCurrentStep("agreement");
setAgreed(false);
setFormData({
userId: "",
userName: "",
email: "",
password: "",
confirmPassword: "",
pswdHnt: "",
pswdAns: "",
mphn: "",
telno: "",
zip: "",
addr: "",
daddr: "",
laeSeCd: "",
ogdpSeCd: "",
instSeCd: "",
dtlInstCd: "",
enoCd: "",
});
setErrors({});
setIdAvailable(null);
setEmailAvailable(null);
}
}, [open]);
const handleAgreementChange = (e: React.ChangeEvent<HTMLInputElement>) => {
setAgreed(e.target.checked);
};
const handleNextStep = () => {
if (agreed) {
setCurrentStep("signup");
}
};
const handleInputChange = (
e: React.ChangeEvent<HTMLInputElement | HTMLSelectElement>,
) => {
const { name, value } = e.target;
setFormData((prev) => ({ ...prev, [name]: value }));
// 에러 초기화
if (errors[name]) {
setErrors((prev) => ({ ...prev, [name]: "" }));
}
};
const validateForm = () => {
const newErrors: Record<string, string> = {};
console.log("폼 검증 시작:", formData);
// 필수 필드 검증
if (!formData.userId) newErrors.userId = "아이디를 입력해주세요.";
if (!formData.userName) newErrors.userName = "이름을 입력해주세요.";
if (!formData.password) newErrors.password = "비밀번호를 입력해주세요.";
if (!formData.confirmPassword)
newErrors.confirmPassword = "비밀번호 확인을 입력해주세요.";
if (!formData.pswdHnt) newErrors.pswdHnt = "비밀번호 힌트를 선택해주세요.";
if (!formData.laeSeCd) newErrors.laeSeCd = "법인구분을 선택해주세요.";
if (!formData.ogdpSeCd) newErrors.ogdpSeCd = "기관구분을 선택해주세요.";
// 비밀번호 확인
if (
formData.password &&
formData.confirmPassword &&
formData.password !== formData.confirmPassword
) {
newErrors.confirmPassword = "비밀번호가 일치하지 않습니다.";
}
// 비밀번호 정규식 검증
const passwordRegex =
/^(?=.*[a-zA-Z])(?=.*\d)(?=.*[!@#$%^&*()_+\-=[\]{};':"\\|,.<>/?]).{8,20}$/;
if (formData.password && !passwordRegex.test(formData.password)) {
newErrors.password =
"비밀번호는 8~20자, 영문/숫자/특수기호를 모두 포함해야 합니다.";
}
// 이메일 형식 검증
if (formData.email && !/^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(formData.email)) {
newErrors.email = "올바른 이메일 형식을 입력해주세요.";
}
// 중복 체크 검증
if (idAvailable === false)
newErrors.userId = "이미 사용 중인 아이디입니다.";
if (emailAvailable === false)
newErrors.email = "이미 사용 중인 이메일입니다.";
setErrors(newErrors);
const isValid = Object.keys(newErrors).length === 0;
console.log("폼 검증 결과:", { isValid, errors: newErrors, formData });
return isValid;
};
const checkUserIdDuplicate = async () => {
if (!formData.userId) {
setErrors((prev) => ({ ...prev, userId: "아이디를 입력해주세요." }));
return;
}
setIsCheckingId(true);
try {
const signupClient = SignupClient.getInstance({
baseURL: BASE_URL,
});
const response = await signupClient.post<boolean>(
`${ENDPOINT.USER_SERVICE.USERS}/existsUserId`,
{ userId: formData.userId },
);
const exists = response.data;
setIdAvailable(!exists);
if (exists) {
setErrors((prev) => ({
...prev,
userId: "이미 사용 중인 아이디입니다.",
}));
} else {
setErrors((prev) => ({ ...prev, userId: "" }));
}
} catch (error) {
console.error("아이디 중복 체크 오류:", error);
setErrors((prev) => ({
...prev,
userId: "아이디 중복 체크 중 오류가 발생했습니다.",
}));
} finally {
setIsCheckingId(false);
}
};
const checkEmailDuplicate = async () => {
if (!formData.email) {
setErrors((prev) => ({ ...prev, email: "이메일을 입력해주세요." }));
return;
}
setIsCheckingEmail(true);
try {
const signupClient = SignupClient.getInstance({
baseURL: BASE_URL,
});
const response = await signupClient.post<boolean>(
`${ENDPOINT.USER_SERVICE.USERS}/exists`,
{ email: formData.email },
);
const exists = response.data;
setEmailAvailable(!exists);
if (exists) {
setErrors((prev) => ({
...prev,
email: "이미 사용 중인 이메일입니다.",
}));
} else {
setErrors((prev) => ({ ...prev, email: "" }));
}
} catch (error) {
console.error("이메일 중복 체크 오류:", error);
setErrors((prev) => ({
...prev,
email: "이메일 중복 체크 중 오류가 발생했습니다.",
}));
} finally {
setIsCheckingEmail(false);
}
};
const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault();
console.log("회원가입 버튼 클릭됨 - handleSubmit");
console.log("현재 폼 데이터:", formData);
if (!validateForm()) {
console.log("폼 검증 실패");
return;
}
console.log("폼 검증 성공, 회원가입 API 호출 시작");
setIsLoading(true);
try {
const signupData: UserSignupData = {
userId: formData.userId,
userName: formData.userName,
email: formData.email || undefined,
password: formData.password,
pswdHnt: formData.pswdHnt,
pswdAns: formData.pswdAns || undefined,
mphn: formData.mphn || undefined,
telno: formData.telno || undefined,
zip: formData.zip || undefined,
addr: formData.addr || undefined,
daddr: formData.daddr || undefined,
laeSeCd: formData.laeSeCd,
ogdpSeCd: formData.ogdpSeCd,
instSeCd: formData.instSeCd || undefined,
dtlInstCd: formData.dtlInstCd || undefined,
enoCd: formData.enoCd || undefined,
};
// SignupClient를 통해 익명사용자 회원가입 API 호출
console.log("SignupClient 생성 및 API 호출:", {
baseURL: BASE_URL,
endpoint: ENDPOINT.USER_SERVICE.USER_JOIN,
data: signupData,
});
const signupClient = SignupClient.getInstance({
baseURL: BASE_URL,
});
const response = await signupClient.post(
ENDPOINT.USER_SERVICE.USER_JOIN,
signupData,
);
console.log("회원가입 성공:", response.data);
// 성공 시 모달 닫기 및 콜백 호출
onSuccess();
onClose();
// 폼 초기화
setFormData({
userId: "",
userName: "",
email: "",
password: "",
confirmPassword: "",
pswdHnt: "",
pswdAns: "",
mphn: "",
telno: "",
zip: "",
addr: "",
daddr: "",
laeSeCd: "",
ogdpSeCd: "",
instSeCd: "",
dtlInstCd: "",
enoCd: "",
});
setErrors({});
setIdAvailable(null);
setEmailAvailable(null);
} catch (error: unknown) {
console.error("회원가입 오류:", error);
if (error instanceof Error) {
setErrors((prev) => ({ ...prev, submit: error.message }));
} else {
setErrors((prev) => ({ ...prev, submit: "회원가입에 실패했습니다." }));
}
} finally {
setIsLoading(false);
}
};
if (!open) return null;
return (
<div className="user-signup-modal-overlay">
<div className="user-signup-modal-content">
<div className="user-signup-modal-header">
<h2>
{currentStep === "agreement" ? "개인정보제공동의" : "회원가입"}
</h2>
<button className="user-signup-close-button" onClick={onClose}>
×
</button>
</div>
{currentStep === "agreement" ? (
<div className="user-signup-agreement-content">
<div className="user-signup-agreement-text">
<h3>개인정보 수집 및 이용 동의</h3>
<p>회원가입을 위해 다음과 같이 개인정보를 수집 및 이용합니다.</p>
<h4>1. 수집하는 개인정보 항목</h4>
<ul>
<li>
필수항목: 아이디, 이름, 비밀번호, 비밀번호 힌트, 법인구분,
기관구분
</li>
<li>
선택항목: 이메일, 휴대폰번호, 전화번호, 주소, 기관코드,
상세기관코드, 사업자등록번호
</li>
</ul>
<h4>2. 개인정보의 수집 및 이용목적</h4>
<ul>
<li>회원가입 및 관리</li>
<li>서비스 제공 및 운영</li>
<li>고객상담 및 문의응답</li>
</ul>
<h4>3. 개인정보의 보유 및 이용기간</h4>
<p>
회원탈퇴 시까지 (단, 관련 법령에 따라 보존이 필요한 경우 해당
기간까지)
</p>
<h4>4. 동의 거부권 및 거부 시 불이익</h4>
<p>
개인정보 수집 및 이용에 대한 동의를 거부할 수 있으며, 동의 거부
시 회원가입이 제한됩니다.
</p>
</div>
<div className="user-signup-agreement-checkbox">
<label>
<input
type="checkbox"
checked={agreed}
onChange={handleAgreementChange}
/>
<span>개인정보 수집 및 이용에 동의합니다.</span>
</label>
</div>
<div className="user-signup-agreement-actions">
<button
type="button"
onClick={onClose}
className="user-signup-cancel-button"
>
취소
</button>
<button
type="button"
onClick={handleNextStep}
className="user-signup-next-button"
disabled={!agreed}
>
다음
</button>
</div>
</div>
) : (
<form onSubmit={handleSubmit} className="user-signup-form">
<div className="user-signup-form-section">
<h3>기본 정보</h3>
<div className="user-signup-form-row">
<div className="user-signup-form-group">
<label htmlFor="userId">아이디 *</label>
<div className="user-signup-input-with-button">
<input
type="text"
id="userId"
name="userId"
value={formData.userId}
onChange={handleInputChange}
placeholder="아이디를 입력하세요"
maxLength={12}
disabled={isLoading}
/>
<button
type="button"
onClick={checkUserIdDuplicate}
disabled={isCheckingId || isLoading || !formData.userId}
className="user-signup-check-button"
>
{isCheckingId ? "확인중..." : "중복확인"}
</button>
</div>
{idAvailable === true && (
<span className="user-signup-success-message">
사용 가능한 아이디입니다.
</span>
)}
{errors.userId && (
<span className="user-signup-error-message">
{errors.userId}
</span>
)}
</div>
</div>
<div className="user-signup-form-row">
<div className="user-signup-form-group">
<label htmlFor="userName">이름 *</label>
<input
type="text"
id="userName"
name="userName"
value={formData.userName}
onChange={handleInputChange}
placeholder="이름을 입력하세요"
disabled={isLoading}
/>
{errors.userName && (
<span className="user-signup-error-message">
{errors.userName}
</span>
)}
</div>
</div>
<div className="user-signup-form-row">
<div className="user-signup-form-group">
<label htmlFor="email">이메일</label>
<div className="user-signup-input-with-button">
<input
type="email"
id="email"
name="email"
value={formData.email}
onChange={handleInputChange}
placeholder="이메일을 입력하세요"
disabled={isLoading}
/>
<button
type="button"
onClick={checkEmailDuplicate}
disabled={isCheckingEmail || isLoading || !formData.email}
className="user-signup-check-button"
>
{isCheckingEmail ? "확인중..." : "중복확인"}
</button>
</div>
{emailAvailable === true && (
<span className="user-signup-success-message">
사용 가능한 이메일입니다.
</span>
)}
{errors.email && (
<span className="user-signup-error-message">
{errors.email}
</span>
)}
</div>
</div>
<div className="user-signup-form-row">
<div className="user-signup-form-group">
<label htmlFor="password">비밀번호 *</label>
<input
type="password"
id="password"
name="password"
value={formData.password}
onChange={handleInputChange}
placeholder="8~20자, 영문/숫자/특수기호 포함"
maxLength={20}
disabled={isLoading}
/>
{errors.password && (
<span className="user-signup-error-message">
{errors.password}
</span>
)}
</div>
</div>
<div className="user-signup-form-row">
<div className="user-signup-form-group">
<label htmlFor="confirmPassword">비밀번호 확인 *</label>
<input
type="password"
id="confirmPassword"
name="confirmPassword"
value={formData.confirmPassword}
onChange={handleInputChange}
placeholder="비밀번호를 다시 입력하세요"
maxLength={20}
disabled={isLoading}
/>
{errors.confirmPassword && (
<span className="user-signup-error-message">
{errors.confirmPassword}
</span>
)}
</div>
</div>
<div className="user-signup-form-row">
<div className="user-signup-form-group">
<label htmlFor="pswdHnt">비밀번호 힌트 *</label>
<AnonymousCodeSelect
upCd="PSWDHNT"
value={formData.pswdHnt}
onValueChange={(value: string) =>
setFormData((prev) => ({ ...prev, pswdHnt: value }))
}
disabled={isLoading}
placeholder="비밀번호 힌트를 선택하세요"
/>
{errors.pswdHnt && (
<span className="user-signup-error-message">
{errors.pswdHnt}
</span>
)}
</div>
</div>
<div className="user-signup-form-row">
<div className="user-signup-form-group">
<label htmlFor="pswdAns">비밀번호 답변</label>
<input
type="text"
id="pswdAns"
name="pswdAns"
value={formData.pswdAns}
onChange={handleInputChange}
placeholder="비밀번호 답변을 입력하세요"
maxLength={100}
disabled={isLoading}
/>
{errors.pswdAns && (
<span className="user-signup-error-message">
{errors.pswdAns}
</span>
)}
</div>
</div>
</div>
<div className="user-signup-form-section">
<h3>연락처 정보</h3>
<div className="user-signup-form-row">
<div className="user-signup-form-group">
<label htmlFor="mphn">휴대폰 번호</label>
<input
type="tel"
id="mphn"
name="mphn"
value={formData.mphn}
onChange={handleInputChange}
placeholder="휴대폰 번호를 입력하세요"
maxLength={11}
disabled={isLoading}
/>
{errors.mphn && (
<span className="user-signup-error-message">
{errors.mphn}
</span>
)}
</div>
</div>
<div className="user-signup-form-row">
<div className="user-signup-form-group">
<label htmlFor="telno">전화번호</label>
<input
type="tel"
id="telno"
name="telno"
value={formData.telno}
onChange={handleInputChange}
placeholder="전화번호를 입력하세요"
maxLength={11}
disabled={isLoading}
/>
{errors.telno && (
<span className="user-signup-error-message">
{errors.telno}
</span>
)}
</div>
</div>
</div>
<div className="user-signup-form-section">
<h3>주소 정보</h3>
<div className="user-signup-form-row">
<div className="user-signup-form-group">
<label htmlFor="zip">우편번호</label>
<input
type="text"
id="zip"
name="zip"
value={formData.zip}
onChange={handleInputChange}
placeholder="우편번호를 입력하세요"
maxLength={5}
disabled={isLoading}
/>
{errors.zip && (
<span className="user-signup-error-message">
{errors.zip}
</span>
)}
</div>
</div>
<div className="user-signup-form-row">
<div className="user-signup-form-group">
<label htmlFor="addr">주소</label>
<input
type="text"
id="addr"
name="addr"
value={formData.addr}
onChange={handleInputChange}
placeholder="주소를 입력하세요"
maxLength={200}
disabled={isLoading}
/>
{errors.addr && (
<span className="user-signup-error-message">
{errors.addr}
</span>
)}
</div>
</div>
<div className="user-signup-form-row">
<div className="user-signup-form-group">
<label htmlFor="daddr">상세주소</label>
<input
type="text"
id="daddr"
name="daddr"
value={formData.daddr}
onChange={handleInputChange}
placeholder="상세주소를 입력하세요"
maxLength={200}
disabled={isLoading}
/>
{errors.daddr && (
<span className="user-signup-error-message">
{errors.daddr}
</span>
)}
</div>
</div>
</div>
<div className="user-signup-form-section">
<h3>기관 정보</h3>
<div className="user-signup-form-row">
<div className="user-signup-form-group">
<label htmlFor="laeSeCd">법인구분 *</label>
<AnonymousCodeSelect
upCd="LAESECD"
value={formData.laeSeCd}
onValueChange={(value: string) =>
setFormData((prev) => ({ ...prev, laeSeCd: value }))
}
disabled={isLoading}
placeholder="법인구분을 선택하세요"
/>
{errors.laeSeCd && (
<span className="user-signup-error-message">
{errors.laeSeCd}
</span>
)}
</div>
</div>
<div className="user-signup-form-row">
<div className="user-signup-form-group">
<label htmlFor="ogdpSeCd">기관구분 *</label>
<AnonymousCodeSelect
upCd="OGDPSECD"
value={formData.ogdpSeCd}
onValueChange={(value: string) =>
setFormData((prev) => ({ ...prev, ogdpSeCd: value }))
}
disabled={isLoading}
placeholder="기관구분을 선택하세요"
/>
{errors.ogdpSeCd && (
<span className="user-signup-error-message">
{errors.ogdpSeCd}
</span>
)}
</div>
</div>
<div className="user-signup-form-row">
<div className="user-signup-form-group">
<label htmlFor="instSeCd">기관코드</label>
<input
type="text"
id="instSeCd"
name="instSeCd"
value={formData.instSeCd}
onChange={handleInputChange}
placeholder="기관코드를 입력하세요"
maxLength={10}
disabled={isLoading}
/>
{errors.instSeCd && (
<span className="user-signup-error-message">
{errors.instSeCd}
</span>
)}
</div>
</div>
<div className="user-signup-form-row">
<div className="user-signup-form-group">
<label htmlFor="dtlInstCd">상세기관코드</label>
<input
type="text"
id="dtlInstCd"
name="dtlInstCd"
value={formData.dtlInstCd}
onChange={handleInputChange}
placeholder="상세기관코드를 입력하세요"
maxLength={10}
disabled={isLoading}
/>
{errors.dtlInstCd && (
<span className="user-signup-error-message">
{errors.dtlInstCd}
</span>
)}
</div>
</div>
</div>
{errors.submit && (
<div className="user-signup-form-row">
<div className="user-signup-error-message user-signup-submit-error">
{errors.submit}
</div>
</div>
)}
<div className="user-signup-form-actions">
<button
type="button"
onClick={() => setCurrentStep("agreement")}
className="user-signup-back-button"
disabled={isLoading}
>
이전
</button>
<button
type="button"
onClick={onClose}
className="user-signup-cancel-button"
disabled={isLoading}
>
취소
</button>
<button
type="submit"
className="user-signup-submit-button"
disabled={isLoading}
onClick={(e) => {
e.preventDefault();
console.log("회원가입 버튼 클릭됨 - onClick");
handleSubmit(e);
}}
>
{isLoading ? "가입 중..." : "회원가입"}
</button>
</div>
</form>
)}
</div>
</div>
);
}
export default UserSignupModal;
@@ -0,0 +1,132 @@
"use client";
import Image from "next/image";
import { images } from "@common/assets";
import { LoginApi } from "@/src/entities/auth";
import "./login.css";
function LoginPage() {
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/mainMap";
};
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 */
@@ -0,0 +1,277 @@
/* 모달 오버레이 */
.modal-overlay {
position: fixed;
top: 0;
left: 0;
right: 0;
bottom: 0;
background-color: rgba(0, 0, 0, 0.5);
display: flex;
justify-content: center;
align-items: center;
z-index: 1000;
padding: 20px;
}
/* 모달 컨텐츠 */
.modal-content {
background: white;
border-radius: 8px;
box-shadow: 0 4px 20px rgba(0, 0, 0, 0.15);
max-width: 800px;
width: 100%;
max-height: 90vh;
overflow-y: auto;
position: relative;
}
/* 모달 헤더 */
.modal-header {
display: flex;
justify-content: space-between;
align-items: center;
padding: 20px 24px;
border-bottom: 1px solid #e5e7eb;
background-color: #f9fafb;
border-radius: 8px 8px 0 0;
}
.modal-header h2 {
margin: 0;
font-size: 1.8rem;
font-weight: 600;
color: #1f2937;
}
.close-button {
background: none;
border: none;
font-size: 1.5rem;
cursor: pointer;
color: #6b7280;
padding: 4px;
border-radius: 4px;
transition: color 0.2s;
}
.close-button:hover {
color: #374151;
}
/* 폼 스타일 */
.signup-form {
padding: 24px;
}
.form-section {
margin-bottom: 32px;
}
.form-section h3 {
margin: 0 0 16px 0;
font-size: 1.4rem;
font-weight: 600;
color: #374151;
padding-bottom: 8px;
border-bottom: 2px solid #e5e7eb;
}
.form-row {
margin-bottom: 16px;
}
.form-group {
display: flex;
flex-direction: column;
}
.form-group label {
margin-bottom: 6px;
font-weight: 500;
color: #374151;
font-size: 1.1rem;
}
.form-group input,
.form-group select {
padding: 12px 14px;
border: 1px solid #d1d5db;
border-radius: 6px;
font-size: 1.1rem;
transition: border-color 0.2s, box-shadow 0.2s;
background-color: white;
}
.form-group input:focus,
.form-group select:focus {
outline: none;
border-color: #3b82f6;
box-shadow: 0 0 0 3px rgba(59, 130, 246, 0.1);
}
.form-group input:disabled,
.form-group select:disabled {
background-color: #f3f4f6;
color: #6b7280;
cursor: not-allowed;
}
/* 입력 필드와 버튼이 함께 있는 경우 */
.input-with-button {
display: flex;
gap: 8px;
align-items: flex-end;
}
.input-with-button input {
flex: 1;
}
.check-button {
padding: 10px 16px;
background-color: #6b7280;
color: white;
border: none;
border-radius: 6px;
font-size: 0.8rem;
font-weight: 500;
cursor: pointer;
transition: background-color 0.2s;
white-space: nowrap;
min-width: 80px;
}
.check-button:hover:not(:disabled) {
background-color: #4b5563;
}
.check-button:disabled {
background-color: #d1d5db;
cursor: not-allowed;
}
/* 메시지 스타일 */
.error-message {
color: #dc2626;
font-size: 0.8rem;
margin-top: 4px;
display: block;
}
.success-message {
color: #059669;
font-size: 0.8rem;
margin-top: 4px;
display: block;
}
.submit-error {
background-color: #fef2f2;
border: 1px solid #fecaca;
color: #dc2626;
padding: 12px;
border-radius: 6px;
margin-bottom: 16px;
text-align: center;
}
/* 폼 액션 버튼 */
.form-actions {
display: flex;
gap: 12px;
justify-content: flex-end;
padding-top: 20px;
border-top: 1px solid #e5e7eb;
margin-top: 24px;
}
.cancel-button,
.submit-button {
padding: 12px 24px;
border-radius: 6px;
font-weight: 500;
font-size: 0.9rem;
cursor: pointer;
transition: all 0.2s;
border: none;
min-width: 100px;
}
.cancel-button {
background-color: #f3f4f6;
color: #374151;
border: 1px solid #d1d5db;
}
.cancel-button:hover:not(:disabled) {
background-color: #e5e7eb;
}
.submit-button {
background-color: #3b82f6;
color: white;
}
.submit-button:hover:not(:disabled) {
background-color: #2563eb;
}
.cancel-button:disabled,
.submit-button:disabled {
opacity: 0.6;
cursor: not-allowed;
}
/* 반응형 디자인 */
@media (max-width: 768px) {
.modal-content {
margin: 10px;
max-height: 95vh;
}
.modal-header {
padding: 16px 20px;
}
.signup-form {
padding: 20px;
}
.form-actions {
flex-direction: column;
}
.cancel-button,
.submit-button {
width: 100%;
}
.input-with-button {
flex-direction: column;
align-items: stretch;
}
.check-button {
width: 100%;
margin-top: 8px;
}
}
/* 스크롤바 스타일 */
.modal-content::-webkit-scrollbar {
width: 6px;
}
.modal-content::-webkit-scrollbar-track {
background: #f1f1f1;
border-radius: 3px;
}
.modal-content::-webkit-scrollbar-thumb {
background: #c1c1c1;
border-radius: 3px;
}
.modal-content::-webkit-scrollbar-thumb:hover {
background: #a8a8a8;
}
@@ -0,0 +1,436 @@
/* 신규 사용자 가입 모달 오버레이 */
.user-signup-modal-overlay {
position: fixed;
top: 0;
left: 0;
right: 0;
bottom: 0;
background-color: rgba(0, 0, 0, 0.5);
display: flex;
justify-content: center;
align-items: center;
z-index: 1000;
padding: 20px;
}
/* 신규 사용자 가입 모달 컨텐츠 */
.user-signup-modal-content {
background: white;
border-radius: 8px;
box-shadow: 0 4px 20px rgba(0, 0, 0, 0.15);
max-width: 800px;
width: 100%;
max-height: 90vh;
overflow-y: auto;
position: relative;
}
/* 신규 사용자 가입 모달 헤더 */
.user-signup-modal-header {
display: flex;
justify-content: space-between;
align-items: center;
padding: 20px 24px;
border-bottom: 1px solid #e5e7eb;
background-color: #f9fafb;
border-radius: 8px 8px 0 0;
}
.user-signup-modal-header h2 {
margin: 0;
font-size: 1.8rem;
font-weight: 600;
color: #1f2937;
}
.user-signup-close-button {
background: none;
border: none;
font-size: 1.5rem;
cursor: pointer;
color: #6b7280;
padding: 4px;
border-radius: 4px;
transition: color 0.2s;
}
.user-signup-close-button:hover {
color: #374151;
}
/* 신규 사용자 가입 폼 스타일 */
.user-signup-form {
padding: 24px;
}
.user-signup-form-section {
margin-bottom: 32px;
}
.user-signup-form-section h3 {
margin: 0 0 16px 0;
font-size: 1.4rem;
font-weight: 600;
color: #374151;
padding-bottom: 8px;
border-bottom: 2px solid #e5e7eb;
}
.user-signup-form-row {
margin-bottom: 16px;
}
.user-signup-form-group {
display: flex;
flex-direction: column;
}
.user-signup-form-group label {
margin-bottom: 6px;
font-weight: 500;
color: #374151;
font-size: 1.1rem;
}
.user-signup-form-group input,
.user-signup-form-group select {
padding: 12px 14px;
border: 1px solid #d1d5db;
border-radius: 6px;
font-size: 1.1rem;
transition: border-color 0.2s, box-shadow 0.2s;
background-color: white;
}
/* CodeSelect 컴포넌트 스타일링 */
.user-signup-form-group .CodeSelect {
width: 100%;
}
.user-signup-form-group .CodeSelect select {
width: 100%;
padding: 12px 14px;
border: 1px solid #d1d5db;
border-radius: 6px;
font-size: 1.1rem;
transition: border-color 0.2s, box-shadow 0.2s;
background-color: white;
}
.user-signup-form-group .CodeSelect select:focus {
outline: none;
border-color: #3b82f6;
box-shadow: 0 0 0 3px rgba(59, 130, 246, 0.1);
}
.user-signup-form-group .CodeSelect select:disabled {
background-color: #f3f4f6;
color: #6b7280;
cursor: not-allowed;
}
/* Select 요소 스타일링 */
.user-signup-select {
width: 100%;
padding: 12px 14px;
border: 1px solid #d1d5db;
border-radius: 6px;
font-size: 1.1rem;
transition: border-color 0.2s, box-shadow 0.2s;
background-color: white;
cursor: pointer;
}
.user-signup-select:focus {
outline: none;
border-color: #3b82f6;
box-shadow: 0 0 0 3px rgba(59, 130, 246, 0.1);
}
.user-signup-select:disabled {
background-color: #f3f4f6;
color: #6b7280;
cursor: not-allowed;
}
.user-signup-select option {
padding: 8px;
font-size: 1rem;
}
.user-signup-form-group input:focus,
.user-signup-form-group select:focus {
outline: none;
border-color: #3b82f6;
box-shadow: 0 0 0 3px rgba(59, 130, 246, 0.1);
}
.user-signup-form-group input:disabled,
.user-signup-form-group select:disabled {
background-color: #f3f4f6;
color: #6b7280;
cursor: not-allowed;
}
/* 입력 필드와 버튼이 함께 있는 경우 */
.user-signup-input-with-button {
display: flex;
gap: 8px;
align-items: flex-end;
}
.user-signup-input-with-button input {
flex: 1;
}
.user-signup-check-button {
padding: 10px 16px;
background-color: #6b7280;
color: white;
border: none;
border-radius: 6px;
font-size: 0.8rem;
font-weight: 500;
cursor: pointer;
transition: background-color 0.2s;
white-space: nowrap;
min-width: 80px;
}
.user-signup-check-button:hover:not(:disabled) {
background-color: #4b5563;
}
.user-signup-check-button:disabled {
background-color: #d1d5db;
cursor: not-allowed;
}
/* 메시지 스타일 */
.user-signup-error-message {
color: #dc2626;
font-size: 0.8rem;
margin-top: 4px;
display: block;
}
.user-signup-success-message {
color: #059669;
font-size: 0.8rem;
margin-top: 4px;
display: block;
}
.user-signup-submit-error {
background-color: #fef2f2;
border: 1px solid #fecaca;
color: #dc2626;
padding: 12px;
border-radius: 6px;
margin-bottom: 16px;
text-align: center;
}
/* 폼 액션 버튼 */
.user-signup-form-actions {
display: flex;
gap: 12px;
justify-content: flex-end;
padding-top: 20px;
border-top: 1px solid #e5e7eb;
margin-top: 24px;
}
.user-signup-cancel-button,
.user-signup-submit-button,
.user-signup-back-button {
padding: 12px 24px;
border-radius: 6px;
font-weight: 500;
font-size: 0.9rem;
cursor: pointer;
transition: all 0.2s;
border: none;
min-width: 100px;
}
.user-signup-cancel-button {
background-color: #f3f4f6;
color: #374151;
border: 1px solid #d1d5db;
}
.user-signup-cancel-button:hover:not(:disabled) {
background-color: #e5e7eb;
}
.user-signup-submit-button {
background-color: #3b82f6;
color: white;
}
.user-signup-submit-button:hover:not(:disabled) {
background-color: #2563eb;
}
.user-signup-back-button {
background-color: #6b7280;
color: white;
}
.user-signup-back-button:hover:not(:disabled) {
background-color: #4b5563;
}
.user-signup-cancel-button:disabled,
.user-signup-submit-button:disabled,
.user-signup-back-button:disabled {
opacity: 0.6;
cursor: not-allowed;
}
/* 개인정보제공동의 스타일 */
.user-signup-agreement-content {
padding: 24px;
}
.user-signup-agreement-text {
margin-bottom: 24px;
}
.user-signup-agreement-text h3 {
margin: 0 0 16px 0;
font-size: 1.4rem;
font-weight: 600;
color: #374151;
}
.user-signup-agreement-text h4 {
margin: 20px 0 8px 0;
font-size: 1.1rem;
font-weight: 600;
color: #374151;
}
.user-signup-agreement-text p {
margin: 8px 0;
color: #6b7280;
line-height: 1.5;
}
.user-signup-agreement-text ul {
margin: 8px 0;
padding-left: 20px;
color: #6b7280;
}
.user-signup-agreement-text li {
margin: 4px 0;
line-height: 1.5;
}
.user-signup-agreement-checkbox {
margin-bottom: 24px;
padding: 16px;
background-color: #f9fafb;
border-radius: 6px;
border: 1px solid #e5e7eb;
}
.user-signup-agreement-checkbox label {
display: flex;
align-items: center;
gap: 8px;
cursor: pointer;
font-weight: 500;
color: #374151;
}
.user-signup-agreement-checkbox input[type="checkbox"] {
width: 18px;
height: 18px;
margin: 0;
}
.user-signup-agreement-actions {
display: flex;
gap: 12px;
justify-content: flex-end;
}
.user-signup-next-button {
padding: 12px 24px;
background-color: #3b82f6;
color: white;
border: none;
border-radius: 6px;
font-weight: 500;
font-size: 0.9rem;
cursor: pointer;
transition: background-color 0.2s;
min-width: 100px;
}
.user-signup-next-button:hover:not(:disabled) {
background-color: #2563eb;
}
.user-signup-next-button:disabled {
background-color: #d1d5db;
cursor: not-allowed;
}
/* 반응형 디자인 */
@media (max-width: 768px) {
.user-signup-modal-content {
margin: 10px;
max-height: 95vh;
}
.user-signup-modal-header {
padding: 16px 20px;
}
.user-signup-form {
padding: 20px;
}
.user-signup-form-actions {
flex-direction: column;
}
.user-signup-cancel-button,
.user-signup-submit-button,
.user-signup-back-button {
width: 100%;
}
.user-signup-input-with-button {
flex-direction: column;
align-items: stretch;
}
.user-signup-check-button {
width: 100%;
margin-top: 8px;
}
}
/* 스크롤바 스타일 */
.user-signup-modal-content::-webkit-scrollbar {
width: 6px;
}
.user-signup-modal-content::-webkit-scrollbar-track {
background: #f1f1f1;
border-radius: 3px;
}
.user-signup-modal-content::-webkit-scrollbar-thumb {
background: #c1c1c1;
border-radius: 3px;
}
.user-signup-modal-content::-webkit-scrollbar-thumb:hover {
background: #a8a8a8;
}
+71
View File
@@ -0,0 +1,71 @@
import axios from "axios";
import type { AxiosInstance, AxiosRequestConfig, AxiosResponse } from "axios";
interface SignupClientConfig {
baseURL: string;
}
class SignupClient {
private static instance: SignupClient;
private axiosInstance: AxiosInstance;
private constructor(config: SignupClientConfig) {
this.axiosInstance = axios.create({
baseURL: config.baseURL,
headers: {
"Content-Type": "application/json",
},
withCredentials: true,
});
// 인증 토큰 없이도 요청할 수 있도록 인터셉터 설정
this.axiosInstance.interceptors.request.use((config) => {
// 인증 토큰을 자동으로 추가하지 않음
return config;
});
}
public static getInstance(config: SignupClientConfig): SignupClient {
if (!SignupClient.instance) {
SignupClient.instance = new SignupClient(config);
}
return SignupClient.instance;
}
public async get<T = unknown, R = AxiosResponse<T>>(
url: string,
config?: AxiosRequestConfig
): Promise<R> {
const response = await this.axiosInstance.get<T, R>(url, config);
return response;
}
public async post<T = unknown, R = AxiosResponse<T>, D = unknown>(
url: string,
data?: D,
config?: AxiosRequestConfig<D>
): Promise<R> {
const response = await this.axiosInstance.post<T, R>(url, data, config);
return response;
}
public async put<T = unknown, R = AxiosResponse<T>, D = unknown>(
url: string,
data?: D,
config?: AxiosRequestConfig<D>
): Promise<R> {
const response = await this.axiosInstance.put<T, R>(url, data, config);
return response;
}
public async delete<T = unknown, R = AxiosResponse<T>>(
url: string,
config?: AxiosRequestConfig
): Promise<R> {
const response = await this.axiosInstance.delete<T, R>(url, config);
return response;
}
}
export type { SignupClientConfig };
export { SignupClient };
+29
View File
@@ -14,13 +14,21 @@ export const ENDPOINT = {
},
USER_SERVICE: {
LOGIN: "/user-service/login",
LOGIN_2FA: "/user-service/api/v1/auth/login",
USERS: "/user-service/api/v1/users",
SILENT_REFRESH: "/user-service/api/v1/users/token/refresh",
AUTHORIZATIONS: "/user-service/api/v1/authorizations",
ROLE_AUTHORIZATIONS: "/user-service/api/v1/role-authorizations",
ROLES: "/user-service/api/v1/roles",
LOGINLOG: "/user-service/api/v1/login-logs",
PRIVACYLOG: "/user-service//api/v1/prvc-inq-logs",
OTP_VERIFY: "/user-service/api/v1/auth/2fa/verify",
TWOFA_SETUP: "/user-service/api/v1/auth/2fa/setup",
TWOFA_ENABLE: "/user-service/api/v1/auth/2fa/enable",
USER_JOIN: "/user-service/api/v1/anonymouse/users/join",
},
CMS_SERVICE: {
DASHBOARD: "/cms-service/api/v1/dashboard",
SITES: "/cms-service/api/v1/sites",
IPS: "/cms-service/api/v1/site-ips",
DOMAINS: "/cms-service/api/v1/site-dmns",
@@ -29,6 +37,8 @@ export const ENDPOINT = {
MENUS: "/cms-service/api/v1/menus",
MENU_ROLES: "/cms-service/api/v1/menu-roles",
MENU_AUTHRTS: "/cms-service/api/v1/menu-authrts",
MENU_TEMPORARY_STORAGE: "/cms-service/api/v1/menu-tmpr-strg",
MENU_HISTORY: "/cms-service/api/v1/menu-hstry",
CONTENTS: "/cms-service/api/v1/contents",
CONTENTS_DETAILS: "/cms-service/api/v1/contents-details",
ORGANIZATIONS: "/cms-service/api/v1/ognzs",
@@ -44,13 +54,32 @@ export const ENDPOINT = {
SURVEY: "/cms-service/api/v1/srvy",
SURVEY_QUESTION: "/cms-service/api/v1/srvy-qitem",
SURVEY_ARTICLE: "/cms-service/api/v1/srvy-artcl",
ZIPS: "/cms-service/api/v1/zips",
GROUPS: "/cms-service/api/v1/groups",
GROUP_USERS: "/cms-service/api/v1/group-users",
MANAGER_IP: "/cms-service/api/v1/mgrip",
APILOG: "/cms-service/api/v1/api-logs",
SITE_AUTHORITY: "/cms-service/api/v1/site-authrt",
CONNECTION_LOG: "/cms-service/api/v1/cntnlog",
},
BOARD_SERVICE: {
BBS: "/board-service/api/v1/bbs",
BBS_TEMPLATES: "/board-service/api/v1/bbsTmplt",
POSTS: "/board-service/api/v1/posts",
POSTS_COMMENTS: "/board-service/api/v1/posts-cmnts",
POST_EVALUATION: "/board-service/api/v1/pstEvl",
},
SEWERAGE_SERVICE: {
PPL_COUNTS: "/sewerage-service/api/v1/ms/pplCounts",
OLD_PPL_COUNTS: "/sewerage-service/api/v1/ms/oldPplCounts",
},
IC_SERVICE: {
GROUPING_STATISTICS: "/ic-service/api/v1/nti/grouping-statistics",
},
DC_SERVICE: {
DATA_STATUS: "/dc-service/api/v1/dc/dataStatus",
},
};
export const JWT_EXPIRATION_TIME = 1800000;
+175
View File
@@ -0,0 +1,175 @@
import * as React from "react";
import * as LabelPrimitive from "@radix-ui/react-label";
import { Slot } from "@radix-ui/react-slot";
import {
Controller,
FormProvider,
useFormContext,
type ControllerProps,
type FieldPath,
type FieldValues,
} from "react-hook-form";
import { cn, Label } from "@common/components";
const Form = FormProvider;
type FormFieldContextValue<
TFieldValues extends FieldValues = FieldValues,
TName extends FieldPath<TFieldValues> = FieldPath<TFieldValues>,
> = {
name: TName;
};
const FormFieldContext = React.createContext<FormFieldContextValue>(
{} as FormFieldContextValue,
);
const FormField = <
TFieldValues extends FieldValues = FieldValues,
TName extends FieldPath<TFieldValues> = FieldPath<TFieldValues>,
>({
...props
}: ControllerProps<TFieldValues, TName>) => {
return (
<FormFieldContext.Provider value={{ name: props.name }}>
<Controller {...props} />
</FormFieldContext.Provider>
);
};
const useFormField = () => {
const fieldContext = React.useContext(FormFieldContext);
const itemContext = React.useContext(FormItemContext);
const { getFieldState, formState } = useFormContext();
const fieldState = getFieldState(fieldContext.name, formState);
if (!fieldContext) {
throw new Error("useFormField should be used within <FormField>");
}
const { id } = itemContext;
return {
id,
name: fieldContext.name,
formItemId: `${id}-form-item`,
formDescriptionId: `${id}-form-item-description`,
formMessageId: `${id}-form-item-message`,
...fieldState,
};
};
type FormItemContextValue = {
id: string;
};
const FormItemContext = React.createContext<FormItemContextValue>(
{} as FormItemContextValue,
);
const FormItem = React.forwardRef<
HTMLDivElement,
React.HTMLAttributes<HTMLDivElement>
>(({ className, ...props }, ref) => {
const id = React.useId();
return (
<FormItemContext.Provider value={{ id }}>
<div ref={ref} className={cn("space-y-2", className)} {...props} />
</FormItemContext.Provider>
);
});
FormItem.displayName = "FormItem";
const FormLabel = React.forwardRef<
React.ElementRef<typeof LabelPrimitive.Root>,
React.ComponentPropsWithoutRef<typeof LabelPrimitive.Root>
>(({ className, ...props }, ref) => {
const { error, formItemId } = useFormField();
return (
<Label
ref={ref}
className={cn(error && "text-destructive", className)}
htmlFor={formItemId}
{...props}
/>
);
});
FormLabel.displayName = "FormLabel";
const FormControl = React.forwardRef<
React.ElementRef<typeof Slot>,
React.ComponentPropsWithoutRef<typeof Slot>
>(({ ...props }, ref) => {
const { error, formItemId, formDescriptionId, formMessageId } =
useFormField();
return (
<Slot
ref={ref}
id={formItemId}
aria-describedby={
!error
? `${formDescriptionId}`
: `${formDescriptionId} ${formMessageId}`
}
aria-invalid={!!error}
{...props}
/>
);
});
FormControl.displayName = "FormControl";
const FormDescription = React.forwardRef<
HTMLParagraphElement,
React.HTMLAttributes<HTMLParagraphElement>
>(({ className, ...props }, ref) => {
const { formDescriptionId } = useFormField();
return (
<p
ref={ref}
id={formDescriptionId}
className={cn("text-[0.8rem] text-muted-foreground", className)}
{...props}
/>
);
});
FormDescription.displayName = "FormDescription";
const FormMessage = React.forwardRef<
HTMLParagraphElement,
React.HTMLAttributes<HTMLParagraphElement>
>(({ className, children, ...props }, ref) => {
const { error, formMessageId } = useFormField();
const body = error ? String(error?.message ?? "") : children;
if (!body) {
return null;
}
return (
<p
ref={ref}
id={formMessageId}
className={cn("text-[1rem] font-medium text-destructive", className)}
{...props}
>
{body}
</p>
);
});
FormMessage.displayName = "FormMessage";
export {
useFormField,
Form,
FormItem,
FormLabel,
FormControl,
FormDescription,
FormMessage,
FormField,
};
+1
View File
@@ -16,6 +16,7 @@
"build:wr": "turbo run build --filter=wr",
"build:fo": "turbo run build --filter=fo",
"build:dm": "turbo run build --filter=dm",
"dev:cm": "turbo run dev --filter=cm",
"dev:cs": "turbo run dev --filter=cs",
"dev:sn": "turbo run dev --filter=sn",
"dev:wr": "turbo run dev --filter=wr",
+6
View File
@@ -904,6 +904,12 @@ importers:
'@minoru/react-dnd-treeview':
specifier: 3.5.1-alpha.1
version: 3.5.1-alpha.1(dnd-core@16.0.1)(react-dnd@16.0.1)(react-dom@18.3.1)(react@18.3.1)
'@radix-ui/react-label':
specifier: ^2.1.2
version: 2.1.7(@types/react-dom@18.3.7)(@types/react@18.3.23)(react-dom@18.3.1)(react@18.3.1)
'@radix-ui/react-slot':
specifier: ^1.1.2
version: 1.2.3(@types/react@18.3.23)(react@18.3.1)
'@tanstack/react-query':
specifier: ^5.67.1
version: 5.84.2(react@18.3.1)