[st] feat: 통계 검증 리포트 화면 작업

This commit is contained in:
2026-04-20 16:08:14 +09:00
parent dbcbd5f8b2
commit 16c8fe716d
22 changed files with 2362 additions and 1111 deletions
+105 -51
View File
@@ -321,10 +321,14 @@ const DataGrid = ({
cellStyleCallback,
cellTooltipCallback,
onRowClick,
onItemChecked,
onItemAllChecked,
onScrollToBottom,
statusInfo,
highlightedRowId,
idFieldName = "id",
enableSort = false,
gridHeight = 65, // vh 단위
}: DataGridProps) => {
const { columns, columnLayout } = columnInfo;
const gridRef = useRef<RealGridReact>(null);
@@ -340,9 +344,18 @@ const DataGrid = ({
const cellTooltipCallbackRef = useRef<CellTooltipCallback | undefined>(
cellTooltipCallback,
);
const onScrollToBottomRef = useRef<(() => void) | undefined>(
onScrollToBottom,
);
const onItemCheckedRef = useRef<typeof onItemChecked>(onItemChecked);
const onItemAllCheckedRef =
useRef<typeof onItemAllChecked>(onItemAllChecked);
rowDataRef.current = rowData;
cellStyleCallbackRef.current = cellStyleCallback;
cellTooltipCallbackRef.current = cellTooltipCallback;
onScrollToBottomRef.current = onScrollToBottom;
onItemCheckedRef.current = onItemChecked;
onItemAllCheckedRef.current = onItemAllChecked;
// highlightedRowId가 변경될 때 해당 행으로 스크롤 및 선택
useEffect(() => {
@@ -558,10 +571,10 @@ const DataGrid = ({
) {
const cell =
worksheet[
XLSX.utils.encode_cell({
r: rowIndex,
c: col,
})
XLSX.utils.encode_cell({
r: rowIndex,
c: col,
})
];
if (
cell &&
@@ -637,7 +650,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}`;
@@ -761,13 +774,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;
@@ -937,7 +950,7 @@ const DataGrid = ({
) {
(
column.footer[
index
index
] as FooterItemWithCallback
).valueCallback =
footerItem.valueCallback;
@@ -990,11 +1003,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();
@@ -1090,6 +1103,47 @@ const DataGrid = ({
};
}, [onRowClick, cellTooltipCallback, columns]);
useEffect(() => {
const gridView = gridRef.current?.gridView;
if (!gridView || !onScrollToBottom) return;
gridView.onScrollToBottom = () => {
onScrollToBottomRef.current?.();
};
return () => {
if (gridView.onScrollToBottom)
gridView.onScrollToBottom = undefined;
};
}, [onScrollToBottom]);
useEffect(() => {
const gridView = gridRef.current?.gridView;
if (!gridView) return;
if (onItemChecked) {
gridView.onItemChecked = (_grid, itemIndex, checked) => {
onItemCheckedRef.current?.(itemIndex, checked);
};
} else {
gridView.onItemChecked = undefined;
}
if (onItemAllChecked) {
gridView.onItemAllChecked = (_grid, checked) => {
onItemAllCheckedRef.current?.(checked);
};
} else {
gridView.onItemAllChecked = undefined;
}
return () => {
if (gridView.onItemChecked) gridView.onItemChecked = undefined;
if (gridView.onItemAllChecked)
gridView.onItemAllChecked = undefined;
};
}, [onItemChecked, onItemAllChecked]);
return (
<div className="mx-[0.6rem] flex flex-col gap-[0.6rem]">
<input
@@ -1105,23 +1159,23 @@ const DataGrid = ({
<span>
진행상황:{" "}
{statusInfo.laeSbmsnSttsCd === null ||
statusInfo.laeSbmsnSttsCd === "0"
statusInfo.laeSbmsnSttsCd === "0"
? "미작성"
: statusInfo.laeSbmsnSttsCd === "1"
? "작성중"
: statusInfo.laeSbmsnSttsCd === "2"
? "제출"
: statusInfo.laeSbmsnSttsCd === "3"
? "승인"
: statusInfo.laeSbmsnSttsCd === "4"
? "반려"
: "검증중"}
? "작성중"
: statusInfo.laeSbmsnSttsCd === "2"
? "제출"
: statusInfo.laeSbmsnSttsCd === "3"
? "승인"
: statusInfo.laeSbmsnSttsCd === "4"
? "반려"
: "검증중"}
</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
: "미제출"}
@@ -1159,7 +1213,7 @@ const DataGrid = ({
</Button>
)}
</div>
<div className="h-[65vh]">
<div style={{ height: `${gridHeight}vh` }}>
<RealGridReact
ref={gridRef}
autoGenerateField={true}
@@ -1176,24 +1230,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(
@@ -1346,14 +1400,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={
@@ -1,10 +1,34 @@
"use client";
import { useEffect, useMemo, useState } from "react";
import type { Dispatch, SetStateAction } from "react";
import { useCallback, useEffect, useMemo, useState } from "react";
import { useAlertStore } from "@/shared/lib";
import { useConfirm } from "@frontend-opensource/use-react-hooks";
import dayjs from "dayjs";
import { useAlertStore, useMutationWrapper, useUserStore } from "@/shared/lib";
import { CodeSelect } from "@/entities/code";
import {
type ApprovalRequest,
statisticsApprovalApi,
useStAnlsSidoCombo,
useStAnlsSigunguCombo,
useStatsVrfcRptData,
useStatsVrfcRsltData,
} from "@/entities/statistics";
import type { VerifyResultItem } from "@/entities/statistics/model/general-verification-interface";
import type { StatsInputStatusList } from "@/entities/statistics/model/input/statistics-input-status-interface";
import {
Button,
Combobox,
ComboboxChip,
ComboboxChips,
ComboboxChipsInput,
ComboboxContent,
ComboboxEmpty,
ComboboxItem,
ComboboxList,
ComboboxValue,
Label,
LoadingWithAnimation,
Select,
@@ -12,259 +36,364 @@ import {
SelectItem,
SelectTrigger,
SelectValue,
useComboboxAnchor,
} from "@common/components";
import {
statisticsVerificationDetailColumnInfo,
statisticsVerificationSummaryColumnInfo,
} from "../../../entities/statistics/model/statistics-verification-report/statistics-verification-report-columns";
import {
APPROVAL_STATUS_OPTIONS,
DEFAULT_FILTERS,
DEFAULT_SUB_FILTERS,
fetchStatisticsVerificationDetailMock,
fetchStatisticsVerificationSummaryMock,
getSigunguOptions,
getYearOptions,
SIDO_OPTIONS,
STATS_INDICATOR_OPTIONS,
STATUS_OPTIONS,
} from "../../../entities/statistics/model/statistics-verification-report/statistics-verification-report-mock";
import type {
StatisticsVerificationDetailRow,
StatisticsVerificationFilterState,
StatisticsVerificationSubFilterState,
StatisticsVerificationSummaryRow,
} from "../../../entities/statistics/model/statistics-verification-report/statistics-verification-report-types";
import ApproveButton from "@/components/button/ApproveButton";
import ReturnButton from "@/components/button/ReturnButton";
import DataGrid from "@/components/module/DataGrid";
import { toGridRows } from "@/components/util/grid/gridFunction";
const getYearOptions = () => {
const currentYear = dayjs().year();
const yearOptionsRange = Array.from(
{ length: currentYear - 2017 },
(_, i) => {
const year = 2017 + i;
return { label: String(year), value: String(year) };
},
);
return yearOptionsRange.map((year) => ({
label: year.label,
value: year.value,
}));
};
function isSameStatsInputStatusRow(
a: StatsInputStatusList,
b: StatsInputStatusList,
): boolean {
return (
a.ctpvNm === b.ctpvNm && a.sggNm === b.sggNm && a.statsCd === b.statsCd
);
}
function isSameVerifyResultRow(
a: VerifyResultItem,
b: VerifyResultItem,
): boolean {
if (a.vrfcSn != null && b.vrfcSn != null) return a.vrfcSn === b.vrfcSn;
return (
a.statsYr === b.statsYr &&
a.statsCd === b.statsCd &&
a.ctpvNm === b.ctpvNm &&
a.sggNm === b.sggNm &&
a.vrfcTrgtNm === b.vrfcTrgtNm &&
a.vrfcMthdCd === b.vrfcMthdCd
);
}
function useGridCheckboxSelection<T>(
sourceRows: T[],
isSameRow: (a: T, b: T) => boolean,
setSelected: Dispatch<SetStateAction<T[]>>,
) {
const handleItemChecked = useCallback(
(itemIndex: number, checked: boolean) => {
const origin = sourceRows[itemIndex];
if (origin === undefined) return;
setSelected((prev) => {
if (checked) {
if (prev.some((r) => isSameRow(r, origin))) return prev;
return [...prev, origin];
}
return prev.filter((r) => !isSameRow(r, origin));
});
},
[sourceRows, isSameRow, setSelected],
);
const handleAllChecked = useCallback(
(checked: boolean) => {
if (checked) setSelected([...sourceRows]);
else setSelected([]);
},
[sourceRows, setSelected],
);
return { handleItemChecked, handleAllChecked };
}
const DEFAULT_FILTERS: StatisticsVerificationFilterState = {
ctpvNm: null,
sggNm: null,
statsYr: String(dayjs().year() - 1),
sewerBzmnCd: null,
statsCd: null,
};
const DEFAULT_SUB_FILTERS: StatisticsVerificationSubFilterState = {
...DEFAULT_FILTERS,
prcsYnList: "1,2,3",
pbcpAprvYn: "N",
};
const STATUS_OPTIONS = [
{ label: "수정완료", value: "1" },
{ label: "예외통과", value: "2" },
{ label: "반려", value: "3" },
];
const APPROVAL_STATUS_OPTIONS = [
{ label: "승인", value: "Y" },
{ label: "미승인", value: "N" },
];
function StatisticsVerificationReport() {
const userInfo = useUserStore((state) => state.userInfo);
const { setMessage: alert } = useAlertStore((state) => state);
const yearOptions = useMemo(() => getYearOptions(), []);
const { confirm } = useConfirm();
const [filters, setFilters] =
useState<StatisticsVerificationFilterState>(DEFAULT_FILTERS);
const [subFilters, setSubFilters] =
useState<StatisticsVerificationSubFilterState>(DEFAULT_SUB_FILTERS);
const [summaryRows, setSummaryRows] = useState<
StatisticsVerificationSummaryRow[]
const [submittedMainFilters, setSubmittedMainFilters] =
useState<StatisticsVerificationFilterState>(DEFAULT_FILTERS);
const [submittedDetailParams, setSubmittedDetailParams] = useState<{
main: StatisticsVerificationFilterState;
sub: StatisticsVerificationSubFilterState;
}>({
main: DEFAULT_FILTERS,
sub: DEFAULT_SUB_FILTERS,
});
const [selectedSummaryRows, setSelectedSummaryRows] = useState<
StatsInputStatusList[]
>([]);
const [detailRows, setDetailRows] = useState<
StatisticsVerificationDetailRow[]
const [selectedDetailRows, setSelectedDetailRows] = useState<
VerifyResultItem[]
>([]);
const [summaryGridRows, setSummaryGridRows] = useState<
Record<string, string | number>[]
>([]);
const [detailGridRows, setDetailGridRows] = useState<
Record<string, string | number>[]
>([]);
const [selectedSummaryRow, setSelectedSummaryRow] =
useState<StatisticsVerificationSummaryRow | null>(null);
const [selectedDetailRow, setSelectedDetailRow] =
useState<StatisticsVerificationDetailRow | null>(null);
const [isLoading, setIsLoading] = useState(false);
const sigunguOptions = useMemo(
() => getSigunguOptions(filters.sido),
[filters.sido],
const { data: sidoData } = useStAnlsSidoCombo();
const { data: sigunguData } = useStAnlsSigunguCombo(
filters.sewerBzmnCd ?? "",
);
useEffect(() => {
void handleSearch(filters, subFilters);
}, []);
const statisticsVerificationReportQuery =
useStatsVrfcRptData(submittedMainFilters);
const statisticsVerificationResultQuery = useStatsVrfcRsltData(
submittedDetailParams,
);
async function handleSearch(
const statisticsVerificationReportData =
statisticsVerificationReportQuery.data ?? [];
const statisticsVerificationReportGridRows = useMemo(
() => toGridRows(statisticsVerificationReportData),
[statisticsVerificationReportData],
);
const statisticsVerificationResultData = useMemo(
() =>
statisticsVerificationResultQuery.data?.pages.flatMap(
(page) => page.verifyResultList ?? [],
) ?? [],
[statisticsVerificationResultQuery.data],
);
const statisticsVerificationResultGridRows = useMemo(
() => toGridRows(statisticsVerificationResultData),
[statisticsVerificationResultData],
);
const isLoading =
statisticsVerificationReportQuery.isFetching ||
statisticsVerificationResultQuery.isFetching;
const isAppendingDetail =
statisticsVerificationResultQuery.isFetchingNextPage;
const canLoadMoreDetail = Boolean(
statisticsVerificationResultQuery.hasNextPage,
);
const {
handleItemChecked: handleReportItemChecked,
handleAllChecked: handleReportAllChecked,
} = useGridCheckboxSelection(
statisticsVerificationReportData,
isSameStatsInputStatusRow,
setSelectedSummaryRows,
);
const {
handleItemChecked: handleResultItemChecked,
handleAllChecked: handleResultAllChecked,
} = useGridCheckboxSelection(
statisticsVerificationResultData,
isSameVerifyResultRow,
setSelectedDetailRows,
);
const { mutateAsync: onApproveStats } = useMutationWrapper({
mutationFn: (approvalRequests: ApprovalRequest[]) =>
statisticsApprovalApi.approveStats(approvalRequests),
onSuccess: (data) => {
alert(data.message ?? "승인이 완료되었습니다.");
},
});
useEffect(() => {
if (!statisticsVerificationReportQuery.isError) return;
alert("통계검증리포트 조회 중 오류가 발생했습니다.");
setSelectedSummaryRows([]);
}, [
alert,
statisticsVerificationReportQuery.errorUpdatedAt,
statisticsVerificationReportQuery.isError,
]);
function handleSearch(
nextFilters: StatisticsVerificationFilterState,
nextSubFilters: StatisticsVerificationSubFilterState,
summaryId?: number | null,
) {
setSelectedSummaryRows([]);
setSelectedDetailRows([]);
setSubmittedMainFilters({ ...nextFilters });
setSubmittedDetailParams({
main: { ...nextFilters },
sub: { ...nextSubFilters },
});
}
async function handleLoadMoreDetail() {
if (isLoading || isAppendingDetail || !canLoadMoreDetail) return;
try {
setIsLoading(true);
const [summaryData, detailData] = await Promise.all([
fetchStatisticsVerificationSummaryMock(nextFilters),
fetchStatisticsVerificationDetailMock(
nextFilters,
nextSubFilters,
summaryId,
),
]);
setSummaryRows(summaryData);
setDetailRows(detailData);
setSummaryGridRows(toGridRows(summaryData));
setDetailGridRows(toGridRows(detailData));
if (summaryData.length === 0)
alert("조회된 상단 목록 데이터가 없습니다.");
if (detailData.length === 0)
alert("조회된 상세 목록 데이터가 없습니다.");
await statisticsVerificationResultQuery.fetchNextPage();
} catch {
alert("통계검증리포트 조회 중 오류가 발생했습니다.");
} finally {
setIsLoading(false);
alert("추가 데이터를 불러오는 중 오류가 발생했습니다.");
}
}
function handleDetailSearchByAreaC() {
setSelectedDetailRows([]);
setSubmittedDetailParams({
main: { ...filters },
sub: { ...subFilters },
});
}
function handleFilterChange<
K extends keyof StatisticsVerificationFilterState,
>(key: K, value: StatisticsVerificationFilterState[K]) {
if (key === "sido") {
setFilters((prev) => ({ ...prev, sido: value, sigungu: "ALL" }));
return;
}
setFilters((prev) => ({ ...prev, [key]: value }));
>(key: K) {
return (
value?: StatisticsVerificationFilterState[K],
optionData?: { label: string; value: string },
) => {
if (key === "sewerBzmnCd") {
setFilters((prev) => ({
...prev,
ctpvNm: optionData?.label,
sewerBzmnCd: optionData?.value,
sggNm: "",
}));
return;
}
setFilters((prev) => ({ ...prev, [key]: value }));
};
}
function handleSubFilterChange<
K extends keyof StatisticsVerificationSubFilterState,
>(key: K, value: StatisticsVerificationSubFilterState[K]) {
setSubFilters((prev) => ({ ...prev, [key]: value }));
>(key: K) {
return (value?: StatisticsVerificationSubFilterState[K]) => {
setSubFilters((prev) => ({ ...prev, [key]: value }));
};
}
function handleReset() {
setFilters(DEFAULT_FILTERS);
setSubFilters(DEFAULT_SUB_FILTERS);
setSelectedSummaryRow(null);
setSelectedDetailRow(null);
void handleSearch(DEFAULT_FILTERS, DEFAULT_SUB_FILTERS);
setSelectedSummaryRows([]);
setSelectedDetailRows([]);
handleSearch(DEFAULT_FILTERS, DEFAULT_SUB_FILTERS);
}
function handleApprove() {
if (!selectedSummaryRow) {
alert("승인할 상단 목록 행을 먼저 선택해주세요.");
return;
async function handleApprove() {
const commonParam = {
statsYr: filters.statsYr,
sewerBzmnCd: filters.sewerBzmnCd ?? "",
statsCd: filters.statsCd ?? "",
mdfrId: userInfo?.userUnqId ?? "",
};
const approvalRequests = selectedSummaryRows.map((row) => ({
...commonParam,
rjctOpnnCn: row.rjctOpnnCn ?? "",
lastCfmtnPicNm: row.laePicNm ?? "",
lastCfmtnPicTelNo: row.laePicTelNo ?? "",
lastCfmtnPicDeptNm: row.laeKbrdrDeptNm ?? "",
lastCfmtnPicJbgdNm: row.laeKbrdrJbgdNm ?? "",
}));
try {
if (await confirm("승인하시겠습니까?")) {
await onApproveStats(approvalRequests);
}
} catch {
alert("승인 중 오류가 발생했습니다.");
}
alert(
`승인 API TBD: ${selectedSummaryRow.sido} ${selectedSummaryRow.sigungu} (${selectedSummaryRow.reportType})`,
);
}
function handleReject() {
if (!selectedDetailRow) {
alert("반려할 상세 항목 행을 먼저 선택해주세요.");
return;
}
alert(
`반려 API TBD: ${selectedDetailRow.sido} ${selectedDetailRow.sigungu} ${selectedDetailRow.detailItem}`,
);
}
function handleDownload() {
if (detailRows.length === 0) {
alert("다운로드할 상세 목록이 없습니다.");
return;
}
const headers = [
"시도",
"시군구",
"지표",
"세부항목",
"검증날짜",
"오류내역",
"입력값",
"비교값",
"상태",
"예외사유",
"반려의견",
"승인여부",
];
const rows = detailRows.map((row) =>
[
row.sido,
row.sigungu,
row.indicator,
row.detailItem,
row.verifyDate,
row.errorDetail,
String(row.inputValue),
String(row.compareValue),
row.status,
row.exceptionReason,
row.returnOpinion,
row.approvalYn,
].join(","),
);
const csv = [headers.join(","), ...rows].join("\n");
const blob = new Blob([`\uFEFF${csv}`], {
type: "text/csv;charset=utf-8;",
});
const url = URL.createObjectURL(blob);
const link = document.createElement("a");
link.href = url;
link.download = "statistics-verification-report-detail.csv";
link.click();
URL.revokeObjectURL(url);
}
function handleSummaryRowClick(row: Record<string, string | number>) {
const summaryId = Number(row.summaryId);
const selected = summaryRows.find(
(item) => item.summaryId === summaryId,
);
setSelectedSummaryRow(selected ?? null);
if (!summaryId) return;
void handleSearch(filters, subFilters, summaryId);
}
function handleDetailRowClick(row: Record<string, string | number>) {
const detailId = Number(row.detailId);
const selected = detailRows.find((item) => item.detailId === detailId);
setSelectedDetailRow(selected ?? null);
}
function handleReject() {}
return (
<div className="relative">
<div className="card !py-0">
<div className="grid grid-cols-1 gap-[0.8rem] p-[1.2rem] md:grid-cols-[repeat(4,minmax(0,1fr))_auto]">
<div className="flex justify-between p-2">
<div className="flex items-center gap-6">
<FilterSelect
label="시도"
value={filters.sido}
onChange={(value) => handleFilterChange("sido", value)}
options={SIDO_OPTIONS}
value={filters.sewerBzmnCd ?? ""}
onChange={handleFilterChange("sewerBzmnCd")}
options={
sidoData?.map((item) => ({
label: item.adstNm,
value: item.adstCd,
})) ?? []
}
/>
<FilterSelect
label="시군구"
value={filters.sigungu}
onChange={(value) =>
handleFilterChange("sigungu", value)
value={filters.sggNm ?? ""}
onChange={handleFilterChange("sggNm")}
options={
sigunguData?.map((item) => ({
label: item.adstNm,
value: item.adstCd,
})) ?? []
}
options={sigunguOptions}
/>
<FilterSelect
label="년도"
value={filters.year}
onChange={(value) => handleFilterChange("year", value)}
options={yearOptions}
value={filters.statsYr}
onChange={handleFilterChange("statsYr")}
options={getYearOptions()}
/>
<FilterSelect
label="통계지표"
value={filters.statsIndicator}
onChange={(value) =>
handleFilterChange("statsIndicator", value)
}
options={STATS_INDICATOR_OPTIONS}
/>
<div className="flex items-end justify-end gap-[0.6rem]">
<Button color="green" onClick={handleReset}>
초기화
</Button>
<Button
onClick={() =>
void handleSearch(filters, subFilters)
}
>
조회
</Button>
<div className="flex items-center gap-2">
<Label>통계지표</Label>
<CodeSelect
upCd="STITEM"
value={filters.statsCd ?? ""}
onValueChange={handleFilterChange("statsCd")}
placeholder="선택"
className="w-[32rem]"
/>
</div>
</div>
</div>
<div className="card !py-0">
<div className="m-[0.6rem] flex items-center justify-end">
<ApproveButton approveFunc={handleApprove} />
<div className="flex items-end justify-end gap-[0.6rem]">
<Button color="white" onClick={handleReset}>
초기화
</Button>
<Button onClick={() => handleSearch(filters, subFilters)}>
조회
</Button>
<Button
color="green"
onClick={handleApprove}
disabled={selectedSummaryRows.length === 0}
>
승인
</Button>
</div>
</div>
@@ -272,78 +401,161 @@ function StatisticsVerificationReport() {
columnInfo={statisticsVerificationSummaryColumnInfo}
fixedColumns={3}
isExcelImport={false}
onRowClick={handleSummaryRowClick}
rowData={summaryGridRows}
isExcelExport={false}
onItemAllChecked={handleReportAllChecked}
onItemChecked={handleReportItemChecked}
rowData={statisticsVerificationReportGridRows}
rowStartNum={2}
setRowData={setSummaryGridRows}
setRowData={() => {}}
showHeaderCheckbox={true}
gridHeight={30}
/>
<div className="card !py-0">
<div className="m-[0.6rem] flex flex-wrap items-end justify-between gap-[0.8rem]">
<div className="grid w-full grid-cols-1 gap-[0.8rem] md:w-auto md:grid-cols-2">
<FilterSelect
label="상태"
value={subFilters.status}
onChange={(value) =>
handleSubFilterChange("status", value)
}
options={STATUS_OPTIONS}
/>
<FilterSelect
label="승인상태"
value={subFilters.approvalStatus}
onChange={(value) =>
handleSubFilterChange("approvalStatus", value)
}
options={APPROVAL_STATUS_OPTIONS}
/>
</div>
<div className="flex items-end justify-end gap-[0.6rem]">
<Button color="gray" onClick={handleDownload}>
다운로드
</Button>
<ReturnButton returnFunc={handleReject} />
</div>
<h3 className="mt-10 px-2 text-[1.8rem] font-bold text-title">
검증 결과
</h3>
<div className="mt-2 flex flex-wrap items-end justify-between p-2">
<div className="flex items-center gap-6">
<FilterCombobox
label="상태"
value={subFilters.prcsYnList}
onChange={handleSubFilterChange("prcsYnList")}
options={STATUS_OPTIONS}
/>
<FilterSelect
label="승인상태"
value={subFilters.pbcpAprvYn}
onChange={handleSubFilterChange("pbcpAprvYn")}
options={APPROVAL_STATUS_OPTIONS}
/>
</div>
<div className="flex items-end justify-end gap-[0.6rem]">
<Button onClick={handleDetailSearchByAreaC}>조회</Button>
<Button
color="red"
onClick={handleReject}
disabled={selectedDetailRows.length === 0}
>
반려
</Button>
</div>
</div>
<DataGrid
columnInfo={statisticsVerificationDetailColumnInfo}
fixedColumns={4}
isExcelImport={false}
onRowClick={handleDetailRowClick}
rowData={detailGridRows}
rowStartNum={2}
setRowData={setDetailGridRows}
showHeaderCheckbox={true}
/>
<div className="relative">
<DataGrid
columnInfo={statisticsVerificationDetailColumnInfo}
fixedColumns={4}
isExcelImport={false}
onItemAllChecked={handleResultAllChecked}
onItemChecked={handleResultItemChecked}
onScrollToBottom={handleLoadMoreDetail}
rowData={statisticsVerificationResultGridRows}
rowStartNum={2}
setRowData={() => {}}
showHeaderCheckbox={true}
/>
{isLoading && (
<div className="[data-theme='black']:bg-transparent absolute inset-0 z-50 flex items-center justify-center rounded-lg p-10">
<div className="relative size-[5rem]">
<LoadingWithAnimation color="primary" />
{(isLoading || isAppendingDetail) && (
<div className="[data-theme='black']:bg-transparent absolute inset-0 z-50 flex items-center justify-center rounded-lg p-10">
<div className="relative size-[6rem]">
<LoadingWithAnimation color="primary" />
</div>
</div>
</div>
)}
)}
</div>
</div>
);
}
interface FilterSelectProps {
label: string;
onChange: (value: string) => void;
onChange: (
value?: string,
optionData?: { label: string; value: string },
) => void;
options: Array<{ label: string; value: string }>;
value: string;
value?: string;
}
function FilterCombobox({
label,
onChange,
options,
value,
}: FilterSelectProps) {
const anchor = useComboboxAnchor();
const items = useMemo(
() => options.map((option) => option.value),
[options],
);
const selectedValues = useMemo(() => {
if (!value) return [];
return value.split(",").filter(Boolean);
}, [value]);
return (
<div className="flex items-center gap-2">
<Label className="whitespace-nowrap">{label}</Label>
<Combobox
multiple
autoHighlight
items={items}
value={selectedValues}
onValueChange={(next) => {
if (next == null) {
onChange(undefined);
return;
}
const list = Array.isArray(next) ? next : [next];
onChange(list.length > 0 ? list.join(",") : undefined);
}}
>
<ComboboxChips ref={anchor} className="max-w-[26rem]">
<ComboboxValue>
{(values: string[]) => (
<>
{values.map((v) => (
<ComboboxChip key={v}>
{options.find((o) => o.value === v)
?.label ?? v}
</ComboboxChip>
))}
<ComboboxChipsInput placeholder="선택" />
</>
)}
</ComboboxValue>
</ComboboxChips>
<ComboboxContent anchor={anchor}>
<ComboboxEmpty>검색 결과가 없습니다.</ComboboxEmpty>
<ComboboxList>
{(item: string) => (
<ComboboxItem key={item} value={item}>
{options.find((o) => o.value === item)?.label ??
item}
</ComboboxItem>
)}
</ComboboxList>
</ComboboxContent>
</Combobox>
</div>
);
}
function FilterSelect({ label, onChange, options, value }: FilterSelectProps) {
return (
<div className="flex items-center gap-[0.8rem]">
<Label className="min-w-[4rem] text-[1.3rem]">{label}</Label>
<Select value={value} onValueChange={onChange}>
<SelectTrigger className="h-[3.2rem] min-w-[14rem]">
<SelectValue placeholder="전체" />
<div className="flex items-center gap-2">
<Label className="whitespace-nowrap">{label}</Label>
<Select
value={value}
onValueChange={(value) =>
onChange(
value,
options.find((option) => option.value === value),
)
}
>
<SelectTrigger className="w-[16rem]">
<SelectValue placeholder="선택" />
</SelectTrigger>
<SelectContent>
{options.map((option) => (
@@ -357,5 +569,4 @@ function FilterSelect({ label, onChange, options, value }: FilterSelectProps) {
);
}
export { StatisticsVerificationReport };
export default StatisticsVerificationReport;
+91 -75
View File
@@ -18,30 +18,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 */
@@ -110,21 +110,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;
@@ -147,31 +147,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 = {
@@ -183,16 +183,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[];
@@ -202,7 +202,10 @@ export type ColumnInfo = {
export type GridTopButtonConfig = {
visible?: boolean;
text: string;
onClick?: (helpers: { openDialog: (title?: string) => void; closeDialog: () => void }) => void;
onClick?: (helpers: {
openDialog: (title?: string) => void;
closeDialog: () => void;
}) => void;
};
export type ColumnInfoList = {
@@ -225,11 +228,18 @@ export type CellStyleCallbackParams = {
row: Record<string, string | number>;
};
export type CellStyleCallback = (params: CellStyleCallbackParams) => string | undefined;
export type CellStyleCallback = (
params: CellStyleCallbackParams,
) => string | undefined;
/** 셀 hover 시 툴팁 문구 (반환 시 해당 문구 표시, 검증 오류 메시지 등) */
export type CellTooltipCallbackParams = { row: Record<string, string | number>; fieldName: string };
export type CellTooltipCallback = (params: CellTooltipCallbackParams) => string | undefined;
export type CellTooltipCallbackParams = {
row: Record<string, string | number>;
fieldName: string;
};
export type CellTooltipCallback = (
params: CellTooltipCallbackParams,
) => string | undefined;
export type DataGridProps = {
columnInfo: ColumnInfo;
@@ -252,6 +262,11 @@ export type DataGridProps = {
current: (() => Record<string, string | number>[]) | null;
};
onRowClick?: (rowData: Record<string, string | number>) => void; // 행 클릭 이벤트
/** CheckBar 행 체크 변경 */
onItemChecked?: (itemIndex: number, checked: boolean) => void;
/** CheckBar 헤더 전체 선택/해제 */
onItemAllChecked?: (checked: boolean) => void;
onScrollToBottom?: () => void;
statusInfo?: SewerageSupplyRateStatusInfo | null;
/** 강조 및 스크롤할 행의 ID */
highlightedRowId?: string | number | null;
@@ -263,6 +278,7 @@ export type DataGridProps = {
compactFullWidth?: boolean;
/** CompactDataGrid 전용: 그리드 영역 높이 Tailwind 클래스 */
compactGridHeightClass?: string;
gridHeight?: number;
};
export type TabDataGridProps = {