; Please enter a commit message to explain why this merge is necessary,
; especially if it merges an updated upstream into a topic branch.
;
; Lines starting with ';' will be ignored, and an empty message aborts
; the commit.
This commit is contained in:
leeheejin 2026-01-08 17:10:04 +09:00
commit 551e893f15
24 changed files with 1330 additions and 408 deletions

View File

@ -282,3 +282,175 @@ export async function previewCodeMerge(
} }
} }
/**
* -
* oldValue를 newValue로
*/
export async function mergeCodeByValue(
req: AuthenticatedRequest,
res: Response
): Promise<void> {
const { oldValue, newValue } = req.body;
const companyCode = req.user?.companyCode;
try {
// 입력값 검증
if (!oldValue || !newValue) {
res.status(400).json({
success: false,
message: "필수 필드가 누락되었습니다. (oldValue, newValue)",
});
return;
}
if (!companyCode) {
res.status(401).json({
success: false,
message: "인증 정보가 없습니다.",
});
return;
}
// 같은 값으로 병합 시도 방지
if (oldValue === newValue) {
res.status(400).json({
success: false,
message: "기존 값과 새 값이 동일합니다.",
});
return;
}
logger.info("값 기반 코드 병합 시작", {
oldValue,
newValue,
companyCode,
userId: req.user?.userId,
});
// PostgreSQL 함수 호출
const result = await pool.query(
"SELECT * FROM merge_code_by_value($1, $2, $3)",
[oldValue, newValue, companyCode]
);
// 결과 처리
const affectedData = Array.isArray(result) ? result : ((result as any).rows || []);
const totalRows = affectedData.reduce(
(sum: number, row: any) => sum + parseInt(row.out_rows_updated || 0),
0
);
logger.info("값 기반 코드 병합 완료", {
oldValue,
newValue,
affectedTablesCount: affectedData.length,
totalRowsUpdated: totalRows,
});
res.json({
success: true,
message: `코드 병합 완료: ${oldValue}${newValue}`,
data: {
oldValue,
newValue,
affectedData: affectedData.map((row: any) => ({
tableName: row.out_table_name,
columnName: row.out_column_name,
rowsUpdated: parseInt(row.out_rows_updated),
})),
totalRowsUpdated: totalRows,
},
});
} catch (error: any) {
logger.error("값 기반 코드 병합 실패:", {
error: error.message,
stack: error.stack,
oldValue,
newValue,
});
res.status(500).json({
success: false,
message: "코드 병합 중 오류가 발생했습니다.",
error: {
code: "CODE_MERGE_BY_VALUE_ERROR",
details: error.message,
},
});
}
}
/**
*
* /
*/
export async function previewMergeCodeByValue(
req: AuthenticatedRequest,
res: Response
): Promise<void> {
const { oldValue } = req.body;
const companyCode = req.user?.companyCode;
try {
if (!oldValue) {
res.status(400).json({
success: false,
message: "필수 필드가 누락되었습니다. (oldValue)",
});
return;
}
if (!companyCode) {
res.status(401).json({
success: false,
message: "인증 정보가 없습니다.",
});
return;
}
logger.info("값 기반 코드 병합 미리보기", { oldValue, companyCode });
// PostgreSQL 함수 호출
const result = await pool.query(
"SELECT * FROM preview_merge_code_by_value($1, $2)",
[oldValue, companyCode]
);
const preview = Array.isArray(result) ? result : ((result as any).rows || []);
const totalRows = preview.reduce(
(sum: number, row: any) => sum + parseInt(row.out_affected_rows || 0),
0
);
logger.info("값 기반 코드 병합 미리보기 완료", {
tablesCount: preview.length,
totalRows,
});
res.json({
success: true,
message: "코드 병합 미리보기 완료",
data: {
oldValue,
preview: preview.map((row: any) => ({
tableName: row.out_table_name,
columnName: row.out_column_name,
affectedRows: parseInt(row.out_affected_rows),
})),
totalAffectedRows: totalRows,
},
});
} catch (error: any) {
logger.error("값 기반 코드 병합 미리보기 실패:", error);
res.status(500).json({
success: false,
message: "코드 병합 미리보기 중 오류가 발생했습니다.",
error: {
code: "PREVIEW_BY_VALUE_ERROR",
details: error.message,
},
});
}
}

View File

@ -2185,3 +2185,67 @@ export async function multiTableSave(
} }
} }
/**
*
* GET /api/table-management/tables/entity-relations?leftTable=xxx&rightTable=yyy
*
* column_labels에서 /
* .
*/
export async function getTableEntityRelations(
req: AuthenticatedRequest,
res: Response
): Promise<void> {
try {
const { leftTable, rightTable } = req.query;
logger.info(`=== 테이블 엔티티 관계 조회 시작: ${leftTable} <-> ${rightTable} ===`);
if (!leftTable || !rightTable) {
const response: ApiResponse<null> = {
success: false,
message: "leftTable과 rightTable 파라미터가 필요합니다.",
error: {
code: "MISSING_PARAMETERS",
details: "leftTable과 rightTable 쿼리 파라미터가 필요합니다.",
},
};
res.status(400).json(response);
return;
}
const tableManagementService = new TableManagementService();
const relations = await tableManagementService.detectTableEntityRelations(
String(leftTable),
String(rightTable)
);
logger.info(`테이블 엔티티 관계 조회 완료: ${relations.length}개 발견`);
const response: ApiResponse<any> = {
success: true,
message: `${relations.length}개의 엔티티 관계를 발견했습니다.`,
data: {
leftTable: String(leftTable),
rightTable: String(rightTable),
relations,
},
};
res.status(200).json(response);
} catch (error) {
logger.error("테이블 엔티티 관계 조회 중 오류 발생:", error);
const response: ApiResponse<null> = {
success: false,
message: "테이블 엔티티 관계 조회 중 오류가 발생했습니다.",
error: {
code: "ENTITY_RELATIONS_ERROR",
details: error instanceof Error ? error.message : "Unknown error",
},
};
res.status(500).json(response);
}
}

View File

@ -3,6 +3,8 @@ import {
mergeCodeAllTables, mergeCodeAllTables,
getTablesWithColumn, getTablesWithColumn,
previewCodeMerge, previewCodeMerge,
mergeCodeByValue,
previewMergeCodeByValue,
} from "../controllers/codeMergeController"; } from "../controllers/codeMergeController";
import { authenticateToken } from "../middleware/authMiddleware"; import { authenticateToken } from "../middleware/authMiddleware";
@ -13,7 +15,7 @@ router.use(authenticateToken);
/** /**
* POST /api/code-merge/merge-all-tables * POST /api/code-merge/merge-all-tables
* ( ) * ( - )
* Body: { columnName, oldValue, newValue } * Body: { columnName, oldValue, newValue }
*/ */
router.post("/merge-all-tables", mergeCodeAllTables); router.post("/merge-all-tables", mergeCodeAllTables);
@ -26,10 +28,24 @@ router.get("/tables-with-column/:columnName", getTablesWithColumn);
/** /**
* POST /api/code-merge/preview * POST /api/code-merge/preview
* ( ) * ( )
* Body: { columnName, oldValue } * Body: { columnName, oldValue }
*/ */
router.post("/preview", previewCodeMerge); router.post("/preview", previewCodeMerge);
/**
* POST /api/code-merge/merge-by-value
* ( )
* Body: { oldValue, newValue }
*/
router.post("/merge-by-value", mergeCodeByValue);
/**
* POST /api/code-merge/preview-by-value
* ( )
* Body: { oldValue }
*/
router.post("/preview-by-value", previewMergeCodeByValue);
export default router; export default router;

View File

@ -698,6 +698,7 @@ router.post(
try { try {
const { tableName } = req.params; const { tableName } = req.params;
const filterConditions = req.body; const filterConditions = req.body;
const userCompany = req.user?.companyCode;
if (!/^[a-zA-Z_][a-zA-Z0-9_]*$/.test(tableName)) { if (!/^[a-zA-Z_][a-zA-Z0-9_]*$/.test(tableName)) {
return res.status(400).json({ return res.status(400).json({
@ -706,11 +707,12 @@ router.post(
}); });
} }
console.log(`🗑️ 그룹 삭제:`, { tableName, filterConditions }); console.log(`🗑️ 그룹 삭제:`, { tableName, filterConditions, userCompany });
const result = await dataService.deleteGroupRecords( const result = await dataService.deleteGroupRecords(
tableName, tableName,
filterConditions filterConditions,
userCompany // 회사 코드 전달
); );
if (!result.success) { if (!result.success) {

View File

@ -25,6 +25,7 @@ import {
toggleLogTable, toggleLogTable,
getCategoryColumnsByMenu, // 🆕 메뉴별 카테고리 컬럼 조회 getCategoryColumnsByMenu, // 🆕 메뉴별 카테고리 컬럼 조회
multiTableSave, // 🆕 범용 다중 테이블 저장 multiTableSave, // 🆕 범용 다중 테이블 저장
getTableEntityRelations, // 🆕 두 테이블 간 엔티티 관계 조회
} from "../controllers/tableManagementController"; } from "../controllers/tableManagementController";
const router = express.Router(); const router = express.Router();
@ -38,6 +39,15 @@ router.use(authenticateToken);
*/ */
router.get("/tables", getTableList); router.get("/tables", getTableList);
/**
*
* GET /api/table-management/tables/entity-relations?leftTable=xxx&rightTable=yyy
*
* column_labels에서 /
* .
*/
router.get("/tables/entity-relations", getTableEntityRelations);
/** /**
* *
* GET /api/table-management/tables/:tableName/columns * GET /api/table-management/tables/:tableName/columns

View File

@ -1189,6 +1189,13 @@ class DataService {
[tableName] [tableName]
); );
console.log(`🔍 테이블 ${tableName}의 Primary Key 조회 결과:`, {
pkColumns: pkResult.map((r) => r.attname),
pkCount: pkResult.length,
inputId: typeof id === "object" ? JSON.stringify(id).substring(0, 200) + "..." : id,
inputIdType: typeof id,
});
let whereClauses: string[] = []; let whereClauses: string[] = [];
let params: any[] = []; let params: any[] = [];
@ -1216,17 +1223,31 @@ class DataService {
params.push(typeof id === "object" ? id[pkColumn] : id); params.push(typeof id === "object" ? id[pkColumn] : id);
} }
const queryText = `DELETE FROM "${tableName}" WHERE ${whereClauses.join(" AND ")}`; const queryText = `DELETE FROM "${tableName}" WHERE ${whereClauses.join(" AND ")} RETURNING *`;
console.log(`🗑️ 삭제 쿼리:`, queryText, params); console.log(`🗑️ 삭제 쿼리:`, queryText, params);
const result = await query<any>(queryText, params); const result = await query<any>(queryText, params);
// 삭제된 행이 없으면 실패 처리
if (result.length === 0) {
console.warn(
`⚠️ 레코드 삭제 실패: ${tableName}, 해당 조건에 맞는 레코드가 없습니다.`,
{ whereClauses, params }
);
return {
success: false,
message: "삭제할 레코드를 찾을 수 없습니다. 이미 삭제되었거나 권한이 없습니다.",
error: "RECORD_NOT_FOUND",
};
}
console.log( console.log(
`✅ 레코드 삭제 완료: ${tableName}, 영향받은 행: ${result.length}` `✅ 레코드 삭제 완료: ${tableName}, 영향받은 행: ${result.length}`
); );
return { return {
success: true, success: true,
data: result[0], // 삭제된 레코드 정보 반환
}; };
} catch (error) { } catch (error) {
console.error(`레코드 삭제 오류 (${tableName}):`, error); console.error(`레코드 삭제 오류 (${tableName}):`, error);
@ -1240,10 +1261,14 @@ class DataService {
/** /**
* ( ) * ( )
* @param tableName
* @param filterConditions
* @param userCompany ( )
*/ */
async deleteGroupRecords( async deleteGroupRecords(
tableName: string, tableName: string,
filterConditions: Record<string, any> filterConditions: Record<string, any>,
userCompany?: string
): Promise<ServiceResponse<{ deleted: number }>> { ): Promise<ServiceResponse<{ deleted: number }>> {
try { try {
const validation = await this.validateTableAccess(tableName); const validation = await this.validateTableAccess(tableName);
@ -1255,6 +1280,7 @@ class DataService {
const whereValues: any[] = []; const whereValues: any[] = [];
let paramIndex = 1; let paramIndex = 1;
// 사용자 필터 조건 추가
for (const [key, value] of Object.entries(filterConditions)) { for (const [key, value] of Object.entries(filterConditions)) {
whereConditions.push(`"${key}" = $${paramIndex}`); whereConditions.push(`"${key}" = $${paramIndex}`);
whereValues.push(value); whereValues.push(value);
@ -1269,10 +1295,24 @@ class DataService {
}; };
} }
// 🔒 멀티테넌시: company_code 필터링 (최고 관리자 제외)
const hasCompanyCode = await this.checkColumnExists(tableName, "company_code");
if (hasCompanyCode && userCompany && userCompany !== "*") {
whereConditions.push(`"company_code" = $${paramIndex}`);
whereValues.push(userCompany);
paramIndex++;
console.log(`🔒 멀티테넌시 필터 적용: company_code = ${userCompany}`);
}
const whereClause = whereConditions.join(" AND "); const whereClause = whereConditions.join(" AND ");
const deleteQuery = `DELETE FROM "${tableName}" WHERE ${whereClause} RETURNING *`; const deleteQuery = `DELETE FROM "${tableName}" WHERE ${whereClause} RETURNING *`;
console.log(`🗑️ 그룹 삭제:`, { tableName, conditions: filterConditions }); console.log(`🗑️ 그룹 삭제:`, {
tableName,
conditions: filterConditions,
userCompany,
whereClause,
});
const result = await pool.query(deleteQuery, whereValues); const result = await pool.query(deleteQuery, whereValues);

View File

@ -1306,6 +1306,41 @@ export class TableManagementService {
paramCount: number; paramCount: number;
} | null> { } | null> {
try { try {
// 🆕 배열 값 처리 (다중 값 검색 - 분할패널 엔티티 타입에서 "2,3" 형태 지원)
// 좌측에서 "2"를 선택해도, 우측에서 "2,3"을 가진 행이 표시되도록 함
if (Array.isArray(value) && value.length > 0) {
// 배열의 각 값에 대해 OR 조건으로 검색
// 우측 컬럼에 "2,3" 같은 다중 값이 있을 수 있으므로
// 각 값을 LIKE 또는 = 조건으로 처리
const conditions: string[] = [];
const values: any[] = [];
value.forEach((v: any, idx: number) => {
const safeValue = String(v).trim();
// 정확히 일치하거나, 콤마로 구분된 값 중 하나로 포함
// 예: "2,3" 컬럼에서 "2"를 찾으려면:
// - 정확히 "2"
// - "2," 로 시작
// - ",2" 로 끝남
// - ",2," 중간에 포함
const paramBase = paramIndex + (idx * 4);
conditions.push(`(
${columnName}::text = $${paramBase} OR
${columnName}::text LIKE $${paramBase + 1} OR
${columnName}::text LIKE $${paramBase + 2} OR
${columnName}::text LIKE $${paramBase + 3}
)`);
values.push(safeValue, `${safeValue},%`, `%,${safeValue}`, `%,${safeValue},%`);
});
logger.info(`🔍 다중 값 배열 검색: ${columnName} IN [${value.join(", ")}]`);
return {
whereClause: `(${conditions.join(" OR ")})`,
values,
paramCount: values.length,
};
}
// 🔧 파이프로 구분된 문자열 처리 (다중선택 또는 날짜 범위) // 🔧 파이프로 구분된 문자열 처리 (다중선택 또는 날짜 범위)
if (typeof value === "string" && value.includes("|")) { if (typeof value === "string" && value.includes("|")) {
const columnInfo = await this.getColumnWebTypeInfo( const columnInfo = await this.getColumnWebTypeInfo(
@ -4630,4 +4665,101 @@ export class TableManagementService {
return false; return false;
} }
} }
/**
*
* column_labels에서 .
*
* @param leftTable
* @param rightTable
* @returns
*/
async detectTableEntityRelations(
leftTable: string,
rightTable: string
): Promise<Array<{
leftColumn: string;
rightColumn: string;
direction: "left_to_right" | "right_to_left";
inputType: string;
displayColumn?: string;
}>> {
try {
logger.info(`두 테이블 간 엔티티 관계 감지 시작: ${leftTable} <-> ${rightTable}`);
const relations: Array<{
leftColumn: string;
rightColumn: string;
direction: "left_to_right" | "right_to_left";
inputType: string;
displayColumn?: string;
}> = [];
// 1. 우측 테이블에서 좌측 테이블을 참조하는 엔티티 컬럼 찾기
// 예: right_table의 customer_id -> left_table(customer_mng)의 customer_code
const rightToLeftRels = await query<{
column_name: string;
reference_column: string;
input_type: string;
display_column: string | null;
}>(
`SELECT column_name, reference_column, input_type, display_column
FROM column_labels
WHERE table_name = $1
AND input_type IN ('entity', 'category')
AND reference_table = $2
AND reference_column IS NOT NULL
AND reference_column != ''`,
[rightTable, leftTable]
);
for (const rel of rightToLeftRels) {
relations.push({
leftColumn: rel.reference_column,
rightColumn: rel.column_name,
direction: "right_to_left",
inputType: rel.input_type,
displayColumn: rel.display_column || undefined,
});
}
// 2. 좌측 테이블에서 우측 테이블을 참조하는 엔티티 컬럼 찾기
// 예: left_table의 item_id -> right_table(item_info)의 item_number
const leftToRightRels = await query<{
column_name: string;
reference_column: string;
input_type: string;
display_column: string | null;
}>(
`SELECT column_name, reference_column, input_type, display_column
FROM column_labels
WHERE table_name = $1
AND input_type IN ('entity', 'category')
AND reference_table = $2
AND reference_column IS NOT NULL
AND reference_column != ''`,
[leftTable, rightTable]
);
for (const rel of leftToRightRels) {
relations.push({
leftColumn: rel.column_name,
rightColumn: rel.reference_column,
direction: "left_to_right",
inputType: rel.input_type,
displayColumn: rel.display_column || undefined,
});
}
logger.info(`엔티티 관계 감지 완료: ${relations.length}개 발견`);
relations.forEach((rel, idx) => {
logger.info(` ${idx + 1}. ${leftTable}.${rel.leftColumn} <-> ${rightTable}.${rel.rightColumn} (${rel.direction})`);
});
return relations;
} catch (error) {
logger.error(`엔티티 관계 감지 실패: ${leftTable} <-> ${rightTable}`, error);
return [];
}
}
} }

View File

@ -1369,58 +1369,25 @@ export const InteractiveScreenViewer: React.FC<InteractiveScreenViewerProps> = (
} }
case "entity": { case "entity": {
// DynamicWebTypeRenderer로 위임하여 EntitySearchInputWrapper 사용
const widget = comp as WidgetComponent; const widget = comp as WidgetComponent;
const config = widget.webTypeConfig as EntityTypeConfig | undefined; return applyStyles(
<DynamicWebTypeRenderer
console.log("🏢 InteractiveScreenViewer - Entity 위젯:", { webType="entity"
componentId: widget.id, config={widget.webTypeConfig}
widgetType: widget.widgetType, props={{
config, component: widget,
appliedSettings: { value: currentValue,
entityName: config?.entityName, onChange: (value: any) => updateFormData(fieldName, value),
displayField: config?.displayField, onFormDataChange: updateFormData,
valueField: config?.valueField, formData: formData,
multiple: config?.multiple, readonly: readonly,
defaultValue: config?.defaultValue, required: required,
}, placeholder: widget.placeholder || "엔티티를 선택하세요",
}); isInteractive: true,
className: "w-full h-full",
const finalPlaceholder = config?.placeholder || "엔티티를 선택하세요...";
const defaultOptions = [
{ label: "사용자", value: "user" },
{ label: "제품", value: "product" },
{ label: "주문", value: "order" },
{ label: "카테고리", value: "category" },
];
return (
<Select
value={currentValue || config?.defaultValue || ""}
onValueChange={(value) => updateFormData(fieldName, value)}
disabled={readonly}
required={required}
>
<SelectTrigger
className="w-full"
style={{ height: "100%" }}
style={{
...comp.style,
width: "100%",
height: "100%",
}} }}
> />,
<SelectValue placeholder={finalPlaceholder} />
</SelectTrigger>
<SelectContent>
{defaultOptions.map((option) => (
<SelectItem key={option.value} value={option.value}>
{config?.displayFormat
? config.displayFormat.replace("{label}", option.label).replace("{value}", option.value)
: option.label}
</SelectItem>
))}
</SelectContent>
</Select>,
); );
} }

View File

@ -6,6 +6,7 @@ import { Input } from "@/components/ui/input";
import { Button } from "@/components/ui/button"; import { Button } from "@/components/ui/button";
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select"; import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
import { Checkbox } from "@/components/ui/checkbox"; import { Checkbox } from "@/components/ui/checkbox";
import { Switch } from "@/components/ui/switch";
import { Badge } from "@/components/ui/badge"; import { Badge } from "@/components/ui/badge";
import { Search, Database, Link, X, Plus } from "lucide-react"; import { Search, Database, Link, X, Plus } from "lucide-react";
import { EntityTypeConfig } from "@/types/screen"; import { EntityTypeConfig } from "@/types/screen";
@ -26,6 +27,8 @@ export const EntityTypeConfigPanel: React.FC<EntityTypeConfigPanelProps> = ({ co
placeholder: "", placeholder: "",
displayFormat: "simple", displayFormat: "simple",
separator: " - ", separator: " - ",
multiple: false, // 다중 선택
uiMode: "select", // UI 모드: select, combo, modal, autocomplete
...config, ...config,
}; };
@ -38,6 +41,8 @@ export const EntityTypeConfigPanel: React.FC<EntityTypeConfigPanelProps> = ({ co
placeholder: safeConfig.placeholder, placeholder: safeConfig.placeholder,
displayFormat: safeConfig.displayFormat, displayFormat: safeConfig.displayFormat,
separator: safeConfig.separator, separator: safeConfig.separator,
multiple: safeConfig.multiple,
uiMode: safeConfig.uiMode,
}); });
const [newFilter, setNewFilter] = useState({ field: "", operator: "=", value: "" }); const [newFilter, setNewFilter] = useState({ field: "", operator: "=", value: "" });
@ -74,6 +79,8 @@ export const EntityTypeConfigPanel: React.FC<EntityTypeConfigPanelProps> = ({ co
placeholder: safeConfig.placeholder, placeholder: safeConfig.placeholder,
displayFormat: safeConfig.displayFormat, displayFormat: safeConfig.displayFormat,
separator: safeConfig.separator, separator: safeConfig.separator,
multiple: safeConfig.multiple,
uiMode: safeConfig.uiMode,
}); });
}, [ }, [
safeConfig.referenceTable, safeConfig.referenceTable,
@ -83,8 +90,18 @@ export const EntityTypeConfigPanel: React.FC<EntityTypeConfigPanelProps> = ({ co
safeConfig.placeholder, safeConfig.placeholder,
safeConfig.displayFormat, safeConfig.displayFormat,
safeConfig.separator, safeConfig.separator,
safeConfig.multiple,
safeConfig.uiMode,
]); ]);
// UI 모드 옵션
const uiModes = [
{ value: "select", label: "드롭다운 선택" },
{ value: "combo", label: "입력 + 모달 버튼" },
{ value: "modal", label: "모달 팝업" },
{ value: "autocomplete", label: "자동완성" },
];
const updateConfig = (key: keyof EntityTypeConfig, value: any) => { const updateConfig = (key: keyof EntityTypeConfig, value: any) => {
// 로컬 상태 즉시 업데이트 // 로컬 상태 즉시 업데이트
setLocalValues((prev) => ({ ...prev, [key]: value })); setLocalValues((prev) => ({ ...prev, [key]: value }));
@ -260,6 +277,46 @@ export const EntityTypeConfigPanel: React.FC<EntityTypeConfigPanelProps> = ({ co
/> />
</div> </div>
{/* UI 모드 */}
<div>
<Label htmlFor="uiMode" className="text-sm font-medium">
UI
</Label>
<Select value={localValues.uiMode || "select"} onValueChange={(value) => updateConfig("uiMode", value)}>
<SelectTrigger className="mt-1 h-8 w-full text-xs">
<SelectValue placeholder="모드 선택" />
</SelectTrigger>
<SelectContent>
{uiModes.map((mode) => (
<SelectItem key={mode.value} value={mode.value}>
{mode.label}
</SelectItem>
))}
</SelectContent>
</Select>
<p className="text-muted-foreground mt-1 text-xs">
{localValues.uiMode === "select" && "검색 가능한 드롭다운 형태로 표시됩니다."}
{localValues.uiMode === "combo" && "입력 필드와 검색 버튼이 함께 표시됩니다."}
{localValues.uiMode === "modal" && "모달 팝업에서 데이터를 검색하고 선택합니다."}
{localValues.uiMode === "autocomplete" && "입력하면서 자동완성 목록이 표시됩니다."}
</p>
</div>
{/* 다중 선택 */}
<div className="flex items-center justify-between rounded-md border p-3">
<div className="space-y-1">
<Label htmlFor="multiple" className="text-sm font-medium">
</Label>
<p className="text-muted-foreground text-xs"> .</p>
</div>
<Switch
id="multiple"
checked={localValues.multiple || false}
onCheckedChange={(checked) => updateConfig("multiple", checked)}
/>
</div>
{/* 필터 관리 */} {/* 필터 관리 */}
<div className="space-y-3"> <div className="space-y-3">
<Label className="text-sm font-medium"> </Label> <Label className="text-sm font-medium"> </Label>

View File

@ -93,10 +93,15 @@ export class DynamicFormApi {
): Promise<ApiResponse<SaveFormDataResponse>> { ): Promise<ApiResponse<SaveFormDataResponse>> {
try { try {
console.log("🔄 폼 데이터 업데이트 요청:", { id, formData }); console.log("🔄 폼 데이터 업데이트 요청:", { id, formData });
console.log("🌐 API URL:", `/dynamic-form/${id}`);
console.log("📦 요청 본문:", JSON.stringify(formData, null, 2));
const response = await apiClient.put(`/dynamic-form/${id}`, formData); const response = await apiClient.put(`/dynamic-form/${id}`, formData);
console.log("✅ 폼 데이터 업데이트 성공:", response.data); console.log("✅ 폼 데이터 업데이트 성공:", response.data);
console.log("📊 응답 상태:", response.status);
console.log("📋 응답 헤더:", response.headers);
return { return {
success: true, success: true,
data: response.data, data: response.data,
@ -104,6 +109,8 @@ export class DynamicFormApi {
}; };
} catch (error: any) { } catch (error: any) {
console.error("❌ 폼 데이터 업데이트 실패:", error); console.error("❌ 폼 데이터 업데이트 실패:", error);
console.error("📊 에러 응답:", error.response?.data);
console.error("📊 에러 상태:", error.response?.status);
const errorMessage = error.response?.data?.message || error.message || "데이터 업데이트 중 오류가 발생했습니다."; const errorMessage = error.response?.data?.message || error.message || "데이터 업데이트 중 오류가 발생했습니다.";

View File

@ -328,6 +328,40 @@ class TableManagementApi {
}; };
} }
} }
/**
*
* column_labels에서 /
* .
*/
async getTableEntityRelations(
leftTable: string,
rightTable: string
): Promise<ApiResponse<{
leftTable: string;
rightTable: string;
relations: Array<{
leftColumn: string;
rightColumn: string;
direction: "left_to_right" | "right_to_left";
inputType: string;
displayColumn?: string;
}>;
}>> {
try {
const response = await apiClient.get(
`${this.basePath}/tables/entity-relations?leftTable=${encodeURIComponent(leftTable)}&rightTable=${encodeURIComponent(rightTable)}`
);
return response.data;
} catch (error: any) {
console.error(`❌ 테이블 엔티티 관계 조회 실패: ${leftTable} <-> ${rightTable}`, error);
return {
success: false,
message: error.response?.data?.message || error.message || "테이블 엔티티 관계를 조회할 수 없습니다.",
errorCode: error.response?.data?.errorCode,
};
}
}
} }
// 싱글톤 인스턴스 생성 // 싱글톤 인스턴스 생성

View File

@ -35,7 +35,9 @@ export function EntitySearchInputComponent({
parentValue: parentValueProp, parentValue: parentValueProp,
parentFieldId, parentFieldId,
formData, formData,
// 🆕 추가 props // 다중선택 props
multiple: multipleProp,
// 추가 props
component, component,
isInteractive, isInteractive,
onFormDataChange, onFormDataChange,
@ -49,8 +51,11 @@ export function EntitySearchInputComponent({
// uiMode가 있으면 우선 사용, 없으면 modeProp 사용, 기본값 "combo" // uiMode가 있으면 우선 사용, 없으면 modeProp 사용, 기본값 "combo"
const mode = (uiMode || modeProp || "combo") as "select" | "modal" | "combo" | "autocomplete"; const mode = (uiMode || modeProp || "combo") as "select" | "modal" | "combo" | "autocomplete";
// 연쇄관계 설정 추출 (webTypeConfig 또는 component.componentConfig에서) // 다중선택 및 연쇄관계 설정 (props > webTypeConfig > componentConfig 순서)
const config = component?.componentConfig || {}; const config = component?.componentConfig || component?.webTypeConfig || {};
const isMultiple = multipleProp ?? config.multiple ?? false;
// 연쇄관계 설정 추출
const effectiveCascadingRelationCode = cascadingRelationCode || config.cascadingRelationCode; const effectiveCascadingRelationCode = cascadingRelationCode || config.cascadingRelationCode;
// cascadingParentField: ConfigPanel에서 저장되는 필드명 // cascadingParentField: ConfigPanel에서 저장되는 필드명
const effectiveParentFieldId = parentFieldId || config.cascadingParentField || config.parentFieldId; const effectiveParentFieldId = parentFieldId || config.cascadingParentField || config.parentFieldId;
@ -68,11 +73,27 @@ export function EntitySearchInputComponent({
const [isLoadingOptions, setIsLoadingOptions] = useState(false); const [isLoadingOptions, setIsLoadingOptions] = useState(false);
const [optionsLoaded, setOptionsLoaded] = useState(false); const [optionsLoaded, setOptionsLoaded] = useState(false);
// 다중선택 상태 (콤마로 구분된 값들)
const [selectedValues, setSelectedValues] = useState<string[]>([]);
const [selectedDataList, setSelectedDataList] = useState<EntitySearchResult[]>([]);
// 연쇄관계 상태 // 연쇄관계 상태
const [cascadingOptions, setCascadingOptions] = useState<EntitySearchResult[]>([]); const [cascadingOptions, setCascadingOptions] = useState<EntitySearchResult[]>([]);
const [isCascadingLoading, setIsCascadingLoading] = useState(false); const [isCascadingLoading, setIsCascadingLoading] = useState(false);
const previousParentValue = useRef<any>(null); const previousParentValue = useRef<any>(null);
// 다중선택 초기값 설정
useEffect(() => {
if (isMultiple && value) {
const vals =
typeof value === "string" ? value.split(",").filter(Boolean) : Array.isArray(value) ? value : [value];
setSelectedValues(vals.map(String));
} else if (isMultiple && !value) {
setSelectedValues([]);
setSelectedDataList([]);
}
}, [isMultiple, value]);
// 부모 필드 값 결정 (직접 전달 또는 formData에서 추출) - 자식 역할일 때만 필요 // 부모 필드 값 결정 (직접 전달 또는 formData에서 추출) - 자식 역할일 때만 필요
const parentValue = isChildRole const parentValue = isChildRole
? (parentValueProp ?? (effectiveParentFieldId && formData ? formData[effectiveParentFieldId] : undefined)) ? (parentValueProp ?? (effectiveParentFieldId && formData ? formData[effectiveParentFieldId] : undefined))
@ -249,23 +270,75 @@ export function EntitySearchInputComponent({
}, [value, displayField, effectiveOptions, mode, valueField, tableName, selectedData]); }, [value, displayField, effectiveOptions, mode, valueField, tableName, selectedData]);
const handleSelect = (newValue: any, fullData: EntitySearchResult) => { const handleSelect = (newValue: any, fullData: EntitySearchResult) => {
if (isMultiple) {
// 다중선택 모드
const valueStr = String(newValue);
const isAlreadySelected = selectedValues.includes(valueStr);
let newSelectedValues: string[];
let newSelectedDataList: EntitySearchResult[];
if (isAlreadySelected) {
// 이미 선택된 항목이면 제거
newSelectedValues = selectedValues.filter((v) => v !== valueStr);
newSelectedDataList = selectedDataList.filter((d) => String(d[valueField]) !== valueStr);
} else {
// 선택되지 않은 항목이면 추가
newSelectedValues = [...selectedValues, valueStr];
newSelectedDataList = [...selectedDataList, fullData];
}
setSelectedValues(newSelectedValues);
setSelectedDataList(newSelectedDataList);
const joinedValue = newSelectedValues.join(",");
onChange?.(joinedValue, newSelectedDataList);
if (isInteractive && onFormDataChange && component?.columnName) {
onFormDataChange(component.columnName, joinedValue);
console.log("📤 EntitySearchInput (multiple) -> onFormDataChange:", component.columnName, joinedValue);
}
} else {
// 단일선택 모드
setSelectedData(fullData); setSelectedData(fullData);
setDisplayValue(fullData[displayField] || ""); setDisplayValue(fullData[displayField] || "");
onChange?.(newValue, fullData); onChange?.(newValue, fullData);
// 🆕 onFormDataChange 호출 (formData에 값 저장)
if (isInteractive && onFormDataChange && component?.columnName) { if (isInteractive && onFormDataChange && component?.columnName) {
onFormDataChange(component.columnName, newValue); onFormDataChange(component.columnName, newValue);
console.log("📤 EntitySearchInput -> onFormDataChange:", component.columnName, newValue); console.log("📤 EntitySearchInput -> onFormDataChange:", component.columnName, newValue);
} }
}
};
// 다중선택 모드에서 개별 항목 제거
const handleRemoveValue = (valueToRemove: string) => {
const newSelectedValues = selectedValues.filter((v) => v !== valueToRemove);
const newSelectedDataList = selectedDataList.filter((d) => String(d[valueField]) !== valueToRemove);
setSelectedValues(newSelectedValues);
setSelectedDataList(newSelectedDataList);
const joinedValue = newSelectedValues.join(",");
onChange?.(joinedValue || null, newSelectedDataList);
if (isInteractive && onFormDataChange && component?.columnName) {
onFormDataChange(component.columnName, joinedValue || null);
console.log("📤 EntitySearchInput (remove) -> onFormDataChange:", component.columnName, joinedValue);
}
}; };
const handleClear = () => { const handleClear = () => {
if (isMultiple) {
setSelectedValues([]);
setSelectedDataList([]);
onChange?.(null, []);
} else {
setDisplayValue(""); setDisplayValue("");
setSelectedData(null); setSelectedData(null);
onChange?.(null, null); onChange?.(null, null);
}
// 🆕 onFormDataChange 호출 (formData에서 값 제거)
if (isInteractive && onFormDataChange && component?.columnName) { if (isInteractive && onFormDataChange && component?.columnName) {
onFormDataChange(component.columnName, null); onFormDataChange(component.columnName, null);
console.log("📤 EntitySearchInput -> onFormDataChange (clear):", component.columnName, null); console.log("📤 EntitySearchInput -> onFormDataChange (clear):", component.columnName, null);
@ -280,7 +353,10 @@ export function EntitySearchInputComponent({
const handleSelectOption = (option: EntitySearchResult) => { const handleSelectOption = (option: EntitySearchResult) => {
handleSelect(option[valueField], option); handleSelect(option[valueField], option);
// 다중선택이 아닌 경우에만 드롭다운 닫기
if (!isMultiple) {
setSelectOpen(false); setSelectOpen(false);
}
}; };
// 높이 계산 (style에서 height가 있으면 사용, 없으면 기본값) // 높이 계산 (style에서 height가 있으면 사용, 없으면 기본값)
@ -289,6 +365,111 @@ export function EntitySearchInputComponent({
// select 모드: 검색 가능한 드롭다운 // select 모드: 검색 가능한 드롭다운
if (mode === "select") { if (mode === "select") {
// 다중선택 모드
if (isMultiple) {
return (
<div className={cn("relative flex flex-col", className)} style={style}>
{/* 라벨 렌더링 */}
{component?.label && component?.style?.labelDisplay !== false && (
<label className="text-muted-foreground absolute -top-6 left-0 text-sm font-medium">
{component.label}
{component.required && <span className="text-destructive">*</span>}
</label>
)}
{/* 선택된 항목들 표시 (태그 형식) */}
<div
className={cn(
"box-border flex min-h-[40px] w-full flex-wrap items-center gap-2 rounded-md border bg-white px-3 py-2",
!disabled && "hover:border-primary/50",
disabled && "cursor-not-allowed bg-gray-100 opacity-60",
)}
onClick={() => !disabled && !isLoading && setSelectOpen(true)}
style={{ cursor: disabled ? "not-allowed" : "pointer" }}
>
{selectedValues.length > 0 ? (
selectedValues.map((val) => {
const opt = effectiveOptions.find((o) => String(o[valueField]) === val);
const label = opt?.[displayField] || val;
return (
<span
key={val}
className="flex items-center gap-1 rounded bg-blue-100 px-2 py-1 text-sm text-blue-800"
>
{label}
{!disabled && (
<button
type="button"
onClick={(e) => {
e.stopPropagation();
handleRemoveValue(val);
}}
className="ml-1 text-blue-600 hover:text-blue-800"
>
×
</button>
)}
</span>
);
})
) : (
<span className="text-muted-foreground text-sm">
{isLoading
? "로딩 중..."
: shouldApplyCascading && !parentValue
? "상위 항목을 먼저 선택하세요"
: placeholder}
</span>
)}
<ChevronsUpDown className="ml-auto h-4 w-4 shrink-0 opacity-50" />
</div>
{/* 옵션 드롭다운 */}
{selectOpen && !disabled && (
<div className="absolute top-full left-0 z-50 mt-1 w-full rounded-md border bg-white shadow-md">
<Command>
<CommandInput placeholder={`${displayField} 검색...`} className="text-xs sm:text-sm" />
<CommandList className="max-h-60">
<CommandEmpty className="py-4 text-center text-xs sm:text-sm"> .</CommandEmpty>
<CommandGroup>
{effectiveOptions.map((option, index) => {
const isSelected = selectedValues.includes(String(option[valueField]));
return (
<CommandItem
key={option[valueField] || index}
value={`${option[displayField] || ""}-${option[valueField] || ""}`}
onSelect={() => handleSelectOption(option)}
className="text-xs sm:text-sm"
>
<Check className={cn("mr-2 h-4 w-4", isSelected ? "opacity-100" : "opacity-0")} />
<div className="flex flex-col">
<span className="font-medium">{option[displayField]}</span>
{valueField !== displayField && (
<span className="text-muted-foreground text-[10px]">{option[valueField]}</span>
)}
</div>
</CommandItem>
);
})}
</CommandGroup>
</CommandList>
</Command>
{/* 닫기 버튼 */}
<div className="border-t p-2">
<Button variant="outline" size="sm" onClick={() => setSelectOpen(false)} className="w-full text-xs">
</Button>
</div>
</div>
)}
{/* 외부 클릭 시 닫기 */}
{selectOpen && <div className="fixed inset-0 z-40" onClick={() => setSelectOpen(false)} />}
</div>
);
}
// 단일선택 모드 (기존 로직)
return ( return (
<div className={cn("relative flex flex-col", className)} style={style}> <div className={cn("relative flex flex-col", className)} style={style}>
{/* 라벨 렌더링 */} {/* 라벨 렌더링 */}
@ -366,6 +547,95 @@ export function EntitySearchInputComponent({
} }
// modal, combo, autocomplete 모드 // modal, combo, autocomplete 모드
// 다중선택 모드
if (isMultiple) {
return (
<div className={cn("relative flex flex-col", className)} style={style}>
{/* 라벨 렌더링 */}
{component?.label && component?.style?.labelDisplay !== false && (
<label className="text-muted-foreground absolute -top-6 left-0 text-sm font-medium">
{component.label}
{component.required && <span className="text-destructive">*</span>}
</label>
)}
{/* 선택된 항목들 표시 (태그 형식) + 검색 버튼 */}
<div className="flex h-full gap-2">
<div
className={cn(
"box-border flex min-h-[40px] flex-1 flex-wrap items-center gap-2 rounded-md border bg-white px-3 py-2",
!disabled && "hover:border-primary/50",
disabled && "cursor-not-allowed bg-gray-100 opacity-60",
)}
>
{selectedValues.length > 0 ? (
selectedValues.map((val) => {
// selectedDataList에서 먼저 찾고, 없으면 effectiveOptions에서 찾기
const dataFromList = selectedDataList.find((d) => String(d[valueField]) === val);
const opt = dataFromList || effectiveOptions.find((o) => String(o[valueField]) === val);
const label = opt?.[displayField] || val;
return (
<span
key={val}
className="flex items-center gap-1 rounded bg-blue-100 px-2 py-1 text-sm text-blue-800"
>
{label}
{!disabled && (
<button
type="button"
onClick={(e) => {
e.stopPropagation();
handleRemoveValue(val);
}}
className="ml-1 text-blue-600 hover:text-blue-800"
>
×
</button>
)}
</span>
);
})
) : (
<span className="text-muted-foreground text-sm">{placeholder}</span>
)}
</div>
{/* 모달 버튼: modal 또는 combo 모드일 때만 표시 */}
{(mode === "modal" || mode === "combo") && (
<Button
type="button"
onClick={handleOpenModal}
disabled={disabled}
className={cn(!componentHeight && "h-8 text-xs sm:h-10 sm:text-sm")}
style={inputStyle}
>
<Search className="h-4 w-4" />
</Button>
)}
</div>
{/* 검색 모달: modal 또는 combo 모드일 때만 렌더링 */}
{(mode === "modal" || mode === "combo") && (
<EntitySearchModal
open={modalOpen}
onOpenChange={setModalOpen}
tableName={tableName}
displayField={displayField}
valueField={valueField}
searchFields={searchFields}
filterCondition={filterCondition}
modalTitle={modalTitle}
modalColumns={modalColumns}
onSelect={handleSelect}
multiple={isMultiple}
selectedValues={selectedValues}
/>
)}
</div>
);
}
// 단일선택 모드 (기존 로직)
return ( return (
<div className={cn("relative flex flex-col", className)} style={style}> <div className={cn("relative flex flex-col", className)} style={style}>
{/* 라벨 렌더링 */} {/* 라벨 렌더링 */}

View File

@ -747,6 +747,23 @@ export function EntitySearchInputConfigPanel({
</p> </p>
</div> </div>
<div className="space-y-2">
<div className="flex items-center justify-between">
<Label className="text-xs sm:text-sm"> </Label>
<Switch
checked={localConfig.multiple || false}
onCheckedChange={(checked) =>
updateConfig({ multiple: checked })
}
/>
</div>
<p className="text-[10px] text-muted-foreground">
{localConfig.multiple
? "여러 항목을 선택할 수 있습니다. 값은 콤마로 구분됩니다."
: "하나의 항목만 선택할 수 있습니다."}
</p>
</div>
<div className="space-y-2"> <div className="space-y-2">
<Label className="text-xs sm:text-sm"></Label> <Label className="text-xs sm:text-sm"></Label>
<Input <Input

View File

@ -0,0 +1,83 @@
"use client";
import React from "react";
import { EntitySearchInputComponent } from "./EntitySearchInputComponent";
import { WebTypeComponentProps } from "@/lib/registry/types";
/**
* EntitySearchInput
* WebTypeRegistry에서 ,
* props를 EntitySearchInputComponent에 .
*/
export const EntitySearchInputWrapper: React.FC<WebTypeComponentProps> = ({
component,
value,
onChange,
readonly = false,
...props
}) => {
// component에서 필요한 설정 추출
const widget = component as any;
const webTypeConfig = widget?.webTypeConfig || {};
const componentConfig = widget?.componentConfig || {};
// 설정 우선순위: webTypeConfig > componentConfig > component 직접 속성
const config = { ...componentConfig, ...webTypeConfig };
// 테이블 타입 관리에서 설정된 참조 테이블 정보 사용
const tableName = config.referenceTable || widget?.referenceTable || "";
const displayField = config.labelField || config.displayColumn || config.displayField || "name";
const valueField = config.valueField || config.referenceColumn || "id";
// UI 모드: uiMode > mode 순서
const uiMode = config.uiMode || config.mode || "select";
// 다중선택 설정
const multiple = config.multiple ?? false;
// placeholder
const placeholder = config.placeholder || widget?.placeholder || "항목을 선택하세요";
console.log("🏢 EntitySearchInputWrapper 렌더링:", {
tableName,
displayField,
valueField,
uiMode,
multiple,
value,
config,
});
// 테이블 정보가 없으면 안내 메시지 표시
if (!tableName) {
return (
<div className="text-muted-foreground flex h-full w-full items-center rounded-md border border-dashed px-3 py-2 text-sm">
</div>
);
}
return (
<EntitySearchInputComponent
tableName={tableName}
displayField={displayField}
valueField={valueField}
uiMode={uiMode}
placeholder={placeholder}
disabled={readonly}
value={value}
onChange={onChange}
multiple={multiple}
component={component}
isInteractive={props.isInteractive}
onFormDataChange={props.onFormDataChange}
formData={props.formData}
className="h-full w-full"
style={widget?.style}
{...props}
/>
);
};
EntitySearchInputWrapper.displayName = "EntitySearchInputWrapper";

View File

@ -11,7 +11,9 @@ import {
} from "@/components/ui/dialog"; } from "@/components/ui/dialog";
import { Button } from "@/components/ui/button"; import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input"; import { Input } from "@/components/ui/input";
import { Search, Loader2 } from "lucide-react"; import { Checkbox } from "@/components/ui/checkbox";
import { Search, Loader2, Check } from "lucide-react";
import { cn } from "@/lib/utils";
import { useEntitySearch } from "./useEntitySearch"; import { useEntitySearch } from "./useEntitySearch";
import { EntitySearchResult } from "./types"; import { EntitySearchResult } from "./types";
@ -26,6 +28,9 @@ interface EntitySearchModalProps {
modalTitle?: string; modalTitle?: string;
modalColumns?: string[]; modalColumns?: string[];
onSelect: (value: any, fullData: EntitySearchResult) => void; onSelect: (value: any, fullData: EntitySearchResult) => void;
// 다중선택 관련
multiple?: boolean;
selectedValues?: string[]; // 이미 선택된 값들
} }
export function EntitySearchModal({ export function EntitySearchModal({
@ -39,6 +44,8 @@ export function EntitySearchModal({
modalTitle = "검색", modalTitle = "검색",
modalColumns = [], modalColumns = [],
onSelect, onSelect,
multiple = false,
selectedValues = [],
}: EntitySearchModalProps) { }: EntitySearchModalProps) {
const [localSearchText, setLocalSearchText] = useState(""); const [localSearchText, setLocalSearchText] = useState("");
const { const {
@ -71,7 +78,15 @@ export function EntitySearchModal({
const handleSelect = (item: EntitySearchResult) => { const handleSelect = (item: EntitySearchResult) => {
onSelect(item[valueField], item); onSelect(item[valueField], item);
// 다중선택이 아닌 경우에만 모달 닫기
if (!multiple) {
onOpenChange(false); onOpenChange(false);
}
};
// 항목이 선택되어 있는지 확인
const isItemSelected = (item: EntitySearchResult): boolean => {
return selectedValues.includes(String(item[valueField]));
}; };
// 표시할 컬럼 결정 // 표시할 컬럼 결정
@ -123,10 +138,16 @@ export function EntitySearchModal({
{/* 검색 결과 테이블 */} {/* 검색 결과 테이블 */}
<div className="border rounded-md overflow-hidden"> <div className="border rounded-md overflow-hidden">
<div className="overflow-x-auto"> <div className="overflow-x-auto max-h-[400px] overflow-y-auto">
<table className="w-full text-xs sm:text-sm"> <table className="w-full text-xs sm:text-sm">
<thead className="bg-muted"> <thead className="bg-muted sticky top-0">
<tr> <tr>
{/* 다중선택 시 체크박스 컬럼 */}
{multiple && (
<th className="px-4 py-2 text-left font-medium text-muted-foreground w-12">
</th>
)}
{displayColumns.map((col) => ( {displayColumns.map((col) => (
<th <th
key={col} key={col}
@ -135,39 +156,56 @@ export function EntitySearchModal({
{col} {col}
</th> </th>
))} ))}
{!multiple && (
<th className="px-4 py-2 text-left font-medium text-muted-foreground w-24"> <th className="px-4 py-2 text-left font-medium text-muted-foreground w-24">
</th> </th>
)}
</tr> </tr>
</thead> </thead>
<tbody> <tbody>
{loading && results.length === 0 ? ( {loading && results.length === 0 ? (
<tr> <tr>
<td colSpan={displayColumns.length + 1} className="px-4 py-8 text-center"> <td colSpan={displayColumns.length + (multiple ? 1 : 2)} className="px-4 py-8 text-center">
<Loader2 className="h-6 w-6 animate-spin mx-auto" /> <Loader2 className="h-6 w-6 animate-spin mx-auto" />
<p className="mt-2 text-muted-foreground"> ...</p> <p className="mt-2 text-muted-foreground"> ...</p>
</td> </td>
</tr> </tr>
) : results.length === 0 ? ( ) : results.length === 0 ? (
<tr> <tr>
<td colSpan={displayColumns.length + 1} className="px-4 py-8 text-center text-muted-foreground"> <td colSpan={displayColumns.length + (multiple ? 1 : 2)} className="px-4 py-8 text-center text-muted-foreground">
</td> </td>
</tr> </tr>
) : ( ) : (
results.map((item, index) => { results.map((item, index) => {
const uniqueKey = item[valueField] !== undefined ? `${item[valueField]}` : `row-${index}`; const uniqueKey = item[valueField] !== undefined ? `${item[valueField]}` : `row-${index}`;
const isSelected = isItemSelected(item);
return ( return (
<tr <tr
key={uniqueKey} key={uniqueKey}
className="border-t hover:bg-accent cursor-pointer transition-colors" className={cn(
"border-t cursor-pointer transition-colors",
isSelected ? "bg-blue-50 hover:bg-blue-100" : "hover:bg-accent"
)}
onClick={() => handleSelect(item)} onClick={() => handleSelect(item)}
> >
{/* 다중선택 시 체크박스 */}
{multiple && (
<td className="px-4 py-2">
<Checkbox
checked={isSelected}
onCheckedChange={() => handleSelect(item)}
onClick={(e) => e.stopPropagation()}
/>
</td>
)}
{displayColumns.map((col) => ( {displayColumns.map((col) => (
<td key={`${uniqueKey}-${col}`} className="px-4 py-2"> <td key={`${uniqueKey}-${col}`} className="px-4 py-2">
{item[col] || "-"} {item[col] || "-"}
</td> </td>
))} ))}
{!multiple && (
<td className="px-4 py-2"> <td className="px-4 py-2">
<Button <Button
size="sm" size="sm"
@ -181,6 +219,7 @@ export function EntitySearchModal({
</Button> </Button>
</td> </td>
)}
</tr> </tr>
); );
}) })
@ -211,12 +250,18 @@ export function EntitySearchModal({
)} )}
<DialogFooter className="gap-2 sm:gap-0"> <DialogFooter className="gap-2 sm:gap-0">
{/* 다중선택 시 선택된 항목 수 표시 */}
{multiple && selectedValues.length > 0 && (
<div className="flex-1 text-sm text-muted-foreground">
{selectedValues.length}
</div>
)}
<Button <Button
variant="outline" variant="outline"
onClick={() => onOpenChange(false)} onClick={() => onOpenChange(false)}
className="h-8 flex-1 text-xs sm:h-10 sm:flex-none sm:text-sm" className="h-8 flex-1 text-xs sm:h-10 sm:flex-none sm:text-sm"
> >
{multiple ? "완료" : "취소"}
</Button> </Button>
</DialogFooter> </DialogFooter>
</DialogContent> </DialogContent>

View File

@ -11,6 +11,9 @@ export interface EntitySearchInputConfig {
showAdditionalInfo?: boolean; showAdditionalInfo?: boolean;
additionalFields?: string[]; additionalFields?: string[];
// 다중 선택 설정
multiple?: boolean; // 여러 항목 선택 가능 여부 (기본: false)
// 연쇄관계 설정 (cascading_relation 테이블과 연동) // 연쇄관계 설정 (cascading_relation 테이블과 연동)
cascadingRelationCode?: string; // 연쇄관계 코드 (WAREHOUSE_LOCATION 등) cascadingRelationCode?: string; // 연쇄관계 코드 (WAREHOUSE_LOCATION 등)
cascadingRole?: "parent" | "child"; // 역할 (부모/자식) cascadingRole?: "parent" | "child"; // 역할 (부모/자식)

View File

@ -42,6 +42,7 @@ export type { EntitySearchInputConfig } from "./config";
// 컴포넌트 내보내기 // 컴포넌트 내보내기
export { EntitySearchInputComponent } from "./EntitySearchInputComponent"; export { EntitySearchInputComponent } from "./EntitySearchInputComponent";
export { EntitySearchInputWrapper } from "./EntitySearchInputWrapper";
export { EntitySearchInputRenderer } from "./EntitySearchInputRenderer"; export { EntitySearchInputRenderer } from "./EntitySearchInputRenderer";
export { EntitySearchModal } from "./EntitySearchModal"; export { EntitySearchModal } from "./EntitySearchModal";
export { useEntitySearch } from "./useEntitySearch"; export { useEntitySearch } from "./useEntitySearch";

View File

@ -19,6 +19,9 @@ export interface EntitySearchInputProps {
placeholder?: string; placeholder?: string;
disabled?: boolean; disabled?: boolean;
// 다중선택
multiple?: boolean; // 여러 항목 선택 가능 여부 (기본: false)
// 필터링 // 필터링
filterCondition?: Record<string, any>; // 추가 WHERE 조건 filterCondition?: Record<string, any>; // 추가 WHERE 조건
companyCode?: string; // 멀티테넌시 companyCode?: string; // 멀티테넌시

View File

@ -830,7 +830,8 @@ export const SplitPanelLayoutComponent: React.FC<SplitPanelLayoutComponentProps>
size: 1, size: 1,
}); });
const detail = result.items && result.items.length > 0 ? result.items[0] : null; // result.data가 EntityJoinResponse의 실제 배열 필드
const detail = result.data && result.data.length > 0 ? result.data[0] : null;
setRightData(detail); setRightData(detail);
} else if (relationshipType === "join") { } else if (relationshipType === "join") {
// 조인 모드: 다른 테이블의 관련 데이터 (여러 개) // 조인 모드: 다른 테이블의 관련 데이터 (여러 개)
@ -899,16 +900,54 @@ export const SplitPanelLayoutComponent: React.FC<SplitPanelLayoutComponentProps>
return; return;
} }
// 🆕 복합키 지원 // 🆕 엔티티 관계 자동 감지 로직 개선
if (keys && keys.length > 0 && leftTable) { // 1. 설정된 keys가 있으면 사용
// 2. 없으면 테이블 타입관리에서 정의된 엔티티 관계를 자동으로 조회
let effectiveKeys = keys || [];
if (effectiveKeys.length === 0 && leftTable && rightTableName) {
// 엔티티 관계 자동 감지
console.log("🔍 [분할패널] 엔티티 관계 자동 감지 시작:", leftTable, "->", rightTableName);
const { tableManagementApi } = await import("@/lib/api/tableManagement");
const relResponse = await tableManagementApi.getTableEntityRelations(leftTable, rightTableName);
if (relResponse.success && relResponse.data?.relations && relResponse.data.relations.length > 0) {
effectiveKeys = relResponse.data.relations.map((rel) => ({
leftColumn: rel.leftColumn,
rightColumn: rel.rightColumn,
}));
console.log("✅ [분할패널] 자동 감지된 관계:", effectiveKeys);
}
}
if (effectiveKeys.length > 0 && leftTable) {
// 복합키: 여러 조건으로 필터링 // 복합키: 여러 조건으로 필터링
const { entityJoinApi } = await import("@/lib/api/entityJoin"); const { entityJoinApi } = await import("@/lib/api/entityJoin");
// 복합키 조건 생성 // 복합키 조건 생성 (다중 값 지원)
// 🆕 항상 배열로 전달하여 백엔드에서 다중 값 컬럼 검색을 지원하도록 함
// 예: 좌측에서 "2"를 선택해도, 우측에서 "2,3"을 가진 행이 표시되도록
const searchConditions: Record<string, any> = {}; const searchConditions: Record<string, any> = {};
keys.forEach((key) => { effectiveKeys.forEach((key) => {
if (key.leftColumn && key.rightColumn && leftItem[key.leftColumn] !== undefined) { if (key.leftColumn && key.rightColumn && leftItem[key.leftColumn] !== undefined) {
searchConditions[key.rightColumn] = leftItem[key.leftColumn]; const leftValue = leftItem[key.leftColumn];
// 다중 값 지원: 모든 값을 배열로 변환하여 다중 값 컬럼 검색 활성화
if (typeof leftValue === "string") {
if (leftValue.includes(",")) {
// "2,3" 형태면 분리해서 배열로
const values = leftValue.split(",").map((v: string) => v.trim()).filter((v: string) => v);
searchConditions[key.rightColumn] = values;
console.log("🔗 [분할패널] 다중 값 검색 (분리):", key.rightColumn, "=", values);
} else {
// 단일 값도 배열로 변환 (우측에 "2,3" 같은 다중 값이 있을 수 있으므로)
searchConditions[key.rightColumn] = [leftValue.trim()];
console.log("🔗 [분할패널] 다중 값 검색 (단일):", key.rightColumn, "=", [leftValue.trim()]);
}
} else {
// 숫자나 다른 타입은 배열로 감싸기
searchConditions[key.rightColumn] = [leftValue];
console.log("🔗 [분할패널] 다중 값 검색 (기타):", key.rightColumn, "=", [leftValue]);
}
} }
}); });
@ -947,7 +986,7 @@ export const SplitPanelLayoutComponent: React.FC<SplitPanelLayoutComponentProps>
setRightData(filteredData); setRightData(filteredData);
} else { } else {
// 단일키 (하위 호환성) // 단일키 (하위 호환성) 또는 관계를 찾지 못한 경우
const leftColumn = componentConfig.rightPanel?.relation?.leftColumn; const leftColumn = componentConfig.rightPanel?.relation?.leftColumn;
const rightColumn = componentConfig.rightPanel?.relation?.foreignKey; const rightColumn = componentConfig.rightPanel?.relation?.foreignKey;
@ -965,6 +1004,9 @@ export const SplitPanelLayoutComponent: React.FC<SplitPanelLayoutComponentProps>
componentConfig.rightPanel?.deduplication, // 🆕 중복 제거 설정 전달 componentConfig.rightPanel?.deduplication, // 🆕 중복 제거 설정 전달
); );
setRightData(joinedData || []); // 모든 관련 레코드 (배열) setRightData(joinedData || []); // 모든 관련 레코드 (배열)
} else {
console.warn("⚠️ [분할패널] 테이블 관계를 찾을 수 없습니다:", leftTable, "->", rightTableName);
setRightData([]);
} }
} }
} }
@ -1613,26 +1655,66 @@ export const SplitPanelLayoutComponent: React.FC<SplitPanelLayoutComponentProps>
try { try {
console.log("🗑️ 데이터 삭제:", { tableName, primaryKey }); console.log("🗑️ 데이터 삭제:", { tableName, primaryKey });
// 🔍 중복 제거 설정 디버깅 // 🔍 그룹 삭제 설정 확인 (editButton.groupByColumns 또는 deduplication)
console.log("🔍 중복 제거 디버깅:", { const groupByColumns = componentConfig.rightPanel?.editButton?.groupByColumns || [];
const deduplication = componentConfig.rightPanel?.dataFilter?.deduplication;
console.log("🔍 삭제 설정 디버깅:", {
panel: deleteModalPanel, panel: deleteModalPanel,
dataFilter: componentConfig.rightPanel?.dataFilter, groupByColumns,
deduplication: componentConfig.rightPanel?.dataFilter?.deduplication, deduplication,
enabled: componentConfig.rightPanel?.dataFilter?.deduplication?.enabled, deduplicationEnabled: deduplication?.enabled,
}); });
let result; let result;
// 🔧 중복 제거가 활성화된 경우, groupByColumn 기준으로 모든 관련 레코드 삭제 // 🔧 우측 패널 삭제 시 그룹 삭제 조건 확인
if (deleteModalPanel === "right" && componentConfig.rightPanel?.dataFilter?.deduplication?.enabled) { if (deleteModalPanel === "right") {
const deduplication = componentConfig.rightPanel.dataFilter.deduplication; // 1. groupByColumns가 설정된 경우 (패널 설정에서 선택된 컬럼들)
const groupByColumn = deduplication.groupByColumn; if (groupByColumns.length > 0) {
const filterConditions: Record<string, any> = {};
if (groupByColumn && deleteModalItem[groupByColumn]) { // 선택된 컬럼들의 값을 필터 조건으로 추가
for (const col of groupByColumns) {
if (deleteModalItem[col] !== undefined && deleteModalItem[col] !== null) {
filterConditions[col] = deleteModalItem[col];
}
}
// 🔒 안전장치: 조인 모드에서 좌측 패널의 키 값도 필터 조건에 포함
// (다른 거래처의 같은 품목이 삭제되는 것을 방지)
if (selectedLeftItem && componentConfig.rightPanel?.mode === "join") {
const leftColumn = componentConfig.rightPanel.join?.leftColumn;
const rightColumn = componentConfig.rightPanel.join?.rightColumn;
if (leftColumn && rightColumn && selectedLeftItem[leftColumn]) {
// rightColumn이 filterConditions에 없으면 추가
if (!filterConditions[rightColumn]) {
filterConditions[rightColumn] = selectedLeftItem[leftColumn];
console.log(`🔒 안전장치: ${rightColumn} = ${selectedLeftItem[leftColumn]} 추가`);
}
}
}
// 필터 조건이 있으면 그룹 삭제
if (Object.keys(filterConditions).length > 0) {
console.log(`🔗 그룹 삭제 (groupByColumns): ${groupByColumns.join(", ")} 기준`);
console.log("🗑️ 그룹 삭제 조건:", filterConditions);
result = await dataApi.deleteGroupRecords(tableName, filterConditions);
} else {
// 필터 조건이 없으면 단일 삭제
console.log("⚠️ groupByColumns 값이 없어 단일 삭제로 전환");
result = await dataApi.deleteRecord(tableName, primaryKey);
}
}
// 2. 중복 제거(deduplication)가 활성화된 경우
else if (deduplication?.enabled && deduplication?.groupByColumn) {
const groupByColumn = deduplication.groupByColumn;
const groupValue = deleteModalItem[groupByColumn]; const groupValue = deleteModalItem[groupByColumn];
if (groupValue) {
console.log(`🔗 중복 제거 활성화: ${groupByColumn} = ${groupValue} 기준으로 모든 레코드 삭제`); console.log(`🔗 중복 제거 활성화: ${groupByColumn} = ${groupValue} 기준으로 모든 레코드 삭제`);
// groupByColumn 값으로 필터링하여 삭제
const filterConditions: Record<string, any> = { const filterConditions: Record<string, any> = {
[groupByColumn]: groupValue, [groupByColumn]: groupValue,
}; };
@ -1645,15 +1727,17 @@ export const SplitPanelLayoutComponent: React.FC<SplitPanelLayoutComponentProps>
} }
console.log("🗑️ 그룹 삭제 조건:", filterConditions); console.log("🗑️ 그룹 삭제 조건:", filterConditions);
// 그룹 삭제 API 호출
result = await dataApi.deleteGroupRecords(tableName, filterConditions); result = await dataApi.deleteGroupRecords(tableName, filterConditions);
} else { } else {
// 단일 레코드 삭제 result = await dataApi.deleteRecord(tableName, primaryKey);
}
}
// 3. 그 외: 단일 레코드 삭제
else {
result = await dataApi.deleteRecord(tableName, primaryKey); result = await dataApi.deleteRecord(tableName, primaryKey);
} }
} else { } else {
// 단일 레코드 삭제 // 좌측 패널: 단일 레코드 삭제
result = await dataApi.deleteRecord(tableName, primaryKey); result = await dataApi.deleteRecord(tableName, primaryKey);
} }

View File

@ -429,6 +429,71 @@ export const SplitPanelLayoutConfigPanel: React.FC<SplitPanelLayoutConfigPanelPr
} }
}, [config.rightPanel?.tableName]); }, [config.rightPanel?.tableName]);
// 🆕 좌측/우측 테이블이 모두 선택되면 엔티티 관계 자동 감지
const [autoDetectedRelations, setAutoDetectedRelations] = useState<
Array<{
leftColumn: string;
rightColumn: string;
direction: "left_to_right" | "right_to_left";
inputType: string;
displayColumn?: string;
}>
>([]);
const [isDetectingRelations, setIsDetectingRelations] = useState(false);
useEffect(() => {
const detectRelations = async () => {
const leftTable = config.leftPanel?.tableName || screenTableName;
const rightTable = config.rightPanel?.tableName;
// 조인 모드이고 양쪽 테이블이 모두 있을 때만 감지
if (relationshipType !== "join" || !leftTable || !rightTable) {
setAutoDetectedRelations([]);
return;
}
setIsDetectingRelations(true);
try {
const { tableManagementApi } = await import("@/lib/api/tableManagement");
const response = await tableManagementApi.getTableEntityRelations(leftTable, rightTable);
if (response.success && response.data?.relations) {
console.log("🔍 엔티티 관계 자동 감지:", response.data.relations);
setAutoDetectedRelations(response.data.relations);
// 감지된 관계가 있고, 현재 설정된 키가 없으면 자동으로 첫 번째 관계를 설정
const currentKeys = config.rightPanel?.relation?.keys || [];
if (response.data.relations.length > 0 && currentKeys.length === 0) {
// 첫 번째 관계만 자동 설정 (사용자가 추가로 설정 가능)
const firstRel = response.data.relations[0];
console.log("✅ 첫 번째 엔티티 관계 자동 설정:", firstRel);
updateRightPanel({
relation: {
...config.rightPanel?.relation,
type: "join",
useMultipleKeys: true,
keys: [
{
leftColumn: firstRel.leftColumn,
rightColumn: firstRel.rightColumn,
},
],
},
});
}
}
} catch (error) {
console.error("❌ 엔티티 관계 감지 실패:", error);
setAutoDetectedRelations([]);
} finally {
setIsDetectingRelations(false);
}
};
detectRelations();
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [config.leftPanel?.tableName, config.rightPanel?.tableName, screenTableName, relationshipType]);
console.log("🔧 SplitPanelLayoutConfigPanel 렌더링"); console.log("🔧 SplitPanelLayoutConfigPanel 렌더링");
console.log(" - config:", config); console.log(" - config:", config);
console.log(" - tables:", tables); console.log(" - tables:", tables);
@ -1633,234 +1698,50 @@ export const SplitPanelLayoutConfigPanel: React.FC<SplitPanelLayoutConfigPanelPr
</div> </div>
)} )}
{/* 컬럼 매핑 - 조인 모드에서만 표시 */} {/* 엔티티 관계 자동 감지 (읽기 전용) - 조인 모드에서만 표시 */}
{relationshipType !== "detail" && ( {relationshipType !== "detail" && (
<div className="space-y-3 rounded-lg border border-gray-200 bg-gray-50 p-3"> <div className="space-y-3 rounded-lg border border-blue-200 bg-blue-50 p-3">
<div className="flex items-center justify-between">
<div> <div>
<Label className="text-sm font-semibold"> ( )</Label> <Label className="text-sm font-semibold"> ( )</Label>
<p className="text-xs text-gray-600"> </p> <p className="text-xs text-gray-600"> </p>
</div>
<Button
size="sm"
variant="ghost"
className="h-6 text-xs"
onClick={() => {
const currentKeys = config.rightPanel?.relation?.keys || [];
// 단일키에서 복합키로 전환 시 기존 값 유지
if (
currentKeys.length === 0 &&
config.rightPanel?.relation?.leftColumn &&
config.rightPanel?.relation?.foreignKey
) {
updateRightPanel({
relation: {
...config.rightPanel?.relation,
keys: [
{
leftColumn: config.rightPanel.relation.leftColumn,
rightColumn: config.rightPanel.relation.foreignKey,
},
{ leftColumn: "", rightColumn: "" },
],
},
});
} else {
updateRightPanel({
relation: {
...config.rightPanel?.relation,
keys: [...currentKeys, { leftColumn: "", rightColumn: "" }],
},
});
}
}}
>
<Plus className="mr-1 h-3 w-3" />
</Button>
</div> </div>
<p className="text-[10px] text-blue-600">복합키: 여러 (: item_code + lot_number)</p> {isDetectingRelations ? (
<div className="flex items-center gap-2 text-xs text-gray-500">
{/* 복합키가 설정된 경우 */} <div className="h-3 w-3 animate-spin rounded-full border-2 border-blue-500 border-t-transparent" />
{(config.rightPanel?.relation?.keys || []).length > 0 ? ( ...
<>
{(config.rightPanel?.relation?.keys || []).map((key, index) => (
<div key={index} className="space-y-2 rounded-md border bg-white p-3">
<div className="flex items-center justify-between">
<span className="text-xs font-medium"> {index + 1}</span>
<Button
size="sm"
variant="ghost"
className="text-destructive h-6 w-6 p-0"
onClick={() => {
const newKeys = (config.rightPanel?.relation?.keys || []).filter((_, i) => i !== index);
updateRightPanel({
relation: { ...config.rightPanel?.relation, keys: newKeys },
});
}}
>
<X className="h-3 w-3" />
</Button>
</div>
<div className="grid grid-cols-2 gap-2">
<div>
<Label className="text-xs"> </Label>
<Select
value={key.leftColumn || ""}
onValueChange={(value) => {
const newKeys = [...(config.rightPanel?.relation?.keys || [])];
newKeys[index] = { ...newKeys[index], leftColumn: value };
updateRightPanel({
relation: { ...config.rightPanel?.relation, keys: newKeys },
});
}}
>
<SelectTrigger className="h-8 text-xs">
<SelectValue placeholder="좌측 컬럼" />
</SelectTrigger>
<SelectContent>
{leftTableColumns.map((column) => (
<SelectItem key={column.columnName} value={column.columnName}>
{column.columnName}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
<div>
<Label className="text-xs"> </Label>
<Select
value={key.rightColumn || ""}
onValueChange={(value) => {
const newKeys = [...(config.rightPanel?.relation?.keys || [])];
newKeys[index] = { ...newKeys[index], rightColumn: value };
updateRightPanel({
relation: { ...config.rightPanel?.relation, keys: newKeys },
});
}}
>
<SelectTrigger className="h-8 text-xs">
<SelectValue placeholder="우측 컬럼" />
</SelectTrigger>
<SelectContent>
{rightTableColumns.map((column) => (
<SelectItem key={column.columnName} value={column.columnName}>
{column.columnName}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
</div> </div>
) : autoDetectedRelations.length > 0 ? (
<div className="space-y-2">
{autoDetectedRelations.map((rel, index) => (
<div key={index} className="flex items-center gap-2 rounded-md border border-blue-300 bg-white p-2">
<span className="rounded bg-blue-100 px-2 py-0.5 text-xs font-medium text-blue-700">
{leftTableName}.{rel.leftColumn}
</span>
<ArrowRight className="h-3 w-3 text-blue-400" />
<span className="rounded bg-green-100 px-2 py-0.5 text-xs font-medium text-green-700">
{rightTableName}.{rel.rightColumn}
</span>
<span className="ml-auto text-[10px] text-gray-500">
{rel.inputType === "entity" ? "엔티티" : "카테고리"}
</span>
</div> </div>
))} ))}
</> <p className="text-[10px] text-blue-600">
/
</p>
</div>
) : config.rightPanel?.tableName ? (
<div className="rounded-md border border-dashed border-gray-300 bg-white p-3 text-center">
<p className="text-xs text-gray-500"> </p>
<p className="mt-1 text-[10px] text-gray-400">
</p>
</div>
) : ( ) : (
/* 단일키 (하위 호환성) */ <div className="rounded-md border border-dashed border-gray-300 bg-white p-3 text-center">
<> <p className="text-xs text-gray-500"> </p>
<div className="space-y-2">
<Label className="text-xs"> </Label>
<Popover open={leftColumnOpen} onOpenChange={setLeftColumnOpen}>
<PopoverTrigger asChild>
<Button
variant="outline"
role="combobox"
aria-expanded={leftColumnOpen}
className="w-full justify-between"
disabled={!config.leftPanel?.tableName}
>
{config.rightPanel?.relation?.leftColumn || "좌측 컬럼 선택"}
<ChevronsUpDown className="ml-2 h-4 w-4 shrink-0 opacity-50" />
</Button>
</PopoverTrigger>
<PopoverContent className="w-full p-0">
<Command>
<CommandInput placeholder="컬럼 검색..." />
<CommandEmpty> .</CommandEmpty>
<CommandGroup className="max-h-[200px] overflow-auto">
{leftTableColumns.map((column) => (
<CommandItem
key={column.columnName}
value={column.columnName}
onSelect={(value) => {
updateRightPanel({
relation: { ...config.rightPanel?.relation, leftColumn: value },
});
setLeftColumnOpen(false);
}}
>
<Check
className={cn(
"mr-2 h-4 w-4",
config.rightPanel?.relation?.leftColumn === column.columnName
? "opacity-100"
: "opacity-0",
)}
/>
{column.columnName}
<span className="ml-2 text-xs text-gray-500">({column.columnLabel || ""})</span>
</CommandItem>
))}
</CommandGroup>
</Command>
</PopoverContent>
</Popover>
</div> </div>
<div className="flex items-center justify-center">
<ArrowRight className="h-4 w-4 text-gray-400" />
</div>
<div className="space-y-2">
<Label className="text-xs"> ()</Label>
<Popover open={rightColumnOpen} onOpenChange={setRightColumnOpen}>
<PopoverTrigger asChild>
<Button
variant="outline"
role="combobox"
aria-expanded={rightColumnOpen}
className="w-full justify-between"
disabled={!config.rightPanel?.tableName}
>
{config.rightPanel?.relation?.foreignKey || "우측 컬럼 선택"}
<ChevronsUpDown className="ml-2 h-4 w-4 shrink-0 opacity-50" />
</Button>
</PopoverTrigger>
<PopoverContent className="w-full p-0">
<Command>
<CommandInput placeholder="컬럼 검색..." />
<CommandEmpty> .</CommandEmpty>
<CommandGroup className="max-h-[200px] overflow-auto">
{rightTableColumns.map((column) => (
<CommandItem
key={column.columnName}
value={column.columnName}
onSelect={(value) => {
updateRightPanel({
relation: { ...config.rightPanel?.relation, foreignKey: value },
});
setRightColumnOpen(false);
}}
>
<Check
className={cn(
"mr-2 h-4 w-4",
config.rightPanel?.relation?.foreignKey === column.columnName
? "opacity-100"
: "opacity-0",
)}
/>
{column.columnName}
<span className="ml-2 text-xs text-gray-500">({column.columnLabel || ""})</span>
</CommandItem>
))}
</CommandGroup>
</Command>
</PopoverContent>
</Popover>
</div>
</>
)} )}
</div> </div>
)} )}

View File

@ -333,6 +333,14 @@ export function UniversalFormModalComponent({
} }
} }
// 🆕 테이블 섹션 데이터 병합 (품목 리스트 등)
for (const [key, value] of Object.entries(formData)) {
if (key.startsWith("_tableSection_") && Array.isArray(value)) {
event.detail.formData[key] = value;
console.log(`[UniversalFormModal] 테이블 섹션 병합: ${key}, ${value.length}개 항목`);
}
}
// 🆕 수정 모드: 원본 그룹 데이터 전달 (UPDATE/DELETE 추적용) // 🆕 수정 모드: 원본 그룹 데이터 전달 (UPDATE/DELETE 추적용)
if (originalGroupedData.length > 0) { if (originalGroupedData.length > 0) {
event.detail.formData._originalGroupedData = originalGroupedData; event.detail.formData._originalGroupedData = originalGroupedData;
@ -355,15 +363,9 @@ export function UniversalFormModalComponent({
// 테이블 타입 섹션 찾기 // 테이블 타입 섹션 찾기
const tableSection = config.sections.find((s) => s.type === "table"); const tableSection = config.sections.find((s) => s.type === "table");
if (!tableSection) { if (!tableSection) {
// console.log("[UniversalFormModal] 테이블 섹션 없음 - _groupedData 무시");
return; return;
} }
// console.log("[UniversalFormModal] 수정 모드 - 테이블 섹션 초기화:", {
// sectionId: tableSection.id,
// itemCount: _groupedData.length,
// });
// 원본 데이터 저장 (수정/삭제 추적용) // 원본 데이터 저장 (수정/삭제 추적용)
setOriginalGroupedData(JSON.parse(JSON.stringify(_groupedData))); setOriginalGroupedData(JSON.parse(JSON.stringify(_groupedData)));

View File

@ -12,7 +12,7 @@ import { CheckboxWidget } from "@/components/screen/widgets/types/CheckboxWidget
import { RadioWidget } from "@/components/screen/widgets/types/RadioWidget"; import { RadioWidget } from "@/components/screen/widgets/types/RadioWidget";
import { FileWidget } from "@/components/screen/widgets/types/FileWidget"; import { FileWidget } from "@/components/screen/widgets/types/FileWidget";
import { CodeWidget } from "@/components/screen/widgets/types/CodeWidget"; import { CodeWidget } from "@/components/screen/widgets/types/CodeWidget";
import { EntityWidget } from "@/components/screen/widgets/types/EntityWidget"; import { EntitySearchInputWrapper } from "@/lib/registry/components/entity-search-input/EntitySearchInputWrapper";
import { ButtonWidget } from "@/components/screen/widgets/types/ButtonWidget"; import { ButtonWidget } from "@/components/screen/widgets/types/ButtonWidget";
// 개별적으로 설정 패널들을 import // 개별적으로 설정 패널들을 import
@ -352,7 +352,7 @@ export function initializeWebTypeRegistry() {
name: "엔티티 선택", name: "엔티티 선택",
category: "input", category: "input",
description: "데이터베이스 엔티티 선택 필드", description: "데이터베이스 엔티티 선택 필드",
component: EntityWidget, component: EntitySearchInputWrapper,
configPanel: EntityConfigPanel, configPanel: EntityConfigPanel,
defaultConfig: { defaultConfig: {
entityType: "", entityType: "",

View File

@ -724,11 +724,16 @@ export class ButtonActionExecutor {
// originalData는 수정 버튼 클릭 시 editData로 전달되어 context.originalData로 설정됨 // originalData는 수정 버튼 클릭 시 editData로 전달되어 context.originalData로 설정됨
// 빈 객체 {}도 truthy이므로 Object.keys로 실제 데이터 유무 확인 // 빈 객체 {}도 truthy이므로 Object.keys로 실제 데이터 유무 확인
const hasRealOriginalData = originalData && Object.keys(originalData).length > 0; const hasRealOriginalData = originalData && Object.keys(originalData).length > 0;
const isUpdate = hasRealOriginalData && !!primaryKeyValue;
// 🆕 폴백 로직: originalData가 없어도 formData에 id가 있으면 UPDATE로 판단
// 조건부 컨테이너 등에서 originalData 전달이 누락되는 경우를 처리
const hasIdInFormData = formData.id !== undefined && formData.id !== null && formData.id !== "";
const isUpdate = (hasRealOriginalData || hasIdInFormData) && !!primaryKeyValue;
console.log("🔍 [handleSave] INSERT/UPDATE 판단:", { console.log("🔍 [handleSave] INSERT/UPDATE 판단:", {
hasOriginalData: !!originalData, hasOriginalData: !!originalData,
hasRealOriginalData, hasRealOriginalData,
hasIdInFormData,
originalDataKeys: originalData ? Object.keys(originalData) : [], originalDataKeys: originalData ? Object.keys(originalData) : [],
primaryKeyValue, primaryKeyValue,
isUpdate, isUpdate,
@ -741,18 +746,18 @@ export class ButtonActionExecutor {
// UPDATE 처리 - 부분 업데이트 사용 (원본 데이터가 있는 경우) // UPDATE 처리 - 부분 업데이트 사용 (원본 데이터가 있는 경우)
console.log("🔄 UPDATE 모드로 저장:", { console.log("🔄 UPDATE 모드로 저장:", {
primaryKeyValue, primaryKeyValue,
formData,
originalData,
hasOriginalData: !!originalData, hasOriginalData: !!originalData,
hasIdInFormData,
updateReason: hasRealOriginalData ? "originalData 존재" : "formData.id 존재 (폴백)",
}); });
if (originalData) { if (hasRealOriginalData) {
// 부분 업데이트: 변경된 필드만 업데이트 // 부분 업데이트: 변경된 필드만 업데이트
console.log("📝 부분 업데이트 실행 (변경된 필드만)"); console.log("📝 부분 업데이트 실행 (변경된 필드만)");
saveResult = await DynamicFormApi.updateFormDataPartial(primaryKeyValue, originalData, formData, tableName); saveResult = await DynamicFormApi.updateFormDataPartial(primaryKeyValue, originalData, formData, tableName);
} else { } else {
// 전체 업데이트 (기존 방식) // 전체 업데이트 (originalData 없이 id로 UPDATE 판단된 경우)
console.log("📝 전체 업데이트 실행 (모든 필드)"); console.log("📝 전체 업데이트 실행 (originalData 없음 - 폴백 모드)");
saveResult = await DynamicFormApi.updateFormData(primaryKeyValue, { saveResult = await DynamicFormApi.updateFormData(primaryKeyValue, {
tableName, tableName,
data: formData, data: formData,
@ -1883,37 +1888,45 @@ export class ButtonActionExecutor {
const originalItem = originalGroupedData.find((orig) => orig.id === item.id); const originalItem = originalGroupedData.find((orig) => orig.id === item.id);
if (!originalItem) { if (!originalItem) {
console.warn(`⚠️ [UPDATE] 원본 데이터 없음 - INSERT로 처리: id=${item.id}`); // 🆕 폴백 로직: 원본 데이터가 없어도 id가 있으면 UPDATE 시도
// 원본이 없으면 신규로 처리 // originalGroupedData 전달이 누락된 경우를 처리
const rowToSave = { ...commonFieldsData, ...item, ...userInfo }; console.warn(`⚠️ [UPDATE] 원본 데이터 없음 - id가 있으므로 UPDATE 시도 (폴백): id=${item.id}`);
Object.keys(rowToSave).forEach((key) => {
// ⚠️ 중요: commonFieldsData가 item보다 우선순위가 높아야 함
// item에 있는 기존 값(예: manager_id=123)이 commonFieldsData의 새 값(manager_id=234)을 덮어쓰지 않도록
// 순서: item(기존) → commonFieldsData(새로 입력) → userInfo(메타데이터)
const rowToUpdate = { ...item, ...commonFieldsData, ...userInfo };
Object.keys(rowToUpdate).forEach((key) => {
if (key.startsWith("_")) { if (key.startsWith("_")) {
delete rowToSave[key]; delete rowToUpdate[key];
} }
}); });
delete rowToSave.id; // id 제거하여 INSERT
// 🆕 메인 레코드 ID 연결 (별도 테이블에 저장하는 경우) console.log("📝 [UPDATE 폴백] 저장할 데이터:", {
if (targetTableName && mainRecordId && saveConfig.primaryKeyColumn) { id: item.id,
rowToSave[saveConfig.primaryKeyColumn] = mainRecordId;
}
const saveResult = await DynamicFormApi.saveFormData({
screenId: screenId!,
tableName: saveTableName, tableName: saveTableName,
data: rowToSave, commonFieldsData,
itemFields: Object.keys(item).filter(k => !k.startsWith("_")),
rowToUpdate,
}); });
if (!saveResult.success) { // id를 유지하고 UPDATE 실행
throw new Error(saveResult.message || "품목 저장 실패"); const updateResult = await DynamicFormApi.updateFormData(item.id, {
tableName: saveTableName,
data: rowToUpdate,
});
if (!updateResult.success) {
throw new Error(updateResult.message || "품목 수정 실패");
} }
insertedCount++; updatedCount++;
continue; continue;
} }
// 변경 사항 확인 (공통 필드 포함) // 변경 사항 확인 (공통 필드 포함)
const currentDataWithCommon = { ...commonFieldsData, ...item }; // ⚠️ 중요: commonFieldsData가 item보다 우선순위가 높아야 함 (새로 입력한 값이 기존 값을 덮어씀)
const currentDataWithCommon = { ...item, ...commonFieldsData };
const hasChanges = this.checkForChanges(originalItem, currentDataWithCommon); const hasChanges = this.checkForChanges(originalItem, currentDataWithCommon);
if (hasChanges) { if (hasChanges) {
@ -1938,13 +1951,14 @@ export class ButtonActionExecutor {
} }
// 3⃣ 삭제된 품목 DELETE (원본에는 있지만 현재에는 없는 항목) // 3⃣ 삭제된 품목 DELETE (원본에는 있지만 현재에는 없는 항목)
const currentIds = new Set(currentItems.map((item) => item.id).filter(Boolean)); // ⚠️ id 타입 통일: 문자열로 변환하여 비교 (숫자 vs 문자열 불일치 방지)
const deletedItems = originalGroupedData.filter((orig) => orig.id && !currentIds.has(orig.id)); const currentIds = new Set(currentItems.map((item) => String(item.id)).filter(Boolean));
const deletedItems = originalGroupedData.filter((orig) => orig.id && !currentIds.has(String(orig.id)));
for (const deletedItem of deletedItems) { for (const deletedItem of deletedItems) {
console.log(`🗑️ [DELETE] 품목 삭제: id=${deletedItem.id}, tableName=${saveTableName}`); console.log(`🗑️ [DELETE] 품목 삭제: id=${deletedItem.id}, tableName=${saveTableName}`);
const deleteResult = await DynamicFormApi.deleteFormDataFromTable(saveTableName, deletedItem.id); const deleteResult = await DynamicFormApi.deleteFormDataFromTable(deletedItem.id, saveTableName);
if (!deleteResult.success) { if (!deleteResult.success) {
throw new Error(deleteResult.message || "품목 삭제 실패"); throw new Error(deleteResult.message || "품목 삭제 실패");
@ -4981,26 +4995,35 @@ export class ButtonActionExecutor {
const { oldValue, newValue } = confirmed; const { oldValue, newValue } = confirmed;
// 미리보기 표시 (옵션) // 미리보기 표시 (값 기반 검색 - 모든 테이블의 모든 컬럼에서 검색)
if (config.mergeShowPreview !== false) { if (config.mergeShowPreview !== false) {
const { apiClient } = await import("@/lib/api/client"); const { apiClient } = await import("@/lib/api/client");
const previewResponse = await apiClient.post("/code-merge/preview", { toast.loading("영향받는 데이터 검색 중...", { duration: Infinity });
columnName,
const previewResponse = await apiClient.post("/code-merge/preview-by-value", {
oldValue, oldValue,
}); });
toast.dismiss();
if (previewResponse.data.success) { if (previewResponse.data.success) {
const preview = previewResponse.data.data; const preview = previewResponse.data.data;
const totalRows = preview.totalAffectedRows; const totalRows = preview.totalAffectedRows;
// 상세 정보 생성
const detailList = preview.preview
.map((p: any) => ` - ${p.tableName}.${p.columnName}: ${p.affectedRows}`)
.join("\n");
const confirmMerge = confirm( const confirmMerge = confirm(
"⚠️ 코드 병합 확인\n\n" + "코드 병합 확인\n\n" +
`${oldValue}${newValue}\n\n` + `${oldValue}${newValue}\n\n` +
"영향받는 데이터:\n" + "영향받는 데이터:\n" +
`- 테이블 수: ${preview.preview.length}\n` + `- 테이블/컬럼 수: ${preview.preview.length}\n` +
`- 총 행 수: ${totalRows}\n\n` + `- 총 행 수: ${totalRows}\n\n` +
`데이터는 삭제되지 않고, "${columnName}" 컬럼 값만 변경됩니다.\n\n` + (preview.preview.length <= 10 ? `상세:\n${detailList}\n\n` : "") +
"모든 테이블에서 해당 값이 변경됩니다.\n\n" +
"계속하시겠습니까?", "계속하시겠습니까?",
); );
@ -5010,13 +5033,12 @@ export class ButtonActionExecutor {
} }
} }
// 병합 실행 // 병합 실행 (값 기반 - 모든 테이블의 모든 컬럼)
toast.loading("코드 병합 중...", { duration: Infinity }); toast.loading("코드 병합 중...", { duration: Infinity });
const { apiClient } = await import("@/lib/api/client"); const { apiClient } = await import("@/lib/api/client");
const response = await apiClient.post("/code-merge/merge-all-tables", { const response = await apiClient.post("/code-merge/merge-by-value", {
columnName,
oldValue, oldValue,
newValue, newValue,
}); });
@ -5025,10 +5047,18 @@ export class ButtonActionExecutor {
if (response.data.success) { if (response.data.success) {
const data = response.data.data; const data = response.data.data;
// 변경된 테이블/컬럼 목록 생성
const changedList = data.affectedData
.map((d: any) => `${d.tableName}.${d.columnName}: ${d.rowsUpdated}`)
.join(", ");
toast.success( toast.success(
"코드 병합 완료!\n" + `${data.affectedTables.length}개 테이블, ${data.totalRowsUpdated}개 행 업데이트`, `코드 병합 완료! ${data.affectedData.length}개 테이블/컬럼, ${data.totalRowsUpdated}개 행 업데이트`,
); );
console.log("코드 병합 결과:", data.affectedData);
// 화면 새로고침 // 화면 새로고침
context.onRefresh?.(); context.onRefresh?.();
context.onFlowRefresh?.(); context.onFlowRefresh?.();

View File

@ -365,6 +365,8 @@ export interface EntityTypeConfig {
separator?: string; // 여러 컬럼 표시 시 구분자 (기본: ' - ') separator?: string; // 여러 컬럼 표시 시 구분자 (기본: ' - ')
// UI 모드 // UI 모드
uiMode?: "select" | "modal" | "combo" | "autocomplete"; // 기본: "combo" uiMode?: "select" | "modal" | "combo" | "autocomplete"; // 기본: "combo"
// 다중 선택
multiple?: boolean; // 여러 항목 선택 가능 여부 (기본: false)
} }
/** /**