공용 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 { 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 */}
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user