Files
frontend/apps/st/src/components/statistics/verification/StatisticsVerificationReport.tsx
T

762 lines
26 KiB
TypeScript

"use client";
import type { Dispatch, SetStateAction } from "react";
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
import { useConfirm } from "@frontend-opensource/use-react-hooks";
import { useQuery } from "@tanstack/react-query";
import dayjs from "dayjs";
import type { RealGridReact } from "realgrid-react";
import { ENDPOINT } from "@/shared/config";
import { useAlertStore, useMutationWrapper, useUserStore } from "@/shared/lib";
import { CodeApi, CodeSelect } from "@/entities/code";
import {
type ApproveRequest,
type RejectRequest,
statisticsApprovalApi,
type StatisticsVerificationFilterState,
statisticsVerificationReportColumnInfo,
statisticsVerificationResultColumnInfo,
type StatisticsVerificationSubFilterState,
type StatsInputStatusList,
useStAnlsSidoCombo,
useStAnlsSigunguCombo,
useStatsVrfcRptData,
useStatsVrfcRsltData,
type VerifyResultItem,
} from "@/entities/statistics";
import {
Button,
Combobox,
ComboboxChip,
ComboboxChips,
ComboboxChipsInput,
ComboboxContent,
ComboboxEmpty,
ComboboxItem,
ComboboxList,
ComboboxValue,
Label,
LoadingWithAnimation,
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
useComboboxAnchor,
} from "@common/components";
import DataGrid from "@/components/module/DataGrid";
import {
type ApprovalFormData,
ApprovalInputDialog,
} from "@/components/statistics/verification/ApprovalInputDialog";
import type { CellEditedCallbackParams } from "@/components/type/DataGrid.type";
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,
laeSbmsnSttsCd: "2",
};
const DEFAULT_SUB_FILTERS: StatisticsVerificationSubFilterState = {
...DEFAULT_FILTERS,
prcsYnList: "1,2,3",
pbcpAprvYn: "N",
};
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 { confirm } = useConfirm();
const [filters, setFilters] =
useState<StatisticsVerificationFilterState>(DEFAULT_FILTERS);
const [subFilters, setSubFilters] =
useState<StatisticsVerificationSubFilterState>(DEFAULT_SUB_FILTERS);
const [selectedReportRows, setSelectedReportRows] = useState<
StatsInputStatusList[]
>([]);
const [selectedResultRows, setSelectedResultRows] = useState<
VerifyResultItem[]
>([]);
const [approvalDialogOpen, setApprovalDialogOpen] = useState(false);
const editedResultRowMapRef = useRef<Map<number, VerifyResultItem>>(
new Map(),
);
const resultGridRef = useRef<RealGridReact | null>(null);
const prevLengthRef = useRef(0);
const { data: sidoData } = useStAnlsSidoCombo();
const { data: sigunguData } = useStAnlsSigunguCombo(
filters.sewerBzmnCd ?? "",
);
const detailParams = useMemo(
() => ({
statsYr: filters.statsYr,
ctpvNm:
subFilters.ctpvNm !== "" ? subFilters.ctpvNm : filters.ctpvNm,
sggNm: subFilters.sggNm !== "" ? subFilters.sggNm : filters.sggNm,
sewerBzmnCd:
subFilters.sewerBzmnCd !== ""
? subFilters.sewerBzmnCd
: filters.sewerBzmnCd,
statsCd:
subFilters.statsCd !== ""
? subFilters.statsCd
: filters.statsCd,
prcsYnList: subFilters.prcsYnList,
pbcpAprvYn: subFilters.pbcpAprvYn,
laeSbmsnSttsCd: subFilters.laeSbmsnSttsCd,
}),
[filters, subFilters],
);
const { data: statusOptions } = useQuery({
queryKey: [ENDPOINT.CMS_SERVICE.CODE, "STINPTEXCT"],
queryFn: () => CodeApi.getCode("STINPTEXCT"),
select: (data) =>
data
.filter(({ lwpoCd }) => !["0", "4"].includes(lwpoCd))
.map(({ lwpoCdNm: label, lwpoCd: value }) => ({
label,
value,
})),
});
const statisticsVerificationReportQuery = useStatsVrfcRptData(filters);
const statisticsVerificationResultQuery =
useStatsVrfcRsltData(detailParams);
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],
);
function mergeWithEditedMap(rows: Record<string, string | number>[]) {
const editedMap = editedResultRowMapRef.current;
if (editedMap.size === 0) return rows;
return rows.map((row) => {
const vrfcSn = Number(row.vrfcSn);
const edited = editedMap.get(vrfcSn);
return edited ? { ...row, ...edited } : row;
});
}
function captureCheckedVrfcSn(): number[] {
const gridView = resultGridRef.current?.gridView;
const dataProvider = resultGridRef.current?.dataProvider;
if (!gridView || !dataProvider) return [];
const checkedRows = gridView.getCheckedRows?.(true, false, true) ?? [];
return checkedRows
.map((row) => Number(dataProvider.getValue?.(row, "vrfcSn")))
.filter((key) => !Number.isNaN(key));
}
function restoreCheckedVrfcSn(
keys: number[],
rows: Record<string, string | number>[],
) {
const gridView = resultGridRef.current?.gridView;
if (!gridView || keys.length === 0) return;
const keySet = new Set(keys);
const rowsToCheck = rows
.map((row, index) => ({ index, key: Number(row.vrfcSn) }))
.filter((item) => !Number.isNaN(item.key) && keySet.has(item.key))
.map((item) => item.index);
if (rowsToCheck.length === 0) return;
gridView.checkRows?.(rowsToCheck, true, false);
}
useEffect(() => {
const dataProvider = resultGridRef.current?.dataProvider;
const nextRows = statisticsVerificationResultGridRows;
const prevLength = prevLengthRef.current;
if (!dataProvider) {
prevLengthRef.current = nextRows.length;
return;
}
if (nextRows.length === 0) {
dataProvider.setRows?.([]);
prevLengthRef.current = 0;
return;
}
if (nextRows.length > prevLength && prevLength > 0) {
// 무한스크롤 시 데이터 처리
const newRows = nextRows.slice(prevLength);
const mergedNewRows = mergeWithEditedMap(newRows);
dataProvider.addRows?.(mergedNewRows);
} else {
// 초기 조회 또는 재조회 시 데이터 처리
const checkedKeys = captureCheckedVrfcSn();
const mergedRows = mergeWithEditedMap(nextRows);
dataProvider.setRows?.(mergedRows);
restoreCheckedVrfcSn(checkedKeys, mergedRows);
}
prevLengthRef.current = nextRows.length;
}, [statisticsVerificationResultGridRows]);
const isLoading =
statisticsVerificationReportQuery.isFetching ||
statisticsVerificationResultQuery.isFetching;
const isAppendingDetail =
statisticsVerificationResultQuery.isFetchingNextPage;
const canLoadMoreDetail = Boolean(
statisticsVerificationResultQuery.hasNextPage,
);
const {
handleItemChecked: handleReportItemChecked,
handleAllChecked: handleReportAllChecked,
} = useGridCheckboxSelection(
statisticsVerificationReportData,
isSameStatsInputStatusRow,
setSelectedReportRows,
);
const {
handleItemChecked: handleResultItemChecked,
handleAllChecked: handleResultAllChecked,
} = useGridCheckboxSelection(
statisticsVerificationResultData,
isSameVerifyResultRow,
setSelectedResultRows,
);
const { mutateAsync: onApproveStats } = useMutationWrapper({
mutationFn: (approvalRequests: ApproveRequest[]) =>
statisticsApprovalApi.approveStats(approvalRequests),
onSuccess: (data) => {
alert(data.message ?? "승인이 완료되었습니다.");
},
});
const { mutateAsync: onRejectStats } = useMutationWrapper({
mutationFn: (rejectRequests: RejectRequest[]) =>
statisticsApprovalApi.rejectStats(rejectRequests),
onSuccess: (data) => {
alert(data.message ?? "반려가 완료되었습니다.");
},
});
useEffect(() => {
if (!statisticsVerificationReportQuery.isError) return;
alert("통계검증리포트 조회 중 오류가 발생했습니다.");
setSelectedReportRows([]);
}, [
alert,
statisticsVerificationReportQuery.errorUpdatedAt,
statisticsVerificationReportQuery.isError,
]);
function resetResultState() {
setSelectedResultRows([]);
editedResultRowMapRef.current = new Map();
prevLengthRef.current = 0;
resultGridRef.current?.dataProvider?.setRows?.([]);
}
function resetReportState() {
setSelectedReportRows([]);
resetResultState();
}
async function handleLoadMoreDetail() {
if (isLoading || isAppendingDetail || !canLoadMoreDetail) return;
try {
await statisticsVerificationResultQuery.fetchNextPage();
} catch {
alert("추가 데이터를 불러오는 중 오류가 발생했습니다.");
}
}
function handleFilterChange<
K extends keyof StatisticsVerificationFilterState,
>(key: K) {
return (
value?: StatisticsVerificationFilterState[K],
optionData?: { label: string; value: string },
) => {
resetReportState();
if (key === "sewerBzmnCd") {
setFilters((prev) => ({
...prev,
ctpvNm: optionData?.label,
sewerBzmnCd: optionData?.value,
sggNm: "",
}));
setSubFilters((prev) => ({
...prev,
ctpvNm: "",
sggNm: "",
sewerBzmnCd: "",
}));
return;
}
if (key === "statsCd") {
setSubFilters((prev) => ({ ...prev, statsCd: "" }));
}
setFilters((prev) => ({ ...prev, [key]: value }));
};
}
function handleSubFilterChange<
K extends keyof StatisticsVerificationSubFilterState,
>(key: K) {
return (value?: StatisticsVerificationSubFilterState[K]) => {
resetResultState();
setSubFilters((prev) => ({ ...prev, [key]: value }));
};
}
function handleSubFilterReset() {
setSubFilters(DEFAULT_SUB_FILTERS);
resetResultState();
}
function handleCellEdited(params: CellEditedCallbackParams) {
const { itemIndex, fieldName, newValue } = params;
const dataProvider = resultGridRef.current?.dataProvider as
| { getValue?: (row: number, field: string) => string | number }
| undefined;
const vrfcSn = Number(dataProvider?.getValue?.(itemIndex, "vrfcSn"));
if (Number.isNaN(vrfcSn)) return;
editedResultRowMapRef.current?.set(vrfcSn, {
...(editedResultRowMapRef.current?.get(vrfcSn) ?? {}),
[fieldName]: newValue,
});
}
function handleReset() {
setFilters(DEFAULT_FILTERS);
setSubFilters(DEFAULT_SUB_FILTERS);
resetReportState();
}
function handleApprove() {
if (selectedReportRows.length === 0) return;
setApprovalDialogOpen(true);
}
async function handleApprovalConfirm(formData: ApprovalFormData) {
const commonParam = {
statsYr: filters.statsYr,
mdfrId: userInfo?.userUnqId ?? "",
};
const approvalRequests = selectedReportRows.map((row) => ({
...commonParam,
...formData,
statsCd: row.statsCd ?? "",
sewerBzmnCd: row.sewerBzmnCd ?? "",
}));
try {
if (await confirm("승인하시겠습니까?")) {
await onApproveStats(approvalRequests);
setApprovalDialogOpen(false);
}
} catch {
alert("승인 중 오류가 발생했습니다.");
}
}
async function handleReject() {
const commonParam = {
statsYr: filters.statsYr,
sewerBzmnCd: filters.sewerBzmnCd ?? "",
statsCd: filters.statsCd ?? "",
mdfrId: userInfo?.userUnqId ?? "",
};
const requestRows = selectedResultRows.map((row) => {
const vrfcSn = Number(row.vrfcSn);
const editedRow = Number.isNaN(vrfcSn)
? undefined
: editedResultRowMapRef.current?.get(vrfcSn);
return { ...row, ...(editedRow ?? {}) };
});
const hasEmptyRjctOpnnCn = requestRows.some(
(row) => !String(row?.rjctOpnnCn ?? "").trim(),
);
if (hasEmptyRjctOpnnCn) {
alert("선택한 항목의 반려의견을 작성해주세요.");
return;
}
const rejectRequests = requestRows.map((row) => {
const vrfcSn = Number(row.vrfcSn);
return {
...commonParam,
vrfcSn: Number.isNaN(vrfcSn) ? 0 : vrfcSn,
rjctOpnnCn: String(row.rjctOpnnCn ?? ""),
};
});
try {
if (await confirm("반려하시겠습니까?")) {
await onRejectStats(rejectRequests);
}
} catch {
alert("반려 중 오류가 발생했습니다.");
}
}
return (
<div className="relative">
<ApprovalInputDialog
open={approvalDialogOpen}
onOpenChange={setApprovalDialogOpen}
onConfirm={handleApprovalConfirm}
/>
<div className="flex justify-between p-2">
<div className="flex items-center gap-6">
<FilterSelect
label="시도"
value={filters.sewerBzmnCd ?? ""}
onChange={handleFilterChange("sewerBzmnCd")}
options={
sidoData?.map((item) => ({
label: item.adstNm,
value: item.adstCd,
})) ?? []
}
/>
<FilterSelect
label="시군구"
value={filters.sggNm ?? ""}
onChange={handleFilterChange("sggNm")}
options={
sigunguData?.map((item) => ({
label: item.adstNm,
value: item.adstCd,
})) ?? []
}
/>
<FilterSelect
label="년도"
value={filters.statsYr}
onChange={handleFilterChange("statsYr")}
options={getYearOptions()}
/>
<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 className="flex items-end justify-end gap-2">
<Button color="white" onClick={handleReset}>
초기화
</Button>
<Button
color="green"
onClick={handleApprove}
disabled={selectedReportRows.length === 0}
>
승인
</Button>
</div>
</div>
<DataGrid
columnInfo={statisticsVerificationReportColumnInfo}
fixedColumns={3}
isExcelImport={false}
isExcelExport={false}
onItemAllChecked={handleReportAllChecked}
onItemChecked={handleReportItemChecked}
rowData={statisticsVerificationReportGridRows}
rowStartNum={2}
setRowData={() => {}}
showHeaderCheckbox={true}
gridHeight={30}
onRowClick={(row: StatsInputStatusList) => {
setSubFilters((prev) => ({
...prev,
ctpvNm: row?.ctpvNm ?? "",
sggNm: row?.sggNm ?? "",
sewerBzmnCd: row?.sewerBzmnCd ?? "",
statsCd: row?.statsCd ?? "",
}));
}}
/>
<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={statusOptions ?? []}
/>
<FilterSelect
label="승인상태"
value={subFilters.pbcpAprvYn}
onChange={handleSubFilterChange("pbcpAprvYn")}
options={APPROVAL_STATUS_OPTIONS}
/>
</div>
<div className="flex items-end justify-end gap-2">
<Button color="white" onClick={handleSubFilterReset}>
초기화
</Button>
<Button
color="red"
onClick={handleReject}
disabled={
selectedResultRows.length === 0 ||
selectedResultRows.some((row) => row.prcsYn !== "2")
}
>
반려
</Button>
</div>
</div>
<div className="relative">
<DataGrid
columnInfo={statisticsVerificationResultColumnInfo}
fixedColumns={4}
isExcelImport={false}
onItemAllChecked={handleResultAllChecked}
onItemChecked={handleResultItemChecked}
onScrollToBottom={handleLoadMoreDetail}
rowData={statisticsVerificationResultGridRows}
rowStartNum={2}
setRowData={() => {}}
showHeaderCheckbox={true}
onCellEdited={handleCellEdited}
getGridRef={resultGridRef}
/>
{(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>
);
}
interface FilterSelectProps {
label: string;
onChange: (
value?: string,
optionData?: { label: string; value: string },
) => void;
options: Array<{ label: 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-[32rem]">
<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-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) => (
<SelectItem key={option.value} value={option.value}>
{option.label}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
);
}
export default StatisticsVerificationReport;