diff --git a/apps/cs/.env.example b/apps/cs/.env.example index 9f786831b..19231af8a 100644 --- a/apps/cs/.env.example +++ b/apps/cs/.env.example @@ -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 \ No newline at end of file +NEXT_PUBLIC_TINY_API_KEY=t9nc4z6inquvvd5pq1vea8wcp7lb01qmqseh6egms9vpaju2 +NEXT_PUBLIC_TINY_BASE_URL= \ No newline at end of file diff --git a/apps/cs/middleware.ts b/apps/cs/middleware.ts index 4cc85b307..98b741325 100644 --- a/apps/cs/middleware.ts +++ b/apps/cs/middleware.ts @@ -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*", + "/", + ], }; diff --git a/apps/cs/src/entities/editor/model/editor-base-config.ts b/apps/cs/src/entities/editor/model/editor-base-config.ts index ee465b675..f0684c5c8 100644 --- a/apps/cs/src/entities/editor/model/editor-base-config.ts +++ b/apps/cs/src/entities/editor/model/editor-base-config.ts @@ -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", diff --git a/apps/cs/src/pages/groups/ui/groups-dialog.tsx b/apps/cs/src/pages/groups/ui/groups-dialog.tsx index 9fca4ebdb..ac7f92916 100644 --- a/apps/cs/src/pages/groups/ui/groups-dialog.tsx +++ b/apps/cs/src/pages/groups/ui/groups-dialog.tsx @@ -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 ( diff --git a/apps/cs/src/pages/groups/ui/role-select.tsx b/apps/cs/src/pages/groups/ui/role-select.tsx index 539888591..bede34ed8 100644 --- a/apps/cs/src/pages/groups/ui/role-select.tsx +++ b/apps/cs/src/pages/groups/ui/role-select.tsx @@ -16,7 +16,13 @@ type RoleSelectProps = React.ComponentPropsWithoutRef & { 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); diff --git a/apps/cs/src/pages/role/model/table-columns.tsx b/apps/cs/src/pages/role/model/table-columns.tsx index 71da16cfe..dcebbc736 100644 --- a/apps/cs/src/pages/role/model/table-columns.tsx +++ b/apps/cs/src/pages/role/model/table-columns.tsx @@ -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[] => [ +export const createColumns = (): ColumnDef[] => [ { id: "no", header: "NO", @@ -44,18 +39,7 @@ export const createColumns = ({ { id: "action", header: "관리", - cell: ({ row }) => ( -
- - -
- ), + cell: ({ row }) => , }, ]; diff --git a/apps/cs/src/pages/role/ui/modify-page.tsx b/apps/cs/src/pages/role/ui/modify-page.tsx index 37f00836f..52c67f4b0 100644 --- a/apps/cs/src/pages/role/ui/modify-page.tsx +++ b/apps/cs/src/pages/role/ui/modify-page.tsx @@ -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({ resolver: zodResolver(roleFormSchema), values: { @@ -47,12 +40,7 @@ function ModifyPage({ roleId }: { roleId: string }) {

권한 수정

- + ); } diff --git a/apps/cs/src/pages/role/ui/role-form.tsx b/apps/cs/src/pages/role/ui/role-form.tsx index bb67adb42..7a4653a9d 100644 --- a/apps/cs/src/pages/role/ui/role-form.tsx +++ b/apps/cs/src/pages/role/ui/role-form.tsx @@ -26,7 +26,6 @@ type RoleFormProps = { roleId?: string; form: UseFormReturn; handleSave: (data: RoleFormData) => Promise; - handleDelete?: () => Promise; }; function RoleForm({ roleId, form, handleSave, ...props }: RoleFormProps) { diff --git a/apps/cs/src/pages/role/ui/role-page.tsx b/apps/cs/src/pages/role/ui/role-page.tsx index 47d3d9a9e..b370ed338 100644 --- a/apps/cs/src/pages/role/ui/role-page.tsx +++ b/apps/cs/src/pages/role/ui/role-page.tsx @@ -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({ + 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, }, diff --git a/apps/cs/src/shared/config/env.ts b/apps/cs/src/shared/config/env.ts index 5e1392b4d..f352e1bd7 100644 --- a/apps/cs/src/shared/config/env.ts +++ b/apps/cs/src/shared/config/env.ts @@ -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; diff --git a/apps/sn/.env.local b/apps/sn/.env.local index ea85705ec..33b8ef842 100644 --- a/apps/sn/.env.local +++ b/apps/sn/.env.local @@ -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 diff --git a/apps/sn/app/(map-layout)/map/components/popupComponent/Ssc.tsx b/apps/sn/app/(map-layout)/map/components/popupComponent/Ssc.tsx index 683fb9df0..6a11df513 100644 --- a/apps/sn/app/(map-layout)/map/components/popupComponent/Ssc.tsx +++ b/apps/sn/app/(map-layout)/map/components/popupComponent/Ssc.tsx @@ -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({}); const [rainMeasureData, setRainMeasureData] = useState({}); // 강우, 측정 정보 const [mntrngsData, setMntrngsData] = useState([]); // 수량/수질측정 정보 @@ -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' && (

중점우수토실 운영정보

+
+ )} - + {isMainSsc && renderMode === 'popup' && ( +
+

중점우수토실 운영정보

+ {renderModalBody()}
)} diff --git a/apps/sn/app/(map-layout)/map/components/popupComponent/SwtrmtFclt.tsx b/apps/sn/app/(map-layout)/map/components/popupComponent/SwtrmtFclt.tsx index 8b029673f..78e082414 100644 --- a/apps/sn/app/(map-layout)/map/components/popupComponent/SwtrmtFclt.tsx +++ b/apps/sn/app/(map-layout)/map/components/popupComponent/SwtrmtFclt.tsx @@ -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 (
setIsModalOpen(true)}>조회 + title: }} headName='디지털 자료관 연계정보' tableData={[]} @@ -43,31 +52,8 @@ const SwtrmtFclt = ({ gisObject }: { gisObject: any }) => { throw new Error("Function not implemented."); }} /> - - } - useDirectRender={true} - />
) } -export default SwtrmtFclt \ No newline at end of file +export default SwtrmtFclt diff --git a/apps/sn/app/(map-layout)/map/components/popupComponent/chart/infilChart.tsx b/apps/sn/app/(map-layout)/map/components/popupComponent/chart/infilChart.tsx index 3836b87f6..91495f412 100644 --- a/apps/sn/app/(map-layout)/map/components/popupComponent/chart/infilChart.tsx +++ b/apps/sn/app/(map-layout)/map/components/popupComponent/chart/infilChart.tsx @@ -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([]); @@ -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 ( - +
+

침입수 분석

+ {renderBody()} +
); }; diff --git a/apps/sn/app/(map-layout)/map/components/popupComponent/chart/rdiiChart.tsx b/apps/sn/app/(map-layout)/map/components/popupComponent/chart/rdiiChart.tsx index aa622be1b..ec1cec8b1 100644 --- a/apps/sn/app/(map-layout)/map/components/popupComponent/chart/rdiiChart.tsx +++ b/apps/sn/app/(map-layout)/map/components/popupComponent/chart/rdiiChart.tsx @@ -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([]); @@ -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 ( - +
+

RDII 분석

+ {renderBody()} +
); }; diff --git a/apps/sn/app/(map-layout)/map/components/popupComponent/chartModalStore.ts b/apps/sn/app/(map-layout)/map/components/popupComponent/chartModalStore.ts deleted file mode 100644 index c84fa48a7..000000000 --- a/apps/sn/app/(map-layout)/map/components/popupComponent/chartModalStore.ts +++ /dev/null @@ -1,16 +0,0 @@ -import { create } from 'zustand'; - -interface ModalStore { - isOpen: boolean; - setIsOpen: (open: boolean) => void; -} - -export const useInfilChartModalStore = create((set) => ({ - isOpen: false, - setIsOpen: (open: boolean) => set({ isOpen: open }), -})); - -export const useRdiiChartModalStore = create((set) => ({ - isOpen: false, - setIsOpen: (open: boolean) => set({ isOpen: open }), -})); \ No newline at end of file diff --git a/apps/sn/app/(map-layout)/map/components/popupComponent/featureClickEventPopup.tsx b/apps/sn/app/(map-layout)/map/components/popupComponent/featureClickEventPopup.tsx index 81be89a5d..1e0cc97d7 100644 --- a/apps/sn/app/(map-layout)/map/components/popupComponent/featureClickEventPopup.tsx +++ b/apps/sn/app/(map-layout)/map/components/popupComponent/featureClickEventPopup.tsx @@ -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(); // api 호출여부 const contentRef = useRef(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(''); // 로딩 상태 @@ -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 === '하수관로' && ( <> - - + + + )} {/* 하수도정비기본계획보고서 탭 */} {/* 📋 결과 리스트 */} @@ -214,22 +272,16 @@ const SearchModal: React.FC = () => { ); + if (renderMode === "launcher") { + return null; + } + return ( -
- +
+

관망도 검색

+
+ {renderBody()} +
); diff --git a/apps/sn/app/(map-layout)/map/searchModal/useBasicTab.ts b/apps/sn/app/(map-layout)/map/searchModal/useBasicTab.ts index 9184286ed..d914d2dbb 100644 --- a/apps/sn/app/(map-layout)/map/searchModal/useBasicTab.ts +++ b/apps/sn/app/(map-layout)/map/searchModal/useBasicTab.ts @@ -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; diff --git a/apps/sn/app/(map-layout)/map/searchModal/useOperationTab.ts b/apps/sn/app/(map-layout)/map/searchModal/useOperationTab.ts index 669dd4cb5..3d80b54d9 100644 --- a/apps/sn/app/(map-layout)/map/searchModal/useOperationTab.ts +++ b/apps/sn/app/(map-layout)/map/searchModal/useOperationTab.ts @@ -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 }; -}; \ No newline at end of file +}; diff --git a/apps/sn/app/(map-layout)/map/searchModal/useSearchModal.ts b/apps/sn/app/(map-layout)/map/searchModal/useSearchModal.ts index 1d317e3d4..585885521 100644 --- a/apps/sn/app/(map-layout)/map/searchModal/useSearchModal.ts +++ b/apps/sn/app/(map-layout)/map/searchModal/useSearchModal.ts @@ -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]; diff --git a/apps/sn/app/(popupLayout)/layout.tsx b/apps/sn/app/(popupLayout)/layout.tsx new file mode 100644 index 000000000..d02ca99ac --- /dev/null +++ b/apps/sn/app/(popupLayout)/layout.tsx @@ -0,0 +1,11 @@ +export default function PopupLayout({ + children, +}: { + children: React.ReactNode; +}) { + return ( +
+ {children} +
+ ); +} diff --git a/apps/sn/app/(popupLayout)/popup/[popupKey]/page.tsx b/apps/sn/app/(popupLayout)/popup/[popupKey]/page.tsx new file mode 100644 index 000000000..8231f4f4d --- /dev/null +++ b/apps/sn/app/(popupLayout)/popup/[popupKey]/page.tsx @@ -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>; +}; + +export default async function PopupPage({ + params, + searchParams, +}: PopupPageProps) { + const { popupKey } = await params; + const resolvedSearchParams = await searchParams; + + if (!isWindowPopupKey(popupKey)) { + return ( +
+

등록되지 않은 팝업입니다.

+

popupKey: {popupKey}

+
+ ); + } + + const PopupComponent = windowPopupRegistry[popupKey].component; + + return ; +} diff --git a/apps/sn/app/(with-base-layout)/oper/floodPre/page.tsx b/apps/sn/app/(with-base-layout)/oper/floodPre/page.tsx index 4b1db105b..3df6a1541 100644 --- a/apps/sn/app/(with-base-layout)/oper/floodPre/page.tsx +++ b/apps/sn/app/(with-base-layout)/oper/floodPre/page.tsx @@ -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(false); // 선택 초기화 상태 const [isResetPage, setIsResetPage] = useState(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} /> - {/* 파일 업로드 모달 */} - { - await registFile(file, year, quater, cd, id) - }} - /> - } - /> - {/* 엑셀 데이터 테이블 모달 */} - } - useDirectRender={true} - /> ) } -export default Page \ No newline at end of file +export default Page diff --git a/apps/sn/app/(with-base-layout)/oper/floodPre/tableModal.tsx b/apps/sn/app/(with-base-layout)/oper/floodPre/tableModal.tsx index f4acc2056..4b35069af 100644 --- a/apps/sn/app/(with-base-layout)/oper/floodPre/tableModal.tsx +++ b/apps/sn/app/(with-base-layout)/oper/floodPre/tableModal.tsx @@ -55,6 +55,12 @@ const TableModal: React.FC = ({ 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 = ({ 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 { diff --git a/apps/sn/app/(with-base-layout)/oper/oldPp/page.tsx b/apps/sn/app/(with-base-layout)/oper/oldPp/page.tsx index a078a9795..86d5d4853 100644 --- a/apps/sn/app/(with-base-layout)/oper/oldPp/page.tsx +++ b/apps/sn/app/(with-base-layout)/oper/oldPp/page.tsx @@ -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(false); // 선택 초기화 상태 const [isResetPage, setIsResetPage] = useState(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} /> - {/* 파일 업로드 모달 */} - { - await registFile(file, year, quater, cd, id) - }} - /> - } - /> - {/* 엑셀 데이터 테이블 모달 */} - } - useDirectRender={true} - /> ) } -export default Page \ No newline at end of file +export default Page diff --git a/apps/sn/app/(with-base-layout)/oper/oldPp/tableModal.tsx b/apps/sn/app/(with-base-layout)/oper/oldPp/tableModal.tsx index 13921b2c1..f22df820c 100644 --- a/apps/sn/app/(with-base-layout)/oper/oldPp/tableModal.tsx +++ b/apps/sn/app/(with-base-layout)/oper/oldPp/tableModal.tsx @@ -64,6 +64,12 @@ const TableModal: React.FC = ({ 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 = ({ 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 { diff --git a/apps/sn/app/components/lib/api/index.ts b/apps/sn/app/components/lib/api/index.ts index 73025226b..aba7a3092 100644 --- a/apps/sn/app/components/lib/api/index.ts +++ b/apps/sn/app/components/lib/api/index.ts @@ -153,7 +153,10 @@ export const getRawJson = async (url: string): Promise => { } 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 => { 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 => { } 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(); diff --git a/apps/sn/app/components/ui/modal.tsx b/apps/sn/app/components/ui/modal.tsx deleted file mode 100644 index dce0ee2b5..000000000 --- a/apps/sn/app/components/ui/modal.tsx +++ /dev/null @@ -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 = ({ isModalOpen, setIsModalOpen, title, children, closeModal, saveModal }) => { - return ( - - - - {title ? - {title} - : null} - {children} - -
- {saveModal ? : null} - -
-
-
-
- ) -} - -export default Modal \ No newline at end of file diff --git a/apps/sn/app/html/(map-layout)/map/components/popupComponent/featureClickEventPopup.tsx b/apps/sn/app/html/(map-layout)/map/components/popupComponent/featureClickEventPopup.tsx index 11e61b006..d507a4342 100644 --- a/apps/sn/app/html/(map-layout)/map/components/popupComponent/featureClickEventPopup.tsx +++ b/apps/sn/app/html/(map-layout)/map/components/popupComponent/featureClickEventPopup.tsx @@ -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(); // api 호출여부 const contentRef = useRef(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} /> -
정보를 저장하시겠습니까?
{ContentComponent && ) { + const theme = (await cookies()).get("site-theme-key"); + + return ( + + + {children} + + + ); +} diff --git a/apps/sn/app/providers.tsx b/apps/sn/app/providers.tsx new file mode 100644 index 000000000..4bbe9f864 --- /dev/null +++ b/apps/sn/app/providers.tsx @@ -0,0 +1 @@ +export { AppProvider } from "../src/app/providers"; diff --git a/apps/sn/src/app/index.tsx b/apps/sn/src/app/index.tsx index 692b55ef0..29d73b03f 100644 --- a/apps/sn/src/app/index.tsx +++ b/apps/sn/src/app/index.tsx @@ -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"; diff --git a/apps/sn/src/features/windowPopup/index.ts b/apps/sn/src/features/windowPopup/index.ts new file mode 100644 index 000000000..8424dcc1d --- /dev/null +++ b/apps/sn/src/features/windowPopup/index.ts @@ -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"; diff --git a/apps/sn/src/features/windowPopup/lib/openWindowPopup.ts b/apps/sn/src/features/windowPopup/lib/openWindowPopup.ts new file mode 100644 index 000000000..dbb90794a --- /dev/null +++ b/apps/sn/src/features/windowPopup/lib/openWindowPopup.ts @@ -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; +}; diff --git a/apps/sn/src/features/windowPopup/lib/popupMessage.ts b/apps/sn/src/features/windowPopup/lib/popupMessage.ts new file mode 100644 index 000000000..6ae7ad7f9 --- /dev/null +++ b/apps/sn/src/features/windowPopup/lib/popupMessage.ts @@ -0,0 +1,24 @@ +import type { WindowPopupMessageType } from "../model/types"; + +export const postPopupMessage = ( + type: WindowPopupMessageType, + payload?: Record, +) => { + if (typeof window === "undefined" || !window.opener) return; + + window.opener.postMessage( + { + type, + payload, + }, + window.location.origin, + ); +}; + +export const postPopupMessageAndClose = ( + type: WindowPopupMessageType, + payload?: Record, +) => { + postPopupMessage(type, payload); + window.close(); +}; diff --git a/apps/sn/src/features/windowPopup/model/popupDefinitions.ts b/apps/sn/src/features/windowPopup/model/popupDefinitions.ts new file mode 100644 index 000000000..b3184650d --- /dev/null +++ b/apps/sn/src/features/windowPopup/model/popupDefinitions.ts @@ -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; + +export type WindowPopupKey = keyof typeof windowPopupDefinitions; + +export const isWindowPopupKey = (key: string): key is WindowPopupKey => { + return key in windowPopupDefinitions; +}; diff --git a/apps/sn/src/features/windowPopup/model/types.ts b/apps/sn/src/features/windowPopup/model/types.ts new file mode 100644 index 000000000..8387e03c1 --- /dev/null +++ b/apps/sn/src/features/windowPopup/model/types.ts @@ -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; +}; + +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]; diff --git a/apps/sn/src/features/windowPopup/registry.tsx b/apps/sn/src/features/windowPopup/registry.tsx new file mode 100644 index 000000000..f8b247744 --- /dev/null +++ b/apps/sn/src/features/windowPopup/registry.tsx @@ -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; diff --git a/apps/sn/src/features/windowPopup/ui/FloodPreTablePopupContent.tsx b/apps/sn/src/features/windowPopup/ui/FloodPreTablePopupContent.tsx new file mode 100644 index 000000000..bbc342b70 --- /dev/null +++ b/apps/sn/src/features/windowPopup/ui/FloodPreTablePopupContent.tsx @@ -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 ( + + ); + } + + return ( +
+

+ {windowPopupDefinitions.floodPreTable.title} +

+
+ +
+
+ ); +} diff --git a/apps/sn/src/features/windowPopup/ui/FloodPreUploadPopupContent.tsx b/apps/sn/src/features/windowPopup/ui/FloodPreUploadPopupContent.tsx new file mode 100644 index 000000000..629c6be53 --- /dev/null +++ b/apps/sn/src/features/windowPopup/ui/FloodPreUploadPopupContent.tsx @@ -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 ( +
+ +
+ ); +} diff --git a/apps/sn/src/features/windowPopup/ui/InfilChartPopupContent.tsx b/apps/sn/src/features/windowPopup/ui/InfilChartPopupContent.tsx new file mode 100644 index 000000000..f6a739f53 --- /dev/null +++ b/apps/sn/src/features/windowPopup/ui/InfilChartPopupContent.tsx @@ -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 ( + + ); + } + + return ; +} diff --git a/apps/sn/src/features/windowPopup/ui/OldPpTablePopupContent.tsx b/apps/sn/src/features/windowPopup/ui/OldPpTablePopupContent.tsx new file mode 100644 index 000000000..3d3439d9c --- /dev/null +++ b/apps/sn/src/features/windowPopup/ui/OldPpTablePopupContent.tsx @@ -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 ( + + ); + } + + return ( +
+

+ {windowPopupDefinitions.oldPpTable.title} +

+
+ +
+
+ ); +} diff --git a/apps/sn/src/features/windowPopup/ui/OldPpUploadPopupContent.tsx b/apps/sn/src/features/windowPopup/ui/OldPpUploadPopupContent.tsx new file mode 100644 index 000000000..e8e1b1e52 --- /dev/null +++ b/apps/sn/src/features/windowPopup/ui/OldPpUploadPopupContent.tsx @@ -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 ( +
+ +
+ ); +} diff --git a/apps/sn/src/features/windowPopup/ui/PopupMissingParams.tsx b/apps/sn/src/features/windowPopup/ui/PopupMissingParams.tsx new file mode 100644 index 000000000..32b8bb088 --- /dev/null +++ b/apps/sn/src/features/windowPopup/ui/PopupMissingParams.tsx @@ -0,0 +1,19 @@ +export default function PopupMissingParams({ + title, + missingParams, +}: { + title: string; + missingParams: string[]; +}) { + return ( +
+

{title}

+

+ 팝업을 여는 데 필요한 값이 누락되었습니다. +

+

+ 누락된 값: {missingParams.join(", ")} +

+
+ ); +} diff --git a/apps/sn/src/features/windowPopup/ui/RdiiChartPopupContent.tsx b/apps/sn/src/features/windowPopup/ui/RdiiChartPopupContent.tsx new file mode 100644 index 000000000..8a591e004 --- /dev/null +++ b/apps/sn/src/features/windowPopup/ui/RdiiChartPopupContent.tsx @@ -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 ( + + ); + } + + return ; +} diff --git a/apps/sn/src/features/windowPopup/ui/ReportFilesPopupContent.tsx b/apps/sn/src/features/windowPopup/ui/ReportFilesPopupContent.tsx new file mode 100644 index 000000000..de3064383 --- /dev/null +++ b/apps/sn/src/features/windowPopup/ui/ReportFilesPopupContent.tsx @@ -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([]); + const [allFiles, setAllFiles] = useState([]); + 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 ( + + ); + } + + return ( +
+

+ {windowPopupDefinitions.reportFiles.title} +

+
+ + { + if (typeof index !== "number" || !files[index]) return; + const target = files[index]; + handleDownload(target.fileSn, target.fileTitle, target.filePath); + }} + /> +
+
+ ); +} diff --git a/apps/sn/src/features/windowPopup/ui/ReportTabPopupContent.tsx b/apps/sn/src/features/windowPopup/ui/ReportTabPopupContent.tsx new file mode 100644 index 000000000..d2913df8e --- /dev/null +++ b/apps/sn/src/features/windowPopup/ui/ReportTabPopupContent.tsx @@ -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 ( +
+

+ {windowPopupDefinitions.reportTab.title} +

+
+ +
+
+ ); +} diff --git a/apps/sn/src/features/windowPopup/ui/SearchModalPopupContent.tsx b/apps/sn/src/features/windowPopup/ui/SearchModalPopupContent.tsx new file mode 100644 index 000000000..0bbb50bbe --- /dev/null +++ b/apps/sn/src/features/windowPopup/ui/SearchModalPopupContent.tsx @@ -0,0 +1,5 @@ +import SearchModal from "@/app/(map-layout)/map/searchModal/searchModal"; + +export default function SearchModalPopupContent() { + return ; +} diff --git a/apps/sn/src/features/windowPopup/ui/SscDetailPopupContent.tsx b/apps/sn/src/features/windowPopup/ui/SscDetailPopupContent.tsx new file mode 100644 index 000000000..9e0ff6464 --- /dev/null +++ b/apps/sn/src/features/windowPopup/ui/SscDetailPopupContent.tsx @@ -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 ( + + ); + } + + return ( + + ); +} diff --git a/apps/sn/src/features/windowPopup/ui/popupContentUtils.ts b/apps/sn/src/features/windowPopup/ui/popupContentUtils.ts new file mode 100644 index 000000000..5b2c1152f --- /dev/null +++ b/apps/sn/src/features/windowPopup/ui/popupContentUtils.ts @@ -0,0 +1,6 @@ +export const getPopupParamValue = ( + value: string | string[] | undefined, +): string => { + if (Array.isArray(value)) return value[0] ?? ""; + return value ?? ""; +}; diff --git a/apps/st/package.json b/apps/st/package.json index 184138657..5cf4bf67d 100644 --- a/apps/st/package.json +++ b/apps/st/package.json @@ -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" + } } diff --git a/apps/st/src/app/providers/app-provider.tsx b/apps/st/src/app/providers/app-provider.tsx index 70e69eee8..4e64d241c 100644 --- a/apps/st/src/app/providers/app-provider.tsx +++ b/apps/st/src/app/providers/app-provider.tsx @@ -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 }>) => { + diff --git a/apps/st/src/components/button/ReviewOpinionButton.tsx b/apps/st/src/components/button/ReviewOpinionButton.tsx index 50df8fcb4..fbcdbd754 100644 --- a/apps/st/src/components/button/ReviewOpinionButton.tsx +++ b/apps/st/src/components/button/ReviewOpinionButton.tsx @@ -1,7 +1,7 @@ import { Button } from "@common/components"; interface ReviewOpinionButtonProps { - reviewOpinionFunc: () => void; + reviewOpinionFunc: () => void | Promise; } const ReviewOpinionButton = ({ reviewOpinionFunc }: ReviewOpinionButtonProps) => { diff --git a/apps/st/src/components/button/VerificationResultButton.tsx b/apps/st/src/components/button/VerificationResultButton.tsx new file mode 100644 index 000000000..580f6d0d0 --- /dev/null +++ b/apps/st/src/components/button/VerificationResultButton.tsx @@ -0,0 +1,16 @@ +import { Button } from "@common/components"; + +interface VerificationResultButtonProps { + VerificationResultButtonFunc: () => void; +} + +const VerificationResultButton = ({ VerificationResultButtonFunc }: VerificationResultButtonProps) => { + return ( + + ); +}; + +export default VerificationResultButton; \ No newline at end of file diff --git a/apps/st/src/components/css/DataGrid.css b/apps/st/src/components/css/DataGrid.css index 8d56c48f8..c1b346ad5 100644 --- a/apps/st/src/components/css/DataGrid.css +++ b/apps/st/src/components/css/DataGrid.css @@ -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; } \ No newline at end of file diff --git a/apps/st/src/components/module/DataGrid.tsx b/apps/st/src/components/module/DataGrid.tsx index d85693a53..93e5fccc5 100644 --- a/apps/st/src/components/module/DataGrid.tsx +++ b/apps/st/src/components/module/DataGrid.tsx @@ -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(null); @@ -340,9 +347,20 @@ const DataGrid = ({ const cellTooltipCallbackRef = useRef( cellTooltipCallback, ); + const onCellEditedRef = useRef(onCellEdited); + const onScrollToBottomRef = useRef<(() => void) | undefined>( + onScrollToBottom, + ); + const onItemCheckedRef = useRef(onItemChecked); + const onItemAllCheckedRef = + useRef(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 (
진행상황:{" "} {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" + ? "반려" + : "검증중"} 제출일:{" "} {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 = ({ )}
-
+
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[], - ): void; - getJsonRow?( - r: number, - ): Record; - } + setFields?(f: unknown[]): void; + setRows?( + r: Record[], + ): void; + getJsonRow?( + r: number, + ): Record; + } | 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={ diff --git a/apps/st/src/components/module/SearchForm.tsx b/apps/st/src/components/module/SearchForm.tsx index c3d7e4fc2..b5cdb06a4 100644 --- a/apps/st/src/components/module/SearchForm.tsx +++ b/apps/st/src/components/module/SearchForm.tsx @@ -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> => { - 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> => { + return statisticsApi.getSidoList({ instSeCd: key[1] }); }; const getSggListFetcher = async ( - key: [string, string], + key: [string, string, string, string], ): Promise> => { - 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>( - "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>( sggKey, getSggListFetcher, @@ -102,7 +118,7 @@ const SearchForm = (props: SearchFormProps = {}) => { ); const sggOptions = sggList?.data ?? []; - const handleSubmit = (e: React.SubmitEvent) => { + const handleSubmit = (e: React.FormEvent) => { e.preventDefault(); props.onSubmit?.(values); }; @@ -163,10 +179,14 @@ const SearchForm = (props: SearchFormProps = {}) => { onValueChange={handleSidoChange} > - + - 선택 + + {sidoSelectText} + {sidoList?.data?.sidoList?.map((sido) => ( > => { - return statisticsAPi.getSidoList(); + return statisticsApi.getSidoList(); }; const getSggAllListFetcher = async ( key: [string, string], ): Promise> => { - return statisticsAPi.getSggAllList(key[1]); + return statisticsApi.getSggAllList(key[1]); }; type SidoSggFacilityNameSearchFormProps = { diff --git a/apps/st/src/components/module/VerificationResultDialog.tsx b/apps/st/src/components/module/VerificationResultDialog.tsx new file mode 100644 index 000000000..5e11dd6ac --- /dev/null +++ b/apps/st/src/components/module/VerificationResultDialog.tsx @@ -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 ( + + + + 검증 결과 + +
+ + + + + + +
+ ); +} + diff --git a/apps/st/src/components/module/VerificationResultHistoryDialog.tsx b/apps/st/src/components/module/VerificationResultHistoryDialog.tsx new file mode 100644 index 000000000..3be4a6a9e --- /dev/null +++ b/apps/st/src/components/module/VerificationResultHistoryDialog.tsx @@ -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; + +const PRCS_YN_LABEL: Record = { + "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([]); + const [imrEntries, setImrEntries] = useState([]); + 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(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[]>) => {}) as Dispatch< + SetStateAction[]> + >; + + const renderGrid = ( + rows: VerificationResultGridRow[], + pageIndex: number, + pageCount: number, + onPageIndexChange: (page: number) => void, + loading: boolean, + ) => { + if (!loading && rows.length === 0) { + return

{emptyMessage}

; + } + + return ( +
+ + + {loading && ( +
+
+ +
+
+ )} +
+ ); + }; + + return ( + + + + {`${title} - ${queryContext?.statsYr} ${queryContext?.ctpvNm} ${queryContext?.sggNm}`} + +
+ {errorMessage && ( +

+ {errorMessage} +

+ )} +
+

일반검증 내역

+ {renderGrid(normalRows, normalPageIndex, normalPageCount, handleNormalPageChange, normalLoading)} +
+
+

I-MR 검증내역

+ {renderGrid(imrRows, imrPageIndex, imrPageCount, handleImrPageChange, imrLoading)} +
+
+ + + + + +
+
+ ); +} diff --git a/apps/st/src/components/module/VerificationResultHistoryPagination.tsx b/apps/st/src/components/module/VerificationResultHistoryPagination.tsx new file mode 100644 index 000000000..e3fa59851 --- /dev/null +++ b/apps/st/src/components/module/VerificationResultHistoryPagination.tsx @@ -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 ( +
+ + +
+ {pageNumbers.map((pageNumber) => ( + + ))} +
+ + +
+ ); +} diff --git a/apps/st/src/components/statistics/input/SewagePipeInp.tsx b/apps/st/src/components/statistics/input/SewagePipeInp.tsx index 42fc8cfd2..91eaaacc3 100644 --- a/apps/st/src/components/statistics/input/SewagePipeInp.tsx +++ b/apps/st/src/components/statistics/input/SewagePipeInp.tsx @@ -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[]>, + 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[]>>(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 = () => {
} - +
+ void handleActiveTabChange(v)} + className="w-full" + variant="default" + > + + {columnInfo.map((info) => ( + + {info.tabLabel || info.tabKey} + + ))} + + {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 ( + +
+
+ {showSplitTopButton && ( + + )} +
+ { + 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; + }); + }} + /> +
+
+ ); + } + + if (!info.columns || !info.columnLayout) { + return null; + } + + const rowData = rowDataMap.get(tabKey) ?? []; + const gridColumnInfo: ColumnInfo = { + columns: info.columns, + columnLayout: info.columnLayout, + }; + + return ( + + { + 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} + /> + + ); + })} +
+
); }; diff --git a/apps/st/src/components/statistics/input/SeweragePenetrationRateInp.tsx b/apps/st/src/components/statistics/input/SeweragePenetrationRateInp.tsx index e8fec3870..c37700dae 100644 --- a/apps/st/src/components/statistics/input/SeweragePenetrationRateInp.tsx +++ b/apps/st/src/components/statistics/input/SeweragePenetrationRateInp.tsx @@ -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((resolve) => setTimeout(resolve, ms)); + +/** IMR 상태가 COMPLETED 이고 job_prog 가 100일 때까지 폴링 */ +async function waitForImrJobCompleted( + jobId: string, + getStatus: (id: string) => Promise, + options?: { + pollIntervalMs?: number; + maxAttempts?: number; + onProgress?: (jobProgress: number) => void; + }, +): Promise { + 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(null); const [statusInfo, setStatusInfo] = useState(null); const [showDailog, setShowDailog] = useState(false); + const [verificationHistoryOpen, setVerificationHistoryOpen] = useState(false); + const [verificationResultDialogOpen, setVerificationResultDialogOpen] = useState(false); /** 예외제출 버튼으로 연 다이얼로그인지 (확인 시 executeSubmit 대신 SubmitExceptionFunc 경로) */ const [isExceptionSubmitFlow, setIsExceptionSubmitFlow] = useState(false); const [statsCd, setStatsCd] = useState(''); 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 = () => {
{showEnterableToolbarButtons && ( <> - {isAdminInfo && statusInfo && statusInfo.laeSbmsnSttsCd !== '0' && ( - submitFunc(true)} /> - )} + getLastYearDataFunc(searchValues ?? {})} /> submitFunc(false)} /> - )} {statusInfo?.laeSbmsnSttsCd === '2' && } @@ -560,7 +614,7 @@ const SeweragePenetrationRateInp = () => {
)} - {isLoading &&
+ {isLoading && !globalProgressVisible &&
@@ -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} /> + + ); }; diff --git a/apps/st/src/components/statistics/input/StatsInputStatusInp.tsx b/apps/st/src/components/statistics/input/StatsInputStatusInp.tsx index 919231fde..5bbf6dd7a 100644 --- a/apps/st/src/components/statistics/input/StatsInputStatusInp.tsx +++ b/apps/st/src/components/statistics/input/StatsInputStatusInp.tsx @@ -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 => { +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 => { 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[]>([]); - const [searchParams, setSearchParams] = useState(null); + const [rowData, setRowData] = useState[]>( + [], + ); + const [searchParams, setSearchParams] = + useState(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( 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 = () => {
-
+
- {isLoading &&
-
- + {isLoading && ( +
+
+ +
-
} - @@ -111,4 +121,4 @@ const StatsInputStatusInp = () => { ); }; -export default StatsInputStatusInp; \ No newline at end of file +export default StatsInputStatusInp; diff --git a/apps/st/src/components/statistics/management/LogHistory.tsx b/apps/st/src/components/statistics/management/LogHistory.tsx index 8614b0b5d..e2abaf60d 100644 --- a/apps/st/src/components/statistics/management/LogHistory.tsx +++ b/apps/st/src/components/statistics/management/LogHistory.tsx @@ -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> => { + return statisticsApi.getSidoList(); +}; + +const getSggListFetcher = async ( + key: [string, string], +): Promise> => { + return statisticsApi.getSggList(key[1]); +}; + const LogHistory = () => { - // const { setMessage: alert } = useAlertStore((state) => state); + const { setMessage: alert } = useAlertStore((state) => state); const [statsCd, setStatsCd] = useState(""); + const [sido, setSido] = useState("ALL"); + const [gugun, setGugun] = useState("ALL"); + const [bsnmCd, setBsnmCd] = useState(""); + const [statsDataChgComCd, setStatsDataChgComCd] = useState("ALL"); const [isLoading, setIsLoading] = useState(false); const [rowData, setRowData] = useState[]>( [], @@ -32,49 +63,148 @@ const LogHistory = () => { string, string | number > | null>(null); + const [startDate, setStartDate] = useState(() => + dayjs().subtract(1, "month").startOf("day").toDate(), + ); + const [endDate, setEndDate] = useState(() => + dayjs().endOf("day").toDate(), + ); + + const { data: sidoList } = useSWR>( + "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>( + 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) => { + const handleSearch = async (e: React.FormEvent) => { 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) => { setSelectedLogRow(row); setDetailDialogOpen(true); @@ -86,14 +216,58 @@ const LogHistory = () => { }; useEffect(() => { - loadLogs(1); + void loadLogs(1); }, []); return (
-
+
+
+ + +
+
+ + +
{ className="w-[16rem]" />
+
+ + +
+
+ + + + +
diff --git a/apps/st/src/components/statistics/management/LogHistoryDetailDialog.tsx b/apps/st/src/components/statistics/management/LogHistoryDetailDialog.tsx index b451b3a14..09667bc88 100644 --- a/apps/st/src/components/statistics/management/LogHistoryDetailDialog.tsx +++ b/apps/st/src/components/statistics/management/LogHistoryDetailDialog.tsx @@ -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); diff --git a/apps/st/src/components/statistics/review/ReturnReasonDialog.tsx b/apps/st/src/components/statistics/review/ReturnReasonDialog.tsx new file mode 100644 index 000000000..b2955bc9a --- /dev/null +++ b/apps/st/src/components/statistics/review/ReturnReasonDialog.tsx @@ -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; +} + +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 ( + + + + 반려 사유 + +
+
+ +