This commit is contained in:
2026-01-12 17:25:22 +09:00
12 changed files with 475 additions and 232 deletions

View File

@@ -185,6 +185,10 @@ export const SplitPanelLayoutComponent: React.FC<SplitPanelLayoutComponentProps>
const [rightCategoryMappings, setRightCategoryMappings] = useState<
Record<string, Record<string, { label: string; color?: string }>>
>({}); // 우측 카테고리 매핑
// 카테고리 코드 라벨 캐시 (CATEGORY_* 코드 -> 라벨)
const [categoryCodeLabels, setCategoryCodeLabels] = useState<Record<string, string>>({});
const { toast } = useToast();
// 추가 모달 상태
@@ -713,6 +717,14 @@ export const SplitPanelLayoutComponent: React.FC<SplitPanelLayoutComponentProps>
);
}
// 🆕 카테고리 코드 패턴 감지 (CATEGORY_로 시작하는 값)
if (typeof value === "string" && value.startsWith("CATEGORY_")) {
const cachedLabel = categoryCodeLabels[value];
if (cachedLabel) {
return <span className="text-sm">{cachedLabel}</span>;
}
}
// 🆕 자동 날짜 감지 (ISO 8601 형식 또는 Date 객체)
if (typeof value === "string" && value.match(/^\d{4}-\d{2}-\d{2}(T|\s)/)) {
return formatDateValue(value, "YYYY-MM-DD");
@@ -734,7 +746,7 @@ export const SplitPanelLayoutComponent: React.FC<SplitPanelLayoutComponentProps>
// 일반 값
return String(value);
},
[formatDateValue, formatNumberValue],
[formatDateValue, formatNumberValue, categoryCodeLabels],
);
// 좌측 데이터 로드
@@ -1079,6 +1091,49 @@ export const SplitPanelLayoutComponent: React.FC<SplitPanelLayoutComponentProps>
],
);
// 🆕 카테고리 코드 라벨 로드 (rightData 변경 시)
useEffect(() => {
const loadCategoryCodeLabels = async () => {
if (!rightData) return;
const categoryCodes = new Set<string>();
// rightData가 배열인 경우 (조인 모드)
const dataArray = Array.isArray(rightData) ? rightData : [rightData];
dataArray.forEach((row: Record<string, any>) => {
if (row) {
Object.values(row).forEach((value) => {
if (typeof value === "string" && value.startsWith("CATEGORY_")) {
categoryCodes.add(value);
}
});
}
});
// 새로운 카테고리 코드만 필터링 (이미 캐시된 것 제외)
const newCodes = Array.from(categoryCodes).filter((code) => !categoryCodeLabels[code]);
if (newCodes.length > 0) {
try {
console.log("🏷️ [SplitPanel] 카테고리 코드 라벨 조회:", newCodes);
const response = await apiClient.post("/table-categories/labels-by-codes", { valueCodes: newCodes });
if (response.data.success && response.data.data) {
console.log("🏷️ [SplitPanel] 카테고리 라벨 조회 결과:", response.data.data);
setCategoryCodeLabels((prev) => ({
...prev,
...response.data.data,
}));
}
} catch (error) {
console.error("카테고리 라벨 조회 실패:", error);
}
}
};
loadCategoryCodeLabels();
}, [rightData]);
// 🆕 추가 탭 데이터 로딩 함수
const loadTabData = useCallback(
async (tabIndex: number, leftItem: any) => {

View File

@@ -398,6 +398,9 @@ export function TableSectionRenderer({
// 소스 테이블의 컬럼 라벨 (API에서 동적 로드)
const [sourceColumnLabels, setSourceColumnLabels] = useState<Record<string, string>>({});
// 카테고리 타입 컬럼의 옵션 (column.type === "category")
const [categoryOptionsMap, setCategoryOptionsMap] = useState<Record<string, { value: string; label: string }[]>>({});
// 외부 데이터(groupedData) 처리: 데이터 전달 모달열기 액션으로 전달받은 데이터를 초기 테이블 데이터로 설정
useEffect(() => {
// 외부 데이터 소스가 활성화되지 않았거나, groupedData가 없으면 스킵
@@ -511,6 +514,46 @@ export function TableSectionRenderer({
loadColumnLabels();
}, [tableConfig.source.tableName, tableConfig.source.columnLabels]);
// 카테고리 타입 컬럼의 옵션 로드
useEffect(() => {
const loadCategoryOptions = async () => {
const sourceTableName = tableConfig.source.tableName;
if (!sourceTableName) return;
if (!tableConfig.columns) return;
// 카테고리 타입인 컬럼만 필터링
const categoryColumns = tableConfig.columns.filter((col) => col.type === "category");
if (categoryColumns.length === 0) return;
const newOptionsMap: Record<string, { value: string; label: string }[]> = {};
for (const col of categoryColumns) {
// 소스 필드 또는 필드명으로 카테고리 값 조회
const actualColumnName = col.sourceField || col.field;
if (!actualColumnName) continue;
try {
const { getCategoryValues } = await import("@/lib/api/tableCategoryValue");
const result = await getCategoryValues(sourceTableName, actualColumnName, false);
if (result && result.success && Array.isArray(result.data)) {
const options = result.data.map((item: any) => ({
value: item.valueCode || item.value_code || item.value || "",
label: item.valueLabel || item.displayLabel || item.display_label || item.label || item.valueCode || item.value_code || item.value || "",
}));
newOptionsMap[col.field] = options;
}
} catch (error) {
console.error(`카테고리 옵션 로드 실패 (${col.field}):`, error);
}
}
setCategoryOptionsMap((prev) => ({ ...prev, ...newOptionsMap }));
};
loadCategoryOptions();
}, [tableConfig.source.tableName, tableConfig.columns]);
// 조건부 테이블: 동적 옵션 로드 (optionSource 설정이 있는 경우)
useEffect(() => {
if (!isConditionalMode) return;
@@ -952,9 +995,15 @@ export function TableSectionRenderer({
baseColumn.selectOptions = dynamicSelectOptionsMap[col.field];
}
// 카테고리 타입인 경우 옵션 적용 및 select 타입으로 변환
if (col.type === "category" && categoryOptionsMap[col.field]) {
baseColumn.type = "select"; // RepeaterTable에서 select로 렌더링
baseColumn.selectOptions = categoryOptionsMap[col.field];
}
return baseColumn;
});
}, [tableConfig.columns, dynamicSelectOptionsMap]);
}, [tableConfig.columns, dynamicSelectOptionsMap, categoryOptionsMap]);
// 원본 계산 규칙 (조건부 계산 포함)
const originalCalculationRules: TableCalculationRule[] = useMemo(

View File

@@ -308,12 +308,29 @@ export function UniversalFormModalConfigPanel({
column_comment?: string;
inputType?: string;
input_type?: string;
}) => ({
name: c.columnName || c.column_name || "",
type: c.dataType || c.data_type || "text",
label: c.displayName || c.columnComment || c.column_comment || c.columnName || c.column_name || "",
inputType: c.inputType || c.input_type || "text",
}),
isNullable?: string;
is_nullable?: string;
}) => {
const colName = c.columnName || c.column_name || "";
const dataType = c.dataType || c.data_type || "text";
const inputType = c.inputType || c.input_type || "text";
const displayName = c.displayName || c.columnComment || c.column_comment || colName;
const isNullable = c.isNullable || c.is_nullable || "YES";
return {
// camelCase (기존 호환성)
name: colName,
type: dataType,
label: displayName,
inputType: inputType,
// snake_case (TableSectionSettingsModal 호환성)
column_name: colName,
data_type: dataType,
is_nullable: isNullable,
comment: displayName,
input_type: inputType,
};
},
),
}));
}

View File

@@ -48,12 +48,12 @@ interface TableColumnSettingsModalProps {
onOpenChange: (open: boolean) => void;
column: TableColumnConfig;
sourceTableName: string; // 소스 테이블명
sourceTableColumns: { column_name: string; data_type: string; comment?: string }[];
sourceTableColumns: { column_name: string; data_type: string; comment?: string; input_type?: string }[];
formFields: { columnName: string; label: string; sectionId?: string; sectionTitle?: string }[]; // formData 필드 목록 (섹션 정보 포함)
sections: { id: string; title: string }[]; // 섹션 목록
onSave: (updatedColumn: TableColumnConfig) => void;
tables: { table_name: string; comment?: string }[];
tableColumns: Record<string, { column_name: string; data_type: string; is_nullable: string; comment?: string }[]>;
tableColumns: Record<string, { column_name: string; data_type: string; is_nullable: string; comment?: string; input_type?: string }[]>;
onLoadTableColumns: (tableName: string) => void;
}
@@ -103,6 +103,18 @@ export function TableColumnSettingsModal({
return tableColumns[externalTableName] || [];
}, [tableColumns, externalTableName]);
// 소스 필드 기준으로 카테고리 타입인지 확인
const actualSourceField = localColumn.sourceField || localColumn.field;
const sourceColumnInfo = sourceTableColumns.find((c) => c.column_name === actualSourceField);
const isCategoryColumn = sourceColumnInfo?.input_type === "category";
// 카테고리 컬럼인 경우 타입을 자동으로 category로 설정
useEffect(() => {
if (isCategoryColumn && localColumn.type !== "category") {
updateColumn({ type: "category" });
}
}, [isCategoryColumn, localColumn.type]);
// 컬럼 업데이트 함수
const updateColumn = (updates: Partial<TableColumnConfig>) => {
setLocalColumn((prev) => ({ ...prev, ...updates }));
@@ -574,10 +586,11 @@ export function TableColumnSettingsModal({
<div>
<Label className="text-xs"></Label>
<Select
value={localColumn.type}
value={isCategoryColumn ? "category" : localColumn.type}
onValueChange={(value: any) => updateColumn({ type: value })}
disabled={isCategoryColumn}
>
<SelectTrigger className="h-8 text-xs mt-1">
<SelectTrigger className={cn("h-8 text-xs mt-1", isCategoryColumn && "opacity-70 cursor-not-allowed")}>
<SelectValue />
</SelectTrigger>
<SelectContent>
@@ -588,6 +601,9 @@ export function TableColumnSettingsModal({
))}
</SelectContent>
</Select>
{isCategoryColumn && (
<p className="text-[10px] text-blue-600 mt-0.5"> </p>
)}
</div>
<div>
<Label className="text-xs"></Label>

View File

@@ -706,15 +706,15 @@ interface ColumnSettingItemProps {
col: TableColumnConfig;
index: number;
totalCount: number;
saveTableColumns: { column_name: string; data_type: string; is_nullable: string; comment?: string }[];
saveTableColumns: { column_name: string; data_type: string; is_nullable: string; comment?: string; input_type?: string }[];
displayColumns: string[]; // 검색 설정에서 선택한 표시 컬럼 목록
sourceTableColumns: { column_name: string; data_type: string; is_nullable: string; comment?: string }[]; // 소스 테이블 컬럼
sourceTableColumns: { column_name: string; data_type: string; is_nullable: string; comment?: string; input_type?: string }[]; // 소스 테이블 컬럼
sourceTableName: string; // 소스 테이블명
externalTableColumns: { column_name: string; data_type: string; is_nullable: string; comment?: string }[]; // 외부 데이터 테이블 컬럼
externalTableColumns: { column_name: string; data_type: string; is_nullable: string; comment?: string; input_type?: string }[]; // 외부 데이터 테이블 컬럼
externalTableName?: string; // 외부 데이터 테이블명
externalDataEnabled?: boolean; // 외부 데이터 소스 활성화 여부
tables: { table_name: string; comment?: string }[]; // 전체 테이블 목록
tableColumns: Record<string, { column_name: string; data_type: string; is_nullable: string; comment?: string }[]>; // 테이블별 컬럼
tableColumns: Record<string, { column_name: string; data_type: string; is_nullable: string; comment?: string; input_type?: string }[]>; // 테이블별 컬럼
sections: { id: string; title: string }[]; // 섹션 목록
formFields: { columnName: string; label: string; sectionId?: string }[]; // 폼 필드 목록
tableConfig: TableSectionConfig; // 현재 행 필드 목록 표시용
@@ -755,6 +755,18 @@ function ColumnSettingItem({
const [parentFieldSearchOpen, setParentFieldSearchOpen] = useState(false);
const [lookupTableOpenMap, setLookupTableOpenMap] = useState<Record<string, boolean>>({});
// 소스 필드 기준으로 카테고리 타입인지 확인
const actualSourceField = col.sourceField || col.field;
const sourceColumnInfo = sourceTableColumns.find((c) => c.column_name === actualSourceField);
const isCategoryColumn = sourceColumnInfo?.input_type === "category";
// 카테고리 컬럼인 경우 타입을 자동으로 category로 설정
useEffect(() => {
if (isCategoryColumn && col.type !== "category") {
onUpdate({ type: "category" });
}
}, [isCategoryColumn, col.type, onUpdate]);
// 조회 옵션 추가
const addLookupOption = () => {
const newOption: LookupOption = {
@@ -1117,8 +1129,12 @@ function ColumnSettingItem({
{/* 타입 */}
<div>
<Label className="text-xs"></Label>
<Select value={col.type} onValueChange={(value: any) => onUpdate({ type: value })}>
<SelectTrigger className="h-8 text-xs mt-1">
<Select
value={isCategoryColumn ? "category" : col.type}
onValueChange={(value: any) => onUpdate({ type: value })}
disabled={isCategoryColumn}
>
<SelectTrigger className={cn("h-8 text-xs mt-1", isCategoryColumn && "opacity-70 cursor-not-allowed")}>
<SelectValue />
</SelectTrigger>
<SelectContent>
@@ -1129,6 +1145,9 @@ function ColumnSettingItem({
))}
</SelectContent>
</Select>
{isCategoryColumn && (
<p className="text-[10px] text-blue-600 mt-0.5"> </p>
)}
</div>
{/* 너비 */}

View File

@@ -899,6 +899,7 @@ export const TABLE_COLUMN_TYPE_OPTIONS = [
{ value: "number", label: "숫자" },
{ value: "date", label: "날짜" },
{ value: "select", label: "선택(드롭다운)" },
{ value: "category", label: "카테고리" },
] as const;
// 값 매핑 타입 옵션

View File

@@ -4980,19 +4980,7 @@ export class ButtonActionExecutor {
*/
private static async handleExcelUpload(config: ButtonActionConfig, context: ButtonActionContext): Promise<boolean> {
try {
console.log("📤 엑셀 업로드 모달 열기:", {
config,
context,
userId: context.userId,
tableName: context.tableName,
screenId: context.screenId,
// 채번 설정 디버깅
numberingRuleId: config.excelNumberingRuleId,
numberingTargetColumn: config.excelNumberingTargetColumn,
afterUploadFlows: config.excelAfterUploadFlows,
});
// 🆕 마스터-디테일 구조 확인 (화면에 분할 패널이 있으면 자동 감지)
// 마스터-디테일 구조 확인 (화면에 분할 패널이 있으면 자동 감지)
let isMasterDetail = false;
let masterDetailRelation: any = null;
let masterDetailExcelConfig: any = undefined;
@@ -5015,6 +5003,10 @@ export class ButtonActionExecutor {
detailTable: relationResponse.data.detailTable,
masterKeyColumn: relationResponse.data.masterKeyColumn,
detailFkColumn: relationResponse.data.detailFkColumn,
// 채번 규칙 ID 추가 (excelNumberingRuleId를 numberingRuleId로 매핑)
numberingRuleId: config.masterDetailExcel.numberingRuleId || config.excelNumberingRuleId,
// 업로드 후 제어 설정 추가
afterUploadFlows: config.masterDetailExcel.afterUploadFlows || config.excelAfterUploadFlows,
};
} else {
// 버튼 설정이 없으면 분할 패널 정보만 사용
@@ -5024,6 +5016,10 @@ export class ButtonActionExecutor {
masterKeyColumn: relationResponse.data.masterKeyColumn,
detailFkColumn: relationResponse.data.detailFkColumn,
simpleMode: true, // 기본값으로 간단 모드 사용
// 채번 규칙 ID 추가 (excelNumberingRuleId 사용)
numberingRuleId: config.excelNumberingRuleId,
// 업로드 후 제어 설정 추가
afterUploadFlows: config.excelAfterUploadFlows,
};
}