통수능/준설관리

This commit is contained in:
2026-08-05 16:05:07 +09:00
parent 0f8b6cd6d0
commit 493acc9ebd
48 changed files with 2698 additions and 2123 deletions
+16 -7
View File
@@ -3,15 +3,23 @@
import { DndProvider } from "react-dnd";
import { MultiBackend, getBackendOptions } from "@minoru/react-dnd-treeview";
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
import { ReactQueryDevtools } from "@tanstack/react-query-devtools";
import { ConfirmProvider } from "@frontend-opensource/use-react-hooks";
import dynamic from "next/dynamic";
import { AuthWrapper } from "@/entities/auth";
import { MenuWrapper } from "@/entities/menu";
import { PromptProvider, StoreProvider } from "@/shared/providers";
import { AlertDialog } from "@/shared/ui/alert-dialog";
import { PromptDialog } from "@/shared/ui/prompt-dialog";
import { ConfirmDialog } from "@/shared/ui/confirm-dialog";
import { GlobalAnalysisCompletionWatcher } from "../../../app/(with-base-layout)/ii/useAnalysisCompletionWatcher";
// 개발 도구는 실제 로컬 개발 환경에서만 별도 청크로 불러와 최초 페이지 컴파일 범위를 줄인다.
const LazyReactQueryDevtools = dynamic(
() =>
import("@tanstack/react-query-devtools").then(
({ ReactQueryDevtools }) => ReactQueryDevtools,
),
{ ssr: false },
);
const AppProvider = ({ children }: Readonly<{ children: React.ReactNode }>) => {
const queryClient = new QueryClient();
@@ -29,7 +37,6 @@ const AppProvider = ({ children }: Readonly<{ children: React.ReactNode }>) => {
{children}
</DndProvider>
</MenuWrapper>
<GlobalAnalysisCompletionWatcher />
<AlertDialog />
<PromptDialog />
<ConfirmDialog />
@@ -37,11 +44,13 @@ const AppProvider = ({ children }: Readonly<{ children: React.ReactNode }>) => {
</ConfirmProvider>
</PromptProvider>
</StoreProvider>
<div className="text-2xl">
<ReactQueryDevtools initialIsOpen={false} />
</div>
{process.env.NODE_ENV === "development" ? (
<div className="text-2xl">
<LazyReactQueryDevtools initialIsOpen={false} />
</div>
) : null}
</QueryClientProvider>
);
};
export { AppProvider };
export { AppProvider };
+24
View File
@@ -64,6 +64,30 @@
@apply flex min-h-0 flex-1 flex-col overflow-hidden p-4;
}
/* 부모 업무화면의 10px rem 기준과 동일한 가독성을 준설 등록 팝업에도 적용한다. */
.sn-dredging-register {
font-size: 1.3rem;
line-height: 1.5;
}
.sn-dredging-register .text-xs {
font-size: 1.1rem;
}
.sn-dredging-register .text-sm,
.sn-dredging-register button,
.sn-dredging-register input {
font-size: 1.3rem;
}
.sn-dredging-register .text-base {
font-size: 1.4rem;
}
.sn-dredging-register .text-xl {
font-size: 1.8rem;
}
.sn-popup-filter-panel {
@apply grid grid-cols-1 gap-3 border-b border-border bg-secondary pb-4;
}
@@ -3,11 +3,9 @@
import type { UIEvent } from "react";
import { useEffect, useMemo, useState } from "react";
import {
downloadExcelWithKoreanHeaders,
type UiTableSort,
} from "@common/business_components/ui";
import type { UiTableSort } from "@common/business_components/ui/table";
import { Button, Input } from "@common/components/ui";
import { downloadExcelWithKoreanHeaders } from "@/shared/lib/download-excel";
import {
getExmnFacilityDetails,
@@ -3,7 +3,11 @@ import {
windowPopupDefinitions,
type WindowPopupKey,
} from "../model/popupDefinitions";
import type { OpenWindowPopupParams, WindowPopupDefinition } from "../model/types";
import type {
OpenWindowPopupOptions,
OpenWindowPopupParams,
WindowPopupDefinition,
} from "../model/types";
const buildQueryString = (params: OpenWindowPopupParams = {}) => {
const searchParams = new URLSearchParams();
@@ -84,13 +88,18 @@ const buildPopupFeatures = ({
export const openWindowPopup = (
popupKey: WindowPopupKey,
params?: OpenWindowPopupParams,
options?: OpenWindowPopupOptions,
) => {
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);
const popupFeatures = buildPopupFeatures({
...definition,
width: options?.width ?? definition.width,
height: options?.height ?? definition.height,
});
const popupWindow = window.open(
popupUrl,
`sn-popup-${popupKey}`,
@@ -21,6 +21,12 @@ export const windowPopupDefinitions = {
height: 720,
requiredParams: ["exmnBizId", "trtdstId", "trtdstNm", "jobType"],
},
dredgingRegister: {
key: "dredgingRegister",
title: "준설결과 등록",
width: 1280,
height: 900,
},
oldPpUpload: {
key: "oldPpUpload",
title: "노후 하수관로 정비사업 등록",
@@ -175,8 +181,8 @@ export const windowPopupDefinitions = {
flowRiskPopup: {
key: "flowRiskPopup",
title: "통수 위험",
width: 920,
height: 760,
width: 1180,
height: 820,
},
} as const satisfies Record<string, WindowPopupDefinition>;
@@ -30,10 +30,16 @@ export type OpenWindowPopupParams = Record<
string | number | boolean | null | undefined
>;
/** 팝업별 기본 정의를 유지하면서 호출 화면에서 필요한 크기만 재정의한다. */
export type OpenWindowPopupOptions = Partial<
Pick<WindowPopupDefinition, "width" | "height">
>;
export const WINDOW_POPUP_MESSAGE_TYPE = {
EXMN_BUSINESS_CREATED: "sn:popup:exmnBusinessCreated",
EXMN_BUSINESS_UPDATED: "sn:popup:exmnBusinessUpdated",
EXMN_RESULT_COMMITTED: "sn:popup:exmnResultCommitted",
DREDGING_REGISTERED: "sn:popup:dredgingRegistered",
OLD_PP_UPLOAD_COMPLETED: "sn:popup:oldPpUploadCompleted",
FLOOD_PRE_UPLOAD_COMPLETED: "sn:popup:floodPreUploadCompleted",
MAP_SEARCH_SELECT_FEATURE: "sn:popup:mapSearchSelectFeature",
@@ -49,11 +55,6 @@ export const WINDOW_POPUP_MESSAGE_TYPE = {
WATER_RISK_READY: "sn:popup:waterRiskReady",
WATER_RISK_SYNC: "sn:popup:waterRiskSync",
WATER_RISK_SELECT_RESULT: "sn:popup:waterRiskSelectResult",
WATER_RISK_START_SETTING_EDIT: "sn:popup:waterRiskStartSettingEdit",
WATER_RISK_STOP_SETTING_EDIT: "sn:popup:waterRiskStopSettingEdit",
WATER_RISK_REMOVE_STAGED_PIPE: "sn:popup:waterRiskRemoveStagedPipe",
WATER_RISK_REORDER_STAGED_PIPE: "sn:popup:waterRiskReorderStagedPipe",
WATER_RISK_CLEAR_STAGED_PIPES: "sn:popup:waterRiskClearStagedPipes",
} as const;
export type WindowPopupMessageType =
@@ -2,6 +2,7 @@ import { windowPopupDefinitions, type WindowPopupKey } from "./model/popupDefini
import type { WindowPopupRegistryItem } from "./model/types";
import AgedPipePopupContent from "./ui/AgedPipePopupContent";
import AgedPipeStatusByCtpvPopupContent from "./ui/AgedPipeStatusByCtpvPopupContent";
import DredgingRegisterPopupContent from "./ui/DredgingRegisterPopupContent";
import ExmnBusinessRegisterPopupContent from "./ui/ExmnBusinessRegisterPopupContent";
import ExmnResultRegisterPopupContent from "./ui/ExmnResultRegisterPopupContent";
import ExmnSurveyDetailPopupContent from "./ui/ExmnSurveyDetailPopupContent";
@@ -50,6 +51,10 @@ export const windowPopupRegistry = {
...windowPopupDefinitions.exmnSurveyDetail,
component: ExmnSurveyDetailPopupContent,
},
dredgingRegister: {
...windowPopupDefinitions.dredgingRegister,
component: DredgingRegisterPopupContent,
},
oldPpUpload: {
...windowPopupDefinitions.oldPpUpload,
component: OldPpUploadPopupContent,
@@ -0,0 +1,19 @@
"use client";
import DredgingRegisterPopupPage from "@/app/(with-base-layout)/oper/dredging/_components/DredgingRegisterPopupPage";
import { useEffect, useState } from "react";
/** 준설결과 등록 화면을 공통 WindowPopup 레이아웃에 연결한다. */
export default function DredgingRegisterPopupContent() {
const [isMounted, setIsMounted] = useState(false);
useEffect(() => {
setIsMounted(true);
}, []);
if (!isMounted) {
return <div className="min-h-dvh bg-[#f3f7fa]" suppressHydrationWarning />;
}
return <DredgingRegisterPopupPage />;
}
+125
View File
@@ -0,0 +1,125 @@
type ExcelDownloadWithHeaderProps = {
data: JSON | JSON[];
fileName: string;
sheetNameArray: string[];
headerRange?: string;
headerMapping: Record<string, string>;
mergeRanges?: string[];
};
type ExcelRow = Record<string, unknown>;
// 엑셀 시트명 제한 문자와 최대 길이를 정리해 다운로드 시 런타임 오류를 방지한다.
const normalizeSheetName = (sheetName: string | undefined) => {
const normalized = (sheetName || "Sheet1").replace(/[\\/?*[\]:]/g, "_");
return normalized.slice(0, 31) || "Sheet1";
};
// 한글을 두 글자 폭으로 계산해 내려받은 파일의 기본 열 너비를 읽기 좋게 맞춘다.
const getDisplayWidth = (value: unknown) =>
Array.from(String(value ?? "")).reduce(
(width, character) => width + (character.charCodeAt(0) > 255 ? 2 : 1),
0,
);
/**
* Turbopack이 지원하지 않는 xlsx-populate의 동적 require를 피하면서
* 기존 한글 헤더, 병합 범위와 XLSX 다운로드 계약을 유지한다.
*/
export const downloadExcelWithKoreanHeaders = async ({
data,
fileName,
sheetNameArray,
headerRange = "A1:V1",
headerMapping,
mergeRanges = [],
}: ExcelDownloadWithHeaderProps) => {
if (typeof window === "undefined") {
throw new Error("엑셀 다운로드는 브라우저에서만 사용할 수 있습니다.");
}
const dataRows = (Array.isArray(data) ? data : [data]) as unknown as ExcelRow[];
if (dataRows.length === 0) {
throw new Error("데이터가 없습니다.");
}
// XLSX 모듈도 다운로드 버튼을 누를 때만 불러와 지도 화면의 최초 컴파일을 줄인다.
const XLSX = await import("xlsx");
const englishHeaders = Object.keys(dataRows[0] ?? {});
const koreanHeaders = englishHeaders.map(
(header) => headerMapping[header] || header,
);
const worksheetRows = [
koreanHeaders,
...dataRows.map((row) => englishHeaders.map((header) => row[header])),
];
const worksheet = XLSX.utils.aoa_to_sheet(worksheetRows);
// SheetJS가 지원하는 범위에서는 기존 공통 다운로드와 유사한 헤더 스타일을 적용한다.
const decodedHeaderRange = XLSX.utils.decode_range(headerRange);
for (
let rowIndex = decodedHeaderRange.s.r;
rowIndex <= decodedHeaderRange.e.r;
rowIndex += 1
) {
for (
let columnIndex = decodedHeaderRange.s.c;
columnIndex <= decodedHeaderRange.e.c;
columnIndex += 1
) {
const cellAddress = XLSX.utils.encode_cell({
r: rowIndex,
c: columnIndex,
});
const headerCell = worksheet[cellAddress] as
| (typeof worksheet[string] & { s?: Record<string, unknown> })
| undefined;
if (headerCell) {
headerCell.s = {
fill: { patternType: "solid", fgColor: { rgb: "9BC1E7" } },
font: { bold: true, sz: 13 },
alignment: {
horizontal: "center",
vertical: "center",
wrapText: true,
},
border: {
top: { style: "thin", color: { rgb: "FFFFFF" } },
bottom: { style: "thin", color: { rgb: "FFFFFF" } },
left: { style: "thin", color: { rgb: "FFFFFF" } },
right: { style: "thin", color: { rgb: "FFFFFF" } },
},
};
}
}
}
worksheet["!autofilter"] = { ref: headerRange };
worksheet["!merges"] = mergeRanges.map((range) =>
XLSX.utils.decode_range(range),
);
worksheet["!cols"] = englishHeaders.map((header, columnIndex) => {
const sampledValues = dataRows
.slice(0, 200)
.map((row) => row[header])
.concat(koreanHeaders[columnIndex]);
const width = Math.max(...sampledValues.map(getDisplayWidth), 10);
return { wch: Math.min(width + 2, 40) };
});
const workbook = XLSX.utils.book_new();
XLSX.utils.book_append_sheet(
workbook,
worksheet,
normalizeSheetName(sheetNameArray[0]),
);
const normalizedFileName = fileName.toLowerCase().endsWith(".xlsx")
? fileName
: `${fileName}.xlsx`;
XLSX.writeFile(workbook, normalizedFileName, {
bookType: "xlsx",
compression: true,
cellStyles: true,
});
};