[st] feat: 차트 포인터 클릭 시 그리드 포커싱되는 기능 추가

This commit is contained in:
2026-03-12 15:20:42 +09:00
parent 969d24736d
commit b6e11b4be4
5 changed files with 313 additions and 84 deletions
+278 -84
View File
@@ -1,24 +1,25 @@
import { useRef, useEffect, useLayoutEffect } from "react";
import { ValueType } from "realgrid";
import { useEffect, useLayoutEffect, useRef } from "react";
import RealGrid, { GridFitStyle, type DisplayOptions } from "realgrid";
import { RealGridReact, RGDataColumn } from "realgrid-react";
import type {
DataGridProps,
RealGridColumnInfo,
GridViewWithSetColumns,
FooterItemWithCallback,
ColumnLayoutItem,
CellStyleCallback,
CellTooltipCallback,
} from "../type/DataGrid.type";
import { getComputedValueCallback } from "@/components/util/grid/computedFieldCallbacks";
import JSZip from "jszip";
import { ValueType } from "realgrid";
import RealGrid, { type DisplayOptions, GridFitStyle } from "realgrid";
import { RealGridReact, RGDataColumn } from "realgrid-react";
import * as XLSX from "xlsx";
import { useAlertStore } from "@/shared/lib";
import { Button } from "@common/components";
import type {
CellStyleCallback,
CellTooltipCallback,
ColumnLayoutItem,
DataGridProps,
FooterItemWithCallback,
GridViewWithSetColumns,
RealGridColumnInfo,
} from "../type/DataGrid.type";
import { getComputedValueCallback } from "@/components/util/grid/computedFieldCallbacks";
import { useTabStore } from "@/store/tab-store";
// RealGrid 타입 확장
@@ -321,19 +322,43 @@ const DataGrid = ({
cellTooltipCallback,
onRowClick,
statusInfo,
highlightedRowId,
idFieldName = "id",
}: DataGridProps) => {
const { columns, columnLayout } = columnInfo;
const gridRef = useRef<RealGridReact>(null);
const fileInputRef = useRef<HTMLInputElement>(null);
const sortDirRef = useRef<Record<string, "asc" | "desc" | "none">>({});
const originalRowsRef = useRef<Record<string, Record<string, string | number>[]>>({});
const originalRowsRef = useRef<
Record<string, Record<string, string | number>[]>
>({});
const rowDataRef = useRef<Record<string, string | number>[]>(rowData);
const cellStyleCallbackRef = useRef<CellStyleCallback | undefined>(cellStyleCallback);
const cellTooltipCallbackRef = useRef<CellTooltipCallback | undefined>(cellTooltipCallback);
const cellStyleCallbackRef = useRef<CellStyleCallback | undefined>(
cellStyleCallback,
);
const cellTooltipCallbackRef = useRef<CellTooltipCallback | undefined>(
cellTooltipCallback,
);
rowDataRef.current = rowData;
cellStyleCallbackRef.current = cellStyleCallback;
cellTooltipCallbackRef.current = cellTooltipCallback;
// highlightedRowId가 변경될 때 해당 행으로 스크롤 및 선택
useEffect(() => {
if (!gridRef.current?.gridView || highlightedRowId == null) return;
const gridView = gridRef.current.gridView;
const index = rowData.findIndex(
(row) => String(row[idFieldName]) === String(highlightedRowId),
);
if (index !== -1) {
// 해당 행으로 포커스 이동 및 스크롤
gridView.setCurrent({ itemIndex: index });
gridView.setFocus();
}
}, [highlightedRowId, rowData, idFieldName]);
useLayoutEffect(() => {
if (!cellStyleCallback || !gridRef.current?.gridView) return;
gridRef.current.gridView.refresh();
@@ -532,10 +557,10 @@ const DataGrid = ({
) {
const cell =
worksheet[
XLSX.utils.encode_cell({
r: rowIndex,
c: col,
})
XLSX.utils.encode_cell({
r: rowIndex,
c: col,
})
];
if (
cell &&
@@ -611,7 +636,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}`;
@@ -909,7 +934,7 @@ const DataGrid = ({
) {
(
column.footer[
index
index
] as FooterItemWithCallback
).valueCallback =
footerItem.valueCallback;
@@ -962,11 +987,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();
@@ -1001,23 +1026,57 @@ const DataGrid = ({
if (!gridView || (!onRowClick && !cellTooltipCallback)) return;
gridView.onCellClicked = (grid, clickData) => {
if (clickData.cellType !== "data" || clickData.itemIndex === undefined) return;
if (
clickData.cellType !== "data" ||
clickData.itemIndex === undefined
)
return;
try {
const row = rowDataRef.current[clickData.itemIndex];
const cellIndex = (grid as { getCurrent?: () => { itemIndex: number; column?: string; fieldName?: string } }).getCurrent?.();
const cellIndex = (
grid as {
getCurrent?: () => {
itemIndex: number;
column?: string;
fieldName?: string;
};
}
).getCurrent?.();
// cellTooltipCallback 있으면 해당 셀 메시지로 showMessage (공식 문서: TooltipOptions + CellIndex)
if (cellTooltipCallbackRef.current && row && cellIndex) {
const fieldName = cellIndex.fieldName ?? cellIndex.column ?? "";
const msg = cellTooltipCallbackRef.current({ row: row as Record<string, string | number>, fieldName });
const fieldName =
cellIndex.fieldName ?? cellIndex.column ?? "";
const msg = cellTooltipCallbackRef.current({
row: row as Record<string, string | number>,
fieldName,
});
if (msg) {
const colName = columns.find((c) => c.fieldName === fieldName || c.name === cellIndex.column)?.name ?? cellIndex.column ?? fieldName;
(gridView as { showMessage?: (opts: { message: string; hideDuration?: number }, idx: { itemIndex: number; column: string }) => void }).showMessage?.(
const colName =
columns.find(
(c) =>
c.fieldName === fieldName ||
c.name === cellIndex.column,
)?.name ??
cellIndex.column ??
fieldName;
(
gridView as {
showMessage?: (
opts: {
message: string;
hideDuration?: number;
},
idx: { itemIndex: number; column: string },
) => void;
}
).showMessage?.(
{ message: msg, hideDuration: 5000 },
{ itemIndex: cellIndex.itemIndex, column: colName }
{ itemIndex: cellIndex.itemIndex, column: colName },
);
}
}
if (onRowClick) onRowClick((row ?? {}) as Record<string, string | number>);
if (onRowClick)
onRowClick((row ?? {}) as Record<string, string | number>);
} catch (error) {
console.error("행 데이터 가져오기 오류:", error);
}
@@ -1038,26 +1097,42 @@ const DataGrid = ({
onChange={handleFileChange}
/>
<div className="flex items-center justify-end gap-[0.6rem] px-[1.2rem]">
{statusInfo &&
{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 === '1' ? '작성중' : statusInfo.laeSbmsnSttsCd === '2' ? '제출'
:
statusInfo.laeSbmsnSttsCd === '3' ? '승인' : '반려'
}
<span>
진행상황:{" "}
{statusInfo.laeSbmsnSttsCd === null ||
statusInfo.laeSbmsnSttsCd === "0"
? "미작성"
: statusInfo.laeSbmsnSttsCd === "1"
? "작성중"
: statusInfo.laeSbmsnSttsCd === "2"
? "제출"
: statusInfo.laeSbmsnSttsCd === "3"
? "승인"
: "반려"}
</span>
<span>
제출일:{" "}
{statusInfo.laeSbmsnYmd
? statusInfo.laeSbmsnYmd.length === 8 &&
/^\d{8}$/.test(statusInfo.laeSbmsnYmd)
? `${statusInfo.laeSbmsnYmd.slice(0, 4)}-${statusInfo.laeSbmsnYmd.slice(4, 6)}-${statusInfo.laeSbmsnYmd.slice(6, 8)}`
: statusInfo.laeSbmsnYmd
: "미제출"}
</span>
<span>
담당자:{" "}
{statusInfo.laePicNm ? statusInfo.laePicNm : "-"}
</span>
<span>
연락처:{" "}
{statusInfo.laePicTelNo
? statusInfo.laePicTelNo
: "-"}
</span>
<span>제출일: {statusInfo.laeSbmsnYmd
? (statusInfo.laeSbmsnYmd.length === 8 && /^\d{8}$/.test(statusInfo.laeSbmsnYmd)
? `${statusInfo.laeSbmsnYmd.slice(0, 4)}-${statusInfo.laeSbmsnYmd.slice(4, 6)}-${statusInfo.laeSbmsnYmd.slice(6, 8)}`
: statusInfo.laeSbmsnYmd)
: '미제출'}</span>
<span>담당자: {statusInfo.laePicNm ? statusInfo.laePicNm : '-'}</span>
<span>연락처: {statusInfo.laePicTelNo ? statusInfo.laePicTelNo : '-'}</span>
</div>
}
)}
{isExcelImport && (
<Button
className="h-[3.2rem] w-[96px] bg-gradient-green px-6 hover:bg-gradient-green-hover"
@@ -1087,12 +1162,36 @@ const DataGrid = ({
rows={rowData}
onInitialized={(grid) => {
if (!grid) return;
(grid as { onSorting?: (g: unknown, f: unknown[], d: unknown[]) => boolean }).onSorting = createOnSortingHandler(columns, setRowData, sortDirRef, originalRowsRef);
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> }
(
grid as {
onSorting?: (
g: unknown,
f: unknown[],
d: unknown[],
) => boolean;
}
).onSorting = createOnSortingHandler(
columns,
setRowData,
sortDirRef,
originalRowsRef,
);
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>;
}
| undefined;
if (dataProvider?.setFields) {
dataProvider.setFields(buildFieldsFromColumns(columns));
dataProvider.setFields(
buildFieldsFromColumns(columns),
);
}
if (dataProvider?.setRows && rowData?.length) {
dataProvider.setRows(rowData);
@@ -1104,33 +1203,101 @@ const DataGrid = ({
if (!gv?.columnByName) return;
// 공식 문서: 데이터 영역 툴팁 = renderer.showTooltip + onShowTooltip (displayOptions는 Ellipsis용이라 사용 안 함)
if (cellTooltipCallbackRef.current) {
(gv as { onShowTooltip?: (grid: unknown, index: { itemIndex: number; column: string }, value: string) => string | undefined }).onShowTooltip = (_grid, index, value) => {
const row = rowDataRef.current[index?.itemIndex];
(
gv as {
onShowTooltip?: (
grid: unknown,
index: {
itemIndex: number;
column: string;
},
value: string,
) => string | undefined;
}
).onShowTooltip = (_grid, index, value) => {
const row =
rowDataRef.current[index?.itemIndex];
if (row == null) return value;
const column = index.column;
const col = columns.find((c) => c.name === column);
const col = columns.find(
(c) => c.name === column,
);
const fieldName = col?.fieldName ?? column;
const custom = cellTooltipCallbackRef.current?.({ row: row as Record<string, string | number>, fieldName });
const custom =
cellTooltipCallbackRef.current?.({
row: row as Record<
string,
string | number
>,
fieldName,
});
// verificationResult 등 메시지가 있을 때만 툴팁 표시 (없으면 undefined 반환 → 툴팁 안 띄움)
return custom && custom.length > 0 ? custom : undefined;
return custom && custom.length > 0
? custom
: undefined;
};
}
columns.forEach((column) => {
const baseStyleName = column.styleName;
const gridCol = gv.columnByName(column.name) as { styleCallback?: (g: unknown, dataCell: { itemIndex?: number; index?: { itemIndex?: number }; dataField?: string; fieldName?: string }) => string; renderer?: { showTooltip?: boolean } } | null;
const gridCol = gv.columnByName(
column.name,
) as {
styleCallback?: (
g: unknown,
dataCell: {
itemIndex?: number;
index?: { itemIndex?: number };
dataField?: string;
fieldName?: string;
},
) => string;
renderer?: { showTooltip?: boolean };
} | null;
if (gridCol) {
if (cellTooltipCallbackRef.current) {
(gv as { setColumnProperty?: (col: string, key: string, value: unknown) => void }).setColumnProperty?.(column.name, 'renderer', { showTooltip: true });
(
gv as {
setColumnProperty?: (
col: string,
key: string,
value: unknown,
) => void;
}
).setColumnProperty?.(
column.name,
"renderer",
{ showTooltip: true },
);
}
gridCol.styleCallback = (_g, dataCell) => {
const itemIndex = dataCell?.index?.itemIndex ?? dataCell?.itemIndex ?? -1;
const fieldName = dataCell?.dataField ?? dataCell?.fieldName ?? column.fieldName;
const itemIndex =
dataCell?.index?.itemIndex ??
dataCell?.itemIndex ??
-1;
const fieldName =
dataCell?.dataField ??
dataCell?.fieldName ??
column.fieldName;
const data = rowDataRef.current;
if (itemIndex < 0 || itemIndex >= data.length) return baseStyleName ?? '';
const row = data[itemIndex] as Record<string, string | number>;
const extra = cellStyleCallbackRef.current?.({ rowIndex: itemIndex, fieldName, row });
if (!extra) return baseStyleName ?? '';
return baseStyleName ? `${extra} ${baseStyleName}` : extra;
if (
itemIndex < 0 ||
itemIndex >= data.length
)
return baseStyleName ?? "";
const row = data[itemIndex] as Record<
string,
string | number
>;
const extra =
cellStyleCallbackRef.current?.({
rowIndex: itemIndex,
fieldName,
row,
});
if (!extra) return baseStyleName ?? "";
return baseStyleName
? `${extra} ${baseStyleName}`
: extra;
};
}
});
@@ -1146,20 +1313,47 @@ const DataGrid = ({
key={column.name}
name={column.name}
fieldName={column.fieldName}
valueType={column.valueType ? column.valueType : ValueType.TEXT}
{...(column.valueType === ValueType.NUMBER && { numberFormat: column.dataFormat ? column.dataFormat : '#,##0.000' })}
valueType={
column.valueType
? column.valueType
: ValueType.TEXT
}
{...(column.valueType === ValueType.NUMBER && {
numberFormat: column.dataFormat
? column.dataFormat
: "#,##0.000",
})}
header={column.header}
editable={column.editable}
{...(column.width != null && { width: column.width })}
{...(baseStyleName != null && { styleName: baseStyleName })}
{...(column.visible === false && { visible: false })}
{...(cellTooltipCallback && { renderer: { showTooltip: true } })}
footer={column.footer && !Array.isArray(column.footer) ? {
text: column.footer.text,
expression: column.footer.expression,
numberFormat: column.footer.numberFormat,
} : undefined}
mergeRule={column.mergeCells ? { criteria: 'value' } : undefined}
{...(column.width != null && {
width: column.width,
})}
{...(baseStyleName != null && {
styleName: baseStyleName,
})}
{...(column.visible === false && {
visible: false,
})}
{...(cellTooltipCallback && {
renderer: { showTooltip: true },
})}
footer={
column.footer &&
!Array.isArray(column.footer)
? {
text: column.footer.text,
expression:
column.footer.expression,
numberFormat:
column.footer.numberFormat,
}
: undefined
}
mergeRule={
column.mergeCells
? { criteria: "value" }
: undefined
}
sortable={!column.sortdisable}
/>
);
@@ -21,6 +21,7 @@ const HbVerificationDetail: React.FC<HbVerificationDetailProps> = ({
null,
);
const [selectedSido, setSelectedSido] = useState<string | null>(null);
const [selectedRowId, setSelectedRowId] = useState<number | null>(null);
const [selectedTargetInfo, setSelectedTargetInfo] = useState<{
statsNm: string;
vrfcTrgtNm: string;
@@ -32,6 +33,7 @@ const HbVerificationDetail: React.FC<HbVerificationDetailProps> = ({
statsNm: row.statsNm,
vrfcTrgtNm: row.vrfcTrgtNm,
});
setSelectedRowId(null);
};
return (
@@ -76,6 +78,7 @@ const HbVerificationDetail: React.FC<HbVerificationDetailProps> = ({
}
selectedSido={selectedSido}
onSidoSelect={setSelectedSido}
selectedRowId={selectedRowId}
/>
</div>
<div className="flex flex-[0.5] flex-col gap-4 p-4 pt-[4.8rem]">
@@ -88,6 +91,7 @@ const HbVerificationDetail: React.FC<HbVerificationDetailProps> = ({
anlsSn={anlsSn}
vrfcTrgtSn={selectedVrfcTrgtSn}
selectedSido={selectedSido}
onPointClick={setSelectedRowId}
/>
</div>
</div>
@@ -28,6 +28,7 @@ interface RegionalGridProps {
vrfcTrgtNm: string;
selectedSido: string | null;
onSidoSelect: (sido: string | null) => void;
selectedRowId?: number | null;
}
interface AppliedFilter {
@@ -43,6 +44,7 @@ const RegionalGrid: React.FC<RegionalGridProps> = ({
vrfcTrgtNm,
selectedSido,
onSidoSelect,
selectedRowId,
}) => {
const [selectedGugun, setSelectedGugun] = useState<string>("");
const [selectedStatus, setSelectedStatus] = useState<string>("");
@@ -369,6 +371,8 @@ const RegionalGrid: React.FC<RegionalGridProps> = ({
setRowData={setGridRowData}
isExcelExport={false}
isExcelImport={false}
highlightedRowId={selectedRowId}
idFieldName="rsltSn"
/>
)}
</div>
@@ -41,6 +41,7 @@ interface ScatterChartProps {
anlsSn: number;
vrfcTrgtSn: string | null;
selectedSido: string | null;
onPointClick?: (rsltSn: number | null) => void;
}
interface LinePoint {
@@ -56,6 +57,7 @@ const ScatterChart: React.FC<ScatterChartProps> = ({
anlsSn,
vrfcTrgtSn,
selectedSido,
onPointClick,
}) => {
const { data: regionCodes } = useQuery({
queryKey: [ENDPOINT.CMS_SERVICE.CODE, "LAESECD"],
@@ -157,6 +159,27 @@ const ScatterChart: React.FC<ScatterChartProps> = ({
const options: ChartOptions<"scatter"> = {
responsive: true,
maintainAspectRatio: false,
onClick: (event, elements) => {
if (elements.length > 0) {
const element = elements[0];
if (!element) return;
const datasetIndex = element.datasetIndex;
const index = element.index;
const dataset = data.datasets[datasetIndex];
if (!dataset) return;
// 상한선/하한선 클릭 시 무시
if (dataset.showLine) return;
const pt = dataset.data[index] as DataPoint;
if (pt?.item?.rsltSn) {
onPointClick?.(pt.item.rsltSn);
}
} else {
onPointClick?.(null);
}
},
plugins: {
zoom: {
pan: {
@@ -244,6 +244,10 @@ export type DataGridProps = {
};
onRowClick?: (rowData: Record<string, string | number>) => void; // 행 클릭 이벤트
statusInfo?: SewerageSupplyRateStatusInfo | null;
/** 강조 및 스크롤할 행의 ID */
highlightedRowId?: string | number | null;
/** ID로 사용할 필드명 (기본값: 'id') */
idFieldName?: string;
};
export type TabDataGridProps = {