Files
DDD1542 8b8186d1c0
Build & Deploy to K8s / build-and-deploy (push) Successful in 4m7s
INVYONE Studio Config Panel — 좌측 팔레트 10 컴포넌트 cp 톤 1차 마이그
신규 / 마이그된 패널:
- 데이터 조회/선택: InvDataConfigPanel (wrapper) + InvRepeaterConfigPanel (V2Repeater 2029줄 폐기 → cp 신규 본체)
- 통합 컴포넌트 7개 in-place cp: button / container / divider / search / stats / table / title
- 옛 v2-* hidden 호환: InvDividerConfigPanel / InvTextConfigPanel / InvButtonConfigPanel

cp 인프라:
- _shared/cp/CPExtras.tsx 신규 — 8 공용 컴포넌트
  (Hint / DimText / InlineLoader / SectionLabel / CpChip / ChipPickerBox /
   FeatureChipGrid / CPVisualGrid)
- FeatureChipGrid: portal tooltip + Stripe 패턴 group cooldown (500ms delay → 즉시 전환)
- CPVisualGrid: 시각 미리보기 카드 (두께/색/사이즈/시맨틱 등 진짜 의미 preview)
- cp.css 다크 luminance 분리 + 키프레임 / CPPrimitives CPSelect 자동 검색·정렬

옛 V2 패널 4개 삭제:
- V2RepeaterConfigPanel (2029) / V2ButtonConfigPanel (2212) /
  V2DividerLineConfigPanel (236) / V2TextDisplayConfigPanel (304)

설계 노트 5개 추가 (notes/gbpark/2026-04-28-*.md):
- cp-panel-standard / cp-panel-day2-html-v4-match / invdata-inventory /
  inv-repeater-redesign / inv-naming-consolidation

다음: Inv* 일괄 네이밍 통합 + dead code 정리

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-28 17:21:50 +09:00

2520 lines
92 KiB
TypeScript

"use client";
/**
* InvRepeaterConfigPanel — 데이터 조회/선택 (v2-repeater) cp 톤 설정 패널
*
* 분류 체계 (Codex 검토 2026-04-28):
* RepKind = "repeater" (1)
* RepType = "inline" | "modal" | "button" (button = reserved/disabled)
* RepFormat = "flat" | "grouped" | "pick-one" | "pick-many"
* | "source-detail" | "external"
*
* 흐름 (V2FieldConfigPanel 입력폼 패턴 — cp-panel-standard.md §2.1):
* CPCrumb (InvDataConfigPanel 가 그림)
* ① 데이터 소속 (always) → ② 유형별 설정 (CPFormatTrigger + FormatBody)
* → ③ 데이터 필터 (modal/source-detail 일 때) → ▾ 동작/편집 → ▾ 표시 → ▾ 컬럼 구성 → ▾ 고급
*
* cp 프리미티브만 (shadcn 직접 import 0건).
*
* Reference: notes/gbpark/2026-04-28-inv-repeater-redesign.md
* notes/gbpark/2026-04-28-cp-panel-standard.md
*/
import React, { useState, useEffect, useMemo, useCallback, useRef } from "react";
import { createPortal } from "react-dom";
import {
Database,
Link2,
Plus,
Trash2,
ArrowRight,
Calculator,
Loader2,
Wand2,
Hash,
Search,
MousePointerClick,
Rows3,
Layers,
PanelsTopLeft,
} from "lucide-react";
import {
CPSection,
CPRow,
CPGroup,
CPText,
CPSelect,
CPNumber,
CPSwitch,
CPSegment,
CPIconBtn,
CPFormatTrigger,
type CPFormatItem,
} from "./_shared/cp";
import { tableTypeApi } from "@/lib/api/screen";
import { tableManagementApi } from "@/lib/api/tableManagement";
import { entityJoinApi } from "@/lib/api/entityJoin";
import { getAvailableNumberingRules } from "@/lib/api/numberingRule";
import { NumberingRuleConfig } from "@/types/numbering-rule";
import {
V2RepeaterConfig,
RepeaterColumnConfig,
DEFAULT_REPEATER_CONFIG,
} from "@/types/v2-repeater";
// ───────────────────────────────────────────────────────
// 분류 체계 (RepKind / RepType / RepFormat)
// ───────────────────────────────────────────────────────
type RepKind = "repeater";
type RepType = "inline" | "modal" | "button";
type RepFormat =
| "flat"
| "grouped"
| "pick-one"
| "pick-many"
| "source-detail"
| "external";
const TYPES: Array<{ id: RepType; name: string; desc: string; disabled?: boolean; icon: React.ReactNode }> = [
{ id: "inline", name: "직접 입력", desc: "행을 화면에서 직접 추가", icon: <Rows3 size={14} /> },
{ id: "modal", name: "모달 선택", desc: "엔티티 검색 후 추가", icon: <Search size={14} /> },
{ id: "button", name: "버튼 연결", desc: "외부 화면으로 연결 (예약)", icon: <MousePointerClick size={14} />, disabled: true },
];
const FORMATS_BY_TYPE: Record<RepType, CPFormatItem[]> = {
inline: [
{ id: "flat", name: "평면", desc: "행을 일직선으로", icon: <Rows3 size={11} /> },
{ id: "grouped", name: "그룹화", desc: "컬럼별 그룹", icon: <Layers size={11} /> },
],
modal: [
{ id: "pick-many", name: "다건 추가", desc: "여러 행 한번에", icon: <ListPlusIcon /> },
{ id: "pick-one", name: "단건 추가", desc: "한 번에 한 행만", icon: <PlusOneIcon /> },
{ id: "source-detail", name: "마스터→자식", desc: "선택 시 자식 자동 채움", icon: <Layers size={11} /> },
],
button: [
{ id: "external", name: "외부 화면", desc: "다른 화면으로 이동", icon: <PanelsTopLeft size={11} /> },
],
};
// 작은 인라인 아이콘 (lucide 추가 import 줄이기)
function ListPlusIcon() {
return (
<svg width="11" height="11" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2.2" strokeLinecap="round" strokeLinejoin="round">
<path d="M11 12H3" />
<path d="M16 6H3" />
<path d="M16 18H3" />
<path d="M18 9v6" />
<path d="M21 12h-6" />
</svg>
);
}
function PlusOneIcon() {
return (
<svg width="11" height="11" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2.2" strokeLinecap="round" strokeLinejoin="round">
<circle cx="12" cy="12" r="9" />
<path d="M12 8v8" />
<path d="M8 12h8" />
</svg>
);
}
// ───────────────────────────────────────────────────────
// resolveTriple / applyTriple — legacy ↔ taxonomy
// ───────────────────────────────────────────────────────
function resolveTriple(config: V2RepeaterConfig): { kind: RepKind; type: RepType; format: RepFormat } {
const renderMode = (config?.render_mode || "inline") as RepType;
const type: RepType = renderMode === "modal" ? "modal" : renderMode === "button" ? "button" : "inline";
let format: RepFormat;
if (type === "inline") {
format = "flat"; // grouped 는 추후 grouped_by 키 도입 시 분기
} else if (type === "modal") {
if (config?.source_detail_config) format = "source-detail";
else if (config?.modal && (config.modal as any).allow_multiple === false) format = "pick-one";
else format = "pick-many";
} else {
format = "external";
}
return { kind: "repeater", type, format };
}
function applyTriple(
prev: V2RepeaterConfig,
type: RepType,
format: RepFormat,
): V2RepeaterConfig {
const next: V2RepeaterConfig = { ...prev };
next.render_mode = type === "button" ? ("button" as any) : (type as "inline" | "modal");
if (type === "modal") {
if (format === "source-detail") {
next.source_detail_config = next.source_detail_config || {
table_name: "",
foreign_key: "",
parent_key: "",
};
} else {
// pick-one / pick-many 는 source_detail_config 비활성 (즉시 삭제 X — 보존)
// 사용자 명시 OFF 시에만 삭제
}
next.modal = {
...(next.modal || {}),
...(format === "pick-one" ? ({ allow_multiple: false } as any) : ({ allow_multiple: true } as any)),
};
} else if (type === "inline") {
// inline 일 때 modal/data_source 키는 보존만 (DB 호환)
}
return next;
}
// ───────────────────────────────────────────────────────
// 보조 타입
// ───────────────────────────────────────────────────────
interface InvRepeaterConfigPanelProps {
config: V2RepeaterConfig;
onChange: (config: V2RepeaterConfig) => void;
currentTableName?: string;
screenTableName?: string;
tableColumns?: any[];
menuObjid?: number | string;
}
interface ColumnOption {
columnName: string;
displayName: string;
input_type?: string;
detailSettings?: {
codeGroup?: string;
referenceTable?: string;
referenceColumn?: string;
displayColumn?: string;
format?: string;
};
}
interface EntityColumnOption {
columnName: string;
displayName: string;
referenceTable?: string;
referenceColumn?: string;
displayColumn?: string;
}
interface CalcRule {
id: string;
target_column: string;
formula: string;
label?: string;
}
interface TableRelation {
tableName: string;
tableLabel: string;
foreign_key_column: string;
referenceColumn: string;
}
const AUTO_FILL_OPTIONS: Array<{ value: string; label: string }> = [
{ value: "none", label: "없음" },
{ value: "currentDate", label: "현재 날짜" },
{ value: "currentDateTime", label: "현재 날짜+시간" },
{ value: "sequence", label: "순번 (1, 2, 3...)" },
{ value: "numbering", label: "채번 규칙" },
{ value: "fromMainForm", label: "메인 폼에서 복사" },
{ value: "fixed", label: "고정값" },
{ value: "parentSequence", label: "부모채번+순번" },
];
// ───────────────────────────────────────────────────────
// Hint / DimText / InlineLoader (cp 톤)
// ───────────────────────────────────────────────────────
function Hint({ children, tone = "default" }: { children: React.ReactNode; tone?: "default" | "warn" | "ok" }) {
const color =
tone === "warn" ? "var(--v5-amber, #d97706)" : tone === "ok" ? "rgb(16, 185, 129)" : "var(--cp-text-muted)";
return (
<div style={{ fontSize: 10.5, color, marginTop: 4, lineHeight: 1.5 }}>
{children}
</div>
);
}
function DimText({ value, mono, muted }: { value: React.ReactNode; mono?: boolean; muted?: boolean }) {
return (
<div
style={{
height: 28,
padding: "0 8px",
display: "flex",
alignItems: "center",
fontSize: 12,
fontFamily: mono ? "var(--v5-font-mono)" : "var(--v5-font-sans)",
background: "var(--cp-bg-subtle)",
border: "1px solid var(--cp-border)",
borderRadius: 6,
color: muted ? "var(--cp-text-muted)" : "var(--cp-text)",
}}
>
{value}
</div>
);
}
function InlineLoader({ text }: { text: string }) {
return (
<span style={{ fontSize: 11, color: "var(--cp-text-muted)", display: "inline-flex", alignItems: "center", gap: 5 }}>
<Loader2 size={11} className="animate-spin" /> {text}
</span>
);
}
// 작은 칩 (선택된 컬럼 표시 등)
function CpChip({
active,
onClick,
children,
tone = "default",
}: {
active?: boolean;
onClick?: () => void;
children: React.ReactNode;
tone?: "default" | "primary";
}) {
return (
<button
type="button"
onClick={onClick}
style={{
display: "inline-flex",
alignItems: "center",
gap: 4,
padding: "3px 8px",
background: active
? "rgba(var(--v5-primary-rgb), 0.14)"
: "var(--cp-bg-subtle)",
border: `1px solid ${
active
? "rgba(var(--v5-primary-rgb), 0.5)"
: "var(--cp-border-subtle)"
}`,
borderRadius: 4,
fontSize: 10.5,
fontWeight: active ? 600 : 500,
color: active ? "var(--v5-primary, #6c5ce7)" : tone === "primary" ? "var(--cp-text)" : "var(--cp-text-sec)",
cursor: "pointer",
fontFamily: "var(--v5-font-sans)",
}}
>
{children}
</button>
);
}
// ───────────────────────────────────────────────────────
// Main Component
// ───────────────────────────────────────────────────────
export const InvRepeaterConfigPanel: React.FC<InvRepeaterConfigPanelProps> = ({
config: propConfig,
onChange,
currentTableName: propCurrentTableName,
screenTableName,
menuObjid,
}) => {
const currentTableName = screenTableName || propCurrentTableName;
// config 안전 초기화
const config: V2RepeaterConfig = useMemo(() => {
return {
...DEFAULT_REPEATER_CONFIG,
...propConfig,
render_mode: propConfig?.render_mode || DEFAULT_REPEATER_CONFIG.render_mode,
data_source: { ...DEFAULT_REPEATER_CONFIG.data_source, ...propConfig?.data_source },
columns: propConfig?.columns || [],
modal: { ...DEFAULT_REPEATER_CONFIG.modal, ...propConfig?.modal } as V2RepeaterConfig["modal"],
features: { ...DEFAULT_REPEATER_CONFIG.features, ...propConfig?.features } as V2RepeaterConfig["features"],
} as V2RepeaterConfig;
}, [propConfig]);
// ── State ────────────────────────────────────────────
const [allTables, setAllTables] = useState<Array<{ tableName: string; displayName: string }>>([]);
const [loadingTables, setLoadingTables] = useState(false);
const [relatedTables, setRelatedTables] = useState<TableRelation[]>([]);
const [loadingRelations, setLoadingRelations] = useState(false);
const [currentTableColumns, setCurrentTableColumns] = useState<ColumnOption[]>([]);
const [entityColumns, setEntityColumns] = useState<EntityColumnOption[]>([]);
const [sourceTableColumns, setSourceTableColumns] = useState<ColumnOption[]>([]);
const [loadingColumns, setLoadingColumns] = useState(false);
const [loadingSourceColumns, setLoadingSourceColumns] = useState(false);
const [entityJoinData, setEntityJoinData] = useState<{
joinTables: Array<{
tableName: string;
currentDisplayColumn: string;
joinConfig?: { sourceColumn?: string; source_column?: string };
availableColumns: Array<{
columnName: string;
columnLabel: string;
dataType: string;
input_type?: string;
}>;
}>;
availableColumns: Array<any>;
}>({ joinTables: [], availableColumns: [] });
const [loadingEntityJoins, setLoadingEntityJoins] = useState(false);
const [calcRules, setCalcRules] = useState<CalcRule[]>(config.calculation_rules || []);
const [expandedColumn, setExpandedColumn] = useState<string | null>(null);
const [parentMenus, setParentMenus] = useState<any[]>([]);
const [loadingMenus, setLoadingMenus] = useState(false);
const [numberingRules, setNumberingRules] = useState<NumberingRuleConfig[]>([]);
const [loadingNumberingRules, setLoadingNumberingRules] = useState(false);
const [selectedMenuObjid, setSelectedMenuObjid] = useState<number | undefined>(() => {
const existing = config.columns.find(
(c) => c.auto_fill?.type === "numbering" && c.auto_fill.selected_menu_objid,
);
return existing?.auto_fill?.selected_menu_objid || (menuObjid ? Number(menuObjid) : undefined);
});
// ── Derived ──────────────────────────────────────────
const triple = useMemo(() => resolveTriple(config), [config]);
const visibleFormats = FORMATS_BY_TYPE[triple.type] || [];
const showFormatTrigger = visibleFormats.length > 1;
const isInlineMode = triple.type === "inline";
const isModalMode = triple.type === "modal";
const isSourceDetail = isModalMode && triple.format === "source-detail";
const targetTableForColumns = config.use_custom_table && config.main_table_name
? config.main_table_name
: currentTableName;
const entityJoinTargetTable = config.use_custom_table && config.main_table_name
? config.main_table_name
: currentTableName;
const inputableColumns = useMemo(() => {
const fkColumn = config.data_source?.foreign_key;
return currentTableColumns.filter(
(col) => col.columnName !== fkColumn && col.input_type !== "entity",
);
}, [currentTableColumns, config.data_source?.foreign_key]);
// ── Helpers ──────────────────────────────────────────
const updateConfig = useCallback(
(updates: Partial<V2RepeaterConfig>) => {
onChange({ ...config, ...updates });
},
[config, onChange],
);
const updateFeatures = useCallback(
(field: string, value: boolean) => {
updateConfig({ features: { ...config.features, [field]: value } as any });
},
[config.features, updateConfig],
);
const updateModal = useCallback(
(field: string, value: any) => {
updateConfig({ modal: { ...config.modal, [field]: value } as V2RepeaterConfig["modal"] });
},
[config.modal, updateConfig],
);
const updateStyle = useCallback(
(field: string, value: any) => {
updateConfig({ style: { ...(config.style || {}), [field]: value } });
},
[config.style, updateConfig],
);
const handleTypeChange = useCallback(
(newType: RepType) => {
const def = FORMATS_BY_TYPE[newType][0]?.id as RepFormat;
const next = applyTriple(config, newType, def);
onChange(next);
},
[config, onChange],
);
const handleFormatChange = useCallback(
(newFormat: string) => {
const next = applyTriple(config, triple.type, newFormat as RepFormat);
onChange(next);
},
[config, triple.type, onChange],
);
const handleSaveTableSelect = useCallback(
(tableName: string) => {
if (!tableName || tableName === currentTableName) {
updateConfig({
use_custom_table: false,
main_table_name: undefined,
foreign_key_column: undefined,
foreign_key_source_column: undefined,
});
return;
}
const relation = relatedTables.find((r) => r.tableName === tableName);
if (relation) {
updateConfig({
use_custom_table: true,
main_table_name: tableName,
foreign_key_column: relation.foreign_key_column,
foreign_key_source_column: relation.referenceColumn,
});
} else {
updateConfig({
use_custom_table: true,
main_table_name: tableName,
foreign_key_column: undefined,
foreign_key_source_column: "id",
});
}
},
[currentTableName, relatedTables, updateConfig],
);
const handleEntityColumnSelect = (columnName: string) => {
const sel = entityColumns.find((c) => c.columnName === columnName);
if (!sel) return;
const displayColInfo = sourceTableColumns.find((c) => c.columnName === sel.displayColumn);
const displayLabel = displayColInfo?.displayName || sel.displayColumn || "";
updateConfig({
data_source: {
...config.data_source,
source_table: sel.referenceTable || "",
foreign_key: sel.columnName,
reference_key: sel.referenceColumn || "id",
display_column: sel.displayColumn,
},
modal: {
...config.modal,
search_fields: sel.displayColumn ? [sel.displayColumn] : [],
source_display_columns: sel.displayColumn
? [{ key: sel.displayColumn, label: displayLabel }]
: [],
} as V2RepeaterConfig["modal"],
});
};
const toggleInputColumn = (col: ColumnOption) => {
const existingIdx = config.columns.findIndex(
(c) => c.key === col.columnName && !c.is_join_column && !c.is_source_display,
);
if (existingIdx >= 0) {
updateConfig({ columns: config.columns.filter((_, i) => i !== existingIdx) });
} else {
const next: RepeaterColumnConfig = {
key: col.columnName,
title: col.displayName,
width: "auto",
visible: true,
editable: true,
input_type: col.input_type || "text",
detail_settings: col.detailSettings
? {
code_group: col.detailSettings.codeGroup,
reference_table: col.detailSettings.referenceTable,
reference_column: col.detailSettings.referenceColumn,
display_column: col.detailSettings.displayColumn,
format: col.detailSettings.format,
}
: undefined,
};
updateConfig({ columns: [...config.columns, next] });
}
};
const toggleSourceDisplayColumn = (col: ColumnOption) => {
const exists = config.columns.some((c) => c.key === col.columnName && c.is_source_display);
if (exists) {
updateConfig({ columns: config.columns.filter((c) => c.key !== col.columnName) });
} else {
updateConfig({
columns: [
...config.columns,
{
key: col.columnName,
title: col.displayName,
width: "auto",
visible: true,
editable: false,
is_source_display: true,
},
],
});
}
};
const isColumnAdded = (name: string) =>
config.columns.some((c) => c.key === name && !c.is_source_display && !c.is_join_column);
const isSourceColumnSelected = (name: string) =>
config.columns.some((c) => c.key === name && c.is_source_display);
const updateColumnProp = (key: string, field: keyof RepeaterColumnConfig, value: any) => {
updateConfig({
columns: config.columns.map((c) => (c.key === key ? { ...c, [field]: value } : c)),
});
};
const removeColumn = (key: string) => {
const col = config.columns.find((c) => c.key === key);
if (!col) return;
if (col.is_join_column) {
const newColumns = config.columns.filter((c) => c.key !== key);
const newJoins = config.entity_joins
?.map((j) => ({ ...j, columns: j.columns.filter((c) => c.display_field !== key) }))
.filter((j) => j.columns.length > 0);
updateConfig({ columns: newColumns, entity_joins: newJoins });
} else {
updateConfig({ columns: config.columns.filter((c) => c.key !== key) });
}
};
const moveColumn = (fromIdx: number, toIdx: number) => {
if (fromIdx === toIdx) return;
const next = [...config.columns];
const [moved] = next.splice(fromIdx, 1);
next.splice(toIdx, 0, moved);
updateConfig({ columns: next });
};
const toggleEntityJoinColumn = (
joinTableName: string,
sourceColumn: string,
refColumnName: string,
refColumnLabel: string,
displayField: string,
columnType?: string,
) => {
const currentJoins = config.entity_joins || [];
const idx = currentJoins.findIndex(
(j) => j.source_column === sourceColumn && j.reference_table === joinTableName,
);
let newJoins = [...currentJoins];
let newColumns = [...config.columns];
if (idx >= 0) {
const cur = currentJoins[idx];
const colIdx = cur.columns.findIndex((c) => c.reference_field === refColumnName);
if (colIdx >= 0) {
const updated = cur.columns.filter((_, i) => i !== colIdx);
if (updated.length === 0) newJoins = newJoins.filter((_, i) => i !== idx);
else newJoins[idx] = { ...cur, columns: updated };
newColumns = newColumns.filter((c) => !(c.key === displayField && c.is_join_column));
} else {
newJoins[idx] = {
...cur,
columns: [...cur.columns, { reference_field: refColumnName, display_field: displayField }],
};
newColumns.push({
key: displayField,
title: refColumnLabel,
width: "auto",
visible: true,
editable: false,
is_join_column: true,
input_type: columnType || "text",
});
}
} else {
newJoins.push({
source_column: sourceColumn,
reference_table: joinTableName,
columns: [{ reference_field: refColumnName, display_field: displayField }],
});
newColumns.push({
key: displayField,
title: refColumnLabel,
width: "auto",
visible: true,
editable: false,
is_join_column: true,
input_type: columnType || "text",
});
}
updateConfig({ entity_joins: newJoins, columns: newColumns });
};
const isEntityJoinColumnActive = (joinTableName: string, sourceColumn: string, refColumnName: string) =>
(config.entity_joins || []).some(
(j) =>
j.source_column === sourceColumn &&
j.reference_table === joinTableName &&
j.columns.some((c) => c.reference_field === refColumnName),
);
// 계산 규칙
const syncCalcRules = (rules: CalcRule[]) => {
setCalcRules(rules);
updateConfig({ calculation_rules: rules });
};
const addCalcRule = () =>
syncCalcRules([...calcRules, { id: `calc_${Date.now()}`, target_column: "", formula: "" }]);
const removeCalcRule = (id: string) => syncCalcRules(calcRules.filter((r) => r.id !== id));
const updateCalcRule = (id: string, field: keyof CalcRule, value: string) =>
syncCalcRules(calcRules.map((r) => (r.id === id ? { ...r, [field]: value } : r)));
const insertColToFormula = (id: string, key: string) => {
const r = calcRules.find((x) => x.id === id);
if (!r) return;
const next = r.formula ? `${r.formula} ${key}` : key;
updateCalcRule(id, "formula", next);
};
const formulaToKorean = (formula: string): string => {
if (!formula) return "";
let out = formula;
const sorted = [...config.columns].sort((a, b) => b.key.length - a.key.length);
for (const c of sorted) {
if (c.title && c.key) out = out.replace(new RegExp(`\\b${c.key}\\b`, "g"), c.title);
}
return out;
};
// ── Effects ──────────────────────────────────────────
useEffect(() => {
let cancelled = false;
(async () => {
setLoadingTables(true);
try {
const r = await tableManagementApi.getTableList();
if (cancelled) return;
if (r.success && r.data) {
setAllTables(
r.data.map((t: any) => ({
tableName: t.tableName || t.table_name,
displayName: t.displayName || t.table_label || t.tableName || t.table_name,
})),
);
}
} catch (e) {
console.error("[InvRepeater] 테이블 목록 로드 실패:", e);
} finally {
if (!cancelled) setLoadingTables(false);
}
})();
return () => {
cancelled = true;
};
}, []);
useEffect(() => {
let cancelled = false;
(async () => {
const base = currentTableName || config.main_table_name;
if (!base) {
setRelatedTables([]);
return;
}
setLoadingRelations(true);
try {
const { apiClient } = await import("@/lib/api/client");
const all: TableRelation[] = [];
if (currentTableName) {
const r = await apiClient.get(`/table-management/columns/${currentTableName}/referenced-by`);
if (r.data.success && r.data.data) {
r.data.data.forEach((rel: any) => {
all.push({
tableName: rel.tableName || rel.table_name,
tableLabel: rel.tableLabel || rel.table_label || rel.tableName || rel.table_name,
foreign_key_column: rel.columnName || rel.column_name,
referenceColumn: rel.referenceColumn || rel.reference_column || "id",
});
});
}
}
if (config.main_table_name && config.main_table_name !== currentTableName) {
const r2 = await apiClient.get(`/table-management/columns/${config.main_table_name}/referenced-by`);
if (r2.data.success && r2.data.data) {
r2.data.data.forEach((rel: any) => {
const exists = all.some((x) => x.tableName === (rel.tableName || rel.table_name));
if (!exists)
all.push({
tableName: rel.tableName || rel.table_name,
tableLabel: rel.tableLabel || rel.table_label || rel.tableName || rel.table_name,
foreign_key_column: rel.columnName || rel.column_name,
referenceColumn: rel.referenceColumn || rel.reference_column || "id",
});
});
}
}
if (!cancelled) setRelatedTables(all);
} catch (e) {
console.error("[InvRepeater] 연관 테이블 로드 실패:", e);
if (!cancelled) setRelatedTables([]);
} finally {
if (!cancelled) setLoadingRelations(false);
}
})();
return () => {
cancelled = true;
};
}, [currentTableName, config.main_table_name]);
useEffect(() => {
let cancelled = false;
(async () => {
if (!targetTableForColumns) {
setCurrentTableColumns([]);
setEntityColumns([]);
return;
}
setLoadingColumns(true);
try {
const data = await tableTypeApi.getColumns(targetTableForColumns);
if (cancelled) return;
const cols: ColumnOption[] = [];
const ents: EntityColumnOption[] = [];
for (const c of data) {
let detail: any = null;
if ((c as any).detailSettings) {
try {
detail =
typeof (c as any).detailSettings === "string"
? JSON.parse((c as any).detailSettings)
: (c as any).detailSettings;
} catch {}
}
const co: ColumnOption = {
columnName: (c as any).columnName || (c as any).column_name,
displayName:
(c as any).displayName || (c as any).columnLabel || (c as any).columnName || (c as any).column_name,
input_type: (c as any).input_type,
detailSettings: detail
? {
codeGroup: detail.codeGroup,
referenceTable: detail.reference_table,
referenceColumn: detail.referenceColumn,
displayColumn: detail.displayColumn,
format: detail.format,
}
: undefined,
};
cols.push(co);
if (co.input_type === "entity") {
const refTable = detail?.reference_table || (c as any).reference_table;
if (refTable) {
ents.push({
columnName: co.columnName,
displayName: co.displayName,
referenceTable: refTable,
referenceColumn: detail?.referenceColumn || (c as any).referenceColumn || "id",
displayColumn: detail?.displayColumn || (c as any).displayColumn,
});
}
}
}
setCurrentTableColumns(cols);
setEntityColumns(ents);
} catch (e) {
console.error("[InvRepeater] 저장 테이블 컬럼 로드 실패:", e);
if (!cancelled) {
setCurrentTableColumns([]);
setEntityColumns([]);
}
} finally {
if (!cancelled) setLoadingColumns(false);
}
})();
return () => {
cancelled = true;
};
}, [targetTableForColumns]);
useEffect(() => {
let cancelled = false;
(async () => {
const src = config.data_source?.source_table;
if (!src || !isModalMode) {
setSourceTableColumns([]);
return;
}
setLoadingSourceColumns(true);
try {
const data = await tableTypeApi.getColumns(src);
if (cancelled) return;
setSourceTableColumns(
data.map((c: any) => ({
columnName: c.columnName || c.column_name,
displayName: c.displayName || c.columnLabel || c.columnName || c.column_name,
input_type: c.input_type,
})),
);
} catch (e) {
console.error("[InvRepeater] 소스 테이블 컬럼 로드 실패:", e);
if (!cancelled) setSourceTableColumns([]);
} finally {
if (!cancelled) setLoadingSourceColumns(false);
}
})();
return () => {
cancelled = true;
};
}, [config.data_source?.source_table, isModalMode]);
useEffect(() => {
let cancelled = false;
(async () => {
if (!entityJoinTargetTable) return;
setLoadingEntityJoins(true);
try {
const r = await entityJoinApi.getEntityJoinColumns(entityJoinTargetTable);
if (cancelled) return;
setEntityJoinData({ joinTables: (r as any).joinTables || [], availableColumns: (r as any).availableColumns || [] });
} catch (e) {
console.error("[InvRepeater] Entity 조인 로드 실패:", e);
if (!cancelled) setEntityJoinData({ joinTables: [], availableColumns: [] });
} finally {
if (!cancelled) setLoadingEntityJoins(false);
}
})();
return () => {
cancelled = true;
};
}, [entityJoinTargetTable]);
useEffect(() => {
let cancelled = false;
(async () => {
setLoadingMenus(true);
try {
const { apiClient } = await import("@/lib/api/client");
const r = await apiClient.get("/admin/menus");
if (cancelled) return;
if (r.data.success && r.data.data) {
setParentMenus(r.data.data.filter((m: any) => m.menu_type === "1" && m.lev === 2));
}
} catch (e) {
console.error("[InvRepeater] 메뉴 로드 실패:", e);
} finally {
if (!cancelled) setLoadingMenus(false);
}
})();
return () => {
cancelled = true;
};
}, []);
useEffect(() => {
let cancelled = false;
(async () => {
if (!selectedMenuObjid) {
setNumberingRules([]);
return;
}
setLoadingNumberingRules(true);
try {
const r = await getAvailableNumberingRules(selectedMenuObjid);
if (cancelled) return;
if ((r as any)?.success && (r as any).data) setNumberingRules((r as any).data);
} catch (e) {
console.error("[InvRepeater] 채번 규칙 로드 실패:", e);
if (!cancelled) setNumberingRules([]);
} finally {
if (!cancelled) setLoadingNumberingRules(false);
}
})();
return () => {
cancelled = true;
};
}, [selectedMenuObjid]);
// ── Render ───────────────────────────────────────────
return (
<div style={{ fontFamily: "var(--v5-font-sans)", color: "var(--cp-text)", padding: "0 12px" }}>
{/* ── ① 데이터 소속 ─────────────────────────── */}
<CPSection title="① 데이터 소속" desc="저장 테이블 + FK 매핑">
<CPRow label="저장 테이블">
<CPSelect
value={
config.use_custom_table && config.main_table_name
? config.main_table_name
: currentTableName || ""
}
onChange={(v) => v && handleSaveTableSelect(v)}
disabled={loadingTables || loadingRelations}
>
<option value=""> </option>
{currentTableName && (
<option value={currentTableName}>{currentTableName} ( )</option>
)}
{relatedTables.map((rel) => (
<option key={rel.tableName} value={rel.tableName}>
{rel.tableLabel} ( · FK )
</option>
))}
{allTables
.filter(
(t) =>
t.tableName !== currentTableName &&
!relatedTables.some((r) => r.tableName === t.tableName),
)
.map((t) => (
<option key={t.tableName} value={t.tableName}>
{t.displayName}
</option>
))}
</CPSelect>
</CPRow>
{config.use_custom_table &&
config.main_table_name &&
currentTableName &&
!relatedTables.some((r) => r.tableName === config.main_table_name) && (
<>
<CPRow label="FK 컬럼" help="저장 테이블의 FK 컬럼명">
<CPText
value={config.foreign_key_column || ""}
onChange={(v) => updateConfig({ foreign_key_column: v })}
placeholder="예: master_id"
/>
</CPRow>
<CPRow label="PK 컬럼" help="화면 테이블의 PK 컬럼명">
<CPText
value={config.foreign_key_source_column || "id"}
onChange={(v) => updateConfig({ foreign_key_source_column: v })}
placeholder="id"
/>
</CPRow>
</>
)}
{currentTableName && (
<Hint>
:{" "}
<span style={{ fontWeight: 700, color: "var(--cp-text)" }}>{currentTableName}</span>
{" · "} {currentTableColumns.length} / {entityColumns.length}
</Hint>
)}
{config.use_custom_table && config.main_table_name && !currentTableName && (
<Hint tone="ok"> · </Hint>
)}
</CPSection>
{/* ── ② 유형별 설정 ─────────────────────────── */}
<CPSection title="② 유형별 설정" desc="입력 방식과 변형">
<CPRow label="입력 방식">
<CPSegment
value={triple.type}
onChange={(v) => handleTypeChange(v as RepType)}
options={TYPES.map((t) => ({
value: t.id,
label: t.name,
disabled: t.disabled,
}))}
/>
</CPRow>
{showFormatTrigger && (
<CPFormatTrigger
formats={visibleFormats}
value={triple.format}
onChange={handleFormatChange}
/>
)}
{/* format 별 본문 */}
{isInlineMode && (
<>
{triple.format === "grouped" && (
<Hint tone="warn"> UI ( ).</Hint>
)}
</>
)}
{isModalMode && (
<>
<CPRow label="검색 엔티티" help="모달에서 검색할 엔티티 (FK 컬럼)">
{loadingColumns ? (
<InlineLoader text="컬럼 로딩..." />
) : (
<CPSelect
value={config.data_source?.foreign_key || ""}
onChange={handleEntityColumnSelect}
disabled={!targetTableForColumns}
>
<option value="">
{!targetTableForColumns
? "저장 테이블 먼저 선택"
: entityColumns.length === 0
? "엔티티 타입 컬럼 없음"
: "엔티티 컬럼 선택"}
</option>
{entityColumns.map((col) => (
<option key={col.columnName} value={col.columnName}>
{col.displayName} {col.referenceTable}
</option>
))}
</CPSelect>
)}
</CPRow>
{config.data_source?.source_table && (
<Hint>
:{" "}
<span style={{ fontWeight: 700, color: "var(--cp-text)" }}>
{config.data_source.source_table}
</span>
{" · "}
{config.data_source.foreign_key} FK
</Hint>
)}
<CPRow label="모달 제목">
<CPText
value={config.modal?.title || ""}
onChange={(v) => updateModal("title", v)}
placeholder="항목 검색"
/>
</CPRow>
<CPRow label="검색 버튼">
<CPText
value={config.modal?.button_text || ""}
onChange={(v) => updateModal("button_text", v)}
placeholder="검색"
/>
</CPRow>
{isSourceDetail && (
<SourceDetailEditor
value={config.source_detail_config}
allTables={allTables}
onChange={(next) => updateConfig({ source_detail_config: next })}
/>
)}
</>
)}
{triple.type === "button" && (
<Hint tone="warn"> (reserved) </Hint>
)}
</CPSection>
{/* ── ③ 데이터 필터 (modal/source-detail 일 때) ─── */}
{isModalMode && config.data_source?.source_table && (
<CPSection title="③ 데이터 필터" desc={`${config.data_source.source_table} 검색 시 WHERE 조건`}>
<CPRow label="필터 컬럼">
<CPText
value={config.data_source.filter?.column || ""}
onChange={(v) =>
updateConfig({
data_source: {
...config.data_source,
filter: { ...(config.data_source.filter || { column: "", value: "" }), column: v },
},
})
}
placeholder="status"
/>
</CPRow>
<CPRow label="필터 값">
<CPText
value={config.data_source.filter?.value || ""}
onChange={(v) =>
updateConfig({
data_source: {
...config.data_source,
filter: { ...(config.data_source.filter || { column: "", value: "" }), value: v },
},
})
}
placeholder="ACTIVE"
/>
</CPRow>
</CPSection>
)}
{/* ── ▾ 동작 / 편집 ─────────────────────────── */}
<CPGroup title="동작 / 편집" defaultOpen>
<FeatureChipGrid
items={[
{
key: "show_add_button",
label: "추가",
default: true,
desc: "리피터 우상단에 [+ 추가] 버튼이 표시됩니다.\n클릭 시 빈 행이 한 줄 추가되고 사용자가 컬럼별로 직접 입력해요.\n모달 모드에서는 검색 모달이 함께 열립니다.",
},
{
key: "show_delete_button",
label: "삭제",
default: true,
desc: "선택된 행을 제거합니다 (체크박스 또는 행 우측 🗑).\n저장 시 DB 에서도 함께 삭제되며, FK 로 연결된 자식 행이 있으면 cascade 정책에 따라 함께 지워질 수 있어요.",
},
{
key: "inline_edit",
label: "인라인",
desc: "셀을 클릭하는 즉시 편집 모드로 전환됩니다.\n별도 [수정] 버튼이 필요 없어 입력 속도가 빠르고, 빠른 데이터 입력에 유리해요.\nOFF 시: 더블클릭이나 별도 폼 모달이 필요합니다.",
},
{
key: "multi_select",
label: "다중 선택",
default: true,
desc: "Shift/Ctrl + 클릭으로 여러 행을 동시에 선택할 수 있어요.\n일괄 삭제, 일괄 복제, 일괄 상태 변경 등 bulk action 에 필요합니다.\nOFF 시 한 번에 한 행만 선택 가능.",
},
{
key: "drag_sort",
label: "드래그",
desc: "행 좌측의 ⋮⋮ 핸들을 잡아 순서를 자유롭게 변경할 수 있어요.\n작업 순서가 의미 있는 데이터 (공정 단계, 결재 라인, 우선순위 등)에 권장.\nDB 에는 sort_order 컬럼이 자동 갱신됩니다.",
},
{
key: "duplicate_row",
label: "복제",
isNew: true,
desc: "행 우측 메뉴에서 [복제] 선택 시 동일 데이터로 새 행이 바로 아래 추가됩니다.\n비슷한 항목 여러 개를 빠르게 입력할 때 유용해요 (예: 같은 품목 다른 수량/일자).",
},
{
key: "inline_validation",
label: "실시간 검증",
isNew: true,
desc: "필수/패턴/범위 위반 시 셀 테두리가 빨간색으로 표시되고 호버하면 이유가 나타납니다.\n저장 [전에] 오류를 발견할 수 있어 사용자 경험이 개선돼요.\n검증 규칙은 컬럼별 설정에서 정의합니다.",
},
{
key: "keyboard_nav",
label: "키보드",
desc: "Enter = 다음 행으로 이동\nTab = 다음 셀, Shift+Tab = 이전 셀\n방향키 = 셀 단위 이동\n마우스 없이 빠른 대량 입력이 가능합니다 (Excel 스타일).",
},
]}
source={config.features as any}
onToggle={updateFeatures}
/>
</CPGroup>
{/* ── ▾ 표시 ─────────────────────────────── */}
<CPGroup title="표시" defaultOpen={false}>
<FeatureChipGrid
items={[
{
key: "show_row_number",
label: "행 번호",
desc: "행 맨 앞에 1, 2, 3... 순번이 자동 표시됩니다.\n사용자에게 행 위치를 시각적으로 알려줄 때 (인쇄, 보고서) 사용.\nDB 에는 저장되지 않는 표시 전용 번호.",
},
{
key: "selectable",
label: "체크박스",
desc: "행 맨 앞에 체크박스 컬럼이 추가됩니다.\n다중 선택을 시각적으로 보여주며, 헤더 체크로 전체 선택 가능.\n[다중 선택] 기능과 함께 켜는 게 일반적이에요.",
},
{
key: "footer_summary",
label: "합계 행",
desc: "숫자 컬럼 (수량, 금액, 시간 등) 의 합계가 표 하단에 자동 표시됩니다.\n컬럼별로 SUM / AVG / MIN / MAX / COUNT 중 선택 가능.\n총합/소계가 중요한 매출/원가 화면에 적합.",
},
{
key: "sticky_header",
label: "헤더 고정",
desc: "스크롤해도 컬럼 헤더가 상단에 고정됩니다.\n행이 많은 표 (50행+) 에서 컬럼명이 안 보여 헷갈리는 문제를 해결.\n고정 행의 z-index 충돌 시 [최대 높이] 도 함께 설정하세요.",
},
{
key: "pagination",
label: "페이지",
desc: "행이 많을 때 페이지 단위로 끊어 표시합니다 (기본 100행/페이지).\n한 페이지당 행 수는 별도 설정 가능 (10/20/50/100).\n총 데이터가 1,000행+ 인 마스터 테이블에 권장.",
},
]}
source={config.features as any}
onToggle={updateFeatures}
/>
<div style={{ marginTop: 8 }}>
<FeatureChipGrid
items={[
{
key: "compact",
label: "컴팩트",
desc: "행 높이를 좁게 (기본 36px → 28px) 표시합니다.\n한 화면에 더 많은 행을 보여줘 정보 밀도가 높아요.\n관리자 화면, 데이터 많은 조회 화면에 적합.",
},
{
key: "borderless",
label: "테두리 X",
desc: "셀 사이의 세로 보더(구분선)를 제거하고 행간 zebra stripe(짝수행 음영)만 남깁니다.\n시각적으로 더 미니멀하지만 컬럼 구분이 약해져 — 컬럼 적은 표 (5개 이하) 에 적합.",
},
]}
source={config.style as any}
onToggle={updateStyle}
/>
</div>
</CPGroup>
{/* ── ▾ 컬럼 구성 ─────────────────────────── */}
<CPGroup title="컬럼 구성" defaultOpen={false}>
<ColumnPickerSection
isModalMode={isModalMode}
loadingSourceColumns={loadingSourceColumns}
sourceTableName={config.data_source?.source_table}
sourceTableColumns={sourceTableColumns}
isSourceColumnSelected={isSourceColumnSelected}
toggleSourceDisplayColumn={toggleSourceDisplayColumn}
targetTableForColumns={targetTableForColumns}
loadingColumns={loadingColumns}
inputableColumns={inputableColumns}
isColumnAdded={isColumnAdded}
toggleInputColumn={toggleInputColumn}
/>
<SelectedColumnsSection
columns={config.columns}
expandedColumn={expandedColumn}
setExpandedColumn={setExpandedColumn}
updateColumnProp={updateColumnProp}
removeColumn={removeColumn}
moveColumn={moveColumn}
parentMenus={parentMenus}
loadingMenus={loadingMenus}
numberingRules={numberingRules}
loadingNumberingRules={loadingNumberingRules}
selectedMenuObjid={selectedMenuObjid}
setSelectedMenuObjid={setSelectedMenuObjid}
/>
</CPGroup>
{/* ── ▾ 고급 설정 ─────────────────────────── */}
<CPGroup title="고급 설정" defaultOpen={false}>
{!isSourceDetail && (
<CPRow label="소스 디테일 자동 조회" help="마스터 데이터의 자식 행 자동 채움">
<CPSwitch
value={!!config.source_detail_config}
onChange={(checked) => {
if (checked)
updateConfig({
source_detail_config: { table_name: "", foreign_key: "", parent_key: "" },
});
else updateConfig({ source_detail_config: undefined });
}}
/>
</CPRow>
)}
{!isSourceDetail && config.source_detail_config && (
<SourceDetailEditor
value={config.source_detail_config}
allTables={allTables}
onChange={(next) => updateConfig({ source_detail_config: next })}
/>
)}
{(isModalMode || isInlineMode) && config.columns.length > 0 && (
<CalcRulesEditor
calcRules={calcRules}
columns={config.columns}
onAdd={addCalcRule}
onRemove={removeCalcRule}
onUpdate={updateCalcRule}
onInsertCol={insertColToFormula}
formulaToKorean={formulaToKorean}
/>
)}
<EntityJoinEditor
loading={loadingEntityJoins}
entityJoinData={entityJoinData}
entityJoinTargetTable={entityJoinTargetTable}
isEntityJoinColumnActive={isEntityJoinColumnActive}
toggleEntityJoinColumn={toggleEntityJoinColumn}
configuredJoins={config.entity_joins || []}
onRemoveJoin={(idx) =>
updateConfig({ entity_joins: (config.entity_joins || []).filter((_, i) => i !== idx) })
}
/>
<FeatureChipGrid
items={[
{
key: "row_lock",
label: "행 잠금",
desc: "한 번 저장(commit) 된 행은 편집/삭제가 불가능해집니다.\n변경 이력이 중요한 회계, 공정 실적, 결재 데이터 등 감사(audit) 가 필요한 화면에 권장.\n새로 추가한 행은 첫 저장 전까지는 자유롭게 편집 가능해요.",
},
{
key: "auto_save",
label: "자동 저장",
desc: "셀 변경 직후 백엔드에 PATCH 가 자동 호출됩니다.\n별도 [저장] 버튼이 필요 없어 빠르지만, 네트워크 오류 시 데이터 손실 가능성이 있어요.\n현재는 실험적 (experimental) — 핵심 데이터에는 권장하지 않음.",
},
{
key: "export_csv",
label: "CSV 출력",
desc: "리피터 우상단에 ⤓ 다운로드 아이콘이 표시됩니다.\n클릭 시 현재 표의 행 데이터를 UTF-8 BOM CSV 로 다운로드.\n엑셀에서 바로 열리며 한글도 깨지지 않아요.",
},
{
key: "no_duplicate_pick",
label: "중복 방지",
default: true,
desc: "modal pick-many 모드에서 이미 추가된 행은 모달 검색 결과에서 회색 처리되어 중복 선택을 막습니다.\n예: 발주서에 같은 품목을 두 번 넣지 못하게 방어.\n기본 ON 권장.",
},
]}
source={config.features as any}
onToggle={updateFeatures}
/>
<CPRow label="최대 높이 (px)">
<CPNumber
value={config.style?.max_height ? Number(String(config.style.max_height).replace(/\D/g, "")) || undefined : undefined}
onChange={(v) => updateStyle("max_height", v ? `${v}px` : undefined)}
placeholder="자동"
/>
</CPRow>
<CPRow label="최소 높이 (px)">
<CPNumber
value={config.style?.min_height ? Number(String(config.style.min_height).replace(/\D/g, "")) || undefined : undefined}
onChange={(v) => updateStyle("min_height", v ? `${v}px` : undefined)}
placeholder="자동"
/>
</CPRow>
</CPGroup>
</div>
);
};
InvRepeaterConfigPanel.displayName = "InvRepeaterConfigPanel";
// ───────────────────────────────────────────────────────
// SourceDetailEditor — 소스 디테일 자동 조회 sub-config
// ───────────────────────────────────────────────────────
function SourceDetailEditor({
value,
allTables,
onChange,
}: {
value: V2RepeaterConfig["source_detail_config"];
allTables: Array<{ tableName: string; displayName: string }>;
onChange: (next: V2RepeaterConfig["source_detail_config"]) => void;
}) {
if (!value) return null;
return (
<div
style={{
marginTop: 6,
marginLeft: 6,
paddingLeft: 10,
borderLeft: "2px solid rgba(var(--v5-primary-rgb), 0.3)",
}}
>
<CPRow label="디테일 테이블">
<CPSelect
value={value.table_name || ""}
onChange={(v) => onChange({ ...value, table_name: v })}
>
<option value=""> </option>
{allTables.map((t) => (
<option key={t.tableName} value={t.tableName}>
{t.displayName}
</option>
))}
</CPSelect>
</CPRow>
<CPRow label="디테일 FK">
<CPText
value={value.foreign_key || ""}
onChange={(v) => onChange({ ...value, foreign_key: v })}
placeholder="order_no"
/>
</CPRow>
<CPRow label="마스터 키">
<CPText
value={value.parent_key || ""}
onChange={(v) => onChange({ ...value, parent_key: v })}
placeholder="order_no"
/>
</CPRow>
<Hint>
[{value.parent_key || "?"}] {value.table_name || "?"}.
{value.foreign_key || "?"}
</Hint>
</div>
);
}
// ───────────────────────────────────────────────────────
// ColumnPickerSection — 입력/소스 컬럼 토글
// ───────────────────────────────────────────────────────
function ColumnPickerSection({
isModalMode,
loadingSourceColumns,
sourceTableName,
sourceTableColumns,
isSourceColumnSelected,
toggleSourceDisplayColumn,
targetTableForColumns,
loadingColumns,
inputableColumns,
isColumnAdded,
toggleInputColumn,
}: {
isModalMode: boolean;
loadingSourceColumns: boolean;
sourceTableName?: string;
sourceTableColumns: ColumnOption[];
isSourceColumnSelected: (n: string) => boolean;
toggleSourceDisplayColumn: (c: ColumnOption) => void;
targetTableForColumns?: string;
loadingColumns: boolean;
inputableColumns: ColumnOption[];
isColumnAdded: (n: string) => boolean;
toggleInputColumn: (c: ColumnOption) => void;
}) {
return (
<>
<Hint>
{isModalMode
? "소스 테이블 컬럼은 표시용, 저장 테이블 컬럼은 입력용"
: "체크한 컬럼이 리피터에 입력 필드로 표시"}
</Hint>
{isModalMode && sourceTableName && (
<div style={{ marginTop: 8 }}>
<SectionLabel icon={<Link2 size={11} />} text={`소스: ${sourceTableName} · 표시용`} primary />
{loadingSourceColumns ? (
<InlineLoader text="로딩..." />
) : sourceTableColumns.length === 0 ? (
<Hint> </Hint>
) : (
<ChipPickerBox>
{sourceTableColumns.map((col) => (
<CpChip
key={`src-${col.columnName}`}
active={isSourceColumnSelected(col.columnName)}
onClick={() => toggleSourceDisplayColumn(col)}
>
{col.displayName}
</CpChip>
))}
</ChipPickerBox>
)}
</div>
)}
<div style={{ marginTop: 8 }}>
<SectionLabel
icon={<Database size={11} />}
text={`저장: ${targetTableForColumns || "미선택"} · 입력용`}
/>
{loadingColumns ? (
<InlineLoader text="로딩..." />
) : inputableColumns.length === 0 ? (
<Hint> </Hint>
) : (
<ChipPickerBox>
{inputableColumns.map((col) => (
<CpChip
key={`in-${col.columnName}`}
active={isColumnAdded(col.columnName)}
onClick={() => toggleInputColumn(col)}
>
{col.displayName}
<span style={{ opacity: 0.55, fontSize: 9.5, marginLeft: 4 }}>
{col.input_type}
</span>
</CpChip>
))}
</ChipPickerBox>
)}
</div>
</>
);
}
function SectionLabel({ icon, text, primary }: { icon?: React.ReactNode; text: string; primary?: boolean }) {
return (
<div
style={{
display: "inline-flex",
alignItems: "center",
gap: 5,
marginBottom: 5,
fontSize: 10.5,
fontWeight: 600,
color: primary ? "var(--v5-primary, #6c5ce7)" : "var(--cp-text-muted)",
}}
>
{icon} {text}
</div>
);
}
function ChipPickerBox({ children }: { children: React.ReactNode }) {
return (
<div
style={{
display: "flex",
flexWrap: "wrap",
gap: 4,
padding: 8,
background: "var(--cp-bg-subtle)",
border: "1px solid var(--cp-border-subtle)",
borderRadius: 6,
}}
>
{children}
</div>
);
}
// ───────────────────────────────────────────────────────
// SelectedColumnsSection — 선택된 컬럼 카드 + 자동입력
// ───────────────────────────────────────────────────────
function SelectedColumnsSection({
columns,
expandedColumn,
setExpandedColumn,
updateColumnProp,
removeColumn,
moveColumn,
parentMenus,
loadingMenus,
numberingRules,
loadingNumberingRules,
selectedMenuObjid,
setSelectedMenuObjid,
}: {
columns: RepeaterColumnConfig[];
expandedColumn: string | null;
setExpandedColumn: (k: string | null) => void;
updateColumnProp: (key: string, field: keyof RepeaterColumnConfig, value: any) => void;
removeColumn: (key: string) => void;
moveColumn: (from: number, to: number) => void;
parentMenus: any[];
loadingMenus: boolean;
numberingRules: NumberingRuleConfig[];
loadingNumberingRules: boolean;
selectedMenuObjid: number | undefined;
setSelectedMenuObjid: (n: number | undefined) => void;
}) {
if (columns.length === 0) {
return <Hint> . .</Hint>;
}
return (
<div style={{ marginTop: 10 }}>
<SectionLabel text={`선택된 컬럼 ${columns.length}개 · 드래그로 순서 변경`} />
<div style={{ display: "flex", flexDirection: "column", gap: 4 }}>
{columns.map((col, idx) => (
<div key={col.key} style={{ display: "flex", flexDirection: "column", gap: 3 }}>
<div
draggable
onDragStart={(e) => e.dataTransfer.setData("colIdx", String(idx))}
onDragOver={(e) => e.preventDefault()}
onDrop={(e) => {
e.preventDefault();
const from = parseInt(e.dataTransfer.getData("colIdx"), 10);
if (!isNaN(from)) moveColumn(from, idx);
}}
style={{
display: "flex",
alignItems: "center",
gap: 6,
padding: "5px 7px",
background: col.is_source_display
? "rgba(var(--v5-primary-rgb), 0.06)"
: col.is_join_column
? "rgba(var(--v5-primary-rgb), 0.04)"
: "var(--cp-bg-subtle)",
border: `1px solid ${
col.is_source_display || col.is_join_column
? "rgba(var(--v5-primary-rgb), 0.25)"
: "var(--cp-border-subtle)"
}`,
borderRadius: 5,
opacity: col.hidden ? 0.55 : 1,
}}
>
<span style={{ cursor: "grab", color: "var(--cp-text-muted)", fontSize: 11 }}></span>
{!col.is_source_display && !col.is_join_column && (
<button
type="button"
onClick={() => setExpandedColumn(expandedColumn === col.key ? null : col.key)}
style={{
width: 16,
height: 16,
border: "none",
background: "transparent",
color: "var(--cp-text-muted)",
cursor: "pointer",
fontSize: 10,
}}
>
{expandedColumn === col.key ? "▾" : "▸"}
</button>
)}
{col.is_source_display ? (
<Link2 size={11} style={{ color: "var(--v5-primary, #6c5ce7)", flexShrink: 0 }} />
) : col.is_join_column ? (
<Link2 size={11} style={{ color: "var(--cp-text-muted)", flexShrink: 0 }} />
) : (
<Database size={11} style={{ color: "var(--cp-text-muted)", flexShrink: 0 }} />
)}
<input
type="text"
value={col.title}
onChange={(e) => updateColumnProp(col.key, "title", e.target.value)}
style={{
flex: 1,
height: 22,
padding: "0 6px",
fontSize: 11.5,
border: "1px solid transparent",
background: "transparent",
color: "var(--cp-text)",
outline: "none",
fontFamily: "var(--v5-font-sans)",
borderRadius: 3,
}}
onFocus={(e) => (e.currentTarget.style.background = "var(--cp-surface)")}
onBlur={(e) => (e.currentTarget.style.background = "transparent")}
/>
{!col.is_source_display && !col.is_join_column && (
<>
<CpChip
active={!col.hidden}
onClick={() => updateColumnProp(col.key, "hidden", !col.hidden)}
>
{col.hidden ? "히든" : "표시"}
</CpChip>
<CpChip
active={col.editable ?? true}
onClick={() => updateColumnProp(col.key, "editable", !(col.editable ?? true))}
>
{(col.editable ?? true) ? "편집" : "읽기"}
</CpChip>
{col.auto_fill?.type && col.auto_fill.type !== "none" && (
<Wand2 size={10} style={{ color: "rgb(168, 85, 247)" }} />
)}
</>
)}
<CPIconBtn tone="danger" size={20} onClick={() => removeColumn(col.key)}>
<Trash2 size={10} />
</CPIconBtn>
</div>
{!col.is_source_display && !col.is_join_column && expandedColumn === col.key && (
<AutoFillEditor
col={col}
updateColumnProp={updateColumnProp}
parentMenus={parentMenus}
loadingMenus={loadingMenus}
numberingRules={numberingRules}
loadingNumberingRules={loadingNumberingRules}
selectedMenuObjid={selectedMenuObjid}
setSelectedMenuObjid={setSelectedMenuObjid}
/>
)}
</div>
))}
</div>
</div>
);
}
// ───────────────────────────────────────────────────────
// AutoFillEditor — 자동 입력 sub-config
// ───────────────────────────────────────────────────────
function AutoFillEditor({
col,
updateColumnProp,
parentMenus,
loadingMenus,
numberingRules,
loadingNumberingRules,
selectedMenuObjid,
setSelectedMenuObjid,
}: {
col: RepeaterColumnConfig;
updateColumnProp: (key: string, field: keyof RepeaterColumnConfig, value: any) => void;
parentMenus: any[];
loadingMenus: boolean;
numberingRules: NumberingRuleConfig[];
loadingNumberingRules: boolean;
selectedMenuObjid: number | undefined;
setSelectedMenuObjid: (n: number | undefined) => void;
}) {
return (
<div
style={{
marginLeft: 22,
padding: "6px 8px",
background: "var(--cp-bg-subtle)",
border: "1px dashed var(--cp-border-subtle)",
borderRadius: 4,
}}
>
<CPRow label="자동 입력">
<CPSelect
value={col.auto_fill?.type || "none"}
onChange={(v) => {
const next = v === "none" ? undefined : { type: v as any };
updateColumnProp(col.key, "auto_fill", next);
}}
>
{AUTO_FILL_OPTIONS.map((o) => (
<option key={o.value} value={o.value}>
{o.label}
</option>
))}
</CPSelect>
</CPRow>
{col.auto_fill?.type === "numbering" && (
<>
<CPRow label="대상 메뉴">
<CPSelect
value={selectedMenuObjid ? String(selectedMenuObjid) : ""}
onChange={(v) => {
const n = parseInt(v);
setSelectedMenuObjid(n);
updateColumnProp(col.key, "auto_fill", {
...col.auto_fill,
selected_menu_objid: n,
numbering_rule_id: undefined,
});
}}
disabled={loadingMenus}
>
<option value="">{loadingMenus ? "메뉴 로딩..." : "메뉴 선택"}</option>
{parentMenus.map((m) => (
<option key={m.objid} value={String(m.objid)}>
{m.menu_name_kor}
</option>
))}
</CPSelect>
</CPRow>
{selectedMenuObjid && (
<CPRow label="채번 규칙">
{loadingNumberingRules ? (
<InlineLoader text="규칙 로딩..." />
) : numberingRules.length === 0 ? (
<Hint tone="warn"> </Hint>
) : (
<CPSelect
value={col.auto_fill?.numbering_rule_id || ""}
onChange={(v) =>
updateColumnProp(col.key, "auto_fill", {
...col.auto_fill,
selected_menu_objid: selectedMenuObjid,
numbering_rule_id: v,
})
}
>
<option value=""> </option>
{numberingRules.map((r) => (
<option key={r.rule_id} value={r.rule_id}>
{r.rule_name}
</option>
))}
</CPSelect>
)}
</CPRow>
)}
{col.auto_fill?.numbering_rule_id && <Hint tone="ok"> API </Hint>}
</>
)}
{col.auto_fill?.type === "fromMainForm" && (
<CPRow label="복사할 필드명">
<CPText
value={col.auto_fill?.source_field || ""}
onChange={(v) => updateColumnProp(col.key, "auto_fill", { ...col.auto_fill, source_field: v })}
placeholder="order_no"
/>
</CPRow>
)}
{col.auto_fill?.type === "fixed" && (
<CPRow label="고정값">
<CPText
value={String(col.auto_fill?.fixed_value || "")}
onChange={(v) => updateColumnProp(col.key, "auto_fill", { ...col.auto_fill, fixed_value: v })}
placeholder="고정값"
/>
</CPRow>
)}
{col.auto_fill?.type === "parentSequence" && (
<>
<CPRow label="부모 번호 필드">
<CPText
value={col.auto_fill?.parent_field || ""}
onChange={(v) =>
updateColumnProp(col.key, "auto_fill", { ...col.auto_fill, parent_field: v })
}
placeholder="work_order_no"
/>
</CPRow>
<CPRow label="구분자">
<CPText
value={col.auto_fill?.separator ?? "-"}
onChange={(v) =>
updateColumnProp(col.key, "auto_fill", { ...col.auto_fill, separator: v })
}
placeholder="-"
/>
</CPRow>
<CPRow label="순번 자릿수">
<CPNumber
value={col.auto_fill?.sequence_length ?? 2}
onChange={(v) =>
updateColumnProp(col.key, "auto_fill", { ...col.auto_fill, sequence_length: v ?? 2 })
}
min={1}
max={5}
/>
</CPRow>
<Hint tone="ok">
: WO-20260223-005{col.auto_fill?.separator ?? "-"}
{String(1).padStart(col.auto_fill?.sequence_length ?? 2, "0")}
</Hint>
</>
)}
</div>
);
}
// ───────────────────────────────────────────────────────
// CalcRulesEditor — 계산 규칙
// ───────────────────────────────────────────────────────
function CalcRulesEditor({
calcRules,
columns,
onAdd,
onRemove,
onUpdate,
onInsertCol,
formulaToKorean,
}: {
calcRules: CalcRule[];
columns: RepeaterColumnConfig[];
onAdd: () => void;
onRemove: (id: string) => void;
onUpdate: (id: string, field: keyof CalcRule, value: string) => void;
onInsertCol: (id: string, key: string) => void;
formulaToKorean: (f: string) => string;
}) {
return (
<div style={{ marginTop: 10 }}>
<div style={{ display: "flex", alignItems: "center", gap: 6, marginBottom: 6 }}>
<Calculator size={12} style={{ color: "var(--cp-text-muted)" }} />
<SectionLabel text={`계산 규칙 ${calcRules.length}`} />
<button
type="button"
onClick={onAdd}
style={{
marginLeft: "auto",
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>
{calcRules.length === 0 ? (
<Hint> . .</Hint>
) : (
<div style={{ display: "flex", flexDirection: "column", gap: 5 }}>
{calcRules.map((r) => (
<div
key={r.id}
style={{
padding: "5px 6px",
background: "var(--cp-bg-subtle)",
border: "1px solid var(--cp-border-subtle)",
borderRadius: 4,
}}
>
<div style={{ display: "flex", alignItems: "center", gap: 4 }}>
<CPSelect
value={r.target_column}
onChange={(v) => onUpdate(r.id, "target_column", v)}
searchable={false}
>
<option value=""></option>
{columns
.filter((c) => !c.is_source_display)
.map((c) => (
<option key={c.key} value={c.key}>
{c.title || c.key}
</option>
))}
</CPSelect>
<span style={{ fontSize: 11 }}>=</span>
<input
type="text"
value={r.formula}
onChange={(e) => onUpdate(r.id, "formula", e.target.value)}
placeholder="컬럼 클릭 또는 직접 입력"
style={{
flex: 1,
height: 22,
padding: "0 5px",
fontSize: 10.5,
fontFamily: "var(--v5-font-mono)",
background: "var(--cp-surface)",
border: "1px solid var(--cp-border)",
borderRadius: 3,
color: "var(--cp-text)",
}}
/>
<CPIconBtn tone="danger" size={20} onClick={() => onRemove(r.id)}>
<Trash2 size={10} />
</CPIconBtn>
</div>
{r.formula && (
<Hint>
{columns.find((c) => c.key === r.target_column)?.title || r.target_column || "결과"} ={" "}
{formulaToKorean(r.formula)}
</Hint>
)}
<div style={{ display: "flex", flexWrap: "wrap", gap: 3, marginTop: 4 }}>
{columns
.filter((c) => c.key !== r.target_column && !c.is_source_display)
.map((c) => (
<CpChip key={c.key} onClick={() => onInsertCol(r.id, c.key)}>
{c.title || c.key}
</CpChip>
))}
{columns
.filter((c) => c.is_source_display)
.map((c) => (
<CpChip key={c.key} onClick={() => onInsertCol(r.id, c.key)} tone="primary">
{c.title || c.key}
</CpChip>
))}
{["+", "-", "*", "/", "(", ")"].map((op) => (
<CpChip key={op} onClick={() => onInsertCol(r.id, op)}>
{op}
</CpChip>
))}
</div>
</div>
))}
</div>
)}
</div>
);
}
// ───────────────────────────────────────────────────────
// EntityJoinEditor — 엔티티 조인
// ───────────────────────────────────────────────────────
function EntityJoinEditor({
loading,
entityJoinData,
entityJoinTargetTable,
isEntityJoinColumnActive,
toggleEntityJoinColumn,
configuredJoins,
onRemoveJoin,
}: {
loading: boolean;
entityJoinData: {
joinTables: Array<{
tableName: string;
currentDisplayColumn: string;
joinConfig?: { sourceColumn?: string; source_column?: string };
availableColumns: Array<{
columnName: string;
columnLabel: string;
dataType: string;
input_type?: string;
}>;
}>;
};
entityJoinTargetTable?: string;
isEntityJoinColumnActive: (j: string, s: string, r: string) => boolean;
toggleEntityJoinColumn: (
j: string,
s: string,
r: string,
label: string,
field: string,
type?: string,
) => void;
configuredJoins: V2RepeaterConfig["entity_joins"];
onRemoveJoin: (idx: number) => void;
}) {
return (
<div style={{ marginTop: 10 }}>
<SectionLabel icon={<Link2 size={11} />} text="엔티티 조인 (FK 자동 표시)" primary />
<Hint>FK </Hint>
{loading ? (
<InlineLoader text="조인 가능 컬럼 찾는 중..." />
) : entityJoinData.joinTables.length === 0 ? (
<Hint>
{entityJoinTargetTable ? "조인 가능한 컬럼이 없어요" : "저장 테이블을 먼저 설정하세요"}
</Hint>
) : (
<div style={{ display: "flex", flexDirection: "column", gap: 6, marginTop: 4 }}>
{entityJoinData.joinTables.map((joinTable, ti) => {
const sourceColumn =
joinTable.joinConfig?.source_column || joinTable.joinConfig?.sourceColumn || "";
return (
<div
key={ti}
style={{
padding: 6,
background: "var(--cp-bg-subtle)",
border: "1px solid var(--cp-border-subtle)",
borderRadius: 4,
}}
>
<div style={{ display: "flex", alignItems: "center", gap: 5, marginBottom: 5 }}>
<Link2 size={11} style={{ color: "var(--v5-primary, #6c5ce7)" }} />
<span style={{ fontSize: 11, fontWeight: 600 }}>{joinTable.tableName}</span>
<span style={{ fontSize: 10, color: "var(--cp-text-muted)" }}>
({sourceColumn})
</span>
</div>
<ChipPickerBox>
{joinTable.availableColumns.map((c) => {
const active = isEntityJoinColumnActive(joinTable.tableName, sourceColumn, c.columnName);
return (
<CpChip
key={c.columnName}
active={active}
onClick={() =>
toggleEntityJoinColumn(
joinTable.tableName,
sourceColumn,
c.columnName,
c.columnLabel,
c.columnName,
c.input_type || c.dataType,
)
}
>
{c.columnLabel}
</CpChip>
);
})}
</ChipPickerBox>
</div>
);
})}
</div>
)}
{configuredJoins && configuredJoins.length > 0 && (
<div style={{ marginTop: 6 }}>
<SectionLabel text={`설정된 조인 ${configuredJoins.length}`} />
<div style={{ display: "flex", flexDirection: "column", gap: 3 }}>
{configuredJoins.map((j, idx) => (
<div
key={idx}
style={{
display: "flex",
alignItems: "center",
gap: 5,
padding: "4px 6px",
fontSize: 10.5,
background: "var(--cp-bg-subtle)",
border: "1px solid var(--cp-border-subtle)",
borderRadius: 4,
}}
>
<Database size={10} style={{ color: "var(--v5-primary, #6c5ce7)" }} />
<span style={{ fontWeight: 600 }}>{j.source_column}</span>
<ArrowRight size={10} style={{ color: "var(--cp-text-muted)" }} />
<span>{j.reference_table}</span>
<span style={{ color: "var(--cp-text-muted)" }}>
({j.columns.map((c) => c.reference_field).join(", ")})
</span>
<span style={{ flex: 1 }} />
<CPIconBtn tone="danger" size={20} onClick={() => onRemoveJoin(idx)}>
<Trash2 size={10} />
</CPIconBtn>
</div>
))}
</div>
</div>
)}
</div>
);
}
// ───────────────────────────────────────────────────────
// FeatureChipGrid — 토글 묶음을 chip grid 로 표현
// (CPRow + CPSwitch row 8개 나열 = 이전 시스템과 똑같이 보임 → grid 로 평탄화)
//
// - 한 줄에 자동 N개 (auto-fill, minmax 96px)
// - 활성: primary 컬러 + 좌측 dot + 약한 글로우
// - 비활성: 회색 톤
// - 호버: tooltip (desc)
// - NEW 라벨: 우측 작은 배지
// ───────────────────────────────────────────────────────
interface FeatureChipItem {
key: string;
label: string;
desc?: string;
default?: boolean;
isNew?: boolean;
}
const TIP_WIDTH = 280;
const TIP_PAD = 8;
// Group Cooldown 패턴 타이밍 (Stripe / Linear 표준)
const TIP_OPEN_DELAY = 500; // 첫 호버 → tooltip 표시까지
const TIP_CLOSE_GRACE = 250; // mouseLeave → 실제 닫힘까지 (다른 칩 이동 시 깜빡임 방지)
const TIP_GROUP_COOLDOWN = 500; // tooltip 닫힘 → group 해제까지 (이 동안 다시 호버 시 즉시 표시)
function FeatureChipGrid({
items,
source,
onToggle,
}: {
items: FeatureChipItem[];
source?: Record<string, any>;
onToggle: (key: string, value: boolean) => void;
}) {
const [hoverIdx, setHoverIdx] = useState<number | null>(null);
const [chipRect, setChipRect] = useState<{ left: number; center: number; top: number } | null>(null);
const [tipVisible, setTipVisible] = useState(false);
// Group Cooldown — group active 상태를 ref 로 (rerender 안 일으킴)
const groupActiveRef = useRef(false);
const openTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
const closeTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
const cooldownTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
const clearTimer = (ref: React.MutableRefObject<ReturnType<typeof setTimeout> | null>) => {
if (ref.current) {
clearTimeout(ref.current);
ref.current = null;
}
};
const handleHoverEnter = useCallback((i: number, el: HTMLElement) => {
// 진행 중인 close grace / group cooldown 취소
clearTimer(closeTimerRef);
clearTimer(cooldownTimerRef);
const r = el.getBoundingClientRect();
setHoverIdx(i);
setChipRect({ left: r.left, center: r.left + r.width / 2, top: r.top });
if (groupActiveRef.current) {
// group active → 즉시 표시
setTipVisible(true);
} else {
// 첫 호버 → open delay 후 표시
clearTimer(openTimerRef);
openTimerRef.current = setTimeout(() => {
setTipVisible(true);
groupActiveRef.current = true;
openTimerRef.current = null;
}, TIP_OPEN_DELAY);
}
}, []);
const handleHoverLeave = useCallback(() => {
// 아직 open delay 진행 중이었으면 cancel (사용자가 빠르게 지나감)
clearTimer(openTimerRef);
// close grace 후 닫기
clearTimer(closeTimerRef);
closeTimerRef.current = setTimeout(() => {
setTipVisible(false);
setHoverIdx(null);
closeTimerRef.current = null;
// group cooldown 후 해제 (이 동안 다시 호버하면 즉시 표시)
clearTimer(cooldownTimerRef);
cooldownTimerRef.current = setTimeout(() => {
groupActiveRef.current = false;
cooldownTimerRef.current = null;
}, TIP_GROUP_COOLDOWN);
}, TIP_CLOSE_GRACE);
}, []);
// unmount 시 모든 timer 정리
useEffect(() => {
return () => {
clearTimer(openTimerRef);
clearTimer(closeTimerRef);
clearTimer(cooldownTimerRef);
};
}, []);
const onCount = items.filter((it) => (source?.[it.key] ?? it.default ?? false)).length;
// hover 중인 칩의 데이터
const hoverItem = hoverIdx !== null ? items[hoverIdx] : null;
const hoverActive = hoverItem ? !!(source?.[hoverItem.key] ?? hoverItem.default ?? false) : false;
// tooltip 위치 계산 (화면 좌우 boundary clamp + arrow 동적 정렬)
let tipLeft = 0;
let arrowLeft = TIP_WIDTH / 2;
if (chipRect && typeof window !== "undefined") {
const rawLeft = chipRect.center - TIP_WIDTH / 2;
const minLeft = TIP_PAD;
const maxLeft = window.innerWidth - TIP_WIDTH - TIP_PAD;
tipLeft = Math.min(Math.max(rawLeft, minLeft), maxLeft);
arrowLeft = chipRect.center - tipLeft;
// arrow 가 tooltip 안쪽 적정 범위에만 있도록 (양 끝 12px 여유)
arrowLeft = Math.min(Math.max(arrowLeft, 12), TIP_WIDTH - 12);
}
return (
<div>
<div
style={{
display: "flex",
alignItems: "center",
gap: 6,
marginBottom: 5,
fontSize: 10,
color: "var(--cp-text-muted)",
fontFamily: "var(--v5-font-mono)",
letterSpacing: 0.02,
}}
>
<span></span>
<span style={{ color: "var(--v5-primary, #6c5ce7)", fontWeight: 700 }}>{onCount}</span>
<span>/ {items.length}</span>
</div>
<div
style={{
display: "grid",
gridTemplateColumns: "repeat(auto-fill, minmax(96px, 1fr))",
gap: 5,
}}
>
{items.map((it, i) => {
const active = !!(source?.[it.key] ?? it.default ?? false);
return (
<button
key={it.key}
type="button"
onClick={() => onToggle(it.key, !active)}
onFocus={(e) => handleHoverEnter(i, e.currentTarget)}
onBlur={handleHoverLeave}
style={{
position: "relative",
display: "flex",
alignItems: "center",
gap: 6,
padding: "6px 8px 6px 7px",
background: active
? "rgba(var(--v5-primary-rgb), 0.10)"
: "var(--cp-bg-subtle)",
border: `1px solid ${
active
? "rgba(var(--v5-primary-rgb), 0.45)"
: "var(--cp-border-subtle)"
}`,
borderRadius: 5,
cursor: "pointer",
fontFamily: "var(--v5-font-sans)",
boxShadow: active
? "0 0 0 2px rgba(var(--v5-primary-rgb), 0.06), 0 1px 2px rgba(0,0,0,0.03)"
: "none",
transition: "all .14s ease",
textAlign: "left",
minHeight: 26,
}}
onMouseEnter={(e) => {
handleHoverEnter(i, e.currentTarget as HTMLElement);
if (!active) {
(e.currentTarget as HTMLButtonElement).style.background =
"var(--cp-surface-hover, var(--cp-surface))";
}
}}
onMouseLeave={(e) => {
handleHoverLeave();
if (!active) {
(e.currentTarget as HTMLButtonElement).style.background =
"var(--cp-bg-subtle)";
}
}}
>
<span
aria-hidden
style={{
width: 6,
height: 6,
borderRadius: 999,
background: active
? "var(--v5-primary, #6c5ce7)"
: "var(--cp-border-strong, var(--cp-border))",
boxShadow: active
? "0 0 5px rgba(var(--v5-primary-rgb), 0.55)"
: "none",
flexShrink: 0,
transition: "all .14s ease",
}}
/>
<span
style={{
flex: 1,
fontSize: 11,
fontWeight: active ? 600 : 500,
color: active ? "var(--cp-text)" : "var(--cp-text-sec)",
overflow: "hidden",
textOverflow: "ellipsis",
whiteSpace: "nowrap",
letterSpacing: "-0.005em",
}}
>
{it.label}
</span>
{it.isNew && (
<span
style={{
fontSize: 8.5,
fontWeight: 700,
padding: "1px 4px",
background: "rgba(var(--v5-primary-rgb), 0.15)",
color: "var(--v5-primary, #6c5ce7)",
borderRadius: 3,
letterSpacing: "0.04em",
}}
>
NEW
</span>
)}
</button>
);
})}
</div>
{/* Portal tooltip — body 에 렌더하여 부모 overflow 우회 */}
{tipVisible && hoverItem && hoverItem.desc && chipRect && typeof document !== "undefined" &&
createPortal(
<div
role="tooltip"
style={{
position: "fixed",
top: chipRect.top - 8,
left: tipLeft,
transform: "translateY(-100%)",
width: TIP_WIDTH,
pointerEvents: "none",
zIndex: 9999,
}}
>
<div
style={{
position: "relative",
background: "rgba(20, 18, 36, 0.97)",
color: "#fff",
padding: "8px 11px 7px 10px",
borderRadius: 6,
boxShadow:
"0 12px 32px rgba(0,0,0,0.42), 0 0 0 1px rgba(var(--v5-primary-rgb), 0.3)",
fontFamily: "var(--v5-font-sans)",
animation: "cp-fade-up 0.14s ease-out",
lineHeight: 1.45,
}}
>
{/* 1행: dot + 라벨 + NEW */}
<div
style={{
display: "flex",
alignItems: "center",
gap: 5,
marginBottom: 3,
}}
>
<span
aria-hidden
style={{
width: 5,
height: 5,
borderRadius: 999,
background: hoverActive ? "#10b981" : "rgba(255,255,255,0.32)",
boxShadow: hoverActive ? "0 0 5px rgba(16,185,129,0.7)" : "none",
flexShrink: 0,
}}
/>
<span
style={{
fontSize: 11.5,
fontWeight: 700,
color: "#fff",
letterSpacing: "-0.01em",
flex: 1,
whiteSpace: "nowrap",
}}
>
{hoverItem.label}
</span>
{hoverItem.isNew && (
<span
style={{
fontSize: 8,
fontWeight: 700,
padding: "1px 4px",
background: "rgba(var(--v5-primary-rgb), 0.3)",
color: "#b8b3ff",
borderRadius: 3,
letterSpacing: "0.04em",
marginLeft: 2,
}}
>
NEW
</span>
)}
</div>
{/* 2행: 설명 (줄바꿈 \n 표시) */}
<div
style={{
fontSize: 10.5,
color: "rgba(255,255,255,0.82)",
fontWeight: 400,
whiteSpace: "pre-line",
lineHeight: 1.55,
}}
>
{hoverItem.desc}
</div>
{/* 3행: 상태 + key path (모노) */}
<div
style={{
marginTop: 5,
paddingTop: 4,
borderTop: "1px solid rgba(255,255,255,0.1)",
fontSize: 9.5,
color: "rgba(255,255,255,0.45)",
fontFamily: "var(--v5-font-mono)",
letterSpacing: 0.02,
display: "flex",
alignItems: "center",
gap: 5,
}}
>
<span style={{ color: hoverActive ? "#10b981" : "rgba(255,255,255,0.42)" }}>
{hoverActive ? "● ON" : "○ OFF"}
</span>
<span style={{ opacity: 0.4 }}>·</span>
<span>{hoverItem.key}</span>
</div>
{/* 화살표 ▾ (chip 중앙으로 동적 정렬) */}
<span
aria-hidden
style={{
position: "absolute",
top: "100%",
left: arrowLeft,
transform: "translateX(-50%)",
width: 0,
height: 0,
borderLeft: "5px solid transparent",
borderRight: "5px solid transparent",
borderTop: "5px solid rgba(20, 18, 36, 0.97)",
}}
/>
</div>
</div>,
document.body,
)}
</div>
);
}
export default InvRepeaterConfigPanel;