feature/v2-unified-renewal #379
|
|
@ -447,7 +447,13 @@ export class EntityJoinController {
|
||||||
logger.info(`Entity 조인 컬럼 조회: ${tableName}`);
|
logger.info(`Entity 조인 컬럼 조회: ${tableName}`);
|
||||||
|
|
||||||
// 1. 현재 테이블의 Entity 조인 설정 조회
|
// 1. 현재 테이블의 Entity 조인 설정 조회
|
||||||
const joinConfigs = await entityJoinService.detectEntityJoins(tableName);
|
const allJoinConfigs = await entityJoinService.detectEntityJoins(tableName);
|
||||||
|
|
||||||
|
// 🆕 화면 디자이너용: table_column_category_values는 카테고리 드롭다운용이므로 제외
|
||||||
|
// 카테고리 값은 엔티티 조인 컬럼이 아니라 셀렉트박스 옵션으로 사용됨
|
||||||
|
const joinConfigs = allJoinConfigs.filter(
|
||||||
|
(config) => config.referenceTable !== "table_column_category_values"
|
||||||
|
);
|
||||||
|
|
||||||
if (joinConfigs.length === 0) {
|
if (joinConfigs.length === 0) {
|
||||||
res.status(200).json({
|
res.status(200).json({
|
||||||
|
|
|
||||||
|
|
@ -750,9 +750,9 @@ export class EntityJoinService {
|
||||||
return columns.map((col) => {
|
return columns.map((col) => {
|
||||||
const labelInfo = labelMap.get(col.column_name);
|
const labelInfo = labelMap.get(col.column_name);
|
||||||
return {
|
return {
|
||||||
columnName: col.column_name,
|
columnName: col.column_name,
|
||||||
displayName: labelInfo?.label || col.column_name,
|
displayName: labelInfo?.label || col.column_name,
|
||||||
dataType: col.data_type,
|
dataType: col.data_type,
|
||||||
inputType: labelInfo?.inputType || "text",
|
inputType: labelInfo?.inputType || "text",
|
||||||
};
|
};
|
||||||
});
|
});
|
||||||
|
|
|
||||||
|
|
@ -2752,7 +2752,7 @@ export default function ScreenDesigner({ selectedScreen, onBackToList }: ScreenD
|
||||||
isEntityJoin: true,
|
isEntityJoin: true,
|
||||||
entityJoinTable: column.entityJoinTable,
|
entityJoinTable: column.entityJoinTable,
|
||||||
entityJoinColumn: column.entityJoinColumn,
|
entityJoinColumn: column.entityJoinColumn,
|
||||||
}),
|
}),
|
||||||
style: {
|
style: {
|
||||||
labelDisplay: false, // 라벨 숨김
|
labelDisplay: false, // 라벨 숨김
|
||||||
labelFontSize: "12px",
|
labelFontSize: "12px",
|
||||||
|
|
@ -2818,7 +2818,7 @@ export default function ScreenDesigner({ selectedScreen, onBackToList }: ScreenD
|
||||||
isEntityJoin: true,
|
isEntityJoin: true,
|
||||||
entityJoinTable: column.entityJoinTable,
|
entityJoinTable: column.entityJoinTable,
|
||||||
entityJoinColumn: column.entityJoinColumn,
|
entityJoinColumn: column.entityJoinColumn,
|
||||||
}),
|
}),
|
||||||
style: {
|
style: {
|
||||||
labelDisplay: false, // 라벨 숨김
|
labelDisplay: false, // 라벨 숨김
|
||||||
labelFontSize: "14px",
|
labelFontSize: "14px",
|
||||||
|
|
|
||||||
|
|
@ -234,8 +234,8 @@ export const TablesPanel: React.FC<TablesPanelProps> = ({
|
||||||
draggable
|
draggable
|
||||||
onDragStart={(e) => onDragStart(e, table, column)}
|
onDragStart={(e) => onDragStart(e, table, column)}
|
||||||
>
|
>
|
||||||
{getWidgetIcon(column.widgetType)}
|
{getWidgetIcon(column.widgetType)}
|
||||||
<div className="min-w-0 flex-1">
|
<div className="min-w-0 flex-1">
|
||||||
<div
|
<div
|
||||||
className="text-xs font-medium"
|
className="text-xs font-medium"
|
||||||
title={column.columnLabel || column.columnName}
|
title={column.columnLabel || column.columnName}
|
||||||
|
|
|
||||||
|
|
@ -12,7 +12,7 @@
|
||||||
|
|
||||||
import React, { useState, useEffect, useCallback, useMemo } from "react";
|
import React, { useState, useEffect, useCallback, useMemo } from "react";
|
||||||
import { Button } from "@/components/ui/button";
|
import { Button } from "@/components/ui/button";
|
||||||
import { Plus, Columns } from "lucide-react";
|
import { Plus } from "lucide-react";
|
||||||
import { cn } from "@/lib/utils";
|
import { cn } from "@/lib/utils";
|
||||||
import {
|
import {
|
||||||
UnifiedRepeaterConfig,
|
UnifiedRepeaterConfig,
|
||||||
|
|
@ -58,17 +58,29 @@ export const UnifiedRepeater: React.FC<UnifiedRepeaterProps> = ({
|
||||||
const [data, setData] = useState<any[]>(initialData || []);
|
const [data, setData] = useState<any[]>(initialData || []);
|
||||||
const [selectedRows, setSelectedRows] = useState<Set<number>>(new Set());
|
const [selectedRows, setSelectedRows] = useState<Set<number>>(new Set());
|
||||||
const [modalOpen, setModalOpen] = useState(false);
|
const [modalOpen, setModalOpen] = useState(false);
|
||||||
const [equalizeWidthsTrigger, setEqualizeWidthsTrigger] = useState(0);
|
|
||||||
|
// 🆕 데이터 변경 시 자동으로 컬럼 너비 조정 트리거
|
||||||
|
const [autoWidthTrigger, setAutoWidthTrigger] = useState(0);
|
||||||
|
|
||||||
// 소스 테이블 컬럼 라벨 매핑
|
// 소스 테이블 컬럼 라벨 매핑
|
||||||
const [sourceColumnLabels, setSourceColumnLabels] = useState<Record<string, string>>({});
|
const [sourceColumnLabels, setSourceColumnLabels] = useState<Record<string, string>>({});
|
||||||
|
|
||||||
|
// 🆕 소스 테이블의 카테고리 타입 컬럼 목록
|
||||||
|
const [sourceCategoryColumns, setSourceCategoryColumns] = useState<string[]>([]);
|
||||||
|
|
||||||
|
// 🆕 카테고리 코드 → 라벨 매핑 (RepeaterTable 표시용)
|
||||||
|
const [categoryLabelMap, setCategoryLabelMap] = useState<Record<string, string>>({});
|
||||||
|
|
||||||
// 현재 테이블 컬럼 정보 (inputType 매핑용)
|
// 현재 테이블 컬럼 정보 (inputType 매핑용)
|
||||||
const [currentTableColumnInfo, setCurrentTableColumnInfo] = useState<Record<string, any>>({});
|
const [currentTableColumnInfo, setCurrentTableColumnInfo] = useState<Record<string, any>>({});
|
||||||
|
|
||||||
// 동적 데이터 소스 상태
|
// 동적 데이터 소스 상태
|
||||||
const [activeDataSources, setActiveDataSources] = useState<Record<string, string>>({});
|
const [activeDataSources, setActiveDataSources] = useState<Record<string, string>>({});
|
||||||
|
|
||||||
|
// 🆕 최신 엔티티 참조 정보 (column_labels에서 조회)
|
||||||
|
const [resolvedSourceTable, setResolvedSourceTable] = useState<string>("");
|
||||||
|
const [resolvedReferenceKey, setResolvedReferenceKey] = useState<string>("id");
|
||||||
|
|
||||||
const isModalMode = config.renderMode === "modal";
|
const isModalMode = config.renderMode === "modal";
|
||||||
|
|
||||||
// 전역 리피터 등록
|
// 전역 리피터 등록
|
||||||
|
|
@ -104,7 +116,8 @@ export const UnifiedRepeater: React.FC<UnifiedRepeaterProps> = ({
|
||||||
let validColumns: Set<string> = new Set();
|
let validColumns: Set<string> = new Set();
|
||||||
try {
|
try {
|
||||||
const columnsResponse = await apiClient.get(`/table-management/tables/${tableName}/columns`);
|
const columnsResponse = await apiClient.get(`/table-management/tables/${tableName}/columns`);
|
||||||
const columns = columnsResponse.data?.data?.columns || columnsResponse.data?.columns || columnsResponse.data || [];
|
const columns =
|
||||||
|
columnsResponse.data?.data?.columns || columnsResponse.data?.columns || columnsResponse.data || [];
|
||||||
validColumns = new Set(columns.map((col: any) => col.columnName || col.column_name || col.name));
|
validColumns = new Set(columns.map((col: any) => col.columnName || col.column_name || col.name));
|
||||||
} catch {
|
} catch {
|
||||||
console.warn("테이블 컬럼 정보 조회 실패");
|
console.warn("테이블 컬럼 정보 조회 실패");
|
||||||
|
|
@ -114,9 +127,7 @@ export const UnifiedRepeater: React.FC<UnifiedRepeaterProps> = ({
|
||||||
const row = data[i];
|
const row = data[i];
|
||||||
|
|
||||||
// 내부 필드 제거
|
// 내부 필드 제거
|
||||||
const cleanRow = Object.fromEntries(
|
const cleanRow = Object.fromEntries(Object.entries(row).filter(([key]) => !key.startsWith("_")));
|
||||||
Object.entries(row).filter(([key]) => !key.startsWith("_"))
|
|
||||||
);
|
|
||||||
|
|
||||||
// 메인 폼 데이터 병합
|
// 메인 폼 데이터 병합
|
||||||
const { id: _mainId, ...mainFormDataWithoutId } = mainFormData || {};
|
const { id: _mainId, ...mainFormDataWithoutId } = mainFormData || {};
|
||||||
|
|
@ -176,110 +187,253 @@ export const UnifiedRepeater: React.FC<UnifiedRepeaterProps> = ({
|
||||||
loadCurrentTableColumnInfo();
|
loadCurrentTableColumnInfo();
|
||||||
}, [config.dataSource?.tableName]);
|
}, [config.dataSource?.tableName]);
|
||||||
|
|
||||||
// 소스 테이블 컬럼 라벨 로드 (modal 모드)
|
// 🆕 FK 컬럼 기반으로 최신 참조 테이블 정보 조회 (column_labels에서)
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const loadSourceColumnLabels = async () => {
|
const resolveEntityReference = async () => {
|
||||||
const sourceTable = config.dataSource?.sourceTable;
|
const tableName = config.dataSource?.tableName;
|
||||||
if (!isModalMode || !sourceTable) return;
|
const foreignKey = config.dataSource?.foreignKey;
|
||||||
|
|
||||||
|
if (!isModalMode || !tableName || !foreignKey) {
|
||||||
|
// config에 저장된 값을 기본값으로 사용
|
||||||
|
setResolvedSourceTable(config.dataSource?.sourceTable || "");
|
||||||
|
setResolvedReferenceKey(config.dataSource?.referenceKey || "id");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const response = await apiClient.get(`/table-management/tables/${sourceTable}/columns`);
|
// 현재 테이블의 컬럼 정보에서 FK 컬럼의 참조 테이블 조회
|
||||||
|
const response = await apiClient.get(`/table-management/tables/${tableName}/columns`);
|
||||||
|
const columns = response.data?.data?.columns || response.data?.columns || response.data || [];
|
||||||
|
|
||||||
|
const fkColumn = columns.find((col: any) => (col.columnName || col.column_name || col.name) === foreignKey);
|
||||||
|
|
||||||
|
if (fkColumn) {
|
||||||
|
// column_labels의 reference_table 사용 (항상 최신값)
|
||||||
|
const refTable =
|
||||||
|
fkColumn.detailSettings?.referenceTable ||
|
||||||
|
fkColumn.reference_table ||
|
||||||
|
fkColumn.referenceTable ||
|
||||||
|
config.dataSource?.sourceTable ||
|
||||||
|
"";
|
||||||
|
const refKey =
|
||||||
|
fkColumn.detailSettings?.referenceColumn ||
|
||||||
|
fkColumn.reference_column ||
|
||||||
|
fkColumn.referenceColumn ||
|
||||||
|
config.dataSource?.referenceKey ||
|
||||||
|
"id";
|
||||||
|
|
||||||
|
console.log("🔄 [UnifiedRepeater] 엔티티 참조 정보 조회:", {
|
||||||
|
foreignKey,
|
||||||
|
resolvedSourceTable: refTable,
|
||||||
|
resolvedReferenceKey: refKey,
|
||||||
|
configSourceTable: config.dataSource?.sourceTable,
|
||||||
|
});
|
||||||
|
|
||||||
|
setResolvedSourceTable(refTable);
|
||||||
|
setResolvedReferenceKey(refKey);
|
||||||
|
} else {
|
||||||
|
// FK 컬럼을 찾지 못한 경우 config 값 사용
|
||||||
|
setResolvedSourceTable(config.dataSource?.sourceTable || "");
|
||||||
|
setResolvedReferenceKey(config.dataSource?.referenceKey || "id");
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error("엔티티 참조 정보 조회 실패:", error);
|
||||||
|
// 오류 시 config 값 사용
|
||||||
|
setResolvedSourceTable(config.dataSource?.sourceTable || "");
|
||||||
|
setResolvedReferenceKey(config.dataSource?.referenceKey || "id");
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
resolveEntityReference();
|
||||||
|
}, [
|
||||||
|
config.dataSource?.tableName,
|
||||||
|
config.dataSource?.foreignKey,
|
||||||
|
config.dataSource?.sourceTable,
|
||||||
|
config.dataSource?.referenceKey,
|
||||||
|
isModalMode,
|
||||||
|
]);
|
||||||
|
|
||||||
|
// 소스 테이블 컬럼 라벨 로드 (modal 모드) - resolvedSourceTable 사용
|
||||||
|
// 🆕 카테고리 타입 컬럼도 함께 감지
|
||||||
|
useEffect(() => {
|
||||||
|
const loadSourceColumnLabels = async () => {
|
||||||
|
if (!isModalMode || !resolvedSourceTable) return;
|
||||||
|
|
||||||
|
try {
|
||||||
|
const response = await apiClient.get(`/table-management/tables/${resolvedSourceTable}/columns`);
|
||||||
const columns = response.data?.data?.columns || response.data?.columns || response.data || [];
|
const columns = response.data?.data?.columns || response.data?.columns || response.data || [];
|
||||||
|
|
||||||
const labels: Record<string, string> = {};
|
const labels: Record<string, string> = {};
|
||||||
|
const categoryCols: string[] = [];
|
||||||
|
|
||||||
columns.forEach((col: any) => {
|
columns.forEach((col: any) => {
|
||||||
const name = col.columnName || col.column_name || col.name;
|
const name = col.columnName || col.column_name || col.name;
|
||||||
labels[name] = col.displayName || col.display_name || col.label || name;
|
labels[name] = col.displayName || col.display_name || col.label || name;
|
||||||
|
|
||||||
|
// 🆕 카테고리 타입 컬럼 감지
|
||||||
|
const inputType = col.inputType || col.input_type || "";
|
||||||
|
if (inputType === "category") {
|
||||||
|
categoryCols.push(name);
|
||||||
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
setSourceColumnLabels(labels);
|
setSourceColumnLabels(labels);
|
||||||
|
setSourceCategoryColumns(categoryCols);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error("소스 컬럼 라벨 로드 실패:", error);
|
console.error("소스 컬럼 라벨 로드 실패:", error);
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
loadSourceColumnLabels();
|
loadSourceColumnLabels();
|
||||||
}, [config.dataSource?.sourceTable, isModalMode]);
|
}, [resolvedSourceTable, isModalMode]);
|
||||||
|
|
||||||
// UnifiedColumnConfig → RepeaterColumnConfig 변환
|
// UnifiedColumnConfig → RepeaterColumnConfig 변환
|
||||||
|
// 🆕 모든 컬럼을 columns 배열의 순서대로 처리 (isSourceDisplay 플래그로 구분)
|
||||||
const repeaterColumns: RepeaterColumnConfig[] = useMemo(() => {
|
const repeaterColumns: RepeaterColumnConfig[] = useMemo(() => {
|
||||||
const displayColumns: RepeaterColumnConfig[] = [];
|
return config.columns
|
||||||
|
.filter((col: UnifiedColumnConfig) => col.visible !== false)
|
||||||
|
.map((col: UnifiedColumnConfig): RepeaterColumnConfig => {
|
||||||
|
const colInfo = currentTableColumnInfo[col.key];
|
||||||
|
const inputType = col.inputType || colInfo?.inputType || "text";
|
||||||
|
|
||||||
// 모달 표시 컬럼 추가 (읽기 전용)
|
// 소스 표시 컬럼인 경우 (모달 모드에서 읽기 전용)
|
||||||
if (isModalMode && config.modal?.sourceDisplayColumns) {
|
if (col.isSourceDisplay) {
|
||||||
config.modal.sourceDisplayColumns.forEach((col) => {
|
const label = col.title || sourceColumnLabels[col.key] || col.key;
|
||||||
const key = typeof col === "string" ? col : col.key;
|
return {
|
||||||
const label = typeof col === "string" ? sourceColumnLabels[col] || col : col.label || sourceColumnLabels[key] || key;
|
field: `_display_${col.key}`,
|
||||||
|
|
||||||
if (key && key !== "none") {
|
|
||||||
displayColumns.push({
|
|
||||||
field: `_display_${key}`,
|
|
||||||
label,
|
label,
|
||||||
type: "text",
|
type: "text",
|
||||||
editable: false,
|
editable: false,
|
||||||
calculated: true,
|
calculated: true,
|
||||||
});
|
width: col.width === "auto" ? undefined : col.width,
|
||||||
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 일반 입력 컬럼
|
||||||
|
let type: "text" | "number" | "date" | "select" | "category" = "text";
|
||||||
|
if (inputType === "number" || inputType === "decimal") type = "number";
|
||||||
|
else if (inputType === "date" || inputType === "datetime") type = "date";
|
||||||
|
else if (inputType === "code") type = "select";
|
||||||
|
else if (inputType === "category") type = "category"; // 🆕 카테고리 타입
|
||||||
|
|
||||||
|
// 🆕 카테고리 참조 ID 가져오기 (tableName.columnName 형식)
|
||||||
|
// category 타입인 경우 현재 테이블명과 컬럼명을 조합
|
||||||
|
let categoryRef: string | undefined;
|
||||||
|
if (inputType === "category") {
|
||||||
|
// 🆕 소스 표시 컬럼이면 소스 테이블 사용, 아니면 타겟 테이블 사용
|
||||||
|
const tableName = col.isSourceDisplay ? resolvedSourceTable : config.dataSource?.tableName;
|
||||||
|
if (tableName) {
|
||||||
|
categoryRef = `${tableName}.${col.key}`;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
field: col.key,
|
||||||
|
label: col.title || colInfo?.displayName || col.key,
|
||||||
|
type,
|
||||||
|
editable: col.editable !== false,
|
||||||
|
width: col.width === "auto" ? undefined : col.width,
|
||||||
|
required: false,
|
||||||
|
categoryRef, // 🆕 카테고리 참조 ID 전달
|
||||||
|
};
|
||||||
});
|
});
|
||||||
}
|
}, [config.columns, sourceColumnLabels, currentTableColumnInfo, resolvedSourceTable, config.dataSource?.tableName]);
|
||||||
|
|
||||||
// 입력 컬럼 추가
|
// 🆕 데이터 변경 시 카테고리 라벨 로드 (RepeaterTable 표시용)
|
||||||
const inputColumns = config.columns.map((col: UnifiedColumnConfig): RepeaterColumnConfig => {
|
useEffect(() => {
|
||||||
const colInfo = currentTableColumnInfo[col.key];
|
const loadCategoryLabels = async () => {
|
||||||
const inputType = col.inputType || colInfo?.inputType || "text";
|
if (sourceCategoryColumns.length === 0 || data.length === 0) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
let type: "text" | "number" | "date" | "select" = "text";
|
// 데이터에서 카테고리 컬럼의 모든 고유 코드 수집
|
||||||
if (inputType === "number" || inputType === "decimal") type = "number";
|
const allCodes = new Set<string>();
|
||||||
else if (inputType === "date" || inputType === "datetime") type = "date";
|
for (const row of data) {
|
||||||
else if (inputType === "code") type = "select";
|
for (const col of sourceCategoryColumns) {
|
||||||
|
// _display_ 접두사가 있는 컬럼과 원본 컬럼 모두 확인
|
||||||
|
const val = row[`_display_${col}`] || row[col];
|
||||||
|
if (val && typeof val === "string") {
|
||||||
|
const codes = val
|
||||||
|
.split(",")
|
||||||
|
.map((c: string) => c.trim())
|
||||||
|
.filter(Boolean);
|
||||||
|
for (const code of codes) {
|
||||||
|
if (!categoryLabelMap[code] && code.startsWith("CATEGORY_")) {
|
||||||
|
allCodes.add(code);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
return {
|
if (allCodes.size === 0) {
|
||||||
field: col.key,
|
return;
|
||||||
label: col.title || colInfo?.displayName || col.key,
|
}
|
||||||
type,
|
|
||||||
editable: col.editable !== false,
|
|
||||||
width: col.width === "auto" ? undefined : col.width,
|
|
||||||
required: false,
|
|
||||||
};
|
|
||||||
});
|
|
||||||
|
|
||||||
return [...displayColumns, ...inputColumns];
|
try {
|
||||||
}, [config.columns, config.modal?.sourceDisplayColumns, isModalMode, sourceColumnLabels, currentTableColumnInfo]);
|
const response = await apiClient.post("/table-categories/labels-by-codes", {
|
||||||
|
valueCodes: Array.from(allCodes),
|
||||||
|
});
|
||||||
|
|
||||||
|
if (response.data?.success && response.data.data) {
|
||||||
|
setCategoryLabelMap((prev) => ({
|
||||||
|
...prev,
|
||||||
|
...response.data.data,
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error("카테고리 라벨 조회 실패:", error);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
loadCategoryLabels();
|
||||||
|
}, [data, sourceCategoryColumns]);
|
||||||
|
|
||||||
// 데이터 변경 핸들러
|
// 데이터 변경 핸들러
|
||||||
const handleDataChange = useCallback((newData: any[]) => {
|
const handleDataChange = useCallback(
|
||||||
setData(newData);
|
(newData: any[]) => {
|
||||||
onDataChange?.(newData);
|
setData(newData);
|
||||||
}, [onDataChange]);
|
onDataChange?.(newData);
|
||||||
|
// 🆕 데이터 변경 시 자동으로 컬럼 너비 조정
|
||||||
|
setAutoWidthTrigger((prev) => prev + 1);
|
||||||
|
},
|
||||||
|
[onDataChange],
|
||||||
|
);
|
||||||
|
|
||||||
// 행 변경 핸들러
|
// 행 변경 핸들러
|
||||||
const handleRowChange = useCallback((index: number, newRow: any) => {
|
const handleRowChange = useCallback(
|
||||||
const newData = [...data];
|
(index: number, newRow: any) => {
|
||||||
newData[index] = newRow;
|
const newData = [...data];
|
||||||
setData(newData);
|
newData[index] = newRow;
|
||||||
onDataChange?.(newData);
|
// 🆕 handleDataChange 대신 직접 호출 (행 변경마다 너비 조정은 불필요)
|
||||||
}, [data, onDataChange]);
|
setData(newData);
|
||||||
|
onDataChange?.(newData);
|
||||||
|
},
|
||||||
|
[data, onDataChange],
|
||||||
|
);
|
||||||
|
|
||||||
// 행 삭제 핸들러
|
// 행 삭제 핸들러
|
||||||
const handleRowDelete = useCallback((index: number) => {
|
const handleRowDelete = useCallback(
|
||||||
const newData = data.filter((_, i) => i !== index);
|
(index: number) => {
|
||||||
setData(newData);
|
const newData = data.filter((_, i) => i !== index);
|
||||||
onDataChange?.(newData);
|
handleDataChange(newData); // 🆕 handleDataChange 사용
|
||||||
|
|
||||||
// 선택 상태 업데이트
|
// 선택 상태 업데이트
|
||||||
const newSelected = new Set<number>();
|
const newSelected = new Set<number>();
|
||||||
selectedRows.forEach((i) => {
|
selectedRows.forEach((i) => {
|
||||||
if (i < index) newSelected.add(i);
|
if (i < index) newSelected.add(i);
|
||||||
else if (i > index) newSelected.add(i - 1);
|
else if (i > index) newSelected.add(i - 1);
|
||||||
});
|
});
|
||||||
setSelectedRows(newSelected);
|
setSelectedRows(newSelected);
|
||||||
}, [data, selectedRows, onDataChange]);
|
},
|
||||||
|
[data, selectedRows, handleDataChange],
|
||||||
|
);
|
||||||
|
|
||||||
// 일괄 삭제 핸들러
|
// 일괄 삭제 핸들러
|
||||||
const handleBulkDelete = useCallback(() => {
|
const handleBulkDelete = useCallback(() => {
|
||||||
const newData = data.filter((_, index) => !selectedRows.has(index));
|
const newData = data.filter((_, index) => !selectedRows.has(index));
|
||||||
setData(newData);
|
handleDataChange(newData); // 🆕 handleDataChange 사용
|
||||||
onDataChange?.(newData);
|
|
||||||
setSelectedRows(new Set());
|
setSelectedRows(new Set());
|
||||||
}, [data, selectedRows, onDataChange]);
|
}, [data, selectedRows, handleDataChange]);
|
||||||
|
|
||||||
// 행 추가 (inline 모드)
|
// 행 추가 (inline 모드)
|
||||||
const handleAddRow = useCallback(() => {
|
const handleAddRow = useCallback(() => {
|
||||||
|
|
@ -291,96 +445,73 @@ export const UnifiedRepeater: React.FC<UnifiedRepeaterProps> = ({
|
||||||
newRow[col.key] = "";
|
newRow[col.key] = "";
|
||||||
});
|
});
|
||||||
const newData = [...data, newRow];
|
const newData = [...data, newRow];
|
||||||
setData(newData);
|
handleDataChange(newData); // 🆕 handleDataChange 사용
|
||||||
onDataChange?.(newData);
|
|
||||||
}
|
}
|
||||||
}, [isModalMode, config.columns, data, onDataChange]);
|
}, [isModalMode, config.columns, data, handleDataChange]);
|
||||||
|
|
||||||
// 모달에서 항목 선택
|
// 모달에서 항목 선택 - 🆕 columns 배열에서 isSourceDisplay 플래그로 구분
|
||||||
const handleSelectItems = useCallback((items: Record<string, unknown>[]) => {
|
const handleSelectItems = useCallback(
|
||||||
const fkColumn = config.dataSource?.foreignKey;
|
(items: Record<string, unknown>[]) => {
|
||||||
const refKey = config.dataSource?.referenceKey || "id";
|
const fkColumn = config.dataSource?.foreignKey;
|
||||||
|
|
||||||
const newRows = items.map((item) => {
|
const newRows = items.map((item) => {
|
||||||
const row: any = { _id: `new_${Date.now()}_${Math.random()}` };
|
const row: any = { _id: `new_${Date.now()}_${Math.random()}` };
|
||||||
|
|
||||||
// FK 값 저장
|
// FK 값 저장 (resolvedReferenceKey 사용)
|
||||||
if (fkColumn && item[refKey]) {
|
if (fkColumn && item[resolvedReferenceKey]) {
|
||||||
row[fkColumn] = item[refKey];
|
row[fkColumn] = item[resolvedReferenceKey];
|
||||||
}
|
}
|
||||||
|
|
||||||
// 표시용 데이터 저장
|
// 모든 컬럼 처리 (순서대로)
|
||||||
if (config.modal?.sourceDisplayColumns) {
|
config.columns.forEach((col) => {
|
||||||
config.modal.sourceDisplayColumns.forEach((col) => {
|
if (col.isSourceDisplay) {
|
||||||
const key = typeof col === "string" ? col : col.key;
|
// 소스 표시 컬럼: 소스 테이블에서 값 복사 (읽기 전용)
|
||||||
if (key && key !== "none") {
|
row[`_display_${col.key}`] = item[col.key] || "";
|
||||||
row[`_display_${key}`] = item[key] || "";
|
} else {
|
||||||
|
// 입력 컬럼: 빈 값으로 초기화
|
||||||
|
if (row[col.key] === undefined) {
|
||||||
|
row[col.key] = "";
|
||||||
|
}
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
}
|
|
||||||
|
|
||||||
// 입력 컬럼 초기화
|
return row;
|
||||||
config.columns.forEach((col) => {
|
|
||||||
if (row[col.key] === undefined) {
|
|
||||||
row[col.key] = "";
|
|
||||||
}
|
|
||||||
});
|
});
|
||||||
|
|
||||||
return row;
|
const newData = [...data, ...newRows];
|
||||||
});
|
handleDataChange(newData); // 🆕 handleDataChange 사용하여 autoWidthTrigger도 증가
|
||||||
|
setModalOpen(false);
|
||||||
|
},
|
||||||
|
[config.dataSource?.foreignKey, resolvedReferenceKey, config.columns, data, handleDataChange],
|
||||||
|
);
|
||||||
|
|
||||||
const newData = [...data, ...newRows];
|
// 소스 컬럼 목록 (모달용) - 🆕 columns 배열에서 isSourceDisplay인 것만 필터링
|
||||||
setData(newData);
|
|
||||||
onDataChange?.(newData);
|
|
||||||
setModalOpen(false);
|
|
||||||
}, [config.dataSource?.foreignKey, config.dataSource?.referenceKey, config.modal?.sourceDisplayColumns, config.columns, data, onDataChange]);
|
|
||||||
|
|
||||||
// 소스 컬럼 목록 (모달용)
|
|
||||||
const sourceColumns = useMemo(() => {
|
const sourceColumns = useMemo(() => {
|
||||||
if (!config.modal?.sourceDisplayColumns) return [];
|
return config.columns
|
||||||
return config.modal.sourceDisplayColumns
|
.filter((col) => col.isSourceDisplay && col.visible !== false)
|
||||||
.map((col) => typeof col === "string" ? col : col.key)
|
.map((col) => col.key)
|
||||||
.filter((key) => key && key !== "none");
|
.filter((key) => key && key !== "none");
|
||||||
}, [config.modal?.sourceDisplayColumns]);
|
}, [config.columns]);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className={cn("space-y-4", className)}>
|
<div className={cn("space-y-4", className)}>
|
||||||
{/* 헤더 영역 */}
|
{/* 헤더 영역 */}
|
||||||
<div className="flex justify-between items-center">
|
<div className="flex items-center justify-between">
|
||||||
<div className="flex items-center gap-2">
|
<div className="flex items-center gap-2">
|
||||||
<span className="text-sm text-muted-foreground">
|
<span className="text-muted-foreground text-sm">
|
||||||
{data.length > 0 && `${data.length}개 항목`}
|
{data.length > 0 && `${data.length}개 항목`}
|
||||||
{selectedRows.size > 0 && ` (${selectedRows.size}개 선택됨)`}
|
{selectedRows.size > 0 && ` (${selectedRows.size}개 선택됨)`}
|
||||||
</span>
|
</span>
|
||||||
{repeaterColumns.length > 0 && (
|
|
||||||
<Button
|
|
||||||
variant="outline"
|
|
||||||
size="sm"
|
|
||||||
onClick={() => setEqualizeWidthsTrigger((prev) => prev + 1)}
|
|
||||||
className="h-7 text-xs px-2"
|
|
||||||
title="컬럼 너비 균등 분배"
|
|
||||||
>
|
|
||||||
<Columns className="h-3.5 w-3.5 mr-1" />
|
|
||||||
균등 분배
|
|
||||||
</Button>
|
|
||||||
)}
|
|
||||||
</div>
|
</div>
|
||||||
<div className="flex gap-2">
|
<div className="flex gap-2">
|
||||||
{selectedRows.size > 0 && (
|
{selectedRows.size > 0 && (
|
||||||
<Button
|
<Button variant="destructive" onClick={handleBulkDelete} className="h-8 text-xs sm:h-10 sm:text-sm">
|
||||||
variant="destructive"
|
|
||||||
onClick={handleBulkDelete}
|
|
||||||
className="h-8 text-xs sm:h-10 sm:text-sm"
|
|
||||||
>
|
|
||||||
선택 삭제 ({selectedRows.size})
|
선택 삭제 ({selectedRows.size})
|
||||||
</Button>
|
</Button>
|
||||||
)}
|
)}
|
||||||
<Button
|
<Button onClick={handleAddRow} className="h-8 text-xs sm:h-10 sm:text-sm">
|
||||||
onClick={handleAddRow}
|
<Plus className="mr-2 h-4 w-4" />
|
||||||
className="h-8 text-xs sm:h-10 sm:text-sm"
|
{isModalMode ? config.modal?.buttonText || "검색" : "추가"}
|
||||||
>
|
|
||||||
<Plus className="h-4 w-4 mr-2" />
|
|
||||||
{isModalMode ? (config.modal?.buttonText || "검색") : "추가"}
|
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
@ -398,23 +529,26 @@ export const UnifiedRepeater: React.FC<UnifiedRepeaterProps> = ({
|
||||||
}}
|
}}
|
||||||
selectedRows={selectedRows}
|
selectedRows={selectedRows}
|
||||||
onSelectionChange={setSelectedRows}
|
onSelectionChange={setSelectedRows}
|
||||||
equalizeWidthsTrigger={equalizeWidthsTrigger}
|
equalizeWidthsTrigger={autoWidthTrigger}
|
||||||
|
categoryColumns={sourceCategoryColumns}
|
||||||
|
categoryLabelMap={categoryLabelMap}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
{/* 항목 선택 모달 (modal 모드) */}
|
{/* 항목 선택 모달 (modal 모드) - 검색 필드는 표시 컬럼과 동일하게 자동 설정 */}
|
||||||
{isModalMode && (
|
{isModalMode && (
|
||||||
<ItemSelectionModal
|
<ItemSelectionModal
|
||||||
open={modalOpen}
|
open={modalOpen}
|
||||||
onOpenChange={setModalOpen}
|
onOpenChange={setModalOpen}
|
||||||
sourceTable={config.dataSource?.sourceTable || ""}
|
sourceTable={resolvedSourceTable}
|
||||||
sourceColumns={sourceColumns}
|
sourceColumns={sourceColumns}
|
||||||
sourceSearchFields={config.modal?.searchFields || sourceColumns}
|
sourceSearchFields={sourceColumns}
|
||||||
multiSelect={config.features?.multiSelect ?? true}
|
multiSelect={config.features?.multiSelect ?? true}
|
||||||
modalTitle={config.modal?.title || "항목 검색"}
|
modalTitle={config.modal?.title || "항목 검색"}
|
||||||
alreadySelected={data}
|
alreadySelected={data}
|
||||||
uniqueField={config.dataSource?.referenceKey || "id"}
|
uniqueField={resolvedReferenceKey}
|
||||||
onSelect={handleSelectItems}
|
onSelect={handleSelectItems}
|
||||||
columnLabels={sourceColumnLabels}
|
columnLabels={sourceColumnLabels}
|
||||||
|
categoryColumns={sourceCategoryColumns}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
|
||||||
|
|
@ -33,8 +33,6 @@ import {
|
||||||
DEFAULT_REPEATER_CONFIG,
|
DEFAULT_REPEATER_CONFIG,
|
||||||
RENDER_MODE_OPTIONS,
|
RENDER_MODE_OPTIONS,
|
||||||
MODAL_SIZE_OPTIONS,
|
MODAL_SIZE_OPTIONS,
|
||||||
COLUMN_WIDTH_OPTIONS,
|
|
||||||
ColumnWidthOption,
|
|
||||||
} from "@/types/unified-repeater";
|
} from "@/types/unified-repeater";
|
||||||
|
|
||||||
interface UnifiedRepeaterConfigPanelProps {
|
interface UnifiedRepeaterConfigPanelProps {
|
||||||
|
|
@ -276,27 +274,33 @@ export const UnifiedRepeaterConfigPanel: React.FC<UnifiedRepeaterConfigPanelProp
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
// 소스 컬럼 토글 (모달에 표시용 - 라벨 포함)
|
// 🆕 소스 컬럼 토글 - columns 배열에 isSourceDisplay: true로 추가
|
||||||
const toggleSourceDisplayColumn = (column: ColumnOption) => {
|
const toggleSourceDisplayColumn = (column: ColumnOption) => {
|
||||||
const sourceDisplayColumns = config.modal?.sourceDisplayColumns || [];
|
const exists = config.columns.some((c) => c.key === column.columnName && c.isSourceDisplay);
|
||||||
const exists = sourceDisplayColumns.some(c => c.key === column.columnName);
|
|
||||||
|
|
||||||
if (exists) {
|
if (exists) {
|
||||||
updateModal("sourceDisplayColumns", sourceDisplayColumns.filter(c => c.key !== column.columnName));
|
// 제거
|
||||||
|
updateConfig({ columns: config.columns.filter((c) => c.key !== column.columnName) });
|
||||||
} else {
|
} else {
|
||||||
updateModal("sourceDisplayColumns", [
|
// 추가 (isSourceDisplay: true)
|
||||||
...sourceDisplayColumns,
|
const newColumn: RepeaterColumnConfig = {
|
||||||
{ key: column.columnName, label: column.displayName }
|
key: column.columnName,
|
||||||
]);
|
title: column.displayName,
|
||||||
|
width: "auto",
|
||||||
|
visible: true,
|
||||||
|
editable: false, // 소스 표시 컬럼은 편집 불가
|
||||||
|
isSourceDisplay: true,
|
||||||
|
};
|
||||||
|
updateConfig({ columns: [...config.columns, newColumn] });
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
const isColumnAdded = (columnName: string) => {
|
const isColumnAdded = (columnName: string) => {
|
||||||
return config.columns.some((c) => c.key === columnName);
|
return config.columns.some((c) => c.key === columnName && !c.isSourceDisplay);
|
||||||
};
|
};
|
||||||
|
|
||||||
const isSourceColumnSelected = (columnName: string) => {
|
const isSourceColumnSelected = (columnName: string) => {
|
||||||
return (config.modal?.sourceDisplayColumns || []).some(c => c.key === columnName);
|
return config.columns.some((c) => c.key === columnName && c.isSourceDisplay);
|
||||||
};
|
};
|
||||||
|
|
||||||
// 컬럼 속성 업데이트
|
// 컬럼 속성 업데이트
|
||||||
|
|
@ -372,10 +376,9 @@ export const UnifiedRepeaterConfigPanel: React.FC<UnifiedRepeaterConfigPanelProp
|
||||||
return (
|
return (
|
||||||
<div className="space-y-4">
|
<div className="space-y-4">
|
||||||
<Tabs defaultValue="basic" className="w-full">
|
<Tabs defaultValue="basic" className="w-full">
|
||||||
<TabsList className="grid w-full grid-cols-3">
|
<TabsList className="grid w-full grid-cols-2">
|
||||||
<TabsTrigger value="basic" className="text-xs">기본</TabsTrigger>
|
<TabsTrigger value="basic" className="text-xs">기본</TabsTrigger>
|
||||||
<TabsTrigger value="columns" className="text-xs">컬럼</TabsTrigger>
|
<TabsTrigger value="columns" className="text-xs">컬럼</TabsTrigger>
|
||||||
<TabsTrigger value="modal" className="text-xs" disabled={!isModalMode}>모달</TabsTrigger>
|
|
||||||
</TabsList>
|
</TabsList>
|
||||||
|
|
||||||
{/* 기본 설정 탭 */}
|
{/* 기본 설정 탭 */}
|
||||||
|
|
@ -540,29 +543,37 @@ export const UnifiedRepeaterConfigPanel: React.FC<UnifiedRepeaterConfigPanelProp
|
||||||
</div>
|
</div>
|
||||||
</TabsContent>
|
</TabsContent>
|
||||||
|
|
||||||
{/* 컬럼 설정 탭 */}
|
{/* 컬럼 설정 탭 - 🆕 통합 컬럼 선택 */}
|
||||||
<TabsContent value="columns" className="mt-4 space-y-4">
|
<TabsContent value="columns" className="mt-4 space-y-4">
|
||||||
{/* 모달 모드: 모달에 표시할 컬럼 */}
|
{/* 통합 컬럼 선택 */}
|
||||||
{isModalMode && config.dataSource?.sourceTable && (
|
<div className="space-y-2">
|
||||||
<>
|
<Label className="text-xs font-medium">컬럼 선택</Label>
|
||||||
<div className="space-y-2">
|
<p className="text-[10px] text-muted-foreground">
|
||||||
<Label className="text-xs font-medium">모달 표시 컬럼</Label>
|
{isModalMode
|
||||||
<p className="text-[10px] text-muted-foreground">
|
? "표시할 컬럼과 입력 컬럼을 선택하세요. 아이콘으로 표시/입력 구분"
|
||||||
검색 모달에서 보여줄 컬럼 (보기용)
|
: "입력받을 컬럼을 선택하세요"
|
||||||
</p>
|
}
|
||||||
|
</p>
|
||||||
|
|
||||||
|
{/* 모달 모드: 소스 테이블 컬럼 (표시용) */}
|
||||||
|
{isModalMode && config.dataSource?.sourceTable && (
|
||||||
|
<>
|
||||||
|
<div className="text-[10px] font-medium text-blue-600 mt-2 mb-1 flex items-center gap-1">
|
||||||
|
<Link2 className="h-3 w-3" />
|
||||||
|
소스 테이블 ({config.dataSource.sourceTable}) - 표시용
|
||||||
|
</div>
|
||||||
{loadingSourceColumns ? (
|
{loadingSourceColumns ? (
|
||||||
<p className="text-muted-foreground py-2 text-xs">로딩 중...</p>
|
<p className="text-muted-foreground py-2 text-xs">로딩 중...</p>
|
||||||
) : sourceTableColumns.length === 0 ? (
|
) : sourceTableColumns.length === 0 ? (
|
||||||
<p className="text-muted-foreground py-2 text-xs">컬럼 정보가 없습니다</p>
|
<p className="text-muted-foreground py-2 text-xs">컬럼 정보가 없습니다</p>
|
||||||
) : (
|
) : (
|
||||||
<div className="max-h-32 space-y-1 overflow-y-auto rounded-md border p-2">
|
<div className="max-h-28 space-y-0.5 overflow-y-auto rounded-md border border-blue-200 bg-blue-50/30 p-2">
|
||||||
{sourceTableColumns.map((column) => (
|
{sourceTableColumns.map((column) => (
|
||||||
<div
|
<div
|
||||||
key={column.columnName}
|
key={`source-${column.columnName}`}
|
||||||
className={cn(
|
className={cn(
|
||||||
"hover:bg-muted/50 flex cursor-pointer items-center gap-2 rounded px-2 py-1",
|
"hover:bg-blue-100/50 flex cursor-pointer items-center gap-2 rounded px-2 py-1",
|
||||||
isSourceColumnSelected(column.columnName) && "bg-blue-50",
|
isSourceColumnSelected(column.columnName) && "bg-blue-100",
|
||||||
)}
|
)}
|
||||||
onClick={() => toggleSourceDisplayColumn(column)}
|
onClick={() => toggleSourceDisplayColumn(column)}
|
||||||
>
|
>
|
||||||
|
|
@ -571,39 +582,32 @@ export const UnifiedRepeaterConfigPanel: React.FC<UnifiedRepeaterConfigPanelProp
|
||||||
onCheckedChange={() => toggleSourceDisplayColumn(column)}
|
onCheckedChange={() => toggleSourceDisplayColumn(column)}
|
||||||
className="pointer-events-none h-3.5 w-3.5"
|
className="pointer-events-none h-3.5 w-3.5"
|
||||||
/>
|
/>
|
||||||
|
<Link2 className="text-blue-500 h-3 w-3 flex-shrink-0" />
|
||||||
<span className="truncate text-xs">{column.displayName}</span>
|
<span className="truncate text-xs">{column.displayName}</span>
|
||||||
|
<span className="text-[10px] text-blue-400 ml-auto">표시</span>
|
||||||
</div>
|
</div>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</div>
|
</>
|
||||||
<Separator />
|
)}
|
||||||
</>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{/* 추가 입력 컬럼 (현재 테이블에서 FK 제외) */}
|
|
||||||
<div className="space-y-2">
|
|
||||||
<Label className="text-xs font-medium">
|
|
||||||
{isModalMode ? "추가 입력 컬럼" : "입력 컬럼"}
|
|
||||||
</Label>
|
|
||||||
<p className="text-[10px] text-muted-foreground">
|
|
||||||
{isModalMode
|
|
||||||
? "엔티티 선택 후 추가로 입력받을 컬럼 (수량, 단가 등)"
|
|
||||||
: "직접 입력받을 컬럼을 선택하세요"
|
|
||||||
}
|
|
||||||
</p>
|
|
||||||
|
|
||||||
|
{/* 현재 테이블 컬럼 (입력용) */}
|
||||||
|
<div className="text-[10px] font-medium text-gray-600 mt-3 mb-1 flex items-center gap-1">
|
||||||
|
<Database className="h-3 w-3" />
|
||||||
|
현재 테이블 ({currentTableName || "미선택"}) - 입력용
|
||||||
|
</div>
|
||||||
{loadingColumns ? (
|
{loadingColumns ? (
|
||||||
<p className="text-muted-foreground py-2 text-xs">로딩 중...</p>
|
<p className="text-muted-foreground py-2 text-xs">로딩 중...</p>
|
||||||
) : inputableColumns.length === 0 ? (
|
) : inputableColumns.length === 0 ? (
|
||||||
<p className="text-muted-foreground py-2 text-xs">
|
<p className="text-muted-foreground py-2 text-xs">
|
||||||
{isModalMode ? "추가 입력 가능한 컬럼이 없습니다" : "컬럼 정보가 없습니다"}
|
컬럼 정보가 없습니다
|
||||||
</p>
|
</p>
|
||||||
) : (
|
) : (
|
||||||
<div className="max-h-48 space-y-1 overflow-y-auto rounded-md border p-2">
|
<div className="max-h-36 space-y-0.5 overflow-y-auto rounded-md border p-2">
|
||||||
{inputableColumns.map((column) => (
|
{inputableColumns.map((column) => (
|
||||||
<div
|
<div
|
||||||
key={column.columnName}
|
key={`input-${column.columnName}`}
|
||||||
className={cn(
|
className={cn(
|
||||||
"hover:bg-muted/50 flex cursor-pointer items-center gap-2 rounded px-2 py-1",
|
"hover:bg-muted/50 flex cursor-pointer items-center gap-2 rounded px-2 py-1",
|
||||||
isColumnAdded(column.columnName) && "bg-primary/10",
|
isColumnAdded(column.columnName) && "bg-primary/10",
|
||||||
|
|
@ -615,7 +619,7 @@ export const UnifiedRepeaterConfigPanel: React.FC<UnifiedRepeaterConfigPanelProp
|
||||||
onCheckedChange={() => toggleInputColumn(column)}
|
onCheckedChange={() => toggleInputColumn(column)}
|
||||||
className="pointer-events-none h-3.5 w-3.5"
|
className="pointer-events-none h-3.5 w-3.5"
|
||||||
/>
|
/>
|
||||||
<Database className="text-muted-foreground h-3 w-3" />
|
<Database className="text-muted-foreground h-3 w-3 flex-shrink-0" />
|
||||||
<span className="truncate text-xs">{column.displayName}</span>
|
<span className="truncate text-xs">{column.displayName}</span>
|
||||||
<span className="text-[10px] text-gray-400 ml-auto">{column.inputType}</span>
|
<span className="text-[10px] text-gray-400 ml-auto">{column.inputType}</span>
|
||||||
</div>
|
</div>
|
||||||
|
|
@ -624,48 +628,69 @@ export const UnifiedRepeaterConfigPanel: React.FC<UnifiedRepeaterConfigPanelProp
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* 선택된 컬럼 상세 설정 */}
|
{/* 선택된 컬럼 상세 설정 - 🆕 모든 컬럼 통합, 순서 변경 가능 */}
|
||||||
{config.columns.length > 0 && (
|
{config.columns.length > 0 && (
|
||||||
<>
|
<>
|
||||||
<Separator />
|
<Separator />
|
||||||
<div className="space-y-2">
|
<div className="space-y-2">
|
||||||
<Label className="text-xs font-medium">선택된 컬럼 ({config.columns.length}개)</Label>
|
<Label className="text-xs font-medium">
|
||||||
<div className="max-h-40 space-y-1 overflow-y-auto">
|
선택된 컬럼 ({config.columns.length}개)
|
||||||
{config.columns.map((col) => (
|
<span className="text-muted-foreground ml-2 font-normal">드래그로 순서 변경</span>
|
||||||
<div key={col.key} className="bg-muted/30 flex items-center gap-2 rounded-md border p-2">
|
</Label>
|
||||||
<GripVertical className="text-muted-foreground h-3 w-3 cursor-grab" />
|
<div className="max-h-48 space-y-1 overflow-y-auto">
|
||||||
<Database className="text-muted-foreground h-3 w-3 flex-shrink-0" />
|
{config.columns.map((col, index) => (
|
||||||
|
<div
|
||||||
|
key={col.key}
|
||||||
|
className={cn(
|
||||||
|
"flex items-center gap-2 rounded-md border p-2",
|
||||||
|
col.isSourceDisplay ? "bg-blue-50 border-blue-200" : "bg-muted/30",
|
||||||
|
)}
|
||||||
|
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 ? (
|
||||||
|
<Link2 className="text-blue-500 h-3 w-3 flex-shrink-0" title="소스 표시 (읽기 전용)" />
|
||||||
|
) : (
|
||||||
|
<Database className="text-muted-foreground h-3 w-3 flex-shrink-0" />
|
||||||
|
)}
|
||||||
<Input
|
<Input
|
||||||
value={col.title}
|
value={col.title}
|
||||||
onChange={(e) => updateColumnProp(col.key, "title", e.target.value)}
|
onChange={(e) => updateColumnProp(col.key, "title", e.target.value)}
|
||||||
placeholder="제목"
|
placeholder="제목"
|
||||||
className="h-6 flex-1 text-xs"
|
className="h-6 flex-1 text-xs"
|
||||||
/>
|
/>
|
||||||
<Select
|
{!col.isSourceDisplay && (
|
||||||
value={col.width || "auto"}
|
<Checkbox
|
||||||
onValueChange={(value) => updateColumnProp(col.key, "width", value as ColumnWidthOption)}
|
checked={col.editable ?? true}
|
||||||
>
|
onCheckedChange={(checked) => updateColumnProp(col.key, "editable", !!checked)}
|
||||||
<SelectTrigger className="h-6 w-20 text-xs">
|
title="편집 가능"
|
||||||
<SelectValue />
|
/>
|
||||||
</SelectTrigger>
|
)}
|
||||||
<SelectContent>
|
|
||||||
{COLUMN_WIDTH_OPTIONS.map((opt) => (
|
|
||||||
<SelectItem key={opt.value} value={opt.value}>
|
|
||||||
{opt.label}
|
|
||||||
</SelectItem>
|
|
||||||
))}
|
|
||||||
</SelectContent>
|
|
||||||
</Select>
|
|
||||||
<Checkbox
|
|
||||||
checked={col.editable ?? true}
|
|
||||||
onCheckedChange={(checked) => updateColumnProp(col.key, "editable", !!checked)}
|
|
||||||
title="편집 가능"
|
|
||||||
/>
|
|
||||||
<Button
|
<Button
|
||||||
type="button"
|
type="button"
|
||||||
variant="ghost"
|
variant="ghost"
|
||||||
size="sm"
|
size="sm"
|
||||||
onClick={() => toggleInputColumn({ columnName: col.key, displayName: col.title })}
|
onClick={() => {
|
||||||
|
if (col.isSourceDisplay) {
|
||||||
|
toggleSourceDisplayColumn({ columnName: col.key, displayName: col.title });
|
||||||
|
} else {
|
||||||
|
toggleInputColumn({ columnName: col.key, displayName: col.title });
|
||||||
|
}
|
||||||
|
}}
|
||||||
className="text-destructive h-6 w-6 p-0"
|
className="text-destructive h-6 w-6 p-0"
|
||||||
>
|
>
|
||||||
<Trash2 className="h-3 w-3" />
|
<Trash2 className="h-3 w-3" />
|
||||||
|
|
@ -744,95 +769,6 @@ export const UnifiedRepeaterConfigPanel: React.FC<UnifiedRepeaterConfigPanelProp
|
||||||
)}
|
)}
|
||||||
</TabsContent>
|
</TabsContent>
|
||||||
|
|
||||||
{/* 모달 설정 탭 */}
|
|
||||||
<TabsContent value="modal" className="mt-4 space-y-4">
|
|
||||||
{isModalMode ? (
|
|
||||||
<>
|
|
||||||
{/* 모달 크기 */}
|
|
||||||
<div className="space-y-2">
|
|
||||||
<Label className="text-xs font-medium">모달 크기</Label>
|
|
||||||
<Select
|
|
||||||
value={config.modal?.size || "lg"}
|
|
||||||
onValueChange={(value) => updateModal("size", value)}
|
|
||||||
>
|
|
||||||
<SelectTrigger className="h-8 text-xs">
|
|
||||||
<SelectValue />
|
|
||||||
</SelectTrigger>
|
|
||||||
<SelectContent>
|
|
||||||
{MODAL_SIZE_OPTIONS.map((opt) => (
|
|
||||||
<SelectItem key={opt.value} value={opt.value}>
|
|
||||||
{opt.label}
|
|
||||||
</SelectItem>
|
|
||||||
))}
|
|
||||||
</SelectContent>
|
|
||||||
</Select>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* 모달 제목 */}
|
|
||||||
<div className="space-y-2">
|
|
||||||
<Label className="text-xs font-medium">모달 제목</Label>
|
|
||||||
<Input
|
|
||||||
value={config.modal?.title || ""}
|
|
||||||
onChange={(e) => updateModal("title", e.target.value)}
|
|
||||||
placeholder="예: 품목 검색"
|
|
||||||
className="h-8 text-xs"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* 버튼 텍스트 */}
|
|
||||||
<div className="space-y-2">
|
|
||||||
<Label className="text-xs font-medium">추가 버튼 텍스트</Label>
|
|
||||||
<Input
|
|
||||||
value={config.modal?.buttonText || ""}
|
|
||||||
onChange={(e) => updateModal("buttonText", e.target.value)}
|
|
||||||
placeholder="예: 품목 검색"
|
|
||||||
className="h-8 text-xs"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<Separator />
|
|
||||||
|
|
||||||
{/* 검색 필드 */}
|
|
||||||
<div className="space-y-2">
|
|
||||||
<Label className="text-xs font-medium">검색 필드</Label>
|
|
||||||
<p className="text-[10px] text-muted-foreground">
|
|
||||||
검색어 입력 시 검색할 필드
|
|
||||||
</p>
|
|
||||||
|
|
||||||
<div className="max-h-24 space-y-1 overflow-y-auto rounded-md border p-2">
|
|
||||||
{sourceTableColumns.map((column) => {
|
|
||||||
const searchFields = config.modal?.searchFields || [];
|
|
||||||
const isChecked = searchFields.includes(column.columnName);
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div
|
|
||||||
key={column.columnName}
|
|
||||||
className={cn(
|
|
||||||
"hover:bg-muted/50 flex cursor-pointer items-center gap-2 rounded px-2 py-1",
|
|
||||||
isChecked && "bg-blue-50",
|
|
||||||
)}
|
|
||||||
onClick={() => {
|
|
||||||
if (isChecked) {
|
|
||||||
updateModal("searchFields", searchFields.filter(f => f !== column.columnName));
|
|
||||||
} else {
|
|
||||||
updateModal("searchFields", [...searchFields, column.columnName]);
|
|
||||||
}
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<Checkbox checked={isChecked} className="pointer-events-none h-3.5 w-3.5" />
|
|
||||||
<span className="truncate text-xs">{column.displayName}</span>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
})}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</>
|
|
||||||
) : (
|
|
||||||
<p className="text-muted-foreground py-4 text-center text-xs">
|
|
||||||
모달 또는 혼합 모드에서만 설정할 수 있습니다
|
|
||||||
</p>
|
|
||||||
)}
|
|
||||||
</TabsContent>
|
|
||||||
</Tabs>
|
</Tabs>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
|
|
||||||
|
|
@ -505,10 +505,8 @@ export function ItemSelectionModal({
|
||||||
) : (
|
) : (
|
||||||
filteredResults.map((item, index) => {
|
filteredResults.map((item, index) => {
|
||||||
const selected = isSelected(item);
|
const selected = isSelected(item);
|
||||||
const uniqueFieldValue = uniqueField ? item[uniqueField] : undefined;
|
// 🔧 index를 조합하여 항상 고유한 key 생성 (중복 데이터 대응)
|
||||||
const itemKey = (uniqueFieldValue !== undefined && uniqueFieldValue !== null)
|
const itemKey = `row-${index}`;
|
||||||
? uniqueFieldValue
|
|
||||||
: `item-${index}`;
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<tr
|
<tr
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,6 @@
|
||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
import React, { useState, useEffect, useRef, useMemo } from "react";
|
import React, { useState, useEffect, useRef, useMemo, useCallback } from "react";
|
||||||
import { Input } from "@/components/ui/input";
|
import { Input } from "@/components/ui/input";
|
||||||
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
|
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
|
||||||
import { Popover, PopoverContent, PopoverTrigger } from "@/components/ui/popover";
|
import { Popover, PopoverContent, PopoverTrigger } from "@/components/ui/popover";
|
||||||
|
|
@ -8,6 +8,7 @@ import { ChevronDown, Check, GripVertical } from "lucide-react";
|
||||||
import { Checkbox } from "@/components/ui/checkbox";
|
import { Checkbox } from "@/components/ui/checkbox";
|
||||||
import { RepeaterColumnConfig } from "./types";
|
import { RepeaterColumnConfig } from "./types";
|
||||||
import { cn } from "@/lib/utils";
|
import { cn } from "@/lib/utils";
|
||||||
|
import { apiClient } from "@/lib/api/client";
|
||||||
|
|
||||||
// @dnd-kit imports
|
// @dnd-kit imports
|
||||||
import {
|
import {
|
||||||
|
|
@ -70,6 +71,9 @@ interface RepeaterTableProps {
|
||||||
onSelectionChange: (selectedRows: Set<number>) => void; // 선택 변경 콜백
|
onSelectionChange: (selectedRows: Set<number>) => void; // 선택 변경 콜백
|
||||||
// 균등 분배 트리거
|
// 균등 분배 트리거
|
||||||
equalizeWidthsTrigger?: number; // 값이 변경되면 균등 분배 실행
|
equalizeWidthsTrigger?: number; // 값이 변경되면 균등 분배 실행
|
||||||
|
// 🆕 카테고리 라벨 변환용
|
||||||
|
categoryColumns?: string[]; // 카테고리 타입 컬럼명 목록
|
||||||
|
categoryLabelMap?: Record<string, string>; // 카테고리 코드 → 라벨 매핑
|
||||||
}
|
}
|
||||||
|
|
||||||
export function RepeaterTable({
|
export function RepeaterTable({
|
||||||
|
|
@ -83,10 +87,86 @@ export function RepeaterTable({
|
||||||
selectedRows,
|
selectedRows,
|
||||||
onSelectionChange,
|
onSelectionChange,
|
||||||
equalizeWidthsTrigger,
|
equalizeWidthsTrigger,
|
||||||
|
categoryColumns = [],
|
||||||
|
categoryLabelMap = {},
|
||||||
}: RepeaterTableProps) {
|
}: RepeaterTableProps) {
|
||||||
// 히든 컬럼을 제외한 표시 가능한 컬럼만 필터링
|
// 히든 컬럼을 제외한 표시 가능한 컬럼만 필터링
|
||||||
const visibleColumns = useMemo(() => columns.filter((col) => !col.hidden), [columns]);
|
const visibleColumns = useMemo(() => columns.filter((col) => !col.hidden), [columns]);
|
||||||
|
|
||||||
|
// 🆕 카테고리 옵션 상태 (categoryRef별로 로드된 옵션)
|
||||||
|
const [categoryOptionsMap, setCategoryOptionsMap] = useState<Record<string, { value: string; label: string }[]>>({});
|
||||||
|
|
||||||
|
// 🆕 카테고리 옵션 로드
|
||||||
|
// categoryRef 형식: "tableName.columnName" (예: "item_info.material")
|
||||||
|
useEffect(() => {
|
||||||
|
const loadCategoryOptions = async () => {
|
||||||
|
// category 타입이면서 categoryRef가 있는 컬럼들 찾기
|
||||||
|
const categoryColumns = visibleColumns.filter((col) => col.type === "category");
|
||||||
|
console.log(
|
||||||
|
"🔍 [RepeaterTable] 카테고리 컬럼 확인:",
|
||||||
|
categoryColumns.map((col) => ({ field: col.field, type: col.type, categoryRef: col.categoryRef })),
|
||||||
|
);
|
||||||
|
|
||||||
|
const categoryRefs = categoryColumns
|
||||||
|
.filter((col) => col.categoryRef)
|
||||||
|
.map((col) => col.categoryRef!)
|
||||||
|
.filter((ref, index, self) => self.indexOf(ref) === index); // 중복 제거
|
||||||
|
|
||||||
|
console.log("🔍 [RepeaterTable] categoryRefs:", categoryRefs);
|
||||||
|
|
||||||
|
if (categoryRefs.length === 0) {
|
||||||
|
console.log("⚠️ [RepeaterTable] categoryRef가 있는 컬럼이 없음");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
for (const categoryRef of categoryRefs) {
|
||||||
|
if (categoryOptionsMap[categoryRef]) {
|
||||||
|
console.log(`⏭️ [RepeaterTable] ${categoryRef} 이미 로드됨`);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
// categoryRef를 tableName.columnName 형식으로 파싱
|
||||||
|
const parts = categoryRef.split(".");
|
||||||
|
let tableName: string;
|
||||||
|
let columnName: string;
|
||||||
|
|
||||||
|
if (parts.length >= 2) {
|
||||||
|
// "tableName.columnName" 형식
|
||||||
|
tableName = parts[0];
|
||||||
|
columnName = parts.slice(1).join("."); // 컬럼명에 .이 포함될 수 있음
|
||||||
|
} else {
|
||||||
|
// 단일 값인 경우 컬럼명만 있다고 가정 (테이블명 불명)
|
||||||
|
console.warn(`카테고리 참조 형식 오류 (${categoryRef}): tableName.columnName 형식이어야 합니다`);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
console.log(`🌐 [RepeaterTable] API 호출: /table-categories/${tableName}/${columnName}/values`);
|
||||||
|
const response = await apiClient.get(`/table-categories/${tableName}/${columnName}/values`);
|
||||||
|
console.log("📥 [RepeaterTable] API 응답:", response.data);
|
||||||
|
|
||||||
|
if (response.data?.success && response.data.data) {
|
||||||
|
const options = response.data.data.map((item: any) => ({
|
||||||
|
value: item.valueCode || item.value_code,
|
||||||
|
label: item.valueLabel || item.value_label || item.displayValue || item.display_value || item.label,
|
||||||
|
}));
|
||||||
|
console.log(`✅ [RepeaterTable] ${categoryRef} 옵션 로드 성공:`, options);
|
||||||
|
setCategoryOptionsMap((prev) => ({
|
||||||
|
...prev,
|
||||||
|
[categoryRef]: options,
|
||||||
|
}));
|
||||||
|
} else {
|
||||||
|
console.warn(`⚠️ [RepeaterTable] ${categoryRef} API 응답이 success가 아니거나 data가 없음`);
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error(`카테고리 옵션 로드 실패 (${categoryRef}):`, error);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
loadCategoryOptions();
|
||||||
|
}, [visibleColumns]);
|
||||||
|
|
||||||
// 컨테이너 ref - 실제 너비 측정용
|
// 컨테이너 ref - 실제 너비 측정용
|
||||||
const containerRef = useRef<HTMLDivElement>(null);
|
const containerRef = useRef<HTMLDivElement>(null);
|
||||||
|
|
||||||
|
|
@ -148,9 +228,11 @@ export function RepeaterTable({
|
||||||
// 컬럼 너비 상태 관리
|
// 컬럼 너비 상태 관리
|
||||||
const [columnWidths, setColumnWidths] = useState<Record<string, number>>(() => {
|
const [columnWidths, setColumnWidths] = useState<Record<string, number>>(() => {
|
||||||
const widths: Record<string, number> = {};
|
const widths: Record<string, number> = {};
|
||||||
columns.filter((col) => !col.hidden).forEach((col) => {
|
columns
|
||||||
widths[col.field] = col.width ? parseInt(col.width) : 120;
|
.filter((col) => !col.hidden)
|
||||||
});
|
.forEach((col) => {
|
||||||
|
widths[col.field] = col.width ? parseInt(col.width) : 120;
|
||||||
|
});
|
||||||
return widths;
|
return widths;
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|
@ -245,8 +327,9 @@ export function RepeaterTable({
|
||||||
let headerText = column.label || field;
|
let headerText = column.label || field;
|
||||||
if (column.dynamicDataSource?.enabled && column.dynamicDataSource.options.length > 0) {
|
if (column.dynamicDataSource?.enabled && column.dynamicDataSource.options.length > 0) {
|
||||||
const activeOptionId = activeDataSources[field] || column.dynamicDataSource.defaultOptionId;
|
const activeOptionId = activeDataSources[field] || column.dynamicDataSource.defaultOptionId;
|
||||||
const activeOption = column.dynamicDataSource.options.find((opt) => opt.id === activeOptionId)
|
const activeOption =
|
||||||
|| column.dynamicDataSource.options[0];
|
column.dynamicDataSource.options.find((opt) => opt.id === activeOptionId) ||
|
||||||
|
column.dynamicDataSource.options[0];
|
||||||
if (activeOption?.headerLabel) {
|
if (activeOption?.headerLabel) {
|
||||||
headerText = activeOption.headerLabel;
|
headerText = activeOption.headerLabel;
|
||||||
}
|
}
|
||||||
|
|
@ -324,17 +407,22 @@ export function RepeaterTable({
|
||||||
return () => clearTimeout(timer);
|
return () => clearTimeout(timer);
|
||||||
}, [visibleColumns]);
|
}, [visibleColumns]);
|
||||||
|
|
||||||
// 트리거 감지: 1=균등분배, 2=자동맞춤
|
// 🆕 트리거 변경 시 자동으로 컬럼 너비 조정 (데이터 기반)
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (equalizeWidthsTrigger === undefined || equalizeWidthsTrigger === 0) return;
|
if (equalizeWidthsTrigger === undefined || equalizeWidthsTrigger === 0) return;
|
||||||
|
if (!containerRef.current || visibleColumns.length === 0) return;
|
||||||
|
|
||||||
// 홀수면 자동맞춤, 짝수면 균등분배 (토글 방식)
|
// 데이터가 있으면 데이터 기반 자동 맞춤, 없으면 균등 분배
|
||||||
if (equalizeWidthsTrigger % 2 === 1) {
|
const timer = setTimeout(() => {
|
||||||
applyAutoFitWidths();
|
if (data.length > 0) {
|
||||||
} else {
|
applyAutoFitWidths();
|
||||||
applyEqualizeWidths();
|
} else {
|
||||||
}
|
applyEqualizeWidths();
|
||||||
}, [equalizeWidthsTrigger]);
|
}
|
||||||
|
}, 50);
|
||||||
|
|
||||||
|
return () => clearTimeout(timer);
|
||||||
|
}, [equalizeWidthsTrigger, data.length]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!resizing) return;
|
if (!resizing) return;
|
||||||
|
|
@ -403,6 +491,31 @@ export function RepeaterTable({
|
||||||
const isEditing = editingCell?.rowIndex === rowIndex && editingCell?.field === column.field;
|
const isEditing = editingCell?.rowIndex === rowIndex && editingCell?.field === column.field;
|
||||||
const value = row[column.field];
|
const value = row[column.field];
|
||||||
|
|
||||||
|
// 🆕 카테고리 라벨 변환 함수
|
||||||
|
const getCategoryDisplayValue = (val: any): string => {
|
||||||
|
if (!val || typeof val !== "string") return val || "-";
|
||||||
|
|
||||||
|
// 카테고리 컬럼이 아니면 그대로 반환
|
||||||
|
const fieldName = column.field.replace(/^_display_/, ""); // _display_ 접두사 제거
|
||||||
|
if (!categoryColumns.includes(fieldName)) return val;
|
||||||
|
|
||||||
|
// 쉼표로 구분된 다중 값 처리
|
||||||
|
const codes = val
|
||||||
|
.split(",")
|
||||||
|
.map((c: string) => c.trim())
|
||||||
|
.filter(Boolean);
|
||||||
|
const labels = codes.map((code: string) => categoryLabelMap[code] || code);
|
||||||
|
return labels.join(", ");
|
||||||
|
};
|
||||||
|
|
||||||
|
// 🆕 40자 초과 시 ... 처리 및 툴팁 표시 함수
|
||||||
|
const truncateText = (text: string, maxLength: number = 40): { truncated: string; isTruncated: boolean } => {
|
||||||
|
if (!text || text.length <= maxLength) {
|
||||||
|
return { truncated: text || "-", isTruncated: false };
|
||||||
|
}
|
||||||
|
return { truncated: text.substring(0, maxLength) + "...", isTruncated: true };
|
||||||
|
};
|
||||||
|
|
||||||
// 계산 필드는 편집 불가
|
// 계산 필드는 편집 불가
|
||||||
if (column.calculated || !column.editable) {
|
if (column.calculated || !column.editable) {
|
||||||
// 숫자 포맷팅 함수: 정수/소수점 자동 구분
|
// 숫자 포맷팅 함수: 정수/소수점 자동 구분
|
||||||
|
|
@ -418,7 +531,17 @@ export function RepeaterTable({
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
return <div className="px-2 py-1">{column.type === "number" ? formatNumber(value) : value || "-"}</div>;
|
// 🆕 카테고리 타입이면 라벨로 변환하여 표시
|
||||||
|
const displayValue = column.type === "number" ? formatNumber(value) : getCategoryDisplayValue(value);
|
||||||
|
|
||||||
|
// 🆕 40자 초과 시 ... 처리 및 툴팁
|
||||||
|
const { truncated, isTruncated } = truncateText(String(displayValue));
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="truncate px-2 py-1" title={isTruncated ? String(displayValue) : undefined}>
|
||||||
|
{truncated}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
// 편집 가능한 필드
|
// 편집 가능한 필드
|
||||||
|
|
@ -484,7 +607,27 @@ export function RepeaterTable({
|
||||||
<SelectValue />
|
<SelectValue />
|
||||||
</SelectTrigger>
|
</SelectTrigger>
|
||||||
<SelectContent>
|
<SelectContent>
|
||||||
{column.selectOptions?.filter((option) => option.value && option.value !== "").map((option) => (
|
{column.selectOptions
|
||||||
|
?.filter((option) => option.value && option.value !== "")
|
||||||
|
.map((option) => (
|
||||||
|
<SelectItem key={option.value} value={option.value}>
|
||||||
|
{option.label}
|
||||||
|
</SelectItem>
|
||||||
|
))}
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
|
);
|
||||||
|
|
||||||
|
case "category": {
|
||||||
|
// 🆕 카테고리 타입: categoryRef로 로드된 옵션 사용
|
||||||
|
const options = column.categoryRef ? categoryOptionsMap[column.categoryRef] || [] : [];
|
||||||
|
return (
|
||||||
|
<Select value={value || ""} onValueChange={(newValue) => handleCellEdit(rowIndex, column.field, newValue)}>
|
||||||
|
<SelectTrigger className="h-8 w-full min-w-0 rounded-none border-gray-200 text-xs focus:border-blue-500 focus:ring-1 focus:ring-blue-500">
|
||||||
|
<SelectValue placeholder="선택..." />
|
||||||
|
</SelectTrigger>
|
||||||
|
<SelectContent>
|
||||||
|
{options.map((option) => (
|
||||||
<SelectItem key={option.value} value={option.value}>
|
<SelectItem key={option.value} value={option.value}>
|
||||||
{option.label}
|
{option.label}
|
||||||
</SelectItem>
|
</SelectItem>
|
||||||
|
|
@ -492,6 +635,7 @@ export function RepeaterTable({
|
||||||
</SelectContent>
|
</SelectContent>
|
||||||
</Select>
|
</Select>
|
||||||
);
|
);
|
||||||
|
}
|
||||||
|
|
||||||
default: // text
|
default: // text
|
||||||
return (
|
return (
|
||||||
|
|
@ -567,7 +711,7 @@ export function RepeaterTable({
|
||||||
>
|
>
|
||||||
{/* 컬럼명 - 선택된 옵션라벨 형식으로 표시 */}
|
{/* 컬럼명 - 선택된 옵션라벨 형식으로 표시 */}
|
||||||
<span>
|
<span>
|
||||||
{activeOption?.headerLabel || `${col.label} - ${activeOption?.label || ''}`}
|
{activeOption?.headerLabel || `${col.label} - ${activeOption?.label || ""}`}
|
||||||
</span>
|
</span>
|
||||||
<ChevronDown className="h-3 w-3 opacity-60" />
|
<ChevronDown className="h-3 w-3 opacity-60" />
|
||||||
</button>
|
</button>
|
||||||
|
|
@ -653,10 +797,12 @@ export function RepeaterTable({
|
||||||
{({ attributes, listeners, isDragging }) => (
|
{({ attributes, listeners, isDragging }) => (
|
||||||
<>
|
<>
|
||||||
{/* 드래그 핸들 - 좌측 고정 */}
|
{/* 드래그 핸들 - 좌측 고정 */}
|
||||||
<td className={cn(
|
<td
|
||||||
"sticky left-0 z-10 border-r border-b border-gray-200 px-1 py-1 text-center",
|
className={cn(
|
||||||
selectedRows.has(rowIndex) ? "bg-blue-50" : "bg-white"
|
"sticky left-0 z-10 border-r border-b border-gray-200 px-1 py-1 text-center",
|
||||||
)}>
|
selectedRows.has(rowIndex) ? "bg-blue-50" : "bg-white",
|
||||||
|
)}
|
||||||
|
>
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
className={cn(
|
className={cn(
|
||||||
|
|
@ -670,10 +816,12 @@ export function RepeaterTable({
|
||||||
</button>
|
</button>
|
||||||
</td>
|
</td>
|
||||||
{/* 체크박스 - 좌측 고정 */}
|
{/* 체크박스 - 좌측 고정 */}
|
||||||
<td className={cn(
|
<td
|
||||||
"sticky left-8 z-10 border-r border-b border-gray-200 px-3 py-1 text-center",
|
className={cn(
|
||||||
selectedRows.has(rowIndex) ? "bg-blue-50" : "bg-white"
|
"sticky left-8 z-10 border-r border-b border-gray-200 px-3 py-1 text-center",
|
||||||
)}>
|
selectedRows.has(rowIndex) ? "bg-blue-50" : "bg-white",
|
||||||
|
)}
|
||||||
|
>
|
||||||
<Checkbox
|
<Checkbox
|
||||||
checked={selectedRows.has(rowIndex)}
|
checked={selectedRows.has(rowIndex)}
|
||||||
onCheckedChange={(checked) => handleRowSelect(rowIndex, !!checked)}
|
onCheckedChange={(checked) => handleRowSelect(rowIndex, !!checked)}
|
||||||
|
|
|
||||||
|
|
@ -43,7 +43,7 @@ export interface ModalRepeaterTableProps {
|
||||||
export interface RepeaterColumnConfig {
|
export interface RepeaterColumnConfig {
|
||||||
field: string; // 필드명
|
field: string; // 필드명
|
||||||
label: string; // 컬럼 헤더 라벨
|
label: string; // 컬럼 헤더 라벨
|
||||||
type?: "text" | "number" | "date" | "select"; // 입력 타입
|
type?: "text" | "number" | "date" | "select" | "category"; // 입력 타입 (🆕 category 추가)
|
||||||
editable?: boolean; // 편집 가능 여부
|
editable?: boolean; // 편집 가능 여부
|
||||||
calculated?: boolean; // 계산 필드 여부
|
calculated?: boolean; // 계산 필드 여부
|
||||||
width?: string; // 컬럼 너비
|
width?: string; // 컬럼 너비
|
||||||
|
|
@ -52,6 +52,17 @@ export interface RepeaterColumnConfig {
|
||||||
defaultValue?: string | number | boolean; // 기본값
|
defaultValue?: string | number | boolean; // 기본값
|
||||||
selectOptions?: { value: string; label: string }[]; // select일 때 옵션
|
selectOptions?: { value: string; label: string }[]; // select일 때 옵션
|
||||||
|
|
||||||
|
// 🆕 카테고리 타입 설정
|
||||||
|
categoryRef?: string; // 카테고리 참조 ID (category 타입일 때 사용)
|
||||||
|
|
||||||
|
// 🆕 자동 입력 설정
|
||||||
|
autoFill?: {
|
||||||
|
type: "currentDate" | "sequence" | "fromMainForm" | "fixed";
|
||||||
|
sourceField?: string; // fromMainForm 타입일 때 메인 폼의 필드명
|
||||||
|
fixedValue?: string | number; // fixed 타입일 때 고정값
|
||||||
|
format?: string; // 날짜 포맷 등
|
||||||
|
};
|
||||||
|
|
||||||
// 컬럼 매핑 설정
|
// 컬럼 매핑 설정
|
||||||
mapping?: ColumnMapping; // 이 컬럼의 데이터를 어디서 가져올지 설정
|
mapping?: ColumnMapping; // 이 컬럼의 데이터를 어디서 가져올지 설정
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -25,6 +25,8 @@ export interface RepeaterColumnConfig {
|
||||||
editable?: boolean; // 편집 가능 여부 (inline 모드)
|
editable?: boolean; // 편집 가능 여부 (inline 모드)
|
||||||
isJoinColumn?: boolean;
|
isJoinColumn?: boolean;
|
||||||
sourceTable?: string;
|
sourceTable?: string;
|
||||||
|
// 🆕 소스 테이블 표시 컬럼 여부 (modal 모드에서 읽기 전용으로 표시)
|
||||||
|
isSourceDisplay?: boolean;
|
||||||
// 입력 타입 (테이블 타입 관리의 inputType을 따름)
|
// 입력 타입 (테이블 타입 관리의 inputType을 따름)
|
||||||
inputType?: string; // text, number, date, code, entity 등
|
inputType?: string; // text, number, date, code, entity 등
|
||||||
// 입력 타입별 상세 설정
|
// 입력 타입별 상세 설정
|
||||||
|
|
@ -34,6 +36,7 @@ export interface RepeaterColumnConfig {
|
||||||
referenceColumn?: string;
|
referenceColumn?: string;
|
||||||
displayColumn?: string;
|
displayColumn?: string;
|
||||||
format?: string; // date, number 타입용
|
format?: string; // date, number 타입용
|
||||||
|
categoryRef?: string; // 🆕 category 타입용 - 카테고리 참조 ID
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -79,10 +82,10 @@ export interface RepeaterDataSource {
|
||||||
// inline 모드: 현재 테이블 설정은 필요 없음 (컬럼만 선택)
|
// inline 모드: 현재 테이블 설정은 필요 없음 (컬럼만 선택)
|
||||||
|
|
||||||
// modal 모드: 소스 테이블 설정
|
// modal 모드: 소스 테이블 설정
|
||||||
sourceTable?: string; // 검색할 테이블 (엔티티 참조 테이블)
|
sourceTable?: string; // 검색할 테이블 (엔티티 참조 테이블)
|
||||||
foreignKey?: string; // 현재 테이블의 FK 컬럼 (part_objid 등)
|
foreignKey?: string; // 현재 테이블의 FK 컬럼 (part_objid 등)
|
||||||
referenceKey?: string; // 소스 테이블의 PK 컬럼 (id 등)
|
referenceKey?: string; // 소스 테이블의 PK 컬럼 (id 등)
|
||||||
displayColumn?: string; // 표시할 컬럼 (item_name 등)
|
displayColumn?: string; // 표시할 컬럼 (item_name 등)
|
||||||
|
|
||||||
// 추가 필터
|
// 추가 필터
|
||||||
filter?: {
|
filter?: {
|
||||||
|
|
@ -141,8 +144,8 @@ export interface UnifiedRepeaterConfig {
|
||||||
// 컴포넌트 Props
|
// 컴포넌트 Props
|
||||||
export interface UnifiedRepeaterProps {
|
export interface UnifiedRepeaterProps {
|
||||||
config: UnifiedRepeaterConfig;
|
config: UnifiedRepeaterConfig;
|
||||||
parentId?: string | number; // 부모 레코드 ID
|
parentId?: string | number; // 부모 레코드 ID
|
||||||
data?: any[]; // 초기 데이터 (없으면 API로 로드)
|
data?: any[]; // 초기 데이터 (없으면 API로 로드)
|
||||||
onDataChange?: (data: any[]) => void;
|
onDataChange?: (data: any[]) => void;
|
||||||
onRowClick?: (row: any) => void;
|
onRowClick?: (row: any) => void;
|
||||||
className?: string;
|
className?: string;
|
||||||
|
|
@ -194,4 +197,3 @@ export const COLUMN_WIDTH_OPTIONS = [
|
||||||
{ value: "250px", label: "250px" },
|
{ value: "250px", label: "250px" },
|
||||||
{ value: "300px", label: "300px" },
|
{ value: "300px", label: "300px" },
|
||||||
] as const;
|
] as const;
|
||||||
|
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue