[st] - 입력-반려/일괄반려, 관리-통계일정관리,검토이력조회, 버튼 권한체크

This commit is contained in:
2026-05-14 15:39:17 +09:00
parent 25320d43c4
commit 49e31134b6
19 changed files with 687 additions and 154 deletions
@@ -565,7 +565,7 @@ export function ExceptionProcessingHistoryDialog({
onClick={handleOpenRejectReasonDialog}
disabled={rejectSubmitting}
>
반려
일괄 반려
</Button>
<DialogClose asChild>
<Button type="button" color="gray">
@@ -12,6 +12,7 @@ import {
DialogHeader,
DialogTitle,
Input,
InputDatePicker,
Label,
} from "@common/components";
@@ -36,27 +37,43 @@ export interface MainFacilityMappingSaveDialogProps {
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 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}`;
}
function validatePrxyCtrtCn(value: string): string | null {
const t = value.trim();
if (!t) return "위탁계약기간을 입력해 주세요.";
/** `YYYYMMDD` → `Date` (로컬 자정) */
function parseYmdToDate(ymd: string): Date | undefined {
const s = ymd.replace(/\D/g, "");
if (s.length !== 8) return undefined;
const y = Number(s.slice(0, 4));
const mo = Number(s.slice(4, 6)) - 1;
const day = Number(s.slice(6, 8));
const d = new Date(y, mo, day);
if (d.getFullYear() !== y || d.getMonth() !== mo || d.getDate() !== day) return undefined;
return d;
}
function parseInitialPrxyCtrtCnToDates(initial: string): { start: Date | undefined; end: Date | undefined } {
const t = initial.trim();
if (!/^\d{8}~\d{8}$/.test(t)) {
return "위탁계약기간은 YYYYMMDD~YYYYMMDD 형식으로 입력해 주세요. (예: 20240101~20281231)";
return { start: undefined, end: undefined };
}
const start = t.slice(0, 8);
const end = t.slice(9, 17);
if (!isValidYmd(start) || !isValidYmd(end)) {
return "시작일 또는 종료일이 유효한 날짜가 아닙니다.";
const start = parseYmdToDate(t.slice(0, 8));
const end = parseYmdToDate(t.slice(9, 17));
return { start, end };
}
function validateContractRange(start: Date | undefined, end: Date | undefined): string | null {
const startYmd = formatDateToYmd(start);
const endYmd = formatDateToYmd(end);
if (!startYmd || !endYmd) {
return "시작일과 종료일을 선택해 주세요.";
}
if (start > end) {
if (startYmd > endYmd) {
return "시작일이 종료일보다 늦을 수 없습니다.";
}
return null;
@@ -75,7 +92,8 @@ export function MainFacilityMappingSaveDialog({
const [prxyBzentyNmCn, setPrxyBzentyNmCn] = useState("");
const [cnsgnCstInput, setCnsgnCstInput] = useState("");
const [wholNopeInput, setWholNopeInput] = useState("");
const [prxyCtrtCn, setPrxyCtrtCn] = useState("");
const [prxyCtrtStart, setPrxyCtrtStart] = useState<Date | undefined>(undefined);
const [prxyCtrtEnd, setPrxyCtrtEnd] = useState<Date | undefined>(undefined);
const [isSubmitting, setIsSubmitting] = useState(false);
useEffect(() => {
@@ -94,7 +112,9 @@ export function MainFacilityMappingSaveDialog({
? ""
: String(initialWholNope),
);
setPrxyCtrtCn((initialPrxyCtrtCn ?? "").trim());
const { start, end } = parseInitialPrxyCtrtCnToDates(initialPrxyCtrtCn ?? "");
setPrxyCtrtStart(start);
setPrxyCtrtEnd(end);
}
}, [open, initialPrxyBzentyNmCn, initialCnsgnCstVl, initialWholNope, initialPrxyCtrtCn]);
@@ -103,7 +123,8 @@ export function MainFacilityMappingSaveDialog({
setPrxyBzentyNmCn("");
setCnsgnCstInput("");
setWholNopeInput("");
setPrxyCtrtCn("");
setPrxyCtrtStart(undefined);
setPrxyCtrtEnd(undefined);
}
onOpenChange(nextOpen);
};
@@ -121,18 +142,21 @@ export function MainFacilityMappingSaveDialog({
alert("전체 인원은 0 이상의 정수로 입력해 주세요.");
return;
}
const prxyCtrtErr = validatePrxyCtrtCn(prxyCtrtCn);
const prxyCtrtErr = validateContractRange(prxyCtrtStart, prxyCtrtEnd);
if (prxyCtrtErr) {
alert(prxyCtrtErr);
return;
}
const startYmd = formatDateToYmd(prxyCtrtStart)!;
const endYmd = formatDateToYmd(prxyCtrtEnd)!;
const prxyCtrtCnParam = `${startYmd}~${endYmd}`;
try {
setIsSubmitting(true);
await onConfirm?.({
prxyBzentyNmCn: prxyBzentyNmCn.trim(),
cnsgnCstVl,
wholNope,
prxyCtrtCn: prxyCtrtCn.trim(),
prxyCtrtCn: prxyCtrtCnParam,
});
} finally {
setIsSubmitting(false);
@@ -193,15 +217,27 @@ export function MainFacilityMappingSaveDialog({
</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"
<div className="flex flex-col gap-3 sm:flex-row sm:items-end sm:gap-4">
<div className="flex min-w-0 flex-1 flex-col gap-2">
<Label className="text-label text-[1.2rem]">시작일</Label>
<InputDatePicker
value={prxyCtrtStart}
onChange={setPrxyCtrtStart}
className="w-full"
disabled={isSubmitting}
/>
</div>
<div className="flex min-w-0 flex-1 flex-col gap-2">
<Label className="text-label text-[1.2rem]">종료일</Label>
<InputDatePicker
value={prxyCtrtEnd}
onChange={setPrxyCtrtEnd}
className="w-full"
disabled={isSubmitting}
/>
</div>
</div>
</div>
</div>
<DialogFooter>
<DialogClose asChild>
@@ -2,7 +2,7 @@ import { useEffect, useRef, useState } from 'react';
import { useConfirm } from '@frontend-opensource/use-react-hooks';
import { useAlertStore, useGlobalProgressStore } from '@/shared/lib';
import { useAlertStore, useGlobalProgressStore, useStatisticsInputToolbarGroupGate } from '@/shared/lib';
import { statisticsInputSewSupplyRateApi } from '@/entities/statistics/api/input/statistics-input-sew-supply-rate-service';
import { statisticsInputPublicSewageFacilityApiService } from '@/entities/statistics/api/input/statistics-public-sewage-facility-input-service';
import { statisticsApi } from '@/entities/statistics/api/statistics-service';
@@ -26,7 +26,7 @@ import { LoadingWithAnimation, Tabs, TabsContent, TabsList, TabsTrigger } from '
import { getUserInfo } from '../../../../../../packages/common/components/src/api';
import { MainFacilityMappingSaveDialog } from './MainFacilityMappingSaveDialog';
import { MainFacilityMappingSaveDialog, type MainFacilityMappingSaveDialogValues } from './MainFacilityMappingSaveDialog';
import SubmitDailog from './SubmitDailog';
import CancelSubmitButton from '@/components/button/CancelSubmitButton';
@@ -145,6 +145,7 @@ const PublicSewageFacilityInp = () => {
const { setMessage: alert } = useAlertStore((state) => state);
const { confirm } = useConfirm();
const { isAdminInfo } = useAdminStore();
const toolbarGroupAllowed = useStatisticsInputToolbarGroupGate();
const showGlobalProgress = useGlobalProgressStore((state) => state.show);
const setGlobalProgress = useGlobalProgressStore((state) => state.setProgress);
const setGlobalProgressLabel = useGlobalProgressStore((state) => state.setLabel);
@@ -415,6 +416,13 @@ const PublicSewageFacilityInp = () => {
.filter((sn) => sn !== '');
const wholNopeFromRow = Number(selectedMain.wholNope ?? 0);
const deleteOnly = saveList.length === 0 && deleteList.length > 0;
const nothingToPersist = saveList.length === 0 && deleteList.length === 0;
if (nothingToPersist) {
alert('저장할 변경 사항이 없습니다.');
return;
}
mainFacilityMappingSaveDraftRef.current = {
statsYr: lastSearchedValues.year ?? '',
sewerBzmnCd: lastSearchedValues.bsnmCd ?? '',
@@ -429,6 +437,16 @@ const PublicSewageFacilityInp = () => {
initialWholNope: Number.isFinite(wholNopeFromRow) ? wholNopeFromRow : 0,
initialPrxyCtrtCn: String(selectedMain.prxyCtrtCn ?? ''),
};
if (deleteOnly) {
const draft = mainFacilityMappingSaveDraftRef.current;
const ok = await runMainFacilityMappingSave(draft, null);
if (ok) {
mainFacilityMappingSaveDraftRef.current = null;
}
return;
}
setMainFacilityMappingSaveDialogKey((k) => k + 1);
setMainFacilityMappingSaveDialogOpen(true);
return;
@@ -671,42 +689,40 @@ const PublicSewageFacilityInp = () => {
}
};
const handleMainFacilityMappingSaveConfirm = async (input: {
prxyBzentyNmCn: string;
cnsgnCstVl: number;
wholNope: number;
prxyCtrtCn: string;
}) => {
const draft = mainFacilityMappingSaveDraftRef.current;
if (!draft || !searchParamRef.current || !lastSearchedValues) {
setMainFacilityMappingSaveDialogOpen(false);
mainFacilityMappingSaveDraftRef.current = null;
return;
const runMainFacilityMappingSave = async (
draft: MainFacilityMappingSaveDraft,
dialogInput: MainFacilityMappingSaveDialogValues | null,
): Promise<boolean> => {
if (!searchParamRef.current || !lastSearchedValues) {
return false;
}
try {
showGlobalProgress({ label: '저장 진행 중...', progress: 0 });
const userInfo = await getUserInfo();
const param: PublicSewageMainFacilityMappingSaveRequest = {
const base = {
statsYr: draft.statsYr,
sewerBzmnCd: draft.sewerBzmnCd,
admdstCd: draft.admdstCd,
admdstNm: draft.admdstNm,
mainFcltSn: draft.mainFcltSn,
mainFcltNm: draft.mainFcltNm,
prxyBzentyNmCn: input.prxyBzentyNmCn,
cnsgnCstVl: input.cnsgnCstVl,
wholNope: input.wholNope,
prxyCtrtCn: input.prxyCtrtCn,
userId: userInfo.userUnqId,
saveList: draft.saveList,
deleteList: draft.deleteList,
};
const param: PublicSewageMainFacilityMappingSaveRequest = dialogInput
? {
...base,
prxyBzentyNmCn: dialogInput.prxyBzentyNmCn,
cnsgnCstVl: dialogInput.cnsgnCstVl,
wholNope: dialogInput.wholNope,
prxyCtrtCn: dialogInput.prxyCtrtCn,
}
: { ...base };
const response =
await statisticsInputPublicSewageFacilityApiService.saveMainFacilityMapping(param);
if (response.status === 'success') {
setGlobalProgress(50);
setMainFacilityMappingSaveDialogOpen(false);
mainFacilityMappingSaveDraftRef.current = null;
await loadPublicSewageFacilityActiveTabData(
'ST00060002',
searchParamRef.current,
@@ -714,16 +730,32 @@ const PublicSewageFacilityInp = () => {
);
setGlobalProgress(100);
alert('저장되었습니다.');
} else {
alert(response.message || '데이터 저장 중 오류가 발생했습니다.');
return true;
}
alert(response.message || '데이터 저장 중 오류가 발생했습니다.');
return false;
} catch {
alert('데이터 저장 중 오류가 발생했습니다.');
return false;
} finally {
hideGlobalProgress();
}
};
const handleMainFacilityMappingSaveConfirm = async (input: MainFacilityMappingSaveDialogValues) => {
const draft = mainFacilityMappingSaveDraftRef.current;
if (!draft || !searchParamRef.current || !lastSearchedValues) {
setMainFacilityMappingSaveDialogOpen(false);
mainFacilityMappingSaveDraftRef.current = null;
return;
}
const ok = await runMainFacilityMappingSave(draft, input);
if (ok) {
setMainFacilityMappingSaveDialogOpen(false);
mainFacilityMappingSaveDraftRef.current = null;
}
};
const handleSearch = async (values: SearchFormValues) => {
if (!values.sido || values.sido === 'ALL') {
alert('시도를 선택해주세요.');
@@ -928,7 +960,14 @@ const PublicSewageFacilityInp = () => {
inputPeriodEnterable,
isAdminInfo,
);
const { showSaveInToolbar, showEnterableToolbarButtons, showActionToolbarCard } = activeTabToolbar;
const {
showSaveInToolbar: baseShowSaveInToolbar,
showEnterableToolbarButtons: baseShowEnterableToolbarButtons,
showActionToolbarCard: baseShowActionToolbarCard,
} = activeTabToolbar;
const showSaveInToolbar = baseShowSaveInToolbar && toolbarGroupAllowed;
const showEnterableToolbarButtons = baseShowEnterableToolbarButtons && toolbarGroupAllowed;
const showActionToolbarCard = baseShowActionToolbarCard && toolbarGroupAllowed;
const isMainFacilityTab = activeTab === 'ST00060002';
const openVerificationHistoryDialog = () => {
@@ -2,7 +2,7 @@ import { useEffect, useLayoutEffect,useMemo, useRef, useState } from "react";
import { useConfirm } from "@frontend-opensource/use-react-hooks";
import { useAlertStore, useGlobalProgressStore } from "@/shared/lib";
import { useAlertStore, useGlobalProgressStore, useStatisticsInputToolbarGroupGate } from "@/shared/lib";
import {
STATISTICS_INPUT_URL_PATH,
verificationSearchToFormValues,
@@ -375,6 +375,7 @@ const SewagePipeInp = () => {
const hideGlobalProgress = useGlobalProgressStore((state) => state.hide);
const globalProgressVisible = useGlobalProgressStore((state) => state.visible);
const { isAdminInfo } = useAdminStore();
const toolbarGroupAllowed = useStatisticsInputToolbarGroupGate();
const getGridDataRef = useRef<(() => Record<string, string | number>[]) | null>(null);
const getFactInstallGridDataRef = useRef<(() => Record<string, string | number>[]) | null>(null);
@@ -1783,10 +1784,12 @@ const SewagePipeInp = () => {
showEnterableToolbarButtons: baseShowEnterableToolbarButtons,
showActionToolbarCard: baseShowActionToolbarCard,
} = activeTabToolbar;
const showSaveInToolbar = !hideToolbarForConnectionTab && baseShowSaveInToolbar;
const showSaveInToolbar =
!hideToolbarForConnectionTab && baseShowSaveInToolbar && toolbarGroupAllowed;
const showEnterableToolbarButtons =
!hideToolbarForConnectionTab && baseShowEnterableToolbarButtons;
const showActionToolbarCard = !hideToolbarForConnectionTab && baseShowActionToolbarCard;
!hideToolbarForConnectionTab && baseShowEnterableToolbarButtons && toolbarGroupAllowed;
const showActionToolbarCard =
!hideToolbarForConnectionTab && baseShowActionToolbarCard && toolbarGroupAllowed;
return (
<>
@@ -2,7 +2,7 @@ import { useEffect, useLayoutEffect, useMemo, useRef, useState } from "react";
import { useConfirm } from "@frontend-opensource/use-react-hooks";
import { useAlertStore, useGlobalProgressStore } from "@/shared/lib";
import { useAlertStore, useGlobalProgressStore, useStatisticsInputToolbarGroupGate } from "@/shared/lib";
import {
STATISTICS_INPUT_URL_PATH,
verificationSearchToFormValues,
@@ -192,6 +192,7 @@ const SeweragePenetrationRateInp = () => {
}, [activeTabId]);
const { isAdminInfo } = useAdminStore();
const toolbarGroupAllowed = useStatisticsInputToolbarGroupGate();
const getGridDataRef = useRef<(() => Record<string, string | number>[]) | null>(null);
@@ -710,12 +711,15 @@ const SeweragePenetrationRateInp = () => {
? Boolean(lastSearchedValues?.gugun && lastSearchedValues.gugun !== 'ALL')
: true;
const showSaveInToolbar = (isEnterable || isAdminInfo) && gugunOkOrNotDoList;
const showEnterableToolbarButtons = isEnterable && gugunOkOrNotDoList;
const baseShowSaveInToolbar = (isEnterable || isAdminInfo) && gugunOkOrNotDoList;
const baseShowEnterableToolbarButtons = isEnterable && gugunOkOrNotDoList;
const showSaveInToolbar = baseShowSaveInToolbar && toolbarGroupAllowed;
const showEnterableToolbarButtons = baseShowEnterableToolbarButtons && toolbarGroupAllowed;
/** 카드는 저장/제출줄/제출취소 중 하나라도 보일 때만 (관리자만으로 빈 카드 방지) */
const showActionToolbarCard =
rowData.length > 0 &&
(showSaveInToolbar ||
toolbarGroupAllowed &&
(baseShowSaveInToolbar ||
(statusInfo?.laeSbmsnSttsCd === '2' && (isAdminInfo || (isEnterable && gugunOkOrNotDoList))));
return (
@@ -1,9 +1,10 @@
import { useCallback, useEffect, useRef, useState } from "react";
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
import { useConfirm } from "@frontend-opensource/use-react-hooks";
import { RealGridReact } from "realgrid-react";
import useSWR, { useSWRConfig } from "swr";
import { useAlertStore } from "@/shared/lib";
import { useAlertStore, useStatisticsInputStatusConfirmGroupGate } from "@/shared/lib";
import { statisticsInputSewSupplyRateApi } from "@/entities/statistics/api/input/statistics-input-sew-supply-rate-service";
import { StatisticsInputStatusApi } from "@/entities/statistics/api/input/statistics-input-status-service";
import { statisticsApi } from "@/entities/statistics/api/statistics-service";
@@ -17,8 +18,8 @@ import type {
StatsInputStatusListRequestParams,
} from "@/entities/statistics/model/input/statistics-input-status-interface";
import { getUserInfo } from "../../../../../../packages/common/components/src/api";
import { LoadingWithAnimation } from "@common/components";
import { LoadingWithAnimation } from "@common/components";
import ApproveButton from "@/components/button/ApproveButton";
import DataGrid from "@/components/module/DataGrid";
import type { DataCellClickCallbackParams } from "@/components/type/DataGrid.type";
@@ -41,7 +42,9 @@ const getStatsInputStatusFetcher = async (
// 하수도 통계 관리 > 00. 통계 입력 현황
const StatsInputStatusInp = () => {
const { setMessage: alert } = useAlertStore((state) => state);
const { confirm } = useConfirm();
const activeTabId = useTabStore((state) => state.activeTabId);
const [rowData, setRowData] = useState<Record<string, string | number>[]>(
[],
@@ -58,6 +61,8 @@ const StatsInputStatusInp = () => {
const [exceptionHistoryContext, setExceptionHistoryContext] = useState<
ExceptionProcessingHistoryQueryContext | null
>(null);
const [isApproveLoading, setIsApproveLoading] = useState(false);
const confirmGroupToolbarAllowed = useStatisticsInputStatusConfirmGroupGate();
const swrKey = searchParams
? (["stats-input-status", searchParams] as const)
@@ -140,6 +145,7 @@ const StatsInputStatusInp = () => {
inputPeriodCurrentRef.current = false;
setExceptionHistoryOpen(false);
setExceptionHistoryContext(null);
setIsApproveLoading(false);
};
const approveFunc = async () => {
@@ -152,15 +158,20 @@ const StatsInputStatusInp = () => {
return;
}
// const hasInvalidStatus = rowData.some((row) =>
// ["0", "1", "4"].includes(String(row.laeSbmsnSttsCd ?? "")),
// );
const hasInvalidStatus = rowData.some((row) =>
["0", "1", "4"].includes(String(row.laeSbmsnSttsCd ?? "")),
);
// if (hasInvalidStatus) {
// alert("미작성이나 미제출, 혹은 보완요청(반려) 상태인 항목이 있습니다.");
// return;
// }
if (hasInvalidStatus) {
alert("미작성이나 미제출, 혹은 보완요청(반려) 상태인 항목이 있습니다.");
return;
}
if (!(await confirm("조회된 통계 항목을 승인하시겠습니까?"))) {
return;
}
setIsApproveLoading(true);
try {
const userInfo = await getUserInfo();
const mdfrId = (userInfo.userUnqId ?? "").trim();
@@ -186,7 +197,7 @@ const StatsInputStatusInp = () => {
}
const response = await statisticsApi.approveStatsInputStatus({ list: approveItems }, "INPUT");
if (response.status === "success") {
if (response.resultCode.toLocaleLowerCase() === "success") {
if (searchParams) {
await mutate(["stats-input-status", searchParams]);
}
@@ -196,6 +207,8 @@ const StatsInputStatusInp = () => {
}
} catch {
alert("승인 처리 중 오류가 발생했습니다.");
} finally {
setIsApproveLoading(false);
}
};
@@ -239,8 +252,21 @@ const StatsInputStatusInp = () => {
const gugunOkOrNotDoList = doList.includes(lastSearchedValues?.bsnmCd || "")
? Boolean(lastSearchedValues?.gugun && lastSearchedValues.gugun !== "ALL")
: true;
const allRowsLaeSbmsnApproved = useMemo(
() =>
rowData.length > 0 &&
rowData.every((row) => String(row.laeSbmsnSttsCd ?? "") === "3"),
[rowData],
);
const showActionToolbarCard =
hasCompletedSearch && rowData.length > 0 && gugunOkOrNotDoList && inputPeriodCurrentRef.current;
hasCompletedSearch &&
rowData.length > 0 &&
gugunOkOrNotDoList &&
inputPeriodCurrentRef.current &&
!allRowsLaeSbmsnApproved &&
confirmGroupToolbarAllowed;
return (
<>
@@ -253,7 +279,7 @@ const StatsInputStatusInp = () => {
</div>
</div>
)}
{isLoading && (
{(isLoading || isApproveLoading) && (
<div className="[data-theme='black']:bg-transparent absolute inset-0 z-50 flex items-center justify-center rounded-lg p-10">
<div className="relative size-[5rem]">
<LoadingWithAnimation color="primary" />
@@ -1,4 +1,4 @@
import { useRef, useState } from "react";
import { useCallback, useRef, useState } from "react";
import { useAlertStore } from "@/shared/lib";
import { statisticsApi } from "@/entities/statistics/api/statistics-service";
@@ -15,7 +15,7 @@ import {
import DataGrid from "@/components/module/DataGrid";
import SearchForm from "@/components/module/SearchForm";
import type { ColumnConfig } from "@/components/type/DataGrid.type";
import type { ColumnConfig, DataCellClickCallbackParams } from "@/components/type/DataGrid.type";
import type { SearchFormValues } from "@/components/type/SearchForm.type";
import { toGridRows } from "@/components/util/grid/gridFunction";
@@ -53,6 +53,8 @@ function formatYmdForDisplay(ymd: string): string {
return `${s.slice(0, 4)}-${s.slice(4, 6)}-${s.slice(6, 8)}`;
}
const PERIOD_DETAIL_FIELD = "detailInfo";
const StatisticsInputManagement = () => {
const { setMessage: alert } = useAlertStore((state) => state);
const [isLoading, setIsLoading] = useState(false);
@@ -88,6 +90,7 @@ const StatisticsInputManagement = () => {
seType: seTypeToDisplayLabel(row.seType),
inptBgngYmd: formatYmdForDisplay(row.inptBgngYmd),
inptEndYmd: formatYmdForDisplay(row.inptEndYmd),
[PERIOD_DETAIL_FIELD]: "상세정보",
}));
setRowData(toGridRows(rowsForGrid));
} else {
@@ -119,7 +122,8 @@ const StatisticsInputManagement = () => {
setPeriodDialogOpen(true);
};
const handleRowClick = async (row: Record<string, string | number>) => {
const openPeriodDetailForRow = useCallback(
async (row: Record<string, string | number>) => {
const seCdRaw = row.seCd;
const seCdNum =
typeof seCdRaw === "number"
@@ -160,7 +164,17 @@ const StatisticsInputManagement = () => {
} finally {
setIsLoading(false);
}
};
},
[alert],
);
const handlePeriodDetailCellClick = useCallback(
({ fieldName, row }: DataCellClickCallbackParams) => {
if (fieldName !== PERIOD_DETAIL_FIELD) return;
void openPeriodDetailForRow(row);
},
[openPeriodDetailForRow],
);
const columns: ColumnConfig[] = [
{
@@ -193,6 +207,14 @@ const StatisticsInputManagement = () => {
header: { text: "종료일" },
editable: false,
},
{
name: PERIOD_DETAIL_FIELD,
fieldName: PERIOD_DETAIL_FIELD,
header: { text: "상세정보" },
editable: false,
width: 118,
styleName: "rg-cell-outline-action-btn",
},
];
const columnLayout = [
@@ -201,6 +223,7 @@ const StatisticsInputManagement = () => {
"seCd",
"inptBgngYmd",
"inptEndYmd",
PERIOD_DETAIL_FIELD,
];
return (
@@ -232,7 +255,7 @@ const StatisticsInputManagement = () => {
setRowData={setRowData}
isExcelImport={false}
isExcelExport={false}
onRowClick={handleRowClick}
onDataCellClick={handlePeriodDetailCellClick}
/>
<StatisticsInputPeriodDialog
open={periodDialogOpen}
@@ -3,36 +3,91 @@ import { useState } from "react";
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";
import { statisticsApi } from "@/entities/statistics/api/statistics-service";
import { useAlertStore } from "@/shared/lib";
import { LoadingWithAnimation } from "@common/components";
const StatisticsReviewHistory = () => {
const { setMessage: alert } = useAlertStore((state) => state);
const [rowData, setRowData] = useState<Record<string, string | number>[]>([]);
const [isLoading, setIsLoading] = useState(false);
const columns: ColumnConfig[] = [
{ name: 'sido', fieldName: '시도', header: { text: '시도' }, mergeCells: true, editable: false, sortdisable: true },
{ name: 'sgg', fieldName: '시군구', header: { text: '시군구' }, mergeCells: true, editable: false, sortdisable: true },
{ name: '구분', fieldName: '구분', header: { text: '구분' }, editable: false, sortdisable: true },
{ name: '광역자치단체', fieldName: '광역자치단체', header: { text: '광역자치단체' }, editable: false, sortdisable: true },
{ name: '공단', fieldName: '공단', header: { text: '공단' }, editable: false, sortdisable: true },
{ name: '처리내역', fieldName: '처리내역', header: { text: '처리내역' }, editable: false, sortdisable: true },
{ name: "statsYr", fieldName: "statsYr", header: { text: "연도" }, editable: false },
{ name: "ctpvNm", fieldName: "ctpvNm", header: { text: "시도" }, editable: false },
{ name: "sggNm", fieldName: "sggNm", header: { text: "시군구" }, editable: false },
{ name: "sewerBzmnCd", fieldName: "sewerBzmnCd", header: { text: "하수사업자코드" }, editable: false },
{ name: "statsCd", fieldName: "statsCd", header: { text: "통계코드" }, editable: false },
{ name: "menuNm", fieldName: "menuNm", header: { text: "통계항목" }, editable: false },
{ name: "chckerOpinionSm", fieldName: "chckerOpinionSm", header: { text: "검토자의견" }, editable: false },
{ name: "wrterOpinionSm", fieldName: "wrterOpinionSm", header: { text: "처리내역" }, editable: false },
];
const columnLayout = [
'sido',
'sgg',
'구분',
{
name: 'groupA',
hideChildHeaders: false,
header: { text: '검토내역' },
items: ['광역자치단체', '공단', '처리내역'],
},
"statsYr",
"ctpvNm",
"sggNm",
"sewerBzmnCd",
"statsCd",
"menuNm",
"chckerOpinionSm",
"wrterOpinionSm",
];
const handleSearch = async (values: SearchFormValues) => {
if (!values.year?.trim()) {
alert("년도를 선택해주세요.");
return;
}
try {
setIsLoading(true);
const res = await statisticsApi.getStatiscicsManagementReviewHistory({
statsYr: values.year.trim(),
...(values.bsnmCd?.trim() ? { sewerBzmnCd: values.bsnmCd.trim() } : {}),
...(values.sido && values.sido !== "ALL" ? { ctpvNm: values.sido } : {}),
});
if (res.status === "success" && Array.isArray(res.data)) {
setRowData(toGridRows(res.data));
} else {
setRowData([]);
alert(res.message || "조회된 데이터가 없습니다.");
}
} catch {
setRowData([]);
alert("데이터 조회 중 오류가 발생했습니다.");
} finally {
setIsLoading(false);
}
};
const handleReset = () => {
setRowData([]);
};
return (
<>
<SearchForm />
<DataGrid columnInfo={{ columns, columnLayout }} rowStartNum={2} fixedColumns={3} rowData={rowData} setRowData={setRowData} isExcelImport={false} />
</>
<div className="relative">
<SearchForm onSubmit={handleSearch} onReset={handleReset} />
{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>
)}
<DataGrid
columnInfo={{ columns, columnLayout }}
rowStartNum={2}
fixedColumns={3}
rowData={rowData}
setRowData={setRowData}
isExcelImport={false}
enableSort={false}
/>
</div>
);
};
@@ -1,4 +1,5 @@
import { useCallback, useMemo, useState } from "react";
import { useConfirm } from "@frontend-opensource/use-react-hooks";
import { createColumnHelper, getCoreRowModel, useReactTable } from "@tanstack/react-table";
import DataGrid from "@/components/module/DataGrid";
@@ -41,7 +42,7 @@ const checkSubmitStatusCd = (code: string) => {
};
const isStatsInputStatusRowApprovable = (row: Record<string, string | number>) =>
String(row.submitStatusCd ?? "") === "3" && String(row.prvnStatusCd ?? "") === "2";
String(row.submitStatusCd ?? "") === "3" && (String(row.prvnStatusCd ?? "") === "2" || String(row.prvnStatusCd ?? "") === "3");
const mapSewerageListToGridRows = (rawList: StatsInputStatusReviewItem[]) =>
rawList.map((r) => ({
@@ -54,9 +55,11 @@ const mapSewerageListToGridRows = (rawList: StatsInputStatusReviewItem[]) =>
// 하수도 통계 검토 00. 통계 입력 현황
const StatsInputStatusRvw = () => {
const { setMessage: alert } = useAlertStore((state) => state);
const { confirm } = useConfirm();
const [rowData, setRowData] = useState<Record<string, string | number>[]>([]);
const [historyRowData, setHistoryRowData] = useState<ReviewHistoryRow[]>([]);
const [isLoading, setIsLoading] = useState<boolean>(false);
const [isApproveLoading, setIsApproveLoading] = useState(false);
const [searchedYear, setSearchedYear] = useState<string>("");
const [lastSearchedValues, setLastSearchedValues] = useState<SearchFormValues | null>(null);
const [hasCompletedSearch, setHasCompletedSearch] = useState(false);
@@ -108,6 +111,7 @@ const StatsInputStatusRvw = () => {
setSearchedYear("");
setLastSearchedValues(null);
setHasCompletedSearch(false);
setIsApproveLoading(false);
};
const handleSearch = async (values: SearchFormValues) => {
@@ -158,11 +162,16 @@ const StatsInputStatusRvw = () => {
return;
}
// if (!rowData.every(isStatsInputStatusRowApprovable)) {
// alert("지자체 진행상태가 승인완료이고 진행상태가 제출완료인 항목만 승인할 수 있습니다.");
// return;
// }
if (!rowData.every(isStatsInputStatusRowApprovable)) {
alert("지자체 진행상태가 승인완료이고 진행상태가 제출완료인 항목만 승인할 수 있습니다.");
return;
}
if (!(await confirm("조회된 통계 항목을 승인하시겠습니까?"))) {
return;
}
setIsApproveLoading(true);
try {
const userInfo = await getUserInfo();
const mdfrId = (userInfo.userUnqId ?? "").trim();
@@ -188,7 +197,7 @@ const StatsInputStatusRvw = () => {
}
const response = await statisticsApi.approveStatsInputStatus({ list: approveItems }, "REVIEW");
if (response.status === "success") {
if (response.resultCode.toLocaleLowerCase() === "success") {
try {
const listResponse = await statisticsManagementApi.getStatsInputStatusList({
ctpvNm: lastSearchedValues.sido,
@@ -209,6 +218,8 @@ const StatsInputStatusRvw = () => {
}
} catch {
alert("승인 처리 중 오류가 발생했습니다.");
} finally {
setIsApproveLoading(false);
}
};
@@ -223,11 +234,20 @@ const StatsInputStatusRvw = () => {
[rowData],
);
/** 진행상태(prvn) 코드가 모두 승인완료(3) */
const allPrvnStatusApproved = useMemo(
() =>
rowData.length > 0 &&
rowData.every((row) => String(row.prvnStatusCd ?? "") === "3"),
[rowData],
);
const showActionToolbarCard =
hasCompletedSearch &&
rowData.length > 0 &&
gugunOkOrNotDoList &&
!allSubmitStatusApproved;
allSubmitStatusApproved &&
!allPrvnStatusApproved;
const openReviewHistoryForRow = useCallback(
async (row: Record<string, string | number>) => {
@@ -282,7 +302,7 @@ const StatsInputStatusRvw = () => {
</div>
)}
{isLoading && (
{(isLoading || isApproveLoading) && (
<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" />
@@ -1,5 +1,7 @@
import { ValueType } from "realgrid";
import type { ColumnInfoList } from "@/components/type/DataGrid.type";
/**
* 하수도 통계 관리 > 02. 하수관로 (합계)
* 항목은 검토(SewagePipeRvwColumn)와 동일하나 footer·mergeCells·구분(displayYear)·비고(rmrk) 제외.
@@ -976,16 +978,18 @@ export const getST00020003Config = (statsYr: string | number) => {
};
};
export const SewagePipeColumnInfoListTot = rawSewagePipeTotList.map((tab) => ({
export const SewagePipeColumnInfoListTot: ColumnInfoList[] = rawSewagePipeTotList.map((tab) => ({
...tab,
columns: tab.columns.map((col) => ({ ...col, editable: false as const })),
}));
SewagePipeColumnInfoListTot.splice(2, 0, {
const st00020003PlaceholderTab: ColumnInfoList = {
tabKey: "ST00020003",
tabLabel: "관종별 현황",
rowStartNum: 2,
fixedColumns: 2,
columns: [],
columnLayout: [],
} as any);
};
SewagePipeColumnInfoListTot.splice(2, 0, st00020003PlaceholderTab);
@@ -0,0 +1,72 @@
import { client } from "@/shared/api";
import { ENDPOINT } from "@/shared/config";
import type {
GroupUserService,
GroupUserSearchParams,
GroupUserListResponseDto,
GroupUserDetailResponse,
PageGroupUserList,
GroupUserSaveData,
GroupUserUpdateData,
} from "../model/group-user-interface";
export class GroupUsersApiService implements GroupUserService {
// 그룹 사용자 목록 조회
public async findPageGroupUser(
params: GroupUserSearchParams,
): Promise<PageGroupUserList> {
const response = await client.get<PageGroupUserList>(
`${ENDPOINT.CMS_SERVICE.GROUP_USERS}`,
{ params },
);
return response.data;
}
// 그룹 사용자 단건 조회
public async findByGroupAndUserId(
grpSn: number,
userUnqId: string,
): Promise<GroupUserDetailResponse> {
const response = await client.get<GroupUserDetailResponse>(
`${ENDPOINT.CMS_SERVICE.GROUP_USERS}/${grpSn}/${userUnqId}`,
);
return response.data;
}
// 그룹 사용자 저장
public async saveGroupUser(
data: GroupUserSaveData[],
): Promise<GroupUserListResponseDto[]> {
const response = await client.post<GroupUserListResponseDto[]>(
`${ENDPOINT.CMS_SERVICE.GROUP_USERS}`,
data,
);
return response.data;
}
// 그룹 사용자 수정
public async updateGroupUser(
grpSn: number,
userUnqId: string,
data: GroupUserUpdateData,
): Promise<GroupUserDetailResponse> {
const response = await client.put<GroupUserDetailResponse>(
`${ENDPOINT.CMS_SERVICE.GROUP_USERS}/${grpSn}/${userUnqId}`,
data,
);
return response.data;
}
// 그룹 사용자 삭제
public async deleteGroupUser(
groupId: string,
userUnqId: string,
): Promise<boolean> {
const response = await client.delete<boolean>(
`${ENDPOINT.CMS_SERVICE.GROUP_USERS}/${groupId}/${userUnqId}`,
);
return response.data;
}
}
export const GroupUsersApi = new GroupUsersApiService();
+5
View File
@@ -0,0 +1,5 @@
export { GroupUsersApi } from "./api/group-user-service";
export {
type GroupUserListResponseDto,
type GroupUserSaveData,
} from "./model/group-user-interface";
@@ -0,0 +1,55 @@
import { GroupUsersApi } from "../api/group-user-service";
export const GRP_ST_INPUT = "GRP_ST_INPUT";
export const GRP_ST_CONFIRM = "GRP_ST_CONFIRM";
export const GRP_ADMIN = "GRP_ADMIN";
const PAGE_SIZE = 500;
/**
* 지정한 그룹(`keyword`)마다 `findPageGroupUser`를 호출하고,
* 응답 목록이 비어 있지 않으며 로그인 사용자 `userUnqId`와 일치하는 행이 있으면 true.
*/
export async function checkPageGroupUserMembershipForUser(
userName: string,
userUnqId: string,
groupKeywords: readonly string[],
): Promise<boolean> {
const uid = userUnqId.trim();
const nm = userName.trim();
if (!uid || !nm) return false;
const paramsBase = { userNm: nm, page: 0, size: PAGE_SIZE };
for (const keyword of groupKeywords) {
const page = await GroupUsersApi.findPageGroupUser({
...paramsBase,
keyword,
});
const rows = page.content ?? [];
if (rows.length > 0 && rows.some((r) => String(r.userUnqId ?? "").trim() === uid)) {
return true;
}
}
return false;
}
/**
* 통계 입력 화면 상단 액션 — `GRP_ST_INPUT` / `GRP_ADMIN`.
*/
export async function checkStatisticsInputToolbarGroupMembership(
userName: string,
userUnqId: string,
): Promise<boolean> {
return checkPageGroupUserMembershipForUser(userName, userUnqId, [GRP_ST_INPUT, GRP_ADMIN]);
}
/**
* 통계 입력 현황 승인 등 — `GRP_ST_CONFIRM` / `GRP_ADMIN`.
*/
export async function checkStatisticsInputStatusConfirmGroupMembership(
userName: string,
userUnqId: string,
): Promise<boolean> {
return checkPageGroupUserMembershipForUser(userName, userUnqId, [GRP_ST_CONFIRM, GRP_ADMIN]);
}
@@ -0,0 +1,79 @@
import type { PageableList, PageableParams } from "@/shared/model";
// 그룹 사용자 검색 파라미터 인터페이스
export interface GroupUserSearchParams extends PageableParams {
keyword?: string;
userNm?: string;
}
// 그룹 사용자 목록 응답 인터페이스
export interface GroupUserListResponseDto {
mode: string;
groupId: string;
userUnqId: string;
userId: string;
userName: string;
ogdpSeCd: string;
ogdpSeNm: string;
mphn: string;
}
// 그룹 사용자 페이지 응답 인터페이스
export type PageGroupUserList = PageableList<GroupUserListResponseDto>;
// 그룹 사용자 상세 응답 인터페이스
export interface GroupUserDetailResponse {
grpSn: number;
userUnqId: string;
userId: string;
userName: string;
email: string;
mphn: string;
regDt: string;
rgtr: string;
mdfcnDt: string;
mdfr: string;
rgtrNm: string;
mdfrNm: string;
}
// 그룹 사용자 저장 데이터 인터페이스
export interface GroupUserSaveData {
mode: string;
groupId: string;
userUnqId: string;
}
// 그룹 사용자 수정 데이터 인터페이스
export interface GroupUserUpdateData {
userUnqId: string;
}
// 그룹 사용자 서비스 인터페이스
export interface GroupUserService {
// 그룹 사용자 목록 조회
findPageGroupUser: (
params: GroupUserSearchParams,
) => Promise<PageGroupUserList>;
// 그룹 사용자 단건 조회
findByGroupAndUserId: (
grpSn: number,
userUnqId: string,
) => Promise<GroupUserDetailResponse>;
// 그룹 사용자 저장
saveGroupUser: (
data: GroupUserSaveData[],
) => Promise<GroupUserListResponseDto[]>;
// 그룹 사용자 수정
updateGroupUser: (
grpSn: number,
userUnqId: string,
data: GroupUserUpdateData,
) => Promise<GroupUserDetailResponse>;
// 그룹 사용자 삭제
deleteGroupUser: (groupId: string, userUnqId: string) => Promise<boolean>;
}
@@ -21,6 +21,8 @@ import {
type ReviewSubmitResponse,
type SearchCondition,
type Sgg,
type StatiscicsManagementReviewHistoryRequest,
type StatiscicsManagementReviewHistoryResponse,
type StatisticsColumnGuideListResponse,
type StatisticsLogDetailResponse,
type StatisticsLogRequest,
@@ -301,6 +303,14 @@ export class StatisticsApiService implements StatisticsService {
);
return response.data;
}
public async getStatiscicsManagementReviewHistory(param: StatiscicsManagementReviewHistoryRequest): Promise<StatiscicsManagementReviewHistoryResponse> {
const response = await stClient.get<StatiscicsManagementReviewHistoryResponse>(
`${ENDPOINT.ST_SERVICE.REVIEW}/stats-review`,
{ params: param },
);
return response.data;
}
}
export const statisticsApi = new StatisticsApiService();
@@ -187,10 +187,11 @@ export interface PublicSewageMainFacilityMappingSaveRequest {
admdstNm: string;
mainFcltSn: string;
mainFcltNm: string;
prxyBzentyNmCn: string;
cnsgnCstVl: number;
wholNope: number;
prxyCtrtCn: string;
/** 매핑 추가(saveList 있음) 시 입력. 매핑 삭제만 할 때는 요청에서 생략 가능 */
prxyBzentyNmCn?: string;
cnsgnCstVl?: number;
wholNope?: number;
prxyCtrtCn?: string;
userId: string;
saveList: PublicSewageMainFacilityMappingSaveItem[];
deleteList: string[];
@@ -393,8 +393,10 @@ export interface StatsInputStatusApproveBatchRequest {
/** 통계 입력 현황 일괄 승인 API 응답 */
export interface StatsInputStatusApproveResponse {
status: string;
message?: string;
exceptionCnt: number;
message: string;
resultCode: string;
updatedStts: string;
}
export interface SubmitVerifyExceptionItem {
@@ -434,6 +436,32 @@ export interface ReviewerInfoResponse {
}
}
export interface StatiscicsManagementReviewHistoryRequest {
statsYr: string;
sewerBzmnCd?: string;
ctpvNm?: string;
statsCd?: string;
}
/** 통계관리 검토 이력 조회 API `data` 배열 요소 */
export interface StatiscicsManagementReviewHistoryItem {
statsYr: string;
ctpvNm: string;
sggNm: string;
sewerBzmnCd: string;
statsCd: string;
menuNm: string;
wrterOpinionSm: string | null;
chckerOpinionSm: string | null;
returnOpinionSm: string | null;
}
export interface StatiscicsManagementReviewHistoryResponse {
status: string;
message?: string;
data: StatiscicsManagementReviewHistoryItem[];
}
export interface StatisticsService {
getSidoList(params?: { instSeCd?: string }): Promise<ApiResponse<SearchCondition>>;
getSggList(sidoName: string, params?: { instSeCd?: string; gugunSkipYn?: "Y" | "N" }): Promise<ApiResponse<Sgg[]>>;
@@ -461,4 +489,5 @@ export interface StatisticsService {
SubmitVerifyException(param: SubmitVerifyExceptionRequest): Promise<SubmitVerifyExceptionResponse>;
getColumnGuides(param: string): Promise<StatisticsColumnGuideListResponse>;
getReviewerInfo(param: ReviewerInfoRequest): Promise<ReviewerInfoResponse>;
getStatiscicsManagementReviewHistory(param: StatiscicsManagementReviewHistoryRequest): Promise<StatiscicsManagementReviewHistoryResponse>;
}
+4
View File
@@ -5,5 +5,9 @@ export { useDataTable } from "./use-data-table";
export { useGlobalProgressStore } from "./use-global-progress-store";
export { useMenuStore } from "./use-menu-store";
export { useUserStore } from "./use-user-store";
export {
useStatisticsInputStatusConfirmGroupGate,
useStatisticsInputToolbarGroupGate,
} from "./use-statistics-input-toolbar-group-gate";
export { usePrompt } from "./use-prompt";
export { useSelectPrompt } from "./use-select-prompt";
@@ -0,0 +1,68 @@
"use client";
import { useEffect, useState } from "react";
import {
checkStatisticsInputStatusConfirmGroupMembership,
checkStatisticsInputToolbarGroupMembership,
} from "@/entities/group-user/lib/check-statistics-input-toolbar-group-membership";
import { useUserStore } from "./use-user-store";
/** 통계 입력 상단 툴바(저장·제출·검증결과 등) — 그룹 멤버십 만족 시에만 true */
export function useStatisticsInputToolbarGroupGate(): boolean {
const userInfo = useUserStore((s) => s.userInfo);
const [allowed, setAllowed] = useState(false);
useEffect(() => {
let cancelled = false;
const name = userInfo?.userName?.trim() ?? "";
const uid = userInfo?.userUnqId?.trim() ?? "";
if (!name || !uid) {
setAllowed(false);
return;
}
void (async () => {
try {
const ok = await checkStatisticsInputToolbarGroupMembership(name, uid);
if (!cancelled) setAllowed(ok);
} catch {
if (!cancelled) setAllowed(false);
}
})();
return () => {
cancelled = true;
};
}, [userInfo?.userName, userInfo?.userUnqId]);
return allowed;
}
/** 통계 입력 현황 — `GRP_ST_CONFIRM` / `GRP_ADMIN` 멤버일 때만 true */
export function useStatisticsInputStatusConfirmGroupGate(): boolean {
const userInfo = useUserStore((s) => s.userInfo);
const [allowed, setAllowed] = useState(false);
useEffect(() => {
let cancelled = false;
const name = userInfo?.userName?.trim() ?? "";
const uid = userInfo?.userUnqId?.trim() ?? "";
if (!name || !uid) {
setAllowed(false);
return;
}
void (async () => {
try {
const ok = await checkStatisticsInputStatusConfirmGroupMembership(name, uid);
if (!cancelled) setAllowed(ok);
} catch {
if (!cancelled) setAllowed(false);
}
})();
return () => {
cancelled = true;
};
}, [userInfo?.userName, userInfo?.userUnqId]);
return allowed;
}