Merge branch 'iipbs/st/dev'

This commit is contained in:
2026-04-29 11:38:46 +09:00
41 changed files with 2615 additions and 653 deletions
+14 -4
View File
@@ -35,6 +35,9 @@
.rg-grid .rg-body .rg-table .rg-data-row .rg-data-cell.rg-cell-rowtype-biz-sum {
@apply !bg-[#ffe9cf];
}
.rg-grid .rg-body .rg-table .rg-data-row .rg-data-cell.rg-cell-rowtype-summary {
@apply !bg-[#feee85];
}
.rg-grid .rg-body .rg-table .rg-data-row .rg-data-cell.rg-cell-rowtype-other {
@apply !bg-[#fff7bf];
}
@@ -42,6 +45,9 @@
.rg-grid .rg-body .rg-data-cell.rg-cell-rowtype-biz-sum {
@apply !bg-[#ffe9cf];
}
.rg-grid .rg-body .rg-data-cell.rg-cell-rowtype-summary {
@apply !bg-[#feee85];
}
.rg-grid .rg-body .rg-data-cell.rg-cell-rowtype-other {
@apply !bg-[#fff7bf];
}
@@ -49,6 +55,10 @@
.rg-grid .rg-fixed-body .rg-data-row .rg-data-cell.rg-cell-rowtype-biz-sum {
@apply !bg-[#ffe9cf];
}
.rg-grid .rg-fixed-body .rg-table .rg-data-row td.rg-cell-rowtype-summary,
.rg-grid .rg-fixed-body .rg-data-row .rg-data-cell.rg-cell-rowtype-summary {
@apply !bg-[#feee85];
}
.rg-grid .rg-fixed-body .rg-table .rg-data-row td.rg-cell-rowtype-other,
.rg-grid .rg-fixed-body .rg-data-row .rg-data-cell.rg-cell-rowtype-other {
@apply !bg-[#fff7bf];
@@ -56,13 +66,13 @@
/* 진행상황: 소프트 인라인 요약 (툴바와 자연스럽게 blend) */
.data-grid-status-bubble {
@apply rounded-lg border border-slate-200/80 bg-slate-50/90 px-4 py-2.5;
@apply rounded-lg border border-slate-200/80 bg-white px-8 py-2.5;
box-shadow: none;
}
.data-grid-status-bubble span {
@apply text-base text-slate-700;
.data-grid-status-bubble > span {
@apply text-xl text-slate-700 font-bold;
}
.data-grid-status-bubble span + span {
.data-grid-status-bubble > span + span {
@apply ml-4 border-l border-slate-200/70 pl-4;
}
@@ -25,6 +25,12 @@ interface RealGridDataProviderComparer {
getValue?(row: number, field: string | number): unknown;
}
type GridViewWithCheckApi = GridViewWithSetColumns & {
checkItem?: (itemIndex: number, checked: boolean, exclusive?: boolean) => void;
checkRow?: (dataRow: number, checked: boolean, exclusive?: boolean) => void;
checkAll?: (checked: boolean) => void;
};
/** columnInfo → RealGrid DataField 배열 (computed → valueCallback 포함, setFields용) */
function buildFieldsFromColumns(columns: { fieldName: string; valueType?: ValueType; computed?: import("../type/DataGrid.type").ComputedFieldConfig }[]) {
return columns.map((col) => {
@@ -72,6 +78,8 @@ const CompactDataGrid = ({
getGridDataRef,
cellStyleCallback,
onRowClick,
onItemChecked,
onItemAllChecked,
compactFullWidth = false,
compactGridHeightClass = "h-[65vh]",
}: DataGridProps) => {
@@ -96,6 +104,32 @@ const CompactDataGrid = ({
(dataProvider as { setRows: (r: unknown[]) => void }).setRows(rowData);
}
// __checked 플래그(1/true/Y) 기반으로 체크바 상태 동기화
if (showHeaderCheckbox) {
const checkGrid = gridView as GridViewWithCheckApi;
const checkByRowFlag = (value: unknown) =>
value === true ||
value === 1 ||
value === "1" ||
value === "Y" ||
value === "y" ||
value === "true";
try {
checkGrid.checkAll?.(false);
rowData.forEach((row, itemIndex) => {
const checked = checkByRowFlag((row as Record<string, unknown>).__checked);
if (!checked) return;
if (checkGrid.checkItem) {
checkGrid.checkItem(itemIndex, true, false);
return;
}
checkGrid.checkRow?.(itemIndex, true, false);
});
} catch (e) {
console.warn("check sync:", e);
}
}
// setDataComparer로 숫자 정렬 (setFields 미사용 → 데이터 유실·필드 중복 오류 없음)
const dp = dataProvider as RealGridDataProviderComparer;
if (typeof dp.setDataComparer === "function" && typeof dp.getValue === "function") {
@@ -337,6 +371,32 @@ const CompactDataGrid = ({
};
}, [onRowClick]);
useEffect(() => {
const gridView = gridRef.current?.gridView;
if (!gridView) return;
if (onItemChecked) {
gridView.onItemChecked = (_grid, itemIndex, checked) => {
onItemChecked(itemIndex, checked);
};
} else {
gridView.onItemChecked = undefined;
}
if (onItemAllChecked) {
gridView.onItemAllChecked = (_grid, checked) => {
onItemAllChecked(checked);
};
} else {
gridView.onItemAllChecked = undefined;
}
return () => {
if (gridView.onItemChecked) gridView.onItemChecked = undefined;
if (gridView.onItemAllChecked) gridView.onItemAllChecked = undefined;
};
}, [onItemChecked, onItemAllChecked]);
return (
<div className={`flex flex-col gap-[0.6rem] ${compactFullWidth ? "w-full min-w-0" : "w-1/2"}`}>
<div className={compactGridHeightClass}>
+81 -71
View File
@@ -580,10 +580,10 @@ const DataGrid = ({
) {
const cell =
worksheet[
XLSX.utils.encode_cell({
r: rowIndex,
c: col,
})
XLSX.utils.encode_cell({
r: rowIndex,
c: col,
})
];
if (
cell &&
@@ -659,7 +659,7 @@ const DataGrid = ({
const col = range.s.c + colIndex;
const cell =
worksheet[
XLSX.utils.encode_cell({ r: row, c: col })
XLSX.utils.encode_cell({ r: row, c: col })
];
const headerName =
headers[colIndex] || `Column${colIndex + 1}`;
@@ -783,13 +783,13 @@ const DataGrid = ({
onSorting?: (g: unknown, f: unknown[], d: unknown[]) => boolean;
}
).onSorting = enableSort
? createOnSortingHandler(
columns,
setRowData,
sortDirRef,
originalRowsRef,
)
: () => false;
? createOnSortingHandler(
columns,
setRowData,
sortDirRef,
originalRowsRef,
)
: () => false;
(gridView.displayOptions as DisplayOptions).fitStyle =
GridFitStyle.EVEN;
@@ -959,7 +959,7 @@ const DataGrid = ({
) {
(
column.footer[
index
index
] as FooterItemWithCallback
).valueCallback =
footerItem.valueCallback;
@@ -1012,11 +1012,11 @@ const DataGrid = ({
getGridDataRef.current = () => {
const gv = gridRef.current?.gridView as
| {
getDataSource?: () => {
getRowCount(): number;
getValue(r: number, f: string): unknown;
};
}
getDataSource?: () => {
getRowCount(): number;
getValue(r: number, f: string): unknown;
};
}
| undefined;
if (!gv?.getDataSource) return [];
const ds = gv.getDataSource();
@@ -1189,29 +1189,37 @@ const DataGrid = ({
style={{ display: "none" }}
onChange={handleFileChange}
/>
<div className="flex items-center justify-end gap-[0.6rem] px-[1.2rem]">
<div className={`flex items-center ${statusInfo ? 'justify-between' : 'justify-end'} gap-[0.6rem] pr-[1.2rem]`}>
{statusInfo && (
<div className="data-grid-status-bubble flex flex-wrap items-center gap-x-4 gap-y-1">
<span>
진행상황:{" "}
{statusInfo.laeSbmsnSttsCd === null ||
statusInfo.laeSbmsnSttsCd === "0"
statusInfo.laeSbmsnSttsCd === "0"
? "미작성"
: statusInfo.laeSbmsnSttsCd === "1"
? "작성중"
: statusInfo.laeSbmsnSttsCd === "2"
? "제출"
: statusInfo.laeSbmsnSttsCd === "3"
? "승인"
: statusInfo.laeSbmsnSttsCd === "4"
? "반려"
: "검증중"}
? <span className="animate-pulse">작성중</span>
: statusInfo.laeSbmsnSttsCd === "2"
? <span className="!text-[#2094fa]">제출</span>
: statusInfo.laeSbmsnSttsCd === "3"
? <span className="!text-[#1fd078]">승인</span>
: statusInfo.laeSbmsnSttsCd === "4"
? (
<span className="relative inline-flex !text-[#ff6c64] pr-3">
반려
<span className="absolute -right-0.5 -top-0.5 inline-flex size-[0.6125rem]">
<span className="absolute inline-flex h-full w-full animate-ping rounded-full bg-[#ff6c64] opacity-75" />
<span className="relative inline-flex size-[0.6125rem] rounded-full bg-[#ff6c64]" />
</span>
</span>
)
: <span className="animate-pulse">검증중</span>}
</span>
<span>
제출일:{" "}
{statusInfo.laeSbmsnYmd
? statusInfo.laeSbmsnYmd.length === 8 &&
/^\d{8}$/.test(statusInfo.laeSbmsnYmd)
/^\d{8}$/.test(statusInfo.laeSbmsnYmd)
? `${statusInfo.laeSbmsnYmd.slice(0, 4)}-${statusInfo.laeSbmsnYmd.slice(4, 6)}-${statusInfo.laeSbmsnYmd.slice(6, 8)}`
: statusInfo.laeSbmsnYmd
: "미제출"}
@@ -1228,26 +1236,28 @@ const DataGrid = ({
</span>
</div>
)}
{isExcelImport && (
<Button
className="h-[3.2rem] w-[96px] bg-gradient-green px-6 hover:bg-gradient-green-hover"
type="button"
onClick={importExcel}
>
<i className="diveicon di-file-excel" />
<span>가져오기</span>
</Button>
)}
{isExcelExport && (
<Button
className="h-[3.2rem] w-[96px] bg-gradient-green px-6 hover:bg-gradient-green-hover"
type="button"
onClick={exportExcel}
>
<i className="diveicon di-file-excel" />
<span>내보내기</span>
</Button>
)}
<div className="flex items-center gap-[0.6rem]">
{isExcelImport && (
<Button
className="h-[3.2rem] w-[96px] bg-gradient-green px-6 hover:bg-gradient-green-hover"
type="button"
onClick={importExcel}
>
<i className="diveicon di-file-excel" />
<span>가져오기</span>
</Button>
)}
{isExcelExport && (
<Button
className="h-[3.2rem] w-[96px] bg-gradient-green px-6 hover:bg-gradient-green-hover"
type="button"
onClick={exportExcel}
>
<i className="diveicon di-file-excel" />
<span>내보내기</span>
</Button>
)}
</div>
</div>
<div style={{ height: `${gridHeight}vh` }}>
<RealGridReact
@@ -1266,24 +1276,24 @@ const DataGrid = ({
) => boolean;
}
).onSorting = enableSort
? createOnSortingHandler(
columns,
setRowData,
sortDirRef,
originalRowsRef,
)
: () => false;
? createOnSortingHandler(
columns,
setRowData,
sortDirRef,
originalRowsRef,
)
: () => false;
const dataProvider = gridRef.current
?.dataProvider as unknown as
| {
setFields?(f: unknown[]): void;
setRows?(
r: Record<string, string | number>[],
): void;
getJsonRow?(
r: number,
): Record<string, unknown>;
}
setFields?(f: unknown[]): void;
setRows?(
r: Record<string, string | number>[],
): void;
getJsonRow?(
r: number,
): Record<string, unknown>;
}
| undefined;
if (dataProvider?.setFields) {
dataProvider.setFields(
@@ -1436,14 +1446,14 @@ const DataGrid = ({
})}
footer={
column.footer &&
!Array.isArray(column.footer)
!Array.isArray(column.footer)
? {
text: column.footer.text,
expression:
column.footer.expression,
numberFormat:
column.footer.numberFormat,
}
text: column.footer.text,
expression:
column.footer.expression,
numberFormat:
column.footer.numberFormat,
}
: undefined
}
mergeRule={
@@ -4,15 +4,20 @@ import { useState } from "react";
import { Button, Dialog, DialogContent, DialogHeader, DialogTitle } from "@common/components";
import SidoSggFacilityNameSearchForm from "./SidoSggFacilityNameSearchForm";
import type { SearchFormValues } from "../type/SearchForm.type";
type GridTopActionButtonProps = {
text: string;
onClick?: (helpers: { openDialog: (title?: string) => void; closeDialog: () => void }) => void;
/** 상위 화면 검색 통계연도 — 시설 조회 다이얼로그에 전달 */
statsYr?: string;
/** 상위 화면 최근 검색조건 */
initialSearchValues?: Pick<SearchFormValues, "sido" | "gugun" | "bsnmCd"> | null;
/** 다이얼로그 저장 성공 후 부모 갱신 */
onSaveSuccess?: () => Promise<void> | void;
};
const GridTopActionButton = ({ text, onClick, statsYr }: GridTopActionButtonProps) => {
const GridTopActionButton = ({ text, onClick, statsYr, initialSearchValues, onSaveSuccess }: GridTopActionButtonProps) => {
const [isOpen, setIsOpen] = useState(false);
const [title, setTitle] = useState(text);
@@ -41,7 +46,11 @@ const GridTopActionButton = ({ text, onClick, statsYr }: GridTopActionButtonProp
<DialogHeader>
<DialogTitle>{title}</DialogTitle>
</DialogHeader>
<SidoSggFacilityNameSearchForm statsYr={statsYr} />
<SidoSggFacilityNameSearchForm
statsYr={statsYr}
initialSearchValues={initialSearchValues}
onSaveSuccess={onSaveSuccess}
/>
</DialogContent>
</Dialog>
</>
+29 -2
View File
@@ -89,6 +89,7 @@ const SearchForm = (props: SearchFormProps = {}) => {
fctName: props.defaultValues?.fctName || "",
bsnmCd: props.defaultValues?.bsnmCd || "",
isDetail: false,
isSummary: props.defaultValues?.isSummary || "",
});
const sggKey: [string, string, string, string] | null =
@@ -132,6 +133,7 @@ const SearchForm = (props: SearchFormProps = {}) => {
fctName: "",
bsnmCd: "",
isDetail: false,
isSummary: "",
};
setValues(resetValues);
props.onReset?.();
@@ -153,7 +155,7 @@ const SearchForm = (props: SearchFormProps = {}) => {
const selectedBsnm =
value === "ALL"
? sidoList?.data.sidoList.find((v) => v.sido === values.sido)
?.bsnmCd || ""
?.bsnmCd || ""
: listForBsnm.find((v) => v.gugun === value)?.bsnmCd || "";
setValues((prev) => ({
@@ -250,10 +252,11 @@ const SearchForm = (props: SearchFormProps = {}) => {
)}
{mergedProps.isDetail && (
<div className="flex items-center gap-2">
<Label className="text-label">
<Label className="text-label cursor-pointer" htmlFor="isDetail">
세부 데이터 포함
</Label>
<Checkbox
id="isDetail"
checked={values.isDetail}
onCheckedChange={(value) =>
setValues({
@@ -264,6 +267,30 @@ const SearchForm = (props: SearchFormProps = {}) => {
/>
</div>
)}
{mergedProps.isSummary && (
<div className="flex items-center gap-2">
<Label className="text-label cursor-pointer">
요약표 포함
</Label>
<Select
value={values.isSummary}
onValueChange={(value) =>
setValues({ ...values, isSummary: value })
}
>
<SelectTrigger className="w-[10rem]">
<SelectValue placeholder="선택 안함"></SelectValue>
</SelectTrigger>
<SelectContent>
<SelectItem value="ALL">선택 안함</SelectItem>
{[1,2,3,4,5,6,7,8,9,10].map((i) => (
<SelectItem value={i.toString()} key={i.toString()}>{`${i}개년`}</SelectItem>
))
}
</SelectContent>
</Select>
</div>
)}
{mergedProps.order && (
<div className="flex items-center gap-2">
<Label className="text-label">차수</Label>
@@ -1,6 +1,6 @@
"use client";
import { useState } from "react";
import { useEffect, useRef, useState } from "react";
import useSWR from "swr";
import { ValueType } from "realgrid";
@@ -29,8 +29,13 @@ import type { ColumnInfo } from "../type/DataGrid.type";
import type {
SeweragePipeFacilitiesListRequest,
SeweragePipeFacilityAdminAreaListRequest,
SeweragePipeFactConnectionSaveRequest,
SeweragePipeFactConnectionSaveItem,
} from "@/entities/statistics/model/input/statistics-sewage-pipe-input-interface";
import { statisticsInputSewPipeApiService } from "@/entities/statistics/api/input/statistics-sewage-pipe-input-service";
import type { SearchFormValues } from "../type/SearchForm.type";
import { doNameList } from "../util/column/input/DoList";
import { getUserInfo } from "../../../../../packages/common/components/src/api";
/** 다이얼로그 좌측: 처리시설 정보 (순번 없음) */
const treatmentFacilityColumnInfo: ColumnInfo = {
@@ -48,21 +53,35 @@ const treatmentFacilityColumnInfo: ColumnInfo = {
editable: false,
},
{
name: "fctNm",
fieldName: "fctNm",
name: "fcltNm",
fieldName: "fcltNm",
header: { text: "시설명" },
editable: false,
},
{
name: "fctCpc",
fieldName: "fctCpc",
name: "fcltCd",
fieldName: "fcltCd",
header: { text: "시설코드" },
editable: false,
visible: false,
},
{
name: "fcltSn",
fieldName: "fcltSn",
header: { text: "시설일련번호" },
editable: false,
visible: false,
},
{
name: "fcltCpct",
fieldName: "fcltCpct",
header: { text: "시설용량(m³/일)" },
valueType: ValueType.NUMBER,
dataFormat: "#,##0",
editable: false,
},
],
columnLayout: ["ctpvNm", "sggNm", "fctNm", "fctCpc"],
columnLayout: ["ctpvNm", "sggNm", "fcltNm", "fcltCpct", "fcltCd"],
};
/** 다이얼로그 우측: 행정구역 정보 (순번 없음, 헤더 체크박스는 showHeaderCheckbox) */
@@ -103,12 +122,26 @@ const getSggAllListFetcher = async (
type SidoSggFacilityNameSearchFormProps = {
/** 상위(SewagePipeInp 등) 검색 조건의 통계연도 — facilitiesList 등 API 필수값 */
statsYr?: string;
/** 상위(SewagePipeInp) 마지막 검색 조건 */
initialSearchValues?: Pick<SearchFormValues, "sido" | "gugun" | "bsnmCd"> | null;
/** 저장 성공 후 부모 탭 데이터 재조회 훅 */
onSaveSuccess?: () => Promise<void> | void;
};
const SidoSggFacilityNameSearchForm = ({
statsYr,
initialSearchValues,
onSaveSuccess,
}: SidoSggFacilityNameSearchFormProps) => {
const { setMessage: alert } = useAlertStore((state) => state);
const initialSidoValue =
initialSearchValues?.sido && initialSearchValues.sido !== "ALL"
? initialSearchValues.sido
: "ALL";
const initialGugunValue =
initialSearchValues?.gugun && initialSearchValues.gugun !== "ALL"
? initialSearchValues.gugun
: "ALL";
const { data: sidoList } = useSWR<ApiResponse<SearchCondition>>(
"sido-list",
@@ -134,9 +167,9 @@ const SidoSggFacilityNameSearchForm = ({
},
);
const [sido, setSido] = useState<string>("ALL");
const [gugun, setGugun] = useState<string>("ALL");
const [fctNm, setFctNm] = useState<string>("");
const [sido, setSido] = useState<string>(initialSidoValue);
const [gugun, setGugun] = useState<string>(initialGugunValue);
const [fcltNm, setFcltNm] = useState<string>("");
const [isLoading, setIsLoading] = useState<boolean>(false);
const [treatmentFacilityRows, setTreatmentFacilityRows] = useState<
@@ -145,6 +178,10 @@ const SidoSggFacilityNameSearchForm = ({
const [adminRegionRows, setAdminRegionRows] = useState<
Record<string, string | number>[]
>([]);
const [selectedFacilityRow, setSelectedFacilityRow] = useState<
Record<string, string | number> | null
>(null);
const didAutoFetchRef = useRef(false);
const sggKey: [string, string] | null =
sido && sido !== "ALL" ? ["sgg-all", sido] : null;
@@ -198,7 +235,7 @@ const SidoSggFacilityNameSearchForm = ({
alert("시도를 선택해주세요.");
return;
}
const trimmedFclty = fctNm.trim();
const trimmedFclty = fcltNm.trim();
const sewerBzmnCd = resolveSewerBzmnCd();
@@ -225,13 +262,15 @@ const SidoSggFacilityNameSearchForm = ({
list.map((item) => ({
ctpvNm: item.ctpvNm,
sggNm: item.sggNm,
fctNm: item.bscFctNm,
fctCpc: item.fctCpc,
bscFctCd: item.bscFctCd,
fcltNm: item.fcltNm,
fcltCpct: item.fcltCpct,
fcltCd: item.fcltCd,
fcltSn: item.fcltSn,
admdstCd: item.admdstCd,
statsYr: item.statsYr,
})),
);
setSelectedFacilityRow(null);
setAdminRegionRows([]);
} catch {
alert("처리시설 목록 조회 중 오류가 발생했습니다.");
@@ -247,7 +286,7 @@ const SidoSggFacilityNameSearchForm = ({
alert("상단에서 통계연도로 조회한 뒤 이용해 주세요.");
return;
}
const swtrmtFcltCd = String(row.bscFctCd ?? "").trim();
const swtrmtFcltCd = String(row.fcltCd ?? "").trim();
if (!swtrmtFcltCd) {
alert("처리시설 코드가 없습니다.");
return;
@@ -261,13 +300,14 @@ const SidoSggFacilityNameSearchForm = ({
const params: SeweragePipeFacilityAdminAreaListRequest = {
statsYr: statsYr.trim(),
swtrmtFcltCd,
sewerBzmnCd: String(row.admdstCd ?? "").trim(),
sewerBzmnCd: initialSearchValues?.bsnmCd || '',
ctpvNm,
sggNm,
...(doNameList.includes(ctpvNm) ? { sggNm } : {}),
};
try {
setIsLoading(true);
setSelectedFacilityRow(row);
const response =
await statisticsInputSewPipeApiService.getFacilityAdminAreaList(
params,
@@ -283,6 +323,8 @@ const SidoSggFacilityNameSearchForm = ({
sggNm: item.sggNm,
emdNm: item.admdstNm,
admdstCd: item.admdstCd,
isConnection: item.isConnection,
__checked: item.isConnection === "1" ? "1" : "0",
})),
);
} catch {
@@ -293,9 +335,172 @@ const SidoSggFacilityNameSearchForm = ({
};
const handleSaveAdminRegions = () => {
alert("저장 API 연동 예정입니다.");
if (!statsYr?.trim()) {
alert("상단에서 통계연도로 조회한 뒤 이용해 주세요.");
return;
}
if (!selectedFacilityRow) {
alert("처리시설을 먼저 선택해주세요.");
return;
}
const hasChanged = adminRegionRows.some(
(row) =>
(row.__checked === "1" && row.isConnection !== "1") ||
(row.__checked !== "1" && row.isConnection === "1"),
);
const saveList: SeweragePipeFactConnectionSaveItem[] = adminRegionRows
.filter((row) => row.__checked === "1")
.map((row) => ({
admdstCd: String(row.admdstCd ?? ""),
admdstNm: String(row.emdNm ?? ""),
}))
.filter((row) => row.admdstCd && row.admdstNm);
const deleteList = adminRegionRows
.filter((row) => row.__checked !== "1" && row.isConnection === "1")
.map((row) => String(row.admdstCd ?? ""))
.filter(Boolean);
if (!hasChanged) {
alert("변경된 연결 정보가 없습니다.");
return;
}
const run = async () => {
try {
setIsLoading(true);
const userInfo = await getUserInfo();
const request: SeweragePipeFactConnectionSaveRequest = {
ctpvNm: String(selectedFacilityRow.ctpvNm ?? ""),
sggNm: String(selectedFacilityRow.sggNm ?? ""),
fcltSn: String(selectedFacilityRow.fcltSn ?? ""),
statsYr: statsYr.trim(),
swtrmtFcltCd: String(selectedFacilityRow.fcltCd ?? ""),
swtrmtFcltNm: String(selectedFacilityRow.fcltNm ?? ""),
prcsFcltCpct: Number(selectedFacilityRow.fcltCpct ?? 0),
sewerBzmnCd: initialSearchValues?.bsnmCd?.trim() ?? "",
userId: userInfo.userUnqId,
saveList,
deleteList,
};
const response =
await statisticsInputSewPipeApiService.saveFactConnection(
request,
);
if (response.status !== "success") {
alert(response.message ?? "연결관계 저장에 실패했습니다.");
return;
}
setAdminRegionRows((prev) =>
prev.map((row) => ({
...row,
isConnection: row.__checked === "1" ? "1" : "0",
})),
);
await onSaveSuccess?.();
alert("저장되었습니다.");
} catch {
alert("연결관계 저장 중 오류가 발생했습니다.");
} finally {
setIsLoading(false);
}
};
void run();
};
const handleAdminRegionItemChecked = (
itemIndex: number,
checked: boolean,
) => {
setAdminRegionRows((prev) =>
prev.map((row, idx) =>
idx === itemIndex
? { ...row, __checked: checked ? "1" : "0" }
: row,
),
);
};
const handleAdminRegionItemAllChecked = (checked: boolean) => {
setAdminRegionRows((prev) =>
prev.map((row) => ({ ...row, __checked: checked ? "1" : "0" })),
);
};
useEffect(() => {
if (!initialSearchValues?.sido || initialSearchValues.sido === "ALL") return;
setSido(initialSearchValues.sido);
setGugun(
initialSearchValues.gugun && initialSearchValues.gugun !== "ALL"
? initialSearchValues.gugun
: "ALL",
);
}, [initialSearchValues?.sido, initialSearchValues?.gugun]);
useEffect(() => {
if (didAutoFetchRef.current) return;
if (!statsYr?.trim()) return;
if (!initialSearchValues?.sido || initialSearchValues.sido === "ALL") return;
const nextSido = initialSearchValues.sido;
const nextGugun =
initialSearchValues.gugun && initialSearchValues.gugun !== "ALL"
? initialSearchValues.gugun
: "ALL";
didAutoFetchRef.current = true;
setSido(nextSido);
setGugun(nextGugun);
const params: SeweragePipeFacilitiesListRequest = {
statsYr: statsYr.trim(),
...(initialSearchValues.bsnmCd?.trim()
? { sewerBzmnCd: initialSearchValues.bsnmCd.trim() }
: {}),
ctpvNm: nextSido,
...(nextGugun !== "ALL" ? { sggNm: nextGugun } : {}),
};
const run = async () => {
try {
setIsLoading(true);
const response =
await statisticsInputSewPipeApiService.getFacilitiesList(params);
if (response.status !== "success") {
alert(response.message ?? "처리시설 목록 조회에 실패했습니다.");
return;
}
const list = response.data ?? [];
setTreatmentFacilityRows(
list.map((item) => ({
ctpvNm: item.ctpvNm,
sggNm: item.sggNm,
fcltNm: item.fcltNm,
fcltCpct: item.fcltCpct,
fcltCd: item.fcltCd,
fcltSn: item.fcltSn,
admdstCd: item.admdstCd,
statsYr: item.statsYr,
})),
);
setSelectedFacilityRow(null);
setAdminRegionRows([]);
} catch {
alert("처리시설 목록 조회 중 오류가 발생했습니다.");
} finally {
setIsLoading(false);
}
};
void run();
}, [statsYr, initialSearchValues, alert]);
return (
<div className="flex flex-col gap-4 pt-2">
<form onSubmit={handleSubmit} className="flex flex-col gap-4">
@@ -346,8 +551,8 @@ const SidoSggFacilityNameSearchForm = ({
<Label className="shrink-0 text-label">시설명</Label>
<Input
className="min-w-[12rem] flex-1"
value={fctNm}
onChange={(e) => setFctNm(e.target.value)}
value={fcltNm}
onChange={(e) => setFcltNm(e.target.value)}
placeholder="시설명 입력"
/>
</div>
@@ -396,6 +601,8 @@ const SidoSggFacilityNameSearchForm = ({
rowData={adminRegionRows}
setRowData={setAdminRegionRows}
showHeaderCheckbox
onItemChecked={handleAdminRegionItemChecked}
onItemAllChecked={handleAdminRegionItemAllChecked}
compactFullWidth
// compactGridHeightClass="h-[min(40vh,28rem)] min-h-[16rem]"
/>
@@ -63,9 +63,6 @@ function Dashboard() {
items={panels.map((p) => p.id)}
strategy={rectSortingStrategy}
>
{/* TBD: 그리드 레이아웃 비율 정의 필요
현재: 3열 그리드, 반응형 브레이크포인트 적용
SPEC 레이아웃에 따라 조정 필요 */}
<div className="grid grid-cols-1 gap-4 p-2 md:grid-cols-2 lg:grid-cols-3">
{panels.map((panel) => (
<SortablePanel
@@ -81,12 +78,12 @@ function Dashboard() {
</DndContext>
) : (
<div className="grid grid-cols-1 gap-4 p-2 md:grid-cols-2 lg:grid-cols-3">
<Skeleton className="h-[20rem] rounded-xl" />
<Skeleton className="h-[20rem] rounded-xl" />
<Skeleton className="h-[20rem] rounded-xl" />
<Skeleton className="h-[20rem] rounded-xl" />
<Skeleton className="h-[20rem] rounded-xl" />
<Skeleton className="h-[20rem] rounded-xl" />
<Skeleton className="h-[20rem] rounded-xl bg-muted" />
<Skeleton className="h-[20rem] rounded-xl bg-muted" />
<Skeleton className="h-[20rem] rounded-xl bg-muted" />
<Skeleton className="h-[20rem] rounded-xl bg-muted" />
<Skeleton className="h-[20rem] rounded-xl bg-muted" />
<Skeleton className="h-[20rem] rounded-xl bg-muted" />
</div>
)}
</>
@@ -1,4 +1,4 @@
import { useState, useMemo, useRef } from "react";
import { useState, useMemo, useRef, useEffect } from "react";
import SearchForm from "@/components/module/SearchForm";
import DataGrid from "@/components/module/DataGrid";
@@ -7,41 +7,44 @@ import GridTopActionButton from "@/components/module/GridTopActionButton";
import SubmitButton from "@/components/button/SumitButton";
import SaveButton from "@/components/button/SaveButton";
import LastYearDataButton from "@/components/button/LastYearDataButton";
import SubmitExceptionButton from "@/components/button/SubmitExceptionButton";
import VerificationResultButton from "@/components/button/VerificationResultButton";
import { VerificationResultHistoryDialog } from "@/components/module/VerificationResultHistoryDialog";
import { SewagePipeInpColumnInfoList } from "@/components/util/column/input/sewagePipe/SewagePipeInpColumn";
import type { ColumnInfo, ColumnInfoList, ColumnConfig } from "@/components/type/DataGrid.type";
import type { SearchFormValues } from "@/components/type/SearchForm.type";
import { useAlertStore } from "@/shared/lib";
import { useAlertStore, useGlobalProgressStore } from "@/shared/lib";
import { useAdminStore } from "@/store/admin-store";
import { statisticsInputSewSupplyRateApi } from "@/entities/statistics/api/input/statistics-input-sew-supply-rate-service";
import { useConfirm } from "@frontend-opensource/use-react-hooks";
import { doList } from "@/components/util/column/input/DoList";
import { statisticsInputSewPipeApiService } from "@/entities/statistics/api/input/statistics-sewage-pipe-input-service";
import type {
SeweragePipeInputFactInstallStatusSaveRequest,
SeweragePipeInputRegionalFactStatusSaveRequest,
SeweragePipeInputStatusInfo,
SeweragePipeInputVerificationResult,
SeweragePipeRequestParams,
} from "@/entities/statistics/model/input/statistics-sewage-pipe-input-interface";
import { isInputPeriodCurrent } from "@/entities/statistics/model/input/statistics-sewage-supply-rate-input-interface";
import { FactInstallStatusInpColumn } from "@/components/util/column/input/sewagePipe/FactInstallStatusInpColumn";
import { RegionalFactStatusInpColumn } from "@/components/util/column/input/sewagePipe/RegionalFactStatusInpColumn";
import { toGridRows } from "@/components/util/grid/gridFunction";
import { LoadingWithAnimation, Tabs, TabsContent, TabsList, TabsTrigger } from "@common/components";
import { getUserInfo } from "../../../../../../packages/common/components/src/api";
const applyEnterableFromStatus = (
/** 탭별 status + 입력기간으로 편집 가능 여부 */
const computeTabEnterable = (
statusInfo: SeweragePipeInputStatusInfo | null | undefined,
periodEnterable: boolean,
setIsEnterable: (v: boolean) => void
) => {
): boolean => {
if (statusInfo?.laeSbmsnSttsCd) {
if (["2", "3"].includes(statusInfo.laeSbmsnSttsCd)) {
setIsEnterable(false);
} else {
setIsEnterable(periodEnterable);
return false;
}
} else {
setIsEnterable(periodEnterable);
return periodEnterable;
}
return periodEnterable;
};
function buildSewagePipeSearchParam(
@@ -58,6 +61,10 @@ function buildSewagePipeSearchParam(
}
/** ST00020006(연결)은 행이 좌·우 그리드 키에만 있음 */
type GridRowWithVerification = Record<string, string | number> & {
verificationResult?: SeweragePipeInputVerificationResult;
};
function tabRowDataExists(
m: Map<string, Record<string, string | number>[]>,
tabKey: string,
@@ -74,6 +81,43 @@ function tabRowDataExists(
return Array.isArray(rows) && rows.length > 0;
}
/** 조회 직후 해당 탭에만 저장하는 상단 툴바 노출 조건 */
type TabToolbarSnapshot = {
showSaveInToolbar: boolean;
showEnterableToolbarButtons: boolean;
showActionToolbarCard: boolean;
};
function buildGugunOkForToolbar(values: SearchFormValues | null): boolean {
return doList.includes(values?.bsnmCd || "")
? Boolean(values?.gugun && values.gugun !== "ALL")
: true;
}
/**
* @param searchContext 조회에 사용된 검색값(해당 요청 기준으로 구군·사업자 조건 반영)
* @param treatAsSearched (hasRow || 이 값) 일 때 카드 후보 — load 내부에서는 searchDoneRef 반영용
*/
function computeTabToolbarSnapshot(
statusInfo: SeweragePipeInputStatusInfo | null,
hasRowData: boolean,
treatAsSearched: boolean,
searchContext: SearchFormValues | null,
periodEnterable: boolean,
isAdminInfo: boolean,
): TabToolbarSnapshot {
const gugunOk = buildGugunOkForToolbar(searchContext);
const enterable = computeTabEnterable(statusInfo, periodEnterable);
const showSaveInToolbar = (enterable || isAdminInfo) && gugunOk;
const showEnterableToolbarButtons = enterable && gugunOk;
const showActionToolbarCard =
(hasRowData || treatAsSearched) &&
(showSaveInToolbar ||
(statusInfo?.laeSbmsnSttsCd === "2" &&
(isAdminInfo || (enterable && gugunOk))));
return { showSaveInToolbar, showEnterableToolbarButtons, showActionToolbarCard };
}
// 하수도 통계 관리 > 02. 하수관로
const SewagePipeInp = () => {
const [rowDataMap, setRowDataMap] = useState<Map<string, Record<string, string | number>[]>>(new Map());
@@ -81,19 +125,31 @@ const SewagePipeInp = () => {
const [activeTab, setActiveTab] = useState<string>(SewagePipeInpColumnInfoList[0]?.tabKey ?? "ST00020001");
const [isLoading, setIsLoading] = useState<boolean>(false);
const [searchValues, setSearchValues] = useState<SearchFormValues | null>(null);
const [isEnterable, setIsEnterable] = useState<boolean>(true);
const [lastSearchedYear, setLastSearchedYear] = useState<string | null>(null);
const [lastSearchedValues, setLastSearchedValues] = useState<SearchFormValues | null>(null);
const [statusInfo, setStatusInfo] = useState<SeweragePipeInputStatusInfo | null>(null);
const [tabStatusInfoMap, setTabStatusInfoMap] = useState<Map<string, SeweragePipeInputStatusInfo | null>>(new Map());
// const [showDailog, setShowDailog] = useState<boolean>(false);
// const [isExceptionSubmitFlow, setIsExceptionSubmitFlow] = useState(false);
const [_statsCd, setStatsCd] = useState<string>('');
const [statsCd, setStatsCd] = useState<string>('');
const [tabStatsCdMap, setTabStatsCdMap] = useState<Map<string, string>>(new Map());
const [verificationHistoryOpen, setVerificationHistoryOpen] = useState(false);
/** API 연결 전에도 검색 후 툴바 등 UI를 쓸 수 있게 함 */
const [hasSearched, setHasSearched] = useState(false);
// const { confirm } = useConfirm();
/** checkInputPeriod — columnInfo useMemo가 입력기간 변경을 반영하도록 state로도 유지 */
const [inputPeriodEnterable, setInputPeriodEnterable] = useState(true);
/** 탭별 조회 시점의 상단 버튼 노출 조건(해당 탭을 다시 조회할 때만 그 탭 항목 갱신) */
const [toolbarSnapshotByTab, setToolbarSnapshotByTab] = useState<Map<string, TabToolbarSnapshot>>(
() => new Map(),
);
const { confirm } = useConfirm();
const showGlobalProgress = useGlobalProgressStore((state) => state.show);
const setGlobalProgress = useGlobalProgressStore((state) => state.setProgress);
const hideGlobalProgress = useGlobalProgressStore((state) => state.hide);
const globalProgressVisible = useGlobalProgressStore((state) => state.visible);
const { isAdminInfo } = useAdminStore();
const getGridDataRef = useRef<(() => Record<string, string | number>[]) | null>(null);
const getFactInstallGridDataRef = useRef<(() => Record<string, string | number>[]) | null>(null);
const searchParamRef = useRef<SeweragePipeRequestParams | null>(null);
/** 검색 시 checkInputPeriod 결과 — 탭 전환 지연 조회 시 편집 가능 여부와 동일하게 맞춤 */
const periodEnterableRef = useRef(true);
@@ -102,23 +158,41 @@ const SewagePipeInp = () => {
const lazyTabLoggedRef = useRef<Set<string>>(new Set());
const firstTabKey = SewagePipeInpColumnInfoList[0]?.tabKey ?? "ST00020001";
const activeTabRef = useRef<string>(firstTabKey);
useEffect(() => {
activeTabRef.current = activeTab;
}, [activeTab]);
const syncTopStatusIfStillOnTab = (loadedTabKey: string, nextStatsCd: string) => {
if (loadedTabKey === activeTabRef.current) {
setStatsCd(nextStatsCd);
}
};
const handleReset = () => {
setRowDataMap(new Map());
setStatusInfo(null);
setTabStatusInfoMap(new Map());
setTabStatsCdMap(new Map());
setLastSearchedYear(null);
setLastSearchedValues(null);
setHasSearched(false);
setInputPeriodEnterable(true);
setToolbarSnapshotByTab(new Map());
searchParamRef.current = null;
searchDoneRef.current = false;
lazyTabLoggedRef.current.clear();
periodEnterableRef.current = true;
activeTabRef.current = firstTabKey;
setActiveTab(firstTabKey);
setStatsCd("");
};
const columnInfo = useMemo(() => {
const canEdit = isEnterable || isAdminInfo;
return SewagePipeInpColumnInfoList.map((tab: ColumnInfoList) => {
const canEdit =
computeTabEnterable(tabStatusInfoMap.get(tab.tabKey) ?? null, inputPeriodEnterable) ||
isAdminInfo;
const applyEditable = (cols: ColumnConfig[] | undefined) =>
(cols ?? []).map((col: ColumnConfig) =>
canEdit
@@ -138,14 +212,36 @@ const SewagePipeInp = () => {
columns: applyEditable(tab.columns),
};
});
}, [isEnterable, isAdminInfo]);
}, [tabStatusInfoMap, isAdminInfo, inputPeriodEnterable]);
const commitToolbarSnapshotForLoadedTab = (
tabKey: string,
statusInfo: SeweragePipeInputStatusInfo | null,
hasRowData: boolean,
searchContext: SearchFormValues,
) => {
setToolbarSnapshotByTab((prev) => {
const next = new Map(prev);
next.set(
tabKey,
computeTabToolbarSnapshot(
statusInfo,
hasRowData,
searchDoneRef.current,
searchContext,
periodEnterableRef.current,
isAdminInfo,
),
);
return next;
});
};
/** mergeIntoExistingMap: true면 기존 맵에 병합(다른 탭 rowData·검색 후 다른 탭 캐시 유지). false면 맵 전체 교체 */
const loadSewagePipeActiveTabData = async (
tabKey: string,
searchParam: SeweragePipeRequestParams,
values: SearchFormValues,
checkEnterable: boolean,
mergeIntoExistingMap: boolean,
updateLastSearchedValues: boolean,
): Promise<boolean> => {
@@ -172,10 +268,16 @@ const SewagePipeInp = () => {
...d,
}));
applyRowMap((m) => m.set('ST00020001', toGridRows(convertedData)));
setStatusInfo(response.data.statusInfo);
setStatsCd(commonInfo.statsCd);
applyEnterableFromStatus(response.data.statusInfo, checkEnterable, setIsEnterable);
setTabStatusInfoMap((prev) => new Map(prev).set(tabKey, response.data.statusInfo));
setTabStatsCdMap((prev) => new Map(prev).set(tabKey, commonInfo.statsCd));
syncTopStatusIfStillOnTab(tabKey, commonInfo.statsCd);
if (updateLastSearchedValues) setLastSearchedValues(values);
commitToolbarSnapshotForLoadedTab(
tabKey,
response.data.statusInfo,
convertedData.length > 0,
values,
);
return true;
}
alert(response.message || '데이터 조회 중 오류가 발생했습니다.');
@@ -190,10 +292,16 @@ const SewagePipeInp = () => {
...d,
}));
applyRowMap((m) => m.set('ST00020002', toGridRows(convertedData)));
setStatusInfo(response.data.statusInfo);
setStatsCd(commonInfo.statsCd);
applyEnterableFromStatus(response.data.statusInfo, checkEnterable, setIsEnterable);
setTabStatusInfoMap((prev) => new Map(prev).set(tabKey, response.data.statusInfo));
setTabStatsCdMap((prev) => new Map(prev).set(tabKey, commonInfo.statsCd));
syncTopStatusIfStillOnTab(tabKey, commonInfo.statsCd);
if (updateLastSearchedValues) setLastSearchedValues(values);
commitToolbarSnapshotForLoadedTab(
tabKey,
response.data.statusInfo,
convertedData.length > 0,
values,
);
return true;
}
alert(response.message || '데이터 조회 중 오류가 발생했습니다.');
@@ -208,10 +316,16 @@ const SewagePipeInp = () => {
...d,
}));
applyRowMap((m) => m.set('ST00020003', toGridRows(convertedData)));
setStatusInfo(response.data.statusInfo);
setStatsCd(commonInfo.statsCd);
applyEnterableFromStatus(response.data.statusInfo, checkEnterable, setIsEnterable);
setTabStatusInfoMap((prev) => new Map(prev).set(tabKey, response.data.statusInfo));
setTabStatsCdMap((prev) => new Map(prev).set(tabKey, commonInfo.statsCd));
syncTopStatusIfStillOnTab(tabKey, commonInfo.statsCd);
if (updateLastSearchedValues) setLastSearchedValues(values);
commitToolbarSnapshotForLoadedTab(
tabKey,
response.data.statusInfo,
convertedData.length > 0,
values,
);
return true;
}
alert(response.message || '데이터 조회 중 오류가 발생했습니다.');
@@ -226,10 +340,16 @@ const SewagePipeInp = () => {
...d,
}));
applyRowMap((m) => m.set('ST00020004', toGridRows(convertedData)));
setStatusInfo(response.data.statusInfo);
setStatsCd(commonInfo.statsCd);
applyEnterableFromStatus(response.data.statusInfo, checkEnterable, setIsEnterable);
setTabStatusInfoMap((prev) => new Map(prev).set(tabKey, response.data.statusInfo));
setTabStatsCdMap((prev) => new Map(prev).set(tabKey, commonInfo.statsCd));
syncTopStatusIfStillOnTab(tabKey, commonInfo.statsCd);
if (updateLastSearchedValues) setLastSearchedValues(values);
commitToolbarSnapshotForLoadedTab(
tabKey,
response.data.statusInfo,
convertedData.length > 0,
values,
);
return true;
}
alert(response.message || '데이터 조회 중 오류가 발생했습니다.');
@@ -244,10 +364,16 @@ const SewagePipeInp = () => {
...d,
}));
applyRowMap((m) => m.set('ST00020005', toGridRows(convertedData)));
setStatusInfo(response.data.statusInfo);
setStatsCd(commonInfo.statsCd);
applyEnterableFromStatus(response.data.statusInfo, checkEnterable, setIsEnterable);
setTabStatusInfoMap((prev) => new Map(prev).set(tabKey, response.data.statusInfo));
setTabStatsCdMap((prev) => new Map(prev).set(tabKey, commonInfo.statsCd));
syncTopStatusIfStillOnTab(tabKey, commonInfo.statsCd);
if (updateLastSearchedValues) setLastSearchedValues(values);
commitToolbarSnapshotForLoadedTab(
tabKey,
response.data.statusInfo,
convertedData.length > 0,
values,
);
return true;
}
alert(response.message || '데이터 조회 중 오류가 발생했습니다.');
@@ -268,10 +394,16 @@ const SewagePipeInp = () => {
m.set('treatmentPlantConnectionInp', toGridRows(facilityConvertedData));
m.set('adminAreaConnectionInp', toGridRows(adminConvertedData));
});
setStatusInfo(response.data.statusInfo);
setStatsCd(commonInfo.statsCd);
applyEnterableFromStatus(response.data.statusInfo, checkEnterable, setIsEnterable);
setTabStatusInfoMap((prev) => new Map(prev).set(tabKey, response.data.statusInfo));
setTabStatsCdMap((prev) => new Map(prev).set(tabKey, commonInfo.statsCd));
syncTopStatusIfStillOnTab(tabKey, commonInfo.statsCd);
if (updateLastSearchedValues) setLastSearchedValues(values);
commitToolbarSnapshotForLoadedTab(
tabKey,
response.data.statusInfo,
facilityConvertedData.length > 0 || adminConvertedData.length > 0,
values,
);
return true;
}
alert(response.message || '데이터 조회 중 오류가 발생했습니다.');
@@ -281,7 +413,6 @@ const SewagePipeInp = () => {
setRowDataMap(new Map());
}
if (updateLastSearchedValues) setLastSearchedValues(values);
applyEnterableFromStatus(null, checkEnterable, setIsEnterable);
return true;
};
@@ -299,8 +430,21 @@ const SewagePipeInp = () => {
return;
}
const checkEnterable = periodResponse.data;
periodEnterableRef.current = checkEnterable;
const periodOk = isInputPeriodCurrent(periodResponse.data);
periodEnterableRef.current = periodOk;
setInputPeriodEnterable(periodOk);
/** 연도가 바뀐 검색만 전 탭 캐시 초기화 + 첫 탭으로 이동. 연도 외 조건만 바뀌면 다른 탭은 이전 조회 데이터 유지 */
const yearChangedFromLastSearch =
lastSearchedValues != null &&
(lastSearchedValues.year ?? "") !== (values.year ?? "");
if (yearChangedFromLastSearch) {
setRowDataMap(new Map());
setTabStatusInfoMap(new Map());
setTabStatsCdMap(new Map());
setStatsCd("");
setToolbarSnapshotByTab(new Map());
}
lazyTabLoggedRef.current.clear();
@@ -312,7 +456,15 @@ const SewagePipeInp = () => {
setHasSearched(true);
setLastSearchedYear(values.year ?? null);
await loadSewagePipeActiveTabData(activeTab, searchParam, values, checkEnterable, true, true);
const tabKeyToLoad = yearChangedFromLastSearch ? firstTabKey : activeTab;
if (yearChangedFromLastSearch) {
activeTabRef.current = firstTabKey;
setActiveTab(firstTabKey);
} else {
activeTabRef.current = activeTab;
}
await loadSewagePipeActiveTabData(tabKeyToLoad, searchParam, values, true, true);
} catch {
alert('데이터 조회 중 오류가 발생했습니다.');
} finally {
@@ -321,7 +473,9 @@ const SewagePipeInp = () => {
};
const handleActiveTabChange = async (tabKey: string) => {
activeTabRef.current = tabKey;
setActiveTab(tabKey);
setStatsCd(tabStatsCdMap.get(tabKey) ?? "");
const hasActiveTabData = tabRowDataExists(rowDataMap, tabKey);
@@ -339,14 +493,7 @@ const SewagePipeInp = () => {
try {
setIsLoading(true);
const ok = await loadSewagePipeActiveTabData(
tabKey,
searchParam,
values,
periodEnterableRef.current,
true,
false,
);
const ok = await loadSewagePipeActiveTabData(tabKey, searchParam, values, true, false);
if (ok) lazyTabLoggedRef.current.add(tabKey);
} catch {
alert('데이터 조회 중 오류가 발생했습니다.');
@@ -355,11 +502,6 @@ const SewagePipeInp = () => {
}
};
const SubmitExceptionFunc = () => {
console.log('submit exception');
// 예외 제출 로직
};
const getLastYearDataFunc = async () => {
const values = lastSearchedValues ?? searchValues;
if (!values?.sido || values.sido === 'ALL') {
@@ -373,7 +515,7 @@ const SewagePipeInp = () => {
try {
setIsLoading(true);
const param = buildSewagePipeSearchParam(values, { mode: 'PRE' });
await loadSewagePipeActiveTabData(activeTab, param, values, periodEnterableRef.current, true, false);
await loadSewagePipeActiveTabData(activeTab, param, values, true, false);
} catch {
alert('데이터 조회 중 오류가 발생했습니다.');
} finally {
@@ -386,115 +528,237 @@ const SewagePipeInp = () => {
// 제출 로직
};
const saveFunc = async () => {
if (activeTab !== "ST00020001") {
const refreshConnectionStatusAfterPopupSave = async () => {
const searchParam = searchParamRef.current;
const values = lastSearchedValues ?? searchValues;
if (!searchParam || !values) return;
await loadSewagePipeActiveTabData("ST00020006", searchParam, values, true, false);
};
const openVerificationHistoryDialog = () => {
const activeTabStatsCd = tabStatsCdMap.get(activeTab) ?? statsCd;
if (!searchValues?.year || !searchValues?.sido || !searchValues?.bsnmCd || !activeTabStatsCd) {
alert("먼저 조회를 진행해주세요.");
return;
}
setVerificationHistoryOpen(true);
};
const saveFunc = async () => {
if (!(await confirm("저장 하시겠습니까?"))) return;
const values = lastSearchedValues ?? searchValues;
if (!values?.year) {
alert("검색 후 저장해 주세요.");
return;
}
try {
const currentData = getGridDataRef.current?.();
if (!currentData || currentData.length === 0) {
alert("저장할 데이터가 없습니다.");
return;
}
const values = lastSearchedValues ?? searchValues;
if (!values?.year) {
alert("검색 후 저장해 주세요.");
return;
}
showGlobalProgress({ label: "저장 진행 중...", progress: 0 });
const columnFormatMap = new Map<string, string>();
RegionalFactStatusInpColumn.columns.forEach((col) => {
if (col.fieldName && col.dataFormat) {
columnFormatMap.set(col.fieldName, col.dataFormat);
switch (activeTab) {
case "ST00020001": {
const currentData = getGridDataRef.current?.();
if (!currentData || currentData.length === 0) {
alert("저장할 데이터가 없습니다.");
return;
}
const columnFormatMap = new Map<string, string>();
RegionalFactStatusInpColumn.columns.forEach((col) => {
if (col.fieldName && col.dataFormat) {
columnFormatMap.set(col.fieldName, col.dataFormat);
}
});
const formatNumber = (value: string | number | null | undefined, fieldName: string): number => {
if (value === null || value === undefined || value === "") return 0;
const num = typeof value === "string" ? parseFloat(value.replace(/,/g, "")) : value;
if (isNaN(num)) return 0;
const format = columnFormatMap.get(fieldName);
if (!format) return num;
const decimalPlaces = format.match(/\.(\d+)/)?.[1]?.length ?? 0;
return decimalPlaces > 0 ? parseFloat(num.toFixed(decimalPlaces)) : Math.round(num);
};
const userInfo = await getUserInfo();
const saveList = currentData.map((d) => ({
admdstCd: String(d.admdstCd ?? ""),
sggNm: String(d.sggNm ?? ""),
admdstNm: String(d.admdstNm ?? ""),
goalYr: String(d.goalYr ?? ""),
wholPplMtncPlanPrlg: formatNumber(d.wholPplMtncPlanPrlg, "wholPplMtncPlanPrlg"),
wholPlanPrlg: formatNumber(d.wholPlanPrlg, "wholPlanPrlg"),
wholFcltPrlg: formatNumber(d.wholFcltPrlg, "wholFcltPrlg"),
swagePplPsplyRt: formatNumber(d.swagePplPsplyRt, "swagePplPsplyRt"),
cbsysPplMtncPlanArea: formatNumber(d.cbsysPplMtncPlanArea, "cbsysPplMtncPlanArea"),
cbsysPplMtncPlanPrlg: formatNumber(d.cbsysPplMtncPlanPrlg, "cbsysPplMtncPlanPrlg"),
cbsysPlanPrlg: formatNumber(d.cbsysPlanPrlg, "cbsysPlanPrlg"),
cbsysFcltPrlg: formatNumber(d.cbsysFcltPrlg, "cbsysFcltPrlg"),
cbsysSqrePrlg: formatNumber(d.cbsysSqrePrlg, "cbsysSqrePrlg"),
cbsysCiclPrlg: formatNumber(d.cbsysCiclPrlg, "cbsysCiclPrlg"),
cbsysOpnPrlg: formatNumber(d.cbsysOpnPrlg, "cbsysOpnPrlg"),
cbsysGttrPrlg: formatNumber(d.cbsysGttrPrlg, "cbsysGttrPrlg"),
drtwPplMtncPlanArea: formatNumber(d.drtwPplMtncPlanArea, "drtwPplMtncPlanArea"),
drtwPplMtncPlanPrlg: formatNumber(d.drtwPplMtncPlanPrlg, "drtwPplMtncPlanPrlg"),
drtwPplPlanPrlg: formatNumber(d.drtwPplPlanPrlg, "drtwPplPlanPrlg"),
drtwPplFcltPrlg: formatNumber(d.drtwPplFcltPrlg, "drtwPplFcltPrlg"),
drtwPplSqrePrlg: formatNumber(d.drtwPplSqrePrlg, "drtwPplSqrePrlg"),
drtwPplCiclPrlg: formatNumber(d.drtwPplCiclPrlg, "drtwPplCiclPrlg"),
exlnPplMtncPlanArea: formatNumber(d.exlnPplMtncPlanArea, "exlnPplMtncPlanArea"),
exlnPplMtncPlanPrlg: formatNumber(d.exlnPplMtncPlanPrlg, "exlnPplMtncPlanPrlg"),
exlnPplPlanPrlg: formatNumber(d.exlnPplPlanPrlg, "exlnPplPlanPrlg"),
exlnPplFcltPrlg: formatNumber(d.exlnPplFcltPrlg, "exlnPplFcltPrlg"),
exlnPplSqrePrlg: formatNumber(d.exlnPplSqrePrlg, "exlnPplSqrePrlg"),
exlnPplCiclPrlg: formatNumber(d.exlnPplCiclPrlg, "exlnPplCiclPrlg"),
exlnPplOpnPrlg: formatNumber(d.exlnPplOpnPrlg, "exlnPplOpnPrlg"),
exlnPplGttrPrlg: formatNumber(d.exlnPplGttrPrlg, "exlnPplGttrPrlg"),
cbsysMnhlCnt: formatNumber(d.cbsysMnhlCnt, "cbsysMnhlCnt"),
sprsysDrtwMnhlCnt: formatNumber(d.sprsysDrtwMnhlCnt, "sprsysDrtwMnhlCnt"),
sprsysExlnMnhlCnt: formatNumber(d.sprsysExlnMnhlCnt, "sprsysExlnMnhlCnt"),
drtwRcvprpCnt: formatNumber(d.drtwRcvprpCnt, "drtwRcvprpCnt"),
exlnRcvprpCnt: formatNumber(d.exlnRcvprpCnt, "exlnRcvprpCnt"),
stofcbrCnt: formatNumber(d.stofcbrCnt, "stofcbrCnt"),
exlnOtflCnt: formatNumber(d.exlnOtflCnt, "exlnOtflCnt"),
sewerLdgrEdpPrlg: formatNumber(d.sewerLdgrEdpPrlg, "sewerLdgrEdpPrlg"),
sewerLdgrEdpRt: formatNumber(d.sewerLdgrEdpRt, "sewerLdgrEdpRt"),
rmrk: (d.rmrk as string | null) ?? null,
}));
const param: SeweragePipeInputRegionalFactStatusSaveRequest = {
statsYr: values.year ?? "",
ctpvNm: values.sido ?? "",
sggNm: values.gugun === "ALL" ? "" : (values.gugun ?? ""),
sewerBzmnCd: values.bsnmCd ?? "",
userId: userInfo.userUnqId,
superAdmin: isAdminInfo ? "superAdmin" : "",
saveList,
};
const response =
await statisticsInputSewPipeApiService.saveRegionalFactStatus(param);
if (response.status === "success") {
setGlobalProgress(50);
const refreshedParam = buildSewagePipeSearchParam(values);
await loadSewagePipeActiveTabData(activeTab, refreshedParam, values, true, false);
setGlobalProgress(100);
alert("저장되었습니다.");
} else {
alert(response.message || "데이터 저장 중 오류가 발생했습니다.");
}
break;
}
});
case "ST00020002": {
const currentData = getFactInstallGridDataRef.current?.();
if (!currentData || currentData.length === 0) {
alert("저장할 데이터가 없습니다.");
return;
}
const formatNumber = (value: string | number | null | undefined, fieldName: string): number => {
if (value === null || value === undefined || value === "") return 0;
const num = typeof value === "string" ? parseFloat(value.replace(/,/g, "")) : value;
if (isNaN(num)) return 0;
const format = columnFormatMap.get(fieldName);
if (!format) return num;
const decimalPlaces = format.match(/\.(\d+)/)?.[1]?.length ?? 0;
return decimalPlaces > 0 ? parseFloat(num.toFixed(decimalPlaces)) : Math.round(num);
};
const columnFormatMap = new Map<string, string>();
FactInstallStatusInpColumn.columns.forEach((col) => {
if (col.fieldName && col.dataFormat) {
columnFormatMap.set(col.fieldName, col.dataFormat);
}
});
const userInfo = await getUserInfo();
const formatNumber = (value: string | number | null | undefined, fieldName: string): number => {
if (value === null || value === undefined || value === "") return 0;
const num = typeof value === "string" ? parseFloat(value.replace(/,/g, "")) : value;
if (isNaN(num)) return 0;
const format = columnFormatMap.get(fieldName);
if (!format) return num;
const decimalPlaces = format.match(/\.(\d+)/)?.[1]?.length ?? 0;
return decimalPlaces > 0 ? parseFloat(num.toFixed(decimalPlaces)) : Math.round(num);
};
const saveList = currentData.map((d) => ({
admdstCd: String(d.admdstCd ?? ""),
sggNm: String(d.sggNm ?? ""),
admdstNm: String(d.admdstNm ?? ""),
goalYr: String(d.goalYr ?? ""),
wholPplMtncPlanPrlg: formatNumber(d.wholPplMtncPlanPrlg, "wholPplMtncPlanPrlg"),
wholPlanPrlg: formatNumber(d.wholPlanPrlg, "wholPlanPrlg"),
wholFcltPrlg: formatNumber(d.wholFcltPrlg, "wholFcltPrlg"),
swagePplPsplyRt: formatNumber(d.swagePplPsplyRt, "swagePplPsplyRt"),
cbsysPplMtncPlanArea: formatNumber(d.cbsysPplMtncPlanArea, "cbsysPplMtncPlanArea"),
cbsysPplMtncPlanPrlg: formatNumber(d.cbsysPplMtncPlanPrlg, "cbsysPplMtncPlanPrlg"),
cbsysPlanPrlg: formatNumber(d.cbsysPlanPrlg, "cbsysPlanPrlg"),
cbsysFcltPrlg: formatNumber(d.cbsysFcltPrlg, "cbsysFcltPrlg"),
cbsysSqrePrlg: formatNumber(d.cbsysSqrePrlg, "cbsysSqrePrlg"),
cbsysCiclPrlg: formatNumber(d.cbsysCiclPrlg, "cbsysCiclPrlg"),
cbsysOpnPrlg: formatNumber(d.cbsysOpnPrlg, "cbsysOpnPrlg"),
cbsysGttrPrlg: formatNumber(d.cbsysGttrPrlg, "cbsysGttrPrlg"),
drtwPplMtncPlanArea: formatNumber(d.drtwPplMtncPlanArea, "drtwPplMtncPlanArea"),
drtwPplMtncPlanPrlg: formatNumber(d.drtwPplMtncPlanPrlg, "drtwPplMtncPlanPrlg"),
drtwPplPlanPrlg: formatNumber(d.drtwPplPlanPrlg, "drtwPplPlanPrlg"),
drtwPplFcltPrlg: formatNumber(d.drtwPplFcltPrlg, "drtwPplFcltPrlg"),
drtwPplSqrePrlg: formatNumber(d.drtwPplSqrePrlg, "drtwPplSqrePrlg"),
drtwPplCiclPrlg: formatNumber(d.drtwPplCiclPrlg, "drtwPplCiclPrlg"),
exlnPplMtncPlanArea: formatNumber(d.exlnPplMtncPlanArea, "exlnPplMtncPlanArea"),
exlnPplMtncPlanPrlg: formatNumber(d.exlnPplMtncPlanPrlg, "exlnPplMtncPlanPrlg"),
exlnPplPlanPrlg: formatNumber(d.exlnPplPlanPrlg, "exlnPplPlanPrlg"),
exlnPplFcltPrlg: formatNumber(d.exlnPplFcltPrlg, "exlnPplFcltPrlg"),
exlnPplSqrePrlg: formatNumber(d.exlnPplSqrePrlg, "exlnPplSqrePrlg"),
exlnPplCiclPrlg: formatNumber(d.exlnPplCiclPrlg, "exlnPplCiclPrlg"),
exlnPplOpnPrlg: formatNumber(d.exlnPplOpnPrlg, "exlnPplOpnPrlg"),
exlnPplGttrPrlg: formatNumber(d.exlnPplGttrPrlg, "exlnPplGttrPrlg"),
cbsysMnhlCnt: formatNumber(d.cbsysMnhlCnt, "cbsysMnhlCnt"),
sprsysDrtwMnhlCnt: formatNumber(d.sprsysDrtwMnhlCnt, "sprsysDrtwMnhlCnt"),
sprsysExlnMnhlCnt: formatNumber(d.sprsysExlnMnhlCnt, "sprsysExlnMnhlCnt"),
drtwRcvprpCnt: formatNumber(d.drtwRcvprpCnt, "drtwRcvprpCnt"),
exlnRcvprpCnt: formatNumber(d.exlnRcvprpCnt, "exlnRcvprpCnt"),
stofcbrCnt: formatNumber(d.stofcbrCnt, "stofcbrCnt"),
exlnOtflCnt: formatNumber(d.exlnOtflCnt, "exlnOtflCnt"),
sewerLdgrEdpPrlg: formatNumber(d.sewerLdgrEdpPrlg, "sewerLdgrEdpPrlg"),
sewerLdgrEdpRt: formatNumber(d.sewerLdgrEdpRt, "sewerLdgrEdpRt"),
rmrk: (d.rmrk as string | null) ?? null,
}));
const userInfo = await getUserInfo();
const param: SeweragePipeInputRegionalFactStatusSaveRequest = {
statsYr: values.year ?? "",
ctpvNm: values.sido ?? "",
sggNm: values.gugun === "ALL" ? "" : (values.gugun ?? ""),
sewerBzmnCd: values.bsnmCd ?? "",
userId: userInfo.userUnqId,
superAdmin: isAdminInfo ? "superAdmin" : "",
saveList,
};
const saveList = currentData.map((d) => ({
admdstCd: String(d.admdstCd ?? ""),
sggNm: String(d.sggNm ?? ""),
admdstNm: String(d.admdstNm ?? ""),
fcltSn: String(d.fcltSn ?? ""),
swtrmtFcltCd: String(d.swtrmtFcltCd ?? ""),
swtrmtFcltNm: String(d.swtrmtFcltNm ?? ""),
prcsFcltCpct: formatNumber(d.prcsFcltCpct, "prcsFcltCpct"),
prcsZoneArea: formatNumber(d.prcsZoneArea, "prcsZoneArea"),
cbsysPrcsZoneArea: formatNumber(d.cbsysPrcsZoneArea, "cbsysPrcsZoneArea"),
sprsysPrcsZoneArea: formatNumber(d.sprsysPrcsZoneArea, "sprsysPrcsZoneArea"),
sprsysVartnRt: formatNumber(d.sprsysVartnRt, "sprsysVartnRt"),
mltvEqpmCnt: formatNumber(d.mltvEqpmCnt, "mltvEqpmCnt"),
cbsysMltvEqpmCnt: formatNumber(d.cbsysMltvEqpmCnt, "cbsysMltvEqpmCnt"),
sprsysMltvEqpmCnt: formatNumber(d.sprsysMltvEqpmCnt, "sprsysMltvEqpmCnt"),
sprsysMltvLnkgCnt: formatNumber(d.sprsysMltvLnkgCnt, "sprsysMltvLnkgCnt"),
sprsysMltvYetLnkgCnt: formatNumber(d.sprsysMltvYetLnkgCnt, "sprsysMltvYetLnkgCnt"),
wholFcltPrlg: formatNumber(d.wholFcltPrlg, "wholFcltPrlg"),
wholIcptgAtlfcsPrlg: formatNumber(d.wholIcptgAtlfcsPrlg, "wholIcptgAtlfcsPrlg"),
icptgPrlg: formatNumber(d.icptgPrlg, "icptgPrlg"),
atlfcsPrlg: formatNumber(d.atlfcsPrlg, "atlfcsPrlg"),
cbsysStofcbrCnt: formatNumber(d.cbsysStofcbrCnt, "cbsysStofcbrCnt"),
sprsysStofcbrCnt: formatNumber(d.sprsysStofcbrCnt, "sprsysStofcbrCnt"),
sprsysExlnOtflCnt: formatNumber(d.sprsysExlnOtflCnt, "sprsysExlnOtflCnt"),
wholBrchlnPrlg: formatNumber(d.wholBrchlnPrlg, "wholBrchlnPrlg"),
cbsysBrchlnPrlg: formatNumber(d.cbsysBrchlnPrlg, "cbsysBrchlnPrlg"),
exlnBrchlnPrlg: formatNumber(d.exlnBrchlnPrlg, "exlnBrchlnPrlg"),
drtwBrchlnPrlg: formatNumber(d.drtwBrchlnPrlg, "drtwBrchlnPrlg"),
rmrk: String(d.rmrk ?? ""),
}));
console.log(param);
const param: SeweragePipeInputFactInstallStatusSaveRequest = {
statsYr: values.year ?? "",
ctpvNm: values.sido ?? "",
sggNm: values.gugun === "ALL" ? "" : (values.gugun ?? ""),
sewerBzmnCd: values.bsnmCd ?? "",
userId: userInfo.userUnqId,
superAdmin: isAdminInfo ? "superAdmin" : "",
saveList,
};
const response =
await statisticsInputSewPipeApiService.saveFactInstallStatus(param);
if (response.status === "success") {
setGlobalProgress(50);
const refreshedParam = buildSewagePipeSearchParam(values);
await loadSewagePipeActiveTabData(activeTab, refreshedParam, values, true, false);
setGlobalProgress(100);
alert("저장되었습니다.");
} else {
alert(response.message || "데이터 저장 중 오류가 발생했습니다.");
}
break;
}
default:
alert("현재 탭 저장 API 연동 전입니다.");
break;
}
} catch {
alert("저장 준비 중 오류가 발생했습니다.");
alert("데이터 저장 중 오류가 발생했습니다.");
} finally {
hideGlobalProgress();
}
};
const gugunOkOrNotDoList = doList.includes(lastSearchedValues?.bsnmCd || '')
? Boolean(lastSearchedValues?.gugun && lastSearchedValues.gugun !== 'ALL')
: true;
const gugunOkOrNotDoList = buildGugunOkForToolbar(lastSearchedValues);
const showSaveInToolbar = (isEnterable || isAdminInfo) && gugunOkOrNotDoList;
const showEnterableToolbarButtons = isEnterable && gugunOkOrNotDoList;
const searchContextForToolbar = lastSearchedValues ?? searchValues;
const activeTabToolbar =
toolbarSnapshotByTab.get(activeTab) ??
computeTabToolbarSnapshot(
tabStatusInfoMap.get(activeTab) ?? null,
tabRowDataExists(rowDataMap, activeTab),
hasSearched,
searchContextForToolbar,
inputPeriodEnterable,
isAdminInfo,
);
/** rowDataMap — 행이 하나라도 있을 때만 툴바·엑셀 의미 있음 */
const hasRowData = Array.from(rowDataMap.values()).some((rows) => rows.length > 0);
const excelToolbarEnabled = hasRowData && isEnterable && gugunOkOrNotDoList;
const showActionToolbarCard =
(hasRowData || hasSearched) &&
(showSaveInToolbar ||
(statusInfo?.laeSbmsnSttsCd === '2' && (isAdminInfo || (isEnterable && gugunOkOrNotDoList))));
const {
showSaveInToolbar,
showEnterableToolbarButtons,
showActionToolbarCard,
} = activeTabToolbar;
return (
<>
@@ -502,9 +766,11 @@ const SewagePipeInp = () => {
{showActionToolbarCard && (
<div className="card !py-0">
<div className="flex justify-end items-center gap-[0.6rem] mx-[0.6rem]">
<VerificationResultButton
VerificationResultButtonFunc={openVerificationHistoryDialog}
/>
{showEnterableToolbarButtons && (
<>
<SubmitExceptionButton SubmitExceptionFunc={SubmitExceptionFunc} />
<LastYearDataButton getLastYearDataFunc={() => void getLastYearDataFunc()} />
<SubmitButton submitFunc={submitFunc} />
</>
@@ -513,7 +779,7 @@ const SewagePipeInp = () => {
</div>
</div>
)}
{isLoading && <div className="absolute inset-0 z-50 [data-theme='black']:bg-transparent rounded-lg p-10 flex justify-center items-center">
{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>
@@ -534,6 +800,14 @@ const SewagePipeInp = () => {
</TabsList>
{columnInfo.map((info) => {
const tabKey = info.tabKey;
const tabEnterableForGrid = computeTabEnterable(
tabStatusInfoMap.get(tabKey) ?? null,
inputPeriodEnterable,
);
const excelToolbarEnabledForTab =
tabRowDataExists(rowDataMap, tabKey) &&
tabEnterableForGrid &&
gugunOkOrNotDoList;
if (info.isSplitGrid && info.left && info.right) {
const leftKey = info.left.tabKey || `${tabKey}_left`;
@@ -544,7 +818,7 @@ const SewagePipeInp = () => {
const showSplitTopButton =
Boolean(splitTopButton?.text) &&
(splitTopButton?.visible ?? true) &&
excelToolbarEnabled;
excelToolbarEnabledForTab;
const leftColumnInfo: ColumnInfo = {
columns: info.left.columns ?? [],
columnLayout: info.left.columnLayout ?? [],
@@ -563,6 +837,16 @@ const SewagePipeInp = () => {
text={splitTopButton?.text ?? ""}
onClick={splitTopButton?.onClick}
statsYr={lastSearchedYear ?? undefined}
initialSearchValues={
lastSearchedValues
? {
sido: lastSearchedValues.sido,
gugun: lastSearchedValues.gugun,
bsnmCd: lastSearchedValues.bsnmCd,
}
: null
}
onSaveSuccess={refreshConnectionStatusAfterPopupSave}
/>
)}
</div>
@@ -618,6 +902,7 @@ const SewagePipeInp = () => {
return (
<TabsContent key={tabKey} value={tabKey}>
<DataGrid
key={(tabEnterableForGrid || isAdminInfo) ? "enterable" : "readonly"}
columnInfo={gridColumnInfo}
rowStartNum={info.rowStartNum}
excelExportFileSuffix={info.tabLabel || tabKey}
@@ -632,18 +917,69 @@ const SewagePipeInp = () => {
return next;
});
}}
isExcelImport={excelToolbarEnabled}
isExcelImport={excelToolbarEnabledForTab}
isExcelExport
showHeaderCheckbox={false}
fixedColumns={info.fixedColumns}
enableSort={false}
getGridDataRef={tabKey === "ST00020001" ? getGridDataRef : undefined}
statusInfo={tabStatusInfoMap.get(tabKey) ?? null}
getGridDataRef={
tabKey === "ST00020001"
? getGridDataRef
: tabKey === "ST00020002"
? getFactInstallGridDataRef
: undefined
}
cellStyleCallback={({ row, fieldName }) => {
const verification = (row as GridRowWithVerification).verificationResult;
if (!verification) return;
const errors = verification[fieldName];
if (Array.isArray(errors) && errors.length > 0) {
const hasImr = errors.some((e) => e.vrfcCd === "IMR");
return hasImr ? "rg-cell-verify-error-imr" : "rg-cell-verify-error";
}
}}
cellTooltipCallback={({ row, fieldName }) => {
const verification = (row as GridRowWithVerification).verificationResult;
if (!verification) return;
const errors = verification[fieldName];
if (Array.isArray(errors) && errors.length > 0) {
return errors
.map((e) => {
const prefix =
e.vrfcCd === "IMR" ? "IMR검증 - " : "일반검증 - ";
const comp = e.compVlCn ?? "";
const now = e.nowVlCn ?? "";
return `${prefix}${e.rsnExpln}(검증 기준값: ${comp} / 현재 입력값: ${now})`;
})
.filter(Boolean)
.join("\n");
}
}}
/>
</TabsContent>
);
})}
</Tabs>
</div>
<VerificationResultHistoryDialog
open={verificationHistoryOpen}
onOpenChange={setVerificationHistoryOpen}
queryContext={
searchValues?.year &&
searchValues?.sido &&
searchValues?.bsnmCd &&
(tabStatsCdMap.get(activeTab) ?? statsCd)
? {
sewerBzmnCd: searchValues.bsnmCd,
ctpvNm: searchValues.sido,
sggNm: searchValues.gugun === "ALL" ? "" : (searchValues.gugun ?? ""),
statsYr: searchValues.year,
statsCd: tabStatsCdMap.get(activeTab) ?? statsCd,
}
: null
}
/>
</>
);
};
@@ -24,13 +24,16 @@ import { getUserInfo } from "../../../../../../packages/common/components/src/ap
import { useConfirm } from "@frontend-opensource/use-react-hooks";
import type { SearchFormValues } from "@/components/type/SearchForm.type";
import type {
SewerageSupplyRateItem,
SewerageSupplyRateRequestParams,
SewerageSupplyRateSaveItem,
SewerageSupplyRateVerificationResult,
SewerageSupplyRateSaveRequest,
SewerageSupplyRateStatusInfo,
import {
type CheckInputPeriodItem,
type SewerageSupplyRateItem,
type SewerageSupplyRateRequestParams,
type SewerageSupplyRateSaveItem,
type SewerageSupplyRateVerificationResult,
type SewerageSupplyRateSaveRequest,
type SewerageSupplyRateStatusInfo,
getVerificationPeriodActionBlockedMessage,
isInputPeriodCurrent,
} from "@/entities/statistics/model/input/statistics-sewage-supply-rate-input-interface";
import type {
cancelSubmitRequest,
@@ -100,6 +103,8 @@ const SeweragePenetrationRateInp = () => {
const [lastSearchedYear, setLastSearchedYear] = useState<string | null>(null);
const [lastSearchedValues, setLastSearchedValues] = useState<SearchFormValues | null>(null);
const [statusInfo, setStatusInfo] = useState<SewerageSupplyRateStatusInfo | null>(null);
/** 마지막 조회 시 check-input-period 응답 (검증기간 여부 판단) */
const [periodCheckList, setPeriodCheckList] = useState<CheckInputPeriodItem[]>([]);
const [showDailog, setShowDailog] = useState<boolean>(false);
const [verificationHistoryOpen, setVerificationHistoryOpen] = useState(false);
const [verificationResultDialogOpen, setVerificationResultDialogOpen] = useState(false);
@@ -147,6 +152,7 @@ const SeweragePenetrationRateInp = () => {
const handleReset = () => {
setRowData([]);
setStatusInfo(null);
setPeriodCheckList([]);
setLastSearchedYear(null);
setLastSearchedValues(null);
setFooterFromApi(null);
@@ -218,8 +224,9 @@ const SeweragePenetrationRateInp = () => {
const response = await statisticsInputSewSupplyRateApi.checkInputPeriod(values.year || '');
if (response.status === 'success') {
const checkEnterable = response.data;
// setIsEnterable(response.data);
const periods = Array.isArray(response.data) ? response.data : [];
setPeriodCheckList(periods);
const checkEnterable = isInputPeriodCurrent(periods);
setLastSearchedYear(values.year || null);
await fetchSewerageSupplyRate(getSewerageSupplyRateParams(values), checkEnterable);
setLastSearchedValues(values);
@@ -232,6 +239,11 @@ const SeweragePenetrationRateInp = () => {
};
const SubmitExceptionFunc = async (submitInfo: StatisticsInputSubmitInfoFormData) => {
const verificationBlocked = getVerificationPeriodActionBlockedMessage(periodCheckList);
if (verificationBlocked) {
alert(verificationBlocked);
return;
}
const userInfo = await getUserInfo();
const params: SubmitExceptionRequest = {
@@ -309,6 +321,11 @@ const SeweragePenetrationRateInp = () => {
};
const executeSubmit = async (submitInfo: StatisticsInputSubmitInfoFormData) => {
const verificationBlocked = getVerificationPeriodActionBlockedMessage(periodCheckList);
if (verificationBlocked) {
alert(verificationBlocked);
return;
}
try {
showGlobalProgress({ label: "제출 진행 중...", progress: 0 });
const userInfo = await getUserInfo();
@@ -388,7 +405,7 @@ const SeweragePenetrationRateInp = () => {
const response = await statisticsInputSewSupplyRateApi.submitSewerageSupplyRate(requestData);
if (response.status === 'success') {
setGlobalProgress(50);
if (response.data.finalStatus === '5') {
const imrParam = {
user_id: userInfo.userUnqId,
@@ -442,6 +459,11 @@ const SeweragePenetrationRateInp = () => {
const saveFunc = async () => {
try {
if (await confirm('저장 하시겠습니까?')) {
const verificationBlocked = getVerificationPeriodActionBlockedMessage(periodCheckList);
if (verificationBlocked) {
alert(verificationBlocked);
return;
}
showGlobalProgress({ label: "저장 진행 중...", progress: 0 });
const userInfo = await getUserInfo();
@@ -520,9 +542,9 @@ const SeweragePenetrationRateInp = () => {
sewerBzmnFrgnrWholPpltnCnt: footerFromApi?.sewerBzmnFrgnrWholPpltnCnt // 행정참고자료 외국인 합계
};
// if (isAdminInfo) {
// requestData.superAdmin = 'superAdmin';
// }
if (isAdminInfo) {
requestData.superAdmin = 'superAdmin';
}
const response = await statisticsInputSewSupplyRateApi.saveSewerageSupplyRate(requestData);
@@ -547,6 +569,11 @@ const SeweragePenetrationRateInp = () => {
const cnacelSubmitFunc = async () => {
try {
if (await confirm('제출된 내용을 취소하시겠습니까?')) {
const verificationBlocked = getVerificationPeriodActionBlockedMessage(periodCheckList);
if (verificationBlocked) {
alert(verificationBlocked);
return;
}
const userInfo = await getUserInfo();
@@ -600,11 +627,11 @@ const SeweragePenetrationRateInp = () => {
{showActionToolbarCard && (
<div className="card !py-0">
<div className="flex justify-end items-center gap-[0.6rem] mx-[0.6rem]">
<VerificationResultButton
VerificationResultButtonFunc={openVerificationHistoryDialog}
/>
{showEnterableToolbarButtons && (
<>
<VerificationResultButton
VerificationResultButtonFunc={openVerificationHistoryDialog}
/>
<LastYearDataButton getLastYearDataFunc={() => getLastYearDataFunc(searchValues ?? {})} />
<SubmitButton submitFunc={() => submitFunc(false)} />
</>
@@ -12,7 +12,6 @@ import type {
ApiResponse,
SearchCondition,
Sgg,
StatisticsLog,
StatisticsLogRequest,
} from "@/entities/statistics/model/statistics-interface";
import {
@@ -0,0 +1,22 @@
const CellColorInfo = () => {
return (
<div className="m-[0.6rem] flex justify-end">
<div className="flex justify-end items-center gap-[1.2rem] !text-xl bg-white w-fit py-3 px-5 border border-slate-200/80 rounded-md">
<div className="flex justify-center items-center gap-x-2">
<div className="w-8 h-6 bg-[#feee85]"></div>
<span>요약표</span>
</div>
<div className="flex justify-center items-center gap-x-2">
<div className="w-8 h-6 bg-[#fff7bf]"></div>
<span>합계</span>
</div>
<div className="flex justify-center items-center gap-x-2">
<div className="w-8 h-6 bg-[#ffe9cf]"></div>
<span>소계</span>
</div>
</div>
</div>
);
};
export default CellColorInfo;
@@ -1,10 +1,202 @@
import { useRef, useState } from "react";
import { useAlertStore } from "@/shared/lib";
import { statisticsTotalStatusApi } from "@/entities/statistics/api/statistics-total-service";
import { LoadingWithAnimation, Tabs, TabsContent, TabsList, TabsTrigger } from "@common/components";
import SearchForm from "@/components/module/SearchForm";
import DataGrid from "@/components/module/DataGrid";
import type { ColumnInfo } from "@/components/type/DataGrid.type";
import type { SearchFormValues } from "@/components/type/SearchForm.type";
import { SewagePipeMaintainColumnListTot } from "@/components/util/column/total/sewagePipeMaintain/sewagePipeMaintainColumn";
import { toGridRows } from "@/components/util/grid/gridFunction";
import type { SewagePipeMaintainTotalRequest } from "@/entities/statistics/model/statistics-total/statistics-sewage-pipe-maintain-total-interface";
const firstTabKey = SewagePipeMaintainColumnListTot[0]?.tabKey ?? "ST00030001";
function buildSewagePipeMaintainTotSearchParam(values: SearchFormValues): SewagePipeMaintainTotalRequest {
return {
ctpvNm: values.sido === "ALL" ? "" : values.sido,
sggNm: values.gugun === "ALL" ? "" : values.gugun,
sewerBzmnCd: values.bsnmCd,
statsYr: values.year,
rowDataYn: values.isDetail,
};
}
const SewagePipeMaintainTot = () => {
const { setMessage: alert } = useAlertStore((state) => state);
const [rowDataMap, setRowDataMap] = useState<Map<string, Record<string, string | number>[]>>(new Map());
const [isLoading, setIsLoading] = useState(false);
const [activeTab, setActiveTab] = useState(firstTabKey);
const lastSearchedValuesRef = useRef<SearchFormValues | null>(null);
const columnInfo = SewagePipeMaintainColumnListTot;
const handleReset = () => {
setRowDataMap(new Map());
setActiveTab(firstTabKey);
lastSearchedValuesRef.current = null;
};
const loadSewagePipeMaintainTotTabData = async (
tabKey: string,
values: SearchFormValues,
mergeIntoExistingMap: boolean,
): Promise<boolean> => {
const param = buildSewagePipeMaintainTotSearchParam(values);
const applyRows = (rows: Record<string, string | number>[]) => {
if (mergeIntoExistingMap) {
setRowDataMap((prev) => {
const next = new Map(prev);
next.set(tabKey, rows);
return next;
});
} else {
const next = new Map<string, Record<string, string | number>[]>();
next.set(tabKey, rows);
setRowDataMap(next);
}
};
if (tabKey === "ST00030001") {
const response = await statisticsTotalStatusApi.getSewagePipeMaintainRepairTotal(param);
if (response.status === "success" && response.data) {
applyRows(toGridRows(response.data as object[]));
return true;
}
alert(response.message || "데이터 조회 중 오류가 발생했습니다.");
return false;
}
if (tabKey === "ST00030002") {
const response = await statisticsTotalStatusApi.getSewagePipeMaintainDredgingTotal(param);
if (response.status === "success" && response.data) {
applyRows(toGridRows(response.data as object[]));
return true;
}
alert(response.message || "데이터 조회 중 오류가 발생했습니다.");
return false;
}
return false;
};
const handleSearch = async (values: SearchFormValues) => {
try {
setIsLoading(true);
let ok = false;
if (activeTab === "ST00030001" || activeTab === "ST00030002") {
ok = await loadSewagePipeMaintainTotTabData(activeTab, values, true);
}
if (ok) {
lastSearchedValuesRef.current = values;
} else {
lastSearchedValuesRef.current = null;
}
} catch {
alert("데이터 조회 중 오류가 발생했습니다.");
lastSearchedValuesRef.current = null;
} finally {
setIsLoading(false);
}
};
const handleActiveTabChange = async (tabKey: string) => {
setActiveTab(tabKey);
const values = lastSearchedValuesRef.current;
if (!values) return;
const existing = rowDataMap.get(tabKey);
const hasData = Array.isArray(existing) && existing.length > 0;
if (hasData) return;
if (tabKey === "ST00030001" || tabKey === "ST00030002") {
try {
setIsLoading(true);
await loadSewagePipeMaintainTotTabData(tabKey, values, true);
} catch {
alert("데이터 조회 중 오류가 발생했습니다.");
} finally {
setIsLoading(false);
}
}
};
return (
<>
<SearchForm />
</>
<div className="relative">
<SearchForm isDetail={true} onSubmit={handleSearch} onReset={handleReset} sidoSelectText="전체" />
{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 className="flex flex-col gap-[0.6rem] mx-[0.6rem]">
<Tabs
value={activeTab}
onValueChange={(v) => void handleActiveTabChange(v)}
className="w-full"
variant="default"
>
<TabsList>
{columnInfo.map((info) => (
<TabsTrigger key={info.tabKey} value={info.tabKey}>
{info.tabLabel || info.tabKey}
</TabsTrigger>
))}
</TabsList>
{columnInfo.map((info) => {
const tabKey = info.tabKey;
if (!info.columns || !info.columnLayout) {
return null;
}
const rowData = rowDataMap.get(tabKey) ?? [];
const gridColumnInfo: ColumnInfo = {
columns: info.columns,
columnLayout: info.columnLayout,
};
return (
<TabsContent key={tabKey} value={tabKey}>
<DataGrid
columnInfo={gridColumnInfo}
rowStartNum={info.rowStartNum}
excelExportFileSuffix={info.tabLabel || tabKey}
rowData={rowData}
setRowData={(updater) => {
setRowDataMap((prev) => {
const next = new Map(prev);
const cur = next.get(tabKey) ?? [];
const n =
typeof updater === "function" ? updater(cur) : updater;
next.set(tabKey, n);
return next;
});
}}
isExcelImport={false}
isExcelExport
showHeaderCheckbox={false}
fixedColumns={info.fixedColumns}
enableSort={false}
cellStyleCallback={({ row }) => {
const rowType = String(row.rowType ?? "");
if (rowType === "DETAIL") return undefined;
if (rowType === "BIZ_SUM") return "rg-cell-rowtype-biz-sum";
return "rg-cell-rowtype-other";
}}
/>
</TabsContent>
);
})}
</Tabs>
</div>
</div>
);
};
@@ -1,4 +1,4 @@
import { useMemo, useState } from "react";
import { useRef, useState } from "react";
import { useAlertStore } from "@/shared/lib";
import { statisticsTotalStatusApi } from "@/entities/statistics/api/statistics-total-service";
@@ -6,71 +6,165 @@ import { LoadingWithAnimation, Tabs, TabsContent, TabsList, TabsTrigger } from "
import SearchForm from "@/components/module/SearchForm";
import DataGrid from "@/components/module/DataGrid";
import type { ColumnConfig, ColumnInfo, ColumnInfoList } from "@/components/type/DataGrid.type";
import type { ColumnInfo } from "@/components/type/DataGrid.type";
import type { SearchFormValues } from "@/components/type/SearchForm.type";
import { SewagePipeColumnInfoListTot } from "@/components/util/column/total/SewagePipeTotColumn";
import { toGridRows } from "@/components/util/grid/gridFunction";
import type { SewagePipeTotalRequest } from "@/entities/statistics/model/statistics-total/statistics-sewage-pipe-total-interface";
const firstTabKey = SewagePipeColumnInfoListTot[0]?.tabKey ?? "ST00020001";
function buildSewagePipeTotSearchParam(values: SearchFormValues): SewagePipeTotalRequest {
return {
ctpvNm: values.sido === "ALL" ? "" : values.sido,
sggNm: values.gugun === "ALL" ? "" : values.gugun,
sewerBzmnCd: values.bsnmCd,
statsYr: values.year,
rowDataYn: values.isDetail,
};
}
const SewagePipeTot = () => {
const { setMessage: alert } = useAlertStore((state) => state);
const [rowDataMap, setRowDataMap] = useState<Map<string, Record<string, string | number>[]>>(new Map());
const [isLoading, setIsLoading] = useState(false);
const [activeTab, setActiveTab] = useState(firstTabKey);
/** 조회 성공 후 탭 전환 시 동일 조건으로 지연 조회 */
const lastSearchedValuesRef = useRef<SearchFormValues | null>(null);
const columnInfo = useMemo(
() =>
SewagePipeColumnInfoListTot.map((tab: ColumnInfoList) => ({
...tab,
columns: (tab.columns ?? []).map((col: ColumnConfig) => ({
...col,
editable: false as const,
styleName: col.styleName ?? ("rg-cell-readonly" as const),
})),
})),
[],
);
const columnInfo = SewagePipeColumnInfoListTot;
const handleReset = () => {
setRowDataMap(new Map());
setActiveTab(firstTabKey);
lastSearchedValuesRef.current = null;
};
/** SewagePipeInp의 `loadSewagePipeActiveTabData`와 동일 역할: 탭키별 API 호출 후 `rowDataMap` 반영 */
const loadSewagePipeTotTabData = async (
tabKey: string,
values: SearchFormValues,
mergeIntoExistingMap: boolean,
): Promise<boolean> => {
const param = buildSewagePipeTotSearchParam(values);
const applyRows = (rows: Record<string, string | number>[]) => {
if (mergeIntoExistingMap) {
setRowDataMap((prev) => {
const next = new Map(prev);
next.set(tabKey, rows);
return next;
});
} else {
const next = new Map<string, Record<string, string | number>[]>();
next.set(tabKey, rows);
setRowDataMap(next);
}
};
if (tabKey === "ST00020001") {
const response = await statisticsTotalStatusApi.getSewagePipeRegionalStatusList(param);
if (response.status === "success" && response.data) {
applyRows(toGridRows(response.data as object[]));
return true;
}
alert(response.message || "데이터 조회 중 오류가 발생했습니다.");
return false;
}
if (tabKey === "ST00020002") {
const response = await statisticsTotalStatusApi.getSewagePipeFactInstallStatus(param);
if (response.status === "success" && response.data) {
applyRows(toGridRows(response.data as object[]));
return true;
}
alert(response.message || "데이터 조회 중 오류가 발생했습니다.");
return false;
}
if (tabKey === "ST00020003") {
const response = await statisticsTotalStatusApi.getSewagePipePipeTypeFacilityStatus(param);
if (response.status === "success" && response.data) {
applyRows(toGridRows(response.data as object[]));
return true;
}
alert(response.message || "데이터 조회 중 오류가 발생했습니다.");
return false;
}
if (tabKey === "ST00020004") {
const response = await statisticsTotalStatusApi.getSewagePipePipeDiamStatus(param);
if (response.status === "success" && response.data) {
applyRows(toGridRows(response.data as object[]));
return true;
}
alert(response.message || "데이터 조회 중 오류가 발생했습니다.");
return false;
}
return false;
};
const handleSearch = async (values: SearchFormValues) => {
setActiveTab(firstTabKey);
setRowDataMap(new Map());
try {
setIsLoading(true);
const param = {
ctpvNm: values.sido === "ALL" ? "" : values.sido,
sggNm: values.gugun === "ALL" ? "" : values.gugun,
sewerBzmnCd: values.bsnmCd,
statsYr: values.year,
rowDataYn: values.isDetail,
};
const response = await statisticsTotalStatusApi.getSewagePipeRegionalStatusList(param);
if (response.status === "success" && response.data) {
const next = new Map<string, Record<string, string | number>[]>();
next.set("ST00020001", toGridRows(response.data as object[]));
setRowDataMap(next);
// SewagePipeInp과 동일: 검색 시 활성 탭 데이터만 조회·맵 병합
let ok = false;
if (
activeTab === "ST00020001" ||
activeTab === "ST00020002" ||
activeTab === "ST00020003" ||
activeTab === "ST00020004"
) {
ok = await loadSewagePipeTotTabData(activeTab, values, true);
}
if (ok) {
lastSearchedValuesRef.current = values;
} else {
alert(response.message || "데이터 조회 중 오류가 발생했습니다.");
lastSearchedValuesRef.current = null;
}
} catch {
alert("데이터 조회 중 오류가 발생했습니다.");
lastSearchedValuesRef.current = null;
} finally {
setIsLoading(false);
}
};
const handleActiveTabChange = async (tabKey: string) => {
setActiveTab(tabKey);
const values = lastSearchedValuesRef.current;
if (!values) return;
const existing = rowDataMap.get(tabKey);
const hasData = Array.isArray(existing) && existing.length > 0;
if (hasData) return;
if (
tabKey === "ST00020001" ||
tabKey === "ST00020002" ||
tabKey === "ST00020003" ||
tabKey === "ST00020004"
) {
try {
setIsLoading(true);
await loadSewagePipeTotTabData(tabKey, values, true);
} catch {
alert("데이터 조회 중 오류가 발생했습니다.");
} finally {
setIsLoading(false);
}
return;
}
console.log("[SewagePipeTot] tabKey (API 미연결):", tabKey);
};
return (
<div className="relative">
<SearchForm isDetail={true} onSubmit={handleSearch} onReset={handleReset} />
<SearchForm isDetail={true} onSubmit={handleSearch} onReset={handleReset} sidoSelectText="전체" />
{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]">
@@ -81,7 +175,7 @@ const SewagePipeTot = () => {
<div className="flex flex-col gap-[0.6rem] mx-[0.6rem]">
<Tabs
value={activeTab}
onValueChange={setActiveTab}
onValueChange={(v) => void handleActiveTabChange(v)}
className="w-full"
variant="default"
>
@@ -8,6 +8,7 @@ import { useAlertStore } from "@/shared/lib";
import SearchForm from "@/components/module/SearchForm";
import DataGrid from "@/components/module/DataGrid";
import CellColorInfo from "./CellColorInfo";
import { LoadingWithAnimation } from "@common/components";
const SeweragePenetrationRateTot = () => {
@@ -19,12 +20,17 @@ const SeweragePenetrationRateTot = () => {
try {
setIsLoading(true);
const isSummaryEnabled = values.isSummary !== 'ALL';
const summaryYearCnt = isSummaryEnabled ? Number(values.isSummary) : undefined;
const param = {
ctpvNm: values.sido === 'ALL' ? '' : values.sido,
sggNm: values.gugun === 'ALL' ? '' : values.gugun,
sewerBzmnCd: values.bsnmCd,
statsYr: values.year,
rowDataYn: values.isDetail
rowDataYn: values.isDetail,
summaryYn: isSummaryEnabled,
...(isSummaryEnabled ? { summaryYearCnt } : {}),
};
const response = await statisticsTotalStatusApi.getSewerageSupplyRateList(param);
@@ -35,7 +41,8 @@ const SeweragePenetrationRateTot = () => {
} else {
alert(response.message || '데이터 조회 중 오류가 발생했습니다.');
}
} catch {
} catch (error) {
console.log(error)
alert('데이터 조회 중 오류가 발생했습니다.');
} finally {
setIsLoading(false);
@@ -44,12 +51,13 @@ const SeweragePenetrationRateTot = () => {
return (
<>
<SearchForm isDetail={true} onSubmit={handleSearch} sidoSelectText="전체" />
<SearchForm isDetail={true} isSummary={true} onSubmit={handleSearch} sidoSelectText="전체" />
{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>}
<CellColorInfo />
<DataGrid
columnInfo={SeweragePentrationRateTotColumn}
rowStartNum={2}
@@ -61,6 +69,7 @@ const SeweragePenetrationRateTot = () => {
const rowType = String(row.rowType ?? '');
if (rowType === 'DETAIL') return undefined;
if (rowType === 'BIZ_SUM') return 'rg-cell-rowtype-biz-sum';
if (rowType === 'SUMMARY') return 'rg-cell-rowtype-summary';
return 'rg-cell-rowtype-other';
}}
/>
@@ -1,14 +1,14 @@
import { useEffect, useMemo, useState } from "react";
import { useRef, useState } from "react";
import { useAlertStore } from "@/shared/lib";
import { statisticsTotalStatusApi } from "@/entities/statistics/api/statistics-total-service";
import { toGridRows } from "@/components/util/grid/gridFunction";
import SearchForm from "@/components/module/SearchForm";
import TabDataGrid from "@/components/module/TabDataGrid";
import { LoadingWithAnimation } from "@common/components";
import DataGrid from "@/components/module/DataGrid";
import { LoadingWithAnimation, Tabs, TabsContent, TabsList, TabsTrigger } from "@common/components";
import type { ColumnConfig, ColumnInfoList } from "@/components/type/DataGrid.type";
import type { ColumnInfo } from "@/components/type/DataGrid.type";
import type { SearchFormValues } from "@/components/type/SearchForm.type";
import { ComplaintInpColumn } from "@/components/util/column/total/sewerageServiceLevel/ComplaintTotColumn";
import { ResidentFriendlyFactTotColumn } from "@/components/util/column/total/sewerageServiceLevel/ResidentFriendlyFactTotColumn";
@@ -17,81 +17,111 @@ const SewerageServiceLevelInpTabList = [
ComplaintInpColumn,
ResidentFriendlyFactTotColumn,
];
const SUPPORTED_TAB_KEYS = [ComplaintInpColumn.tabKey, ResidentFriendlyFactTotColumn.tabKey] as const;
const SewerageServiceLevelTot = () => {
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(ComplaintInpColumn.tabKey);
const [lastSearchValues, setLastSearchValues] = useState<SearchFormValues | null>(null);
const lastSearchValuesRef = useRef<SearchFormValues | null>(null);
const columnInfo = useMemo(
() =>
SewerageServiceLevelInpTabList.map((tab: ColumnInfoList) => ({
...tab,
columns: (tab.columns ?? []).map((col: ColumnConfig) => ({
...col,
editable: false as const,
styleName: col.styleName ?? ("rg-cell-readonly" as const),
})),
})),
[],
);
const columnInfo = SewerageServiceLevelInpTabList;
const handleReset = () => {
setRowDataMap(new Map());
setActiveTab(ComplaintInpColumn.tabKey);
setLastSearchValues(null);
lastSearchValuesRef.current = null;
};
const fetchByTab = async (tabKey: string, values: SearchFormValues) => {
try {
setIsLoading(true);
const loadSewerageServiceLevelTotTabData = async (
tabKey: string,
values: SearchFormValues,
mergeIntoExistingMap: boolean,
) => {
const param = {
ctpvNm: values.sido === "ALL" ? "" : values.sido,
sggNm: values.gugun === "ALL" ? "" : values.gugun,
sewerBzmnCd: values.bsnmCd,
statsYr: values.year,
rowDataYn: values.isDetail,
};
const param = {
ctpvNm: values.sido === "ALL" ? "" : values.sido,
sggNm: values.gugun === "ALL" ? "" : values.gugun,
sewerBzmnCd: values.bsnmCd,
statsYr: values.year,
rowDataYn: values.isDetail,
};
const fetcherMap = {
[ComplaintInpColumn.tabKey]: statisticsTotalStatusApi.getSewerageServiceLevelComplaint.bind(
statisticsTotalStatusApi,
),
[ResidentFriendlyFactTotColumn.tabKey]:
statisticsTotalStatusApi.getSewerageServiceLevelResidentFriendlyFact.bind(
statisticsTotalStatusApi,
),
};
const fetcher = fetcherMap[tabKey];
if (!fetcher) {
return false;
}
const response = await fetcher(param);
let response;
if (tabKey === ResidentFriendlyFactTotColumn.tabKey) {
response =
await statisticsTotalStatusApi.getSewerageServiceLevelResidentFriendlyFact(param);
} else {
response =
await statisticsTotalStatusApi.getSewerageServiceLevelComplaint(param);
}
if (response.status === "success" && response.data) {
if (response.status === "success" && response.data) {
const rows = toGridRows(response.data as object[]);
if (mergeIntoExistingMap) {
setRowDataMap((prev) => {
const next = new Map(prev);
next.set(tabKey, toGridRows(response.data as object[]));
next.set(tabKey, rows);
return next;
});
} else {
alert(response.message || "데이터 조회 중 오류가 발생했습니다.");
const next = new Map<string, Record<string, string | number>[]>();
next.set(tabKey, rows);
setRowDataMap(next);
}
return true;
}
alert(response.message || "데이터 조회 중 오류가 발생했습니다.");
return false;
};
const handleSearch = async (values: SearchFormValues) => {
try {
setIsLoading(true);
let ok = false;
if (SUPPORTED_TAB_KEYS.includes(activeTab)) {
ok = await loadSewerageServiceLevelTotTabData(activeTab, values, true);
}
if (ok) {
lastSearchValuesRef.current = values;
} else {
lastSearchValuesRef.current = null;
}
} catch {
alert("데이터 조회 중 오류가 발생했습니다.");
lastSearchValuesRef.current = null;
} finally {
setIsLoading(false);
}
};
const handleSearch = async (values: SearchFormValues) => {
setLastSearchValues(values);
setRowDataMap(new Map());
await fetchByTab(activeTab, values);
};
const handleActiveTabChange = async (tabKey: string) => {
setActiveTab(tabKey);
useEffect(() => {
if (lastSearchValues == null) return;
if (rowDataMap.has(activeTab)) return;
void fetchByTab(activeTab, lastSearchValues);
}, [activeTab]);
const values = lastSearchValuesRef.current;
if (!values) return;
const existing = rowDataMap.get(tabKey);
const hasData = Array.isArray(existing) && existing.length > 0;
if (hasData) return;
if (SUPPORTED_TAB_KEYS.includes(tabKey)) {
try {
setIsLoading(true);
await loadSewerageServiceLevelTotTabData(tabKey, values, true);
} catch {
alert("데이터 조회 중 오류가 발생했습니다.");
} finally {
setIsLoading(false);
}
}
};
return (
<div className="relative">
@@ -103,20 +133,60 @@ const SewerageServiceLevelTot = () => {
</div>
</div>
)}
<TabDataGrid
columnInfo={columnInfo}
isExcelImport={false}
rowDataMap={rowDataMap}
setRowDataMap={setRowDataMap}
activeTab={activeTab}
onActiveTabChange={setActiveTab}
cellStyleCallback={({ row }) => {
const rowType = String(row.rowType ?? "");
if (rowType === "DETAIL") return undefined;
if (rowType === "BIZ_SUM") return "rg-cell-rowtype-biz-sum";
return "rg-cell-rowtype-other";
}}
/>
<div className="flex flex-col gap-[0.6rem] mx-[0.6rem]">
<Tabs value={activeTab} onValueChange={(v) => void handleActiveTabChange(v)} className="w-full" variant="default">
<TabsList>
{columnInfo.map((info) => (
<TabsTrigger key={info.tabKey} value={info.tabKey}>
{info.tabLabel || info.tabKey}
</TabsTrigger>
))}
</TabsList>
{columnInfo.map((info) => {
const tabKey = info.tabKey;
if (!info.columns || !info.columnLayout) {
return null;
}
const rowData = rowDataMap.get(tabKey) ?? [];
const gridColumnInfo: ColumnInfo = {
columns: info.columns,
columnLayout: info.columnLayout,
};
return (
<TabsContent key={tabKey} value={tabKey}>
<DataGrid
columnInfo={gridColumnInfo}
rowStartNum={info.rowStartNum}
excelExportFileSuffix={info.tabLabel || tabKey}
rowData={rowData}
setRowData={(updater) => {
setRowDataMap((prev) => {
const next = new Map(prev);
const cur = next.get(tabKey) ?? [];
const n = typeof updater === "function" ? updater(cur) : updater;
next.set(tabKey, n);
return next;
});
}}
isExcelImport={false}
isExcelExport
showHeaderCheckbox={false}
fixedColumns={info.fixedColumns}
enableSort={false}
cellStyleCallback={({ row }) => {
const rowType = String(row.rowType ?? "");
if (rowType === "DETAIL") return undefined;
if (rowType === "BIZ_SUM") return "rg-cell-rowtype-biz-sum";
return "rg-cell-rowtype-other";
}}
/>
</TabsContent>
);
})}
</Tabs>
</div>
</div>
);
};
@@ -3,7 +3,6 @@ import { useEffect, useRef, useState } from 'react';
import { NoData } from '@/shared/ui/no-data';
import {
Button,
Input,
Table,
TableBody,
TableCell,
+80 -74
View File
@@ -3,9 +3,15 @@ import React from "react";
import { ValueType } from "realgrid";
import type { RealGridReact } from "realgrid-react";
import { type SewerageSupplyRateStatusInfo } from "@/entities/statistics/model/input/statistics-sewage-supply-rate-input-interface";
// RealGrid 타입 정의
/** DataGrid 상단 진행상황/제출일/담당자 표시에 사용 (보급률·하수관로 등 공통) */
export type DataGridStatusBannerInfo = {
laeSbmsnSttsCd: string | null;
laeSbmsnYmd: string | null;
laePicNm: string | null;
laePicTelNo: string | null;
};
/** setColumns에 전달하는 컬럼 정보 (footer 배열/단일 객체 포함) */
export type RealGridColumnInfo = {
name: string;
@@ -19,30 +25,30 @@ export type RealGridColumnInfo = {
mergeRule?: { criteria: "value" | string };
sortable?: boolean;
footer?:
| {
text?: string;
expression?: "sum" | "avg" | "count" | "max" | "min";
numberFormat?: string;
valueCallback?: (
grid: GridView,
column: DataColumn,
footerIndex: number,
columnFooter: ColumnFooter,
value: number | string | undefined,
) => number | string;
}
| Array<{
text?: string;
expression?: "sum" | "avg" | "count" | "max" | "min";
numberFormat?: string;
valueCallback?: (
grid: GridView,
column: DataColumn,
footerIndex: number,
columnFooter: ColumnFooter,
value: number | string | undefined,
) => number | string;
}>;
| {
text?: string;
expression?: "sum" | "avg" | "count" | "max" | "min";
numberFormat?: string;
valueCallback?: (
grid: GridView,
column: DataColumn,
footerIndex: number,
columnFooter: ColumnFooter,
value: number | string | undefined,
) => number | string;
}
| Array<{
text?: string;
expression?: "sum" | "avg" | "count" | "max" | "min";
numberFormat?: string;
valueCallback?: (
grid: GridView,
column: DataColumn,
footerIndex: number,
columnFooter: ColumnFooter,
value: number | string | undefined,
) => number | string;
}>;
};
/** setColumns 메서드가 있는 GridView */
@@ -111,21 +117,21 @@ export type DataFieldValueCallback = (
export type ComputedFieldConfig =
| { fields: string[]; type: "sum" | "avg" }
| {
numeratorFields: string[];
denominatorFields: string[];
type: "pblswrgPopltnSuprtRate";
}
numeratorFields: string[];
denominatorFields: string[];
type: "pblswrgPopltnSuprtRate";
}
| {
numeratorFields: string[];
denominatorFields: string[];
type: "adtrtPopltnSuprtRate";
}
numeratorFields: string[];
denominatorFields: string[];
type: "adtrtPopltnSuprtRate";
}
| {
numeratorFields: string[];
denominatorFields: string[];
thirdFields: string[];
type: "swrgInstlrtRate";
};
numeratorFields: string[];
denominatorFields: string[];
thirdFields: string[];
type: "swrgInstlrtRate";
};
export type ColumnConfig = {
name: string;
@@ -148,31 +154,31 @@ export type ColumnConfig = {
valueType?: ValueType;
dataFormat?: string;
footer?:
| {
text?: string;
expression?: "sum" | "avg" | "count" | "max" | "min";
numberFormat?: string;
valueCallback?: (
grid: GridView,
column: DataColumn,
footerIndex: number,
columnFooter: ColumnFooter,
value: number | string | undefined,
) => number | string;
multiRow?: boolean; // 여러 행 footer 사용 여부
}
| Array<{
text?: string;
expression?: "sum" | "avg" | "count" | "max" | "min";
numberFormat?: string;
valueCallback?: (
grid: GridView,
column: DataColumn,
footerIndex: number,
columnFooter: ColumnFooter,
value: number | string | undefined,
) => number | string;
}>; // RealGrid에서 footer를 배열로 설정할 수 있음
| {
text?: string;
expression?: "sum" | "avg" | "count" | "max" | "min";
numberFormat?: string;
valueCallback?: (
grid: GridView,
column: DataColumn,
footerIndex: number,
columnFooter: ColumnFooter,
value: number | string | undefined,
) => number | string;
multiRow?: boolean; // 여러 행 footer 사용 여부
}
| Array<{
text?: string;
expression?: "sum" | "avg" | "count" | "max" | "min";
numberFormat?: string;
valueCallback?: (
grid: GridView,
column: DataColumn,
footerIndex: number,
columnFooter: ColumnFooter,
value: number | string | undefined,
) => number | string;
}>; // RealGrid에서 footer를 배열로 설정할 수 있음
};
export type FooterUserSpan = {
@@ -184,16 +190,16 @@ export type FooterUserSpan = {
export type ColumnLayoutItem =
| string
| {
name: string;
hideChildHeaders?: boolean;
header: { text: string };
items: (string | ColumnLayoutItem)[];
footerUserSpans?: FooterUserSpan[]; // footer 병합 설정
}
name: string;
hideChildHeaders?: boolean;
header: { text: string };
items: (string | ColumnLayoutItem)[];
footerUserSpans?: FooterUserSpan[]; // footer 병합 설정
}
| {
column: string; // 컬럼 이름 (footerUserSpans 사용 시)
footerUserSpans?: FooterUserSpan[]; // footer 병합 설정
};
column: string; // 컬럼 이름 (footerUserSpans 사용 시)
footerUserSpans?: FooterUserSpan[]; // footer 병합 설정
};
export type ColumnInfo = {
columns: ColumnConfig[];
@@ -285,7 +291,7 @@ export type DataGridProps = {
/** CheckBar 헤더 전체 선택/해제 */
onItemAllChecked?: (checked: boolean) => void;
onScrollToBottom?: () => void;
statusInfo?: SewerageSupplyRateStatusInfo | null;
statusInfo?: DataGridStatusBannerInfo | null;
/** 강조 및 스크롤할 행의 ID */
highlightedRowId?: string | number | null;
/** ID로 사용할 필드명 (기본값: 'id') */
@@ -6,6 +6,7 @@ export type SearchFormValues = {
fctName?: string;
bsnmCd?: string;
isDetail?: boolean;
isSummary?: string;
};
export type SearchFormProps = {
@@ -17,6 +18,7 @@ export type SearchFormProps = {
fctName?: boolean;
userMenual?: boolean;
isDetail?: boolean;
isSummary?: boolean;
onSubmit?: (values: SearchFormValues) => void;
onReset?: () => void;
onValuesChange?: (values: SearchFormValues) => void;
@@ -33,4 +35,5 @@ export const defaultSearchFormProps: Required<
fctName: false,
userMenual: false,
isDetail: false,
isSummary: false,
};
@@ -20,4 +20,15 @@ export const needBsnmFrgnrPoplSmSidoList = [
"4700000000",
"4800000000",
"5000000000",
];
export const doNameList = [
"경기도",
"강원특별자치도",
"충청북도",
"충청남도",
"전북특별자치도",
"전라남도",
"경상북도",
"경상남도",
];
@@ -49,18 +49,18 @@ export const AdminAreaConnectionInpColumn = {
export const TreatPlantConnectionInpColumn = {
columns: [
// 하수처리장 정보
{ name: 'bscFctNm', fieldName: 'bscFctNm', header: { text: '하수처리장명' }, editable: false },
{ name: 'fctCpc', fieldName: 'fctCpc', header: { text: '용량' }, valueType: ValueType.NUMBER, dataFormat: '#,##0', editable: false },
{ name: 'bscFctCd', fieldName: 'bscFctCd', header: { text: '하수처리장코드' }, editable: false },
{ name: 'fcltNm', fieldName: 'fcltNm', header: { text: '하수처리장명' }, editable: false },
{ name: 'fcltCpct', fieldName: 'fcltCpct', header: { text: '용량' }, valueType: ValueType.NUMBER, dataFormat: '#,##0', editable: false },
{ name: 'fcltSn', fieldName: 'fcltSn', header: { text: '하수처리장코드' }, editable: false },
// 연결여부
{ name: 'isConnection', fieldName: 'isConnection', header: { text: '연결여부' }, editable: false },
// 연결지역
{ name: 'connectionAreaList', fieldName: 'connectionAreaList', header: { text: '연결지역' }, editable: false },
],
columnLayout: [
'bscFctNm',
'fctCpc',
'bscFctCd',
'fcltNm',
'fcltCpct',
'fcltSn',
'isConnection',
'connectionAreaList',
],
@@ -58,7 +58,7 @@ export const FactInstallStatusInpColumn = {
// 식별·키 필드 (API SeweragePipeInputFactInstallStatusItem, 그리드 비표시 — fixedColumns와 겹치지 않도록 맨 뒤)
{ name: 'admdstCd', fieldName: 'admdstCd', header: { text: '행정구역코드' }, visible: false, editable: false, styleName: 'rg-cell-readonly' },
{ name: 'prcsFcltSn', fieldName: 'prcsFcltSn', header: { text: '처리시설일련번호' }, visible: false, editable: false, valueType: ValueType.NUMBER, dataFormat: '#,##0' },
{ name: 'fcltSn', fieldName: 'fcltSn', header: { text: '처리시설일련번호' }, visible: false, editable: false, valueType: ValueType.TEXT },
{ name: 'swtrmtFcltCd', fieldName: 'swtrmtFcltCd', header: { text: '하수처리시설코드' }, visible: false, editable: false, styleName: 'rg-cell-readonly' },
],
columnLayout: [
@@ -146,7 +146,7 @@ export const FactInstallStatusInpColumn = {
},
'rmrk',
'admdstCd',
'prcsFcltSn',
'fcltSn',
'swtrmtFcltCd',
],
rowStartNum: 4,
@@ -1,5 +1,3 @@
import dayjs from "dayjs";
import { headers } from "next/headers";
import { ValueType } from "realgrid";
/**
@@ -7,9 +5,9 @@ import { ValueType } from "realgrid";
* 항목은 검토(SewagePipeRvwColumn)와 동일하나 footer·mergeCells·구분(displayYear)·비고(rmrk) 제외.
* 표시 형식은 SeweragePentrationRateTotColumn과 같이 dataFormat + editable: false.
*/
const currentYear = dayjs().year() - 1;
const startYear = 1996;
const totalCount = currentYear - startYear + 1 + 1;
/** ST00020003 관종별: 입력 `SeweragePipeInputPipeTypeStatusItem`·합계 `SewagePipePipeTypeFacilityStatusItem`과 동일 (yy1=1996년 이전 … yy30) */
const YY_PIPE_TYPE_FACILITY_MAX = 30;
const rawSewagePipeTotList = [
{
@@ -642,11 +640,11 @@ const rawSewagePipeTotList = [
dataFormat: "#,##0.0",
editable: false,
},
...Array.from({ length: totalCount }, (_, i) => {
const dtoNum = totalCount - i;
const wholField = `yy${dtoNum}PrlgWhol`;
const lyngField = `yy${dtoNum}LyngPrlg`;
const dmltnField = `yy${dtoNum}DmltnPrlg`;
...Array.from({ length: YY_PIPE_TYPE_FACILITY_MAX }, (_, i) => {
const yyIdx = i + 1;
const wholField = `yy${yyIdx}PrlgWhol`;
const lyngField = `yy${yyIdx}LyngPrlg`;
const dmltnField = `yy${yyIdx}DmltnPrlg`;
return [
{
name: wholField,
@@ -687,13 +685,18 @@ const rawSewagePipeTotList = [
header: { text: "총계" },
items: ["pipeByPrstWholSum", "lyngPrlgSum", "dmltnPrlgSum"],
},
...Array.from({ length: totalCount }, (_, i) => {
const dtoNum = totalCount - i;
const yearLabel = i === 0 ? "1996년이전" : `${startYear + (i - 1)}년`;
{
name: "year1996Before",
header: { text: "1996년이전" },
items: ["yy1PrlgWhol", "yy1LyngPrlg", "yy1DmltnPrlg"],
},
...Array.from({ length: YY_PIPE_TYPE_FACILITY_MAX - 1 }, (_, i) => {
const yyIdx = i + 2;
const year = 1994 + yyIdx;
return {
name: `group_${yearLabel}`,
header: { text: yearLabel },
items: [`yy${dtoNum}PrlgWhol`, `yy${dtoNum}LyngPrlg`, `yy${dtoNum}DmltnPrlg`],
name: `group_yy${yyIdx}`,
header: { text: `${year}년` },
items: [`yy${yyIdx}PrlgWhol`, `yy${yyIdx}LyngPrlg`, `yy${yyIdx}DmltnPrlg`],
};
}),
],
@@ -0,0 +1,79 @@
import { ValueType } from "realgrid";
// 하수도 통계 관리 > 03.하수관로 유지관리 > tab 2. 관로준설
export const PipeDredgingStatusTotColumn = {
columns: [
// 행정구역
{ name: 'ctpvNm', fieldName: 'ctpvNm', header: { text: '시도' } },
{ name: 'sggNm', fieldName: 'sggNm', header: { text: '시군구명' } },
{ name: 'admdstNm', fieldName: 'admdstNm', header: { text: '행정구역명' } },
// 하수관로 총 시설연장
{ name: 'wholFcltPrlg', fieldName: 'wholFcltPrlg', header: { text: '하수관로총시설연장(m)' }, valueType: ValueType.NUMBER, dataFormat: '#,##0.00' },
// 준설연장 및 개소수 > 관로(m)
{ name: 'wholSwagePpl', fieldName: 'wholSwagePpl', header: { text: '합계' }, valueType: ValueType.NUMBER, dataFormat: '#,##0.0' },
{ name: 'cbsysPplPrlg', fieldName: 'cbsysPplPrlg', header: { text: '합류식' }, valueType: ValueType.NUMBER, dataFormat: '#,##0.0' },
// 준설연장 및 개소수 > 관로(m) > 분류식
{ name: 'sprsysExlnPrlg', fieldName: 'sprsysExlnPrlg', header: { text: '우수' }, valueType: ValueType.NUMBER, dataFormat: '#,##0.0' },
{ name: 'sprsysDrtwPrlg', fieldName: 'sprsysDrtwPrlg', header: { text: '오수' }, valueType: ValueType.NUMBER, dataFormat: '#,##0.0' },
// 준설연장 및 개소수 > 맨홀, 오·우수받이, 토실·토구
{ name: 'mnhlCnt', fieldName: 'mnhlCnt', header: { text: '맨홀(개소)' }, valueType: ValueType.NUMBER, dataFormat: '#,##0' },
{ name: 'exlnDrtwRcvprpCnt', fieldName: 'exlnDrtwRcvprpCnt', header: { text: '오·우수받이(개소)' }, valueType: ValueType.NUMBER, dataFormat: '#,##0' },
{ name: 'stofcbrOtflCnt', fieldName: 'stofcbrOtflCnt', header: { text: '토실·토구(개소)' }, valueType: ValueType.NUMBER, dataFormat: '#,##0' },
// 계획 & 비율
{ name: 'drdgnPlanPrlg', fieldName: 'drdgnPlanPrlg', header: { text: '준설 계획 연장(m)' }, valueType: ValueType.NUMBER, dataFormat: '#,##0.0' },
{ name: 'drdgnTrgtCtrsDrdgnRt', fieldName: 'drdgnTrgtCtrsDrdgnRt', header: { text: '계획대비 준설율(%)' }, valueType: ValueType.NUMBER, dataFormat: '#,##0.0' },
{ name: 'wholPrlgCtrsDrdgnRt', fieldName: 'wholPrlgCtrsDrdgnRt', header: { text: '총시설연장대비 준설율(%)' }, valueType: ValueType.NUMBER, dataFormat: '#,##0.0' },
// 준설실적
{ name: 'drdgnAmt', fieldName: 'drdgnAmt', header: { text: '금액(백만원)' }, valueType: ValueType.NUMBER, dataFormat: '#,##0' },
{ name: 'drdgnQnt', fieldName: 'drdgnQnt', header: { text: '준설량(m³)' }, valueType: ValueType.NUMBER, dataFormat: '#,##0' },
// 준설토사처리
{ name: 'drdgnUntprcAmt', fieldName: 'drdgnUntprcAmt', header: { text: '준설토처리비(백만원)' }, valueType: ValueType.NUMBER, dataFormat: '#,##0' },
{ name: 'wholDrdgnPrcsQnt', fieldName: 'wholDrdgnPrcsQnt', header: { text: '처리량 계(톤)' }, valueType: ValueType.NUMBER, dataFormat: '#,##0.0' },
{ name: 'rclmQnt', fieldName: 'rclmQnt', header: { text: '매립' }, valueType: ValueType.NUMBER, dataFormat: '#,##0.0' },
{ name: 'oceanEmsnQnt', fieldName: 'oceanEmsnQnt', header: { text: '해양투기' }, valueType: ValueType.NUMBER, dataFormat: '#,##0.0' },
{ name: 'rcycQnt', fieldName: 'rcycQnt', header: { text: '재활용' }, valueType: ValueType.NUMBER, dataFormat: '#,##0.0' },
{ name: 'etcQnt', fieldName: 'etcQnt', header: { text: '기타(소각, 고형화, 시멘트원료 등)' }, valueType: ValueType.NUMBER, dataFormat: '#,##0.0' },
].map((col) => ({ ...col, editable: false })),
columnLayout: [
{ name: 'group_admin', header: { text: '행정구역' }, items: ['ctpvNm', 'sggNm', 'admdstNm'] },
'wholFcltPrlg',
{
name: 'group_dredge_info',
header: { text: '준설연장 및 개소수' },
items: [
{
name: 'group_pipe',
header: { text: '관로(m)' },
items: [
'wholSwagePpl', 'cbsysPplPrlg',
{ name: 'group_sep', header: { text: '분류식' }, items: ['sprsysExlnPrlg', 'sprsysDrtwPrlg'] }
]
},
'mnhlCnt', 'exlnDrtwRcvprpCnt', 'stofcbrOtflCnt'
]
},
'drdgnPlanPrlg', 'drdgnTrgtCtrsDrdgnRt', 'wholPrlgCtrsDrdgnRt',
{
name: 'group_perf',
header: { text: '준설실적' },
items: ['drdgnAmt', 'drdgnQnt']
},
{
name: 'group_soil_treat',
header: { text: '준설토사처리' },
items: ['drdgnUntprcAmt', 'wholDrdgnPrcsQnt', 'rclmQnt', 'oceanEmsnQnt', 'rcycQnt', 'etcQnt']
},
],
rowStartNum: 4,
fixedColumns: 1,
tabKey: 'ST00030002',
tabLabel: '관로준설'
};
@@ -0,0 +1,112 @@
import { ValueType } from "realgrid";
// 하수도 통계 관리 > 03.하수관로 유지관리 > tab 1. 개보수
export const RepairStatusTotColumn = {
columns: [
// 행정구역
{ name: 'ctpvNm', fieldName: 'ctpvNm', header: { text: '시도' }, editable: false },
{ name: 'sggNm', fieldName: 'sggNm', header: { text: '시군구명' }, editable: false },
{ name: 'admdstNm', fieldName: 'admdstNm', header: { text: '행정구역명' }, editable: false },
// 개·보수관로 > 전체보수(m)
{ name: 'mendngWhol', fieldName: 'mendngWhol', header: { text: '계' }, editable: false, valueType: ValueType.NUMBER, dataFormat: '#,##0.0' },
{ name: 'cbsysMendngPrlg', fieldName: 'cbsysMendngPrlg', header: { text: '합류식' }, valueType: ValueType.NUMBER, dataFormat: '#,##0.0' },
// 개·보수관로 > 전체보수(m) > 분류식
{ name: 'sprsysExlnPrlg', fieldName: 'sprsysExlnPrlg', header: { text: '우수' }, valueType: ValueType.NUMBER, dataFormat: '#,##0.0' },
{ name: 'sprsysDrtwPrlg', fieldName: 'sprsysDrtwPrlg', header: { text: '오수' }, valueType: ValueType.NUMBER, dataFormat: '#,##0.0' },
// 개·보수관로 > 부분보수(개소)
{ name: 'partMendngWhol', fieldName: 'partMendngWhol', header: { text: '계' }, editable: false, valueType: ValueType.NUMBER, dataFormat: '#,##0' },
{ name: 'cbsysPartMendngPrlg', fieldName: 'cbsysPartMendngPrlg', header: { text: '합류식' }, valueType: ValueType.NUMBER, dataFormat: '#,##0' },
// 개·보수관로 > 부분보수(개소) > 분류식
{ name: 'sprsysExlnMendngPrlg', fieldName: 'sprsysExlnMendngPrlg', header: { text: '우수' }, valueType: ValueType.NUMBER, dataFormat: '#,##0' },
{ name: 'sprsysDrtwMendngPrlg', fieldName: 'sprsysDrtwMendngPrlg', header: { text: '오수' }, valueType: ValueType.NUMBER, dataFormat: '#,##0' },
// 맨홀(개소)
{ name: 'cbsysMnhlCnt', fieldName: 'cbsysMnhlCnt', header: { text: '합류식맨홀' }, valueType: ValueType.NUMBER, dataFormat: '#,##0' },
// 맨홀(개소) > 분류식
{ name: 'sprsysDrtwMnhlCnt', fieldName: 'sprsysDrtwMnhlCnt', header: { text: '오수맨홀' }, valueType: ValueType.NUMBER, dataFormat: '#,##0' },
{ name: 'sprsysExlnMnhlCnt', fieldName: 'sprsysExlnMnhlCnt', header: { text: '우수맨홀' }, valueType: ValueType.NUMBER, dataFormat: '#,##0' },
// 받이
{ name: 'drtwRcvprpCnt', fieldName: 'drtwRcvprpCnt', header: { text: '오수받이(개소)' }, valueType: ValueType.NUMBER, dataFormat: '#,##0' },
{ name: 'exlnRcvprpCnt', fieldName: 'exlnRcvprpCnt', header: { text: '빗물받이(개소)' }, valueType: ValueType.NUMBER, dataFormat: '#,##0' },
// 토실, 토구
{ name: 'stofcbrCnt', fieldName: 'stofcbrCnt', header: { text: '토실(개소)' }, valueType: ValueType.NUMBER, dataFormat: '#,##0' },
{ name: 'otflCnt', fieldName: 'otflCnt', header: { text: '토구(개소)' }, valueType: ValueType.NUMBER, dataFormat: '#,##0' },
// 수밀검사CCTV조사량(m)
{ name: 'cctvExmnPrlg', fieldName: 'cctvExmnPrlg', header: { text: '수밀검사·CCTV조사량(m)' }, valueType: ValueType.NUMBER, dataFormat: '#,##0.0' },
].map((col) => ({ ...col, editable: false })),
columnLayout: [
{
name: 'group_admin',
header: { text: '행정구역' },
items: ['ctpvNm', 'sggNm', 'admdstNm']
},
{
name: 'group_repair_main',
header: { text: '개·보수관로' },
items: [
{
name: 'group_full_repair',
header: { text: '전체보수(m)' },
items: [
'mendngWhol',
'cbsysMendngPrlg',
{
name: 'group_full_sep',
header: { text: '분류식' },
items: ['sprsysExlnPrlg', 'sprsysDrtwPrlg']
}
]
},
{
name: 'group_part_repair',
header: { text: '부분보수(개소)' },
items: [
'partMendngWhol',
'cbsysPartMendngPrlg',
{
name: 'group_part_sep',
header: { text: '분류식' },
items: ['sprsysExlnMendngPrlg', 'sprsysDrtwMendngPrlg']
}
]
}
]
},
{
name: 'group_manhole',
header: { text: '맨홀(개소)' },
items: [
'cbsysMnhlCnt',
{
name: 'group_mh_sep',
header: { text: '분류식' },
items: ['sprsysDrtwMnhlCnt', 'sprsysExlnMnhlCnt']
}
]
},
{
name: 'group_inlet',
header: { text: '받이' },
items: ['drtwRcvprpCnt', 'exlnRcvprpCnt']
},
{
name: 'group_chamber',
header: { text: '토실,토구' },
items: ['stofcbrCnt', 'otflCnt']
},
'cctvExmnPrlg',
],
rowStartNum: 4,
fixedColumns: 1,
tabKey: 'ST00030001',
tabLabel: '개보수'
};
@@ -0,0 +1,12 @@
import { PipeDredgingStatusTotColumn } from "./PipeDredgingStatusTotColumn"
import { RepairStatusTotColumn } from "./RepairStatusTotColumn"
const sewagePipeMaintainColumn = [
RepairStatusTotColumn,
PipeDredgingStatusTotColumn
];
export const SewagePipeMaintainColumnListTot = sewagePipeMaintainColumn.map((tab) => ({
...tab,
columns: tab.columns.map((col) => ({ ...col, editable: false as const })),
}));
@@ -3,6 +3,8 @@ import { stClient } from "@/shared/api/client";
import type {
SeweragePipeFacilityAdminAreaListRequest,
SeweragePipeFacilityAdminAreaListResponse,
SeweragePipeFactConnectionSaveRequest,
SeweragePipeFactConnectionSaveResponse,
SeweragePipeFacilitiesListRequest,
SeweragePipeFacilitiesListResponse,
SeweragePipeInputConnectionStatusResponse,
@@ -12,9 +14,13 @@ import type {
SeweragePipeInputPipeTypeResponse,
SeweragePipeInputRegionalFactStatusSaveRequest,
SeweragePipeInputRegionalFactStatusSaveResponse,
SeweragePipeInputRegionalFactStatusSubmitRequest,
SeweragePipeInputRegionalFactStatusSubmitResponse,
SeweragePipeInputRegionalResponse,
SeweragePipeRequestParams,
StatisticsInputSewPipeService,
SeweragePipeInputFactInstallStatusSaveRequest,
SeweragePipeInputFactInstallStatusSaveResponse,
} from "../../model/input/statistics-sewage-pipe-input-interface";
export class StatisticsInputSewPipeApiService implements StatisticsInputSewPipeService {
@@ -82,6 +88,14 @@ export class StatisticsInputSewPipeApiService implements StatisticsInputSewPipeS
return response.data;
}
public async saveFactConnection(param: SeweragePipeFactConnectionSaveRequest): Promise<SeweragePipeFactConnectionSaveResponse> {
const response = await stClient.post<SeweragePipeFactConnectionSaveResponse>(
`${ENDPOINT.ST_SERVICE.INPUT}/sewage-pipe/facility-connection-status/popup/save`,
param,
);
return response.data;
}
public async saveRegionalFactStatus(param: SeweragePipeInputRegionalFactStatusSaveRequest): Promise<SeweragePipeInputRegionalFactStatusSaveResponse> {
const response = await stClient.post<SeweragePipeInputRegionalFactStatusSaveResponse>(
`${ENDPOINT.ST_SERVICE.INPUT}/sewage-pipe/regional-facility-status/save`,
@@ -89,6 +103,22 @@ export class StatisticsInputSewPipeApiService implements StatisticsInputSewPipeS
);
return response.data;
}
public async submitRegionalFactStatus(param: SeweragePipeInputRegionalFactStatusSubmitRequest): Promise<SeweragePipeInputRegionalFactStatusSubmitResponse> {
const response = await stClient.post<SeweragePipeInputRegionalFactStatusSubmitResponse>(
`${ENDPOINT.ST_SERVICE.INPUT}/sewage-pipe/regional-facility-status/submit`,
param,
);
return response.data;
}
public async saveFactInstallStatus(param: SeweragePipeInputFactInstallStatusSaveRequest): Promise<SeweragePipeInputFactInstallStatusSaveResponse> {
const response = await stClient.post<SeweragePipeInputFactInstallStatusSaveResponse>(
`${ENDPOINT.ST_SERVICE.INPUT}/sewage-pipe/facility-install-status/save`,
param,
);
return response.data;
}
};
export const statisticsInputSewPipeApiService = new StatisticsInputSewPipeApiService();
@@ -2,7 +2,18 @@ import { ENDPOINT } from "@/shared/config";
import { stClient } from "@/shared/api/client";
import type { SewerageSupplyRateRequest, SewerageSupplyRateResponse } from "../model/statistics-total/statistics-sewage-supply-rate-total-interface";
import type { SewagePipeTotalRequest, SewagePipeTotalResponse } from "../model/statistics-total/statistics-sewage-pipe-total-interface";
import type {
SewagePipeMaintainDredgingTotalResponse,
SewagePipeMaintainTotalRequest,
SewagePipeMaintainRepairTotalResponse,
} from "../model/statistics-total/statistics-sewage-pipe-maintain-total-interface";
import type {
SewagePipeFactInstallStatusResponse,
SewagePipePipeDiamStatusResponse,
SewagePipePipeTypeFacilityStatusResponse,
SewagePipeTotalRequest,
SewagePipeTotalResponse,
} from "../model/statistics-total/statistics-sewage-pipe-total-interface";
import type {
SewerageServiceLevelComplaintResponse,
SewerageServiceLevelRequest,
@@ -41,6 +52,60 @@ export class StatisticsTotalApiService {
);
return response.data;
}
public async getSewagePipeFactInstallStatus(
params: SewagePipeTotalRequest,
): Promise<SewagePipeFactInstallStatusResponse> {
const response = await stClient.get<SewagePipeFactInstallStatusResponse>(
`${ENDPOINT.ST_SERVICE.TOTAL}/sewer-pipe/facility-installation-status`,
{ params },
);
return response.data;
}
/** ST00020003 — 관종·관경별 시설현황(합계) */
public async getSewagePipePipeTypeFacilityStatus(
params: SewagePipeTotalRequest,
): Promise<SewagePipePipeTypeFacilityStatusResponse> {
const response = await stClient.get<SewagePipePipeTypeFacilityStatusResponse>(
`${ENDPOINT.ST_SERVICE.TOTAL}/sewer-pipe/pipe-type-facility-status`,
{ params },
);
return response.data;
}
/** ST00020004 — 관경별 시설현황(합계) */
public async getSewagePipePipeDiamStatus(
params: SewagePipeTotalRequest,
): Promise<SewagePipePipeDiamStatusResponse> {
const response = await stClient.get<SewagePipePipeDiamStatusResponse>(
`${ENDPOINT.ST_SERVICE.TOTAL}/sewer-pipe/pipe-diam-status`,
{ params },
);
return response.data;
}
/** ST00030001 — 하수관로 유지관리 > 개보수(합계) */
public async getSewagePipeMaintainRepairTotal(
params: SewagePipeMaintainTotalRequest,
): Promise<SewagePipeMaintainRepairTotalResponse> {
const response = await stClient.get<SewagePipeMaintainRepairTotalResponse>(
`${ENDPOINT.ST_SERVICE.TOTAL}/sewer-pipe-mtc/repair`,
{ params },
);
return response.data;
}
/** ST00030002 — 하수관로 유지관리 > 관로준설(합계) */
public async getSewagePipeMaintainDredgingTotal(
params: SewagePipeMaintainTotalRequest,
): Promise<SewagePipeMaintainDredgingTotalResponse> {
const response = await stClient.get<SewagePipeMaintainDredgingTotalResponse>(
`${ENDPOINT.ST_SERVICE.TOTAL}/sewer-pipe-mtc/dredging`,
{ params },
);
return response.data;
}
}
export const statisticsTotalStatusApi = new StatisticsTotalApiService();
@@ -22,6 +22,10 @@ export interface SeweragePipeInputStatusInfo {
export interface VerifyResultItem {
vrfcCd: string;
rsnExpln: string;
/** 현재값(문자열로 내려오는 경우가 많음) */
nowVlCn?: string;
/** 비교값 */
compVlCn?: string;
}
/** 필드명 -> 검증 결과 배열 */
@@ -106,6 +110,12 @@ export type SeweragePipeInputRegionalFactStatusSaveItem = Omit<
"verificationResult"
>;
/** Tab2 저장 — saveList */
export type SeweragePipeInputFactInstallStatusSaveItem = Omit<
SeweragePipeInputFactInstallStatusItem,
"verificationResult"
>;
/** 지역별 시설현황 저장 API 요청 */
export interface SeweragePipeInputRegionalFactStatusSaveRequest {
statsYr: string;
@@ -118,7 +128,50 @@ export interface SeweragePipeInputRegionalFactStatusSaveRequest {
}
export interface SeweragePipeInputRegionalFactStatusSaveResponse {
data: string;
data?: string;
message?: string;
status: string;
}
/** 지역별 시설현황 제출 API 요청 (save item 구조 재사용) */
export interface SeweragePipeInputRegionalFactStatusSubmitRequest {
statsYr: string;
ctpvNm: string;
sggNm: string;
sewerBzmnCd: string;
userId: string;
laePicNm: string;
laePicTelNo: string;
laeKbrdrDeptNm: string;
laeKbrdrJbgdNm: string;
laeIdfrNm: string;
laeIdfrTelNo: string;
laeIdfrDeptNm: string;
laeIdfrJbgdNm: string;
submitList: SeweragePipeInputRegionalFactStatusSaveItem[];
}
export interface SeweragePipeInputRegionalFactStatusSubmitResponse {
data: {
finalStatus: string;
nvpassed: boolean;
}
message?: string;
status: string;
}
export interface SeweragePipeInputFactInstallStatusSaveRequest {
statsYr: string;
ctpvNm: string;
sggNm: string;
sewerBzmnCd: string;
userId: string;
superAdmin: string;
saveList: SeweragePipeInputFactInstallStatusSaveItem[];
}
export interface SeweragePipeInputFactInstallStatusSaveResponse {
data?: string;
message?: string;
status: string;
}
@@ -128,7 +181,7 @@ export interface SeweragePipeInputFactInstallStatusItem {
admdstCd: string;
sggNm: string;
admdstNm: string;
prcsFcltSn: number;
fcltSn: string;
swtrmtFcltCd: string;
swtrmtFcltNm: string;
prcsFcltCpct: number;
@@ -425,9 +478,9 @@ export interface SeweragePipeInputManholeResponse {
/** 시설별 현황 관리 - 하수처리장별 연결 현황 목록 항목 */
export interface SeweragePipeInputFacilityConnectionItem {
bscFctNm: string;
fctCpc: number;
bscFctCd: string;
fcltNm: string;
fcltCpct: number;
fcltSn: string;
isConnection: string;
connectionAreaList: string;
}
@@ -472,9 +525,10 @@ export interface SeweragePipeFacilitiesListItem {
ctpvNm: string;
sggNm: string;
admdstCd: string;
bscFctNm: string;
fctCpc: number;
bscFctCd: string;
fcltNm: string;
fcltCpct: number;
fcltCd: string;
fcltSn: string;
}
/** facilitiesList API 응답 */
@@ -490,7 +544,7 @@ export interface SeweragePipeFacilityAdminAreaListRequest {
swtrmtFcltCd: string;
sewerBzmnCd: string;
ctpvNm: string;
sggNm: string;
sggNm?: string;
}
/** 처리시설별 행정구역 목록 항목 */
@@ -509,6 +563,34 @@ export interface SeweragePipeFacilityAdminAreaListResponse {
message?: string;
}
/** 시설별 연결관계 저장 — saveList 항목 */
export interface SeweragePipeFactConnectionSaveItem {
admdstCd: string;
admdstNm: string;
}
/** 시설별 연결관계 저장 요청 */
export interface SeweragePipeFactConnectionSaveRequest {
ctpvNm: string;
sggNm: string;
fcltSn: string;
statsYr: string;
swtrmtFcltCd: string;
swtrmtFcltNm: string;
prcsFcltCpct: number;
sewerBzmnCd: string;
userId: string;
saveList: SeweragePipeFactConnectionSaveItem[];
deleteList: string[];
}
/** 시설별 연결관계 저장 응답 */
export interface SeweragePipeFactConnectionSaveResponse {
status: string;
data?: string;
message?: string;
}
export interface StatisticsInputSewPipeService {
getRegionalFactStatus(param: SeweragePipeRequestParams): Promise<SeweragePipeInputRegionalResponse>;
getFactInstallStatus(param: SeweragePipeRequestParams): Promise<SeweragePipeInputFactInstallResponse>;
@@ -518,7 +600,8 @@ export interface StatisticsInputSewPipeService {
getConnectionStatus(param: SeweragePipeRequestParams): Promise<SeweragePipeInputConnectionStatusResponse>;
getFacilitiesList(param: SeweragePipeFacilitiesListRequest): Promise<SeweragePipeFacilitiesListResponse>;
getFacilityAdminAreaList(param: SeweragePipeFacilityAdminAreaListRequest): Promise<SeweragePipeFacilityAdminAreaListResponse>;
saveRegionalFactStatus(
param: SeweragePipeInputRegionalFactStatusSaveRequest,
): Promise<SeweragePipeInputRegionalFactStatusSaveResponse>;
saveFactConnection(param: SeweragePipeFactConnectionSaveRequest): Promise<SeweragePipeFactConnectionSaveResponse>;
saveRegionalFactStatus(param: SeweragePipeInputRegionalFactStatusSaveRequest): Promise<SeweragePipeInputRegionalFactStatusSaveResponse>;
submitRegionalFactStatus(param: SeweragePipeInputRegionalFactStatusSubmitRequest): Promise<SeweragePipeInputRegionalFactStatusSubmitResponse>;
saveFactInstallStatus(param: SeweragePipeInputFactInstallStatusSaveRequest): Promise<SeweragePipeInputFactInstallStatusSaveResponse>;
}
@@ -167,11 +167,61 @@ export interface SewerageSupplyRateSubmitResponse {
status: string;
}
// 하수도 통계 입력 기간 조회
// 하수도 통계 입력 기간 조회 (입력 INPT / 검증 VRFC 구간)
export interface CheckInputPeriodItem {
statsYr: string;
seCd: number;
seType: string;
bgngYmd: string;
endYmd: string;
current: boolean;
}
export interface CheckInputPeriodResponse {
status: string;
message: string;
data: boolean;
data: CheckInputPeriodItem[];
}
/** 입력기간(INPT) 중 현재 구간이면 true */
export function isInputPeriodCurrent(periods: CheckInputPeriodItem[] | null | undefined): boolean {
return (periods ?? []).some((p) => p.seType === "INPT" && p.current === true);
}
/** `YYYYMMDD` → `yyyy-mm-dd` (그 외 형식은 그대로 반환) */
export function formatCheckInputYmdAsDash(ymd: string | null | undefined): string {
if (ymd == null || ymd === "") return "";
if (ymd.length === 8 && /^\d{8}$/.test(ymd)) {
return `${ymd.slice(0, 4)}-${ymd.slice(4, 6)}-${ymd.slice(6, 8)}`;
}
return ymd;
}
/** 검증기간(VRFC) 중 `current`인 항목 (없으면 null) */
export function getCurrentVerificationPeriod(
periods: CheckInputPeriodItem[] | null | undefined,
): CheckInputPeriodItem | null {
return (periods ?? []).find((p) => p.seType === "VRFC" && p.current === true) ?? null;
}
/** 검증기간(VRFC) 중 현재 구간이면 true — 저장·제출·제출 취소 불가 */
export function isVerificationPeriodCurrent(periods: CheckInputPeriodItem[] | null | undefined): boolean {
return getCurrentVerificationPeriod(periods) !== null;
}
/**
* 검증기간이면 차단 안내 문구(줄바꿈 + yyyy-mm-dd ~ yyyy-mm-dd), 아니면 null
*/
export function getVerificationPeriodActionBlockedMessage(
periods: CheckInputPeriodItem[] | null | undefined,
): string | null {
const v = getCurrentVerificationPeriod(periods);
if (!v) return null;
const from = formatCheckInputYmdAsDash(v.bgngYmd);
const to = formatCheckInputYmdAsDash(v.endYmd);
const range =
from && to ? `${from} ~ ${to}` : [from, to].filter(Boolean).join(" ~ ");
return `검증기간에는 저장, 제출, 제출 취소를 할 수 없습니다.\n검증기간: ${range}`;
}
export interface StatisticsInputSewSupplyRateService {
@@ -0,0 +1,77 @@
export interface SewagePipeMaintainTotalRequest {
ctpvNm?: string;
sggNm?: string;
statsYr?: string;
sewerBzmnCd?: string;
rowDataYn?: boolean;
}
export interface SewagePipeMaintainRepairTotalItem {
ctpvNm: string;
sggNm: string | null;
statsYr: string;
rowType: string;
sewerBzmnCd: string | null;
admdstNm: string;
mendngWhol: number;
cbsysMendngPrlg: number;
sprsysDrtwPrlg: number;
sprsysExlnPrlg: number;
partMendngWhol: number;
cbsysPartMendngPrlg: number;
sprsysDrtwMendngPrlg: number;
sprsysExlnMendngPrlg: number;
cbsysMnhlCnt: number;
sprsysDrtwMnhlCnt: number;
sprsysExlnMnhlCnt: number;
drtwRcvprpCnt: number;
exlnRcvprpCnt: number;
stofcbrCnt: number;
otflCnt: number;
cctvExmnPrlg: number;
rmrk: string | null;
}
export interface SewagePipeMaintainRepairTotalResponse {
status: string;
message?: string;
data: SewagePipeMaintainRepairTotalItem[];
}
/** `GET .../sewer-pipe-mtc/dredging` 목록 항목 */
export interface SewagePipeMaintainDredgingTotalItem {
ctpvNm: string;
sggNm: string | null;
admdstNm: string;
admdstCd: string;
statsYr: string;
rowType: string;
sewerBzmnCd: string | null;
wholFcltPrlg: number;
wholSwagePpl: number;
cbsysPplPrlg: number;
sprsysDrtwPrlg: number;
sprsysExlnPrlg: number;
mnhlCnt: number;
exlnDrtwRcvprpCnt: number;
stofcbrOtflCnt: number;
drdgnPlanPrlg: number;
drdgnTrgtCtrsDrdgnRt: number;
wholPrlgCtrsDrdgnRt: number;
drdgnAmt: number;
drdgnQnt: number;
drdgnUntprcAmt: number;
wholDrdgnPrcsQnt: number;
rclmQnt: number;
oceanEmsnQnt: number;
rcycQnt: number;
etcQnt: number;
rmrk: string | null;
}
/** `GET .../sewer-pipe-mtc/dredging` 응답 */
export interface SewagePipeMaintainDredgingTotalResponse {
status: string;
message?: string;
data: SewagePipeMaintainDredgingTotalItem[];
}
@@ -57,3 +57,180 @@ export interface SewagePipeTotalResponse {
message: string;
data: SewagePipeTotalItem[];
}
export interface SewagePipeFactInstallStatusItem {
statsYr: string;
ctpvNm: string;
sggNm: string | null;
admdstCd: string;
admdstNm: string;
swtrmtFcltNm: string;
prcsFcltCpct: number | null;
rowType: string;
prcsZoneArea: number;
cbsysPrcsZoneArea: number;
sprsysPrcsZoneArea: number;
sprsysVartnRt: number;
mltvEqpmCnt: number;
cbsysMltvEqpmCnt: number;
sprsysMltvEqpmCnt: number;
sprsysMltvLnkgCnt: number;
sprsysMltvYetLnkgCnt: number;
planPrcsZoneArea: number;
cbsysPlanPrcsZoneArea: number;
sprsysPlanPrcsZoneArea: number;
wholFcltPrlg: number;
wholIcptgAtlfcPrlg: number | null;
icptgPrlg: number;
atlfcsPrlg: number;
cbsysStofcbrCnt: number;
sprsysStofcbrCnt: number;
sprsysExlnOtflCnt: number;
wholBrchlnPrlg: number;
cbsysBrchlnPrlg: number;
exlnBrchlnPrlg: number;
drtwBrchlnPrlg: number;
}
export interface SewagePipeFactInstallStatusResponse {
status: string;
message?: string;
data: SewagePipeFactInstallStatusItem[];
}
/** ST00020003 합계 — `GET .../sewer-pipe/pipe-type-facility-status` */
export interface SewagePipePipeTypeFacilityStatusItem {
pipeByPrstWhol: number;
lyngPrlg: number;
dmltnPrlg: number;
yy1PrlgWhol: number;
yy1LyngPrlg: number;
yy1DmltnPrlg: number;
yy2PrlgWhol: number;
yy2LyngPrlg: number;
yy2DmltnPrlg: number;
yy3PrlgWhol: number;
yy3LyngPrlg: number;
yy3DmltnPrlg: number;
yy4PrlgWhol: number;
yy4LyngPrlg: number;
yy4DmltnPrlg: number;
yy5PrlgWhol: number;
yy5LyngPrlg: number;
yy5DmltnPrlg: number;
yy6PrlgWhol: number;
yy6LyngPrlg: number;
yy6DmltnPrlg: number;
yy7PrlgWhol: number;
yy7LyngPrlg: number;
yy7DmltnPrlg: number;
yy8PrlgWhol: number;
yy8LyngPrlg: number;
yy8DmltnPrlg: number;
yy9PrlgWhol: number;
yy9LyngPrlg: number;
yy9DmltnPrlg: number;
yy10PrlgWhol: number;
yy10LyngPrlg: number;
yy10DmltnPrlg: number;
yy11PrlgWhol: number;
yy11LyngPrlg: number;
yy11DmltnPrlg: number;
yy12PrlgWhol: number;
yy12LyngPrlg: number;
yy12DmltnPrlg: number;
yy13PrlgWhol: number;
yy13LyngPrlg: number;
yy13DmltnPrlg: number;
yy14PrlgWhol: number;
yy14LyngPrlg: number;
yy14DmltnPrlg: number;
yy15PrlgWhol: number;
yy15LyngPrlg: number;
yy15DmltnPrlg: number;
yy16PrlgWhol: number;
yy16LyngPrlg: number;
yy16DmltnPrlg: number;
yy17PrlgWhol: number;
yy17LyngPrlg: number;
yy17DmltnPrlg: number;
yy18PrlgWhol: number;
yy18LyngPrlg: number;
yy18DmltnPrlg: number;
yy19PrlgWhol: number;
yy19LyngPrlg: number;
yy19DmltnPrlg: number;
yy20PrlgWhol: number;
yy20LyngPrlg: number;
yy20DmltnPrlg: number;
yy21PrlgWhol: number;
yy21LyngPrlg: number;
yy21DmltnPrlg: number;
yy22PrlgWhol: number;
yy22LyngPrlg: number;
yy22DmltnPrlg: number;
yy23PrlgWhol: number;
yy23LyngPrlg: number;
yy23DmltnPrlg: number;
yy24PrlgWhol: number;
yy24LyngPrlg: number;
yy24DmltnPrlg: number;
yy25PrlgWhol: number;
yy25LyngPrlg: number;
yy25DmltnPrlg: number;
yy26PrlgWhol: number;
yy26LyngPrlg: number;
yy26DmltnPrlg: number;
yy27PrlgWhol: number;
yy27LyngPrlg: number;
yy27DmltnPrlg: number;
yy28PrlgWhol: number;
yy28LyngPrlg: number;
yy28DmltnPrlg: number;
yy29PrlgWhol: number;
yy29LyngPrlg: number;
yy29DmltnPrlg: number;
yy30PrlgWhol: number;
yy30LyngPrlg: number;
yy30DmltnPrlg: number;
ctpvNm: string;
sggNm: string | null;
admdstCd: string;
admdstNm: string;
swrgPipeNm: string;
statsYr: string;
rowType: string;
rmrk: string | null;
}
export interface SewagePipePipeTypeFacilityStatusResponse {
status: string;
message?: string;
data: SewagePipePipeTypeFacilityStatusItem[];
}
/** ST00020004 합계 — `GET .../sewer-pipe/pipe-diam-status` */
export interface SewagePipePipeDiamStatusItem {
ctpvNm: string;
sggNm: string | null;
admdstCd: string;
admdstNm: string;
statsYr: string;
swrgPipeNm: string;
rowType: string;
pdmtByTsmWhol: number;
step1PdmtPrlg: number;
step2PdmtPrlg: number;
step3PdmtPrlg: number;
step4PdmtPrlg: number;
step5PdmtPrlg: number;
sqrePdmtPrlg: number;
etcPdmtPrlg: number;
rmrk: string | null;
}
export interface SewagePipePipeDiamStatusResponse {
status: string;
message?: string;
data: SewagePipePipeDiamStatusItem[];
}
@@ -4,6 +4,8 @@ export interface SewerageSupplyRateRequest {
statsYr?: string;
sewerBzmnCd?: string;
rowDataYn?: boolean;
summaryYn?: boolean;
summaryYearCnt?: number;
}
export interface SewerageSupplyRateTotalItem {
@@ -6,7 +6,7 @@ import { ScheduleCalendarPanel } from "../ui/panels/schedule-calendar-panel";
import { ScheduleInfoPanel } from "../ui/panels/schedule-info-panel";
import { StatsSubmissionPanel } from "../ui/panels/stats-submission-panel";
import { UnSubmitPanel } from "../ui/panels/unsubmit-panel";
import { VrfcErrPanel } from "../ui/panels/vrfc-err-panel";
// import { VrfcErrPanel } from "../ui/panels/vrfc-err-panel";
import { VrfcReportPanel } from "../ui/panels/vrfc-report-panel";
import type { PanelItem } from "./types";
@@ -36,23 +36,23 @@ export function getInitialPanels({
component: <NoticePanel />,
className: "",
},
// {
// id: "panel-d",
// component: <VrfcErrPanel params={dashboardParams} />,
// className: "",
// },
{
id: "panel-d",
component: <VrfcErrPanel params={dashboardParams} />,
className: "",
},
{
id: "panel-e",
component: <ScheduleCalendarPanel />,
className: "",
},
{
id: "panel-f",
id: "panel-e",
component: <VrfcReportPanel params={dashboardParams} />,
className: "",
},
{
id: "panel-g",
id: "panel-f",
component: <UnSubmitPanel params={dashboardParams} />,
className: "",
},
@@ -17,7 +17,7 @@ export function NoticePanel() {
const recentPostListQuery = useRecentPostList({
page: 0,
size: 3,
size: 4,
keywordType: "bbsTypeCd",
keyword: "BBSNOTICE",
});
@@ -45,14 +45,18 @@ export function NoticePanel() {
});
return (
<div className="card card-border !m-0 h-full">
<div className="card card-border !m-0 h-full border-border bg-background text-foreground">
<div
className={`card-header cursor-grab active:cursor-grabbing ${isDragging ? "opacity-50" : ""}`}
className={`card-header cursor-grab border-border active:cursor-grabbing ${isDragging ? "opacity-50" : ""}`}
{...dragHandleProps}
title="헤더를 드래그하여 패널 순서 변경"
>
<h3 className="text-lg font-semibold">공지사항</h3>
<Button onClick={handleMoreClick} className="h-8">
<h3 className="text-2xl font-semibold">공지사항</h3>
<Button
size="sm"
className="!text-xl"
onClick={handleMoreClick}
>
더보기
</Button>
</div>
@@ -67,17 +71,16 @@ const noticeColumns = [
columnHelper.accessor("pstTtl", {
header: "제목",
cell: ({ row }) => (
<div className="py-2">
<div className="truncate font-medium">
{row.original.pstTtl}
</div>
<div className="mt-1 flex justify-between text-sm text-muted-foreground">
<span>{row.original.wrtrNm}</span>
<span>{formatDate(row.original.regDt)}</span>
</div>
</div>
<div className="min-w-[20rem] truncate">{row.original.pstTtl}</div>
),
}),
columnHelper.accessor("wrtrNm", {
header: "작성자",
}),
columnHelper.accessor("regDt", {
header: "등록일",
cell: ({ row }) => formatDate(row.original.regDt),
}),
];
function formatDate(value: string) {
@@ -8,6 +8,7 @@ import dayjs from "dayjs";
import { SITE_ID } from "@/shared/config";
import { useSchedules } from "@/entities/schedule";
import { LoadingWithAnimation } from "@common/components";
import { useDragHandle } from "../sortable-panel";
@@ -31,21 +32,24 @@ export function ScheduleCalendarPanel() {
});
return (
<div className="card card-border !m-0 h-full">
<div className="card card-border !m-0 h-full border-border bg-background text-foreground">
<div
className={`card-header cursor-grab active:cursor-grabbing ${isDragging ? "opacity-50" : ""}`}
className={`card-header cursor-grab border-border active:cursor-grabbing ${isDragging ? "opacity-50" : ""}`}
{...dragHandleProps}
title="헤더를 드래그하여 패널 순서 변경"
>
<h3 className="text-lg font-semibold">일정관리</h3>
<h3 className="text-2xl font-semibold">일정관리</h3>
</div>
<div className="card-body">
<div className="card-body max-h-[calc(100%-3.25rem)] py-2.5">
{isLoading && (
<div className="py-8 text-center">로딩 중...</div>
<div className="flex h-full items-center justify-center">
<div className="relative size-[5rem]">
<LoadingWithAnimation color="primary" />
</div>
</div>
)}
{data && (
<div className="h-[400px]">
{/* TBD: FullCalendar 상세 설정 (locale, plugins, 이벤트 색상 등) */}
<div className="h-[48rem]">
<FullCalendar
plugins={[dayGridPlugin, timeGridPlugin]}
initialView="dayGridMonth"
@@ -67,7 +71,6 @@ export function ScheduleCalendarPanel() {
// TBD: allDay 판단 기준 (1439분 이상?)
allDay: true,
}))}
// TBD: 이벤트 클릭 핸들러 (현재 SPEC에서 제거됨)
height="100%"
/>
</div>
@@ -1,7 +1,14 @@
"use client";
import dayjs from "dayjs";
import type { ScheduleInfoSearchParams } from "@/entities/statistics";
import { useScheduleInfo } from "@/entities/statistics";
import {
Badge,
type BadgeProps,
LoadingWithAnimation,
} from "@common/components";
import { useDragHandle } from "../sortable-panel";
@@ -9,63 +16,121 @@ interface ScheduleInfoPanelProps {
params: ScheduleInfoSearchParams | null;
}
const CURRENT_STATUS_LABEL_MAP = {
INPT: "입력중",
VRFC: "검증중",
NONE: "기간외",
};
const CURRENT_STATUS_COLOR_MAP = {
INPT: "",
VRFC: "gray",
NONE: "lightGray",
};
export function ScheduleInfoPanel({ params }: ScheduleInfoPanelProps) {
const { dragHandleProps, isDragging } = useDragHandle();
const { data, isLoading, error } = useScheduleInfo(params);
return (
<div className="card card-border !m-0 h-full">
<div className="card card-border !m-0 h-full border-border bg-background text-foreground">
<div
className={`card-header cursor-grab active:cursor-grabbing ${isDragging ? "opacity-50" : ""}`}
className={`card-header cursor-grab border-border active:cursor-grabbing ${isDragging ? "opacity-50" : ""}`}
{...dragHandleProps}
title="헤더를 드래그하여 패널 순서 변경"
>
<h3 className="text-lg font-semibold">일정안내</h3>
<h3 className="text-2xl font-semibold">일정안내</h3>
{data && (
<div className="flex items-center gap-2">
<span className="text-xl tracking-tight">
{data.ddayLabel}
</span>
<Badge
size="lg"
color="orange"
className="rounded-full !px-3 text-xl font-semibold"
>
{data.dday}
</Badge>
</div>
)}
</div>
<div className="card-body">
<div className="card-body max-h-[calc(100%-3.25rem)] py-2.5">
{isLoading && (
<div className="py-8 text-center">로딩 중...</div>
<div className="flex h-full items-center justify-center">
<div className="relative size-[5rem]">
<LoadingWithAnimation color="primary" />
</div>
</div>
)}
{error && (
<div className="py-8 text-center text-red-500">
<div className="flex h-full items-center justify-center">
데이터를 불러올 수 없습니다.
</div>
)}
{data && (
<div className="space-y-4">
{/* D-day 표시 */}
<div className="rounded-lg bg-blue-50 p-4 text-center">
<div className="mb-1 text-sm text-muted-foreground">
{data.ddayLabel}
</div>
<div className="text-3xl font-bold text-blue-600">
{data.dday}
</div>
<div className="mt-1 text-sm text-muted-foreground">
{data.currentSe}
</div>
</div>
{/* TBD: 일정 목록 표시 형식 정의 필요 */}
<div className="space-y-2">
<div className="rounded border p-3">
<div className="mb-1 text-sm font-medium">
입력기간
</div>
<div className="text-sm text-muted-foreground">
{data.inptBgngYmd} ~ {data.inptEndYmd}
<>
<div className="space-y-4">
<div className="mt-2 flex items-center justify-between">
<h4 className="px-2 text-2xl font-semibold tracking-tight text-label">
이번 주 주요 일정
</h4>
<div className="flex items-center gap-2">
<span className="tracking-tight text-label">
현재 진행 상태
</span>
<Badge
color={
CURRENT_STATUS_COLOR_MAP[
data.currentSe as keyof typeof CURRENT_STATUS_COLOR_MAP
] as BadgeProps["color"]
}
className="rounded-full !px-3"
>
{
CURRENT_STATUS_LABEL_MAP[
data.currentSe as keyof typeof CURRENT_STATUS_LABEL_MAP
]
}
</Badge>
</div>
</div>
<div className="rounded border p-3">
<div className="mb-1 text-sm font-medium">
검증기간
<div className="space-y-2">
<div className="flex items-center justify-between rounded-sm border border-border p-3">
<Badge size="lg" className="font-semibold">
{data.inptSeCd}차 입력기간
</Badge>
<p className="text-2xl">
{dayjs(data.inptBgngYmd).format(
"YYYY.MM.DD",
)}{" "}
~{" "}
{dayjs(data.inptEndYmd).format(
"YYYY.MM.DD",
)}
</p>
</div>
<div className="text-sm text-muted-foreground">
{data.vrfcBgngYmd} ~ {data.vrfcEndYmd}
<div className="flex items-center justify-between rounded-sm border border-border p-3">
<Badge
size="lg"
color="gray"
className="font-semibold"
>
{data.vrfcSeCd}차 검증기간
</Badge>
<p className="text-2xl">
{dayjs(data.vrfcBgngYmd).format(
"YYYY.MM.DD",
)}{" "}
~{" "}
{dayjs(data.vrfcEndYmd).format(
"YYYY.MM.DD",
)}
</p>
</div>
</div>
</div>
</div>
</>
)}
</div>
</div>
@@ -1,8 +1,14 @@
"use client";
import { useDashboardStatsSubmissionRate } from "@/entities/statistics";
import { Frown, Laugh, Meh, OctagonAlert, OctagonX, Smile } from "lucide-react";
import {
useDashboardEcptReqCount,
useDashboardStatsSubmissionRate,
useDashboardVrfcErrCount,
} from "@/entities/statistics";
import type { DashboardSearchParams } from "@/entities/statistics/model/dashboard/dashboard-interface";
import { LoadingWithAnimation } from "@common/components";
import { LoadingWithAnimation, Progress } from "@common/components";
import { useDragHandle } from "../sortable-panel";
@@ -10,70 +16,147 @@ interface StatsSubmissionPanelProps {
params: DashboardSearchParams | null;
}
const STATUS_LEVEL_TEXT_COLOR = {
GREAT: "text-blue-500",
GOOD: "text-green-500",
WARNING: "text-yellow-500",
BAD: "text-red-500",
};
const STATUS_LEVEL_EMOJI = {
GREAT: <Laugh size={160} className={STATUS_LEVEL_TEXT_COLOR.GREAT} />,
GOOD: <Smile size={160} className={STATUS_LEVEL_TEXT_COLOR.GOOD} />,
WARNING: <Meh size={160} className={STATUS_LEVEL_TEXT_COLOR.WARNING} />,
BAD: <Frown size={160} className={STATUS_LEVEL_TEXT_COLOR.BAD} />,
};
export function StatsSubmissionPanel({ params }: StatsSubmissionPanelProps) {
const { dragHandleProps, isDragging } = useDragHandle();
const { data, isLoading, error } = useDashboardStatsSubmissionRate(params);
const vrfcErrQuery = useDashboardVrfcErrCount(params);
const ecptReqQuery = useDashboardEcptReqCount(params);
return (
<div className="card card-border !m-0 h-full">
<div
className={`card-header cursor-grab active:cursor-grabbing ${isDragging ? "opacity-50" : ""}`}
<div className="card card-border !m-0 h-full border-border bg-background text-foreground">
{/* <div
className={`card-header cursor-grab border-border active:cursor-grabbing ${isDragging ? "opacity-50" : ""}`}
{...dragHandleProps}
title="헤더를 드래그하여 패널 순서 변경"
>
<h3 className="text-xl font-semibold">통계입력현황</h3>
</div>
<div className="card-body h-[calc(100%-3.25rem)] py-2.5">
{isLoading && (
<div className="flex h-full items-center justify-center">
<div className="relative size-[5rem]">
<LoadingWithAnimation color="primary" />
</div>
</div>
)}
{error && (
<div className="flex h-full items-center justify-center py-8 text-center">
데이터를 불러올 수 없습니다.
</div>
)}
{data && (
<div className="space-y-4">
{/* TBD: statusLevel별 이모지 매핑 규칙 필요 */}
<div className="text-center">
<div className="mb-2 text-4xl">
{/* TBD: statusLevel 값에 따른 이모지 표시 */}
{data.statusLevel === "GOOD"
? "😊"
: data.statusLevel === "WARNING"
? "⚠️"
: "📊"}
<h3 className="text-2xl font-semibold">통계입력현황</h3>
</div> */}
<div
className={`card-body cursor-grab py-2 active:cursor-grabbing ${isDragging ? "opacity-50" : ""}`}
{...dragHandleProps}
>
<div className="flex h-full gap-6 px-2">
<div className="h-full flex-1 flex-col">
<h3 className="text-2xl font-semibold">통계입력현황</h3>
{isLoading && (
<div className="flex h-full items-center justify-center">
<div className="relative size-[5rem]">
<LoadingWithAnimation color="primary" />
</div>
</div>
<div className="text-sm text-muted-foreground">
{data.statusLevel}
)}
{error && (
<div className="flex h-full items-center justify-center py-8 text-center">
데이터를 불러올 수 없습니다.
</div>
</div>
)}
<div className="h-full">
{data && (
<div className="flex h-full flex-col items-center gap-4">
{/* 통계제출률 상태 이모지 */}
<div className="flex justify-center">
{
STATUS_LEVEL_EMOJI[
data.statusLevel as keyof typeof STATUS_LEVEL_EMOJI
]
}
</div>
{/* 통계제출률 프로그레스바 */}
<div>
<div className="mb-2 flex justify-between text-sm">
<span>통계제출률</span>
<span className="font-semibold">
{data.submissionRate?.toFixed(1)}%
</span>
{/* 통계제출률 프로그레스바 */}
<div className="w-full space-y-3">
<div className="flex w-full justify-between">
<span className="text-2xl">
통계제출률
</span>
<span className="text-2xl font-semibold">
{data.submissionRate?.toFixed(
1,
)}
%
<span className="ml-1">
({data.submittedCount}/
{data.totalCount})
</span>
</span>
</div>
<Progress
value={data.submissionRate}
className="h-6 bg-muted"
/>
</div>
</div>
)}
</div>
</div>
<div className="h-full flex-[0.5]">
<h3 className="text-2xl font-semibold">
검증오류 및 예외
</h3>
<div className="mt-4 space-y-4">
{/* 검증오류 수 카드 */}
<div className="flex flex-col items-center justify-center gap-2 rounded-lg border border-red-200 bg-red-100 p-4 text-center">
<div className="flex flex-col items-center gap-2">
<OctagonX
className="text-red-600"
size={20}
/>
<span className="text-lg">검증오류 수</span>
</div>
{vrfcErrQuery.isLoading && (
<div className="text-2xl font-bold">-</div>
)}
{vrfcErrQuery.error && (
<div className="text-sm">
데이터를 불러올 수 없습니다.
</div>
)}
{vrfcErrQuery.data && (
<div className="text-5xl font-bold text-red-600">
{vrfcErrQuery.data.vrfcErrCount.toLocaleString()}
</div>
)}
</div>
<div className="h-2.5 w-full rounded-full bg-gray-200">
<div
className="h-2.5 rounded-full bg-blue-600 transition-all"
style={{ width: `${data.submissionRate}%` }}
/>
</div>
<div className="mt-1 flex justify-between text-xs text-muted-foreground">
<span>제출: {data.submittedCount}</span>
<span>전체: {data.totalCount}</span>
{/* 예외신청 수 카드 */}
<div className="flex flex-col items-center justify-center gap-2 rounded-lg border border-orange-200 bg-orange-100 p-4 text-center">
<div className="flex flex-col items-center gap-2">
<OctagonAlert
className="text-orange-600"
size={20}
/>
<span className="text-lg">예외신청 수</span>
</div>
{ecptReqQuery.isLoading && (
<div className="text-2xl font-bold">-</div>
)}
{ecptReqQuery.error && (
<div className="text-sm">
데이터를 불러올 수 없습니다.
</div>
)}
{ecptReqQuery.data && (
<div className="text-5xl font-bold text-orange-600">
{ecptReqQuery.data.ecptReqCount.toLocaleString()}
</div>
)}
</div>
</div>
</div>
)}
</div>
</div>
</div>
);
@@ -15,23 +15,20 @@ export function UnSubmitPanel({ params }: UnSubmitPanelProps) {
const { data, isLoading, error } = useDashboardUnSubmitCount(params);
return (
<div className="card card-border !m-0 h-full">
<div className="card card-border !m-0 h-full border-border bg-background text-foreground">
<div
className={`card-header cursor-grab active:cursor-grabbing ${isDragging ? "opacity-50" : ""}`}
className={`card-header cursor-grab border-border active:cursor-grabbing ${isDragging ? "opacity-50" : ""}`}
{...dragHandleProps}
title="헤더를 드래그하여 패널 순서 변경"
>
<h3 className="text-xl font-semibold">미제출 지표</h3>
<h3 className="text-2xl font-semibold">미제출 지표</h3>
{data && (
<Badge
color="red"
className="text-md rounded-full px-2 py-1 text-destructive-foreground"
>
<Badge size="lg" className="rounded-full !px-3">
{data.unSubmitCount}건
</Badge>
)}
</div>
<div className="card-body h-[calc(100%-3.25rem)] py-2.5">
<div className="card-body max-h-[calc(100%-3.25rem)] py-2.5">
{isLoading && (
<div className="flex h-full items-center justify-center">
<div className="relative size-[5rem]">
@@ -51,11 +48,11 @@ export function UnSubmitPanel({ params }: UnSubmitPanelProps) {
미제출 지표가 없습니다.
</div>
) : (
<div className="max-h-[32rem] space-y-2 overflow-y-auto">
<div className="max-h-[48rem] space-y-2 overflow-y-auto">
{data.unSubmitList.map((item) => (
<div
key={item.statsCd}
className="rounded-sm border p-3"
className="rounded-sm border border-border p-3"
>
<p className="font-medium">
{item.statsNm}
@@ -20,15 +20,15 @@ export function VrfcErrPanel({ params }: VrfcErrPanelProps) {
const ecptReqQuery = useDashboardEcptReqCount(params);
return (
<div className="card card-border !m-0 h-full">
<div className="card card-border !m-0 h-full border-border bg-background text-foreground">
<div
className={`card-header cursor-grab active:cursor-grabbing ${isDragging ? "opacity-50" : ""}`}
className={`card-header cursor-grab border-border active:cursor-grabbing ${isDragging ? "opacity-50" : ""}`}
{...dragHandleProps}
title="헤더를 드래그하여 패널 순서 변경"
>
<h3 className="text-xl font-semibold">검증오류 및 예외</h3>
<h3 className="text-2xl font-semibold">검증오류 및 예외</h3>
</div>
<div className="card-body h-[calc(100%-3.25rem)] py-2.5">
<div className="card-body max-h-[calc(100%-3.25rem)] py-2.5">
<div className="grid h-full grid-cols-2 gap-4">
{/* 검증오류 수 카드 */}
<div className="flex flex-col items-center justify-center gap-2 rounded-lg border border-red-200 bg-red-100 p-4 text-center">
@@ -2,6 +2,11 @@
import { useDashboardVrfcReport } from "@/entities/statistics";
import type { DashboardSearchParams } from "@/entities/statistics/model/dashboard/dashboard-interface";
import {
Badge,
type BadgeProps,
LoadingWithAnimation,
} from "@common/components";
import { useDragHandle } from "../sortable-panel";
@@ -9,79 +14,107 @@ interface VrfcReportPanelProps {
params: DashboardSearchParams | null;
}
const TYPE_CODE_NAME_MAP: Record<string, string> = {
NV: "일반",
IMR: "I-MR",
};
const TYPE_CODE_COLOR_MAP: Record<string, BadgeProps["color"]> = {
NV: "gray",
IMR: "red",
};
export function VrfcReportPanel({ params }: VrfcReportPanelProps) {
const { dragHandleProps, isDragging } = useDragHandle();
const { data, isLoading, error } = useDashboardVrfcReport(params);
return (
<div className="card card-border !m-0 h-full">
<div className="card card-border !m-0 h-full border-border bg-background text-foreground">
<div
className={`card-header cursor-grab active:cursor-grabbing ${isDragging ? "opacity-50" : ""}`}
className={`card-header cursor-grab border-border active:cursor-grabbing ${isDragging ? "opacity-50" : ""}`}
{...dragHandleProps}
title="헤더를 드래그하여 패널 순서 변경"
>
<h3 className="text-lg font-semibold">이상치 선별보고서</h3>
<h3 className="text-2xl font-semibold">이상치 선별보고서</h3>
</div>
<div className="card-body">
<div className="card-body max-h-[calc(100%-3.25rem)] py-2.5">
{isLoading && (
<div className="py-8 text-center">로딩 중...</div>
<div className="flex h-full items-center justify-center">
<div className="relative size-[5rem]">
<LoadingWithAnimation color="primary" />
</div>
</div>
)}
{error && (
<div className="py-8 text-center text-red-500">
<div className="flex h-full items-center justify-center">
데이터를 불러올 수 없습니다.
</div>
)}
{data && (
<div className="space-y-4">
<div className="space-y-2">
{/* TBD: typeCounts 표시 방법 (탭? 필터?) */}
{data.typeCounts &&
Object.keys(data.typeCounts).length > 0 && (
<div className="flex flex-wrap gap-2">
{Object.entries(data.typeCounts).map(
([type, count]) => (
<span
<Badge
key={type}
className="rounded-full bg-gray-100 px-3 py-1 text-sm"
color={
TYPE_CODE_COLOR_MAP[type]
}
className="rounded-full px-2 py-1 text-lg"
>
{type}: {count}
</span>
{TYPE_CODE_NAME_MAP[type]}:{" "}
{count}
</Badge>
),
)}
</div>
)}
{/* TBD: 테이블 컬럼 정의, 증감 표시 방법 */}
<div className="max-h-96 overflow-y-auto">
<div className="max-h-[44rem] overflow-y-auto">
{data.list.length === 0 ? (
<div className="py-8 text-center text-muted-foreground">
<div className="flex h-full items-center justify-center">
이상치 항목이 없습니다.
</div>
) : (
<div className="space-y-2">
<div className="space-y-2 tracking-tight">
{data.list.map((item, index) => (
<div
key={index}
className="cursor-pointer rounded border p-3 transition-colors hover:bg-accent"
/* TBD: 행 클릭 시 이동 라우트 */
className="flex flex-col rounded-sm border border-border p-3"
>
<div className="mb-2 flex items-start justify-between">
<div className="font-medium">
<div className="mb-2 flex items-center justify-between">
<span className="font-medium">
{item.statsNm}
</div>
{/* TBD: vrfcCd 값별 표시 레이블 */}
<span className="rounded bg-red-100 px-2 py-0.5 text-xs text-red-700">
{item.vrfcCd}
</span>
<Badge
color={
TYPE_CODE_COLOR_MAP[
item.vrfcCd
]
}
className="text-sm"
>
{
TYPE_CODE_NAME_MAP[
item.vrfcCd
]
}
</Badge>
</div>
<div className="mb-1 text-sm text-muted-foreground">
<p className="text-lg text-muted-foreground">
{item.rsnExpln}
</div>
<div className="flex justify-between text-xs text-muted-foreground">
</p>
<div className="flex justify-between text-lg text-muted-foreground">
<span>{item.admdstNm}</span>
<span>
{/* TBD: 증감 표시 방법 (nowVlCn - compVlCn 계산?) */}
현재: {item.nowVlCn} / 비교:{" "}
{item.compVlCn}
현재: {item.nowVlCn ?? "-"}{" "}
/ 비교:{" "}
{item.compVlCn ?? "-"}
</span>
</div>
{/* TBD: 등급/점수/단계/업데이트 시각 필드 미제공 */}
@@ -37,14 +37,14 @@ const StatisticsPage = () => {
// 탭이 없으면 Dashboard 표시
if (tabs.length === 0) {
return (
<div className="card card-border !mt-6">
<div className="card card-border !mt-6 border-border bg-background">
<Dashboard />
</div>
);
}
return (
<div className="card card-border !mt-6">
<div className="card card-border !mt-6 border-border bg-background">
{tabs.map((tab) => {
// 렌더링 시점에 컴포넌트가 없으면 바로 로드
if (!componentCache.current.has(tab.id)) {