Merge branch 'main' of https://www.onasys.co.kr:3000/ONAANDALL/frontend into iipbs/as/dev
This commit is contained in:
@@ -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"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
|
||||
@@ -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";
|
||||
|
||||
@@ -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,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;
|
||||
|
||||
@@ -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() {
|
||||
|
||||
@@ -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 ?? "",
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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
@@ -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=
|
||||
|
||||
@@ -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=
|
||||
|
||||
@@ -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=
|
||||
|
||||
@@ -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"> </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"> </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"> </span>
|
||||
<span class="bubble bubble-green"> </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"> </span>
|
||||
<span class="bubble bubble-green"> </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"> </span>
|
||||
<span class="bubble bubble-green"> </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"> </span>
|
||||
<span class="bubble bubble-green"> </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"> </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"> </span>
|
||||
<span class="bubble bubble-green"> </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"> </span>
|
||||
<span class="bubble bubble-green"> </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"> </span>
|
||||
<span class="bubble bubble-green"> </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"> </span>
|
||||
<span class="bubble bubble-green"> </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"> </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"> </span>
|
||||
<span class="bubble bubble-green"> </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"> </span>
|
||||
<span class="bubble bubble-green"> </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"> </span>
|
||||
<span class="bubble bubble-green"> </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"> </span>
|
||||
<span class="bubble bubble-green"> </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"> </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"> </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"> </span>
|
||||
<span class="bubble bubble-green"> </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"> </span>
|
||||
<span class="bubble bubble-green"> </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"> </span>
|
||||
<span class="bubble bubble-green"> </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"> </span>
|
||||
<span class="bubble bubble-green"> </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"> </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"> </span>
|
||||
<span class="bubble bubble-green"> </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"> </span>
|
||||
<span class="bubble bubble-green"> </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"> </span>
|
||||
<span class="bubble bubble-green"> </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"> </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"> </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"> </span><span class="bubble bubble-green"> </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"> </span><span class="bubble bubble-green"> </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"> </span><span class="bubble bubble-green"> </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"> </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"> </span><span class="bubble bubble-green"> </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"> </span><span class="bubble bubble-green"> </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"> </span><span class="bubble bubble-green"> </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"> </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"> </span><span class="bubble bubble-green"> </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"> </span><span class="bubble bubble-green"> </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"> </span><span class="bubble bubble-green"> </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"> </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"> </span><span class="bubble bubble-green"> </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"> </span><span class="bubble bubble-green"> </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"> </span><span class="bubble bubble-green"> </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"> </span><span class="bubble bubble-green"> </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"> </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"> </span><span class="bubble bubble-green"> </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"> </span><span class="bubble bubble-green"> </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"> </span><span class="bubble bubble-green"> </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"> </span><span class="bubble bubble-green"> </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"> </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"> </span><span class="bubble bubble-green"> </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"> </span><span class="bubble bubble-green"> </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"> </span><span class="bubble bubble-green"> </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"> </span><span class="bubble bubble-green"> </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"> </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"> </span><span class="bubble bubble-green"> </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"> </span><span class="bubble bubble-green"> </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"> </span><span class="bubble bubble-green"> </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"> </span><span class="bubble bubble-green"> </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"> </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"> </span><span class="bubble bubble-green"> </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"> </span><span class="bubble bubble-green"> </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"> </span><span class="bubble bubble-green"> </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"> </span><span class="bubble bubble-green"> </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"> </span><span class="bubble bubble-green"> </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"> </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"> </span><span class="bubble bubble-green"> </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"> </span><span class="bubble bubble-green"> </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"> </span><span class="bubble bubble-green"> </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"> </span><span class="bubble bubble-green"> </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} />;
|
||||
}
|
||||
@@ -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"> </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"> </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"> </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"> </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"> </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"> </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"> </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"> </span>
|
||||
// </h2>
|
||||
// </div>
|
||||
|
||||
// <div class="sections">
|
||||
// <section id="law-plan" class="section first">
|
||||
// <div class="section-title">
|
||||
// <span class="section-bar"> </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"> </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"> </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"> </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"> </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"> </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"> </span>
|
||||
// </h2>
|
||||
// </div>
|
||||
|
||||
// <div class="sections">
|
||||
// <section id="rain-intro" class="section first">
|
||||
// <div class="section-title">
|
||||
// <span class="section-bar"> </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"> </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"> </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"> </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"> </span>
|
||||
// </h2>
|
||||
// </div>
|
||||
|
||||
// <div class="sections">
|
||||
// <section id="greywater-intro" class="section first">
|
||||
// <div class="section-title">
|
||||
// <span class="section-bar"> </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"> </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"> </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"> </span>
|
||||
// </h2>
|
||||
// </div>
|
||||
|
||||
// <div class="sections">
|
||||
// <section id="treatedwater-intro" class="section first">
|
||||
// <div class="section-title">
|
||||
// <span class="section-bar"> </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"> </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 }}
|
||||
// />
|
||||
// );
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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,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;
|
||||
|
||||
@@ -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"
|
||||
>
|
||||
수요처등록
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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 |
@@ -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}
|
||||
>
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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) =
|
||||
}))
|
||||
},
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
@@ -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*`,
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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
|
||||
@@ -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>
|
||||
|
||||
);
|
||||
|
||||
}
|
||||
|
||||
|
||||
|
||||
@@ -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;
|
||||
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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(() => {
|
||||
|
||||
@@ -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 (
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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 = {
|
||||
|
||||
+2
-2
@@ -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,
|
||||
|
||||
+79
-16
@@ -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,
|
||||
|
||||
+39
-7
@@ -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,
|
||||
|
||||
+91
-92
@@ -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
Reference in New Issue
Block a user