Files
vexplor/PLAN.MD
SeongHyun Kim 578cca2687 feat(pop-dashboard): 4가지 아이템 모드 완성 - 설정 UI 추가 및 버그 수정
설정 패널 (PopDashboardConfig):
- groupBy(X축 분류) Combobox 설정 UI 추가
- 차트 xAxisColumn/yAxisColumn 입력 UI 추가
- 통계 카드 카테고리 추가/삭제/편집 인라인 에디터 추가
- 대상 컬럼 Select를 Combobox(검색 가능)로 개선

데이터 처리 버그 수정 (PopDashboardComponent):
- 차트: groupBy 있을 때 xAxisColumn 자동 보정 로직 추가
- 통계 카드: 카테고리별 필터 실제 적용 (기존: 모든 카테고리에 rows.length 동일 입력)
- useCallback 의존성 안정화 (visibleItemIds 문자열 키 사용)
- refreshInterval 최소 5초 강제

데이터 fetcher 방어 로직 (dataFetcher.ts):
- validateDataSourceConfig() 추가: 설정 미완료 시 SQL 전송 차단
- 빈 필터/불완전 조인 건너뜀 처리
- COUNT 컬럼 미선택 시 COUNT(*) 자동 처리
- fetchTableColumns() 이중 폴백 (tableManagementApi -> dashboardApi)

아이템 UI 개선:
- KPI/차트/게이지/통계 카드 패딩 및 폰트 크기 조정
- 작은 셀에서도 라벨/단위/증감율 표시되도록 hidden 제거

기타:
- GridMode MIN_CELL_WIDTH 160 -> 80 축소
- PLAN.MD: 대시보드 4가지 아이템 모드 완성 계획으로 갱신
- STATUS.md: 프로젝트 상태 추적 파일 추가

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-02-10 16:12:29 +09:00

19 KiB

현재 구현 계획: pop-dashboard 4가지 아이템 모드 완성

작성일: 2026-02-10 상태: 코딩 완료 (방어 로직 패치 포함) 목적: 대시보드 설정 패널의 미구현/버그 4건을 수정하여 KPI카드, 차트, 게이지, 통계카드 모두 실제 데이터로 동작하도록 완성


1. 문제 요약

pop-dashboard 컴포넌트의 4가지 아이템 모드 중 설정 UI가 누락되거나 데이터 처리 로직에 버그가 있어 실제 테스트 불가.

# 문제 심각도 영향
BUG-1 차트: groupBy 설정 UI 없음 높음 차트가 단일 값만 표시, X축 카테고리 분류 불가
BUG-2 차트: xAxisColumn 설정 UI 없음 높음 groupBy 결과의 컬럼명과 xKey 불일치로 차트 빈 화면
BUG-3 통계 카드: 카테고리 설정 UI 없음 높음 statConfig.categories를 설정할 방법 없음
BUG-4 통계 카드: 카테고리별 필터 미적용 높음 모든 카테고리에 동일 값(rows.length) 입력되는 버그

2. 수정 대상 파일 (2개)

파일 A: frontend/lib/registry/pop-components/pop-dashboard/PopDashboardConfig.tsx

변경 유형: 설정 UI 추가 3건

변경 A-1: DataSourceEditor에 groupBy 설정 추가 (라인 253~362 부근, 집계 함수 아래)

집계 함수가 선택된 상태에서 "그룹핑 컬럼"을 선택할 수 있는 드롭다운 추가.

추가할 위치: {/* 집계 함수 + 대상 컬럼 */} 블록 다음, {/* 자동 새로고침 */} 블록 이전

추가할 코드 (약 50줄):

{/* 그룹핑 (차트용 X축 분류) */}
{dataSource.aggregation && (
  <div>
    <Label className="text-xs">그룹핑 (X축)</Label>
    <Popover open={groupByOpen} onOpenChange={setGroupByOpen}>
      <PopoverTrigger asChild>
        <Button
          variant="outline"
          role="combobox"
          aria-expanded={groupByOpen}
          disabled={loadingCols}
          className="h-8 w-full justify-between text-xs"
        >
          {dataSource.aggregation.groupBy?.length
            ? dataSource.aggregation.groupBy.join(", ")
            : "없음 (단일 값)"}
          <ChevronsUpDown className="ml-2 h-3 w-3 shrink-0 opacity-50" />
        </Button>
      </PopoverTrigger>
      <PopoverContent
        className="p-0"
        style={{ width: "var(--radix-popover-trigger-width)" }}
        align="start"
      >
        <Command>
          <CommandInput placeholder="컬럼 검색..." className="text-xs" />
          <CommandList>
            <CommandEmpty className="py-2 text-center text-xs">
              컬럼을 찾을  없습니다.
            </CommandEmpty>
            <CommandGroup>
              {columns.map((col) => (
                <CommandItem
                  key={col.name}
                  value={col.name}
                  onSelect={() => {
                    const current = dataSource.aggregation?.groupBy ?? [];
                    const isSelected = current.includes(col.name);
                    const newGroupBy = isSelected
                      ? current.filter((g) => g !== col.name)
                      : [...current, col.name];
                    onChange({
                      ...dataSource,
                      aggregation: {
                        ...dataSource.aggregation!,
                        groupBy: newGroupBy.length > 0 ? newGroupBy : undefined,
                      },
                    });
                    setGroupByOpen(false);
                  }}
                  className="text-xs"
                >
                  <Check
                    className={cn(
                      "mr-2 h-3 w-3",
                      dataSource.aggregation?.groupBy?.includes(col.name)
                        ? "opacity-100"
                        : "opacity-0"
                    )}
                  />
                  <span className="font-medium">{col.name}</span>
                  <span className="ml-1 text-muted-foreground">({col.type})</span>
                </CommandItem>
              ))}
            </CommandGroup>
          </CommandList>
        </Command>
      </PopoverContent>
    </Popover>
    <p className="mt-0.5 text-[10px] text-muted-foreground">
      차트에서 X축 카테고리로 사용됩니다
    </p>
  </div>
)}

필요한 state 추가 (DataSourceEditor 내부, 기존 state 옆):

const [groupByOpen, setGroupByOpen] = useState(false);

변경 A-2: 차트 타입 설정에 xAxisColumn/yAxisColumn 추가 (라인 1187~1212 부근)

추가할 위치: {item.subType === "chart" && ( 블록 내부, 차트 유형 Select 다음

추가할 코드 (약 30줄):

{/* X축 컬럼 */}
<div>
  <Label className="text-xs">X축 컬럼</Label>
  <Input
    value={item.chartConfig?.xAxisColumn ?? ""}
    onChange={(e) =>
      onUpdate({
        ...item,
        chartConfig: {
          ...item.chartConfig,
          chartType: item.chartConfig?.chartType ?? "bar",
          xAxisColumn: e.target.value || undefined,
        },
      })
    }
    placeholder="groupBy 컬럼명 (비우면 자동)"
    className="h-8 text-xs"
  />
  <p className="mt-0.5 text-[10px] text-muted-foreground">
    그룹핑 컬럼명과 동일하게 입력. 비우면  번째 groupBy 컬럼 사용
  </p>
</div>

변경 A-3: 통계 카드 타입에 카테고리 설정 UI 추가 (라인 1272 부근, 게이지 설정 다음)

추가할 위치: {item.subType === "gauge" && ( 블록 다음에 새 블록 추가

추가할 코드 (약 100줄): StatCategoryEditor 인라인 블록

{item.subType === "stat-card" && (
  <div className="space-y-2">
    <div className="flex items-center justify-between">
      <Label className="text-xs font-medium">카테고리 설정</Label>
      <Button
        variant="outline"
        size="sm"
        className="h-6 text-[10px]"
        onClick={() => {
          const currentCats = item.statConfig?.categories ?? [];
          onUpdate({
            ...item,
            statConfig: {
              ...item.statConfig,
              categories: [
                ...currentCats,
                {
                  label: `카테고리 ${currentCats.length + 1}`,
                  filter: { column: "", operator: "=", value: "" },
                },
              ],
            },
          });
        }}
      >
        <Plus className="mr-1 h-3 w-3" />
        카테고리 추가
      </Button>
    </div>

    {(item.statConfig?.categories ?? []).map((cat, catIdx) => (
      <div key={catIdx} className="space-y-1 rounded border p-2">
        <div className="flex items-center gap-1">
          <Input
            value={cat.label}
            onChange={(e) => {
              const newCats = [...(item.statConfig?.categories ?? [])];
              newCats[catIdx] = { ...cat, label: e.target.value };
              onUpdate({
                ...item,
                statConfig: { ...item.statConfig, categories: newCats },
              });
            }}
            placeholder="라벨 (예: 수주)"
            className="h-6 flex-1 text-xs"
          />
          <Input
            value={cat.color ?? ""}
            onChange={(e) => {
              const newCats = [...(item.statConfig?.categories ?? [])];
              newCats[catIdx] = { ...cat, color: e.target.value || undefined };
              onUpdate({
                ...item,
                statConfig: { ...item.statConfig, categories: newCats },
              });
            }}
            placeholder="#색상코드"
            className="h-6 w-20 text-xs"
          />
          <Button
            variant="ghost"
            size="icon"
            className="h-6 w-6 text-destructive"
            onClick={() => {
              const newCats = (item.statConfig?.categories ?? []).filter(
                (_, i) => i !== catIdx
              );
              onUpdate({
                ...item,
                statConfig: { ...item.statConfig, categories: newCats },
              });
            }}
          >
            <Trash2 className="h-3 w-3" />
          </Button>
        </div>
        {/* 필터 조건: 컬럼 / 연산자 / 값 */}
        <div className="flex items-center gap-1 text-[10px]">
          <Input
            value={cat.filter.column}
            onChange={(e) => {
              const newCats = [...(item.statConfig?.categories ?? [])];
              newCats[catIdx] = {
                ...cat,
                filter: { ...cat.filter, column: e.target.value },
              };
              onUpdate({
                ...item,
                statConfig: { ...item.statConfig, categories: newCats },
              });
            }}
            placeholder="컬럼"
            className="h-6 w-20 text-[10px]"
          />
          <Select
            value={cat.filter.operator}
            onValueChange={(val) => {
              const newCats = [...(item.statConfig?.categories ?? [])];
              newCats[catIdx] = {
                ...cat,
                filter: { ...cat.filter, operator: val as FilterOperator },
              };
              onUpdate({
                ...item,
                statConfig: { ...item.statConfig, categories: newCats },
              });
            }}
          >
            <SelectTrigger className="h-6 w-16 text-[10px]">
              <SelectValue />
            </SelectTrigger>
            <SelectContent>
              <SelectItem value="=" className="text-xs">= 같음</SelectItem>
              <SelectItem value="!=" className="text-xs">!= 다름</SelectItem>
              <SelectItem value="like" className="text-xs">LIKE</SelectItem>
            </SelectContent>
          </Select>
          <Input
            value={String(cat.filter.value ?? "")}
            onChange={(e) => {
              const newCats = [...(item.statConfig?.categories ?? [])];
              newCats[catIdx] = {
                ...cat,
                filter: { ...cat.filter, value: e.target.value },
              };
              onUpdate({
                ...item,
                statConfig: { ...item.statConfig, categories: newCats },
              });
            }}
            placeholder="값"
            className="h-6 flex-1 text-[10px]"
          />
        </div>
      </div>
    ))}

    {(item.statConfig?.categories ?? []).length === 0 && (
      <p className="text-[10px] text-muted-foreground">
        카테고리를 추가하면  조건에 맞는 건수가 표시됩니다
      </p>
    )}
  </div>
)}

파일 B: frontend/lib/registry/pop-components/pop-dashboard/PopDashboardComponent.tsx

변경 유형: 데이터 처리 로직 수정 2건

변경 B-1: 차트 xAxisColumn 자동 설정 (라인 276~283 부근)

차트에 groupBy가 있지만 xAxisColumn이 설정되지 않은 경우, 첫 번째 groupBy 컬럼을 자동으로 xAxisColumn에 반영.

현재 코드 (라인 276~283):

case "chart":
  return (
    <ChartItemComponent
      item={item}
      rows={itemData.rows}
      containerWidth={containerWidth}
    />
  );

변경 코드:

case "chart": {
  // groupBy가 있지만 xAxisColumn이 미설정이면 자동 보정
  const chartItem = { ...item };
  if (
    item.dataSource.aggregation?.groupBy?.length &&
    !item.chartConfig?.xAxisColumn
  ) {
    chartItem.chartConfig = {
      ...chartItem.chartConfig,
      chartType: chartItem.chartConfig?.chartType ?? "bar",
      xAxisColumn: item.dataSource.aggregation.groupBy[0],
    };
  }
  return (
    <ChartItemComponent
      item={chartItem}
      rows={itemData.rows}
      containerWidth={containerWidth}
    />
  );
}

변경 B-2: 통계 카드 카테고리별 독립 데이터 조회 (라인 286~297)

현재 코드 (버그):

case "stat-card": {
  const categoryData: Record<string, number> = {};
  if (item.statConfig?.categories) {
    for (const cat of item.statConfig.categories) {
      categoryData[cat.label] = itemData.rows.length; // BUG: 모든 카테고리에 동일 값
    }
  }
  return (
    <StatCardComponent item={item} categoryData={categoryData} />
  );
}

변경 코드:

case "stat-card": {
  const categoryData: Record<string, number> = {};
  if (item.statConfig?.categories) {
    for (const cat of item.statConfig.categories) {
      if (cat.filter.column && cat.filter.value) {
        // 카테고리 필터로 rows 필터링
        const filtered = itemData.rows.filter((row) => {
          const cellValue = String(row[cat.filter.column] ?? "");
          const filterValue = String(cat.filter.value ?? "");
          switch (cat.filter.operator) {
            case "=":
              return cellValue === filterValue;
            case "!=":
              return cellValue !== filterValue;
            case "like":
              return cellValue.toLowerCase().includes(filterValue.toLowerCase());
            default:
              return cellValue === filterValue;
          }
        });
        categoryData[cat.label] = filtered.length;
      } else {
        categoryData[cat.label] = itemData.rows.length;
      }
    }
  }
  return (
    <StatCardComponent item={item} categoryData={categoryData} />
  );
}

주의: 이 방식은 rows에 전체 데이터가 있어야 작동함. 통계 카드의 dataSource에 집계 함수를 설정하지 않고(또는 groupBy를 사용하여) 원시 rows를 가져와야 한다.


3. 구현 순서 (의존성 기반)

순서 작업 파일 의존성 상태
1 A-1: DataSourceEditor에 groupBy UI 추가 PopDashboardConfig.tsx 없음 [x]
2 A-2: 차트 xAxisColumn 입력 UI 추가 PopDashboardConfig.tsx 없음 [x]
3 A-3: 통계 카드 카테고리 설정 UI 추가 PopDashboardConfig.tsx 없음 [x]
4 B-1: 차트 xAxisColumn 자동 보정 로직 PopDashboardComponent.tsx 순서 1 [x]
5 B-2: 통계 카드 카테고리별 필터 적용 PopDashboardComponent.tsx 순서 3 [x]
6 린트 검사 전체 순서 1~5 [x]
7 C-1: SQL 빌더 방어 로직 (빈 컬럼/테이블 차단) dataFetcher.ts 없음 [x]
8 C-2: refreshInterval 최소값 강제 (5초) PopDashboardComponent.tsx 없음 [x]
9 브라우저 테스트 - 순서 1~8 [ ]

순서 1, 2, 3은 서로 독립이므로 병렬 가능. 순서 4는 순서 1의 groupBy 값이 있어야 의미 있음. 순서 5는 순서 3의 카테고리 설정이 있어야 의미 있음. 순서 7, 8은 백엔드 부하 방지를 위한 방어 패치.


4. 사전 충돌 검사 결과

새로 추가할 식별자 목록

식별자 타입 정의 파일 사용 파일 충돌 여부
groupByOpen state (boolean) PopDashboardConfig.tsx DataSourceEditor 내부 동일 함수 내부 충돌 없음
setGroupByOpen state setter PopDashboardConfig.tsx DataSourceEditor 내부 동일 함수 내부 충돌 없음
chartItem const (DashboardItem) PopDashboardComponent.tsx renderSingleItem 내부 동일 함수 내부 충돌 없음

Grep 검색 결과 (전체 pop-dashboard 폴더):

  • groupByOpen: 0건 - 충돌 없음
  • setGroupByOpen: 0건 - 충돌 없음
  • groupByColumns: 0건 - 충돌 없음
  • chartItem: 0건 - 충돌 없음
  • StatCategoryEditor: 0건 - 충돌 없음
  • loadCategoryData: 0건 - 충돌 없음

기존 타입/함수 재사용 목록

기존 식별자 정의 위치 이번 수정에서 사용하는 곳
DataSourceConfig.aggregation.groupBy types.ts 라인 155 A-1 UI에서 읽기/쓰기
ChartItemConfig.xAxisColumn types.ts 라인 248 A-2 UI, B-1 자동 보정
StatCategory types.ts 라인 261 A-3 카테고리 편집
StatCardConfig.categories types.ts 라인 268 A-3 UI에서 읽기/쓰기
FilterOperator types.ts (import 이미 존재) A-3 카테고리 필터 Select
columns (state) PopDashboardConfig.tsx DataSourceEditor 내부 A-1 groupBy 컬럼 목록

사용처 있는데 정의 누락된 항목: 없음


5. 에러 함정 경고

함정 1: 차트에 groupBy만 설정하고 xAxisColumn을 비우면

ChartItem은 기본 xKey로 "name"을 사용하는데, groupBy 결과 행은 {status: "수주", value: 79} 형태. name 키가 없으므로 X축이 빈 채로 렌더링됨. B-1의 자동 보정 로직이 필수. 순서 4를 빠뜨리면 차트가 깨짐.

함정 2: 통계 카드에 집계 함수를 설정하면

집계(COUNT 등)가 설정되면 rows에 [{value: 87}] 하나만 들어옴. 카테고리별 필터링이 작동하려면 집계 함수를 "없음"으로 두거나, groupBy를 설정해야 개별 행이 rows에 포함됨. 통계 카드에서는 집계를 사용하지 않는 것이 올바른 사용법. 설정 가이드 문서에 이 점을 명시해야 함.

함정 3: PopDashboardConfig.tsx의 import 누락

현재 FilterOperator는 이미 import되어 있음 (라인 54). StatCategory는 직접 사용하지 않고 item.statConfig.categories 구조로 접근하므로 import 불필요. 새로운 import 추가 필요 없음.

함정 4: 통계 카드 카테고리 필터에서 숫자 비교

String(row[col]) vs String(filter.value) 비교이므로, 숫자 컬럼도 문자열로 비교됨. "100" === "100"은 정상 동작하지만, "100.00" !== "100"이 될 수 있음. 현재 대부분 컬럼이 varchar이므로 문제없지만, numeric 컬럼 사용 시 주의.

함정 5: DataSourceEditor의 columns state 타이밍

groupByOpen Popover에서 columns 배열을 사용하는데, 테이블 선택 직후 columns가 아직 로딩 중일 수 있음. 기존 코드에서 loadingCols 상태로 버튼을 disabled 처리하고 있으므로 문제없음.


6. 검증 방법

차트 (BUG-1, BUG-2)

  1. 아이템 추가 > "차트" 선택
  2. 테이블: sales_order_mng, 집계: COUNT, 컬럼: id, 그룹핑: status
  3. 차트 유형: 막대 차트
  4. 기대 결과: X축에 "수주", "진행중", "완료" / Y축에 79, 7, 1

통계 카드 (BUG-3, BUG-4)

  1. 아이템 추가 > "통계 카드" 선택
  2. 테이블: sales_order_mng, 집계: 없음 (중요!)
  3. 카테고리 추가:
    • "수주" / status / = / 수주
    • "진행중" / status / = / 진행중
    • "완료" / status / = / 완료
  4. 기대 결과: 수주 79, 진행중 7, 완료 1

이전 완료 계획 (아카이브)

POP 뷰어 스크롤 수정 (완료)
  • 라인 185: overflow-hidden 제거
  • 라인 266: overflow-auto 공통 적용
  • 라인 275: 일반 모드 min-h-full 추가
  • 린트 검사 통과
POP 뷰어 실제 컴포넌트 렌더링 (완료)
  • 뷰어 페이지에 레지스트리 초기화 import 추가
  • renderActualComponent() 실제 컴포넌트 렌더링으로 교체
  • 린트 검사 통과
V2/V2 컴포넌트 설정 스키마 정비 (완료)
  • 레거시 컴포넌트 스키마 제거
  • V2 컴포넌트 overrides 스키마 정의 (16개)
  • V2 컴포넌트 overrides 스키마 정의 (9개)
  • componentConfig.ts 한 파일에서 통합 관리
화면 복제 기능 개선 (진행 중)
  • [완료] DB 구조 개편 (menu_objid 의존성 제거)
  • [완료] 복제 옵션 정리
  • [완료] 화면 간 연결 복제 버그 수정
  • [대기] 화면 간 연결 복제 테스트
  • [대기] 제어관리 복제 테스트
  • [대기] 추가 옵션 복제 테스트