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

This commit is contained in:
2026-06-05 10:33:30 +09:00
163 changed files with 9850 additions and 3635 deletions
+2 -2
View File
@@ -19,7 +19,7 @@
"@tanstack/react-table": "^8.21.2",
"axios": "^1.8.1",
"dayjs": "^1.11.13",
"next": "15.3.8",
"next": "15.5.18",
"react-hook-form": "^7.54.2",
"zod": "^3.24.2"
},
@@ -33,4 +33,4 @@
"tailwindcss": "^3.4.14",
"typescript": "^5.7.2"
}
}
}
+1 -1
View File
@@ -27,7 +27,7 @@
"dayjs": "^1.11.13",
"js-cookie": "^3.0.5",
"lucide-react": "^0.474.0",
"next": "15.3.8",
"next": "15.5.18",
"react-hook-form": "^7.54.2",
"tus-js-client": "^4.3.1",
"zod": "^3.24.2"
+2 -2
View File
@@ -37,7 +37,7 @@
"dayjs": "^1.11.13",
"js-cookie": "^3.0.5",
"lucide-react": "^0.474.0",
"next": "15.3.8",
"next": "15.5.18",
"next-qrcode": "^2.5.1",
"next-themes": "^0.4.6",
"react-chartjs-2": "^5.3.0",
@@ -63,4 +63,4 @@
"tailwindcss": "^3.4.14",
"typescript": "^5.7.2"
}
}
}
+1 -1
View File
@@ -28,7 +28,7 @@
"chart.js": "^4.4.8",
"dayjs": "^1.11.13",
"js-cookie": "^3.0.5",
"next": "15.3.8",
"next": "15.5.18",
"next-themes": "^0.4.6",
"react-chartjs-2": "^5.3.0",
"react-dnd": "^16.0.1",
+1 -1
View File
@@ -35,7 +35,7 @@
"dayjs": "^1.11.13",
"js-cookie": "^3.0.5",
"lucide-react": "^0.474.0",
"next": "15.3.8",
"next": "15.5.18",
"next-qrcode": "^2.5.1",
"next-themes": "^0.4.6",
"react-chartjs-2": "^5.3.0",
+15 -8
View File
@@ -1,16 +1,17 @@
import { getRemainingMs } from "@common/auth";
import {
ENDPOINT,
CLAIM_NAME,
ACCESS_TOKEN,
AUTH_USER_ID,
CLAIM_NAME,
ENDPOINT,
} from "@/shared/config";
import type { ILogin } from "../model/auth-interface";
import { getRemainingMs } from "@common/auth";
import {
setToken,
clearAuthToken,
setToken,
setupRefreshTimer,
} from "../lib/token-manager";
import type { AuthPayload, AuthService } from "../model/auth-interface";
function resolveLoginError(message: string | undefined): string {
switch (message) {
@@ -27,7 +28,7 @@ function resolveLoginError(message: string | undefined): string {
}
}
export async function login(payload: ILogin): Promise<void> {
async function login(payload: AuthPayload): Promise<void> {
const response = await fetch(`/api/proxy${ENDPOINT.USER_SERVICE.LOGIN}`, {
method: "POST",
headers: { "Content-Type": "application/json" },
@@ -56,7 +57,7 @@ export async function login(payload: ILogin): Promise<void> {
}
}
export async function silentRefresh(): Promise<void> {
async function silentRefresh(): Promise<void> {
console.log(`[silentRefresh] Called at ${new Date().toISOString()}`);
try {
const response = await fetch(
@@ -97,9 +98,15 @@ export async function silentRefresh(): Promise<void> {
}
}
export async function logout(): Promise<void> {
async function logout(): Promise<void> {
clearAuthToken();
if (typeof window !== "undefined") {
window.dispatchEvent(new CustomEvent("auth:logout"));
}
}
export const authService: AuthService = {
login,
silentRefresh,
logout,
};
+2 -2
View File
@@ -1,3 +1,3 @@
export { login, silentRefresh, logout } from "./api/auth-service";
export { authService } from "./api/auth-service";
export { AuthWrapper } from "./lib/auth-wrapper";
export { tokenManager } from "./lib/token-manager";
export { tokenManager } from "./lib/token-manager";
+15 -11
View File
@@ -2,20 +2,24 @@
import { useEffect } from "react";
import { useRouter } from "next/navigation";
import { isAxiosError } from "axios";
import { client } from "@/shared/api";
import {
AUTH_LOGIN_REDIRECT_URL,
AUTH_USER_ID,
CLAIM_NAME,
ENDPOINT,
} from "@/shared/config";
import { useUserStore } from "@/shared/lib";
import type { IUserInfo } from "@/shared/store";
import { getRemainingMs } from "@common/auth";
import { isAtLoginRedirectUrl } from "@common/network";
import { client } from "@/shared/api";
import type { IUserInfo } from "@/shared/store";
import { useUserStore } from "@/shared/lib";
import {
ENDPOINT,
AUTH_USER_ID,
AUTH_LOGIN_REDIRECT_URL,
CLAIM_NAME,
} from "@/shared/config";
import { authService } from "../api/auth-service";
import { getToken, setupRefreshTimer } from "./token-manager";
import { silentRefresh } from "../api/auth-service";
const AuthWrapper = ({ children }: { children: React.ReactNode }) => {
const router = useRouter();
@@ -31,7 +35,7 @@ const AuthWrapper = ({ children }: { children: React.ReactNode }) => {
console.log(
`[AuthWrapper] Refresh timer set: ${remainingMs}ms (${remainingMs / 1000}s)`,
);
setupRefreshTimer(silentRefresh, remainingMs);
setupRefreshTimer(authService.silentRefresh, remainingMs);
}
}
@@ -1,4 +1,10 @@
export interface ILogin {
export interface AuthPayload {
userId: string;
password: string;
}
export interface AuthService {
login(payload: AuthPayload): Promise<void>;
silentRefresh(): Promise<void>;
logout(): Promise<void>;
}
@@ -1,4 +1,5 @@
import { z } from "zod";
import type { PageableList, PageableParams } from "@/shared/model";
export const USER_STATE_CODE = {
@@ -153,6 +154,7 @@ export const userUpdateSchema = z.object({
autzrId: z.string().uuid().optional(),
expryYmd: z.string().max(8),
instSeCd: z.string().optional(),
instSeNm: z.string().optional(),
dtlInstCd: z.string().optional(),
aprvYmd: z.string().max(8).optional(),
enoCd: z.string().optional(),
@@ -2,8 +2,10 @@
import { useState } from "react";
import Image from "next/image";
import { authService } from "@/entities/auth";
import { images } from "@common/assets";
import { login } from "@/entities/auth";
import "./login.css";
function LoginPage() {
@@ -18,7 +20,7 @@ function LoginPage() {
const password = formData.get("password") as string;
try {
await login({ userId, password });
await authService.login({ userId, password });
window.location.href = "/dashboard";
} catch (error) {
const message =
@@ -31,12 +33,14 @@ function LoginPage() {
<div className="login fx">
<div className="left_box fx">
<div className="text">
<h1>
<h1>
유역하수도통합플랫폼
<br />
<span>하수도플랫폼 통합관리(CMS)시스템</span>
</h1>
<p>유역하수도통합플랫폼 통합관리(CMS)시스템에 오신 것을 환영합니다.</p>
<p>
유역하수도통합플랫폼 통합관리(CMS)시스템에 오신 것을 환영합니다.
</p>
</div>
<div className="img">
<Image src={images.logo} width={229} height={46} alt="logo" />
+1 -1
View File
@@ -1,3 +1,3 @@
export { UsersPage } from "./ui/users-page";
export { CreatePage } from "./ui/create-page";
export { UserDetail } from "./ui/user-detail";
export { UsersPage } from "./ui/users-page";
@@ -1,5 +1,6 @@
import dayjs from "dayjs";
import type { ColumnDef } from "@tanstack/react-table";
import dayjs from "dayjs";
import {
USER_STATE_CODE,
type UserListResponse,
@@ -1,4 +1,5 @@
import { useState } from "react";
import {
keepPreviousData,
useQuery,
@@ -9,25 +10,26 @@ import {
getPaginationRowModel,
type PaginationState,
} from "@tanstack/react-table";
import { ENDPOINT } from "@/shared/config";
import { useDataTable } from "@/shared/lib";
import {
type LwpoCode,
LwpoCodeApi,
type LwpoCodeParams,
} from "@/entities/code";
import {
Button,
Dialog,
DialogClose,
DialogContent,
DialogHeader,
DialogFooter,
DialogHeader,
DialogTitle,
DialogTrigger,
DialogClose,
Button,
Input,
Label,
} from "@common/components";
import {
LwpoCodeApi,
type LwpoCode,
type LwpoCodeParams,
} from "@/entities/code";
import { ENDPOINT } from "@/shared/config";
import { useDataTable } from "@/shared/lib";
interface AgencySearchDialogProps {
onSelectAgency: (data: { lwpoCd: string; lwpoCdNm: string }) => void;
+6 -3
View File
@@ -1,10 +1,13 @@
"use client";
import { useRouter } from "next/navigation";
import { useForm } from "react-hook-form";
import { useRouter } from "next/navigation";
import { zodResolver } from "@hookform/resolvers/zod";
import { useMutationWrapper, useAlertStore } from "@/shared/lib";
import { UserApi, userSaveSchema, type UserSaveData } from "@/entities/user";
import { useAlertStore, useMutationWrapper } from "@/shared/lib";
import { UserApi, type UserSaveData, userSaveSchema } from "@/entities/user";
import { UsersForm } from "./users-form";
function CreatePage() {
+8 -4
View File
@@ -1,17 +1,20 @@
"use client";
import React from "react";
import { useRouter } from "next/navigation";
import { useForm } from "react-hook-form";
import { useRouter } from "next/navigation";
import { zodResolver } from "@hookform/resolvers/zod";
import { useQuery } from "@tanstack/react-query";
import { ENDPOINT } from "@/shared/config";
import { useAlertStore, useMutationWrapper } from "@/shared/lib";
import {
UserApi,
userUpdateSchema,
type UserUpdateData,
userUpdateSchema,
} from "@/entities/user";
import { ENDPOINT } from "@/shared/config";
import { useMutationWrapper, useAlertStore } from "@/shared/lib";
import { UsersForm } from "./users-form";
interface ModifyPageProps {
@@ -47,6 +50,7 @@ export function ModifyPage({ userUnqId }: ModifyPageProps) {
aprvYn: userData?.aprvYn ?? "",
expryYmd: userData?.expryYmd ?? "",
instSeCd: userData?.instSeCd ?? "",
instSeNm: userData?.instSeNm ?? "",
dtlInstCd: userData?.dtlInstCd ?? "",
aprvYmd: userData?.aprvYmd ?? "",
enoCd: userData?.enoCd ?? "",
+7 -5
View File
@@ -1,9 +1,14 @@
"use client";
import dayjs from "dayjs";
import { usePathname, useRouter } from "next/navigation";
import { useQuery, useQueryClient } from "@tanstack/react-query";
import { useConfirm } from "@frontend-opensource/use-react-hooks";
import { useQuery, useQueryClient } from "@tanstack/react-query";
import dayjs from "dayjs";
import { ENDPOINT } from "@/shared/config";
import { useAlertStore, useMutationWrapper } from "@/shared/lib";
import { PasswordResetDialog, USER_STATE_CODE, UserApi } from "@/entities/user";
import {
Button,
Table,
@@ -12,9 +17,6 @@ import {
TableHead,
TableRow,
} from "@common/components";
import { USER_STATE_CODE, UserApi, PasswordResetDialog } from "@/entities/user";
import { ENDPOINT } from "@/shared/config";
import { useAlertStore, useMutationWrapper } from "@/shared/lib";
function UserDetail({ userUnqId }: { userUnqId: string }) {
const router = useRouter();
+49 -33
View File
@@ -1,42 +1,45 @@
import { useState, useMemo, useCallback } from "react";
import { useCallback, useMemo, useState } from "react";
import {
useWatch,
type Path,
type PathValue,
type UseFormReturn,
useWatch,
} from "react-hook-form";
import { useQuery } from "@tanstack/react-query";
import { ENDPOINT } from "@/shared/config";
import { useAlertStore, useMutationWrapper } from "@/shared/lib";
import {
Button,
Input,
Table,
TableBody,
TableCell,
TableHead,
TableRow,
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from "@common/components";
import { FormWrapper } from "@/widgets/form-wrapper";
FormControl,
FormField,
FormItem,
FormMessage,
} from "@/shared/ui/form";
import { CodeApi, CodeSelect } from "@/entities/code";
import {
UserApi,
type UserSaveData,
type UserUpdateData,
} from "@/entities/user";
import { FormWrapper } from "@/widgets/form-wrapper";
import {
FormField,
FormItem,
FormControl,
FormMessage,
} from "@/shared/ui/form";
import { ENDPOINT } from "@/shared/config";
import { useAlertStore, useMutationWrapper } from "@/shared/lib";
import { ZipSearchDialog } from "./zip-search-dialog";
Button,
Input,
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
Table,
TableBody,
TableCell,
TableHead,
TableRow,
} from "@common/components";
import { AgencySearchDialog } from "./agency-search-dialog";
import { ZipSearchDialog } from "./zip-search-dialog";
type UsersFormProps<T extends UserSaveData | UserUpdateData> = {
userId?: string;
@@ -61,7 +64,6 @@ function UsersForm<T extends UserSaveData | UserUpdateData>({
const [isValidUserId, setIsValidUserId] = useState(false);
const [isValidEmail, setIsValidEmail] = useState(false);
const [passwordMatch, setPasswordMatch] = useState(true);
const [instSeCdNm, setInstSeCdNm] = useState("");
const { data: authSeCd } = useQuery({
queryKey: [ENDPOINT.CMS_SERVICE.CODE, "AUTHSECD"],
@@ -130,7 +132,7 @@ function UsersForm<T extends UserSaveData | UserUpdateData>({
(code) => code.lwpoCd.startsWith("G3") && code.lwpoCd.length === 4,
) ?? []
);
if (watchDtlInstCd === "ROLE_USER5")
if (watchDtlInstCd === "ROLE_USER05")
return (
laeSeCd?.filter(
(code) => code.lwpoCd.startsWith("G3") && code.lwpoCd.length === 7,
@@ -163,7 +165,10 @@ function UsersForm<T extends UserSaveData | UserUpdateData>({
"instSeCd" as Path<T>,
data.lwpoCd as PathValue<T, Path<T>>,
);
setInstSeCdNm(data.lwpoCdNm);
form.setValue(
"instSeNm" as Path<T>,
data.lwpoCdNm as PathValue<T, Path<T>>,
);
},
[form],
);
@@ -622,8 +627,8 @@ function UsersForm<T extends UserSaveData | UserUpdateData>({
onValueChange={field.onChange}
disabled={
!(
watchDtlInstCd === "ROLE_USER4" ||
watchDtlInstCd === "ROLE_USER5"
watchDtlInstCd === "ROLE_USER04" ||
watchDtlInstCd === "ROLE_USER05"
)
}
>
@@ -650,10 +655,21 @@ function UsersForm<T extends UserSaveData | UserUpdateData>({
<TableHead>기관/업체명</TableHead>
<TableCell className="border">
<div className="flex items-center gap-2">
<Input
value={instSeCdNm}
className="w-[24rem] !text-[1.3rem]"
disabled
<FormField
control={form.control}
name={"instSeNm" as Path<T>}
render={({ field }) => (
<FormItem>
<FormControl>
<Input
{...field}
value={field.value ?? ""}
className="w-[24rem] !text-[1.3rem]"
disabled
/>
</FormControl>
</FormItem>
)}
/>
<AgencySearchDialog onSelectAgency={handleAgencySelect} />
</div>
+15 -12
View File
@@ -1,10 +1,21 @@
"use client";
import dayjs from "dayjs";
import { useState } from "react";
import { useRouter } from "next/navigation";
import { useQuery } from "@tanstack/react-query";
import { getCoreRowModel, type PaginationState } from "@tanstack/react-table";
import dayjs from "dayjs";
import { ENDPOINT } from "@/shared/config";
import { useDataTable } from "@/shared/lib";
import { InputDateRangePicker } from "@/shared/ui/input-date-range-picker";
import { CodeApi } from "@/entities/code";
import {
UserApi,
type UserListResponse,
type UserSearchParams,
} from "@/entities/user";
import {
Button,
Checkbox,
@@ -16,20 +27,12 @@ import {
SelectTrigger,
SelectValue,
} from "@common/components";
import { CodeApi } from "@/entities/code";
import {
UserApi,
type UserListResponse,
type UserSearchParams,
} from "@/entities/user";
import { ENDPOINT } from "@/shared/config";
import { useDataTable } from "@/shared/lib";
import { InputDateRangePicker } from "@/shared/ui/input-date-range-picker";
import { columns } from "../model/table-columns";
type DateRange = {
from?: Date | undefined;
to?: Date | undefined;
from?: Date | null;
to?: Date | null;
};
function UsersPage() {
@@ -1,4 +1,5 @@
import { useState, useRef, useEffect } from "react";
import { useEffect, useRef, useState } from "react";
import {
keepPreviousData,
useQuery,
@@ -9,21 +10,23 @@ import {
getPaginationRowModel,
type PaginationState,
} from "@tanstack/react-table";
import { ENDPOINT } from "@/shared/config";
import { useDataTable } from "@/shared/lib";
import {
Button,
Dialog,
DialogClose,
DialogContent,
DialogHeader,
DialogFooter,
DialogHeader,
DialogTitle,
DialogTrigger,
DialogClose,
Button,
Input,
Label,
} from "@common/components";
import { ENDPOINT } from "@/shared/config";
import { useDataTable } from "@/shared/lib";
import { ZipApi, type Zip, type ZipSearchParams } from "../api/zip-service";
import { type Zip, ZipApi, type ZipSearchParams } from "../api/zip-service";
interface ZipSearchDialogProps {
onSelectAddress: (data: { zip: string; addr: string }) => void;
+2 -2
View File
@@ -23,7 +23,7 @@
"dayjs": "^1.11.13",
"js-cookie": "^3.0.5",
"lucide-react": "^0.474.0",
"next": "15.3.8",
"next": "15.5.18",
"picocolors": "^1.1.1",
"react-chartjs-2": "^5.3.0",
"react-dnd": "^16.0.1",
@@ -49,4 +49,4 @@
"tailwindcss": "^3.4.14",
"typescript": "^5.7.2"
}
}
}
+2 -1
View File
@@ -19,7 +19,8 @@ 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_GATEWAY_URL=http://con.mind-one.co.kr:11083 # 개발 서버 API
# NEXT_PUBLIC_GATEWAY_URL=http://localhost:8883 # 개발 서버 API
CONTENTS_INTRO = 31
CONTENTS_EXAMPLE = 32
NEXT_PUBLIC_FINDADRESS_URL=https://business.juso.go.kr/addrlink/addrLinkApi.do
NEXT_PRIVATE_WR_ADDRESS_KEY = devU01TX0FVVEgyMDI1MDgyNTEwMzkwNDExNjEwNTM=
+2 -1
View File
@@ -20,7 +20,8 @@ NEXT_PUBLIC_SEWERAGE_API_URL=http://con.mind-one.co.kr:11083/sewerage-service
NEXT_PUBLIC_GATEWAY_URL=http://con.mind-one.co.kr:11083 # 개발 서버 API
# NEXT_PUBLIC_SEWERAGE_API_URL=http://192.168.1.128:8883/sewerage-service/api # 최용성
#NEXT_PUBLIC_SEWERAGE_API_URL=http://192.168.1.66:8883/sewerage-service/api # 박이정
CONTENTS_INTRO = 31
CONTENTS_EXAMPLE = 32
NEXT_PUBLIC_FINDADRESS_URL=https://business.juso.go.kr/addrlink/addrLinkApi.do
NEXT_PRIVATE_WR_ADDRESS_KEY = devU01TX0FVVEgyMDI1MDgyNTEwMzkwNDExNjEwNTM=
+2 -1
View File
@@ -18,7 +18,8 @@ NEXT_PUBLIC_SEWERAGE_API_URL = http://apigateway.iipbs-was.svc.cluster.local:800
NEXT_PUBLIC_NGII_URL = http://172.29.143.87/poiact
NEXT_PUBLIC_NGII_PARAM = BDE2BEF39DAFEC293AB6730150F222AC&service=WMTS&request=GetTile&version=1.0.0&format=image/png&layer=korean_map&style=korean&tilematrixset=korean&tilematrix={z}&tilecol={y}&tilerow={x}
NEXT_PUBLIC_GATEWAY_URL = http://apigateway.iipbs-was.svc.cluster.local:8000
CONTENTS_INTRO = 31
CONTENTS_EXAMPLE = 32
NEXT_PUBLIC_FINDADRESS_URL=https://business.juso.go.kr/addrlink/addrLinkApi.do
NEXT_PRIVATE_WR_ADDRESS_KEY = U01TXOFVVEgyMDI1MDgyODE0MTAxNzExNjEyMjA=
+22 -5
View File
@@ -1,9 +1,26 @@
export const dynamic = "force-dynamic";
import { ContentsPage } from "@/components/ui/contents";
import { revalidateTag } from "next/cache";
import { Button } from "@common/components/ui/button";
export default async function Contents() {
const contsSn = 28;
const menuCd = "24";
/**
* 'dm-content-detail' 태그를 가진 캐시 데이터를 즉시 무효화하는 서버 액션입니다.
* 버튼 클릭 시 실행되며, 이후 이 태그를 사용하는 모든 페이지는 최신 데이터를 다시 가져옵니다.
*/
async function revalidateAction() {
"use server";
revalidateTag("dm-content-detail");
console.log("Cache revalidated for tag: dm-content-detail");
}
return <ContentsPage contsSn={contsSn} menuCd={menuCd} />;
return (
<div className="container mx-auto p-10">
<div className="bg-white p-8 rounded-xl shadow-sm border border-gray-200 text-center">
<h1 className="text-2xl font-bold mb-2">콘텐츠 관리 도구</h1>
<p className="text-gray-500 mb-6">변경된 콘텐츠 내용을 즉시 반영하려면 아래 버튼을 클릭하세요.</p>
<form action={revalidateAction}>
<Button type="submit" className="px-8">캐시 즉시 갱신 (dm-content-detail)</Button>
</form>
</div>
</div>
);
}
File diff suppressed because it is too large Load Diff
@@ -1,191 +1,415 @@
"use client";
function SectionTitle({ children }: { children: React.ReactNode }) {
return (
<div className="flex items-center gap-3">
<span className="h-5 w-[3px] bg-sky-500" />
<h3 className="text-2xl font-bold text-neutral-900">{children}</h3>
</div>
);
}
function BlockTitle({ children }: { children: React.ReactNode }) {
return (
<div className="flex items-center gap-2">
<span className="relative inline-block h-[16px] w-[27px] align-middle"> <span className="absolute left-0 top-0 h-[16px] w-[16px] rounded-full bg-[#01ACE5]/40" /> <span className="absolute left-[7px] top-0 h-[16px] w-[16px] rounded-full bg-[#6DBB4F]/40" /></span>
<h4 className="text-[20px] font-semibold text-[#1A1A1A]">{children}</h4>
</div>
);
}
// 구분선
function Hr({ className = "" }: { className?: string }) {
return <div className={`h-[2px] w-full bg-black/10 ${className}`} />;
}
type SupplyRow = {
facility: string;
usage: string;
amount: string;
};
function SupplyTable({ rows }: { rows: SupplyRow[] }) {
return (
<div className="mt-8 px-10">
<table className="w-full min-w-[860px] border border-[#D8DDE3] text-center">
<thead>
<tr>
<th className="border border-[#D8DDE3] bg-[#14A9D9] px-4 py-2 text-[18px] font-medium text-white">
시설명
</th>
<th className="border border-[#D8DDE3] bg-[#40B5A5] px-4 py-2 text-[18px] font-medium text-white">
이용용도
</th>
<th className="border border-[#D8DDE3] bg-[#73BF4B] px-4 py-2 text-[18px] font-medium text-white">
공급량(㎥)
</th>
</tr>
</thead>
<tbody>
{rows.map((row, index) => (
<tr key={`${row.facility}-${index}`}>
<td className="border border-[#D8DDE3] bg-white px-4 py-2 text-[16px] text-[#4F4F4F]">
{row.facility}
</td>
<td className="border border-[#D8DDE3] bg-white px-4 py-2 text-[16px] text-[#4F4F4F]">
{row.usage}
</td>
<td className="border border-[#D8DDE3] bg-white px-4 py-2 text-[16px] text-[#4F4F4F]">
{row.amount}
</td>
</tr>
))}
</tbody>
</table>
</div>
);
}
function InfoList({
projectName,
purpose,
totalCost,
treatment,
}: {
projectName: string;
purpose: string;
totalCost: string;
treatment: string;
}) {
return (
<div className="mt-10 space-y-10 px-10">
<div>
<BlockTitle>사업명</BlockTitle>
<ul className="mt-4 list-disc pl-8 text-16px] leading-[1.8] text-[#4F4F4F]">
<li>{projectName}</li>
</ul>
</div>
<div>
<BlockTitle>사업목적</BlockTitle>
<ul className="mt-4 list-disc pl-8 text-16px] leading-[1.8] text-[#4F4F4F]">
<li>{purpose}</li>
</ul>
</div>
<div>
<BlockTitle>사업 주요 내용</BlockTitle>
<ul className="mt-4 list-disc pl-8 text-16px] leading-[1.8] text-[#4F4F4F]">
<li>총사업비 : {totalCost}</li>
<li>처리공법 : {treatment}</li>
</ul>
</div>
<div>
<BlockTitle>사업량</BlockTitle>
</div>
</div>
);
}
import { useEffect, useRef } from "react";
export default function GreywaterExampleSection() {
const title = "중수도 사례";
return (
<div className="bg-white">
{/* 상단 타이틀 */}
<div className="inline-block">
<h2 className="relative inline-block px-2 text-[28px] font-extrabold tracking-tight leading-none text-slate-900">
<span className="relative z-10">{title}</span>
<span className="absolute left-0 bottom-[-4px] z-0 h-3 w-full bg-gradient-to-r from-sky-300 to-green-300" />
const rootRef = useRef<HTMLDivElement>(null);
useEffect(() => {
const root = rootRef.current;
if (!root) return;
const cleanups: Array<() => void> = [];
const sliders = Array.from(root.querySelectorAll(".image-slider"));
sliders.forEach((sliderElement) => {
const slider = sliderElement as HTMLElement;
const viewport = slider.querySelector(
".image-slider-viewport",
) as HTMLElement | null;
const track = slider.querySelector(".image-slider-track") as HTMLElement | null;
const dotsWrap = slider.querySelector(
".image-slider-dots",
) as HTMLElement | null;
const pages = Array.from(
slider.querySelectorAll(".image-slider-page"),
) as HTMLElement[];
if (!viewport || !track || !dotsWrap || pages.length === 0) return;
let page = 0;
let dragging = false;
let isHover = false;
let startX = 0;
let deltaX = 0;
let timer: number | null = null;
const autoPlayMs = Number(slider.dataset.autoplay ?? "4500");
const canSlide = pages.length > 1;
if (!canSlide) {
dotsWrap.style.display = "none";
} else {
dotsWrap.style.display = "";
}
const render = () => {
track.style.transform = `translateX(-${page * 100}%)`;
Array.from(dotsWrap.querySelectorAll("button")).forEach((dot, index) => {
dot.classList.toggle("is-active", index === page);
});
};
const goTo = (nextPage: number) => {
if (!canSlide) return;
if (nextPage < 0) page = pages.length - 1;
else if (nextPage >= pages.length) page = 0;
else page = nextPage;
render();
};
const stopTimer = () => {
if (timer !== null) {
window.clearInterval(timer);
timer = null;
}
};
const startTimer = () => {
stopTimer();
if (!canSlide || dragging || isHover) return;
timer = window.setInterval(() => {
page = page + 1 >= pages.length ? 0 : page + 1;
render();
}, autoPlayMs);
};
const dotButtons = pages.map((_, index) => {
const button = document.createElement("button");
button.type = "button";
button.className = "image-slider-dot";
const onClick = () => {
goTo(index);
startTimer();
};
button.addEventListener("click", onClick);
dotsWrap.appendChild(button);
cleanups.push(() => {
button.removeEventListener("click", onClick);
});
return button;
});
const onMouseEnter = () => {
isHover = true;
stopTimer();
};
const onMouseLeave = () => {
isHover = false;
startTimer();
};
const onPointerDown = (event: PointerEvent) => {
if (!canSlide) return;
dragging = true;
startX = event.clientX;
deltaX = 0;
viewport.setPointerCapture(event.pointerId);
};
const onPointerMove = (event: PointerEvent) => {
if (!dragging) return;
deltaX = event.clientX - startX;
};
const onPointerUp = () => {
if (!dragging) return;
dragging = false;
if (deltaX > 50) goTo(page - 1);
if (deltaX < -50) goTo(page + 1);
deltaX = 0;
startTimer();
};
viewport.addEventListener("mouseenter", onMouseEnter);
viewport.addEventListener("mouseleave", onMouseLeave);
viewport.addEventListener("pointerdown", onPointerDown);
viewport.addEventListener("pointermove", onPointerMove);
viewport.addEventListener("pointerup", onPointerUp);
viewport.addEventListener("pointercancel", onPointerUp);
cleanups.push(() => {
stopTimer();
viewport.removeEventListener("mouseenter", onMouseEnter);
viewport.removeEventListener("mouseleave", onMouseLeave);
viewport.removeEventListener("pointerdown", onPointerDown);
viewport.removeEventListener("pointermove", onPointerMove);
viewport.removeEventListener("pointerup", onPointerUp);
viewport.removeEventListener("pointercancel", onPointerUp);
dotButtons.forEach((button) => button.remove());
});
render();
startTimer();
});
return () => {
cleanups.forEach((cleanup) => cleanup());
};
}, []);
const contentsHtml = `
<div class="contents-page">
<div class="page-title">
<h2>
<span class="title-text">중수도 사례</span>
<span class="title-bg">&nbsp;</span>
</h2>
</div>
<div className="mt-10 pb-[120px]">
<section id="example-greywater-ansan" className="scroll-mt-[120px]">
<SectionTitle>경기도 안산시</SectionTitle>
<InfoList
projectName="안산시 중수도 설치사업"
purpose="수자원을 효율적으로 활용하여 지속가능성 도모 및 물 절약 실현"
totalCost="81억원"
treatment="MBLM공법"
/>
<div class="sections greywater-example-sections">
<section id="example-greywater-ansan" class="section first">
<div class="section-title">
<span class="section-bar">&nbsp;</span>
<h3>경기도 안산시</h3>
</div>
<SupplyTable
rows={[
{ facility: "경기테크노파크", usage: "청소·화장실용수", amount: "40㎥/일" },
{ facility: "올림픽수영장", usage: "청소·화장실/세척·살수용수", amount: "35㎥/일" },
{ facility: "와스타디움", usage: "청소·화장실/조경용수", amount: "90㎥/일" },
{ facility: "호수실내수영장", usage: "청소·화장실/조경/세척·살수용수", amount: "50㎥/일" },
{ facility: "한양대학교 에리카캠퍼스", usage: "청소·화장실용수", amount: "370㎥/일" },
]}
/>
<div class="content-group">
<div class="content-block">
<div class="block-title">
<span class="block-title-icon">
<span class="bubble bubble-sky">&nbsp;</span>
<span class="bubble bubble-green">&nbsp;</span>
</span>
<h4>사업명</h4>
</div>
<ul class="bullet-list">
<li>안산시 중수도 설치사업</li>
</ul>
</div>
<div class="content-block">
<div class="block-title">
<span class="block-title-icon">
<span class="bubble bubble-sky">&nbsp;</span>
<span class="bubble bubble-green">&nbsp;</span>
</span>
<h4>사업목적</h4>
</div>
<ul class="bullet-list">
<li>수자원을 효율적으로 활용하여 지속가능성 도모 및 물 절약 실현</li>
</ul>
</div>
<div class="content-block">
<div class="block-title">
<span class="block-title-icon">
<span class="bubble bubble-sky">&nbsp;</span>
<span class="bubble bubble-green">&nbsp;</span>
</span>
<h4>사업 주요 내용</h4>
</div>
<ul class="bullet-list">
<li>총사업비 : 81억원</li>
<li>처리공법 : MBLM공법</li>
</ul>
</div>
<div class="content-block">
<div class="block-title">
<span class="block-title-icon">
<span class="bubble bubble-sky">&nbsp;</span>
<span class="bubble bubble-green">&nbsp;</span>
</span>
<h4>사업량</h4>
</div>
<div class="table-wrap greywater-table-wrap">
<table class="contents-table greywater-table">
<thead>
<tr>
<th class="th-blue">시설명</th>
<th class="th-teal-greywater">이용용도</th>
<th class="th-green">공급량(㎥)</th>
</tr>
</thead>
<tbody>
<tr><td>경기테크노파크</td><td>청소·화장실용수</td><td>40㎥/일</td></tr>
<tr><td>올림픽수영장</td><td>청소·화장실/세척·살수용수</td><td>35㎥/일</td></tr>
<tr><td>와스타디움</td><td>청소·화장실/조경용수</td><td>90㎥/일</td></tr>
<tr><td>호수실내수영장</td><td>청소·화장실/조경/세척·살수용수</td><td>50㎥/일</td></tr>
<tr><td>한양대학교 에리카캠퍼스</td><td>청소·화장실용수</td><td>370㎥/일</td></tr>
</tbody>
</table>
</div>
</div>
</div>
</section>
<Hr className="my-20" />
<div class="divider"></div>
<section id="example-greywater-yongin" className="scroll-mt-[120px]">
<SectionTitle>경기도 용인시</SectionTitle>
<section id="example-greywater-yongin" class="section">
<div class="section-title">
<span class="section-bar">&nbsp;</span>
<h3>경기도 용인시</h3>
</div>
<InfoList
projectName="용인시 중수도 설치사업"
purpose="물 부족 등의 비상상황에 대한 대비, 하수라도 사용할 집결 등 수자원의 효율적 활용"
totalCost="40억원"
treatment="MBLM공법"
/>
<div class="content-group">
<div class="content-block">
<div class="block-title">
<span class="block-title-icon">
<span class="bubble bubble-sky">&nbsp;</span>
<span class="bubble bubble-green">&nbsp;</span>
</span>
<h4>사업명</h4>
</div>
<ul class="bullet-list">
<li>용인시 중수도 설치사업</li>
</ul>
</div>
<SupplyTable
rows={[
{ facility: "여성회관", usage: "청소·화장실용수", amount: "35㎥/일" },
{ facility: "수지아르피아", usage: "청소·화장실용수", amount: "35㎥/일" },
{ facility: "지상작전사령부", usage: "청소·화장실/조경/세척·살수용수", amount: "53㎥/일" },
]}
/>
<div class="content-block">
<div class="block-title">
<span class="block-title-icon">
<span class="bubble bubble-sky">&nbsp;</span>
<span class="bubble bubble-green">&nbsp;</span>
</span>
<h4>사업목적</h4>
</div>
<ul class="bullet-list">
<li>물 부족 등의 비상상황에 대한 대비, 하수라도 사용할 집결 등 수자원의 효율적 활용</li>
</ul>
</div>
<div class="content-block">
<div class="block-title">
<span class="block-title-icon">
<span class="bubble bubble-sky">&nbsp;</span>
<span class="bubble bubble-green">&nbsp;</span>
</span>
<h4>사업 주요 내용</h4>
</div>
<ul class="bullet-list">
<li>총사업비 : 40억원</li>
<li>처리공법 : MBLM공법</li>
</ul>
</div>
<div class="content-block">
<div class="block-title">
<span class="block-title-icon">
<span class="bubble bubble-sky">&nbsp;</span>
<span class="bubble bubble-green">&nbsp;</span>
</span>
<h4>사업량</h4>
</div>
<div class="table-wrap greywater-table-wrap">
<table class="contents-table greywater-table">
<thead>
<tr>
<th class="th-blue">시설명</th>
<th class="th-teal-greywater">이용용도</th>
<th class="th-green">공급량(㎥)</th>
</tr>
</thead>
<tbody>
<tr><td>여성회관</td><td>청소·화장실용수</td><td>35㎥/일</td></tr>
<tr><td>수지아르피아</td><td>청소·화장실용수</td><td>35㎥/일</td></tr>
<tr><td>지상작전사령부</td><td>청소·화장실/조경/세척·살수용수</td><td>53㎥/일</td></tr>
</tbody>
</table>
</div>
</div>
</div>
</section>
<Hr className="my-20" />
<div class="divider"></div>
<section id="example-greywater-pyeongtaek" className="scroll-mt-[120px]">
<SectionTitle>경기도 평택시</SectionTitle>
<section id="example-greywater-pyeongtaek" class="section">
<div class="section-title">
<span class="section-bar">&nbsp;</span>
<h3>경기도 평택시</h3>
</div>
<InfoList
projectName="평택시 중수도 설치사업"
purpose="수자원을 효율적으로 활용하여 지속가능성 도모 및 물 절약 실현"
totalCost="21억원"
treatment="MBLM공법"
/>
<div class="content-group">
<div class="content-block">
<div class="block-title">
<span class="block-title-icon">
<span class="bubble bubble-sky">&nbsp;</span>
<span class="bubble bubble-green">&nbsp;</span>
</span>
<h4>사업명</h4>
</div>
<ul class="bullet-list">
<li>평택시 중수도 설치사업</li>
</ul>
</div>
<SupplyTable
rows={[
{
facility: "평택대학교",
usage: "청소·화장실용수",
amount: "50㎥/일",
},
]}
/>
<div class="content-block">
<div class="block-title">
<span class="block-title-icon">
<span class="bubble bubble-sky">&nbsp;</span>
<span class="bubble bubble-green">&nbsp;</span>
</span>
<h4>사업목적</h4>
</div>
<ul class="bullet-list">
<li>수자원을 효율적으로 활용하여 지속가능성 도모 및 물 절약 실현</li>
</ul>
</div>
<div class="content-block">
<div class="block-title">
<span class="block-title-icon">
<span class="bubble bubble-sky">&nbsp;</span>
<span class="bubble bubble-green">&nbsp;</span>
</span>
<h4>사업 주요 내용</h4>
</div>
<ul class="bullet-list">
<li>총사업비 : 21억원</li>
<li>처리공법 : MBLM공법</li>
</ul>
</div>
<div class="content-block">
<div class="block-title">
<span class="block-title-icon">
<span class="bubble bubble-sky">&nbsp;</span>
<span class="bubble bubble-green">&nbsp;</span>
</span>
<h4>사업량</h4>
</div>
<div class="table-wrap greywater-table-wrap">
<table class="contents-table greywater-table">
<thead>
<tr>
<th class="th-blue">시설명</th>
<th class="th-teal-greywater">이용용도</th>
<th class="th-green">공급량(㎥)</th>
</tr>
</thead>
<tbody>
<tr><td>평택대학교</td><td>청소·화장실용수</td><td>50㎥/일</td></tr>
</tbody>
</table>
</div>
</div>
</div>
</section>
</div>
</div>
`;
return (
<div
ref={rootRef}
className="bg-white"
dangerouslySetInnerHTML={{ __html: contentsHtml }}
/>
);
}
}
@@ -1,225 +1,382 @@
"use client";
import CaseImageSlider from "@/components/CaseImageSlider";
function SectionTitle({ children }: { children: React.ReactNode }) {
return (
<div className="flex items-center gap-3">
<span className="h-5 w-[3px] bg-sky-500" />
<h3 className="text-2xl font-bold text-neutral-900">{children}</h3>
</div>
);
}
function BlockTitle({ children }: { children: React.ReactNode }) {
return (
<div className="flex items-center gap-2">
<span className="relative inline-block h-[16px] w-[27px] align-middle">
<span className="absolute left-0 top-0 h-[16px] w-[16px] rounded-full bg-[#01ACE5]/40" />
<span className="absolute left-[7px] top-0 h-[16px] w-[16px] rounded-full bg-[#6DBB4F]/40" />
</span>
<h4 className="text-[20px] font-semibold text-[#1A1A1A]">{children}</h4>
</div>
);
}
function Hr({ className = "" }: { className?: string }) {
return <div className={`h-[2px] w-full bg-black/10 ${className}`} />;
}
function InfoList({
projectName,
purpose,
details,
}: {
projectName: string;
purpose: string;
details: string[];
}) {
return (
<div className="mt-10 space-y-9 px-10">
<div>
<BlockTitle>사업명</BlockTitle>
<ul className="mt-4 list-disc pl-8 text-[16px] leading-[1.8] text-[#4F4F4F]">
<li>{projectName}</li>
</ul>
</div>
<div>
<BlockTitle>사업목적</BlockTitle>
<ul className="mt-4 list-disc pl-8 text-[16px] leading-[1.8] text-[#4F4F4F]">
<li>{purpose}</li>
</ul>
</div>
<div>
<BlockTitle>사업 주요 내용</BlockTitle>
<ul className="mt-4 list-disc pl-8 text-[16px] leading-[1.8] text-[#4F4F4F]">
{details.map((item, index) => (
<li key={index}>{item}</li>
))}
</ul>
</div>
</div>
);
}
type RainTableRow = {
facility: string;
storage: string;
supply: string;
pipe: string;
};
function RainTable({ rows }: { rows: RainTableRow[] }) {
return (
<div className="mt-8 overflow-x-auto">
<table className="w-full min-w-[860px] border border-[#D8DDE3] text-center">
<thead>
<tr>
<th className="border border-[#D8DDE3] bg-[#14A9D9] px-4 py-3 text-[18px] font-medium text-white">
시설명
</th>
<th className="border border-[#D8DDE3] bg-[#39B2BD] px-4 py-3 text-[18px] font-medium text-white">
저류조용량(㎥)
</th>
<th className="border border-[#D8DDE3] bg-[#58B99A] px-4 py-3 text-[18px] font-medium text-white">
빗물공급기
</th>
<th className="border border-[#D8DDE3] bg-[#70BF4B] px-4 py-3 text-[18px] font-medium text-white">
빗물분사장치(m)
</th>
</tr>
</thead>
<tbody>
{rows.map((row, index) => (
<tr key={`${row.facility}-${index}`}>
<td className="border border-[#D8DDE3] bg-white px-4 py-3 text-[16px] text-[#4F4F4F]">
{row.facility}
</td>
<td className="border border-[#D8DDE3] bg-white px-4 py-3 text-[16px] text-[#4F4F4F]">
{row.storage}
</td>
{index === 0 && (
<td
rowSpan={rows.length}
className="border border-[#D8DDE3] bg-white px-4 py-3 text-[16px] text-[#4F4F4F]"
>
{row.supply}
</td>
)}
<td className="border border-[#D8DDE3] bg-white px-4 py-3 text-[16px] text-[#4F4F4F]">
{row.pipe}
</td>
</tr>
))}
</tbody>
</table>
</div>
);
}
import { useEffect, useRef } from "react";
export default function RainExampleSection() {
const title = "빗물이용시설 사례";
const rootRef = useRef<HTMLDivElement>(null);
return (
<div className="bg-white">
<div className="inline-block">
<h2 className="relative inline-block px-2 text-[28px] font-extrabold leading-none tracking-tight text-slate-900">
<span className="relative z-10">{title}</span>
<span className="absolute bottom-[-4px] left-0 z-0 h-3 w-full bg-gradient-to-r from-sky-300 to-green-300" />
useEffect(() => {
const root = rootRef.current;
if (!root) return;
const cleanups: Array<() => void> = [];
const sliders = Array.from(root.querySelectorAll(".image-slider"));
sliders.forEach((sliderElement) => {
const slider = sliderElement as HTMLElement;
const viewport = slider.querySelector(".image-slider-viewport") as HTMLElement | null;
const track = slider.querySelector(".image-slider-track") as HTMLElement | null;
const dotsWrap = slider.querySelector(".image-slider-dots") as HTMLElement | null;
const pages = Array.from(
slider.querySelectorAll(".image-slider-page"),
) as HTMLElement[];
if (!viewport || !track || !dotsWrap || pages.length === 0) return;
let page = 0;
let dragging = false;
let isHover = false;
let startX = 0;
let deltaX = 0;
let timer: number | null = null;
const autoPlayMs = Number(slider.dataset.autoplay ?? "4500");
const canSlide = pages.length > 1;
if (!canSlide) {
dotsWrap.style.display = "none";
} else {
dotsWrap.style.display = "";
}
const render = () => {
track.style.transform = `translateX(-${page * 100}%)`;
Array.from(dotsWrap.querySelectorAll("button")).forEach((dot, index) => {
dot.classList.toggle("is-active", index === page);
});
};
const goTo = (nextPage: number) => {
if (!canSlide) return;
if (nextPage < 0) page = pages.length - 1;
else if (nextPage >= pages.length) page = 0;
else page = nextPage;
render();
};
const stopTimer = () => {
if (timer !== null) {
window.clearInterval(timer);
timer = null;
}
};
const startTimer = () => {
stopTimer();
if (!canSlide || dragging || isHover) return;
timer = window.setInterval(() => {
page = page + 1 >= pages.length ? 0 : page + 1;
render();
}, autoPlayMs);
};
const dotButtons = pages.map((_, index) => {
const button = document.createElement("button");
button.type = "button";
button.className = "image-slider-dot";
const onClick = () => {
goTo(index);
startTimer();
};
button.addEventListener("click", onClick);
dotsWrap.appendChild(button);
cleanups.push(() => {
button.removeEventListener("click", onClick);
});
return button;
});
const onMouseEnter = () => {
isHover = true;
stopTimer();
};
const onMouseLeave = () => {
isHover = false;
startTimer();
};
const onPointerDown = (event: PointerEvent) => {
if (!canSlide) return;
dragging = true;
startX = event.clientX;
deltaX = 0;
viewport.setPointerCapture(event.pointerId);
};
const onPointerMove = (event: PointerEvent) => {
if (!dragging) return;
deltaX = event.clientX - startX;
};
const onPointerUp = () => {
if (!dragging) return;
dragging = false;
if (deltaX > 50) goTo(page - 1);
if (deltaX < -50) goTo(page + 1);
deltaX = 0;
startTimer();
};
viewport.addEventListener("mouseenter", onMouseEnter);
viewport.addEventListener("mouseleave", onMouseLeave);
viewport.addEventListener("pointerdown", onPointerDown);
viewport.addEventListener("pointermove", onPointerMove);
viewport.addEventListener("pointerup", onPointerUp);
viewport.addEventListener("pointercancel", onPointerUp);
cleanups.push(() => {
stopTimer();
viewport.removeEventListener("mouseenter", onMouseEnter);
viewport.removeEventListener("mouseleave", onMouseLeave);
viewport.removeEventListener("pointerdown", onPointerDown);
viewport.removeEventListener("pointermove", onPointerMove);
viewport.removeEventListener("pointerup", onPointerUp);
viewport.removeEventListener("pointercancel", onPointerUp);
dotButtons.forEach((button) => button.remove());
});
render();
startTimer();
});
return () => {
cleanups.forEach((cleanup) => cleanup());
};
}, []);
const contentsHtml = `
<div class="contents-page">
<div class="page-title">
<h2>
<span class="title-text">빗물이용시설 사례</span>
<span class="title-bg">&nbsp;</span>
</h2>
</div>
<div className="mt-10 pb-[120px]">
<section id="example-rain-suwon" className="scroll-mt-[120px]">
<SectionTitle>경기도 수원시</SectionTitle>
<div class="sections rain-example-sections">
<section id="example-rain-suwon" class="section first">
<div class="section-title">
<span class="section-bar">&nbsp;</span>
<h3>경기도 수원시</h3>
</div>
<CaseImageSlider
className="mt-8"
maxWidthClassName="max-w-[1240px]"
slides={[
{ src: "/images/landing/business/rain/suwon_01.png", alt: "수원시 빗물이용시설 사례 1" },
{ src: "/images/landing/business/rain/suwon_02.png", alt: "수원시 빗물이용시설 사례 2" },
{ src: "/images/landing/business/rain/suwon_03.png", alt: "수원시 빗물이용시설 사례 3" },
{ src: "/images/landing/business/rain/suwon_04.png", alt: "수원시 빗물이용시설 사례 4" },
{ src: "/images/landing/business/rain/suwon_05.png", alt: "수원시 빗물이용시설 사례 5" },
]}
/>
<div class="image-slider" data-autoplay="4500">
<div class="image-slider-inner">
<div class="image-slider-viewport">
<div class="image-slider-track">
<div class="image-slider-page">
<div class="image-slide">
<img src="/images/landing/business/rain/suwon_01.png" alt="수원시 빗물이용시설 사례 1" />
</div>
<div class="image-slide">
<img src="/images/landing/business/rain/suwon_02.png" alt="수원시 빗물이용시설 사례 2" />
</div>
</div>
<div class="image-slider-page">
<div class="image-slide">
<img src="/images/landing/business/rain/suwon_03.png" alt="수원시 빗물이용시설 사례 3" />
</div>
<div class="image-slide">
<img src="/images/landing/business/rain/suwon_04.png" alt="수원시 빗물이용시설 사례 4" />
</div>
</div>
<div class="image-slider-page">
<div class="image-slide">
<img src="/images/landing/business/rain/suwon_05.png" alt="수원시 빗물이용시설 사례 5" />
</div>
</div>
</div>
</div>
<div class="image-slider-dots"></div>
</div>
</div>
<InfoList
projectName="수원시 빗물이용시설 설치사업"
purpose="월드컵경기장 등 4개소에 노면 빗물분사시스템 및 빗물공급기를 설치하여 축서기 도시 열섬효과 및 도로 비산먼지 저감"
details={[
"총사업비 : 20억원",
"위치 : 수원시 영통구, 팔달구 및 장안구 일원",
]}
/>
<div class="content-group">
<div class="content-block">
<div class="block-title">
<span class="block-title-icon">
<span class="bubble bubble-sky">&nbsp;</span>
<span class="bubble bubble-green">&nbsp;</span>
</span>
<h4>사업명</h4>
</div>
<ul class="bullet-list">
<li>수원시 빗물이용시설 설치사업</li>
</ul>
</div>
<div className="mt-9 px-10">
<BlockTitle>사업량</BlockTitle>
<div class="content-block">
<div class="block-title">
<span class="block-title-icon">
<span class="bubble bubble-sky">&nbsp;</span>
<span class="bubble bubble-green">&nbsp;</span>
</span>
<h4>사업목적</h4>
</div>
<ul class="bullet-list">
<li>월드컵경기장 등 4개소에 노면 빗물분사시스템 및 빗물공급기를 설치하여 축서기 도시 열섬효과 및 도로 비산먼지 저감</li>
</ul>
</div>
<RainTable
rows={[
{
facility: "광교중학교",
storage: "4,500",
supply: "1대",
pipe: "160",
},
{
facility: "수원월드컵경기장",
storage: "12,000",
supply: "1대",
pipe: "451",
},
{
facility: "수원종합운동장",
storage: "10,000",
supply: "1대",
pipe: "231",
},
{
facility: "수원컨벤션센터",
storage: "3,600",
supply: "1대",
pipe: "460",
},
]}
/>
<div class="content-block">
<div class="block-title">
<span class="block-title-icon">
<span class="bubble bubble-sky">&nbsp;</span>
<span class="bubble bubble-green">&nbsp;</span>
</span>
<h4>사업 주요 내용</h4>
</div>
<ul class="bullet-list">
<li>총사업비 : 20억원</li>
<li>위치 : 수원시 영통구, 팔달구 및 장안구 일원</li>
</ul>
</div>
<div class="content-block">
<div class="block-title">
<span class="block-title-icon">
<span class="bubble bubble-sky">&nbsp;</span>
<span class="bubble bubble-green">&nbsp;</span>
</span>
<h4>사업량</h4>
</div>
<div class="table-wrap">
<table class="contents-table rain-table">
<thead>
<tr>
<th class="th-blue">시설명</th>
<th class="th-teal">저류조용량(㎥)</th>
<th class="th-mint">빗물공급기</th>
<th class="th-green">빗물분사장치(m)</th>
</tr>
</thead>
<tbody>
<tr>
<td>광교중학교</td>
<td>4,500</td>
<td rowspan="4">1대</td>
<td>160</td>
</tr>
<tr>
<td>수원월드컵경기장</td>
<td>12,000</td>
<td>451</td>
</tr>
<tr>
<td>수원종합운동장</td>
<td>10,000</td>
<td>231</td>
</tr>
<tr>
<td>수원컨벤션센터</td>
<td>3,600</td>
<td>460</td>
</tr>
</tbody>
</table>
</div>
</div>
</div>
</section>
<Hr className="my-20" />
<div class="divider"></div>
<section id="example-rain-jeju" className="scroll-mt-[120px]">
<SectionTitle>제주특별자치도</SectionTitle>
<section id="example-rain-jeju" class="section">
<div class="section-title">
<span class="section-bar">&nbsp;</span>
<h3>제주특별자치도</h3>
</div>
<CaseImageSlider
className="mt-8"
maxWidthClassName="max-w-[1240px]"
slides={[
{ src: "/images/landing/business/rain/jeju_01.png", alt: "제주특별자치도 빗물이용시설 사례 1" },
{ src: "/images/landing/business/rain/jeju_02.png", alt: "제주특별자치도 빗물이용시설 사례 2" },
{ src: "/images/landing/business/rain/jeju_03.png", alt: "제주특별자치도 빗물이용시설 사례 3" },
]}
/>
<div class="image-slider" data-autoplay="4500">
<div class="image-slider-inner">
<div class="image-slider-viewport">
<div class="image-slider-track">
<div class="image-slider-page">
<div class="image-slide">
<img src="/images/landing/business/rain/jeju_01.png" alt="제주특별자치도 빗물이용시설 사례 1" />
</div>
<div class="image-slide">
<img src="/images/landing/business/rain/jeju_02.png" alt="제주특별자치도 빗물이용시설 사례 2" />
</div>
</div>
<div class="image-slider-page">
<div class="image-slide">
<img src="/images/landing/business/rain/jeju_03.png" alt="제주특별자치도 빗물이용시설 사례 3" />
</div>
</div>
</div>
</div>
<div class="image-slider-dots"></div>
</div>
</div>
<InfoList
projectName="제주특별자치도 중규모 빗물이용시설 설치사업"
purpose="강우시 비닐하우스에서 발생하는 대체수자원인 빗물을 순환 활용하고자 하는 사업으로, 지하수 사용 저감과 집중호우시 도로와 농경지 침수피해 예방"
details={[
"총사업비 : 278억원",
"위치 : 제주특별자치도 서귀포시 남원읍 위미리 일원",
"사업량 : 저류조 7,000㎥, 배수갑 500㎡, 관로 21.7km",
"사업기간 : 2024. 5. ~ 2028. 12.",
]}
/>
<div class="content-group">
<div class="content-block">
<div class="block-title">
<span class="block-title-icon">
<span class="bubble bubble-sky">&nbsp;</span>
<span class="bubble bubble-green">&nbsp;</span>
</span>
<h4>사업명</h4>
</div>
<ul class="bullet-list">
<li>제주특별자치도 중규모 빗물이용시설 설치사업</li>
</ul>
</div>
<div class="content-block">
<div class="block-title">
<span class="block-title-icon">
<span class="bubble bubble-sky">&nbsp;</span>
<span class="bubble bubble-green">&nbsp;</span>
</span>
<h4>사업목적</h4>
</div>
<ul class="bullet-list">
<li>강우시 비닐하우스에서 발생하는 대체수자원인 빗물을 순환 활용하고자 하는 사업으로, 지하수 사용 저감과 집중호우시 도로와 농경지 침수피해 예방</li>
</ul>
</div>
<div class="content-block">
<div class="block-title">
<span class="block-title-icon">
<span class="bubble bubble-sky">&nbsp;</span>
<span class="bubble bubble-green">&nbsp;</span>
</span>
<h4>사업 주요 내용</h4>
</div>
<ul class="bullet-list">
<li>총사업비 : 278억원</li>
<li>위치 : 제주특별자치도 서귀포시 남원읍 위미리 일원</li>
<li>사업량 : 저류조 7,000㎥, 배수갑 500㎡, 관로 21.7km</li>
<li>사업기간 : 2024. 5. ~ 2028. 12.</li>
</ul>
</div>
</div>
</section>
</div>
</div>
`;
return (
<div
ref={rootRef}
className="bg-white"
dangerouslySetInnerHTML={{ __html: contentsHtml }}
/>
);
}
}
@@ -1,367 +1,582 @@
"use client";
import CaseImageSlider from "@/components/CaseImageSlider";
type SewageCase = {
id: string;
title: string;
slides: {
src: string;
alt: string;
}[];
projectName: string;
purpose: string[];
method?: string[];
details: string[];
note?: string[];
};
function SectionTitle({ children }: { children: React.ReactNode }) {
return (
<div className="flex items-center gap-3">
<span className="h-5 w-[3px] bg-sky-500" />
<h3 className="text-2xl font-bold text-neutral-900">{children}</h3>
</div>
);
}
function BlockTitle({ children }: { children: React.ReactNode }) {
return (
<div className="flex items-center gap-2">
<span className="relative inline-block h-[16px] w-[27px] align-middle">
<span className="absolute left-0 top-0 h-[16px] w-[16px] rounded-full bg-[#01ACE5]/40" />
<span className="absolute left-[7px] top-0 h-[16px] w-[16px] rounded-full bg-[#6DBB4F]/40" />
</span>
<h4 className="text-[20px] font-semibold text-[#1A1A1A]">{children}</h4>
</div>
);
}
function Hr({ className = "" }: { className?: string }) {
return <div className={`h-[2px] w-full bg-black/10 ${className}`} />;
}
function InfoBlock({
title,
items,
}: {
title: string;
items?: string[];
}) {
if (!items || items.length === 0) return null;
return (
<div>
<BlockTitle>{title}</BlockTitle>
<ul className="mt-4 list-disc space-y-2 pl-8 text-[16px] leading-[1.8] text-[#4F4F4F]">
{items.map((item, index) => (
<li key={`${title}-${index}`}>{item}</li>
))}
</ul>
</div>
);
}
function InfoList({ item }: { item: SewageCase }) {
return (
<div className="mt-10 space-y-9 px-10">
<InfoBlock title="사업명" items={[item.projectName]} />
<InfoBlock title="사업목적" items={item.purpose} />
<InfoBlock title="사업방식" items={item.method} />
<InfoBlock title="사업 주요 내용" items={item.details} />
<InfoBlock title="특이사항" items={item.note} />
</div>
);
}
const sewageCases: SewageCase[] = [
{
id: "example-sewage-guri",
title: "경기도 구리시",
slides: [
{
src: "/images/landing/business/sewage/guri_01.png",
alt: "구리시 도로자동청소시스템",
},
{
src: "/images/landing/business/sewage/guri_02.png",
alt: "구리시 도로자동청소시스템 위치도",
},
{
src: "/images/landing/business/sewage/guri_03.png",
alt: "구리시 도로자동청소시스템 위치도2",
},
{
src: "/images/landing/business/sewage/guri_04.png",
alt: "구리시 도로자동청소시스템 원리",
},
],
projectName: "구리시 도로자동청소시스템 설치사업",
purpose: [
"고농도 미세먼지 발생 및 폭염의 급증에 따라 공공하수처리시설 발생 재이용수 일부를 도로자동청소시스템에 활용하여 미세먼지 저감 및 폭염 취약성 완화",
],
details: [
"총사업비 : 61억원",
"위치 : 구리시 경춘로 및 갈매중앙로 일대 등",
"관로 : 5.8km",
"공급량 : 178㎥/일",
],
},
{
id: "example-sewage-bucheon",
title: "경기도 부천시",
slides: [
{
src: "/images/landing/business/sewage/bucheon_01.png",
alt: "부천시 하수 재이용수 생산시설",
},
{
src: "/images/landing/business/sewage/bucheon_02.png",
alt: "부천시 하수처리수 재이용 사례",
},
],
projectName: "부천시 하수 재이용수 생산시설 설치 운영",
purpose: [
"굴포하수처리시설 하수처리수 재이용수 생산시설 설치 운영을 통해 여월천 등 부천 관내 하천의 건천화 방지, 하천 생태계 보존 및 주민들에게 친수 공간 제공",
],
details: [
"총사업비 : 212억원",
"위치 : 부천시 여월천, 삼정천, 심곡천 및 시민의 강",
"관로 : 23.6km",
"공급량 : 50,000㎥/일",
],
},
{
id: "example-sewage-suwon",
title: "경기도 수원시",
slides: [
{
src: "/images/landing/business/sewage/suwon_01.png",
alt: "수원시 황구지천1",
},
{
src: "/images/landing/business/sewage/suwon_02.png",
alt: "수원시 황구지천2",
},
{
src: "/images/landing/business/sewage/suwon_03.png",
alt: "수원시 황구지천 지도",
},
],
projectName: "수원시 황구지천 하수처리수 재이용사업",
purpose: [
"황구지천 공공하수처리시설 하수처리수 재이용사업으로 황구 호매실천 및 금곡천 건천화 방지로 주민들에게 친수 공간 제공 및 하천 생태계 보전",
],
details: [
"총사업비 : 110억원",
"위치 : 수원시 권선구 호매실천, 금곡천",
"관로 : 3.9km",
"공급량 : 25,000㎥/일(하수처리시설 용량 : 45,000㎥/일)",
],
},
{
id: "example-sewage-paju",
title: "경기도 파주시",
slides: [
{
src: "/images/landing/business/sewage/paju_01.png",
alt: "파주시 하수처리수 시설 전경1",
},
{
src: "/images/landing/business/sewage/paju_02.png",
alt: "파주시 하수처리수 시설 전경2",
},
],
projectName: "파주시 하수처리수 재이용사업",
purpose: [
"운정 및 금촌하수처리시설의 하수처리수를 막공법으로 재처리하여 파주LCD산업단지 내 3개 업체에 공업용수를 공급(LG디스플레이, 희성전자, 엔비텍)",
"하수처리수 재이용시설 자체 농축폐수 처리공정 개발 및 적용 성공사례",
],
method: ["민간투자사업(시행사 : 파주그린허브(주), 운영사 : 테크로스)"],
details: [
"총사업비 : 651억원",
"위치 : 파주시 금촌동 714번지 일원",
"관로 : 13.6km",
"공급량 : 40,000㎥/일(시설용량 : 41,200㎥/일)",
],
},
{
id: "example-sewage-pohang",
title: "경상북도 포항시",
slides: [
{
src: "/images/landing/business/sewage/pohang_01.png",
alt: "포항시 하수처리수 전경1",
},
{
src: "/images/landing/business/sewage/pohang_02.png",
alt: "포항시 하수처리수 전경2",
},
{
src: "/images/landing/business/sewage/pohang_03.png",
alt: "포항시 하수처리수 재이용 공급계통",
},
],
projectName: "포항시 하수처리수 재이용사업",
purpose: [
"포항시 내 공단 개발 확대로 지속적이고 안정적인 공업용수 공급 추진(포스코, 동국산업 등)",
],
method: ["민간투자사업(운영사 : ㈜포웰, 한국수자원공사)"],
details: [
"총사업비 : 1,400억원",
"위치 : 포항시 남구 상도동 125번지 일원",
"관로 : 11.7km",
"공급량 : 100,000㎥/일(시설용량 : 100,000㎥/일)",
"처리공법 : 전처리시설(U/F) + 주처리시설(R/O)",
],
},
{
id: "example-sewage-ulsan",
title: "울산광역시",
slides: [
{
src: "/images/landing/business/sewage/ulsan_01.png",
alt: "울산광역시 폐수처리수 전경1",
},
{
src: "/images/landing/business/sewage/ulsan_02.png",
alt: "울산광역시 폐수처리수 전경2",
},
{
src: "/images/landing/business/sewage/ulsan_03.png",
alt: "울산광역시 폐수처리수 시설1",
},
{
src: "/images/landing/business/sewage/ulsan_04.png",
alt: "울산광역시 폐수처리수 시설2",
},
],
projectName: "울산광역시 폐수처리수 재이용사업",
purpose: [
"’18년 갈수기시 낙동강 수질 악화로 울산석유화학단지 용수 공급 부족을 해소하기 위해 미포산단 내 한화케미칼 등 8개사 공업용수 공급",
],
method: ["민간투자사업(시행사 :울산화이텍워터(주))"],
details: [
"총사업비 : 1,729억원",
"위치 : 울산광역시 울주군 청량읍 상개로 14 일원",
"관로 : 11.7km",
"공급량 : 17,000㎥/일(시설용량 : 86,000㎥/일)",
"처리공법 : 전처리시설(U/F) + 주처리시설(R/O, EDI)",
],
},
{
id: "example-sewage-yeosu",
title: "전라남도 여수시",
slides: [
{
src: "/images/landing/business/sewage/yeosu_01.png",
alt: "여수시 하수처리수 재이용시설1",
},
{
src: "/images/landing/business/sewage/yeosu_02.png",
alt: "여수시 하수처리수 재이용시설2",
},
],
projectName: "여수시 하수처리수 재이용사업",
purpose: [
"수요자가 원하는 양질의 공업용수를 저렴하게 공급하여 한정된 수자원의 효율적 이용 및 지역적 물 부족 해소, 하수재이용을 통한 오염총량 저감 및 방류수역 수질개선을 위한 여수국가산단 내 공업용수 공급(GS칼텍스, YNCC 등)",
],
method: ["민간투자사업(시행사 : 여수그린허브(주), 운영사 : 테크로스)"],
details: [
"총사업비 : 750억원",
"위치 : 여수시 신월로 284 일원",
"관로 : 18.6km",
"공급량 : 50,000㎥/일(시설용량 : 50,000㎥/일)",
"처리공법 : 전처리시설(U/F) + 주처리시설(R/O)",
],
},
{
id: "example-sewage-boryeong",
title: "충청남도 보령시",
slides: [
{
src: "/images/landing/business/sewage/boryeong_01.png",
alt: "보령시 하수처리수 재이용시설1",
},
{
src: "/images/landing/business/sewage/boryeong_02.png",
alt: "보령시 하수처리수 재이용시설2",
},
],
projectName: "보령시 하수처리수 재이용사업",
purpose: [
"한국중부발전(주)의 보령 및 신보령화력발전소에 10,000㎥/일의 탈황용수 공급을 위해 보령하수처리시설의 하수처리수 재이용 사업 추진",
"국내 유일 하수재이용 공업용수 공급 재정사업으로 2016년 충남 서부권 가뭄 시 지역사회 기여 차원에서 기관간 MOU(기후에너지환경부, 충청남도, 보령시, 한국중부발전(주))를 바탕으로 추진되어 경제성보다 공익성을 바탕으로 국가전력 기반시설의 안정적 운영 도모",
],
method: ["재정사업(2017년 한국환경공단과 사업대행 위·수탁협약 체결)"],
details: [
"총사업비 : 315억원",
"위치 : 보령시 대천방조제로 322 일원",
"관로 : 8.7km",
"공급량 : 10,000㎥/일(시설용량 : 10,000㎥/일)",
"처리공법 : 전처리시설(U/F) + 주처리시설(R/O)",
],
note: [
"국내 유일 하수재이용 공업용수 공급 재정사업으로 2016년 충남 서부권 가뭄 시 지역사회 기여 차원에서 기관간 MOU(환경부, 충청남도, 보령시, 한국중부발전㈜)를 바탕으로 추진 되어 경제성보다 공익성을 바탕으로 국가전력 기반시설의 안정적 운영 도모",
],
},
{
id: "example-sewage-asan",
title: "충청남도 아산시",
slides: [
{
src: "/images/landing/business/sewage/asan_01.png",
alt: "아산시 하수처리수 재이용시설",
},
{
src: "/images/landing/business/sewage/asan_02.png",
alt: "아산시 하수처리수 재이용 공정도",
},
],
projectName: "아산시 하수처리수 재이용사업",
purpose: [
"신도시 개발에 따른 인구 급증으로 발생하는 하수를 재처리하여 탕정산업단지(삼성디스플레이)에 공업용수를 안정적으로 공급함으로써 지역기반 및 활성화를 도모",
],
method: ["민간투자사업(시행사 : 아산스마트원터, 운영사 : 한국수자원공사)"],
details: [
"총사업비 : 297억원",
"위치 : 아산시 탕정면 갈산리 807 일원",
"관로 : 4.2km",
"공급량 : 27,000㎥/일(시설용량 : 45,000㎥/일)",
"처리공법 : 전처리시설(U/F) + 주처리시설(R/O)",
],
},
];
import { useEffect, useRef } from "react";
export default function SewageExampleSection() {
const title = "하수처리수 재이용 사례";
const rootRef = useRef<HTMLDivElement>(null);
return (
<div className="bg-white">
<div className="inline-block">
<h2 className="relative inline-block px-2 text-[28px] font-extrabold leading-none tracking-tight text-slate-900">
<span className="relative z-10">{title}</span>
<span className="absolute bottom-[-4px] left-0 z-0 h-3 w-full bg-gradient-to-r from-sky-300 to-green-300" />
useEffect(() => {
const root = rootRef.current;
if (!root) return;
const cleanups: Array<() => void> = [];
const sliders = Array.from(root.querySelectorAll(".image-slider"));
sliders.forEach((sliderElement) => {
const slider = sliderElement as HTMLElement;
const viewport = slider.querySelector(
".image-slider-viewport",
) as HTMLElement | null;
const track = slider.querySelector(".image-slider-track") as HTMLElement | null;
const dotsWrap = slider.querySelector(
".image-slider-dots",
) as HTMLElement | null;
const pages = Array.from(
slider.querySelectorAll(".image-slider-page"),
) as HTMLElement[];
if (!viewport || !track || !dotsWrap || pages.length === 0) return;
let page = 0;
let dragging = false;
let isHover = false;
let startX = 0;
let deltaX = 0;
let timer: number | null = null;
const autoPlayMs = Number(slider.dataset.autoplay ?? "4500");
const canSlide = pages.length > 1;
if (!canSlide) {
dotsWrap.style.display = "none";
} else {
dotsWrap.style.display = "";
}
const render = () => {
track.style.transform = `translateX(-${page * 100}%)`;
Array.from(dotsWrap.querySelectorAll("button")).forEach((dot, index) => {
dot.classList.toggle("is-active", index === page);
});
};
const goTo = (nextPage: number) => {
if (!canSlide) return;
if (nextPage < 0) page = pages.length - 1;
else if (nextPage >= pages.length) page = 0;
else page = nextPage;
render();
};
const stopTimer = () => {
if (timer !== null) {
window.clearInterval(timer);
timer = null;
}
};
const startTimer = () => {
stopTimer();
if (!canSlide || dragging || isHover) return;
timer = window.setInterval(() => {
page = page + 1 >= pages.length ? 0 : page + 1;
render();
}, autoPlayMs);
};
const dotButtons = pages.map((_, index) => {
const button = document.createElement("button");
button.type = "button";
button.className = "image-slider-dot";
const onClick = () => {
goTo(index);
startTimer();
};
button.addEventListener("click", onClick);
dotsWrap.appendChild(button);
cleanups.push(() => {
button.removeEventListener("click", onClick);
});
return button;
});
const onMouseEnter = () => {
isHover = true;
stopTimer();
};
const onMouseLeave = () => {
isHover = false;
startTimer();
};
const onPointerDown = (event: PointerEvent) => {
if (!canSlide) return;
dragging = true;
startX = event.clientX;
deltaX = 0;
viewport.setPointerCapture(event.pointerId);
};
const onPointerMove = (event: PointerEvent) => {
if (!dragging) return;
deltaX = event.clientX - startX;
};
const onPointerUp = () => {
if (!dragging) return;
dragging = false;
if (deltaX > 50) goTo(page - 1);
if (deltaX < -50) goTo(page + 1);
deltaX = 0;
startTimer();
};
viewport.addEventListener("mouseenter", onMouseEnter);
viewport.addEventListener("mouseleave", onMouseLeave);
viewport.addEventListener("pointerdown", onPointerDown);
viewport.addEventListener("pointermove", onPointerMove);
viewport.addEventListener("pointerup", onPointerUp);
viewport.addEventListener("pointercancel", onPointerUp);
cleanups.push(() => {
stopTimer();
viewport.removeEventListener("mouseenter", onMouseEnter);
viewport.removeEventListener("mouseleave", onMouseLeave);
viewport.removeEventListener("pointerdown", onPointerDown);
viewport.removeEventListener("pointermove", onPointerMove);
viewport.removeEventListener("pointerup", onPointerUp);
viewport.removeEventListener("pointercancel", onPointerUp);
dotButtons.forEach((button) => button.remove());
});
render();
startTimer();
});
return () => {
cleanups.forEach((cleanup) => cleanup());
};
}, []);
const contentsHtml = `
<div class="contents-page">
<div class="page-title">
<h2>
<span class="title-text">하수처리수 재이용 사례</span>
<span class="title-bg">&nbsp;</span>
</h2>
</div>
<div className="mt-10 pb-[120px]">
{sewageCases.map((item, index) => (
<section key={item.id} id={item.id} className="scroll-mt-[120px]">
<SectionTitle>{item.title}</SectionTitle>
<div class="sections sewage-example-sections">
<section id="example-sewage-guri" class="section first">
<div class="section-title">
<span class="section-bar">&nbsp;</span>
<h3>경기도 구리시</h3>
</div>
<CaseImageSlider
className="mt-8"
maxWidthClassName="max-w-[1240px]"
slides={item.slides}
/>
<div class="image-slider" data-autoplay="4500">
<div class="image-slider-inner">
<div class="image-slider-viewport">
<div class="image-slider-track">
<div class="image-slider-page">
<div class="image-slide"><img src="/images/landing/business/sewage/guri_01.png" alt="구리시 도로자동청소시스템" /></div>
<div class="image-slide"><img src="/images/landing/business/sewage/guri_02.png" alt="구리시 도로자동청소시스템 위치도" /></div>
</div>
<div class="image-slider-page">
<div class="image-slide"><img src="/images/landing/business/sewage/guri_03.png" alt="구리시 도로자동청소시스템 위치도2" /></div>
<div class="image-slide"><img src="/images/landing/business/sewage/guri_04.png" alt="구리시 도로자동청소시스템 원리" /></div>
</div>
</div>
</div>
<div class="image-slider-dots"></div>
</div>
</div>
<InfoList item={item} />
<div class="content-group">
<div class="content-block">
<div class="block-title"><span class="block-title-icon"><span class="bubble bubble-sky">&nbsp;</span><span class="bubble bubble-green">&nbsp;</span></span><h4>사업명</h4></div>
<ul class="bullet-list"><li>구리시 도로자동청소시스템 설치사업</li></ul>
</div>
<div class="content-block">
<div class="block-title"><span class="block-title-icon"><span class="bubble bubble-sky">&nbsp;</span><span class="bubble bubble-green">&nbsp;</span></span><h4>사업목적</h4></div>
<ul class="bullet-list"><li>고농도 미세먼지 발생 및 폭염의 급증에 따라 공공하수처리시설 발생 재이용수 일부를 도로자동청소시스템에 활용하여 미세먼지 저감 및 폭염 취약성 완화</li></ul>
</div>
<div class="content-block">
<div class="block-title"><span class="block-title-icon"><span class="bubble bubble-sky">&nbsp;</span><span class="bubble bubble-green">&nbsp;</span></span><h4>사업 주요 내용</h4></div>
<ul class="bullet-list">
<li>총사업비 : 61억원</li>
<li>위치 : 구리시 경춘로 및 갈매중앙로 일대 등</li>
<li>관로 : 5.8km</li>
<li>공급량 : 178㎥/일</li>
</ul>
</div>
</div>
</section>
{index !== sewageCases.length - 1 && <Hr className="my-20" />}
</section>
))}
<div class="divider"></div>
<section id="example-sewage-bucheon" class="section">
<div class="section-title"><span class="section-bar">&nbsp;</span><h3>경기도 부천시</h3></div>
<div class="image-slider" data-autoplay="4500">
<div class="image-slider-inner">
<div class="image-slider-viewport">
<div class="image-slider-track">
<div class="image-slider-page">
<div class="image-slide"><img src="/images/landing/business/sewage/bucheon_01.png" alt="부천시 하수 재이용수 생산시설" /></div>
<div class="image-slide"><img src="/images/landing/business/sewage/bucheon_02.png" alt="부천시 하수처리수 재이용 사례" /></div>
</div>
</div>
</div>
<div class="image-slider-dots"></div>
</div>
</div>
<div class="content-group">
<div class="content-block">
<div class="block-title"><span class="block-title-icon"><span class="bubble bubble-sky">&nbsp;</span><span class="bubble bubble-green">&nbsp;</span></span><h4>사업명</h4></div>
<ul class="bullet-list"><li>부천시 하수 재이용수 생산시설 설치 운영</li></ul>
</div>
<div class="content-block">
<div class="block-title"><span class="block-title-icon"><span class="bubble bubble-sky">&nbsp;</span><span class="bubble bubble-green">&nbsp;</span></span><h4>사업목적</h4></div>
<ul class="bullet-list"><li>굴포하수처리시설 하수처리수 재이용수 생산시설 설치 운영을 통해 여월천 등 부천 관내 하천의 건천화 방지, 하천 생태계 보존 및 주민들에게 친수 공간 제공</li></ul>
</div>
<div class="content-block">
<div class="block-title"><span class="block-title-icon"><span class="bubble bubble-sky">&nbsp;</span><span class="bubble bubble-green">&nbsp;</span></span><h4>사업 주요 내용</h4></div>
<ul class="bullet-list">
<li>총사업비 : 212억원</li>
<li>위치 : 부천시 여월천, 삼정천, 심곡천 및 시민의 강</li>
<li>관로 : 23.6km</li>
<li>공급량 : 50,000㎥/일</li>
</ul>
</div>
</div>
</section>
<div class="divider"></div>
<section id="example-sewage-suwon" class="section">
<div class="section-title"><span class="section-bar">&nbsp;</span><h3>경기도 수원시</h3></div>
<div class="image-slider" data-autoplay="4500">
<div class="image-slider-inner">
<div class="image-slider-viewport">
<div class="image-slider-track">
<div class="image-slider-page">
<div class="image-slide"><img src="/images/landing/business/sewage/suwon_01.png" alt="수원시 황구지천1" /></div>
<div class="image-slide"><img src="/images/landing/business/sewage/suwon_02.png" alt="수원시 황구지천2" /></div>
</div>
<div class="image-slider-page">
<div class="image-slide"><img src="/images/landing/business/sewage/suwon_03.png" alt="수원시 황구지천 지도" /></div>
</div>
</div>
</div>
<div class="image-slider-dots"></div>
</div>
</div>
<div class="content-group">
<div class="content-block">
<div class="block-title"><span class="block-title-icon"><span class="bubble bubble-sky">&nbsp;</span><span class="bubble bubble-green">&nbsp;</span></span><h4>사업명</h4></div>
<ul class="bullet-list"><li>수원시 황구지천 하수처리수 재이용사업</li></ul>
</div>
<div class="content-block">
<div class="block-title"><span class="block-title-icon"><span class="bubble bubble-sky">&nbsp;</span><span class="bubble bubble-green">&nbsp;</span></span><h4>사업목적</h4></div>
<ul class="bullet-list"><li>황구지천 공공하수처리시설 하수처리수 재이용사업으로 황구 호매실천 및 금곡천 건천화 방지로 주민들에게 친수 공간 제공 및 하천 생태계 보전</li></ul>
</div>
<div class="content-block">
<div class="block-title"><span class="block-title-icon"><span class="bubble bubble-sky">&nbsp;</span><span class="bubble bubble-green">&nbsp;</span></span><h4>사업 주요 내용</h4></div>
<ul class="bullet-list">
<li>총사업비 : 110억원</li>
<li>위치 : 수원시 권선구 호매실천, 금곡천</li>
<li>관로 : 3.9km</li>
<li>공급량 : 25,000㎥/일(하수처리시설 용량 : 45,000㎥/일)</li>
</ul>
</div>
</div>
</section>
<div class="divider"></div>
<section id="example-sewage-paju" class="section">
<div class="section-title"><span class="section-bar">&nbsp;</span><h3>경기도 파주시</h3></div>
<div class="image-slider" data-autoplay="4500">
<div class="image-slider-inner">
<div class="image-slider-viewport">
<div class="image-slider-track">
<div class="image-slider-page">
<div class="image-slide"><img src="/images/landing/business/sewage/paju_01.png" alt="파주시 하수처리수 시설 전경1" /></div>
<div class="image-slide"><img src="/images/landing/business/sewage/paju_02.png" alt="파주시 하수처리수 시설 전경2" /></div>
</div>
</div>
</div>
<div class="image-slider-dots"></div>
</div>
</div>
<div class="content-group">
<div class="content-block">
<div class="block-title"><span class="block-title-icon"><span class="bubble bubble-sky">&nbsp;</span><span class="bubble bubble-green">&nbsp;</span></span><h4>사업명</h4></div>
<ul class="bullet-list"><li>파주시 하수처리수 재이용사업</li></ul>
</div>
<div class="content-block">
<div class="block-title"><span class="block-title-icon"><span class="bubble bubble-sky">&nbsp;</span><span class="bubble bubble-green">&nbsp;</span></span><h4>사업목적</h4></div>
<ul class="bullet-list">
<li>운정 및 금촌하수처리시설의 하수처리수를 막공법으로 재처리하여 파주LCD산업단지 내 3개 업체에 공업용수를 공급(LG디스플레이, 희성전자, 엔비텍)</li>
<li>하수처리수 재이용시설 자체 농축폐수 처리공정 개발 및 적용 성공사례</li>
</ul>
</div>
<div class="content-block">
<div class="block-title"><span class="block-title-icon"><span class="bubble bubble-sky">&nbsp;</span><span class="bubble bubble-green">&nbsp;</span></span><h4>사업방식</h4></div>
<ul class="bullet-list"><li>민간투자사업(시행사 : 파주그린허브(주), 운영사 : 테크로스)</li></ul>
</div>
<div class="content-block">
<div class="block-title"><span class="block-title-icon"><span class="bubble bubble-sky">&nbsp;</span><span class="bubble bubble-green">&nbsp;</span></span><h4>사업 주요 내용</h4></div>
<ul class="bullet-list">
<li>총사업비 : 651억원</li>
<li>위치 : 파주시 금촌동 714번지 일원</li>
<li>관로 : 13.6km</li>
<li>공급량 : 40,000㎥/일(시설용량 : 41,200㎥/일)</li>
</ul>
</div>
</div>
</section>
<div class="divider"></div>
<section id="example-sewage-pohang" class="section">
<div class="section-title"><span class="section-bar">&nbsp;</span><h3>경상북도 포항시</h3></div>
<div class="image-slider" data-autoplay="4500">
<div class="image-slider-inner">
<div class="image-slider-viewport">
<div class="image-slider-track">
<div class="image-slider-page">
<div class="image-slide"><img src="/images/landing/business/sewage/pohang_01.png" alt="포항시 하수처리수 전경1" /></div>
<div class="image-slide"><img src="/images/landing/business/sewage/pohang_02.png" alt="포항시 하수처리수 전경2" /></div>
</div>
<div class="image-slider-page">
<div class="image-slide"><img src="/images/landing/business/sewage/pohang_03.png" alt="포항시 하수처리수 재이용 공급계통" /></div>
</div>
</div>
</div>
<div class="image-slider-dots"></div>
</div>
</div>
<div class="content-group">
<div class="content-block">
<div class="block-title"><span class="block-title-icon"><span class="bubble bubble-sky">&nbsp;</span><span class="bubble bubble-green">&nbsp;</span></span><h4>사업명</h4></div>
<ul class="bullet-list"><li>포항시 하수처리수 재이용사업</li></ul>
</div>
<div class="content-block">
<div class="block-title"><span class="block-title-icon"><span class="bubble bubble-sky">&nbsp;</span><span class="bubble bubble-green">&nbsp;</span></span><h4>사업목적</h4></div>
<ul class="bullet-list"><li>포항시 내 공단 개발 확대로 지속적이고 안정적인 공업용수 공급 추진(포스코, 동국산업 등)</li></ul>
</div>
<div class="content-block">
<div class="block-title"><span class="block-title-icon"><span class="bubble bubble-sky">&nbsp;</span><span class="bubble bubble-green">&nbsp;</span></span><h4>사업방식</h4></div>
<ul class="bullet-list"><li>민간투자사업(운영사 : ㈜포웰, 한국수자원공사)</li></ul>
</div>
<div class="content-block">
<div class="block-title"><span class="block-title-icon"><span class="bubble bubble-sky">&nbsp;</span><span class="bubble bubble-green">&nbsp;</span></span><h4>사업 주요 내용</h4></div>
<ul class="bullet-list">
<li>총사업비 : 1,400억원</li>
<li>위치 : 포항시 남구 상도동 125번지 일원</li>
<li>관로 : 11.7km</li>
<li>공급량 : 100,000㎥/일(시설용량 : 100,000㎥/일)</li>
<li>처리공법 : 전처리시설(U/F) + 주처리시설(R/O)</li>
</ul>
</div>
</div>
</section>
<div class="divider"></div>
<section id="example-sewage-ulsan" class="section">
<div class="section-title"><span class="section-bar">&nbsp;</span><h3>울산광역시</h3></div>
<div class="image-slider" data-autoplay="4500">
<div class="image-slider-inner">
<div class="image-slider-viewport">
<div class="image-slider-track">
<div class="image-slider-page">
<div class="image-slide"><img src="/images/landing/business/sewage/ulsan_01.png" alt="울산광역시 폐수처리수 전경1" /></div>
<div class="image-slide"><img src="/images/landing/business/sewage/ulsan_02.png" alt="울산광역시 폐수처리수 전경2" /></div>
</div>
<div class="image-slider-page">
<div class="image-slide"><img src="/images/landing/business/sewage/ulsan_03.png" alt="울산광역시 폐수처리수 시설1" /></div>
<div class="image-slide"><img src="/images/landing/business/sewage/ulsan_04.png" alt="울산광역시 폐수처리수 시설2" /></div>
</div>
</div>
</div>
<div class="image-slider-dots"></div>
</div>
</div>
<div class="content-group">
<div class="content-block">
<div class="block-title"><span class="block-title-icon"><span class="bubble bubble-sky">&nbsp;</span><span class="bubble bubble-green">&nbsp;</span></span><h4>사업명</h4></div>
<ul class="bullet-list"><li>울산광역시 폐수처리수 재이용사업</li></ul>
</div>
<div class="content-block">
<div class="block-title"><span class="block-title-icon"><span class="bubble bubble-sky">&nbsp;</span><span class="bubble bubble-green">&nbsp;</span></span><h4>사업목적</h4></div>
<ul class="bullet-list"><li>’18년 갈수기시 낙동강 수질 악화로 울산석유화학단지 용수 공급 부족을 해소하기 위해 미포산단 내 한화케미칼 등 8개사 공업용수 공급</li></ul>
</div>
<div class="content-block">
<div class="block-title"><span class="block-title-icon"><span class="bubble bubble-sky">&nbsp;</span><span class="bubble bubble-green">&nbsp;</span></span><h4>사업방식</h4></div>
<ul class="bullet-list"><li>민간투자사업(시행사 :울산화이텍워터(주))</li></ul>
</div>
<div class="content-block">
<div class="block-title"><span class="block-title-icon"><span class="bubble bubble-sky">&nbsp;</span><span class="bubble bubble-green">&nbsp;</span></span><h4>사업 주요 내용</h4></div>
<ul class="bullet-list">
<li>총사업비 : 1,729억원</li>
<li>위치 : 울산광역시 울주군 청량읍 상개로 14 일원</li>
<li>관로 : 11.7km</li>
<li>공급량 : 17,000㎥/일(시설용량 : 86,000㎥/일)</li>
<li>처리공법 : 전처리시설(U/F) + 주처리시설(R/O, EDI)</li>
</ul>
</div>
</div>
</section>
<div class="divider"></div>
<section id="example-sewage-yeosu" class="section">
<div class="section-title"><span class="section-bar">&nbsp;</span><h3>전라남도 여수시</h3></div>
<div class="image-slider" data-autoplay="4500">
<div class="image-slider-inner">
<div class="image-slider-viewport">
<div class="image-slider-track">
<div class="image-slider-page">
<div class="image-slide"><img src="/images/landing/business/sewage/yeosu_01.png" alt="여수시 하수처리수 재이용시설1" /></div>
<div class="image-slide"><img src="/images/landing/business/sewage/yeosu_02.png" alt="여수시 하수처리수 재이용시설2" /></div>
</div>
</div>
</div>
<div class="image-slider-dots"></div>
</div>
</div>
<div class="content-group">
<div class="content-block">
<div class="block-title"><span class="block-title-icon"><span class="bubble bubble-sky">&nbsp;</span><span class="bubble bubble-green">&nbsp;</span></span><h4>사업명</h4></div>
<ul class="bullet-list"><li>여수시 하수처리수 재이용사업</li></ul>
</div>
<div class="content-block">
<div class="block-title"><span class="block-title-icon"><span class="bubble bubble-sky">&nbsp;</span><span class="bubble bubble-green">&nbsp;</span></span><h4>사업목적</h4></div>
<ul class="bullet-list"><li>수요자가 원하는 양질의 공업용수를 저렴하게 공급하여 한정된 수자원의 효율적 이용 및 지역적 물 부족 해소, 하수재이용을 통한 오염총량 저감 및 방류수역 수질개선을 위한 여수국가산단 내 공업용수 공급(GS칼텍스, YNCC 등)</li></ul>
</div>
<div class="content-block">
<div class="block-title"><span class="block-title-icon"><span class="bubble bubble-sky">&nbsp;</span><span class="bubble bubble-green">&nbsp;</span></span><h4>사업방식</h4></div>
<ul class="bullet-list"><li>민간투자사업(시행사 : 여수그린허브(주), 운영사 : 테크로스)</li></ul>
</div>
<div class="content-block">
<div class="block-title"><span class="block-title-icon"><span class="bubble bubble-sky">&nbsp;</span><span class="bubble bubble-green">&nbsp;</span></span><h4>사업 주요 내용</h4></div>
<ul class="bullet-list">
<li>총사업비 : 750억원</li>
<li>위치 : 여수시 신월로 284 일원</li>
<li>관로 : 18.6km</li>
<li>공급량 : 50,000㎥/일(시설용량 : 50,000㎥/일)</li>
<li>처리공법 : 전처리시설(U/F) + 주처리시설(R/O)</li>
</ul>
</div>
</div>
</section>
<div class="divider"></div>
<section id="example-sewage-boryeong" class="section">
<div class="section-title"><span class="section-bar">&nbsp;</span><h3>충청남도 보령시</h3></div>
<div class="image-slider" data-autoplay="4500">
<div class="image-slider-inner">
<div class="image-slider-viewport">
<div class="image-slider-track">
<div class="image-slider-page">
<div class="image-slide"><img src="/images/landing/business/sewage/boryeong_01.png" alt="보령시 하수처리수 재이용시설1" /></div>
<div class="image-slide"><img src="/images/landing/business/sewage/boryeong_02.png" alt="보령시 하수처리수 재이용시설2" /></div>
</div>
</div>
</div>
<div class="image-slider-dots"></div>
</div>
</div>
<div class="content-group">
<div class="content-block">
<div class="block-title"><span class="block-title-icon"><span class="bubble bubble-sky">&nbsp;</span><span class="bubble bubble-green">&nbsp;</span></span><h4>사업명</h4></div>
<ul class="bullet-list"><li>보령시 하수처리수 재이용사업</li></ul>
</div>
<div class="content-block">
<div class="block-title"><span class="block-title-icon"><span class="bubble bubble-sky">&nbsp;</span><span class="bubble bubble-green">&nbsp;</span></span><h4>사업목적</h4></div>
<ul class="bullet-list">
<li>한국중부발전(주)의 보령 및 신보령화력발전소에 10,000㎥/일의 탈황용수 공급을 위해 보령하수처리시설의 하수처리수 재이용 사업 추진</li>
<li>국내 유일 하수재이용 공업용수 공급 재정사업으로 2016년 충남 서부권 가뭄 시 지역사회 기여 차원에서 기관간 MOU(기후에너지환경부, 충청남도, 보령시, 한국중부발전(주))를 바탕으로 추진되어 경제성보다 공익성을 바탕으로 국가전력 기반시설의 안정적 운영 도모</li>
</ul>
</div>
<div class="content-block">
<div class="block-title"><span class="block-title-icon"><span class="bubble bubble-sky">&nbsp;</span><span class="bubble bubble-green">&nbsp;</span></span><h4>사업방식</h4></div>
<ul class="bullet-list"><li>재정사업(2017년 한국환경공단과 사업대행 위·수탁협약 체결)</li></ul>
</div>
<div class="content-block">
<div class="block-title"><span class="block-title-icon"><span class="bubble bubble-sky">&nbsp;</span><span class="bubble bubble-green">&nbsp;</span></span><h4>사업 주요 내용</h4></div>
<ul class="bullet-list">
<li>총사업비 : 315억원</li>
<li>위치 : 보령시 대천방조제로 322 일원</li>
<li>관로 : 8.7km</li>
<li>공급량 : 10,000㎥/일(시설용량 : 10,000㎥/일)</li>
<li>처리공법 : 전처리시설(U/F) + 주처리시설(R/O)</li>
</ul>
</div>
<div class="content-block">
<div class="block-title"><span class="block-title-icon"><span class="bubble bubble-sky">&nbsp;</span><span class="bubble bubble-green">&nbsp;</span></span><h4>특이사항</h4></div>
<ul class="bullet-list"><li>국내 유일 하수재이용 공업용수 공급 재정사업으로 2016년 충남 서부권 가뭄 시 지역사회 기여 차원에서 기관간 MOU(환경부, 충청남도, 보령시, 한국중부발전㈜)를 바탕으로 추진 되어 경제성보다 공익성을 바탕으로 국가전력 기반시설의 안정적 운영 도모</li></ul>
</div>
</div>
</section>
<div class="divider"></div>
<section id="example-sewage-asan" class="section">
<div class="section-title"><span class="section-bar">&nbsp;</span><h3>충청남도 아산시</h3></div>
<div class="image-slider" data-autoplay="4500">
<div class="image-slider-inner">
<div class="image-slider-viewport">
<div class="image-slider-track">
<div class="image-slider-page">
<div class="image-slide"><img src="/images/landing/business/sewage/asan_01.png" alt="아산시 하수처리수 재이용시설" /></div>
<div class="image-slide"><img src="/images/landing/business/sewage/asan_02.png" alt="아산시 하수처리수 재이용 공정도" /></div>
</div>
</div>
</div>
<div class="image-slider-dots"></div>
</div>
</div>
<div class="content-group">
<div class="content-block">
<div class="block-title"><span class="block-title-icon"><span class="bubble bubble-sky">&nbsp;</span><span class="bubble bubble-green">&nbsp;</span></span><h4>사업명</h4></div>
<ul class="bullet-list"><li>아산시 하수처리수 재이용사업</li></ul>
</div>
<div class="content-block">
<div class="block-title"><span class="block-title-icon"><span class="bubble bubble-sky">&nbsp;</span><span class="bubble bubble-green">&nbsp;</span></span><h4>사업목적</h4></div>
<ul class="bullet-list"><li>신도시 개발에 따른 인구 급증으로 발생하는 하수를 재처리하여 탕정산업단지(삼성디스플레이)에 공업용수를 안정적으로 공급함으로써 지역기반 및 활성화를 도모</li></ul>
</div>
<div class="content-block">
<div class="block-title"><span class="block-title-icon"><span class="bubble bubble-sky">&nbsp;</span><span class="bubble bubble-green">&nbsp;</span></span><h4>사업방식</h4></div>
<ul class="bullet-list"><li>민간투자사업(시행사 : 아산스마트원터, 운영사 : 한국수자원공사)</li></ul>
</div>
<div class="content-block">
<div class="block-title"><span class="block-title-icon"><span class="bubble bubble-sky">&nbsp;</span><span class="bubble bubble-green">&nbsp;</span></span><h4>사업 주요 내용</h4></div>
<ul class="bullet-list">
<li>총사업비 : 297억원</li>
<li>위치 : 아산시 탕정면 갈산리 807 일원</li>
<li>관로 : 4.2km</li>
<li>공급량 : 27,000㎥/일(시설용량 : 45,000㎥/일)</li>
<li>처리공법 : 전처리시설(U/F) + 주처리시설(R/O)</li>
</ul>
</div>
</div>
</section>
</div>
</div>
`;
return (
<div
ref={rootRef}
className="bg-white"
dangerouslySetInnerHTML={{ __html: contentsHtml }}
/>
);
}
}
@@ -0,0 +1,8 @@
import { ContentsPage } from "@/components/ui/contents";
export default function ExampleSection() {
const contsSn = Number(process.env.CONTENTS_EXAMPLE ?? 32);
const menuCd = "24";
return <ContentsPage contsSn={contsSn} menuCd={menuCd} />;
}
+10 -7
View File
@@ -1,13 +1,15 @@
/**
* 물 재이용 사례
*/
export const dynamic = "force-dynamic";
import SubPageHeader from "@/components/SubPageHeader";
import BusinessSubNav, { type BusinessSubNavMenu } from "@/components/BusinessSubNav";
import RainExampleSection from "./_components/RainExampleSection";
import GreywaterExampleSection from "./_components/GreywaterExampleSection";
import SewageExampleSection from "./_components/SewageExampleSection";
import GlobalCaseSection from "./_components/GlobalCaseSection";
import ExampleSection from "./exampleSection";
// import RainExampleSection from "./_components/RainExampleSection";
// import GreywaterExampleSection from "./_components/GreywaterExampleSection";
// import SewageExampleSection from "./_components/SewageExampleSection";
// import GlobalCaseSection from "./_components/GlobalCaseSection";
const menus: BusinessSubNavMenu[] = [
{
@@ -78,11 +80,12 @@ export default function ExamplePage() {
<BusinessSubNav menus={menus} offsetTop={120} />
<div className="max-w-[1320px] pb-24">
<RainExampleSection />
<ExampleSection />
{/* <RainExampleSection />
<GreywaterExampleSection />
<SewageExampleSection />
<GlobalCaseSection />
<GlobalCaseSection /> */}
</div>
</div>
);
}
}
@@ -1,123 +1,93 @@
"use client";
import React from "react";
import Image, { type StaticImageData } from "next/image";
import lawPlan from "@/public/images/landing/intro/law_timeline.png";
import planPerformance from "@/public/images/landing/intro/performance_left.png";
import domesticChange from "@/public/images/landing/intro/domestic_textbox.png";
import policyHistory from "@/public/images/landing/intro/policy_timeline.png";
import lawRelation from "@/public/images/landing/intro/rel_note.png";
import domesticLinkage from "@/public/images/landing/intro/linkage.png";
function SectionTitle({ children }: { children: React.ReactNode }) {
return (
<div className="flex items-center gap-3">
<span className="h-5 w-[3px] bg-sky-500" />
<h3 className="text-[24px] font-bold text-neutral-900">{children}</h3>
</div>
);
}
function Hr({ className = "" }: { className?: string }) {
return <div className={`h-[2px] w-full bg-black/10 ${className}`} />;
}
function ImgCard({ src, alt }: { src: StaticImageData; alt: string }) {
return (
<div className="w-full overflow-x-auto">
<Image
src={src}
alt={alt}
className="mx-auto block max-w-none"
style={{ width: "auto", height: "auto" }}
/>
</div>
);
}
function SectionShell({
id,
title,
children,
className = "",
}: {
id: string;
title: string;
children: React.ReactNode;
className?: string;
}) {
return (
<section id={id} className={`scroll-mt-[120px] ${className}`}>
<SectionTitle>{title}</SectionTitle>
<div className="mt-8">{children}</div>
</section>
);
}
export default function LawIntroSection() {
const title = "물재이용법";
return (
<div className="bg-white">
<div className="inline-block">
<h2 className="relative inline-block px-2 text-[28px] font-extrabold leading-none tracking-tight text-slate-900">
<span className="relative z-10">{title}</span>
<span className="absolute bottom-[-4px] left-0 z-0 h-3 w-full bg-gradient-to-r from-sky-300 to-green-300" />
const contentsHtml = `
<div class="contents-page">
<div class="page-title">
<h2>
<span class="title-text">물재이용법</span>
<span class="title-bg">&nbsp;</span>
</h2>
</div>
<div className="mt-10">
<SectionShell
id="law-plan"
title="법률 제·개정 및 기본계획 수립"
className="mt-10"
>
<ImgCard src={lawPlan} alt="법률 제·개정 및 기본계획 수립" />
</SectionShell>
<div class="sections">
<section id="law-plan" class="section first">
<div class="section-title">
<span class="section-bar">&nbsp;</span>
<h3>법률 제·개정 및 기본계획 수립</h3>
</div>
<div class="image-wrap">
<img src="/images/landing/intro/law_timeline.png" alt="법률 제·개정 및 기본계획 수립" />
</div>
</section>
<Hr className="my-11" />
<div class="divider"></div>
<SectionShell
id="plan-performance"
title="제2차 계획 대비 실적 현황"
>
<ImgCard src={planPerformance} alt="제2차 계획 대비 실적 현황" />
</SectionShell>
<section id="plan-performance" class="section">
<div class="section-title">
<span class="section-bar">&nbsp;</span>
<h3>제2차 계획 대비 실적 현황</h3>
</div>
<div class="image-wrap">
<img src="/images/landing/intro/performance_left.png" alt="제2차 계획 대비 실적 현황" />
</div>
</section>
<Hr className="my-11" />
<div class="divider"></div>
<SectionShell id="domestic-change" title="우리나라의 수자원 현황 및 물이용 여건">
<ImgCard src={domesticChange} alt="우리나라의 수자원 현황 및 물이용 여건" />
</SectionShell>
<section id="domestic-change" class="section">
<div class="section-title">
<span class="section-bar">&nbsp;</span>
<h3>우리나라의 수자원 현황 및 물이용 여건</h3>
</div>
<div class="image-wrap">
<img src="/images/landing/intro/domestic_textbox.png" alt="우리나라의 수자원 현황 및 물이용 여건" />
</div>
</section>
<Hr className="my-11" />
<div class="divider"></div>
<SectionShell
id="policy-history"
title="물 재이용 정책 변화 추이"
>
<ImgCard src={policyHistory} alt="물 재이용 정책 변화 추이" />
</SectionShell>
<section id="policy-history" class="section">
<div class="section-title">
<span class="section-bar">&nbsp;</span>
<h3>물 재이용 정책 변화 추이</h3>
</div>
<div class="image-wrap">
<img src="/images/landing/intro/policy_timeline.png" alt="물 재이용 정책 변화 추이" />
</div>
</section>
<Hr className="my-11" />
<div class="divider"></div>
<SectionShell
id="law-relation"
title="타법령과의 관계 검토"
>
<ImgCard src={lawRelation} alt="타법령과의 관계 검토" />
</SectionShell>
<section id="law-relation" class="section">
<div class="section-title">
<span class="section-bar">&nbsp;</span>
<h3>타법령과의 관계 검토</h3>
</div>
<div class="image-wrap">
<img src="/images/landing/intro/rel_note.png" alt="타법령과의 관계 검토" />
</div>
</section>
<Hr className="my-11" />
<div class="divider"></div>
<SectionShell
id="domestic-linkage"
title="주요 국가계획의 정책방향과 연계한 물 재이용 정책 필요"
>
<ImgCard src={domesticLinkage} alt="주요 국가계획의 정책방향과 연계한 물 재이용 정책 필요" />
</SectionShell>
<section id="domestic-linkage" class="section">
<div class="section-title">
<span class="section-bar">&nbsp;</span>
<h3>주요 국가계획의 정책방향과 연계한 물 재이용 정책 필요</h3>
</div>
<div class="image-wrap">
<img src="/images/landing/intro/linkage.png" alt="주요 국가계획의 정책방향과 연계한 물 재이용 정책 필요" />
</div>
</section>
</div>
</div>
`;
return (
<div
className="bg-white"
dangerouslySetInnerHTML={{ __html: contentsHtml }}
/>
);
}
}
@@ -0,0 +1,232 @@
import { ContentsPage } from "@/components/ui/contents";
export default function IntroSection() {
// const contentsHtml = `
// <div class="contents-page">
// <div class="page-title">
// <h2>
// <span class="title-text">물재이용법</span>
// <span class="title-bg">&nbsp;</span>
// </h2>
// </div>
// <div class="sections">
// <section id="law-plan" class="section first">
// <div class="section-title">
// <span class="section-bar">&nbsp;</span>
// <h3>법률 제·개정 및 기본계획 수립</h3>
// </div>
// <div class="image-wrap">
// <img src="/images/landing/intro/law_timeline.png" alt="법률 제·개정 및 기본계획 수립" />
// </div>
// </section>
// <div class="divider"></div>
// <section id="plan-performance" class="section">
// <div class="section-title">
// <span class="section-bar">&nbsp;</span>
// <h3>제2차 계획 대비 실적 현황</h3>
// </div>
// <div class="image-wrap">
// <img src="/images/landing/intro/performance_left.png" alt="제2차 계획 대비 실적 현황" />
// </div>
// </section>
// <div class="divider"></div>
// <section id="domestic-change" class="section">
// <div class="section-title">
// <span class="section-bar">&nbsp;</span>
// <h3>우리나라의 수자원 현황 및 물이용 여건</h3>
// </div>
// <div class="image-wrap">
// <img src="/images/landing/intro/domestic_textbox.png" alt="우리나라의 수자원 현황 및 물이용 여건" />
// </div>
// </section>
// <div class="divider"></div>
// <section id="policy-history" class="section">
// <div class="section-title">
// <span class="section-bar">&nbsp;</span>
// <h3>물 재이용 정책 변화 추이</h3>
// </div>
// <div class="image-wrap">
// <img src="/images/landing/intro/policy_timeline.png" alt="물 재이용 정책 변화 추이" />
// </div>
// </section>
// <div class="divider"></div>
// <section id="law-relation" class="section">
// <div class="section-title">
// <span class="section-bar">&nbsp;</span>
// <h3>타법령과의 관계 검토</h3>
// </div>
// <div class="image-wrap">
// <img src="/images/landing/intro/rel_note.png" alt="타법령과의 관계 검토" />
// </div>
// </section>
// <div class="divider"></div>
// <section id="domestic-linkage" class="section">
// <div class="section-title">
// <span class="section-bar">&nbsp;</span>
// <h3>주요 국가계획의 정책방향과 연계한 물 재이용 정책 필요</h3>
// </div>
// <div class="image-wrap">
// <img src="/images/landing/intro/linkage.png" alt="주요 국가계획의 정책방향과 연계한 물 재이용 정책 필요" />
// </div>
// </section>
// </div>
// </div>
// <div class="contents-page" style="margin-top:120px;">
// <div class="page-title">
// <h2>
// <span class="title-text">빗물이용시설</span>
// <span class="title-bg">&nbsp;</span>
// </h2>
// </div>
// <div class="sections">
// <section id="rain-intro" class="section first">
// <div class="section-title">
// <span class="section-bar">&nbsp;</span>
// <h3>빗물 재이용 소개</h3>
// </div>
// <div class="image-wrap">
// <img src="/images/landing/intro/rainreuse_intro.png" alt="빗물 재이용 소개" />
// </div>
// </section>
// <div class="divider"></div>
// <section id="rain-mandatory" class="section">
// <div class="section-title">
// <span class="section-bar">&nbsp;</span>
// <h3>빗물이용시설 설치 관리 법적 의무 대상</h3>
// </div>
// <div class="image-wrap">
// <img src="/images/landing/intro/rainreuse_mandatory.png" alt="빗물이용시설 설치 관리 법적 의무 대상" />
// </div>
// </section>
// <div class="divider"></div>
// <section id="rain-design" class="section">
// <div class="section-title">
// <span class="section-bar">&nbsp;</span>
// <h3>빗물이용시설 설계 방안</h3>
// </div>
// <div class="image-wrap">
// <img src="/images/landing/intro/rainwater_design.png" alt="빗물이용시설 설계 방안" />
// </div>
// </section>
// <div class="divider"></div>
// <section id="rain-standards" class="section">
// <div class="section-title">
// <span class="section-bar">&nbsp;</span>
// <h3>빗물이용시설 시설기준 및 관리기준</h3>
// </div>
// <div class="image-wrap">
// <img src="/images/landing/intro/rainwater_standards.png" alt="빗물이용시설 시설기준 및 관리기준" />
// </div>
// </section>
// </div>
// </div>
// <div class="contents-page" style="margin-top:120px;">
// <div class="page-title">
// <h2>
// <span class="title-text">중수도</span>
// <span class="title-bg">&nbsp;</span>
// </h2>
// </div>
// <div class="sections">
// <section id="greywater-intro" class="section first">
// <div class="section-title">
// <span class="section-bar">&nbsp;</span>
// <h3>중수 재이용 소개</h3>
// </div>
// <div class="image-wrap">
// <img src="/images/landing/intro/greywater_intro.png" alt="중수 재이용 소개" />
// </div>
// </section>
// <div class="divider"></div>
// <section id="greywater-mandatory" class="section">
// <div class="section-title">
// <span class="section-bar">&nbsp;</span>
// <h3>중수도 설치 의무 대상</h3>
// </div>
// <div class="image-wrap">
// <img src="/images/landing/intro/greywater_mandatory.png" alt="중수도 설치 의무 대상" />
// </div>
// </section>
// <div class="divider"></div>
// <section id="greywater-standard" class="section">
// <div class="section-title">
// <span class="section-bar">&nbsp;</span>
// <h3>중수도 시설기준 및 관리기준</h3>
// </div>
// <div class="image-wrap">
// <img src="/images/landing/intro/greywater_standard.png" alt="중수도 시설기준 및 관리기준" />
// </div>
// </section>
// </div>
// </div>
// <div class="contents-page" style="margin-top:120px;">
// <div class="page-title">
// <h2>
// <span class="title-text">하수처리수 재이용</span>
// <span class="title-bg">&nbsp;</span>
// </h2>
// </div>
// <div class="sections">
// <section id="treatedwater-intro" class="section first">
// <div class="section-title">
// <span class="section-bar">&nbsp;</span>
// <h3>하수처리수 소개</h3>
// </div>
// <div class="image-wrap">
// <img src="/images/landing/intro/treatedwater_intro.png" alt="하수처리수" />
// </div>
// </section>
// <div class="divider"></div>
// <section id="treatedwater-mandatory" class="section">
// <div class="section-title">
// <span class="section-bar">&nbsp;</span>
// <h3>하수처리수 재이용시설 의무 대상</h3>
// </div>
// <div class="image-wrap">
// <img src="/images/landing/intro/treatedwater_mandatory.png" alt="하수처리수 재이용시설 의무 대상" />
// </div>
// </section>
// </div>
// </div>
// `;
const contsSn = Number(process.env.CONTENTS_INTRO ?? 31);
const menuCd = "24";
return <ContentsPage contsSn={contsSn} menuCd={menuCd} />;
// return (
// <div
// className="bg-white"
// dangerouslySetInnerHTML={{ __html: contentsHtml }}
// />
// );
}
+5 -9
View File
@@ -1,10 +1,9 @@
export const dynamic = "force-dynamic";
// DM 물 재이용 소개 페이지
import BusinessSubNav, { type BusinessSubNavMenu } from "@/components/BusinessSubNav";
import SubPageHeader from "@/components/SubPageHeader";
import LawIntroSection from "./_components/LawIntroSection";
import RainIntroSection from "./_components/RainIntroSection";
import ReuseWaterIntroSection from "./_components/ReuseWaterIntroSection";
import TreatedIntroSection from "./_components/TreatedIntroSection";
import IntroSection from "./introSection";
const menus: BusinessSubNavMenu[] = [
{
@@ -76,11 +75,8 @@ export default function IntroPage() {
{/* 본문 */}
<div className="max-w-[1320px] pb-24">
<LawIntroSection />
<RainIntroSection />
<ReuseWaterIntroSection />
<TreatedIntroSection />
<IntroSection />
</div>
</div>
);
}
}
+565
View File
@@ -0,0 +1,565 @@
.contents-page {
color: #0f172a;
font-size: 16px;
line-height: 1.5;
}
.contents-page .page-title {
display: inline-block;
}
.contents-page .page-title h2 {
position: relative;
display: inline-block;
padding: 0 0.5rem;
font-size: 28px;
font-weight: 800;
line-height: 1;
letter-spacing: -0.025em;
color: #0f172a;
}
.contents-page .page-title .title-text {
position: relative;
z-index: 1;
}
.contents-page .page-title .title-bg {
position: absolute;
left: 0;
bottom: -4px;
z-index: 0;
height: 12px;
width: 100%;
background: linear-gradient(to right, #7dd3fc, #86efac);
}
.contents-page .sections {
margin-top: 2.5rem;
}
.contents-page .section {
scroll-margin-top: 120px;
}
.contents-page .section.first {
margin-top: 2.5rem;
}
.contents-page .section-title {
display: flex;
align-items: center;
gap: 0.75rem;
}
.contents-page .section-bar {
height: 20px;
width: 3px;
flex-shrink: 0;
background: #0ea5e9;
}
.contents-page .section-title h3 {
font-size: 24px;
font-weight: 700;
line-height: 1.2;
color: #171717;
}
.contents-page .image-wrap {
margin-top: 2rem;
width: 100%;
overflow-x: auto;
}
.contents-page .image-wrap img {
display: block;
margin: 0 auto;
max-width: none;
height: auto;
}
.contents-page .divider {
height: 2px;
width: 100%;
margin: 2.75rem 0;
background: rgba(0, 0, 0, 0.1);
}
.contents-page .rain-example-sections {
padding-bottom: 120px;
}
.contents-page .image-slider {
margin-top: 2rem;
}
.contents-page .image-slider-inner {
max-width: 1240px;
margin: 0 auto;
}
.contents-page .image-slider-viewport {
overflow: hidden;
user-select: none;
touch-action: pan-y;
cursor: grab;
}
.contents-page .image-slider-viewport:active {
cursor: grabbing;
}
.contents-page .image-slider-track {
display: flex;
transition: transform 0.5s ease-out;
}
.contents-page .image-slider-page {
display: grid;
grid-template-columns: repeat(2, minmax(0, 1fr));
gap: 1.25rem;
min-width: 100%;
}
.contents-page .image-slide {
position: relative;
height: 340px;
overflow: hidden;
border-radius: 20px;
background: #f5f5f5;
}
.contents-page .image-slide img {
display: block;
width: 100%;
height: 100%;
object-fit: cover;
}
.contents-page .image-slider-dots {
display: flex;
align-items: center;
justify-content: center;
gap: 6px;
margin-top: 1.25rem;
}
.contents-page .image-slider-dot {
width: 8px;
height: 8px;
border: 0;
border-radius: 9999px;
background: #d8dde3;
transition: all 0.2s ease;
cursor: pointer;
}
.contents-page .image-slider-dot.is-active {
background: #18bceb;
}
@media (max-width: 768px) {
.contents-page .image-slider-page {
grid-template-columns: 1fr;
}
.contents-page .image-slide {
height: 280px;
}
}
.contents-page .content-group {
margin-top: 2.5rem;
padding: 0 2.5rem;
}
.contents-page .content-block + .content-block {
margin-top: 2.25rem;
}
.contents-page .block-title {
display: flex;
align-items: center;
gap: 0.5rem;
}
.contents-page .block-title-icon {
position: relative;
display: inline-block;
width: 27px;
height: 16px;
flex-shrink: 0;
}
.contents-page .block-title-icon .bubble {
position: absolute;
top: 0;
width: 16px;
height: 16px;
border-radius: 9999px;
}
.contents-page .block-title-icon .bubble-sky {
left: 0;
background: rgba(1, 172, 229, 0.4);
}
.contents-page .block-title-icon .bubble-green {
left: 7px;
background: rgba(109, 187, 79, 0.4);
}
.contents-page .block-title h4 {
font-size: 20px;
font-weight: 600;
color: #1a1a1a;
}
.contents-page .bullet-list {
margin-top: 1rem;
padding-left: 2rem;
color: #4f4f4f;
font-size: 16px;
line-height: 1.8;
list-style: disc;
}
.contents-page .table-wrap {
margin-top: 2rem;
overflow-x: auto;
}
.contents-page .contents-table {
width: 100%;
min-width: 860px;
border-collapse: collapse;
border: 1px solid #d8dde3;
text-align: center;
}
.contents-page .contents-table th,
.contents-page .contents-table td {
border: 1px solid #d8dde3;
padding: 0.75rem 1rem;
}
.contents-page .contents-table th {
font-size: 18px;
font-weight: 500;
color: #fff;
}
.contents-page .contents-table td {
background: #fff;
font-size: 16px;
color: #4f4f4f;
}
.contents-page .contents-table .th-blue {
background: #14a9d9;
}
.contents-page .contents-table .th-teal {
background: #39b2bd;
}
.contents-page .contents-table .th-mint {
background: #58b99a;
}
.contents-page .contents-table .th-green {
background: #70bf4b;
}
.contents-page .contents-table .th-teal-greywater {
background: #40b5a5;
}
.contents-page .contents-table th.th-gray,
.contents-page .global-table th.th-gray {
background: #bfc9d2;
color: #222;
}
.contents-page .contents-table th.th-blue,
.contents-page .global-table th.th-blue {
background: #14a9d9;
color: #fff;
}
.contents-page .global-table th.th-teal-2 {
background: #55bda5;
color: #fff;
}
.contents-page .global-table th.th-green-2 {
background: #6ab84e;
color: #fff;
}
.contents-page .bg-cyan {
background: #eaf7f8 !important;
}
.contents-page .bg-green {
background: #eaf8e6 !important;
}
.contents-page .global-case-sections {
padding-bottom: 120px;
}
.contents-page .greywater-example-sections {
padding-bottom: 120px;
}
.contents-page .sewage-example-sections {
padding-bottom: 120px;
}
.contents-page .global-content-group {
gap: 0;
}
.contents-page .content-rich-list {
margin-top: 1rem;
color: #4f4f4f;
font-size: 16px;
line-height: 1.8;
}
.contents-page .content-rich-list li + li {
margin-top: 0.5rem;
}
.contents-page .content-rich-list .depth-0 {
margin-left: 1.5rem;
padding-left: 0.25rem;
list-style: disc;
font-weight: 600;
color: #555;
}
.contents-page .content-rich-list .depth-1 {
margin-left: 2.125rem;
list-style: none;
color: #777;
}
.contents-page .content-rich-list .depth-1::before {
content: "- ";
}
.contents-page .content-rich-list .depth-2 {
margin-left: 3.25rem;
list-style: none;
color: #666;
}
.contents-page .content-rich-list .depth-3 {
margin-left: 3.5rem;
list-style: none;
color: #777;
}
.contents-page .global-notice-box,
.contents-page .global-description-box {
max-width: 1000px;
margin: 1.25rem auto 0;
border-radius: 20px;
background: #eaf7f8;
}
.contents-page .global-notice-box {
padding: 1.75rem 2rem;
}
.contents-page .global-description-box {
padding: 1.5rem 1.75rem;
}
.contents-page .global-notice-row {
display: flex;
align-items: flex-start;
gap: 0.5rem;
}
.contents-page .global-water-bullet {
width: 27px;
height: 33px;
margin-top: 0.25rem;
flex-shrink: 0;
}
.contents-page .global-notice-title {
display: block;
font-size: 26px;
font-weight: 800;
color: #007fa4;
}
.contents-page .global-text-dark {
color: #1a1a1a;
}
.contents-page .global-plain-list {
margin-top: 1.25rem;
padding-left: 1.25rem;
list-style: disc;
color: #555;
font-size: 16px;
line-height: 1.7;
}
.contents-page .global-plain-list li + li {
margin-top: 0.5rem;
}
.contents-page .global-table-wrap {
overflow-x: auto;
}
.contents-page .global-overview-table-wrap {
max-width: 1000px;
margin: 1.25rem auto 0;
}
.contents-page .global-table {
width: 100%;
text-align: center;
border-collapse: collapse;
}
.contents-page .global-table th {
padding: 0.75rem 1rem;
font-size: 17px;
font-weight: 700;
color: #fff;
}
.contents-page .overview-compare-table {
min-width: 760px;
}
.contents-page .overview-compare-table th {
padding-top: 1rem;
padding-bottom: 1rem;
font-size: 18px;
font-weight: 500;
border-top-left-radius: 10px;
border-top-right-radius: 10px;
}
.contents-page .overview-compare-table td {
border: 1px solid #aeb9c2;
padding: 1rem;
font-size: 28px;
font-weight: 700;
color: #4f4f4f;
background: #fff;
}
.contents-page .overview-compare-table .country-cell {
font-size: 20px;
color: #333;
}
.contents-page .country-wrap {
display: flex;
align-items: center;
justify-content: center;
gap: 0.75rem;
}
.contents-page .country-flag {
width: 42px;
height: 28px;
object-fit: cover;
}
.contents-page .reuse-rate-table {
min-width: 960px;
}
.contents-page .reuse-rate-table th {
border-top-left-radius: 10px;
border-top-right-radius: 10px;
}
.contents-page .reuse-rate-table td {
border: 1px solid #aeb9c2;
padding: 0.75rem 1rem;
font-size: 15px;
color: #4f4f4f;
background: #fff;
}
.contents-page .reuse-rate-table .cell-strong {
font-weight: 600;
}
.contents-page .info-card-grid {
display: grid;
grid-template-columns: repeat(4, minmax(0, 1fr));
gap: 1.25rem;
margin-top: 2rem;
}
.contents-page .info-card {
border: 2px solid #01ace5;
border-radius: 8px;
background: #eaf5f5;
padding: 1.25rem;
}
.contents-page .info-card strong {
display: block;
font-size: 20px;
font-weight: 700;
color: #333;
}
.contents-page .info-card p {
margin-top: 0.75rem;
font-size: 15px;
line-height: 1.6;
color: #555;
}
.contents-page .global-table-padding {
margin-top: 2rem;
padding: 0 120px;
}
.contents-page .global-price-table td {
background: #fff;
}
.contents-page .global-accent {
color: #01ace5;
}
@media (max-width: 1200px) {
.contents-page .info-card-grid {
grid-template-columns: repeat(2, minmax(0, 1fr));
}
.contents-page .global-table-padding {
padding: 0;
}
}
@media (max-width: 768px) {
.contents-page .global-notice-row {
flex-direction: column;
}
.contents-page .global-notice-title {
font-size: 22px;
}
.contents-page .info-card-grid {
grid-template-columns: 1fr;
}
}
+1
View File
@@ -1,5 +1,6 @@
/* @import "../../../packages/common/components/src/base.css"; */
/* @import "../../../packages/common/components/src/base.css"; */
@import "./contents-page.css";
@tailwind base;
@tailwind components;
+14 -7
View File
@@ -63,10 +63,15 @@ export default function LoginModal({
setSubmitting(false);
};
const handleClose = () => {
resetForm();
onClose();
};
useEffect(() => {
if (!open) return;
const onKeyDown = (e: KeyboardEvent) => {
if (e.key === "Escape") onClose(); // ESC 키로 모달 닫기
if (e.key === "Escape") handleClose(); // ESC 키로 모달 닫기
};
window.addEventListener("keydown", onKeyDown);
@@ -77,9 +82,8 @@ export default function LoginModal({
return () => {
window.removeEventListener("keydown", onKeyDown);
document.body.style.overflow = prev;
resetForm();
};
}, [open, onClose]);
}, [open]);
useEffect(() => {
if (noWorker) {
@@ -160,7 +164,7 @@ export default function LoginModal({
if (sessionUser) {
const parsed = JSON.parse(sessionUser) as { dmdCoNm?: string };
onSuccess?.({ userName: parsed.dmdCoNm ?? "수요처" });
onClose();
handleClose();
router.push("/apply/status");
}
} finally {
@@ -180,7 +184,7 @@ export default function LoginModal({
<button
type="button"
aria-label="닫기"
onClick={onClose}
onClick={handleClose}
className="absolute inset-0 bg-black/40"
/>
@@ -196,7 +200,7 @@ export default function LoginModal({
<div className="relative px-10 pt-10">
<button
type="button"
onClick={onClose}
onClick={handleClose}
aria-label="닫기"
className="absolute right-8 top-8 z-40 h-10 w-10 grid place-items-center rounded-full hover:bg-gray-100"
>
@@ -352,7 +356,10 @@ export default function LoginModal({
<button
type="button"
onClick={() => {onClose(); router.push(REGISTER_HREF);}}
onClick={() => {
handleClose();
router.push(REGISTER_HREF);
}}
className="shrink-0 rounded-[4px] h-[48px] px-10 bg-[#004A94] text-white text-[18px] hover:opacity-95"
>
수요처등록
+16 -2
View File
@@ -4,14 +4,24 @@ import { unstable_cache } from "next/cache";
import { ENDPOINT } from "@/src/shared/config";
function getServerBaseUrl() {
const port = process.env.PORT ?? process.env.NEXT_PUBLIC_PORT ?? "3000";
const proxyHost = process.env.NEXT_PUBLIC_PROXY_HOST;
if (process.env.NODE_ENV === "production" && process.env.HOSTNAME) {
return `http://${process.env.HOSTNAME}:${port}`;
}
return proxyHost || `http://localhost:${port}`;
}
export const getCachedContentDetail = unstable_cache(
async (contsSn: number, menuCd: string) => {
const baseUrl = process.env.NEXT_PUBLIC_PROXY_HOST || "http://localhost:3015";
const baseUrl = getServerBaseUrl();
const res = await fetch(
`${baseUrl}${ENDPOINT.CMS_SERVICE.CONTENTS}/${contsSn}?menuCd=${menuCd}`,
{
next: { revalidate: 3600, tags: [`content:${contsSn}`] },
next: { revalidate: 0, tags: [`content:${contsSn}`] },
}
);
@@ -23,4 +33,8 @@ export const getCachedContentDetail = unstable_cache(
return res.json();
},
["dm-content-detail"],
{
revalidate: 86400, // 1시간마다 자동 갱신 (선택 사항)
tags: ["dm-content-detail"], // 여기에 태그를 명시해야 revalidateTag가 작동합니다.
}
);
@@ -1,6 +1,7 @@
"use client";
import { useEffect, useRef } from "react";
import type { ContentsDetail } from "@/src/entities/content/model/contents-interface";
type ContentsPageClientProps = {
@@ -8,28 +9,175 @@ type ContentsPageClientProps = {
};
function ContentsPageClient({ initialContent }: ContentsPageClientProps) {
const shadowRootRef = useRef<HTMLDivElement>(null);
const rootRef = useRef<HTMLDivElement>(null);
useEffect(() => {
if (!shadowRootRef.current || !initialContent?.contsCn) return;
const root = rootRef.current;
if (!root) return;
const shadowRoot =
shadowRootRef.current.shadowRoot ??
shadowRootRef.current.attachShadow({ mode: "open" });
shadowRoot.innerHTML = "";
const cleanups: Array<() => void> = [];
const sliders = Array.from(root.querySelectorAll(".image-slider"));
const styleElement = document.createElement("style");
styleElement.textContent = `div { font-size: 16px; line-height: 1.5; }`;
shadowRoot.appendChild(styleElement);
sliders.forEach((sliderElement) => {
const slider = sliderElement as HTMLElement;
const viewport = slider.querySelector(
".image-slider-viewport",
) as HTMLElement | null;
const track = slider.querySelector(
".image-slider-track",
) as HTMLElement | null;
const dotsWrap = slider.querySelector(
".image-slider-dots",
) as HTMLElement | null;
const pages = Array.from(
slider.querySelectorAll(".image-slider-page"),
) as HTMLElement[];
const contentElement = document.createElement("div");
contentElement.innerHTML = initialContent.contsCn;
shadowRoot.appendChild(contentElement);
}, [initialContent]);
if (!viewport || !track || !dotsWrap || pages.length === 0) return;
let page = 0;
let dragging = false;
let isHover = false;
let startX = 0;
let deltaX = 0;
let timer: number | null = null;
const autoPlayMs = Number(slider.dataset.autoplay ?? "4500");
const canSlide = pages.length > 1;
if (!canSlide) {
dotsWrap.style.display = "none";
} else {
dotsWrap.style.display = "";
}
const render = () => {
track.style.transform = `translateX(-${page * 100}%)`;
Array.from(dotsWrap.querySelectorAll("button")).forEach((dot, index) => {
dot.classList.toggle("is-active", index === page);
});
};
const goTo = (nextPage: number) => {
if (!canSlide) return;
if (nextPage < 0) page = pages.length - 1;
else if (nextPage >= pages.length) page = 0;
else page = nextPage;
render();
};
const stopTimer = () => {
if (timer !== null) {
window.clearInterval(timer);
timer = null;
}
};
const startTimer = () => {
stopTimer();
if (!canSlide || dragging || isHover) return;
timer = window.setInterval(() => {
page = page + 1 >= pages.length ? 0 : page + 1;
render();
}, autoPlayMs);
};
const dotButtons = pages.map((_, index) => {
const button = document.createElement("button");
button.type = "button";
button.className = "image-slider-dot";
const onClick = () => {
goTo(index);
startTimer();
};
button.addEventListener("click", onClick);
dotsWrap.appendChild(button);
cleanups.push(() => {
button.removeEventListener("click", onClick);
});
return button;
});
const onMouseEnter = () => {
isHover = true;
stopTimer();
};
const onMouseLeave = () => {
isHover = false;
startTimer();
};
const onPointerDown = (event: PointerEvent) => {
if (!canSlide) return;
dragging = true;
startX = event.clientX;
deltaX = 0;
viewport.setPointerCapture(event.pointerId);
};
const onPointerMove = (event: PointerEvent) => {
if (!dragging) return;
deltaX = event.clientX - startX;
};
const onPointerUp = () => {
if (!dragging) return;
dragging = false;
if (deltaX > 50) goTo(page - 1);
if (deltaX < -50) goTo(page + 1);
deltaX = 0;
startTimer();
};
viewport.addEventListener("mouseenter", onMouseEnter);
viewport.addEventListener("mouseleave", onMouseLeave);
viewport.addEventListener("pointerdown", onPointerDown);
viewport.addEventListener("pointermove", onPointerMove);
viewport.addEventListener("pointerup", onPointerUp);
viewport.addEventListener("pointercancel", onPointerUp);
cleanups.push(() => {
stopTimer();
viewport.removeEventListener("mouseenter", onMouseEnter);
viewport.removeEventListener("mouseleave", onMouseLeave);
viewport.removeEventListener("pointerdown", onPointerDown);
viewport.removeEventListener("pointermove", onPointerMove);
viewport.removeEventListener("pointerup", onPointerUp);
viewport.removeEventListener("pointercancel", onPointerUp);
dotButtons.forEach((button) => button.remove());
});
render();
startTimer();
});
return () => {
cleanups.forEach((cleanup) => cleanup());
};
}, [initialContent?.contsCn]);
return (
<div className="card card-border !mt-6 !p-6">
<div ref={shadowRootRef} />
<div className="card card-border !mt-6">
<div
ref={rootRef}
className="contents-page text-[16px] leading-[1.5]"
dangerouslySetInnerHTML={{
__html: initialContent?.contsCn ?? "",
}}
/>
</div>
);
}
+2 -2
View File
@@ -18,7 +18,7 @@
"js-cookie": "^3.0.5",
"js-sha512": "^0.9.0",
"lucide-react": "^0.474.0",
"next": "15.3.8",
"next": "15.5.18",
"react-hook-form": "^7.54.2",
"react-hot-toast": "^2.5.2",
"tailwind-merge": "^3.0.1",
@@ -30,7 +30,7 @@
"@types/webpack": "^5.28.5",
"eslint": "^9",
"eslint-config-next": "15.3.8",
"postcss": "^8",
"postcss": "^8",
"tailwindcss": "^3.4.1",
"typescript": "^5"
}
Binary file not shown.

Before

Width:  |  Height:  |  Size: 118 KiB

After

Width:  |  Height:  |  Size: 139 KiB

+41 -9
View File
@@ -10,11 +10,21 @@ type thisProps = {
fcltyName? : string | undefined;
}
function useDebouncedValue<T>(value: T, delay = 500) {
const [v, setV] = useState(value);
useEffect(() => {
const t = setTimeout(() => setV(value), delay);
return () => clearTimeout(t);
}, [value, delay]);
return v;
}
export default function SearchInputText({label=undefined, setValue=()=>{}, sidoSigun=undefined, fcltyName=undefined} :thisProps) {
const { ProcessFacilityList, searchProcessFacilitySearching } = useFoStore((state) => state);
const [isOpen, setIsOpen] = useState(false);
const [textValue, setTextValue] = useState(fcltyName || '');
const debouncedTextValue = useDebouncedValue(textValue);
const wrapperRef = useRef<HTMLDivElement>(null);
const processFacilityList = ProcessFacilityList?.processFacilityList || [];
@@ -22,30 +32,52 @@ export default function SearchInputText({label=undefined, setValue=()=>{}, sidoS
const value = e.target.value;
setTextValue(value);
if (value) {
setIsOpen(true);
if(sidoSigun) {
searchProcessFacilitySearching(1, 4999, 'A', sidoSigun, '0', '0', '0', value);
} else {
searchProcessFacilitySearching(1, 4999, '0', '0', '0', '0', '0', value);
}
setIsOpen(true); // 추천 목록 표시
// 삭제된 기존 코드:
// if(sidoSigun) {
// searchProcessFacilitySearching(1, 4999, 'A', sidoSigun, '0', '0', '0', value);
// } else {
// searchProcessFacilitySearching(1, 4999, '0', '0', '0', '0', '0', value);
// }
// onChange 안에서 바로 검색하면 글자를 입력할 때마다 요청이 발생해서
// debounce가 사실상 동작하지 않습니다.
// 실제 검색은 아래 debounced useEffect에서만 실행되도록 변경했습니다.
} else {
setIsOpen(false);
}
}, [searchProcessFacilitySearching, sidoSigun]);
}, []);
const onInputFocus = useCallback(() => {
setIsOpen(true);
if (textValue.trim()) return;
// 입력값이 없을 때만 기본 목록을 조회합니다.
// 이미 입력 중인 경우에는 기존 값으로 다시 전체/광범위 조회하지 않습니다.
if(sidoSigun) {
searchProcessFacilitySearching(1, 4999, 'A', sidoSigun, '0', '0', '0');
} else {
searchProcessFacilitySearching(1, 4999, '0', '0', '0', '0', '0');
}
}, [searchProcessFacilitySearching, sidoSigun]);
}, [searchProcessFacilitySearching, sidoSigun, textValue]);
useEffect(() => {
setTextValue(fcltyName || '');
}, [fcltyName]);
useEffect(() => {
const keyword = debouncedTextValue.trim();
if (!keyword) return;
// debounce가 적용된 값이 확정된 뒤에만 검색해서
// 시설명을 입력하는 도중의 과도한 검색 요청을 막습니다.
if(sidoSigun) {
searchProcessFacilitySearching(1, 4999, 'A', sidoSigun, '0', '0', '0', keyword);
} else {
searchProcessFacilitySearching(1, 4999, '0', '0', '0', '0', '0', keyword);
}
}, [debouncedTextValue, searchProcessFacilitySearching, sidoSigun]);
const handleSuggestionClick = useCallback((bscFctCd: string, bscFctNm: string, fctCpc: number | '', trtFrfNm: string | '', efflWqltStndrdNm: string | '') => {
setTextValue(bscFctNm);
setValue(bscFctCd, bscFctNm, fctCpc, trtFrfNm, efflWqltStndrdNm);
@@ -100,4 +132,4 @@ export default function SearchInputText({label=undefined, setValue=()=>{}, sidoS
)}
</div>
)
}
}
@@ -229,13 +229,6 @@ export default function DashBoardChart({searchYear = '', fcltyList = []}: facili
</CardContent>
</Card>
</ListDiv>
<ListDiv className="h-[300px]">
<Card className="h-full">
<CardContent className="h-full">
<MainChart chartForm={CHART_FORM.LINE} chartConfig={chartConfig_tptp} chartData={chart_tpTp} chartTitle="총인유입T-P-방류T-P" yFieldText="T-P(mg/L)" setXAxisYearTick={true}></MainChart>
</CardContent>
</Card>
</ListDiv>
{/* 유입T-N-방류T-N */}
<ListDiv className="h-[300px]">
<Card className="h-full">
@@ -260,6 +253,14 @@ export default function DashBoardChart({searchYear = '', fcltyList = []}: facili
</CardContent>
</Card>
</ListDiv>
{/* 총인유입T-P-방류T-P */}
<ListDiv className="h-[300px]">
<Card className="h-full">
<CardContent className="h-full">
<MainChart chartForm={CHART_FORM.LINE} chartConfig={chartConfig_tptp} chartData={chart_tpTp} chartTitle="총인유입T-P-방류T-P" yFieldText="T-P(mg/L)" setXAxisYearTick={true}></MainChart>
</CardContent>
</Card>
</ListDiv>
</ContentsDiv>
</>
);
@@ -1,6 +1,7 @@
"use client"
import { BaseLayout } from "@common/components";
import { useQueryClient } from "@tanstack/react-query";
import type { MenuItem } from "@/app/store/common";
import { useFoStore } from "@/app/store";
import { usePathname, useRouter } from "next/navigation";
@@ -16,6 +17,7 @@ export default function RootLayout({
const setCurrentMenuInfo = useMenuStore((state) => state.setCurrentMenuInfo);
const pathname = usePathname();
const router = useRouter();
const queryClient = useQueryClient();
const handleClick = (id: MenuItem) => {
setCurrentMenuInfo(id);
@@ -25,6 +27,7 @@ export default function RootLayout({
return (
<BaseLayout
queryClient={queryClient}
currentPath={pathname ?? ""}
handleMenuClick={handleClick}
>
+14 -2
View File
@@ -459,8 +459,20 @@ const fetchFacilityList = async (pageNo: number, pageSize: number, partValue?: s
}
};
// 시설정보 리스트 데이터 호출 (대시보드용)
const fetchFacilityListSearching = async (pageNo: number, pageSize: number, partValue?: string, nameCd?: string, fcltCpct?: string, oprtngSttsCd?: string, facilityPartValue?: string, facilityName?: string): Promise<{ data: facilityListProps[], count: number }> => {
/**
* 시설정보 리스트 데이터 호출 (대시보드용)
* @param pageNo 페이지번호
* @param pageSize 페이지크기
* @param partValue 조회구분값(0 전체,A: 행정, R: 유역, G: 환경청)
* @param nameCd 조회구분에 따른 지역코드(0 전체)
* @param fcltCpct 시설용량 필터값(0 전체)
* @param oprtngSttsCd 운영상태 코드(0 전체,1: 가동중, 2:재가동, 3:가동중지, 4:시설폐쇄)
* @param facilityPartValue 시설구분 코드(0 전체,ET:기타기초시설,SW:공공하수처리시설,SS:오수처리장,NS:분뇨처리장)
* @param facilityName 시설명 검색어
* @returns 시설목록과 전체건수
*/
const fetchFacilityListSearching = async (pageNo: number, pageSize: number, partValue?: string, nameCd?: string
, fcltCpct?: string, oprtngSttsCd?: string, facilityPartValue?: string, facilityName?: string): Promise<{ data: facilityListProps[], count: number }> => {
try {
const tailStr = partValue ? '/' + (partValue === 'G' ? 'A' : partValue) + '/' + nameCd + '/' + fcltCpct + '/' + oprtngSttsCd + '/' + facilityPartValue + (facilityName && facilityName !== '' ? '/' + facilityName : '') : '';
const url = '/prcsFclt/searching/' + pageNo + '/' + pageSize + tailStr;
+43 -59
View File
@@ -48,6 +48,20 @@ export type WtrUsgYmProps = {
levyYm: string, // 부과년월
wtrUsg: number, // 월별 물사용량(m³/월)
}
export type SewageOccurSummaryRow = {
bscFctCd?: string;
BSC_FCT_CD?: string;
waterUsage?: string | number;
estimationSewage?: string | number;
sewageInputYear?: string | number;
sewageInputChungchun?: string | number;
}
export type SewageOccurSummaryResponse = {
status?: number;
data?: SewageOccurSummaryRow[];
}
// 검색단
const LabelArray: string[] = ['구분', '시도', '시군구', '용량별', '시설명', '조회기간', '시설상태', '권역', '단위유역', '중권역', '환경청별'];
@@ -174,9 +188,10 @@ function getDaysInMonth(yyyymm) {
// }
// 시설들 유량 정보 조회
export const fetchSweageOccurListFlow = async (searchYear: string | undefined, fcltCdList: string[]): Promise<SewageOccurProps | null> => {
export const fetchSweageOccurListFlow = async (searchYear: string | undefined, fcltCdList: string[]): Promise<SewageOccurSummaryResponse | null> => {
try {
const res = await postFetch('fo', '/fclt/flow/' + searchYear, fcltCdList);
// const res = await postFetch('fo', '/fclt/flow/' + searchYear, fcltCdList);
const res = await postFetch('fo', '/fclt/flow/sewageOccur/' + searchYear, fcltCdList);
return res;
} catch (error) {
console.error('시설정보 오수발생량 상세 데이터를 가져오는데 실패했습니다:', error);
@@ -317,7 +332,7 @@ export const sewageOccurReducer: (set: any) => SewageOccurActions = (set: any) =
daddr: item.daddr,
fctCpc: parseFloat(item.fctCpc.toString().replace(/,/g, '')),
waterUsage: 0,
swageChangePercent: 0,
swageChangePercent: 90,
estimationSewage: 0,
sewageInputYear: 0,
sewageInputChungchun: 0,
@@ -328,34 +343,39 @@ export const sewageOccurReducer: (set: any) => SewageOccurActions = (set: any) =
msrDt: '',
});
});
const sewageOccurMap = new Map(
sewageOccurPropsList.map((item) => [item.bscFctCd, item])
);
// console.log(sewageOccurMap);
// const soccurDetailRes: any = await fetchSweageOccurListFlow(searchYear, fcltCdList);
// const wtrUsgYmRes: any = await fetchWtrUsgYmList(searchYear, fcltCdList);
//---------
const rainDetailList: Record<string, any[]> = {};
// const rainDetailList: Record<string, any[]> = {};
const wtrDetailList: Record<string, any[]> = {};
const results: any[] = [];
const wtrResults: any[] = [];
for (let idx = 0; idx < fcltCdList.length; idx += 100) {
const slicedItems = fcltCdList.slice(idx, idx + 100);
const slicedItems = fcltCdList.slice(idx, idx + 100); // 100건씩 가져오기
const result = await fetchSweageOccurListFlow(searchYear, slicedItems);
const wtrResult = await fetchWtrUsgYmList(searchYear, slicedItems);
results.push(result);
if (result?.status === 200 && Array.isArray(result.data)) {
result.data.forEach((row: any) => {
const bscFctCd = String(row.bscFctCd ?? "");
const target = sewageOccurMap.get(bscFctCd);
if (!target) return;
target.waterUsage = Number(row.waterUsage ?? 0);
target.estimationSewage = Number(row.estimationSewage ?? 0);
target.sewageInputYear = Number(row.sewageInputYear ?? 0);
target.sewageInputChungchun = Number(row.sewageInputChungchun ?? 0);
});
}
wtrResults.push(wtrResult);
}
//console.log(results);
// 결과를 합치기
for (const res of results) {
if (res.status === 200 && res.data) {
for (const [key, value] of Object.entries(res.data)) {
const keyStr = String(key);
//rainDetailList[keyStr] = [...(rainDetailList[keyStr] || []), ...(value as any[])];
if (!rainDetailList[keyStr]) rainDetailList[keyStr] = [];
rainDetailList[keyStr].push(...(value as any[]));
}
}
}
for (const res of wtrResults) {
if (res.status === 200 && res.data) {
for (const [key, value] of Object.entries(res.data)) {
@@ -368,34 +388,10 @@ export const sewageOccurReducer: (set: any) => SewageOccurActions = (set: any) =
}
//-------
let maxEstimationSewage = 0, minEstimationSewage = 999999999;
// let maxEstimationSewage = 0, minEstimationSewage = 999999999;
for (const item of sewageOccurPropsList) {
//const detailList = soccurDetailRes?.data[item.bscFctCd];
// const wtrUsgYmList = wtrUsgYmRes?.data[item.bscFctCd];
const detailList = rainDetailList[item.bscFctCd];
const wtrUsgYmList = wtrDetailList[item.bscFctCd];
item.swageChangePercent = 90;
let sumChungcunDay = 0; // 청천일수
let chungcunSumFlow = 0;// 청천시 하수유입량
let totWaterUsage = 0; // 연간 하수유입량
let rainEffectDay = 0;
detailList?.forEach(item2 => {
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(item2.inflAmnt);
});
let wtrUsgYearTotal = 0; // 연간 물사용량
let wtrUsgYearDays = 0; // 연간 물사용 일수
wtrUsgYmList?.forEach(item3 => {
@@ -404,22 +400,10 @@ export const sewageOccurReducer: (set: any) => SewageOccurActions = (set: any) =
wtrUsgYearDays += days;
});
// console.log("totWaterUsage:", totWaterUsage);
const count = detailList ? detailList.length : 0;
const avg = count == 0 ? 0 : totWaterUsage / count; // 연간 하수유입량(m³/일)
item.waterUsage = parseFloat((avg * 0.65).toFixed(2)); // 물사용량 = 연간 하수유입량(m³/일) * 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³/일) = 물사용량(m³/일) * 오수전환율
if (item.estimationSewage > maxEstimationSewage) maxEstimationSewage = item.estimationSewage;
if (item.estimationSewage < minEstimationSewage) minEstimationSewage = item.estimationSewage;
// 반올림 처리
["fctCpc", "waterUsage", "swageChangePercent", "estimationSewage", "sewageInputYear", "sewageInputChungchun"].forEach(
(key) => (item[key] = roundNumber(item[key], 0))
);
item.estimationSewage = Math.round(item.waterUsage * OSU_PERCENT * 1) / 1; // 오수발생량 추정
}
if (pageSize >= 5000) {
@@ -590,4 +574,4 @@ export const sewageOccurReducer: (set: any) => SewageOccurActions = (set: any) =
}))
},
}
}
}
+61 -102
View File
@@ -1,5 +1,5 @@
import { getFetch, postFetch } from "@/app/components/api";
import DataForm, { roundNumber } from "@/app/components/common/DataForm";
import DataForm from "@/app/components/common/DataForm";
import type { TableUpperProps } from "../dashboard";
import type { ExcelType, UnderWaterInputModalProps, UnderWaterProps, WtrUsgYmProps } from "./types";
// import CalcRainEffect from "@/app/components/common/CalcRainEffect";
@@ -21,6 +21,21 @@ export type SewageOccurDetailProps = {
sumWtrUsg: number, // 물사용량 합
avgWtrUsg: number, // 일평균 물사용량
}
export type UnderWaterRow = {
bscFctCd: string;
inflYear: number; // 연간 하수유입량(m³/일)
inflSummer: number; // 하절기 하수유입량(m³/일)
inflWinter: number; // 동절기 하수유입량(m³/일)
inflSunny: number; // 청천시 연간 하수유입량(m³/일)
inflSunnySummer: number; // 청천시 하절기 하수유입량(m³/일)
inflSunnyWinter: number; // 청천시 동절기 하수유입량(m³/일)
}
export type UnderWaterResponse = {
status?: number;
data?: UnderWaterRow[];
}
/**====================================
* Store - 타입
====================================*/
@@ -217,12 +232,12 @@ const fetchUnderWaterList = async (pageNo: number, pageSize: number, searchYear:
// }
// 시설들 유량 정보 조회
export const fetchDetailListFlow = async (searchYear: string | undefined, fcltCdList: string[]): Promise<UnderWaterProps | null> => {
export const fetchDetailListFlow = async (searchYear: string | undefined, fcltCdList: string[]): Promise<UnderWaterResponse | null> => {
try {
const res = await postFetch('fo', '/fclt/flow/' + searchYear, fcltCdList);
const res = await postFetch('fo', '/fclt/flow/underWater/' + searchYear, fcltCdList);
return res;
} catch (error) {
console.error('시설정보 오수발생량 상세 데이터를 가져오는데 실패했습니다:', error);
console.error('시설정보 지하수 유입량 데이터를 가져오는데 실패했습니다:', error);
return null;
}
};
@@ -373,35 +388,44 @@ export const underWaterReducer: (set: (fn: (state: UnderWaterType) => UnderWater
lgbsn: item.lgbsn,
mdmbsn: item.mdmbsn,
msrDt: '',
inflSummer: 0,
inflWinter: 0,
inflSunny: 0,
inflSunnySummer: 0,
inflSunnyWinter: 0,
});
});
// const soccurDetailRes: any = await fetchDetailListFlow(searchYear, fcltCdList);
// const wtrUsgYmRes: any = await fetchWtrUsgYmList(searchYear, fcltCdList);
//---------
const rainDetailList: Record<string, any[]> = {};
const underWaterMap = new Map(
underWaterPropsList.map((item) => [item.bscFctCd, item])
);
// const rainDetailList: Record<string, any[]> = {};
const wtrDetailList: Record<string, any[]> = {};
const results: any[] = [];
const wtrResults: any[] = [];
for (let idx = 0; idx < fcltCdList.length; idx += 100) {
const slicedItems = fcltCdList.slice(idx, idx + 100);
const result = await fetchDetailListFlow(searchYear, slicedItems);
const wtrResult = await fetchWtrUsgYmList(searchYear, slicedItems);
results.push(result);
if (result?.status === 200 && Array.isArray(result.data)) {
result.data.forEach((row: any) => {
const bscFctCd = String(row.bscFctCd ?? "");
const target = underWaterMap.get(bscFctCd);
if (!target) return;
target.inflAmnt = Number(row.inflYear ?? 0); // 연평균 유입하수량(m³/일)
target.inflSummer = Number(row.inflSummer ?? 0); // 하절기 유입하수량(m³/일)
target.inflWinter = Number(row.inflWinter ?? 0); // 동절기 유입하수량(m³/일)
target.inflSunny = Number(row.inflSunny ?? 0); // 청천시 연간 유입하수량(m³/일)
target.inflSunnySummer = Number(row.inflSunnySummer ?? 0); // 청천시 하절기 유입하수량(m³/일)
target.inflSunnyWinter = Number(row.inflSunnyWinter ?? 0); // 청천시 동절기 유입하수량(m³/일)
});
}
wtrResults.push(wtrResult);
}
//console.log(results);
// 결과를 합치기
for (const res of results) {
if (res.status === 200 && res.data) {
for (const [key, value] of Object.entries(res.data)) {
const keyStr = String(key);
//rainDetailList[keyStr] = [...(rainDetailList[keyStr] || []), ...(value as any[])];
if (!rainDetailList[keyStr]) rainDetailList[keyStr] = [];
rainDetailList[keyStr].push(...(value as any[]));
}
}
}
// console.log(underWaterMap);
for (const res of wtrResults) {
if (res.status === 200 && res.data) {
for (const [key, value] of Object.entries(res.data)) {
@@ -414,65 +438,11 @@ export const underWaterReducer: (set: (fn: (state: UnderWaterType) => UnderWater
}
//-------
for (const item of underWaterPropsList) {
// const detailList = soccurDetailRes?.data[item.bscFctCd];
// const wtrUsgYmList = wtrUsgYmRes?.data[item.bscFctCd];
const detailList = rainDetailList[item.bscFctCd];
const wtrUsgYmList = wtrDetailList[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 => {
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;
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;
}
});
const count = detailList ? detailList.length : 0;
const avg = count == 0 ? 0 : totWaterUsage / count; // 연간 일평균 유입량
let wtrUsgYearTotal = 0; // 연간 물사용량
let summerWtrUsgTot = 0; // 하절기 물사용량
let winterWtrUsgTot = 0; // 동절기 물사용량
@@ -492,37 +462,26 @@ export const underWaterReducer: (set: (fn: (state: UnderWaterType) => UnderWater
}
});
if (wtrUsgYearTotal > 0) { // 물사용량이 있는 경우
const usage = chungcunSumFlow && chungcunSumFlow > 0 ? chungcunSumFlow / sumChungcunDay : 0; // 청천시 일평균 유입량
const summerUsage = sumSummerChungcunFlow ? sumSummerChungcunFlow / sumSummerChungcunDay : 0; // 하절기 청천시 일평균 유입량
const winterUsage = sumWinterChungcunFlow ? sumWinterChungcunFlow / sumWinterChungcunDay : 0; // 동절기 청천시 일평균 유입량
if (wtrUsgYearTotal > 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 ? '초과유입' : '적정'; // 적정여부
item.inflAmnt = Math.round(item.inflAmnt);
item.underWaterInputYear = Math.round((item.inflSunny - (wtrUsg * OSU_PERCENT)) ) ; // 연간지하수유입량(m³/일) = 청천시 실제유입량 - (물사용량 × 오수전환율)
item.underWaterInputSummer = Math.round((item.inflSunnySummer - (summerWtrUsg * OSU_PERCENT)) ) ; // 하절기지하수유입량(m³/일)
item.underWaterInputWinter = Math.round((item.inflSunnyWinter - (winterWtrUsg * OSU_PERCENT))) ; // 동절기지하수유입량(m³/일)
item.properYn = Math.round( (item.inflSunny - (wtrUsg * OSU_PERCENT)) / Number(item.inflAmnt) * 100 ) > 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; // 동절기 일평균 물사용량
const wtrUsg = item.inflAmnt * 0.65; // 일평균 물사용량
const summerWtrUsg = item.inflSummer * 0.65; // 하절기 일평균 물사용량
const winterWtrUsg = item.inflWinter * 0.65; // 동절기 일평균 물사용량
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))
// );
item.inflAmnt = Math.round(item.inflAmnt);
item.underWaterInputYear = Math.round((item.inflSunny - (wtrUsg * OSU_PERCENT)) ) ; // 연간지하수유입량(m³/일) = 청천시 실제유입량 - (물사용량 × 오수전환율)
item.underWaterInputSummer = Math.round((item.inflSunnySummer - (summerWtrUsg * OSU_PERCENT)) ) ; // 하절기지하수유입량(m³/일) = 청천시 실제유입량 - (물사용량 × 오수전환율)
item.underWaterInputWinter = Math.round((item.inflSunnyWinter - (winterWtrUsg * OSU_PERCENT)) ) ; // 동절기지하수유입량(m³/일) = 청천시 실제유입량 - (물사용량 × 오수전환율)
item.properYn = Math.round( (item.inflSunny - (wtrUsg * OSU_PERCENT)) / Number(item.inflAmnt) * 100 ) > 20 ? '초과유입' : '적정'; // 적정여부
}
}
@@ -18,6 +18,11 @@ export type UnderWaterProps = {
inflAmnt : number, // 연평균유입하수량(m³/일)
properYn : string | '', // 적정여부
msrDt : string, // 측정일자
inflSummer : number, // 하절기 하수유입량(m³/일)
inflWinter : number, // 동절기 하수유입량(m³/일)
inflSunny : number, // 청천시 연간 하수유입량(m³/일)
inflSunnySummer : number, // 청천시 하절기 하수유입량(m³/일)
inflSunnyWinter : number, // 청천시 동절기 하수유입량(m³/일)
}
export type WtrUsgYmProps = {
+17 -17
View File
@@ -1,6 +1,6 @@
import type { NextConfig } from "next";
const domain = process.env.NEXT_PUBLIC_PROXY_HOST || "http://localhost:3000";
// const domain = process.env.NEXT_PUBLIC_PROXY_HOST || "http://localhost:3000";
const subDomain = process.env.NEXT_PUBLIC_SUB_DOMAIN || "http://localhost:13000";
const nextConfig: NextConfig = {
@@ -24,22 +24,22 @@ const nextConfig: NextConfig = {
},
],
afterFiles: [
{
source: "/sewerage-service/:path*",
destination: `${domain}/sewerage-service/:path*`,
},
{
source: "/reuse-service/:path*",
destination: `${domain}/reuse-service/:path*`,
},
{
source: "/operation-service/:path*",
destination: `${domain}/operation-service/:path*`,
},
{
source: "/ngii-server/:path*",
destination: `${domain}/ngii-server/:path*`,
},
// {
// source: "/sewerage-service/:path*",
// destination: `${domain}/sewerage-service/:path*`,
// },
// {
// source: "/reuse-service/:path*",
// destination: `${domain}/reuse-service/:path*`,
// },
// {
// source: "/operation-service/:path*",
// destination: `${domain}/operation-service/:path*`,
// },
// {
// source: "/ngii-server/:path*",
// destination: `${domain}/ngii-server/:path*`,
// },
{
source: "/cms/:path*",
destination: `${subDomain}/cms/:path*`,
+3 -3
View File
@@ -15,7 +15,7 @@
"@tailwindcss/vite": "^4.0.10",
"html2canvas": "^1.4.1",
"js-cookie": "^3.0.5",
"next": "15.3.8",
"next": "15.5.18",
"select": "^1.1.2",
"webpack": "^5.98.0",
"zustand": "^5.0.3",
@@ -28,8 +28,8 @@
"@tanstack/react-query-devtools": "^5.75.5",
"@tanstack/react-table": "^8.21.2",
"react-dnd": "^16.0.1",
"@minoru/react-dnd-treeview": "3.5.1-alpha.1",
"react-hook-form": "^7.54.2",
"@minoru/react-dnd-treeview": "3.5.1-alpha.1",
"react-hook-form": "^7.54.2",
"zod": "^3.24.2",
"axios": "^1.8.1",
"proj4": "^2.9.0"
+1 -1
View File
@@ -27,7 +27,7 @@
"dayjs": "^1.11.13",
"js-cookie": "^3.0.5",
"moment": "^2.30.1",
"next": "15.3.8",
"next": "15.5.18",
"react-chartjs-2": "^5.3.0",
"react-hot-toast": "^2.5.2",
"react-dnd": "^16.0.1",
+3 -3
View File
@@ -1,7 +1,7 @@
# apps/cs/src/shared/config/env.ts 참고
NEXT_PUBLIC_PORT=3000
NEXT_PUBLIC_PROXY_HOST=http://localhost:3000
NEXT_PUBLIC_PORT=3001
NEXT_PUBLIC_PROXY_HOST=http://localhost:3001
SERVER_API_URL=http://192.168.0.102:8000
NEXT_PUBLIC_SITE_ID=CS
NEXT_PUBLIC_SITE_ID=ST
NEXT_PUBLIC_SUB_DOMAIN=http://localhost:3000
NEXT_PUBLIC_TINY_API_KEY=t9nc4z6inquvvd5pq1vea8wcp7lb01qmqseh6egms9vpaju2
+252 -22
View File
@@ -1,193 +1,423 @@
"use client";
import { useCallback, useEffect, useMemo, useState } from "react";
import { Suspense, useCallback, useEffect, useMemo, useState } from "react";
import { usePathname } from "next/navigation";
import {
getTabStoreState as getGridTabStoreState,
useTabStore as gridTabStore,
} from "@rsone/components";
import { useQuery, useQueryClient } from "@tanstack/react-query";
import { ENDPOINT } from "@/shared/config";
import { getCookie, useAlertStore, useMenuStore } from "@/shared/lib";
import { useStatisticsTabUrlSync } from "@/shared/lib/use-statistics-tab-url-sync";
import { isVerificationSelectionReportMenu } from "@/shared/lib/verification-selection-report";
import { StatisticsPageLayout } from "@/shared/ui/statistics-page-layout";
import { tokenManager } from "@/entities/auth";
import type { MenuItem } from "@/entities/menu";
import { MenuApi } from "@/entities/menu";
import { BaseLayout } from "@common/components";
import { getConfig } from "@common/config";
import { getTabStoreState, useTabStore } from "@/store/tab-store";
import VerificationSelectionReport from "@/components/statistics/VerificationSelectionReport";
const appConfig = getConfig();
const userId = getCookie(appConfig.AUTH_USER_ID ?? "");
const appSiteId = getCookie("site-id-key") ?? "";
const isLoggedIn = !!userId;
export default function WithBaseLayout({
function WithBaseLayoutInner({
children,
}: {
children: React.ReactNode;
}) {
const pathname = usePathname();
const queryClient = useQueryClient();
/** 탭 추가 없이 메뉴만 눌렀을 때 패널 유지 (다른 메뉴 클릭해도 유지) */
const [isVerificationSelectionReportPanelOpen, setReportPanelOpen] =
useState(false);
const setCurrentMenuInfo = useMenuStore(
(state) => state.setCurrentMenuInfo,
);
const currentMenuInfo = useMenuStore((state) => state.currentMenuInfo);
const addTab = useTabStore((state) => state.addTab);
const setActiveTab = useTabStore((state) => state.setActiveTab);
const setMenuList = useTabStore((state) => state.setMenuList);
const activeTabId = useTabStore((state) => state.activeTabId);
const menuList = useTabStore((state) => state.menuList);
const tabs = useTabStore((state) => state.tabs);
const addTabForGrid = gridTabStore((state) => state.addTab);
const setActiveTabForGrid = gridTabStore((state) => state.setActiveTab);
const setMenuListForGrid = gridTabStore((state) => state.setMenuList);
const alert = useAlertStore((state) => state.setMessage);
const { data: menuListData } = useQuery({
queryKey: [ENDPOINT.CMS_SERVICE.MENU_ROLES],
queryFn: () => MenuApi.getMenuRoles(appSiteId),
enabled: isLoggedIn,
const { syncTabToUrl } = useStatisticsTabUrlSync({
pathname,
menuList,
activeTabId,
addTab,
addTabForGrid,
setCurrentMenuInfo,
});
const { data: menuListData } = useQuery({
queryKey: [ENDPOINT.CMS_SERVICE.MENU_ROLES],
queryFn: () => MenuApi.getMenuRoles(appSiteId),
enabled: isLoggedIn,
});
useEffect(() => {
if (menuListData) {
setMenuList(menuListData);
setMenuListForGrid(menuListData);
}
}, [menuListData, setMenuList, setMenuListForGrid]);
// 새로고침 후 persist로 앱 탭만 복원된 경우 rsone 그리드 스토어에 탭을 맞춤
useEffect(() => {
if (!menuList) return;
const appTabs = getTabStoreState().tabs;
if (appTabs.length === 0) return;
if (getGridTabStoreState().tabs.length > 0) return;
for (const tab of appTabs) {
getGridTabStoreState().addTab(tab.menuItem);
}
const activeId = getTabStoreState().activeTabId;
if (activeId) {
setActiveTab(activeId);
const activeTab = appTabs.find((t) => t.id === activeId);
const gridActiveId = activeTab?.menuItem.menuEngNm;
if (gridActiveId) {
getGridTabStoreState().setActiveTab(gridActiveId);
}
}
}, [menuList, tabs]);
useEffect(() => {
if (!pathname) return;
// pathname이 변경될 때만 실행되도록 tabs를 직접 가져옴
const currentTabs = getTabStoreState().tabs;
const tab = currentTabs.find((t) => t.path === pathname);
if (tab) {
setActiveTab(tab.id);
const gridActiveId = tab.menuItem.menuEngNm;
if (gridActiveId) {
setActiveTabForGrid(gridActiveId);
}
}
}, [pathname, setActiveTab, setActiveTabForGrid]);
}, [menuList, tabs, setActiveTab]);
// 활성 탭의 메뉴를 찾아서 urlPath를 가져옴. 탭이 전부 닫히면 ''를 넘겨 LNB에서 메뉴 강조 제거
const currentPath = useMemo(() => {
if (tabs.length === 0) return "";
if (activeTabId && menuList) {
// menuList에서 활성 탭의 메뉴 찾기
const findMenuByEngNm = (items: MenuItem[]): MenuItem | null => {
for (const item of items) {
if (item.urlPath === activeTabId) {
return item;
}
if (item.children) {
const found = findMenuByEngNm(item.children);
if (found) return found;
}
}
return null;
};
const activeMenu = findMenuByEngNm(menuList);
if (activeMenu?.urlPath) {
return activeMenu.urlPath;
}
}
// 탭에서 직접 찾기
if (activeTabId) {
const currentTabs = getTabStoreState().tabs;
const activeTab = currentTabs.find((t) => t.id === activeTabId);
if (activeTab?.menuItem?.urlPath) {
return activeTab.menuItem.urlPath;
}
}
// currentMenuInfo에서 가져오기
if (currentMenuInfo?.urlPath) {
return currentMenuInfo.urlPath;
}
// 기본값으로 pathname 사용
return pathname ?? "";
}, [tabs.length, activeTabId, menuList, currentMenuInfo, pathname]);
const handleMenuClick = useCallback(
(item: MenuItem) => {
setCurrentMenuInfo(item);
if (isVerificationSelectionReportMenu(item)) {
setReportPanelOpen(true);
return;
}
const addTabResult: boolean = addTab(item);
// packages/rsone의 스토어에도 동기화
if (addTabResult) {
addTabForGrid(item);
if (item.urlPath) {
syncTabToUrl(item.urlPath);
}
}
if (!addTabResult) {
alert(`최대 추가 할 수 있는 화면 수(10개)를 \n초과 하였습니다.
기존 화면을 닫은 후 이용해주세요.`);
}
},
[setCurrentMenuInfo, addTab, addTabForGrid, alert, setReportPanelOpen],
[
setCurrentMenuInfo,
addTab,
addTabForGrid,
alert,
setReportPanelOpen,
syncTabToUrl,
],
);
return (
<BaseLayout
queryClient={queryClient}
tokenManager={tokenManager}
currentPath={currentPath}
handleMenuClick={handleMenuClick}
>
<div className="flex flex-1 h-full">
{isVerificationSelectionReportPanelOpen && (
<VerificationSelectionReport
onClose={() => setReportPanelOpen(false)}
/>
)}
<StatisticsPageLayout>{children}</StatisticsPageLayout>
</div>
</BaseLayout>
);
}
export default function WithBaseLayout({
children,
}: {
children: React.ReactNode;
}) {
return (
<Suspense fallback={null}>
<WithBaseLayoutInner>{children}</WithBaseLayoutInner>
</Suspense>
);
}
+1 -1
View File
@@ -10,7 +10,7 @@ import {
ST_SERVICE_URL,
} from "./src/shared/config";
const serverApiUrl = process.env.SERVER_API_URL ?? "http://localhost:3000";
const serverApiUrl = process.env.SERVER_API_URL;
const stServiceUrl = ST_SERVICE_URL;
+2 -2
View File
@@ -3,7 +3,7 @@
"version": "0.1.0",
"private": true,
"scripts": {
"dev": "next dev",
"dev": "next dev -p 3001",
"build": "next build",
"start": "next start",
"lint": "next lint"
@@ -44,7 +44,7 @@
"js-cookie": "^3.0.5",
"jszip": "^3.10.1",
"lucide-react": "^0.474.0",
"next": "15.3.8",
"next": "15.5.18",
"next-qrcode": "^2.5.1",
"next-themes": "^0.4.6",
"react-chartjs-2": "^5.3.0",
@@ -6,7 +6,7 @@ interface AddArticleButtonProps {
const AddArticleButton = ({ addArticleFunc }: AddArticleButtonProps) => {
return (
<Button className="h-[3.2rem] w-[96px]" onClick={addArticleFunc}>
<Button className="h-[3.2rem] w-[96px]" color="white" onClick={addArticleFunc}>
<i className="ri-add-fill" />
<span>항목 추가</span>
</Button>
@@ -6,7 +6,7 @@ interface DeleteArticleButtonProps {
const DeleteArticleButton = ({ deleteArticleFunc }: DeleteArticleButtonProps) => {
return (
<Button className="h-[3.2rem] w-[96px]" color="red" onClick={deleteArticleFunc}>
<Button className="h-[3.2rem] w-[96px]" color="white" onClick={deleteArticleFunc}>
<i className="ri-subtract-fill"></i>
<span>항목 제거</span>
</Button>
+72
View File
@@ -21,6 +21,16 @@
.rg-grid .rg-body .rg-table .rg-data-row .rg-data-cell.rg-cell-vrf-prcs-ok {
@apply !font-semibold !text-[#3372cf];
}
/* 검증결과 다이얼로그: 구분(NV·IMR·HB) */
.rg-grid .rg-body .rg-table .rg-data-row .rg-data-cell.rg-cell-vrf-mthd-nv {
@apply !bg-[#f3f4f6] !font-semibold !text-[#4b5563];
}
.rg-grid .rg-body .rg-table .rg-data-row .rg-data-cell.rg-cell-vrf-mthd-imr {
@apply !bg-[#fef2f2] !font-semibold !text-[#dc2626];
}
.rg-grid .rg-body .rg-table .rg-data-row .rg-data-cell.rg-cell-vrf-mthd-hb {
@apply !bg-[#eff6ff] !font-semibold !text-[#2563eb];
}
/* 개별 반려: 높이/레이아웃은 RealGrid 행에 그대로 두고, 색·테두리·정렬만 버튼 느낌 */
.rg-grid .rg-body .rg-table .rg-data-row .rg-data-cell.rg-cell-reject-mini-btn {
text-align: center !important;
@@ -144,6 +154,24 @@
@apply !bg-[#fff7bf];
}
/*
* 검증 오류 셀(rg-cell-verify-error / -imr) 최우선 적용.
* 클래스 이중화로 specificity를 한 단계 올려 동일 specificity의 다른 셀 클래스
* (rg-cell-rowtype-*, rg-cell-total, rg-cell-readonly 등)보다 항상 이김.
*/
.rg-grid .rg-body .rg-data-cell.rg-cell-verify-error.rg-cell-verify-error,
.rg-grid .rg-body .rg-table .rg-data-row .rg-data-cell.rg-cell-verify-error.rg-cell-verify-error,
.rg-grid .rg-fixed-body .rg-data-cell.rg-cell-verify-error.rg-cell-verify-error,
.rg-grid .rg-fixed-body .rg-data-row .rg-data-cell.rg-cell-verify-error.rg-cell-verify-error {
@apply !bg-[#ffe6e6] !text-[#c00];
}
.rg-grid .rg-body .rg-data-cell.rg-cell-verify-error-imr.rg-cell-verify-error-imr,
.rg-grid .rg-body .rg-table .rg-data-row .rg-data-cell.rg-cell-verify-error-imr.rg-cell-verify-error-imr,
.rg-grid .rg-fixed-body .rg-data-cell.rg-cell-verify-error-imr.rg-cell-verify-error-imr,
.rg-grid .rg-fixed-body .rg-data-row .rg-data-cell.rg-cell-verify-error-imr.rg-cell-verify-error-imr {
@apply !bg-[#fff7bf] !text-[#b45309];
}
/* 진행상황: 소프트 인라인 요약 (툴바와 자연스럽게 blend) */
.data-grid-status-bubble {
@apply rounded-lg border border-slate-200/80 bg-white px-8 py-2.5;
@@ -252,6 +280,18 @@
[data-theme="black"] .rg-grid .rg-body .rg-table .rg-data-row .rg-data-cell.rg-cell-vrf-prcs-ok {
color: #3372cf !important;
}
[data-theme="black"] .rg-grid .rg-body .rg-table .rg-data-row .rg-data-cell.rg-cell-vrf-mthd-nv {
background-color: #4b5563 !important;
color: #f9fafb !important;
}
[data-theme="black"] .rg-grid .rg-body .rg-table .rg-data-row .rg-data-cell.rg-cell-vrf-mthd-imr {
background-color: rgb(127 29 29 / 0.55) !important;
color: #fecaca !important;
}
[data-theme="black"] .rg-grid .rg-body .rg-table .rg-data-row .rg-data-cell.rg-cell-vrf-mthd-hb {
background-color: rgb(30 58 138 / 0.55) !important;
color: #bfdbfe !important;
}
[data-theme="black"] .rg-grid .rg-body .rg-table .rg-data-row .rg-data-cell.rg-cell-reject-mini-btn {
background-color: rgb(127 29 29 / 0.42) !important;
border-color: rgb(248 113 113 / 0.5) !important;
@@ -314,3 +354,35 @@
background-color: #6bafae !important;
color: #ffffff !important;
}
.rg-grid .rg-body .rg-data-cell.rg-cell-verify-error.rg-cell-verify-error,
.rg-grid .rg-body .rg-table .rg-data-row .rg-data-cell.rg-cell-verify-error.rg-cell-verify-error,
.rg-grid .rg-fixed-body .rg-data-cell.rg-cell-verify-error.rg-cell-verify-error,
.rg-grid .rg-fixed-body .rg-data-row .rg-data-cell.rg-cell-verify-error.rg-cell-verify-error {
background-color: #ffe6e6 !important;
color: #c00 !important;
}
.rg-grid .rg-body .rg-data-cell.rg-cell-verify-error-imr.rg-cell-verify-error-imr,
.rg-grid .rg-body .rg-table .rg-data-row .rg-data-cell.rg-cell-verify-error-imr.rg-cell-verify-error-imr,
.rg-grid .rg-fixed-body .rg-data-cell.rg-cell-verify-error-imr.rg-cell-verify-error-imr,
.rg-grid .rg-fixed-body .rg-data-row .rg-data-cell.rg-cell-verify-error-imr.rg-cell-verify-error-imr {
background-color: #fff7bf !important;
color: #b45309 !important;
}
/* dark theme에서도 검증 오류 색을 유지하고 싶다면 동일 색 유지(가독성 우선) */
[data-theme="black"] .rg-grid .rg-body .rg-data-cell.rg-cell-verify-error.rg-cell-verify-error,
[data-theme="black"] .rg-grid .rg-body .rg-table .rg-data-row .rg-data-cell.rg-cell-verify-error.rg-cell-verify-error,
[data-theme="black"] .rg-grid .rg-fixed-body .rg-data-cell.rg-cell-verify-error.rg-cell-verify-error,
[data-theme="black"] .rg-grid .rg-fixed-body .rg-data-row .rg-data-cell.rg-cell-verify-error.rg-cell-verify-error {
background-color: #ffe6e6 !important;
color: #c00 !important;
}
[data-theme="black"] .rg-grid .rg-body .rg-data-cell.rg-cell-verify-error-imr.rg-cell-verify-error-imr,
[data-theme="black"] .rg-grid .rg-body .rg-table .rg-data-row .rg-data-cell.rg-cell-verify-error-imr.rg-cell-verify-error-imr,
[data-theme="black"] .rg-grid .rg-fixed-body .rg-data-cell.rg-cell-verify-error-imr.rg-cell-verify-error-imr,
[data-theme="black"] .rg-grid .rg-fixed-body .rg-data-row .rg-data-cell.rg-cell-verify-error-imr.rg-cell-verify-error-imr {
background-color: #fff7bf !important;
color: #b45309 !important;
}
@@ -116,7 +116,8 @@ const CompactDataGrid = ({
onItemChecked,
onItemAllChecked,
compactFullWidth = false,
compactGridHeightClass = "h-[65vh]",
compactGridHeightClass = "h-[55vh]",
headerHeight = 60,
}: DataGridProps) => {
const { columns, columnLayout } = columnInfo;
const gridRef = useRef<RealGridReact>(null);
@@ -188,7 +189,7 @@ const CompactDataGrid = ({
fitStyle: GridFitStyle.EVEN
});
gridView.header.height = 60;
gridView.header.height = headerHeight;
if (columns.some((c) => Boolean(resolveColumnTooltip(c)))) {
(gridView.header as { showTooltip?: boolean }).showTooltip = true;
}
@@ -449,7 +450,17 @@ const CompactDataGrid = ({
return () => {
if (getGridDataRef) getGridDataRef.current = null;
};
}, [columns, columnLayout, fixedColumns, rowData, showHeaderCheckbox, getGridDataRef, compactFullWidth, compactGridHeightClass]);
}, [
columns,
columnLayout,
fixedColumns,
rowData,
showHeaderCheckbox,
getGridDataRef,
compactFullWidth,
compactGridHeightClass,
headerHeight,
]);
// 행 클릭 이벤트
useEffect(() => {
+295 -16
View File
@@ -20,6 +20,11 @@ import type {
} from "../type/DataGrid.type";
import { getComputedValueCallback } from "@/components/util/grid/computedFieldCallbacks";
import {
prepareGridColumnsForExcelExport,
restoreGridColumnsAfterExcelExport,
shouldPrepareColumnsForExcelExport,
} from "@/components/util/gridExcelExport";
import { useTabStore } from "@/store/tab-store";
// RealGrid 타입 확장
@@ -47,6 +52,159 @@ if (typeof window !== "undefined") {
RealGrid.setLicenseKey(process.env.NEXT_PUBLIC_ST_REALGRID_KEY || "");
function isDropDownEditorColumn(column: {
editor?: { type?: string };
values?: string[];
}): boolean {
const type = column.editor?.type;
return (
type === "dropdown" ||
type === "list" ||
type === "dropDown" ||
(column.values != null && column.values.length > 0)
);
}
type GridViewWithSetColumnProperty = {
setColumnProperty?: (col: string, key: string, value: unknown) => void;
};
type GridViewWithEditOptions = {
setEditOptions?: (options: { commitWhenLeave?: boolean }) => void;
};
/** 포커스가 그리드 밖으로 나갈 때 편집 중인 값을 자동 commit */
function applyDefaultGridEditOptions(
gridView: GridViewWithEditOptions | null | undefined,
) {
gridView?.setEditOptions?.({ commitWhenLeave: true });
}
function isDateEditorColumn(column: {
editor?: { type?: string };
}): boolean {
return column.editor?.type === "date";
}
/** RealGrid 날짜(date) 편집기 컬럼 속성 적용 */
function applyColumnDateEditorProperties(
gridView: GridViewWithSetColumnProperty,
column: import("../type/DataGrid.type").ColumnConfig,
) {
if (!gridView.setColumnProperty) return;
const fmt =
column.datetimeFormat ??
column.editor?.datetimeFormat ??
"yyyy-MM-dd";
gridView.setColumnProperty(column.name, "datetimeFormat", fmt);
gridView.setColumnProperty(column.name, "editor", {
type: "date",
datetimeFormat: fmt,
dropDownWhenClick: column.editor?.dropDownWhenClick ?? true,
showToday: column.editor?.showToday ?? true,
});
gridView.setColumnProperty(column.name, "editButtonVisibility", "always");
}
/** RealGrid 드롭다운·날짜 등 컬럼 편집기 속성 적용 */
function applyColumnListEditorProperties(
gridView: GridViewWithSetColumnProperty,
column: import("../type/DataGrid.type").ColumnConfig,
) {
if (!gridView.setColumnProperty) return;
if (isDateEditorColumn(column)) {
applyColumnDateEditorProperties(gridView, column);
return;
}
const values = column.values ?? column.editor?.values;
const labels = column.labels ?? column.editor?.labels ?? values;
const isDropDown = isDropDownEditorColumn(column);
if (!isDropDown) {
if (column.editor) {
gridView.setColumnProperty(column.name, "editor", column.editor);
}
return;
}
if (values?.length) {
gridView.setColumnProperty(column.name, "values", values);
gridView.setColumnProperty(column.name, "labels", labels ?? values);
}
if (column.lookupDisplay) {
gridView.setColumnProperty(column.name, "lookupDisplay", true);
}
gridView.setColumnProperty(column.name, "editor", {
type: "dropdown",
domainOnly: column.editor?.domainOnly ?? true,
textReadOnly: column.editor?.textReadOnly ?? true,
commitOnSelect: column.editor?.commitOnSelect ?? true,
dropDownWhenClick: column.editor?.dropDownWhenClick ?? true,
values,
labels: labels ?? values,
});
gridView.setColumnProperty(column.name, "editButtonVisibility", "always");
}
/** 그리드 마운트·데이터 로드 후 드롭다운 컬럼 재적용용 */
export function applyDropDownColumnToGrid(
gridView: GridViewWithSetColumnProperty | null | undefined,
columnName: string,
options: string[],
) {
if (!gridView?.setColumnProperty || options.length === 0) return;
(
gridView as {
setEditOptions?: (opts: { editable?: boolean }) => void;
}
).setEditOptions?.({ editable: true });
gridView.setColumnProperty(columnName, "editable", true);
gridView.setColumnProperty(columnName, "values", options);
gridView.setColumnProperty(columnName, "labels", options);
gridView.setColumnProperty(columnName, "lookupDisplay", true);
gridView.setColumnProperty(columnName, "editor", {
type: "dropdown",
domainOnly: true,
textReadOnly: true,
commitOnSelect: true,
dropDownWhenClick: true,
values: options,
labels: options,
});
gridView.setColumnProperty(columnName, "editButtonVisibility", "always");
(gridView as { refresh?: () => void }).refresh?.();
}
function reapplyListEditorsOnGrid(
gridView: GridViewWithSetColumnProperty | null | undefined,
cols: import("../type/DataGrid.type").ColumnConfig[],
) {
if (!gridView) return;
cols.forEach((col) => applyColumnListEditorProperties(gridView, col));
if (cols.some((c) => isDropDownEditorColumn(c) || isDateEditorColumn(c))) {
(gridView as { refresh?: () => void }).refresh?.();
}
}
type GridViewWithCheckApi = {
checkItem?: (itemIndex: number, checked: boolean, exclusive?: boolean) => void;
checkRow?: (dataRow: number, checked: boolean, exclusive?: boolean) => void;
checkAll?: (checked: boolean) => void;
};
function isRowCheckedFlag(value: unknown): boolean {
return (
value === true ||
value === 1 ||
value === "1" ||
value === "Y" ||
value === "y" ||
value === "true"
);
}
/** setDataComparer(field, comparer) - setFields 없이 숫자 정렬용. comparer(field, row1, row2) => -1|0|1 */
interface RealGridDataProviderComparer {
setDataComparer?(
@@ -303,6 +461,8 @@ function buildFieldsFromColumns(
columns: {
fieldName: string;
valueType?: ValueType;
datetimeFormat?: string;
editor?: { type?: string; datetimeFormat?: string };
computed?: import("../type/DataGrid.type").ComputedFieldConfig;
}[],
) {
@@ -310,6 +470,7 @@ function buildFieldsFromColumns(
const field: {
fieldName: string;
dataType: string;
datetimeFormat?: string;
valueCallback?: (
prod: unknown,
dataRow: number,
@@ -319,8 +480,19 @@ function buildFieldsFromColumns(
) => number;
} = {
fieldName: col.fieldName,
dataType: col.valueType === ValueType.NUMBER ? "number" : "text",
dataType:
col.editor?.type === "date"
? "datetime"
: col.valueType === ValueType.NUMBER
? "number"
: "text",
};
if (col.editor?.type === "date") {
field.datetimeFormat =
col.datetimeFormat ??
col.editor.datetimeFormat ??
"yyyy-MM-dd";
}
if (col.computed) {
const hasFields =
"fields" in col.computed && col.computed.fields.length > 0;
@@ -386,7 +558,9 @@ const DataGrid = ({
highlightedRowId,
idFieldName = "id",
enableSort = false,
gridHeight = 65, // vh 단위
gridHeight = 55, // vh 단위
headerHeight = 60,
fitStyle = GridFitStyle.EVEN,
}: DataGridProps) => {
const { columns, columnLayout } = columnInfo;
const gridRef = useRef<RealGridReact>(null);
@@ -411,6 +585,7 @@ const DataGrid = ({
const onItemCheckedRef = useRef<typeof onItemChecked>(onItemChecked);
const onItemAllCheckedRef =
useRef<typeof onItemAllChecked>(onItemAllChecked);
const isSyncingChecksFromRowDataRef = useRef(false);
columnsRef.current = columns;
rowDataRef.current = rowData;
cellStyleCallbackRef.current = cellStyleCallback;
@@ -547,6 +722,12 @@ const DataGrid = ({
gridView.setColumnLayout?.(normalizedLayout);
}
const useExcelColumnVisibilityPrep =
shouldPrepareColumnsForExcelExport(columns);
const excelExportVisibilitySnapshot = useExcelColumnVisibilityPrep
? prepareGridColumnsForExcelExport(gridView, columns)
: undefined;
const exportBaseName = excelExportFileSuffix
? `${menuName}_${excelExportFileSuffix}`
: menuName;
@@ -556,8 +737,14 @@ const DataGrid = ({
target: "local",
fileName: `${exportBaseName}.xlsx`,
footer: "hidden",
allColumns: true, // 숨김(visible: false) 컬럼도 엑셀에 포함
allColumns: !useExcelColumnVisibilityPrep,
done: () => {
if (excelExportVisibilitySnapshot) {
restoreGridColumnsAfterExcelExport(
gridView,
excelExportVisibilitySnapshot,
);
}
// 헤더 원복
columns.forEach((col) => {
if (col.fieldName && gridView.columnByName) {
@@ -807,6 +994,7 @@ const DataGrid = ({
const gridView = gridRef.current.gridView;
const dataProvider = gridRef.current.dataProvider;
applyDefaultGridEditOptions(gridView as GridViewWithEditOptions);
// mergeCells: true인 컬럼이 있으면 onSorting에서 클라이언트 정렬로 처리
const hasMergeCells = columns.some((col) => col.mergeCells === true);
@@ -850,23 +1038,15 @@ const DataGrid = ({
)
: () => false;
(gridView.displayOptions as DisplayOptions).fitStyle =
GridFitStyle.EVEN;
(gridView.displayOptions as DisplayOptions).fitStyle = fitStyle;
gridView.displayOptions.rowHeight = 30;
gridView.header.height = 60;
gridView.header.height = headerHeight;
if (columns.some((c) => Boolean(resolveColumnTooltip(c)))) {
(gridView.header as { showTooltip?: boolean }).showTooltip = true;
}
columns.forEach((column) => {
(
gridView as {
setColumnProperty?: (
col: string,
key: string,
value: unknown,
) => void;
}
).setColumnProperty?.(
const gv = gridView as GridViewWithSetColumnProperty;
gv.setColumnProperty?.(
column.name,
"header",
realGridHeaderWithTooltip({
@@ -874,7 +1054,11 @@ const DataGrid = ({
tooltip: resolveColumnTooltip(column),
}),
);
applyColumnListEditorProperties(gv, column);
});
if (columns.some((c) => isDropDownEditorColumn(c))) {
(gridView as { refresh?: () => void }).refresh?.();
}
(
gridView as {
onShowHeaderTooltip?: (
@@ -1059,6 +1243,12 @@ const DataGrid = ({
(currentGridView as GridViewWithSetColumns).setColumns?.(
columnsWithFooter,
);
columns.forEach((col) =>
applyColumnListEditorProperties(
currentGridView as GridViewWithSetColumnProperty,
col,
),
);
applyFooterSettings(currentGridView);
if (hasFooterUserSpans) {
currentGridView.setColumnLayout?.(columnLayout);
@@ -1178,6 +1368,9 @@ const DataGrid = ({
rowData,
showHeaderCheckbox,
getGridDataRef,
headerHeight,
fitStyle,
enableSort,
]);
// 행 클릭 이벤트 + 검증 오류 셀 클릭 시 showMessage (공식 문서 방식)
@@ -1278,6 +1471,7 @@ const DataGrid = ({
if (onItemChecked) {
gridView.onItemChecked = (_grid, itemIndex, checked) => {
if (isSyncingChecksFromRowDataRef.current) return;
onItemCheckedRef.current?.(itemIndex, checked);
};
} else {
@@ -1286,6 +1480,7 @@ const DataGrid = ({
if (onItemAllChecked) {
gridView.onItemAllChecked = (_grid, checked) => {
if (isSyncingChecksFromRowDataRef.current) return;
onItemAllCheckedRef.current?.(checked);
};
} else {
@@ -1299,6 +1494,45 @@ const DataGrid = ({
};
}, [onItemChecked, onItemAllChecked]);
useEffect(() => {
if (!gridRef.current?.gridView || !showHeaderCheckbox) return;
const gridView = gridRef.current.gridView as GridViewWithCheckApi;
const dataProvider = gridRef.current.dataProvider as
| { setRows?: (rows: Record<string, string | number>[]) => void }
| undefined;
if (dataProvider?.setRows) {
dataProvider.setRows(rowData);
}
const hasCheckedColumn = rowData.some((row) =>
Object.prototype.hasOwnProperty.call(row, "__checked"),
);
if (!hasCheckedColumn) return;
const timer = window.setTimeout(() => {
isSyncingChecksFromRowDataRef.current = true;
try {
gridView.checkAll?.(false);
rowData.forEach((row, itemIndex) => {
if (!isRowCheckedFlag((row as Record<string, unknown>).__checked)) return;
if (gridView.checkItem) {
gridView.checkItem(itemIndex, true, false);
return;
}
gridView.checkRow?.(itemIndex, true, false);
});
} catch (e) {
console.warn("check sync:", e);
} finally {
isSyncingChecksFromRowDataRef.current = false;
}
}, 0);
return () => window.clearTimeout(timer);
}, [rowData, showHeaderCheckbox]);
useEffect(() => {
const gridView = gridRef.current?.gridView;
if (!gridView) return;
@@ -1419,6 +1653,12 @@ const DataGrid = ({
autoGenerateField={true}
layout={columnLayout}
rows={rowData}
onRGDataColumnsChanged={(grid) => {
reapplyListEditorsOnGrid(
grid as GridViewWithSetColumnProperty,
columnsRef.current,
);
}}
onInitialized={(grid) => {
if (!grid) return;
(
@@ -1458,6 +1698,9 @@ const DataGrid = ({
dataProvider.setRows(rowData);
if (getGridRef) getGridRef.current = gridRef.current;
const gridView = gridRef.current?.gridView ?? grid;
applyDefaultGridEditOptions(
gridView as GridViewWithEditOptions,
);
if (!gridView?.columnByName) return;
setTimeout(() => {
const gv = gridRef.current?.gridView;
@@ -1573,6 +1816,10 @@ const DataGrid = ({
tooltip: resolveColumnTooltip(column),
}),
);
applyColumnListEditorProperties(
gv as GridViewWithSetColumnProperty,
column,
);
if (cellTooltipCallbackRef.current) {
(
gv as {
@@ -1627,9 +1874,15 @@ const DataGrid = ({
{columns.map((column) => {
const baseStyleName = column.styleName;
const columnReactKey =
column.values?.length &&
isDropDownEditorColumn(column)
? `${column.name}-dropdown-${column.values.length}`
: column.name;
return (
<RGDataColumn
key={column.name}
key={columnReactKey}
name={column.name}
fieldName={column.fieldName}
valueType={
@@ -1647,6 +1900,32 @@ const DataGrid = ({
tooltip: resolveColumnTooltip(column),
})}
editable={column.editable}
{...(column.editor != null && {
editor: column.editor,
})}
{...(column.values != null && {
values: column.values,
})}
{...(column.labels != null && {
labels: column.labels,
})}
{...(column.lookupDisplay === true && {
lookupDisplay: true,
})}
{...(isDateEditorColumn(column) && {
field: {
fieldName: column.fieldName,
dataType: ValueType.DATETIME,
datetimeFormat:
column.datetimeFormat ??
column.editor?.datetimeFormat ??
"yyyy-MM-dd",
},
datetimeFormat:
column.datetimeFormat ??
column.editor?.datetimeFormat ??
"yyyy-MM-dd",
})}
{...(column.width != null && {
width: column.width,
})}
@@ -466,7 +466,7 @@ export function ExceptionProcessingHistoryDialog({
gridRef: MutableRefObject<RealGridReact | null>,
) => {
if (!loading && rows.length === 0) {
return <p className="p-6 text-center text-[1.3rem] text-muted-foreground">{emptyMessage}</p>;
return <p className="p-6 text-center text-[1.3rem] text-muted-foreground h-[65vh] flex justify-center items-center">{emptyMessage}</p>;
}
return (
+62 -28
View File
@@ -47,6 +47,47 @@ const getSggListFetcher = async (
});
};
function buildSearchFormValues(
props: SearchFormProps,
sidoList: ApiResponse<SearchCondition> | undefined,
defaultYear: number,
): SearchFormValues {
const d = props.defaultValues;
const initialClsfCd: SearchFormClsfCd =
d?.clsfCd === "ALL" || d?.clsfCd === "INPT" || d?.clsfCd === "VRFC"
? d.clsfCd
: "ALL";
if (d?.sido) {
return {
sido: d.sido,
gugun: d.gugun || "",
year: d.year || defaultYear.toString(),
order: d.order || "",
fctName: d.fctName || "",
bsnmCd: d.bsnmCd || "",
isDetail: false,
isSummary: d.isSummary || "",
clsfCd: initialClsfCd,
};
}
const first = sidoList?.data?.sidoList?.[0];
const useFirstSido = (props.sido ?? defaultSearchFormProps.sido) && first;
return {
sido: useFirstSido ? first.sido : "",
gugun: "",
year: d?.year || defaultYear.toString(),
order: d?.order || "",
fctName: d?.fctName || "",
bsnmCd: useFirstSido ? first.bsnmCd : "",
isDetail: false,
isSummary: d?.isSummary || "",
clsfCd: initialClsfCd,
};
}
const SearchForm = (props: SearchFormProps = {}) => {
const { setMessage: alert } = useAlertStore((state) => state);
const userInfo = useUserStore((state) => state.userInfo);
@@ -82,23 +123,27 @@ const SearchForm = (props: SearchFormProps = {}) => {
},
);
const initialClsfCd: SearchFormClsfCd = (() => {
const d = props.defaultValues?.clsfCd;
if (d === "ALL" || d === "INPT" || d === "VRFC") return d;
return "ALL";
})();
// 마운트 시 SWR 캐시의 시도 목록으로 초기값 설정 (탭 닫았다 열 때 API 재호출 없이 동작)
const [values, setValues] = useState<SearchFormValues>(() =>
buildSearchFormValues(props, sidoList, defaultYear),
);
const [values, setValues] = useState<SearchFormValues>({
sido: props.defaultValues?.sido || "",
gugun: props.defaultValues?.gugun || "",
year: props.defaultValues?.year || defaultYear.toString(),
order: props.defaultValues?.order || "",
fctName: props.defaultValues?.fctName || "",
bsnmCd: props.defaultValues?.bsnmCd || "",
isDetail: false,
isSummary: props.defaultValues?.isSummary || "",
clsfCd: initialClsfCd,
});
// 최초 진입만 시도 API가 늦게 오는 경우
useEffect(() => {
if (props.defaultValues?.sido) return;
const first = sidoList?.data?.sidoList?.[0];
if (!first || !mergedProps.sido) return;
setValues((prev) =>
prev.sido
? prev
: {
...prev,
sido: first.sido,
gugun: "",
bsnmCd: first.bsnmCd,
},
);
}, [sidoList, props.defaultValues?.sido, mergedProps.sido]);
const sggKey: [string, string, string, string] | null =
values.sido && values.sido !== "ALL"
@@ -133,18 +178,7 @@ const SearchForm = (props: SearchFormProps = {}) => {
};
const handleReset = () => {
const resetValues: SearchFormValues = {
sido: "",
gugun: "",
year: String(defaultYear),
order: "",
fctName: "",
bsnmCd: "",
isDetail: false,
isSummary: "",
clsfCd: "ALL",
};
setValues(resetValues);
setValues(buildSearchFormValues(props, sidoList, defaultYear));
props.onReset?.();
};
@@ -334,7 +334,23 @@ const SidoSggFacilityNameSearchForm = ({
}
};
const handleSaveAdminRegions = () => {
const handleSaveAdminRegions = async () => {
const statusCodeResponse = await statisticsApi.CheckStatusCode({
sewerBzmnCd: initialSearchValues?.bsnmCd?.trim() ?? "",
statsCd: 'ST00020002',
statsYr: statsYr?.trim() ?? ''
});
if (statusCodeResponse.status === 'success') {
if (!['0', '1', '4'].includes(statusCodeResponse.data)) {
const codeName = statusCodeResponse.data === '2' ? '제출' : (statusCodeResponse.data === '3' ? '승인' : '검증중');
alert(`시설별 설치 현황 데이터가 ${codeName} 상태일 때는 처리 시설 정보를 변경할 수 없습니다.`);
return;
}
} else {
alert('오류가 발생했습니다.\n관리자에게 문의하세요')
}
if (!statsYr?.trim()) {
alert("상단에서 통계연도로 조회한 뒤 이용해 주세요.");
return;
+14 -1
View File
@@ -16,6 +16,11 @@ import GridTopActionButton from "./GridTopActionButton";
import SplitDataGrid from "./SplitDataGrid";
import { getComputedValueCallback } from "@/components/util/grid/computedFieldCallbacks"
import {
prepareGridColumnsForExcelExport,
restoreGridColumnsAfterExcelExport,
shouldPrepareColumnsForExcelExport,
} from "@/components/util/gridExcelExport"
import { useTabStore } from "@/store/tab-store"
// RealGrid 타입 확장
@@ -432,13 +437,21 @@ const TabDataGrid = ({
gridView.setColumnLayout?.(normalizedLayout);
}
const useExcelColumnVisibilityPrep = shouldPrepareColumnsForExcelExport(columns);
const excelExportVisibilitySnapshot = useExcelColumnVisibilityPrep
? prepareGridColumnsForExcelExport(gridView, columns)
: undefined;
gridView.exportGrid({
type: 'excel',
target: 'local',
fileName: `${menuName}_${tabLabel}.xlsx`,
footer: 'hidden',
allColumns: true, // 숨김(visible: false) 컬럼도 엑셀에 포함
allColumns: !useExcelColumnVisibilityPrep,
done: () => {
if (excelExportVisibilitySnapshot) {
restoreGridColumnsAfterExcelExport(gridView, excelExportVisibilitySnapshot);
}
// 헤더 원복
columns.forEach((col) => {
if (col.fieldName && gridView.columnByName) {
@@ -23,7 +23,11 @@ import {
} from "@common/components";
import DataGrid from "@/components/module/DataGrid";
import { VerificationResultHistoryPagination } from "@/components/module/VerificationResultHistoryPagination";
import type { CellEditedCallbackParams, ColumnInfo } from "@/components/type/DataGrid.type";
import type {
CellEditedCallbackParams,
CellStyleCallbackParams,
ColumnInfo,
} from "@/components/type/DataGrid.type";
import { getUserInfo } from "../../../../../packages/common/components/src/api";
import { statisticsApi } from "@/entities/statistics/api/statistics-service";
import type { VerificationResultListRequest, VerificationResultReportItem } from "@/entities/statistics/model/statistics-interface";
@@ -31,6 +35,16 @@ import { useAlertStore } from "@/shared/lib";
type VerificationResultGridRow = Record<string, string | number>;
type VerificationMethodCd = "NV" | "IMR" | "HB";
const VERIFICATION_METHOD_CODES: VerificationMethodCd[] = ["NV", "IMR", "HB"];
const VRFC_MTHD_NAME_MAP: Record<VerificationMethodCd, string> = {
NV: "일반",
IMR: "I-MR",
HB: "HB",
};
export type VerificationExceptionSubmitItem = {
vrfcSn: number;
prcsOpnnExpln: string;
@@ -60,9 +74,23 @@ const PRCS_YN_LABEL: Record<string, string> = {
"3": "반려",
};
/** History와 동일 + 편집 가능한 처리의견(예외사유, API 필드명 `prcsOpnnExpln`과 동일) */
function verificationMthdCellStyle({
fieldName,
row,
}: CellStyleCallbackParams): string | undefined {
if (fieldName !== "vrfcMthdNm") return undefined;
const code = String(row.vrfcMthdCd ?? "");
if (code === "IMR") return "rg-cell-vrf-mthd-imr";
if (code === "HB") return "rg-cell-vrf-mthd-hb";
if (code === "NV") return "rg-cell-vrf-mthd-nv";
return undefined;
}
/** History와 동일 + 구분(검증유형) + 편집 가능한 처리의견(예외사유) */
const verificationSubmitResultColumnInfo: ColumnInfo = {
columns: [
{ name: "vrfcMthdNm", fieldName: "vrfcMthdNm", header: { text: "구분" }, editable: false },
{ name: "vrfcMthdCd", fieldName: "vrfcMthdCd", header: { text: "검증유형코드" }, editable: false, visible: false },
{ name: "ctpvNm", fieldName: "ctpvNm", header: { text: "시도" }, editable: false },
{ name: "sggNm", fieldName: "sggNm", header: { text: "시군구" }, editable: false },
{ name: "vrfcTrgtNm", fieldName: "vrfcTrgtNm", header: { text: "검증대상" }, editable: false },
@@ -81,6 +109,7 @@ const verificationSubmitResultColumnInfo: ColumnInfo = {
},
],
columnLayout: [
"vrfcMthdNm",
"ctpvNm",
"sggNm",
"vrfcTrgtNm",
@@ -121,7 +150,6 @@ export function VerificationResultDialog({
emptyMessage = "표시할 검증 결과가 없습니다.",
onExceptionSubmitted,
}: VerificationResultDialogProps) {
/** 부모가 매 렌더마다 새 객체를 넘기면 queryContext 참조만 바뀌어 loadInitial이 재실행되므로 필드 값 기준으로 안정화 */
const stableQueryContext = useMemo((): VerificationResultDialogQueryContext | null => {
if (!queryContext) return null;
return queryContext;
@@ -133,47 +161,24 @@ export function VerificationResultDialog({
queryContext?.statsCd,
]);
const [normalPageIndex, setNormalPageIndex] = useState(0);
const [imrPageIndex, setImrPageIndex] = useState(0);
const [normalPageCount, setNormalPageCount] = useState(1);
const [imrPageCount, setImrPageCount] = useState(1);
const [normalLoading, setNormalLoading] = useState(false);
const [imrLoading, setImrLoading] = useState(false);
const [pageIndex, setPageIndex] = useState(0);
const [pageCount, setPageCount] = useState(1);
const [loading, setLoading] = useState(false);
const [errorMessage, setErrorMessage] = useState<string | null>(null);
const [normalRows, setNormalRows] = useState<VerificationResultGridRow[]>([]);
const [imrRows, setImrRows] = useState<VerificationResultGridRow[]>([]);
const [normalCheckedIndices, setNormalCheckedIndices] = useState<Set<number>>(
() => new Set(),
);
const [imrCheckedIndices, setImrCheckedIndices] = useState<Set<number>>(() => new Set());
const [rows, setRows] = useState<VerificationResultGridRow[]>([]);
const [checkedIndices, setCheckedIndices] = useState<Set<number>>(() => new Set());
const normalGridRef = useRef<RealGridReact | null>(null);
const imrGridRef = useRef<RealGridReact | null>(null);
/** 메인 입력 화면처럼 편집 값은 그리드에 두고, 제출 시에만 스냅샷 조회 → Enter 시 rows 재주입 충돌 방지 */
const getNormalGridDataRef = useRef<(() => VerificationResultGridRow[]) | null>(null);
const getImrGridDataRef = useRef<(() => VerificationResultGridRow[]) | null>(null);
const gridRef = useRef<RealGridReact | null>(null);
const getGridDataRef = useRef<(() => VerificationResultGridRow[]) | null>(null);
const alert = useAlertStore((state) => state.setMessage);
const onNormalPrcsOpnnExplnEdited = useCallback((params: CellEditedCallbackParams) => {
const onPrcsOpnnExplnEdited = useCallback((params: CellEditedCallbackParams) => {
if (params.fieldName !== "prcsOpnnExpln") return;
const text = String(params.newValue ?? "");
const hasValue = text.trim().length > 0;
setRowCheckByItemIndex(normalGridRef, params.itemIndex, hasValue);
setNormalCheckedIndices((prev) => {
const next = new Set(prev);
if (hasValue) next.add(params.itemIndex);
else next.delete(params.itemIndex);
return next;
});
}, []);
const onImrPrcsOpnnExplnEdited = useCallback((params: CellEditedCallbackParams) => {
if (params.fieldName !== "prcsOpnnExpln") return;
const text = String(params.newValue ?? "");
const hasValue = text.trim().length > 0;
setRowCheckByItemIndex(imrGridRef, params.itemIndex, hasValue);
setImrCheckedIndices((prev) => {
setRowCheckByItemIndex(gridRef, params.itemIndex, hasValue);
setCheckedIndices((prev) => {
const next = new Set(prev);
if (hasValue) next.add(params.itemIndex);
else next.delete(params.itemIndex);
@@ -182,38 +187,38 @@ export function VerificationResultDialog({
}, []);
const resetState = useCallback(() => {
setNormalPageIndex(0);
setImrPageIndex(0);
setNormalPageCount(1);
setImrPageCount(1);
setNormalLoading(false);
setImrLoading(false);
setPageIndex(0);
setPageCount(1);
setLoading(false);
setErrorMessage(null);
setNormalRows([]);
setImrRows([]);
setNormalCheckedIndices(new Set());
setImrCheckedIndices(new Set());
setRows([]);
setCheckedIndices(new Set());
}, []);
const mapItemToRow = useCallback((item: VerificationResultReportItem): VerificationResultGridRow => {
return {
vrfcSn: item.vrfcSn,
ctpvNm: item.ctpvNm,
sggNm: item.sggNm,
vrfcTrgtNm: item.vrfcTrgtNm,
rsnExpln: item.rsnExpln,
nowVlCn: item.nowVlCn ?? "-",
compVlCn: item.compVlCn ?? "-",
prcsYn: PRCS_YN_LABEL[item.prcsYn] ?? item.prcsYn,
pbcpAprvYn: item.pbcpAprvYn,
regDt: (item.regDt ?? "").slice(0, 16),
mdfcnDt: (item.mdfcnDt ?? "").slice(0, 16),
prcsOpnnExpln: item.prcsOpnnExpln ?? "",
};
}, []);
const mapItemToRow = useCallback(
(item: VerificationResultReportItem, vrfcMthdCd: VerificationMethodCd): VerificationResultGridRow => {
return {
vrfcSn: item.vrfcSn,
vrfcMthdCd,
vrfcMthdNm: VRFC_MTHD_NAME_MAP[vrfcMthdCd] ?? item.vrfcMthdNm,
ctpvNm: item.ctpvNm,
sggNm: item.sggNm,
vrfcTrgtNm: item.vrfcTrgtNm,
rsnExpln: item.rsnExpln,
nowVlCn: item.nowVlCn ?? "-",
compVlCn: item.compVlCn ?? "-",
prcsYn: PRCS_YN_LABEL[item.prcsYn] ?? item.prcsYn,
pbcpAprvYn: item.pbcpAprvYn,
regDt: (item.regDt ?? "").slice(0, 16),
mdfcnDt: (item.mdfcnDt ?? "").slice(0, 16),
prcsOpnnExpln: item.prcsOpnnExpln ?? "",
};
},
[],
);
const fetchVerificationResultPage = useCallback(
async (vrfcMthdCd: "NV" | "IMR", pageIndex: number) => {
async (vrfcMthdCd: VerificationMethodCd, page: number) => {
if (!stableQueryContext) return null;
const param: VerificationResultListRequest = {
sewerBzmnCd: stableQueryContext.sewerBzmnCd,
@@ -224,7 +229,7 @@ export function VerificationResultDialog({
pbcpAprvYn: "N",
vrfcMthdCd,
prcsYnList: "0",
page: pageIndex + 1,
page: page + 1,
size: 10000,
};
return statisticsApi.getVerificationResultList(param);
@@ -232,81 +237,56 @@ export function VerificationResultDialog({
[stableQueryContext],
);
const loadInitial = useCallback(async () => {
if (!stableQueryContext) {
setErrorMessage("조회 조건이 없어 검증 결과를 불러올 수 없습니다.");
return;
}
setErrorMessage(null);
setNormalLoading(true);
setImrLoading(true);
setNormalPageIndex(0);
setImrPageIndex(0);
try {
const [nvRes, imrRes] = await Promise.all([
fetchVerificationResultPage("NV", 0),
fetchVerificationResultPage("IMR", 0),
]);
const nvList = nvRes?.data?.verifyResultList ?? [];
const imrList = imrRes?.data?.verifyResultList ?? [];
setNormalRows(nvList.map(mapItemToRow));
setImrRows(imrList.map(mapItemToRow));
setNormalPageCount(nvRes?.data?.pageInfo?.totalPages ?? 1);
setImrPageCount(imrRes?.data?.pageInfo?.totalPages ?? 1);
} catch {
setNormalRows([]);
setImrRows([]);
setNormalPageCount(1);
setImrPageCount(1);
setErrorMessage("검증 결과 조회 중 오류가 발생했습니다.");
} finally {
setNormalLoading(false);
setImrLoading(false);
setNormalCheckedIndices(new Set());
setImrCheckedIndices(new Set());
}
}, [fetchVerificationResultPage, mapItemToRow, stableQueryContext]);
const handleNormalPageChange = useCallback(
async (pageIndex: number) => {
if (pageIndex === normalPageIndex || !stableQueryContext) return;
const loadPage = useCallback(
async (nextPageIndex: number) => {
if (!stableQueryContext) {
setErrorMessage("조회 조건이 없어 검증 결과를 불러올 수 없습니다.");
return;
}
setErrorMessage(null);
setNormalLoading(true);
setLoading(true);
try {
const response = await fetchVerificationResultPage("NV", pageIndex);
const list = response?.data?.verifyResultList ?? [];
setNormalRows(list.map(mapItemToRow));
setNormalPageCount(response?.data?.pageInfo?.totalPages ?? 1);
setNormalPageIndex(pageIndex);
const responses = await Promise.all(
VERIFICATION_METHOD_CODES.map((code) =>
fetchVerificationResultPage(code, nextPageIndex),
),
);
const merged: VerificationResultGridRow[] = [];
let maxPageCount = 1;
VERIFICATION_METHOD_CODES.forEach((code, index) => {
const response = responses[index];
const list = response?.data?.verifyResultList ?? [];
merged.push(...list.map((item) => mapItemToRow(item, code)));
maxPageCount = Math.max(
maxPageCount,
response?.data?.pageInfo?.totalPages ?? 1,
);
});
setRows(merged);
setPageCount(maxPageCount);
setPageIndex(nextPageIndex);
} catch {
setErrorMessage("일반검증 내역 조회 중 오류가 발생했습니다.");
setRows([]);
setPageCount(1);
setErrorMessage("검증 결과 조회 중 오류가 발생했습니다.");
} finally {
setNormalLoading(false);
setNormalCheckedIndices(new Set());
setLoading(false);
setCheckedIndices(new Set());
}
},
[fetchVerificationResultPage, mapItemToRow, normalPageIndex, stableQueryContext],
[fetchVerificationResultPage, mapItemToRow, stableQueryContext],
);
const handleImrPageChange = useCallback(
async (pageIndex: number) => {
if (pageIndex === imrPageIndex || !stableQueryContext) return;
setErrorMessage(null);
setImrLoading(true);
try {
const response = await fetchVerificationResultPage("IMR", pageIndex);
const list = response?.data?.verifyResultList ?? [];
setImrRows(list.map(mapItemToRow));
setImrPageCount(response?.data?.pageInfo?.totalPages ?? 1);
setImrPageIndex(pageIndex);
} catch {
setErrorMessage("I-MR 검증내역 조회 중 오류가 발생했습니다.");
} finally {
setImrLoading(false);
setImrCheckedIndices(new Set());
}
const loadInitial = useCallback(async () => {
await loadPage(0);
}, [loadPage]);
const handlePageChange = useCallback(
async (nextPageIndex: number) => {
if (nextPageIndex === pageIndex || !stableQueryContext) return;
await loadPage(nextPageIndex);
},
[fetchVerificationResultPage, mapItemToRow, imrPageIndex, stableQueryContext],
[loadPage, pageIndex, stableQueryContext],
);
useEffect(() => {
@@ -317,58 +297,32 @@ export function VerificationResultDialog({
void loadInitial();
}, [loadInitial, open, resetState]);
/** 조회된 행에 예외사유 텍스트가 있으면 체크바·체크 인덱스와 맞춤 */
useEffect(() => {
if (normalLoading) return;
if (loading) return;
const id = requestAnimationFrame(() => {
setNormalCheckedIndices((prev) => {
setCheckedIndices((prev) => {
const next = new Set(prev);
normalRows.forEach((row, itemIndex) => {
rows.forEach((row, itemIndex) => {
const has = String(row.prcsOpnnExpln ?? "").trim().length > 0;
setRowCheckByItemIndex(normalGridRef, itemIndex, has);
setRowCheckByItemIndex(gridRef, itemIndex, has);
if (has) next.add(itemIndex);
});
return next;
});
});
return () => cancelAnimationFrame(id);
}, [normalRows, normalLoading]);
useEffect(() => {
if (imrLoading) return;
const id = requestAnimationFrame(() => {
setImrCheckedIndices((prev) => {
const next = new Set(prev);
imrRows.forEach((row, itemIndex) => {
const has = String(row.prcsOpnnExpln ?? "").trim().length > 0;
setRowCheckByItemIndex(imrGridRef, itemIndex, has);
if (has) next.add(itemIndex);
});
return next;
});
});
return () => cancelAnimationFrame(id);
}, [imrRows, imrLoading]);
}, [rows, loading]);
const handleExceptionSubmitClick = useCallback(async () => {
const nvGrid = getNormalGridDataRef.current?.() ?? [];
const imGrid = getImrGridDataRef.current?.() ?? [];
const gridRows = getGridDataRef.current?.() ?? [];
const payload: VerificationExceptionSubmitItem[] = [];
const pushChecked = (
indices: Set<number>,
gridRows: VerificationResultGridRow[],
idRows: VerificationResultGridRow[],
) => {
indices.forEach((idx) => {
const prcsOpnnExpln = String(gridRows[idx]?.prcsOpnnExpln ?? "").trim();
if (!prcsOpnnExpln) return;
const vrfcSn = Number(idRows[idx]?.vrfcSn);
if (!Number.isFinite(vrfcSn)) return;
payload.push({ vrfcSn, prcsOpnnExpln });
});
};
pushChecked(normalCheckedIndices, nvGrid, normalRows);
pushChecked(imrCheckedIndices, imGrid, imrRows);
checkedIndices.forEach((idx) => {
const prcsOpnnExpln = String(gridRows[idx]?.prcsOpnnExpln ?? "").trim();
if (!prcsOpnnExpln) return;
const vrfcSn = Number(rows[idx]?.vrfcSn);
if (!Number.isFinite(vrfcSn)) return;
payload.push({ vrfcSn, prcsOpnnExpln });
});
if (payload.length === 0) {
alert("작성된 예외 제출 항목이 없습니다.");
return;
@@ -402,75 +356,13 @@ export function VerificationResultDialog({
}
}, [
alert,
imrCheckedIndices,
imrRows,
normalCheckedIndices,
normalRows,
checkedIndices,
rows,
onExceptionSubmitted,
onOpenChange,
stableQueryContext,
]);
const renderGrid = (
rows: VerificationResultGridRow[],
setRows: Dispatch<SetStateAction<VerificationResultGridRow[]>>,
pageIndex: number,
pageCount: number,
onPageIndexChange: (page: number) => void,
loading: boolean,
gridRef: MutableRefObject<RealGridReact | null>,
getGridDataRef: MutableRefObject<(() => VerificationResultGridRow[]) | null>,
onPrcsOpnnExplnCellEdited: (params: CellEditedCallbackParams) => void,
setCheckedIndices: Dispatch<SetStateAction<Set<number>>>,
) => {
if (!loading && rows.length === 0) {
return <p className="p-6 text-center text-[1.3rem] text-muted-foreground">{emptyMessage}</p>;
}
return (
<div className="relative space-y-2">
<DataGrid
columnInfo={verificationSubmitResultColumnInfo}
rowData={rows}
setRowData={setRows as Dispatch<SetStateAction<Record<string, string | number>[]>>}
isExcelImport={false}
isExcelExport={false}
showHeaderCheckbox
enableSort={false}
getGridRef={gridRef}
getGridDataRef={getGridDataRef}
onCellEdited={onPrcsOpnnExplnCellEdited}
onItemChecked={(itemIndex, checked) => {
setCheckedIndices((prev) => {
const next = new Set(prev);
if (checked) next.add(itemIndex);
else next.delete(itemIndex);
return next;
});
}}
onItemAllChecked={(checked) => {
setCheckedIndices(
checked ? new Set(rows.map((_, i) => i)) : new Set(),
);
}}
/>
<VerificationResultHistoryPagination
pageCount={Math.max(pageCount, 1)}
pageIndex={Math.max(pageIndex, 0)}
onPageIndexChange={onPageIndexChange}
className="mx-2 mb-2"
/>
{loading && (
<div className="absolute inset-0 z-10 rounded-md [data-theme='black']:bg-transparent bg-white/40 flex items-center justify-center">
<div className="relative size-[5rem]">
<LoadingWithAnimation color="primary" />
</div>
</div>
)}
</div>
);
};
const titleSuffix = useMemo(() => {
if (!stableQueryContext) return "";
const sgg = stableQueryContext.sggNm?.trim() ? ` ${stableQueryContext.sggNm}` : "";
@@ -488,38 +380,61 @@ export function VerificationResultDialog({
</DialogHeader>
<div className="max-h-[85vh] space-y-5 overflow-auto">
{errorMessage && (
<p className="rounded-md border border-destructive/40 bg-destructive/10 px-3 py-2 text-[1.2rem] text-destructive">
<p className="rounded-md border border-destructive/40 bg-destructive/10 px-3 py-2 text-[1.2rem] text-destructive h-[65vh] flex justify-center items-center">
{errorMessage}
</p>
)}
<section className="rounded-md border border-border">
<h3 className="border-b border-border px-4 py-2 text-[1.35rem] font-semibold">일반검증 내역</h3>
{renderGrid(
normalRows,
setNormalRows,
normalPageIndex,
normalPageCount,
handleNormalPageChange,
normalLoading,
normalGridRef,
getNormalGridDataRef,
onNormalPrcsOpnnExplnEdited,
setNormalCheckedIndices,
)}
</section>
<section className="rounded-md border border-border">
<h3 className="border-b border-border px-4 py-2 text-[1.35rem] font-semibold">I-MR 검증내역</h3>
{renderGrid(
imrRows,
setImrRows,
imrPageIndex,
imrPageCount,
handleImrPageChange,
imrLoading,
imrGridRef,
getImrGridDataRef,
onImrPrcsOpnnExplnEdited,
setImrCheckedIndices,
{!loading && rows.length === 0 ? (
<p className="p-6 text-center text-[1.3rem] text-muted-foreground">
{emptyMessage}
</p>
) : (
<div className="relative space-y-2">
<DataGrid
columnInfo={verificationSubmitResultColumnInfo}
rowData={rows}
setRowData={
setRows as Dispatch<
SetStateAction<Record<string, string | number>[]>
>
}
isExcelImport={false}
isExcelExport={false}
showHeaderCheckbox
enableSort={false}
getGridRef={gridRef}
getGridDataRef={getGridDataRef}
onCellEdited={onPrcsOpnnExplnEdited}
cellStyleCallback={verificationMthdCellStyle}
onItemChecked={(itemIndex, checked) => {
setCheckedIndices((prev) => {
const next = new Set(prev);
if (checked) next.add(itemIndex);
else next.delete(itemIndex);
return next;
});
}}
onItemAllChecked={(checked) => {
setCheckedIndices(
checked ? new Set(rows.map((_, i) => i)) : new Set(),
);
}}
/>
<VerificationResultHistoryPagination
pageCount={Math.max(pageCount, 1)}
pageIndex={Math.max(pageIndex, 0)}
onPageIndexChange={handlePageChange}
className="mx-2 mb-2"
/>
{loading && (
<div className="absolute inset-0 z-10 flex items-center justify-center rounded-md bg-white/40 [data-theme='black']:bg-transparent">
<div className="relative size-[5rem]">
<LoadingWithAnimation color="primary" />
</div>
</div>
)}
</div>
)}
</section>
</div>
@@ -1,7 +1,18 @@
"use client";
import { type Dispatch, type SetStateAction, useCallback, useEffect, useMemo, useState } from "react";
import {
type Dispatch,
type MutableRefObject,
type SetStateAction,
useCallback,
useEffect,
useMemo,
useRef,
useState,
} from "react";
import type { RealGridReact } from "realgrid-react";
import { getUserInfo } from "../../../../../packages/common/components/src/api";
import { statisticsApi } from "@/entities/statistics/api/statistics-service";
import type { VerificationResultListRequest, VerificationResultReportItem } from "@/entities/statistics/model/statistics-interface";
import {
@@ -22,6 +33,7 @@ import {
import DataGrid from "@/components/module/DataGrid";
import { VerificationResultHistoryPagination } from "@/components/module/VerificationResultHistoryPagination";
import type { CellStyleCallbackParams, ColumnInfo } from "@/components/type/DataGrid.type";
import { useAlertStore } from "@/shared/lib";
type VerificationResultGridRow = Record<string, string | number>;
type VerificationHistoryTabKey = "NV" | "IMR" | "HB";
@@ -32,6 +44,24 @@ const verificationHistoryTabs: { value: VerificationHistoryTabKey; label: string
{ value: "HB", label: "HB 검증내역" },
];
const PAGE_SIZE = 30;
/** vrfcSn → prcsYn (페이지 이동 후에도 취소 가능 여부 검증용) */
const createEmptyCheckedVrfcByTab = (): Record<VerificationHistoryTabKey, Map<number, string>> => ({
NV: new Map(),
IMR: new Map(),
HB: new Map(),
});
const initialTabPageState = (): Record<
VerificationHistoryTabKey,
{ pageIndex: number; pageCount: number }
> => ({
NV: { pageIndex: 0, pageCount: 1 },
IMR: { pageIndex: 0, pageCount: 1 },
HB: { pageIndex: 0, pageCount: 1 },
});
function prcsYnCodeToNumber(raw: string | number | undefined): number | undefined {
if (raw === undefined || raw === null) return undefined;
if (typeof raw === "number" && Number.isFinite(raw)) return raw;
@@ -41,7 +71,62 @@ function prcsYnCodeToNumber(raw: string | number | undefined): number | undefine
return Number.isFinite(n) ? n : undefined;
}
/** prcsYn 0·4 → 빨간색, 그 외 → 초록색 (처리여부 컬럼) */
/** 예외 제출 취소 가능: prcsYn = 2 (예외처리) */
function canExceptionReset(prcsYn: string | number | undefined): boolean {
return prcsYnCodeToNumber(prcsYn) === 2;
}
const EXCEPTION_RESET_ONLY_PRCS_YN_2_MESSAGE =
"예외 제출 취소는 처리여부가 사용자예외처리인 항목만 선택할 수 있습니다.";
type GridViewWithCheckApi = {
getCheckedRows?: (allItems?: boolean, visibleOnly?: boolean, exclusive?: boolean) => number[];
checkItem?: (itemIndex: number, checked: boolean, exclusive?: boolean) => void;
};
function uncheckGridRow(gridRef: MutableRefObject<RealGridReact | null>, itemIndex: number) {
try {
(gridRef.current?.gridView as GridViewWithCheckApi | undefined)?.checkItem?.(itemIndex, false, false);
} catch {
/* ignore */
}
}
function syncCheckedMapFromGrid(
entries: VerificationResultReportItem[],
gridRef: MutableRefObject<RealGridReact | null>,
prevMap: Map<number, string>,
): Map<number, string> {
const next = new Map(prevMap);
for (const entry of entries) {
const sn = Number(entry.vrfcSn);
if (Number.isFinite(sn)) next.delete(sn);
}
const indices =
(gridRef.current?.gridView as GridViewWithCheckApi | undefined)?.getCheckedRows?.(true, false, true) ??
[];
for (const itemIndex of indices) {
const entry = entries[itemIndex];
if (!entry) continue;
const sn = Number(entry.vrfcSn);
if (!Number.isFinite(sn)) continue;
next.set(sn, String(entry.prcsYn ?? ""));
}
return next;
}
function selectionHasNonExceptionResetAllowed(
map: Map<number, string>,
currentEntries: VerificationResultReportItem[],
): boolean {
for (const [vrfcSn, storedPrcsYn] of map) {
const onPage = currentEntries.find((e) => Number(e.vrfcSn) === vrfcSn);
const prcsYn = onPage ? onPage.prcsYn : storedPrcsYn;
if (!canExceptionReset(prcsYn)) return true;
}
return false;
}
function verificationPrcsYnNmCellStyle({ fieldName, row }: CellStyleCallbackParams): string | undefined {
if (fieldName !== "prcsYnNm") return undefined;
const code = prcsYnCodeToNumber(row.prcsYn);
@@ -58,12 +143,24 @@ const verificationResultColumnInfo: ColumnInfo = {
{ name: "nowVlCn", fieldName: "nowVlCn", header: { text: "입력값" }, editable: false },
{ name: "compVlCn", fieldName: "compVlCn", header: { text: "기준값" }, editable: false },
{ name: "prcsYnNm", fieldName: "prcsYnNm", header: { text: "처리여부" }, editable: false },
{ name: "pbcpAprvYn", fieldName: "pbcpAprvYn", header: { text: "공단승인여부" }, editable: false },
{ name: "regDt", fieldName: "regDt", header: { text: "등록일시" }, editable: false },
{ name: "mdfcnDt", fieldName: "mdfcnDt", header: { text: "갱신일시" }, editable: false },
{ name: "prcsYn", fieldName: "prcsYn", header: { text: "검증결과상태코드" }, editable: false, visible: false },
{ name: "vrfcSn", fieldName: "vrfcSn", header: { text: "검증일련번호" }, editable: false, visible: false },
],
columnLayout: [
"ctpvNm",
"sggNm",
"vrfcTrgtNm",
"rsnExpln",
"nowVlCn",
"compVlCn",
"prcsYnNm",
"regDt",
"mdfcnDt",
"prcsYn",
"vrfcSn",
],
columnLayout: ["ctpvNm", "sggNm", "vrfcTrgtNm", "rsnExpln", "nowVlCn", "compVlCn", "prcsYnNm", "pbcpAprvYn", "regDt", "mdfcnDt", "prcsYn"],
};
export interface VerificationResultHistoryDialogProps {
@@ -87,152 +184,168 @@ export function VerificationResultHistoryDialog({
title = "검증 결과 내역",
emptyMessage = "표시할 검증 결과가 없습니다.",
}: VerificationResultHistoryDialogProps) {
const stableQueryContext = useMemo(() => {
if (!queryContext) return null;
return queryContext;
}, [
queryContext?.sewerBzmnCd,
queryContext?.ctpvNm,
queryContext?.sggNm,
queryContext?.statsYr,
queryContext?.statsCd,
]);
const [activeTab, setActiveTab] = useState<VerificationHistoryTabKey>("NV");
const [normalEntries, setNormalEntries] = useState<VerificationResultReportItem[]>([]);
const [imrEntries, setImrEntries] = useState<VerificationResultReportItem[]>([]);
const [hbEntries, setHbEntries] = useState<VerificationResultReportItem[]>([]);
const [normalPageIndex, setNormalPageIndex] = useState(0);
const [imrPageIndex, setImrPageIndex] = useState(0);
const [hbPageIndex, setHbPageIndex] = useState(0);
const [normalPageCount, setNormalPageCount] = useState(1);
const [imrPageCount, setImrPageCount] = useState(1);
const [hbPageCount, setHbPageCount] = useState(1);
const [normalLoading, setNormalLoading] = useState(false);
const [imrLoading, setImrLoading] = useState(false);
const [hbLoading, setHbLoading] = useState(false);
const [entriesByTab, setEntriesByTab] = useState<
Record<VerificationHistoryTabKey, VerificationResultReportItem[]>
>({
NV: [],
IMR: [],
HB: [],
});
const [tabPages, setTabPages] = useState(initialTabPageState);
const [loadingByTab, setLoadingByTab] = useState<Record<VerificationHistoryTabKey, boolean>>({
NV: false,
IMR: false,
HB: false,
});
const [errorMessage, setErrorMessage] = useState<string | null>(null);
const [resetSubmitting, setResetSubmitting] = useState(false);
const [checkedVrfcByTab, setCheckedVrfcByTab] = useState(createEmptyCheckedVrfcByTab);
const nvGridRef = useRef<RealGridReact | null>(null);
const imrGridRef = useRef<RealGridReact | null>(null);
const hbGridRef = useRef<RealGridReact | null>(null);
const alert = useAlertStore((state) => state.setMessage);
const gridRefByTab: Record<VerificationHistoryTabKey, MutableRefObject<RealGridReact | null>> = {
NV: nvGridRef,
IMR: imrGridRef,
HB: hbGridRef,
};
const checkedVrfcByTabRef = useRef(checkedVrfcByTab);
checkedVrfcByTabRef.current = checkedVrfcByTab;
const resetState = useCallback(() => {
setActiveTab("NV");
setNormalEntries([]);
setImrEntries([]);
setHbEntries([]);
setNormalPageIndex(0);
setImrPageIndex(0);
setHbPageIndex(0);
setNormalPageCount(1);
setImrPageCount(1);
setHbPageCount(1);
setNormalLoading(false);
setImrLoading(false);
setHbLoading(false);
setEntriesByTab({ NV: [], IMR: [], HB: [] });
setTabPages(initialTabPageState());
setLoadingByTab({ NV: false, IMR: false, HB: false });
setErrorMessage(null);
setResetSubmitting(false);
setCheckedVrfcByTab(createEmptyCheckedVrfcByTab());
}, []);
const fetchVerificationResultPage = useCallback(
async (vrfcMthdCd: VerificationHistoryTabKey, pageIndex: number) => {
if (!queryContext) return null;
if (!stableQueryContext) return null;
const param: VerificationResultListRequest = {
sewerBzmnCd: queryContext.sewerBzmnCd,
ctpvNm: queryContext.ctpvNm,
sggNm: queryContext.sggNm,
statsYr: queryContext.statsYr,
statsCd: queryContext.statsCd,
pbcpAprvYn: 'N',
sewerBzmnCd: stableQueryContext.sewerBzmnCd,
ctpvNm: stableQueryContext.ctpvNm,
sggNm: stableQueryContext.sggNm,
statsYr: stableQueryContext.statsYr,
statsCd: stableQueryContext.statsCd,
pbcpAprvYn: "N",
vrfcMthdCd,
prcsYnList: "0,2,3",
page: pageIndex + 1,
size: 30,
size: PAGE_SIZE,
};
return statisticsApi.getVerificationResultList(param);
},
[queryContext],
[stableQueryContext],
);
const applyPageResponse = useCallback(
(tab: VerificationHistoryTabKey, pageIndex: number, list: VerificationResultReportItem[], totalPages: number) => {
const pageCount = Math.max(totalPages, 1);
const clampedIndex = Math.min(Math.max(pageIndex, 0), pageCount - 1);
setEntriesByTab((prev) => ({ ...prev, [tab]: list }));
setTabPages((prev) => ({
...prev,
[tab]: { pageIndex: clampedIndex, pageCount },
}));
},
[],
);
const loadTabPage = useCallback(
async (tab: VerificationHistoryTabKey, pageIndex: number) => {
if (!stableQueryContext) {
setErrorMessage("조회 조건이 없어 검증 결과를 불러올 수 없습니다.");
return;
}
setErrorMessage(null);
setLoadingByTab((prev) => ({ ...prev, [tab]: true }));
try {
const response = await fetchVerificationResultPage(tab, pageIndex);
const list = response?.data?.verifyResultList ?? [];
const totalPages = response?.data?.pageInfo?.totalPages ?? 1;
const apiCurrentPage = response?.data?.pageInfo?.currentPage;
const resolvedPageIndex =
apiCurrentPage != null && Number.isFinite(apiCurrentPage)
? Math.max(0, Number(apiCurrentPage) - 1)
: pageIndex;
applyPageResponse(tab, resolvedPageIndex, list, totalPages);
} catch {
const tabLabel =
verificationHistoryTabs.find((t) => t.value === tab)?.label ?? tab;
setErrorMessage(`${tabLabel} 조회 중 오류가 발생했습니다.`);
} finally {
setLoadingByTab((prev) => ({ ...prev, [tab]: false }));
}
},
[applyPageResponse, fetchVerificationResultPage, stableQueryContext],
);
const loadInitial = useCallback(async () => {
if (!queryContext) {
if (!stableQueryContext) {
setErrorMessage("조회 조건이 없어 검증 결과를 불러올 수 없습니다.");
return;
}
setErrorMessage(null);
setNormalLoading(true);
setImrLoading(true);
setHbLoading(true);
setNormalPageIndex(0);
setImrPageIndex(0);
setHbPageIndex(0);
setTabPages(initialTabPageState());
setLoadingByTab({ NV: true, IMR: true, HB: true });
try {
const [nvRes, imrRes, hbRes] = await Promise.all([
fetchVerificationResultPage("NV", 0),
fetchVerificationResultPage("IMR", 0),
fetchVerificationResultPage("HB", 0),
]);
setNormalEntries(nvRes?.data?.verifyResultList ?? []);
setImrEntries(imrRes?.data?.verifyResultList ?? []);
setHbEntries(hbRes?.data?.verifyResultList ?? []);
setNormalPageCount(nvRes?.data?.pageInfo?.totalPages ?? 1);
setImrPageCount(imrRes?.data?.pageInfo?.totalPages ?? 1);
setHbPageCount(hbRes?.data?.pageInfo?.totalPages ?? 1);
const results = await Promise.all(
verificationHistoryTabs.map((tab) =>
fetchVerificationResultPage(tab.value, 0),
),
);
setEntriesByTab({
NV: results[0]?.data?.verifyResultList ?? [],
IMR: results[1]?.data?.verifyResultList ?? [],
HB: results[2]?.data?.verifyResultList ?? [],
});
setTabPages({
NV: {
pageIndex: 0,
pageCount: Math.max(results[0]?.data?.pageInfo?.totalPages ?? 1, 1),
},
IMR: {
pageIndex: 0,
pageCount: Math.max(results[1]?.data?.pageInfo?.totalPages ?? 1, 1),
},
HB: {
pageIndex: 0,
pageCount: Math.max(results[2]?.data?.pageInfo?.totalPages ?? 1, 1),
},
});
} catch {
setNormalEntries([]);
setImrEntries([]);
setHbEntries([]);
setNormalPageCount(1);
setImrPageCount(1);
setHbPageCount(1);
setEntriesByTab({ NV: [], IMR: [], HB: [] });
setTabPages(initialTabPageState());
setErrorMessage("검증 결과 내역 조회 중 오류가 발생했습니다.");
} finally {
setNormalLoading(false);
setImrLoading(false);
setHbLoading(false);
setLoadingByTab({ NV: false, IMR: false, HB: false });
}
}, [fetchVerificationResultPage, queryContext]);
}, [fetchVerificationResultPage, stableQueryContext]);
const handleNormalPageChange = useCallback(
async (pageIndex: number) => {
if (pageIndex === normalPageIndex || !queryContext) return;
setErrorMessage(null);
setNormalLoading(true);
try {
const response = await fetchVerificationResultPage("NV", pageIndex);
setNormalEntries(response?.data?.verifyResultList ?? []);
setNormalPageCount(response?.data?.pageInfo?.totalPages ?? 1);
setNormalPageIndex(pageIndex);
} catch {
setErrorMessage("일반검증 내역 조회 중 오류가 발생했습니다.");
} finally {
setNormalLoading(false);
}
const handleTabPageChange = useCallback(
(tab: VerificationHistoryTabKey, pageIndex: number) => {
if (pageIndex === tabPages[tab].pageIndex) return;
void loadTabPage(tab, pageIndex);
},
[fetchVerificationResultPage, normalPageIndex, queryContext],
);
const handleImrPageChange = useCallback(
async (pageIndex: number) => {
if (pageIndex === imrPageIndex || !queryContext) return;
setErrorMessage(null);
setImrLoading(true);
try {
const response = await fetchVerificationResultPage("IMR", pageIndex);
setImrEntries(response?.data?.verifyResultList ?? []);
setImrPageCount(response?.data?.pageInfo?.totalPages ?? 1);
setImrPageIndex(pageIndex);
} catch {
setErrorMessage("I-MR 검증내역 조회 중 오류가 발생했습니다.");
} finally {
setImrLoading(false);
}
},
[fetchVerificationResultPage, imrPageIndex, queryContext],
);
const handleHbPageChange = useCallback(
async (pageIndex: number) => {
if (pageIndex === hbPageIndex || !queryContext) return;
setErrorMessage(null);
setHbLoading(true);
try {
const response = await fetchVerificationResultPage("HB", pageIndex);
setHbEntries(response?.data?.verifyResultList ?? []);
setHbPageCount(response?.data?.pageInfo?.totalPages ?? 1);
setHbPageIndex(pageIndex);
} catch {
setErrorMessage("HB 검증내역 조회 중 오류가 발생했습니다.");
} finally {
setHbLoading(false);
}
},
[fetchVerificationResultPage, hbPageIndex, queryContext],
[loadTabPage, tabPages],
);
useEffect(() => {
@@ -243,56 +356,173 @@ export function VerificationResultHistoryDialog({
void loadInitial();
}, [loadInitial, open, resetState]);
const mapRows = (entries: VerificationResultReportItem[]): VerificationResultGridRow[] =>
entries.map((item) => ({
ctpvNm: item.ctpvNm,
sggNm: item.sggNm,
vrfcTrgtNm: item.vrfcTrgtNm,
rsnExpln: item.rsnExpln,
nowVlCn: item.nowVlCn ?? "-",
compVlCn: item.compVlCn ?? "-",
prcsYnNm: item.prcsYnNm ?? "-",
prcsYn: item.prcsYn ?? "",
pbcpAprvYn: item.pbcpAprvYn,
regDt: (item.regDt ?? "").slice(0, 16),
mdfcnDt: (item.mdfcnDt ?? "").slice(0, 16),
}));
const updateCheckedVrfcForTab = useCallback(
(tab: VerificationHistoryTabKey, updater: (map: Map<number, string>) => void) => {
setCheckedVrfcByTab((prev) => {
const nextMap = new Map(prev[tab]);
updater(nextMap);
return { ...prev, [tab]: nextMap };
});
},
[],
);
const normalRows = useMemo(() => mapRows(normalEntries), [normalEntries]);
const imrRows = useMemo(() => mapRows(imrEntries), [imrEntries]);
const hbRows = useMemo(() => mapRows(hbEntries), [hbEntries]);
const mapRows = useCallback(
(entries: VerificationResultReportItem[], checkedMap: Map<number, string>): VerificationResultGridRow[] =>
entries.map((item) => {
const vrfcSn = Number(item.vrfcSn);
const isChecked = Number.isFinite(vrfcSn) && checkedMap.has(vrfcSn);
return {
vrfcSn: item.vrfcSn,
__checked: isChecked ? 1 : 0,
ctpvNm: item.ctpvNm,
sggNm: item.sggNm,
vrfcTrgtNm: item.vrfcTrgtNm,
rsnExpln: item.rsnExpln,
nowVlCn: item.nowVlCn ?? "-",
compVlCn: item.compVlCn ?? "-",
prcsYnNm: item.prcsYnNm ?? "-",
prcsYn: item.prcsYn ?? "",
pbcpAprvYn: item.pbcpAprvYn,
regDt: (item.regDt ?? "").slice(0, 16),
mdfcnDt: (item.mdfcnDt ?? "").slice(0, 16),
};
}),
[],
);
/** 페이지(entries) 변경 시에만 __checked 반영 — 클릭마다 rowData를 바꾸지 않아 체크 UI가 안정적 */
const rowsByTab = useMemo(
() => {
const checked = checkedVrfcByTabRef.current;
return {
NV: mapRows(entriesByTab.NV, checked.NV),
IMR: mapRows(entriesByTab.IMR, checked.IMR),
HB: mapRows(entriesByTab.HB, checked.HB),
};
},
[entriesByTab, mapRows],
);
const noopSetRows = ((_: SetStateAction<Record<string, string | number>[]>) => {}) as Dispatch<
SetStateAction<Record<string, string | number>[]>
>;
const renderGrid = (
rows: VerificationResultGridRow[],
pageIndex: number,
pageCount: number,
onPageIndexChange: (page: number) => void,
loading: boolean,
) => {
const handleExceptionResetClick = useCallback(async () => {
if (!stableQueryContext) {
alert("조회 조건이 없습니다.");
return;
}
const tab = activeTab;
const currentEntries = entriesByTab[tab];
const syncedMap = syncCheckedMapFromGrid(currentEntries, gridRefByTab[tab], checkedVrfcByTab[tab]);
if (syncedMap.size === 0) {
alert("예외 제출 취소할 항목을 체크해주세요.");
return;
}
if (selectionHasNonExceptionResetAllowed(syncedMap, currentEntries)) {
alert(EXCEPTION_RESET_ONLY_PRCS_YN_2_MESSAGE);
return;
}
setCheckedVrfcByTab((prev) => ({ ...prev, [tab]: syncedMap }));
const vrfcSnList = Array.from(syncedMap.keys());
let mdfrId: string;
try {
const userInfo = await getUserInfo();
mdfrId = userInfo.userUnqId;
} catch {
alert("사용자 정보를 불러오지 못했습니다.");
return;
}
setResetSubmitting(true);
setErrorMessage(null);
try {
const response = await statisticsApi.StatiscicsExceptionReset({
statsYr: stableQueryContext.statsYr,
sewerBzmnCd: stableQueryContext.sewerBzmnCd,
statsCd: stableQueryContext.statsCd,
mdfrId,
vrfcSnList,
});
if (response.status !== "success") {
alert(response.message ?? "예외 제출 취소 처리 중 오류가 발생했습니다.");
return;
}
alert("예외 제출 취소 처리되었습니다.");
setCheckedVrfcByTab((prev) => ({ ...prev, [tab]: new Map() }));
await loadTabPage(tab, tabPages[tab].pageIndex);
} catch {
alert("예외 제출 취소 처리 중 오류가 발생했습니다.");
} finally {
setResetSubmitting(false);
}
}, [activeTab, alert, checkedVrfcByTab, entriesByTab, loadTabPage, stableQueryContext, tabPages]);
const renderActiveTabPanel = (tab: VerificationHistoryTabKey) => {
const entries = entriesByTab[tab];
const rows = rowsByTab[tab];
const { pageIndex, pageCount } = tabPages[tab];
const loading = loadingByTab[tab];
const gridRef = gridRefByTab[tab];
if (!loading && rows.length === 0) {
return <p className="p-6 text-center text-[1.3rem] text-muted-foreground">{emptyMessage}</p>;
return <p className="p-6 text-center text-[1.3rem] text-muted-foreground h-[65vh] flex justify-center items-center">{emptyMessage}</p>;
}
return (
<div className="relative space-y-2">
<DataGrid
key={`verification-history-grid-${tab}`}
columnInfo={verificationResultColumnInfo}
rowData={rows}
setRowData={noopSetRows}
isExcelImport={false}
isExcelExport={false}
showHeaderCheckbox={false}
showHeaderCheckbox
enableSort={false}
getGridRef={gridRef}
cellStyleCallback={verificationPrcsYnNmCellStyle}
onItemChecked={(itemIndex, checked) => {
const entry = entries[itemIndex];
if (!entry) return;
const vrfcSn = Number(entry.vrfcSn);
if (!Number.isFinite(vrfcSn)) return;
if (checked && !canExceptionReset(entry.prcsYn)) {
alert(EXCEPTION_RESET_ONLY_PRCS_YN_2_MESSAGE);
uncheckGridRow(gridRef, itemIndex);
return;
}
updateCheckedVrfcForTab(tab, (map) => {
if (checked) map.set(vrfcSn, String(entry.prcsYn ?? ""));
else map.delete(vrfcSn);
});
}}
onItemAllChecked={(checked) => {
updateCheckedVrfcForTab(tab, (map) => {
entries.forEach((entry, itemIndex) => {
const vrfcSn = Number(entry.vrfcSn);
if (!Number.isFinite(vrfcSn)) return;
if (!checked) {
map.delete(vrfcSn);
return;
}
if (canExceptionReset(entry.prcsYn)) {
map.set(vrfcSn, String(entry.prcsYn ?? ""));
} else {
uncheckGridRow(gridRef, itemIndex);
}
});
});
if (checked && entries.some((e) => !canExceptionReset(e.prcsYn))) {
alert(EXCEPTION_RESET_ONLY_PRCS_YN_2_MESSAGE);
}
}}
/>
<VerificationResultHistoryPagination
pageCount={Math.max(pageCount, 1)}
pageIndex={Math.max(pageIndex, 0)}
onPageIndexChange={onPageIndexChange}
key={`verification-history-pagination-${tab}`}
pageCount={pageCount}
pageIndex={pageIndex}
onPageIndexChange={(nextPage) => handleTabPageChange(tab, nextPage)}
className="mx-2 mb-2"
/>
{loading && (
@@ -318,7 +548,12 @@ export function VerificationResultHistoryDialog({
{errorMessage}
</p>
)}
<Tabs value={activeTab} onValueChange={(value) => setActiveTab(value as VerificationHistoryTabKey)} className="w-full" variant="default">
<Tabs
value={activeTab}
onValueChange={(value) => setActiveTab(value as VerificationHistoryTabKey)}
className="w-full"
variant="default"
>
<TabsList>
{verificationHistoryTabs.map((tab) => (
<TabsTrigger key={tab.value} value={tab.value}>
@@ -326,20 +561,28 @@ export function VerificationResultHistoryDialog({
</TabsTrigger>
))}
</TabsList>
<TabsContent value="NV" className="rounded-md border border-border">
{renderGrid(normalRows, normalPageIndex, normalPageCount, handleNormalPageChange, normalLoading)}
</TabsContent>
<TabsContent value="IMR" className="rounded-md border border-border">
{renderGrid(imrRows, imrPageIndex, imrPageCount, handleImrPageChange, imrLoading)}
</TabsContent>
<TabsContent value="HB" className="rounded-md border border-border">
{renderGrid(hbRows, hbPageIndex, hbPageCount, handleHbPageChange, hbLoading)}
</TabsContent>
{verificationHistoryTabs.map((tab) => (
<TabsContent
key={tab.value}
value={tab.value}
className="rounded-md border border-border"
>
{activeTab === tab.value ? renderActiveTabPanel(tab.value) : null}
</TabsContent>
))}
</Tabs>
</div>
<DialogFooter>
<Button
type="button"
color="primary"
disabled={resetSubmitting || loadingByTab[activeTab]}
onClick={() => void handleExceptionResetClick()}
>
예외 제출 취소
</Button>
<DialogClose asChild>
<Button type="button" color="gray">
<Button type="button" color="gray" disabled={resetSubmitting}>
닫기
</Button>
</DialogClose>
@@ -0,0 +1,85 @@
import { statisticsApi } from "@/entities/statistics/api/statistics-service";
import type { VerificationResultListResponse } from "@/entities/statistics/model/statistics-interface";
import type { VerificationResultDialogQueryContext } from "./VerificationResultDialog";
const POST_SUBMIT_VRFC_MTHD_CODES = ["NV", "IMR", "HB"] as const;
export const POST_SUBMIT_COMPLETE_MESSAGE = "제출 완료되었습니다.";
export type OpenVerificationResultDialogOptions = {
notify?: (message: string) => void;
/** 미통과 검증 결과가 없을 때 제출 완료 알림 (기본 true, IMR 오류 catch 등에서는 false) */
notifySubmitComplete?: boolean;
submitCompleteMessage?: string;
};
function responseHasVerificationResults(response: VerificationResultListResponse | null): boolean {
const total = response?.data?.pageInfo?.totalElements;
if (typeof total === "number" && total > 0) return true;
const list = response?.data?.verifyResultList;
return Array.isArray(list) && list.length > 0;
}
/**
* 제출 직후 `VerificationResultDialog`에 표시할 미통과(prcsYn=0) 검증 결과가
* 일반검증(NV)·I-MR(IMR)·HB 중 하나라도 있는지 조회합니다.
*/
export async function hasAnyPostSubmitVerificationResult(
ctx: VerificationResultDialogQueryContext,
): Promise<boolean> {
const base = {
sewerBzmnCd: ctx.sewerBzmnCd,
ctpvNm: ctx.ctpvNm,
sggNm: ctx.sggNm,
statsYr: ctx.statsYr,
statsCd: ctx.statsCd,
pbcpAprvYn: "N" as const,
prcsYnList: "0",
page: 1,
size: 1,
};
const responses = await Promise.all(
POST_SUBMIT_VRFC_MTHD_CODES.map((vrfcMthdCd) =>
statisticsApi.getVerificationResultList({ ...base, vrfcMthdCd }),
),
);
return responses.some(responseHasVerificationResults);
}
/**
* 미통과 검증 결과가 있으면 다이얼로그를 열고, 없으면 제출 완료 알림을 표시합니다.
* @returns 다이얼로그를 열었으면 true
*/
export async function openVerificationResultDialogIfNeeded(
ctx: VerificationResultDialogQueryContext | null | undefined,
setOpen: (open: boolean) => void,
options?: OpenVerificationResultDialogOptions,
): Promise<boolean> {
const {
notify,
notifySubmitComplete = true,
submitCompleteMessage = POST_SUBMIT_COMPLETE_MESSAGE,
} = options ?? {};
const notifyComplete = () => {
if (notifySubmitComplete && notify) {
notify(submitCompleteMessage);
}
};
if (!ctx) {
notifyComplete();
return false;
}
if (await hasAnyPostSubmitVerificationResult(ctx)) {
setOpen(true);
return true;
}
notifyComplete();
return false;
}
@@ -1,11 +1,20 @@
"use client";
import { useCallback } from "react";
import { useQueries } from "@tanstack/react-query";
import { useCallback, useState } from "react";
import {
type InfiniteData,
keepPreviousData,
useInfiniteQuery,
useQueryClient,
} from "@tanstack/react-query";
import { useTabStore as useGridTabStore } from "@rsone/components";
import { statisticsApi } from "@/entities/statistics/api/statistics-service";
import type { VerificationResultReportItem } from "@/entities/statistics/model/statistics-interface";
import type {
VerificationResultListRequest,
VerificationResultListResponse,
VerificationResultReportItem,
} from "@/entities/statistics/model/statistics-interface";
import { useAlertStore, useMenuStore } from "@/shared/lib";
import {
findMenuItemByUrlPath,
@@ -19,18 +28,92 @@ import {
Badge,
type BadgeProps,
LoadingWithAnimation,
Tabs,
TabsContent,
TabsList,
TabsTrigger,
Label,
} from "@common/components";
import { CodeSelectWithAll } from "@/entities/code";
type VerificationReportTabKey = "NV" | "IMR" | "HB";
const VERIFICATION_REPORT_TABS: { value: VerificationReportTabKey; label: string }[] = [
{ value: "NV", label: "일반" },
{ value: "IMR", label: "I-MR" },
{ value: "HB", label: "HB" },
];
const TYPE_CODE_COLOR_MAP: Record<string, BadgeProps["color"]> = {
NV: "gray",
IMR: "red",
HB: "blue",
};
const TYPE_CODE_NAME_MAP: Record<string, string> = {
NV: "일반",
IMR: "I-MR",
HB: "HB",
};
const PAGE_SIZE = 100;
const SCROLL_LOAD_THRESHOLD_PX = 120;
function getNextVerificationResultPage(
lastPage: VerificationResultListResponse,
): number | undefined {
const pageInfo = lastPage?.data?.pageInfo;
if (!pageInfo) return undefined;
const { currentPage, totalPages } = pageInfo;
if (currentPage + 1 <= totalPages) return currentPage + 1;
return undefined;
}
function flattenVerificationResults(
data: InfiniteData<VerificationResultListResponse> | undefined,
): VerificationResultReportItem[] {
return (
data?.pages.flatMap((page) => page?.data?.verifyResultList ?? []) ?? []
);
}
function getVerificationResultTotalCount(
data: InfiniteData<VerificationResultListResponse> | undefined,
loadedCount: number,
): number {
return data?.pages[0]?.data?.pageInfo?.totalElements ?? loadedCount;
}
function useVerificationReportList(
statsYr: string,
statsCdQueryKey: string,
vrfcMthdCd: VerificationReportTabKey,
statsCd: string,
) {
return useInfiniteQuery({
queryKey: [
"verificationResultList",
statsYr,
vrfcMthdCd,
statsCdQueryKey,
],
initialPageParam: 1,
queryFn: ({ pageParam }) =>
statisticsApi.getVerificationResultList({
statsYr,
pbcpAprvYn: "N",
page: pageParam,
size: PAGE_SIZE,
prcsYnList: "0",
vrfcMthdCd,
...(!(statsCd === "" || statsCd === "ALL") && { statsCd }),
} satisfies VerificationResultListRequest),
getNextPageParam: getNextVerificationResultPage,
placeholderData: keepPreviousData,
refetchOnWindowFocus: false,
});
}
type VerificationSelectionReportProps = {
onClose?: () => void;
};
@@ -38,6 +121,7 @@ type VerificationSelectionReportProps = {
const VerificationSelectionReport = ({
onClose,
}: VerificationSelectionReportProps) => {
const queryClient = useQueryClient();
const menuList = useTabStore((s) => s.menuList);
const addTab = useTabStore((s) => s.addTab);
const setCurrentMenuInfo = useMenuStore((s) => s.setCurrentMenuInfo);
@@ -46,59 +130,73 @@ const VerificationSelectionReport = ({
(s) => s.setPendingInputTab,
);
const alert = useAlertStore((s) => s.setMessage);
const [activeTab, setActiveTab] = useState<VerificationReportTabKey>("NV");
const [statsCd, setStatsCd] = useState<string>("ALL");
const statsYr = String(dayjs().year() - 1);
const statsCdQueryKey =
statsCd === "" || statsCd === "ALL" ? "" : statsCd;
const baseRequest = {
const nvQuery = useVerificationReportList(
statsYr,
pbcpAprvYn: "N" as const,
page: 1,
size: 10000,
prcsYnList: "0",
statsCdQueryKey,
"NV",
statsCd,
);
const imrQuery = useVerificationReportList(
statsYr,
statsCdQueryKey,
"IMR",
statsCd,
);
const hbQuery = useVerificationReportList(
statsYr,
statsCdQueryKey,
"HB",
statsCd,
);
const queryByTab = {
NV: nvQuery,
IMR: imrQuery,
HB: hbQuery,
} as const;
const isInitialLoading =
nvQuery.isPending || imrQuery.isPending || hbQuery.isPending;
const isRefreshing =
(nvQuery.isFetching && !nvQuery.isPending) ||
(imrQuery.isFetching && !imrQuery.isPending) ||
(hbQuery.isFetching && !hbQuery.isPending);
const error = nvQuery.error ?? imrQuery.error ?? hbQuery.error;
const handleRefresh = useCallback(() => {
const tabCodes: VerificationReportTabKey[] = ["NV", "IMR", "HB"];
void Promise.all(
tabCodes.map((code) =>
queryClient.resetQueries({
queryKey: [
"verificationResultList",
statsYr,
code,
statsCdQueryKey,
],
}),
),
);
}, [queryClient, statsYr, statsCdQueryKey]);
const listsByTab: Record<VerificationReportTabKey, VerificationResultReportItem[]> = {
NV: flattenVerificationResults(nvQuery.data),
IMR: flattenVerificationResults(imrQuery.data),
HB: flattenVerificationResults(hbQuery.data),
};
const [nvQuery, imrQuery] = useQueries({
queries: [
{
queryKey: [
"verificationResultList",
statsYr,
"NV",
baseRequest.pbcpAprvYn,
baseRequest.page,
baseRequest.size,
baseRequest.prcsYnList,
],
queryFn: () =>
statisticsApi.getVerificationResultList({
...baseRequest,
vrfcMthdCd: "NV",
}),
},
{
queryKey: [
"verificationResultList",
statsYr,
"IMR",
baseRequest.pbcpAprvYn,
baseRequest.page,
baseRequest.size,
baseRequest.prcsYnList,
],
queryFn: () =>
statisticsApi.getVerificationResultList({
...baseRequest,
vrfcMthdCd: "IMR",
}),
},
],
});
const isLoading = nvQuery.isLoading || imrQuery.isLoading;
const error = nvQuery.error ?? imrQuery.error;
const nvList = nvQuery.data?.data?.verifyResultList ?? [];
const imrList = imrQuery.data?.data?.verifyResultList ?? [];
const totalCountByTab: Record<VerificationReportTabKey, number> = {
NV: getVerificationResultTotalCount(nvQuery.data, listsByTab.NV.length),
IMR: getVerificationResultTotalCount(imrQuery.data, listsByTab.IMR.length),
HB: getVerificationResultTotalCount(hbQuery.data, listsByTab.HB.length),
};
const openInputFromStatsCd = useCallback(
(item: VerificationResultReportItem) => {
@@ -147,75 +245,123 @@ const VerificationSelectionReport = ({
],
);
const renderSection = (
typeCode: "IMR" | "NV",
const handleListScroll = useCallback(
(
e: React.UIEvent<HTMLDivElement>,
hasNextPage: boolean,
isFetchingNextPage: boolean,
fetchNextPage: () => void,
) => {
const el = e.currentTarget;
const remaining =
el.scrollHeight - el.scrollTop - el.clientHeight;
if (remaining > SCROLL_LOAD_THRESHOLD_PX) return;
if (hasNextPage && !isFetchingNextPage) {
fetchNextPage();
}
},
[],
);
const renderResultList = (
typeCode: VerificationReportTabKey,
items: VerificationResultReportItem[],
hasNextPage: boolean,
isFetchingNextPage: boolean,
fetchNextPage: () => void,
) => (
<section className="flex min-h-0 flex-1 flex-col rounded-sm border border-border h-[50rem]">
<div className="flex items-center justify-between border-b border-border px-3 py-2">
<Badge color={TYPE_CODE_COLOR_MAP[typeCode]} className="rounded-full px-2 py-1 text-lg">
{TYPE_CODE_NAME_MAP[typeCode]} {items.length}
</Badge>
</div>
<div className="min-h-0 flex-1 overflow-y-auto p-2">
{items.length === 0 ? (
<div className="flex h-full min-h-[8rem] items-center justify-center text-lg text-muted-foreground">
항목이 없습니다.
</div>
) : (
<div className="space-y-2 tracking-tight">
{items.map((item) => (
<div
key={`${typeCode}-${item.vrfcSn}`}
role="button"
tabIndex={0}
className="flex flex-col rounded-sm border border-border p-3 cursor-pointer hover:bg-gray-100"
onClick={() => openInputFromStatsCd(item)}
onKeyDown={(e) => {
if (e.key === "Enter" || e.key === " ") {
e.preventDefault();
openInputFromStatsCd(item);
<div
className="min-h-0 flex-1 overflow-y-auto p-2"
onScroll={(e) =>
handleListScroll(
e,
hasNextPage,
isFetchingNextPage,
fetchNextPage,
)
}
>
{items.length === 0 && !isFetchingNextPage ? (
<div className="flex h-full min-h-[8rem] items-center justify-center text-lg text-muted-foreground">
항목이 없습니다.
</div>
) : (
<div className="space-y-2 tracking-tight">
{items.map((item) => (
<div
key={`${typeCode}-${item.vrfcSn}`}
role="button"
tabIndex={0}
className="flex flex-col rounded-sm border border-border p-3 cursor-pointer hover:bg-gray-100"
onClick={() => openInputFromStatsCd(item)}
onKeyDown={(e) => {
if (e.key === "Enter" || e.key === " ") {
e.preventDefault();
openInputFromStatsCd(item);
}
}}
>
<div className="mb-2 flex items-center justify-between">
<span className="font-medium">
{item.statsNm}
</span>
<Badge
color={
TYPE_CODE_COLOR_MAP[item.vrfcMthdCd] ??
"gray"
}
}}
>
<div className="mb-2 flex items-center justify-between">
<span className="font-medium">
{item.statsNm}
</span>
<Badge
color={
TYPE_CODE_COLOR_MAP[item.vrfcMthdCd] ??
"gray"
}
className="text-sm"
>
{TYPE_CODE_NAME_MAP[item.vrfcMthdCd] ??
item.vrfcMthdNm}
</Badge>
</div>
<p className="text-lg text-muted-foreground">
{item.rsnExpln}
</p>
<div className="flex justify-between text-lg text-muted-foreground">
<span>{item.admdstNm}</span>
<span>
현재: {item.nowVlCn ?? "-"} / 비교:{" "}
{item.compVlCn ?? "-"}
</span>
</div>
className="text-sm"
>
{TYPE_CODE_NAME_MAP[item.vrfcMthdCd] ??
item.vrfcMthdNm}
</Badge>
</div>
))}
</div>
)}
</div>
</section>
<p className="text-lg text-muted-foreground">
{item.rsnExpln}
</p>
<div className="flex justify-between text-lg text-muted-foreground">
<span>{item.admdstNm}</span>
<span>
현재: {item.nowVlCn ?? "-"} / 비교:{" "}
{item.compVlCn ?? "-"}
</span>
</div>
</div>
))}
{isFetchingNextPage && (
<div className="flex justify-center py-3">
<div className="relative size-[3rem]">
<LoadingWithAnimation color="primary" />
</div>
</div>
)}
</div>
)}
</div>
);
return (
<div className="w-1/5 bg-container p-4 !pb-2 flex border-r border-border">
<div className="card card-border flex-1 bg-background !px-0">
<div className="card card-border flex-1 bg-background !px-0 max-h-[1000px] flex flex-col">
<div className="border-b border-border flex justify-between">
<h2 className="text-[2.4rem] font-semibold text-title p-4 ">이상치 선별 보고서</h2>
<div className="flex justify-center items-center text-[2.4rem] ">
<h2 className="font-semibold text-title p-4 pr-1">이상치 선별 보고서</h2>
<span
role="button"
tabIndex={0}
aria-label="새로고침"
className={`text-gray-400 cursor-pointer hover:text-[#2094fa] ${isRefreshing ? "pointer-events-none opacity-50" : ""}`}
onClick={handleRefresh}
onKeyDown={(e) => {
if (e.key === "Enter" || e.key === " ") {
e.preventDefault();
handleRefresh();
}
}}
>
<i className="diveicon di-arrow-reflash" />
</span>
</div>
<span
role="button"
tabIndex={0}
@@ -232,8 +378,18 @@ const VerificationSelectionReport = ({
<i className="ri-close-line" />
</span>
</div>
<div className="card-body max-h-[calc(100%-3.25rem)] p-2.5">
{isLoading && (
<div className="flex items-center gap-2 pt-2.5 px-4">
<Label className="text-label shrink-0">통계항목</Label>
<CodeSelectWithAll
upCd="STITEM"
value={statsCd}
onValueChange={setStatsCd}
placeholder="전체"
className="w-full"
/>
</div>
<div className="card-body p-2.5 flex-1 flex flex-col overflow-hidden relative min-h-0">
{isInitialLoading && (
<div className="absolute inset-0 z-50 [data-theme='black']:bg-transparent rounded-lg p-10 flex justify-center items-center">
<div className="relative size-[5rem]">
<LoadingWithAnimation color="primary" />
@@ -245,11 +401,49 @@ const VerificationSelectionReport = ({
데이터를 불러올 수 없습니다.
</div>
)}
{!isLoading && !error && (
<div className="grid grid-rows-2 gap-6">
{renderSection("IMR", imrList)}
{renderSection("NV", nvList)}
</div>
{!isInitialLoading && !error && (
<section className="flex min-h-0 flex-1 flex-col">
<Tabs
value={activeTab}
onValueChange={(value) =>
setActiveTab(value as VerificationReportTabKey)
}
className="flex min-h-0 flex-1 flex-col"
variant="default"
>
<TabsList className="grid w-full grid-cols-3 p-1 shrink-0">
{VERIFICATION_REPORT_TABS.map((tab) => (
<TabsTrigger
key={tab.value}
value={tab.value}
className="!px-2 !py-2 text-[1.3rem] whitespace-normal"
>
{tab.label} ({totalCountByTab[tab.value]})
</TabsTrigger>
))}
</TabsList>
{VERIFICATION_REPORT_TABS.map((tab) => (
<TabsContent
key={tab.value}
value={tab.value}
className="mt-0 flex min-h-0 flex-1 flex-col p-0 data-[state=inactive]:hidden"
>
{activeTab === tab.value
? renderResultList(
tab.value,
listsByTab[tab.value],
queryByTab[tab.value]
.hasNextPage ?? false,
queryByTab[tab.value]
.isFetchingNextPage,
queryByTab[tab.value]
.fetchNextPage,
)
: null}
</TabsContent>
))}
</Tabs>
</section>
)}
</div>
</div>
@@ -1,7 +1,7 @@
import { useState } from "react";
import SearchForm from "@/components/module/SearchForm";
import { DataGrid } from "@rsone/components";
import DataGrid from "@/components/module/DataGrid";
import SubmitButton from "@/components/button/SumitButton";
import SaveButton from "@/components/button/SaveButton";
import LastYearDataButton from "@/components/button/LastYearDataButton";
@@ -240,11 +240,6 @@ export function MainFacilityMappingSaveDialog({
</div>
</div>
<DialogFooter>
<DialogClose asChild>
<Button type="button" color="gray" disabled={isSubmitting}>
취소
</Button>
</DialogClose>
<Button
type="button"
color="primary"
@@ -253,6 +248,11 @@ export function MainFacilityMappingSaveDialog({
>
저장
</Button>
<DialogClose asChild>
<Button type="button" color="gray" disabled={isSubmitting}>
취소
</Button>
</DialogClose>
</DialogFooter>
</DialogContent>
</Dialog>
@@ -1,7 +1,7 @@
import { useState } from "react";
import SearchForm from "@/components/module/SearchForm";
import { TabDataGrid } from "@rsone/components";
import TabDataGrid from "@/components/module/TabDataGrid";
import SubmitButton from "@/components/button/SumitButton";
import SaveButton from "@/components/button/SaveButton";
import SubmitExceptionButton from "@/components/button/SubmitExceptionButton";
@@ -38,6 +38,7 @@ import VerificationResultButton from '@/components/button/VerificationResultButt
import DataGrid from '@/components/module/DataGrid';
import SearchForm from '@/components/module/SearchForm';
import SplitDataGrid from '@/components/module/SplitDataGrid';
import { openVerificationResultDialogIfNeeded } from '@/components/module/post-submit-verification-results';
import { VerificationResultDialog } from '@/components/module/VerificationResultDialog';
import { VerificationResultHistoryDialog } from '@/components/module/VerificationResultHistoryDialog';
import type { CellStyleCallbackParams, ColumnInfo, ColumnInfoList } from '@/components/type/DataGrid.type';
@@ -86,13 +87,16 @@ function computeTabToolbarSnapshot(
): TabToolbarSnapshot {
const gugunOk = buildGugunOkForToolbar(searchContext);
const enterable = computeTabEnterable(statusInfo, periodEnterable);
const isSubmitted = statusInfo?.laeSbmsnSttsCd === '2';
const showSaveInToolbar = (enterable || isAdminInfo) && gugunOk;
const showEnterableToolbarButtons = enterable && gugunOk;
const showSubmittedInInputPeriodToolbar = isSubmitted && periodEnterable && gugunOk;
const showActionToolbarCard =
(hasRowData || treatAsSearched) &&
(showSaveInToolbar ||
(statusInfo?.laeSbmsnSttsCd === '2' &&
(isAdminInfo || (enterable && gugunOk))));
showEnterableToolbarButtons ||
showSubmittedInInputPeriodToolbar ||
(isSubmitted && isAdminInfo));
return { showSaveInToolbar, showEnterableToolbarButtons, showActionToolbarCard };
}
@@ -161,7 +165,7 @@ const PublicSewageFacilityInp = () => {
const [tabStatsCdMap, setTabStatsCdMap] = useState<Map<string, string>>(new Map());
/** 탭키별 `engColNm -> columnGuide` 캐시 */
const [columnGuidesByTab, setColumnGuidesByTab] = useState<Record<string, Record<string, string>>>({});
const [toolbarSnapshotByTab, setToolbarSnapshotByTab] = useState<Map<string, TabToolbarSnapshot>>(() => new Map());
const [_toolbarSnapshotByTab, setToolbarSnapshotByTab] = useState<Map<string, TabToolbarSnapshot>>(() => new Map());
const [inputPeriodEnterable, setInputPeriodEnterable] = useState(true);
const [periodCheckList, setPeriodCheckList] = useState<CheckInputPeriodItem[]>([]);
const [showDailog, setShowDailog] = useState<boolean>(false);
@@ -176,7 +180,7 @@ const PublicSewageFacilityInp = () => {
const searchDoneRef = useRef(false);
/** ST00060001만: RealGrid 편집값이 state에 없을 수 있어 저장/제출 시 그리드에서 직접 읽음 */
const st60001GridGetRowsRef = useRef<(() => Record<string, string | number>[]) | null>(null);
const submitFunc = () => {
void (async () => {
if (activeTab !== 'ST00060001') {
@@ -187,9 +191,10 @@ const PublicSewageFacilityInp = () => {
alert('먼저 조회를 진행해주세요.');
return;
}
const treatAsNoApplicable = getNoApplicableForRequest() || isNotApplicable;
const currentData =
st60001GridGetRowsRef.current?.() ?? rowDataMap.get('ST00060001') ?? [];
if (!currentData || currentData.length === 0) {
if (!treatAsNoApplicable && (!currentData || currentData.length === 0)) {
alert('제출할 데이터가 없습니다.');
return;
}
@@ -266,9 +271,12 @@ const PublicSewageFacilityInp = () => {
alert('먼저 조회를 진행해주세요.');
return;
}
const noApplicable = getNoApplicableForRequest();
const treatAsNoApplicable = noApplicable || isNotApplicable;
const currentData =
st60001GridGetRowsRef.current?.() ?? rowDataMap.get('ST00060001') ?? [];
if (!currentData || currentData.length === 0) {
if (!treatAsNoApplicable && (!currentData || currentData.length === 0)) {
alert('제출할 데이터가 없습니다.');
return;
}
@@ -276,7 +284,7 @@ const PublicSewageFacilityInp = () => {
try {
showGlobalProgress({ label: '서버에 요청 중...', progress: 0 });
const userInfo = await getUserInfo();
const submitList = buildFacilitySaveList(currentData);
const submitList = treatAsNoApplicable ? [] : buildFacilitySaveList(currentData);
const activeTabStatus = tabStatusInfoMap.get(activeTab) ?? null;
const statsCdForTab = tabStatsCdMap.get(activeTab) ?? activeTab;
const requestData = {
@@ -285,7 +293,7 @@ const PublicSewageFacilityInp = () => {
sggNm: lastSearchedValues.gugun === 'ALL' ? '' : lastSearchedValues.gugun ?? '',
sewerBzmnCd: lastSearchedValues.bsnmCd ?? '',
userId: userInfo.userUnqId,
noApplicable: getNoApplicableForRequest(),
noApplicable,
laePicNm: submitInfo.laePicNm,
laeKbrdrJbgdNm: submitInfo.laeKbrdrJbgdNm,
laeKbrdrDeptNm: submitInfo.laeKbrdrDeptNm,
@@ -342,14 +350,34 @@ const PublicSewageFacilityInp = () => {
searchParamRef.current,
lastSearchedValues,
);
setVerificationResultDialogOpen(true);
await openVerificationResultDialogIfNeeded(
{
sewerBzmnCd: lastSearchedValues.bsnmCd ?? '',
ctpvNm: lastSearchedValues.sido ?? '',
sggNm: lastSearchedValues.gugun === 'ALL' ? '' : (lastSearchedValues.gugun ?? ''),
statsYr: lastSearchedValues.year ?? '',
statsCd: statsCdForTab,
},
setVerificationResultDialogOpen,
{ notify: alert },
);
} catch (e) {
await loadPublicSewageFacilityActiveTabData(
'ST00060001',
searchParamRef.current,
lastSearchedValues,
);
setVerificationResultDialogOpen(true);
await openVerificationResultDialogIfNeeded(
{
sewerBzmnCd: lastSearchedValues.bsnmCd ?? '',
ctpvNm: lastSearchedValues.sido ?? '',
sggNm: lastSearchedValues.gugun === 'ALL' ? '' : (lastSearchedValues.gugun ?? ''),
statsYr: lastSearchedValues.year ?? '',
statsCd: statsCdForTab,
},
setVerificationResultDialogOpen,
{ notify: alert, notifySubmitComplete: false },
);
const msg =
e instanceof Error && e.message
? e.message
@@ -363,7 +391,17 @@ const PublicSewageFacilityInp = () => {
searchParamRef.current,
lastSearchedValues,
);
setVerificationResultDialogOpen(true);
await openVerificationResultDialogIfNeeded(
{
sewerBzmnCd: lastSearchedValues.bsnmCd ?? '',
ctpvNm: lastSearchedValues.sido ?? '',
sggNm: lastSearchedValues.gugun === 'ALL' ? '' : (lastSearchedValues.gugun ?? ''),
statsYr: lastSearchedValues.year ?? '',
statsCd: statsCdForTab,
},
setVerificationResultDialogOpen,
{ notify: alert },
);
}
} catch {
alert('데이터 제출 중 오류가 발생했습니다.');
@@ -374,12 +412,34 @@ const PublicSewageFacilityInp = () => {
const saveFunc = () => {
void (async () => {
const verificationBlocked = getVerificationPeriodActionBlockedMessage(periodCheckList);
if (verificationBlocked) {
alert(verificationBlocked);
return;
}
if (!lastSearchedValues || !searchParamRef.current) {
alert('먼저 조회를 진행해주세요.');
return;
}
if (activeTab === 'ST00060002') {
const statusCodeResponse = await statisticsApi.CheckStatusCode({
sewerBzmnCd: lastSearchedValues.bsnmCd ?? '',
statsCd: 'ST00060001',
statsYr: lastSearchedValues.year ?? ''
});
if (statusCodeResponse.status === 'success') {
if (!['0', '1', '4'].includes(statusCodeResponse.data)) {
const codeName = statusCodeResponse.data === '2' ? '제출' : (statusCodeResponse.data === '3' ? '승인' : '검증중');
alert(`공공 하수처리 시설 데이터가 ${codeName} 상태일 때는 중심 처리 시설 데이터를 변경할 수 없습니다.`);
return;
}
} else {
alert('오류가 발생했습니다.\n관리자에게 문의하세요')
}
if (!(await confirm('저장 하시겠습니까?'))) return;
const leftRows = rowDataMap.get('MainFacility') ?? [];
@@ -458,22 +518,24 @@ const PublicSewageFacilityInp = () => {
try {
showGlobalProgress({ label: '저장 진행 중...', progress: 0 });
const userInfo = await getUserInfo();
const noApplicable = getNoApplicableForRequest();
const treatAsNoApplicable = noApplicable || isNotApplicable;
const currentData =
st60001GridGetRowsRef.current?.() ?? rowDataMap.get('ST00060001') ?? [];
if (!currentData || currentData.length === 0) {
if (!treatAsNoApplicable && (!currentData || currentData.length === 0)) {
alert('저장할 데이터가 없습니다.');
return;
}
const saveList = buildFacilitySaveList(currentData);
const saveList = treatAsNoApplicable ? [] : buildFacilitySaveList(currentData);
const param: PublicSewageFacilitySaveRequest = {
statsYr: lastSearchedValues.year ?? '',
ctpvNm: lastSearchedValues.sido ?? '',
sggNm: lastSearchedValues.gugun === 'ALL' ? '' : lastSearchedValues.gugun ?? '',
sewerBzmnCd: lastSearchedValues.bsnmCd ?? '',
userId: userInfo.userUnqId,
superAdmin: isAdminInfo ? 'superAdmin' : '',
noApplicable: getNoApplicableForRequest(),
// superAdmin: isAdminInfo ? 'superAdmin' : '',
noApplicable,
saveList,
};
@@ -593,6 +655,8 @@ const PublicSewageFacilityInp = () => {
param: PublicSewageFacilityRequestParams,
searchContext: SearchFormValues | null,
): Promise<boolean> => {
// 서버 데이터로 새로 불러오는 시점이므로 "해당없음" 체크는 항상 초기화
setIsNotApplicable(false);
try {
if (tabKey === 'ST00060001') {
const response = await statisticsInputPublicSewageFacilityApiService.getPublicSewageFacility(param);
@@ -949,26 +1013,34 @@ const PublicSewageFacilityInp = () => {
};
const searchContextForToolbar = lastSearchedValues ?? searchValues;
const gugunOkOrNotDoList = buildGugunOkForToolbar(searchContextForToolbar);
const activeTabStatus = tabStatusInfoMap.get(activeTab) ?? null;
const activeTabToolbar =
toolbarSnapshotByTab.get(activeTab) ??
computeTabToolbarSnapshot(
activeTabStatus,
tabRowDataExists(activeTab),
hasSearched,
searchContextForToolbar,
inputPeriodEnterable,
isAdminInfo,
);
const activeTabToolbar = computeTabToolbarSnapshot(
activeTabStatus,
tabRowDataExists(activeTab),
hasSearched,
searchContextForToolbar,
inputPeriodEnterable,
isAdminInfo,
);
const {
showSaveInToolbar: baseShowSaveInToolbar,
showEnterableToolbarButtons: baseShowEnterableToolbarButtons,
showActionToolbarCard: baseShowActionToolbarCard,
} = activeTabToolbar;
const showSaveInToolbar = baseShowSaveInToolbar && toolbarGroupAllowed;
const showEnterableToolbarButtons = baseShowEnterableToolbarButtons && toolbarGroupAllowed;
const showActionToolbarCard = baseShowActionToolbarCard && toolbarGroupAllowed;
const isMainFacilityTab = activeTab === 'ST00060002';
const showMainFacilitySaveInToolbar =
isMainFacilityTab &&
tabRowDataExists('ST00060002') &&
gugunOkOrNotDoList &&
(isAdminInfo || inputPeriodEnterable);
const showSaveInToolbar =
toolbarGroupAllowed && (baseShowSaveInToolbar || showMainFacilitySaveInToolbar);
const showEnterableToolbarButtons =
baseShowEnterableToolbarButtons && toolbarGroupAllowed;
const showActionToolbarCard =
toolbarGroupAllowed &&
(baseShowActionToolbarCard || showMainFacilitySaveInToolbar);
const openVerificationHistoryDialog = () => {
const ctx = lastSearchedValues ?? searchValues;
@@ -1004,16 +1076,18 @@ const PublicSewageFacilityInp = () => {
onValuesChange={setSearchValues}
/>
{showActionToolbarCard && (
<div className="card !py-0">
<div className="card !py-0">
<div className="flex justify-end items-center gap-[0.6rem] mx-[0.6rem]">
<VerificationResultButton
VerificationResultButtonFunc={openVerificationHistoryDialog}
/>
{!isMainFacilityTab && (
<VerificationResultButton
VerificationResultButtonFunc={openVerificationHistoryDialog}
/>
)}
{showEnterableToolbarButtons && !isMainFacilityTab && (
<>
{showIsNotApplicable && <NotApplicableCheckButton isCheck={isNotApplicable} setIsCheck={setIsNotApplicable} />}
<UpdateOperateDataButton updateOperateDataButtonFunc={updateOperateDataButtonFunc} />
<SubmitButton submitFunc={submitFunc} />
<UpdateOperateDataButton updateOperateDataButtonFunc={updateOperateDataButtonFunc} />
<SubmitButton submitFunc={submitFunc} />
</>
)}
{!isMainFacilityTab && activeTabStatus?.laeSbmsnSttsCd === '2' && (
@@ -1028,7 +1102,7 @@ const PublicSewageFacilityInp = () => {
<div className="relative size-[5rem]">
<LoadingWithAnimation color="primary" />
</div>
</div>
</div>
)}
<Tabs value={activeTab} onValueChange={(v) => void handleActiveTabChange(v)}>
<TabsList>
@@ -1039,151 +1113,165 @@ const PublicSewageFacilityInp = () => {
))}
</TabsList>
{PublicSewageFactInpColumnList.map((tab) => {
if (tab.isSplitGrid && tab.left && tab.right) {
const leftKey = tab.left.tabKey ?? `${tab.tabKey}_left`;
const rightKey = tab.right.tabKey ?? `${tab.tabKey}_right`;
{PublicSewageFactInpColumnList.map((tab) => {
if (tab.isSplitGrid && tab.left && tab.right) {
const leftKey = tab.left.tabKey ?? `${tab.tabKey}_left`;
const rightKey = tab.right.tabKey ?? `${tab.tabKey}_right`;
return (
<TabsContent key={tab.tabKey} value={tab.tabKey}>
<SplitDataGrid
leftColumnInfo={getGridColumnInfo(tab.left, tab.tabKey)}
rightColumnInfo={getGridColumnInfo(tab.right, tab.tabKey)}
leftFixedColumns={tab.left.fixedColumns}
rightFixedColumns={tab.right.fixedColumns}
showHeaderCheckbox={true}
enableSort={false}
leftRowData={rowDataMap.get(leftKey) ?? []}
rightRowData={rowDataMap.get(rightKey) ?? []}
leftOnRowClick={
tab.tabKey === 'ST00060002'
? (row) => {
void handleMainFacilityRowClick(row);
}
: undefined
}
leftCellStyleCallback={
tab.tabKey === 'ST00060002'
? leftMainFacilityCellStyleCallback
: undefined
}
rightOnItemChecked={
tab.tabKey === 'ST00060002'
? (itemIndex, checked) => {
setRowDataMap((prev) => {
const next = new Map(prev);
const rows = [...(next.get('MainFacilityTarget') ?? [])];
if (!rows[itemIndex]) return prev;
rows[itemIndex] = {
...rows[itemIndex],
__checked: checked ? 'Y' : '',
};
next.set('MainFacilityTarget', rows);
return next;
});
}
: undefined
}
rightOnItemAllChecked={
tab.tabKey === 'ST00060002'
? (checked) => {
setRowDataMap((prev) => {
const next = new Map(prev);
const rows = [...(next.get('MainFacilityTarget') ?? [])].map((r) => ({
...r,
__checked: checked ? 'Y' : '',
}));
next.set('MainFacilityTarget', rows);
return next;
});
}
: undefined
}
setLeftRowData={(updater) => {
setRowDataMap((prev) => {
const next = new Map(prev);
const cur = next.get(leftKey) ?? [];
const n =
typeof updater === 'function'
? (updater as (p: Record<string, string | number>[]) => Record<string, string | number>[])(cur)
: updater;
next.set(leftKey, n);
return next;
});
}}
setRightRowData={(updater) => {
setRowDataMap((prev) => {
const next = new Map(prev);
const cur = next.get(rightKey) ?? [];
const n =
typeof updater === 'function'
? (updater as (p: Record<string, string | number>[]) => Record<string, string | number>[])(cur)
: updater;
next.set(rightKey, n);
return next;
});
}}
/>
</TabsContent>
);
}
const gridColumnInfo = getGridColumnInfo(tab);
const tabStatus = tabStatusInfoMap.get(tab.tabKey) ?? null;
const tabEnterableForGrid = computeTabEnterable(
tabStatus,
inputPeriodEnterable,
);
const excelToolbarEnabledForTab =
tabRowDataExists(tab.tabKey) &&
tabEnterableForGrid &&
gugunOkOrNotDoList;
return (
<TabsContent key={tab.tabKey} value={tab.tabKey}>
<SplitDataGrid
leftColumnInfo={getGridColumnInfo(tab.left, tab.tabKey)}
rightColumnInfo={getGridColumnInfo(tab.right, tab.tabKey)}
leftFixedColumns={tab.left.fixedColumns}
rightFixedColumns={tab.right.fixedColumns}
showHeaderCheckbox={true}
enableSort={false}
leftRowData={rowDataMap.get(leftKey) ?? []}
rightRowData={rowDataMap.get(rightKey) ?? []}
leftOnRowClick={
tab.tabKey === 'ST00060002'
? (row) => {
void handleMainFacilityRowClick(row);
}
: undefined
}
leftCellStyleCallback={
tab.tabKey === 'ST00060002'
? leftMainFacilityCellStyleCallback
: undefined
}
rightOnItemChecked={
tab.tabKey === 'ST00060002'
? (itemIndex, checked) => {
setRowDataMap((prev) => {
const next = new Map(prev);
const rows = [...(next.get('MainFacilityTarget') ?? [])];
if (!rows[itemIndex]) return prev;
rows[itemIndex] = {
...rows[itemIndex],
__checked: checked ? 'Y' : '',
};
next.set('MainFacilityTarget', rows);
return next;
});
}
: undefined
}
rightOnItemAllChecked={
tab.tabKey === 'ST00060002'
? (checked) => {
setRowDataMap((prev) => {
const next = new Map(prev);
const rows = [...(next.get('MainFacilityTarget') ?? [])].map((r) => ({
...r,
__checked: checked ? 'Y' : '',
}));
next.set('MainFacilityTarget', rows);
return next;
});
}
: undefined
}
setLeftRowData={(updater) => {
<DataGrid
key={(tabEnterableForGrid || isAdminInfo) ? 'enterable' : 'readonly'}
columnInfo={gridColumnInfo}
rowStartNum={tab.rowStartNum}
excelExportFileSuffix={tab.tabLabel || tab.tabKey}
fixedColumns={tab.fixedColumns}
statusInfo={tabStatus}
rowData={rowDataMap.get(tab.tabKey) ?? []}
setRowData={(updater) => {
setRowDataMap((prev) => {
const next = new Map(prev);
const cur = next.get(leftKey) ?? [];
const cur = next.get(tab.tabKey) ?? [];
const n =
typeof updater === 'function'
? (updater as (p: Record<string, string | number>[]) => Record<string, string | number>[])(cur)
: updater;
next.set(leftKey, n);
next.set(tab.tabKey, n);
return next;
});
}}
setRightRowData={(updater) => {
setRowDataMap((prev) => {
const next = new Map(prev);
const cur = next.get(rightKey) ?? [];
const n =
typeof updater === 'function'
? (updater as (p: Record<string, string | number>[]) => Record<string, string | number>[])(cur)
: updater;
next.set(rightKey, n);
return next;
});
getGridDataRef={
tab.tabKey === 'ST00060001' ? st60001GridGetRowsRef : undefined
}
isExcelImport={excelToolbarEnabledForTab}
isExcelExport
showHeaderCheckbox={false}
enableSort={false}
cellStyleCallback={({ row, fieldName }) => {
const verification = (row as GridRowWithVerification).verificationResult;
if (!verification) return;
const errors = verification[fieldName];
if (Array.isArray(errors) && errors.length > 0) {
const hasImr = errors.some((e) => e.vrfcCd === "IMR" || e.vrfcCd === "HB");
return hasImr ? "rg-cell-verify-error-imr" : "rg-cell-verify-error";
}
}}
cellTooltipCallback={({ row, fieldName }) => {
const verification = (row as GridRowWithVerification).verificationResult;
if (!verification) return;
const errors = verification[fieldName];
if (Array.isArray(errors) && errors.length > 0) {
return errors
.map((e) => {
const prefix =
e.vrfcCd === "IMR" ? "IMR검증 - " : (e.vrfcCd === "NV" ? "일반검증 - " : "HB검증 - ");
const comp = e.compVlCn ?? "";
const now = e.nowVlCn ?? "";
return `${prefix}${e.rsnExpln}
(검증 기준값: ${comp} / 현재 입력값: ${now})`;
})
.filter(Boolean)
.join("\n");
}
}}
/>
</TabsContent>
);
}
const gridColumnInfo = getGridColumnInfo(tab);
return (
<TabsContent key={tab.tabKey} value={tab.tabKey}>
<DataGrid
columnInfo={gridColumnInfo}
rowStartNum={tab.rowStartNum}
fixedColumns={tab.fixedColumns}
statusInfo={activeTabStatus}
rowData={rowDataMap.get(tab.tabKey) ?? []}
setRowData={(updater) => {
setRowDataMap((prev) => {
const next = new Map(prev);
const cur = next.get(tab.tabKey) ?? [];
const n =
typeof updater === 'function'
? (updater as (p: Record<string, string | number>[]) => Record<string, string | number>[])(cur)
: updater;
next.set(tab.tabKey, n);
return next;
});
}}
getGridDataRef={
tab.tabKey === 'ST00060001' ? st60001GridGetRowsRef : undefined
}
showHeaderCheckbox={false}
enableSort={false}
cellStyleCallback={({ row, fieldName }) => {
const verification = (row as GridRowWithVerification).verificationResult;
if (!verification) return;
const errors = verification[fieldName];
if (Array.isArray(errors) && errors.length > 0) {
const hasImr = errors.some((e) => e.vrfcCd === "IMR" || e.vrfcCd === "HB");
return hasImr ? "rg-cell-verify-error-imr" : "rg-cell-verify-error";
}
}}
cellTooltipCallback={({ row, fieldName }) => {
const verification = (row as GridRowWithVerification).verificationResult;
if (!verification) return;
const errors = verification[fieldName];
if (Array.isArray(errors) && errors.length > 0) {
return errors
.map((e) => {
const prefix =
e.vrfcCd === "IMR" ? "IMR검증 - " : (e.vrfcCd === "NV" ? "일반검증 - " : "HB검증 - ");
const comp = e.compVlCn ?? "";
const now = e.nowVlCn ?? "";
return `${prefix}${e.rsnExpln}
(검증 기준값: ${comp} / 현재 입력값: ${now})`;
})
.filter(Boolean)
.join("\n");
}
}}
/>
</TabsContent>
);
})}
})}
</Tabs>
</div>
<VerificationResultHistoryDialog
@@ -1,7 +1,7 @@
import { useState } from "react";
import SearchForm from "@/components/module/SearchForm";
import { DataGrid } from '@rsone/components';
import DataGrid from "@/components/module/DataGrid";
import SubmitButton from "@/components/button/SumitButton";
import SaveButton from "@/components/button/SaveButton";
import LastYearDataButton from "@/components/button/LastYearDataButton";
@@ -1,7 +1,7 @@
import { useState } from "react";
import SearchForm from "@/components/module/SearchForm";
import { TabDataGrid } from "@rsone/components";
import TabDataGrid from "@/components/module/TabDataGrid";
import SubmitButton from "@/components/button/SumitButton";
import SaveButton from "@/components/button/SaveButton";
import SubmitExceptionButton from "@/components/button/SubmitExceptionButton";
@@ -1,7 +1,7 @@
import { useState } from "react";
import SearchForm from "@/components/module/SearchForm";
import { TabDataGrid } from "@rsone/components";
import TabDataGrid from "@/components/module/TabDataGrid";
import SubmitButton from "@/components/button/SumitButton";
import SaveButton from "@/components/button/SaveButton";
import SubmitExceptionButton from "@/components/button/SubmitExceptionButton";
@@ -56,6 +56,7 @@ import DataGrid from "@/components/module/DataGrid";
import GridTopActionButton from "@/components/module/GridTopActionButton";
import SearchForm from "@/components/module/SearchForm";
import SplitDataGrid from "@/components/module/SplitDataGrid";
import { openVerificationResultDialogIfNeeded } from "@/components/module/post-submit-verification-results";
import { VerificationResultDialog } from "@/components/module/VerificationResultDialog";
import { VerificationResultHistoryDialog } from "@/components/module/VerificationResultHistoryDialog";
import type { ColumnConfig,ColumnInfo, ColumnInfoList } from "@/components/type/DataGrid.type";
@@ -331,13 +332,16 @@ function computeTabToolbarSnapshot(
): TabToolbarSnapshot {
const gugunOk = buildGugunOkForToolbar(searchContext);
const enterable = computeTabEnterable(statusInfo, periodEnterable);
const isSubmitted = statusInfo?.laeSbmsnSttsCd === "2";
const showSaveInToolbar = (enterable || isAdminInfo) && gugunOk;
const showEnterableToolbarButtons = enterable && gugunOk;
const showSubmittedInInputPeriodToolbar = isSubmitted && periodEnterable && gugunOk;
const showActionToolbarCard =
(hasRowData || treatAsSearched) &&
(showSaveInToolbar ||
(statusInfo?.laeSbmsnSttsCd === "2" &&
(isAdminInfo || (enterable && gugunOk))));
showEnterableToolbarButtons ||
showSubmittedInInputPeriodToolbar ||
(isSubmitted && isAdminInfo));
return { showSaveInToolbar, showEnterableToolbarButtons, showActionToolbarCard };
}
@@ -1248,7 +1252,17 @@ const SewagePipeInp = () => {
true,
false,
);
setVerificationResultDialogOpen(true);
await openVerificationResultDialogIfNeeded(
{
sewerBzmnCd: values.bsnmCd ?? "",
ctpvNm: values.sido ?? "",
sggNm: values.gugun === "ALL" ? "" : (values.gugun ?? ""),
statsYr: values.year ?? "",
statsCd: statsCdForTab,
},
setVerificationResultDialogOpen,
{ notify: alert },
);
} catch (e) {
await loadSewagePipeActiveTabData(
tabKey,
@@ -1257,7 +1271,17 @@ const SewagePipeInp = () => {
true,
false,
);
setVerificationResultDialogOpen(true);
await openVerificationResultDialogIfNeeded(
{
sewerBzmnCd: values.bsnmCd ?? "",
ctpvNm: values.sido ?? "",
sggNm: values.gugun === "ALL" ? "" : (values.gugun ?? ""),
statsYr: values.year ?? "",
statsCd: statsCdForTab,
},
setVerificationResultDialogOpen,
{ notify: alert, notifySubmitComplete: false },
);
const msg =
e instanceof Error && e.message
? e.message
@@ -1273,7 +1297,17 @@ const SewagePipeInp = () => {
true,
false,
);
setVerificationResultDialogOpen(true);
await openVerificationResultDialogIfNeeded(
{
sewerBzmnCd: values.bsnmCd ?? "",
ctpvNm: values.sido ?? "",
sggNm: values.gugun === "ALL" ? "" : (values.gugun ?? ""),
statsYr: values.year ?? "",
statsCd: statsCdForTab,
},
setVerificationResultDialogOpen,
{ notify: alert },
);
}
} catch {
alert("데이터 제출 중 오류가 발생했습니다.");
@@ -1429,7 +1463,7 @@ const SewagePipeInp = () => {
sggNm: values.gugun === "ALL" ? "" : (values.gugun ?? ""),
sewerBzmnCd: values.bsnmCd ?? "",
userId: userInfo.userUnqId,
superAdmin: isAdminInfo ? "superAdmin" : "",
// superAdmin: isAdminInfo ? "superAdmin" : "",
saveList,
};
@@ -1509,7 +1543,7 @@ const SewagePipeInp = () => {
sggNm: values.gugun === "ALL" ? "" : (values.gugun ?? ""),
sewerBzmnCd: values.bsnmCd ?? "",
userId: userInfo.userUnqId,
superAdmin: isAdminInfo ? "superAdmin" : "",
// superAdmin: isAdminInfo ? "superAdmin" : "",
saveList,
};
@@ -1594,7 +1628,7 @@ const SewagePipeInp = () => {
sggNm: values.gugun === "ALL" ? "" : (values.gugun ?? ""),
sewerBzmnCd: values.bsnmCd ?? "",
userId: userInfo.userUnqId,
superAdmin: isAdminInfo ? "superAdmin" : "",
// superAdmin: isAdminInfo ? "superAdmin" : "",
saveList,
};
@@ -1657,7 +1691,7 @@ const SewagePipeInp = () => {
sggNm: values.gugun === "ALL" ? "" : (values.gugun ?? ""),
sewerBzmnCd: values.bsnmCd ?? "",
userId: userInfo.userUnqId,
superAdmin: isAdminInfo ? "superAdmin" : "",
// superAdmin: isAdminInfo ? "superAdmin" : "",
saveList,
};
@@ -1736,7 +1770,7 @@ const SewagePipeInp = () => {
sggNm: values.gugun === "ALL" ? "" : (values.gugun ?? ""),
sewerBzmnCd: values.bsnmCd ?? "",
userId: userInfo.userUnqId,
superAdmin: isAdminInfo ? "superAdmin" : "",
// superAdmin: isAdminInfo ? "superAdmin" : "",
saveList,
};
@@ -1856,9 +1890,11 @@ const SewagePipeInp = () => {
const rightRowData = rowDataMap.get(rightKey) ?? [];
const splitTopButton = info.right.topButton;
const showSplitTopButton =
activeTab === tabKey &&
Boolean(splitTopButton?.text) &&
(splitTopButton?.visible ?? true) &&
excelToolbarEnabledForTab;
tabRowDataExists(rowDataMap, tabKey) &&
gugunOkOrNotDoList;
const leftColumnInfo: ColumnInfo = {
columns: info.left.columns ?? [],
columnLayout: info.left.columnLayout ?? [],
File diff suppressed because it is too large Load Diff
@@ -1,85 +1,928 @@
import { useState } from "react";
import {
useCallback,
useMemo,
useRef,
useState,
type RefObject,
} from "react";
import { flushSync } from "react-dom";
import { ValueType } from "realgrid";
import type { RealGridReact } from "realgrid-react";
import { LoadingWithAnimation, Tabs, TabsContent, TabsList, TabsTrigger } from "@common/components";
import SearchForm from "@/components/module/SearchForm";
import { TabDataGrid } from '@rsone/components';
import SubmitButton from "@/components/button/SumitButton";
import SaveButton from "@/components/button/SaveButton";
import LastYearDataButton from "@/components/button/LastYearDataButton";
import SubmitExceptionButton from "@/components/button/SubmitExceptionButton";
import AddArticleButton from "@/components/button/AddArticleButton";
import CancelSubmitButton from "@/components/button/CancelSubmitButton";
import DeleteArticleButton from "@/components/button/DeleteArticleButton";
import LastYearDataButton from "@/components/button/LastYearDataButton";
import NotApplicableCheckButton from "@/components/button/NotApplicableCheckButton";
import { SewageReservoirInpColumn } from "@/components/util/column/input/sewageReservoirPump/SewageReservoirInpColumn";
import SaveButton from "@/components/button/SaveButton";
import SubmitButton from "@/components/button/SumitButton";
import VerificationResultButton from "@/components/button/VerificationResultButton";
import DataGrid, { applyDropDownColumnToGrid } from "@/components/module/DataGrid";
import SearchForm from "@/components/module/SearchForm";
import { VerificationResultHistoryDialog } from "@/components/module/VerificationResultHistoryDialog";
import type { SearchFormValues } from "@/components/type/SearchForm.type";
import type {
CellEditedCallbackParams,
CellStyleCallbackParams,
CellTooltipCallbackParams,
ColumnConfig,
ColumnInfo,
ColumnInfoList,
} from "@/components/type/DataGrid.type";
import { doList } from "@/components/util/column/input/DoList";
import { RainfallPumpStationInpColumn } from "@/components/util/column/input/sewageReservoirPump/RainfallPumpStationInpColumn";
import { SewageReservoirInpColumn } from "@/components/util/column/input/sewageReservoirPump/SewageReservoirInpColumn";
import { toGridRows } from "@/components/util/grid/gridFunction";
import { formatCompactYmdForGrid, normalizeOxYnValue } from "@/components/util/grid/ymdFormat";
import { statisticsInputSewageReservoirPumpApi } from "@/entities/statistics/api/input/statistics-input-sewage-reservoir-pump-service";
import { statisticsApi } from "@/entities/statistics/api/statistics-service";
import {
SEWAGE_RESERVOIR_STATS_CD,
type SewageReservoirItem,
type SewageReservoirRequestParams,
type RainfallPumpStationItem,
type SewageReservoirVerificationResult,
} from "@/entities/statistics/model/input/statistics-input-sewage-reservoir-pump-interface";
import type {
ApiResponse,
Sgg,
SewerageStatusInfo,
} from "@/entities/statistics/model/statistics-interface";
import { useAlertStore, useStatisticsInputToolbarGroupGate } from "@/shared/lib";
import { useAdminStore } from "@/store/admin-store";
const SewageReservoirPumpInpTabList = [
SewageReservoirInpColumn,
RainfallPumpStationInpColumn,
] as ColumnInfoList[];
const toGridColumnInfo = (tab: ColumnInfoList): ColumnInfo => ({
columns: tab.columns ?? [],
columnLayout: tab.columnLayout ?? [],
});
const firstTabKey = SewageReservoirPumpInpTabList[0]?.tabKey ?? SEWAGE_RESERVOIR_STATS_CD;
const rainfallPumpTabKey =
RainfallPumpStationInpColumn.tabKey ?? "rainfallPumpStationInp";
type GridRowWithVerification = Record<string, string | number> & {
verificationResult?: SewageReservoirVerificationResult;
};
type TabToolbarSnapshot = {
showSaveInToolbar: boolean;
showEnterableToolbarButtons: boolean;
showActionToolbarCard: boolean;
};
const computeTabEnterable = (
statusInfo: SewerageStatusInfo | null | undefined,
periodEnterable: boolean,
): boolean => {
if (statusInfo?.laeSbmsnSttsCd) {
if (["2", "3"].includes(statusInfo.laeSbmsnSttsCd)) {
return false;
}
return periodEnterable;
}
return periodEnterable;
};
function buildGugunOkForToolbar(values: SearchFormValues | null): boolean {
return doList.includes(values?.bsnmCd || "")
? Boolean(values?.gugun && values.gugun !== "ALL")
: true;
}
function computeTabToolbarSnapshot(
statusInfo: SewerageStatusInfo | null,
hasRowData: boolean,
treatAsSearched: boolean,
searchContext: SearchFormValues | null,
periodEnterable: boolean,
isAdminInfo: boolean,
): TabToolbarSnapshot {
const gugunOk = buildGugunOkForToolbar(searchContext);
const enterable = computeTabEnterable(statusInfo, periodEnterable);
const isSubmitted = statusInfo?.laeSbmsnSttsCd === "2";
const showSaveInToolbar = (enterable || isAdminInfo) && gugunOk;
const showEnterableToolbarButtons = enterable && gugunOk;
const showSubmittedInInputPeriodToolbar =
isSubmitted && periodEnterable && gugunOk;
const showActionToolbarCard =
(hasRowData || treatAsSearched) &&
(showSaveInToolbar ||
showEnterableToolbarButtons ||
showSubmittedInInputPeriodToolbar ||
(isSubmitted && isAdminInfo));
return { showSaveInToolbar, showEnterableToolbarButtons, showActionToolbarCard };
}
function tabRowDataExists(
m: Map<string, Record<string, string | number>[]>,
tabKey: string,
): boolean {
const rows = m.get(tabKey);
return Array.isArray(rows) && rows.length > 0;
}
function buildSewageReservoirSearchParam(
values: SearchFormValues,
overrides?: { mode?: string },
): SewageReservoirRequestParams {
const fcltNm = values.fctName?.trim();
return {
statsYr: values.year,
sewerBzmnCd: values.bsnmCd,
ctpvNm: values.sido,
sggNm: values.gugun === "ALL" ? "" : values.gugun,
...(fcltNm ? { fcltNm } : {}),
...(overrides?.mode ? { mode: overrides.mode } : {}),
};
}
/** 항목 추가 시 빈 행 (시도는 조회 시 선택한 시도명) */
function createEmptySewageReservoirRow(
ctpvNm: string,
sggNm = "",
): GridRowWithVerification {
const row: GridRowWithVerification = { ctpvNm, sggNm };
for (const col of SewageReservoirInpColumn.columns) {
const fieldName = col.fieldName;
if (fieldName === "ctpvNm" || fieldName === "sggNm") continue;
row[fieldName] = col.valueType === ValueType.NUMBER ? 0 : "";
}
return row;
}
/** 빗물펌프장 탭 항목 추가 시 빈 행 */
function createEmptyRainfallPumpStationRow(
ctpvNm: string,
sggNm = "",
): GridRowWithVerification {
const row: GridRowWithVerification = { ctpvNm, sggNm };
for (const col of RainfallPumpStationInpColumn.columns) {
const fieldName = col.fieldName;
if (!fieldName) continue;
if (fieldName === "ctpvNm" || fieldName === "sggNm") continue;
row[fieldName] = col.valueType === ValueType.NUMBER ? 0 : "";
}
return row;
}
function applySggNmDropDownToReservoirGrid(
gridRef: RefObject<RealGridReact | null>,
labels: string[],
) {
if (labels.length === 0) return;
applyDropDownColumnToGrid(
gridRef.current?.gridView as {
setColumnProperty?: (col: string, key: string, value: unknown) => void;
},
"sggNm",
labels,
);
}
function parseSggListFromResponse(res: ApiResponse<Sgg[]>): Sgg[] {
if (res.status !== "success" || res.data == null) return [];
return Array.isArray(res.data) ? res.data : [];
}
function buildSggNmColumn(
col: ColumnConfig,
canEdit: boolean,
sggOptions: Sgg[],
): ColumnConfig {
if (!canEdit) {
return {
...col,
editable: false as const,
styleName: col.styleName ?? "rg-cell-readonly",
};
}
if (sggOptions.length === 0) {
return { ...col, editable: true };
}
const labels = sggOptions.map((s) => s.gugun);
return {
...col,
editable: true,
editor: {
type: "dropdown",
domainOnly: true,
textReadOnly: true,
commitOnSelect: true,
dropDownWhenClick: true,
values: labels,
labels,
},
values: labels,
labels,
lookupDisplay: true,
};
}
function mapSewageReservoirListToGridRows(
list: SewageReservoirItem[],
ctpvNm: string,
): GridRowWithVerification[] {
return list.map((item) => {
const row = toGridRows([
{
...item,
ctpvNm,
oprtngStrtYmd: formatCompactYmdForGrid(item.oprtngStrtYmd),
fldngPrvtnYn: normalizeOxYnValue(item.fldngPrvtnYn),
wqPlltnMtgtYn: normalizeOxYnValue(item.wqPlltnMtgtYn),
rtlztnUsgYn: normalizeOxYnValue(item.rtlztnUsgYn),
},
])[0] as GridRowWithVerification;
if (item.verificationResult) {
row.verificationResult = item.verificationResult;
}
return row;
});
}
function mapRainfallPumpStationListToGridRows(
list: RainfallPumpStationItem[],
ctpvNm: string,
): GridRowWithVerification[] {
return list.map((item) => {
const row = toGridRows([
{
...item,
ctpvNm,
oprtngStrtYmd: formatCompactYmdForGrid(item.oprtngStrtYmd),
rsvrYn: normalizeOxYnValue(item.rsvrYn),
prcsgFcltYn: normalizeOxYnValue(item.prcsgFcltYn),
ddrztnEqpmYn: normalizeOxYnValue(item.ddrztnEqpmYn),
oslfEtgtYn: normalizeOxYnValue(item.oslfEtgtYn),
},
])[0] as GridRowWithVerification;
if (item.verificationResult) {
row.verificationResult = item.verificationResult;
}
return row;
});
}
// 하수도 통계 관리 > 04. 하수저류 시설 및 빗물 펌프장
const SewageReservoirPumpInp = () => {
// tab List
const SewageReservoirPumpInpTabList = [
SewageReservoirInpColumn,
RainfallPumpStationInpColumn
];
const submitExceptionFunc = () => {
console.log('submit exception');
// 예외 제출 로직
};
const getLastYearDataFunc = () => {
console.log('get last year data');
// 지난연도 데이터 가져오기 로직
};
const { setMessage: alert } = useAlertStore((state) => state);
const { isAdminInfo } = useAdminStore();
const toolbarGroupAllowed = useStatisticsInputToolbarGroupGate();
const submitFunc = () => {
console.log('submit');
// 제출 로직
console.log("submit");
};
const saveFunc = () => {
console.log('save');
// 저장 로직
console.log("save");
};
const addArticleFunc = () => {
console.log('save');
// 항목 추가 로직
const cnacelSubmitFunc = () => {
console.log("cancel submit");
};
const deleteArticleFunc = () => {
console.log('save');
// 항목 제외 로직
console.log("delete article");
};
const [rowDataMap, setRowDataMap] = useState<Map<string, Record<string, string | number>[]>>(new Map());
const [activeTab, setActiveTab] = useState(firstTabKey);
const [rowDataMap, setRowDataMap] = useState<
Map<string, Record<string, string | number>[]>
>(new Map());
const [isNotApplicable, setIsNotApplicable] = useState(false);
const [showIsNotApplicable, setShowIsNotApplicable] = useState(false);
const [searchValues, setSearchValues] = useState<SearchFormValues | null>(null);
const [lastSearchedValues, setLastSearchedValues] = useState<SearchFormValues | null>(
null,
);
const [hasSearched, setHasSearched] = useState(false);
const [isLoading, setIsLoading] = useState(false);
const [inputPeriodEnterable, setInputPeriodEnterable] = useState(true);
const [tabStatusInfoMap, setTabStatusInfoMap] = useState<
Map<string, SewerageStatusInfo | null>
>(new Map());
const [toolbarSnapshotByTab, setToolbarSnapshotByTab] = useState<
Map<string, TabToolbarSnapshot>
>(new Map());
const [tabStatsCdMap, setTabStatsCdMap] = useState<Map<string, string>>(new Map());
const [verificationHistoryOpen, setVerificationHistoryOpen] = useState(false);
const [sggOptions, setSggOptions] = useState<Sgg[]>([]);
const reservoirGridRef = useRef<RealGridReact | null>(null);
const sggOptionsSidoRef = useRef<string | null>(null);
const searchDoneRef = useRef(false);
const periodEnterableRef = useRef(true);
const activeTabRef = useRef(activeTab);
activeTabRef.current = activeTab;
const commitToolbarSnapshotForLoadedTab = (
tabKey: string,
statusInfo: SewerageStatusInfo | null,
hasRowData: boolean,
searchContext: SearchFormValues,
) => {
setToolbarSnapshotByTab((prev) => {
const next = new Map(prev);
next.set(
tabKey,
computeTabToolbarSnapshot(
statusInfo,
hasRowData,
searchDoneRef.current,
searchContext,
periodEnterableRef.current,
isAdminInfo,
),
);
return next;
});
};
const applyRowMap = (
mergeIntoExistingMap: boolean,
apply: (m: Map<string, Record<string, string | number>[]>) => void,
) => {
if (mergeIntoExistingMap) {
setRowDataMap((prev) => {
const next = new Map(prev);
apply(next);
return next;
});
} else {
const next = new Map<string, Record<string, string | number>[]>();
apply(next);
setRowDataMap(next);
}
};
const loadActiveTabData = async (
tabKey: string,
values: SearchFormValues,
mergeIntoExistingMap: boolean,
updateLastSearchedValues: boolean,
overrides?: { mode?: string },
): Promise<boolean> => {
// 서버 데이터로 새로 불러오는 시점이므로 "해당없음" 체크는 항상 초기화
setIsNotApplicable(false);
if (tabKey === SEWAGE_RESERVOIR_STATS_CD) {
const response =
await statisticsInputSewageReservoirPumpApi.getSewageReservoirList(
buildSewageReservoirSearchParam(values, overrides),
);
if (response.status !== "success" || !response.data) {
alert(response.message ?? "데이터 조회 중 오류가 발생했습니다.");
return false;
}
const { commonInfo, statusInfo, sewageReservoirList } = response.data;
const list = sewageReservoirList ?? [];
const gridRows = mapSewageReservoirListToGridRows(
list,
commonInfo.ctpvNm,
);
if (activeTabRef.current === tabKey) {
setShowIsNotApplicable(list.length === 0);
}
applyRowMap(mergeIntoExistingMap, (m) => {
m.set(tabKey, gridRows);
});
setTabStatusInfoMap((prev) => new Map(prev).set(tabKey, statusInfo));
setTabStatsCdMap((prev) => new Map(prev).set(tabKey, commonInfo.statsCd));
if (updateLastSearchedValues) {
setLastSearchedValues(values);
}
commitToolbarSnapshotForLoadedTab(
tabKey,
statusInfo,
gridRows.length > 0,
values,
);
return true;
}
if (tabKey === rainfallPumpTabKey) {
const response =
await statisticsInputSewageReservoirPumpApi.getRainfallPumpStationList(
buildSewageReservoirSearchParam(values, overrides),
);
if (response.status !== "success" || !response.data) {
alert(response.message ?? "데이터 조회 중 오류가 발생했습니다.");
return false;
}
const { commonInfo, statusInfo, rainfallPumpStationList } = response.data;
const list = rainfallPumpStationList ?? [];
const gridRows = mapRainfallPumpStationListToGridRows(
list,
commonInfo.ctpvNm,
);
applyRowMap(mergeIntoExistingMap, (m) => {
m.set(tabKey, gridRows);
});
setTabStatusInfoMap((prev) => new Map(prev).set(tabKey, statusInfo));
setTabStatsCdMap((prev) => new Map(prev).set(tabKey, commonInfo.statsCd));
if (updateLastSearchedValues) {
setLastSearchedValues(values);
}
commitToolbarSnapshotForLoadedTab(
tabKey,
statusInfo,
gridRows.length > 0,
values,
);
return true;
}
alert("해당 탭 조회 API가 없습니다.");
return false;
};
const getLastYearDataFunc = async () => {
const values = lastSearchedValues ?? searchValues;
if (!values?.sido || values.sido === "ALL") {
alert("시도를 선택해주세요.");
return;
}
if (!hasSearched) {
alert("먼저 데이터를 조회해주세요.");
return;
}
try {
setIsLoading(true);
await loadActiveTabData(activeTabRef.current, values, true, false, {
mode: "PRE",
});
} catch {
alert("데이터 조회 중 오류가 발생했습니다.");
} finally {
setIsLoading(false);
}
};
const loadSggOptions = useCallback(async (sido: string): Promise<Sgg[]> => {
if (!sido || sido === "ALL") {
setSggOptions([]);
return [];
}
try {
const res = await statisticsApi.getSggAllList(sido);
const list = parseSggListFromResponse(res);
setSggOptions(list);
return list;
} catch {
setSggOptions([]);
return [];
}
}, []);
const handleReset = () => {
setRowDataMap(new Map());
setTabStatusInfoMap(new Map());
setTabStatsCdMap(new Map());
setToolbarSnapshotByTab(new Map());
setLastSearchedValues(null);
setSggOptions([]);
sggOptionsSidoRef.current = null;
setHasSearched(false);
setInputPeriodEnterable(true);
setIsNotApplicable(false);
setShowIsNotApplicable(false);
searchDoneRef.current = false;
periodEnterableRef.current = true;
setActiveTab(firstTabKey);
};
const handleSearch = async (values: SearchFormValues) => {
if (!values.sido || values.sido === "ALL") {
alert("시도를 선택해주세요.");
return;
}
const yearChangedFromLastSearch =
lastSearchedValues != null &&
(lastSearchedValues.year ?? "") !== (values.year ?? "");
if (yearChangedFromLastSearch) {
setRowDataMap(new Map());
setTabStatusInfoMap(new Map());
setTabStatsCdMap(new Map());
setToolbarSnapshotByTab(new Map());
setActiveTab(firstTabKey);
}
try {
setIsLoading(true);
searchDoneRef.current = true;
setHasSearched(true);
setLastSearchedValues(values);
const tabKeyToLoad = yearChangedFromLastSearch ? firstTabKey : activeTabRef.current;
if (yearChangedFromLastSearch) {
activeTabRef.current = firstTabKey;
setActiveTab(firstTabKey);
}
const ok = await loadActiveTabData(
tabKeyToLoad,
values,
!yearChangedFromLastSearch,
true,
);
if (!ok) return;
} catch {
alert("데이터 조회 중 오류가 발생했습니다.");
} finally {
setIsLoading(false);
}
};
const handleActiveTabChange = async (tabKey: string) => {
setActiveTab(tabKey);
if (tabKey === SEWAGE_RESERVOIR_STATS_CD) {
setShowIsNotApplicable(
hasSearched && !tabRowDataExists(rowDataMap, tabKey),
);
} else {
setShowIsNotApplicable(false);
}
if (!hasSearched || !lastSearchedValues) return;
if (tabRowDataExists(rowDataMap, tabKey)) return;
try {
setIsLoading(true);
await loadActiveTabData(tabKey, lastSearchedValues, true, false);
} catch {
alert("데이터 조회 중 오류가 발생했습니다.");
} finally {
setIsLoading(false);
}
};
const searchContextForToolbar = lastSearchedValues ?? searchValues;
const activeTabStatus = tabStatusInfoMap.get(activeTab) ?? null;
const activeTabToolbar =
toolbarSnapshotByTab.get(activeTab) ??
computeTabToolbarSnapshot(
activeTabStatus,
tabRowDataExists(rowDataMap, activeTab),
hasSearched,
searchContextForToolbar,
inputPeriodEnterable,
isAdminInfo,
);
const {
showSaveInToolbar: baseShowSaveInToolbar,
showEnterableToolbarButtons: baseShowEnterableToolbarButtons,
showActionToolbarCard: baseShowActionToolbarCard,
} = activeTabToolbar;
const showSaveInToolbar = baseShowSaveInToolbar && toolbarGroupAllowed;
const showEnterableToolbarButtons =
baseShowEnterableToolbarButtons && toolbarGroupAllowed;
const showActionToolbarCard =
baseShowActionToolbarCard && toolbarGroupAllowed;
// 항목추가/제거 버튼은 "데이터가 있든 없든", "과거 데이터(입력 불가 상태)여도"
// 조회를 하고나면 항상 보여야 합니다.
const isReservoirOrRainfallTab =
activeTab === SEWAGE_RESERVOIR_STATS_CD || activeTab === rainfallPumpTabKey;
const showArticleToolbarCard =
hasSearched && isReservoirOrRainfallTab && toolbarGroupAllowed;
const openVerificationHistoryDialog = () => {
const ctx = lastSearchedValues ?? searchValues;
const activeTabStatsCd = tabStatsCdMap.get(activeTab);
if (!ctx?.year || !ctx?.sido || !ctx?.bsnmCd || !activeTabStatsCd) {
alert("먼저 조회를 진행해주세요.");
return;
}
setVerificationHistoryOpen(true);
};
const addArticleFunc = async () => {
const ctx = lastSearchedValues ?? searchValues;
if (!hasSearched || !ctx?.sido || ctx.sido === "ALL") {
alert("먼저 조회를 진행해주세요.");
return;
}
const activeTabKey = activeTabRef.current;
const isReservoirTab = activeTabKey === SEWAGE_RESERVOIR_STATS_CD;
const isRainfallPumpTab = activeTabKey === rainfallPumpTabKey;
if (!isReservoirTab && !isRainfallPumpTab) {
alert("항목을 추가할 수 없는 탭입니다.");
return;
}
const sggNm =
ctx.gugun && ctx.gugun !== "ALL" ? ctx.gugun : "";
if (isRainfallPumpTab) {
const newRow = createEmptyRainfallPumpStationRow(ctx.sido, sggNm);
setRowDataMap((prev) => {
const next = new Map(prev);
const current = next.get(rainfallPumpTabKey) ?? [];
next.set(rainfallPumpTabKey, [...current, newRow]);
return next;
});
const statusInfo = tabStatusInfoMap.get(rainfallPumpTabKey) ?? null;
commitToolbarSnapshotForLoadedTab(rainfallPumpTabKey, statusInfo, true, ctx);
return;
}
let list = sggOptions;
if (sggOptionsSidoRef.current !== ctx.sido || list.length === 0) {
list = await loadSggOptions(ctx.sido);
sggOptionsSidoRef.current = ctx.sido;
}
if (list.length === 0) {
alert("시군구 목록을 불러오지 못했습니다.");
return;
}
const tabKey = SEWAGE_RESERVOIR_STATS_CD;
const labels = list.map((s) => s.gugun).filter(Boolean);
const newRow = createEmptySewageReservoirRow(ctx.sido, sggNm);
flushSync(() => {
setSggOptions(list);
setRowDataMap((prev) => {
const next = new Map(prev);
const current = next.get(tabKey) ?? [];
next.set(tabKey, [...current, newRow]);
return next;
});
});
// 드롭다운 컬럼은 columnInfoByTab(buildSggNmColumn) 렌더링으로 반영한다.
// 행 추가 직후 setColumnProperty를 강제 재적용하면 타이밍 이슈로
// `fieldIndex is out of bounds`가 발생할 수 있어 제거한다.
setShowIsNotApplicable(false);
const statusInfo = tabStatusInfoMap.get(tabKey) ?? null;
commitToolbarSnapshotForLoadedTab(tabKey, statusInfo, true, ctx);
};
const columnInfoByTab = useMemo(() => {
const map = new Map<string, ColumnInfo>();
for (const tab of SewageReservoirPumpInpTabList) {
const tabKey = tab.tabKey ?? "";
const canEdit =
computeTabEnterable(
tabStatusInfoMap.get(tabKey) ?? null,
inputPeriodEnterable,
) || isAdminInfo;
const isReservoirTab = tabKey === SEWAGE_RESERVOIR_STATS_CD;
const columns = (tab.columns ?? []).map((col: ColumnConfig) => {
if (isReservoirTab && col.fieldName === "sggNm") {
return buildSggNmColumn(col, canEdit, sggOptions);
}
return canEdit
? col
: {
...col,
editable: false as const,
styleName: col.styleName ?? "rg-cell-readonly",
};
});
map.set(tabKey, {
columns,
columnLayout: tab.columnLayout ?? [],
});
}
return map;
}, [tabStatusInfoMap, inputPeriodEnterable, isAdminInfo, sggOptions]);
const handleSewageReservoirCellEdited = useCallback(
(params: CellEditedCallbackParams) => {
if (params.fieldName !== "sggNm") return;
const selected = sggOptions.find((s) => s.gugun === params.newValue);
if (!selected) return;
const tabKey = SEWAGE_RESERVOIR_STATS_CD;
setRowDataMap((prev) => {
const next = new Map(prev);
const rows = [...(next.get(tabKey) ?? [])];
const row = rows[params.itemIndex];
if (!row) return prev;
rows[params.itemIndex] = {
...row,
sggNm: selected.gugun,
admdstCd: selected.adstDstrCd,
admdstNm: selected.gugun,
};
next.set(tabKey, rows);
return next;
});
},
[sggOptions],
);
const verificationCellStyle = ({
fieldName,
row,
}: CellStyleCallbackParams): string | undefined => {
const verification = (row as GridRowWithVerification).verificationResult;
if (!verification) return;
const errors = verification[fieldName];
if (Array.isArray(errors) && errors.length > 0) {
const hasImr = errors.some((e) => e.vrfcCd === "IMR" || e.vrfcCd === "HB");
return hasImr ? "rg-cell-verify-error-imr" : "rg-cell-verify-error";
}
};
const verificationCellTooltip = ({
fieldName,
row,
}: CellTooltipCallbackParams): string | undefined => {
const verification = (row as GridRowWithVerification).verificationResult;
if (!verification) return;
const errors = verification[fieldName];
if (Array.isArray(errors) && errors.length > 0) {
return errors
.map((e) => {
const prefix =
e.vrfcCd === "IMR"
? "IMR검증 - "
: e.vrfcCd === "NV"
? "일반검증 - "
: "HB검증 -";
return `${prefix}${e.rsnExpln}
(검증 기준값: ${e.compVlCn} / 현재 입력값: ${e.nowVlCn})`;
})
.filter(Boolean)
.join("\n");
}
};
const verificationQueryContext = (() => {
const ctx = lastSearchedValues ?? searchValues;
const statsCd = tabStatsCdMap.get(activeTab);
if (!ctx?.year || !ctx?.sido || !ctx?.bsnmCd || !statsCd) return null;
return {
sewerBzmnCd: ctx.bsnmCd,
ctpvNm: ctx.sido,
sggNm: ctx.gugun === "ALL" ? "" : (ctx.gugun ?? ""),
statsYr: ctx.year,
statsCd,
};
})();
return (
<>
<SearchForm fctName={true} />
<div className="card !py-0">
<div className="flex justify-end items-center gap-[0.6rem] m-[0.6rem]">
<SubmitExceptionButton SubmitExceptionFunc={submitExceptionFunc} />
<LastYearDataButton getLastYearDataFunc={getLastYearDataFunc} />
<SubmitButton submitFunc={submitFunc} />
<SaveButton saveFunc={saveFunc} />
</div>
<div className="relative">
<SearchForm
fctName={true}
onSubmit={handleSearch}
onReset={handleReset}
onValuesChange={setSearchValues}
/>
{showActionToolbarCard && (
<div className="card !py-0">
<div className="m-[0.6rem] flex items-center justify-end gap-[0.6rem]">
<VerificationResultButton
VerificationResultButtonFunc={openVerificationHistoryDialog}
/>
{showEnterableToolbarButtons && (
<>
<LastYearDataButton
getLastYearDataFunc={() => void getLastYearDataFunc()}
/>
<SubmitButton submitFunc={submitFunc} />
</>
)}
{activeTabStatus?.laeSbmsnSttsCd === "2" && (
<CancelSubmitButton cnacelSubmitFunc={cnacelSubmitFunc} />
)}
{showSaveInToolbar && <SaveButton saveFunc={saveFunc} />}
</div>
</div>
)}
{showArticleToolbarCard && (
<div className="card !py-0">
<div className="m-[0.6rem] flex items-center justify-end gap-[0.6rem]">
{showIsNotApplicable && (
<NotApplicableCheckButton
isCheck={isNotApplicable}
setIsCheck={setIsNotApplicable}
/>
)}
<AddArticleButton addArticleFunc={addArticleFunc} />
<DeleteArticleButton deleteArticleFunc={deleteArticleFunc} />
</div>
</div>
)}
{isLoading && (
<div className="absolute inset-0 z-50 flex items-center justify-center rounded-lg p-10 [data-theme='black']:bg-transparent">
<div className="relative size-[5rem]">
<LoadingWithAnimation color="primary" />
</div>
</div>
)}
<Tabs
value={activeTab}
onValueChange={(v) => void handleActiveTabChange(v)}
>
<TabsList>
{SewageReservoirPumpInpTabList.map((tab) => (
<TabsTrigger key={tab.tabKey} value={tab.tabKey ?? ""}>
{tab.tabLabel || tab.tabKey}
</TabsTrigger>
))}
</TabsList>
{SewageReservoirPumpInpTabList.map((tab) => {
const tabKey = tab.tabKey ?? "";
const tabEnterableForGrid =
computeTabEnterable(
tabStatusInfoMap.get(tabKey) ?? null,
inputPeriodEnterable,
) || isAdminInfo;
const gugunOk = buildGugunOkForToolbar(lastSearchedValues);
const tabStatusInfo = tabStatusInfoMap.get(tabKey) ?? null;
const isReservoirTab = tabKey === SEWAGE_RESERVOIR_STATS_CD;
return (
<TabsContent key={tabKey} value={tabKey}>
<DataGrid
key={
tabEnterableForGrid || isAdminInfo
? `${tabKey}-enterable`
: `${tabKey}-readonly`
}
getGridRef={
isReservoirTab ? reservoirGridRef : undefined
}
columnInfo={
columnInfoByTab.get(tabKey) ??
toGridColumnInfo(tab)
}
rowStartNum={tab.rowStartNum}
fixedColumns={tab.fixedColumns}
rowData={rowDataMap.get(tabKey) ?? []}
setRowData={(updater) => {
setRowDataMap((prev) => {
const next = new Map(prev);
const cur = next.get(tabKey) ?? [];
const n =
typeof updater === "function"
? (updater as (
p: Record<
string,
string | number
>[],
) => Record<
string,
string | number
>[])(cur)
: updater;
next.set(tabKey, n);
return next;
});
}}
showHeaderCheckbox
statusInfo={tabStatusInfo}
isExcelImport={
tabRowDataExists(rowDataMap, tabKey) &&
tabEnterableForGrid &&
gugunOk
}
cellStyleCallback={verificationCellStyle}
cellTooltipCallback={verificationCellTooltip}
onCellEdited={
isReservoirTab &&
tabEnterableForGrid &&
sggOptions.length > 0
? handleSewageReservoirCellEdited
: undefined
}
/>
</TabsContent>
);
})}
</Tabs>
</div>
<div className="card !py-0">
<div className="flex justify-end items-center gap-[0.6rem] m-[0.6rem]">
<NotApplicableCheckButton isCheck={isNotApplicable} setIsCheck={setIsNotApplicable} />
<AddArticleButton addArticleFunc={addArticleFunc} />
<DeleteArticleButton deleteArticleFunc={deleteArticleFunc} />
</div>
</div>
<TabDataGrid
columnInfo={SewageReservoirPumpInpTabList}
rowDataMap={rowDataMap}
setRowDataMap={setRowDataMap}
<VerificationResultHistoryDialog
open={verificationHistoryOpen}
onOpenChange={setVerificationHistoryOpen}
queryContext={verificationQueryContext}
/>
</>
);
};
export default SewageReservoirPumpInp;
export default SewageReservoirPumpInp;
@@ -1,7 +1,7 @@
import { useState } from "react";
import SearchForm from "@/components/module/SearchForm";
import { TabDataGrid } from '@rsone/components';
import TabDataGrid from "@/components/module/TabDataGrid";
import SubmitButton from "@/components/button/SumitButton";
import SaveButton from "@/components/button/SaveButton";
import SubmitExceptionButton from "@/components/button/SubmitExceptionButton";
@@ -41,6 +41,7 @@ import VerificationResultButton from "@/components/button/VerificationResultButt
// import { DataGrid } from "@rsone/components";
import DataGrid from "@/components/module/DataGrid";
import SearchForm from "@/components/module/SearchForm";
import { openVerificationResultDialogIfNeeded } from "@/components/module/post-submit-verification-results";
import { VerificationResultDialog } from "@/components/module/VerificationResultDialog";
import { VerificationResultHistoryDialog } from "@/components/module/VerificationResultHistoryDialog";
import type { SearchFormValues } from "@/components/type/SearchForm.type";
@@ -253,7 +254,6 @@ const SeweragePenetrationRateInp = () => {
...d,
ctpvNm: commonInfo.ctpvNm,
spcCtpnRgnCn: d.spcCtpnRgnCn === '' || d.spcCtpnRgnCn === null ? '해당없음' : d.spcCtpnRgnCn,
swagePplPsplyRt: commonInfo.swagePplPsplyRt,
}));
setRowData(
convertedData.map((d) => {
@@ -348,9 +348,9 @@ const SeweragePenetrationRateInp = () => {
statsCd: statsCd,
};
if (isAdminInfo) {
params.superAdmin = 'superAdmin';
}
// if (isAdminInfo) {
// params.superAdmin = 'superAdmin';
// }
try {
setIsLoading(true);
@@ -525,11 +525,35 @@ const SeweragePenetrationRateInp = () => {
}
setGlobalProgress(100);
await fetchSewerageSupplyRate(getSewerageSupplyRateParams(searchValues ?? {}), true);
setVerificationResultDialogOpen(true);
await openVerificationResultDialogIfNeeded(
searchValues?.year && searchValues?.sido && searchValues?.bsnmCd && statsCd
? {
sewerBzmnCd: searchValues.bsnmCd,
ctpvNm: searchValues.sido,
sggNm: searchValues.gugun === "ALL" ? "" : (searchValues.gugun ?? ""),
statsYr: searchValues.year,
statsCd,
}
: null,
setVerificationResultDialogOpen,
{ notify: alert },
);
} catch (e) {
await fetchSewerageSupplyRate(getSewerageSupplyRateParams(searchValues ?? {}), true);
setIsLoading(false);
setVerificationResultDialogOpen(true);
await openVerificationResultDialogIfNeeded(
searchValues?.year && searchValues?.sido && searchValues?.bsnmCd && statsCd
? {
sewerBzmnCd: searchValues.bsnmCd,
ctpvNm: searchValues.sido,
sggNm: searchValues.gugun === "ALL" ? "" : (searchValues.gugun ?? ""),
statsYr: searchValues.year,
statsCd,
}
: null,
setVerificationResultDialogOpen,
{ notify: alert, notifySubmitComplete: false },
);
const msg =
e instanceof Error && e.message
? e.message
@@ -540,7 +564,19 @@ const SeweragePenetrationRateInp = () => {
} else {
setGlobalProgress(100);
await fetchSewerageSupplyRate(getSewerageSupplyRateParams(searchValues ?? {}), false);
setVerificationResultDialogOpen(true);
await openVerificationResultDialogIfNeeded(
searchValues?.year && searchValues?.sido && searchValues?.bsnmCd && statsCd
? {
sewerBzmnCd: searchValues.bsnmCd,
ctpvNm: searchValues.sido,
sggNm: searchValues.gugun === "ALL" ? "" : (searchValues.gugun ?? ""),
statsYr: searchValues.year,
statsCd,
}
: null,
setVerificationResultDialogOpen,
{ notify: alert },
);
}
} else {
alert(response.message || '데이터 제출 중 오류가 발생했습니다.');
@@ -641,9 +677,9 @@ const SeweragePenetrationRateInp = () => {
sewerBzmnFrgnrWholPpltnCnt: footerFromApi?.sewerBzmnFrgnrWholPpltnCnt // 행정참고자료 외국인 합계
};
if (isAdminInfo) {
requestData.superAdmin = 'superAdmin';
}
// if (isAdminInfo) {
// requestData.superAdmin = 'superAdmin';
// }
const response = await statisticsInputSewSupplyRateApi.saveSewerageSupplyRate(requestData);
@@ -711,16 +747,21 @@ const SeweragePenetrationRateInp = () => {
? Boolean(lastSearchedValues?.gugun && lastSearchedValues.gugun !== 'ALL')
: true;
const inputPeriodCurrent = isInputPeriodCurrent(periodCheckList);
const isSubmitted = statusInfo?.laeSbmsnSttsCd === '2';
const baseShowSaveInToolbar = (isEnterable || isAdminInfo) && gugunOkOrNotDoList;
const baseShowEnterableToolbarButtons = isEnterable && gugunOkOrNotDoList;
const showSubmittedInInputPeriodToolbar =
isSubmitted && inputPeriodCurrent && gugunOkOrNotDoList;
const showSaveInToolbar = baseShowSaveInToolbar && toolbarGroupAllowed;
const showEnterableToolbarButtons = baseShowEnterableToolbarButtons && toolbarGroupAllowed;
/** 카드는 저장/제출줄/제출취소 중 하나라도 보일 때만 (관리자만으로 빈 카드 방지) */
const showActionToolbarCard =
rowData.length > 0 &&
toolbarGroupAllowed &&
(baseShowSaveInToolbar ||
(statusInfo?.laeSbmsnSttsCd === '2' && (isAdminInfo || (isEnterable && gugunOkOrNotDoList))));
baseShowEnterableToolbarButtons ||
showSubmittedInInputPeriodToolbar ||
(isSubmitted && isAdminInfo));
return (
<>
@@ -781,7 +822,7 @@ const SeweragePenetrationRateInp = () => {
if (Array.isArray(errors) && errors.length > 0) {
return errors
.map((e) => {
const prefix = e.vrfcCd === 'IMR' ? 'IMR검증 - ' : (e.vrfcCd === 'NM' ? '일반검증 - ' : 'HB검증 -');
const prefix = e.vrfcCd === 'IMR' ? 'IMR검증 - ' : (e.vrfcCd === 'NV' ? '일반검증 - ' : 'HB검증 -');
return `${prefix}${e.rsnExpln}
(검증 기준값: ${e.compVlCn} / 현재 입력값: ${e.nowVlCn})`;
})
@@ -1,7 +1,7 @@
import { useState } from "react";
import SearchForm from "@/components/module/SearchForm";
import { TabDataGrid } from '@rsone/components';
import TabDataGrid from "@/components/module/TabDataGrid";
import SubmitButton from "@/components/button/SumitButton";
import SaveButton from "@/components/button/SaveButton";
import LastYearDataButton from "@/components/button/LastYearDataButton";
@@ -4,7 +4,7 @@ import { useConfirm } from "@frontend-opensource/use-react-hooks";
import { RealGridReact } from "realgrid-react";
import useSWR, { useSWRConfig } from "swr";
import { useAlertStore, useStatisticsInputStatusConfirmGroupGate } from "@/shared/lib";
import { useActiveTabRegAuthorityAllowed, useAlertStore } from "@/shared/lib";
import { statisticsInputSewSupplyRateApi } from "@/entities/statistics/api/input/statistics-input-sew-supply-rate-service";
import { StatisticsInputStatusApi } from "@/entities/statistics/api/input/statistics-input-status-service";
import { statisticsApi } from "@/entities/statistics/api/statistics-service";
@@ -62,7 +62,7 @@ const StatsInputStatusInp = () => {
ExceptionProcessingHistoryQueryContext | null
>(null);
const [isApproveLoading, setIsApproveLoading] = useState(false);
const confirmGroupToolbarAllowed = useStatisticsInputStatusConfirmGroupGate();
const regAuthorityToolbarAllowed = useActiveTabRegAuthorityAllowed();
const swrKey = searchParams
? (["stats-input-status", searchParams] as const)
@@ -214,6 +214,10 @@ const StatsInputStatusInp = () => {
const openExceptionHistoryForRow = useCallback(
(row: Record<string, string | number>) => {
if (!regAuthorityToolbarAllowed) {
alert("권한이 없습니다.");
return;
}
const ctx = lastSearchedValues;
if (!ctx?.year || !ctx?.sido || !ctx?.bsnmCd) {
alert("먼저 조회를 진행해주세요.");
@@ -233,7 +237,7 @@ const StatsInputStatusInp = () => {
});
setExceptionHistoryOpen(true);
},
[alert, lastSearchedValues],
[alert, lastSearchedValues, regAuthorityToolbarAllowed],
);
const handleExceptionSubmitHistoryCellClick = useCallback(
@@ -266,7 +270,7 @@ const StatsInputStatusInp = () => {
gugunOkOrNotDoList &&
inputPeriodCurrentRef.current &&
!allRowsLaeSbmsnApproved &&
confirmGroupToolbarAllowed;
regAuthorityToolbarAllowed;
return (
<>
@@ -1,7 +1,7 @@
import { useState } from "react";
import SearchForm from "@/components/module/SearchForm";
import { TabDataGrid } from '@rsone/components';
import TabDataGrid from "@/components/module/TabDataGrid";
import SubmitButton from "@/components/button/SumitButton";
import SaveButton from "@/components/button/SaveButton";
import LastYearDataButton from "@/components/button/LastYearDataButton";
@@ -1,6 +1,7 @@
"use client";
import { useEffect } from "react";
import { useEffect, useMemo, useState } from "react";
import { GridFitStyle } from "realgrid";
import {
Button,
@@ -10,9 +11,20 @@ import {
DialogFooter,
DialogHeader,
DialogTitle,
LoadingWithAnimation,
} from "@common/components";
import DataGrid from "@/components/module/DataGrid";
import type { ColumnInfo } from "@/components/type/DataGrid.type";
import {
alignLogDetailRowsToColumns,
buildLogHistoryDetailColumnInfo,
emptyLogHistoryDetailColumnInfo,
} from "@/components/util/column/management/LogHistoryDetailColumn";
import { toGridRows } from "@/components/util/grid/gridFunction";
import { statisticsApi } from "@/entities/statistics/api/statistics-service";
import type { StatisticsLogDetailRow } from "@/entities/statistics/model/statistics-interface";
import { useAlertStore } from "@/shared/lib";
export interface LogHistoryDetailDialogProps {
open: boolean;
@@ -25,24 +37,115 @@ export function LogHistoryDetailDialog({
onOpenChange,
row,
}: LogHistoryDetailDialogProps) {
const { setMessage: alert } = useAlertStore((state) => state);
const [isLoading, setIsLoading] = useState(false);
const [rowData, setRowData] = useState<Record<string, string | number>[]>(
[],
);
const [columnInfo, setColumnInfo] = useState<ColumnInfo>(
emptyLogHistoryDetailColumnInfo,
);
useEffect(() => {
if (!open || !row) return;
if (!open) {
setRowData([]);
setColumnInfo(emptyLogHistoryDetailColumnInfo);
setIsLoading(false);
return;
}
if (!row) return;
const sn = row.statsLogSn;
let cancelled = false;
void (async () => {
const response = await statisticsApi.getLogDetail(
sn !== undefined && sn !== null ? String(sn) : "",
);
console.log(response.data);
setIsLoading(true);
setRowData([]);
try {
const response = await statisticsApi.getLogDetail(
sn !== undefined && sn !== null ? String(sn) : "",
);
if (cancelled) return;
if (response.status !== "success") {
alert(
response.message ??
"로그 상세 정보를 불러오는데 실패했습니다.",
);
return;
}
const list = Array.isArray(response.data)
? response.data
: [];
const nextColumnInfo = buildLogHistoryDetailColumnInfo(list);
const nextRows = alignLogDetailRowsToColumns(
toGridRows(list as StatisticsLogDetailRow[]),
nextColumnInfo,
);
setColumnInfo(nextColumnInfo);
setRowData(nextRows);
} catch {
if (!cancelled) {
alert("로그 상세 정보를 불러오는데 실패했습니다.");
}
} finally {
if (!cancelled) setIsLoading(false);
}
})();
}, [open, row]);
return () => {
cancelled = true;
};
}, [open, row, alert]);
const gridRemountKey = useMemo(
() => columnInfo.columns.map((c) => c.fieldName).join("\u0001"),
[columnInfo],
);
const showGrid = !isLoading && rowData.length > 0;
return (
<Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent className="max-w-[60rem]" aria-describedby={undefined}>
<DialogContent
className="max-w-[80%]"
aria-describedby={undefined}
>
<DialogHeader>
<DialogTitle />
<DialogTitle>로그 상세 정보</DialogTitle>
</DialogHeader>
<div className="relative min-h-[32rem]">
{isLoading && (
<div className="absolute inset-0 z-10 flex items-center justify-center rounded-lg bg-background/80">
<div className="relative size-[5rem]">
<LoadingWithAnimation color="primary" />
</div>
</div>
)}
{showGrid ? (
<DataGrid
key={gridRemountKey}
columnInfo={columnInfo}
rowData={rowData}
setRowData={setRowData}
isExcelImport={false}
isExcelExport={false}
enableSort={false}
gridHeight={55}
fitStyle={GridFitStyle.NONE}
/>
) : (
!isLoading && (
<p className="flex h-full items-center justify-center text-[1.3rem] text-muted-foreground">
표시할 상세 데이터가 없습니다.
</p>
)
)}
</div>
<DialogFooter>
<DialogClose asChild>
<Button size="lg" color="white" type="button">
@@ -1,41 +1,342 @@
import { useState } from "react";
import { useMemo, useState } from "react";
import { GridFitStyle } from "realgrid";
import { useAlertStore, useUserStore } from "@/shared/lib";
import { statisticsApi } from "@/entities/statistics/api/statistics-service";
import type {
UnSubmitDataStatusItem,
unSubmitDataRequest,
} from "@/entities/statistics/model/statistics-interface";
import { LoadingWithAnimation } from "@common/components";
import DataGrid from "@/components/module/DataGrid";
import SearchForm from "@/components/module/SearchForm";
import type { ColumnConfig } from "@/components/type/DataGrid.type";
import type { ColumnConfig, ColumnLayoutItem } from "@/components/type/DataGrid.type";
import type { SearchFormValues } from "@/components/type/SearchForm.type";
import { toGridRows } from "@/components/util/grid/gridFunction";
const NotEnteredStatus = () => {
const [rowData, setRowData] = useState<Record<string, string | number>[]>([]);
const SIDO_FIELD = "\uc2dc\ub3c4";
const SGG_FIELD = "\uc2dc\uad70\uad6c";
const columns: ColumnConfig[] = [
{ name: 'sido', fieldName: '시도', header: { text: '시도' }, mergeCells: true, editable: false, sortdisable: true },
{ name: 'sgg', fieldName: '시군구', header: { text: '시군구' }, mergeCells: true, editable: false, sortdisable: true },
{ name: '1', fieldName: '1', header: { text: '시군구' }, mergeCells: true, editable: false, sortdisable: true },
{ name: '2-1', fieldName: '2-1', header: { text: '2-1' }, mergeCells: true, editable: false, sortdisable: true },
{ name: '2-2', fieldName: '2-2', header: { text: '2-2' }, mergeCells: true, editable: false, sortdisable: true },
{ name: '2-3', fieldName: '2-3', header: { text: '2-3' }, mergeCells: true, editable: false, sortdisable: true },
{ name: '2-4', fieldName: '2-4', header: { text: '2-4' }, mergeCells: true, editable: false, sortdisable: true },
{ name: '3-1', fieldName: '3-1', header: { text: '3-1' }, mergeCells: true, editable: false, sortdisable: true },
{ name: '3-2', fieldName: '3-2', header: { text: '3-2' }, mergeCells: true, editable: false, sortdisable: true },
];
function sbmsnYnToOx(sbmsnYn: string): string {
const v = sbmsnYn.trim().toUpperCase();
if (v === "Y") return "O";
if (v === "N") return "X";
return sbmsnYn;
}
const columnLayout = [
'sido',
'sgg',
function splitSidoSggNm(sidoSggNm: string): { sido: string; sgg: string } {
const trimmed = sidoSggNm.trim();
if (!trimmed) return { sido: "", sgg: "" };
const spaceIdx = trimmed.indexOf(" ");
if (spaceIdx < 0) return { sido: trimmed, sgg: "" };
return {
sido: trimmed.slice(0, spaceIdx),
sgg: trimmed.slice(spaceIdx + 1).trim(),
};
}
function statColumnKey(statsNm: string, statsCd: string): string {
const trimmed = statsNm.trim();
const dotIdx = trimmed.indexOf(".");
if (dotIdx > 0) return trimmed.slice(0, dotIdx).trim();
return statsCd;
}
type StatDefinition = { statsCd: string; statsNm: string; columnKey: string };
/** submission-status statusList 기준 고정 컬럼(번호) */
const FIXED_STAT_DEFINITIONS: StatDefinition[] = [
{ columnKey: "1", statsCd: "ST00010001", statsNm: "1.하수도보급률" },
{ columnKey: "2-1", statsCd: "ST00020001", statsNm: "2-1.하수관로-지역별 시설현황" },
{ columnKey: "2-2", statsCd: "ST00020002", statsNm: "2-2.하수관로-시설별 설치현황" },
{ columnKey: "2-3", statsCd: "ST00020003", statsNm: "2-3.하수관로-관종별 현황" },
{ columnKey: "2-4", statsCd: "ST00020004", statsNm: "2-4.하수관로-관경별 현황" },
{ columnKey: "2-5", statsCd: "ST00020005", statsNm: "2-5.하수관로-맨홀 현황" },
{ columnKey: "3-1", statsCd: "ST00030001", statsNm: "3-1.하수관로유지관리-개․보수" },
{ columnKey: "3-2", statsCd: "ST00030002", statsNm: "3-2.하수관로유지관리-하수관로준설" },
{
columnKey: "4-1",
statsCd: "ST00040001",
statsNm: "4-1.하수저류시설및빗물펌프장-하수저류시설",
},
{
columnKey: "4-2",
statsCd: "ST00040002",
statsNm: "4-2.하수저류시설및빗물펌프장-빗물펌프장",
},
{ columnKey: "5", statsCd: "ST00050001", statsNm: "5.펌프장" },
{ columnKey: "6", statsCd: "ST00060001", statsNm: "6.공공하수처리시설" },
{ columnKey: "7", statsCd: "ST00070001", statsNm: "7.분뇨처리시설" },
{
columnKey: "8-1",
statsCd: "ST00080001",
statsNm: "8-1.개인하수처리시설-처리용량별 오수처리시설 현황",
},
{
columnKey: "8-2",
statsCd: "ST00080002",
statsNm: "8-2.개인하수처리시설-정화조 현황(처리방식별, 처리용량별)",
},
{
columnKey: "8-3",
statsCd: "ST00080003",
statsNm: "8-3.개인하수처리시설-개인하수도관리지역 지정공고 현황",
},
{ columnKey: "9-1", statsCd: "ST00090001", statsNm: "9-1.하수도재정-하수도 재정 결산" },
{ columnKey: "9-2", statsCd: "ST00090002", statsNm: "9-2.하수도재정-하수도 요금" },
{ columnKey: "9-3", statsCd: "ST00090003", statsNm: "9-3.하수도재정-업종별 하수도 사용료" },
{ columnKey: "9-4", statsCd: "ST00090004", statsNm: "9-4.하수도재정-하수도 원인자부담금" },
{ columnKey: "10-1", statsCd: "ST00100001", statsNm: "10-1.물재이용-공공하수처리수 재이용" },
{ columnKey: "10-2", statsCd: "ST00100002", statsNm: "10-2.물재이용-중수도 현황" },
{ columnKey: "10-3", statsCd: "ST00100003", statsNm: "10-3.물재이용-빗물이용시설현황" },
{
columnKey: "10-4",
statsCd: "ST00100004",
statsNm: "10-4.물재이용-하․폐수처리수 재이용시설 설계시공업 등록현황",
},
{
columnKey: "10-5",
statsCd: "ST00100005",
statsNm: "10-5.물재이용-하․폐수처리수 재이용사업 인가현황",
},
{
columnKey: "11-1",
statsCd: "ST00110001",
statsNm: "11-1.하수및분뇨찌꺼기(슬러지)처리-하수찌꺼기 발생 및 처리현황",
},
{
columnKey: "11-2",
statsCd: "ST00110002",
statsNm: "11-2.하수및분뇨찌꺼기(슬러지)처리-소화조 운영 현황",
},
{
columnKey: "11-3",
statsCd: "ST00110003",
statsNm: "11-3.하수및분뇨찌꺼기(슬러지)처리-하수찌꺼기 처리시설 운영 현황",
},
{
columnKey: "11-4",
statsCd: "ST00110004",
statsNm: "11-4.하수및분뇨찌꺼기(슬러지)처리-분뇨찌꺼기 처리현황",
},
{
columnKey: "12-1",
statsCd: "ST00120001",
statsNm: "12-1.하수및분뇨관련산업-공공기관 하수분야",
},
{
columnKey: "12-2",
statsCd: "ST00120002",
statsNm: "12-2.하수및분뇨관련산업-공공기관 분뇨분야",
},
{
columnKey: "12-3",
statsCd: "ST00120003",
statsNm: "12-3.하수및분뇨관련산업-분뇨수집 · 운반업 현황",
},
{
columnKey: "12-4",
statsCd: "ST00120004",
statsNm: "12-4.하수및분뇨관련산업-개인하수처리 관련업 현황",
},
{ columnKey: "13-1", statsCd: "ST00130001", statsNm: "13-1.하수도서비스수준-민원" },
{ columnKey: "13-2", statsCd: "ST00130002", statsNm: "13-2.하수도서비스수준-주민친화시설" },
];
const STAT_COLUMN_KEYS = FIXED_STAT_DEFINITIONS.map((d) => d.columnKey);
const STATS_CD_TO_COLUMN_KEY = Object.fromEntries(
FIXED_STAT_DEFINITIONS.map((d) => [d.statsCd, d.columnKey]),
) as Record<string, string>;
function resolveColumnKey(item: UnSubmitDataStatusItem): string {
return STATS_CD_TO_COLUMN_KEY[item.statsCd] ?? statColumnKey(item.statsNm, item.statsCd);
}
/** 헤더(statsNm) 텍스트 길이에 맞춘 컬럼 너비(px) */
function columnWidthForHeaderText(text: string, min = 88, max = 340): number {
return Math.min(max, Math.max(min, text.length * 9 + 28));
}
function buildColumnInfo(statDefinitions: StatDefinition[]) {
const fixedColumns: ColumnConfig[] = [
{
name: 'groupA',
hideChildHeaders: false,
header: { text: '미입력 하수도 통계 세부항목(번호)' },
items: ['1', '2-1', '2-2', '2-3', '2-4', '3-1', '3-2'],
name: "sido",
fieldName: SIDO_FIELD,
header: { text: "시도" },
mergeCells: true,
editable: false,
sortdisable: true,
width: 100,
},
{
name: "sgg",
fieldName: SGG_FIELD,
header: { text: "시군구" },
mergeGroupField: SIDO_FIELD,
editable: false,
sortdisable: true,
width: 120,
},
];
const statColumns: ColumnConfig[] = statDefinitions.map(({ columnKey, statsNm }) => ({
name: columnKey,
fieldName: columnKey,
header: { text: statsNm },
editable: false,
sortdisable: true,
width: columnWidthForHeaderText(statsNm),
}));
const columns = [...fixedColumns, ...statColumns];
const statKeys = statDefinitions.map((s) => s.columnKey);
const columnLayout: ColumnLayoutItem[] = [
"sido",
"sgg",
{
name: "statsGroup",
hideChildHeaders: false,
header: { text: "미입력 하수도 통계 세부항목(번호)" },
items: statKeys,
},
];
return { columns, columnLayout, statKeys };
}
function buildRowsFromStatusList(
statusList: UnSubmitDataStatusItem[],
statKeys: string[],
): Record<string, string | number>[] {
const rowMap = new Map<string, Record<string, string | number>>();
for (const item of statusList) {
const { sido, sgg } = splitSidoSggNm(item.sidoSggNm);
const rowKey = `${sido}\u0001${sgg}`;
const columnKey = resolveColumnKey(item);
if (!rowMap.has(rowKey)) {
const row: Record<string, string | number> = {
[SIDO_FIELD]: sido,
[SGG_FIELD]: sgg,
};
for (const key of statKeys) {
row[key] = "";
}
rowMap.set(rowKey, row);
}
const row = rowMap.get(rowKey)!;
if (statKeys.includes(columnKey)) {
row[columnKey] = sbmsnYnToOx(item.sbmsnYn);
}
}
return Array.from(rowMap.values()).sort((a, b) => {
const sidoCmp = String(a[SIDO_FIELD]).localeCompare(String(b[SIDO_FIELD]), "ko");
if (sidoCmp !== 0) return sidoCmp;
return String(a[SGG_FIELD]).localeCompare(String(b[SGG_FIELD]), "ko");
});
}
function buildUnSubmitDataRequest(
values: SearchFormValues,
userInfo: { dtlInstCd?: string; enoCd?: string; laeSeCd?: string } | null,
): unSubmitDataRequest {
const sewerBzmnCd = values.bsnmCd?.trim();
return {
statsYr: values.year ?? "",
dtlInstCd: userInfo?.dtlInstCd ?? "",
enoCd: userInfo?.enoCd ?? "",
laeSeCd: userInfo?.laeSeCd ?? "",
...(sewerBzmnCd ? { sewerBzmnCd } : {}),
sido: !values.sido || values.sido === "ALL" ? "" : values.sido,
sggNm: !values.gugun || values.gugun === "ALL" ? "" : values.gugun,
unSubmitOnly: false,
};
}
const COLUMN_INFO = buildColumnInfo(FIXED_STAT_DEFINITIONS);
const NotEnteredStatus = () => {
const { setMessage: alert } = useAlertStore((state) => state);
const userInfo = useUserStore((state) => state.userInfo);
const [isLoading, setIsLoading] = useState(false);
const [rowData, setRowData] = useState<Record<string, string | number>[]>([]);
const gridColumnInfo = useMemo(
() => ({
columns: COLUMN_INFO.columns,
columnLayout: COLUMN_INFO.columnLayout,
}),
[],
);
const handleSearch = async (values: SearchFormValues) => {
if (!values.year) {
alert("년도를 선택해주세요.");
return;
}
const params = buildUnSubmitDataRequest(values, userInfo);
console.log(params);
if (!params.dtlInstCd) {
alert("기관 정보를 확인할 수 없습니다.");
return;
}
try {
setIsLoading(true);
const response = await statisticsApi.getUnSubmitData(params);
if (response.status !== "success" || !response.data) {
alert(response.message ?? "조회에 실패했습니다.");
setRowData([]);
return;
}
const statusList = response.data.statusList ?? [];
setRowData(toGridRows(buildRowsFromStatusList(statusList, STAT_COLUMN_KEYS)));
} catch {
alert("데이터 조회 중 오류가 발생했습니다.");
setRowData([]);
} finally {
setIsLoading(false);
}
};
const handleReset = () => {
setRowData([]);
};
return (
<>
<SearchForm />
<DataGrid columnInfo={{ columns, columnLayout }} rowData={rowData} setRowData={setRowData} isExcelImport={false} fixedColumns={2} rowStartNum={2} />
</>
<div className="relative">
<SearchForm
onSubmit={handleSearch}
onReset={handleReset}
sidoSelectText="전체"
/>
{isLoading && (
<div className="absolute inset-0 z-50 flex items-center justify-center rounded-lg p-10 [data-theme='black']:bg-transparent">
<div className="relative size-[5rem]">
<LoadingWithAnimation color="primary" />
</div>
</div>
)}
<DataGrid
columnInfo={gridColumnInfo}
rowData={rowData}
setRowData={setRowData}
isExcelImport={false}
fixedColumns={2}
rowStartNum={2}
fitStyle={GridFitStyle.NONE}
enableSort={false}
/>
</div>
);
};
export default NotEnteredStatus;
export default NotEnteredStatus;
@@ -120,7 +120,7 @@ const PublicSewageFacilityRvw = () => {
const rawList = response.data.publicSewageList;
const convertedData: PublicSewageFacilityReviewItem[] = rawList.map((item) => ({
...item,
impdFcltPstnCn: item.fcltPstnCn ?? "",
fcltPstnCn: item.fcltPstnCn ?? "",
prxyBzentyNmCn: item.prxyBzentyNmCn ?? "",
prxyCtrtCn: item.prxyCtrtCn ?? "",
intgPrcsFcltCn: item.intgPrcsFcltCn ?? "",
@@ -73,7 +73,7 @@ interface VerificationFormState {
percentile: string;
a: string;
c: string;
verificationYear: string;
statisticsYear: string;
}
interface OutlierDialogState {
@@ -118,7 +118,7 @@ export default function HbStatisticsVerification() {
percentile: "",
a: "",
c: "",
verificationYear: defaultYear,
statisticsYear: defaultYear,
});
const [selectedTargets, setSelectedTargets] = useState<string[]>([]);
const [outlierDialog, setOutlierDialog] = useState<OutlierDialogState>({
@@ -298,8 +298,8 @@ export default function HbStatisticsVerification() {
}
async function handleSubmit() {
if (!formState.verificationYear) {
alert("검증년도를 선택해 주세요.");
if (!formState.statisticsYear) {
alert("통계년도를 선택해 주세요.");
return;
}
@@ -308,7 +308,7 @@ export default function HbStatisticsVerification() {
return;
}
const yearNew = toNumber(formState.verificationYear);
const yearNew = toNumber(formState.statisticsYear);
const payload: AnalysisRequestPayload = {
dataset:
verifyType === "ALL"
@@ -337,7 +337,7 @@ export default function HbStatisticsVerification() {
percentile: "0",
a: "0",
c: "0",
verificationYear: defaultYear,
statisticsYear: defaultYear,
});
setSelectedTargets([]);
}
@@ -385,13 +385,13 @@ export default function HbStatisticsVerification() {
<div className="flex items-center gap-6">
<div className="flex items-center gap-4 pl-2">
<Label className="text-[1.3rem] font-medium text-label">
검증 년도
통계년도
</Label>
<Select
value={formState.verificationYear}
value={formState.statisticsYear}
onValueChange={(value) =>
handleFormChange(
"verificationYear",
"statisticsYear",
value,
)
}
@@ -86,10 +86,27 @@ function getImrDetailGridColumnInfo(statsCd: string): ColumnInfo {
};
}
function getVerificationYearOptions(): string[] {
const currentYear = dayjs().year();
const startYear = 2017;
const endYear = currentYear - 1;
if (endYear < startYear) return [];
return Array.from({ length: endYear - startYear + 1 }, (_, index) =>
String(startYear + index),
);
}
const ImrVerificationHistory = () => {
const yearOptions = useMemo(() => getVerificationYearOptions(), []);
const defaultYear: string =
yearOptions.length > 0
? (yearOptions[yearOptions.length - 1] ?? "")
: "";
// Area A Search States
const [searchSido, setSearchSido] = useState<string>("");
const [searchSigungu, setSearchSigungu] = useState<string>("");
const [searchStatsYr, setSearchStatsYr] = useState<string>(defaultYear);
const [searchStatsCd, setSearchStatsCd] = useState<string>("");
const [dateRange, setDateRange] = useState<DateRangeValue | undefined>({
from: dayjs().subtract(1, "month").toDate(),
@@ -101,6 +118,7 @@ const ImrVerificationHistory = () => {
size: 10,
vrfcMthdCd: "IMR",
sort: "anlsSn,desc",
statsYr: defaultYear,
});
// Selected States
@@ -226,6 +244,7 @@ const ImrVerificationHistory = () => {
if (searchSido !== "")
params.sewerBzmnCd =
searchSigungu !== "" ? searchSigungu : searchSido;
if (searchStatsYr) params.statsYr = searchStatsYr;
if (searchStatsCd) params.statsCd = searchStatsCd;
if (dateRange?.from)
params.verifyDtStart = dayjs(dateRange.from).format("YYYY-MM-DD");
@@ -368,7 +387,7 @@ const ImrVerificationHistory = () => {
return (
<div className="flex flex-col gap-4">
{/* 영역 A: 검증 이력 그리드 + 검색 필터 */}
<div className="card card-border bg-white p-6">
<div className="card card-border bg-white px-4">
<div className="flex flex-wrap items-end gap-6 p-2">
<div className="flex items-center gap-2">
<Label className="text-[1.3rem] font-medium text-label">
@@ -422,6 +441,27 @@ const ImrVerificationHistory = () => {
</Select>
</div>
<div className="flex items-center gap-2">
<Label className="text-[1.3rem] font-medium text-label">
통계년도
</Label>
<Select
value={searchStatsYr}
onValueChange={setSearchStatsYr}
>
<SelectTrigger className="w-[10rem] flex-1">
<SelectValue />
</SelectTrigger>
<SelectContent>
{yearOptions.map((year) => (
<SelectItem key={year} value={year}>
{year}년
</SelectItem>
))}
</SelectContent>
</Select>
</div>
<div className="flex items-center gap-2">
<Label className="text-[1.3rem] font-medium text-label">
검증일시
@@ -4,8 +4,6 @@ import { useEffect, useState } from "react";
import { Controller, useForm } from "react-hook-form";
import { zodResolver } from "@hookform/resolvers/zod";
import { Search } from "lucide-react";
import { Tooltip, TooltipContent, TooltipTrigger } from "@common/components";
import { CodeSelect } from "@/entities/code/ui/code-select";
import { verificationTargetManagementApi } from "@/entities/statistics/api/verification-target-management-service";
@@ -17,6 +15,14 @@ import {
type VerificationTargetManagementFormData,
verificationTargetManagementSchema,
} from "@/entities/statistics/model/verification-target-management-schema";
import {
Label,
RadioGroup,
RadioGroupItem,
Tooltip,
TooltipContent,
TooltipTrigger,
} from "@common/components";
import {
Button,
Dialog,
@@ -321,16 +327,27 @@ export function VerificationTargetDialog({
{tabValue !== "IMR" && (
<TableRow>
<TableHead className="bg-muted font-medium">
<Tooltip>
<TooltipTrigger asChild>
<span className="inline-flex cursor-help items-center gap-1">
중요도 계수(μ)
<i className="diveicon di-info-circle text-muted-foreground" />
</span>
<span className="inline-flex cursor-help items-center gap-1">
중요도 계수(μ)
<i className="diveicon di-info-circle text-muted-foreground" />
</span>
</TooltipTrigger>
<TooltipContent>
<p>통계청(Statistics Canada) 등에서 기업 단위의 주기적 조사 데이터(수익, 매출, 생산량 등)에서 이상치(Outlier)를 탐지할 때, 데이터의 크기(규모)에 따라 이상치 판별 기준을 다르게 적용하기 위해 도입된 가중치 요인</p>
<p>
통계청(Statistics
Canada) 등에서 기업
단위의 주기적 조사
데이터(수익, 매출,
생산량 등)에서
이상치(Outlier)를 탐지할
때, 데이터의
크기(규모)에 따라 이상치
판별 기준을 다르게
적용하기 위해 도입된
가중치 요인
</p>
</TooltipContent>
</Tooltip>
</TableHead>
@@ -370,13 +387,19 @@ export function VerificationTargetDialog({
<TableHead className="bg-muted font-medium">
<Tooltip>
<TooltipTrigger asChild>
<span className="inline-flex cursor-help items-center gap-1">
백분위수 기준값(pct)
<i className="diveicon di-info-circle text-muted-foreground" />
</span>
<span className="inline-flex cursor-help items-center gap-1">
백분위수 기준값(pct)
<i className="diveicon di-info-circle text-muted-foreground" />
</span>
</TooltipTrigger>
<TooltipContent>
<p>데이터가 정규분포를 따르지 않아도, 데이터의 하위/상위 몇 % 위치를 기준으로 이상치 범위를 정할지 결정</p>
<p>
데이터가 정규분포를
따르지 않아도, 데이터의
하위/상위 몇 % 위치를
기준으로 이상치 범위를
정할지 결정
</p>
</TooltipContent>
</Tooltip>
</TableHead>
@@ -419,16 +442,23 @@ export function VerificationTargetDialog({
{tabValue !== "IMR" && (
<TableRow>
<TableHead className="bg-muted font-medium">
<Tooltip>
<TooltipTrigger asChild>
<span className="inline-flex cursor-help items-center gap-1">
스케일 조정 계수(A)
<i className="diveicon di-info-circle text-muted-foreground" />
</span>
<span className="inline-flex cursor-help items-center gap-1">
스케일 조정 계수(A)
<i className="diveicon di-info-circle text-muted-foreground" />
</span>
</TooltipTrigger>
<TooltipContent>
<p>데이터의 크기가 다른 경우, 특히 규모가 작은 업체가 이상치로 잘못 탐지되는 것을 막기 위해 변환된 비율(Symmetric Ratios)에 규모(size)의 영향을 보정하는 역할</p>
<p>
데이터의 크기가 다른
경우, 특히 규모가 작은
업체가 이상치로 잘못
탐지되는 것을 막기 위해
변환된 비율(Symmetric
Ratios)에 규모(size)의
영향을 보정하는 역할
</p>
</TooltipContent>
</Tooltip>
</TableHead>
@@ -465,16 +495,22 @@ export function VerificationTargetDialog({
)}
/>
</TableCell>
<TableHead className="bg-muted font-medium" >
<TableHead className="bg-muted font-medium">
<Tooltip>
<TooltipTrigger asChild>
<span className="inline-flex cursor-help items-center gap-1">
임계 계수(c)
<i className="diveicon di-info-circle text-muted-foreground" />
</span>
<span className="inline-flex cursor-help items-center gap-1">
임계 계수(c)
<i className="diveicon di-info-circle text-muted-foreground" />
</span>
</TooltipTrigger>
<TooltipContent>
<p>데이터의 분포가 중앙값(median)을 중심으로 얼마나 흩어져 있는지를 바탕으로 이상치 범위를 결정</p>
<p>
데이터의 분포가
중앙값(median)을
중심으로 얼마나 흩어져
있는지를 바탕으로 이상치
범위를 결정
</p>
</TooltipContent>
</Tooltip>
</TableHead>
@@ -513,6 +549,48 @@ export function VerificationTargetDialog({
</TableCell>
</TableRow>
)}
<TableRow>
<TableHead className="bg-muted font-medium">
사용 여부
</TableHead>
<TableCell colSpan={3}>
<Controller
name="useYn"
control={control}
render={({ field }) => (
<div className="space-y-1">
<RadioGroup
className="flex items-center"
{...field}
onValueChange={
field.onChange
}
>
<div className="flex items-center gap-1">
<RadioGroupItem
value="Y"
id="useY"
/>
<Label htmlFor="useY">
예
</Label>
</div>
<div className="flex items-center gap-1">
<RadioGroupItem
value="N"
id="useN"
/>
<Label htmlFor="useN">
아니오
</Label>
</div>
</RadioGroup>
</div>
)}
/>
</TableCell>
</TableRow>
</TableBody>
</Table>
@@ -612,4 +690,3 @@ export function VerificationTargetDialog({
</>
);
}
@@ -147,7 +147,7 @@ const HbVerificationDetail: React.FC<HbVerificationDetailProps> = ({
<h3 className="p-4 pb-0 text-[1.8rem] font-bold text-title">
검증 결과 상세
</h3>
<div className="flex">
<div className="grid grid-cols-1 lg:grid-cols-2">
<div className="flex-1 p-4">
<RegionalGrid
anlsSn={anlsSn}
@@ -104,12 +104,10 @@ const ScatterChart: React.FC<ScatterChartProps> = ({
anlsSn,
vrfcTrgtSn,
);
console.log("resultData: ", resultData);
const { data: chartData, isLoading: isLoadingChart } = useStAnlsChartData(
anlsSn,
vrfcTrgtSn,
);
console.log("chartData: ", chartData);
const isLoading = isLoadingGrid || isLoadingChart;
@@ -303,7 +301,7 @@ const ScatterChart: React.FC<ScatterChartProps> = ({
</div>
</div>
) : chartConfig ? (
<div className="size-[52rem]">
<div className="size-[40rem]">
<Scatter
data={chartConfig.data}
options={chartConfig.options}
@@ -86,7 +86,7 @@ const VerificationList: React.FC<VerificationListProps> = ({
variant="secondary"
onRowClick={handleRowClick}
viewPagination={false}
className="whitespace-nowrap text-[1.2rem] [&_td]:!px-2 [&_th]:!px-2"
className="text-[1.2rem] [&_td]:!px-2 [&_th]:!px-2"
/>
)}
</div>
+27 -1
View File
@@ -1,6 +1,6 @@
import React from "react";
import { ValueType } from "realgrid";
import { GridFitStyle, ValueType } from "realgrid";
import type { RealGridReact } from "realgrid-react";
// RealGrid 타입 정의
@@ -162,6 +162,8 @@ export type ColumnConfig = {
editable?: boolean; // 셀 편집 가능 여부
/** 그리드에서 컬럼 숨김 (수식용 필드 등) */
visible?: boolean;
/** true이면 엑셀보내기에서 제외 (화면에는 표시 가능) */
excludeFromExcelExport?: boolean;
/** 컬럼 너비 (px). 지정 시 ... 생략 없이 텍스트 표시에 유리 */
width?: number;
/** 셀에 적용할 CSS 클래스명 (예: 'rg-cell-readonly', 'rg-cell-total') */
@@ -173,6 +175,26 @@ export type ColumnConfig = {
sortPreserveGroupOrder?: SortPreserveGroupOrder;
valueType?: ValueType;
dataFormat?: string;
/** RealGrid 드롭다운 목록 값 (예: 시군구 `gugun`) */
values?: string[];
/** RealGrid 드롭다운 표시 라벨 (`values`와 동일 길이 권장) */
labels?: string[];
/** true이면 셀에 라벨 표시 */
lookupDisplay?: boolean;
/** RealGrid 날짜 표시 형식 (예: `yyyy-MM-dd`) */
datetimeFormat?: string;
/** RealGrid 셀 편집기 */
editor?: {
type?: "dropdown" | "list" | "date";
domainOnly?: boolean;
textReadOnly?: boolean;
commitOnSelect?: boolean;
dropDownWhenClick?: boolean;
showToday?: boolean;
datetimeFormat?: string;
values?: string[];
labels?: string[];
};
footer?:
| {
text?: string;
@@ -335,6 +357,10 @@ export type DataGridProps = {
/** CompactDataGrid 전용: 그리드 영역 높이 Tailwind 클래스 */
compactGridHeightClass?: string;
gridHeight?: number;
/** RealGrid `gridView.header.height` (기본 60, columnLayout 2단 헤더 등에서 조절) */
headerHeight?: number;
/** RealGrid `displayOptions.fitStyle` (기본 EVEN). 컬럼별 width 유지 시 NONE */
fitStyle?: GridFitStyle;
};
export type TabDataGridProps = {
@@ -15,7 +15,7 @@ export const PublicSewageFactColumn = {
{ name: 'swtrmtFcltNm', fieldName: 'swtrmtFcltNm', header: { text: '시설명(500m³/일 이상/미만)' }, editable: false, styleName: 'rg-cell-readonly' },
// 소재지
{ name: 'impdFcltPstnCn', fieldName: 'impdFcltPstnCn', header: { text: '소재지' }, editable: false, styleName: 'rg-cell-readonly' },
{ name: 'fcltPstnCn', fieldName: 'fcltPstnCn', header: { text: '소재지' }, editable: false, styleName: 'rg-cell-readonly' },
// 시설용량
{ name: 'prcsFcltCpct', fieldName: 'prcsFcltCpct', header: { text: '시설용량(500m³/일 이상/미만)' }, editable: false, footer: footerDecimal1, valueType: ValueType.NUMBER, dataFormat: '#,##0.0', styleName: 'rg-cell-total' },
@@ -126,7 +126,7 @@ export const PublicSewageFactColumn = {
items: ['ctpvNm', 'sggNm'],
},
'swtrmtFcltNm',
'impdFcltPstnCn',
'fcltPstnCn',
'prcsFcltCpct',
{
name: 'facilityCapacityDetail',
@@ -4,7 +4,7 @@ import { ValueType } from "realgrid";
// 하수도 통계 관리 > 02.하수관로 > tab 5. 맨홀 현황
// SeweragePipeInputManholeStatusItem + commonInfo.ctpvNm (그리드 행에 병합)
// API: yy14=2014년 … yy{(현재연도-1)%100} (필드 접두 yy + 연도 끝자리)
// API: yy14=2015년 이전(2014년 데이터) … yy{(현재연도-1)%100} (필드 접두 yy + 연도 끝자리)
const MANHOLE_START_YEAR = 2014;
const MANHOLE_LAST_DATA_YEAR = Math.max(MANHOLE_START_YEAR, new Date().getFullYear() - 1);
@@ -12,6 +12,12 @@ const MANHOLE_YY_MIN = MANHOLE_START_YEAR % 100;
const MANHOLE_YY_MAX = MANHOLE_LAST_DATA_YEAR % 100;
const calendarYearFromYy = (yy: number) => 2000 + yy;
/** yy14(2014) 구간은 헤더에 「2015년 이전」 표기 (합계·검토 컬럼과 동일) */
const manholeYearHeaderText = (yy: number): string => {
const y = calendarYearFromYy(yy);
return y === MANHOLE_START_YEAR ? "2015년 이전" : `${y}년`;
};
const intCol = (name: string, headerText: string): ColumnConfig => ({
name,
fieldName: name,
@@ -98,7 +104,7 @@ function yyYearLayout(yy: number): ColumnLayoutItem {
return {
name: `year${y}`,
hideChildHeaders: false,
header: { text: `${y}년` },
header: { text: manholeYearHeaderText(yy) },
items: [
{
name: `year${y}Material`,
@@ -211,8 +211,9 @@ export const PipeTypeStatusInpColumn = {
],
},
...yearLayoutItems,
"rmrk",
'rmrk',
'admdstCd',
'pipeSeCd'
] as ColumnLayoutItem[],
rowStartNum: 2,
fixedColumns: 2,
@@ -1,26 +1,38 @@
import { ValueType } from "realgrid";
import { footerDecimal1, footerDecimal2,footerInt } from "@/components/util/grid/footerSummary";
import { footerDecimal1, footerDecimal2, footerInt } from "@/components/util/grid/footerSummary";
// 하수도 통계 관리 > 03.하수관로 유지관리 > tab 2. 관로준설
export const PipeDredgingStatusInpColumn = {
columns: [
// 행정구역
{ name: 'admdstCd', fieldName: 'admdstCd', header: { text: '행정구역코드' }, editable: false },
{ name: 'sggNm', fieldName: 'sggNm', header: { text: '시군구명' }, editable: false, footer: { text: '합계' } },
{ name: 'admdstNm', fieldName: 'admdstNm', header: { text: '행정구역명' }, editable: false },
{ name: 'ctpvNm', fieldName: 'ctpvNm', header: { text: '시도' }, footer: { text: '합계' }, editable: false, styleName: 'rg-cell-readonly' },
{ name: 'sggNm', fieldName: 'sggNm', header: { text: '시군구명' }, editable: false, footer: { text: '합계' }, styleName: 'rg-cell-readonly' },
// 하수관로 총 시설연장
{ name: 'wholFcltPrlg', fieldName: 'wholFcltPrlg', header: { text: '하수관로총시설연장(m)' }, editable: false, footer: footerDecimal2, valueType: ValueType.NUMBER, dataFormat: '#,##0.00' },
{ name: 'wholFcltPrlg', fieldName: 'wholFcltPrlg', header: { text: '하수관로총시설연장(m)' }, editable: false, footer: footerDecimal2, valueType: ValueType.NUMBER, dataFormat: '#,##0.00', styleName: 'rg-cell-readonly' },
// 준설연장 및 개소수 > 관로(m)
{ name: 'wholSwagePpl', fieldName: 'wholSwagePpl', header: { text: '합계' }, editable: false, footer: footerDecimal1, valueType: ValueType.NUMBER, dataFormat: '#,##0.0' },
{
name: 'wholSwagePpl',
fieldName: 'wholSwagePpl',
header: { text: '합계' },
editable: false,
footer: footerDecimal1,
valueType: ValueType.NUMBER,
dataFormat: '#,##0.0',
styleName: 'rg-cell-total',
computed: {
fields: ['cbsysPplPrlg', 'sprsysExlnPrlg', 'sprsysDrtwPrlg'],
type: 'sum' as const,
},
},
{ name: 'cbsysPplPrlg', fieldName: 'cbsysPplPrlg', header: { text: '합류식' }, footer: footerDecimal1, valueType: ValueType.NUMBER, dataFormat: '#,##0.0' },
// 준설연장 및 개소수 > 관로(m) > 분류식
{ name: 'sprsysExlnPrlg', fieldName: 'sprsysExlnPrlg', header: { text: '우수' }, footer: footerDecimal1, valueType: ValueType.NUMBER, dataFormat: '#,##0.0' },
{ name: 'sprsysDrtwPrlg', fieldName: 'sprsysDrtwPrlg', header: { text: '오수' }, footer: footerDecimal1, valueType: ValueType.NUMBER, dataFormat: '#,##0.0' },
// 준설연장 및 개소수 > 맨홀, 오·우수받이, 토실·토구
{ name: 'mnhlCnt', fieldName: 'mnhlCnt', header: { text: '맨홀(개소)' }, footer: footerInt, valueType: ValueType.NUMBER, dataFormat: '#,##0' },
{ name: 'exlnDrtwRcvprpCnt', fieldName: 'exlnDrtwRcvprpCnt', header: { text: '오·우수받이(개소)' }, footer: footerInt, valueType: ValueType.NUMBER, dataFormat: '#,##0' },
@@ -28,8 +40,36 @@ export const PipeDredgingStatusInpColumn = {
// 계획 & 비율
{ name: 'drdgnPlanPrlg', fieldName: 'drdgnPlanPrlg', header: { text: '준설 계획 연장(m)' }, footer: footerDecimal1, valueType: ValueType.NUMBER, dataFormat: '#,##0.0' },
{ name: 'drdgnTrgtCtrsDrdgnRt', fieldName: 'drdgnTrgtCtrsDrdgnRt', header: { text: '계획대비 준설율(%)' }, editable: false, footer: footerDecimal1, valueType: ValueType.NUMBER, dataFormat: '#,##0.0' },
{ name: 'wholPrlgCtrsDrdgnRt', fieldName: 'wholPrlgCtrsDrdgnRt', header: { text: '총시설연장대비 준설율(%)' }, editable: false, footer: footerDecimal1, valueType: ValueType.NUMBER, dataFormat: '#,##0.0' },
{
name: 'drdgnTrgtCtrsDrdgnRt',
fieldName: 'drdgnTrgtCtrsDrdgnRt',
header: { text: '계획대비 준설율(%)' },
editable: false,
footer: footerDecimal1,
valueType: ValueType.NUMBER,
dataFormat: '#,##0.0',
styleName: 'rg-cell-total',
computed: {
numeratorFields: ['cbsysPplPrlg', 'sprsysExlnPrlg', 'sprsysDrtwPrlg'],
denominatorFields: ['drdgnPlanPrlg'],
type: 'pblswrgPopltnSuprtRate' as const,
},
},
{
name: 'wholPrlgCtrsDrdgnRt',
fieldName: 'wholPrlgCtrsDrdgnRt',
header: { text: '총시설연장대비 준설율(%)' },
editable: false,
footer: footerDecimal1,
valueType: ValueType.NUMBER,
dataFormat: '#,##0.0',
styleName: 'rg-cell-total',
computed: {
numeratorFields: ['drdgnPlanPrlg'],
denominatorFields: ['wholFcltPrlg'],
type: 'pblswrgPopltnSuprtRate' as const,
},
},
// 준설실적
{ name: 'drdgnAmt', fieldName: 'drdgnAmt', header: { text: '금액(백만원)' }, footer: footerInt, valueType: ValueType.NUMBER, dataFormat: '#,##0' },
@@ -37,17 +77,38 @@ export const PipeDredgingStatusInpColumn = {
// 준설토사처리
{ name: 'drdgnUntprcAmt', fieldName: 'drdgnUntprcAmt', header: { text: '준설토처리비(백만원)' }, footer: footerInt, valueType: ValueType.NUMBER, dataFormat: '#,##0' },
{ name: 'wholDrdgnPrcsQnt', fieldName: 'wholDrdgnPrcsQnt', header: { text: '처리량 계(톤)' }, editable: false, footer: footerDecimal1, valueType: ValueType.NUMBER, dataFormat: '#,##0.0' },
{
name: 'wholDrdgnPrcsQnt',
fieldName: 'wholDrdgnPrcsQnt',
header: { text: '처리량 계(톤)' },
editable: false,
footer: footerDecimal1,
valueType: ValueType.NUMBER,
dataFormat: '#,##0.0',
styleName: 'rg-cell-total',
computed: {
fields: ['rclmQnt', 'oceanEmsnQnt', 'rcycQnt', 'etcQnt'],
type: 'sum' as const,
},
},
{ name: 'rclmQnt', fieldName: 'rclmQnt', header: { text: '매립' }, footer: footerDecimal1, valueType: ValueType.NUMBER, dataFormat: '#,##0.0' },
{ name: 'oceanEmsnQnt', fieldName: 'oceanEmsnQnt', header: { text: '해양투기' }, footer: footerDecimal1, valueType: ValueType.NUMBER, dataFormat: '#,##0.0' },
{ name: 'rcycQnt', fieldName: 'rcycQnt', header: { text: '재활용' }, footer: footerDecimal1, valueType: ValueType.NUMBER, dataFormat: '#,##0.0' },
{ name: 'etcQnt', fieldName: 'etcQnt', header: { text: '기타(소각, 고형화, 시멘트원료 등)' }, footer: footerDecimal1, valueType: ValueType.NUMBER, dataFormat: '#,##0.0' },
// 비고
{ name: 'rmrk', fieldName: 'rmrk', header: { text: '비고' } },
{ name: 'admdstCd', fieldName: 'admdstCd', header: { text: '행정구역코드' }, editable: false, visible: false },
{ name: 'admdstNm', fieldName: 'admdstNm', header: { text: '행정구역명' }, editable: false, visible: false },
],
columnLayout: [
{ name: 'group_admin', header: { text: '행정구역' }, items: ['admdstCd', 'sggNm', 'admdstNm'] },
{
name: 'group_admin', header: { text: '행정구역' },
items: [
{ column: 'ctpvNm', footerUserSpans: [{ colspan: 2 }] },
'sggNm',
],
},
'wholFcltPrlg',
{
name: 'group_dredge_info',
@@ -75,7 +136,9 @@ export const PipeDredgingStatusInpColumn = {
header: { text: '준설토사처리' },
items: ['drdgnUntprcAmt', 'wholDrdgnPrcsQnt', 'rclmQnt', 'oceanEmsnQnt', 'rcycQnt', 'etcQnt']
},
'rmrk'
'rmrk',
'admdstCd',
'admdstNm',
],
rowStartNum: 4,
fixedColumns: 1,
@@ -5,12 +5,24 @@ import { ValueType } from "realgrid";
export const RepairStatusInpColumn = {
columns: [
// 행정구역
{ name: 'admdstCd', fieldName: 'admdstCd', header: { text: '행정구역코드' }, editable: false },
{ name: 'sggNm', fieldName: 'sggNm', header: { text: '시군구명' }, editable: false, footer: { text: '합계' } },
{ name: 'admdstNm', fieldName: 'admdstNm', header: { text: '행정구역명' }, editable: false },
{ name: 'ctpvNm', fieldName: 'ctpvNm', header: { text: '시도' }, footer: { text: '합계' }, editable: false, styleName: 'rg-cell-readonly' },
{ name: 'sggNm', fieldName: 'sggNm', header: { text: '시군구명' }, editable: false, footer: { text: '합계' }, styleName: 'rg-cell-readonly' },
// 개·보수관로 > 전체보수(m)
{ name: 'mendngWhol', fieldName: 'mendngWhol', header: { text: '소계' }, editable: false, footer: footerDecimal1, valueType: ValueType.NUMBER, dataFormat: '#,##0.0' },
{
name: 'mendngWhol',
fieldName: 'mendngWhol',
header: { text: '소계' },
editable: false,
footer: footerDecimal1,
valueType: ValueType.NUMBER,
dataFormat: '#,##0.0',
styleName: 'rg-cell-total',
computed: {
fields: ['cbsysMendngPrlg', 'sprsysExlnPrlg', 'sprsysDrtwPrlg'],
type: 'sum' as const,
},
},
{ name: 'cbsysMendngPrlg', fieldName: 'cbsysMendngPrlg', header: { text: '합류식' }, footer: footerDecimal1, valueType: ValueType.NUMBER, dataFormat: '#,##0.0' },
// 개·보수관로 > 전체보수(m) > 분류식
@@ -18,7 +30,20 @@ export const RepairStatusInpColumn = {
{ name: 'sprsysDrtwPrlg', fieldName: 'sprsysDrtwPrlg', header: { text: '오수' }, footer: footerDecimal1, valueType: ValueType.NUMBER, dataFormat: '#,##0.0' },
// 개·보수관로 > 부분보수(개소)
{ name: 'partMendngWhol', fieldName: 'partMendngWhol', header: { text: '소계' }, editable: false, footer: footerInt, valueType: ValueType.NUMBER, dataFormat: '#,##0' },
{
name: 'partMendngWhol',
fieldName: 'partMendngWhol',
header: { text: '소계' },
editable: false,
footer: footerInt,
valueType: ValueType.NUMBER,
dataFormat: '#,##0',
styleName: 'rg-cell-total',
computed: {
fields: ['cbsysPartMendngPrlg', 'sprsysExlnMendngPrlg', 'sprsysDrtwMendngPrlg'],
type: 'sum' as const,
},
},
{ name: 'cbsysPartMendngPrlg', fieldName: 'cbsysPartMendngPrlg', header: { text: '합류식' }, footer: footerInt, valueType: ValueType.NUMBER, dataFormat: '#,##0' },
// 개·보수관로 > 부분보수(개소) > 분류식
@@ -45,12 +70,17 @@ export const RepairStatusInpColumn = {
// 비고
{ name: 'rmrk', fieldName: 'rmrk', header: { text: '비고' } },
{ name: 'admdstCd', fieldName: 'admdstCd', header: { text: '행정구역코드' }, editable: false, visible: false },
{ name: 'admdstNm', fieldName: 'admdstNm', header: { text: '행정구역명' }, editable: false, visible: false },
],
columnLayout: [
{
name: 'group_admin',
header: { text: '행정구역' },
items: ['admdstCd', 'sggNm', 'admdstNm']
items: [
{ column: 'ctpvNm', footerUserSpans: [{ colspan: 2 }] },
'sggNm',
],
},
{
name: 'group_repair_main',
@@ -107,7 +137,9 @@ export const RepairStatusInpColumn = {
items: ['stofcbrCnt', 'otflCnt']
},
'cctvExmnPrlg',
'rmrk'
'rmrk',
'admdstCd',
'admdstNm',
],
rowStartNum: 4,
fixedColumns: 1,
@@ -4,90 +4,85 @@ import { ValueType } from "realgrid";
// 하수도 통계 관리 > 04.하수저류시설 및 빗물펌프장 > tab 2: 빗물펌프장
export const RainfallPumpStationInpColumn = {
columns: [
// Hidden Columns : 시스템 관리를 위한 값
{ name: 'sidoCd', fieldName: '시도코드', header: { text: '시도코드' }, hidden: true },
{ name: 'adstDstrCd', fieldName: '행정구역코드', header: { text: '행정구역코드' }, hidden: true },
{ name: 'adstCd', fieldName: '행정코드', header: { text: '행정코드' }, hidden: true },
{ name: 'swgeStrgeFctSeq', fieldName: '시설SEQ', header: { text: '시설SEQ' }, hidden: true },
{ name: 'yr', fieldName: '년도', header: { text: '년도' }, hidden: true },
{ name: 'odr', fieldName: '차수', header: { text: '차수' }, hidden: true },
{ name: 'bsnmCd', fieldName: '사업자코드', header: { text: '사업자코드' }, hidden: true },
{ name: 'ctpvNm', fieldName: 'ctpvNm', header: { text: '시도' }, footer: { text: '합계' }, editable: false, styleName: 'rg-cell-readonly' },
{ name: 'sggNm', fieldName: 'sggNm', header: { text: '시군구' } },
// Visible Columns
// 행정구역
{ name: 'sido', fieldName: '시도', header: { text: '시도' }, editable: false },
{ name: 'gugun', fieldName: '시군구', header: { text: '시군구' }, footer: { text: '합계' } },
{ name: 'fcltNm', fieldName: 'fcltNm', header: { text: '시설명' } },
{ name: 'fcltPstnCn', fieldName: 'fcltPstnCn', header: { text: '위치' } },
// 시설명
{ name: 'swgeStrgeNm', fieldName: '시설명', header: { text: '시설명' } },
{ name: 'fcltyKndCn', fieldName: 'fcltyKndCn', header: { text: '시설물종류' } },
// 위치
{ name: 'addr', fieldName: '위치', header: { text: '위치' } },
// 시설물종류
{ name: 'facRes', fieldName: '시설물종류', header: { text: '시설물종류' } },
// 가동개시 년월일 yyyy-mm-dd
{ name: 'rnDt', fieldName: '가동개시 년월일 yyyy-mm-dd', header: { text: '가동개시 년월일 yyyy-mm-dd' } },
// 계획설계빈도
{ name: 'plDsgnFrqnc', fieldName: '계획설계빈도', header: { text: '계획설계빈도' } },
{
name: 'oprtngStrtYmd',
fieldName: 'oprtngStrtYmd',
header: { text: '가동개시 년월일 yyyy-mm-dd' },
datetimeFormat: 'yyyy-MM-dd',
editor: {
type: 'date',
dropDownWhenClick: true,
datetimeFormat: 'yyyy-MM-dd',
},
},
{ name: 'planDesignFreqCnt', fieldName: 'planDesignFreqCnt', header: { text: '계획설계빈도' } },
// 집수구역면적(㎢)
{ name: 'dgrAr', fieldName: '집수구역면적(㎢)', header: { text: '집수구역면적(㎢)' }, footer: footerDecimal3, valueType: ValueType.NUMBER, dataFormat: '#,##0.000' },
{ name: 'wtrStrgZoneArea', fieldName: 'wtrStrgZoneArea', header: { text: '집수구역면적(㎢)' }, footer: footerDecimal3, valueType: ValueType.NUMBER, dataFormat: '#,##0.000' },
// 유수지
{ name: 'revFnnc', fieldName: '유수지유무(유,무)', header: { text: '유수지유무(유,무)' } },
{ name: 'revCpcty', fieldName: '저류랑(m³)', header: { text: '저류랑(m³)' }, valueType: ValueType.NUMBER, dataFormat: '#,##0.0' },
{ name: 'rsvrYn', fieldName: 'rsvrYn', header: { text: '유수지유무(유,무)' } },
{ name: 'rsvrImpndQnt', fieldName: 'rsvrImpndQnt', header: { text: '저류랑(m³)' }, valueType: ValueType.NUMBER, dataFormat: '#,##0.0' },
// 흡수정
{ name: 'absCpcty', fieldName: '저류랑(m³)', header: { text: '저류랑(m³)' }, valueType: ValueType.NUMBER, dataFormat: '#,##0.0' },
{ name: 'abstnRgnImpdQnt', fieldName: 'abstnRgnImpdQnt', header: { text: '저류랑(m³)' }, valueType: ValueType.NUMBER, dataFormat: '#,##0.0' },
// 펌프시설 > 총양수량(m³/min)
{ name: 'wpCpcty', fieldName: '총양수량(m³/min)', header: { text: '총양수량(m³/min)' }, editable: false, footer: footerDecimal1, valueType: ValueType.NUMBER, dataFormat: '#,##0.0' },
{ name: 'grssPpqty', fieldName: 'grssPpqty', header: { text: '총양수량(m³/min)' }, editable: false, footer: footerDecimal1, valueType: ValueType.NUMBER, dataFormat: '#,##0.0' },
// 펌프시설1
{ name: 'pumpCalbr', fieldName: '구경(mm)', header: { text: '구경(mm)' }, valueType: ValueType.NUMBER, dataFormat: '#,##0' },
{ name: 'pumpHpCo', fieldName: '대당 양수량(m³/min·대)', header: { text: '대당 양수량(m³/min·대)' }, footer: footerDecimal1, valueType: ValueType.NUMBER, dataFormat: '#,##0.0' },
{ name: 'pumpCo', fieldName: '펌프대수', header: { text: '펌프대수' }, footer: footerInt, valueType: ValueType.NUMBER, dataFormat: '#,##0' },
{ name: 'pumpPreparCo', fieldName: '예비펌프대수', header: { text: '예비펌프대수' }, footer: footerInt, valueType: ValueType.NUMBER, dataFormat: '#,##0' },
{ name: 'pumpFcltN1DmtrExpln', fieldName: 'pumpFcltN1DmtrExpln', header: { text: '구경(mm)' } },
{ name: 'pumpFcltN1PerPpqty', fieldName: 'pumpFcltN1PerPpqty', header: { text: '대당 양수량(m³/min·대)' }, footer: footerDecimal1, valueType: ValueType.NUMBER, dataFormat: '#,##0.0' },
{ name: 'pumpFcltN1PumpCnt', fieldName: 'pumpFcltN1PumpCnt', header: { text: '펌프대수' }, footer: footerInt, valueType: ValueType.NUMBER, dataFormat: '#,##0' },
{ name: 'pumpFcltN1RsrvPumpCnt', fieldName: 'pumpFcltN1RsrvPumpCnt', header: { text: '예비펌프대수' }, footer: footerInt, valueType: ValueType.NUMBER, dataFormat: '#,##0' },
// 펌프시설2
{ name: 'pumpCalbr2', fieldName: '구경(mm)', header: { text: '구경(mm)' }, valueType: ValueType.NUMBER, dataFormat: '#,##0' },
{ name: 'pumpHpCo2', fieldName: '대당 양수량(m³/min·대)', header: { text: '대당 양수량(m³/min·대)' }, footer: footerDecimal1, valueType: ValueType.NUMBER, dataFormat: '#,##0.0' },
{ name: 'pumpCo2', fieldName: '펌프대수', header: { text: '펌프대수' }, footer: footerInt, valueType: ValueType.NUMBER, dataFormat: '#,##0' },
{ name: 'pumpPreparCo2', fieldName: '예비펌프대수', header: { text: '예비펌프대수' }, footer: footerInt, valueType: ValueType.NUMBER, dataFormat: '#,##0' },
{ name: 'pumpFcltN2DmtrExpln', fieldName: 'pumpFcltN2DmtrExpln', header: { text: '구경(mm)' } },
{ name: 'pumpFcltN2PerPpqty', fieldName: 'pumpFcltN2PerPpqty', header: { text: '대당 양수량(m³/min·대)' }, footer: footerDecimal1, valueType: ValueType.NUMBER, dataFormat: '#,##0.0' },
{ name: 'pumpFcltN2PumpCnt', fieldName: 'pumpFcltN2PumpCnt', header: { text: '펌프대수' }, footer: footerInt, valueType: ValueType.NUMBER, dataFormat: '#,##0' },
{ name: 'pumpFcltN2RsrvPumpCnt', fieldName: 'pumpFcltN2RsrvPumpCnt', header: { text: '예비펌프대수' }, footer: footerInt, valueType: ValueType.NUMBER, dataFormat: '#,##0' },
// 펌프시설3
{ name: 'pumpCalbr3', fieldName: '구경(mm)', header: { text: '구경(mm)' }, valueType: ValueType.NUMBER, dataFormat: '#,##0' },
{ name: 'pumpHpCo3', fieldName: '대당 양수량(m³/min·대)', header: { text: '대당 양수량(m³/min·대)' }, footer: footerDecimal1, valueType: ValueType.NUMBER, dataFormat: '#,##0.0' },
{ name: 'pumpCo3', fieldName: '펌프대수', header: { text: '펌프대수' }, footer: footerInt, valueType: ValueType.NUMBER, dataFormat: '#,##0' },
{ name: 'pumpPreparCo3', fieldName: '예비펌프대수', header: { text: '예비펌프대수' }, footer: footerInt, valueType: ValueType.NUMBER, dataFormat: '#,##0' },
{ name: 'pumpFcltN3DmtrExpln', fieldName: 'pumpFcltN3DmtrExpln', header: { text: '구경(mm)' } },
{ name: 'pumpFcltN3PerPpqty', fieldName: 'pumpFcltN3PerPpqty', header: { text: '대당 양수량(m³/min·대)' }, footer: footerDecimal1, valueType: ValueType.NUMBER, dataFormat: '#,##0.0' },
{ name: 'pumpFcltN3PumpCnt', fieldName: 'pumpFcltN3PumpCnt', header: { text: '펌프대수' }, footer: footerInt, valueType: ValueType.NUMBER, dataFormat: '#,##0' },
{ name: 'pumpFcltN3RsrvPumpCnt', fieldName: 'pumpFcltN3RsrvPumpCnt', header: { text: '예비펌프대수' }, footer: footerInt, valueType: ValueType.NUMBER, dataFormat: '#,##0' },
// 부대시설
{ name: 'dorZeqpFnnc', fieldName: '전처리시설유무(유/무)', header: { text: '전처리시설유무(유/무)' } },
{ name: 'selfEtrqFnnc', fieldName: '탈취설비유무(유/무)', header: { text: '탈취설비유무(유/무)' } },
{ name: 'freFnnc', fieldName: '자가발전유무(유/무)', header: { text: '자가발전유무(유/무)' } },
{ name: 'prcsgFcltYn', fieldName: 'prcsgFcltYn', header: { text: '전처리시설유무(유/무)' } },
{ name: 'ddrztnEqpmYn', fieldName: 'ddrztnEqpmYn', header: { text: '탈취설비유무(유/무)' } },
{ name: 'oslfEtgtYn', fieldName: 'oslfEtgtYn', header: { text: '자가발전유무(유/무)' } },
// 방류수역
{ name: 'disWat', fieldName: '방류수역', header: { text: '방류수역' } },
{ name: 'dschgWtrsExpln', fieldName: 'dschgWtrsExpln', header: { text: '방류수역' } },
// 최종처분량(ton/년)
{ name: 'sdmtTreCpcty', fieldName: '침사', header: { text: '침사' }, footer: footerInt, valueType: ValueType.NUMBER, dataFormat: '#,##0' },
{ name: 'iprtTreCpcty', fieldName: '협잡물', header: { text: '협잡물' }, footer: footerInt, valueType: ValueType.NUMBER, dataFormat: '#,##0' },
{ name: 'prcptnLastDspsQnt', fieldName: 'prcptnLastDspsQnt', header: { text: '침사' }, footer: footerInt, valueType: ValueType.NUMBER, dataFormat: '#,##0' },
{ name: 'ctmnLastDspsQnt', fieldName: 'ctmnLastDspsQnt', header: { text: '협잡물' }, footer: footerInt, valueType: ValueType.NUMBER, dataFormat: '#,##0' },
// 최종처분방법
{ name: 'sdmtTreMth', fieldName: '침사', header: { text: '침사' } },
{ name: 'iprtTreMth', fieldName: '협잡물', header: { text: '협잡물' } },
{ name: 'prcptnLastDspsMthdExpln', fieldName: 'prcptnLastDspsMthdExpln', header: { text: '침사' } },
{ name: 'ctmnLastDspsMthdExpln', fieldName: 'ctmnLastDspsMthdExpln', header: { text: '협잡물' } },
// 운영실적
{ name: 'yrstpRnDyn', fieldName: '가동일수(일/년)', header: { text: '가동일수(일/년)' }, valueType: ValueType.NUMBER, dataFormat: '#,##0' }, // 입력값 3자리만 가능
{ name: 'dsplcmntAmnt', fieldName: '총배수량(m³/년)', header: { text: '총배수량(m³/년)' }, footer: footerDecimal2, valueType: ValueType.NUMBER, dataFormat: '#,##0.00' },
{ name: 'oprtngDayCnt', fieldName: 'oprtngDayCnt', header: { text: '가동일수(일/년)' }, valueType: ValueType.NUMBER, dataFormat: '#,##0' }, // 입력값 3자리만 가능
{ name: 'grssMltvQnt', fieldName: 'grssMltvQnt', header: { text: '총배수량(m³/년)' }, footer: footerDecimal2, valueType: ValueType.NUMBER, dataFormat: '#,##0.00' },
// 비고
{ name: 'rmk', fieldName: '비고', header: { text: '비고' } },
{ name: 'chckerOpinion', fieldName: '검토자의견', header: { text: '검토자의견' } },
{ name: 'wrterOpinion', fieldName: '사용자의견', header: { text: '사용자의견' } },
{ name: 'rmrk', fieldName: 'rmrk', header: { text: '비고' } },
// Hidden Columns : 시스템 관리를 위한 값
{ name: 'admdstCd', fieldName: 'admdstCd', header: { text: '행정구역코드' }, visible: false, },
{ name: 'fcltSn', fieldName: 'fcltSn', header: { text: '시설일련번호' }, visible: false, },
{ name: 'admdstNm', fieldName: 'admdstNm', header: { text: '행정구역명' }, visible: false },
],
columnLayout: [
@@ -95,21 +90,24 @@ export const RainfallPumpStationInpColumn = {
name: 'adminArea',
hideChildHeaders: false,
header: { text: '행정구역' },
items: ['sido', 'gugun'],
items: [
{ column: 'ctpvNm', footerUserSpans: [{ colspan: 3 }] },
'sggNm',
],
},
'swgeStrgeNm',
'addr',
'facRes',
'rnDt',
'plDsgnFrqnc',
'dgrAr',
'fcltNm',
'fcltPstnCn',
'fcltyKndCn',
'oprtngStrtYmd',
'planDesignFreqCnt',
'wtrStrgZoneArea',
{
name: 'reservoir',
hideChildHeaders: false,
header: { text: '유수지' },
items: [
'revFnnc',
'revCpcty',
'rsvrYn',
'rsvrImpndQnt',
],
},
{
@@ -117,7 +115,7 @@ export const RainfallPumpStationInpColumn = {
hideChildHeaders: false,
header: { text: '흡수정' },
items: [
'absCpcty',
'abstnRgnImpdQnt',
],
},
{
@@ -125,16 +123,16 @@ export const RainfallPumpStationInpColumn = {
hideChildHeaders: false,
header: { text: '펌프시설' },
items: [
'wpCpcty',
'grssPpqty',
{
name: 'pumpSystem1',
hideChildHeaders: false,
header: { text: '펌프시설1' },
items: [
'pumpCalbr',
'pumpHpCo',
'pumpCo',
'pumpPreparCo',
'pumpFcltN1DmtrExpln',
'pumpFcltN1PerPpqty',
'pumpFcltN1PumpCnt',
'pumpFcltN1RsrvPumpCnt',
],
},
{
@@ -142,10 +140,10 @@ export const RainfallPumpStationInpColumn = {
hideChildHeaders: false,
header: { text: '펌프시설2' },
items: [
'pumpCalbr2',
'pumpHpCo2',
'pumpCo2',
'pumpPreparCo2',
'pumpFcltN2DmtrExpln',
'pumpFcltN2PerPpqty',
'pumpFcltN2PumpCnt',
'pumpFcltN2RsrvPumpCnt',
],
},
{
@@ -153,10 +151,10 @@ export const RainfallPumpStationInpColumn = {
hideChildHeaders: false,
header: { text: '펌프시설3' },
items: [
'pumpCalbr3',
'pumpHpCo3',
'pumpCo3',
'pumpPreparCo3',
'pumpFcltN3DmtrExpln',
'pumpFcltN3PerPpqty',
'pumpFcltN3PumpCnt',
'pumpFcltN3RsrvPumpCnt',
],
},
],
@@ -166,19 +164,19 @@ export const RainfallPumpStationInpColumn = {
hideChildHeaders: false,
header: { text: '부대시설' },
items: [
'dorZeqpFnnc',
'selfEtrqFnnc',
'freFnnc',
'prcsgFcltYn',
'ddrztnEqpmYn',
'oslfEtgtYn',
],
},
'disWat',
'dschgWtrsExpln',
{
name: 'finalDisposalAmount',
hideChildHeaders: false,
header: { text: '최종처분량(ton/년)' },
items: [
'sdmtTreCpcty',
'iprtTreCpcty',
'prcptnLastDspsQnt',
'ctmnLastDspsQnt',
],
},
{
@@ -186,8 +184,8 @@ export const RainfallPumpStationInpColumn = {
hideChildHeaders: false,
header: { text: '최종처분방법' },
items: [
'sdmtTreMth',
'iprtTreMth',
'prcptnLastDspsMthdExpln',
'ctmnLastDspsMthdExpln',
],
},
{
@@ -195,17 +193,18 @@ export const RainfallPumpStationInpColumn = {
hideChildHeaders: false,
header: { text: '운영실적' },
items: [
'yrstpRnDyn',
'dsplcmntAmnt',
'oprtngDayCnt',
'grssMltvQnt',
],
},
'rmk',
'chckerOpinion',
'wrterOpinion',
'rmrk',
'admdstCd',
'fcltSn',
'admdstNm'
],
rowStartNum: 3,
fixedColumns: 2,
tabKey: 'rainfallPumpStationInp',
tabKey: 'ST00040002',
tabLabel: '빗물펌프장',
};

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