공용 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:
@@ -26,30 +26,31 @@ import { ItemSearchDialog, ItemRow } from "@/components/common/ItemSearchDialog"
|
|||||||
import { AttachmentDialog } from "@/components/common/AttachmentDialog";
|
import { AttachmentDialog } from "@/components/common/AttachmentDialog";
|
||||||
import { EstimateMailDialog } from "@/components/sales/EstimateMailDialog";
|
import { EstimateMailDialog } from "@/components/sales/EstimateMailDialog";
|
||||||
import { salesEstimateApi, EstimateRow, EstimateBody, EstimateItem } from "@/lib/api/salesEstimate";
|
import { salesEstimateApi, EstimateRow, EstimateBody, EstimateItem } from "@/lib/api/salesEstimate";
|
||||||
|
import { exportToExcel } from "@/lib/utils/excelExport";
|
||||||
|
|
||||||
// ─── 컬럼 ─────────────────────────────────────────────────────
|
// ─── 컬럼 ─────────────────────────────────────────────────────
|
||||||
|
|
||||||
// wace_plm 원본 견적관리 그리드 컬럼 순서를 그대로 따름
|
// wace_plm 원본 견적관리 그리드 컬럼 순서를 그대로 따름
|
||||||
const GRID_COLUMNS: DataGridColumn[] = [
|
const GRID_COLUMNS: DataGridColumn[] = [
|
||||||
{ key: "contract_no", label: "영업번호", width: "w-[125px]", frozen: true },
|
{ 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: "receipt_date", label: "접수일", width: "w-[115px]", align: "center" },
|
||||||
{ key: "earliest_due_date_label", label: "요청납기", width: "w-[160px]", align: "center" },
|
{ key: "earliest_due_date_label", label: "요청납기", width: "w-[160px]", align: "center" },
|
||||||
{ key: "customer_name", label: "고객사", width: "w-[150px]" },
|
{ key: "customer_name", label: "고객사", width: "w-[150px]" },
|
||||||
{ key: "item_summary", label: "품명", width: "w-[200px]" },
|
{ key: "item_summary", label: "품명", width: "w-[200px]" },
|
||||||
{ key: "estimate_quantity", label: "견적수량", width: "w-[80px]", align: "right", formatNumber: true },
|
{ key: "estimate_quantity", label: "견적수량", width: "w-[115px]", align: "right", formatNumber: true },
|
||||||
{ key: "paid_type_name", label: "유/무상", width: "w-[70px]", align: "center" },
|
{ key: "paid_type_name", label: "유/무상", width: "w-[100px]", align: "center" },
|
||||||
{ key: "est_total_amount", label: "공급가액", width: "w-[110px]", formatMoney: true },
|
{ key: "est_total_amount", label: "공급가액", width: "w-[130px]", formatMoney: true },
|
||||||
{ key: "est_total_amount_krw", label: "원화환산공급가액", width: "w-[140px]", formatMoney: true },
|
{ key: "est_total_amount_krw", label: "원화환산공급가액", width: "w-[180px]", formatMoney: true },
|
||||||
{ key: "est_status", label: "견적현황", width: "w-[80px]", align: "center", renderType: "folder" },
|
{ key: "est_status", label: "견적현황", width: "w-[115px]", align: "center", renderType: "folder" },
|
||||||
{ key: "add_est_cnt", label: "추가견적", width: "w-[80px]", align: "center", renderType: "clip" },
|
{ key: "add_est_cnt", label: "추가견적", width: "w-[115px]", align: "center", renderType: "clip" },
|
||||||
{ key: "appr_status", label: "결재상태", width: "w-[90px]", align: "center" },
|
{ key: "appr_status", label: "결재상태", width: "w-[115px]", align: "center" },
|
||||||
{ key: "mail_send_status_label", label: "메일발송", width: "w-[110px]", align: "center" },
|
{ key: "mail_send_status_label", label: "메일발송", width: "w-[125px]", align: "center" },
|
||||||
{ key: "contract_currency_name", label: "환종", width: "w-[70px]", align: "center" },
|
{ key: "contract_currency_name", label: "환종", width: "w-[95px]", align: "center" },
|
||||||
{ key: "exchange_rate", label: "환율", width: "w-[80px]", formatMoney: true },
|
{ key: "exchange_rate", label: "환율", width: "w-[95px]", formatMoney: true },
|
||||||
{ key: "serial_no", label: "S/N", width: "w-[140px]" },
|
{ key: "serial_no", label: "S/N", width: "w-[140px]" },
|
||||||
{ key: "part_no", label: "품번", width: "w-[120px]" },
|
{ 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 — 비활성(주석) 컬럼. 활성화 시 아래 주석 해제.
|
/* wace estimateList_new.jsp 494~502 — 비활성(주석) 컬럼. 활성화 시 아래 주석 해제.
|
||||||
{ key: "product_name", label: "제품구분", width: "w-[90px]", align: "center" },
|
{ key: "product_name", label: "제품구분", width: "w-[90px]", align: "center" },
|
||||||
{ key: "area_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]);
|
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 () => {
|
const openCreate = async () => {
|
||||||
@@ -567,6 +585,7 @@ export default function SalesEstimatePage() {
|
|||||||
|
|
||||||
{/* 그리드 — 첫 컬럼 체크박스 (행 아무 셀 클릭으로 단일 선택, 클립/폴더 등 팝업 컬럼은 stopPropagation으로 제외) */}
|
{/* 그리드 — 첫 컬럼 체크박스 (행 아무 셀 클릭으로 단일 선택, 클립/폴더 등 팝업 컬럼은 stopPropagation으로 제외) */}
|
||||||
<DataGrid
|
<DataGrid
|
||||||
|
gridId="sales-estimate"
|
||||||
columns={gridColumns}
|
columns={gridColumns}
|
||||||
data={rows}
|
data={rows}
|
||||||
showCheckbox
|
showCheckbox
|
||||||
@@ -583,6 +602,22 @@ export default function SalesEstimatePage() {
|
|||||||
onRowDoubleClick={(row) => { setSelected(row); openEdit(); }}
|
onRowDoubleClick={(row) => { setSelected(row); openEdit(); }}
|
||||||
emptyMessage={loading ? "조회 중..." : "데이터가 없습니다"}
|
emptyMessage={loading ? "조회 중..." : "데이터가 없습니다"}
|
||||||
loading={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 */}
|
{/* 등록/수정 Dialog */}
|
||||||
|
|||||||
@@ -22,7 +22,9 @@ import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@
|
|||||||
import { Checkbox } from "@/components/ui/checkbox";
|
import { Checkbox } from "@/components/ui/checkbox";
|
||||||
import { Popover, PopoverContent, PopoverTrigger } from "@/components/ui/popover";
|
import { Popover, PopoverContent, PopoverTrigger } from "@/components/ui/popover";
|
||||||
import { Input } from "@/components/ui/input";
|
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 { cn } from "@/lib/utils";
|
||||||
import { toast } from "sonner";
|
import { toast } from "sonner";
|
||||||
import { apiClient } from "@/lib/api/client";
|
import { apiClient } from "@/lib/api/client";
|
||||||
@@ -76,6 +78,22 @@ export interface DataGridProps {
|
|||||||
showPagination?: boolean;
|
showPagination?: boolean;
|
||||||
/** 초기 페이지 크기 (기본: 50) */
|
/** 초기 페이지 크기 (기본: 50) */
|
||||||
defaultPageSize?: number;
|
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) => {
|
const fmtNum = (val: any) => {
|
||||||
@@ -140,15 +158,19 @@ function SortableHeaderCell({
|
|||||||
style={style}
|
style={style}
|
||||||
className={cn(
|
className={cn(
|
||||||
widthPx == null && col.width, widthPx == null && col.minWidth,
|
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),
|
col.frozen && cn("sticky z-20 bg-background", frozenLeftClass),
|
||||||
)}
|
)}
|
||||||
>
|
>
|
||||||
<div className="inline-flex items-center gap-1">
|
<div className="inline-flex items-center gap-1 w-full">
|
||||||
<div
|
<GripVertical
|
||||||
{...attributes}
|
{...attributes}
|
||||||
{...listeners}
|
{...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) => {
|
onClick={(e) => {
|
||||||
e.stopPropagation();
|
e.stopPropagation();
|
||||||
if (col.sortable !== false) onSort(col.key);
|
if (col.sortable !== false) onSort(col.key);
|
||||||
@@ -273,10 +295,64 @@ export function DataGrid({
|
|||||||
gridId,
|
gridId,
|
||||||
showPagination = true,
|
showPagination = true,
|
||||||
defaultPageSize = 50,
|
defaultPageSize = 50,
|
||||||
|
paginationStyle = "classic",
|
||||||
|
pageSizeOptions,
|
||||||
|
showColumnSettings = false,
|
||||||
|
systemColumnKeys,
|
||||||
|
summaryStats,
|
||||||
|
onRefresh,
|
||||||
|
onDownload,
|
||||||
|
showChart = false,
|
||||||
}: DataGridProps) {
|
}: DataGridProps) {
|
||||||
const [columns, setColumns] = useState(initialColumns);
|
const [columns, setColumns] = useState(initialColumns);
|
||||||
useEffect(() => { setColumns(initialColumns); }, [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 [sortKey, setSortKey] = useState<string | null>(null);
|
||||||
const [sortDir, setSortDir] = useState<"asc" | "desc">("asc");
|
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 */ }
|
try { localStorage.setItem(`datagrid_col_widths_${gridId}`, JSON.stringify(next)); } catch { /* skip */ }
|
||||||
}
|
}
|
||||||
}, [gridId]);
|
}, [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 발생 시 호출.
|
// 리사이즈 드래그 핸들러 — 헤더 우측 핸들에서 mousedown 발생 시 호출.
|
||||||
const startResize = useCallback((e: React.MouseEvent, colKey: string, currentWidthPx: number) => {
|
const startResize = useCallback((e: React.MouseEvent, colKey: string, currentWidthPx: number) => {
|
||||||
@@ -629,7 +715,7 @@ export function DataGrid({
|
|||||||
const isRightAlign = col.align === "right" || isNumericCol;
|
const isRightAlign = col.align === "right" || isNumericCol;
|
||||||
|
|
||||||
return (
|
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 ?? "")}>
|
title={String(val ?? "")}>
|
||||||
{display}
|
{display}
|
||||||
</span>
|
</span>
|
||||||
@@ -637,19 +723,132 @@ export function DataGrid({
|
|||||||
};
|
};
|
||||||
|
|
||||||
// 좌측 고정 보조: NO/체크박스 컬럼은 40px(=left-10), 첫 frozen 컬럼은 그 다음 위치
|
// 좌측 고정 보조: NO/체크박스 컬럼은 40px(=left-10), 첫 frozen 컬럼은 그 다음 위치
|
||||||
const hasFrozen = columns.some((c) => c.frozen);
|
const hasFrozen = visibleColumns.some((c) => c.frozen);
|
||||||
const hasFirstCol = showCheckbox || showRowNumber;
|
const hasFirstCol = showCheckbox || showRowNumber;
|
||||||
const stickyFirstColClass = "sticky left-0 z-20 bg-background";
|
const stickyFirstColClass = "sticky left-0 z-20 bg-background";
|
||||||
const stickyFirstColBodyClass = "sticky left-0 z-[6]";
|
const stickyFirstColBodyClass = "sticky left-0 z-[6]";
|
||||||
const frozenLeftClass = hasFirstCol ? "left-10" : "left-0";
|
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 (
|
return (
|
||||||
<div className="flex flex-col h-full flex-1 min-h-0">
|
<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">
|
<div className="flex-1 min-h-0 overflow-auto">
|
||||||
<DndContext sensors={sensors} collisionDetection={closestCenter} onDragEnd={handleDragEnd}>
|
<DndContext sensors={sensors} collisionDetection={closestCenter} onDragEnd={handleDragEnd}>
|
||||||
<Table noWrapper className="table-fixed">
|
<Table noWrapper className="table-fixed">
|
||||||
<TableHeader className="sticky top-0 bg-background z-10 shadow-[0_1px_0_0_hsl(var(--border))]">
|
<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={columns.map((c) => c.key)} strategy={horizontalListSortingStrategy}>
|
<SortableContext items={visibleColumns.map((c) => c.key)} strategy={horizontalListSortingStrategy}>
|
||||||
<TableRow>
|
<TableRow>
|
||||||
{showCheckbox && (
|
{showCheckbox && (
|
||||||
<TableHead className={cn("w-[40px] text-center ", hasFrozen && stickyFirstColClass)}>
|
<TableHead className={cn("w-[40px] text-center ", hasFrozen && stickyFirstColClass)}>
|
||||||
@@ -664,7 +863,7 @@ export function DataGrid({
|
|||||||
{showRowNumber && !showCheckbox && (
|
{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
|
<SortableHeaderCell
|
||||||
key={col.key}
|
key={col.key}
|
||||||
col={col}
|
col={col}
|
||||||
@@ -683,16 +882,16 @@ export function DataGrid({
|
|||||||
</TableRow>
|
</TableRow>
|
||||||
</SortableContext>
|
</SortableContext>
|
||||||
</TableHeader>
|
</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 ? (
|
{loading ? (
|
||||||
<TableRow>
|
<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>
|
</TableCell>
|
||||||
</TableRow>
|
</TableRow>
|
||||||
) : paginatedData.length === 0 ? (
|
) : paginatedData.length === 0 ? (
|
||||||
<TableRow>
|
<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}
|
{emptyMessage}
|
||||||
</TableCell>
|
</TableCell>
|
||||||
</TableRow>
|
</TableRow>
|
||||||
@@ -704,6 +903,7 @@ export function DataGrid({
|
|||||||
return (
|
return (
|
||||||
<TableRow
|
<TableRow
|
||||||
key={row.id || rowIdx}
|
key={row.id || rowIdx}
|
||||||
|
style={{ height: 28 }}
|
||||||
className={cn("cursor-pointer group",
|
className={cn("cursor-pointer group",
|
||||||
isSelected && "bg-accent text-accent-foreground",
|
isSelected && "bg-accent text-accent-foreground",
|
||||||
)}
|
)}
|
||||||
@@ -749,7 +949,7 @@ export function DataGrid({
|
|||||||
{pageOffset + rowIdx + 1}
|
{pageOffset + rowIdx + 1}
|
||||||
</TableCell>
|
</TableCell>
|
||||||
)}
|
)}
|
||||||
{columns.map((col) => {
|
{visibleColumns.map((col) => {
|
||||||
const w = columnWidths[col.key];
|
const w = columnWidths[col.key];
|
||||||
const inlineStyle = w != null ? { width: w, minWidth: w, maxWidth: w } : undefined;
|
const inlineStyle = w != null ? { width: w, minWidth: w, maxWidth: w } : undefined;
|
||||||
// 일반 텍스트 셀에 컬럼 단위 onClick 지원 (clip/folder 외)
|
// 일반 텍스트 셀에 컬럼 단위 onClick 지원 (clip/folder 외)
|
||||||
@@ -759,7 +959,7 @@ export function DataGrid({
|
|||||||
key={col.key}
|
key={col.key}
|
||||||
style={inlineStyle}
|
style={inlineStyle}
|
||||||
className={cn(
|
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",
|
col.editable && "cursor-text",
|
||||||
cellClickable && "cursor-pointer hover:underline text-primary",
|
cellClickable && "cursor-pointer hover:underline text-primary",
|
||||||
isSelected && "bg-accent",
|
isSelected && "bg-accent",
|
||||||
@@ -784,17 +984,67 @@ export function DataGrid({
|
|||||||
</DndContext>
|
</DndContext>
|
||||||
</div>
|
</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 */}
|
{/* 페이지네이션 footer */}
|
||||||
{showPagination && (
|
{showPagination && (
|
||||||
<div className="flex items-center justify-between border-t px-3 py-2 text-xs text-muted-foreground shrink-0">
|
<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-3">
|
||||||
<div className="flex items-center gap-1">
|
<div className="flex items-center gap-1">
|
||||||
|
{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>전체</span>
|
||||||
<span className="font-medium text-foreground">{totalItems.toLocaleString()}</span>
|
<span className="font-medium text-foreground">{totalItems.toLocaleString()}</span>
|
||||||
<span>건</span>
|
<span>건</span>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
<div className="flex items-center gap-1.5">
|
<div className="flex items-center gap-1.5">
|
||||||
|
{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
|
<Input
|
||||||
type="number"
|
type="number"
|
||||||
min={1}
|
min={1}
|
||||||
@@ -805,6 +1055,8 @@ export function DataGrid({
|
|||||||
className="h-7 w-16 text-center text-xs"
|
className="h-7 w-16 text-center text-xs"
|
||||||
/>
|
/>
|
||||||
<span>건씩 보기</span>
|
<span>건씩 보기</span>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -854,6 +1106,11 @@ export function DataGrid({
|
|||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
{/* 차트 분석 패널 — showChart + chartPanelOpen 시 노출 */}
|
||||||
|
{showChart && chartPanelOpen && (
|
||||||
|
<DataGridChartPanel gridId={gridId} columns={columns} data={processedData} />
|
||||||
|
)}
|
||||||
|
|
||||||
{/* 이미지 확대 모달 */}
|
{/* 이미지 확대 모달 */}
|
||||||
{previewImage && (
|
{previewImage && (
|
||||||
<div className="fixed inset-0 z-[9999] flex items-center justify-center bg-black/70"
|
<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>
|
||||||
|
);
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user