합계 및 요약행 (Summary Row)
BGridProps의 summary 설정으로 상단 또는 하단에 사용자 정의 요약 셀을 표시하는 방법을 학습합니다.
LIVE DEMO합계 및 요약행 (Summary Row) 인터랙티브 예제
인터랙티브 데모를 불러오는 중입니다…
LIVE DEMO합계 및 요약행 (Summary Row) 인터랙티브 예제
인터랙티브 데모를 불러오는 중입니다…
import { t } from './i18n';
import { Radio } from 'antd';
import * as React from 'react';
import { BGrid, BGridColumn, BGridDataItem, BGridProps } from 'beautiful-grid';
import { useContainerSize } from '../hooks/useContainerSize';
import DataGridContainer from '../components/DataGridContainer';
interface SalesOrder {
channel: string;
orderNo: string;
customer: string;
quantity: number;
supplyAmount: number;
taxAmount: number;
totalAmount: number;
}
const customers = [t('에이원 리테일', 'A1 Retail'), t('한빛상사', 'Hanbit Sangsa'), t('모노마켓', 'Mono Market'), t('오로라스토어', 'Aurora Store')];
const channels = [t('자사몰', 'Own Mall'), t('스마트스토어', 'Smart Store'), 'B2B'];
const data: BGridDataItem<SalesOrder>[] = Array.from({ length: 30 }, (_, index) => {
const quantity = (index % 8) + 1;
const supplyAmount = quantity * (48_000 + (index % 5) * 12_000);
const taxAmount = Math.round(supplyAmount * 0.1);
return {
values: {
channel: channels[index % channels.length],
orderNo: `ORD-2026-${String(index + 1).padStart(5, '0')}`,
customer: customers[index % customers.length],
quantity,
supplyAmount,
taxAmount,
totalAmount: supplyAmount + taxAmount,
},
};
});
const formatWon = (value: number) => `${value.toLocaleString()}${t('원', 'KRW')}`;
const sumBy = (rows: BGridDataItem<SalesOrder>[], key: 'quantity' | 'supplyAmount' | 'taxAmount' | 'totalAmount') =>
rows.reduce((total, item) => total + item.values[key], 0);
const createSummary = (position: 'top' | 'bottom'): BGridProps<SalesOrder>['summary'] => ({
position,
rows: [
{
columns: [
{
columnIndex: 0,
colSpan: 3,
align: 'center',
itemRender: ({ data }) => <strong>{position === 'top' ? t('상단', 'Top') : t('하단', 'Bottom')} {t('매출 합계 ·', 'Total Sales ·')} {data.length}{t('건', 'cases')}</strong>,
},
{ columnIndex: 3, align: 'right', itemRender: ({ data }) => <strong>{sumBy(data, 'quantity')}{t('개', 'ea')}</strong> },
{ columnIndex: 4, align: 'right', itemRender: ({ data }) => <strong>{formatWon(sumBy(data, 'supplyAmount'))}</strong> },
{ columnIndex: 5, align: 'right', itemRender: ({ data }) => <strong>{formatWon(sumBy(data, 'taxAmount'))}</strong> },
{ columnIndex: 6, align: 'right', itemRender: ({ data }) => <strong>{formatWon(sumBy(data, 'totalAmount'))}</strong> },
],
},
],
});
const createMultiSummary = (position: 'top' | 'bottom'): BGridProps<SalesOrder>['summary'] => ({
position,
rows: [
{
style: { backgroundColor: '#fffbeb', fontWeight: 600 },
columns: [
{
columnIndex: 0,
colSpan: 3,
align: 'center',
itemRender: ({ data }) => (
<span>
{position === 'top' ? t('상단', 'Top') : t('하단', 'Bottom')} {t('평균', 'Average')} ({data.length}{t('건', 'cases')})
</span>
),
},
{
columnIndex: 3,
align: 'right',
itemRender: ({ data }) => <span>{(sumBy(data, 'quantity') / data.length).toFixed(1)}{t('개', 'ea')}</span>,
},
{
columnIndex: 4,
align: 'right',
itemRender: ({ data }) => <span>{formatWon(Math.round(sumBy(data, 'supplyAmount') / data.length))}</span>,
},
{
columnIndex: 5,
align: 'right',
itemRender: ({ data }) => <span>{formatWon(Math.round(sumBy(data, 'taxAmount') / data.length))}</span>,
},
{
columnIndex: 6,
align: 'right',
itemRender: ({ data }) => <span>{formatWon(Math.round(sumBy(data, 'totalAmount') / data.length))}</span>,
},
],
},
{
style: { backgroundColor: '#f1f5f9', fontWeight: 700 },
columns: [
{
columnIndex: 0,
colSpan: 3,
align: 'center',
itemRender: ({ data }) => (
<strong>
{position === 'top' ? t('상단', 'Top') : t('하단', 'Bottom')} {t('합계', 'Total')} ({data.length}{t('건', 'cases')})
</strong>
),
},
{ columnIndex: 3, align: 'right', itemRender: ({ data }) => <strong>{sumBy(data, 'quantity')}{t('개', 'ea')}</strong> },
{ columnIndex: 4, align: 'right', itemRender: ({ data }) => <strong>{formatWon(sumBy(data, 'supplyAmount'))}</strong> },
{ columnIndex: 5, align: 'right', itemRender: ({ data }) => <strong>{formatWon(sumBy(data, 'taxAmount'))}</strong> },
{ columnIndex: 6, align: 'right', itemRender: ({ data }) => <strong>{formatWon(sumBy(data, 'totalAmount'))}</strong> },
],
},
],
});
function SummaryExample() {
const [position, setPosition] = React.useState<'top' | 'bottom'>('top');
const [summaryMode, setSummaryMode] = React.useState<'single' | 'multi'>('single');
const [columns, setColumns] = React.useState<BGridColumn<SalesOrder>[]>([
{ key: 'channel', label: t('주문채널', 'Order Channel'), width: 100, align: 'center' },
{ key: 'orderNo', label: t('주문번호', 'Order Number'), width: 140 },
{ key: 'customer', label: t('고객사', 'Client'), width: 150 },
{ key: 'quantity', label: t('수량', 'Quantity'), width: 80, align: 'right' },
{ key: 'supplyAmount', label: t('공급가액', 'Supply Amount'), width: 130, align: 'right', itemRender: ({ values }) => <>{formatWon(values.supplyAmount)}</> },
{ key: 'taxAmount', label: t('부가세', 'VAT'), width: 110, align: 'right', itemRender: ({ values }) => <>{formatWon(values.taxAmount)}</> },
{ key: 'totalAmount', label: t('결제금액', 'Payment Amount'), width: 140, align: 'right', itemRender: ({ values }) => <strong>{formatWon(values.totalAmount)}</strong> },
]);
const containerRef = React.useRef<HTMLDivElement>(null);
const { width, height } = useContainerSize(containerRef);
return (
<>
<div style={{ display: 'flex', gap: 16, marginBottom: 10, flexWrap: 'wrap' }}>
<Radio.Group
options={[{ label: t('다중 행 요약 (평균+합계)', 'Multi-row (Avg+Sum)'), value: 'multi' }, { label: t('단일 행 요약', 'Single Row'), value: 'single' }]}
value={summaryMode}
onChange={event => setSummaryMode(event.target.value)}
/>
<Radio.Group
options={[{ label: t('상단 요약', 'Top Summary'), value: 'top' }, { label: t('하단 요약', 'Bottom Summary'), value: 'bottom' }]}
value={position}
onChange={event => setPosition(event.target.value)}
/>
</div>
<DataGridContainer ref={containerRef}>
<BGrid<SalesOrder>
showLineNumber
frozenColumnIndex={1}
width={width}
height={height}
data={data}
columns={columns}
rowKey='orderNo'
onChangeColumns={(_columnIndex, { columns }) => setColumns(columns)}
variant='vertical-bordered'
summary={summaryMode === 'multi' ? createMultiSummary(position) : createSummary(position)}
/>
</DataGridContainer>
</>
);
}
export default SummaryExample;import * as React from 'react';
import './DataGridContainer.css';
interface DataGridContainerProps extends React.HTMLAttributes<HTMLDivElement> {
children?: React.ReactNode;
}
/**
* Keeps a DataGrid in a measured, fixed layout box.
*
* BGrid's rendered root is absolutely positioned within this relative
* container. This makes a ResizeObserver measurement authoritative when a
* surrounding flex or grid layout shrinks as well as when it expands.
*/
const DataGridContainer = React.forwardRef<HTMLDivElement, DataGridContainerProps>(
({ className, ...rest }, ref) => (
<div ref={ref} className={`data-grid-container ${className ?? ''}`.trim()} {...rest} />
),
);
DataGridContainer.displayName = 'DataGridContainer';
export default DataGridContainer;.data-grid-container {
position: relative;
width: 100%;
height: 400px;
overflow: hidden;
font-size: 13px;
}
.data-grid-container > .bgrid-root {
position: absolute;
inset: 0;
}import * as React from 'react';
export function useContainerSize(ref: React.MutableRefObject<HTMLElement | null>, additionalDeps: unknown[] = []) {
const [width, setWidth] = React.useState(0);
const [height, setHeight] = React.useState(0);
const resizeObserver = React.useRef(
new ResizeObserver(entries => {
if (entries.length !== 1) {
throw new Error('Invalid Container length');
}
const [entry] = entries;
const { width, height } = entry.contentRect;
setWidth(width);
setHeight(height);
}),
);
React.useEffect(() => {
if (!ref.current) return;
const observer = resizeObserver.current;
const element = ref.current;
setWidth(element.clientWidth);
setHeight(element.clientHeight);
observer.observe(element);
return () => {
observer.unobserve(element);
};
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [...additionalDeps, ref]);
return {
width,
height,
};
}Summary 설정
summary는 일반 <table>이나 <tr>을 직접 반환하는 렌더 prop이 아닙니다. 표시 위치(position)와 행별 요약 정의 배열(rows)을 전달합니다. 평균과 합계처럼 여러 줄의 요약행(Multi-row Summary) 도 손쉽게 구성할 수 있습니다.
const summary: BGridProps<Row>['summary'] = {
position: 'bottom', // 'top' | 'bottom'
rows: [
// 1행: 평균
{
className: 'summary-avg-row',
columns: [
{ columnIndex: 0, colSpan: 2, align: 'center', itemRender: () => <>평균</> },
{
columnIndex: 2,
align: 'right',
itemRender: ({ data }) => (
<>{Math.round(data.reduce((sum, item) => sum + item.values.amount, 0) / (data.length || 1)).toLocaleString()}</>
),
},
],
},
// 2행: 합계
{
className: 'summary-total-row',
style: { fontWeight: 'bold' },
columns: [
{ columnIndex: 0, colSpan: 2, align: 'center', itemRender: () => <>합계</> },
{
columnIndex: 2,
align: 'right',
itemRender: ({ data }) => (
<>{data.reduce((sum, item) => sum + item.values.amount, 0).toLocaleString()}</>
),
},
],
},
],
};
<BGrid
summary={summary}
summaryRowHeight={32} // 또는 (rowIndex) => (rowIndex === 1 ? 36 : 28)
{...props}
/>
주요 옵션 및 속성
최상위 Summary 옵션 (summary)
| 속성 | 타입 | 설명 |
|---|---|---|
position |
'top' | 'bottom' |
요약 행을 데이터 영역 위(top) 또는 아래(bottom)에 배치합니다. |
rows |
BGridSummaryRow<T>[] |
요약 행 목록입니다. 여러 줄의 요약행을 정의할 때 사용합니다. (권장) |
columns |
BGridSummaryColumn<T>[] |
단일 요약행을 위한 하위 호환용 간편 속성입니다. |
요약 행 옵션 (BGridSummaryRow<T>)
| 속성 | 타입 | 설명 |
|---|---|---|
columns |
BGridSummaryColumn<T>[] |
해당 행에 표시될 컬럼 요약 셀 정의 배열입니다. |
className |
string |
요약 <tr> 요소에 적용할 커스텀 CSS 클래스명입니다. |
style |
CSSProperties |
요약 <tr> 요소에 적용할 인라인 스타일입니다. |
요약 셀 옵션 (BGridSummaryColumn<T>)
| 속성 | 타입 | 설명 |
|---|---|---|
columnIndex |
number |
요약 셀이 시작될 컬럼의 0 기반 인덱스입니다. |
colSpan |
number |
요약 셀이 가로로 차지할 컬럼 수입니다. |
align |
'left' | 'center' | 'right' |
요약 셀 내부 텍스트 정렬입니다. |
className |
string |
요약 셀(<td>)에 적용할 커스텀 클래스명입니다. |
style |
CSSProperties |
요약 셀(<td>)에 적용할 인라인 스타일입니다. |
itemRender |
(props) => ReactNode |
요약 셀 내용을 렌더링하는 함수입니다. { column, columnIndex, rowIndex, data } 인자가 제공됩니다. |
요약 영역 높이 설정
| 최상위 Prop | 타입 | 설명 |
|---|---|---|
summaryRowHeight |
number | ((rowIndex: number) => number) |
요약 행의 개별 높이를 지정합니다. 숫자로 지정하면 모든 요약행에 균등 적용되며, 함수로 지정하면 행 인덱스(rowIndex)별로 서로 다른 높이를 적용할 수 있습니다. (기본값: 30px) |
summaryHeight |
number |
전체 요약 영역 높이를 고정할 때 사용합니다. 다중 요약행에서는 행 개수에 맞춰 균등 분할됩니다. |
팁 & 주의사항
- 데이터 참조 (
data):itemRender의data는BGridDataItem<T>[]타입이므로 실제 데이터 필드는item.values를 통해 안전하게 접근합니다. - 단일행 하위 호환성: 기존의
summary={{ position: 'bottom', columns: [...] }}형태도 100% 동일하게 동작합니다. 신규 코드에서는 다중행 확장이 용이한rows: [...]형태를 권장합니다. - 빈 공간 테두리 처리: 요약 컬럼으로 채워지지 않은 우측 여백 셀은 그리드에서 자동으로 비워지며 깔끔한 레이아웃을 위해 불필요한 테두리가 제거됩니다.