3883031c0b
INV Studio 데이터 뷰 시리즈. 솔루션 개발 단계라 backward-compat alias 없이 깔끔하게. Backend: - TableManagementController + Service: /aggregate, /aggregate-group, /select-rows endpoint 추가 sanitize + hasColumn 검증 + buildAggregateWhere 공유 헬퍼 Frontend canonical view components (신규): - stats: DB-first KPI editor (CPSegment 메타 chip, 컬럼 dropdown, 디자인 모드 debounce 350ms preview) - chart: recharts (bar / horizontalBar / line / donut) - card-list: title/subtitles/metrics 카드 카탈로그 (list / grid 레이아웃) - grouped-table: 클라이언트 측 groupBy + 그룹 헤더 row Canonical container (Phase G.2 / G.2.5 / G.2.6): - containerType='tabs' 활성 탭만 mount, ChildSlot 으로 자식 렌더 - ScreenDesigner.handleComponentDrop 가 canonical container tabs 도 인식 - 우측 V2PropertiesPanel 4-way 분기: tab child / panel child / selected / empty nested path update + saveToHistory, delete handler 동기화 Shared utilities: - useDbColumns hook (모듈 캐시), ColumnPicker (CPSelect 기반) - OptionFilterRow 자연어 카드 형식 (컬럼 dropdown / 조건 select / 값 입력) - _shared/use-table-rows.ts (cardList + groupedTable 공용 fetch) - IconPicker: 한글 키워드 80+ alias, 휠 스크롤 fix, 360px 상한, 결과 80→300 stats DB-first UX (Phase G.4.x): - DB / 정적 모드 이분법 제거 — 항상 dataSource 시작 - collapsed: 라벨 input + KpiMetaSegment chip (테이블 · 집계 · 컬럼 · 필터수) - expanded: 데이터 / 필터 / 외형 / 고급 flat CP rows - useSlideToggle hook 으로 펼침/닫힘 양방향 애니메이션 - 변화량 (delta) 수동 입력 UI 제거 — 향후 DB 자동 계산 영역 - 카드 fetch state 명시: loading / error / 대기 중 / 테이블 미설정 기타: - ScreenDesigner.tsx → InvyoneStudio.tsx rename (활성 빌더 파일) - 모든 hardcoded #6c5ce7 fallback 제거, hsl(var(--primary)) 토큰만 사용 (light/dark/테마 자동 적응) - StatsDefinition default_config 도 DB-first placeholder (value: 0 박지 않음) Docs: - notes/gbpark/2026-05-14-studio-data-view-roadmap.md (G.0 ~ G.4.2 진행 기록) Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
473 lines
14 KiB
TypeScript
473 lines
14 KiB
TypeScript
"use client";
|
|
|
|
/**
|
|
* InvCardListConfigPanel — canonical card-list 설정 패널 (Phase G.3.1, cp 톤)
|
|
*
|
|
* 흐름:
|
|
* ① 기본 — 제목 + 레이아웃 (list / grid)
|
|
* ② 데이터 소스 — 테이블 + 정렬 컬럼 + limit
|
|
* ③ 필드 매핑 — title / subtitles / metrics
|
|
* ④ 필터 — OptionFilter 빌더
|
|
*/
|
|
|
|
import React from "react";
|
|
import { Plus, Trash2 } from "lucide-react";
|
|
import {
|
|
CPSection,
|
|
CPRow,
|
|
CPText,
|
|
CPSelect,
|
|
CPSegment,
|
|
CPNumber,
|
|
CPSwitch,
|
|
Hint,
|
|
} from "@/components/v2/config-panels/_shared/cp";
|
|
import { useDbTables } from "../common/useDbTables";
|
|
import { OptionFilterRow } from "../_shared/FilterRow";
|
|
import type { OptionFilter } from "../input/use-option-loader";
|
|
import type {
|
|
CardListConfig,
|
|
CardListDataSource,
|
|
CardListLayout,
|
|
CardListMetric,
|
|
} from "./types";
|
|
|
|
export interface InvCardListConfigPanelProps {
|
|
config?: CardListConfig;
|
|
onChange?: (config: CardListConfig) => void;
|
|
selectedComponent?: { id: string; config?: CardListConfig; [k: string]: any };
|
|
}
|
|
|
|
export const InvCardListConfigPanel: React.FC<InvCardListConfigPanelProps> = ({
|
|
config,
|
|
onChange,
|
|
selectedComponent,
|
|
}) => {
|
|
const current: CardListConfig =
|
|
(config as CardListConfig) || (selectedComponent?.config as CardListConfig) || {};
|
|
const patch = (p: Partial<CardListConfig>) => onChange?.({ ...current, ...p });
|
|
|
|
const ds = current.dataSource ?? {};
|
|
const patchDataSource = (p: Partial<CardListDataSource>) => {
|
|
const next = { ...ds, ...p };
|
|
if (
|
|
!next.tableName &&
|
|
(!next.filters || next.filters.length === 0) &&
|
|
(!next.orderBy || next.orderBy.length === 0)
|
|
) {
|
|
patch({ dataSource: undefined });
|
|
} else {
|
|
patch({ dataSource: next });
|
|
}
|
|
};
|
|
|
|
const filters = ds.filters ?? [];
|
|
const subtitles = current.subtitleFields ?? [];
|
|
const metrics: CardListMetric[] = current.metricFields ?? [];
|
|
|
|
const updateFilter = (idx: number, f: Partial<OptionFilter>) => {
|
|
const next = filters.map((it, i) => (i === idx ? { ...it, ...f } : it));
|
|
patchDataSource({ filters: next });
|
|
};
|
|
const addFilter = () =>
|
|
patchDataSource({
|
|
filters: [...filters, { column: "", operator: "=", value_type: "static", value: "" } as OptionFilter],
|
|
});
|
|
const removeFilter = (idx: number) =>
|
|
patchDataSource({ filters: filters.filter((_, i) => i !== idx).length === 0 ? undefined : filters.filter((_, i) => i !== idx) });
|
|
|
|
const updateSubtitle = (idx: number, v: string) => {
|
|
const next = subtitles.map((it, i) => (i === idx ? v : it));
|
|
patch({ subtitleFields: next });
|
|
};
|
|
const addSubtitle = () => patch({ subtitleFields: [...subtitles, ""] });
|
|
const removeSubtitle = (idx: number) =>
|
|
patch({
|
|
subtitleFields: subtitles.filter((_, i) => i !== idx).length === 0
|
|
? undefined
|
|
: subtitles.filter((_, i) => i !== idx),
|
|
});
|
|
|
|
const updateMetric = (idx: number, m: Partial<CardListMetric>) => {
|
|
const next = metrics.map((it, i) => (i === idx ? { ...it, ...m } : it));
|
|
patch({ metricFields: next });
|
|
};
|
|
const addMetric = () => patch({ metricFields: [...metrics, { column: "" }] });
|
|
const removeMetric = (idx: number) => {
|
|
const next = metrics.filter((_, i) => i !== idx);
|
|
patch({ metricFields: next.length === 0 ? undefined : next });
|
|
};
|
|
|
|
const orderByCol = ds.orderBy?.[0]?.column || "";
|
|
const orderByDir = ds.orderBy?.[0]?.direction || "desc";
|
|
|
|
const { options: tableOptions } = useDbTables();
|
|
|
|
return (
|
|
<div style={{ fontFamily: "var(--v5-font-sans)", color: "var(--cp-text)", padding: "0 12px" }}>
|
|
{/* ── ① 기본 ─────────────────────────── */}
|
|
<CPSection title="① 기본">
|
|
<CPRow label="제목" help="비우면 제목 영역 숨김">
|
|
<CPText
|
|
value={current.title || ""}
|
|
onChange={(v) => patch({ title: v || undefined })}
|
|
placeholder="부서별 카드"
|
|
/>
|
|
</CPRow>
|
|
<CPRow label="레이아웃">
|
|
<CPSegment
|
|
value={current.layout || "list"}
|
|
onChange={(v) => patch({ layout: v as CardListLayout })}
|
|
options={[
|
|
{ value: "list", label: "리스트" },
|
|
{ value: "grid", label: "그리드" },
|
|
]}
|
|
/>
|
|
</CPRow>
|
|
{(current.layout ?? "list") === "grid" && (
|
|
<CPRow label="그리드 컬럼" help="비우면 auto-fit. 1 ~ 6 권장">
|
|
<CPNumber
|
|
value={current.columns ?? undefined}
|
|
onChange={(v) => patch({ columns: v ?? undefined })}
|
|
min={1}
|
|
max={8}
|
|
placeholder="auto"
|
|
/>
|
|
</CPRow>
|
|
)}
|
|
<CPRow label="빈 결과 문구">
|
|
<CPText
|
|
value={current.emptyText || ""}
|
|
onChange={(v) => patch({ emptyText: v || undefined })}
|
|
placeholder="데이터 없음"
|
|
/>
|
|
</CPRow>
|
|
</CPSection>
|
|
|
|
{/* ── ② 데이터 소스 ─────────────────────────── */}
|
|
<CPSection title="② 데이터 소스">
|
|
<CPRow label="테이블">
|
|
<CPSelect
|
|
value={ds.tableName || ""}
|
|
onChange={(v) => patchDataSource({ tableName: v || undefined })}
|
|
>
|
|
<option value="">선택...</option>
|
|
{tableOptions.map((t) => (
|
|
<option key={t.value} value={t.value}>
|
|
{t.label}
|
|
</option>
|
|
))}
|
|
</CPSelect>
|
|
</CPRow>
|
|
<CPRow label="정렬 컬럼" help="비우면 created_date desc (있을 때) 또는 PK 순">
|
|
<CPText
|
|
value={orderByCol}
|
|
onChange={(v) =>
|
|
patchDataSource({
|
|
orderBy: v
|
|
? [{ column: v, direction: orderByDir as "asc" | "desc" }]
|
|
: undefined,
|
|
})
|
|
}
|
|
placeholder="created_date"
|
|
/>
|
|
</CPRow>
|
|
{orderByCol && (
|
|
<CPRow label="정렬 방향">
|
|
<CPSegment
|
|
value={orderByDir}
|
|
onChange={(v) =>
|
|
patchDataSource({
|
|
orderBy: [{ column: orderByCol, direction: v as "asc" | "desc" }],
|
|
})
|
|
}
|
|
options={[
|
|
{ value: "desc", label: "내림차순" },
|
|
{ value: "asc", label: "오름차순" },
|
|
]}
|
|
/>
|
|
</CPRow>
|
|
)}
|
|
<CPRow label="최대 행 수" help="1 ~ 500. 기본 50">
|
|
<CPNumber
|
|
value={ds.limit ?? 50}
|
|
onChange={(v) => patchDataSource({ limit: v ?? 50 })}
|
|
min={1}
|
|
max={500}
|
|
/>
|
|
</CPRow>
|
|
</CPSection>
|
|
|
|
{/* ── ③ 필드 매핑 ─────────────────────────── */}
|
|
<CPSection title="③ 필드 매핑" desc="카드 한 장에 보일 컬럼들">
|
|
<CPRow label="제목 컬럼">
|
|
<CPText
|
|
value={current.titleField || ""}
|
|
onChange={(v) => patch({ titleField: v || undefined })}
|
|
placeholder="dept_name"
|
|
/>
|
|
</CPRow>
|
|
|
|
<div
|
|
style={{
|
|
display: "flex",
|
|
alignItems: "center",
|
|
justifyContent: "space-between",
|
|
marginTop: 6,
|
|
}}
|
|
>
|
|
<span style={{ fontSize: 10.5, color: "var(--cp-text-sec)", fontWeight: 700 }}>
|
|
부제 컬럼 {subtitles.length > 0 && `(${subtitles.length})`}
|
|
</span>
|
|
<button
|
|
type="button"
|
|
onClick={addSubtitle}
|
|
style={{
|
|
padding: "2px 6px",
|
|
fontSize: 10,
|
|
background: "var(--cp-bg-subtle)",
|
|
border: "1px solid var(--cp-border)",
|
|
borderRadius: 3,
|
|
cursor: "pointer",
|
|
color: "var(--cp-text)",
|
|
display: "inline-flex",
|
|
alignItems: "center",
|
|
gap: 3,
|
|
}}
|
|
>
|
|
<Plus size={9} /> 추가
|
|
</button>
|
|
</div>
|
|
{subtitles.length === 0 ? (
|
|
<Hint>부제 없음</Hint>
|
|
) : (
|
|
<div style={{ display: "flex", flexDirection: "column", gap: 4, marginTop: 4 }}>
|
|
{subtitles.map((col, idx) => (
|
|
<SubtitleRow
|
|
key={idx}
|
|
value={col}
|
|
onChange={(v) => updateSubtitle(idx, v)}
|
|
onRemove={() => removeSubtitle(idx)}
|
|
/>
|
|
))}
|
|
</div>
|
|
)}
|
|
|
|
<div
|
|
style={{
|
|
display: "flex",
|
|
alignItems: "center",
|
|
justifyContent: "space-between",
|
|
marginTop: 10,
|
|
}}
|
|
>
|
|
<span style={{ fontSize: 10.5, color: "var(--cp-text-sec)", fontWeight: 700 }}>
|
|
metric 칩 {metrics.length > 0 && `(${metrics.length})`}
|
|
</span>
|
|
<button
|
|
type="button"
|
|
onClick={addMetric}
|
|
style={{
|
|
padding: "2px 6px",
|
|
fontSize: 10,
|
|
background: "var(--cp-bg-subtle)",
|
|
border: "1px solid var(--cp-border)",
|
|
borderRadius: 3,
|
|
cursor: "pointer",
|
|
color: "var(--cp-text)",
|
|
display: "inline-flex",
|
|
alignItems: "center",
|
|
gap: 3,
|
|
}}
|
|
>
|
|
<Plus size={9} /> 추가
|
|
</button>
|
|
</div>
|
|
{metrics.length === 0 ? (
|
|
<Hint>metric 칩 없음 — 카드 우측/하단 숫자 강조 영역 빈 상태</Hint>
|
|
) : (
|
|
<div style={{ display: "flex", flexDirection: "column", gap: 4, marginTop: 4 }}>
|
|
{metrics.map((m, idx) => (
|
|
<MetricRow
|
|
key={idx}
|
|
metric={m}
|
|
onChange={(p) => updateMetric(idx, p)}
|
|
onRemove={() => removeMetric(idx)}
|
|
/>
|
|
))}
|
|
</div>
|
|
)}
|
|
</CPSection>
|
|
|
|
{/* ── ④ 필터 ─────────────────────────── */}
|
|
<CPSection title={`④ 필터 ${filters.length > 0 ? `(${filters.length})` : ""}`}>
|
|
<div style={{ display: "flex", justifyContent: "flex-end", marginBottom: 5 }}>
|
|
<button
|
|
type="button"
|
|
onClick={addFilter}
|
|
style={{
|
|
padding: "3px 8px",
|
|
fontSize: 10.5,
|
|
background: "var(--cp-bg-subtle)",
|
|
border: "1px solid var(--cp-border)",
|
|
borderRadius: 4,
|
|
cursor: "pointer",
|
|
color: "var(--cp-text)",
|
|
display: "inline-flex",
|
|
alignItems: "center",
|
|
gap: 3,
|
|
}}
|
|
>
|
|
<Plus size={10} /> 추가
|
|
</button>
|
|
</div>
|
|
{filters.length === 0 ? (
|
|
<Hint>필터 없음 — 테이블 전체 row</Hint>
|
|
) : (
|
|
<div style={{ display: "flex", flexDirection: "column", gap: 4 }}>
|
|
{filters.map((f, idx) => (
|
|
<OptionFilterRow
|
|
key={idx}
|
|
filter={f}
|
|
onChange={(p) => updateFilter(idx, p)}
|
|
onRemove={() => removeFilter(idx)}
|
|
tableName={ds.tableName}
|
|
/>
|
|
))}
|
|
</div>
|
|
)}
|
|
</CPSection>
|
|
</div>
|
|
);
|
|
};
|
|
|
|
InvCardListConfigPanel.displayName = "InvCardListConfigPanel";
|
|
|
|
function SubtitleRow({
|
|
value,
|
|
onChange,
|
|
onRemove,
|
|
}: {
|
|
value: string;
|
|
onChange: (v: string) => void;
|
|
onRemove: () => void;
|
|
}) {
|
|
return (
|
|
<div style={{ display: "grid", gridTemplateColumns: "1fr 18px", gap: 4, alignItems: "center" }}>
|
|
<input
|
|
type="text"
|
|
value={value}
|
|
onChange={(e) => onChange(e.target.value)}
|
|
placeholder="컬럼명"
|
|
style={{
|
|
height: 22,
|
|
padding: "0 6px",
|
|
fontSize: 11,
|
|
fontFamily: "var(--v5-font-sans)",
|
|
background: "var(--cp-surface)",
|
|
border: "1px solid var(--cp-border)",
|
|
borderRadius: 3,
|
|
color: "var(--cp-text)",
|
|
outline: "none",
|
|
minWidth: 0,
|
|
}}
|
|
/>
|
|
<button
|
|
type="button"
|
|
onClick={onRemove}
|
|
style={{
|
|
width: 18,
|
|
height: 18,
|
|
padding: 0,
|
|
border: "none",
|
|
background: "transparent",
|
|
cursor: "pointer",
|
|
color: "var(--cp-text-muted)",
|
|
display: "inline-flex",
|
|
alignItems: "center",
|
|
justifyContent: "center",
|
|
}}
|
|
aria-label="삭제"
|
|
>
|
|
<Trash2 size={11} />
|
|
</button>
|
|
</div>
|
|
);
|
|
}
|
|
|
|
function MetricRow({
|
|
metric,
|
|
onChange,
|
|
onRemove,
|
|
}: {
|
|
metric: CardListMetric;
|
|
onChange: (p: Partial<CardListMetric>) => void;
|
|
onRemove: () => void;
|
|
}) {
|
|
return (
|
|
<div
|
|
style={{
|
|
display: "grid",
|
|
gridTemplateColumns: "1fr 1fr 60px 18px",
|
|
gap: 4,
|
|
alignItems: "center",
|
|
}}
|
|
>
|
|
<input
|
|
type="text"
|
|
value={metric.column}
|
|
onChange={(e) => onChange({ column: e.target.value })}
|
|
placeholder="컬럼명"
|
|
style={inputBase()}
|
|
/>
|
|
<input
|
|
type="text"
|
|
value={metric.label || ""}
|
|
onChange={(e) => onChange({ label: e.target.value || undefined })}
|
|
placeholder="라벨"
|
|
style={inputBase()}
|
|
/>
|
|
<span style={{ display: "inline-flex", alignItems: "center", gap: 3 }}>
|
|
<CPSwitch value={!!metric.emphasis} onChange={(v) => onChange({ emphasis: v })} />
|
|
<span style={{ fontSize: 9, color: "var(--cp-text-muted)" }}>강조</span>
|
|
</span>
|
|
<button
|
|
type="button"
|
|
onClick={onRemove}
|
|
style={{
|
|
width: 18,
|
|
height: 18,
|
|
padding: 0,
|
|
border: "none",
|
|
background: "transparent",
|
|
cursor: "pointer",
|
|
color: "var(--cp-text-muted)",
|
|
display: "inline-flex",
|
|
alignItems: "center",
|
|
justifyContent: "center",
|
|
}}
|
|
aria-label="metric 삭제"
|
|
>
|
|
<Trash2 size={11} />
|
|
</button>
|
|
</div>
|
|
);
|
|
}
|
|
|
|
function inputBase(): React.CSSProperties {
|
|
return {
|
|
height: 22,
|
|
padding: "0 6px",
|
|
fontSize: 11,
|
|
fontFamily: "var(--v5-font-sans)",
|
|
background: "var(--cp-surface)",
|
|
border: "1px solid var(--cp-border)",
|
|
borderRadius: 3,
|
|
color: "var(--cp-text)",
|
|
outline: "none",
|
|
minWidth: 0,
|
|
};
|
|
}
|
|
|
|
export default InvCardListConfigPanel;
|