공용 DataGrid — logicstudio 스타일 toolbar + footer 통계 + 차트 분석 패널 흡수, 견적관리 적용

DataGrid:
- ⟳ Refresh · ⬇ Download · ⚙️ 컬럼 표시 설정 · 📊 차트 분석 toolbar
- 컬럼 visibility 토글 (데이터/시스템 그룹 분리 + 표시·순서·너비 reset)
- summaryStats 하단 통계 행 (라벨/값/접미사)
- paginationStyle 'range' — "1-N / 총 X건" + 페이지 크기 Select
- 행 높이 컴팩트화 (h-7 + py-0 + leading-none, 아이콘 h-3.5)
- sticky 헤더 불투명 배경(bg-muted)으로 스크롤 시 본문 비침 차단
- ⋮⋮ 드래그 핸들 항상 표시

DataGridChartPanel (신규):
- 여러 차트 추가/삭제, 제목 인라인 편집, 드래그 순서 변경
- Bar/Line/Pie + X/Y축 선택 + count/sum/avg/min/max 집계
- localStorage 영속, 360px 고정 높이 + 내부 스크롤

견적관리:
- 컬럼 폭 조정 (⋮⋮ 추가로 좁아진 한국어 4글자 라벨 보장)
- summaryStats, onRefresh, onDownload(exportToExcel) 연결
- gridId="sales-estimate"로 영속화

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
hjjeong
2026-05-14 14:42:52 +09:00
parent 2b4282fbeb
commit 36c1f3579e
3 changed files with 703 additions and 45 deletions
@@ -26,30 +26,31 @@ import { ItemSearchDialog, ItemRow } from "@/components/common/ItemSearchDialog"
import { AttachmentDialog } from "@/components/common/AttachmentDialog";
import { EstimateMailDialog } from "@/components/sales/EstimateMailDialog";
import { salesEstimateApi, EstimateRow, EstimateBody, EstimateItem } from "@/lib/api/salesEstimate";
import { exportToExcel } from "@/lib/utils/excelExport";
// ─── 컬럼 ─────────────────────────────────────────────────────
// wace_plm 원본 견적관리 그리드 컬럼 순서를 그대로 따름
const GRID_COLUMNS: DataGridColumn[] = [
{ key: "contract_no", label: "영업번호", width: "w-[125px]", frozen: true },
{ key: "category_name", label: "주문유형", width: "w-[90px]", align: "center" },
{ key: "category_name", label: "주문유형", width: "w-[115px]", align: "center" },
{ key: "receipt_date", label: "접수일", width: "w-[115px]", align: "center" },
{ key: "earliest_due_date_label", label: "요청납기", width: "w-[160px]", align: "center" },
{ key: "customer_name", label: "고객사", width: "w-[150px]" },
{ key: "item_summary", label: "품명", width: "w-[200px]" },
{ key: "estimate_quantity", label: "견적수량", width: "w-[80px]", align: "right", formatNumber: true },
{ key: "paid_type_name", label: "유/무상", width: "w-[70px]", align: "center" },
{ key: "est_total_amount", label: "공급가액", width: "w-[110px]", formatMoney: true },
{ key: "est_total_amount_krw", label: "원화환산공급가액", width: "w-[140px]", formatMoney: true },
{ key: "est_status", label: "견적현황", width: "w-[80px]", align: "center", renderType: "folder" },
{ key: "add_est_cnt", label: "추가견적", width: "w-[80px]", align: "center", renderType: "clip" },
{ key: "appr_status", label: "결재상태", width: "w-[90px]", align: "center" },
{ key: "mail_send_status_label", label: "메일발송", width: "w-[110px]", align: "center" },
{ key: "contract_currency_name", label: "환종", width: "w-[70px]", align: "center" },
{ key: "exchange_rate", label: "환율", width: "w-[80px]", formatMoney: true },
{ key: "estimate_quantity", label: "견적수량", width: "w-[115px]", align: "right", formatNumber: true },
{ key: "paid_type_name", label: "유/무상", width: "w-[100px]", align: "center" },
{ key: "est_total_amount", label: "공급가액", width: "w-[130px]", formatMoney: true },
{ key: "est_total_amount_krw", label: "원화환산공급가액", width: "w-[180px]", formatMoney: true },
{ key: "est_status", label: "견적현황", width: "w-[115px]", align: "center", renderType: "folder" },
{ key: "add_est_cnt", label: "추가견적", width: "w-[115px]", align: "center", renderType: "clip" },
{ key: "appr_status", label: "결재상태", width: "w-[115px]", align: "center" },
{ key: "mail_send_status_label", label: "메일발송", width: "w-[125px]", align: "center" },
{ key: "contract_currency_name", label: "환종", width: "w-[95px]", align: "center" },
{ key: "exchange_rate", label: "환율", width: "w-[95px]", formatMoney: true },
{ key: "serial_no", label: "S/N", width: "w-[140px]" },
{ key: "part_no", label: "품번", width: "w-[120px]" },
{ key: "writer_name", label: "작성자", width: "w-[100px]" },
{ key: "writer_name", label: "작성자", width: "w-[115px]" },
/* wace estimateList_new.jsp 494~502 — 비활성(주석) 컬럼. 활성화 시 아래 주석 해제.
{ key: "product_name", label: "제품구분", width: "w-[90px]", align: "center" },
{ key: "area_name", label: "국내/해외", width: "w-[90px]", align: "center" },
@@ -251,6 +252,23 @@ export default function SalesEstimatePage() {
useEffect(() => { fetchList(); }, [fetchList]);
// ─── 하단 통계 ──────────────────────────────────────────────
// 견적 건수 / 견적수량 합계 / 공급가액 합계 / 원화환산공급가액 합계
const estimateSummary = useMemo(() => {
const count = rows.length;
const qtySum = rows.reduce((acc, r) => acc + Number(r.estimate_quantity || 0), 0);
const amtSum = rows.reduce((acc, r) => acc + Number(r.est_total_amount || 0), 0);
const krwSum = rows.reduce((acc, r) => acc + Number(r.est_total_amount_krw || 0), 0);
const money = (n: number) => n.toLocaleString(undefined, { minimumFractionDigits: 2, maximumFractionDigits: 2 });
const intFmt = (n: number) => n.toLocaleString();
return [
{ label: "견적 건수", value: intFmt(count), suffix: "건" },
{ label: "견적수량 합계", value: intFmt(qtySum) },
{ label: "공급가액 합계", value: money(amtSum) },
{ label: "원화환산공급가액 합계", value: money(krwSum) },
];
}, [rows]);
// ─── 다이얼로그 열기 ────────────────────────────────────────
const openCreate = async () => {
@@ -567,6 +585,7 @@ export default function SalesEstimatePage() {
{/* 그리드 — 첫 컬럼 체크박스 (행 아무 셀 클릭으로 단일 선택, 클립/폴더 등 팝업 컬럼은 stopPropagation으로 제외) */}
<DataGrid
gridId="sales-estimate"
columns={gridColumns}
data={rows}
showCheckbox
@@ -583,6 +602,22 @@ export default function SalesEstimatePage() {
onRowDoubleClick={(row) => { setSelected(row); openEdit(); }}
emptyMessage={loading ? "조회 중..." : "데이터가 없습니다"}
loading={loading}
showColumnSettings
paginationStyle="range"
pageSizeOptions={[10, 15, 20, 50, 100]}
summaryStats={estimateSummary}
systemColumnKeys={["writer_name"]}
onRefresh={fetchList}
onDownload={() => {
if (rows.length === 0) { toast.info("내보낼 데이터가 없습니다."); return; }
const exportRows = rows.map((r) => {
const out: Record<string, any> = {};
GRID_COLUMNS.forEach((col) => { out[col.label] = r[col.key] ?? ""; });
return out;
});
exportToExcel(exportRows, "견적관리.xlsx", "견적관리");
}}
showChart
/>
{/* 등록/수정 Dialog */}
+290 -33
View File
@@ -22,7 +22,9 @@ import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@
import { Checkbox } from "@/components/ui/checkbox";
import { Popover, PopoverContent, PopoverTrigger } from "@/components/ui/popover";
import { Input } from "@/components/ui/input";
import { Filter, Check, Search, ImageIcon, X, ChevronLeft, ChevronRight, ChevronsLeft, ChevronsRight, Folder, Paperclip } from "lucide-react";
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
import { Filter, Check, Search, ImageIcon, X, ChevronLeft, ChevronRight, ChevronsLeft, ChevronsRight, Folder, Paperclip, Settings, GripVertical, RotateCw, Download as DownloadIcon, BarChart3 } from "lucide-react";
import { DataGridChartPanel } from "./DataGridChartPanel";
import { cn } from "@/lib/utils";
import { toast } from "sonner";
import { apiClient } from "@/lib/api/client";
@@ -76,6 +78,22 @@ export interface DataGridProps {
showPagination?: boolean;
/** 초기 페이지 크기 (기본: 50) */
defaultPageSize?: number;
/** 페이지네이션 라벨 포맷 — 'classic'(기본): "전체 N건", 'range': "1-N / 총 X건" */
paginationStyle?: "classic" | "range";
/** 페이지 크기 드롭다운 옵션. 지정 시 Input 대신 Select 렌더. 예: [10,15,20,50,100] */
pageSizeOptions?: number[];
/** 컬럼 표시/숨김 설정 드롭다운 활성화 (gear 아이콘) */
showColumnSettings?: boolean;
/** 시스템 컬럼으로 분리 표시할 key 배열. 표시 메뉴에서 '시스템 컬럼' 그룹으로 묶임 */
systemColumnKeys?: string[];
/** 하단 통계 행 — 평균/합계 등. footer에 좌측 배치 */
summaryStats?: Array<{ label: string; value: string | number; suffix?: string }>;
/** 재조회 콜백. 지정 시 그리드 상단 toolbar에 ⟳ 아이콘 표시 */
onRefresh?: () => void;
/** 다운로드 콜백 (Excel/CSV 등). 지정 시 그리드 상단 toolbar에 ⬇ 아이콘 표시 */
onDownload?: () => void;
/** 차트 분석 패널 활성화. 지정 시 toolbar에 📊 토글 + 그리드 하단에 패널 노출 */
showChart?: boolean;
}
const fmtNum = (val: any) => {
@@ -140,15 +158,19 @@ function SortableHeaderCell({
style={style}
className={cn(
widthPx == null && col.width, widthPx == null && col.minWidth,
"select-none relative",
"select-none relative group/th",
col.frozen && cn("sticky z-20 bg-background", frozenLeftClass),
)}
>
<div className="inline-flex items-center gap-1">
<div
<div className="inline-flex items-center gap-1 w-full">
<GripVertical
{...attributes}
{...listeners}
className="flex items-center gap-0.5 cursor-pointer min-w-0"
className="h-3 w-3 text-muted-foreground/50 shrink-0 cursor-grab active:cursor-grabbing"
aria-label="컬럼 드래그"
/>
<div
className="flex items-center gap-0.5 cursor-pointer min-w-0 flex-1"
onClick={(e) => {
e.stopPropagation();
if (col.sortable !== false) onSort(col.key);
@@ -273,10 +295,64 @@ export function DataGrid({
gridId,
showPagination = true,
defaultPageSize = 50,
paginationStyle = "classic",
pageSizeOptions,
showColumnSettings = false,
systemColumnKeys,
summaryStats,
onRefresh,
onDownload,
showChart = false,
}: DataGridProps) {
const [columns, setColumns] = useState(initialColumns);
useEffect(() => { setColumns(initialColumns); }, [initialColumns]);
// 차트 패널 열림 상태 (gridId 있으면 localStorage 영속)
const [chartPanelOpen, setChartPanelOpen] = useState(false);
useEffect(() => {
if (!gridId) return;
const saved = localStorage.getItem(`datagrid_chart_open_${gridId}`);
if (saved === "1") setChartPanelOpen(true);
}, [gridId]);
const toggleChartPanel = useCallback(() => {
setChartPanelOpen((prev) => {
const next = !prev;
if (gridId) {
try { localStorage.setItem(`datagrid_chart_open_${gridId}`, next ? "1" : "0"); } catch { /* skip */ }
}
return next;
});
}, [gridId]);
// 컬럼 visibility — 숨겨진 컬럼 key Set. localStorage 영속(gridId 있을 때).
const [hiddenColumns, setHiddenColumns] = useState<Set<string>>(new Set());
useEffect(() => {
if (!gridId) return;
const saved = localStorage.getItem(`datagrid_col_hidden_${gridId}`);
if (saved) {
try { setHiddenColumns(new Set(JSON.parse(saved) as string[])); } catch { /* skip */ }
}
}, [gridId]);
const toggleColumnVisibility = useCallback((key: string) => {
setHiddenColumns((prev) => {
const next = new Set(prev);
if (next.has(key)) next.delete(key); else next.add(key);
if (gridId) {
try { localStorage.setItem(`datagrid_col_hidden_${gridId}`, JSON.stringify(Array.from(next))); } catch { /* skip */ }
}
return next;
});
}, [gridId]);
const resetVisibility = useCallback(() => {
setHiddenColumns(new Set());
if (gridId) try { localStorage.removeItem(`datagrid_col_hidden_${gridId}`); } catch { /* skip */ }
}, [gridId]);
const resetOrder = useCallback(() => {
setColumns(initialColumns);
if (gridId) try { localStorage.removeItem(`datagrid_col_order_${gridId}`); } catch { /* skip */ }
}, [gridId, initialColumns]);
// resetWidthsAll은 setColumnWidths 선언 이후에 정의 (아래에서)
// 정렬
const [sortKey, setSortKey] = useState<string | null>(null);
const [sortDir, setSortDir] = useState<"asc" | "desc">("asc");
@@ -331,6 +407,16 @@ export function DataGrid({
try { localStorage.setItem(`datagrid_col_widths_${gridId}`, JSON.stringify(next)); } catch { /* skip */ }
}
}, [gridId]);
const resetWidthsAll = useCallback(() => {
setColumnWidths({});
if (gridId) try { localStorage.removeItem(`datagrid_col_widths_${gridId}`); } catch { /* skip */ }
}, [gridId]);
// 가시 컬럼 — hiddenColumns 적용. 헤더/바디 렌더에 사용. DnD/순서 영속은 전체 columns 기준 유지.
const visibleColumns = useMemo(
() => columns.filter((c) => !hiddenColumns.has(c.key)),
[columns, hiddenColumns],
);
// 리사이즈 드래그 핸들러 — 헤더 우측 핸들에서 mousedown 발생 시 호출.
const startResize = useCallback((e: React.MouseEvent, colKey: string, currentWidthPx: number) => {
@@ -629,7 +715,7 @@ export function DataGrid({
const isRightAlign = col.align === "right" || isNumericCol;
return (
<span className={cn("text-xs", truncateClass, isRightAlign && "text-right w-full inline-block")}
<span className={cn("text-xs leading-tight", truncateClass, isRightAlign && "text-right w-full inline-block")}
title={String(val ?? "")}>
{display}
</span>
@@ -637,22 +723,135 @@ export function DataGrid({
};
// 좌측 고정 보조: NO/체크박스 컬럼은 40px(=left-10), 첫 frozen 컬럼은 그 다음 위치
const hasFrozen = columns.some((c) => c.frozen);
const hasFrozen = visibleColumns.some((c) => c.frozen);
const hasFirstCol = showCheckbox || showRowNumber;
const stickyFirstColClass = "sticky left-0 z-20 bg-background";
const stickyFirstColBodyClass = "sticky left-0 z-[6]";
const frozenLeftClass = hasFirstCol ? "left-10" : "left-0";
// 컬럼 settings dropdown — 데이터/시스템 그룹 분리. 시스템 키는 systemColumnKeys로 지정.
const systemKeySet = useMemo(() => new Set(systemColumnKeys ?? []), [systemColumnKeys]);
const dataCols = useMemo(() => columns.filter((c) => !systemKeySet.has(c.key)), [columns, systemKeySet]);
const systemCols = useMemo(() => columns.filter((c) => systemKeySet.has(c.key)), [columns, systemKeySet]);
const hasToolbar = showColumnSettings || onRefresh || onDownload || showChart;
return (
<div className="flex flex-col h-full flex-1 min-h-0">
{hasToolbar && (
<div className="flex items-center justify-end gap-0.5 border-b bg-muted/20 px-2 py-1 shrink-0">
{onRefresh && (
<button
type="button"
onClick={onRefresh}
className="h-7 w-7 inline-flex items-center justify-center rounded hover:bg-muted text-muted-foreground"
title="재조회"
>
<RotateCw className="h-4 w-4" />
</button>
)}
{showChart && (
<button
type="button"
onClick={toggleChartPanel}
className={cn(
"h-7 w-7 inline-flex items-center justify-center rounded hover:bg-muted",
chartPanelOpen ? "text-primary bg-primary/10" : "text-muted-foreground",
)}
title="차트 분석"
>
<BarChart3 className="h-4 w-4" />
</button>
)}
{onDownload && (
<button
type="button"
onClick={onDownload}
className="h-7 w-7 inline-flex items-center justify-center rounded hover:bg-muted text-muted-foreground"
title="Excel 다운로드"
>
<DownloadIcon className="h-4 w-4" />
</button>
)}
{showColumnSettings && (
<Popover>
<PopoverTrigger asChild>
<button
type="button"
className="h-7 w-7 inline-flex items-center justify-center rounded hover:bg-muted text-muted-foreground"
title="컬럼 표시 설정"
>
<Settings className="h-4 w-4" />
</button>
</PopoverTrigger>
<PopoverContent className="w-64 p-0" align="end">
<div className="px-3 py-2 border-b">
<span className="text-sm font-semibold"> </span>
</div>
<div className="max-h-[60vh] overflow-y-auto px-2 py-2 space-y-3">
{dataCols.length > 0 && (
<div>
<div className="text-[11px] font-medium text-muted-foreground px-1 mb-1"> </div>
<div className="space-y-0.5">
{dataCols.map((col) => {
const checked = !hiddenColumns.has(col.key);
return (
<label
key={col.key}
className="flex items-center gap-2 px-1.5 py-1 rounded hover:bg-muted cursor-pointer text-sm"
>
<Checkbox checked={checked} onCheckedChange={() => toggleColumnVisibility(col.key)} />
<span className="truncate">{col.label}</span>
</label>
);
})}
</div>
</div>
)}
{systemCols.length > 0 && (
<div>
<div className="text-[11px] font-medium text-muted-foreground px-1 mb-1"> </div>
<div className="space-y-0.5">
{systemCols.map((col) => {
const checked = !hiddenColumns.has(col.key);
return (
<label
key={col.key}
className="flex items-center gap-2 px-1.5 py-1 rounded hover:bg-muted cursor-pointer text-sm"
>
<Checkbox checked={checked} onCheckedChange={() => toggleColumnVisibility(col.key)} />
<span className="truncate">{col.label}</span>
</label>
);
})}
</div>
</div>
)}
</div>
<div className="border-t px-2 py-1.5 flex flex-col items-start gap-0.5">
<button onClick={resetVisibility} className="text-primary text-xs hover:underline px-1.5 py-0.5">
/
</button>
<button onClick={resetOrder} className="text-primary text-xs hover:underline px-1.5 py-0.5">
</button>
<button onClick={resetWidthsAll} className="text-primary text-xs hover:underline px-1.5 py-0.5">
</button>
</div>
</PopoverContent>
</Popover>
)}
</div>
)}
<div className="flex-1 min-h-0 overflow-auto">
<DndContext sensors={sensors} collisionDetection={closestCenter} onDragEnd={handleDragEnd}>
<Table noWrapper className="table-fixed">
<TableHeader className="sticky top-0 bg-background z-10 shadow-[0_1px_0_0_hsl(var(--border))]">
<SortableContext items={columns.map((c) => c.key)} strategy={horizontalListSortingStrategy}>
<TableHeader className="sticky top-0 bg-muted z-10 shadow-[0_1px_0_0_hsl(var(--border))] [&_th]:h-9 [&_th]:bg-muted">
<SortableContext items={visibleColumns.map((c) => c.key)} strategy={horizontalListSortingStrategy}>
<TableRow>
{showCheckbox && (
<TableHead className={cn("w-[40px] text-center", hasFrozen && stickyFirstColClass)}>
<TableHead className={cn("w-[40px] text-center ", hasFrozen && stickyFirstColClass)}>
<Checkbox
checked={processedData.length > 0 && checkedIds.length === processedData.length}
onCheckedChange={(checked) => {
@@ -662,9 +861,9 @@ export function DataGrid({
</TableHead>
)}
{showRowNumber && !showCheckbox && (
<TableHead className={cn("w-[40px] text-center text-xs", hasFrozen && stickyFirstColClass)}>No</TableHead>
<TableHead className={cn("w-[40px] text-center text-xs ", hasFrozen && stickyFirstColClass)}>No</TableHead>
)}
{columns.map((col) => (
{visibleColumns.map((col) => (
<SortableHeaderCell
key={col.key}
col={col}
@@ -683,16 +882,16 @@ export function DataGrid({
</TableRow>
</SortableContext>
</TableHeader>
<TableBody>
<TableBody className="[&_tr]:!h-7 [&_td]:!py-0 [&_td]:!h-7 [&_td]:!leading-none [&_td_*]:!leading-none [&_td_svg]:!h-3.5 [&_td_svg]:!w-3.5">
{loading ? (
<TableRow>
<TableCell colSpan={columns.length + 1} className="text-center py-8 text-muted-foreground">
<TableCell colSpan={visibleColumns.length + 1} className="text-center py-8 text-muted-foreground">
...
</TableCell>
</TableRow>
) : paginatedData.length === 0 ? (
<TableRow>
<TableCell colSpan={columns.length + 1} className="text-center py-8 text-muted-foreground">
<TableCell colSpan={visibleColumns.length + 1} className="text-center py-8 text-muted-foreground">
{emptyMessage}
</TableCell>
</TableRow>
@@ -704,6 +903,7 @@ export function DataGrid({
return (
<TableRow
key={row.id || rowIdx}
style={{ height: 28 }}
className={cn("cursor-pointer group",
isSelected && "bg-accent text-accent-foreground",
)}
@@ -723,7 +923,7 @@ export function DataGrid({
{showCheckbox && (
<TableCell
className={cn(
"text-center",
"text-center ",
isSelected && "bg-accent",
hasFrozen && cn(stickyFirstColBodyClass, stickyBgClass),
)}
@@ -742,14 +942,14 @@ export function DataGrid({
)}
{showRowNumber && !showCheckbox && (
<TableCell className={cn(
"text-center text-[10px] text-muted-foreground",
"text-center text-[10px] text-muted-foreground ",
isSelected && "bg-accent",
hasFrozen && cn(stickyFirstColBodyClass, stickyBgClass),
)}>
{pageOffset + rowIdx + 1}
</TableCell>
)}
{columns.map((col) => {
{visibleColumns.map((col) => {
const w = columnWidths[col.key];
const inlineStyle = w != null ? { width: w, minWidth: w, maxWidth: w } : undefined;
// 일반 텍스트 셀에 컬럼 단위 onClick 지원 (clip/folder 외)
@@ -759,7 +959,7 @@ export function DataGrid({
key={col.key}
style={inlineStyle}
className={cn(
w == null && col.width, w == null && col.minWidth, "py-2.5",
w == null && col.width, w == null && col.minWidth, "py-1",
col.editable && "cursor-text",
cellClickable && "cursor-pointer hover:underline text-primary",
isSelected && "bg-accent",
@@ -784,27 +984,79 @@ export function DataGrid({
</DndContext>
</div>
{/* 하단 통계 행 — summaryStats 지정 시. 페이지네이션 footer 위에 별도 띠로 노출. */}
{summaryStats && summaryStats.length > 0 && (
<div className="flex flex-wrap items-center gap-x-4 gap-y-1 border-t bg-muted/30 px-3 py-1.5 text-xs shrink-0">
{summaryStats.map((s, i) => (
<div key={`${s.label}-${i}`} className="inline-flex items-center gap-1.5">
<span className="text-muted-foreground">{s.label}</span>
<span className="font-semibold text-foreground tabular-nums">{s.value}{s.suffix ?? ""}</span>
</div>
))}
</div>
)}
{/* 페이지네이션 footer */}
{showPagination && (
<div className="flex items-center justify-between border-t px-3 py-2 text-xs text-muted-foreground shrink-0">
{/* 좌측: 데이터 수량 + 페이지 크기 입력 */}
{/* 좌측: 데이터 수량 + 페이지 크기 입력/Select */}
<div className="flex items-center gap-3">
<div className="flex items-center gap-1">
<span></span>
<span className="font-medium text-foreground">{totalItems.toLocaleString()}</span>
<span></span>
{paginationStyle === "range" ? (
<>
<span className="font-medium text-foreground tabular-nums">
{totalItems === 0 ? 0 : (pageOffset + 1).toLocaleString()}-{(pageOffset + paginatedData.length).toLocaleString()}
</span>
<span>/ </span>
<span className="font-medium text-foreground tabular-nums">{totalItems.toLocaleString()}</span>
<span></span>
</>
) : (
<>
<span></span>
<span className="font-medium text-foreground">{totalItems.toLocaleString()}</span>
<span></span>
</>
)}
</div>
<div className="flex items-center gap-1.5">
<Input
type="number"
min={1}
value={pageSizeInput}
onChange={(e) => setPageSizeInput(e.target.value)}
onBlur={applyPageSize}
onKeyDown={(e) => { if (e.key === "Enter") { e.preventDefault(); applyPageSize(); } }}
className="h-7 w-16 text-center text-xs"
/>
<span> </span>
{pageSizeOptions && pageSizeOptions.length > 0 ? (
<Select
value={String(pageSize)}
onValueChange={(v) => {
const n = Number(v);
if (!isNaN(n) && n >= 1) {
setPageSize(n);
setPageSizeInput(String(n));
setCurrentPage(1);
}
}}
>
<SelectTrigger className="h-7 w-[88px] text-xs">
<SelectValue />
</SelectTrigger>
<SelectContent>
{pageSizeOptions.map((n) => (
<SelectItem key={n} value={String(n)} className="text-xs">
{n} /
</SelectItem>
))}
</SelectContent>
</Select>
) : (
<>
<Input
type="number"
min={1}
value={pageSizeInput}
onChange={(e) => setPageSizeInput(e.target.value)}
onBlur={applyPageSize}
onKeyDown={(e) => { if (e.key === "Enter") { e.preventDefault(); applyPageSize(); } }}
className="h-7 w-16 text-center text-xs"
/>
<span> </span>
</>
)}
</div>
</div>
@@ -854,6 +1106,11 @@ export function DataGrid({
</div>
)}
{/* 차트 분석 패널 — showChart + chartPanelOpen 시 노출 */}
{showChart && chartPanelOpen && (
<DataGridChartPanel gridId={gridId} columns={columns} data={processedData} />
)}
{/* 이미지 확대 모달 */}
{previewImage && (
<div className="fixed inset-0 z-[9999] flex items-center justify-center bg-black/70"
@@ -0,0 +1,366 @@
"use client";
/**
* DataGridChartPanel — DataGrid 하단 차트 분석 패널.
*
* 기능:
* - 여러 차트 동시 관리 (추가/삭제/제목 편집/드래그 순서 변경)
* - 차트 타입: Bar / Line / Pie
* - X축 컬럼 선택 (모든 컬럼)
* - Y축 컬럼 선택 (숫자 컬럼)
* - 집계: count / sum / avg / min / max
* - localStorage 영속 (gridId 기준)
*/
import React, { useCallback, useEffect, useMemo, useState } from "react";
import {
BarChart, Bar, LineChart, Line, PieChart, Pie, Cell,
XAxis, YAxis, CartesianGrid, Tooltip, Legend, ResponsiveContainer,
} from "recharts";
import {
DndContext, closestCenter, PointerSensor, useSensor, useSensors, type DragEndEvent,
} from "@dnd-kit/core";
import { SortableContext, useSortable, arrayMove, verticalListSortingStrategy } from "@dnd-kit/sortable";
import { CSS } from "@dnd-kit/utilities";
import { Plus, X, Pencil, ChevronDown, ChevronUp, GripVertical } from "lucide-react";
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
import { Input } from "@/components/ui/input";
import { cn } from "@/lib/utils";
import type { DataGridColumn } from "./DataGrid";
type AggType = "count" | "sum" | "avg" | "min" | "max";
type ChartType = "bar" | "line" | "pie";
interface ChartConfig {
id: string;
title: string;
type: ChartType;
xKey: string;
yKey: string;
agg: AggType;
}
const AGG_LABEL: Record<AggType, string> = {
count: "건수", sum: "합계", avg: "평균", min: "최소", max: "최대",
};
const CHART_TYPE_LABEL: Record<ChartType, string> = {
bar: "막대", line: "선", pie: "원형",
};
const PIE_COLORS = ["#3b82f6", "#f59e0b", "#10b981", "#ef4444", "#8b5cf6", "#ec4899", "#06b6d4", "#84cc16"];
function newChartConfig(columns: DataGridColumn[]): ChartConfig {
const numericCols = columns.filter((c) => c.formatNumber || c.formatMoney || c.inputType === "number");
const firstCat = columns.find((c) => !numericCols.includes(c));
const firstNum = numericCols[0];
return {
id: `chart-${Date.now()}-${Math.random().toString(36).slice(2, 8)}`,
title: "제목 없는 차트",
type: "bar",
xKey: firstCat?.key ?? columns[0]?.key ?? "",
yKey: firstNum?.key ?? "",
agg: firstNum ? "sum" : "count",
};
}
function aggregate(
rows: Record<string, any>[],
xKey: string,
yKey: string,
agg: AggType,
): Array<{ name: string; value: number }> {
const groups: Record<string, number[]> = {};
for (const row of rows) {
const xVal = row[xKey] == null || row[xKey] === "" ? "(빈 값)" : String(row[xKey]);
if (!groups[xVal]) groups[xVal] = [];
if (agg === "count") {
groups[xVal].push(1);
} else {
const raw = row[yKey];
const n = typeof raw === "number" ? raw : Number(String(raw ?? "").replace(/,/g, ""));
if (!isNaN(n)) groups[xVal].push(n);
}
}
return Object.entries(groups).map(([name, vals]) => {
let v: number;
if (agg === "count") v = vals.length;
else if (agg === "sum") v = vals.reduce((a, b) => a + b, 0);
else if (agg === "avg") v = vals.length > 0 ? vals.reduce((a, b) => a + b, 0) / vals.length : 0;
else if (agg === "min") v = vals.length > 0 ? Math.min(...vals) : 0;
else v = vals.length > 0 ? Math.max(...vals) : 0;
return { name, value: Number(v.toFixed(2)) };
});
}
// ─── 단일 차트 카드 ─────────────────────────────────────────────
interface ChartCardProps {
cfg: ChartConfig;
columns: DataGridColumn[];
data: Record<string, any>[];
onChange: (next: ChartConfig) => void;
onDelete: () => void;
}
function SortableChartCard({ cfg, columns, data, onChange, onDelete }: ChartCardProps) {
const { attributes, listeners, setNodeRef, transform, transition, isDragging } = useSortable({ id: cfg.id });
const [editingTitle, setEditingTitle] = useState(false);
const [titleDraft, setTitleDraft] = useState(cfg.title);
const numericCols = useMemo(
() => columns.filter((c) => c.formatNumber || c.formatMoney || c.inputType === "number"),
[columns],
);
const chartData = useMemo(
() => aggregate(data, cfg.xKey, cfg.yKey, cfg.agg),
[data, cfg.xKey, cfg.yKey, cfg.agg],
);
const style: React.CSSProperties = {
transform: CSS.Transform.toString(transform),
transition,
opacity: isDragging ? 0.5 : 1,
};
return (
<div
ref={setNodeRef}
style={style}
className="rounded-md border bg-card text-card-foreground shadow-sm"
>
{/* 카드 헤더: 드래그 핸들 + 제목 + 삭제 */}
<div className="flex items-center gap-2 border-b px-3 py-2">
<button
{...attributes}
{...listeners}
className="cursor-grab active:cursor-grabbing text-muted-foreground"
title="드래그하여 순서 변경"
type="button"
>
<GripVertical className="h-4 w-4" />
</button>
{editingTitle ? (
<Input
value={titleDraft}
autoFocus
onChange={(e) => setTitleDraft(e.target.value)}
onBlur={() => { onChange({ ...cfg, title: titleDraft.trim() || "제목 없는 차트" }); setEditingTitle(false); }}
onKeyDown={(e) => {
if (e.key === "Enter") { onChange({ ...cfg, title: titleDraft.trim() || "제목 없는 차트" }); setEditingTitle(false); }
if (e.key === "Escape") { setTitleDraft(cfg.title); setEditingTitle(false); }
}}
className="h-7 text-sm flex-1"
/>
) : (
<button
type="button"
onClick={() => { setTitleDraft(cfg.title); setEditingTitle(true); }}
className="text-sm font-medium hover:underline flex-1 text-left truncate"
title="클릭하여 제목 편집"
>
{cfg.title}
<Pencil className="inline ml-1.5 h-3 w-3 text-muted-foreground/60" />
</button>
)}
<button
type="button"
onClick={onDelete}
className="h-7 w-7 inline-flex items-center justify-center rounded hover:bg-muted text-muted-foreground"
title="차트 삭제"
>
<X className="h-4 w-4" />
</button>
</div>
{/* 컨트롤 행 */}
<div className="flex flex-wrap items-center gap-2 px-3 py-2 border-b bg-muted/20 text-xs">
<span className="text-muted-foreground"></span>
<Select value={cfg.type} onValueChange={(v) => onChange({ ...cfg, type: v as ChartType })}>
<SelectTrigger className="h-7 w-[80px] text-xs"><SelectValue /></SelectTrigger>
<SelectContent>
{(["bar", "line", "pie"] as ChartType[]).map((t) => (
<SelectItem key={t} value={t} className="text-xs">{CHART_TYPE_LABEL[t]}</SelectItem>
))}
</SelectContent>
</Select>
<span className="text-muted-foreground ml-2">X축</span>
<Select value={cfg.xKey} onValueChange={(v) => onChange({ ...cfg, xKey: v })}>
<SelectTrigger className="h-7 w-[140px] text-xs"><SelectValue /></SelectTrigger>
<SelectContent>
{columns.map((c) => (
<SelectItem key={c.key} value={c.key} className="text-xs">{c.label}</SelectItem>
))}
</SelectContent>
</Select>
<span className="text-muted-foreground ml-2"></span>
<Select value={cfg.agg} onValueChange={(v) => onChange({ ...cfg, agg: v as AggType })}>
<SelectTrigger className="h-7 w-[80px] text-xs"><SelectValue /></SelectTrigger>
<SelectContent>
{(["count", "sum", "avg", "min", "max"] as AggType[]).map((a) => (
<SelectItem key={a} value={a} className="text-xs">{AGG_LABEL[a]}</SelectItem>
))}
</SelectContent>
</Select>
{cfg.agg !== "count" && (
<>
<span className="text-muted-foreground ml-2">Y축</span>
<Select value={cfg.yKey} onValueChange={(v) => onChange({ ...cfg, yKey: v })}>
<SelectTrigger className="h-7 w-[140px] text-xs"><SelectValue placeholder="숫자 컬럼 선택" /></SelectTrigger>
<SelectContent>
{numericCols.length === 0 ? (
<div className="px-2 py-1 text-xs text-muted-foreground"> </div>
) : numericCols.map((c) => (
<SelectItem key={c.key} value={c.key} className="text-xs">{c.label}</SelectItem>
))}
</SelectContent>
</Select>
</>
)}
</div>
{/* 차트 본체 */}
<div className="p-3">
{chartData.length === 0 ? (
<div className="h-[240px] flex items-center justify-center text-muted-foreground text-xs">
</div>
) : (
<ResponsiveContainer width="100%" height={240}>
{cfg.type === "bar" ? (
<BarChart data={chartData}>
<CartesianGrid strokeDasharray="3 3" stroke="hsl(var(--border))" />
<XAxis dataKey="name" tick={{ fontSize: 11 }} />
<YAxis tick={{ fontSize: 11 }} />
<Tooltip />
<Bar dataKey="value" fill="#3b82f6" name={`${columns.find((c) => c.key === cfg.yKey)?.label ?? ""} ${AGG_LABEL[cfg.agg]}`} />
</BarChart>
) : cfg.type === "line" ? (
<LineChart data={chartData}>
<CartesianGrid strokeDasharray="3 3" stroke="hsl(var(--border))" />
<XAxis dataKey="name" tick={{ fontSize: 11 }} />
<YAxis tick={{ fontSize: 11 }} />
<Tooltip />
<Line type="monotone" dataKey="value" stroke="#3b82f6" strokeWidth={2} name={`${columns.find((c) => c.key === cfg.yKey)?.label ?? ""} ${AGG_LABEL[cfg.agg]}`} />
</LineChart>
) : (
<PieChart>
<Pie data={chartData} dataKey="value" nameKey="name" cx="50%" cy="50%" outerRadius={80} label={{ fontSize: 11 }}>
{chartData.map((_, idx) => <Cell key={idx} fill={PIE_COLORS[idx % PIE_COLORS.length]} />)}
</Pie>
<Tooltip />
<Legend wrapperStyle={{ fontSize: 11 }} />
</PieChart>
)}
</ResponsiveContainer>
)}
</div>
</div>
);
}
// ─── 패널 본체 ──────────────────────────────────────────────────
interface DataGridChartPanelProps {
/** localStorage 키 prefix용 grid ID. 미지정 시 영속 비활성 */
gridId?: string;
columns: DataGridColumn[];
data: Record<string, any>[];
}
export function DataGridChartPanel({ gridId, columns, data }: DataGridChartPanelProps) {
const [charts, setCharts] = useState<ChartConfig[]>([]);
const [collapsed, setCollapsed] = useState(false);
const storageKey = gridId ? `datagrid_charts_${gridId}` : null;
// 영속 로드
useEffect(() => {
if (!storageKey) return;
try {
const saved = localStorage.getItem(storageKey);
if (saved) {
const parsed = JSON.parse(saved) as ChartConfig[];
if (Array.isArray(parsed)) setCharts(parsed);
}
} catch { /* skip */ }
}, [storageKey]);
const persist = useCallback((next: ChartConfig[]) => {
setCharts(next);
if (storageKey) {
try { localStorage.setItem(storageKey, JSON.stringify(next)); } catch { /* skip */ }
}
}, [storageKey]);
const addChart = () => persist([...charts, newChartConfig(columns)]);
const updateChart = (id: string, next: ChartConfig) => persist(charts.map((c) => c.id === id ? next : c));
const deleteChart = (id: string) => persist(charts.filter((c) => c.id !== id));
const sensors = useSensors(useSensor(PointerSensor, { activationConstraint: { distance: 8 } }));
const handleDragEnd = (e: DragEndEvent) => {
const { active, over } = e;
if (!over || active.id === over.id) return;
const oldIdx = charts.findIndex((c) => c.id === active.id);
const newIdx = charts.findIndex((c) => c.id === over.id);
if (oldIdx < 0 || newIdx < 0) return;
persist(arrayMove(charts, oldIdx, newIdx));
};
return (
<div className={cn("border-t bg-muted/10 flex flex-col shrink-0", collapsed ? "" : "h-[360px]")}>
{/* 패널 헤더 */}
<div className="flex items-center justify-between px-3 py-2 border-b shrink-0">
<div className="flex items-center gap-2">
<button
type="button"
onClick={() => setCollapsed((p) => !p)}
className="h-6 w-6 inline-flex items-center justify-center rounded hover:bg-muted text-muted-foreground"
title={collapsed ? "펼치기" : "접기"}
>
{collapsed ? <ChevronDown className="h-4 w-4" /> : <ChevronUp className="h-4 w-4" />}
</button>
<span className="text-sm font-semibold"> ({charts.length})</span>
</div>
<button
type="button"
onClick={addChart}
className="h-7 inline-flex items-center gap-1 px-2 rounded hover:bg-muted text-muted-foreground text-xs"
title="차트 추가"
>
<Plus className="h-3.5 w-3.5" />
</button>
</div>
{/* 차트 카드들 — 내부 스크롤 */}
{!collapsed && (
<div className="flex-1 min-h-0 overflow-y-auto p-3">
{charts.length === 0 ? (
<div className="py-8 text-center text-muted-foreground text-xs">
+ .
</div>
) : (
<DndContext sensors={sensors} collisionDetection={closestCenter} onDragEnd={handleDragEnd}>
<SortableContext items={charts.map((c) => c.id)} strategy={verticalListSortingStrategy}>
<div className={cn("grid gap-3", charts.length >= 2 ? "grid-cols-1 lg:grid-cols-2" : "grid-cols-1")}>
{charts.map((cfg) => (
<SortableChartCard
key={cfg.id}
cfg={cfg}
columns={columns}
data={data}
onChange={(next) => updateChart(cfg.id, next)}
onDelete={() => deleteChart(cfg.id)}
/>
))}
</div>
</SortableContext>
</DndContext>
)}
</div>
)}
</div>
);
}