diff --git a/apps/dm/app/(with-layout)/contents/page.tsx b/apps/dm/app/(with-layout)/contents/page.tsx new file mode 100644 index 000000000..27d0974be --- /dev/null +++ b/apps/dm/app/(with-layout)/contents/page.tsx @@ -0,0 +1,9 @@ +export const dynamic = "force-dynamic"; +import { ContentsPage } from "@/components/ui/contents"; + +export default async function Contents() { + const contsSn = 28; + const menuCd = "24"; + + return ; +} diff --git a/apps/dm/app/(with-layout)/example/page.tsx b/apps/dm/app/(with-layout)/example/page.tsx index b32b3346e..94e908d81 100644 --- a/apps/dm/app/(with-layout)/example/page.tsx +++ b/apps/dm/app/(with-layout)/example/page.tsx @@ -1,3 +1,6 @@ +/** + * 물 재이용 사례 + */ import SubPageHeader from "@/components/SubPageHeader"; import BusinessSubNav, { type BusinessSubNavMenu } from "@/components/BusinessSubNav"; diff --git a/apps/dm/app/(with-layout)/status/_components/SewageStatusSection.tsx b/apps/dm/app/(with-layout)/status/_components/SewageStatusSection.tsx index cd660a929..f7eb49a84 100644 --- a/apps/dm/app/(with-layout)/status/_components/SewageStatusSection.tsx +++ b/apps/dm/app/(with-layout)/status/_components/SewageStatusSection.tsx @@ -287,7 +287,7 @@ export default function SewageStatusSection() { const isSubItem = type === "red-item" || type === "blue-item"; if (index === 0) { - console.log(header.id, value); + // console.log(header.id, value); return ( {isSubItem ? ( diff --git a/apps/dm/app/(with-layout)/status/page.tsx b/apps/dm/app/(with-layout)/status/page.tsx index b18662c06..8bed3d04f 100644 --- a/apps/dm/app/(with-layout)/status/page.tsx +++ b/apps/dm/app/(with-layout)/status/page.tsx @@ -58,7 +58,7 @@ const menus: BusinessSubNavMenu[] = [ }, { id: "status-treated-usage", - label: "연도별 하수처리수 재이용 현황", + label: "용도별 하수처리수 재이용 현황", }, { id: "status-treated-facility", diff --git a/apps/dm/app/layout.tsx b/apps/dm/app/layout.tsx index 1fd9ea1cc..49c6c357c 100644 --- a/apps/dm/app/layout.tsx +++ b/apps/dm/app/layout.tsx @@ -16,7 +16,7 @@ export default function RootLayout({ 물재이용 대민 -
+
{children}
diff --git a/apps/dm/app/main/layout.tsx b/apps/dm/app/main/layout.tsx index b1304a66a..873024784 100644 --- a/apps/dm/app/main/layout.tsx +++ b/apps/dm/app/main/layout.tsx @@ -1,12 +1,10 @@ // app/main/layout.tsx import "../reuse.module.css"; -import TopNav from "@/components/TopNav"; import { StoreProvider } from "../store"; export default function ReuseLandingLayout({ children }: { children: React.ReactNode }) { return ( -
{children}
{/* TopNav 높이만큼 */}
); diff --git a/apps/dm/app/store/store.tsx b/apps/dm/app/store/store.tsx index c1d4fcdfd..3b7b2c24d 100644 --- a/apps/dm/app/store/store.tsx +++ b/apps/dm/app/store/store.tsx @@ -33,7 +33,7 @@ export const StoreZus = ( return createStore()((set) => { if (typeof window !== "undefined") { tokenManager.setToken("site-id-key", process.env.NEXT_PUBLIC_SITE_ID||"DM"); - tokenManager.setToken("site-theme-key", "green"); + tokenManager.setToken("site-theme-key", "white"); } return { diff --git a/apps/dm/components/BusinessSubNav.tsx b/apps/dm/components/BusinessSubNav.tsx index 6b4246412..d8753b5dd 100644 --- a/apps/dm/components/BusinessSubNav.tsx +++ b/apps/dm/components/BusinessSubNav.tsx @@ -1,3 +1,6 @@ +/** + * 우측 메뉴 + */ "use client"; import Link from "next/link"; @@ -47,44 +50,67 @@ export default function BusinessSubNav({ setOpenMenus(initialOpenState); }, [menus]); - useEffect(() => { - if (!allSections.length) return; +useEffect(() => { + if (!allSections.length) return; - const observer = new IntersectionObserver( - (entries) => { - const visible = entries - .filter((entry) => entry.isIntersecting) - .sort((a, b) => b.intersectionRatio - a.intersectionRatio); + const targets = allSections + .map((section) => document.getElementById(section.id)) + .filter(Boolean) as HTMLElement[]; - if (visible[0]) { - setActiveId(visible[0].target.id); - } - }, - { - rootMargin: `-${offsetTop}px 0px -55% 0px`, - threshold: [0.2, 0.35, 0.5, 0.7], + if (!targets.length) return; + + let rafId = 0; + + const updateActiveSection = () => { + const baseLine = offsetTop + 500; + let nextActiveId = targets[0]?.id ?? ""; + + for (const el of targets) { + const { top } = el.getBoundingClientRect(); + if (top <= baseLine) { + nextActiveId = el.id; + continue; } - ); - const targets = allSections - .map((section) => document.getElementById(section.id)) - .filter(Boolean) as HTMLElement[]; + break; + } - targets.forEach((el) => observer.observe(el)); + setActiveId((prev) => (prev === nextActiveId ? prev : nextActiveId)); + }; - const hash = window.location.hash.replace("#", ""); - const initialActive = - allSections.find((section) => section.id === hash)?.id ?? - allSections[0]?.id ?? - ""; + const requestUpdate = () => { + if (rafId) return; - setActiveId(initialActive); + rafId = window.requestAnimationFrame(() => { // 예약번호 반환 + updateActiveSection(); + rafId = 0; + }); + }; - return () => observer.disconnect(); - }, [allSections, offsetTop]); + const hash = window.location.hash.replace("#", ""); + const initialActive = + allSections.find((section) => section.id === hash)?.id ?? + allSections[0]?.id ?? + ""; - useEffect(() => { - if (!activeId) return; + setActiveId(initialActive); + updateActiveSection(); + + window.addEventListener("scroll", requestUpdate, { passive: true }); + window.addEventListener("resize", requestUpdate); + + return () => { + window.removeEventListener("scroll", requestUpdate); + window.removeEventListener("resize", requestUpdate); + + if (rafId) { + window.cancelAnimationFrame(rafId); + } + }; +}, [allSections, offsetTop]); + + useEffect(() => { + if (!activeId) return; const parentMenu = menus.find((menu) => (menu.sections ?? []).some((section) => section.id === activeId) @@ -93,13 +119,13 @@ export default function BusinessSubNav({ if (!parentMenu) return; setOpenMenus(() => { - const nextOpenState: Record = {}; + const nextOpenState: Record = {}; - menus.forEach((menu) => { + menus.forEach((menu) => { nextOpenState[menu.key] = menu.key === parentMenu.key; - }); + }); - return nextOpenState; + return nextOpenState; }); }, [activeId, menus]); diff --git a/apps/dm/components/ui/contents/contents-api.ts b/apps/dm/components/ui/contents/contents-api.ts new file mode 100644 index 000000000..3a7c64639 --- /dev/null +++ b/apps/dm/components/ui/contents/contents-api.ts @@ -0,0 +1,26 @@ +// apps/dm/src/entities/content/ +// contents-api.ts +import { unstable_cache } from "next/cache"; +import { ENDPOINT } from "@/src/shared/config"; + + + +export const getCachedContentDetail = unstable_cache( + async (contsSn: number, menuCd: string) => { + const baseUrl = process.env.NEXT_PUBLIC_PROXY_HOST || "http://localhost:3015"; + const res = await fetch( + `${baseUrl}${ENDPOINT.CMS_SERVICE.CONTENTS}/${contsSn}?menuCd=${menuCd}`, + { + next: { revalidate: 3600, tags: [`content:${contsSn}`] }, + } + ); + + if (!res.ok) { + console.log(res); + throw new Error("Failed to fetch content"); + } + + return res.json(); + }, + ["dm-content-detail"], +); diff --git a/apps/dm/components/ui/contents/contentsPage.tsx b/apps/dm/components/ui/contents/contentsPage.tsx new file mode 100644 index 000000000..c823acd42 --- /dev/null +++ b/apps/dm/components/ui/contents/contentsPage.tsx @@ -0,0 +1,18 @@ +/** + * 컨텐츠 서버 페이지 + */ +import { getCachedContentDetail } from "./contents-api"; +import { ContentsPageClient } from "./contentsPageClient"; + +type ContentsPageProps = { + contsSn: number; + menuCd: string; +}; + +async function ContentsPage({ contsSn, menuCd }: ContentsPageProps) { + const initialContent = await getCachedContentDetail(contsSn, menuCd); + + return ; +} + +export { ContentsPage }; diff --git a/apps/dm/components/ui/contents/contentsPageClient.tsx b/apps/dm/components/ui/contents/contentsPageClient.tsx new file mode 100644 index 000000000..97c62bb83 --- /dev/null +++ b/apps/dm/components/ui/contents/contentsPageClient.tsx @@ -0,0 +1,37 @@ +"use client"; + +import { useEffect, useRef } from "react"; +import type { ContentsDetail } from "@/src/entities/content/model/contents-interface"; + +type ContentsPageClientProps = { + initialContent: ContentsDetail; +}; + +function ContentsPageClient({ initialContent }: ContentsPageClientProps) { + const shadowRootRef = useRef(null); + + useEffect(() => { + if (!shadowRootRef.current || !initialContent?.contsCn) return; + + const shadowRoot = + shadowRootRef.current.shadowRoot ?? + shadowRootRef.current.attachShadow({ mode: "open" }); + shadowRoot.innerHTML = ""; + + const styleElement = document.createElement("style"); + styleElement.textContent = `div { font-size: 16px; line-height: 1.5; }`; + shadowRoot.appendChild(styleElement); + + const contentElement = document.createElement("div"); + contentElement.innerHTML = initialContent.contsCn; + shadowRoot.appendChild(contentElement); + }, [initialContent]); + + return ( +
+
+
+ ); +} + +export { ContentsPageClient }; diff --git a/apps/dm/components/ui/contents/index.ts b/apps/dm/components/ui/contents/index.ts new file mode 100644 index 000000000..82a5dad4f --- /dev/null +++ b/apps/dm/components/ui/contents/index.ts @@ -0,0 +1 @@ +export { ContentsPage } from "./contentsPage"; diff --git a/apps/dm/middleware.ts b/apps/dm/middleware.ts index f4f145217..c2a228931 100644 --- a/apps/dm/middleware.ts +++ b/apps/dm/middleware.ts @@ -60,7 +60,7 @@ export function middleware(req: NextRequest) { if (pathname.startsWith("/user-service") || pathname.startsWith("/cms-service")) { - let queryParams = searchParams.toString(); + const queryParams = searchParams.toString(); const requestHeaders = new Headers(req.headers); const newUrl = `${serverApiUrl}${pathname}${queryParams ? `?${queryParams}` : ""}`; diff --git a/apps/dm/next.config.ts b/apps/dm/next.config.ts index 5b5bf85da..bbe67b0ce 100644 --- a/apps/dm/next.config.ts +++ b/apps/dm/next.config.ts @@ -48,6 +48,10 @@ const nextConfig: NextConfig = { source: "/operation-service/:path*", destination: `${subDomain}/operation-service/:path*`, }, + { + source: "/cms-service/:path*", + destination: `${subDomain}/cms-service/:path*`, + }, ], fallback: [], }; diff --git a/apps/dm/src/entities/content/api/contents-service.ts b/apps/dm/src/entities/content/api/contents-service.ts new file mode 100644 index 000000000..292d9b795 --- /dev/null +++ b/apps/dm/src/entities/content/api/contents-service.ts @@ -0,0 +1,101 @@ +import { client } from "@/src/shared/api"; +import { ENDPOINT } from "@/src/shared/config"; +import type { + ContentsService, + ContentsParams, + ContentsList, + ContentsFormData, + ContentsVersionParams, + ContentsVersionList, + ContentsDetail, +} from "../model/contents-interface"; + +export class ContentsApiService implements ContentsService { + public async getContentsList(params: ContentsParams): Promise { + const response = await client.get( + `${ENDPOINT.CMS_SERVICE.CONTENTS}`, + { params }, + ); + return response.data; + } + + public async getLatestApprovedContentsInfo( + contsSn: number, + menuCd: string, + ): Promise { + const response = await client.get( + `${ENDPOINT.CMS_SERVICE.CONTENTS}/${contsSn}`, + { params: { menuCd } }, + ); + return response.data; + } + + public async getContentsVersionList( + params: ContentsVersionParams, + ): Promise { + const response = await client.get( + `${ENDPOINT.CMS_SERVICE.CONTENTS_DETAILS}`, + { params }, + ); + return response.data; + } + + public async getLatestContentsInfo( + contsSn: number, + contsVer: number, + ): Promise { + const response = await client.get( + `${ENDPOINT.CMS_SERVICE.CONTENTS_DETAILS}/${contsSn}/${contsVer}`, + ); + return response.data; + } + + public async createContents(data: ContentsFormData): Promise { + const response = await client.post( + `${ENDPOINT.CMS_SERVICE.CONTENTS_DETAILS}`, + data, + ); + return response.data; + } + + public async updateContents( + contsSn: number, + contsVer: number, + data: ContentsFormData, + ): Promise { + const response = await client.put( + `${ENDPOINT.CMS_SERVICE.CONTENTS_DETAILS}/${contsSn}/${contsVer}`, + data, + ); + return response.data; + } + + public async deleteContents( + contsSn: number, + contsVer: number, + ): Promise { + await client.delete( + `${ENDPOINT.CMS_SERVICE.CONTENTS_DETAILS}/${contsSn}/${contsVer}`, + ); + } + + public async requestApprove( + contsSn: number, + contsVer: number, + ): Promise { + await client.put( + `${ENDPOINT.CMS_SERVICE.CONTENTS_DETAILS}/${contsSn}/${contsVer}/approveRequest`, + ); + } + + public async approveContents( + contsSn: number, + contsVer: number, + ): Promise { + await client.put( + `${ENDPOINT.CMS_SERVICE.CONTENTS_DETAILS}/${contsSn}/${contsVer}/approve`, + ); + } +} + +export const ContentsApi = new ContentsApiService(); diff --git a/apps/dm/src/entities/content/index.ts b/apps/dm/src/entities/content/index.ts new file mode 100644 index 000000000..2446dd716 --- /dev/null +++ b/apps/dm/src/entities/content/index.ts @@ -0,0 +1,8 @@ +export { ContentsApi } from "./api/contents-service"; +export { + CONTENTS_APPROVE_STATUS, + contentsFormSchema, + type Contents, + type ContentsParams, + type ContentsFormData, +} from "./model/contents-interface"; diff --git a/apps/dm/src/entities/content/model/api-interface.ts b/apps/dm/src/entities/content/model/api-interface.ts new file mode 100644 index 000000000..8218bfb0d --- /dev/null +++ b/apps/dm/src/entities/content/model/api-interface.ts @@ -0,0 +1,34 @@ +interface SortConfig { + empty: boolean; + sorted: boolean; + unsorted: boolean; +} + +interface PageableConfig { + sort: SortConfig; + offset: number; + pageNumber: number; + pageSize: number; + paged: boolean; + unpaged: boolean; +} + +export interface PageableList { + content: T[]; + pageable: PageableConfig; + sort: SortConfig; + totalPages: number; + totalElements: number; + number: number; + numberOfElements: number; + size: number; + first: boolean; + last: boolean; + empty: boolean; +} + +export interface PageableParams { + page?: number; + size?: number; + sort?: string; +} diff --git a/apps/dm/src/entities/content/model/contents-interface.ts b/apps/dm/src/entities/content/model/contents-interface.ts new file mode 100644 index 000000000..474dfe97c --- /dev/null +++ b/apps/dm/src/entities/content/model/contents-interface.ts @@ -0,0 +1,105 @@ +import { z } from "zod"; +import type { PageableList, PageableParams } from "./api-interface"; + +export const CONTENTS_APPROVE_STATUS = { + DRAFT: "01", + REQUESTED: "02", + APPROVED: "99", +} as const; + +export type ContentsApproveStatus = + (typeof CONTENTS_APPROVE_STATUS)[keyof typeof CONTENTS_APPROVE_STATUS]; + +export interface ContentsParams extends PageableParams { + contsTtl?: string; + aprvContsVer?: number; + useYn?: string; +} + +export interface ContentsVersionParams extends PageableParams { + contsSn?: number; + contsCn?: string; + aprvCd?: ContentsApproveStatus; + cclCd?: string; + koglCd?: string; + tag?: string; + useYn?: string; +} + +export interface ContentsVersion { + contsSn: number; + contsVer: number; + contsCn: string; + useYn: string; +} + +export const contentsSchema = z.object({ + contsSn: z.number(), + contsTtl: z.string(), + aprvContsVer: z.number(), + useYn: z.string(), + mdfcnDt: z.string(), + mdfr: z.string(), + mdfrNm: z.string(), + aprvDt: z.string(), + autzrNm: z.string(), + workAprvCd: z.string(), + workAprvNm: z.string(), + workContsVer: z.number(), +}); + +export const contentsDetailSchema = z.object({ + contsSn: z.number(), + contsVer: z.number(), + contsTtl: z.string(), + contsCn: z.string(), + contsRmrk: z.string().nullish(), + aprvCd: z.enum([ + CONTENTS_APPROVE_STATUS.DRAFT, + CONTENTS_APPROVE_STATUS.REQUESTED, + CONTENTS_APPROVE_STATUS.APPROVED, + ]), + cclCd: z.string().nullish(), + koglCd: z.string().nullish(), + tag: z.string().nullish(), + aprvDt: z.string(), + autzr: z.string(), + useYn: z.string(), + menuPicUserUnqIdNm: z.string().nullish(), +}); + +export const contentsFormSchema = contentsDetailSchema.omit({ + contsVer: true, + aprvDt: true, + autzr: true, +}); + +export type Contents = z.infer; +export type ContentsDetail = z.infer; +export type ContentsFormData = z.infer; +export type ContentsList = PageableList; +export type ContentsVersionList = PageableList; + +export interface ContentsService { + getContentsList(params: ContentsParams): Promise; + getLatestApprovedContentsInfo( + contsSn: number, + menuCd: string, + ): Promise; + getContentsVersionList( + params: ContentsVersionParams, + ): Promise; + getLatestContentsInfo( + contsSn: number, + contsVer: number, + ): Promise; + createContents(data: ContentsFormData): Promise; + updateContents( + contsSn: number, + contsVer: number, + data: ContentsFormData, + ): Promise; + deleteContents(contsSn: number, contsVer: number): Promise; + requestApprove(contsSn: number, contsVer: number): Promise; + approveContents(contsSn: number, contsVer: number): Promise; +} diff --git a/apps/st/src/components/css/DataGrid.css b/apps/st/src/components/css/DataGrid.css index 190d41a79..a79e3ebd4 100644 --- a/apps/st/src/components/css/DataGrid.css +++ b/apps/st/src/components/css/DataGrid.css @@ -195,7 +195,7 @@ * .rg-grid … 특이도로 덮어씀. */ .rg-grid .rg-header .rg-table .rg-header-group-cell { - background-color: #466995 !important; + background-color: #5987bf !important; color: #ffffff !important; } .rg-grid .rg-header .rg-table { @@ -215,6 +215,8 @@ color: #ffffff !important; } +.rg-header .rg-table tr td:hover { background: #5987bf; } + [data-theme="black"] .rg-grid .rg-header .rg-table .rg-header-group-cell { background-color: #3c4043 !important; color: #ffffff !important; @@ -235,6 +237,11 @@ background-color: #62686d !important; color: #ffffff !important; } +[data-theme="black"] .rg-header .rg-table tr td:hover { + background-color: #62686d !important; + color: #ffffff !important; +} + [data-theme="black"] .rg-grid .rg-body .rg-table .rg-data-row .rg-data-cell { background-color: #545556 !important; color: #ffffff !important; diff --git a/apps/st/src/components/module/ExceptionProcessingHistoryDialog.tsx b/apps/st/src/components/module/ExceptionProcessingHistoryDialog.tsx index 04cd06dc9..483346a6c 100644 --- a/apps/st/src/components/module/ExceptionProcessingHistoryDialog.tsx +++ b/apps/st/src/components/module/ExceptionProcessingHistoryDialog.tsx @@ -565,7 +565,7 @@ export function ExceptionProcessingHistoryDialog({ onClick={handleOpenRejectReasonDialog} disabled={rejectSubmitting} > - 반려 + 일괄 반려
- setPrxyCtrtCn(e.target.value)} - className="!text-[1.3rem]" - placeholder="YYYYMMDD~YYYYMMDD (예: 20240101~20281231)" - autoComplete="off" - disabled={isSubmitting} - /> +
+
+ + +
+
+ + +
+
diff --git a/apps/st/src/components/statistics/input/PublicSewageFacilityInp.tsx b/apps/st/src/components/statistics/input/PublicSewageFacilityInp.tsx index 471547ec5..e8c71de1b 100644 --- a/apps/st/src/components/statistics/input/PublicSewageFacilityInp.tsx +++ b/apps/st/src/components/statistics/input/PublicSewageFacilityInp.tsx @@ -2,7 +2,7 @@ import { useEffect, useRef, useState } from 'react'; import { useConfirm } from '@frontend-opensource/use-react-hooks'; -import { useAlertStore, useGlobalProgressStore } from '@/shared/lib'; +import { useAlertStore, useGlobalProgressStore, useStatisticsInputToolbarGroupGate } from '@/shared/lib'; import { statisticsInputSewSupplyRateApi } from '@/entities/statistics/api/input/statistics-input-sew-supply-rate-service'; import { statisticsInputPublicSewageFacilityApiService } from '@/entities/statistics/api/input/statistics-public-sewage-facility-input-service'; import { statisticsApi } from '@/entities/statistics/api/statistics-service'; @@ -26,7 +26,7 @@ import { LoadingWithAnimation, Tabs, TabsContent, TabsList, TabsTrigger } from ' import { getUserInfo } from '../../../../../../packages/common/components/src/api'; -import { MainFacilityMappingSaveDialog } from './MainFacilityMappingSaveDialog'; +import { MainFacilityMappingSaveDialog, type MainFacilityMappingSaveDialogValues } from './MainFacilityMappingSaveDialog'; import SubmitDailog from './SubmitDailog'; import CancelSubmitButton from '@/components/button/CancelSubmitButton'; @@ -145,6 +145,7 @@ const PublicSewageFacilityInp = () => { const { setMessage: alert } = useAlertStore((state) => state); const { confirm } = useConfirm(); const { isAdminInfo } = useAdminStore(); + const toolbarGroupAllowed = useStatisticsInputToolbarGroupGate(); const showGlobalProgress = useGlobalProgressStore((state) => state.show); const setGlobalProgress = useGlobalProgressStore((state) => state.setProgress); const setGlobalProgressLabel = useGlobalProgressStore((state) => state.setLabel); @@ -415,6 +416,13 @@ const PublicSewageFacilityInp = () => { .filter((sn) => sn !== ''); const wholNopeFromRow = Number(selectedMain.wholNope ?? 0); + const deleteOnly = saveList.length === 0 && deleteList.length > 0; + const nothingToPersist = saveList.length === 0 && deleteList.length === 0; + if (nothingToPersist) { + alert('저장할 변경 사항이 없습니다.'); + return; + } + mainFacilityMappingSaveDraftRef.current = { statsYr: lastSearchedValues.year ?? '', sewerBzmnCd: lastSearchedValues.bsnmCd ?? '', @@ -429,6 +437,16 @@ const PublicSewageFacilityInp = () => { initialWholNope: Number.isFinite(wholNopeFromRow) ? wholNopeFromRow : 0, initialPrxyCtrtCn: String(selectedMain.prxyCtrtCn ?? ''), }; + + if (deleteOnly) { + const draft = mainFacilityMappingSaveDraftRef.current; + const ok = await runMainFacilityMappingSave(draft, null); + if (ok) { + mainFacilityMappingSaveDraftRef.current = null; + } + return; + } + setMainFacilityMappingSaveDialogKey((k) => k + 1); setMainFacilityMappingSaveDialogOpen(true); return; @@ -671,42 +689,40 @@ const PublicSewageFacilityInp = () => { } }; - const handleMainFacilityMappingSaveConfirm = async (input: { - prxyBzentyNmCn: string; - cnsgnCstVl: number; - wholNope: number; - prxyCtrtCn: string; - }) => { - const draft = mainFacilityMappingSaveDraftRef.current; - if (!draft || !searchParamRef.current || !lastSearchedValues) { - setMainFacilityMappingSaveDialogOpen(false); - mainFacilityMappingSaveDraftRef.current = null; - return; + const runMainFacilityMappingSave = async ( + draft: MainFacilityMappingSaveDraft, + dialogInput: MainFacilityMappingSaveDialogValues | null, + ): Promise => { + if (!searchParamRef.current || !lastSearchedValues) { + return false; } try { showGlobalProgress({ label: '저장 진행 중...', progress: 0 }); const userInfo = await getUserInfo(); - const param: PublicSewageMainFacilityMappingSaveRequest = { + const base = { statsYr: draft.statsYr, sewerBzmnCd: draft.sewerBzmnCd, admdstCd: draft.admdstCd, admdstNm: draft.admdstNm, mainFcltSn: draft.mainFcltSn, mainFcltNm: draft.mainFcltNm, - prxyBzentyNmCn: input.prxyBzentyNmCn, - cnsgnCstVl: input.cnsgnCstVl, - wholNope: input.wholNope, - prxyCtrtCn: input.prxyCtrtCn, userId: userInfo.userUnqId, saveList: draft.saveList, deleteList: draft.deleteList, }; + const param: PublicSewageMainFacilityMappingSaveRequest = dialogInput + ? { + ...base, + prxyBzentyNmCn: dialogInput.prxyBzentyNmCn, + cnsgnCstVl: dialogInput.cnsgnCstVl, + wholNope: dialogInput.wholNope, + prxyCtrtCn: dialogInput.prxyCtrtCn, + } + : { ...base }; const response = await statisticsInputPublicSewageFacilityApiService.saveMainFacilityMapping(param); if (response.status === 'success') { setGlobalProgress(50); - setMainFacilityMappingSaveDialogOpen(false); - mainFacilityMappingSaveDraftRef.current = null; await loadPublicSewageFacilityActiveTabData( 'ST00060002', searchParamRef.current, @@ -714,16 +730,32 @@ const PublicSewageFacilityInp = () => { ); setGlobalProgress(100); alert('저장되었습니다.'); - } else { - alert(response.message || '데이터 저장 중 오류가 발생했습니다.'); + return true; } + alert(response.message || '데이터 저장 중 오류가 발생했습니다.'); + return false; } catch { alert('데이터 저장 중 오류가 발생했습니다.'); + return false; } finally { hideGlobalProgress(); } }; + const handleMainFacilityMappingSaveConfirm = async (input: MainFacilityMappingSaveDialogValues) => { + const draft = mainFacilityMappingSaveDraftRef.current; + if (!draft || !searchParamRef.current || !lastSearchedValues) { + setMainFacilityMappingSaveDialogOpen(false); + mainFacilityMappingSaveDraftRef.current = null; + return; + } + const ok = await runMainFacilityMappingSave(draft, input); + if (ok) { + setMainFacilityMappingSaveDialogOpen(false); + mainFacilityMappingSaveDraftRef.current = null; + } + }; + const handleSearch = async (values: SearchFormValues) => { if (!values.sido || values.sido === 'ALL') { alert('시도를 선택해주세요.'); @@ -928,7 +960,14 @@ const PublicSewageFacilityInp = () => { inputPeriodEnterable, isAdminInfo, ); - const { showSaveInToolbar, showEnterableToolbarButtons, showActionToolbarCard } = activeTabToolbar; + const { + showSaveInToolbar: baseShowSaveInToolbar, + showEnterableToolbarButtons: baseShowEnterableToolbarButtons, + showActionToolbarCard: baseShowActionToolbarCard, + } = activeTabToolbar; + const showSaveInToolbar = baseShowSaveInToolbar && toolbarGroupAllowed; + const showEnterableToolbarButtons = baseShowEnterableToolbarButtons && toolbarGroupAllowed; + const showActionToolbarCard = baseShowActionToolbarCard && toolbarGroupAllowed; const isMainFacilityTab = activeTab === 'ST00060002'; const openVerificationHistoryDialog = () => { diff --git a/apps/st/src/components/statistics/input/SewagePipeInp.tsx b/apps/st/src/components/statistics/input/SewagePipeInp.tsx index 34314a12b..963be10fb 100644 --- a/apps/st/src/components/statistics/input/SewagePipeInp.tsx +++ b/apps/st/src/components/statistics/input/SewagePipeInp.tsx @@ -2,7 +2,7 @@ import { useEffect, useLayoutEffect,useMemo, useRef, useState } from "react"; import { useConfirm } from "@frontend-opensource/use-react-hooks"; -import { useAlertStore, useGlobalProgressStore } from "@/shared/lib"; +import { useAlertStore, useGlobalProgressStore, useStatisticsInputToolbarGroupGate } from "@/shared/lib"; import { STATISTICS_INPUT_URL_PATH, verificationSearchToFormValues, @@ -375,6 +375,7 @@ const SewagePipeInp = () => { const hideGlobalProgress = useGlobalProgressStore((state) => state.hide); const globalProgressVisible = useGlobalProgressStore((state) => state.visible); const { isAdminInfo } = useAdminStore(); + const toolbarGroupAllowed = useStatisticsInputToolbarGroupGate(); const getGridDataRef = useRef<(() => Record[]) | null>(null); const getFactInstallGridDataRef = useRef<(() => Record[]) | null>(null); @@ -1783,10 +1784,12 @@ const SewagePipeInp = () => { showEnterableToolbarButtons: baseShowEnterableToolbarButtons, showActionToolbarCard: baseShowActionToolbarCard, } = activeTabToolbar; - const showSaveInToolbar = !hideToolbarForConnectionTab && baseShowSaveInToolbar; + const showSaveInToolbar = + !hideToolbarForConnectionTab && baseShowSaveInToolbar && toolbarGroupAllowed; const showEnterableToolbarButtons = - !hideToolbarForConnectionTab && baseShowEnterableToolbarButtons; - const showActionToolbarCard = !hideToolbarForConnectionTab && baseShowActionToolbarCard; + !hideToolbarForConnectionTab && baseShowEnterableToolbarButtons && toolbarGroupAllowed; + const showActionToolbarCard = + !hideToolbarForConnectionTab && baseShowActionToolbarCard && toolbarGroupAllowed; return ( <> diff --git a/apps/st/src/components/statistics/input/SeweragePenetrationRateInp.tsx b/apps/st/src/components/statistics/input/SeweragePenetrationRateInp.tsx index 928e4b2ca..22b160656 100644 --- a/apps/st/src/components/statistics/input/SeweragePenetrationRateInp.tsx +++ b/apps/st/src/components/statistics/input/SeweragePenetrationRateInp.tsx @@ -2,7 +2,7 @@ import { useEffect, useLayoutEffect, useMemo, useRef, useState } from "react"; import { useConfirm } from "@frontend-opensource/use-react-hooks"; -import { useAlertStore, useGlobalProgressStore } from "@/shared/lib"; +import { useAlertStore, useGlobalProgressStore, useStatisticsInputToolbarGroupGate } from "@/shared/lib"; import { STATISTICS_INPUT_URL_PATH, verificationSearchToFormValues, @@ -192,6 +192,7 @@ const SeweragePenetrationRateInp = () => { }, [activeTabId]); const { isAdminInfo } = useAdminStore(); + const toolbarGroupAllowed = useStatisticsInputToolbarGroupGate(); const getGridDataRef = useRef<(() => Record[]) | null>(null); @@ -710,12 +711,15 @@ const SeweragePenetrationRateInp = () => { ? Boolean(lastSearchedValues?.gugun && lastSearchedValues.gugun !== 'ALL') : true; - const showSaveInToolbar = (isEnterable || isAdminInfo) && gugunOkOrNotDoList; - const showEnterableToolbarButtons = isEnterable && gugunOkOrNotDoList; + const baseShowSaveInToolbar = (isEnterable || isAdminInfo) && gugunOkOrNotDoList; + const baseShowEnterableToolbarButtons = isEnterable && gugunOkOrNotDoList; + const showSaveInToolbar = baseShowSaveInToolbar && toolbarGroupAllowed; + const showEnterableToolbarButtons = baseShowEnterableToolbarButtons && toolbarGroupAllowed; /** 카드는 저장/제출줄/제출취소 중 하나라도 보일 때만 (관리자만으로 빈 카드 방지) */ const showActionToolbarCard = rowData.length > 0 && - (showSaveInToolbar || + toolbarGroupAllowed && + (baseShowSaveInToolbar || (statusInfo?.laeSbmsnSttsCd === '2' && (isAdminInfo || (isEnterable && gugunOkOrNotDoList)))); return ( diff --git a/apps/st/src/components/statistics/input/StatsInputStatusInp.tsx b/apps/st/src/components/statistics/input/StatsInputStatusInp.tsx index af78598e7..db04bc957 100644 --- a/apps/st/src/components/statistics/input/StatsInputStatusInp.tsx +++ b/apps/st/src/components/statistics/input/StatsInputStatusInp.tsx @@ -1,9 +1,10 @@ -import { useCallback, useEffect, useRef, useState } from "react"; +import { useCallback, useEffect, useMemo, useRef, useState } from "react"; +import { useConfirm } from "@frontend-opensource/use-react-hooks"; import { RealGridReact } from "realgrid-react"; import useSWR, { useSWRConfig } from "swr"; -import { useAlertStore } from "@/shared/lib"; +import { useAlertStore, useStatisticsInputStatusConfirmGroupGate } from "@/shared/lib"; import { statisticsInputSewSupplyRateApi } from "@/entities/statistics/api/input/statistics-input-sew-supply-rate-service"; import { StatisticsInputStatusApi } from "@/entities/statistics/api/input/statistics-input-status-service"; import { statisticsApi } from "@/entities/statistics/api/statistics-service"; @@ -17,8 +18,8 @@ import type { StatsInputStatusListRequestParams, } from "@/entities/statistics/model/input/statistics-input-status-interface"; import { getUserInfo } from "../../../../../../packages/common/components/src/api"; -import { LoadingWithAnimation } from "@common/components"; +import { LoadingWithAnimation } from "@common/components"; import ApproveButton from "@/components/button/ApproveButton"; import DataGrid from "@/components/module/DataGrid"; import type { DataCellClickCallbackParams } from "@/components/type/DataGrid.type"; @@ -41,7 +42,9 @@ const getStatsInputStatusFetcher = async ( // 하수도 통계 관리 > 00. 통계 입력 현황 const StatsInputStatusInp = () => { + const { setMessage: alert } = useAlertStore((state) => state); + const { confirm } = useConfirm(); const activeTabId = useTabStore((state) => state.activeTabId); const [rowData, setRowData] = useState[]>( [], @@ -58,6 +61,8 @@ const StatsInputStatusInp = () => { const [exceptionHistoryContext, setExceptionHistoryContext] = useState< ExceptionProcessingHistoryQueryContext | null >(null); + const [isApproveLoading, setIsApproveLoading] = useState(false); + const confirmGroupToolbarAllowed = useStatisticsInputStatusConfirmGroupGate(); const swrKey = searchParams ? (["stats-input-status", searchParams] as const) @@ -140,6 +145,7 @@ const StatsInputStatusInp = () => { inputPeriodCurrentRef.current = false; setExceptionHistoryOpen(false); setExceptionHistoryContext(null); + setIsApproveLoading(false); }; const approveFunc = async () => { @@ -152,15 +158,20 @@ const StatsInputStatusInp = () => { return; } - // const hasInvalidStatus = rowData.some((row) => - // ["0", "1", "4"].includes(String(row.laeSbmsnSttsCd ?? "")), - // ); + const hasInvalidStatus = rowData.some((row) => + ["0", "1", "4"].includes(String(row.laeSbmsnSttsCd ?? "")), + ); - // if (hasInvalidStatus) { - // alert("미작성이나 미제출, 혹은 보완요청(반려) 상태인 항목이 있습니다."); - // return; - // } + if (hasInvalidStatus) { + alert("미작성이나 미제출, 혹은 보완요청(반려) 상태인 항목이 있습니다."); + return; + } + if (!(await confirm("조회된 통계 항목을 승인하시겠습니까?"))) { + return; + } + + setIsApproveLoading(true); try { const userInfo = await getUserInfo(); const mdfrId = (userInfo.userUnqId ?? "").trim(); @@ -186,7 +197,7 @@ const StatsInputStatusInp = () => { } const response = await statisticsApi.approveStatsInputStatus({ list: approveItems }, "INPUT"); - if (response.status === "success") { + if (response.resultCode.toLocaleLowerCase() === "success") { if (searchParams) { await mutate(["stats-input-status", searchParams]); } @@ -196,6 +207,8 @@ const StatsInputStatusInp = () => { } } catch { alert("승인 처리 중 오류가 발생했습니다."); + } finally { + setIsApproveLoading(false); } }; @@ -239,8 +252,21 @@ const StatsInputStatusInp = () => { const gugunOkOrNotDoList = doList.includes(lastSearchedValues?.bsnmCd || "") ? Boolean(lastSearchedValues?.gugun && lastSearchedValues.gugun !== "ALL") : true; + + const allRowsLaeSbmsnApproved = useMemo( + () => + rowData.length > 0 && + rowData.every((row) => String(row.laeSbmsnSttsCd ?? "") === "3"), + [rowData], + ); + const showActionToolbarCard = - hasCompletedSearch && rowData.length > 0 && gugunOkOrNotDoList && inputPeriodCurrentRef.current; + hasCompletedSearch && + rowData.length > 0 && + gugunOkOrNotDoList && + inputPeriodCurrentRef.current && + !allRowsLaeSbmsnApproved && + confirmGroupToolbarAllowed; return ( <> @@ -253,7 +279,7 @@ const StatsInputStatusInp = () => { )} - {isLoading && ( + {(isLoading || isApproveLoading) && (
diff --git a/apps/st/src/components/statistics/management/StatisticsInputManagement.tsx b/apps/st/src/components/statistics/management/StatisticsInputManagement.tsx index 2ef6b312e..cb63324eb 100644 --- a/apps/st/src/components/statistics/management/StatisticsInputManagement.tsx +++ b/apps/st/src/components/statistics/management/StatisticsInputManagement.tsx @@ -1,4 +1,4 @@ -import { useRef, useState } from "react"; +import { useCallback, useRef, useState } from "react"; import { useAlertStore } from "@/shared/lib"; import { statisticsApi } from "@/entities/statistics/api/statistics-service"; @@ -15,7 +15,7 @@ import { import DataGrid from "@/components/module/DataGrid"; import SearchForm from "@/components/module/SearchForm"; -import type { ColumnConfig } from "@/components/type/DataGrid.type"; +import type { ColumnConfig, DataCellClickCallbackParams } from "@/components/type/DataGrid.type"; import type { SearchFormValues } from "@/components/type/SearchForm.type"; import { toGridRows } from "@/components/util/grid/gridFunction"; @@ -53,6 +53,8 @@ function formatYmdForDisplay(ymd: string): string { return `${s.slice(0, 4)}-${s.slice(4, 6)}-${s.slice(6, 8)}`; } +const PERIOD_DETAIL_FIELD = "detailInfo"; + const StatisticsInputManagement = () => { const { setMessage: alert } = useAlertStore((state) => state); const [isLoading, setIsLoading] = useState(false); @@ -88,6 +90,7 @@ const StatisticsInputManagement = () => { seType: seTypeToDisplayLabel(row.seType), inptBgngYmd: formatYmdForDisplay(row.inptBgngYmd), inptEndYmd: formatYmdForDisplay(row.inptEndYmd), + [PERIOD_DETAIL_FIELD]: "상세정보", })); setRowData(toGridRows(rowsForGrid)); } else { @@ -119,48 +122,59 @@ const StatisticsInputManagement = () => { setPeriodDialogOpen(true); }; - const handleRowClick = async (row: Record) => { - const seCdRaw = row.seCd; - const seCdNum = - typeof seCdRaw === "number" - ? seCdRaw - : parseInt(String(seCdRaw ?? ""), 10); - const seCd = Number.isFinite(seCdNum) ? seCdNum : 0; - const statsYr = String(row.statsYr ?? ""); - const seType = rowSeTypeToCode(row); + const openPeriodDetailForRow = useCallback( + async (row: Record) => { + const seCdRaw = row.seCd; + const seCdNum = + typeof seCdRaw === "number" + ? seCdRaw + : parseInt(String(seCdRaw ?? ""), 10); + const seCd = Number.isFinite(seCdNum) ? seCdNum : 0; + const statsYr = String(row.statsYr ?? ""); + const seType = rowSeTypeToCode(row); - try { - setIsLoading(true); - const res = await statisticsApi.getStatsInputPeriodDetail({ - statsYr, - seType, - seCd, - }); + try { + setIsLoading(true); + const res = await statisticsApi.getStatsInputPeriodDetail({ + statsYr, + seType, + seCd, + }); - if (res.status !== "success" || !res.data) { - alert(res.message || "상세 정보를 조회하지 못했습니다."); - return; + if (res.status !== "success" || !res.data) { + alert(res.message || "상세 정보를 조회하지 못했습니다."); + return; + } + + const detail = res.data; + setPeriodDialogMode("edit"); + setPeriodDialogInitial({ + statsYr: String(detail.statsYr ?? statsYr), + seType: detail.seType === "VRFC" ? "VRFC" : "INPT", + seCd: + typeof detail.seCd === "number" && Number.isFinite(detail.seCd) + ? detail.seCd + : seCd, + inptBgngYmd: parseYmdToDate(detail.inptBgngYmd), + inptEndYmd: parseYmdToDate(detail.inptEndYmd), + }); + setPeriodDialogOpen(true); + } catch { + alert("상세 정보 조회 중 오류가 발생했습니다."); + } finally { + setIsLoading(false); } + }, + [alert], + ); - const detail = res.data; - setPeriodDialogMode("edit"); - setPeriodDialogInitial({ - statsYr: String(detail.statsYr ?? statsYr), - seType: detail.seType === "VRFC" ? "VRFC" : "INPT", - seCd: - typeof detail.seCd === "number" && Number.isFinite(detail.seCd) - ? detail.seCd - : seCd, - inptBgngYmd: parseYmdToDate(detail.inptBgngYmd), - inptEndYmd: parseYmdToDate(detail.inptEndYmd), - }); - setPeriodDialogOpen(true); - } catch { - alert("상세 정보 조회 중 오류가 발생했습니다."); - } finally { - setIsLoading(false); - } - }; + const handlePeriodDetailCellClick = useCallback( + ({ fieldName, row }: DataCellClickCallbackParams) => { + if (fieldName !== PERIOD_DETAIL_FIELD) return; + void openPeriodDetailForRow(row); + }, + [openPeriodDetailForRow], + ); const columns: ColumnConfig[] = [ { @@ -193,6 +207,14 @@ const StatisticsInputManagement = () => { header: { text: "종료일" }, editable: false, }, + { + name: PERIOD_DETAIL_FIELD, + fieldName: PERIOD_DETAIL_FIELD, + header: { text: "상세정보" }, + editable: false, + width: 118, + styleName: "rg-cell-outline-action-btn", + }, ]; const columnLayout = [ @@ -201,6 +223,7 @@ const StatisticsInputManagement = () => { "seCd", "inptBgngYmd", "inptEndYmd", + PERIOD_DETAIL_FIELD, ]; return ( @@ -232,7 +255,7 @@ const StatisticsInputManagement = () => { setRowData={setRowData} isExcelImport={false} isExcelExport={false} - onRowClick={handleRowClick} + onDataCellClick={handlePeriodDetailCellClick} /> { + const { setMessage: alert } = useAlertStore((state) => state); const [rowData, setRowData] = useState[]>([]); + const [isLoading, setIsLoading] = useState(false); const columns: ColumnConfig[] = [ - { name: 'sido', fieldName: '시도', header: { text: '시도' }, mergeCells: true, editable: false, sortdisable: true }, - { name: 'sgg', fieldName: '시군구', header: { text: '시군구' }, mergeCells: true, editable: false, sortdisable: true }, - { name: '구분', fieldName: '구분', header: { text: '구분' }, editable: false, sortdisable: true }, - { name: '광역자치단체', fieldName: '광역자치단체', header: { text: '광역자치단체' }, editable: false, sortdisable: true }, - { name: '공단', fieldName: '공단', header: { text: '공단' }, editable: false, sortdisable: true }, - { name: '처리내역', fieldName: '처리내역', header: { text: '처리내역' }, editable: false, sortdisable: true }, + { name: "statsYr", fieldName: "statsYr", header: { text: "연도" }, editable: false }, + { name: "ctpvNm", fieldName: "ctpvNm", header: { text: "시도" }, editable: false }, + { name: "sggNm", fieldName: "sggNm", header: { text: "시군구" }, editable: false }, + { name: "sewerBzmnCd", fieldName: "sewerBzmnCd", header: { text: "하수사업자코드" }, editable: false }, + { name: "statsCd", fieldName: "statsCd", header: { text: "통계코드" }, editable: false }, + { name: "menuNm", fieldName: "menuNm", header: { text: "통계항목" }, editable: false }, + { name: "chckerOpinionSm", fieldName: "chckerOpinionSm", header: { text: "검토자의견" }, editable: false }, + { name: "wrterOpinionSm", fieldName: "wrterOpinionSm", header: { text: "처리내역" }, editable: false }, ]; const columnLayout = [ - 'sido', - 'sgg', - '구분', - { - name: 'groupA', - hideChildHeaders: false, - header: { text: '검토내역' }, - items: ['광역자치단체', '공단', '처리내역'], - }, + "statsYr", + "ctpvNm", + "sggNm", + "sewerBzmnCd", + "statsCd", + "menuNm", + "chckerOpinionSm", + "wrterOpinionSm", ]; + const handleSearch = async (values: SearchFormValues) => { + if (!values.year?.trim()) { + alert("년도를 선택해주세요."); + return; + } + + try { + setIsLoading(true); + const res = await statisticsApi.getStatiscicsManagementReviewHistory({ + statsYr: values.year.trim(), + ...(values.bsnmCd?.trim() ? { sewerBzmnCd: values.bsnmCd.trim() } : {}), + ...(values.sido && values.sido !== "ALL" ? { ctpvNm: values.sido } : {}), + }); + + if (res.status === "success" && Array.isArray(res.data)) { + setRowData(toGridRows(res.data)); + } else { + setRowData([]); + alert(res.message || "조회된 데이터가 없습니다."); + } + } catch { + setRowData([]); + alert("데이터 조회 중 오류가 발생했습니다."); + } finally { + setIsLoading(false); + } + }; + + const handleReset = () => { + setRowData([]); + }; + return ( - <> - - - +
+ + {isLoading && ( +
+
+ +
+
+ )} + +
); }; -export default StatisticsReviewHistory; \ No newline at end of file +export default StatisticsReviewHistory; diff --git a/apps/st/src/components/statistics/review/StatsInputStatusRvw.tsx b/apps/st/src/components/statistics/review/StatsInputStatusRvw.tsx index 73258a130..8241d9bcd 100644 --- a/apps/st/src/components/statistics/review/StatsInputStatusRvw.tsx +++ b/apps/st/src/components/statistics/review/StatsInputStatusRvw.tsx @@ -1,4 +1,5 @@ import { useCallback, useMemo, useState } from "react"; +import { useConfirm } from "@frontend-opensource/use-react-hooks"; import { createColumnHelper, getCoreRowModel, useReactTable } from "@tanstack/react-table"; import DataGrid from "@/components/module/DataGrid"; @@ -41,7 +42,7 @@ const checkSubmitStatusCd = (code: string) => { }; const isStatsInputStatusRowApprovable = (row: Record) => - String(row.submitStatusCd ?? "") === "3" && String(row.prvnStatusCd ?? "") === "2"; + String(row.submitStatusCd ?? "") === "3" && (String(row.prvnStatusCd ?? "") === "2" || String(row.prvnStatusCd ?? "") === "3"); const mapSewerageListToGridRows = (rawList: StatsInputStatusReviewItem[]) => rawList.map((r) => ({ @@ -54,9 +55,11 @@ const mapSewerageListToGridRows = (rawList: StatsInputStatusReviewItem[]) => // 하수도 통계 검토 00. 통계 입력 현황 const StatsInputStatusRvw = () => { const { setMessage: alert } = useAlertStore((state) => state); + const { confirm } = useConfirm(); const [rowData, setRowData] = useState[]>([]); const [historyRowData, setHistoryRowData] = useState([]); const [isLoading, setIsLoading] = useState(false); + const [isApproveLoading, setIsApproveLoading] = useState(false); const [searchedYear, setSearchedYear] = useState(""); const [lastSearchedValues, setLastSearchedValues] = useState(null); const [hasCompletedSearch, setHasCompletedSearch] = useState(false); @@ -108,6 +111,7 @@ const StatsInputStatusRvw = () => { setSearchedYear(""); setLastSearchedValues(null); setHasCompletedSearch(false); + setIsApproveLoading(false); }; const handleSearch = async (values: SearchFormValues) => { @@ -158,11 +162,16 @@ const StatsInputStatusRvw = () => { return; } - // if (!rowData.every(isStatsInputStatusRowApprovable)) { - // alert("지자체 진행상태가 승인완료이고 진행상태가 제출완료인 항목만 승인할 수 있습니다."); - // return; - // } + if (!rowData.every(isStatsInputStatusRowApprovable)) { + alert("지자체 진행상태가 승인완료이고 진행상태가 제출완료인 항목만 승인할 수 있습니다."); + return; + } + if (!(await confirm("조회된 통계 항목을 승인하시겠습니까?"))) { + return; + } + + setIsApproveLoading(true); try { const userInfo = await getUserInfo(); const mdfrId = (userInfo.userUnqId ?? "").trim(); @@ -188,7 +197,7 @@ const StatsInputStatusRvw = () => { } const response = await statisticsApi.approveStatsInputStatus({ list: approveItems }, "REVIEW"); - if (response.status === "success") { + if (response.resultCode.toLocaleLowerCase() === "success") { try { const listResponse = await statisticsManagementApi.getStatsInputStatusList({ ctpvNm: lastSearchedValues.sido, @@ -209,6 +218,8 @@ const StatsInputStatusRvw = () => { } } catch { alert("승인 처리 중 오류가 발생했습니다."); + } finally { + setIsApproveLoading(false); } }; @@ -223,11 +234,20 @@ const StatsInputStatusRvw = () => { [rowData], ); + /** 진행상태(prvn) 코드가 모두 승인완료(3) */ + const allPrvnStatusApproved = useMemo( + () => + rowData.length > 0 && + rowData.every((row) => String(row.prvnStatusCd ?? "") === "3"), + [rowData], + ); + const showActionToolbarCard = hasCompletedSearch && rowData.length > 0 && gugunOkOrNotDoList && - !allSubmitStatusApproved; + allSubmitStatusApproved && + !allPrvnStatusApproved; const openReviewHistoryForRow = useCallback( async (row: Record) => { @@ -282,7 +302,7 @@ const StatsInputStatusRvw = () => {
)} - {isLoading && ( + {(isLoading || isApproveLoading) && (
diff --git a/apps/st/src/components/statistics/verification/StatisticsVerificationReport.tsx b/apps/st/src/components/statistics/verification/StatisticsVerificationReport.tsx index 36274ddcd..3e537c987 100644 --- a/apps/st/src/components/statistics/verification/StatisticsVerificationReport.tsx +++ b/apps/st/src/components/statistics/verification/StatisticsVerificationReport.tsx @@ -131,12 +131,12 @@ const DEFAULT_FILTERS: StatisticsVerificationFilterState = { statsYr: String(dayjs().year() - 1), sewerBzmnCd: null, statsCd: null, - laeSbmsnSttsCd: "2", + laeSbmsnSttsCd: "3", }; const DEFAULT_SUB_FILTERS: StatisticsVerificationSubFilterState = { ...DEFAULT_FILTERS, - prcsYnList: "1,2,3", + prcsYnList: "1,2", pbcpAprvYn: "N", }; @@ -468,8 +468,6 @@ function StatisticsVerificationReport() { async function handleReject() { const commonParam = { statsYr: filters.statsYr, - sewerBzmnCd: filters.sewerBzmnCd ?? "", - statsCd: filters.statsCd ?? "", mdfrId: userInfo?.userUnqId ?? "", }; @@ -496,6 +494,8 @@ function StatisticsVerificationReport() { ...commonParam, vrfcSn: Number.isNaN(vrfcSn) ? 0 : vrfcSn, rjctOpnnCn: String(row.rjctOpnnCn ?? ""), + sewerBzmnCd: String(row.sewerBzmnCd ?? ""), + statsCd: String(row.statsCd ?? ""), }; }); diff --git a/apps/st/src/components/util/column/total/SewagePipeTotColumn.ts b/apps/st/src/components/util/column/total/SewagePipeTotColumn.ts index a576c5caa..d0100b790 100644 --- a/apps/st/src/components/util/column/total/SewagePipeTotColumn.ts +++ b/apps/st/src/components/util/column/total/SewagePipeTotColumn.ts @@ -1,5 +1,7 @@ import { ValueType } from "realgrid"; +import type { ColumnInfoList } from "@/components/type/DataGrid.type"; + /** * 하수도 통계 관리 > 02. 하수관로 (합계) * 항목은 검토(SewagePipeRvwColumn)와 동일하나 footer·mergeCells·구분(displayYear)·비고(rmrk) 제외. @@ -976,16 +978,18 @@ export const getST00020003Config = (statsYr: string | number) => { }; }; -export const SewagePipeColumnInfoListTot = rawSewagePipeTotList.map((tab) => ({ +export const SewagePipeColumnInfoListTot: ColumnInfoList[] = rawSewagePipeTotList.map((tab) => ({ ...tab, columns: tab.columns.map((col) => ({ ...col, editable: false as const })), })); -SewagePipeColumnInfoListTot.splice(2, 0, { +const st00020003PlaceholderTab: ColumnInfoList = { tabKey: "ST00020003", tabLabel: "관종별 현황", rowStartNum: 2, fixedColumns: 2, - columns: [], - columnLayout: [], -} as any); \ No newline at end of file + columns: [], + columnLayout: [], +}; + +SewagePipeColumnInfoListTot.splice(2, 0, st00020003PlaceholderTab); \ No newline at end of file diff --git a/apps/st/src/entities/group-user/api/group-user-service.ts b/apps/st/src/entities/group-user/api/group-user-service.ts new file mode 100644 index 000000000..85355a88f --- /dev/null +++ b/apps/st/src/entities/group-user/api/group-user-service.ts @@ -0,0 +1,72 @@ +import { client } from "@/shared/api"; +import { ENDPOINT } from "@/shared/config"; +import type { + GroupUserService, + GroupUserSearchParams, + GroupUserListResponseDto, + GroupUserDetailResponse, + PageGroupUserList, + GroupUserSaveData, + GroupUserUpdateData, +} from "../model/group-user-interface"; + +export class GroupUsersApiService implements GroupUserService { + // 그룹 사용자 목록 조회 + public async findPageGroupUser( + params: GroupUserSearchParams, + ): Promise { + const response = await client.get( + `${ENDPOINT.CMS_SERVICE.GROUP_USERS}`, + { params }, + ); + return response.data; + } + + // 그룹 사용자 단건 조회 + public async findByGroupAndUserId( + grpSn: number, + userUnqId: string, + ): Promise { + const response = await client.get( + `${ENDPOINT.CMS_SERVICE.GROUP_USERS}/${grpSn}/${userUnqId}`, + ); + return response.data; + } + + // 그룹 사용자 저장 + public async saveGroupUser( + data: GroupUserSaveData[], + ): Promise { + const response = await client.post( + `${ENDPOINT.CMS_SERVICE.GROUP_USERS}`, + data, + ); + return response.data; + } + + // 그룹 사용자 수정 + public async updateGroupUser( + grpSn: number, + userUnqId: string, + data: GroupUserUpdateData, + ): Promise { + const response = await client.put( + `${ENDPOINT.CMS_SERVICE.GROUP_USERS}/${grpSn}/${userUnqId}`, + data, + ); + return response.data; + } + + // 그룹 사용자 삭제 + public async deleteGroupUser( + groupId: string, + userUnqId: string, + ): Promise { + const response = await client.delete( + `${ENDPOINT.CMS_SERVICE.GROUP_USERS}/${groupId}/${userUnqId}`, + ); + return response.data; + } +} + +export const GroupUsersApi = new GroupUsersApiService(); diff --git a/apps/st/src/entities/group-user/index.ts b/apps/st/src/entities/group-user/index.ts new file mode 100644 index 000000000..349194a90 --- /dev/null +++ b/apps/st/src/entities/group-user/index.ts @@ -0,0 +1,5 @@ +export { GroupUsersApi } from "./api/group-user-service"; +export { + type GroupUserListResponseDto, + type GroupUserSaveData, +} from "./model/group-user-interface"; diff --git a/apps/st/src/entities/group-user/lib/check-statistics-input-toolbar-group-membership.ts b/apps/st/src/entities/group-user/lib/check-statistics-input-toolbar-group-membership.ts new file mode 100644 index 000000000..647442b2c --- /dev/null +++ b/apps/st/src/entities/group-user/lib/check-statistics-input-toolbar-group-membership.ts @@ -0,0 +1,55 @@ +import { GroupUsersApi } from "../api/group-user-service"; + +export const GRP_ST_INPUT = "GRP_ST_INPUT"; +export const GRP_ST_CONFIRM = "GRP_ST_CONFIRM"; +export const GRP_ADMIN = "GRP_ADMIN"; + +const PAGE_SIZE = 500; + +/** + * 지정한 그룹(`keyword`)마다 `findPageGroupUser`를 호출하고, + * 응답 목록이 비어 있지 않으며 로그인 사용자 `userUnqId`와 일치하는 행이 있으면 true. + */ +export async function checkPageGroupUserMembershipForUser( + userName: string, + userUnqId: string, + groupKeywords: readonly string[], +): Promise { + const uid = userUnqId.trim(); + const nm = userName.trim(); + if (!uid || !nm) return false; + + const paramsBase = { userNm: nm, page: 0, size: PAGE_SIZE }; + + for (const keyword of groupKeywords) { + const page = await GroupUsersApi.findPageGroupUser({ + ...paramsBase, + keyword, + }); + const rows = page.content ?? []; + if (rows.length > 0 && rows.some((r) => String(r.userUnqId ?? "").trim() === uid)) { + return true; + } + } + return false; +} + +/** + * 통계 입력 화면 상단 액션 — `GRP_ST_INPUT` / `GRP_ADMIN`. + */ +export async function checkStatisticsInputToolbarGroupMembership( + userName: string, + userUnqId: string, +): Promise { + return checkPageGroupUserMembershipForUser(userName, userUnqId, [GRP_ST_INPUT, GRP_ADMIN]); +} + +/** + * 통계 입력 현황 승인 등 — `GRP_ST_CONFIRM` / `GRP_ADMIN`. + */ +export async function checkStatisticsInputStatusConfirmGroupMembership( + userName: string, + userUnqId: string, +): Promise { + return checkPageGroupUserMembershipForUser(userName, userUnqId, [GRP_ST_CONFIRM, GRP_ADMIN]); +} diff --git a/apps/st/src/entities/group-user/model/group-user-interface.ts b/apps/st/src/entities/group-user/model/group-user-interface.ts new file mode 100644 index 000000000..52fa97fce --- /dev/null +++ b/apps/st/src/entities/group-user/model/group-user-interface.ts @@ -0,0 +1,79 @@ +import type { PageableList, PageableParams } from "@/shared/model"; + +// 그룹 사용자 검색 파라미터 인터페이스 +export interface GroupUserSearchParams extends PageableParams { + keyword?: string; + userNm?: string; +} + +// 그룹 사용자 목록 응답 인터페이스 +export interface GroupUserListResponseDto { + mode: string; + groupId: string; + userUnqId: string; + userId: string; + userName: string; + ogdpSeCd: string; + ogdpSeNm: string; + mphn: string; +} + +// 그룹 사용자 페이지 응답 인터페이스 +export type PageGroupUserList = PageableList; + +// 그룹 사용자 상세 응답 인터페이스 +export interface GroupUserDetailResponse { + grpSn: number; + userUnqId: string; + userId: string; + userName: string; + email: string; + mphn: string; + regDt: string; + rgtr: string; + mdfcnDt: string; + mdfr: string; + rgtrNm: string; + mdfrNm: string; +} + +// 그룹 사용자 저장 데이터 인터페이스 +export interface GroupUserSaveData { + mode: string; + groupId: string; + userUnqId: string; +} + +// 그룹 사용자 수정 데이터 인터페이스 +export interface GroupUserUpdateData { + userUnqId: string; +} + +// 그룹 사용자 서비스 인터페이스 +export interface GroupUserService { + // 그룹 사용자 목록 조회 + findPageGroupUser: ( + params: GroupUserSearchParams, + ) => Promise; + + // 그룹 사용자 단건 조회 + findByGroupAndUserId: ( + grpSn: number, + userUnqId: string, + ) => Promise; + + // 그룹 사용자 저장 + saveGroupUser: ( + data: GroupUserSaveData[], + ) => Promise; + + // 그룹 사용자 수정 + updateGroupUser: ( + grpSn: number, + userUnqId: string, + data: GroupUserUpdateData, + ) => Promise; + + // 그룹 사용자 삭제 + deleteGroupUser: (groupId: string, userUnqId: string) => Promise; +} diff --git a/apps/st/src/entities/statistics/api/statistics-service.ts b/apps/st/src/entities/statistics/api/statistics-service.ts index 0fdfbf292..20edb9935 100644 --- a/apps/st/src/entities/statistics/api/statistics-service.ts +++ b/apps/st/src/entities/statistics/api/statistics-service.ts @@ -21,6 +21,8 @@ import { type ReviewSubmitResponse, type SearchCondition, type Sgg, + type StatiscicsManagementReviewHistoryRequest, + type StatiscicsManagementReviewHistoryResponse, type StatisticsColumnGuideListResponse, type StatisticsLogDetailResponse, type StatisticsLogRequest, @@ -301,6 +303,14 @@ export class StatisticsApiService implements StatisticsService { ); return response.data; } + + public async getStatiscicsManagementReviewHistory(param: StatiscicsManagementReviewHistoryRequest): Promise { + const response = await stClient.get( + `${ENDPOINT.ST_SERVICE.REVIEW}/stats-review`, + { params: param }, + ); + return response.data; + } } export const statisticsApi = new StatisticsApiService(); diff --git a/apps/st/src/entities/statistics/model/input/statistics-public-sewage-facility-input-interface.ts b/apps/st/src/entities/statistics/model/input/statistics-public-sewage-facility-input-interface.ts index efda5e1aa..2c82cbb0d 100644 --- a/apps/st/src/entities/statistics/model/input/statistics-public-sewage-facility-input-interface.ts +++ b/apps/st/src/entities/statistics/model/input/statistics-public-sewage-facility-input-interface.ts @@ -187,10 +187,11 @@ export interface PublicSewageMainFacilityMappingSaveRequest { admdstNm: string; mainFcltSn: string; mainFcltNm: string; - prxyBzentyNmCn: string; - cnsgnCstVl: number; - wholNope: number; - prxyCtrtCn: string; + /** 매핑 추가(saveList 있음) 시 입력. 매핑 삭제만 할 때는 요청에서 생략 가능 */ + prxyBzentyNmCn?: string; + cnsgnCstVl?: number; + wholNope?: number; + prxyCtrtCn?: string; userId: string; saveList: PublicSewageMainFacilityMappingSaveItem[]; deleteList: string[]; diff --git a/apps/st/src/entities/statistics/model/statistics-interface.ts b/apps/st/src/entities/statistics/model/statistics-interface.ts index cd8caaf77..de3a9bf7b 100644 --- a/apps/st/src/entities/statistics/model/statistics-interface.ts +++ b/apps/st/src/entities/statistics/model/statistics-interface.ts @@ -393,8 +393,10 @@ export interface StatsInputStatusApproveBatchRequest { /** 통계 입력 현황 일괄 승인 API 응답 */ export interface StatsInputStatusApproveResponse { - status: string; - message?: string; + exceptionCnt: number; + message: string; + resultCode: string; + updatedStts: string; } export interface SubmitVerifyExceptionItem { @@ -434,6 +436,32 @@ export interface ReviewerInfoResponse { } } +export interface StatiscicsManagementReviewHistoryRequest { + statsYr: string; + sewerBzmnCd?: string; + ctpvNm?: string; + statsCd?: string; +} + +/** 통계관리 검토 이력 조회 API `data` 배열 요소 */ +export interface StatiscicsManagementReviewHistoryItem { + statsYr: string; + ctpvNm: string; + sggNm: string; + sewerBzmnCd: string; + statsCd: string; + menuNm: string; + wrterOpinionSm: string | null; + chckerOpinionSm: string | null; + returnOpinionSm: string | null; +} + +export interface StatiscicsManagementReviewHistoryResponse { + status: string; + message?: string; + data: StatiscicsManagementReviewHistoryItem[]; +} + export interface StatisticsService { getSidoList(params?: { instSeCd?: string }): Promise>; getSggList(sidoName: string, params?: { instSeCd?: string; gugunSkipYn?: "Y" | "N" }): Promise>; @@ -461,4 +489,5 @@ export interface StatisticsService { SubmitVerifyException(param: SubmitVerifyExceptionRequest): Promise; getColumnGuides(param: string): Promise; getReviewerInfo(param: ReviewerInfoRequest): Promise; + getStatiscicsManagementReviewHistory(param: StatiscicsManagementReviewHistoryRequest): Promise; } \ No newline at end of file diff --git a/apps/st/src/entities/statistics/model/statistics-verification-report/statistics-verification-report-columns.ts b/apps/st/src/entities/statistics/model/statistics-verification-report/statistics-verification-report-columns.ts index 969d3c6b3..025654b05 100644 --- a/apps/st/src/entities/statistics/model/statistics-verification-report/statistics-verification-report-columns.ts +++ b/apps/st/src/entities/statistics/model/statistics-verification-report/statistics-verification-report-columns.ts @@ -26,14 +26,6 @@ export const statisticsVerificationReportColumnInfo: ColumnInfo = { styleName: READ_ONLY_STYLE, editable: false, }, - { - name: "locgStatusNm", - fieldName: "locgStatusNm", - header: { text: "진행상태" }, - width: 120, - styleName: READ_ONLY_STYLE, - editable: false, - }, { name: "laePicNm", fieldName: "laePicNm", @@ -109,7 +101,6 @@ export const statisticsVerificationReportColumnInfo: ColumnInfo = { "ctpvNm", "sggNm", "menuTabName", - "locgStatusNm", "laePicNm", "laePicTelNo", "laeKbrdrDeptNm", diff --git a/apps/st/src/pages/statistics/ui/panels/vrfc-report-panel.tsx b/apps/st/src/pages/statistics/ui/panels/vrfc-report-panel.tsx index 51834d857..1463cc035 100644 --- a/apps/st/src/pages/statistics/ui/panels/vrfc-report-panel.tsx +++ b/apps/st/src/pages/statistics/ui/panels/vrfc-report-panel.tsx @@ -17,11 +17,13 @@ interface VrfcReportPanelProps { const TYPE_CODE_NAME_MAP: Record = { NV: "일반", IMR: "I-MR", + HB: "HB", }; const TYPE_CODE_COLOR_MAP: Record = { NV: "gray", IMR: "red", + HB: "blue", }; export function VrfcReportPanel({ params }: VrfcReportPanelProps) { diff --git a/apps/st/src/shared/lib/index.ts b/apps/st/src/shared/lib/index.ts index e2ddab13d..ec5104865 100644 --- a/apps/st/src/shared/lib/index.ts +++ b/apps/st/src/shared/lib/index.ts @@ -5,5 +5,9 @@ export { useDataTable } from "./use-data-table"; export { useGlobalProgressStore } from "./use-global-progress-store"; export { useMenuStore } from "./use-menu-store"; export { useUserStore } from "./use-user-store"; +export { + useStatisticsInputStatusConfirmGroupGate, + useStatisticsInputToolbarGroupGate, +} from "./use-statistics-input-toolbar-group-gate"; export { usePrompt } from "./use-prompt"; export { useSelectPrompt } from "./use-select-prompt"; diff --git a/apps/st/src/shared/lib/use-statistics-input-toolbar-group-gate.ts b/apps/st/src/shared/lib/use-statistics-input-toolbar-group-gate.ts new file mode 100644 index 000000000..e8337f496 --- /dev/null +++ b/apps/st/src/shared/lib/use-statistics-input-toolbar-group-gate.ts @@ -0,0 +1,68 @@ +"use client"; + +import { useEffect, useState } from "react"; + +import { + checkStatisticsInputStatusConfirmGroupMembership, + checkStatisticsInputToolbarGroupMembership, +} from "@/entities/group-user/lib/check-statistics-input-toolbar-group-membership"; + +import { useUserStore } from "./use-user-store"; + +/** 통계 입력 상단 툴바(저장·제출·검증결과 등) — 그룹 멤버십 만족 시에만 true */ +export function useStatisticsInputToolbarGroupGate(): boolean { + const userInfo = useUserStore((s) => s.userInfo); + const [allowed, setAllowed] = useState(false); + + useEffect(() => { + let cancelled = false; + const name = userInfo?.userName?.trim() ?? ""; + const uid = userInfo?.userUnqId?.trim() ?? ""; + if (!name || !uid) { + setAllowed(false); + return; + } + void (async () => { + try { + const ok = await checkStatisticsInputToolbarGroupMembership(name, uid); + if (!cancelled) setAllowed(ok); + } catch { + if (!cancelled) setAllowed(false); + } + })(); + return () => { + cancelled = true; + }; + }, [userInfo?.userName, userInfo?.userUnqId]); + + return allowed; +} + +/** 통계 입력 현황 — `GRP_ST_CONFIRM` / `GRP_ADMIN` 멤버일 때만 true */ +export function useStatisticsInputStatusConfirmGroupGate(): boolean { + const userInfo = useUserStore((s) => s.userInfo); + const [allowed, setAllowed] = useState(false); + + useEffect(() => { + let cancelled = false; + const name = userInfo?.userName?.trim() ?? ""; + const uid = userInfo?.userUnqId?.trim() ?? ""; + if (!name || !uid) { + setAllowed(false); + return; + } + void (async () => { + try { + const ok = await checkStatisticsInputStatusConfirmGroupMembership(name, uid); + if (!cancelled) setAllowed(ok); + } catch { + if (!cancelled) setAllowed(false); + } + })(); + return () => { + cancelled = true; + }; + }, [userInfo?.userName, userInfo?.userUnqId]); + + return allowed; +}