ERP-node/docs/테이블_검색필터_컴포넌트_분리_계획서.md

58 KiB

테이블 검색 필터 컴포넌트 분리 및 통합 계획서

📋 목차

  1. 현황 분석
  2. 목표 및 요구사항
  3. 아키텍처 설계
  4. 구현 계획
  5. 파일 구조
  6. 통합 시나리오
  7. 주요 기능 및 개선 사항
  8. 예상 장점
  9. 구현 우선순위
  10. 체크리스트

1. 현황 분석

1.1 현재 구조

  • 테이블 리스트 컴포넌트에 테이블 옵션이 내장되어 있음
  • 각 테이블 컴포넌트마다 개별적으로 옵션 기능 구현
  • 코드 중복 및 유지보수 어려움

1.2 현재 제공 기능

테이블 옵션

  • 컬럼 표시/숨김 설정
  • 컬럼 순서 변경 (드래그앤드롭)
  • 컬럼 너비 조정
  • 고정 컬럼 설정

필터 설정

  • 컬럼별 검색 필터 적용
  • 다중 필터 조건 지원
  • 연산자 선택 (같음, 포함, 시작, 끝)

그룹 설정

  • 컬럼별 데이터 그룹화
  • 다중 그룹 레벨 지원
  • 그룹별 집계 표시

1.3 적용 대상 컴포넌트

  1. TableList: 기본 테이블 리스트 컴포넌트
  2. SplitPanel: 좌/우 분할 테이블 (마스터-디테일 관계)
  3. FlowWidget: 플로우 스텝별 데이터 테이블

2. 목표 및 요구사항

2.1 핵심 목표

  1. 테이블 옵션 기능을 재사용 가능한 공통 컴포넌트로 분리
  2. 화면에 있는 테이블 컴포넌트를 자동 감지하여 검색 가능
  3. 각 컴포넌트의 테이블 데이터와 독립적으로 연동
  4. 기존 기능을 유지하면서 확장 가능한 구조 구축

2.2 기능 요구사항

자동 감지

  • 화면 로드 시 테이블 컴포넌트 자동 식별
  • 컴포넌트 추가/제거 시 동적 반영
  • 테이블 ID 기반 고유 식별

다중 테이블 지원

  • 한 화면에 여러 테이블이 있을 경우 선택 가능
  • 테이블 간 독립적인 설정 관리
  • 선택된 테이블에만 옵션 적용

실시간 적용

  • 필터/그룹 설정 시 즉시 테이블 업데이트
  • 불필요한 전체 화면 리렌더링 방지
  • 최적화된 데이터 조회

상태 독립성

  • 각 테이블의 설정이 독립적으로 유지
  • 한 테이블의 설정이 다른 테이블에 영향 없음
  • 화면 전환 시 설정 보존 (선택사항)

2.3 비기능 요구사항

  • 성능: 100개 이상의 컬럼도 부드럽게 처리
  • 접근성: 키보드 네비게이션 지원
  • 반응형: 모바일/태블릿 대응
  • 확장성: 새로운 테이블 타입 추가 용이

3. 아키텍처 설계

3.1 컴포넌트 구조

TableOptionsToolbar (신규 - 메인 툴바)
├── TableSelector (다중 테이블 선택 드롭다운)
├── ColumnVisibilityButton (테이블 옵션 버튼)
├── FilterButton (필터 설정 버튼)
└── GroupingButton (그룹 설정 버튼)

패널 컴포넌트들 (Dialog 형태)
├── ColumnVisibilityPanel (컬럼 표시/숨김 설정)
├── FilterPanel (검색 필터 설정)
└── GroupingPanel (그룹화 설정)

Context & Provider
├── TableOptionsContext (테이블 등록 및 관리)
└── TableOptionsProvider (전역 상태 관리)

화면 컴포넌트들 (기존 수정)
├── TableList → TableOptionsContext 연동
├── SplitPanel → 좌/우 각각 등록
└── FlowWidget → 스텝별 등록

3.2 데이터 흐름

graph TD
    A[화면 컴포넌트] --> B[registerTable 호출]
    B --> C[TableOptionsContext에 등록]
    C --> D[TableOptionsToolbar에서 목록 조회]
    D --> E[사용자가 테이블 선택]
    E --> F[옵션 버튼 클릭]
    F --> G[패널 열림]
    G --> H[설정 변경]
    H --> I[선택된 테이블의 콜백 호출]
    I --> J[테이블 컴포넌트 업데이트]
    J --> K[데이터 재조회/재렌더링]

3.3 상태 관리 구조

// Context에서 관리하는 전역 상태
{
  registeredTables: Map<tableId, TableRegistration> {
    "table-list-123": {
      tableId: "table-list-123",
      label: "품목 관리",
      tableName: "item_info",
      columns: [...],
      onFilterChange: (filters) => {},
      onGroupChange: (groups) => {},
      onColumnVisibilityChange: (columns) => {}
    },
    "split-panel-left-456": {
      tableId: "split-panel-left-456",
      label: "분할 패널 (좌측)",
      tableName: "category_values",
      columns: [...],
      ...
    }
  }
}

// 각 테이블 컴포넌트가 관리하는 로컬 상태
{
  filters: [
    { columnName: "item_name", operator: "contains", value: "나사" }
  ],
  grouping: ["category_id", "material"],
  columnVisibility: [
    { columnName: "item_name", visible: true, width: 200, order: 1 },
    { columnName: "status", visible: false, width: 100, order: 2 }
  ]
}

4. 구현 계획

Phase 1: Context 및 Provider 구현

4.1.1 타입 정의

파일: types/table-options.ts

/**
 * 테이블 필터 조건
 */
export interface TableFilter {
  columnName: string;
  operator:
    | "equals"
    | "contains"
    | "startsWith"
    | "endsWith"
    | "gt"
    | "lt"
    | "gte"
    | "lte"
    | "notEquals";
  value: string | number | boolean;
}

/**
 * 컬럼 표시 설정
 */
export interface ColumnVisibility {
  columnName: string;
  visible: boolean;
  width?: number;
  order?: number;
  fixed?: boolean; // 좌측 고정 여부
}

/**
 * 테이블 컬럼 정보
 */
export interface TableColumn {
  columnName: string;
  columnLabel: string;
  inputType: string;
  visible: boolean;
  width: number;
  sortable?: boolean;
  filterable?: boolean;
}

/**
 * 테이블 등록 정보
 */
export interface TableRegistration {
  tableId: string; // 고유 ID (예: "table-list-123")
  label: string; // 사용자에게 보이는 이름 (예: "품목 관리")
  tableName: string; // 실제 DB 테이블명 (예: "item_info")
  columns: TableColumn[];

  // 콜백 함수들
  onFilterChange: (filters: TableFilter[]) => void;
  onGroupChange: (groups: string[]) => void;
  onColumnVisibilityChange: (columns: ColumnVisibility[]) => void;
}

/**
 * Context 값 타입
 */
export interface TableOptionsContextValue {
  registeredTables: Map<string, TableRegistration>;
  registerTable: (registration: TableRegistration) => void;
  unregisterTable: (tableId: string) => void;
  getTable: (tableId: string) => TableRegistration | undefined;
  selectedTableId: string | null;
  setSelectedTableId: (tableId: string | null) => void;
}

4.1.2 Context 생성

파일: contexts/TableOptionsContext.tsx

import React, {
  createContext,
  useContext,
  useState,
  useCallback,
  ReactNode,
} from "react";
import {
  TableRegistration,
  TableOptionsContextValue,
} from "@/types/table-options";

const TableOptionsContext = createContext<TableOptionsContextValue | undefined>(
  undefined
);

export const TableOptionsProvider: React.FC<{ children: ReactNode }> = ({
  children,
}) => {
  const [registeredTables, setRegisteredTables] = useState<
    Map<string, TableRegistration>
  >(new Map());
  const [selectedTableId, setSelectedTableId] = useState<string | null>(null);

  /**
   * 테이블 등록
   */
  const registerTable = useCallback((registration: TableRegistration) => {
    setRegisteredTables((prev) => {
      const newMap = new Map(prev);
      newMap.set(registration.tableId, registration);

      // 첫 번째 테이블이면 자동 선택
      if (newMap.size === 1) {
        setSelectedTableId(registration.tableId);
      }

      return newMap;
    });

    console.log(
      `[TableOptions] 테이블 등록: ${registration.label} (${registration.tableId})`
    );
  }, []);

  /**
   * 테이블 등록 해제
   */
  const unregisterTable = useCallback(
    (tableId: string) => {
      setRegisteredTables((prev) => {
        const newMap = new Map(prev);
        const removed = newMap.delete(tableId);

        if (removed) {
          console.log(`[TableOptions] 테이블 해제: ${tableId}`);

          // 선택된 테이블이 제거되면 첫 번째 테이블 선택
          if (selectedTableId === tableId) {
            const firstTableId = newMap.keys().next().value;
            setSelectedTableId(firstTableId || null);
          }
        }

        return newMap;
      });
    },
    [selectedTableId]
  );

  /**
   * 특정 테이블 조회
   */
  const getTable = useCallback(
    (tableId: string) => {
      return registeredTables.get(tableId);
    },
    [registeredTables]
  );

  return (
    <TableOptionsContext.Provider
      value={{
        registeredTables,
        registerTable,
        unregisterTable,
        getTable,
        selectedTableId,
        setSelectedTableId,
      }}
    >
      {children}
    </TableOptionsContext.Provider>
  );
};

/**
 * Context Hook
 */
export const useTableOptions = () => {
  const context = useContext(TableOptionsContext);
  if (!context) {
    throw new Error("useTableOptions must be used within TableOptionsProvider");
  }
  return context;
};

Phase 2: TableOptionsToolbar 컴포넌트 구현

파일: components/screen/table-options/TableOptionsToolbar.tsx

import React, { useState } from "react";
import { useTableOptions } from "@/contexts/TableOptionsContext";
import { Button } from "@/components/ui/button";
import {
  Select,
  SelectContent,
  SelectItem,
  SelectTrigger,
  SelectValue,
} from "@/components/ui/select";
import { Settings, Filter, Layers } from "lucide-react";
import { ColumnVisibilityPanel } from "./ColumnVisibilityPanel";
import { FilterPanel } from "./FilterPanel";
import { GroupingPanel } from "./GroupingPanel";

export const TableOptionsToolbar: React.FC = () => {
  const { registeredTables, selectedTableId, setSelectedTableId } =
    useTableOptions();

  const [columnPanelOpen, setColumnPanelOpen] = useState(false);
  const [filterPanelOpen, setFilterPanelOpen] = useState(false);
  const [groupPanelOpen, setGroupPanelOpen] = useState(false);

  const tableList = Array.from(registeredTables.values());
  const selectedTable = selectedTableId
    ? registeredTables.get(selectedTableId)
    : null;

  // 테이블이 없으면 표시하지 않음
  if (tableList.length === 0) {
    return null;
  }

  return (
    <div className="flex items-center gap-2 border-b bg-background p-2">
      {/* 테이블 선택 (2개 이상일 때만 표시) */}
      {tableList.length > 1 && (
        <Select
          value={selectedTableId || ""}
          onValueChange={setSelectedTableId}
        >
          <SelectTrigger className="h-8 w-48 text-xs sm:h-9 sm:w-64 sm:text-sm">
            <SelectValue placeholder="테이블 선택" />
          </SelectTrigger>
          <SelectContent>
            {tableList.map((table) => (
              <SelectItem key={table.tableId} value={table.tableId}>
                {table.label}
              </SelectItem>
            ))}
          </SelectContent>
        </Select>
      )}

      {/* 테이블이 1개일 때는 이름만 표시 */}
      {tableList.length === 1 && (
        <div className="text-xs font-medium sm:text-sm">
          {tableList[0].label}
        </div>
      )}

      {/* 컬럼 수 표시 */}
      <div className="text-xs text-muted-foreground sm:text-sm">
        전체 {selectedTable?.columns.length || 0}
      </div>

      <div className="flex-1" />

      {/* 옵션 버튼들 */}
      <Button
        variant="outline"
        size="sm"
        onClick={() => setColumnPanelOpen(true)}
        className="h-8 text-xs sm:h-9 sm:text-sm"
        disabled={!selectedTableId}
      >
        <Settings className="mr-2 h-4 w-4" />
        테이블 옵션
      </Button>

      <Button
        variant="outline"
        size="sm"
        onClick={() => setFilterPanelOpen(true)}
        className="h-8 text-xs sm:h-9 sm:text-sm"
        disabled={!selectedTableId}
      >
        <Filter className="mr-2 h-4 w-4" />
        필터 설정
      </Button>

      <Button
        variant="outline"
        size="sm"
        onClick={() => setGroupPanelOpen(true)}
        className="h-8 text-xs sm:h-9 sm:text-sm"
        disabled={!selectedTableId}
      >
        <Layers className="mr-2 h-4 w-4" />
        그룹 설정
      </Button>

      {/* 패널들 */}
      {selectedTableId && (
        <>
          <ColumnVisibilityPanel
            tableId={selectedTableId}
            open={columnPanelOpen}
            onOpenChange={setColumnPanelOpen}
          />
          <FilterPanel
            tableId={selectedTableId}
            open={filterPanelOpen}
            onOpenChange={setFilterPanelOpen}
          />
          <GroupingPanel
            tableId={selectedTableId}
            open={groupPanelOpen}
            onOpenChange={setGroupPanelOpen}
          />
        </>
      )}
    </div>
  );
};

Phase 3: 패널 컴포넌트 구현

4.3.1 ColumnVisibilityPanel

파일: components/screen/table-options/ColumnVisibilityPanel.tsx

import React, { useState, useEffect } from "react";
import { useTableOptions } from "@/contexts/TableOptionsContext";
import {
  Dialog,
  DialogContent,
  DialogDescription,
  DialogFooter,
  DialogHeader,
  DialogTitle,
} from "@/components/ui/dialog";
import { Button } from "@/components/ui/button";
import { Checkbox } from "@/components/ui/checkbox";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
import { ScrollArea } from "@/components/ui/scroll-area";
import { GripVertical, Eye, EyeOff } from "lucide-react";
import { ColumnVisibility } from "@/types/table-options";

interface Props {
  tableId: string;
  open: boolean;
  onOpenChange: (open: boolean) => void;
}

export const ColumnVisibilityPanel: React.FC<Props> = ({
  tableId,
  open,
  onOpenChange,
}) => {
  const { getTable } = useTableOptions();
  const table = getTable(tableId);

  const [localColumns, setLocalColumns] = useState<ColumnVisibility[]>([]);

  // 테이블 정보 로드
  useEffect(() => {
    if (table) {
      setLocalColumns(
        table.columns.map((col) => ({
          columnName: col.columnName,
          visible: col.visible,
          width: col.width,
          order: 0,
        }))
      );
    }
  }, [table]);

  const handleVisibilityChange = (columnName: string, visible: boolean) => {
    setLocalColumns((prev) =>
      prev.map((col) =>
        col.columnName === columnName ? { ...col, visible } : col
      )
    );
  };

  const handleWidthChange = (columnName: string, width: number) => {
    setLocalColumns((prev) =>
      prev.map((col) =>
        col.columnName === columnName ? { ...col, width } : col
      )
    );
  };

  const handleApply = () => {
    table?.onColumnVisibilityChange(localColumns);
    onOpenChange(false);
  };

  const handleReset = () => {
    if (table) {
      setLocalColumns(
        table.columns.map((col) => ({
          columnName: col.columnName,
          visible: true,
          width: 150,
          order: 0,
        }))
      );
    }
  };

  const visibleCount = localColumns.filter((col) => col.visible).length;

  return (
    <Dialog open={open} onOpenChange={onOpenChange}>
      <DialogContent className="max-w-[95vw] sm:max-w-2xl">
        <DialogHeader>
          <DialogTitle className="text-base sm:text-lg">
            테이블 옵션
          </DialogTitle>
          <DialogDescription className="text-xs sm:text-sm">
            컬럼 표시/숨기기, 순서 변경, 너비 등을 설정할  있습니다. 모든
            테두리를 드래그하여 크기를 조정할  있습니다.
          </DialogDescription>
        </DialogHeader>

        <div className="space-y-3 sm:space-y-4">
          {/* 상태 표시 */}
          <div className="flex items-center justify-between rounded-lg border bg-muted/50 p-3">
            <div className="text-xs text-muted-foreground sm:text-sm">
              {visibleCount}/{localColumns.length} 컬럼 표시 
            </div>
            <Button
              variant="ghost"
              size="sm"
              onClick={handleReset}
              className="h-7 text-xs"
            >
              초기화
            </Button>
          </div>

          {/* 컬럼 리스트 */}
          <ScrollArea className="h-[300px] sm:h-[400px]">
            <div className="space-y-2 pr-4">
              {localColumns.map((col, index) => {
                const columnMeta = table?.columns.find(
                  (c) => c.columnName === col.columnName
                );
                return (
                  <div
                    key={col.columnName}
                    className="flex items-center gap-3 rounded-lg border bg-background p-3 transition-colors hover:bg-muted/50"
                  >
                    {/* 드래그 핸들 */}
                    <GripVertical className="h-4 w-4 shrink-0 text-muted-foreground" />

                    {/* 체크박스 */}
                    <Checkbox
                      checked={col.visible}
                      onCheckedChange={(checked) =>
                        handleVisibilityChange(
                          col.columnName,
                          checked as boolean
                        )
                      }
                    />

                    {/* 가시성 아이콘 */}
                    {col.visible ? (
                      <Eye className="h-4 w-4 shrink-0 text-primary" />
                    ) : (
                      <EyeOff className="h-4 w-4 shrink-0 text-muted-foreground" />
                    )}

                    {/* 컬럼명 */}
                    <div className="flex-1">
                      <div className="text-xs font-medium sm:text-sm">
                        {columnMeta?.columnLabel}
                      </div>
                      <div className="text-[10px] text-muted-foreground sm:text-xs">
                        {col.columnName}
                      </div>
                    </div>

                    {/* 너비 설정 */}
                    <div className="flex items-center gap-2">
                      <Label className="text-xs text-muted-foreground">
                        너비:
                      </Label>
                      <Input
                        type="number"
                        value={col.width || 150}
                        onChange={(e) =>
                          handleWidthChange(
                            col.columnName,
                            parseInt(e.target.value) || 150
                          )
                        }
                        className="h-7 w-16 text-xs sm:h-8 sm:w-20 sm:text-sm"
                        min={50}
                        max={500}
                      />
                    </div>
                  </div>
                );
              })}
            </div>
          </ScrollArea>
        </div>

        <DialogFooter className="gap-2 sm:gap-0">
          <Button
            variant="outline"
            onClick={() => onOpenChange(false)}
            className="h-8 flex-1 text-xs sm:h-10 sm:flex-none sm:text-sm"
          >
            취소
          </Button>
          <Button
            onClick={handleApply}
            className="h-8 flex-1 text-xs sm:h-10 sm:flex-none sm:text-sm"
          >
            저장
          </Button>
        </DialogFooter>
      </DialogContent>
    </Dialog>
  );
};

4.3.2 FilterPanel

파일: components/screen/table-options/FilterPanel.tsx

import React, { useState, useEffect } from "react";
import { useTableOptions } from "@/contexts/TableOptionsContext";
import {
  Dialog,
  DialogContent,
  DialogDescription,
  DialogFooter,
  DialogHeader,
  DialogTitle,
} from "@/components/ui/dialog";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import {
  Select,
  SelectContent,
  SelectItem,
  SelectTrigger,
  SelectValue,
} from "@/components/ui/select";
import { ScrollArea } from "@/components/ui/scroll-area";
import { Plus, X } from "lucide-react";
import { TableFilter } from "@/types/table-options";

interface Props {
  tableId: string;
  open: boolean;
  onOpenChange: (open: boolean) => void;
}

export const FilterPanel: React.FC<Props> = ({
  tableId,
  open,
  onOpenChange,
}) => {
  const { getTable } = useTableOptions();
  const table = getTable(tableId);

  const [activeFilters, setActiveFilters] = useState<TableFilter[]>([]);

  const addFilter = () => {
    setActiveFilters([
      ...activeFilters,
      { columnName: "", operator: "contains", value: "" },
    ]);
  };

  const removeFilter = (index: number) => {
    setActiveFilters(activeFilters.filter((_, i) => i !== index));
  };

  const updateFilter = (
    index: number,
    field: keyof TableFilter,
    value: any
  ) => {
    setActiveFilters(
      activeFilters.map((filter, i) =>
        i === index ? { ...filter, [field]: value } : filter
      )
    );
  };

  const applyFilters = () => {
    // 빈 필터 제거
    const validFilters = activeFilters.filter(
      (f) => f.columnName && f.value !== ""
    );
    table?.onFilterChange(validFilters);
    onOpenChange(false);
  };

  const clearFilters = () => {
    setActiveFilters([]);
    table?.onFilterChange([]);
  };

  const operatorLabels: Record<string, string> = {
    equals: "같음",
    contains: "포함",
    startsWith: "시작",
    endsWith: "끝",
    gt: "보다 큼",
    lt: "보다 작음",
    gte: "이상",
    lte: "이하",
    notEquals: "같지 않음",
  };

  return (
    <Dialog open={open} onOpenChange={onOpenChange}>
      <DialogContent className="max-w-[95vw] sm:max-w-2xl">
        <DialogHeader>
          <DialogTitle className="text-base sm:text-lg">
            검색 필터 설정
          </DialogTitle>
          <DialogDescription className="text-xs sm:text-sm">
            검색 필터로 사용할 컬럼을 선택하세요. 선택한 컬럼의 검색 입력 필드가
            표시됩니다.
          </DialogDescription>
        </DialogHeader>

        <div className="space-y-3 sm:space-y-4">
          {/* 전체 선택/해제 */}
          <div className="flex items-center justify-between">
            <div className="text-xs text-muted-foreground sm:text-sm">
               {activeFilters.length}개의 검색 필터가 표시됩니다
            </div>
            <Button
              variant="ghost"
              size="sm"
              onClick={clearFilters}
              className="h-7 text-xs"
            >
              초기화
            </Button>
          </div>

          {/* 필터 리스트 */}
          <ScrollArea className="h-[300px] sm:h-[400px]">
            <div className="space-y-3 pr-4">
              {activeFilters.map((filter, index) => (
                <div
                  key={index}
                  className="flex flex-col gap-2 rounded-lg border bg-background p-3 sm:flex-row sm:items-center"
                >
                  {/* 컬럼 선택 */}
                  <Select
                    value={filter.columnName}
                    onValueChange={(val) =>
                      updateFilter(index, "columnName", val)
                    }
                  >
                    <SelectTrigger className="h-8 text-xs sm:h-9 sm:w-40 sm:text-sm">
                      <SelectValue placeholder="컬럼 선택" />
                    </SelectTrigger>
                    <SelectContent>
                      {table?.columns
                        .filter((col) => col.filterable !== false)
                        .map((col) => (
                          <SelectItem
                            key={col.columnName}
                            value={col.columnName}
                          >
                            {col.columnLabel}
                          </SelectItem>
                        ))}
                    </SelectContent>
                  </Select>

                  {/* 연산자 선택 */}
                  <Select
                    value={filter.operator}
                    onValueChange={(val) =>
                      updateFilter(index, "operator", val)
                    }
                  >
                    <SelectTrigger className="h-8 text-xs sm:h-9 sm:w-32 sm:text-sm">
                      <SelectValue />
                    </SelectTrigger>
                    <SelectContent>
                      {Object.entries(operatorLabels).map(([value, label]) => (
                        <SelectItem key={value} value={value}>
                          {label}
                        </SelectItem>
                      ))}
                    </SelectContent>
                  </Select>

                  {/* 값 입력 */}
                  <Input
                    value={filter.value as string}
                    onChange={(e) =>
                      updateFilter(index, "value", e.target.value)
                    }
                    placeholder="값 입력"
                    className="h-8 flex-1 text-xs sm:h-9 sm:text-sm"
                  />

                  {/* 삭제 버튼 */}
                  <Button
                    variant="ghost"
                    size="icon"
                    onClick={() => removeFilter(index)}
                    className="h-8 w-8 shrink-0 sm:h-9 sm:w-9"
                  >
                    <X className="h-4 w-4" />
                  </Button>
                </div>
              ))}
            </div>
          </ScrollArea>

          {/* 필터 추가 버튼 */}
          <Button
            variant="outline"
            onClick={addFilter}
            className="h-8 w-full text-xs sm:h-9 sm:text-sm"
          >
            <Plus className="mr-2 h-4 w-4" />
            필터 추가
          </Button>
        </div>

        <DialogFooter className="gap-2 sm:gap-0">
          <Button
            variant="outline"
            onClick={() => onOpenChange(false)}
            className="h-8 flex-1 text-xs sm:h-10 sm:flex-none sm:text-sm"
          >
            취소
          </Button>
          <Button
            onClick={applyFilters}
            className="h-8 flex-1 text-xs sm:h-10 sm:flex-none sm:text-sm"
          >
            저장
          </Button>
        </DialogFooter>
      </DialogContent>
    </Dialog>
  );
};

4.3.3 GroupingPanel

파일: components/screen/table-options/GroupingPanel.tsx

import React, { useState } from "react";
import { useTableOptions } from "@/contexts/TableOptionsContext";
import {
  Dialog,
  DialogContent,
  DialogDescription,
  DialogFooter,
  DialogHeader,
  DialogTitle,
} from "@/components/ui/dialog";
import { Button } from "@/components/ui/button";
import { Checkbox } from "@/components/ui/checkbox";
import { ScrollArea } from "@/components/ui/scroll-area";
import { ArrowRight } from "lucide-react";

interface Props {
  tableId: string;
  open: boolean;
  onOpenChange: (open: boolean) => void;
}

export const GroupingPanel: React.FC<Props> = ({
  tableId,
  open,
  onOpenChange,
}) => {
  const { getTable } = useTableOptions();
  const table = getTable(tableId);

  const [selectedColumns, setSelectedColumns] = useState<string[]>([]);

  const toggleColumn = (columnName: string) => {
    if (selectedColumns.includes(columnName)) {
      setSelectedColumns(selectedColumns.filter((c) => c !== columnName));
    } else {
      setSelectedColumns([...selectedColumns, columnName]);
    }
  };

  const applyGrouping = () => {
    table?.onGroupChange(selectedColumns);
    onOpenChange(false);
  };

  const clearGrouping = () => {
    setSelectedColumns([]);
    table?.onGroupChange([]);
  };

  return (
    <Dialog open={open} onOpenChange={onOpenChange}>
      <DialogContent className="max-w-[95vw] sm:max-w-xl">
        <DialogHeader>
          <DialogTitle className="text-base sm:text-lg">그룹 설정</DialogTitle>
          <DialogDescription className="text-xs sm:text-sm">
            데이터를 그룹화할 컬럼을 선택하세요
          </DialogDescription>
        </DialogHeader>

        <div className="space-y-3 sm:space-y-4">
          {/* 상태 표시 */}
          <div className="flex items-center justify-between rounded-lg border bg-muted/50 p-3">
            <div className="text-xs text-muted-foreground sm:text-sm">
              {selectedColumns.length} 컬럼으로 그룹화
            </div>
            <Button
              variant="ghost"
              size="sm"
              onClick={clearGrouping}
              className="h-7 text-xs"
            >
              초기화
            </Button>
          </div>

          {/* 컬럼 리스트 */}
          <ScrollArea className="h-[250px] sm:h-[300px]">
            <div className="space-y-2 pr-4">
              {table?.columns.map((col, index) => {
                const isSelected = selectedColumns.includes(col.columnName);
                const order = selectedColumns.indexOf(col.columnName) + 1;

                return (
                  <div
                    key={col.columnName}
                    className="flex items-center gap-3 rounded-lg border bg-background p-3 transition-colors hover:bg-muted/50"
                  >
                    <Checkbox
                      checked={isSelected}
                      onCheckedChange={() => toggleColumn(col.columnName)}
                    />

                    <div className="flex-1">
                      <div className="text-xs font-medium sm:text-sm">
                        {col.columnLabel}
                      </div>
                      <div className="text-[10px] text-muted-foreground sm:text-xs">
                        {col.columnName}
                      </div>
                    </div>

                    {isSelected && (
                      <div className="flex items-center gap-1 rounded-full bg-primary px-2 py-0.5 text-xs text-primary-foreground">
                        {order}번째
                      </div>
                    )}
                  </div>
                );
              })}
            </div>
          </ScrollArea>

          {/* 그룹 순서 미리보기 */}
          {selectedColumns.length > 0 && (
            <div className="rounded-lg border bg-muted/30 p-3">
              <div className="mb-2 text-xs font-medium sm:text-sm">
                그룹화 순서
              </div>
              <div className="flex flex-wrap items-center gap-2 text-xs sm:text-sm">
                {selectedColumns.map((colName, index) => {
                  const col = table?.columns.find(
                    (c) => c.columnName === colName
                  );
                  return (
                    <React.Fragment key={colName}>
                      <div className="rounded bg-primary/10 px-2 py-1 font-medium">
                        {col?.columnLabel}
                      </div>
                      {index < selectedColumns.length - 1 && (
                        <ArrowRight className="h-3 w-3 text-muted-foreground" />
                      )}
                    </React.Fragment>
                  );
                })}
              </div>
            </div>
          )}
        </div>

        <DialogFooter className="gap-2 sm:gap-0">
          <Button
            variant="outline"
            onClick={() => onOpenChange(false)}
            className="h-8 flex-1 text-xs sm:h-10 sm:flex-none sm:text-sm"
          >
            취소
          </Button>
          <Button
            onClick={applyGrouping}
            className="h-8 flex-1 text-xs sm:h-10 sm:flex-none sm:text-sm"
          >
            저장
          </Button>
        </DialogFooter>
      </DialogContent>
    </Dialog>
  );
};

Phase 4: 기존 테이블 컴포넌트 통합

4.4.1 TableList 컴포넌트 수정

파일: components/screen/interactive/TableList.tsx

import { useEffect, useState, useCallback } from "react";
import { useTableOptions } from "@/contexts/TableOptionsContext";
import { TableFilter, ColumnVisibility } from "@/types/table-options";

export const TableList: React.FC<Props> = ({ component }) => {
  const { registerTable, unregisterTable } = useTableOptions();

  // 로컬 상태
  const [filters, setFilters] = useState<TableFilter[]>([]);
  const [grouping, setGrouping] = useState<string[]>([]);
  const [columnVisibility, setColumnVisibility] = useState<ColumnVisibility[]>(
    []
  );
  const [data, setData] = useState<any[]>([]);

  const tableId = `table-list-${component.id}`;

  // 테이블 등록
  useEffect(() => {
    registerTable({
      tableId,
      label: component.title || "테이블",
      tableName: component.tableName,
      columns: component.columns.map((col) => ({
        columnName: col.field,
        columnLabel: col.label,
        inputType: col.inputType,
        visible: col.visible ?? true,
        width: col.width || 150,
        sortable: col.sortable,
        filterable: col.filterable,
      })),
      onFilterChange: setFilters,
      onGroupChange: setGrouping,
      onColumnVisibilityChange: setColumnVisibility,
    });

    return () => unregisterTable(tableId);
  }, [component.id, component.tableName, component.columns]);

  // 데이터 조회
  const fetchData = useCallback(async () => {
    try {
      const params = {
        tableName: component.tableName,
        filters: JSON.stringify(filters),
        groupBy: grouping.join(","),
      };

      const response = await apiClient.get("/api/table/data", { params });

      if (response.data.success) {
        setData(response.data.data);
      }
    } catch (error) {
      console.error("데이터 조회 실패:", error);
    }
  }, [component.tableName, filters, grouping]);

  // 필터/그룹 변경 시 데이터 재조회
  useEffect(() => {
    fetchData();
  }, [fetchData]);

  // 표시할 컬럼 필터링
  const visibleColumns = component.columns.filter((col) => {
    const visibility = columnVisibility.find((v) => v.columnName === col.field);
    return visibility ? visibility.visible : col.visible !== false;
  });

  return (
    <div className="flex h-full flex-col">
      {/* 기존 테이블 UI */}
      <div className="flex-1 overflow-auto">
        <table className="w-full">
          <thead>
            <tr>
              {visibleColumns.map((col) => {
                const visibility = columnVisibility.find(
                  (v) => v.columnName === col.field
                );
                const width = visibility?.width || col.width || 150;

                return (
                  <th key={col.field} style={{ width: `${width}px` }}>
                    {col.label}
                  </th>
                );
              })}
            </tr>
          </thead>
          <tbody>
            {data.map((row, rowIndex) => (
              <tr key={rowIndex}>
                {visibleColumns.map((col) => (
                  <td key={col.field}>{row[col.field]}</td>
                ))}
              </tr>
            ))}
          </tbody>
        </table>
      </div>
    </div>
  );
};

4.4.2 SplitPanel 컴포넌트 수정

파일: components/screen/interactive/SplitPanel.tsx

export const SplitPanel: React.FC<Props> = ({ component }) => {
  const { registerTable, unregisterTable } = useTableOptions();

  // 좌측 테이블 상태
  const [leftFilters, setLeftFilters] = useState<TableFilter[]>([]);
  const [leftGrouping, setLeftGrouping] = useState<string[]>([]);
  const [leftColumnVisibility, setLeftColumnVisibility] = useState<
    ColumnVisibility[]
  >([]);

  // 우측 테이블 상태
  const [rightFilters, setRightFilters] = useState<TableFilter[]>([]);
  const [rightGrouping, setRightGrouping] = useState<string[]>([]);
  const [rightColumnVisibility, setRightColumnVisibility] = useState<
    ColumnVisibility[]
  >([]);

  const leftTableId = `split-panel-left-${component.id}`;
  const rightTableId = `split-panel-right-${component.id}`;

  // 좌측 테이블 등록
  useEffect(() => {
    registerTable({
      tableId: leftTableId,
      label: `${component.title || "분할 패널"} (좌측)`,
      tableName: component.leftPanel.tableName,
      columns: component.leftPanel.columns.map((col) => ({
        columnName: col.field,
        columnLabel: col.label,
        inputType: col.inputType,
        visible: col.visible ?? true,
        width: col.width || 150,
      })),
      onFilterChange: setLeftFilters,
      onGroupChange: setLeftGrouping,
      onColumnVisibilityChange: setLeftColumnVisibility,
    });

    return () => unregisterTable(leftTableId);
  }, [component.leftPanel]);

  // 우측 테이블 등록
  useEffect(() => {
    registerTable({
      tableId: rightTableId,
      label: `${component.title || "분할 패널"} (우측)`,
      tableName: component.rightPanel.tableName,
      columns: component.rightPanel.columns.map((col) => ({
        columnName: col.field,
        columnLabel: col.label,
        inputType: col.inputType,
        visible: col.visible ?? true,
        width: col.width || 150,
      })),
      onFilterChange: setRightFilters,
      onGroupChange: setRightGrouping,
      onColumnVisibilityChange: setRightColumnVisibility,
    });

    return () => unregisterTable(rightTableId);
  }, [component.rightPanel]);

  return (
    <div className="flex h-full gap-4">
      {/* 좌측 테이블 */}
      <div className="flex-1">
        <TableList
          component={component.leftPanel}
          filters={leftFilters}
          grouping={leftGrouping}
          columnVisibility={leftColumnVisibility}
        />
      </div>

      {/* 우측 테이블 */}
      <div className="flex-1">
        <TableList
          component={component.rightPanel}
          filters={rightFilters}
          grouping={rightGrouping}
          columnVisibility={rightColumnVisibility}
        />
      </div>
    </div>
  );
};

4.4.3 FlowWidget 컴포넌트 수정

파일: components/screen/interactive/FlowWidget.tsx

export const FlowWidget: React.FC<Props> = ({ component }) => {
  const { registerTable, unregisterTable } = useTableOptions();

  const [selectedStep, setSelectedStep] = useState<any | null>(null);
  const [filters, setFilters] = useState<TableFilter[]>([]);
  const [grouping, setGrouping] = useState<string[]>([]);
  const [columnVisibility, setColumnVisibility] = useState<ColumnVisibility[]>(
    []
  );

  const tableId = selectedStep
    ? `flow-widget-${component.id}-step-${selectedStep.id}`
    : null;

  // 선택된 스텝의 테이블 등록
  useEffect(() => {
    if (!selectedStep || !tableId) return;

    registerTable({
      tableId,
      label: `${selectedStep.name} 데이터`,
      tableName: component.tableName,
      columns: component.displayColumns.map((col) => ({
        columnName: col.field,
        columnLabel: col.label,
        inputType: col.inputType,
        visible: col.visible ?? true,
        width: col.width || 150,
      })),
      onFilterChange: setFilters,
      onGroupChange: setGrouping,
      onColumnVisibilityChange: setColumnVisibility,
    });

    return () => unregisterTable(tableId);
  }, [selectedStep, component.displayColumns]);

  return (
    <div className="flex h-full flex-col">
      {/* 플로우 스텝 선택 UI */}
      <div className="border-b p-2">{/* 스텝 선택 드롭다운 */}</div>

      {/* 테이블 */}
      <div className="flex-1">
        {selectedStep && (
          <TableList
            component={component}
            filters={filters}
            grouping={grouping}
            columnVisibility={columnVisibility}
          />
        )}
      </div>
    </div>
  );
};

Phase 5: InteractiveScreenViewer 통합

파일: components/screen/InteractiveScreenViewer.tsx

import { TableOptionsProvider } from "@/contexts/TableOptionsContext";
import { TableOptionsToolbar } from "@/components/screen/table-options/TableOptionsToolbar";

export const InteractiveScreenViewer: React.FC<Props> = ({ screenData }) => {
  return (
    <TableOptionsProvider>
      <div className="flex h-full flex-col">
        {/* 테이블 옵션 툴바 */}
        <TableOptionsToolbar />

        {/* 화면 컨텐츠 */}
        <div className="flex-1 overflow-auto">
          {screenData.components.map((component) => (
            <ComponentRenderer key={component.id} component={component} />
          ))}
        </div>
      </div>
    </TableOptionsProvider>
  );
};

Phase 6: 백엔드 API 개선

파일: backend-node/src/controllers/tableController.ts

/**
 * 테이블 데이터 조회 (필터/그룹 지원)
 */
export async function getTableData(req: Request, res: Response) {
  const companyCode = req.user!.companyCode;
  const { tableName, filters, groupBy, page = 1, pageSize = 50 } = req.query;

  try {
    // 필터 파싱
    const parsedFilters: TableFilter[] = filters
      ? JSON.parse(filters as string)
      : [];

    // WHERE 절 생성
    const whereConditions: string[] = [`company_code = $1`];
    const params: any[] = [companyCode];

    parsedFilters.forEach((filter, index) => {
      const paramIndex = index + 2;

      switch (filter.operator) {
        case "equals":
          whereConditions.push(`${filter.columnName} = $${paramIndex}`);
          params.push(filter.value);
          break;
        case "contains":
          whereConditions.push(`${filter.columnName} ILIKE $${paramIndex}`);
          params.push(`%${filter.value}%`);
          break;
        case "startsWith":
          whereConditions.push(`${filter.columnName} ILIKE $${paramIndex}`);
          params.push(`${filter.value}%`);
          break;
        case "endsWith":
          whereConditions.push(`${filter.columnName} ILIKE $${paramIndex}`);
          params.push(`%${filter.value}`);
          break;
        case "gt":
          whereConditions.push(`${filter.columnName} > $${paramIndex}`);
          params.push(filter.value);
          break;
        case "lt":
          whereConditions.push(`${filter.columnName} < $${paramIndex}`);
          params.push(filter.value);
          break;
        case "gte":
          whereConditions.push(`${filter.columnName} >= $${paramIndex}`);
          params.push(filter.value);
          break;
        case "lte":
          whereConditions.push(`${filter.columnName} <= $${paramIndex}`);
          params.push(filter.value);
          break;
        case "notEquals":
          whereConditions.push(`${filter.columnName} != $${paramIndex}`);
          params.push(filter.value);
          break;
      }
    });

    const whereSql = `WHERE ${whereConditions.join(" AND ")}`;
    const groupBySql = groupBy ? `GROUP BY ${groupBy}` : "";

    // 페이징
    const offset =
      (parseInt(page as string) - 1) * parseInt(pageSize as string);
    const limitSql = `LIMIT ${pageSize} OFFSET ${offset}`;

    // 카운트 쿼리
    const countQuery = `SELECT COUNT(*) as total FROM ${tableName} ${whereSql}`;
    const countResult = await pool.query(countQuery, params);
    const total = parseInt(countResult.rows[0].total);

    // 데이터 쿼리
    const dataQuery = `
      SELECT * FROM ${tableName}
      ${whereSql}
      ${groupBySql}
      ORDER BY id DESC
      ${limitSql}
    `;
    const dataResult = await pool.query(dataQuery, params);

    return res.json({
      success: true,
      data: dataResult.rows,
      pagination: {
        page: parseInt(page as string),
        pageSize: parseInt(pageSize as string),
        total,
        totalPages: Math.ceil(total / parseInt(pageSize as string)),
      },
    });
  } catch (error: any) {
    logger.error("테이블 데이터 조회 실패", {
      error: error.message,
      tableName,
    });
    return res.status(500).json({
      success: false,
      error: "데이터 조회 중 오류가 발생했습니다",
    });
  }
}

5. 파일 구조

frontend/
├── types/
│   └── table-options.ts                     # 타입 정의
│
├── contexts/
│   └── TableOptionsContext.tsx              # Context 및 Provider
│
├── components/
│   └── screen/
│       ├── table-options/
│       │   ├── TableOptionsToolbar.tsx      # 메인 툴바
│       │   ├── ColumnVisibilityPanel.tsx    # 테이블 옵션 패널
│       │   ├── FilterPanel.tsx              # 필터 설정 패널
│       │   └── GroupingPanel.tsx            # 그룹 설정 패널
│       │
│       ├── interactive/
│       │   ├── TableList.tsx                # 수정: Context 연동
│       │   ├── SplitPanel.tsx               # 수정: Context 연동
│       │   └── FlowWidget.tsx               # 수정: Context 연동
│       │
│       └── InteractiveScreenViewer.tsx      # 수정: Provider 래핑
│
backend-node/
└── src/
    └── controllers/
        └── tableController.ts               # 수정: 필터/그룹 지원

6. 통합 시나리오

6.1 단일 테이블 화면

<InteractiveScreenViewer>
  <TableOptionsProvider>
    <TableOptionsToolbar /> {/* 자동으로 1개 테이블 선택 */}
    <TableList /> {/* 자동 등록 */}
  </TableOptionsProvider>
</InteractiveScreenViewer>

동작 흐름:

  1. TableList 마운트 → Context에 테이블 등록
  2. TableOptionsToolbar에서 자동으로 해당 테이블 선택
  3. 사용자가 필터 설정 → onFilterChange 콜백 호출
  4. TableList에서 filters 상태 업데이트 → 데이터 재조회

6.2 다중 테이블 화면 (SplitPanel)

<InteractiveScreenViewer>
  <TableOptionsProvider>
    <TableOptionsToolbar /> {/* 좌/우 테이블 선택 가능 */}
    <SplitPanel>
      {" "}
      {/* 좌/우 각각 등록 */}
      <TableList /> {/* 좌측 */}
      <TableList /> {/* 우측 */}
    </SplitPanel>
  </TableOptionsProvider>
</InteractiveScreenViewer>

동작 흐름:

  1. SplitPanel 마운트 → 좌/우 테이블 각각 등록
  2. TableOptionsToolbar에서 드롭다운으로 테이블 선택
  3. 선택된 테이블에 대해서만 옵션 적용
  4. 각 테이블의 상태는 독립적으로 관리

6.3 플로우 위젯 화면

<InteractiveScreenViewer>
  <TableOptionsProvider>
    <TableOptionsToolbar /> {/* 현재 스텝 테이블 자동 선택 */}
    <FlowWidget /> {/* 스텝 변경 시 자동 재등록 */}
  </TableOptionsProvider>
</InteractiveScreenViewer>

동작 흐름:

  1. FlowWidget 마운트 → 초기 스텝 테이블 등록
  2. 사용자가 다른 스텝 선택 → 기존 테이블 해제 + 새 테이블 등록
  3. TableOptionsToolbar에서 자동으로 새 테이블 선택
  4. 스텝별로 독립적인 필터/그룹 설정 유지

7. 주요 기능 및 개선 사항

7.1 자동 감지 메커니즘

구현 방법:

  • 각 테이블 컴포넌트가 마운트될 때 registerTable() 호출
  • 언마운트 시 unregisterTable() 호출
  • Context가 등록된 테이블 목록을 Map으로 관리

장점:

  • 개발자가 수동으로 테이블 목록을 관리할 필요 없음
  • 동적으로 컴포넌트가 추가/제거되어도 자동 반영
  • 컴포넌트 간 느슨한 결합 유지

7.2 독립적 상태 관리

구현 방법:

  • 각 테이블 컴포넌트가 자체 상태(filters, grouping, columnVisibility) 관리
  • Context는 상태를 직접 저장하지 않고 콜백 함수만 저장
  • 콜백을 통해 각 테이블에 설정 전달

장점:

  • 한 테이블의 설정이 다른 테이블에 영향 없음
  • 메모리 효율적 (Context에 모든 상태 저장 불필요)
  • 각 테이블이 독립적으로 최적화 가능

7.3 실시간 반영

구현 방법:

  • 옵션 변경 시 즉시 해당 테이블의 콜백 호출
  • 테이블 컴포넌트는 상태 변경을 감지하여 자동 리렌더링
  • useCallback과 useMemo로 불필요한 리렌더링 방지

장점:

  • 사용자 경험 향상 (즉각적인 피드백)
  • 성능 최적화 (변경된 테이블만 업데이트)

7.4 확장성

새로운 테이블 컴포넌트 추가 방법:

export const MyCustomTable: React.FC = () => {
  const { registerTable, unregisterTable } = useTableOptions();
  const [filters, setFilters] = useState<TableFilter[]>([]);

  useEffect(() => {
    registerTable({
      tableId: "my-custom-table-123",
      label: "커스텀 테이블",
      tableName: "custom_table",
      columns: [...],
      onFilterChange: setFilters,
      onGroupChange: setGrouping,
      onColumnVisibilityChange: setColumnVisibility,
    });

    return () => unregisterTable("my-custom-table-123");
  }, []);

  // 나머지 구현...
};

8. 예상 장점

8.1 개발자 측면

  1. 코드 재사용성: 공통 로직을 한 곳에서 관리
  2. 유지보수 용이: 버그 수정 시 한 곳만 수정
  3. 일관된 UX: 모든 테이블에서 동일한 사용자 경험
  4. 빠른 개발: 새 테이블 추가 시 Context만 연동

8.2 사용자 측면

  1. 직관적인 UI: 통일된 인터페이스로 학습 비용 감소
  2. 유연한 검색: 다양한 필터 조합으로 원하는 데이터 빠르게 찾기
  3. 맞춤 설정: 각 테이블별로 컬럼 표시/숨김 설정 가능
  4. 효율적인 작업: 그룹화로 대량 데이터를 구조적으로 확인

8.3 성능 측면

  1. 최적화된 렌더링: 변경된 테이블만 리렌더링
  2. 효율적인 상태 관리: Context에 최소한의 정보만 저장
  3. 지연 로딩: 패널은 열릴 때만 렌더링
  4. 백엔드 부하 감소: 필터링된 데이터만 조회

9. 구현 우선순위

Phase 1: 기반 구조 (1-2일)

  • 타입 정의 작성
  • Context 및 Provider 구현
  • 테스트용 간단한 TableOptionsToolbar 작성

Phase 2: 툴바 및 패널 (2-3일)

  • TableOptionsToolbar 완성
  • ColumnVisibilityPanel 구현
  • FilterPanel 구현
  • GroupingPanel 구현

Phase 3: 기존 컴포넌트 통합 (2-3일)

  • TableList Context 연동
  • SplitPanel Context 연동 (좌/우 분리)
  • FlowWidget Context 연동
  • InteractiveScreenViewer Provider 래핑

Phase 4: 백엔드 API (1-2일)

  • 필터 처리 로직 구현
  • 그룹화 처리 로직 구현
  • 페이징 최적화
  • 성능 테스트

Phase 5: 테스트 및 최적화 (1-2일)

  • 단위 테스트 작성
  • 통합 테스트
  • 성능 프로파일링
  • 버그 수정 및 최적화

총 예상 기간: 약 7-12일


10. 체크리스트

개발 전 확인사항

  • 현재 테이블 옵션 기능 목록 정리
  • 기존 코드의 중복 로직 파악
  • 백엔드 API 현황 파악
  • 성능 요구사항 정의

개발 중 확인사항

  • 타입 정의 완료
  • Context 및 Provider 동작 테스트
  • 각 패널 UI/UX 검토
  • 기존 컴포넌트와의 호환성 확인
  • 백엔드 API 연동 테스트

개발 후 확인사항

  • 모든 테이블 컴포넌트에서 정상 작동
  • 다중 테이블 화면에서 독립성 확인
  • 성능 요구사항 충족 확인
  • 사용자 테스트 및 피드백 반영
  • 문서화 완료

배포 전 확인사항

  • 기존 화면에 영향 없는지 확인
  • 롤백 계획 수립
  • 사용자 가이드 작성
  • 팀 공유 및 교육

11. 주의사항

11.1 멀티테넌시 준수

모든 데이터 조회 시 company_code 필터링 필수:

// ✅ 올바른 방법
const whereConditions: string[] = [`company_code = $1`];
const params: any[] = [companyCode];

// ❌ 잘못된 방법
const whereConditions: string[] = []; // company_code 필터링 누락

11.2 SQL 인젝션 방지

필터 값은 반드시 파라미터 바인딩 사용:

// ✅ 올바른 방법
whereConditions.push(`${filter.columnName} = $${paramIndex}`);
params.push(filter.value);

// ❌ 잘못된 방법
whereConditions.push(`${filter.columnName} = '${filter.value}'`); // SQL 인젝션 위험

11.3 성능 고려사항

  • 컬럼이 많은 테이블(100개 이상)의 경우 가상 스크롤 적용
  • 필터 변경 시 디바운싱으로 API 호출 최소화
  • 그룹화는 데이터량에 따라 프론트엔드/백엔드 선택적 처리

11.4 접근성

  • 키보드 네비게이션 지원 (Tab, Enter, Esc)
  • 스크린 리더 호환성 확인
  • 색상 대비 4.5:1 이상 유지

12. 추가 고려사항

12.1 설정 저장 기능

사용자별로 테이블 설정을 저장하여 화면 재방문 시 복원:

// 로컬 스토리지에 저장
localStorage.setItem(
  `table-settings-${tableId}`,
  JSON.stringify({ columnVisibility, filters, grouping })
);

// 불러오기
const savedSettings = localStorage.getItem(`table-settings-${tableId}`);
if (savedSettings) {
  const { columnVisibility, filters, grouping } = JSON.parse(savedSettings);
  setColumnVisibility(columnVisibility);
  setFilters(filters);
  setGrouping(grouping);
}

12.2 내보내기 기능

현재 필터/그룹 설정으로 Excel 내보내기:

const exportToExcel = () => {
  const params = {
    tableName: component.tableName,
    filters: JSON.stringify(filters),
    groupBy: grouping.join(","),
    columns: visibleColumns.map((c) => c.field),
  };

  window.location.href = `/api/table/export?${new URLSearchParams(params)}`;
};

12.3 필터 프리셋

자주 사용하는 필터 조합을 프리셋으로 저장:

interface FilterPreset {
  id: string;
  name: string;
  filters: TableFilter[];
  grouping: string[];
}

const presets: FilterPreset[] = [
  { id: "active-items", name: "활성 품목만", filters: [...], grouping: [] },
  { id: "by-category", name: "카테고리별 그룹", filters: [], grouping: ["category_id"] },
];

13. 참고 자료


14. 브라우저 테스트 결과

테스트 환경

테스트 항목 및 결과

1. 테이블 옵션 (ColumnVisibilityPanel)

  • 상태: 정상 동작
  • 테스트 내용:
    • 툴바의 "테이블 옵션" 버튼 클릭 시 다이얼로그 정상 표시
    • 7개 컬럼 모두 정상 표시 (장치 코드, 시리얼넘버, manufacturer, 모델명, 품번, 차량 타입, 차량 번호)
    • 각 컬럼마다 체크박스, 드래그 핸들, 미리보기 아이콘, 너비 설정 표시
    • "초기화" 버튼 표시
  • 스크린샷: column-visibility-panel.png

2. 필터 설정 (FilterPanel)

  • 상태: 정상 동작
  • 테스트 내용:
    • 툴바의 "필터 설정" 버튼 클릭 시 다이얼로그 정상 표시
    • "총 0개의 검색 필터가 표시됩니다" 메시지 표시
    • "필터 추가" 버튼 정상 표시
    • "초기화" 버튼 표시
  • 스크린샷: filter-panel-empty.png

3. 그룹 설정 (GroupingPanel)

  • 상태: 정상 동작
  • 테스트 내용:
    • 툴바의 "그룹 설정" 버튼 클릭 시 다이얼로그 정상 표시
    • "0개 컬럼으로 그룹화" 메시지 표시
    • 7개 컬럼 모두 체크박스로 표시
    • 각 컬럼의 라벨 및 필드명 정상 표시
    • "초기화" 버튼 표시
  • 스크린샷: grouping-panel.png

4. Context 통합

  • 상태: 정상 동작
  • 테스트 내용:
    • TableOptionsProvider/screens/[screenId]/page.tsx에 정상 통합
    • FlowWidget 컴포넌트가 TableOptionsContext에 정상 등록
    • 에러 없이 페이지 로드 및 렌더링 완료

검증 완료 사항

  1. 타입 정의 및 Context 구현 완료
  2. 패널 컴포넌트 3개 구현 완료 (ColumnVisibility, Filter, Grouping)
  3. TableOptionsToolbar 메인 컴포넌트 구현 완료
  4. TableOptionsProvider 통합 완료
  5. FlowWidget에 Context 연동 완료
  6. 브라우저 테스트 완료 (모든 기능 정상 동작)

향후 개선 사항

  1. 백엔드 API 통합: 현재는 프론트엔드 상태 관리만 구현됨. 백엔드 API에 필터/그룹/컬럼 설정 파라미터 전달 필요
  2. 필터 적용 로직: 필터 추가 후 실제 데이터 필터링 구현
  3. 그룹화 적용 로직: 그룹 선택 후 실제 데이터 그룹화 구현
  4. 컬럼 순서/너비 적용: 드래그앤드롭으로 변경한 순서 및 너비를 실제 테이블에 반영

15. 변경 이력

날짜 버전 변경 내용 작성자
2025-01-13 1.0 초안 작성 AI
2025-01-13 1.1 프론트엔드 구현 완료 및 브라우저 테스트 완료 AI

16. 구현 완료 요약

생성된 파일

  1. frontend/types/table-options.ts - 타입 정의
  2. frontend/contexts/TableOptionsContext.tsx - Context 구현
  3. frontend/components/screen/table-options/ColumnVisibilityPanel.tsx - 컬럼 가시성 패널
  4. frontend/components/screen/table-options/FilterPanel.tsx - 필터 패널
  5. frontend/components/screen/table-options/GroupingPanel.tsx - 그룹핑 패널
  6. frontend/components/screen/table-options/TableOptionsToolbar.tsx - 메인 툴바

수정된 파일

  1. frontend/app/(main)/screens/[screenId]/page.tsx - Provider 통합 (화면 뷰어)
  2. frontend/components/screen/ScreenDesigner.tsx - Provider 통합 (화면 디자이너)
  3. frontend/components/screen/InteractiveDataTable.tsx - Context 연동
  4. frontend/components/screen/widgets/FlowWidget.tsx - Context 연동
  5. frontend/lib/registry/components/table-list/TableListComponent.tsx - Context 연동
  6. frontend/lib/registry/components/split-panel-layout/SplitPanelLayoutComponent.tsx - Context 연동

구현 완료 기능

  • Context API 기반 테이블 자동 감지 시스템
  • 컬럼 표시/숨기기, 순서 변경, 너비 설정
  • 필터 추가 UI (백엔드 연동 대기)
  • 그룹화 컬럼 선택 UI (백엔드 연동 대기)
  • 여러 테이블 컴포넌트 지원 (FlowWidget, TableList, SplitPanel, InteractiveDataTable)
  • shadcn/ui 기반 일관된 디자인 시스템
  • 브라우저 테스트 완료

이 계획서를 검토하신 후 수정사항이나 추가 요구사항을 알려주세요!