[st] feat: 집계 2,6 / 입력 2,6 / 통계 입력 관리

This commit is contained in:
2026-05-11 13:20:08 +09:00
parent 679ea8a6b4
commit e10ebf4219
17 changed files with 1350 additions and 266 deletions
+13
View File
@@ -29,6 +29,13 @@
.rg-grid .rg-body .rg-table .rg-data-row .rg-data-cell.rg-cell-verify-error-imr {
@apply !bg-[#fff7bf] !text-[#b45309];
}
/* 검증결과 내역: 처리여부(prcsYn 0·4 미처리 등) */
.rg-grid .rg-body .rg-table .rg-data-row .rg-data-cell.rg-cell-vrf-prcs-bad {
@apply !font-semibold !text-[#dc2626];
}
.rg-grid .rg-body .rg-table .rg-data-row .rg-data-cell.rg-cell-vrf-prcs-ok {
@apply !font-semibold !text-[#3372cf];
}
.rg-grid .rg-body .rg-table .rg-data-row .rg-data-cell.rg-cell-total {
@apply !bg-[#e3eaf2];
}
@@ -95,6 +102,12 @@
.rg-grid .rg-body .rg-table .rg-data-row .rg-data-cell {
@apply !bg-[#545556] !text-[#fff];
}
.rg-grid .rg-body .rg-table .rg-data-row .rg-data-cell.rg-cell-vrf-prcs-bad {
@apply !text-[#f87171];
}
.rg-grid .rg-body .rg-table .rg-data-row .rg-data-cell.rg-cell-vrf-prcs-ok {
@apply !text-[#3372cf];
}
}
[data-theme="red"] {
@@ -25,6 +25,7 @@ import {
import {
defaultSearchFormProps,
type SearchFormClsfCd,
type SearchFormProps,
type SearchFormValues,
} from "../type/SearchForm.type";
@@ -81,6 +82,12 @@ const SearchForm = (props: SearchFormProps = {}) => {
},
);
const initialClsfCd: SearchFormClsfCd = (() => {
const d = props.defaultValues?.clsfCd;
if (d === "ALL" || d === "INPT" || d === "VRFC") return d;
return "ALL";
})();
const [values, setValues] = useState<SearchFormValues>({
sido: props.defaultValues?.sido || "",
gugun: props.defaultValues?.gugun || "",
@@ -90,6 +97,7 @@ const SearchForm = (props: SearchFormProps = {}) => {
bsnmCd: props.defaultValues?.bsnmCd || "",
isDetail: false,
isSummary: props.defaultValues?.isSummary || "",
clsfCd: initialClsfCd,
});
const sggKey: [string, string, string, string] | null =
@@ -134,6 +142,7 @@ const SearchForm = (props: SearchFormProps = {}) => {
bsnmCd: "",
isDetail: false,
isSummary: "",
clsfCd: "ALL",
};
setValues(resetValues);
props.onReset?.();
@@ -250,6 +259,29 @@ const SearchForm = (props: SearchFormProps = {}) => {
</Select>
</div>
)}
{mergedProps.clsf && (
<div className="flex items-center gap-2">
<Label className="text-label">분류</Label>
<Select
value={values.clsfCd ?? "ALL"}
onValueChange={(value) =>
setValues({
...values,
clsfCd: value as SearchFormClsfCd,
})
}
>
<SelectTrigger className="w-[10rem]">
<SelectValue placeholder="선택" />
</SelectTrigger>
<SelectContent>
<SelectItem value="ALL">전체</SelectItem>
<SelectItem value="INPT">입력</SelectItem>
<SelectItem value="VRFC">검증</SelectItem>
</SelectContent>
</Select>
</div>
)}
{mergedProps.fctName && (
<div className="flex items-center gap-2">
<Label
@@ -1,6 +1,9 @@
"use client";
import { type Dispatch, type SetStateAction, useCallback, useEffect, useMemo, useState } from "react";
import { statisticsApi } from "@/entities/statistics/api/statistics-service";
import type { VerificationResultListRequest, VerificationResultReportItem } from "@/entities/statistics/model/statistics-interface";
import {
Button,
Dialog,
@@ -11,20 +14,29 @@ import {
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";
import type { CellStyleCallbackParams, ColumnInfo } from "@/components/type/DataGrid.type";
type VerificationResultGridRow = Record<string, string | number>;
const PRCS_YN_LABEL: Record<string, string> = {
"0": "미통과",
"1": "통과",
"2": "예외처리",
"3": "반려",
};
function prcsYnCodeToNumber(raw: string | number | undefined): number | undefined {
if (raw === undefined || raw === null) return undefined;
if (typeof raw === "number" && Number.isFinite(raw)) return raw;
const s = String(raw).trim();
if (s === "") return undefined;
const n = Number.parseInt(s, 10);
return Number.isFinite(n) ? n : undefined;
}
/** prcsYn 0·4 → 빨간색, 그 외 → 초록색 (처리여부 컬럼) */
function verificationPrcsYnNmCellStyle({ fieldName, row }: CellStyleCallbackParams): string | undefined {
if (fieldName !== "prcsYnNm") return undefined;
const code = prcsYnCodeToNumber(row.prcsYn);
if (code === 0 || code === 4) return "rg-cell-vrf-prcs-bad";
return "rg-cell-vrf-prcs-ok";
}
const verificationResultColumnInfo: ColumnInfo = {
columns: [
@@ -34,12 +46,13 @@ const verificationResultColumnInfo: ColumnInfo = {
{ 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: "prcsYnNm", fieldName: "prcsYnNm", 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 },
{ name: "prcsYn", fieldName: "prcsYn", header: { text: "검증결과상태코드" }, editable: false, visible: false },
],
columnLayout: ["ctpvNm", "sggNm", "vrfcTrgtNm", "rsnExpln", "nowVlCn", "compVlCn", "prcsYn", "pbcpAprvYn", "regDt", "mdfcnDt"],
columnLayout: ["ctpvNm", "sggNm", "vrfcTrgtNm", "rsnExpln", "nowVlCn", "compVlCn", "prcsYnNm", "pbcpAprvYn", "regDt", "mdfcnDt", "prcsYn"],
};
export interface VerificationResultHistoryDialogProps {
@@ -190,7 +203,8 @@ export function VerificationResultHistoryDialog({
rsnExpln: item.rsnExpln,
nowVlCn: item.nowVlCn ?? "-",
compVlCn: item.compVlCn ?? "-",
prcsYn: PRCS_YN_LABEL[item.prcsYn] ?? item.prcsYn,
prcsYnNm: item.prcsYnNm ?? "-",
prcsYn: item.prcsYn ?? "",
pbcpAprvYn: item.pbcpAprvYn,
regDt: (item.regDt ?? "").slice(0, 16),
mdfcnDt: (item.mdfcnDt ?? "").slice(0, 16),
@@ -224,6 +238,7 @@ export function VerificationResultHistoryDialog({
isExcelExport={false}
showHeaderCheckbox={false}
enableSort={false}
cellStyleCallback={verificationPrcsYnNmCellStyle}
/>
<VerificationResultHistoryPagination
pageCount={Math.max(pageCount, 1)}
@@ -215,7 +215,7 @@ const VerificationSelectionReport = ({
<div className="w-1/5 bg-container p-4 !pb-2 flex border-r border-border">
<div className="card card-border flex-1 bg-background !px-0">
<div className="border-b border-border flex justify-between">
<h2 className="text-[2.4rem] font-semibold text-title p-4 ">검증 미통과 내역</h2>
<h2 className="text-[2.4rem] font-semibold text-title p-4 ">이상치 선별 보고서</h2>
<span
role="button"
tabIndex={0}
@@ -0,0 +1,224 @@
"use client";
import { useEffect, useState } from "react";
import { useAlertStore } from "@/shared/lib";
import {
Button,
Dialog,
DialogClose,
DialogContent,
DialogFooter,
DialogHeader,
DialogTitle,
Input,
Label,
} from "@common/components";
export interface MainFacilityMappingSaveDialogValues {
prxyBzentyNmCn: string;
/** 위탁비용(백만원/년) */
cnsgnCstVl: number;
wholNope: number;
/** YYYYMMDD~YYYYMMDD */
prxyCtrtCn: string;
}
export interface MainFacilityMappingSaveDialogProps {
open: boolean;
onOpenChange: (open: boolean) => void;
initialPrxyBzentyNmCn: string;
/** 위탁비용(백만원/년) 초기값 */
initialCnsgnCstVl: number;
initialWholNope: number;
/** 위탁계약기간 초기값 (YYYYMMDD~YYYYMMDD) */
initialPrxyCtrtCn: string;
onConfirm?: (values: MainFacilityMappingSaveDialogValues) => void | Promise<void>;
}
function isValidYmd(ymd: string): boolean {
if (!/^\d{8}$/.test(ymd)) return false;
const y = Number(ymd.slice(0, 4));
const m = Number(ymd.slice(4, 6));
const d = Number(ymd.slice(6, 8));
const dt = new Date(y, m - 1, d);
return dt.getFullYear() === y && dt.getMonth() === m - 1 && dt.getDate() === d;
}
function validatePrxyCtrtCn(value: string): string | null {
const t = value.trim();
if (!t) return "위탁계약기간을 입력해 주세요.";
if (!/^\d{8}~\d{8}$/.test(t)) {
return "위탁계약기간은 YYYYMMDD~YYYYMMDD 형식으로 입력해 주세요. (예: 20240101~20281231)";
}
const start = t.slice(0, 8);
const end = t.slice(9, 17);
if (!isValidYmd(start) || !isValidYmd(end)) {
return "시작일 또는 종료일이 유효한 날짜가 아닙니다.";
}
if (start > end) {
return "시작일이 종료일보다 늦을 수 없습니다.";
}
return null;
}
export function MainFacilityMappingSaveDialog({
open,
onOpenChange,
initialPrxyBzentyNmCn,
initialCnsgnCstVl,
initialWholNope,
initialPrxyCtrtCn,
onConfirm,
}: MainFacilityMappingSaveDialogProps) {
const { setMessage: alert } = useAlertStore((state) => state);
const [prxyBzentyNmCn, setPrxyBzentyNmCn] = useState("");
const [cnsgnCstInput, setCnsgnCstInput] = useState("");
const [wholNopeInput, setWholNopeInput] = useState("");
const [prxyCtrtCn, setPrxyCtrtCn] = useState("");
const [isSubmitting, setIsSubmitting] = useState(false);
useEffect(() => {
if (open) {
setPrxyBzentyNmCn(initialPrxyBzentyNmCn ?? "");
const initCost = Number(initialCnsgnCstVl);
setCnsgnCstInput(
initialCnsgnCstVl === null ||
initialCnsgnCstVl === undefined ||
Number.isNaN(initCost)
? ""
: String(initCost),
);
setWholNopeInput(
initialWholNope === null || initialWholNope === undefined || Number.isNaN(Number(initialWholNope))
? ""
: String(initialWholNope),
);
setPrxyCtrtCn((initialPrxyCtrtCn ?? "").trim());
}
}, [open, initialPrxyBzentyNmCn, initialCnsgnCstVl, initialWholNope, initialPrxyCtrtCn]);
const handleDialogOpenChange = (nextOpen: boolean) => {
if (!nextOpen && !isSubmitting) {
setPrxyBzentyNmCn("");
setCnsgnCstInput("");
setWholNopeInput("");
setPrxyCtrtCn("");
}
onOpenChange(nextOpen);
};
const handleConfirm = async () => {
const trimmedCost = cnsgnCstInput.trim();
const cnsgnCstVl = trimmedCost === "" ? 0 : Number.parseFloat(trimmedCost.replace(/,/g, ""));
if (!Number.isFinite(cnsgnCstVl) || cnsgnCstVl < 0) {
alert("위탁비용은 0 이상의 숫자로 입력해 주세요.");
return;
}
const trimmedWhol = wholNopeInput.trim();
const wholNope = trimmedWhol === "" ? 0 : Number.parseInt(trimmedWhol, 10);
if (!Number.isFinite(wholNope) || wholNope < 0) {
alert("전체 인원은 0 이상의 정수로 입력해 주세요.");
return;
}
const prxyCtrtErr = validatePrxyCtrtCn(prxyCtrtCn);
if (prxyCtrtErr) {
alert(prxyCtrtErr);
return;
}
try {
setIsSubmitting(true);
await onConfirm?.({
prxyBzentyNmCn: prxyBzentyNmCn.trim(),
cnsgnCstVl,
wholNope,
prxyCtrtCn: prxyCtrtCn.trim(),
});
} finally {
setIsSubmitting(false);
}
};
return (
<Dialog open={open} onOpenChange={handleDialogOpenChange}>
<DialogContent className="max-w-[48rem]" aria-describedby={undefined}>
<DialogHeader>
<DialogTitle>저장 정보 입력</DialogTitle>
</DialogHeader>
<div className="flex flex-col gap-4 py-2">
<div className="flex flex-col gap-2">
<Label className="text-label">위탁업체명</Label>
<Input
value={prxyBzentyNmCn}
onChange={(e) => setPrxyBzentyNmCn(e.target.value)}
className="!text-[1.3rem]"
placeholder="위탁사업자명을 입력하세요."
disabled={isSubmitting}
/>
</div>
<div className="flex flex-col gap-2">
<Label className="text-label">위탁비용(백만원/년)</Label>
<Input
type="text"
inputMode="decimal"
value={cnsgnCstInput}
onChange={(e) => {
const v = e.target.value.replace(/[^\d.]/g, "");
const firstDot = v.indexOf(".");
const next =
firstDot === -1
? v
: v.slice(0, firstDot + 1) + v.slice(firstDot + 1).replace(/\./g, "");
setCnsgnCstInput(next);
}}
className="!text-[1.3rem]"
placeholder="0"
disabled={isSubmitting}
/>
</div>
<div className="flex flex-col gap-2">
<Label className="text-label">전체 인원</Label>
<Input
type="text"
inputMode="numeric"
value={wholNopeInput}
onChange={(e) => {
const v = e.target.value.replace(/\D/g, "");
setWholNopeInput(v);
}}
className="!text-[1.3rem]"
placeholder="0"
disabled={isSubmitting}
/>
</div>
<div className="flex flex-col gap-2">
<Label className="text-label">위탁계약기간</Label>
<Input
value={prxyCtrtCn}
onChange={(e) => setPrxyCtrtCn(e.target.value)}
className="!text-[1.3rem]"
placeholder="YYYYMMDD~YYYYMMDD (예: 20240101~20281231)"
autoComplete="off"
disabled={isSubmitting}
/>
</div>
</div>
<DialogFooter>
<DialogClose asChild>
<Button type="button" color="gray" disabled={isSubmitting}>
취소
</Button>
</DialogClose>
<Button
type="button"
color="primary"
disabled={isSubmitting}
onClick={() => void handleConfirm()}
>
저장
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
);
}
@@ -2,7 +2,7 @@ import { useRef, useState } from 'react';
import { useConfirm } from '@frontend-opensource/use-react-hooks';
import { useAlertStore } from '@/shared/lib';
import { useAlertStore, useGlobalProgressStore } 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';
@@ -25,6 +25,7 @@ import { LoadingWithAnimation, Tabs, TabsContent, TabsList, TabsTrigger } from '
import { getUserInfo } from '../../../../../../packages/common/components/src/api';
import { MainFacilityMappingSaveDialog } from './MainFacilityMappingSaveDialog';
import SubmitDailog from './SubmitDailog';
import CancelSubmitButton from '@/components/button/CancelSubmitButton';
@@ -32,9 +33,12 @@ import NotApplicableCheckButton from '@/components/button/NotApplicableCheckButt
import SaveButton from '@/components/button/SaveButton';
import SubmitButton from '@/components/button/SumitButton';
import UpdateOperateDataButton from '@/components/button/UpdateOperateDataButton';
import VerificationResultButton from '@/components/button/VerificationResultButton';
import DataGrid from '@/components/module/DataGrid';
import SearchForm from '@/components/module/SearchForm';
import SplitDataGrid from '@/components/module/SplitDataGrid';
import { VerificationResultDialog } from '@/components/module/VerificationResultDialog';
import { VerificationResultHistoryDialog } from '@/components/module/VerificationResultHistoryDialog';
import type { CellStyleCallbackParams, ColumnInfo, ColumnInfoList } from '@/components/type/DataGrid.type';
import type { SearchFormValues } from '@/components/type/SearchForm.type';
import { doList } from '@/components/util/column/input/DoList';
@@ -95,12 +99,14 @@ async function waitForImrJobCompleted(
options?: {
pollIntervalMs?: number;
maxAttempts?: number;
onProgress?: (jobProgress: number) => void;
},
): Promise<void> {
const pollIntervalMs = options?.pollIntervalMs ?? 2000;
const maxAttempts = options?.maxAttempts ?? 150;
for (let i = 0; i < maxAttempts; i++) {
const res = await getStatus(jobId);
options?.onProgress?.(Math.max(0, Math.min(100, Number(res.job_prog) || 0)));
if (res.status === 'COMPLETED' && Number(res.job_prog) === 100) return;
if (res.status === 'FAILED' || res.status === 'ERROR' || res.status === 'CANCELLED') {
throw new Error(res.error_msg || `IMR 검증이 ${res.status} 상태로 종료되었습니다.`);
@@ -110,11 +116,31 @@ async function waitForImrJobCompleted(
throw new Error('IMR 검증 완료 대기 시간이 초과되었습니다.');
}
type MainFacilityMappingSaveDraft = {
statsYr: string;
sewerBzmnCd: string;
admdstCd: string;
admdstNm: string;
mainFcltSn: string;
mainFcltNm: string;
initialCnsgnCstVl: number;
saveList: PublicSewageMainFacilityMappingSaveItem[];
deleteList: string[];
initialPrxyBzentyNmCn: string;
initialWholNope: number;
initialPrxyCtrtCn: string;
};
// 하수도 통계 관리 > 06. 공공하수처리시설
const PublicSewageFacilityInp = () => {
const { setMessage: alert } = useAlertStore((state) => state);
const { confirm } = useConfirm();
const { isAdminInfo } = useAdminStore();
const showGlobalProgress = useGlobalProgressStore((state) => state.show);
const setGlobalProgress = useGlobalProgressStore((state) => state.setProgress);
const setGlobalProgressLabel = useGlobalProgressStore((state) => state.setLabel);
const hideGlobalProgress = useGlobalProgressStore((state) => state.hide);
const globalProgressVisible = useGlobalProgressStore((state) => state.visible);
const [isLoading, setIsLoading] = useState<boolean>(false);
const [hasSearched, setHasSearched] = useState(false);
const [isNotApplicable, setIsNotApplicable] = useState(false);
@@ -128,9 +154,16 @@ const PublicSewageFacilityInp = () => {
const [periodCheckList, setPeriodCheckList] = useState<CheckInputPeriodItem[]>([]);
const [showDailog, setShowDailog] = useState<boolean>(false);
const [selectedMainFacilitySn, setSelectedMainFacilitySn] = useState<string | null>(null);
const mainFacilityMappingSaveDraftRef = useRef<MainFacilityMappingSaveDraft | null>(null);
const [mainFacilityMappingSaveDialogOpen, setMainFacilityMappingSaveDialogOpen] = useState(false);
const [mainFacilityMappingSaveDialogKey, setMainFacilityMappingSaveDialogKey] = useState(0);
const [verificationResultDialogOpen, setVerificationResultDialogOpen] = useState(false);
const [verificationHistoryOpen, setVerificationHistoryOpen] = useState(false);
const periodEnterableRef = useRef(true);
const searchDoneRef = useRef(false);
/** ST00060001만: RealGrid 편집값이 state에 없을 수 있어 저장/제출 시 그리드에서 직접 읽음 */
const st60001GridGetRowsRef = useRef<(() => Record<string, string | number>[]) | null>(null);
const submitFunc = () => {
void (async () => {
@@ -142,7 +175,8 @@ const PublicSewageFacilityInp = () => {
alert('먼저 조회를 진행해주세요.');
return;
}
const currentData = rowDataMap.get('ST00060001') ?? [];
const currentData =
st60001GridGetRowsRef.current?.() ?? rowDataMap.get('ST00060001') ?? [];
if (!currentData || currentData.length === 0) {
alert('제출할 데이터가 없습니다.');
return;
@@ -220,72 +254,109 @@ const PublicSewageFacilityInp = () => {
alert('먼저 조회를 진행해주세요.');
return;
}
const currentData = rowDataMap.get('ST00060001') ?? [];
const currentData =
st60001GridGetRowsRef.current?.() ?? rowDataMap.get('ST00060001') ?? [];
if (!currentData || currentData.length === 0) {
alert('제출할 데이터가 없습니다.');
return;
}
const userInfo = await getUserInfo();
const submitList = buildFacilitySaveList(currentData);
const activeTabStatus = tabStatusInfoMap.get(activeTab) ?? null;
const statsCdForTab = tabStatsCdMap.get(activeTab) ?? activeTab;
const requestData = {
statsYr: lastSearchedValues.year ?? '',
ctpvNm: lastSearchedValues.sido ?? '',
sggNm: lastSearchedValues.gugun === 'ALL' ? '' : lastSearchedValues.gugun ?? '',
sewerBzmnCd: lastSearchedValues.bsnmCd ?? '',
userId: userInfo.userUnqId,
noApplicable: getNoApplicableForRequest(),
laePicNm: submitInfo.laePicNm,
laeKbrdrJbgdNm: submitInfo.laeKbrdrJbgdNm,
laeKbrdrDeptNm: submitInfo.laeKbrdrDeptNm,
laePicTelNo: submitInfo.laePicTelNo,
laeIdfrNm: submitInfo.laeIdfrNm,
laeIdfrJbgdNm: submitInfo.laeIdfrJbgdNm,
laeIdfrDeptNm: submitInfo.laeIdfrDeptNm,
laeIdfrTelNo: submitInfo.laeIdfrTelNo,
submitList,
};
try {
setIsLoading(true);
const response = await statisticsInputPublicSewageFacilityApiService.submitPublicSewageFacility(requestData);
if (response.status === 'success') {
if (response.data?.finalStatus === '5') {
const imrParam = {
user_id: userInfo.userUnqId,
sewer_bzmn_cd: lastSearchedValues.bsnmCd ?? '',
stats_cd: statsCdForTab,
stats_yr: lastSearchedValues.year ?? '',
lae_sbmsn_stts_cd: activeTabStatus?.laeSbmsnSttsCd === '4' ? '4' : '2',
gen_verify_stts_cd: response.data?.nvpassed ? '1' : '2',
};
try {
const imrResponse = await statisticsApi.callImrService(imrParam);
if (imrResponse.job_id) {
await waitForImrJobCompleted(
imrResponse.job_id,
(id) => statisticsApi.getImrStatus(id),
);
}
alert('제출되었습니다.');
} catch {
alert('제출은 완료되었으나 IMR 검증 처리 중 오류가 발생했습니다.');
showGlobalProgress({ label: '서버에 요청 중...', progress: 0 });
const userInfo = await getUserInfo();
const submitList = buildFacilitySaveList(currentData);
const activeTabStatus = tabStatusInfoMap.get(activeTab) ?? null;
const statsCdForTab = tabStatsCdMap.get(activeTab) ?? activeTab;
const requestData = {
statsYr: lastSearchedValues.year ?? '',
ctpvNm: lastSearchedValues.sido ?? '',
sggNm: lastSearchedValues.gugun === 'ALL' ? '' : lastSearchedValues.gugun ?? '',
sewerBzmnCd: lastSearchedValues.bsnmCd ?? '',
userId: userInfo.userUnqId,
noApplicable: getNoApplicableForRequest(),
laePicNm: submitInfo.laePicNm,
laeKbrdrJbgdNm: submitInfo.laeKbrdrJbgdNm,
laeKbrdrDeptNm: submitInfo.laeKbrdrDeptNm,
laePicTelNo: submitInfo.laePicTelNo,
laeIdfrNm: submitInfo.laeIdfrNm,
laeIdfrJbgdNm: submitInfo.laeIdfrJbgdNm,
laeIdfrDeptNm: submitInfo.laeIdfrDeptNm,
laeIdfrTelNo: submitInfo.laeIdfrTelNo,
submitList,
};
setGlobalProgressLabel('일반 검증 진행 중...');
const response =
await statisticsInputPublicSewageFacilityApiService.submitPublicSewageFacility(
requestData,
);
if (response.status !== 'success') {
alert(response.message || '데이터 제출 중 오류가 발생했습니다.');
return;
}
const finalStatus = response.data?.finalStatus;
const nvpassed = response.data?.nvpassed;
if (finalStatus === '5') {
setGlobalProgress(50);
setGlobalProgressLabel('IMR 검증 진행 중...');
const imrParam = {
user_id: userInfo.userUnqId,
sewer_bzmn_cd: lastSearchedValues.bsnmCd ?? '',
stats_cd: statsCdForTab,
stats_yr: lastSearchedValues.year ?? '',
lae_sbmsn_stts_cd: activeTabStatus?.laeSbmsnSttsCd === '4' ? '4' : '2',
gen_verify_stts_cd: nvpassed ? '1' : '2',
};
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));
},
},
);
}
} else {
alert(response.message || '제출 처리 결과를 확인해주세요.');
setGlobalProgress(100);
await loadPublicSewageFacilityActiveTabData(
'ST00060001',
searchParamRef.current,
lastSearchedValues,
);
setVerificationResultDialogOpen(true);
} catch (e) {
await loadPublicSewageFacilityActiveTabData(
'ST00060001',
searchParamRef.current,
lastSearchedValues,
);
setVerificationResultDialogOpen(true);
const msg =
e instanceof Error && e.message
? e.message
: 'IMR 검증 중 오류가 발생했습니다.\n관리자에게 문의하세요.';
alert(msg);
}
} else {
setGlobalProgress(100);
await loadPublicSewageFacilityActiveTabData(
'ST00060001',
searchParamRef.current,
lastSearchedValues,
);
} else {
alert(response.message || '데이터 제출 중 오류가 발생했습니다.');
setVerificationResultDialogOpen(true);
}
} catch {
alert('데이터 제출 중 오류가 발생했습니다.');
} finally {
setIsLoading(false);
hideGlobalProgress();
}
};
@@ -296,14 +367,70 @@ const PublicSewageFacilityInp = () => {
return;
}
if (!(await confirm("저장 하시겠습니까?"))) return;
if (activeTab === 'ST00060002') {
if (!(await confirm('저장 하시겠습니까?'))) return;
try {
setIsLoading(true);
const userInfo = await getUserInfo();
const leftRows = rowDataMap.get('MainFacility') ?? [];
const rightRows = rowDataMap.get('MainFacilityTarget') ?? [];
if (!leftRows.length || !rightRows.length) {
alert('저장할 데이터가 없습니다.');
return;
}
if (!selectedMainFacilitySn) {
alert('좌측에서 중심 처리 시설을 먼저 선택해주세요.');
return;
}
if (activeTab === 'ST00060001') {
const currentData = rowDataMap.get('ST00060001') ?? [];
const selectedMain = leftRows.find((r) => String(r.fcltSn ?? '') === selectedMainFacilitySn);
if (!selectedMain) {
alert('선택된 중심 처리 시설 정보를 찾을 수 없습니다.');
return;
}
const saveList: PublicSewageMainFacilityMappingSaveItem[] = rightRows
.filter((r) => isCheckedRow(r))
.map((r) => ({
fcltSn: String(r.fcltSn ?? ''),
admdstCd: String(r.admdstCd ?? ''),
admdstNm: String(r.admdstNm ?? ''),
swtrmtFcltCd: String(r.swtrmtFcltCd ?? ''),
swtrmtFcltNm: String(r.swtrmtFcltNm ?? ''),
isMainYn: String(r.isMainYn ?? ''),
}));
const deleteList = rightRows
.filter((r) => String(r.mappedYn ?? '') === 'Y' && !isCheckedRow(r))
.map((r) => String(r.fcltSn ?? ''))
.filter((sn) => sn !== '');
const wholNopeFromRow = Number(selectedMain.wholNope ?? 0);
mainFacilityMappingSaveDraftRef.current = {
statsYr: lastSearchedValues.year ?? '',
sewerBzmnCd: lastSearchedValues.bsnmCd ?? '',
admdstCd: String(selectedMain.admdstCd ?? ''),
admdstNm: String(selectedMain.admdstNm ?? ''),
mainFcltSn: String(selectedMain.fcltSn ?? ''),
mainFcltNm: String(selectedMain.swtrmtFcltNm ?? ''),
initialCnsgnCstVl: Number(selectedMain.cnsgnCstVl ?? 0),
saveList,
deleteList,
initialPrxyBzentyNmCn: String(selectedMain.prxyBzentyNmCn ?? ''),
initialWholNope: Number.isFinite(wholNopeFromRow) ? wholNopeFromRow : 0,
initialPrxyCtrtCn: String(selectedMain.prxyCtrtCn ?? ''),
};
setMainFacilityMappingSaveDialogKey((k) => k + 1);
setMainFacilityMappingSaveDialogOpen(true);
return;
}
if (!(await confirm('저장 하시겠습니까?'))) return;
if (activeTab === 'ST00060001') {
try {
showGlobalProgress({ label: '저장 진행 중...', progress: 0 });
const userInfo = await getUserInfo();
const currentData =
st60001GridGetRowsRef.current?.() ?? rowDataMap.get('ST00060001') ?? [];
if (!currentData || currentData.length === 0) {
alert('저장할 데이터가 없습니다.');
return;
@@ -321,86 +448,31 @@ const PublicSewageFacilityInp = () => {
saveList,
};
const response = await statisticsInputPublicSewageFacilityApiService.savePublicSewageFacility(param);
const response =
await statisticsInputPublicSewageFacilityApiService.savePublicSewageFacility(
param,
);
if (response.status === 'success') {
alert('저장되었습니다.');
setGlobalProgress(50);
await loadPublicSewageFacilityActiveTabData(
'ST00060001',
searchParamRef.current,
lastSearchedValues,
);
} else {
alert(response.message || '데이터 저장 중 오류가 발생했습니다.');
}
return;
}
if (activeTab === 'ST00060002') {
const leftRows = rowDataMap.get('MainFacility') ?? [];
const rightRows = rowDataMap.get('MainFacilityTarget') ?? [];
if (!leftRows.length || !rightRows.length) {
alert('저장할 데이터가 없습니다.');
return;
}
if (!selectedMainFacilitySn) {
alert('좌측에서 중심 처리 시설을 먼저 선택해주세요.');
return;
}
const selectedMain = leftRows.find((r) => String(r.fcltSn ?? '') === selectedMainFacilitySn);
if (!selectedMain) {
alert('선택된 중심 처리 시설 정보를 찾을 수 없습니다.');
return;
}
const saveList: PublicSewageMainFacilityMappingSaveItem[] = rightRows
.filter((r) => isCheckedRow(r))
.map((r) => ({
fcltSn: String(r.fcltSn ?? ''),
admdstCd: String(r.admdstCd ?? ''),
admdstNm: String(r.admdstNm ?? ''),
swtrmtFcltCd: String(r.swtrmtFcltCd ?? ''),
swtrmtFcltNm: String(r.swtrmtFcltNm ?? ''),
isMainYn: String(r.isMainYn ?? ''),
}));
const deleteList = rightRows
.filter((r) => String(r.mappedYn ?? '') === 'Y' && !isCheckedRow(r))
.map((r) => String(r.fcltSn ?? ''))
.filter((sn) => sn !== '');
const param: PublicSewageMainFacilityMappingSaveRequest = {
statsYr: lastSearchedValues.year ?? '',
sewerBzmnCd: lastSearchedValues.bsnmCd ?? '',
mainFcltSn: String(selectedMain.fcltSn ?? ''),
mainFcltNm: String(selectedMain.swtrmtFcltNm ?? ''),
prxyBzentyNmCn: String(selectedMain.prxyBzentyNmCn ?? ''),
cnsgnCstVl: Number(selectedMain.cnsgnCstVl ?? 0),
wholNope: Number(selectedMain.wholNope ?? 0),
prxyCtrtCn: String(selectedMain.prxyCtrtCn ?? ''),
userId: userInfo.userUnqId,
saveList,
deleteList,
};
const response = await statisticsInputPublicSewageFacilityApiService.saveMainFacilityMapping(param);
if (response.status === 'success') {
setGlobalProgress(100);
alert('저장되었습니다.');
await loadPublicSewageFacilityActiveTabData(
'ST00060002',
searchParamRef.current,
lastSearchedValues,
);
} else {
alert(response.message || '데이터 저장 중 오류가 발생했습니다.');
}
return;
} catch {
alert('데이터 저장 중 오류가 발생했습니다.');
} finally {
hideGlobalProgress();
}
alert('현재 탭 저장 API 연동 전입니다.');
} finally {
setIsLoading(false);
return;
}
alert('현재 탭 저장 API 연동 전입니다.');
})();
};
@@ -558,6 +630,59 @@ 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;
}
try {
showGlobalProgress({ label: '저장 진행 중...', progress: 0 });
const userInfo = await getUserInfo();
const param: PublicSewageMainFacilityMappingSaveRequest = {
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 response =
await statisticsInputPublicSewageFacilityApiService.saveMainFacilityMapping(param);
if (response.status === 'success') {
setGlobalProgress(50);
setMainFacilityMappingSaveDialogOpen(false);
mainFacilityMappingSaveDraftRef.current = null;
await loadPublicSewageFacilityActiveTabData(
'ST00060002',
searchParamRef.current,
lastSearchedValues,
);
setGlobalProgress(100);
alert('저장되었습니다.');
} else {
alert(response.message || '데이터 저장 중 오류가 발생했습니다.');
}
} catch {
alert('데이터 저장 중 오류가 발생했습니다.');
} finally {
hideGlobalProgress();
}
};
const handleSearch = async (values: SearchFormValues) => {
if (!values.sido || values.sido === 'ALL') {
alert('시도를 선택해주세요.');
@@ -754,6 +879,30 @@ const PublicSewageFacilityInp = () => {
const { showSaveInToolbar, showEnterableToolbarButtons, showActionToolbarCard } = activeTabToolbar;
const isMainFacilityTab = activeTab === 'ST00060002';
const openVerificationHistoryDialog = () => {
const ctx = lastSearchedValues ?? searchValues;
const activeTabStatsCd = tabStatsCdMap.get(activeTab) ?? activeTab;
if (!ctx?.year || !ctx?.sido || !ctx?.bsnmCd || !activeTabStatsCd) {
alert('먼저 조회를 진행해주세요.');
return;
}
setVerificationHistoryOpen(true);
};
const verificationQueryContext =
(() => {
const ctx = lastSearchedValues ?? searchValues;
const statsCd = tabStatsCdMap.get(activeTab) ?? activeTab;
if (!ctx?.year || !ctx?.sido || !ctx?.bsnmCd || !statsCd) return null;
return {
sewerBzmnCd: ctx.bsnmCd,
ctpvNm: ctx.sido,
sggNm: ctx.gugun === 'ALL' ? '' : (ctx.gugun ?? ''),
statsYr: ctx.year,
statsCd,
};
})();
return (
<>
<div className="relative">
@@ -766,6 +915,9 @@ const PublicSewageFacilityInp = () => {
{showActionToolbarCard && (
<div className="card !py-0">
<div className="flex justify-end items-center gap-[0.6rem] mx-[0.6rem]">
<VerificationResultButton
VerificationResultButtonFunc={openVerificationHistoryDialog}
/>
{showEnterableToolbarButtons && !isMainFacilityTab && (
<>
{showIsNotApplicable && <NotApplicableCheckButton isCheck={isNotApplicable} setIsCheck={setIsNotApplicable} />}
@@ -780,12 +932,13 @@ const PublicSewageFacilityInp = () => {
</div>
</div>
)}
{isLoading && <div className="absolute inset-0 z-50 [data-theme='black']:bg-transparent rounded-lg p-10 flex justify-center items-center">
<div className="relative size-[5rem]">
<LoadingWithAnimation color="primary" />
{isLoading && !globalProgressVisible && (
<div className="absolute inset-0 z-50 [data-theme='black']:bg-transparent rounded-lg p-10 flex justify-center items-center">
<div className="relative size-[5rem]">
<LoadingWithAnimation color="primary" />
</div>
</div>
</div>
}
)}
<Tabs value={activeTab} onValueChange={(v) => void handleActiveTabChange(v)}>
<TabsList>
{PublicSewageFactInpColumnList.map((tab) => (
@@ -904,6 +1057,9 @@ const PublicSewageFacilityInp = () => {
return next;
});
}}
getGridDataRef={
tab.tabKey === 'ST00060001' ? st60001GridGetRowsRef : undefined
}
showHeaderCheckbox={false}
enableSort={false}
/>
@@ -912,6 +1068,11 @@ const PublicSewageFacilityInp = () => {
})}
</Tabs>
</div>
<VerificationResultHistoryDialog
open={verificationHistoryOpen}
onOpenChange={setVerificationHistoryOpen}
queryContext={verificationQueryContext}
/>
<SubmitDailog
open={showDailog}
onOpenChange={setShowDailog}
@@ -920,6 +1081,26 @@ const PublicSewageFacilityInp = () => {
submitMode="normal"
isLoading={isLoading}
/>
<VerificationResultDialog
open={verificationResultDialogOpen}
onOpenChange={setVerificationResultDialogOpen}
queryContext={verificationQueryContext}
/>
<MainFacilityMappingSaveDialog
key={`main-facility-mapping-save-${mainFacilityMappingSaveDialogKey}`}
open={mainFacilityMappingSaveDialogOpen}
onOpenChange={(open) => {
setMainFacilityMappingSaveDialogOpen(open);
if (!open) {
mainFacilityMappingSaveDraftRef.current = null;
}
}}
initialPrxyBzentyNmCn={mainFacilityMappingSaveDraftRef.current?.initialPrxyBzentyNmCn ?? ''}
initialCnsgnCstVl={mainFacilityMappingSaveDraftRef.current?.initialCnsgnCstVl ?? 0}
initialWholNope={mainFacilityMappingSaveDraftRef.current?.initialWholNope ?? 0}
initialPrxyCtrtCn={mainFacilityMappingSaveDraftRef.current?.initialPrxyCtrtCn ?? ''}
onConfirm={handleMainFacilityMappingSaveConfirm}
/>
</>
);
};
@@ -1,71 +1,74 @@
import { useState, useMemo, useRef, useEffect, useLayoutEffect } from "react";
import { useEffect, useLayoutEffect,useMemo, useRef, useState } from "react";
import SearchForm from "@/components/module/SearchForm";
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 VerificationResultButton from "@/components/button/VerificationResultButton";
import CancelSubmitButton from "@/components/button/CancelSubmitButton";
import { VerificationResultHistoryDialog } from "@/components/module/VerificationResultHistoryDialog";
import { VerificationResultDialog } from "@/components/module/VerificationResultDialog";
import SubmitDailog from "./SubmitDailog";
import { useConfirm } from "@frontend-opensource/use-react-hooks";
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, useGlobalProgressStore } from "@/shared/lib";
import {
STATISTICS_INPUT_URL_PATH,
verificationSearchToFormValues,
} from "@/shared/lib/stats-cd-input-navigation";
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 { statisticsApi } from "@/entities/statistics/api/statistics-service";
import type {
SeweragePipeInputFactInstallStatusSaveRequest,
SeweragePipeInputFactInstallStatusSubmitRequest,
SeweragePipeInputManholeStatusItem,
SeweragePipeInputManholeStatusSaveYearlyStatItem,
SeweragePipeInputManholeStatusSaveRequest,
SeweragePipeInputManholeStatusSaveYearlyStatItem,
SeweragePipeInputManholeStatusSubmitRequest,
SeweragePipeInputPipeDiameterStatusSaveRequest,
SeweragePipeInputPipeDiameterStatusSubmitRequest,
SeweragePipeInputPipeTypeStatusItem,
SeweragePipeInputPipeTypeStatusSaveRequest,
SeweragePipeInputPipeTypeStatusSubmitRequest,
SeweragePipeInputRegionalFactStatusSaveRequest,
SeweragePipeInputRegionalFactStatusSaveItem,
SeweragePipeInputRegionalFactStatusSaveRequest,
SeweragePipeInputRegionalFactStatusSubmitRequest,
SeweragePipeInputSubmitResponse,
SeweragePipeInputVerificationResult,
SeweragePipeRequestParams,
} from "@/entities/statistics/model/input/statistics-sewage-pipe-input-interface";
import {
type CheckInputPeriodItem,
getVerificationPeriodActionBlockedMessage,
isInputPeriodCurrent,
} from "@/entities/statistics/model/input/statistics-sewage-supply-rate-input-interface";
import type { StatisticsInputSubmitInfoFormData } from "@/entities/statistics/model/statistics-input-submit-info-schema";
import type {
cancelSubmitRequest,
ImrStatusResponse,
SewerageStatusInfo,
SubmitExceptionRequest,
} from "@/entities/statistics/model/statistics-interface";
import type { StatisticsInputSubmitInfoFormData } from "@/entities/statistics/model/statistics-input-submit-info-schema";
import {
type CheckInputPeriodItem,
getVerificationPeriodActionBlockedMessage,
isInputPeriodCurrent,
} from "@/entities/statistics/model/input/statistics-sewage-supply-rate-input-interface";
import { LoadingWithAnimation, Tabs, TabsContent, TabsList, TabsTrigger } from "@common/components";
import { getUserInfo } from "../../../../../../packages/common/components/src/api";
import SubmitDailog from "./SubmitDailog";
import CancelSubmitButton from "@/components/button/CancelSubmitButton";
import LastYearDataButton from "@/components/button/LastYearDataButton";
import SaveButton from "@/components/button/SaveButton";
import SubmitButton from "@/components/button/SumitButton";
import VerificationResultButton from "@/components/button/VerificationResultButton";
import DataGrid from "@/components/module/DataGrid";
import GridTopActionButton from "@/components/module/GridTopActionButton";
import SearchForm from "@/components/module/SearchForm";
import SplitDataGrid from "@/components/module/SplitDataGrid";
import { VerificationResultDialog } from "@/components/module/VerificationResultDialog";
import { VerificationResultHistoryDialog } from "@/components/module/VerificationResultHistoryDialog";
import type { ColumnConfig,ColumnInfo, ColumnInfoList } from "@/components/type/DataGrid.type";
import type { SearchFormValues } from "@/components/type/SearchForm.type";
import { doList } from "@/components/util/column/input/DoList";
import { FactInstallStatusInpColumn } from "@/components/util/column/input/sewagePipe/FactInstallStatusInpColumn";
import { ManholeStatusInpColumn } from "@/components/util/column/input/sewagePipe/ManholeStatusInpColumn";
import { PipeDiameterStatusInpColumn } from "@/components/util/column/input/sewagePipe/PipeDiameterStatusInpColumn";
import { PipeTypeStatusInpColumn } from "@/components/util/column/input/sewagePipe/PipeTypeStatusInpColumn";
import { RegionalFactStatusInpColumn } from "@/components/util/column/input/sewagePipe/RegionalFactStatusInpColumn";
import { SewagePipeInpColumnInfoList } from "@/components/util/column/input/sewagePipe/SewagePipeInpColumn";
import { toGridRows } from "@/components/util/grid/gridFunction";
import { LoadingWithAnimation, Tabs, TabsContent, TabsList, TabsTrigger } from "@common/components";
import { getUserInfo } from "../../../../../../packages/common/components/src/api";
import { statisticsApi } from "@/entities/statistics/api/statistics-service";
import { useAdminStore } from "@/store/admin-store";
import { useStatisticsInputFocusStore } from "@/store/statistics-input-focus-store";
import { useTabStore } from "@/store/tab-store";
@@ -740,7 +743,7 @@ const SewagePipeInp = () => {
if (!job) return;
postVerificationSearchRef.current = null;
void handleSearch(job.values, { forcedTabKey: job.tabKey });
// eslint-disable-next-line react-hooks/exhaustive-deps -- 검색폼 remount 직후 1회 자동 조회
}, [searchFormRemountKey]);
const handleActiveTabChange = async (tabKey: string) => {
@@ -1209,6 +1212,7 @@ const SewagePipeInp = () => {
true,
false,
);
setVerificationResultDialogOpen(true);
const msg =
e instanceof Error && e.message
? e.message
@@ -1224,7 +1228,7 @@ const SewagePipeInp = () => {
true,
false,
);
alert("제출되었습니다.");
setVerificationResultDialogOpen(true);
}
} catch {
alert("데이터 제출 중 오류가 발생했습니다.");
@@ -1,52 +1,54 @@
import { useState, useMemo, useRef, useLayoutEffect, useEffect } from "react";
import { useEffect,useLayoutEffect, useMemo, useRef, useState } from "react";
import SearchForm from "@/components/module/SearchForm";
// import { DataGrid } from "@rsone/components";
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 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 { useConfirm } from "@frontend-opensource/use-react-hooks";
import { useAlertStore, useGlobalProgressStore } from "@/shared/lib";
import {
STATISTICS_INPUT_URL_PATH,
verificationSearchToFormValues,
} from "@/shared/lib/stats-cd-input-navigation";
import { useAdminStore } from "@/store/admin-store";
import { ConfirmDialog } from "@/shared/ui/confirm-dialog";
import { statisticsInputSewSupplyRateApi } from "@/entities/statistics/api/input/statistics-input-sew-supply-rate-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";
import { useConfirm } from "@frontend-opensource/use-react-hooks";
import type { SearchFormValues } from "@/components/type/SearchForm.type";
import {
type CheckInputPeriodItem,
getVerificationPeriodActionBlockedMessage,
isInputPeriodCurrent,
type SewerageSupplyRateItem,
type SewerageSupplyRateRequestParams,
type SewerageSupplyRateSaveItem,
type SewerageSupplyRateVerificationResult,
type SewerageSupplyRateSaveRequest,
getVerificationPeriodActionBlockedMessage,
isInputPeriodCurrent,
type SewerageSupplyRateVerificationResult,
} from "@/entities/statistics/model/input/statistics-sewage-supply-rate-input-interface";
import type { StatisticsInputSubmitInfoFormData } from "@/entities/statistics/model/statistics-input-submit-info-schema";
import type {
cancelSubmitRequest,
ImrStatusResponse,
SewerageStatusInfo,
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 { LoadingWithAnimation } from "@common/components";
import { getUserInfo } from "../../../../../../packages/common/components/src/api";
import SubmitDailog from "./SubmitDailog";
import CancelSubmitButton from "@/components/button/CancelSubmitButton";
import LastYearDataButton from "@/components/button/LastYearDataButton";
import SaveButton from "@/components/button/SaveButton";
import SubmitButton from "@/components/button/SumitButton";
import VerificationResultButton from "@/components/button/VerificationResultButton";
// import { DataGrid } from "@rsone/components";
import DataGrid from "@/components/module/DataGrid";
import SearchForm from "@/components/module/SearchForm";
import { VerificationResultDialog } from "@/components/module/VerificationResultDialog";
import { VerificationResultHistoryDialog } from "@/components/module/VerificationResultHistoryDialog";
import type { SearchFormValues } from "@/components/type/SearchForm.type";
import { doList } from "@/components/util/column/input/DoList";
import { SeweragePenetrationRateInpColumn } from "@/components/util/column/input/SeweragePenetrationRateInpColumn";
import { getFooterSummary } from "@/components/util/grid/footerSummary";
import { toGridRows } from "@/components/util/grid/gridFunction";
import { useAdminStore } from "@/store/admin-store";
import { useStatisticsInputFocusStore } from "@/store/statistics-input-focus-store";
import { useTabStore } from "@/store/tab-store";
@@ -274,7 +276,7 @@ const SeweragePenetrationRateInp = () => {
if (!values) return;
postVerificationSearchRef.current = null;
void handleSearch(values);
// eslint-disable-next-line react-hooks/exhaustive-deps -- 검색폼 remount 직후 1회 자동 조회
}, [searchFormRemountKey]);
const SubmitExceptionFunc = async (submitInfo: StatisticsInputSubmitInfoFormData) => {
@@ -475,6 +477,7 @@ const SeweragePenetrationRateInp = () => {
} catch (e) {
await fetchSewerageSupplyRate(getSewerageSupplyRateParams(searchValues ?? {}), true);
setIsLoading(false);
setVerificationResultDialogOpen(true);
const msg =
e instanceof Error && e.message
? e.message
@@ -485,7 +488,7 @@ const SeweragePenetrationRateInp = () => {
} else {
setGlobalProgress(100);
await fetchSewerageSupplyRate(getSewerageSupplyRateParams(searchValues ?? {}), false);
alert('제출되었습니다.');
setVerificationResultDialogOpen(true);
}
} else {
alert(response.message || '데이터 제출 중 오류가 발생했습니다.');
@@ -1,84 +1,230 @@
import { useState } from "react";
import { useRef, useState } from "react";
import { useAlertStore } from "@/shared/lib";
import { statisticsApi } from "@/entities/statistics/api/statistics-service";
import type {
StatsInputPeriodItem,
StatsInputPeriodListRequest,
} from "@/entities/statistics/model/statistics-interface";
import { Button, LoadingWithAnimation } from "@common/components";
import {
StatisticsInputPeriodDialog,
type StatisticsInputPeriodDialogValues,
} from "./StatisticsInputPeriodDialog";
import DataGrid from "@/components/module/DataGrid";
import SearchForm from "@/components/module/SearchForm";
import type { ColumnConfig } from "@/components/type/DataGrid.type";
import type { SearchFormValues } from "@/components/type/SearchForm.type";
import { toGridRows } from "@/components/util/grid/gridFunction";
function seTypeToDisplayLabel(seType: StatsInputPeriodItem["seType"]): string {
if (seType === "INPT") return "입력";
if (seType === "VRFC") return "검증";
return seType;
}
function rowSeTypeToCode(row: Record<string, string | number>): "INPT" | "VRFC" {
const v = String(row.seType ?? "");
if (v === "INPT" || v === "입력") return "INPT";
if (v === "VRFC" || v === "검증") return "VRFC";
return "INPT";
}
/** API `YYYYMMDD` 등 → Date */
function parseYmdToDate(ymd: unknown): Date | undefined {
const s = String(ymd ?? "").replace(/\D/g, "");
if (s.length !== 8) return undefined;
const y = Number(s.slice(0, 4));
const m = Number(s.slice(4, 6));
const d = Number(s.slice(6, 8));
if (!y || m < 1 || m > 12 || d < 1 || d > 31) return undefined;
const dt = new Date(y, m - 1, d);
if (dt.getFullYear() !== y || dt.getMonth() !== m - 1 || dt.getDate() !== d) {
return undefined;
}
return dt;
}
const StatisticsInputManagement = () => {
const { setMessage: alert } = useAlertStore((state) => state);
const [isLoading, setIsLoading] = useState(false);
const [rowData, setRowData] = useState<Record<string, string | number>[]>(
[],
);
const [periodDialogOpen, setPeriodDialogOpen] = useState(false);
const [periodDialogMode, setPeriodDialogMode] = useState<"edit" | "create">("edit");
const [createDefaultStatsYr, setCreateDefaultStatsYr] = useState("");
const [periodDialogInitial, setPeriodDialogInitial] =
useState<StatisticsInputPeriodDialogValues | null>(null);
const [searchFormValues, setSearchFormValues] = useState<SearchFormValues | null>(null);
const lastSearchRef = useRef<SearchFormValues | null>(null);
const handleSearch = async (values: SearchFormValues) => {
if (!values.year) {
alert("년도를 선택해주세요.");
return;
}
lastSearchRef.current = values;
try {
setIsLoading(true);
const params: StatsInputPeriodListRequest = {
statsYr: values.year,
};
if (values.clsfCd && values.clsfCd !== "ALL") {
params.seType = values.clsfCd;
}
const res = await statisticsApi.getStatsInputPeriodList(params);
if (res.status === "success" && Array.isArray(res.data)) {
const rowsForGrid = res.data.map((row) => ({
...row,
seType: seTypeToDisplayLabel(row.seType),
}));
setRowData(toGridRows(rowsForGrid));
} else {
setRowData([]);
alert(res.message || "데이터 조회에 실패했습니다.");
}
} catch {
setRowData([]);
alert("데이터 조회 중 오류가 발생했습니다.");
} finally {
setIsLoading(false);
}
};
const handleReset = () => {
setRowData([]);
};
const handleRegisterClick = () => {
const yr =
searchFormValues?.year ?? lastSearchRef.current?.year ?? "";
if (!yr) {
alert("검색 폼에서 년도를 선택해 주세요.");
return;
}
setPeriodDialogMode("create");
setCreateDefaultStatsYr(yr);
setPeriodDialogInitial(null);
setPeriodDialogOpen(true);
};
const handleRowClick = (row: Record<string, string | number>) => {
const seCdRaw = row.seCd;
const seCdNum =
typeof seCdRaw === "number"
? seCdRaw
: parseInt(String(seCdRaw ?? ""), 10);
setPeriodDialogMode("edit");
setPeriodDialogInitial({
statsYr: String(row.statsYr ?? ""),
seType: rowSeTypeToCode(row),
seCd: Number.isFinite(seCdNum) ? seCdNum : 0,
inptBgngYmd: parseYmdToDate(row.inptBgngYmd),
inptEndYmd: parseYmdToDate(row.inptEndYmd),
});
setPeriodDialogOpen(true);
};
const columns: ColumnConfig[] = [
{
name: "sido",
fieldName: "시도",
header: { text: "시도" },
mergeCells: true,
name: "statsYr",
fieldName: "statsYr",
header: { text: "연도" },
editable: false,
sortdisable: true,
},
{
name: "sgg",
fieldName: "시군구",
header: { text: "시군구" },
mergeCells: true,
name: "seType",
fieldName: "seType",
header: { text: "분류" },
editable: false,
sortdisable: true,
},
{
name: "구분",
fieldName: "구분",
name: "seCd",
fieldName: "seCd",
header: { text: "구분" },
editable: false,
sortdisable: true,
},
{
name: "광역자치단체",
fieldName: "광역자치단체",
header: { text: "광역자치단체" },
name: "inptBgngYmd",
fieldName: "inptBgngYmd",
header: { text: "시작일" },
editable: false,
sortdisable: true,
},
{
name: "공단",
fieldName: "공단",
header: { text: "공단" },
name: "inptEndYmd",
fieldName: "inptEndYmd",
header: { text: "종료일" },
editable: false,
sortdisable: true,
},
{
name: "처리내역",
fieldName: "처리내역",
header: { text: "처리내역" },
editable: false,
sortdisable: true,
},
];
const columnLayout = [
"sido",
"sgg",
"구분",
{
name: "groupA",
hideChildHeaders: false,
header: { text: "검토내역" },
items: ["광역자치단체", "공단", "처리내역"],
},
"statsYr",
"seType",
"seCd",
"inptBgngYmd",
"inptEndYmd",
];
return (
<>
<SearchForm sido={false} />
<div className="relative">
<SearchForm
sido={false}
gugun={false}
clsf
onSubmit={handleSearch}
onReset={handleReset}
onValuesChange={setSearchFormValues}
/>
<div className="card !py-0">
<div className="flex justify-end items-center gap-[0.6rem] mx-[0.6rem]">
<Button
color="primary"
className="h-[3.2rem] w-[96px]"
type="button"
onClick={handleRegisterClick}
>
<span>등록</span>
</Button>
</div>
</div>
<DataGrid
columnInfo={{ columns, columnLayout }}
rowData={rowData}
enableSort={false}
setRowData={setRowData}
isExcelImport={false}
isExcelExport={false}
onRowClick={handleRowClick}
/>
</>
<StatisticsInputPeriodDialog
open={periodDialogOpen}
mode={periodDialogMode}
createDefaultStatsYr={createDefaultStatsYr}
onOpenChange={(open) => {
setPeriodDialogOpen(open);
if (!open) {
setPeriodDialogInitial(null);
setPeriodDialogMode("edit");
}
}}
initial={periodDialogInitial}
onSaveSuccess={async () => {
const v = lastSearchRef.current;
if (v) await handleSearch(v);
}}
/>
{isLoading && (
<div className="absolute inset-0 z-50 [data-theme='black']:bg-transparent rounded-lg p-10 flex justify-center items-center">
<div className="relative size-[5rem]">
<LoadingWithAnimation color="primary" />
</div>
</div>
)}
</div>
);
};
@@ -0,0 +1,315 @@
"use client";
import { useEffect, useState } from "react";
import { useConfirm } from "@frontend-opensource/use-react-hooks";
import { useAlertStore } from "@/shared/lib";
import { statisticsApi } from "@/entities/statistics/api/statistics-service";
import type {
StatsInputPeriodDeleteRequest,
StatsInputPeriodSaveRequest,
} from "@/entities/statistics/model/statistics-interface";
import {
Button,
Dialog,
DialogClose,
DialogContent,
DialogFooter,
DialogHeader,
DialogTitle,
Input,
InputDatePicker,
Label,
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from "@common/components";
import { getUserInfo } from "../../../../../../packages/common/components/src/api";
export type StatisticsInputPeriodDialogValues = {
statsYr: string;
seType: "INPT" | "VRFC";
seCd: number;
inptBgngYmd: Date | undefined;
inptEndYmd: Date | undefined;
};
export type StatisticsInputPeriodDialogMode = "edit" | "create";
function normalizeStatsYrInput(raw: string): string {
return raw.replace(/\D/g, "").slice(0, 4);
}
function formatDateToYmd(d: Date | undefined): string | null {
if (!d || Number.isNaN(d.getTime())) return null;
const y = d.getFullYear();
const m = String(d.getMonth() + 1).padStart(2, "0");
const day = String(d.getDate()).padStart(2, "0");
return `${y}${m}${day}`;
}
export interface StatisticsInputPeriodDialogProps {
open: boolean;
onOpenChange: (open: boolean) => void;
/** 행 클릭(수정) 시에만 전달. 등록 모드에서는 null */
initial: StatisticsInputPeriodDialogValues | null;
mode: StatisticsInputPeriodDialogMode;
/** mode가 create일 때 SearchForm의 연도 */
createDefaultStatsYr?: string;
/** 저장 성공 후 목록 재조회 등 */
onSaveSuccess?: () => void | Promise<void>;
}
export function StatisticsInputPeriodDialog({
open,
onOpenChange,
initial,
mode,
createDefaultStatsYr,
onSaveSuccess,
}: StatisticsInputPeriodDialogProps) {
const { setMessage: alert } = useAlertStore((state) => state);
const { confirm } = useConfirm();
const [statsYr, setStatsYr] = useState("");
const [seType, setSeType] = useState<"INPT" | "VRFC" | null>(null);
const [seCdInput, setSeCdInput] = useState("");
const [inptBgngYmd, setInptBgngYmd] = useState<Date | undefined>(undefined);
const [inptEndYmd, setInptEndYmd] = useState<Date | undefined>(undefined);
const [isSubmitting, setIsSubmitting] = useState(false);
useEffect(() => {
if (!open) return;
if (mode === "create") {
setStatsYr(normalizeStatsYrInput(createDefaultStatsYr ?? ""));
setSeType(null);
setSeCdInput("0");
setInptBgngYmd(undefined);
setInptEndYmd(undefined);
return;
}
if (!initial) return;
setStatsYr(normalizeStatsYrInput(String(initial.statsYr ?? "")));
setSeType(initial.seType);
setSeCdInput(String(Number.isFinite(initial.seCd) ? initial.seCd : 0));
setInptBgngYmd(initial.inptBgngYmd);
setInptEndYmd(initial.inptEndYmd);
}, [open, mode, initial, createDefaultStatsYr]);
const handleSave = async () => {
const bgng = formatDateToYmd(inptBgngYmd);
const end = formatDateToYmd(inptEndYmd);
if (!bgng || !end) {
alert("시작일과 종료일을 선택해주세요.");
return;
}
if (bgng > end) {
alert("시작일은 종료일보다 늦을 수 없습니다.");
return;
}
if (seType === null) {
alert("분류를 선택해주세요.");
return;
}
const seCdParsed =
mode === "create"
? 0
: (() => {
const n = parseInt(seCdInput, 10);
return Number.isFinite(n) && n >= 0 ? n : NaN;
})();
if (mode === "edit" && !Number.isFinite(seCdParsed)) {
alert("구분 값이 올바르지 않습니다.");
return;
}
const yr = normalizeStatsYrInput(statsYr.trim());
if (mode === "create" && !/^\d{4}$/.test(yr)) {
alert("연도는 숫자 4자리로 입력해 주세요.");
return;
}
if (mode === "edit" && !yr) {
alert("연도가 없습니다.");
return;
}
try {
setIsSubmitting(true);
const userInfo = await getUserInfo();
const param: StatsInputPeriodSaveRequest = {
statsYr: yr,
seType,
seCd: seCdParsed,
inptBgngYmd: bgng,
inptEndYmd: end,
rgtrId: userInfo.userUnqId,
};
const res = await statisticsApi.saveStatsInputPeriod(param);
if (res.status === "success") {
alert(`${isCreate ? '등록 되었습니다.' : '수정 되었습니다.'}`);
onOpenChange(false);
await onSaveSuccess?.();
} else {
alert(res.message || "저장에 실패했습니다.");
}
} catch {
alert("저장 중 오류가 발생했습니다.");
} finally {
setIsSubmitting(false);
}
};
const handleDelete = async () => {
if (mode === "create" || !initial) return;
if (!(await confirm("삭제하시겠습니까?"))) return;
const bgng = formatDateToYmd(initial.inptBgngYmd);
const end = formatDateToYmd(initial.inptEndYmd);
if (!bgng || !end) {
alert("삭제할 기간 정보가 없습니다.");
return;
}
const yr = normalizeStatsYrInput(String(initial.statsYr ?? ""));
if (!yr) {
alert("연도가 없습니다.");
return;
}
const seCd =
Number.isFinite(initial.seCd) && initial.seCd >= 0 ? initial.seCd : 0;
try {
setIsSubmitting(true);
const userInfo = await getUserInfo();
const param: StatsInputPeriodDeleteRequest = {
statsYr: yr,
seType: initial.seType,
seCd,
inptBgngYmd: bgng,
inptEndYmd: end,
rgtrId: userInfo.userUnqId,
};
const res = await statisticsApi.deleteStatsInputPeriod(param);
if (res.status === "success") {
alert("삭제되었습니다.");
onOpenChange(false);
await onSaveSuccess?.();
} else {
alert(res.message || "삭제에 실패했습니다.");
}
} catch {
alert("삭제 중 오류가 발생했습니다.");
} finally {
setIsSubmitting(false);
}
};
const isCreate = mode === "create";
return (
<Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent className="max-w-[48rem]" aria-describedby={undefined}>
<DialogHeader>
<DialogTitle>{isCreate ? "입력 기간 등록" : "입력 관리"}</DialogTitle>
</DialogHeader>
<div className="flex flex-col gap-4 py-2">
<div className="flex flex-col gap-2">
<Label className="text-label">연도</Label>
{isCreate ? (
<Input
type="text"
inputMode="numeric"
maxLength={4}
autoComplete="off"
value={statsYr}
onChange={(e) =>
setStatsYr(normalizeStatsYrInput(e.target.value))
}
placeholder="예: 2025"
disabled={isSubmitting}
className="!text-[1.3rem]"
/>
) : (
<Input
value={statsYr}
readOnly
disabled
className="!text-[1.3rem]"
/>
)}
</div>
<div className="flex flex-col gap-2">
<Label className="text-label">분류</Label>
<Select
value={seType ?? undefined}
onValueChange={(v) => setSeType(v as "INPT" | "VRFC")}
disabled={isSubmitting}
>
<SelectTrigger className="w-full">
<SelectValue placeholder="선택" />
</SelectTrigger>
<SelectContent>
<SelectItem value="INPT">입력</SelectItem>
<SelectItem value="VRFC">검증</SelectItem>
</SelectContent>
</Select>
</div>
{!isCreate && (
<div className="flex flex-col gap-2">
<Label className="text-label">구분</Label>
<Input
type="number"
inputMode="numeric"
min={0}
value={seCdInput}
disabled
className="!text-[1.3rem]"
/>
</div>
)}
<div className="flex flex-col gap-2">
<Label className="text-label">시작일</Label>
<InputDatePicker
value={inptBgngYmd}
onChange={setInptBgngYmd}
className="w-full"
/>
</div>
<div className="flex flex-col gap-2">
<Label className="text-label">종료일</Label>
<InputDatePicker
value={inptEndYmd}
onChange={setInptEndYmd}
className="w-full"
/>
</div>
</div>
<DialogFooter>
<DialogClose asChild>
<Button type="button" color="gray" disabled={isSubmitting}>
닫기
</Button>
</DialogClose>
{!isCreate && (
<Button
type="button"
color="red"
disabled={isSubmitting}
onClick={() => void handleDelete()}
>
삭제
</Button>
)}
<Button
type="button"
color="green"
disabled={isSubmitting}
onClick={() => void handleSave()}
>
{isCreate ? '등록' : '수정'}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
);
}
@@ -1,14 +1,16 @@
import { useRef, useState } from "react";
import { useMemo, useRef, useState } from "react";
import { useAlertStore } from "@/shared/lib";
import { statisticsManagementApi } from "@/entities/statistics/api/statistics-management-service";
import type { SewagePipeReviewRequestParams } from "@/entities/statistics/model/statistics-sewage-pipe-review-interface";
import type {
SewagePipeReviewRequestParams,
StatsStatusInfo,
} from "@/entities/statistics/model/statistics-sewage-pipe-review-interface";
import { LoadingWithAnimation, Tabs, TabsContent, TabsList, TabsTrigger } from "@common/components";
import ApproveButton from "@/components/button/ApproveButton";
import SubmitButton from "@/components/button/SumitButton";
import ReturnButton from "@/components/button/ReturnButton";
import ReviewOpinionButton from "@/components/button/ReviewOpinionButton";
import SaveButton from "@/components/button/SaveButton";
import DataGrid from "@/components/module/DataGrid";
import SearchForm from "@/components/module/SearchForm";
import type { ColumnInfo } from "@/components/type/DataGrid.type";
@@ -59,20 +61,51 @@ function extractReviewRowsFromResponseData(
}
}
function extractStatsStatusInfo(data: unknown): StatsStatusInfo | null {
if (!data || typeof data !== "object") return null;
const ssi = (data as { statsStatusInfo?: StatsStatusInfo }).statsStatusInfo;
return ssi && typeof ssi === "object" ? ssi : null;
}
// 하수도 통계 검토 02.하수관로
const SewagePipeRvw = () => {
const { setMessage: alert } = useAlertStore((state) => state);
const [rowDataMap, setRowDataMap] = useState<Map<string, Record<string, string | number>[]>>(new Map());
const [isLoading, setIsLoading] = useState<boolean>(false);
const [activeTab, setActiveTab] = useState<string>(firstTabKey);
/** 탭별 조회 응답의 statsStatusInfo — 활성 탭에 맞춰 버튼 노출 (SewagePipeInp의 tabStatusInfoMap과 동일 패턴) */
const [tabStatsStatusInfoMap, setTabStatsStatusInfoMap] = useState<
Map<string, StatsStatusInfo>
>(new Map());
const searchParamRef = useRef<SewagePipeReviewRequestParams | null>(null);
const searchDoneRef = useRef(false);
const lazyTabLoggedRef = useRef<Set<string>>(new Set());
const { showReviewOpinionButton, showReturnButton, showSubmitButton } = useMemo(() => {
const statsStatusInfo = tabStatsStatusInfoMap.get(activeTab) ?? {};
const submit =
statsStatusInfo.submitStatusCd != null
? String(statsStatusInfo.submitStatusCd)
: "";
const prvn =
statsStatusInfo.prvnStatusCd != null ? String(statsStatusInfo.prvnStatusCd) : "";
const submitIs3 = submit === "3";
const reviewSubmitVisible = submitIs3 && (prvn === "0" || prvn === "1");
return {
showReviewOpinionButton: reviewSubmitVisible,
showSubmitButton: reviewSubmitVisible,
showReturnButton: submitIs3 && prvn !== "3",
};
}, [tabStatsStatusInfoMap, activeTab]);
const showActionBar =
showReviewOpinionButton || showReturnButton || showSubmitButton;
const handleReset = () => {
setRowDataMap(new Map());
setActiveTab(firstTabKey);
setTabStatsStatusInfoMap(new Map());
searchParamRef.current = null;
searchDoneRef.current = false;
lazyTabLoggedRef.current.clear();
@@ -101,6 +134,12 @@ const SewagePipeRvw = () => {
const response = await fetchSewagePipeReviewTab(activeTab, searchParam);
if (response?.status === "success" && response.data) {
const ssi = extractStatsStatusInfo(response.data);
setTabStatsStatusInfoMap((prev) => {
const next = new Map(prev);
next.set(activeTab, ssi ?? {});
return next;
});
const rows = extractReviewRowsFromResponseData(activeTab, response.data);
setRowDataMap((prev) => {
const next = new Map(prev);
@@ -108,9 +147,11 @@ const SewagePipeRvw = () => {
return next;
});
} else {
setTabStatsStatusInfoMap(new Map());
alert(response?.message || "데이터 조회 중 오류가 발생했습니다.");
}
} catch {
setTabStatsStatusInfoMap(new Map());
alert("데이터 조회 중 오류가 발생했습니다.");
} finally {
setIsLoading(false);
@@ -141,6 +182,12 @@ const SewagePipeRvw = () => {
lazyTabLoggedRef.current.add(tabKey);
if (response?.status === "success" && response.data) {
const ssi = extractStatsStatusInfo(response.data);
setTabStatsStatusInfoMap((prev) => {
const next = new Map(prev);
next.set(tabKey, ssi ?? {});
return next;
});
const rows = extractReviewRowsFromResponseData(tabKey, response.data);
setRowDataMap((prev) => {
const next = new Map(prev);
@@ -161,14 +208,19 @@ const SewagePipeRvw = () => {
<div className="relative">
<SearchForm onSubmit={handleSearch} onReset={handleReset} />
<div className="card !py-0">
<div className="flex justify-end items-center gap-[0.6rem] m-[0.6rem]">
<ReviewOpinionButton reviewOpinionFunc={() => { }} />
<ApproveButton approveFunc={() => { }} />
<ReturnButton returnFunc={() => { }} />
<SaveButton saveFunc={() => { }} />
{showActionBar && (
<div className="card !py-0">
<div className="flex justify-end items-center gap-[0.6rem] m-[0.6rem]">
{showReviewOpinionButton && (
<ReviewOpinionButton reviewOpinionFunc={() => {}} />
)}
{showReturnButton && <ReturnButton returnFunc={() => {}} />}
{showSubmitButton && (
<SubmitButton submitFunc={() => {}} />
)}
</div>
</div>
</div>
)}
{isLoading && (
<div className="absolute inset-0 z-50 [data-theme='black']:bg-transparent rounded-lg p-10 flex justify-center items-center">
<div className="relative size-[5rem]">
@@ -1,3 +1,6 @@
/** 통계 분류: 전체 / 입력 / 검증 */
export type SearchFormClsfCd = "ALL" | "INPT" | "VRFC";
export type SearchFormValues = {
sido?: string;
gugun?: string;
@@ -7,6 +10,8 @@ export type SearchFormValues = {
bsnmCd?: string;
isDetail?: boolean;
isSummary?: string;
/** 분류: 전체(ALL), 입력(INPT), 검증(VRFC) */
clsfCd?: SearchFormClsfCd;
};
export type SearchFormProps = {
@@ -16,6 +21,8 @@ export type SearchFormProps = {
year?: boolean;
order?: boolean;
fctName?: boolean;
/** 분류(입력/검증) 셀렉트 표시 */
clsf?: boolean;
userMenual?: boolean;
isDetail?: boolean;
isSummary?: boolean;
@@ -33,6 +40,7 @@ export const defaultSearchFormProps: Required<
year: true,
order: false,
fctName: false,
clsf: false,
userMenual: false,
isDetail: false,
isSummary: false,
@@ -4,10 +4,10 @@ export const MainFacilityColumn = {
columns: [
{ name: 'swtrmtFcltNm', fieldName: 'swtrmtFcltNm', header: { text: '시설명' }, editable: false },
{ name: 'fcltPstnCn', fieldName: 'fcltPstnCn', header: { text: '소재지' }, editable: false },
{ name: 'prxyBzentyNmCn', fieldName: 'prxyBzentyNmCn', header: { text: '위탁업체명' } },
{ name: 'prxyBzentyNmCn', fieldName: 'prxyBzentyNmCn', header: { text: '위탁업체명' }, editable: false },
{ name: 'cnsgnCstVl', fieldName: 'cnsgnCstVl', header: { text: '위탁비용(백만원/년)' }, editable: false },
{ name: 'prxyCtrtCn', fieldName: 'prxyCtrtCn', header: { text: '위탁계약기간' }, editable: false },
{ name: 'wholNope', fieldName: 'wholNope', header: { text: '직원수(명)' } },
{ name: 'wholNope', fieldName: 'wholNope', header: { text: '직원수(명)' }, editable: false },
{ name: 'isMainYn', fieldName: 'isMainYn', header: { text: '중심처리시설여부' }, editable: false },
{ name: 'admdstCd', fieldName: 'admdstCd', header: { text: '행정구역코드' }, visible: false, editable: false, styleName: 'rg-cell-readonly' },
@@ -46,6 +46,7 @@ export const MainFacilityTargetColumn = {
{ name: 'fcltSn', fieldName: 'fcltSn', header: { text: '처리시설일련번호' }, visible: false, editable: false, valueType: ValueType.TEXT, styleName: 'rg-cell-readonly' },
{ name: 'swtrmtFcltCd', fieldName: 'swtrmtFcltCd', header: { text: '하수처리시설코드' }, visible: false, editable: false, styleName: 'rg-cell-readonly' },
{ name: 'admdstCd', fieldName: 'admdstCd', header: { text: '행정구역코드' }, visible: false, editable: false, styleName: 'rg-cell-readonly' },
{ name: 'admdstNm', fieldName: 'admdstNm', header: { text: '행정구역명' }, visible: false, editable: false, styleName: 'rg-cell-readonly' },
{ name: 'mainFcltSn', fieldName: 'mainFcltSn', header: { text: '연결된중심시설일련번호' }, visible: false, editable: false, styleName: 'rg-cell-readonly' },
{ name: 'isMainYn', fieldName: 'isMainYn', header: { text: '중심처리시설여부' }, visible: false, editable: false, styleName: 'rg-cell-readonly' },
@@ -56,6 +57,7 @@ export const MainFacilityTargetColumn = {
'mappedYn',
'fcltSn',
'swtrmtFcltCd',
'admdstCd',
'admdstNm',
'mainFcltSn',
'isMainYn',
@@ -21,9 +21,9 @@ export const PublicSewageFactColumn = {
{ name: 'prcsFcltCpct', fieldName: 'prcsFcltCpct', header: { text: '시설용량(500m³/일 이상/미만)' }, editable: false, footer: footerDecimal1, valueType: ValueType.NUMBER, dataFormat: '#,##0.0', styleName: 'rg-cell-total' },
// (m³/일)
{ name: 'prcsFcltPhysCpct', fieldName: 'prcsFcltPhysCpct', header: { text: '물리적' }, editable: false, footer: footerDecimal1, valueType: ValueType.NUMBER, dataFormat: '#,##0.0' },
{ name: 'prcsFcltBoilCpct', fieldName: 'prcsFcltBoilCpct', header: { text: '생물학적' }, editable: false, footer: footerDecimal1, valueType: ValueType.NUMBER, dataFormat: '#,##0.0' },
{ name: 'prcsFcltAltdCpct', fieldName: 'prcsFcltAltdCpct', header: { text: '고도' }, editable: false, footer: footerDecimal1, valueType: ValueType.NUMBER, dataFormat: '#,##0.0' },
{ name: 'prcsFcltPhysCpct', fieldName: 'prcsFcltPhysCpct', header: { text: '물리적' }, footer: footerDecimal1, valueType: ValueType.NUMBER, dataFormat: '#,##0.0' },
{ name: 'prcsFcltBoilCpct', fieldName: 'prcsFcltBoilCpct', header: { text: '생물학적' }, footer: footerDecimal1, valueType: ValueType.NUMBER, dataFormat: '#,##0.0' },
{ name: 'prcsFcltAltdCpct', fieldName: 'prcsFcltAltdCpct', header: { text: '고도' }, footer: footerDecimal1, valueType: ValueType.NUMBER, dataFormat: '#,##0.0' },
// 유입하수량
{ name: 'inflwSwageQnt', fieldName: 'inflwSwageQnt', header: { text: '유입하수량(500m³/일 이상/미만)' }, editable: false, footer: footerDecimal1, valueType: ValueType.NUMBER, dataFormat: '#,##0.0', styleName: 'rg-cell-readonly' },
@@ -8,26 +8,32 @@ import {
type getReviewOpinionDetailRequest,
type getReviewOpinionDetailResponse,
type ImrRequest,
type ImrResponse,
type ImrStatusResponse,
type ReviewOpinionSaveRequest,
type ReviewOpinionSaveResponse,
type ReviewReturnSaveRequest,
type ReviewReturnSaveResponse,
type ReviewSubmitRequest,
type ReviewSubmitResponse,
type VerificationResultListRequest,
type VerificationResultListResponse,
type SearchCondition,
type Sgg,
type StatisticsLogDetailResponse,
type StatisticsLogRequest,
type StatisticsLogResponse,
type StatisticsService,
type StatsInputPeriodDeleteApiResponse,
type StatsInputPeriodDeleteRequest,
type StatsInputPeriodListApiResponse,
type StatsInputPeriodListRequest,
type StatsInputPeriodSaveApiResponse,
type StatsInputPeriodSaveRequest,
type SubmitExceptionRequest,
type SubmitExceptionResponse,
type ImrResponse,
type ImrStatusResponse,
type SubmitVerifyExceptionRequest,
type SubmitVerifyExceptionResponse,
type VerificationResultListRequest,
type VerificationResultListResponse,
} from "../model/statistics-interface";
export class StatisticsApiService implements StatisticsService {
@@ -63,6 +69,39 @@ export class StatisticsApiService implements StatisticsService {
return response.data;
}
/** 통계 입력 기간(연도·분류별) 목록 조회 — 경로는 백엔드 스펙에 맞게 수정 */
public async getStatsInputPeriodList(
params?: StatsInputPeriodListRequest,
): Promise<StatsInputPeriodListApiResponse> {
const response = await stClient.get<StatsInputPeriodListApiResponse>(
`${ENDPOINT.ST_SERVICE.DASHBOARD}/schedule-info/list`,
{ params },
);
return response.data;
}
/** 통계 입력 기간 저장 — 경로는 백엔드 스펙에 맞게 수정 */
public async saveStatsInputPeriod(
param: StatsInputPeriodSaveRequest,
): Promise<StatsInputPeriodSaveApiResponse> {
const response = await stClient.post<StatsInputPeriodSaveApiResponse>(
`${ENDPOINT.ST_SERVICE.DASHBOARD}/schedule-info`,
param,
);
return response.data;
}
/** 통계 입력 기간 삭제 — JSON body. HTTP 메서드·URL은 백엔드 스펙에 맞게 수정 */
public async deleteStatsInputPeriod(
param: StatsInputPeriodDeleteRequest,
): Promise<StatsInputPeriodDeleteApiResponse> {
const response = await stClient.delete<StatsInputPeriodDeleteApiResponse>(
`${ENDPOINT.ST_SERVICE.DASHBOARD}/schedule-info`,
{ data: param },
);
return response.data;
}
// 입력 - 제출 취소
public async cancelSubmit(
param: cancelSubmitRequest,
@@ -151,6 +151,7 @@ export interface PublicSewageMappingTargetsRequestParams {
/** ST00060002 메인시설 클릭 시 우측 매핑대상 조회 항목 */
export interface PublicSewageMappingTargetItem {
fcltSn: string;
admdstCd?: string;
admdstNm: string;
swtrmtFcltCd: string;
swtrmtFcltNm: string;
@@ -181,6 +182,9 @@ export interface PublicSewageMainFacilityMappingSaveItem {
export interface PublicSewageMainFacilityMappingSaveRequest {
statsYr: string;
sewerBzmnCd: string;
/** 좌측에서 선택한 중심 처리 시설의 행정구역 */
admdstCd: string;
admdstNm: string;
mainFcltSn: string;
mainFcltNm: string;
prxyBzentyNmCn: string;
@@ -35,6 +35,46 @@ export interface ApiResponse<T> {
message?: string;
}
/** 통계 입력 관리 등 — 연도·구분·분류(입력/검증)별 입력 기간 한 행 */
export interface StatsInputPeriodItem {
statsYr: string;
seCd: number;
seType: "INPT" | "VRFC";
inptBgngYmd: string;
inptEndYmd: string;
}
/** `data`가 `StatsInputPeriodItem[]` 인 조회 응답 */
export type StatsInputPeriodListApiResponse = ApiResponse<StatsInputPeriodItem[]>;
/** 통계 입력 기간 목록 조회 요청 쿼리 */
export interface StatsInputPeriodListRequest {
statsYr?: string;
/** 전체 / 입력 / 검증 (SearchForm `clsfCd`와 동일) */
seType?: "ALL" | "INPT" | "VRFC";
}
/** 통계 입력 기간 등록·수정 등 요청 본문 */
export interface StatsInputPeriodSaveRequest {
statsYr: string;
seType: string;
seCd: number;
/** 시작일 (백엔드 형식 문자열, 예: YYYYMMDD) */
inptBgngYmd: string;
/** 종료일 (백엔드 형식 문자열, 예: YYYYMMDD) */
inptEndYmd: string;
rgtrId: string;
}
/** 통계 입력 기간 저장 응답 */
export type StatsInputPeriodSaveApiResponse = ApiResponse<unknown>;
/** 통계 입력 기간 삭제 요청 (필드 구성은 저장과 동일) */
export type StatsInputPeriodDeleteRequest = StatsInputPeriodSaveRequest;
/** 통계 입력 기간 삭제 응답 */
export type StatsInputPeriodDeleteApiResponse = ApiResponse<unknown>;
/** 하수 입력/보급률 등 조회 `data.statusInfo` 공통 (LAE 제출·담당자 정보) */
export interface SewerageStatusInfo {
laePicNm: string | null;
@@ -116,6 +156,7 @@ export interface VerificationResultReportItem {
nowVlCn: string;
compVlCn: string | null;
prcsYn: string;
prcsYnNm: string;
prcsOpnnExpln: string | null;
prcrId: string | null;
rjctOpnnCn: string | null;
@@ -305,6 +346,11 @@ export interface StatisticsService {
getSidoList(params?: { instSeCd?: string }): Promise<ApiResponse<SearchCondition>>;
getSggList(sidoName: string, params?: { instSeCd?: string; gugunSkipYn?: "Y" | "N" }): Promise<ApiResponse<Sgg[]>>;
getSggAllList(sidoName: string, params?: { instSeCd?: string; gugunSkipYn?: "Y" | "N" }): Promise<ApiResponse<Sgg[]>>;
getStatsInputPeriodList(
params?: StatsInputPeriodListRequest,
): Promise<StatsInputPeriodListApiResponse>;
saveStatsInputPeriod(param: StatsInputPeriodSaveRequest): Promise<StatsInputPeriodSaveApiResponse>;
deleteStatsInputPeriod(param: StatsInputPeriodDeleteRequest): Promise<StatsInputPeriodDeleteApiResponse>;
cancelSubmit(param: cancelSubmitRequest): Promise<cancelSubmitResponse>;
callImrService(param: ImrRequest): Promise<ImrResponse>;
submitException(param: SubmitExceptionRequest): Promise<SubmitExceptionResponse>;