feat(universal-form-modal): 조건부 테이블, 동적 Select 옵션, 서브 테이블 수정 로드 기능 구현

조건부 테이블: 체크박스/탭으로 조건 선택 시 다른 테이블 데이터 관리
동적 Select 옵션: 소스 테이블에서 드롭다운 옵션 동적 로드
행 선택 모드: Select 값 변경 시 같은 소스 행의 연관 컬럼 자동 채움
수정 모드 서브 테이블 로드: loadOnEdit 옵션으로 반복 섹션 데이터 자동 로드
SplitPanelLayout2 메인 테이블 병합: 서브 테이블 수정 시 메인 데이터 함께 조회
연결 필드 그룹 표시 형식: subDisplayColumn 추가로 메인/서브 컬럼 분리 설정
UX 개선: 컬럼 선택 UI를 검색 가능한 Combobox로 전환
saveMainAsFirst 로직 개선: items 없어도 메인 데이터 저장 가능
This commit is contained in:
SeongHyun Kim 2025-12-28 19:32:13 +09:00
parent 486e5ee29b
commit 47b23d1aa3
13 changed files with 3461 additions and 241 deletions

View File

@ -1973,15 +1973,21 @@ export async function multiTableSave(
for (const subTableConfig of subTables || []) {
const { tableName, linkColumn, items, options } = subTableConfig;
if (!tableName || !items || items.length === 0) {
logger.info(`서브 테이블 ${tableName} 스킵: 데이터 없음`);
// saveMainAsFirst가 활성화된 경우, items가 비어있어도 메인 데이터를 서브 테이블에 저장해야 함
const hasSaveMainAsFirst = options?.saveMainAsFirst &&
options?.mainFieldMappings &&
options.mainFieldMappings.length > 0;
if (!tableName || (!items?.length && !hasSaveMainAsFirst)) {
logger.info(`서브 테이블 ${tableName} 스킵: 데이터 없음 (saveMainAsFirst: ${hasSaveMainAsFirst})`);
continue;
}
logger.info(`서브 테이블 ${tableName} 저장 시작:`, {
itemsCount: items.length,
itemsCount: items?.length || 0,
linkColumn,
options,
hasSaveMainAsFirst,
});
// 기존 데이터 삭제 옵션
@ -1999,7 +2005,15 @@ export async function multiTableSave(
}
// 메인 데이터도 서브 테이블에 저장 (옵션)
if (options?.saveMainAsFirst && options?.mainFieldMappings && linkColumn?.subColumn) {
// mainFieldMappings가 비어 있으면 건너뜀 (필수 컬럼 누락 방지)
logger.info(`saveMainAsFirst 옵션 확인:`, {
saveMainAsFirst: options?.saveMainAsFirst,
mainFieldMappings: options?.mainFieldMappings,
mainFieldMappingsLength: options?.mainFieldMappings?.length,
linkColumn,
mainDataKeys: Object.keys(mainData),
});
if (options?.saveMainAsFirst && options?.mainFieldMappings && options.mainFieldMappings.length > 0 && linkColumn?.subColumn) {
const mainSubItem: Record<string, any> = {
[linkColumn.subColumn]: savedPkValue,
};

View File

@ -996,14 +996,6 @@ export const EditModal: React.FC<EditModalProps> = ({ className }) => {
screenId: modalState.screenId, // 화면 ID 추가
};
// 🔍 디버깅: enrichedFormData 확인
console.log("🔑 [EditModal] enrichedFormData 생성:", {
"screenData.screenInfo": screenData.screenInfo,
"screenData.screenInfo?.tableName": screenData.screenInfo?.tableName,
"enrichedFormData.tableName": enrichedFormData.tableName,
"enrichedFormData.id": enrichedFormData.id,
});
return (
<InteractiveScreenViewerDynamic
key={component.id}

View File

@ -675,7 +675,7 @@ export const SplitPanelLayout2Component: React.FC<SplitPanelLayout2ComponentProp
// 우측 패널 수정 버튼 클릭
const handleEditItem = useCallback(
(item: any) => {
async (item: any) => {
// 수정용 모달 화면 ID 결정 (editModalScreenId 우선, 없으면 addModalScreenId 사용)
const modalScreenId = config.rightPanel?.editModalScreenId || config.rightPanel?.addModalScreenId;
@ -684,13 +684,42 @@ export const SplitPanelLayout2Component: React.FC<SplitPanelLayout2ComponentProp
return;
}
// 메인 테이블 데이터 조회 (우측 패널이 서브 테이블인 경우)
let editData = { ...item };
// 연결 설정이 있고, 메인 테이블이 설정되어 있으면 메인 테이블 데이터도 조회
if (config.rightPanel?.mainTableForEdit) {
const { tableName, linkColumn } = config.rightPanel.mainTableForEdit;
const linkValue = item[linkColumn?.subColumn || ""];
if (tableName && linkValue) {
try {
const response = await apiClient.get(`/table-management/tables/${tableName}/data`, {
params: {
filters: JSON.stringify({ [linkColumn?.mainColumn || linkColumn?.subColumn || ""]: linkValue }),
page: 1,
pageSize: 1,
},
});
if (response.data?.success && response.data?.data?.items?.[0]) {
// 메인 테이블 데이터를 editData에 병합 (서브 테이블 데이터 우선)
editData = { ...response.data.data.items[0], ...item };
console.log("[SplitPanelLayout2] 메인 테이블 데이터 병합:", editData);
}
} catch (error) {
console.error("[SplitPanelLayout2] 메인 테이블 데이터 조회 실패:", error);
}
}
}
// EditModal 열기 이벤트 발생 (수정 모드)
const event = new CustomEvent("openEditModal", {
detail: {
screenId: modalScreenId,
title: "수정",
modalSize: "lg",
editData: item, // 기존 데이터 전달
editData: editData, // 병합된 데이터 전달
isCreateMode: false, // 수정 모드
onSave: () => {
if (selectedLeftItem) {
@ -700,9 +729,9 @@ export const SplitPanelLayout2Component: React.FC<SplitPanelLayout2ComponentProp
},
});
window.dispatchEvent(event);
console.log("[SplitPanelLayout2] 우측 수정 모달 열기:", item);
console.log("[SplitPanelLayout2] 우측 수정 모달 열기:", editData);
},
[config.rightPanel?.editModalScreenId, config.rightPanel?.addModalScreenId, selectedLeftItem, loadRightData],
[config.rightPanel?.editModalScreenId, config.rightPanel?.addModalScreenId, config.rightPanel?.mainTableForEdit, selectedLeftItem, loadRightData],
);
// 좌측 패널 수정 버튼 클릭
@ -791,11 +820,11 @@ export const SplitPanelLayout2Component: React.FC<SplitPanelLayout2ComponentProp
toast.success(`${itemsToDelete.length}개 항목이 삭제되었습니다.`);
setSelectedRightItems(new Set<string | number>());
} else if (itemToDelete) {
// 단일 삭제 - 해당 항목 데이터를 body로 전달
// 단일 삭제 - 해당 항목 데이터를 배열로 감싸서 body로 전달 (백엔드가 배열을 기대함)
console.log(`[SplitPanelLayout2] ${deleteTargetPanel === "left" ? "좌측" : "우측"} 단일 삭제:`, itemToDelete);
await apiClient.delete(`/table-management/tables/${tableName}/delete`, {
data: itemToDelete,
data: [itemToDelete],
});
toast.success("항목이 삭제되었습니다.");
}

View File

@ -1343,6 +1343,65 @@ export const SplitPanelLayout2ConfigPanel: React.FC<SplitPanelLayout2ConfigPanel
</p>
</div>
)}
{/* 수정 시 메인 테이블 조회 설정 */}
{config.rightPanel?.showEditButton && (
<div className="mt-3 border-t pt-3">
<div className="flex items-center justify-between mb-2">
<Label className="text-xs font-medium"> </Label>
<Switch
checked={!!config.rightPanel?.mainTableForEdit?.tableName}
onCheckedChange={(checked) => {
if (checked) {
updateConfig("rightPanel.mainTableForEdit", {
tableName: "",
linkColumn: { mainColumn: "", subColumn: "" },
});
} else {
updateConfig("rightPanel.mainTableForEdit", undefined);
}
}}
/>
</div>
<p className="text-muted-foreground mb-2 text-[10px]">
,
</p>
{config.rightPanel?.mainTableForEdit && (
<div className="space-y-2 rounded-lg border p-2 bg-muted/30">
<div>
<Label className="text-[10px]"> </Label>
<Input
value={config.rightPanel.mainTableForEdit.tableName || ""}
onChange={(e) => updateConfig("rightPanel.mainTableForEdit.tableName", e.target.value)}
placeholder="예: user_info"
className="h-7 text-xs mt-1"
/>
</div>
<div className="grid grid-cols-2 gap-2">
<div>
<Label className="text-[10px]"> </Label>
<Input
value={config.rightPanel.mainTableForEdit.linkColumn?.mainColumn || ""}
onChange={(e) => updateConfig("rightPanel.mainTableForEdit.linkColumn.mainColumn", e.target.value)}
placeholder="예: user_id"
className="h-7 text-xs mt-1"
/>
</div>
<div>
<Label className="text-[10px]"> </Label>
<Input
value={config.rightPanel.mainTableForEdit.linkColumn?.subColumn || ""}
onChange={(e) => updateConfig("rightPanel.mainTableForEdit.linkColumn.subColumn", e.target.value)}
placeholder="예: user_id"
className="h-7 text-xs mt-1"
/>
</div>
</div>
</div>
)}
</div>
)}
</div>
</div>

View File

@ -211,6 +211,20 @@ export interface RightPanelConfig {
* - 결과: 부서별 ,
*/
joinTables?: JoinTableConfig[];
/**
*
* (: user_dept), (: user_info)
* .
*/
mainTableForEdit?: {
tableName: string; // 메인 테이블명 (예: user_info)
linkColumn: {
mainColumn: string; // 메인 테이블의 연결 컬럼 (예: user_id)
subColumn: string; // 서브 테이블의 연결 컬럼 (예: user_id)
};
};
// 탭 설정
tabConfig?: TabConfig;
}

View File

@ -212,15 +212,23 @@ export function UniversalFormModalComponent({
// 마지막으로 초기화된 데이터의 ID를 추적 (수정 모달에서 다른 항목 선택 시 재초기화 필요)
const lastInitializedId = useRef<string | undefined>(undefined);
// 초기화 - 최초 마운트 시 또는 initialData의 ID가 변경되었을 때 실행
// 초기화 - 최초 마운트 시 또는 initialData가 변경되었을 때 실행
useEffect(() => {
// initialData에서 ID 값 추출 (id, ID, objid 등)
const currentId = initialData?.id || initialData?.ID || initialData?.objid;
const currentIdString = currentId !== undefined ? String(currentId) : undefined;
// 생성 모드에서 부모로부터 전달받은 데이터 해시 (ID가 없을 때만)
const createModeDataHash = !currentIdString && initialData && Object.keys(initialData).length > 0
? JSON.stringify(initialData)
: undefined;
// 이미 초기화되었고, ID가 동일하면 스킵
// 이미 초기화되었고, ID가 동일하고, 생성 모드 데이터도 동일하면 스킵
if (hasInitialized.current && lastInitializedId.current === currentIdString) {
return;
// 생성 모드에서 데이터가 새로 전달된 경우는 재초기화 필요
if (!createModeDataHash || capturedInitialData.current) {
return;
}
}
// 🆕 수정 모드: initialData에 데이터가 있으면서 ID가 변경된 경우 재초기화
@ -245,7 +253,7 @@ export function UniversalFormModalComponent({
hasInitialized.current = true;
initializeForm();
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [initialData?.id, initialData?.ID, initialData?.objid]); // ID 값 변경 시 재초기화
}, [initialData]); // initialData 전체 변경 시 재초기화
// config 변경 시에만 재초기화 (initialData 변경은 무시) - 채번규칙 제외
useEffect(() => {
@ -478,6 +486,82 @@ export function UniversalFormModalComponent({
setActivatedOptionalFieldGroups(newActivatedGroups);
setOriginalData(effectiveInitialData || {});
// 수정 모드에서 서브 테이블 데이터 로드 (겸직 등)
const multiTable = config.saveConfig?.customApiSave?.multiTable;
if (multiTable && effectiveInitialData) {
const pkColumn = multiTable.mainTable?.primaryKeyColumn;
const pkValue = effectiveInitialData[pkColumn];
// PK 값이 있으면 수정 모드로 판단
if (pkValue) {
console.log("[initializeForm] 수정 모드 - 서브 테이블 데이터 로드 시작");
for (const subTableConfig of multiTable.subTables || []) {
// loadOnEdit 옵션이 활성화된 경우에만 로드
if (!subTableConfig.enabled || !subTableConfig.options?.loadOnEdit) {
continue;
}
const { tableName, linkColumn, repeatSectionId, fieldMappings, options } = subTableConfig;
if (!tableName || !linkColumn?.subColumn || !repeatSectionId) {
continue;
}
try {
// 서브 테이블에서 데이터 조회
const filters: Record<string, any> = {
[linkColumn.subColumn]: pkValue,
};
// 서브 항목만 로드 (메인 항목 제외)
if (options?.loadOnlySubItems && options?.mainMarkerColumn) {
filters[options.mainMarkerColumn] = options.subMarkerValue ?? false;
}
console.log(`[initializeForm] 서브 테이블 ${tableName} 조회:`, filters);
const response = await apiClient.get(`/table-management/tables/${tableName}/data`, {
params: {
filters: JSON.stringify(filters),
page: 1,
pageSize: 100,
},
});
if (response.data?.success && response.data?.data?.items) {
const subItems = response.data.data.items;
console.log(`[initializeForm] 서브 테이블 ${tableName} 데이터 ${subItems.length}건 로드됨`);
// 역매핑: 서브 테이블 데이터 → 반복 섹션 데이터
const repeatItems: RepeatSectionItem[] = subItems.map((item: any, index: number) => {
const repeatItem: RepeatSectionItem = {
_id: generateUniqueId("repeat"),
_index: index,
_originalData: item, // 원본 데이터 보관 (수정 시 필요)
};
// 필드 매핑 역변환 (targetColumn → formField)
for (const mapping of fieldMappings || []) {
if (mapping.formField && mapping.targetColumn) {
repeatItem[mapping.formField] = item[mapping.targetColumn];
}
}
return repeatItem;
});
// 반복 섹션에 데이터 설정
newRepeatSections[repeatSectionId] = repeatItems;
setRepeatSections({ ...newRepeatSections });
console.log(`[initializeForm] 반복 섹션 ${repeatSectionId}${repeatItems.length}건 설정`);
}
} catch (error) {
console.error(`[initializeForm] 서브 테이블 ${tableName} 로드 실패:`, error);
}
}
}
}
// 채번규칙 자동 생성
console.log("[initializeForm] generateNumberingValues 호출");
await generateNumberingValues(newFormData);
@ -1142,6 +1226,20 @@ export function UniversalFormModalComponent({
}
});
});
// 1-0. receiveFromParent 필드 값도 mainData에 추가 (서브 테이블 저장용)
// 이 필드들은 메인 테이블에는 저장되지 않지만, 서브 테이블 저장 시 필요할 수 있음
config.sections.forEach((section) => {
if (section.repeatable || section.type === "table") return;
(section.fields || []).forEach((field) => {
if (field.receiveFromParent && !mainData[field.columnName]) {
const value = formData[field.columnName];
if (value !== undefined && value !== null && value !== "") {
mainData[field.columnName] = value;
}
}
});
});
// 1-1. 채번규칙 처리 (저장 시점에 실제 순번 할당)
for (const section of config.sections) {
@ -1185,36 +1283,42 @@ export function UniversalFormModalComponent({
}> = [];
for (const subTableConfig of multiTable.subTables || []) {
if (!subTableConfig.enabled || !subTableConfig.tableName || !subTableConfig.repeatSectionId) {
// 서브 테이블이 활성화되어 있고 테이블명이 있어야 함
// repeatSectionId는 선택사항 (saveMainAsFirst만 사용하는 경우 없을 수 있음)
if (!subTableConfig.enabled || !subTableConfig.tableName) {
continue;
}
const subItems: Record<string, any>[] = [];
const repeatData = repeatSections[subTableConfig.repeatSectionId] || [];
// 반복 섹션이 있는 경우에만 반복 데이터 처리
if (subTableConfig.repeatSectionId) {
const repeatData = repeatSections[subTableConfig.repeatSectionId] || [];
// 반복 섹션 데이터를 필드 매핑에 따라 변환
for (const item of repeatData) {
const mappedItem: Record<string, any> = {};
// 반복 섹션 데이터를 필드 매핑에 따라 변환
for (const item of repeatData) {
const mappedItem: Record<string, any> = {};
// 연결 컬럼 값 설정
if (subTableConfig.linkColumn?.mainField && subTableConfig.linkColumn?.subColumn) {
mappedItem[subTableConfig.linkColumn.subColumn] = mainData[subTableConfig.linkColumn.mainField];
}
// 필드 매핑에 따라 데이터 변환
for (const mapping of subTableConfig.fieldMappings || []) {
if (mapping.formField && mapping.targetColumn) {
mappedItem[mapping.targetColumn] = item[mapping.formField];
// 연결 컬럼 값 설정
if (subTableConfig.linkColumn?.mainField && subTableConfig.linkColumn?.subColumn) {
mappedItem[subTableConfig.linkColumn.subColumn] = mainData[subTableConfig.linkColumn.mainField];
}
}
// 메인/서브 구분 컬럼 설정 (서브 데이터는 서브 마커 값)
if (subTableConfig.options?.mainMarkerColumn) {
mappedItem[subTableConfig.options.mainMarkerColumn] = subTableConfig.options?.subMarkerValue ?? false;
}
// 필드 매핑에 따라 데이터 변환
for (const mapping of subTableConfig.fieldMappings || []) {
if (mapping.formField && mapping.targetColumn) {
mappedItem[mapping.targetColumn] = item[mapping.formField];
}
}
if (Object.keys(mappedItem).length > 0) {
subItems.push(mappedItem);
// 메인/서브 구분 컬럼 설정 (서브 데이터는 서브 마커 값)
if (subTableConfig.options?.mainMarkerColumn) {
mappedItem[subTableConfig.options.mainMarkerColumn] = subTableConfig.options?.subMarkerValue ?? false;
}
if (Object.keys(mappedItem).length > 0) {
subItems.push(mappedItem);
}
}
}
@ -1226,8 +1330,9 @@ export function UniversalFormModalComponent({
// fieldMappings에 정의된 targetColumn만 매핑 (서브 테이블에 존재하는 컬럼만)
for (const mapping of subTableConfig.fieldMappings || []) {
if (mapping.targetColumn) {
// 메인 데이터에서 동일한 컬럼명이 있으면 매핑
if (mainData[mapping.targetColumn] !== undefined && mainData[mapping.targetColumn] !== null && mainData[mapping.targetColumn] !== "") {
// formData에서 동일한 컬럼명이 있으면 매핑 (receiveFromParent 필드 포함)
const formValue = formData[mapping.targetColumn];
if (formValue !== undefined && formValue !== null && formValue !== "") {
mainFieldMappings.push({
formField: mapping.targetColumn,
targetColumn: mapping.targetColumn,
@ -1238,11 +1343,14 @@ export function UniversalFormModalComponent({
config.sections.forEach((section) => {
if (section.repeatable || section.type === "table") return;
const matchingField = (section.fields || []).find((f) => f.columnName === mapping.targetColumn);
if (matchingField && mainData[matchingField.columnName] !== undefined && mainData[matchingField.columnName] !== null && mainData[matchingField.columnName] !== "") {
mainFieldMappings!.push({
formField: matchingField.columnName,
targetColumn: mapping.targetColumn,
});
if (matchingField) {
const fieldValue = formData[matchingField.columnName];
if (fieldValue !== undefined && fieldValue !== null && fieldValue !== "") {
mainFieldMappings!.push({
formField: matchingField.columnName,
targetColumn: mapping.targetColumn,
});
}
}
});
}
@ -1255,15 +1363,18 @@ export function UniversalFormModalComponent({
);
}
subTablesData.push({
tableName: subTableConfig.tableName,
linkColumn: subTableConfig.linkColumn,
items: subItems,
options: {
...subTableConfig.options,
mainFieldMappings, // 메인 데이터 매핑 추가
},
});
// 서브 테이블 데이터 추가 (반복 데이터가 없어도 saveMainAsFirst가 있으면 추가)
if (subItems.length > 0 || subTableConfig.options?.saveMainAsFirst) {
subTablesData.push({
tableName: subTableConfig.tableName,
linkColumn: subTableConfig.linkColumn,
items: subItems,
options: {
...subTableConfig.options,
mainFieldMappings, // 메인 데이터 매핑 추가
},
});
}
}
// 3. 범용 다중 테이블 저장 API 호출
@ -1489,13 +1600,20 @@ export function UniversalFormModalComponent({
// 표시 텍스트 생성 함수
const getDisplayText = (row: Record<string, unknown>): string => {
const displayVal = row[lfg.displayColumn || ""] || "";
const valueVal = row[valueColumn] || "";
// 메인 표시 컬럼 (displayColumn)
const mainDisplayVal = row[lfg.displayColumn || ""] || "";
// 서브 표시 컬럼 (subDisplayColumn이 있으면 사용, 없으면 valueColumn 사용)
const subDisplayVal = lfg.subDisplayColumn
? (row[lfg.subDisplayColumn] || "")
: (row[valueColumn] || "");
switch (lfg.displayFormat) {
case "code_name":
return `${valueVal} - ${displayVal}`;
// 서브 - 메인 형식
return `${subDisplayVal} - ${mainDisplayVal}`;
case "name_code":
return `${displayVal} (${valueVal})`;
// 메인 (서브) 형식
return `${mainDisplayVal} (${subDisplayVal})`;
case "custom":
// 커스텀 형식: {컬럼명}을 실제 값으로 치환
if (lfg.customDisplayFormat) {
@ -1511,10 +1629,10 @@ export function UniversalFormModalComponent({
}
return result;
}
return String(displayVal);
return String(mainDisplayVal);
case "name_only":
default:
return String(displayVal);
return String(mainDisplayVal);
}
};

View File

@ -11,6 +11,8 @@ import {
TablePreFilter,
TableModalFilter,
TableCalculationRule,
ConditionalTableConfig,
ConditionalTableOption,
} from "./types";
// 기본 설정값
@ -133,6 +135,33 @@ export const defaultTableSectionConfig: TableSectionConfig = {
multiSelect: true,
maxHeight: "400px",
},
conditionalTable: undefined,
};
// 기본 조건부 테이블 설정
export const defaultConditionalTableConfig: ConditionalTableConfig = {
enabled: false,
triggerType: "checkbox",
conditionColumn: "",
options: [],
optionSource: {
enabled: false,
tableName: "",
valueColumn: "",
labelColumn: "",
filterCondition: "",
},
sourceFilter: {
enabled: false,
filterColumn: "",
},
};
// 기본 조건부 테이블 옵션 설정
export const defaultConditionalTableOptionConfig: ConditionalTableOption = {
id: "",
value: "",
label: "",
};
// 기본 테이블 컬럼 설정
@ -300,3 +329,8 @@ export const generateColumnModeId = (): string => {
export const generateFilterId = (): string => {
return generateUniqueId("filter");
};
// 유틸리티: 조건부 테이블 옵션 ID 생성
export const generateConditionalOptionId = (): string => {
return generateUniqueId("cond");
};

View File

@ -98,6 +98,9 @@ export function FieldDetailSettingsModal({
// Combobox 열림 상태
const [sourceTableOpen, setSourceTableOpen] = useState(false);
const [targetColumnOpenMap, setTargetColumnOpenMap] = useState<Record<number, boolean>>({});
const [displayColumnOpen, setDisplayColumnOpen] = useState(false);
const [subDisplayColumnOpen, setSubDisplayColumnOpen] = useState(false); // 서브 표시 컬럼 Popover 상태
const [sourceColumnOpenMap, setSourceColumnOpenMap] = useState<Record<number, boolean>>({});
// open이 변경될 때마다 필드 데이터 동기화
useEffect(() => {
@ -105,6 +108,16 @@ export function FieldDetailSettingsModal({
setLocalField(field);
}
}, [open, field]);
// 모달이 열릴 때 소스 테이블 컬럼 자동 로드
useEffect(() => {
if (open && field.linkedFieldGroup?.sourceTable) {
// tableColumns에 해당 테이블 컬럼이 없으면 로드
if (!tableColumns[field.linkedFieldGroup.sourceTable] || tableColumns[field.linkedFieldGroup.sourceTable].length === 0) {
onLoadTableColumns(field.linkedFieldGroup.sourceTable);
}
}
}, [open, field.linkedFieldGroup?.sourceTable, tableColumns, onLoadTableColumns]);
// 모든 카테고리 컬럼 목록 로드 (모달 열릴 때)
useEffect(() => {
@ -735,32 +748,108 @@ export function FieldDetailSettingsModal({
<HelpText> (: customer_mng)</HelpText>
</div>
{/* 표시 형식 선택 */}
<div>
<Label className="text-[10px]"> </Label>
<Label className="text-[10px]"> </Label>
<Select
value={localField.linkedFieldGroup?.displayFormat || "name_only"}
onValueChange={(value) =>
updateField({
linkedFieldGroup: {
...localField.linkedFieldGroup,
displayFormat: value as "name_only" | "code_name" | "name_code",
// name_only 선택 시 서브 컬럼 초기화
...(value === "name_only" ? { subDisplayColumn: undefined } : {}),
},
})
}
>
<SelectTrigger className="h-7 text-xs mt-1">
<SelectValue />
</SelectTrigger>
<SelectContent>
{LINKED_FIELD_DISPLAY_FORMAT_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">
{opt.value === "name_only" && "메인 컬럼만 표시"}
{opt.value === "code_name" && "서브 - 메인 형식"}
{opt.value === "name_code" && "메인 (서브) 형식"}
</span>
</div>
</SelectItem>
))}
</SelectContent>
</Select>
<HelpText> </HelpText>
</div>
{/* 메인 표시 컬럼 */}
<div>
<Label className="text-[10px]"> </Label>
{sourceTableColumns.length > 0 ? (
<Select
value={localField.linkedFieldGroup?.displayColumn || ""}
onValueChange={(value) =>
updateField({
linkedFieldGroup: {
...localField.linkedFieldGroup,
displayColumn: value,
},
})
}
>
<SelectTrigger className="h-7 text-xs mt-1">
<SelectValue placeholder="컬럼 선택" />
</SelectTrigger>
<SelectContent>
{sourceTableColumns.map((col) => (
<SelectItem key={col.name} value={col.name}>
{col.name}
{col.label !== col.name && ` (${col.label})`}
</SelectItem>
))}
</SelectContent>
</Select>
<Popover open={displayColumnOpen} onOpenChange={setDisplayColumnOpen}>
<PopoverTrigger asChild>
<Button
variant="outline"
role="combobox"
aria-expanded={displayColumnOpen}
className="h-7 w-full justify-between text-xs mt-1 font-normal"
>
{localField.linkedFieldGroup?.displayColumn
? (() => {
const selectedCol = sourceTableColumns.find(
(c) => c.name === localField.linkedFieldGroup?.displayColumn
);
return selectedCol
? `${selectedCol.name} (${selectedCol.label})`
: localField.linkedFieldGroup?.displayColumn;
})()
: "컬럼 선택..."}
<ChevronsUpDown className="ml-2 h-3 w-3 shrink-0 opacity-50" />
</Button>
</PopoverTrigger>
<PopoverContent className="w-[280px] p-0" align="start">
<Command>
<CommandInput placeholder="컬럼 검색..." className="h-8 text-xs" />
<CommandList>
<CommandEmpty className="py-2 text-xs text-center">
.
</CommandEmpty>
<CommandGroup>
{sourceTableColumns.map((col) => (
<CommandItem
key={col.name}
value={`${col.name} ${col.label}`}
onSelect={() => {
updateField({
linkedFieldGroup: {
...localField.linkedFieldGroup,
displayColumn: col.name,
},
});
setDisplayColumnOpen(false);
}}
className="text-xs"
>
<Check
className={cn(
"mr-2 h-3 w-3",
localField.linkedFieldGroup?.displayColumn === col.name
? "opacity-100"
: "opacity-0"
)}
/>
<span className="font-medium">{col.name}</span>
<span className="ml-1 text-muted-foreground">({col.label})</span>
</CommandItem>
))}
</CommandGroup>
</CommandList>
</Command>
</PopoverContent>
</Popover>
) : (
<Input
value={localField.linkedFieldGroup?.displayColumn || ""}
@ -772,39 +861,133 @@ export function FieldDetailSettingsModal({
},
})
}
placeholder="customer_name"
placeholder="item_name"
className="h-7 text-xs mt-1"
/>
)}
<HelpText> (: customer_name)</HelpText>
<HelpText> (: item_name)</HelpText>
</div>
<div>
<Label className="text-[10px]"> </Label>
<Select
value={localField.linkedFieldGroup?.displayFormat || "name_only"}
onValueChange={(value) =>
updateField({
linkedFieldGroup: {
...localField.linkedFieldGroup,
displayFormat: value as "name_only" | "code_name" | "name_code",
},
})
}
>
<SelectTrigger className="h-7 text-xs mt-1">
<SelectValue />
</SelectTrigger>
<SelectContent>
{LINKED_FIELD_DISPLAY_FORMAT_OPTIONS.map((opt) => (
<SelectItem key={opt.value} value={opt.value}>
{opt.label}
</SelectItem>
))}
</SelectContent>
</Select>
<HelpText> </HelpText>
</div>
{/* 서브 표시 컬럼 - 표시 형식이 name_only가 아닌 경우에만 표시 */}
{localField.linkedFieldGroup?.displayFormat &&
localField.linkedFieldGroup.displayFormat !== "name_only" && (
<div>
<Label className="text-[10px]"> </Label>
{sourceTableColumns.length > 0 ? (
<Popover open={subDisplayColumnOpen} onOpenChange={setSubDisplayColumnOpen}>
<PopoverTrigger asChild>
<Button
variant="outline"
role="combobox"
aria-expanded={subDisplayColumnOpen}
className="h-7 w-full justify-between text-xs mt-1 font-normal"
>
{localField.linkedFieldGroup?.subDisplayColumn
? (() => {
const selectedCol = sourceTableColumns.find(
(c) => c.name === localField.linkedFieldGroup?.subDisplayColumn
);
return selectedCol
? `${selectedCol.name} (${selectedCol.label})`
: localField.linkedFieldGroup?.subDisplayColumn;
})()
: "컬럼 선택..."}
<ChevronsUpDown className="ml-2 h-3 w-3 shrink-0 opacity-50" />
</Button>
</PopoverTrigger>
<PopoverContent className="w-[280px] p-0" align="start">
<Command>
<CommandInput placeholder="컬럼 검색..." className="h-8 text-xs" />
<CommandList>
<CommandEmpty className="py-2 text-xs text-center">
.
</CommandEmpty>
<CommandGroup>
{sourceTableColumns.map((col) => (
<CommandItem
key={col.name}
value={`${col.name} ${col.label}`}
onSelect={() => {
updateField({
linkedFieldGroup: {
...localField.linkedFieldGroup,
subDisplayColumn: col.name,
},
});
setSubDisplayColumnOpen(false);
}}
className="text-xs"
>
<Check
className={cn(
"mr-2 h-3 w-3",
localField.linkedFieldGroup?.subDisplayColumn === col.name
? "opacity-100"
: "opacity-0"
)}
/>
<span className="font-medium">{col.name}</span>
<span className="ml-1 text-muted-foreground">({col.label})</span>
</CommandItem>
))}
</CommandGroup>
</CommandList>
</Command>
</PopoverContent>
</Popover>
) : (
<Input
value={localField.linkedFieldGroup?.subDisplayColumn || ""}
onChange={(e) =>
updateField({
linkedFieldGroup: {
...localField.linkedFieldGroup,
subDisplayColumn: e.target.value,
},
})
}
placeholder="item_code"
className="h-7 text-xs mt-1"
/>
)}
<HelpText>
{localField.linkedFieldGroup?.displayFormat === "code_name"
? "메인 앞에 표시될 서브 컬럼 (예: 서브 - 메인)"
: "메인 뒤에 표시될 서브 컬럼 (예: 메인 (서브))"}
</HelpText>
</div>
)}
{/* 미리보기 - 메인 컬럼이 선택된 경우에만 표시 */}
{localField.linkedFieldGroup?.displayColumn && (
<div className="p-3 bg-muted/50 rounded-lg border border-dashed">
<p className="text-[10px] text-muted-foreground mb-2">:</p>
{(() => {
const mainCol = localField.linkedFieldGroup?.displayColumn || "";
const subCol = localField.linkedFieldGroup?.subDisplayColumn || "";
const mainLabel = sourceTableColumns.find(c => c.name === mainCol)?.label || mainCol;
const subLabel = sourceTableColumns.find(c => c.name === subCol)?.label || subCol;
const format = localField.linkedFieldGroup?.displayFormat || "name_only";
let preview = "";
if (format === "name_only") {
preview = mainLabel;
} else if (format === "code_name" && subCol) {
preview = `${subLabel} - ${mainLabel}`;
} else if (format === "name_code" && subCol) {
preview = `${mainLabel} (${subLabel})`;
} else if (format !== "name_only" && !subCol) {
preview = `${mainLabel} (서브 컬럼을 선택하세요)`;
} else {
preview = mainLabel;
}
return (
<p className="text-sm font-medium">{preview}</p>
);
})()}
</div>
)}
<Separator />
@ -846,24 +1029,67 @@ export function FieldDetailSettingsModal({
<div>
<Label className="text-[9px]"> ( )</Label>
{sourceTableColumns.length > 0 ? (
<Select
value={mapping.sourceColumn || ""}
onValueChange={(value) =>
updateLinkedFieldMapping(index, { sourceColumn: value })
<Popover
open={sourceColumnOpenMap[index] || false}
onOpenChange={(open) =>
setSourceColumnOpenMap((prev) => ({ ...prev, [index]: open }))
}
>
<SelectTrigger className="h-6 text-[9px] mt-0.5">
<SelectValue placeholder="컬럼 선택" />
</SelectTrigger>
<SelectContent>
{sourceTableColumns.map((col) => (
<SelectItem key={col.name} value={col.name}>
{col.name}
{col.label !== col.name && ` (${col.label})`}
</SelectItem>
))}
</SelectContent>
</Select>
<PopoverTrigger asChild>
<Button
variant="outline"
role="combobox"
aria-expanded={sourceColumnOpenMap[index] || false}
className="h-6 w-full justify-between text-[9px] mt-0.5 font-normal"
>
{mapping.sourceColumn
? (() => {
const selectedCol = sourceTableColumns.find(
(c) => c.name === mapping.sourceColumn
);
return selectedCol
? `${selectedCol.name} (${selectedCol.label})`
: mapping.sourceColumn;
})()
: "컬럼 선택..."}
<ChevronsUpDown className="ml-1 h-3 w-3 shrink-0 opacity-50" />
</Button>
</PopoverTrigger>
<PopoverContent className="w-[250px] p-0" align="start">
<Command>
<CommandInput placeholder="컬럼 검색..." className="h-7 text-[9px]" />
<CommandList>
<CommandEmpty className="py-2 text-[9px] text-center">
.
</CommandEmpty>
<CommandGroup>
{sourceTableColumns.map((col) => (
<CommandItem
key={col.name}
value={`${col.name} ${col.label}`}
onSelect={() => {
updateLinkedFieldMapping(index, { sourceColumn: col.name });
setSourceColumnOpenMap((prev) => ({ ...prev, [index]: false }));
}}
className="text-[9px]"
>
<Check
className={cn(
"mr-2 h-3 w-3",
mapping.sourceColumn === col.name
? "opacity-100"
: "opacity-0"
)}
/>
<span className="font-medium">{col.name}</span>
<span className="ml-1 text-muted-foreground">({col.label})</span>
</CommandItem>
))}
</CommandGroup>
</CommandList>
</Command>
</PopoverContent>
</Popover>
) : (
<Input
value={mapping.sourceColumn || ""}

View File

@ -57,13 +57,34 @@ export function SaveSettingsModal({
const [mainTableSearchOpen, setMainTableSearchOpen] = useState(false);
const [subTableSearchOpen, setSubTableSearchOpen] = useState<Record<number, boolean>>({});
// 컬럼 검색 Popover 상태
const [mainKeyColumnSearchOpen, setMainKeyColumnSearchOpen] = useState(false);
const [mainFieldSearchOpen, setMainFieldSearchOpen] = useState<Record<number, boolean>>({});
const [subColumnSearchOpen, setSubColumnSearchOpen] = useState<Record<number, boolean>>({});
const [subTableColumnSearchOpen, setSubTableColumnSearchOpen] = useState<Record<string, boolean>>({});
const [markerColumnSearchOpen, setMarkerColumnSearchOpen] = useState<Record<number, boolean>>({});
// open이 변경될 때마다 데이터 동기화
useEffect(() => {
if (open) {
setLocalSaveConfig(saveConfig);
setSaveMode(saveConfig.customApiSave?.enabled && saveConfig.customApiSave?.multiTable?.enabled ? "multi" : "single");
// 모달이 열릴 때 기존에 설정된 테이블들의 컬럼 정보 로드
const mainTableName = saveConfig.customApiSave?.multiTable?.mainTable?.tableName;
if (mainTableName && !tableColumns[mainTableName]) {
onLoadTableColumns(mainTableName);
}
// 서브 테이블들의 컬럼 정보도 로드
const subTables = saveConfig.customApiSave?.multiTable?.subTables || [];
subTables.forEach((subTable) => {
if (subTable.tableName && !tableColumns[subTable.tableName]) {
onLoadTableColumns(subTable.tableName);
}
});
}
}, [open, saveConfig]);
}, [open, saveConfig, tableColumns, onLoadTableColumns]);
// 저장 설정 업데이트 함수
const updateSaveConfig = (updates: Partial<SaveConfig>) => {
@ -558,35 +579,76 @@ export function SaveSettingsModal({
<div>
<Label className="text-[10px]"> </Label>
{mainTableColumns.length > 0 ? (
<Select
value={localSaveConfig.customApiSave?.multiTable?.mainTable?.primaryKeyColumn || ""}
onValueChange={(value) =>
updateSaveConfig({
customApiSave: {
...localSaveConfig.customApiSave,
multiTable: {
...localSaveConfig.customApiSave?.multiTable,
mainTable: {
...localSaveConfig.customApiSave?.multiTable?.mainTable,
primaryKeyColumn: value,
},
},
},
})
}
>
<SelectTrigger className="h-7 text-xs mt-1">
<SelectValue placeholder="컬럼 선택" />
</SelectTrigger>
<SelectContent>
{mainTableColumns.map((col) => (
<SelectItem key={col.name} value={col.name}>
{col.name}
{col.label !== col.name && ` (${col.label})`}
</SelectItem>
))}
</SelectContent>
</Select>
<Popover open={mainKeyColumnSearchOpen} onOpenChange={setMainKeyColumnSearchOpen}>
<PopoverTrigger asChild>
<Button
variant="outline"
role="combobox"
aria-expanded={mainKeyColumnSearchOpen}
className="h-7 w-full justify-between text-xs mt-1 font-normal"
>
{localSaveConfig.customApiSave?.multiTable?.mainTable?.primaryKeyColumn ? (
<>
{localSaveConfig.customApiSave.multiTable.mainTable.primaryKeyColumn}
{(() => {
const col = mainTableColumns.find(c => c.name === localSaveConfig.customApiSave?.multiTable?.mainTable?.primaryKeyColumn);
return col?.label && col.label !== col.name ? ` (${col.label})` : "";
})()}
</>
) : (
<span className="text-muted-foreground"> </span>
)}
<ChevronsUpDown className="ml-2 h-3 w-3 shrink-0 opacity-50" />
</Button>
</PopoverTrigger>
<PopoverContent className="w-[280px] p-0" align="start">
<Command>
<CommandInput placeholder="컬럼명 또는 라벨로 검색..." className="text-xs h-8" />
<CommandList className="max-h-[200px]">
<CommandEmpty className="py-3 text-center text-xs">
.
</CommandEmpty>
<CommandGroup>
{mainTableColumns.map((col) => (
<CommandItem
key={col.name}
value={`${col.name} ${col.label || ""}`}
onSelect={() => {
updateSaveConfig({
customApiSave: {
...localSaveConfig.customApiSave,
multiTable: {
...localSaveConfig.customApiSave?.multiTable,
mainTable: {
...localSaveConfig.customApiSave?.multiTable?.mainTable,
primaryKeyColumn: col.name,
},
},
},
});
setMainKeyColumnSearchOpen(false);
}}
className="text-xs"
>
<Check
className={cn(
"mr-2 h-3 w-3",
localSaveConfig.customApiSave?.multiTable?.mainTable?.primaryKeyColumn === col.name ? "opacity-100" : "opacity-0"
)}
/>
<div className="flex flex-col">
<span className="font-medium">{col.name}</span>
{col.label && col.label !== col.name && (
<span className="text-[10px] text-muted-foreground">{col.label}</span>
)}
</div>
</CommandItem>
))}
</CommandGroup>
</CommandList>
</Command>
</PopoverContent>
</Popover>
) : (
<Input
value={localSaveConfig.customApiSave?.multiTable?.mainTable?.primaryKeyColumn || ""}
@ -775,25 +837,70 @@ export function SaveSettingsModal({
<div>
<Label className="text-[9px]"> </Label>
{mainTableColumns.length > 0 ? (
<Select
value={subTable.linkColumn?.mainField || ""}
onValueChange={(value) =>
updateSubTable(subIndex, {
linkColumn: { ...subTable.linkColumn, mainField: value },
})
}
<Popover
open={mainFieldSearchOpen[subIndex] || false}
onOpenChange={(open) => setMainFieldSearchOpen(prev => ({ ...prev, [subIndex]: open }))}
>
<SelectTrigger className="h-6 text-[9px] mt-0.5">
<SelectValue placeholder="필드 선택" />
</SelectTrigger>
<SelectContent>
{mainTableColumns.map((col) => (
<SelectItem key={col.name} value={col.name}>
{col.name}
</SelectItem>
))}
</SelectContent>
</Select>
<PopoverTrigger asChild>
<Button
variant="outline"
role="combobox"
aria-expanded={mainFieldSearchOpen[subIndex] || false}
className="h-6 w-full justify-between text-[9px] mt-0.5 font-normal"
>
{subTable.linkColumn?.mainField ? (
<>
{subTable.linkColumn.mainField}
{(() => {
const col = mainTableColumns.find(c => c.name === subTable.linkColumn?.mainField);
return col?.label && col.label !== col.name ? ` (${col.label})` : "";
})()}
</>
) : (
<span className="text-muted-foreground"> </span>
)}
<ChevronsUpDown className="ml-1 h-2.5 w-2.5 shrink-0 opacity-50" />
</Button>
</PopoverTrigger>
<PopoverContent className="w-[220px] p-0" align="start">
<Command>
<CommandInput placeholder="컬럼명/라벨 검색..." className="text-xs h-7" />
<CommandList className="max-h-[180px]">
<CommandEmpty className="py-2 text-center text-[10px]">
.
</CommandEmpty>
<CommandGroup>
{mainTableColumns.map((col) => (
<CommandItem
key={col.name}
value={`${col.name} ${col.label || ""}`}
onSelect={() => {
updateSubTable(subIndex, {
linkColumn: { ...subTable.linkColumn, mainField: col.name },
});
setMainFieldSearchOpen(prev => ({ ...prev, [subIndex]: false }));
}}
className="text-[10px]"
>
<Check
className={cn(
"mr-2 h-3 w-3",
subTable.linkColumn?.mainField === col.name ? "opacity-100" : "opacity-0"
)}
/>
<div className="flex flex-col">
<span className="font-medium">{col.name}</span>
{col.label && col.label !== col.name && (
<span className="text-[9px] text-muted-foreground">{col.label}</span>
)}
</div>
</CommandItem>
))}
</CommandGroup>
</CommandList>
</Command>
</PopoverContent>
</Popover>
) : (
<Input
value={subTable.linkColumn?.mainField || ""}
@ -811,25 +918,70 @@ export function SaveSettingsModal({
<div>
<Label className="text-[9px]"> </Label>
{subTableColumns.length > 0 ? (
<Select
value={subTable.linkColumn?.subColumn || ""}
onValueChange={(value) =>
updateSubTable(subIndex, {
linkColumn: { ...subTable.linkColumn, subColumn: value },
})
}
<Popover
open={subColumnSearchOpen[subIndex] || false}
onOpenChange={(open) => setSubColumnSearchOpen(prev => ({ ...prev, [subIndex]: open }))}
>
<SelectTrigger className="h-6 text-[9px] mt-0.5">
<SelectValue placeholder="컬럼 선택" />
</SelectTrigger>
<SelectContent>
{subTableColumns.map((col) => (
<SelectItem key={col.name} value={col.name}>
{col.name}
</SelectItem>
))}
</SelectContent>
</Select>
<PopoverTrigger asChild>
<Button
variant="outline"
role="combobox"
aria-expanded={subColumnSearchOpen[subIndex] || false}
className="h-6 w-full justify-between text-[9px] mt-0.5 font-normal"
>
{subTable.linkColumn?.subColumn ? (
<>
{subTable.linkColumn.subColumn}
{(() => {
const col = subTableColumns.find(c => c.name === subTable.linkColumn?.subColumn);
return col?.label && col.label !== col.name ? ` (${col.label})` : "";
})()}
</>
) : (
<span className="text-muted-foreground"> </span>
)}
<ChevronsUpDown className="ml-1 h-2.5 w-2.5 shrink-0 opacity-50" />
</Button>
</PopoverTrigger>
<PopoverContent className="w-[220px] p-0" align="start">
<Command>
<CommandInput placeholder="컬럼명/라벨 검색..." className="text-xs h-7" />
<CommandList className="max-h-[180px]">
<CommandEmpty className="py-2 text-center text-[10px]">
.
</CommandEmpty>
<CommandGroup>
{subTableColumns.map((col) => (
<CommandItem
key={col.name}
value={`${col.name} ${col.label || ""}`}
onSelect={() => {
updateSubTable(subIndex, {
linkColumn: { ...subTable.linkColumn, subColumn: col.name },
});
setSubColumnSearchOpen(prev => ({ ...prev, [subIndex]: false }));
}}
className="text-[10px]"
>
<Check
className={cn(
"mr-2 h-3 w-3",
subTable.linkColumn?.subColumn === col.name ? "opacity-100" : "opacity-0"
)}
/>
<div className="flex flex-col">
<span className="font-medium">{col.name}</span>
{col.label && col.label !== col.name && (
<span className="text-[9px] text-muted-foreground">{col.label}</span>
)}
</div>
</CommandItem>
))}
</CommandGroup>
</CommandList>
</Command>
</PopoverContent>
</Popover>
) : (
<Input
value={subTable.linkColumn?.subColumn || ""}
@ -911,23 +1063,68 @@ export function SaveSettingsModal({
<div>
<Label className="text-[8px]"> </Label>
{subTableColumns.length > 0 ? (
<Select
value={mapping.targetColumn || ""}
onValueChange={(value) =>
updateFieldMapping(subIndex, mapIndex, { targetColumn: value })
}
<Popover
open={subTableColumnSearchOpen[`${subIndex}-${mapIndex}`] || false}
onOpenChange={(open) => setSubTableColumnSearchOpen(prev => ({ ...prev, [`${subIndex}-${mapIndex}`]: open }))}
>
<SelectTrigger className="h-5 text-[8px] mt-0.5">
<SelectValue placeholder="컬럼 선택" />
</SelectTrigger>
<SelectContent>
{subTableColumns.map((col) => (
<SelectItem key={col.name} value={col.name}>
{col.name}
</SelectItem>
))}
</SelectContent>
</Select>
<PopoverTrigger asChild>
<Button
variant="outline"
role="combobox"
aria-expanded={subTableColumnSearchOpen[`${subIndex}-${mapIndex}`] || false}
className="h-5 w-full justify-between text-[8px] mt-0.5 font-normal"
>
{mapping.targetColumn ? (
<>
{mapping.targetColumn}
{(() => {
const col = subTableColumns.find(c => c.name === mapping.targetColumn);
return col?.label && col.label !== col.name ? ` (${col.label})` : "";
})()}
</>
) : (
<span className="text-muted-foreground"> </span>
)}
<ChevronsUpDown className="ml-1 h-2 w-2 shrink-0 opacity-50" />
</Button>
</PopoverTrigger>
<PopoverContent className="w-[200px] p-0" align="start">
<Command>
<CommandInput placeholder="컬럼명/라벨 검색..." className="text-xs h-7" />
<CommandList className="max-h-[180px]">
<CommandEmpty className="py-2 text-center text-[10px]">
.
</CommandEmpty>
<CommandGroup>
{subTableColumns.map((col) => (
<CommandItem
key={col.name}
value={`${col.name} ${col.label || ""}`}
onSelect={() => {
updateFieldMapping(subIndex, mapIndex, { targetColumn: col.name });
setSubTableColumnSearchOpen(prev => ({ ...prev, [`${subIndex}-${mapIndex}`]: false }));
}}
className="text-[10px]"
>
<Check
className={cn(
"mr-2 h-3 w-3",
mapping.targetColumn === col.name ? "opacity-100" : "opacity-0"
)}
/>
<div className="flex flex-col">
<span className="font-medium">{col.name}</span>
{col.label && col.label !== col.name && (
<span className="text-[9px] text-muted-foreground">{col.label}</span>
)}
</div>
</CommandItem>
))}
</CommandGroup>
</CommandList>
</Command>
</PopoverContent>
</Popover>
) : (
<Input
value={mapping.targetColumn || ""}
@ -946,6 +1143,289 @@ export function SaveSettingsModal({
</div>
)}
</div>
<Separator />
{/* 대표 데이터 구분 저장 옵션 */}
<div className="space-y-2">
{!subTable.options?.saveMainAsFirst ? (
// 비활성화 상태: 추가 버튼 표시
<div className="border-2 border-dashed border-muted rounded-lg p-3 hover:border-primary/50 hover:bg-muted/30 transition-colors">
<div className="flex items-center justify-between">
<div>
<p className="text-[10px] font-medium text-muted-foreground">/ </p>
<p className="text-[9px] text-muted-foreground/70 mt-0.5">
</p>
</div>
<Button
variant="outline"
size="sm"
onClick={() => updateSubTable(subIndex, {
options: {
...subTable.options,
saveMainAsFirst: true,
mainMarkerColumn: "",
mainMarkerValue: true,
subMarkerValue: false,
}
})}
className="h-6 text-[9px] px-2 shrink-0"
>
<Plus className="h-3 w-3 mr-1" />
</Button>
</div>
</div>
) : (
// 활성화 상태: 설정 필드 표시
<div className="border rounded-lg p-3 bg-amber-50/50 space-y-3">
<div className="flex items-center justify-between">
<div>
<p className="text-[10px] font-medium">/ </p>
<p className="text-[9px] text-muted-foreground">
</p>
</div>
<Button
variant="ghost"
size="sm"
onClick={() => updateSubTable(subIndex, {
options: {
...subTable.options,
saveMainAsFirst: false,
mainMarkerColumn: undefined,
mainMarkerValue: undefined,
subMarkerValue: undefined,
}
})}
className="h-6 text-[9px] px-2 text-destructive hover:text-destructive"
>
<Trash2 className="h-3 w-3 mr-1" />
</Button>
</div>
<div className="space-y-2">
<div>
<Label className="text-[9px]"> </Label>
{subTableColumns.length > 0 ? (
<Popover
open={markerColumnSearchOpen[subIndex] || false}
onOpenChange={(open) => setMarkerColumnSearchOpen(prev => ({ ...prev, [subIndex]: open }))}
>
<PopoverTrigger asChild>
<Button
variant="outline"
role="combobox"
aria-expanded={markerColumnSearchOpen[subIndex] || false}
className="h-6 w-full justify-between text-[9px] mt-0.5 font-normal"
>
{subTable.options?.mainMarkerColumn ? (
<>
{subTable.options.mainMarkerColumn}
{(() => {
const col = subTableColumns.find(c => c.name === subTable.options?.mainMarkerColumn);
return col?.label && col.label !== col.name ? ` (${col.label})` : "";
})()}
</>
) : (
<span className="text-muted-foreground"> </span>
)}
<ChevronsUpDown className="ml-1 h-2.5 w-2.5 shrink-0 opacity-50" />
</Button>
</PopoverTrigger>
<PopoverContent className="w-[220px] p-0" align="start">
<Command>
<CommandInput placeholder="컬럼명/라벨 검색..." className="text-xs h-7" />
<CommandList className="max-h-[180px]">
<CommandEmpty className="py-2 text-center text-[10px]">
.
</CommandEmpty>
<CommandGroup>
{subTableColumns.map((col) => (
<CommandItem
key={col.name}
value={`${col.name} ${col.label || ""}`}
onSelect={() => {
updateSubTable(subIndex, {
options: {
...subTable.options,
mainMarkerColumn: col.name,
}
});
setMarkerColumnSearchOpen(prev => ({ ...prev, [subIndex]: false }));
}}
className="text-[10px]"
>
<Check
className={cn(
"mr-2 h-3 w-3",
subTable.options?.mainMarkerColumn === col.name ? "opacity-100" : "opacity-0"
)}
/>
<div className="flex flex-col">
<span className="font-medium">{col.name}</span>
{col.label && col.label !== col.name && (
<span className="text-[9px] text-muted-foreground">{col.label}</span>
)}
</div>
</CommandItem>
))}
</CommandGroup>
</CommandList>
</Command>
</PopoverContent>
</Popover>
) : (
<Input
value={subTable.options?.mainMarkerColumn || ""}
onChange={(e) => updateSubTable(subIndex, {
options: {
...subTable.options,
mainMarkerColumn: e.target.value,
}
})}
placeholder="is_primary"
className="h-6 text-[9px] mt-0.5"
/>
)}
<HelpText>/ </HelpText>
</div>
<div className="grid grid-cols-2 gap-2">
<div>
<Label className="text-[9px]"> ()</Label>
<Input
value={String(subTable.options?.mainMarkerValue ?? "true")}
onChange={(e) => {
const val = e.target.value;
// true/false 문자열은 boolean으로 변환
let parsedValue: any = val;
if (val === "true") parsedValue = true;
else if (val === "false") parsedValue = false;
else if (!isNaN(Number(val)) && val !== "") parsedValue = Number(val);
updateSubTable(subIndex, {
options: {
...subTable.options,
mainMarkerValue: parsedValue,
}
});
}}
placeholder="true, Y, 1 등"
className="h-6 text-[9px] mt-0.5"
/>
<HelpText> </HelpText>
</div>
<div>
<Label className="text-[9px]"> ()</Label>
<Input
value={String(subTable.options?.subMarkerValue ?? "false")}
onChange={(e) => {
const val = e.target.value;
let parsedValue: any = val;
if (val === "true") parsedValue = true;
else if (val === "false") parsedValue = false;
else if (!isNaN(Number(val)) && val !== "") parsedValue = Number(val);
updateSubTable(subIndex, {
options: {
...subTable.options,
subMarkerValue: parsedValue,
}
});
}}
placeholder="false, N, 0 등"
className="h-6 text-[9px] mt-0.5"
/>
<HelpText> </HelpText>
</div>
</div>
</div>
</div>
)}
</div>
<Separator />
{/* 수정 시 데이터 로드 옵션 */}
<div className="space-y-2">
{!subTable.options?.loadOnEdit ? (
// 비활성화 상태: 추가 버튼 표시
<div className="border-2 border-dashed border-muted rounded-lg p-3 hover:border-primary/50 hover:bg-muted/30 transition-colors">
<div className="flex items-center justify-between">
<div>
<p className="text-[10px] font-medium text-muted-foreground"> </p>
<p className="text-[9px] text-muted-foreground/70 mt-0.5">
</p>
</div>
<Button
variant="outline"
size="sm"
onClick={() => updateSubTable(subIndex, {
options: {
...subTable.options,
loadOnEdit: true,
loadOnlySubItems: true,
}
})}
className="h-6 text-[9px] px-2 shrink-0"
>
<Plus className="h-3 w-3 mr-1" />
</Button>
</div>
</div>
) : (
// 활성화 상태: 설정 필드 표시
<div className="border rounded-lg p-3 bg-blue-50/50 space-y-3">
<div className="flex items-center justify-between">
<div>
<p className="text-[10px] font-medium"> </p>
<p className="text-[9px] text-muted-foreground">
</p>
</div>
<Button
variant="ghost"
size="sm"
onClick={() => updateSubTable(subIndex, {
options: {
...subTable.options,
loadOnEdit: false,
loadOnlySubItems: undefined,
}
})}
className="h-6 text-[9px] px-2 text-destructive hover:text-destructive"
>
<Trash2 className="h-3 w-3 mr-1" />
</Button>
</div>
<div className="flex items-center gap-2">
<Switch
id={`loadOnlySubItems-${subIndex}`}
checked={subTable.options?.loadOnlySubItems ?? true}
onCheckedChange={(checked) => updateSubTable(subIndex, {
options: {
...subTable.options,
loadOnlySubItems: checked,
}
})}
/>
<Label htmlFor={`loadOnlySubItems-${subIndex}`} className="text-[9px]">
( )
</Label>
</div>
<HelpText>
,
</HelpText>
</div>
)}
</div>
</AccordionContent>
</AccordionItem>
</Accordion>

View File

@ -699,6 +699,357 @@ export function TableColumnSettingsModal({
</Button>
</div>
</div>
{/* 동적 Select 옵션 (소스 테이블에서 로드) */}
<Separator />
<div className="space-y-3">
<div className="flex items-center justify-between">
<div>
<h4 className="text-sm font-medium"> ( )</h4>
<p className="text-xs text-muted-foreground">
. .
</p>
</div>
<Switch
checked={localColumn.dynamicSelectOptions?.enabled ?? false}
onCheckedChange={(checked) => {
updateColumn({
dynamicSelectOptions: checked
? {
enabled: true,
sourceField: "",
distinct: true,
}
: undefined,
});
}}
/>
</div>
{localColumn.dynamicSelectOptions?.enabled && (
<div className="space-y-3 pl-2 border-l-2 border-primary/20">
{/* 소스 필드 */}
<div>
<Label className="text-xs"> </Label>
<p className="text-[10px] text-muted-foreground mb-1">
</p>
{sourceTableColumns.length > 0 ? (
<Select
value={localColumn.dynamicSelectOptions.sourceField || ""}
onValueChange={(value) => {
updateColumn({
dynamicSelectOptions: {
...localColumn.dynamicSelectOptions!,
sourceField: value,
},
});
}}
>
<SelectTrigger className="h-8 text-xs">
<SelectValue placeholder="컬럼 선택" />
</SelectTrigger>
<SelectContent>
{sourceTableColumns.map((col) => (
<SelectItem key={col.column_name} value={col.column_name}>
{col.column_name} {col.comment && `(${col.comment})`}
</SelectItem>
))}
</SelectContent>
</Select>
) : (
<Input
value={localColumn.dynamicSelectOptions.sourceField || ""}
onChange={(e) => {
updateColumn({
dynamicSelectOptions: {
...localColumn.dynamicSelectOptions!,
sourceField: e.target.value,
},
});
}}
placeholder="inspection_item"
className="h-8 text-xs"
/>
)}
</div>
{/* 라벨 필드 */}
<div>
<Label className="text-xs"> ()</Label>
<p className="text-[10px] text-muted-foreground mb-1">
( )
</p>
{sourceTableColumns.length > 0 ? (
<Select
value={localColumn.dynamicSelectOptions.labelField || ""}
onValueChange={(value) => {
updateColumn({
dynamicSelectOptions: {
...localColumn.dynamicSelectOptions!,
labelField: value || undefined,
},
});
}}
>
<SelectTrigger className="h-8 text-xs">
<SelectValue placeholder="(소스 컬럼과 동일)" />
</SelectTrigger>
<SelectContent>
<SelectItem value=""> ( )</SelectItem>
{sourceTableColumns.map((col) => (
<SelectItem key={col.column_name} value={col.column_name}>
{col.column_name} {col.comment && `(${col.comment})`}
</SelectItem>
))}
</SelectContent>
</Select>
) : (
<Input
value={localColumn.dynamicSelectOptions.labelField || ""}
onChange={(e) => {
updateColumn({
dynamicSelectOptions: {
...localColumn.dynamicSelectOptions!,
labelField: e.target.value || undefined,
},
});
}}
placeholder="(비워두면 소스 컬럼 사용)"
className="h-8 text-xs"
/>
)}
</div>
{/* 행 선택 모드 */}
<div className="space-y-2 pt-2 border-t">
<div className="flex items-center gap-2">
<Switch
checked={localColumn.dynamicSelectOptions.rowSelectionMode?.enabled ?? false}
onCheckedChange={(checked) => {
updateColumn({
dynamicSelectOptions: {
...localColumn.dynamicSelectOptions!,
rowSelectionMode: checked
? {
enabled: true,
autoFillMappings: [],
}
: undefined,
},
});
}}
className="scale-75"
/>
<div>
<Label className="text-xs"> </Label>
<p className="text-[10px] text-muted-foreground">
</p>
</div>
</div>
{localColumn.dynamicSelectOptions.rowSelectionMode?.enabled && (
<div className="space-y-3 pl-4">
{/* 소스 ID 저장 설정 */}
<div className="grid grid-cols-2 gap-2">
<div>
<Label className="text-[10px]"> ID </Label>
{sourceTableColumns.length > 0 ? (
<Select
value={localColumn.dynamicSelectOptions.rowSelectionMode.sourceIdColumn || ""}
onValueChange={(value) => {
updateColumn({
dynamicSelectOptions: {
...localColumn.dynamicSelectOptions!,
rowSelectionMode: {
...localColumn.dynamicSelectOptions!.rowSelectionMode!,
sourceIdColumn: value || undefined,
},
},
});
}}
>
<SelectTrigger className="h-7 text-xs mt-1">
<SelectValue placeholder="id" />
</SelectTrigger>
<SelectContent>
{sourceTableColumns.map((col) => (
<SelectItem key={col.column_name} value={col.column_name}>
{col.column_name}
</SelectItem>
))}
</SelectContent>
</Select>
) : (
<Input
value={localColumn.dynamicSelectOptions.rowSelectionMode.sourceIdColumn || ""}
onChange={(e) => {
updateColumn({
dynamicSelectOptions: {
...localColumn.dynamicSelectOptions!,
rowSelectionMode: {
...localColumn.dynamicSelectOptions!.rowSelectionMode!,
sourceIdColumn: e.target.value || undefined,
},
},
});
}}
placeholder="id"
className="h-7 text-xs mt-1"
/>
)}
</div>
<div>
<Label className="text-[10px]"> </Label>
<Input
value={localColumn.dynamicSelectOptions.rowSelectionMode.targetIdField || ""}
onChange={(e) => {
updateColumn({
dynamicSelectOptions: {
...localColumn.dynamicSelectOptions!,
rowSelectionMode: {
...localColumn.dynamicSelectOptions!.rowSelectionMode!,
targetIdField: e.target.value || undefined,
},
},
});
}}
placeholder="inspection_standard_id"
className="h-7 text-xs mt-1"
/>
</div>
</div>
{/* 자동 채움 매핑 */}
<div>
<div className="flex items-center justify-between mb-2">
<Label className="text-[10px]"> </Label>
<Button
size="sm"
variant="outline"
onClick={() => {
const currentMappings = localColumn.dynamicSelectOptions?.rowSelectionMode?.autoFillMappings || [];
updateColumn({
dynamicSelectOptions: {
...localColumn.dynamicSelectOptions!,
rowSelectionMode: {
...localColumn.dynamicSelectOptions!.rowSelectionMode!,
autoFillMappings: [...currentMappings, { sourceColumn: "", targetField: "" }],
},
},
});
}}
className="h-6 text-[10px] px-2"
>
<Plus className="h-3 w-3 mr-1" />
</Button>
</div>
<div className="space-y-2">
{(localColumn.dynamicSelectOptions.rowSelectionMode.autoFillMappings || []).map((mapping, idx) => (
<div key={idx} className="flex items-center gap-2">
{sourceTableColumns.length > 0 ? (
<Select
value={mapping.sourceColumn}
onValueChange={(value) => {
const newMappings = [...(localColumn.dynamicSelectOptions?.rowSelectionMode?.autoFillMappings || [])];
newMappings[idx] = { ...newMappings[idx], sourceColumn: value };
updateColumn({
dynamicSelectOptions: {
...localColumn.dynamicSelectOptions!,
rowSelectionMode: {
...localColumn.dynamicSelectOptions!.rowSelectionMode!,
autoFillMappings: newMappings,
},
},
});
}}
>
<SelectTrigger className="h-7 text-xs flex-1">
<SelectValue placeholder="소스 컬럼" />
</SelectTrigger>
<SelectContent>
{sourceTableColumns.map((col) => (
<SelectItem key={col.column_name} value={col.column_name}>
{col.column_name}
</SelectItem>
))}
</SelectContent>
</Select>
) : (
<Input
value={mapping.sourceColumn}
onChange={(e) => {
const newMappings = [...(localColumn.dynamicSelectOptions?.rowSelectionMode?.autoFillMappings || [])];
newMappings[idx] = { ...newMappings[idx], sourceColumn: e.target.value };
updateColumn({
dynamicSelectOptions: {
...localColumn.dynamicSelectOptions!,
rowSelectionMode: {
...localColumn.dynamicSelectOptions!.rowSelectionMode!,
autoFillMappings: newMappings,
},
},
});
}}
placeholder="소스 컬럼"
className="h-7 text-xs flex-1"
/>
)}
<span className="text-xs text-muted-foreground"></span>
<Input
value={mapping.targetField}
onChange={(e) => {
const newMappings = [...(localColumn.dynamicSelectOptions?.rowSelectionMode?.autoFillMappings || [])];
newMappings[idx] = { ...newMappings[idx], targetField: e.target.value };
updateColumn({
dynamicSelectOptions: {
...localColumn.dynamicSelectOptions!,
rowSelectionMode: {
...localColumn.dynamicSelectOptions!.rowSelectionMode!,
autoFillMappings: newMappings,
},
},
});
}}
placeholder="타겟 필드"
className="h-7 text-xs flex-1"
/>
<Button
size="sm"
variant="ghost"
onClick={() => {
const newMappings = (localColumn.dynamicSelectOptions?.rowSelectionMode?.autoFillMappings || []).filter((_, i) => i !== idx);
updateColumn({
dynamicSelectOptions: {
...localColumn.dynamicSelectOptions!,
rowSelectionMode: {
...localColumn.dynamicSelectOptions!.rowSelectionMode!,
autoFillMappings: newMappings,
},
},
});
}}
className="h-7 w-7 p-0 text-destructive"
>
<Trash2 className="h-3 w-3" />
</Button>
</div>
))}
{(localColumn.dynamicSelectOptions.rowSelectionMode.autoFillMappings || []).length === 0 && (
<p className="text-[10px] text-muted-foreground text-center py-2 border border-dashed rounded">
(: inspection_criteria inspection_standard)
</p>
)}
</div>
</div>
</div>
)}
</div>
</div>
)}
</div>
</>
)}
</TabsContent>

View File

@ -80,7 +80,8 @@ export interface FormFieldConfig {
linkedFieldGroup?: {
enabled?: boolean; // 사용 여부
sourceTable?: string; // 소스 테이블 (예: dept_info)
displayColumn?: string; // 표시할 컬럼 (예: dept_name) - 드롭다운에 보여줄 텍스트
displayColumn?: string; // 메인 표시 컬럼 (예: item_name) - 드롭다운에 보여줄 메인 텍스트
subDisplayColumn?: string; // 서브 표시 컬럼 (예: item_number) - 메인과 함께 표시될 서브 텍스트
displayFormat?: "name_only" | "code_name" | "name_code" | "custom"; // 표시 형식
// 커스텀 표시 형식 (displayFormat이 "custom"일 때 사용)
// 형식: {컬럼명} 으로 치환됨 (예: "{item_name} ({item_number})" → "철판 (ITEM-001)")
@ -256,6 +257,11 @@ export interface TableSectionConfig {
modalTitle?: string; // 모달 제목 (기본: "항목 검색 및 선택")
multiSelect?: boolean; // 다중 선택 허용 (기본: true)
maxHeight?: string; // 테이블 최대 높이 (기본: "400px")
// 추가 버튼 타입
// - search: 검색 모달 열기 (기본값) - 기존 데이터에서 선택
// - addRow: 빈 행 직접 추가 - 새 데이터 직접 입력
addButtonType?: "search" | "addRow";
};
// 7. 조건부 테이블 설정 (고급)
@ -295,6 +301,13 @@ export interface ConditionalTableConfig {
labelColumn: string; // 예: type_name
filterCondition?: string; // 예: is_active = 'Y'
};
// 소스 테이블 필터링 설정
// 조건 선택 시 소스 테이블(검사기준 등)에서 해당 조건으로 필터링
sourceFilter?: {
enabled: boolean;
filterColumn: string; // 소스 테이블에서 필터링할 컬럼 (예: inspection_type)
};
}
/**
@ -373,6 +386,30 @@ export interface TableColumnConfig {
// Select 옵션 (type이 "select"일 때)
selectOptions?: { value: string; label: string }[];
// 동적 Select 옵션 (소스 테이블에서 옵션 로드)
// 조건부 테이블의 sourceFilter가 활성화되어 있으면 자동으로 필터 적용
dynamicSelectOptions?: {
enabled: boolean;
sourceField: string; // 소스 테이블에서 가져올 컬럼 (예: inspection_item)
labelField?: string; // 표시 라벨 컬럼 (없으면 sourceField 사용)
distinct?: boolean; // 중복 제거 (기본: true)
// 행 선택 모드: 이 컬럼 선택 시 같은 소스 행의 다른 컬럼들도 자동 채움
// 활성화하면 이 컬럼이 "대표 컬럼"이 되어 선택 시 연관 컬럼들이 자동으로 채워짐
rowSelectionMode?: {
enabled: boolean;
// 자동 채움할 컬럼 매핑 (소스 컬럼 → 타겟 필드)
// 예: [{ sourceColumn: "inspection_criteria", targetField: "inspection_standard" }]
autoFillColumns?: {
sourceColumn: string; // 소스 테이블의 컬럼
targetField: string; // 현재 테이블의 필드
}[];
// 소스 테이블의 ID 컬럼 (참조 ID 저장용)
sourceIdColumn?: string; // 예: "id"
targetIdField?: string; // 예: "inspection_standard_id"
};
};
// 값 매핑 (핵심 기능) - 고급 설정용
valueMapping?: ValueMappingConfig;
@ -642,6 +679,10 @@ export interface SubTableSaveConfig {
// 저장 전 기존 데이터 삭제
deleteExistingBefore?: boolean;
deleteOnlySubItems?: boolean; // 메인 항목은 유지하고 서브만 삭제
// 수정 모드에서 서브 테이블 데이터 로드
loadOnEdit?: boolean; // 수정 시 서브 테이블 데이터 로드 여부
loadOnlySubItems?: boolean; // 서브 항목만 로드 (메인 항목 제외)
};
}