- Updated the toggleEntityJoinColumn function to include an optional columnType parameter for better flexibility in handling join columns. - Improved the logic for managing entity joins and columns, ensuring that columns are correctly added or removed based on user interactions. - Introduced a new section in the UI to display entity join columns in a read-only format, providing users with clear visibility of the join configurations. - Added loading states and messages to enhance user experience during data retrieval for entity joins. These changes aim to improve the functionality and usability of the V2RepeaterConfigPanel in managing entity relationships.
1927 lines
86 KiB
TypeScript
1927 lines
86 KiB
TypeScript
"use client";
|
|
|
|
/**
|
|
* V2Repeater 설정 패널
|
|
*
|
|
* 렌더링 모드별 설정:
|
|
* - inline: 현재 화면 테이블 컬럼 직접 입력
|
|
* - modal: 엔티티 선택 + 추가 입력 (FK 저장 + 추가 컬럼 입력)
|
|
*/
|
|
|
|
import React, { useState, useEffect, useMemo, useCallback } from "react";
|
|
import { Label } from "@/components/ui/label";
|
|
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
|
|
import { Separator } from "@/components/ui/separator";
|
|
import { Checkbox } from "@/components/ui/checkbox";
|
|
import { Button } from "@/components/ui/button";
|
|
import { Input } from "@/components/ui/input";
|
|
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs";
|
|
import {
|
|
Database,
|
|
Link2,
|
|
Plus,
|
|
Trash2,
|
|
GripVertical,
|
|
ArrowRight,
|
|
Calculator,
|
|
ChevronDown,
|
|
ChevronRight,
|
|
Eye,
|
|
EyeOff,
|
|
Wand2,
|
|
Check,
|
|
ChevronsUpDown,
|
|
ListTree,
|
|
} from "lucide-react";
|
|
import {
|
|
Command,
|
|
CommandEmpty,
|
|
CommandGroup,
|
|
CommandInput,
|
|
CommandItem,
|
|
CommandList,
|
|
} from "@/components/ui/command";
|
|
import {
|
|
Popover,
|
|
PopoverContent,
|
|
PopoverTrigger,
|
|
} from "@/components/ui/popover";
|
|
import { tableTypeApi } from "@/lib/api/screen";
|
|
import { tableManagementApi } from "@/lib/api/tableManagement";
|
|
import { entityJoinApi } from "@/lib/api/entityJoin";
|
|
import { getAvailableNumberingRules, getAvailableNumberingRulesForScreen } from "@/lib/api/numberingRule";
|
|
import { NumberingRuleConfig } from "@/types/numbering-rule";
|
|
import { cn } from "@/lib/utils";
|
|
import {
|
|
V2RepeaterConfig,
|
|
RepeaterColumnConfig,
|
|
RepeaterEntityJoin,
|
|
DEFAULT_REPEATER_CONFIG,
|
|
RENDER_MODE_OPTIONS,
|
|
MODAL_SIZE_OPTIONS,
|
|
} from "@/types/v2-repeater";
|
|
|
|
// 테이블 엔티티 관계 정보
|
|
interface TableRelation {
|
|
tableName: string;
|
|
tableLabel: string;
|
|
foreignKeyColumn: string; // 저장 테이블의 FK 컬럼
|
|
referenceColumn: string; // 마스터 테이블의 PK 컬럼
|
|
}
|
|
|
|
interface V2RepeaterConfigPanelProps {
|
|
config: V2RepeaterConfig;
|
|
onChange: (config: V2RepeaterConfig) => void;
|
|
currentTableName?: string;
|
|
screenTableName?: string;
|
|
tableColumns?: any[];
|
|
menuObjid?: number | string; // 🆕 메뉴 ID (채번 규칙 조회용)
|
|
}
|
|
|
|
interface ColumnOption {
|
|
columnName: string;
|
|
displayName: string;
|
|
inputType?: 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 CalculationRule {
|
|
id: string;
|
|
targetColumn: string;
|
|
formula: string;
|
|
label?: string;
|
|
}
|
|
|
|
export const V2RepeaterConfigPanel: React.FC<V2RepeaterConfigPanelProps> = ({
|
|
config: propConfig,
|
|
onChange,
|
|
currentTableName: propCurrentTableName,
|
|
screenTableName,
|
|
menuObjid,
|
|
}) => {
|
|
const currentTableName = screenTableName || propCurrentTableName;
|
|
|
|
// config 안전하게 초기화
|
|
const config: V2RepeaterConfig = useMemo(() => ({
|
|
...DEFAULT_REPEATER_CONFIG,
|
|
...propConfig,
|
|
renderMode: propConfig?.renderMode || DEFAULT_REPEATER_CONFIG.renderMode,
|
|
dataSource: {
|
|
...DEFAULT_REPEATER_CONFIG.dataSource,
|
|
...propConfig?.dataSource,
|
|
},
|
|
columns: propConfig?.columns || [],
|
|
modal: {
|
|
...DEFAULT_REPEATER_CONFIG.modal,
|
|
...propConfig?.modal,
|
|
},
|
|
features: {
|
|
...DEFAULT_REPEATER_CONFIG.features,
|
|
...propConfig?.features,
|
|
},
|
|
}), [propConfig]);
|
|
|
|
// 상태 관리
|
|
const [currentTableColumns, setCurrentTableColumns] = useState<ColumnOption[]>([]); // 현재 테이블 컬럼
|
|
const [entityColumns, setEntityColumns] = useState<EntityColumnOption[]>([]); // 엔티티 타입 컬럼
|
|
const [sourceTableColumns, setSourceTableColumns] = useState<ColumnOption[]>([]); // 소스(엔티티) 테이블 컬럼
|
|
const [calculationRules, setCalculationRules] = useState<CalculationRule[]>(
|
|
config.calculationRules || []
|
|
);
|
|
|
|
const [loadingColumns, setLoadingColumns] = useState(false);
|
|
const [loadingSourceColumns, setLoadingSourceColumns] = useState(false);
|
|
|
|
// 저장 테이블 관련 상태
|
|
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 [tableComboboxOpen, setTableComboboxOpen] = useState(false); // 테이블 Combobox 열림 상태
|
|
|
|
// Entity 조인 관련 상태
|
|
const [entityJoinData, setEntityJoinData] = useState<{
|
|
joinTables: Array<{
|
|
tableName: string;
|
|
currentDisplayColumn: string;
|
|
joinConfig?: { sourceColumn?: string };
|
|
availableColumns: Array<{
|
|
columnName: string;
|
|
columnLabel: string;
|
|
dataType: string;
|
|
inputType?: string;
|
|
}>;
|
|
}>;
|
|
availableColumns: Array<{
|
|
tableName: string;
|
|
columnName: string;
|
|
columnLabel: string;
|
|
joinAlias: string;
|
|
}>;
|
|
}>({ joinTables: [], availableColumns: [] });
|
|
const [loadingEntityJoins, setLoadingEntityJoins] = useState(false);
|
|
|
|
// 🆕 확장된 컬럼 (상세 설정 표시용)
|
|
const [expandedColumn, setExpandedColumn] = useState<string | null>(null);
|
|
|
|
// 🆕 채번 규칙 목록
|
|
const [numberingRules, setNumberingRules] = useState<NumberingRuleConfig[]>([]);
|
|
const [loadingNumberingRules, setLoadingNumberingRules] = useState(false);
|
|
|
|
// 🆕 대상 메뉴 목록 (채번 규칙 선택용)
|
|
const [parentMenus, setParentMenus] = useState<any[]>([]);
|
|
const [loadingMenus, setLoadingMenus] = useState(false);
|
|
|
|
// 🆕 선택된 메뉴 OBJID (컬럼별로 저장, 한 번 선택하면 공유)
|
|
const [selectedMenuObjid, setSelectedMenuObjid] = useState<number | undefined>(() => {
|
|
// 기존 config에서 저장된 값이 있으면 복원
|
|
const existingAutoFill = config.columns.find(c => c.autoFill?.type === "numbering" && c.autoFill.selectedMenuObjid);
|
|
return existingAutoFill?.autoFill?.selectedMenuObjid || (menuObjid ? Number(menuObjid) : undefined);
|
|
});
|
|
|
|
// 자동 입력 타입 옵션
|
|
const autoFillOptions = [
|
|
{ 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: "부모채번+순번 (예: WO-001-01)" },
|
|
];
|
|
|
|
// 🆕 대상 메뉴 목록 로드 (사용자 메뉴의 레벨 2)
|
|
useEffect(() => {
|
|
const loadMenus = async () => {
|
|
setLoadingMenus(true);
|
|
try {
|
|
const { apiClient } = await import("@/lib/api/client");
|
|
const response = await apiClient.get("/admin/menus");
|
|
|
|
if (response.data.success && response.data.data) {
|
|
const allMenus = response.data.data;
|
|
|
|
// 사용자 메뉴(menu_type='1')의 레벨 2만 필터링
|
|
const level2UserMenus = allMenus.filter((menu: any) =>
|
|
menu.menu_type === '1' && menu.lev === 2
|
|
);
|
|
|
|
setParentMenus(level2UserMenus);
|
|
}
|
|
} catch (error) {
|
|
console.error("부모 메뉴 로드 실패:", error);
|
|
} finally {
|
|
setLoadingMenus(false);
|
|
}
|
|
};
|
|
loadMenus();
|
|
}, []);
|
|
|
|
// 🆕 채번 규칙 로드 (선택된 메뉴 기준)
|
|
useEffect(() => {
|
|
const loadNumberingRules = async () => {
|
|
// 메뉴가 선택되지 않았으면 로드하지 않음
|
|
if (!selectedMenuObjid) {
|
|
setNumberingRules([]);
|
|
return;
|
|
}
|
|
|
|
setLoadingNumberingRules(true);
|
|
try {
|
|
const result = await getAvailableNumberingRules(selectedMenuObjid);
|
|
|
|
if (result?.success && result.data) {
|
|
setNumberingRules(result.data);
|
|
}
|
|
} catch (error) {
|
|
console.error("채번 규칙 로드 실패:", error);
|
|
setNumberingRules([]);
|
|
} finally {
|
|
setLoadingNumberingRules(false);
|
|
}
|
|
};
|
|
loadNumberingRules();
|
|
}, [selectedMenuObjid]);
|
|
|
|
// 전체 테이블 목록 로드
|
|
useEffect(() => {
|
|
const loadTables = async () => {
|
|
setLoadingTables(true);
|
|
try {
|
|
const response = await tableManagementApi.getTableList();
|
|
if (response.success && response.data) {
|
|
setAllTables(response.data.map((t: any) => ({
|
|
tableName: t.tableName || t.table_name,
|
|
displayName: t.displayName || t.table_label || t.tableName || t.table_name,
|
|
})));
|
|
}
|
|
} catch (error) {
|
|
console.error("테이블 목록 로드 실패:", error);
|
|
} finally {
|
|
setLoadingTables(false);
|
|
}
|
|
};
|
|
loadTables();
|
|
}, []);
|
|
|
|
// 연관 테이블 목록 로드 (엔티티 관계 기반)
|
|
// 1. 화면 메인 테이블이 있으면: 그 테이블을 참조하는 테이블 (자식 테이블)
|
|
// 2. 저장 테이블이 선택되었으면: 그 테이블을 참조하는 테이블도 추가
|
|
useEffect(() => {
|
|
const loadRelatedTables = async () => {
|
|
// 화면 메인 테이블 또는 저장 테이블 중 하나라도 있어야 함
|
|
const baseTable = currentTableName || config.mainTableName;
|
|
if (!baseTable) {
|
|
setRelatedTables([]);
|
|
return;
|
|
}
|
|
|
|
setLoadingRelations(true);
|
|
try {
|
|
const { apiClient } = await import("@/lib/api/client");
|
|
const allRelations: TableRelation[] = [];
|
|
|
|
// 1. 화면 메인 테이블을 참조하는 테이블 조회 (자식 테이블)
|
|
if (currentTableName) {
|
|
const response = await apiClient.get(`/table-management/columns/${currentTableName}/referenced-by`);
|
|
if (response.data.success && response.data.data) {
|
|
const relations: TableRelation[] = response.data.data.map((rel: any) => ({
|
|
tableName: rel.tableName || rel.table_name,
|
|
tableLabel: rel.tableLabel || rel.table_label || rel.tableName || rel.table_name,
|
|
foreignKeyColumn: rel.columnName || rel.column_name, // FK 컬럼 (자식 테이블의)
|
|
referenceColumn: rel.referenceColumn || rel.reference_column || "id", // PK 컬럼 (부모 테이블의)
|
|
}));
|
|
allRelations.push(...relations);
|
|
}
|
|
}
|
|
|
|
// 2. 저장 테이블이 화면 메인 테이블과 다르면, 저장 테이블을 참조하는 테이블도 조회
|
|
if (config.mainTableName && config.mainTableName !== currentTableName) {
|
|
const response2 = await apiClient.get(`/table-management/columns/${config.mainTableName}/referenced-by`);
|
|
if (response2.data.success && response2.data.data) {
|
|
const relations2: TableRelation[] = response2.data.data.map((rel: any) => ({
|
|
tableName: rel.tableName || rel.table_name,
|
|
tableLabel: rel.tableLabel || rel.table_label || rel.tableName || rel.table_name,
|
|
foreignKeyColumn: rel.columnName || rel.column_name,
|
|
referenceColumn: rel.referenceColumn || rel.reference_column || "id",
|
|
}));
|
|
// 중복 제거 후 추가
|
|
relations2.forEach(rel => {
|
|
if (!allRelations.some(r => r.tableName === rel.tableName)) {
|
|
allRelations.push(rel);
|
|
}
|
|
});
|
|
}
|
|
}
|
|
|
|
setRelatedTables(allRelations);
|
|
} catch (error) {
|
|
console.error("연관 테이블 로드 실패:", error);
|
|
setRelatedTables([]);
|
|
} finally {
|
|
setLoadingRelations(false);
|
|
}
|
|
};
|
|
loadRelatedTables();
|
|
}, [currentTableName, config.mainTableName]);
|
|
|
|
// Entity 조인 컬럼 정보 로드 (저장 테이블 기준)
|
|
const entityJoinTargetTable = config.useCustomTable && config.mainTableName
|
|
? config.mainTableName
|
|
: currentTableName;
|
|
|
|
useEffect(() => {
|
|
const fetchEntityJoinColumns = async () => {
|
|
if (!entityJoinTargetTable) return;
|
|
setLoadingEntityJoins(true);
|
|
try {
|
|
const result = await entityJoinApi.getEntityJoinColumns(entityJoinTargetTable);
|
|
setEntityJoinData({
|
|
joinTables: result.joinTables || [],
|
|
availableColumns: result.availableColumns || [],
|
|
});
|
|
} catch (error) {
|
|
console.error("Entity 조인 컬럼 조회 오류:", error);
|
|
setEntityJoinData({ joinTables: [], availableColumns: [] });
|
|
} finally {
|
|
setLoadingEntityJoins(false);
|
|
}
|
|
};
|
|
fetchEntityJoinColumns();
|
|
}, [entityJoinTargetTable]);
|
|
|
|
// 설정 업데이트 헬퍼
|
|
const updateConfig = useCallback(
|
|
(updates: Partial<V2RepeaterConfig>) => {
|
|
onChange({ ...config, ...updates });
|
|
},
|
|
[config, onChange],
|
|
);
|
|
|
|
// Entity 조인 컬럼 토글 (추가/제거)
|
|
const toggleEntityJoinColumn = useCallback(
|
|
(joinTableName: string, sourceColumn: string, refColumnName: string, refColumnLabel: string, displayField: string, columnType?: string) => {
|
|
const currentJoins = config.entityJoins || [];
|
|
const existingJoinIdx = currentJoins.findIndex(
|
|
(j) => j.sourceColumn === sourceColumn && j.referenceTable === joinTableName,
|
|
);
|
|
|
|
let newEntityJoins = [...currentJoins];
|
|
let newColumns = [...config.columns];
|
|
|
|
if (existingJoinIdx >= 0) {
|
|
const existingJoin = currentJoins[existingJoinIdx];
|
|
const existingColIdx = existingJoin.columns.findIndex((c) => c.referenceField === refColumnName);
|
|
|
|
if (existingColIdx >= 0) {
|
|
const updatedColumns = existingJoin.columns.filter((_, i) => i !== existingColIdx);
|
|
if (updatedColumns.length === 0) {
|
|
newEntityJoins = newEntityJoins.filter((_, i) => i !== existingJoinIdx);
|
|
} else {
|
|
newEntityJoins[existingJoinIdx] = { ...existingJoin, columns: updatedColumns };
|
|
}
|
|
// config.columns에서도 제거
|
|
newColumns = newColumns.filter(c => !(c.key === displayField && c.isJoinColumn));
|
|
} else {
|
|
newEntityJoins[existingJoinIdx] = {
|
|
...existingJoin,
|
|
columns: [...existingJoin.columns, { referenceField: refColumnName, displayField }],
|
|
};
|
|
// config.columns에 추가
|
|
newColumns.push({
|
|
key: displayField,
|
|
title: refColumnLabel,
|
|
width: "auto",
|
|
visible: true,
|
|
editable: false,
|
|
isJoinColumn: true,
|
|
inputType: columnType || "text",
|
|
});
|
|
}
|
|
} else {
|
|
newEntityJoins.push({
|
|
sourceColumn,
|
|
referenceTable: joinTableName,
|
|
columns: [{ referenceField: refColumnName, displayField }],
|
|
});
|
|
// config.columns에 추가
|
|
newColumns.push({
|
|
key: displayField,
|
|
title: refColumnLabel,
|
|
width: "auto",
|
|
visible: true,
|
|
editable: false,
|
|
isJoinColumn: true,
|
|
inputType: columnType || "text",
|
|
});
|
|
}
|
|
|
|
updateConfig({ entityJoins: newEntityJoins, columns: newColumns });
|
|
},
|
|
[config.entityJoins, config.columns, updateConfig],
|
|
);
|
|
|
|
// Entity 조인에 특정 컬럼이 설정되어 있는지 확인
|
|
const isEntityJoinColumnActive = useCallback(
|
|
(joinTableName: string, sourceColumn: string, refColumnName: string) => {
|
|
return (config.entityJoins || []).some(
|
|
(j) =>
|
|
j.sourceColumn === sourceColumn &&
|
|
j.referenceTable === joinTableName &&
|
|
j.columns.some((c) => c.referenceField === refColumnName),
|
|
);
|
|
},
|
|
[config.entityJoins],
|
|
);
|
|
|
|
const updateDataSource = useCallback(
|
|
(field: string, value: any) => {
|
|
updateConfig({
|
|
dataSource: { ...config.dataSource, [field]: value },
|
|
});
|
|
},
|
|
[config.dataSource, updateConfig],
|
|
);
|
|
|
|
const updateModal = useCallback(
|
|
(field: string, value: any) => {
|
|
updateConfig({
|
|
modal: { ...config.modal, [field]: value },
|
|
});
|
|
},
|
|
[config.modal, updateConfig],
|
|
);
|
|
|
|
const updateFeatures = useCallback(
|
|
(field: string, value: boolean) => {
|
|
updateConfig({
|
|
features: { ...config.features, [field]: value },
|
|
});
|
|
},
|
|
[config.features, updateConfig],
|
|
);
|
|
|
|
// 저장 테이블 선택 핸들러 - 엔티티 관계에서 FK/PK 자동 설정
|
|
const handleSaveTableSelect = useCallback((tableName: string) => {
|
|
// 빈 값 선택 시 (현재 테이블로 복원)
|
|
if (!tableName || tableName === currentTableName) {
|
|
updateConfig({
|
|
useCustomTable: false,
|
|
mainTableName: undefined,
|
|
foreignKeyColumn: undefined,
|
|
foreignKeySourceColumn: undefined,
|
|
});
|
|
return;
|
|
}
|
|
|
|
// 연관 테이블에서 FK 관계 찾기
|
|
const relation = relatedTables.find(r => r.tableName === tableName);
|
|
|
|
if (relation) {
|
|
// 엔티티 관계가 있으면 자동으로 FK/PK 설정
|
|
updateConfig({
|
|
useCustomTable: true,
|
|
mainTableName: tableName,
|
|
foreignKeyColumn: relation.foreignKeyColumn,
|
|
foreignKeySourceColumn: relation.referenceColumn,
|
|
});
|
|
} else {
|
|
// 엔티티 관계가 없으면 직접 입력 필요
|
|
updateConfig({
|
|
useCustomTable: true,
|
|
mainTableName: tableName,
|
|
foreignKeyColumn: undefined,
|
|
foreignKeySourceColumn: "id",
|
|
});
|
|
}
|
|
}, [currentTableName, relatedTables, updateConfig]);
|
|
|
|
// 저장 테이블 컬럼 로드 (저장 테이블이 설정되면 해당 테이블, 아니면 현재 화면 테이블)
|
|
// 실제 저장할 테이블의 컬럼을 보여줘야 함
|
|
const targetTableForColumns = config.useCustomTable && config.mainTableName
|
|
? config.mainTableName
|
|
: currentTableName;
|
|
|
|
useEffect(() => {
|
|
const loadCurrentTableColumns = async () => {
|
|
if (!targetTableForColumns) {
|
|
setCurrentTableColumns([]);
|
|
setEntityColumns([]);
|
|
return;
|
|
}
|
|
|
|
setLoadingColumns(true);
|
|
try {
|
|
const columnData = await tableTypeApi.getColumns(targetTableForColumns);
|
|
const cols: ColumnOption[] = [];
|
|
const entityCols: EntityColumnOption[] = [];
|
|
|
|
for (const c of columnData) {
|
|
// detailSettings 파싱
|
|
let detailSettings: any = null;
|
|
if (c.detailSettings) {
|
|
try {
|
|
detailSettings = typeof c.detailSettings === "string"
|
|
? JSON.parse(c.detailSettings)
|
|
: c.detailSettings;
|
|
} catch (e) {
|
|
console.warn("detailSettings 파싱 실패:", c.detailSettings);
|
|
}
|
|
}
|
|
|
|
const col: ColumnOption = {
|
|
columnName: c.columnName || c.column_name,
|
|
displayName: c.displayName || c.columnLabel || c.columnName || c.column_name,
|
|
inputType: c.inputType || c.input_type,
|
|
detailSettings: detailSettings ? {
|
|
codeGroup: detailSettings.codeGroup,
|
|
referenceTable: detailSettings.referenceTable,
|
|
referenceColumn: detailSettings.referenceColumn,
|
|
displayColumn: detailSettings.displayColumn,
|
|
format: detailSettings.format,
|
|
} : undefined,
|
|
};
|
|
cols.push(col);
|
|
|
|
// 엔티티 타입 컬럼 감지
|
|
if (col.inputType === "entity") {
|
|
const referenceTable = detailSettings?.referenceTable || c.referenceTable;
|
|
const referenceColumn = detailSettings?.referenceColumn || c.referenceColumn || "id";
|
|
const displayColumn = detailSettings?.displayColumn || c.displayColumn;
|
|
|
|
if (referenceTable) {
|
|
entityCols.push({
|
|
columnName: col.columnName,
|
|
displayName: col.displayName,
|
|
referenceTable,
|
|
referenceColumn,
|
|
displayColumn,
|
|
});
|
|
}
|
|
}
|
|
}
|
|
|
|
setCurrentTableColumns(cols);
|
|
setEntityColumns(entityCols);
|
|
} catch (error) {
|
|
console.error("저장 테이블 컬럼 로드 실패:", error);
|
|
setCurrentTableColumns([]);
|
|
setEntityColumns([]);
|
|
} finally {
|
|
setLoadingColumns(false);
|
|
}
|
|
};
|
|
loadCurrentTableColumns();
|
|
}, [targetTableForColumns]);
|
|
|
|
// 소스(엔티티) 테이블 컬럼 로드 (모달 모드일 때)
|
|
useEffect(() => {
|
|
const loadSourceTableColumns = async () => {
|
|
const sourceTable = config.dataSource?.sourceTable;
|
|
if (!sourceTable) {
|
|
setSourceTableColumns([]);
|
|
return;
|
|
}
|
|
|
|
setLoadingSourceColumns(true);
|
|
try {
|
|
const columnData = await tableTypeApi.getColumns(sourceTable);
|
|
const cols: ColumnOption[] = columnData.map((c: any) => ({
|
|
columnName: c.columnName || c.column_name,
|
|
displayName: c.displayName || c.columnLabel || c.columnName || c.column_name,
|
|
inputType: c.inputType || c.input_type,
|
|
}));
|
|
setSourceTableColumns(cols);
|
|
} catch (error) {
|
|
console.error("소스 테이블 컬럼 로드 실패:", error);
|
|
setSourceTableColumns([]);
|
|
} finally {
|
|
setLoadingSourceColumns(false);
|
|
}
|
|
};
|
|
|
|
if (config.renderMode === "modal") {
|
|
loadSourceTableColumns();
|
|
}
|
|
}, [config.dataSource?.sourceTable, config.renderMode]);
|
|
|
|
// 컬럼 토글 (현재 테이블 컬럼 - 입력용)
|
|
const toggleInputColumn = (column: ColumnOption) => {
|
|
const existingIndex = config.columns.findIndex((c) => c.key === column.columnName && !c.isJoinColumn && !c.isSourceDisplay);
|
|
if (existingIndex >= 0) {
|
|
const newColumns = config.columns.filter((_, i) => i !== existingIndex);
|
|
updateConfig({ columns: newColumns });
|
|
} else {
|
|
// 컬럼의 inputType과 detailSettings 정보 포함
|
|
const newColumn: RepeaterColumnConfig = {
|
|
key: column.columnName,
|
|
title: column.displayName,
|
|
width: "auto",
|
|
visible: true,
|
|
editable: true,
|
|
inputType: column.inputType || "text",
|
|
detailSettings: column.detailSettings ? {
|
|
codeGroup: column.detailSettings.codeGroup,
|
|
referenceTable: column.detailSettings.referenceTable,
|
|
referenceColumn: column.detailSettings.referenceColumn,
|
|
displayColumn: column.detailSettings.displayColumn,
|
|
format: column.detailSettings.format,
|
|
} : undefined,
|
|
};
|
|
updateConfig({ columns: [...config.columns, newColumn] });
|
|
}
|
|
};
|
|
|
|
// 🆕 소스 컬럼 토글 - columns 배열에 isSourceDisplay: true로 추가
|
|
const toggleSourceDisplayColumn = (column: ColumnOption) => {
|
|
const exists = config.columns.some((c) => c.key === column.columnName && c.isSourceDisplay);
|
|
|
|
if (exists) {
|
|
// 제거
|
|
updateConfig({ columns: config.columns.filter((c) => c.key !== column.columnName) });
|
|
} else {
|
|
// 추가 (isSourceDisplay: true)
|
|
const newColumn: RepeaterColumnConfig = {
|
|
key: column.columnName,
|
|
title: column.displayName,
|
|
width: "auto",
|
|
visible: true,
|
|
editable: false, // 소스 표시 컬럼은 편집 불가
|
|
isSourceDisplay: true,
|
|
};
|
|
updateConfig({ columns: [...config.columns, newColumn] });
|
|
}
|
|
};
|
|
|
|
const isColumnAdded = (columnName: string) => {
|
|
return config.columns.some((c) => c.key === columnName && !c.isSourceDisplay && !c.isJoinColumn);
|
|
};
|
|
|
|
const isSourceColumnSelected = (columnName: string) => {
|
|
return config.columns.some((c) => c.key === columnName && c.isSourceDisplay);
|
|
};
|
|
|
|
// 컬럼 속성 업데이트
|
|
const updateColumnProp = (key: string, field: keyof RepeaterColumnConfig, value: any) => {
|
|
const newColumns = config.columns.map((col) => (col.key === key ? { ...col, [field]: value } : col));
|
|
updateConfig({ columns: newColumns });
|
|
};
|
|
|
|
// 계산 규칙을 config에 반영하는 헬퍼
|
|
const syncCalculationRules = (rules: CalculationRule[]) => {
|
|
setCalculationRules(rules);
|
|
updateConfig({ calculationRules: rules });
|
|
};
|
|
|
|
// 계산 규칙 추가
|
|
const addCalculationRule = () => {
|
|
const newRules = [
|
|
...calculationRules,
|
|
{ id: `calc_${Date.now()}`, targetColumn: "", formula: "" }
|
|
];
|
|
syncCalculationRules(newRules);
|
|
};
|
|
|
|
// 계산 규칙 삭제
|
|
const removeCalculationRule = (id: string) => {
|
|
syncCalculationRules(calculationRules.filter(r => r.id !== id));
|
|
};
|
|
|
|
// 계산 규칙 업데이트
|
|
const updateCalculationRule = (id: string, field: keyof CalculationRule, value: string) => {
|
|
syncCalculationRules(
|
|
calculationRules.map(r => r.id === id ? { ...r, [field]: value } : r)
|
|
);
|
|
};
|
|
|
|
// 수식 입력 필드에 컬럼명 삽입
|
|
const insertColumnToFormula = (ruleId: string, columnKey: string) => {
|
|
const rule = calculationRules.find(r => r.id === ruleId);
|
|
if (!rule) return;
|
|
const newFormula = rule.formula ? `${rule.formula} ${columnKey}` : columnKey;
|
|
updateCalculationRule(ruleId, "formula", newFormula);
|
|
};
|
|
|
|
// 수식의 영어 컬럼명을 한글 제목으로 변환
|
|
const formulaToKorean = (formula: string): string => {
|
|
if (!formula) return "";
|
|
let result = formula;
|
|
const allCols = config.columns || [];
|
|
// 긴 컬럼명부터 치환 (부분 매칭 방지)
|
|
const sorted = [...allCols].sort((a, b) => b.key.length - a.key.length);
|
|
for (const col of sorted) {
|
|
if (col.title && col.key) {
|
|
result = result.replace(new RegExp(`\\b${col.key}\\b`, "g"), col.title);
|
|
}
|
|
}
|
|
return result;
|
|
};
|
|
|
|
// 엔티티 컬럼 선택 시 소스 테이블 자동 설정
|
|
const handleEntityColumnSelect = (columnName: string) => {
|
|
const selectedEntity = entityColumns.find(c => c.columnName === columnName);
|
|
|
|
if (selectedEntity) {
|
|
console.log("엔티티 컬럼 선택:", selectedEntity);
|
|
|
|
// 소스 테이블 컬럼에서 라벨 정보 찾기
|
|
const displayColInfo = sourceTableColumns.find(c => c.columnName === selectedEntity.displayColumn);
|
|
const displayLabel = displayColInfo?.displayName || selectedEntity.displayColumn || "";
|
|
|
|
updateConfig({
|
|
dataSource: {
|
|
...config.dataSource,
|
|
sourceTable: selectedEntity.referenceTable || "",
|
|
foreignKey: selectedEntity.columnName,
|
|
referenceKey: selectedEntity.referenceColumn || "id",
|
|
displayColumn: selectedEntity.displayColumn,
|
|
},
|
|
modal: {
|
|
...config.modal,
|
|
searchFields: selectedEntity.displayColumn ? [selectedEntity.displayColumn] : [],
|
|
// 라벨 포함 형식으로 저장
|
|
sourceDisplayColumns: selectedEntity.displayColumn
|
|
? [{ key: selectedEntity.displayColumn, label: displayLabel }]
|
|
: [],
|
|
},
|
|
});
|
|
}
|
|
};
|
|
|
|
// 모드 여부
|
|
const isInlineMode = config.renderMode === "inline";
|
|
const isModalMode = config.renderMode === "modal";
|
|
|
|
// 엔티티 컬럼 제외한 입력 가능 컬럼 (FK 컬럼 제외)
|
|
const inputableColumns = useMemo(() => {
|
|
const fkColumn = config.dataSource?.foreignKey;
|
|
return currentTableColumns.filter(col =>
|
|
col.columnName !== fkColumn && // FK 컬럼 제외
|
|
col.inputType !== "entity" // 다른 엔티티 컬럼도 제외 (필요시)
|
|
);
|
|
}, [currentTableColumns, config.dataSource?.foreignKey]);
|
|
|
|
return (
|
|
<div className="space-y-4">
|
|
<Tabs defaultValue="basic" className="w-full">
|
|
<TabsList className="grid w-full grid-cols-2">
|
|
<TabsTrigger value="basic" className="text-xs">기본</TabsTrigger>
|
|
<TabsTrigger value="columns" className="text-xs">컬럼</TabsTrigger>
|
|
</TabsList>
|
|
|
|
{/* 기본 설정 탭 */}
|
|
<TabsContent value="basic" className="mt-4 space-y-4">
|
|
{/* 렌더링 모드 */}
|
|
<div className="space-y-2">
|
|
<Label className="text-xs font-medium">렌더링 모드</Label>
|
|
<Select
|
|
value={config.renderMode}
|
|
onValueChange={(value) => {
|
|
const newMode = value as any;
|
|
const currentMode = config.renderMode;
|
|
|
|
// 모달 → 인라인 모드로 변경 시: isSourceDisplay 컬럼 제거 및 모달 설정 초기화
|
|
if (currentMode === "modal" && newMode === "inline") {
|
|
const filteredColumns = config.columns.filter((col) => !col.isSourceDisplay);
|
|
updateConfig({
|
|
renderMode: newMode,
|
|
columns: filteredColumns,
|
|
dataSource: {
|
|
...config.dataSource,
|
|
sourceTable: undefined,
|
|
foreignKey: undefined,
|
|
referenceKey: undefined,
|
|
displayColumn: undefined,
|
|
},
|
|
modal: {
|
|
...config.modal,
|
|
searchFields: [],
|
|
sourceDisplayColumns: [],
|
|
},
|
|
});
|
|
} else {
|
|
updateConfig({ renderMode: newMode });
|
|
}
|
|
}}
|
|
>
|
|
<SelectTrigger className="h-8 text-xs">
|
|
<SelectValue placeholder="모드 선택" />
|
|
</SelectTrigger>
|
|
<SelectContent>
|
|
{RENDER_MODE_OPTIONS.map((opt) => (
|
|
<SelectItem key={opt.value} value={opt.value}>
|
|
<div className="flex flex-col">
|
|
<span>{opt.label}</span>
|
|
<span className="text-[10px] text-muted-foreground/70">
|
|
{opt.value === "inline" && "현재 테이블 컬럼 직접 입력"}
|
|
{opt.value === "modal" && "엔티티 선택 후 추가 정보 입력"}
|
|
{opt.value === "button" && "버튼으로 관련 화면 열기"}
|
|
</span>
|
|
</div>
|
|
</SelectItem>
|
|
))}
|
|
</SelectContent>
|
|
</Select>
|
|
</div>
|
|
|
|
<Separator />
|
|
|
|
{/* 저장 대상 테이블 */}
|
|
<div className="space-y-2">
|
|
<Label className="text-xs font-medium">저장 테이블</Label>
|
|
|
|
{/* 현재 선택된 테이블 표시 (기존 테이블 UI와 동일한 스타일) */}
|
|
<div className={cn(
|
|
"rounded-lg border p-3",
|
|
config.useCustomTable && config.mainTableName
|
|
? "border-orange-300 bg-amber-50"
|
|
: "border-primary/40 bg-primary/10"
|
|
)}>
|
|
<div className="flex items-center gap-2">
|
|
<Database className={cn(
|
|
"h-4 w-4",
|
|
config.useCustomTable && config.mainTableName
|
|
? "text-amber-600"
|
|
: "text-primary"
|
|
)} />
|
|
<div className="flex-1">
|
|
<p className={cn(
|
|
"text-sm font-medium",
|
|
config.useCustomTable && config.mainTableName
|
|
? "text-orange-700"
|
|
: "text-primary"
|
|
)}>
|
|
{config.useCustomTable && config.mainTableName
|
|
? (allTables.find(t => t.tableName === config.mainTableName)?.displayName || config.mainTableName)
|
|
: (currentTableName || "미설정")
|
|
}
|
|
</p>
|
|
{config.useCustomTable && config.mainTableName && config.foreignKeyColumn && (
|
|
<p className="text-[10px] text-amber-600 mt-0.5">
|
|
FK: {config.foreignKeyColumn} → {currentTableName}.{config.foreignKeySourceColumn || "id"}
|
|
</p>
|
|
)}
|
|
{!config.useCustomTable && currentTableName && (
|
|
<p className="text-[10px] text-primary mt-0.5">화면 메인 테이블</p>
|
|
)}
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
{/* 테이블 변경 Combobox */}
|
|
<Popover open={tableComboboxOpen} onOpenChange={setTableComboboxOpen}>
|
|
<PopoverTrigger asChild>
|
|
<Button
|
|
variant="outline"
|
|
role="combobox"
|
|
aria-expanded={tableComboboxOpen}
|
|
disabled={loadingTables || loadingRelations}
|
|
className="h-8 w-full justify-between text-xs"
|
|
>
|
|
{loadingTables ? "로딩 중..." : "다른 테이블 선택..."}
|
|
<ChevronsUpDown className="ml-2 h-3 w-3 shrink-0 opacity-50" />
|
|
</Button>
|
|
</PopoverTrigger>
|
|
<PopoverContent
|
|
className="p-0"
|
|
style={{ width: "var(--radix-popover-trigger-width)" }}
|
|
align="start"
|
|
>
|
|
<Command>
|
|
<CommandInput placeholder="테이블 검색..." className="text-xs" />
|
|
<CommandList className="max-h-60">
|
|
<CommandEmpty className="text-xs py-3 text-center">
|
|
테이블을 찾을 수 없습니다.
|
|
</CommandEmpty>
|
|
|
|
{/* 현재 테이블 (기본) */}
|
|
{currentTableName && (
|
|
<CommandGroup heading="기본">
|
|
<CommandItem
|
|
value={currentTableName}
|
|
onSelect={() => {
|
|
handleSaveTableSelect(currentTableName);
|
|
setTableComboboxOpen(false);
|
|
}}
|
|
className="text-xs"
|
|
>
|
|
<Check
|
|
className={cn(
|
|
"mr-2 h-3 w-3",
|
|
!config.useCustomTable || !config.mainTableName ? "opacity-100" : "opacity-0"
|
|
)}
|
|
/>
|
|
<Database className="mr-2 h-3 w-3 text-primary" />
|
|
<span>{currentTableName}</span>
|
|
<span className="ml-1 text-[10px] text-muted-foreground">(기본)</span>
|
|
</CommandItem>
|
|
</CommandGroup>
|
|
)}
|
|
|
|
{/* 연관 테이블 (엔티티 관계) */}
|
|
{relatedTables.length > 0 && (
|
|
<CommandGroup heading="연관 테이블 (FK 자동 설정)">
|
|
{relatedTables.map((rel) => (
|
|
<CommandItem
|
|
key={rel.tableName}
|
|
value={`${rel.tableName} ${rel.tableLabel}`}
|
|
onSelect={() => {
|
|
handleSaveTableSelect(rel.tableName);
|
|
setTableComboboxOpen(false);
|
|
}}
|
|
className="text-xs"
|
|
>
|
|
<Check
|
|
className={cn(
|
|
"mr-2 h-3 w-3",
|
|
config.mainTableName === rel.tableName ? "opacity-100" : "opacity-0"
|
|
)}
|
|
/>
|
|
<Link2 className="mr-2 h-3 w-3 text-amber-500" />
|
|
<span>{rel.tableLabel}</span>
|
|
<span className="ml-1 text-[10px] text-muted-foreground">
|
|
({rel.foreignKeyColumn})
|
|
</span>
|
|
</CommandItem>
|
|
))}
|
|
</CommandGroup>
|
|
)}
|
|
|
|
{/* 전체 테이블 목록 */}
|
|
<CommandGroup heading="전체 테이블 (FK 직접 입력)">
|
|
{allTables
|
|
.filter(t => t.tableName !== currentTableName && !relatedTables.some(r => r.tableName === t.tableName))
|
|
.map((table) => (
|
|
<CommandItem
|
|
key={table.tableName}
|
|
value={`${table.tableName} ${table.displayName}`}
|
|
onSelect={() => {
|
|
handleSaveTableSelect(table.tableName);
|
|
setTableComboboxOpen(false);
|
|
}}
|
|
className="text-xs"
|
|
>
|
|
<Check
|
|
className={cn(
|
|
"mr-2 h-3 w-3",
|
|
config.mainTableName === table.tableName ? "opacity-100" : "opacity-0"
|
|
)}
|
|
/>
|
|
<Database className="mr-2 h-3 w-3 text-muted-foreground/70" />
|
|
<span>{table.displayName}</span>
|
|
</CommandItem>
|
|
))
|
|
}
|
|
</CommandGroup>
|
|
</CommandList>
|
|
</Command>
|
|
</PopoverContent>
|
|
</Popover>
|
|
|
|
{/* FK 직접 입력 - 화면 메인 테이블이 있고 연관 테이블이 아닌 경우만 표시 */}
|
|
{/* 화면 메인 테이블이 없으면 FK 설정 불필요 (독립 저장) */}
|
|
{config.useCustomTable && config.mainTableName && currentTableName &&
|
|
!relatedTables.some(r => r.tableName === config.mainTableName) && (
|
|
<div className="space-y-2 rounded border border-amber-200 bg-amber-50 p-2">
|
|
<p className="text-[10px] text-amber-700">
|
|
화면 테이블({currentTableName})과의 엔티티 관계가 없습니다. FK 컬럼을 직접 입력하세요.
|
|
</p>
|
|
<div className="grid grid-cols-2 gap-2">
|
|
<div className="space-y-1">
|
|
<Label className="text-[10px]">FK 컬럼 (저장 테이블)</Label>
|
|
<Input
|
|
value={config.foreignKeyColumn || ""}
|
|
onChange={(e) => updateConfig({ foreignKeyColumn: e.target.value })}
|
|
placeholder="예: master_id"
|
|
className="h-7 text-xs"
|
|
/>
|
|
</div>
|
|
<div className="space-y-1">
|
|
<Label className="text-[10px]">PK 컬럼 (화면 테이블)</Label>
|
|
<Input
|
|
value={config.foreignKeySourceColumn || "id"}
|
|
onChange={(e) => updateConfig({ foreignKeySourceColumn: e.target.value })}
|
|
placeholder="id"
|
|
className="h-7 text-xs"
|
|
/>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
)}
|
|
|
|
{/* 화면 메인 테이블이 없을 때 안내 */}
|
|
{config.useCustomTable && config.mainTableName && !currentTableName && (
|
|
<div className="rounded border border-primary/20 bg-primary/10 p-2">
|
|
<p className="text-[10px] text-primary">
|
|
독립 저장 모드: 화면 테이블 없이 직접 저장합니다.
|
|
</p>
|
|
</div>
|
|
)}
|
|
</div>
|
|
|
|
<Separator />
|
|
|
|
{/* 현재 화면 정보 (메인 테이블이 설정된 경우에만 표시) */}
|
|
{currentTableName && (
|
|
<div className="space-y-2">
|
|
<Label className="text-xs font-medium">메인 화면 테이블 (참고)</Label>
|
|
<div className="rounded border border-border bg-muted p-2">
|
|
<p className="text-xs text-foreground font-medium">{currentTableName}</p>
|
|
<p className="text-[10px] text-muted-foreground">
|
|
컬럼 {currentTableColumns.length}개 / 엔티티 {entityColumns.length}개
|
|
</p>
|
|
</div>
|
|
</div>
|
|
)}
|
|
|
|
{/* 모달 모드: 엔티티 컬럼 선택 */}
|
|
{isModalMode && (
|
|
<>
|
|
<Separator />
|
|
<div className="space-y-2">
|
|
<Label className="text-xs font-medium">엔티티 선택</Label>
|
|
<p className="text-[10px] text-muted-foreground">
|
|
모달에서 검색할 엔티티를 선택하세요 (FK만 저장됨)
|
|
</p>
|
|
|
|
{entityColumns.length > 0 ? (
|
|
<Select
|
|
value={config.dataSource?.foreignKey || ""}
|
|
onValueChange={handleEntityColumnSelect}
|
|
disabled={!targetTableForColumns}
|
|
>
|
|
<SelectTrigger className="h-8 text-xs">
|
|
<SelectValue placeholder="엔티티 컬럼 선택" />
|
|
</SelectTrigger>
|
|
<SelectContent>
|
|
{entityColumns.map((col) => (
|
|
<SelectItem key={col.columnName} value={col.columnName}>
|
|
<div className="flex items-center gap-2">
|
|
<Link2 className="h-3 w-3 text-primary" />
|
|
<span>{col.displayName}</span>
|
|
<ArrowRight className="h-3 w-3 text-muted-foreground/70" />
|
|
<span className="text-muted-foreground">{col.referenceTable}</span>
|
|
</div>
|
|
</SelectItem>
|
|
))}
|
|
</SelectContent>
|
|
</Select>
|
|
) : (
|
|
<div className="rounded border border-border bg-muted p-2">
|
|
<p className="text-[10px] text-muted-foreground">
|
|
{loadingColumns
|
|
? "로딩 중..."
|
|
: !targetTableForColumns
|
|
? "저장 테이블을 먼저 선택하세요"
|
|
: "엔티티 타입 컬럼이 없습니다"}
|
|
</p>
|
|
</div>
|
|
)}
|
|
|
|
{/* 선택된 엔티티 정보 */}
|
|
{config.dataSource?.sourceTable && (
|
|
<div className="rounded border border-emerald-200 bg-emerald-50 p-2 space-y-1">
|
|
<p className="text-xs text-emerald-700 font-medium">선택된 엔티티</p>
|
|
<div className="text-[10px] text-emerald-600">
|
|
<p>검색 테이블: {config.dataSource.sourceTable}</p>
|
|
<p>저장 컬럼: {config.dataSource.foreignKey} (FK)</p>
|
|
</div>
|
|
</div>
|
|
)}
|
|
</div>
|
|
</>
|
|
)}
|
|
|
|
<Separator />
|
|
|
|
{/* 소스 디테일 자동 조회 설정 */}
|
|
<div className="space-y-2">
|
|
<div className="flex items-center space-x-2">
|
|
<Checkbox
|
|
id="enableSourceDetail"
|
|
checked={!!config.sourceDetailConfig}
|
|
onCheckedChange={(checked) => {
|
|
if (checked) {
|
|
updateConfig({
|
|
sourceDetailConfig: {
|
|
tableName: "",
|
|
foreignKey: "",
|
|
parentKey: "",
|
|
},
|
|
});
|
|
} else {
|
|
updateConfig({ sourceDetailConfig: undefined });
|
|
}
|
|
}}
|
|
/>
|
|
<label htmlFor="enableSourceDetail" className="text-xs font-medium flex items-center gap-1">
|
|
<ListTree className="h-3 w-3" />
|
|
소스 디테일 자동 조회
|
|
</label>
|
|
</div>
|
|
<p className="text-[10px] text-muted-foreground">
|
|
모달에서 전달받은 마스터 데이터의 디테일 행을 자동으로 조회하여 리피터에 채웁니다.
|
|
</p>
|
|
|
|
{config.sourceDetailConfig && (
|
|
<div className="space-y-2 rounded border border-violet-200 bg-violet-50 p-2">
|
|
<div className="space-y-1">
|
|
<Label className="text-[10px]">디테일 테이블</Label>
|
|
<Popover>
|
|
<PopoverTrigger asChild>
|
|
<Button
|
|
variant="outline"
|
|
role="combobox"
|
|
className="h-7 w-full justify-between text-xs"
|
|
>
|
|
{config.sourceDetailConfig.tableName
|
|
? (allTables.find(t => t.tableName === config.sourceDetailConfig!.tableName)?.displayName || config.sourceDetailConfig.tableName)
|
|
: "테이블 선택..."
|
|
}
|
|
<ChevronsUpDown className="ml-2 h-3 w-3 shrink-0 opacity-50" />
|
|
</Button>
|
|
</PopoverTrigger>
|
|
<PopoverContent
|
|
className="p-0"
|
|
style={{ width: "var(--radix-popover-trigger-width)" }}
|
|
align="start"
|
|
>
|
|
<Command>
|
|
<CommandInput placeholder="테이블 검색..." className="text-xs" />
|
|
<CommandList className="max-h-48">
|
|
<CommandEmpty className="text-xs py-3 text-center">테이블을 찾을 수 없습니다.</CommandEmpty>
|
|
<CommandGroup>
|
|
{allTables.map((table) => (
|
|
<CommandItem
|
|
key={table.tableName}
|
|
value={`${table.tableName} ${table.displayName}`}
|
|
onSelect={() => {
|
|
updateConfig({
|
|
sourceDetailConfig: {
|
|
...config.sourceDetailConfig!,
|
|
tableName: table.tableName,
|
|
},
|
|
});
|
|
}}
|
|
className="text-xs"
|
|
>
|
|
<Check className={cn("mr-2 h-3 w-3", config.sourceDetailConfig!.tableName === table.tableName ? "opacity-100" : "opacity-0")} />
|
|
<span>{table.displayName}</span>
|
|
</CommandItem>
|
|
))}
|
|
</CommandGroup>
|
|
</CommandList>
|
|
</Command>
|
|
</PopoverContent>
|
|
</Popover>
|
|
</div>
|
|
|
|
<div className="grid grid-cols-2 gap-2">
|
|
<div className="space-y-1">
|
|
<Label className="text-[10px]">디테일 FK 컬럼</Label>
|
|
<Input
|
|
value={config.sourceDetailConfig.foreignKey || ""}
|
|
onChange={(e) =>
|
|
updateConfig({
|
|
sourceDetailConfig: {
|
|
...config.sourceDetailConfig!,
|
|
foreignKey: e.target.value,
|
|
},
|
|
})
|
|
}
|
|
placeholder="예: order_no"
|
|
className="h-7 text-xs"
|
|
/>
|
|
</div>
|
|
<div className="space-y-1">
|
|
<Label className="text-[10px]">마스터 키 컬럼</Label>
|
|
<Input
|
|
value={config.sourceDetailConfig.parentKey || ""}
|
|
onChange={(e) =>
|
|
updateConfig({
|
|
sourceDetailConfig: {
|
|
...config.sourceDetailConfig!,
|
|
parentKey: e.target.value,
|
|
},
|
|
})
|
|
}
|
|
placeholder="예: order_no"
|
|
className="h-7 text-xs"
|
|
/>
|
|
</div>
|
|
</div>
|
|
|
|
<p className="text-[10px] text-violet-600">
|
|
마스터에서 [{config.sourceDetailConfig.parentKey || "?"}] 추출 →
|
|
{" "}{config.sourceDetailConfig.tableName || "?"}.{config.sourceDetailConfig.foreignKey || "?"} 로 조회
|
|
</p>
|
|
</div>
|
|
)}
|
|
</div>
|
|
|
|
<Separator />
|
|
|
|
{/* 기능 옵션 */}
|
|
<div className="space-y-3">
|
|
<Label className="text-xs font-medium">기능 옵션</Label>
|
|
<div className="grid grid-cols-2 gap-2">
|
|
<div className="flex items-center space-x-2">
|
|
<Checkbox
|
|
id="showAddButton"
|
|
checked={config.features?.showAddButton ?? true}
|
|
onCheckedChange={(checked) => updateFeatures("showAddButton", !!checked)}
|
|
/>
|
|
<label htmlFor="showAddButton" className="text-xs">추가 버튼</label>
|
|
</div>
|
|
|
|
<div className="flex items-center space-x-2">
|
|
<Checkbox
|
|
id="showDeleteButton"
|
|
checked={config.features?.showDeleteButton ?? true}
|
|
onCheckedChange={(checked) => updateFeatures("showDeleteButton", !!checked)}
|
|
/>
|
|
<label htmlFor="showDeleteButton" className="text-xs">삭제 버튼</label>
|
|
</div>
|
|
|
|
<div className="flex items-center space-x-2">
|
|
<Checkbox
|
|
id="inlineEdit"
|
|
checked={config.features?.inlineEdit ?? false}
|
|
onCheckedChange={(checked) => updateFeatures("inlineEdit", !!checked)}
|
|
/>
|
|
<label htmlFor="inlineEdit" className="text-xs">인라인 편집</label>
|
|
</div>
|
|
|
|
<div className="flex items-center space-x-2">
|
|
<Checkbox
|
|
id="multiSelect"
|
|
checked={config.features?.multiSelect ?? true}
|
|
onCheckedChange={(checked) => updateFeatures("multiSelect", !!checked)}
|
|
/>
|
|
<label htmlFor="multiSelect" className="text-xs">다중 선택</label>
|
|
</div>
|
|
|
|
<div className="flex items-center space-x-2">
|
|
<Checkbox
|
|
id="showRowNumber"
|
|
checked={config.features?.showRowNumber ?? false}
|
|
onCheckedChange={(checked) => updateFeatures("showRowNumber", !!checked)}
|
|
/>
|
|
<label htmlFor="showRowNumber" className="text-xs">행 번호</label>
|
|
</div>
|
|
|
|
<div className="flex items-center space-x-2">
|
|
<Checkbox
|
|
id="selectable"
|
|
checked={config.features?.selectable ?? false}
|
|
onCheckedChange={(checked) => updateFeatures("selectable", !!checked)}
|
|
/>
|
|
<label htmlFor="selectable" className="text-xs">행 선택</label>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</TabsContent>
|
|
|
|
{/* 컬럼 설정 탭 - 🆕 통합 컬럼 선택 */}
|
|
<TabsContent value="columns" className="mt-4 space-y-4">
|
|
{/* 통합 컬럼 선택 */}
|
|
<div className="space-y-2">
|
|
<Label className="text-xs font-medium">컬럼 선택</Label>
|
|
<p className="text-[10px] text-muted-foreground">
|
|
{isModalMode
|
|
? "표시할 컬럼과 입력 컬럼을 선택하세요. 아이콘으로 표시/입력 구분"
|
|
: "입력받을 컬럼을 선택하세요"
|
|
}
|
|
</p>
|
|
|
|
{/* 모달 모드: 소스 테이블 컬럼 (표시용) */}
|
|
{isModalMode && config.dataSource?.sourceTable && (
|
|
<>
|
|
<div className="text-[10px] font-medium text-primary mt-2 mb-1 flex items-center gap-1">
|
|
<Link2 className="h-3 w-3" />
|
|
소스 테이블 ({config.dataSource.sourceTable}) - 표시용
|
|
</div>
|
|
{loadingSourceColumns ? (
|
|
<p className="text-muted-foreground py-2 text-xs">로딩 중...</p>
|
|
) : sourceTableColumns.length === 0 ? (
|
|
<p className="text-muted-foreground py-2 text-xs">컬럼 정보가 없습니다</p>
|
|
) : (
|
|
<div className="max-h-28 space-y-0.5 overflow-y-auto rounded-md border border-primary/20 bg-primary/10/30 p-2">
|
|
{sourceTableColumns.map((column) => (
|
|
<div
|
|
key={`source-${column.columnName}`}
|
|
className={cn(
|
|
"hover:bg-primary/10/50 flex cursor-pointer items-center gap-2 rounded px-2 py-1",
|
|
isSourceColumnSelected(column.columnName) && "bg-primary/10",
|
|
)}
|
|
onClick={() => toggleSourceDisplayColumn(column)}
|
|
>
|
|
<Checkbox
|
|
checked={isSourceColumnSelected(column.columnName)}
|
|
onCheckedChange={() => toggleSourceDisplayColumn(column)}
|
|
className="pointer-events-none h-3.5 w-3.5"
|
|
/>
|
|
<Link2 className="text-primary h-3 w-3 flex-shrink-0" />
|
|
<span className="truncate text-xs">{column.displayName}</span>
|
|
<span className="text-[10px] text-primary/80 ml-auto">표시</span>
|
|
</div>
|
|
))}
|
|
</div>
|
|
)}
|
|
</>
|
|
)}
|
|
|
|
{/* 저장 테이블 컬럼 (입력용) */}
|
|
<div className="text-[10px] font-medium text-muted-foreground mt-3 mb-1 flex items-center gap-1">
|
|
<Database className="h-3 w-3" />
|
|
저장 테이블 ({targetTableForColumns || "미선택"}) - 입력용
|
|
</div>
|
|
{loadingColumns ? (
|
|
<p className="text-muted-foreground py-2 text-xs">로딩 중...</p>
|
|
) : inputableColumns.length === 0 ? (
|
|
<p className="text-muted-foreground py-2 text-xs">
|
|
컬럼 정보가 없습니다
|
|
</p>
|
|
) : (
|
|
<div className="max-h-36 space-y-0.5 overflow-y-auto rounded-md border p-2">
|
|
{inputableColumns.map((column) => (
|
|
<div
|
|
key={`input-${column.columnName}`}
|
|
className={cn(
|
|
"hover:bg-muted/50 flex cursor-pointer items-center gap-2 rounded px-2 py-1",
|
|
isColumnAdded(column.columnName) && "bg-primary/10",
|
|
)}
|
|
onClick={() => toggleInputColumn(column)}
|
|
>
|
|
<Checkbox
|
|
checked={isColumnAdded(column.columnName)}
|
|
onCheckedChange={() => toggleInputColumn(column)}
|
|
className="pointer-events-none h-3.5 w-3.5"
|
|
/>
|
|
<Database className="text-muted-foreground h-3 w-3 flex-shrink-0" />
|
|
<span className="truncate text-xs">{column.displayName}</span>
|
|
<span className="text-[10px] text-muted-foreground/70 ml-auto">{column.inputType}</span>
|
|
</div>
|
|
))}
|
|
</div>
|
|
)}
|
|
</div>
|
|
|
|
{/* ===== 🆕 Entity 조인 컬럼 (표시용) ===== */}
|
|
<div className="space-y-2 mt-4">
|
|
<div className="flex items-center gap-2">
|
|
<Link2 className="h-4 w-4 text-primary" />
|
|
<Label className="text-xs font-medium text-primary">Entity 조인 컬럼 (읽기전용)</Label>
|
|
</div>
|
|
<p className="text-[10px] text-muted-foreground">
|
|
FK 컬럼을 기반으로 참조 테이블의 데이터를 자동으로 조회하여 표시합니다.
|
|
</p>
|
|
|
|
{loadingEntityJoins ? (
|
|
<p className="text-muted-foreground py-2 text-xs">로딩 중...</p>
|
|
) : entityJoinData.joinTables.length === 0 ? (
|
|
<p className="text-muted-foreground py-2 text-xs">
|
|
{entityJoinTargetTable
|
|
? `${entityJoinTargetTable} 테이블에 Entity 조인 가능한 컬럼이 없습니다`
|
|
: "저장 테이블을 먼저 설정해주세요"}
|
|
</p>
|
|
) : (
|
|
<div className="space-y-3">
|
|
{entityJoinData.joinTables.map((joinTable, tableIndex) => {
|
|
const sourceColumn = (joinTable as any).joinConfig?.sourceColumn || "";
|
|
|
|
return (
|
|
<div key={tableIndex} className="space-y-1">
|
|
<div className="mb-1 flex items-center gap-2 text-[10px] font-medium text-primary">
|
|
<Link2 className="h-3 w-3" />
|
|
<span>{joinTable.tableName}</span>
|
|
<span className="text-muted-foreground">({sourceColumn})</span>
|
|
</div>
|
|
<div className="max-h-40 space-y-0.5 overflow-y-auto rounded-md border border-primary/20 bg-primary/10/30 p-2">
|
|
{joinTable.availableColumns.map((column, colIndex) => {
|
|
const isActive = isEntityJoinColumnActive(
|
|
joinTable.tableName,
|
|
sourceColumn,
|
|
column.columnName,
|
|
);
|
|
const matchingCol = config.columns.find((c) => c.key === column.columnName && c.isJoinColumn);
|
|
const displayField = matchingCol?.key || column.columnName;
|
|
|
|
return (
|
|
<div
|
|
key={colIndex}
|
|
className={cn(
|
|
"flex cursor-pointer items-center gap-2 rounded px-2 py-1 hover:bg-primary/10/50",
|
|
isActive && "bg-primary/10",
|
|
)}
|
|
onClick={() =>
|
|
toggleEntityJoinColumn(
|
|
joinTable.tableName,
|
|
sourceColumn,
|
|
column.columnName,
|
|
column.columnLabel,
|
|
displayField,
|
|
column.inputType || column.dataType
|
|
)
|
|
}
|
|
>
|
|
<Checkbox
|
|
checked={isActive}
|
|
className="pointer-events-none h-3.5 w-3.5"
|
|
/>
|
|
<Link2 className="h-3 w-3 flex-shrink-0 text-primary" />
|
|
<span className="truncate text-xs">{column.columnLabel}</span>
|
|
<span className="ml-auto text-[10px] text-primary/80">
|
|
{column.inputType || column.dataType}
|
|
</span>
|
|
</div>
|
|
);
|
|
})}
|
|
</div>
|
|
</div>
|
|
);
|
|
})}
|
|
</div>
|
|
)}
|
|
</div>
|
|
|
|
{/* 선택된 컬럼 상세 설정 - 🆕 모든 컬럼 통합, 순서 변경 가능 */}
|
|
{config.columns.length > 0 && (
|
|
<>
|
|
<Separator />
|
|
<div className="space-y-2">
|
|
<Label className="text-xs font-medium">
|
|
선택된 컬럼 ({config.columns.length}개)
|
|
<span className="text-muted-foreground ml-2 font-normal">드래그로 순서 변경</span>
|
|
</Label>
|
|
<div className="max-h-48 space-y-1 overflow-y-auto">
|
|
{config.columns.map((col, index) => (
|
|
<div key={col.key} className="space-y-1">
|
|
{/* 컬럼 헤더 (드래그 가능) */}
|
|
<div
|
|
className={cn(
|
|
"flex items-center gap-2 rounded-md border p-2",
|
|
(col.isSourceDisplay || col.isJoinColumn) ? "border-primary/20 bg-primary/10/50" : "border-border bg-muted/30",
|
|
col.hidden && "opacity-50",
|
|
)}
|
|
draggable
|
|
onDragStart={(e) => {
|
|
e.dataTransfer.setData("columnIndex", String(index));
|
|
}}
|
|
onDragOver={(e) => e.preventDefault()}
|
|
onDrop={(e) => {
|
|
e.preventDefault();
|
|
const fromIndex = parseInt(e.dataTransfer.getData("columnIndex"), 10);
|
|
if (fromIndex !== index) {
|
|
const newColumns = [...config.columns];
|
|
const [movedCol] = newColumns.splice(fromIndex, 1);
|
|
newColumns.splice(index, 0, movedCol);
|
|
updateConfig({ columns: newColumns });
|
|
}
|
|
}}
|
|
>
|
|
<GripVertical className="text-muted-foreground h-3 w-3 cursor-grab flex-shrink-0" />
|
|
|
|
{/* 확장/축소 버튼 (입력 컬럼만) */}
|
|
{(!col.isSourceDisplay && !col.isJoinColumn) && (
|
|
<button
|
|
type="button"
|
|
onClick={() => setExpandedColumn(expandedColumn === col.key ? null : col.key)}
|
|
className="p-0.5 hover:bg-muted/80 rounded"
|
|
>
|
|
{expandedColumn === col.key ? (
|
|
<ChevronDown className="h-3 w-3 text-muted-foreground" />
|
|
) : (
|
|
<ChevronRight className="h-3 w-3 text-muted-foreground" />
|
|
)}
|
|
</button>
|
|
)}
|
|
|
|
{col.isSourceDisplay ? (
|
|
<Link2 className="text-primary h-3 w-3 flex-shrink-0" title="소스 표시 (읽기 전용)" />
|
|
) : col.isJoinColumn ? (
|
|
<Link2 className="text-amber-500 h-3 w-3 flex-shrink-0" title="Entity 조인 (읽기 전용)" />
|
|
) : (
|
|
<Database className="text-muted-foreground h-3 w-3 flex-shrink-0" />
|
|
)}
|
|
|
|
<Input
|
|
value={col.title}
|
|
onChange={(e) => updateColumnProp(col.key, "title", e.target.value)}
|
|
placeholder="제목"
|
|
className="h-6 flex-1 text-xs"
|
|
/>
|
|
|
|
{/* 히든 토글 (입력 컬럼만) */}
|
|
{(!col.isSourceDisplay && !col.isJoinColumn) && (
|
|
<button
|
|
type="button"
|
|
onClick={() => updateColumnProp(col.key, "hidden", !col.hidden)}
|
|
className={cn(
|
|
"p-1 rounded hover:bg-muted/80",
|
|
col.hidden ? "text-muted-foreground/70" : "text-muted-foreground",
|
|
)}
|
|
title={col.hidden ? "히든 (저장만 됨)" : "표시됨"}
|
|
>
|
|
{col.hidden ? <EyeOff className="h-3 w-3" /> : <Eye className="h-3 w-3" />}
|
|
</button>
|
|
)}
|
|
|
|
{/* 자동입력 표시 아이콘 */}
|
|
{(!col.isSourceDisplay && !col.isJoinColumn) && col.autoFill?.type && col.autoFill.type !== "none" && (
|
|
<Wand2 className="h-3 w-3 text-purple-500 flex-shrink-0" title="자동 입력" />
|
|
)}
|
|
|
|
{/* 편집 가능 토글 */}
|
|
{(!col.isSourceDisplay && !col.isJoinColumn) && (
|
|
<button
|
|
type="button"
|
|
onClick={() => updateColumnProp(col.key, "editable", !(col.editable ?? true))}
|
|
className={cn(
|
|
"shrink-0 rounded px-1.5 py-0.5 text-[9px] font-medium transition-colors",
|
|
(col.editable ?? true)
|
|
? "bg-emerald-100 text-emerald-700 dark:bg-emerald-900/30 dark:text-emerald-400"
|
|
: "bg-muted text-muted-foreground dark:bg-foreground/90 dark:text-muted-foreground/70"
|
|
)}
|
|
title={(col.editable ?? true) ? "편집 가능 (클릭하여 읽기 전용으로 변경)" : "읽기 전용 (클릭하여 편집 가능으로 변경)"}
|
|
>
|
|
{(col.editable ?? true) ? "편집" : "읽기"}
|
|
</button>
|
|
)}
|
|
|
|
<Button
|
|
type="button"
|
|
variant="ghost"
|
|
size="sm"
|
|
onClick={() => {
|
|
if (col.isSourceDisplay) {
|
|
toggleSourceDisplayColumn({ columnName: col.key, displayName: col.title });
|
|
} else if (col.isJoinColumn) {
|
|
const newColumns = config.columns.filter(c => c.key !== col.key);
|
|
const newEntityJoins = config.entityJoins?.map(join => ({
|
|
...join,
|
|
columns: join.columns.filter(c => c.displayField !== col.key)
|
|
})).filter(join => join.columns.length > 0);
|
|
updateConfig({ columns: newColumns, entityJoins: newEntityJoins });
|
|
} else {
|
|
toggleInputColumn({ columnName: col.key, displayName: col.title });
|
|
}
|
|
}}
|
|
className="text-destructive h-6 w-6 p-0"
|
|
>
|
|
<Trash2 className="h-3 w-3" />
|
|
</Button>
|
|
</div>
|
|
|
|
{/* 확장된 상세 설정 (입력 컬럼만) */}
|
|
{(!col.isSourceDisplay && !col.isJoinColumn) && expandedColumn === col.key && (
|
|
<div className="ml-6 space-y-2 rounded-md border border-dashed border-input bg-muted p-2">
|
|
{/* 자동 입력 설정 */}
|
|
<div className="space-y-1">
|
|
<Label className="text-[10px] text-muted-foreground">자동 입력</Label>
|
|
<Select
|
|
value={col.autoFill?.type || "none"}
|
|
onValueChange={(value) => {
|
|
const autoFill = value === "none" ? undefined : { type: value as any };
|
|
updateColumnProp(col.key, "autoFill", autoFill);
|
|
}}
|
|
>
|
|
<SelectTrigger className="h-7 text-xs">
|
|
<SelectValue />
|
|
</SelectTrigger>
|
|
<SelectContent>
|
|
{autoFillOptions.map((opt) => (
|
|
<SelectItem key={opt.value} value={opt.value} className="text-xs">
|
|
{opt.label}
|
|
</SelectItem>
|
|
))}
|
|
</SelectContent>
|
|
</Select>
|
|
</div>
|
|
|
|
{/* 채번 규칙 선택 */}
|
|
{col.autoFill?.type === "numbering" && (
|
|
<div className="space-y-2">
|
|
{/* 대상 메뉴 선택 */}
|
|
<div className="space-y-1">
|
|
<Label className="text-[10px] text-muted-foreground">
|
|
대상 메뉴 선택 <span className="text-destructive">*</span>
|
|
</Label>
|
|
<Select
|
|
value={selectedMenuObjid?.toString() || ""}
|
|
onValueChange={(value) => {
|
|
const menuObjidNum = parseInt(value);
|
|
setSelectedMenuObjid(menuObjidNum);
|
|
|
|
// 컬럼 설정에도 저장하여 유지
|
|
updateColumnProp(col.key, "autoFill", {
|
|
...col.autoFill,
|
|
selectedMenuObjid: menuObjidNum,
|
|
numberingRuleId: undefined, // 메뉴 변경 시 규칙 초기화
|
|
});
|
|
}}
|
|
disabled={loadingMenus}
|
|
>
|
|
<SelectTrigger className="h-7 text-xs">
|
|
<SelectValue placeholder={loadingMenus ? "메뉴 로딩 중..." : "채번규칙을 사용할 메뉴 선택"} />
|
|
</SelectTrigger>
|
|
<SelectContent>
|
|
{parentMenus.length === 0 ? (
|
|
<SelectItem value="no-menus" disabled>
|
|
사용 가능한 메뉴가 없습니다
|
|
</SelectItem>
|
|
) : (
|
|
parentMenus.map((menu) => (
|
|
<SelectItem key={menu.objid} value={menu.objid.toString()}>
|
|
{menu.menu_name_kor}
|
|
</SelectItem>
|
|
))
|
|
)}
|
|
</SelectContent>
|
|
</Select>
|
|
<p className="text-[10px] text-muted-foreground">
|
|
이 입력 필드가 어느 메뉴에 속할지 선택하세요 (해당 메뉴의 채번규칙이 적용됩니다)
|
|
</p>
|
|
</div>
|
|
|
|
{/* 채번 규칙 선택 (메뉴 선택 후) */}
|
|
{selectedMenuObjid ? (
|
|
<div className="space-y-1">
|
|
<Label className="text-[10px] text-muted-foreground">
|
|
채번 규칙 선택 <span className="text-destructive">*</span>
|
|
</Label>
|
|
{loadingNumberingRules ? (
|
|
<p className="text-[10px] text-muted-foreground/70">규칙 로딩 중...</p>
|
|
) : numberingRules.length === 0 ? (
|
|
<div className="rounded-md border border-amber-200 bg-amber-50 p-2 text-[10px] text-amber-800">
|
|
선택된 메뉴에 사용 가능한 채번 규칙이 없습니다
|
|
</div>
|
|
) : (
|
|
<Select
|
|
value={col.autoFill?.numberingRuleId || ""}
|
|
onValueChange={(value) => updateColumnProp(col.key, "autoFill", {
|
|
...col.autoFill,
|
|
selectedMenuObjid,
|
|
numberingRuleId: value,
|
|
})}
|
|
>
|
|
<SelectTrigger className="h-7 text-xs">
|
|
<SelectValue placeholder="채번 규칙 선택" />
|
|
</SelectTrigger>
|
|
<SelectContent>
|
|
{numberingRules.map((rule) => (
|
|
<SelectItem key={rule.ruleId} value={rule.ruleId} className="text-xs">
|
|
{rule.ruleName}
|
|
{rule.description && (
|
|
<span className="text-muted-foreground ml-2">
|
|
- {rule.description}
|
|
</span>
|
|
)}
|
|
</SelectItem>
|
|
))}
|
|
</SelectContent>
|
|
</Select>
|
|
)}
|
|
{col.autoFill?.numberingRuleId && (
|
|
<p className="text-[10px] text-emerald-600">
|
|
저장 시 채번 API를 통해 자동 생성됩니다.
|
|
</p>
|
|
)}
|
|
</div>
|
|
) : (
|
|
<div className="rounded-md border border-amber-200 bg-amber-50 p-2 text-[10px] text-amber-800">
|
|
먼저 대상 메뉴를 선택하세요
|
|
</div>
|
|
)}
|
|
</div>
|
|
)}
|
|
|
|
{/* 메인 폼에서 복사 설정 */}
|
|
{col.autoFill?.type === "fromMainForm" && (
|
|
<div className="space-y-1">
|
|
<Label className="text-[10px] text-muted-foreground">복사할 필드명</Label>
|
|
<Input
|
|
value={col.autoFill?.sourceField || ""}
|
|
onChange={(e) => updateColumnProp(col.key, "autoFill", {
|
|
...col.autoFill,
|
|
sourceField: e.target.value,
|
|
})}
|
|
placeholder="order_no"
|
|
className="h-6 text-xs"
|
|
/>
|
|
</div>
|
|
)}
|
|
|
|
{/* 고정값 설정 */}
|
|
{col.autoFill?.type === "fixed" && (
|
|
<div className="space-y-1">
|
|
<Label className="text-[10px] text-muted-foreground">고정값</Label>
|
|
<Input
|
|
value={String(col.autoFill?.fixedValue || "")}
|
|
onChange={(e) => updateColumnProp(col.key, "autoFill", {
|
|
...col.autoFill,
|
|
fixedValue: e.target.value,
|
|
})}
|
|
placeholder="고정값 입력"
|
|
className="h-6 text-xs"
|
|
/>
|
|
</div>
|
|
)}
|
|
|
|
{/* 부모채번+순번 설정 */}
|
|
{col.autoFill?.type === "parentSequence" && (
|
|
<div className="space-y-2">
|
|
<div className="space-y-1">
|
|
<Label className="text-[10px] text-muted-foreground">부모 번호 필드명</Label>
|
|
<Input
|
|
value={col.autoFill?.parentField || ""}
|
|
onChange={(e) => updateColumnProp(col.key, "autoFill", {
|
|
...col.autoFill,
|
|
parentField: e.target.value,
|
|
})}
|
|
placeholder="work_order_no"
|
|
className="h-6 text-xs"
|
|
/>
|
|
<p className="text-[10px] text-muted-foreground/70">메인 폼에서 가져올 부모 채번 필드</p>
|
|
</div>
|
|
<div className="flex gap-2">
|
|
<div className="flex-1 space-y-1">
|
|
<Label className="text-[10px] text-muted-foreground">구분자</Label>
|
|
<Input
|
|
value={col.autoFill?.separator ?? "-"}
|
|
onChange={(e) => updateColumnProp(col.key, "autoFill", {
|
|
...col.autoFill,
|
|
separator: e.target.value,
|
|
})}
|
|
placeholder="-"
|
|
className="h-6 text-xs"
|
|
/>
|
|
</div>
|
|
<div className="flex-1 space-y-1">
|
|
<Label className="text-[10px] text-muted-foreground">순번 자릿수</Label>
|
|
<Input
|
|
type="number"
|
|
min={1}
|
|
max={5}
|
|
value={col.autoFill?.sequenceLength ?? 2}
|
|
onChange={(e) => updateColumnProp(col.key, "autoFill", {
|
|
...col.autoFill,
|
|
sequenceLength: parseInt(e.target.value) || 2,
|
|
})}
|
|
className="h-6 text-xs"
|
|
/>
|
|
</div>
|
|
</div>
|
|
<p className="text-[10px] text-emerald-600">
|
|
예시: WO-20260223-005{col.autoFill?.separator ?? "-"}{String(1).padStart(col.autoFill?.sequenceLength ?? 2, "0")}
|
|
</p>
|
|
</div>
|
|
)}
|
|
</div>
|
|
)}
|
|
</div>
|
|
))}
|
|
</div>
|
|
</div>
|
|
</>
|
|
)}
|
|
|
|
{/* 계산 규칙 */}
|
|
{(isModalMode || isInlineMode) && config.columns.length > 0 && (
|
|
<>
|
|
<Separator />
|
|
<div className="space-y-1">
|
|
<div className="flex items-center justify-between">
|
|
<Label className="text-xs font-medium">계산 규칙</Label>
|
|
<Button type="button" variant="outline" size="sm" onClick={addCalculationRule} className="h-6 text-xs">
|
|
<Calculator className="mr-1 h-3 w-3" />
|
|
추가
|
|
</Button>
|
|
</div>
|
|
|
|
<div className="space-y-2">
|
|
{calculationRules.map((rule) => (
|
|
<div key={rule.id} className="space-y-1 rounded border p-1.5">
|
|
<div className="flex items-center gap-1">
|
|
<Select
|
|
value={rule.targetColumn}
|
|
onValueChange={(value) => updateCalculationRule(rule.id, "targetColumn", value)}
|
|
>
|
|
<SelectTrigger className="h-6 w-20 text-[10px]">
|
|
<SelectValue placeholder="결과" />
|
|
</SelectTrigger>
|
|
<SelectContent>
|
|
{config.columns.filter(col => !col.isSourceDisplay).map((col) => (
|
|
<SelectItem key={col.key} value={col.key} className="text-xs">
|
|
{col.title || col.key}
|
|
</SelectItem>
|
|
))}
|
|
</SelectContent>
|
|
</Select>
|
|
<span className="text-[10px]">=</span>
|
|
<Input
|
|
value={rule.formula}
|
|
onChange={(e) => updateCalculationRule(rule.id, "formula", e.target.value)}
|
|
placeholder="컬럼 클릭 또는 직접 입력"
|
|
className="h-6 flex-1 font-mono text-[10px]"
|
|
/>
|
|
<Button
|
|
type="button"
|
|
variant="ghost"
|
|
size="sm"
|
|
onClick={() => removeCalculationRule(rule.id)}
|
|
className="h-6 w-6 p-0 text-destructive"
|
|
>
|
|
<Trash2 className="h-3 w-3" />
|
|
</Button>
|
|
</div>
|
|
|
|
{/* 한글 수식 미리보기 */}
|
|
{rule.formula && (
|
|
<p className="truncate rounded bg-muted/50 px-1.5 py-0.5 text-[10px] text-muted-foreground">
|
|
{config.columns.find(c => c.key === rule.targetColumn)?.title || rule.targetColumn || "결과"} = {formulaToKorean(rule.formula)}
|
|
</p>
|
|
)}
|
|
|
|
{/* 컬럼 칩: 디테일 컬럼 + 소스(품목) 컬럼 + 연산자 */}
|
|
<div className="flex flex-wrap gap-0.5">
|
|
{config.columns
|
|
.filter(col => col.key !== rule.targetColumn && !col.isSourceDisplay)
|
|
.map((col) => (
|
|
<Button
|
|
key={col.key}
|
|
type="button"
|
|
variant="secondary"
|
|
size="sm"
|
|
onClick={() => insertColumnToFormula(rule.id, col.key)}
|
|
className="h-4 px-1 text-[9px]"
|
|
>
|
|
{col.title || col.key}
|
|
</Button>
|
|
))}
|
|
{config.columns
|
|
.filter(col => col.isSourceDisplay)
|
|
.map((col) => (
|
|
<Button
|
|
key={col.key}
|
|
type="button"
|
|
variant="outline"
|
|
size="sm"
|
|
onClick={() => insertColumnToFormula(rule.id, col.key)}
|
|
className="h-4 border-dashed px-1 text-[9px] text-primary"
|
|
title="품목 정보 컬럼"
|
|
>
|
|
{col.title || col.key}
|
|
</Button>
|
|
))}
|
|
{["+", "-", "*", "/", "(", ")"].map((op) => (
|
|
<Button
|
|
key={op}
|
|
type="button"
|
|
variant="outline"
|
|
size="sm"
|
|
onClick={() => insertColumnToFormula(rule.id, op)}
|
|
className="h-4 w-4 p-0 font-mono text-[9px]"
|
|
>
|
|
{op}
|
|
</Button>
|
|
))}
|
|
</div>
|
|
</div>
|
|
))}
|
|
|
|
{calculationRules.length === 0 && (
|
|
<p className="text-muted-foreground py-1 text-center text-[10px]">
|
|
계산 규칙이 없습니다
|
|
</p>
|
|
)}
|
|
</div>
|
|
</div>
|
|
</>
|
|
)}
|
|
</TabsContent>
|
|
|
|
</Tabs>
|
|
</div>
|
|
);
|
|
};
|
|
|
|
V2RepeaterConfigPanel.displayName = "V2RepeaterConfigPanel";
|
|
|
|
export default V2RepeaterConfigPanel;
|