8b8186d1c0
Build & Deploy to K8s / build-and-deploy (push) Successful in 4m7s
신규 / 마이그된 패널: - 데이터 조회/선택: 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>
2520 lines
92 KiB
TypeScript
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;
|