diff --git a/apps/st/src/components/module/SidoSggFacilityNameSearchForm.tsx b/apps/st/src/components/module/SidoSggFacilityNameSearchForm.tsx
index d8647037e..82a1bae17 100644
--- a/apps/st/src/components/module/SidoSggFacilityNameSearchForm.tsx
+++ b/apps/st/src/components/module/SidoSggFacilityNameSearchForm.tsx
@@ -1,6 +1,6 @@
"use client";
-import { useState } from "react";
+import { useEffect, useRef, useState } from "react";
import useSWR from "swr";
import { ValueType } from "realgrid";
@@ -29,8 +29,13 @@ import type { ColumnInfo } from "../type/DataGrid.type";
import type {
SeweragePipeFacilitiesListRequest,
SeweragePipeFacilityAdminAreaListRequest,
+ SeweragePipeFactConnectionSaveRequest,
+ SeweragePipeFactConnectionSaveItem,
} from "@/entities/statistics/model/input/statistics-sewage-pipe-input-interface";
import { statisticsInputSewPipeApiService } from "@/entities/statistics/api/input/statistics-sewage-pipe-input-service";
+import type { SearchFormValues } from "../type/SearchForm.type";
+import { doNameList } from "../util/column/input/DoList";
+import { getUserInfo } from "../../../../../packages/common/components/src/api";
/** 다이얼로그 좌측: 처리시설 정보 (순번 없음) */
const treatmentFacilityColumnInfo: ColumnInfo = {
@@ -48,21 +53,35 @@ const treatmentFacilityColumnInfo: ColumnInfo = {
editable: false,
},
{
- name: "fctNm",
- fieldName: "fctNm",
+ name: "fcltNm",
+ fieldName: "fcltNm",
header: { text: "시설명" },
editable: false,
},
{
- name: "fctCpc",
- fieldName: "fctCpc",
+ name: "fcltCd",
+ fieldName: "fcltCd",
+ header: { text: "시설코드" },
+ editable: false,
+ visible: false,
+ },
+ {
+ name: "fcltSn",
+ fieldName: "fcltSn",
+ header: { text: "시설일련번호" },
+ editable: false,
+ visible: false,
+ },
+ {
+ name: "fcltCpct",
+ fieldName: "fcltCpct",
header: { text: "시설용량(m³/일)" },
valueType: ValueType.NUMBER,
dataFormat: "#,##0",
editable: false,
},
],
- columnLayout: ["ctpvNm", "sggNm", "fctNm", "fctCpc"],
+ columnLayout: ["ctpvNm", "sggNm", "fcltNm", "fcltCpct", "fcltCd"],
};
/** 다이얼로그 우측: 행정구역 정보 (순번 없음, 헤더 체크박스는 showHeaderCheckbox) */
@@ -103,12 +122,26 @@ const getSggAllListFetcher = async (
type SidoSggFacilityNameSearchFormProps = {
/** 상위(SewagePipeInp 등) 검색 조건의 통계연도 — facilitiesList 등 API 필수값 */
statsYr?: string;
+ /** 상위(SewagePipeInp) 마지막 검색 조건 */
+ initialSearchValues?: Pick
| null;
+ /** 저장 성공 후 부모 탭 데이터 재조회 훅 */
+ onSaveSuccess?: () => Promise | void;
};
const SidoSggFacilityNameSearchForm = ({
statsYr,
+ initialSearchValues,
+ onSaveSuccess,
}: SidoSggFacilityNameSearchFormProps) => {
const { setMessage: alert } = useAlertStore((state) => state);
+ const initialSidoValue =
+ initialSearchValues?.sido && initialSearchValues.sido !== "ALL"
+ ? initialSearchValues.sido
+ : "ALL";
+ const initialGugunValue =
+ initialSearchValues?.gugun && initialSearchValues.gugun !== "ALL"
+ ? initialSearchValues.gugun
+ : "ALL";
const { data: sidoList } = useSWR>(
"sido-list",
@@ -134,9 +167,9 @@ const SidoSggFacilityNameSearchForm = ({
},
);
- const [sido, setSido] = useState("ALL");
- const [gugun, setGugun] = useState("ALL");
- const [fctNm, setFctNm] = useState("");
+ const [sido, setSido] = useState(initialSidoValue);
+ const [gugun, setGugun] = useState(initialGugunValue);
+ const [fcltNm, setFcltNm] = useState("");
const [isLoading, setIsLoading] = useState(false);
const [treatmentFacilityRows, setTreatmentFacilityRows] = useState<
@@ -145,6 +178,10 @@ const SidoSggFacilityNameSearchForm = ({
const [adminRegionRows, setAdminRegionRows] = useState<
Record[]
>([]);
+ const [selectedFacilityRow, setSelectedFacilityRow] = useState<
+ Record | null
+ >(null);
+ const didAutoFetchRef = useRef(false);
const sggKey: [string, string] | null =
sido && sido !== "ALL" ? ["sgg-all", sido] : null;
@@ -198,7 +235,7 @@ const SidoSggFacilityNameSearchForm = ({
alert("시도를 선택해주세요.");
return;
}
- const trimmedFclty = fctNm.trim();
+ const trimmedFclty = fcltNm.trim();
const sewerBzmnCd = resolveSewerBzmnCd();
@@ -225,13 +262,15 @@ const SidoSggFacilityNameSearchForm = ({
list.map((item) => ({
ctpvNm: item.ctpvNm,
sggNm: item.sggNm,
- fctNm: item.bscFctNm,
- fctCpc: item.fctCpc,
- bscFctCd: item.bscFctCd,
+ fcltNm: item.fcltNm,
+ fcltCpct: item.fcltCpct,
+ fcltCd: item.fcltCd,
+ fcltSn: item.fcltSn,
admdstCd: item.admdstCd,
statsYr: item.statsYr,
})),
);
+ setSelectedFacilityRow(null);
setAdminRegionRows([]);
} catch {
alert("처리시설 목록 조회 중 오류가 발생했습니다.");
@@ -247,7 +286,7 @@ const SidoSggFacilityNameSearchForm = ({
alert("상단에서 통계연도로 조회한 뒤 이용해 주세요.");
return;
}
- const swtrmtFcltCd = String(row.bscFctCd ?? "").trim();
+ const swtrmtFcltCd = String(row.fcltCd ?? "").trim();
if (!swtrmtFcltCd) {
alert("처리시설 코드가 없습니다.");
return;
@@ -261,13 +300,14 @@ const SidoSggFacilityNameSearchForm = ({
const params: SeweragePipeFacilityAdminAreaListRequest = {
statsYr: statsYr.trim(),
swtrmtFcltCd,
- sewerBzmnCd: String(row.admdstCd ?? "").trim(),
+ sewerBzmnCd: initialSearchValues?.bsnmCd || '',
ctpvNm,
- sggNm,
+ ...(doNameList.includes(ctpvNm) ? { sggNm } : {}),
};
try {
setIsLoading(true);
+ setSelectedFacilityRow(row);
const response =
await statisticsInputSewPipeApiService.getFacilityAdminAreaList(
params,
@@ -283,6 +323,8 @@ const SidoSggFacilityNameSearchForm = ({
sggNm: item.sggNm,
emdNm: item.admdstNm,
admdstCd: item.admdstCd,
+ isConnection: item.isConnection,
+ __checked: item.isConnection === "1" ? "1" : "0",
})),
);
} catch {
@@ -293,9 +335,172 @@ const SidoSggFacilityNameSearchForm = ({
};
const handleSaveAdminRegions = () => {
- alert("저장 API 연동 예정입니다.");
+ if (!statsYr?.trim()) {
+ alert("상단에서 통계연도로 조회한 뒤 이용해 주세요.");
+ return;
+ }
+ if (!selectedFacilityRow) {
+ alert("처리시설을 먼저 선택해주세요.");
+ return;
+ }
+
+ const hasChanged = adminRegionRows.some(
+ (row) =>
+ (row.__checked === "1" && row.isConnection !== "1") ||
+ (row.__checked !== "1" && row.isConnection === "1"),
+ );
+
+ const saveList: SeweragePipeFactConnectionSaveItem[] = adminRegionRows
+ .filter((row) => row.__checked === "1")
+ .map((row) => ({
+ admdstCd: String(row.admdstCd ?? ""),
+ admdstNm: String(row.emdNm ?? ""),
+ }))
+ .filter((row) => row.admdstCd && row.admdstNm);
+
+ const deleteList = adminRegionRows
+ .filter((row) => row.__checked !== "1" && row.isConnection === "1")
+ .map((row) => String(row.admdstCd ?? ""))
+ .filter(Boolean);
+
+ if (!hasChanged) {
+ alert("변경된 연결 정보가 없습니다.");
+ return;
+ }
+
+ const run = async () => {
+ try {
+ setIsLoading(true);
+ const userInfo = await getUserInfo();
+ const request: SeweragePipeFactConnectionSaveRequest = {
+ ctpvNm: String(selectedFacilityRow.ctpvNm ?? ""),
+ sggNm: String(selectedFacilityRow.sggNm ?? ""),
+ fcltSn: String(selectedFacilityRow.fcltSn ?? ""),
+ statsYr: statsYr.trim(),
+ swtrmtFcltCd: String(selectedFacilityRow.fcltCd ?? ""),
+ swtrmtFcltNm: String(selectedFacilityRow.fcltNm ?? ""),
+ prcsFcltCpct: Number(selectedFacilityRow.fcltCpct ?? 0),
+ sewerBzmnCd: initialSearchValues?.bsnmCd?.trim() ?? "",
+ userId: userInfo.userUnqId,
+ saveList,
+ deleteList,
+ };
+
+ const response =
+ await statisticsInputSewPipeApiService.saveFactConnection(
+ request,
+ );
+
+ if (response.status !== "success") {
+ alert(response.message ?? "연결관계 저장에 실패했습니다.");
+ return;
+ }
+
+ setAdminRegionRows((prev) =>
+ prev.map((row) => ({
+ ...row,
+ isConnection: row.__checked === "1" ? "1" : "0",
+ })),
+ );
+ await onSaveSuccess?.();
+ alert("저장되었습니다.");
+ } catch {
+ alert("연결관계 저장 중 오류가 발생했습니다.");
+ } finally {
+ setIsLoading(false);
+ }
+ };
+
+ void run();
};
+ const handleAdminRegionItemChecked = (
+ itemIndex: number,
+ checked: boolean,
+ ) => {
+ setAdminRegionRows((prev) =>
+ prev.map((row, idx) =>
+ idx === itemIndex
+ ? { ...row, __checked: checked ? "1" : "0" }
+ : row,
+ ),
+ );
+ };
+
+ const handleAdminRegionItemAllChecked = (checked: boolean) => {
+ setAdminRegionRows((prev) =>
+ prev.map((row) => ({ ...row, __checked: checked ? "1" : "0" })),
+ );
+ };
+
+ useEffect(() => {
+ if (!initialSearchValues?.sido || initialSearchValues.sido === "ALL") return;
+ setSido(initialSearchValues.sido);
+ setGugun(
+ initialSearchValues.gugun && initialSearchValues.gugun !== "ALL"
+ ? initialSearchValues.gugun
+ : "ALL",
+ );
+ }, [initialSearchValues?.sido, initialSearchValues?.gugun]);
+
+ useEffect(() => {
+ if (didAutoFetchRef.current) return;
+ if (!statsYr?.trim()) return;
+ if (!initialSearchValues?.sido || initialSearchValues.sido === "ALL") return;
+
+ const nextSido = initialSearchValues.sido;
+ const nextGugun =
+ initialSearchValues.gugun && initialSearchValues.gugun !== "ALL"
+ ? initialSearchValues.gugun
+ : "ALL";
+
+ didAutoFetchRef.current = true;
+ setSido(nextSido);
+ setGugun(nextGugun);
+
+ const params: SeweragePipeFacilitiesListRequest = {
+ statsYr: statsYr.trim(),
+ ...(initialSearchValues.bsnmCd?.trim()
+ ? { sewerBzmnCd: initialSearchValues.bsnmCd.trim() }
+ : {}),
+ ctpvNm: nextSido,
+ ...(nextGugun !== "ALL" ? { sggNm: nextGugun } : {}),
+ };
+
+ const run = async () => {
+ try {
+ setIsLoading(true);
+ const response =
+ await statisticsInputSewPipeApiService.getFacilitiesList(params);
+ if (response.status !== "success") {
+ alert(response.message ?? "처리시설 목록 조회에 실패했습니다.");
+ return;
+ }
+ const list = response.data ?? [];
+ setTreatmentFacilityRows(
+ list.map((item) => ({
+ ctpvNm: item.ctpvNm,
+ sggNm: item.sggNm,
+ fcltNm: item.fcltNm,
+ fcltCpct: item.fcltCpct,
+ fcltCd: item.fcltCd,
+ fcltSn: item.fcltSn,
+ admdstCd: item.admdstCd,
+ statsYr: item.statsYr,
+ })),
+ );
+ setSelectedFacilityRow(null);
+ setAdminRegionRows([]);
+ } catch {
+ alert("처리시설 목록 조회 중 오류가 발생했습니다.");
+ } finally {
+ setIsLoading(false);
+ }
+ };
+
+ void run();
+ }, [statsYr, initialSearchValues, alert]);
+
return (
@@ -396,6 +601,8 @@ const SidoSggFacilityNameSearchForm = ({
rowData={adminRegionRows}
setRowData={setAdminRegionRows}
showHeaderCheckbox
+ onItemChecked={handleAdminRegionItemChecked}
+ onItemAllChecked={handleAdminRegionItemAllChecked}
compactFullWidth
// compactGridHeightClass="h-[min(40vh,28rem)] min-h-[16rem]"
/>
diff --git a/apps/st/src/components/statistics/Dashboard.tsx b/apps/st/src/components/statistics/Dashboard.tsx
new file mode 100644
index 000000000..423294d2f
--- /dev/null
+++ b/apps/st/src/components/statistics/Dashboard.tsx
@@ -0,0 +1,93 @@
+"use client";
+
+import { useEffect, useState } from "react";
+
+import { closestCenter, DndContext } from "@dnd-kit/core";
+import { rectSortingStrategy, SortableContext } from "@dnd-kit/sortable";
+import dayjs from "dayjs";
+
+import { useUserStore } from "@/shared/lib";
+import { Skeleton } from "@common/components";
+
+import { useDndSensors } from "@/pages/statistics/lib/hooks/use-dnd-sensors";
+import { usePanelOrder } from "@/pages/statistics/lib/hooks/use-panel-order";
+import { getInitialPanels } from "@/pages/statistics/model/panel-config";
+import { SortablePanel } from "@/pages/statistics/ui/sortable-panel";
+
+interface PanelItem {
+ id: string;
+ component: React.ReactNode;
+ className?: string;
+}
+
+function Dashboard() {
+ const userInfo = useUserStore((state) => state.userInfo);
+ const [initialPanels, setInitialPanels] = useState([]);
+ const [isMounted, setIsMounted] = useState(false);
+ const sensors = useDndSensors();
+
+ const { panels, handleDragEnd } = usePanelOrder({
+ initialPanels,
+ storageKey: "statistics-dashboard-panels-order",
+ });
+
+ useEffect(() => {
+ setIsMounted(true);
+ }, []);
+
+ useEffect(() => {
+ if (!userInfo) return;
+ const initialPanels = getInitialPanels({
+ dashboardParams: {
+ statsYr: String(dayjs().year() - 1),
+ dtlInstCd: userInfo.dtlInstCd,
+ enoCd: userInfo.enoCd,
+ laeSeCd: userInfo.laeSeCd,
+ },
+ scheduleInfoParams: {
+ statsYr: String(dayjs().year() - 1),
+ },
+ });
+ setInitialPanels(initialPanels);
+ }, [userInfo]);
+
+ return (
+ <>
+ {isMounted && initialPanels.length > 0 ? (
+
+ p.id)}
+ strategy={rectSortingStrategy}
+ >
+
+ {panels.map((panel) => (
+
+ {panel.component}
+
+ ))}
+
+
+
+ ) : (
+
+
+
+
+
+
+
+
+ )}
+ >
+ );
+}
+
+export default Dashboard;
diff --git a/apps/st/src/components/statistics/input/SewagePipeInp.tsx b/apps/st/src/components/statistics/input/SewagePipeInp.tsx
index 91eaaacc3..5ea57e205 100644
--- a/apps/st/src/components/statistics/input/SewagePipeInp.tsx
+++ b/apps/st/src/components/statistics/input/SewagePipeInp.tsx
@@ -1,4 +1,4 @@
-import { useState, useMemo, useRef } from "react";
+import { useState, useMemo, useRef, useEffect } from "react";
import SearchForm from "@/components/module/SearchForm";
import DataGrid from "@/components/module/DataGrid";
@@ -7,41 +7,44 @@ 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 VerificationResultButton from "@/components/button/VerificationResultButton";
+import { VerificationResultHistoryDialog } from "@/components/module/VerificationResultHistoryDialog";
import { SewagePipeInpColumnInfoList } from "@/components/util/column/input/sewagePipe/SewagePipeInpColumn";
import type { ColumnInfo, ColumnInfoList, ColumnConfig } from "@/components/type/DataGrid.type";
import type { SearchFormValues } from "@/components/type/SearchForm.type";
-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 { 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 {
+ SeweragePipeInputFactInstallStatusSaveRequest,
SeweragePipeInputRegionalFactStatusSaveRequest,
SeweragePipeInputStatusInfo,
+ SeweragePipeInputVerificationResult,
SeweragePipeRequestParams,
} from "@/entities/statistics/model/input/statistics-sewage-pipe-input-interface";
+import { isInputPeriodCurrent } from "@/entities/statistics/model/input/statistics-sewage-supply-rate-input-interface";
+import { FactInstallStatusInpColumn } from "@/components/util/column/input/sewagePipe/FactInstallStatusInpColumn";
import { RegionalFactStatusInpColumn } from "@/components/util/column/input/sewagePipe/RegionalFactStatusInpColumn";
import { toGridRows } from "@/components/util/grid/gridFunction";
import { LoadingWithAnimation, Tabs, TabsContent, TabsList, TabsTrigger } from "@common/components";
import { getUserInfo } from "../../../../../../packages/common/components/src/api";
-const applyEnterableFromStatus = (
+/** 탭별 status + 입력기간으로 편집 가능 여부 */
+const computeTabEnterable = (
statusInfo: SeweragePipeInputStatusInfo | null | undefined,
periodEnterable: boolean,
- setIsEnterable: (v: boolean) => void
-) => {
+): boolean => {
if (statusInfo?.laeSbmsnSttsCd) {
if (["2", "3"].includes(statusInfo.laeSbmsnSttsCd)) {
- setIsEnterable(false);
- } else {
- setIsEnterable(periodEnterable);
+ return false;
}
- } else {
- setIsEnterable(periodEnterable);
+ return periodEnterable;
}
+ return periodEnterable;
};
function buildSewagePipeSearchParam(
@@ -58,6 +61,10 @@ function buildSewagePipeSearchParam(
}
/** ST00020006(연결)은 행이 좌·우 그리드 키에만 있음 */
+type GridRowWithVerification = Record & {
+ verificationResult?: SeweragePipeInputVerificationResult;
+};
+
function tabRowDataExists(
m: Map[]>,
tabKey: string,
@@ -74,6 +81,43 @@ function tabRowDataExists(
return Array.isArray(rows) && rows.length > 0;
}
+/** 조회 직후 해당 탭에만 저장하는 상단 툴바 노출 조건 */
+type TabToolbarSnapshot = {
+ showSaveInToolbar: boolean;
+ showEnterableToolbarButtons: boolean;
+ showActionToolbarCard: boolean;
+};
+
+function buildGugunOkForToolbar(values: SearchFormValues | null): boolean {
+ return doList.includes(values?.bsnmCd || "")
+ ? Boolean(values?.gugun && values.gugun !== "ALL")
+ : true;
+}
+
+/**
+ * @param searchContext 조회에 사용된 검색값(해당 요청 기준으로 구군·사업자 조건 반영)
+ * @param treatAsSearched (hasRow || 이 값) 일 때 카드 후보 — load 내부에서는 searchDoneRef 반영용
+ */
+function computeTabToolbarSnapshot(
+ statusInfo: SeweragePipeInputStatusInfo | null,
+ hasRowData: boolean,
+ treatAsSearched: boolean,
+ searchContext: SearchFormValues | null,
+ periodEnterable: boolean,
+ isAdminInfo: boolean,
+): TabToolbarSnapshot {
+ const gugunOk = buildGugunOkForToolbar(searchContext);
+ const enterable = computeTabEnterable(statusInfo, periodEnterable);
+ const showSaveInToolbar = (enterable || isAdminInfo) && gugunOk;
+ const showEnterableToolbarButtons = enterable && gugunOk;
+ const showActionToolbarCard =
+ (hasRowData || treatAsSearched) &&
+ (showSaveInToolbar ||
+ (statusInfo?.laeSbmsnSttsCd === "2" &&
+ (isAdminInfo || (enterable && gugunOk))));
+ return { showSaveInToolbar, showEnterableToolbarButtons, showActionToolbarCard };
+}
+
// 하수도 통계 관리 > 02. 하수관로
const SewagePipeInp = () => {
const [rowDataMap, setRowDataMap] = useState