Merge iipbs/sn/dev_popup into iipbs/sn/dev2_kic

This commit is contained in:
kiminchan
2026-04-27 17:00:38 +09:00
123 changed files with 6173 additions and 2045 deletions
+3 -2
View File
@@ -1,7 +1,8 @@
# apps/cs/src/shared/config/env.ts 참고
NEXT_PUBLIC_PORT=3000
NEXT_PUBLIC_PROXY_HOST=http://localhost:3000
SERVER_API_URL=http://192.168.0.102:8000
NEXT_PUBLIC_SITE_ID=CS
NEXT_PUBLIC_SUB_DOMAIN=http://localhost:3000
NEXT_PUBLIC_SERVER_API_URL=http://192.168.0.102:8000
NEXT_PUBLIC_TINY_API_KEY=t9nc4z6inquvvd5pq1vea8wcp7lb01qmqseh6egms9vpaju2
NEXT_PUBLIC_TINY_API_KEY=t9nc4z6inquvvd5pq1vea8wcp7lb01qmqseh6egms9vpaju2
NEXT_PUBLIC_TINY_BASE_URL=
+17 -8
View File
@@ -1,21 +1,25 @@
import { NextRequest, NextResponse } from "next/server";
import {
CLAIM_NAME,
AUTH_USER_ID,
CLAIM_NAME,
PORT,
PROXY_HOST,
SERVER_API_URL,
SITE_ID,
} from "./src/shared/config";
const serverApiUrl =
process.env.NEXT_PUBLIC_SERVER_API_URL ?? "http://localhost:3000";
const serverApiUrl = process.env.SERVER_API_URL ?? "http://localhost:3000";
const linkBackendApiHost = process.env.NEXT_PUBLIC_LINK_BACKEND_API_HOST ?? "http://localhost:8080";
const linkBackendApiHost =
process.env.NEXT_PUBLIC_LINK_BACKEND_API_HOST ?? "http://localhost:8080";
// CSP 헤더 설정 함수 (img-src만)
function addSecurityHeaders(response: NextResponse): NextResponse {
response.headers.set("Content-Security-Policy", "img-src 'self' data: https: blob:;");
response.headers.set(
"Content-Security-Policy",
"img-src 'self' data: https: blob:;",
);
return response;
}
@@ -66,7 +70,7 @@ export function middleware(req: NextRequest) {
const requestHeaders = new Headers(req.headers);
const token = req.cookies.get(CLAIM_NAME)?.value;
const userId = req.cookies.get(AUTH_USER_ID)?.value;
// /api/link 경로를 제거하고 백엔드 API 경로로 변환
const apiPath = pathname.replace("/api/link", "");
const queryParams = searchParams.toString();
@@ -94,11 +98,16 @@ export function middleware(req: NextRequest) {
logMessages.push("\n--------------------------------\n");
// console.log(logMessages.join("\n"));
const response = NextResponse.next();
return addSecurityHeaders(response);
}
export const config = {
matcher: ["/server/:path*", "/attach-service/:path*", "/api/link/:path*", "/"],
matcher: [
"/server/:path*",
"/attach-service/:path*",
"/api/link/:path*",
"/",
],
};
@@ -1,4 +1,4 @@
import { SERVER_API_URL } from "@/shared/config";
import { TINY_BASE_URL } from "@/shared/config";
export const editorBaseConfig = {
// 폐쇄망 환경에서는 API 키 없이 자체 호스팅 모드 사용
@@ -7,7 +7,7 @@ export const editorBaseConfig = {
licenseKey: "gpl",
init: {
language: "ko_KR",
language_url: '/tinymce/langs/ko_KR.js', // 직접 호스팅한 경우 경로 지정
language_url: "/tinymce/langs/ko_KR.js", // 직접 호스팅한 경우 경로 지정
height: 500,
// 폐쇄망 환경을 위한 설정
base_url: "/tinymce",
@@ -38,7 +38,7 @@ export const editorBaseConfig = {
"undo redo | blocks fontfamily fontsize | bold italic underline strikethrough forecolor backcolor | alignleft aligncenter alignright alignjustify | bullist numlist blockquote | table",
"link image",
],
images_upload_base_path: SERVER_API_URL,
images_upload_base_path: TINY_BASE_URL,
file_picker_types: "image",
// 폐쇄망 환경을 위한 추가 설정
content_css: "/tinymce/skins/content/default/content.min.css",
@@ -43,7 +43,7 @@ const GroupsDialog = memo(
const { data: group } = useQuery({
queryKey: [ENDPOINT.CMS_SERVICE.GROUPS, groupId],
queryFn: () => GroupApi.findGroupById(groupId!),
enabled: !!groupId && open, // open 시점 groupId가 있을 때만 상세 조회 API 호출
enabled: !isRegistration,
});
const { mutateAsync: createGroup } = useMutationWrapper({
@@ -92,8 +92,9 @@ const GroupsDialog = memo(
};
useEffect(() => {
if (open) form.reset();
}, [open]);
// 그룹 등록 및 취소 시 폼 초기화
if (isRegistration && !open) form.reset();
}, [groupId, open]);
return (
<Dialog open={open} onOpenChange={setOpen} {...props}>
+7 -1
View File
@@ -16,7 +16,13 @@ type RoleSelectProps = React.ComponentPropsWithoutRef<typeof Select> & {
function RoleSelect({ mngrYn = "N", ...props }: RoleSelectProps) {
const { data: roles } = useQuery({
queryKey: [ENDPOINT.USER_SERVICE.ROLES],
queryFn: () => RoleApi.getRoles(),
queryFn: () =>
RoleApi.getRoles({
keywordType: "useYn",
keyword: "Y",
page: 0,
size: 100,
}),
});
const options = roles?.content.filter((role) => role.mngrYn === mngrYn);
+2 -18
View File
@@ -3,13 +3,8 @@ import { useState } from "react";
import type { Role } from "@/entities/role";
import type { ColumnDef } from "@tanstack/react-table";
import { MappingRoleDialog } from "../ui/mapping-role-dialog";
import { Button } from "@common/components";
export const createColumns = ({
deleteRole,
}: {
deleteRole: (roleId: string) => void;
}): ColumnDef<Role>[] => [
export const createColumns = (): ColumnDef<Role>[] => [
{
id: "no",
header: "NO",
@@ -44,18 +39,7 @@ export const createColumns = ({
{
id: "action",
header: "관리",
cell: ({ row }) => (
<div className="flex items-center justify-center gap-2">
<MappingRoleButton roleId={row.original.roleId} />
<Button
size="sm"
color="red"
onClick={() => deleteRole(row.original.roleId)}
>
삭제
</Button>
</div>
),
cell: ({ row }) => <MappingRoleButton roleId={row.original.roleId} />,
},
];
+1 -13
View File
@@ -24,13 +24,6 @@ function ModifyPage({ roleId }: { roleId: string }) {
},
});
const { mutateAsync: deleteRole } = useMutationWrapper({
mutationFn: () => RoleApi.deleteRole(roleId),
onSuccess: () => {
alert("삭제되었습니다.", () => router.push("/role"));
},
});
const form = useForm<RoleFormData>({
resolver: zodResolver(roleFormSchema),
values: {
@@ -47,12 +40,7 @@ function ModifyPage({ roleId }: { roleId: string }) {
<div className="card-header">
<h3 className="text-2xl font-medium text-label">권한 수정</h3>
</div>
<RoleForm
roleId={roleId}
form={form}
handleSave={updateRole}
handleDelete={deleteRole}
/>
<RoleForm roleId={roleId} form={form} handleSave={updateRole} />
</div>
);
}
-1
View File
@@ -26,7 +26,6 @@ type RoleFormProps = {
roleId?: string;
form: UseFormReturn<RoleFormData>;
handleSave: (data: RoleFormData) => Promise<void>;
handleDelete?: () => Promise<void>;
};
function RoleForm({ roleId, form, handleSave, ...props }: RoleFormProps) {
+3 -11
View File
@@ -7,7 +7,7 @@ import { getCoreRowModel, type PaginationState } from "@tanstack/react-table";
import { Button, Input, Label } from "@common/components";
import { RoleApi, type Role, type RoleParams } from "@/entities/role";
import { ENDPOINT } from "@/shared/config";
import { useAlertStore, useDataTable, useMutationWrapper } from "@/shared/lib";
import { useAlertStore, useDataTable } from "@/shared/lib";
import { createColumns } from "../model/table-columns";
function RolePage() {
@@ -16,6 +16,7 @@ function RolePage() {
const [keyword, setKeyword] = useState("");
const [searchParams, setSearchParams] = useState<RoleParams>({
keywordType: "roleName",
keyword: "",
sort: "regDt,desc",
});
@@ -34,13 +35,6 @@ function RolePage() {
}),
});
const { mutateAsync: deleteRole } = useMutationWrapper({
mutationFn: (roleId: string) => RoleApi.deleteRole(roleId),
onSuccess: () => {
alert("삭제되었습니다.", query.refetch);
},
});
const handleRowClick = (row: Role) => {
router.push(`/role/register/${row.roleId}`);
};
@@ -48,9 +42,7 @@ function RolePage() {
const { tableComponent } = useDataTable({
query,
tableOptions: {
columns: createColumns({
deleteRole,
}),
columns: createColumns(),
state: {
pagination,
},
+2 -1
View File
@@ -1,7 +1,7 @@
export const PORT = process.env.NEXT_PUBLIC_PORT || "3000";
export const PROXY_HOST =
process.env.NEXT_PUBLIC_PROXY_HOST || `http://localhost:${PORT}`;
export const SERVER_API_URL = process.env.NEXT_PUBLIC_SERVER_API_URL;
export const SERVER_API_URL = process.env.SERVER_API_URL;
export const AUTH_LOGIN_REDIRECT_URL =
process.env.NEXT_PUBLIC_AUTH_LOGIN_REDIRECT_URL || "/auth/login";
export const SITE_ID = process.env.NEXT_PUBLIC_SITE_ID || "CS";
@@ -11,4 +11,5 @@ export const ACCESS_TOKEN =
export const REFRESH_TOKEN =
process.env.NEXT_PUBLIC_REFRESH_TOKEN || "refresh-token";
export const AUTH_USER_ID = process.env.NEXT_PUBLIC_AUTH_USER_ID || "token-id";
export const TINY_BASE_URL = process.env.NEXT_PUBLIC_TINY_BASE_URL;
export const COOKIE_DOMAIN = process.env.NEXT_PUBLIC_COOKIE_DOMAIN;
+2 -1
View File
@@ -12,7 +12,8 @@ NEXT_PUBLIC_LX_GEOSERVER_URL = http://con.mind-one.co.kr:11306/geoserver
NEXT_PUBLIC_NGII_URL = https://map.ngii.go.kr
NEXT_PUBLIC_NGII_PARAM = 04trYP9_xwLAfALjwZ-B8g&service=WMTS&request=GetTile&version=1.0.0&format=image/png&layer=korean_map&style=korean&tilematrixset=korean&tilematrix={z}&tilecol={x}&tilerow={y}
NEXT_PUBLIC_SERVER_API_URL = http://dev.onasys.co.kr:8000
NEXT_PUBLIC_SEWERAGE_API_URL = http://localhost:8883
NEXT_PUBLIC_DC_URL = https://apigateway.apps.rsonesoft.com
# NEXT_PUBLIC_CM_LOGIN_PAGE_URL = http://dev.onasys.co.kr:3000/auth/login
NEXT_PUBLIC_CM_LOGIN_PAGE_URL = /auth/login
NEXT_PUBLIC_SEWERAGE_API_URL=http://localhost:8883
# NEXT_PUBLIC_SEWERAGE_API_URL=http://192.168.1.66:8883
@@ -13,7 +13,8 @@
* - 위경도로 강우시 미처리 하수 목록 조회: GET /api/v1/onsemiro/getSwgRainsByGeomety (sewerage-service)
* - 강우시 월류 하수 관리 모니터링 조회: GET /api/v1/onsemiro/swgRain/{adstDstrCd}/{manageNo}/mntrngs (sewerage-service)
*/
import { UiModal, UiTable } from '@common/business_components/ui';
'use client';
import { UiTable } from '@common/business_components/ui';
import { Button, TableHead, TableRow } from '@common/components/ui';
import React, { useState, useEffect } from 'react';
import { untrtSwgRainCol3, untrtSwgRainCol4, untrtSwgRainCol5 } from '../common/table';
@@ -21,21 +22,21 @@ import { VerticalTable } from '@/components/ui/verticalTable';
import { getRawJson } from "@/components/lib/api";
import { formatDisplayData } from '@/components/lib/utils/formatDisplayValue';
import { Label } from '@radix-ui/react-label';
import { openWindowPopup } from '@/features/windowPopup/lib/openWindowPopup';
const inputColumn = [
{ id: "prrtymngYn", title: "중점여부", type: "select", options: ["우수토실", "중점우수토실"] },
{ id: "swtrmtZoneNm", title: "처리구역", type: "input" },
];
const Ssc = ({ featureData, isMainSsc, chmbrXcnts, chmbrYdnts, onSaveClick }: {
const Ssc = ({ featureData, isMainSsc, chmbrXcnts, chmbrYdnts, onSaveClick, renderMode = 'inline' }: {
featureData: any,
isMainSsc?: boolean,
chmbrXcnts?: string,
chmbrYdnts?: string,
onSaveClick?: () => void
onSaveClick?: () => void,
renderMode?: 'inline' | 'popup'
}) => {
const [isModalOpen, setIsModalOpen] = useState(false);
const [isDataLoaded, setIsDataLoaded] = useState(false);
const [formData, setFormData] = useState<any>({});
const [rainMeasureData, setRainMeasureData] = useState<any>({}); // 강우, 측정 정보
const [mntrngsData, setMntrngsData] = useState<any[]>([]); // 수량/수질측정 정보
@@ -151,14 +152,15 @@ const Ssc = ({ featureData, isMainSsc, chmbrXcnts, chmbrYdnts, onSaveClick }: {
}
}, [chmbrXcnts, chmbrYdnts]);
const openModal = () => {
setIsModalOpen(true);
};
const openDetailPopup = () => {
const popupWindow = openWindowPopup("sscDetail", {
chmbrXcnts,
chmbrYdnts,
sourcePath: window.location.pathname,
});
const closeModal = () => {
setIsModalOpen(false);
if (!isDataLoaded) {
setTimeout(() => setIsDataLoaded(true), 100);
if (!popupWindow) {
alert('팝업이 차단되었습니다. 브라우저 팝업 허용 설정을 확인해주세요.');
}
};
@@ -292,33 +294,24 @@ const Ssc = ({ featureData, isMainSsc, chmbrXcnts, chmbrYdnts, onSaveClick }: {
/>
)}
{isMainSsc && (
{isMainSsc && renderMode !== 'popup' && (
<div>
<div className="card-header">
<h3 className="text-2xl mx-3 font-medium text-label">중점우수토실 운영정보</h3>
</div>
<Button
className='mx-auto block w-64 mt-3 py-2 text-white rounded-sm text-sm font-medium hover:bg-blue-700 bg-blue-600'
onClick={openModal}
onClick={openDetailPopup}
>
상세보기
</Button>
</div>
)}
<UiModal
open={isModalOpen}
onOpenChange={setIsModalOpen}
bodyClass={`
h-[100vh]
w-[60vw]
max-w-none
max-h-none
overflow-y-auto
z-[101]
`}
body={renderModalBody()}
noHeaderYn='Y'
noFooterYn='Y'
/>
{isMainSsc && renderMode === 'popup' && (
<div className="min-h-dvh bg-white p-4">
<h1 className="mb-3 text-2xl font-semibold text-gray-900">중점우수토실 운영정보</h1>
{renderModalBody()}
</div>
)}
</div>
@@ -9,11 +9,10 @@
* - 조회 버튼을 통한 모달 표시 및 ReportTab 연동
*/
'use client'
import React, { useState } from 'react'
import React from 'react'
import { VerticalTable } from '@/components/ui/verticalTable';
import { UiModal } from '@common/business_components/ui';
import { Button } from '@common/components/ui';
import ReportTab from '../../searchModal/reportTab';
import { openWindowPopup } from '@/features/windowPopup/lib/openWindowPopup';
// 하수처리시설
const headList = [
@@ -21,16 +20,26 @@ const headList = [
];
const SwtrmtFclt = ({ gisObject }: { gisObject: any }) => {
const [isModalOpen, setIsModalOpen] = useState(false);
const ctpvNm = gisObject?.ctpv_nm;
const sggNm = gisObject?.sgg_nm;
const handleOpenReportPopup = () => {
const popupWindow = openWindowPopup("reportTab", {
ctpvNm,
sggNm,
sourcePath: window.location.pathname,
});
if (!popupWindow) {
alert('팝업이 차단되었습니다. 브라우저 팝업 허용 설정을 확인해주세요.');
}
};
return (
<div>
<VerticalTable
children={{
title: <Button className="gray" onClick={() => setIsModalOpen(true)}>조회</Button>
title: <Button className="gray" onClick={handleOpenReportPopup}>조회</Button>
}}
headName='디지털 자료관 연계정보'
tableData={[]}
@@ -43,31 +52,8 @@ const SwtrmtFclt = ({ gisObject }: { gisObject: any }) => {
throw new Error("Function not implemented.");
}}
/>
<UiModal
open={isModalOpen}
onOpenChange={setIsModalOpen}
headerTitle="하수도정비 기본계획보고서"
headerTitleClass="text-3xl font-semibold text-gray-700"
bodyClass={`
h-[100vh]
w-[60vw]
max-w-[60vw]
max-h-[100vh]
overflow-hidden
z-[101]
`}
body={
<ReportTab
open={isModalOpen}
isSearch={false}
ctpvNm={ctpvNm}
sggNm={sggNm}
/>
}
useDirectRender={true}
/>
</div >
)
}
export default SwtrmtFclt
export default SwtrmtFclt
@@ -7,14 +7,12 @@ import {
CHART_FORM,
type ChartConfig,
MainChart,
UiModal,
} from '@common/business_components';
import { SelectBox } from '@common/business_components/ui';
import { Button, Input, Label } from '@common/components';
import type { TInftwtAnls } from './aplcnYms';
import * as fn from './aplcnYms';
import { useInfilChartModalStore } from '../chartModalStore';
type ModalOpenProp = {
pplMngNo?: string; // 관로관리번호
@@ -28,7 +26,6 @@ type DataType = {
const INITIAL_ANALYSIS_OPTIONS = [{ val: 0, text: '선택' }];
const InfilChart = ({ pplMngNo = '' }: ModalOpenProp) => {
const { isOpen: isModalOpen, setIsOpen: setIsModalOpen } = useInfilChartModalStore();
const [selectedAplcnYm, setSelectedAplcnYm] = useState('');
const [inftwtAnlss, setInftwtAnlss] = useState<TInftwtAnls[]>([]);
@@ -66,12 +63,6 @@ const InfilChart = ({ pplMngNo = '' }: ModalOpenProp) => {
setInftwtQty('0');
};
useEffect(() => {
if (!isModalOpen) {
resetState(); // 모달이 닫힐 때 리셋
}
}, [isModalOpen]);
const handleAplcnYmChange = async (val: string | number) => {
const selectedValue = String(val);
@@ -276,21 +267,10 @@ const InfilChart = ({ pplMngNo = '' }: ModalOpenProp) => {
);
return (
<UiModal
open={isModalOpen}
onOpenChange={setIsModalOpen}
headerTitle=""
bodyClass={`
w-[78vw]
max-w-none
min-w-[1000px]
min-h-[72vh]
overflow-visible
z-[50]
`}
body={renderBody()}
noFooterYn="Y"
/>
<div className="min-h-dvh bg-white p-4">
<h1 className="mb-3 text-2xl font-semibold text-gray-900">침입수 분석</h1>
{renderBody()}
</div>
);
};
@@ -7,14 +7,12 @@ import {
CHART_FORM,
type ChartConfig,
MainChart,
UiModal,
} from '@common/business_components';
import { SelectBox } from '@common/business_components/ui';
import { Button, Input, Label } from '@common/components';
import type { TRdiiAnls } from './aplcnYms';
import * as fn from './aplcnYms';
import { useRdiiChartModalStore } from '../chartModalStore';
type ModalOpenProp = {
pplMngNo?: string; // 관로관리번호
@@ -29,7 +27,6 @@ type LineBarChartData = {
const INITIAL_ANALYSIS_OPTIONS = [{ val: 0, text: '선택' }];
const RdiiChart = ({ pplMngNo = '' }: ModalOpenProp) => {
const { isOpen: isModalOpen, setIsOpen: setIsModalOpen } = useRdiiChartModalStore();
const [aplcnYm, setAplcnYm] = useState('');
const [rdiiAnlss, setRdiiAnlss] = useState<TRdiiAnls[]>([]);
@@ -72,12 +69,6 @@ const RdiiChart = ({ pplMngNo = '' }: ModalOpenProp) => {
setTotalVolume('0');// 총체적 m³/mm 초기화
};
useEffect(() => {
if (!isModalOpen) {
resetState();
}
}, [isModalOpen]);
const handleAplcnYmChange = async (val: string | number) => {
const selectedValue = String(val);
@@ -336,21 +327,10 @@ const RdiiChart = ({ pplMngNo = '' }: ModalOpenProp) => {
);
return (
<UiModal
open={isModalOpen}
onOpenChange={setIsModalOpen}
headerTitle=""
bodyClass={`
w-[78vw]
max-w-none
min-w-[1100px]
min-h-[72vh]
overflow-visible
z-[50]
`}
body={renderBody()}
noFooterYn="Y"
/>
<div className="min-h-dvh bg-white p-4">
<h1 className="mb-3 text-2xl font-semibold text-gray-900">RDII 분석</h1>
{renderBody()}
</div>
);
};
@@ -1,16 +0,0 @@
import { create } from 'zustand';
interface ModalStore {
isOpen: boolean;
setIsOpen: (open: boolean) => void;
}
export const useInfilChartModalStore = create<ModalStore>((set) => ({
isOpen: false,
setIsOpen: (open: boolean) => set({ isOpen: open }),
}));
export const useRdiiChartModalStore = create<ModalStore>((set) => ({
isOpen: false,
setIsOpen: (open: boolean) => set({ isOpen: open }),
}));
@@ -23,7 +23,6 @@
*/
'use client'
import { getData, postData } from "@/components/lib/api";
import Modal from "@/components/ui/modal";
import { VerticalTable } from "@/components/ui/verticalTable";
import { useGisContext } from "@common/business_components/ui/map/Gis";
import { Button } from "@common/components/ui";
@@ -34,14 +33,12 @@ import Stroke from "ol/style/Stroke.js";
import CircleStyle from "ol/style/Circle.js";
import { getLayerContentComponent } from "../common";
import { SwtrmtFcltCol, SwtrmtFcltCol2, SwtrmtPrcsZoneCol, SwtrmtPrcsZoneCol2, untrtSwgRainCol1, untrtSwgRainCol2, MsSwagePplCol, MsSwagePplCol2, ExclnSewagePumpCol, ExclnSewagePumpCol2, DrtwSewagePumpCol, DrtwSewagePumpCol2, MsSwageMhlCol, MsSwageMhlCol2, stofcbrCol, stofcbrCol2, MS_SWAGE_MHL_CODES, DRTW_SEWAGE_PUMP_USE_CODES } from "../common/table";
import InfilChart from './chart/infilChart';
import RdiiChart from './chart/rdiiChart';
import { useInfilChartModalStore, useRdiiChartModalStore } from "./chartModalStore";
import { formatDisplayData } from '@/components/lib/utils/formatDisplayValue';
import { codeMapper } from "../common/codeMapper";
import LoadingOverlay from '@/components/ui/LoadingBar';
import { searchUpperPpls } from "./smh";
import { getElapsedYearsDisplayText, isInvalidInstallYmd } from "../agedPipe/agedPipeUtils";
import { openWindowPopup } from '@/features/windowPopup/lib/openWindowPopup';
const linkedFacilityHighlightStyle = new Style({
image: new CircleStyle({
@@ -74,12 +71,9 @@ const FeatureClickEventPopup = ({ onClose, featureData }: { onClose: () => void,
const [buttonChk, setButtonChk] = useState(true)
const [fetchData, setFetchData] = useState<any>(); // api 호출여부
const contentRef = useRef<HTMLDivElement>(null);
const [isModalOpen, setIsModalOpen] = useState(false); // 모달 열기 상태
const [isMainSsc, setIsMainSsc] = useState(false); // 중점우수토실 여부
const [isDrtwSewagePump, setIsDrtwSewagePump] = useState(false); // 오수펌프장 여부
// 🎯 InfilChart 및 RdiiChart 관련 상태 추가
const { setIsOpen: setIsModalInfil } = useInfilChartModalStore();
const { setIsOpen: setIsModalRdii } = useRdiiChartModalStore();
const [isClicked, setIsClicked] = useState(true); // 클릭 여부 상태
const [isLoading, setIsLoading] = useState(false); // 로딩 상태
const [chmbrXcnts, setChmbrXcnts] = useState<string>(''); // 로딩 상태
@@ -469,16 +463,9 @@ const FeatureClickEventPopup = ({ onClose, featureData }: { onClose: () => void,
// 저장 시 모달 띄우기
const handleSaveClick = () => {
setIsModalOpen(true); // 모달 열기
};
const handleModalSave = () => {
setIsModalOpen(false); // 모달 닫기
btnClickEvent(); // 저장 이벤트 실행
};
const handleModalClose = () => {
setIsModalOpen(false); // 모달 닫기
const shouldSave = window.confirm('정보를 저장하시겠습니까?');
if (!shouldSave) return;
btnClickEvent();
};
// 저장 버튼 이벤트
@@ -565,14 +552,26 @@ const FeatureClickEventPopup = ({ onClose, featureData }: { onClose: () => void,
{/* 하수관로일 때만 Infil/RDII 버튼 표시 */}
{layerNm === '하수관로' && (
<>
<Button onClick={() => setIsModalInfil(true)}
<Button
onClick={() => {
const popupWindow = openWindowPopup("infilChart", { pplMngNo });
if (!popupWindow) {
alert('팝업이 차단되었습니다. 브라우저 팝업 허용 설정을 확인해주세요.');
}
}}
className="py-1 text-xl text-blue-600 border border-blue-600 rounded hover:bg-gray-700"
color="white"
size="sm"
>
침입수분석
</Button>
<Button onClick={() => setIsModalRdii(true)}
<Button
onClick={() => {
const popupWindow = openWindowPopup("rdiiChart", { pplMngNo });
if (!popupWindow) {
alert('팝업이 차단되었습니다. 브라우저 팝업 허용 설정을 확인해주세요.');
}
}}
className="py-1 text-xl text-gray-600 border border-gray-600 rounded hover:bg-gray-700 mr-4"
color="white"
size="sm"
@@ -629,7 +628,6 @@ const FeatureClickEventPopup = ({ onClose, featureData }: { onClose: () => void,
total2={0}
isEditable2={buttonChk}
/>
<Modal isModalOpen={isModalOpen} title="자체입력정보 저장" setIsModalOpen={setIsModalOpen} closeModal={handleModalClose} saveModal={handleModalSave}><div className="text-lg">정보를 저장하시겠습니까?</div></Modal>
{ContentComponent &&
<ContentComponent
featureData={fetchData}
@@ -642,9 +640,6 @@ const FeatureClickEventPopup = ({ onClose, featureData }: { onClose: () => void,
/>
}
</div>
<InfilChart pplMngNo={pplMngNo} />
<RdiiChart pplMngNo={pplMngNo} />
</div>
)
}
+50 -4
View File
@@ -45,6 +45,7 @@ import {
type AgedPipeRow,
AGED_PIPE_DEFAULT_FILTERS,
} from "./components/agedPipe/agedPipeUtils";
import { WINDOW_POPUP_MESSAGE_TYPE } from "@/features/windowPopup/model/types";
const AGED_PIPE_CONTROL_NODE_ID = "SN_AGED_PIPE";
const AGED_PIPE_HIGHLIGHT_LAYER_ID = "SN_AGED_PIPE_HIGHLIGHT";
@@ -102,6 +103,7 @@ const Map = () => {
const { isSearchModalOpen, setIsSearchModalOpen } = useSearchModalStore();
const [treeData, setTreeData] = useState<any[]>([]); // 초기값을 객체 또는 null로 설정
const [layerLoadError, setLayerLoadError] = useState<string | null>(null);
const gisMaps = useRef<any | null>(null);
const [isOpen, setIsOpen] = useState(false); // 팝업 열기/닫기 상태
const [featureData, setFeatureData] = useState<any>(null); // 선택된 피쳐 데이터
@@ -135,17 +137,56 @@ const Map = () => {
}
}, 300);
return () => window.clearInterval(timer);
const handlePopupMessage = async (event: MessageEvent) => {
if (event.origin !== window.location.origin) return;
const data = event.data;
const core = GisApp.Module.layer as any;
if (data?.type === WINDOW_POPUP_MESSAGE_TYPE.MAP_SEARCH_SELECT_FEATURE) {
const layerNm = data.payload?.layerNm;
const fid = data.payload?.fid;
if (!layerNm || !fid || !core?.showSearchResultsFromGeoJSON) return;
await core.showSearchResultsFromGeoJSON(layerNm, fid);
return;
}
if (data?.type === WINDOW_POPUP_MESSAGE_TYPE.MAP_SEARCH_SHOW_MULTIPLE) {
const layerNm = data.payload?.layerNm;
const fidsRaw = data.payload?.fids;
if (!layerNm || !fidsRaw || !core?.showMultipleSearchResultsFromGeoJSON) return;
let fids: string[] = [];
try {
fids = JSON.parse(fidsRaw);
} catch (error) {
console.error("다중 시설 메시지 파싱 실패:", error);
return;
}
await core.showMultipleSearchResultsFromGeoJSON(layerNm, fids);
}
};
window.addEventListener("message", handlePopupMessage);
return () => {
window.clearInterval(timer);
window.removeEventListener("message", handlePopupMessage);
};
}, []);
const fetchData = async () => {
try {
const data = await getRawJson("/sewerage-service/api/v1/map/lyrStngs/SN_NETWORK");
setTreeData(appendAgedPipeControlNode(data));
setLayerLoadError(null);
} catch (error) {
console.error('JSON parsing error:', error);
// JSON 파싱에 실패한 경우, 원본 텍스트 또는 null 반환
throw new Error('Invalid JSON response from server');
const message =
error instanceof Error ? error.message : "레이어 설정 조회 중 알 수 없는 오류가 발생했습니다.";
console.error("레이어 설정 조회 실패:", error);
setTreeData([]);
setLayerLoadError(message);
}
};
@@ -488,6 +529,11 @@ const Map = () => {
return (
<>
<div className="bg-container relative h-[calc(100dvh-5rem)] overflow-hidden">
{layerLoadError ? (
<div className="absolute left-4 top-4 z-[60] max-w-[520px] rounded border border-red-200 bg-white px-4 py-3 text-sm text-red-700 shadow">
레이어 설정 조회 실패: {layerLoadError}
</div>
) : null}
<Gis
treeData={treeData}
isSearch={true}
@@ -1,108 +0,0 @@
/**
* ➡️ 경로 : 하수관망도 메인 페이지 (오른쪽 돋보기 모양 클릭) - 검색 모달 창 내 하수도정비기본계획보고서 탭 (셀 클릭)
*
* 📄 Report Files Modal
* Next.js App Router (app/) 기반 보고서 파일 모달 컴포넌트
*
* - 하수도정비 기본계획 보고서 파일 목록 표시
* - 파일명 및 등록일자 정보 테이블 출력
* - 페이지네이션 및 로딩 오버레이 지원
* - 파일 클릭 이벤트 처리 및 상세 조회
* - Zustand를 통한 모달 상태 및 데이터 관리
* - 모달 열림/닫힘 시 데이터 초기화 기능
*/
'use client'
import React, { useEffect } from 'react';
import LoadingOverlay from '@/components/ui/LoadingBar';
import useReportTab from './useReportTab';
import { useReportTabStore } from '@/store/useReportTabStore';
import { UiTable } from '@common/business_components/ui';
import { UiModal } from "@common/business_components";
import useSearchModalStore from '@/store/useSearchModalStore';
type ModalOpenProp = { clsfSystmSn: string } // 자료관 기본계획 일련번호
const ReportFilesModal: React.FC<ModalOpenProp> = ({ clsfSystmSn }: ModalOpenProp) => {
// 하수도정비 기본계획 보고서 파일목록
const REPORT_FILES_HEADLIST = [
{ id: "no", title: "NO" },
{ id: "fileTitle", title: "파일명" },
{ id: "regDt", title: "등록일자" },
];
const {
curPage, setCurPage,
isLoading,
isResetPage,
isResetSelection,
setIsResetPage,
setIsResetSelection
} = useSearchModalStore();
const {
isOpen: isModalOpen,
files,
dcFiles: filesOrigin,
setIsOpen: setIsModalOpen,
setFiles,
fetchDcFiles
} = useReportTabStore();
const { getFiles, handleCellClick } = useReportTab();
useEffect(() => {
fetchDcFiles(clsfSystmSn);
}, [clsfSystmSn]);
useEffect(() => {
if (isModalOpen === true) {
getFiles(); // 파일 목록 조회
} else {
setFiles([]); // 모달이 닫힐 때 파일 목록 초기화
setCurPage(1); // 페이지를 1로 초기화
setIsResetPage(true); // 페이지를 초기화
setIsResetSelection(true); // 선택 초기화
}
}, [filesOrigin, isModalOpen, curPage]);
const renderBody = () => (
<>
<div className="flex-1 px-2 pb-2">
<LoadingOverlay isLoading={isLoading} />
<UiTable
headName={(() => {
return (<>조회 결과 ({filesOrigin.length.toLocaleString('ko-KR')})건</>);
})()
}
tableData={files}
headlist={REPORT_FILES_HEADLIST}
pageSize={10}
total={filesOrigin.length}
cellClick={handleCellClick}
onPageChange={setCurPage}
resetPageNo={isResetPage}
resetSelection={isResetSelection}
/>
</div>
</>
);
return (
<UiModal
open={isModalOpen}
onOpenChange={setIsModalOpen}
headerTitle=""
bodyClass={`
min-h-[50vh] max-h-[90dvh]
max-w-[750px]
h-auto w-auto
overflow-auto
z-[5000]
flex items-start
`}
body={renderBody()}
noFooterYn='Y'
/>
);
};
export default React.memo(ReportFilesModal);
@@ -22,7 +22,7 @@ import useStore from '@/store/useStore';
import { CtpvSggPrcsFcltSelectBox } from '@/components/ui/ctpvSggPrcsFcltSelectBox';
import useSearchModalStore from '@/store/useSearchModalStore';
import { useReportTabStore } from '@/store/useReportTabStore';
import ReportFilesModal from './reportFilesModal'; // 파일목록모달
import { openWindowPopup } from '@/features/windowPopup/lib/openWindowPopup';
// 하수도정비 기본계획 보고서
@@ -56,7 +56,6 @@ const ReportTab: React.FC<ReportTabProps> = ({ open, isSearch = true, ctpvNm, sg
const [ctpvCd, setCtpvCd] = useState('0');
const [sggCd, setSggCd] = useState('0');
const [reportGubun, setReportGubun] = useState("0");
const [clsfSystmSn, setClsfSystmSn] = useState(''); // 자료관 기본계획 일련번호
const {
tableData,
@@ -72,8 +71,6 @@ const ReportTab: React.FC<ReportTabProps> = ({ open, isSearch = true, ctpvNm, sg
} = useSearchModalStore();
const { ctpv, sgg } = useStore();
const {
isOpen: isReportFilesModalOpen,
setIsOpen: setIsReportFilesModalOpen,
dcTree: reportOriginData,
fetchDcTree
} = useReportTabStore(); // 모달 상태 관리 스토어
@@ -139,8 +136,14 @@ const ReportTab: React.FC<ReportTabProps> = ({ open, isSearch = true, ctpvNm, sg
if (typeof index === "number" && tableData[index]) {
const reportData = tableData[index];
if (reportData.fileSn !== "") {
setClsfSystmSn(reportData.clsfSystmSn); // 자료관 기본계획 일련번호 설정
setIsReportFilesModalOpen(true); // 모달 열기
const popupWindow = openWindowPopup("reportFiles", {
clsfSystmSn: reportData.clsfSystmSn,
sourcePath: window.location.pathname,
});
if (!popupWindow) {
alert('팝업이 차단되었습니다. 브라우저 팝업 허용 설정을 확인해주세요.');
}
}
}
};
@@ -263,10 +266,8 @@ const ReportTab: React.FC<ReportTabProps> = ({ open, isSearch = true, ctpvNm, sg
/>
</div>
</div>
{/* 모달 컴포넌트 */}
{isReportFilesModalOpen && <ReportFilesModal clsfSystmSn={clsfSystmSn} />}
</>
);
};
export default React.memo(ReportTab);
export default React.memo(ReportTab);
@@ -11,21 +11,26 @@
* - Zustand를 통한 검색 모달 상태 관리
* - 로딩 오버레이 및 선택 초기화 지원
*/
'use client';
import LoadingOverlay from '@/components/ui/LoadingBar';
import { UiModal, UITab, UiTable } from '@common/business_components/ui';
import { UITab, UiTable } from '@common/business_components/ui';
import { Button } from '@common/components/ui';
import React, { useEffect, useMemo } from 'react';
import React, { useEffect, useMemo, useRef } from 'react';
import { Toaster } from "react-hot-toast";
import useSearchModalStore from '@/store/useSearchModalStore';
import { ExclnSewagePumpCol2, MsSwageMhlCol2, MsSwagePplCol2, stofcbrCol2, SwtrmtFcltCol2, SwtrmtPrcsZoneCol2 } from '../components/common/table';
import BasicTab, { BASIC_TYPES } from './basicTab';
import OperationTab from './operationTab';
import ReportTab from './reportTab';
import useSearchModal from './useSearchModal';
import useBasicTab from './useBasicTab';
import useOperationTab from './useOperationTab';
import { openWindowPopup } from '@/features/windowPopup/lib/openWindowPopup';
const SearchModal: React.FC = () => {
type SearchModalProps = {
renderMode?: "launcher" | "popup";
};
const SearchModal: React.FC<SearchModalProps> = ({ renderMode = "launcher" }) => {
const {
showTable,
isSearchModalOpen, setIsSearchModalOpen,
@@ -59,6 +64,7 @@ const SearchModal: React.FC = () => {
operationResultExcelDownload,
handleOperationPageNation
} = useOperationTab();
const hasAutoOpenedReportRef = useRef(false);
const dynamicHeadlist: { id: string, title: string }[] = useMemo(() => {
@@ -87,6 +93,42 @@ const SearchModal: React.FC = () => {
return [];
}, [activeTab, workType]);
useEffect(() => {
if (renderMode !== "launcher" || !isSearchModalOpen) return;
const popupWindow = openWindowPopup("mapSearch", {
sourcePath: window.location.pathname,
});
if (!popupWindow) {
alert('팝업이 차단되었습니다. 브라우저 팝업 허용 설정을 확인해주세요.');
}
setIsSearchModalOpen(false);
}, [renderMode, isSearchModalOpen]);
const openReportTabPopup = () => {
const popupWindow = openWindowPopup("reportTab", {
sourcePath: window.location.pathname,
});
if (!popupWindow) {
alert('?앹뾽??李⑤떒?섏뿀?듬땲?? 釉뚮씪?곗? ?앹뾽 ?덉슜 ?ㅼ젙???뺤씤?댁<?몄슂.');
}
};
useEffect(() => {
if (activeTab !== 'report') {
hasAutoOpenedReportRef.current = false;
return;
}
if (hasAutoOpenedReportRef.current) return;
openReportTabPopup();
hasAutoOpenedReportRef.current = true;
}, [activeTab]);
useEffect(() => {
resetFilters();
}, [activeTab, basicType, workType]);
@@ -119,7 +161,23 @@ const SearchModal: React.FC = () => {
/>
{activeTab === 'basic' && (<BasicTab />)} {/* 기본정보 탭 */}
{activeTab === 'operation' && (<OperationTab />)} {/* 유지관리 정보 탭 */}
{activeTab === 'report' && (<ReportTab open={isSearchModalOpen} />)} {/* 하수도정비기본계획보고서 탭 */}
{activeTab === 'report' && (
<div className="mt-4 flex h-[79dvh] w-full items-center justify-center rounded-lg border bg-white p-6">
<div className="flex max-w-[520px] flex-col items-center text-center">
<h2 className="mb-3 text-2xl font-semibold text-gray-900">
하수도정비 기본계획보고서
</h2>
<p className="mb-5 text-lg leading-8 text-gray-600">
보고서 상세는 모달 내부 탭 대신
<br />
전용 윈도우 팝업으로 열리도록 전환되었습니다.
</p>
<Button className="px-6 py-2 text-white" onClick={openReportTabPopup}>
전용 팝업 열기
</Button>
</div>
</div>
)} {/* 하수도정비기본계획보고서 탭 */}
{/* 📋 결과 리스트 */}
@@ -214,22 +272,16 @@ const SearchModal: React.FC = () => {
</>
);
if (renderMode === "launcher") {
return null;
}
return (
<div className='overflow-x-auto overflow-y-hidden'>
<UiModal
open={isSearchModalOpen}
onOpenChange={setIsSearchModalOpen}
bodyClass={`
h-[100dvh]
w-[90dvw]
max-w-[1220px]
max-h-[970px]
overflow-y-hidden
z-[101]
`}
body={renderBody()}
useDirectRender={true}
/>
<div className='h-dvh overflow-x-auto overflow-y-hidden bg-white p-4'>
<h1 className="mb-3 text-2xl font-semibold text-gray-900">관망도 검색</h1>
<div className="h-[calc(100dvh-5.6rem)] w-full overflow-y-hidden rounded-lg border bg-white p-4">
{renderBody()}
</div>
<Toaster position="top-center"></Toaster>
</div>
);
@@ -31,6 +31,13 @@ import { swagePrcsZoneCqlFilters } from './swagePrcsZone';
import { swageMhlCqlFilters } from './swageMhl';
import { swagePmstnCqlFilters } from './swagePmstn';
import { stofcbrCqlFilters } from './stofcbr';
import { postPopupMessageAndClose } from '@/features/windowPopup/lib/popupMessage';
import { WINDOW_POPUP_MESSAGE_TYPE } from '@/features/windowPopup/model/types';
const isPopupWindow = () =>
typeof window !== "undefined" &&
!!window.opener &&
window.location.pathname.startsWith("/popup/");
export default function useBasicTab() {
@@ -115,9 +122,21 @@ export default function useBasicTab() {
// 기본정보 테이블 cellClick 핸들러
const handleBasicCellClick = async (index?: number | undefined, headId?: String) => {
if (typeof index === "number") {
const row = tableData[index];
if (isPopupWindow()) {
postPopupMessageAndClose(
WINDOW_POPUP_MESSAGE_TYPE.MAP_SEARCH_SELECT_FEATURE,
{
layerNm: row.lyrTypeNm,
fid: String(row.fid),
},
);
return;
}
setIsLoading(true);
const row = tableData[index];
const layerNm = row.lyrTypeNm;
const fid = row.fid;
const core = GisApp.Module.layer as any;
@@ -23,6 +23,13 @@ import { confirmToast } from '@/components/ui/toast'
import { toast } from "react-hot-toast";
import useSearchModalStore from '@/store/useSearchModalStore';
import useStore from '@/store/useStore';
import { postPopupMessageAndClose } from '@/features/windowPopup/lib/popupMessage';
import { WINDOW_POPUP_MESSAGE_TYPE } from '@/features/windowPopup/model/types';
const isPopupWindow = () =>
typeof window !== "undefined" &&
!!window.opener &&
window.location.pathname.startsWith("/popup/");
export default function useOperationTab() {
@@ -200,9 +207,21 @@ export default function useOperationTab() {
// 유지관리 정보 디테일 테이블 cellClick 핸들러
const handleOperationResultCellClick = async (index?: number | undefined, headId?: String) => {
if (typeof index === "number") {
const row = detailTableData[index];
if (isPopupWindow()) {
postPopupMessageAndClose(
WINDOW_POPUP_MESSAGE_TYPE.MAP_SEARCH_SELECT_FEATURE,
{
layerNm: row.lyrTypeNm,
fid: String(row.fid),
},
);
return;
}
setIsLoading(true);
const row = detailTableData[index];
const layerNm = row.lyrTypeNm;
const fid = row.fid;
const core = GisApp.Module.layer as any;
@@ -434,6 +453,20 @@ export default function useOperationTab() {
}
};
const emitMultipleFeaturesToMap = () => {
if (detailTableData.length === 0 || !isPopupWindow()) return false;
const row = detailTableData[0];
postPopupMessageAndClose(
WINDOW_POPUP_MESSAGE_TYPE.MAP_SEARCH_SHOW_MULTIPLE,
{
layerNm: row.lyrTypeNm,
fids: JSON.stringify(detailTableData.map((item) => item.fid)),
},
);
return true;
};
return {
WORK_TYPES,
@@ -446,7 +479,8 @@ export default function useOperationTab() {
handleOperationCellClick,
handleOperationResultCellClick,
handleOperationPageNation,
emitMultipleFeaturesToMap,
operationExcelDownload,
operationResultExcelDownload
};
};
};
@@ -19,6 +19,13 @@ import GisApp from "@common/business_components/ui/map/lib/gis.core";
import useSearchModalStore from '../../../store/useSearchModalStore';
import useBasicTab from './useBasicTab';
import useOperationTab from './useOperationTab';
import { postPopupMessageAndClose } from '@/features/windowPopup/lib/popupMessage';
import { WINDOW_POPUP_MESSAGE_TYPE } from '@/features/windowPopup/model/types';
const isPopupWindow = () =>
typeof window !== "undefined" &&
!!window.opener &&
window.location.pathname.startsWith("/popup/");
export default function useSearchModal() {
const {
@@ -132,6 +139,18 @@ export default function useSearchModal() {
const viewMultipleFeature = async () => {
if (activeTab === 'basic') {
if (!(tableData.length === 0)) {
if (isPopupWindow()) {
const row = tableData[0];
postPopupMessageAndClose(
WINDOW_POPUP_MESSAGE_TYPE.MAP_SEARCH_SHOW_MULTIPLE,
{
layerNm: row.lyrTypeNm,
fids: JSON.stringify(tableData.map((item) => item.fid)),
},
);
return;
}
setIsLoading(true);
const row = tableData[0];
@@ -144,6 +163,18 @@ export default function useSearchModal() {
}
} else if (activeTab === 'operation') {
if (!(detailTableData.length === 0)) {
if (isPopupWindow()) {
const row = detailTableData[0];
postPopupMessageAndClose(
WINDOW_POPUP_MESSAGE_TYPE.MAP_SEARCH_SHOW_MULTIPLE,
{
layerNm: row.lyrTypeNm,
fids: JSON.stringify(detailTableData.map((item) => item.fid)),
},
);
return;
}
setIsLoading(true);
const row = detailTableData[0];
+11
View File
@@ -0,0 +1,11 @@
export default function PopupLayout({
children,
}: {
children: React.ReactNode;
}) {
return (
<main className="min-h-dvh bg-white text-foreground">
{children}
</main>
);
}
@@ -0,0 +1,30 @@
import { isWindowPopupKey } from "@/features/windowPopup/model/popupDefinitions";
import { windowPopupRegistry } from "@/features/windowPopup/registry";
type PopupPageProps = {
params: Promise<{
popupKey: string;
}>;
searchParams: Promise<Record<string, string | string[] | undefined>>;
};
export default async function PopupPage({
params,
searchParams,
}: PopupPageProps) {
const { popupKey } = await params;
const resolvedSearchParams = await searchParams;
if (!isWindowPopupKey(popupKey)) {
return (
<section className="flex min-h-dvh flex-col items-center justify-center gap-4 p-8 text-center">
<h1 className="text-2xl font-semibold">등록되지 않은 팝업입니다.</h1>
<p className="text-base text-gray-600">popupKey: {popupKey}</p>
</section>
);
}
const PopupComponent = windowPopupRegistry[popupKey].component;
return <PopupComponent searchParams={resolvedSearchParams} />;
}
@@ -17,22 +17,20 @@
* - 첨부파일 삭제및 Soft Delete 삭제: DELETE /api/v1/atfl/attachments/{atflId} (attach-service)
*/
'use client'
import { deleteData, postData, useCookieAuth } from '@/components/lib/api'
import { deleteData, useCookieAuth } from '@/components/lib/api'
import { searchData } from '@/components/lib/common/apiHelpers'
import { formatDate, handleDownload } from '@/components/lib/utils'
import LoadingOverlay from '@/components/ui/LoadingBar'
import { SearchDiv, SearchFormLeft, SearchFormRight, UiTable, UiModal } from '@common/business_components/ui'
import { SearchDiv, SearchFormLeft, SearchFormRight, UiTable } from '@common/business_components/ui'
import ListDiv from '@common/business_components/ui/list_div'
import { Button, DatePicker, Input, Label } from '@common/components/ui'
import { useEffect, useState } from 'react'
import { tableHeadlist } from '..'
import OperFileUploadForm from '../../../components/ui/operFileUploadForm'
import { tokenManager } from '@/entities/auth/lib/token-manager';
import { AUTH_USER_ID } from "@/shared/config";
import { useQuery } from "@tanstack/react-query";
import { ENDPOINT } from "@common/components/config";
import { getUserInfo } from "@common/components/api";
import TableModal from './tableModal';
import { openWindowPopup } from '@/features/windowPopup/lib/openWindowPopup';
import { WINDOW_POPUP_MESSAGE_TYPE } from '@/features/windowPopup/model/types';
const Page = () => {
@@ -51,8 +49,6 @@ const Page = () => {
const [curPage, setCurPage] = useState(1) //현재 페이지
const [totalData, setTotalData] = useState(0) //총 데이터 수
const [isLoading, setIsLoading] = useState(true) //로딩 상태
const [isUploadModalOpen, setIsUploadModalOpen] = useState(false) // 등록 모달 상태
const [isTableModalOpen, setIsTableModalOpen] = useState(false) // 테이블 모달 상태
const [resetSelection, setResetSelection] = useState<boolean>(false); // 선택 초기화 상태
const [isResetPage, setIsResetPage] = useState<boolean>(false);
@@ -67,6 +63,17 @@ const Page = () => {
fetchData()
}, [])
useEffect(() => {
const handleMessage = (event: MessageEvent) => {
if (event.origin !== window.location.origin) return;
if (event.data?.type !== WINDOW_POPUP_MESSAGE_TYPE.FLOOD_PRE_UPLOAD_COMPLETED) return;
handleSearch();
};
window.addEventListener("message", handleMessage);
return () => window.removeEventListener("message", handleMessage);
}, [fromDate, toDate, pjNm, curPage]);
// 페이지 변경 시마다 데이터 fetch
useEffect(() => {
setIsResetPage(false);
@@ -140,27 +147,24 @@ const Page = () => {
}
}
// 현황 등록
const registFile = async (fileName: string, year: string, quater: string, cd: string, id: string) => {
const json = {
mngYr: year,
mngQu: quater,
atflId: id,
atflCd: cd,
rgtr: tokenManager.getToken(AUTH_USER_ID) || null,
sbmrgPrevntBizNm: fileName
}
const data = await postData('v1/sbmrgPrevnt', json)
if (data) {
alert('파일이 등록되었습니다.');
setIsUploadModalOpen(false);
handleSearch();
// 등록 버튼 클릭 핸들러
const handleRegisterClick = () => {
const popupWindow = openWindowPopup("floodPreUpload");
if (!popupWindow) {
alert('팝업이 차단되었습니다. 브라우저 팝업 허용 설정을 확인해주세요.');
}
}
// 등록 버튼 클릭 핸들러
const handleRegisterClick = () => {
setIsUploadModalOpen(true);
const handleTablePopupOpen = (fileName: string, id: string) => {
const popupWindow = openWindowPopup("floodPreTable", {
fileNm: fileName,
atflId: id,
sourcePath: window.location.pathname,
});
if (!popupWindow) {
alert('팝업이 차단되었습니다. 브라우저 팝업 허용 설정을 확인해주세요.');
}
}
@@ -246,7 +250,7 @@ const Page = () => {
setFileNm(fileName);
setAtflId(id);
setSbmrgPrevntBizSn(tableData[index]?.sbmrgPrevntBizSn);
setIsTableModalOpen(true);
handleTablePopupOpen(fileName, id);
}
}}
onPageChange={setCurPage}
@@ -254,40 +258,8 @@ const Page = () => {
resetPageNo={isResetPage}
/>
</ListDiv>
{/* 파일 업로드 모달 */}
<UiModal
open={isUploadModalOpen}
onOpenChange={setIsUploadModalOpen}
bodyClass="w-auto max-w-[90vw] min-w-[600px] h-auto"
noHeaderYn="N"
noFooterYn="Y"
body={
<OperFileUploadForm
title='도시침수 예방사업 추진현황 등록'
onSubmit={async (file, year, quater, cd, id) => {
await registFile(file, year, quater, cd, id)
}}
/>
}
/>
{/* 엑셀 데이터 테이블 모달 */}
<UiModal
open={isTableModalOpen}
onOpenChange={setIsTableModalOpen}
bodyClass={`
h-[100dvh]
w-[90dvw]
max-w-[100dvw]
max-h-[100dvh]
overflow-hidden
z-[101]
flex flex-col gap-[10px]
`}
body={<TableModal fileNm={fileNm} atflId={atflId} />}
useDirectRender={true}
/>
</>
)
}
export default Page
export default Page
@@ -55,6 +55,12 @@ const TableModal: React.FC<TableModalProps> = ({ fileNm, atflId }) => {
return false
}
if (atflId === '' || atflId === undefined) {
alert('첨부파일 ID가 없어 파일을 불러올 수 없습니다.');
setIsLoading(false);
return false;
}
const blob = await getBlobData(`/attach-service/api/v1/download/${atflId}`);
const file = new File([blob], fileNm, {
@@ -110,6 +116,8 @@ const TableModal: React.FC<TableModalProps> = ({ fileNm, atflId }) => {
const filteredResultData = filterEmptyRows(resultData);
setResultTableData(filteredResultData);
} catch (error) {
const message = error instanceof Error ? error.message : 'Unknown error';
console.error('FloodPre attachment fetch details:', { atflId, fileNm, message });
console.error('파일 처리 중 오류 발생:', error);
alert('파일 처리 중 오류가 발생했습니다.');
} finally {
@@ -17,22 +17,20 @@
* - 첨부파일 삭제및 Soft Delete 삭제: DELETE /api/v1/atfl/attachments/{atflId} (attach-service)
*/
'use client'
import { deleteData, postData, useCookieAuth } from '@/components/lib/api'
import { deleteData, useCookieAuth } from '@/components/lib/api'
import { searchData } from '@/components/lib/common/apiHelpers'
import { formatDate, handleDownload } from '@/components/lib/utils'
import LoadingOverlay from '@/components/ui/LoadingBar'
import { SearchDiv, SearchFormLeft, SearchFormRight, UiTable, UiModal } from '@common/business_components/ui'
import { SearchDiv, SearchFormLeft, SearchFormRight, UiTable } from '@common/business_components/ui'
import ListDiv from '@common/business_components/ui/list_div'
import { Button, DatePicker, Input, Label } from '@common/components/ui'
import { useEffect, useState } from 'react'
import { tableHeadlistOldPp } from '..'
import OperFileUploadForm from '../../../components/ui/operFileUploadForm'
import { tokenManager } from '@/entities/auth/lib/token-manager';
import { AUTH_USER_ID } from "@/shared/config";
import { useQuery } from "@tanstack/react-query";
import { ENDPOINT } from "@common/components/config";
import { getUserInfo } from "@common/components/api";
import TableModal from './tableModal';
import { openWindowPopup } from '@/features/windowPopup/lib/openWindowPopup';
import { WINDOW_POPUP_MESSAGE_TYPE } from '@/features/windowPopup/model/types';
const Page = () => {
@@ -49,8 +47,6 @@ const Page = () => {
const [curPage, setCurPage] = useState(1)
const [totalData, setTotalData] = useState(0) //총 데이터 수
const [isLoading, setIsLoading] = useState(true) //로딩 상태
const [isModalOpen, setIsModalOpen] = useState(false) // 등록 모달 상태
const [isTableModalOpen, setIsTableModalOpen] = useState(false) // 테이블 모달 상태
const [resetSelection, setResetSelection] = useState<boolean>(false); // 선택 초기화 상태
const [isResetPage, setIsResetPage] = useState<boolean>(false);
@@ -65,6 +61,17 @@ const Page = () => {
fetchData()
}, [])
useEffect(() => {
const handleMessage = (event: MessageEvent) => {
if (event.origin !== window.location.origin) return;
if (event.data?.type !== WINDOW_POPUP_MESSAGE_TYPE.OLD_PP_UPLOAD_COMPLETED) return;
handleSearch();
};
window.addEventListener("message", handleMessage);
return () => window.removeEventListener("message", handleMessage);
}, [fromDate, toDate, pjNm, curPage]);
// 페이지 변경 시마다 데이터 fetch
useEffect(() => {
setIsResetPage(false);
@@ -137,27 +144,25 @@ const Page = () => {
}
}
// 파일 등록
const registFile = async (fileNm: string, year: string, quater: string, cd: string, id: string) => {
const json = {
mngYr: year,
mngQu: quater,
atflCd: cd,
atflId: id,
rgtr: tokenManager.getToken(AUTH_USER_ID) || null,
dcrpPplMtncBizNm: fileNm
}
const data = await postData('v1/dcrpPpl', json);
if (data) {
alert('파일이 등록되었습니다.');
setIsModalOpen(false);
handleSearch();
// 등록 버튼 클릭 핸들러
const handleRegisterClick = () => {
const popupWindow = openWindowPopup("oldPpUpload");
if (!popupWindow) {
alert('팝업이 차단되었습니다. 브라우저 팝업 허용 설정을 확인해주세요.');
}
}
// 등록 버튼 클릭 핸들러
const handleRegisterClick = () => {
setIsModalOpen(true);
// 엑셀 데이터는 브라우저 윈도우 팝업에서 독립 조회
const handleTablePopupOpen = (fileName: string, id: string) => {
const popupWindow = openWindowPopup("oldPpTable", {
fileNm: fileName,
atflId: id,
sourcePath: window.location.pathname,
});
if (!popupWindow) {
alert('팝업이 차단되었습니다. 브라우저 팝업 허용 설정을 확인해주세요.');
}
}
@@ -242,47 +247,15 @@ const Page = () => {
setFileNm(fileName);
setAtflId(id);
setDcrpPplMtncBizSn(tableData[index]?.dcrpPplMtncBizSn);
setIsTableModalOpen(true);
handleTablePopupOpen(fileName, id);
}
}}
onPageChange={setCurPage}
resetSelection={resetSelection}
/>
</ListDiv>
{/* 파일 업로드 모달 */}
<UiModal
open={isModalOpen}
onOpenChange={setIsModalOpen}
bodyClass="w-auto max-w-[90vw] min-w-[600px] h-auto"
noHeaderYn="N"
noFooterYn="Y"
body={
<OperFileUploadForm
title='노후 하수관로 정비사업 추진현황 등록'
onSubmit={async (file, year, quater, cd, id) => {
await registFile(file, year, quater, cd, id)
}}
/>
}
/>
{/* 엑셀 데이터 테이블 모달 */}
<UiModal
open={isTableModalOpen}
onOpenChange={setIsTableModalOpen}
bodyClass={`
h-[100dvh]
w-[90dvw]
max-w-[100dvw]
max-h-[100dvh]
overflow-hidden
z-[101]
flex flex-col gap-[10px]
`}
body={<TableModal fileNm={fileNm} atflId={atflId} />}
useDirectRender={true}
/>
</>
)
}
export default Page
export default Page
@@ -64,6 +64,12 @@ const TableModal: React.FC<TableModalProps> = ({ fileNm, atflId }) => {
return false;
}
if (atflId === '' || atflId === undefined) {
alert('첨부파일 ID가 없어 파일을 불러올 수 없습니다.');
setIsLoading(false);
return false;
}
const blob = await getBlobData(`/attach-service/api/v1/download/${atflId}`);
const file = new File([blob], fileNm, {
@@ -242,6 +248,8 @@ const TableModal: React.FC<TableModalProps> = ({ fileNm, atflId }) => {
}));
setTableData6(filteredData6);
} catch (error) {
const message = error instanceof Error ? error.message : 'Unknown error';
console.error('OldPp attachment fetch details:', { atflId, fileNm, message });
console.error('파일 처리 중 오류 발생:', error);
alert('파일 처리 중 오류가 발생했습니다.');
} finally {
+9 -3
View File
@@ -153,7 +153,10 @@ export const getRawJson = async (url: string): Promise<any> => {
}
if (!res.ok) {
throw new Error('Failed to fetch data');
const errorText = await res.text().catch(() => "");
throw new Error(
`Failed to fetch data: ${res.status} ${res.statusText}${errorText ? ` - ${errorText.slice(0, 200)}` : ""}`,
);
}
const contentLength = res.headers.get('content-length');
@@ -170,7 +173,7 @@ export const getRawJson = async (url: string): Promise<any> => {
return JSON.parse(text);
} catch (error) {
console.error('JSON parsing error:', error);
throw new Error('Invalid JSON response from server');
throw new Error(`Invalid JSON response from server: ${url}`);
}
};
@@ -184,7 +187,10 @@ export const getBlobData = async (url: string): Promise<Blob> => {
}
if (!res.ok) {
throw new Error('Failed to fetch data');
const errorText = await res.text().catch(() => "");
throw new Error(
`Failed to fetch blob: ${res.status} ${res.statusText}${errorText ? ` - ${errorText.slice(0, 200)}` : ""} (${url})`,
);
}
return res.blob();
-45
View File
@@ -1,45 +0,0 @@
/**
* 📄 Modal Component
* 모달 컴포넌트
*
* - 모달 열기/닫기 상태 관리
* - 제목 및 내용 표시
* - 저장 및 닫기 버튼 지원
* - 반응형 크기 조정 (최대/최소 너비 및 높이)
* - 오버레이 및 z-index를 통한 최상위 레이어 배치
* - 접근성 지원 (aria-describedby)
*/
import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogFooter, DialogOverlay } from '@common/components/ui'
import React from 'react'
interface ModalProps {
isModalOpen: boolean;
setIsModalOpen: (open: boolean) => void;
title?: string;
children: React.ReactNode;
closeModal: () => void;
saveModal?: () => void;
description?: string;
}
const Modal: React.FC<ModalProps> = ({ isModalOpen, setIsModalOpen, title, children, closeModal, saveModal }) => {
return (
<Dialog open={isModalOpen} onOpenChange={setIsModalOpen}>
<DialogOverlay className='z-[200]' />
<DialogContent className="w-auto max-w-[1200px] min-w-[400px] min-h-[150px] h-auto max-h-[450px] overflow-auto z-[1000]" aria-describedby={undefined}>
{title ? <DialogHeader>
<DialogTitle>{title}</DialogTitle>
</DialogHeader> : null}
{children}
<DialogFooter>
<div className='flex justify-end gap-2'>
{saveModal ? <button className="px-4 py-2 bg-primary text-white rounded" onClick={saveModal}>저장</button> : null}
<button className="px-4 py-2 bg-gray-600 text-white rounded" onClick={closeModal}>닫기</button>
</div>
</DialogFooter>
</DialogContent>
</Dialog>
)
}
export default Modal
@@ -23,7 +23,6 @@
*/
'use client'
import { getData, postData } from "@/components/lib/api";
import Modal from "@/components/ui/modal";
import { VerticalTable } from "@/components/ui/verticalTable";
import { useGisContext } from "@common/business_components/ui/map/Gis";
import { Button } from "@common/components/ui";
@@ -74,7 +73,6 @@ const FeatureClickEventPopup = ({ onClose, featureData }: { onClose: () => void,
const [buttonChk, setButtonChk] = useState(true)
const [fetchData, setFetchData] = useState<any>(); // api 호출여부
const contentRef = useRef<HTMLDivElement>(null);
const [isModalOpen, setIsModalOpen] = useState(false); // 모달 열기 상태
const [isMainSsc, setIsMainSsc] = useState(false); // 중점우수토실 여부
const [isDrtwSewagePump, setIsDrtwSewagePump] = useState(false); // 오수펌프장 여부
// 🎯 InfilChart 및 RdiiChart 관련 상태 추가
@@ -459,18 +457,11 @@ const FeatureClickEventPopup = ({ onClose, featureData }: { onClose: () => void,
};
}, [featureData, gisMap]);
// 저장 시 모달 띄우기
// Use the browser confirm dialog for lightweight save confirmation.
const handleSaveClick = () => {
setIsModalOpen(true); // 모달 열기
};
const handleModalSave = () => {
setIsModalOpen(false); // 모달 닫기
btnClickEvent(); // 저장 이벤트 실행
};
const handleModalClose = () => {
setIsModalOpen(false); // 모달 닫기
const shouldSave = window.confirm('정보를 저장하시겠습니까?');
if (!shouldSave) return;
void btnClickEvent();
};
// 저장 버튼 이벤트
@@ -617,7 +608,6 @@ const FeatureClickEventPopup = ({ onClose, featureData }: { onClose: () => void,
total2={0}
isEditable2={buttonChk}
/>
<Modal isModalOpen={isModalOpen} title="자체입력정보 저장" setIsModalOpen={setIsModalOpen} closeModal={handleModalClose} saveModal={handleModalSave}><div className="text-lg">정보를 저장하시겠습니까?</div></Modal>
{ContentComponent &&
<ContentComponent
featureData={fetchData}
+18 -2
View File
@@ -12,7 +12,11 @@
* - 앱 전체의 공통 스타일 및 스크립트 적용
*/
import type { Metadata } from "next";
import { App } from "@/app";
import "@fontsource-variable/noto-sans-kr";
import { cookies } from "next/headers";
import { AppProvider } from "./providers";
import "../src/app/styles/base.css";
import "../src/app/styles/components.css";
export const metadata: Metadata = {
title: "디지털 하수관로",
@@ -24,4 +28,16 @@ export const metadata: Metadata = {
}
};
export default App;
export default async function RootLayout({
children,
}: Readonly<{ children: React.ReactNode }>) {
const theme = (await cookies()).get("site-theme-key");
return (
<html lang="ko" data-theme={theme?.value}>
<body>
<AppProvider>{children}</AppProvider>
</body>
</html>
);
}
+1
View File
@@ -0,0 +1 @@
export { AppProvider } from "../src/app/providers";
-4
View File
@@ -1,7 +1,3 @@
import "./styles/base.css";
import "./styles/components.css";
import "@fontsource-variable/noto-sans-kr";
import { AppProvider } from "./providers";
import { cookies } from "next/headers";
+14
View File
@@ -0,0 +1,14 @@
export { openWindowPopup } from "./lib/openWindowPopup";
export {
isWindowPopupKey,
windowPopupDefinitions,
type WindowPopupKey,
} from "./model/popupDefinitions";
export { windowPopupRegistry } from "./registry";
export type {
OpenWindowPopupParams,
WindowPopupContentProps,
WindowPopupMessageType,
WindowPopupSearchParams,
} from "./model/types";
export { WINDOW_POPUP_MESSAGE_TYPE } from "./model/types";
@@ -0,0 +1,61 @@
import {
isWindowPopupKey,
windowPopupDefinitions,
type WindowPopupKey,
} from "../model/popupDefinitions";
import type { OpenWindowPopupParams } from "../model/types";
const buildQueryString = (params: OpenWindowPopupParams = {}) => {
const searchParams = new URLSearchParams();
Object.entries(params).forEach(([key, value]) => {
if (value === null || value === undefined || value === "") return;
searchParams.set(key, String(value));
});
const queryString = searchParams.toString();
return queryString ? `?${queryString}` : "";
};
const buildPopupFeatures = (width: number, height: number) => {
const screenLeft = window.screenX ?? window.screenLeft ?? 0;
const screenTop = window.screenY ?? window.screenTop ?? 0;
const outerWidth = window.outerWidth || window.screen.width;
const outerHeight = window.outerHeight || window.screen.height;
const left = Math.max(0, screenLeft + Math.round((outerWidth - width) / 2));
const top = Math.max(0, screenTop + Math.round((outerHeight - height) / 2));
return [
`width=${width}`,
`height=${height}`,
`left=${left}`,
`top=${top}`,
"resizable=yes",
"scrollbars=yes",
"status=no",
"location=no",
"menubar=no",
"toolbar=no",
].join(",");
};
export const openWindowPopup = (
popupKey: WindowPopupKey,
params?: OpenWindowPopupParams,
) => {
if (typeof window === "undefined") return null;
if (!isWindowPopupKey(popupKey)) return null;
const definition = windowPopupDefinitions[popupKey];
const popupUrl = `/popup/${popupKey}${buildQueryString(params)}`;
const popupFeatures = buildPopupFeatures(definition.width, definition.height);
const popupWindow = window.open(
popupUrl,
`sn-popup-${popupKey}`,
popupFeatures,
);
popupWindow?.focus();
return popupWindow;
};
@@ -0,0 +1,24 @@
import type { WindowPopupMessageType } from "../model/types";
export const postPopupMessage = (
type: WindowPopupMessageType,
payload?: Record<string, string>,
) => {
if (typeof window === "undefined" || !window.opener) return;
window.opener.postMessage(
{
type,
payload,
},
window.location.origin,
);
};
export const postPopupMessageAndClose = (
type: WindowPopupMessageType,
payload?: Record<string, string>,
) => {
postPopupMessage(type, payload);
window.close();
};
@@ -0,0 +1,76 @@
import type { WindowPopupDefinition } from "./types";
export const windowPopupDefinitions = {
oldPpUpload: {
key: "oldPpUpload",
title: "노후 하수관로 정비사업 등록",
width: 860,
height: 360,
},
oldPpTable: {
key: "oldPpTable",
title: "노후 하수관로 정비사업 상세",
width: 1400,
height: 900,
requiredParams: ["fileNm", "atflId"],
},
floodPreUpload: {
key: "floodPreUpload",
title: "도시침수 예방사업 등록",
width: 860,
height: 360,
},
floodPreTable: {
key: "floodPreTable",
title: "도시침수 예방사업 상세",
width: 1400,
height: 900,
requiredParams: ["fileNm", "atflId"],
},
reportTab: {
key: "reportTab",
title: "하수도정비 기본계획보고서",
width: 1280,
height: 920,
},
reportFiles: {
key: "reportFiles",
title: "보고서 파일 목록",
width: 860,
height: 720,
requiredParams: ["clsfSystmSn"],
},
mapSearch: {
key: "mapSearch",
title: "관망도 검색",
width: 1280,
height: 980,
},
infilChart: {
key: "infilChart",
title: "침입수 분석",
width: 1360,
height: 980,
requiredParams: ["pplMngNo"],
},
rdiiChart: {
key: "rdiiChart",
title: "RDII 분석",
width: 1360,
height: 980,
requiredParams: ["pplMngNo"],
},
sscDetail: {
key: "sscDetail",
title: "중점우수토실 운영정보",
width: 1180,
height: 980,
requiredParams: ["chmbrXcnts", "chmbrYdnts"],
},
} as const satisfies Record<string, WindowPopupDefinition>;
export type WindowPopupKey = keyof typeof windowPopupDefinitions;
export const isWindowPopupKey = (key: string): key is WindowPopupKey => {
return key in windowPopupDefinitions;
};
@@ -0,0 +1,37 @@
import type { ComponentType } from "react";
export type WindowPopupSearchParams = Record<
string,
string | string[] | undefined
>;
export type WindowPopupContentProps = {
searchParams: WindowPopupSearchParams;
};
export type WindowPopupDefinition = {
key: string;
title: string;
width: number;
height: number;
requiredParams?: string[];
};
export type WindowPopupRegistryItem = WindowPopupDefinition & {
component: ComponentType<WindowPopupContentProps>;
};
export type OpenWindowPopupParams = Record<
string,
string | number | boolean | null | undefined
>;
export const WINDOW_POPUP_MESSAGE_TYPE = {
OLD_PP_UPLOAD_COMPLETED: "sn:popup:oldPpUploadCompleted",
FLOOD_PRE_UPLOAD_COMPLETED: "sn:popup:floodPreUploadCompleted",
MAP_SEARCH_SELECT_FEATURE: "sn:popup:mapSearchSelectFeature",
MAP_SEARCH_SHOW_MULTIPLE: "sn:popup:mapSearchShowMultiple",
} as const;
export type WindowPopupMessageType =
(typeof WINDOW_POPUP_MESSAGE_TYPE)[keyof typeof WINDOW_POPUP_MESSAGE_TYPE];
@@ -0,0 +1,55 @@
import { windowPopupDefinitions, type WindowPopupKey } from "./model/popupDefinitions";
import type { WindowPopupRegistryItem } from "./model/types";
import FloodPreTablePopupContent from "./ui/FloodPreTablePopupContent";
import FloodPreUploadPopupContent from "./ui/FloodPreUploadPopupContent";
import InfilChartPopupContent from "./ui/InfilChartPopupContent";
import OldPpUploadPopupContent from "./ui/OldPpUploadPopupContent";
import OldPpTablePopupContent from "./ui/OldPpTablePopupContent";
import RdiiChartPopupContent from "./ui/RdiiChartPopupContent";
import ReportFilesPopupContent from "./ui/ReportFilesPopupContent";
import ReportTabPopupContent from "./ui/ReportTabPopupContent";
import SearchModalPopupContent from "./ui/SearchModalPopupContent";
import SscDetailPopupContent from "./ui/SscDetailPopupContent";
export const windowPopupRegistry = {
oldPpUpload: {
...windowPopupDefinitions.oldPpUpload,
component: OldPpUploadPopupContent,
},
oldPpTable: {
...windowPopupDefinitions.oldPpTable,
component: OldPpTablePopupContent,
},
floodPreUpload: {
...windowPopupDefinitions.floodPreUpload,
component: FloodPreUploadPopupContent,
},
floodPreTable: {
...windowPopupDefinitions.floodPreTable,
component: FloodPreTablePopupContent,
},
reportTab: {
...windowPopupDefinitions.reportTab,
component: ReportTabPopupContent,
},
reportFiles: {
...windowPopupDefinitions.reportFiles,
component: ReportFilesPopupContent,
},
mapSearch: {
...windowPopupDefinitions.mapSearch,
component: SearchModalPopupContent,
},
infilChart: {
...windowPopupDefinitions.infilChart,
component: InfilChartPopupContent,
},
rdiiChart: {
...windowPopupDefinitions.rdiiChart,
component: RdiiChartPopupContent,
},
sscDetail: {
...windowPopupDefinitions.sscDetail,
component: SscDetailPopupContent,
},
} as const satisfies Record<WindowPopupKey, WindowPopupRegistryItem>;
@@ -0,0 +1,36 @@
import TableModal from "@/app/(with-base-layout)/oper/floodPre/tableModal";
import { windowPopupDefinitions } from "../model/popupDefinitions";
import type { WindowPopupContentProps } from "../model/types";
import PopupMissingParams from "./PopupMissingParams";
import { getPopupParamValue } from "./popupContentUtils";
export default function FloodPreTablePopupContent({
searchParams,
}: WindowPopupContentProps) {
const fileNm = getPopupParamValue(searchParams.fileNm);
const atflId = getPopupParamValue(searchParams.atflId);
const missingParams = [
!fileNm ? "fileNm" : "",
!atflId ? "atflId" : "",
].filter(Boolean);
if (missingParams.length > 0) {
return (
<PopupMissingParams
title={windowPopupDefinitions.floodPreTable.title}
missingParams={missingParams}
/>
);
}
return (
<div className="flex h-dvh flex-col overflow-hidden bg-white p-4">
<h1 className="mb-3 shrink-0 text-2xl font-semibold">
{windowPopupDefinitions.floodPreTable.title}
</h1>
<div className="min-h-0 flex-1 overflow-hidden">
<TableModal fileNm={fileNm} atflId={atflId} />
</div>
</div>
);
}
@@ -0,0 +1,42 @@
"use client";
import { postData } from "@/components/lib/api";
import OperFileUploadForm from "@/components/ui/operFileUploadForm";
import { tokenManager } from "@/entities/auth/lib/token-manager";
import { AUTH_USER_ID } from "@/shared/config";
import { postPopupMessageAndClose } from "../lib/popupMessage";
import { windowPopupDefinitions } from "../model/popupDefinitions";
import { WINDOW_POPUP_MESSAGE_TYPE } from "../model/types";
export default function FloodPreUploadPopupContent() {
const handleSubmit = async (
fileNm: string,
year: string,
quater: string,
cd: string,
id: string,
) => {
const data = await postData("v1/sbmrgPrevnt", {
mngYr: year,
mngQu: quater,
atflId: id,
atflCd: cd,
rgtr: tokenManager.getToken(AUTH_USER_ID) || null,
sbmrgPrevntBizNm: fileNm,
});
if (data) {
alert("파일이 등록되었습니다.");
postPopupMessageAndClose(WINDOW_POPUP_MESSAGE_TYPE.FLOOD_PRE_UPLOAD_COMPLETED);
}
};
return (
<div className="min-h-dvh bg-white p-4">
<OperFileUploadForm
title={windowPopupDefinitions.floodPreUpload.title}
onSubmit={handleSubmit}
/>
</div>
);
}
@@ -0,0 +1,22 @@
import InfilChart from "@/app/(map-layout)/map/components/popupComponent/chart/infilChart";
import { windowPopupDefinitions } from "../model/popupDefinitions";
import type { WindowPopupContentProps } from "../model/types";
import PopupMissingParams from "./PopupMissingParams";
import { getPopupParamValue } from "./popupContentUtils";
export default function InfilChartPopupContent({
searchParams,
}: WindowPopupContentProps) {
const pplMngNo = getPopupParamValue(searchParams.pplMngNo);
if (!pplMngNo) {
return (
<PopupMissingParams
title={windowPopupDefinitions.infilChart.title}
missingParams={["pplMngNo"]}
/>
);
}
return <InfilChart pplMngNo={pplMngNo} />;
}
@@ -0,0 +1,36 @@
import TableModal from "@/app/(with-base-layout)/oper/oldPp/tableModal";
import { windowPopupDefinitions } from "../model/popupDefinitions";
import type { WindowPopupContentProps } from "../model/types";
import PopupMissingParams from "./PopupMissingParams";
import { getPopupParamValue } from "./popupContentUtils";
export default function OldPpTablePopupContent({
searchParams,
}: WindowPopupContentProps) {
const fileNm = getPopupParamValue(searchParams.fileNm);
const atflId = getPopupParamValue(searchParams.atflId);
const missingParams = [
!fileNm ? "fileNm" : "",
!atflId ? "atflId" : "",
].filter(Boolean);
if (missingParams.length > 0) {
return (
<PopupMissingParams
title={windowPopupDefinitions.oldPpTable.title}
missingParams={missingParams}
/>
);
}
return (
<div className="flex h-dvh flex-col overflow-hidden bg-white p-4">
<h1 className="mb-3 shrink-0 text-2xl font-semibold">
{windowPopupDefinitions.oldPpTable.title}
</h1>
<div className="min-h-0 flex-1 overflow-hidden">
<TableModal fileNm={fileNm} atflId={atflId} />
</div>
</div>
);
}
@@ -0,0 +1,42 @@
"use client";
import { postData } from "@/components/lib/api";
import OperFileUploadForm from "@/components/ui/operFileUploadForm";
import { tokenManager } from "@/entities/auth/lib/token-manager";
import { AUTH_USER_ID } from "@/shared/config";
import { postPopupMessageAndClose } from "../lib/popupMessage";
import { windowPopupDefinitions } from "../model/popupDefinitions";
import { WINDOW_POPUP_MESSAGE_TYPE } from "../model/types";
export default function OldPpUploadPopupContent() {
const handleSubmit = async (
fileNm: string,
year: string,
quater: string,
cd: string,
id: string,
) => {
const data = await postData("v1/dcrpPpl", {
mngYr: year,
mngQu: quater,
atflCd: cd,
atflId: id,
rgtr: tokenManager.getToken(AUTH_USER_ID) || null,
dcrpPplMtncBizNm: fileNm,
});
if (data) {
alert("파일이 등록되었습니다.");
postPopupMessageAndClose(WINDOW_POPUP_MESSAGE_TYPE.OLD_PP_UPLOAD_COMPLETED);
}
};
return (
<div className="min-h-dvh bg-white p-4">
<OperFileUploadForm
title={windowPopupDefinitions.oldPpUpload.title}
onSubmit={handleSubmit}
/>
</div>
);
}
@@ -0,0 +1,19 @@
export default function PopupMissingParams({
title,
missingParams,
}: {
title: string;
missingParams: string[];
}) {
return (
<section className="flex min-h-dvh flex-col items-center justify-center gap-4 p-8 text-center">
<h1 className="text-2xl font-semibold">{title}</h1>
<p className="text-base text-gray-600">
팝업을 여는 데 필요한 값이 누락되었습니다.
</p>
<p className="text-sm text-gray-500">
누락된 값: {missingParams.join(", ")}
</p>
</section>
);
}
@@ -0,0 +1,22 @@
import RdiiChart from "@/app/(map-layout)/map/components/popupComponent/chart/rdiiChart";
import { windowPopupDefinitions } from "../model/popupDefinitions";
import type { WindowPopupContentProps } from "../model/types";
import PopupMissingParams from "./PopupMissingParams";
import { getPopupParamValue } from "./popupContentUtils";
export default function RdiiChartPopupContent({
searchParams,
}: WindowPopupContentProps) {
const pplMngNo = getPopupParamValue(searchParams.pplMngNo);
if (!pplMngNo) {
return (
<PopupMissingParams
title={windowPopupDefinitions.rdiiChart.title}
missingParams={["pplMngNo"]}
/>
);
}
return <RdiiChart pplMngNo={pplMngNo} />;
}
@@ -0,0 +1,121 @@
"use client";
import { useEffect, useState } from "react";
import LoadingOverlay from "@/components/ui/LoadingBar";
import { getDcData } from "@/components/lib/api";
import { UiTable } from "@common/business_components/ui";
import { windowPopupDefinitions } from "../model/popupDefinitions";
import type { WindowPopupContentProps } from "../model/types";
import PopupMissingParams from "./PopupMissingParams";
import { getPopupParamValue } from "./popupContentUtils";
type ReportDataFile = {
no?: number;
fileTitle: string;
regDt: string;
fileSn: string;
filePath: string;
};
const REPORT_FILES_HEADLIST = [
{ id: "no", title: "NO" },
{ id: "fileTitle", title: "파일명" },
{ id: "regDt", title: "등록일자" },
];
export default function ReportFilesPopupContent({
searchParams,
}: WindowPopupContentProps) {
const clsfSystmSn = getPopupParamValue(searchParams.clsfSystmSn);
const [files, setFiles] = useState<ReportDataFile[]>([]);
const [allFiles, setAllFiles] = useState<ReportDataFile[]>([]);
const [curPage, setCurPage] = useState(1);
const [isLoading, setIsLoading] = useState(false);
useEffect(() => {
if (!clsfSystmSn) return;
const fetchFiles = async () => {
setIsLoading(true);
try {
const result = await getDcData(`/api/v1/file?clsfSystmSn=${clsfSystmSn}`);
const normalized = (result ?? []).map((item: ReportDataFile, index: number) => ({
...item,
no: index + 1,
}));
setAllFiles(normalized);
} catch (error) {
console.error("보고서 파일 목록 조회 실패:", error);
setAllFiles([]);
} finally {
setIsLoading(false);
}
};
fetchFiles();
}, [clsfSystmSn]);
useEffect(() => {
const paged = allFiles.slice((curPage - 1) * 10, curPage * 10);
setFiles(paged);
}, [allFiles, curPage]);
const handleDownload = async (fileSn: string, fileTitle: string, filePath: string) => {
if (!filePath) {
alert("filePath 정보가 없으므로 다운로드가 불가합니다.");
return;
}
try {
const responseBlob = await getDcData(
`/api/v1/fileDownload?filePath=${encodeURIComponent(filePath)}`,
"blob",
);
const objectUrl = URL.createObjectURL(responseBlob);
const anchor = document.createElement("a");
anchor.href = objectUrl;
anchor.download = fileTitle;
document.body.appendChild(anchor);
anchor.click();
anchor.remove();
URL.revokeObjectURL(objectUrl);
} catch (error) {
console.error("보고서 파일 다운로드 실패:", error);
alert("파일 다운로드 중 오류가 발생했습니다.");
}
};
if (!clsfSystmSn) {
return (
<PopupMissingParams
title={windowPopupDefinitions.reportFiles.title}
missingParams={["clsfSystmSn"]}
/>
);
}
return (
<div className="flex h-dvh flex-col overflow-hidden bg-white p-4">
<h1 className="mb-3 shrink-0 text-2xl font-semibold">
{windowPopupDefinitions.reportFiles.title}
</h1>
<div className="min-h-0 flex-1 overflow-auto">
<LoadingOverlay isLoading={isLoading} />
<UiTable
headName={`조회 결과 (${allFiles.length.toLocaleString("ko-KR")})건`}
tableData={files}
headlist={REPORT_FILES_HEADLIST}
pageSize={10}
total={allFiles.length}
onPageChange={setCurPage}
cellClick={(index) => {
if (typeof index !== "number" || !files[index]) return;
const target = files[index];
handleDownload(target.fileSn, target.fileTitle, target.filePath);
}}
/>
</div>
</div>
);
}
@@ -0,0 +1,27 @@
import ReportTab from "@/app/(map-layout)/map/searchModal/reportTab";
import { windowPopupDefinitions } from "../model/popupDefinitions";
import type { WindowPopupContentProps } from "../model/types";
import { getPopupParamValue } from "./popupContentUtils";
export default function ReportTabPopupContent({
searchParams,
}: WindowPopupContentProps) {
const ctpvNm = getPopupParamValue(searchParams.ctpvNm);
const sggNm = getPopupParamValue(searchParams.sggNm);
return (
<div className="flex h-dvh flex-col overflow-hidden bg-white p-4">
<h1 className="mb-3 shrink-0 text-2xl font-semibold">
{windowPopupDefinitions.reportTab.title}
</h1>
<div className="min-h-0 flex-1 overflow-hidden">
<ReportTab
open={true}
isSearch={false}
ctpvNm={ctpvNm || undefined}
sggNm={sggNm || undefined}
/>
</div>
</div>
);
}
@@ -0,0 +1,5 @@
import SearchModal from "@/app/(map-layout)/map/searchModal/searchModal";
export default function SearchModalPopupContent() {
return <SearchModal renderMode="popup" />;
}
@@ -0,0 +1,36 @@
import Ssc from "@/app/(map-layout)/map/components/popupComponent/Ssc";
import { windowPopupDefinitions } from "../model/popupDefinitions";
import type { WindowPopupContentProps } from "../model/types";
import PopupMissingParams from "./PopupMissingParams";
import { getPopupParamValue } from "./popupContentUtils";
export default function SscDetailPopupContent({
searchParams,
}: WindowPopupContentProps) {
const chmbrXcnts = getPopupParamValue(searchParams.chmbrXcnts);
const chmbrYdnts = getPopupParamValue(searchParams.chmbrYdnts);
const missingParams = [
!chmbrXcnts ? "chmbrXcnts" : "",
!chmbrYdnts ? "chmbrYdnts" : "",
].filter(Boolean);
if (missingParams.length > 0) {
return (
<PopupMissingParams
title={windowPopupDefinitions.sscDetail.title}
missingParams={missingParams}
/>
);
}
return (
<Ssc
featureData={null}
isMainSsc={true}
chmbrXcnts={chmbrXcnts}
chmbrYdnts={chmbrYdnts}
renderMode="popup"
/>
);
}
@@ -0,0 +1,6 @@
export const getPopupParamValue = (
value: string | string[] | undefined,
): string => {
if (Array.isArray(value)) return value[0] ?? "";
return value ?? "";
};
+74 -74
View File
@@ -1,76 +1,76 @@
{
"name": "st",
"version": "0.1.0",
"private": true,
"scripts": {
"dev": "next dev",
"build": "next build",
"start": "next start",
"lint": "next lint"
},
"dependencies": {
"@common/assets": "workspace:*",
"@common/auth": "workspace:*",
"@common/components": "workspace:*",
"@common/config": "workspace:*",
"@common/network": "workspace:*",
"@rsone/components": "workspace:*",
"@faker-js/faker": "^9.9.0",
"@fontsource-variable/noto-sans-kr": "^5.2.5",
"@frontend-opensource/use-react-hooks": "^1.1.4",
"@fullcalendar/core": "^6.1.17",
"@fullcalendar/daygrid": "^6.1.17",
"@fullcalendar/interaction": "^6.1.17",
"@fullcalendar/react": "^6.1.17",
"@fullcalendar/timegrid": "^6.1.17",
"@hookform/resolvers": "^4.1.3",
"@minoru/react-dnd-treeview": "3.5.1-alpha.1",
"@radix-ui/react-icons": "^1.3.2",
"@radix-ui/react-label": "^2.1.2",
"@radix-ui/react-slot": "^1.1.2",
"@radix-ui/react-tooltip": "^1.1.8",
"@tanstack/react-query": "^5.67.1",
"@tanstack/react-query-devtools": "^5.75.5",
"@tanstack/react-table": "^8.21.2",
"@tinymce/tinymce-react": "^5.1.1",
"@types/xlsx": "^0.0.36",
"axios": "^1.8.1",
"chart.js": "^4.4.8",
"chartjs-plugin-zoom": "^2.2.0",
"dayjs": "^1.11.13",
"js-cookie": "^3.0.5",
"jszip": "^3.10.1",
"lucide-react": "^0.474.0",
"next": "15.3.8",
"next-qrcode": "^2.5.1",
"next-themes": "^0.4.6",
"react-chartjs-2": "^5.3.0",
"react-dnd": "^16.0.1",
"react-hook-form": "^7.54.2",
"realgrid": "^2.9.5",
"realgrid-react": "^0.9.7",
"remixicon": "^4.7.0",
"sonner": "^2.0.1",
"swr": "^2.3.7",
"tailwindcss-animate": "^1.0.7",
"tus-js-client": "^4.3.1",
"xlsx": "^0.18.5",
"zod": "^3.24.2",
"zustand": "^5.0.3"
},
"devDependencies": {
"@eslint/eslintrc": "^3",
"@types/js-cookie": "^3.0.6",
"@types/node": "^22.13.1",
"@types/webpack": "^5.28.5",
"eslint": "^9",
"eslint-config-next": "15.3.8",
"eslint-plugin-react-hooks": "^5.0.0",
"postcss": "^8",
"prettier": "^3.4.2",
"prettier-plugin-tailwindcss": "^0.6.11",
"steiger": "^0.5.5",
"tailwindcss": "^3.4.1",
"typescript": "^5"
}
"name": "st",
"version": "0.1.0",
"private": true,
"scripts": {
"dev": "next dev",
"build": "next build",
"start": "next start",
"lint": "next lint"
},
"dependencies": {
"@common/assets": "workspace:*",
"@common/auth": "workspace:*",
"@common/components": "workspace:*",
"@common/config": "workspace:*",
"@common/network": "workspace:*",
"@rsone/components": "workspace:*",
"@faker-js/faker": "^9.9.0",
"@fontsource-variable/noto-sans-kr": "^5.2.5",
"@frontend-opensource/use-react-hooks": "^1.1.4",
"@fullcalendar/core": "^6.1.17",
"@fullcalendar/daygrid": "^6.1.17",
"@fullcalendar/interaction": "^6.1.17",
"@fullcalendar/react": "^6.1.17",
"@fullcalendar/timegrid": "^6.1.17",
"@hookform/resolvers": "^4.1.3",
"@minoru/react-dnd-treeview": "3.5.1-alpha.1",
"@radix-ui/react-icons": "^1.3.2",
"@radix-ui/react-label": "^2.1.2",
"@radix-ui/react-slot": "^1.1.2",
"@radix-ui/react-tooltip": "^1.1.8",
"@tanstack/react-query": "^5.67.1",
"@tanstack/react-query-devtools": "^5.75.5",
"@tanstack/react-table": "^8.21.2",
"@tinymce/tinymce-react": "^5.1.1",
"@types/xlsx": "^0.0.36",
"axios": "^1.8.1",
"chart.js": "^4.4.8",
"chartjs-plugin-zoom": "^2.2.0",
"dayjs": "^1.11.13",
"js-cookie": "^3.0.5",
"jszip": "^3.10.1",
"lucide-react": "^0.474.0",
"next": "15.3.8",
"next-qrcode": "^2.5.1",
"next-themes": "^0.4.6",
"react-chartjs-2": "^5.3.0",
"react-dnd": "^16.0.1",
"react-hook-form": "^7.54.2",
"realgrid": "^2.9.5",
"realgrid-react": "^0.9.7",
"remixicon": "^4.7.0",
"sonner": "^2.0.1",
"swr": "^2.3.7",
"tailwindcss-animate": "^1.0.7",
"tus-js-client": "^4.3.1",
"xlsx": "^0.18.5",
"zod": "^3.24.2",
"zustand": "^5.0.3"
},
"devDependencies": {
"@eslint/eslintrc": "^3",
"@types/js-cookie": "^3.0.6",
"@types/node": "^22.13.1",
"@types/webpack": "^5.28.5",
"eslint": "^9",
"eslint-config-next": "15.3.8",
"eslint-plugin-react-hooks": "^5.0.0",
"postcss": "^8",
"prettier": "^3.4.2",
"prettier-plugin-tailwindcss": "^0.6.11",
"steiger": "^0.5.5",
"tailwindcss": "^3.4.1",
"typescript": "^5"
}
}
@@ -12,6 +12,7 @@ import { AlertDialog } from "@/shared/ui/alert-dialog";
import { PromptDialog } from "@/shared/ui/prompt-dialog";
import { ConfirmDialog } from "@/shared/ui/confirm-dialog";
import { SelectPromptDialog } from "@/shared/ui/select-prompt-dialog";
import { GlobalProgressOverlay } from "@/shared/ui/global-progress-overlay";
import { PromptProvider, StoreProvider } from "@/shared/providers";
import { AlertDialog as RsoneAlertDialog } from "@rsone/components";
@@ -36,6 +37,7 @@ const AppProvider = ({ children }: Readonly<{ children: React.ReactNode }>) => {
<PromptDialog />
<ConfirmDialog />
<SelectPromptDialog />
<GlobalProgressOverlay />
<Toaster position="top-center" />
</AuthWrapper>
</ConfirmProvider>
@@ -1,7 +1,7 @@
import { Button } from "@common/components";
interface ReviewOpinionButtonProps {
reviewOpinionFunc: () => void;
reviewOpinionFunc: () => void | Promise<void>;
}
const ReviewOpinionButton = ({ reviewOpinionFunc }: ReviewOpinionButtonProps) => {
@@ -0,0 +1,16 @@
import { Button } from "@common/components";
interface VerificationResultButtonProps {
VerificationResultButtonFunc: () => void;
}
const VerificationResultButton = ({ VerificationResultButtonFunc }: VerificationResultButtonProps) => {
return (
<Button color="white" className="h-[3.2rem]" onClick={VerificationResultButtonFunc}>
<i className="diveicon di-list" />
<span>지난 검증 결과</span>
</Button>
);
};
export default VerificationResultButton;
+6 -6
View File
@@ -16,12 +16,6 @@
@apply !bg-[#5987bf] !text-[#fff];
}
.rg-grid .rg-header .rg-table tr td,
.rg-grid .rg-head .rg-table tr td {
border: none;
border-bottom: 1px solid #e5e7eb;
}
.rg-grid .rg-body .rg-table .rg-data-row .rg-data-cell {
@apply !bg-white;
}
@@ -138,4 +132,10 @@
.rg-grid .rg-body .rg-table tr.rg-alternate-row td,
.rg-grid .rg-body .rg-table tr.rg-alternate-row .rg-data-cell {
background-color: #ffffff !important;
}
.rg-grid .rg-header .rg-table tr td,
.rg-grid .rg-head .rg-table tr td {
border: none;
border-bottom: 1px solid #e5e7eb;
}
+144 -54
View File
@@ -311,6 +311,7 @@ function buildFieldsFromColumns(
const DataGrid = ({
columnInfo,
rowStartNum,
excelExportFileSuffix,
isExcelImport = true,
isExcelExport = true,
fixedColumns,
@@ -318,13 +319,19 @@ const DataGrid = ({
rowData,
setRowData,
getGridDataRef,
getGridRef,
cellStyleCallback,
cellTooltipCallback,
onCellEdited,
onRowClick,
onItemChecked,
onItemAllChecked,
onScrollToBottom,
statusInfo,
highlightedRowId,
idFieldName = "id",
enableSort = false,
gridHeight = 65, // vh 단위
}: DataGridProps) => {
const { columns, columnLayout } = columnInfo;
const gridRef = useRef<RealGridReact>(null);
@@ -340,9 +347,20 @@ const DataGrid = ({
const cellTooltipCallbackRef = useRef<CellTooltipCallback | undefined>(
cellTooltipCallback,
);
const onCellEditedRef = useRef<typeof onCellEdited>(onCellEdited);
const onScrollToBottomRef = useRef<(() => void) | undefined>(
onScrollToBottom,
);
const onItemCheckedRef = useRef<typeof onItemChecked>(onItemChecked);
const onItemAllCheckedRef =
useRef<typeof onItemAllChecked>(onItemAllChecked);
rowDataRef.current = rowData;
cellStyleCallbackRef.current = cellStyleCallback;
cellTooltipCallbackRef.current = cellTooltipCallback;
onScrollToBottomRef.current = onScrollToBottom;
onItemCheckedRef.current = onItemChecked;
onItemAllCheckedRef.current = onItemAllChecked;
onCellEditedRef.current = onCellEdited;
// highlightedRowId가 변경될 때 해당 행으로 스크롤 및 선택
useEffect(() => {
@@ -470,10 +488,14 @@ const DataGrid = ({
gridView.setColumnLayout?.(normalizedLayout);
}
const exportBaseName = excelExportFileSuffix
? `${menuName}_${excelExportFileSuffix}`
: menuName;
gridView.exportGrid({
type: "excel",
target: "local",
fileName: `${menuName}.xlsx`,
fileName: `${exportBaseName}.xlsx`,
footer: "hidden",
allColumns: true, // 숨김(visible: false) 컬럼도 엑셀에 포함
done: () => {
@@ -558,10 +580,10 @@ const DataGrid = ({
) {
const cell =
worksheet[
XLSX.utils.encode_cell({
r: rowIndex,
c: col,
})
XLSX.utils.encode_cell({
r: rowIndex,
c: col,
})
];
if (
cell &&
@@ -637,7 +659,7 @@ const DataGrid = ({
const col = range.s.c + colIndex;
const cell =
worksheet[
XLSX.utils.encode_cell({ r: row, c: col })
XLSX.utils.encode_cell({ r: row, c: col })
];
const headerName =
headers[colIndex] || `Column${colIndex + 1}`;
@@ -761,13 +783,13 @@ const DataGrid = ({
onSorting?: (g: unknown, f: unknown[], d: unknown[]) => boolean;
}
).onSorting = enableSort
? createOnSortingHandler(
columns,
setRowData,
sortDirRef,
originalRowsRef,
)
: () => false;
? createOnSortingHandler(
columns,
setRowData,
sortDirRef,
originalRowsRef,
)
: () => false;
(gridView.displayOptions as DisplayOptions).fitStyle =
GridFitStyle.EVEN;
@@ -937,7 +959,7 @@ const DataGrid = ({
) {
(
column.footer[
index
index
] as FooterItemWithCallback
).valueCallback =
footerItem.valueCallback;
@@ -990,11 +1012,11 @@ const DataGrid = ({
getGridDataRef.current = () => {
const gv = gridRef.current?.gridView as
| {
getDataSource?: () => {
getRowCount(): number;
getValue(r: number, f: string): unknown;
};
}
getDataSource?: () => {
getRowCount(): number;
getValue(r: number, f: string): unknown;
};
}
| undefined;
if (!gv?.getDataSource) return [];
const ds = gv.getDataSource();
@@ -1090,6 +1112,74 @@ const DataGrid = ({
};
}, [onRowClick, cellTooltipCallback, columns]);
useEffect(() => {
const gridView = gridRef.current?.gridView;
if (!gridView || !onScrollToBottom) return;
gridView.onScrollToBottom = () => {
onScrollToBottomRef.current?.();
};
return () => {
if (gridView.onScrollToBottom)
gridView.onScrollToBottom = undefined;
};
}, [onScrollToBottom]);
useEffect(() => {
const gridView = gridRef.current?.gridView;
if (!gridView) return;
if (onItemChecked) {
gridView.onItemChecked = (_grid, itemIndex, checked) => {
onItemCheckedRef.current?.(itemIndex, checked);
};
} else {
gridView.onItemChecked = undefined;
}
if (onItemAllChecked) {
gridView.onItemAllChecked = (_grid, checked) => {
onItemAllCheckedRef.current?.(checked);
};
} else {
gridView.onItemAllChecked = undefined;
}
return () => {
if (gridView.onItemChecked) gridView.onItemChecked = undefined;
if (gridView.onItemAllChecked)
gridView.onItemAllChecked = undefined;
};
}, [onItemChecked, onItemAllChecked]);
useEffect(() => {
const gridView = gridRef.current?.gridView;
if (!gridView) return;
gridView.onCellEdited = (grid, itemIndex, _row, field) => {
const fieldName = columns[field]?.fieldName;
if (!fieldName) return;
const newValue = grid.getValue(itemIndex, fieldName);
const prevRow = rowDataRef.current[itemIndex];
const oldValue = prevRow?.[fieldName] ?? "";
onCellEditedRef.current?.({
itemIndex,
fieldName,
oldValue,
newValue,
});
};
return () => {
if (gridView.onCellEdited) {
gridView.onCellEdited = undefined;
}
};
}, [columns]);
return (
<div className="mx-[0.6rem] flex flex-col gap-[0.6rem]">
<input
@@ -1105,23 +1195,23 @@ const DataGrid = ({
<span>
진행상황:{" "}
{statusInfo.laeSbmsnSttsCd === null ||
statusInfo.laeSbmsnSttsCd === "0"
statusInfo.laeSbmsnSttsCd === "0"
? "미작성"
: statusInfo.laeSbmsnSttsCd === "1"
? "작성중"
: statusInfo.laeSbmsnSttsCd === "2"
? "제출"
: statusInfo.laeSbmsnSttsCd === "3"
? "승인"
: statusInfo.laeSbmsnSttsCd === "4"
? "반려"
: "검증중"}
? "작성중"
: statusInfo.laeSbmsnSttsCd === "2"
? "제출"
: statusInfo.laeSbmsnSttsCd === "3"
? "승인"
: statusInfo.laeSbmsnSttsCd === "4"
? "반려"
: "검증중"}
</span>
<span>
제출일:{" "}
{statusInfo.laeSbmsnYmd
? statusInfo.laeSbmsnYmd.length === 8 &&
/^\d{8}$/.test(statusInfo.laeSbmsnYmd)
/^\d{8}$/.test(statusInfo.laeSbmsnYmd)
? `${statusInfo.laeSbmsnYmd.slice(0, 4)}-${statusInfo.laeSbmsnYmd.slice(4, 6)}-${statusInfo.laeSbmsnYmd.slice(6, 8)}`
: statusInfo.laeSbmsnYmd
: "미제출"}
@@ -1159,7 +1249,7 @@ const DataGrid = ({
</Button>
)}
</div>
<div className="h-[65vh]">
<div style={{ height: `${gridHeight}vh` }}>
<RealGridReact
ref={gridRef}
autoGenerateField={true}
@@ -1176,33 +1266,33 @@ const DataGrid = ({
) => boolean;
}
).onSorting = enableSort
? createOnSortingHandler(
columns,
setRowData,
sortDirRef,
originalRowsRef,
)
: () => false;
? createOnSortingHandler(
columns,
setRowData,
sortDirRef,
originalRowsRef,
)
: () => false;
const dataProvider = gridRef.current
?.dataProvider as unknown as
| {
setFields?(f: unknown[]): void;
setRows?(
r: Record<string, string | number>[],
): void;
getJsonRow?(
r: number,
): Record<string, unknown>;
}
setFields?(f: unknown[]): void;
setRows?(
r: Record<string, string | number>[],
): void;
getJsonRow?(
r: number,
): Record<string, unknown>;
}
| undefined;
if (dataProvider?.setFields) {
dataProvider.setFields(
buildFieldsFromColumns(columns),
);
}
if (dataProvider?.setRows && rowData?.length) {
if (dataProvider?.setRows)
dataProvider.setRows(rowData);
}
if (getGridRef) getGridRef.current = gridRef.current;
const gridView = gridRef.current?.gridView ?? grid;
if (!gridView?.columnByName) return;
setTimeout(() => {
@@ -1346,14 +1436,14 @@ const DataGrid = ({
})}
footer={
column.footer &&
!Array.isArray(column.footer)
!Array.isArray(column.footer)
? {
text: column.footer.text,
expression:
column.footer.expression,
numberFormat:
column.footer.numberFormat,
}
text: column.footer.text,
expression:
column.footer.expression,
numberFormat:
column.footer.numberFormat,
}
: undefined
}
mergeRule={
+50 -30
View File
@@ -1,52 +1,66 @@
"use client";
import { useState, useEffect } from "react";
import { useEffect, useState } from "react";
import useSWR from "swr";
import {
Button,
Input,
Label,
Select,
SelectTrigger,
SelectContent,
SelectItem,
SelectValue,
Checkbox,
} from "@common/components";
import { useAlertStore } from "@/shared/lib";
import {
type SearchFormProps,
type SearchFormValues,
defaultSearchFormProps,
} from "../type/SearchForm.type";
import { statisticsAPi } from "@/entities/statistics/api/statistics-service";
import { useAlertStore, useUserStore } from "@/shared/lib";
import { statisticsApi } from "@/entities/statistics/api/statistics-service";
import type {
ApiResponse,
SearchCondition,
Sgg,
} from "@/entities/statistics/model/statistics-interface";
import {
Button,
Checkbox,
Input,
Label,
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from "@common/components";
const getSidoListFetcher = async (): Promise<ApiResponse<SearchCondition>> => {
return statisticsAPi.getSidoList();
import {
defaultSearchFormProps,
type SearchFormProps,
type SearchFormValues,
} from "../type/SearchForm.type";
import { doList } from "../util/column/input/DoList";
const getSidoListFetcher = async (
key: [string, string],
): Promise<ApiResponse<SearchCondition>> => {
return statisticsApi.getSidoList({ instSeCd: key[1] });
};
const getSggListFetcher = async (
key: [string, string],
key: [string, string, string, string],
): Promise<ApiResponse<Sgg[]>> => {
return statisticsAPi.getSggList(key[1]);
const gugunSkipYn = doList.includes(key[3]) ? "N" : "Y";
return statisticsApi.getSggAllList(key[1], {
instSeCd: key[2],
gugunSkipYn,
});
};
const SearchForm = (props: SearchFormProps = {}) => {
const { setMessage: alert } = useAlertStore((state) => state);
const userInfo = useUserStore((state) => state.userInfo);
const mergedProps = { ...defaultSearchFormProps, ...props };
const instSeCd = userInfo?.instSeCd ?? "";
const sidoSelectText =
props.sidoSelectText && props.sidoSelectText.trim() !== ""
? props.sidoSelectText
: "선택";
const currentYear = new Date().getFullYear();
const defaultYear = currentYear - 1;
const { data: sidoList } = useSWR<ApiResponse<SearchCondition>>(
"sido-list",
["sido-list", instSeCd],
getSidoListFetcher,
{
revalidateOnFocus: false,
@@ -77,8 +91,10 @@ const SearchForm = (props: SearchFormProps = {}) => {
isDetail: false,
});
const sggKey: [string, string] | null =
values.sido && values.sido !== "ALL" ? ["sgg-list", values.sido] : null;
const sggKey: [string, string, string, string] | null =
values.sido && values.sido !== "ALL"
? ["sgg-list", values.sido, instSeCd, values.bsnmCd || ""]
: null;
const { data: sggList } = useSWR<ApiResponse<Sgg[]>>(
sggKey,
getSggListFetcher,
@@ -102,7 +118,7 @@ const SearchForm = (props: SearchFormProps = {}) => {
);
const sggOptions = sggList?.data ?? [];
const handleSubmit = (e: React.SubmitEvent<HTMLFormElement>) => {
const handleSubmit = (e: React.FormEvent<HTMLFormElement>) => {
e.preventDefault();
props.onSubmit?.(values);
};
@@ -163,10 +179,14 @@ const SearchForm = (props: SearchFormProps = {}) => {
onValueChange={handleSidoChange}
>
<SelectTrigger className="w-[12rem]">
<SelectValue placeholder="선택"></SelectValue>
<SelectValue
placeholder={sidoSelectText}
></SelectValue>
</SelectTrigger>
<SelectContent>
<SelectItem value="ALL">선택</SelectItem>
<SelectItem value="ALL">
{sidoSelectText}
</SelectItem>
{sidoList?.data?.sidoList?.map((sido) => (
<SelectItem
key={sido.adstDstrCd}
@@ -19,7 +19,7 @@ import CompactDataGrid from "./CompactDataGrid";
import SaveButton from "../button/SaveButton";
import { useAlertStore } from "@/shared/lib";
import { statisticsAPi } from "@/entities/statistics/api/statistics-service";
import { statisticsApi } from "@/entities/statistics/api/statistics-service";
import type {
ApiResponse,
SearchCondition,
@@ -91,13 +91,13 @@ const adminRegionColumnInfo: ColumnInfo = {
};
const getSidoListFetcher = async (): Promise<ApiResponse<SearchCondition>> => {
return statisticsAPi.getSidoList();
return statisticsApi.getSidoList();
};
const getSggAllListFetcher = async (
key: [string, string],
): Promise<ApiResponse<Sgg[]>> => {
return statisticsAPi.getSggAllList(key[1]);
return statisticsApi.getSggAllList(key[1]);
};
type SidoSggFacilityNameSearchFormProps = {
@@ -0,0 +1,40 @@
"use client";
import {
Button,
Dialog,
DialogClose,
DialogContent,
DialogFooter,
DialogHeader,
DialogTitle,
} from "@common/components";
export interface VerificationResultDialogProps {
open: boolean;
onOpenChange: (open: boolean) => void;
}
export function VerificationResultDialog({
open,
onOpenChange,
}: VerificationResultDialogProps) {
return (
<Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent className="max-w-[640px]" aria-describedby={undefined}>
<DialogHeader>
<DialogTitle>검증 결과</DialogTitle>
</DialogHeader>
<div className="min-h-[120px]" />
<DialogFooter>
<DialogClose asChild>
<Button type="button" color="gray">
닫기
</Button>
</DialogClose>
</DialogFooter>
</DialogContent>
</Dialog>
);
}
@@ -0,0 +1,276 @@
"use client";
import { type Dispatch, type SetStateAction, useCallback, useEffect, useMemo, useState } from "react";
import {
Button,
Dialog,
DialogClose,
DialogContent,
DialogFooter,
DialogHeader,
DialogTitle,
LoadingWithAnimation,
} from "@common/components";
import DataGrid from "@/components/module/DataGrid";
import { VerificationResultHistoryPagination } from "@/components/module/VerificationResultHistoryPagination";
import type { ColumnInfo } from "@/components/type/DataGrid.type";
import { statisticsApi } from "@/entities/statistics/api/statistics-service";
import type { VerificationResultListRequest, VerificationResultReportItem } from "@/entities/statistics/model/statistics-interface";
type VerificationResultGridRow = Record<string, string | number>;
const PRCS_YN_LABEL: Record<string, string> = {
"0": "미통과",
"1": "통과",
"2": "예외처리",
"3": "반려",
};
const verificationResultColumnInfo: ColumnInfo = {
columns: [
{ name: "ctpvNm", fieldName: "ctpvNm", header: { text: "시도" }, editable: false },
{ name: "sggNm", fieldName: "sggNm", header: { text: "시군구" }, editable: false },
{ name: "vrfcTrgtNm", fieldName: "vrfcTrgtNm", header: { text: "검증대상" }, editable: false },
{ name: "rsnExpln", fieldName: "rsnExpln", header: { text: "사유" }, editable: false },
{ name: "nowVlCn", fieldName: "nowVlCn", header: { text: "입력값" }, editable: false },
{ name: "compVlCn", fieldName: "compVlCn", header: { text: "기준값" }, editable: false },
{ name: "prcsYn", fieldName: "prcsYn", header: { text: "처리여부" }, editable: false },
{ name: "pbcpAprvYn", fieldName: "pbcpAprvYn", header: { text: "공단승인여부" }, editable: false },
{ name: "regDt", fieldName: "regDt", header: { text: "등록일시" }, editable: false },
{ name: "mdfcnDt", fieldName: "mdfcnDt", header: { text: "갱신일시" }, editable: false },
],
columnLayout: ["ctpvNm", "sggNm", "vrfcTrgtNm", "rsnExpln", "nowVlCn", "compVlCn", "prcsYn", "pbcpAprvYn", "regDt", "mdfcnDt"],
};
export interface VerificationResultHistoryDialogProps {
open: boolean;
onOpenChange: (open: boolean) => void;
queryContext?: {
sewerBzmnCd: string;
ctpvNm: string;
sggNm: string;
statsYr: string;
statsCd: string;
} | null;
title?: string;
emptyMessage?: string;
}
export function VerificationResultHistoryDialog({
open,
onOpenChange,
queryContext,
title = "검증 결과 내역",
emptyMessage = "표시할 검증 결과가 없습니다.",
}: VerificationResultHistoryDialogProps) {
const [normalEntries, setNormalEntries] = useState<VerificationResultReportItem[]>([]);
const [imrEntries, setImrEntries] = useState<VerificationResultReportItem[]>([]);
const [normalPageIndex, setNormalPageIndex] = useState(0);
const [imrPageIndex, setImrPageIndex] = useState(0);
const [normalPageCount, setNormalPageCount] = useState(1);
const [imrPageCount, setImrPageCount] = useState(1);
const [normalLoading, setNormalLoading] = useState(false);
const [imrLoading, setImrLoading] = useState(false);
const [errorMessage, setErrorMessage] = useState<string | null>(null);
const resetState = useCallback(() => {
setNormalEntries([]);
setImrEntries([]);
setNormalPageIndex(0);
setImrPageIndex(0);
setNormalPageCount(1);
setImrPageCount(1);
setNormalLoading(false);
setImrLoading(false);
setErrorMessage(null);
}, []);
const fetchVerificationResultPage = useCallback(
async (vrfcMthdCd: "NV" | "IMR", pageIndex: number) => {
if (!queryContext) return null;
const param: VerificationResultListRequest = {
sewerBzmnCd: queryContext.sewerBzmnCd,
ctpvNm: queryContext.ctpvNm,
sggNm: queryContext.sggNm,
statsYr: queryContext.statsYr,
statsCd: queryContext.statsCd,
pbcpAprvYn: 'N',
vrfcMthdCd,
prcsYnList: "0,2,3",
page: pageIndex + 1,
size: 30,
};
return statisticsApi.getVerificationResultList(param);
},
[queryContext],
);
const loadInitial = useCallback(async () => {
if (!queryContext) {
setErrorMessage("조회 조건이 없어 검증 결과를 불러올 수 없습니다.");
return;
}
setErrorMessage(null);
setNormalLoading(true);
setImrLoading(true);
setNormalPageIndex(0);
setImrPageIndex(0);
try {
const [nvRes, imrRes] = await Promise.all([
fetchVerificationResultPage("NV", 0),
fetchVerificationResultPage("IMR", 0),
]);
setNormalEntries(nvRes?.data?.verifyResultList ?? []);
setImrEntries(imrRes?.data?.verifyResultList ?? []);
setNormalPageCount(nvRes?.data?.pageInfo?.totalPages ?? 1);
setImrPageCount(imrRes?.data?.pageInfo?.totalPages ?? 1);
} catch {
setNormalEntries([]);
setImrEntries([]);
setNormalPageCount(1);
setImrPageCount(1);
setErrorMessage("검증 결과 내역 조회 중 오류가 발생했습니다.");
} finally {
setNormalLoading(false);
setImrLoading(false);
}
}, [fetchVerificationResultPage, queryContext]);
const handleNormalPageChange = useCallback(
async (pageIndex: number) => {
if (pageIndex === normalPageIndex || !queryContext) return;
setErrorMessage(null);
setNormalLoading(true);
try {
const response = await fetchVerificationResultPage("NV", pageIndex);
setNormalEntries(response?.data?.verifyResultList ?? []);
setNormalPageCount(response?.data?.pageInfo?.totalPages ?? 1);
setNormalPageIndex(pageIndex);
} catch {
setErrorMessage("일반검증 내역 조회 중 오류가 발생했습니다.");
} finally {
setNormalLoading(false);
}
},
[fetchVerificationResultPage, normalPageIndex, queryContext],
);
const handleImrPageChange = useCallback(
async (pageIndex: number) => {
if (pageIndex === imrPageIndex || !queryContext) return;
setErrorMessage(null);
setImrLoading(true);
try {
const response = await fetchVerificationResultPage("IMR", pageIndex);
setImrEntries(response?.data?.verifyResultList ?? []);
setImrPageCount(response?.data?.pageInfo?.totalPages ?? 1);
setImrPageIndex(pageIndex);
} catch {
setErrorMessage("I-MR 검증내역 조회 중 오류가 발생했습니다.");
} finally {
setImrLoading(false);
}
},
[fetchVerificationResultPage, imrPageIndex, queryContext],
);
useEffect(() => {
if (!open) {
resetState();
return;
}
void loadInitial();
}, [loadInitial, open, resetState]);
const mapRows = (entries: VerificationResultReportItem[]): VerificationResultGridRow[] =>
entries.map((item) => ({
ctpvNm: item.ctpvNm,
sggNm: item.sggNm,
vrfcTrgtNm: item.vrfcTrgtNm,
rsnExpln: item.rsnExpln,
nowVlCn: item.nowVlCn ?? "-",
compVlCn: item.compVlCn ?? "-",
prcsYn: PRCS_YN_LABEL[item.prcsYn] ?? item.prcsYn,
pbcpAprvYn: item.pbcpAprvYn,
regDt: (item.regDt ?? "").slice(0, 16),
mdfcnDt: (item.mdfcnDt ?? "").slice(0, 16),
}));
const normalRows = useMemo(() => mapRows(normalEntries), [normalEntries]);
const imrRows = useMemo(() => mapRows(imrEntries), [imrEntries]);
const noopSetRows = ((_: SetStateAction<Record<string, string | number>[]>) => {}) as Dispatch<
SetStateAction<Record<string, string | number>[]>
>;
const renderGrid = (
rows: VerificationResultGridRow[],
pageIndex: number,
pageCount: number,
onPageIndexChange: (page: number) => void,
loading: boolean,
) => {
if (!loading && rows.length === 0) {
return <p className="p-6 text-center text-[1.3rem] text-muted-foreground">{emptyMessage}</p>;
}
return (
<div className="relative space-y-2">
<DataGrid
columnInfo={verificationResultColumnInfo}
rowData={rows}
setRowData={noopSetRows}
isExcelImport={false}
isExcelExport={false}
showHeaderCheckbox={false}
enableSort={false}
/>
<VerificationResultHistoryPagination
pageCount={Math.max(pageCount, 1)}
pageIndex={Math.max(pageIndex, 0)}
onPageIndexChange={onPageIndexChange}
className="mx-2 mb-2"
/>
{loading && (
<div className="absolute inset-0 z-10 rounded-md [data-theme='black']:bg-transparent bg-white/40 flex items-center justify-center">
<div className="relative size-[5rem]">
<LoadingWithAnimation color="primary" />
</div>
</div>
)}
</div>
);
};
return (
<Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent className="max-w-[80%]" aria-describedby={undefined}>
<DialogHeader>
<DialogTitle>{`${title} - ${queryContext?.statsYr} ${queryContext?.ctpvNm} ${queryContext?.sggNm}`}</DialogTitle>
</DialogHeader>
<div className="max-h-[85vh] space-y-5 overflow-auto">
{errorMessage && (
<p className="rounded-md border border-destructive/40 bg-destructive/10 px-3 py-2 text-[1.2rem] text-destructive">
{errorMessage}
</p>
)}
<section className="rounded-md border border-border">
<h3 className="border-b border-border px-4 py-2 text-[1.35rem] font-semibold">일반검증 내역</h3>
{renderGrid(normalRows, normalPageIndex, normalPageCount, handleNormalPageChange, normalLoading)}
</section>
<section className="rounded-md border border-border">
<h3 className="border-b border-border px-4 py-2 text-[1.35rem] font-semibold">I-MR 검증내역</h3>
{renderGrid(imrRows, imrPageIndex, imrPageCount, handleImrPageChange, imrLoading)}
</section>
</div>
<DialogFooter>
<DialogClose asChild>
<Button type="button" color="gray">
닫기
</Button>
</DialogClose>
</DialogFooter>
</DialogContent>
</Dialog>
);
}
@@ -0,0 +1,99 @@
"use client";
import {
ChevronLeftIcon,
ChevronRightIcon,
DoubleArrowLeftIcon,
DoubleArrowRightIcon,
} from "@radix-ui/react-icons";
import { Button, cn } from "@common/components";
export type VerificationResultHistoryPaginationProps = {
pageCount: number;
pageIndex: number;
onPageIndexChange: (pageIndex: number) => void;
className?: string;
};
export function VerificationResultHistoryPagination({
pageCount,
pageIndex: currentPage,
onPageIndexChange,
className,
}: VerificationResultHistoryPaginationProps) {
const maxVisiblePages = 10;
let startPage = Math.max(0, currentPage - maxVisiblePages / 2);
const endPage = Math.min(pageCount, startPage + maxVisiblePages);
if (endPage - startPage < maxVisiblePages) {
startPage = Math.max(0, endPage - maxVisiblePages);
}
const pageNumbers = Array(endPage - startPage)
.fill(0)
.map((_, i) => startPage + i);
const canPrevious = currentPage > 0;
const canNext = pageCount > 0 && currentPage < pageCount - 1;
return (
<div className={cn("mt-2 flex items-center justify-center gap-1 rounded-md border border-border/70 bg-muted/20 px-2 py-2", className)}>
<Button
variant="ghost"
color="white"
className="hidden size-8 p-0 lg:flex"
onClick={() => onPageIndexChange(0)}
disabled={!canPrevious}
>
<span className="sr-only">첫 페이지로</span>
<DoubleArrowLeftIcon className="size-4" />
</Button>
<Button
variant="ghost"
color="white"
className="size-8 p-0"
onClick={() => onPageIndexChange(currentPage - 1)}
disabled={!canPrevious}
>
<span className="sr-only">이전 페이지로</span>
<ChevronLeftIcon className="size-4" />
</Button>
<div className="flex items-center justify-center text-sm font-medium">
{pageNumbers.map((pageNumber) => (
<Button
key={pageNumber}
variant="ghost"
className={cn(
"inline-block h-8 min-w-8 border border-transparent !bg-transparent px-2 py-1 text-foreground hover:!bg-accent",
currentPage === pageNumber &&
"!bg-primary !font-bold !text-primary-foreground hover:!bg-primary",
)}
onClick={() => onPageIndexChange(pageNumber)}
>
{pageNumber + 1}
</Button>
))}
</div>
<Button
variant="ghost"
color="white"
className="size-8 p-0"
onClick={() => onPageIndexChange(currentPage + 1)}
disabled={!canNext}
>
<span className="sr-only">다음 페이지로</span>
<ChevronRightIcon className="size-4" />
</Button>
<Button
variant="ghost"
color="white"
className="hidden size-8 p-0 lg:flex"
onClick={() => onPageIndexChange(Math.max(0, pageCount - 1))}
disabled={!canNext}
>
<span className="sr-only">마지막 페이지로</span>
<DoubleArrowRightIcon className="size-4" />
</Button>
</div>
);
}
@@ -1,14 +1,16 @@
import { useState, useMemo, useRef } from "react";
import SearchForm from "@/components/module/SearchForm";
import TabDataGrid from "@/components/module/TabDataGrid";
import DataGrid from "@/components/module/DataGrid";
import SplitDataGrid from "@/components/module/SplitDataGrid";
import GridTopActionButton from "@/components/module/GridTopActionButton";
import SubmitButton from "@/components/button/SumitButton";
import SaveButton from "@/components/button/SaveButton";
import LastYearDataButton from "@/components/button/LastYearDataButton";
import SubmitExceptionButton from "@/components/button/SubmitExceptionButton";
import { SewagePipeInpColumnInfoList } from "@/components/util/column/input/sewagePipe/SewagePipeInpColumn";
import type { ColumnInfoList, ColumnConfig } from "@/components/type/DataGrid.type";
import type { ColumnInfo, ColumnInfoList, ColumnConfig } from "@/components/type/DataGrid.type";
import type { SearchFormValues } from "@/components/type/SearchForm.type";
import { useAlertStore } from "@/shared/lib";
import { useAdminStore } from "@/store/admin-store";
@@ -17,20 +19,13 @@ import { useConfirm } from "@frontend-opensource/use-react-hooks";
import { doList } from "@/components/util/column/input/DoList";
import { statisticsInputSewPipeApiService } from "@/entities/statistics/api/input/statistics-sewage-pipe-input-service";
import type {
SeweragePipeInputAdminAreaConnectionItem,
SeweragePipeInputFacilityConnectionItem,
SeweragePipeInputFactInstallStatusItem,
SeweragePipeInputManholeStatusItem,
SeweragePipeInputPipeDiameterStatusItem,
SeweragePipeInputPipeTypeStatusItem,
SeweragePipeInputRegionalFactStatusItem,
SeweragePipeInputRegionalFactStatusSaveRequest,
SeweragePipeInputStatusInfo,
SeweragePipeRequestParams,
} from "@/entities/statistics/model/input/statistics-sewage-pipe-input-interface";
import { RegionalFactStatusInpColumn } from "@/components/util/column/input/sewagePipe/RegionalFactStatusInpColumn";
import { toGridRows } from "@/components/util/grid/gridFunction";
import { LoadingWithAnimation } from "@common/components";
import { LoadingWithAnimation, Tabs, TabsContent, TabsList, TabsTrigger } from "@common/components";
import { getUserInfo } from "../../../../../../packages/common/components/src/api";
const applyEnterableFromStatus = (
@@ -62,6 +57,23 @@ function buildSewagePipeSearchParam(
};
}
/** ST00020006(연결)은 행이 좌·우 그리드 키에만 있음 */
function tabRowDataExists(
m: Map<string, Record<string, string | number>[]>,
tabKey: string,
): boolean {
if (tabKey === "ST00020006") {
const left = m.get("treatmentPlantConnectionInp");
const right = m.get("adminAreaConnectionInp");
return (
(Array.isArray(left) && left.length > 0) ||
(Array.isArray(right) && right.length > 0)
);
}
const rows = m.get(tabKey);
return Array.isArray(rows) && rows.length > 0;
}
// 하수도 통계 관리 > 02. 하수관로
const SewagePipeInp = () => {
const [rowDataMap, setRowDataMap] = useState<Map<string, Record<string, string | number>[]>>(new Map());
@@ -128,7 +140,7 @@ const SewagePipeInp = () => {
});
}, [isEnterable, isAdminInfo]);
/** mergeIntoExistingMap: true면 지난연도(PRE) 등 — 현재 탭만 갱신, 나머지 탭 캐시 유지 */
/** mergeIntoExistingMap: true면 기존 맵에 병합(다른 탭 rowData·검색 후 다른 탭 캐시 유지). false면 맵 전체 교체 */
const loadSewagePipeActiveTabData = async (
tabKey: string,
searchParam: SeweragePipeRequestParams,
@@ -290,7 +302,6 @@ const SewagePipeInp = () => {
const checkEnterable = periodResponse.data;
periodEnterableRef.current = checkEnterable;
setRowDataMap(new Map());
lazyTabLoggedRef.current.clear();
setIsLoading(true);
@@ -301,7 +312,7 @@ const SewagePipeInp = () => {
setHasSearched(true);
setLastSearchedYear(values.year ?? null);
await loadSewagePipeActiveTabData(activeTab, searchParam, values, checkEnterable, false, true);
await loadSewagePipeActiveTabData(activeTab, searchParam, values, checkEnterable, true, true);
} catch {
alert('데이터 조회 중 오류가 발생했습니다.');
} finally {
@@ -312,8 +323,7 @@ const SewagePipeInp = () => {
const handleActiveTabChange = async (tabKey: string) => {
setActiveTab(tabKey);
const activeTabData = rowDataMap.get(tabKey);
const hasActiveTabData = Array.isArray(activeTabData) && activeTabData.length > 0;
const hasActiveTabData = tabRowDataExists(rowDataMap, tabKey);
const canLazyLoad =
searchParamRef.current &&
@@ -324,86 +334,20 @@ const SewagePipeInp = () => {
if (!canLazyLoad) return;
const searchParam = searchParamRef.current;
if (!searchParam) return;
const values = lastSearchedValues ?? searchValues;
if (!searchParam || !values) return;
try {
setIsLoading(true);
let response;
if (tabKey === 'ST00020001') {
response = await statisticsInputSewPipeApiService.getRegionalFactStatus(searchParam);
} else if (tabKey === 'ST00020002') {
response = await statisticsInputSewPipeApiService.getFactInstallStatus(searchParam);
} else if (tabKey === 'ST00020003') {
response = await statisticsInputSewPipeApiService.getPipeTypeStatus(searchParam);
} else if (tabKey === 'ST00020004') {
response = await statisticsInputSewPipeApiService.getPipeDiameterStatus(searchParam);
} else if (tabKey === 'ST00020005') {
response = await statisticsInputSewPipeApiService.getManholeStatus(searchParam);
} else if (tabKey === 'ST00020006') {
response = await statisticsInputSewPipeApiService.getConnectionStatus(searchParam);
}
lazyTabLoggedRef.current.add(tabKey);
if (response?.status === 'success') {
setStatusInfo(response.data.statusInfo);
setStatsCd(response.data.commonInfo.statsCd);
applyEnterableFromStatus(response.data.statusInfo, periodEnterableRef.current, setIsEnterable);
setRowDataMap(prev => {
const next = new Map(prev);
if (tabKey === 'ST00020001') {
const commonInfo = response.data.commonInfo;
const convertedData = (response.data.regionalFactStatusList ?? []).map((d: SeweragePipeInputRegionalFactStatusItem) => ({
ctpvNm: commonInfo.ctpvNm,
...d
}));
next.set(tabKey, toGridRows(convertedData));
} else if (tabKey === 'ST00020002') {
const commonInfo = response.data.commonInfo;
const convertedData = (response.data.factInstallStatusList ?? []).map((d: SeweragePipeInputFactInstallStatusItem) => ({
ctpvNm: commonInfo.ctpvNm,
...d
}));
next.set(tabKey, toGridRows(convertedData));
} else if (tabKey === 'ST00020003') {
const commonInfo = response.data.commonInfo;
const convertedData = (response.data.pipeTypeStatusList ?? []).map((d: SeweragePipeInputPipeTypeStatusItem) => ({
ctpvNm: commonInfo.ctpvNm,
...d
}));
next.set(tabKey, toGridRows(convertedData));
} else if (tabKey === 'ST00020004') {
const commonInfo = response.data.commonInfo;
const convertedData = (response.data.pipeDiameterStatusList ?? []).map((d: SeweragePipeInputPipeDiameterStatusItem) => ({
ctpvNm: commonInfo.ctpvNm,
...d
}));
next.set(tabKey, toGridRows(convertedData));
} else if (tabKey === 'ST00020005') {
const commonInfo = response.data.commonInfo;
const convertedData = (response.data.manholeStatusList ?? []).map((d: SeweragePipeInputManholeStatusItem) => ({
ctpvNm: commonInfo.ctpvNm,
...d
}));
next.set(tabKey, toGridRows(convertedData));
} else if (tabKey === 'ST00020006') {
const commonInfo = response.data.commonInfo;
const facilityConvertedData = (response.data.facilityConnectionList ?? []).map((d: SeweragePipeInputFacilityConnectionItem) => ({
...d
}));
const adminConvertedData = (response.data.adminAreaConnectionList ?? []).map((d: SeweragePipeInputAdminAreaConnectionItem) => ({
...d,
ctpvNm: d.ctpvNm || commonInfo.ctpvNm,
}));
next.set('treatmentPlantConnectionInp', toGridRows(facilityConvertedData));
next.set('adminAreaConnectionInp', toGridRows(adminConvertedData));
}
return next;
});
} else {
alert(response?.message || '데이터 조회 중 오류가 발생했습니다.');
}
const ok = await loadSewagePipeActiveTabData(
tabKey,
searchParam,
values,
periodEnterableRef.current,
true,
false,
);
if (ok) lazyTabLoggedRef.current.add(tabKey);
} catch {
alert('데이터 조회 중 오류가 발생했습니다.');
} finally {
@@ -543,8 +487,9 @@ const SewagePipeInp = () => {
const showSaveInToolbar = (isEnterable || isAdminInfo) && gugunOkOrNotDoList;
const showEnterableToolbarButtons = isEnterable && gugunOkOrNotDoList;
/** TabDataGrid는 rowDataMap 사용 — 행이 하나라도 있을 때만 툴바 카드 의미 있음 */
/** rowDataMap — 행이 하나라도 있을 때만 툴바·엑셀 의미 있음 */
const hasRowData = Array.from(rowDataMap.values()).some((rows) => rows.length > 0);
const excelToolbarEnabled = hasRowData && isEnterable && gugunOkOrNotDoList;
const showActionToolbarCard =
(hasRowData || hasSearched) &&
@@ -573,16 +518,132 @@ const SewagePipeInp = () => {
<LoadingWithAnimation color="primary" />
</div>
</div>}
<TabDataGrid
columnInfo={columnInfo}
rowDataMap={rowDataMap}
setRowDataMap={setRowDataMap}
activeTab={activeTab}
onActiveTabChange={handleActiveTabChange}
isExcelImport={hasRowData && isEnterable && gugunOkOrNotDoList}
facilitySearchStatsYr={lastSearchedYear}
getGridDataRef={getGridDataRef}
/>
<div className="flex flex-col gap-[0.6rem] mx-[0.6rem]">
<Tabs
value={activeTab}
onValueChange={(v) => void handleActiveTabChange(v)}
className="w-full"
variant="default"
>
<TabsList>
{columnInfo.map((info) => (
<TabsTrigger key={info.tabKey} value={info.tabKey}>
{info.tabLabel || info.tabKey}
</TabsTrigger>
))}
</TabsList>
{columnInfo.map((info) => {
const tabKey = info.tabKey;
if (info.isSplitGrid && info.left && info.right) {
const leftKey = info.left.tabKey || `${tabKey}_left`;
const rightKey = info.right.tabKey || `${tabKey}_right`;
const leftRowData = rowDataMap.get(leftKey) ?? [];
const rightRowData = rowDataMap.get(rightKey) ?? [];
const splitTopButton = info.right.topButton;
const showSplitTopButton =
Boolean(splitTopButton?.text) &&
(splitTopButton?.visible ?? true) &&
excelToolbarEnabled;
const leftColumnInfo: ColumnInfo = {
columns: info.left.columns ?? [],
columnLayout: info.left.columnLayout ?? [],
};
const rightColumnInfo: ColumnInfo = {
columns: info.right.columns ?? [],
columnLayout: info.right.columnLayout ?? [],
};
return (
<TabsContent key={tabKey} value={tabKey}>
<div className="flex flex-col gap-[0.6rem]">
<div className="flex justify-end gap-[0.6rem]">
{showSplitTopButton && (
<GridTopActionButton
text={splitTopButton?.text ?? ""}
onClick={splitTopButton?.onClick}
statsYr={lastSearchedYear ?? undefined}
/>
)}
</div>
<SplitDataGrid
leftColumnInfo={leftColumnInfo}
rightColumnInfo={rightColumnInfo}
leftFixedColumns={info.left.fixedColumns}
rightFixedColumns={info.right.fixedColumns}
showHeaderCheckbox={false}
enableSort={false}
leftRowData={leftRowData}
rightRowData={rightRowData}
setLeftRowData={(updater) => {
setRowDataMap((prev) => {
const next = new Map(prev);
const cur = next.get(leftKey) ?? [];
const n =
typeof updater === "function"
? updater(cur)
: updater;
next.set(leftKey, n);
return next;
});
}}
setRightRowData={(updater) => {
setRowDataMap((prev) => {
const next = new Map(prev);
const cur = next.get(rightKey) ?? [];
const n =
typeof updater === "function"
? updater(cur)
: updater;
next.set(rightKey, n);
return next;
});
}}
/>
</div>
</TabsContent>
);
}
if (!info.columns || !info.columnLayout) {
return null;
}
const rowData = rowDataMap.get(tabKey) ?? [];
const gridColumnInfo: ColumnInfo = {
columns: info.columns,
columnLayout: info.columnLayout,
};
return (
<TabsContent key={tabKey} value={tabKey}>
<DataGrid
columnInfo={gridColumnInfo}
rowStartNum={info.rowStartNum}
excelExportFileSuffix={info.tabLabel || tabKey}
rowData={rowData}
setRowData={(updater) => {
setRowDataMap((prev) => {
const next = new Map(prev);
const cur = next.get(tabKey) ?? [];
const n =
typeof updater === "function" ? updater(cur) : updater;
next.set(tabKey, n);
return next;
});
}}
isExcelImport={excelToolbarEnabled}
isExcelExport
showHeaderCheckbox={false}
fixedColumns={info.fixedColumns}
enableSort={false}
getGridDataRef={tabKey === "ST00020001" ? getGridDataRef : undefined}
/>
</TabsContent>
);
})}
</Tabs>
</div>
</>
);
};
@@ -6,16 +6,18 @@ import DataGrid from "@/components/module/DataGrid";
import SubmitButton from "@/components/button/SumitButton";
import SaveButton from "@/components/button/SaveButton";
import LastYearDataButton from "@/components/button/LastYearDataButton";
import SubmitExceptionButton from "@/components/button/SubmitExceptionButton";
import CancelSubmitButton from "@/components/button/CancelSubmitButton";
import VerificationResultButton from "@/components/button/VerificationResultButton";
import { VerificationResultHistoryDialog } from "@/components/module/VerificationResultHistoryDialog";
import { VerificationResultDialog } from "@/components/module/VerificationResultDialog";
import { LoadingWithAnimation } from "@common/components";
import { ConfirmDialog } from "@/shared/ui/confirm-dialog";
import SubmitDailog from "./SubmitDailog";
import { useAlertStore } from "@/shared/lib";
import { useAlertStore, useGlobalProgressStore } from "@/shared/lib";
import { useAdminStore } from "@/store/admin-store";
import { statisticsInputSewSupplyRateApi } from "@/entities/statistics/api/input/statistics-input-sew-supply-rate-service";
import { statisticsAPi } from "@/entities/statistics/api/statistics-service";
import { statisticsApi } from "@/entities/statistics/api/statistics-service";
import { toGridRows } from "@/components/util/grid/gridFunction";
import { getFooterSummary } from "@/components/util/grid/footerSummary";
import { getUserInfo } from "../../../../../../packages/common/components/src/api";
@@ -30,7 +32,11 @@ import type {
SewerageSupplyRateSaveRequest,
SewerageSupplyRateStatusInfo,
} from "@/entities/statistics/model/input/statistics-sewage-supply-rate-input-interface";
import type { cancelSubmitRequest, SubmitExceptionRequest } from "@/entities/statistics/model/statistics-interface";
import type {
cancelSubmitRequest,
ImrStatusResponse,
SubmitExceptionRequest,
} from "@/entities/statistics/model/statistics-interface";
import type { StatisticsInputSubmitInfoFormData } from "@/entities/statistics/model/statistics-input-submit-info-schema";
import { SeweragePenetrationRateInpColumn } from "@/components/util/column/input/SeweragePenetrationRateInpColumn";
import { doList } from "@/components/util/column/input/DoList";
@@ -45,6 +51,32 @@ const hasVerificationErrors = (verification?: SewerageSupplyRateVerificationResu
return Object.values(verification).some((errors) => Array.isArray(errors) && errors.length > 0);
};
const sleep = (ms: number) => new Promise<void>((resolve) => setTimeout(resolve, ms));
/** IMR 상태가 COMPLETED 이고 job_prog 가 100일 때까지 폴링 */
async function waitForImrJobCompleted(
jobId: string,
getStatus: (id: string) => Promise<ImrStatusResponse>,
options?: {
pollIntervalMs?: number;
maxAttempts?: number;
onProgress?: (jobProgress: number) => void;
},
): Promise<void> {
const pollIntervalMs = options?.pollIntervalMs ?? 2000;
const maxAttempts = options?.maxAttempts ?? 150;
for (let i = 0; i < maxAttempts; i++) {
const res = await getStatus(jobId);
options?.onProgress?.(Math.max(0, Math.min(100, Number(res.job_prog) || 0)));
if (res.status === "COMPLETED" && Number(res.job_prog) === 100) return;
if (res.status === "FAILED" || res.status === "ERROR" || res.status === "CANCELLED") {
throw new Error(res.error_msg || `IMR 검증이 ${res.status} 상태로 종료되었습니다.`);
}
await sleep(pollIntervalMs);
}
throw new Error("IMR 검증 완료 대기 시간이 초과되었습니다.");
}
function getSewerageSupplyRateParams(
values: SearchFormValues,
overrides?: { yr?: string; demoYr?: string; mode?: string }
@@ -69,11 +101,17 @@ const SeweragePenetrationRateInp = () => {
const [lastSearchedValues, setLastSearchedValues] = useState<SearchFormValues | null>(null);
const [statusInfo, setStatusInfo] = useState<SewerageSupplyRateStatusInfo | null>(null);
const [showDailog, setShowDailog] = useState<boolean>(false);
const [verificationHistoryOpen, setVerificationHistoryOpen] = useState(false);
const [verificationResultDialogOpen, setVerificationResultDialogOpen] = useState(false);
/** 예외제출 버튼으로 연 다이얼로그인지 (확인 시 executeSubmit 대신 SubmitExceptionFunc 경로) */
const [isExceptionSubmitFlow, setIsExceptionSubmitFlow] = useState(false);
const [statsCd, setStatsCd] = useState<string>('');
const [footerFromApi, setFooterFromApi] = useState<{ sewerBzmnWholPpltnCnt?: number; sewerBzmnDomplWholPpltnCnt?: number; sewerBzmnFrgnrWholPpltnCnt?: number; sewerBzmnArea?: number } | null>(null);
const { confirm } = useConfirm();
const showGlobalProgress = useGlobalProgressStore((state) => state.show);
const setGlobalProgress = useGlobalProgressStore((state) => state.setProgress);
const hideGlobalProgress = useGlobalProgressStore((state) => state.hide);
const globalProgressVisible = useGlobalProgressStore((state) => state.visible);
const { isAdminInfo } = useAdminStore();
@@ -88,6 +126,7 @@ const SeweragePenetrationRateInp = () => {
});
// 최상위 관리자일땐 모든 상황에서 edit 가능하게 수정 필요.
const columnInfo = useMemo(() => {
const { columns, columnLayout } = SeweragePenetrationRateInpColumn;
const base = (isEnterable || isAdminInfo) ? columns : columns.map((col) => ({ ...col, editable: false as const }));
@@ -212,7 +251,7 @@ const SeweragePenetrationRateInp = () => {
try {
setIsLoading(true);
const response = await statisticsAPi.submitException(params);
const response = await statisticsApi.submitException(params);
if (response.status === 'success') {
await fetchSewerageSupplyRate(getSewerageSupplyRateParams(searchValues ?? {}), false);
@@ -271,7 +310,7 @@ const SeweragePenetrationRateInp = () => {
const executeSubmit = async (submitInfo: StatisticsInputSubmitInfoFormData) => {
try {
setIsLoading(true);
showGlobalProgress({ label: "제출 진행 중...", progress: 0 });
const userInfo = await getUserInfo();
const currentData = getGridDataRef.current?.();
@@ -348,39 +387,62 @@ const SeweragePenetrationRateInp = () => {
const response = await statisticsInputSewSupplyRateApi.submitSewerageSupplyRate(requestData);
if (response.status === 'success') {
const imrParam = {
user_id: userInfo.userUnqId,
sewer_bzmn_cd: searchValues?.bsnmCd ?? '',
stats_cd: statsCd,
stats_yr: searchValues?.year ?? '',
lae_sbmsn_stts_cd: (statusInfo?.laeSbmsnSttsCd === '4' ? '4' : '2'),
};
setGlobalProgress(50);
if (response.data.finalStatus === '5') {
const imrParam = {
user_id: userInfo.userUnqId,
sewer_bzmn_cd: searchValues?.bsnmCd ?? '',
stats_cd: statsCd,
stats_yr: searchValues?.year ?? '',
lae_sbmsn_stts_cd: (statusInfo?.laeSbmsnSttsCd === '4' ? '4' : '2'),
gen_verify_stts_cd: response.data.nvpassed ? '1' : '2',
};
try {
await statisticsAPi.callImrService(imrParam);
} catch {
await fetchSewerageSupplyRate(getSewerageSupplyRateParams(searchValues ?? {}), true);
setIsLoading(false);
alert('IMR 검증 중 오류가 발생했습니다.\n관리자에게 문의하세요.');
return;
try {
const imrResponse = await statisticsApi.callImrService(imrParam);
if (imrResponse.job_id) {
await waitForImrJobCompleted(imrResponse.job_id, (id) =>
statisticsApi.getImrStatus(id),
{
onProgress: (jobProgress) => {
setGlobalProgress(50 + Math.round(jobProgress / 2));
},
},
);
}
setGlobalProgress(100);
await fetchSewerageSupplyRate(getSewerageSupplyRateParams(searchValues ?? {}), true);
setVerificationResultDialogOpen(true);
} catch (e) {
await fetchSewerageSupplyRate(getSewerageSupplyRateParams(searchValues ?? {}), true);
setIsLoading(false);
const msg =
e instanceof Error && e.message
? e.message
: 'IMR 검증 중 오류가 발생했습니다.\n관리자에게 문의하세요.';
alert(msg);
return;
}
} else {
setGlobalProgress(100);
await fetchSewerageSupplyRate(getSewerageSupplyRateParams(searchValues ?? {}), false);
alert('제출되었습니다.');
}
await fetchSewerageSupplyRate(getSewerageSupplyRateParams(searchValues ?? {}), false);
alert('제출되었습니다.');
} else {
alert(response.message || '데이터 제출 중 오류가 발생했습니다.');
}
} catch {
alert('데이터 제출 중 오류가 발생했습니다.');
} finally {
setIsLoading(false);
hideGlobalProgress();
}
};
const saveFunc = async () => {
try {
if (await confirm('저장 하시겠습니까?')) {
setIsLoading(true);
showGlobalProgress({ label: "저장 진행 중...", progress: 0 });
const userInfo = await getUserInfo();
const currentData = getGridDataRef.current?.();
@@ -465,23 +527,8 @@ const SeweragePenetrationRateInp = () => {
const response = await statisticsInputSewSupplyRateApi.saveSewerageSupplyRate(requestData);
if (response.status === 'success') {
const imrParam = {
user_id: userInfo.userUnqId,
sewer_bzmn_cd: searchValues?.bsnmCd ?? '',
stats_cd: statsCd,
stats_yr: searchValues?.year ?? '',
lae_sbmsn_stts_cd: (statusInfo?.laeSbmsnSttsCd === '4' ? '3' : '1'),
};
try {
await statisticsAPi.callImrService(imrParam);
} catch {
await fetchSewerageSupplyRate(getSewerageSupplyRateParams(searchValues ?? {}), true);
setIsLoading(false);
alert('IMR 검증 중 오류가 발생했습니다.\n관리자에게 문의하세요.');
return;
}
setGlobalProgress(50);
setGlobalProgress(100);
await fetchSewerageSupplyRate(getSewerageSupplyRateParams(searchValues ?? {}), true);
alert('저장되었습니다.');
} else {
@@ -493,7 +540,7 @@ const SeweragePenetrationRateInp = () => {
} catch {
alert('데이터 저장 중 오류가 발생했습니다.');
} finally {
setIsLoading(false);
hideGlobalProgress();
}
};
@@ -512,7 +559,7 @@ const SeweragePenetrationRateInp = () => {
statsCd: statsCd,
};
const response = await statisticsAPi.cancelSubmit(requestData);
const response = await statisticsApi.cancelSubmit(requestData);
if (response.status === 'success') {
fetchSewerageSupplyRate(getSewerageSupplyRateParams(searchValues ?? {}), true);
@@ -526,6 +573,14 @@ const SeweragePenetrationRateInp = () => {
}
};
const openVerificationHistoryDialog = () => {
if (!searchValues?.year || !searchValues?.sido || !searchValues?.bsnmCd || !statsCd) {
alert("먼저 조회를 진행해주세요.");
return;
}
setVerificationHistoryOpen(true);
};
const gugunOkOrNotDoList = doList.includes(lastSearchedValues?.bsnmCd || '')
? Boolean(lastSearchedValues?.gugun && lastSearchedValues.gugun !== 'ALL')
: true;
@@ -547,12 +602,11 @@ const SeweragePenetrationRateInp = () => {
<div className="flex justify-end items-center gap-[0.6rem] mx-[0.6rem]">
{showEnterableToolbarButtons && (
<>
{isAdminInfo && statusInfo && statusInfo.laeSbmsnSttsCd !== '0' && (
<SubmitExceptionButton SubmitExceptionFunc={() => submitFunc(true)} />
)}
<VerificationResultButton
VerificationResultButtonFunc={openVerificationHistoryDialog}
/>
<LastYearDataButton getLastYearDataFunc={() => getLastYearDataFunc(searchValues ?? {})} />
<SubmitButton submitFunc={() => submitFunc(false)} />
</>
)}
{statusInfo?.laeSbmsnSttsCd === '2' && <CancelSubmitButton cnacelSubmitFunc={cnacelSubmitFunc} />}
@@ -560,7 +614,7 @@ const SeweragePenetrationRateInp = () => {
</div>
</div>
)}
{isLoading && <div className="absolute inset-0 z-50 [data-theme='black']:bg-transparent rounded-lg p-10 flex justify-center items-center">
{isLoading && !globalProgressVisible && <div className="absolute inset-0 z-50 [data-theme='black']:bg-transparent rounded-lg p-10 flex justify-center items-center">
<div className="relative size-[5rem]">
<LoadingWithAnimation color="primary" />
</div>
@@ -593,7 +647,7 @@ const SeweragePenetrationRateInp = () => {
return errors
.map((e) => {
const prefix = e.vrfcCd === 'IMR' ? 'IMR검증 - ' : '일반검증 - ';
return `${prefix}${e.rsnExpln}`;
return `${prefix}${e.rsnExpln}(검증 기준값: ${e.compVlCn} / 현재 입력값: ${e.nowVlCn})`;
})
.filter(Boolean)
.join('\n');
@@ -614,6 +668,25 @@ const SeweragePenetrationRateInp = () => {
submitMode={isExceptionSubmitFlow ? 'exception' : 'normal'}
isLoading={isLoading}
/>
<VerificationResultHistoryDialog
open={verificationHistoryOpen}
onOpenChange={setVerificationHistoryOpen}
queryContext={
searchValues?.year && searchValues?.sido && searchValues?.bsnmCd && statsCd
? {
sewerBzmnCd: searchValues.bsnmCd,
ctpvNm: searchValues.sido,
sggNm: searchValues.gugun === "ALL" ? "" : (searchValues.gugun ?? ""),
statsYr: searchValues.year,
statsCd,
}
: null
}
/>
<VerificationResultDialog
open={verificationResultDialogOpen}
onOpenChange={setVerificationResultDialogOpen}
/>
</>
);
};
@@ -1,22 +1,25 @@
import { useState, useEffect } from "react";
import { useEffect, useState } from "react";
import useSWR, { useSWRConfig } from "swr";
import SearchForm from "@/components/module/SearchForm";
import { useTabStore } from "@/store/tab-store";
import DataGrid from "@/components/module/DataGrid";
import ApproveButton from "@/components/button/ApproveButton";
import RejectButton from "@/components/button/RejectButton";
import { LoadingWithAnimation } from "@common/components";
import { StatsInputStatusInpColumn } from "@/components/util/column/input/StatsInputStatusInpColumn";
import { useAlertStore } from "@/shared/lib";
import { StatisticsInputStatusApi } from "@/entities/statistics/api/input/statistics-input-status-service";
import type { StatsInputStatusListRequestParams } from "@/entities/statistics/model/input/statistics-input-status-interface";
import type { SearchFormValues } from "@/components/type/SearchForm.type";
import { useAlertStore } from "@/shared/lib";
import { toGridRows } from "@/components/util/grid/gridFunction";
import type { StatsInputStatusListApiResponse } from "@/entities/statistics/model/input/statistics-input-status-interface";
import { LoadingWithAnimation } from "@common/components";
const getStatsInputStatusFetcher = async (key: [string, StatsInputStatusListRequestParams]): Promise<StatsInputStatusListApiResponse> => {
import ApproveButton from "@/components/button/ApproveButton";
import RejectButton from "@/components/button/RejectButton";
import DataGrid from "@/components/module/DataGrid";
import SearchForm from "@/components/module/SearchForm";
import type { SearchFormValues } from "@/components/type/SearchForm.type";
import { StatsInputStatusInpColumn } from "@/components/util/column/input/StatsInputStatusInpColumn";
import { toGridRows } from "@/components/util/grid/gridFunction";
import { useTabStore } from "@/store/tab-store";
const getStatsInputStatusFetcher = async (
key: [string, StatsInputStatusListRequestParams],
): Promise<StatsInputStatusListApiResponse> => {
return StatisticsInputStatusApi.getStatisticsInputStatusList(key[1]);
};
@@ -24,10 +27,15 @@ const getStatsInputStatusFetcher = async (key: [string, StatsInputStatusListRequ
const StatsInputStatusInp = () => {
const { setMessage: alert } = useAlertStore((state) => state);
const activeTabId = useTabStore((state) => state.activeTabId);
const [rowData, setRowData] = useState<Record<string, string | number>[]>([]);
const [searchParams, setSearchParams] = useState<StatsInputStatusListRequestParams | null>(null);
const [rowData, setRowData] = useState<Record<string, string | number>[]>(
[],
);
const [searchParams, setSearchParams] =
useState<StatsInputStatusListRequestParams | null>(null);
const swrKey = searchParams ? (["stats-input-status", searchParams] as const) : null;
const swrKey = searchParams
? (["stats-input-status", searchParams] as const)
: null;
const { mutate } = useSWRConfig();
const { isLoading } = useSWR<StatsInputStatusListApiResponse>(
swrKey,
@@ -49,7 +57,7 @@ const StatsInputStatusInp = () => {
);
useEffect(() => {
if (activeTabId === 'StatsInputStatusInp' && swrKey) {
if (activeTabId === "StatsInputStatusInp" && swrKey) {
mutate(swrKey);
}
}, [activeTabId]);
@@ -63,7 +71,7 @@ const StatsInputStatusInp = () => {
ctpvNm: values.sido,
sewerBzmnCd: values.bsnmCd,
sggNm: values.gugun === "ALL" ? "" : values.gugun,
statsYr: values.year,
statsYr: values.year ?? "",
};
setSearchParams(params);
mutate(["stats-input-status", params]);
@@ -87,22 +95,24 @@ const StatsInputStatusInp = () => {
<div className="relative">
<SearchForm onSubmit={handleSearch} onReset={handleReset} />
<div className="card !py-0">
<div className="flex justify-end items-center gap-[0.6rem] m-[0.6rem]">
<div className="m-[0.6rem] flex items-center justify-end gap-[0.6rem]">
<RejectButton rejectFunc={rejectFunc} />
<ApproveButton approveFunc={approveFunc} />
</div>
</div>
{isLoading && <div className="absolute inset-0 z-50 [data-theme='black']:bg-transparent rounded-lg p-10 flex justify-center items-center">
<div className="relative size-[5rem]">
<LoadingWithAnimation color="primary" />
{isLoading && (
<div className="[data-theme='black']:bg-transparent absolute inset-0 z-50 flex items-center justify-center rounded-lg p-10">
<div className="relative size-[5rem]">
<LoadingWithAnimation color="primary" />
</div>
</div>
</div>}
<DataGrid
columnInfo={ StatsInputStatusInpColumn }
rowStartNum={2}
fixedColumns={3}
rowData={rowData}
setRowData={setRowData}
)}
<DataGrid
columnInfo={StatsInputStatusInpColumn}
rowStartNum={2}
fixedColumns={3}
rowData={rowData}
setRowData={setRowData}
isExcelImport={false}
showHeaderCheckbox={true}
/>
@@ -111,4 +121,4 @@ const StatsInputStatusInp = () => {
);
};
export default StatsInputStatusInp;
export default StatsInputStatusInp;
@@ -2,13 +2,30 @@
import { useCallback, useEffect, useState } from "react";
import dayjs from "dayjs";
import useSWR from "swr";
import { useAlertStore } from "@/shared/lib";
import { CodeSelectWithAll } from "@/entities/code";
import { statisticsAPi } from "@/entities/statistics/api/statistics-service";
import { statisticsApi } from "@/entities/statistics/api/statistics-service";
import type {
ApiResponse,
SearchCondition,
Sgg,
StatisticsLog,
StatisticsLogRequest,
} from "@/entities/statistics/model/statistics-interface";
import { Button, Label, LoadingWithAnimation } from "@common/components";
import {
Button,
InputDatePicker,
Label,
LoadingWithAnimation,
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from "@common/components";
import DataGrid from "@/components/module/DataGrid";
import { LogHistoryDetailDialog } from "@/components/statistics/management/LogHistoryDetailDialog";
@@ -18,9 +35,23 @@ import { toGridRows } from "@/components/util/grid/gridFunction";
const PAGE_SIZE = 100;
const getSidoListFetcher = async (): Promise<ApiResponse<SearchCondition>> => {
return statisticsApi.getSidoList();
};
const getSggListFetcher = async (
key: [string, string],
): Promise<ApiResponse<Sgg[]>> => {
return statisticsApi.getSggList(key[1]);
};
const LogHistory = () => {
// const { setMessage: alert } = useAlertStore((state) => state);
const { setMessage: alert } = useAlertStore((state) => state);
const [statsCd, setStatsCd] = useState<string>("");
const [sido, setSido] = useState<string>("ALL");
const [gugun, setGugun] = useState<string>("ALL");
const [bsnmCd, setBsnmCd] = useState<string>("");
const [statsDataChgComCd, setStatsDataChgComCd] = useState<string>("ALL");
const [isLoading, setIsLoading] = useState<boolean>(false);
const [rowData, setRowData] = useState<Record<string, string | number>[]>(
[],
@@ -32,49 +63,148 @@ const LogHistory = () => {
string,
string | number
> | null>(null);
const [startDate, setStartDate] = useState<Date | undefined>(() =>
dayjs().subtract(1, "month").startOf("day").toDate(),
);
const [endDate, setEndDate] = useState<Date | undefined>(() =>
dayjs().endOf("day").toDate(),
);
const { data: sidoList } = useSWR<ApiResponse<SearchCondition>>(
"log-history-sido-list",
getSidoListFetcher,
{
revalidateOnFocus: false,
revalidateOnReconnect: false,
revalidateIfStale: false,
dedupingInterval: 2000,
shouldRetryOnError: false,
onSuccess: (data) => {
if (data.status !== "success") {
alert(`시도 목록을 불러오는데 실패했습니다.
새로고침 후 다시 시도해주세요.`);
}
},
onError: () => {
alert(`시도 목록을 불러오는데 실패했습니다.
새로고침 후 다시 시도해주세요.`);
},
},
);
const sggKey: [string, string] | null =
sido && sido !== "ALL" ? ["log-history-sgg-list", sido] : null;
const { data: sggList } = useSWR<ApiResponse<Sgg[]>>(
sggKey,
getSggListFetcher,
{
revalidateOnFocus: false,
revalidateOnReconnect: false,
revalidateIfStale: false,
dedupingInterval: 2000,
shouldRetryOnError: false,
onSuccess: (data) => {
if (data.status !== "success") {
alert(`시군구 목록을 불러오는데 실패했습니다.
새로고침 후 다시 시도해주세요.`);
}
},
onError: () => {
alert(`시군구 목록을 불러오는데 실패했습니다.
새로고침 후 다시 시도해주세요.`);
},
},
);
const sggOptions = sggList?.data ?? [];
const handleSidoChange = (value: string) => {
const selectedBsnm =
sidoList?.data.sidoList.find((v) => v.sido === value)?.bsnmCd ?? "";
setSido(value);
setGugun("ALL");
setBsnmCd(selectedBsnm);
};
const handleSggChange = (value: string) => {
const listForBsnm = sggList?.data ?? [];
const selectedBsnm =
value === "ALL"
? (sidoList?.data.sidoList.find((v) => v.sido === sido)
?.bsnmCd ?? "")
: (listForBsnm.find((v) => v.gugun === value)?.bsnmCd ?? "");
setGugun(value);
setBsnmCd(selectedBsnm);
};
const loadLogs = useCallback(
async (page1Based: number) => {
const param: StatisticsLogRequest = {
statsCd: statsCd === "" || statsCd === "ALL" ? "" : statsCd,
page: page1Based,
size: PAGE_SIZE,
startDate: startDate
? dayjs(startDate).format("YYYY-MM-DD")
: "",
endDate: endDate ? dayjs(endDate).format("YYYY-MM-DD") : "",
...(!(statsCd === "" || statsCd === "ALL") && { statsCd }),
...(sido && sido !== "ALL" && { ctpvNm: sido }),
...(gugun && gugun !== "ALL" && { sggNm: gugun }),
...(bsnmCd && { sewerBzmnCd: bsnmCd }),
...(statsDataChgComCd &&
statsDataChgComCd !== "ALL" && {
statsDataChgComCd,
}),
};
setIsLoading(true);
try {
setIsLoading(true);
const response = await statisticsAPi.getLogList(param);
if (response.status === "success") {
const lostList = response.data?.statsLogList ?? [];
const convertedData: StatisticsLog[] = lostList.map(
(item: StatisticsLog) => ({
...item,
dmndFwkNm:
item.dmndFwkNm === "save"
? "저장"
: item.dmndFwkNm === "submit"
? "제출"
: "예외 제출",
}),
);
const response = await statisticsApi.getLogList(param);
setRowData(toGridRows(convertedData));
setTotalPages(response.data?.pageInfo?.totalPages ?? 0);
setPageIndex(Math.max(0, page1Based - 1));
} else {
alert(
response.message || "데이터 조회 중 오류가 발생했습니다.",
);
if (response.status === "success") {
const lostList = response.data?.statsLogList ?? [];
setRowData(toGridRows(lostList));
setTotalPages(response.data?.pageInfo?.totalPages ?? 0);
setPageIndex(Math.max(0, page1Based - 1));
} else {
alert(
response.message ||
"데이터 조회 중 오류가 발생했습니다.",
);
}
} catch {
alert("데이터 조회 중 오류가 발생했습니다.");
} finally {
setIsLoading(false);
}
},
[statsCd],
[
statsCd,
startDate,
endDate,
sido,
gugun,
bsnmCd,
statsDataChgComCd,
alert,
],
);
const handleSearch = async (e: React.SubmitEvent<HTMLFormElement>) => {
const handleSearch = async (e: React.FormEvent<HTMLFormElement>) => {
e.preventDefault();
await loadLogs(1);
};
const handleReset = () => {
setStatsCd("");
setSido("ALL");
setGugun("ALL");
setBsnmCd("");
setStatsDataChgComCd("ALL");
setStartDate(dayjs().subtract(1, "month").startOf("day").toDate());
setEndDate(dayjs().endOf("day").toDate());
};
const handleRowClick = (row: Record<string, string | number>) => {
setSelectedLogRow(row);
setDetailDialogOpen(true);
@@ -86,14 +216,58 @@ const LogHistory = () => {
};
useEffect(() => {
loadLogs(1);
void loadLogs(1);
}, []);
return (
<div className="relative">
<div className="card card-border">
<form onSubmit={handleSearch}>
<div className="flex items-center gap-8 p-2">
<div className="flex flex-wrap items-center gap-8 p-2">
<div className="flex items-center gap-2">
<Label className="text-label">시도</Label>
<Select
value={sido}
onValueChange={handleSidoChange}
>
<SelectTrigger className="w-[12rem]">
<SelectValue placeholder="전체" />
</SelectTrigger>
<SelectContent>
<SelectItem value="ALL">전체</SelectItem>
{sidoList?.data?.sidoList?.map((item) => (
<SelectItem
key={item.adstDstrCd}
value={item.sido}
>
{item.sido}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
<div className="flex items-center gap-2">
<Label className="text-label">시군구</Label>
<Select
value={gugun}
onValueChange={handleSggChange}
>
<SelectTrigger className="w-[12rem]">
<SelectValue placeholder="전체" />
</SelectTrigger>
<SelectContent>
<SelectItem value="ALL">전체</SelectItem>
{sggOptions.map((sgg) => (
<SelectItem
key={sgg.adstDstrCd}
value={sgg.gugun}
>
{sgg.gugun}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
<div className="flex items-center gap-2">
<Label className="text-label">통계항목</Label>
<CodeSelectWithAll
@@ -104,11 +278,36 @@ const LogHistory = () => {
className="w-[16rem]"
/>
</div>
<div className="flex items-center gap-2">
<Label className="text-label">요청 기능</Label>
<CodeSelectWithAll
upCd="STATSDATACHG"
value={statsDataChgComCd}
onValueChange={setStatsDataChgComCd}
placeholder="전체"
className="w-[16rem]"
/>
</div>
<div className="flex items-center gap-2">
<Label className="mx-2 text-label">시작일</Label>
<InputDatePicker
value={startDate}
onChange={setStartDate}
className="w-[14rem]"
/>
<Label className="mx-2 text-label">종료일</Label>
<InputDatePicker
value={endDate}
onChange={setEndDate}
className="w-[14rem]"
/>
</div>
<div className="ml-auto flex items-center gap-2">
<Button
color="green"
className="h-[3.2rem] w-[96px]"
type="button"
onClick={handleReset}
>
초기화
</Button>
@@ -12,7 +12,7 @@ import {
DialogTitle,
} from "@common/components";
import { statisticsAPi } from "@/entities/statistics/api/statistics-service";
import { statisticsApi } from "@/entities/statistics/api/statistics-service";
export interface LogHistoryDetailDialogProps {
open: boolean;
@@ -30,7 +30,7 @@ export function LogHistoryDetailDialog({
const sn = row.statsLogSn;
void (async () => {
const response = await statisticsAPi.getLogDetail(
const response = await statisticsApi.getLogDetail(
sn !== undefined && sn !== null ? String(sn) : "",
);
console.log(response.data);
@@ -0,0 +1,99 @@
"use client";
import { useEffect, useState } from "react";
import {
Button,
Dialog,
DialogClose,
DialogContent,
DialogFooter,
DialogHeader,
DialogTitle,
Label,
Textarea,
} from "@common/components";
export interface ReturnReasonDialogProps {
open: boolean;
onOpenChange: (open: boolean) => void;
onSave?: (reason: string) => void | Promise<void>;
}
export function ReturnReasonDialog({
open,
onOpenChange,
onSave,
}: ReturnReasonDialogProps) {
const [reason, setReason] = useState("");
const [isSaving, setIsSaving] = useState(false);
const [reasonError, setReasonError] = useState(false);
useEffect(() => {
if (!open) return;
setReason("");
setReasonError(false);
}, [open]);
const handleSave = async () => {
if (reason.trim() === "") {
setReasonError(true);
return;
}
try {
setIsSaving(true);
await onSave?.(reason);
onOpenChange(false);
} finally {
setIsSaving(false);
}
};
return (
<Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent className="max-w-[56rem]" aria-describedby={undefined}>
<DialogHeader>
<DialogTitle>반려 사유</DialogTitle>
</DialogHeader>
<div className="flex flex-col gap-4 py-2">
<div className="flex flex-col gap-2">
<Label className="text-label">반려 사유</Label>
<Textarea
value={reason}
onChange={(e) => {
setReason(e.target.value);
if (reasonError) {
setReasonError(false);
}
}}
className="min-h-[12rem] resize-y !text-[1.3rem] !leading-6"
placeholder="반려 사유를 입력하세요."
disabled={isSaving}
/>
{reasonError && (
<p className="text-[1.2rem] text-red-500">
반려 사유를 작성해주세요.
</p>
)}
</div>
</div>
<DialogFooter>
<DialogClose asChild>
<Button type="button" color="gray" disabled={isSaving}>
취소
</Button>
</DialogClose>
<Button
type="button"
color="primary"
disabled={isSaving}
onClick={() => void handleSave()}
>
저장
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
);
}
@@ -0,0 +1,168 @@
"use client";
import { useEffect, useState } from "react";
import {
Button,
Dialog,
DialogClose,
DialogContent,
DialogFooter,
DialogHeader,
DialogTitle,
Label,
Textarea,
} from "@common/components";
import { getUserInfo } from "../../../../../../packages/common/components/src/api";
import type { ReviewOpinionSaveRequest } from "@/entities/statistics/model/statistics-interface";
import { useAlertStore } from "@/shared/lib";
export type ReviewOpinionDialogContext = Pick<
ReviewOpinionSaveRequest,
"statsYr" | "sewerBzmnCd" | "statsCd"
>;
export interface ReviewOpinionDialogProps {
open: boolean;
onOpenChange: (open: boolean) => void;
/** 조회 성공 후의 통계연도·사업자코드·통계코드 (없으면 저장 불가) */
context: ReviewOpinionDialogContext | null;
/** 다이얼로그 열릴 때 검토의견 초기값 */
initialReviewOpinion?: string;
/** 다이얼로그 열릴 때 처리내역 초기값 */
initialProcessHistory?: string;
/** 저장 시 생성된 param 전달 (API 호출 등) */
onSave?: (param: ReviewOpinionSaveRequest) => void | Promise<void>;
}
export function ReviewOpinionDialog({
open,
onOpenChange,
context,
initialReviewOpinion = "",
initialProcessHistory = "",
onSave,
}: ReviewOpinionDialogProps) {
const { setMessage: alert } = useAlertStore((state) => state);
const [reviewOpinion, setReviewOpinion] = useState("");
const [processHistory, setProcessHistory] = useState("");
const [isSaving, setIsSaving] = useState(false);
const [reviewOpinionError, setReviewOpinionError] = useState(false);
const resetForm = () => {
setReviewOpinion("");
setProcessHistory("");
setReviewOpinionError(false);
};
const handleDialogOpenChange = (nextOpen: boolean) => {
if (!nextOpen) resetForm();
onOpenChange(nextOpen);
};
useEffect(() => {
if (!open) return;
setReviewOpinion(initialReviewOpinion);
setProcessHistory(initialProcessHistory);
}, [open, initialReviewOpinion, initialProcessHistory]);
const handleSave = async () => {
if (reviewOpinion.trim() === "") {
setReviewOpinionError(true);
return;
}
if (
context == null ||
context.statsYr === "" ||
context.sewerBzmnCd === ""
) {
alert("시도·시군구·년도로 조회한 뒤 검토의견을 저장할 수 있습니다.");
return;
}
try {
setIsSaving(true);
let userInfo: Awaited<ReturnType<typeof getUserInfo>>;
try {
userInfo = await getUserInfo();
} catch {
alert("사용자 정보를 불러오지 못했습니다. 다시 시도해주세요.");
return;
}
const param: ReviewOpinionSaveRequest = {
statsYr: context.statsYr,
sewerBzmnCd: context.sewerBzmnCd,
statsCd: context.statsCd,
splmntOpnnCn: reviewOpinion,
wrterOpnnCn: processHistory,
mdfrId: userInfo.userUnqId,
};
await onSave?.(param);
resetForm();
onOpenChange(false);
} catch {
/* 저장 실패 시 부모에서 alert 후 throw — 다이얼로그는 유지 */
} finally {
setIsSaving(false);
}
};
return (
<Dialog open={open} onOpenChange={handleDialogOpenChange}>
<DialogContent className="max-w-[56rem]" aria-describedby={undefined}>
<DialogHeader>
<DialogTitle>검토의견작성</DialogTitle>
</DialogHeader>
<div className="flex flex-col gap-4 py-2">
<div className="flex flex-col gap-2">
<Label className="text-label">검토의견</Label>
<Textarea
value={reviewOpinion}
onChange={(e) => {
setReviewOpinion(e.target.value);
if (reviewOpinionError) {
setReviewOpinionError(false);
}
}}
className="min-h-[10rem] resize-y !text-[1.3rem] !leading-6"
placeholder="검토의견을 입력하세요."
disabled={isSaving}
/>
{reviewOpinionError && (
<p className="text-[1.2rem] text-red-500">
검토의견을 작성해주세요.
</p>
)}
</div>
<div className="flex flex-col gap-2">
<Label className="text-label">처리내역</Label>
<Textarea
value={processHistory}
onChange={(e) => setProcessHistory(e.target.value)}
className="min-h-[10rem] resize-y !text-[1.3rem] !leading-6"
placeholder="처리내역을 입력하세요."
disabled={isSaving}
/>
</div>
</div>
<DialogFooter>
<DialogClose asChild>
<Button type="button" color="gray" disabled={isSaving}>
취소
</Button>
</DialogClose>
<Button
type="button"
color="primary"
disabled={isSaving}
onClick={() => void handleSave()}
>
저장
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
);
}
@@ -1,17 +1,17 @@
import { useMemo, useRef,useState } from "react";
import { useMemo, useRef, useState } from "react";
import { useAlertStore } from "@/shared/lib";
import { statisticsManagementApi } from "@/entities/statistics/api/statistics-management-service";
import type { SewerPipeMtcRequestParams } from "@/entities/statistics/model/statistics-sewage-pipe-mtc-review-interface";
import { LoadingWithAnimation } from "@common/components";
import { LoadingWithAnimation, Tabs, TabsContent, TabsList, TabsTrigger } from "@common/components";
import ApproveButton from "@/components/button/ApproveButton";
import ReturnButton from "@/components/button/ReturnButton";
import ReviewOpinionButton from "@/components/button/ReviewOpinionButton";
import SaveButton from "@/components/button/SaveButton";
import SearchForm from "@/components/module/SearchForm";
import TabDataGrid from "@/components/module/TabDataGrid";
import type { ColumnConfig,ColumnInfoList } from "@/components/type/DataGrid.type";
import DataGrid from "@/components/module/DataGrid";
import type { ColumnConfig, ColumnInfo, ColumnInfoList } from "@/components/type/DataGrid.type";
import type { SearchFormValues } from "@/components/type/SearchForm.type";
import { SewagePipeMaintainColumnInfoList } from "@/components/util/column/review/SewagePipeMaintainRvwColumn";
import { toGridRows } from "@/components/util/grid/gridFunction";
@@ -79,8 +79,8 @@ const SewagePipeMaintainRvw = () => {
};
const handleSearch = async (values: SearchFormValues) => {
if (!values.sido || values.sido === 'ALL') {
alert('시도를 선택해주세요.');
if (!values.sido || values.sido === "ALL") {
alert("시도를 선택해주세요.");
return;
}
@@ -91,26 +91,27 @@ const SewagePipeMaintainRvw = () => {
statsYr: values.year,
sewerBzmnCd: values.bsnmCd,
ctpvNm: values.sido,
sggNm: values.gugun === 'ALL' ? '' : values.gugun
sggNm: values.gugun === "ALL" ? "" : values.gugun,
};
searchParamRef.current = searchParam;
searchDoneRef.current = true;
lazyTabLoggedRef.current.clear();
setRowDataMap(new Map());
const response = await fetchSewagePipeMaintainReviewTab(activeTab, searchParam);
if (response?.status === 'success' && response.data) {
if (response?.status === "success" && response.data) {
const listData = getListDataByTab(activeTab, response.data);
setRowDataMap(
new Map([[activeTab, toGridRows(listData)]]),
);
setRowDataMap((prev) => {
const next = new Map(prev);
next.set(activeTab, toGridRows(listData));
return next;
});
} else {
alert(response?.message || "데이터가 없습니다.");
}
} catch {
alert('데이터 조회 중 오류가 발생했습니다.');
alert("데이터 조회 중 오류가 발생했습니다.");
} finally {
setIsLoading(false);
}
@@ -177,14 +178,60 @@ const SewagePipeMaintainRvw = () => {
</div>
)}
<TabDataGrid
columnInfo={columnInfo}
isExcelImport={false}
rowDataMap={rowDataMap}
setRowDataMap={setRowDataMap}
activeTab={activeTab}
onActiveTabChange={handleActiveTabChange}
/>
<div className="flex flex-col gap-[0.6rem] mx-[0.6rem]">
<Tabs
value={activeTab}
onValueChange={(v) => void handleActiveTabChange(v)}
className="w-full"
variant="default"
>
<TabsList>
{columnInfo.map((info) => (
<TabsTrigger key={info.tabKey} value={info.tabKey}>
{info.tabLabel || info.tabKey}
</TabsTrigger>
))}
</TabsList>
{columnInfo.map((info) => {
const tabKey = info.tabKey;
if (!info.columns || !info.columnLayout) {
return null;
}
const rowData = rowDataMap.get(tabKey) ?? [];
const gridColumnInfo: ColumnInfo = {
columns: info.columns,
columnLayout: info.columnLayout,
};
return (
<TabsContent key={tabKey} value={tabKey}>
<DataGrid
columnInfo={gridColumnInfo}
rowStartNum={info.rowStartNum}
excelExportFileSuffix={info.tabLabel || tabKey}
rowData={rowData}
setRowData={(updater) => {
setRowDataMap((prev) => {
const next = new Map(prev);
const cur = next.get(tabKey) ?? [];
const n =
typeof updater === "function" ? updater(cur) : updater;
next.set(tabKey, n);
return next;
});
}}
isExcelImport={false}
isExcelExport
showHeaderCheckbox={false}
fixedColumns={info.fixedColumns}
enableSort={false}
/>
</TabsContent>
);
})}
</Tabs>
</div>
</div>
);
};
@@ -1,17 +1,17 @@
import { useMemo, useRef,useState } from "react";
import { useRef, useState } from "react";
import { useAlertStore } from "@/shared/lib";
import { statisticsManagementApi } from "@/entities/statistics/api/statistics-management-service";
import type { SewagePipeReviewRequestParams } from "@/entities/statistics/model/statistics-sewage-pipe-review-interface";
import { LoadingWithAnimation } from "@common/components";
import { LoadingWithAnimation, Tabs, TabsContent, TabsList, TabsTrigger } from "@common/components";
import ApproveButton from "@/components/button/ApproveButton";
import ReturnButton from "@/components/button/ReturnButton";
import ReviewOpinionButton from "@/components/button/ReviewOpinionButton";
import SaveButton from "@/components/button/SaveButton";
import SearchForm from "@/components/module/SearchForm";
import TabDataGrid from "@/components/module/TabDataGrid";
import type { ColumnConfig,ColumnInfoList } from "@/components/type/DataGrid.type";
import DataGrid from "@/components/module/DataGrid";
import type { ColumnInfo } from "@/components/type/DataGrid.type";
import type { SearchFormValues } from "@/components/type/SearchForm.type";
import { SewagePipeColumnInfoListRvw } from "@/components/util/column/review/SewagePipeRvwColumn";
import { toGridRows } from "@/components/util/grid/gridFunction";
@@ -48,17 +48,6 @@ const SewagePipeRvw = () => {
const searchDoneRef = useRef(false);
const lazyTabLoggedRef = useRef<Set<string>>(new Set());
const columnInfo = useMemo(() => {
return SewagePipeColumnInfoListRvw.map((tab: ColumnInfoList) => ({
...tab,
columns: (tab.columns ?? []).map((col: ColumnConfig) => ({
...col,
editable: false as const,
styleName: col.styleName ?? ("rg-cell-readonly" as const),
})),
}));
}, []);
const handleReset = () => {
setRowDataMap(new Map());
setActiveTab(firstTabKey);
@@ -86,14 +75,15 @@ const SewagePipeRvw = () => {
searchParamRef.current = searchParam;
searchDoneRef.current = true;
lazyTabLoggedRef.current.clear();
setRowDataMap(new Map());
const response = await fetchSewagePipeReviewTab(activeTab, searchParam);
if (response?.status === "success" && response.data) {
setRowDataMap(
new Map([[activeTab, toGridRows(response.data as object[])]]),
);
setRowDataMap((prev) => {
const next = new Map(prev);
next.set(activeTab, toGridRows(response.data as object[]));
return next;
});
} else {
alert(response?.message || "데이터 조회 중 오류가 발생했습니다.");
}
@@ -163,14 +153,60 @@ const SewagePipeRvw = () => {
</div>
)}
<TabDataGrid
columnInfo={columnInfo}
isExcelImport={false}
rowDataMap={rowDataMap}
setRowDataMap={setRowDataMap}
activeTab={activeTab}
onActiveTabChange={handleActiveTabChange}
/>
<div className="flex flex-col gap-[0.6rem] mx-[0.6rem]">
<Tabs
value={activeTab}
onValueChange={(v) => void handleActiveTabChange(v)}
className="w-full"
variant="default"
>
<TabsList>
{SewagePipeColumnInfoListRvw.map((info) => (
<TabsTrigger key={info.tabKey} value={info.tabKey}>
{info.tabLabel || info.tabKey}
</TabsTrigger>
))}
</TabsList>
{SewagePipeColumnInfoListRvw.map((info) => {
const tabKey = info.tabKey;
if (!info.columns || !info.columnLayout) {
return null;
}
const rowData = rowDataMap.get(tabKey) ?? [];
const gridColumnInfo: ColumnInfo = {
columns: info.columns,
columnLayout: info.columnLayout,
};
return (
<TabsContent key={tabKey} value={tabKey}>
<DataGrid
columnInfo={gridColumnInfo}
rowStartNum={info.rowStartNum}
excelExportFileSuffix={info.tabLabel || tabKey}
rowData={rowData}
setRowData={(updater) => {
setRowDataMap((prev) => {
const next = new Map(prev);
const cur = next.get(tabKey) ?? [];
const n =
typeof updater === "function" ? updater(cur) : updater;
next.set(tabKey, n);
return next;
});
}}
isExcelImport={false}
isExcelExport
showHeaderCheckbox={false}
fixedColumns={info.fixedColumns}
enableSort={false}
/>
</TabsContent>
);
})}
</Tabs>
</div>
</div>
);
};
@@ -5,13 +5,22 @@ import ReviewOpinionButton from "@/components/button/ReviewOpinionButton";
import ReturnButton from "@/components/button/ReturnButton";
import SubmitButton from "@/components/button/SumitButton";
import { LoadingWithAnimation } from "@common/components";
import { ReturnReasonDialog } from "@/components/statistics/review/ReturnReasonDialog";
import { getUserInfo } from "../../../../../../packages/common/components/src/api";
import { useAlertStore } from "@/shared/lib";
import { useConfirm } from "@frontend-opensource/use-react-hooks";
import { SeweragePenetrationRateInfoList } from "@/components/util/column/review/SeweragePenetrationRateRvwColumn";
import type { SewerageSupplyRateReviewItem, statsStatusInfo } from "@/entities/statistics/model/statistics-sewerage-penetration-review-interface";
import type { ReviewOpinionSaveRequest } from "@/entities/statistics/model/statistics-interface";
import { statisticsApi } from "@/entities/statistics/api/statistics-service";
import { statisticsManagementApi } from "@/entities/statistics/api/statistics-management-service";
import type { SearchFormValues } from "@/components/type/SearchForm.type";
import { toGridRows } from "@/components/util/grid/gridFunction";
import {
ReviewOpinionDialog,
type ReviewOpinionDialogContext,
} from "@/components/statistics/review/ReviewOpinionDialog";
// 하수도 통계 검토 01.하수도 보급률
const SeweragePenetrationRateRvw = () => {
@@ -19,6 +28,14 @@ const SeweragePenetrationRateRvw = () => {
const [rowData, setRowData] = useState<Record<string, string | number>[]>([]);
const [isLoading, setIsLoading] = useState<boolean>(false);
const [statsStatusInfo, setStatsStatusInfo] = useState<statsStatusInfo>({});
const [reviewOpinionOpen, setReviewOpinionOpen] = useState(false);
const [returnDialogOpen, setReturnDialogOpen] = useState(false);
const [reviewOpinionContext, setReviewOpinionContext] =
useState<ReviewOpinionDialogContext | null>(null);
const [latestReviewOpinion, setLatestReviewOpinion] = useState("");
const [latestProcessHistory, setLatestProcessHistory] = useState("");
const [lastSearchValues, setLastSearchValues] = useState<SearchFormValues | null>(null);
const { confirm } = useConfirm();
const columnInfo = useMemo(() => {
const { columns, columnLayout, fixedColumns, rowStartNum } = SeweragePenetrationRateInfoList;
@@ -35,14 +52,14 @@ const SeweragePenetrationRateRvw = () => {
}, []);
const { showReviewOpinionButton, showReturnButton, showSubmitButton } = useMemo(() => {
const lae = statsStatusInfo.laeSbmsnSttsCd != null ? String(statsStatusInfo.laeSbmsnSttsCd) : "";
const pic = statsStatusInfo.statPicSbmsnSttsCd != null ? String(statsStatusInfo.statPicSbmsnSttsCd) : "";
const laeIs3 = lae === "3";
const reviewSubmitVisible = laeIs3 && (pic === "0" || pic === "1");
const submit = statsStatusInfo.submitStatusCd != null ? String(statsStatusInfo.submitStatusCd) : "";
const prvn = statsStatusInfo.prvnStatusCd != null ? String(statsStatusInfo.prvnStatusCd) : "";
const submitIs3 = submit === "3";
const reviewSubmitVisible = submitIs3 && (prvn === "0" || prvn === "1");
return {
showReviewOpinionButton: reviewSubmitVisible,
showSubmitButton: reviewSubmitVisible,
showReturnButton: laeIs3 && pic !== "3",
showReturnButton: submitIs3 && prvn !== "3",
};
}, [statsStatusInfo]);
@@ -52,6 +69,10 @@ const SeweragePenetrationRateRvw = () => {
const handleReset = () => {
setRowData([]);
setStatsStatusInfo({});
setReviewOpinionContext(null);
setLatestReviewOpinion("");
setLatestProcessHistory("");
setLastSearchValues(null);
};
const handleSearch = async (values: SearchFormValues) => {
@@ -60,6 +81,7 @@ const SeweragePenetrationRateRvw = () => {
alert('시도를 선택해주세요.');
return;
}
setLastSearchValues(values);
try {
setIsLoading(true);
@@ -80,9 +102,23 @@ const SeweragePenetrationRateRvw = () => {
}));
setStatsStatusInfo(response.data.statsStatusInfo);
setReviewOpinionContext({
statsYr: values.year ?? "",
sewerBzmnCd: values.bsnmCd ?? "",
statsCd: response.data.statsStatusInfo.statsCd ?? "",
});
setLatestReviewOpinion(
response.data.statsStatusInfo.splmntOpnnCn ?? "",
);
setLatestProcessHistory(
response.data.statsStatusInfo.wrterOpnnCn ?? "",
);
setRowData(toGridRows(convertedData));
} else {
setRowData([]);
setReviewOpinionContext(null);
setLatestReviewOpinion("");
setLatestProcessHistory("");
alert(response.message || "데이터가 없습니다.");
}
} catch (error) {
@@ -93,11 +129,120 @@ const SeweragePenetrationRateRvw = () => {
}
};
const submitFunc = () => console.log('submit');
const submitFunc = async () => {
if (reviewOpinionContext == null) {
alert("조회 후 제출할 수 있습니다.");
return;
}
const reviewOpinionFunc = () => console.log('reviewOpinionFunc');
if (!(await confirm("제출 하시겠습니까?"))) {
return;
}
const returnFunc = () => console.log('returnFunc');
try {
const userInfo = await getUserInfo();
const response = await statisticsApi.submitReview({
statsYr: reviewOpinionContext.statsYr,
sewerBzmnCd: reviewOpinionContext.sewerBzmnCd,
statsCd: reviewOpinionContext.statsCd,
mdfrId: userInfo.userUnqId,
});
if (response.status !== "success") {
alert(response.message || "제출에 실패했습니다.");
return;
}
alert(response.message || "제출되었습니다.");
if (lastSearchValues != null) {
await handleSearch(lastSearchValues);
}
} catch {
alert("제출 중 오류가 발생했습니다.");
}
};
const reviewOpinionFunc = async () => {
if (reviewOpinionContext == null) {
alert("조회 후 검토의견을 작성할 수 있습니다.");
return;
}
try {
const detailRes = await statisticsApi.getReviewOpinionDetail({
statsYr: reviewOpinionContext.statsYr,
sewerBzmnCd: reviewOpinionContext.sewerBzmnCd,
statsCd: reviewOpinionContext.statsCd,
});
if (detailRes.status !== "success") {
alert(detailRes.message || "검토의견 조회에 실패했습니다.");
return;
}
const row = detailRes.data?.[0];
setLatestReviewOpinion(row?.splmntOpnnCn ?? "");
setLatestProcessHistory(row?.wrterOpnnCn ?? "");
setReviewOpinionOpen(true);
} catch {
alert("검토의견 조회 중 오류가 발생했습니다.");
}
};
const saveReviewOpinion = async (param: ReviewOpinionSaveRequest) => {
try {
const response = await statisticsApi.saveReviewOpinion(param);
if (response.status !== "success") {
alert(response.message || "저장에 실패했습니다.");
return;
}
alert(response.message || "저장되었습니다.");
setStatsStatusInfo((prev) => ({
...prev,
splmntOpnnCn: param.splmntOpnnCn,
wrterOpnnCn: param.wrterOpnnCn,
}));
setLatestReviewOpinion(param.splmntOpnnCn);
setLatestProcessHistory(param.wrterOpnnCn);
} catch {
alert("저장 중 오류가 발생했습니다.");
}
};
const returnFunc = () => setReturnDialogOpen(true);
const handleSaveReturnReason = async (reason: string) => {
if (reviewOpinionContext == null) {
alert("조회 후 반려할 수 있습니다.");
return;
}
if (await confirm('반려 처리 하시겠습니까?')) {
try {
const userInfo = await getUserInfo();
const response = await statisticsApi.saveReviewReturn({
statsYr: reviewOpinionContext.statsYr,
sewerBzmnCd: reviewOpinionContext.sewerBzmnCd,
statsCd: reviewOpinionContext.statsCd,
rjctOpnnCn: reason,
mdfrId: userInfo.userUnqId,
});
if (response.status !== "success") {
alert(response.message || "반려 처리 중 오류가 발생했습니다.");
return;
}
alert(response.message || "반려 처리 되었습니다.");
if (lastSearchValues != null) {
await handleSearch(lastSearchValues);
}
} catch {
alert("반려 처리 중 오류가 발생했습니다.");
}
}
};
return (
<div className="relative">
@@ -131,6 +276,20 @@ const SeweragePenetrationRateRvw = () => {
rowData={rowData}
setRowData={setRowData}
/>
<ReviewOpinionDialog
open={reviewOpinionOpen}
onOpenChange={setReviewOpinionOpen}
context={reviewOpinionContext}
initialReviewOpinion={latestReviewOpinion}
initialProcessHistory={latestProcessHistory}
onSave={saveReviewOpinion}
/>
<ReturnReasonDialog
open={returnDialogOpen}
onOpenChange={setReturnDialogOpen}
onSave={handleSaveReturnReason}
/>
</div>
);
};
@@ -1,23 +1,54 @@
import { useState, useMemo } from "react";
import { createColumnHelper, getCoreRowModel, useReactTable } from "@tanstack/react-table";
import DataGrid from "@/components/module/DataGrid";
import SearchForm from "@/components/module/SearchForm";
import SaveButton from "@/components/button/SaveButton";
import ApproveButton from "@/components/button/ApproveButton";
import ReviewOpinionButton from "@/components/button/ReviewOpinionButton";
import ReturnButton from "@/components/button/ReturnButton";
import { LoadingWithAnimation } from "@common/components";
import {
Button,
Dialog,
DialogContent,
DialogFooter,
DialogHeader,
DialogTitle,
LoadingWithAnimation,
DataTable
} from "@common/components";
import { useAlertStore } from "@/shared/lib";
import { StatsInputStatusInfoList } from "@/components/util/column/review/StatsInputStatusRvwColumn";
import { statisticsManagementApi } from "@/entities/statistics/api/statistics-management-service";
import type { ReviewHistoryResponse } from "@/entities/statistics/model/statistics-input-review-interface";
import { toGridRows } from "@/components/util/grid/gridFunction";
import type { SearchFormValues } from "@/components/type/SearchForm.type";
type ReviewHistoryRow = ReviewHistoryResponse["data"][number];
const checkPrvnStatusCd = (code: string) => {
return (code === '0' || code === '1') ? '미작성' : (code === '2' ? '제출완료' : (code === '3' ? '승인완료' : '반려'));
};
const checkSubmitStatusCd = (code: string) => {
return code === '0' ? '미작성' : (code === '1' ? '작성중' : (code === '2' ? '제출완료' : (code === '3' ? '승인완료' : '보완요청')));
};
// 하수도 통계 검토 00. 통계 입력 현황
const StatsInputStatusRvw = () => {
const { setMessage: alert } = useAlertStore((state) => state);
const [rowData, setRowData] = useState<Record<string, string | number>[]>([]);
const [historyRowData, setHistoryRowData] = useState<ReviewHistoryRow[]>([]);
const [isLoading, setIsLoading] = useState<boolean>(false);
const [searchedYear, setSearchedYear] = useState<string>("");
const [reviewHistoryOpen, setReviewHistoryOpen] = useState<boolean>(false);
const [historySearchInfo, setHistorySearchInfo] = useState<{
ctpvNm: string;
sggNm: string;
tabNm: string;
}>({
ctpvNm: '',
sggNm: '',
tabNm: '',
});
const columnInfo = useMemo(() => {
const { columns, columnLayout } = StatsInputStatusInfoList;
@@ -30,8 +61,30 @@ const StatsInputStatusRvw = () => {
};
}, []);
const reviewHistoryColumns = useMemo(() => {
const col = createColumnHelper<ReviewHistoryRow>();
return [
col.accessor("logDt", {
header: "등록일",
cell: (info) => info.getValue(),
}),
col.accessor("historyContent", {
header: "내용",
cell: (info) => info.getValue(),
}),
];
}, []);
const historyTable = useReactTable({
data: historyRowData,
columns: reviewHistoryColumns,
getCoreRowModel: getCoreRowModel(),
getRowId: (_row, index) => String(index),
});
const handleReset = () => {
setRowData([]);
setSearchedYear("");
};
const handleSearch = async (values: SearchFormValues) => {
@@ -42,24 +95,29 @@ const StatsInputStatusRvw = () => {
try {
setIsLoading(true);
setSearchedYear(values.year ?? "");
const response = await statisticsManagementApi.getStatsInputStatusList({
ctpvNm: values.sido,
sggNm: values.gugun === 'ALL' ? '' : values.gugun,
statsYr: values.year,
sggNm: values.gugun === 'ALL' ? '' : values.gugun,
statsYr: values.year,
sewerBzmnCd: values.bsnmCd
});
if (response.status === 'success' && response.data) {
const rawList = response.data.sewerageInputList ?? [];
const convertedData = rawList.map((r) => ({
...r,
prvnStatusNm: checkPrvnStatusCd(r.prvnStatusCd || '0'),
submitStatusNm: checkSubmitStatusCd(r.submitStatusCd || '0')
}));
setRowData(toGridRows(rawList));
setRowData(toGridRows(convertedData));
} else {
setRowData([]);
alert(response.message || "조회된 데이터가 없습니다.");
}
} catch (error) {
console.error("입력현황 조회 중 에러 발생:", error);
} catch {
alert('데이터 조회 중 오류가 발생했습니다.');
setRowData([]);
} finally {
@@ -68,20 +126,40 @@ const StatsInputStatusRvw = () => {
};
const approveFunc = () => console.log('approveFunc');
const saveFunc = () => console.log('submit');
const reviewOpinionFunc = () => console.log('reviewOpinionFunc');
const returnFunc = () => console.log('returnFunc');
const handleRowClick = async (row: Record<string, string | number>) => {
setHistorySearchInfo({
ctpvNm: String(row.ctpvNm ?? ""),
sggNm: String(row.sggNm ?? ""),
tabNm: String(row.tabNm ?? ""),
});
const params = {
statsYr: searchedYear,
sewerBzmnCd: String(row.sewerBzmnCd ?? ""),
statsCd: String(row.statsCd ?? ""),
};
try {
const response = await statisticsManagementApi.getReviewHistory(params);
if (response.status === 'success') {
setHistoryRowData(response.data);
setReviewHistoryOpen(true);
} else {
alert(response.message || '검토 이력 조회 중 오류가 발생했습니다.');
}
} catch {
alert('검토 이력 조회 중 오류가 발생했습니다.');
}
};
return (
<div className="relative">
<SearchForm onSubmit={handleSearch} onReset={handleReset} />
<div className="card !py-0">
<div className="flex justify-end items-center gap-[0.6rem] m-[0.6rem]">
<ReviewOpinionButton reviewOpinionFunc={reviewOpinionFunc} />
<ApproveButton approveFunc={approveFunc} />
<ReturnButton returnFunc={returnFunc} />
<SaveButton saveFunc={saveFunc} />
</div>
</div>
@@ -96,12 +174,43 @@ const StatsInputStatusRvw = () => {
<DataGrid
columnInfo={columnInfo}
isExcelImport={false}
showHeaderCheckbox={true}
rowStartNum={2}
showHeaderCheckbox={true}
rowStartNum={2}
fixedColumns={3}
rowData={rowData}
setRowData={setRowData}
onRowClick={handleRowClick}
/>
<Dialog open={reviewHistoryOpen} onOpenChange={setReviewHistoryOpen}>
<DialogContent
className="flex max-h-[85vh] max-w-[48rem] flex-col gap-4"
aria-describedby={undefined}
>
<DialogHeader className="shrink-0">
<DialogTitle>
{(historySearchInfo.sggNm ? `${historySearchInfo.ctpvNm} ${historySearchInfo.sggNm} - ${searchedYear}년 ${historySearchInfo.tabNm} 검토이력` : `${historySearchInfo.ctpvNm} - ${searchedYear}년 ${historySearchInfo.tabNm} 검토이력`)}
</DialogTitle>
</DialogHeader>
{historyRowData.length === 0 ?
<div className="text-2xl h-40 flex items-center justify-center">
검토 이력이 없습니다.
</div>
:
<div className="min-h-0 flex-1 overflow-y-auto overflow-x-auto">
<DataTable table={historyTable} />
</div>
}
<DialogFooter className="shrink-0">
<Button
type="button"
color="gray"
onClick={() => setReviewHistoryOpen(false)}
>
닫기
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
</div>
);
};
@@ -2,20 +2,22 @@ import { useMemo, useState } from "react";
import { useAlertStore } from "@/shared/lib";
import { statisticsTotalStatusApi } from "@/entities/statistics/api/statistics-total-service";
import { LoadingWithAnimation } from "@common/components";
import { LoadingWithAnimation, Tabs, TabsContent, TabsList, TabsTrigger } from "@common/components";
import SearchForm from "@/components/module/SearchForm";
import TabDataGrid from "@/components/module/TabDataGrid";
import type { ColumnConfig, ColumnInfoList } from "@/components/type/DataGrid.type";
import DataGrid from "@/components/module/DataGrid";
import type { ColumnConfig, ColumnInfo, ColumnInfoList } from "@/components/type/DataGrid.type";
import type { SearchFormValues } from "@/components/type/SearchForm.type";
import { SewagePipeColumnInfoListTot } from "@/components/util/column/total/SewagePipeTotColumn";
import { toGridRows } from "@/components/util/grid/gridFunction";
const firstTabKey = SewagePipeColumnInfoListTot[0]?.tabKey ?? "ST00020001";
const SewagePipeTot = () => {
const { setMessage: alert } = useAlertStore((state) => state);
const [rowDataMap, setRowDataMap] = useState<Map<string, Record<string, string | number>[]>>(new Map());
const [isLoading, setIsLoading] = useState(false);
const [activeTab, setActiveTab] = useState("ST00020001");
const [activeTab, setActiveTab] = useState(firstTabKey);
const columnInfo = useMemo(
() =>
@@ -32,11 +34,11 @@ const SewagePipeTot = () => {
const handleReset = () => {
setRowDataMap(new Map());
setActiveTab("ST00020001");
setActiveTab(firstTabKey);
};
const handleSearch = async (values: SearchFormValues) => {
setActiveTab("ST00020001");
setActiveTab(firstTabKey);
setRowDataMap(new Map());
try {
@@ -76,20 +78,66 @@ const SewagePipeTot = () => {
</div>
</div>
)}
<TabDataGrid
columnInfo={columnInfo}
isExcelImport={false}
rowDataMap={rowDataMap}
setRowDataMap={setRowDataMap}
activeTab={activeTab}
onActiveTabChange={setActiveTab}
cellStyleCallback={({ row }) => {
const rowType = String(row.rowType ?? "");
if (rowType === "DETAIL") return undefined;
if (rowType === "BIZ_SUM") return "rg-cell-rowtype-biz-sum";
return "rg-cell-rowtype-other";
}}
/>
<div className="flex flex-col gap-[0.6rem] mx-[0.6rem]">
<Tabs
value={activeTab}
onValueChange={setActiveTab}
className="w-full"
variant="default"
>
<TabsList>
{columnInfo.map((info) => (
<TabsTrigger key={info.tabKey} value={info.tabKey}>
{info.tabLabel || info.tabKey}
</TabsTrigger>
))}
</TabsList>
{columnInfo.map((info) => {
const tabKey = info.tabKey;
if (!info.columns || !info.columnLayout) {
return null;
}
const rowData = rowDataMap.get(tabKey) ?? [];
const gridColumnInfo: ColumnInfo = {
columns: info.columns,
columnLayout: info.columnLayout,
};
return (
<TabsContent key={tabKey} value={tabKey}>
<DataGrid
columnInfo={gridColumnInfo}
rowStartNum={info.rowStartNum}
excelExportFileSuffix={info.tabLabel || tabKey}
rowData={rowData}
setRowData={(updater) => {
setRowDataMap((prev) => {
const next = new Map(prev);
const cur = next.get(tabKey) ?? [];
const n =
typeof updater === "function" ? updater(cur) : updater;
next.set(tabKey, n);
return next;
});
}}
isExcelImport={false}
isExcelExport
showHeaderCheckbox={false}
fixedColumns={info.fixedColumns}
enableSort={false}
cellStyleCallback={({ row }) => {
const rowType = String(row.rowType ?? "");
if (rowType === "DETAIL") return undefined;
if (rowType === "BIZ_SUM") return "rg-cell-rowtype-biz-sum";
return "rg-cell-rowtype-other";
}}
/>
</TabsContent>
);
})}
</Tabs>
</div>
</div>
);
};
@@ -44,7 +44,7 @@ const SeweragePenetrationRateTot = () => {
return (
<>
<SearchForm isDetail={true} onSubmit={handleSearch} />
<SearchForm isDetail={true} onSubmit={handleSearch} sidoSelectText="전체" />
{isLoading && <div className="absolute inset-0 z-50 [data-theme='black']:bg-transparent rounded-lg p-10 flex justify-center items-center">
<div className="relative size-[5rem]">
<LoadingWithAnimation color="primary" />
@@ -1,10 +1,123 @@
import SearchForm from "../../module/SearchForm";
import { useEffect, useMemo, useState } from "react";
import { useAlertStore } from "@/shared/lib";
import { statisticsTotalStatusApi } from "@/entities/statistics/api/statistics-total-service";
import { toGridRows } from "@/components/util/grid/gridFunction";
import SearchForm from "@/components/module/SearchForm";
import TabDataGrid from "@/components/module/TabDataGrid";
import { LoadingWithAnimation } from "@common/components";
import type { ColumnConfig, ColumnInfoList } from "@/components/type/DataGrid.type";
import type { SearchFormValues } from "@/components/type/SearchForm.type";
import { ComplaintInpColumn } from "@/components/util/column/total/sewerageServiceLevel/ComplaintTotColumn";
import { ResidentFriendlyFactTotColumn } from "@/components/util/column/total/sewerageServiceLevel/ResidentFriendlyFactTotColumn";
const SewerageServiceLevelInpTabList = [
ComplaintInpColumn,
ResidentFriendlyFactTotColumn,
];
const SewerageServiceLevelTot = () => {
const { setMessage: alert } = useAlertStore((state) => state);
const [rowDataMap, setRowDataMap] = useState<Map<string, Record<string, string | number>[]>>(new Map());
const [isLoading, setIsLoading] = useState<boolean>(false);
const [activeTab, setActiveTab] = useState(ComplaintInpColumn.tabKey);
const [lastSearchValues, setLastSearchValues] = useState<SearchFormValues | null>(null);
const columnInfo = useMemo(
() =>
SewerageServiceLevelInpTabList.map((tab: ColumnInfoList) => ({
...tab,
columns: (tab.columns ?? []).map((col: ColumnConfig) => ({
...col,
editable: false as const,
styleName: col.styleName ?? ("rg-cell-readonly" as const),
})),
})),
[],
);
const handleReset = () => {
setRowDataMap(new Map());
setActiveTab(ComplaintInpColumn.tabKey);
setLastSearchValues(null);
};
const fetchByTab = async (tabKey: string, values: SearchFormValues) => {
try {
setIsLoading(true);
const param = {
ctpvNm: values.sido === "ALL" ? "" : values.sido,
sggNm: values.gugun === "ALL" ? "" : values.gugun,
sewerBzmnCd: values.bsnmCd,
statsYr: values.year,
rowDataYn: values.isDetail,
};
let response;
if (tabKey === ResidentFriendlyFactTotColumn.tabKey) {
response =
await statisticsTotalStatusApi.getSewerageServiceLevelResidentFriendlyFact(param);
} else {
response =
await statisticsTotalStatusApi.getSewerageServiceLevelComplaint(param);
}
if (response.status === "success" && response.data) {
setRowDataMap((prev) => {
const next = new Map(prev);
next.set(tabKey, toGridRows(response.data as object[]));
return next;
});
} else {
alert(response.message || "데이터 조회 중 오류가 발생했습니다.");
}
} catch {
alert("데이터 조회 중 오류가 발생했습니다.");
} finally {
setIsLoading(false);
}
};
const handleSearch = async (values: SearchFormValues) => {
setLastSearchValues(values);
setRowDataMap(new Map());
await fetchByTab(activeTab, values);
};
useEffect(() => {
if (lastSearchValues == null) return;
if (rowDataMap.has(activeTab)) return;
void fetchByTab(activeTab, lastSearchValues);
}, [activeTab]);
return (
<>
<SearchForm />
</>
<div className="relative">
<SearchForm isDetail={true} onSubmit={handleSearch} onReset={handleReset} sidoSelectText="전체" />
{isLoading && (
<div className="absolute inset-0 z-50 [data-theme='black']:bg-transparent rounded-lg p-10 flex justify-center items-center">
<div className="relative size-[5rem]">
<LoadingWithAnimation color="primary" />
</div>
</div>
)}
<TabDataGrid
columnInfo={columnInfo}
isExcelImport={false}
rowDataMap={rowDataMap}
setRowDataMap={setRowDataMap}
activeTab={activeTab}
onActiveTabChange={setActiveTab}
cellStyleCallback={({ row }) => {
const rowType = String(row.rowType ?? "");
if (rowType === "DETAIL") return undefined;
if (rowType === "BIZ_SUM") return "rg-cell-rowtype-biz-sum";
return "rg-cell-rowtype-other";
}}
/>
</div>
);
};
@@ -0,0 +1,177 @@
"use client";
import { useEffect, useState } from "react";
import {
Button,
Dialog,
DialogClose,
DialogContent,
DialogFooter,
DialogHeader,
DialogTitle,
Input,
Label,
Textarea,
} from "@common/components";
export interface ApprovalFormData {
rjctOpnnCn: string;
lastCfmtnPicNm: string;
lastCfmtnPicTelNo: string;
lastCfmtnPicDeptNm: string;
lastCfmtnPicJbgdNm: string;
}
export interface ApprovalInputDialogProps {
open: boolean;
onOpenChange: (open: boolean) => void;
onConfirm?: (data: ApprovalFormData) => void | Promise<void>;
}
export function ApprovalInputDialog({
open,
onOpenChange,
onConfirm,
}: ApprovalInputDialogProps) {
const [rjctOpnnCn, setRjctOpnnCn] = useState("");
const [lastCfmtnPicNm, setLastCfmtnPicNm] = useState("");
const [lastCfmtnPicTelNo, setLastCfmtnPicTelNo] = useState("");
const [lastCfmtnPicDeptNm, setLastCfmtnPicDeptNm] = useState("");
const [lastCfmtnPicJbgdNm, setLastCfmtnPicJbgdNm] = useState("");
const [isSubmitting, setIsSubmitting] = useState(false);
const resetForm = () => {
setRjctOpnnCn("");
setLastCfmtnPicNm("");
setLastCfmtnPicTelNo("");
setLastCfmtnPicDeptNm("");
setLastCfmtnPicJbgdNm("");
};
const handleDialogOpenChange = (nextOpen: boolean) => {
if (!nextOpen) resetForm();
onOpenChange(nextOpen);
};
useEffect(() => {
if (!open) {
resetForm();
}
}, [open]);
const handleConfirm = async () => {
try {
setIsSubmitting(true);
const formData: ApprovalFormData = {
rjctOpnnCn: rjctOpnnCn.trim(),
lastCfmtnPicNm: lastCfmtnPicNm.trim(),
lastCfmtnPicTelNo: lastCfmtnPicTelNo.trim(),
lastCfmtnPicDeptNm: lastCfmtnPicDeptNm.trim(),
lastCfmtnPicJbgdNm: lastCfmtnPicJbgdNm.trim(),
};
await onConfirm?.(formData);
} finally {
setIsSubmitting(false);
}
};
return (
<Dialog open={open} onOpenChange={handleDialogOpenChange}>
<DialogContent
className="max-w-[56rem]"
aria-describedby={undefined}
>
<DialogHeader>
<DialogTitle>승인 정보 입력</DialogTitle>
</DialogHeader>
<div className="flex flex-col gap-4 py-2">
<div className="flex flex-col gap-2">
<Label className="text-label">최종 확인 담당자명</Label>
<Input
value={lastCfmtnPicNm}
onChange={(e) => {
setLastCfmtnPicNm(e.target.value);
}}
className="!text-[1.3rem]"
placeholder="담당자명을 입력하세요."
disabled={isSubmitting}
/>
</div>
<div className="flex flex-col gap-2">
<Label className="text-label">
최종 확인 담당자 전화번호
</Label>
<Input
value={lastCfmtnPicTelNo}
onChange={(e) => {
setLastCfmtnPicTelNo(e.target.value);
}}
className="!text-[1.3rem]"
placeholder="전화번호를 입력하세요."
disabled={isSubmitting}
/>
</div>
<div className="flex flex-col gap-2">
<Label className="text-label">
최종 확인 담당자 부서명
</Label>
<Input
value={lastCfmtnPicDeptNm}
onChange={(e) => {
setLastCfmtnPicDeptNm(e.target.value);
}}
className="!text-[1.3rem]"
placeholder="부서명을 입력하세요."
disabled={isSubmitting}
/>
</div>
<div className="flex flex-col gap-2">
<Label className="text-label">
최종 확인 담당자 직급명
</Label>
<Input
value={lastCfmtnPicJbgdNm}
onChange={(e) => {
setLastCfmtnPicJbgdNm(e.target.value);
}}
className="!text-[1.3rem]"
placeholder="직급명을 입력하세요."
disabled={isSubmitting}
/>
</div>
<div className="flex flex-col gap-2">
<Label className="text-label">반려 의견 내용</Label>
<Textarea
value={rjctOpnnCn}
onChange={(e) => setRjctOpnnCn(e.target.value)}
className="min-h-[10rem] resize-y !text-[1.3rem] !leading-6"
placeholder="반려 의견 내용을 입력하세요. (선택사항)"
disabled={isSubmitting}
/>
</div>
</div>
<DialogFooter>
<DialogClose asChild>
<Button
type="button"
color="gray"
disabled={isSubmitting}
>
취소
</Button>
</DialogClose>
<Button
type="button"
color="primary"
disabled={isSubmitting}
onClick={() => void handleConfirm()}
>
확인
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
);
}
File diff suppressed because it is too large Load Diff
+109 -75
View File
@@ -1,6 +1,7 @@
import React from "react";
import { ValueType } from "realgrid";
import type { RealGridReact } from "realgrid-react";
import { type SewerageSupplyRateStatusInfo } from "@/entities/statistics/model/input/statistics-sewage-supply-rate-input-interface";
@@ -18,30 +19,30 @@ export type RealGridColumnInfo = {
mergeRule?: { criteria: "value" | string };
sortable?: boolean;
footer?:
| {
text?: string;
expression?: "sum" | "avg" | "count" | "max" | "min";
numberFormat?: string;
valueCallback?: (
grid: GridView,
column: DataColumn,
footerIndex: number,
columnFooter: ColumnFooter,
value: number | string | undefined,
) => number | string;
}
| Array<{
text?: string;
expression?: "sum" | "avg" | "count" | "max" | "min";
numberFormat?: string;
valueCallback?: (
grid: GridView,
column: DataColumn,
footerIndex: number,
columnFooter: ColumnFooter,
value: number | string | undefined,
) => number | string;
}>;
| {
text?: string;
expression?: "sum" | "avg" | "count" | "max" | "min";
numberFormat?: string;
valueCallback?: (
grid: GridView,
column: DataColumn,
footerIndex: number,
columnFooter: ColumnFooter,
value: number | string | undefined,
) => number | string;
}
| Array<{
text?: string;
expression?: "sum" | "avg" | "count" | "max" | "min";
numberFormat?: string;
valueCallback?: (
grid: GridView,
column: DataColumn,
footerIndex: number,
columnFooter: ColumnFooter,
value: number | string | undefined,
) => number | string;
}>;
};
/** setColumns 메서드가 있는 GridView */
@@ -110,21 +111,21 @@ export type DataFieldValueCallback = (
export type ComputedFieldConfig =
| { fields: string[]; type: "sum" | "avg" }
| {
numeratorFields: string[];
denominatorFields: string[];
type: "pblswrgPopltnSuprtRate";
}
numeratorFields: string[];
denominatorFields: string[];
type: "pblswrgPopltnSuprtRate";
}
| {
numeratorFields: string[];
denominatorFields: string[];
type: "adtrtPopltnSuprtRate";
}
numeratorFields: string[];
denominatorFields: string[];
type: "adtrtPopltnSuprtRate";
}
| {
numeratorFields: string[];
denominatorFields: string[];
thirdFields: string[];
type: "swrgInstlrtRate";
};
numeratorFields: string[];
denominatorFields: string[];
thirdFields: string[];
type: "swrgInstlrtRate";
};
export type ColumnConfig = {
name: string;
@@ -147,31 +148,31 @@ export type ColumnConfig = {
valueType?: ValueType;
dataFormat?: string;
footer?:
| {
text?: string;
expression?: "sum" | "avg" | "count" | "max" | "min";
numberFormat?: string;
valueCallback?: (
grid: GridView,
column: DataColumn,
footerIndex: number,
columnFooter: ColumnFooter,
value: number | string | undefined,
) => number | string;
multiRow?: boolean; // 여러 행 footer 사용 여부
}
| Array<{
text?: string;
expression?: "sum" | "avg" | "count" | "max" | "min";
numberFormat?: string;
valueCallback?: (
grid: GridView,
column: DataColumn,
footerIndex: number,
columnFooter: ColumnFooter,
value: number | string | undefined,
) => number | string;
}>; // RealGrid에서 footer를 배열로 설정할 수 있음
| {
text?: string;
expression?: "sum" | "avg" | "count" | "max" | "min";
numberFormat?: string;
valueCallback?: (
grid: GridView,
column: DataColumn,
footerIndex: number,
columnFooter: ColumnFooter,
value: number | string | undefined,
) => number | string;
multiRow?: boolean; // 여러 행 footer 사용 여부
}
| Array<{
text?: string;
expression?: "sum" | "avg" | "count" | "max" | "min";
numberFormat?: string;
valueCallback?: (
grid: GridView,
column: DataColumn,
footerIndex: number,
columnFooter: ColumnFooter,
value: number | string | undefined,
) => number | string;
}>; // RealGrid에서 footer를 배열로 설정할 수 있음
};
export type FooterUserSpan = {
@@ -183,16 +184,16 @@ export type FooterUserSpan = {
export type ColumnLayoutItem =
| string
| {
name: string;
hideChildHeaders?: boolean;
header: { text: string };
items: (string | ColumnLayoutItem)[];
footerUserSpans?: FooterUserSpan[]; // footer 병합 설정
}
name: string;
hideChildHeaders?: boolean;
header: { text: string };
items: (string | ColumnLayoutItem)[];
footerUserSpans?: FooterUserSpan[]; // footer 병합 설정
}
| {
column: string; // 컬럼 이름 (footerUserSpans 사용 시)
footerUserSpans?: FooterUserSpan[]; // footer 병합 설정
};
column: string; // 컬럼 이름 (footerUserSpans 사용 시)
footerUserSpans?: FooterUserSpan[]; // footer 병합 설정
};
export type ColumnInfo = {
columns: ColumnConfig[];
@@ -202,7 +203,10 @@ export type ColumnInfo = {
export type GridTopButtonConfig = {
visible?: boolean;
text: string;
onClick?: (helpers: { openDialog: (title?: string) => void; closeDialog: () => void }) => void;
onClick?: (helpers: {
openDialog: (title?: string) => void;
closeDialog: () => void;
}) => void;
};
export type ColumnInfoList = {
@@ -225,15 +229,35 @@ export type CellStyleCallbackParams = {
row: Record<string, string | number>;
};
export type CellStyleCallback = (params: CellStyleCallbackParams) => string | undefined;
export type CellStyleCallback = (
params: CellStyleCallbackParams,
) => string | undefined;
/** 셀 hover 시 툴팁 문구 (반환 시 해당 문구 표시, 검증 오류 메시지 등) */
export type CellTooltipCallbackParams = { row: Record<string, string | number>; fieldName: string };
export type CellTooltipCallback = (params: CellTooltipCallbackParams) => string | undefined;
export type CellTooltipCallbackParams = {
row: Record<string, string | number>;
fieldName: string;
};
export type CellTooltipCallback = (
params: CellTooltipCallbackParams,
) => string | undefined;
/** 셀 편집 완료 시 호출되는 콜백 (RealGrid onEditRowChanged) */
export type CellEditedCallbackParams = {
itemIndex: number;
fieldName: string;
oldValue: string | number;
newValue: string | number;
};
export type CellEditedCallback = (
params: CellEditedCallbackParams,
) => void;
export type DataGridProps = {
columnInfo: ColumnInfo;
rowStartNum?: number; // 헤더 행 개수 (1부터 시작, 예: 2면 1행과 2행까지 헤더로 간주)
/** 있으면보내기 파일명: `{메뉴명}_{suffix}.xlsx` (TabDataGrid의 탭 라벨 접미사와 동일) */
excelExportFileSuffix?: string;
isExcelImport?: boolean; // 엑셀 가져오기 유무
isExcelExport?: boolean;
onRowsChange?: (rows: Record<string, string | number>[]) => void;
@@ -247,11 +271,20 @@ export type DataGridProps = {
cellStyleCallback?: CellStyleCallback;
/** 셀 hover 시 툴팁 문구 (예: verificationResult의 rsnExpln) */
cellTooltipCallback?: CellTooltipCallback;
/** 셀 편집 완료 시 호출 (RealGrid onEditRowChanged) */
onCellEdited?: CellEditedCallback;
/** 저장 등 시점에 그리드 현재 데이터를 가져올 수 있도록 할당 (편집 반영된 값) */
getGridDataRef?: {
current: (() => Record<string, string | number>[]) | null;
};
/** 외부에서 gridRef에 직접 접근하기 위한 ref (setRows/addRows/setValue 직접 호출용) */
getGridRef?: React.MutableRefObject<RealGridReact | null>;
onRowClick?: (rowData: Record<string, string | number>) => void; // 행 클릭 이벤트
/** CheckBar 행 체크 변경 */
onItemChecked?: (itemIndex: number, checked: boolean) => void;
/** CheckBar 헤더 전체 선택/해제 */
onItemAllChecked?: (checked: boolean) => void;
onScrollToBottom?: () => void;
statusInfo?: SewerageSupplyRateStatusInfo | null;
/** 강조 및 스크롤할 행의 ID */
highlightedRowId?: string | number | null;
@@ -263,6 +296,7 @@ export type DataGridProps = {
compactFullWidth?: boolean;
/** CompactDataGrid 전용: 그리드 영역 높이 Tailwind 클래스 */
compactGridHeightClass?: string;
gridHeight?: number;
};
export type TabDataGridProps = {
@@ -10,6 +10,7 @@ export type SearchFormValues = {
export type SearchFormProps = {
sido?: boolean;
sidoSelectText?: string;
gugun?: boolean;
year?: boolean;
order?: boolean;
@@ -22,7 +23,9 @@ export type SearchFormProps = {
defaultValues?: SearchFormValues;
};
export const defaultSearchFormProps: Required<Omit<SearchFormProps, 'onSubmit' | 'onReset' | 'onValuesChange' | 'defaultValues'>> = {
export const defaultSearchFormProps: Required<
Omit<SearchFormProps, "onSubmit" | "onReset" | "onValuesChange" | "defaultValues" | "sidoSelectText">
> = {
sido: true,
gugun: true,
year: true,
@@ -2,20 +2,26 @@ import { ValueType } from "realgrid";
export const LogHistoryColumn = {
columns: [
{ name: 'statsLogSn', fieldName: 'statsLogSn', header: { text: '일련번호' }, editable: false, valueType: ValueType.NUMBER, dataFormat: '#,##0' },
{ name: 'ctpvNm', fieldName: 'ctpvNm', header: { text: '시도' }, editable: false },
{ name: 'sggNm', fieldName: 'sggNm', header: { text: '시군구' }, editable: false },
{ name: 'statsYr', fieldName: 'statsYr', header: { text: '연도' }, editable: false },
{ name: 'statsLogSn', fieldName: 'statsLogSn', header: { text: '일련번호' }, editable: false, visible: false, valueType: ValueType.NUMBER, dataFormat: '#,##0' },
{ name: 'menuNm', fieldName: 'menuNm', header: { text: '메뉴명' }, editable: false },
{ name: 'tblNm', fieldName: 'tblNm', header: { text: '테이블명' }, visible: false, editable: false },
{ name: 'dmndFwkNm', fieldName: 'dmndFwkNm', header: { text: '요청 기능명' }, editable: false },
{ name: 'statsDataChgComNm', fieldName: 'statsDataChgComNm', header: { text: '요청 기능명' }, editable: false },
{ name: 'rgtrId', fieldName: 'rgtrId', header: { text: '등록자' }, editable: false, visible: false },
{ name: 'regDt', fieldName: 'regDt', header: { text: '등록일' }, editable: false },
],
columnLayout: [
'statsLogSn',
'ctpvNm',
'sggNm',
'statsYr',
'menuNm',
'tblNm',
'dmndFwkNm',
'statsDataChgComNm',
'rgtrId',
'regDt'
'regDt',
'statsLogSn',
]
};
@@ -0,0 +1,77 @@
import { ValueType } from "realgrid";
// 하수도 통계 관리 > 13. 하수도 서비스 수준 > tab 1: 민원
export const ComplaintInpColumn = {
columns: [
{ name: "ctpvNm", fieldName: "ctpvNm", header: { text: "시도" } },
{ name: "sggNm", fieldName: "sggNm", header: { text: "시군구" } },
{ name: "admdstNm", fieldName: "admdstNm", header: { text: "행정구역명" } },
{
name: "swtrmtPpltnCnt",
fieldName: "swtrmtPpltnCnt",
header: { text: "하수처리장민원건수(건)" },
valueType: ValueType.NUMBER,
dataFormat: "#,##0",
},
{
name: "whol",
fieldName: "whol",
header: { text: "합계" },
valueType: ValueType.NUMBER,
dataFormat: "#,##0",
},
{ name: "odorCnt", fieldName: "odorCnt", header: { text: "악취" }, valueType: ValueType.NUMBER, dataFormat: "#,##0" },
{ name: "swagePplCnt", fieldName: "swagePplCnt", header: { text: "하수관거" }, valueType: ValueType.NUMBER, dataFormat: "#,##0" },
{ name: "dschgWqCnt", fieldName: "dschgWqCnt", header: { text: "방류수수질" }, valueType: ValueType.NUMBER, dataFormat: "#,##0" },
{ name: "ldsCnt", fieldName: "ldsCnt", header: { text: "퇴적토" }, valueType: ValueType.NUMBER, dataFormat: "#,##0" },
{ name: "plltnAcdntCnt", fieldName: "plltnAcdntCnt", header: { text: "오염사고" }, valueType: ValueType.NUMBER, dataFormat: "#,##0" },
{ name: "sewerCrgAmt", fieldName: "sewerCrgAmt", header: { text: "하수도요금" }, valueType: ValueType.NUMBER, dataFormat: "#,##0" },
{ name: "etcCnt", fieldName: "etcCnt", header: { text: "기타" }, valueType: ValueType.NUMBER, dataFormat: "#,##0" },
{
name: "crtrPpltnCvlcptEvntcsCnt",
fieldName: "crtrPpltnCvlcptEvntcsCnt",
header: { text: "인구10만명당 민원건수(건)" },
valueType: ValueType.NUMBER,
dataFormat: "#,##0.00",
},
{ name: "rmrk", fieldName: "rmrk", header: { text: "비고" } },
{ name: "statsYr", fieldName: "statsYr", header: { text: "통계연도" }, visible: false },
{ name: "admdstCd", fieldName: "admdstCd", header: { text: "행정코드" }, visible: false },
{ name: "rowType", fieldName: "rowType", header: { text: "행타입" }, visible: false },
].map((col) => ({ ...col, editable: false })),
columnLayout: [
{
name: "groupA",
hideChildHeaders: false,
header: { text: "행정구역" },
items: ["ctpvNm", "sggNm", "admdstNm"],
},
{
name: "groupB",
hideChildHeaders: false,
header: { text: "민원건수(건)" },
items: [
"whol",
"odorCnt",
"swagePplCnt",
"dschgWqCnt",
"ldsCnt",
"plltnAcdntCnt",
"sewerCrgAmt",
"etcCnt",
],
},
"swtrmtPpltnCnt",
"crtrPpltnCvlcptEvntcsCnt",
"rmrk",
"statsYr",
"admdstCd",
"rowType",
],
rowStartNum: 2,
fixedColumns: 1,
tabKey: "ST00130001",
tabLabel: "민원",
};
@@ -0,0 +1,81 @@
import { ValueType } from "realgrid";
// 하수도 통계 관리 > 13. 하수도 서비스 수준 > tab 2: 주민친화시설
export const ResidentFriendlyFactTotColumn = {
columns: [
{ name: "ctpvNm", fieldName: "ctpvNm", header: { text: "시도" } },
{ name: "sggNm", fieldName: "sggNm", header: { text: "시군구" } },
{ name: "admdstNm", fieldName: "admdstNm", header: { text: "행정구역명" } },
{ name: "swtrmtFcltNm", fieldName: "swtrmtFcltNm", header: { text: "처리장명" } },
{
name: "fcltCpct",
fieldName: "fcltCpct",
header: { text: "시설용량(m³/일)" },
valueType: ValueType.NUMBER,
dataFormat: "#,##0",
},
{
name: "tmplntArea",
fieldName: "tmplntArea",
header: { text: "처리장면적(㎡)" },
valueType: ValueType.NUMBER,
dataFormat: "#,##0.00",
},
{
name: "whol",
fieldName: "whol",
header: { text: "총계" },
valueType: ValueType.NUMBER,
dataFormat: "#,##0",
},
{ name: "brkFcltArea", fieldName: "brkFcltArea", header: { text: "휴게시설" }, valueType: ValueType.NUMBER, dataFormat: "#,##0" },
{ name: "childFrshpSpacArea", fieldName: "childFrshpSpacArea", header: { text: "어린이놀이공간" }, valueType: ValueType.NUMBER, dataFormat: "#,##0" },
{ name: "bdySportsFcltArea", fieldName: "bdySportsFcltArea", header: { text: "체력단련시설" }, valueType: ValueType.NUMBER, dataFormat: "#,##0" },
{ name: "sportsFcltArea", fieldName: "sportsFcltArea", header: { text: "운동시설" }, valueType: ValueType.NUMBER, dataFormat: "#,##0" },
{ name: "pkltArea", fieldName: "pkltArea", header: { text: "주차장" }, valueType: ValueType.NUMBER, dataFormat: "#,##0" },
{ name: "eduMultiFcltArea", fieldName: "eduMultiFcltArea", header: { text: "교육 및 문화시설" }, valueType: ValueType.NUMBER, dataFormat: "#,##0" },
{ name: "wtlndMakeArea", fieldName: "wtlndMakeArea", header: { text: "습지조성(생태공원)" }, valueType: ValueType.NUMBER, dataFormat: "#,##0" },
{ name: "etcCnt", fieldName: "etcCnt", header: { text: "기타" }, valueType: ValueType.NUMBER, dataFormat: "#,##0" },
{ name: "rmrk", fieldName: "rmrk", header: { text: "비고" } },
{ name: "statsYr", fieldName: "statsYr", header: { text: "통계연도" }, visible: false },
{ name: "admdstCd", fieldName: "admdstCd", header: { text: "행정코드" }, visible: false },
{ name: "rowType", fieldName: "rowType", header: { text: "행타입" }, visible: false },
].map((col) => ({ ...col, editable: false })),
columnLayout: [
{
name: "groupA",
hideChildHeaders: false,
header: { text: "행정구역" },
items: ["ctpvNm", "sggNm", "admdstNm"],
},
"swtrmtFcltNm",
"fcltCpct",
"tmplntArea",
{
name: "groupB",
hideChildHeaders: false,
header: { text: "주민친화시설 면적(㎡)" },
items: [
"whol",
"brkFcltArea",
"childFrshpSpacArea",
"bdySportsFcltArea",
"sportsFcltArea",
"pkltArea",
"eduMultiFcltArea",
"wtlndMakeArea",
"etcCnt",
],
},
"rmrk",
"statsYr",
"admdstCd",
"rowType",
],
rowStartNum: 2,
fixedColumns: 3,
tabKey: "ST00130002",
tabLabel: "주민친화시설",
};
@@ -1,127 +1,169 @@
import { stClient } from "@/shared/api/client";
import { ENDPOINT } from "@/shared/config";
import type {
import type {
CheckVerificationNameRequest,
CheckVerificationNameResponse,
CopyVerifyDataRequest,
DeleteVrfcMasterResponse,
GeneralverificationDetailResponse,
GeneralverificationInfoResponse,
GeneralverificationRequest,
GeneralverificationResponse,
GeneralverificationService,
GeneralverificationInfoResponse,
CheckVerificationNameRequest,
CheckVerificationNameResponse,
UpdateVrfcMasterRequest,
UpdateVrfcMasterResponse,
RegistVrfcMasterRequest,
RegistVrfcMasterResponse,
DeleteVrfcMasterResponse,
VrfcOperatorsResponse,
GeneralverificationDetailResponse,
UpdateVrfcMasterRequest,
UpdateVrfcMasterResponse,
VerifyColumnListRequest,
VerifyColumnListResponse,
VerifyDetailRegistRequest,
VerifyDetailUpdateRequest,
CopyVerifyDataRequest,
VerifyResultListRequest,
VerifyResultListResponse,
VrfcOperatorsResponse,
} from "../model/general-verification-interface";
export class GeneralverificationApiService implements GeneralverificationService {
public async getGeneralverificationList(param: GeneralverificationRequest): Promise<GeneralverificationResponse> {
const response = await stClient.get<GeneralverificationResponse> (
public async getGeneralverificationList(
param: GeneralverificationRequest,
): Promise<GeneralverificationResponse> {
const response = await stClient.get<GeneralverificationResponse>(
`${ENDPOINT.ST_SERVICE.INPUTVERIFY}/masters`,
{ params: param },
);
return response.data;
}
public async getGeneralverificationInfo(param: number): Promise<GeneralverificationInfoResponse> {
const response = await stClient.get<GeneralverificationInfoResponse> (
public async getGeneralverificationInfo(
param: number,
): Promise<GeneralverificationInfoResponse> {
const response = await stClient.get<GeneralverificationInfoResponse>(
`${ENDPOINT.ST_SERVICE.INPUTVERIFY}/masters/${param}`,
);
return response.data;
}
public async checkVerificationName(param: CheckVerificationNameRequest): Promise<CheckVerificationNameResponse> {
const response = await stClient.get<CheckVerificationNameResponse> (
public async checkVerificationName(
param: CheckVerificationNameRequest,
): Promise<CheckVerificationNameResponse> {
const response = await stClient.get<CheckVerificationNameResponse>(
`${ENDPOINT.ST_SERVICE.INPUTVERIFY}/check-name`,
{ params: param },
);
return response.data;
}
public async UpdateVrfcMaster(param: UpdateVrfcMasterRequest): Promise<UpdateVrfcMasterResponse> {
const response = await stClient.put<UpdateVrfcMasterResponse> (
public async UpdateVrfcMaster(
param: UpdateVrfcMasterRequest,
): Promise<UpdateVrfcMasterResponse> {
const response = await stClient.put<UpdateVrfcMasterResponse>(
`${ENDPOINT.ST_SERVICE.INPUTVERIFY}/masters/${param.vrfcSn}`,
param,
);
return response.data;
}
public async RegistVrfcMaster(param: RegistVrfcMasterRequest): Promise<RegistVrfcMasterResponse> {
const response = await stClient.post<RegistVrfcMasterResponse> (
public async RegistVrfcMaster(
param: RegistVrfcMasterRequest,
): Promise<RegistVrfcMasterResponse> {
const response = await stClient.post<RegistVrfcMasterResponse>(
`${ENDPOINT.ST_SERVICE.INPUTVERIFY}/master`,
param,
);
return response.data;
}
public async DeleteVrfcMaster(param: number): Promise<DeleteVrfcMasterResponse> {
const response = await stClient.delete<DeleteVrfcMasterResponse> (
public async DeleteVrfcMaster(
param: number,
): Promise<DeleteVrfcMasterResponse> {
const response = await stClient.delete<DeleteVrfcMasterResponse>(
`${ENDPOINT.ST_SERVICE.INPUTVERIFY}/${param}`,
);
return response.data;
}
public async getVrfcOperators(): Promise<VrfcOperatorsResponse> {
const response = await stClient.get<VrfcOperatorsResponse> (
const response = await stClient.get<VrfcOperatorsResponse>(
`${ENDPOINT.ST_SERVICE.INPUTVERIFY}/operators`,
);
return response.data;
}
public async getVrfcDetail(param: number): Promise<GeneralverificationDetailResponse> {
const response = await stClient.get<GeneralverificationDetailResponse> (
public async getVrfcDetail(
param: number,
): Promise<GeneralverificationDetailResponse> {
const response = await stClient.get<GeneralverificationDetailResponse>(
`${ENDPOINT.ST_SERVICE.INPUTVERIFY}/${param}`,
);
return response.data;
}
public async getVerifyColumnList(param: VerifyColumnListRequest): Promise<VerifyColumnListResponse> {
const response = await stClient.get<VerifyColumnListResponse> (
param.refStatsCd ?
`${ENDPOINT.ST_SERVICE.INPUTVERIFY}/columns/${param.trgtStatsCd}/${param.vrfcDtlSn}/?refStatsCd=${param.refStatsCd}`
:
`${ENDPOINT.ST_SERVICE.INPUTVERIFY}/columns/${param.trgtStatsCd}/${param.vrfcDtlSn}`,
public async getVerifyColumnList(
param: VerifyColumnListRequest,
): Promise<VerifyColumnListResponse> {
const response = await stClient.get<VerifyColumnListResponse>(
param.refStatsCd
? `${ENDPOINT.ST_SERVICE.INPUTVERIFY}/columns/${param.trgtStatsCd}/${param.vrfcDtlSn}/?refStatsCd=${param.refStatsCd}`
: `${ENDPOINT.ST_SERVICE.INPUTVERIFY}/columns/${param.trgtStatsCd}/${param.vrfcDtlSn}`,
);
return response.data;
}
public async getSelectedVerifyColumnList(param: string): Promise<VerifyColumnListResponse> {
const response = await stClient.get<VerifyColumnListResponse> (
public async getSelectedVerifyColumnList(
param: string,
): Promise<VerifyColumnListResponse> {
const response = await stClient.get<VerifyColumnListResponse>(
`${ENDPOINT.ST_SERVICE.INPUTVERIFY}/columns/${param}`,
);
return response.data;
}
public async RegistVrfcDetail(param: VerifyDetailRegistRequest): Promise<RegistVrfcMasterResponse> {
const response = await stClient.post<RegistVrfcMasterResponse> (
public async RegistVrfcDetail(
param: VerifyDetailRegistRequest,
): Promise<RegistVrfcMasterResponse> {
const response = await stClient.post<RegistVrfcMasterResponse>(
`${ENDPOINT.ST_SERVICE.INPUTVERIFY}/${param.vrfcSn}/details`,
param
param,
);
return response.data;
}
public async UpdateVrfcDetail(param: VerifyDetailUpdateRequest): Promise<RegistVrfcMasterResponse> {
const response = await stClient.put<RegistVrfcMasterResponse> (
public async UpdateVrfcDetail(
param: VerifyDetailUpdateRequest,
): Promise<RegistVrfcMasterResponse> {
const response = await stClient.put<RegistVrfcMasterResponse>(
`${ENDPOINT.ST_SERVICE.INPUTVERIFY}/${param.vrfcSn}/details`,
param
param,
);
return response.data;
}
public async CopyVrfcData(param: CopyVerifyDataRequest): Promise<RegistVrfcMasterResponse> {
const response = await stClient.post<RegistVrfcMasterResponse> (
public async CopyVrfcData(
param: CopyVerifyDataRequest,
): Promise<RegistVrfcMasterResponse> {
const response = await stClient.post<RegistVrfcMasterResponse>(
`${ENDPOINT.ST_SERVICE.INPUTVERIFY}/${param.vrfcSn}/copy?targetStatsCd=${param.targetStatsCd}&rgtrId=${param.rgtrId}`,
);
return response.data;
}
public async getVerifyResultList(
param: VerifyResultListRequest,
): Promise<VerifyResultListResponse> {
const response = await stClient.get<VerifyResultListResponse>(
`${ENDPOINT.ST_SERVICE.INPUTVERIFY}/results`,
{
params: {
...param.searchDto,
page: param.page,
size: param.size,
},
},
);
return response.data;
}
}
export const generalverificationApiService = new GeneralverificationApiService();
export const generalverificationApiService =
new GeneralverificationApiService();
@@ -7,7 +7,8 @@ import {
type SewerageSupplyRateApiResponse,
type CheckInputPeriodResponse,
type SewerageSupplyRateSaveRequest,
type SewerageSupplyRateSaveApiResponse,
type SewerageSupplyRateSubmitResponse,
type SewerageSupplyRateSaveResponse,
} from "../../model/input/statistics-sewage-supply-rate-input-interface";
export class StatisticsInputSewSupplyRateApiService implements StatisticsInputSewSupplyRateService {
@@ -26,16 +27,16 @@ export class StatisticsInputSewSupplyRateApiService implements StatisticsInputSe
return response.data;
}
public async saveSewerageSupplyRate(params: SewerageSupplyRateSaveRequest): Promise<SewerageSupplyRateSaveApiResponse> {
const response = await stClient.post<SewerageSupplyRateSaveApiResponse>(
public async saveSewerageSupplyRate(params: SewerageSupplyRateSaveRequest): Promise<SewerageSupplyRateSaveResponse> {
const response = await stClient.post<SewerageSupplyRateSaveResponse>(
`${ENDPOINT.ST_SERVICE.INPUT}/sewerage-supply-rate/save`,
params,
);
return response.data;
}
public async submitSewerageSupplyRate(params: SewerageSupplyRateSaveRequest): Promise<SewerageSupplyRateSaveApiResponse> {
const response = await stClient.post<SewerageSupplyRateSaveApiResponse>(
public async submitSewerageSupplyRate(params: SewerageSupplyRateSaveRequest): Promise<SewerageSupplyRateSubmitResponse> {
const response = await stClient.post<SewerageSupplyRateSubmitResponse>(
`${ENDPOINT.ST_SERVICE.INPUT}/sewerage-supply-rate/submit`,
params,
);
@@ -1,14 +1,16 @@
import { ENDPOINT } from "@/shared/config";
import { stClient } from "@/shared/api/client";
import { ENDPOINT } from "@/shared/config";
import type {
StatisticsInputStatusService,
StatsInputStatusListRequestParams,
StatsInputStatusListApiResponse,
StatsInputStatusListRequestParams,
} from "../../model/input/statistics-input-status-interface";
export class StatisticsInputStatusApiService implements StatisticsInputStatusService {
public async getStatisticsInputStatusList(param: StatsInputStatusListRequestParams): Promise<StatsInputStatusListApiResponse> {
public async getStatisticsInputStatusList(
param: StatsInputStatusListRequestParams,
): Promise<StatsInputStatusListApiResponse> {
const response = await stClient.get<StatsInputStatusListApiResponse>(
`${ENDPOINT.ST_SERVICE.INPUT}/stats-input-status`,
{ params: param },
@@ -17,4 +19,4 @@ export class StatisticsInputStatusApiService implements StatisticsInputStatusSer
}
}
export const StatisticsInputStatusApi = new StatisticsInputStatusApiService();
export const StatisticsInputStatusApi = new StatisticsInputStatusApiService();
@@ -11,7 +11,7 @@ import type {
SeweragePipeInputPipeDiameterResponse,
SeweragePipeInputPipeTypeResponse,
SeweragePipeInputRegionalFactStatusSaveRequest,
SeweragePipeInputRegionalFactStatusSaveApiResponse,
SeweragePipeInputRegionalFactStatusSaveResponse,
SeweragePipeInputRegionalResponse,
SeweragePipeRequestParams,
StatisticsInputSewPipeService,
@@ -82,8 +82,8 @@ export class StatisticsInputSewPipeApiService implements StatisticsInputSewPipeS
return response.data;
}
public async saveRegionalFactStatus(param: SeweragePipeInputRegionalFactStatusSaveRequest): Promise<SeweragePipeInputRegionalFactStatusSaveApiResponse> {
const response = await stClient.post<SeweragePipeInputRegionalFactStatusSaveApiResponse>(
public async saveRegionalFactStatus(param: SeweragePipeInputRegionalFactStatusSaveRequest): Promise<SeweragePipeInputRegionalFactStatusSaveResponse> {
const response = await stClient.post<SeweragePipeInputRegionalFactStatusSaveResponse>(
`${ENDPOINT.ST_SERVICE.INPUT}/sewage-pipe/regional-facility-status/save`,
param,
);
+100 -1
View File
@@ -1,10 +1,24 @@
import { useQuery } from "@tanstack/react-query";
import { useInfiniteQuery, useQuery } from "@tanstack/react-query";
import { ENDPOINT } from "@/shared/config";
import { generalverificationApiService } from "@/entities/statistics/api/general-verification-service";
import { StatisticsInputStatusApi } from "@/entities/statistics/api/input/statistics-input-status-service";
import type { VerifyResultPageData } from "@/entities/statistics/model/general-verification-interface";
import type {
StatisticsVerificationFilterState,
StatisticsVerificationSubFilterState,
} from "@/entities/statistics/model/statistics-verification-report/statistics-verification-report-types";
import { stAnlsMstApi } from "./statistics-analysis-master-api";
import { stAnlsRsltApi } from "./statistics-analysis-result-api";
const DETAIL_PAGE_SIZE = 100;
interface StatisticsVerificationDetailSubmittedParams {
main: StatisticsVerificationFilterState;
sub: StatisticsVerificationSubFilterState;
}
export const useStAnlsSidoCombo = () => {
return useQuery({
queryKey: [ENDPOINT.STATVERIFY_SERVICE.ST_ANLS_MST, "sido"],
@@ -58,3 +72,88 @@ export const useStAnlsChartData = (
enabled: !!anlsSn && !!vrfcTrgtSn,
});
};
export const useStatsVrfcRptData = (
submittedMainFilters: StatisticsVerificationFilterState | null,
) => {
return useQuery({
queryKey: [
ENDPOINT.ST_SERVICE.INPUT,
"statistics-input-status",
submittedMainFilters?.statsYr ?? "",
submittedMainFilters?.ctpvNm ?? "",
submittedMainFilters?.sggNm ?? "",
submittedMainFilters?.sewerBzmnCd ?? "",
submittedMainFilters?.statsCd ?? "",
],
queryFn: async () => {
if (!submittedMainFilters) return [];
const response =
await StatisticsInputStatusApi.getStatisticsInputStatusList(
submittedMainFilters,
);
if (response.status !== "success")
throw new Error(response.message);
return response.data ?? [];
},
enabled: Boolean(submittedMainFilters),
refetchOnWindowFocus: false,
});
};
export const useStatsVrfcRsltData = (
submittedDetailParams: StatisticsVerificationDetailSubmittedParams | null,
) => {
return useInfiniteQuery({
queryKey: [
ENDPOINT.ST_SERVICE.INPUTVERIFY,
"verify-results",
submittedDetailParams?.main.statsYr ?? "",
submittedDetailParams?.main.ctpvNm ?? "",
submittedDetailParams?.main.sggNm ?? "",
submittedDetailParams?.main.sewerBzmnCd ?? "",
submittedDetailParams?.main.statsCd ?? "",
submittedDetailParams?.sub.prcsYnList ?? "",
submittedDetailParams?.sub.pbcpAprvYn ?? "",
],
initialPageParam: 1,
queryFn: async ({ pageParam }) => {
if (!submittedDetailParams)
return {
verifyResultList: [],
pageInfo: {
currentPage: 0,
size: DETAIL_PAGE_SIZE,
totalElements: 0,
totalPages: 0,
},
} satisfies VerifyResultPageData;
const response =
await generalverificationApiService.getVerifyResultList({
page: pageParam,
size: DETAIL_PAGE_SIZE,
searchDto: {
statsYr: submittedDetailParams.main.statsYr,
ctpvNm: submittedDetailParams.main.ctpvNm,
sggNm: submittedDetailParams.main.sggNm,
sewerBzmnCd: submittedDetailParams.main.sewerBzmnCd,
statsCd: submittedDetailParams.main.statsCd,
prcsYnList: submittedDetailParams.sub.prcsYnList,
pbcpAprvYn: submittedDetailParams.sub.pbcpAprvYn,
},
});
if (response.status !== "success")
throw new Error(response.message);
return response.data;
},
getNextPageParam: (lastPage) => {
const { currentPage, totalPages } = lastPage.pageInfo;
if (currentPage + 1 <= totalPages) return currentPage + 1;
return undefined;
},
enabled: Boolean(submittedDetailParams),
refetchOnWindowFocus: false,
});
};
@@ -0,0 +1,33 @@
import { stClient } from "@/shared/api/client";
import { ENDPOINT } from "@/shared/config";
import type {
ApprovalResponse,
ApproveRequest,
RejectRequest,
StatisticsApprovalService,
} from "../model/statistics-approval-interface";
class StatisticsApprovalApiService implements StatisticsApprovalService {
public async approveStats(
params: ApproveRequest[],
): Promise<ApprovalResponse> {
const response = await stClient.post<ApprovalResponse>(
`${ENDPOINT.ST_SERVICE.APPROVAL}/approve?type=VRFC`,
params,
);
return response.data;
}
public async rejectStats(
params: RejectRequest[],
): Promise<ApprovalResponse> {
const response = await stClient.post<ApprovalResponse>(
`${ENDPOINT.ST_SERVICE.APPROVAL}/reject?type=VRFC`,
params,
);
return response.data;
}
}
export const statisticsApprovalApi = new StatisticsApprovalApiService();
@@ -2,8 +2,10 @@ import { ENDPOINT } from "@/shared/config";
import { stClient } from "@/shared/api/client";
import {
type StatsInputStatusReviewApiResponse
import type {
StatsInputStatusReviewApiResponse,
ReviewHistoryRequest,
ReviewHistoryResponse,
} from "../model/statistics-input-review-interface";
import {
type SewerageSupplyRateReviewRequestParams,
@@ -190,6 +192,14 @@ export class StatisticsManagementApiService {
);
return response.data;
}
public async getReviewHistory(params: ReviewHistoryRequest): Promise<ReviewHistoryResponse> {
const response = await stClient.get<ReviewHistoryResponse>(
`${ENDPOINT.ST_SERVICE.REVIEW}/history`,
{ params }
);
return response.data;
}
}
// 통합 인스턴스 수출
@@ -1,72 +1,106 @@
import { client, stClient } from "@/shared/api/client";
import { ENDPOINT } from "@/shared/config";
import { stClient, client } from "@/shared/api/client";
import {
type StatisticsService,
type SearchCondition,
type ApiResponse,
type Sgg,
type cancelSubmitRequest,
type cancelSubmitResponse,
type getReviewOpinionDetailRequest,
type getReviewOpinionDetailResponse,
type ImrRequest,
type SubmitExceptionRequest,
type SubmitExceptionResponse,
type ReviewOpinionSaveRequest,
type ReviewOpinionSaveResponse,
type ReviewReturnSaveRequest,
type ReviewReturnSaveResponse,
type ReviewSubmitRequest,
type ReviewSubmitResponse,
type VerificationResultListRequest,
type VerificationResultListResponse,
type SearchCondition,
type Sgg,
type StatisticsLogDetailResponse,
type StatisticsLogRequest,
type StatisticsLogResponse,
type StatisticsLogDetailResponse,
type StatisticsService,
type SubmitExceptionRequest,
type SubmitExceptionResponse,
type ImrResponse,
type ImrStatusResponse,
} from "../model/statistics-interface";
export class StatisticsAPiService implements StatisticsService {
public async getSidoList(): Promise<ApiResponse<SearchCondition>> {
export class StatisticsApiService implements StatisticsService {
public async getSidoList(params?: {
instSeCd?: string;
}): Promise<ApiResponse<SearchCondition>> {
const response = await stClient.get<ApiResponse<SearchCondition>>(
`${ENDPOINT.ST_SERVICE.COMMON}/search-condition`,
{ params },
);
return response.data;
}
public async getSggList(sidoName: string): Promise<ApiResponse<Sgg[]>> {
public async getSggList(
sidoName: string,
params?: { instSeCd?: string; gugunSkipYn?: "Y" | "N" },
): Promise<ApiResponse<Sgg[]>> {
const response = await stClient.get<ApiResponse<Sgg[]>>(
`${ENDPOINT.ST_SERVICE.COMMON}/sgg`,
{ params: { sidoName } },
{ params: { sidoName, ...params } },
);
return response.data;
}
public async getSggAllList(sidoName: string): Promise<ApiResponse<Sgg[]>> {
public async getSggAllList(
sidoName: string,
params?: { instSeCd?: string; gugunSkipYn?: "Y" | "N" },
): Promise<ApiResponse<Sgg[]>> {
const response = await stClient.get<ApiResponse<Sgg[]>>(
`${ENDPOINT.ST_SERVICE.COMMON}/sgg-all`,
{ params: { sidoName } },
{ params: { sidoName, ...params } },
);
return response.data;
}
// 입력 - 제출 취소
public async cancelSubmit(param: cancelSubmitRequest): Promise<cancelSubmitResponse> {
public async cancelSubmit(
param: cancelSubmitRequest,
): Promise<cancelSubmitResponse> {
const response = await stClient.put<cancelSubmitResponse>(
`${ENDPOINT.ST_SERVICE.INPUTCOMMON}/cancel-submit`,
param
param,
);
return response.data;
}
public async callImrService(param: ImrRequest): Promise<null> {
const response = await client.post<null>(
public async callImrService(param: ImrRequest): Promise<ImrResponse> {
const response = await client.post<ImrResponse>(
`${ENDPOINT.STATVERIFY_SERVICE.IMR}`,
param
param,
);
return response.data;
}
public async getImrStatus(param: string): Promise<ImrStatusResponse> {
const response = await client.get<ImrStatusResponse>(
`${ENDPOINT.STATVERIFY_SERVICE.IMR}/status/${param}`,
);
return response.data;
}
// 예외 제출
public async submitException(param: SubmitExceptionRequest): Promise<SubmitExceptionResponse> {
public async submitException(
param: SubmitExceptionRequest,
): Promise<SubmitExceptionResponse> {
const response = await stClient.post<SubmitExceptionResponse>(
`${ENDPOINT.ST_SERVICE.INPUTCOMMON}/submit-exception`,
param
param,
);
return response.data;
}
public async getLogList(param: StatisticsLogRequest): Promise<StatisticsLogResponse> {
public async getLogList(
param: StatisticsLogRequest,
): Promise<StatisticsLogResponse> {
const response = await stClient.get<StatisticsLogResponse>(
`${ENDPOINT.ST_SERVICE.INPUTCOMMON}/logs`,
{ params: param },
@@ -74,12 +108,62 @@ export class StatisticsAPiService implements StatisticsService {
return response.data;
}
public async getLogDetail(param: string): Promise<StatisticsLogDetailResponse> {
public async getLogDetail(
param: string,
): Promise<StatisticsLogDetailResponse> {
const response = await stClient.get<StatisticsLogDetailResponse>(
`${ENDPOINT.ST_SERVICE.INPUTCOMMON}/logs/${param}`,
);
return response.data;
}
public async saveReviewOpinion(
param: ReviewOpinionSaveRequest,
): Promise<ReviewOpinionSaveResponse> {
const response = await stClient.post<ReviewOpinionSaveResponse>(
`${ENDPOINT.ST_SERVICE.REVIEW}/opinion-save`,
param,
);
return response.data;
}
public async getReviewOpinionDetail(
param: getReviewOpinionDetailRequest,
): Promise<getReviewOpinionDetailResponse> {
const response = await stClient.get<getReviewOpinionDetailResponse>(
`${ENDPOINT.ST_SERVICE.REVIEW}/opinion-select`,
{ params: param },
);
return response.data;
}
public async saveReviewReturn(
param: ReviewReturnSaveRequest,
): Promise<ReviewReturnSaveResponse> {
const response = await stClient.post<ReviewReturnSaveResponse>(
`${ENDPOINT.ST_SERVICE.REVIEW}/return`,
param,
);
return response.data;
}
public async submitReview(
param: ReviewSubmitRequest,
): Promise<ReviewSubmitResponse> {
const response = await stClient.post<ReviewSubmitResponse>(
`${ENDPOINT.ST_SERVICE.REVIEW}/submit`,
param,
);
return response.data;
}
public async getVerificationResultList(param: VerificationResultListRequest): Promise<VerificationResultListResponse> {
const response = await stClient.get<VerificationResultListResponse>(
`${ENDPOINT.ST_SERVICE.INPUTVERIFY}/results`,
{ params: param },
);
return response.data;
}
}
export const statisticsAPi = new StatisticsAPiService();
export const statisticsApi = new StatisticsApiService();
@@ -3,6 +3,11 @@ import { stClient } from "@/shared/api/client";
import type { SewerageSupplyRateRequest, SewerageSupplyRateResponse } from "../model/statistics-total/statistics-sewage-supply-rate-total-interface";
import type { SewagePipeTotalRequest, SewagePipeTotalResponse } from "../model/statistics-total/statistics-sewage-pipe-total-interface";
import type {
SewerageServiceLevelComplaintResponse,
SewerageServiceLevelRequest,
SewerageServiceLevelResidentFriendlyFactResponse
} from "../model/statistics-total/statistics-service-level-total-interface";
export class StatisticsTotalApiService {
public async getSewerageSupplyRateList(params: SewerageSupplyRateRequest): Promise<SewerageSupplyRateResponse> {
@@ -20,6 +25,22 @@ export class StatisticsTotalApiService {
);
return response.data;
}
public async getSewerageServiceLevelComplaint(params: SewerageServiceLevelRequest): Promise<SewerageServiceLevelComplaintResponse> {
const response = await stClient.get<SewerageServiceLevelComplaintResponse> (
`${ENDPOINT.ST_SERVICE.TOTAL}/sewage-complaint`,
{ params: params }
);
return response.data;
}
public async getSewerageServiceLevelResidentFriendlyFact(params: SewerageServiceLevelRequest): Promise<SewerageServiceLevelResidentFriendlyFactResponse> {
const response = await stClient.get<SewerageServiceLevelResidentFriendlyFactResponse> (
`${ENDPOINT.ST_SERVICE.TOTAL}/resident-friendly-facility`,
{ params: params }
);
return response.data;
}
}
export const statisticsTotalStatusApi = new StatisticsTotalApiService();
+22
View File
@@ -1,3 +1,10 @@
export {
useStAnlsSidoCombo,
useStAnlsSigunguCombo,
useStatsVrfcRptData,
useStatsVrfcRsltData,
} from "./api/queries";
export { statisticsApprovalApi } from "./api/statistics-approval-service";
export {
type AnalysisHistoryListApiResponse,
type AnalysisRequestPayload,
@@ -7,6 +14,7 @@ export {
type JobStatusDto,
type OutlierRecordDto,
} from "./model/analysis/analysis-interface";
export { type VerifyResultItem } from "./model/general-verification-interface";
export {
IMR_DETAIL_VISIBLE_COLUMNS_BY_STATS_CD,
IMR_DETAIL_VISIBLE_COLUMNS_DEFAULT,
@@ -25,6 +33,7 @@ export {
type ImrLogResponse,
} from "./model/imr/imr-interface";
export { imrVerificationHistoryColumns } from "./model/imr/imr-verification-history-columns";
export { type StatsInputStatusList } from "./model/input/statistics-input-status-interface";
export {
type StAnlsDtlListApiResponse,
type StAnlsDtlResponse,
@@ -54,6 +63,19 @@ export {
} from "./model/statistics-analysis/statistics-analysis-result-interface";
export { verificationHistoryColumns } from "./model/statistics-analysis/verification-history-columns";
export { getVerificationListColumns } from "./model/statistics-analysis/verification-list-columns";
export {
type ApprovalResponse,
type ApproveRequest,
type RejectRequest,
} from "./model/statistics-approval-interface";
export {
statisticsVerificationReportColumnInfo,
statisticsVerificationResultColumnInfo,
} from "./model/statistics-verification-report/statistics-verification-report-columns";
export {
type StatisticsVerificationFilterState,
type StatisticsVerificationSubFilterState,
} from "./model/statistics-verification-report/statistics-verification-report-types";
export {
hbArticleColumnInfo,
imrArticleColumnInfo,
@@ -86,7 +86,7 @@ export interface VrfcOperatorsResponse {
opertSn: string;
opertSymbl: string;
opertKornNm: string;
}[]
}[];
}
/** 검증 상세 - 공통 정보 */
@@ -165,48 +165,133 @@ export interface SelectedVerifyColumnListResponseData {
export interface SelectedVerifyColumnListResponse {
status: string;
message?: string;
data: VerifyColumnListResponseData
data: VerifyColumnListResponseData;
}
export interface VerifyDetailRegistData {
trgtStatsCd: string;
refStatsCd: string | null;
kornArtopnEqtnCn: string,
engArtopnEqtnCn: string,
kornArtopnEqtnCn: string;
engArtopnEqtnCn: string;
cmprOpert: string | null;
eqtnCombSeq: number
eqtnCombSeq: number;
}
export interface VerifyDetailRegistRequest {
vrfcSn: number | null;
detailList: VerifyDetailRegistData[]
detailList: VerifyDetailRegistData[];
rgtrId: string;
}
export interface VerifyDetailUpdateRequest {
vrfcSn: number | null;
detailList: VerifyDetailRegistData[]
mdfrId: string;
detailList: VerifyDetailRegistData[];
mdfrId: string;
}
export interface CopyVerifyDataRequest {
vrfcSn?: number | null;
targetStatsCd : string;
targetStatsCd: string;
rgtrId: string;
}
export interface VerifyResultSearchRequest {
sewerBzmnCd?: string | null;
ctpvNm?: string | null;
sggNm?: string | null;
statsYr: string;
statsCd?: string | null;
vrfcMthdCd?: string;
prcsYnList?: string; // 검증 상태 리스트 (0: 미검증, 1: 수정완료, 2:예외통과, 3: 반려)
pbcpAprvYn?: string; // 공단 승인 여부 (Y/N)
}
export interface VerifyResultListRequest {
searchDto: VerifyResultSearchRequest;
page?: number;
size?: number;
}
export interface VerifyResultItem {
statsYr?: string;
statsCd?: string;
statsNm?: string;
sewerBzmnCd?: string;
ctpvNm?: string;
sggNm?: string;
admdstNm?: string;
vrfcSn?: number;
vrfcMthdCd?: string;
vrfcMthdNm?: string;
vrfcTrgtNm?: string;
regDt?: string;
rsnExpln?: string;
nowVlCn?: string;
compVlCn?: string;
prcsYn?: string;
prcsOpnnExpln?: string;
prcrId?: string;
rjctOpnnCn?: string;
pbcpAprvYn?: string;
mdfrId?: string;
mdfcnDt?: string;
}
export interface VerifyResultPageInfo {
totalElements: number;
totalPages: number;
currentPage: number;
size: number;
}
export interface VerifyResultPageData {
verifyResultList: VerifyResultItem[];
pageInfo: VerifyResultPageInfo;
}
export interface VerifyResultListResponse {
status: string;
message?: string;
data: VerifyResultPageData;
code?: string;
httpStatus?: number;
}
export interface GeneralverificationService {
getGeneralverificationList(param: GeneralverificationRequest): Promise<GeneralverificationResponse>;
getGeneralverificationInfo(param: number): Promise<GeneralverificationInfoResponse>;
checkVerificationName(param: CheckVerificationNameRequest): Promise<CheckVerificationNameResponse>;
UpdateVrfcMaster(param: UpdateVrfcMasterRequest): Promise<UpdateVrfcMasterResponse>;
RegistVrfcMaster(param: RegistVrfcMasterRequest): Promise<RegistVrfcMasterResponse>;
getGeneralverificationList(
param: GeneralverificationRequest,
): Promise<GeneralverificationResponse>;
getGeneralverificationInfo(
param: number,
): Promise<GeneralverificationInfoResponse>;
checkVerificationName(
param: CheckVerificationNameRequest,
): Promise<CheckVerificationNameResponse>;
UpdateVrfcMaster(
param: UpdateVrfcMasterRequest,
): Promise<UpdateVrfcMasterResponse>;
RegistVrfcMaster(
param: RegistVrfcMasterRequest,
): Promise<RegistVrfcMasterResponse>;
DeleteVrfcMaster(param: number): Promise<DeleteVrfcMasterResponse>;
getVrfcOperators(): Promise<VrfcOperatorsResponse>;
getVrfcDetail(param: number): Promise<GeneralverificationDetailResponse>;
getVerifyColumnList(param: VerifyColumnListRequest): Promise<VerifyColumnListResponse>;
getSelectedVerifyColumnList(param: string): Promise<VerifyColumnListResponse>;
RegistVrfcDetail(param: VerifyDetailRegistRequest): Promise<RegistVrfcMasterResponse>;
UpdateVrfcDetail(param: VerifyDetailUpdateRequest): Promise<RegistVrfcMasterResponse>;
CopyVrfcData(param: CopyVerifyDataRequest): Promise<RegistVrfcMasterResponse>;
}
getVerifyColumnList(
param: VerifyColumnListRequest,
): Promise<VerifyColumnListResponse>;
getSelectedVerifyColumnList(
param: string,
): Promise<VerifyColumnListResponse>;
RegistVrfcDetail(
param: VerifyDetailRegistRequest,
): Promise<RegistVrfcMasterResponse>;
UpdateVrfcDetail(
param: VerifyDetailUpdateRequest,
): Promise<RegistVrfcMasterResponse>;
CopyVrfcData(
param: CopyVerifyDataRequest,
): Promise<RegistVrfcMasterResponse>;
getVerifyResultList(
param: VerifyResultListRequest,
): Promise<VerifyResultListResponse>;
}
@@ -117,7 +117,7 @@ export const imrDetailGridColumnInfo: ColumnInfo = {
header: { text: "당해년도" },
width: 100,
valueType: ValueType.NUMBER,
dataFormat: "#,##0",
dataFormat: "#,##0.##",
styleName: "right-column",
},
],
@@ -1,18 +1,18 @@
import type { ApiResponse } from "../statistics-interface";
export interface StatsInputStatusListRequestParams {
ctpvNm?: string;
sggNm?: string;
sewerBzmnCd?: string;
statsYr?: string;
mode?: string;
statsCd?: string;
ctpvNm?: string | null;
sggNm?: string | null;
sewerBzmnCd?: string | null;
statsYr: string;
statsCd?: string | null;
}
export interface StatsInputStatusList {
ctpvNm?: string;
sggNm?: string;
statsCd?: string;
sewerBzmnCd?: string;
menuTabName?: string;
locgStatusNm?: string;
inputRate?: number;
@@ -1,5 +1,3 @@
import type { SewerageSupplyRateSaveApiResponse } from "./statistics-sewage-supply-rate-input-interface";
export interface SeweragePipeRequestParams {
ctpvNm?: string;
sggNm?: string;
@@ -119,8 +117,11 @@ export interface SeweragePipeInputRegionalFactStatusSaveRequest {
saveList: SeweragePipeInputRegionalFactStatusSaveItem[];
}
/** 지역별 시설현황 저장 API 응답 — 보급률 저장과 동일 래핑 */
export type SeweragePipeInputRegionalFactStatusSaveApiResponse = SewerageSupplyRateSaveApiResponse;
export interface SeweragePipeInputRegionalFactStatusSaveResponse {
data: string;
message?: string;
status: string;
}
/** 시설별 설치 현황 — 목록 항목 */
export interface SeweragePipeInputFactInstallStatusItem {
@@ -519,5 +520,5 @@ export interface StatisticsInputSewPipeService {
getFacilityAdminAreaList(param: SeweragePipeFacilityAdminAreaListRequest): Promise<SeweragePipeFacilityAdminAreaListResponse>;
saveRegionalFactStatus(
param: SeweragePipeInputRegionalFactStatusSaveRequest,
): Promise<SeweragePipeInputRegionalFactStatusSaveApiResponse>;
): Promise<SeweragePipeInputRegionalFactStatusSaveResponse>;
}

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